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]
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()

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()

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

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()

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
- 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?
- 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?
- 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?
- 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.














