When you get ready to move your right hand, a rhythm over the left side of your motor cortex switches off. It does the same if you only imagine the movement. That fact, measurable through the skull with a few electrodes, is the basis of almost every non-invasive brain-computer interface, and this lesson takes you from the raw EEG to a decoder that reads which hand a person is imagining with 84 percent accuracy from two numbers per trial. It uses the tools of the last two lessons, band power from lesson 6 and trial alignment from lesson 2, and ends with the decoder from lesson 3. The new ingredient is a spatial filter, and the lesson is really about why that one step turns nothing into something.
flowchart LR A[Continuous EEG + cue times] --> B[Epochs: trials x channels x time] B --> C[8 to 30 Hz power over time] C --> D[% change from each trial's baseline] D --> E[Average by hand: ERD curves and maps] D --> F[Two numbers per trial: decode left vs right] F --> G[Add a spatial filter: decode imagination]
The task, and the signal
The subject sat in front of a screen. Every eight seconds or so a target appeared on the left or right, and they clenched the corresponding fist until it disappeared, four seconds later. In three other runs they did the same without moving: they imagined the clench. The recording is 64 channels at 160 Hz, as in lesson 6, and the file gives the sample at which each cue appeared and which hand it called for.
The signal we are after was described by Gert Pfurtscheller in the 1970s and 80s: during movement, and during its preparation and imagination, the 8 to 30 Hz rhythms over the motor cortex that controls the moving limb drop in power. He called it event-related desynchronisation, ERD, on the theory that the rhythm reflects large groups of neurons oscillating in step, and that putting them to work breaks the step. Motor cortex for the right hand is on the left side of the head, under electrode C3, and for the left hand under C4. So the prediction is specific: right fist, C3 drops more; left fist, C4 drops more.
Step 1: epochs
Exactly as in lesson 2, cut the continuous recording into trials aligned to the cue: two seconds before to four seconds after. The result is the familiar three-dimensional array.
import numpy as np
import matplotlib.pyplot as plt
from scipy.signal import butter, filtfilt
dat = np.load("eeg_s007_motor.zip")
fs = float(dat["fs"]) # 160 samples per second
names = list(dat["channels"])
eeg = dat["real"].astype(float) # 64 channels x 60000 samples: three runs of real fist clenching
cue = dat["real_cue_sample"] # sample at which each cue appeared
hand = dat["real_cue_hand"] # 1 = left fist, 2 = right fist
print(eeg.shape, "channels x samples =", eeg.shape[1] / fs / 60, "minutes")
print(len(cue), "cues:", np.sum(hand == 1), "left,", np.sum(hand == 2), "right; first five hands:", hand[:5])
before, after = int(2 * fs), int(4 * fs) # 2 s before the cue to 4 s after
epochs = np.stack([eeg[:, c - before:c + after] for c in cue]) # trials x channels x time, as in lesson 2
te = np.arange(-before, after) / fs
print(epochs.shape, "trials x channels x time")
# (64, 60000) channels x samples = 6.25 minutes
# 45 cues: 23 left, 22 right; first five hands: [2 1 2 1 1]
# (45, 64, 960) trials x channels x time
Step 2: band power, and the drop
Lesson 6 measured the power in a band from a whole recording. Here we want it moment by moment within a trial: band-pass filter to 8 to 30 Hz, square the result, and smooth over a quarter of a second. Then express each moment as a percentage change from the trial’s own pre-cue baseline, so that trials and channels with different absolute levels can be averaged together. Negative numbers are desynchronisation.
def band_power(x, fs, low=8, high=30, smooth=0.25):
"""Power in a frequency band over time: band-pass, square, and average over a short window."""
b, a = butter(3, [low / (fs / 2), high / (fs / 2)], "band")
filtered = filtfilt(b, a, x, axis=-1)
kernel = np.ones(int(smooth * fs)) / int(smooth * fs)
return np.apply_along_axis(lambda v: np.convolve(v, kernel, mode="same"), -1, filtered ** 2)
power = band_power(epochs, fs) # trials x channels x time, in uV^2
baseline = power[:, :, te < 0].mean(axis=2, keepdims=True) # each trial's own pre-cue level, per channel
erd = (power - baseline) / baseline * 100 # percent change from baseline: negative means desynchronised
C3, C4 = names.index("C3"), names.index("C4") # over the left and right hand areas of motor cortex
fig, axes = plt.subplots(1, 2, figsize=(11, 3.8), sharey=True)
for ax, h, label in zip(axes, [1, 2], ["left fist", "right fist"]):
ax.plot(te, erd[hand == h, C3].mean(axis=0), label="C3 (left hemisphere)")
ax.plot(te, erd[hand == h, C4].mean(axis=0), label="C4 (right hemisphere)")
ax.axvline(0, color="orange"); ax.axhline(0, color="gray", ls=":")
ax.set(title=label, xlabel="time from cue (s)")
axes[0].set_ylabel("8 to 30 Hz power, % change from baseline")
axes[0].legend()
plt.show()
window = (te >= 0.5) & (te <= 2.5) # where the effect is, for the numbers below
for h, label in [(1, "left fist"), (2, "right fist")]:
print(f"{label}: C3 {erd[hand == h, C3][:, window].mean():+5.0f}% C4 {erd[hand == h, C4][:, window].mean():+5.0f}%")
# left fist: C3 -21% C4 -33%
# right fist: C3 -26% C4 -14%

The prediction holds. Left fist: C4 falls 33 percent, C3 21. Right fist: C3 falls 26 percent, C4 14. Both hemispheres respond, because the whole motor system is engaged, but the contralateral one responds more, and that difference is what will let us tell the hands apart.
Step 3: where the drop is
With 64 channels we can ask the same question everywhere. Average the percentage change over the 0.5 to 2.5 s window for every channel and look for the largest drops.
erd_map = erd[:, :, window].mean(axis=2) # trials x channels: one number per channel per trial
for h, label in [(1, "left fist"), (2, "right fist")]:
m = erd_map[hand == h].mean(axis=0)
top = np.argsort(m)[:5]
print(f"{label}, most desynchronised channels:", ", ".join(f"{names[i]} {m[i]:.0f}%" for i in top))
# left fist, most desynchronised channels: Poz -51%, Oz -50%, Po3 -50%, O1 -49%, P4 -47%
# right fist, most desynchronised channels: Cp3 -35%, T10 -31%, Cp5 -30%, C3 -26%, Cp1 -24%

Here is the surprise, and the reason this lesson needs a spatial filter. For the left fist, the five channels with the biggest drop are all at the back of the head: Poz, Oz, Po3, O1. That is the alpha rhythm from lesson 6 being blocked, because the cue was a visual target and the subject looked at it. It has nothing to do with which hand moved, it is large, and it reaches every channel on the head by conduction, as the ICA primer showed blinks doing. The hand-specific signal is the small asymmetry between C3 and C4 sitting on top of that global drop. The difference map on the right subtracts the shared part and shows it clearly, but a decoder has to work one trial at a time, where there is nothing to subtract against. Yet.
Step 4: imagination
The same analysis on the three runs where the subject only imagined clenching.
imag = dat["imagined"].astype(float) # the same three runs, but the subject only imagined the movement
cue_i, hand_i = dat["imagined_cue_sample"], dat["imagined_cue_hand"]
epochs_i = np.stack([imag[:, c - before:c + after] for c in cue_i])
power_i = band_power(epochs_i, fs)
erd_i = (power_i - power_i[:, :, te < 0].mean(axis=2, keepdims=True)) / power_i[:, :, te < 0].mean(axis=2, keepdims=True) * 100
for h, label in [(1, "imagined left", ), (2, "imagined right")]:
print(f"{label}: C3 {erd_i[hand_i == h, C3][:, window].mean():+5.0f}% C4 {erd_i[hand_i == h, C4][:, window].mean():+5.0f}%")
# imagined left: C3 -11% C4 -11%
# imagined right: C3 -9% C4 +18%
Weaker and messier, as everyone who has built one of these finds. Imagined left: both hemispheres down 11 percent, no asymmetry. Imagined right: C3 down 9, and C4 actually up 18. There is something there, but on plain channels it is buried, and the next step shows how buried.
Step 5: a decoder from two numbers
The simplest possible BCI. For each trial, two features: the log of band power at C3 and at C4 in the window, relative to that trial’s baseline. Then lesson 3’s logistic regression, cross-validated, to guess the hand. Standardising the features uses the training trials only, as it must.
def train_decoder(X, y, lr=0.1, steps=2000):
"""Logistic regression by gradient descent, from lesson 3, with a small penalty on large weights."""
w, b = np.zeros(X.shape[1]), 0.0
for _ in range(steps):
p = 1 / (1 + np.exp(-(X @ w + b)))
w -= lr * (X.T @ (p - y) / len(y) + 0.01 * w)
b -= lr * (p - y).mean()
return w, b
def cross_validate(X, y, folds=5, seed=0):
rng = np.random.default_rng(seed)
parts = np.array_split(rng.permutation(len(y)), folds)
scores = []
for part in parts:
test = np.zeros(len(y), dtype=bool); test[part] = True
mean, sd = X[~test].mean(axis=0), X[~test].std(axis=0) # standardise using training trials only
w, b = train_decoder((X[~test] - mean) / sd, y[~test])
scores.append(np.mean((((X[test] - mean) / sd) @ w + b > 0) == y[test]))
return np.mean(scores)
def features(power, channels):
"""One number per channel per trial: log of band power in the window relative to the trial's baseline."""
in_window = power[:, channels][:, :, window].mean(axis=2)
pre = power[:, channels][:, :, te < 0].mean(axis=2)
return np.log(in_window / pre)
y_real, y_imag = (hand == 2).astype(int), (hand_i == 2).astype(int) # 1 = right fist
acc = lambda X, y: np.mean([cross_validate(X, y, seed=s) for s in range(10)])
print(f"real movement, C3 and C4: {acc(features(power, [C3, C4]), y_real):.0%}")
print(f"imagined, C3 and C4: {acc(features(power_i, [C3, C4]), y_imag):.0%}")
# real movement, C3 and C4: 66%
# imagined, C3 and C4: 49%
Real movement, 66 percent: above chance, not useful. Imagined movement, 49 percent: chance. If this were your BCI, it would be a coin toss, and that is where most first attempts at motor imagery end up.
Step 6: the spatial filter
The trouble is the thing step 3 found. What C3 records is mostly what every electrode records: the visual alpha, the blinks, the common background. The part that is specific to the cortex under C3 is a small addition on top. A Laplacian spatial filter removes the shared part by the simplest possible means: subtract from C3 the average of its four nearest neighbours. Whatever is common to the whole patch cancels; whatever is local to C3 survives. It is the EEG equivalent of the common-average reference from lesson 5’s exercises, done locally, and it costs two lines.
def laplacian(x, centre, neighbours):
"""A channel minus the average of its neighbours: what is happening here and not everywhere."""
return x[:, names.index(centre)] - np.mean([x[:, names.index(n)] for n in neighbours], axis=0)
def laplacian_pair(ep):
lap3 = laplacian(ep, "C3", ["Fc3", "Cp3", "C1", "C5"])
lap4 = laplacian(ep, "C4", ["Fc4", "Cp4", "C2", "C6"])
return np.stack([lap3, lap4], axis=1) # trials x 2 x time
power_lap = band_power(laplacian_pair(epochs), fs)
power_lap_i = band_power(laplacian_pair(epochs_i), fs)
print(f"real movement, Laplacian C3 and C4: {acc(features(power_lap, [0, 1]), y_real):.0%}")
print(f"imagined, Laplacian C3 and C4: {acc(features(power_lap_i, [0, 1]), y_imag):.0%}")
# real movement, Laplacian C3 and C4: 82%
# imagined, Laplacian C3 and C4: 84%
erd_lap_i = (power_lap_i - power_lap_i[:, :, te < 0].mean(axis=2, keepdims=True)) / power_lap_i[:, :, te < 0].mean(axis=2, keepdims=True) * 100
fig, axes = plt.subplots(1, 2, figsize=(11, 3.8), sharey=True)
for ax, h, label in zip(axes, [1, 2], ["imagined left fist", "imagined right fist"]):
ax.plot(te, erd_lap_i[hand_i == h, 0].mean(axis=0), label="C3, Laplacian")
ax.plot(te, erd_lap_i[hand_i == h, 1].mean(axis=0), label="C4, Laplacian")
ax.axvline(0, color="orange"); ax.axhline(0, color="gray", ls=":")
ax.set(title=label, xlabel="time from cue (s)")
axes[0].set_ylabel("8 to 30 Hz power, % change"); axes[0].legend()
plt.show()


From 49 percent to 84 percent, with the same two channels, the same window, the same classifier, and no training data beyond the 45 trials. All the filter did was remove what the channels had in common, and with it the visual alpha that had been swamping the motor signal. Real movement rose to 82 percent too. In the bottom row of the third figure you can now see what imagination does to motor cortex: the hemisphere opposite the imagined hand desynchronises, and the one on the same side does the reverse, the pattern Pfurtscheller described, now visible one subject at a time from a few electrodes.
This is the decode stage of the BCI loop from the BCI explainer, built. A working system adds a feedback stage (a cursor that moves with the decoder’s output) and then something interesting happens: the user learns, and the signals get stronger over sessions. Everything after that, the better spatial filters (common spatial patterns is the standard), the adaptive classifiers, the invasive versions with far more information per second, is engineering on the thing you just did.
What you just did
- Aligned EEG to events and measured band power trial by trial, as a percentage of baseline.
- Reproduced event-related desynchronisation: a contralateral drop in 8 to 30 Hz power during real and imagined hand movement.
- Mapped it over the head and found the trap: a global visual-alpha drop that swamps the motor signal on plain channels.
- Built a two-feature decoder and watched it fail on imagined movement, at chance.
- Added a Laplacian spatial filter and took the same decoder to 84 percent, the core of a motor-imagery BCI.
Exercises
- Change the window from 0.5 to 2.5 s to 0 to 1 s, and then to 2 to 4 s. How early can the hand be decoded? A BCI user cares about latency as much as accuracy.
- Split the band: use 8 to 13 Hz (mu) and 13 to 30 Hz (beta) as separate features, four numbers per trial. Does it help? Which band carries the hand?
- Build a Laplacian for every channel that has four neighbours, decode from all of them, and compare with C3 and C4 alone. More features is not always better with 45 trials; see lesson 3.
- Harder: this is one subject chosen because the effect is clear. Download another subject’s runs 4, 8 and 12 from PhysioNet (the file names are S00xR04.edf and so on; the ICA primer’s source notes how to read EDF) and run the pipeline. Many subjects decode poorly, and the field calls them “BCI-illiterate”; what does their ERD look like?
Data: EEG Motor Movement/Imagery Dataset (Schalk et al. 2004) via PhysioNet (Goldberger et al. 2000), subject 7, runs 3, 4, 7, 8, 11 and 12, Open Data Commons Attribution licence. ERD: Pfurtscheller and Lopes da Silva, Clinical Neurophysiology 1999. The code runs top to bottom in about ten seconds.


















































