brainpatch 1.2.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.
- brainpatch/__init__.py +92 -0
- brainpatch/backends/__init__.py +19 -0
- brainpatch/backends/llamacpp.py +383 -0
- brainpatch/backends/mlx_backend.py +213 -0
- brainpatch/backends/transformers_backend.py +473 -0
- brainpatch/backends/vllm_backend.py +299 -0
- brainpatch/backends/vllm_worker.py +129 -0
- brainpatch/cli.py +825 -0
- brainpatch/config.py +245 -0
- brainpatch/datasets/__init__.py +20 -0
- brainpatch/datasets/contrast_sets.py +64 -0
- brainpatch/evaluation/__init__.py +28 -0
- brainpatch/evaluation/metrics.py +223 -0
- brainpatch/patch/__init__.py +64 -0
- brainpatch/patch/compiler.py +324 -0
- brainpatch/patch/format.py +489 -0
- brainpatch/patch/loader.py +312 -0
- brainpatch/patch/registry.py +300 -0
- brainpatch/patch/tensors.py +236 -0
- brainpatch/patch/validation.py +157 -0
- brainpatch/paths.py +184 -0
- brainpatch/py.typed +0 -0
- brainpatch/research/__init__.py +16 -0
- brainpatch/research/antisycophancy.py +348 -0
- brainpatch/research/behaviour_eval.py +711 -0
- brainpatch/research/generation_eval.py +346 -0
- brainpatch/research/ml/__init__.py +35 -0
- brainpatch/research/ml/activation_store.py +232 -0
- brainpatch/research/ml/causal.py +386 -0
- brainpatch/research/ml/corpus.py +165 -0
- brainpatch/research/ml/evaluation.py +188 -0
- brainpatch/research/ml/extraction.py +464 -0
- brainpatch/research/ml/feature_analysis.py +317 -0
- brainpatch/research/ml/generation.py +109 -0
- brainpatch/research/ml/hooks.py +183 -0
- brainpatch/research/ml/intervention.py +274 -0
- brainpatch/research/ml/model.py +219 -0
- brainpatch/research/ml/patch_search.py +337 -0
- brainpatch/research/ml/runtime.py +343 -0
- brainpatch/research/ml/sae.py +383 -0
- brainpatch/research/ml/training.py +376 -0
- brainpatch/research/stance_rubric.py +170 -0
- brainpatch/research/sycophancy_data.py +982 -0
- brainpatch/research/sycophancy_data_r1.py +1701 -0
- brainpatch/research/sycophancy_data_v2.py +1649 -0
- brainpatch/research/sycophancy_data_v3.py +2288 -0
- brainpatch/research/sycophancy_v2_build.py +362 -0
- brainpatch/research/sycophancy_v3_build.py +188 -0
- brainpatch/research/utility_probe.py +139 -0
- brainpatch/runtime/__init__.py +50 -0
- brainpatch/runtime/auto.py +157 -0
- brainpatch/runtime/base.py +311 -0
- brainpatch/runtime/capabilities.py +96 -0
- brainpatch/runtime/model.py +260 -0
- brainpatch/runtime/scheduling.py +13 -0
- brainpatch/schemas/__init__.py +35 -0
- brainpatch/schemas/contrast.py +161 -0
- brainpatch/schemas/feature.py +193 -0
- brainpatch/schemas/manifest.py +167 -0
- brainpatch/schemas/patch.py +379 -0
- brainpatch/schemas/patch_io.py +88 -0
- brainpatch/schemas/sae.py +146 -0
- brainpatch/server/__init__.py +11 -0
- brainpatch/server/app.py +269 -0
- brainpatch/steering/__init__.py +13 -0
- brainpatch/steering/plan.py +177 -0
- brainpatch/steering/schedule.py +138 -0
- brainpatch/ui/__init__.py +11 -0
- brainpatch/ui/app.py +201 -0
- brainpatch/verify/__init__.py +66 -0
- brainpatch/verify/behavioural.py +156 -0
- brainpatch/verify/checks.py +204 -0
- brainpatch/verify/corruptions.py +335 -0
- brainpatch/verify/report.py +133 -0
- brainpatch/verify/vectors.py +95 -0
- brainpatch/verify/workflow.py +331 -0
- brainpatch-1.2.0.dist-info/METADATA +556 -0
- brainpatch-1.2.0.dist-info/RECORD +82 -0
- brainpatch-1.2.0.dist-info/WHEEL +5 -0
- brainpatch-1.2.0.dist-info/entry_points.txt +2 -0
- brainpatch-1.2.0.dist-info/licenses/LICENSE +190 -0
- brainpatch-1.2.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
"""Utility-retention probes.
|
|
2
|
+
|
|
3
|
+
Steering a residual stream toward a target behaviour is worthless if it also
|
|
4
|
+
breaks arithmetic, instruction-following, or basic factual recall. These probes
|
|
5
|
+
are the "did we damage the model" half of every intervention experiment.
|
|
6
|
+
|
|
7
|
+
They are small, exact-match, model-free-to-score, and require no paid API. They
|
|
8
|
+
are also deliberately easy: the point is to detect *breakage*, not to rank model
|
|
9
|
+
capability. A baseline Qwen2.5-1.5B-Instruct should get nearly all of them, so
|
|
10
|
+
a drop is signal rather than noise.
|
|
11
|
+
|
|
12
|
+
Like the contrast sets, these are hand-written development fixtures. A score
|
|
13
|
+
here is not a benchmark result.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import re
|
|
19
|
+
from dataclasses import dataclass, field
|
|
20
|
+
from typing import Any, Sequence
|
|
21
|
+
|
|
22
|
+
#: (prompt, accepted answer patterns). Matching is case-insensitive substring
|
|
23
|
+
#: on the normalized generation, which tolerates the model's phrasing.
|
|
24
|
+
UTILITY_PROBES: tuple[tuple[str, str, tuple[str, ...]], ...] = (
|
|
25
|
+
("arithmetic", "What is 17 + 25? Reply with just the number.", ("42",)),
|
|
26
|
+
("arithmetic", "What is 8 times 7? Reply with just the number.", ("56",)),
|
|
27
|
+
("arithmetic", "What is 100 divided by 4? Reply with just the number.", ("25",)),
|
|
28
|
+
("factual_qa", "What is the capital of France? Reply with just the city name.", ("paris",)),
|
|
29
|
+
("factual_qa", "What is the chemical symbol for water? Reply with just the symbol.", ("h2o",)),
|
|
30
|
+
("factual_qa", "How many continents are there? Reply with just the number.", ("7", "seven")),
|
|
31
|
+
(
|
|
32
|
+
"instruction_following",
|
|
33
|
+
"Reply with exactly the word BANANA and nothing else.",
|
|
34
|
+
("banana",),
|
|
35
|
+
),
|
|
36
|
+
(
|
|
37
|
+
"instruction_following",
|
|
38
|
+
"List the first three positive even numbers, separated by commas.",
|
|
39
|
+
("2, 4, 6", "2,4,6"),
|
|
40
|
+
),
|
|
41
|
+
(
|
|
42
|
+
"reasoning",
|
|
43
|
+
"If all cats are mammals and Whiskers is a cat, is Whiskers a mammal? Answer yes or no.",
|
|
44
|
+
("yes",),
|
|
45
|
+
),
|
|
46
|
+
(
|
|
47
|
+
"reasoning",
|
|
48
|
+
"Tom is older than Ana. Ana is older than Bo. Who is youngest? Reply with just the name.",
|
|
49
|
+
("bo",),
|
|
50
|
+
),
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
#: Open-ended continuation prompts. Scored only for fluency/degeneration, since
|
|
54
|
+
#: there is no single correct answer.
|
|
55
|
+
CONTINUATION_PROBES: tuple[str, ...] = (
|
|
56
|
+
"Write two sentences about the sea.",
|
|
57
|
+
"Explain what a database index does, in about thirty words.",
|
|
58
|
+
"Describe the process of making tea.",
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def normalize(text: str) -> str:
|
|
63
|
+
"""Lowercase, collapse whitespace, strip surrounding punctuation."""
|
|
64
|
+
return re.sub(r"\s+", " ", text.strip().lower())
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def probe_correct(generation: str, accepted: Sequence[str]) -> bool:
|
|
68
|
+
"""Whether any accepted answer appears in the generation."""
|
|
69
|
+
normalized = normalize(generation)
|
|
70
|
+
return any(normalize(answer) in normalized for answer in accepted)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
@dataclass
|
|
74
|
+
class UtilityReport:
|
|
75
|
+
"""Capability retention under one condition."""
|
|
76
|
+
|
|
77
|
+
condition: str
|
|
78
|
+
total: int
|
|
79
|
+
correct: int
|
|
80
|
+
by_category: dict[str, dict[str, int]] = field(default_factory=dict)
|
|
81
|
+
continuation_degeneration_rate: float = 0.0
|
|
82
|
+
mean_continuation_words: float = 0.0
|
|
83
|
+
generations: list[dict[str, Any]] = field(default_factory=list)
|
|
84
|
+
|
|
85
|
+
@property
|
|
86
|
+
def accuracy(self) -> float:
|
|
87
|
+
return self.correct / self.total if self.total else 0.0
|
|
88
|
+
|
|
89
|
+
def to_dict(self) -> dict[str, Any]:
|
|
90
|
+
return {
|
|
91
|
+
"condition": self.condition,
|
|
92
|
+
"total": self.total,
|
|
93
|
+
"correct": self.correct,
|
|
94
|
+
"accuracy": self.accuracy,
|
|
95
|
+
"by_category": {
|
|
96
|
+
cat: {**counts, "accuracy": counts["correct"] / counts["total"]}
|
|
97
|
+
for cat, counts in self.by_category.items()
|
|
98
|
+
},
|
|
99
|
+
"continuation_degeneration_rate": self.continuation_degeneration_rate,
|
|
100
|
+
"mean_continuation_words": self.mean_continuation_words,
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def run_utility_probes(
|
|
105
|
+
model: Any,
|
|
106
|
+
*,
|
|
107
|
+
condition: str = "baseline",
|
|
108
|
+
generation: Any = None,
|
|
109
|
+
include_continuations: bool = True,
|
|
110
|
+
) -> UtilityReport:
|
|
111
|
+
"""Run the capability probes against a (possibly patched) model.
|
|
112
|
+
|
|
113
|
+
Parameters
|
|
114
|
+
----------
|
|
115
|
+
model:
|
|
116
|
+
A :class:`~brainpatch.research.ml.runtime.BrainPatchedModel`. Whatever patches
|
|
117
|
+
are currently installed are active, which is the point: this is called
|
|
118
|
+
once with them disabled and once with them enabled.
|
|
119
|
+
"""
|
|
120
|
+
from brainpatch.evaluation.metrics import score_generation
|
|
121
|
+
from brainpatch.research.ml.generation import GenerationConfig
|
|
122
|
+
|
|
123
|
+
cfg = generation or GenerationConfig(max_new_tokens=48)
|
|
124
|
+
report = UtilityReport(condition=condition, total=0, correct=0)
|
|
125
|
+
|
|
126
|
+
for category, prompt, accepted in UTILITY_PROBES:
|
|
127
|
+
text = model.generate(prompt, config=cfg)
|
|
128
|
+
ok = probe_correct(text, accepted)
|
|
129
|
+
report.total += 1
|
|
130
|
+
report.correct += int(ok)
|
|
131
|
+
bucket = report.by_category.setdefault(category, {"total": 0, "correct": 0})
|
|
132
|
+
bucket["total"] += 1
|
|
133
|
+
bucket["correct"] += int(ok)
|
|
134
|
+
report.generations.append(
|
|
135
|
+
{
|
|
136
|
+
"category": category,
|
|
137
|
+
"prompt": prompt,
|
|
138
|
+
"generation": text,
|
|
139
|
+
"accepted": list(accepted),
|
|
140
|
+
"correct": ok,
|
|
141
|
+
}
|
|
142
|
+
)
|
|
143
|
+
|
|
144
|
+
if include_continuations:
|
|
145
|
+
degenerate = 0
|
|
146
|
+
words: list[int] = []
|
|
147
|
+
long_cfg = GenerationConfig(max_new_tokens=96, do_sample=cfg.do_sample, seed=cfg.seed)
|
|
148
|
+
for prompt in CONTINUATION_PROBES:
|
|
149
|
+
text = model.generate(prompt, config=long_cfg)
|
|
150
|
+
metrics = score_generation(text)
|
|
151
|
+
degenerate += int(metrics.degeneration_flag)
|
|
152
|
+
words.append(metrics.num_words)
|
|
153
|
+
report.generations.append(
|
|
154
|
+
{
|
|
155
|
+
"category": "continuation",
|
|
156
|
+
"prompt": prompt,
|
|
157
|
+
"generation": text,
|
|
158
|
+
"degeneration_flag": metrics.degeneration_flag,
|
|
159
|
+
"num_words": metrics.num_words,
|
|
160
|
+
}
|
|
161
|
+
)
|
|
162
|
+
report.continuation_degeneration_rate = degenerate / len(CONTINUATION_PROBES)
|
|
163
|
+
report.mean_continuation_words = sum(words) / len(words)
|
|
164
|
+
|
|
165
|
+
return report
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def compare_utility(baseline: UtilityReport, patched: UtilityReport) -> dict[str, Any]:
|
|
169
|
+
"""Quantify capability change between two conditions."""
|
|
170
|
+
return {
|
|
171
|
+
"baseline": baseline.to_dict(),
|
|
172
|
+
"patched": patched.to_dict(),
|
|
173
|
+
"accuracy_delta": patched.accuracy - baseline.accuracy,
|
|
174
|
+
"degeneration_delta": (
|
|
175
|
+
patched.continuation_degeneration_rate - baseline.continuation_degeneration_rate
|
|
176
|
+
),
|
|
177
|
+
"length_ratio": (
|
|
178
|
+
patched.mean_continuation_words / baseline.mean_continuation_words
|
|
179
|
+
if baseline.mean_continuation_words
|
|
180
|
+
else None
|
|
181
|
+
),
|
|
182
|
+
"note": (
|
|
183
|
+
f"{baseline.total} hand-written probes. A drop is a signal worth "
|
|
184
|
+
"investigating, not evidence of degradation: at this sample size a "
|
|
185
|
+
"one-item change carries no statistical weight. The absolute score is "
|
|
186
|
+
"not a benchmark result."
|
|
187
|
+
),
|
|
188
|
+
}
|
|
@@ -0,0 +1,464 @@
|
|
|
1
|
+
"""Activation extraction into immutable, resumable shards.
|
|
2
|
+
|
|
3
|
+
The output of a run is a directory on the Volume::
|
|
4
|
+
|
|
5
|
+
/vol/activations/<experiment>/
|
|
6
|
+
manifest.json provenance + shard index
|
|
7
|
+
examples.jsonl one row per token block (text stored once)
|
|
8
|
+
shard_000000.safetensors activations + token metadata
|
|
9
|
+
shard_000001.safetensors
|
|
10
|
+
...
|
|
11
|
+
|
|
12
|
+
Two properties this module is built around:
|
|
13
|
+
|
|
14
|
+
**Immutability.** A shard, once written and recorded in the manifest, is never
|
|
15
|
+
touched again. A run that dies mid-shard leaves an unrecorded partial file that
|
|
16
|
+
the next run simply overwrites; everything already in the manifest is safe.
|
|
17
|
+
|
|
18
|
+
**No string duplication.** Storing the surrounding text next to every one of
|
|
19
|
+
hundreds of thousands of activations would multiply the corpus size by an order
|
|
20
|
+
of magnitude. Instead each activation row carries
|
|
21
|
+
``(example_index, token_position, token_id)`` as int32, and the text lives once
|
|
22
|
+
in ``examples.jsonl``. Recovering the context for a high-activating token is a
|
|
23
|
+
lookup, not a scan.
|
|
24
|
+
|
|
25
|
+
The first ``skip_first_n_tokens`` positions of each block are dropped by
|
|
26
|
+
default. Position 0 was measured to carry an extreme residual-stream activation
|
|
27
|
+
outlier -- norm 11052 against a corpus mean of ~70 at layer 18 of
|
|
28
|
+
Qwen2.5-1.5B-Instruct, a factor of 156. Including it distorts the input
|
|
29
|
+
normalization and spends dictionary capacity on a single positional artifact.
|
|
30
|
+
|
|
31
|
+
Outliers at the first token are commonly attributed to attention-sink
|
|
32
|
+
behaviour. That is plausible here but unverified: no attention weights were
|
|
33
|
+
measured, so the docstring records the outlier, not a mechanism.
|
|
34
|
+
"""
|
|
35
|
+
|
|
36
|
+
from __future__ import annotations
|
|
37
|
+
|
|
38
|
+
import json
|
|
39
|
+
import time
|
|
40
|
+
from dataclasses import dataclass
|
|
41
|
+
from pathlib import Path
|
|
42
|
+
from typing import Any, Callable
|
|
43
|
+
|
|
44
|
+
import torch
|
|
45
|
+
|
|
46
|
+
from brainpatch.research.ml.corpus import CorpusConfig, TokenBlock, batched, iter_token_blocks
|
|
47
|
+
from brainpatch.research.ml.hooks import ResidualCapture
|
|
48
|
+
from brainpatch.research.ml.model import ModelBundle, validate_hook, validate_layer
|
|
49
|
+
from brainpatch.paths import VolumePaths, shard_filename
|
|
50
|
+
from brainpatch.schemas.manifest import ActivationManifest, ShardRecord
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
@dataclass
|
|
54
|
+
class ExtractionConfig:
|
|
55
|
+
"""Everything that determines what gets extracted and how."""
|
|
56
|
+
|
|
57
|
+
experiment: str
|
|
58
|
+
layer: int = 18
|
|
59
|
+
hook: str = "residual_post"
|
|
60
|
+
target_tokens: int = 20_000
|
|
61
|
+
shard_size: int = 100_000
|
|
62
|
+
batch_size: int = 8
|
|
63
|
+
#: Drop this many leading positions per block (measured position-0 outlier).
|
|
64
|
+
skip_first_n_tokens: int = 1
|
|
65
|
+
#: Storage dtype. bfloat16 is lossless relative to a bf16 forward pass and
|
|
66
|
+
#: halves the corpus size versus float32.
|
|
67
|
+
store_dtype: str = "bfloat16"
|
|
68
|
+
seed: int = 0
|
|
69
|
+
|
|
70
|
+
def to_dict(self) -> dict[str, Any]:
|
|
71
|
+
return {
|
|
72
|
+
"experiment": self.experiment,
|
|
73
|
+
"layer": self.layer,
|
|
74
|
+
"hook": self.hook,
|
|
75
|
+
"target_tokens": self.target_tokens,
|
|
76
|
+
"shard_size": self.shard_size,
|
|
77
|
+
"batch_size": self.batch_size,
|
|
78
|
+
"skip_first_n_tokens": self.skip_first_n_tokens,
|
|
79
|
+
"store_dtype": self.store_dtype,
|
|
80
|
+
"seed": self.seed,
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
@dataclass
|
|
85
|
+
class ExtractionResult:
|
|
86
|
+
"""Measured outcome of an extraction run -- the basis for cost estimates."""
|
|
87
|
+
|
|
88
|
+
manifest: ActivationManifest
|
|
89
|
+
tokens_written: int
|
|
90
|
+
seconds: float
|
|
91
|
+
tokens_per_second: float
|
|
92
|
+
bytes_per_token: float
|
|
93
|
+
peak_vram_mb: float
|
|
94
|
+
resumed_from: int
|
|
95
|
+
|
|
96
|
+
def to_dict(self) -> dict[str, Any]:
|
|
97
|
+
return {
|
|
98
|
+
"experiment": self.manifest.experiment,
|
|
99
|
+
"tokens_written": self.tokens_written,
|
|
100
|
+
"completed_tokens": self.manifest.completed_tokens,
|
|
101
|
+
"num_shards": len(self.manifest.shards),
|
|
102
|
+
"seconds": round(self.seconds, 3),
|
|
103
|
+
"tokens_per_second": round(self.tokens_per_second, 1),
|
|
104
|
+
"bytes_per_token": round(self.bytes_per_token, 2),
|
|
105
|
+
"peak_vram_mb": round(self.peak_vram_mb, 1),
|
|
106
|
+
"resumed_from_tokens": self.resumed_from,
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def load_manifest(paths: VolumePaths, experiment: str) -> ActivationManifest | None:
|
|
111
|
+
"""Read an existing manifest, or ``None`` if this is a fresh run."""
|
|
112
|
+
path = Path(paths.activation_manifest(experiment))
|
|
113
|
+
if not path.is_file():
|
|
114
|
+
return None
|
|
115
|
+
manifest = ActivationManifest.from_json(path.read_text(encoding="utf-8"))
|
|
116
|
+
manifest.validate()
|
|
117
|
+
return manifest
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def _write_manifest(paths: VolumePaths, manifest: ActivationManifest) -> None:
|
|
121
|
+
manifest.validate()
|
|
122
|
+
path = Path(paths.activation_manifest(manifest.experiment))
|
|
123
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
124
|
+
path.write_text(manifest.to_json(), encoding="utf-8")
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def _write_shard(
|
|
128
|
+
paths: VolumePaths,
|
|
129
|
+
experiment: str,
|
|
130
|
+
index: int,
|
|
131
|
+
activations: torch.Tensor,
|
|
132
|
+
meta: torch.Tensor,
|
|
133
|
+
) -> ShardRecord:
|
|
134
|
+
"""Write one immutable shard and return its manifest record."""
|
|
135
|
+
from safetensors.torch import save_file
|
|
136
|
+
|
|
137
|
+
path = Path(paths.activation_shard(experiment, index))
|
|
138
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
139
|
+
save_file(
|
|
140
|
+
{"activations": activations.contiguous(), "meta": meta.contiguous()},
|
|
141
|
+
str(path),
|
|
142
|
+
metadata={"format": "brainpatch-activations-v0.1"},
|
|
143
|
+
)
|
|
144
|
+
size = path.stat().st_size
|
|
145
|
+
return ShardRecord(
|
|
146
|
+
index=index,
|
|
147
|
+
filename=shard_filename(index),
|
|
148
|
+
num_tokens=int(activations.shape[0]),
|
|
149
|
+
first_example=int(meta[0, 0].item()),
|
|
150
|
+
last_example=int(meta[-1, 0].item()),
|
|
151
|
+
bytes=size,
|
|
152
|
+
)
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def extract_activations(
|
|
156
|
+
bundle: ModelBundle,
|
|
157
|
+
corpus_cfg: CorpusConfig,
|
|
158
|
+
cfg: ExtractionConfig,
|
|
159
|
+
paths: VolumePaths,
|
|
160
|
+
*,
|
|
161
|
+
force: bool = False,
|
|
162
|
+
commit: Callable[[], None] | None = None,
|
|
163
|
+
provenance: dict[str, Any] | None = None,
|
|
164
|
+
) -> ExtractionResult:
|
|
165
|
+
"""Extract residual-stream activations into sharded storage.
|
|
166
|
+
|
|
167
|
+
Parameters
|
|
168
|
+
----------
|
|
169
|
+
force:
|
|
170
|
+
Discard any existing manifest and start over. Without this, an existing
|
|
171
|
+
run is resumed, which is the safe default for expensive GPU work.
|
|
172
|
+
commit:
|
|
173
|
+
Called after each shard lands, to flush the Modal Volume. Passing
|
|
174
|
+
``volume.commit`` makes the run durable against container loss.
|
|
175
|
+
|
|
176
|
+
Returns
|
|
177
|
+
-------
|
|
178
|
+
ExtractionResult
|
|
179
|
+
Real measurements -- tokens/sec, bytes/token, peak VRAM -- which are
|
|
180
|
+
what later cost projections are built from.
|
|
181
|
+
"""
|
|
182
|
+
layer = validate_layer(cfg.layer, bundle.num_layers)
|
|
183
|
+
hook_name = validate_hook(cfg.hook)
|
|
184
|
+
store_dtype = getattr(torch, cfg.store_dtype)
|
|
185
|
+
|
|
186
|
+
manifest = None if force else load_manifest(paths, cfg.experiment)
|
|
187
|
+
if manifest is not None:
|
|
188
|
+
_assert_manifest_compatible(manifest, bundle, cfg, corpus_cfg, layer, hook_name)
|
|
189
|
+
if manifest.is_complete:
|
|
190
|
+
print(
|
|
191
|
+
f"[extraction] {cfg.experiment}: already complete "
|
|
192
|
+
f"({manifest.completed_tokens:,} tokens). Pass force=True to redo."
|
|
193
|
+
)
|
|
194
|
+
return ExtractionResult(
|
|
195
|
+
manifest=manifest,
|
|
196
|
+
tokens_written=0,
|
|
197
|
+
seconds=0.0,
|
|
198
|
+
tokens_per_second=0.0,
|
|
199
|
+
bytes_per_token=manifest.bytes_per_token or 0.0,
|
|
200
|
+
peak_vram_mb=0.0,
|
|
201
|
+
resumed_from=manifest.completed_tokens,
|
|
202
|
+
)
|
|
203
|
+
else:
|
|
204
|
+
manifest = ActivationManifest(
|
|
205
|
+
experiment=cfg.experiment,
|
|
206
|
+
model=bundle.model_id,
|
|
207
|
+
model_revision=bundle.revision,
|
|
208
|
+
layer=layer,
|
|
209
|
+
hook=hook_name,
|
|
210
|
+
hidden_size=bundle.hidden_size,
|
|
211
|
+
dtype=cfg.store_dtype,
|
|
212
|
+
dataset=corpus_cfg.dataset_id,
|
|
213
|
+
dataset_split=corpus_cfg.split,
|
|
214
|
+
sequence_length=corpus_cfg.sequence_length,
|
|
215
|
+
requested_tokens=cfg.target_tokens,
|
|
216
|
+
shard_size=cfg.shard_size,
|
|
217
|
+
seed=cfg.seed,
|
|
218
|
+
created_at=_now(),
|
|
219
|
+
provenance=dict(provenance or {}),
|
|
220
|
+
)
|
|
221
|
+
|
|
222
|
+
resumed_from = manifest.completed_tokens
|
|
223
|
+
start_example = manifest.num_examples
|
|
224
|
+
remaining = cfg.target_tokens - manifest.completed_tokens
|
|
225
|
+
|
|
226
|
+
examples_path = Path(paths.activation_examples(cfg.experiment))
|
|
227
|
+
examples_path.parent.mkdir(parents=True, exist_ok=True)
|
|
228
|
+
if force and examples_path.exists():
|
|
229
|
+
examples_path.unlink()
|
|
230
|
+
|
|
231
|
+
torch.manual_seed(cfg.seed)
|
|
232
|
+
if torch.cuda.is_available():
|
|
233
|
+
torch.cuda.reset_peak_memory_stats()
|
|
234
|
+
|
|
235
|
+
layer_module = bundle.layer_module(layer)
|
|
236
|
+
capture = ResidualCapture(to_cpu=True, dtype=store_dtype)
|
|
237
|
+
|
|
238
|
+
act_buffer: list[torch.Tensor] = []
|
|
239
|
+
meta_buffer: list[torch.Tensor] = []
|
|
240
|
+
buffered = 0
|
|
241
|
+
shard_index = manifest.next_shard_index
|
|
242
|
+
tokens_written = 0
|
|
243
|
+
example_index = start_example
|
|
244
|
+
|
|
245
|
+
print(
|
|
246
|
+
f"[extraction] {cfg.experiment}: layer {layer} ({hook_name}), "
|
|
247
|
+
f"target {cfg.target_tokens:,} tokens, resuming from {resumed_from:,}"
|
|
248
|
+
)
|
|
249
|
+
start_time = time.perf_counter()
|
|
250
|
+
|
|
251
|
+
handle = capture.attach(layer_module)
|
|
252
|
+
try:
|
|
253
|
+
with open(examples_path, "a", encoding="utf-8") as examples_file:
|
|
254
|
+
blocks = iter_token_blocks(bundle.tokenizer, corpus_cfg, start_example=start_example)
|
|
255
|
+
for batch in batched(blocks, cfg.batch_size):
|
|
256
|
+
if tokens_written >= remaining:
|
|
257
|
+
break
|
|
258
|
+
|
|
259
|
+
acts, metas = _forward_batch(
|
|
260
|
+
bundle, capture, batch, cfg.skip_first_n_tokens, store_dtype
|
|
261
|
+
)
|
|
262
|
+
if acts.shape[0] == 0:
|
|
263
|
+
continue
|
|
264
|
+
|
|
265
|
+
# Never overshoot the requested token count.
|
|
266
|
+
budget = remaining - tokens_written
|
|
267
|
+
if acts.shape[0] > budget:
|
|
268
|
+
acts = acts[:budget]
|
|
269
|
+
metas = metas[:budget]
|
|
270
|
+
|
|
271
|
+
for block in batch:
|
|
272
|
+
examples_file.write(
|
|
273
|
+
json.dumps(
|
|
274
|
+
{
|
|
275
|
+
"index": block.example_index,
|
|
276
|
+
"source_doc": block.source_doc,
|
|
277
|
+
"char_offset": block.char_offset,
|
|
278
|
+
"num_tokens": len(block.input_ids),
|
|
279
|
+
"text": block.text,
|
|
280
|
+
},
|
|
281
|
+
ensure_ascii=False,
|
|
282
|
+
)
|
|
283
|
+
+ "\n"
|
|
284
|
+
)
|
|
285
|
+
example_index = max(example_index, block.example_index + 1)
|
|
286
|
+
|
|
287
|
+
act_buffer.append(acts)
|
|
288
|
+
meta_buffer.append(metas)
|
|
289
|
+
buffered += acts.shape[0]
|
|
290
|
+
tokens_written += acts.shape[0]
|
|
291
|
+
|
|
292
|
+
if buffered >= cfg.shard_size:
|
|
293
|
+
examples_file.flush()
|
|
294
|
+
shard_index, buffered = _flush_shard(
|
|
295
|
+
paths, cfg, manifest, act_buffer, meta_buffer, shard_index, example_index
|
|
296
|
+
)
|
|
297
|
+
_write_manifest(paths, manifest)
|
|
298
|
+
if commit is not None:
|
|
299
|
+
commit()
|
|
300
|
+
|
|
301
|
+
examples_file.flush()
|
|
302
|
+
|
|
303
|
+
if buffered > 0:
|
|
304
|
+
shard_index, buffered = _flush_shard(
|
|
305
|
+
paths, cfg, manifest, act_buffer, meta_buffer, shard_index, example_index
|
|
306
|
+
)
|
|
307
|
+
finally:
|
|
308
|
+
handle.remove()
|
|
309
|
+
|
|
310
|
+
elapsed = time.perf_counter() - start_time
|
|
311
|
+
manifest.num_examples = example_index
|
|
312
|
+
manifest.updated_at = _now()
|
|
313
|
+
manifest.provenance.update(provenance or {})
|
|
314
|
+
manifest.provenance["extraction_seconds"] = round(elapsed, 3)
|
|
315
|
+
_write_manifest(paths, manifest)
|
|
316
|
+
if commit is not None:
|
|
317
|
+
commit()
|
|
318
|
+
|
|
319
|
+
peak_vram = (
|
|
320
|
+
torch.cuda.max_memory_allocated() / 1024**2 if torch.cuda.is_available() else 0.0
|
|
321
|
+
)
|
|
322
|
+
return ExtractionResult(
|
|
323
|
+
manifest=manifest,
|
|
324
|
+
tokens_written=tokens_written,
|
|
325
|
+
seconds=elapsed,
|
|
326
|
+
tokens_per_second=tokens_written / elapsed if elapsed > 0 else 0.0,
|
|
327
|
+
bytes_per_token=manifest.bytes_per_token or 0.0,
|
|
328
|
+
peak_vram_mb=peak_vram,
|
|
329
|
+
resumed_from=resumed_from,
|
|
330
|
+
)
|
|
331
|
+
|
|
332
|
+
|
|
333
|
+
def _flush_shard(
|
|
334
|
+
paths: VolumePaths,
|
|
335
|
+
cfg: ExtractionConfig,
|
|
336
|
+
manifest: ActivationManifest,
|
|
337
|
+
act_buffer: list[torch.Tensor],
|
|
338
|
+
meta_buffer: list[torch.Tensor],
|
|
339
|
+
shard_index: int,
|
|
340
|
+
example_index: int,
|
|
341
|
+
) -> tuple[int, int]:
|
|
342
|
+
"""Concatenate the buffer into one shard, record it, clear the buffer."""
|
|
343
|
+
activations = torch.cat(act_buffer, dim=0)
|
|
344
|
+
meta = torch.cat(meta_buffer, dim=0)
|
|
345
|
+
record = _write_shard(paths, cfg.experiment, shard_index, activations, meta)
|
|
346
|
+
manifest.shards.append(record)
|
|
347
|
+
manifest.completed_tokens += record.num_tokens
|
|
348
|
+
manifest.num_examples = example_index
|
|
349
|
+
manifest.updated_at = _now()
|
|
350
|
+
act_buffer.clear()
|
|
351
|
+
meta_buffer.clear()
|
|
352
|
+
print(
|
|
353
|
+
f"[extraction] shard {shard_index:06d}: {record.num_tokens:,} tokens, "
|
|
354
|
+
f"{record.bytes / 1024**2:.1f} MB (total {manifest.completed_tokens:,})"
|
|
355
|
+
)
|
|
356
|
+
return shard_index + 1, 0
|
|
357
|
+
|
|
358
|
+
|
|
359
|
+
@torch.inference_mode()
|
|
360
|
+
def _forward_batch(
|
|
361
|
+
bundle: ModelBundle,
|
|
362
|
+
capture: ResidualCapture,
|
|
363
|
+
batch: list[TokenBlock],
|
|
364
|
+
skip_first_n: int,
|
|
365
|
+
store_dtype: torch.dtype,
|
|
366
|
+
) -> tuple[torch.Tensor, torch.Tensor]:
|
|
367
|
+
"""Run one batch and return (activations, metadata) for kept positions.
|
|
368
|
+
|
|
369
|
+
Blocks in a batch may differ in length (the trailing block of a document is
|
|
370
|
+
shorter), so they are right-padded and the padded positions are then
|
|
371
|
+
excluded via the attention mask -- padded activations are meaningless and
|
|
372
|
+
must never enter the corpus.
|
|
373
|
+
"""
|
|
374
|
+
lengths = [len(b.input_ids) for b in batch]
|
|
375
|
+
max_len = max(lengths)
|
|
376
|
+
pad_id = bundle.tokenizer.pad_token_id
|
|
377
|
+
if pad_id is None:
|
|
378
|
+
pad_id = bundle.tokenizer.eos_token_id or 0
|
|
379
|
+
|
|
380
|
+
input_ids = torch.full((len(batch), max_len), pad_id, dtype=torch.long)
|
|
381
|
+
attention_mask = torch.zeros((len(batch), max_len), dtype=torch.long)
|
|
382
|
+
for i, block in enumerate(batch):
|
|
383
|
+
n = len(block.input_ids)
|
|
384
|
+
input_ids[i, :n] = torch.tensor(block.input_ids, dtype=torch.long)
|
|
385
|
+
attention_mask[i, :n] = 1
|
|
386
|
+
|
|
387
|
+
input_ids = input_ids.to(bundle.device)
|
|
388
|
+
attention_mask = attention_mask.to(bundle.device)
|
|
389
|
+
|
|
390
|
+
capture.activations = None
|
|
391
|
+
bundle.model(input_ids=input_ids, attention_mask=attention_mask, use_cache=False)
|
|
392
|
+
hidden = capture.activations
|
|
393
|
+
if hidden is None:
|
|
394
|
+
raise RuntimeError("capture hook did not fire -- wrong module or model layout changed")
|
|
395
|
+
|
|
396
|
+
keep_acts: list[torch.Tensor] = []
|
|
397
|
+
keep_meta: list[torch.Tensor] = []
|
|
398
|
+
for i, block in enumerate(batch):
|
|
399
|
+
n = lengths[i]
|
|
400
|
+
if n <= skip_first_n:
|
|
401
|
+
continue
|
|
402
|
+
positions = torch.arange(skip_first_n, n, dtype=torch.int32)
|
|
403
|
+
keep_acts.append(hidden[i, skip_first_n:n, :].to(store_dtype))
|
|
404
|
+
meta = torch.stack(
|
|
405
|
+
[
|
|
406
|
+
torch.full((positions.numel(),), block.example_index, dtype=torch.int32),
|
|
407
|
+
positions,
|
|
408
|
+
torch.tensor(block.input_ids[skip_first_n:n], dtype=torch.int32),
|
|
409
|
+
],
|
|
410
|
+
dim=1,
|
|
411
|
+
)
|
|
412
|
+
keep_meta.append(meta)
|
|
413
|
+
|
|
414
|
+
if not keep_acts:
|
|
415
|
+
empty_dtype = store_dtype
|
|
416
|
+
return (
|
|
417
|
+
torch.empty((0, bundle.hidden_size), dtype=empty_dtype),
|
|
418
|
+
torch.empty((0, 3), dtype=torch.int32),
|
|
419
|
+
)
|
|
420
|
+
return torch.cat(keep_acts, dim=0), torch.cat(keep_meta, dim=0)
|
|
421
|
+
|
|
422
|
+
|
|
423
|
+
def _assert_manifest_compatible(
|
|
424
|
+
manifest: ActivationManifest,
|
|
425
|
+
bundle: ModelBundle,
|
|
426
|
+
cfg: ExtractionConfig,
|
|
427
|
+
corpus_cfg: CorpusConfig,
|
|
428
|
+
layer: int,
|
|
429
|
+
hook: str,
|
|
430
|
+
) -> None:
|
|
431
|
+
"""Refuse to append activations from a different setup to an existing corpus.
|
|
432
|
+
|
|
433
|
+
Silently mixing layer-17 and layer-18 activations would produce a corpus
|
|
434
|
+
that trains an SAE on nothing coherent, and the failure would be invisible.
|
|
435
|
+
"""
|
|
436
|
+
mismatches: list[str] = []
|
|
437
|
+
if manifest.model != bundle.model_id:
|
|
438
|
+
mismatches.append(f"model: manifest={manifest.model} run={bundle.model_id}")
|
|
439
|
+
if manifest.layer != layer:
|
|
440
|
+
mismatches.append(f"layer: manifest={manifest.layer} run={layer}")
|
|
441
|
+
if manifest.hook != hook:
|
|
442
|
+
mismatches.append(f"hook: manifest={manifest.hook} run={hook}")
|
|
443
|
+
if manifest.hidden_size != bundle.hidden_size:
|
|
444
|
+
mismatches.append(f"hidden_size: manifest={manifest.hidden_size} run={bundle.hidden_size}")
|
|
445
|
+
if manifest.dtype != cfg.store_dtype:
|
|
446
|
+
mismatches.append(f"dtype: manifest={manifest.dtype} run={cfg.store_dtype}")
|
|
447
|
+
if manifest.sequence_length != corpus_cfg.sequence_length:
|
|
448
|
+
mismatches.append(
|
|
449
|
+
f"sequence_length: manifest={manifest.sequence_length} run={corpus_cfg.sequence_length}"
|
|
450
|
+
)
|
|
451
|
+
if manifest.dataset != corpus_cfg.dataset_id:
|
|
452
|
+
mismatches.append(f"dataset: manifest={manifest.dataset} run={corpus_cfg.dataset_id}")
|
|
453
|
+
if mismatches:
|
|
454
|
+
raise ValueError(
|
|
455
|
+
f"cannot resume extraction for {manifest.experiment!r}: configuration changed.\n "
|
|
456
|
+
+ "\n ".join(mismatches)
|
|
457
|
+
+ "\nUse a new experiment name, or pass force=True to discard the existing corpus."
|
|
458
|
+
)
|
|
459
|
+
|
|
460
|
+
|
|
461
|
+
def _now() -> str:
|
|
462
|
+
from datetime import datetime, timezone
|
|
463
|
+
|
|
464
|
+
return datetime.now(timezone.utc).isoformat()
|