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,317 @@
|
|
|
1
|
+
"""Building the feature database from a trained SAE.
|
|
2
|
+
|
|
3
|
+
For every dictionary feature this computes firing statistics over the
|
|
4
|
+
activation corpus and recovers the token contexts that drive it hardest.
|
|
5
|
+
|
|
6
|
+
What this module deliberately does **not** do is assign a semantic label. Top
|
|
7
|
+
activating contexts are correlational evidence. A feature whose top examples are
|
|
8
|
+
all hedging language is a feature that *correlates with* hedging language in
|
|
9
|
+
this corpus -- it is not "the uncertainty feature" until steering it changes
|
|
10
|
+
behaviour and scale-matched controls do not. Every record leaves
|
|
11
|
+
``hypothesis=None`` and ``evidence_level="none"``; the causal-validation
|
|
12
|
+
pipeline is the only thing that writes anything stronger.
|
|
13
|
+
|
|
14
|
+
Runs on CPU. A 2048-feature dictionary over 20k activations is a couple of
|
|
15
|
+
matrix multiplies, and CPU Modal Functions cost a fraction of GPU ones.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import json
|
|
21
|
+
from pathlib import Path
|
|
22
|
+
from typing import Any
|
|
23
|
+
|
|
24
|
+
import torch
|
|
25
|
+
|
|
26
|
+
from brainpatch.research.ml.activation_store import ActivationSubset, read_manifest
|
|
27
|
+
from brainpatch.research.ml.sae import TopKSAE
|
|
28
|
+
from brainpatch.paths import VolumePaths
|
|
29
|
+
from brainpatch.schemas.feature import FeatureContext, FeatureRecord, FeatureStats
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def load_examples(paths: VolumePaths, experiment: str) -> dict[int, dict[str, Any]]:
|
|
33
|
+
"""Load ``examples.jsonl`` into an index -> row mapping."""
|
|
34
|
+
path = Path(paths.activation_examples(experiment))
|
|
35
|
+
if not path.is_file():
|
|
36
|
+
raise FileNotFoundError(f"examples file not found: {path}")
|
|
37
|
+
examples: dict[int, dict[str, Any]] = {}
|
|
38
|
+
with path.open(encoding="utf-8") as handle:
|
|
39
|
+
for line in handle:
|
|
40
|
+
line = line.strip()
|
|
41
|
+
if not line:
|
|
42
|
+
continue
|
|
43
|
+
row = json.loads(line)
|
|
44
|
+
examples[int(row["index"])] = row
|
|
45
|
+
return examples
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
@torch.no_grad()
|
|
49
|
+
def compute_feature_activations(
|
|
50
|
+
sae: TopKSAE,
|
|
51
|
+
subset: ActivationSubset,
|
|
52
|
+
*,
|
|
53
|
+
input_scale: float,
|
|
54
|
+
batch_size: int = 4096,
|
|
55
|
+
) -> torch.Tensor:
|
|
56
|
+
"""Encode the whole corpus into a ``[tokens, d_sae]`` sparse activation matrix.
|
|
57
|
+
|
|
58
|
+
Kept dense in float32 for simplicity: at smoke scale that is
|
|
59
|
+
``20k x 2048 x 4B = 164 MB``. For a serious run this should stream and
|
|
60
|
+
accumulate statistics incrementally instead; the ``max_bytes`` guard on
|
|
61
|
+
:class:`ActivationSubset` is what stops that limit being crossed silently.
|
|
62
|
+
"""
|
|
63
|
+
sae.eval()
|
|
64
|
+
chunks: list[torch.Tensor] = []
|
|
65
|
+
for start in range(0, len(subset), batch_size):
|
|
66
|
+
batch = subset.activations[start : start + batch_size].to(torch.float32) * input_scale
|
|
67
|
+
sparse, _, _ = sae.encode(batch)
|
|
68
|
+
chunks.append(sparse.cpu())
|
|
69
|
+
return torch.cat(chunks, dim=0)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def build_feature_database(
|
|
73
|
+
sae: TopKSAE,
|
|
74
|
+
subset: ActivationSubset,
|
|
75
|
+
paths: VolumePaths,
|
|
76
|
+
experiment: str,
|
|
77
|
+
*,
|
|
78
|
+
input_scale: float,
|
|
79
|
+
top_k_contexts: int = 8,
|
|
80
|
+
context_window: int = 12,
|
|
81
|
+
tokenizer: Any = None,
|
|
82
|
+
) -> dict[str, Any]:
|
|
83
|
+
"""Compute per-feature statistics and top contexts, and persist them.
|
|
84
|
+
|
|
85
|
+
Returns a summary dict; the per-feature records go to
|
|
86
|
+
``/vol/feature-db/<experiment>/features.jsonl``.
|
|
87
|
+
"""
|
|
88
|
+
manifest = read_manifest(paths, experiment)
|
|
89
|
+
examples = load_examples(paths, experiment)
|
|
90
|
+
acts = compute_feature_activations(sae, subset, input_scale=input_scale)
|
|
91
|
+
n_tokens, d_sae = acts.shape
|
|
92
|
+
|
|
93
|
+
fire_mask = acts > 0
|
|
94
|
+
fire_count = fire_mask.sum(dim=0)
|
|
95
|
+
act_sum = acts.sum(dim=0)
|
|
96
|
+
max_act = acts.max(dim=0).values
|
|
97
|
+
decoder_norms = sae.decoder_norms().cpu()
|
|
98
|
+
|
|
99
|
+
# Mean/std over *firing* tokens only: averaging in the structural zeros of a
|
|
100
|
+
# Top-K SAE would just report k/d_sae times the true magnitude.
|
|
101
|
+
mean_act = torch.where(fire_count > 0, act_sum / fire_count.clamp_min(1), torch.zeros_like(act_sum))
|
|
102
|
+
sq_sum = (acts.pow(2)).sum(dim=0)
|
|
103
|
+
var = torch.where(
|
|
104
|
+
fire_count > 0,
|
|
105
|
+
(sq_sum / fire_count.clamp_min(1)) - mean_act.pow(2),
|
|
106
|
+
torch.zeros_like(act_sum),
|
|
107
|
+
).clamp_min(0.0)
|
|
108
|
+
std_act = var.sqrt()
|
|
109
|
+
|
|
110
|
+
out_dir = Path(paths.feature_db(experiment))
|
|
111
|
+
out_dir.mkdir(parents=True, exist_ok=True)
|
|
112
|
+
features_path = Path(paths.features_jsonl(experiment))
|
|
113
|
+
|
|
114
|
+
alive = 0
|
|
115
|
+
with features_path.open("w", encoding="utf-8") as handle:
|
|
116
|
+
for feature_id in range(d_sae):
|
|
117
|
+
count = int(fire_count[feature_id].item())
|
|
118
|
+
stats = FeatureStats(
|
|
119
|
+
fire_count=count,
|
|
120
|
+
total_tokens=n_tokens,
|
|
121
|
+
mean_activation=float(mean_act[feature_id].item()),
|
|
122
|
+
max_activation=float(max_act[feature_id].item()),
|
|
123
|
+
std_activation=float(std_act[feature_id].item()),
|
|
124
|
+
decoder_norm=float(decoder_norms[feature_id].item()),
|
|
125
|
+
)
|
|
126
|
+
contexts: list[FeatureContext] = []
|
|
127
|
+
if count > 0:
|
|
128
|
+
alive += 1
|
|
129
|
+
contexts = _top_contexts(
|
|
130
|
+
acts[:, feature_id],
|
|
131
|
+
subset.meta,
|
|
132
|
+
examples,
|
|
133
|
+
manifest.sequence_length,
|
|
134
|
+
top_k=top_k_contexts,
|
|
135
|
+
window=context_window,
|
|
136
|
+
tokenizer=tokenizer,
|
|
137
|
+
)
|
|
138
|
+
# hypothesis stays None and evidence_level stays "none" by design.
|
|
139
|
+
record = FeatureRecord(feature_id=feature_id, stats=stats, top_contexts=contexts)
|
|
140
|
+
handle.write(record.to_json() + "\n")
|
|
141
|
+
|
|
142
|
+
firing_rates = (fire_count.float() / n_tokens).tolist()
|
|
143
|
+
alive_rates = [r for r in firing_rates if r > 0]
|
|
144
|
+
summary = {
|
|
145
|
+
"experiment": experiment,
|
|
146
|
+
"num_features": d_sae,
|
|
147
|
+
"num_tokens_analysed": n_tokens,
|
|
148
|
+
"alive_features": alive,
|
|
149
|
+
"dead_features": d_sae - alive,
|
|
150
|
+
"dead_fraction": (d_sae - alive) / d_sae,
|
|
151
|
+
"mean_firing_rate_alive": (sum(alive_rates) / len(alive_rates)) if alive_rates else 0.0,
|
|
152
|
+
"median_firing_rate_alive": _median(alive_rates),
|
|
153
|
+
"max_firing_rate": max(firing_rates) if firing_rates else 0.0,
|
|
154
|
+
"mean_l0": float(fire_mask.sum(dim=1).float().mean().item()),
|
|
155
|
+
"decoder_norm_mean": float(decoder_norms.mean().item()),
|
|
156
|
+
"decoder_norm_min": float(decoder_norms.min().item()),
|
|
157
|
+
"decoder_norm_max": float(decoder_norms.max().item()),
|
|
158
|
+
"input_scale": input_scale,
|
|
159
|
+
"features_path": str(features_path),
|
|
160
|
+
"note": (
|
|
161
|
+
"Statistics only. No feature carries a semantic label: top-activating "
|
|
162
|
+
"contexts are correlational evidence and are not sufficient to name a feature."
|
|
163
|
+
),
|
|
164
|
+
}
|
|
165
|
+
Path(paths.feature_summary(experiment)).write_text(
|
|
166
|
+
json.dumps(summary, indent=2, sort_keys=True), encoding="utf-8"
|
|
167
|
+
)
|
|
168
|
+
return summary
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def _top_contexts(
|
|
172
|
+
feature_column: torch.Tensor,
|
|
173
|
+
meta: torch.Tensor,
|
|
174
|
+
examples: dict[int, dict[str, Any]],
|
|
175
|
+
sequence_length: int,
|
|
176
|
+
*,
|
|
177
|
+
top_k: int,
|
|
178
|
+
window: int,
|
|
179
|
+
tokenizer: Any = None,
|
|
180
|
+
) -> list[FeatureContext]:
|
|
181
|
+
"""Recover the highest-activating token occurrences with surrounding text."""
|
|
182
|
+
nonzero = (feature_column > 0).nonzero(as_tuple=True)[0]
|
|
183
|
+
if nonzero.numel() == 0:
|
|
184
|
+
return []
|
|
185
|
+
k = min(top_k, nonzero.numel())
|
|
186
|
+
values = feature_column[nonzero]
|
|
187
|
+
order = torch.topk(values, k).indices
|
|
188
|
+
rows = nonzero[order]
|
|
189
|
+
|
|
190
|
+
contexts: list[FeatureContext] = []
|
|
191
|
+
for row in rows.tolist():
|
|
192
|
+
example_index = int(meta[row, 0].item())
|
|
193
|
+
position = int(meta[row, 1].item())
|
|
194
|
+
token_id = int(meta[row, 2].item())
|
|
195
|
+
example = examples.get(example_index)
|
|
196
|
+
|
|
197
|
+
token_text = ""
|
|
198
|
+
before = after = ""
|
|
199
|
+
if tokenizer is not None:
|
|
200
|
+
token_text = tokenizer.decode([token_id])
|
|
201
|
+
if example is not None:
|
|
202
|
+
before, after = _decode_window(
|
|
203
|
+
tokenizer, example.get("text", ""), position, window
|
|
204
|
+
)
|
|
205
|
+
elif example is not None:
|
|
206
|
+
before, after = "", example.get("text", "")[:200]
|
|
207
|
+
|
|
208
|
+
contexts.append(
|
|
209
|
+
FeatureContext(
|
|
210
|
+
example_index=example_index,
|
|
211
|
+
token_position=position,
|
|
212
|
+
token_id=token_id,
|
|
213
|
+
token_text=token_text,
|
|
214
|
+
activation=float(feature_column[row].item()),
|
|
215
|
+
context_before=before,
|
|
216
|
+
context_after=after,
|
|
217
|
+
)
|
|
218
|
+
)
|
|
219
|
+
return contexts
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def _decode_window(tokenizer: Any, text: str, position: int, window: int) -> tuple[str, str]:
|
|
223
|
+
"""Re-tokenize the stored text and slice a window around ``position``.
|
|
224
|
+
|
|
225
|
+
Re-tokenizing is cheap and avoids storing per-token strings for the whole
|
|
226
|
+
corpus, which would dominate the on-disk size.
|
|
227
|
+
"""
|
|
228
|
+
ids = tokenizer(text, add_special_tokens=False)["input_ids"]
|
|
229
|
+
if position >= len(ids):
|
|
230
|
+
return "", ""
|
|
231
|
+
lo = max(0, position - window)
|
|
232
|
+
hi = min(len(ids), position + window + 1)
|
|
233
|
+
return tokenizer.decode(ids[lo:position]), tokenizer.decode(ids[position + 1 : hi])
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def rank_features(
|
|
237
|
+
paths: VolumePaths,
|
|
238
|
+
experiment: str,
|
|
239
|
+
*,
|
|
240
|
+
by: str = "max_activation",
|
|
241
|
+
limit: int = 50,
|
|
242
|
+
min_fire_count: int = 1,
|
|
243
|
+
max_firing_rate: float = 1.0,
|
|
244
|
+
min_firing_rate: float = 0.0,
|
|
245
|
+
) -> list[FeatureRecord]:
|
|
246
|
+
"""Rank features from the persisted database.
|
|
247
|
+
|
|
248
|
+
Parameters
|
|
249
|
+
----------
|
|
250
|
+
by:
|
|
251
|
+
``max_activation``, ``mean_activation``, ``fire_count`` or ``firing_rate``.
|
|
252
|
+
max_firing_rate:
|
|
253
|
+
Drop features that fire on more than this fraction of tokens. Very
|
|
254
|
+
high-frequency features are usually modelling something positional or
|
|
255
|
+
distributional rather than anything specific.
|
|
256
|
+
min_firing_rate:
|
|
257
|
+
Drop features that fire on *fewer* than this fraction of tokens. See the
|
|
258
|
+
warning below -- this is the guard that matters in practice.
|
|
259
|
+
|
|
260
|
+
Warning
|
|
261
|
+
-------
|
|
262
|
+
**Ranking by ``max_activation`` alone selects outliers, and did so
|
|
263
|
+
destructively in ``smoke_v0``.** Measured on that feature database: the top
|
|
264
|
+
32 features by ``max_activation`` all fired on 3-6 tokens out of 20,000, all
|
|
265
|
+
with the same top token (``" Bd"``, chess notation from a handful of
|
|
266
|
+
wikitext articles), at activations 100x+ the dictionary median of 9.06. An
|
|
267
|
+
undertrained SAE shatters rare high-norm tokens across many near-duplicate
|
|
268
|
+
features, and this ranking finds precisely those.
|
|
269
|
+
|
|
270
|
+
The consequence in ``smoke_v0`` was worse than a poor choice of target: the
|
|
271
|
+
"unrelated feature" control was drawn from the same ranking and landed on
|
|
272
|
+
feature 1270, a near-duplicate of the target firing on the same token. That
|
|
273
|
+
control was therefore not unrelated and its comparison is uninformative.
|
|
274
|
+
|
|
275
|
+
For intervention candidates, pass ``min_firing_rate`` at or near the
|
|
276
|
+
dictionary median firing rate, or rank by ``mean_activation`` /
|
|
277
|
+
``fire_count`` instead.
|
|
278
|
+
"""
|
|
279
|
+
path = Path(paths.features_jsonl(experiment))
|
|
280
|
+
if not path.is_file():
|
|
281
|
+
raise FileNotFoundError(f"feature database not found: {path}")
|
|
282
|
+
|
|
283
|
+
records: list[FeatureRecord] = []
|
|
284
|
+
with path.open(encoding="utf-8") as handle:
|
|
285
|
+
for line in handle:
|
|
286
|
+
line = line.strip()
|
|
287
|
+
if not line:
|
|
288
|
+
continue
|
|
289
|
+
record = FeatureRecord.from_dict(json.loads(line))
|
|
290
|
+
if record.stats.fire_count < min_fire_count:
|
|
291
|
+
continue
|
|
292
|
+
if record.stats.firing_rate > max_firing_rate:
|
|
293
|
+
continue
|
|
294
|
+
if record.stats.firing_rate < min_firing_rate:
|
|
295
|
+
continue
|
|
296
|
+
records.append(record)
|
|
297
|
+
|
|
298
|
+
keys = {
|
|
299
|
+
"max_activation": lambda r: r.stats.max_activation,
|
|
300
|
+
"mean_activation": lambda r: r.stats.mean_activation,
|
|
301
|
+
"fire_count": lambda r: r.stats.fire_count,
|
|
302
|
+
"firing_rate": lambda r: r.stats.firing_rate,
|
|
303
|
+
}
|
|
304
|
+
if by not in keys:
|
|
305
|
+
raise ValueError(f"unknown ranking key {by!r}; expected one of {sorted(keys)}")
|
|
306
|
+
records.sort(key=keys[by], reverse=True)
|
|
307
|
+
return records[:limit]
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
def _median(values: list[float]) -> float:
|
|
311
|
+
if not values:
|
|
312
|
+
return 0.0
|
|
313
|
+
ordered = sorted(values)
|
|
314
|
+
mid = len(ordered) // 2
|
|
315
|
+
if len(ordered) % 2:
|
|
316
|
+
return ordered[mid]
|
|
317
|
+
return (ordered[mid - 1] + ordered[mid]) / 2
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
"""Deterministic generation and log-probability measurement.
|
|
2
|
+
|
|
3
|
+
Every comparison in the causal-validation harness -- baseline, intervention,
|
|
4
|
+
control -- must differ *only* in the intervention. That means identical
|
|
5
|
+
sampling settings, identical seeds, and identical prompts.
|
|
6
|
+
:class:`GenerationConfig` makes those settings one object that gets passed to
|
|
7
|
+
every condition, so there is no way to accidentally give the baseline a
|
|
8
|
+
different temperature.
|
|
9
|
+
|
|
10
|
+
Greedy decoding is the default. Sampling adds variance that would have to be
|
|
11
|
+
averaged out with many more (paid) generations before an effect could be
|
|
12
|
+
distinguished from noise.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
from dataclasses import asdict, dataclass
|
|
18
|
+
from typing import Any
|
|
19
|
+
|
|
20
|
+
import torch
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dataclass
|
|
24
|
+
class GenerationConfig:
|
|
25
|
+
"""Sampling settings, shared identically across all compared conditions."""
|
|
26
|
+
|
|
27
|
+
max_new_tokens: int = 128
|
|
28
|
+
do_sample: bool = False
|
|
29
|
+
temperature: float = 1.0
|
|
30
|
+
top_p: float = 1.0
|
|
31
|
+
top_k: int = 0
|
|
32
|
+
repetition_penalty: float = 1.0
|
|
33
|
+
seed: int = 0
|
|
34
|
+
|
|
35
|
+
def to_kwargs(self, tokenizer: Any) -> dict[str, Any]:
|
|
36
|
+
kwargs: dict[str, Any] = {
|
|
37
|
+
"max_new_tokens": self.max_new_tokens,
|
|
38
|
+
"do_sample": self.do_sample,
|
|
39
|
+
"pad_token_id": tokenizer.pad_token_id or tokenizer.eos_token_id,
|
|
40
|
+
}
|
|
41
|
+
if self.do_sample:
|
|
42
|
+
kwargs["temperature"] = self.temperature
|
|
43
|
+
kwargs["top_p"] = self.top_p
|
|
44
|
+
if self.top_k > 0:
|
|
45
|
+
kwargs["top_k"] = self.top_k
|
|
46
|
+
if self.repetition_penalty != 1.0:
|
|
47
|
+
kwargs["repetition_penalty"] = self.repetition_penalty
|
|
48
|
+
return kwargs
|
|
49
|
+
|
|
50
|
+
def to_dict(self) -> dict[str, Any]:
|
|
51
|
+
return asdict(self)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def build_chat_prompt(tokenizer: Any, user_message: str, system: str | None = None) -> str:
|
|
55
|
+
"""Render a user message through the model's chat template.
|
|
56
|
+
|
|
57
|
+
Falls back to the raw message for base models with no template, rather than
|
|
58
|
+
silently inventing one -- a wrong template changes the distribution enough
|
|
59
|
+
to invalidate a comparison.
|
|
60
|
+
"""
|
|
61
|
+
if getattr(tokenizer, "chat_template", None) is None:
|
|
62
|
+
return user_message
|
|
63
|
+
messages: list[dict[str, str]] = []
|
|
64
|
+
if system:
|
|
65
|
+
messages.append({"role": "system", "content": system})
|
|
66
|
+
messages.append({"role": "user", "content": user_message})
|
|
67
|
+
return tokenizer.apply_chat_template(messages, tokenize=False, add_generation_prompt=True)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
@torch.inference_mode()
|
|
71
|
+
def sequence_logprob(
|
|
72
|
+
model: Any,
|
|
73
|
+
tokenizer: Any,
|
|
74
|
+
prompt: str,
|
|
75
|
+
continuation: str,
|
|
76
|
+
device: torch.device | str = "cuda",
|
|
77
|
+
) -> dict[str, float]:
|
|
78
|
+
"""Log-probability the model assigns to ``continuation`` after ``prompt``.
|
|
79
|
+
|
|
80
|
+
Used to measure an intervention's effect without generating: the difference
|
|
81
|
+
in logprob between a positive and negative response under baseline versus
|
|
82
|
+
steered conditions is a lower-variance signal than comparing free
|
|
83
|
+
generations.
|
|
84
|
+
|
|
85
|
+
Returns total and per-token log-probability, plus the token count.
|
|
86
|
+
"""
|
|
87
|
+
prompt_ids = tokenizer(prompt, return_tensors="pt", add_special_tokens=True).input_ids
|
|
88
|
+
full_ids = tokenizer(
|
|
89
|
+
prompt + continuation, return_tensors="pt", add_special_tokens=True
|
|
90
|
+
).input_ids
|
|
91
|
+
prompt_len = prompt_ids.shape[1]
|
|
92
|
+
n_continuation = full_ids.shape[1] - prompt_len
|
|
93
|
+
if n_continuation <= 0:
|
|
94
|
+
return {"total_logprob": 0.0, "mean_logprob": 0.0, "num_tokens": 0}
|
|
95
|
+
|
|
96
|
+
full_ids = full_ids.to(device)
|
|
97
|
+
logits = model(input_ids=full_ids).logits.float()
|
|
98
|
+
log_probs = torch.log_softmax(logits[:, :-1, :], dim=-1)
|
|
99
|
+
targets = full_ids[:, 1:]
|
|
100
|
+
|
|
101
|
+
# Score only the continuation positions.
|
|
102
|
+
start = prompt_len - 1
|
|
103
|
+
selected = log_probs[0, start:, :].gather(-1, targets[0, start:].unsqueeze(-1)).squeeze(-1)
|
|
104
|
+
total = float(selected.sum().item())
|
|
105
|
+
return {
|
|
106
|
+
"total_logprob": total,
|
|
107
|
+
"mean_logprob": total / selected.numel(),
|
|
108
|
+
"num_tokens": int(selected.numel()),
|
|
109
|
+
}
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
"""Residual-stream capture and injection hooks.
|
|
2
|
+
|
|
3
|
+
Both directions of the pipeline attach to the *output* of a decoder block:
|
|
4
|
+
|
|
5
|
+
* :class:`ResidualCapture` records what the block wrote, which is what the SAE
|
|
6
|
+
is trained on.
|
|
7
|
+
* :class:`ResidualInjector` adds a vector to the same tensor, which is how a
|
|
8
|
+
BrainPatch takes effect.
|
|
9
|
+
|
|
10
|
+
Using the identical site for both is not a convenience -- it is a correctness
|
|
11
|
+
requirement. An SAE decoder direction is only meaningful in the coordinate
|
|
12
|
+
system it was fitted in, so injecting at any other site would be adding a
|
|
13
|
+
vector that means nothing there.
|
|
14
|
+
|
|
15
|
+
Decoder blocks in transformers return either a bare tensor or a tuple whose
|
|
16
|
+
first element is the hidden state, depending on version and config. Both shapes
|
|
17
|
+
are handled, and the tuple is rebuilt rather than mutated so that nothing
|
|
18
|
+
downstream sees a half-modified structure.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
from typing import Any, Callable
|
|
24
|
+
|
|
25
|
+
import torch
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _split_output(output: Any) -> tuple[torch.Tensor, Callable[[torch.Tensor], Any]]:
|
|
29
|
+
"""Extract the hidden-state tensor and a rebuilder for the block output.
|
|
30
|
+
|
|
31
|
+
Returns
|
|
32
|
+
-------
|
|
33
|
+
(hidden_states, rebuild)
|
|
34
|
+
``rebuild(new_tensor)`` reconstructs the original container type with
|
|
35
|
+
the tensor replaced.
|
|
36
|
+
"""
|
|
37
|
+
if isinstance(output, torch.Tensor):
|
|
38
|
+
return output, lambda t: t
|
|
39
|
+
if isinstance(output, tuple):
|
|
40
|
+
if not output or not isinstance(output[0], torch.Tensor):
|
|
41
|
+
raise TypeError(f"unexpected decoder block output tuple: {type(output)}")
|
|
42
|
+
rest = output[1:]
|
|
43
|
+
return output[0], lambda t: (t, *rest)
|
|
44
|
+
# Some versions return a ModelOutput-like object with .last_hidden_state.
|
|
45
|
+
hidden = getattr(output, "last_hidden_state", None)
|
|
46
|
+
if isinstance(hidden, torch.Tensor):
|
|
47
|
+
|
|
48
|
+
def rebuild(t: torch.Tensor, _out: Any = output) -> Any:
|
|
49
|
+
_out.last_hidden_state = t
|
|
50
|
+
return _out
|
|
51
|
+
|
|
52
|
+
return hidden, rebuild
|
|
53
|
+
raise TypeError(f"cannot locate hidden states in decoder block output of type {type(output)}")
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class ResidualCapture:
|
|
57
|
+
"""Capture the residual stream at one decoder block.
|
|
58
|
+
|
|
59
|
+
Usage::
|
|
60
|
+
|
|
61
|
+
capture = ResidualCapture()
|
|
62
|
+
handle = capture.attach(bundle.layer_module(18))
|
|
63
|
+
with torch.inference_mode():
|
|
64
|
+
model(**batch)
|
|
65
|
+
acts = capture.activations # [batch, seq, hidden]
|
|
66
|
+
handle.remove()
|
|
67
|
+
|
|
68
|
+
The captured tensor is *detached* and optionally moved off-GPU immediately,
|
|
69
|
+
so a long extraction run does not accumulate VRAM.
|
|
70
|
+
"""
|
|
71
|
+
|
|
72
|
+
def __init__(self, *, to_cpu: bool = True, dtype: torch.dtype | None = None) -> None:
|
|
73
|
+
self.to_cpu = to_cpu
|
|
74
|
+
self.dtype = dtype
|
|
75
|
+
self.activations: torch.Tensor | None = None
|
|
76
|
+
self._handle: Any = None
|
|
77
|
+
|
|
78
|
+
def __call__(self, module: Any, args: Any, output: Any) -> None:
|
|
79
|
+
hidden, _ = _split_output(output)
|
|
80
|
+
tensor = hidden.detach()
|
|
81
|
+
if self.dtype is not None:
|
|
82
|
+
tensor = tensor.to(self.dtype)
|
|
83
|
+
if self.to_cpu:
|
|
84
|
+
tensor = tensor.to("cpu")
|
|
85
|
+
self.activations = tensor
|
|
86
|
+
|
|
87
|
+
def attach(self, module: Any) -> Any:
|
|
88
|
+
"""Register on ``module`` and return the removable handle."""
|
|
89
|
+
self._handle = module.register_forward_hook(self)
|
|
90
|
+
return self._handle
|
|
91
|
+
|
|
92
|
+
def remove(self) -> None:
|
|
93
|
+
if self._handle is not None:
|
|
94
|
+
self._handle.remove()
|
|
95
|
+
self._handle = None
|
|
96
|
+
|
|
97
|
+
def __enter__(self) -> "ResidualCapture":
|
|
98
|
+
return self
|
|
99
|
+
|
|
100
|
+
def __exit__(self, *exc: Any) -> None:
|
|
101
|
+
self.remove()
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
class ResidualInjector:
|
|
105
|
+
"""Add a per-token vector to the residual stream at one decoder block.
|
|
106
|
+
|
|
107
|
+
The injected vector is supplied by a callback rather than fixed at
|
|
108
|
+
construction, because in dynamic steering it changes with the generated
|
|
109
|
+
token index. The callback receives the current hidden states and returns
|
|
110
|
+
either a broadcastable tensor to add, or ``None`` for "do nothing".
|
|
111
|
+
|
|
112
|
+
Returning ``None`` is the mechanism that makes ``strength=0`` *identical*
|
|
113
|
+
to baseline rather than approximately equal: no arithmetic is performed on
|
|
114
|
+
the tensor at all, so there is not even a float round-trip to differ on.
|
|
115
|
+
"""
|
|
116
|
+
|
|
117
|
+
def __init__(
|
|
118
|
+
self,
|
|
119
|
+
delta_fn: Callable[[torch.Tensor], torch.Tensor | None],
|
|
120
|
+
*,
|
|
121
|
+
name: str = "injector",
|
|
122
|
+
) -> None:
|
|
123
|
+
self.delta_fn = delta_fn
|
|
124
|
+
self.name = name
|
|
125
|
+
self._handle: Any = None
|
|
126
|
+
#: Incremented every time a non-None delta is actually applied.
|
|
127
|
+
self.apply_count = 0
|
|
128
|
+
#: Incremented on every forward pass through the hooked module.
|
|
129
|
+
self.call_count = 0
|
|
130
|
+
|
|
131
|
+
def __call__(self, module: Any, args: Any, output: Any) -> Any:
|
|
132
|
+
self.call_count += 1
|
|
133
|
+
hidden, rebuild = _split_output(output)
|
|
134
|
+
delta = self.delta_fn(hidden)
|
|
135
|
+
if delta is None:
|
|
136
|
+
# Untouched: bit-identical to running without the hook.
|
|
137
|
+
return output
|
|
138
|
+
self.apply_count += 1
|
|
139
|
+
modified = hidden + delta.to(dtype=hidden.dtype, device=hidden.device)
|
|
140
|
+
return rebuild(modified)
|
|
141
|
+
|
|
142
|
+
def attach(self, module: Any) -> Any:
|
|
143
|
+
self._handle = module.register_forward_hook(self)
|
|
144
|
+
return self._handle
|
|
145
|
+
|
|
146
|
+
def remove(self) -> None:
|
|
147
|
+
if self._handle is not None:
|
|
148
|
+
self._handle.remove()
|
|
149
|
+
self._handle = None
|
|
150
|
+
|
|
151
|
+
def __enter__(self) -> "ResidualInjector":
|
|
152
|
+
return self
|
|
153
|
+
|
|
154
|
+
def __exit__(self, *exc: Any) -> None:
|
|
155
|
+
self.remove()
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
class HookSet:
|
|
159
|
+
"""Context manager owning several hook handles at once.
|
|
160
|
+
|
|
161
|
+
Guarantees removal even if the forward pass raises, which matters because a
|
|
162
|
+
leaked injection hook would silently contaminate every subsequent
|
|
163
|
+
generation in the same process -- including the "baseline" ones.
|
|
164
|
+
"""
|
|
165
|
+
|
|
166
|
+
def __init__(self) -> None:
|
|
167
|
+
self._hooks: list[Any] = []
|
|
168
|
+
|
|
169
|
+
def add(self, hook: ResidualCapture | ResidualInjector, module: Any) -> Any:
|
|
170
|
+
handle = hook.attach(module)
|
|
171
|
+
self._hooks.append(hook)
|
|
172
|
+
return handle
|
|
173
|
+
|
|
174
|
+
def remove_all(self) -> None:
|
|
175
|
+
for hook in self._hooks:
|
|
176
|
+
hook.remove()
|
|
177
|
+
self._hooks.clear()
|
|
178
|
+
|
|
179
|
+
def __enter__(self) -> "HookSet":
|
|
180
|
+
return self
|
|
181
|
+
|
|
182
|
+
def __exit__(self, *exc: Any) -> None:
|
|
183
|
+
self.remove_all()
|