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
cotlab/main.py ADDED
@@ -0,0 +1,317 @@
1
+ """Main entry point for the CoT research framework."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import inspect
6
+ import os
7
+ import random
8
+ import re
9
+ import sys
10
+ import traceback
11
+ from contextlib import contextmanager
12
+ from pathlib import Path
13
+ from typing import Iterator, TextIO
14
+
15
+ import hydra
16
+ import numpy as np
17
+ import torch
18
+ from omegaconf import DictConfig, OmegaConf
19
+
20
+ from .core import create_component
21
+ from .experiment import ExperimentDocumenter
22
+ from .logging import ExperimentLogger
23
+
24
+
25
+ class _TeeStream:
26
+ """Mirror writes to multiple text streams."""
27
+
28
+ def __init__(self, *streams: TextIO):
29
+ self._streams = streams
30
+ self._primary = streams[0]
31
+
32
+ def write(self, data: str) -> int:
33
+ for stream in self._streams:
34
+ stream.write(data)
35
+ return len(data)
36
+
37
+ def flush(self) -> None:
38
+ for stream in self._streams:
39
+ stream.flush()
40
+
41
+ def isatty(self) -> bool:
42
+ isatty = getattr(self._primary, "isatty", None)
43
+ return bool(isatty()) if callable(isatty) else False
44
+
45
+ @property
46
+ def encoding(self) -> str:
47
+ return getattr(self._primary, "encoding", "utf-8")
48
+
49
+ def __getattr__(self, name: str):
50
+ # Delegate other stream attributes (e.g. fileno, buffer).
51
+ return getattr(self._primary, name)
52
+
53
+
54
+ @contextmanager
55
+ def _tee_stdout_stderr(log_path: Path) -> Iterator[None]:
56
+ """Tee stdout/stderr to the given log file."""
57
+ log_path.parent.mkdir(parents=True, exist_ok=True)
58
+
59
+ original_stdout = sys.stdout
60
+ original_stderr = sys.stderr
61
+ log_file = open(log_path, "a", buffering=1, encoding="utf-8")
62
+
63
+ try:
64
+ sys.stdout = _TeeStream(original_stdout, log_file)
65
+ sys.stderr = _TeeStream(original_stderr, log_file)
66
+ yield
67
+ finally:
68
+ try:
69
+ sys.stdout.flush()
70
+ sys.stderr.flush()
71
+ finally:
72
+ sys.stdout = original_stdout
73
+ sys.stderr = original_stderr
74
+ log_file.close()
75
+
76
+
77
+ def _safe_model_name(model_value: str) -> str:
78
+ return re.sub(r"[^a-zA-Z0-9]+", "_", model_value).strip("_").lower()
79
+
80
+
81
+ def _repo_root() -> Path:
82
+ return Path(__file__).resolve().parents[2]
83
+
84
+
85
+ def _normalize_hf_model_id(model_value: str) -> str:
86
+ """Normalize a HF model id: preserve org/, convert underscores to hyphens in the repo name.
87
+
88
+ google/medgemma_4b_it -> google/medgemma-4b-it
89
+ google/medgemma-4b-it -> google/medgemma-4b-it (unchanged)
90
+ medgemma_4b_it -> medgemma_4b_it (no slash, leave as-is)
91
+ """
92
+ if "/" in model_value:
93
+ org, repo = model_value.split("/", 1)
94
+ return f"{org}/{repo.replace('_', '-')}"
95
+ return model_value
96
+
97
+
98
+ def _ensure_model_config_file(hf_id: str) -> None:
99
+ """Create conf/model/{org}/{repo}.yaml if it doesn't exist."""
100
+ hf_name = _normalize_hf_model_id(hf_id)
101
+ config_path = _repo_root() / "conf" / "model" / f"{hf_name}.yaml"
102
+ if config_path.exists():
103
+ return
104
+ config_path.parent.mkdir(parents=True, exist_ok=True)
105
+ content = "".join(
106
+ [
107
+ "# Auto-generated model config\n",
108
+ f"name: {hf_name}\n",
109
+ "max_new_tokens: 512\n",
110
+ "temperature: 0.7\n",
111
+ "top_p: 0.9\n",
112
+ ]
113
+ )
114
+ config_path.write_text(content)
115
+ print(f"[cotlab] Auto-generated model config: conf/model/{hf_name}.yaml")
116
+
117
+
118
+ def _rewrite_hf_model_override(argv: list[str]) -> list[str]:
119
+ """Support `model=org/repo` by auto-generating conf/model/{org}/{repo}.yaml if missing."""
120
+ rewritten: list[str] = []
121
+
122
+ for arg in argv:
123
+ if arg.startswith("model="):
124
+ model_value = arg.split("=", 1)[1]
125
+ if "/" in model_value:
126
+ hf_id = _normalize_hf_model_id(model_value)
127
+ _ensure_model_config_file(hf_id)
128
+ rewritten.append(f"model={hf_id}")
129
+ continue
130
+ rewritten.append(arg)
131
+
132
+ return rewritten
133
+
134
+
135
+ def _maybe_rewrite_argv() -> None:
136
+ """Rewrite argv in-place to support `model=<hf-id>` overrides and auto-config generation."""
137
+ if os.environ.get("COTLAB_DISABLE_HF_MODEL_REWRITE") == "1":
138
+ return
139
+ sys.argv[:] = _rewrite_hf_model_override(sys.argv)
140
+
141
+
142
+ def set_seed(seed: int) -> None:
143
+ """Set random seeds for reproducibility."""
144
+ random.seed(seed)
145
+ np.random.seed(seed)
146
+ torch.manual_seed(seed)
147
+ if torch.cuda.is_available():
148
+ torch.cuda.manual_seed_all(seed)
149
+
150
+
151
+ def _extract_backend_load_kwargs(cfg_backend: DictConfig) -> dict:
152
+ backend_cfg = OmegaConf.to_container(cfg_backend, resolve=True)
153
+ keys = [
154
+ "load_in_4bit",
155
+ "load_in_8bit",
156
+ "bnb_4bit_quant_type",
157
+ "bnb_4bit_compute_dtype",
158
+ "bnb_4bit_use_double_quant",
159
+ ]
160
+ return {key: backend_cfg.get(key) for key in keys if backend_cfg.get(key) is not None}
161
+
162
+
163
+ @hydra.main(version_base=None, config_path="../../conf", config_name="config")
164
+ def _hydra_main(cfg: DictConfig) -> None:
165
+ """
166
+ Hydra-decorated experiment runner. Do not call directly — use main().
167
+
168
+ Uses Hydra for configuration management. Override configs via CLI:
169
+ python -m cotlab.main model.name=google/gemma-3-1b-it
170
+ python -m cotlab.main -m prompt=chain_of_thought,direct_answer # multirun
171
+ """
172
+ # Get output directory from Hydra
173
+ output_dir = Path(hydra.core.hydra_config.HydraConfig.get().runtime.output_dir)
174
+ main_log_path = output_dir / "main.log"
175
+
176
+ with _tee_stdout_stderr(main_log_path):
177
+ try:
178
+ # Print config if verbose
179
+ if cfg.verbose:
180
+ print("=" * 60)
181
+ print("Configuration:")
182
+ print("=" * 60)
183
+ print(OmegaConf.to_yaml(cfg))
184
+ print("=" * 60)
185
+
186
+ # Set seed
187
+ set_seed(cfg.seed)
188
+
189
+ # Initialize logger
190
+ logger = ExperimentLogger(str(output_dir))
191
+ logger.log_config(cfg)
192
+
193
+ # Initialize experiment documenter and create initial EXPERIMENT.md
194
+ documenter = ExperimentDocumenter(cfg, output_dir)
195
+ doc_path = documenter.save()
196
+ print(f"Created experiment documentation: {doc_path}")
197
+
198
+ if cfg.dry_run:
199
+ print("Dry run - exiting without running experiment")
200
+ return
201
+
202
+ # Create components from config
203
+ print(f"Loading backend: {cfg.backend._target_}")
204
+ backend = create_component(cfg.backend)
205
+
206
+ print(f"Loading model: {cfg.model.name}")
207
+ backend.load_model(cfg.model.name, **_extract_backend_load_kwargs(cfg.backend))
208
+
209
+ print(f"Creating prompt strategy: {cfg.prompt.name}")
210
+ prompt_strategy = create_component(cfg.prompt)
211
+
212
+ print(f"Loading dataset: {cfg.dataset.name}")
213
+ dataset = create_component(cfg.dataset)
214
+
215
+ print(f"Creating experiment: {cfg.experiment.name}")
216
+ experiment = create_component(cfg.experiment)
217
+
218
+ # Run experiment
219
+ print("=" * 60)
220
+ print(f"Running experiment: {cfg.experiment.name}")
221
+ print("=" * 60)
222
+
223
+ try:
224
+ # Track experiment timing
225
+ import time
226
+
227
+ start_time = time.time()
228
+
229
+ # Generation kwargs (passed through to backend.generate_batch via experiments that accept **kwargs).
230
+ is_sampling = bool(cfg.model.temperature and cfg.model.temperature > 0)
231
+ gen_kwargs: dict = {
232
+ "max_new_tokens": cfg.model.max_new_tokens,
233
+ "temperature": cfg.model.temperature,
234
+ }
235
+ # top_p / top_k are only valid when sampling; suppress the HF warning otherwise.
236
+ if is_sampling:
237
+ gen_kwargs["top_p"] = cfg.model.top_p
238
+
239
+ # HF Transformers uses `do_sample`; vLLM's SamplingParams does not accept it.
240
+ # For vLLM, greedy decoding is achieved by setting temperature=0.
241
+ if str(cfg.backend._target_).endswith("TransformersBackend"):
242
+ gen_kwargs["do_sample"] = is_sampling
243
+
244
+ # num_samples is optional for some experiments; None means use all samples
245
+ num_samples = OmegaConf.select(cfg, "experiment.num_samples", default=None)
246
+ if isinstance(num_samples, int) and num_samples > 0:
247
+ dataset_size = len(dataset)
248
+ if num_samples > dataset_size:
249
+ dataset_name = getattr(dataset, "name", type(dataset).__name__)
250
+ print(
251
+ "Warning: experiment.num_samples="
252
+ f"{num_samples} exceeds dataset size ({dataset_size}) "
253
+ f"for '{dataset_name}'. Using {dataset_size} samples."
254
+ )
255
+ num_samples = dataset_size
256
+
257
+ # Only pass kwargs that the experiment.run signature can accept.
258
+ # Some analysis experiments do not accept generation args.
259
+ run_sig = inspect.signature(experiment.run)
260
+ run_params = run_sig.parameters
261
+ accepts_var_kwargs = any(
262
+ p.kind == inspect.Parameter.VAR_KEYWORD for p in run_params.values()
263
+ )
264
+
265
+ run_kwargs = {
266
+ "backend": backend,
267
+ "dataset": dataset,
268
+ "prompt_strategy": prompt_strategy,
269
+ "logger": logger,
270
+ }
271
+ extra_kwargs = dict(gen_kwargs)
272
+ extra_kwargs["num_samples"] = num_samples # None = all samples, int = cap
273
+
274
+ if accepts_var_kwargs:
275
+ run_kwargs.update(extra_kwargs)
276
+ else:
277
+ run_kwargs.update({k: v for k, v in extra_kwargs.items() if k in run_params})
278
+
279
+ result = experiment.run(**run_kwargs)
280
+
281
+ # Calculate duration
282
+ duration = time.time() - start_time
283
+
284
+ # Save results
285
+ results_path = logger.save_results(result)
286
+ print(f"\nResults saved to: {results_path}")
287
+
288
+ # Print metrics summary
289
+ print("\nMetrics:")
290
+ for name, value in result.metrics.items():
291
+ print(f" {name}: {value}")
292
+
293
+ # Update EXPERIMENT.md with results
294
+ results_dict = {**result.metrics, "total_samples": len(result.raw_outputs)}
295
+ updated_doc = documenter.update_with_results(results_dict, duration)
296
+ documenter.save(updated_doc)
297
+ print(f"\nExperiment documentation updated: {doc_path}")
298
+
299
+ finally:
300
+ # Cleanup
301
+ backend.unload()
302
+ print("\nExperiment complete.")
303
+
304
+ except Exception:
305
+ # Ensure traceback is captured in main.log before Hydra handles it.
306
+ traceback.print_exc()
307
+ raise
308
+
309
+
310
+ def main() -> None:
311
+ """Public entry point: rewrites argv for auto-config generation, then runs Hydra."""
312
+ _maybe_rewrite_argv()
313
+ _hydra_main()
314
+
315
+
316
+ if __name__ == "__main__":
317
+ main()
@@ -0,0 +1,24 @@
1
+ """Activation patching module for causal interventions."""
2
+
3
+ from .cache import ActivationCache
4
+ from .hooks import HookManager
5
+ from .interventions import (
6
+ Intervention,
7
+ InterventionType,
8
+ LayerImportance,
9
+ PatchingExperimentSpec,
10
+ ThoughtAnchor,
11
+ )
12
+ from .patcher import ActivationPatcher, PatchingResult
13
+
14
+ __all__ = [
15
+ "HookManager",
16
+ "ActivationCache",
17
+ "ActivationPatcher",
18
+ "PatchingResult",
19
+ "InterventionType",
20
+ "Intervention",
21
+ "PatchingExperimentSpec",
22
+ "LayerImportance",
23
+ "ThoughtAnchor",
24
+ ]
@@ -0,0 +1,141 @@
1
+ """Activation cache for storing and accessing layer outputs."""
2
+
3
+ from dataclasses import dataclass, field
4
+ from typing import Dict, Iterator, List, Optional, Tuple
5
+
6
+ import torch
7
+
8
+
9
+ @dataclass
10
+ class ActivationCache:
11
+ """
12
+ Store activations from model forward passes.
13
+
14
+ This provides a clean interface for:
15
+ - Storing activations by layer index
16
+ - Retrieving full or sliced activations
17
+ - Managing GPU memory
18
+
19
+ Example:
20
+ >>> cache = ActivationCache()
21
+ >>> cache.store(0, layer_0_activations)
22
+ >>> cache.store(5, layer_5_activations)
23
+ >>> act = cache.get(0) # Shape: [batch, seq_len, hidden_dim]
24
+ >>> sliced = cache.slice_tokens(0, (10, 20)) # Tokens 10-20
25
+ """
26
+
27
+ _cache: Dict[int, torch.Tensor] = field(default_factory=dict)
28
+ _metadata: Dict[str, any] = field(default_factory=dict)
29
+
30
+ def store(self, layer_idx: int, activation: torch.Tensor) -> None:
31
+ """
32
+ Store activation for a layer.
33
+
34
+ Args:
35
+ layer_idx: Layer index
36
+ activation: Activation tensor, typically [batch, seq_len, hidden_dim]
37
+ """
38
+ self._cache[layer_idx] = activation
39
+
40
+ def get(self, layer_idx: int) -> Optional[torch.Tensor]:
41
+ """
42
+ Retrieve activation for a layer.
43
+
44
+ Args:
45
+ layer_idx: Layer index
46
+
47
+ Returns:
48
+ Activation tensor or None if not cached
49
+ """
50
+ return self._cache.get(layer_idx)
51
+
52
+ def __getitem__(self, layer_idx: int) -> torch.Tensor:
53
+ """Allow dict-like access: cache[0]"""
54
+ if layer_idx not in self._cache:
55
+ raise KeyError(f"Layer {layer_idx} not in cache. Available: {self.layers}")
56
+ return self._cache[layer_idx]
57
+
58
+ def __contains__(self, layer_idx: int) -> bool:
59
+ """Check if layer is cached: 0 in cache"""
60
+ return layer_idx in self._cache
61
+
62
+ def __iter__(self) -> Iterator[int]:
63
+ """Iterate over cached layer indices."""
64
+ return iter(sorted(self._cache.keys()))
65
+
66
+ def __len__(self) -> int:
67
+ """Number of cached layers."""
68
+ return len(self._cache)
69
+
70
+ @property
71
+ def layers(self) -> List[int]:
72
+ """List of cached layer indices, sorted."""
73
+ return sorted(self._cache.keys())
74
+
75
+ @property
76
+ def shape(self) -> Optional[Tuple[int, ...]]:
77
+ """Shape of cached activations (assumed uniform)."""
78
+ if not self._cache:
79
+ return None
80
+ first = next(iter(self._cache.values()))
81
+ return tuple(first.shape)
82
+
83
+ def get_all(self) -> Dict[int, torch.Tensor]:
84
+ """Get all cached activations."""
85
+ return self._cache.copy()
86
+
87
+ def slice_tokens(self, layer_idx: int, token_range: Tuple[int, int]) -> torch.Tensor:
88
+ """
89
+ Get activations for specific token positions.
90
+
91
+ Args:
92
+ layer_idx: Layer index
93
+ token_range: (start, end) tuple, end is exclusive
94
+
95
+ Returns:
96
+ Sliced activation tensor
97
+ """
98
+ activation = self._cache[layer_idx]
99
+ start, end = token_range
100
+ return activation[:, start:end, :]
101
+
102
+ def slice_positions(self, layer_idx: int, positions: List[int]) -> torch.Tensor:
103
+ """
104
+ Get activations for specific non-contiguous positions.
105
+
106
+ Args:
107
+ layer_idx: Layer index
108
+ positions: List of token positions
109
+
110
+ Returns:
111
+ Activation tensor with shape [batch, len(positions), hidden_dim]
112
+ """
113
+ activation = self._cache[layer_idx]
114
+ return activation[:, positions, :]
115
+
116
+ def clear(self) -> None:
117
+ """Clear all cached activations to free memory."""
118
+ self._cache.clear()
119
+ torch.cuda.empty_cache()
120
+
121
+ def to_device(self, device: str) -> "ActivationCache":
122
+ """Move all cached activations to a device."""
123
+ new_cache = ActivationCache()
124
+ for layer_idx, activation in self._cache.items():
125
+ new_cache.store(layer_idx, activation.to(device))
126
+ return new_cache
127
+
128
+ def detach(self) -> "ActivationCache":
129
+ """Create a new cache with all activations detached."""
130
+ new_cache = ActivationCache()
131
+ for layer_idx, activation in self._cache.items():
132
+ new_cache.store(layer_idx, activation.detach().clone())
133
+ return new_cache
134
+
135
+ def set_metadata(self, key: str, value: any) -> None:
136
+ """Store metadata about the cache."""
137
+ self._metadata[key] = value
138
+
139
+ def get_metadata(self, key: str) -> any:
140
+ """Retrieve metadata."""
141
+ return self._metadata.get(key)