Capstone C5, connectivity with and without leakage: coherence, wPLI, CSD + wPLI and leakage-corrected source-space connectivity on the same ds-lemon eyes-closed epochs, with a divergence paragraph, a rubric and a FULL_COHORT switch

nb-c5-connectivity Level 5 · Connectivity and Spatial Analysis capstone ~5 min

Downloads from ds-lemon when you run it.

Download the notebook (.ipynb) Outputs below are the ones stored when it was executed — you do not need to run anything to read it.

nb-c5-connectivity · Capstone C5 — connectivity with and without leakage

Capstone C5 · Level 5 · Status draft — for expert review; uncertain points carry TODO(confirm).

The brief (spec §6, C5). On ds-lemon eyes-closed data, analyse alpha-band connectivity four ways — coherence at the sensors, wPLI at the sensors, CSD followed by wPLI, and a leakage-corrected analysis in source space — and explain every difference between the four results. The rubric asks that the reference and CSD choices be stated, that leakage be addressed, and that no claim exceed what the method supports.

One substitution, and it is the fourth analysis. The brief says "parcel-level orthogonalized connectivity on fsaverage". A parcellation is an anatomical object and ds-fsaverage does not clear the spec §10.7 licence gate; nor does ds-mne-sample, whose licence is a new open decision for the author. Neither is used anywhere on this site. The fourth analysis is therefore run on regions of a concentric sphere defined by geometry, exactly as in nb-5-6-source-connectivity: the leakage, the inverse operator and the orthogonalisation are all real, and the only thing missing is the ability to put an anatomical name on a region. Every claim below is written so that it does not need one.

Subset and the switch. SUBSET is four subjects; FULL_COHORT = True runs the documented full list. The subset is small because each subject costs about 60 MB of download, every byte of which is deleted before the next subject starts. FULL_COHORT = True has not been executed for these outputs.

Deliverables

  1. Four alpha-band connectivity matrices per subject, on identical epochs.
  2. A group summary: how much each analysis agrees with the others, and how many connections each one calls significant against the same surrogate null.
  3. A divergence paragraph explaining every difference, with the numbers that support it.
  4. A rubric checklist, answered.

Data. ds-lemon — LEMON, Babayan et al. (2019), DOI 10.1038/sdata.2018.308, CC BY 4.0. Eyes-closed resting blocks, fetched as the first 3.2 minutes of each raw recording by an HTTP Range request.

In [1]:
# Setup: dependencies, the shared helpers, non-interactive plotting.
import importlib.util
import subprocess
import sys
import warnings
from pathlib import Path

# 1. Dependencies are pinned in notebooks/requirements.txt.  Nothing is installed when the pinned
#    stack is already present (local runs, CI); a fresh Colab or Binder kernel installs it once.
#    On Colab, run from a clone of the repository so that notebooks/_shared/ is available
#    (repository URL: TODO(confirm), spec section 13 item 3).
_needed = ("mne", "scipy", "matplotlib", "pooch")
_missing = [p for p in _needed if importlib.util.find_spec(p) is None]
if _missing:
    _req = next((d / "requirements.txt" for d in (Path.cwd(), *Path.cwd().parents)
                 if (d / "requirements.txt").exists()), None)
    _cmd = [sys.executable, "-m", "pip", "install", "-q"]
    _cmd += ["-r", str(_req)] if _req else ["mne==1.10.2", "pooch>=1.8"]
    subprocess.check_call(_cmd)

# 2. Shared helpers, located relative to the working directory -- notebooks/<level>/ or notebooks/ --
#    never through an absolute path.
_shared = next((d / "_shared" for d in (Path.cwd(), *Path.cwd().parents)
                if (d / "_shared" / "helpers_l5.py").exists()), None)
if _shared is None:
    raise FileNotFoundError("start the kernel in notebooks/L5/ (or notebooks/) so that _shared/helpers_l5.py is found")
sys.path.insert(0, str(_shared))
import helpers
import helpers_l5 as L5

# 3. Plotting: Jupyter's default inline backend renders static PNGs through Agg (no windows, nothing
#    blocks); outside Jupyter the helpers select Agg.  Every MNE figure is requested with show=False
#    and each figure cell ends with plt.show().
import matplotlib.pyplot as plt
import numpy as np
import mne
import pooch

mne.set_log_level("WARNING")
pooch.get_logger().setLevel("WARNING")   # no download chatter: it would print local paths
plt.rcParams["figure.dpi"] = 72
print(f"MNE {mne.__version__}; helpers_l5 imported from notebooks/_shared")
print("mne-connectivity available:", L5.have_mne_connectivity())
print(L5.disk_line("disk at the start"))
MNE 1.10.2; helpers_l5 imported from notebooks/_shared
mne-connectivity available: True
disk at the start: 4.26 GB free on the working volume
In [2]:
print("Licences, from data/directory.yaml (never from memory):")
L5.print_licences("ds-lemon", notes=True)
print()
L5.print_source_model_block()
Licences, from data/directory.yaml (never from memory):
  ds-lemon — LEMON (MPI-Leipzig Mind-Brain-Body): licence CC-BY-4.0, access open (data/directory.yaml)
      CC BY 4.0 per the data descriptor; the NITRC/INDI page references an Open Data (PDDL-style) dedication;
      exact dataset terms TODO(confirm) (§13 item 5)

WHY THERE IS NO BOUNDARY-ELEMENT HEAD MODEL IN THIS NOTEBOOK

Spec section 6 writes Level 5's forward and inverse lessons around two datasets, and neither of them
clears the spec section 10.7 licence gate, so neither is used anywhere on this site.

  ds-fsaverage (the FreeSurfer average template).  SETTLED, and the answer is no.  The governing terms
  are the FreeSurfer Software License Agreement v1.0 (February 2011), which covers "downloads of
  software and/or data".  It does permit derivative works and redistribution -- but only by propagating
  the entire agreement onto every copy, which is share-alike in substance and is not one of section
  10.7's permissive names.  What makes this a finding rather than a lookup is that every convenient
  channel is silent: MNE's copy of the archive contains no LICENSE file, the OSF node that serves it
  records no licence, and MNE's dataset documentation names none.

  ds-mne-sample (MNE-Python's own sample dataset).  GENUINELY CONTESTED, and a new open decision for
  the author.  MNE's own documentation grants no licence and adds a restriction -- the data are
  "provided solely for the purpose of getting familiar with the MNE software"; the channel
  data_path() downloads from carries no licence record and no LICENSE file; and a public mirror
  (OpenNeuro ds000248) declares CC0 while reproducing MNE's acquisition paragraph and dropping the
  restriction sentence.  Unlike ERP CORE, there is no strictest-reading compromise: "familiarisation
  only" cannot be complied with by being stricter.  It either governs, in which case nothing ships, or
  it does not.

WHAT THIS NOTEBOOK DOES INSTEAD, AND WHAT YOU LOSE

  MNE's concentric-sphere head model is analytic and ships with the library: it needs no anatomy and no
  dataset, so the forward problem, the leadfield, the inverse operators and the resolution analysis are
  all still computed here on real electrode positions.  What a sphere cannot give you is a cortical
  surface, an anatomical parcellation, or a named brain region -- so no result below is labelled with
  an anatomical name, and none of them can be.  Two concentric spheres also cannot move the peak
  channel, while a real head can, so every comparison here UNDERSTATES what a realistic model would
  change.  Read the numbers as a floor, not as an estimate.

The two directory records, as the site holds them:
  ds-fsaverage — FreeSurfer average template (fsaverage): licence FreeSurfer-Software-License-1.0, license_status: contested, access registration (data/directory.yaml)
  ds-mne-sample — MNE sample dataset (MEG + EEG audiovisual task with a structural MRI): licence none, license_status: contested, access open (data/directory.yaml)

1 · Inputs, fixed before anything is computed

In [3]:
import warnings
from mne.minimum_norm import make_inverse_operator
from mne.minimum_norm.inverse import _assemble_kernel, prepare_inverse_operator

FULL_COHORT = False
SUBSET = ("sub-010002", "sub-010003", "sub-010004", "sub-010005")
FULL_LIST = ("sub-010002", "sub-010003", "sub-010004", "sub-010005", "sub-010006", "sub-010007",
             "sub-010008", "sub-010009", "sub-010010", "sub-010011", "sub-010012", "sub-010013",
             "sub-010014", "sub-010015", "sub-010016", "sub-010017", "sub-010018", "sub-010019",
             "sub-010020", "sub-010021")
SUBJECTS = FULL_LIST if FULL_COHORT else SUBSET

MAX_MINUTES = 3.2
BAND = L5.ALPHA_BAND
CSD_PARAMS = dict(lambda2=1e-5, stiffness=4, n_legendre_terms=50)
N_REGIONS, MAX_DEPTH_MM = 12, 35.0
NOISE_UV, SNR = 1.0, 3.0
LAMBDA2 = 1.0 / SNR ** 2
N_SURROGATE = 100

ANALYSES = ("A sensor coherence", "B sensor wPLI", "C CSD then wPLI", "D source regions, wPLI + orth.")

print(f"FULL_COHORT = {FULL_COHORT}; {len(SUBJECTS)} subjects: {list(SUBJECTS)}")
print(f"  the full list is {len(FULL_LIST)} subjects and has NOT been executed for these outputs")
print(f"download per subject: the first {MAX_MINUTES:g} min of the raw BrainVision file, about 60 MB, "
      f"deleted before the next subject")
print()
for k, v in L5.LEMON_PIPELINE.items():
    print(f"  {k:16s}: {v}")
print(f"  {'band_hz':16s}: {BAND}")
print(f"  {'csd':16s}: mne.preprocessing.compute_current_source_density(sphere='auto', "
      + ", ".join(f"{a}={b}" for a, b in CSD_PARAMS.items()) + ")")
print(f"  {'source model':16s}: four-shell concentric sphere, minimum-norm inverse, lambda^2 = {LAMBDA2:.4f}, "
      f"{N_REGIONS} GEOMETRIC regions")
print(f"  {'surrogates':16s}: {N_SURROGATE} epoch shuffles per analysis per subject, max statistic over "
      f"pairs, 95th percentile")
print()
print(L5.disk_line("disk before any download"))
FULL_COHORT = False; 4 subjects: ['sub-010002', 'sub-010003', 'sub-010004', 'sub-010005']
  the full list is 20 subjects and has NOT been executed for these outputs
download per subject: the first 3.2 min of the raw BrainVision file, about 60 MB, deleted before the next subject

  source          : raw release (BrainVision), first N minutes by HTTP Range request
  channels        : 61 EEG (FCz, the online reference, is not a data channel); VEOG dropped
  resample_hz     : 250.0
  filter_hz       : (1.0, 45.0)
  filter_note     : FIR, zero-phase, MNE defaults; 1 Hz high-pass because connectivity is estimated per 2-s epoch
  reference       : average over the 61 EEG channels
  reference_note  : the online reference FCz is absent, so the average is over 61 of the 62 nominal sites
  epoch_s         : 2.0
  epoch_note      : one epoch per marker tick, so epochs tile the block without overlap
  band_hz         : (8.0, 12.0)
  csd             : mne.preprocessing.compute_current_source_density(sphere='auto', lambda2=1e-05, stiffness=4, n_legendre_terms=50)
  source model    : four-shell concentric sphere, minimum-norm inverse, lambda^2 = 0.1111, 12 GEOMETRIC regions
  surrogates      : 100 epoch shuffles per analysis per subject, max statistic over pairs, 95th percentile

disk before any download: 4.26 GB free on the working volume

2 · The four analyses, stated as code

Three of the four are sensor-level and share the same channel set and the same epochs, so any difference between them is the analysis and nothing else. The fourth projects the same epochs through a sphere forward model, aggregates to geometric regions and orthogonalises them.

In [4]:
bem = L5.sphere_models()["four-shell"]
rr_all = L5.source_grid(spacing_m=0.012, max_fraction=0.85, min_radius_m=0.012)
depth_all = (L5.HEAD_RADIUS_M - np.linalg.norm(rr_all, axis=1)) * 1000
rr = rr_all[depth_all <= MAX_DEPTH_MM]
nn = rr / np.linalg.norm(rr, axis=1, keepdims=True)

rng = np.random.default_rng(L5.SEED)
centres = rr[rng.choice(len(rr), N_REGIONS, replace=False)]
for _ in range(200):
    lab = np.argmin(((rr[:, None, :] - centres[None]) ** 2).sum(-1), axis=1)
    new = np.stack([rr[lab == k].mean(axis=0) if (lab == k).any() else centres[k] for k in range(N_REGIONS)])
    if np.allclose(new, centres):
        break
    centres = new
labels = np.argmin(((rr[:, None, :] - centres[None]) ** 2).sum(-1), axis=1)
REGIONS = [f"R{k + 1:02d}" for k in range(N_REGIONS)]
print(f"{len(rr)} sources within {MAX_DEPTH_MM:.0f} mm of the scalp, {N_REGIONS} geometric regions "
      f"(k-means on position, seed {L5.SEED})")
print("  region labels are coordinates, not anatomy; no structure is named anywhere in this capstone")


def source_region_kernel(info_l):
    """Sensor-to-region weights: one row per geometric region, from a minimum-norm inverse."""
    fwd_l, G_l = L5.leadfield(info_l, bem, rr, fixed_normals=nn)
    with warnings.catch_warnings():
        warnings.simplefilter("ignore")
        cov = mne.make_ad_hoc_cov(info_l, std=NOISE_UV * 1e-6, verbose=False)
        inv = make_inverse_operator(info_l, fwd_l, cov, loose=0.0, depth=None, fixed=True, verbose=False)
        prep = prepare_inverse_operator(inv, nave=1, lambda2=LAMBDA2, method="MNE", verbose=False)
        K, _, _, _ = _assemble_kernel(prep, None, "MNE", pick_ori=None, verbose=False)
    return K


def region_rows(K, data_cov):
    W = np.zeros((N_REGIONS, K.shape[1]))
    for k in range(N_REGIONS):
        Kk = K[labels == k]
        w, V = np.linalg.eigh(Kk @ data_cov @ Kk.T)
        row = V[:, -1] @ Kk
        W[k] = row * np.sign(row[np.argmax(np.abs(row))])
    return W


def surrogate_threshold(tc, method, n=N_SURROGATE, rng=None):
    rng = rng or np.random.default_rng(L5.SEED)
    out = np.empty(n)
    for k in range(n):
        sh = np.stack([tc[rng.permutation(tc.shape[0]), c, :] for c in range(tc.shape[1])], axis=1)
        c_k, _ = L5.connectivity(sh, sfreq=SFREQ_ANALYSIS, methods=(method,), fmin=BAND[0], fmax=BAND[1],
                                 backend="numpy")
        out[k] = np.abs(L5.upper_pairs(np.abs(c_k[method]))).max()
    return float(np.percentile(out, 95))


SFREQ_ANALYSIS = L5.LEMON_PIPELINE["resample_hz"]
print(f"analyses: {ANALYSES}")
668 sources within 35 mm of the scalp, 12 geometric regions (k-means on position, seed 20260918)
  region labels are coordinates, not anatomy; no structure is named anywhere in this capstone
analyses: ('A sensor coherence', 'B sensor wPLI', 'C CSD then wPLI', 'D source regions, wPLI + orth.')
In [5]:
results, per_subject = {}, []
K_cache = {}
for subject in SUBJECTS:
    paths = []
    try:
        epochs, meta = L5.lemon_condition_epochs(subject, "eyes-closed", max_minutes=MAX_MINUTES,
                                                 verbose=False)
        paths = L5.lemon_files(subject)
        if epochs is None:
            print(f"  {subject}: SKIPPED -- {meta.get('error')}")
            continue
        sensor = epochs.get_data(copy=True)
        csd = mne.preprocessing.compute_current_source_density(epochs.copy(), **CSD_PARAMS,
                                                              verbose=False).get_data(copy=True)
        ch = tuple(epochs.ch_names)
        info_l = epochs.info.copy()
    finally:
        L5.delete_files(paths, verbose=False)

    if ch not in K_cache:
        K_cache[ch] = source_region_kernel(info_l)
    W = region_rows(K_cache[ch], np.mean([np.cov(x) for x in sensor], axis=0))
    tc = np.einsum("rc,ect->ert", W, sensor)
    flat = tc.transpose(1, 0, 2).reshape(N_REGIONS, -1)
    tc_orth = L5.symmetric_orthogonalise(flat).reshape(N_REGIONS, tc.shape[0],
                                                       tc.shape[2]).transpose(1, 0, 2)

    con_sensor, backend = L5.connectivity(sensor, sfreq=SFREQ_ANALYSIS, methods=("coh", "wpli"),
                                          fmin=BAND[0], fmax=BAND[1])
    con_csd, _ = L5.connectivity(csd, sfreq=SFREQ_ANALYSIS, methods=("wpli",), fmin=BAND[0], fmax=BAND[1])
    con_src, _ = L5.connectivity(tc_orth, sfreq=SFREQ_ANALYSIS, methods=("wpli",), fmin=BAND[0], fmax=BAND[1])
    M = {ANALYSES[0]: np.abs(con_sensor["coh"]), ANALYSES[1]: np.abs(con_sensor["wpli"]),
         ANALYSES[2]: np.abs(con_csd["wpli"]), ANALYSES[3]: np.abs(con_src["wpli"])}
    thr = {ANALYSES[0]: surrogate_threshold(sensor, "coh"), ANALYSES[1]: surrogate_threshold(sensor, "wpli"),
           ANALYSES[2]: surrogate_threshold(csd, "wpli"), ANALYSES[3]: surrogate_threshold(tc_orth, "wpli")}
    results[subject] = {"matrices": M, "thresholds": thr, "n_epochs": len(sensor), "channels": list(ch)}
    row = {"subject": subject, "n_epochs": len(sensor), "alpha_ratio": meta["alpha_check"]["ratio"]}
    for a in ANALYSES:
        v = L5.upper_pairs(M[a])
        row[a] = (float(np.median(v)), int((v > thr[a]).sum()), int(v.size), float(thr[a]))
    per_subject.append(row)
    print(f"  {subject}: {len(sensor)} epochs, {len(ch)} channels, eyes-closed/open alpha ratio "
          f"{meta['alpha_check']['ratio']:.2f}")
    for a in ANALYSES:
        med, k, n, t = row[a]
        print(f"      {a:32s} median {med:.4f}, {k:4d} of {n:4d} pairs above the null ({t:.4f})")
print()
print(f"backend for every connectivity estimate: {backend['backend']}")
print(L5.disk_line("disk after every download was deleted"))
  sub-010002: 60 epochs, 61 channels, eyes-closed/open alpha ratio 3.54
      A sensor coherence               median 0.4178, 1450 of 1830 pairs above the null (0.2301)
      B sensor wPLI                    median 0.1851,   29 of 1830 pairs above the null (0.3770)
      C CSD then wPLI                  median 0.1798,   33 of 1830 pairs above the null (0.3746)
      D source regions, wPLI + orth.   median 0.2004,    8 of   66 pairs above the null (0.3205)
  sub-010003: 57 epochs, 61 channels, eyes-closed/open alpha ratio 8.31
      A sensor coherence               median 0.5114, 1713 of 1830 pairs above the null (0.2302)
      B sensor wPLI                    median 0.2906,  310 of 1830 pairs above the null (0.3681)
      C CSD then wPLI                  median 0.2441,  186 of 1830 pairs above the null (0.3797)
      D source regions, wPLI + orth.   median 0.2594,   15 of   66 pairs above the null (0.3259)
  sub-010004: 57 epochs, 61 channels, eyes-closed/open alpha ratio 6.64
      A sensor coherence               median 0.4912, 1569 of 1830 pairs above the null (0.2369)
      B sensor wPLI                    median 0.2548,  101 of 1830 pairs above the null (0.3857)
      C CSD then wPLI                  median 0.2296,   90 of 1830 pairs above the null (0.4101)
      D source regions, wPLI + orth.   median 0.2128,   11 of   66 pairs above the null (0.3347)
  sub-010005: 59 epochs, 61 channels, eyes-closed/open alpha ratio 4.13
      A sensor coherence               median 0.6143, 1793 of 1830 pairs above the null (0.2330)
      B sensor wPLI                    median 0.4401, 1144 of 1830 pairs above the null (0.3724)
      C CSD then wPLI                  median 0.3123,  611 of 1830 pairs above the null (0.3881)
      D source regions, wPLI + orth.   median 0.2580,   13 of   66 pairs above the null (0.3237)

backend for every connectivity estimate: mne-connectivity 0.9.0 (spectral_connectivity_epochs, mode='fourier')
disk after every download was deleted: 4.26 GB free on the working volume

3 · The group summary

In [6]:
print(f"{len(results)} subjects, alpha band {BAND[0]:g}-{BAND[1]:g} Hz")
print()
print(f"{'analysis':>34s} {'unit':>14s} {'pairs':>7s} {'median (mean over subjects)':>28s} "
      f"{'significant pairs, mean':>25s} {'%':>7s}")
UNITS = {ANALYSES[0]: "channel pair", ANALYSES[1]: "channel pair", ANALYSES[2]: "channel pair",
         ANALYSES[3]: "region pair"}
group = {}
for a in ANALYSES:
    meds = np.array([r[a][0] for r in per_subject])
    ks = np.array([r[a][1] for r in per_subject], float)
    ns = np.array([r[a][2] for r in per_subject], float)
    group[a] = (meds, ks, ns)
    print(f"{a:>34s} {UNITS[a]:>14s} {int(ns[0]):7d} {meds.mean():28.4f} {ks.mean():25.1f} "
          f"{100 * (ks / ns).mean():7.1f}")
print()
print("The three sensor analyses share a channel set, so their pair counts are identical and comparable.")
print(f"The source analysis has {N_REGIONS} regions and therefore {int(group[ANALYSES[3]][2][0])} pairs; its")
print("percentage is comparable, its count is not.")
4 subjects, alpha band 8-12 Hz

                          analysis           unit   pairs  median (mean over subjects)   significant pairs, mean       %
                A sensor coherence   channel pair    1830                       0.5087                    1631.2    89.1
                     B sensor wPLI   channel pair    1830                       0.2927                     396.0    21.6
                   C CSD then wPLI   channel pair    1830                       0.2414                     230.0    12.6
    D source regions, wPLI + orth.    region pair      66                       0.2327                      11.8    17.8

The three sensor analyses share a channel set, so their pair counts are identical and comparable.
The source analysis has 12 regions and therefore 66 pairs; its
percentage is comparable, its count is not.
In [7]:
# How much do the three sensor analyses agree, pair by pair, within a subject?
print("Agreement between the three sensor-level analyses, as a correlation across channel pairs "
      "(per subject):")
print(f"{'subject':>12s} " + " ".join(f"{a.split()[0] + '-' + b.split()[0]:>10s}"
                                      for a in ANALYSES[:3] for b in ANALYSES[:3] if a < b))
agree = {}
for subject, res in results.items():
    line, vals = f"{subject:>12s} ", []
    for a in ANALYSES[:3]:
        for b in ANALYSES[:3]:
            if a < b:
                r = float(np.corrcoef(L5.upper_pairs(res["matrices"][a]),
                                      L5.upper_pairs(res["matrices"][b]))[0, 1])
                vals.append(((a, b), r))
                line += f"{r:10.3f} "
    agree[subject] = vals
    print(line)
print()
pairs = [k for k, _ in agree[list(agree)[0]]]
for k in pairs:
    rs = [dict(v)[k] for v in agree.values()]
    print(f"  {k[0]} vs {k[1]}: mean r = {np.mean(rs):+.3f} over {len(rs)} subjects")
print()
print("Two analyses of the same epochs correlating at r below 0.5 across pairs are not two views of one")
print("result.  They are different results, and the capstone's job is to say why.")
Agreement between the three sensor-level analyses, as a correlation across channel pairs (per subject):
     subject        A-B        A-C        B-C
  sub-010002      0.226      0.085      0.404 
  sub-010003      0.139      0.112      0.437 
  sub-010004      0.013      0.099      0.495 
  sub-010005      0.029      0.040      0.535 

  A sensor coherence vs B sensor wPLI: mean r = +0.102 over 4 subjects
  A sensor coherence vs C CSD then wPLI: mean r = +0.084 over 4 subjects
  B sensor wPLI vs C CSD then wPLI: mean r = +0.468 over 4 subjects

Two analyses of the same epochs correlating at r below 0.5 across pairs are not two views of one
result.  They are different results, and the capstone's job is to say why.
In [8]:
subject0 = list(results)[0]
fig, axes = plt.subplots(1, 4, figsize=(19, 4.4))
for ax, a in zip(axes, ANALYSES):
    M = results[subject0]["matrices"][a]
    lab = REGIONS if a == ANALYSES[3] else None
    L5.plot_matrix(M, lab, title=f"{a}\n(median {np.median(L5.upper_pairs(M)):.3f})", ax=ax, vmin=0,
                   vmax=1.0 if "coherence" in a else 0.5, cbar_label="dimensionless (0 to 1)")
fig.suptitle(f"C5, ds-lemon {subject0}, eyes closed, {BAND[0]:g}-{BAND[1]:g} Hz: the same epochs, four "
             f"analyses", y=1.02, fontsize=11)
fig.tight_layout()
plt.show()   # render the static figure(s) of this cell inline

fig, axes = plt.subplots(1, 2, figsize=(12.5, 4.2))
x = np.arange(len(ANALYSES))
axes[0].bar(x, [group[a][0].mean() for a in ANALYSES],
            yerr=[group[a][0].std() for a in ANALYSES], capsize=4, color="C0")
axes[0].set_xticks(x); axes[0].set_xticklabels([a.split()[0] for a in ANALYSES])
axes[0].set_ylabel("median connectivity over pairs (dimensionless)")
axes[0].set_title(f"mean and SD over {len(results)} subjects", fontsize=9)
axes[1].bar(x, [100 * (group[a][1] / group[a][2]).mean() for a in ANALYSES],
            yerr=[100 * (group[a][1] / group[a][2]).std() for a in ANALYSES], capsize=4, color="C1")
axes[1].set_xticks(x); axes[1].set_xticklabels([a.split()[0] for a in ANALYSES])
axes[1].set_ylabel("pairs above the surrogate null (% of all pairs)")
axes[1].set_title("how many connections each analysis calls significant", fontsize=9)
for ax in axes:
    ax.grid(alpha=0.25, axis="y")
fig.suptitle("C5 group summary: four analyses of identical epochs", y=1.02, fontsize=10)
fig.tight_layout()
plt.show()   # render the static figure(s) of this cell inline
Figure 1 of notebook nb-c5-connectivity, an output plot. The text around it states what it shows and the units of every axis.
Figure 2 of notebook nb-c5-connectivity, an output plot. The text around it states what it shows and the units of every axis.

4 · The divergence paragraph

This is the deliverable the rubric actually grades: every difference between the four results, explained, with the number that supports it. The cell below assembles it from the numbers computed above rather than asserting it.

In [9]:
A, B, Cc, D = ANALYSES
g = {a: (group[a][0].mean(), 100 * (group[a][1] / group[a][2]).mean()) for a in ANALYSES}
mean_r = {k: float(np.mean([dict(v)[k] for v in agree.values()])) for k in pairs}

print("DIVERGENCE PARAGRAPH (draft; TODO(confirm) at author review)")
print()
print(f"Four analyses of the same {int(np.mean([r['n_epochs'] for r in per_subject])):.0f} eyes-closed epochs "
      f"per subject, over {len(results)} subjects, in the {BAND[0]:g}-{BAND[1]:g} Hz band, give four different")
print("answers, and every difference has a cause that can be named.")
print()
print(f"(1) A against B -- coherence {g[A][0]:.3f} against wPLI {g[B][0]:.3f}, correlating at "
      f"r = {mean_r[(A, B)]:+.3f} across pairs,")
print(f"    and {g[A][1]:.1f} % of pairs significant against {g[B][1]:.1f} %.  Coherence keeps everything two "
      f"channels share,")
print("    lagged or not; wPLI keeps only the lagged part.  Volume conduction is instantaneous, so the gap")
print("    between them is the share of the shared variance that has no lag in it -- which nb-5-1 shows is")
print("    everything, when there is one generator.  The honest reading of A is 'how much do these two")
print("    electrodes have in common', not 'how connected are these two places'.")
print()
print(f"(2) B against C -- wPLI {g[B][0]:.3f} against wPLI-after-CSD {g[Cc][0]:.3f}, correlating at "
      f"r = {mean_r[(B, Cc)]:+.3f}.")
print("    The surface Laplacian makes each channel a contrast with its neighbourhood, so the part of a")
print("    channel that is shared with the whole head is removed BEFORE the connectivity estimate.  It also")
print("    removes any genuinely broad or deep generator (nb-5-3 measures both), and it is reference-free by")
print("    construction, so C is the only one of the four whose value does not depend on the reference at all.")
print()
print(f"(3) C against D -- sensor wPLI after CSD {g[Cc][0]:.3f} against leakage-corrected source wPLI "
      f"{g[D][0]:.3f}.")
print("    These are not the same units of analysis: C is a pair of electrodes and D is a pair of regions, so")
print("    the counts are not comparable and only the proportions are.  D is the only one of the four in")
print("    which the mixing has been modelled rather than avoided -- an inverse operator plus symmetric")
print("    orthogonalisation -- and it is also the one that depends on the most assumptions: a head model, a")
print("    regularization parameter, a region definition and an orthogonalisation convention.")
print()
print(f"(4) The reference.  A and B are computed on the average reference of "
      f"{len(results[subject0]['channels'])} channels; changing it")
print("    changes every value in both (nb-5-2 measures the size of that), C is immune to it, and D inherits")
print("    it through the inverse operator's leadfield.  Any of these numbers quoted without the reference is")
print("    incomplete.")
print()
print(f"(5) What NONE of the four can do.  The source analysis uses geometric regions on a concentric sphere,")
print("    because the template anatomy the brief names does not clear the licence gate.  No result here can")
print("    be attached to a brain structure, and a claim that named one would not be supported by anything")
print("    computed in this notebook.")
DIVERGENCE PARAGRAPH (draft; TODO(confirm) at author review)

Four analyses of the same 58 eyes-closed epochs per subject, over 4 subjects, in the 8-12 Hz band, give four different
answers, and every difference has a cause that can be named.

(1) A against B -- coherence 0.509 against wPLI 0.293, correlating at r = +0.102 across pairs,
    and 89.1 % of pairs significant against 21.6 %.  Coherence keeps everything two channels share,
    lagged or not; wPLI keeps only the lagged part.  Volume conduction is instantaneous, so the gap
    between them is the share of the shared variance that has no lag in it -- which nb-5-1 shows is
    everything, when there is one generator.  The honest reading of A is 'how much do these two
    electrodes have in common', not 'how connected are these two places'.

(2) B against C -- wPLI 0.293 against wPLI-after-CSD 0.241, correlating at r = +0.468.
    The surface Laplacian makes each channel a contrast with its neighbourhood, so the part of a
    channel that is shared with the whole head is removed BEFORE the connectivity estimate.  It also
    removes any genuinely broad or deep generator (nb-5-3 measures both), and it is reference-free by
    construction, so C is the only one of the four whose value does not depend on the reference at all.

(3) C against D -- sensor wPLI after CSD 0.241 against leakage-corrected source wPLI 0.233.
    These are not the same units of analysis: C is a pair of electrodes and D is a pair of regions, so
    the counts are not comparable and only the proportions are.  D is the only one of the four in
    which the mixing has been modelled rather than avoided -- an inverse operator plus symmetric
    orthogonalisation -- and it is also the one that depends on the most assumptions: a head model, a
    regularization parameter, a region definition and an orthogonalisation convention.

(4) The reference.  A and B are computed on the average reference of 61 channels; changing it
    changes every value in both (nb-5-2 measures the size of that), C is immune to it, and D inherits
    it through the inverse operator's leadfield.  Any of these numbers quoted without the reference is
    incomplete.

(5) What NONE of the four can do.  The source analysis uses geometric regions on a concentric sphere,
    because the template anatomy the brief names does not clear the licence gate.  No result here can
    be attached to a brain structure, and a claim that named one would not be supported by anything
    computed in this notebook.

5 · The rubric, answered

In [10]:
RUBRIC = [
    ("Reference stated", f"yes -- {L5.LEMON_PIPELINE['reference']}; {L5.LEMON_PIPELINE['reference_note']}. "
                         f"Analyses A, B and D depend on it; C does not."),
    ("CSD choices stated", "yes -- mne.preprocessing.compute_current_source_density(sphere='auto', "
                           + ", ".join(f"{k}={v}" for k, v in CSD_PARAMS.items())
                           + "); units uV/m^2 by the display convention of nb-5-3."),
    ("Leakage addressed", f"yes -- analysis D uses a minimum-norm inverse on a four-shell sphere and symmetric "
                          f"(Colclough-style) orthogonalisation of the {N_REGIONS} region time courses; "
                          f"nb-5-6 measures the leakage of the same operator directly from its resolution "
                          f"matrix."),
    ("A null that pays for multiple comparisons",
     f"yes -- {N_SURROGATE} epoch-shuffled surrogates per analysis per subject, max statistic over pairs, "
     f"95th percentile."),
    ("No claim exceeds what the method supports",
     "the regions are geometric and no anatomical name appears anywhere; coherence is described as shared "
     "variance rather than as connection; and every count is reported beside the null it was tested against."),
    ("Subset documented and a FULL_COHORT switch",
     f"yes -- SUBSET = {list(SUBSET)} ({len(SUBSET)} subjects); FULL_COHORT = True runs {len(FULL_LIST)} and "
     f"has not been executed for these outputs."),
    ("Downloads deleted", "yes -- every subject's BrainVision files are deleted in a finally before the next "
                          "subject is fetched; peak disk is one subject."),
]
def wrap(text, width=100):
    lines, cur = [], ""
    for w in text.split():
        if len(cur) + len(w) + 1 > width:
            lines.append(cur)
            cur = w
        else:
            cur = f"{cur} {w}".strip()
    if cur:
        lines.append(cur)
    return lines


for item, answer in RUBRIC:
    print(f"[x] {item}")
    for line in wrap(answer):
        print(f"      {line}")
[x] Reference stated
      yes -- average over the 61 EEG channels; the online reference FCz is absent, so the average is over
      61 of the 62 nominal sites. Analyses A, B and D depend on it; C does not.
[x] CSD choices stated
      yes -- mne.preprocessing.compute_current_source_density(sphere='auto', lambda2=1e-05, stiffness=4,
      n_legendre_terms=50); units uV/m^2 by the display convention of nb-5-3.
[x] Leakage addressed
      yes -- analysis D uses a minimum-norm inverse on a four-shell sphere and symmetric (Colclough-style)
      orthogonalisation of the 12 region time courses; nb-5-6 measures the leakage of the same operator
      directly from its resolution matrix.
[x] A null that pays for multiple comparisons
      yes -- 100 epoch-shuffled surrogates per analysis per subject, max statistic over pairs, 95th
      percentile.
[x] No claim exceeds what the method supports
      the regions are geometric and no anatomical name appears anywhere; coherence is described as shared
      variance rather than as connection; and every count is reported beside the null it was tested
      against.
[x] Subset documented and a FULL_COHORT switch
      yes -- SUBSET = ['sub-010002', 'sub-010003', 'sub-010004', 'sub-010005'] (4 subjects); FULL_COHORT =
      True runs 20 and has not been executed for these outputs.
[x] Downloads deleted
      yes -- every subject's BrainVision files are deleted in a finally before the next subject is
      fetched; peak disk is one subject.

6 · The numbers

In [11]:
print("nb-c5-connectivity -- C5 numbers (draft; TODO(confirm) at author review)")
print(f"Data: ds-lemon, eyes closed, first {MAX_MINUTES:g} min per subject; "
      f"{L5.licence_line('ds-lemon')}")
print(f"Subjects: {list(SUBJECTS)} (FULL_COHORT = {FULL_COHORT}; the full list has "
      f"{len(FULL_LIST)} subjects and was not executed)")
print(f"Band {BAND[0]:g}-{BAND[1]:g} Hz; {L5.LEMON_PIPELINE['filter_hz']} Hz filter; "
      f"{L5.LEMON_PIPELINE['reference']}; {int(np.mean([r['n_epochs'] for r in per_subject]))} epochs per "
      f"subject on average")
print()
print("PER SUBJECT")
print(f"{'subject':>12s} {'epochs':>7s} {'EC/EO alpha':>12s} " +
      " ".join(f"{a.split()[0]:>26s}" for a in ANALYSES))
for r in per_subject:
    print(f"{r['subject']:>12s} {r['n_epochs']:7d} {r['alpha_ratio']:12.2f} " +
          " ".join(f"{r[a][0]:.4f} ({r[a][1]:3d}/{r[a][2]:<4d}){'':>4s}" for a in ANALYSES))
print()
print("GROUP (mean over subjects)")
print(f"{'analysis':>34s} {'median':>9s} {'significant pairs':>19s} {'% of pairs':>12s}")
for a in ANALYSES:
    meds, ks, ns = group[a]
    print(f"{a:>34s} {meds.mean():9.4f} {ks.mean():19.1f} {100 * (ks / ns).mean():12.1f}")
print()
print("AGREEMENT between the three sensor-level analyses (mean correlation across channel pairs):")
for k in pairs:
    print(f"      {k[0]} vs {k[1]}: r = {mean_r[k]:+.3f}")
print()
print("WHAT THE LICENCE FINDING COST THIS CAPSTONE, AND WHAT IT DID NOT:")
print("  cost: the fourth analysis is on geometric regions of a sphere, not on an anatomical parcellation, so")
print("        no connection found here can be named.  The brief's 'parcel-level ... on fsaverage' is not")
print("        possible while ds-fsaverage and ds-mne-sample fail the spec 10.7 gate.")
print("  kept: the inverse operator, the leakage, the orthogonalisation, the surrogate null and every")
print("        comparison between the four analyses.  If the author settles the ds-mne-sample licence")
print("        (spec section 13), only the region definition changes.")
print()
print(L5.disk_line("disk at the end"))
nb-c5-connectivity -- C5 numbers (draft; TODO(confirm) at author review)
Data: ds-lemon, eyes closed, first 3.2 min per subject; ds-lemon — LEMON (MPI-Leipzig Mind-Brain-Body): licence CC-BY-4.0, access open (data/directory.yaml)
Subjects: ['sub-010002', 'sub-010003', 'sub-010004', 'sub-010005'] (FULL_COHORT = False; the full list has 20 subjects and was not executed)
Band 8-12 Hz; (1.0, 45.0) Hz filter; average over the 61 EEG channels; 58 epochs per subject on average

PER SUBJECT
     subject  epochs  EC/EO alpha                          A                          B                          C                          D
  sub-010002      60         3.54 0.4178 (1450/1830)     0.1851 ( 29/1830)     0.1798 ( 33/1830)     0.2004 (  8/66  )    
  sub-010003      57         8.31 0.5114 (1713/1830)     0.2906 (310/1830)     0.2441 (186/1830)     0.2594 ( 15/66  )    
  sub-010004      57         6.64 0.4912 (1569/1830)     0.2548 (101/1830)     0.2296 ( 90/1830)     0.2128 ( 11/66  )    
  sub-010005      59         4.13 0.6143 (1793/1830)     0.4401 (1144/1830)     0.3123 (611/1830)     0.2580 ( 13/66  )    

GROUP (mean over subjects)
                          analysis    median   significant pairs   % of pairs
                A sensor coherence    0.5087              1631.2         89.1
                     B sensor wPLI    0.2927               396.0         21.6
                   C CSD then wPLI    0.2414               230.0         12.6
    D source regions, wPLI + orth.    0.2327                11.8         17.8

AGREEMENT between the three sensor-level analyses (mean correlation across channel pairs):
      A sensor coherence vs B sensor wPLI: r = +0.102
      A sensor coherence vs C CSD then wPLI: r = +0.084
      B sensor wPLI vs C CSD then wPLI: r = +0.468

WHAT THE LICENCE FINDING COST THIS CAPSTONE, AND WHAT IT DID NOT:
  cost: the fourth analysis is on geometric regions of a sphere, not on an anatomical parcellation, so
        no connection found here can be named.  The brief's 'parcel-level ... on fsaverage' is not
        possible while ds-fsaverage and ds-mne-sample fail the spec 10.7 gate.
  kept: the inverse operator, the leakage, the orthogonalisation, the surrogate null and every
        comparison between the four analyses.  If the author settles the ds-mne-sample licence
        (spec section 13), only the region definition changes.

disk at the end: 4.26 GB free on the working volume