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,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
|
+
)
|