From voltage to spikes: your first spike sorter

Every lesson so far started from spike times, or spike counts, that someone else had already extracted. This is where they come from. A Neuropixels probe does not record neurons; it records voltage, thirty thousand times a second on each of 384 electrode sites, and somewhere in that voltage are the spikes. Turning one into the other is called spike detection and spike sorting, and in this lesson you will do both from scratch on ten seconds of a real recording: look at the raw trace, filter it, find the spikes, cut out their waveforms, use the PCA you wrote in lesson 4 plus a ten-line k-means to split them into neurons, and then check your answer against what a professional pipeline found on the same data. This is the first lesson of the Signals track.

flowchart LR

A[Raw voltage: samples x sites] --> B[High-pass filter]

B --> C[Threshold: find the troughs]

C --> D[Cut out waveforms]

D --> E[PCA + k-means: sort into units]

E --> F[Footprints and refractory check]

F --> G[Compare with the real sorter]
The pipeline. Every box is a step below, and every step is a few lines.

The data

Ten seconds of raw voltage from eight neighbouring sites of a Neuropixels probe in a mouse, recorded by the International Brain Laboratory (session KS023, 10 December 2019, from the Carandini and Harris lab at UCL) and shared under CC-BY as part of their brain-wide map. The IBL publishes the complete recording, 26 GB for this probe alone, so I cut out a slice: ibl_ks023_raw.zip. Save it next to your code and do not unzip it. Alongside the voltage, the file carries the spike times that the IBL’s own spike sorter found in the same ten seconds, which we will keep sealed until the last step.

The eight sites sit two to a row, with rows 20 micrometres apart along the probe. That spacing matters: a neuron’s spike is an electrical event in the tissue, and a site a few tens of micrometres away sees the same event, smaller. We will use that.

Step 1: look at raw voltage

The probe stores each sample as a 16-bit integer, and a number in the file header says how many microvolts one integer step is worth. Converting is one multiplication. Then plot one second on all eight sites, stacked so they do not overlap.

import numpy as np
import matplotlib.pyplot as plt

dat = np.load("ibl_ks023_raw.npz")
raw = dat["raw"]                          # samples x channels, int16, exactly as the probe wrote it
fs = float(dat["fs"])                     # 30000 samples per second
uv = float(dat["uv_per_bit"])             # 2.34 microvolts per integer step
chan = dat["channel_ids"]                 # which of the probe's 384 electrode sites these are
t = np.arange(raw.shape[0]) / fs          # time of each sample, in seconds

v = raw.astype(float) * uv                # the same data in microvolts
print(raw.shape, raw.dtype, "=", raw.shape[0] / fs, "seconds on", raw.shape[1], "channels")
print("sites:", chan)
print("first samples on site 333:", raw[:6, 5], "=", v[:6, 5].round(1), "uV")
# (300000, 8) int16 = 10.0 seconds on 8 channels
# sites: [328 329 330 331 332 333 334 335]
# first samples on site 333: [54 52 49 51 49 50] = [126.6 121.9 114.8 119.5 114.8 117.2] uV

fig, ax = plt.subplots(figsize=(10, 6))
for i in range(v.shape[1]):
    ax.plot(t, v[:, i] + i * 400, lw=0.5)                      # stack the channels 400 uV apart
ax.set(xlim=(0, 1), xlabel="time (s)", yticks=np.arange(8) * 400, yticklabels=chan, ylabel="electrode site")
plt.show()
One second of raw voltage on eight neighbouring sites, 20 micrometres apart. The slow wobble is shared by all of them; the downward spikes are mostly on a few sites at a time. Everything in this lesson is in that picture.
One second of raw voltage on eight neighbouring sites, 20 micrometres apart. The slow wobble is shared by all of them; the downward spikes are mostly on a few sites at a time. Everything in this lesson is in that picture.

Three things are visible before any analysis. There is a slow wobble shared by every site, which is the local field potential, the summed activity of thousands of neurons, and the subject of the next lesson. There is a fuzz of fast noise. And there are sharp downward blips, a few hundred microvolts deep and a millisecond wide, that appear on some sites and not others. Those are the spikes. A spike is a negative deflection at an extracellular electrode because the sodium rushing into the neuron is leaving the outside, where the electrode is.

Step 2: filter

The wobble and the spikes live at different speeds. The wobble changes over tens of milliseconds, which is to say below about 100 Hz; a spike is over in one millisecond, which is energy above 300 Hz. A high-pass filter keeps the fast part and discards the slow, and this one is a standard Butterworth design from SciPy. Two details: the cutoff is given as a fraction of half the sampling rate, which is how the function wants it, and filtfilt runs the filter forwards and then backwards, so the result lines up in time with the original. A filter run only forwards shifts everything slightly later, and in spike work a tenth of a millisecond matters.

from scipy.signal import butter, filtfilt

b, a = butter(3, 300 / (fs / 2), "high")                  # a high-pass filter: keep everything above 300 Hz
clean = filtfilt(b, a, v, axis=0)                         # filtfilt runs it forwards then backwards, so nothing shifts in time

fig, axes = plt.subplots(2, 1, figsize=(10, 5), sharex=True)
for ax, x, name in zip(axes, [v, clean], ["raw", "high-pass filtered"]):
    ax.plot(t, x[:, 5], lw=0.6)
    ax.set(ylabel="uV", title=name, xlim=(0.40, 0.50))
axes[-1].set_xlabel("time (s)")
plt.show()
100 ms on site 333. Top: the raw trace, sitting about 120 microvolts above zero and riding on the slow wobble. Bottom: after the high-pass filter, flat at zero with the spikes standing clear of the noise. The dashed line is the threshold from the next step.
100 ms on site 333. Top: the raw trace, sitting about 120 microvolts above zero and riding on the slow wobble. Bottom: after the high-pass filter, flat at zero with the spikes standing clear of the noise. The dashed line is the threshold from the next step.

Filtering is a large topic and the next lesson treats it properly, including what the filter threw away. For now the picture says enough: the baseline is flat, the spikes are untouched.

Step 3: find the spikes

A spike is a sample that goes much deeper than the noise. The question is how to measure the noise when the trace is full of spikes: the standard deviation would count them and come out too large. The trick, due to Quiroga and colleagues in 2004, is to take the median of the absolute voltage and divide by 0.6745, which gives the standard deviation of the noise alone, because the median hardly notices the rare large values. Threshold at five times that, find every downward crossing, locate the deepest point within a millisecond of it, and refuse a second detection within a millisecond of the first.

def detect_spikes(x, fs, n_sigma=5, refractory=0.001):
    """Times (in samples) of the troughs of negative deflections more than n_sigma noise levels deep."""
    sigma = np.median(np.abs(x)) / 0.6745                 # the noise level, estimated in a way spikes cannot inflate
    threshold = -n_sigma * sigma
    crossings = np.where((x[1:] < threshold) & (x[:-1] >= threshold))[0] + 1
    peaks, last = [], -np.inf
    for c in crossings:
        p = c + np.argmin(x[c:c + int(0.001 * fs)])      # the deepest point within 1 ms of the crossing
        if p - last > refractory * fs:                    # ignore a second crossing of the same spike
            peaks.append(p)
            last = p
    return np.array(peaks), threshold

for i, site in enumerate(chan):
    peaks, threshold = detect_spikes(clean[:, i], fs)
    print(f"site {site}: noise {-threshold / 5:4.1f} uV, threshold {threshold:6.1f} uV, {len(peaks):3d} spikes = {len(peaks) / 10:4.1f} per second")
# site 328: noise 14.6 uV, threshold  -73.0 uV, 363 spikes = 36.3 per second
# site 329: noise 15.4 uV, threshold  -76.8 uV, 313 spikes = 31.3 per second
# site 330: noise 15.4 uV, threshold  -76.9 uV, 481 spikes = 48.1 per second
# site 331: noise 15.3 uV, threshold  -76.7 uV, 286 spikes = 28.6 per second
# site 332: noise 14.7 uV, threshold  -73.5 uV, 409 spikes = 40.9 per second
# site 333: noise 14.8 uV, threshold  -73.9 uV, 500 spikes = 50.0 per second
# site 334: noise 14.5 uV, threshold  -72.4 uV, 401 spikes = 40.1 per second
# site 335: noise 14.9 uV, threshold  -74.5 uV, 379 spikes = 37.9 per second

Thirty to fifty detections per second on every site. In lesson 1 the typical neuron fired twice a second, so these are not single neurons. A site hears every neuron within something like fifty to a hundred micrometres, and in a dense region that is dozens. Detection finds spikes; it does not tell you whose they are. That is the job of sorting, and the rest of the lesson.

Step 4: cut out the waveforms

For each detected trough on site 333, cut out a window from one millisecond before to two after, on all eight sites at once. The result is a three-dimensional array, spikes by samples by sites, and as in lesson 2 the shape is half the lesson.

x = clean[:, 5]                                           # site 333
peaks, threshold = detect_spikes(x, fs)
before, after = int(0.001 * fs), int(0.002 * fs)          # 1 ms before the trough, 2 ms after
peaks = peaks[(peaks >= before) & (peaks < len(x) - after)]
waves = np.stack([clean[p - before:p + after] for p in peaks])    # spikes x samples x channels
tw = (np.arange(-before, after) / fs) * 1000              # waveform time axis, in ms
print(waves.shape, "spikes x samples x channels")
# (500, 90, 8) spikes x samples x channels

fig, axes = plt.subplots(1, 2, figsize=(11, 4.5))
axes[0].plot(tw, waves[:150, :, 5].T, color="gray", lw=0.4, alpha=0.5)   # 150 spikes, site 333 only
axes[0].plot(tw, waves[:, :, 5].mean(axis=0), lw=2)
axes[0].axhline(threshold, color="orange", ls="--")
axes[0].set(xlabel="time from trough (ms)", ylabel="uV", title="site 333: 150 spikes and the average")
big = np.argmin(waves[:, before, 5])                      # the single largest spike
for i in range(8):
    axes[1].plot(tw, waves[big, :, i] + i * 150, lw=1)    # the same spike on all eight sites
axes[1].set(xlabel="time from trough (ms)", yticks=np.arange(8) * 150, yticklabels=chan, title="one spike, eight sites")
plt.show()
Left: 150 of the 500 spikes detected on site 333, with the average of all of them. They are not one shape: there are at least two depths of trough. Right: the single largest spike as seen by all eight sites at the same moment. It is biggest on 333 and 332, still clear on 330 and 335, and nearly gone by 328: a spike has a location.
Left: 150 of the 500 spikes detected on site 333, with the average of all of them. They are not one shape: there are at least two depths of trough. Right: the single largest spike as seen by all eight sites at the same moment. It is biggest on 333 and 332, still clear on 330 and 335, and nearly gone by 328: a spike has a location.

The left panel is the reason sorting exists. These were all caught by the same threshold on the same site, but they are clearly not all the same thing: some troughs reach 100 microvolts, others 200, and the recoveries differ. The right panel is the clue that will separate them. A spike appears on several sites at once with an amplitude that falls off with distance, so each neuron has a spatial footprint on the probe set by where its body sits. Two neurons with similar waveforms on site 333 can still have different footprints.

Step 5: sort

Each spike is now 90 samples times 8 sites, 720 numbers. Finding groups in 720 dimensions is the problem lesson 4 solved: run PCA, keep a few components, and look. Then split the cloud with k-means, the simplest clustering algorithm there is: guess k centres, assign every point to its nearest centre, move each centre to the middle of its points, repeat until nothing moves. Both functions are below; pca is the one from lesson 4 without the sign-fixing loop.

def pca(data):
    mean = data.mean(axis=0)
    centered = data - mean
    cov = centered.T @ centered / (len(data) - 1)
    variance, components = np.linalg.eigh(cov)
    order = np.argsort(variance)[::-1]
    return components[:, order], variance[order], mean

def kmeans(points, k, seed=0):
    """Split points into k groups by repeatedly assigning each to the nearest centre and moving the centres."""
    rng = np.random.default_rng(seed)
    centres = points[rng.choice(len(points), k, replace=False)]        # start from k random points
    for _ in range(100):
        distance = ((points[:, None, :] - centres[None, :, :]) ** 2).sum(axis=2)   # every point to every centre
        group = distance.argmin(axis=1)                                 # nearest centre wins
        new = np.array([points[group == j].mean(axis=0) for j in range(k)])
        if np.allclose(new, centres):                                   # nothing moved: done
            break
        centres = new
    return group

features = waves.reshape(len(waves), -1)                  # each spike: 90 samples x 8 sites = 720 numbers
components, variance, mean = pca(features)
scores = (features - mean) @ components[:, :3]            # keep three
print("variance in the first three components:", (variance[:3] / variance.sum()).round(2))

k = 5
group = kmeans(scores, k)
for j in range(k):
    print(f"cluster {j}: {np.sum(group == j):3d} spikes, trough on site 333 {waves[group == j, before, 5].mean():6.0f} uV")
# variance in the first three components: [0.23 0.09 0.08]
# cluster 0: 145 spikes, trough on site 333    -89 uV
# cluster 1:  83 spikes, trough on site 333    -89 uV
# cluster 2: 109 spikes, trough on site 333   -123 uV
# cluster 3:  65 spikes, trough on site 333   -177 uV
# cluster 4:  98 spikes, trough on site 333   -157 uV

fig, ax = plt.subplots(figsize=(7, 6))
for j in range(k):
    ax.scatter(scores[group == j, 0], scores[group == j, 1], s=10, alpha=0.7, label=f"cluster {j}")
ax.set(xlabel="component 1", ylabel="component 2")
ax.legend()
plt.show()
500 spikes in the plane of their first two principal components, coloured by the five k-means groups. Clusters 3 and 4 are islands. Clusters 0, 1 and 2 overlap in these two dimensions and are separated by the third.
500 spikes in the plane of their first two principal components, coloured by the five k-means groups. Clusters 3 and 4 are islands. Clusters 0, 1 and 2 overlap in these two dimensions and are separated by the third.

Two islands and a crowded mainland. The islands, clusters 3 and 4, have the deepest troughs and are the easy neurons. The mainland is three clusters that k-means split in the third component, which this plot cannot show. Whether those splits are real is exactly what the next two steps check. I chose five clusters after trying four and seeing two of the big units merged; choosing k is a judgement, and the exercises come back to it.

Step 6: footprints and the refractory test

Two checks that need no answer key. First, the footprint: average each cluster’s waveform on every site. If a cluster is one neuron, its average should peak on one site and fall off smoothly around it, and different clusters should peak in different places or with different shapes. Second, the refractory period: a neuron cannot fire twice within about two milliseconds, so if a cluster’s spike train has many intervals shorter than that, it contains more than one neuron.

fig, axes = plt.subplots(1, k, figsize=(13, 4.5), sharey=True)
for j, ax in enumerate(axes):
    footprint = waves[group == j].mean(axis=0)            # samples x channels: this cluster's average on every site
    for i in range(8):
        ax.plot(tw, footprint[:, i] + i * 100, lw=1.2)
    ax.set(title=f"cluster {j}, n = {np.sum(group == j)}", xlabel="ms")
axes[0].set(yticks=np.arange(8) * 100, yticklabels=chan)
plt.show()

for j in range(k):
    times = np.sort(peaks[group == j]) / fs
    isi = np.diff(times)
    print(f"cluster {j}: {np.mean(isi < 0.002):.1%} of intervals shorter than 2 ms")
# cluster 0: 0.0% of intervals shorter than 2 ms
# cluster 1: 2.4% of intervals shorter than 2 ms
# cluster 2: 0.9% of intervals shorter than 2 ms
# cluster 3: 0.0% of intervals shorter than 2 ms
# cluster 4: 0.0% of intervals shorter than 2 ms
Average waveform of each cluster on all eight sites. Clusters 4, 3 and 2 peak on sites 329, 331 and 333 respectively: three different neurons at three different depths, all caught by a threshold on site 333. Clusters 0 and 1 both peak on 333 with smaller, similar shapes, and are less certain.
Average waveform of each cluster on all eight sites. Clusters 4, 3 and 2 peak on sites 329, 331 and 333 respectively: three different neurons at three different depths, all caught by a threshold on site 333. Clusters 0 and 1 both peak on 333 with smaller, similar shapes, and are less certain.

Clusters 2, 3 and 4 are neurons. Each has a clean footprint with a single peak, on three different sites, and none of them has a meaningful number of refractory violations. Cluster 4 is the clearest case of why the footprint matters: its trough is on site 329, two rows below the site we detected it on, and on site 333 alone its waveform looks much like cluster 2’s. Cluster 0 passes the refractory test and peaks on 333, but with a shallower trough than cluster 2 and a broader shape; it is probably mostly one neuron. Cluster 1 has 2.4 percent of its intervals under two milliseconds, and its footprint looks like a blend of its neighbours; it is where the leftovers went.

Step 7: open the answer key

The file also holds what the IBL’s pipeline found: a version of Kilosort, run on all 384 sites of the full recording, followed by curation. For each spike we detected, look for a sorter spike within half a millisecond, and tally which sorter unit each of our clusters mostly corresponds to.

sorter_t = dat["sorter_samples"]                          # the professional pipeline's spike times, in samples
sorter_unit = dat["sorter_clusters"]                      # and which unit it assigned each one to

def nearest_sorter_unit(p, tolerance=15):
    """The unit the sorter assigned to the spike nearest to sample p, if there is one within half a millisecond."""
    i = np.argmin(np.abs(sorter_t - p))
    return sorter_unit[i] if abs(sorter_t[i] - p) <= tolerance else -1

sorter_label = np.array([nearest_sorter_unit(p) for p in peaks])
print("detected spikes the sorter also found:", f"{np.mean(sorter_label >= 0):.0%}")
for j in range(k):
    units, counts = np.unique(sorter_label[group == j], return_counts=True)
    top = np.argsort(counts)[::-1][:3]
    print(f"cluster {j}: " + ", ".join(f"unit {u} x{c}" if u >= 0 else f"unmatched x{c}" for u, c in zip(units[top], counts[top])))

ids, site_of = dat["sorter_cluster_ids"], dat["sorter_cluster_channel"]
for u in [1302, 1325, 1310, 1327]:
    print(f"unit {u}: peak site {site_of[ids == u][0]}, {np.sum(sorter_unit == u)} spikes in these 10 seconds")
# detected spikes the sorter also found: 93%
# cluster 0: unit 1327 x84, unmatched x25, unit 1325 x10
# cluster 1: unit 1333 x27, unit 1327 x18, unmatched x11
# cluster 2: unit 1325 x88, unit 1327 x7, unit 1336 x4
# cluster 3: unit 1310 x57, unit 1322 x2, unit 1325 x2
# cluster 4: unit 1302 x95, unit 1625 x1, unit 1333 x1
# unit 1302: peak site 329, 97 spikes in these 10 seconds
# unit 1325: peak site 333, 105 spikes in these 10 seconds
# unit 1310: peak site 331, 61 spikes in these 10 seconds
# unit 1327: peak site 333, 131 spikes in these 10 seconds

Of the spikes we detected, 93 percent are spikes the sorter also found. Cluster 4 is unit 1302: 95 of its 98 spikes, and the sorter agrees it peaks on site 329. Cluster 3 is unit 1310 on site 331, cluster 2 is unit 1325 on site 333. Three neurons found correctly, from one site, with a threshold, PCA and k-means. Cluster 0 is mostly unit 1327, the fourth neuron on site 333, but 25 of its spikes have no match in the sorter’s output for the units near these sites. Cluster 1 is a mixture of unit 1333 from site 335 with leftovers of 1327, as its refractory violations suggested. And unit 1327 itself has 131 spikes in these ten seconds, of which we found 84 on site 333 and labelled another 18 as cluster 1: the smaller the spike, the more of them fall below the threshold or into the wrong cluster.

That is a fair summary of the difference between what you just wrote and a real spike sorter. Kilosort uses every site, not one, so a neuron is detected wherever it is loudest; it learns a template for each neuron and subtracts it, so two spikes that overlap in time can both be recovered; and it tracks the slow drift of the brain past the probe over an hour, which turns one neuron into two clusters in a naive sort. Those three things are most of what the sorting literature since 2015 has been about. The core idea, though, is the one you implemented: a spike is a point in waveform space, and neurons are clusters.

What you just did

  • Read raw extracellular voltage straight from a Neuropixels probe and converted it to microvolts.
  • High-pass filtered it with SciPy, zero-phase, and saw the spikes stand clear of the slow activity.
  • Estimated the noise level robustly and detected spikes by threshold, with a refractory rule.
  • Cut out multi-site waveforms and saw that a spike has a spatial footprint on the probe.
  • Sorted spikes into neurons with your own PCA and your own k-means, and checked the result with footprints and refractory violations.
  • Compared your sort with a professional pipeline and found three neurons exactly, and learned where the simple method fails and why.

Exercises

  1. Change the threshold to 4 and then 6 noise levels. How does the spike count change on site 333, and what happens to the islands in the component plot?
  2. Run k-means with k = 4. Which two sorter units end up in the same cluster, and what do their footprints have in common? Then try k = 6 and see what splits.
  3. A common step in real pipelines is to subtract the median across all sites at each sample, which removes noise shared by the whole probe. Try it with these eight sites: clean = clean - np.median(clean, axis=1, keepdims=True). The noise drops, but so does something else. Look at figure 3’s right panel and explain why a median over eight neighbouring sites is a bad idea, and why a median over 384 is fine.
  4. Harder: detect spikes on every site rather than just 333, then merge detections that fall within half a millisecond of each other on neighbouring sites, keeping the one with the deepest trough. How many distinct spikes are there in ten seconds, and how many neurons can you now sort out?

Data: International Brain Laboratory, brain-wide map of neural activity during complex behaviour, CC-BY 4.0; session KS023 2019-12-10 (cortexlab), probe 00, sites 328 to 335, seconds 1500 to 1510 of the recording, with the spike times from the IBL’s pykilosort pipeline for units peaking nearby. The code in this lesson runs top to bottom in a few seconds.

What the filter threw away: EEG, LFP and the Fourier transform

In lesson 5 the first thing we did to the raw voltage was throw away everything slower than 300 Hz, because the spikes lived above it. This lesson is about what we threw away. Below 300 Hz, the voltage near an electrode is the summed activity of thousands of neurons at once, the local field potential, and recorded from the scalp through skull and skin it is the EEG. It is where the brain’s rhythms live, and the tool that reveals them is the Fourier transform, which we will build from a dot product before using NumPy’s. By the end you will have reproduced, on real recordings, the first discovery ever made with EEG and the best-known rhythm in the rodent brain.

flowchart LR

A[Frequency: match a signal against sines] --> B[Power spectrum: average over segments]

B --> C[EEG: eyes open vs closed]

C --> D[Spectrogram: power over time]

B --> E[Mouse LFP: 16 depths]

E --> F[Band-pass: pull one rhythm out]
The lesson. The first box is the only new idea; the rest is applying it.

Step 1: what a frequency is, and how to measure it

Any signal can be written as a sum of sine waves at different frequencies, each with its own size and its own shift in time. That is a theorem, not a metaphor, and it means a recording can be described two ways: as a voltage at each moment, or as an amount at each frequency. The Fourier transform converts one into the other. Here is the whole idea in one function: to ask how much of a given frequency is in a signal, multiply the signal by a sine wave at that frequency and add up. Where the two line up, the products are positive; where they do not, they cancel. Do it with a cosine as well, because the rhythm might be shifted in time, and combine the two.

import numpy as np
import matplotlib.pyplot as plt

fs = 200                                                   # samples per second
t = np.arange(0, 4, 1 / fs)                                # four seconds
signal = 2 * np.sin(2 * np.pi * 3 * t) + 1 * np.sin(2 * np.pi * 10 * t + 1) + 0.5 * np.sin(2 * np.pi * 27 * t)

def amplitude_at(x, freq, fs):
    """How much of frequency `freq` is in x: match it against a cosine and a sine of that frequency."""
    tt = np.arange(len(x)) / fs
    c = np.sum(x * np.cos(2 * np.pi * freq * tt))            # how well x lines up with a cosine
    s = np.sum(x * np.sin(2 * np.pi * freq * tt))            # and with a sine (same frequency, shifted)
    return 2 * np.sqrt(c ** 2 + s ** 2) / len(x)             # combine them: the amplitude, whatever the phase

freqs = np.arange(0, 50, 0.25)
amps = np.array([amplitude_at(signal, f, fs) for f in freqs])
for f in freqs[amps > 0.3]:
    print(f"{f:5.2f} Hz: amplitude {amps[freqs == f][0]:.2f}")

spectrum = np.abs(np.fft.rfft(signal)) * 2 / len(signal)   # the same thing, every frequency at once, in a millisecond
f_fft = np.fft.rfftfreq(len(signal), 1 / fs)
print("FFT agrees at 10 Hz:", spectrum[f_fft == 10].round(2))
#  3.00 Hz: amplitude 2.00
# 10.00 Hz: amplitude 1.00
# 27.00 Hz: amplitude 0.50
# FFT agrees at 10 Hz: [1.]
A made-up signal built from three sine waves, and its spectrum, read back from the signal by the matching function. The three ingredients come out at the right frequencies with the right amplitudes. np.fft.rfft gives the same answer for every frequency at once, which is the only reason to use it instead of the loop.
A made-up signal built from three sine waves, and its spectrum, read back from the signal by the matching function. The three ingredients come out at the right frequencies with the right amplitudes. np.fft.rfft gives the same answer for every frequency at once, which is the only reason to use it instead of the loop.

The loop is slow and the fast Fourier transform, FFT, is the same calculation organised cleverly, which is why everyone uses it. But it is worth having written the loop once: a Fourier transform is nothing more than a list of dot products with sine waves, one per frequency, and everything from here on is that.

Step 2: the first rhythm

Hans Berger recorded the first human EEG in Jena in 1924 and published in 1929. His first observation is still the clearest in the field: when a resting subject closes their eyes, a 10-cycle-per-second wave appears over the back of the head and vanishes when they open them. He called it the alpha rhythm because it was the first thing he saw. Here it is, from a subject in the PhysioNet motor-imagery dataset, one minute with eyes open and one with eyes closed, at electrode Oz over visual cortex.

eeg = np.load("eeg_s001_open_closed.zip")
fs = float(eeg["fs"])                                      # 160 samples per second
names = list(eeg["channels"])
oz = names.index("Oz")                                     # the electrode at the back of the head, over visual cortex
open_eyes = eeg["eyes_open"][oz] - eeg["eyes_open"][oz].mean()
closed_eyes = eeg["eyes_closed"][oz] - eeg["eyes_closed"][oz].mean()
te = np.arange(len(open_eyes)) / fs

fig, axes = plt.subplots(2, 1, figsize=(10, 4.6), sharex=True, sharey=True)
axes[0].plot(te, open_eyes, lw=0.7); axes[0].set(ylabel="uV", title="Oz, eyes open")
axes[1].plot(te, closed_eyes, lw=0.7); axes[1].set(ylabel="uV", title="Oz, eyes closed", xlabel="time (s)", xlim=(10, 15))
plt.show()
Five seconds at Oz, eyes open above and eyes closed below. Even by eye the lower trace is more regular: count the waves in any one second of it and you get about ten.
Five seconds at Oz, eyes open above and eyes closed below. Even by eye the lower trace is more regular: count the waves in any one second of it and you get about ten.

Step 3: the power spectrum

To put a number on it, measure how much of each frequency is present. There is one practical wrinkle. A Fourier transform of a whole minute gives a very finely spaced and very noisy spectrum, because the brain does not hold a frequency steady for a minute. The fix, due to Peter Welch in 1967, is to cut the recording into short segments, transform each one, and average the power. Each segment is also tapered at the edges with a window so that the cut itself does not look like a sudden event. Ten lines.

def power_spectrum(x, fs, seconds=2.0):
    """Average power at each frequency, estimated from many short segments (Welch's method)."""
    n = int(seconds * fs)
    window = np.hanning(n)                                 # taper each segment so its edges do not look like jumps
    freqs = np.fft.rfftfreq(n, 1 / fs)
    power = np.zeros(len(freqs))
    segments = 0
    for start in range(0, len(x) - n + 1, n // 2):         # segments overlap by half
        seg = (x[start:start + n] - x[start:start + n].mean()) * window
        power += np.abs(np.fft.rfft(seg)) ** 2
        segments += 1
    return freqs, power / segments / (fs * np.sum(window ** 2))   # scaled to microvolts squared per Hz

f, p_open = power_spectrum(open_eyes, fs)
f, p_closed = power_spectrum(closed_eyes, fs)
alpha = (f >= 8) & (f <= 12)
print(f"alpha band power at Oz: eyes open {p_open[alpha].mean():5.1f}, eyes closed {p_closed[alpha].mean():5.1f}, ratio {p_closed[alpha].mean() / p_open[alpha].mean():.1f}")
print(f"peak of the eyes-closed spectrum between 4 and 30 Hz: {f[(f > 4) & (f < 30)][np.argmax(p_closed[(f > 4) & (f < 30)])]:.1f} Hz")
# alpha band power at Oz: eyes open  24.3, eyes closed 327.4, ratio 13.5
# peak of the eyes-closed spectrum between 4 and 30 Hz: 10.0 Hz

fig, ax = plt.subplots(figsize=(10, 4))
ax.semilogy(f, p_open, label="eyes open")
ax.semilogy(f, p_closed, label="eyes closed")
ax.axvspan(8, 12, color="orange", alpha=0.15)
ax.set(xlabel="frequency (Hz)", ylabel="power (uV$^2$ / Hz)", xlim=(0, 60))
ax.legend()
plt.show()
Power at each frequency for Oz, eyes open in grey and eyes closed in blue, on a log scale with the conventional bands shaded. Eyes closed, a peak appears at exactly 10 Hz with 13 times the alpha power, and a smaller bump near 20 Hz, its harmonic. Everything else is similar. The downward slope on both curves is the 1/f background that every brain recording has.
Power at each frequency for Oz, eyes open in grey and eyes closed in blue, on a log scale with the conventional bands shaded. Eyes closed, a peak appears at exactly 10 Hz with 13 times the alpha power, and a smaller bump near 20 Hz, its harmonic. Everything else is similar. The downward slope on both curves is the 1/f background that every brain recording has.

Thirteen times more alpha power with the eyes closed, with the peak at 10.0 Hz. That is Berger’s result, ninety-seven years on, in a few lines. Two more things in the figure deserve a look. Both spectra fall steadily with frequency: this 1/f shape is universal in brain signals and means that comparing power across bands is only meaningful relative to that slope, which is why the ratio between conditions is a safer number than the raw power. And the bands themselves are conventions, not physics: useful labels for regions of the spectrum where particular rhythms tend to appear.

BandFrequencyWhere you see it
delta1 to 4 HzDeep sleep; large and slow, everywhere
theta4 to 8 Hz (6 to 10 in rodents)Hippocampus during movement and memory; frontal midline during effort
alpha8 to 12 HzVisual cortex at rest with the eyes closed; the first rhythm ever recorded
beta13 to 30 HzMotor cortex holding still; drops before a movement
gamma30 to 100 Hz and upLocal processing; weak at the scalp, strong on the probe

Step 4: where it is

Alpha is a rhythm of visual cortex at rest, so it should be strongest at the back of the head and weaker at the front. The same function on four electrodes:

for ch in ["Oz", "O1", "Cz", "Fp1"]:
    i = names.index(ch)
    _, po = power_spectrum(eeg["eyes_open"][i], fs)
    _, pc = power_spectrum(eeg["eyes_closed"][i], fs)
    print(f"{ch:4s} alpha power, closed / open: {pc[alpha].mean() / po[alpha].mean():4.1f}")
# Oz   alpha power, closed / open: 13.5
# O1   alpha power, closed / open: 15.2
# Cz   alpha power, closed / open:  4.2
# Fp1  alpha power, closed / open:  2.6

Fifteen-fold at O1 and thirteen-fold at Oz, both at the back; four-fold at the vertex; under three-fold at the forehead. The rhythm is generated in and around visual cortex, and what Fp1 sees is the tail of it conducted through the head. The ICA primer showed the same conduction working the other way, carrying eye blinks backwards.

Step 5: power over time

A spectrum averages over the whole recording, which hides when things happen. A spectrogram keeps the time axis: compute a spectrum in a short window, slide the window along, and stack the results into an image with time across and frequency up. The function is the previous one without the averaging.

def spectrogram(x, fs, window_s=1.0, step_s=0.25):
    """Power spectrum in a sliding window: frequency x time."""
    n, step = int(window_s * fs), int(step_s * fs)
    taper = np.hanning(n)
    starts = np.arange(0, len(x) - n, step)
    freqs = np.fft.rfftfreq(n, 1 / fs)
    S = np.array([np.abs(np.fft.rfft((x[s:s + n] - x[s:s + n].mean()) * taper)) ** 2 for s in starts]).T
    return freqs, (starts + n / 2) / fs, S

both = np.concatenate([open_eyes, closed_eyes])            # one minute eyes open, then one minute eyes closed
f_s, t_s, S = spectrogram(both, fs)

fig, ax = plt.subplots(figsize=(10, 4))
ax.pcolormesh(t_s, f_s, np.log10(S), shading="auto")
ax.axvline(len(open_eyes) / fs, color="white", ls="--")
ax.set(ylim=(0, 40), xlabel="time (s)", ylabel="frequency (Hz)")
plt.show()
Oz over two minutes, the eyes-open minute followed by the eyes-closed minute, as a spectrogram: power in one-second windows, brighter is more. The 10 Hz band switches on at the dashed line and stays on, with brief gaps. The low-frequency glow at the bottom is the 1/f background.
Oz over two minutes, the eyes-open minute followed by the eyes-closed minute, as a spectrogram: power in one-second windows, brighter is more. The 10 Hz band switches on at the dashed line and stays on, with brief gaps. The low-frequency glow at the bottom is the 1/f background.

This is how most EEG is actually looked at, because the interesting questions are about change: what happens when the eyes close, when a stimulus appears, when a seizure starts. The trade-off is built into the method: a one-second window cannot tell 10 Hz from 10.5 Hz, and a ten-second window cannot tell you which second the rhythm began in. You choose the window for the question.

Step 6: inside the brain

The scalp sees the whole cortex blurred together. A probe sees one place at a time, and the Neuropixels probe from lesson 5 has 384 sites along its length, so it sees many places at once. This file holds 40 seconds of the slow band, sampled at 2,500 Hz, from 16 sites spaced along a probe in the same mouse, the other probe from the lesson 5 session. The IBL’s histology puts the sites in visual cortex at the top, then white matter, then the hippocampus, then the thalamus at the tip. Compute a spectrum at every depth and ask where the theta band, 6 to 10 Hz in rodents, stands out from the background.

lf = np.load("ibl_ks023_lfp.zip")
lfp = lf["lfp"].astype(float) * float(lf["uv_per_bit"])    # samples x 16 channels, microvolts
fs_lfp = float(lf["fs"])                                   # 2500 samples per second
depth, region = lf["depth_um"], lf["area"]
print(len(lfp) / fs_lfp, "seconds;", "depths (um from tip):", depth.astype(int))
print("regions:", list(region))

theta_ratio = []
for k in range(lfp.shape[1]):
    f_l, p_l = power_spectrum(lfp[:, k], fs_lfp, seconds=2.0)
    theta = (f_l >= 6) & (f_l <= 10)
    delta = (f_l >= 1) & (f_l <= 4)
    theta_ratio.append(p_l[theta].mean() / p_l[delta].mean())
for d, r, ratio in zip(depth, region, theta_ratio):
    print(f"{d:5.0f} um  {r:8s} theta / delta = {ratio:.2f}")
# 40.0 seconds; depths (um from tip): [  40  300  540  800 1040 1300 1540 1800 2040 2300 2540 2800 3040 3300
#  3540 3800]
# regions: ['ml', 'VPM', 'PO', 'PO', 'PO', 'LP', 'LP', 'DG-mo', 'DG-po', 'DG-mo', 'CA1', 'CA1', 'scwm', 'VISa5', 'VISa4', 'VISa2/3']
#    40 um  ml       theta / delta = 0.09
#   300 um  VPM      theta / delta = 0.10
#   540 um  PO       theta / delta = 0.10
#   800 um  PO       theta / delta = 0.10
#  1040 um  PO       theta / delta = 0.11
#  1300 um  LP       theta / delta = 0.13
#  1540 um  LP       theta / delta = 0.15
#  1800 um  DG-mo    theta / delta = 0.20
#  2040 um  DG-po    theta / delta = 0.29
#  2300 um  DG-mo    theta / delta = 0.57
#  2540 um  CA1      theta / delta = 0.55
#  2800 um  CA1      theta / delta = 0.11
#  3040 um  scwm     theta / delta = 0.10
#  3300 um  VISa5    theta / delta = 0.08
#  3540 um  VISa4    theta / delta = 0.07
#  3800 um  VISa2/3  theta / delta = 0.06
Left: three seconds of LFP at four of the sixteen depths. Right: theta power relative to the slow background at every depth, with the brain region from the IBL's histology. Theta is strongest in the dentate gyrus and CA1 of the hippocampus, five times the ratio seen in cortex or thalamus, and in the traces the hippocampal sites are visibly swinging at about seven cycles per second while cortex and thalamus barely move.
Left: three seconds of LFP at four of the sixteen depths. Right: theta power relative to the slow background at every depth, with the brain region from the IBL’s histology. Theta is strongest in the dentate gyrus and CA1 of the hippocampus, five times the ratio seen in cortex or thalamus, and in the traces the hippocampal sites are visibly swinging at about seven cycles per second while cortex and thalamus barely move.

The hippocampus is the one part of this probe that oscillates, and it does so at about 7 Hz, which is the rodent theta rhythm: the signature of a mouse that is moving or attending, generated by a pacemaker in the septum and organising the hippocampus into cycles within which place cells fire in sequence. The peak of the ratio sits at 2,300 micrometres, in the dentate gyrus molecular layer just below CA1, which is where the textbooks put the largest theta amplitude, at the hippocampal fissure. Four hundred micrometres further up, in the CA1 cell layer itself, the ratio has already fallen to 0.11, because the field reverses polarity across the layer and nearly cancels there. The LFP is a spatial signal as much as a temporal one.

Step 7: keep what we threw away

Lesson 5 used a filter to remove the slow activity. Use the same tool to keep only a band of it: a band-pass filter from 6 to 10 Hz, applied to the theta-rich site and to a site at the probe tip, pulls the rhythm out of the raw trace so you can see it against the signal it came from.

from scipy.signal import butter, filtfilt
b, a = butter(3, [6 / (fs_lfp / 2), 10 / (fs_lfp / 2)], "band")   # keep only 6 to 10 Hz
k = int(np.argmax(theta_ratio))                                      # the channel where theta is strongest
theta_wave = filtfilt(b, a, lfp[:, k])
tl = np.arange(len(lfp)) / fs_lfp

fig, axes = plt.subplots(2, 1, figsize=(10, 5), sharex=True)
axes[0].plot(tl, lfp[:, k], lw=0.7)
axes[0].plot(tl, theta_wave, lw=1.4)
axes[0].set(ylabel="uV", title=f"{region[k]}, {depth[k]:.0f} um: raw LFP and its 6 to 10 Hz part")
axes[1].plot(tl, lfp[:, 0], lw=0.7)
axes[1].plot(tl, filtfilt(b, a, lfp[:, 0]), lw=1.4)
axes[1].set(ylabel="uV", xlabel="time (s)", xlim=(10, 13), title=f"{region[0]}, {depth[0]:.0f} um: the same")
plt.show()
Top: the dentate gyrus site, raw LFP in grey and its 6 to 10 Hz component in blue: a near-sinusoidal theta wave of several hundred microvolts, riding under the sharper fluctuations. Bottom: the same filter at the probe tip, in a fibre tract below the thalamus, finds a wave fifty times smaller. A filter always returns something in its band; whether it means anything is what the spectrum tells you.
Top: the dentate gyrus site, raw LFP in grey and its 6 to 10 Hz component in blue: a near-sinusoidal theta wave of several hundred microvolts, riding under the sharper fluctuations. Bottom: the same filter at the probe tip, in a fibre tract below the thalamus, finds a wave fifty times smaller. A filter always returns something in its band; whether it means anything is what the spectrum tells you.

That last point is the warning for this lesson. A band-pass filter will produce a 6 to 10 Hz wave from any signal, including pure noise, because that is what it does. The lower trace shows it: a neat small sinusoid from a site with no theta rhythm at all. Filtered traces are for seeing; the spectrum is for deciding.

What you just did

  • Measured the amount of a frequency in a signal with a dot product, and understood the FFT as that, done for every frequency at once.
  • Written Welch’s method for a power spectrum and a spectrogram, each in about ten lines.
  • Reproduced Berger’s 1929 discovery: a 10 Hz alpha rhythm over visual cortex that appears when the eyes close, thirteen times stronger, and fades toward the front of the head.
  • Mapped a rhythm to a place in the brain: hippocampal theta at 7 Hz, strongest at the dentate gyrus, absent in cortex and thalamus on the same probe.
  • Used a band-pass filter to isolate a rhythm, and learned why a filtered trace is not evidence that the rhythm exists.

Exercises

  1. Change the segment length in power_spectrum to 0.5 and then 8 seconds. What happens to the sharpness of the alpha peak, and to the roughness of the curve? This is the same trade-off as the spectrogram window.
  2. Repeat step 3 for every one of the 64 channels and plot the closed/open alpha ratio on the schematic head from the ICA primer. Where exactly is the rhythm?
  3. In the LFP file, compute the spectrum at the hippocampal sites in two-second windows across the 40 seconds. Does theta come and go? Mice run in bouts; theta should follow.
  4. Harder: at the CA1 site at 2,800 um the theta ratio was low, and the text claims the field reverses polarity across the cell layer. Test it: band-pass both CA1 sites and plot them together. Are the theta waves in phase or opposite?

Data: EEG from the PhysioNet EEG Motor Movement/Imagery Dataset (Schalk et al. 2004; Goldberger et al. 2000), subject 1, runs 1 and 2, Open Data Commons Attribution licence. LFP from the International Brain Laboratory brain-wide map, CC-BY 4.0, session KS023 2019-12-10, probe 01, LF band, seconds 1500 to 1540, with brain regions from the IBL’s histology alignment. The code runs top to bottom in a few seconds.

The brain getting ready to move: motor imagery and your first BCI signal

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 pipeline, which is lesson 2 applied to band power instead of spikes, ending in lesson 3.

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%
Real fist clenching: 8 to 30 Hz power at C3 and C4 as a percentage of the pre-cue baseline, averaged over 23 left-hand and 22 right-hand trials. After the cue both hemispheres drop, but the hemisphere opposite the hand drops more: C4 for the left fist, C3 for the right. The rise after 2.5 s is the rebound at the end of the movement, and the bumps before the cue are the tail of the previous trial.
Real fist clenching: 8 to 30 Hz power at C3 and C4 as a percentage of the pre-cue baseline, averaged over 23 left-hand and 22 right-hand trials. After the cue both hemispheres drop, but the hemisphere opposite the hand drops more: C4 for the left fist, C3 for the right. The rise after 2.5 s is the rebound at the end of the movement, and the bumps before the cue are the tail of the previous trial.

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%
The 8 to 30 Hz drop over the whole head during real movement. Left fist (left head): the largest drops are at the back, over visual cortex, not over motor cortex at all. Right fist (middle): drops concentrated at C3 and Cp3, over the left hand area. Right: the difference between the two, which cancels everything the two hands share and leaves the hand-specific pattern.
The 8 to 30 Hz drop over the whole head during real movement. Left fist (left head): the largest drops are at the back, over visual cortex, not over motor cortex at all. Right fist (middle): drops concentrated at C3 and Cp3, over the left hand area. Right: the difference between the two, which cancels everything the two hands share and leaves the hand-specific pattern.

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()
Imagined movement, before and after the spatial filter. Top: plain C3 and C4, where the two hemispheres move together and the hand cannot be read. Bottom: the Laplacian versions, where imagining the left fist now drops C4 and imagining the right fist drops C3, exactly as real movement did, and the opposite hemisphere rises.
Imagined movement, before and after the spatial filter. Top: plain C3 and C4, where the two hemispheres move together and the hand cannot be read. Bottom: the Laplacian versions, where imagining the left fist now drops C4 and imagining the right fist drops C3, exactly as real movement did, and the opposite hemisphere rises.
Left: the Laplacian at C3, a site minus the mean of its four neighbours. Right: cross-validated accuracy of the two-feature decoder, before and after. The filter takes imagined movement from chance to 84 percent.
Left: the Laplacian at C3, a site minus the mean of its four neighbours. Right: cross-validated accuracy of the two-feature decoder, before and after. The filter takes imagined movement from chance to 84 percent.

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

  1. 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.
  2. 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?
  3. 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.
  4. 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.