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.
Files changed (54) hide show
  1. logogram/__init__.py +6 -0
  2. logogram/__main__.py +5 -0
  3. logogram/analysis.py +419 -0
  4. logogram/atp.py +120 -0
  5. logogram/backends/__init__.py +5 -0
  6. logogram/backends/base.py +202 -0
  7. logogram/backends/hub.py +375 -0
  8. logogram/backends/saes.py +277 -0
  9. logogram/backends/transformer_lens.py +872 -0
  10. logogram/cli.py +496 -0
  11. logogram/compare.py +177 -0
  12. logogram/datasets.py +159 -0
  13. logogram/direct.py +193 -0
  14. logogram/engine.py +550 -0
  15. logogram/examples/ioi-gpt2/.gitignore +3 -0
  16. logogram/examples/ioi-gpt2/datasets/ioi.jsonl +32 -0
  17. logogram/examples/ioi-gpt2/experiments/ioi-head-patching/spec.json +42 -0
  18. logogram/examples/ioi-gpt2/project.json +6 -0
  19. logogram/exports.py +33 -0
  20. logogram/features.py +368 -0
  21. logogram/fileio.py +63 -0
  22. logogram/ioi.py +220 -0
  23. logogram/paths.py +204 -0
  24. logogram/project.py +444 -0
  25. logogram/prompts.py +204 -0
  26. logogram/research.py +84 -0
  27. logogram/results.py +240 -0
  28. logogram/runner.py +396 -0
  29. logogram/runs.py +98 -0
  30. logogram/sae.py +161 -0
  31. logogram/schema.py +302 -0
  32. logogram/server/__init__.py +1 -0
  33. logogram/server/app.py +1083 -0
  34. logogram/server/models.py +426 -0
  35. logogram/server/security.py +212 -0
  36. logogram/server/state.py +585 -0
  37. logogram/sites.py +249 -0
  38. logogram/spec.py +518 -0
  39. logogram/stats.py +171 -0
  40. logogram/steering.py +258 -0
  41. logogram/system.py +379 -0
  42. logogram/updates.py +194 -0
  43. logogram/verify.py +39 -0
  44. logogram/web_dist/assets/index-BvCU-2uy.js +54 -0
  45. logogram/web_dist/assets/index-DTr8_ucV.css +1 -0
  46. logogram/web_dist/assets/instrument-sans-latin-ext-standard-normal-C5E2Gvlv.woff2 +0 -0
  47. logogram/web_dist/assets/instrument-sans-latin-standard-normal-BVScPF0l.woff2 +0 -0
  48. logogram/web_dist/favicon.svg +1 -0
  49. logogram/web_dist/index.html +15 -0
  50. logogram-0.1.0.dist-info/METADATA +550 -0
  51. logogram-0.1.0.dist-info/RECORD +54 -0
  52. logogram-0.1.0.dist-info/WHEEL +4 -0
  53. logogram-0.1.0.dist-info/entry_points.txt +2 -0
  54. 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)