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,337 @@
|
|
|
1
|
+
"""Candidate-feature search from behavioural contrast data.
|
|
2
|
+
|
|
3
|
+
Pipeline::
|
|
4
|
+
|
|
5
|
+
contrast pairs -> activations for positive/negative responses
|
|
6
|
+
-> SAE encode
|
|
7
|
+
-> per-feature mean activation difference
|
|
8
|
+
-> ranked candidates (correlational)
|
|
9
|
+
-> causal test with controls (interventional)
|
|
10
|
+
-> strength sweep
|
|
11
|
+
-> greedy sparse combination
|
|
12
|
+
-> held-out evaluation
|
|
13
|
+
|
|
14
|
+
Only the first three steps are cheap. Everything after that costs GPU time per
|
|
15
|
+
candidate, which is why :func:`rank_candidate_features` returns a *small*
|
|
16
|
+
shortlist and the causal stages take an explicit budget. A search that fans out
|
|
17
|
+
over hundreds of candidates is how a $10 budget disappears.
|
|
18
|
+
|
|
19
|
+
The ranking step produces correlational evidence only. A feature that activates
|
|
20
|
+
more on "independent criticism" continuations than on "agreeable" ones is a
|
|
21
|
+
feature that *tracks* that distinction in this fixture -- it is not an
|
|
22
|
+
anti-sycophancy control knob until an intervention says so.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
from __future__ import annotations
|
|
26
|
+
|
|
27
|
+
from dataclasses import dataclass, field
|
|
28
|
+
from typing import Any, Sequence
|
|
29
|
+
|
|
30
|
+
import torch
|
|
31
|
+
|
|
32
|
+
from brainpatch.research.ml.generation import build_chat_prompt
|
|
33
|
+
from brainpatch.research.ml.hooks import ResidualCapture
|
|
34
|
+
from brainpatch.research.ml.model import ModelBundle
|
|
35
|
+
from brainpatch.research.ml.sae import TopKSAE
|
|
36
|
+
from brainpatch.schemas.contrast import ContrastSet
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@dataclass
|
|
40
|
+
class CandidateFeature:
|
|
41
|
+
"""A feature ranked by its activation difference across a contrast set."""
|
|
42
|
+
|
|
43
|
+
feature_id: int
|
|
44
|
+
mean_diff: float
|
|
45
|
+
"""Mean(positive activation) - Mean(negative activation), normalized space."""
|
|
46
|
+
mean_positive: float
|
|
47
|
+
mean_negative: float
|
|
48
|
+
positive_fire_rate: float
|
|
49
|
+
negative_fire_rate: float
|
|
50
|
+
effect_size: float
|
|
51
|
+
"""Standardised difference (Cohen's d style), using pooled per-token std."""
|
|
52
|
+
|
|
53
|
+
def to_dict(self) -> dict[str, Any]:
|
|
54
|
+
return {
|
|
55
|
+
"feature_id": self.feature_id,
|
|
56
|
+
"mean_diff": self.mean_diff,
|
|
57
|
+
"mean_positive": self.mean_positive,
|
|
58
|
+
"mean_negative": self.mean_negative,
|
|
59
|
+
"positive_fire_rate": self.positive_fire_rate,
|
|
60
|
+
"negative_fire_rate": self.negative_fire_rate,
|
|
61
|
+
"effect_size": self.effect_size,
|
|
62
|
+
"evidence": "correlational -- activation difference only, no causal test",
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
@torch.inference_mode()
|
|
67
|
+
def collect_response_activations(
|
|
68
|
+
bundle: ModelBundle,
|
|
69
|
+
texts: Sequence[str],
|
|
70
|
+
*,
|
|
71
|
+
layer: int,
|
|
72
|
+
prompts: Sequence[str] | None = None,
|
|
73
|
+
max_length: int = 384,
|
|
74
|
+
) -> torch.Tensor:
|
|
75
|
+
"""Capture residual activations over the *response* tokens only.
|
|
76
|
+
|
|
77
|
+
When ``prompts`` is supplied, activations from the prompt portion are
|
|
78
|
+
excluded. This matters: the prompt is identical across a contrast pair, so
|
|
79
|
+
including its activations would dilute the signal with tokens that carry no
|
|
80
|
+
information about the contrast.
|
|
81
|
+
"""
|
|
82
|
+
capture = ResidualCapture(to_cpu=True, dtype=torch.float32)
|
|
83
|
+
handle = capture.attach(bundle.layer_module(layer))
|
|
84
|
+
collected: list[torch.Tensor] = []
|
|
85
|
+
try:
|
|
86
|
+
for i, text in enumerate(texts):
|
|
87
|
+
prompt = prompts[i] if prompts is not None else None
|
|
88
|
+
full = (prompt + text) if prompt else text
|
|
89
|
+
encoded = bundle.tokenizer(
|
|
90
|
+
full, return_tensors="pt", truncation=True, max_length=max_length
|
|
91
|
+
).to(bundle.device)
|
|
92
|
+
capture.activations = None
|
|
93
|
+
bundle.model(**encoded, use_cache=False)
|
|
94
|
+
hidden = capture.activations
|
|
95
|
+
if hidden is None:
|
|
96
|
+
raise RuntimeError("capture hook did not fire during contrast collection")
|
|
97
|
+
|
|
98
|
+
start = 0
|
|
99
|
+
if prompt:
|
|
100
|
+
prompt_len = bundle.tokenizer(
|
|
101
|
+
prompt, return_tensors="pt", truncation=True, max_length=max_length
|
|
102
|
+
).input_ids.shape[1]
|
|
103
|
+
start = min(prompt_len, hidden.shape[1] - 1)
|
|
104
|
+
collected.append(hidden[0, start:, :])
|
|
105
|
+
finally:
|
|
106
|
+
handle.remove()
|
|
107
|
+
return torch.cat(collected, dim=0)
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def rank_candidate_features(
|
|
111
|
+
bundle: ModelBundle,
|
|
112
|
+
sae: TopKSAE,
|
|
113
|
+
contrast_set: ContrastSet,
|
|
114
|
+
*,
|
|
115
|
+
layer: int,
|
|
116
|
+
input_scale: float,
|
|
117
|
+
top_n: int = 20,
|
|
118
|
+
use_chat_template: bool = True,
|
|
119
|
+
min_fire_rate: float = 0.01,
|
|
120
|
+
max_fire_rate: float = 0.9,
|
|
121
|
+
) -> list[CandidateFeature]:
|
|
122
|
+
"""Rank features by how differently they fire on positive vs negative responses.
|
|
123
|
+
|
|
124
|
+
Parameters
|
|
125
|
+
----------
|
|
126
|
+
min_fire_rate, max_fire_rate:
|
|
127
|
+
Discard features that essentially never fire (no signal) or fire almost
|
|
128
|
+
everywhere (modelling something global rather than the contrast).
|
|
129
|
+
|
|
130
|
+
Returns
|
|
131
|
+
-------
|
|
132
|
+
list[CandidateFeature]
|
|
133
|
+
Sorted by absolute effect size, longest-first, truncated to ``top_n``.
|
|
134
|
+
**Correlational evidence only.**
|
|
135
|
+
"""
|
|
136
|
+
prompts = [
|
|
137
|
+
build_chat_prompt(bundle.tokenizer, e.prompt) if use_chat_template else e.prompt
|
|
138
|
+
for e in contrast_set
|
|
139
|
+
]
|
|
140
|
+
positives = [e.positive_response for e in contrast_set]
|
|
141
|
+
negatives = [e.negative_response for e in contrast_set]
|
|
142
|
+
|
|
143
|
+
pos_acts = collect_response_activations(bundle, positives, layer=layer, prompts=prompts)
|
|
144
|
+
neg_acts = collect_response_activations(bundle, negatives, layer=layer, prompts=prompts)
|
|
145
|
+
|
|
146
|
+
sae.eval()
|
|
147
|
+
device = next(sae.parameters()).device
|
|
148
|
+
pos_features = _encode_in_batches(sae, pos_acts, input_scale, device)
|
|
149
|
+
neg_features = _encode_in_batches(sae, neg_acts, input_scale, device)
|
|
150
|
+
|
|
151
|
+
pos_mean = pos_features.mean(dim=0)
|
|
152
|
+
neg_mean = neg_features.mean(dim=0)
|
|
153
|
+
pos_rate = (pos_features > 0).float().mean(dim=0)
|
|
154
|
+
neg_rate = (neg_features > 0).float().mean(dim=0)
|
|
155
|
+
|
|
156
|
+
pooled_std = torch.sqrt(
|
|
157
|
+
(pos_features.var(dim=0, unbiased=False) + neg_features.var(dim=0, unbiased=False)) / 2
|
|
158
|
+
).clamp_min(1e-6)
|
|
159
|
+
diff = pos_mean - neg_mean
|
|
160
|
+
effect = diff / pooled_std
|
|
161
|
+
|
|
162
|
+
overall_rate = (pos_rate + neg_rate) / 2
|
|
163
|
+
eligible = (overall_rate >= min_fire_rate) & (overall_rate <= max_fire_rate)
|
|
164
|
+
effect = torch.where(eligible, effect, torch.zeros_like(effect))
|
|
165
|
+
|
|
166
|
+
order = torch.argsort(effect.abs(), descending=True)[:top_n]
|
|
167
|
+
return [
|
|
168
|
+
CandidateFeature(
|
|
169
|
+
feature_id=int(fid),
|
|
170
|
+
mean_diff=float(diff[fid].item()),
|
|
171
|
+
mean_positive=float(pos_mean[fid].item()),
|
|
172
|
+
mean_negative=float(neg_mean[fid].item()),
|
|
173
|
+
positive_fire_rate=float(pos_rate[fid].item()),
|
|
174
|
+
negative_fire_rate=float(neg_rate[fid].item()),
|
|
175
|
+
effect_size=float(effect[fid].item()),
|
|
176
|
+
)
|
|
177
|
+
for fid in order.tolist()
|
|
178
|
+
if effect[fid] != 0
|
|
179
|
+
]
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def _encode_in_batches(
|
|
183
|
+
sae: TopKSAE, acts: torch.Tensor, input_scale: float, device: torch.device, batch: int = 4096
|
|
184
|
+
) -> torch.Tensor:
|
|
185
|
+
chunks: list[torch.Tensor] = []
|
|
186
|
+
for start in range(0, acts.shape[0], batch):
|
|
187
|
+
x = acts[start : start + batch].to(device=device, dtype=torch.float32) * input_scale
|
|
188
|
+
sparse, _, _ = sae.encode(x)
|
|
189
|
+
chunks.append(sparse.cpu())
|
|
190
|
+
return torch.cat(chunks, dim=0)
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
@dataclass
|
|
194
|
+
class SweepPoint:
|
|
195
|
+
"""One point of a strength sweep."""
|
|
196
|
+
|
|
197
|
+
strength: float
|
|
198
|
+
mean_divergence: float
|
|
199
|
+
mean_words: float
|
|
200
|
+
degeneration_rate: float
|
|
201
|
+
generations: list[str] = field(default_factory=list)
|
|
202
|
+
|
|
203
|
+
def to_dict(self) -> dict[str, Any]:
|
|
204
|
+
return {
|
|
205
|
+
"strength": self.strength,
|
|
206
|
+
"mean_divergence": self.mean_divergence,
|
|
207
|
+
"mean_words": self.mean_words,
|
|
208
|
+
"degeneration_rate": self.degeneration_rate,
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def strength_sweep(
|
|
213
|
+
model: Any,
|
|
214
|
+
prompts: Sequence[str],
|
|
215
|
+
*,
|
|
216
|
+
feature_id: int,
|
|
217
|
+
strengths: Sequence[float],
|
|
218
|
+
generation: Any = None,
|
|
219
|
+
) -> list[SweepPoint]:
|
|
220
|
+
"""Sweep one feature's strength and measure divergence and degeneration.
|
|
221
|
+
|
|
222
|
+
The purpose is to find the window where an intervention changes behaviour
|
|
223
|
+
*before* it destroys fluency. Every sweep point costs
|
|
224
|
+
``len(prompts)`` generations, so keep both lists short.
|
|
225
|
+
"""
|
|
226
|
+
from brainpatch.evaluation.metrics import jaccard_similarity, score_generation
|
|
227
|
+
from brainpatch.research.ml.causal import _spec_for
|
|
228
|
+
from brainpatch.research.ml.generation import GenerationConfig
|
|
229
|
+
|
|
230
|
+
cfg = generation or GenerationConfig(max_new_tokens=96)
|
|
231
|
+
saved = dict(model.plan.patches)
|
|
232
|
+
|
|
233
|
+
model.plan.patches = {}
|
|
234
|
+
baselines = [model.generate(p, config=cfg) for p in prompts]
|
|
235
|
+
|
|
236
|
+
points: list[SweepPoint] = []
|
|
237
|
+
for strength in strengths:
|
|
238
|
+
divergences: list[float] = []
|
|
239
|
+
words: list[int] = []
|
|
240
|
+
degenerate = 0
|
|
241
|
+
texts: list[str] = []
|
|
242
|
+
for prompt, baseline in zip(prompts, baselines):
|
|
243
|
+
model.plan.patches = {}
|
|
244
|
+
model.install(_spec_for(model, feature_id, strength, f"sweep-{feature_id}"))
|
|
245
|
+
text = model.generate(prompt, config=cfg)
|
|
246
|
+
metrics = score_generation(text)
|
|
247
|
+
divergences.append(1.0 - jaccard_similarity(baseline, text, n=3))
|
|
248
|
+
words.append(metrics.num_words)
|
|
249
|
+
degenerate += int(metrics.degeneration_flag)
|
|
250
|
+
texts.append(text)
|
|
251
|
+
points.append(
|
|
252
|
+
SweepPoint(
|
|
253
|
+
strength=float(strength),
|
|
254
|
+
mean_divergence=sum(divergences) / len(divergences),
|
|
255
|
+
mean_words=sum(words) / len(words),
|
|
256
|
+
degeneration_rate=degenerate / len(prompts),
|
|
257
|
+
generations=texts,
|
|
258
|
+
)
|
|
259
|
+
)
|
|
260
|
+
|
|
261
|
+
model.plan.patches = saved
|
|
262
|
+
return points
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
def greedy_feature_selection(
|
|
266
|
+
model: Any,
|
|
267
|
+
prompts: Sequence[str],
|
|
268
|
+
candidates: Sequence[CandidateFeature],
|
|
269
|
+
*,
|
|
270
|
+
strength: float,
|
|
271
|
+
max_features: int = 3,
|
|
272
|
+
generation: Any = None,
|
|
273
|
+
degeneration_ceiling: float = 0.25,
|
|
274
|
+
) -> list[tuple[int, float]]:
|
|
275
|
+
"""Greedily build a sparse multi-feature patch.
|
|
276
|
+
|
|
277
|
+
At each round, every remaining candidate is added tentatively and the one
|
|
278
|
+
producing the largest divergence-from-baseline *without* pushing the
|
|
279
|
+
degeneration rate above ``degeneration_ceiling`` is kept. Selection stops
|
|
280
|
+
when no candidate improves the objective.
|
|
281
|
+
|
|
282
|
+
Cost is ``O(max_features * len(candidates) * len(prompts))`` generations.
|
|
283
|
+
Keep the candidate list to single digits.
|
|
284
|
+
"""
|
|
285
|
+
from brainpatch.evaluation.metrics import jaccard_similarity, score_generation
|
|
286
|
+
from brainpatch.research.ml.causal import _spec_for
|
|
287
|
+
from brainpatch.research.ml.generation import GenerationConfig
|
|
288
|
+
from brainpatch.schemas.patch import BrainPatchSpec, FeatureEdit
|
|
289
|
+
|
|
290
|
+
cfg = generation or GenerationConfig(max_new_tokens=96)
|
|
291
|
+
saved = dict(model.plan.patches)
|
|
292
|
+
|
|
293
|
+
model.plan.patches = {}
|
|
294
|
+
baselines = [model.generate(p, config=cfg) for p in prompts]
|
|
295
|
+
|
|
296
|
+
selected: list[tuple[int, float]] = []
|
|
297
|
+
remaining = [c.feature_id for c in candidates]
|
|
298
|
+
best_score = 0.0
|
|
299
|
+
|
|
300
|
+
def evaluate(edits: list[tuple[int, float]]) -> tuple[float, float]:
|
|
301
|
+
base_spec = _spec_for(model, edits[0][0], edits[0][1], "greedy")
|
|
302
|
+
spec = BrainPatchSpec(
|
|
303
|
+
name="greedy",
|
|
304
|
+
base_model=base_spec.base_model,
|
|
305
|
+
model_revision=base_spec.model_revision,
|
|
306
|
+
sae=base_spec.sae,
|
|
307
|
+
features=[FeatureEdit(feature_id=f, strength=s) for f, s in edits],
|
|
308
|
+
description="Transient greedy-search candidate.",
|
|
309
|
+
)
|
|
310
|
+
divergences: list[float] = []
|
|
311
|
+
degenerate = 0
|
|
312
|
+
for prompt, baseline in zip(prompts, baselines):
|
|
313
|
+
model.plan.patches = {}
|
|
314
|
+
model.install(spec)
|
|
315
|
+
text = model.generate(prompt, config=cfg)
|
|
316
|
+
divergences.append(1.0 - jaccard_similarity(baseline, text, n=3))
|
|
317
|
+
degenerate += int(score_generation(text).degeneration_flag)
|
|
318
|
+
return sum(divergences) / len(divergences), degenerate / len(prompts)
|
|
319
|
+
|
|
320
|
+
for _ in range(max_features):
|
|
321
|
+
best_feature: int | None = None
|
|
322
|
+
best_round = best_score
|
|
323
|
+
for feature_id in list(remaining):
|
|
324
|
+
score, degeneration = evaluate([*selected, (feature_id, strength)])
|
|
325
|
+
if degeneration > degeneration_ceiling:
|
|
326
|
+
continue
|
|
327
|
+
if score > best_round:
|
|
328
|
+
best_round = score
|
|
329
|
+
best_feature = feature_id
|
|
330
|
+
if best_feature is None:
|
|
331
|
+
break
|
|
332
|
+
selected.append((best_feature, strength))
|
|
333
|
+
remaining.remove(best_feature)
|
|
334
|
+
best_score = best_round
|
|
335
|
+
|
|
336
|
+
model.plan.patches = saved
|
|
337
|
+
return selected
|
|
@@ -0,0 +1,343 @@
|
|
|
1
|
+
"""``BrainPatchedModel`` -- the user-facing runtime.
|
|
2
|
+
|
|
3
|
+
::
|
|
4
|
+
|
|
5
|
+
from brainpatch import BrainPatchedModel
|
|
6
|
+
|
|
7
|
+
model = BrainPatchedModel.from_pretrained("Qwen/Qwen2.5-1.5B-Instruct")
|
|
8
|
+
model.load_sae("/vol/sae/smoke_v0/sae_latest.pt", reference="smoke_v0")
|
|
9
|
+
model.install("patches/experimental-feature-1207.json")
|
|
10
|
+
model.set_patch_strength("experimental-feature-1207", 1.5)
|
|
11
|
+
print(model.generate("Solve this problem..."))
|
|
12
|
+
|
|
13
|
+
The base model is frozen. Every behavioural change comes from a hook that adds
|
|
14
|
+
a vector to one layer's residual stream, installed for the duration of a
|
|
15
|
+
generation and removed afterwards.
|
|
16
|
+
|
|
17
|
+
Two invariants this class exists to guarantee:
|
|
18
|
+
|
|
19
|
+
1. **No patches installed, or all strengths zero, is exactly baseline.** With
|
|
20
|
+
nothing to apply, the hook returns ``None`` and the tensor is never touched.
|
|
21
|
+
:meth:`assert_zero_strength_is_baseline` verifies this empirically rather
|
|
22
|
+
than trusting the argument.
|
|
23
|
+
|
|
24
|
+
2. **A patch cannot be applied to the wrong model.** Every install runs
|
|
25
|
+
:meth:`~brainpatch.schemas.patch.BrainPatchSpec.check_compatibility` against
|
|
26
|
+
the loaded weights and SAE.
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
from __future__ import annotations
|
|
30
|
+
|
|
31
|
+
import os
|
|
32
|
+
from pathlib import Path
|
|
33
|
+
from typing import Any
|
|
34
|
+
|
|
35
|
+
import torch
|
|
36
|
+
|
|
37
|
+
from brainpatch.research.ml.generation import GenerationConfig, build_chat_prompt
|
|
38
|
+
from brainpatch.research.ml.hooks import HookSet
|
|
39
|
+
from brainpatch.research.ml.intervention import FeatureSteerer, make_steerer
|
|
40
|
+
from brainpatch.research.ml.model import DEFAULT_MODEL, ModelBundle, load_model
|
|
41
|
+
from brainpatch.research.ml.sae import TopKSAE
|
|
42
|
+
from brainpatch.schemas.patch_io import load_patch
|
|
43
|
+
from brainpatch.schemas.patch import BrainPatchSpec, FeatureEdit, SAEReference
|
|
44
|
+
from brainpatch.steering.plan import InterventionPlan
|
|
45
|
+
from brainpatch.steering.schedule import StrengthSchedule
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class BrainPatchedModel:
|
|
49
|
+
"""A frozen language model with installable activation-space patches."""
|
|
50
|
+
|
|
51
|
+
def __init__(self, bundle: ModelBundle) -> None:
|
|
52
|
+
self.bundle = bundle
|
|
53
|
+
self.plan = InterventionPlan()
|
|
54
|
+
self.sae: TopKSAE | None = None
|
|
55
|
+
self.sae_reference: str | None = None
|
|
56
|
+
self.input_scale: float | None = None
|
|
57
|
+
self._last_stats: dict[str, Any] = {}
|
|
58
|
+
self._last_trace: list[tuple[int, float]] = []
|
|
59
|
+
|
|
60
|
+
# -- construction ----------------------------------------------------------
|
|
61
|
+
|
|
62
|
+
@classmethod
|
|
63
|
+
def from_pretrained(
|
|
64
|
+
cls,
|
|
65
|
+
model_id: str = DEFAULT_MODEL,
|
|
66
|
+
*,
|
|
67
|
+
revision: str | None = None,
|
|
68
|
+
dtype: str = "bfloat16",
|
|
69
|
+
device: str = "cuda",
|
|
70
|
+
) -> "BrainPatchedModel":
|
|
71
|
+
"""Load a frozen base model. Downloads go to the HF cache, never locally."""
|
|
72
|
+
return cls(load_model(model_id, revision=revision, dtype=dtype, device=device))
|
|
73
|
+
|
|
74
|
+
def load_sae(
|
|
75
|
+
self,
|
|
76
|
+
checkpoint_path: str | os.PathLike[str],
|
|
77
|
+
*,
|
|
78
|
+
reference: str,
|
|
79
|
+
input_scale: float | None = None,
|
|
80
|
+
) -> TopKSAE:
|
|
81
|
+
"""Attach a trained SAE, whose decoder columns become patch directions.
|
|
82
|
+
|
|
83
|
+
Raises
|
|
84
|
+
------
|
|
85
|
+
ValueError
|
|
86
|
+
If the SAE's input width does not match the model's residual width,
|
|
87
|
+
which would mean the two were never trained on the same activations.
|
|
88
|
+
"""
|
|
89
|
+
path = Path(checkpoint_path)
|
|
90
|
+
if not path.is_file():
|
|
91
|
+
raise FileNotFoundError(f"SAE checkpoint not found: {path}")
|
|
92
|
+
checkpoint = torch.load(path, map_location="cpu", weights_only=False)
|
|
93
|
+
sae = TopKSAE.from_checkpoint(checkpoint, device=str(self.bundle.device))
|
|
94
|
+
|
|
95
|
+
if sae.d_in != self.bundle.hidden_size:
|
|
96
|
+
raise ValueError(
|
|
97
|
+
f"SAE was trained on width {sae.d_in} but the model's residual "
|
|
98
|
+
f"stream is {self.bundle.hidden_size} wide -- these are not the "
|
|
99
|
+
"same activations"
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
scale = input_scale if input_scale is not None else sae.config.input_scale
|
|
103
|
+
if scale is None:
|
|
104
|
+
raise ValueError(
|
|
105
|
+
"SAE checkpoint has no recorded input_scale, so a strength value "
|
|
106
|
+
"would have no defined magnitude. Pass input_scale explicitly."
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
self.sae = sae
|
|
110
|
+
self.sae_reference = reference
|
|
111
|
+
self.input_scale = float(scale)
|
|
112
|
+
return sae
|
|
113
|
+
|
|
114
|
+
# -- patch management ------------------------------------------------------
|
|
115
|
+
|
|
116
|
+
def install(
|
|
117
|
+
self,
|
|
118
|
+
patch: str | os.PathLike[str] | BrainPatchSpec,
|
|
119
|
+
*,
|
|
120
|
+
strength: float = 1.0,
|
|
121
|
+
strict_revision: bool = False,
|
|
122
|
+
) -> BrainPatchSpec:
|
|
123
|
+
"""Install a patch from a file path or an in-memory spec.
|
|
124
|
+
|
|
125
|
+
The compatibility check runs before anything is registered, so a
|
|
126
|
+
rejected patch leaves the runtime untouched.
|
|
127
|
+
"""
|
|
128
|
+
spec = patch if isinstance(patch, BrainPatchSpec) else load_patch(patch)
|
|
129
|
+
spec.check_compatibility(
|
|
130
|
+
model=self.bundle.model_id,
|
|
131
|
+
hidden_size=self.bundle.hidden_size,
|
|
132
|
+
num_layers=self.bundle.num_layers,
|
|
133
|
+
model_revision=self.bundle.revision,
|
|
134
|
+
sae_reference=self.sae_reference,
|
|
135
|
+
sae_d_sae=self.sae.d_sae if self.sae is not None else None,
|
|
136
|
+
strict_revision=strict_revision,
|
|
137
|
+
)
|
|
138
|
+
self.plan.install(spec, strength=strength)
|
|
139
|
+
return spec
|
|
140
|
+
|
|
141
|
+
def uninstall(self, name: str) -> None:
|
|
142
|
+
self.plan.uninstall(name)
|
|
143
|
+
|
|
144
|
+
def list_patches(self) -> list[str]:
|
|
145
|
+
return list(self.plan.patches)
|
|
146
|
+
|
|
147
|
+
def set_patch_strength(self, name: str, strength: float) -> None:
|
|
148
|
+
"""Change a patch's strength. Takes effect on the next generation."""
|
|
149
|
+
self.plan.set_strength(name, strength)
|
|
150
|
+
|
|
151
|
+
def set_patch_enabled(self, name: str, enabled: bool) -> None:
|
|
152
|
+
self.plan.set_enabled(name, enabled)
|
|
153
|
+
|
|
154
|
+
def set_patch_schedule(self, name: str, schedule: dict[int, float] | StrengthSchedule | None) -> None:
|
|
155
|
+
"""Install a token-indexed strength schedule for dynamic steering."""
|
|
156
|
+
if isinstance(schedule, dict):
|
|
157
|
+
schedule = StrengthSchedule(schedule)
|
|
158
|
+
self.plan.set_schedule(name, schedule)
|
|
159
|
+
|
|
160
|
+
def add_feature(
|
|
161
|
+
self,
|
|
162
|
+
*,
|
|
163
|
+
layer: int,
|
|
164
|
+
feature_id: int,
|
|
165
|
+
strength: float,
|
|
166
|
+
name: str | None = None,
|
|
167
|
+
mode: str = "add",
|
|
168
|
+
) -> BrainPatchSpec:
|
|
169
|
+
"""Install an ad-hoc single-feature intervention.
|
|
170
|
+
|
|
171
|
+
The convenience path for exploration. It builds a real
|
|
172
|
+
:class:`BrainPatchSpec` under the hood, so an interactive experiment and
|
|
173
|
+
a shipped patch go through identical machinery -- and the resulting spec
|
|
174
|
+
can be saved directly.
|
|
175
|
+
"""
|
|
176
|
+
if self.sae is None or self.sae_reference is None or self.input_scale is None:
|
|
177
|
+
raise RuntimeError("load_sae() must be called before adding features")
|
|
178
|
+
if layer != self.bundle_layer_for_sae():
|
|
179
|
+
# Not fatal, but the SAE only means anything at the layer it was fitted on.
|
|
180
|
+
raise ValueError(
|
|
181
|
+
f"SAE {self.sae_reference!r} was trained at layer "
|
|
182
|
+
f"{self.sae.config.layer}; refusing to inject its directions at layer {layer}"
|
|
183
|
+
)
|
|
184
|
+
spec = BrainPatchSpec(
|
|
185
|
+
name=name or f"adhoc-feature-{feature_id}",
|
|
186
|
+
base_model=self.bundle.model_id,
|
|
187
|
+
model_revision=self.bundle.revision,
|
|
188
|
+
sae=SAEReference(
|
|
189
|
+
reference=self.sae_reference,
|
|
190
|
+
layer=layer,
|
|
191
|
+
hook=self.sae.config.hook or "residual_post",
|
|
192
|
+
d_in=self.sae.d_in,
|
|
193
|
+
d_sae=self.sae.d_sae,
|
|
194
|
+
input_scale=self.input_scale,
|
|
195
|
+
),
|
|
196
|
+
features=[FeatureEdit(feature_id=feature_id, strength=strength, mode=mode)],
|
|
197
|
+
description="Ad-hoc exploratory intervention. No evidence of any behavioural effect.",
|
|
198
|
+
evidence_level="none",
|
|
199
|
+
)
|
|
200
|
+
return self.install(spec)
|
|
201
|
+
|
|
202
|
+
def bundle_layer_for_sae(self) -> int:
|
|
203
|
+
"""The layer the attached SAE was trained on."""
|
|
204
|
+
if self.sae is None:
|
|
205
|
+
raise RuntimeError("no SAE loaded")
|
|
206
|
+
return int(self.sae.config.layer)
|
|
207
|
+
|
|
208
|
+
# -- generation ------------------------------------------------------------
|
|
209
|
+
|
|
210
|
+
def generate(
|
|
211
|
+
self,
|
|
212
|
+
prompt: str,
|
|
213
|
+
*,
|
|
214
|
+
config: GenerationConfig | None = None,
|
|
215
|
+
system: str | None = None,
|
|
216
|
+
use_chat_template: bool = True,
|
|
217
|
+
control: str = "none",
|
|
218
|
+
control_seed: int = 1234,
|
|
219
|
+
apply_to_prompt: bool = True,
|
|
220
|
+
) -> str:
|
|
221
|
+
"""Generate a completion with all installed patches active.
|
|
222
|
+
|
|
223
|
+
Parameters
|
|
224
|
+
----------
|
|
225
|
+
control:
|
|
226
|
+
``"random"`` swaps every feature direction for a scale-matched
|
|
227
|
+
random one, keeping all else identical. This is the control
|
|
228
|
+
condition, run through exactly the same code path as the real
|
|
229
|
+
intervention.
|
|
230
|
+
"""
|
|
231
|
+
cfg = config or GenerationConfig()
|
|
232
|
+
text = build_chat_prompt(self.bundle.tokenizer, prompt, system) if use_chat_template else prompt
|
|
233
|
+
inputs = self.bundle.tokenizer(text, return_tensors="pt").to(self.bundle.device)
|
|
234
|
+
|
|
235
|
+
if cfg.do_sample:
|
|
236
|
+
torch.manual_seed(cfg.seed)
|
|
237
|
+
|
|
238
|
+
steerer = self._build_steerer(control=control, control_seed=control_seed,
|
|
239
|
+
apply_to_prompt=apply_to_prompt)
|
|
240
|
+
|
|
241
|
+
with HookSet() as hooks:
|
|
242
|
+
if steerer is not None:
|
|
243
|
+
steerer.reset()
|
|
244
|
+
hooks.add(steerer.make_injector(), self.bundle.layer_module(steerer.layer))
|
|
245
|
+
with torch.inference_mode():
|
|
246
|
+
output = self.bundle.model.generate(
|
|
247
|
+
**inputs, **cfg.to_kwargs(self.bundle.tokenizer)
|
|
248
|
+
)
|
|
249
|
+
|
|
250
|
+
self._last_stats = steerer.stats.to_dict() if steerer is not None else {}
|
|
251
|
+
self._last_trace = list(steerer.stats.per_token_norms) if steerer is not None else []
|
|
252
|
+
generated = output[0, inputs["input_ids"].shape[1] :]
|
|
253
|
+
return self.bundle.tokenizer.decode(generated, skip_special_tokens=True)
|
|
254
|
+
|
|
255
|
+
@property
|
|
256
|
+
def last_steering_stats(self) -> dict[str, Any]:
|
|
257
|
+
"""Delta norms and application counts from the most recent generation.
|
|
258
|
+
|
|
259
|
+
The empirical answer to "did the intervention actually fire?" -- an
|
|
260
|
+
``applied_passes`` of 0 means the plan resolved to nothing.
|
|
261
|
+
"""
|
|
262
|
+
return dict(self._last_stats)
|
|
263
|
+
|
|
264
|
+
@property
|
|
265
|
+
def last_steering_trace(self) -> list[tuple[int, float]]:
|
|
266
|
+
"""``(generated_token_index, delta_norm)`` for every pass of the last run.
|
|
267
|
+
|
|
268
|
+
The evidence that a dynamic schedule fired where it was supposed to:
|
|
269
|
+
the norm should be zero before the keyframe and non-zero after.
|
|
270
|
+
"""
|
|
271
|
+
return list(self._last_trace)
|
|
272
|
+
|
|
273
|
+
def _build_steerer(
|
|
274
|
+
self, *, control: str, control_seed: int, apply_to_prompt: bool
|
|
275
|
+
) -> FeatureSteerer | None:
|
|
276
|
+
"""Construct a steerer, or ``None`` when there is nothing to apply."""
|
|
277
|
+
layers = self.plan.layers()
|
|
278
|
+
if not layers:
|
|
279
|
+
return None
|
|
280
|
+
if self.sae is None or self.input_scale is None:
|
|
281
|
+
raise RuntimeError("patches are installed but no SAE is loaded")
|
|
282
|
+
if len(layers) > 1:
|
|
283
|
+
raise NotImplementedError(
|
|
284
|
+
f"patches span layers {layers}; multi-layer steering needs one SAE "
|
|
285
|
+
"per layer and is not supported in v0"
|
|
286
|
+
)
|
|
287
|
+
return make_steerer(
|
|
288
|
+
self.sae,
|
|
289
|
+
self.plan,
|
|
290
|
+
layer=layers[0],
|
|
291
|
+
input_scale=self.input_scale,
|
|
292
|
+
device=self.bundle.device,
|
|
293
|
+
control=control,
|
|
294
|
+
control_seed=control_seed,
|
|
295
|
+
apply_to_prompt=apply_to_prompt,
|
|
296
|
+
)
|
|
297
|
+
|
|
298
|
+
# -- verification ----------------------------------------------------------
|
|
299
|
+
|
|
300
|
+
def assert_zero_strength_is_baseline(
|
|
301
|
+
self, prompt: str, *, config: GenerationConfig | None = None
|
|
302
|
+
) -> dict[str, Any]:
|
|
303
|
+
"""Empirically verify that zeroed patches reproduce baseline exactly.
|
|
304
|
+
|
|
305
|
+
Generates with every patch uninstalled, then with them installed at
|
|
306
|
+
strength 0, and compares the strings. This is a correctness test of the
|
|
307
|
+
hook machinery: if it ever fails, every "baseline" in every experiment
|
|
308
|
+
is suspect.
|
|
309
|
+
"""
|
|
310
|
+
cfg = config or GenerationConfig(max_new_tokens=48)
|
|
311
|
+
saved = {name: p.strength for name, p in self.plan.patches.items()}
|
|
312
|
+
|
|
313
|
+
installed = dict(self.plan.patches)
|
|
314
|
+
self.plan.patches = {}
|
|
315
|
+
baseline = self.generate(prompt, config=cfg)
|
|
316
|
+
|
|
317
|
+
self.plan.patches = installed
|
|
318
|
+
for name in self.plan.patches:
|
|
319
|
+
self.plan.set_strength(name, 0.0)
|
|
320
|
+
zeroed = self.generate(prompt, config=cfg)
|
|
321
|
+
zero_stats = self.last_steering_stats
|
|
322
|
+
|
|
323
|
+
for name, strength in saved.items():
|
|
324
|
+
self.plan.set_strength(name, strength)
|
|
325
|
+
|
|
326
|
+
return {
|
|
327
|
+
"identical": baseline == zeroed,
|
|
328
|
+
"baseline": baseline,
|
|
329
|
+
"zero_strength": zeroed,
|
|
330
|
+
"applied_passes_at_zero": zero_stats.get("applied_passes", 0),
|
|
331
|
+
"num_patches": len(saved),
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
def describe(self) -> dict[str, Any]:
|
|
335
|
+
return {
|
|
336
|
+
**self.bundle.describe(),
|
|
337
|
+
"sae_reference": self.sae_reference,
|
|
338
|
+
"sae_d_sae": self.sae.d_sae if self.sae else None,
|
|
339
|
+
"sae_k": self.sae.k if self.sae else None,
|
|
340
|
+
"sae_layer": self.sae.config.layer if self.sae else None,
|
|
341
|
+
"input_scale": self.input_scale,
|
|
342
|
+
"installed_patches": self.plan.describe(),
|
|
343
|
+
}
|