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