""" Activation Patching — causal localization of behavior over model components. Activation patching runs the model on a BASE prompt while splicing in cached activations from a SOURCE prompt at one site (layer × position, or layer × head), then measures how much a behavioral metric moves. Sweeping sites yields a map of where the computation that distinguishes the two prompts lives. The two directions answer different questions and can disagree; both are exposed and neither is labelled "the circuit": - **denoising** (patch clean → corrupted run): does restoring this site SUFFICE to recover the clean behavior? - **noising** (patch corrupted → clean run): is this site NECESSARY — does corrupting it alone destroy the clean behavior? Metric: logit difference between two single-token answers at the final position (Wang et al. 2022, IOI). `normalized` rescales it so 0 = the base run's own value and 1 = the source run's value; in denoising 1 means full restoration, in noising 1 means full destruction. Deliberately NOT clamped: values outside [0, 1] mean the patch overshot or backfired, which is the surprising result worth seeing. Like logit_lens / attention, this probes raw representations — prompts are used as-is, with no chat template. """ from typing import Any, Callable, Dict, List, Optional import torch import torch.nn.functional as F from model import get_model VALID_DIRECTIONS = ("denoising", "noising") VALID_COMPONENTS = ("resid_post", "head_z") # ----------------------------------------------------------------------------- # Metric helpers # ----------------------------------------------------------------------------- def logit_diff(final_logits: torch.Tensor, answer_id: int, baseline_id: int) -> float: """logits[answer] - logits[baseline] at one position. final_logits: [d_vocab].""" return float((final_logits[answer_id] - final_logits[baseline_id]).item()) def kl_divergence(p_logits: torch.Tensor, q_logits: torch.Tensor) -> float: """KL(P || Q) in nats between the next-token distributions of two logit vectors.""" log_p = F.log_softmax(p_logits, dim=-1) log_q = F.log_softmax(q_logits, dim=-1) return float(torch.sum(log_p.exp() * (log_p - log_q)).item()) def normalized_recovery(patched: float, base: float, source: float) -> Optional[float]: """ Rescale a patched metric: 0 = base run's value, 1 = source run's value. Returns None when |source - base| is numerically zero — the two runs don't disagree on the metric, so "fraction of the gap crossed" is undefined. """ denom = source - base if abs(denom) < 1e-9: return None return (patched - base) / denom # ----------------------------------------------------------------------------- # Core: token-level patching sweep # ----------------------------------------------------------------------------- def _make_position_patch_hook(source_act: torch.Tensor, pos: int) -> Callable: """Hook that overwrites one position of the residual stream with `source_act`.""" def hook_fn(activation, hook): # activation: [batch, seq_len, d_model] activation[:, pos, :] = source_act[pos, :] return activation return hook_fn def _make_head_patch_hook(source_z: torch.Tensor, head: int) -> Callable: """Hook that overwrites one head's output (all positions) with `source_z`.""" def hook_fn(activation, hook): # activation: [batch, seq_len, n_heads, d_head] activation[:, :, head, :] = source_z[:, head, :] return activation return hook_fn def patch_grid( base_tokens: torch.Tensor, source_tokens: torch.Tensor, answer_id: int, baseline_id: int, component: str = "resid_post", layers: Optional[List[int]] = None, positions: Optional[List[int]] = None, heads: Optional[List[int]] = None, max_runs: Optional[int] = None, ) -> Dict[str, Any]: """ Sweep single-site patches of `source_tokens` activations into `base_tokens` runs. The metric at every cell is logit_diff(answer_id, baseline_id) at the final position. Returns raw per-cell metrics plus both unpatched baselines; direction semantics (which prompt is base vs source) are the caller's. Raises ValueError on shape mismatch, out-of-range sites, or a sweep larger than `max_runs` (each cell is a full forward pass — callers exposing this publicly should cap it). """ model = get_model() if base_tokens.shape != source_tokens.shape: raise ValueError( f"base and source prompts must tokenize to the same shape; got " f"{tuple(base_tokens.shape)} vs {tuple(source_tokens.shape)}. " f"Pick prompts that differ only in same-token-length spans." ) seq_len = base_tokens.shape[1] if component not in VALID_COMPONENTS: raise ValueError(f"component must be one of {VALID_COMPONENTS}, got {component!r}") n_layers, n_heads = model.cfg.n_layers, model.cfg.n_heads layers = list(range(n_layers)) if layers is None else list(layers) for layer in layers: if not 0 <= layer < n_layers: raise ValueError(f"layer {layer} out of range for n_layers={n_layers}") if component == "resid_post": positions = list(range(seq_len)) if positions is None else [ p if p >= 0 else seq_len + p for p in positions ] for pos in positions: if not 0 <= pos < seq_len: raise ValueError(f"position {pos} out of range for seq_len={seq_len}") cols = positions else: heads = list(range(n_heads)) if heads is None else list(heads) for head in heads: if not 0 <= head < n_heads: raise ValueError(f"head {head} out of range for n_heads={n_heads}") cols = heads n_runs = len(layers) * len(cols) if max_runs is not None and n_runs > max_runs: raise ValueError( f"sweep of {len(layers)} layers x {len(cols)} sites = {n_runs} forward " f"passes exceeds the cap of {max_runs}; restrict layers/positions/heads" ) hook_of_layer = ( (lambda layer: f"blocks.{layer}.hook_resid_post") if component == "resid_post" else (lambda layer: f"blocks.{layer}.attn.hook_z") ) wanted = {hook_of_layer(layer) for layer in layers} with torch.no_grad(): source_logits, source_cache = model.run_with_cache( source_tokens, names_filter=lambda name: name in wanted ) base_logits = model(base_tokens) base_ld = logit_diff(base_logits[0, -1], answer_id, baseline_id) source_ld = logit_diff(source_logits[0, -1], answer_id, baseline_id) rows = [] for layer in layers: hook_name = hook_of_layer(layer) source_act = source_cache[hook_name][0] # [seq, d_model] or [seq, n_heads, d_head] cells = [] for col in cols: if component == "resid_post": hook_fn = _make_position_patch_hook(source_act, col) cell_key = "position" else: hook_fn = _make_head_patch_hook(source_act, col) cell_key = "head" with torch.no_grad(): patched_logits = model.run_with_hooks( base_tokens, fwd_hooks=[(hook_name, hook_fn)] ) final = patched_logits[0, -1] patched_ld = logit_diff(final, answer_id, baseline_id) cells.append({ cell_key: col, "logit_diff": round(patched_ld, 6), "normalized": _round_opt(normalized_recovery(patched_ld, base_ld, source_ld)), "kl_from_base": round(kl_divergence(final, base_logits[0, -1]), 6), "kl_to_source": round(kl_divergence(final, source_logits[0, -1]), 6), }) rows.append({"layer": layer, "cells": cells}) return { "component": component, "layers": layers, ("positions" if component == "resid_post" else "heads"): cols, "base_logit_diff": round(base_ld, 6), "source_logit_diff": round(source_ld, 6), "grid": rows, } def _round_opt(x: Optional[float]) -> Optional[float]: return None if x is None else round(x, 6) # ----------------------------------------------------------------------------- # String-level wrapper (API surface) # ----------------------------------------------------------------------------- def _resolve_single_token(answer: str) -> int: """Map an answer string to exactly one token id, or fail with a usable message.""" model = get_model() try: return int(model.to_single_token(answer)) except Exception: try: pieces = model.to_str_tokens(answer, prepend_bos=False) detail = f" (splits into {pieces})" except Exception: detail = "" raise ValueError( f"answer {answer!r} is not a single token for this tokenizer" f"{detail}; pick a single-token answer — for GPT-2 that usually " f"means a leading space, e.g. ' Paris'" ) def run_activation_patching( clean_prompt: str, corrupted_prompt: str, clean_answer: str, corrupted_answer: str, direction: str = "denoising", component: str = "resid_post", layers: Optional[List[int]] = None, positions: Optional[List[int]] = None, heads: Optional[List[int]] = None, max_runs: Optional[int] = None, ) -> Dict[str, Any]: """ Full activation-patching sweep between a clean/corrupted prompt pair. The metric is always logit_diff = logits[clean_answer] - logits[corrupted_answer] at the final position, so `clean_logit_diff` should be positive and `corrupted_logit_diff` negative (or at least smaller) when the pair is well-formed; a note is attached when it isn't. direction: "denoising" — base = corrupted run, source = clean activations. "noising" — base = clean run, source = corrupted activations. """ if direction not in VALID_DIRECTIONS: raise ValueError(f"direction must be one of {VALID_DIRECTIONS}, got {direction!r}") model = get_model() clean_tokens = model.to_tokens(clean_prompt) corrupted_tokens = model.to_tokens(corrupted_prompt) answer_id = _resolve_single_token(clean_answer) baseline_id = _resolve_single_token(corrupted_answer) if answer_id == baseline_id: raise ValueError( f"clean_answer and corrupted_answer resolve to the same token id " f"({answer_id}); the logit-diff metric would be identically zero" ) if direction == "denoising": base_tokens, source_tokens = corrupted_tokens, clean_tokens else: base_tokens, source_tokens = clean_tokens, corrupted_tokens result = patch_grid( base_tokens, source_tokens, answer_id, baseline_id, component=component, layers=layers, positions=positions, heads=heads, max_runs=max_runs, ) # Re-express the direction-relative baselines in clean/corrupted terms. if direction == "denoising": corrupted_ld, clean_ld = result["base_logit_diff"], result["source_logit_diff"] else: clean_ld, corrupted_ld = result["base_logit_diff"], result["source_logit_diff"] notes = [] if clean_ld <= corrupted_ld: notes.append( "clean prompt does not favor clean_answer over corrupted_answer " f"(clean logit_diff {clean_ld} <= corrupted {corrupted_ld}); " "check the answers aren't swapped" ) return { "direction": direction, "clean_prompt": clean_prompt, "corrupted_prompt": corrupted_prompt, "clean_answer": clean_answer, "corrupted_answer": corrupted_answer, "tokens": model.to_str_tokens(base_tokens[0]), "clean_logit_diff": clean_ld, "corrupted_logit_diff": corrupted_ld, "notes": notes, **result, }