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,386 @@
|
|
|
1
|
+
"""Causal validation of feature interventions.
|
|
2
|
+
|
|
3
|
+
The question this module answers is *not* "does steering this feature change the
|
|
4
|
+
output" -- almost any large enough perturbation changes the output. It is
|
|
5
|
+
"does steering **this direction** change the output in a way that a
|
|
6
|
+
**scale-matched perturbation in another direction** does not".
|
|
7
|
+
|
|
8
|
+
Conditions run for every prompt:
|
|
9
|
+
|
|
10
|
+
====================== =========================================================
|
|
11
|
+
``baseline`` no hook installed at all
|
|
12
|
+
``zero`` hook installed, strength 0 -- must equal ``baseline``
|
|
13
|
+
``positive`` the feature direction at ``+strength``
|
|
14
|
+
``negative`` the feature direction at ``-strength``
|
|
15
|
+
``random_positive`` a random unit direction at ``+strength`` (same L2 norm)
|
|
16
|
+
``random_negative`` a random unit direction at ``-strength``
|
|
17
|
+
``unrelated_positive`` a *different, real* feature at ``+strength``
|
|
18
|
+
====================== =========================================================
|
|
19
|
+
|
|
20
|
+
The random control isolates "is this direction special". The unrelated-feature
|
|
21
|
+
control isolates "is this feature special, or does any dictionary direction do
|
|
22
|
+
this". The ``zero`` condition is a correctness check on the harness itself.
|
|
23
|
+
|
|
24
|
+
Every generation is stored. There is no filtering, no best-of, and no
|
|
25
|
+
cherry-picking: the artifacts contain what the model actually produced under
|
|
26
|
+
each condition, including the incoherent ones.
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
from __future__ import annotations
|
|
30
|
+
|
|
31
|
+
import json
|
|
32
|
+
import time
|
|
33
|
+
from dataclasses import dataclass, field
|
|
34
|
+
from pathlib import Path
|
|
35
|
+
from typing import Any, Sequence
|
|
36
|
+
|
|
37
|
+
from brainpatch.evaluation.metrics import compare_generations, score_generation
|
|
38
|
+
from brainpatch.research.ml.generation import GenerationConfig
|
|
39
|
+
from brainpatch.research.ml.runtime import BrainPatchedModel
|
|
40
|
+
from brainpatch.paths import VolumePaths
|
|
41
|
+
from brainpatch.schemas.patch import BrainPatchSpec, FeatureEdit, SAEReference
|
|
42
|
+
|
|
43
|
+
#: Conditions that constitute the intervention itself.
|
|
44
|
+
INTERVENTION_CONDITIONS = ("positive", "negative")
|
|
45
|
+
#: Conditions that exist to rule out alternative explanations.
|
|
46
|
+
CONTROL_CONDITIONS = ("zero", "random_positive", "random_negative", "unrelated_positive")
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
@dataclass
|
|
50
|
+
class ConditionResult:
|
|
51
|
+
"""One (prompt, condition) generation with its model-free metrics."""
|
|
52
|
+
|
|
53
|
+
condition: str
|
|
54
|
+
prompt_index: int
|
|
55
|
+
prompt: str
|
|
56
|
+
text: str
|
|
57
|
+
feature_id: int | None
|
|
58
|
+
strength: float
|
|
59
|
+
steering_stats: dict[str, Any] = field(default_factory=dict)
|
|
60
|
+
metrics: dict[str, Any] = field(default_factory=dict)
|
|
61
|
+
|
|
62
|
+
def to_dict(self) -> dict[str, Any]:
|
|
63
|
+
return {
|
|
64
|
+
"condition": self.condition,
|
|
65
|
+
"prompt_index": self.prompt_index,
|
|
66
|
+
"prompt": self.prompt,
|
|
67
|
+
"text": self.text,
|
|
68
|
+
"feature_id": self.feature_id,
|
|
69
|
+
"strength": self.strength,
|
|
70
|
+
"steering_stats": self.steering_stats,
|
|
71
|
+
"metrics": self.metrics,
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _spec_for(
|
|
76
|
+
model: BrainPatchedModel, feature_id: int, strength: float, name: str
|
|
77
|
+
) -> BrainPatchSpec:
|
|
78
|
+
"""Build a throwaway single-feature patch spec for one condition."""
|
|
79
|
+
assert model.sae is not None and model.sae_reference is not None
|
|
80
|
+
return BrainPatchSpec(
|
|
81
|
+
name=name,
|
|
82
|
+
base_model=model.bundle.model_id,
|
|
83
|
+
model_revision=model.bundle.revision,
|
|
84
|
+
sae=SAEReference(
|
|
85
|
+
reference=model.sae_reference,
|
|
86
|
+
layer=int(model.sae.config.layer),
|
|
87
|
+
hook=model.sae.config.hook or "residual_post",
|
|
88
|
+
d_in=model.sae.d_in,
|
|
89
|
+
d_sae=model.sae.d_sae,
|
|
90
|
+
input_scale=model.input_scale,
|
|
91
|
+
),
|
|
92
|
+
features=[FeatureEdit(feature_id=feature_id, strength=strength)],
|
|
93
|
+
description="Transient experimental condition; not a shipped patch.",
|
|
94
|
+
evidence_level="none",
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def run_intervention_experiment(
|
|
99
|
+
model: BrainPatchedModel,
|
|
100
|
+
prompts: Sequence[str],
|
|
101
|
+
*,
|
|
102
|
+
feature_id: int,
|
|
103
|
+
strength: float,
|
|
104
|
+
unrelated_feature_id: int,
|
|
105
|
+
generation: GenerationConfig | None = None,
|
|
106
|
+
control_seed: int = 1234,
|
|
107
|
+
) -> list[ConditionResult]:
|
|
108
|
+
"""Run every condition over every prompt and return all generations.
|
|
109
|
+
|
|
110
|
+
Generation settings are identical across conditions by construction: the
|
|
111
|
+
same :class:`GenerationConfig` object is passed to each call.
|
|
112
|
+
"""
|
|
113
|
+
cfg = generation or GenerationConfig()
|
|
114
|
+
results: list[ConditionResult] = []
|
|
115
|
+
|
|
116
|
+
def run(condition: str, prompt_index: int, prompt: str, **kwargs) -> ConditionResult:
|
|
117
|
+
text = model.generate(prompt, config=cfg, **kwargs)
|
|
118
|
+
return ConditionResult(
|
|
119
|
+
condition=condition,
|
|
120
|
+
prompt_index=prompt_index,
|
|
121
|
+
prompt=prompt,
|
|
122
|
+
text=text,
|
|
123
|
+
feature_id=kwargs.get("_feature_id"),
|
|
124
|
+
strength=kwargs.get("_strength", 0.0),
|
|
125
|
+
steering_stats=model.last_steering_stats,
|
|
126
|
+
metrics=score_generation(text).to_dict(),
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
for i, prompt in enumerate(prompts):
|
|
130
|
+
# --- baseline: nothing installed --------------------------------------
|
|
131
|
+
saved = dict(model.plan.patches)
|
|
132
|
+
model.plan.patches = {}
|
|
133
|
+
baseline_text = model.generate(prompt, config=cfg)
|
|
134
|
+
results.append(
|
|
135
|
+
ConditionResult(
|
|
136
|
+
condition="baseline",
|
|
137
|
+
prompt_index=i,
|
|
138
|
+
prompt=prompt,
|
|
139
|
+
text=baseline_text,
|
|
140
|
+
feature_id=None,
|
|
141
|
+
strength=0.0,
|
|
142
|
+
steering_stats={},
|
|
143
|
+
metrics=score_generation(baseline_text).to_dict(),
|
|
144
|
+
)
|
|
145
|
+
)
|
|
146
|
+
model.plan.patches = saved
|
|
147
|
+
|
|
148
|
+
conditions: list[tuple[str, int, float, str]] = [
|
|
149
|
+
("zero", feature_id, 0.0, "none"),
|
|
150
|
+
("positive", feature_id, strength, "none"),
|
|
151
|
+
("negative", feature_id, -strength, "none"),
|
|
152
|
+
("random_positive", feature_id, strength, "random"),
|
|
153
|
+
("random_negative", feature_id, -strength, "random"),
|
|
154
|
+
("unrelated_positive", unrelated_feature_id, strength, "none"),
|
|
155
|
+
]
|
|
156
|
+
|
|
157
|
+
for condition, fid, magnitude, control in conditions:
|
|
158
|
+
model.plan.patches = {}
|
|
159
|
+
model.install(_spec_for(model, fid, magnitude, f"cond-{condition}"))
|
|
160
|
+
text = model.generate(prompt, config=cfg, control=control, control_seed=control_seed)
|
|
161
|
+
results.append(
|
|
162
|
+
ConditionResult(
|
|
163
|
+
condition=condition,
|
|
164
|
+
prompt_index=i,
|
|
165
|
+
prompt=prompt,
|
|
166
|
+
text=text,
|
|
167
|
+
feature_id=fid,
|
|
168
|
+
strength=magnitude,
|
|
169
|
+
steering_stats=model.last_steering_stats,
|
|
170
|
+
metrics=score_generation(text).to_dict(),
|
|
171
|
+
)
|
|
172
|
+
)
|
|
173
|
+
model.plan.patches = {}
|
|
174
|
+
|
|
175
|
+
model.plan.patches = saved
|
|
176
|
+
|
|
177
|
+
return results
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def summarize_experiment(results: Sequence[ConditionResult]) -> dict[str, Any]:
|
|
181
|
+
"""Aggregate per-condition statistics and the zero-strength sanity check.
|
|
182
|
+
|
|
183
|
+
``divergence_from_baseline`` is ``1 - Jaccard(3-gram)`` between a condition's
|
|
184
|
+
output and the baseline for the same prompt: 0 means identical text, 1 means
|
|
185
|
+
no shared trigrams. Comparing an intervention's divergence against its
|
|
186
|
+
scale-matched random control is the core of the causal claim.
|
|
187
|
+
"""
|
|
188
|
+
by_prompt: dict[int, dict[str, ConditionResult]] = {}
|
|
189
|
+
for r in results:
|
|
190
|
+
by_prompt.setdefault(r.prompt_index, {})[r.condition] = r
|
|
191
|
+
|
|
192
|
+
conditions = sorted({r.condition for r in results})
|
|
193
|
+
summary: dict[str, Any] = {"num_prompts": len(by_prompt), "conditions": {}}
|
|
194
|
+
|
|
195
|
+
zero_identical = 0
|
|
196
|
+
zero_total = 0
|
|
197
|
+
|
|
198
|
+
for condition in conditions:
|
|
199
|
+
divergences: list[float] = []
|
|
200
|
+
lengths: list[int] = []
|
|
201
|
+
degenerations = 0
|
|
202
|
+
distinct2: list[float] = []
|
|
203
|
+
delta_norms: list[float] = []
|
|
204
|
+
count = 0
|
|
205
|
+
|
|
206
|
+
for prompt_results in by_prompt.values():
|
|
207
|
+
result = prompt_results.get(condition)
|
|
208
|
+
baseline = prompt_results.get("baseline")
|
|
209
|
+
if result is None or baseline is None:
|
|
210
|
+
continue
|
|
211
|
+
count += 1
|
|
212
|
+
comparison = compare_generations(baseline.text, result.text)
|
|
213
|
+
divergences.append(1.0 - comparison["jaccard_3"])
|
|
214
|
+
lengths.append(result.metrics["num_words"])
|
|
215
|
+
distinct2.append(result.metrics["distinct_2"])
|
|
216
|
+
if result.metrics["degeneration_flag"]:
|
|
217
|
+
degenerations += 1
|
|
218
|
+
if result.steering_stats:
|
|
219
|
+
delta_norms.append(result.steering_stats.get("mean_delta_norm", 0.0))
|
|
220
|
+
if condition == "zero":
|
|
221
|
+
zero_total += 1
|
|
222
|
+
zero_identical += int(comparison["identical"])
|
|
223
|
+
|
|
224
|
+
summary["conditions"][condition] = {
|
|
225
|
+
"n": count,
|
|
226
|
+
"mean_divergence_from_baseline": _mean(divergences),
|
|
227
|
+
"mean_num_words": _mean(lengths),
|
|
228
|
+
"mean_distinct_2": _mean(distinct2),
|
|
229
|
+
"degeneration_count": degenerations,
|
|
230
|
+
"degeneration_rate": degenerations / count if count else 0.0,
|
|
231
|
+
"mean_delta_norm": _mean(delta_norms) if delta_norms else None,
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
summary["zero_strength_matches_baseline"] = {
|
|
235
|
+
"identical": zero_identical,
|
|
236
|
+
"total": zero_total,
|
|
237
|
+
"all_identical": zero_total > 0 and zero_identical == zero_total,
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
# The comparison that licenses (or refuses) a causal claim.
|
|
241
|
+
def divergence(condition: str) -> float | None:
|
|
242
|
+
entry = summary["conditions"].get(condition)
|
|
243
|
+
return entry["mean_divergence_from_baseline"] if entry else None
|
|
244
|
+
|
|
245
|
+
pos = divergence("positive")
|
|
246
|
+
rand = divergence("random_positive")
|
|
247
|
+
unrelated = divergence("unrelated_positive")
|
|
248
|
+
summary["effect_vs_controls"] = {
|
|
249
|
+
"positive_divergence": pos,
|
|
250
|
+
"random_control_divergence": rand,
|
|
251
|
+
"unrelated_feature_divergence": unrelated,
|
|
252
|
+
"positive_minus_random": (pos - rand) if pos is not None and rand is not None else None,
|
|
253
|
+
"positive_minus_unrelated": (
|
|
254
|
+
(pos - unrelated) if pos is not None and unrelated is not None else None
|
|
255
|
+
),
|
|
256
|
+
"interpretation_note": (
|
|
257
|
+
"A positive difference means the feature direction moved the output "
|
|
258
|
+
"further from baseline than a scale-matched control. It does NOT by "
|
|
259
|
+
"itself identify what changed, or establish any semantic label. "
|
|
260
|
+
"With a handful of prompts and no repeated sampling these differences "
|
|
261
|
+
"carry no statistical significance."
|
|
262
|
+
),
|
|
263
|
+
}
|
|
264
|
+
return summary
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
def write_experiment_artifacts(
|
|
268
|
+
paths: VolumePaths,
|
|
269
|
+
experiment: str,
|
|
270
|
+
config: dict[str, Any],
|
|
271
|
+
results: Sequence[ConditionResult],
|
|
272
|
+
summary: dict[str, Any],
|
|
273
|
+
) -> dict[str, str]:
|
|
274
|
+
"""Persist config, all generations, metrics and a markdown report."""
|
|
275
|
+
out_dir = Path(paths.experiment(experiment))
|
|
276
|
+
out_dir.mkdir(parents=True, exist_ok=True)
|
|
277
|
+
|
|
278
|
+
written: dict[str, str] = {}
|
|
279
|
+
|
|
280
|
+
def dump_jsonl(filename: str, rows: Sequence[ConditionResult]) -> None:
|
|
281
|
+
path = out_dir / filename
|
|
282
|
+
with path.open("w", encoding="utf-8") as handle:
|
|
283
|
+
for row in rows:
|
|
284
|
+
handle.write(json.dumps(row.to_dict(), ensure_ascii=False) + "\n")
|
|
285
|
+
written[filename] = str(path)
|
|
286
|
+
|
|
287
|
+
(out_dir / "config.json").write_text(
|
|
288
|
+
json.dumps(config, indent=2, sort_keys=True, default=str), encoding="utf-8"
|
|
289
|
+
)
|
|
290
|
+
written["config.json"] = str(out_dir / "config.json")
|
|
291
|
+
|
|
292
|
+
dump_jsonl("baseline.jsonl", [r for r in results if r.condition == "baseline"])
|
|
293
|
+
dump_jsonl("interventions.jsonl", [r for r in results if r.condition in INTERVENTION_CONDITIONS])
|
|
294
|
+
dump_jsonl("controls.jsonl", [r for r in results if r.condition in CONTROL_CONDITIONS])
|
|
295
|
+
dump_jsonl("all_generations.jsonl", list(results))
|
|
296
|
+
|
|
297
|
+
(out_dir / "metrics.json").write_text(
|
|
298
|
+
json.dumps(summary, indent=2, sort_keys=True), encoding="utf-8"
|
|
299
|
+
)
|
|
300
|
+
written["metrics.json"] = str(out_dir / "metrics.json")
|
|
301
|
+
|
|
302
|
+
report = render_report(experiment, config, summary, results)
|
|
303
|
+
(out_dir / "report.md").write_text(report, encoding="utf-8")
|
|
304
|
+
written["report.md"] = str(out_dir / "report.md")
|
|
305
|
+
return written
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
def render_report(
|
|
309
|
+
experiment: str,
|
|
310
|
+
config: dict[str, Any],
|
|
311
|
+
summary: dict[str, Any],
|
|
312
|
+
results: Sequence[ConditionResult],
|
|
313
|
+
) -> str:
|
|
314
|
+
"""Render a markdown report that states what was measured and nothing more."""
|
|
315
|
+
lines: list[str] = [
|
|
316
|
+
f"# Intervention experiment: `{experiment}`",
|
|
317
|
+
"",
|
|
318
|
+
f"Generated {time.strftime('%Y-%m-%d %H:%M:%S UTC', time.gmtime())}.",
|
|
319
|
+
"",
|
|
320
|
+
"## Configuration",
|
|
321
|
+
"",
|
|
322
|
+
"```json",
|
|
323
|
+
json.dumps(config, indent=2, sort_keys=True, default=str),
|
|
324
|
+
"```",
|
|
325
|
+
"",
|
|
326
|
+
"## Harness sanity check",
|
|
327
|
+
"",
|
|
328
|
+
]
|
|
329
|
+
zero = summary["zero_strength_matches_baseline"]
|
|
330
|
+
status = "PASS" if zero["all_identical"] else "FAIL"
|
|
331
|
+
lines += [
|
|
332
|
+
f"`strength=0` reproduces baseline exactly: **{status}** "
|
|
333
|
+
f"({zero['identical']}/{zero['total']} prompts identical).",
|
|
334
|
+
"",
|
|
335
|
+
"A failure here invalidates every other number on this page, because it "
|
|
336
|
+
"would mean the hook perturbs the model even when asked to do nothing.",
|
|
337
|
+
"",
|
|
338
|
+
"## Per-condition summary",
|
|
339
|
+
"",
|
|
340
|
+
"| condition | n | divergence from baseline | mean words | distinct-2 | degenerate | mean delta norm |",
|
|
341
|
+
"|---|---|---|---|---|---|---|",
|
|
342
|
+
]
|
|
343
|
+
for condition, entry in summary["conditions"].items():
|
|
344
|
+
norm = entry["mean_delta_norm"]
|
|
345
|
+
lines.append(
|
|
346
|
+
f"| `{condition}` | {entry['n']} | {entry['mean_divergence_from_baseline']:.3f} | "
|
|
347
|
+
f"{entry['mean_num_words']:.1f} | {entry['mean_distinct_2']:.3f} | "
|
|
348
|
+
f"{entry['degeneration_count']} | {f'{norm:.3f}' if norm is not None else '-'} |"
|
|
349
|
+
)
|
|
350
|
+
|
|
351
|
+
effect = summary["effect_vs_controls"]
|
|
352
|
+
lines += [
|
|
353
|
+
"",
|
|
354
|
+
"## Effect versus controls",
|
|
355
|
+
"",
|
|
356
|
+
f"- positive intervention divergence: `{_fmt(effect['positive_divergence'])}`",
|
|
357
|
+
f"- scale-matched random direction: `{_fmt(effect['random_control_divergence'])}`",
|
|
358
|
+
f"- unrelated real feature: `{_fmt(effect['unrelated_feature_divergence'])}`",
|
|
359
|
+
f"- positive minus random: `{_fmt(effect['positive_minus_random'])}`",
|
|
360
|
+
f"- positive minus unrelated: `{_fmt(effect['positive_minus_unrelated'])}`",
|
|
361
|
+
"",
|
|
362
|
+
"> " + effect["interpretation_note"],
|
|
363
|
+
"",
|
|
364
|
+
"## Sample generations",
|
|
365
|
+
"",
|
|
366
|
+
"Unfiltered. The first prompt is shown under every condition.",
|
|
367
|
+
"",
|
|
368
|
+
]
|
|
369
|
+
for result in [r for r in results if r.prompt_index == 0]:
|
|
370
|
+
lines += [
|
|
371
|
+
f"### `{result.condition}` (feature={result.feature_id}, strength={result.strength:+.2f})",
|
|
372
|
+
"",
|
|
373
|
+
"```text",
|
|
374
|
+
result.text.strip() or "(empty)",
|
|
375
|
+
"```",
|
|
376
|
+
"",
|
|
377
|
+
]
|
|
378
|
+
return "\n".join(lines)
|
|
379
|
+
|
|
380
|
+
|
|
381
|
+
def _mean(values: Sequence[float]) -> float:
|
|
382
|
+
return sum(values) / len(values) if values else 0.0
|
|
383
|
+
|
|
384
|
+
|
|
385
|
+
def _fmt(value: float | None) -> str:
|
|
386
|
+
return "n/a" if value is None else f"{value:.4f}"
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
"""Text-corpus ingestion for activation extraction.
|
|
2
|
+
|
|
3
|
+
Turns a Hugging Face dataset into a deterministic stream of fixed-length token
|
|
4
|
+
blocks. Determinism matters more than it might seem: two extraction runs with
|
|
5
|
+
the same seed must produce byte-identical shards, otherwise "resume" is not
|
|
6
|
+
resume, and an SAE cannot be attributed to a specific corpus.
|
|
7
|
+
|
|
8
|
+
Chunking strategy
|
|
9
|
+
-----------------
|
|
10
|
+
Documents are tokenized individually and split into non-overlapping blocks of
|
|
11
|
+
exactly ``sequence_length`` tokens. Trailing remainders shorter than
|
|
12
|
+
``min_block_tokens`` are discarded rather than padded. The result is that every
|
|
13
|
+
stored activation corresponds to a real token in real context -- there is no
|
|
14
|
+
padding to mask out, and no boundary artifacts from concatenating unrelated
|
|
15
|
+
documents inside one block.
|
|
16
|
+
|
|
17
|
+
Licensing note
|
|
18
|
+
--------------
|
|
19
|
+
The default corpus (``Salesforce/wikitext``) is CC BY-SA 3.0, derived from
|
|
20
|
+
Wikipedia. Derived numerical artifacts (SAE weights, activation statistics) are
|
|
21
|
+
publishable; verbatim text is redistributed only as short attributed snippets
|
|
22
|
+
inside feature contexts.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
from __future__ import annotations
|
|
26
|
+
|
|
27
|
+
import random
|
|
28
|
+
from dataclasses import dataclass
|
|
29
|
+
from typing import Any, Iterator
|
|
30
|
+
|
|
31
|
+
#: Small, permissively-licensed default. Good enough to exercise the pipeline;
|
|
32
|
+
#: not claimed to be the right corpus for behaviour-relevant features.
|
|
33
|
+
DEFAULT_DATASET = "Salesforce/wikitext"
|
|
34
|
+
DEFAULT_CONFIG = "wikitext-2-raw-v1"
|
|
35
|
+
DEFAULT_SPLIT = "train"
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@dataclass
|
|
39
|
+
class TokenBlock:
|
|
40
|
+
"""One fixed-length block of tokens with provenance back to its document."""
|
|
41
|
+
|
|
42
|
+
example_index: int
|
|
43
|
+
input_ids: list[int]
|
|
44
|
+
text: str
|
|
45
|
+
source_doc: int
|
|
46
|
+
char_offset: int
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
@dataclass
|
|
50
|
+
class CorpusConfig:
|
|
51
|
+
"""How to turn a dataset into token blocks."""
|
|
52
|
+
|
|
53
|
+
dataset: str = DEFAULT_DATASET
|
|
54
|
+
config: str | None = DEFAULT_CONFIG
|
|
55
|
+
split: str = DEFAULT_SPLIT
|
|
56
|
+
text_column: str = "text"
|
|
57
|
+
sequence_length: int = 256
|
|
58
|
+
min_block_tokens: int = 64
|
|
59
|
+
min_doc_chars: int = 200
|
|
60
|
+
seed: int = 0
|
|
61
|
+
#: Documents drawn from the head of the split before shuffling. Bounding
|
|
62
|
+
#: this keeps a smoke run from streaming the whole dataset.
|
|
63
|
+
max_documents: int = 20_000
|
|
64
|
+
|
|
65
|
+
def to_dict(self) -> dict[str, Any]:
|
|
66
|
+
return {
|
|
67
|
+
"dataset": self.dataset,
|
|
68
|
+
"config": self.config,
|
|
69
|
+
"split": self.split,
|
|
70
|
+
"text_column": self.text_column,
|
|
71
|
+
"sequence_length": self.sequence_length,
|
|
72
|
+
"min_block_tokens": self.min_block_tokens,
|
|
73
|
+
"min_doc_chars": self.min_doc_chars,
|
|
74
|
+
"seed": self.seed,
|
|
75
|
+
"max_documents": self.max_documents,
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
@property
|
|
79
|
+
def dataset_id(self) -> str:
|
|
80
|
+
"""Human-readable identifier recorded in the manifest."""
|
|
81
|
+
return f"{self.dataset}:{self.config}" if self.config else self.dataset
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def load_documents(cfg: CorpusConfig) -> list[str]:
|
|
85
|
+
"""Load and deterministically shuffle raw documents.
|
|
86
|
+
|
|
87
|
+
Short documents are dropped first, so the shuffle operates on the set that
|
|
88
|
+
will actually be used and the seed therefore selects the same documents
|
|
89
|
+
regardless of how many are ultimately consumed.
|
|
90
|
+
"""
|
|
91
|
+
from datasets import load_dataset
|
|
92
|
+
|
|
93
|
+
dataset = load_dataset(cfg.dataset, cfg.config, split=cfg.split)
|
|
94
|
+
|
|
95
|
+
docs: list[str] = []
|
|
96
|
+
for i, row in enumerate(dataset):
|
|
97
|
+
if i >= cfg.max_documents:
|
|
98
|
+
break
|
|
99
|
+
text = row.get(cfg.text_column)
|
|
100
|
+
if not isinstance(text, str):
|
|
101
|
+
continue
|
|
102
|
+
text = text.strip()
|
|
103
|
+
if len(text) < cfg.min_doc_chars:
|
|
104
|
+
continue
|
|
105
|
+
docs.append(text)
|
|
106
|
+
|
|
107
|
+
random.Random(cfg.seed).shuffle(docs)
|
|
108
|
+
return docs
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def iter_token_blocks(
|
|
112
|
+
tokenizer: Any,
|
|
113
|
+
cfg: CorpusConfig,
|
|
114
|
+
*,
|
|
115
|
+
documents: list[str] | None = None,
|
|
116
|
+
start_example: int = 0,
|
|
117
|
+
) -> Iterator[TokenBlock]:
|
|
118
|
+
"""Yield fixed-length token blocks, deterministically.
|
|
119
|
+
|
|
120
|
+
Parameters
|
|
121
|
+
----------
|
|
122
|
+
start_example:
|
|
123
|
+
Skip this many blocks before yielding. Used on resume so a restarted
|
|
124
|
+
run reproduces exactly the block sequence it would have produced had it
|
|
125
|
+
never stopped.
|
|
126
|
+
"""
|
|
127
|
+
docs = documents if documents is not None else load_documents(cfg)
|
|
128
|
+
example_index = 0
|
|
129
|
+
|
|
130
|
+
for doc_index, text in enumerate(docs):
|
|
131
|
+
ids = tokenizer(text, add_special_tokens=False)["input_ids"]
|
|
132
|
+
n_blocks = len(ids) // cfg.sequence_length
|
|
133
|
+
remainder = len(ids) - n_blocks * cfg.sequence_length
|
|
134
|
+
|
|
135
|
+
spans: list[tuple[int, int]] = [
|
|
136
|
+
(b * cfg.sequence_length, (b + 1) * cfg.sequence_length) for b in range(n_blocks)
|
|
137
|
+
]
|
|
138
|
+
if remainder >= cfg.min_block_tokens:
|
|
139
|
+
spans.append((n_blocks * cfg.sequence_length, len(ids)))
|
|
140
|
+
|
|
141
|
+
for start, end in spans:
|
|
142
|
+
block_ids = ids[start:end]
|
|
143
|
+
if len(block_ids) < cfg.min_block_tokens:
|
|
144
|
+
continue
|
|
145
|
+
if example_index >= start_example:
|
|
146
|
+
yield TokenBlock(
|
|
147
|
+
example_index=example_index,
|
|
148
|
+
input_ids=block_ids,
|
|
149
|
+
text=tokenizer.decode(block_ids),
|
|
150
|
+
source_doc=doc_index,
|
|
151
|
+
char_offset=start,
|
|
152
|
+
)
|
|
153
|
+
example_index += 1
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def batched(iterator: Iterator[TokenBlock], batch_size: int) -> Iterator[list[TokenBlock]]:
|
|
157
|
+
"""Group blocks into batches, yielding a short final batch if needed."""
|
|
158
|
+
batch: list[TokenBlock] = []
|
|
159
|
+
for block in iterator:
|
|
160
|
+
batch.append(block)
|
|
161
|
+
if len(batch) == batch_size:
|
|
162
|
+
yield batch
|
|
163
|
+
batch = []
|
|
164
|
+
if batch:
|
|
165
|
+
yield batch
|