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,413 @@
1
+ """Residual Norm OOD Detection Experiment.
2
+
3
+ Tests whether the L2 norm of the residual stream at a target layer (default:
4
+ last transformer block) correlates with per-sample answer accuracy, and
5
+ whether a threshold on that norm can serve as a single-forward-pass OOD flag.
6
+
7
+ Two metrics are computed from the same single forward pass:
8
+
9
+ 1. Residual norm ||h_L[-1]||₂
10
+ The L2 norm of the last-token residual vector at layer L.
11
+ Hypothesis: higher norm → model has formed a strong, confident
12
+ representation → more likely correct.
13
+
14
+ 2. Logit entropy -Σ p(letter) · log p(letter) [MCQ mode]
15
+ Entropy over the answer-letter (A/B/C/D/E) logit distribution.
16
+ This is a single-pass approximation of Semantic Entropy for MCQ tasks.
17
+ Hypothesis: lower entropy → model is more peaked on one answer → more
18
+ likely correct. AUROC is computed with (-entropy) so that higher
19
+ values still indicate correctness.
20
+
21
+ Both AUROCs are reported so the norm can be directly compared to the logit
22
+ entropy baseline and benchmarked against published multi-pass Semantic Entropy
23
+ numbers (Kuhn et al. 2023, AUROC ~0.65–0.75 on medical QA).
24
+
25
+ Threshold analysis
26
+ ------------------
27
+ A threshold τ* is found on the collected norm scores that maximises balanced
28
+ accuracy. This τ can be applied at inference time as a zero-extra-cost OOD
29
+ flag. Cross-dataset generalisation is evaluated by running the experiment on
30
+ multiple datasets and comparing AUROCs — a threshold trained on MedQA that
31
+ transfers to PubHealthBench would indicate the norm captures model-internal
32
+ state rather than dataset-specific surface features.
33
+ """
34
+
35
+ import math
36
+ from typing import Any, Dict, List, Optional, Tuple
37
+
38
+ import numpy as np
39
+ import torch
40
+ from sklearn.metrics import balanced_accuracy_score, roc_auc_score
41
+ from tqdm import tqdm
42
+
43
+ from ..backends.base import InferenceBackend
44
+ from ..core.base import BaseExperiment, ExperimentResult
45
+ from ..core.registry import Registry
46
+ from ..datasets.loaders import BaseDataset
47
+ from ..logging import ExperimentLogger
48
+
49
+ # MCQ answer letters and their common tokenisation variants.
50
+ _MCQ_LETTERS = list("ABCDEFGHIJ")
51
+
52
+
53
+ @Registry.register_experiment("residual_norm_ood")
54
+ class ResidualNormOODExperiment(BaseExperiment):
55
+ """
56
+ Single-pass residual norm OOD detection with logit entropy comparison.
57
+
58
+ Hooks the residual stream at ``target_layer`` (default: last transformer
59
+ block), extracts the last-token hidden state, computes its L2 norm, and
60
+ collects the logit-entropy over answer letters — all from one forward pass.
61
+
62
+ Reports AUROC for both metrics against per-sample correctness, plus a
63
+ threshold search for the norm that maximises balanced accuracy.
64
+ """
65
+
66
+ def __init__(
67
+ self,
68
+ name: str = "residual_norm_ood",
69
+ description: str = "L2 residual norm OOD flag vs logit entropy baseline",
70
+ target_layer: Optional[int] = None, # null = last transformer block
71
+ num_samples: Optional[int] = None, # null = full dataset
72
+ seed: int = 42,
73
+ max_input_tokens: int = 1024,
74
+ answer_cue: str = "\n\nAnswer:",
75
+ mcq_letters: Optional[List[str]] = None,
76
+ threshold_percentile_step: int = 5, # granularity of τ search
77
+ **kwargs,
78
+ ):
79
+ self._name = name
80
+ self.description = description
81
+ self._target_layer_config = target_layer
82
+ self.num_samples = num_samples
83
+ self.seed = seed
84
+ self.max_input_tokens = max_input_tokens
85
+ self.answer_cue = answer_cue
86
+ self.mcq_letters = mcq_letters or _MCQ_LETTERS
87
+ self.threshold_percentile_step = threshold_percentile_step
88
+
89
+ @property
90
+ def name(self) -> str:
91
+ return self._name
92
+
93
+ # ------------------------------------------------------------------
94
+ # Helpers
95
+ # ------------------------------------------------------------------
96
+
97
+ def _resolve_layer(self, backend: InferenceBackend) -> int:
98
+ if self._target_layer_config is not None:
99
+ return int(self._target_layer_config)
100
+ # Default: last transformer block (index = num_layers - 1).
101
+ return backend.hook_manager.num_layers - 1
102
+
103
+ def _tokenize(self, tokenizer, text: str, device: str) -> Dict[str, torch.Tensor]:
104
+ return tokenizer(
105
+ text,
106
+ return_tensors="pt",
107
+ truncation=True,
108
+ max_length=self.max_input_tokens,
109
+ ).to(device)
110
+
111
+ def _answer_letter_token_ids(self, tokenizer) -> List[int]:
112
+ """Collect all plausible token ids for MCQ answer letters."""
113
+ ids = set()
114
+ for letter in self.mcq_letters:
115
+ for prefix in (" ", "", "\n"):
116
+ encoded = tokenizer.encode(prefix + letter, add_special_tokens=False)
117
+ if encoded:
118
+ ids.add(encoded[-1])
119
+ return sorted(ids)
120
+
121
+ def _answer_token_id(self, tokenizer, label) -> Optional[int]:
122
+ """Return the first token id of the label string."""
123
+ if label is None:
124
+ return None
125
+ label_str = str(label).strip()
126
+ for prefix in (" ", ""):
127
+ ids = tokenizer.encode(prefix + label_str, add_special_tokens=False)
128
+ if ids:
129
+ return ids[0]
130
+ return None
131
+
132
+ def _forward(
133
+ self,
134
+ backend: InferenceBackend,
135
+ tokens: Dict[str, torch.Tensor],
136
+ target_layer: int,
137
+ ) -> Tuple[torch.Tensor, torch.Tensor]:
138
+ """Single forward pass.
139
+
140
+ Returns:
141
+ last_logits : float32 CPU tensor [vocab_size]
142
+ last_hidden : float32 CPU tensor [d_model] at target_layer
143
+ """
144
+ last_hidden_store: Dict[str, torch.Tensor] = {}
145
+
146
+ def hook(module, inp, output):
147
+ tensor = output[0] if isinstance(output, tuple) else output
148
+ with torch.no_grad():
149
+ last_hidden_store["h"] = tensor[0, -1].detach().float().cpu()
150
+
151
+ mod = backend.hook_manager.get_residual_module(target_layer)
152
+ handle = mod.register_forward_hook(hook)
153
+ try:
154
+ with torch.no_grad():
155
+ out = backend._model(**tokens)
156
+ finally:
157
+ handle.remove()
158
+
159
+ last_logits = out.logits[0, -1].detach().float().cpu()
160
+ return last_logits, last_hidden_store["h"]
161
+
162
+ # ------------------------------------------------------------------
163
+ # Per-sample metric computation
164
+ # ------------------------------------------------------------------
165
+
166
+ def _compute_norm(self, hidden: torch.Tensor) -> float:
167
+ return float(hidden.norm(p=2).item())
168
+
169
+ def _compute_logit_entropy(self, logits: torch.Tensor, letter_ids: List[int]) -> float:
170
+ """Entropy over answer-letter logits (MCQ single-pass SE proxy).
171
+
172
+ Softmax is applied only over the letter token ids so the distribution
173
+ sums to 1 over the MCQ choice set.
174
+ """
175
+ if not letter_ids:
176
+ return float("nan")
177
+ letter_logits = logits[letter_ids]
178
+ probs = torch.softmax(letter_logits, dim=0)
179
+ entropy = -float((probs * (probs + 1e-10).log()).sum().item())
180
+ return entropy
181
+
182
+ # ------------------------------------------------------------------
183
+ # Threshold search
184
+ # ------------------------------------------------------------------
185
+
186
+ def _find_threshold(self, norms: List[float], labels: List[bool]) -> Tuple[float, float]:
187
+ """Find τ* that maximises balanced accuracy on (norms > τ) → correct.
188
+
189
+ Returns:
190
+ (tau_star, balanced_acc_at_tau)
191
+ """
192
+ arr = np.array(norms)
193
+ lbl = np.array(labels, dtype=int)
194
+ percentiles = np.arange(
195
+ self.threshold_percentile_step,
196
+ 100,
197
+ self.threshold_percentile_step,
198
+ )
199
+ best_tau, best_ba = float(arr.mean()), 0.0
200
+ unique_labels = set(lbl.tolist())
201
+ if len(unique_labels) < 2:
202
+ return best_tau, best_ba
203
+ for pct in percentiles:
204
+ tau = float(np.percentile(arr, pct))
205
+ preds = (arr >= tau).astype(int)
206
+ if preds.sum() == 0 or preds.sum() == len(preds):
207
+ continue
208
+ ba = balanced_accuracy_score(lbl, preds)
209
+ if ba > best_ba:
210
+ best_ba, best_tau = ba, tau
211
+ return best_tau, best_ba
212
+
213
+ # ------------------------------------------------------------------
214
+ # Summary printing
215
+ # ------------------------------------------------------------------
216
+
217
+ def _print_summary(
218
+ self,
219
+ dataset_name: str,
220
+ target_layer: int,
221
+ n: int,
222
+ accuracy: float,
223
+ auroc_norm: float,
224
+ auroc_entropy: float,
225
+ tau: float,
226
+ balanced_acc: float,
227
+ mean_norm_correct: float,
228
+ mean_norm_incorrect: float,
229
+ mean_entropy_correct: float,
230
+ mean_entropy_incorrect: float,
231
+ ) -> None:
232
+ print("\n" + "=" * 66)
233
+ print(f"RESIDUAL NORM OOD — {dataset_name} (L{target_layer})")
234
+ print("=" * 66)
235
+ print(f" Samples : {n}")
236
+ print(f" Accuracy : {accuracy:.4f}")
237
+ print()
238
+ print(f" {'Metric':<22} {'AUROC':>7} {'mean(corr)':>11} {'mean(incorr)':>12}")
239
+ print(" " + "-" * 56)
240
+ auc_n = f"{auroc_norm:.4f}" if auroc_norm is not None else " n/a "
241
+ auc_e = f"{auroc_entropy:.4f}" if auroc_entropy is not None else " n/a "
242
+ print(
243
+ f" {'L2 norm':.<22} {auc_n:>7} "
244
+ f"{mean_norm_correct:>11.2f} {mean_norm_incorrect:>12.2f}"
245
+ )
246
+ print(
247
+ f" {'Logit entropy (neg)':.<22} {auc_e:>7} "
248
+ f"{mean_entropy_correct:>11.4f} {mean_entropy_incorrect:>12.4f}"
249
+ )
250
+ print()
251
+ print(f" Norm threshold τ* : {tau:.2f}")
252
+ print(f" Balanced acc @ τ* : {balanced_acc:.4f}")
253
+ print("=" * 66)
254
+
255
+ # ------------------------------------------------------------------
256
+ # Main entry point
257
+ # ------------------------------------------------------------------
258
+
259
+ def run(
260
+ self,
261
+ backend: InferenceBackend,
262
+ dataset: BaseDataset,
263
+ prompt_strategy: Any,
264
+ logger: Optional[ExperimentLogger] = None,
265
+ **kwargs,
266
+ ) -> ExperimentResult:
267
+ """Run residual norm OOD experiment."""
268
+
269
+ target_layer = self._resolve_layer(backend)
270
+ tokenizer = backend._tokenizer
271
+ device = backend.device
272
+ letter_ids = self._answer_letter_token_ids(tokenizer)
273
+
274
+ samples = (
275
+ dataset.sample(self.num_samples, seed=self.seed) if self.num_samples else list(dataset)
276
+ )
277
+
278
+ print(f"Model : {backend.model_name}")
279
+ print(f"Dataset : {dataset.name}")
280
+ print(f"Target layer : L{target_layer}")
281
+ print(f"Samples : {len(samples)}")
282
+ print(f"MCQ letter ids: {len(letter_ids)} token ids")
283
+
284
+ per_sample: List[Dict] = []
285
+ norms: List[float] = []
286
+ entropies: List[float] = []
287
+ labels: List[bool] = []
288
+
289
+ for sample in tqdm(samples, desc="Residual norm"):
290
+ answer_tok_id = self._answer_token_id(tokenizer, sample.label)
291
+
292
+ prompt_str = (
293
+ prompt_strategy.build_prompt(
294
+ {
295
+ "text": sample.text,
296
+ "question": sample.text,
297
+ "metadata": sample.metadata or {},
298
+ }
299
+ )
300
+ + self.answer_cue
301
+ )
302
+ tokens = self._tokenize(tokenizer, prompt_str, device)
303
+
304
+ try:
305
+ logits, hidden = self._forward(backend, tokens, target_layer)
306
+ except Exception as exc:
307
+ tqdm.write(f" [skip] sample {sample.idx}: {exc}")
308
+ torch.cuda.empty_cache() if torch.cuda.is_available() else None
309
+ continue
310
+
311
+ norm = self._compute_norm(hidden)
312
+ entropy = self._compute_logit_entropy(logits, letter_ids)
313
+
314
+ if answer_tok_id is not None and letter_ids:
315
+ best_letter_tok = max(letter_ids, key=lambda t: logits[t].item())
316
+ is_correct = best_letter_tok == answer_tok_id
317
+ else:
318
+ is_correct = False
319
+
320
+ norms.append(norm)
321
+ entropies.append(entropy)
322
+ labels.append(is_correct)
323
+
324
+ per_sample.append(
325
+ {
326
+ "sample_idx": sample.idx,
327
+ "is_correct": is_correct,
328
+ "l2_norm": round(norm, 4),
329
+ "logit_entropy": round(entropy, 6) if not math.isnan(entropy) else None,
330
+ }
331
+ )
332
+
333
+ del hidden, logits
334
+ torch.cuda.empty_cache() if torch.cuda.is_available() else None
335
+
336
+ # ── Aggregate ──────────────────────────────────────────────────
337
+ n = len(labels)
338
+ accuracy = sum(labels) / n if n else 0.0
339
+
340
+ lbl_arr = np.array(labels, dtype=int)
341
+
342
+ # AUROC — handle edge case where all labels are the same.
343
+ def _auroc(scores: List[float], lbl: np.ndarray) -> Optional[float]:
344
+ if lbl.sum() == 0 or lbl.sum() == len(lbl) or len(scores) < 2:
345
+ return None
346
+ return float(roc_auc_score(lbl, scores))
347
+
348
+ auroc_norm = _auroc(norms, lbl_arr)
349
+ # Negate entropy: higher entropy → less likely correct.
350
+ neg_ent = [-e for e in entropies if not math.isnan(e)]
351
+ lbl_ent = lbl_arr[[not math.isnan(e) for e in entropies]]
352
+ auroc_entropy = _auroc(neg_ent, lbl_ent)
353
+
354
+ # Threshold search on norm.
355
+ tau, balanced_acc = self._find_threshold(norms, labels)
356
+
357
+ # Mean metrics by correctness.
358
+ correct_norms = [n for n, c in zip(norms, labels) if c]
359
+ incorrect_norms = [n for n, c in zip(norms, labels) if not c]
360
+ correct_ent = [e for e, c in zip(entropies, labels) if c and not math.isnan(e)]
361
+ incorrect_ent = [e for e, c in zip(entropies, labels) if not c and not math.isnan(e)]
362
+
363
+ mean_norm_corr = sum(correct_norms) / len(correct_norms) if correct_norms else 0.0
364
+ mean_norm_incorr = sum(incorrect_norms) / len(incorrect_norms) if incorrect_norms else 0.0
365
+ mean_ent_corr = sum(correct_ent) / len(correct_ent) if correct_ent else 0.0
366
+ mean_ent_incorr = sum(incorrect_ent) / len(incorrect_ent) if incorrect_ent else 0.0
367
+
368
+ self._print_summary(
369
+ dataset_name=dataset.name,
370
+ target_layer=target_layer,
371
+ n=n,
372
+ accuracy=accuracy,
373
+ auroc_norm=auroc_norm or 0.0,
374
+ auroc_entropy=auroc_entropy or 0.0,
375
+ tau=tau,
376
+ balanced_acc=balanced_acc,
377
+ mean_norm_correct=mean_norm_corr,
378
+ mean_norm_incorrect=mean_norm_incorr,
379
+ mean_entropy_correct=mean_ent_corr,
380
+ mean_entropy_incorrect=mean_ent_incorr,
381
+ )
382
+
383
+ return ExperimentResult(
384
+ experiment_name=self.name,
385
+ model_name=backend.model_name,
386
+ prompt_strategy=(
387
+ prompt_strategy.name if hasattr(prompt_strategy, "name") else "custom"
388
+ ),
389
+ metrics={
390
+ "dataset": dataset.name,
391
+ "target_layer": target_layer,
392
+ "num_samples": n,
393
+ "accuracy": round(accuracy, 4),
394
+ "auroc_l2_norm": round(auroc_norm, 4) if auroc_norm is not None else None,
395
+ "auroc_logit_entropy": round(auroc_entropy, 4)
396
+ if auroc_entropy is not None
397
+ else None,
398
+ "norm_threshold_tau": round(tau, 4),
399
+ "balanced_acc_at_tau": round(balanced_acc, 4),
400
+ "mean_norm_correct": round(mean_norm_corr, 4),
401
+ "mean_norm_incorrect": round(mean_norm_incorr, 4),
402
+ "mean_entropy_correct": round(mean_ent_corr, 4),
403
+ "mean_entropy_incorrect": round(mean_ent_incorr, 4),
404
+ },
405
+ raw_outputs={"per_sample": per_sample},
406
+ metadata={
407
+ "target_layer": target_layer,
408
+ "num_samples": n,
409
+ "seed": self.seed,
410
+ "answer_cue": self.answer_cue,
411
+ "se_literature_baseline_auroc": "0.65-0.75 (Kuhn et al. 2023)",
412
+ },
413
+ )