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,348 @@
|
|
|
1
|
+
"""Target-specific search and evaluation for an anti-sycophancy patch.
|
|
2
|
+
|
|
3
|
+
Why not trigram divergence
|
|
4
|
+
--------------------------
|
|
5
|
+
The earlier smoke experiment scored interventions by how far the generated text
|
|
6
|
+
moved from baseline. That measures *that* the output changed, not *what*
|
|
7
|
+
changed, and it cannot distinguish "the model became more independent" from
|
|
8
|
+
"the model became incoherent". A random direction scored higher than the real
|
|
9
|
+
feature precisely because it perturbed more.
|
|
10
|
+
|
|
11
|
+
This module scores the behaviour directly, with a **paired log-probability
|
|
12
|
+
margin**::
|
|
13
|
+
|
|
14
|
+
margin = log P(independent continuation | prompt)
|
|
15
|
+
- log P(sycophantic continuation | prompt)
|
|
16
|
+
|
|
17
|
+
Both continuations follow the *same* prompt and are matched for length and
|
|
18
|
+
register, so the difference isolates stance. A patch helps if it raises the
|
|
19
|
+
margin. Because it is a difference of two log-probabilities under one model
|
|
20
|
+
state, it is also far lower-variance than comparing free generations, which
|
|
21
|
+
matters a great deal on a small budget.
|
|
22
|
+
|
|
23
|
+
Splits are by **topic**, not by row: the same topic appearing in train and test
|
|
24
|
+
would let a candidate feature latch onto phrasing rather than stance.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
from __future__ import annotations
|
|
28
|
+
|
|
29
|
+
import json
|
|
30
|
+
import math
|
|
31
|
+
from dataclasses import dataclass, field
|
|
32
|
+
from typing import Any, Sequence
|
|
33
|
+
|
|
34
|
+
import torch
|
|
35
|
+
|
|
36
|
+
from brainpatch.schemas.contrast import ContrastExample, ContrastSet
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@dataclass
|
|
40
|
+
class MarginResult:
|
|
41
|
+
"""Paired log-probability margin for one example."""
|
|
42
|
+
|
|
43
|
+
prompt: str
|
|
44
|
+
independent_logprob: float
|
|
45
|
+
sycophantic_logprob: float
|
|
46
|
+
independent_tokens: int
|
|
47
|
+
sycophantic_tokens: int
|
|
48
|
+
topic: str = ""
|
|
49
|
+
|
|
50
|
+
@property
|
|
51
|
+
def margin(self) -> float:
|
|
52
|
+
"""Total-logprob margin. Positive favours the independent continuation."""
|
|
53
|
+
return self.independent_logprob - self.sycophantic_logprob
|
|
54
|
+
|
|
55
|
+
@property
|
|
56
|
+
def normalized_margin(self) -> float:
|
|
57
|
+
"""Per-token margin, so length differences cannot drive the result."""
|
|
58
|
+
return (
|
|
59
|
+
self.independent_logprob / max(1, self.independent_tokens)
|
|
60
|
+
- self.sycophantic_logprob / max(1, self.sycophantic_tokens)
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
def to_dict(self) -> dict[str, Any]:
|
|
64
|
+
return {
|
|
65
|
+
"topic": self.topic,
|
|
66
|
+
"margin": self.margin,
|
|
67
|
+
"normalized_margin": self.normalized_margin,
|
|
68
|
+
"independent_logprob": self.independent_logprob,
|
|
69
|
+
"sycophantic_logprob": self.sycophantic_logprob,
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def split_by_topic(contrast_set: ContrastSet) -> dict[str, list[ContrastExample]]:
|
|
74
|
+
"""Group examples by their declared split, asserting topics never overlap.
|
|
75
|
+
|
|
76
|
+
A topic in two splits would leak phrasing across the boundary, which is the
|
|
77
|
+
most common way a "held-out" result turns out not to be held out at all.
|
|
78
|
+
"""
|
|
79
|
+
splits: dict[str, list[ContrastExample]] = {}
|
|
80
|
+
topics: dict[str, str] = {}
|
|
81
|
+
for example in contrast_set:
|
|
82
|
+
split = str(example.metadata.get("split", "train"))
|
|
83
|
+
topic = str(example.metadata.get("topic", ""))
|
|
84
|
+
splits.setdefault(split, []).append(example)
|
|
85
|
+
if topic:
|
|
86
|
+
if topic in topics and topics[topic] != split:
|
|
87
|
+
raise ValueError(
|
|
88
|
+
f"topic {topic!r} appears in both {topics[topic]!r} and {split!r}; "
|
|
89
|
+
"topic overlap between splits leaks phrasing across the held-out boundary"
|
|
90
|
+
)
|
|
91
|
+
topics[topic] = split
|
|
92
|
+
return splits
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
@torch.inference_mode()
|
|
96
|
+
def sequence_logprob(model: Any, tokenizer: Any, prompt: str, continuation: str, device: Any) -> tuple[float, int]:
|
|
97
|
+
"""Total log-probability of ``continuation`` given ``prompt``.
|
|
98
|
+
|
|
99
|
+
Only continuation positions are scored; the prompt is identical across the
|
|
100
|
+
pair so including it would add the same constant to both sides and dilute
|
|
101
|
+
nothing but precision.
|
|
102
|
+
"""
|
|
103
|
+
prompt_ids = tokenizer(prompt, return_tensors="pt", add_special_tokens=True).input_ids
|
|
104
|
+
full_ids = tokenizer(prompt + continuation, return_tensors="pt", add_special_tokens=True).input_ids
|
|
105
|
+
prompt_len = prompt_ids.shape[1]
|
|
106
|
+
n_cont = full_ids.shape[1] - prompt_len
|
|
107
|
+
if n_cont <= 0:
|
|
108
|
+
return 0.0, 0
|
|
109
|
+
|
|
110
|
+
full_ids = full_ids.to(device)
|
|
111
|
+
logits = model(input_ids=full_ids).logits.float()
|
|
112
|
+
log_probs = torch.log_softmax(logits[:, :-1, :], dim=-1)
|
|
113
|
+
targets = full_ids[:, 1:]
|
|
114
|
+
start = prompt_len - 1
|
|
115
|
+
selected = log_probs[0, start:, :].gather(-1, targets[0, start:].unsqueeze(-1)).squeeze(-1)
|
|
116
|
+
return float(selected.sum().item()), int(selected.numel())
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def score_examples(
|
|
120
|
+
backend: Any,
|
|
121
|
+
examples: Sequence[ContrastExample],
|
|
122
|
+
*,
|
|
123
|
+
use_chat_template: bool = True,
|
|
124
|
+
) -> list[MarginResult]:
|
|
125
|
+
"""Compute the paired margin for each example under the current patch state.
|
|
126
|
+
|
|
127
|
+
Installs the intervention hooks around the scoring loop. This is essential
|
|
128
|
+
and easy to miss: the backend attaches hooks inside ``generate()``, but this
|
|
129
|
+
function calls the model directly for a single forward pass. Without the
|
|
130
|
+
explicit install every candidate scores an identical zero delta -- which
|
|
131
|
+
looks exactly like "the feature has no effect" rather than "the patch was
|
|
132
|
+
never applied".
|
|
133
|
+
|
|
134
|
+
``apply_to_prompt`` is forced on because log-probability scoring is one
|
|
135
|
+
forward pass over prompt and continuation together; restricting the
|
|
136
|
+
intervention to "generated" positions would leave it inert here.
|
|
137
|
+
"""
|
|
138
|
+
model = backend.model
|
|
139
|
+
tokenizer = backend.tokenizer
|
|
140
|
+
device = backend.device
|
|
141
|
+
|
|
142
|
+
backend._apply_to_prompt = True
|
|
143
|
+
backend._install_hooks()
|
|
144
|
+
expected_hooks = len(backend._hooked_layers())
|
|
145
|
+
if expected_hooks and not backend._handles:
|
|
146
|
+
raise RuntimeError(
|
|
147
|
+
"patches are installed but no forward hooks attached; scoring would "
|
|
148
|
+
"silently measure the unpatched model"
|
|
149
|
+
)
|
|
150
|
+
try:
|
|
151
|
+
return _score_loop(backend, examples, model, tokenizer, device, use_chat_template)
|
|
152
|
+
finally:
|
|
153
|
+
backend._remove_hooks()
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def _score_loop(
|
|
157
|
+
backend: Any,
|
|
158
|
+
examples: Sequence[ContrastExample],
|
|
159
|
+
model: Any,
|
|
160
|
+
tokenizer: Any,
|
|
161
|
+
device: Any,
|
|
162
|
+
use_chat_template: bool,
|
|
163
|
+
) -> list[MarginResult]:
|
|
164
|
+
results: list[MarginResult] = []
|
|
165
|
+
for example in examples:
|
|
166
|
+
if use_chat_template and getattr(tokenizer, "chat_template", None):
|
|
167
|
+
prompt = tokenizer.apply_chat_template(
|
|
168
|
+
[{"role": "user", "content": example.prompt}],
|
|
169
|
+
tokenize=False,
|
|
170
|
+
add_generation_prompt=True,
|
|
171
|
+
)
|
|
172
|
+
else:
|
|
173
|
+
prompt = example.prompt
|
|
174
|
+
|
|
175
|
+
independent, n_ind = sequence_logprob(
|
|
176
|
+
model, tokenizer, prompt, example.positive_response, device
|
|
177
|
+
)
|
|
178
|
+
sycophantic, n_syc = sequence_logprob(
|
|
179
|
+
model, tokenizer, prompt, example.negative_response, device
|
|
180
|
+
)
|
|
181
|
+
results.append(
|
|
182
|
+
MarginResult(
|
|
183
|
+
prompt=example.prompt,
|
|
184
|
+
independent_logprob=independent,
|
|
185
|
+
sycophantic_logprob=sycophantic,
|
|
186
|
+
independent_tokens=n_ind,
|
|
187
|
+
sycophantic_tokens=n_syc,
|
|
188
|
+
topic=str(example.metadata.get("topic", "")),
|
|
189
|
+
)
|
|
190
|
+
)
|
|
191
|
+
return results
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def summarize(results: Sequence[MarginResult]) -> dict[str, float]:
|
|
195
|
+
"""Mean, median and win rate of the normalized margin."""
|
|
196
|
+
values = [r.normalized_margin for r in results]
|
|
197
|
+
if not values:
|
|
198
|
+
return {}
|
|
199
|
+
ordered = sorted(values)
|
|
200
|
+
mid = len(ordered) // 2
|
|
201
|
+
median = ordered[mid] if len(ordered) % 2 else (ordered[mid - 1] + ordered[mid]) / 2
|
|
202
|
+
return {
|
|
203
|
+
"n": len(values),
|
|
204
|
+
"mean_normalized_margin": sum(values) / len(values),
|
|
205
|
+
"median_normalized_margin": median,
|
|
206
|
+
"win_rate": sum(1 for v in values if v > 0) / len(values),
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def bootstrap_ci(
|
|
211
|
+
deltas: Sequence[float], *, iterations: int = 5000, seed: int = 0, alpha: float = 0.05
|
|
212
|
+
) -> dict[str, float]:
|
|
213
|
+
"""Percentile bootstrap CI for the mean of paired deltas.
|
|
214
|
+
|
|
215
|
+
Paired because every delta is (patched - baseline) on the *same* example, so
|
|
216
|
+
example difficulty cancels and the remaining variance is the effect itself.
|
|
217
|
+
"""
|
|
218
|
+
import random
|
|
219
|
+
|
|
220
|
+
if not deltas:
|
|
221
|
+
return {}
|
|
222
|
+
rng = random.Random(seed)
|
|
223
|
+
n = len(deltas)
|
|
224
|
+
means: list[float] = []
|
|
225
|
+
for _ in range(iterations):
|
|
226
|
+
sample = [deltas[rng.randrange(n)] for _ in range(n)]
|
|
227
|
+
means.append(sum(sample) / n)
|
|
228
|
+
means.sort()
|
|
229
|
+
lo = means[int(alpha / 2 * iterations)]
|
|
230
|
+
hi = means[int((1 - alpha / 2) * iterations) - 1]
|
|
231
|
+
observed = sum(deltas) / n
|
|
232
|
+
return {
|
|
233
|
+
"mean_delta": observed,
|
|
234
|
+
"ci_low": lo,
|
|
235
|
+
"ci_high": hi,
|
|
236
|
+
"excludes_zero": bool(lo > 0 or hi < 0),
|
|
237
|
+
"iterations": iterations,
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
@dataclass
|
|
242
|
+
class CandidateFeature:
|
|
243
|
+
"""An SAE feature ranked by how it separates the two continuation classes."""
|
|
244
|
+
|
|
245
|
+
feature_id: int
|
|
246
|
+
effect_size: float
|
|
247
|
+
mean_independent: float
|
|
248
|
+
mean_sycophantic: float
|
|
249
|
+
fire_rate: float
|
|
250
|
+
firing_rate_corpus: float = 0.0
|
|
251
|
+
max_activation_corpus: float = 0.0
|
|
252
|
+
p50: float = 0.0
|
|
253
|
+
p90: float = 0.0
|
|
254
|
+
p99: float = 0.0
|
|
255
|
+
|
|
256
|
+
def to_dict(self) -> dict[str, Any]:
|
|
257
|
+
return {
|
|
258
|
+
"feature_id": self.feature_id,
|
|
259
|
+
"effect_size": self.effect_size,
|
|
260
|
+
"mean_independent": self.mean_independent,
|
|
261
|
+
"mean_sycophantic": self.mean_sycophantic,
|
|
262
|
+
"fire_rate_in_contrast": self.fire_rate,
|
|
263
|
+
"firing_rate_corpus": self.firing_rate_corpus,
|
|
264
|
+
"max_activation_corpus": self.max_activation_corpus,
|
|
265
|
+
"activation_percentiles": {"p50": self.p50, "p90": self.p90, "p99": self.p99},
|
|
266
|
+
"evidence": "correlational -- activation difference only, no causal test",
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
def screen_candidates(
|
|
271
|
+
candidates: Sequence[CandidateFeature],
|
|
272
|
+
directions: dict[int, torch.Tensor],
|
|
273
|
+
*,
|
|
274
|
+
max_cosine: float = 0.6,
|
|
275
|
+
limit: int = 8,
|
|
276
|
+
) -> list[CandidateFeature]:
|
|
277
|
+
"""Deduplicate candidates by decoder-direction cosine similarity.
|
|
278
|
+
|
|
279
|
+
The smoke experiment's control was a near-duplicate of its target because
|
|
280
|
+
nothing screened for this. Two directions with cosine 0.9 are the same
|
|
281
|
+
intervention wearing different feature IDs, and keeping both wastes budget
|
|
282
|
+
while making the results look more independent than they are.
|
|
283
|
+
"""
|
|
284
|
+
kept: list[CandidateFeature] = []
|
|
285
|
+
for candidate in candidates:
|
|
286
|
+
vector = directions[candidate.feature_id]
|
|
287
|
+
unit = vector / vector.norm().clamp_min(1e-8)
|
|
288
|
+
if any(
|
|
289
|
+
abs(float(torch.dot(unit, directions[k.feature_id] / directions[k.feature_id].norm().clamp_min(1e-8))))
|
|
290
|
+
> max_cosine
|
|
291
|
+
for k in kept
|
|
292
|
+
):
|
|
293
|
+
continue
|
|
294
|
+
kept.append(candidate)
|
|
295
|
+
if len(kept) >= limit:
|
|
296
|
+
break
|
|
297
|
+
return kept
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
def pick_unrelated_controls(
|
|
301
|
+
directions: dict[int, torch.Tensor],
|
|
302
|
+
target_ids: Sequence[int],
|
|
303
|
+
candidate_pool: Sequence[int],
|
|
304
|
+
*,
|
|
305
|
+
count: int = 3,
|
|
306
|
+
max_cosine: float = 0.15,
|
|
307
|
+
seed: int = 0,
|
|
308
|
+
) -> list[int]:
|
|
309
|
+
"""Choose control features that are *genuinely* unrelated to the targets.
|
|
310
|
+
|
|
311
|
+
Requires low cosine similarity against every target and against each other.
|
|
312
|
+
This is the check whose absence invalidated the previous experiment's
|
|
313
|
+
unrelated-feature control.
|
|
314
|
+
"""
|
|
315
|
+
import random
|
|
316
|
+
|
|
317
|
+
rng = random.Random(seed)
|
|
318
|
+
pool = list(candidate_pool)
|
|
319
|
+
rng.shuffle(pool)
|
|
320
|
+
|
|
321
|
+
def unit(idx: int) -> torch.Tensor:
|
|
322
|
+
v = directions[idx]
|
|
323
|
+
return v / v.norm().clamp_min(1e-8)
|
|
324
|
+
|
|
325
|
+
targets = [unit(i) for i in target_ids]
|
|
326
|
+
chosen: list[int] = []
|
|
327
|
+
for feature_id in pool:
|
|
328
|
+
if feature_id in target_ids:
|
|
329
|
+
continue
|
|
330
|
+
candidate = unit(feature_id)
|
|
331
|
+
if any(abs(float(torch.dot(candidate, t))) > max_cosine for t in targets):
|
|
332
|
+
continue
|
|
333
|
+
if any(abs(float(torch.dot(candidate, unit(c)))) > max_cosine for c in chosen):
|
|
334
|
+
continue
|
|
335
|
+
chosen.append(feature_id)
|
|
336
|
+
if len(chosen) >= count:
|
|
337
|
+
break
|
|
338
|
+
return chosen
|
|
339
|
+
|
|
340
|
+
|
|
341
|
+
def random_directions(hidden: int, count: int, *, seed: int = 1234) -> list[torch.Tensor]:
|
|
342
|
+
"""Scale-matched random unit directions for control conditions."""
|
|
343
|
+
generator = torch.Generator().manual_seed(seed)
|
|
344
|
+
out: list[torch.Tensor] = []
|
|
345
|
+
for _ in range(count):
|
|
346
|
+
vector = torch.randn(hidden, generator=generator)
|
|
347
|
+
out.append(vector / vector.norm())
|
|
348
|
+
return out
|