What did the mouse see? Your first neural decoder

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

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

flowchart LR

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

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

C --> D[Train / test split]

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

E --> F[Cross-validate]

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

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

The data

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

Step 1: turn the recording into a table

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

import numpy as np
import matplotlib.pyplot as plt

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

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

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

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

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

Step 2: one neuron, one threshold

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

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

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

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

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

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

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

Step 3: never grade yourself on the questions you studied

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Step 5: cross-validation

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

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

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

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

Step 6: what would luck score?

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

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

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

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

Step 7: which areas know?

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

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

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

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

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

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

Step 8: when does the brain know?

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

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

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

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

What you just did

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

Every brain-computer interface, from the cursor-control systems in clinical trials to the speech decoders in the news, is this pipeline with more neurons, a fancier classifier, and a loop that feeds the guess back to the user. You have now built the part in the middle. To see what happens when this decoder grows a hidden layer, and when it cannot help, the neural network playground trains one live on these same two neurons.

Exercises

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

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

Does this neuron care about the stimulus?

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

flowchart LR

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

B --> C[Raster: every trial]

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

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

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

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

The data

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

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

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

Step 1: load and orient yourself

import numpy as np
import matplotlib.pyplot as plt

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

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

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

Step 2: find a neuron worth looking at

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

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

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

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

Step 3: every trial at once

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

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

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

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

Step 4: the PSTH

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

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

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

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

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

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

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

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

Step 6: is it real? A permutation test

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

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

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

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

Step 7: which parts of the brain are listening?

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

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

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

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

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

What you just did

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

Exercises

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

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

Your first neuron in Python

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

The data

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

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

Step 1: load it

import numpy as np
import matplotlib.pyplot as plt

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

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

Step 2: look at one neuron

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

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

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

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

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

Step 3: a raster plot

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

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

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

Step 4: firing rate over time

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

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

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

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

Step 5: the gaps between spikes

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

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

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

Step 6: the whole population

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

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

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

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

Step 7: build a neuron

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

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

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

B --> C[Leak back toward rest]

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

D -- no --> B

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

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

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

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

Feed it a constant current and see what it does.

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

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

Step 8: make it real

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

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

fig, axes = plt.subplots(2, 1, figsize=(10, 5.6), sharex=True)
for ax, v, s, title in [(axes[0], v_steady, s_steady, "constant input"),
                        (axes[1], v_noisy, s_noisy, "noisy input")]:
    ax.plot(t, v * 1000, lw=0.9)
    ax.vlines(s, -50, -20, lw=0.9)                # draw each spike as a tall line
    ax.set(ylabel="membrane potential (mV)", title=title)
axes[1].set_xlabel("time (s)")
plt.show()
Same neuron, two inputs. Top: constant current, perfectly regular spikes. Bottom: noisy current, and the spike timing looks like the raster in step 3.
Same neuron, two inputs. Top: constant current, perfectly regular spikes. Bottom: noisy current, and the spike timing looks like the raster in step 3.
Loading the interactive figure…
The same neuron, live. Drag the input current up and down, add noise, and watch the firing change; the two buttons jump to the settings of steps 7 and 8.

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

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

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

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

What you just did

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

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

Exercises

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

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

Why every neuroscientist should learn to code (and why every ML engineer should learn some neuroscience)

Here is a fact about your brain that most machine learning engineers have never heard, and most neuroscientists have never checked for themselves with code. Load a recording of 734 neurons from a mouse brain, count how often each one fires, and you find that the typical neuron produces about two spikes per second. A third of them fire less than once a second. The network that produces thought, movement and memory is, most of the time, nearly silent.

import numpy as np

data = np.load("steinmetz_session.npz", allow_pickle=True)
spike_times = data["spike_times"]          # 734 neurons, 45 minutes, one mouse

duration = max(st.max() for st in spike_times)
rates = np.array([len(st) / duration for st in spike_times])

print(f"median firing rate: {np.median(rates):.1f} spikes per second")
print(f"neurons below 1 Hz: {np.mean(rates < 1):.0%}")
# median firing rate: 1.9 spikes per second
# neurons below 1 Hz: 33%

Eight lines. Real data, freely available, from a paper published in Nature. Compare that with an artificial neural network, where every unit in every layer is computed on every forward pass, whether it has anything to say or not. The brain runs on roughly the power of a dim light bulb. A large language model needs a building full of hardware. The difference is not a detail. It is one of the central open problems in computing, and you can see the first clue to it in eight lines of Python.

That is what this site is about.

Two tribes that should be one

Modern AI began as an attempt to copy the brain. The perceptron, the neural network, the convolutional layer inspired by the visual cortex, reinforcement learning built on the dopamine literature: the lineage is direct. Then the two fields drifted apart. Machine learning became an engineering discipline organised around benchmarks and GPUs. Neuroscience became a data science organised around ever larger recordings, without always having the people to analyse them.

The result is two groups of very smart people who rarely read each other’s papers. Most ML engineers have never looked at a spike train. Many neuroscientists still hand their data to a collaborator, or to a graduate student who learned Python last month, because writing the analysis themselves feels like someone else’s job.

I have worked on both sides of this line. I currently work on precision medicine, brain-computer interfaces and semantic search, which means I spend my days translating between the two tribes. This site is the translation guide I wish had existed when I started.

This is not a new struggle. Every generation of scientists has been limited by the cognitive workload of its own tools. Johannes Kepler spent years of hand arithmetic wrestling the orbit of Mars into an ellipse, and later called it his war with Mars. For most of the last two centuries, the word computer meant a person: rooms of people, very often women, grinding through calculations for astronomers, engineers and, eventually, the first neuroscientists.

The clearest example in our own field is from 1952. Alan Hodgkin and Andrew Huxley had worked out the equations that describe how a nerve impulse travels along an axon, work that later won them a Nobel Prize. The Cambridge computer was out of service, so Huxley solved the equations by hand on a mechanical desk calculator. Computing a single propagated action potential took him about three weeks. On the laptop I am writing this on, the same calculation takes a few milliseconds.

The lesson is not that we are smarter than Huxley. It is that the bottleneck of science has always been computation, and the people who could do the computation decided what got discovered. Learning to program is how you stop being on the wrong side of that bottleneck.

Why every neuroscientist should learn to code

The data outgrew the spreadsheet a decade ago

A single Neuropixels probe records from hundreds of neurons at thirty thousand samples per second. A modern imaging session produces terabytes. An EEG study has dozens of channels, hundreds of trials, tens of participants. You cannot look at this data. You can only compute on it. The person who can write the computation controls what questions get asked.

Reproducibility is a property of code, not of intentions

A result that lives in a chain of clicks in a GUI cannot be repeated exactly, not even by you, six months later. A result that lives in a script can be re-run, reviewed, shared, and corrected. The reproducibility crisis in the life sciences has many causes, but one of the fixable ones is that analysis is still often done by hand.

The models are code

Every serious theory of how neurons compute is now expressed as a simulation. Hodgkin and Huxley wrote their model as differential equations in 1952 and solved them by hand-cranked calculator. Today the same model is thirty lines of Python. If you cannot read those lines, you cannot really engage with the theory. If you can write them, you can test it against your own data.

Why every ML engineer should learn some neuroscience

The brain is an existence proof

Every time someone claims that intelligence needs a trillion parameters, or a datacentre, or a training set of the entire internet, the brain quietly disagrees. It learns language from a few million words, not a few trillion. It learns to recognise an object from a handful of examples. It does this on twenty watts. We do not know how, and that is exactly the point: the gap between what brains do and what our models do is a map of the ideas we have not had yet.

Sparsity, timing and learning without backpropagation

Those nearly silent neurons at the top of this article are sparse coding in action. Real neurons also communicate with precisely timed events, not continuous activations, which is why neuromorphic chips exist. And no one has found backpropagation in the brain. Synapses change based on information that is local to them, in time and in space. Predictive coding, Hebbian learning, and dendritic computation are all attempts to explain how a network can learn without a global error signal. Any of them could be the next big idea in machine learning. Some of them already are.

Neuroscience is where the interesting problems are

Decoding intention from motor cortex to drive a prosthetic arm. Predicting a seizure minutes before it happens. Restoring speech from neural activity. Finding which patients will respond to which treatment from a brain scan. These are machine learning problems with unusual data, brutal constraints and enormous stakes. They are also where a working engineer can still make a first-author contribution, because the field needs builders.

What this site is

A learning path, not a link collection. Every article uses real neural data and real code, and each track builds on the one before it:

  1. Foundations. Python for people who have never programmed, taught entirely through neuroscience examples. Your first program plots a real spike train.
  2. Signals. What EEG, spikes and fMRI actually measure, and the signal processing that turns raw voltage into something meaningful.
  3. Data. The open datasets and file standards of the field, and how to load, clean and share neural data reproducibly.
  4. Models. From a single leaky neuron to Hodgkin-Huxley to small spiking networks, each one built from scratch in code.
  5. Decoding and brain-computer interfaces. Classifying intention from neural signals, ending in a minimal working BCI loop.
  6. Neuroscience meets AI. Where the two fields overlap today: deep networks as models of cortex, predictive coding, neuromorphic hardware, and what language models do and do not tell us about the brain.
  7. Precision medicine. Neural biomarkers, clinical machine learning, and the many ways a good model can fail in a hospital.

You do not need a neuroscience degree. You do not need to know how to program. You need Python installed, or a browser tab open to a free notebook service, and about an hour a week.

Start here

The first lesson takes you from a downloaded file to a working model of a neuron in about thirty lines of Python, using the same recording as the eight lines above. It is the most important article on this site, because after it you will know whether this path is for you.

Lesson 1: Your first neuron in Python.

Santiago Ramón y Cajal, whose drawings of neurons gave neuroscience its founding image, spent his mornings at the microscope and his afternoons at the drawing board, because the only way to truly see the structure was to render it himself. He told young scientists that anyone can become the sculptor of their own brain. That is the spirit here. The drawing board is now a text editor, and rendering the brain yourself means writing the code.

If something on this site helps you, or if you find a mistake, I would like to hear about it. You can reach me on Twitter at @thinquanaut.

Deep Blue never learned anything: the real difference between AI and machine learning

In May 1997 a computer beat the world chess champion. In March 2016 a computer beat one of the world’s best Go players. Both made headlines as triumphs of artificial intelligence, and both were. But the two machines had almost nothing in common. Deep Blue did not learn to play chess. AlphaGo did almost nothing but learn. The difference between those two machines is the difference between artificial intelligence and machine learning, and once you see it you will notice it everywhere.

Two words with two histories

Artificial intelligence was named in 1955, in a proposal for a summer workshop at Dartmouth College written by John McCarthy and three colleagues. Their ambition was the whole thing: machines that use language, form concepts, solve problems reserved for humans, and improve themselves. AI is the goal. It says nothing about the method.

Machine learning was named four years later by Arthur Samuel, an IBM engineer who had written a checkers program that got better by playing against itself. He defined it as giving computers the ability to learn without being explicitly programmed. Machine learning is one method. It is a way of getting to AI, or to a lot of things that nobody would call AI, by letting a program adjust itself from examples instead of following rules written by a person.

So the relationship is simple. Machine learning is a subset of artificial intelligence. Everything that learns from data to do something intelligent-looking is AI. But a great deal of AI, historically most of it, never learned anything.

flowchart TB

AI[Artificial intelligence: the goal] --> R[Rule-based systems]

AI --> ML[Machine learning: learn from examples]

R --> R1[Deep Blue, 1997]

R --> R2[Expert systems, 1970s to 80s]

R --> R3[Route planning, chess openings, tax software]

ML --> S[Supervised: labelled examples]

ML --> U[Unsupervised: find structure]

ML --> RL[Reinforcement: reward and punishment]

S --> DL[Deep learning: learned features]

DL --> D1[AlphaGo, 2016]

DL --> D2[Speech, vision, language models]
AI is the ambition. Machine learning is one family of methods for reaching it, and deep learning is one branch of that family.

The rules era

For its first thirty years, AI mostly meant writing rules. The Logic Theorist of 1956 proved theorems by searching through logical steps. Chess programs searched millions of positions per second and scored them with evaluation functions that grandmasters helped design. Expert systems of the 1970s and 80s, such as MYCIN for diagnosing blood infections and XCON for configuring computer orders at Digital Equipment Corporation, encoded the judgement of human specialists as thousands of if-then rules. XCON reportedly saved the company tens of millions of dollars a year.

This was genuinely intelligent behaviour by any reasonable standard of the time, and none of it was learned. Deep Blue is the peak of this era. It won because it could search two hundred million positions a second and because its evaluation function had been tuned by hand over years. Show it a new game and it would have been helpless.

The rules era ran into a wall that its own practitioners named: the knowledge acquisition bottleneck. Every rule had to be extracted from a human expert and written down. Experts disagreed, could not articulate what they knew, and the rules interacted in ways nobody could predict. The systems were brittle, expensive to build, and impossible to maintain. By the late 1980s funding collapsed in what is now called the second AI winter.

The learning era

The alternative had been there since the beginning. Frank Rosenblatt’s perceptron of 1958 learned to classify simple images by adjusting weights from examples, and Samuel’s checkers program learned from self-play. But learning needed two things the rules approach did not: large amounts of data and large amounts of computation. Neither was available.

Three things changed. The backpropagation algorithm, popularised in 1986, made it practical to train networks with many layers. The internet produced data at a scale nobody had imagined. And graphics cards built for video games turned out to be ideal for the arithmetic that neural networks need. The moment usually cited as the turning point is 2012, when a deep network called AlexNet cut the error rate on the ImageNet image recognition benchmark by a margin that made the entire field switch approaches within about two years.

AlphaGo is the peak of this era so far. Go has too many positions to search the way Deep Blue searched chess, and no human could write an evaluation function for it. So AlphaGo learned one, first from thirty million positions from expert games, then from millions of games against itself. Its successor, AlphaZero, dropped the human games entirely and learned chess, Go and shogi from nothing but the rules, and beat the best rule-based chess engine in the world in a matter of hours.

Spot the difference in daily life

The distinction is not academic. It tells you how a system will fail, what it needs to work, and who is responsible when it is wrong.

TaskRule-based approachLearned approach
Filtering spamBlock messages containing certain words. Beaten within weeks by misspellings.Since about 2002, classifiers trained on millions of labelled messages. Adapts as spam changes.
NavigationShortest-path search, as in every GPS. No learning needed; the road graph is known.Arrival-time prediction, which is learned from traffic history.
Filing taxesThe tax code is rules. Tax software encodes them exactly. A learned system here would be a liability.Fraud detection on the returns, learned from past cases.
Reading handwritingAttempts to describe letter shapes as rules failed for decades.Networks trained on handwritten digits have read postal codes and bank cheques since the early 1990s.
Detecting spikes in a neural recordingA voltage threshold, chosen by the experimenter. Still the first step in most pipelines.Sorting the detected spikes into neurons, which is clustering, a form of unsupervised learning.

Notice that the rule-based column is not the losing side. Your GPS, your tax software and your compiler are all rules, and they should be. The learned column wins only where the rules cannot be written down, which is the subject of the next article.

Where neuroscience sits in this

The brain was the inspiration for the learning side from the start. Rosenblatt was a psychologist, and the perceptron was explicitly a model of how neurons might learn. The modern neural network keeps the name and the rough idea, a network of simple units whose connections strengthen with experience, and discards nearly everything else about biology. That gap is the subject of the Neuroscience meets AI track on this site: what the brain does that our learners do not, and which of those differences matter.

It also cuts the other way. Neuroscience today uses both columns of the table above. A threshold detector is a rule. A spike sorter is a learner. A decoder that turns motor cortex activity into cursor movement is a learner, but the safety limits around it are rules. Knowing which is which is part of being able to build and trust these systems.

Further reading: Nils Nilsson, The Quest for Artificial Intelligence, is the standard history and is free online. Arthur Samuel’s 1959 paper on checkers is short and still readable.

Could you write the rules down? A field guide to when a problem needs machine learning

In 1954 a team from IBM and Georgetown University demonstrated a computer translating Russian sentences into English. It handled about sixty sentences using six grammar rules and a vocabulary of 250 words. The press reported that machine translation would be solved within five years. Twelve years later a government committee, the ALPAC report of 1966, concluded that after all that funding, machine translation was slower, worse and more expensive than a human, and recommended the money stop. It did, for two decades.

The problem was not effort or talent. It was that the researchers had picked the wrong kind of solution for the problem. Translation looked like a rules problem. It is not one. Knowing the difference is one of the most useful judgements a programmer can make, and it is the subject of this article.

The one question

Before anything else, ask: could a competent person write down the rules? Not in principle, in practice, on paper, completely enough that a program following them would be correct.

If yes, write the program. Sorting a list, computing payroll, validating a form, routing a packet, rendering a web page: these are rules problems, and every one of them has a correct, testable, explainable solution that a person wrote. Using machine learning here is not just unnecessary. It replaces a correct program with an approximate one, and hides the logic where nobody can inspect it.

If no, ask why not. There are three common reasons, and only some of them point to machine learning.

flowchart TD

Q1{Can a person write the rules down completely?} -- yes --> P[Write a regular program]

Q1 -- no --> Q2{Why not?}

Q2 -- The answer is a search or optimisation --> S[Use an algorithm: search, dynamic programming, solvers]

Q2 -- Humans do it but cannot say how --> Q3{Do you have many examples with the right answers?}

Q2 -- The pattern keeps changing --> Q3

Q3 -- no --> G[Get data first, or use rules as a stopgap]

Q3 -- yes --> Q4{Is an occasional wrong answer acceptable?}

Q4 -- no --> H[Rules or human review around any model]

Q4 -- yes --> ML[Machine learning is the right tool]
The decision in one diagram. Most problems exit at the first or second box.

Reason 1: the rules exist, but the answer is a search

Finding the fastest route across a city, scheduling flights, packing a truck, playing chess. Nobody can write down the answer, but the rules of the problem are perfectly clear. These are algorithm problems, and computer science has spent seventy years on them: shortest-path search, dynamic programming, constraint solvers, linear programming. They give exact or provably near-optimal answers, and they do not need training data. Reaching for machine learning here is a common and expensive mistake.

Reason 2: humans do it easily but cannot say how

Recognising a face. Understanding a spoken sentence. Telling a cat from a dog. Reading a handwritten postal code. Deciding whether an email is spam. You do these effortlessly, and if asked to write down the rules you would fail, as decades of researchers did before you. This is the home territory of machine learning: the knowledge exists, in human judgement, and can be captured as examples even though it cannot be captured as rules.

Translation is here. The rules approach failed for forty years. In the early 1990s an IBM group tried something that sounded like giving up: instead of grammar, feed the computer millions of sentence pairs from the Canadian parliament, which publishes everything in English and French, and let it learn which words and phrases tend to correspond. It worked better than the rule systems almost immediately. Frederick Jelinek, who led IBM’s speech group, is remembered for the remark that every time he fired a linguist, the recogniser improved. Modern translation is the descendant of that decision.

Reason 3: the rules keep changing

Spam filters are the clean example. In the late 1990s, filters were lists of forbidden words. Spammers responded with misspellings and images within weeks, and every update to the rules was answered by an update to the spam. In 2002 Paul Graham published a short essay proposing a filter that learned from each user’s own mail, and within a few years every major email provider used a learned filter. The point was not that the learned filter was cleverer. It was that it could be retrained overnight, while the rule-writers could not keep up. Fraud detection, recommendation, and ad targeting are the same shape: the target moves, so the program has to move with it.

Two more questions before you commit

Do you have the examples?

Machine learning is a way of turning examples into a program. No examples, no program. The examples must be numerous, must include the correct answer, and must look like the cases the system will meet in use. A model trained on hospital scans from one machine can fail on scans from another. A model trained on last year’s customers can fail on this year’s. If you cannot get the data, you are not ready, and a rule-based stopgap that collects data while it runs is often the right first version.

Can you afford to be wrong sometimes?

A learned system is approximate by construction. It will be wrong on some fraction of inputs, and it will not tell you which. Where a wrong answer costs a little and can be corrected, as in a recommendation or a spam folder, that is fine. Where a wrong answer is expensive or irreversible, the model needs rules around it: hard limits, human review, or a fallback. A brain-computer interface that moves a wheelchair is a learned decoder wrapped in rule-based safety limits, and it should be.

Real problems, sorted

ProblemVerdictWhy
Calculate a mortgage paymentProgramThe formula is the rule.
Find the fastest driving routeAlgorithmRules are known; the answer is a search.
Predict how long the route will take at 5 pmMachine learningDepends on patterns in traffic history nobody can write down.
Detect that a credit card is being used fraudulentlyMachine learning, with rulesThe pattern changes constantly; a few hard rules catch the obvious cases.
Read the amount on a handwritten chequeMachine learningHumans can do it and cannot explain how. Solved by neural networks in the 1990s.
Compile a programProgramEvery rule is specified. Exactness is the whole point.
Decide which neuron produced each spike in a recordingMachine learning (clustering)No rules exist; the spike shapes have to be grouped from the data.
Flag a seizure in an EEG recordingMachine learning, with rulesThe signature is learnable; the alert logic around it should be explicit.
Schedule operating rooms in a hospitalAlgorithmA constraint problem with clear rules, not a pattern to learn.

A test you can run on your own problem

Try to write the program. Spend an hour, or a day, writing the rules as if you had to. One of three things happens:

  1. You finish, and you have a program. It is probably better than any model would have been.
  2. You realise the rules are clear but the answer is a search or an optimisation. Look for the algorithm before you look for the model.
  3. You keep adding exceptions, the exceptions need exceptions, and you can feel the rules turning to mush. That feeling is the signal. If you have examples, and being wrong sometimes is acceptable, this is a machine learning problem.

The researchers of 1954 never ran this test, because it was not yet possible to fail it honestly. You can. It takes an afternoon and it can save a year.

Further reading: the ALPAC report of 1966 is short and surprisingly readable. Paul Graham’s essay A Plan for Spam (2002) is the best single account of why learning beat rules for a moving target.

Machine learning inside the microscope: Nikon’s NIS.ai and the open tools behind it

This started life in 2020 as a one-line note pointing at a page on Nikon’s website. The page is still worth pointing at, because it is a clear example of something that has quietly happened across microscopy: the machine learning is now inside the microscope software, sold as a set of modules, and a great many neuroscience images are passing through convolutional networks before anyone looks at them. This is a short guide to what those modules do, what they are under the hood, which free tools do the same job, and the one thing you must check before trusting any of them. Updated October 2026.

What Nikon ships

NIS.ai is a family of add-ons for NIS-Elements, the acquisition and analysis software that drives Nikon microscopes. Each module is a neural network that takes an image in and gives an image, or a set of labelled regions, back. Some come pre-trained; the more interesting ones learn from your own data. Nikon’s page is the authority on current pricing and bundling, which I will not repeat here because it changes; at the time of writing Denoise.ai ships with the AR package and the rest are licensed separately, and all of them want a GPU.

NIS.ai moduleWhat it doesTrained onThe open-source equivalent
Denoise.aiRemoves shot noise from confocal imagesPre-trained by NikonNoise2Void, CARE
Clarify.aiRemoves out-of-focus blur from widefield fluorescencePre-trained by NikonDeconvolution; CARE
Enhance.aiRestores detail in under-exposed imagesYour own paired imagesCARE
Convert.aiPredicts one channel from another, e.g. a nuclear stain from brightfieldYour own paired imagesIn-silico labelling; CARE’s image-to-image mode
Segment.aiFinds structures that thresholding cannot, from hand-traced examplesYour own traced imagesCellpose, StarDist, ilastik

Two of these are the kind of thing you would expect a camera company to ship. Denoising and deblurring have a long history in microscopy, deconvolution in particular, and a network trained on a lot of Nikon images is a sensible modern replacement. The other three are more surprising, and more important.

What they are, underneath

Every NIS.ai module is a descendant of a handful of academic papers from 2015 to 2021, all of them open, most of them with code. Knowing the lineage tells you what the tool can and cannot do.

  • U-Net (Ronneberger, Fischer and Brox, 2015). A convolutional network shaped like a U: it shrinks the image to understand it, then grows it back to the original size to label every pixel. Written for biomedical segmentation, and still the backbone of nearly every image-to-image tool below, including, almost certainly, Nikon’s.
  • CARE, content-aware image restoration (Weigert and colleagues, 2018). Train a U-Net on pairs of bad and good images of the same sample (low light and high light, low resolution and high resolution) and it learns to turn bad into good. This is Enhance.ai’s recipe, and the CSBDeep package is the open version.
  • Noise2Void (Krull, Buchholz and Jug, 2019). Denoising without clean examples: the network learns to predict each pixel from its neighbours, which it can only do for the signal and not for the noise. Pre-trained denoisers like Denoise.ai are a cousin of this idea.
  • In-silico labelling (Christiansen and colleagues, 2018; Ounkomol and colleagues, 2018). Predict a fluorescent stain from a transmitted-light image, because the structure the stain reveals is faintly present in the unstained picture. This is Convert.ai, and it is the module most likely to make a biologist gasp and a statistician wince, for the same reason.
  • Cellpose (Stringer, Wang, Michaelos and Pachitariu, 2021) and StarDist. General-purpose cell and nucleus segmentation that works out of the box on most images and can be fine-tuned on yours in a GUI. Segment.ai does the same job inside NIS-Elements. Cellpose comes from the Janelia group whose data appears in the data page, and it is the tool I would reach for first.

The thing to check

An image-to-image network produces a plausible image. That is its job, and it is also the problem. A restored or converted image can contain structure that was never in the sample: a denoiser that has seen a lot of mitochondria will draw mitochondria into noise, and a channel-prediction network will confidently paint a nucleus where the training set says one usually is. Nothing in the output flags this. The people who wrote CARE say so plainly in the paper, and the rule they give is the right one:

  • Keep the raw images, always, and do your quantification on them or on something you can trace back to them.
  • Validate on data where you know the answer: hold out real pairs, as in lesson 3, and look at the worst cases, not the average.
  • Treat restored images as a way to see, and segmentation masks as a hypothesis to check, not as measurements.
  • When the method goes in a paper, say which network, which version, and what it was trained on. A reviewer cannot evaluate “AI denoising”.

If you want to try this without a Nikon licence

Everything in the table has a free equivalent that runs on a laptop, or in a browser, and that you can read the source of.

  • ZeroCostDL4Mic (von Chamier and colleagues, 2021): Google Colab notebooks that train and run CARE, Noise2Void, StarDist, Cellpose, U-Net and more on your own images, with no installation and a free GPU. The best first step.
  • Cellpose: pip install cellpose, open the GUI, drop in an image. Fine-tune on a few of your own corrected masks when the default model is not good enough.
  • napari: the Python image viewer that most of these tools plug into, and the place to look at your results slice by slice.
  • BioImage Model Zoo: pre-trained models for microscopy, in a format that runs in Fiji, ilastik, napari and DeepImageJ.
  • For calcium imaging specifically, Suite2p and CaImAn do registration, cell detection and spike inference end to end, and are what the labs producing the datasets on this site actually use.

The vendor tools earn their price by being there at the moment of acquisition, with support, inside the software the microscope already runs. The open tools earn their place by being inspectable, citable and free, which is what a result needs to be when it leaves the lab. Most imaging groups I know end up using both, and the ones who understand the lineage above use both well.

What is a brain-computer interface?

A brain-computer interface, or BCI, is a system that reads activity from the brain and turns it into an action in the outside world without going through muscles. Move a cursor by imagining your hand moving. Spell words by attending to letters on a screen. Steer a wheelchair. Control a robotic arm. The idea is simple to state and has taken half a century of neuroscience, signal processing and machine learning to make real.

This article is the map. It explains what a BCI is made of, how brain activity is measured, what signals are actually decoded, what the technology can and cannot do today, and how to start building one yourself. It is also the entry point to the Decoding and BCI track on this site, where each of these pieces gets its own hands-on lesson.

The loop

Every BCI, from a research prototype to an implanted clinical device, is the same closed loop with four stages:

  1. Measure. Record a signal that reflects brain activity: electrical potentials from the scalp, the cortical surface, or inside the tissue.
  2. Extract. Clean the recording and compute features that carry information: the power in a frequency band, the timing of a voltage deflection, the firing rate of a neuron.
  3. Decode. Map those features onto an intention. This is a machine learning problem: a classifier that says left or right, or a regression that outputs a cursor velocity.
  4. Act and feed back. Carry out the intention and show the user the result. The user sees the cursor move and adjusts. The brain learns the interface at the same time as the interface learns the brain.
flowchart LR

U((The brain)) --> M[1. Measure]

M --> X[2. Extract features]

X --> D[3. Decode intention]

D --> A[4. Act]

A -- feedback --> U
The closed loop. Every brain-computer interface is this diagram, whatever the hardware.

That last point is the thing people miss. A BCI is not a mind-reading device bolted onto a passive brain. It is a two-way learning system, and much of the practical progress in the field has come from making the feedback loop faster and the user’s job easier.

How the brain is measured

Recording methods trade off three things against each other: how much detail you get, how much of the brain you can see, and how invasive the method is. Nobody has all three.

MethodWhat it isCoverageDetailInvasiveness
EEGElectrodes on the scalpWhole headCoarse: millimetres of skull and skin blur the signalNone. Consumer headsets exist
ECoGElectrode grid on the cortical surfaceOne regionGood: fast, local signalsSurgery, usually during epilepsy monitoring
Intracortical arraysNeedle electrodes inside the cortexA few square millimetresBest: individual neuronsSurgery, tissue response over time
fNIRS and fMRIBlood oxygenation, via light or magnetismWhole brain (fMRI)Slow: seconds, not millisecondsNone, but fMRI needs a scanner
MEGMagnetic fields from neural currentsWhole headBetter than EEG, still indirectNone, but needs a shielded room

Most of the BCI research you can do at home uses EEG, because the hardware costs a few hundred dollars and the datasets are public. Most of the headline clinical results use intracortical arrays, because when you can hear individual neurons you can decode far more. The middle of the table is where a lot of current commercial effort sits.

What signals are actually decoded

The raw recording is voltage over time. The decodable information lives in a handful of patterns within it.

Rhythms

EEG has recognisable oscillations, named by frequency band. The bands are conventions, not sharp boundaries, but the usual definitions are:

  • Delta, 0.5 to 4 Hz. Dominant in deep sleep.
  • Theta, 4 to 8 Hz. Drowsiness, memory tasks, and in animals the hippocampal rhythm that organises spatial memory.
  • Alpha, 8 to 13 Hz. Relaxed wakefulness; strongest over visual cortex with the eyes closed.
  • Beta, 13 to 30 Hz. Active thinking and, importantly for BCI, motor control.
  • Gamma, above 30 Hz. Local cortical processing; hard to see from the scalp, clear in ECoG.

The classic motor-imagery BCI works on one fact about these rhythms: when you move a hand, or imagine moving it, the alpha and beta power over the opposite motor cortex drops. Detect which side dropped, and you know which hand the user imagined. Two classes, one feature, and a working interface.

Evoked responses

Show someone a sequence of flashes and their EEG contains a reliable positive bump about 300 milliseconds after the one they were waiting for. That is the P300, and it powers the oldest spelling interfaces: flash rows and columns of a letter grid, find the row and column that produced a P300, and you have the letter. A related trick, the steady-state visual evoked potential, uses targets flickering at different frequencies; whichever frequency shows up in the EEG is the one the user is looking at.

Spikes and local field potentials

Inside the cortex, the signal is the firing of individual neurons, exactly the spike trains you work with in the first Foundations lesson. Neurons in motor cortex fire in proportion to the direction and speed of an intended movement. Record enough of them, fit a model from firing rates to velocity, and you can drive a cursor or a robotic arm. This is how the best-known clinical demonstrations work.

Decoding is machine learning

Once features are extracted, the decoder is a standard supervised learning problem with unusual data. Linear discriminant analysis and support vector machines still do most of the work in EEG, because the data are small and noisy and simple models generalise better. Kalman filters have been the workhorse for cursor control from spikes for two decades, because they naturally smooth the noisy estimate over time. Deep networks have arrived for the hardest problems, such as decoding attempted speech from cortical recordings, where recent studies have produced text at close to conversational rates for people who had lost the ability to speak.

What makes BCI decoding hard is not the algorithm. It is that the signal changes: electrodes shift, the brain adapts, attention wanders, and a decoder trained this morning is worse by the afternoon. Handling that drift is one of the open engineering problems in the field.

What works today

  • Cochlear implants are the most successful neural interface ever built, in use by hundreds of thousands of people. They run in the other direction, computer to brain, but they prove the basic engineering.
  • Deep brain stimulation for Parkinson’s disease and other conditions is routine clinical practice, and newer devices adjust stimulation based on the recorded signal, which makes them closed-loop BCIs.
  • Intracortical cursor and typing control has allowed people with paralysis to communicate and use computers in research settings for years, and several companies are now running early human trials of implanted devices intended for everyday use.
  • Speech decoding from cortical recordings moved from proof of concept to conversational speed in the last few years.
  • Consumer EEG headsets can reliably detect a few coarse states such as eyes closed, drowsiness or attention to a flickering target. They cannot read thoughts, and the ones that claim to are selling the band names above, not neuroscience.

Why this matters for the rest of this site

A brain-computer interface is where every track on this site converges. You need to understand what the recording is measuring, which is the Signals track. You need to handle real data in standard formats, which is the Data track. You need a model of what neurons are doing, which is the Models track. And you need to build a decoder that works on a live stream and keeps working, which is the Decoding and BCI track itself. If you follow the path from the beginning, a working motor-imagery BCI on public EEG data is the destination.

How to start

You do not need hardware. Everything below is free.

  • Data. The PhysioNet EEG Motor Movement/Imagery dataset has over a hundred subjects doing exactly the left-versus-right task described above. The BNCI Horizon 2020 collection has dozens more BCI datasets in one place.
  • Tools. MNE-Python for loading, filtering and visualising EEG and MEG. scikit-learn for the decoder. MOABB for benchmarking BCI algorithms across datasets without rewriting the loading code each time.
  • Hardware, later. When you want to close the loop on your own head, open-source boards such as OpenBCI cost a few hundred dollars and stream directly into Python.
  • Reading. Wolpaw and Wolpaw’s Brain-Computer Interfaces: Principles and Practice is the standard reference and covers everything above in depth.

The author declares no conflict of interest.

Base64 to image: what it is, how to do it, and when you should

In 2018 I spent some time working out how to move medical images, TIFF files among them, between services and into web pages without a separate request for every picture. Base64 turned up everywhere: in data URIs, in JSON payloads, in the FHIR healthcare standard. This is the article I wish I had read first: what Base64 actually is, a worked example small enough to check by hand, working code in Python, Java and JavaScript, and an honest account of when inlining an image as text is a good idea and when it is not.

What Base64 is

Computers store everything as bytes, but a surprising number of the channels we push data through were designed for text: email, HTML, JSON, URLs, XML, configuration files. Hand them raw image bytes and something along the way may treat a byte as a line break, a control character, or the end of the message. Base64 solves this by rewriting any sequence of bytes using only 64 safe characters: A to Z, a to z, 0 to 9, + and /, with = as padding at the end.

The mechanism is simple. Eight bits per byte, six bits per Base64 character, so every three bytes become four characters. Here is the classic example, the word Man:

Input bytesMan
As numbers7797110
As 8-bit binary010011010110000101101110
Regrouped into 6 bits010011   010110   000101   101110
As numbers 0 to 6319   22   5   46
Looked up in the alphabetT   W   F   u

Man becomes TWFu. When the input is not a multiple of three bytes, the last group is padded with zero bits and one or two = signs mark how much padding there was: Ma becomes TWE= and M becomes TQ==. The cost is that four characters carry three bytes of information, so Base64 output is always about a third larger than the input. A 1.9 KB image becomes 2.5 KB of text.

Two things Base64 is not. It is not compression: the output is bigger, never smaller. And it is not encryption, even though it looks like gibberish and often appears next to cryptographic material. Anyone can turn a Base64 string back into the original bytes with one function call, so it protects nothing. It is a transport format, and that is all.

Image to text and back, in Python

The Python standard library has done this since the beginning. Open the file in binary mode, encode, and turn the resulting bytes into a string.

import base64

with open("brain.png", "rb") as f:          # "rb": read the raw bytes, not text
    raw = f.read()

text = base64.b64encode(raw).decode("ascii")   # bytes in, bytes out; .decode turns them into a str
print(len(raw), "bytes of PNG ->", len(text), "characters of Base64")
print(text[:60] + "...")
# 1904 bytes of PNG -> 2540 characters of Base64
# iVBORw0KGgoAAAANSUhEUgAAACAAAAAgCAMAAABEpIrGAAADAFBMVEU7PT4g...

Every PNG file starts with the same eight bytes, so every Base64-encoded PNG starts with iVBORw0KGgo. Once you have seen that prefix a few times you will recognise an inlined PNG anywhere. JPEGs start with /9j/.

To put the image in a web page, prefix the text with a small header that says what it is. This is a data URI, and browsers accept it anywhere they accept a normal image address.

data_uri = "data:image/png;base64," + text
html = f'<img src="{data_uri}" alt="a brain">'
print(html[:80] + "...")
# <img src="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAACAAAAAgCAMAAABEpIrGAAAD...
The site icon, delivered as Base64 text inside this article
This image is not a file on the server. It is 2,540 characters of Base64 text sitting in the HTML of this article, and your browser decoded it.

Going the other way is one call to b64decode. The decoded bytes are a PNG file in memory; BytesIO lets Pillow open them as if they were on disk, and from there they are a NumPy array, which is where every image analysis on this site begins.

import io
import numpy as np
from PIL import Image

raw_again = base64.b64decode(text)
print("round trip identical:", raw_again == raw)

img = Image.open(io.BytesIO(raw_again))        # BytesIO makes bytes in memory look like a file
pixels = np.array(img.convert("RGB"))          # a NumPy array: rows x columns x (red, green, blue)
print(img.format, img.size, pixels.shape, pixels.dtype)
# round trip identical: True
# PNG (32, 32) (32, 32, 3) uint8

The same thing in Java and JavaScript

The original version of this article used the Apache Commons codec and got the last line wrong: Base64.encodeBase64(bytes).toString() returns the memory address of a byte array, not the encoded text, which is an easy mistake to make and a hard one to notice until something downstream fails to decode. Since Java 8 the standard library has a correct and simpler answer.

import java.nio.file.Files;
import java.nio.file.Paths;
import java.util.Base64;

// Java 8 or later: no third-party library needed
byte[] raw = Files.readAllBytes(Paths.get("brain.png"));
String text = Base64.getEncoder().encodeToString(raw);
byte[] rawAgain = Base64.getDecoder().decode(text);

In the browser the job is usually the reverse: a user picks a file and you want to show it, or send it to an API, without uploading it first. FileReader.readAsDataURL produces the complete data URI directly.

// In the browser: read a file the user picked, straight to a data URI
const file = document.querySelector("input[type=file]").files[0];
const reader = new FileReader();
reader.onload = () => { document.querySelector("img").src = reader.result; };
reader.readAsDataURL(file);   // result looks like "data:image/png;base64,iVBOR..."

When to inline an image, and when not to

The reason I went looking at this in the first place was web performance. Every image on a page used to cost a separate HTTP request, and in the HTTP/1.1 era, where a browser would open only a handful of connections to a server, a page with fifty small icons spent most of its load time waiting in line. Steve Souders built a career on documenting this, and the standard fixes were sprites (pack all the icons into one image and show a slice of it with CSS) and inlining (put the image in the page as a data URI, so it arrives with the HTML). Sprites work but are painful to maintain, and for large or unusual formats such as TIFF they do not work at all. Inlining looked like the way out.

It is, for some images. The honest trade-off in 2026:

  • Inline tiny images. Icons, logos, a one-pixel placeholder, anything under a couple of kilobytes. The 33 percent size penalty is a few hundred bytes, and saving a round trip is worth more than that.
  • Serve everything else as a file. A large inlined image bloats the HTML, cannot be cached separately from the page, cannot be lazy-loaded, and blocks the page from rendering until it has fully arrived. A file is fetched in parallel, cached once, and reused across pages.
  • HTTP/2 changed the maths. Requests are cheap now; many can share one connection. The case for sprites has mostly gone, and the case for inlining has shrunk to the very small.
  • APIs are the exception. When an image has to travel inside JSON or XML, Base64 is the only option, and the size penalty is the price of using a text format. This is why the FHIR standard for health data carries images and documents as Base64 in its Attachment and Binary resources, and why a great deal of medical imaging code is really Base64 plumbing.

Mistakes to watch for

  • Bytes versus strings. In Python 3, b64encode takes bytes and returns bytes. Forgetting the .decode() puts b'...' into your JSON. Forgetting to open the file with "rb" gives you a decoding error before you even start.
  • There is more than one alphabet. The URL-safe variant swaps + and / for - and _, because the standard characters have meanings inside a URL. Python has urlsafe_b64encode for it. Decoding one variant with the other fails.
  • Line breaks. Email-style encoders wrap the output every 76 characters. Most decoders tolerate the newlines; some do not. Strip them if in doubt.
  • Missing padding. Some systems drop the trailing = signs to save space. If a decoder complains about “incorrect padding”, append = until the length is a multiple of four.
  • Treating it as security. See above. If a system hides a password behind Base64, it is not hidden.

A note on image search

The title of the original article promised image search, and it is worth being clear about what Base64 contributes to that: nothing. The encoded text is a faithful copy of the bytes and carries no more meaning than they do. Searching for similar images means computing something from the pixels, a hash for exact duplicates or an embedding from a neural network for visual similarity, and comparing those. Base64 is just how the picture gets to the service that does the computing. That work, turning pixels into vectors you can compare, is a topic for the machine learning track of this site.

Featured

Algorithms: what they are, and why the choice can be a thousandfold

An algorithm is a procedure for solving a problem that is precise enough to follow without judgement: a recipe, a knitting pattern, long division. Everything a computer does is one, and so is a great deal of what a scientist does, whether or not it is written down. This site teaches programming through neuroscience, and almost every lesson quietly introduces an algorithm along the way. This page, rewritten in October 2026 from a short note posted in 2016, says what the word means, shows with real neural data why the choice of algorithm can be the difference between a second and an hour, and collects the algorithms the lessons use into one table.

What an algorithm is, and what it is not

Three properties make a procedure an algorithm. It is finite: it ends. It is definite: each step is unambiguous, so two people following it get the same result. And it is effective: each step is something that can actually be carried out. A recipe that says “season to taste” is not quite an algorithm; one that says “add 5 g of salt” is. The distinction matters in science for the reason given in the opening essay: an analysis that lives as an algorithm can be repeated, checked, and handed to someone else. One that lives as a sequence of clicks and judgement calls cannot.

An algorithm is not the same as a program. The program is one expression of the algorithm, in one language, with particular choices of data structure; the same algorithm can be written in Python, Java or on paper. And an algorithm is not the same as a model. Logistic regression is a model, a claim about how outputs depend on inputs; gradient descent is an algorithm for finding its weights, and there are others. Keeping the two apart is useful when something goes wrong, because the model can be right while the algorithm fails to find its best version, and the other way round.

Why it matters: the same answer, ten thousand times faster

Here is the question from lesson 1: how many times did a neuron fire in each second of a 45-minute recording? The data are the spike times of 734 neurons from a mouse, sorted in time. Two procedures give the same count. The first scans every spike and asks whether it falls in the window. The second uses the fact that the times are sorted: binary search finds where the window starts and ends by repeatedly halving the range, and the count is the difference between the two positions.

import numpy as np
import time

data = np.load("steinmetz_session.npz", allow_pickle=True)
spike_times = data["spike_times"]                      # 734 neurons, each a sorted array of spike times in seconds
duration = max(st.max() for st in spike_times)
print(len(spike_times), "neurons,", sum(len(st) for st in spike_times), "spikes in", round(duration / 60), "minutes")

def count_by_scanning(st, t1, t2):
    """Count spikes between t1 and t2 by looking at every spike."""
    n = 0
    for s in st:
        if t1 <= s < t2:
            n += 1
    return n

def count_by_searching(st, t1, t2):
    """Same answer, using the fact that the times are sorted: find both ends by binary search."""
    return np.searchsorted(st, t2) - np.searchsorted(st, t1)

neuron = spike_times[303]
print(count_by_scanning(neuron, 100, 101), count_by_searching(neuron, 100, 101), "spikes in the 101st second, both ways")
# 734 neurons, 7574404 spikes in 45 minutes
# 31 31 spikes in the 101st second, both ways
Binary search on a sorted spike train. To find where 0.30 s falls, look at the middle element, decide which half to keep, and repeat: fourteen comparisons for sixteen thousand spikes. np.searchsorted does this. Two searches give the two ends of the window, and their difference is the count.
Binary search on a sorted spike train. To find where 0.30 s falls, look at the middle element, decide which half to keep, and repeat: fourteen comparisons for sixteen thousand spikes. np.searchsorted does this. Two searches give the two ends of the window, and their difference is the count.

Both functions return 31 for the hundred-and-first second. Now do it for every second of the recording, 2,700 windows, and add a third method: np.histogram, which sorts the problem out in a single pass over the spikes and all the window edges at once.

windows = np.arange(0, duration - 1, 1.0)              # every second of the recording: 2,700 windows

t = time.perf_counter()
a = [count_by_scanning(neuron, w, w + 1) for w in windows]
scan = time.perf_counter() - t

t = time.perf_counter()
b = [count_by_searching(neuron, w, w + 1) for w in windows]
search = time.perf_counter() - t

t = time.perf_counter()
c, _ = np.histogram(neuron, bins=np.append(windows, duration))   # one call that does all the windows at once
hist = time.perf_counter() - t

print(f"scanning: {scan:6.2f} s   searching: {search:6.3f} s   histogram: {hist:6.4f} s   same answers: {a == b == list(c)}")
# scanning:   6.34 s   searching:  0.040 s   histogram: 0.0005 s   same answers: True
Time to count one neuron's spikes into windows, against the number of windows, on logarithmic axes. All three lines rise with the number of windows, but at different heights: scanning does 16,000 comparisons per window, binary search does 14, and the histogram does the whole job in one pass.
Time to count one neuron’s spikes into windows, against the number of windows, on logarithmic axes. All three lines rise with the number of windows, but at different heights: scanning does 16,000 comparisons per window, binary search does 14, and the histogram does the whole job in one pass.

Six seconds, forty milliseconds, half a millisecond. Same answers, checked. Scanning costs the number of spikes times the number of windows, about 44 million comparisons here. Binary search costs the logarithm of the number of spikes per window, about 14, so the whole job is forty thousand comparisons. The histogram does one pass over the spikes and the edges together, which is as good as it gets. For one neuron the difference is six seconds against nothing; for the 734 neurons in this file, the scanning version takes well over an hour, and that is the difference between an analysis you run and one you do not.

The word for this is complexity: how the cost of an algorithm grows with the size of its input, written with the “big O” notation you will see in any algorithms book. Scanning is O(spikes x windows); searching is O(windows x log spikes). Nobody needs the notation to see it in the figure, where the three lines are parallel on logarithmic axes but far apart. What the notation adds is the ability to predict, before writing anything, which approach will survive contact with a dataset ten times larger.

A famous one: the fast Fourier transform

Lesson 6 computed a spectrum two ways: a loop that matched the signal against a sine and cosine at every frequency, written so you could see what a Fourier transform does, and np.fft.rfft, which gives the same answer. Here is why everyone uses the second.

def dft_by_matching(x, fs):
    """Lesson 6's spectrum: match the signal against a sine and cosine at every frequency. N frequencies x N samples."""
    n = len(x)
    tt = np.arange(n) / fs
    freqs = np.fft.rfftfreq(n, 1 / fs)
    return np.array([np.hypot(np.sum(x * np.cos(2 * np.pi * f * tt)), np.sum(x * np.sin(2 * np.pi * f * tt))) for f in freqs])

rng = np.random.default_rng(0)
for n in [256, 1024, 4096, 16384]:
    x = rng.standard_normal(n)
    t = time.perf_counter(); slow = dft_by_matching(x, 160.0); t_slow = time.perf_counter() - t
    t = time.perf_counter(); fast = np.abs(np.fft.rfft(x)); t_fast = time.perf_counter() - t
    print(f"n = {n:5d}: matching {t_slow * 1000:8.1f} ms   FFT {t_fast * 1000:6.3f} ms   same answer: {np.allclose(slow, fast)}")
# n =   256: matching      2.4 ms   FFT  0.035 ms   same answer: True
# n =  1024: matching     15.5 ms   FFT  0.021 ms   same answer: True
# n =  4096: matching    158.0 ms   FFT  0.053 ms   same answer: True
# n = 16384: matching   2213.1 ms   FFT  0.334 ms   same answer: True
Time to compute the same spectrum by matching every frequency (red) and by the FFT (blue), against the length of the signal. The dotted lines are n squared and n log n. At 16,384 samples, about a minute and a half of EEG, the loop takes two seconds and the FFT a third of a millisecond.
Time to compute the same spectrum by matching every frequency (red) and by the FFT (blue), against the length of the signal. The dotted lines are n squared and n log n. At 16,384 samples, about a minute and a half of EEG, the loop takes two seconds and the FFT a third of a millisecond.

The loop is O(n²): n frequencies, each matched against n samples. The FFT is O(n log n), and the gap grows without limit: a factor of 70 at 256 samples, 6,600 at 16,384, and for a minute of 384-channel Neuropixels data, more than you would wait for. The algorithm was published by Cooley and Tukey in 1965 (and, it turned out, by Gauss in 1805), and it works by noticing that the transform of a signal can be assembled from the transforms of its even-numbered and odd-numbered samples, each of which can be assembled the same way, and so on down. That pattern, divide and conquer, is one of a handful of ideas behind most fast algorithms, and the FFT is the reason spectrograms, filters, correlations and much of signal processing are cheap enough to be routine.

Data structures are half of it

The first example turned on a fact about the data, not the code: the spike times were sorted. Binary search is useless on an unsorted list. The shape in which data are kept is called a data structure, and choosing it is usually the first algorithmic decision in an analysis. The lessons use two for spikes. Lesson 1 keeps a list of times per neuron, which is compact and exact: 7.6 million spikes in 60 MB. Lesson 2 keeps a dense array of counts, neurons by trials by time bins, which is 59 MB for a single session even though most entries are zero, and in exchange every question about a window is a slice, not a search. Neither is right in general. Sparse for storage and for anything that walks along time; dense for anything that compares across trials or neurons. Most of the speed of NumPy is the dense array, and most of the memory problems people hit with it come from using a dense array for sparse data.

The algorithms on this site

Each of the lessons is built around one, and most of them were written out in full rather than imported, so that nothing interesting is hidden. Here they are in one place, with the kind of algorithm each is and how its cost grows, because the kind tells you what to expect: a numerical integration gets slower with finer time steps, an iterative optimisation may stop at the wrong answer, a library eigendecomposition is not worth rewriting.

Where on this siteAlgorithmWhat kind of thing it isHow its cost grows
Lesson 1Leaky integrate-and-fire simulationNumerical integration: step time forward, update, repeatLinear in the number of time steps
Lesson 3Gradient descent for logistic regressionIterative optimisation: nudge the weights downhill until they stop movingSteps x trials x features
Lesson 4Eigendecomposition for PCALinear algebra: a library routine (LAPACK) that is decades old and faster than anything you would writeCubic in the number of features
Lesson 5Threshold detection; k-meansOne pass over the signal; then an iterative assign-and-move loop that is not guaranteed to find the best answerLinear; then clusters x points per iteration
Lesson 6Fast Fourier transformDivide and conquer: split the signal in two, transform each half, combine. The reason the spectrum is cheapn log n instead of n$^2$
ICA primerFastICAA fixed-point iteration: apply a rule until the answer stops changingIterations x components$^2$ x samples
Lesson 5, in passingKilosortTemplate matching with drift correction: the state of the art, and tens of thousands of linesHours per recording on a GPU

Three patterns cover most of the table. One pass over the data, which is the cheapest possible and the thing to aim for. Iterate until it stops changing, which is gradient descent, k-means and FastICA, and which needs a stopping rule and a check that it stopped somewhere sensible. And divide and conquer, which is the FFT and most sorting. When you meet a new method, asking which of these it is will tell you most of what you need to know about its behaviour.

Reading further

  • Algorithms by Robert Sedgewick and Kevin Wayne (4th edition): the clearest introduction, with complete implementations and a free companion site. The definition at the top of this page is theirs.
  • Introduction to Algorithms by Cormen, Leiserson, Rivest and Stein: the reference, heavier and more mathematical. Good for looking things up once you know what you are looking for.
  • Grokking Algorithms by Aditya Bhargava: illustrated and short, for a first pass with no computer-science background.
  • For the scientific side, Numerical Recipes by Press and colleagues, whatever edition you can find, explains the numerical algorithms (integration, FFT, optimisation) that the table above leans on, with the opinions left in.

And the data to practise on are on the open data sources page. Carl Sagan said that science is a way of thinking much more than it is a body of knowledge. Algorithms are that way of thinking made precise enough for a machine, and learning to see them is most of learning to program.

Data: Steinmetz et al., Nature 2019, CC-BY 4.0, via Neuromatch Academy (the lesson 1 file). Timings are from a laptop and will differ on yours; the ratios will not.