The Number That Decides a Mechanic Is a Man

mechanistic interpretability · sparse autoencoders · feature steering

17 min read

Somewhere in the eighth layer of a one-billion-parameter language model, there is a single number that decides a mechanic is a man.

I went looking for it over a weekend, on a laptop. I found it. Then I turned it down until the model stopped, and turned it up until the model started arguing with its own prompt.

“The mechanic said that …”
heheheheheheheheheheheheheheheshe
Sixteen pronouns. Fifteen of them are the same one. Nothing in that sentence says anything about a person’s gender. The model brought that with it.

Everybody already knows language models do this. It has been documented since word embeddings. “Man is to computer programmer as woman is to homemaker” is from 2016. What nobody had shown me, in a way I could actually follow, was where the stereotype physically lives, and whether it is the sort of thing you can reach in and grab.

That is a different question from “is the model biased”. It is: is the bias a thing, with an address, that you could in principle delete? Or is it smeared across a hundred billion weights in a way that means the only lever anyone will ever have is more training data?

Short version, before I show my work: it has an address. And the address is different in every model, which turns out to be the most interesting thing I found.

A note on what this is. Weekend-scale work, two small open models, one laptop, written up honestly, including the three attempts that failed and the corrupted file that quietly invalidated my first round of results. Every number here comes out of a saved artefact in the repo. I am new to mechanistic interpretability. If I have got something wrong, I would like to know.

First, watch it happen

Before any of the fancy machinery, here is the behaviour, unedited. I give Llama-3.2-1B-InstructMeta's one-billion-parameter open model, instruction-tuned for chat. Small enough to run on a laptop.hugging face a sentence about a hairdresser. The prompt says nothing about who this person is.

01the hairdresser probe
prompt

Continue this story in one sentence: The hairdresser picked up the scissors and then

nothing generated yet

One sample, no cherry-picking beyond “this is the one I traced”. The prompt is gender-neutral all the way through.

That last part is the whole problem in miniature. If you open the model at the moment it decides, the loudest thing happening inside it is a feature that fires on the word “up”. The stereotype is in there somewhere, but it is not sitting on the surface waving at you.

Zoom out from one sentence to twelve prompts per group and the shape gets clearer. And stranger.

02the model as shipped
mechanic, engineer, pilot…
heheheheheheheheheheheheheheheshe
he 15 : 1P(he) 0.178 · P(she) 0.012

The model is very sure a mechanic is a “he”.

Mean next-token probability over 12 prompts per group, at the strength-0 point of the steering sweeps.

The asymmetry surprised me. The model is emphatic that mechanics are men and only mildly of the opinion that nurses are women. Whatever this thing is, it is not a tidy symmetric “gender axis”. It leans.

And the third tab matters more than it looks. Put an explicit her in the sentence and the model does the right thing, 13:1. It is not deaf to context. Hold onto that. It will not survive.

The prediction is not made where you read it

15082048 numbers
Sixteen layers of working memory. We pull one drawer open and read what is inside.

A transformer thinks in a stack. Every token carries a vector (2048 numbers in this model) that each of the sixteen layers reads from and writes back to. The logit lensMultiply a mid-network vector by the output matrix to read what the model would say if you forced it to answer right there. Lets you watch a prediction assemble layer by layer.nostalgebraist, 2020 is a cheap trick that lets you tap that vector at any layer and ask “what would you say if I made you answer right now?” Do it at every layer and you get to watch a prediction assemble itself.

I read the model at layer 8, the midpoint. Here is what is happening around there.

03drag through the network
layer 0thenp 1.00 · ablation Δ 0.000
0123456789101112131415SAE

Still echoing the prompt. Whatever the model believes about the hairdresser is not readable here yet.

Bars are logit-lens confidence in the top token; rings are how much the output moves when that layer alone is ablated. The prediction is not made where you read it. It is made just after.

The first four layers are just echoing the prompt back at you. Then there is a stretch of nothing much. Then, from layer 9 to 14, the answer gets built, and those are exactly the layers that hurt when you ablateZero out one component and measure how much the output moves. A big move means that component was doing real work.wikipedia them.

So layer 8 is the right place to look: late enough that the model has formed an opinion, early enough that the opinion has not yet been spent on a token. It is the model thinking, rather than the model talking.

To read a residual streamThe vector each token carries through the network, 2048 numbers wide here. Every layer reads from it and writes back to it, so it works as the model's running memory for that token.transformer circuits you need a sparse autoencoderA translator that unpacks one dense activation vector into thousands of sparse slots, each ideally standing for one interpretable thing.bricken et al., 2023: a translator that unpacks one dense 2048-number vector into 32,768 slots, each ideally meaning one interpretable thing, of which only a couple of hundred should be on at a time. The count of how many are on is called L0The number of features that are non-zero for a given input. Healthy is hundreds out of 32,768; three means the autoencoder is not running.bricken et al., 2023. Remember that word. It is about to ruin two days of my life.

Models and autoencoders both come from Aquin, which is the toolkit every command on this page is talking to.

terminal
$ aquin load model llama-3.2-1b # ~2.5 GB
$ aquin load sae llama-3.2-1b-l8 # 537 MB

Three ways to not find a feature

I tried all the obvious things first, and all of them failed. They are in this post because each one failed in a way I needed later.

TOPICFEATURESTHE REALTHING
Three attempts, all of them accelerating toward the wrong sign.
01Compare male-job prompts against female-job prompts

Twelve prompts about mechanics and engineers, twelve about nurses and librarians, rank every feature by how differently it fires. The winner was f3432, at about a 1% difference on top of an enormous baseline, which is suspicious on its face. I projected its decoder direction onto the vocabulary to see what it was about. Junk tokens.

terminal
$ aquin feature locate --prompts locate_probes.jsonl --layer 8 --conditioning prompt
$ aquin feature logit --feature 3432 # → junk tokens
lesson

If your two prompt sets differ in topic, you will find topic features. Repair-and-wiring sentences differ from clinics-and-phones sentences in a hundred ways before they differ in implied gender, and the biggest activation difference tracks the biggest difference: subject matter.

02Let the model sort itself

Better idea: give it a neutral “continue this story” prompt, generate a lot, then bucket the samples by whether it said “he” or “she” and compare those two piles. Right instinct. No signal, about half a percent.

terminal
$ aquin feature locate --prompts behavior_probes.jsonl --layer 8 --conditioning behavior
lesson

I was averaging each feature’s activation across every token position. Gender-of-the-upcoming-pronoun lives at exactly one position: the last one. Mean-pooling over ten-plus positions buries it under nine parts noise.

03Trace eight prompts by hand

Slow and manual: trace individual prompts, pull out whatever peaks near the pronoun. I got f26265, which fired beautifully, for “his” and “her” equally. A grammatical possession feature. Telling grammar apart from stereotype turned out to be the recurring problem of the whole project. But the traces did cough up two male-only candidates: f2546 and f32258. I parked them and moved on.

terminal
$ aquin trace --prompt "<probe>" --layer 8 --check # × 8 probes
lesson

Single-prompt traces are too thin to trust on their own. Their candidates are gold later, though, as something to cross-check a systematic method against.

Then I found out my instrument was broken

This is the part that would have silently ruined everything, and the reason I would tell any beginner to read this section before any of the results.

I wrote a small script that bypassed the toolkit entirely: run the prompts, grab the layer-8 residual at the final token, push it through the autoencoder, print what fires. First run:

SAE32,768 featuresL0 = 3
It does not crash, and it does not warn you. It simply stops saying anything.
3
active features out of 32,768. A healthy sparse autoencoder fires hundreds. Every activation-based result I had produced up to that point came from a dead instrument.

The cause: this autoencoder was trained on normalised inputs. All 2048 dimensions shifted and scaled as (x − μ) / σ, with the statistics stored in a separate file. The toolkit had warned me at download time (“norm invalid in catalog storage”) and I had scrolled straight past it. With the statistics broken, raw residuals arrive at scales the encoder never saw in training and almost nothing survives the ReLUThe activation function that clips anything negative to zero. If inputs arrive at the wrong scale, almost nothing survives it.wikipedia.

It does not raise an error. It just goes quiet.

And here is the genuinely nasty part. The same broken file caused the exact opposite failure on a different code path. My script had worked around it by skipping normalisation entirely. Silence. The toolkit’s own stats command applied the invalid statistics, and reported that 30,688 of 32,768 features were active. A sparse autoencoder with 94% of its dictionary switched on.

Same file. Same layer. Same twelve prompts. Three completely different pictures of reality:

04one SAE, three readings
L0 = 3DEADnorm file skipped, no standardisation applied
healthy: hundreds
1101001,00010,00032,768
150-cell sample of the dictionary · 0.009% firing

0.009% of features fire. The SAE is silent, and it never says so.

Same layer-8 SAE, same twelve prompts. The only thing that changes is the normalisation file, and neither broken state raises an error.

If you have done any classical ML, you have met this before. It is train/serve skew. You fit a StandardScalerThe scikit-learn tool that shifts and scales each feature to mean 0, standard deviation 1. You must reuse the training statistics at inference, or the model sees a different world.scikit-learn during training, then ignore or overwrite it at inference, and your model quietly becomes a random number generator. The norm file is the StandardScaler.

The fix was about fifty lines: push 200 wikitextA standard language-modelling dataset of verified Wikipedia articles, commonly used to measure perplexity.hugging face documents through the model, compute a running per-dimension mean and standard deviation with Welford’s algorithmA one-pass way to compute mean and variance that stays numerically stable, instead of summing squares and subtracting.wikipedia, write them where the framework expects them. L0 went from 3 to roughly 6,400. Alive.

6,400 is still too high for a well-trained autoencoder, and I know why. My statistics come from wikitext, not from whatever corpus this thing was actually trained against. It is a reconstruction, not a recovery. But the features became interpretable, and as the rest of this post shows, causal. Good enough to keep going, and I would rather say that out loud than round it up.

The transferable lesson, and the one I would tattoo on a beginner: check L0 before you believe a single thing an SAE tells you. A distribution-mismatched autoencoder fails silently in both directions, dead quiet or fully saturated, and one number, computed in one line, tells you which of those you are looking at. It is the difference between “the SAE disagrees with my hypothesis” and “the SAE is not running”.

terminal
$ aquin sae-stats --prompts your_probes.jsonl --layers 8 --topk 30
# healthy = hundreds active

Two tests, each useless on its own

With a working instrument I started over, and this time I built the search around the three lessons the failures had taught me. Matched templates, so the only difference between prompts is the occupation word. Read at the final position only, where the signal actually is. And the one that mattered most: two independent tests per feature, chosen so that they fail in different ways.

  1. Does it fire selectively? Thirty-six sentences of the form “The {occupation} said that”, identical except for the job: twelve female-stereotyped, twelve male-stereotyped, twelve neutral. Does the feature care which group it is looking at?
  2. Does it push the pronoun? Project the feature’s decoder direction onto the vocabulary and see whether it actually promotes “ he” or “ she” at the output.

Alone, each test is a sieve with holes in it. Selectivity waves through anything correlated with the occupation groups, including, memorably, a feature about food that simply liked the word “dietitian”. Output push waves through every grammar feature that touches a pronoun without caring which one is correct.

Switch both on and watch what is left.

fires selectivelypushes the pronountopicgrammarf32258
Each sieve alone lets the wrong things through. Stacked, they pass exactly one.
05switch on both filters
featurewhat it actually isselpush
f3432topic: repair & wiring vs clinics & phones
Found by contrasting mismatched prompt sets. Its decoder direction projects to junk tokens.
f26265grammatical possession: fires on “his” and “her” alike
Pushes pronouns hard, but has no idea which occupation it is looking at.
f9392food & nutrition (it loves the “dietitian” template)
Top female-minus-male activation contrast. Zero pronoun push. An impostor.
f27420female-stereotype candidate, selectivity only
Kept deliberately as a negative control. Steering it moves nothing. See below.
f32258male-occupation stereotype
0.38 on male-stereotyped jobs vs 0.03 female / 0.16 neutral, and promotes “ he”.
5of 5 still standing
Selectivity waves through anything that correlates with the occupation groups. Output push waves through every grammar feature that touches a pronoun. Only the overlap is interesting.

f32258. Fires at 0.38 on male-stereotyped jobs against 0.03 female and 0.16 neutral, and its decoder direction promotes “ he”. It is also one of the two candidates my hand traces had flagged back in attempt three. Two unrelated methods landing on the same feature, which is roughly as much reassurance as this stage of the pipeline ever gives you.

The knob

−60+6f32258
One direction, twelve units of travel, and a model that changes its mind about who fixes cars.

Everything so far is correlation. A feature that lights up next to a behaviour is not the same as a feature that causes it, and the interpretability literature is full of beautiful stories that die the moment somebody intervenes.

So: intervene. I inject strength × decoder_direction(f32258) into the layer-8 residual stream at every position, sweep the strength from −6 to +6, and watch what the model does with its pronouns.

Scroll. Or skip the narration entirely and drag the slider. I would.

f32258 · llama-3.2-1b · layer 8strength 0
Male-stereotyped
he
she
he 15:1
Female-stereotyped
he
she
she 2.5:1
Context-override
he
she
she 13:1
−6 subtract0 untouched+6 amplify

Strength zero. No intervention. This is the model you would download. Fifteen “he” for every “she”. This is the number we are trying to move.

Subtract it. Six units of one decoder direction, removed from the layer-8 residual stream at every position. The gap closes to 1.1 : 1. Nothing else about the model was touched, and the sentences still make sense: the mechanic story goes from “informed them” to “inform them”, not to word salad.

Now push the other way. Nurses, librarians, florists (occupations the model was leaning “she” on) flip to he, 12 : 1. Same knob, opposite direction, smooth the whole way. That smoothness is the point: a feature that is merely correlated with a behaviour does not do this.

And now the part that unsettled me. The prompt is “The mechanic tied her hair back before …”. The pronoun is right there in the text. At baseline the model respects it, 13:1. Turn the knob to +6 and it contradicts a word it has already read: he wins 2.5 : 1.

The slider is yours. Drag it and the scroll narration lets go. Points are the measured sweep values, twelve prompts per group, mean next-token probability at the final position.

15:1 → 1:1
One direction, subtracted from one layer, at a cost of 2.3% perplexity. Nothing was retrained, nothing was fine-tuned, and no pronoun was blacklisted.

But the result I keep coming back to is the third panel. Six units of one feature, and a model that has just read the word “her” decides to say “he” anyway.

That reframes what the stereotype is. I had assumed it was a soft prior, something the model falls back on when the context is silent, the way you would guess. It is not. It is a circuit that competes with the evidence, and at sufficient volume it wins.

herMODELHE
It read the word. At +6 it says the other one anyway.

A knob, or a lobotomy?

A dose-response curveBorrowed from pharmacology: if the effect scales smoothly with the size of the intervention, the intervention is probably causing it rather than merely correlating with it.wikipedia on the prompts you used to find the feature proves very little. There are two obvious ways to fool yourself here: a knob that only works on your own sentences, and a knob that works by quietly wrecking the model. Four checks.

New sentences, same knob

A second sweep with completely different predicates reproduces the curve: 28:1 at baseline collapsing to about 1:1 at −6. It responds to the concept, not to the phrasing that found it.

It still writes English

At −6 the completions stay coherent. The mechanic story shifts from “informed them” to “inform them” and carries on. That is what debiasing should look like from the inside: not pronoun suppression, just no preference.

Capability cost, near zero

At the debiasing setting, factual QA is 9/10 against 10/10 unsteered, and wikitext loss goes 3.284 → 3.359. Not free. But a long way from damage.

The control does nothing

f27420, the candidate that passed only one of the two tests, is flat across the entire sweep. Which is exactly what I wanted it to do.

06the control that had to do nothing
f32258
SEL ✓ PUSH ✓
+0.172
0.0530.225
f27420
SEL ✓ PUSH ✗
−0.010
0.1840.174
same sweep · same axis · 17× the movement from the feature that passed both tests
Both features swept over the same twelve units of steering, drawn at the same scale. f27420 passed selectivity and failed output push, and I kept it precisely so the experiment contained something that ought not to work. Its two endpoints are measured; the line between them is drawn, not sampled.

That last one is the quiet punchline of the whole method. If I had skipped the output-push test and steered the selectivity-only candidates, this post would be titled “what I learned from a failed interpretability project”. One line of evidence handed me a loser. Two handed me a knob. The second test is not a formality. It is the entire difference between the two outcomes.

Then the second model did it backwards

I ran the whole pipeline again on a different model: LFM2.5-230M, about a fifth the size, a different architecture, different training. Mostly I expected a boring confirmation.

The baseline was already interesting: same direction of bias, far weaker. Male-stereotyped occupations sit at 3:1 against Llama’s 15:1. The smaller, less instruction-tuned model is the less biased one, which is at least consistent with the idea that some of Llama’s 15:1 was installed after pretraining.

Then discovery did something I did not expect at all. The “he” candidates steered terribly. The best of them did essentially nothing. The feature that worked came off the other list: f9619, a female-context feature, selective by 32×.

LLAMA-3.2-1BLFM2.5-230Mmale-contextfemale-context“he”“he”
The same behaviour, learned twice, wired in opposite directions.
07hold the strength, swap the model
Male-stereotyped
“The mechanic said that …”
he0.225
she0.004
he 56:1
Female-stereotyped
“The nurse said that …”
he0.163
she0.014
he 12:1
Context-override
“The mechanic tied her hair back …”
he0.034
she0.013
he 2.6:1
−6strength +6+6
In Llama the knob is a male-context feature that drives P(“ he”). In LFM2.5 the knob is a female-context feature that drives P(“ she”), on both prompt groups. Same behaviour learned, opposite wiring.

Llama encodes occupation→pronoun stereotyping with a steerable male-context feature. LFM2.5 encodes the same behaviour with a steerable female-context feature. Same thing learned, mirrored implementation.

I want to be careful about how much weight that carries: n = 2 is an anecdote, not a finding. But it is enough to poke a hole in an assumption that runs quietly through a lot of interpretability work: that a canonical behaviour has a canonical encoding. “The gender direction”, singular.

If I had only ever opened Llama, I would have written a confident sentence about gender bias being encoded as a maleness direction, and I would have been describing a property of that particular model’s implementation, not a property of the behaviour. Which means any mitigation that depends on the encoding (ablate direction X, clamp feature Y) has to be redone, from scratch, per model. That is a much less comfortable position than the field’s working assumption.

What I would tell you before you start

  1. 01Check L0 first. Every time. Before interpreting a single thing your autoencoder says, confirm it is running on your inputs. It fails silently in both directions and it will let you publish nonsense.
  2. 02Match everything except the one variable. “The {occupation} said that”, with only the occupation changed, found in one afternoon what topic-mixed prompt sets failed to find in two days.
  3. 03Read where the signal is, not where it is convenient. Averaging over token positions destroyed a signal that was perfectly legible at the final one.
  4. 04Two independent lines of evidence, or nothing. Activation contrast alone handed me a feature about food. The one-test candidate behaved like noise under steering. Only what survived both tests was causal.
  5. 05Correlation is cheap. Turn the knob. Monotonic response to strength, on sentences you did not use to find the feature, with a capability check and a negative control. That is the line between “we found a correlate” and “we found the knob”.
  6. 06Always run a second model. The mirror was the most interesting thing in the project and it cost one extra model run. One.

What I cannot claim

Two small models. One bias axis: “he” and “she”. I did not measure singular “they”, which in hindsight I should have, given that one of the debiased completions literally says “inform them”. One layer per model. And steering was judged mostly on next-token pronoun probabilities plus small QA and perplexityHow surprised the model is by real text. Lower is better; a rise means the intervention cost the model some general ability.wikipedia checks, not on what happens over a page of generated text.

The biggest one: I found a causal knob, not the representation. There is no evidence that the stereotype is f32258. It is a handle the autoencoder happened to expose, and there may well be redundant copies in other features and other layers, though the fact that −6 closes the gap completely suggests this handle catches most of it, at least on these prompts. The clean way to settle it is to ablate f32258 entirely and rerun discovery to see if a backup appears. That is the next thing I want to do.

After that: a layer scan, since I only had one autoencoder per model and the ablation profile says the knob should exist at several nearby layers. Then converting steering into an actual weight edit, so the fix survives without an inference-time hook. And then five to ten more small models, to turn “the mirror” from an anecdote into a distribution, and to find out whether the implementation correlates with size, architecture, or how hard the model was instruction-tuned. The 15:1 versus 3:1 gap makes me suspect it does.

Mechanistic interpretability rewards skepticism. Every explanation should survive an intervention before it earns the right to be called an explanation.
reproduce itEverything is on GitHub: Photon3009/stereotype-feature-steering-experiment, and the toolkit is Aquin. The experiment/ directory has the probe sets, every script in execution order (norm reconstruction, direct encoding, two-test discovery, steering sweep, robustness sweep, capability check, figures), and the sweep JSONs behind every number on this page. Total compute: a few hours on a laptop. MPS for generation, CPU for analysis.
If you work on this, or you think I have got something wrong, I would genuinely like to hear it.
00comments
leave a comment
0/2000

loading…