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,524 @@
1
+ """Composite Distribution Shift Detector Experiment.
2
+
3
+ Combines two single-pass signals to build a composite OOD / distribution-shift
4
+ detector and tests whether that signal *precedes* observable accuracy degradation.
5
+
6
+ Signals (one forward pass each)
7
+ --------------------------------
8
+ 1. L61 residual norm ||h_{L61}[-1]||₂
9
+ Hypothesis: OOD samples → abnormal norms (too high or too low).
10
+
11
+ 2. L3 attention entropy -Σ p_head * log(p_head + ε) averaged over heads
12
+ Entropy of the last-token attention distribution at layer 3.
13
+ Hypothesis: OOD / uncertain samples → diffuse attention → higher entropy.
14
+
15
+ Composite anomaly score
16
+ -----------------------
17
+ Mahalanobis distance from an in-distribution baseline:
18
+
19
+ d_M(x) = sqrt( (x - μ)ᵀ Σ⁻¹ (x - μ) )
20
+
21
+ where x = [norm, attn_entropy], μ and Σ are estimated on a calibration split
22
+ (``calibration_fraction`` of the data, taken from the first N samples).
23
+
24
+ Degradation analysis
25
+ --------------------
26
+ Samples are sorted by ascending composite score (most in-distribution first).
27
+ A rolling window of ``window_size`` consecutive sorted samples yields a
28
+ rolling accuracy curve. Spearman ρ between composite score and *inversely*
29
+ ordered accuracy measures whether high anomaly score precedes accuracy drop.
30
+
31
+ Additionally, samples are binned into ``num_bins`` quantile groups and mean
32
+ accuracy per bin is reported.
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 tqdm import tqdm
41
+
42
+ from ..backends.base import InferenceBackend
43
+ from ..core.base import BaseExperiment, ExperimentResult
44
+ from ..core.registry import Registry
45
+ from ..datasets.loaders import BaseDataset
46
+ from ..logging import ExperimentLogger
47
+
48
+
49
+ @Registry.register_experiment("composite_shift_detector")
50
+ class CompositeShiftDetectorExperiment(BaseExperiment):
51
+ """Composite L61-norm + L3-entropy distribution shift detector.
52
+
53
+ Single forward pass with ``output_attentions=True`` extracts both signals.
54
+ Mahalanobis distance from a calibration baseline is used as the composite
55
+ anomaly score. Spearman correlation between anomaly score and accuracy
56
+ over sorted windows tests the "precedes degradation" hypothesis.
57
+ """
58
+
59
+ def __init__(
60
+ self,
61
+ name: str = "composite_shift_detector",
62
+ description: str = "L61 norm + L3 entropy Mahalanobis OOD detector",
63
+ norm_layer: Optional[int] = None, # null = last transformer block
64
+ attn_layer: int = 3, # L3 attention
65
+ num_samples: Optional[int] = None,
66
+ calibration_fraction: float = 0.3, # fraction used to fit μ, Σ
67
+ window_size: int = 20, # rolling accuracy window
68
+ num_bins: int = 5, # quantile bins for accuracy report
69
+ seed: int = 42,
70
+ max_input_tokens: int = 1024,
71
+ answer_cue: str = "\n\nAnswer:",
72
+ mcq_letters: Optional[List[str]] = None,
73
+ **kwargs,
74
+ ):
75
+ self._name = name
76
+ self.description = description
77
+ self._norm_layer_config = norm_layer
78
+ self.attn_layer = attn_layer
79
+ self.num_samples = num_samples
80
+ self.calibration_fraction = calibration_fraction
81
+ self.window_size = window_size
82
+ self.num_bins = num_bins
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 list("ABCDEFGHIJ")
87
+
88
+ @property
89
+ def name(self) -> str:
90
+ return self._name
91
+
92
+ # ------------------------------------------------------------------
93
+ # Helpers
94
+ # ------------------------------------------------------------------
95
+
96
+ def _resolve_norm_layer(self, backend: InferenceBackend) -> int:
97
+ if self._norm_layer_config is not None:
98
+ return int(self._norm_layer_config)
99
+ return backend.hook_manager.num_layers - 1
100
+
101
+ def _tokenize(self, tokenizer, text: str, device: str) -> Dict[str, torch.Tensor]:
102
+ return tokenizer(
103
+ text,
104
+ return_tensors="pt",
105
+ truncation=True,
106
+ max_length=self.max_input_tokens,
107
+ ).to(device)
108
+
109
+ def _answer_letter_token_ids(self, tokenizer) -> List[int]:
110
+ ids = set()
111
+ for letter in self.mcq_letters:
112
+ for prefix in (" ", "", "\n"):
113
+ encoded = tokenizer.encode(prefix + letter, add_special_tokens=False)
114
+ if encoded:
115
+ ids.add(encoded[-1])
116
+ return sorted(ids)
117
+
118
+ def _answer_token_id(self, tokenizer, label) -> Optional[int]:
119
+ if label is None:
120
+ return None
121
+ label_str = str(label).strip()
122
+ for prefix in (" ", ""):
123
+ ids = tokenizer.encode(prefix + label_str, add_special_tokens=False)
124
+ if ids:
125
+ return ids[0]
126
+ return None
127
+
128
+ # ------------------------------------------------------------------
129
+ # Single forward pass: residual norm + L3 attention entropy
130
+ # ------------------------------------------------------------------
131
+
132
+ def _forward(
133
+ self,
134
+ backend: InferenceBackend,
135
+ tokens: Dict[str, torch.Tensor],
136
+ norm_layer: int,
137
+ ) -> Tuple[torch.Tensor, float, float]:
138
+ """Single forward pass with attention output.
139
+
140
+ Returns:
141
+ last_logits : float32 CPU tensor [vocab_size]
142
+ l2_norm : scalar float
143
+ attn_entropy : scalar float — mean last-token entropy across heads at L3
144
+ """
145
+ hidden_store: Dict[str, torch.Tensor] = {}
146
+
147
+ def hook(module, inp, output):
148
+ tensor = output[0] if isinstance(output, tuple) else output
149
+ with torch.no_grad():
150
+ hidden_store["h"] = tensor[0, -1].detach().float().cpu()
151
+
152
+ mod = backend.hook_manager.get_residual_module(norm_layer)
153
+ handle = mod.register_forward_hook(hook)
154
+ try:
155
+ with torch.no_grad():
156
+ out = backend._model(
157
+ **tokens,
158
+ output_attentions=True,
159
+ return_dict=True,
160
+ )
161
+ finally:
162
+ handle.remove()
163
+
164
+ last_logits = out.logits[0, -1].detach().float().cpu()
165
+ l2_norm = float(hidden_store["h"].norm(p=2).item())
166
+
167
+ # L3 attention: out.attentions is a tuple len=num_layers,
168
+ # each [batch, heads, seq, seq]. We want last-token row.
169
+ attn_entropy = float("nan")
170
+ attn_l3 = (
171
+ out.attentions[self.attn_layer]
172
+ if (out.attentions is not None and len(out.attentions) > self.attn_layer)
173
+ else None
174
+ )
175
+ if attn_l3 is not None:
176
+ last_tok_attn = attn_l3[0, :, -1, :].float().cpu() # [heads, seq]
177
+ eps = 1e-10
178
+ ent_per_head = -(last_tok_attn * (last_tok_attn + eps).log()).sum(dim=-1)
179
+ attn_entropy = float(ent_per_head.mean().item())
180
+
181
+ return last_logits, l2_norm, attn_entropy
182
+
183
+ # ------------------------------------------------------------------
184
+ # Mahalanobis distance
185
+ # ------------------------------------------------------------------
186
+
187
+ def _fit_mahalanobis(self, features: np.ndarray) -> Tuple[np.ndarray, np.ndarray]:
188
+ """Fit μ and Σ⁻¹ on calibration features.
189
+
190
+ Falls back to diagonal covariance if the matrix is singular or sklearn
191
+ is unavailable.
192
+ """
193
+ mu = features.mean(axis=0)
194
+ try:
195
+ from sklearn.covariance import EmpiricalCovariance # noqa: PLC0415
196
+
197
+ ec = EmpiricalCovariance(assume_centered=False).fit(features)
198
+ prec = ec.precision_
199
+ except Exception:
200
+ # Diagonal fallback
201
+ var = features.var(axis=0) + 1e-8
202
+ prec = np.diag(1.0 / var)
203
+ return mu, prec
204
+
205
+ def _mahalanobis(self, x: np.ndarray, mu: np.ndarray, prec: np.ndarray) -> float:
206
+ diff = x - mu
207
+ return float(math.sqrt(max(diff @ prec @ diff, 0.0)))
208
+
209
+ # ------------------------------------------------------------------
210
+ # Degradation analysis
211
+ # ------------------------------------------------------------------
212
+
213
+ def _rolling_accuracy(
214
+ self, scores: List[float], labels: List[bool]
215
+ ) -> Tuple[List[float], List[float]]:
216
+ """Sort by score, compute rolling accuracy over window_size samples."""
217
+ order = np.argsort(scores)
218
+ sorted_labels = np.array([int(labels[i]) for i in order], dtype=float)
219
+ ws = min(self.window_size, len(sorted_labels))
220
+ roll_acc = []
221
+ roll_score = []
222
+ for start in range(len(sorted_labels) - ws + 1):
223
+ chunk = sorted_labels[start : start + ws]
224
+ roll_acc.append(float(chunk.mean()))
225
+ # centre score for this window
226
+ idx_chunk = order[start : start + ws]
227
+ roll_score.append(float(np.mean([scores[i] for i in idx_chunk])))
228
+ return roll_score, roll_acc
229
+
230
+ def _spearman(self, x: List[float], y: List[float]) -> Tuple[float, float]:
231
+ """Spearman ρ with p-value (scipy if available, else normal approx)."""
232
+ if len(x) < 3:
233
+ return float("nan"), float("nan")
234
+ try:
235
+ from scipy.stats import spearmanr # noqa: PLC0415
236
+
237
+ res = spearmanr(x, y)
238
+ return float(res.statistic), float(res.pvalue)
239
+ except Exception:
240
+ # Normal approximation
241
+ n = len(x)
242
+ rx = np.argsort(np.argsort(x)).astype(float)
243
+ ry = np.argsort(np.argsort(y)).astype(float)
244
+ rho = float(np.corrcoef(rx, ry)[0, 1])
245
+ t_stat = rho * math.sqrt((n - 2) / max(1 - rho**2, 1e-10))
246
+ # two-tailed p via normal approx of t
247
+ z = abs(t_stat) / math.sqrt(1 + t_stat**2 / (n - 2))
248
+ p = 2 * (1 - 0.5 * (1 + math.erf(z / math.sqrt(2))))
249
+ return rho, p
250
+
251
+ def _bin_accuracy(self, scores: List[float], labels: List[bool]) -> List[Dict]:
252
+ """Split into num_bins quantile bins, report mean accuracy per bin."""
253
+ if not scores:
254
+ return []
255
+ arr = np.array(scores)
256
+ lbl = np.array([int(b) for b in labels])
257
+ bins = []
258
+ percentiles = np.linspace(0, 100, self.num_bins + 1)
259
+ edges = np.percentile(arr, percentiles)
260
+ for i in range(self.num_bins):
261
+ lo, hi = edges[i], edges[i + 1]
262
+ if i == self.num_bins - 1:
263
+ mask = (arr >= lo) & (arr <= hi)
264
+ else:
265
+ mask = (arr >= lo) & (arr < hi)
266
+ subset = lbl[mask]
267
+ bins.append(
268
+ {
269
+ "bin": i + 1,
270
+ "score_range": [round(float(lo), 4), round(float(hi), 4)],
271
+ "n": int(mask.sum()),
272
+ "accuracy": round(float(subset.mean()), 4) if len(subset) else None,
273
+ }
274
+ )
275
+ return bins
276
+
277
+ # ------------------------------------------------------------------
278
+ # Summary
279
+ # ------------------------------------------------------------------
280
+
281
+ def _print_summary(
282
+ self,
283
+ dataset_name: str,
284
+ norm_layer: int,
285
+ n: int,
286
+ n_cal: int,
287
+ accuracy: float,
288
+ spearman_rho: float,
289
+ spearman_p: float,
290
+ auroc: Optional[float],
291
+ mean_score_correct: float,
292
+ mean_score_incorrect: float,
293
+ bins: List[Dict],
294
+ ) -> None:
295
+ print("\n" + "=" * 66)
296
+ print(
297
+ f"COMPOSITE SHIFT DETECTOR — {dataset_name} (norm=L{norm_layer}, attn=L{self.attn_layer})"
298
+ )
299
+ print("=" * 66)
300
+ print(f" Samples : {n} (calibration: {n_cal})")
301
+ print(f" Accuracy : {accuracy:.4f}")
302
+ print()
303
+ print(
304
+ f" Mahalanobis AUROC : {auroc:.4f}"
305
+ if auroc is not None
306
+ else " Mahalanobis AUROC : n/a"
307
+ )
308
+ print(f" Mean score (correct) : {mean_score_correct:.4f}")
309
+ print(f" Mean score (incorrect) : {mean_score_incorrect:.4f}")
310
+ print()
311
+ sig = "*" if (not math.isnan(spearman_p) and spearman_p < 0.05) else ""
312
+ print(f" Spearman ρ (score vs acc window) : {spearman_rho:.4f}{sig} p={spearman_p:.4f}")
313
+ print()
314
+ print(f" {'Bin':>4} {'Score range':>22} {'N':>5} {'Accuracy':>8}")
315
+ print(" " + "-" * 44)
316
+ for b in bins:
317
+ acc_str = f"{b['accuracy']:.4f}" if b["accuracy"] is not None else " n/a "
318
+ lo, hi = b["score_range"]
319
+ print(f" {b['bin']:>4} [{lo:>8.4f}, {hi:>8.4f}] {b['n']:>5} {acc_str:>8}")
320
+ print("=" * 66)
321
+
322
+ # ------------------------------------------------------------------
323
+ # Main entry point
324
+ # ------------------------------------------------------------------
325
+
326
+ def run(
327
+ self,
328
+ backend: InferenceBackend,
329
+ dataset: BaseDataset,
330
+ prompt_strategy: Any,
331
+ logger: Optional[ExperimentLogger] = None,
332
+ **kwargs,
333
+ ) -> ExperimentResult:
334
+ """Run composite shift detector experiment."""
335
+
336
+ norm_layer = self._resolve_norm_layer(backend)
337
+ tokenizer = backend._tokenizer
338
+ device = backend.device
339
+
340
+ samples = (
341
+ dataset.sample(self.num_samples, seed=self.seed) if self.num_samples else list(dataset)
342
+ )
343
+
344
+ # Switch to eager attention so output_attentions=True is honoured
345
+ model = backend._model
346
+ current_attn = getattr(getattr(model, "config", None), "_attn_implementation", None)
347
+ if current_attn != "eager" and hasattr(model, "set_attn_implementation"):
348
+ print(f"Switching attention implementation: {current_attn} → eager")
349
+ model.set_attn_implementation("eager")
350
+
351
+ print(f"Model : {backend.model_name}")
352
+ print(f"Dataset : {dataset.name}")
353
+ print(f"Norm layer : L{norm_layer}")
354
+ print(f"Attention layer : L{self.attn_layer}")
355
+ print(f"Samples : {len(samples)}")
356
+ print(f"Calibration frac : {self.calibration_fraction}")
357
+
358
+ letter_ids = self._answer_letter_token_ids(tokenizer)
359
+
360
+ per_sample: List[Dict] = []
361
+ all_norms: List[float] = []
362
+ all_entropies: List[float] = []
363
+ all_labels: List[bool] = []
364
+
365
+ for sample in tqdm(samples, desc="Composite signal"):
366
+ answer_tok_id = self._answer_token_id(tokenizer, sample.label)
367
+
368
+ prompt_str = (
369
+ prompt_strategy.build_prompt(
370
+ {
371
+ "text": sample.text,
372
+ "question": sample.text,
373
+ "metadata": sample.metadata or {},
374
+ }
375
+ )
376
+ + self.answer_cue
377
+ )
378
+ tokens = self._tokenize(tokenizer, prompt_str, device)
379
+
380
+ try:
381
+ logits, l2_norm, attn_entropy = self._forward(backend, tokens, norm_layer)
382
+ except Exception as exc:
383
+ tqdm.write(f" [skip] sample {sample.idx}: {exc}")
384
+ torch.cuda.empty_cache() if torch.cuda.is_available() else None
385
+ continue
386
+
387
+ if answer_tok_id is not None and letter_ids:
388
+ best_letter_tok = max(letter_ids, key=lambda t: logits[t].item())
389
+ is_correct = best_letter_tok == answer_tok_id
390
+ else:
391
+ is_correct = False
392
+
393
+ all_norms.append(l2_norm)
394
+ all_entropies.append(attn_entropy)
395
+ all_labels.append(is_correct)
396
+
397
+ per_sample.append(
398
+ {
399
+ "sample_idx": sample.idx,
400
+ "is_correct": is_correct,
401
+ "l2_norm": round(l2_norm, 4),
402
+ "attn_entropy_l3": round(attn_entropy, 6)
403
+ if not math.isnan(attn_entropy)
404
+ else None,
405
+ "mahalanobis": None, # filled in after calibration
406
+ }
407
+ )
408
+
409
+ del logits
410
+ torch.cuda.empty_cache() if torch.cuda.is_available() else None
411
+
412
+ # ── Build feature matrix ────────────────────────────────────────
413
+ n = len(all_labels)
414
+ if n == 0:
415
+ print("\n[composite_shift_detector] No samples collected — all were skipped. Aborting.")
416
+ return ExperimentResult(
417
+ experiment_name=self.name,
418
+ model_name=backend.model_name,
419
+ prompt_strategy=(
420
+ prompt_strategy.name if hasattr(prompt_strategy, "name") else "custom"
421
+ ),
422
+ metrics={"error": "all_samples_skipped", "num_samples": 0},
423
+ raw_outputs={},
424
+ metadata={},
425
+ )
426
+ accuracy = sum(all_labels) / n if n else 0.0
427
+
428
+ # Filter out NaN entropies; replace with mean for Mahalanobis
429
+ ent_arr = np.array(all_entropies)
430
+ valid_ent_mask = ~np.isnan(ent_arr)
431
+ if valid_ent_mask.sum() > 0:
432
+ ent_arr[~valid_ent_mask] = float(ent_arr[valid_ent_mask].mean())
433
+ else:
434
+ ent_arr = np.zeros(n)
435
+
436
+ norm_arr = np.array(all_norms)
437
+ features = np.column_stack([norm_arr, ent_arr]) # [n, 2]
438
+
439
+ # ── Calibration: fit on first calibration_fraction samples ──────
440
+ n_cal = max(2, int(n * self.calibration_fraction))
441
+ cal_features = features[:n_cal]
442
+ mu, prec = self._fit_mahalanobis(cal_features)
443
+
444
+ # ── Mahalanobis scores for all samples ──────────────────────────
445
+ scores = [self._mahalanobis(features[i], mu, prec) for i in range(n)]
446
+ for i, rec in enumerate(per_sample):
447
+ rec["mahalanobis"] = round(scores[i], 6)
448
+
449
+ # ── AUROC: Mahalanobis vs is_correct (higher score → incorrect) ─
450
+ lbl_arr = np.array([int(b) for b in all_labels])
451
+ auroc = None
452
+ if lbl_arr.sum() > 0 and lbl_arr.sum() < n and n >= 2:
453
+ try:
454
+ from sklearn.metrics import roc_auc_score # noqa: PLC0415
455
+
456
+ # Higher Mahalanobis → OOD → incorrect: negate for AUROC
457
+ auroc = float(roc_auc_score(lbl_arr, [-s for s in scores]))
458
+ except Exception:
459
+ pass
460
+
461
+ # ── Rolling accuracy + Spearman ─────────────────────────────────
462
+ roll_scores, roll_acc = self._rolling_accuracy(scores, all_labels)
463
+ spearman_rho, spearman_p = self._spearman(roll_scores, roll_acc)
464
+
465
+ # ── Bin analysis ─────────────────────────────────────────────────
466
+ bins = self._bin_accuracy(scores, all_labels)
467
+
468
+ # ── Mean score by correctness ────────────────────────────────────
469
+ correct_scores = [s for s, c in zip(scores, all_labels) if c]
470
+ incorrect_scores = [s for s, c in zip(scores, all_labels) if not c]
471
+ mean_score_corr = sum(correct_scores) / len(correct_scores) if correct_scores else 0.0
472
+ mean_score_incorr = (
473
+ sum(incorrect_scores) / len(incorrect_scores) if incorrect_scores else 0.0
474
+ )
475
+
476
+ self._print_summary(
477
+ dataset_name=dataset.name,
478
+ norm_layer=norm_layer,
479
+ n=n,
480
+ n_cal=n_cal,
481
+ accuracy=accuracy,
482
+ spearman_rho=spearman_rho,
483
+ spearman_p=spearman_p,
484
+ auroc=auroc,
485
+ mean_score_correct=mean_score_corr,
486
+ mean_score_incorrect=mean_score_incorr,
487
+ bins=bins,
488
+ )
489
+
490
+ return ExperimentResult(
491
+ experiment_name=self.name,
492
+ model_name=backend.model_name,
493
+ prompt_strategy=(
494
+ prompt_strategy.name if hasattr(prompt_strategy, "name") else "custom"
495
+ ),
496
+ metrics={
497
+ "dataset": dataset.name,
498
+ "norm_layer": norm_layer,
499
+ "attn_layer": self.attn_layer,
500
+ "num_samples": n,
501
+ "calibration_n": n_cal,
502
+ "accuracy": round(accuracy, 4),
503
+ "auroc_mahalanobis": round(auroc, 4) if auroc is not None else None,
504
+ "spearman_rho_score_vs_acc": round(spearman_rho, 4)
505
+ if not math.isnan(spearman_rho)
506
+ else None,
507
+ "spearman_p": round(spearman_p, 4) if not math.isnan(spearman_p) else None,
508
+ "mean_score_correct": round(mean_score_corr, 4),
509
+ "mean_score_incorrect": round(mean_score_incorr, 4),
510
+ "accuracy_by_bin": bins,
511
+ },
512
+ raw_outputs={
513
+ "per_sample": per_sample,
514
+ "rolling": {"scores": roll_scores, "accuracy": roll_acc},
515
+ },
516
+ metadata={
517
+ "norm_layer": norm_layer,
518
+ "attn_layer": self.attn_layer,
519
+ "calibration_fraction": self.calibration_fraction,
520
+ "window_size": self.window_size,
521
+ "num_bins": self.num_bins,
522
+ "seed": self.seed,
523
+ },
524
+ )