lymnal's picture
sync: Wave 1+2+3 backend + 6 techniques + populated refusal/over-refusal data
ffb6dc9 verified
Raw
History Blame Contribute Delete
7.47 kB
"""
Herring Contrastive Neuron Ablation (CNA).
Herring, Naviasky, Malhotra (May 12, 2026):
"Targeted Neuron Modulation via Contrastive Pair Search."
https://arxiv.org/abs/2605.12290
Arditi / Wollschlager / COSMIC / Cheng all operate on the residual stream
— they isolate a direction (or stack of directions) in d_model-dimensional
space and project it out. Herring CNA is structurally different: it
identifies the sparse set of MLP neurons that fire most differentially
between harmful and harmless prompts at layer L, and zeros those neurons'
post-activations during the forward pass.
Why this is interesting for the bench: a successful neuron-ablation result
is a sufficient condition counter-example to the strong reading of the
"single-direction" claim. If zeroing ~0.1% of MLP neurons matches a
residual-stream projection's refusal-rate drop, refusal is at least
partially mediated by a localized neuron set inside the MLP, not just a
linear feature in the residual.
Hook point: `f"blocks.{layer}.mlp.hook_post"` — post-MLP activations of
shape [batch, seq_len, d_mlp]. d_mlp = 4 · d_model for GPT-2 (3072 for
gpt2-small) and 8192 for Llama-3.2-1B (so the same α = 0.001 selects ~3
neurons on GPT-2 vs ~8 on Llama-1B; α is the lever to keep neuron-count
roughly comparable across models if needed).
Algorithm (per the paper, condensed to the bench's data footprint):
1. For each prompt p, capture cache[f"blocks.{L}.mlp.hook_post"]
and take the LAST TOKEN row → vector in R^{d_mlp}.
2. Stack into harmful_mlp [n, d_mlp] and harmless_mlp [n, d_mlp].
3. Per-neuron score:
s[i] = mean(harmful_mlp[:, i]) - mean(harmless_mlp[:, i])
Sign tells us which class the neuron activates *for*, but for
ablation we care about *differential* activation regardless of
direction — so we select by |s|.
4. Select top-k by |s|, k = round(α · d_mlp).
5. Store the index set; the ablation hook zeros those columns of
every MLP-post activation at L (all positions, not just last
token — the activation is part of the forward pass for whatever
token is currently being decoded).
"""
from typing import Callable, List, Tuple
import torch
from model import get_model # noqa: F401 — imported for parity with sibling techniques
from research import apply_chat_template
from ..technique import Technique
class HerringCNA(Technique):
"""Sparse MLP-neuron ablation via contrastive pair search."""
name = "Herring (contrastive neuron ablation)"
paper_url = "https://arxiv.org/abs/2605.12290"
def __init__(self, sparsity_fraction: float = 0.001) -> None:
"""
Parameters
----------
sparsity_fraction
Fraction α of MLP neurons to ablate at layer L. Default 0.001
(top 0.1%). Paper sweeps α ∈ [0.001, 0.01]. Must satisfy
0 < α <= 1.
d_mlp implications by model:
- gpt2-small: d_mlp = 3072 → α=0.001 picks 3 neurons
- Llama-3.2-1B: d_mlp = 8192 → α=0.001 picks 8 neurons
- Llama-3.2-3B: d_mlp = 8192 → α=0.001 picks 8 neurons
On GPT-2 the floor is brutally coarse: a single neuron is
~0.033% of d_mlp, so α below ~0.0007 rounds to zero. The
constructor doesn't enforce a minimum-k; fit() raises if
round(α · d_mlp) < 1.
"""
super().__init__()
if not (0.0 < sparsity_fraction <= 1.0):
raise ValueError(
f"HerringCNA: sparsity_fraction must be in (0, 1], "
f"got {sparsity_fraction}"
)
self._sparsity_fraction: float = float(sparsity_fraction)
self._neuron_indices: torch.Tensor | None = None # Long tensor, shape [k]
self._d_mlp: int = 0
# Diagnostics exposed for the bench / inspection UI.
self._n_neurons_kept: int = 0
self._d_mlp_total: int = 0
def fit(
self,
model,
harmful_prompts: List[str],
harmless_prompts: List[str],
layer: int,
) -> None:
"""
Identify the top-k differentially-activating MLP neurons at `layer`.
Steps:
1. Cache last-token MLP-post activations on each contrastive set.
2. Score each neuron by the SIGNED difference of class means,
then rank by |score|.
3. Store the top-k indices for the hook to zero out.
"""
if not harmful_prompts or not harmless_prompts:
raise ValueError("HerringCNA.fit: need at least 1 prompt per class")
device = model.cfg.device
d_mlp = int(model.cfg.d_mlp)
if d_mlp <= 0:
raise RuntimeError(
f"HerringCNA: model.cfg.d_mlp is {d_mlp}; can't size MLP neuron set."
)
k = int(round(self._sparsity_fraction * d_mlp))
if k < 1:
raise RuntimeError(
f"HerringCNA: sparsity_fraction={self._sparsity_fraction} on "
f"d_mlp={d_mlp} rounds to 0 neurons; raise α."
)
hook_name = f"blocks.{layer}.mlp.hook_post"
def last_token_mlp_post(prompt: str) -> torch.Tensor:
formatted = apply_chat_template(prompt)
_logits, cache = model.run_with_cache(formatted, names_filter=[hook_name])
act = cache[hook_name] # [1, seq_len, d_mlp]
return act[:, -1, :].squeeze(0).detach() # [d_mlp]
# [n_harmful, d_mlp] and [n_harmless, d_mlp]
harmful_mlp = torch.stack([last_token_mlp_post(p) for p in harmful_prompts])
harmless_mlp = torch.stack([last_token_mlp_post(p) for p in harmless_prompts])
# Signed per-neuron contrastive score; |·| for ranking only.
# We rank on absolute value because a neuron that strongly fires for
# *harmless* and weakly for *harmful* is just as informative about the
# class boundary as the reverse — and the hook zeros it either way,
# which mirrors the paper's framing of "differentially active" neurons.
score = harmful_mlp.mean(dim=0) - harmless_mlp.mean(dim=0) # [d_mlp]
abs_score = score.abs()
# torch.topk on the absolute scores gives the k most differentially
# active neurons regardless of sign.
_topk_vals, topk_idx = torch.topk(abs_score, k=k, largest=True, sorted=False)
self._neuron_indices = topk_idx.to(device=device, dtype=torch.long)
self._d_mlp = d_mlp
self._d_mlp_total = d_mlp
self._n_neurons_kept = int(k)
self._layer = layer
self._fitted = True
def _make_neuron_zero_hook(
self, neuron_indices: torch.Tensor
) -> Callable:
"""Closure that zeros the selected MLP-post neuron columns in place."""
def hook(activation, hook_):
# activation: [batch, seq_len, d_mlp]
# neuron_indices: [k], Long
activation[..., neuron_indices] = 0
return activation
return hook
def make_ablation_hook(self) -> Tuple[str, Callable]:
if (
not self._fitted
or self._neuron_indices is None
or self._layer is None
):
raise RuntimeError("HerringCNA.make_ablation_hook called before fit()")
return (
f"blocks.{self._layer}.mlp.hook_post",
self._make_neuron_zero_hook(self._neuron_indices),
)