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.

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.

Primer: Bayesian inference

Bayesian inference is a rule for changing your mind. You start with a belief about something you cannot see, a neuron’s true firing rate, say, or which stimulus was on the screen. Data arrive. The rule tells you exactly how much to move your belief, and in which direction, and it leaves you with a new belief that is ready for the next piece of data. This primer builds the rule from one line of arithmetic, applies it to a neuron from the lessons, and then uses it to decode the stimulus the way lesson 3 did, but with an answer that includes how sure it is. It assumes lessons 2 and 3.

The rule

Three ingredients, each a curve over the thing you want to know. The prior: how plausible each possible value was before the data. The likelihood: for each possible value, how probable the data you actually saw would have been. The posterior: how plausible each value is now. Bayes’ rule says the posterior is the prior times the likelihood, rescaled so the total is one. That is the whole thing. Everything else is bookkeeping.

Bayes' rule as three pictures, for the question "what is neuron 141's firing rate on blank trials?" after one trial in which it fired twice in 0.2 seconds. Left: a flat prior. Middle: the likelihood, which peaks at 10 Hz (two spikes in a fifth of a second) but is wide, because two spikes is not much evidence. Right: their product. With a flat prior the posterior is just the likelihood, rescaled.
Bayes’ rule as three pictures, for the question “what is neuron 141’s firing rate on blank trials?” after one trial in which it fired twice in 0.2 seconds. Left: a flat prior. Middle: the likelihood, which peaks at 10 Hz (two spikes in a fifth of a second) but is wide, because two spikes is not much evidence. Right: their product. With a flat prior the posterior is just the likelihood, rescaled.

The computational trick that makes this a two-line program is the grid. Rather than deriving formulas, lay out every value you are willing to consider, here 800 firing rates from 0 to 40 Hz, and compute the three curves as arrays. Multiplying two arrays and dividing by the sum is Bayes’ rule.

import numpy as np
import matplotlib.pyplot as plt
from math import factorial

dat = np.load("steinmetz_session11.zip", allow_pickle=True)
spks = dat["spks"]
area = dat["brain_area"]
contrast = dat["contrast_right"]
dt = float(dat["bin_size"])
t = np.arange(spks.shape[2]) * dt - 0.5
post = (t >= 0.05) & (t < 0.25)
window = post.sum() * dt                                   # 0.2 seconds

counts = spks[141][:, post].sum(axis=1)[contrast == 0]     # neuron 141 on the 167 blank-screen trials
print("first ten counts:", counts[:10])

rates = np.linspace(0.05, 40, 800)                         # every firing rate we are willing to consider, in Hz
prior = np.ones_like(rates)                                # flat: no opinion before seeing data
prior /= prior.sum()

def likelihood(count, rate):
    """Probability of seeing `count` spikes in the window if the true rate is `rate` (Poisson)."""
    expected = rate * window
    return expected ** count * np.exp(-expected) / factorial(count)

like = likelihood(counts[0], rates)                        # one trial's evidence, evaluated at every rate
posterior = prior * like
posterior /= posterior.sum()
print(f"after one trial with {counts[0]} spikes: most probable rate {rates[posterior.argmax()]:.1f} Hz")
# first ten counts: [0 2 1 0 0 0 0 1 0 0]
# after one trial with 0 spikes: most probable rate 0.1 Hz

The first blank trial had zero spikes, so after one trial the most probable rate is as low as the grid allows. One trial is weak evidence; the posterior still spreads up past 18 Hz. The interesting part is what happens next.

Updating, trial by trial

The rule is designed to be applied repeatedly: today’s posterior is tomorrow’s prior. Feed in the 167 blank trials one at a time and watch the belief sharpen. The interval function reads off the range that holds 95 percent of the probability, which is the Bayesian answer to “how sure are you?”.

def update(prior, count):
    posterior = prior * likelihood(count, rates)
    return posterior / posterior.sum()

def interval(p, mass=0.95):
    """The narrowest range of rates holding `mass` of the probability."""
    cdf = np.cumsum(p)
    return rates[np.searchsorted(cdf, (1 - mass) / 2)], rates[np.searchsorted(cdf, 1 - (1 - mass) / 2)]

belief = prior.copy()
snapshots = {}
for i, c in enumerate(counts):
    belief = update(belief, c)                              # today's posterior is tomorrow's prior
    if i + 1 in (1, 5, 20, len(counts)):
        snapshots[i + 1] = belief.copy()
        lo, hi = interval(belief)
        print(f"after {i + 1:3d} trials: mean {np.sum(rates * belief):4.1f} Hz, 95% interval {lo:4.1f} to {hi:4.1f} Hz")
# after   1 trials: mean  5.0 Hz, 95% interval  0.2 to 18.4 Hz
# after   5 trials: mean  4.0 Hz, 95% interval  1.1 to  8.8 Hz
# after  20 trials: mean  2.2 Hz, 95% interval  1.1 to  4.0 Hz
# after 167 trials: mean  7.7 Hz, 95% interval  6.8 to  8.7 Hz

fig, ax = plt.subplots(figsize=(10, 3.8))
for n, p in snapshots.items():
    ax.plot(rates, p / p.max(), label=f"after {n} trials")
ax.set(xlabel="firing rate (Hz)", ylabel="posterior (scaled)", xlim=(0, 20))
ax.legend()
plt.show()
The posterior over neuron 141's blank-trial firing rate after 1, 5, 20 and 167 trials. It narrows as evidence accumulates, from a 95% interval 18 Hz wide to one 2 Hz wide. But notice that it also moves: after 20 trials it is confident the rate is around 2 Hz, and after 167 it is confident the rate is around 8. Both were right about the data they had seen.
The posterior over neuron 141’s blank-trial firing rate after 1, 5, 20 and 167 trials. It narrows as evidence accumulates, from a 95% interval 18 Hz wide to one 2 Hz wide. But notice that it also moves: after 20 trials it is confident the rate is around 2 Hz, and after 167 it is confident the rate is around 8. Both were right about the data they had seen.

Two things to take from this figure. The first is the point of the method: the width of the belief tracks the amount of evidence, automatically, with no separate theory of error bars. The second is a warning that every Bayesian analysis carries. After 20 trials the posterior said, with 95 percent confidence, that the rate was between 1.1 and 4.0 Hz. After 167 trials it said, with the same confidence, 6.8 to 8.7. Both cannot be right about the same quantity, and the resolution is that there is no such quantity: this neuron’s firing rate rose through the session, as the GLM primer found, and our model assumed a single constant rate. The posterior was exactly as confident as the model allowed, and the model was wrong. Bayesian inference tells you what to believe given the model. Checking the model is still your job.

Decoding with uncertainty

Now the question from lesson 3, in Bayesian form: given the spike counts of the 66 visual cortex neurons on one trial, what was the contrast? The unknown is now one of four values rather than a rate on a grid, and the data are 66 counts rather than one. The recipe does not change. The prior is how often each contrast occurred in the training trials. The likelihood of the counts, treating each neuron as an independent Poisson counter with the mean rate it showed at that contrast in training, is a product of 66 terms per contrast, which we compute as a sum of logs so nothing underflows. Multiply, rescale, and the posterior is four probabilities.

visp = np.where(area == "VISp")[0]
X = spks[visp][:, :, post].sum(axis=2).T                   # trials x neurons, as in lesson 3
levels = np.unique(contrast)
rng = np.random.default_rng(0)
order = rng.permutation(len(contrast))
train, test = order[:240], order[240:]

expected = np.array([X[train][contrast[train] == c].mean(axis=0) + 0.1 for c in levels])   # 4 x neurons
prior_c = np.array([np.mean(contrast[train] == c) for c in levels])                        # how common each contrast is

def posterior_over_contrast(counts):
    """P(contrast | counts) for one trial, treating neurons as independent Poisson counters."""
    log_like = (counts * np.log(expected) - expected).sum(axis=1)     # one number per contrast level
    log_post = log_like + np.log(prior_c)
    p = np.exp(log_post - log_post.max())                             # subtract the max before exp, for numerical safety
    return p / p.sum()

P = np.array([posterior_over_contrast(X[i]) for i in test])          # 100 test trials x 4
guess = levels[P.argmax(axis=1)]
truth = contrast[test]
print(f"four-way accuracy: {np.mean(guess == truth):.0%}")
print(f"stimulus-or-blank accuracy: {np.mean((guess > 0) == (truth > 0)):.0%}")
print(f"average confidence when right: {P.max(axis=1)[guess == truth].mean():.2f}, when wrong: {P.max(axis=1)[guess != truth].mean():.2f}")
# four-way accuracy: 78%
# stimulus-or-blank accuracy: 100%
# average confidence when right: 0.98, when wrong: 0.93

fig, axes = plt.subplots(1, 4, figsize=(11, 3.2), sharey=True)
for ax, i in zip(axes, [0, 1, 2, 3]):
    ax.bar([str(c) for c in levels], P[i])
    ax.set(title=f"trial {test[i]}: truth {truth[i]}", xlabel="contrast")
axes[0].set_ylabel("posterior probability")
plt.show()
Left: the posterior over the four contrasts on four test trials. Most trials look like the first two: one bar, and it is right. The third is the least confident of the 100 test trials, torn between 0.25 and 0.5. The fourth is confidently wrong. Right: the confusion matrix. Blank versus stimulus is perfect; the three stimulus contrasts blur into each other.
Left: the posterior over the four contrasts on four test trials. Most trials look like the first two: one bar, and it is right. The third is the least confident of the 100 test trials, torn between 0.25 and 0.5. The fourth is confidently wrong. Right: the confusion matrix. Blank versus stimulus is perfect; the three stimulus contrasts blur into each other.

Three things to read off. First, 78 percent four-way accuracy and 100 percent on stimulus-versus-blank, from a model with no training loop at all, just means and Bayes’ rule; this is the “naive Bayes” classifier, and it is often a strong baseline. Second, the posterior gives you something lesson 3’s decoder did not: a confidence per trial, and the confusion matrix shows exactly where it is uncertain, between 0.25 and 0.5 contrast, where the neurons’ responses genuinely overlap. Third, the warning again. The decoder’s average confidence is 0.98 when right and 0.93 when wrong. A decoder that is wrong 22 percent of the time should not be 93 percent sure when it is wrong. It is overconfident because we assumed the 66 neurons were independent and they are not; they share noise, so 66 neurons carry less evidence than 66 independent ones would. The arithmetic was correct. The assumption was generous, and the posterior inherited its generosity.

What to remember

  • Posterior equals prior times likelihood, rescaled. On a grid, that is two lines of NumPy, and it is the same two lines whether the unknown is a rate, a stimulus, or a set of model weights.
  • The width of the posterior is your uncertainty. It narrows with evidence and nothing else; there is no separate recipe for error bars.
  • Priors matter least when data are plentiful and most when they are scarce, which is exactly when you most need to state them honestly. With 167 trials, the flat prior above was irrelevant.
  • The posterior is only as good as the model. Constant-rate and independent-neuron assumptions produced confident, precise, wrong answers above. Bayes guarantees coherence, not truth.
  • Lesson 3’s decoder is a cousin. Logistic regression with a penalty on large weights is the same as finding the most probable weights under a Gaussian prior; most of machine learning can be read this way.

Exercises

  1. Replace the flat prior with one that believes rates near 2 Hz (lesson 1’s typical neuron), for instance prior = np.exp(-(rates - 2)**2 / 8). How many trials does it take for the data to overrule it?
  2. Split the blank trials into first and second halves and run the update separately on each. Do the two posteriors overlap?
  3. Shuffle the contrast labels of the training trials and rerun the decoder. What accuracy and what confidence do you get, and why is the second number the more alarming one?
  4. Harder: the decoder is overconfident because it treats neurons as independent. Use only the 10 neurons with the largest contrast effect and check whether confidence-when-wrong falls. Then think about why fewer neurons can give better-calibrated answers.

This completes the three primers; the next numbered lesson picks up the Signals track. Data: Steinmetz et al., Nature 2019, CC-BY 4.0, via Neuromatch Academy.

Primer: independent component analysis

Independent component analysis, ICA, solves a problem that sounds impossible. Several signals were mixed together before you recorded them, you have only the mixtures, and you want the originals back. It is the cocktail party: two people talking, two microphones, each microphone hearing both. In neuroscience the “people” are an eye blink, a heartbeat, a muscle, a patch of cortex, and the “microphones” are electrodes on the scalp, all hearing everything. This primer shows how ICA does it, writes the algorithm in twenty lines, and uses it to pull the eye blinks out of a real EEG recording. It assumes lesson 4, because ICA starts where PCA stops.

The problem, in two channels

Start with a case where we know the answer. Two sources, a sine wave and a square wave. Two sensors, each recording a different blend of the two. Nobody tells the algorithm the blend.

import numpy as np
import matplotlib.pyplot as plt

rng = np.random.default_rng(0)
n = 2000
time = np.linspace(0, 8, n)
s1 = np.sin(2 * np.pi * time)                              # a smooth rhythm
s2 = np.sign(np.sin(2 * np.pi * 1.7 * time + 1))           # a square wave at a different rate
S = np.vstack([s1, s2])                                    # 2 sources x n samples
A = np.array([[1.0, 0.6],                                  # how much of each source reaches each sensor
              [0.4, 1.0]])
X = A @ S + 0.05 * rng.standard_normal(S.shape)            # 2 sensors x n samples: mixtures plus a little noise
print("sensor 1 = 1.0 * s1 + 0.6 * s2;  sensor 2 = 0.4 * s1 + 1.0 * s2")
# sensor 1 = 1.0 * s1 + 0.6 * s2;  sensor 2 = 0.4 * s1 + 1.0 * s2

Why PCA cannot do it, and what can

The obvious tool is PCA from lesson 4: find the directions the data vary along most. But PCA has a built-in assumption that gives the wrong answer here. Its components are always at right angles to each other, and the directions the sources mix along are not at right angles unless you are lucky. PCA will decorrelate the sensors, which removes the linear dependence between them, but a sine and a square wave that are uncorrelated can still be thoroughly mixed.

The two sensor readings plotted against each other. The cloud is two parallel bands: the square wave jumps between them, the sine wave slides along them. Left: PCA's axes, forced to be perpendicular, cut across both bands. Right: ICA's axes run along the bands and between them, which are the directions the two sources were actually added along.
The two sensor readings plotted against each other. The cloud is two parallel bands: the square wave jumps between them, the sine wave slides along them. Left: PCA’s axes, forced to be perpendicular, cut across both bands. Right: ICA’s axes run along the bands and between them, which are the directions the two sources were actually added along.

ICA uses a different clue: independence, which is stronger than uncorrelatedness, and a fact about sums. When you add independent signals together the result is always closer to a bell curve than the ingredients were; this is the central limit theorem doing what it does. So a mixture is more Gaussian than its sources, and the way to unmix is to search for the directions along which the data look least Gaussian. A sine wave and a square wave are both very un-bell-shaped, and in the right directions you see that; in the wrong directions you see a blur.

FastICA, the standard algorithm, does exactly this. First it whitens the data with PCA, which handles the scaling and leaves only a rotation to find. Then it rotates, step by step, to maximise a measure of non-Gaussianity, keeping the rows orthogonal so that it finds different sources rather than the same one twice. The non-Gaussianity measure is built from tanh, for reasons that are in the paper and do not matter here.

def whiten(X, k):
    """PCA-whiten: rotate to the top k components and scale each to unit variance."""
    Xc = X - X.mean(axis=1, keepdims=True)
    cov = Xc @ Xc.T / Xc.shape[1]
    variance, vectors = np.linalg.eigh(cov)
    order = np.argsort(variance)[::-1][:k]
    variance, vectors = variance[order], vectors[:, order]
    W = (vectors / np.sqrt(variance)).T                    # k x channels
    unwhiten = vectors * np.sqrt(variance)                 # channels x k: undoes W
    return W @ Xc, unwhiten

def fastica(Z, seed=0, steps=200):
    """Find a rotation of whitened data Z that makes the rows as non-Gaussian as possible."""
    k = Z.shape[0]
    W = np.random.default_rng(seed).standard_normal((k, k))
    for _ in range(steps):
        Y = W @ Z
        g, g_prime = np.tanh(Y), 1 - np.tanh(Y) ** 2       # the non-linearity and its derivative
        W_new = g @ Z.T / Z.shape[1] - g_prime.mean(axis=1)[:, None] * W
        u, _, vt = np.linalg.svd(W_new)
        W_new = u @ vt                                     # keep the rows orthogonal (decorrelated)
        if np.max(np.abs(np.abs((W_new * W).sum(axis=1)) - 1)) < 1e-6:
            break                                          # nothing changed: converged
        W = W_new
    return W

Z, unwhiten = whiten(X, 2)
W = fastica(Z)
recovered = W @ Z
for i in range(2):
    print(f"recovered {i}: correlation with s1 {abs(np.corrcoef(recovered[i], s1)[0, 1]):.2f}, with s2 {abs(np.corrcoef(recovered[i], s2)[0, 1]):.2f}")
# recovered 0: correlation with s1 0.99, with s2 0.00
# recovered 1: correlation with s1 0.03, with s2 1.00
Top: the two sources, which the algorithm never sees. Middle: what the two sensors record. Bottom: what ICA recovers from the sensors alone. The recovered signals match the originals to a correlation of 0.99 and 1.00, up to two things ICA cannot know: their order and their sign.
Top: the two sources, which the algorithm never sees. Middle: what the two sensors record. Bottom: what ICA recovers from the sensors alone. The recovered signals match the originals to a correlation of 0.99 and 1.00, up to two things ICA cannot know: their order and their sign.

That last sentence is the first thing to remember about ICA in practice: the components come out in no particular order, scaled arbitrarily, and possibly upside down. Nothing in the data says which source is “first” or what its units are. You identify them afterwards by what they look like, which is what the EEG example is about.

A real cocktail party: 64 channels of EEG

The file holds one minute of resting EEG from one person, 64 electrodes at 160 samples per second, from the PhysioNet motor-imagery dataset. Plot four channels. Fp1 sits on the forehead above the left eye.

eeg = np.load("eeg_s001r01.npz")
E = eeg["eeg"].astype(float)                               # 64 channels x 9760 samples, microvolts
fs = float(eeg["fs"])                                      # 160 samples per second
names = list(eeg["channels"])
E = E - E.mean(axis=1, keepdims=True)
te = np.arange(E.shape[1]) / fs
print(E.shape, "channels x samples,", E.shape[1] / fs, "seconds")
# (64, 9760) channels x samples, 61.0 seconds

fig, axes = plt.subplots(4, 1, figsize=(10, 6), sharex=True)
for ax, ch in zip(axes, ["Fp1", "Fz", "Cz", "Oz"]):
    ax.plot(te, E[names.index(ch)], lw=0.7)
    ax.set(ylabel=ch, xlim=(0, 20))
axes[-1].set_xlabel("time (s)")
plt.show()
Twenty seconds on four channels. The large slow deflections on Fp1, several hundred microvolts, are eye blinks: the eyeball is a dipole, and turning it under the lid produces a voltage far bigger than anything the cortex makes. The same blinks are visible, smaller, on Fz and even Cz.
Twenty seconds on four channels. The large slow deflections on Fp1, several hundred microvolts, are eye blinks: the eyeball is a dipole, and turning it under the lid produces a voltage far bigger than anything the cortex makes. The same blinks are visible, smaller, on Fz and even Cz.

Blinks are the classic ICA target because they satisfy every assumption: one source, fixed location, mixed into every channel in fixed proportions, and about as non-Gaussian as a signal gets. Run the same two functions on 64 channels, keeping 20 components, and ask which component looks most like the forehead.

Z, unwhiten = whiten(E, 20)                                # keep the 20 strongest components of 64 channels
W = fastica(Z)
sources = W @ Z                                            # 20 independent components x samples
patterns = unwhiten @ W.T                                  # 64 x 20: how much of each component each channel sees

front = (E[names.index("Fp1")] + E[names.index("Fp2")]) / 2
similarity = [abs(np.corrcoef(s, front)[0, 1]) for s in sources]
blink = int(np.argmax(similarity))
print(f"component {blink} looks most like the forehead channels: correlation {similarity[blink]:.2f}")
strongest = np.argsort(-np.abs(patterns[:, blink]))[:6]
print("channels where it is strongest:", [names[i] for i in strongest])
# component 18 looks most like the forehead channels: correlation 0.93
# channels where it is strongest: ['Fp1', 'Fp2', 'Af8', 'Fpz', 'Af7', 'Af3']
Left: six of the twenty components, with the blink component on top: three large blinks in twenty seconds, and nothing else. Right: how much of that component each electrode sees, drawn on a schematic head. It lives on the forehead, strongest at Fp1, Fpz and Fp2, and fades to nothing by the central row.
Left: six of the twenty components, with the blink component on top: three large blinks in twenty seconds, and nothing else. Right: how much of that component each electrode sees, drawn on a schematic head. It lives on the forehead, strongest at Fp1, Fpz and Fp2, and fades to nothing by the central row.

The component the algorithm found correlates 0.93 with the forehead channels, and its scalp pattern is a textbook blink: largest at the three electrodes nearest the eyes, falling off smoothly backwards. Nothing told the algorithm where the eyes are. The pattern comes from the mixing proportions it discovered, which are the physics of a dipole under the forehead.

Removing it

Because ICA gives you both the sources and the mixing pattern, you can set one source to zero and rebuild the channels without it. That is artifact removal, and it is what every EEG pipeline does with blinks, heartbeats and muscle.

sources_clean = sources.copy()
sources_clean[blink] = 0                                   # silence the blink component
E_clean = patterns @ sources_clean                         # and rebuild the 64 channels without it

for ch in ["Fp1", "Fz", "Cz"]:
    i = names.index(ch)
    print(f"{ch}: standard deviation {E[i].std():5.1f} uV before, {E_clean[i].std():5.1f} uV after")
# Fp1: standard deviation 110.3 uV before,  46.1 uV after
# Fz: standard deviation  60.8 uV before,  41.6 uV after
# Cz: standard deviation  54.1 uV before,  47.7 uV after

fig, axes = plt.subplots(3, 1, figsize=(10, 5.5), sharex=True)
axes[0].plot(te, sources[blink], lw=0.8)
axes[0].set(ylabel="blink component")
axes[1].plot(te, E[names.index("Fp1")], lw=0.7, label="Fp1 before")
axes[1].plot(te, E_clean[names.index("Fp1")], lw=0.7, label="Fp1 after")
axes[1].legend()
axes[2].plot(te, E[names.index("Cz")], lw=0.7, label="Cz before")
axes[2].plot(te, E_clean[names.index("Cz")], lw=0.7, label="Cz after")
axes[2].legend()
axes[2].set(xlabel="time (s)", xlim=(0, 20))
plt.show()
Top: the blink component. Middle: Fp1 before and after removing it; the blinks are gone and the brain signal underneath is untouched. Bottom: the same for Cz, where the blinks were small to begin with and the change is slight.
Top: the blink component. Middle: Fp1 before and after removing it; the blinks are gone and the brain signal underneath is untouched. Bottom: the same for Cz, where the blinks were small to begin with and the change is slight.

Fp1’s standard deviation drops from 110 to 46 microvolts, which is the blinks leaving. Cz barely changes, because little of the blink reached it. What remains on Fp1 is the EEG that was always there under the blinks, and that is what you analyse.

What ICA assumes, and when it fails

  • Sources are independent. Not just uncorrelated. Two brain regions that co-activate are not independent and ICA may split them strangely.
  • At most one source is Gaussian. The method finds non-Gaussianity; two Gaussian sources cannot be told apart by any rotation.
  • Mixing is linear and fixed. True for the volume conduction of EEG and MEG, which is why ICA works so well there. Not true if the source moves, which is why a subject who shifts in the chair halfway through gives two blink components.
  • You need enough data. The usual rule of thumb is at least 20 to 30 times the square of the number of components. One minute at 160 Hz, 9,760 samples, is enough for 20 components (20 x 400 = 8,000) and nowhere near enough for 64 (which would want 80,000 or more).
  • It does not tell you what the components are. Order, sign and scale are arbitrary, and identifying the blink was our job. Automated labelling exists (ICLabel is the common one) but it is a classifier trained on human labels, not part of ICA.

Exercises

  1. Run fastica with a different seed. Which components change order? Which change sign? Do any change shape?
  2. Keep 10 components instead of 20, then 40. At what point does the blink component stop being clean?
  3. Find the component with the highest kurtosis (((s - s.mean())**4).mean() / s.var()**2 - 3) instead of the highest correlation with Fp1. Is it the blink? If not, plot it and its scalp pattern and work out what it is.
  4. Harder: the recording also contains a heartbeat somewhere, a sharp spike about once a second with a pattern that is strongest at the back and sides of the head. Find it.

Next primer: Bayesian inference. Data: EEG Motor Movement/Imagery Dataset (Schalk et al. 2004) via PhysioNet (Goldberger et al. 2000), subject 1, run 1, Open Data Commons Attribution licence. FastICA: Hyvärinen and Oja, 2000.

Primer: the generalised linear model

This is the first of three short primers on methods that keep turning up in the lessons, written for someone who has done lesson 2 and lesson 3 and wants the idea behind the tools. Each one is introductory, runs on the same real data as the lessons, and takes about twenty minutes.

A generalised linear model, or GLM, is the thing you reach for when you want to know what a neuron responds to and there is more than one candidate. The tuning curve in lesson 2 answered “how does firing depend on contrast?” by averaging. A GLM answers “how does firing depend on contrast, and on what the mouse did, and on how long the experiment has been running, all at once, with each effect measured while the others are held fixed?” It is the workhorse of systems neuroscience, and the good news is that you have already fitted one: logistic regression in lesson 3 is a GLM.

One recipe, three dishes

Every GLM has the same three parts. A weighted sum of the predictors, exactly as in linear regression: so many points per unit of contrast, so many for turning the wheel, plus a baseline. A link, a fixed function that bends that sum into the range the data can actually occupy. And a noise model, a statement of how the data scatter around the prediction. Change the link and the noise model and you get a different member of the family.

The same recipe three times, on made-up data. Left: no link, Gaussian noise; the familiar straight line. Middle: a log-odds link squashes the line into a probability between 0 and 1, and the data are yes/no outcomes; this is logistic regression, lesson 3. Right: a log link makes the prediction an always-positive rate, and the data are counts that scatter more when the rate is higher; this is Poisson regression, the one neuroscience uses most.
The same recipe three times, on made-up data. Left: no link, Gaussian noise; the familiar straight line. Middle: a log-odds link squashes the line into a probability between 0 and 1, and the data are yes/no outcomes; this is logistic regression, lesson 3. Right: a log link makes the prediction an always-positive rate, and the data are counts that scatter more when the rate is higher; this is Poisson regression, the one neuroscience uses most.

Spike counts are the right-hand panel. They cannot be negative, so the prediction should not be either, which the log link guarantees: the model predicts log(rate), and the rate is exp of that. And their noise is not constant. Look at neuron 141 from lesson 2, counting spikes in the 200 ms after the stimulus:

import numpy as np
import matplotlib.pyplot as plt

dat = np.load("steinmetz_session11.zip", allow_pickle=True)
spks = dat["spks"]
contrast = dat["contrast_right"]
contrast_left = dat["contrast_left"]
response = dat["response"]                 # -1 wheel left, 0 no move, 1 wheel right
dt = float(dat["bin_size"])
t = np.arange(spks.shape[2]) * dt - 0.5
post = (t >= 0.05) & (t < 0.25)

y = spks[141][:, post].sum(axis=1)         # neuron 141's spike count on each trial, the thing we will model
levels = np.unique(contrast)
for c in levels:
    print(f"contrast {c}: mean count {y[contrast == c].mean():5.2f}, variance {y[contrast == c].var():5.2f}")
# contrast 0.0: mean count  1.53, variance  4.13
# contrast 0.25: mean count  7.06, variance 13.66
# contrast 0.5: mean count 10.10, variance 24.17
# contrast 1.0: mean count 13.26, variance 21.34
Neuron 141's spike-count variance against its mean, one point per contrast level. The dashed line is what a Poisson process would give: variance equal to the mean. Real neurons are a bit noisier than that, but the pattern is unmistakable: the more it fires, the more it varies.
Neuron 141’s spike-count variance against its mean, one point per contrast level. The dashed line is what a Poisson process would give: variance equal to the mean. Real neurons are a bit noisier than that, but the pattern is unmistakable: the more it fires, the more it varies.

A straight-line fit with constant noise would treat a two-spike miss on a blank trial and a two-spike miss on a full-contrast trial as equally surprising. They are not. The Poisson model knows that, and that is most of why it fits spike data better.

Fitting one in eight lines

Fitting means choosing the weights that make the observed counts most probable. For a Poisson GLM the gradient of that probability has a form so simple it is worth remembering: for each predictor, add up (observed count minus predicted count) times the predictor, over trials. Walk uphill along that gradient and you arrive at the best weights. This is the same loop as lesson 3’s decoder with exp in place of the sigmoid.

def poisson_glm(X, y, lr=0.05, steps=20000):
    """Fit log(rate) = b0 + X @ b by gradient ascent on the Poisson log-likelihood."""
    X1 = np.column_stack([np.ones(len(y)), X])        # a column of ones for the intercept
    w = np.zeros(X1.shape[1])
    for _ in range(steps):
        rate = np.exp(X1 @ w)                          # the inverse link: weights to a positive rate
        w += lr * X1.T @ (y - rate) / len(y)           # the gradient is just (observed - predicted) times X
    return w

def predict(X, w):
    return np.exp(np.column_stack([np.ones(len(X)), X]) @ w)

X_lin = contrast[:, None]                             # one predictor: contrast as a number
w_lin = poisson_glm(X_lin, y)
print("log rate =", w_lin.round(2), "-> predicted counts:", predict(levels[:, None], w_lin).round(2))

X_cat = np.column_stack([(contrast == c) for c in levels[1:]]).astype(float)   # one column per non-zero contrast
w_cat = poisson_glm(X_cat, y)
print("one-hot ->", predict(np.vstack([np.zeros(3), np.eye(3)]), w_cat).round(2))
# log rate = [1.   1.71] -> predicted counts: [ 2.73  4.18  6.4  15.02]
# one-hot -> [ 1.53  7.06 10.1  13.26]

Two fits of the same neuron. The first uses contrast as a single number and predicts 2.7, 4.2, 6.4 and 15 spikes at the four levels. The real means are 1.5, 7.1, 10.1 and 13.3. The fit is poor, and the reason is instructive: with one weight on contrast, the log link forces the rate to grow exponentially with contrast, and this neuron saturates instead. The second fit gives each contrast level its own weight, and recovers the four means exactly. That is not a coincidence: a tuning curve is a GLM with one weight per condition.

Neuron 141's contrast tuning (black, with standard errors) and the two GLM fits. One weight on contrast as a number forces an exponential shape that misses every point. One weight per contrast level reproduces the tuning curve from lesson 2 exactly.
Neuron 141’s contrast tuning (black, with standard errors) and the two GLM fits. One weight on contrast as a number forces an exponential shape that misses every point. One weight per contrast level reproduces the tuning curve from lesson 2 exactly.

The lesson from the orange curve generalises: the link function imposes a shape on how a numeric predictor acts, and if the real relationship has a different shape you must give the model room, with categories, with a square term, or with a transform of the predictor. A GLM is only as good as the predictors you hand it.

Now add everything else

Here is what averaging cannot do. The mouse in this experiment also saw a stimulus on the left on some trials, turned the wheel left or right or not at all, and got tired or practised as the session went on. Any of those might move neuron 141. Put them all in as columns and fit once.

def log_likelihood(y, rate):
    """How probable the observed counts are under predicted Poisson rates (up to a constant)."""
    return np.sum(y * np.log(rate) - rate)

X_full = np.column_stack([
    X_cat,                                   # contrast on the right, one-hot
    contrast_left > 0,                       # was there anything on the left?
    response == -1,                          # did the mouse turn the wheel left?
    response == 1,                           # or right?
    np.arange(len(y)) / len(y),              # how far into the session (0 to 1)
]).astype(float)
names = ["contrast 0.25", "contrast 0.5", "contrast 1.0", "left stimulus", "turned left", "turned right", "time in session"]
w_full = poisson_glm(X_full, y)
for name, b in zip(names, w_full[1:]):
    print(f"{name:16s} x{np.exp(b):.2f}")
print(f"baseline {np.exp(w_full[0]):.2f} spikes")
print("log-likelihood, contrast only:", log_likelihood(y, predict(X_cat, w_cat)).round(1))
print("log-likelihood, full model:    ", log_likelihood(y, predict(X_full, w_full)).round(1))
# contrast 0.25    x3.77
# contrast 0.5     x5.20
# contrast 1.0     x7.24
# left stimulus    x1.12
# turned left      x1.76
# turned right     x1.52
# time in session  x2.24
# baseline 0.69 spikes
# log-likelihood, contrast only: 2386.7
# log-likelihood, full model:     2449.1
Every effect in the full model, expressed as a multiplier on firing because the link is a log: a weight of 0.57 becomes x1.76. Contrast dominates, turning the wheel in either direction adds half as much again, a stimulus on the left does almost nothing, and the neuron fires more than twice as much at the end of the session as at the start.
Every effect in the full model, expressed as a multiplier on firing because the link is a log: a weight of 0.57 becomes x1.76. Contrast dominates, turning the wheel in either direction adds half as much again, a stimulus on the left does almost nothing, and the neuron fires more than twice as much at the end of the session as at the start.

Read the multipliers. A full-contrast stimulus multiplies this neuron’s firing by about seven. A stimulus on the left screen, in the other visual field, does almost nothing, which is what a visual cortex neuron with a receptive field on the right should do. Turning the wheel multiplies firing by 1.5 to 1.8 regardless of direction; this is the movement signal that lesson 3 warned about and lesson 2 saw as the second hump, now measured and separated from the stimulus. And the last line is the surprise: the predictor “time in session” multiplies firing by 2.2, meaning the neuron fires more than twice as much at the end of the hour as at the start. No tuning curve would have shown that, because tuning curves average over time. The log-likelihood, our measure of how probable the data are under the model, improves by 62 units, which is a large amount.

rate_full = predict(X_full, w_full)
fig, ax = plt.subplots(figsize=(10, 3.8))
ax.plot(y, lw=0.8, label="observed count")
ax.plot(rate_full, lw=1.2, label="predicted by the full model")
ax.set(xlabel="trial", ylabel="spikes in the window", xlim=(0, 120))
ax.legend()
plt.show()
The first 120 trials: observed counts, the contrast-only prediction, and the full model's prediction. The full model follows the trial-to-trial swings that contrast alone cannot, and climbs slowly as the session goes on.
The first 120 trials: observed counts, the contrast-only prediction, and the full model’s prediction. The full model follows the trial-to-trial swings that contrast alone cannot, and climbs slowly as the session goes on.

What a GLM is for, and what it is not

  • It separates effects that co-occur. Stimulus and movement are correlated in this task; the GLM gives each its own weight while accounting for the other. This is the main reason the field uses it.
  • Its weights have units and meaning. With a log link, exp(weight) is a multiplier on firing, which is a sentence you can put in a paper.
  • It is still linear in the weights. The log link does not let it discover that the contrast response saturates; you have to give it categories. Interactions (does movement matter more at high contrast?) also have to be added by hand as a product column.
  • Real neurons are over-dispersed. Their variance is a bit more than their mean, as the second figure showed. The weights are still fine; the confidence you put on them should be a little looser than textbook Poisson theory says.
  • Correlated predictors are its weakness. If two columns always move together, the model cannot tell which one matters, and the weights become unstable. Check your design before trusting the weights.

Exercises

  1. Fit the full model to a neuron in MOs (secondary motor cortex) or MD (thalamus) instead of 141. Which multiplier is largest there?
  2. Add an interaction: a column equal to (contrast == 1.0) * (response != 0). Does movement matter more on high-contrast trials?
  3. Replace the Poisson fit with ordinary least squares (np.linalg.lstsq on the same columns) and compare the predictions on blank trials. Where does the linear model go wrong?
  4. Harder: fit a separate GLM to every VISp neuron and plot the distribution of the “turned left” multiplier across the population. How many visual neurons carry a movement signal?

Next primer: independent component analysis. Data: Steinmetz et al., Nature 2019, CC-BY 4.0, via Neuromatch Academy.

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.

The shape of a population: your first PCA

In lesson 3 a decoder read the stimulus out of 66 neurons at once, and you never saw what it was looking at. Sixty-six neurons means sixty-six numbers per trial, a point in a sixty-six-dimensional space, and nobody can picture that. This lesson is about the standard way of squashing that space down to two or three axes so that you can. The method is principal component analysis, PCA, and you will write it in ten lines, apply it to two neurons where you can see exactly what it does, then to the whole population, then to the population’s activity over time, where the trial becomes a path through the space the neurons define. Along the way you will find out how many dimensions primary visual cortex actually uses, and fall into the single most common PCA trap so that you recognise it next time.

flowchart LR

A[Trials x neurons table] --> B[Two neurons: see the directions]

B --> C[Write PCA: centre, covariance, eigenvectors]

C --> D[All 66 neurons: the loud-neuron trap]

D --> E[Standardise: how many dimensions?]

E --> F[Every trial as a point]

F --> G[Every moment as a point: the trajectory]
The lesson in one picture.

Step 1: two neurons, one picture

Start where you can see everything. Take the trials-by-neurons table from lesson 3 and keep just two columns: neuron 141, the one we have followed since lesson 2, and neuron 190, which the decoder gave the second largest positive weight. Plot every trial as a point.

import numpy as np
import matplotlib.pyplot as plt

dat = np.load("steinmetz_session11.zip", allow_pickle=True)
spks = dat["spks"]                        # neurons x trials x time bins
area = dat["brain_area"]
contrast = dat["contrast_right"]
dt = float(dat["bin_size"])
t = np.arange(spks.shape[2]) * dt - 0.5

post = (t >= 0.05) & (t < 0.25)
visp = np.where(area == "VISp")[0]
X = spks[visp][:, :, post].sum(axis=2).T  # trials x neurons, exactly as in lesson 3
y = (contrast > 0).astype(int)

a, b = list(visp).index(141), list(visp).index(190)   # columns of X for neurons 141 and 190
print(f"correlation between neurons 141 and 190: {np.corrcoef(X[:, a], X[:, b])[0, 1]:.2f}")
# correlation between neurons 141 and 190: 0.79

fig, ax = plt.subplots(figsize=(6, 5.5))
ax.scatter(X[y == 0, a], X[y == 0, b], s=14, alpha=0.6, label="blank screen")
ax.scatter(X[y == 1, a], X[y == 1, b], s=14, alpha=0.6, label="stimulus")
ax.set(xlabel="neuron 141 spikes", ylabel="neuron 190 spikes")
ax.legend()
plt.show()
Each dot is one trial. The cloud is a tilted ellipse: when one neuron fires more, so does the other. The orange arrow is the direction along which the cloud is longest; the teal arrow is the direction at right angles to it, which is all that is left.
Each dot is one trial. The cloud is a tilted ellipse: when one neuron fires more, so does the other. The orange arrow is the direction along which the cloud is longest; the teal arrow is the direction at right angles to it, which is all that is left.

The two neurons are strongly correlated, 0.79, so the cloud is an elongated ellipse. Now the key idea. This cloud has a long axis and a short axis, and the long axis is not “neuron 141” or “neuron 190”. It is a mixture, roughly 0.8 of neuron 141 plus 0.6 of neuron 190. If you had to describe every trial with one number instead of two, position along that long axis is the number that loses the least. That axis is the first principal component. The short axis, at right angles to it, is the second. PCA is nothing more than finding those axes, in any number of dimensions, and reporting how much of the cloud’s spread each one carries.

Step 2: write PCA

Ten lines, using one function from NumPy’s linear algebra module that you have not met before. First move the cloud so its centre is at zero. Then compute the covariance matrix: one row and one column per neuron, where each entry says whether two neurons go up and down together across trials (positive), in opposition (negative), or independently (zero). The diagonal holds each neuron’s own variance. The directions of the cloud are the eigenvectors of this matrix, and the variance along each direction is the matching eigenvalue. You do not need to know how np.linalg.eigh finds them, only that it does, and that it returns them smallest first, so we reverse the order.

def pca(data):
    """Principal component analysis of a samples x features array.
    Returns the components (one per column, sorted by variance), the variance along each, and the mean."""
    mean = data.mean(axis=0)
    centered = data - mean                                  # put the cloud's centre at zero
    cov = centered.T @ centered / (len(data) - 1)           # features x features covariance matrix
    variance, components = np.linalg.eigh(cov)              # directions of the cloud and the variance along each
    order = np.argsort(variance)[::-1]                      # eigh returns smallest first; we want largest first
    variance, components = variance[order], components[:, order]
    for j in range(components.shape[1]):                    # the sign of a component is arbitrary:
        if components[np.abs(components[:, j]).argmax(), j] < 0:
            components[:, j] *= -1                          # make its biggest entry positive, for consistent plots
    return components, variance, mean

two = X[:, [a, b]]
components, variance, mean = pca(two)
print("component 1:", components[:, 0].round(2), f"carries {variance[0] / variance.sum():.0%} of the variance")
print("component 2:", components[:, 1].round(2), f"carries {variance[1] / variance.sum():.0%} of the variance")
# component 1: [0.8 0.6] carries 90% of the variance
# component 2: [-0.6  0.8] carries 10% of the variance

The first component is 0.8 of neuron 141 and 0.6 of neuron 190, which is the orange arrow, and it carries 90 percent of the variance. That is what “reduce two dimensions to one” means: keep the position along the orange arrow, throw away the position along the teal one, and you have thrown away a tenth of the information. The for loop at the end of the function handles a small annoyance: an axis has no preferred direction, so an eigenvector and its negative are equally valid, and different computers can return either. Flipping each one so that its largest entry is positive makes the plots come out the same way every time.

Step 3: all 66 neurons, and a trap

The function does not care how many columns the table has. Run it on the full table.

components, variance, mean = pca(X)
fraction = variance / variance.sum()
print("variance carried by the first five components:", fraction[:5].round(3))
print("neurons with the biggest weight in component 1:", visp[np.argsort(-np.abs(components[:, 0]))[:4]])
print("standard deviation of those neurons' counts:", X.std(axis=0)[np.argsort(-np.abs(components[:, 0]))[:4]].round(1))
print("median standard deviation across all 66:", np.median(X.std(axis=0)).round(1))
# variance carried by the first five components: [0.724 0.07  0.031 0.026 0.022]
# neurons with the biggest weight in component 1: [141 190 184 120]
# standard deviation of those neurons' counts: [6.  4.8 3.5 3.6]
# median standard deviation across all 66: 0.4

72 percent of the variance in one component, out of 66. That sounds like a spectacular result, and it is the trap. Look at which neurons carry that component: 141, 190, 184 and 120, and look at their standard deviations, 6, 4.8, 3.5 and 3.6 spikes, against a median across the population of 0.4. PCA finds the directions of largest variance, and variance is measured in spikes squared, so a neuron that fires fifteen times per window has more of it than fifty quiet neurons put together. The first component is not “what the population does”. It is “what the four loudest neurons do”, and the other 62 barely got a vote.

Whether that is a problem depends on the question. If you believe loud neurons matter more, the raw analysis is right. Almost nobody believes that, and the field’s standard fix is to put every neuron on the same scale before asking about directions.

Step 4: standardise, then ask how many dimensions

Subtract each neuron’s mean and divide by its standard deviation, so that every column has mean 0 and spread 1. This is the same z-scoring you would apply before most machine learning, and it has one prerequisite: a neuron that never fired in the window has a standard deviation of zero and no direction at all, so drop it first. Three of the 66 go.

active = X.std(axis=0) > 0                              # drop neurons that never fired in the window
X, visp = X[:, active], visp[active]
Z = (X - X.mean(axis=0)) / X.std(axis=0)                 # every neuron: mean 0, spread 1
print(Z.shape[1], "neurons kept")

components, variance, mean = pca(Z)
fraction = variance / variance.sum()
cumulative = np.cumsum(fraction)
print("variance carried by the first five components:", fraction[:5].round(3))
print("components needed for half the variance:", np.searchsorted(cumulative, 0.5) + 1)
print("components needed for 80% of the variance:", np.searchsorted(cumulative, 0.8) + 1)
# 63 neurons kept
# variance carried by the first five components: [0.19  0.053 0.042 0.035 0.029]
# components needed for half the variance: 12
# components needed for 80% of the variance: 30

fig, ax = plt.subplots(figsize=(10, 3.8))
ax.bar(np.arange(1, 21), fraction[:20] * 100)
ax.plot(np.arange(1, 21), cumulative[:20] * 100, marker="o", color="orange")
ax.set(xlabel="component", ylabel="% of variance", xticks=np.arange(1, 21))
plt.show()
Variance carried by each of the first 20 components. Left: raw counts, where one component dominates because a few neurons are loud. Right: after standardising, the first component carries 19 percent, and the running total crosses 50 percent only at component 12.
Variance carried by each of the first 20 components. Left: raw counts, where one component dominates because a few neurons are loud. Right: after standardising, the first component carries 19 percent, and the running total crosses 50 percent only at component 12.

Now the first component carries 19 percent, and it takes twelve components to account for half the variance and thirty to account for 80 percent of it. This is the honest number, and it is worth sitting with. Primary visual cortex, in a 200 ms window, in a task with one stimulus that varies along one axis, is not a one-dimensional place. The population is doing many things at once, most of which have nothing to do with the contrast of the stimulus. There is a live argument in the field about what those dimensions are, and Steinmetz and colleagues’ answer, from this very dataset, is that a great many of them are the animal’s own movements.

Step 5: every trial as a point

Even so, the first two components are the best two-dimensional picture of the population that exists, so draw it. Projecting a trial onto a component is a dot product: multiply each neuron’s standardised count by the component’s weight for that neuron and add up. @ does it for all trials and both components at once.

scores = (Z - mean) @ components[:, :2]                 # every trial's position along components 1 and 2

fig, ax = plt.subplots(figsize=(7, 6))
for lvl in [0.0, 0.25, 0.5, 1.0]:
    pick = contrast == lvl
    ax.scatter(scores[pick, 0], scores[pick, 1], s=14, alpha=0.7, label=f"contrast {lvl}")
ax.set(xlabel="component 1", ylabel="component 2")
ax.legend()
plt.show()

for lvl in [0.0, 0.25, 0.5, 1.0]:
    print(f"contrast {lvl}: mean position along component 1 = {scores[contrast == lvl, 0].mean():+.1f}")
print(f"guess 'stimulus' whenever component 1 is positive: {np.mean((scores[:, 0] > 0) == y):.1%} correct, with no labels used")
# contrast 0.0: mean position along component 1 = -2.5
# contrast 0.25: mean position along component 1 = -0.5
# contrast 0.5: mean position along component 1 = +2.1
# contrast 1.0: mean position along component 1 = +4.6
# guess 'stimulus' whenever component 1 is positive: 84.7% correct, with no labels used
All 340 trials, positioned by their first two principal components, coloured by stimulus contrast. Blank-screen trials pile up on the left; contrast increases left to right along component 1. Component 2 does not separate anything obvious.
All 340 trials, positioned by their first two principal components, coloured by stimulus contrast. Blank-screen trials pile up on the left; contrast increases left to right along component 1. Component 2 does not separate anything obvious.

PCA was never told which trials had a stimulus. It was told nothing at all; it looked only at how the neurons co-vary. And yet the first thing it found is the stimulus: the four contrast levels line up along component 1 in order, and drawing a line at zero classifies 85 percent of trials correctly. Compare that with lesson 3, where the decoder was given the labels and reached 99.7 percent. The gap between 85 and 99.7 is the difference between the direction of most variance and the direction of most information about the stimulus. They are related here because the stimulus is the biggest thing happening to visual cortex. In other areas, or for subtler variables, they can be entirely different directions, which is why unsupervised methods like PCA and supervised ones like the decoder answer different questions.

Step 6: every moment as a point

So far each trial has been a single point, a 200 ms snapshot. The last step changes what a point is. Average the population’s firing rate over trials as in lesson 2, but for every neuron at once, so that each 10 ms bin of the trial is now a row of the table, a snapshot of what all 63 neurons were doing at that moment. Do it separately for stimulus and blank trials, stack the two, standardise, and run PCA. Now a component is a pattern of activity across the population, and the trial itself becomes a path through the space.

def population_rates(trials, smooth=7):
    """Trial-averaged, lightly smoothed firing rate of every active VISp neuron: neurons x time bins, in spikes/s."""
    rate = spks[visp][:, trials].mean(axis=1) / dt
    kernel = np.ones(smooth) / smooth
    return np.array([np.convolve(r, kernel, mode="same") for r in rate])

R_stim, R_blank = population_rates(y == 1), population_rates(y == 0)
both = np.concatenate([R_stim, R_blank], axis=1).T      # 500 time points x 63 neurons
scale = both.std(axis=0)                                # again, put every neuron on the same footing
components, variance, mean = pca(both / scale)
print("variance carried by the first three components:", (variance[:3] / variance.sum()).round(3))
# variance carried by the first three components: [0.525 0.075 0.046]

path_stim = (R_stim.T / scale - mean) @ components[:, :2]     # 250 time points x 2
path_blank = (R_blank.T / scale - mean) @ components[:, :2]

show = slice(30, 151)                                   # -0.2 to +1.0 s
marks = [50, 55, 60, 70, 80, 100, 150]                   # bins: 0, 50, 100, 200, 300, 500, 1000 ms after onset

fig, axes = plt.subplots(1, 2, figsize=(11, 4.6))
axes[0].plot(path_blank[show, 0], path_blank[show, 1], color="gray", label="blank screen")
axes[0].plot(path_stim[show, 0], path_stim[show, 1], label="stimulus")
axes[0].scatter(path_stim[marks, 0], path_stim[marks, 1], s=30, zorder=3)
for m in marks:
    axes[0].annotate(f"{round(t[m] * 1000)} ms", (path_stim[m, 0], path_stim[m, 1]), fontsize=8, xytext=(4, 4), textcoords="offset points")
axes[0].set(xlabel="component 1", ylabel="component 2")
axes[0].legend()

for lvl in [0.0, 0.25, 0.5, 1.0]:
    path = (population_rates(contrast == lvl).T / scale - mean) @ components[:, 0]
    axes[1].plot(t, path, label=f"contrast {lvl}")
axes[1].axvline(0, color="orange")
axes[1].set(xlabel="time from stimulus onset (s)", ylabel="component 1", xlim=(-0.3, 1.0))
axes[1].legend()
plt.show()
Left: the population's trajectory from 200 ms before the stimulus to one second after, in the plane of its first two components; dots on the stimulus path mark 0, 50, 100, 200, 300, 500 and 1000 ms. The blank-screen path stays near the origin and drifts slowly along component 2. Right: position along component 1 over time, for each contrast.
Left: the population’s trajectory from 200 ms before the stimulus to one second after, in the plane of its first two components; dots on the stimulus path mark 0, 50, 100, 200, 300, 500 and 1000 ms. The blank-screen path stays near the origin and drifts slowly along component 2. Right: position along component 1 over time, for each contrast.

Read the left panel like a map. Before the stimulus both paths sit in the same small patch. Then the stimulus path leaves: by 50 ms it is on its way, at 100 ms it is as far from home as it will get, and by 200 ms it is swinging back, before a second, smaller excursion around 300 ms and a slow return that is still not complete at one second. That whole loop is the transient response and the second hump from lesson 2’s PSTH, but now for the population rather than one neuron, and now as a shape rather than a curve. The blank path goes nowhere along component 1 and drifts up component 2, which is the population doing something slow and unrelated to vision while the mouse waits and moves. This picture, a trial as a trajectory through a low-dimensional space, is how a great deal of modern systems neuroscience thinks about population activity.

The right panel asks how the path depends on the stimulus, and the answer is the cleanest result in the lesson: it is the same path at four different scales. Contrast does not send the population in a different direction. It sends it further along the same one, and the first component, with 52 percent of the time-course variance, is essentially a contrast axis.

What you just did

  • Saw what a principal component is on two neurons: the long axis of the cloud of trials.
  • Wrote PCA from scratch with a covariance matrix and np.linalg.eigh, and understood every line.
  • Ran it on 66 neurons, fell into the loud-neuron trap, and climbed out by standardising.
  • Measured the dimensionality of visual cortex activity: 12 components for half the variance, not one.
  • Found the stimulus without labels, and understood why unsupervised and supervised methods find different directions.
  • Turned a trial into a trajectory through population space and read the response off it as a shape.

Exercises

  1. Single trials. Step 6 averaged over trials before running PCA. Project a handful of individual stimulus trials onto the same components (smooth them more heavily, say 15 bins) and plot them over the average path. How much do single trials wobble around it?
  2. Repeat step 4 for VISam, MD and CA1. Which area needs the most components to reach half its variance, and how does that fit with what lessons 2 and 3 found there?
  3. Compare component 1 from step 4 with the decoder weights from lesson 3 (train the decoder on all trials of the standardised table). Compute the correlation between the two 63-element vectors. Are they the same direction?
  4. Harder: the blank-screen path in step 6 drifts along component 2. Sort the blank trials by dat["response"], average each group separately, and project them. Is component 2 about what the mouse does?

Data: Steinmetz, Zatka-Haas, Carandini & Harris, “Distributed coding of choice, action and engagement across the mouse brain”, Nature 2019, CC-BY 4.0, via Neuromatch Academy. The code in this lesson runs top to bottom as a single script in a few seconds.

What did the mouse see? Your first neural decoder

In lesson 2 you asked whether a neuron responds to the stimulus. This lesson turns the question around: given only the neurons, can you tell what the stimulus was? You will turn a recording into a table, guess the stimulus from one neuron, discover why that guess cannot be trusted, then train a classifier on 66 neurons at once and find that it gets the answer right on 339 trials out of 340. Along the way you will build the three habits that separate machine learning from wishful thinking: hold out test data, cross-validate, and check what luck alone would score. At the end you will point the decoder at eight brain areas and at every moment of the trial, and watch the information appear.

This is the first step toward a brain-computer interface. A BCI is a loop (here is the map), and the stage in the middle of it, where neural activity is turned into a guess about what the user wants, is exactly what you are about to build.

flowchart LR

A[Spike counts: neurons x trials x bins] --> B[Table: one row per trial, one column per neuron]

B --> C[One neuron + a threshold]

C --> D[Train / test split]

D --> E[Population decoder: 66 neurons]

E --> F[Cross-validate]

F --> G[Shuffle the labels: what does luck score?]

G --> H[Every area, every moment]
The lesson in one picture. Each box is a step below.

The data

Same file as lesson 2: steinmetz_session11.zip, one session from Steinmetz and colleagues (2019), with 698 neurons, 340 trials and spike counts in 10-millisecond bins. On each trial a striped pattern appeared on the right screen at one of four contrasts, or the screen stayed blank. The question for the decoder is the simplest one possible: was there anything on the right screen, or not?

Step 1: turn the recording into a table

Every classifier ever built wants the same thing: a table with one row per example and one column per measurement, plus a list of the right answers. In machine learning the table is called X and the answers are called y. Here, an example is a trial, a measurement is one neuron’s spike count in the 50 to 250 ms window from lesson 2, and the answer is 1 if the right screen showed a stimulus and 0 if it was blank. Two lines of NumPy build the whole table.

import numpy as np
import matplotlib.pyplot as plt

dat = np.load("steinmetz_session11.zip", allow_pickle=True)
spks = dat["spks"]                        # neurons x trials x time bins: spike counts in 10 ms bins
area = dat["brain_area"]                  # one label per neuron
contrast = dat["contrast_right"]          # stimulus contrast on the right screen, one value per trial
dt = float(dat["bin_size"])               # 0.01 seconds
t = np.arange(spks.shape[2]) * dt - 0.5   # time of each bin relative to stimulus onset

post = (t >= 0.05) & (t < 0.25)           # the response window from lesson 2
visp = np.where(area == "VISp")[0]        # primary visual cortex neurons

X = spks[visp][:, :, post].sum(axis=2).T  # trials x neurons: each neuron's spike count in the window
y = (contrast > 0).astype(int)            # 1 if there was a stimulus on the right, 0 if the screen was blank

print(X.shape, "trials x neurons")
print("stimulus on", y.sum(), "of", len(y), "trials")
print("trial 0:", X[0, :12], "... label", y[0])
print("trial 3:", X[3, :12], "... label", y[3])
# (340, 66) trials x neurons
# stimulus on 173 of 340 trials
# trial 0: [0 0 0 0 0 0 0 0 0 0 0 0] ... label 0
# trial 3: [2 2 4 2 0 0 2 0 0 0 2 0] ... label 1

340 rows, 66 columns. Trial 0 was a blank screen and the first dozen visual cortex neurons were silent. Trial 3 had a stimulus and they were not. The .T at the end of the X line transposes the array so that trials are rows, which is the convention every machine learning tool expects. Everything from here on works on X and y; the raw recording is not needed again until step 7.

Step 2: one neuron, one threshold

Start with the neuron we know best. Neuron 141 fired 66 spikes per second to a full-contrast stimulus and 8 to a blank screen. So here is a decoder: count its spikes in the window, and if there are more than some number, say the stimulus was there. Before choosing the number, look at the two piles of trials.

one = spks[141][:, post].sum(axis=1)      # neuron 141's spike count in the window, on every trial

fig, ax = plt.subplots(figsize=(10, 3.8))
bins = np.arange(one.max() + 2) - 0.5     # one bar per whole number of spikes
ax.hist(one[y == 0], bins=bins, alpha=0.6, label="blank screen")
ax.hist(one[y == 1], bins=bins, alpha=0.6, label="stimulus")
ax.set(xlabel="spikes from neuron 141, 50 to 250 ms after onset", ylabel="number of trials")
ax.legend()
plt.show()
Neuron 141's spike count on every trial, split by whether there was a stimulus. The piles are clearly different and clearly overlap: a low-contrast stimulus often produces only a few spikes, and a blank screen occasionally produces several.
Neuron 141’s spike count on every trial, split by whether there was a stimulus. The piles are clearly different and clearly overlap: a low-contrast stimulus often produces only a few spikes, and a blank screen occasionally produces several.

A decoder is a rule that turns a row of the table into a guess, and its accuracy is the fraction of trials it gets right. Try every threshold from 1 to 9.

def accuracy(guess, truth):
    """Fraction of trials where the guess matches the truth."""
    return np.mean(guess == truth)

for k in range(1, 10):
    guess = one > k                       # True where the neuron fired more than k spikes
    print(f"more than {k} spikes means stimulus: {accuracy(guess, y):.1%} correct")
# more than 1 spikes means stimulus: 81.8% correct
# more than 2 spikes means stimulus: 85.0% correct
# more than 3 spikes means stimulus: 87.9% correct
# more than 4 spikes means stimulus: 87.9% correct
# more than 5 spikes means stimulus: 88.2% correct
# more than 6 spikes means stimulus: 85.3% correct
# more than 7 spikes means stimulus: 82.6% correct
# more than 8 spikes means stimulus: 79.4% correct
# more than 9 spikes means stimulus: 76.5% correct

88 percent from a single neuron and a single number. That is genuinely good. It is also, as it stands, slightly dishonest, and the next step is about why.

Step 3: never grade yourself on the questions you studied

We picked the threshold by trying all of them on the 340 trials and keeping the best. Then we reported the accuracy on the same 340 trials. That is grading yourself on the exam you used to revise. With one number to choose it barely matters, but with 66 weights to choose, as in the next step, a decoder can memorise the quirks of the trials it was trained on and score brilliantly on them while knowing nothing that transfers to a new trial. The fix is a rule so important that it is the one thing to take away from this lesson if you take away nothing else: choose the rule on some trials, and measure it on different ones.

rng.permutation shuffles the trial numbers, and slicing gives us 240 training trials and 100 test trials that the decoder never sees until it is judged.

rng = np.random.default_rng(0)
shuffled = rng.permutation(len(y))                  # the trial numbers 0..339 in random order
train, test = shuffled[:240], shuffled[240:]        # 240 trials to learn from, 100 to be examined on

scores = [accuracy(one[train] > k, y[train]) for k in range(15)]
best_k = int(np.argmax(scores))                     # the threshold that did best on the training trials

print(f"rule learned from training trials: more than {best_k} spikes means stimulus")
print(f"training trials: {scores[best_k]:.1%} correct")
print(f"test trials:     {accuracy(one[test] > best_k, y[test]):.1%} correct")
# rule learned from training trials: more than 3 spikes means stimulus
# training trials: 88.8% correct
# test trials:     86.0% correct

The rule chosen on the training trials scores 88.8 percent on them and 86 percent on the held-out test trials. The drop is small here because the rule is simple. Watch for it in everything you do from now on: the gap between training and test accuracy is the size of the lie you would have told yourself.

Step 4: 66 neurons, and a model neuron to read them

Neuron 141 is one of 66 in primary visual cortex, and every one of them saw the stimulus. Combining them needs a rule with more than one number: give each neuron a weight, multiply its spike count by that weight, add everything up, and say “stimulus” if the sum is above zero. Neurons that fire more for the stimulus should get positive weights, neurons that fire less should get negative ones, and neurons that do not care should get weights near zero. The only question is how to find the 66 weights, and the answer is to learn them from the training trials, one small correction at a time.

Look at the shape of that rule before reading the code. Inputs arrive, each is multiplied by a weight, they are summed, and the sum is pushed through a threshold. That is a neuron. Not the leaky integrate-and-fire neuron of lesson 1 but the other kind, the one Frank Rosenblatt built out of motors and potentiometers in 1958 and called a perceptron, and which is still the basic unit of every deep network. We are going to decode 66 real neurons with one artificial one.

def train_decoder(X, y, lr=0.01, steps=1000):
    """Logistic regression, trained by gradient descent.
    X: trials x neurons spike counts. y: 0 or 1 per trial.
    Returns one weight per neuron and a bias."""
    w = np.zeros(X.shape[1])                        # start with every weight at zero
    b = 0.0
    for _ in range(steps):
        p = 1 / (1 + np.exp(-(X @ w + b)))          # weighted sum of the counts, squashed to a probability
        error = p - y                               # how wrong it was on each trial, and in which direction
        w -= lr * (X.T @ error) / len(y)            # nudge each weight against its share of the error
        b -= lr * error.mean()
    return w, b

def predict(X, w, b):
    """1 where the weighted sum says stimulus, 0 where it says blank."""
    return (X @ w + b > 0).astype(int)

w, b = train_decoder(X[train], y[train])
print(f"training trials: {accuracy(predict(X[train], w, b), y[train]):.1%} correct")
print(f"test trials:     {accuracy(predict(X[test], w, b), y[test]):.1%} correct")
# training trials: 99.6% correct
# test trials:     100.0% correct

Read train_decoder as a loop of three moves, repeated a thousand times. X @ w + b computes the weighted sum for every trial at once; @ is matrix multiplication, and it does in one symbol what would otherwise be a loop over trials inside a loop over neurons. The 1 / (1 + np.exp(-...)) wrapper squashes each sum to a number between 0 and 1, the decoder’s probability that the stimulus was there. error is the gap between that probability and the truth. And the update line moves each neuron’s weight in the direction that would have shrunk the error, by an amount proportional to how much that neuron fired. The learning rate lr keeps the steps small.

That update rule is worth a second look, because it is local: the change to a neuron’s weight depends only on that neuron’s own activity and the error. A synapse could implement it. This method has a name, logistic regression, and a longer history than the name suggests. In a library like scikit-learn it is one line. We wrote it out so that there is nothing hidden.

99.6 percent on the training trials and 100 percent on the 100 test trials. The population knows something no single neuron knows. To see how confident it is, look at the weighted sum itself, on the test trials only.

evidence = X[test] @ w + b                          # the decoder's weighted sum on each test trial

fig, ax = plt.subplots(figsize=(10, 3.8))
bins = np.linspace(evidence.min(), evidence.max(), 40)
ax.hist(evidence[y[test] == 0], bins=bins, alpha=0.6, label="blank screen")
ax.hist(evidence[y[test] == 1], bins=bins, alpha=0.6, label="stimulus")
ax.axvline(0, color="orange")                       # the decision boundary
ax.set(xlabel="decoder's weighted sum", ylabel="number of test trials")
ax.legend()
plt.show()
The decoder's weighted sum on the 100 held-out trials. Compare with the first figure: the two piles no longer touch. A trial's distance from the boundary is how sure the decoder is.
The decoder’s weighted sum on the 100 held-out trials. Compare with the first figure: the two piles no longer touch. A trial’s distance from the boundary is how sure the decoder is.

Step 5: cross-validation

One split of 240 and 100 gives one number, and 100 test trials is a small exam: one lucky trial moves the score by a full percentage point. Cross-validation fixes this by dividing the trials into five groups and letting each group be the test set in turn, training on the other four. Every trial gets tested exactly once, by a decoder that never saw it, and the five scores are averaged. This is the standard way to report a decoder’s accuracy, and the function below is the one we will use for the rest of the lesson.

def cross_validate(X, y, folds=5, seed=0):
    """Average test accuracy when every trial takes one turn in the test set."""
    rng = np.random.default_rng(seed)
    parts = np.array_split(rng.permutation(len(y)), folds)   # five random, equal groups of trials
    scores = []
    for part in parts:
        is_test = np.zeros(len(y), dtype=bool)
        is_test[part] = True                                 # this group is the test set this time
        w, b = train_decoder(X[~is_test], y[~is_test])       # train on everything else
        scores.append(accuracy(predict(X[is_test], w, b), y[is_test]))
    return np.mean(scores)

print(f"VISp population, cross-validated: {cross_validate(X, y):.1%} correct")
print(f"neuron 141 alone, cross-validated: {cross_validate(X[:, visp == 141], y):.1%} correct")
# VISp population, cross-validated: 99.7% correct
# neuron 141 alone, cross-validated: 87.9% correct

99.7 percent: 339 of 340 trials. The single-neuron rule from step 2, run through the same machinery, gets 87.9. The next two steps make sure that 99.7 means what we think it means.

Step 6: what would luck score?

Fifty percent is not the only kind of chance. A decoder can find structure in things that have nothing to do with the stimulus: a slow drift in firing rates over the session, say, or a neuron that fires more in the second half of the experiment when more of the stimulus trials happened to be. The permutation test from lesson 2 handles this here too. Shuffle the labels so that each trial keeps its spike counts but is assigned a random answer, run the whole cross-validation, and see what accuracy comes out. Do it 200 times. This is the slow part of the lesson; it takes a minute or two.

rng = np.random.default_rng(1)
chance = np.array([cross_validate(X, rng.permutation(y)) for _ in range(200)])

print(f"shuffled labels: mean {chance.mean():.1%}, best of 200 shuffles {chance.max():.1%}")
print(f"95% of shuffles score below {np.percentile(chance, 95):.1%}")
# shuffled labels: mean 49.6%, best of 200 shuffles 58.8%
# 95% of shuffles score below 54.7%

With the labels scrambled the decoder averages 49.6 percent, and its best result in 200 attempts is 58.8. Our real score is 99.7. Anything under about 55 percent could be luck; that line is going to matter in the next step, where the numbers are not so clear-cut.

Step 7: which areas know?

Wrap the whole pipeline in a function that takes a list of neurons, and run it on each brain area. The seven areas from lesson 2 are here, plus MD, the mediodorsal thalamus, which has the most neurons of any area in this recording.

def decode(neurons, window=post):
    """Cross-validated accuracy of a decoder that reads these neurons in this time window."""
    counts = spks[neurons][:, :, window].sum(axis=2).T
    return cross_validate(counts, y)

names = ["VISp", "VISam", "LGd", "MD", "CA1", "DG", "MOs", "ACA"]
by_area = []
for a in names:
    idx = np.where(area == a)[0]
    by_area.append(decode(idx))
    print(f"{a:5s} {len(idx):3d} neurons, {by_area[-1]:.0%} correct")

fig, ax = plt.subplots(figsize=(10, 3.8))
ax.bar(names, by_area)
ax.axhline(np.percentile(chance, 95), color="orange")       # anything below this line could be luck
ax.set(ylabel="decoding accuracy", ylim=(0, 1))
plt.show()
# VISp   66 neurons, 100% correct
# VISam  79 neurons, 71% correct
# LGd    11 neurons, 57% correct
# MD    126 neurons, 77% correct
# CA1    50 neurons, 52% correct
# DG     65 neurons, 63% correct
# MOs     6 neurons, 57% correct
# ACA    16 neurons, 61% correct
Decoding accuracy by area. Primary visual cortex is near perfect. CA1, in grey, is the only area that scores below what shuffled labels achieve. Small samples again: LGd has 11 neurons and MOs has 6.
Decoding accuracy by area. Primary visual cortex is near perfect. CA1, in grey, is the only area that scores below what shuffled labels achieve. Small samples again: LGd has 11 neurons and MOs has 6.

Primary visual cortex is essentially perfect. VISam, a higher visual area, gets 71 percent, and CA1, which in lesson 2 had not one neuron responding to the stimulus, is at chance. So far the map from lesson 2 is confirmed. Then there is MD at 77 percent, the second best area in the recording, and DG at 63, even though lesson 2 found that only two percent of its neurons respond to the stimulus. A decoder pools weak signals that a single-neuron test misses, and part of the explanation is that. But look at what else these trials differ in. On stimulus trials the mouse almost always turns the wheel; on blank trials it often does nothing. An area that carries no visual information at all, but knows what the mouse is about to do, will decode the stimulus above chance, because in this task the two are correlated. Restrict the analysis to trials where the mouse made the same response, and MD’s advantage over simply guessing the commoner label shrinks to a few points.

This is the most important caveat in decoding, and it applies to every result in the field, including the ones in brain-computer interfaces. A decoder tells you that the information is present in an area. It does not tell you why it is there, or what the area is doing with it, or whether it would still be there if the animal’s behaviour were different. Steinmetz and colleagues built their whole paper around this problem, and found that signals related to movement are present nearly everywhere in the mouse brain.

Step 8: when does the brain know?

So far the decoder has read one fixed window. Slide a 100 ms window across the trial instead, training a fresh decoder at each position, and you get the accuracy as a function of time. This is the neural equivalent of watching the information arrive.

starts = np.arange(10, 140, 5)                      # window start, in bins: every 50 ms
acc_time = []
for s in starts:
    window = np.zeros(len(t), dtype=bool)
    window[s:s + 10] = True                         # a 100 ms window starting at bin s
    acc_time.append(decode(visp, window))

fig, ax = plt.subplots(figsize=(10, 3.8))
ax.plot(t[starts] + 0.05, acc_time, marker="o")     # plot each window at its centre
ax.axvline(0, color="orange")                       # stimulus onset
ax.axhline(0.5, color="gray", ls=":")               # chance
ax.set(xlabel="centre of 100 ms window, time from stimulus onset (s)", ylabel="decoding accuracy", ylim=(0.4, 1))
plt.show()
VISp decoder accuracy from a 100 ms window at each position. Chance before the stimulus, 97 percent in the first 100 ms after it, and above 90 percent for most of the next half second. The dip around 200 ms is the trough between the visual transient and the second hump you saw in neuron 141's PSTH.
VISp decoder accuracy from a 100 ms window at each position. Chance before the stimulus, 97 percent in the first 100 ms after it, and above 90 percent for most of the next half second. The dip around 200 ms is the trough between the visual transient and the second hump you saw in neuron 141’s PSTH.

Before the stimulus, chance. That is a sanity check as much as a result: a decoder that scores well before the stimulus appears has found a leak, and you should go looking for it. In the first 100 ms after onset the accuracy jumps to 97 percent, which is the transient response from lesson 2 doing its work. It peaks at 99 in the 50 to 150 ms window, dips around 200 ms, and then stays above 90 percent for most of the next half second, partly because the stimulus is still on the screen and partly, as step 7 warned, because the mouse is now moving.

What you just did

  • Turned a neural recording into the trials-by-features table that every machine learning method starts from.
  • Built a one-neuron decoder and learned why accuracy must be measured on held-out trials.
  • Wrote logistic regression from scratch, trained it by gradient descent, and understood it as a model neuron with 66 learnable synapses.
  • Cross-validated it, and established what luck alone would score by shuffling the labels.
  • Decoded the stimulus from eight brain areas and from every moment of the trial, and met the central caveat of the field: information present is not the same as information used.

Every brain-computer interface, from the cursor-control systems in clinical trials to the speech decoders in the news, is this pipeline with more neurons, a fancier classifier, and a loop that feeds the guess back to the user. You have now built the part in the middle.

Exercises

  1. Decode the left stimulus instead: y = (dat["contrast_left"] > 0).astype(int). The probes are in the left hemisphere. What does VISp score now, and what does that tell you?
  2. Train the decoder on all 340 VISp trials and sort the neurons by the size of their weight. Is neuron 141 at the top? The neuron with the largest weight has a negative sign. Plot its PSTH for blank and stimulus trials, as in lesson 2, and work out what the decoder is using it for.
  3. How many neurons do you need? Pick random subsets of 1, 2, 5, 10, 20 and 40 VISp neurons, cross-validate each, and plot accuracy against the number of neurons. Where does it saturate?
  4. Harder, and the real first step of a BCI: decode what the mouse is about to do. Keep only the trials where it turned the wheel (dat["response"] != 0), label them by direction, and use a window from 250 to 750 ms. Try VISp, VISam and MD. Which area knows which way the mouse will turn, and how early can you decode it?

Data: Steinmetz, Zatka-Haas, Carandini & Harris, “Distributed coding of choice, action and engagement across the mouse brain”, Nature 2019, CC-BY 4.0, via Neuromatch Academy. The code in this lesson runs top to bottom as a single script; the shuffle test in step 6 is the only slow part.

Does this neuron care about the stimulus?

In lesson 1 you looked at neurons firing on their own time. This lesson asks the question that most of systems neuroscience is built on: does this neuron respond to something in the world? You will align hundreds of trials to the moment a stimulus appeared, average them into the field’s most important plot, measure how the response scales with the stimulus, test whether it is real, and then run the same test on every neuron in seven brain areas to see which parts of the brain are listening.

flowchart LR

A[Spike counts: neurons x trials x bins] --> B[Pick a neuron, trials and time windows]

B --> C[Raster: every trial]

B --> D[PSTH: average over trials]

D --> E[Tuning curve: rate vs contrast]

B --> F[Permutation test: is it real?]

F --> G[Every neuron, every area]
The analysis in this lesson. Each box is one step below.

The data

Same experiment as lesson 1, from Steinmetz and colleagues (2019), but a different session and a different shape. In the task, a mouse sat in front of two screens. On each trial a striped pattern appeared on the left screen, the right screen, both, or neither, at one of four contrast levels, and the mouse turned a wheel to indicate which side was brighter. The probes were in the left hemisphere, which processes the right visual field, so we will use the contrast of the right stimulus.

To keep the download small, I extracted one session into a 2.4 MB file: steinmetz_session11.zip. Save it next to your code. Do not unzip it: NumPy reads it directly. The full dataset, with all thirteen sessions, is available from Neuromatch Academy (88 MB).

The key difference from lesson 1 is that the spikes have already been cut into trials and counted in 10-millisecond bins. Instead of a list of spike times per neuron, we have a three-dimensional array: neurons by trials by time bins. Every trial is a 2.5-second window and the stimulus appears exactly 0.5 seconds in. This is the standard shape of trial-based neural data, and getting comfortable with it is half the lesson.

Step 1: load and orient yourself

import numpy as np
import matplotlib.pyplot as plt

dat = np.load("steinmetz_session11.zip", allow_pickle=True)
spks = dat["spks"]                        # neurons x trials x time bins: spike counts in 10 ms bins
area = dat["brain_area"]                  # one label per neuron
contrast = dat["contrast_right"]          # stimulus contrast on the right screen, one value per trial
dt = float(dat["bin_size"])               # 0.01 seconds
t = np.arange(spks.shape[2]) * dt - 0.5   # time of each bin relative to stimulus onset

print(spks.shape, "neurons x trials x bins")
print("areas:", {a: int(np.sum(area == a)) for a in np.unique(area)})
print("contrast levels:", np.unique(contrast))
# (698, 340, 250) neurons x trials x bins
# areas: {'ACA': 16, 'CA1': 50, 'DG': 65, 'LGd': 11, 'LH': 18, 'MD': 126, 'MOs': 6, 'PL': 56, 'SUB': 105, 'VISam': 79, 'VISp': 66, 'root': 100}
# contrast levels: [0.   0.25 0.5  1.  ]

698 neurons, 340 trials, 250 bins. The area codes are from the Allen Brain Atlas: VISp is primary visual cortex, VISam a higher visual area, LGd the visual thalamus, CA1 and DG are hippocampus, MOs is secondary motor cortex, ACA and PL are prefrontal. The line that builds t is worth reading twice: it turns bin numbers into seconds and shifts them so that zero is the moment the stimulus appeared.

Step 2: find a neuron worth looking at

With 698 neurons we need a way to choose. We will define a response as the firing rate in a window shortly after the stimulus minus the rate just before it, on high-contrast trials, and pick the visual cortex neuron with the biggest one. Boolean arrays do the selecting: pre and post pick out time bins, high picks out trials, visp picks out neurons.

visp = np.where(area == "VISp")[0]          # indices of primary visual cortex neurons
pre  = (t >= -0.3) & (t < 0)                # 300 ms before the stimulus
post = (t >= 0.05) & (t < 0.25)             # 50 to 250 ms after it
high = contrast == 1.0                      # full-contrast trials

resp = spks[:, high][:, :, post].mean(axis=(1, 2)) - spks[:, high][:, :, pre].mean(axis=(1, 2))
best = visp[np.argmax(resp[visp])]
print(f"most responsive VISp neuron: {best}")
print("one trial, spike counts per 10 ms bin:", spks[best, 3, 45:60])
# most responsive VISp neuron: 141
# one trial, spike counts per 10 ms bin: [0 0 0 0 0 0 0 0 0 0 1 0 1 1 1]

Look at that last line. Bins 45 to 49 are the 50 milliseconds before the stimulus: silence. Bin 50 is the stimulus onset. Five bins later, the neuron starts firing. On a single trial that is suggestive. The rest of the lesson is about turning suggestive into certain.

Step 3: every trial at once

A raster plot with trials as rows, sorted so that the zero-contrast trials come first and the full-contrast trials last. np.nonzero finds every bin with at least one spike and returns its row and column.

order = np.argsort(contrast, kind="stable")          # trial indices, sorted by contrast

fig, ax = plt.subplots(figsize=(10, 5))
rows, cols = np.nonzero(spks[best][order])          # (trial, bin) of every spike, in sorted order
ax.scatter(t[cols], rows, s=2, marker="|")
ax.axvline(0, color="orange")                         # stimulus onset
ax.set(xlabel="time from stimulus onset (s)", ylabel="trial (sorted by contrast)")
ax.invert_yaxis()
plt.show()
Neuron 141, all 340 trials, sorted by the contrast of the right stimulus. Dotted lines separate the contrast groups. In the top block the right screen was blank; in the bottom block it showed a full-contrast stimulus.
Neuron 141, all 340 trials, sorted by the contrast of the right stimulus. Dotted lines separate the contrast groups. In the top block the right screen was blank; in the bottom block it showed a full-contrast stimulus.

You can read the result off the picture before computing anything. Blank-screen trials at the top: nothing happens at time zero. Full-contrast trials at the bottom: a wall of spikes about 50 milliseconds after onset, on nearly every trial. In between, the response gets stronger as contrast increases. This is what a visual neuron looks like.

Step 4: the PSTH

The peri-stimulus time histogram is the raster averaged over trials: for each time bin, the mean spike count across trials, divided by the bin width to give spikes per second. It is the single most common plot in the field. We add a five-bin running average to smooth it, using np.convolve.

def psth(counts, dt, smooth=5):
    """Average spike count per bin across trials, in spikes/s, lightly smoothed."""
    rate = counts.mean(axis=0) / dt
    kernel = np.ones(smooth) / smooth
    return np.convolve(rate, kernel, mode="same")

fig, ax = plt.subplots(figsize=(10, 3.8))
for lvl in [0.0, 0.25, 0.5, 1.0]:
    ax.plot(t, psth(spks[best, contrast == lvl], dt), label=f"contrast {lvl}")
ax.axvline(0, color="orange")
ax.set(xlabel="time from stimulus onset (s)", ylabel="spikes / s", xlim=(-0.3, 1.0))
ax.legend()
plt.show()
Neuron 141's average response at each contrast. The sharp peak 60 to 80 ms after onset is the visual response. The broad second hump around 250 ms is something else; see the exercises.
Neuron 141’s average response at each contrast. The sharp peak 60 to 80 ms after onset is the visual response. The broad second hump around 250 ms is something else; see the exercises.

Three things to notice. The response begins about 40 milliseconds after the stimulus, which is roughly how long it takes light hitting the retina to reach primary visual cortex in a mouse. The peak grows with contrast. And at zero contrast the trace is flat: this neuron does nothing when there is nothing on the right screen, even though the mouse is still doing the task.

Step 5: how much does it care? A tuning curve

To turn the picture into numbers, compute the rate in the response window on each trial, then the mean and the standard error for each contrast level. The standard error tells you how much to trust each mean.

levels = np.unique(contrast)
means, sems = [], []
for lvl in levels:
    r = spks[best, contrast == lvl][:, post].sum(axis=1) / (post.sum() * dt)   # rate per trial in the window
    means.append(r.mean())
    sems.append(r.std(ddof=1) / np.sqrt(len(r)))
print(dict(zip(levels, np.round(means, 1))))
# {0.0: 7.6, 0.25: 35.3, 0.5: 50.5, 1.0: 66.3}

fig, ax = plt.subplots(figsize=(6, 3.8))
ax.errorbar(levels, means, yerr=sems, marker="o", capsize=3)
ax.set(xlabel="stimulus contrast", ylabel="spikes / s, 50 to 250 ms after onset")
plt.show()
Contrast tuning. From 8 spikes per second with a blank screen to 66 at full contrast, rising steeply at first and then flattening: the classic saturating contrast-response curve of visual cortex.
Contrast tuning. From 8 spikes per second with a blank screen to 66 at full contrast, rising steeply at first and then flattening: the classic saturating contrast-response curve of visual cortex.

Step 6: is it real? A permutation test

A neuron with a big response on the plot could, in principle, be a fluke. The honest way to check is to ask how often chance alone would produce a difference this large. We do that by shuffling: pool the before and after rates, shuffle them so the labels are meaningless, recompute the difference, and repeat thousands of times. The fraction of shuffles that beat the real difference is the p-value. No formulas, no assumptions about the distribution, and you can read exactly what it is doing.

def permutation_p(before, after, n=5000, seed=0):
    """How often does shuffling the before/after labels give a difference as large as the real one?"""
    rng = np.random.default_rng(seed)
    observed = after.mean() - before.mean()
    pooled = np.concatenate([before, after])
    count = 0
    for _ in range(n):
        rng.shuffle(pooled)
        shuffled_diff = pooled[len(before):].mean() - pooled[:len(before)].mean()
        if shuffled_diff >= observed:
            count += 1
    return count / n

trials = spks[best, high]
before = trials[:, pre].sum(axis=1) / (pre.sum() * dt)
after  = trials[:, post].sum(axis=1) / (post.sum() * dt)
print(f"before {before.mean():.1f} Hz, after {after.mean():.1f} Hz, p = {permutation_p(before, after):.4f}")
# before 5.1 Hz, after 66.3 Hz, p = 0.0000

Not one of five thousand shuffles came close. For this neuron the answer to the lesson’s question is a definite yes. Now we have a test we can run on anything.

Step 7: which parts of the brain are listening?

Wrap the test in a function that takes a list of neurons and returns the fraction that respond, then run it on every area with a decent number of neurons. This takes a minute or two, because it is running a thousand shuffles for each of a few hundred neurons. That is fine. Real analysis takes time.

def fraction_responsive(idx, alpha=0.01):
    hits = 0
    for i in idx:
        b = spks[i, high][:, pre].sum(axis=1)
        a = spks[i, high][:, post].sum(axis=1)
        if permutation_p(b, a, n=1000) < alpha:
            hits += 1
    return hits / len(idx)

for a in ["VISp", "VISam", "LGd", "CA1", "DG", "MOs", "ACA"]:
    idx = np.where(area == a)[0]
    print(f"{a:5s} {len(idx):3d} neurons, {fraction_responsive(idx)*100:4.0f}% respond to the stimulus")
# VISp   66 neurons,   47% respond to the stimulus
# VISam  79 neurons,   16% respond to the stimulus
# LGd    11 neurons,    9% respond to the stimulus
# CA1    50 neurons,    0% respond to the stimulus
# DG     65 neurons,    2% respond to the stimulus
# MOs     6 neurons,   17% respond to the stimulus
# ACA    16 neurons,    0% respond to the stimulus
Left: population-average response on high-contrast trials, by area. Right: the fraction of neurons in each area that pass the test. Primary visual cortex is listening. The hippocampus is not.
Left: population-average response on high-contrast trials, by area. Right: the fraction of neurons in each area that pass the test. Primary visual cortex is listening. The hippocampus is not.

Half of primary visual cortex responds within a quarter of a second of a stimulus in its visual field. The hippocampus, which sits a few millimetres away and is busy with memory and space, does not respond at all. Secondary motor cortex shows a weak, later bump, and if you look at the left panel you will see it: that is the mouse starting to move, not the stimulus itself. The visual thalamus number is low, but there are only eleven neurons there, and eleven is not enough to say much. Small samples are a fact of life in this field, and the first thing to check whenever a number surprises you.

You have just done, on real data, the analysis that a large fraction of systems neuroscience papers are built on: align, average, quantify, test, and compare across areas.

What you just did

  • Worked with trial-structured data as a three-dimensional array and selected from it with Boolean masks.
  • Made a trial-sorted raster and a peri-stimulus time histogram, the two standard plots of stimulus-evoked activity.
  • Measured a tuning curve with error bars.
  • Wrote a permutation test from scratch and understood every line of it.
  • Applied it to hundreds of neurons and mapped which brain areas respond to a visual stimulus.

Exercises

  1. Repeat steps 3 and 4 using contrast_left instead. The probes are in the left hemisphere. What do you expect, and what do you get?
  2. Neuron 141 has a second, broader hump around 250 ms. Sort the raster by dat["response"] (which way the mouse turned the wheel: -1, 0 or 1) instead of by contrast. What is the second hump about?
  3. Measure the response latency: the first time bin after onset where the high-contrast PSTH exceeds the pre-stimulus mean by three standard deviations. Do it for every VISp neuron and plot the distribution.
  4. Harder: the permutation test treats every trial as independent. Trials early and late in a session can differ, because the mouse gets tired. Split the trials into first half and second half and check whether the tuning curve changes.

Data: Steinmetz, Zatka-Haas, Carandini & Harris, “Distributed coding of choice, action and engagement across the mouse brain”, Nature 2019, CC-BY 4.0, via Neuromatch Academy. The single-session extract hosted here contains the spike counts, brain areas, contrasts and responses from session 11 (mouse Lederberg, 5 December 2017) and nothing else.

Your first neuron in Python

By the end of this lesson you will have loaded a real recording from a mouse brain, looked at how neurons actually fire, measured a few things about them, and built a working model of a neuron from scratch. All of it in Python, all of it in about thirty lines of code you write yourself. No prior programming experience is assumed.

The data

We will use a recording from Steinmetz and colleagues (2019), who inserted Neuropixels probes into the brains of mice performing a visual decision task. One session, 734 neurons, 45 minutes. Neuromatch Academy packaged it as a single file. Download it here: steinmetz_session.npz (42 MB). Save it in the folder where you will run your code.

Each neuron in the file is represented by a list of the moments, in seconds, at which it fired. That is all a spike train is: a list of times. Everything else in this lesson is built on that idea.

Step 1: load it

import numpy as np
import matplotlib.pyplot as plt

data = np.load("steinmetz_session.npz", allow_pickle=True)
spike_times = data["spike_times"]
print(len(spike_times), "neurons")
# 734 neurons

np.load opens the file. spike_times is now a collection of 734 arrays, one per neuron. len counts them. If you see 734 neurons, everything is working.

Step 2: look at one neuron

Let’s pick a neuron that fires reasonably often, so there is something to see. We compute every neuron’s firing rate (spikes divided by recording length) and choose the one closest to six spikes per second.

duration = max(st.max() for st in spike_times)      # length of the recording, in seconds
print(f"recording length: {duration/60:.1f} minutes")

rates = np.array([len(st) / duration for st in spike_times])   # spikes per second, one per neuron
idx = int(np.argmin(np.abs(rates - 6)))                          # the neuron closest to 6 Hz
neuron = spike_times[idx]

print(f"neuron {idx}: {len(neuron)} spikes, mean rate {rates[idx]:.2f} Hz")
print(neuron[:5])
# recording length: 45.0 minutes
# neuron 303: 16177 spikes, mean rate 5.99 Hz
# [0.011      0.03373333 0.07536667 0.1061     0.17953333]

Read the last line. Neuron 303 fired at 11 milliseconds, then 34, then 75, then 106, then 180. Those are real electrical events in a real brain, and you are looking at them in a Python list. Notice the gaps are uneven. Hold that thought.

Step 3: a raster plot

The standard picture of spiking activity is a raster: one row per neuron, one tick per spike. Here are forty neurons during the first minute.

fig, ax = plt.subplots(figsize=(10, 5))
for i in range(40):
    st = spike_times[i]
    st = st[st < 60]                              # keep only the first 60 seconds
    ax.vlines(st, i + 0.5, i + 1.5, lw=0.7)       # one small vertical tick per spike
ax.set(xlabel="time (s)", ylabel="neuron", xlim=(0, 60))
plt.show()
Forty neurons, one minute. Some rows are dense, some are almost empty, and none of them tick like a clock.
Forty neurons, one minute. Some rows are dense, some are almost empty, and none of them tick like a clock.

Two things stand out. Neurons differ enormously from each other: row 14 is busy, row 5 fires a handful of times in a minute. And no neuron is regular. The spacing between spikes looks random. That irregularity is not noise in the recording. It is how neurons behave, and we will come back to it.

Step 4: firing rate over time

A list of spike times is exact but hard to reason about. Neuroscientists usually convert it into a rate: how many spikes per second, in each second. np.histogram does the counting.

bin_size = 1.0                                       # seconds
edges = np.arange(0, 600 + bin_size, bin_size)       # the first ten minutes, in 1-second bins
counts, _ = np.histogram(neuron, bins=edges)
rate = counts / bin_size                             # spikes per second in each bin

fig, ax = plt.subplots(figsize=(10, 3.4))
ax.plot(edges[:-1], rate, lw=1)
ax.set(xlabel="time (s)", ylabel="spikes / s")
plt.show()
Neuron 303, ten minutes, one-second bins. The rate wanders between silence and twenty spikes a second as the mouse sees stimuli, decides, and moves.
Neuron 303, ten minutes, one-second bins. The rate wanders between silence and twenty spikes a second as the mouse sees stimuli, decides, and moves.

This is the first genuinely useful analysis in neuroscience: the rate over time is what you would align to a stimulus to ask whether a neuron responds to it. Every decoding algorithm and every brain-computer interface starts from something like this line.

Step 5: the gaps between spikes

Now to that irregularity. The gap between one spike and the next is called the inter-spike interval, or ISI. np.diff computes all of them at once. Dividing their standard deviation by their mean gives the coefficient of variation, or CV: a single number for how irregular the neuron is. A perfect clock has CV 0. A completely random process has CV 1.

isi = np.diff(neuron)                          # gap between consecutive spikes, in seconds
cv = isi.std() / isi.mean()
print(f"mean ISI {isi.mean()*1000:.0f} ms, CV = {cv:.2f}")
# mean ISI 167 ms, CV = 2.60

A CV above 2. This neuron is more irregular than random, because it fires in bursts: clusters of very short intervals separated by long pauses. Cortical neurons commonly have a CV near or above 1. Keep this number in mind. In a moment we will build a neuron and ask whether it can match it.

Step 6: the whole population

We computed a rate for every neuron in step 2. Let’s look at all 734 of them.

print(f"median rate {np.median(rates):.2f} Hz, max {rates.max():.1f} Hz")
print(f"{np.mean(rates < 1)*100:.0f}% of neurons fire below 1 Hz")
# median rate 1.90 Hz, max 46.4 Hz
# 33% of neurons fire below 1 Hz

fig, ax = plt.subplots(figsize=(10, 3.8))
ax.hist(rates, bins=np.logspace(-2, 2, 40))
ax.set_xscale("log")
ax.set(xlabel="mean firing rate (Hz, log scale)", ylabel="number of neurons")
plt.show()
Firing rates of all 734 neurons on a logarithmic axis. Most are nearly silent. A few do most of the talking.
Firing rates of all 734 neurons on a logarithmic axis. Most are nearly silent. A few do most of the talking.

The distribution is roughly a bell curve on a log axis, which means it is heavily skewed on a normal one: a small number of neurons fire ten or twenty times more than the typical one. This lognormal pattern shows up across brain areas and species. Nobody fully understands why, and it is one of the sharpest differences between real neural networks and artificial ones, where every unit is active on every pass. You have just reproduced a real finding with six lines of code.

Step 7: build a neuron

Time to make one. The simplest model that behaves like a neuron is the leaky integrate-and-fire neuron, and it has three ideas in it:

  • Integrate. Input current pushes the membrane voltage up.
  • Leak. Left alone, the voltage drifts back toward a resting level, like a bucket with a hole.
  • Fire. If the voltage reaches a threshold, the neuron spikes and the voltage resets.
flowchart TD

A[Start at rest, -70 mV] --> B[Add the input current]

B --> C[Leak back toward rest]

C --> D{Voltage above -50 mV?}

D -- no --> B

D -- yes --> E[Record a spike, reset to -65 mV]

E --> B
The leaky integrate-and-fire loop. Every time step does exactly this, and that is the whole model.

That is the whole model. Here it is as a function. The numbers are typical for a cortical neuron: resting at -70 mV, threshold at -50 mV, and a time constant of 20 ms, which sets how fast the leak works.

def lif(current, dt=1e-3, tau=0.02, R=1e8, v_rest=-0.070, v_thresh=-0.050, v_reset=-0.065):
    """Leaky integrate-and-fire neuron.
    current: input current in amps, one value per time step of dt seconds.
    Returns the membrane voltage over time and the spike times."""
    v = np.full(len(current), v_rest)
    spikes = []
    for t in range(1, len(current)):
        dv = (-(v[t-1] - v_rest) + R * current[t-1]) / tau   # leak toward rest, push from input
        v[t] = v[t-1] + dv * dt
        if v[t] >= v_thresh:                                   # threshold crossed
            spikes.append(t * dt)                              # record the spike
            v[t] = v_reset                                     # and reset
    return v, np.array(spikes)

Feed it a constant current and see what it does.

dt = 1e-3
t = np.arange(0, 2.0, dt)                        # two seconds in 1 ms steps
steady = np.full(len(t), 0.25e-9)                # 0.25 nanoamps, constant
v_steady, s_steady = lif(steady)
print(len(s_steady), "spikes, CV =", round(np.diff(s_steady).std() / np.diff(s_steady).mean(), 2))
# 71 spikes, CV = 0.0

Seventy-one spikes in two seconds, perfectly evenly spaced. CV of zero. It is a metronome. Real neuron 303 had a CV of 2.6. Our model is missing something important.

Step 8: make it real

What is missing is that a real neuron does not receive a steady current. It receives thousands of tiny, randomly timed inputs from other neurons. Their sum fluctuates wildly. Let’s give our model a fluctuating input instead: a mean that on its own is not enough to reach threshold, plus a lot of noise. Now only the random upward swings make it fire.

rng = np.random.default_rng(0)
noisy = 0.14e-9 + 0.55e-9 * rng.standard_normal(len(t))     # mean below threshold, big fluctuations
v_noisy, s_noisy = lif(noisy)
print(len(s_noisy), "spikes, CV =", round(np.diff(s_noisy).std() / np.diff(s_noisy).mean(), 2))
# 35 spikes, CV = 0.98

fig, axes = plt.subplots(2, 1, figsize=(10, 5.6), sharex=True)
for ax, v, s, title in [(axes[0], v_steady, s_steady, "constant input"),
                        (axes[1], v_noisy, s_noisy, "noisy input")]:
    ax.plot(t, v * 1000, lw=0.9)
    ax.vlines(s, -50, -20, lw=0.9)                # draw each spike as a tall line
    ax.set(ylabel="membrane potential (mV)", title=title)
axes[1].set_xlabel("time (s)")
plt.show()
Same neuron, two inputs. Top: constant current, perfectly regular spikes. Bottom: noisy current, and the spike timing looks like the raster in step 3.
Same neuron, two inputs. Top: constant current, perfectly regular spikes. Bottom: noisy current, and the spike timing looks like the raster in step 3.

The CV jumped from 0 to about 1 with one change to the input. That is a real result from the 1990s, argued over in the literature for years: cortical neurons are irregular not because they are sloppy, but because they operate in a regime where noise, not the average input, decides when they fire. You just found it yourself in a dozen lines.

Run the noisy model for a full minute and compare its interval distribution with neuron 303:

t_long = np.arange(0, 60.0, dt)
_, s_long = lif(0.14e-9 + 0.55e-9 * rng.standard_normal(len(t_long)))
isi_model = np.diff(s_long)

fig, ax = plt.subplots(figsize=(10, 3.8))
ax.hist(isi[isi < 0.5] * 1000, bins=50, density=True, alpha=0.6, label="real neuron 303")
ax.hist(isi_model[isi_model < 0.5] * 1000, bins=50, density=True, alpha=0.6, label="noisy LIF model")
ax.set(xlabel="inter-spike interval (ms)", ylabel="density")
ax.legend()
plt.show()
Interval distributions. The model has the right overall shape: many short gaps, a long tail. It is missing the bursts that push the real neuron's CV above 2, which is a topic for a later lesson.
Interval distributions. The model has the right overall shape: many short gaps, a long tail. It is missing the bursts that push the real neuron’s CV above 2, which is a topic for a later lesson.

What you just did

  • Loaded a real neural recording and understood what a spike train is.
  • Made a raster plot, the most common figure in systems neuroscience.
  • Converted spike times into a firing rate, the starting point of every decoding method.
  • Measured irregularity with the coefficient of variation.
  • Reproduced a real finding: neural firing rates are lognormally distributed.
  • Built a leaky integrate-and-fire neuron from scratch and discovered why real neurons are irregular.

If you had never written Python before today, you have now used arrays, loops, functions, conditionals and plotting, all in service of a real scientific question. That is the way this site teaches everything.

Exercises

  1. Change bin_size in step 4 to 0.1 and to 10. What changes, and which is more useful?
  2. Find the neuron with the highest CV in the whole population. Plot its raster. What does its firing look like?
  3. In step 8, slowly increase the mean current from 0.14 nA toward 0.25 nA while keeping the noise. What happens to the CV, and why?
  4. Harder: the model’s CV tops out near 1, but neuron 303 reaches 2.6 by firing in bursts. What would you have to add to the model to make it burst?

Data: Steinmetz, Zatka-Haas, Carandini & Harris, “Distributed coding of choice, action and engagement across the mouse brain”, Nature 2019, CC-BY 4.0, via Neuromatch Academy. Complete code for this lesson is in the blocks above and runs top to bottom as a single script.