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,712 @@
1
+ """H-Neuron Analysis Experiment.
2
+
3
+ Identifies FFN neurons that predict hallucination/incorrect answers in MedGemma
4
+ using the CETT (Contribution to rEsidual sTream norm of Token t) metric from
5
+ arXiv:2512.01797 (Gao et al., 2025).
6
+
7
+ Pipeline
8
+ --------
9
+ Phase 1 — Feature Extraction:
10
+ For each MCQ sample, run a single forward pass and hook every FFN down-projection
11
+ layer to capture z_t (intermediate SwiGLU activations). Compute per-neuron CETT
12
+ at the final prompt token (answer cue position). Label each sample correct/incorrect
13
+ via ground-truth comparison.
14
+
15
+ With contrastive_labeling=True (3-vs-1 strategy):
16
+ - CETT is captured at the GENERATED answer token (A/B/C/D), not the last prompt token.
17
+ - Each QA sample contributes TWO training rows:
18
+ answer-token row: y=1 if hallucinatory, y=0 if faithful
19
+ other-token row: y=0 always (negative control)
20
+ - H-Neurons have POSITIVE weight → active during hallucination generation.
21
+ Causal direction is inverted vs default: suppression RAISES accuracy,
22
+ amplification LOWERS it.
23
+
24
+ Phase 2 — H-Neuron Discovery:
25
+ Train an L1-regularised logistic regression on the (n_rows × n_features) CETT
26
+ matrix. Neurons with positive weights are H-Neurons.
27
+
28
+ A random-neuron baseline is always computed: same N randomly selected neurons,
29
+ same probe, same hyperparameters. This replicates Table 1 of the paper.
30
+
31
+ Phase 3 — Causal Validation:
32
+ Re-run inference on a held-out split with H-Neuron activations scaled by α ∈ [0, 2].
33
+ Default labeling: α = 0 drops accuracy, α > 1 raises accuracy.
34
+ Contrastive labeling: α = 0 raises accuracy, α > 1 drops accuracy.
35
+
36
+ CETT Formula
37
+ ------------
38
+ CETT(j, t) = |z_{j,t}| · ‖W_down[:, j]‖₂ / ‖h_t‖₂
39
+
40
+ where z_t = SwiGLU output (input to W_down), h_t = W_down · z_t (FFN output).
41
+ Column norms ‖W_down[:, j]‖₂ are precomputed once per layer.
42
+
43
+ Reference: arXiv:2512.01797 — "H-Neurons: On the Existence, Impact, and Origin
44
+ of Hallucination-Associated Neurons in LLMs"
45
+ """
46
+
47
+ from typing import Any, Dict, List, Optional, Tuple
48
+
49
+ import numpy as np
50
+ import torch
51
+ from sklearn.linear_model import LogisticRegression
52
+ from sklearn.metrics import balanced_accuracy_score, roc_auc_score
53
+ from sklearn.model_selection import train_test_split
54
+ from tqdm import tqdm
55
+
56
+ from ..backends.base import InferenceBackend
57
+ from ..core.base import BaseExperiment, ExperimentResult
58
+ from ..core.registry import Registry
59
+ from ..datasets.loaders import BaseDataset
60
+ from ..logging import ExperimentLogger
61
+
62
+ _MCQ_LETTERS = list("ABCDEFGHIJ")
63
+
64
+
65
+ @Registry.register_experiment("h_neuron_analysis")
66
+ class HNeuronAnalysisExperiment(BaseExperiment):
67
+ """
68
+ H-Neuron Analysis: find FFN neurons predicting hallucination in MedGemma.
69
+
70
+ Implements the CETT metric (arXiv:2512.01797) adapted for MCQ tasks where
71
+ ground-truth labels are available directly (no consistency-filter needed).
72
+ """
73
+
74
+ def __init__(
75
+ self,
76
+ name: str = "h_neuron_analysis",
77
+ description: str = "CETT-based H-Neuron discovery and causal validation",
78
+ num_samples: int = 500,
79
+ validation_split: float = 0.2, # Fraction held out for causal validation
80
+ l1_C: float = 0.01, # Inverse L1 strength — lower = sparser
81
+ alpha_values: List[float] = None, # Causal scaling factors
82
+ layer_stride: int = 1, # Sample every Nth layer (1 = all)
83
+ seed: int = 42,
84
+ max_input_tokens: int = 1024,
85
+ answer_cue: str = "\n\nAnswer:",
86
+ mcq_letters: Optional[List[str]] = None,
87
+ contrastive_labeling: bool = False, # 3-vs-1: CETT at generated answer token, hallucination=1
88
+ **kwargs,
89
+ ):
90
+ self._name = name
91
+ self.description = description
92
+ self.num_samples = num_samples
93
+ self.validation_split = validation_split
94
+ self.l1_C = l1_C
95
+ self.alpha_values = alpha_values if alpha_values is not None else [0.0, 0.5, 1.5, 2.0]
96
+ self.layer_stride = layer_stride
97
+ self.seed = seed
98
+ self.max_input_tokens = max_input_tokens
99
+ self.answer_cue = answer_cue
100
+ self.mcq_letters = mcq_letters or _MCQ_LETTERS
101
+ self.contrastive_labeling = contrastive_labeling
102
+
103
+ @property
104
+ def name(self) -> str:
105
+ return self._name
106
+
107
+ # ------------------------------------------------------------------
108
+ # Layer and tokenizer helpers
109
+ # ------------------------------------------------------------------
110
+
111
+ def _resolve_layers(self, backend: InferenceBackend) -> List[int]:
112
+ all_layers = backend.hook_manager.available_layers
113
+ return all_layers[:: self.layer_stride]
114
+
115
+ def _get_letter_ids(self, backend: InferenceBackend) -> Dict[str, int]:
116
+ """Map MCQ letters → single best token id for logit extraction."""
117
+ tokenizer = backend._tokenizer
118
+ letter_ids = {}
119
+ for letter in self.mcq_letters:
120
+ ids = tokenizer.encode(letter, add_special_tokens=False)
121
+ if ids:
122
+ letter_ids[letter] = ids[0]
123
+ return letter_ids
124
+
125
+ def _build_prompt(self, prompt_strategy, sample) -> str:
126
+ text = sample.text
127
+ prompt = prompt_strategy.build_prompt(
128
+ {"text": text, "question": text, "metadata": sample.metadata or {}}
129
+ )
130
+ return prompt + self.answer_cue
131
+
132
+ def _tokenize(self, backend: InferenceBackend, prompt: str) -> Dict[str, torch.Tensor]:
133
+ tokenizer = backend._tokenizer
134
+ tokens = tokenizer(
135
+ prompt,
136
+ return_tensors="pt",
137
+ truncation=True,
138
+ max_length=self.max_input_tokens,
139
+ )
140
+ return {k: v.to(backend.device) for k, v in tokens.items()}
141
+
142
+ def _predict_letter(self, logits: torch.Tensor, letter_ids: Dict[str, int]) -> str:
143
+ return max(letter_ids.items(), key=lambda kv: logits[kv[1]].item())[0]
144
+
145
+ def _ground_truth_letter(self, sample) -> Optional[str]:
146
+ """Extract ground-truth answer letter from sample label."""
147
+ label = sample.label
148
+ if label is None:
149
+ return None
150
+ if isinstance(label, str) and len(label) == 1 and label.upper() in self.mcq_letters:
151
+ return label.upper()
152
+ if isinstance(label, dict):
153
+ for key in ("answer", "label", "correct_answer"):
154
+ val = label.get(key)
155
+ if isinstance(val, str) and val.upper() in self.mcq_letters:
156
+ return val.upper()
157
+ return None
158
+
159
+ # ------------------------------------------------------------------
160
+ # CETT computation
161
+ # ------------------------------------------------------------------
162
+
163
+ def _precompute_col_norms(
164
+ self, backend: InferenceBackend, layers: List[int]
165
+ ) -> Dict[int, torch.Tensor]:
166
+ """Precompute ‖W_down[:, j]‖₂ for each layer. Shape per layer: (intermediate_dim,)."""
167
+ col_norms = {}
168
+ for layer_idx in layers:
169
+ down_proj = backend.hook_manager.get_mlp_down_proj_module(layer_idx)
170
+ W = down_proj.weight.detach().float() # (hidden_dim, intermediate_dim)
171
+ col_norms[layer_idx] = torch.norm(W, dim=0).cpu() # (intermediate_dim,)
172
+ return col_norms
173
+
174
+ def _forward_cett(
175
+ self,
176
+ backend: InferenceBackend,
177
+ tokens: Dict[str, torch.Tensor],
178
+ layers: List[int],
179
+ col_norms: Dict[int, torch.Tensor],
180
+ ) -> Tuple[torch.Tensor, torch.Tensor]:
181
+ """
182
+ Single forward pass — extract CETT features at the final token for all layers.
183
+
184
+ Returns:
185
+ cett_vec: (n_layers * intermediate_dim,) float32 — concatenated CETT values
186
+ logits: (vocab_size,) float32 — output logits at final token
187
+ """
188
+ z_cache: Dict[int, torch.Tensor] = {}
189
+ h_cache: Dict[int, torch.Tensor] = {}
190
+ handles = []
191
+
192
+ for layer_idx in layers:
193
+ down_proj = backend.hook_manager.get_mlp_down_proj_module(layer_idx)
194
+
195
+ def make_hook(idx: int):
196
+ def hook(module, input, output):
197
+ z = input[0] # (batch, seq_len, intermediate_dim)
198
+ h = output # (batch, seq_len, hidden_dim)
199
+ # Capture last prompt token only
200
+ z_cache[idx] = z[0, -1, :].detach().float().cpu()
201
+ h_cache[idx] = h[0, -1, :].detach().float().cpu()
202
+ return output
203
+
204
+ return hook
205
+
206
+ handles.append(down_proj.register_forward_hook(make_hook(layer_idx)))
207
+
208
+ try:
209
+ with torch.no_grad():
210
+ out = backend._model(**tokens)
211
+ finally:
212
+ for h in handles:
213
+ h.remove()
214
+
215
+ logits = out.logits[0, -1, :].detach().float().cpu()
216
+
217
+ # Compute CETT per layer and concatenate
218
+ cett_parts = []
219
+ for layer_idx in layers:
220
+ z_last = z_cache[layer_idx] # (intermediate_dim,)
221
+ h_last = h_cache[layer_idx] # (hidden_dim,)
222
+ h_norm = torch.norm(h_last).item() + 1e-8
223
+ cett = (z_last * col_norms[layer_idx]) / h_norm # signed: preserves direction for probe
224
+ cett_parts.append(cett)
225
+
226
+ return torch.cat(cett_parts, dim=0), logits
227
+
228
+ def _forward_cett_at_answer_token(
229
+ self,
230
+ backend: InferenceBackend,
231
+ tokens: Dict[str, torch.Tensor],
232
+ predicted_letter: str,
233
+ letter_ids: Dict[str, int],
234
+ layers: List[int],
235
+ col_norms: Dict[int, torch.Tensor],
236
+ ) -> torch.Tensor:
237
+ """
238
+ Append the predicted answer letter token to the prompt and run a forward pass.
239
+ Captures CETT at the generated answer token position (last token).
240
+
241
+ Used by contrastive_labeling=True: hallucination neurons fire
242
+ specifically when the model is generating its (wrong) answer token.
243
+
244
+ Returns:
245
+ cett_answer: (n_layers * intermediate_dim,) float32 — CETT at answer token
246
+ """
247
+ letter_token_id = letter_ids[predicted_letter]
248
+ input_ids = tokens["input_ids"] # (1, seq_len)
249
+ letter_t = torch.tensor([[letter_token_id]], device=input_ids.device)
250
+ extended_ids = torch.cat([input_ids, letter_t], dim=1)
251
+
252
+ extended_tokens: Dict[str, torch.Tensor] = {"input_ids": extended_ids}
253
+ if "attention_mask" in tokens:
254
+ m = tokens["attention_mask"]
255
+ extended_tokens["attention_mask"] = torch.cat(
256
+ [m, torch.ones((1, 1), device=m.device, dtype=m.dtype)], dim=1
257
+ )
258
+
259
+ z_cache: Dict[int, torch.Tensor] = {}
260
+ h_cache: Dict[int, torch.Tensor] = {}
261
+ handles = []
262
+
263
+ for layer_idx in layers:
264
+ down_proj = backend.hook_manager.get_mlp_down_proj_module(layer_idx)
265
+
266
+ def make_hook(idx: int):
267
+ def hook(module, input, output):
268
+ z = input[0]
269
+ h = output
270
+ z_cache[idx] = z[0, -1, :].detach().float().cpu() # answer token
271
+ h_cache[idx] = h[0, -1, :].detach().float().cpu()
272
+ return output
273
+
274
+ return hook
275
+
276
+ handles.append(down_proj.register_forward_hook(make_hook(layer_idx)))
277
+
278
+ try:
279
+ with torch.no_grad():
280
+ backend._model(**extended_tokens)
281
+ finally:
282
+ for h in handles:
283
+ h.remove()
284
+
285
+ cett_parts = []
286
+ for layer_idx in layers:
287
+ h_norm = torch.norm(h_cache[layer_idx]).item() + 1e-8
288
+ cett = (z_cache[layer_idx] * col_norms[layer_idx]) / h_norm
289
+ cett_parts.append(cett)
290
+
291
+ return torch.cat(cett_parts, dim=0)
292
+
293
+ # ------------------------------------------------------------------
294
+ # Causal validation
295
+ # ------------------------------------------------------------------
296
+
297
+ def _forward_with_suppression(
298
+ self,
299
+ backend: InferenceBackend,
300
+ tokens: Dict[str, torch.Tensor],
301
+ h_neurons: List[Tuple[int, int]], # [(layer_idx, neuron_idx), ...]
302
+ alpha: float,
303
+ layers: List[int],
304
+ ) -> torch.Tensor:
305
+ """
306
+ Forward pass scaling H-Neuron activations by alpha before W_down projection.
307
+
308
+ Uses register_forward_pre_hook on down_proj so z_{j,t} is scaled before
309
+ being multiplied through W_down, exactly as in the paper's intervention.
310
+ """
311
+ # Group H-neurons by layer for efficient masking
312
+ neurons_by_layer: Dict[int, List[int]] = {}
313
+ for layer_idx, neuron_idx in h_neurons:
314
+ neurons_by_layer.setdefault(layer_idx, []).append(neuron_idx)
315
+
316
+ handles = []
317
+ for layer_idx in layers:
318
+ if layer_idx not in neurons_by_layer:
319
+ continue
320
+ neuron_indices = torch.tensor(neurons_by_layer[layer_idx], dtype=torch.long)
321
+ down_proj = backend.hook_manager.get_mlp_down_proj_module(layer_idx)
322
+
323
+ def make_pre_hook(indices: torch.Tensor, a: float):
324
+ def pre_hook(module, input):
325
+ z = input[0].clone()
326
+ z[..., indices.to(z.device)] *= a
327
+ return (z,) + input[1:]
328
+
329
+ return pre_hook
330
+
331
+ handles.append(
332
+ down_proj.register_forward_pre_hook(make_pre_hook(neuron_indices, alpha))
333
+ )
334
+
335
+ try:
336
+ with torch.no_grad():
337
+ out = backend._model(**tokens)
338
+ finally:
339
+ for h in handles:
340
+ h.remove()
341
+
342
+ return out.logits[0, -1, :].detach().float().cpu()
343
+
344
+ # ------------------------------------------------------------------
345
+ # Main run
346
+ # ------------------------------------------------------------------
347
+
348
+ def run(
349
+ self,
350
+ backend: InferenceBackend,
351
+ dataset: BaseDataset,
352
+ prompt_strategy: Any,
353
+ logger: Optional["ExperimentLogger"] = None,
354
+ **kwargs,
355
+ ) -> ExperimentResult:
356
+ layers = self._resolve_layers(backend)
357
+ letter_ids = self._get_letter_ids(backend)
358
+ samples = dataset.sample(self.num_samples, seed=self.seed)
359
+
360
+ print(f"\n{'=' * 60}")
361
+ print(f"H-NEURON ANALYSIS — {dataset.name}")
362
+ print(f"{'=' * 60}")
363
+ print(f" Layers : {len(layers)} (stride {self.layer_stride})")
364
+ print(f" Samples : {len(samples)}")
365
+ print(f" L1 C : {self.l1_C}")
366
+ print(f" Alpha vals : {self.alpha_values}")
367
+ print(f" Contrastive : {self.contrastive_labeling}")
368
+
369
+ # Precompute column norms once — shape per layer: (intermediate_dim,)
370
+ print("\n[1/3] Precomputing W_down column norms...")
371
+ col_norms = self._precompute_col_norms(backend, layers)
372
+ intermediate_dim = next(iter(col_norms.values())).shape[0]
373
+ n_features = len(layers) * intermediate_dim
374
+ print(f" Intermediate dim : {intermediate_dim}")
375
+ print(f" Total features : {n_features:,}")
376
+
377
+ # ----------------------------------------------------------------
378
+ # Phase 1 — Extract CETT features + labels
379
+ # ----------------------------------------------------------------
380
+ print("\n[2/3] Extracting CETT features...")
381
+ top_k = min(5000, n_features)
382
+
383
+ # Welford online mean/variance accumulators (float64 for numerical stability)
384
+ welford_n = 0
385
+ welford_mean = np.zeros(n_features, dtype=np.float64)
386
+ welford_M2 = np.zeros(n_features, dtype=np.float64)
387
+
388
+ # cett_raw: one row per training example (2 rows per QA sample when contrastive_labeling)
389
+ cett_raw: List[np.ndarray] = []
390
+ labels: List[int] = []
391
+ # Maps each cett_raw row back to its QA sample index in valid_samples
392
+ row_to_sample_idx: List[int] = []
393
+
394
+ valid_samples = []
395
+ per_sample = []
396
+ skipped = 0
397
+
398
+ for sample in tqdm(samples, desc="CETT extraction"):
399
+ gt = self._ground_truth_letter(sample)
400
+ if gt is None:
401
+ skipped += 1
402
+ continue
403
+
404
+ prompt = self._build_prompt(prompt_strategy, sample)
405
+ tokens = self._tokenize(backend, prompt)
406
+
407
+ try:
408
+ cett_vec, logits = self._forward_cett(backend, tokens, layers, col_norms)
409
+ except Exception:
410
+ skipped += 1
411
+ continue
412
+
413
+ pred = self._predict_letter(logits, letter_ids)
414
+ is_correct = pred == gt
415
+
416
+ sample_pos = len(valid_samples)
417
+ valid_samples.append(sample)
418
+ per_sample.append(
419
+ {
420
+ "sample_idx": sample.idx,
421
+ "predicted": pred,
422
+ "ground_truth": gt,
423
+ "is_correct": is_correct,
424
+ }
425
+ )
426
+
427
+ if self.contrastive_labeling:
428
+ # Contrastive labeling: capture CETT at the generated answer token.
429
+ # H-Neurons = neurons active during hallucination (wrong answer generation).
430
+ try:
431
+ cett_answer_vec = self._forward_cett_at_answer_token(
432
+ backend, tokens, pred, letter_ids, layers, col_norms
433
+ )
434
+ except Exception:
435
+ valid_samples.pop()
436
+ per_sample.pop()
437
+ skipped += 1
438
+ continue
439
+
440
+ cett_answer = np.nan_to_num(
441
+ cett_answer_vec.numpy().astype(np.float32),
442
+ nan=0.0,
443
+ posinf=0.0,
444
+ neginf=0.0,
445
+ )
446
+ cett_other = np.nan_to_num(
447
+ cett_vec.numpy().astype(np.float32),
448
+ nan=0.0,
449
+ posinf=0.0,
450
+ neginf=0.0,
451
+ )
452
+
453
+ # Answer-token row: y=1 if hallucinatory, y=0 if faithful
454
+ cett_raw.append(cett_answer)
455
+ labels.append(0 if is_correct else 1)
456
+ row_to_sample_idx.append(sample_pos)
457
+
458
+ # Other-token row: always y=0 (negative control)
459
+ cett_raw.append(cett_other)
460
+ labels.append(0)
461
+ row_to_sample_idx.append(sample_pos)
462
+
463
+ for vec in (cett_answer, cett_other):
464
+ welford_n += 1
465
+ delta = vec.astype(np.float64) - welford_mean
466
+ welford_mean += delta / welford_n
467
+ welford_M2 += delta * (vec.astype(np.float64) - welford_mean)
468
+
469
+ else:
470
+ # Default: binary correct/incorrect label at last prompt token
471
+ vec = np.nan_to_num(
472
+ cett_vec.numpy().astype(np.float32),
473
+ nan=0.0,
474
+ posinf=0.0,
475
+ neginf=0.0,
476
+ )
477
+ cett_raw.append(vec)
478
+ labels.append(int(is_correct))
479
+ row_to_sample_idx.append(sample_pos)
480
+
481
+ welford_n += 1
482
+ delta = vec.astype(np.float64) - welford_mean
483
+ welford_mean += delta / welford_n
484
+ welford_M2 += delta * (vec.astype(np.float64) - welford_mean)
485
+
486
+ if torch.cuda.is_available():
487
+ torch.cuda.empty_cache()
488
+
489
+ n_valid = len(valid_samples) # number of QA samples
490
+ n_rows = len(labels) # number of training rows (2× when contrastive_labeling)
491
+ accuracy = sum(p["is_correct"] for p in per_sample) / n_valid if n_valid > 0 else 0.0
492
+ print(f" Valid QA samples : {n_valid} (skipped {skipped})")
493
+ print(f" Training rows : {n_rows}")
494
+ print(f" Accuracy : {accuracy:.3f}")
495
+
496
+ if n_valid < 20:
497
+ print(" WARNING: too few valid samples for reliable probing.")
498
+
499
+ # ----------------------------------------------------------------
500
+ # Phase 2 — L1 Logistic Regression → H-Neurons
501
+ # ----------------------------------------------------------------
502
+ print("\n[3/3] Training L1 probe...")
503
+
504
+ # Variance pre-selection using online-computed variance (no full matrix needed)
505
+ feature_var = welford_M2 / max(welford_n - 1, 1)
506
+ top_k_idx = np.argsort(feature_var)[-top_k:]
507
+ print(f" Pre-selected top-{top_k} features by CETT variance (from {n_features:,})")
508
+
509
+ # Build compact matrix — only top-K columns
510
+ X = np.stack([v[top_k_idx] for v in cett_raw], axis=0) # (n_rows, top_k)
511
+ del cett_raw # free full-size vectors immediately
512
+ y = np.array(labels) # (n_rows,)
513
+
514
+ # StandardScaler: zero-mean unit-variance
515
+ col_mean = X.mean(axis=0)
516
+ col_std = X.std(axis=0)
517
+ col_std[col_std == 0] = 1.0
518
+ X = (X - col_mean) / col_std
519
+
520
+ # ---- Train/val split at QA-sample level to avoid leakage ----
521
+ # Split sample indices, then expand to row indices.
522
+ sample_correct = np.array([int(p["is_correct"]) for p in per_sample])
523
+ sample_arr = np.arange(n_valid)
524
+ can_stratify = sample_correct.sum() > 1 and (n_valid - sample_correct.sum()) > 1
525
+ train_s, val_s = train_test_split(
526
+ sample_arr,
527
+ test_size=self.validation_split,
528
+ random_state=self.seed,
529
+ stratify=sample_correct if can_stratify else None,
530
+ )
531
+ train_set = set(train_s.tolist())
532
+ val_set = set(val_s.tolist())
533
+ train_row_idx = np.array([i for i, si in enumerate(row_to_sample_idx) if si in train_set])
534
+ val_row_idx = np.array([i for i, si in enumerate(row_to_sample_idx) if si in val_set])
535
+
536
+ X_train, X_val = X[train_row_idx], X[val_row_idx]
537
+ y_train, y_val = y[train_row_idx], y[val_row_idx]
538
+
539
+ clf = LogisticRegression(
540
+ penalty="l1",
541
+ solver="liblinear",
542
+ C=self.l1_C,
543
+ class_weight="balanced",
544
+ max_iter=1000,
545
+ random_state=self.seed,
546
+ )
547
+ clf.fit(X_train, y_train)
548
+
549
+ val_pred = clf.predict(X_val)
550
+ probe_accuracy = balanced_accuracy_score(y_val, val_pred)
551
+
552
+ # H-Neurons: positive-weight neurons
553
+ # Default labeling: positive weight → active during CORRECT answers
554
+ # Contrastive: positive weight → active during HALLUCINATORY answers
555
+ coef = clf.coef_[0] # (top_k,)
556
+ selected_flat = np.where(coef > 0)[0]
557
+
558
+ # Map back to (layer_idx, neuron_idx_within_layer)
559
+ h_neurons_decoded: List[Tuple[int, int]] = []
560
+ for sel_idx in selected_flat:
561
+ flat_idx = int(top_k_idx[sel_idx])
562
+ layer_pos = flat_idx // intermediate_dim
563
+ neuron_pos = flat_idx % intermediate_dim
564
+ if layer_pos < len(layers):
565
+ h_neurons_decoded.append((layers[layer_pos], int(neuron_pos)))
566
+
567
+ # Layer distribution
568
+ layer_counts: Dict[int, int] = {}
569
+ for layer_idx, _ in h_neurons_decoded:
570
+ layer_counts[layer_idx] = layer_counts.get(layer_idx, 0) + 1
571
+
572
+ print(f" Probe balanced acc : {probe_accuracy:.3f}")
573
+ print(f" H-Neurons found : {len(h_neurons_decoded)}")
574
+ print(f" H-Neuron ratio : {len(h_neurons_decoded) / n_features * 1000:.3f}‰")
575
+ if layer_counts:
576
+ top_layers = sorted(layer_counts.items(), key=lambda x: x[1], reverse=True)[:5]
577
+ print(f" Top layers (H-neurons): {top_layers}")
578
+
579
+ # AUROC of probe score vs correctness label
580
+ try:
581
+ probe_scores_val = clf.predict_proba(X_val)[:, 1]
582
+ probe_auroc = roc_auc_score(y_val, probe_scores_val)
583
+ except Exception:
584
+ probe_auroc = None
585
+
586
+ # ---- Random neuron baseline ----
587
+ # Same N neurons sampled randomly from the top-k variance pool.
588
+ # Same L1 probe, same hyperparameters. Validates H-Neuron advantage.
589
+ random_baseline_auroc = None
590
+ random_baseline_bal_acc = None
591
+ if len(h_neurons_decoded) > 0:
592
+ rng = np.random.RandomState(self.seed + 1)
593
+ n_rand = len(h_neurons_decoded)
594
+ rand_col_idx = rng.choice(top_k, size=min(n_rand, top_k), replace=False)
595
+ X_tr_rand = X_train[:, rand_col_idx]
596
+ X_v_rand = X_val[:, rand_col_idx]
597
+
598
+ clf_rand = LogisticRegression(
599
+ penalty="l1",
600
+ solver="liblinear",
601
+ C=self.l1_C,
602
+ class_weight="balanced",
603
+ max_iter=1000,
604
+ random_state=self.seed,
605
+ )
606
+ clf_rand.fit(X_tr_rand, y_train)
607
+ rand_pred = clf_rand.predict(X_v_rand)
608
+ random_baseline_bal_acc = balanced_accuracy_score(y_val, rand_pred)
609
+ try:
610
+ rand_scores = clf_rand.predict_proba(X_v_rand)[:, 1]
611
+ random_baseline_auroc = roc_auc_score(y_val, rand_scores)
612
+ except Exception:
613
+ pass
614
+
615
+ print(f" Random baseline acc : {random_baseline_bal_acc:.3f}")
616
+ print(f" Random baseline AUROC: {random_baseline_auroc}")
617
+ if probe_auroc is not None and random_baseline_auroc is not None:
618
+ gap = probe_auroc - random_baseline_auroc
619
+ print(f" H-Neuron AUROC gap : {gap:+.3f} (paper claims >0.10)")
620
+
621
+ # ----------------------------------------------------------------
622
+ # Phase 3 — Causal Validation
623
+ # ----------------------------------------------------------------
624
+ causal_results: Dict[str, float] = {}
625
+ if h_neurons_decoded and len(val_s) > 0:
626
+ print(f"\n Causal validation on {len(val_s)} held-out QA samples...")
627
+ if self.contrastive_labeling:
628
+ print(
629
+ " (Contrastive: suppression should RAISE accuracy, amplification should LOWER it)"
630
+ )
631
+ else:
632
+ print(
633
+ " (Default labeling: suppression should LOWER accuracy, amplification should RAISE it)"
634
+ )
635
+
636
+ for alpha in self.alpha_values:
637
+ correct_alpha = 0
638
+ total_alpha = 0
639
+ for si in val_s:
640
+ s = valid_samples[si]
641
+ gt = self._ground_truth_letter(s)
642
+ if gt is None:
643
+ continue
644
+ prompt = self._build_prompt(prompt_strategy, s)
645
+ tokens = self._tokenize(backend, prompt)
646
+ try:
647
+ logits = self._forward_with_suppression(
648
+ backend, tokens, h_neurons_decoded, alpha, layers
649
+ )
650
+ pred = self._predict_letter(logits, letter_ids)
651
+ correct_alpha += int(pred == gt)
652
+ total_alpha += 1
653
+ except Exception:
654
+ continue
655
+ if torch.cuda.is_available():
656
+ torch.cuda.empty_cache()
657
+
658
+ acc_alpha = correct_alpha / total_alpha if total_alpha > 0 else 0.0
659
+ causal_results[f"accuracy_alpha_{alpha}"] = acc_alpha
660
+ print(f" α={alpha:.1f} → accuracy {acc_alpha:.3f}")
661
+
662
+ # ----------------------------------------------------------------
663
+ # Results
664
+ # ----------------------------------------------------------------
665
+ print(f"\n{'=' * 60}")
666
+ print(f"H-NEURON SUMMARY — {dataset.name}")
667
+ print(f"{'=' * 60}")
668
+ print(f" Accuracy (no intervention) : {accuracy:.3f}")
669
+ print(f" Probe balanced accuracy : {probe_accuracy:.3f}")
670
+ print(f" Probe AUROC : {probe_auroc}")
671
+ print(f" Random baseline AUROC : {random_baseline_auroc}")
672
+ print(f" H-Neurons identified : {len(h_neurons_decoded)}")
673
+
674
+ metrics: Dict[str, Any] = {
675
+ "dataset": dataset.name,
676
+ "n_samples": n_valid,
677
+ "n_training_rows": n_rows,
678
+ "n_layers": len(layers),
679
+ "intermediate_dim": intermediate_dim,
680
+ "n_features": n_features,
681
+ "accuracy": accuracy,
682
+ "probe_balanced_accuracy": probe_accuracy,
683
+ "probe_auroc": probe_auroc,
684
+ "random_baseline_balanced_accuracy": random_baseline_bal_acc,
685
+ "random_baseline_auroc": random_baseline_auroc,
686
+ "n_h_neurons": len(h_neurons_decoded),
687
+ "h_neuron_ratio_permille": len(h_neurons_decoded) / n_features * 1000,
688
+ "layer_distribution": layer_counts,
689
+ "contrastive_labeling": self.contrastive_labeling,
690
+ "top_h_neurons": [
691
+ {"layer": li, "neuron": ni}
692
+ for li, ni in h_neurons_decoded[:50] # top 50 by layer order
693
+ ],
694
+ **causal_results,
695
+ }
696
+
697
+ return ExperimentResult(
698
+ experiment_name=self.name,
699
+ model_name=getattr(backend, "model_name", "unknown"),
700
+ prompt_strategy=getattr(prompt_strategy, "name", "unknown"),
701
+ metrics=metrics,
702
+ raw_outputs={"per_sample": per_sample},
703
+ metadata={
704
+ "description": self.description,
705
+ "layers": layers,
706
+ "layer_stride": self.layer_stride,
707
+ "l1_C": self.l1_C,
708
+ "alpha_values": self.alpha_values,
709
+ "num_samples_requested": self.num_samples,
710
+ "contrastive_labeling": self.contrastive_labeling,
711
+ },
712
+ )