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.
- cotlab/__init__.py +3 -0
- cotlab/analyse_experiments.py +392 -0
- cotlab/analysis/__init__.py +11 -0
- cotlab/analysis/cot_parser.py +243 -0
- cotlab/analysis/faithfulness_metrics.py +192 -0
- cotlab/backends/__init__.py +16 -0
- cotlab/backends/base.py +78 -0
- cotlab/backends/transformers_backend.py +335 -0
- cotlab/backends/vllm_backend.py +227 -0
- cotlab/cli.py +83 -0
- cotlab/core/__init__.py +34 -0
- cotlab/core/base.py +749 -0
- cotlab/core/config.py +90 -0
- cotlab/core/registry.py +68 -0
- cotlab/datasets/__init__.py +45 -0
- cotlab/datasets/loaders.py +1889 -0
- cotlab/experiment/__init__.py +315 -0
- cotlab/experiments/__init__.py +43 -0
- cotlab/experiments/activation_compare.py +290 -0
- cotlab/experiments/activation_patching.py +1050 -0
- cotlab/experiments/attention_analysis.py +885 -0
- cotlab/experiments/classification.py +235 -0
- cotlab/experiments/composite_shift_detector.py +524 -0
- cotlab/experiments/cot_ablation.py +277 -0
- cotlab/experiments/cot_faithfulness.py +187 -0
- cotlab/experiments/cot_heads.py +208 -0
- cotlab/experiments/full_layer_cot.py +232 -0
- cotlab/experiments/full_layer_patching.py +225 -0
- cotlab/experiments/h_neuron_analysis.py +712 -0
- cotlab/experiments/logit_lens.py +439 -0
- cotlab/experiments/multi_head_cot.py +220 -0
- cotlab/experiments/multi_head_patching.py +229 -0
- cotlab/experiments/probing_classifier.py +402 -0
- cotlab/experiments/residual_norm_ood.py +413 -0
- cotlab/experiments/sae_feature_analysis.py +673 -0
- cotlab/experiments/steering_vectors.py +223 -0
- cotlab/experiments/sycophancy_heads.py +224 -0
- cotlab/logging/__init__.py +5 -0
- cotlab/logging/json_logger.py +161 -0
- cotlab/main.py +317 -0
- cotlab/patching/__init__.py +24 -0
- cotlab/patching/cache.py +141 -0
- cotlab/patching/hooks.py +558 -0
- cotlab/patching/interventions.py +86 -0
- cotlab/patching/patcher.py +439 -0
- cotlab/patching/sae.py +181 -0
- cotlab/prompts/__init__.py +43 -0
- cotlab/prompts/cardiology.py +378 -0
- cotlab/prompts/histopathology.py +265 -0
- cotlab/prompts/length_matched_strategies.py +157 -0
- cotlab/prompts/mcq.py +193 -0
- cotlab/prompts/neurology.py +353 -0
- cotlab/prompts/oncology.py +367 -0
- cotlab/prompts/plab.py +162 -0
- cotlab/prompts/pubhealthbench.py +82 -0
- cotlab/prompts/pubmedqa.py +173 -0
- cotlab/prompts/radiology.py +414 -0
- cotlab/prompts/strategies.py +939 -0
- cotlab/prompts/tcga.py +168 -0
- cotlab/runner.py +204 -0
- cotlab-0.8.0.dist-info/METADATA +166 -0
- cotlab-0.8.0.dist-info/RECORD +65 -0
- cotlab-0.8.0.dist-info/WHEEL +4 -0
- cotlab-0.8.0.dist-info/entry_points.txt +3 -0
- 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
|
+
)
|