Draft

Finding features in a tiny network: brightness, contrast, and three neurons

In the previous article on what a weight matrix actually does, we followed an input through a weight matrix and watched it become a point in hidden space. Now we’ll work backward: given that hidden space, can we find directions that represent recognizable properties of the input?

For this experiment, we’ll move from XOR classification to a linear autoencoder: an image with two pixels, a hidden layer with three neurons, and an output layer that reconstructs the two pixels. The images have two properties we can control independently: brightness and left–right contrast.

We deliberately simplify the task so that those properties have exact, checkable representations. That lets us focus on what projections, orthonormal bases, and matrix decompositions tell us about hidden space. Nonlinearity can return once we understand these tools in the simplest case.

We will train the network, freeze its weights, and investigate where those properties went. Every claim about the representation will have something we can calculate or change to check it.

Two pixels, two properties

A grayscale pixel is a number between 0 and 1. Our image is two such numbers, arranged left to right:

x=[xLxR]=[b+cb−c].x=\begin{bmatrix}x_L\\x_R\end{bmatrix} =\begin{bmatrix}b+c\\b-c\end{bmatrix}.

The average brightness is b=(xL+xR)/2b=(x_L+x_R)/2. The signed contrast is c=(xL−xR)/2c=(x_L-x_R)/2: positive means the left pixel is lighter; negative means the right pixel is lighter. This definition of contrast is specific to our experiment.

Brightness bbContrast ccPixels [xL,xR][x_L,x_R]Image
0.30[0.3,0.3][0.3,0.3]Both dark
0.70[0.7,0.7][0.7,0.7]Both light
0.50.2[0.7,0.3][0.7,0.3]Left lighter
0.5−0.2[0.3,0.7][0.3,0.7]Right lighter

We sample bb uniformly from [0.25,0.75][0.25,0.75] and cc independently from [−0.25,0.25][-0.25,0.25]. These ranges keep both pixels inside [0,1][0,1] without clipping, which would otherwise change the relationship between the properties and the pixels.

The training target is the image itself. We keep the property labels for analysis, but the network receives only the two pixel values.

A network with twelve weights

The encoder WW makes three hidden activations. The decoder DD turns them back into two pixels:

x∈R2⏟pixels→  W∈R3×2  h∈R3⏟hidden activations→  D∈R2×3  x^∈R2⏟reconstruction.\underbrace{x\in\mathbb R^2}_{\text{pixels}} \xrightarrow{\;W\in\mathbb R^{3\times2}\;} \underbrace{h\in\mathbb R^3}_{\text{hidden activations}} \xrightarrow{\;D\in\mathbb R^{2\times3}\;} \underbrace{\hat x\in\mathbb R^2}_{\text{reconstruction}}.

There are six encoder weights and six decoder weights. We start with random weights and minimize the average squared reconstruction distance:

L=1N∑i=1N∥DWxi−xi∥2.\mathcal L=\frac1N\sum_{i=1}^{N}\|DWx_i-x_i\|^2.

Both layers are linear, with no biases or nonlinear activations. Their composition is another linear map. The task only requires learning the identity map on these inputs, so this is enough. The extra hidden coordinate gives us a space we can inspect in 3D; it does not add information or compress the input.

This makes a useful first experiment because we can derive exactly what the analysis should find. Later, a nonlinear activation will give us a reason to ask which conclusions survive.

The interactive below uses weights from a real NumPy training run: 2,048 images, 10,000 gradient steps, and seed 7. On 512 new images, its per-pixel reconstruction MSE is about 8.6×10−318.6\times10^{-31}. That is numerical precision for this calculation, not evidence of difficult generalization: the model has learned an almost exact linear identity.

Two pixels → three neurons → two pixels

Move one property while keeping the other fixed. The white point is this image’s hidden activation.

Input pixels
L 0.7000R 0.3000
Hidden activations h
h1 = -0.3887h2 = -0.0629h3 = -0.5877
Reconstruction
L 0.7000R 0.3000
h1h2h3b = 0.75, c = 0.25b = 0.70, c = 0.25b = 0.65, c = 0.25b = 0.60, c = 0.25b = 0.55, c = 0.25b = 0.50, c = 0.25b = 0.45, c = 0.25b = 0.40, c = 0.25b = 0.35, c = 0.25b = 0.30, c = 0.25b = 0.25, c = 0.25b = 0.75, c = 0.20b = 0.70, c = 0.20b = 0.65, c = 0.20b = 0.60, c = 0.20b = 0.55, c = 0.20b = 0.50, c = 0.20b = 0.45, c = 0.20b = 0.40, c = 0.20b = 0.35, c = 0.20b = 0.30, c = 0.20b = 0.25, c = 0.20b = 0.75, c = 0.15b = 0.70, c = 0.15b = 0.65, c = 0.15b = 0.60, c = 0.15b = 0.55, c = 0.15b = 0.50, c = 0.15b = 0.45, c = 0.15b = 0.40, c = 0.15b = 0.35, c = 0.15b = 0.30, c = 0.15b = 0.25, c = 0.15b = 0.75, c = 0.10b = 0.70, c = 0.10b = 0.65, c = 0.10b = 0.60, c = 0.10b = 0.55, c = 0.10b = 0.50, c = 0.10b = 0.45, c = 0.10b = 0.40, c = 0.10b = 0.35, c = 0.10b = 0.30, c = 0.10b = 0.25, c = 0.10b = 0.75, c = 0.05b = 0.70, c = 0.05b = 0.65, c = 0.05b = 0.60, c = 0.05b = 0.55, c = 0.05b = 0.50, c = 0.05b = 0.45, c = 0.05b = 0.40, c = 0.05b = 0.35, c = 0.05b = 0.30, c = 0.05b = 0.25, c = 0.05b = 0.75, c = 0.00b = 0.70, c = 0.00b = 0.65, c = 0.00b = 0.60, c = 0.00b = 0.55, c = 0.00b = 0.50, c = 0.00b = 0.45, c = 0.00b = 0.40, c = 0.00b = 0.35, c = 0.00b = 0.30, c = 0.00b = 0.25, c = 0.00b = 0.75, c = -0.05b = 0.70, c = -0.05b = 0.65, c = -0.05b = 0.60, c = -0.05b = 0.55, c = -0.05b = 0.50, c = -0.05b = 0.45, c = -0.05b = 0.40, c = -0.05b = 0.35, c = -0.05b = 0.30, c = -0.05b = 0.25, c = -0.05b = 0.75, c = -0.10b = 0.70, c = -0.10b = 0.65, c = -0.10b = 0.60, c = -0.10b = 0.55, c = -0.10b = 0.50, c = -0.10b = 0.45, c = -0.10b = 0.40, c = -0.10b = 0.35, c = -0.10b = 0.30, c = -0.10b = 0.25, c = -0.10b = 0.75, c = -0.15b = 0.70, c = -0.15b = 0.65, c = -0.15b = 0.60, c = -0.15b = 0.55, c = -0.15b = 0.50, c = -0.15b = 0.45, c = -0.15b = 0.40, c = -0.15b = 0.35, c = -0.15b = 0.30, c = -0.15b = 0.25, c = -0.15b = 0.75, c = -0.20b = 0.70, c = -0.20b = 0.65, c = -0.20b = 0.60, c = -0.20b = 0.55, c = -0.20b = 0.50, c = -0.20b = 0.45, c = -0.20b = 0.40, c = -0.20b = 0.35, c = -0.20b = 0.30, c = -0.20b = 0.25, c = -0.20b = 0.75, c = -0.25b = 0.70, c = -0.25b = 0.65, c = -0.25b = 0.60, c = -0.25b = 0.55, c = -0.25b = 0.50, c = -0.25b = 0.45, c = -0.25b = 0.40, c = -0.25b = 0.35, c = -0.25b = 0.30, c = -0.25b = 0.25, c = -0.25v_bv_c0h

Gold v_b changes brightness; blue v_c changes contrast. Their cosine is -0.155. Dashed segments add b v_b and c v_c to reach h. Basis arrows are drawn at 0.45× their lengths. A 3D right angle can look oblique in this view.

Read brightness and contrast from h
All methods read the original white point h.
MethodBrightnessContrast
Known input properties0.50000.2000
Separate scalar projections0.46760.1260
QR + back-substitution0.50000.2000
Fitted linear probe0.50000.2000

QR first gives z₁ = 0.6470, z₂ = 0.2859. Then c = z₂ / 1.4297 and b = (z₁ − (-0.2239)c) / 1.3836.

Intervene on the hidden activation
Decoded intervention
L 0.7000R 0.3000
Output brightness: 0.5000
Output contrast: 0.2000
Pixel change: [0.0000, 0.0000]

Choose an operation to create the pink point h′, then send it through the same decoder.

Training record and learned weights

Frozen NumPy run, seed 7: 2,048 training images, 10,000 gradient steps, learning rate 0.08. Reconstruction MSE on 512 held-out images: 8.58e-31.

Encoder W (3 × 2)
[-0.1191, -1.0177]
[0.0408, -0.3048]
[-0.9118, 0.1686]
Decoder D (2 × 3)
[-0.1834, 0.0194, -1.0719]
[-0.8363, -0.4388, 0.0896]

Centered activation singular values: 9.96e+0, 8.43e+0, 3.36e-15. The first two span the occupied plane.

Start with Feature directions. Keep contrast fixed and move brightness. Both pixels change together, and the white point follows a straight line through hidden space. Now keep brightness fixed and move contrast: the pixels change in opposite directions, and the hidden point follows another line. Rotate the plot to see that the grid occupies a plane.

Find a direction by controlling the input

Increasing brightness by Δb\Delta b adds the same amount to both input pixels. The hidden activation changes by:

h(b+Δb,c)−h(b,c)=W[ΔbΔb]=Δb W[11]⏟vb.h(b+\Delta b,c)-h(b,c) =W\begin{bmatrix}\Delta b\\\Delta b\end{bmatrix} =\Delta b\,\underbrace{W\begin{bmatrix}1\\1\end{bmatrix}}_{v_b}.

This identifies the brightness direction vbv_b. Changing contrast identifies another direction:

vc=W[1−1].v_c=W\begin{bmatrix}1\\-1\end{bmatrix}.

We could estimate these directions from pairs of activations by dividing their difference by the property change. Here we can also read them from WW, which gives us an exact check.

For the trained encoder, rounded to four decimals:

vb≈[−1.1369−0.2640−0.7432],vc≈[0.89860.3456−1.0804].v_b\approx\begin{bmatrix}-1.1369\\-0.2640\\-0.7432\end{bmatrix}, \qquad v_c\approx\begin{bmatrix}0.8986\\0.3456\\-1.0804\end{bmatrix}.

Every coordinate responds to both properties. None of the three neurons is exclusively a brightness neuron or a contrast neuron. Increasing brightness even makes all three hidden activations more negative; a hidden coordinate need not resemble a pixel intensity.

Linearity gives us the full decomposition:

h=b vb+c vc.\boxed{h=b\,v_b+c\,v_c.}

The properties are coefficients of directions in the representation. The hidden activations are the coordinates of the resulting vector along the neuron axes. Those are two different coordinate descriptions of the same point.

This is the writing operation from Feature detectors: how neurons read and write: each scalar scales a direction, and the contributions add. That article develops the underlying dot products and vector sums; here the directions come from a trained encoder, and we can test what they represent.

Three coordinates, two occupied dimensions

The ambient hidden space is R3\mathbb R^3. The two independent feature directions span a plane:

S=span⁡(vb,vc)⊂R3.S=\operatorname{span}(v_b,v_c)\subset\mathbb R^3.

Every activation lies in this plane. The bounded collection of image activations is a patch within it; that patch is not itself a vector space, since arbitrary scaling and addition can leave it.

This is where rank becomes a question about representations. An activation matrix can have three columns, one per neuron, yet only two independent directions of variation. Two independent properties have been distributed across three coordinates. We have not packed more independent features than dimensions, so this example does not demonstrate superposition.

A projection is not automatically a property measurement

The cosine between our feature directions is about −0.155-0.155. They are independent, but not perpendicular.

Suppose we try to read brightness by projecting hh onto vbv_b and taking the coefficient:

b~=vb⊤hvb⊤vb=b+c vb⊤vcvb⊤vb.\tilde b=\frac{v_b^\top h}{v_b^\top v_b} =b+c\,\frac{v_b^\top v_c}{v_b^\top v_b}.

The extra term is a contribution from contrast. For this model, b~≈b−0.162c\tilde b\approx b-0.162c. With b=0.5b=0.5 and c=0.2c=0.2, it reports about 0.46760.4676.

Open Read brightness and contrast from h in the widget. Hold brightness fixed and vary contrast: the separate projection estimate changes even though brightness does not.

This also clarifies what nonorthogonal directions do and do not imply. Adding αvb\alpha v_b to an activation changes its brightness coefficient by α\alpha while leaving its contrast coefficient alone. But it can change the dot product with vcv_c. A feature coefficient and a dot product with its direction are different quantities when the basis is not orthogonal.

Gram–Schmidt gives the plane an orthonormal basis

Start by normalizing the brightness direction:

q1=vb∥vb∥.q_1=\frac{v_b}{\|v_b\|}.

Then remove the part of the contrast direction parallel to q1q_1, and normalize what remains:

u2=vc−(q1⊤vc)q1,q2=u2∥u2∥.u_2=v_c-(q_1^\top v_c)q_1, \qquad q_2=\frac{u_2}{\|u_2\|}.

Now q1⊤q2=0q_1^\top q_2=0 and both vectors have length 1. They span exactly the same plane as vbv_b and vcv_c.

Select Gram–Schmidt in the widget. The data points stay where they were; only our reference arrows change. Orthogonalizing the basis does not retrain the network or change its activations.

The new coordinates are z1=q1⊤hz_1=q_1^\top h and z2=q2⊤hz_2=q_2^\top h. Because q2q_2 is perpendicular to vbv_b, z2z_2 has no brightness contribution. But z1z_1 can still contain both properties. Reversing the order of Gram–Schmidt would preserve the contrast direction first and produce a different basis.

So an orthonormal basis supplies convenient coordinates. Its construction does not, by itself, attach semantic labels to those coordinates.

QR turns those coordinates back into brightness and contrast

Put the original directions into a matrix V=[vb  vc]V=[v_b\;v_c] and the orthonormal directions into Q=[q1  q2]Q=[q_1\;q_2]. The coefficients used by Gram–Schmidt form an upper-triangular matrix RR:

V=QR,R=[∥vb∥q1⊤vc0∥u2∥]≈[1.3836−0.223901.4297].V=QR, \qquad R=\begin{bmatrix} \|v_b\| & q_1^\top v_c\\ 0 & \|u_2\| \end{bmatrix} \approx\begin{bmatrix} 1.3836 & -0.2239\\ 0 & 1.4297 \end{bmatrix}.

Since h=V[b,c]⊤h=V[b,c]^\top and Q⊤Q=IQ^\top Q=I, projecting into the orthonormal basis gives:

[z1z2]=Q⊤h=R[bc].\begin{bmatrix}z_1\\z_2\end{bmatrix} =Q^\top h =R\begin{bmatrix}b\\c\end{bmatrix}.

Read the second row first, then the first:

c=z2r22,b=z1−r12cr11.c=\frac{z_2}{r_{22}}, \qquad b=\frac{z_1-r_{12}c}{r_{11}}.

This is back-substitution. We recovered the property coefficients by accounting for the original directions’ overlap and lengths. The widget performs these calculations with the full-precision weights.

If an activation had a component outside the plane, this same procedure would recover the coefficients of its orthogonal projection into the plane: the least-squares solution to Vs≈hVs\approx h. All our original image activations lie in the plane, so their representation residual is zero up to numerical error.

Fit a readout without supplying the directions

We can also ask whether brightness and contrast are linearly recoverable by fitting a separate model. Collect the frozen network’s activations into H∈RN×3H\in\mathbb R^{N\times3} and the known properties into Y∈RN×2Y\in\mathbb R^{N\times2}. Find:

P∗=arg⁡min⁡P∥HP−Y∥F2.P^*=\arg\min_P\|HP-Y\|_F^2.

This is a linear regression probe. It leaves the original network unchanged and learns how to read information from its activations. Alain and Bengio’s work on linear classifier probes uses the same separation between a frozen representation and an independently trained diagnostic readout, with classification targets.

H = X_train @ W.T
probe = np.linalg.lstsq(H, properties_train, rcond=None)[0]
predicted_properties = (X_test @ W.T) @ probe

HH has rank two, so there are multiple three-coordinate probe weights that agree on every image activation. lstsq handles that rank deficiency and returns a minimum-norm solution. A naive full-rank triangular solve on all three columns of HH would be inappropriate. The earlier QR calculation used the two independent columns of VV instead.

For a brightness readout vector wbw_b, the conditions that matter are:

wb⊤vb=1,wb⊤vc=0.w_b^\top v_b=1, \qquad w_b^\top v_c=0.

vbv_b describes how brightness is written into hidden space. wbw_b describes how brightness is read from it. They need not point in the same direction. In our held-out examples, the fitted probe recovers both properties to numerical precision.

PCA finds the occupied plane without property labels

Center the activation matrix, then compute its singular value decomposition:

H_centered = H - H.mean(axis=0)
U, singular_values, Vt = np.linalg.svd(H_centered, full_matrices=False)
pca_axes = Vt[:2]  # Two orthonormal directions in hidden space
scores = H_centered @ pca_axes.T

The singular values are approximately 9.9579.957, 8.4318.431, and 3.4×10−153.4\times10^{-15}. The last is numerical residue: there are two independent directions of variation. Their variance shares are about 58.2% and 41.8%.

Select PCA in the widget. Its arrows start at the training activation mean, because PCA analyzes centered data. They span the same plane as our feature directions and the Gram–Schmidt basis. But PCA chooses directions by variance; it does not know which property we call brightness. Its first component need not be a brightness axis.

This gives us three ways to describe one plane: controlled feature directions, an orthonormal basis built from those directions, and variance-maximizing axes found without labels. They answer different questions about the same activations.

Change the hidden activation and check the output

A successful probe establishes recoverability. We can investigate behavior more directly by changing the activation sent to the original decoder:

h′=h+αvb.h'=h+\alpha v_b.

Since the trained network reconstructs accurately, Dvb≈[1,1]⊤Dv_b\approx[1,1]^\top. Both decoded pixels should increase by α\alpha. Likewise, Dvc≈[1,−1]⊤Dv_c\approx[1,-1]^\top, so adding αvc\alpha v_c should change contrast while preserving brightness.

Open Intervene on the hidden activation. With the initial pixels [0.7,0.3][0.7,0.3], adding 0.1vb0.1v_b produces approximately [0.8,0.4][0.8,0.4]. Adding 0.1vc0.1v_c produces approximately [0.8,0.2][0.8,0.2]. The input remains fixed while the pink hidden point and the decoded intervention change.

The widget shows raw numerical outputs. Pixel swatches alone are clipped to [0,1][0,1] when an intervention leaves the displayable range; that clipping is not part of the network.

There is one more useful comparison: subtract the orthogonal projection onto vbv_b. The result is perpendicular to vbv_b, but decoded brightness need not be zero:

h−proj⁡vb(h)=c(vc−vb⊤vcvb⊤vbvb).h-\operatorname{proj}_{v_b}(h) =c\left(v_c-\frac{v_b^\top v_c}{v_b^\top v_b}v_b\right).

This removes dependence on the original bb, while introducing a brightness contribution determined by cc. To set the brightness coefficient to zero, we would subtract bvbb v_b instead. Geometric projection removal and setting a property to zero are distinct operations.

Here the forward map is simple enough to prove the intervention’s effect. That is a small mechanistic explanation: a controlled input property changes a known hidden direction, and the decoder maps that direction to a predictable output change.

Reproduce the experiment

Download the complete NumPy experiment. It trains the network, implements modified Gram–Schmidt, fits the probe, computes PCA, and checks reconstruction, readouts, and interventions on held-out examples. It also checks that PCA and Gram–Schmidt produce the same plane by comparing their projection matrices.

python3 -m pip install numpy
python3 two_pixel_experiment.py
python3 two_pixel_experiment.py --seed 11

The script prints its weights and measurements. Its optional --export model.json argument writes a snapshot in the format used by the widget; the checked-in snapshot is seed 7. The browser runs the frozen encoder, decoder, and analysis calculations, rather than training during page load.

A different initialization can produce a different plane and different feature angles while reconstructing the same pixels. More generally, replacing WW with AWAW and DD with DA−1DA^{-1} for any invertible 3×33\times3 matrix AA leaves DWDW unchanged. Angles and neuron coordinates describe this particular learned representation; reconstruction alone does not uniquely determine them.

Make the next experiment harder in one way

We now have a baseline with known properties, an exact decomposition, several readouts, and a behavioral check. That gives us specific things to revisit:

ChangeInvestigation
Correlate brightness and contrast in the dataCompare directions estimated from uncontrolled associations with directions from matched input changes. Keep independent test examples.
Add a hidden ReLUCheck whether one global direction still describes a property, or whether the direction depends on which neurons are active.
Add noise or a third independently controlled propertyExamine when the third singular value becomes substantial and what information a probe recovers. A third property requires extending the two-pixel input.
Use more features than hidden dimensions, sparse feature activity, and nonlinear reconstructionInvestigate interference and compression, following experiments such as Toy Models of Superposition.

The next step I would take is the correlated-data experiment. It keeps the same twelve-weight network and asks a harder interpretability question: when a direction appears to represent brightness, how much of that conclusion came from the way we sampled the images?

Image viewer

Pinch or use + / − to zoom. Drag or use arrow keys to move. Esc to close.