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
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
|
+
]
|
cotlab/patching/cache.py
ADDED
|
@@ -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)
|