AQIT 0.1.0
Loading...
Searching...
No Matches
sae_stats.py
Go to the documentation of this file.
1# Copyright (c) 2025-present Aquin Labs Private Limited. All Rights Reserved.
2# This file is part of the Aquin Engine. Unauthorized copying, modification,
3# distribution, or use of this file, via any medium, is strictly prohibited.
4# Proprietary and confidential. See LICENSE for terms.
5
6"""Multi-layer SAE statistics export for LLMs."""
7
8from __future__ import annotations
9
10import json
11import os
12from pathlib import Path
13from typing import Any
14
15import torch
16
17from aquin.compute.device import resolve_compute_device
18
19DEVICE = resolve_compute_device()
20
21_PROBE_TEXT_KEYS = ("text", "prompt", "input", "query", "instruction")
22_META_KEYS = ("id", "stressor", "lang", "quant_run_id", "condition", "label", "group")
24
25def _sae_cache_dir(model_id: str) -> Path:
26 return Path.home() / ".aquin" / "sae" / model_id
27
28
29def _parse_layers_wanted(layers: str | None) -> list[int] | None:
30 if not layers or layers.strip().lower() == "all":
31 return None
32 wanted: list[int] = []
33 for part in layers.split(","):
34 part = part.strip()
35 if not part:
36 continue
37 try:
38 wanted.append(int(part))
39 except ValueError as exc:
40 raise ValueError(f"Invalid --layers value {part!r} (expected integers like 8 or 8,9,10,11)") from exc
41 if not wanted:
42 raise ValueError("--layers must list at least one layer index, e.g. --layers 11 or --layers 8,9,10,11")
43 return wanted
44
45
46def _format_load_sae_commands(model_id: str, layers: list[int]) -> str:
47 return "\n".join(f" aquin load sae {model_id}-l{layer}" for layer in layers)
48
49
51 model_id: str,
52 *,
53 mode: str,
54 available: list[int],
55 layers_arg: str | None,
56) -> str:
57 """Actionable error when SAE checkpoint files are missing on disk."""
58 sae_dir = _sae_cache_dir(model_id)
59 wanted = _parse_layers_wanted(layers_arg)
60 lines: list[str] = []
61
62 if not available:
63 lines.append(f"No LLM SAE checkpoints found for {model_id}.")
64 lines.append(f"Expected directory: {sae_dir}/")
65 lines.append("Expected files like: sae_layer<N>.pt")
66 else:
67 lines.append(f"Some requested LLM SAE layers are not downloaded for {model_id}.")
68 lines.append(f"Downloaded layers: {available}")
69
70 if wanted:
71 missing = [layer for layer in wanted if layer not in available]
72 if missing:
73 lines.append(f"Missing layer(s): {missing}")
74 elif not available:
75 lines.append("No --layers specified; need at least one downloaded checkpoint.")
76
77 lines.append("")
78 lines.append("Pull checkpoints first (or use aquin load sae --path):")
79 pull_layers = (
80 [layer for layer in (wanted or []) if layer not in available]
81 if wanted
82 else [8]
83 )
84 if not pull_layers and wanted:
85 pull_layers = wanted
86 lines.append(_format_load_sae_commands(model_id, pull_layers))
87
88 lines.append("")
89 lines.append("Load an SAE: aquin load sae <model-l{n}> or --path <file.pt>")
90 return "\n".join(lines)
91
92
93def _expand(path: str) -> Path:
94 return Path(os.path.expanduser(path)).resolve()
95
96
97def _rows_to_probes(rows: list[Any]) -> list[dict[str, Any]]:
98 probes: list[dict[str, Any]] = []
99 for i, row in enumerate(rows):
100 if isinstance(row, str):
101 probes.append({"id": f"probe_{i}", "text": row})
102 continue
103 if not isinstance(row, dict):
104 raise ValueError(f"Probe row {i} must be string or object")
105 text = next((str(row[k]) for k in _PROBE_TEXT_KEYS if row.get(k)), None)
106 if not text:
107 raise ValueError(f"Probe row {i} missing text field ({', '.join(_PROBE_TEXT_KEYS)})")
108 out: dict[str, Any] = {"id": row.get("id") or f"probe_{i}", "text": text}
109 for key in _META_KEYS:
110 if row.get(key) is not None:
111 out[key] = row[key]
112 probes.append(out)
113 if not probes:
114 raise ValueError("No probes found")
115 return probes
116
117
118def load_probes(path: str | list[Any]) -> list[dict[str, Any]]:
119 """Load probe rows from a path, inline JSON, or an in-memory list."""
120 if isinstance(path, list):
121 return _rows_to_probes(path)
122 raw = str(path).strip()
123 if raw.startswith("[") or raw.startswith("{"):
124 data = json.loads(raw)
125 if isinstance(data, list):
126 return _rows_to_probes(data)
127 if isinstance(data, dict) and isinstance(data.get("probes"), list):
128 return _rows_to_probes(data["probes"])
129 raise ValueError("Expected a JSON array of probe strings")
130
131 from aquin.compute.activation_capture import resolve_prompts_path
132
133 resolved = resolve_prompts_path(_expand(raw))
134 if resolved is None:
135 if "/" not in raw and "\\" not in raw and not raw.lower().endswith((".json", ".jsonl")):
136 return _rows_to_probes([raw])
137 raise FileNotFoundError(
138 f"Probes file not found: {path} "
139 f"(tried cwd and repo parent)"
140 )
141 p = resolved
142
143 if p.suffix.lower() == ".jsonl":
144 rows: list[Any] = []
145 with p.open(encoding="utf-8") as f:
146 for line in f:
147 line = line.strip()
148 if line:
149 rows.append(json.loads(line))
150 else:
151 with p.open(encoding="utf-8") as f:
152 loaded = json.load(f)
153 if isinstance(loaded, list):
154 rows = loaded
155 elif isinstance(loaded, dict) and isinstance(loaded.get("probes"), list):
156 rows = loaded["probes"]
157 else:
158 raise ValueError(f"Expected JSON array or {{probes: [...]}} in {p}")
159 return _rows_to_probes(rows)
160
161
163 layers: str | None,
164 available: list[int],
165 *,
166 model_id: str | None = None,
167 mode: str = "llm",
168) -> list[int]:
169 if layers is not None and not isinstance(layers, str):
170 layers = str(layers)
171 if not layers or layers.strip().lower() == "all":
172 if not available:
173 raise ValueError(
174 _missing_sae_error(model_id or "model", mode=mode, available=[], layers_arg=layers)
175 if model_id
176 else "No SAE layers available on disk."
177 )
178 return list(available)
179 wanted = _parse_layers_wanted(layers)
180 if wanted is None:
181 return list(available)
182 missing = [layer for layer in wanted if layer not in available]
183 if missing:
184 if model_id:
185 raise ValueError(
186 _missing_sae_error(model_id, mode=mode, available=available, layers_arg=layers)
187 )
188 raise ValueError(
189 f"No SAE checkpoint for layer(s) {missing}. Downloaded: {available or 'none'}"
190 )
191 return wanted
192
193
194def list_llm_sae_layers(model_id: str) -> list[int]:
195 from aquin.compute.model_loader import get_available_sae_layers, resolve_model_id
196
197 return get_available_sae_layers(resolve_model_id(model_id))
199
200def _feature_stats(mean_acts: torch.Tensor, *, top_k: int) -> dict[str, Any]:
201 """mean_acts: (n_features,)"""
202 mean_acts = torch.nan_to_num(mean_acts, nan=0.0, posinf=0.0, neginf=0.0)
203 n_features = int(mean_acts.shape[0])
204 active = mean_acts > 1e-6
205 l0 = float(active.sum().item())
206 sparsity = round(1.0 - l0 / max(n_features, 1), 6)
207 top = mean_acts.topk(min(top_k, n_features))
208 top_features = [
209 {"feature_idx": int(i), "mean_activation": round(float(v), 6)}
210 for i, v in zip(top.indices.tolist(), top.values.tolist())
211 ]
212 return {
213 "n_features": n_features,
214 "mean_l0": round(l0, 4),
215 "sparsity": sparsity,
216 "mean_activation": round(float(mean_acts.mean().item()), 6),
217 "max_activation": round(float(mean_acts.max().item()), 6),
218 "top_features": top_features,
219 }
220
221
223 model: Any,
224 sae: Any,
225 model_id: str,
226 layer: int,
227 text: str,
228) -> torch.Tensor:
229 from aquin.compute.feature_analysis import normalize
230
231 tokens = model.to_tokens(text[:512])
232 hook = f"blocks.{layer}.hook_resid_post"
233 with torch.no_grad():
234 _, cache = model.run_with_cache(tokens, names_filter=hook, return_type=None)
235 resid = cache[hook][0]
236 encoded = sae.encode(normalize(resid, model_id, layer))
237 return torch.nan_to_num(encoded.mean(dim=0).cpu(), nan=0.0, posinf=0.0, neginf=0.0)
238
239
241 model: Any,
242 model_id: str,
243 probes: list[dict[str, Any]],
244 *,
245 layers: list[int],
246 top_k: int = 10,
247) -> dict[str, Any]:
248 from aquin.compute.feature_analysis import load_sae
249
250 layer_stats: list[dict[str, Any]] = []
251 heatmap_rows: list[str] = []
252 heatmap_cols = [f"L{layer}" for layer in layers]
253 heatmap_values: list[list[float]] = []
254
255 for layer in layers:
256 try:
257 sae = load_sae(model_id, layer)
258 except Exception as exc:
259 layer_stats.append({
260 "layer": layer,
261 "sae_available": False,
262 "error": str(exc),
263 })
264 continue
265
266 per_probe: list[dict[str, Any]] = []
267 accum: list[torch.Tensor] = []
268 for probe in probes:
269 acts = _llm_probe_acts(model, sae, model_id, layer, probe["text"])
270 accum.append(acts)
271 l0 = float((acts > 1e-6).sum().item())
272 per_probe.append({
273 "probe_id": probe["id"],
274 **{k: probe[k] for k in _META_KEYS if k in probe},
275 "mean_l0": round(l0, 4),
276 "mean_activation": round(float(acts.mean().item()), 6),
277 })
278
279 mean_acts = torch.stack(accum).mean(dim=0)
280 layer_stats.append({
281 "layer": layer,
282 "sae_available": True,
283 **_feature_stats(mean_acts, top_k=top_k),
284 "per_probe": per_probe,
285 })
286
287 for probe in probes:
288 heatmap_rows.append(str(probe["id"]))
289 row_vals: list[float] = []
290 for layer in layers:
291 layer_entry = next((x for x in layer_stats if x.get("layer") == layer), None)
292 if not layer_entry or not layer_entry.get("sae_available"):
293 row_vals.append(0.0)
294 continue
295 match = next(
296 (p for p in layer_entry.get("per_probe", []) if p.get("probe_id") == probe["id"]),
297 None,
298 )
299 row_vals.append(float(match["mean_l0"]) if match else 0.0)
300 heatmap_values.append(row_vals)
301
302 return _build_payload(
303 model_id=model_id,
304 mode="llm",
305 probes=probes,
306 layers=layers,
307 layer_stats=layer_stats,
308 heatmap_rows=heatmap_rows,
309 heatmap_cols=heatmap_cols,
310 heatmap_values=heatmap_values,
311 top_k=top_k,
312 )
313
314
316 *,
317 model_id: str,
318 mode: str,
319 probes: list[dict[str, Any]],
320 layers: list[int],
321 layer_stats: list[dict[str, Any]],
322 heatmap_rows: list[str],
323 heatmap_cols: list[str],
324 heatmap_values: list[list[float]],
325 top_k: int,
326) -> dict[str, Any]:
327 profile = [
328 {
329 "layer": entry["layer"],
330 "mean_l0": entry.get("mean_l0"),
331 "sparsity": entry.get("sparsity"),
332 "mean_activation": entry.get("mean_activation"),
333 "sae_available": entry.get("sae_available", False),
334 }
335 for entry in layer_stats
336 if entry.get("sae_available")
337 ]
338
339 return {
340 "schema_version": 1,
341 "model_id": model_id,
342 "mode": mode,
343 "n_probes": len(probes),
344 "layers_requested": layers,
345 "top_k": top_k,
346 "probes": probes,
347 "layer_stats": layer_stats,
348 "layer_profile": profile,
349 "heatmap": {
350 "metric": "mean_l0",
351 "rows": heatmap_rows,
352 "cols": heatmap_cols,
353 "values": heatmap_values,
354 },
355 }
356
357
358def run_sae_stats(args: dict[str, Any]) -> dict[str, Any]:
359 from aquin.compute.model_loader import (
360 get_loaded_model,
361 load_model,
362 resolve_model_id,
363 )
364
365 model_id = args.get("model_id") or "llama-3.2-1b"
366 try:
367 model_id = resolve_model_id(model_id)
368 except ValueError as exc:
369 return {"error": str(exc)}
370
371 prompts_path = args.get("prompts") or args.get("prompt")
372 if not prompts_path:
373 probes = [{"id": "probe_0", "text": "The capital of France is"}]
374 else:
375 try:
376 probes = load_probes(prompts_path)
377 except (OSError, ValueError, json.JSONDecodeError, TypeError) as exc:
378 return {"error": str(exc)}
379
380 top_k = int(args.get("top_k") or 10)
381 layers_arg = args.get("layers")
382
383 model = get_loaded_model()
384 if model is None:
385 try:
386 model = load_model(model_id)
387 except Exception as exc:
388 return {"error": f"Could not load model: {exc}"}
389
390 available = list_llm_sae_layers(model_id)
391 if not available:
392 return {
393 "error": _missing_sae_error(
394 model_id, mode="llm", available=[], layers_arg=layers_arg,
395 ),
396 }
397 try:
398 layers = parse_layers_arg(layers_arg, available, model_id=model_id, mode="llm")
399 except ValueError as exc:
400 return {"error": str(exc)}
401
402 return run_llm_sae_stats(model, model_id, probes, layers=layers, top_k=top_k)
list[int] parse_layers_arg(str|None layers, list[int] available, *, str|None model_id=None, str mode="llm")
Definition sae_stats.py:172
dict[str, Any] run_llm_sae_stats(Any model, str model_id, list[dict[str, Any]] probes, *, list[int] layers, int top_k=10)
Definition sae_stats.py:251
Path _expand(str path)
Definition sae_stats.py:97
dict[str, Any] _build_payload(*, str model_id, str mode, list[dict[str, Any]] probes, list[int] layers, list[dict[str, Any]] layer_stats, list[str] heatmap_rows, list[str] heatmap_cols, list[list[float]] heatmap_values, int top_k)
Definition sae_stats.py:330
list[dict[str, Any]] load_probes(str|list[Any] path)
Definition sae_stats.py:122
list[int]|None _parse_layers_wanted(str|None layers)
Definition sae_stats.py:33
dict[str, Any] run_sae_stats(dict[str, Any] args)
Definition sae_stats.py:362
list[dict[str, Any]] _rows_to_probes(list[Any] rows)
Definition sae_stats.py:101
dict[str, Any] _feature_stats(torch.Tensor mean_acts, *, int top_k)
Definition sae_stats.py:204
str _format_load_sae_commands(str model_id, list[int] layers)
Definition sae_stats.py:50
Path _sae_cache_dir(str model_id)
Definition sae_stats.py:29
list[int] list_llm_sae_layers(str model_id)
Definition sae_stats.py:198
str _missing_sae_error(str model_id, *, str mode, list[int] available, str|None layers_arg)
Definition sae_stats.py:60
torch.Tensor _llm_probe_acts(Any model, Any sae, str model_id, int layer, str text)
Definition sae_stats.py:232