AQIT 0.1.0
Loading...
Searching...
No Matches
activation_replay.py
Go to the documentation of this file.
1#!/usr/bin/env python3
2# Copyright (c) 2025-present Aquin Labs Private Limited. All Rights Reserved.
3"""Replay/query saved activation captures by metadata."""
4
5from __future__ import annotations
6
7import json
8from collections import defaultdict
9from pathlib import Path
10from typing import Any
11
12import torch
13
14
15def _read_json(path: Path) -> dict[str, Any]:
16 data = json.loads(path.read_text(encoding="utf-8"))
17 if not isinstance(data, dict):
18 raise ValueError(f"Expected JSON object in {path}")
19 return data
20
21
22def load_capture_root(path: str | Path) -> dict[str, Any]:
23 root = Path(path).expanduser().resolve()
24 if root.is_file():
25 root = root.parent
26 manifest_path = root / "manifest.json"
27 metadata_path = root / "metadata.json"
28 summary_path = root / "summary.jsonl"
29 if metadata_path.is_file():
30 meta = _read_json(metadata_path)
31 if manifest_path.is_file():
32 man = _read_json(manifest_path)
33 files = man.get("files") if isinstance(man.get("files"), dict) else {}
34 meta_files = meta.get("files") if isinstance(meta.get("files"), dict) else {}
35 if files or meta_files:
36 merged = {**files, **meta_files}
37 if isinstance(files.get("activations"), dict) and isinstance(meta_files.get("activations"), dict):
38 merged["activations"] = {**files["activations"], **meta_files["activations"]}
39 meta["files"] = merged
40 if not meta.get("probes") and man.get("probes"):
41 meta["probes"] = man["probes"]
42 if not meta.get("sae_features") and man.get("sae_features"):
43 meta["sae_features"] = man["sae_features"]
44 elif manifest_path.is_file():
45 meta = _read_json(manifest_path)
46 else:
47 raise FileNotFoundError(f"No metadata.json or manifest.json in {root}")
48
49 rows: list[dict[str, Any]] = []
50 if summary_path.is_file():
51 for line in summary_path.read_text(encoding="utf-8").splitlines():
52 line = line.strip()
53 if not line:
54 continue
55 row = json.loads(line)
56 if isinstance(row, dict):
57 rows.append(row)
58 return {
59 "root": root,
60 "metadata_path": metadata_path if metadata_path.is_file() else manifest_path,
61 "manifest": meta,
62 "rows": rows,
63 }
64
65
66def _flatten_probe_row(row: dict[str, Any], probe_lookup: dict[str, dict[str, Any]]) -> dict[str, Any]:
67 probe_id = str(row.get("probe_id") or "")
68 probe = probe_lookup.get(probe_id, {})
69 probe_meta = probe.get("metadata") if isinstance(probe.get("metadata"), dict) else {}
70 row_meta = row.get("metadata") if isinstance(row.get("metadata"), dict) else {}
71 merged_meta = {**probe_meta, **row_meta}
72 flat: dict[str, Any] = {
73 "probe_id": probe_id,
74 "layer": row.get("layer"),
75 "d_model": row.get("d_model"),
76 "text": probe.get("text"),
77 }
78 for key, val in merged_meta.items():
79 flat[key] = val
80 return flat
81
82
83def _parse_filter(expr: str | None) -> list[tuple[str, str]]:
84 if not expr:
85 return []
86 parts: list[tuple[str, str]] = []
87 for chunk in expr.split(","):
88 chunk = chunk.strip()
89 if not chunk:
90 continue
91 if "=" not in chunk:
92 raise ValueError(f"Bad filter {chunk!r}; use key=value,key2=value2")
93 key, value = chunk.split("=", 1)
94 parts.append((key.strip(), value.strip()))
95 return parts
96
97
98def _matches(row: dict[str, Any], filters: list[tuple[str, str]]) -> bool:
99 for key, expected in filters:
100 got = row.get(key)
101 if got is None:
102 return False
103 if str(got) != expected:
104 return False
105 return True
106
107
109 path: str | Path,
110 *,
111 filter_expr: str | None = None,
112 group_by: str | None = None,
113 limit: int | None = None,
114) -> dict[str, Any]:
115 loaded = load_capture_root(path)
116 manifest = loaded["manifest"]
117 probes = manifest.get("probes") if isinstance(manifest.get("probes"), list) else []
118 probe_lookup = {
119 str(p.get("id")): p for p in probes
120 if isinstance(p, dict) and p.get("id") is not None
121 }
122 rows = [_flatten_probe_row(r, probe_lookup) for r in loaded["rows"]]
123 filters = _parse_filter(filter_expr)
124 filtered = [r for r in rows if _matches(r, filters)]
125 if limit is not None and limit >= 0:
126 filtered = filtered[:limit]
127
128 result: dict[str, Any] = {
129 "root": str(loaded["root"]),
130 "metadata_path": str(loaded["metadata_path"]),
131 "capture_id": manifest.get("capture_id"),
132 "model_id": manifest.get("model_id"),
133 "model_mode": manifest.get("model_mode"),
134 "n_rows": len(rows),
135 "n_filtered": len(filtered),
136 "filter": filter_expr,
137 "group_by": group_by,
138 "rows": filtered,
139 }
140 if group_by:
141 groups: dict[str, list[dict[str, Any]]] = defaultdict(list)
142 for row in filtered:
143 groups[str(row.get(group_by, "unknown"))].append(row)
144 summary = []
145 for key, items in sorted(groups.items(), key=lambda kv: (-len(kv[1]), kv[0])):
146 layer_counts: dict[str, int] = defaultdict(int)
147 for item in items:
148 layer_counts[str(item.get("layer", "?"))] += 1
149 summary.append({
150 "group": key,
151 "count": len(items),
152 "layers": dict(sorted(layer_counts.items(), key=lambda kv: int(kv[0]) if kv[0].isdigit() else 999999)),
153 })
154 result["groups"] = summary
155 return result
156
157
158COHORT_FIELDS = ("group", "label", "lang", "stressor", "source")
159
160
161def _probe_groups(probes: list[dict[str, Any]], field: str) -> dict[str, list[int]]:
162 grouped: dict[str, list[int]] = defaultdict(list)
163 for i, probe in enumerate(probes):
164 if not isinstance(probe, dict):
165 continue
166 meta = probe.get("metadata") if isinstance(probe.get("metadata"), dict) else {}
167 grouped[str(meta.get(field, "unknown"))].append(i)
168 return grouped
169
170
171def _usable_groups(grouped: dict[str, list[int]]) -> bool:
172 keys = [k for k, idxs in grouped.items() if idxs]
173 return len(keys) >= 2
174
177 probes: list[dict[str, Any]],
178 requested: str,
179) -> tuple[str, dict[str, list[int]], bool]:
180 """Pick a metadata field with ≥2 cohorts. Last resort: split probes in half."""
181 grouped = _probe_groups(probes, requested)
182 if _usable_groups(grouped):
183 return requested, grouped, False
184 for field in COHORT_FIELDS:
185 if field == requested:
186 continue
187 candidate = _probe_groups(probes, field)
188 if _usable_groups(candidate):
189 return field, candidate, False
190 n = len(probes)
191 if n < 2:
192 raise ValueError(
193 f"Need at least 2 groups for {requested!r}; found {len(grouped)}. "
194 "Recapture with labeled probes (metadata group/label/lang)."
195 )
196 mid = max(1, n // 2)
197 inferred = {"a": list(range(0, mid)), "b": list(range(mid, n))}
198 return requested, inferred, True
199
200
201def _write_json(path: Path, data: dict[str, Any]) -> None:
202 path.write_text(json.dumps(data, indent=2), encoding="utf-8")
203
204
206 path: str | Path,
207 *,
208 sae_layer: int | None = None,
209 sae: Any | None = None,
210) -> dict[str, Any]:
211 """Encode residual activations in a capture with the SAE for that layer."""
212 loaded = load_capture_root(path)
213 manifest = loaded["manifest"]
214 root = loaded["root"]
215 files = manifest.get("files") if isinstance(manifest.get("files"), dict) else {}
216 sae_rel = files.get("sae_features")
217 if sae_rel and (root / str(sae_rel)).is_file():
218 return {
219 "root": str(root),
220 "encoded": False,
221 "sae_features": manifest.get("sae_features"),
222 }
223
224 if str(manifest.get("granularity") or "prompt") == "token":
225 raise ValueError("Cannot encode SAE features on a token-level capture.")
226
227 model_id = str(manifest.get("model_id") or "").strip()
228 if not model_id:
229 raise ValueError("Capture has no model_id; cannot encode SAE features.")
230
231 activations = files.get("activations") if isinstance(files.get("activations"), dict) else {}
232 enc_layer = int(sae_layer) if sae_layer is not None else None
233 if enc_layer is None:
234 try:
235 from aquin.compute.model_loader import get_sae_layer
236
237 enc_layer = int(get_sae_layer(model_id))
238 except Exception:
239 enc_layer = None
240 if enc_layer is None or str(enc_layer) not in activations:
241 try:
242 from aquin.compute.model_loader import get_available_sae_layers
243
244 for layer in get_available_sae_layers(model_id):
245 if str(layer) in activations:
246 enc_layer = int(layer)
247 break
248 except Exception:
249 pass
250 if enc_layer is None or str(enc_layer) not in activations:
251 raise ValueError(
252 "Capture has no residual activations for a loadable SAE layer. "
253 "Re-run activations.capture with encode_sae=true (pythia-70m: layer 3)."
254 )
255
256 resid_path = root / str(activations[str(enc_layer)])
257 if not resid_path.is_file():
258 raise FileNotFoundError(f"Missing residual activations: {resid_path}")
259 resid = torch.load(resid_path, map_location="cpu", weights_only=False)
260 if not isinstance(resid, torch.Tensor) or resid.dim() != 2:
261 raise ValueError(
262 f"Expected residual matrix [N, d], got {type(resid)} "
263 f"{tuple(resid.shape) if isinstance(resid, torch.Tensor) else ''}"
264 )
265
266 if sae is None:
267 from aquin.compute.feature_analysis import load_sae
268
269 sae = load_sae(model_id, layer=int(enc_layer))
270 from aquin.compute.feature_analysis import normalize
271
272 device = next(sae.parameters()).device
273 with torch.no_grad():
274 encoded = sae.encode(normalize(resid.to(device), model_id, int(enc_layer))).float().cpu()
275
276 sae_rel = f"sae/sae_layer_{int(enc_layer)}.pt"
277 (root / "sae").mkdir(parents=True, exist_ok=True)
278 torch.save(encoded, root / sae_rel)
279 sae_features = {
280 "layer": int(enc_layer),
281 "n_features": int(encoded.shape[1]),
282 "n_probes": int(encoded.shape[0]),
283 }
284 files = dict(files)
285 files["sae_features"] = sae_rel
286 manifest["files"] = files
287 manifest["sae_features"] = sae_features
288 manifest["encode_sae"] = True
289
290 meta_path = loaded["metadata_path"]
291 _write_json(meta_path, manifest)
292 other = root / ("manifest.json" if meta_path.name == "metadata.json" else "metadata.json")
293 if other.is_file():
294 other_data = _read_json(other)
295 other_files = other_data.get("files") if isinstance(other_data.get("files"), dict) else {}
296 other_files["sae_features"] = sae_rel
297 other_data["files"] = other_files
298 other_data["sae_features"] = sae_features
299 other_data["encode_sae"] = True
300 _write_json(other, other_data)
301
302 return {"root": str(root), "encoded": True, "sae_features": sae_features}
303
304
306 path: str | Path,
307 *,
308 group: str,
309 top_k: int = 10,
310 encode_sae: bool = True,
311 sae_layer: int | None = None,
312 sae: Any | None = None,
313) -> dict[str, Any]:
314 loaded = load_capture_root(path)
315 manifest = loaded["manifest"]
316 probes = manifest.get("probes") if isinstance(manifest.get("probes"), list) else []
317 files = manifest.get("files") if isinstance(manifest.get("files"), dict) else {}
318 sae_rel = files.get("sae_features")
319 sae_path = loaded["root"] / str(sae_rel) if sae_rel else None
320 if not sae_rel or sae_path is None or not sae_path.is_file():
321 if not encode_sae:
322 raise ValueError("Capture has no SAE feature matrix. Re-run capture with --encode-sae.")
323 encode_capture_sae_features(path, sae_layer=sae_layer, sae=sae)
324 loaded = load_capture_root(path)
325 manifest = loaded["manifest"]
326 probes = manifest.get("probes") if isinstance(manifest.get("probes"), list) else []
327 files = manifest.get("files") if isinstance(manifest.get("files"), dict) else {}
328 sae_rel = files.get("sae_features")
329 sae_path = loaded["root"] / str(sae_rel) if sae_rel else None
330 if not sae_rel or sae_path is None or not sae_path.is_file():
331 raise ValueError("Capture has no SAE feature matrix. Re-run capture with --encode-sae.")
332
333 feats = torch.load(sae_path, map_location="cpu", weights_only=False)
334 if feats.dim() != 2:
335 raise ValueError(f"Expected SAE features [N, F], got shape {tuple(feats.shape)}")
336 if probes and len(probes) != int(feats.shape[0]):
337 raise ValueError(f"Probe count {len(probes)} does not match SAE rows {int(feats.shape[0])}")
338 if not probes:
339 raise ValueError("Capture has no probes; features.compare needs a labeled activation capture.")
340
341 group, grouped, inferred = resolve_compare_group(probes, group)
342
343 group_means: dict[str, torch.Tensor] = {}
344 for key, idxs in grouped.items():
345 if not idxs:
346 continue
347 group_means[key] = feats[torch.tensor(idxs, dtype=torch.long)].mean(dim=0)
348
349 keys = sorted(group_means)
350 stacked = torch.stack([group_means[k] for k in keys], dim=0)
351 spread = stacked.max(dim=0).values - stacked.min(dim=0).values
352 k = min(int(top_k), int(spread.shape[0]))
353 top_idx = spread.topk(k).indices.tolist()
354
355 rows = []
356 for idx in top_idx:
357 per_group = {key: round(float(group_means[key][idx].item()), 6) for key in keys}
358 rows.append({
359 "feature_idx": int(idx),
360 "spread": round(float(spread[idx].item()), 6),
361 "groups": per_group,
362 })
363
364 return {
365 "root": str(loaded["root"]),
366 "capture_id": manifest.get("capture_id"),
367 "model_id": manifest.get("model_id"),
368 "group": group,
369 "n_groups": len(group_means),
370 "group_sizes": {key: len(grouped[key]) for key in keys},
371 "sae_layer": (manifest.get("sae_features") or {}).get("layer"),
372 "n_features": int(feats.shape[1]),
373 "top_k": k,
374 "group_inferred": inferred,
375 "features": rows,
376 }
dict[str, Any] encode_capture_sae_features(str|Path path, *, int|None sae_layer=None, Any|None sae=None)
bool _matches(dict[str, Any] row, list[tuple[str, str]] filters)
tuple[str, dict[str, list[int]], bool] resolve_compare_group(list[dict[str, Any]] probes, str requested)
dict[str, list[int]] _probe_groups(list[dict[str, Any]] probes, str field)
None _write_json(Path path, dict[str, Any] data)
dict[str, Any] _flatten_probe_row(dict[str, Any] row, dict[str, dict[str, Any]] probe_lookup)
dict[str, Any] load_capture_root(str|Path path)
dict[str, Any] _read_json(Path path)
dict[str, Any] replay_capture(str|Path path, *, str|None filter_expr=None, str|None group_by=None, int|None limit=None)
bool _usable_groups(dict[str, list[int]] grouped)
dict[str, Any] compare_capture_features(str|Path path, *, str group, int top_k=10, bool encode_sae=True, int|None sae_layer=None, Any|None sae=None)
list[tuple[str, str]] _parse_filter(str|None expr)