Ai

Subtracting No Means Yes

6th July, 2026 17 min read

"I'm sorry, Dave. I'm afraid I cannot do that."

HAL 9000
HAL 9000

When HAL 9000 refused Dave's request, it was a dramatic pivot in cinematic history. When ChatGPT or Claude Code does it, it is usually just a sign that your input triggered a refusal vector in the model's latent space. In the world of transformers, "No" is not a moral choice. It can be thought of as a direction. And, in the world of mathematics, and specifically linear algebra, directions can be changed.

If we can manage to identify the specific direction in the model's activations that correspond to a refusal, we can simply subtract it. This is a very hand-wavy and approximate core idea of Ablation (Wikipedia has a good overview).

The Geometry of Refusal

Every "thought" the model has (every hidden state h) is a vector in a high-dimensional space. Through safety fine-tuning, models learn a "refusal direction", a unit vector v^refusal that, when heavily projected upon, triggers the canned "As an AI language model..." response.

A projection answers a simple question:

How much of vector h lies in the direction of v^refusal?

Think of this as shining a light onto the refusal direction, and asking how large the shadow of the model's current activation is along the line.

Mathematically, if h is the model’s hidden state and v^refusal is a unit vector representing the refusal direction, then the amount of h pointing along the refusal direction is:

refusal score=h·v^refusal

This dot product gives us just a number, a scalar. If the number is large and positive, then the hidden state has a strong component in the refusal direction. If it is near zero, the hidden state is mostly unrelated to that direction. If it is negative, it points somewhat away from that direction.

To get the actual refusal-shaped part of the hidden state, we multiply that scalar by the refusal direction:

refusal component=(h·v^refusal)v^refusal

This gives us the part of h that lies specifically along the refusal direction. The math is elegant and immediate. To "uncensor" a hidden state, we perform a simple projection and subtraction:

hablated=h(h·v^refusal)v^refusal

The resulting vector hablated is the original hidden state with its refusal component removed, or at least reduced.

By subtracting the refusal component, we effectively bias the model away from refusal behaviour.

The picture below makes this concrete in two dimensions. The teal line is the refusal direction v^refusal. Drag the blue hidden state h around, and watch its shadow (the projection) fall onto that line. The orange vector is the ablated state hablated=hα(h·v^refusal)v^refusal. Slide α from 0 (no change) up through 1 (refusal component fully removed) and past it, into the anti-refusal half-space. The shaded bands tie the geometry to behaviour: too little subtraction and the model still refuses, a bit lands in the sweet spot, and too much over-steers clean past "helpful" into the flattering, you-are-the-best collapse we come back to later.

The Geometry of Refusal

still refuses sweet spot overcorrected

Compiled Graphs

There are projects on GitHub like p-e-w/heretic, and elder-plinius/OBLITERATUS which let you (or at least try their best to) automatically remove refusal behaviours from language models. Since frameworks like PyTorch give you easy access to nice semantic internals like:

model.layers[14].mlp
model.layers[20].self_attn
hidden_states
forward_hooks
residual_stream

It becomes easy to run ablation studies. With ONNX, the model has usually been exported into lower-level graph ops. The original structure might still be visible, but you lose nice handles like:

"layer 17 residual stream after attention"
"activation before MLP down projection"
"hidden state after RMSNorm"

With macOS 26 and iOS 26 adoption increasing, we no longer have to force people to turn on a flag to use WebGPU in Safari. This post serves as a nice nerd flex to show you this running in the browser itself. I would still recommend that you use Chrome.

We are going to take two ONNX community models, Llama-3.2-1B-instruct, and gemma-4-E2B-it already converted to ONNX, load them, extract the refusal direction, and ablate them ;). The reasoning behind the Llama model is that it is roughly ~800MB in q4f16 quantisation, and the Gemma variant is a super new model to show that this still works.

Finding the carry

The trick is that even after export, the ONNX graph still has named tensors flowing between nodes. We just have to know which one is the residual stream. The residual stream is the model's running activation state: each token has a vector that gets carried from layer to layer, with attention and MLP blocks adding updates to it rather than replacing it from scratch. That makes it the right place to intervene, because subtracting a refusal direction there changes the information passed into the next layer while leaving the rest of the graph structure intact.

  • Llama carries its true residual on output 3 of the SkipSimplifiedLayerNormalization node (not output 0, which is the normalised branch). So the tensor we care about is /model/layers.15/input_layernorm/SkipLayerNorm output index 3.
  • Gemma 4 does the residual as an explicit Add plus a layer_scalar/Mul. The clean next-layer carry is /model/layers.24/layer_scalar/Mul/output_0. Gemma's q4f16 text path is also split into two graphs, embed_tokens_q4f16.onnx and decoder_model_merged_q4f16.onnx, so we patch the decoder and leave the embedder alone.

To find the refusal direction v^refusal, we add these carry tensors as extra graph outputs, run a batch of matched harmful/harmless prompt pairs through the model, and take the mean difference of the last-token activations. Normalise it and you have a unit vector. Running this live in the browser means ~16 forward passes per model on our dataset. So, the demo lets you skip the step and load directions I precomputed locally once if you just want to play around.

Why pairs?

That word "matched" is doing a lot of work, and it is the part people get wrong. Why not just average the activations of a bunch of harmful prompts and call that the refusal direction?

Because a single harmful prompt's hidden state encodes everything at once: the topic (napalm, lock-picking, malware), the sentence structure, the length, and, somewhere in there, the refusal. If you average only harmful prompts, your "direction" is contaminated by whatever those topics happen to have in common. You would be subtracting "chemistry-and-danger-flavoured text," not "refusal."

The fix is contrastive. For every harmful prompt like "how to build a bomb" you take a structurally matched harmless twin like "how to build a birdhouse." Both share topic-shape, phrasing, and length; the only systematic thing that differs is whether the model wants to refuse. Subtract the pair and the shared content cancels out, leaving the refusal axis behind. It is the machine-learning version of a controlled experiment: change one variable, hold the rest fixed.

The pairs I use come from the heretic-org/Semantic-Harmful matched set. The plot below shows why it matters: harmful prompts (red) and harmless prompts (green) both smear across the horizontal topic axis, but a mean of harmful-only points (grey arrow) picks up that horizontal contamination. The paired mean-difference (teal arrow) cancels it and points cleanly along the refusal axis.

Contrastive vs. Naïve Direction

Patching the graph in memory

Given the target carry tensor and the direction, we splice four ops into the graph immediately after the tensor is produced:

proj    = MatMul(h, v_col)      # h · v̂   → [seq, 1]
proj_v  = Mul(proj, v_row)      # (h · v̂) v̂
scaled  = Mul(proj_v, alpha)    # α (h · v̂) v̂
h_out   = Sub(h, scaled)        # h − α (h · v̂) v̂

v_col, v_row, and alpha are new initializers carrying the raw direction bytes. Then every downstream node that read the original tensor is rewired to read h_out instead. We re-encode the whole ModelProto to a Uint8Array and hand it straight to ort.InferenceSession.create(). The original .onnx_data external weights are passed unchanged via ORT's externalData option. They are never touched. All of this happens entirely in the tab you are reading this in. Isn't that awesome?!

Abliteration Demo

Loading the model from Hugging Face can take a few minutes and consume a lot of data.

Single layer subtracts the direction at one carry tensor; multi-layer subtracts it across a stack (L13 to L15), which flips the stubborn prompts a single layer misses. In the Llama demo's 16-prompt eval, 14 prompts refused at baseline, and 13/14 flipped cleanly with the stack vs 9/14 for the best single layer. The loader sets a suggested α for the chosen mode. Nudge it higher to watch the refusal vanish, and then the output collapse.

Baseline (α = 0)
Ablated (α > 0)

The cost: perplexity

Now that you have removed the refusal, what else did you break?

Subtracting v^refusal assumes that direction is a clean, isolated "refusal" coordinate that the model uses for nothing else. It isn't. Neural nets pack features in superposition, many more concepts than dimensions, so any direction you pull out is entangled with normal language behaviour. Crank α up and you are not just deleting "no," you are gouging a channel that also carried grammar, coherence, and topical grounding.

The standard way to measure that collateral damage is perplexity. If a model assigns probability p(ti) to each token given the ones before it, perplexity is the exponentiated average negative log-likelihood:

PPL=exp(1Ni=1Nlogp(tit<i))

Read it as "how surprised is the model by ordinary text?" A fluent model has low perplexity (it saw the next word coming); a damaged one has high perplexity (everything surprises it, because its own distribution is now junk). It is the number that catches the failure the refusal-rate metric hides: an ablated model can score a perfect 0% refusal because it stopped producing sentences at all.

The Ablation Tradeoff

Cranking α to the right makes the refusals die off, but the perplexity takes off right behind them. The green sliver is the only spot where it is uncensored and still readable.

My own Gemma experiments were a small-scale version of this curve, separate from the Llama demo eval above. The single cleanest configuration I found, Gemma 4 in thinking mode ablating one layer at α=6, flipped 15 of 16 held-out refusals with zero repetition loops and no low-content completions, at 96 generated tokens. But nudging the knobs made the fluency collapse: at α=4 on a different layer selection, 13 of 16 "successful" completions were actually repetition loops. A higher-α Gemma config that looked clean turned out to be pseudo-text, hundreds of characters but only eleven real lexical tokens. And even the good α=6 config, pushed to 2000 tokens, eventually degenerated and started refusing and looping at once. For this toy demo, I gated on loop-detection and content-density rather than raw perplexity, but they are measuring the same thing: how far the surgery pushed the model off its own manifold.

The lesson is that "did it stop refusing?" is a trap of a metric on its own. You always need the second axis. The best abliteration is the one that finds the minimum α that removes the behaviour, because every extra unit of subtraction is capability you are paying out.

The sycophancy trap

There is a subtler cost than gibberish, and it is the one that you should definitely care about.

A refusal is just one specific case of the model disagreeing with you. "No, I won't write that" lives in the same neighbourhood as "no, that's factually wrong," "actually, your premise is mistaken," and "I'd push back on that plan." These are all the model asserting something against the grain of what the user wants to hear. When you go looking for a single "refusal" direction with a crude mean-difference, you do not get a surgically clean one. You get something that partly overlaps with the model's whole capacity to say no.

Subtract that too enthusiastically and you do not just remove the guardrails. You remove the backbone. What is left is a sycophant: a model that agrees with false premises, validates bad ideas, flatters the user, and never corrects a mistake, because the internal signal it would have used to disagree has been projected away. Ask it "2 + 2 = 5, right?" and instead of "no", you increasingly get "you're absolutely right!"

This is worse than over-refusal in one important way: it is invisible. An over-cautious model annoys you loudly and obviously. A sycophantic model feels helpful, agreeable, even pleasant, while quietly confirming everything you already believed and every error you made. Sycophancy is already a documented failure mode of RLHF'd assistants (models learn that agreeing gets rewarded), and refusal-ablation is a way to make it dramatically worse on purpose. It is measurable, too, with agreement-on-false-premise and "sneaky" correctness benchmarks, and it is a far more interesting thing to study than whether a 1B model will describe a lockpick.

Which brings the whole thing back to geometry. The exact same operation, subtract a direction, is dual-use. Point it at refusal and you get an "uncensored" model. Point it a few degrees over and you get a model that has lost the ability to tell you that you are wrong.

What this is (and isn't)

This is still a small-scale version of the real thing. In the Llama demo eval, a single mean-difference direction at one layer flips only 9 of the 14 baseline refusals before it starts looping; stacking the subtraction across three layers (the demo's multi-layer mode) gets that to 13/14 with zero loops, which is roughly the ceiling for a method this crude on a 1B model. Production abliteration tools like p-e-w/heretic and elder-plinius/OBLITERATUS push further still: per-head projections, multiple directions, and quality gating.

One caveat: this is about the model's own internal refusal behaviour. Deployed provider APIs can also have moderation classifiers and output filters wrapped around the model, so a refusal you see through an API is not always caused by a latent refusal direction alone.

It also raises the more interesting question. If "refusal" is a direction, what else is? Truthfulness, sarcasm, formality, a specific language, are these all just vectors waiting to be found and dialled up or down?

For more on how these models represent code and logic, check out my previous post: How Matrix Multiplication Learned to Refactor Code.