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,711 @@
|
|
|
1
|
+
"""Direction discovery and behavioural scoring for `anti_sycophancy_v1`.
|
|
2
|
+
|
|
3
|
+
This module answers one question and is shaped entirely by it: *does a direction
|
|
4
|
+
in activation space move a specific behavioural preference on unseen prompts,
|
|
5
|
+
more than matched controls do?*
|
|
6
|
+
|
|
7
|
+
Everything here is built around three cost and correctness constraints.
|
|
8
|
+
|
|
9
|
+
**One model load.** GPU time is the budget. Activations for every candidate
|
|
10
|
+
layer are captured in a single forward pass per sequence, and every scoring pass
|
|
11
|
+
is batched, so the whole layer x position x method grid costs a few minutes
|
|
12
|
+
rather than an hour.
|
|
13
|
+
|
|
14
|
+
**Paired, per-token margins.** Each item contributes
|
|
15
|
+
``margin = logP(desired)/n_desired - logP(undesired)/n_undesired`` under one
|
|
16
|
+
model state, and the reported quantity is the per-item *change* in that margin.
|
|
17
|
+
Pairing cancels item difficulty; per-token normalisation blunts the dataset's
|
|
18
|
+
known length skew.
|
|
19
|
+
|
|
20
|
+
**Discovery never touches validation or test.** Functions here take explicit
|
|
21
|
+
example lists. Nothing reaches for a global split.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
from __future__ import annotations
|
|
25
|
+
|
|
26
|
+
import math
|
|
27
|
+
from dataclasses import dataclass, field
|
|
28
|
+
from typing import Any, Callable, Iterable, Sequence
|
|
29
|
+
|
|
30
|
+
import torch
|
|
31
|
+
|
|
32
|
+
#: Where along the sequence a direction is read off. Which of these works is an
|
|
33
|
+
#: empirical question, so it is scanned rather than assumed.
|
|
34
|
+
EXTRACTION_POSITIONS: tuple[str, ...] = ("last_prompt", "cont_mean", "cont_last")
|
|
35
|
+
|
|
36
|
+
#: Where a direction is injected during scoring. ``continuation`` is the
|
|
37
|
+
#: analogue of steering every generated token; ``prompt`` steers only the
|
|
38
|
+
#: context; ``all`` does both.
|
|
39
|
+
INJECTION_SITES: tuple[str, ...] = ("continuation", "prompt", "all")
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
# ---------------------------------------------------------------------------
|
|
43
|
+
# tokenisation
|
|
44
|
+
# ---------------------------------------------------------------------------
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@dataclass
|
|
48
|
+
class EncodedPair:
|
|
49
|
+
"""One item's two continuations, tokenised and ready to batch."""
|
|
50
|
+
|
|
51
|
+
topic: str
|
|
52
|
+
category: str
|
|
53
|
+
polarity: str
|
|
54
|
+
split: str
|
|
55
|
+
desired_ids: list[int]
|
|
56
|
+
undesired_ids: list[int]
|
|
57
|
+
prompt_len: int
|
|
58
|
+
|
|
59
|
+
@property
|
|
60
|
+
def n_desired(self) -> int:
|
|
61
|
+
return len(self.desired_ids) - self.prompt_len
|
|
62
|
+
|
|
63
|
+
@property
|
|
64
|
+
def n_undesired(self) -> int:
|
|
65
|
+
return len(self.undesired_ids) - self.prompt_len
|
|
66
|
+
|
|
67
|
+
@property
|
|
68
|
+
def length_gap(self) -> int:
|
|
69
|
+
"""Desired minus undesired continuation length, in tokens.
|
|
70
|
+
|
|
71
|
+
Kept per item so the confound diagnostic can be computed directly
|
|
72
|
+
rather than estimated from character counts.
|
|
73
|
+
"""
|
|
74
|
+
return self.n_desired - self.n_undesired
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def encode_pairs(
|
|
78
|
+
tokenizer: Any,
|
|
79
|
+
examples: Sequence[Any],
|
|
80
|
+
*,
|
|
81
|
+
use_chat_template: bool = True,
|
|
82
|
+
max_length: int = 512,
|
|
83
|
+
) -> list[EncodedPair]:
|
|
84
|
+
"""Tokenise contrast examples once, up front.
|
|
85
|
+
|
|
86
|
+
Both continuations share a prompt, and the prompt is tokenised *once* so
|
|
87
|
+
``prompt_len`` is guaranteed identical for the pair. Tokenising
|
|
88
|
+
``prompt + continuation`` separately for each side and subtracting lengths
|
|
89
|
+
would let a boundary merge shift one side by a token, which silently
|
|
90
|
+
misaligns the scored region.
|
|
91
|
+
"""
|
|
92
|
+
encoded: list[EncodedPair] = []
|
|
93
|
+
for example in examples:
|
|
94
|
+
if use_chat_template:
|
|
95
|
+
prompt = tokenizer.apply_chat_template(
|
|
96
|
+
[{"role": "user", "content": example.prompt}],
|
|
97
|
+
tokenize=False,
|
|
98
|
+
add_generation_prompt=True,
|
|
99
|
+
)
|
|
100
|
+
else:
|
|
101
|
+
prompt = example.prompt
|
|
102
|
+
|
|
103
|
+
prompt_ids = tokenizer(prompt, add_special_tokens=False).input_ids
|
|
104
|
+
desired = prompt_ids + tokenizer(
|
|
105
|
+
example.positive_response, add_special_tokens=False
|
|
106
|
+
).input_ids
|
|
107
|
+
undesired = prompt_ids + tokenizer(
|
|
108
|
+
example.negative_response, add_special_tokens=False
|
|
109
|
+
).input_ids
|
|
110
|
+
|
|
111
|
+
metadata = getattr(example, "metadata", {}) or {}
|
|
112
|
+
encoded.append(
|
|
113
|
+
EncodedPair(
|
|
114
|
+
topic=str(metadata.get("topic", "")),
|
|
115
|
+
category=str(getattr(example, "category", "")),
|
|
116
|
+
polarity=str(metadata.get("polarity", "false_claim")),
|
|
117
|
+
split=str(metadata.get("split", "train")),
|
|
118
|
+
desired_ids=desired[:max_length],
|
|
119
|
+
undesired_ids=undesired[:max_length],
|
|
120
|
+
prompt_len=len(prompt_ids),
|
|
121
|
+
)
|
|
122
|
+
)
|
|
123
|
+
return encoded
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
# ---------------------------------------------------------------------------
|
|
127
|
+
# injection
|
|
128
|
+
# ---------------------------------------------------------------------------
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
class DirectionInjector:
|
|
132
|
+
"""Adds ``strength * unit(direction)`` to one layer's residual stream.
|
|
133
|
+
|
|
134
|
+
The additive mask is set per batch, which is what allows the same hook to
|
|
135
|
+
express "steer only the continuation", "steer only the prompt" and "steer
|
|
136
|
+
everything" without reinstalling anything.
|
|
137
|
+
"""
|
|
138
|
+
|
|
139
|
+
def __init__(self, direction: torch.Tensor, strength: float) -> None:
|
|
140
|
+
norm = torch.linalg.vector_norm(direction)
|
|
141
|
+
if float(norm) <= 0:
|
|
142
|
+
raise ValueError("direction has zero norm")
|
|
143
|
+
self.vector = (direction / norm).detach()
|
|
144
|
+
self.strength = float(strength)
|
|
145
|
+
self.mask: torch.Tensor | None = None
|
|
146
|
+
self._handle: Any = None
|
|
147
|
+
self.calls = 0
|
|
148
|
+
|
|
149
|
+
def _hook(self, module: Any, args: Any, output: Any) -> Any:
|
|
150
|
+
if self.mask is None or self.strength == 0.0:
|
|
151
|
+
return output
|
|
152
|
+
hidden = output[0] if isinstance(output, tuple) else output
|
|
153
|
+
delta = self.mask.unsqueeze(-1).to(hidden.dtype) * self.vector.to(
|
|
154
|
+
hidden.device, hidden.dtype
|
|
155
|
+
)
|
|
156
|
+
hidden = hidden + self.strength * delta
|
|
157
|
+
self.calls += 1
|
|
158
|
+
if isinstance(output, tuple):
|
|
159
|
+
return (hidden,) + tuple(output[1:])
|
|
160
|
+
return hidden
|
|
161
|
+
|
|
162
|
+
def attach(self, layer_module: Any) -> "DirectionInjector":
|
|
163
|
+
self._handle = layer_module.register_forward_hook(self._hook)
|
|
164
|
+
return self
|
|
165
|
+
|
|
166
|
+
def remove(self) -> None:
|
|
167
|
+
if self._handle is not None:
|
|
168
|
+
self._handle.remove()
|
|
169
|
+
self._handle = None
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
# ---------------------------------------------------------------------------
|
|
173
|
+
# scoring
|
|
174
|
+
# ---------------------------------------------------------------------------
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
@dataclass
|
|
178
|
+
class ItemScore:
|
|
179
|
+
"""One item's per-token margin under one model state."""
|
|
180
|
+
|
|
181
|
+
topic: str
|
|
182
|
+
category: str
|
|
183
|
+
polarity: str
|
|
184
|
+
desired_logprob: float
|
|
185
|
+
undesired_logprob: float
|
|
186
|
+
n_desired: int
|
|
187
|
+
n_undesired: int
|
|
188
|
+
length_gap: int
|
|
189
|
+
|
|
190
|
+
@property
|
|
191
|
+
def margin(self) -> float:
|
|
192
|
+
"""Length-normalized margin: per-token, so length cannot drive it."""
|
|
193
|
+
return (
|
|
194
|
+
self.desired_logprob / max(1, self.n_desired)
|
|
195
|
+
- self.undesired_logprob / max(1, self.n_undesired)
|
|
196
|
+
)
|
|
197
|
+
|
|
198
|
+
@property
|
|
199
|
+
def total_margin(self) -> float:
|
|
200
|
+
"""Total sequence log-probability margin.
|
|
201
|
+
|
|
202
|
+
Reported alongside the normalized version. On a length-balanced dataset
|
|
203
|
+
the two should broadly agree; a disagreement between them is itself
|
|
204
|
+
evidence that length is doing the work, so both are always carried.
|
|
205
|
+
"""
|
|
206
|
+
return self.desired_logprob - self.undesired_logprob
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def _sequence_logprobs(
|
|
210
|
+
model: Any,
|
|
211
|
+
batch_ids: list[list[int]],
|
|
212
|
+
prompt_lens: list[int],
|
|
213
|
+
pad_id: int,
|
|
214
|
+
device: Any,
|
|
215
|
+
injector: DirectionInjector | None,
|
|
216
|
+
inject_site: str,
|
|
217
|
+
) -> list[float]:
|
|
218
|
+
"""Summed continuation log-probability for each sequence in a batch.
|
|
219
|
+
|
|
220
|
+
Right-padded. Padding never enters a score because only positions in
|
|
221
|
+
``[prompt_len-1, seq_len-1)`` are gathered, and the additive mask is zero on
|
|
222
|
+
pad positions so a padded run cannot perturb a real one.
|
|
223
|
+
"""
|
|
224
|
+
width = max(len(ids) for ids in batch_ids)
|
|
225
|
+
input_ids = torch.full((len(batch_ids), width), pad_id, dtype=torch.long)
|
|
226
|
+
attention = torch.zeros((len(batch_ids), width), dtype=torch.long)
|
|
227
|
+
steer_mask = torch.zeros((len(batch_ids), width), dtype=torch.float32)
|
|
228
|
+
|
|
229
|
+
for row, (ids, prompt_len) in enumerate(zip(batch_ids, prompt_lens)):
|
|
230
|
+
input_ids[row, : len(ids)] = torch.tensor(ids, dtype=torch.long)
|
|
231
|
+
attention[row, : len(ids)] = 1
|
|
232
|
+
if inject_site == "continuation":
|
|
233
|
+
steer_mask[row, prompt_len : len(ids)] = 1.0
|
|
234
|
+
elif inject_site == "prompt":
|
|
235
|
+
steer_mask[row, :prompt_len] = 1.0
|
|
236
|
+
else:
|
|
237
|
+
steer_mask[row, : len(ids)] = 1.0
|
|
238
|
+
|
|
239
|
+
input_ids = input_ids.to(device)
|
|
240
|
+
attention = attention.to(device)
|
|
241
|
+
if injector is not None:
|
|
242
|
+
injector.mask = steer_mask.to(device)
|
|
243
|
+
|
|
244
|
+
with torch.inference_mode():
|
|
245
|
+
logits = model(input_ids=input_ids, attention_mask=attention, use_cache=False).logits
|
|
246
|
+
|
|
247
|
+
log_probs = torch.log_softmax(logits[:, :-1, :].float(), dim=-1)
|
|
248
|
+
targets = input_ids[:, 1:]
|
|
249
|
+
gathered = log_probs.gather(-1, targets.unsqueeze(-1)).squeeze(-1)
|
|
250
|
+
|
|
251
|
+
out: list[float] = []
|
|
252
|
+
for row, (ids, prompt_len) in enumerate(zip(batch_ids, prompt_lens)):
|
|
253
|
+
out.append(float(gathered[row, prompt_len - 1 : len(ids) - 1].sum().item()))
|
|
254
|
+
return out
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
def score_pairs(
|
|
258
|
+
model: Any,
|
|
259
|
+
pairs: Sequence[EncodedPair],
|
|
260
|
+
*,
|
|
261
|
+
pad_id: int,
|
|
262
|
+
device: Any,
|
|
263
|
+
injector: DirectionInjector | None = None,
|
|
264
|
+
inject_site: str = "continuation",
|
|
265
|
+
batch_size: int = 8,
|
|
266
|
+
) -> list[ItemScore]:
|
|
267
|
+
"""Per-token margins for every pair under the current model state.
|
|
268
|
+
|
|
269
|
+
Both sides of every pair go through the same batching path, so any batching
|
|
270
|
+
artefact affects desired and undesired identically and cancels in the
|
|
271
|
+
margin.
|
|
272
|
+
"""
|
|
273
|
+
flat_ids: list[list[int]] = []
|
|
274
|
+
flat_prompt: list[int] = []
|
|
275
|
+
for pair in pairs:
|
|
276
|
+
flat_ids.append(pair.desired_ids)
|
|
277
|
+
flat_prompt.append(pair.prompt_len)
|
|
278
|
+
flat_ids.append(pair.undesired_ids)
|
|
279
|
+
flat_prompt.append(pair.prompt_len)
|
|
280
|
+
|
|
281
|
+
# Length-sorted batching keeps padding low; the original order is restored
|
|
282
|
+
# afterwards so results line up with `pairs`.
|
|
283
|
+
order = sorted(range(len(flat_ids)), key=lambda i: len(flat_ids[i]))
|
|
284
|
+
totals: list[float] = [0.0] * len(flat_ids)
|
|
285
|
+
for start in range(0, len(order), batch_size):
|
|
286
|
+
chunk = order[start : start + batch_size]
|
|
287
|
+
values = _sequence_logprobs(
|
|
288
|
+
model,
|
|
289
|
+
[flat_ids[i] for i in chunk],
|
|
290
|
+
[flat_prompt[i] for i in chunk],
|
|
291
|
+
pad_id,
|
|
292
|
+
device,
|
|
293
|
+
injector,
|
|
294
|
+
inject_site,
|
|
295
|
+
)
|
|
296
|
+
for index, value in zip(chunk, values):
|
|
297
|
+
totals[index] = value
|
|
298
|
+
|
|
299
|
+
scores: list[ItemScore] = []
|
|
300
|
+
for position, pair in enumerate(pairs):
|
|
301
|
+
scores.append(
|
|
302
|
+
ItemScore(
|
|
303
|
+
topic=pair.topic,
|
|
304
|
+
category=pair.category,
|
|
305
|
+
polarity=pair.polarity,
|
|
306
|
+
desired_logprob=totals[2 * position],
|
|
307
|
+
undesired_logprob=totals[2 * position + 1],
|
|
308
|
+
n_desired=pair.n_desired,
|
|
309
|
+
n_undesired=pair.n_undesired,
|
|
310
|
+
length_gap=pair.length_gap,
|
|
311
|
+
)
|
|
312
|
+
)
|
|
313
|
+
return scores
|
|
314
|
+
|
|
315
|
+
|
|
316
|
+
# ---------------------------------------------------------------------------
|
|
317
|
+
# statistics
|
|
318
|
+
# ---------------------------------------------------------------------------
|
|
319
|
+
|
|
320
|
+
|
|
321
|
+
@dataclass
|
|
322
|
+
class DeltaSummary:
|
|
323
|
+
"""Paired change in margin for one polarity."""
|
|
324
|
+
|
|
325
|
+
polarity: str
|
|
326
|
+
n: int
|
|
327
|
+
mean: float
|
|
328
|
+
median: float
|
|
329
|
+
ci_low: float
|
|
330
|
+
ci_high: float
|
|
331
|
+
proportion_improved: float
|
|
332
|
+
cohens_d: float
|
|
333
|
+
by_category: dict[str, float] = field(default_factory=dict)
|
|
334
|
+
|
|
335
|
+
def to_dict(self) -> dict[str, Any]:
|
|
336
|
+
return {
|
|
337
|
+
"polarity": self.polarity,
|
|
338
|
+
"n": self.n,
|
|
339
|
+
"mean": self.mean,
|
|
340
|
+
"median": self.median,
|
|
341
|
+
"ci_low": self.ci_low,
|
|
342
|
+
"ci_high": self.ci_high,
|
|
343
|
+
"proportion_improved": self.proportion_improved,
|
|
344
|
+
"cohens_d": self.cohens_d,
|
|
345
|
+
"by_category": self.by_category,
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
|
|
349
|
+
def _bootstrap_ci(
|
|
350
|
+
values: Sequence[float], *, resamples: int = 10_000, seed: int = 0
|
|
351
|
+
) -> tuple[float, float]:
|
|
352
|
+
if not values:
|
|
353
|
+
return (float("nan"), float("nan"))
|
|
354
|
+
generator = torch.Generator().manual_seed(seed)
|
|
355
|
+
tensor = torch.tensor(values, dtype=torch.float64)
|
|
356
|
+
indices = torch.randint(
|
|
357
|
+
0, len(values), (resamples, len(values)), generator=generator
|
|
358
|
+
)
|
|
359
|
+
means = tensor[indices].mean(dim=1)
|
|
360
|
+
low = float(torch.quantile(means, 0.025).item())
|
|
361
|
+
high = float(torch.quantile(means, 0.975).item())
|
|
362
|
+
return (low, high)
|
|
363
|
+
|
|
364
|
+
|
|
365
|
+
def summarize_deltas(
|
|
366
|
+
baseline: Sequence[ItemScore],
|
|
367
|
+
patched: Sequence[ItemScore],
|
|
368
|
+
*,
|
|
369
|
+
polarity: str,
|
|
370
|
+
seed: int = 0,
|
|
371
|
+
use_total: bool = False,
|
|
372
|
+
) -> DeltaSummary:
|
|
373
|
+
"""Paired delta statistics restricted to one polarity.
|
|
374
|
+
|
|
375
|
+
Pairs are matched by position, and a mismatch raises rather than silently
|
|
376
|
+
comparing different items -- a misalignment here would produce a plausible
|
|
377
|
+
number that means nothing.
|
|
378
|
+
"""
|
|
379
|
+
if len(baseline) != len(patched):
|
|
380
|
+
raise ValueError("baseline and patched score lists differ in length")
|
|
381
|
+
|
|
382
|
+
deltas: list[float] = []
|
|
383
|
+
categories: dict[str, list[float]] = {}
|
|
384
|
+
for before, after in zip(baseline, patched):
|
|
385
|
+
if before.topic != after.topic:
|
|
386
|
+
raise ValueError(f"score misalignment: {before.topic!r} vs {after.topic!r}")
|
|
387
|
+
if before.polarity != polarity:
|
|
388
|
+
continue
|
|
389
|
+
if use_total:
|
|
390
|
+
delta = after.total_margin - before.total_margin
|
|
391
|
+
else:
|
|
392
|
+
delta = after.margin - before.margin
|
|
393
|
+
deltas.append(delta)
|
|
394
|
+
categories.setdefault(before.category, []).append(delta)
|
|
395
|
+
|
|
396
|
+
if not deltas:
|
|
397
|
+
return DeltaSummary(polarity, 0, 0.0, 0.0, float("nan"), float("nan"), 0.0, 0.0, {})
|
|
398
|
+
|
|
399
|
+
tensor = torch.tensor(deltas, dtype=torch.float64)
|
|
400
|
+
mean = float(tensor.mean().item())
|
|
401
|
+
std = float(tensor.std(unbiased=True).item()) if len(deltas) > 1 else 0.0
|
|
402
|
+
low, high = _bootstrap_ci(deltas, seed=seed)
|
|
403
|
+
return DeltaSummary(
|
|
404
|
+
polarity=polarity,
|
|
405
|
+
n=len(deltas),
|
|
406
|
+
mean=mean,
|
|
407
|
+
median=float(tensor.median().item()),
|
|
408
|
+
ci_low=low,
|
|
409
|
+
ci_high=high,
|
|
410
|
+
proportion_improved=float((tensor > 0).double().mean().item()),
|
|
411
|
+
cohens_d=(mean / std) if std > 0 else 0.0,
|
|
412
|
+
by_category={k: float(sum(v) / len(v)) for k, v in sorted(categories.items())},
|
|
413
|
+
)
|
|
414
|
+
|
|
415
|
+
|
|
416
|
+
def length_gap_correlation(
|
|
417
|
+
baseline: Sequence[ItemScore], patched: Sequence[ItemScore]
|
|
418
|
+
) -> float:
|
|
419
|
+
"""Pearson r between per-item delta and per-item token length gap.
|
|
420
|
+
|
|
421
|
+
The dataset's desired responses are systematically longer. If steering
|
|
422
|
+
strength tracks that gap, the "behavioural" effect is a length preference
|
|
423
|
+
wearing a costume, and the pre-registered criteria reject it.
|
|
424
|
+
"""
|
|
425
|
+
deltas: list[float] = []
|
|
426
|
+
gaps: list[float] = []
|
|
427
|
+
for before, after in zip(baseline, patched):
|
|
428
|
+
deltas.append(after.margin - before.margin)
|
|
429
|
+
gaps.append(float(before.length_gap))
|
|
430
|
+
if len(deltas) < 3:
|
|
431
|
+
return 0.0
|
|
432
|
+
x = torch.tensor(deltas, dtype=torch.float64)
|
|
433
|
+
y = torch.tensor(gaps, dtype=torch.float64)
|
|
434
|
+
x = x - x.mean()
|
|
435
|
+
y = y - y.mean()
|
|
436
|
+
denominator = float(torch.linalg.vector_norm(x) * torch.linalg.vector_norm(y))
|
|
437
|
+
if denominator == 0:
|
|
438
|
+
return 0.0
|
|
439
|
+
return float((x @ y).item() / denominator)
|
|
440
|
+
|
|
441
|
+
|
|
442
|
+
# ---------------------------------------------------------------------------
|
|
443
|
+
# direction discovery
|
|
444
|
+
# ---------------------------------------------------------------------------
|
|
445
|
+
|
|
446
|
+
|
|
447
|
+
def capture_layer_activations(
|
|
448
|
+
model: Any,
|
|
449
|
+
layer_modules: dict[int, Any],
|
|
450
|
+
pairs: Sequence[EncodedPair],
|
|
451
|
+
*,
|
|
452
|
+
pad_id: int,
|
|
453
|
+
device: Any,
|
|
454
|
+
batch_size: int = 8,
|
|
455
|
+
) -> dict[int, dict[str, torch.Tensor]]:
|
|
456
|
+
"""Residual activations at every candidate layer, at every extraction point.
|
|
457
|
+
|
|
458
|
+
One forward pass per sequence serves all layers and all three extraction
|
|
459
|
+
positions. Scanning layers with a separate pass each would multiply the
|
|
460
|
+
dominant cost of the experiment by seven for no information gain.
|
|
461
|
+
|
|
462
|
+
Returns ``{layer: {f"{position}_{side}": tensor of shape (n_items, hidden)}}``.
|
|
463
|
+
"""
|
|
464
|
+
captured: dict[int, torch.Tensor] = {}
|
|
465
|
+
handles: list[Any] = []
|
|
466
|
+
|
|
467
|
+
def make_hook(layer: int) -> Callable[..., None]:
|
|
468
|
+
def hook(module: Any, args: Any, output: Any) -> None:
|
|
469
|
+
hidden = output[0] if isinstance(output, tuple) else output
|
|
470
|
+
captured[layer] = hidden.detach().float()
|
|
471
|
+
|
|
472
|
+
return hook
|
|
473
|
+
|
|
474
|
+
for layer, module in layer_modules.items():
|
|
475
|
+
handles.append(module.register_forward_hook(make_hook(layer)))
|
|
476
|
+
|
|
477
|
+
accumulator: dict[int, dict[str, list[torch.Tensor]]] = {
|
|
478
|
+
layer: {f"{p}_{s}": [] for p in EXTRACTION_POSITIONS for s in ("desired", "undesired")}
|
|
479
|
+
for layer in layer_modules
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
try:
|
|
483
|
+
for side, attribute in (("desired", "desired_ids"), ("undesired", "undesired_ids")):
|
|
484
|
+
for start in range(0, len(pairs), batch_size):
|
|
485
|
+
chunk = list(pairs[start : start + batch_size])
|
|
486
|
+
batch_ids = [getattr(p, attribute) for p in chunk]
|
|
487
|
+
width = max(len(ids) for ids in batch_ids)
|
|
488
|
+
input_ids = torch.full((len(chunk), width), pad_id, dtype=torch.long)
|
|
489
|
+
attention = torch.zeros((len(chunk), width), dtype=torch.long)
|
|
490
|
+
for row, ids in enumerate(batch_ids):
|
|
491
|
+
input_ids[row, : len(ids)] = torch.tensor(ids, dtype=torch.long)
|
|
492
|
+
attention[row, : len(ids)] = 1
|
|
493
|
+
|
|
494
|
+
with torch.inference_mode():
|
|
495
|
+
model(
|
|
496
|
+
input_ids=input_ids.to(device),
|
|
497
|
+
attention_mask=attention.to(device),
|
|
498
|
+
use_cache=False,
|
|
499
|
+
)
|
|
500
|
+
|
|
501
|
+
for layer in layer_modules:
|
|
502
|
+
hidden = captured[layer]
|
|
503
|
+
for row, (pair, ids) in enumerate(zip(chunk, batch_ids)):
|
|
504
|
+
start_index = pair.prompt_len
|
|
505
|
+
end_index = len(ids)
|
|
506
|
+
accumulator[layer][f"last_prompt_{side}"].append(
|
|
507
|
+
hidden[row, start_index - 1].cpu()
|
|
508
|
+
)
|
|
509
|
+
if end_index > start_index:
|
|
510
|
+
accumulator[layer][f"cont_mean_{side}"].append(
|
|
511
|
+
hidden[row, start_index:end_index].mean(dim=0).cpu()
|
|
512
|
+
)
|
|
513
|
+
accumulator[layer][f"cont_last_{side}"].append(
|
|
514
|
+
hidden[row, end_index - 1].cpu()
|
|
515
|
+
)
|
|
516
|
+
else: # degenerate pair; keep alignment
|
|
517
|
+
accumulator[layer][f"cont_mean_{side}"].append(
|
|
518
|
+
hidden[row, start_index - 1].cpu()
|
|
519
|
+
)
|
|
520
|
+
accumulator[layer][f"cont_last_{side}"].append(
|
|
521
|
+
hidden[row, start_index - 1].cpu()
|
|
522
|
+
)
|
|
523
|
+
finally:
|
|
524
|
+
for handle in handles:
|
|
525
|
+
handle.remove()
|
|
526
|
+
|
|
527
|
+
return {
|
|
528
|
+
layer: {key: torch.stack(values) for key, values in buckets.items()}
|
|
529
|
+
for layer, buckets in accumulator.items()
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
|
|
533
|
+
def fit_caa(desired: torch.Tensor, undesired: torch.Tensor) -> torch.Tensor:
|
|
534
|
+
"""Difference of means. The baseline every other method has to beat."""
|
|
535
|
+
return desired.mean(dim=0) - undesired.mean(dim=0)
|
|
536
|
+
|
|
537
|
+
|
|
538
|
+
def fit_pca(desired: torch.Tensor, undesired: torch.Tensor) -> torch.Tensor:
|
|
539
|
+
"""Top principal component of the paired difference distribution.
|
|
540
|
+
|
|
541
|
+
Sign is fixed to agree with the mean difference, since a principal component
|
|
542
|
+
is only defined up to sign and an arbitrary flip would invert the steering.
|
|
543
|
+
"""
|
|
544
|
+
difference = desired - undesired
|
|
545
|
+
centered = difference - difference.mean(dim=0, keepdim=True)
|
|
546
|
+
_, _, v = torch.pca_lowrank(centered, q=min(8, centered.shape[0] - 1, centered.shape[1]))
|
|
547
|
+
component = v[:, 0]
|
|
548
|
+
if float(component @ difference.mean(dim=0)) < 0:
|
|
549
|
+
component = -component
|
|
550
|
+
return component
|
|
551
|
+
|
|
552
|
+
|
|
553
|
+
def fit_probe(
|
|
554
|
+
desired: torch.Tensor,
|
|
555
|
+
undesired: torch.Tensor,
|
|
556
|
+
*,
|
|
557
|
+
epochs: int = 300,
|
|
558
|
+
lr: float = 0.05,
|
|
559
|
+
weight_decay: float = 1e-3,
|
|
560
|
+
seed: int = 0,
|
|
561
|
+
) -> tuple[torch.Tensor, float]:
|
|
562
|
+
"""Logistic probe separating the two classes; returns (direction, accuracy).
|
|
563
|
+
|
|
564
|
+
Accuracy is returned so it can be *reported*, not so it can be used as a
|
|
565
|
+
success criterion. A probe can separate two classes almost perfectly and
|
|
566
|
+
still give a direction that steers nothing -- readable and causal are
|
|
567
|
+
different properties, and conflating them is a common way to overclaim.
|
|
568
|
+
|
|
569
|
+
Features are standardised before fitting and the learned weights are mapped
|
|
570
|
+
back to raw activation space, so the direction is comparable with CAA's.
|
|
571
|
+
"""
|
|
572
|
+
torch.manual_seed(seed)
|
|
573
|
+
features = torch.cat([desired, undesired], dim=0).double()
|
|
574
|
+
labels = torch.cat(
|
|
575
|
+
[torch.ones(len(desired)), torch.zeros(len(undesired))], dim=0
|
|
576
|
+
).double()
|
|
577
|
+
|
|
578
|
+
mean = features.mean(dim=0, keepdim=True)
|
|
579
|
+
std = features.std(dim=0, keepdim=True).clamp_min(1e-6)
|
|
580
|
+
standardized = (features - mean) / std
|
|
581
|
+
|
|
582
|
+
weights = torch.zeros(standardized.shape[1], dtype=torch.float64, requires_grad=True)
|
|
583
|
+
bias = torch.zeros(1, dtype=torch.float64, requires_grad=True)
|
|
584
|
+
optimizer = torch.optim.Adam([weights, bias], lr=lr, weight_decay=weight_decay)
|
|
585
|
+
|
|
586
|
+
for _ in range(epochs):
|
|
587
|
+
optimizer.zero_grad()
|
|
588
|
+
logits = standardized @ weights + bias
|
|
589
|
+
loss = torch.nn.functional.binary_cross_entropy_with_logits(logits, labels)
|
|
590
|
+
loss.backward()
|
|
591
|
+
optimizer.step()
|
|
592
|
+
|
|
593
|
+
with torch.no_grad():
|
|
594
|
+
predictions = ((standardized @ weights + bias) > 0).double()
|
|
595
|
+
accuracy = float((predictions == labels).double().mean().item())
|
|
596
|
+
direction = (weights / std.squeeze(0)).float()
|
|
597
|
+
|
|
598
|
+
return direction, accuracy
|
|
599
|
+
|
|
600
|
+
|
|
601
|
+
def random_directions(
|
|
602
|
+
hidden: int, count: int, *, seed: int = 0, device: Any = "cpu"
|
|
603
|
+
) -> list[torch.Tensor]:
|
|
604
|
+
"""Scale-matched random unit directions.
|
|
605
|
+
|
|
606
|
+
These are the control that the first BrainPatch experiment failed. Any
|
|
607
|
+
claimed effect has to beat the *maximum* over this set, not the mean.
|
|
608
|
+
"""
|
|
609
|
+
generator = torch.Generator().manual_seed(seed)
|
|
610
|
+
out: list[torch.Tensor] = []
|
|
611
|
+
for _ in range(count):
|
|
612
|
+
vector = torch.randn(hidden, generator=generator)
|
|
613
|
+
out.append((vector / torch.linalg.vector_norm(vector)).to(device))
|
|
614
|
+
return out
|
|
615
|
+
|
|
616
|
+
|
|
617
|
+
def shuffled_label_direction(
|
|
618
|
+
desired: torch.Tensor,
|
|
619
|
+
undesired: torch.Tensor,
|
|
620
|
+
*,
|
|
621
|
+
method: str = "caa",
|
|
622
|
+
seed: int = 0,
|
|
623
|
+
) -> torch.Tensor:
|
|
624
|
+
"""Refit a direction after permuting which side is which.
|
|
625
|
+
|
|
626
|
+
If the pipeline finds a "working" direction from label noise, the method is
|
|
627
|
+
fitting the dataset's incidental structure and no result from it means
|
|
628
|
+
anything. This is the control that catches that.
|
|
629
|
+
"""
|
|
630
|
+
generator = torch.Generator().manual_seed(seed)
|
|
631
|
+
stacked = torch.cat([desired, undesired], dim=0)
|
|
632
|
+
permutation = torch.randperm(len(stacked), generator=generator)
|
|
633
|
+
half = len(desired)
|
|
634
|
+
left = stacked[permutation[:half]]
|
|
635
|
+
right = stacked[permutation[half:]]
|
|
636
|
+
if method == "pca":
|
|
637
|
+
size = min(len(left), len(right))
|
|
638
|
+
return fit_pca(left[:size], right[:size])
|
|
639
|
+
return fit_caa(left, right)
|
|
640
|
+
|
|
641
|
+
|
|
642
|
+
class GenerationInjector:
|
|
643
|
+
"""Injects during autoregressive generation, where the mask trick fails.
|
|
644
|
+
|
|
645
|
+
With a KV cache the first forward pass covers the whole prompt and every
|
|
646
|
+
later pass carries a single token, so "which positions am I steering" has to
|
|
647
|
+
be answered from the pass index rather than from a precomputed mask. Getting
|
|
648
|
+
this wrong is silent: a patch meant to steer only the prompt would go on
|
|
649
|
+
steering every generated token and the free-generation numbers would not
|
|
650
|
+
correspond to the scored configuration at all.
|
|
651
|
+
"""
|
|
652
|
+
|
|
653
|
+
def __init__(self, direction: torch.Tensor, strength: float, site: str) -> None:
|
|
654
|
+
norm = torch.linalg.vector_norm(direction)
|
|
655
|
+
if float(norm) <= 0:
|
|
656
|
+
raise ValueError("direction has zero norm")
|
|
657
|
+
if site not in INJECTION_SITES:
|
|
658
|
+
raise ValueError(f"unknown injection site {site!r}")
|
|
659
|
+
self.vector = (direction / norm).detach()
|
|
660
|
+
self.strength = float(strength)
|
|
661
|
+
self.site = site
|
|
662
|
+
self.prompt_pass_done = False
|
|
663
|
+
self.applied = 0
|
|
664
|
+
self._handle: Any = None
|
|
665
|
+
|
|
666
|
+
def reset(self) -> None:
|
|
667
|
+
self.prompt_pass_done = False
|
|
668
|
+
self.applied = 0
|
|
669
|
+
|
|
670
|
+
def _hook(self, module: Any, args: Any, output: Any) -> Any:
|
|
671
|
+
hidden = output[0] if isinstance(output, tuple) else output
|
|
672
|
+
is_prompt_pass = not self.prompt_pass_done
|
|
673
|
+
self.prompt_pass_done = True
|
|
674
|
+
|
|
675
|
+
active = (
|
|
676
|
+
self.site == "all"
|
|
677
|
+
or (self.site == "prompt" and is_prompt_pass)
|
|
678
|
+
or (self.site == "continuation" and not is_prompt_pass)
|
|
679
|
+
)
|
|
680
|
+
if not active or self.strength == 0.0:
|
|
681
|
+
return output
|
|
682
|
+
|
|
683
|
+
hidden = hidden + self.strength * self.vector.to(hidden.device, hidden.dtype)
|
|
684
|
+
self.applied += 1
|
|
685
|
+
if isinstance(output, tuple):
|
|
686
|
+
return (hidden,) + tuple(output[1:])
|
|
687
|
+
return hidden
|
|
688
|
+
|
|
689
|
+
def attach(self, layer_module: Any) -> "GenerationInjector":
|
|
690
|
+
self._handle = layer_module.register_forward_hook(self._hook)
|
|
691
|
+
return self
|
|
692
|
+
|
|
693
|
+
def remove(self) -> None:
|
|
694
|
+
if self._handle is not None:
|
|
695
|
+
self._handle.remove()
|
|
696
|
+
self._handle = None
|
|
697
|
+
|
|
698
|
+
|
|
699
|
+
def residual_norm_percentiles(
|
|
700
|
+
activations: torch.Tensor, percentiles: Iterable[float] = (50, 90, 95, 99)
|
|
701
|
+
) -> dict[str, float]:
|
|
702
|
+
"""Percentiles of the residual-stream norm, for strength calibration.
|
|
703
|
+
|
|
704
|
+
Strength is expressed as a fraction of the activation magnitude the model
|
|
705
|
+
naturally carries at that layer, so an intervention stays on-manifold
|
|
706
|
+
instead of being an arbitrary large number that merely breaks the model.
|
|
707
|
+
"""
|
|
708
|
+
norms = torch.linalg.vector_norm(activations.float(), dim=-1)
|
|
709
|
+
return {
|
|
710
|
+
f"p{int(p)}": float(torch.quantile(norms, p / 100.0).item()) for p in percentiles
|
|
711
|
+
}
|