logogram 0.1.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.
- logogram/__init__.py +6 -0
- logogram/__main__.py +5 -0
- logogram/analysis.py +419 -0
- logogram/atp.py +120 -0
- logogram/backends/__init__.py +5 -0
- logogram/backends/base.py +202 -0
- logogram/backends/hub.py +375 -0
- logogram/backends/saes.py +277 -0
- logogram/backends/transformer_lens.py +872 -0
- logogram/cli.py +496 -0
- logogram/compare.py +177 -0
- logogram/datasets.py +159 -0
- logogram/direct.py +193 -0
- logogram/engine.py +550 -0
- logogram/examples/ioi-gpt2/.gitignore +3 -0
- logogram/examples/ioi-gpt2/datasets/ioi.jsonl +32 -0
- logogram/examples/ioi-gpt2/experiments/ioi-head-patching/spec.json +42 -0
- logogram/examples/ioi-gpt2/project.json +6 -0
- logogram/exports.py +33 -0
- logogram/features.py +368 -0
- logogram/fileio.py +63 -0
- logogram/ioi.py +220 -0
- logogram/paths.py +204 -0
- logogram/project.py +444 -0
- logogram/prompts.py +204 -0
- logogram/research.py +84 -0
- logogram/results.py +240 -0
- logogram/runner.py +396 -0
- logogram/runs.py +98 -0
- logogram/sae.py +161 -0
- logogram/schema.py +302 -0
- logogram/server/__init__.py +1 -0
- logogram/server/app.py +1083 -0
- logogram/server/models.py +426 -0
- logogram/server/security.py +212 -0
- logogram/server/state.py +585 -0
- logogram/sites.py +249 -0
- logogram/spec.py +518 -0
- logogram/stats.py +171 -0
- logogram/steering.py +258 -0
- logogram/system.py +379 -0
- logogram/updates.py +194 -0
- logogram/verify.py +39 -0
- logogram/web_dist/assets/index-BvCU-2uy.js +54 -0
- logogram/web_dist/assets/index-DTr8_ucV.css +1 -0
- logogram/web_dist/assets/instrument-sans-latin-ext-standard-normal-C5E2Gvlv.woff2 +0 -0
- logogram/web_dist/assets/instrument-sans-latin-standard-normal-BVScPF0l.woff2 +0 -0
- logogram/web_dist/favicon.svg +1 -0
- logogram/web_dist/index.html +15 -0
- logogram-0.1.0.dist-info/METADATA +550 -0
- logogram-0.1.0.dist-info/RECORD +54 -0
- logogram-0.1.0.dist-info/WHEEL +4 -0
- logogram-0.1.0.dist-info/entry_points.txt +2 -0
- logogram-0.1.0.dist-info/licenses/LICENSE +21 -0
logogram/spec.py
ADDED
|
@@ -0,0 +1,518 @@
|
|
|
1
|
+
"""The experiment spec: the single description of an experiment that the GUI and CLI both run.
|
|
2
|
+
|
|
3
|
+
A spec is stored as ``experiments/<id>/spec.json``. Every methodological choice that can change
|
|
4
|
+
a number (direction, baseline, position, metric, normalization, seeds, batch size, dtype) lives
|
|
5
|
+
here explicitly, so a run can always be traced back to how it was produced.
|
|
6
|
+
|
|
7
|
+
Specs refer to abstract sites (``resid_pre``, ``attn_out``, ``head``, ...). Backend-specific hook
|
|
8
|
+
names never appear in a spec.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import json
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from typing import Annotated, Literal
|
|
16
|
+
|
|
17
|
+
from pydantic import BaseModel, ConfigDict, Field, model_validator
|
|
18
|
+
|
|
19
|
+
NAME_MAX = 200
|
|
20
|
+
|
|
21
|
+
SPEC_VERSION = 1
|
|
22
|
+
|
|
23
|
+
StreamSiteKind = Literal["resid_pre", "resid_mid", "resid_post", "attn_out", "mlp_out"]
|
|
24
|
+
SiteKind = Literal[
|
|
25
|
+
"resid_pre", "resid_mid", "resid_post", "attn_out", "mlp_out", "head", "sae_feature"
|
|
26
|
+
]
|
|
27
|
+
|
|
28
|
+
SITE_KIND_LABELS: dict[str, str] = {
|
|
29
|
+
"resid_pre": "residual stream before the layer",
|
|
30
|
+
"resid_mid": "residual stream between attention and MLP",
|
|
31
|
+
"resid_post": "residual stream after the layer",
|
|
32
|
+
"attn_out": "attention output",
|
|
33
|
+
"mlp_out": "MLP output",
|
|
34
|
+
"head": "attention head output (z)",
|
|
35
|
+
"sae_feature": "SAE feature",
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class _Strict(BaseModel):
|
|
40
|
+
model_config = ConfigDict(extra="forbid")
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
# ---------------------------------------------------------------------------------------------
|
|
44
|
+
# Positions
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class AllPositions(_Strict):
|
|
48
|
+
"""Intervene on every token position at once."""
|
|
49
|
+
|
|
50
|
+
kind: Literal["all"] = "all"
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class LastPosition(_Strict):
|
|
54
|
+
"""The final token of each prompt (the position that predicts the answer)."""
|
|
55
|
+
|
|
56
|
+
kind: Literal["last"] = "last"
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class IndexPosition(_Strict):
|
|
60
|
+
"""A fixed token index. Negative values count from the end (-1 is the last token)."""
|
|
61
|
+
|
|
62
|
+
kind: Literal["index"] = "index"
|
|
63
|
+
index: int
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
class LabelPosition(_Strict):
|
|
67
|
+
"""A named position from the dataset, such as ``S2`` in IOI prompts."""
|
|
68
|
+
|
|
69
|
+
kind: Literal["label"] = "label"
|
|
70
|
+
label: str = Field(min_length=1)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
Position = Annotated[
|
|
74
|
+
AllPositions | LastPosition | IndexPosition | LabelPosition, Field(discriminator="kind")
|
|
75
|
+
]
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def describe_position(position: AllPositions | LastPosition | IndexPosition | LabelPosition) -> str:
|
|
79
|
+
if isinstance(position, AllPositions):
|
|
80
|
+
return "all positions"
|
|
81
|
+
if isinstance(position, LastPosition):
|
|
82
|
+
return "the last token"
|
|
83
|
+
if isinstance(position, IndexPosition):
|
|
84
|
+
return f"token index {position.index}"
|
|
85
|
+
return f"position {position.label}"
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
# ---------------------------------------------------------------------------------------------
|
|
89
|
+
# Sites and sweep scopes
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
class Site(_Strict):
|
|
93
|
+
kind: SiteKind
|
|
94
|
+
layer: int = Field(ge=0)
|
|
95
|
+
head: int | None = Field(default=None, ge=0)
|
|
96
|
+
# A feature of the spec's SAE (kind sae_feature): its index among the SAE's features.
|
|
97
|
+
feature: int | None = Field(default=None, ge=0)
|
|
98
|
+
position: Position = Field(default_factory=AllPositions)
|
|
99
|
+
|
|
100
|
+
@model_validator(mode="after")
|
|
101
|
+
def _head_matches_kind(self) -> Site:
|
|
102
|
+
if self.kind == "head" and self.head is None:
|
|
103
|
+
raise ValueError("a head site needs a head index")
|
|
104
|
+
if self.kind != "head" and self.head is not None:
|
|
105
|
+
raise ValueError(f"a {self.kind} site has no head index")
|
|
106
|
+
if self.kind == "sae_feature" and self.feature is None:
|
|
107
|
+
raise ValueError("an SAE feature site needs a feature index")
|
|
108
|
+
if self.kind != "sae_feature" and self.feature is not None:
|
|
109
|
+
raise ValueError(f"a {self.kind} site has no feature index")
|
|
110
|
+
return self
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
class HeadsScope(_Strict):
|
|
114
|
+
"""Every attention head in every layer, at one position: a layer x head grid."""
|
|
115
|
+
|
|
116
|
+
kind: Literal["heads"] = "heads"
|
|
117
|
+
position: Position = Field(default_factory=AllPositions)
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
class LayerPositionScope(_Strict):
|
|
121
|
+
"""One stream site at every layer and every position: a layer x position grid.
|
|
122
|
+
|
|
123
|
+
``positions="each"`` uses every token index and needs prompts of equal length.
|
|
124
|
+
``positions="labels"`` uses the dataset's named positions, which works across templates.
|
|
125
|
+
"""
|
|
126
|
+
|
|
127
|
+
kind: Literal["layer_position"] = "layer_position"
|
|
128
|
+
site: StreamSiteKind = "resid_pre"
|
|
129
|
+
positions: Literal["each", "labels"] = "each"
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
class LayerComponentsScope(_Strict):
|
|
133
|
+
"""Attention and MLP outputs of every layer, at one position: a layer x component grid."""
|
|
134
|
+
|
|
135
|
+
kind: Literal["layer_components"] = "layer_components"
|
|
136
|
+
components: list[StreamSiteKind] = Field(default_factory=lambda: ["attn_out", "mlp_out"])
|
|
137
|
+
position: Position = Field(default_factory=AllPositions)
|
|
138
|
+
|
|
139
|
+
@model_validator(mode="after")
|
|
140
|
+
def _non_empty(self) -> LayerComponentsScope:
|
|
141
|
+
if not self.components:
|
|
142
|
+
raise ValueError("choose at least one component")
|
|
143
|
+
if len(set(self.components)) != len(self.components):
|
|
144
|
+
raise ValueError("components must not repeat")
|
|
145
|
+
return self
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
class SitesScope(_Strict):
|
|
149
|
+
"""An explicit list of sites, for example a single head chosen on the map."""
|
|
150
|
+
|
|
151
|
+
kind: Literal["sites"] = "sites"
|
|
152
|
+
sites: list[Site] = Field(min_length=1)
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
class FeaturesScope(_Strict):
|
|
156
|
+
"""Every feature of the spec's SAE, at one position (or summed over all of them).
|
|
157
|
+
|
|
158
|
+
Only attribution patching can sweep every feature: it estimates them all from one gradient
|
|
159
|
+
and keeps the ``top`` with the largest estimated effects (by magnitude) as the run's sites,
|
|
160
|
+
ready to verify by patching.
|
|
161
|
+
"""
|
|
162
|
+
|
|
163
|
+
kind: Literal["features"] = "features"
|
|
164
|
+
position: Position = Field(default_factory=AllPositions)
|
|
165
|
+
top: int = Field(ge=1, le=500)
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
Scope = Annotated[
|
|
169
|
+
HeadsScope | LayerPositionScope | LayerComponentsScope | SitesScope | FeaturesScope,
|
|
170
|
+
Field(discriminator="kind"),
|
|
171
|
+
]
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
# ---------------------------------------------------------------------------------------------
|
|
175
|
+
# Experiments
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
class ActivationPatching(_Strict):
|
|
179
|
+
"""Copy activations from one prompt of each pair into the run on the other.
|
|
180
|
+
|
|
181
|
+
``clean_to_corrupt`` runs the corrupt prompt and patches in clean activations
|
|
182
|
+
("does this restore the behavior?"). ``corrupt_to_clean`` runs the clean prompt and patches
|
|
183
|
+
in corrupt activations ("does this break it?").
|
|
184
|
+
"""
|
|
185
|
+
|
|
186
|
+
kind: Literal["activation_patching"] = "activation_patching"
|
|
187
|
+
direction: Literal["clean_to_corrupt", "corrupt_to_clean"]
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
class ZeroBaseline(_Strict):
|
|
191
|
+
kind: Literal["zero"] = "zero"
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
class MeanBaseline(_Strict):
|
|
195
|
+
"""Replace the activation with its mean over a stated reference set of prompts.
|
|
196
|
+
|
|
197
|
+
With an all-positions intervention the mean is taken per position, over reference prompts
|
|
198
|
+
of the same token length. With a single position it is taken at each reference prompt's own
|
|
199
|
+
resolved position.
|
|
200
|
+
"""
|
|
201
|
+
|
|
202
|
+
kind: Literal["mean"] = "mean"
|
|
203
|
+
reference: Literal["clean", "corrupt"] = "corrupt"
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
class ResampleBaseline(_Strict):
|
|
207
|
+
"""Replace the activation with the same activation from other prompts (donors).
|
|
208
|
+
|
|
209
|
+
Each prompt draws ``donors`` donors from the pool without replacement, never itself, using
|
|
210
|
+
``seed``. The patched metric is averaged over donors. The same donors are used at every site.
|
|
211
|
+
"""
|
|
212
|
+
|
|
213
|
+
kind: Literal["resample"] = "resample"
|
|
214
|
+
pool: Literal["clean", "corrupt"] = "corrupt"
|
|
215
|
+
donors: int = Field(default=10, ge=1, le=1000)
|
|
216
|
+
seed: int = 0
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
Baseline = Annotated[ZeroBaseline | MeanBaseline | ResampleBaseline, Field(discriminator="kind")]
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
class Ablation(_Strict):
|
|
223
|
+
"""Run the clean prompt and replace the activation with a baseline value."""
|
|
224
|
+
|
|
225
|
+
kind: Literal["ablation"] = "ablation"
|
|
226
|
+
baseline: Baseline
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
class DirectLogitAttribution(_Strict):
|
|
230
|
+
"""Split the logit difference of the chosen prompts into what each component writes directly.
|
|
231
|
+
|
|
232
|
+
At the last position, the residual stream is the sum of the embeddings and every attention and
|
|
233
|
+
MLP output. With the final normalization's scale held at its value in the run, the logit
|
|
234
|
+
difference is a sum of one term per component plus a constant from biases. A component's term
|
|
235
|
+
is its direct effect: it leaves out everything the component does through later components.
|
|
236
|
+
This is a decomposition of one forward pass, not an intervention.
|
|
237
|
+
"""
|
|
238
|
+
|
|
239
|
+
kind: Literal["direct_logit_attribution"] = "direct_logit_attribution"
|
|
240
|
+
prompts: Literal["clean", "corrupt"]
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
class AttributionPatching(_Strict):
|
|
244
|
+
"""Estimate activation patching at every site from one gradient (attribution patching).
|
|
245
|
+
|
|
246
|
+
For each site, the change patching would cause is estimated as (source activation - receiver
|
|
247
|
+
activation) · the gradient of the logit difference at the receiver run, from one forward and
|
|
248
|
+
backward pass per batch of prompts instead of one patched run per site. It is a first-order
|
|
249
|
+
estimate: it misses saturation (in attention, normalization and the softmax) and can miss or
|
|
250
|
+
even invert an effect, so verify the strongest sites with activation patching.
|
|
251
|
+
"""
|
|
252
|
+
|
|
253
|
+
kind: Literal["attribution_patching"] = "attribution_patching"
|
|
254
|
+
direction: Literal["clean_to_corrupt", "corrupt_to_clean"]
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
class Steering(_Strict):
|
|
258
|
+
"""Add a direction to the residual stream at several strengths, and measure what it does.
|
|
259
|
+
|
|
260
|
+
At each steered site, the direction is the mean difference between the two prompts of each
|
|
261
|
+
pair, from the receiver (``apply_to``) toward the other prompt. It is computed on a training
|
|
262
|
+
split of the prompts and applied to the held-out rest, so no prompt receives its own
|
|
263
|
+
difference. A strength of 1 adds the whole mean difference. The effect is normalized like
|
|
264
|
+
patching: 1 means the steered prompt moves as far as switching to the other prompt. With
|
|
265
|
+
``control``, a random direction of the same length, at the same strengths, runs alongside.
|
|
266
|
+
"""
|
|
267
|
+
|
|
268
|
+
kind: Literal["steering"] = "steering"
|
|
269
|
+
apply_to: Literal["clean", "corrupt"]
|
|
270
|
+
coefficients: list[float] = Field(min_length=1, max_length=16)
|
|
271
|
+
train_fraction: float = Field(gt=0.0, lt=1.0)
|
|
272
|
+
seed: int
|
|
273
|
+
control: bool
|
|
274
|
+
|
|
275
|
+
@model_validator(mode="after")
|
|
276
|
+
def _distinct_finite(self) -> Steering:
|
|
277
|
+
if any(c != c or c in (float("inf"), float("-inf")) for c in self.coefficients):
|
|
278
|
+
raise ValueError("steering strengths must be finite numbers")
|
|
279
|
+
if len(set(self.coefficients)) != len(self.coefficients):
|
|
280
|
+
raise ValueError("steering strengths must not repeat")
|
|
281
|
+
return self
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
class HeadReceiver(_Strict):
|
|
285
|
+
"""A later attention head whose query, key or value input receives the path."""
|
|
286
|
+
|
|
287
|
+
kind: Literal["head"] = "head"
|
|
288
|
+
layer: int = Field(ge=0)
|
|
289
|
+
head: int = Field(ge=0)
|
|
290
|
+
input: Literal["q", "k", "v"]
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
class LogitsReceiver(_Strict):
|
|
294
|
+
"""The residual stream at the end of the model, read directly by the unembedding."""
|
|
295
|
+
|
|
296
|
+
kind: Literal["logits"] = "logits"
|
|
297
|
+
|
|
298
|
+
|
|
299
|
+
PathReceiver = Annotated[HeadReceiver | LogitsReceiver, Field(discriminator="kind")]
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
class PathPatching(_Strict):
|
|
303
|
+
"""Patch a component's effect along chosen paths only (path patching).
|
|
304
|
+
|
|
305
|
+
For each sender (a site of the scope), the receiver prompt runs with the sender's activation
|
|
306
|
+
from the source prompt and every other attention head held at its own value, so the change
|
|
307
|
+
travels only through the residual stream (and through the MLPs, unless ``freeze_mlps``). The
|
|
308
|
+
receivers' inputs from that run are recorded, then patched into an otherwise unchanged
|
|
309
|
+
receiver run, and the metric is read there. The result is the sender's effect through those
|
|
310
|
+
receivers alone. Senders must come before at least one receiver.
|
|
311
|
+
"""
|
|
312
|
+
|
|
313
|
+
kind: Literal["path_patching"] = "path_patching"
|
|
314
|
+
direction: Literal["clean_to_corrupt", "corrupt_to_clean"]
|
|
315
|
+
receivers: list[PathReceiver] = Field(min_length=1, max_length=64)
|
|
316
|
+
freeze_mlps: bool
|
|
317
|
+
|
|
318
|
+
@model_validator(mode="after")
|
|
319
|
+
def _distinct(self) -> PathPatching:
|
|
320
|
+
keys = [r.model_dump_json() for r in self.receivers]
|
|
321
|
+
if len(set(keys)) != len(keys):
|
|
322
|
+
raise ValueError("path patching receivers must not repeat")
|
|
323
|
+
return self
|
|
324
|
+
|
|
325
|
+
|
|
326
|
+
def receiver_text(receiver: HeadReceiver | LogitsReceiver) -> str:
|
|
327
|
+
if isinstance(receiver, LogitsReceiver):
|
|
328
|
+
return "logits"
|
|
329
|
+
return f"L{receiver.layer} H{receiver.head} {receiver.input}"
|
|
330
|
+
|
|
331
|
+
|
|
332
|
+
Experiment = Annotated[
|
|
333
|
+
ActivationPatching
|
|
334
|
+
| Ablation
|
|
335
|
+
| DirectLogitAttribution
|
|
336
|
+
| AttributionPatching
|
|
337
|
+
| Steering
|
|
338
|
+
| PathPatching,
|
|
339
|
+
Field(discriminator="kind"),
|
|
340
|
+
]
|
|
341
|
+
|
|
342
|
+
|
|
343
|
+
def strength_text(coefficient: float) -> str:
|
|
344
|
+
"""A steering strength as written on the map, for example ×2 or ×−0.5."""
|
|
345
|
+
text = f"{coefficient:g}".replace("-", "−")
|
|
346
|
+
return f"×{text}"
|
|
347
|
+
|
|
348
|
+
|
|
349
|
+
# ---------------------------------------------------------------------------------------------
|
|
350
|
+
# Model, data, metric, statistics
|
|
351
|
+
|
|
352
|
+
|
|
353
|
+
class ModelRef(_Strict):
|
|
354
|
+
id: str = Field(min_length=1, description="Hugging Face model id")
|
|
355
|
+
revision: str | None = Field(
|
|
356
|
+
default=None, description="Exact commit. None resolves the current main branch at run time."
|
|
357
|
+
)
|
|
358
|
+
dtype: Literal["float32", "float16", "bfloat16"] = "float32"
|
|
359
|
+
device: Literal["auto", "cpu", "cuda", "mps"] = "auto"
|
|
360
|
+
process_weights: bool = Field(
|
|
361
|
+
default=True,
|
|
362
|
+
description="Fold LayerNorm weights and center writing weights and the unembedding, as "
|
|
363
|
+
"TransformerLens does by default. Logit differences are unchanged by this.",
|
|
364
|
+
)
|
|
365
|
+
|
|
366
|
+
|
|
367
|
+
class DatasetRef(_Strict):
|
|
368
|
+
path: str = Field(min_length=1, description="Project-relative path to a JSONL dataset")
|
|
369
|
+
sha256: str | None = Field(default=None, description="Expected hash of the file, if pinned")
|
|
370
|
+
limit: int | None = Field(default=None, ge=1, description="Use only the first n prompts")
|
|
371
|
+
|
|
372
|
+
|
|
373
|
+
class SAERef(_Strict):
|
|
374
|
+
"""A published sparse autoencoder on Hugging Face: the repository, the folder holding it
|
|
375
|
+
(empty for the top level) and the exact commit, pinned when a run starts."""
|
|
376
|
+
|
|
377
|
+
repo: str = Field(min_length=1)
|
|
378
|
+
path: str = ""
|
|
379
|
+
revision: str | None = None
|
|
380
|
+
|
|
381
|
+
|
|
382
|
+
class Tokenization(_Strict):
|
|
383
|
+
prepend_bos: bool = True
|
|
384
|
+
|
|
385
|
+
|
|
386
|
+
class Metric(_Strict):
|
|
387
|
+
"""Logit difference at the last position: logit(answer) - logit(distractor).
|
|
388
|
+
|
|
389
|
+
The normalized effect for each prompt is (patched - receiver) divided by either the
|
|
390
|
+
dataset's mean gap (``dataset_gap``) or that prompt's own gap (``prompt_gap``). The gap is
|
|
391
|
+
source - receiver for patching, and corrupt - clean for ablation. 0 means no change and 1
|
|
392
|
+
means a change as large as swapping to the other prompt.
|
|
393
|
+
"""
|
|
394
|
+
|
|
395
|
+
kind: Literal["logit_diff"] = "logit_diff"
|
|
396
|
+
normalization: Literal["dataset_gap", "prompt_gap"] = "dataset_gap"
|
|
397
|
+
|
|
398
|
+
|
|
399
|
+
class Statistics(_Strict):
|
|
400
|
+
bootstrap: int = Field(default=1000, ge=100, le=100_000)
|
|
401
|
+
ci: float = Field(default=0.95, gt=0.5, lt=1.0)
|
|
402
|
+
seed: int = 0
|
|
403
|
+
|
|
404
|
+
|
|
405
|
+
class Execution(_Strict):
|
|
406
|
+
batch_size: int = Field(
|
|
407
|
+
default=64,
|
|
408
|
+
ge=1,
|
|
409
|
+
le=4096,
|
|
410
|
+
description="Rows per forward pass. Part of the spec because batch shape can change "
|
|
411
|
+
"floating-point results in the last digits.",
|
|
412
|
+
)
|
|
413
|
+
|
|
414
|
+
|
|
415
|
+
class PredictionSettings(_Strict):
|
|
416
|
+
"""An optional, explicitly configured diagnostic; it never changes an intervention."""
|
|
417
|
+
|
|
418
|
+
method: Literal["final_norm_logit_lens"]
|
|
419
|
+
prompt_index: int = Field(ge=0)
|
|
420
|
+
which: Literal["clean", "corrupt"]
|
|
421
|
+
position: Annotated[LastPosition | IndexPosition, Field(discriminator="kind")]
|
|
422
|
+
top_k: int = Field(ge=1, le=20)
|
|
423
|
+
|
|
424
|
+
|
|
425
|
+
class Spec(_Strict):
|
|
426
|
+
logogram_spec: Literal[1] = SPEC_VERSION
|
|
427
|
+
name: str = Field(min_length=1, max_length=NAME_MAX)
|
|
428
|
+
notes: str = ""
|
|
429
|
+
model: ModelRef
|
|
430
|
+
dataset: DatasetRef
|
|
431
|
+
tokenization: Tokenization = Field(default_factory=Tokenization)
|
|
432
|
+
experiment: Experiment
|
|
433
|
+
scope: Scope
|
|
434
|
+
metric: Metric = Field(default_factory=Metric)
|
|
435
|
+
statistics: Statistics = Field(default_factory=Statistics)
|
|
436
|
+
execution: Execution = Field(default_factory=Execution)
|
|
437
|
+
predictions: PredictionSettings | None = None
|
|
438
|
+
# The SAE whose features sae_feature sites and the features scope refer to.
|
|
439
|
+
sae: SAERef | None = None
|
|
440
|
+
|
|
441
|
+
@model_validator(mode="after")
|
|
442
|
+
def _features_have_an_sae(self) -> Spec:
|
|
443
|
+
uses = isinstance(self.scope, FeaturesScope) or (
|
|
444
|
+
isinstance(self.scope, SitesScope)
|
|
445
|
+
and any(s.kind == "sae_feature" for s in self.scope.sites)
|
|
446
|
+
)
|
|
447
|
+
if uses and self.sae is None:
|
|
448
|
+
raise ValueError("SAE features need the spec's sae: the SAE they belong to")
|
|
449
|
+
return self
|
|
450
|
+
|
|
451
|
+
def to_json(self) -> str:
|
|
452
|
+
return json.dumps(self.model_dump(mode="json"), indent=2) + "\n"
|
|
453
|
+
|
|
454
|
+
@classmethod
|
|
455
|
+
def from_path(cls, path: str | Path) -> Spec:
|
|
456
|
+
return cls.model_validate_json(Path(path).read_text(encoding="utf-8"))
|
|
457
|
+
|
|
458
|
+
|
|
459
|
+
def describe_intervention(
|
|
460
|
+
exp: ActivationPatching
|
|
461
|
+
| Ablation
|
|
462
|
+
| DirectLogitAttribution
|
|
463
|
+
| AttributionPatching
|
|
464
|
+
| Steering
|
|
465
|
+
| PathPatching,
|
|
466
|
+
) -> str:
|
|
467
|
+
"""The intervention in a few words, for example 'Resample-ablate (10 corrupt donors, seed 0)'."""
|
|
468
|
+
if isinstance(exp, ActivationPatching):
|
|
469
|
+
return (
|
|
470
|
+
"Patch clean → corrupt"
|
|
471
|
+
if exp.direction == "clean_to_corrupt"
|
|
472
|
+
else "Patch corrupt → clean"
|
|
473
|
+
)
|
|
474
|
+
if isinstance(exp, DirectLogitAttribution):
|
|
475
|
+
return f"Direct logit attribution ({exp.prompts} prompts)"
|
|
476
|
+
if isinstance(exp, AttributionPatching):
|
|
477
|
+
arrow = "clean → corrupt" if exp.direction == "clean_to_corrupt" else "corrupt → clean"
|
|
478
|
+
return f"Attribution patching, estimated ({arrow})"
|
|
479
|
+
if isinstance(exp, PathPatching):
|
|
480
|
+
arrow = "clean → corrupt" if exp.direction == "clean_to_corrupt" else "corrupt → clean"
|
|
481
|
+
names = ", ".join(receiver_text(r) for r in exp.receivers[:4])
|
|
482
|
+
more = f" and {len(exp.receivers) - 4} more" if len(exp.receivers) > 4 else ""
|
|
483
|
+
frozen = "heads and MLPs" if exp.freeze_mlps else "other heads"
|
|
484
|
+
return f"Path patching {arrow} into {names}{more} ({frozen} frozen)"
|
|
485
|
+
if isinstance(exp, Steering):
|
|
486
|
+
toward = "corrupt" if exp.apply_to == "clean" else "clean"
|
|
487
|
+
strengths = ", ".join(strength_text(c) for c in exp.coefficients)
|
|
488
|
+
control = ", random control" if exp.control else ""
|
|
489
|
+
return (
|
|
490
|
+
f"Steer {exp.apply_to} prompts toward {toward} ({strengths}; "
|
|
491
|
+
f"{exp.train_fraction:.0%} of pairs train the direction, seed {exp.seed}{control})"
|
|
492
|
+
)
|
|
493
|
+
b = exp.baseline
|
|
494
|
+
if isinstance(b, ZeroBaseline):
|
|
495
|
+
return "Zero-ablate"
|
|
496
|
+
if isinstance(b, MeanBaseline):
|
|
497
|
+
return f"Mean-ablate ({b.reference} mean)"
|
|
498
|
+
return f"Resample-ablate ({b.donors} {b.pool} donors, seed {b.seed})"
|
|
499
|
+
|
|
500
|
+
|
|
501
|
+
def describe_experiment(spec: Spec) -> str:
|
|
502
|
+
"""A one-line plain-language description, used in history and summaries."""
|
|
503
|
+
what = describe_intervention(spec.experiment)
|
|
504
|
+
scope = spec.scope
|
|
505
|
+
if isinstance(scope, HeadsScope):
|
|
506
|
+
where = f"every head, {describe_position(scope.position)}"
|
|
507
|
+
elif isinstance(scope, LayerPositionScope):
|
|
508
|
+
unit = "every position" if scope.positions == "each" else "each labelled position"
|
|
509
|
+
where = f"{scope.site} at every layer and {unit}"
|
|
510
|
+
elif isinstance(scope, LayerComponentsScope):
|
|
511
|
+
where = f"{' and '.join(scope.components)} per layer, {describe_position(scope.position)}"
|
|
512
|
+
elif isinstance(scope, FeaturesScope):
|
|
513
|
+
where = (
|
|
514
|
+
f"every SAE feature, {describe_position(scope.position)}, keeping the top {scope.top}"
|
|
515
|
+
)
|
|
516
|
+
else:
|
|
517
|
+
where = f"{len(scope.sites)} chosen site{'s' if len(scope.sites) != 1 else ''}"
|
|
518
|
+
return f"{what} · {where}"
|
logogram/stats.py
ADDED
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
"""Per-site statistics: means, standard deviations, bootstrap confidence intervals, sign counts.
|
|
2
|
+
|
|
3
|
+
Everything is vectorized over sites. Bootstrap resamples are drawn once per run from the raw
|
|
4
|
+
PCG64 bit stream (stable across NumPy versions) and shared by every site, so differences between
|
|
5
|
+
sites are not blurred by different resamples.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from dataclasses import dataclass
|
|
11
|
+
|
|
12
|
+
import numpy as np
|
|
13
|
+
|
|
14
|
+
Normalization = str # "dataset_gap" | "prompt_gap"
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def resample_counts(n: int, n_boot: int, seed: int) -> np.ndarray:
|
|
18
|
+
"""A [n_boot, n] matrix: how often each prompt appears in each bootstrap resample."""
|
|
19
|
+
raw = np.random.PCG64(seed).random_raw(n_boot * n)
|
|
20
|
+
idx = (raw % np.uint64(n)).astype(np.int64).reshape(n_boot, n)
|
|
21
|
+
counts = np.zeros((n_boot, n), dtype=np.float64)
|
|
22
|
+
rows = np.repeat(np.arange(n_boot), n)
|
|
23
|
+
np.add.at(counts, (rows, idx.ravel()), 1.0)
|
|
24
|
+
return counts
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _quantiles(boot: np.ndarray, ci: float) -> tuple[np.ndarray, np.ndarray]:
|
|
28
|
+
alpha = (1.0 - ci) / 2.0
|
|
29
|
+
lo, hi = np.quantile(boot, [alpha, 1.0 - alpha], axis=1)
|
|
30
|
+
return lo, hi
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _sd(values: np.ndarray) -> np.ndarray:
|
|
34
|
+
n = values.shape[1]
|
|
35
|
+
if n < 2:
|
|
36
|
+
return np.zeros(values.shape[0])
|
|
37
|
+
return values.std(axis=1, ddof=1)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@dataclass
|
|
41
|
+
class SiteStats:
|
|
42
|
+
"""Statistics for S sites over n prompts. Arrays have length S unless noted."""
|
|
43
|
+
|
|
44
|
+
n: int
|
|
45
|
+
effect: np.ndarray # [S, n] per-prompt normalized effect
|
|
46
|
+
effect_mean: np.ndarray
|
|
47
|
+
effect_sd: np.ndarray
|
|
48
|
+
effect_lo: np.ndarray
|
|
49
|
+
effect_hi: np.ndarray
|
|
50
|
+
delta: np.ndarray # [S, n] patched - receiver logit difference
|
|
51
|
+
delta_mean: np.ndarray
|
|
52
|
+
delta_sd: np.ndarray
|
|
53
|
+
delta_lo: np.ndarray
|
|
54
|
+
delta_hi: np.ndarray
|
|
55
|
+
patched_mean: np.ndarray
|
|
56
|
+
prob_mean: np.ndarray
|
|
57
|
+
prob_delta_mean: np.ndarray
|
|
58
|
+
sign_flips: np.ndarray # prompts whose logit difference changed sign
|
|
59
|
+
opposite_sign: np.ndarray # prompts whose effect has the opposite sign to the mean
|
|
60
|
+
denominator: float # mean gap (dataset_gap) or nan (prompt_gap)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def normalize(
|
|
64
|
+
delta: np.ndarray, gap: np.ndarray, normalization: Normalization
|
|
65
|
+
) -> tuple[np.ndarray, float]:
|
|
66
|
+
if normalization == "dataset_gap":
|
|
67
|
+
denom = float(gap.mean())
|
|
68
|
+
return delta / denom, denom
|
|
69
|
+
if normalization == "prompt_gap":
|
|
70
|
+
return delta / gap[None, :], float("nan")
|
|
71
|
+
raise ValueError(f"unknown normalization {normalization!r}")
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def compute_site_stats(
|
|
75
|
+
patched_ld: np.ndarray,
|
|
76
|
+
patched_prob: np.ndarray,
|
|
77
|
+
receiver_ld: np.ndarray,
|
|
78
|
+
source_ld: np.ndarray,
|
|
79
|
+
receiver_prob: np.ndarray,
|
|
80
|
+
normalization: Normalization,
|
|
81
|
+
counts: np.ndarray,
|
|
82
|
+
ci: float,
|
|
83
|
+
delta: np.ndarray | None = None,
|
|
84
|
+
gap: np.ndarray | None = None,
|
|
85
|
+
) -> SiteStats:
|
|
86
|
+
"""``patched_*`` are [S, n]; ``receiver_*``/``source_ld`` are [n]; ``counts`` is [B, n].
|
|
87
|
+
|
|
88
|
+
``delta`` [S, n] and ``gap`` [n] replace patched - receiver and source - receiver for methods
|
|
89
|
+
whose per-prompt values aren't patched runs (estimates, and terms of a decomposition). Where
|
|
90
|
+
nothing ran patched, ``patched_*`` are NaN and count no sign flips.
|
|
91
|
+
"""
|
|
92
|
+
patched_ld = np.asarray(patched_ld, dtype=np.float64)
|
|
93
|
+
patched_prob = np.asarray(patched_prob, dtype=np.float64)
|
|
94
|
+
receiver_ld = np.asarray(receiver_ld, dtype=np.float64)
|
|
95
|
+
source_ld = np.asarray(source_ld, dtype=np.float64)
|
|
96
|
+
n = patched_ld.shape[1]
|
|
97
|
+
|
|
98
|
+
delta = (
|
|
99
|
+
patched_ld - receiver_ld[None, :] if delta is None else np.asarray(delta, dtype=np.float64)
|
|
100
|
+
)
|
|
101
|
+
gap = source_ld - receiver_ld if gap is None else np.asarray(gap, dtype=np.float64)
|
|
102
|
+
effect, denom = normalize(delta, gap, normalization)
|
|
103
|
+
|
|
104
|
+
weights = counts.T / n # [n, B]
|
|
105
|
+
delta_boot = delta @ weights # [S, B]
|
|
106
|
+
if normalization == "dataset_gap":
|
|
107
|
+
gap_boot = gap @ weights # [B]
|
|
108
|
+
effect_boot = delta_boot / gap_boot[None, :]
|
|
109
|
+
else:
|
|
110
|
+
effect_boot = effect @ weights
|
|
111
|
+
effect_lo, effect_hi = _quantiles(effect_boot, ci)
|
|
112
|
+
delta_lo, delta_hi = _quantiles(delta_boot, ci)
|
|
113
|
+
if n < 2:
|
|
114
|
+
# One prompt has no sampling distribution: report no interval rather than a false one.
|
|
115
|
+
nan = np.full(len(effect_lo), np.nan)
|
|
116
|
+
effect_lo, effect_hi, delta_lo, delta_hi = nan, nan.copy(), nan.copy(), nan.copy()
|
|
117
|
+
|
|
118
|
+
effect_mean = effect.mean(axis=1)
|
|
119
|
+
flips = ((receiver_ld[None, :] > 0) & (patched_ld < 0)) | (
|
|
120
|
+
(receiver_ld[None, :] < 0) & (patched_ld > 0)
|
|
121
|
+
)
|
|
122
|
+
mean_sign = np.sign(effect_mean)[:, None]
|
|
123
|
+
opposite = (np.sign(effect) == -mean_sign) & (mean_sign != 0)
|
|
124
|
+
|
|
125
|
+
return SiteStats(
|
|
126
|
+
n=n,
|
|
127
|
+
effect=effect,
|
|
128
|
+
effect_mean=effect_mean,
|
|
129
|
+
effect_sd=_sd(effect),
|
|
130
|
+
effect_lo=effect_lo,
|
|
131
|
+
effect_hi=effect_hi,
|
|
132
|
+
delta=delta,
|
|
133
|
+
delta_mean=delta.mean(axis=1),
|
|
134
|
+
delta_sd=_sd(delta),
|
|
135
|
+
delta_lo=delta_lo,
|
|
136
|
+
delta_hi=delta_hi,
|
|
137
|
+
patched_mean=patched_ld.mean(axis=1),
|
|
138
|
+
prob_mean=patched_prob.mean(axis=1),
|
|
139
|
+
prob_delta_mean=(patched_prob - np.asarray(receiver_prob)[None, :]).mean(axis=1),
|
|
140
|
+
sign_flips=flips.sum(axis=1),
|
|
141
|
+
opposite_sign=opposite.sum(axis=1),
|
|
142
|
+
denominator=denom,
|
|
143
|
+
)
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def rankdata(values: np.ndarray) -> np.ndarray:
|
|
147
|
+
"""Ranks starting at 1, ties get the average rank (as in scipy.stats.rankdata)."""
|
|
148
|
+
values = np.asarray(values, dtype=np.float64)
|
|
149
|
+
order = np.argsort(values, kind="mergesort")
|
|
150
|
+
ranks = np.empty(len(values), dtype=np.float64)
|
|
151
|
+
sorted_vals = values[order]
|
|
152
|
+
i = 0
|
|
153
|
+
while i < len(values):
|
|
154
|
+
j = i
|
|
155
|
+
while j + 1 < len(values) and sorted_vals[j + 1] == sorted_vals[i]:
|
|
156
|
+
j += 1
|
|
157
|
+
ranks[order[i : j + 1]] = (i + j) / 2.0 + 1.0
|
|
158
|
+
i = j + 1
|
|
159
|
+
return ranks
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def spearman(a: np.ndarray, b: np.ndarray) -> float:
|
|
163
|
+
if len(a) < 2:
|
|
164
|
+
return float("nan")
|
|
165
|
+
ra, rb = rankdata(a), rankdata(b)
|
|
166
|
+
ra -= ra.mean()
|
|
167
|
+
rb -= rb.mean()
|
|
168
|
+
denom = np.sqrt((ra * ra).sum() * (rb * rb).sum())
|
|
169
|
+
if denom == 0:
|
|
170
|
+
return float("nan")
|
|
171
|
+
return float((ra * rb).sum() / denom)
|