The inverse problem on a sphere: the null space, minimum-norm, dSPM, sLORETA, eLORETA and an LCMV beamformer, resolution matrices, the depth bias — and why a deep source and a superficial patch cannot be told apart

nb-5-5-inverse Level 5 · Connectivity and Spatial Analysis ~3 min Used in L5.5 · The inverse problem and source estimation

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-5-inverse · The inverse problem and source estimation (L5.5)

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

Read this first: what is missing, and why. Spec §6 writes L5.5 as "dSPM of the P3 on ds-fsaverage; beamformer on the same data". ds-fsaverage and ds-mne-sample both fail the spec §10.7 licence gate (ds-fsaverage settled, ds-mne-sample contested and a new open decision for the author), so neither is used anywhere on this site and neither appears below. The first cell prints both records in full.

Without them there is no cortical surface and no anatomical parcellation, which removes exactly one thing: the ability to put a name on a location. Everything else the lesson asks for — ill-posedness, minimum-norm, dSPM, sLORETA, eLORETA, an LCMV beamformer, regularization, noise covariance, resolution matrices, point spread, depth bias — is computed below on MNE's analytic concentric-sphere model, which ships with the library and needs no anatomy.

That removal is the lesson, not a loss. L5.5's exercise asks which of two claims a source map can defend, "lateral parietal" or "hippocampal". A notebook that could print an anatomical label would make that exercise easier to get wrong. Section 5 measures why neither label is safe, and why the second one is not safe at any electrode count.

What you will do

  1. Look at the null space: combinations of sources that produce exactly nothing at the sensors.
  2. Build minimum-norm, dSPM, sLORETA and eLORETA estimates and an LCMV beamformer of the same simulated source, and measure where each puts it.
  3. Compute each method's resolution matrix and read point-spread off it: localisation error and spatial dispersion, as a function of depth.
  4. Add depth weighting by hand and watch the depth bias move.
  5. Measure how far a deep source and a superficial patch can be told apart, which is the exercise.

Data. None. Every number in this notebook comes from MNE's analytic sphere model, the standard_1005 template montage and seeded pseudo-random noise. Nothing is downloaded and nothing is deleted.

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.37 GB free on the working volume
In [2]:
L5.print_source_model_block()
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 · Ill-posed means the data do not contain the answer

With 61 electrodes and a grid of about a thousand candidate sources, the leadfield G maps a thousand-dimensional source space onto a sixty-dimensional measurement. Its null space — the set of source configurations that produce identically zero at every electrode — has all the remaining dimensions.

Any source estimate is therefore (something the data constrain) + (anything at all from the null space), and which element of the null space you add is decided entirely by the method's assumptions. The cell below takes a null-space vector, checks that it really does vanish at the sensors, and draws it.

In [3]:
import warnings

MONTAGE = ["Fp1", "Fp2", "F7", "F3", "Fz", "F4", "F8", "FC5", "FC1", "FC2", "FC6", "T7", "C3", "Cz", "C4",
           "T8", "CP5", "CP1", "CP2", "CP6", "AFz", "P7", "P3", "Pz", "P4", "P8", "PO9", "O1", "Oz", "O2",
           "PO10", "AF7", "AF3", "AF4", "AF8", "F5", "F1", "F2", "F6", "FT7", "FC3", "FC4", "FT8", "C5",
           "C1", "C2", "C6", "TP7", "CP3", "CPz", "CP4", "TP8", "P5", "P1", "P2", "P6", "PO7", "PO3",
           "POz", "PO4", "PO8"]
GRID_SPACING_M, GRID_MAX_FRACTION = 0.012, 0.85
SFREQ, NOISE_UV, SNR = 250.0, 1.0, 3.0
LAMBDA2 = 1.0 / SNR ** 2

info = L5.sphere_info(MONTAGE, sfreq=SFREQ)
# An average-reference projector, because an inverse operator needs to know what the reference did.
_raw = mne.io.RawArray(np.zeros((len(MONTAGE), 10)), info.copy(), verbose=False)
_raw.set_eeg_reference("average", projection=True, verbose=False)
info = _raw.info

bem = L5.sphere_models()["four-shell"]
rr = L5.source_grid(spacing_m=GRID_SPACING_M, max_fraction=GRID_MAX_FRACTION, min_radius_m=0.012)
nn = rr / np.linalg.norm(rr, axis=1, keepdims=True)
fwd, G = L5.leadfield(info, bem, rr, fixed_normals=nn)
depth_mm = (L5.HEAD_RADIUS_M - np.linalg.norm(rr, axis=1)) * 1000
print(f"{len(MONTAGE)} electrodes, {len(rr)} radial sources on a {GRID_SPACING_M * 1000:.0f} mm grid; "
      f"leadfield {G.shape}")

rank = np.linalg.matrix_rank(G)
print(f"rank(G) = {rank}; null space dimension = {G.shape[1] - rank} of {G.shape[1]} source dimensions")
U, S, Vt = np.linalg.svd(G, full_matrices=True)
null_vec = Vt[rank]                                       # the first direction G annihilates
sensor = G @ null_vec
NULL_REL = float(np.abs(sensor).max() / np.abs(G).max())
print(f"a unit-norm null-space source configuration produces at most {NULL_REL:.2e} of the leadfield's "
      f"largest entry at any electrode -- zero, to double precision")
print(f"  (MNE stores a forward solution in SINGLE precision; helpers_l5.leadfield hands back a float64 copy. "
      f"Left in float32")
print(f"   the same residual reads about 6e-08, which is the arithmetic and not the physics.)")
print(f"singular values of G: largest {S[0]:.3e}, smallest {S[rank - 1]:.3e}, "
      f"ratio {S[0] / S[rank - 1]:.1f}")
print()
print("The ratio above is the honest version of 'EEG cannot see deep sources': the worst-seen source "
      "direction")
print(f"reaches the sensors {S[0] / S[rank - 1]:.0f} times more weakly than the best-seen one, so noise that "
      "is small compared")
print("with the best direction is large compared with the worst.  Regularization is the decision about where "
      "to stop.")
61 electrodes, 1063 radial sources on a 12 mm grid; leadfield (61, 1063)
rank(G) = 61; null space dimension = 1002 of 1063 source dimensions
a unit-norm null-space source configuration produces at most 3.14e-16 of the leadfield's largest entry at any electrode -- zero, to double precision
  (MNE stores a forward solution in SINGLE precision; helpers_l5.leadfield hands back a float64 copy. Left in float32
   the same residual reads about 6e-08, which is the arithmetic and not the physics.)
singular values of G: largest 6.847e+03, smallest 7.005e+01, ratio 97.7

The ratio above is the honest version of 'EEG cannot see deep sources': the worst-seen source direction
reaches the sensors 98 times more weakly than the best-seen one, so noise that is small compared
with the best direction is large compared with the worst.  Regularization is the decision about where to stop.
In [4]:
fig, axes = plt.subplots(1, 3, figsize=(15, 4.0))
sl = np.abs(rr[:, 0]) < GRID_SPACING_M / 2
v = np.abs(null_vec[sl]).max()
sc = axes[0].scatter(rr[sl, 1] * 1000, rr[sl, 2] * 1000, c=null_vec[sl], s=70, cmap="RdBu_r",
                     vmin=-v, vmax=v)
cb = fig.colorbar(sc, ax=axes[0]); cb.set_label("source amplitude (arbitrary, unit norm)")
axes[0].set_xlabel("y, anterior + (mm)"); axes[0].set_ylabel("z, superior + (mm)")
axes[0].set_title("a source pattern the sensors cannot see\n(mid-sagittal slice)", fontsize=9)
axes[0].set_aspect("equal")

im, _ = mne.viz.plot_topomap(sensor * 1e6, info, axes=axes[1], show=False, contours=6, sensors=True)
cb = fig.colorbar(im, ax=axes[1]); cb.set_label("uV")
axes[1].set_title(f"what it produces at the scalp\n(peak {np.abs(sensor).max() * 1e6:.1e} uV)", fontsize=9)

axes[2].semilogy(np.arange(1, len(S) + 1), S, "o-", ms=4)
axes[2].set_xlabel("singular value index")
axes[2].set_ylabel("singular value of G (V per A.m)")
axes[2].set_title("how unevenly the sensors see\nthe source space", fontsize=9)
axes[2].grid(alpha=0.25)
fig.suptitle("Ill-posedness, drawn: the null space, its (absent) scalp map, and the spectrum of the leadfield",
             y=1.02, fontsize=10)
fig.tight_layout()
plt.show()   # render the static figure(s) of this cell inline
Figure 1 of notebook nb-5-5-inverse, an output plot. The text around it states what it shows and the units of every axis.

2 · Five estimators of the same simulated source

A single radial dipole is simulated at a known grid point, projected through the same leadfield the inverse operators use, and buried in independent sensor noise. Because the model is exactly right and the source is exactly at a grid point, every number below is a best case; the errors are what the method does when nothing else is wrong.

  • MNE — the minimum-norm estimate: of all the source configurations that explain the data, the one with the smallest total power. That is a choice, and it is the choice that biases towards superficial sources.
  • dSPM — MNE divided, per source, by the noise the operator would let through there.
  • sLORETA — MNE divided by the square root of the resolution matrix's diagonal.
  • eLORETA — the weighting iterated until the resolution matrix's diagonal is exactly uniform.
  • LCMV — a beamformer: a separate spatial filter per source, built to pass that source and suppress everything else, from the data covariance rather than from a prior.
In [5]:
from mne.beamformer import apply_lcmv, make_lcmv
from mne.minimum_norm import apply_inverse, make_inverse_operator, make_inverse_resolution_matrix

rng = np.random.default_rng(L5.SEED)
N_EPOCHS, N_TIMES, F_HZ, MOMENT_nAm = 40, 250, 10.0, 50.0
wave = np.sin(2 * np.pi * F_HZ * np.arange(N_TIMES) / SFREQ)


def simulate(j, moment_nAm=MOMENT_nAm, noise_uv=NOISE_UV, n_epochs=N_EPOCHS, rng=rng):
    """Epochs of one radial dipole at grid point ``j`` plus independent sensor noise."""
    clean = G[:, [j]] @ (moment_nAm * 1e-9 * wave)[None, :]
    X = np.stack([clean + rng.standard_normal(clean.shape) * noise_uv * 1e-6 for _ in range(n_epochs)])
    return mne.EpochsArray(X, info, tmin=0.0, verbose=False), clean


TRUE = int(np.argmin(np.abs(depth_mm - 25) + np.abs(rr[:, 0]) * 1000 + np.abs(rr[:, 1] + 0.05) * 1000))
epochs, clean = simulate(TRUE)
evoked = epochs.average()
print(f"simulated source: grid point {TRUE}, {depth_mm[TRUE]:.0f} mm below the scalp, "
      f"{MOMENT_nAm:.0f} nA.m radial at {F_HZ:g} Hz")
print(f"  peak scalp amplitude {np.abs(clean).max() * 1e6:.2f} uV; sensor noise {NOISE_UV:g} uV SD per "
      f"electrode; {N_EPOCHS} epochs averaged")
print(f"  regularization: lambda^2 = 1/SNR^2 = {LAMBDA2:.4f} (SNR {SNR:g})")

noise_cov = mne.make_ad_hoc_cov(info, std=NOISE_UV * 1e-6, verbose=False)
with warnings.catch_warnings():
    warnings.simplefilter("ignore")
    inv = make_inverse_operator(info, fwd, noise_cov, loose=0.0, depth=None, fixed=True, verbose=False)
    data_cov = mne.compute_covariance(epochs, verbose=False)
    lcmv = make_lcmv(info, fwd, data_cov, reg=0.05, noise_cov=noise_cov, verbose=False)

estimates = {}
for method in ("MNE", "dSPM", "sLORETA", "eLORETA"):
    with warnings.catch_warnings():
        warnings.simplefilter("ignore")
        stc = apply_inverse(evoked, inv, lambda2=LAMBDA2, method=method, verbose=False)
    estimates[method] = np.abs(stc.data).max(axis=1)
with warnings.catch_warnings():
    warnings.simplefilter("ignore")
    estimates["LCMV"] = np.abs(apply_lcmv(evoked, lcmv, verbose=False).data).max(axis=1)


def where(amp):
    peak = int(np.argmax(amp))
    w = amp ** 2
    d = np.linalg.norm(rr - rr[TRUE], axis=1) * 1000
    return peak, float(np.linalg.norm(rr[peak] - rr[TRUE]) * 1000), \
        float(np.sqrt((w * d ** 2).sum() / w.sum())), float(depth_mm[peak])


print()
print(f"{'method':>10s} {'peak error (mm)':>16s} {'spatial dispersion (mm)':>24s} {'peak depth (mm)':>16s} "
      f"{'true depth (mm)':>16s}")
est_rows = []
for method, amp in estimates.items():
    peak, err, disp, pd = where(amp)
    est_rows.append((method, err, disp, pd))
    print(f"{method:>10s} {err:16.1f} {disp:24.1f} {pd:16.1f} {depth_mm[TRUE]:16.1f}")
print()
print("Peak error alone is a poor summary: a method can put its maximum in the right place and still spread "
      "the")
print("estimate over half the head, which is what the dispersion column is for.  Both are reported "
      "everywhere below.")
simulated source: grid point 434, 24 mm below the scalp, 50 nA.m radial at 10 Hz
  peak scalp amplitude 4.65 uV; sensor noise 1 uV SD per electrode; 40 epochs averaged
  regularization: lambda^2 = 1/SNR^2 = 0.1111 (SNR 3)

    method  peak error (mm)  spatial dispersion (mm)  peak depth (mm)  true depth (mm)
       MNE             29.4                     75.1             13.9             23.5
      dSPM             50.9                     60.5             72.3             23.5
   sLORETA              0.0                     56.3             23.5             23.5
   eLORETA              0.0                     57.0             23.5             23.5
      LCMV              0.0                     71.5             23.5             23.5

Peak error alone is a poor summary: a method can put its maximum in the right place and still spread the
estimate over half the head, which is what the dispersion column is for.  Both are reported everywhere below.
In [6]:
fig, axes = plt.subplots(1, len(estimates), figsize=(3.1 * len(estimates), 3.6))
sl = np.abs(rr[:, 0] - rr[TRUE, 0]) < GRID_SPACING_M / 2
for ax, (method, amp) in zip(axes, estimates.items()):
    a = amp / amp.max()
    sc = ax.scatter(rr[sl, 1] * 1000, rr[sl, 2] * 1000, c=a[sl], s=60, cmap="magma", vmin=0, vmax=1)
    ax.plot(rr[TRUE, 1] * 1000, rr[TRUE, 2] * 1000, "o", mfc="none", mec="cyan", ms=14, mew=2)
    ax.set_title(f"{method}", fontsize=9); ax.set_aspect("equal")
    ax.set_xlabel("y, anterior + (mm)")
axes[0].set_ylabel("z, superior + (mm)")
cb = fig.colorbar(sc, ax=axes, shrink=0.8); cb.set_label("estimate, normalised to its own peak")
fig.suptitle(f"One simulated radial dipole {depth_mm[TRUE]:.0f} mm below the scalp (cyan ring), five "
             f"estimators, coronal-plane slice of the grid", y=1.02, fontsize=10)
plt.show()   # render the static figure(s) of this cell inline
Figure 2 of notebook nb-5-5-inverse, an output plot. The text around it states what it shows and the units of every axis.

3 · Resolution matrices: what each method would do to every source

One simulation is one draw. The resolution matrix R = K G answers the same question for every source at once: column j is the estimate the method returns for a unit source at j. A perfect method would make R the identity; none of them does.

Two numbers per column say how far off it is, both in millimetres:

  • peak localisation error — how far the column's maximum sits from the source that produced it;
  • spatial dispersion — the amplitude-weighted RMS distance of the whole column from the true position, which is the blur that remains even when the peak is right.

MNE ships mne.minimum_norm.resolution_metrics, but it accepts only a surface source space, which needs a subject anatomy — the thing the licence finding removes. The arithmetic is four lines, so helpers_l5 writes it out.

In [7]:
res_rows = []
res_metrics = {}
for method in ("MNE", "dSPM", "sLORETA", "eLORETA"):
    with warnings.catch_warnings():
        warnings.simplefilter("ignore")
        R = make_inverse_resolution_matrix(fwd, inv, method=method, lambda2=LAMBDA2)
    met = L5.resolution_metrics_grid(R, rr)
    res_metrics[method] = met
    res_rows.append((method, float(np.median(met["peak_error_mm"])), float(met["peak_error_mm"].mean()),
                     float(np.median(met["spatial_dispersion_mm"]))))

# The beamformer has a kernel too, one row per source; build its resolution matrix the same way.
K_lcmv = lcmv["weights"]
R_lcmv = L5.resolution_matrix(G, K_lcmv)
met = L5.resolution_metrics_grid(R_lcmv, rr)
res_metrics["LCMV"] = met
res_rows.append(("LCMV", float(np.median(met["peak_error_mm"])), float(met["peak_error_mm"].mean()),
                 float(np.median(met["spatial_dispersion_mm"]))))

print(f"Resolution over all {len(rr)} grid sources, noiseless (the resolution matrix has no noise in it):")
print(f"{'method':>10s} {'peak error median (mm)':>24s} {'mean':>7s} {'dispersion median (mm)':>24s}")
for method, med, mean_e, disp in res_rows:
    print(f"{method:>10s} {med:24.1f} {mean_e:7.1f} {disp:24.1f}")
print()
print("sLORETA and eLORETA reach ZERO median peak error, which is not an accident and not an achievement "
      "either:")
print("both are constructed so that the resolution matrix's diagonal dominates its column, which is exactly")
print("'the peak lands in the right place'.  Their dispersion columns say what that construction costs -- "
      "the")
print("estimate is still spread over centimetres.  Zero localisation error for a point source is a property "
      "of")
print("the estimator, not evidence that the estimate is sharp.")
Resolution over all 1063 grid sources, noiseless (the resolution matrix has no noise in it):
    method   peak error median (mm)    mean   dispersion median (mm)
       MNE                     20.8    25.2                     39.0
      dSPM                     36.0    35.7                     46.3
   sLORETA                      0.0     0.0                     44.3
   eLORETA                      0.0     0.0                     40.7
      LCMV                      0.0     3.3                     68.8

sLORETA and eLORETA reach ZERO median peak error, which is not an accident and not an achievement either:
both are constructed so that the resolution matrix's diagonal dominates its column, which is exactly
'the peak lands in the right place'.  Their dispersion columns say what that construction costs -- the
estimate is still spread over centimetres.  Zero localisation error for a point source is a property of
the estimator, not evidence that the estimate is sharp.
In [8]:
fig, axes = plt.subplots(1, 2, figsize=(12.5, 4.2))
for method in res_metrics:
    o = np.argsort(depth_mm)
    for ax, key, lab in ((axes[0], "peak_error_mm", "peak localisation error (mm)"),
                         (axes[1], "spatial_dispersion_mm", "spatial dispersion (mm)")):
        y = res_metrics[method][key][o]
        # median in 10 mm depth bins, so five curves stay readable
        edges = np.arange(depth_mm.min(), depth_mm.max() + 10, 10)
        xs = 0.5 * (edges[:-1] + edges[1:])
        ys = [np.median(res_metrics[method][key][(depth_mm >= a) & (depth_mm < b)])
              if ((depth_mm >= a) & (depth_mm < b)).any() else np.nan
              for a, b in zip(edges[:-1], edges[1:])]
        ax.plot(xs, ys, "-o", ms=4, label=method)
        ax.set_xlabel("depth below the scalp (mm)"); ax.set_ylabel(lab)
        ax.grid(alpha=0.25)
axes[0].set_title("where the peak lands", fontsize=9)
axes[1].set_title("how far the estimate spreads", fontsize=9)
axes[0].legend(fontsize=8)
fig.suptitle(f"Resolution against depth, {len(MONTAGE)} electrodes, four-shell sphere "
             f"(median per 10 mm depth bin)", 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-5-inverse, an output plot. The text around it states what it shows and the units of every axis.

4 · The depth bias, and the weighting that trades it for something else

Minimum-norm asks for the smallest total source power that explains the data. A deep source needs a large moment to produce the same scalp map as a shallow one, so "smallest power" systematically prefers the shallow explanation. Depth weighting fixes that by making deep sources cheaper: the source covariance becomes diag(‖G_j‖^(−2γ)), so the estimate is

K = R Gᵀ (G R Gᵀ + λ² C)⁻¹, R = diag(‖G_j‖^(−2γ)).

γ = 0 is plain MNE. The cell below sweeps γ and measures both halves of the trade: the depth of the estimated peak for a deep source, and the localisation error over the whole grid.

In [9]:
# Both the leadfield and the data are average-referenced here, because that is the reference the simulated
# measurement carries; an inverse operator built on a differently referenced leadfield is solving a different
# problem.
G_avg = G - G.mean(axis=0, keepdims=True)
col_norm = np.linalg.norm(G_avg, axis=0)
print(f"column norms of the average-referenced leadfield span {col_norm.min():.1f} to {col_norm.max():.1f} "
      f"(a ratio of {col_norm.max() / col_norm.min():.1f}),")
print("which is all the leverage a depth weighting has.")


def mne_kernel(gamma, lambda2=LAMBDA2):
    """Depth-weighted minimum-norm kernel, written out: K = R G' (G R G' + lambda^2 tr/n I)^-1."""
    w = col_norm ** (-2.0 * gamma)                          # the source covariance R = diag(w)
    GR = G_avg * w
    A = GR @ G_avg.T
    A = A + lambda2 * np.trace(A) / len(MONTAGE) * np.eye(len(MONTAGE))
    return GR.T @ np.linalg.inv(A)


def avg_ref(x):
    return x - x.mean(axis=0, keepdims=True)


DEEP = int(np.argmin(np.abs(depth_mm - 60) + np.abs(rr[:, 0]) * 1000))
deep_epochs, deep_clean = simulate(DEEP)
deep_v = deep_epochs.average().data
print(f"a second, deeper source for this section: grid point {DEEP}, {depth_mm[DEEP]:.0f} mm below the "
      f"scalp, peak {np.abs(deep_clean).max() * 1e6:.2f} uV")
print()
print(f"{'gamma':>7s} {'peak depth, shallow source (mm)':>32s} {'peak depth, deep source (mm)':>30s} "
      f"{'median peak error over the grid (mm)':>38s}")
gamma_rows = []
for gamma in (0.0, 0.2, 0.4, 0.6, 0.8, 1.0, 1.5, 2.0):
    K = mne_kernel(gamma)
    a_shallow = np.abs(K @ avg_ref(evoked.data)).max(axis=1)
    a_deep = np.abs(K @ avg_ref(deep_v)).max(axis=1)
    R = L5.resolution_matrix(G_avg, K)
    met = L5.resolution_metrics_grid(R, rr)
    row = (gamma, float(depth_mm[int(np.argmax(a_shallow))]), float(depth_mm[int(np.argmax(a_deep))]),
           float(np.median(met["peak_error_mm"])), float(np.median(met["spatial_dispersion_mm"])))
    gamma_rows.append(row)
    print(f"{gamma:7.1f} {row[1]:32.1f} {row[2]:30.1f} {row[3]:38.1f}")
print()
print(f"true depths: shallow source {depth_mm[TRUE]:.0f} mm, deep source {depth_mm[DEEP]:.0f} mm")
print()
print("Depth weighting moves the estimate of a deep source deeper -- and then STOPS.  On this geometry it")
print(f"takes the {depth_mm[DEEP]:.0f} mm source's estimate from {gamma_rows[0][2]:.0f} mm to about "
      f"{max(r[2] for r in gamma_rows):.0f} mm and no further, however")
print("large gamma is made, because the column norms of the leadfield span only a factor of "
      f"{col_norm.max() / col_norm.min():.0f} across the")
print("whole grid: that is all the leverage the weighting has.  A source 60 mm below the scalp is never")
print("recovered at 60 mm by any gamma tried here.  Depth weighting is also not free -- the dispersion column")
print("grows with gamma -- and it is not a measurement: gamma is a knob, and a source estimate is only as deep")
print("as the knob was set.")
print(f"{'gamma':>7s} {'median dispersion over the grid (mm)':>38s}")
for gamma, ds, dd, pe, disp in gamma_rows:
    print(f"{gamma:7.1f} {disp:38.1f}")
column norms of the average-referenced leadfield span 53.7 to 654.2 (a ratio of 12.2),
which is all the leverage a depth weighting has.
a second, deeper source for this section: grid point 459, 60 mm below the scalp, peak 5.57 uV

  gamma  peak depth, shallow source (mm)   peak depth, deep source (mm)   median peak error over the grid (mm)
    0.0                             13.9                           22.5                                   20.8
    0.2                             13.9                           22.5                                   20.8
    0.4                             13.9                           22.5                                   20.8
    0.6                             13.7                           22.5                                   17.0
    0.8                             13.7                           22.5                                   20.8
    1.0                             23.3                           24.6                                   24.0
    1.5                             23.3                           24.6                                   61.2
    2.0                             23.3                           24.9                                   76.8

true depths: shallow source 24 mm, deep source 60 mm

Depth weighting moves the estimate of a deep source deeper -- and then STOPS.  On this geometry it
takes the 60 mm source's estimate from 22 mm to about 25 mm and no further, however
large gamma is made, because the column norms of the leadfield span only a factor of 12 across the
whole grid: that is all the leverage the weighting has.  A source 60 mm below the scalp is never
recovered at 60 mm by any gamma tried here.  Depth weighting is also not free -- the dispersion column
grows with gamma -- and it is not a measurement: gamma is a knob, and a source estimate is only as deep
as the knob was set.
  gamma   median dispersion over the grid (mm)
    0.0                                   38.9
    0.2                                   38.9
    0.4                                   39.3
    0.6                                   40.8
    0.8                                   43.5
    1.0                                   48.7
    1.5                                   62.6
    2.0                                   74.1

5 · The exercise: which claim can a source map defend?

L5.5's exercise offers two claims about a source map — "lateral parietal" and "hippocampal" — and asks which is defensible. The measurement that settles it is not about either brain region, because the sensors do not know about brain regions. It is this: for a deep source, is there a superficial configuration that produces the same scalp map? If there is, the deep claim is not a claim the data can support, whatever the map looks like.

The cell below takes a deep radial dipole and searches over superficial patches — caps of superficial dipoles of growing angular radius — for the one whose scalp map best matches it. This is the same construction as the site's pf-deep-source-claims figure, recomputed here on a different montage and a different grid, so the two are an independent check on each other.

In [10]:
def patch_dirs(centre_dir, radius_deg, n=61):
    """``n`` directions spread evenly over a cap of angular radius ``radius_deg`` about ``centre_dir``."""
    if radius_deg == 0:
        return centre_dir[None, :]
    e1 = np.cross(centre_dir, [0, 0, 1.0])
    e1 = e1 / np.linalg.norm(e1) if np.linalg.norm(e1) > 1e-9 else np.array([1.0, 0.0, 0.0])
    e2 = np.cross(centre_dir, e1)
    k = np.arange(n)
    th = np.deg2rad(radius_deg) * np.sqrt((k + 0.5) / n)
    ph = 2 * np.pi * ((k * 0.61803398875) % 1.0)
    d = (centre_dir[None, :] * np.cos(th)[:, None]
         + (e1[None, :] * np.cos(ph)[:, None] + e2[None, :] * np.sin(ph)[:, None]) * np.sin(th)[:, None])
    return d / np.linalg.norm(d, axis=1, keepdims=True)


def patch_map(centre_dir, radius_deg, depth_fraction, bem, info, n=61, moment_nAm=20.0):
    """Scalp map (uV) of a cap of radial dipoles, total moment held at ``moment_nAm``.

    One forward solution for the whole cap rather than one per dipole -- the search below tries several
    hundred caps and the difference is a minute of wall clock.
    """
    d = patch_dirs(centre_dir, radius_deg, n)
    pos = d * (1 - depth_fraction) * L5.HEAD_RADIUS_M
    _, Gp = L5.leadfield(info, bem, pos, fixed_normals=d)
    return Gp.sum(axis=1) * (moment_nAm / len(d)) * 1e-9 * 1e6


PATCH_DEPTH = 0.15
RADII = np.arange(0.0, 75.1, 2.5)
CENTRE = np.array([0.0, -np.cos(np.deg2rad(22.0)), np.sin(np.deg2rad(22.0))])
info21 = L5.sphere_info(["Fp1", "Fpz", "Fp2", "F7", "F3", "Fz", "F4", "F8", "T7", "C3", "Cz", "C4", "T8",
                         "P7", "P3", "Pz", "P4", "P8", "O1", "Oz", "O2"], sfreq=SFREQ)

print(f"For each source depth, the best-matching superficial patch (centred at {PATCH_DEPTH:g} R depth):")
print(f"{'source depth (mm)':>19s} {'peak (uV)':>11s} | {'21 electrodes: best r':>23s} {'radius (deg)':>13s} "
      f"| {'61 electrodes: best r':>23s} {'radius (deg)':>13s}")
deep_rows = []
for frac in (0.2, 0.3, 0.4, 0.5, 0.6, 0.7):
    depth = frac * L5.HEAD_RADIUS_M * 1000
    row = [depth]
    for label, inf in (("21", info21), ("61", info)):
        target = patch_map(CENTRE, 0.0, frac, bem, inf)
        best_r, best_rad = -2.0, np.nan
        for rad in RADII:
            m = patch_map(CENTRE, float(rad), PATCH_DEPTH, bem, inf)
            r = float(np.corrcoef(target - target.mean(), m - m.mean())[0, 1])
            if r > best_r:
                best_r, best_rad = r, float(rad)
        row += [best_r, best_rad]
        if label == "21":
            row.append(float(np.abs(target).max()))
    deep_rows.append(row)
    print(f"{row[0]:19.1f} {row[3]:11.3f} | {row[1]:23.4f} {row[2]:13.1f} | {row[4]:23.4f} {row[5]:13.1f}")
print()
print("Every depth has a superficial patch whose map is nearly the same.  More electrodes help -- the")
print("correlations at 61 are below those at 21 -- but they do not solve it, and neither number is anywhere")
print("near low enough to call the two configurations distinguishable in the presence of real noise.")
For each source depth, the best-matching superficial patch (centred at 0.15 R depth):
  source depth (mm)   peak (uV) |   21 electrodes: best r  radius (deg) |   61 electrodes: best r  radius (deg)
               18.0       5.151 |                  0.9998          15.0 |                  0.9996          12.5
               27.0       4.207 |                  0.9983          25.0 |                  0.9976          25.0
               36.0       3.446 |                  0.9969          35.0 |                  0.9959          35.0
               45.0       2.867 |                  0.9954          42.5 |                  0.9942          42.5
               54.0       2.424 |                  0.9937          52.5 |                  0.9927          50.0
               63.0       2.080 |                  0.9918          60.0 |                  0.9908          57.5

Every depth has a superficial patch whose map is nearly the same.  More electrodes help -- the
correlations at 61 are below those at 21 -- but they do not solve it, and neither number is anywhere
near low enough to call the two configurations distinguishable in the presence of real noise.
In [11]:
fig, axes = plt.subplots(1, 3, figsize=(14.5, 4.0))
frac = 0.5
target21 = patch_map(CENTRE, 0.0, frac, bem, info21)
best = max(RADII, key=lambda rad: float(np.corrcoef(target21 - target21.mean(),
                                                    patch_map(CENTRE, float(rad), PATCH_DEPTH, bem, info21) -
                                                    patch_map(CENTRE, float(rad), PATCH_DEPTH, bem,
                                                              info21).mean())[0, 1]))
match21 = patch_map(CENTRE, float(best), PATCH_DEPTH, bem, info21)
scale = np.abs(target21).max() / np.abs(match21).max()
v = np.abs(target21).max()
for ax, (vals, title) in zip(axes[:2], ((target21, f"one dipole {frac * L5.HEAD_RADIUS_M * 1000:.0f} mm deep"),
                                        (match21 * scale,
                                         f"{best:.0f}-degree superficial patch, rescaled"))):
    im, _ = mne.viz.plot_topomap(vals, info21, axes=ax, show=False, contours=6, sensors=True,
                                 vlim=(-v, v))
    ax.set_title(title, fontsize=9)
cb = fig.colorbar(im, ax=axes[:2], shrink=0.8); cb.set_label("uV")
for k, label in ((1, "21 electrodes"), (4, "61 electrodes")):
    axes[2].plot([r[0] for r in deep_rows], [r[k] for r in deep_rows], "-o", ms=5, label=label)
axes[2].set_xlabel("source depth below the scalp (mm)")
axes[2].set_ylabel("best correlation with a superficial patch (dimensionless)")
axes[2].set_ylim(0.8, 1.005); axes[2].grid(alpha=0.25); axes[2].legend(fontsize=8)
axes[2].set_title("how well a superficial patch can imitate a deep source", fontsize=9)
fig.suptitle("The measurement behind the exercise: depth and spatial extent trade off against each other, "
             "and the scalp cannot separate them", y=1.03, fontsize=10)
plt.show()   # render the static figure(s) of this cell inline
Figure 4 of notebook nb-5-5-inverse, an output plot. The text around it states what it shows and the units of every axis.

6 · The numbers

In [12]:
print("nb-5-5-inverse -- L5.5 numbers (draft; TODO(confirm) at author review)")
print(f"Model: four-shell concentric sphere ({L5.SPHERE_CALLS['four-shell']}), {len(MONTAGE)} electrodes at "
      f"standard_1005 template positions,")
print(f"       {len(rr)} radial sources on a {GRID_SPACING_M * 1000:.0f} mm grid.  NO dataset, NO anatomy, "
      f"NO cortical surface -- see section 0.")
print(f"Simulation: one radial dipole, {MOMENT_nAm:.0f} nA.m at {F_HZ:g} Hz, {N_EPOCHS} epochs, "
      f"{NOISE_UV:g} uV sensor noise, seed {L5.SEED}")
print(f"Regularization: lambda^2 = {LAMBDA2:.4f} (SNR {SNR:g}); noise covariance ad hoc, "
      f"{NOISE_UV:g} uV per electrode")
print()
print(f"ANSWER KEY -- ill-posedness: rank(G) = {rank} with {G.shape[1]} source dimensions, so the null space "
      f"has {G.shape[1] - rank}")
print(f"    dimensions.  A unit-norm null-space source pattern produces {NULL_REL:.1e} of the leadfield's "
      f"largest entry at any")
print(f"    electrode -- nothing.  Largest / smallest singular value of G = {S[0] / S[rank - 1]:.1f}.")
print()
print(f"ANSWER KEY -- five estimators on one simulated source {depth_mm[TRUE]:.0f} mm below the scalp:")
print(f"{'':6s}{'method':>10s} {'peak error (mm)':>17s} {'dispersion (mm)':>17s} {'estimated depth (mm)':>21s}")
for method, err, disp, pd in est_rows:
    print(f"{'':6s}{method:>10s} {err:17.1f} {disp:17.1f} {pd:21.1f}")
print()
print("ANSWER KEY -- resolution over the whole grid (noiseless):")
print(f"{'':6s}{'method':>10s} {'peak error median (mm)':>24s} {'mean':>7s} {'dispersion median (mm)':>24s}")
for method, med, mean_e, disp in res_rows:
    print(f"{'':6s}{method:>10s} {med:24.1f} {mean_e:7.1f} {disp:24.1f}")
print("      sLORETA and eLORETA reach zero median peak error BY CONSTRUCTION.  It is a property of the")
print("      estimator, not evidence about the data, and their dispersion is no better than MNE's.")
print()
print("ANSWER KEY -- depth weighting (minimum-norm, gamma sweep):")
print(f"{'':6s}{'gamma':>7s} {'peak depth, shallow (mm)':>26s} {'peak depth, deep (mm)':>23s} "
      f"{'median peak error (mm)':>24s} {'median dispersion (mm)':>24s}")
for gamma, ds, dd, pe, disp in gamma_rows:
    print(f"{'':6s}{gamma:7.1f} {ds:26.1f} {dd:23.1f} {pe:24.1f} {disp:24.1f}")
print(f"      true depths {depth_mm[TRUE]:.0f} mm and {depth_mm[DEEP]:.0f} mm.  gamma is a choice, not a "
      f"measurement.")
print()
print("ANSWER KEY -- ex-5-5 (which claim is defensible: 'lateral parietal' or 'hippocampal'):")
print("    THE ANSWER IS 'LATERAL PARIETAL', AND THE REASON IS NOT ABOUT PARIETAL CORTEX.")
print("    For every source depth tested, a superficial patch exists whose scalp map is nearly identical:")
print(f"{'':6s}{'source depth (mm)':>19s} {'best r, 21 electrodes':>23s} {'best r, 61 electrodes':>23s} "
      f"{'peak (uV)':>11s}")
for row in deep_rows:
    print(f"{'':6s}{row[0]:19.1f} {row[1]:23.4f} {row[4]:23.4f} {row[3]:11.3f}")
print("    A superficial claim is defensible because a superficial generator is the configuration the data")
print("    ALREADY support -- every estimator above places its peak superficially unless a depth weighting is")
print("    turned up by hand.  A deep claim needs the data to EXCLUDE the superficial alternative, and the")
print("    table shows they cannot: the alternative reproduces the map to r > 0.9 at every depth, on both")
print("    montages.  More electrodes lower the correlation but never far enough.")
print("    A hippocampal claim from scalp EEG needs a constraint from outside the EEG -- an anatomical prior,")
print("    a simultaneous intracranial recording, or a lesion -- and a source map on its own is not one.")
print()
print("WHAT THIS NOTEBOOK CANNOT DO, AND WHY: no cortical surface, no anatomical parcellation and no region")
print("names, because ds-fsaverage and ds-mne-sample both fail the spec 10.7 licence gate (section 0).  Every")
print("location above is a coordinate in a sphere, and the notebook never calls one by an anatomical name.")
print("That is not a workaround: it is the same discipline the exercise is testing.")
print()
print("Pitfall: pf-deep-source-claims.")
print(L5.disk_line("disk at the end"))
print("Downloads in this notebook: none.")
nb-5-5-inverse -- L5.5 numbers (draft; TODO(confirm) at author review)
Model: four-shell concentric sphere (mne.make_sphere_model(r0=(0, 0, 0), head_radius=0.09)), 61 electrodes at standard_1005 template positions,
       1063 radial sources on a 12 mm grid.  NO dataset, NO anatomy, NO cortical surface -- see section 0.
Simulation: one radial dipole, 50 nA.m at 10 Hz, 40 epochs, 1 uV sensor noise, seed 20260918
Regularization: lambda^2 = 0.1111 (SNR 3); noise covariance ad hoc, 1 uV per electrode

ANSWER KEY -- ill-posedness: rank(G) = 61 with 1063 source dimensions, so the null space has 1002
    dimensions.  A unit-norm null-space source pattern produces 3.1e-16 of the leadfield's largest entry at any
    electrode -- nothing.  Largest / smallest singular value of G = 97.7.

ANSWER KEY -- five estimators on one simulated source 24 mm below the scalp:
          method   peak error (mm)   dispersion (mm)  estimated depth (mm)
             MNE              29.4              75.1                  13.9
            dSPM              50.9              60.5                  72.3
         sLORETA               0.0              56.3                  23.5
         eLORETA               0.0              57.0                  23.5
            LCMV               0.0              71.5                  23.5

ANSWER KEY -- resolution over the whole grid (noiseless):
          method   peak error median (mm)    mean   dispersion median (mm)
             MNE                     20.8    25.2                     39.0
            dSPM                     36.0    35.7                     46.3
         sLORETA                      0.0     0.0                     44.3
         eLORETA                      0.0     0.0                     40.7
            LCMV                      0.0     3.3                     68.8
      sLORETA and eLORETA reach zero median peak error BY CONSTRUCTION.  It is a property of the
      estimator, not evidence about the data, and their dispersion is no better than MNE's.

ANSWER KEY -- depth weighting (minimum-norm, gamma sweep):
        gamma   peak depth, shallow (mm)   peak depth, deep (mm)   median peak error (mm)   median dispersion (mm)
          0.0                       13.9                    22.5                     20.8                     38.9
          0.2                       13.9                    22.5                     20.8                     38.9
          0.4                       13.9                    22.5                     20.8                     39.3
          0.6                       13.7                    22.5                     17.0                     40.8
          0.8                       13.7                    22.5                     20.8                     43.5
          1.0                       23.3                    24.6                     24.0                     48.7
          1.5                       23.3                    24.6                     61.2                     62.6
          2.0                       23.3                    24.9                     76.8                     74.1
      true depths 24 mm and 60 mm.  gamma is a choice, not a measurement.

ANSWER KEY -- ex-5-5 (which claim is defensible: 'lateral parietal' or 'hippocampal'):
    THE ANSWER IS 'LATERAL PARIETAL', AND THE REASON IS NOT ABOUT PARIETAL CORTEX.
    For every source depth tested, a superficial patch exists whose scalp map is nearly identical:
        source depth (mm)   best r, 21 electrodes   best r, 61 electrodes   peak (uV)
                     18.0                  0.9998                  0.9996       5.151
                     27.0                  0.9983                  0.9976       4.207
                     36.0                  0.9969                  0.9959       3.446
                     45.0                  0.9954                  0.9942       2.867
                     54.0                  0.9937                  0.9927       2.424
                     63.0                  0.9918                  0.9908       2.080
    A superficial claim is defensible because a superficial generator is the configuration the data
    ALREADY support -- every estimator above places its peak superficially unless a depth weighting is
    turned up by hand.  A deep claim needs the data to EXCLUDE the superficial alternative, and the
    table shows they cannot: the alternative reproduces the map to r > 0.9 at every depth, on both
    montages.  More electrodes lower the correlation but never far enough.
    A hippocampal claim from scalp EEG needs a constraint from outside the EEG -- an anatomical prior,
    a simultaneous intracranial recording, or a lesion -- and a source map on its own is not one.

WHAT THIS NOTEBOOK CANNOT DO, AND WHY: no cortical surface, no anatomical parcellation and no region
names, because ds-fsaverage and ds-mne-sample both fail the spec 10.7 licence gate (section 0).  Every
location above is a coordinate in a sphere, and the notebook never calls one by an anatomical name.
That is not a workaround: it is the same discipline the exercise is testing.

Pitfall: pf-deep-source-claims.
disk at the end: 4.37 GB free on the working volume
Downloads in this notebook: none.