Channel locations and bad channels: pyprep NoisyChannels against a stated manual rule, interpolation, and what it costs the average reference and the rank

nb-2-2-bad-channels Level 2 · Preprocessing as a Pipeline ~4 min Used in L2.2 · Channel locations and bad channels

Downloads from ds-erpcore 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-2-2-bad-channels · Channel locations and bad channels (L2.2)

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

Five steps:

  1. Verify the montage first, because every bad-channel criterion except "flat" is spatial and is meaningless if the positions are wrong.
  2. Flag by hand with a stated, reproducible rule (robust z of the channel amplitude, absolute excursions, flatness) — the pass a person makes from the traces, written down so it can be compared.
  3. Flag with pyprep's NoisyChannels, criterion by criterion, and see where the two passes agree and disagree.
  4. Interpolate, and measure what interpolation does to the average reference (a bad channel in the average contaminates every channel) and to the rank of the data — the number L2.6's ICA has to be told.
  5. Bridging: the failure that looks like success, detected with the opposite test.

Data. ds-erpcore P3 (CC BY 4.0, open, per-subject downloadable; Biosemi ActiveTwo, 30 EEG + 3 EOG, 10-20 placement, 1024 Hz, CMS reference, 60 Hz mains, no software filters, 40 participants). Subjects sub-001, sub-002, sub-003 — the same three the site's w-ica-component-gallery uses, so the decompositions there and the flags here describe the same recordings. About 56 MB per subject; already-downloaded subjects are re-used. TODO(confirm): the author mirrors the ERP CORE entry into the catalog registry and signs off the dataset page (§10.11 item 8).

pyprep is a small pure-Python package (pip install pyprep) implementing the PREP pipeline's channel criteria; it is installed by the first cell if missing.

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', 'pyprep')
_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"]
    if "pyprep" in _missing:
        _cmd += ["pyprep>=0.9"]
    subprocess.check_call(_cmd)

# 2. Shared helpers (notebooks/_shared/helpers.py and helpers_l2.py), 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_l2.py").exists()), None)
if _shared is None:
    raise FileNotFoundError("start the kernel in notebooks/L2/ (or notebooks/) so that _shared/helpers_l2.py is found")
sys.path.insert(0, str(_shared))
import helpers
import helpers_l2 as l2

# 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 plt.show() renders each cell's
#    figures in place.
import matplotlib.pyplot as plt
import numpy as np
import mne

# Warnings are worth reading, so they are not silenced -- but their default format prints the
# absolute path of the file that raised them, which is nobody else's business and would put this
# machine's directory layout into the saved outputs.  Only the class and the message are shown.
warnings.formatwarning = lambda message, category, *a, **k: f"{category.__name__}: {message}\n"

mne.viz.set_browser_backend("matplotlib", verbose=False)
mne.set_log_level("WARNING")
plt.rcParams["figure.dpi"] = 72
print(f"MNE {mne.__version__}; helpers imported from notebooks/_shared")
print("ERP CORE downloads (~56 MB per subject) are cached under data/downloads/erpcore/ in a repository "
      "clone, otherwise under MNE's data directory; nothing is re-fetched.")
MNE 1.10.2; helpers imported from notebooks/_shared
ERP CORE downloads (~56 MB per subject) are cached under data/downloads/erpcore/ in a repository clone, otherwise under MNE's data directory; nothing is re-fetched.

1. Load three subjects and verify the montage

The four montage checks of L2.2, run as code: every EEG channel has a position, no two channels share one, none sits outside the head, and the layout looks like a head with the nose where it belongs.

The data are resampled to 256 Hz on load. That is a filter decision (MNE applies its anti-alias low-pass), it is recorded, and it costs nothing this lesson uses: 1024 Hz would make every step four times slower for content above 128 Hz that no ERP analysis here touches (L2.4).

In [2]:
SUBJECTS = ["sub-001", "sub-002", "sub-003"]
RESAMPLE_HZ = 256.0

raws, facts = {}, {}
for sid in SUBJECTS:
    raws[sid], facts[sid] = l2.load_erpcore("P3", sid, resample_hz=RESAMPLE_HZ, verbose=True)

f0 = facts[SUBJECTS[0]]
print(f"\nfrom the dataset's own sidecar: {f0['eeg_json']}")
print(f"EEG channels ({len(f0['eeg_channels'])}): {f0['eeg_channels']}")
print(f"EOG channels typed: {f0['eog_channels']}; units column: {f0['units']}")
sub-001: 467 s, 30 EEG + 3 EOG at 256 Hz (file 1024 Hz), reference CMS
sub-002: 405 s, 30 EEG + 3 EOG at 256 Hz (file 1024 Hz), reference CMS
sub-003: 377 s, 30 EEG + 3 EOG at 256 Hz (file 1024 Hz), reference CMS

from the dataset's own sidecar: {'TaskName': 'P3', 'Manufacturer': 'Biosemi', 'ManufacturersModelName': 'ActiveTwo', 'EEGReference': 'CMS', 'SamplingFrequency': 1024, 'PowerLineFrequency': 60, 'SoftwareFilters': 'n/a', 'EEGPlacementScheme': '10-20', 'EEGChannelCount': 30, 'EOGChannelCount': 3, 'RecordingDuration': 467}
EEG channels (30): ['FP1', 'F3', 'F7', 'FC3', 'C3', 'C5', 'P3', 'P7', 'P9', 'PO7', 'PO3', 'O1', 'Oz', 'Pz', 'CPz', 'FP2', 'Fz', 'F4', 'F8', 'FC4', 'FCz', 'Cz', 'C4', 'C6', 'P4', 'P8', 'P10', 'PO8', 'PO4', 'O2']
EOG channels typed: ['HEOG_left', 'HEOG_right', 'VEOG_lower']; units column: ['microV']
In [3]:
raw0 = raws[SUBJECTS[0]]
eeg_names = [raw0.ch_names[i] for i in mne.pick_types(raw0.info, eeg=True)]
pos = raw0.get_montage().get_positions()["ch_pos"]
xyz = np.array([pos[c] for c in eeg_names])

no_position = [c for c in eeg_names if c not in pos or not np.isfinite(pos[c]).all()]
d = np.linalg.norm(xyz[:, None, :] - xyz[None, :, :], axis=-1)
np.fill_diagonal(d, np.inf)
duplicates = [(eeg_names[i], eeg_names[j]) for i, j in zip(*np.where(d < 1e-6)) if i < j]
radius = np.linalg.norm(xyz, axis=1)

print(f"montage checks for {SUBJECTS[0]} ({len(eeg_names)} EEG channels, {facts[SUBJECTS[0]]['montage']}):")
print(f"  channels without a position:            {no_position or 'none'}")
print(f"  channel pairs sharing a position:       {duplicates or 'none'}")
print(f"  radius from the head centre (m):        min {radius.min():.3f}, max {radius.max():.3f} "
      f"(a template head is a sphere of about 0.095 m)")
print(f"  closest neighbour distance (m):         min {d.min():.3f} ({eeg_names[int(np.unravel_index(d.argmin(), d.shape)[0])]}"
      f"-{eeg_names[int(np.unravel_index(d.argmin(), d.shape)[1])]}), "
      f"median of each channel's nearest neighbour {np.median(d.min(axis=1)):.3f}")
print(f"  channels the montage did not know:      {facts[SUBJECTS[0]]['montage_missing'] or 'none (the 3 EOG channels are typed eog, not eeg)'}")
print("\nThat median nearest-neighbour distance is the fact behind everything below: with 30 electrodes over "
      "the whole head, a channel's nearest neighbour is several centimetres away, so neighbouring channels "
      "are genuinely less correlated than they would be on a 64- or 128-channel cap.")

fig, axes = plt.subplots(1, 2, figsize=(11, 4.4), gridspec_kw=dict(width_ratios=[1, 1.5]))
raw0.plot_sensors(show_names=True, axes=axes[0], show=False)
axes[0].set_title(f"{SUBJECTS[0]}: 30 EEG positions, nose up (standard_1005)")
helpers.plot_traces(raw0, ["Fp1", "Fz", "Cz", "Pz", "P9", "P10", "O1", "Oz"], t0=60, duration=10,
                    spacing_uV=150, ax=axes[1], title=f"{SUBJECTS[0]}, 10 s from 60 s (uV)")
fig.tight_layout()
plt.show()   # render the static figure(s) of this cell inline
montage checks for sub-001 (30 EEG channels, standard_1005):
  channels without a position:            none
  channel pairs sharing a position:       none
  radius from the head centre (m):        min 0.085, max 0.143 (a template head is a sphere of about 0.095 m)
  closest neighbour distance (m):         min 0.030 (PO7-O1), median of each channel's nearest neighbour 0.036
  channels the montage did not know:      none (the 3 EOG channels are typed eog, not eeg)

That median nearest-neighbour distance is the fact behind everything below: with 30 electrodes over the whole head, a channel's nearest neighbour is several centimetres away, so neighbouring channels are genuinely less correlated than they would be on a 64- or 128-channel cap.
Figure 1 of notebook nb-2-2-bad-channels, an output plot. The text around it states what it shows and the units of every axis.

2. The manual pass, written down before it is run

"It looked noisy" is not an answer (L2.2). So the by-eye pass is stated as a rule with numbers, applied identically to every subject, and run on a 1 Hz high-passed copy so that drift does not dominate the amplitude statistic:

  • amplitude: robust z of the channel's standard deviation, z = (sd − median(sd)) / (1.4826 · MAD(sd)), flagged at |z| > 3.5;
  • excursion: the 99.9th percentile of |x| above 250 µV;
  • flat: standard deviation below 0.5 µV.

The thresholds are conventions. They are written here so that they can be argued with, which is the only property that matters.

In [4]:
MANUAL = dict(z_sd=3.5, excursion_uv=250.0, flat_uv=0.5, highpass_hz=1.0)


def manual_flags(raw, *, z_sd=MANUAL["z_sd"], excursion_uv=MANUAL["excursion_uv"],
                 flat_uv=MANUAL["flat_uv"], highpass_hz=MANUAL["highpass_hz"]):
    """The stated by-eye rule, as code. Returns {criterion: [channels]} plus the per-channel numbers."""
    r = raw.copy().pick("eeg").filter(highpass_hz, None, verbose=False)
    x = r.get_data() * 1e6
    names = r.ch_names
    sd = x.std(axis=1)
    exc = np.percentile(np.abs(x), 99.9, axis=1)
    mad = 1.4826 * np.median(np.abs(sd - np.median(sd)))
    z = (sd - np.median(sd)) / mad if mad > 0 else np.zeros_like(sd)
    flags = {
        "manual_amplitude": [names[i] for i in np.where(np.abs(z) > z_sd)[0]],
        "manual_excursion": [names[i] for i in np.where(exc > excursion_uv)[0]],
        "manual_flat": [names[i] for i in np.where(sd < flat_uv)[0]],
    }
    metrics = {names[i]: dict(std_uv=float(sd[i]), robust_z=float(z[i]), p999_abs_uv=float(exc[i]))
               for i in range(len(names))}
    return flags, metrics


manual, metrics = {}, {}
for sid in SUBJECTS:
    manual[sid], metrics[sid] = manual_flags(raws[sid])
    flagged = sorted({c for v in manual[sid].values() for c in v})
    print(f"{sid}: manual flags {flagged or 'none'}  " +
          "  ".join(f"{k.replace('manual_', '')}={v}" for k, v in manual[sid].items() if v))

sid = SUBJECTS[0]
order = sorted(metrics[sid], key=lambda c: -metrics[sid][c]["std_uv"])
fig, axes = plt.subplots(1, 2, figsize=(13, 4))
axes[0].bar(range(len(order)), [metrics[sid][c]["std_uv"] for c in order], color="tab:blue")
axes[0].axhline(np.median([m["std_uv"] for m in metrics[sid].values()]), color="k", lw=0.8, label="median")
axes[0].set(xticks=range(len(order)), ylabel="standard deviation (uV)",
            title=f"{sid}: per-channel amplitude after a 1 Hz high-pass (uV)")
axes[0].set_xticklabels(order, rotation=90, fontsize=7)
axes[0].legend(fontsize=8)
axes[0].grid(alpha=0.3, axis="y")
zs = np.array([metrics[sid][c]["robust_z"] for c in order])
axes[1].bar(range(len(order)), zs, color=["tab:orange" if abs(v) > MANUAL["z_sd"] else "0.6" for v in zs])
axes[1].axhline(MANUAL["z_sd"], color="tab:red", lw=0.8)
axes[1].axhline(-MANUAL["z_sd"], color="tab:red", lw=0.8)
axes[1].set(xticks=range(len(order)), ylabel="robust z of the standard deviation (dimensionless)",
            title=f"{sid}: robust z, flagged above |z| = {MANUAL['z_sd']:g}")
axes[1].set_xticklabels(order, rotation=90, fontsize=7)
axes[1].grid(alpha=0.3, axis="y")
fig.tight_layout()
plt.show()   # render the static figure(s) of this cell inline
sub-001: manual flags ['F8', 'Fp1', 'Fp2']  amplitude=['Fp1', 'Fp2', 'F8']  excursion=['Fp1', 'Fp2']
sub-002: manual flags none  
sub-003: manual flags ['F7', 'Fp1', 'Fp2']  amplitude=['Fp1', 'Fp2']  excursion=['Fp1', 'F7', 'Fp2']
Figure 2 of notebook nb-2-2-bad-channels, an output plot. The text around it states what it shows and the units of every axis.

3. pyprep's NoisyChannels, criterion by criterion

The same six criteria the lesson tabulates — flat, deviation, high-frequency noise, low neighbour correlation, dropout, RANSAC predictability (plus pyprep's spectral-outlier criterion) — with pyprep's own default thresholds.

Detection runs on data that has been high-passed but not low-passed. That ordering is the argument of L2.8 step 3: drift dominates the deviation criterion if you leave it in, while a low-pass deletes the evidence the high-frequency-noise criterion is looking for. The second table shows what happens if you ignore that and detect on 0.1–30 Hz data instead.

In [5]:
import time

detection, detection_after_lowpass = {}, {}
t0 = time.time()
for sid in SUBJECTS:
    detection[sid] = l2.detect_bad_channels(raws[sid], highpass_hz=1.0, ransac=True, seed=l2.SEED)
    lp = raws[sid].copy().filter(0.1, 30.0, picks=["eeg", "eog"], verbose=False)
    detection_after_lowpass[sid] = l2.detect_bad_channels(lp, highpass_hz=None, ransac=True, seed=l2.SEED)
print(f"pyprep NoisyChannels on {len(SUBJECTS)} subjects, twice each: {time.time() - t0:.0f} s\n")

for sid in SUBJECTS:
    print(f"{sid} -- detection on the 1 Hz high-passed copy (the pipeline's setting)")
    for crit, chs in detection[sid]["flags"].items():
        print(f"    {crit:22s} {l2.BAD_CRITERIA.get(crit, ''):.58s}\n        -> {chs}")
    print(f"    channels flagged by more than one criterion: "
          f"{[c for c, cr in detection[sid]['by_channel'].items() if len(cr) > 1] or 'none'}")
    print(f"{sid} -- detection after a 0.1-30 Hz band-pass (the wrong order)")
    print(f"    flagged: {detection_after_lowpass[sid]['all_flagged'] or 'none'}  "
          f"(criteria that fired: {sorted(detection_after_lowpass[sid]['flags'])})")
    print()
print("The high-frequency-noise criterion cannot fire once a 30 Hz low-pass has removed the evidence, and the "
      "neighbour-correlation criterion looks much better on smoothed data. Detect first, then filter (L2.8).")
pyprep NoisyChannels on 3 subjects, twice each: 41 s

sub-001 -- detection on the 1 Hz high-passed copy (the pipeline's setting)
    bad_by_deviation       robust z-score of the channel's amplitude against all chan
        -> ['F8']
    bad_by_correlation     maximum correlation with other channels in short windows
        -> ['P3', 'PO7', 'PO3', 'O1', 'F8']
    bad_by_psd             outlying power spectral density relative to the other chan
        -> ['Fp2', 'F8']
    channels flagged by more than one criterion: ['F8']
sub-001 -- detection after a 0.1-30 Hz band-pass (the wrong order)
    flagged: ['F8', 'Fp1', 'Fp2', 'O1', 'P3', 'PO3', 'PO7']  (criteria that fired: ['bad_by_SNR', 'bad_by_correlation', 'bad_by_deviation', 'bad_by_hf_noise', 'bad_by_psd'])

sub-002 -- detection on the 1 Hz high-passed copy (the pipeline's setting)
    bad_by_hf_noise        power above the signal band relative to power inside it
        -> ['PO7', 'O1']
    bad_by_correlation     maximum correlation with other channels in short windows
        -> ['P3', 'PO3']
    channels flagged by more than one criterion: none
sub-002 -- detection after a 0.1-30 Hz band-pass (the wrong order)
    flagged: ['P3', 'PO3']  (criteria that fired: ['bad_by_correlation'])

sub-003 -- detection on the 1 Hz high-passed copy (the pipeline's setting)
    channels flagged by more than one criterion: none
sub-003 -- detection after a 0.1-30 Hz band-pass (the wrong order)
    flagged: ['Fp1', 'Fp2']  (criteria that fired: ['bad_by_hf_noise'])

The high-frequency-noise criterion cannot fire once a 30 Hz low-pass has removed the evidence, and the neighbour-correlation criterion looks much better on smoothed data. Detect first, then filter (L2.8).

4. Where the two passes agree

Agreement is scored per channel, so a false alarm costs the same as a miss — the same rule the w-bad-channel-detective drill uses.

In [6]:
rows = []
for sid in SUBJECTS:
    man = {c for v in manual[sid].values() for c in v}
    auto = set(detection[sid]["all_flagged"])
    names = set(m for m in metrics[sid])
    agree = len(names - (man ^ auto))
    rows.append({"subject": sid, "manual": sorted(man) or ["-"], "pyprep": sorted(auto) or ["-"],
                 "both": sorted(man & auto) or ["-"], "pyprep only": sorted(auto - man) or ["-"],
                 "manual only": sorted(man - auto) or ["-"],
                 "per-channel agreement": f"{100 * agree / len(names):.0f} %"})
print(l2.fmt_table(rows, ["subject", "manual", "pyprep", "both", "pyprep only", "manual only",
                          "per-channel agreement"]))
print()
print("The disagreements are informative, not embarrassing, and they run in both directions.")
print("  - The manual rule flags Fp1/Fp2 on two subjects. Those channels are not bad: they are frontal, and "
      "blinks are enormous there, so an amplitude rule measures the participant rather than the electrode. "
      "That is a false alarm no spatial criterion makes, and it is a reason not to interpolate on amplitude alone.")
print("  - pyprep flags posterior channels (P3, PO3, PO7, O1) that the amplitude rule finds unremarkable. Some "
      "of that is real -- an electrode recording something the rest of the head is not -- and some of it is the "
      "montage: section 1 measured a median nearest-neighbour distance of several centimetres, so a correlation "
      "cutoff tuned on denser caps fires more often here.")
print("  - Neither pass can see a channel that is clean but wrong (bridging, section 8).")
subject  manual        pyprep                     both     pyprep only       manual only   per-channel agreement
-------  ------------  -------------------------  -------  ----------------  ------------  ---------------------
sub-001  F8, Fp1, Fp2  F8, Fp2, O1, P3, PO3, PO7  F8, Fp2  O1, P3, PO3, PO7  Fp1           83 %                 
sub-002  -             O1, P3, PO3, PO7           -        O1, P3, PO3, PO7  -             87 %                 
sub-003  F7, Fp1, Fp2  -                          -        -                 F7, Fp1, Fp2  90 %                 

The disagreements are informative, not embarrassing, and they run in both directions.
  - The manual rule flags Fp1/Fp2 on two subjects. Those channels are not bad: they are frontal, and blinks are enormous there, so an amplitude rule measures the participant rather than the electrode. That is a false alarm no spatial criterion makes, and it is a reason not to interpolate on amplitude alone.
  - pyprep flags posterior channels (P3, PO3, PO7, O1) that the amplitude rule finds unremarkable. Some of that is real -- an electrode recording something the rest of the head is not -- and some of it is the montage: section 1 measured a median nearest-neighbour distance of several centimetres, so a correlation cutoff tuned on denser caps fires more often here.
  - Neither pass can see a channel that is clean but wrong (bridging, section 8).

5. The interpolation decision, stated before the data were seen

Three parts, exactly as the lesson sets them out:

  1. A fraction. At most 10 % of the montage — 3 of 30 channels — may be interpolated. A fraction, not a count, so it means the same thing on a 30-channel and a 128-channel cap.
  2. Evidence. A channel is interpolated when at least two criteria flagged it. The reason is the nearest-neighbour distance measured in section 1: on this montage the correlation criterion alone flags channels that are not bad.
  3. A spatial and region-of-interest clause. The number of contiguous flagged channels is reported, and interpolating the measurement channel itself (Pz for the P3, L2.3) is called out rather than done silently.

A subject over the cap is flagged for review; it is not silently trimmed. That is where the subject-exclusion rule comes from.

In [7]:
decisions = {}
for sid in SUBJECTS:
    n_eeg = len(mne.pick_types(raws[sid].info, eeg=True))
    dec = l2.select_bads(detection[sid], n_eeg, min_criteria=2, max_fraction=0.10)
    # The spatial clause: how close are the flagged channels to one another?
    p = raws[sid].get_montage().get_positions()["ch_pos"]
    fl = detection[sid]["all_flagged"]
    contiguous = 0
    if len(fl) > 1:
        dd = np.array([[np.linalg.norm(p[a] - p[b]) for b in fl] for a in fl])
        np.fill_diagonal(dd, np.inf)
        contiguous = int((dd.min(axis=1) < 0.045).sum())     # within ~4.5 cm: adjacent on this montage
    dec["contiguous_flagged"] = contiguous
    dec["measurement_channel_flagged"] = l2.P3_CHANNEL in fl
    decisions[sid] = dec
    print(f"{sid}: flagged {len(fl):2d} -> interpolate {dec['bads'] or 'none'} "
          f"({len(dec['bads'])}/{n_eeg} = {len(dec['bads']) / n_eeg:.0%}, cap {dec['cap']}); "
          f"kept despite a flag: {dec['flagged_not_interpolated'] or 'none'}")
    print(f"    spatial clause: {contiguous} of the flagged channels have another flagged channel within 4.5 cm")
    print(f"    ROI clause:     the measurement channel {l2.P3_CHANNEL} "
          f"{'IS flagged -- this needs a stated decision' if dec['measurement_channel_flagged'] else 'is not flagged'}")
    print(f"    over the cap:   {dec['over_cap']}")
print(f"\nRule in force: {decisions[SUBJECTS[0]]['rule']}")
sub-001: flagged  6 -> interpolate ['F8'] (1/30 = 3%, cap 3); kept despite a flag: ['Fp2', 'O1', 'P3', 'PO3', 'PO7']
    spatial clause: 4 of the flagged channels have another flagged channel within 4.5 cm
    ROI clause:     the measurement channel Pz is not flagged
    over the cap:   False
sub-002: flagged  4 -> interpolate none (0/30 = 0%, cap 3); kept despite a flag: ['O1', 'P3', 'PO3', 'PO7']
    spatial clause: 4 of the flagged channels have another flagged channel within 4.5 cm
    ROI clause:     the measurement channel Pz is not flagged
    over the cap:   False
sub-003: flagged  0 -> interpolate none (0/30 = 0%, cap 3); kept despite a flag: none
    spatial clause: 0 of the flagged channels have another flagged channel within 4.5 cm
    ROI clause:     the measurement channel Pz is not flagged
    over the cap:   False

Rule in force: interpolate a channel flagged by >= 2 criteria, at most 10% of the montage (3 of 30 channels)

6. What a bad channel does to an average reference, and what interpolation costs

The lesson's claim is that a bad channel included in an average reference contaminates every channel. That is measurable: re-reference one subject three ways and compare the same good channel.

  • (a) average over all 30 channels, bad channel included — what a script that never looked does;
  • (b) average over the good channels only, the bad channel left out;
  • (c) interpolate first, then average over all 30 — the pipeline's order (L2.8 step 5 then 6).

The difference between (a) and (b) at a good channel is the contamination, in microvolts, that one electrode spread over the whole head.

In [8]:
# Use the subject with the most convincing flag; if none has one, use the first.
SID_DEMO = next((s for s in SUBJECTS if decisions[s]["bads"]), SUBJECTS[0])
BAD = decisions[SID_DEMO]["bads"][0] if decisions[SID_DEMO]["bads"] else detection[SID_DEMO]["all_flagged"][0]
print(f"demonstration subject {SID_DEMO}, bad channel {BAD} "
      f"(criteria: {detection[SID_DEMO]['by_channel'][BAD]})")

base = raws[SID_DEMO].copy().filter(0.1, 30.0, picks=["eeg", "eog"], verbose=False)
eeg = [base.ch_names[i] for i in mne.pick_types(base.info, eeg=True)]
good = [c for c in eeg if c != BAD]

a = base.copy().set_eeg_reference("average", verbose=False)
b = base.copy().set_eeg_reference(good, verbose=False)
c = base.copy()
c.info["bads"] = [BAD]
c.interpolate_bads(reset_bads=True, verbose=False)
c.set_eeg_reference("average", verbose=False)

probe = l2.P3_CHANNEL if l2.P3_CHANNEL != BAD else "Cz"
xa, xb, xc = (r.get_data(picks=[probe])[0] * 1e6 for r in (a, b, c))
print(f"\nat the good channel {probe}, over the whole recording:")
print(f"  (a) bad channel in the average   vs (b) bad channel excluded: "
      f"RMS difference {np.sqrt(((xa - xb) ** 2).mean()):6.2f} uV, max |difference| {np.abs(xa - xb).max():7.2f} uV")
print(f"  (c) interpolated, then averaged  vs (b) bad channel excluded: "
      f"RMS difference {np.sqrt(((xc - xb) ** 2).mean()):6.2f} uV, max |difference| {np.abs(xc - xb).max():7.2f} uV")
print(f"  for scale, {probe}'s own RMS in (b) is {np.sqrt((xb ** 2).mean()):.2f} uV")

t0 = 60.0
sl = slice(int(t0 * base.info["sfreq"]), int((t0 + 8) * base.info["sfreq"]))
t = base.times[sl]
fig, axes = plt.subplots(2, 1, figsize=(12, 6.5), sharex=True)
axes[0].plot(t, base.get_data(picks=[BAD])[0][sl] * 1e6, "k", lw=0.7, label=f"{BAD} (flagged)")
axes[0].plot(t, base.get_data(picks=[probe])[0][sl] * 1e6, color="tab:blue", lw=0.7, label=f"{probe} (good)")
axes[0].set(ylabel="Amplitude (uV)", title=f"{SID_DEMO}: the flagged channel next to a good one, recorded reference (uV)")
axes[0].legend(fontsize=8); axes[0].grid(alpha=0.3)
axes[1].plot(t, xb[sl], color="0.55", lw=1.1, label="(b) average over the good channels")
axes[1].plot(t, xa[sl], color="tab:red", lw=0.9, label="(a) average including the flagged channel")
axes[1].plot(t, xc[sl], color="tab:green", lw=0.9, ls="--", label="(c) interpolate, then average")
axes[1].set(xlabel="Time (s)", ylabel="Amplitude (uV)",
            title=f"{probe} under three references (uV, positive up)")
axes[1].legend(fontsize=8); axes[1].grid(alpha=0.3)
fig.tight_layout()
plt.show()   # render the static figure(s) of this cell inline
demonstration subject sub-001, bad channel F8 (criteria: ['bad_by_correlation', 'bad_by_deviation', 'bad_by_psd'])
at the good channel Pz, over the whole recording:
  (a) bad channel in the average   vs (b) bad channel excluded: RMS difference   1.36 uV, max |difference|    9.71 uV
  (c) interpolated, then averaged  vs (b) bad channel excluded: RMS difference   2.82 uV, max |difference|   17.50 uV
  for scale, Pz's own RMS in (b) is 16.53 uV
Figure 3 of notebook nb-2-2-bad-channels, an output plot. The text around it states what it shows and the units of every axis.

7. Rank: the number ICA has to be told

Nothing in the array's shape records how many independent dimensions are left. Three numbers are compared below, and the interesting result is that they do not all agree: the number of rows in the array, the numerical rank of the data, and what mne.compute_rank(..., rank='info') infers from the metadata. rank='info' counts channels and projectors — it has no way to know that a channel was interpolated or that an average reference was applied by hand, so it happily reports 30. The numerical rank does not. Carry the arithmetic yourself and pass it.

In [9]:
def rank_of(r, label):
    est = mne.compute_rank(r, rank="info", verbose=False)["eeg"]
    data_rank = int(np.linalg.matrix_rank(r.get_data(picks="eeg"), tol=1e-6 * float(np.abs(r.get_data(picks="eeg")).max())))
    return {"stage": label, "n_eeg_rows": len(mne.pick_types(r.info, eeg=True)),
            "rank (compute_rank, 'info')": est, "rank (numerical, from the data)": data_rank}


n_eeg = len(mne.pick_types(base.info, eeg=True))
stages = [rank_of(base, "as loaded (recorded CMS reference)")]
stages.append(rank_of(c.copy(), f"1 channel interpolated + average reference"))
print(l2.fmt_table(stages))
print()
for sid in SUBJECTS:
    k = len(decisions[sid]["bads"])
    before = l2.data_rank(n_eeg)
    after = l2.data_rank(n_eeg, n_interpolated=k, average_reference=True)
    print(f"{sid}: rank before {before['rank']}  ->  after {after['rank']}   ({after['arithmetic']})")
print()
print(f"The arithmetic ({l2.data_rank(n_eeg, n_interpolated=1, average_reference=True)['arithmetic']}) matches "
      f"the numerical rank of the data ({stages[1]['rank (numerical, from the data)']}) and not what "
      f"compute_rank(rank='info') reports ({stages[1][chr(114) + 'ank (compute_rank, ' + chr(39) + 'info' + chr(39) + ')']}): "
      "the Info object records channels and projectors, not the fact that one channel is now a linear "
      "combination of the others and that the rows sum to zero.")
print("The array keeps 30 rows throughout, so nothing warns you. A script that asks for 30 ICA components from "
      "this data is asking for dimensions that no longer exist (pf-interpolation-rank, L2.6). Pass the number: "
      "ICA(n_components=rank).")
stage                                       n_eeg_rows  rank (compute_rank, 'info')  rank (numerical, from the data)
------------------------------------------  ----------  ---------------------------  -------------------------------
as loaded (recorded CMS reference)          30          30                           30                             
1 channel interpolated + average reference  30          30                           28                             

sub-001: rank before 30  ->  after 28   (30 channels - 1 interpolated - 1 average reference = rank 28)
sub-002: rank before 30  ->  after 29   (30 channels - 1 average reference = rank 29)
sub-003: rank before 30  ->  after 29   (30 channels - 1 average reference = rank 29)

The arithmetic (30 channels - 1 interpolated - 1 average reference = rank 28) matches the numerical rank of the data (28) and not what compute_rank(rank='info') reports (30): the Info object records channels and projectors, not the fact that one channel is now a linear combination of the others and that the rows sum to zero.
The array keeps 30 rows throughout, so nothing warns you. A script that asks for 30 ICA components from this data is asking for dimensions that no longer exist (pf-interpolation-rank, L2.6). Pass the number: ICA(n_components=rank).

8. Bridging: the failure that looks like success

If gel bridges two neighbouring electrodes they record nearly the same signal. Every "bad" criterion says they are fine — the neighbour correlation is higher than average, not lower — so the detection is the opposite test: near-unity correlation between two neighbours together with a near-zero variance of their difference. The electrical-distance statistic below is the variance of the difference between two channels, normalised by the median over all pairs; a bridged pair sits near zero.

In [10]:
def bridge_check(raw, *, corr_min=0.99, ed_max=0.05, n_report=3):
    r = raw.copy().pick("eeg").filter(1.0, 40.0, verbose=False)
    x = r.get_data() * 1e6
    names = r.ch_names
    corr = np.corrcoef(x)
    ed = np.var(x[:, None, :] - x[None, :, :], axis=-1)      # electrical distance (uV^2)
    med = np.median(ed[np.triu_indices_from(ed, 1)])
    ed_n = ed / med
    iu = np.triu_indices_from(corr, 1)
    order = np.argsort(ed_n[iu])
    closest = [{"pair": f"{names[iu[0][k]]}-{names[iu[1][k]]}", "r": float(corr[iu][k]),
                "ed_over_median": float(ed_n[iu][k]),
                "bridged": bool(corr[iu][k] > corr_min and ed_n[iu][k] < ed_max)}
               for k in order[:n_report]]
    n_bridged = int(((corr[iu] > corr_min) & (ed_n[iu] < ed_max)).sum())
    return closest, n_bridged, float(np.median(corr[iu])), float(np.max(corr[iu])), med


BRIDGE_RULE = "r > 0.99 AND electrical distance < 5 % of the median over all pairs"
total_bridged = 0
for sid in SUBJECTS:
    closest, n_bridged, med_r, max_r, med_ed = bridge_check(raws[sid])
    total_bridged += n_bridged
    print(f"{sid}: median pair correlation {med_r:+.2f}, largest {max_r:+.2f}, median electrical distance "
          f"{med_ed:.0f} uV^2; pairs meeting the rule: {n_bridged}")
    print("    the three electrically closest pairs: " +
          ", ".join(f"{p['pair']} (r {p['r']:+.2f}, ED {100 * p['ed_over_median']:.0f} % of median"
                    f"{', BRIDGED' if p['bridged'] else ''})" for p in closest))
print(f"\nRule used here: {BRIDGE_RULE}. It is a screening rule chosen for this notebook, not a published "
      "threshold -- TODO(confirm) against a bridging-detection reference before quoting it.")
print(f"Pairs meeting it across the three recordings: {total_bridged}. " +
      ("None, which is the expected result for a well-run lab recording."
       if total_bridged == 0 else
       "Each is a neighbouring pair and needs a person to look before anything is done about it."))
print("Note which way the numbers point: the closest pairs are all physically adjacent electrodes, where a high "
      "correlation is exactly what should happen. That is why bridging cannot be caught by a correlation "
      "threshold alone, why no 'bad channel' criterion will ever fire on it, and why the real fix is at the cap "
      "before recording (pf-bridged-electrodes).")
sub-001: median pair correlation +0.42, largest +0.97, median electrical distance 225 uV^2; pairs meeting the rule: 0
    the three electrically closest pairs: FC3-FCz (r +0.96, ED 7 % of median), F3-FC3 (r +0.97, ED 10 % of median), FCz-Cz (r +0.95, ED 11 % of median)
sub-002: median pair correlation +0.48, largest +0.97, median electrical distance 141 uV^2; pairs meeting the rule: 0
    the three electrically closest pairs: P8-PO8 (r +0.94, ED 9 % of median), PO8-O2 (r +0.94, ED 10 % of median), O1-Oz (r +0.85, ED 11 % of median)
sub-003: median pair correlation +0.35, largest +0.98, median electrical distance 358 uV^2; pairs meeting the rule: 0
    the three electrically closest pairs: P7-PO7 (r +0.93, ED 5 % of median), PO8-O2 (r +0.93, ED 6 % of median), P8-PO8 (r +0.93, ED 6 % of median)

Rule used here: r > 0.99 AND electrical distance < 5 % of the median over all pairs. It is a screening rule chosen for this notebook, not a published threshold -- TODO(confirm) against a bridging-detection reference before quoting it.
Pairs meeting it across the three recordings: 0. None, which is the expected result for a well-run lab recording.
Note which way the numbers point: the closest pairs are all physically adjacent electrodes, where a high correlation is exactly what should happen. That is why bridging cannot be caught by a correlation threshold alone, why no 'bad channel' criterion will ever fire on it, and why the real fix is at the cap before recording (pf-bridged-electrodes).

9. The numbers

In [11]:
print("nb-2-2-bad-channels -- L2.2 numbers (draft; TODO(confirm) at author review)")
print(f"Data: ds-erpcore P3, {', '.join(SUBJECTS)} (CC BY-SA 4.0 per data/directory.yaml, contested at source; open; per-subject "
      f"downloadable). 30 EEG + 3 EOG, 1024 Hz resampled to {RESAMPLE_HZ:g} Hz, CMS reference, no software filters.")
print(f"Detector: pyprep NoisyChannels.find_all_bads(ransac=True) on a 1 Hz high-passed, not low-passed copy, "
      f"random_state {l2.SEED}. Manual rule: {MANUAL}.")
print(f"Interpolation rule: {decisions[SUBJECTS[0]]['rule']}.")
print()
print("Flagged channels per subject, per criterion:")
for sid in SUBJECTS:
    print(f"  {sid}")
    for crit, chs in detection[sid]["flags"].items():
        print(f"      {crit:22s} {chs}")
    print(f"      {'manual (stated rule)':22s} {sorted({c for v in manual[sid].values() for c in v}) or []}")
    print(f"      {'-> interpolated':22s} {decisions[sid]['bads'] or []}  "
          f"({len(decisions[sid]['bads'])} of {n_eeg} = {len(decisions[sid]['bads']) / n_eeg:.0%} of the montage)")
print()
print("Rank before and after (30 channels; the array keeps 30 rows throughout):")
for sid in SUBJECTS:
    k = len(decisions[sid]["bads"])
    print(f"  {sid}: rank {l2.data_rank(n_eeg)['rank']} as loaded  ->  "
          f"{l2.data_rank(n_eeg, n_interpolated=k, average_reference=True)['rank']} after interpolating {k} "
          f"channel(s) and taking an average reference   "
          f"[{l2.data_rank(n_eeg, n_interpolated=k, average_reference=True)['arithmetic']}]")
print()
print(f"Effect of interpolation on the average reference ({SID_DEMO}, bad channel {BAD}, probe {probe}):")
print(f"  including the flagged channel in the average shifts {probe} by "
      f"{np.sqrt(((xa - xb) ** 2).mean()):.2f} uV RMS (max {np.abs(xa - xb).max():.1f} uV), against "
      f"{probe}'s own {np.sqrt((xb ** 2).mean()):.2f} uV RMS")
print(f"  interpolating first leaves a residual of {np.sqrt(((xc - xb) ** 2).mean()):.2f} uV RMS -- "
      "interpolation does not restore information, it restores a usable channel set")
print()
print("ex-2-2-flag-bads-drill is scored in the w-bad-channel-detective widget against its own reference flags "
      "(label_source: algorithmic until the author reviews them); this notebook produces no numeric key.")
nb-2-2-bad-channels -- L2.2 numbers (draft; TODO(confirm) at author review)
Data: ds-erpcore P3, sub-001, sub-002, sub-003 (CC BY-SA 4.0 per data/directory.yaml, contested at source; open; per-subject downloadable). 30 EEG + 3 EOG, 1024 Hz resampled to 256 Hz, CMS reference, no software filters.
Detector: pyprep NoisyChannels.find_all_bads(ransac=True) on a 1 Hz high-passed, not low-passed copy, random_state 20260917. Manual rule: {'z_sd': 3.5, 'excursion_uv': 250.0, 'flat_uv': 0.5, 'highpass_hz': 1.0}.
Interpolation rule: interpolate a channel flagged by >= 2 criteria, at most 10% of the montage (3 of 30 channels).

Flagged channels per subject, per criterion:
  sub-001
      bad_by_deviation       ['F8']
      bad_by_correlation     ['P3', 'PO7', 'PO3', 'O1', 'F8']
      bad_by_psd             ['Fp2', 'F8']
      manual (stated rule)   ['F8', 'Fp1', 'Fp2']
      -> interpolated        ['F8']  (1 of 30 = 3% of the montage)
  sub-002
      bad_by_hf_noise        ['PO7', 'O1']
      bad_by_correlation     ['P3', 'PO3']
      manual (stated rule)   []
      -> interpolated        []  (0 of 30 = 0% of the montage)
  sub-003
      manual (stated rule)   ['F7', 'Fp1', 'Fp2']
      -> interpolated        []  (0 of 30 = 0% of the montage)

Rank before and after (30 channels; the array keeps 30 rows throughout):
  sub-001: rank 30 as loaded  ->  28 after interpolating 1 channel(s) and taking an average reference   [30 channels - 1 interpolated - 1 average reference = rank 28]
  sub-002: rank 30 as loaded  ->  29 after interpolating 0 channel(s) and taking an average reference   [30 channels - 1 average reference = rank 29]
  sub-003: rank 30 as loaded  ->  29 after interpolating 0 channel(s) and taking an average reference   [30 channels - 1 average reference = rank 29]

Effect of interpolation on the average reference (sub-001, bad channel F8, probe Pz):
  including the flagged channel in the average shifts Pz by 1.36 uV RMS (max 9.7 uV), against Pz's own 16.53 uV RMS
  interpolating first leaves a residual of 2.82 uV RMS -- interpolation does not restore information, it restores a usable channel set

ex-2-2-flag-bads-drill is scored in the w-bad-channel-detective widget against its own reference flags (label_source: algorithmic until the author reviews them); this notebook produces no numeric key.