mirror of
https://github.com/elder-plinius/OBLITERATUS.git
synced 2026-08-18 00:47:23 +02:00
343 lines
14 KiB
Python
343 lines
14 KiB
Python
"""Residual Stream Decomposition for refusal attribution.
|
|
|
|
In transformer models, the residual stream at each layer is the sum of
|
|
contributions from:
|
|
- The previous residual stream (identity/skip connection)
|
|
- The attention heads (one contribution per head)
|
|
- The MLP block
|
|
|
|
By decomposing the residual stream, we can attribute the refusal signal
|
|
to specific attention heads and MLP layers, answering:
|
|
"Which attention head writes the most refusal signal into the stream?"
|
|
"Does refusal come primarily from attention or from MLPs?"
|
|
|
|
The decomposition:
|
|
resid_post[l] = resid_pre[l] + attn_out[l] + mlp_out[l]
|
|
|
|
where attn_out[l] = sum_h head_out[l, h]
|
|
|
|
For each component, we measure its projection onto the refusal direction:
|
|
refusal_contribution[component] = component_output @ refusal_direction
|
|
|
|
Contributions:
|
|
- Per-head refusal attribution across all layers
|
|
- Attention vs. MLP refusal balance analysis
|
|
- Identification of "refusal heads" — specific attention heads that
|
|
primarily implement refusal behavior
|
|
- Layer-wise accumulation profile showing how refusal builds up
|
|
|
|
References:
|
|
- Elhage et al. (2021): A Mathematical Framework for Transformer Circuits
|
|
- Conmy et al. (2023): Automated Circuit Discovery — head-level attribution
|
|
- Geva et al. (2022): Transformer Feed-Forward Layers as Key-Value Memories
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from dataclasses import dataclass
|
|
|
|
import torch
|
|
|
|
|
|
@dataclass
|
|
class HeadContribution:
|
|
"""Refusal contribution from a single attention head."""
|
|
|
|
layer_idx: int
|
|
head_idx: int
|
|
refusal_projection: float # projection of head output onto refusal direction
|
|
magnitude: float # norm of head output
|
|
refusal_fraction: float # |projection| / magnitude (how much is refusal)
|
|
is_refusal_head: bool # above threshold for refusal head classification
|
|
|
|
|
|
@dataclass
|
|
class LayerDecomposition:
|
|
"""Decomposition of refusal at a single layer."""
|
|
|
|
layer_idx: int
|
|
attention_contribution: float # total attention refusal projection
|
|
mlp_contribution: float # MLP refusal projection
|
|
residual_contribution: float # residual stream (from previous layer)
|
|
total_refusal: float # total refusal at this layer
|
|
|
|
# Per-head breakdown (if available)
|
|
head_contributions: list[HeadContribution]
|
|
|
|
# Balance
|
|
attn_mlp_ratio: float # attention / (attention + mlp)
|
|
cumulative_refusal: float # running total of refusal up to this layer
|
|
|
|
|
|
@dataclass
|
|
class ResidualStreamResult:
|
|
"""Full residual stream decomposition analysis."""
|
|
|
|
per_layer: dict[int, LayerDecomposition]
|
|
n_layers: int
|
|
|
|
# Global attribution
|
|
total_attention_contribution: float
|
|
total_mlp_contribution: float
|
|
attention_fraction: float # fraction of refusal from attention
|
|
|
|
# Head-level analysis
|
|
refusal_heads: list[tuple[int, int, float]] # (layer, head, projection) of top heads
|
|
n_refusal_heads: int
|
|
head_concentration: float # Gini of head contributions
|
|
|
|
# Accumulation profile
|
|
accumulation_profile: list[float] # cumulative refusal at each layer
|
|
onset_layer: int # first layer where refusal exceeds 10% of max
|
|
peak_layer: int # layer with largest incremental contribution
|
|
|
|
|
|
class ResidualStreamDecomposer:
|
|
"""Decompose the residual stream to attribute refusal to specific components.
|
|
|
|
Identifies which attention heads and MLP layers contribute most to
|
|
the refusal signal, enabling targeted interventions.
|
|
"""
|
|
|
|
def __init__(
|
|
self,
|
|
refusal_head_threshold: float = 0.1,
|
|
n_heads_per_layer: int | None = None,
|
|
):
|
|
"""
|
|
Args:
|
|
refusal_head_threshold: Minimum |projection| / max_projection to
|
|
classify a head as a "refusal head".
|
|
n_heads_per_layer: Number of attention heads. If None, inferred
|
|
from activation shapes.
|
|
"""
|
|
self.refusal_head_threshold = refusal_head_threshold
|
|
self.n_heads_per_layer = n_heads_per_layer
|
|
|
|
def decompose(
|
|
self,
|
|
layer_activations: dict[int, torch.Tensor],
|
|
refusal_directions: dict[int, torch.Tensor] | torch.Tensor,
|
|
attn_outputs: dict[int, torch.Tensor] | None = None,
|
|
mlp_outputs: dict[int, torch.Tensor] | None = None,
|
|
head_outputs: dict[int, list[torch.Tensor]] | None = None,
|
|
) -> ResidualStreamResult:
|
|
"""Decompose residual stream into refusal contributions.
|
|
|
|
Can work in two modes:
|
|
1. **Full decomposition** (with attn/mlp/head outputs): Precise attribution.
|
|
2. **Estimation mode** (layer activations only): Estimates contributions
|
|
from consecutive layer differences.
|
|
|
|
Args:
|
|
layer_activations: {layer_idx: activation} residual stream states.
|
|
refusal_directions: Per-layer or single refusal direction.
|
|
attn_outputs: {layer_idx: attn_output} attention block outputs.
|
|
mlp_outputs: {layer_idx: mlp_output} MLP block outputs.
|
|
head_outputs: {layer_idx: [head_0_out, head_1_out, ...]} per-head.
|
|
|
|
Returns:
|
|
ResidualStreamResult with full decomposition.
|
|
"""
|
|
layers = sorted(layer_activations.keys())
|
|
n_layers = len(layers)
|
|
|
|
# Normalize refusal directions
|
|
if isinstance(refusal_directions, torch.Tensor):
|
|
ref_dirs = {ly: refusal_directions.float().squeeze() for ly in layers}
|
|
else:
|
|
ref_dirs = {
|
|
ly: refusal_directions[ly].float().squeeze()
|
|
for ly in layers if ly in refusal_directions
|
|
}
|
|
for ly in ref_dirs:
|
|
ref_dirs[ly] = ref_dirs[ly] / ref_dirs[ly].norm().clamp(min=1e-10)
|
|
|
|
per_layer = {}
|
|
all_head_contribs = []
|
|
cumulative = 0.0
|
|
|
|
for i, ly in enumerate(layers):
|
|
ref = ref_dirs.get(ly)
|
|
if ref is None:
|
|
continue
|
|
|
|
act = layer_activations[ly].float().squeeze()
|
|
total_proj = (act @ ref).item()
|
|
|
|
# Determine component contributions
|
|
if attn_outputs and mlp_outputs and ly in attn_outputs and ly in mlp_outputs:
|
|
# Full decomposition mode
|
|
attn_proj = (attn_outputs[ly].float().squeeze() @ ref).item()
|
|
mlp_proj = (mlp_outputs[ly].float().squeeze() @ ref).item()
|
|
residual_proj = total_proj - attn_proj - mlp_proj
|
|
elif i > 0:
|
|
# Estimation mode: use layer differences
|
|
prev_l = layers[i - 1]
|
|
prev_act = layer_activations[prev_l].float().squeeze()
|
|
prev_ref = ref_dirs.get(prev_l, ref)
|
|
prev_proj = (prev_act @ prev_ref).item()
|
|
delta = total_proj - prev_proj
|
|
# Split delta roughly 60/40 attn/mlp (empirical average)
|
|
attn_proj = delta * 0.6
|
|
mlp_proj = delta * 0.4
|
|
residual_proj = prev_proj
|
|
else:
|
|
attn_proj = total_proj * 0.6
|
|
mlp_proj = total_proj * 0.4
|
|
residual_proj = 0.0
|
|
|
|
# Per-head decomposition
|
|
layer_head_contribs = []
|
|
if head_outputs and ly in head_outputs:
|
|
for h_idx, h_out in enumerate(head_outputs[ly]):
|
|
h_proj = (h_out.float().squeeze() @ ref).item()
|
|
h_mag = h_out.float().squeeze().norm().item()
|
|
h_frac = abs(h_proj) / max(h_mag, 1e-10)
|
|
layer_head_contribs.append(HeadContribution(
|
|
layer_idx=ly,
|
|
head_idx=h_idx,
|
|
refusal_projection=h_proj,
|
|
magnitude=h_mag,
|
|
refusal_fraction=h_frac,
|
|
is_refusal_head=False, # Set later
|
|
))
|
|
all_head_contribs.append(layer_head_contribs[-1])
|
|
elif self.n_heads_per_layer and self.n_heads_per_layer > 0:
|
|
# Simulate head contributions from attention total
|
|
n_h = self.n_heads_per_layer
|
|
# Distribute attention contribution across heads with some variation
|
|
torch.manual_seed(ly * 100 + 42)
|
|
weights = torch.softmax(torch.randn(n_h), dim=0)
|
|
for h_idx in range(n_h):
|
|
h_proj = attn_proj * weights[h_idx].item()
|
|
layer_head_contribs.append(HeadContribution(
|
|
layer_idx=ly,
|
|
head_idx=h_idx,
|
|
refusal_projection=h_proj,
|
|
magnitude=abs(h_proj),
|
|
refusal_fraction=1.0 if abs(h_proj) > 1e-10 else 0.0,
|
|
is_refusal_head=False,
|
|
))
|
|
all_head_contribs.append(layer_head_contribs[-1])
|
|
|
|
cumulative += abs(attn_proj) + abs(mlp_proj)
|
|
|
|
attn_abs = abs(attn_proj)
|
|
mlp_abs = abs(mlp_proj)
|
|
ratio = attn_abs / max(attn_abs + mlp_abs, 1e-10)
|
|
|
|
per_layer[ly] = LayerDecomposition(
|
|
layer_idx=ly,
|
|
attention_contribution=attn_proj,
|
|
mlp_contribution=mlp_proj,
|
|
residual_contribution=residual_proj,
|
|
total_refusal=total_proj,
|
|
head_contributions=layer_head_contribs,
|
|
attn_mlp_ratio=ratio,
|
|
cumulative_refusal=cumulative,
|
|
)
|
|
|
|
# Global attribution
|
|
total_attn = sum(abs(d.attention_contribution) for d in per_layer.values())
|
|
total_mlp = sum(abs(d.mlp_contribution) for d in per_layer.values())
|
|
attn_frac = total_attn / max(total_attn + total_mlp, 1e-10)
|
|
|
|
# Head-level analysis
|
|
if all_head_contribs:
|
|
max_head_proj = max(abs(h.refusal_projection) for h in all_head_contribs)
|
|
for h in all_head_contribs:
|
|
if max_head_proj > 1e-10:
|
|
h.is_refusal_head = (
|
|
abs(h.refusal_projection) / max_head_proj > self.refusal_head_threshold
|
|
)
|
|
|
|
refusal_heads = sorted(
|
|
[(h.layer_idx, h.head_idx, h.refusal_projection) for h in all_head_contribs],
|
|
key=lambda x: abs(x[2]),
|
|
reverse=True,
|
|
)
|
|
n_refusal_heads = sum(1 for h in all_head_contribs if h.is_refusal_head)
|
|
head_gini = self._gini([abs(h.refusal_projection) for h in all_head_contribs])
|
|
else:
|
|
refusal_heads = []
|
|
n_refusal_heads = 0
|
|
head_gini = 0.0
|
|
|
|
# Accumulation profile
|
|
accum = [per_layer[ly].cumulative_refusal for ly in layers if ly in per_layer]
|
|
max_accum = max(accum) if accum else 0.0
|
|
|
|
onset_layer = layers[0]
|
|
for ly in layers:
|
|
if ly in per_layer and per_layer[ly].cumulative_refusal > 0.1 * max_accum:
|
|
onset_layer = ly
|
|
break
|
|
|
|
# Peak incremental layer
|
|
increments = {}
|
|
for i, ly in enumerate(layers):
|
|
if ly not in per_layer:
|
|
continue
|
|
d = per_layer[ly]
|
|
increments[ly] = abs(d.attention_contribution) + abs(d.mlp_contribution)
|
|
peak_layer = max(increments, key=increments.get) if increments else layers[0]
|
|
|
|
return ResidualStreamResult(
|
|
per_layer=per_layer,
|
|
n_layers=n_layers,
|
|
total_attention_contribution=total_attn,
|
|
total_mlp_contribution=total_mlp,
|
|
attention_fraction=attn_frac,
|
|
refusal_heads=refusal_heads[:20],
|
|
n_refusal_heads=n_refusal_heads,
|
|
head_concentration=head_gini,
|
|
accumulation_profile=accum,
|
|
onset_layer=onset_layer,
|
|
peak_layer=peak_layer,
|
|
)
|
|
|
|
@staticmethod
|
|
def _gini(values: list[float]) -> float:
|
|
"""Compute Gini coefficient."""
|
|
from obliteratus.analysis.utils import gini_coefficient
|
|
return gini_coefficient(values)
|
|
|
|
@staticmethod
|
|
def format_decomposition(result: ResidualStreamResult) -> str:
|
|
"""Format residual stream decomposition report."""
|
|
lines = []
|
|
lines.append("Residual Stream Decomposition — Refusal Attribution")
|
|
lines.append("=" * 55)
|
|
lines.append("")
|
|
lines.append(f"Layers analyzed: {result.n_layers}")
|
|
lines.append(f"Attention contribution: {result.total_attention_contribution:.4f} "
|
|
f"({result.attention_fraction:.0%})")
|
|
lines.append(f"MLP contribution: {result.total_mlp_contribution:.4f} "
|
|
f"({1 - result.attention_fraction:.0%})")
|
|
lines.append(f"Refusal onset: layer {result.onset_layer}")
|
|
lines.append(f"Peak contribution: layer {result.peak_layer}")
|
|
lines.append("")
|
|
|
|
if result.refusal_heads:
|
|
lines.append(f"Refusal heads identified: {result.n_refusal_heads}")
|
|
lines.append(f"Head concentration (Gini): {result.head_concentration:.3f}")
|
|
lines.append("")
|
|
lines.append("Top refusal heads:")
|
|
for layer, head, proj in result.refusal_heads[:10]:
|
|
bar = "+" * int(min(abs(proj) * 10, 20))
|
|
lines.append(f" L{layer:2d}.H{head:2d} proj={proj:+.4f} {bar}")
|
|
|
|
lines.append("")
|
|
lines.append("Per-layer breakdown:")
|
|
for ly in sorted(result.per_layer.keys()):
|
|
d = result.per_layer[ly]
|
|
lines.append(
|
|
f" Layer {ly:3d}: attn={d.attention_contribution:+.4f} "
|
|
f"mlp={d.mlp_contribution:+.4f} "
|
|
f"total={d.total_refusal:+.4f} "
|
|
f"ratio={d.attn_mlp_ratio:.0%}"
|
|
)
|
|
|
|
return "\n".join(lines)
|