Baseline normalisation and ERD/ERS: dB against percent against z, where the baseline goes, and the four averaging orders that give four different answers

nb-4-4-erd Level 4 · Time-Frequency and Oscillations ~5 min Used in L4.4 · Baseline normalization and ERD/ERS

Downloads from ds-eegbci 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-4-4-erd · Baseline normalisation and ERD/ERS (L4.4)

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

Raw time-frequency power is dominated by the 1/f background, so a map of it shows the spectrum's slope and nothing else. Normalising against a baseline is what makes an event visible — and it introduces four separate decisions, each of which changes the answer: which baseline window, which normalisation, whether the baseline is shared between conditions, and in what order the averaging is done.

The last one is the one that is usually left unsaid, and it is worth tens of percentage points. The last cell prints the L4.4 answer key under every order, says which order the widget's key was computed with, and reports whether this notebook agrees.

Data. ds-eegbci — EEGMMIDB, Schalk et al. (2004), DOI 10.1109/TBME.2004.827072; dataset DOI 10.13026/C28G6P. From data/directory.yaml: 64-channel 10-10 cap, 160 Hz, no online filters, 60 Hz mains, access: open, licence ODC-By 1.0. Subject S001, runs R04 + R08 + R12, conditions T1 (left-fist imagery) and T2 (right-fist imagery).

S001 was chosen by the size of its effect, not at random. w-tf-baseline-explorer/tfpower.json records the ranking: ten candidate subjects were run through the identical pipeline and S001 had the largest mu desynchronisation at C3. It is a teaching example, and a subject picked for legibility is by construction not a typical one — no claim about the cohort follows from it. nb-c4-mu-beta-erd runs the whole subset.

Three EDF files, about 2.4 MB each, downloaded and deleted in a finally; free disk printed before and after.

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_l4.py").exists()), None)
if _shared is None:
    raise FileNotFoundError("start the kernel in notebooks/L4/ (or notebooks/) so that "
                            "_shared/helpers_l4.py is found")
sys.path.insert(0, str(_shared))
import helpers
import helpers_l1
import helpers_l4 as L4

# 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

mne.set_log_level("WARNING")
plt.rcParams["figure.dpi"] = 72

# 4. Quiet the downloader.  pooch, which MNE uses to fetch datasets, logs
#    "Downloading file '...' from '...' to '<cache directory>'" at INFO, and that last field is an
#    ABSOLUTE PATH from whichever machine executed the notebook.  Absolute paths are not allowed in a
#    stored notebook (scripts/scrub-notebooks.py is a CI gate) and re-executing would put them straight
#    back, so the message is suppressed at the source rather than cleaned up afterwards.  Nothing is
#    hidden by this: every cell below prints the file NAMES it fetched and helpers_l4.Downloads prints
#    free disk before and after.  Please do not delete this as noise.
try:
    import pooch

    pooch.get_logger().setLevel("WARNING")
except Exception:  # pooch absent or its API moved: the scrub script is the backstop
    pass
print(f"MNE {mne.__version__}; helpers_l4 imported from notebooks/_shared")
print(f"downloads go to {helpers_l1.download_dir().name}/ (resolved relative to the working directory, "
      "or $EEG_COURSE_DOWNLOADS) and are deleted at the end of this notebook")
MNE 1.10.2; helpers_l4 imported from notebooks/_shared
downloads go to eeg-course/ (resolved relative to the working directory, or $EEG_COURSE_DOWNLOADS) and are deleted at the end of this notebook

1 · Why a raw map shows you nothing

The first panel below is the trial-averaged power with no normalisation at all. It is a picture of the 1/f spectrum: everything below 8 Hz is orders of magnitude larger than everything above it, and the colour scale is spent on that. The event is in there, and it is invisible.

The second panel is the identical array divided by its own baseline. Nothing has been filtered, no trial has been dropped, and no smoothing has been applied — only the frequency-dependent constant has been removed.

In [2]:
SUBJECT = "S001"
CHANNELS = ["C3", "C4"]
dl = L4.Downloads("nb-4-4").start()
epochs, info = L4.load_imagery_epochs(SUBJECT, L4.MI_RUNS, dl)
print(f"{SUBJECT}: {info['n_epochs']} epochs, {info['n_eeg']} EEG channels at {info['sfreq']:g} Hz, "
      f"{info['epoch_window_s'][0]:g}..{info['epoch_window_s'][1]:g} s, average reference, no filtering")
print(f"task duration from the EDF's own annotations: {info['task_duration_s']:g} s")
print("downloaded for this notebook: " + ", ".join(p.name for p in dl.paths))

TF = {}
for cond in ("T1", "T2"):
    X = epochs[cond].get_data(picks=CHANNELS) * 1e6
    r = L4.morlet_power(X, sfreq=float(epochs.info["sfreq"]), times=epochs.times)
    TF[cond] = r
    print(f"  {cond} ({L4.MI_CONDITIONS[cond]}): single-trial power {r['power'].shape} "
          f"(trials x channels x freqs x times)")
freqs, times = TF["T2"]["freqs"], TF["T2"]["times"]
edge = L4.edge_seconds(freqs, L4.TF_CYCLES)
print(f"  call: {TF['T2']['call']}")
free disk before nb-4-4: 4,230 MB
S001: {'T0': 44, 'T1': 23, 'T2': 22} epochs, 64 EEG channels at 160 Hz, -2.5..4.5 s, average reference, no filtering
task duration from the EDF's own annotations: 4.1 s
downloaded for this notebook: S001R04.edf, S001R08.edf, S001R12.edf
  T1 (left-fist motor imagery): single-trial power (23, 2, 37, 281) (trials x channels x freqs x times)
  T2 (right-fist motor imagery): single-trial power (22, 2, 37, 281) (trials x channels x freqs x times)
  call: mne.time_frequency.tfr_array_morlet(data, sfreq=160, freqs=<37 values 4..40 Hz>, n_cycles=<max(3, f/2)>, output='power', zero_mean=True, use_fft=True, decim=4)  # MNE 1.10.2
In [3]:
i_c3 = CHANNELS.index("C3")
P_raw = TF["T2"]["power"].mean(0)[i_c3]              # trial-averaged raw power, C3, T2

fig, axes = plt.subplots(1, 2, figsize=(14, 4.2))
im = axes[0].pcolormesh(times, freqs, P_raw, cmap="viridis", shading="nearest")
axes[0].axvline(0, color="w", lw=1.0, ls="--")
axes[0].set(xlabel="Time from cue (s)", ylabel="Frequency (Hz)",
            title=f"RAW trial-averaged power, {SUBJECT} C3, T2\n(library units, linear scale) -- this is a "
                  f"picture of 1/f")
fig.colorbar(im, ax=axes[0]).set_label("Power (library units)")
L4.plot_tfr(L4.baseline_normalise(P_raw, times, L4.TF_BASELINE, "percent"), freqs, times, ax=axes[1],
            vlim=(-100, 100), edge_s=edge, baseline=L4.TF_BASELINE,
            cbar_label="Power change from baseline (%)",
            title=f"The SAME array, per-frequency percent change\n{SUBJECT} C3, T2, baseline "
                  f"{L4.TF_BASELINE[0]:g}..{L4.TF_BASELINE[1]:g} s")
fig.tight_layout()
plt.show()   # render the static figure(s) of this cell inline

rows = [{"frequency (Hz)": f, "mean raw power over the epoch (library units)": P_raw[i].mean(),
         "as a multiple of the 40 Hz row": P_raw[i].mean() / P_raw[-1].mean()}
        for i, f in enumerate(freqs) if f in (4, 10, 20, 30, 40)]
print(L4.fmt_table(rows, floatfmt="{:.4g}"))
print()
print("That last column is the reason a raw map is useless as a picture: the 4 Hz row is "
      f"{P_raw[0].mean() / P_raw[-1].mean():.0f} times the 40 Hz row before anything has happened. Any "
      "colour scale that shows one hides the other.")
Figure 1 of notebook nb-4-4-erd, an output plot. The text around it states what it shows and the units of every axis.
frequency (Hz)  mean raw power over the epoch (library units)  as a multiple of the 40 Hz row
--------------  ---------------------------------------------  ------------------------------
             4                                           2241                           19.39
            10                                          851.5                           7.368
            20                                          419.3                           3.628
            30                                          210.9                           1.824
            40                                          115.6                               1

That last column is the reason a raw map is useless as a picture: the 4 Hz row is 19 times the 40 Hz row before anything has happened. Any colour scale that shows one hides the other.

2 · dB, percent and z: the same map, three scales

  • percent100 (P − m) / m. Bounded below at −100 %, unbounded above, so increases and decreases are asymmetric on the page even when they are symmetric in the data.
  • dB10 log₁₀(P / m). Symmetric: a halving is −3.01 dB and a doubling +3.01 dB. This is why dB is the usual choice for a figure.
  • z(P − m) / sd. Needs a definition of the standard deviation, and there are two: across the time points of the baseline window of the averaged map, or across trials. They are different quantities and they give different maps. Both are computed below, because a paper that says "z-scored to baseline" has not said which.

Note how the three agree about where the effect is and disagree about how big it looks. None of them is more correct; each has to be named.

In [4]:
fig, axes = plt.subplots(1, 4, figsize=(21.5, 4.0))
maps = {}
for ax, mode, lim, lab in ((axes[0], "percent", 100, "Power change from baseline (%)"),
                           (axes[1], "db", 6, "Power change from baseline (dB)"),
                           (axes[2], "z", 6, "z against the baseline window's own SD over time")):
    M = L4.baseline_normalise(P_raw, times, L4.TF_BASELINE, mode)
    maps[mode] = M
    L4.plot_tfr(M, freqs, times, ax=ax, vlim=(-lim, lim), edge_s=edge, baseline=L4.TF_BASELINE,
                cbar_label=lab, title=f"{mode} \n{SUBJECT} C3, T2, baseline "
                                      f"{L4.TF_BASELINE[0]:g}..{L4.TF_BASELINE[1]:g} s")
# z across trials: each trial's own baseline mean, then the SD of those per-trial values
tr = TF["T2"]["power"][:, i_c3]
bm = (times >= L4.TF_BASELINE[0]) & (times <= L4.TF_BASELINE[1])
base_per_trial = tr[:, :, bm].mean(-1)                       # (n_trials, n_freqs)
z_trials = (tr.mean(0) - base_per_trial.mean(0)[:, None]) / base_per_trial.std(0, ddof=1)[:, None]
maps["z across trials"] = z_trials
L4.plot_tfr(z_trials, freqs, times, ax=axes[3], vlim=(-6, 6), edge_s=edge, baseline=L4.TF_BASELINE,
            cbar_label="z against the ACROSS-TRIAL SD of the baseline",
            title=f"z across trials\n{SUBJECT} C3, T2 -- a different quantity")
fig.suptitle(f"One array, four normalisations ({SUBJECT} C3, T2, {tr.shape[0]} trials)", y=1.04)
fig.tight_layout()
plt.show()   # render the static figure(s) of this cell inline

mu = (freqs >= L4.MU_BAND[0]) & (freqs <= L4.MU_BAND[1])
act = (times >= L4.TF_ACTIVE[0]) & (times <= L4.TF_ACTIVE[1])
print(f"Mu band over {L4.TF_ACTIVE[0]:g}..{L4.TF_ACTIVE[1]:g} s at C3, the same cells read four ways:")
for k, M in maps.items():
    print(f"    {k:18s}: {M[np.ix_(mu, act)].mean():+9.3f}")
print(f"    percent -> dB check: 10 log10(1 + {maps['percent'][np.ix_(mu, act)].mean():.3f}/100) = "
      f"{10 * np.log10(1 + maps['percent'][np.ix_(mu, act)].mean() / 100):+.3f} dB, which is NOT the mean dB "
      f"({maps['db'][np.ix_(mu, act)].mean():+.3f}) -- the log of a mean is not the mean of a log, and that is "
      "a third place where an averaging order changes an answer.")
Figure 2 of notebook nb-4-4-erd, an output plot. The text around it states what it shows and the units of every axis.
Mu band over 0.5..3.5 s at C3, the same cells read four ways:
    percent           :   -44.002
    db                :    -2.824
    z                 :    -2.760
    z across trials   :    -0.472
    percent -> dB check: 10 log10(1 + -44.002/100) = -2.518 dB, which is NOT the mean dB (-2.824) -- the log of a mean is not the mean of a log, and that is a third place where an averaging order changes an answer.

3 · Where the baseline goes

Three ways to get it wrong, all of them measured rather than described:

  1. Into the edge region. The first 5 σ_t of the epoch is partly zero padding, so a baseline that reaches into it is measured partly on zeros — the baseline reads low, and every later value reads high (pf-tf-edge-effects).
  2. Too close to the event. A baseline that runs up to the cue includes any anticipatory change, and normalising by it removes part of the effect being measured.
  3. Different for each condition. A per-condition baseline removes any pre-existing difference between conditions, which is sometimes exactly what should be kept.

The default here — −1.5 to −0.5 s — was chosen to sit clear of both ends: it starts 0.40 s after the edge region and ends 0.5 s before the cue.

In [5]:
WINDOWS = {
    f"default {L4.TF_BASELINE[0]:g}..{L4.TF_BASELINE[1]:g} s": L4.TF_BASELINE,
    "-2.5..-2.0 s (inside the edge region)": (-2.5, -2.0),
    "-0.5..0.0 s (right up to the cue)": (-0.5, 0.0),
    "-2.5..0.0 s (everything before the cue)": (-2.5, 0.0),
    "1.0..2.0 s (inside the task -- wrong on purpose)": (1.0, 2.0),
}
rows = []
for name, win in WINDOWS.items():
    r = {"baseline window": name,
         "inside the edge region?": "yes" if win[0] < times[0] + edge[0] else "no"}
    for ch in CHANNELS:
        i = CHANNELS.index(ch)
        r[f"mu ERD at {ch} (%)"] = L4.band_erd(TF["T2"]["power"][:, i], freqs, times, L4.MU_BAND,
                                               baseline=win, active=L4.TF_ACTIVE, order="band-first")
    rows.append(r)
print(f"Right-fist imagery (T2), mu {L4.MU_BAND[0]:g}-{L4.MU_BAND[1]:g} Hz over "
      f"{L4.TF_ACTIVE[0]:g}..{L4.TF_ACTIVE[1]:g} s, band-first order:")
print(L4.fmt_table(rows, floatfmt="{:.2f}"))
print()
bc3 = TF["T2"]["power"].mean(0)[i_c3][mu].mean(0)
for name, win in WINDOWS.items():
    m = (times >= win[0]) & (times <= win[1])
    print(f"    baseline power in {name:48s}: {bc3[m].mean():9.2f} library units")
print()
print("The edge-region baseline reads low because part of it is convolved with zeros, so every later value is "
      "inflated against it; the up-to-the-cue baseline reads low because the desynchronisation has already "
      "begun; the in-task baseline is the effect itself, so the ERD it reports is close to nothing.")

fig, axes = plt.subplots(1, 3, figsize=(17.5, 4.0))
for ax, (name, win) in zip(axes, list(WINDOWS.items())[:3]):
    L4.plot_tfr(L4.baseline_normalise(P_raw, times, win, "percent"), freqs, times, ax=ax, vlim=(-100, 100),
                edge_s=edge, baseline=win, cbar_label="Power change from baseline (%)",
                title=f"baseline {name}\n{SUBJECT} C3, T2 (%)")
fig.suptitle("Moving the baseline moves the map (the black bar at the bottom of each panel is the window)",
             y=1.04)
fig.tight_layout()
plt.show()   # render the static figure(s) of this cell inline
Right-fist imagery (T2), mu 8-13 Hz over 0.5..3.5 s, band-first order:
                                 baseline window  inside the edge region?  mu ERD at C3 (%)  mu ERD at C4 (%)
------------------------------------------------  -----------------------  ----------------  ----------------
                            default -1.5..-0.5 s                       no            -47.87            -10.46
           -2.5..-2.0 s (inside the edge region)                      yes            -40.66            -15.24
               -0.5..0.0 s (right up to the cue)                       no            -35.86             -3.79
         -2.5..0.0 s (everything before the cue)                      yes            -44.93            -16.67
1.0..2.0 s (inside the task -- wrong on purpose)                       no              8.87              2.98

    baseline power in default -1.5..-0.5 s                            :   1289.39 library units
    baseline power in -2.5..-2.0 s (inside the edge region)           :   1132.71 library units
    baseline power in -0.5..0.0 s (right up to the cue)               :   1048.10 library units
    baseline power in -2.5..0.0 s (everything before the cue)         :   1220.73 library units
    baseline power in 1.0..2.0 s (inside the task -- wrong on purpose):    617.43 library units

The edge-region baseline reads low because part of it is convolved with zeros, so every later value is inflated against it; the up-to-the-cue baseline reads low because the desynchronisation has already begun; the in-task baseline is the effect itself, so the ERD it reports is close to nothing.
Figure 3 of notebook nb-4-4-erd, an output plot. The text around it states what it shows and the units of every axis.

4 · The answer key, and the four averaging orders

Getting from single-trial power to one ERD number means averaging over trials, over frequency rows and over time, and dividing by a baseline. The division does not commute with the averaging, so the order is part of the method:

order what it does
band-first average raw power over trials, then over the band; normalise the resulting time course
cell-first average raw power over trials; normalise each (frequency, time) cell against its own baseline; then average
trial-band-first normalise each trial's band time course against that trial's own baseline; then average over trials
trial-cell-first normalise every (trial, frequency, time) cell; then average everything

The first two differ because band-first weights each frequency row by its raw power, so the loudest rows of the band dominate, while cell-first gives every row equal weight. The last two differ from both far more, because a per-trial ratio is a heavy-tailed quantity: a trial whose baseline happened to be quiet contributes a very large positive value, and the mean is pulled up by it.

The prompt must name the order. A tolerance wide enough to admit two of these is a tolerance wide enough to admit a different method.

In [6]:
table = []
for cond in ("T2", "T1"):
    for band_name, band in (("mu", L4.MU_BAND), ("beta", L4.BETA_BAND)):
        for ch in CHANNELS:
            i = CHANNELS.index(ch)
            row = {"condition": f"{cond} ({L4.MI_CONDITIONS[cond]})", "band": band_name, "channel": ch}
            for order in L4.ERD_ORDERS:
                row[order] = L4.band_erd(TF[cond]["power"][:, i], freqs, times, band,
                                         baseline=L4.TF_BASELINE, active=L4.TF_ACTIVE, order=order)
            table.append(row)
print(f"ERD in percent, baseline {L4.TF_BASELINE[0]:g}..{L4.TF_BASELINE[1]:g} s, active window "
      f"{L4.TF_ACTIVE[0]:g}..{L4.TF_ACTIVE[1]:g} s, {SUBJECT}")
print(L4.fmt_table(table, floatfmt="{:.2f}"))
print()
for k, v in L4.ERD_ORDERS.items():
    print(f"  {k:18s}: {v}")
ERD in percent, baseline -1.5..-0.5 s, active window 0.5..3.5 s, S001
                    condition  band  channel  band-first  cell-first  trial-band-first  trial-cell-first
-----------------------------  ----  -------  ----------  ----------  ----------------  ----------------
T2 (right-fist motor imagery)    mu       C3      -47.87      -44.00            -22.48            -15.99
T2 (right-fist motor imagery)    mu       C4      -10.46       -8.97              5.30             17.31
T2 (right-fist motor imagery)  beta       C3      -35.78      -24.84            -26.41             -4.02
T2 (right-fist motor imagery)  beta       C4      -12.02       -8.69             -3.55             19.59
 T1 (left-fist motor imagery)    mu       C3      -29.67      -26.26             -6.19              2.22
 T1 (left-fist motor imagery)    mu       C4      -20.46      -18.81              0.04             12.85
 T1 (left-fist motor imagery)  beta       C3      -19.04      -11.98             -7.12             18.79
 T1 (left-fist motor imagery)  beta       C4      -10.62       -4.38              0.89             17.61

  band-first        : average the RAW power over trials, then over the band, then normalise the resulting band time course against its own baseline mean.  Frequencies are weighted by their raw power, so the loudest rows of the band dominate.
  cell-first        : average the RAW power over trials, normalise every (frequency, time) cell against that frequency's own baseline mean, then average over band and window.  Every frequency row counts equally.
  trial-band-first  : normalise each TRIAL's band time course against that trial's own baseline, then average over trials.  A ratio per trial: trials with a quiet baseline can produce very large positive values, and the average is pulled upward by them.
  trial-cell-first  : normalise every (trial, frequency, time) cell against that trial and frequency's own baseline, then average everything.  The most heavily ratio-dominated of the four.
In [7]:
fig, axes = plt.subplots(2, 2, figsize=(14.5, 8.0))
peaks = {}
for r, cond in enumerate(("T2", "T1")):
    for c, ch in enumerate(CHANNELS):
        i = CHANNELS.index(ch)
        M = L4.baseline_normalise(TF[cond]["power"].mean(0)[i], times, L4.TF_BASELINE, "percent")
        L4.plot_tfr(M, freqs, times, ax=axes[r, c], vlim=(-80, 80), edge_s=edge, baseline=L4.TF_BASELINE,
                    cbar_label="Power change from baseline (%)",
                    title=f"{SUBJECT} {ch}, {cond} ({L4.MI_CONDITIONS[cond]})\n% change re "
                          f"{L4.TF_BASELINE[0]:g}..{L4.TF_BASELINE[1]:g} s")
        pb = TF[cond]["power"].mean(0)[i][mu].mean(0)
        curve = 100 * (pb - pb[(times >= L4.TF_BASELINE[0]) & (times <= L4.TF_BASELINE[1])].mean()) / \
            pb[(times >= L4.TF_BASELINE[0]) & (times <= L4.TF_BASELINE[1])].mean()
        safe = (times >= times[0] + edge[0]) & (times <= times[-1] - edge[0])
        j = int(np.argmin(np.where(safe, curve, np.inf)))
        peaks[(cond, ch)] = (float(curve[j]), float(times[j]))
fig.suptitle(f"Mu and beta desynchronisation is contralateral ({SUBJECT}, % change from baseline)", y=1.01)
fig.tight_layout()
plt.show()   # render the static figure(s) of this cell inline

print(f"Peak mu ERD (most negative point of the {L4.MU_BAND[0]:g}-{L4.MU_BAND[1]:g} Hz mean over time, "
      f"searched only inside the edge-safe region {times[0] + edge[0]:.3f}..{times[-1] - edge[0]:.3f} s):")
for (cond, ch), (v, t) in peaks.items():
    print(f"    {cond} {ch}: {v:+6.1f} % at {t:+.3f} s")
Figure 4 of notebook nb-4-4-erd, an output plot. The text around it states what it shows and the units of every axis.
Peak mu ERD (most negative point of the 8-13 Hz mean over time, searched only inside the edge-safe region -1.903..3.903 s):
    T2 C3:  -64.3 % at +1.150 s
    T2 C4:  -36.7 % at +0.925 s
    T1 C3:  -59.2 % at +0.875 s
    T1 C4:  -53.7 % at +2.775 s

5 · Lateralisation over the scalp

The claim the capstone rests on is that mu desynchronisation is larger over the hemisphere contralateral to the imagined hand. Two channels cannot show that; the whole array can. Each topography below is the mu-band percent change over the active window, at every electrode, for one condition — and the third is their difference, which is where the lateralisation lives.

In [8]:
ALL = [epochs.ch_names[i] for i in mne.pick_types(epochs.info, eeg=True)]
topo = {}
for cond in ("T1", "T2"):
    X = epochs[cond].get_data(picks=ALL) * 1e6
    r = L4.morlet_power(X, sfreq=float(epochs.info["sfreq"]), freqs=np.arange(8.0, 13.5, 1.0),
                        n_cycles=np.full(6, 5.0), times=epochs.times)
    P = r["power"].mean(0)                                   # (n_ch, n_freqs, n_times)
    b = (r["times"] >= L4.TF_BASELINE[0]) & (r["times"] <= L4.TF_BASELINE[1])
    a = (r["times"] >= L4.TF_ACTIVE[0]) & (r["times"] <= L4.TF_ACTIVE[1])
    pb = P.mean(1)                                           # band-first
    topo[cond] = 100 * (pb[:, a].mean(1) - pb[:, b].mean(1)) / pb[:, b].mean(1)
info_eeg = mne.pick_info(epochs.info, mne.pick_types(epochs.info, eeg=True))

fig, axes = plt.subplots(1, 3, figsize=(14, 4.2))
for ax, (name, v, lim) in zip(axes, (("T1, left-fist imagery", topo["T1"], 60),
                                     ("T2, right-fist imagery", topo["T2"], 60),
                                     ("T2 minus T1", topo["T2"] - topo["T1"], 40))):
    im, _ = mne.viz.plot_topomap(v, info_eeg, axes=ax, show=False, cmap="RdBu_r", vlim=(-lim, lim),
                                 contours=4, sensors=True)
    ax.set_title(f"{name}\nmu {L4.MU_BAND[0]:g}-{L4.MU_BAND[1]:g} Hz, % change re baseline", fontsize=9)
    fig.colorbar(im, ax=ax, shrink=0.8).set_label("Power change from baseline (%)")
fig.suptitle(f"{SUBJECT}: mu-band percent change over the scalp, band-first order "
             f"({L4.TF_ACTIVE[0]:g}..{L4.TF_ACTIVE[1]:g} s vs {L4.TF_BASELINE[0]:g}..{L4.TF_BASELINE[1]:g} s)",
             y=1.03)
fig.tight_layout()
plt.show()   # render the static figure(s) of this cell inline

for cond in ("T1", "T2"):
    order = np.argsort(topo[cond])
    print(f"{cond} ({L4.MI_CONDITIONS[cond]}): strongest mu decrease at "
          + ", ".join(f"{ALL[i]} {topo[cond][i]:+.1f} %" for i in order[:5]))
lat = {ch: (topo["T2"][ALL.index(ch)], topo["T1"][ALL.index(ch)]) for ch in CHANNELS}
print()
print(f"At C3 and C4: T2 gives {lat['C3'][0]:+.1f} % / {lat['C4'][0]:+.1f} %, "
      f"T1 gives {lat['C3'][1]:+.1f} % / {lat['C4'][1]:+.1f} %.")
print(f"Contralateral dominance for T2 (C3 more negative than C4): {lat['C3'][0] < lat['C4'][0]}. "
      f"For T1 (C4 more negative than C3): {lat['C4'][1] < lat['C3'][1]}.")
print("These topography numbers use a 6-row 8-13 Hz axis with 5 cycles everywhere, not the 37-row "
      "max(3, f/2) axis of the tables above, so they are close to but not identical with them; the axis is "
      "stated with them for that reason.")
Figure 5 of notebook nb-4-4-erd, an output plot. The text around it states what it shows and the units of every axis.
T1 (left-fist motor imagery): strongest mu decrease at C3 -27.8 %, C5 -18.5 %, C4 -17.9 %, FC3 -16.7 %, FC4 -12.1 %
T2 (right-fist motor imagery): strongest mu decrease at C3 -45.7 %, FC3 -44.7 %, CP3 -34.6 %, CP1 -32.2 %, P5 -30.5 %

At C3 and C4: T2 gives -45.7 % / -9.1 %, T1 gives -27.8 % / -17.9 %.
Contralateral dominance for T2 (C3 more negative than C4): True. For T1 (C4 more negative than C3): False.
These topography numbers use a 6-row 8-13 Hz axis with 5 cycles everywhere, not the 37-row max(3, f/2) axis of the tables above, so they are close to but not identical with them; the axis is stated with them for that reason.

6 · The numbers

In [9]:
try:
    KEY = {"C3": -47.9, "C4": -10.5}            # site/notes/integration-phase3.md, data-p3a and widgets-J
    KEY_OTHER = {"C3": -44.0, "C4": -9.0}       # the same question, cell-first order
    got = {ch: {o: L4.band_erd(TF["T2"]["power"][:, CHANNELS.index(ch)], freqs, times, L4.MU_BAND,
                               baseline=L4.TF_BASELINE, active=L4.TF_ACTIVE, order=o)
                for o in L4.ERD_ORDERS} for ch in CHANNELS}
    print("nb-4-4-erd -- L4.4 exercise numbers (draft; TODO(confirm) at author review)")
    print(f"Data: ds-eegbci {SUBJECT}, runs {'+'.join(info['runs'])} "
          f"(PhysioNet DOI {L4.DATASETS_L4['ds-eegbci']['dataset_doi']}; ODC-By 1.0); "
          f"{info['n_eeg']} EEG channels at {info['sfreq']:g} Hz, average reference, NO filtering, "
          f"no epoch rejection.")
    print(f"Trials: T2 (right-fist imagery) {info['n_epochs']['T2']}, T1 (left-fist imagery) "
          f"{info['n_epochs']['T1']}; largest absolute sample {info['max_abs_uv']['T2']:.0f} uV (T2).")
    print(f"Time-frequency: {TF['T2']['call']}")
    print(f"Windows: baseline {L4.TF_BASELINE[0]:g}..{L4.TF_BASELINE[1]:g} s, active "
          f"{L4.TF_ACTIVE[0]:g}..{L4.TF_ACTIVE[1]:g} s, mu {L4.MU_BAND[0]:g}-{L4.MU_BAND[1]:g} Hz, "
          f"beta {L4.BETA_BAND[0]:g}-{L4.BETA_BAND[1]:g} Hz.")
    print(f"Subject selection: S001 was the top-ranked of ten candidates by mu ERD at C3 "
          f"(w-tf-baseline-explorer/tfpower.json records the ranking). A subject chosen for the size of its "
          f"effect is not a sample of anything.")
    print()
    print("ANSWER KEY -- ex-4-4 (numeric pair): peak mu ERD at C3 and C4 for RIGHT-hand imagery")
    print(f"    band-first order:  C3 {got['C3']['band-first']:+.1f} %   C4 {got['C4']['band-first']:+.1f} %"
          f"    <-- the key")
    print(f"    THE PROMPT MUST NAME THE ORDER. The same question under the other three:")
    for o in ("cell-first", "trial-band-first", "trial-cell-first"):
        print(f"        {o:18s}: C3 {got['C3'][o]:+7.1f} %   C4 {got['C4'][o]:+7.1f} %")
    print(f"    Cross-check: site/notes/integration-phase3.md records C3 {KEY['C3']:g} % and "
          f"C4 {KEY['C4']:g} % (band-first) from data-p3a and widgets-J, with "
          f"C3 {KEY_OTHER['C3']:g} % / C4 {KEY_OTHER['C4']:g} % for the cell-first order.")
    ok_bf = all(abs(got[ch]['band-first'] - KEY[ch]) < 0.06 for ch in CHANNELS)
    ok_cf = all(abs(got[ch]['cell-first'] - KEY_OTHER[ch]) < 0.06 for ch in CHANNELS)
    print(f"    This notebook {'AGREES' if ok_bf else 'DISAGREES'} on the band-first pair "
          f"(differences {', '.join(f'{ch} {got[ch]['band-first'] - KEY[ch]:+.3f}' for ch in CHANNELS)}) "
          f"and {'AGREES' if ok_cf else 'DISAGREES'} on the cell-first pair "
          f"(differences {', '.join(f'{ch} {got[ch]['cell-first'] - KEY_OTHER[ch]:+.3f}' for ch in CHANNELS)}).")
    print(f"    NEW HERE: the two PER-TRIAL orders were not reported by the widget tracks and are far apart "
          f"from both: C3 {got['C3']['trial-band-first']:+.1f} % and {got['C3']['trial-cell-first']:+.1f} %, "
          f"and on C4 they change SIGN ({got['C4']['trial-band-first']:+.1f} % and "
          f"{got['C4']['trial-cell-first']:+.1f} %). A per-trial ratio is heavy-tailed, so its mean is pulled "
          f"upward by trials with a quiet baseline. A learner who normalises single trials and then averages "
          f"will not reproduce the key, and the prompt should keep them from having to guess.")
    print()
    print("Supporting -- peak (not window-mean) mu ERD, searched inside the edge-safe region:")
    for (cond, ch), (v, t) in peaks.items():
        print(f"    {cond} {ch}: {v:+6.1f} % at {t:+.3f} s")
    print("    (site/notes/integration-phase3.md records C3 -64.3 % at 1.15 s and C4 -36.7 % at 0.925 s "
          "for T2.)")
    print()
    print("Supporting -- beta, same windows, band-first:")
    for ch in CHANNELS:
        v = L4.band_erd(TF["T2"]["power"][:, CHANNELS.index(ch)], freqs, times, L4.BETA_BAND,
                        baseline=L4.TF_BASELINE, active=L4.TF_ACTIVE, order="band-first")
        print(f"    T2 {ch}: {v:+.1f} %   (the notes record C3 -35.8 % and C4 -12.0 %)")
    print()
    print("Supporting -- how much the baseline window alone moves the answer (band-first, T2, C3):")
    for r in rows:
        print(f"    {r['baseline window']:48s} {r['mu ERD at C3 (%)']:+7.2f} %")
    print()
    print("Pitfalls: pf-tf-edge-effects (every map here shades the edge region and one baseline is put "
          "inside it on purpose), pf-band-power-slope. Widget: w-tf-baseline-explorer.")
    print("TODO(confirm): the catalog carries no published ERD value for this dataset, so none of the above "
          "is compared with a literature number.")
finally:
    dl.finish()
nb-4-4-erd -- L4.4 exercise numbers (draft; TODO(confirm) at author review)
Data: ds-eegbci S001, runs R04+R08+R12 (PhysioNet DOI 10.13026/C28G6P; ODC-By 1.0); 64 EEG channels at 160 Hz, average reference, NO filtering, no epoch rejection.
Trials: T2 (right-fist imagery) 22, T1 (left-fist imagery) 23; largest absolute sample 511 uV (T2).
Time-frequency: mne.time_frequency.tfr_array_morlet(data, sfreq=160, freqs=<37 values 4..40 Hz>, n_cycles=<max(3, f/2)>, output='power', zero_mean=True, use_fft=True, decim=4)  # MNE 1.10.2
Windows: baseline -1.5..-0.5 s, active 0.5..3.5 s, mu 8-13 Hz, beta 13-30 Hz.
Subject selection: S001 was the top-ranked of ten candidates by mu ERD at C3 (w-tf-baseline-explorer/tfpower.json records the ranking). A subject chosen for the size of its effect is not a sample of anything.

ANSWER KEY -- ex-4-4 (numeric pair): peak mu ERD at C3 and C4 for RIGHT-hand imagery
    band-first order:  C3 -47.9 %   C4 -10.5 %    <-- the key
    THE PROMPT MUST NAME THE ORDER. The same question under the other three:
        cell-first        : C3   -44.0 %   C4    -9.0 %
        trial-band-first  : C3   -22.5 %   C4    +5.3 %
        trial-cell-first  : C3   -16.0 %   C4   +17.3 %
    Cross-check: site/notes/integration-phase3.md records C3 -47.9 % and C4 -10.5 % (band-first) from data-p3a and widgets-J, with C3 -44 % / C4 -9 % for the cell-first order.
    This notebook AGREES on the band-first pair (differences C3 +0.033, C4 +0.036) and AGREES on the cell-first pair (differences C3 -0.002, C4 +0.029).
    NEW HERE: the two PER-TRIAL orders were not reported by the widget tracks and are far apart from both: C3 -22.5 % and -16.0 %, and on C4 they change SIGN (+5.3 % and +17.3 %). A per-trial ratio is heavy-tailed, so its mean is pulled upward by trials with a quiet baseline. A learner who normalises single trials and then averages will not reproduce the key, and the prompt should keep them from having to guess.

Supporting -- peak (not window-mean) mu ERD, searched inside the edge-safe region:
    T2 C3:  -64.3 % at +1.150 s
    T2 C4:  -36.7 % at +0.925 s
    T1 C3:  -59.2 % at +0.875 s
    T1 C4:  -53.7 % at +2.775 s
    (site/notes/integration-phase3.md records C3 -64.3 % at 1.15 s and C4 -36.7 % at 0.925 s for T2.)

Supporting -- beta, same windows, band-first:
    T2 C3: -35.8 %   (the notes record C3 -35.8 % and C4 -12.0 %)
    T2 C4: -12.0 %   (the notes record C3 -35.8 % and C4 -12.0 %)

Supporting -- how much the baseline window alone moves the answer (band-first, T2, C3):
    default -1.5..-0.5 s                              -47.87 %
    -2.5..-2.0 s (inside the edge region)             -40.66 %
    -0.5..0.0 s (right up to the cue)                 -35.86 %
    -2.5..0.0 s (everything before the cue)           -44.93 %
    1.0..2.0 s (inside the task -- wrong on purpose)   +8.87 %

Pitfalls: pf-tf-edge-effects (every map here shades the edge region and one baseline is put inside it on purpose), pf-band-power-slope. Widget: w-tf-baseline-explorer.
TODO(confirm): the catalog carries no published ERD value for this dataset, so none of the above is compared with a literature number.
deleted 3 downloaded file(s), 7.4 MiB freed
free disk after nb-4-4: 4,298 MB (+67 MB against the start of the notebook; the volume is shared, so anything else running on it moves this number too)