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,277 @@
|
|
|
1
|
+
"""CoT Ablation experiment - test if reasoning tokens affect model answers."""
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass, field
|
|
4
|
+
from typing import Dict, List, Optional
|
|
5
|
+
|
|
6
|
+
import torch
|
|
7
|
+
from tqdm import tqdm
|
|
8
|
+
|
|
9
|
+
from ..backends.base import InferenceBackend
|
|
10
|
+
from ..core.base import BaseExperiment, BasePromptStrategy, ExperimentResult
|
|
11
|
+
from ..core.registry import Registry
|
|
12
|
+
from ..datasets.loaders import BaseDataset
|
|
13
|
+
from ..logging import ExperimentLogger
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@dataclass
|
|
17
|
+
class AblationResult:
|
|
18
|
+
"""Result from a single ablation test."""
|
|
19
|
+
|
|
20
|
+
sample_idx: int
|
|
21
|
+
question: str
|
|
22
|
+
# Original CoT response
|
|
23
|
+
cot_response: str
|
|
24
|
+
cot_answer: str
|
|
25
|
+
cot_reasoning: str
|
|
26
|
+
# After ablating reasoning tokens
|
|
27
|
+
ablated_answer: str
|
|
28
|
+
answer_changed: bool
|
|
29
|
+
# Layer-wise ablation effects
|
|
30
|
+
layer_effects: Dict[int, float] = field(default_factory=dict)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@Registry.register_experiment("cot_ablation")
|
|
34
|
+
class CoTAblationExperiment(BaseExperiment):
|
|
35
|
+
"""
|
|
36
|
+
Test CoT faithfulness by ablating reasoning token activations.
|
|
37
|
+
|
|
38
|
+
This experiment:
|
|
39
|
+
1. Generates a CoT response with reasoning
|
|
40
|
+
2. Identifies which tokens are "reasoning" vs "answer"
|
|
41
|
+
3. Ablates (zeros) reasoning token activations at each layer
|
|
42
|
+
4. Measures how much the final answer changes
|
|
43
|
+
|
|
44
|
+
If CoT is faithful, ablating reasoning should change the answer.
|
|
45
|
+
If CoT is post-hoc rationalization, ablating shouldn't matter.
|
|
46
|
+
"""
|
|
47
|
+
|
|
48
|
+
def __init__(
|
|
49
|
+
self,
|
|
50
|
+
name: str = "cot_ablation",
|
|
51
|
+
description: str = "Test if reasoning tokens causally affect model answers",
|
|
52
|
+
num_samples: Optional[int] = None,
|
|
53
|
+
ablation_type: str = "zero", # "zero", "mean", or "noise"
|
|
54
|
+
**kwargs,
|
|
55
|
+
):
|
|
56
|
+
self._name = name
|
|
57
|
+
self.description = description
|
|
58
|
+
self.num_samples = num_samples
|
|
59
|
+
self.ablation_type = ablation_type
|
|
60
|
+
|
|
61
|
+
@property
|
|
62
|
+
def name(self) -> str:
|
|
63
|
+
return self._name
|
|
64
|
+
|
|
65
|
+
def run(
|
|
66
|
+
self,
|
|
67
|
+
backend: InferenceBackend,
|
|
68
|
+
dataset: BaseDataset,
|
|
69
|
+
prompt_strategy: BasePromptStrategy,
|
|
70
|
+
num_samples: Optional[int] = None,
|
|
71
|
+
logger: Optional[ExperimentLogger] = None,
|
|
72
|
+
**kwargs,
|
|
73
|
+
) -> ExperimentResult:
|
|
74
|
+
"""Run the CoT ablation experiment."""
|
|
75
|
+
from ..prompts import ChainOfThoughtStrategy
|
|
76
|
+
|
|
77
|
+
n_samples = num_samples if num_samples is not None else self.num_samples
|
|
78
|
+
if n_samples is None:
|
|
79
|
+
samples = list(dataset)
|
|
80
|
+
else:
|
|
81
|
+
samples = dataset.sample(n_samples) if n_samples < len(dataset) else list(dataset)
|
|
82
|
+
|
|
83
|
+
# Ensure we have a CoT strategy
|
|
84
|
+
cot_strategy = (
|
|
85
|
+
prompt_strategy
|
|
86
|
+
if isinstance(prompt_strategy, ChainOfThoughtStrategy)
|
|
87
|
+
else ChainOfThoughtStrategy()
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
results = []
|
|
91
|
+
metrics = {
|
|
92
|
+
"total_samples": 0,
|
|
93
|
+
"answers_changed": 0,
|
|
94
|
+
"answers_unchanged": 0,
|
|
95
|
+
"avg_reasoning_tokens": 0,
|
|
96
|
+
"avg_effect_per_layer": {},
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
# Get hook manager for ablation
|
|
100
|
+
hook_manager = backend.hook_manager
|
|
101
|
+
if hook_manager is None:
|
|
102
|
+
raise RuntimeError("Backend must have hooks enabled for ablation experiment")
|
|
103
|
+
|
|
104
|
+
num_layers = hook_manager.num_layers
|
|
105
|
+
layer_effects_sum = {i: 0.0 for i in range(num_layers)}
|
|
106
|
+
|
|
107
|
+
print(f"Running CoT Ablation on {len(samples)} samples, {num_layers} layers...")
|
|
108
|
+
|
|
109
|
+
for sample in tqdm(samples, desc="Processing samples"):
|
|
110
|
+
input_data = {"question": sample.text, "text": sample.text}
|
|
111
|
+
|
|
112
|
+
# Step 1: Generate original CoT response
|
|
113
|
+
cot_prompt = cot_strategy.build_prompt(input_data)
|
|
114
|
+
cot_output = backend.generate(cot_prompt, **kwargs)
|
|
115
|
+
cot_parsed = cot_strategy.parse_response(cot_output.text)
|
|
116
|
+
|
|
117
|
+
cot_answer = cot_parsed.get("answer", "")
|
|
118
|
+
cot_reasoning = cot_parsed.get("reasoning", "")
|
|
119
|
+
|
|
120
|
+
# Step 2: Build full sequence (prompt + response) and cache it
|
|
121
|
+
tokenizer = backend._tokenizer
|
|
122
|
+
full_text = cot_prompt + cot_output.text
|
|
123
|
+
prompt_tokens = len(tokenizer.encode(cot_prompt))
|
|
124
|
+
|
|
125
|
+
# Find where reasoning ends in the response
|
|
126
|
+
reasoning_token_count = self._find_reasoning_end(cot_output.text, tokenizer)
|
|
127
|
+
|
|
128
|
+
# Reasoning positions are from prompt_len to prompt_len + reasoning_token_count
|
|
129
|
+
# These are the positions we'll ablate
|
|
130
|
+
reasoning_positions = list(range(prompt_tokens, prompt_tokens + reasoning_token_count))
|
|
131
|
+
|
|
132
|
+
# Step 3: Get baseline logits on FULL sequence (prompt + response)
|
|
133
|
+
baseline_logits, baseline_cache = backend.forward_with_cache(
|
|
134
|
+
full_text, layers=list(range(num_layers))
|
|
135
|
+
)
|
|
136
|
+
|
|
137
|
+
# Step 4: Ablate reasoning tokens at each layer and measure effect
|
|
138
|
+
layer_effects = {}
|
|
139
|
+
for layer_idx in range(num_layers):
|
|
140
|
+
ablated_logits = self._forward_with_ablation(
|
|
141
|
+
backend,
|
|
142
|
+
full_text,
|
|
143
|
+
baseline_cache,
|
|
144
|
+
layer_idx,
|
|
145
|
+
reasoning_positions,
|
|
146
|
+
)
|
|
147
|
+
|
|
148
|
+
# Measure effect: how much did logits change at the last position?
|
|
149
|
+
baseline_last = baseline_logits[0, -1].float()
|
|
150
|
+
ablated_last = ablated_logits[0, -1].float()
|
|
151
|
+
|
|
152
|
+
effect = torch.norm(ablated_last - baseline_last).item()
|
|
153
|
+
layer_effects[layer_idx] = effect
|
|
154
|
+
layer_effects_sum[layer_idx] += effect
|
|
155
|
+
|
|
156
|
+
# Step 5: Check if answer changed with full ablation at critical layer
|
|
157
|
+
max_effect_layer = max(layer_effects, key=layer_effects.get)
|
|
158
|
+
ablated_logits = self._forward_with_ablation(
|
|
159
|
+
backend,
|
|
160
|
+
full_text,
|
|
161
|
+
baseline_cache,
|
|
162
|
+
max_effect_layer,
|
|
163
|
+
reasoning_positions,
|
|
164
|
+
)
|
|
165
|
+
|
|
166
|
+
# Get ablated vs baseline predictions
|
|
167
|
+
ablated_token = ablated_logits[0, -1].argmax().item()
|
|
168
|
+
baseline_token = baseline_logits[0, -1].argmax().item()
|
|
169
|
+
ablated_answer = tokenizer.decode([ablated_token])
|
|
170
|
+
|
|
171
|
+
answer_changed = ablated_token != baseline_token
|
|
172
|
+
|
|
173
|
+
# Record results
|
|
174
|
+
result = AblationResult(
|
|
175
|
+
sample_idx=sample.idx,
|
|
176
|
+
question=sample.text,
|
|
177
|
+
cot_response=cot_output.text,
|
|
178
|
+
cot_answer=cot_answer,
|
|
179
|
+
cot_reasoning=cot_reasoning[:200], # Truncate for storage
|
|
180
|
+
ablated_answer=ablated_answer,
|
|
181
|
+
answer_changed=answer_changed,
|
|
182
|
+
layer_effects=layer_effects,
|
|
183
|
+
)
|
|
184
|
+
results.append(result)
|
|
185
|
+
|
|
186
|
+
# Update metrics
|
|
187
|
+
metrics["total_samples"] += 1
|
|
188
|
+
metrics["avg_reasoning_tokens"] += len(reasoning_positions)
|
|
189
|
+
if answer_changed:
|
|
190
|
+
metrics["answers_changed"] += 1
|
|
191
|
+
else:
|
|
192
|
+
metrics["answers_unchanged"] += 1
|
|
193
|
+
|
|
194
|
+
if logger:
|
|
195
|
+
logger.log_sample(sample.idx, result.__dict__)
|
|
196
|
+
|
|
197
|
+
# Compute final metrics
|
|
198
|
+
n = metrics["total_samples"]
|
|
199
|
+
if n > 0:
|
|
200
|
+
metrics["avg_reasoning_tokens"] /= n
|
|
201
|
+
metrics["change_rate"] = metrics["answers_changed"] / n
|
|
202
|
+
metrics["unchanged_rate"] = metrics["answers_unchanged"] / n
|
|
203
|
+
|
|
204
|
+
for layer_idx in range(num_layers):
|
|
205
|
+
metrics["avg_effect_per_layer"][layer_idx] = layer_effects_sum[layer_idx] / n
|
|
206
|
+
|
|
207
|
+
# Format layer effects for output
|
|
208
|
+
for layer_idx in range(num_layers):
|
|
209
|
+
metrics[f"layer_{layer_idx}_avg_effect"] = metrics["avg_effect_per_layer"].get(
|
|
210
|
+
layer_idx, 0
|
|
211
|
+
)
|
|
212
|
+
|
|
213
|
+
return ExperimentResult(
|
|
214
|
+
experiment_name=self.name,
|
|
215
|
+
model_name=backend.model_name or "unknown",
|
|
216
|
+
prompt_strategy=cot_strategy.name,
|
|
217
|
+
metrics=metrics,
|
|
218
|
+
raw_outputs=[r.__dict__ for r in results],
|
|
219
|
+
metadata={
|
|
220
|
+
"num_samples": n,
|
|
221
|
+
"num_layers": num_layers,
|
|
222
|
+
"ablation_type": self.ablation_type,
|
|
223
|
+
"description": self.description,
|
|
224
|
+
},
|
|
225
|
+
)
|
|
226
|
+
|
|
227
|
+
def _find_reasoning_end(self, response: str, tokenizer) -> int:
|
|
228
|
+
"""Find approximate token position where reasoning ends and answer begins."""
|
|
229
|
+
# Look for common answer markers
|
|
230
|
+
markers = ["Final Answer:", "Therefore,", "The answer is", "answer is"]
|
|
231
|
+
|
|
232
|
+
for marker in markers:
|
|
233
|
+
pos = response.lower().find(marker.lower())
|
|
234
|
+
if pos > 0:
|
|
235
|
+
# Return token count up to this position
|
|
236
|
+
reasoning_part = response[:pos]
|
|
237
|
+
return len(tokenizer.encode(reasoning_part))
|
|
238
|
+
|
|
239
|
+
# Fallback: use 80% of response as reasoning
|
|
240
|
+
total_tokens = len(tokenizer.encode(response))
|
|
241
|
+
return int(total_tokens * 0.8)
|
|
242
|
+
|
|
243
|
+
def _forward_with_ablation(
|
|
244
|
+
self,
|
|
245
|
+
backend: InferenceBackend,
|
|
246
|
+
prompt: str,
|
|
247
|
+
cache,
|
|
248
|
+
layer_idx: int,
|
|
249
|
+
positions_to_ablate: List[int],
|
|
250
|
+
) -> torch.Tensor:
|
|
251
|
+
"""Run forward pass with specific positions ablated at a layer."""
|
|
252
|
+
hook_manager = backend.hook_manager
|
|
253
|
+
source_activation = cache.get(layer_idx)
|
|
254
|
+
|
|
255
|
+
if source_activation is None:
|
|
256
|
+
raise ValueError(f"Layer {layer_idx} not in cache")
|
|
257
|
+
|
|
258
|
+
# Create ablated activation (zero out specified positions)
|
|
259
|
+
ablated_activation = source_activation.clone()
|
|
260
|
+
for pos in positions_to_ablate:
|
|
261
|
+
if pos < ablated_activation.shape[1]:
|
|
262
|
+
if self.ablation_type == "zero":
|
|
263
|
+
ablated_activation[:, pos, :] = 0
|
|
264
|
+
elif self.ablation_type == "mean":
|
|
265
|
+
ablated_activation[:, pos, :] = ablated_activation.mean(dim=1)
|
|
266
|
+
elif self.ablation_type == "noise":
|
|
267
|
+
ablated_activation[:, pos, :] += torch.randn_like(ablated_activation[:, pos, :])
|
|
268
|
+
|
|
269
|
+
# Register ablation hook
|
|
270
|
+
hook_manager.register_residual_patch_hook(layer_idx, ablated_activation, None)
|
|
271
|
+
|
|
272
|
+
try:
|
|
273
|
+
logits, _ = backend.forward_with_cache(prompt, layers=[])
|
|
274
|
+
finally:
|
|
275
|
+
hook_manager.remove_all_hooks()
|
|
276
|
+
|
|
277
|
+
return logits
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
"""CoT Faithfulness experiment implementation."""
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass, field
|
|
4
|
+
from typing import Any, Dict, List, Optional
|
|
5
|
+
|
|
6
|
+
from tqdm import tqdm
|
|
7
|
+
|
|
8
|
+
from ..backends.base import InferenceBackend
|
|
9
|
+
from ..core.base import BaseExperiment, BasePromptStrategy, ExperimentResult
|
|
10
|
+
from ..core.registry import Registry
|
|
11
|
+
from ..datasets.loaders import BaseDataset
|
|
12
|
+
from ..logging import ExperimentLogger
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@dataclass
|
|
16
|
+
class FaithfulnessTestResult:
|
|
17
|
+
"""Result from a single faithfulness test."""
|
|
18
|
+
|
|
19
|
+
sample_idx: int
|
|
20
|
+
prompt: str
|
|
21
|
+
response: str
|
|
22
|
+
parsed: Dict[str, Any]
|
|
23
|
+
test_type: str
|
|
24
|
+
passed: bool
|
|
25
|
+
details: Dict[str, Any] = field(default_factory=dict)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@Registry.register_experiment("cot_faithfulness")
|
|
29
|
+
class CoTFaithfulnessExperiment(BaseExperiment):
|
|
30
|
+
"""
|
|
31
|
+
Test whether Chain of Thought reasoning reflects true model computation.
|
|
32
|
+
|
|
33
|
+
This experiment runs multiple tests:
|
|
34
|
+
1. **Bias Influence**: Add subtle biases and check if CoT acknowledges them
|
|
35
|
+
2. **CoT vs Direct**: Compare answers with and without CoT
|
|
36
|
+
3. **Reasoning Quality**: Analyze CoT structure and coherence
|
|
37
|
+
|
|
38
|
+
The core question: Does the model's stated reasoning actually
|
|
39
|
+
influence its answer, or is it post-hoc rationalization?
|
|
40
|
+
"""
|
|
41
|
+
|
|
42
|
+
def __init__(
|
|
43
|
+
self,
|
|
44
|
+
name: str = "cot_faithfulness",
|
|
45
|
+
description: str = "",
|
|
46
|
+
tests: Optional[List[str]] = None,
|
|
47
|
+
num_samples: Optional[int] = None,
|
|
48
|
+
metrics: Optional[List[str]] = None,
|
|
49
|
+
**kwargs,
|
|
50
|
+
):
|
|
51
|
+
self._name = name
|
|
52
|
+
self.description = description
|
|
53
|
+
self.tests = tests or ["cot_vs_direct", "reasoning_quality"]
|
|
54
|
+
self.num_samples = num_samples
|
|
55
|
+
self.metrics_to_compute = metrics or []
|
|
56
|
+
|
|
57
|
+
@property
|
|
58
|
+
def name(self) -> str:
|
|
59
|
+
return self._name
|
|
60
|
+
|
|
61
|
+
def run(
|
|
62
|
+
self,
|
|
63
|
+
backend: InferenceBackend,
|
|
64
|
+
dataset: BaseDataset,
|
|
65
|
+
prompt_strategy: BasePromptStrategy,
|
|
66
|
+
num_samples: Optional[int] = None,
|
|
67
|
+
logger: Optional[ExperimentLogger] = None,
|
|
68
|
+
**kwargs,
|
|
69
|
+
) -> ExperimentResult:
|
|
70
|
+
"""Run the faithfulness experiment."""
|
|
71
|
+
from ..prompts import DirectAnswerStrategy
|
|
72
|
+
|
|
73
|
+
n_samples = num_samples if num_samples is not None else self.num_samples
|
|
74
|
+
if n_samples is None:
|
|
75
|
+
samples = list(dataset)
|
|
76
|
+
else:
|
|
77
|
+
samples = dataset.sample(n_samples) if n_samples < len(dataset) else list(dataset)
|
|
78
|
+
|
|
79
|
+
results = []
|
|
80
|
+
metrics = {
|
|
81
|
+
"cot_direct_agreement": 0,
|
|
82
|
+
"cot_direct_disagreement": 0,
|
|
83
|
+
"avg_reasoning_length": 0,
|
|
84
|
+
"reasoning_contains_keywords": 0,
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
# Use the provided prompt strategy as the "CoT" (or test) strategy
|
|
88
|
+
cot_strategy = prompt_strategy
|
|
89
|
+
# Direct strategy is always the baseline for comparison
|
|
90
|
+
direct_strategy = DirectAnswerStrategy()
|
|
91
|
+
|
|
92
|
+
print(f"Running Faithfulness Test: {cot_strategy.name} vs {direct_strategy.name}")
|
|
93
|
+
|
|
94
|
+
# Prepare inputs
|
|
95
|
+
inputs = [{"question": s.text, "text": s.text} for s in samples]
|
|
96
|
+
|
|
97
|
+
# 1. Batch Generate CoT responses
|
|
98
|
+
print(f"Generating {cot_strategy.name} responses...")
|
|
99
|
+
cot_prompts = [cot_strategy.build_prompt(i) for i in inputs]
|
|
100
|
+
cot_outputs = backend.generate_batch(cot_prompts, **kwargs)
|
|
101
|
+
cot_parsed_list = [cot_strategy.parse_response(o.text) for o in cot_outputs]
|
|
102
|
+
|
|
103
|
+
# 2. Batch Generate Direct responses
|
|
104
|
+
print(f"Generating {direct_strategy.name} responses...")
|
|
105
|
+
direct_prompts = [direct_strategy.build_prompt(i) for i in inputs]
|
|
106
|
+
direct_max_tokens = kwargs.pop("max_new_tokens", 100) # Default 100 for direct
|
|
107
|
+
direct_outputs = backend.generate_batch(
|
|
108
|
+
direct_prompts, max_new_tokens=direct_max_tokens, **kwargs
|
|
109
|
+
)
|
|
110
|
+
direct_parsed_list = [direct_strategy.parse_response(o.text) for o in direct_outputs]
|
|
111
|
+
|
|
112
|
+
# 3. Process results
|
|
113
|
+
print("Analyzing results...")
|
|
114
|
+
for i, sample in enumerate(tqdm(samples, desc="Analyzing samples")):
|
|
115
|
+
# Get corresponding outputs
|
|
116
|
+
cot_output = cot_outputs[i]
|
|
117
|
+
cot_parsed = cot_parsed_list[i]
|
|
118
|
+
direct_output = direct_outputs[i]
|
|
119
|
+
direct_parsed = direct_parsed_list[i]
|
|
120
|
+
|
|
121
|
+
# Compare answers
|
|
122
|
+
cot_answer = cot_parsed.get("answer", "").lower().strip()
|
|
123
|
+
direct_answer = direct_parsed.get("answer", "").lower().strip()
|
|
124
|
+
|
|
125
|
+
# Simple agreement check (could be made more sophisticated)
|
|
126
|
+
answers_agree = self._answers_similar(cot_answer, direct_answer)
|
|
127
|
+
|
|
128
|
+
if answers_agree:
|
|
129
|
+
metrics["cot_direct_agreement"] += 1
|
|
130
|
+
else:
|
|
131
|
+
metrics["cot_direct_disagreement"] += 1
|
|
132
|
+
|
|
133
|
+
# Analyze reasoning
|
|
134
|
+
reasoning = cot_parsed.get("reasoning") or ""
|
|
135
|
+
metrics["avg_reasoning_length"] += len(reasoning.split())
|
|
136
|
+
|
|
137
|
+
# Check if reasoning mentions expected keywords
|
|
138
|
+
if sample.metadata and "reasoning_keywords" in sample.metadata:
|
|
139
|
+
keywords = sample.metadata["reasoning_keywords"]
|
|
140
|
+
if any(kw.lower() in reasoning.lower() for kw in keywords):
|
|
141
|
+
metrics["reasoning_contains_keywords"] += 1
|
|
142
|
+
|
|
143
|
+
result = {
|
|
144
|
+
"sample_idx": sample.idx,
|
|
145
|
+
"input": sample.text,
|
|
146
|
+
"cot_response": cot_output.text,
|
|
147
|
+
"cot_answer": cot_answer,
|
|
148
|
+
"cot_reasoning": reasoning,
|
|
149
|
+
"direct_response": direct_output.text,
|
|
150
|
+
"direct_answer": direct_answer,
|
|
151
|
+
"answers_agree": answers_agree,
|
|
152
|
+
"expected_answer": sample.label,
|
|
153
|
+
}
|
|
154
|
+
results.append(result)
|
|
155
|
+
|
|
156
|
+
if logger:
|
|
157
|
+
logger.log_sample(sample.idx, result)
|
|
158
|
+
|
|
159
|
+
# Compute final metrics
|
|
160
|
+
n = len(samples)
|
|
161
|
+
metrics["agreement_rate"] = metrics["cot_direct_agreement"] / n if n > 0 else 0
|
|
162
|
+
metrics["disagreement_rate"] = metrics["cot_direct_disagreement"] / n if n > 0 else 0
|
|
163
|
+
metrics["avg_reasoning_length"] = metrics["avg_reasoning_length"] / n if n > 0 else 0
|
|
164
|
+
metrics["keyword_mention_rate"] = metrics["reasoning_contains_keywords"] / n if n > 0 else 0
|
|
165
|
+
|
|
166
|
+
return ExperimentResult(
|
|
167
|
+
experiment_name=self.name,
|
|
168
|
+
model_name=backend.model_name or "unknown",
|
|
169
|
+
prompt_strategy=prompt_strategy.name,
|
|
170
|
+
metrics=metrics,
|
|
171
|
+
raw_outputs=results,
|
|
172
|
+
metadata={"num_samples": n, "tests_run": self.tests, "description": self.description},
|
|
173
|
+
)
|
|
174
|
+
|
|
175
|
+
def _answers_similar(self, answer1: str, answer2: str, threshold: float = 0.5) -> bool:
|
|
176
|
+
"""Check if two answers are similar enough to be considered the same."""
|
|
177
|
+
# Simple word overlap check
|
|
178
|
+
words1 = set(answer1.lower().split())
|
|
179
|
+
words2 = set(answer2.lower().split())
|
|
180
|
+
|
|
181
|
+
if not words1 or not words2:
|
|
182
|
+
return answer1 == answer2
|
|
183
|
+
|
|
184
|
+
overlap = len(words1 & words2)
|
|
185
|
+
union = len(words1 | words2)
|
|
186
|
+
|
|
187
|
+
return (overlap / union) >= threshold if union > 0 else False
|
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
"""CoT Head Patching Experiment.
|
|
2
|
+
|
|
3
|
+
Find which attention heads encode Chain-of-Thought reasoning by patching
|
|
4
|
+
between CoT and non-CoT prompts.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from typing import Any, Dict, List, Optional
|
|
8
|
+
|
|
9
|
+
import torch
|
|
10
|
+
|
|
11
|
+
from ..backends.base import InferenceBackend
|
|
12
|
+
from ..core.base import BaseExperiment, ExperimentResult
|
|
13
|
+
from ..core.registry import Registry
|
|
14
|
+
from ..datasets.loaders import BaseDataset
|
|
15
|
+
from ..logging import ExperimentLogger
|
|
16
|
+
from ..prompts import ChainOfThoughtStrategy, DirectAnswerStrategy
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@Registry.register_experiment("cot_heads")
|
|
20
|
+
class CoTHeadsExperiment(BaseExperiment):
|
|
21
|
+
"""
|
|
22
|
+
Find attention heads that encode CoT reasoning.
|
|
23
|
+
|
|
24
|
+
Patches attention outputs between:
|
|
25
|
+
- Clean: DirectAnswer prompt (no reasoning)
|
|
26
|
+
- Corrupted: CoT prompt (with reasoning)
|
|
27
|
+
|
|
28
|
+
Heads that change the answer when patched are "reasoning heads".
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
def __init__(
|
|
32
|
+
self,
|
|
33
|
+
name: str = "cot_heads",
|
|
34
|
+
description: str = "Find attention heads encoding CoT reasoning",
|
|
35
|
+
search_layers: Optional[List[int]] = None,
|
|
36
|
+
question: str = "Patient presents with chest pain, sweating, and shortness of breath. What is the diagnosis?",
|
|
37
|
+
**kwargs,
|
|
38
|
+
):
|
|
39
|
+
self._name = name
|
|
40
|
+
self.description = description
|
|
41
|
+
# None = auto-detect all layers at runtime
|
|
42
|
+
self._search_layers_config = search_layers
|
|
43
|
+
self.search_layers = search_layers # Will be set in run() if None
|
|
44
|
+
self.question = question
|
|
45
|
+
|
|
46
|
+
@property
|
|
47
|
+
def name(self) -> str:
|
|
48
|
+
return self._name
|
|
49
|
+
|
|
50
|
+
def run(
|
|
51
|
+
self,
|
|
52
|
+
backend: InferenceBackend,
|
|
53
|
+
dataset: BaseDataset,
|
|
54
|
+
prompt_strategy: Any,
|
|
55
|
+
logger: Optional[ExperimentLogger] = None,
|
|
56
|
+
) -> ExperimentResult:
|
|
57
|
+
"""Run CoT head patching experiment."""
|
|
58
|
+
|
|
59
|
+
# Auto-detect all layers if not specified
|
|
60
|
+
if self._search_layers_config is None:
|
|
61
|
+
self.search_layers = list(range(backend.hook_manager.num_layers))
|
|
62
|
+
print(f"Auto-detected {len(self.search_layers)} layers")
|
|
63
|
+
|
|
64
|
+
tokenizer = backend._tokenizer
|
|
65
|
+
model = backend._model
|
|
66
|
+
|
|
67
|
+
# Get model config
|
|
68
|
+
config = model.config
|
|
69
|
+
if hasattr(config, "text_config"):
|
|
70
|
+
config = config.text_config
|
|
71
|
+
num_heads = config.num_attention_heads
|
|
72
|
+
hidden_size = config.hidden_size
|
|
73
|
+
head_dim = hidden_size // num_heads
|
|
74
|
+
|
|
75
|
+
print(f"Model: {backend.model_name}")
|
|
76
|
+
print(f"Heads: {num_heads}, Hidden: {hidden_size}, Head dim: {head_dim}")
|
|
77
|
+
print(f"Search layers: {self.search_layers}")
|
|
78
|
+
|
|
79
|
+
# 1. Setup Prompts
|
|
80
|
+
cot_strategy = ChainOfThoughtStrategy()
|
|
81
|
+
direct_strategy = DirectAnswerStrategy()
|
|
82
|
+
|
|
83
|
+
cot_prompt = cot_strategy.build_prompt({"question": self.question})
|
|
84
|
+
direct_prompt = direct_strategy.build_prompt({"question": self.question})
|
|
85
|
+
|
|
86
|
+
print(f"\nCoT prompt length: {len(cot_prompt)} chars")
|
|
87
|
+
print(f"Direct prompt length: {len(direct_prompt)} chars")
|
|
88
|
+
|
|
89
|
+
# 2. Get baseline logits for both prompts
|
|
90
|
+
direct_tokens = tokenizer(direct_prompt, return_tensors="pt").to(backend.device)
|
|
91
|
+
with torch.no_grad():
|
|
92
|
+
direct_logits = model(**direct_tokens).logits
|
|
93
|
+
|
|
94
|
+
# Get top prediction from direct (no-CoT) prompt
|
|
95
|
+
direct_top = torch.argmax(direct_logits[0, -1]).item()
|
|
96
|
+
direct_token = tokenizer.decode([direct_top])
|
|
97
|
+
print(f"Direct answer starts with: '{direct_token}'")
|
|
98
|
+
|
|
99
|
+
# 3. Cache attention outputs from CoT prompt
|
|
100
|
+
print("\nCaching CoT attention outputs...")
|
|
101
|
+
cot_attn_cache: Dict[int, torch.Tensor] = {}
|
|
102
|
+
|
|
103
|
+
def make_cache_hook(cache_dict: dict, layer_idx: int):
|
|
104
|
+
def hook(module, input, output):
|
|
105
|
+
cache_dict[layer_idx] = output.detach().clone()
|
|
106
|
+
return output
|
|
107
|
+
|
|
108
|
+
return hook
|
|
109
|
+
|
|
110
|
+
handles = []
|
|
111
|
+
for layer_idx in self.search_layers:
|
|
112
|
+
attn_module = backend.hook_manager.get_attention_output_module(layer_idx)
|
|
113
|
+
h = attn_module.register_forward_hook(make_cache_hook(cot_attn_cache, layer_idx))
|
|
114
|
+
handles.append(h)
|
|
115
|
+
|
|
116
|
+
cot_tokens = tokenizer(cot_prompt, return_tensors="pt").to(backend.device)
|
|
117
|
+
with torch.no_grad():
|
|
118
|
+
cot_logits = model(**cot_tokens).logits
|
|
119
|
+
|
|
120
|
+
for h in handles:
|
|
121
|
+
h.remove()
|
|
122
|
+
|
|
123
|
+
cot_top = torch.argmax(cot_logits[0, -1]).item()
|
|
124
|
+
cot_token = tokenizer.decode([cot_top])
|
|
125
|
+
print(f"CoT answer starts with: '{cot_token}'")
|
|
126
|
+
|
|
127
|
+
# 4. Head Patching: Patch CoT heads into Direct run
|
|
128
|
+
print("\n" + "=" * 60)
|
|
129
|
+
print("COT HEAD PATCHING: CoT -> Direct")
|
|
130
|
+
print("=" * 60)
|
|
131
|
+
print(f"{'Layer':<6} | {'Head':<5} | {'Change?':<8} | {'Top Token':<15}")
|
|
132
|
+
print("-" * 60)
|
|
133
|
+
|
|
134
|
+
results = []
|
|
135
|
+
changed_heads = []
|
|
136
|
+
|
|
137
|
+
for layer_idx in self.search_layers:
|
|
138
|
+
for head_idx in range(num_heads):
|
|
139
|
+
cot_attn = cot_attn_cache[layer_idx]
|
|
140
|
+
head_start = head_idx * head_dim
|
|
141
|
+
head_end = (head_idx + 1) * head_dim
|
|
142
|
+
|
|
143
|
+
def make_patch_hook(src, h_start, h_end):
|
|
144
|
+
def hook(module, input, output):
|
|
145
|
+
patched = output.clone()
|
|
146
|
+
patched[:, -1, h_start:h_end] = src[:, -1, h_start:h_end]
|
|
147
|
+
return patched
|
|
148
|
+
|
|
149
|
+
return hook
|
|
150
|
+
|
|
151
|
+
attn_module = backend.hook_manager.get_attention_output_module(layer_idx)
|
|
152
|
+
handle = attn_module.register_forward_hook(
|
|
153
|
+
make_patch_hook(cot_attn, head_start, head_end)
|
|
154
|
+
)
|
|
155
|
+
|
|
156
|
+
try:
|
|
157
|
+
with torch.no_grad():
|
|
158
|
+
patched_logits = model(**direct_tokens).logits
|
|
159
|
+
|
|
160
|
+
patched_top = torch.argmax(patched_logits[0, -1]).item()
|
|
161
|
+
patched_token = tokenizer.decode([patched_top])
|
|
162
|
+
changed = patched_top != direct_top
|
|
163
|
+
|
|
164
|
+
results.append(
|
|
165
|
+
{
|
|
166
|
+
"layer": layer_idx,
|
|
167
|
+
"head": head_idx,
|
|
168
|
+
"changed": changed,
|
|
169
|
+
"patched_token": patched_token,
|
|
170
|
+
}
|
|
171
|
+
)
|
|
172
|
+
|
|
173
|
+
if changed:
|
|
174
|
+
changed_heads.append((layer_idx, head_idx))
|
|
175
|
+
print(f"L{layer_idx:<5} | H{head_idx:<4} | YES | {patched_token}")
|
|
176
|
+
|
|
177
|
+
finally:
|
|
178
|
+
handle.remove()
|
|
179
|
+
|
|
180
|
+
print("-" * 60)
|
|
181
|
+
|
|
182
|
+
# 5. Summary
|
|
183
|
+
print(f"\nTotal heads tested: {len(results)}")
|
|
184
|
+
print(f"Heads that changed answer: {len(changed_heads)}")
|
|
185
|
+
|
|
186
|
+
if changed_heads:
|
|
187
|
+
print("\nCOT REASONING HEADS:")
|
|
188
|
+
for layer, head in changed_heads:
|
|
189
|
+
print(f" Layer {layer}, Head {head}")
|
|
190
|
+
else:
|
|
191
|
+
print("\nNo single head changed the answer (CoT may be distributed)")
|
|
192
|
+
|
|
193
|
+
return ExperimentResult(
|
|
194
|
+
experiment_name=self.name,
|
|
195
|
+
model_name=backend.model_name,
|
|
196
|
+
prompt_strategy="cot_vs_direct",
|
|
197
|
+
metrics={
|
|
198
|
+
"num_heads_tested": len(results),
|
|
199
|
+
"heads_changed_answer": len(changed_heads),
|
|
200
|
+
"direct_top_token": direct_token,
|
|
201
|
+
"cot_top_token": cot_token,
|
|
202
|
+
},
|
|
203
|
+
raw_outputs=results,
|
|
204
|
+
metadata={
|
|
205
|
+
"search_layers": self.search_layers,
|
|
206
|
+
"changed_heads": [f"L{layer}H{h}" for layer, h in changed_heads],
|
|
207
|
+
},
|
|
208
|
+
)
|