AQIT 0.1.0
Loading...
Searching...
No Matches
activation_capture.py
Go to the documentation of this file.
1# Copyright (c) 2025-present Aquin Labs Private Limited. All Rights Reserved.
2"""Batch activation capture for labeled probe sets (SAE training, diff, analysis)."""
3
4from __future__ import annotations
5
6import json
7import uuid
8from datetime import datetime, timezone
9from pathlib import Path
10from typing import Any, Literal
11
12import torch
13
14PROBE_TEXT_KEYS = ("instruction", "prompt", "text", "content", "response")
15RESERVED_PROBE_KEYS = frozenset({"id", *PROBE_TEXT_KEYS})
16
17Position = Literal["last", "mean"]
18Granularity = Literal["prompt", "token"]
19ModelMode = Literal["llm"]
20MAX_PROBE_COUNT = 64
21BALANCE_PRIORITY = ("label", "stressor", "lang", "group")
24def resolve_prompts_path(path: str | Path) -> Path | None:
25 """Resolve prompts file from cwd or repo parent (e.g. when run from cli/)."""
26 raw = Path(path)
27 if raw.is_file():
28 return raw.resolve()
29 candidates = [
30 Path.cwd() / raw,
31 Path.cwd().parent / raw,
32 Path.cwd().parent.parent / raw,
33 ]
34 for candidate in candidates:
35 if candidate.is_file():
36 return candidate.resolve()
37 return None
38
39
40def write_probes_jsonl(path: Path, probes: list[dict[str, Any]]) -> Path:
41 path.parent.mkdir(parents=True, exist_ok=True)
42 with path.open("w", encoding="utf-8") as fh:
43 for probe in probes:
44 row = {"id": probe["id"], "instruction": probe["text"], **(probe.get("metadata") or {})}
45 fh.write(json.dumps(row, ensure_ascii=False) + "\n")
46 return path
47
48
49def _balance_key(probe: dict[str, Any], group: str | None = None) -> str | None:
50 meta = probe.get("metadata") if isinstance(probe.get("metadata"), dict) else {}
51 if group:
52 val = meta.get(group)
53 if val is not None and str(val).strip():
54 return f"{group}:{val}"
55 return None
56 for key in BALANCE_PRIORITY:
57 val = meta.get(key)
58 if val is not None and str(val).strip():
59 return f"{key}:{val}"
60 return None
61
62
64 probes: list[dict[str, Any]],
65 *,
66 group: str | None = None,
67) -> tuple[list[dict[str, Any]], dict[str, Any]]:
68 groups: dict[str, list[dict[str, Any]]] = {}
69 for probe in probes:
70 key = _balance_key(probe, group=group)
71 if key is None:
72 return probes, {
73 "balanced": False,
74 "balance_reason": "no_metadata_groups",
75 **({"balance_group": group} if group else {}),
76 }
77 groups.setdefault(key, []).append(probe)
78 if len(groups) <= 1:
79 return probes, {
80 "balanced": False,
81 "balance_reason": "single_group",
82 "balance_groups": len(groups),
83 **({"balance_group": group} if group else {}),
84 }
85
86 target = min(len(items) for items in groups.values())
87 balanced: list[dict[str, Any]] = []
88 group_counts: dict[str, int] = {}
89 for key in sorted(groups):
90 kept = groups[key][:target]
91 balanced.extend(kept)
92 group_counts[key] = len(kept)
93 return balanced, {
94 "balanced": True,
95 "balance_groups": len(groups),
96 "balance_per_group": target,
97 "balance_counts": group_counts,
98 **({"balance_group": group} if group else {}),
99 }
100
101
103 model_id: str,
104 count: int,
105 *,
106 topic: str = "general knowledge, reasoning, and instructions",
107) -> list[dict[str, Any]]:
108 """Use the loaded LLM to generate diverse probe strings."""
109 from aquin.compute.causal_trace import run_chat
110 from aquin.compute.model_loader import get_loaded_model, load_model
111 from aquin.compute.sae_diff import DEFAULT_PROMPTS
112
113 model = get_loaded_model() or load_model(model_id)
114 n = max(1, min(int(count), MAX_PROBE_COUNT))
115 topic = " ".join(str(topic).split()) or "general knowledge"
116
117 prompt = (
118 f"Generate exactly {n} diverse short English probe prompts for language model "
119 f"interpretability testing. Mix factual completions, reasoning, and instruction-style "
120 f"prompts. Theme: {topic}. Output one probe per line. No numbering, bullets, or explanations."
121 )
122 text = run_chat(
123 prompt,
124 model_id=model_id,
125 max_new_tokens=min(n * 28, 900),
126 temperature=0.85,
127 model=model,
128 )
129
130 lines: list[str] = []
131 for raw in text.splitlines():
132 line = raw.strip().lstrip("-•*0123456789.) ").strip('"').strip("'").strip()
133 if len(line) < 8 or line.lower().startswith(("here are", "sure", "generate", "probe")):
134 continue
135 lines.append(line[:512])
136
137 probes: list[dict[str, Any]] = [
138 {
139 "id": f"g{i}",
140 "text": line,
141 "metadata": {"source": "generated", "topic": topic, "group": "factual" if i % 2 == 0 else "instruction"},
142 }
143 for i, line in enumerate(lines[:n])
144 ]
145
146 if len(probes) < n:
147 for j, fallback in enumerate(DEFAULT_PROMPTS):
148 if len(probes) >= n:
149 break
150 probes.append({
151 "id": f"d{j}",
152 "text": fallback,
153 "metadata": {"source": "default", "topic": topic, "group": "factual" if j % 2 == 0 else "instruction"},
154 })
155
156 print(f"[capture] generated {len(probes[:n])} LLM probes (topic={topic!r})", flush=True)
157 return probes[:n]
158
159
160
162 *,
163 model_id: str,
164 model_mode: ModelMode,
165 prompts_path: str | Path | None,
166 count: int,
167 topic: str | None,
168 balance: bool = False,
169 balance_group: str | None = None,
170 output_dir: Path | None = None,
171) -> tuple[list[dict[str, Any]], dict[str, Any]]:
172 """Load probes from file or generate when --prompts omitted."""
173 meta: dict[str, Any] = {
174 "probes_source": "file",
175 "requested_count": count,
176 "topic": topic,
177 }
178
179 if prompts_path:
180 resolved = resolve_prompts_path(prompts_path)
181 if resolved is None:
182 raise FileNotFoundError(
183 f"Prompts file not found: {prompts_path} "
184 f"(cwd={Path.cwd()}). Omit --prompts to auto-generate with --count and --topic."
185 )
186 meta["prompts_path"] = str(resolved)
187 probes = load_probes(resolved)
188 if count and len(probes) > count:
189 probes = probes[:count]
190 if balance:
191 probes, bal_meta = balance_probes(probes, group=balance_group)
192 meta.update(bal_meta)
193 return probes, meta
194
195 theme = topic or "general knowledge, reasoning, and instructions"
196 meta["probes_source"] = "generated"
197 probes = generate_llm_probes(model_id, count, topic=theme)
198
199 if output_dir is not None:
200 out_path = write_probes_jsonl(output_dir / "probes.jsonl", probes)
201 meta["generated_probes_path"] = str(out_path)
202 print(f"[capture] wrote {out_path}", flush=True)
203
204 if balance:
205 probes, bal_meta = balance_probes(probes, group=balance_group)
206 meta.update(bal_meta)
207
208 return probes, meta
209
210
211def load_probes(path: str | Path | None, *, fallback: list[str] | None = None) -> list[dict[str, Any]]:
212 """Load probes with optional metadata from JSON/JSONL."""
213 if not path:
214 texts = fallback or []
215 return [{"id": f"p{i}", "text": t, "metadata": {}} for i, t in enumerate(texts)]
216
217 p = Path(path)
218 if not p.is_file():
219 resolved = resolve_prompts_path(p)
220 if resolved is None:
221 raise FileNotFoundError(f"Prompts file not found: {p}")
222 p = resolved
223 text = p.read_text(encoding="utf-8").strip()
224 if p.suffix == ".jsonl":
225 rows = [json.loads(line) for line in text.splitlines() if line.strip()]
226 else:
227 data = json.loads(text)
228 rows = data if isinstance(data, list) else [data]
229
230 out: list[dict[str, Any]] = []
231 for i, row in enumerate(rows):
232 if isinstance(row, str):
233 out.append({"id": f"p{i}", "text": row.strip(), "metadata": {}})
234 continue
235 if not isinstance(row, dict):
236 continue
237 text_val = ""
238 for key in PROBE_TEXT_KEYS:
239 val = row.get(key)
240 if isinstance(val, str) and val.strip():
241 text_val = val.strip()
242 break
243 if not text_val:
244 continue
245 probe_id = str(row.get("id") or f"p{i}")
246 metadata = {k: v for k, v in row.items() if k not in RESERVED_PROBE_KEYS}
247 out.append({"id": probe_id, "text": text_val, "metadata": metadata})
248
249 if not out:
250 raise ValueError(f"No probes found in {p}")
251 return out
252
253
254def parse_layers(spec: str | None, n_layers: int) -> list[int]:
255 if not spec or spec.strip().lower() == "all":
256 return list(range(n_layers))
257 layers: list[int] = []
258 for part in spec.split(","):
259 part = part.strip()
260 if not part:
261 continue
262 layer = int(part)
263 if layer < 0 or layer >= n_layers:
264 raise ValueError(f"Layer {layer} out of range [0, {n_layers - 1}]")
265 layers.append(layer)
266 if not layers:
267 raise ValueError("No layers specified")
268 return sorted(set(layers))
269
270
271def resolve_capture_model_id(model_id: str) -> tuple[str, ModelMode]:
272 from aquin.compute.model_loader import resolve_model_id
273
274 return resolve_model_id(model_id), "llm"
276
277def llm_layer_count(model_id: str, checkpoint_path: str | Path | None = None) -> int:
278 """Catalog layer count — never load weights just to read n_layers (OOM on T4)."""
279 from aquin.compute.model_loader import get_config, resolve_model_id
280
281 _ = checkpoint_path # checkpoint cannot change architecture layer count
282 return int(get_config(resolve_model_id(model_id))["n_layers"])
283
284
285
286def _pool_activation(tensor: torch.Tensor, position: Position) -> torch.Tensor:
287 """tensor: (seq, d_model) -> (d_model,)"""
288 if position == "last":
289 return tensor[-1].float().cpu()
290 return tensor.mean(dim=0).float().cpu()
291
292
294 model: Any,
295 text: str,
296 layers: list[int],
297 *,
298 position: Position,
299 max_chars: int = 512,
300) -> dict[int, torch.Tensor]:
301 tokens = model.to_tokens(text[:max_chars])
302 hooks = {f"blocks.{layer}.hook_resid_post" for layer in layers}
303 with torch.no_grad():
304 _, cache = model.run_with_cache(
305 tokens,
306 names_filter=lambda name: name in hooks,
307 return_type=None,
308 )
309 out: dict[int, torch.Tensor] = {}
310 for layer in layers:
311 key = f"blocks.{layer}.hook_resid_post"
312 if key in cache:
313 out[layer] = _pool_activation(cache[key][0], position)
314 return out
315
316
318 model: Any,
319 text: str,
320 layers: list[int],
321 *,
322 max_chars: int = 512,
323) -> tuple[dict[int, torch.Tensor], list[str]]:
324 tokens = model.to_tokens(text[:max_chars])
325 hooks = {f"blocks.{layer}.hook_resid_post" for layer in layers}
326 with torch.no_grad():
327 _, cache = model.run_with_cache(
328 tokens,
329 names_filter=lambda name: name in hooks,
330 return_type=None,
331 )
332 token_ids = tokens[0].tolist()
333 token_texts = [model.tokenizer.decode([tid], skip_special_tokens=False) for tid in token_ids]
334 out: dict[int, torch.Tensor] = {}
335 for layer in layers:
336 key = f"blocks.{layer}.hook_resid_post"
337 if key in cache:
338 out[layer] = cache[key][0].float().cpu()
339 return out, token_texts
340
341
342
344 *,
345 out_root: Path,
346 probes: list[dict[str, Any]],
347 layer_list: list[int],
348 per_layer: dict[int, list[torch.Tensor]],
349 summary_rows: list[dict[str, Any]],
350 model_id: str,
351 model_mode: ModelMode,
352 d_model: int,
353 checkpoint_path: str | Path | None,
354 checkpoint_name: str | None,
355 position: Position,
356 encode_sae: bool,
357 capture_name: str | None,
358 sae_file: str | None,
359 sae_features: dict[str, Any] | None,
360 manifest_extras: dict[str, Any] | None = None,
361 granularity: Granularity = "prompt",
362 token_spans: list[dict[str, Any]] | None = None,
363) -> dict[str, Any]:
364 (out_root / "layers").mkdir(parents=True, exist_ok=True)
365 activation_files: dict[str, str] = {}
366 for layer, vecs in per_layer.items():
367 if not vecs:
368 continue
369 rel = f"layers/layer_{layer}.pt"
370 stacked = torch.cat(vecs, dim=0) if granularity == "token" else torch.stack(vecs)
371 torch.save(stacked, out_root / rel)
372 activation_files[str(layer)] = rel
373
374 capture_id = capture_name or uuid.uuid4().hex[:12]
375 manifest: dict[str, Any] = {
376 "schema_version": 1,
377 "capture_id": capture_id,
378 "created_at": datetime.now(timezone.utc).isoformat(),
379 "model_id": model_id,
380 "model_mode": model_mode,
381 "checkpoint": str(checkpoint_path) if checkpoint_path else None,
382 "checkpoint_name": checkpoint_name,
383 "position": position,
384 "granularity": granularity,
385 "encode_sae": encode_sae,
386 "n_probes": len(probes),
387 "layers": layer_list,
388 "d_model": d_model,
389 "output_dir": str(out_root.resolve()),
390 "files": {
391 "summary": "summary.jsonl",
392 "activations": activation_files,
393 },
394 "probes": probes,
395 }
396 if manifest_extras:
397 manifest.update(manifest_extras)
398 if sae_file:
399 manifest["files"]["sae_features"] = sae_file
400 manifest["sae_features"] = sae_features
401 if granularity == "token":
402 manifest["files"]["token_spans"] = "token_spans.jsonl"
403
404 metadata_payload: dict[str, Any] = {
405 "schema_version": 1,
406 "kind": "activation_capture_metadata",
407 "capture_id": capture_id,
408 "created_at": manifest["created_at"],
409 "model_id": model_id,
410 "model_mode": model_mode,
411 "checkpoint": str(checkpoint_path) if checkpoint_path else None,
412 "checkpoint_name": checkpoint_name,
413 "position": position,
414 "granularity": granularity,
415 "encode_sae": encode_sae,
416 "n_probes": len(probes),
417 "layers": layer_list,
418 "d_model": d_model,
419 "output_dir": str(out_root.resolve()),
420 "files": manifest["files"],
421 "probes": probes,
422 }
423 if manifest_extras:
424 metadata_payload.update(manifest_extras)
425 if sae_features:
426 metadata_payload["sae_features"] = sae_features
427
428 (out_root / "manifest.json").write_text(json.dumps(manifest, indent=2), encoding="utf-8")
429 (out_root / "metadata.json").write_text(json.dumps(metadata_payload, indent=2), encoding="utf-8")
430 with (out_root / "summary.jsonl").open("w", encoding="utf-8") as fh:
431 for row in summary_rows:
432 fh.write(json.dumps(row) + "\n")
433 if granularity == "token":
434 with (out_root / "token_spans.jsonl").open("w", encoding="utf-8") as fh:
435 for row in (token_spans or []):
436 fh.write(json.dumps(row, ensure_ascii=False) + "\n")
437
438 probe_samples = [
439 {
440 "id": p["id"],
441 **({"label": p["metadata"]["label"]} if p.get("metadata", {}).get("label") else {}),
442 **({"lang": p["metadata"]["lang"]} if p.get("metadata", {}).get("lang") else {}),
443 }
444 for p in probes[:6]
445 ]
446
447 return {
448 "capture_id": capture_id,
449 "model_id": model_id,
450 "model_mode": model_mode,
451 "n_probes": len(probes),
452 "layers": layer_list,
453 "position": position,
454 "granularity": granularity,
455 "encode_sae": encode_sae,
456 "d_model": d_model,
457 "output_dir": str(out_root.resolve()),
458 "manifest_path": str((out_root / "manifest.json").resolve()),
459 "metadata_path": str((out_root / "metadata.json").resolve()),
460 "activation_files": activation_files,
461 "sae_features": sae_features,
462 "probe_samples": probe_samples,
463 "checkpoint": str(checkpoint_path) if checkpoint_path else None,
464 "checkpoint_name": checkpoint_name,
465 "status": "done",
466 **(manifest_extras or {}),
467 }
468
469
471 model_id: str,
472 probes: list[dict[str, Any]],
473 output_dir: str | Path,
474 *,
475 layers: list[int],
476 checkpoint_path: str | Path | None,
477 checkpoint_name: str | None,
478 position: Position,
479 encode_sae: bool,
480 sae_layer: int | None,
481 capture_name: str | None,
482 manifest_extras: dict[str, Any] | None = None,
483 granularity: Granularity = "prompt",
484) -> dict[str, Any]:
485 from aquin.compute.device import empty_device_cache
486 from aquin.compute.feature_analysis import load_sae, normalize
487 from aquin.compute.model_loader import get_sae_layer
488 from aquin.compute.sae_diff import load_tl_from_checkpoint
489
490 # Checkpoint path builds a second full model — free any resident weights first (T4).
491 if checkpoint_path is not None:
492 from aquin.compute.model_runtime import unload_weights
493
494 unload_weights(clear_active=False)
495 empty_device_cache()
496 model = load_tl_from_checkpoint(model_id, checkpoint_path)
497 d_model = int(model.cfg.d_model)
498 out_root = Path(output_dir)
499 (out_root / "layers").mkdir(parents=True, exist_ok=True)
500
501 per_layer: dict[int, list[torch.Tensor]] = {layer: [] for layer in layers}
502 summary_rows: list[dict[str, Any]] = []
503 token_spans: list[dict[str, Any]] = []
504
505 if granularity == "token" and encode_sae:
506 raise ValueError("--encode-sae is not yet supported with --granularity token")
507
508 for probe in probes:
509 if granularity == "token":
510 acts, token_texts = _forward_llm_layers_token(model, probe["text"], layers)
511 else:
512 acts = _forward_llm_layers(model, probe["text"], layers, position=position)
513 token_texts = []
514 row_base = {"probe_id": probe["id"], "metadata": probe.get("metadata") or {}}
515 for layer, vec in acts.items():
516 per_layer[layer].append(vec)
517 if granularity == "token":
518 start = sum(t.shape[0] for t in per_layer[layer][:-1])
519 end = start + int(vec.shape[0])
520 summary_rows.append({**row_base, "layer": layer, "n_tokens": int(vec.shape[0]), "d_model": int(vec.shape[1])})
521 token_spans.append({
522 **row_base,
523 "layer": layer,
524 "token_start": start,
525 "token_end": end,
526 "n_tokens": int(vec.shape[0]),
527 "tokens": token_texts,
528 })
529 else:
530 summary_rows.append({**row_base, "layer": layer, "d_model": int(vec.shape[0])})
531
532 sae_file = None
533 sae_features = None
534 if encode_sae:
535 enc_layer = sae_layer if sae_layer is not None else get_sae_layer(model_id)
536 if enc_layer not in layers:
537 raise ValueError(
538 f"--encode-sae requires layer {enc_layer} in captured layers ({layers})"
539 )
540 sae = load_sae(model_id, layer=enc_layer)
541 encoded_rows: list[torch.Tensor] = []
542 for probe in probes:
543 acts = _forward_llm_layers(model, probe["text"], [enc_layer], position=position)
544 resid = acts[enc_layer].to(next(sae.parameters()).device)
545 encoded = sae.encode(normalize(resid.unsqueeze(0), model_id, enc_layer))[0]
546 encoded_rows.append(encoded.float().cpu())
547 sae_stack = torch.stack(encoded_rows)
548 sae_rel = f"sae/sae_layer_{enc_layer}.pt"
549 (out_root / "sae").mkdir(parents=True, exist_ok=True)
550 torch.save(sae_stack, out_root / sae_rel)
551 sae_file = sae_rel
552 sae_features = {
553 "layer": enc_layer,
554 "n_features": int(sae_stack.shape[1]),
555 "n_probes": int(sae_stack.shape[0]),
556 }
557
558 del model
559 empty_device_cache()
561 out_root=out_root,
562 probes=probes,
563 layer_list=layers,
564 per_layer=per_layer,
565 summary_rows=summary_rows,
566 model_id=model_id,
567 model_mode="llm",
568 d_model=d_model,
569 checkpoint_path=checkpoint_path,
570 checkpoint_name=checkpoint_name,
571 position=position,
572 encode_sae=encode_sae,
573 capture_name=capture_name,
574 sae_file=sae_file,
575 sae_features=sae_features,
576 manifest_extras=manifest_extras,
577 granularity=granularity,
578 token_spans=token_spans,
579 )
580
581
582
584 model_id: str,
585 probes: list[dict[str, Any]],
586 output_dir: str | Path,
587 *,
588 model_mode: ModelMode | None = None,
589 layers: list[int] | None = None,
590 checkpoint_path: str | Path | None = None,
591 checkpoint_name: str | None = None,
592 position: Position = "last",
593 encode_sae: bool = False,
594 sae_layer: int | None = None,
595 capture_name: str | None = None,
596 manifest_extras: dict[str, Any] | None = None,
597 granularity: Granularity = "prompt",
598) -> dict[str, Any]:
599 mid, _mode = resolve_capture_model_id(model_id)
600 del model_mode # LLM-only capture
601
602 from aquin.compute.model_loader import get_config
603
604 n_layers = int(get_config(mid)["n_layers"])
605 layer_list = layers if layers is not None else list(range(n_layers))
606
607 return _run_capture_llm(
608 mid,
609 probes,
610 output_dir,
611 layers=layer_list,
612 checkpoint_path=checkpoint_path,
613 checkpoint_name=checkpoint_name,
614 position=position,
615 encode_sae=encode_sae,
616 sae_layer=sae_layer,
617 capture_name=capture_name,
618 manifest_extras=manifest_extras,
619 granularity=granularity,
620 )
str|None _balance_key(dict[str, Any] probe, str|None group=None)
tuple[str, ModelMode] resolve_capture_model_id(str model_id)
int llm_layer_count(str model_id, str|Path|None checkpoint_path=None)
dict[int, torch.Tensor] _forward_llm_layers(Any model, str text, list[int] layers, *, Position position, int max_chars=512)
dict[str, Any] _write_capture_artifacts(*, Path out_root, list[dict[str, Any]] probes, list[int] layer_list, dict[int, list[torch.Tensor]] per_layer, list[dict[str, Any]] summary_rows, str model_id, ModelMode model_mode, int d_model, str|Path|None checkpoint_path, str|None checkpoint_name, Position position, bool encode_sae, str|None capture_name, str|None sae_file, dict[str, Any]|None sae_features, dict[str, Any]|None manifest_extras=None, Granularity granularity="prompt", list[dict[str, Any]]|None token_spans=None)
dict[str, Any] run_capture_activations(str model_id, list[dict[str, Any]] probes, str|Path output_dir, *, ModelMode|None model_mode=None, list[int]|None layers=None, str|Path|None checkpoint_path=None, str|None checkpoint_name=None, Position position="last", bool encode_sae=False, int|None sae_layer=None, str|None capture_name=None, dict[str, Any]|None manifest_extras=None, Granularity granularity="prompt")
Path|None resolve_prompts_path(str|Path path)
tuple[dict[int, torch.Tensor], list[str]] _forward_llm_layers_token(Any model, str text, list[int] layers, *, int max_chars=512)
tuple[list[dict[str, Any]], dict[str, Any]] resolve_probes_for_capture(*, str model_id, ModelMode model_mode, str|Path|None prompts_path, int count, str|None topic, bool balance=False, str|None balance_group=None, Path|None output_dir=None)
torch.Tensor _pool_activation(torch.Tensor tensor, Position position)
list[dict[str, Any]] load_probes(str|Path|None path, *, list[str]|None fallback=None)
dict[str, Any] _run_capture_llm(str model_id, list[dict[str, Any]] probes, str|Path output_dir, *, list[int] layers, str|Path|None checkpoint_path, str|None checkpoint_name, Position position, bool encode_sae, int|None sae_layer, str|None capture_name, dict[str, Any]|None manifest_extras=None, Granularity granularity="prompt")
tuple[list[dict[str, Any]], dict[str, Any]] balance_probes(list[dict[str, Any]] probes, *, str|None group=None)
list[int] parse_layers(str|None spec, int n_layers)
list[dict[str, Any]] generate_llm_probes(str model_id, int count, *, str topic="general knowledge, reasoning, and instructions")
Path write_probes_jsonl(Path path, list[dict[str, Any]] probes)