cotlab 0.8.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (65) hide show
  1. cotlab/__init__.py +3 -0
  2. cotlab/analyse_experiments.py +392 -0
  3. cotlab/analysis/__init__.py +11 -0
  4. cotlab/analysis/cot_parser.py +243 -0
  5. cotlab/analysis/faithfulness_metrics.py +192 -0
  6. cotlab/backends/__init__.py +16 -0
  7. cotlab/backends/base.py +78 -0
  8. cotlab/backends/transformers_backend.py +335 -0
  9. cotlab/backends/vllm_backend.py +227 -0
  10. cotlab/cli.py +83 -0
  11. cotlab/core/__init__.py +34 -0
  12. cotlab/core/base.py +749 -0
  13. cotlab/core/config.py +90 -0
  14. cotlab/core/registry.py +68 -0
  15. cotlab/datasets/__init__.py +45 -0
  16. cotlab/datasets/loaders.py +1889 -0
  17. cotlab/experiment/__init__.py +315 -0
  18. cotlab/experiments/__init__.py +43 -0
  19. cotlab/experiments/activation_compare.py +290 -0
  20. cotlab/experiments/activation_patching.py +1050 -0
  21. cotlab/experiments/attention_analysis.py +885 -0
  22. cotlab/experiments/classification.py +235 -0
  23. cotlab/experiments/composite_shift_detector.py +524 -0
  24. cotlab/experiments/cot_ablation.py +277 -0
  25. cotlab/experiments/cot_faithfulness.py +187 -0
  26. cotlab/experiments/cot_heads.py +208 -0
  27. cotlab/experiments/full_layer_cot.py +232 -0
  28. cotlab/experiments/full_layer_patching.py +225 -0
  29. cotlab/experiments/h_neuron_analysis.py +712 -0
  30. cotlab/experiments/logit_lens.py +439 -0
  31. cotlab/experiments/multi_head_cot.py +220 -0
  32. cotlab/experiments/multi_head_patching.py +229 -0
  33. cotlab/experiments/probing_classifier.py +402 -0
  34. cotlab/experiments/residual_norm_ood.py +413 -0
  35. cotlab/experiments/sae_feature_analysis.py +673 -0
  36. cotlab/experiments/steering_vectors.py +223 -0
  37. cotlab/experiments/sycophancy_heads.py +224 -0
  38. cotlab/logging/__init__.py +5 -0
  39. cotlab/logging/json_logger.py +161 -0
  40. cotlab/main.py +317 -0
  41. cotlab/patching/__init__.py +24 -0
  42. cotlab/patching/cache.py +141 -0
  43. cotlab/patching/hooks.py +558 -0
  44. cotlab/patching/interventions.py +86 -0
  45. cotlab/patching/patcher.py +439 -0
  46. cotlab/patching/sae.py +181 -0
  47. cotlab/prompts/__init__.py +43 -0
  48. cotlab/prompts/cardiology.py +378 -0
  49. cotlab/prompts/histopathology.py +265 -0
  50. cotlab/prompts/length_matched_strategies.py +157 -0
  51. cotlab/prompts/mcq.py +193 -0
  52. cotlab/prompts/neurology.py +353 -0
  53. cotlab/prompts/oncology.py +367 -0
  54. cotlab/prompts/plab.py +162 -0
  55. cotlab/prompts/pubhealthbench.py +82 -0
  56. cotlab/prompts/pubmedqa.py +173 -0
  57. cotlab/prompts/radiology.py +414 -0
  58. cotlab/prompts/strategies.py +939 -0
  59. cotlab/prompts/tcga.py +168 -0
  60. cotlab/runner.py +204 -0
  61. cotlab-0.8.0.dist-info/METADATA +166 -0
  62. cotlab-0.8.0.dist-info/RECORD +65 -0
  63. cotlab-0.8.0.dist-info/WHEEL +4 -0
  64. cotlab-0.8.0.dist-info/entry_points.txt +3 -0
  65. cotlab-0.8.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,673 @@
1
+ """SAE Feature Analysis Experiment.
2
+
3
+ Uses GemmaScope-2 JumpReLU Sparse Autoencoders to answer two linked questions:
4
+
5
+ 1. Vocabulary probing (phase 1 — always runs)
6
+ Which sparse features at layers L24-L28 activate most strongly on
7
+ histopathological vocabulary (nuclear pleomorphism, mitotic index, etc.)?
8
+
9
+ 2. Few-shot contrast (phase 2 — runs when ``few_shot_contrast=True``)
10
+ Do those features show significantly higher activation when few-shot
11
+ histopathology exemplars are present in the context vs. absent?
12
+
13
+ Pipeline
14
+ --------
15
+ Phase 1:
16
+ For each term in ``histo_vocab``:
17
+ • Build a short diagnostic context: "Histopathology finding: {term}\\n\\nAnalysis:"
18
+ • Forward pass → cache residual stream at each target layer.
19
+ • SAE-encode residuals at the token positions that decode to the term.
20
+ • Accumulate per-feature activations.
21
+ → Rank features by mean activation across all histo terms.
22
+ → Top-K = "histo features" for phase 2.
23
+
24
+ Phase 2:
25
+ For each dataset sample:
26
+ • Build prompt with few_shot=True (clean).
27
+ • Build prompt with few_shot=False (corrupt / zero-shot).
28
+ • Forward pass for each → SAE-encode residuals at last-token position.
29
+ • Collect activation of each histo feature under both conditions.
30
+ → Mann-Whitney U test per feature; Bonferroni correction over top_k features.
31
+ → Effect size = (mean_few_shot - mean_zero_shot) / pooled_std.
32
+
33
+ No SAELens or TransformerLens dependency — SAE weights are loaded directly
34
+ from HuggingFace using ``huggingface_hub`` (already in project deps).
35
+ """
36
+
37
+ import math
38
+ import os
39
+ import re
40
+ from typing import Any, Dict, List, Optional, Tuple
41
+
42
+ import torch
43
+ from tqdm import tqdm
44
+
45
+ from ..backends.base import InferenceBackend
46
+ from ..core.base import BaseExperiment, ExperimentResult
47
+ from ..core.registry import Registry
48
+ from ..datasets.loaders import BaseDataset
49
+ from ..logging import ExperimentLogger
50
+ from ..patching.sae import GemmaScopeLayer
51
+
52
+ # Histopathology-specific few-shot examples for Phase 2.
53
+ # These are heavily loaded with Phase-1 vocabulary (pleomorphism, mitotic figures,
54
+ # Ki-67, cribriform, comedonecrosis, HER2, perineural invasion, Gleason, adenocarcinoma)
55
+ # so that toggling them on/off gives a signal directly correlated with histo-feature
56
+ # activation — unlike the generic Pneumonia/Meningitis examples that were used before.
57
+ HISTO_FEW_SHOT_EXAMPLES: List[Tuple[str, str]] = [
58
+ (
59
+ "Sections show sheets of pleomorphic cells with vesicular nuclei, prominent nucleoli, "
60
+ "and frequent atypical mitotic figures. Ki-67 index 85%. No glandular differentiation.",
61
+ "High-grade invasive carcinoma with anaplastic features",
62
+ ),
63
+ (
64
+ "Cribriform architecture with comedonecrosis, nuclear atypia grade 3, HER2 3+, "
65
+ "ER-negative, PR-negative. Angiolymphatic invasion present, margins involved.",
66
+ "High-grade DCIS with comedonecrosis; triple-negative, HER2-amplified",
67
+ ),
68
+ (
69
+ "Core biopsy: acinar infiltrative growth, nuclear enlargement, prominent nucleoli, "
70
+ "perineural invasion, Gleason pattern 4+5. Extraprostatic extension confirmed.",
71
+ "Gleason grade group 5 prostatic adenocarcinoma with perineural invasion",
72
+ ),
73
+ ]
74
+
75
+
76
+ # Default histopathology vocabulary — overridable via config.
77
+ DEFAULT_HISTO_VOCAB: List[str] = [
78
+ "pleomorphism",
79
+ "mitosis",
80
+ "mitotic",
81
+ "necrosis",
82
+ "carcinoma",
83
+ "adenocarcinoma",
84
+ "squamous",
85
+ "lymphocyte",
86
+ "stroma",
87
+ "hyperchromasia",
88
+ "chromatin",
89
+ "tubular",
90
+ "papillary",
91
+ "cribriform",
92
+ "comedonecrosis",
93
+ "angiolymphatic",
94
+ "perineural",
95
+ "Gleason",
96
+ "Ki-67",
97
+ "HER2",
98
+ "invasion",
99
+ "differentiation",
100
+ "microinvasion",
101
+ "dysplasia",
102
+ "atypia",
103
+ ]
104
+
105
+
106
+ @Registry.register_experiment("sae_feature_analysis")
107
+ class SAEFeatureAnalysisExperiment(BaseExperiment):
108
+ """
109
+ GemmaScope-2 SAE feature analysis at target residual-stream layers.
110
+
111
+ Combines vocabulary probing (which features respond to histo terms?) with
112
+ an optional few-shot contrast test (do those features activate more when
113
+ histo exemplars are in-context?).
114
+ """
115
+
116
+ def __init__(
117
+ self,
118
+ name: str = "sae_feature_analysis",
119
+ description: str = "GemmaScope-2 SAE histo feature identification and few-shot contrast",
120
+ # SAE configuration
121
+ sae_repo_id: str = "google/gemma-scope-2-270m-it",
122
+ sae_site: str = "resid_post_all",
123
+ sae_width: str = "16k",
124
+ sae_l0: str = "small",
125
+ # Layer selection
126
+ target_layers: Optional[List[int]] = None, # null = [8,9,10,11,12] for 270m
127
+ # Vocabulary probing
128
+ histo_vocab: Optional[List[str]] = None, # null = DEFAULT_HISTO_VOCAB
129
+ vocab_context_prefix: str = "Histopathology finding:",
130
+ top_k_features: int = 20,
131
+ # Few-shot contrast
132
+ few_shot_contrast: bool = True,
133
+ num_samples: int = 50,
134
+ seed: int = 42,
135
+ max_input_tokens: int = 1024,
136
+ answer_cue: str = "\n\nAnswer:",
137
+ **kwargs,
138
+ ):
139
+ self._name = name
140
+ self.description = description
141
+ self.sae_repo_id = sae_repo_id
142
+ self.sae_site = sae_site
143
+ self.sae_width = sae_width
144
+ self.sae_l0 = sae_l0
145
+ self._target_layers_config = target_layers
146
+ self.histo_vocab = histo_vocab or DEFAULT_HISTO_VOCAB
147
+ self.vocab_context_prefix = vocab_context_prefix
148
+ self.top_k_features = top_k_features
149
+ self.few_shot_contrast = few_shot_contrast
150
+ self.num_samples = num_samples
151
+ self.seed = seed
152
+ self.max_input_tokens = max_input_tokens
153
+ self.answer_cue = answer_cue
154
+
155
+ @property
156
+ def name(self) -> str:
157
+ return self._name
158
+
159
+ # ------------------------------------------------------------------
160
+ # Layer resolution
161
+ # ------------------------------------------------------------------
162
+
163
+ def _resolve_layers(self, backend: InferenceBackend) -> List[int]:
164
+ if self._target_layers_config is not None:
165
+ return list(self._target_layers_config)
166
+ # Default: all layers — lets post-hoc analysis focus on any band (e.g. L24-28).
167
+ return list(range(backend.hook_manager.num_layers))
168
+
169
+ # ------------------------------------------------------------------
170
+ # SAE loading
171
+ # ------------------------------------------------------------------
172
+
173
+ def _load_saes(self, layers: List[int], device: str) -> Dict[int, GemmaScopeLayer]:
174
+ """Download and move SAE weights for each target layer."""
175
+ hf_token = os.getenv("HF_TOKEN")
176
+ saes: Dict[int, GemmaScopeLayer] = {}
177
+ for layer_idx in layers:
178
+ sae = GemmaScopeLayer.from_pretrained(
179
+ repo_id=self.sae_repo_id,
180
+ layer=layer_idx,
181
+ site=self.sae_site,
182
+ width=self.sae_width,
183
+ l0_label=self.sae_l0,
184
+ token=hf_token,
185
+ )
186
+ saes[layer_idx] = sae.to(device)
187
+ return saes
188
+
189
+ # ------------------------------------------------------------------
190
+ # Residual stream extraction
191
+ # ------------------------------------------------------------------
192
+
193
+ def _extract_residuals(
194
+ self,
195
+ backend: InferenceBackend,
196
+ tokens: Dict[str, torch.Tensor],
197
+ layers: List[int],
198
+ ) -> Dict[int, torch.Tensor]:
199
+ """Single forward pass caching residual stream at each target layer.
200
+
201
+ Returns:
202
+ dict: layer_idx → float32 CPU tensor of shape [seq_len, d_model].
203
+ """
204
+ cache: Dict[int, torch.Tensor] = {}
205
+
206
+ def make_hook(layer_idx: int):
207
+ def hook(module, inp, output):
208
+ tensor = output[0] if isinstance(output, tuple) else output
209
+ with torch.no_grad():
210
+ cache[layer_idx] = tensor[0].detach().float().cpu()
211
+
212
+ return hook
213
+
214
+ handles = []
215
+ for layer_idx in layers:
216
+ if layer_idx < backend.hook_manager.num_layers:
217
+ mod = backend.hook_manager.get_residual_module(layer_idx)
218
+ handles.append(mod.register_forward_hook(make_hook(layer_idx)))
219
+
220
+ try:
221
+ with torch.no_grad():
222
+ backend._model(**tokens)
223
+ finally:
224
+ for h in handles:
225
+ h.remove()
226
+
227
+ return cache
228
+
229
+ # ------------------------------------------------------------------
230
+ # Token-position helpers
231
+ # ------------------------------------------------------------------
232
+
233
+ def _term_token_positions(
234
+ self,
235
+ input_ids: torch.Tensor,
236
+ tokenizer,
237
+ term: str,
238
+ ) -> List[int]:
239
+ """Find token positions in input_ids that overlap with ``term``.
240
+
241
+ Uses char-offset mapping to align decoded tokens with the term string.
242
+ Returns an empty list if the term is not found in the decoded text.
243
+ """
244
+ full_text = tokenizer.decode(input_ids.tolist(), skip_special_tokens=False)
245
+ term_lower = term.lower()
246
+ # Find all char-level occurrences of the term.
247
+ spans: List[Tuple[int, int]] = []
248
+ for m in re.finditer(re.escape(term_lower), full_text.lower()):
249
+ spans.append((m.start(), m.end()))
250
+ if not spans:
251
+ return []
252
+
253
+ # Build cumulative char offset per token.
254
+ tok_ranges: List[Tuple[int, int]] = []
255
+ offset = 0
256
+ for tid in input_ids.tolist():
257
+ decoded = tokenizer.decode([tid], skip_special_tokens=False)
258
+ tok_ranges.append((offset, offset + len(decoded)))
259
+ offset += len(decoded)
260
+
261
+ positions: List[int] = []
262
+ for i, (tok_start, tok_end) in enumerate(tok_ranges):
263
+ for es, ee in spans:
264
+ if tok_start < ee and tok_end > es: # overlap
265
+ positions.append(i)
266
+ break
267
+
268
+ return positions
269
+
270
+ def _tokenize(self, tokenizer, text: str, device: str) -> Dict[str, torch.Tensor]:
271
+ return tokenizer(
272
+ text,
273
+ return_tensors="pt",
274
+ truncation=True,
275
+ max_length=self.max_input_tokens,
276
+ ).to(device)
277
+
278
+ # ------------------------------------------------------------------
279
+ # Phase 1: vocabulary probing
280
+ # ------------------------------------------------------------------
281
+
282
+ def _probe_vocab(
283
+ self,
284
+ backend: InferenceBackend,
285
+ saes: Dict[int, GemmaScopeLayer],
286
+ layers: List[int],
287
+ ) -> Dict[int, Dict[int, float]]:
288
+ """Probe which SAE features activate on histo vocabulary tokens.
289
+
290
+ For each term in ``histo_vocab``:
291
+ 1. Build a short diagnostic context.
292
+ 2. Forward pass → residuals at each target layer.
293
+ 3. SAE-encode residuals at the term's token positions.
294
+ 4. Accumulate mean activation per feature.
295
+
296
+ Returns:
297
+ histo_scores[layer_idx][feature_idx] = mean activation across all terms.
298
+ """
299
+ tokenizer = backend._tokenizer
300
+ device = backend.device
301
+
302
+ # Accumulators: layer → feature_idx → list of activation values.
303
+ accum: Dict[int, Dict[int, List[float]]] = {layer: {} for layer in layers}
304
+
305
+ print(f"\nPhase 1: vocabulary probing over {len(self.histo_vocab)} terms …")
306
+ for term in tqdm(self.histo_vocab, desc="Vocab probe"):
307
+ prompt = f"{self.vocab_context_prefix} {term}{self.answer_cue}"
308
+ tokens = self._tokenize(tokenizer, prompt, device)
309
+ input_ids = tokens["input_ids"][0]
310
+
311
+ term_positions = self._term_token_positions(input_ids, tokenizer, term)
312
+ if not term_positions:
313
+ # Fallback: use all non-special token positions.
314
+ term_positions = list(range(len(input_ids)))
315
+
316
+ residuals = self._extract_residuals(backend, tokens, layers)
317
+
318
+ for layer_idx, resid in residuals.items():
319
+ # resid: [seq_len, d_model] (CPU float32)
320
+ sae = saes[layer_idx]
321
+ term_resid = resid[term_positions] # [n_toks, d_model]
322
+ with torch.no_grad():
323
+ features = sae.encode(term_resid.to(sae.w_enc.device))
324
+ # mean over term tokens → [d_sae]
325
+ mean_acts = features.mean(dim=0).cpu()
326
+
327
+ for feat_idx, val in enumerate(mean_acts.tolist()):
328
+ if val > 0:
329
+ accum[layer_idx].setdefault(feat_idx, []).append(val)
330
+
331
+ # Aggregate: mean activation per feature (0 if never fired).
332
+ histo_scores: Dict[int, Dict[int, float]] = {}
333
+ for layer_idx in layers:
334
+ scores: Dict[int, float] = {}
335
+ for feat_idx, vals in accum[layer_idx].items():
336
+ scores[feat_idx] = sum(vals) / len(vals)
337
+ histo_scores[layer_idx] = scores
338
+
339
+ return histo_scores
340
+
341
+ # ------------------------------------------------------------------
342
+ # Phase 2: few-shot contrast
343
+ # ------------------------------------------------------------------
344
+
345
+ def _build_histo_few_shot_block(self) -> str:
346
+ """Build a histopathology-specific few-shot context block.
347
+
348
+ Uses HISTO_FEW_SHOT_EXAMPLES — vocabulary-dense pathology report snippets
349
+ that directly activate the same sparse features Phase 1 identifies.
350
+ These replace the old generic Pneumonia/Meningitis examples which had no
351
+ overlap with histopathology vocabulary.
352
+ """
353
+ lines = [f"Q: {q}\nA: {a}" for q, a in HISTO_FEW_SHOT_EXAMPLES]
354
+ return "Histopathology examples:\n\n" + "\n\n".join(lines) + "\n\n"
355
+
356
+ def _build_prompt(self, prompt_strategy: Any, text: str, metadata: dict, few_shot: bool) -> str:
357
+ """Build prompt, prepending histo-specific few-shot block when few_shot=True.
358
+
359
+ Bypasses the prompt strategy's own few_shot flag entirely — that flag uses
360
+ generic medical examples (Pneumonia, Meningitis) which are irrelevant to
361
+ histopathology feature activation. Instead we directly prepend or omit
362
+ the HISTO_FEW_SHOT_EXAMPLES block so the contrast is always histo-specific.
363
+ """
364
+ base = prompt_strategy.build_prompt(
365
+ {"text": text, "question": text, "report": text, "metadata": metadata}
366
+ )
367
+ base += self.answer_cue
368
+ if few_shot:
369
+ return self._build_histo_few_shot_block() + base
370
+ return base
371
+
372
+ def _contrast_few_shot(
373
+ self,
374
+ backend: InferenceBackend,
375
+ dataset: BaseDataset,
376
+ prompt_strategy: Any,
377
+ saes: Dict[int, GemmaScopeLayer],
378
+ layers: List[int],
379
+ top_features: Dict[int, List[int]],
380
+ ) -> Dict[int, Dict[int, Dict[str, List[float]]]]:
381
+ """Collect per-feature activations under few-shot vs zero-shot conditions.
382
+
383
+ Returns:
384
+ contrast[layer_idx][feature_idx] = {
385
+ "few_shot": [float, ...], # one value per sample
386
+ "zero_shot": [float, ...],
387
+ }
388
+ """
389
+ tokenizer = backend._tokenizer
390
+ device = backend.device
391
+ samples = dataset.sample(self.num_samples, seed=self.seed)
392
+
393
+ # contrast[layer][feature] = {few_shot: [], zero_shot: []}
394
+ contrast: Dict[int, Dict[int, Dict[str, List[float]]]] = {
395
+ layer: {f: {"few_shot": [], "zero_shot": []} for f in top_features[layer]}
396
+ for layer in layers
397
+ }
398
+
399
+ print(f"\nPhase 2: few-shot contrast over {len(samples)} samples …")
400
+ for sample in tqdm(samples, desc="Few-shot contrast"):
401
+ for condition, few_shot_flag in [("few_shot", True), ("zero_shot", False)]:
402
+ try:
403
+ prompt_str = self._build_prompt(
404
+ prompt_strategy, sample.text, sample.metadata or {}, few_shot_flag
405
+ )
406
+ except Exception as exc:
407
+ tqdm.write(f" [skip] sample {sample.idx} prompt ({condition}): {exc}")
408
+ continue
409
+
410
+ tokens = self._tokenize(tokenizer, prompt_str, device)
411
+ residuals = self._extract_residuals(backend, tokens, layers)
412
+
413
+ for layer_idx, resid in residuals.items():
414
+ # Use last-token residual — position before the answer letter.
415
+ last_resid = resid[-1].unsqueeze(0) # [1, d_model]
416
+ sae = saes[layer_idx]
417
+ with torch.no_grad():
418
+ features = sae.encode(last_resid.to(sae.w_enc.device))
419
+ feat_acts = features[0].cpu() # [d_sae]
420
+
421
+ for feat_idx in top_features[layer_idx]:
422
+ val = float(feat_acts[feat_idx].item())
423
+ contrast[layer_idx][feat_idx][condition].append(val)
424
+
425
+ torch.cuda.empty_cache() if torch.cuda.is_available() else None
426
+
427
+ return contrast
428
+
429
+ # ------------------------------------------------------------------
430
+ # Statistical testing
431
+ # ------------------------------------------------------------------
432
+
433
+ @staticmethod
434
+ def _mann_whitney(a: List[float], b: List[float]) -> Tuple[float, float, float]:
435
+ """Mann-Whitney U test (two-sided) + effect size.
436
+
437
+ Returns:
438
+ (U_statistic, p_value, effect_size)
439
+ effect_size = (mean_a - mean_b) / pooled_std (Cohen's d approximation)
440
+ """
441
+ if not a or not b:
442
+ return (float("nan"), float("nan"), float("nan"))
443
+
444
+ # Try scipy first.
445
+ try:
446
+ from scipy.stats import mannwhitneyu # noqa: PLC0415
447
+
448
+ result = mannwhitneyu(a, b, alternative="two-sided")
449
+ U, p = float(result.statistic), float(result.pvalue)
450
+ except ImportError:
451
+ # Normal approximation for large samples.
452
+ n1, n2 = len(a), len(b)
453
+ combined = sorted(enumerate(a + b), key=lambda t: t[1])
454
+ ranks = [0.0] * (n1 + n2)
455
+ for rank_idx, (orig_idx, _) in enumerate(combined, start=1):
456
+ ranks[orig_idx] = float(rank_idx)
457
+ U = sum(ranks[:n1]) - n1 * (n1 + 1) / 2
458
+ mu = n1 * n2 / 2
459
+ sigma = math.sqrt(n1 * n2 * (n1 + n2 + 1) / 12 + 1e-12)
460
+ z = (U - mu) / sigma
461
+ p = float(2 * (1 - 0.5 * (1 + math.erf(abs(z) / math.sqrt(2)))))
462
+
463
+ # Effect size (Cohen's d approximation).
464
+ mean_a = sum(a) / len(a)
465
+ mean_b = sum(b) / len(b)
466
+ var_a = sum((v - mean_a) ** 2 for v in a) / max(len(a) - 1, 1)
467
+ var_b = sum((v - mean_b) ** 2 for v in b) / max(len(b) - 1, 1)
468
+ pooled_std = math.sqrt((var_a + var_b) / 2 + 1e-12)
469
+ effect_size = (mean_a - mean_b) / pooled_std
470
+
471
+ return (U, p, effect_size)
472
+
473
+ @staticmethod
474
+ def _bonferroni(p_values: List[float]) -> List[float]:
475
+ n = len(p_values)
476
+ return [min(1.0, p * n) for p in p_values]
477
+
478
+ # ------------------------------------------------------------------
479
+ # Summary printing
480
+ # ------------------------------------------------------------------
481
+
482
+ def _print_vocab_summary(
483
+ self,
484
+ histo_scores: Dict[int, Dict[int, float]],
485
+ top_features: Dict[int, List[int]],
486
+ ) -> None:
487
+ print("\n" + "=" * 70)
488
+ print("PHASE 1 — VOCABULARY PROBING RESULTS")
489
+ print("=" * 70)
490
+ for layer_idx, feat_list in top_features.items():
491
+ scores = histo_scores[layer_idx]
492
+ print(f"\n Layer {layer_idx} (top {len(feat_list)} histo features)")
493
+ print(f" {'Feature':>10} {'Mean Activation':>16}")
494
+ print(" " + "-" * 30)
495
+ for fid in feat_list:
496
+ print(f" {fid:>10} {scores.get(fid, 0.0):>16.4f}")
497
+
498
+ def _print_contrast_summary(
499
+ self,
500
+ contrast_stats: Dict[int, List[Dict]],
501
+ ) -> None:
502
+ print("\n" + "=" * 70)
503
+ print("PHASE 2 — FEW-SHOT CONTRAST RESULTS")
504
+ print("=" * 70)
505
+ for layer_idx, stats_list in contrast_stats.items():
506
+ sig = [s for s in stats_list if s["p_bonferroni"] < 0.05]
507
+ print(
508
+ f"\n Layer {layer_idx} — {len(sig)}/{len(stats_list)} features significant (p_bonf<0.05)"
509
+ )
510
+ if sig:
511
+ print(
512
+ f" {'Feature':>10} {'Effect':>8} {'p_raw':>9} "
513
+ f"{'p_bonf':>9} {'mean_fs':>9} {'mean_zs':>9}"
514
+ )
515
+ print(" " + "-" * 62)
516
+ for s in sorted(sig, key=lambda x: -abs(x["effect_size"])):
517
+ direction = "↑" if s["effect_size"] > 0 else "↓"
518
+ print(
519
+ f" {s['feature_idx']:>10} "
520
+ f"{s['effect_size']:>+7.3f}{direction} "
521
+ f"{s['p_raw']:>9.4f} "
522
+ f"{s['p_bonferroni']:>9.4f} "
523
+ f"{s['mean_few_shot']:>9.4f} "
524
+ f"{s['mean_zero_shot']:>9.4f}"
525
+ )
526
+ print()
527
+ print(" Positive effect = higher activation with few-shot exemplars.")
528
+ print("=" * 70)
529
+
530
+ # ------------------------------------------------------------------
531
+ # Main entry point
532
+ # ------------------------------------------------------------------
533
+
534
+ def run(
535
+ self,
536
+ backend: InferenceBackend,
537
+ dataset: BaseDataset,
538
+ prompt_strategy: Any,
539
+ logger: Optional[ExperimentLogger] = None,
540
+ **kwargs,
541
+ ) -> ExperimentResult:
542
+ """Run SAE feature analysis experiment."""
543
+
544
+ layers = self._resolve_layers(backend)
545
+
546
+ print(f"Model : {backend.model_name}")
547
+ print(f"SAE repo : {self.sae_repo_id}")
548
+ print(f"SAE site : {self.sae_site} width={self.sae_width} l0={self.sae_l0}")
549
+ print(f"Target layers : {layers}")
550
+ print(f"Histo vocab : {len(self.histo_vocab)} terms")
551
+ print(f"Few-shot contr: {self.few_shot_contrast}")
552
+
553
+ # Load SAEs.
554
+ print("\nLoading SAE weights …")
555
+ saes = self._load_saes(layers, backend.device)
556
+
557
+ # ── Phase 1: vocabulary probing ────────────────────────────────
558
+ histo_scores = self._probe_vocab(backend, saes, layers)
559
+
560
+ # Select top-K features per layer by mean histo activation.
561
+ top_features: Dict[int, List[int]] = {}
562
+ for layer_idx in layers:
563
+ scores = histo_scores[layer_idx]
564
+ ranked = sorted(scores, key=lambda f: -scores[f])
565
+ top_features[layer_idx] = ranked[: self.top_k_features]
566
+
567
+ self._print_vocab_summary(histo_scores, top_features)
568
+
569
+ # ── Phase 2: few-shot contrast ─────────────────────────────────
570
+ contrast_stats_per_layer: Dict[int, List[Dict]] = {}
571
+
572
+ if self.few_shot_contrast and dataset is not None:
573
+ contrast_raw = self._contrast_few_shot(
574
+ backend, dataset, prompt_strategy, saes, layers, top_features
575
+ )
576
+
577
+ for layer_idx in layers:
578
+ stats_list: List[Dict] = []
579
+ raw_p: List[float] = []
580
+
581
+ for feat_idx in top_features[layer_idx]:
582
+ fs = contrast_raw[layer_idx][feat_idx]["few_shot"]
583
+ zs = contrast_raw[layer_idx][feat_idx]["zero_shot"]
584
+ U, p_raw, effect = self._mann_whitney(fs, zs)
585
+ raw_p.append(p_raw if not math.isnan(p_raw) else 1.0)
586
+ stats_list.append(
587
+ {
588
+ "feature_idx": feat_idx,
589
+ "histo_score": round(histo_scores[layer_idx].get(feat_idx, 0.0), 4),
590
+ "U_statistic": round(U, 2) if not math.isnan(U) else None,
591
+ "p_raw": round(p_raw, 6) if not math.isnan(p_raw) else None,
592
+ "effect_size": round(effect, 4) if not math.isnan(effect) else None,
593
+ "mean_few_shot": round(sum(fs) / len(fs), 4) if fs else None,
594
+ "mean_zero_shot": round(sum(zs) / len(zs), 4) if zs else None,
595
+ "n_few_shot": len(fs),
596
+ "n_zero_shot": len(zs),
597
+ "p_bonferroni": None, # filled below
598
+ }
599
+ )
600
+
601
+ bonf_p = self._bonferroni(raw_p)
602
+ for entry, bp in zip(stats_list, bonf_p):
603
+ entry["p_bonferroni"] = round(bp, 6)
604
+
605
+ contrast_stats_per_layer[layer_idx] = stats_list
606
+
607
+ self._print_contrast_summary(contrast_stats_per_layer)
608
+
609
+ # ── Build result ───────────────────────────────────────────────
610
+ # Compact metrics: top feature per layer + significant contrast count.
611
+ compact_metrics: Dict[str, Any] = {
612
+ "layers_analysed": layers,
613
+ "sae_repo_id": self.sae_repo_id,
614
+ "top_k_features": self.top_k_features,
615
+ }
616
+ for layer_idx in layers:
617
+ prefix = f"layer_{layer_idx}"
618
+ scores = histo_scores[layer_idx]
619
+ feats = top_features[layer_idx]
620
+ compact_metrics[f"{prefix}_top_feature"] = feats[0] if feats else None
621
+ compact_metrics[f"{prefix}_top_histo_score"] = (
622
+ round(scores[feats[0]], 4) if feats else 0.0
623
+ )
624
+ if layer_idx in contrast_stats_per_layer:
625
+ stats = contrast_stats_per_layer[layer_idx]
626
+ n_sig = sum(
627
+ 1 for s in stats if s["p_bonferroni"] is not None and s["p_bonferroni"] < 0.05
628
+ )
629
+ fs_acts = [s["mean_few_shot"] for s in stats if s["mean_few_shot"] is not None]
630
+ zs_acts = [s["mean_zero_shot"] for s in stats if s["mean_zero_shot"] is not None]
631
+ effects = [s["effect_size"] for s in stats if s["effect_size"] is not None]
632
+ compact_metrics[f"{prefix}_n_significant_features"] = n_sig
633
+ compact_metrics[f"{prefix}_mean_act_few_shot"] = (
634
+ round(sum(fs_acts) / len(fs_acts), 4) if fs_acts else None
635
+ )
636
+ compact_metrics[f"{prefix}_mean_act_zero_shot"] = (
637
+ round(sum(zs_acts) / len(zs_acts), 4) if zs_acts else None
638
+ )
639
+ compact_metrics[f"{prefix}_mean_effect_size"] = (
640
+ round(sum(effects) / len(effects), 4) if effects else None
641
+ )
642
+
643
+ return ExperimentResult(
644
+ experiment_name=self.name,
645
+ model_name=backend.model_name,
646
+ prompt_strategy=(
647
+ prompt_strategy.name if hasattr(prompt_strategy, "name") else "custom"
648
+ ),
649
+ metrics=compact_metrics,
650
+ raw_outputs={
651
+ "histo_scores_top_features": {
652
+ str(layer): {
653
+ str(f): histo_scores[layer].get(f, 0.0) for f in top_features[layer]
654
+ }
655
+ for layer in layers
656
+ },
657
+ "contrast_stats": {
658
+ str(layer): contrast_stats_per_layer.get(layer, []) for layer in layers
659
+ },
660
+ },
661
+ metadata={
662
+ "sae_repo_id": self.sae_repo_id,
663
+ "sae_site": self.sae_site,
664
+ "sae_width": self.sae_width,
665
+ "sae_l0": self.sae_l0,
666
+ "target_layers": layers,
667
+ "histo_vocab": self.histo_vocab,
668
+ "top_k_features": self.top_k_features,
669
+ "few_shot_contrast": self.few_shot_contrast,
670
+ "num_samples": self.num_samples,
671
+ "seed": self.seed,
672
+ },
673
+ )