The shape of a population: your first PCA

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

flowchart LR

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

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

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

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

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

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

Step 1: two neurons, one picture

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

import numpy as np
import matplotlib.pyplot as plt

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

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

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

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

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

Step 2: write PCA

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

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

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

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

Step 3: all 66 neurons, and a trap

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

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

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

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

Step 4: standardise, then ask how many dimensions

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

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

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

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

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

Step 5: every trial as a point

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

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

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

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

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

Step 6: every moment as a point

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

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

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

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

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

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

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

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

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

What you just did

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

Exercises

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

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

What did the mouse see? Your first neural decoder

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

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

flowchart LR

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

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

C --> D[Train / test split]

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

E --> F[Cross-validate]

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

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

The data

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

Step 1: turn the recording into a table

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

import numpy as np
import matplotlib.pyplot as plt

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

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

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

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

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

Step 2: one neuron, one threshold

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

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

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

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

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

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

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

Step 3: never grade yourself on the questions you studied

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Step 5: cross-validation

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

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

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

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

Step 6: what would luck score?

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

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

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

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

Step 7: which areas know?

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

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

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

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

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

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

Step 8: when does the brain know?

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

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

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

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

What you just did

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

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

Exercises

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

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

Does this neuron care about the stimulus?

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

flowchart LR

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

B --> C[Raster: every trial]

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

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

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

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

The data

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

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

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

Step 1: load and orient yourself

import numpy as np
import matplotlib.pyplot as plt

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

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

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

Step 2: find a neuron worth looking at

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

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

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

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

Step 3: every trial at once

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

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

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

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

Step 4: the PSTH

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

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

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

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

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

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

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

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

Step 6: is it real? A permutation test

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

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

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

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

Step 7: which parts of the brain are listening?

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

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

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

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

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

What you just did

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

Exercises

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

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

Your first neuron in Python

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

The data

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

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

Step 1: load it

import numpy as np
import matplotlib.pyplot as plt

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

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

Step 2: look at one neuron

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

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

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

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

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

Step 3: a raster plot

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

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

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

Step 4: firing rate over time

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

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

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

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

Step 5: the gaps between spikes

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

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

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

Step 6: the whole population

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

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

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

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

Step 7: build a neuron

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

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

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

B --> C[Leak back toward rest]

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

D -- no --> B

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

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

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

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

Feed it a constant current and see what it does.

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

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

Step 8: make it real

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

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

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

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

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

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

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

What you just did

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

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

Exercises

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

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