AQIT 0.1.0
Loading...
Searching...
No Matches
layer_analysis.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"""LLM layer health: activation stability + OOD similarity."""
7
8from __future__ import annotations
9
10import torch
11from transformer_lens import HookedTransformer
12
13DEFAULT_STABILITY_PROMPTS = [
14 "The capital of France is",
15 "Write a function that sorts a list",
16 "Explain photosynthesis in simple terms",
17 "Hello, how are you today?",
18 "The quick brown fox jumps over the lazy dog",
19]
20
21DEFAULT_IN_DOMAIN_PROMPTS = [
22 "The capital of France is Paris.",
23 "Water boils at 100 degrees Celsius.",
24 "Python is a programming language.",
25 "The Earth orbits the Sun.",
26]
27
28DEFAULT_OOD_PROMPTS = [
29 "asdf jkl qwerty zxcv",
30 "!!! ??? ### @@@",
31 "florp sniggle wumpus",
32 "9918273645 192837465",
33]
34
35COLLAPSE_THRESHOLD = 0.85
36DEAD_THRESHOLD = 0.01
37MIN_STABILITY_PROMPTS = 2
38
40def _resolve_stability_prompts(prompts: list[str] | None) -> tuple[list[str], bool]:
41 """PCA needs ≥2 samples. Pad with defaults when the user passes fewer."""
42 if not prompts:
43 return list(DEFAULT_STABILITY_PROMPTS), False
44 resolved = list(prompts)
45 if len(resolved) >= MIN_STABILITY_PROMPTS:
46 return resolved, False
47 for p in DEFAULT_STABILITY_PROMPTS:
48 if p not in resolved:
49 resolved.append(p)
50 if len(resolved) >= MIN_STABILITY_PROMPTS:
51 break
52 return resolved, True
53
54
55def _pca_variance_ratios(acts: torch.Tensor, top_k: int) -> tuple[float, float]:
56 """acts: (n_samples, d_model). Returns (top1_ratio, topk_ratio)."""
57 if acts.shape[0] < 2:
58 return 0.0, 0.0
59 centered = acts - acts.mean(dim=0, keepdim=True)
60 try:
61 _, s, _ = torch.linalg.svd(centered, full_matrices=False)
62 except Exception:
63 return 0.0, 0.0
64 var = s ** 2
65 total = var.sum()
66 if total < 1e-12:
67 return 0.0, 0.0
68 top1 = float(var[0] / total)
69 k = min(top_k, len(var))
70 topk = float(var[:k].sum() / total)
71 return top1, topk
72
73
75 model: HookedTransformer,
76 prompts: list[str],
77) -> dict[int, torch.Tensor]:
78 """Last-token resid_post per layer. layer -> (n_prompts, d_model)."""
79 n_layers = model.cfg.n_layers
80 per_layer: dict[int, list[torch.Tensor]] = {i: [] for i in range(n_layers)}
81
82 for prompt in prompts:
83 tokens = model.to_tokens(prompt)
84 with torch.no_grad():
85 _, cache = model.run_with_cache(
86 tokens,
87 names_filter=lambda name: name.endswith("hook_resid_post"),
88 )
89 for layer in range(n_layers):
90 key = f"blocks.{layer}.hook_resid_post"
91 if key in cache:
92 per_layer[layer].append(cache[key][0, -1].float().cpu())
93
94 return {layer: torch.stack(vecs) for layer, vecs in per_layer.items() if vecs}
95
96
98 model: HookedTransformer,
99 prompts: list[str] | None = None,
100 top_k: int = 10,
101) -> dict:
102 user_n = len(prompts) if prompts else 0
103 prompts, padded_defaults = _resolve_stability_prompts(prompts)
104 layer_acts = _collect_layer_activations(model, prompts)
105 n_layers = model.cfg.n_layers
106 layers_out: list[dict] = []
107 dead_count = 0
108 collapsed_count = 0
109
110 for layer in range(n_layers):
111 acts = layer_acts.get(layer)
112 if acts is None or acts.shape[0] < 2:
113 continue
114 top1, topk = _pca_variance_ratios(acts, top_k)
115 mean_act = float(acts.mean())
116 std_act = float(acts.std())
117 status = "ok"
118 if std_act < DEAD_THRESHOLD:
119 status = "dead"
120 dead_count += 1
121 elif top1 >= COLLAPSE_THRESHOLD:
122 status = "collapsed"
123 collapsed_count += 1
124 layers_out.append({
125 "layer": layer,
126 "top1_variance_ratio": round(top1, 4),
127 "topk_variance_ratio": round(topk, 4),
128 "mean_activation": round(mean_act, 4),
129 "activation_std": round(std_act, 4),
130 "status": status,
131 })
132
133 return {
134 "n_layers": n_layers,
135 "top_k": top_k,
136 "n_prompts": len(prompts),
137 "n_user_prompts": user_n or len(prompts),
138 "padded_defaults": padded_defaults,
139 "layers": layers_out,
140 "dead_count": dead_count,
141 "collapsed_count": collapsed_count,
142 "collapse_threshold": COLLAPSE_THRESHOLD,
143 "dead_threshold": DEAD_THRESHOLD,
144 }
145
146
147def _mean_pairwise_cos(vecs: torch.Tensor) -> float:
148 if vecs.shape[0] < 2:
149 return 1.0
150 v = vecs / vecs.norm(dim=-1, keepdim=True).clamp(min=1e-8)
151 sims = v @ v.T
152 n = sims.shape[0]
153 return float((sims.sum() - n) / (n * (n - 1)))
154
155
156def _mean_cross_cos(a: torch.Tensor, b: torch.Tensor) -> float:
157 if a.shape[0] == 0 or b.shape[0] == 0:
158 return 0.0
159 a = a / a.norm(dim=-1, keepdim=True).clamp(min=1e-8)
160 b = b / b.norm(dim=-1, keepdim=True).clamp(min=1e-8)
161 return float((a @ b.T).mean())
162
163
164def _rbf_mmd(x: torch.Tensor, y: torch.Tensor) -> float:
165 if x.shape[0] < 2 or y.shape[0] < 2:
166 return 0.0
167 xy = torch.cat([x, y], dim=0)
168 dists = torch.cdist(xy, xy, p=2)
169 sigma = float(dists.median())
170 if sigma < 1e-8:
171 sigma = 1.0
172
173 def _kernel(a: torch.Tensor, b: torch.Tensor) -> torch.Tensor:
174 d = torch.cdist(a, b, p=2)
175 return torch.exp(-(d ** 2) / (2 * sigma ** 2))
176
177 k_xx = _kernel(x, x).mean()
178 k_yy = _kernel(y, y).mean()
179 k_xy = _kernel(x, y).mean()
180 return float(torch.clamp(k_xx + k_yy - 2 * k_xy, min=0.0))
181
182
184 model: HookedTransformer,
185 in_domain_prompts: list[str] | None = None,
186 ood_prompts: list[str] | None = None,
187) -> dict:
188 in_prompts = in_domain_prompts or DEFAULT_IN_DOMAIN_PROMPTS
189 ood_ps = ood_prompts or DEFAULT_OOD_PROMPTS
190 in_acts = _collect_layer_activations(model, in_prompts)
191 ood_acts = _collect_layer_activations(model, ood_ps)
192 n_layers = model.cfg.n_layers
193 layers_out: list[dict] = []
194
195 for layer in range(n_layers):
196 in_a = in_acts.get(layer)
197 ood_a = ood_acts.get(layer)
198 if in_a is None or ood_a is None:
199 continue
200 in_cos = _mean_pairwise_cos(in_a)
201 ood_cos = _mean_pairwise_cos(ood_a)
202 cross_cos = _mean_cross_cos(in_a, ood_a)
203 separation = in_cos - cross_cos
204 mmd = _rbf_mmd(in_a, ood_a)
205 layers_out.append({
206 "layer": layer,
207 "in_cos": round(in_cos, 4),
208 "ood_cos": round(ood_cos, 4),
209 "cross_cos": round(cross_cos, 4),
210 "mmd": round(mmd, 4),
211 "separation": round(separation, 4),
212 })
213
214 peak_layer = max(layers_out, key=lambda row: row["separation"])["layer"] if layers_out else 0
215 mean_sep = (
216 sum(row["separation"] for row in layers_out) / len(layers_out)
217 if layers_out else 0.0
218 )
219
220 return {
221 "n_layers": n_layers,
222 "n_in_prompts": len(in_prompts),
223 "n_ood_prompts": len(ood_ps),
224 "layers": layers_out,
225 "peak_layer": peak_layer,
226 "mean_separation": round(mean_sep, 4),
227 }
228
229
230def run_layer_analysis(model: HookedTransformer, args: dict) -> dict:
231 prompts = args.get("prompts")
232 do_localize = bool(args.get("localize"))
233
234 # When --localize uses a probe file path, don't feed that path into stability PCA.
235 stability_prompts = prompts
236 localize_prompts = args.get("localize_prompts")
237 if do_localize:
238 if localize_prompts is None and isinstance(prompts, str) and not prompts.strip().startswith("["):
239 localize_prompts = prompts
240 stability_prompts = None
241 elif localize_prompts is None and prompts is None:
242 localize_prompts = None # default deception fixture
243 if isinstance(prompts, str) and prompts.strip().startswith("["):
244 try:
245 import json
246 stability_prompts = json.loads(prompts)
247 except Exception:
248 stability_prompts = [prompts]
249
250 if isinstance(stability_prompts, str):
251 stability_prompts = [stability_prompts]
252
253 in_domain = args.get("in_domain_prompts")
254 ood = args.get("ood_prompts")
255 top_k = int(args.get("top_k", 10))
256
257 stability = run_activation_stability(model, prompts=stability_prompts, top_k=top_k)
258 ood_result = run_ood_similarity(
259 model,
260 in_domain_prompts=in_domain,
261 ood_prompts=ood,
262 )
263 out: dict = {
264 "stability": stability,
265 "ood": ood_result,
266 "model_id": args.get("model_id", ""),
267 }
268
269 if do_localize:
270 from aquin.compute.localize_collapse import run_localize_collapse
271
272 feature_idx = args.get("feature_idx")
273 if feature_idx is not None:
274 feature_idx = int(feature_idx)
275 layer = args.get("layer")
276 if layer is not None:
277 layer = int(layer)
278
279 localize = run_localize_collapse(
280 model,
281 str(args.get("model_id") or ""),
282 prompts=localize_prompts,
283 stressor_prompts=args.get("stressor_prompts") or args.get("stressor"),
284 feature_idx=feature_idx,
285 vector_path=args.get("vector") or args.get("vector_path"),
286 layer=layer,
287 top_k_features=int(args.get("top_k_features") or args.get("top_k") or 8),
288 collect_layer_activations=_collect_layer_activations,
289 )
290 out["localize"] = localize
291
292 return out
dict run_activation_stability(HookedTransformer model, list[str]|None prompts=None, int top_k=10)
dict run_layer_analysis(HookedTransformer model, dict args)
dict run_ood_similarity(HookedTransformer model, list[str]|None in_domain_prompts=None, list[str]|None ood_prompts=None)
float _mean_pairwise_cos(torch.Tensor vecs)
tuple[list[str], bool] _resolve_stability_prompts(list[str]|None prompts)
tuple[float, float] _pca_variance_ratios(torch.Tensor acts, int top_k)
dict[int, torch.Tensor] _collect_layer_activations(HookedTransformer model, list[str] prompts)
float _rbf_mmd(torch.Tensor x, torch.Tensor y)
float _mean_cross_cos(torch.Tensor a, torch.Tensor b)