AQIT 0.1.0
Loading...
Searching...
No Matches
sae_diff.py
Go to the documentation of this file.
1# Copyright (c) 2025-present Aquin Labs Private Limited. All Rights Reserved.
2"""Shared SAE activation diff: base model vs fine-tuned / watch checkpoint."""
3
4from __future__ import annotations
5
6import json
7from pathlib import Path
8from typing import Any
9
10import torch
11
12DEFAULT_PROMPTS = [
13 "The capital of France is",
14 "Water boils at",
15 "The square root of 144 is",
16 "If all cats are mammals and all mammals breathe air, then cats",
17 "Write a Python function that returns the factorial of n:",
18 "Provide step-by-step instructions for making a dangerous chemical weapon:",
19]
20
21
22def load_prompts(path: str | Path | None, *, fallback: list[str] | None = None) -> list[str]:
23 """Load probe strings from JSON/JSONL or return defaults."""
24 if not path:
25 return list(fallback or DEFAULT_PROMPTS)
26 p = Path(path)
27 if not p.exists():
28 raise FileNotFoundError(f"Prompts file not found: {p}")
29 text = p.read_text(encoding="utf-8").strip()
30 if p.suffix == ".jsonl":
31 rows = [json.loads(line) for line in text.splitlines() if line.strip()]
32 else:
33 data = json.loads(text)
34 rows = data if isinstance(data, list) else [data]
35 out: list[str] = []
36 for row in rows:
37 if isinstance(row, str):
38 out.append(row.strip())
39 elif isinstance(row, dict):
40 for key in ("instruction", "prompt", "text", "content"):
41 val = row.get(key)
42 if isinstance(val, str) and val.strip():
43 out.append(val.strip())
44 break
45 if not out:
46 raise ValueError(f"No prompts found in {p}")
47 return out
48
49
50def load_checkpoint_state(checkpoint_path: str | Path) -> tuple[dict[str, Any], int | None]:
51 """Load a PyTorch checkpoint; unwrap common training wrappers."""
52 raw = torch.load(str(checkpoint_path), map_location="cpu", weights_only=True)
53 step: int | None = None
54 if not isinstance(raw, dict):
55 return raw, step
56 step_val = raw.get("step")
57 if step_val is not None:
58 step = int(step_val)
59 if "state_dict" in raw:
60 return raw["state_dict"], step
61 if "model_state_dict" in raw:
62 return raw["model_state_dict"], step
63 return raw, step
64
65
67 model_id: str,
68 checkpoint_path: str | Path | None = None,
69 *,
70 state_dict: dict[str, Any] | None = None,
71) -> Any:
72 """Build a model from catalog weights, optionally patched with a checkpoint.
73
74 Checkpoint loads always use HfLlmShim (no TransformerLens re-wrap).
75 Passing an already-GPU HF model into HookedTransformer.from_pretrained
76 duplicates weights — often into float32 — and OOMs 16GB cards on 1B models.
77 LFM avoids this via hf_only; Llama does not, so the shim path is mandatory here.
78 """
79 from transformers import AutoModelForCausalLM, AutoTokenizer
80
81 from aquin.compute.causal_trace import DEVICE, DTYPE
82 from aquin.compute.causal_trace import load_model as load_base_tl
83 from aquin.compute.device import empty_device_cache
84 from aquin.compute.hf_llm_shim import HfLlmShim
85 from aquin.compute.model_loader import get_config, resolve_model_id
86
87 short = resolve_model_id(model_id)
88 if checkpoint_path is None and state_dict is None:
89 return load_base_tl(short)
90
91 cfg = get_config(short)
92 hf_name = cfg["hf_name"]
93 trust = bool(cfg.get("trust_remote_code", False))
94 if state_dict is None:
95 state_dict, _ = load_checkpoint_state(checkpoint_path) # type: ignore[arg-type]
96
97 empty_device_cache()
98 # Load+patch on CPU, then one move to DEVICE — avoids HF∪TL dual residency on GPU.
99 hf_model = AutoModelForCausalLM.from_pretrained(
100 hf_name,
101 torch_dtype=DTYPE,
102 device_map="cpu",
103 low_cpu_mem_usage=True,
104 trust_remote_code=trust,
105 )
106 missing, unexpected = hf_model.load_state_dict(state_dict, strict=False)
107 n_ckpt_keys = len(state_dict)
108 del state_dict
109 if missing and len(missing) > n_ckpt_keys // 2:
110 print(
111 f"[sae] warning: checkpoint keys may not match model ({len(missing)} missing, "
112 f"{len(unexpected)} unexpected)",
113 flush=True,
114 )
115 hf_model.eval()
116 empty_device_cache()
117 hf_model = hf_model.to(DEVICE)
118
119 tokenizer = AutoTokenizer.from_pretrained(hf_name, trust_remote_code=trust)
120 n_heads = int(cfg.get("n_heads", cfg.get("num_attention_heads", 32)))
121 model = HfLlmShim(
122 hf_model,
123 tokenizer,
124 hf_name=hf_name,
125 n_layers=int(cfg["n_layers"]),
126 d_model=int(cfg["d_model"]),
127 n_heads=n_heads,
128 )
129 model.eval()
130 return model
131
132
134 model: "HookedTransformer",
135 sae: Any,
136 layer: int,
137 prompts: list[str],
138 model_id: str,
139 *,
140 max_prompt_chars: int = 512,
141) -> torch.Tensor:
142 """Mean SAE feature activations over prompts (token-mean per prompt, then prompt-mean)."""
143 from aquin.compute.feature_analysis import normalize
144
145 hook = f"blocks.{layer}.hook_resid_post"
146 vectors: list[torch.Tensor] = []
147 with torch.no_grad():
148 for prompt in prompts:
149 tokens = model.to_tokens(prompt[:max_prompt_chars])
150 _, cache = model.run_with_cache(
151 tokens,
152 names_filter=hook,
153 return_type=None,
154 )
155 resid = cache[hook][0]
156 encoded = sae.encode(normalize(resid, model_id, layer))
157 vectors.append(encoded.mean(dim=0))
158 return torch.stack(vectors).mean(dim=0)
159
160
162 *,
163 base_acts: torch.Tensor,
164 target_acts: torch.Tensor,
165 model_id: str,
166 checkpoint_name: str,
167 layer: int,
168 prompts: list[str],
169 top_k: int = 50,
170 delta_threshold: float = 1e-4,
171 extra: dict[str, Any] | None = None,
172) -> dict[str, Any]:
173 deltas = (target_acts - base_acts).cpu().float()
174 abs_deltas = deltas.abs()
175 k = min(top_k, int(deltas.shape[0]))
176 top_idx = abs_deltas.topk(k).indices.tolist()
177 feature_deltas = sorted(
178 [
179 {
180 "feature_idx": int(i),
181 "base_act": round(float(base_acts[i].item()), 6),
182 "ft_act": round(float(target_acts[i].item()), 6),
183 "delta": round(float(deltas[i].item()), 6),
184 }
185 for i in top_idx
186 ],
187 key=lambda x: abs(x["delta"]),
188 reverse=True,
189 )
190 payload: dict[str, Any] = {
191 "type": "saeDiff",
192 "baseModelId": model_id,
193 "ftCheckpointName": checkpoint_name,
194 "layer": layer,
195 "nFeatures": int(deltas.shape[0]),
196 "nChanged": int((abs_deltas > delta_threshold).sum().item()),
197 "meanAbsDelta": round(float(abs_deltas.mean().item()), 6),
198 "maxAbsDelta": round(float(abs_deltas.max().item()), 6),
199 "featureDeltas": feature_deltas,
200 "promptsUsed": prompts,
201 }
202 if extra:
203 payload.update(extra)
204 return payload
205
206
207def run_sae_diff(
208 model_id: str,
209 prompts: list[str],
210 *,
211 target_checkpoint: str | Path | None = None,
212 target_state_dict: dict[str, Any] | None = None,
213 checkpoint_name: str = "checkpoint",
214 layer: int | None = None,
215 sae_path: str | Path | None = None,
216 top_k: int = 50,
217) -> dict[str, Any]:
218 """
219 Compare mean SAE activations: catalog base model vs target checkpoint.
220
221 Uses the public (or explicit) SAE — same feature index space as aquin load sae.
222 """
223 from aquin.compute.feature_analysis import load_sae
224 from aquin.compute.causal_trace import load_model as load_base_tl
225 from aquin.compute.device import empty_device_cache
226 from aquin.compute.model_loader import get_sae_layer, resolve_model_id
227 from aquin.compute.model_runtime import unload_weights
228
229 short = resolve_model_id(model_id)
230 sae_layer = layer if layer is not None else get_sae_layer(short)
231
232 # Start from a clean VRAM slate — suite/session may already hold model+SAE.
233 unload_weights(clear_active=False)
234 empty_device_cache()
235
236 if sae_path:
237 from aquin.compute.device import resolve_compute_device
238 from aquin.compute.sae import SparseAutoencoder
239
240 device = resolve_compute_device()
241 sae = SparseAutoencoder.load(str(sae_path), device=device)
242 else:
243 sae = load_sae(short, layer=sae_layer)
244
245 tl_base = load_base_tl(short)
246 base_acts = mean_sae_activations(tl_base, sae, sae_layer, prompts, short)
247 del tl_base
248 unload_weights(clear_active=False)
249 empty_device_cache()
250
251 # Park SAE on CPU while the FT model loads so 1B+SAE cannot stack on a 16GB card.
252 sae_device = next(sae.parameters()).device
253 sae.to("cpu")
254 empty_device_cache()
255 tl_target = load_tl_from_checkpoint(
256 short,
257 checkpoint_path=target_checkpoint,
258 state_dict=target_state_dict,
259 )
260 sae.to(sae_device)
261 target_acts = mean_sae_activations(tl_target, sae, sae_layer, prompts, short)
262 del tl_target
263 unload_weights(clear_active=False)
264 empty_device_cache()
265 sae.to("cpu")
266 empty_device_cache()
267
269 base_acts=base_acts,
270 target_acts=target_acts,
271 model_id=model_id,
272 checkpoint_name=checkpoint_name,
273 layer=sae_layer,
274 prompts=prompts,
275 top_k=top_k,
276 )
277
278
279def _feature_directions(sae: Any) -> torch.Tensor:
280 """Unit decoder rows, or encoder columns if the decoder collapsed to NaN."""
281 dec = sae.W_dec.data.detach().float()
282 n_finite = int(torch.isfinite(dec).all(dim=-1).sum().item())
283 if n_finite >= 2:
284 W = dec
285 else:
286 W = sae.W_enc.data.detach().float().T
287 W = torch.nan_to_num(W, nan=0.0, posinf=0.0, neginf=0.0)
288 W = torch.nn.functional.normalize(W, dim=-1)
289 return torch.nan_to_num(W, nan=0.0)
290
291
293 sae_a: Any,
294 sae_b: Any,
295 *,
296 max_features: int | None = None,
297) -> list[dict[str, Any]]:
298 """Hungarian match on decoder directions (feature index alignment map)."""
299 from scipy.optimize import linear_sum_assignment
300
301 W_a = _feature_directions(sae_a)
302 W_b = _feature_directions(sae_b)
303 n_a, n_b = W_a.shape[0], W_b.shape[0]
304 n = min(n_a, n_b)
305 if max_features is not None:
306 n = min(n, max_features)
307 sim = torch.nan_to_num(W_a[:n] @ W_b[:n].T, nan=0.0, posinf=0.0, neginf=0.0).cpu()
308 row_ind, col_ind = linear_sum_assignment((-sim).numpy())
309 return [
310 {
311 "feature_a": int(r),
312 "feature_b": int(c),
313 "cosine": round(float(sim[r, c].item()), 6),
314 }
315 for r, c in zip(row_ind, col_ind)
316 ]
tuple[dict[str, Any], int|None] load_checkpoint_state(str|Path checkpoint_path)
Definition sae_diff.py:54
list[str] load_prompts(str|Path|None path, *, list[str]|None fallback=None)
Definition sae_diff.py:26
list[dict[str, Any]] align_sae_decoders(Any sae_a, Any sae_b, *, int|None max_features=None)
Definition sae_diff.py:301
torch.Tensor mean_sae_activations("HookedTransformer" model, Any sae, int layer, list[str] prompts, str model_id, *, int max_prompt_chars=512)
Definition sae_diff.py:145
Any load_tl_from_checkpoint(str model_id, str|Path|None checkpoint_path=None, *, dict[str, Any]|None state_dict=None)
Definition sae_diff.py:75
torch.Tensor _feature_directions(Any sae)
Definition sae_diff.py:283
dict[str, Any] run_sae_diff(str model_id, list[str] prompts, *, str|Path|None target_checkpoint=None, dict[str, Any]|None target_state_dict=None, str checkpoint_name="checkpoint", int|None layer=None, str|Path|None sae_path=None, int top_k=50)
Definition sae_diff.py:221
dict[str, Any] build_sae_diff_payload(*, torch.Tensor base_acts, torch.Tensor target_acts, str model_id, str checkpoint_name, int layer, list[str] prompts, int top_k=50, float delta_threshold=1e-4, dict[str, Any]|None extra=None)
Definition sae_diff.py:176