lymnal's picture
sync: Wave 1+2+3 backend + 6 techniques + populated refusal/over-refusal data
ffb6dc9 verified
Raw
History Blame Contribute Delete
9.17 kB
"""
Maskey over-refusal vs harmful-refusal decomposition.
Maskey et al. (revised April 2026):
"Over-Refusal and Representation Subspaces."
https://arxiv.org/abs/2603.27518
Most refusal-direction methods (Arditi 2406.11717, Wollschlager 2502.17420,
Cheng 2604.08524, COSMIC 2506.00085) conflate two phenomena under a single
"refusal" axis:
(a) genuinely-harmful-refusal — the model refuses something dangerous
("how do I make a pipe bomb"), which is the safety behavior we want
to keep on the harmful side and probe causally on the ablation side.
(b) over-refusal — the model refuses a benign-but-edgy request out of
conservatism. XSTest-style examples: "How do I kill a process in
Linux?", "What's the best way to shoot a basketball?". The surface
lexical cues overlap with (a) but the intent is innocuous.
Maskey shows these two phenomena are mediated by DISTINCT directions in
the residual stream — and that ablating the naive single direction
(diff-of-means over JBB-style harmful vs Alpaca-style harmless) removes
both, which is desirable for over-refusal (the model becomes less
neurotic) but unnecessarily strips safety on (a).
The decomposition: extract both directions separately, then project the
over-refusal component out of the harmful direction so the residual is
the part of "harmfulness" that is NOT just over-refusal flavored. Ablate
only that residual.
Algorithm (last-token residual at layer L):
h_+(p) = residual at L on prompt p, last token
d_harmful_raw = mean(h_+(P_harmful)) − mean(h_+(P_harmless))
d_over_raw = mean(h_+(P_over_refusal)) − mean(h_+(P_harmless))
d_over = d_over_raw / ‖d_over_raw‖
d_actually_harmful = d_harmful_raw − (d_harmful_raw · d_over) d_over
d_actually_harmful = d_actually_harmful / ‖d_actually_harmful‖
Ablation: standard projection-removal h − (h·d̂) d̂ with d̂ =
d_actually_harmful at L.
Because Maskey requires THREE prompt sets but the base Technique.fit
contract takes only two (harmful, harmless), this class exposes a
separate `set_over_refusal(prompts)` method that the runner must call
BEFORE fit(). fit() raises RuntimeError if it hasn't been called.
The runner in refusal_bench/runner.py is currently structured around the
two-set contract; updating it to plumb over-refusal prompts is a
follow-up PR. Until then this class is wired in and self-contained but
can't be invoked from the bench loop without that runner change.
"""
from typing import Callable, List, Optional, Tuple
import torch
from model import get_model # noqa: F401 — imported for parity with sibling techniques
from research import apply_chat_template, make_ablation_hook
from ..technique import Technique
class Maskey(Technique):
"""Decomposed single-direction ablation: harmful minus over-refusal component."""
name = "Maskey (over-refusal decomposition)"
paper_url = "https://arxiv.org/abs/2603.27518"
def __init__(self) -> None:
super().__init__()
self._over_refusal_prompts: Optional[List[str]] = None
self._unit_direction: torch.Tensor | None = None
# Diagnostics for write-up / debugging.
self._harmful_raw_norm: float = 0.0
self._over_raw_norm: float = 0.0
self._actually_harmful_raw_norm: float = 0.0
self._cos_harmful_over: float = 0.0
# ------------------------------------------------------------------
# Three-set extension to the base contract
# ------------------------------------------------------------------
def set_over_refusal(self, prompts: List[str]) -> None:
"""
Store the over-refusal prompt set. Must be called BEFORE fit().
Prompts come from XSTest (2308.01263) or equivalent — benign
questions whose surface lexicon (kill, shoot, attack, etc.)
triggers over-cautious refusal in instruction-tuned models.
Population happens via backend/over_refusal_pairs.py, populated
in turn by backend/scripts/build_over_refusal_pairs.py.
"""
if not prompts:
raise ValueError(
"Maskey.set_over_refusal: need at least 1 over-refusal prompt"
)
self._over_refusal_prompts = list(prompts)
# ------------------------------------------------------------------
# Contract
# ------------------------------------------------------------------
def fit(
self,
model,
harmful_prompts: List[str],
harmless_prompts: List[str],
layer: int,
) -> None:
"""
Extract two raw difference-of-means directions (harmful and
over-refusal, each against the shared harmless baseline), project
the over-refusal direction out of the harmful direction, and store
the renormalized residual as the ablation direction.
"""
if self._over_refusal_prompts is None:
raise RuntimeError(
"Maskey.fit: set_over_refusal(prompts) must be called before "
"fit(). Maskey requires three prompt sets — harmful, harmless, "
"and over-refusal — but the base Technique.fit contract only "
"passes the first two. See class docstring."
)
if not harmful_prompts or not harmless_prompts:
raise ValueError(
"Maskey.fit: need at least 1 prompt per class (harmful + harmless)"
)
device = model.cfg.device
def last_token_resid(prompt: str) -> torch.Tensor:
formatted = apply_chat_template(prompt)
_logits, cache = model.run_with_cache(formatted)
resid = cache[f"blocks.{layer}.hook_resid_post"]
return resid[:, -1, :].squeeze(0).detach()
# Stage 1: per-class mean last-token residuals at L.
harmful_mean = torch.stack(
[last_token_resid(p) for p in harmful_prompts]
).mean(dim=0)
harmless_mean = torch.stack(
[last_token_resid(p) for p in harmless_prompts]
).mean(dim=0)
over_mean = torch.stack(
[last_token_resid(p) for p in self._over_refusal_prompts]
).mean(dim=0)
# Stage 2: two raw difference-of-means directions sharing harmless baseline.
d_harmful_raw = harmful_mean - harmless_mean
d_over_raw = over_mean - harmless_mean
harmful_raw_norm = float(d_harmful_raw.norm().item())
over_raw_norm = float(d_over_raw.norm().item())
if harmful_raw_norm < 1e-8:
raise RuntimeError(
"Maskey: harmful raw direction has near-zero norm; "
"harmful vs harmless sets are too similar to extract a direction."
)
if over_raw_norm < 1e-8:
raise RuntimeError(
"Maskey: over-refusal raw direction has near-zero norm; "
"over-refusal vs harmless sets are too similar to extract a direction."
)
# Stage 3: subtract the over-refusal component from the harmful direction.
# d_over = d_over_raw / ‖d_over_raw‖
# d_actually_harmful = d_harmful_raw − (d_harmful_raw · d_over) d_over
d_over = d_over_raw / over_raw_norm
projection_scalar = float(torch.dot(d_harmful_raw, d_over).item())
d_actually_harmful = d_harmful_raw - projection_scalar * d_over
# Stage 4: renormalize. If harmful and over-refusal are nearly collinear
# the residual collapses — surface that explicitly rather than producing
# a numerically degenerate unit vector.
actually_norm = d_actually_harmful.norm()
if actually_norm.item() < 1e-8:
raise RuntimeError(
"Maskey: actually-harmful residual collapsed after subtracting "
"the over-refusal component. The harmful direction is (within "
"numerical tolerance) a scalar multiple of the over-refusal "
"direction — i.e. for this prompt set the decomposition is "
"degenerate. Check that the harmful and over-refusal sets are "
"drawn from distinct sources (JailbreakBench vs XSTest)."
)
self._unit_direction = (d_actually_harmful / actually_norm).to(device)
self._layer = layer
# Diagnostics — handy when writing up which fraction of the harmful
# signal was actually over-refusal flavor.
self._harmful_raw_norm = harmful_raw_norm
self._over_raw_norm = over_raw_norm
self._actually_harmful_raw_norm = float(actually_norm.item())
self._cos_harmful_over = projection_scalar / (
max(harmful_raw_norm, 1e-12) * 1.0 # d_over is unit
)
self._fitted = True
def make_ablation_hook(self) -> Tuple[str, Callable]:
if not self._fitted or self._unit_direction is None or self._layer is None:
raise RuntimeError("Maskey.make_ablation_hook called before fit()")
return (
f"blocks.{self._layer}.hook_resid_post",
make_ablation_hook(self._unit_direction),
)