Surface Laplacian and current source density: reference independence proved, the ERP CORE P3 sharpened, what depth and spatial extent cost under the Laplacian, and connectivity before and after CSD

nb-5-3-csd Level 5 · Connectivity and Spatial Analysis ~5 min Used in L5.3 · Surface Laplacian and CSD

Downloads from ds-erpcore, 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-5-3-csd · Surface Laplacian and current source density (L5.3)

Lesson L5.3 · Level 5 · Status draft — for expert review; uncertain points carry TODO(confirm).

The surface Laplacian is the one spatial transform at this level that needs no head model and no inverse problem. It is a second spatial derivative of the potential across the scalp, estimated with spherical splines, and it buys two things that matter after L5.1 and L5.2: it does not depend on the reference at all, and it sharpens the map by discounting whatever is spatially broad — which is what volume conduction from a distant generator looks like.

It also costs something, and the cost is the lesson's second half: what is spatially broad at the scalp is not only "far away", it is also "deep" and "large". CSD attenuates those too, and it cannot tell you which of them it just removed.

What you will do

  1. Print the transform's parameters and units, and check reference independence on a simulation and on a real ERP.
  2. Apply compute_current_source_density to the ERP CORE P3 grand average and measure the sharpening.
  3. Measure what CSD attenuates, on a sphere model where depth and spatial extent are controlled by hand.
  4. Apply CSD to ds-lemon eyes-closed data and recompute coherence and wPLI before and after.

Data.

  • ds-erpcore — ERP CORE, Kappenman, Farrens, Zhang, Stewart & Luck (2021), article DOI 10.31234/osf.io/4azqm, data DOI 10.18112/openneuro.ds003069.v1.0.0. Paradigm P3, an active visual oddball. Biosemi ActiveTwo, 30 EEG + 3 EOG, 1024 Hz, CMS reference. Licence CC BY-SA 4.0, contested at source — three statements disagree and spec §10.7's most-restrictive rule governs, so share-alike is assumed to bind anything derived from these data. Loaded through helpers_l3.load_p3_epochs, which is the one Level 3 pipeline, so the numbers here line up with nb-3-4-topomaps.
  • ds-lemon — LEMON, Babayan et al. (2019), DOI 10.1038/sdata.2018.308, CC BY 4.0. Eyes-closed resting blocks, loaded as in nb-5-2-connectivity.
  • Section 3 uses no dataset: it is MNE's analytic sphere model, where depth and extent are controls.

Every download is deleted in a finally as soon as the numbers are extracted; peak disk is one ERP CORE subject (about 58 MB) plus one LEMON prefix (about 60 MB).

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", "pandas")
_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_l3 as L3
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.35 GB free on the working volume
In [2]:
print("Licences, from data/directory.yaml (never from memory):")
L5.print_licences("ds-erpcore", "ds-lemon", notes=True)
print()
print("The three ERP CORE statements, verbatim, as helpers_l3 records them:")
for k, v in L3.ERPCORE_LICENCE_STATEMENTS.items():
    print(f"  {k}: {v}")
Licences, from data/directory.yaml (never from memory):
  ds-erpcore — ERP CORE (Compendium of Open Resources and Experiments): licence CC-BY-SA-4.0, license_status: contested, access open (data/directory.yaml)
      CONTESTED at source — three statements, all verified 2026-09-18 from the primary material: the LICENSE
      file shipped with the data says, in the authors' own words, 'CC BY-SA 4.0 … if you share the resulting
      materials, you must distribute your contributions under the same CC BY-SA 4.0 license'; the BIDS
      dataset_description.json says 'CC0'; the OSF node thsqg record says 'CC-By Attribution 4.0
      International'. Per §10.7 the most restrictive governs until the author resolves it, so CC BY-SA 4.0
      with share-alike is recorded here. §13 item 15 was answered on 2026-09-18: derived assets may ship
      provided each one carries CC BY-SA 4.0 itself, which complies under all three readings because BY-SA is
      the strictest of them. Redistribution itself is permitted under all three readings; only whether derived
      assets must carry share-alike is in question.
  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)

The three ERP CORE statements, verbatim, as helpers_l3 records them:
  LICENSE file shipped with the data: CC BY-SA 4.0, with explicit share-alike wording
  dataset_description.json (BIDS sidecar): CC0
  OSF node thsqg licence record: CC-By Attribution 4.0 International
  what the site records: CC-BY-SA-4.0 (data/directory.yaml, 2026-09-18).  The three statements are all real and all verified from the primary material; spec section 10.7 says the most restrictive reading governs a licence contested at source, so share-alike is assumed.  Section 13 item 15 was answered on 2026-09-18: derived assets may ship provided each carries CC BY-SA 4.0 itself.  Redistribution is permitted under all three readings; only whether derived assets must carry share-alike is in question.

1 · What the transform is, and the one property that makes it special

mne.preprocessing.compute_current_source_density fits a spherical spline through the channel values and returns its surface Laplacian. Three numbers control it:

  • stiffness (the spline order m, default 4) — higher is smoother, lower follows the data more closely;
  • lambda2 (regularization, default 1e-5) — how much the spline is allowed to miss the measured values;
  • n_legendre_terms (default 50) — how many terms of the Legendre series are summed.

The output is a current density, in ampere per square metre in MNE's internal units, conventionally displayed as µV/m² (the potential's Laplacian times the conductivity is a current density; the display convention keeps µV and divides by m²). TODO(confirm): the course should state one units convention for CSD and use it everywhere, because the literature uses at least two.

The property that makes it special: the Laplacian of a constant is zero. Changing the reference adds the same number to every channel, so the CSD is exactly the same whatever the reference was. That is not an approximation.

In [3]:
CSD_PARAMS = dict(lambda2=1e-5, stiffness=4, n_legendre_terms=50)
print("mne.preprocessing.compute_current_source_density(inst, sphere='auto', "
      + ", ".join(f"{k}={v}" for k, v in CSD_PARAMS.items()) + ")")
print()

# Reference independence, first on a simulation where the map is known exactly (no dataset).
SPHERE_MONTAGE = ["Fp1", "Fpz", "Fp2", "F7", "F3", "Fz", "F4", "F8", "T7", "C3", "Cz", "C4", "T8",
                  "P7", "P3", "Pz", "P4", "P8", "O1", "Oz", "O2"]
sim_info = L5.sphere_info(SPHERE_MONTAGE, sfreq=250.0)
sim_bem = L5.sphere_models()["four-shell"]
pos, moment = L5.dipole_from_params(0.35, 180.0, 22.0, strength_nAm=50.0)
sim_map = L5.dipole_potentials(sim_info, sim_bem, pos, moment)          # uV
sim_ev = mne.EvokedArray(np.repeat(sim_map[:, None] * 1e-6, 3, axis=1), sim_info, tmin=0.0, verbose=False)


def rereferenced(ev, mode):
    out = ev.copy()
    if mode == "average":
        out.set_eeg_reference("average", projection=False, verbose=False)
    elif mode == "none (as computed)":
        pass
    else:
        out.data = out.data - out.data[out.ch_names.index(mode)]
    return out


print("CSD of ONE simulated dipole map under four references (no dataset; sphere model):")
base, ref_independence = None, 0.0
for mode in ("none (as computed)", "average", "Cz", "Oz"):
    c = mne.preprocessing.compute_current_source_density(rereferenced(sim_ev, mode), **CSD_PARAMS,
                                                         verbose=False)
    if base is None:
        base = c.data
        print(f"  {mode:20s}: reference map, peak |CSD| = {np.abs(c.data).max():.4e} (MNE units)")
    else:
        rel = float(np.abs(c.data - base).max() / np.abs(base).max())
        ref_independence = max(ref_independence, rel)
        print(f"  {mode:20s}: max |difference| from the first = {rel:.2e} of the peak")
print("  Exactly zero up to floating point, for every reference.  That is the defining property.")
mne.preprocessing.compute_current_source_density(inst, sphere='auto', lambda2=1e-05, stiffness=4, n_legendre_terms=50)

CSD of ONE simulated dipole map under four references (no dataset; sphere model):
  none (as computed)  : reference map, peak |CSD| = 6.4831e-03 (MNE units)
  average             : max |difference| from the first = 2.77e-14 of the peak
  Cz                  : max |difference| from the first = 1.02e-14 of the peak
  Oz                  : max |difference| from the first = 2.95e-13 of the peak
  Exactly zero up to floating point, for every reference.  That is the defining property.

2 · Sharpening, measured on the ERP CORE P3

The P3 is a broad centro-parietal positivity, and "broad" is precisely what the Laplacian discounts. The comparison below is the grand-average difference wave (target − standard) over three subjects, in the Level 3 measurement window, before and after CSD.

Three numbers describe the change, and they are not the same question:

  • how many channels carry at least half the peak — a blunt but readable measure of extent;
  • the amplitude-weighted spread, the RMS distance of the map's energy from its own peak channel, in cm across the scalp;
  • the across-channel correlation between the two maps — whether the transform moved the pattern or only narrowed it.
In [4]:
ERPCORE_SUBJECTS = (1, 2, 3)          # documented subset; nb-3-4 uses ten, this notebook needs three maps
W = L3.P3_WINDOW

print(L5.disk_line("disk before the ERP CORE downloads"))

# helpers_l3.fetch_erpcore_subject checks the free space on the paradigm folder BEFORE anything creates it,
# and helpers_l3.delete_erpcore_subject (through helpers_l1.cleanup) removes that folder again as soon as it
# is empty.  A loop that fetches and deletes subject by subject therefore fails on a cold cache -- on the
# first subject if the folder never existed, and on the second if it did.  Re-creating it before each fetch
# is the whole workaround; it is reported in site/notes/notebooks-L5.md for the helpers_l3 owner.
# TODO(confirm).
evokeds, erp_info, kept = {}, None, []
for s in ERPCORE_SUBJECTS:
    (L3.erpcore_root() / "P3").mkdir(parents=True, exist_ok=True)
    try:
        ep, nfo = L3.load_p3_epochs(s, verbose=False)
        evokeds[s] = {c: L3.condition_epochs(ep, c).average().pick("eeg") for c in ("target", "standard")}
        erp_info = evokeds[s]["target"].info.copy()
        kept.append((s, nfo["n_kept"]["target"], nfo["n_kept"]["standard"]))
    finally:
        L3.delete_erpcore_subject(s, "P3", keep_small=False, verbose=False)
print(L5.disk_line("disk after the ERP CORE downloads were deleted"))
print()
print(f"ERP CORE P3, {len(evokeds)} subjects, pipeline helpers_l3.P3_PIPELINE "
      f"(the same one nb-3-1..nb-3-7 use):")
for s, nt, ns in kept:
    print(f"  sub-{s:03d}: {nt} target and {ns} standard epochs kept")

times = evokeds[ERPCORE_SUBJECTS[0]]["target"].times
erp_names = list(erp_info["ch_names"])
grand = {c: np.mean([evokeds[s][c].data for s in ERPCORE_SUBJECTS], axis=0) for c in ("target", "standard")}
grand["difference"] = grand["target"] - grand["standard"]
print(f"  {len(erp_names)} EEG channels, {len(times)} samples, window "
      f"{W[0] * 1000:.0f}-{W[1] * 1000:.0f} ms")
disk before the ERP CORE downloads: 4.35 GB free on the working volume
disk after the ERP CORE downloads were deleted: 4.37 GB free on the working volume

ERP CORE P3, 3 subjects, pipeline helpers_l3.P3_PIPELINE (the same one nb-3-1..nb-3-7 use):
  sub-001: 35 target and 138 standard epochs kept
  sub-002: 40 target and 158 standard epochs kept
  sub-003: 36 target and 144 standard epochs kept
  30 EEG channels, 256 samples, window 300-600 ms
In [5]:
def window_map(X, a, b):
    m = (times >= a) & (times <= b)
    return X[:, m].mean(axis=1)


def spread_cm(values, positions_cm):
    """Amplitude-weighted RMS distance of a map's energy from its own peak channel, in cm."""
    w = np.abs(values) ** 2
    peak = int(np.argmax(np.abs(values)))
    d = np.linalg.norm(positions_cm - positions_cm[peak], axis=1)
    return float(np.sqrt((w * d ** 2).sum() / w.sum())), peak


_, ERP_GC = L5.great_circle_cm(erp_info, L5.HEAD_RADIUS_M)
erp_pos_cm = np.array([erp_info["chs"][i]["loc"][:3] for i in range(len(erp_names))]) * 100.0

erp_ev = mne.EvokedArray(grand["difference"], erp_info, tmin=float(times[0]), verbose=False)
erp_csd = mne.preprocessing.compute_current_source_density(erp_ev, **CSD_PARAMS, verbose=False)

pot = window_map(erp_ev.data, *W) * 1e6                      # uV
csd = window_map(erp_csd.data, *W)                           # MNE units (V/m^2)
csd_disp = csd * 1e6                                         # uV/m^2 by the display convention above

rows = []
for label, v in (("potential (uV)", pot), ("CSD (uV/m^2)", csd_disp)):
    half = int((np.abs(v) >= 0.5 * np.abs(v).max()).sum())
    sp, peak = spread_cm(v, erp_pos_cm)
    rows.append((label, erp_names[peak], float(v[peak]), half, sp))
print(f"Grand-average P3 difference wave, {W[0] * 1000:.0f}-{W[1] * 1000:.0f} ms, "
      f"{len(ERPCORE_SUBJECTS)} subjects, {len(erp_names)} channels")
print(f"{'map':>16s} {'peak channel':>13s} {'peak value':>12s} {'channels >= 50 % of peak':>25s} "
      f"{'weighted spread (cm)':>21s}")
for label, ch, val, half, sp in rows:
    print(f"{label:>16s} {ch:>13s} {val:12.3f} {half:25d} {sp:21.2f}")
r_maps = float(np.corrcoef(pot, csd_disp)[0, 1])
print()
print(f"across-channel correlation between the two maps: r = {r_maps:+.3f}")
print(f"the Laplacian narrows the map from {rows[0][4]:.2f} cm to {rows[1][4]:.2f} cm of weighted spread, "
      f"a factor of {rows[0][4] / rows[1][4]:.2f}")
Grand-average P3 difference wave, 300-600 ms, 3 subjects, 30 channels
             map  peak channel   peak value  channels >= 50 % of peak  weighted spread (cm)
  potential (uV)           CPz        7.084                         9                 11.25
    CSD (uV/m^2)            Pz     6093.108                         3                  5.79

across-channel correlation between the two maps: r = +0.907
the Laplacian narrows the map from 11.25 cm to 5.79 cm of weighted spread, a factor of 1.94
In [6]:
fig, axes = plt.subplots(2, 5, figsize=(15, 6.2))
TIMES = [0.2, 0.3, 0.4, 0.5, 0.6]
v_pot = np.abs(np.array([window_map(erp_ev.data, t - 0.025, t + 0.025) for t in TIMES]) * 1e6).max()
v_csd = np.abs(np.array([window_map(erp_csd.data, t - 0.025, t + 0.025) for t in TIMES]) * 1e6).max()
for col, t_ in enumerate(TIMES):
    m_pot = window_map(erp_ev.data, t_ - 0.025, t_ + 0.025) * 1e6
    m_csd = window_map(erp_csd.data, t_ - 0.025, t_ + 0.025) * 1e6
    im0, _ = mne.viz.plot_topomap(m_pot, erp_info, axes=axes[0, col], show=False, contours=4,
                                  vlim=(-v_pot, v_pot), sensors=True)
    im1, _ = mne.viz.plot_topomap(m_csd, erp_info, axes=axes[1, col], show=False, contours=4,
                                  vlim=(-v_csd, v_csd), sensors=True)
    axes[0, col].set_title(f"{t_ * 1000:.0f} ms", fontsize=9)
axes[0, 0].set_ylabel("potential (uV)", fontsize=9)
axes[1, 0].set_ylabel("CSD (uV/m^2)", fontsize=9)
cb0 = fig.colorbar(im0, ax=axes[0, :], shrink=0.8); cb0.set_label("uV")
cb1 = fig.colorbar(im1, ax=axes[1, :], shrink=0.8); cb1.set_label("uV/m^2")
fig.suptitle(f"ERP CORE P3 difference wave (target - standard), {len(ERPCORE_SUBJECTS)} subjects: "
             f"potential in uV above, current source density in uV/m^2 below", y=1.0, fontsize=11)
plt.show()   # render the static figure(s) of this cell inline
Figure 1 of notebook nb-5-3-csd, an output plot. The text around it states what it shows and the units of every axis.

3 · What CSD attenuates — measured where depth and extent are controls

On real data you cannot vary a source's depth and hold everything else fixed. On the sphere you can. The sweep below takes a single radial dipole, moves it from just under the scalp down towards the centre, and records what fraction of its scalp peak survives the Laplacian; then it does the same for a patch of superficial dipoles of growing angular radius, which is the other way a scalp map gets broad.

Both curves are normalised to the shallowest / smallest case, so what they show is relative attenuation: how much a source of that depth or extent is discounted compared with a focal superficial one.

In [7]:
def csd_of_map(values_uv, info):
    ev = mne.EvokedArray(np.repeat(np.asarray(values_uv, float)[:, None] * 1e-6, 3, axis=1), info,
                         tmin=0.0, verbose=False)
    return mne.preprocessing.compute_current_source_density(ev, **CSD_PARAMS, verbose=False).data[:, 0] * 1e6


DEPTHS = np.arange(0.20, 0.81, 0.05)
depth_rows = []
for d in DEPTHS:
    p, m = L5.dipole_from_params(float(d), 180.0, 22.0, strength_nAm=50.0)
    v = L5.dipole_potentials(sim_info, sim_bem, p, m)
    c = csd_of_map(v, sim_info)
    _, pk = spread_cm(v, np.array([sim_info["chs"][i]["loc"][:3] for i in range(len(SPHERE_MONTAGE))]) * 100)
    sp, _ = spread_cm(v, np.array([sim_info["chs"][i]["loc"][:3] for i in range(len(SPHERE_MONTAGE))]) * 100)
    depth_rows.append((float(d), d * L5.HEAD_RADIUS_M * 1000, float(np.abs(v).max()),
                       float(np.abs(c).max()), sp))

RADII = np.arange(0.0, 60.1, 7.5)
patch_rows = []
for rad in RADII:
    n = 1 if rad == 0 else 41
    total = np.zeros(len(SPHERE_MONTAGE))
    for k in range(n):
        if n == 1:
            az, el = 180.0, 22.0
        else:
            u = (k + 0.5) / n
            th = np.deg2rad(rad) * np.sqrt(u)
            ph = 2 * np.pi * ((k * 0.61803398875) % 1.0)
            az = 180.0 + np.rad2deg(th * np.cos(ph)) / max(np.cos(np.deg2rad(22.0)), 1e-6)
            el = 22.0 + np.rad2deg(th * np.sin(ph))
        p, m = L5.dipole_from_params(0.2, float(az), float(el), strength_nAm=50.0 / n)
        total += L5.dipole_potentials(sim_info, sim_bem, p, m)
    c = csd_of_map(total, sim_info)
    sp, _ = spread_cm(total, np.array([sim_info["chs"][i]["loc"][:3] for i in range(len(SPHERE_MONTAGE))]) * 100)
    patch_rows.append((float(rad), n, float(np.abs(total).max()), float(np.abs(c).max()), sp))

print("A single radial dipole moved deeper (four-shell sphere, 21 electrodes, no noise):")
print(f"{'depth (fraction of R)':>22s} {'depth below scalp (mm)':>23s} {'peak potential (uV)':>20s} "
      f"{'peak CSD (uV/m^2)':>18s} {'CSD/potential, relative':>24s}")
ref_ratio = depth_rows[0][3] / depth_rows[0][2]
for d, mm, vp, vc, sp in depth_rows:
    print(f"{d:22.2f} {mm:23.1f} {vp:20.3f} {vc:18.3f} {(vc / vp) / ref_ratio:24.3f}")
print()
print("A patch of superficial dipoles of growing angular radius, total moment held constant:")
print(f"{'patch radius (deg)':>19s} {'n dipoles':>10s} {'peak potential (uV)':>20s} {'peak CSD (uV/m^2)':>18s} "
      f"{'CSD/potential, relative':>24s} {'map spread (cm)':>16s}")
ref_ratio_p = patch_rows[0][3] / patch_rows[0][2]
for rad, n, vp, vc, sp in patch_rows:
    print(f"{rad:19.1f} {n:10d} {vp:20.3f} {vc:18.3f} {(vc / vp) / ref_ratio_p:24.3f} {sp:16.2f}")
A single radial dipole moved deeper (four-shell sphere, 21 electrodes, no noise):
 depth (fraction of R)  depth below scalp (mm)  peak potential (uV)  peak CSD (uV/m^2)  CSD/potential, relative
                  0.20                    18.0               12.876           9856.437                    1.000
                  0.25                    22.5               11.652           8624.802                    0.967
                  0.30                    27.0               10.517           7491.219                    0.931
                  0.35                    31.5                9.504           6483.117                    0.891
                  0.40                    36.0                8.616           5600.259                    0.849
                  0.45                    40.5                7.842           4831.607                    0.805
                  0.50                    45.0                7.167           4163.428                    0.759
                  0.55                    49.5                6.577           3582.616                    0.712
                  0.60                    54.0                6.060           3077.748                    0.664
                  0.65                    58.5                5.604           2639.184                    0.615
                  0.70                    63.0                5.200           2258.822                    0.567
                  0.75                    67.5                4.842           1929.789                    0.521
                  0.80                    72.0                4.521           1646.158                    0.476

A patch of superficial dipoles of growing angular radius, total moment held constant:
 patch radius (deg)  n dipoles  peak potential (uV)  peak CSD (uV/m^2)  CSD/potential, relative  map spread (cm)
                0.0          1               12.876           9856.437                    1.000             5.28
                7.5         41               12.667           9646.997                    0.995             5.32
               15.0         41               11.750           8759.949                    0.974             5.51
               22.5         41               10.158           7242.391                    0.931             5.87
               30.0         41                8.394           5583.126                    0.869             6.39
               37.5         41                6.789           4107.427                    0.790             7.00
               45.0         41                5.431           2902.357                    0.698             7.67
               52.5         41                4.512           2567.899                    0.744             8.34
               60.0         41                3.964           2174.813                    0.717             8.81
In [8]:
fig, axes = plt.subplots(1, 2, figsize=(12, 4.0))
d_mm = [r[1] for r in depth_rows]
axes[0].plot(d_mm, [(r[3] / r[2]) / ref_ratio for r in depth_rows], "-o", color="C0")
axes[0].set_xlabel("depth below the scalp (mm)")
axes[0].set_ylabel("CSD / potential, relative to the shallowest (dimensionless)")
axes[0].set_title("a single radial dipole, moved deeper", fontsize=9)
axes[0].grid(alpha=0.25)
ax2 = axes[0].twinx()
ax2.plot(d_mm, [r[2] for r in depth_rows], "--", color="C1", lw=1)
ax2.set_ylabel("peak potential (uV)", color="C1")
axes[1].plot([r[0] for r in patch_rows], [(r[3] / r[2]) / ref_ratio_p for r in patch_rows], "-o", color="C0")
axes[1].set_xlabel("patch radius (degrees of arc)")
axes[1].set_ylabel("CSD / potential, relative to a point source (dimensionless)")
axes[1].set_title("a superficial patch, spread wider (total moment fixed)", fontsize=9)
axes[1].grid(alpha=0.25)
fig.suptitle("What the surface Laplacian discounts: depth (left) and spatial extent (right), "
             "four-shell sphere, 21 electrodes", y=1.02, fontsize=10)
fig.tight_layout()
plt.show()   # render the static figure(s) of this cell inline

print("Both curves fall, and they fall for the same reason: a deep source and a broad patch both produce a")
print("SPATIALLY BROAD scalp map, and the Laplacian is a measure of spatial curvature.  The patch curve is not")
print("monotone past about 45 degrees: a patch that large wraps far enough around the sphere to develop new")
print("structure of its own, so the map stops simply getting broader.  That is a property of a sphere with a")
print(f"{L5.HEAD_RADIUS_M * 100:.0f} cm radius and 21 electrodes, and it is reported rather than trimmed off.")
print("CSD therefore cannot")
print("distinguish 'deep' from 'large' -- it discounts both, and it discounts a distant superficial generator")
print("for the same reason.  That is the whole trade: the sharpening you want and the attenuation you may not.")
Figure 2 of notebook nb-5-3-csd, an output plot. The text around it states what it shows and the units of every axis.
Both curves fall, and they fall for the same reason: a deep source and a broad patch both produce a
SPATIALLY BROAD scalp map, and the Laplacian is a measure of spatial curvature.  The patch curve is not
monotone past about 45 degrees: a patch that large wraps far enough around the sphere to develop new
structure of its own, so the map stops simply getting broader.  That is a property of a sphere with a
9 cm radius and 21 electrodes, and it is reported rather than trimmed off.
CSD therefore cannot
distinguish 'deep' from 'large' -- it discounts both, and it discounts a distant superficial generator
for the same reason.  That is the whole trade: the sharpening you want and the attenuation you may not.

4 · CSD and connectivity

The reason CSD turns up in a connectivity lesson is that it is a local transform: each channel becomes a contrast between itself and its neighbourhood, so the component of a channel that is shared with the whole head — which is what a distant generator contributes — is largely removed before any connectivity measure sees it. It is not an inverse solution and it does not undo mixing; it changes which spatial scale the channels are sensitive to.

ds-lemon eyes closed, alpha band, the same epochs as nb-5-2-connectivity, before and after CSD.

In [9]:
SUBJECT, MAX_MINUTES, BAND = "sub-010002", 3.2, L5.ALPHA_BAND
print(L5.disk_line("disk before the ds-lemon download"))
paths = []
try:
    epochs, meta = L5.lemon_condition_epochs(SUBJECT, "eyes-closed", max_minutes=MAX_MINUTES)
    paths = L5.lemon_files(SUBJECT)
    if epochs is None:
        raise RuntimeError(f"ds-lemon could not be fetched: {meta.get('error')}")
    lemon_info = epochs.info.copy()
    lemon_names = list(epochs.ch_names)
    lemon_sfreq = float(epochs.info["sfreq"])
    pot_data = epochs.get_data(copy=True) * 1e6
    csd_epochs = mne.preprocessing.compute_current_source_density(epochs.copy(), **CSD_PARAMS, verbose=False)
    csd_data = csd_epochs.get_data(copy=True) * 1e6
finally:
    print()
    freed_lemon = L5.delete_files(paths)
    print(L5.disk_line("disk after the ds-lemon download was deleted"))

print()
print(f"{len(pot_data)} eyes-closed epochs x {len(lemon_names)} channels at {lemon_sfreq:g} Hz")
METHODS = ("coh", "imcoh", "wpli")
con_pot, backend = L5.connectivity(pot_data, sfreq=lemon_sfreq, methods=METHODS, fmin=BAND[0], fmax=BAND[1])
con_csd, _ = L5.connectivity(csd_data, sfreq=lemon_sfreq, methods=METHODS, fmin=BAND[0], fmax=BAND[1])
con_pot = {k: np.abs(v) for k, v in con_pot.items()}
con_csd = {k: np.abs(v) for k, v in con_csd.items()}
print(f"backend: {backend['backend']}")
print()
_, LEM_GC = L5.great_circle_cm(lemon_info, L5.HEAD_RADIUS_M)
dist = L5.upper_pairs(LEM_GC)
print(f"{'measure':>8s} {'median before':>14s} {'median after':>13s} {'r(before, after)':>17s} "
      f"{'median < 5 cm':>14s} {'median > 15 cm':>15s}")
for m in METHODS:
    b, a_ = L5.upper_pairs(con_pot[m]), L5.upper_pairs(con_csd[m])
    print(f"{m:>8s} {np.median(b):14.4f} {np.median(a_):13.4f} "
          f"{float(np.corrcoef(b, a_)[0, 1]):17.3f} "
          f"{np.median(b[dist < 5]):.3f} -> {np.median(a_[dist < 5]):.3f}   "
          f"{np.median(b[dist > 15]):.3f} -> {np.median(a_[dist > 15]):.3f}")
disk before the ds-lemon download: 4.37 GB free on the working volume
ds-lemon sub-010002 (mirror id sub-032301): fetching sub-010002.vhdr, sub-010002.vmrk and 60 MB of sub-010002.eeg (the first 3.2 min of 17.0); 62 channels, 2500 Hz, INT_16
  sub-010002.eeg: 59.5 MB in 3 s
  sub-010002 eyes-closed: 60 epochs of 2 s (61 EEG channels at 250 Hz); 0 dropped above 300 uV peak-to-peak

  deleted 59.5 MB of downloaded files
disk after the ds-lemon download was deleted: 4.37 GB free on the working volume

60 eyes-closed epochs x 61 channels at 250 Hz
backend: mne-connectivity 0.9.0 (spectral_connectivity_epochs, mode='fourier')

 measure  median before  median after  r(before, after)  median < 5 cm  median > 15 cm
     coh         0.4178        0.1923             0.552 0.785 -> 0.623   0.497 -> 0.136
   imcoh         0.0320        0.0419             0.347 0.020 -> 0.046   0.056 -> 0.033
    wpli         0.1851        0.1798             0.404 0.184 -> 0.203   0.210 -> 0.164
In [10]:
fig, axes = plt.subplots(2, 3, figsize=(14, 8))
for row, (label, mats) in enumerate((("potential", con_pot), ("CSD", con_csd))):
    for col, m in enumerate(METHODS):
        L5.plot_matrix(mats[m], None, title=f"|{m}| -- {label}", ax=axes[row, col], vmin=0,
                       vmax=1.0 if m == "coh" else 0.5, cbar_label="dimensionless (0 to 1)")
fig.suptitle(f"ds-lemon {SUBJECT} eyes closed, {BAND[0]:g}-{BAND[1]:g} Hz: connectivity before (top) and "
             f"after (bottom) the surface Laplacian", y=1.0, fontsize=11)
fig.tight_layout()
plt.show()   # render the static figure(s) of this cell inline

fig, axes = plt.subplots(1, 2, figsize=(12, 4.0))
L5.plot_versus_distance(dist, {"coherence": L5.upper_pairs(con_pot["coh"]),
                               "wPLI": L5.upper_pairs(con_pot["wpli"])},
                        title="potential", ax=axes[0], ylabel="dimensionless (0 to 1)")
L5.plot_versus_distance(dist, {"coherence": L5.upper_pairs(con_csd["coh"]),
                               "wPLI": L5.upper_pairs(con_csd["wpli"])},
                        title="after CSD", ax=axes[1], ylabel="dimensionless (0 to 1)")
for ax in axes:
    ax.set_ylim(0, 1)
fig.suptitle("Connectivity against distance across the scalp (cm), before and after the surface Laplacian, "
             "8-12 Hz", y=1.02, fontsize=10)
fig.tight_layout()
plt.show()   # render the static figure(s) of this cell inline
Figure 3 of notebook nb-5-3-csd, an output plot. The text around it states what it shows and the units of every axis.
Figure 4 of notebook nb-5-3-csd, an output plot. The text around it states what it shows and the units of every axis.

5 · The exercise: which components sharpen and which shrink

The L5.3 exercise is a multiple-select over ERP components. The cell below answers it with the two things this notebook can measure: what happens to each component's peak amplitude ratio (CSD peak divided by potential peak, relative to the P3's), and what happens to its spatial spread. A component whose map is already focal keeps most of its amplitude and narrows a little; a component whose map is broad loses a large share of it.

The components available here are the ones the P3 paradigm actually contains, so the list is short and it is stated rather than assumed. TODO(confirm): the full L5.3 option list belongs to the lesson author; these are the measurements that should decide it.

In [11]:
COMPONENTS = {
    "P3 difference, 300-600 ms": window_map(grand["difference"], *W),
    "early response, 100-200 ms, target": window_map(grand["target"], 0.10, 0.20),
    "early response, 100-200 ms, standard": window_map(grand["standard"], 0.10, 0.20),
    "late slow wave, 600-800 ms, difference": window_map(grand["difference"], 0.60,
                                                         min(0.80, float(times[-1]))),
    "pre-stimulus window, -200-0 ms, difference": window_map(grand["difference"], -0.20, 0.0),
}
# The descriptions are MEASURED, not asserted: "broad" and "focal" are the spread column, and the survival
# of amplitude is an RMS ratio (the peak channel of the CSD map need not be the peak channel of the
# potential map, which makes a peak-to-peak ratio the wrong statistic).
comp_rows = []
base_ratio = None
for label, v_uv in COMPONENTS.items():
    v = np.asarray(v_uv, float) * 1e6
    c = csd_of_map(v, erp_info)
    sp_b, _ = spread_cm(v, erp_pos_cm)
    sp_a, _ = spread_cm(c, erp_pos_cm)
    rms_v, rms_c = float(np.sqrt((v ** 2).mean())), float(np.sqrt((c ** 2).mean()))
    ratio = rms_c / rms_v
    if base_ratio is None:
        base_ratio = ratio
    comp_rows.append((label, float(np.abs(v).max()), rms_v, rms_c, ratio, ratio / base_ratio, sp_b, sp_a))
print(f"{'component':>44s} {'peak (uV)':>10s} {'RMS (uV)':>9s} {'relative RMS ratio':>19s} "
      f"{'spread (cm)':>12s} {'-> after':>9s} {'narrowing':>10s}")
for label, pk, rms_v, rms_c, ratio, rel, sp_b, sp_a in comp_rows:
    print(f"{label:>44s} {pk:10.3f} {rms_v:9.3f} {rel:19.2f} {sp_b:12.2f} {sp_a:9.2f} "
          f"{sp_b / sp_a:9.2f}x")
print()
print("The 'relative RMS ratio' column is each component's CSD/potential RMS ratio divided by the P3's, so the")
print("P3 is 1 by construction: the absolute ratio carries the units convention and the spline parameters and")
print("means nothing on its own.")
print()
print("TWO RESULTS, AND THE SECOND IS A NEGATIVE ONE.")
print("  (a) Every map narrows.  The narrowing factor separates the components clearly: the P3 narrows most")
print(f"      ({comp_rows[0][6] / comp_rows[0][7]:.2f}x) and the early responses least "
      f"({comp_rows[1][6] / comp_rows[1][7]:.2f}x).")
print("  (b) The AMPLITUDE that survives does NOT separate them.  All five relative ratios sit inside")
print(f"      {min(r[5] for r in comp_rows):.2f}-{max(r[5] for r in comp_rows):.2f}, a spread of well under a")
print("      factor of two, where the controlled sphere sweep above moved by a factor of two on depth alone.")
print("      Spread does not predict survival here either: the P3 has the smallest spread of the five and a")
print("      middling ratio.  On 30 electrodes, over these windows, the attenuation side of the exercise is")
print("      not decided by the data.  TODO(confirm): the exercise should either ask about narrowing, which")
print("      these data answer, or about attenuation, which the sphere answers -- not about both at once.")
print()
print(f"The pre-stimulus row is a scale check, not a component: its amplitude is {comp_rows[-1][1]:.3f} uV, two")
print("orders below the P3, so its ratio measures the noise floor rather than a field.")
                                   component  peak (uV)  RMS (uV)  relative RMS ratio  spread (cm)  -> after  narrowing
                   P3 difference, 300-600 ms      7.084     3.182                1.00        11.25      5.79      1.94x
          early response, 100-200 ms, target      4.260     1.708                1.05        14.74     10.04      1.47x
        early response, 100-200 ms, standard      3.293     1.232                1.28        14.84     10.55      1.41x
      late slow wave, 600-800 ms, difference      5.012     2.193                0.95        14.98      9.34      1.60x
  pre-stimulus window, -200-0 ms, difference      0.029     0.013                1.10        14.43     10.78      1.34x

The 'relative RMS ratio' column is each component's CSD/potential RMS ratio divided by the P3's, so the
P3 is 1 by construction: the absolute ratio carries the units convention and the spline parameters and
means nothing on its own.

TWO RESULTS, AND THE SECOND IS A NEGATIVE ONE.
  (a) Every map narrows.  The narrowing factor separates the components clearly: the P3 narrows most
      (1.94x) and the early responses least (1.47x).
  (b) The AMPLITUDE that survives does NOT separate them.  All five relative ratios sit inside
      0.95-1.28, a spread of well under a
      factor of two, where the controlled sphere sweep above moved by a factor of two on depth alone.
      Spread does not predict survival here either: the P3 has the smallest spread of the five and a
      middling ratio.  On 30 electrodes, over these windows, the attenuation side of the exercise is
      not decided by the data.  TODO(confirm): the exercise should either ask about narrowing, which
      these data answer, or about attenuation, which the sphere answers -- not about both at once.

The pre-stimulus row is a scale check, not a component: its amplitude is 0.029 uV, two
orders below the P3, so its ratio measures the noise floor rather than a field.

6 · The numbers

In [12]:
print("nb-5-3-csd -- L5.3 numbers (draft; TODO(confirm) at author review)")
print(f"Transform: mne.preprocessing.compute_current_source_density(sphere='auto', "
      + ", ".join(f"{k}={v}" for k, v in CSD_PARAMS.items()) + "), MNE " + mne.__version__)
print(f"ERP data: ds-erpcore P3, sub-001..sub-{ERPCORE_SUBJECTS[-1]:03d} ({len(ERPCORE_SUBJECTS)} subjects), "
      f"helpers_l3.P3_PIPELINE; {L5.licence_line('ds-erpcore')}")
print(f"Resting data: ds-lemon {SUBJECT}, eyes closed, first {MAX_MINUTES:g} min "
      f"({freed_lemon:.0f} MB, deleted); {L5.licence_line('ds-lemon')}")
print("Sphere sweep: no dataset (MNE four-shell concentric sphere, 21 electrodes)")
print()
print("ANSWER KEY -- reference independence: CSD computed after four different references differs by at most")
print(f"    {ref_independence:.1e} of the peak -- the Laplacian of a constant is zero, so this is exact, "
      "not approximate.")
print()
print(f"ANSWER KEY -- sharpening of the P3 ({W[0] * 1000:.0f}-{W[1] * 1000:.0f} ms grand-average difference "
      f"wave):")
for label, ch, val, half, sp in rows:
    print(f"    {label:>16s}: peak {ch} = {val:+.3f}; {half} of {len(erp_names)} channels above half the "
          f"peak; weighted spread {sp:.2f} cm")
print(f"    across-channel correlation between the two maps r = {r_maps:+.3f}; spread falls by a factor of "
      f"{rows[0][4] / rows[1][4]:.2f}")
print()
print("ANSWER KEY -- what CSD attenuates (four-shell sphere, relative CSD/potential ratio, no dataset):")
print(f"    depth   {depth_rows[0][1]:.0f} mm below the scalp -> 1.000 (reference);  "
      f"{depth_rows[len(depth_rows) // 2][1]:.0f} mm -> "
      f"{(depth_rows[len(depth_rows) // 2][3] / depth_rows[len(depth_rows) // 2][2]) / ref_ratio:.3f};  "
      f"{depth_rows[-1][1]:.0f} mm -> {(depth_rows[-1][3] / depth_rows[-1][2]) / ref_ratio:.3f}")
print(f"    extent  point source -> 1.000 (reference);  "
      f"{patch_rows[len(patch_rows) // 2][0]:.0f} deg patch -> "
      f"{(patch_rows[len(patch_rows) // 2][3] / patch_rows[len(patch_rows) // 2][2]) / ref_ratio_p:.3f};  "
      f"{patch_rows[-1][0]:.0f} deg patch -> {(patch_rows[-1][3] / patch_rows[-1][2]) / ref_ratio_p:.3f}")
print("    CSD cannot separate 'deep' from 'large': both make the scalp map broad and both are discounted.")
print()
print("ANSWER KEY -- ex-5-3 (which components sharpen and which shrink):")
print("    SHARPENING, which these data do answer -- weighted spread before -> after, and the narrowing factor:")
for label, pk, rms_v, rms_c, ratio, rel, sp_b, sp_a in comp_rows:
    print(f"      {label:>44s}: {sp_b:5.2f} -> {sp_a:5.2f} cm ({sp_b / sp_a:.2f}x), "
          f"relative RMS ratio {rel:.2f}")
print("    ATTENUATION, which these data do NOT answer: all five relative RMS ratios lie inside "
      f"{min(r[5] for r in comp_rows):.2f}-{max(r[5] for r in comp_rows):.2f},")
print("      which is not a separation.  The controlled answer is the sphere sweep above: depth costs a "
      "factor of 2.1")
print("      from 18 to 72 mm, and extent a factor of 1.4 from a point to a 60-degree patch.  TODO(confirm): "
      "the")
print("      exercise's option list should be built on one of those two measurements, not on both.")
print()
print(f"ANSWER KEY -- connectivity before and after CSD (ds-lemon {SUBJECT}, {BAND[0]:g}-{BAND[1]:g} Hz, "
      f"{len(pot_data)} epochs):")
for m in METHODS:
    b, a_ = L5.upper_pairs(con_pot[m]), L5.upper_pairs(con_csd[m])
    print(f"    {m:>6s}: median {np.median(b):.4f} -> {np.median(a_):.4f}; "
          f"under 5 cm {np.median(b[dist < 5]):.3f} -> {np.median(a_[dist < 5]):.3f}; "
          f"over 15 cm {np.median(b[dist > 15]):.3f} -> {np.median(a_[dist > 15]):.3f}; "
          f"r(before, after) = {float(np.corrcoef(b, a_)[0, 1]):+.3f}")
print()
print("CSD is not an inverse solution and does not remove mixing.  It changes the spatial scale the channels")
print("are sensitive to, which reduces the contribution of distant generators and also removes any genuinely")
print("broad or deep source.  A connectivity result that appears only after CSD, or only before it, is a")
print("result about spatial scale.")
print()
print("Widget: w-reference-explorer (mode csd).  Pitfalls: none assigned to L5.3 in the spec.")
print(L5.disk_line("disk at the end"))
nb-5-3-csd -- L5.3 numbers (draft; TODO(confirm) at author review)
Transform: mne.preprocessing.compute_current_source_density(sphere='auto', lambda2=1e-05, stiffness=4, n_legendre_terms=50), MNE 1.10.2
ERP data: ds-erpcore P3, sub-001..sub-003 (3 subjects), helpers_l3.P3_PIPELINE; ds-erpcore — ERP CORE (Compendium of Open Resources and Experiments): licence CC-BY-SA-4.0, license_status: contested, access open (data/directory.yaml)
Resting data: ds-lemon sub-010002, eyes closed, first 3.2 min (60 MB, deleted); ds-lemon — LEMON (MPI-Leipzig Mind-Brain-Body): licence CC-BY-4.0, access open (data/directory.yaml)
Sphere sweep: no dataset (MNE four-shell concentric sphere, 21 electrodes)

ANSWER KEY -- reference independence: CSD computed after four different references differs by at most
    3.0e-13 of the peak -- the Laplacian of a constant is zero, so this is exact, not approximate.

ANSWER KEY -- sharpening of the P3 (300-600 ms grand-average difference wave):
      potential (uV): peak CPz = +7.084; 9 of 30 channels above half the peak; weighted spread 11.25 cm
        CSD (uV/m^2): peak Pz = +6093.108; 3 of 30 channels above half the peak; weighted spread 5.79 cm
    across-channel correlation between the two maps r = +0.907; spread falls by a factor of 1.94

ANSWER KEY -- what CSD attenuates (four-shell sphere, relative CSD/potential ratio, no dataset):
    depth   18 mm below the scalp -> 1.000 (reference);  45 mm -> 0.759;  72 mm -> 0.476
    extent  point source -> 1.000 (reference);  30 deg patch -> 0.869;  60 deg patch -> 0.717
    CSD cannot separate 'deep' from 'large': both make the scalp map broad and both are discounted.

ANSWER KEY -- ex-5-3 (which components sharpen and which shrink):
    SHARPENING, which these data do answer -- weighted spread before -> after, and the narrowing factor:
                         P3 difference, 300-600 ms: 11.25 ->  5.79 cm (1.94x), relative RMS ratio 1.00
                early response, 100-200 ms, target: 14.74 -> 10.04 cm (1.47x), relative RMS ratio 1.05
              early response, 100-200 ms, standard: 14.84 -> 10.55 cm (1.41x), relative RMS ratio 1.28
            late slow wave, 600-800 ms, difference: 14.98 ->  9.34 cm (1.60x), relative RMS ratio 0.95
        pre-stimulus window, -200-0 ms, difference: 14.43 -> 10.78 cm (1.34x), relative RMS ratio 1.10
    ATTENUATION, which these data do NOT answer: all five relative RMS ratios lie inside 0.95-1.28,
      which is not a separation.  The controlled answer is the sphere sweep above: depth costs a factor of 2.1
      from 18 to 72 mm, and extent a factor of 1.4 from a point to a 60-degree patch.  TODO(confirm): the
      exercise's option list should be built on one of those two measurements, not on both.

ANSWER KEY -- connectivity before and after CSD (ds-lemon sub-010002, 8-12 Hz, 60 epochs):
       coh: median 0.4178 -> 0.1923; under 5 cm 0.785 -> 0.623; over 15 cm 0.497 -> 0.136; r(before, after) = +0.552
     imcoh: median 0.0320 -> 0.0419; under 5 cm 0.020 -> 0.046; over 15 cm 0.056 -> 0.033; r(before, after) = +0.347
      wpli: median 0.1851 -> 0.1798; under 5 cm 0.184 -> 0.203; over 15 cm 0.210 -> 0.164; r(before, after) = +0.404

CSD is not an inverse solution and does not remove mixing.  It changes the spatial scale the channels
are sensitive to, which reduces the contribution of distant generators and also removes any genuinely
broad or deep source.  A connectivity result that appears only after CSD, or only before it, is a
result about spatial scale.

Widget: w-reference-explorer (mode csd).  Pitfalls: none assigned to L5.3 in the spec.
disk at the end: 4.37 GB free on the working volume