How a neural network learns: a playground

The figure below is a complete neural network: two inputs, a handful of hidden units you choose, one output, trained by gradient descent on whatever you point it at, running in your browser. It is the smallest network that shows the things that matter about large ones, and the point of this page is to let you see them happen rather than read about them. Each experiment is a couple of clicks. If the words neural network, hidden unit or training are new, read the beginner’s guide first; if you have done lesson 3, you have already built the version of this with no hidden units.

Loading the interactive figure…
The playground. Pick a dataset, choose how many hidden units, press train. Filled dots are training examples, hollow dots are the held-out test set, and the shading is the network’s current answer for every point in the plane.

What you are looking at

Each dot is an example with two measurements (its position) and a label (its colour). The network's job is to learn a rule that gives the right colour from the position. The big square shows the rule it currently has: for every point on the plane, blue or orange, stronger where it is more sure. The small diagram on the right is the network itself, with each weight drawn as a line whose thickness is its size and whose colour is its sign. The curves below it are the loss, a measure of how wrong the network is, on the training examples (solid) and on the examples it never sees during training (dashed).

Training is the same loop as lesson 3: show the network the training examples, measure how wrong it is, work out for every weight which direction would make it less wrong, and move each weight a little in that direction. The learning rate is how little. One press of "one step" does that once; "train" does it a few times per frame until you stop it.

Five experiments

  1. A straight line is enough. Choose two blobs and set hidden units to none. Press train. This is logistic regression, exactly lesson 3's decoder, and it can only draw a straight boundary. For two blobs a straight line is all you need, and both accuracies go to about 95 percent within a hundred epochs. Now watch the weights in the diagram: two lines, one from each input, and their thickness is the whole model.
  2. Rings need a bend. Choose rings, still with no hidden units, and train. It cannot do it: there is no straight line that separates an inner disc from an outer ring, and the accuracy stays near 50 percent however long you wait. Set hidden units to 1 and reset: a little better, since one unit can bend the boundary once. Set it to 3: the boundary closes into a loop and the accuracy climbs past 95 percent. Each hidden unit draws one straight line of its own; the output combines them, and three lines can enclose a region.
  3. The problem that stalled the field. Choose four corners: blue where both inputs have the same sign, orange where they differ. With no hidden units this is impossible, and in 1969 Minsky and Papert used exactly this example to argue that single-layer networks were a dead end, which helped stop research for a decade. Two hidden units solve it. Watch how: each learns one diagonal, and the output learns to say "one but not both".
  4. Overfitting, live. Choose spiral, set 8 hidden units and a learning rate of about 1.5, and let it train for a while. The training loss keeps falling. The dashed test loss falls, flattens, and then, often, begins to rise, while the shading grows strange tongues that fit single training dots. The network is memorising the examples rather than learning the rule, and the only thing that reveals it is the data you held out. This is why lesson 3 insisted on a test set, and why every serious model is judged on data it has not seen.
  5. Real neurons. Choose real: two neurons. The dots are the 340 trials from lesson 3, positioned by how many spikes neurons 141 and 190 fired, coloured by whether a stimulus was on the screen. With no hidden units the network reaches about 97 percent, which is lesson 3's result for these two cells. Now add hidden units. Nothing improves, because the two classes overlap where they overlap and no boundary, however bendy, can separate what the neurons themselves did not. More model does not fix less information. Lesson 3 got to 99.7 percent not with a cleverer network but with 64 more neurons.

Things to notice along the way

  • The learning rate is a dial between slow and unstable. Set it very low and training crawls. Set it to 3 on the spiral and the loss jumps around instead of falling: each step overshoots. Real training uses schedules that start high and shrink, for this reason.
  • Reset changes the answer. "Reset weights" starts from different random values. On the spiral, train twice from different starts and you will get two different boundaries with similar accuracy. There is no single right network, only a family of acceptable ones, which is one reason the weights of a trained model are hard to interpret.
  • Width buys shape, not understanding. Hidden units are straight lines; more of them means a boundary with more bends. That is the entire source of a network's flexibility, in two dimensions and, with more layers, in billions.
  • Nothing here knows what a ring is. The network has no concept of circles, corners or spirals. It has weights and a gradient. Everything that looks like understanding in the shading is the gradient having found a boundary that lowers the loss on these dots. Keep that in mind when a much larger version of the same machine produces a paragraph.

How it relates to the rest of the site

The network with no hidden units is the logistic regression of lesson 3, and the GLM primer explains why that is one member of a family. The hidden layer is new, and it is what turns a linear decoder into a universal one: a theorem from 1989 says that one hidden layer with enough units can approximate any reasonable boundary, which the rings and spiral experiments demonstrate in miniature. The train/test split is lesson 3's central rule, and the overfitting experiment is the reason for it. The real-data experiment is a reminder that in neuroscience the data, not the model, are usually the limit, which is the recurring lesson of lesson 4 and of the whole Foundations track.

The network is written in about a hundred lines of plain JavaScript inside this site's plugin: a forward pass, a backward pass computed by hand, and a loop. There is nothing in it that lesson 3 did not already contain except the hidden layer and the chain rule. The real dataset is neurons 141 and 190 from the Steinmetz recordings (Nature 2019, CC-BY, via Neuromatch).

How a brain works, how a language model works, and where they meet

This page is for someone who has heard that artificial intelligence was inspired by the brain, that language models are “neural networks”, and that nobody quite agrees on what either machine is doing. It explains, with no equations and no code, what a neuron does, what a brain does with billions of them, what a language model does, and where the two really overlap. The pictures move. Each one is a cartoon of the mechanism, drawn to be true in spirit rather than in detail, and the text says where the cartoon ends.

One neuron, one unit

Start with the smallest parts. A neuron is a cell. It receives signals from other neurons on its branches (dendrites), it has an electrical voltage across its skin, and when that voltage is pushed high enough it fires: a spike, a thousandth of a second long, that travels down its axon to the thousands of neurons it connects to. Between spikes the voltage leaks back to rest, so inputs that arrive close together count for more than inputs spread out in time. A neuron is a device for deciding, moment by moment, whether enough has happened recently.

An artificial unit, the building block of a language model, was invented in the 1940s and 50s as a cartoon of exactly that. It receives numbers. Each number is multiplied by a weight, which stands in for how strong a connection is. The products are added up and the sum is squashed into a fixed range, and that is the output. There is no voltage, no leak, no spike and no time: the unit is a formula, evaluated once.

Loading the interactive figure…
Watch them side by side. Left: spikes arrive, the voltage fills and leaks, and the neuron fires when it reaches threshold. Right: numbers arrive, meet their weights, are summed and squashed, and a number comes out.

Both are summarised by one idea: add up your inputs, weighted by how much you trust each source, and respond when the total is large. That idea is the whole inheritance from neuroscience to artificial intelligence, and lesson 1 builds the neuron version in thirty lines of Python so you can see the leak and the threshold do their work.

A brain: billions of them, in time

A brain is about 86 billion neurons wired into one another, each receiving from thousands and sending to thousands. Three things about how they work together are easy to miss from the single-neuron picture.

  • It is sparse. At any instant only a small fraction of neurons are firing; the typical neuron fires a few times a second, many fire less than once. Most of the brain is nearly silent most of the time, and that silence is informative: which cells are active, out of the many that could be, carries the message.
  • It runs in time. A spike is an event with a precise moment. Information is carried by when cells fire relative to each other, not just whether they fire, and rhythms (the alpha and theta waves of lesson 6) organise the timing across whole regions.
  • It is organised by what it does. Different regions handle vision, hearing, movement, memory and planning, each with its own wiring, connected in loops rather than in a single direction. There is no input end and output end; the system is always in the middle of doing something.

A language model: the next word, a trillion times

A large language model is a program with one job: given the words so far, produce a probability for every word that could come next. Type "The mouse saw the", and it reports that "cheese" is likely, "cat" fairly likely, "screen" possible, and "democracy" not. Pick a word from that list, add it to the text, ask again, and you have a machine that writes. Everything a chatbot does, answering, summarising, coding, is this loop.

Inside, the words are converted to lists of numbers, and those numbers pass through dozens of layers of the artificial units from the first figure, hundreds of billions of weights in all. One ingredient makes modern models different from earlier ones: attention. At each layer, each word position works out how much to draw on every earlier position. Reading "the" at the end of "The mouse saw the", the model attends strongly to "mouse" and "saw", because those are what make "cheese" and "cat" likely. Attention is how a model keeps track of what a sentence is about, and it is learned, not programmed.

Loading the interactive figure…
Left: a cartoon of a patch of cortex, with spikes rippling through a sparse network a few cells at a time. Right: a cartoon of a language model reading a sentence, attending to the earlier words, and producing probabilities for the next one. The sentences and numbers are illustrative.

How does it learn the weights? By reading. The model is shown trillions of words of text, one at a time, asked to predict the next, and told how wrong it was. A procedure called backpropagation works out, for every one of the billions of weights, which direction would have made the prediction less wrong, and nudges each by a tiny amount. Repeat for months on thousands of computers. No one writes the rules of grammar or the facts in; they are whatever settles into the weights because they make the next word more predictable.

Where they differ

A brainA large language model
Basic unitA neuron: a cell that fires brief electrical spikesAn artificial unit: a weighted sum of numbers, squashed
How manyAbout 86 billion neurons, each with thousands of connectionsHundreds of billions to trillions of weights, arranged in layers
SignalSpikes: all-or-nothing events, timed to the millisecondNumbers, passed through the layers once per word
ActivitySparse: most neurons are nearly silent most of the timeDense: every unit computes on every pass
TimeBuilt in: everything is a process unfolding at its own paceNot built in: a sequence is handled as a list of positions
LearningSynapses strengthen or weaken from local activity; nobody has found a global error signalEvery weight is nudged by a global error signal, backpropagation, over trillions of words
PowerAbout 20 wattsMegawatts to train; a data centre to run at scale
What it predictsWhat the senses will report next, and the outcome of actions, continuouslyThe next word, given the words so far

Two rows deserve emphasis. The brain learns without anything resembling backpropagation: a synapse changes based on what happens at that synapse, and no one has found the global error signal that would be needed to tell every connection how it contributed to a mistake. How brains learn as well as they do with only local rules is one of the central open questions of the field, and one of the places where neuroscience may still have something to give back to AI. And the brain runs on 20 watts, about a dim light bulb, because most of it is silent most of the time. A model that evaluates every unit on every pass cannot do that, and the gap in energy is not a detail; it is a sign that the two machines are organised on different principles.

Where they meet

Both are prediction machines. A brain is constantly predicting what its senses will report next and what its actions will bring about, and much of what it does can be read as correcting those predictions; a language model predicts the next word. Both store what they know in the strengths of connections rather than in a list of facts. Both are made of simple units that are individually dumb and collectively not. And both have turned out to be far more capable than their units would suggest, in ways their builders did not plan and do not fully understand.

Those similarities are why the two fields keep borrowing from each other: convolutional networks came from visual cortex, reinforcement learning from the dopamine system, and in the other direction, language models are now used as models of how the brain processes language, because their internal activity predicts brain recordings surprisingly well. The essay on the difference between AI and machine learning and the opening argument of this site go further into that exchange.

Words you will meet

  • Neuron: a brain cell that sends electrical spikes. Synapse: the junction where one neuron influences another; its strength is what learning changes.
  • Spike (action potential): the millisecond-long electrical pulse a neuron fires. Firing rate: spikes per second, the most common summary of what a neuron is doing.
  • EEG: the brain's electrical activity recorded from the scalp, which is the sum of millions of neurons at once.
  • Unit (artificial neuron): a weighted sum followed by a squashing function. Weight: a number standing for a connection strength; a model is its weights.
  • Token: the unit of text a language model reads, usually a word or part of one. Attention: the mechanism by which each position in the text draws on the others.
  • Backpropagation: the procedure that works out how to adjust every weight after a mistake. Training: doing that trillions of times.

Where to go from here

If this page made sense, you are ready for the learning path. Lesson 1 builds the neuron from the first figure in Python and loads a real recording of 734 of them. Lesson 3 builds the artificial unit from the same figure and uses it to read a mouse's mind. Lesson 5 shows what spikes look like coming off a real electrode, and lesson 7 turns brain rhythms into a control signal. None of it needs more than Python and an hour a week. And if you would rather see a network learn before you build one, the neural network playground trains one in your browser while you watch.

The animations on this page are cartoons of the mechanisms, built to be watched rather than measured. The neuron is a leaky integrate-and-fire model; the network is a random feed-forward sketch; the language-model sentences and probabilities are invented to show attention and next-word prediction. Real numbers for all of these are in the lessons.

Detecting a seizure: from scalp EEG to a Raspberry Pi monitor

This is the first article in the precision-medicine track, and it is a project rather than a lesson: take the tools from lesson 6 and lesson 7, point them at a clinical problem, and carry the result all the way to a device. The problem is seizure detection from scalp EEG. The data are real: seven seizures from one child in the CHB-MIT database. The detector is five features and a logistic regression, and it catches all seven with a five-second delay. The second half of the article turns it into a service that runs on a Raspberry Pi with an open-source EEG board, with the diagrams to build it. Throughout, it is specific about what this can and cannot detect, because that distinction is the whole of the difference between a project and a medical device.

Which seizures, exactly

“Seizure” covers very different events, and a detector built from scalp EEG can only find the ones that change the scalp EEG. The International League Against Epilepsy classifies seizures by where they start (focal or generalised), by awareness, and by whether there is movement. For a detector the more useful split is electrographic: does the seizure produce a rhythm at the scalp that is large, sustained and different from the background? The seizures in this article do. All seven start in the right hemisphere, under the right temporal chain of electrodes, as a rhythm that grows over seconds and spreads; they last 27 to 101 seconds; and they are what the CHB-MIT documentation describes as intractable (drug-resistant) seizures in a child being evaluated for surgery. In the ILAE language they are focal-onset seizures with a clear scalp EEG correlate, several of which spread to both hemispheres. Here is the honest scope of what the methods on this site can and cannot do with them:

EventWhat the scalp EEG showsThis detector
Focal seizure with a scalp signature (the seven here: right-hemisphere onset, 27 to 101 s)Rhythmic activity that starts in one region, grows, spreads and slows; large and sustainedYes. This is what it was built and tested on
Focal to bilateral tonic-clonic (a “grand mal” that starts focally)Same start, then very large, then muscle artefact swamps everythingYes, by the onset; the later part is mostly muscle, which also trips the features
Absence seizure (typical, childhood)3-per-second spike-and-wave, generalised, abrupt, 5 to 20 sProbably: large rhythmic delta/theta is exactly what the weights reward, but it is untested here; see the exercises
Focal aware seizure with little or no scalp changeOften nothing visible; the source is small or deepNo. Nothing to detect at the scalp
Myoclonic jerkA single brief burst, under a secondNo. Shorter than the window and the persistence rule
Non-epileptic event (fainting, a psychogenic seizure)No ictal rhythm; often movement artefactCannot tell; may false-alarm on the artefact
Which type a seizure isNeeds the clinical picture and usually videoNo. Detection is not diagnosis

Two more boundaries. Everything here is detection: raising an alarm while the seizure is happening. Prediction, raising it minutes before, is a different problem that decades of work have not solved reliably, and anyone selling it to you should be asked for their false-alarm rate. And the detector is patient-specific: trained on this child’s seizures and tested on her other seizures. A detector that works across patients it has never seen is a harder problem, and the state of the art there is still a human reading the trace.

The data

The CHB-MIT Scalp EEG Database was collected at Boston Children’s Hospital: 22 children with drug-resistant epilepsy, monitored for days while their medication was reduced to provoke seizures ahead of possible surgery, 23 channels at 256 Hz, with every seizure marked by a clinician. It is the dataset most seizure-detection papers of the last fifteen years were tested on. Patient chb01 is an eleven-year-old girl with seven seizures over the 40 hours of her recording. The channels are bipolar: each is the difference between two neighbouring electrodes, which cancels what they share, the same idea as lesson 7’s Laplacian. The file holds two chains of four, running front to back on the left (FP1-F7 to P7-O1) and on the right (FP2-F8 to P8-O2).

Step 1: look at one

import numpy as np
import matplotlib.pyplot as plt
from scipy.signal import butter, filtfilt

dat = np.load("chbmit_chb01.zip")
eeg = dat["eeg"].astype(float)                 # 8 channels x samples, microvolts
fs = float(dat["fs"])                          # 128 samples per second
names = list(dat["channels"])                  # bipolar pairs: each channel is the difference between two electrodes
sz_start, sz_end = dat["seizure_start"], dat["seizure_end"]      # sample indices of the seven seizures
seg_start = dat["segment_start"]               # where each recording segment begins in the array
print(eeg.shape, "=", eeg.shape[1] / fs / 60, "minutes;", len(sz_start), "seizures lasting", ((sz_end - sz_start) / fs).astype(int), "seconds")
print("channels:", names)
# (8, 494336) = 64.36666666666666 minutes; 7 seizures lasting [ 40  27  40  51  90  93 101] seconds
# channels: ['FP1-F7', 'F7-T7', 'T7-P7', 'P7-O1', 'FP2-F8', 'F8-T8', 'T8-P8', 'P8-O2']

k = 0                                          # the first seizure
t0 = sz_start[k] / fs
window = slice(int((t0 - 30) * fs), int((t0 + 60) * fs))
t = np.arange(eeg.shape[1]) / fs - t0
fig, ax = plt.subplots(figsize=(11, 6))
for i in range(8):
    ax.plot(t[window], eeg[i, window] - i * 400, lw=0.5)
ax.axvline(0, color="orange"); ax.axvline((sz_end[k] - sz_start[k]) / fs, color="orange", ls="--")
ax.set(yticks=-np.arange(8) * 400, yticklabels=names, xlabel="time from seizure onset (s)")
plt.show()
Seizure 1 of 7, on all eight channels. Thirty seconds of ordinary background, then at time zero a rhythm begins on the right-hand chain (blue), grows over ten seconds, spreads to the left chain, and stops forty seconds later, leaving a slower, flatter trace. The spectrogram of F8-T8 below shows the same thing as lesson 6 would: a burst of power that starts across many frequencies and slows as the seizure evolves. The horizontal line at 16 Hz is an equipment artefact present throughout.
Seizure 1 of 7, on all eight channels. Thirty seconds of ordinary background, then at time zero a rhythm begins on the right-hand chain (blue), grows over ten seconds, spreads to the left chain, and stops forty seconds later, leaving a slower, flatter trace. The spectrogram of F8-T8 below shows the same thing as lesson 6 would: a burst of power that starts across many frequencies and slows as the seizure evolves. The horizontal line at 16 Hz is an equipment artefact present throughout.

Everything a detector needs is visible by eye. The seizure is larger than the background, it is rhythmic, it evolves (fast at first, slowing toward the end, which is the signature that distinguishes a seizure from an artefact of fixed frequency), and it lasts. The features below are each a way of putting a number on one of those.

Step 2: five numbers per second

Cut the recording into two-second windows, one per second, as in lesson 7. For each window compute the power in four bands, delta through beta, the way lesson 6 did, and one more feature: line length, the average absolute difference between consecutive samples, which is large when the trace is both big and fast and is the single most used feature in the seizure-detection literature because it is cheap and it works. Take each feature on every channel and keep the loudest channel, so that a seizure confined to one side is not diluted by the quiet side. Five numbers per window. Label a window as seizure if its centre falls inside one of the clinician’s marked intervals.

def features(window_data, fs):
    """One row of numbers describing a 2-second window of multichannel EEG."""
    out = []
    for lo, hi in [(1, 4), (4, 8), (8, 13), (13, 30)]:                        # delta, theta, alpha, beta
        b, a = butter(3, [lo / (fs / 2), hi / (fs / 2)], "band")
        out.append(np.log(filtfilt(b, a, window_data, axis=1).var(axis=1) + 1e-6).max())   # band power, loudest channel
    out.append(np.log(np.abs(np.diff(window_data, axis=1)).mean(axis=1)).max())           # line length: how wiggly the trace is
    return np.array(out)

win, step = int(2 * fs), int(1 * fs)
starts = np.arange(0, eeg.shape[1] - win, step)                    # one window per second
X = np.array([features(eeg[:, s:s + win], fs) for s in starts])   # windows x 5 features
label = np.zeros(len(starts), dtype=int)
for s, e in zip(sz_start, sz_end):
    label[(starts + win / 2 >= s) & (starts + win / 2 <= e)] = 1  # a window is "seizure" if its centre is inside one
print(X.shape, "windows x features;", label.sum(), "seizure windows,", (label == 0).sum(), "normal windows")
# (3860, 5) windows x features; 449 seizure windows, 3411 normal windows
What the detector sees through seizure 1, one window per second. Top: delta and theta power on the loudest channel, which rise ten to fifty fold at onset. Middle: line length, which triples. Bottom: the classifier's score, with the threshold dashed and the alarm (red) seven seconds after onset. The two line-length spikes after the seizure are movement artefacts, and the score ignores them because their band powers do not match.
What the detector sees through seizure 1, one window per second. Top: delta and theta power on the loudest channel, which rise ten to fifty fold at onset. Middle: line length, which triples. Bottom: the classifier’s score, with the threshold dashed and the alarm (red) seven seconds after onset. The two line-length spikes after the seizure are movement artefacts, and the score ignores them because their band powers do not match.

Step 3: the classifier, tested honestly

The classifier is lesson 3‘s logistic regression on the five features, with a small penalty on the weights. The important part is how it is tested. The 449 seizure windows and 3,411 normal windows are not independent: windows from the same seizure are near-copies of each other, and a random split would put copies of every test seizure in the training set and report an accuracy that means nothing. So the split is by seizure: for each of the seven, train on the other six segments and score this one. Every score below was produced by a model that had never seen the seizure it was scoring. The seizure-free quarter hour is scored by a model trained on all seven.

def train_decoder(X, y, lr=0.1, steps=3000):
    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 score(X, w, b):
    return 1 / (1 + np.exp(-(X @ w + b)))

segment_of = np.searchsorted(seg_start, starts, side="right") - 1    # which recording segment each window came from
scores = np.zeros(len(starts))
for seg in range(len(sz_start)):                                      # leave one seizure's segment out, test on it
    train = segment_of != seg
    mean, sd = X[train].mean(axis=0), X[train].std(axis=0)
    w, b = train_decoder((X[train] - mean) / sd, label[train])
    scores[segment_of == seg] = score((X[segment_of == seg] - mean) / sd, w, b)
mean, sd = X[segment_of < 7].mean(axis=0), X[segment_of < 7].std(axis=0)   # the seizure-free hour: scored by a model trained on all seven
w, b = train_decoder((X[segment_of < 7] - mean) / sd, label[segment_of < 7])
scores[segment_of == 7] = score((X[segment_of == 7] - mean) / sd, w, b)
print("weights (delta, theta, alpha, beta, line length):", w.round(2))
# weights (delta, theta, alpha, beta, line length): [1.21 1.38 0.49 0.4  0.42]

The weights are readable. Delta and theta power carry the most, line length and the faster bands less. That matches the figure: this patient’s seizures are slow rhythms, 2 to 8 Hz, that get large.

Step 4: from a score to an alarm

A score above 0.5 on a single window is not an alarm. Backgrounds have one-second bumps all the time, and a device that buzzed on each of them would be switched off within a day. Real detectors add two pieces of logic that have nothing to do with machine learning. Persistence: the score has to stay above threshold for several windows in a row. Refractory period: after an alarm, stay quiet for a while, so that a ninety-second seizure produces one alarm and not thirty. Both trade latency and sensitivity against false alarms, and the numbers that matter clinically are exactly those three: how many seizures were caught, how long after onset, and how many false alarms per hour.

The decision logic on a made-up score trace. A single window over the threshold is ignored. The third consecutive window fires the alarm. For the next thirty seconds the detector stays silent.
The decision logic on a made-up score trace. A single window over the threshold is ignored. The third consecutive window fires the alarm. For the next thirty seconds the detector stays silent.
Loading the interactive figure…
Tune the detector yourself. The sliders are the three settings from step 4; the numbers and alarms update as you move them, and clicking the timeline shows the EEG at that moment.
def alarms(scores, threshold=0.5, persistence=3, refractory=30):
    """Fire when the score has been above threshold for `persistence` windows in a row; then stay quiet for `refractory` windows."""
    fired, streak, quiet_until = [], 0, -1
    for i, s in enumerate(scores):
        streak = streak + 1 if s > threshold else 0
        if streak >= persistence and i > quiet_until:
            fired.append(i); quiet_until = i + refractory
    return np.array(fired)

def evaluate(fired):
    detected, latency = 0, []
    for s, e in zip(sz_start, sz_end):
        hits = [f for f in fired if s <= starts[f] + win / 2 <= e + 10 * fs]      # an alarm during the seizure (or within 10 s after)
        if hits:
            detected += 1; latency.append((starts[hits[0]] + win / 2 - s) / fs)
    false = [f for f in fired if label[max(0, f - 5):f + 6].sum() == 0]           # alarms with no seizure within 5 s
    hours_normal = (label == 0).sum() / 3600
    return detected, np.mean(latency) if latency else np.nan, len(false) / hours_normal

for persistence in [1, 3, 5]:
    d, lat, fa = evaluate(alarms(scores, persistence=persistence))
    print(f"persistence {persistence} windows: {d} of 7 seizures detected, mean latency {lat:4.1f} s, {fa:4.1f} false alarms per hour")
# persistence 1 windows: 7 of 7 seizures detected, mean latency  6.9 s, 23.2 false alarms per hour
# persistence 3 windows: 7 of 7 seizures detected, mean latency  5.1 s,  3.2 false alarms per hour
# persistence 5 windows: 7 of 7 seizures detected, mean latency  9.9 s,  1.1 false alarms per hour
All 64 minutes of the file: the classifier score, the clinician's seizure intervals in orange, alarms as triangles, false alarms in red. The seven seizures are each caught within seconds. The three false alarms all fall in the last quarter hour, the recording from a different hour of the day that no training segment resembled.
All 64 minutes of the file: the classifier score, the clinician's seizure intervals in orange, alarms as triangles, false alarms in red. The seven seizures are each caught within seconds. The three false alarms all fall in the last quarter hour, the recording from a different hour of the day that no training segment resembled.

All seven seizures caught at every setting. With persistence 3, the alarm comes 5 seconds after onset on average and there are 3.2 false alarms per hour; with persistence 5, latency doubles and false alarms drop to about one an hour. That is the trade-off every detector lives on, and where to sit on it is a question for the person wearing it, not the engineer. The odd result, that persistence 1 has longer latency than persistence 3, is the refractory period at work: a single-window false alarm just before a seizure silences the detector for thirty seconds and delays the real alarm.

Look at where the false alarms are. All three fall in the fifteen minutes from a different hour, a recording whose background the model had never seen. Within the segments around seizures, the background was learned and the false-alarm rate is zero; one hour later, the same patient, awake or asleep or moving differently, and the rate is twelve an hour. This is the central practical problem of seizure detection and of clinical machine learning generally: the data you test on must come from a different time than the data you trained on, or the number you report is fiction. It is lesson 3's train-test rule again, with time as the thing to hold out.

Step 5: save the model

Everything the device needs is five weights, a bias, the standardisation constants, and the three decision parameters. Write them to a file.

import json
model = {"fs": fs, "weights": w.tolist(), "bias": float(b), "mean": mean.tolist(), "sd": sd.tolist(),
         "threshold": 0.5, "persistence": 3, "refractory": 30}
json.dump(model, open("model.json", "w"), indent=1)
print("saved model.json:", {k: (np.round(v, 2).tolist() if isinstance(v, list) else v) for k, v in model.items()})
# saved model.json

Building the device

A detector that runs on a laptop against a file is an analysis. A detector that runs on a small computer against electrodes on a head, unattended, for days, is a system, and most of what makes it one is not the classifier. This section describes a build from open parts and gives the code. The pipeline is the one you have just run; what changes is that the data arrive one sample at a time and never stop.

The detector as a loop. Everything up to the classifier is lessons 6 and 7, the classifier is lesson 3, and the decision logic is step 4. On a device this loop runs once a second, forever.
The detector as a loop. Everything up to the classifier is lessons 6 and 7, the classifier is lesson 3, and the decision logic is step 4. On a device this loop runs once a second, forever.

Hardware

  • Electrodes. Eight gold-cup electrodes with conductive paste, placed by the 10-20 system at the positions the bipolar chains need (FP1, F7, T7, P7, O1 and the right-side equivalents), or a dry-electrode headband for a less fussy and noisier setup. Paste electrodes on a child for days is how the hospital did it, and it is a skill.
  • Amplifier. The signal is microvolts; the board that turns it into numbers is the one part that is hard to improvise. Two open-hardware options use the same Texas Instruments ADS1299 chip that research amplifiers use: the OpenBCI Cyton, an 8-channel board that streams at 250 Hz to a USB dongle and is supported by the BrainFlow library, and the PiEEG, an 8-channel shield that plugs directly onto a Raspberry Pi and talks over SPI. Both are open-source designs with published schematics.
  • Computer. A Raspberry Pi 4 or 5. The detector loop needs a few milliseconds a second; the Pi spends the rest idle. Power it from a battery bank if the wearer moves.
  • Outputs. A buzzer on a GPIO pin for the room, and a push notification for a phone elsewhere; the free ntfy service does the latter with one HTTP request and no account.
What talks to what. The amplifier board turns microvolts into numbers and hands them to the Pi over USB or SPI; one process fills a ring buffer, the detector reads the last two seconds from it once a second, and alarms go to a buzzer, a phone and a log. A systemd unit keeps it running.
What talks to what. The amplifier board turns microvolts into numbers and hands them to the Pi over USB or SPI; one process fills a ring buffer, the detector reads the last two seconds from it once a second, and alarms go to a buzzer, a phone and a log. A systemd unit keeps it running.

The service

One file. It has two sources that look identical to the rest of the program: a FileSource that replays a recording at real speed (or faster) and a BrainFlowSource that reads a Cyton. The Detector holds the last two seconds in a ring buffer, computes the same five features as step 2 on them, applies the saved weights, and runs the persistence and refractory logic. The main loop steps the detector once per second of data, not of wall-clock time, which is the kind of detail that only matters when you run it for real: a laggy USB read must not turn into alarms with the wrong timestamp.

"""monitor.py: a seizure detector as a long-running service.

Run on a laptop with a recording:   python monitor.py --source file --file chbmit_chb01.zip
Run on a Raspberry Pi with a board:  python monitor.py --source brainflow --port /dev/ttyUSB0
"""
import argparse, json, time, logging, urllib.request
import numpy as np
from collections import deque
from scipy.signal import butter, filtfilt

log = logging.getLogger("monitor")


# ---------- sources: anything with .read() that returns the newest samples as channels x n ----------
class FileSource:
    """Replays a recording at real speed, so the rest of the program cannot tell it from a live board."""
    def __init__(self, path, fs):
        d = np.load(path); self.eeg = d["eeg"].astype(float); self.fs = fs; self.pos = 0; self.t = time.time()
    def read(self):
        due = int((time.time() - self.t) * self.fs) - self.pos              # how many samples have "arrived" since last call
        chunk = self.eeg[:, self.pos:self.pos + due]; self.pos += chunk.shape[1]
        return chunk


class BrainFlowSource:
    """An OpenBCI Cyton (or any BrainFlow board) over USB. pip install brainflow."""
    def __init__(self, port, channels=8):
        from brainflow.board_shim import BoardShim, BrainFlowInputParams, BoardIds
        params = BrainFlowInputParams(); params.serial_port = port
        self.board = BoardShim(BoardIds.CYTON_BOARD, params)
        self.rows = BoardShim.get_eeg_channels(BoardIds.CYTON_BOARD)[:channels]
        self.fs = BoardShim.get_sampling_rate(BoardIds.CYTON_BOARD)         # 250
        self.board.prepare_session(); self.board.start_stream()
    def read(self):
        return self.board.get_board_data()[self.rows]                         # everything since the last call, in uV


# ---------- the detector: the same five features and the weights trained in the article ----------
class Detector:
    def __init__(self, model, fs, threshold=0.5, persistence=3, refractory=30):
        self.w, self.b = np.array(model["weights"]), model["bias"]
        self.mean, self.sd = np.array(model["mean"]), np.array(model["sd"])
        self.fs = fs; self.buffer = deque(maxlen=int(2 * fs))                 # the last two seconds, per sample
        self.threshold, self.persistence, self.refractory = threshold, persistence, refractory
        self.streak, self.quiet_until = 0, 0

    def features(self, window):
        out = []
        for lo, hi in [(1, 4), (4, 8), (8, 13), (13, 30)]:
            b, a = butter(3, [lo / (self.fs / 2), hi / (self.fs / 2)], "band")
            out.append(np.log(filtfilt(b, a, window, axis=1).var(axis=1) + 1e-6).max())
        out.append(np.log(np.abs(np.diff(window, axis=1)).mean(axis=1)).max())
        return (np.array(out) - self.mean) / self.sd

    def step(self, now):
        """Called once a second. Returns the score, and True if an alarm should fire."""
        window = np.array(self.buffer).T                                      # channels x samples
        if window.shape[1] < self.buffer.maxlen:
            return None, False
        s = 1 / (1 + np.exp(-(self.features(window) @ self.w + self.b)))
        self.streak = self.streak + 1 if s > self.threshold else 0
        fire = self.streak >= self.persistence and now > self.quiet_until
        if fire:
            self.quiet_until = now + self.refractory
        return s, fire


def notify(topic, text):
    """Push to a phone with ntfy.sh (free, no account): subscribe to the topic in the ntfy app."""
    try:
        urllib.request.urlopen(urllib.request.Request(f"https://ntfy.sh/{topic}", data=text.encode(), headers={"Priority": "high"}), timeout=5)
    except Exception as e:
        log.warning("notification failed: %s", e)


def main():
    ap = argparse.ArgumentParser()
    ap.add_argument("--source", choices=["file", "brainflow"], default="file")
    ap.add_argument("--file", default="chbmit_chb01.zip"); ap.add_argument("--port", default="/dev/ttyUSB0")
    ap.add_argument("--model", default="model.json"); ap.add_argument("--topic", default=None)
    ap.add_argument("--speed", type=float, default=1.0, help="replay faster than real time (file source only)")
    args = ap.parse_args()
    logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")

    model = json.load(open(args.model))
    source = FileSource(args.file, model["fs"] * args.speed) if args.source == "file" else BrainFlowSource(args.port)
    det = Detector(model, model["fs"], model["threshold"], model["persistence"], model["refractory"])
    log.info("running; source=%s", args.source)
    fs = model["fs"]
    n_samples = 0                                                             # count data, not wall-clock time
    while True:
        chunk = source.read()
        for col in chunk.T:
            det.buffer.append(col)
            n_samples += 1
            if n_samples % int(fs) == 0:                                      # another second of data has arrived
                now = n_samples / fs
                s, fire = det.step(now)
                if fire:
                    log.warning("ALARM at %d s (score %.2f)", now, s)
                    if args.topic: notify(args.topic, f"Possible seizure at {time.strftime('%H:%M:%S')}")
        if args.source == "file" and source.pos >= source.eeg.shape[1]:
            log.info("end of recording"); break
        time.sleep(0.05)


if __name__ == "__main__":
    main()

Try it against the article's file, at fifty times real speed:

pip install numpy scipy
python monitor.py --source file --file chbmit_chb01.zip --speed 50
# 15:06:49 WARNING ALARM at 247 s (score 0.96)
# 15:06:51 WARNING ALARM at 644 s (score 0.97)
# 15:06:53 WARNING ALARM at 1032 s (score 0.77)
# ... one alarm per seizure within seconds of onset, three false alarms in the last quarter hour

The replay produces the same alarms as step 4, which is the test that the streaming code is equivalent to the offline analysis. That equivalence is worth checking every time you change either, because the two versions drift apart in practice: the offline version sees the whole recording and filters it in one pass; the online one sees two seconds and must get the same answer from them.

Packaging it

On the Pi, the service needs to start when the power comes on, restart if it crashes, and leave a log. That is what systemd is for, and the whole configuration is this file, installed with sudo cp seizure-monitor.service /etc/systemd/system/ && sudo systemctl enable --now seizure-monitor. The log is journalctl -u seizure-monitor -f.

[Unit]
Description=Seizure monitor (research build, not a medical device)
After=network-online.target

[Service]
User=pi
WorkingDirectory=/home/pi/seizure-monitor
ExecStart=/home/pi/seizure-monitor/venv/bin/python monitor.py --source brainflow --port /dev/ttyUSB0 --topic my-secret-topic-name
Restart=always
RestartSec=5

[Install]
WantedBy=multi-user.target

The model file it loads is the one step 5 wrote, so retraining on new seizures is a matter of replacing one small file:

{
 "fs": 128.0,
 "weights": [
  1.2117927709917629,
  1.3755688993410002,
  0.4899623463951435,
  0.40485118358151967,
  0.42444341307141376
 ],
 "bias": -4.152963406295196,
 "mean": [
  7.391718334533521,
  5.870881518587343,
  4.750163443873513,
  3.9429359195743587,
  2.5285384028505717
 ],
 "sd": [
  1.2656932215114118,
  0.9868093864383984,
  0.9293513440481552,
  1.1640161259724342,
  0.5585923924815547
 ],
 "threshold": 0.5,
 "persistence": 3,
 "refractory": 30
}

Three things the production version of this would add, in order of importance. An impedance check at startup, because an electrode that has come loose produces huge slow artefacts that look exactly like what the delta weight rewards; the ADS1299 can measure electrode impedance and both boards expose it. Artefact rejection before the classifier: the two line-length spikes after seizure 1 in the features figure are movements, and a blink detector from the ICA primer or a simple amplitude ceiling would catch most of them. Storage of every alarm's EEG, so that a human can later decide which alarms were real, which is how the threshold and the model get better over time, and how you would ever know the device's true false-alarm rate on its wearer.

What you built, and what it is worth

  • A seizure detector from scalp EEG: five features, a logistic regression, and two rules, that catches all seven of one patient's focal seizures within about five seconds at three false alarms an hour.
  • An honest evaluation: tested by leaving each seizure out, with the metrics clinicians use, and with the false alarms traced to the recording the model had not seen the background of.
  • A clear statement of scope: focal seizures with a scalp signature, yes; silent focal seizures and brief jerks, no; seizure type, never.
  • A streaming service that gives the same answers as the analysis, packaged to run unattended on a Raspberry Pi with an open-hardware amplifier.

The gap between this and a product is not the classifier; published clinical detectors use features much like these. It is the thousand hours of recordings across many patients needed to know the false-alarm rate, the artefact handling, the electrodes that stay on for a week, the battery, the failure modes when a wire breaks at 3 a.m., and the regulatory evidence that the whole thing does more good than harm. That is years of work by a team, and it is why those products cost what they do. What you have is the understanding of how they work, which is the part that cannot be bought.

Exercises

  1. Move the threshold from 0.5 to 0.3 and to 0.7 and redo step 4. Plot sensitivity and false alarms per hour against the threshold: this curve is how detectors are compared in papers.
  2. Download another CHB-MIT patient (chb03 and chb05 are good) and run steps 1 to 4 on them with the same code. Then test chb01's model on them without retraining. How well does one child's detector transfer to another?
  3. Add a sixth feature: the ratio of power on the right chain to the left chain. For this patient every seizure starts on the right. Does the detector get faster? What would happen on a patient whose seizures start on the left?
  4. Harder: the false alarms live in the seizure-free quarter hour. Download the full chb01_01.edf (42 MB) and chb01_02.edf from PhysioNet, add two hours of seizure-free background to the training set, and measure the false-alarm rate again on a third hour. This is what fixing a detector actually looks like.

Data: CHB-MIT Scalp EEG Database (Shoeb 2009; Goldberger et al. 2000) via PhysioNet, patient chb01, Open Data Commons Attribution licence. Line length as a seizure feature: Esteller et al. 2001. The analysis runs in about fifteen seconds; the monitor replay at 50x takes a minute and a half.

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.