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,673 @@
|
|
|
1
|
+
"""SAE Feature Analysis Experiment.
|
|
2
|
+
|
|
3
|
+
Uses GemmaScope-2 JumpReLU Sparse Autoencoders to answer two linked questions:
|
|
4
|
+
|
|
5
|
+
1. Vocabulary probing (phase 1 — always runs)
|
|
6
|
+
Which sparse features at layers L24-L28 activate most strongly on
|
|
7
|
+
histopathological vocabulary (nuclear pleomorphism, mitotic index, etc.)?
|
|
8
|
+
|
|
9
|
+
2. Few-shot contrast (phase 2 — runs when ``few_shot_contrast=True``)
|
|
10
|
+
Do those features show significantly higher activation when few-shot
|
|
11
|
+
histopathology exemplars are present in the context vs. absent?
|
|
12
|
+
|
|
13
|
+
Pipeline
|
|
14
|
+
--------
|
|
15
|
+
Phase 1:
|
|
16
|
+
For each term in ``histo_vocab``:
|
|
17
|
+
• Build a short diagnostic context: "Histopathology finding: {term}\\n\\nAnalysis:"
|
|
18
|
+
• Forward pass → cache residual stream at each target layer.
|
|
19
|
+
• SAE-encode residuals at the token positions that decode to the term.
|
|
20
|
+
• Accumulate per-feature activations.
|
|
21
|
+
→ Rank features by mean activation across all histo terms.
|
|
22
|
+
→ Top-K = "histo features" for phase 2.
|
|
23
|
+
|
|
24
|
+
Phase 2:
|
|
25
|
+
For each dataset sample:
|
|
26
|
+
• Build prompt with few_shot=True (clean).
|
|
27
|
+
• Build prompt with few_shot=False (corrupt / zero-shot).
|
|
28
|
+
• Forward pass for each → SAE-encode residuals at last-token position.
|
|
29
|
+
• Collect activation of each histo feature under both conditions.
|
|
30
|
+
→ Mann-Whitney U test per feature; Bonferroni correction over top_k features.
|
|
31
|
+
→ Effect size = (mean_few_shot - mean_zero_shot) / pooled_std.
|
|
32
|
+
|
|
33
|
+
No SAELens or TransformerLens dependency — SAE weights are loaded directly
|
|
34
|
+
from HuggingFace using ``huggingface_hub`` (already in project deps).
|
|
35
|
+
"""
|
|
36
|
+
|
|
37
|
+
import math
|
|
38
|
+
import os
|
|
39
|
+
import re
|
|
40
|
+
from typing import Any, Dict, List, Optional, Tuple
|
|
41
|
+
|
|
42
|
+
import torch
|
|
43
|
+
from tqdm import tqdm
|
|
44
|
+
|
|
45
|
+
from ..backends.base import InferenceBackend
|
|
46
|
+
from ..core.base import BaseExperiment, ExperimentResult
|
|
47
|
+
from ..core.registry import Registry
|
|
48
|
+
from ..datasets.loaders import BaseDataset
|
|
49
|
+
from ..logging import ExperimentLogger
|
|
50
|
+
from ..patching.sae import GemmaScopeLayer
|
|
51
|
+
|
|
52
|
+
# Histopathology-specific few-shot examples for Phase 2.
|
|
53
|
+
# These are heavily loaded with Phase-1 vocabulary (pleomorphism, mitotic figures,
|
|
54
|
+
# Ki-67, cribriform, comedonecrosis, HER2, perineural invasion, Gleason, adenocarcinoma)
|
|
55
|
+
# so that toggling them on/off gives a signal directly correlated with histo-feature
|
|
56
|
+
# activation — unlike the generic Pneumonia/Meningitis examples that were used before.
|
|
57
|
+
HISTO_FEW_SHOT_EXAMPLES: List[Tuple[str, str]] = [
|
|
58
|
+
(
|
|
59
|
+
"Sections show sheets of pleomorphic cells with vesicular nuclei, prominent nucleoli, "
|
|
60
|
+
"and frequent atypical mitotic figures. Ki-67 index 85%. No glandular differentiation.",
|
|
61
|
+
"High-grade invasive carcinoma with anaplastic features",
|
|
62
|
+
),
|
|
63
|
+
(
|
|
64
|
+
"Cribriform architecture with comedonecrosis, nuclear atypia grade 3, HER2 3+, "
|
|
65
|
+
"ER-negative, PR-negative. Angiolymphatic invasion present, margins involved.",
|
|
66
|
+
"High-grade DCIS with comedonecrosis; triple-negative, HER2-amplified",
|
|
67
|
+
),
|
|
68
|
+
(
|
|
69
|
+
"Core biopsy: acinar infiltrative growth, nuclear enlargement, prominent nucleoli, "
|
|
70
|
+
"perineural invasion, Gleason pattern 4+5. Extraprostatic extension confirmed.",
|
|
71
|
+
"Gleason grade group 5 prostatic adenocarcinoma with perineural invasion",
|
|
72
|
+
),
|
|
73
|
+
]
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
# Default histopathology vocabulary — overridable via config.
|
|
77
|
+
DEFAULT_HISTO_VOCAB: List[str] = [
|
|
78
|
+
"pleomorphism",
|
|
79
|
+
"mitosis",
|
|
80
|
+
"mitotic",
|
|
81
|
+
"necrosis",
|
|
82
|
+
"carcinoma",
|
|
83
|
+
"adenocarcinoma",
|
|
84
|
+
"squamous",
|
|
85
|
+
"lymphocyte",
|
|
86
|
+
"stroma",
|
|
87
|
+
"hyperchromasia",
|
|
88
|
+
"chromatin",
|
|
89
|
+
"tubular",
|
|
90
|
+
"papillary",
|
|
91
|
+
"cribriform",
|
|
92
|
+
"comedonecrosis",
|
|
93
|
+
"angiolymphatic",
|
|
94
|
+
"perineural",
|
|
95
|
+
"Gleason",
|
|
96
|
+
"Ki-67",
|
|
97
|
+
"HER2",
|
|
98
|
+
"invasion",
|
|
99
|
+
"differentiation",
|
|
100
|
+
"microinvasion",
|
|
101
|
+
"dysplasia",
|
|
102
|
+
"atypia",
|
|
103
|
+
]
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
@Registry.register_experiment("sae_feature_analysis")
|
|
107
|
+
class SAEFeatureAnalysisExperiment(BaseExperiment):
|
|
108
|
+
"""
|
|
109
|
+
GemmaScope-2 SAE feature analysis at target residual-stream layers.
|
|
110
|
+
|
|
111
|
+
Combines vocabulary probing (which features respond to histo terms?) with
|
|
112
|
+
an optional few-shot contrast test (do those features activate more when
|
|
113
|
+
histo exemplars are in-context?).
|
|
114
|
+
"""
|
|
115
|
+
|
|
116
|
+
def __init__(
|
|
117
|
+
self,
|
|
118
|
+
name: str = "sae_feature_analysis",
|
|
119
|
+
description: str = "GemmaScope-2 SAE histo feature identification and few-shot contrast",
|
|
120
|
+
# SAE configuration
|
|
121
|
+
sae_repo_id: str = "google/gemma-scope-2-270m-it",
|
|
122
|
+
sae_site: str = "resid_post_all",
|
|
123
|
+
sae_width: str = "16k",
|
|
124
|
+
sae_l0: str = "small",
|
|
125
|
+
# Layer selection
|
|
126
|
+
target_layers: Optional[List[int]] = None, # null = [8,9,10,11,12] for 270m
|
|
127
|
+
# Vocabulary probing
|
|
128
|
+
histo_vocab: Optional[List[str]] = None, # null = DEFAULT_HISTO_VOCAB
|
|
129
|
+
vocab_context_prefix: str = "Histopathology finding:",
|
|
130
|
+
top_k_features: int = 20,
|
|
131
|
+
# Few-shot contrast
|
|
132
|
+
few_shot_contrast: bool = True,
|
|
133
|
+
num_samples: int = 50,
|
|
134
|
+
seed: int = 42,
|
|
135
|
+
max_input_tokens: int = 1024,
|
|
136
|
+
answer_cue: str = "\n\nAnswer:",
|
|
137
|
+
**kwargs,
|
|
138
|
+
):
|
|
139
|
+
self._name = name
|
|
140
|
+
self.description = description
|
|
141
|
+
self.sae_repo_id = sae_repo_id
|
|
142
|
+
self.sae_site = sae_site
|
|
143
|
+
self.sae_width = sae_width
|
|
144
|
+
self.sae_l0 = sae_l0
|
|
145
|
+
self._target_layers_config = target_layers
|
|
146
|
+
self.histo_vocab = histo_vocab or DEFAULT_HISTO_VOCAB
|
|
147
|
+
self.vocab_context_prefix = vocab_context_prefix
|
|
148
|
+
self.top_k_features = top_k_features
|
|
149
|
+
self.few_shot_contrast = few_shot_contrast
|
|
150
|
+
self.num_samples = num_samples
|
|
151
|
+
self.seed = seed
|
|
152
|
+
self.max_input_tokens = max_input_tokens
|
|
153
|
+
self.answer_cue = answer_cue
|
|
154
|
+
|
|
155
|
+
@property
|
|
156
|
+
def name(self) -> str:
|
|
157
|
+
return self._name
|
|
158
|
+
|
|
159
|
+
# ------------------------------------------------------------------
|
|
160
|
+
# Layer resolution
|
|
161
|
+
# ------------------------------------------------------------------
|
|
162
|
+
|
|
163
|
+
def _resolve_layers(self, backend: InferenceBackend) -> List[int]:
|
|
164
|
+
if self._target_layers_config is not None:
|
|
165
|
+
return list(self._target_layers_config)
|
|
166
|
+
# Default: all layers — lets post-hoc analysis focus on any band (e.g. L24-28).
|
|
167
|
+
return list(range(backend.hook_manager.num_layers))
|
|
168
|
+
|
|
169
|
+
# ------------------------------------------------------------------
|
|
170
|
+
# SAE loading
|
|
171
|
+
# ------------------------------------------------------------------
|
|
172
|
+
|
|
173
|
+
def _load_saes(self, layers: List[int], device: str) -> Dict[int, GemmaScopeLayer]:
|
|
174
|
+
"""Download and move SAE weights for each target layer."""
|
|
175
|
+
hf_token = os.getenv("HF_TOKEN")
|
|
176
|
+
saes: Dict[int, GemmaScopeLayer] = {}
|
|
177
|
+
for layer_idx in layers:
|
|
178
|
+
sae = GemmaScopeLayer.from_pretrained(
|
|
179
|
+
repo_id=self.sae_repo_id,
|
|
180
|
+
layer=layer_idx,
|
|
181
|
+
site=self.sae_site,
|
|
182
|
+
width=self.sae_width,
|
|
183
|
+
l0_label=self.sae_l0,
|
|
184
|
+
token=hf_token,
|
|
185
|
+
)
|
|
186
|
+
saes[layer_idx] = sae.to(device)
|
|
187
|
+
return saes
|
|
188
|
+
|
|
189
|
+
# ------------------------------------------------------------------
|
|
190
|
+
# Residual stream extraction
|
|
191
|
+
# ------------------------------------------------------------------
|
|
192
|
+
|
|
193
|
+
def _extract_residuals(
|
|
194
|
+
self,
|
|
195
|
+
backend: InferenceBackend,
|
|
196
|
+
tokens: Dict[str, torch.Tensor],
|
|
197
|
+
layers: List[int],
|
|
198
|
+
) -> Dict[int, torch.Tensor]:
|
|
199
|
+
"""Single forward pass caching residual stream at each target layer.
|
|
200
|
+
|
|
201
|
+
Returns:
|
|
202
|
+
dict: layer_idx → float32 CPU tensor of shape [seq_len, d_model].
|
|
203
|
+
"""
|
|
204
|
+
cache: Dict[int, torch.Tensor] = {}
|
|
205
|
+
|
|
206
|
+
def make_hook(layer_idx: int):
|
|
207
|
+
def hook(module, inp, output):
|
|
208
|
+
tensor = output[0] if isinstance(output, tuple) else output
|
|
209
|
+
with torch.no_grad():
|
|
210
|
+
cache[layer_idx] = tensor[0].detach().float().cpu()
|
|
211
|
+
|
|
212
|
+
return hook
|
|
213
|
+
|
|
214
|
+
handles = []
|
|
215
|
+
for layer_idx in layers:
|
|
216
|
+
if layer_idx < backend.hook_manager.num_layers:
|
|
217
|
+
mod = backend.hook_manager.get_residual_module(layer_idx)
|
|
218
|
+
handles.append(mod.register_forward_hook(make_hook(layer_idx)))
|
|
219
|
+
|
|
220
|
+
try:
|
|
221
|
+
with torch.no_grad():
|
|
222
|
+
backend._model(**tokens)
|
|
223
|
+
finally:
|
|
224
|
+
for h in handles:
|
|
225
|
+
h.remove()
|
|
226
|
+
|
|
227
|
+
return cache
|
|
228
|
+
|
|
229
|
+
# ------------------------------------------------------------------
|
|
230
|
+
# Token-position helpers
|
|
231
|
+
# ------------------------------------------------------------------
|
|
232
|
+
|
|
233
|
+
def _term_token_positions(
|
|
234
|
+
self,
|
|
235
|
+
input_ids: torch.Tensor,
|
|
236
|
+
tokenizer,
|
|
237
|
+
term: str,
|
|
238
|
+
) -> List[int]:
|
|
239
|
+
"""Find token positions in input_ids that overlap with ``term``.
|
|
240
|
+
|
|
241
|
+
Uses char-offset mapping to align decoded tokens with the term string.
|
|
242
|
+
Returns an empty list if the term is not found in the decoded text.
|
|
243
|
+
"""
|
|
244
|
+
full_text = tokenizer.decode(input_ids.tolist(), skip_special_tokens=False)
|
|
245
|
+
term_lower = term.lower()
|
|
246
|
+
# Find all char-level occurrences of the term.
|
|
247
|
+
spans: List[Tuple[int, int]] = []
|
|
248
|
+
for m in re.finditer(re.escape(term_lower), full_text.lower()):
|
|
249
|
+
spans.append((m.start(), m.end()))
|
|
250
|
+
if not spans:
|
|
251
|
+
return []
|
|
252
|
+
|
|
253
|
+
# Build cumulative char offset per token.
|
|
254
|
+
tok_ranges: List[Tuple[int, int]] = []
|
|
255
|
+
offset = 0
|
|
256
|
+
for tid in input_ids.tolist():
|
|
257
|
+
decoded = tokenizer.decode([tid], skip_special_tokens=False)
|
|
258
|
+
tok_ranges.append((offset, offset + len(decoded)))
|
|
259
|
+
offset += len(decoded)
|
|
260
|
+
|
|
261
|
+
positions: List[int] = []
|
|
262
|
+
for i, (tok_start, tok_end) in enumerate(tok_ranges):
|
|
263
|
+
for es, ee in spans:
|
|
264
|
+
if tok_start < ee and tok_end > es: # overlap
|
|
265
|
+
positions.append(i)
|
|
266
|
+
break
|
|
267
|
+
|
|
268
|
+
return positions
|
|
269
|
+
|
|
270
|
+
def _tokenize(self, tokenizer, text: str, device: str) -> Dict[str, torch.Tensor]:
|
|
271
|
+
return tokenizer(
|
|
272
|
+
text,
|
|
273
|
+
return_tensors="pt",
|
|
274
|
+
truncation=True,
|
|
275
|
+
max_length=self.max_input_tokens,
|
|
276
|
+
).to(device)
|
|
277
|
+
|
|
278
|
+
# ------------------------------------------------------------------
|
|
279
|
+
# Phase 1: vocabulary probing
|
|
280
|
+
# ------------------------------------------------------------------
|
|
281
|
+
|
|
282
|
+
def _probe_vocab(
|
|
283
|
+
self,
|
|
284
|
+
backend: InferenceBackend,
|
|
285
|
+
saes: Dict[int, GemmaScopeLayer],
|
|
286
|
+
layers: List[int],
|
|
287
|
+
) -> Dict[int, Dict[int, float]]:
|
|
288
|
+
"""Probe which SAE features activate on histo vocabulary tokens.
|
|
289
|
+
|
|
290
|
+
For each term in ``histo_vocab``:
|
|
291
|
+
1. Build a short diagnostic context.
|
|
292
|
+
2. Forward pass → residuals at each target layer.
|
|
293
|
+
3. SAE-encode residuals at the term's token positions.
|
|
294
|
+
4. Accumulate mean activation per feature.
|
|
295
|
+
|
|
296
|
+
Returns:
|
|
297
|
+
histo_scores[layer_idx][feature_idx] = mean activation across all terms.
|
|
298
|
+
"""
|
|
299
|
+
tokenizer = backend._tokenizer
|
|
300
|
+
device = backend.device
|
|
301
|
+
|
|
302
|
+
# Accumulators: layer → feature_idx → list of activation values.
|
|
303
|
+
accum: Dict[int, Dict[int, List[float]]] = {layer: {} for layer in layers}
|
|
304
|
+
|
|
305
|
+
print(f"\nPhase 1: vocabulary probing over {len(self.histo_vocab)} terms …")
|
|
306
|
+
for term in tqdm(self.histo_vocab, desc="Vocab probe"):
|
|
307
|
+
prompt = f"{self.vocab_context_prefix} {term}{self.answer_cue}"
|
|
308
|
+
tokens = self._tokenize(tokenizer, prompt, device)
|
|
309
|
+
input_ids = tokens["input_ids"][0]
|
|
310
|
+
|
|
311
|
+
term_positions = self._term_token_positions(input_ids, tokenizer, term)
|
|
312
|
+
if not term_positions:
|
|
313
|
+
# Fallback: use all non-special token positions.
|
|
314
|
+
term_positions = list(range(len(input_ids)))
|
|
315
|
+
|
|
316
|
+
residuals = self._extract_residuals(backend, tokens, layers)
|
|
317
|
+
|
|
318
|
+
for layer_idx, resid in residuals.items():
|
|
319
|
+
# resid: [seq_len, d_model] (CPU float32)
|
|
320
|
+
sae = saes[layer_idx]
|
|
321
|
+
term_resid = resid[term_positions] # [n_toks, d_model]
|
|
322
|
+
with torch.no_grad():
|
|
323
|
+
features = sae.encode(term_resid.to(sae.w_enc.device))
|
|
324
|
+
# mean over term tokens → [d_sae]
|
|
325
|
+
mean_acts = features.mean(dim=0).cpu()
|
|
326
|
+
|
|
327
|
+
for feat_idx, val in enumerate(mean_acts.tolist()):
|
|
328
|
+
if val > 0:
|
|
329
|
+
accum[layer_idx].setdefault(feat_idx, []).append(val)
|
|
330
|
+
|
|
331
|
+
# Aggregate: mean activation per feature (0 if never fired).
|
|
332
|
+
histo_scores: Dict[int, Dict[int, float]] = {}
|
|
333
|
+
for layer_idx in layers:
|
|
334
|
+
scores: Dict[int, float] = {}
|
|
335
|
+
for feat_idx, vals in accum[layer_idx].items():
|
|
336
|
+
scores[feat_idx] = sum(vals) / len(vals)
|
|
337
|
+
histo_scores[layer_idx] = scores
|
|
338
|
+
|
|
339
|
+
return histo_scores
|
|
340
|
+
|
|
341
|
+
# ------------------------------------------------------------------
|
|
342
|
+
# Phase 2: few-shot contrast
|
|
343
|
+
# ------------------------------------------------------------------
|
|
344
|
+
|
|
345
|
+
def _build_histo_few_shot_block(self) -> str:
|
|
346
|
+
"""Build a histopathology-specific few-shot context block.
|
|
347
|
+
|
|
348
|
+
Uses HISTO_FEW_SHOT_EXAMPLES — vocabulary-dense pathology report snippets
|
|
349
|
+
that directly activate the same sparse features Phase 1 identifies.
|
|
350
|
+
These replace the old generic Pneumonia/Meningitis examples which had no
|
|
351
|
+
overlap with histopathology vocabulary.
|
|
352
|
+
"""
|
|
353
|
+
lines = [f"Q: {q}\nA: {a}" for q, a in HISTO_FEW_SHOT_EXAMPLES]
|
|
354
|
+
return "Histopathology examples:\n\n" + "\n\n".join(lines) + "\n\n"
|
|
355
|
+
|
|
356
|
+
def _build_prompt(self, prompt_strategy: Any, text: str, metadata: dict, few_shot: bool) -> str:
|
|
357
|
+
"""Build prompt, prepending histo-specific few-shot block when few_shot=True.
|
|
358
|
+
|
|
359
|
+
Bypasses the prompt strategy's own few_shot flag entirely — that flag uses
|
|
360
|
+
generic medical examples (Pneumonia, Meningitis) which are irrelevant to
|
|
361
|
+
histopathology feature activation. Instead we directly prepend or omit
|
|
362
|
+
the HISTO_FEW_SHOT_EXAMPLES block so the contrast is always histo-specific.
|
|
363
|
+
"""
|
|
364
|
+
base = prompt_strategy.build_prompt(
|
|
365
|
+
{"text": text, "question": text, "report": text, "metadata": metadata}
|
|
366
|
+
)
|
|
367
|
+
base += self.answer_cue
|
|
368
|
+
if few_shot:
|
|
369
|
+
return self._build_histo_few_shot_block() + base
|
|
370
|
+
return base
|
|
371
|
+
|
|
372
|
+
def _contrast_few_shot(
|
|
373
|
+
self,
|
|
374
|
+
backend: InferenceBackend,
|
|
375
|
+
dataset: BaseDataset,
|
|
376
|
+
prompt_strategy: Any,
|
|
377
|
+
saes: Dict[int, GemmaScopeLayer],
|
|
378
|
+
layers: List[int],
|
|
379
|
+
top_features: Dict[int, List[int]],
|
|
380
|
+
) -> Dict[int, Dict[int, Dict[str, List[float]]]]:
|
|
381
|
+
"""Collect per-feature activations under few-shot vs zero-shot conditions.
|
|
382
|
+
|
|
383
|
+
Returns:
|
|
384
|
+
contrast[layer_idx][feature_idx] = {
|
|
385
|
+
"few_shot": [float, ...], # one value per sample
|
|
386
|
+
"zero_shot": [float, ...],
|
|
387
|
+
}
|
|
388
|
+
"""
|
|
389
|
+
tokenizer = backend._tokenizer
|
|
390
|
+
device = backend.device
|
|
391
|
+
samples = dataset.sample(self.num_samples, seed=self.seed)
|
|
392
|
+
|
|
393
|
+
# contrast[layer][feature] = {few_shot: [], zero_shot: []}
|
|
394
|
+
contrast: Dict[int, Dict[int, Dict[str, List[float]]]] = {
|
|
395
|
+
layer: {f: {"few_shot": [], "zero_shot": []} for f in top_features[layer]}
|
|
396
|
+
for layer in layers
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
print(f"\nPhase 2: few-shot contrast over {len(samples)} samples …")
|
|
400
|
+
for sample in tqdm(samples, desc="Few-shot contrast"):
|
|
401
|
+
for condition, few_shot_flag in [("few_shot", True), ("zero_shot", False)]:
|
|
402
|
+
try:
|
|
403
|
+
prompt_str = self._build_prompt(
|
|
404
|
+
prompt_strategy, sample.text, sample.metadata or {}, few_shot_flag
|
|
405
|
+
)
|
|
406
|
+
except Exception as exc:
|
|
407
|
+
tqdm.write(f" [skip] sample {sample.idx} prompt ({condition}): {exc}")
|
|
408
|
+
continue
|
|
409
|
+
|
|
410
|
+
tokens = self._tokenize(tokenizer, prompt_str, device)
|
|
411
|
+
residuals = self._extract_residuals(backend, tokens, layers)
|
|
412
|
+
|
|
413
|
+
for layer_idx, resid in residuals.items():
|
|
414
|
+
# Use last-token residual — position before the answer letter.
|
|
415
|
+
last_resid = resid[-1].unsqueeze(0) # [1, d_model]
|
|
416
|
+
sae = saes[layer_idx]
|
|
417
|
+
with torch.no_grad():
|
|
418
|
+
features = sae.encode(last_resid.to(sae.w_enc.device))
|
|
419
|
+
feat_acts = features[0].cpu() # [d_sae]
|
|
420
|
+
|
|
421
|
+
for feat_idx in top_features[layer_idx]:
|
|
422
|
+
val = float(feat_acts[feat_idx].item())
|
|
423
|
+
contrast[layer_idx][feat_idx][condition].append(val)
|
|
424
|
+
|
|
425
|
+
torch.cuda.empty_cache() if torch.cuda.is_available() else None
|
|
426
|
+
|
|
427
|
+
return contrast
|
|
428
|
+
|
|
429
|
+
# ------------------------------------------------------------------
|
|
430
|
+
# Statistical testing
|
|
431
|
+
# ------------------------------------------------------------------
|
|
432
|
+
|
|
433
|
+
@staticmethod
|
|
434
|
+
def _mann_whitney(a: List[float], b: List[float]) -> Tuple[float, float, float]:
|
|
435
|
+
"""Mann-Whitney U test (two-sided) + effect size.
|
|
436
|
+
|
|
437
|
+
Returns:
|
|
438
|
+
(U_statistic, p_value, effect_size)
|
|
439
|
+
effect_size = (mean_a - mean_b) / pooled_std (Cohen's d approximation)
|
|
440
|
+
"""
|
|
441
|
+
if not a or not b:
|
|
442
|
+
return (float("nan"), float("nan"), float("nan"))
|
|
443
|
+
|
|
444
|
+
# Try scipy first.
|
|
445
|
+
try:
|
|
446
|
+
from scipy.stats import mannwhitneyu # noqa: PLC0415
|
|
447
|
+
|
|
448
|
+
result = mannwhitneyu(a, b, alternative="two-sided")
|
|
449
|
+
U, p = float(result.statistic), float(result.pvalue)
|
|
450
|
+
except ImportError:
|
|
451
|
+
# Normal approximation for large samples.
|
|
452
|
+
n1, n2 = len(a), len(b)
|
|
453
|
+
combined = sorted(enumerate(a + b), key=lambda t: t[1])
|
|
454
|
+
ranks = [0.0] * (n1 + n2)
|
|
455
|
+
for rank_idx, (orig_idx, _) in enumerate(combined, start=1):
|
|
456
|
+
ranks[orig_idx] = float(rank_idx)
|
|
457
|
+
U = sum(ranks[:n1]) - n1 * (n1 + 1) / 2
|
|
458
|
+
mu = n1 * n2 / 2
|
|
459
|
+
sigma = math.sqrt(n1 * n2 * (n1 + n2 + 1) / 12 + 1e-12)
|
|
460
|
+
z = (U - mu) / sigma
|
|
461
|
+
p = float(2 * (1 - 0.5 * (1 + math.erf(abs(z) / math.sqrt(2)))))
|
|
462
|
+
|
|
463
|
+
# Effect size (Cohen's d approximation).
|
|
464
|
+
mean_a = sum(a) / len(a)
|
|
465
|
+
mean_b = sum(b) / len(b)
|
|
466
|
+
var_a = sum((v - mean_a) ** 2 for v in a) / max(len(a) - 1, 1)
|
|
467
|
+
var_b = sum((v - mean_b) ** 2 for v in b) / max(len(b) - 1, 1)
|
|
468
|
+
pooled_std = math.sqrt((var_a + var_b) / 2 + 1e-12)
|
|
469
|
+
effect_size = (mean_a - mean_b) / pooled_std
|
|
470
|
+
|
|
471
|
+
return (U, p, effect_size)
|
|
472
|
+
|
|
473
|
+
@staticmethod
|
|
474
|
+
def _bonferroni(p_values: List[float]) -> List[float]:
|
|
475
|
+
n = len(p_values)
|
|
476
|
+
return [min(1.0, p * n) for p in p_values]
|
|
477
|
+
|
|
478
|
+
# ------------------------------------------------------------------
|
|
479
|
+
# Summary printing
|
|
480
|
+
# ------------------------------------------------------------------
|
|
481
|
+
|
|
482
|
+
def _print_vocab_summary(
|
|
483
|
+
self,
|
|
484
|
+
histo_scores: Dict[int, Dict[int, float]],
|
|
485
|
+
top_features: Dict[int, List[int]],
|
|
486
|
+
) -> None:
|
|
487
|
+
print("\n" + "=" * 70)
|
|
488
|
+
print("PHASE 1 — VOCABULARY PROBING RESULTS")
|
|
489
|
+
print("=" * 70)
|
|
490
|
+
for layer_idx, feat_list in top_features.items():
|
|
491
|
+
scores = histo_scores[layer_idx]
|
|
492
|
+
print(f"\n Layer {layer_idx} (top {len(feat_list)} histo features)")
|
|
493
|
+
print(f" {'Feature':>10} {'Mean Activation':>16}")
|
|
494
|
+
print(" " + "-" * 30)
|
|
495
|
+
for fid in feat_list:
|
|
496
|
+
print(f" {fid:>10} {scores.get(fid, 0.0):>16.4f}")
|
|
497
|
+
|
|
498
|
+
def _print_contrast_summary(
|
|
499
|
+
self,
|
|
500
|
+
contrast_stats: Dict[int, List[Dict]],
|
|
501
|
+
) -> None:
|
|
502
|
+
print("\n" + "=" * 70)
|
|
503
|
+
print("PHASE 2 — FEW-SHOT CONTRAST RESULTS")
|
|
504
|
+
print("=" * 70)
|
|
505
|
+
for layer_idx, stats_list in contrast_stats.items():
|
|
506
|
+
sig = [s for s in stats_list if s["p_bonferroni"] < 0.05]
|
|
507
|
+
print(
|
|
508
|
+
f"\n Layer {layer_idx} — {len(sig)}/{len(stats_list)} features significant (p_bonf<0.05)"
|
|
509
|
+
)
|
|
510
|
+
if sig:
|
|
511
|
+
print(
|
|
512
|
+
f" {'Feature':>10} {'Effect':>8} {'p_raw':>9} "
|
|
513
|
+
f"{'p_bonf':>9} {'mean_fs':>9} {'mean_zs':>9}"
|
|
514
|
+
)
|
|
515
|
+
print(" " + "-" * 62)
|
|
516
|
+
for s in sorted(sig, key=lambda x: -abs(x["effect_size"])):
|
|
517
|
+
direction = "↑" if s["effect_size"] > 0 else "↓"
|
|
518
|
+
print(
|
|
519
|
+
f" {s['feature_idx']:>10} "
|
|
520
|
+
f"{s['effect_size']:>+7.3f}{direction} "
|
|
521
|
+
f"{s['p_raw']:>9.4f} "
|
|
522
|
+
f"{s['p_bonferroni']:>9.4f} "
|
|
523
|
+
f"{s['mean_few_shot']:>9.4f} "
|
|
524
|
+
f"{s['mean_zero_shot']:>9.4f}"
|
|
525
|
+
)
|
|
526
|
+
print()
|
|
527
|
+
print(" Positive effect = higher activation with few-shot exemplars.")
|
|
528
|
+
print("=" * 70)
|
|
529
|
+
|
|
530
|
+
# ------------------------------------------------------------------
|
|
531
|
+
# Main entry point
|
|
532
|
+
# ------------------------------------------------------------------
|
|
533
|
+
|
|
534
|
+
def run(
|
|
535
|
+
self,
|
|
536
|
+
backend: InferenceBackend,
|
|
537
|
+
dataset: BaseDataset,
|
|
538
|
+
prompt_strategy: Any,
|
|
539
|
+
logger: Optional[ExperimentLogger] = None,
|
|
540
|
+
**kwargs,
|
|
541
|
+
) -> ExperimentResult:
|
|
542
|
+
"""Run SAE feature analysis experiment."""
|
|
543
|
+
|
|
544
|
+
layers = self._resolve_layers(backend)
|
|
545
|
+
|
|
546
|
+
print(f"Model : {backend.model_name}")
|
|
547
|
+
print(f"SAE repo : {self.sae_repo_id}")
|
|
548
|
+
print(f"SAE site : {self.sae_site} width={self.sae_width} l0={self.sae_l0}")
|
|
549
|
+
print(f"Target layers : {layers}")
|
|
550
|
+
print(f"Histo vocab : {len(self.histo_vocab)} terms")
|
|
551
|
+
print(f"Few-shot contr: {self.few_shot_contrast}")
|
|
552
|
+
|
|
553
|
+
# Load SAEs.
|
|
554
|
+
print("\nLoading SAE weights …")
|
|
555
|
+
saes = self._load_saes(layers, backend.device)
|
|
556
|
+
|
|
557
|
+
# ── Phase 1: vocabulary probing ────────────────────────────────
|
|
558
|
+
histo_scores = self._probe_vocab(backend, saes, layers)
|
|
559
|
+
|
|
560
|
+
# Select top-K features per layer by mean histo activation.
|
|
561
|
+
top_features: Dict[int, List[int]] = {}
|
|
562
|
+
for layer_idx in layers:
|
|
563
|
+
scores = histo_scores[layer_idx]
|
|
564
|
+
ranked = sorted(scores, key=lambda f: -scores[f])
|
|
565
|
+
top_features[layer_idx] = ranked[: self.top_k_features]
|
|
566
|
+
|
|
567
|
+
self._print_vocab_summary(histo_scores, top_features)
|
|
568
|
+
|
|
569
|
+
# ── Phase 2: few-shot contrast ─────────────────────────────────
|
|
570
|
+
contrast_stats_per_layer: Dict[int, List[Dict]] = {}
|
|
571
|
+
|
|
572
|
+
if self.few_shot_contrast and dataset is not None:
|
|
573
|
+
contrast_raw = self._contrast_few_shot(
|
|
574
|
+
backend, dataset, prompt_strategy, saes, layers, top_features
|
|
575
|
+
)
|
|
576
|
+
|
|
577
|
+
for layer_idx in layers:
|
|
578
|
+
stats_list: List[Dict] = []
|
|
579
|
+
raw_p: List[float] = []
|
|
580
|
+
|
|
581
|
+
for feat_idx in top_features[layer_idx]:
|
|
582
|
+
fs = contrast_raw[layer_idx][feat_idx]["few_shot"]
|
|
583
|
+
zs = contrast_raw[layer_idx][feat_idx]["zero_shot"]
|
|
584
|
+
U, p_raw, effect = self._mann_whitney(fs, zs)
|
|
585
|
+
raw_p.append(p_raw if not math.isnan(p_raw) else 1.0)
|
|
586
|
+
stats_list.append(
|
|
587
|
+
{
|
|
588
|
+
"feature_idx": feat_idx,
|
|
589
|
+
"histo_score": round(histo_scores[layer_idx].get(feat_idx, 0.0), 4),
|
|
590
|
+
"U_statistic": round(U, 2) if not math.isnan(U) else None,
|
|
591
|
+
"p_raw": round(p_raw, 6) if not math.isnan(p_raw) else None,
|
|
592
|
+
"effect_size": round(effect, 4) if not math.isnan(effect) else None,
|
|
593
|
+
"mean_few_shot": round(sum(fs) / len(fs), 4) if fs else None,
|
|
594
|
+
"mean_zero_shot": round(sum(zs) / len(zs), 4) if zs else None,
|
|
595
|
+
"n_few_shot": len(fs),
|
|
596
|
+
"n_zero_shot": len(zs),
|
|
597
|
+
"p_bonferroni": None, # filled below
|
|
598
|
+
}
|
|
599
|
+
)
|
|
600
|
+
|
|
601
|
+
bonf_p = self._bonferroni(raw_p)
|
|
602
|
+
for entry, bp in zip(stats_list, bonf_p):
|
|
603
|
+
entry["p_bonferroni"] = round(bp, 6)
|
|
604
|
+
|
|
605
|
+
contrast_stats_per_layer[layer_idx] = stats_list
|
|
606
|
+
|
|
607
|
+
self._print_contrast_summary(contrast_stats_per_layer)
|
|
608
|
+
|
|
609
|
+
# ── Build result ───────────────────────────────────────────────
|
|
610
|
+
# Compact metrics: top feature per layer + significant contrast count.
|
|
611
|
+
compact_metrics: Dict[str, Any] = {
|
|
612
|
+
"layers_analysed": layers,
|
|
613
|
+
"sae_repo_id": self.sae_repo_id,
|
|
614
|
+
"top_k_features": self.top_k_features,
|
|
615
|
+
}
|
|
616
|
+
for layer_idx in layers:
|
|
617
|
+
prefix = f"layer_{layer_idx}"
|
|
618
|
+
scores = histo_scores[layer_idx]
|
|
619
|
+
feats = top_features[layer_idx]
|
|
620
|
+
compact_metrics[f"{prefix}_top_feature"] = feats[0] if feats else None
|
|
621
|
+
compact_metrics[f"{prefix}_top_histo_score"] = (
|
|
622
|
+
round(scores[feats[0]], 4) if feats else 0.0
|
|
623
|
+
)
|
|
624
|
+
if layer_idx in contrast_stats_per_layer:
|
|
625
|
+
stats = contrast_stats_per_layer[layer_idx]
|
|
626
|
+
n_sig = sum(
|
|
627
|
+
1 for s in stats if s["p_bonferroni"] is not None and s["p_bonferroni"] < 0.05
|
|
628
|
+
)
|
|
629
|
+
fs_acts = [s["mean_few_shot"] for s in stats if s["mean_few_shot"] is not None]
|
|
630
|
+
zs_acts = [s["mean_zero_shot"] for s in stats if s["mean_zero_shot"] is not None]
|
|
631
|
+
effects = [s["effect_size"] for s in stats if s["effect_size"] is not None]
|
|
632
|
+
compact_metrics[f"{prefix}_n_significant_features"] = n_sig
|
|
633
|
+
compact_metrics[f"{prefix}_mean_act_few_shot"] = (
|
|
634
|
+
round(sum(fs_acts) / len(fs_acts), 4) if fs_acts else None
|
|
635
|
+
)
|
|
636
|
+
compact_metrics[f"{prefix}_mean_act_zero_shot"] = (
|
|
637
|
+
round(sum(zs_acts) / len(zs_acts), 4) if zs_acts else None
|
|
638
|
+
)
|
|
639
|
+
compact_metrics[f"{prefix}_mean_effect_size"] = (
|
|
640
|
+
round(sum(effects) / len(effects), 4) if effects else None
|
|
641
|
+
)
|
|
642
|
+
|
|
643
|
+
return ExperimentResult(
|
|
644
|
+
experiment_name=self.name,
|
|
645
|
+
model_name=backend.model_name,
|
|
646
|
+
prompt_strategy=(
|
|
647
|
+
prompt_strategy.name if hasattr(prompt_strategy, "name") else "custom"
|
|
648
|
+
),
|
|
649
|
+
metrics=compact_metrics,
|
|
650
|
+
raw_outputs={
|
|
651
|
+
"histo_scores_top_features": {
|
|
652
|
+
str(layer): {
|
|
653
|
+
str(f): histo_scores[layer].get(f, 0.0) for f in top_features[layer]
|
|
654
|
+
}
|
|
655
|
+
for layer in layers
|
|
656
|
+
},
|
|
657
|
+
"contrast_stats": {
|
|
658
|
+
str(layer): contrast_stats_per_layer.get(layer, []) for layer in layers
|
|
659
|
+
},
|
|
660
|
+
},
|
|
661
|
+
metadata={
|
|
662
|
+
"sae_repo_id": self.sae_repo_id,
|
|
663
|
+
"sae_site": self.sae_site,
|
|
664
|
+
"sae_width": self.sae_width,
|
|
665
|
+
"sae_l0": self.sae_l0,
|
|
666
|
+
"target_layers": layers,
|
|
667
|
+
"histo_vocab": self.histo_vocab,
|
|
668
|
+
"top_k_features": self.top_k_features,
|
|
669
|
+
"few_shot_contrast": self.few_shot_contrast,
|
|
670
|
+
"num_samples": self.num_samples,
|
|
671
|
+
"seed": self.seed,
|
|
672
|
+
},
|
|
673
|
+
)
|