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/runner.py ADDED
@@ -0,0 +1,396 @@
1
+ """Run a spec end to end and write its experiment folder. The GUI and the CLI both call this."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import hashlib
6
+ import json
7
+ import os
8
+ import platform
9
+ import random
10
+ import threading
11
+ import time
12
+ from collections.abc import Callable
13
+ from dataclasses import dataclass
14
+ from importlib.metadata import PackageNotFoundError, version
15
+ from pathlib import Path
16
+ from typing import Any
17
+
18
+ import numpy as np
19
+ import torch
20
+
21
+ from logogram import __version__
22
+ from logogram.backends.base import ModelBackend
23
+ from logogram.datasets import parse_jsonl
24
+ from logogram.engine import Cancelled, run_experiment
25
+ from logogram.fileio import atomic_output, write_text_atomic
26
+ from logogram.project import Project, now_iso
27
+ from logogram.prompts import prepare_prompts
28
+ from logogram.results import (
29
+ build_summary,
30
+ compute_stats,
31
+ results_table,
32
+ site_payload,
33
+ write_results,
34
+ )
35
+ from logogram.schema import Manifest
36
+ from logogram.spec import ModelRef, SAERef, Spec
37
+ from logogram.stats import resample_counts
38
+
39
+ EventFn = Callable[[str, dict[str, Any]], None]
40
+ ModelProvider = Callable[[ModelRef], ModelBackend]
41
+ SAEProvider = Callable[[SAERef, ModelBackend], Any]
42
+
43
+
44
+ class RunError(RuntimeError):
45
+ pass
46
+
47
+
48
+ def configure_determinism(seed: int = 0) -> None:
49
+ """Make repeated runs on the same machine bit-identical."""
50
+ os.environ.setdefault("CUBLAS_WORKSPACE_CONFIG", ":4096:8")
51
+ torch.use_deterministic_algorithms(True)
52
+ random.seed(seed)
53
+ np.random.seed(seed % (2**32))
54
+ torch.manual_seed(seed)
55
+ torch.backends.cudnn.benchmark = False
56
+ torch.backends.cudnn.deterministic = True
57
+ torch.backends.cuda.matmul.allow_tf32 = False
58
+ torch.backends.cudnn.allow_tf32 = False
59
+
60
+
61
+ def _pkg_version(name: str) -> str | None:
62
+ try:
63
+ return version(name)
64
+ except PackageNotFoundError:
65
+ return None
66
+
67
+
68
+ def environment_versions() -> dict[str, Any]:
69
+ return {
70
+ "logogram": __version__,
71
+ "python": platform.python_version(),
72
+ "torch": torch.__version__,
73
+ "transformer_lens": _pkg_version("transformer-lens"),
74
+ "transformers": _pkg_version("transformers"),
75
+ "numpy": _pkg_version("numpy"),
76
+ "pyarrow": _pkg_version("pyarrow"),
77
+ }
78
+
79
+
80
+ def model_matches(backend: ModelBackend | None, ref: ModelRef) -> bool:
81
+ if backend is None:
82
+ return False
83
+ from logogram.backends.transformer_lens import resolve_device
84
+
85
+ info = backend.info
86
+ try:
87
+ device = resolve_device(ref.device)
88
+ except Exception: # noqa: BLE001
89
+ return False
90
+ return (
91
+ info.id == ref.id
92
+ and (ref.revision is None or ref.revision == info.revision)
93
+ and info.dtype == ref.dtype
94
+ and info.device == device
95
+ and info.process_weights == ref.process_weights
96
+ )
97
+
98
+
99
+ def default_provider(on_event: EventFn | None = None) -> ModelProvider:
100
+ def provide(ref: ModelRef) -> ModelBackend:
101
+ from logogram.backends.transformer_lens import load_model
102
+
103
+ return load_model(
104
+ ref.id,
105
+ revision=ref.revision,
106
+ dtype=ref.dtype,
107
+ device=ref.device,
108
+ process_weights=ref.process_weights,
109
+ on_progress=(lambda p: on_event("model", p)) if on_event else None,
110
+ )
111
+
112
+ return provide
113
+
114
+
115
+ @dataclass
116
+ class RunOutcome:
117
+ run_id: str
118
+ folder: Path
119
+ status: str
120
+ summary: dict[str, Any] | None
121
+ manifest: dict[str, Any]
122
+ backend: ModelBackend | None
123
+
124
+
125
+ def write_manifest(path: Path, manifest: dict[str, Any]) -> None:
126
+ write_json(path, Manifest.model_validate(manifest).model_dump(mode="json"))
127
+
128
+
129
+ def write_json(path: Path, data: Any) -> None:
130
+ """Write atomically, so readers (the history panel) never see a half-written file."""
131
+ write_text_atomic(path, json.dumps(data, indent=2, ensure_ascii=False) + "\n")
132
+
133
+
134
+ def run_spec(
135
+ spec: Spec,
136
+ project: Project,
137
+ *,
138
+ run_id: str | None = None,
139
+ backend: ModelBackend | None = None,
140
+ provider: ModelProvider | None = None,
141
+ on_event: EventFn | None = None,
142
+ cancel: threading.Event | None = None,
143
+ derived_from: dict[str, Any] | None = None,
144
+ sae: Any = None,
145
+ sae_provider: SAEProvider | None = None,
146
+ ) -> RunOutcome:
147
+ """Execute ``spec`` and write ``experiments/<run_id>/`` in ``project``.
148
+
149
+ ``sae`` is an already loaded SAE, used when it is the one the spec names; otherwise
150
+ ``sae_provider`` (by default, a download from Hugging Face) loads it."""
151
+ emit = on_event or (lambda kind, data: None)
152
+ configure_determinism(spec.statistics.seed)
153
+ run_id = run_id or project.new_run_id(spec.name)
154
+ folder = project.prepare_run_dir(run_id)
155
+ started_at = now_iso()
156
+ t0 = time.perf_counter()
157
+
158
+ manifest: dict[str, Any] = {
159
+ "logogram_manifest": 1,
160
+ "run_id": run_id,
161
+ "status": "running",
162
+ "started_at": started_at,
163
+ "versions": environment_versions(),
164
+ "derived_from": derived_from,
165
+ }
166
+ # The spec as executed: written first so a failed run still records what was attempted.
167
+ write_json(folder / "spec.json", spec.model_dump(mode="json"))
168
+ write_manifest(folder / "manifest.json", manifest)
169
+
170
+ def fail(status: str, message: str | None) -> RunOutcome:
171
+ manifest.update(
172
+ {
173
+ "status": status,
174
+ "error": message,
175
+ "finished_at": now_iso(),
176
+ "wall_time_s": round(time.perf_counter() - t0, 3),
177
+ }
178
+ )
179
+ for name in ("results.parquet", "summary.json", "predictions.json"):
180
+ (folder / name).unlink(missing_ok=True)
181
+ write_manifest(folder / "manifest.json", manifest)
182
+ return RunOutcome(run_id, folder, status, None, manifest, backend)
183
+
184
+ try:
185
+ dataset_path = project.resolve_dataset(spec.dataset.path)
186
+ # Hash, parse and snapshot the same bytes, even if the source is subsequently edited.
187
+ content = dataset_path.read_bytes()
188
+ sha = hashlib.sha256(content).hexdigest()
189
+ if spec.dataset.sha256 and sha != spec.dataset.sha256:
190
+ raise RunError(
191
+ f"{spec.dataset.path} has changed since this spec was written (its hash no longer "
192
+ "matches). Restore the original file, or remove dataset.sha256 to accept the new one."
193
+ )
194
+ records = parse_jsonl(content.decode("utf-8"), source=spec.dataset.path)
195
+ if spec.dataset.limit is not None:
196
+ records = records[: spec.dataset.limit]
197
+ source_path = spec.dataset.path
198
+ snapshots = project.writable(project.datasets_dir / "snapshots")
199
+ snapshots.mkdir(exist_ok=True)
200
+ snapshot = project.writable(snapshots / f"{sha}.jsonl")
201
+ if os.path.lexists(snapshot):
202
+ if project.readable(snapshot).read_bytes() != content:
203
+ raise RunError(
204
+ "The pinned dataset snapshot has changed. Restore it from a backup before rerunning."
205
+ )
206
+ else:
207
+ with atomic_output(snapshot) as output:
208
+ output.write(content)
209
+ spec = spec.model_copy(
210
+ update={
211
+ "dataset": spec.dataset.model_copy(
212
+ update={
213
+ "path": snapshot.relative_to(project.root).as_posix(),
214
+ "sha256": sha,
215
+ }
216
+ )
217
+ }
218
+ )
219
+ write_json(folder / "spec.json", spec.model_dump(mode="json"))
220
+ if cancel is not None and cancel.is_set():
221
+ raise Cancelled()
222
+
223
+ if not model_matches(backend, spec.model):
224
+ emit("status", {"message": f"Loading {spec.model.id}"})
225
+ backend = (provider or default_provider(on_event))(spec.model)
226
+ assert backend is not None
227
+ info = backend.info
228
+
229
+ # Pin exactly what ran, so `logogram run` on this file reproduces it.
230
+ spec = spec.model_copy(
231
+ update={
232
+ "model": spec.model.model_copy(update={"revision": info.revision}),
233
+ "dataset": spec.dataset.model_copy(update={"sha256": sha}),
234
+ }
235
+ )
236
+ write_json(folder / "spec.json", spec.model_dump(mode="json"))
237
+ manifest.update(
238
+ {
239
+ "model": {
240
+ "id": info.id,
241
+ "revision": info.revision,
242
+ "architecture": info.architecture,
243
+ "process_weights": info.process_weights,
244
+ "backend": info.backend,
245
+ "backend_version": info.backend_version,
246
+ },
247
+ "device": {
248
+ "type": info.device,
249
+ "name": info.device_name,
250
+ "cuda": torch.version.cuda if info.device == "cuda" else None,
251
+ "threads": torch.get_num_threads(),
252
+ "deterministic_algorithms": torch.are_deterministic_algorithms_enabled(),
253
+ },
254
+ "dtype": info.dtype,
255
+ "dataset": {
256
+ "path": spec.dataset.path,
257
+ "sha256": sha,
258
+ "n": len(records),
259
+ "source_path": source_path,
260
+ },
261
+ }
262
+ )
263
+ write_manifest(folder / "manifest.json", manifest)
264
+
265
+ if spec.sae is not None:
266
+ from logogram.sae import load_sae, sae_matches
267
+
268
+ if not sae_matches(sae, spec.sae):
269
+ emit("status", {"message": f"Loading the SAE {spec.sae.repo}"})
270
+ sae = (
271
+ sae_provider(spec.sae, backend)
272
+ if sae_provider is not None
273
+ else load_sae(spec.sae, info.device, cancel=cancel)
274
+ )
275
+ spec = spec.model_copy(
276
+ update={"sae": spec.sae.model_copy(update={"revision": sae.revision})}
277
+ )
278
+ write_json(folder / "spec.json", spec.model_dump(mode="json"))
279
+ manifest["sae"] = {
280
+ "repo": sae.repo,
281
+ "path": sae.path,
282
+ "revision": sae.revision,
283
+ "format": sae.params.format,
284
+ "site": sae.site,
285
+ "layer": sae.layer,
286
+ "d_sae": sae.d_sae,
287
+ }
288
+ write_manifest(folder / "manifest.json", manifest)
289
+
290
+ prompts = prepare_prompts(backend, records, spec.tokenization.prepend_bos)
291
+ # Bootstrap resamples over the prompts a method measures (steering measures held-out
292
+ # prompts only), drawn once per run so every site shares them.
293
+ resamples: dict[int, np.ndarray] = {}
294
+
295
+ def counts_for(result: Any) -> np.ndarray:
296
+ n = len(result.prompts)
297
+ if n not in resamples:
298
+ resamples[n] = resample_counts(n, spec.statistics.bootstrap, spec.statistics.seed)
299
+ return resamples[n]
300
+
301
+ model_shape = {
302
+ "id": info.id,
303
+ "n_layers": info.n_layers,
304
+ "n_heads": info.n_heads,
305
+ "d_model": info.d_model,
306
+ "d_head": info.d_head,
307
+ "d_mlp": info.d_mlp,
308
+ "site_kinds": list(info.site_kinds),
309
+ "block_structure": info.extra.get("block_structure"),
310
+ "normalization": info.extra.get("normalization"),
311
+ "activation": info.extra.get("activation"),
312
+ }
313
+
314
+ if spec.predictions is not None:
315
+ from logogram.analysis import prediction_report
316
+
317
+ emit("status", {"message": "Measuring the configured layer predictions"})
318
+ with backend.lock:
319
+ predictions = prediction_report(
320
+ backend,
321
+ records,
322
+ index=spec.predictions.prompt_index,
323
+ settings=spec.predictions,
324
+ prepend_bos=spec.tokenization.prepend_bos,
325
+ batch_size=spec.execution.batch_size,
326
+ )
327
+ if cancel is not None and cancel.is_set():
328
+ raise Cancelled()
329
+ write_json(folder / "predictions.json", predictions)
330
+
331
+ def on_start(sites: list[Any], layout: dict[str, Any]) -> None:
332
+ emit(
333
+ "started",
334
+ {
335
+ "run_id": run_id,
336
+ "n_prompts": len(prompts),
337
+ "model": model_shape,
338
+ "layout": layout,
339
+ "sites": [s.to_dict() for s in sites],
340
+ },
341
+ )
342
+
343
+ def on_layer(layer: int, indices: list[int], partial: Any) -> None:
344
+ # A finished site's statistics depend only on its own rows, so they are final.
345
+ stats = compute_stats(spec, partial, counts_for(partial), indices)
346
+ emit(
347
+ "layer",
348
+ {"run_id": run_id, "layer": layer, "sites": site_payload(partial, stats, indices)},
349
+ )
350
+
351
+ def on_progress(done: int, total: int, layer: int) -> None:
352
+ emit(
353
+ "progress",
354
+ {
355
+ "run_id": run_id,
356
+ "done": done,
357
+ "total": total,
358
+ "layer": layer,
359
+ "memory": backend.memory_in_use() if backend else None,
360
+ "elapsed_s": round(time.perf_counter() - t0, 2),
361
+ },
362
+ )
363
+
364
+ result = run_experiment(
365
+ spec,
366
+ backend,
367
+ prompts,
368
+ on_progress=on_progress,
369
+ on_layer=on_layer,
370
+ on_start=on_start,
371
+ cancel=cancel,
372
+ sae=sae,
373
+ )
374
+ stats = compute_stats(spec, result, counts_for(result))
375
+ summary = build_summary(spec, result, stats, run_id, model_shape)
376
+ write_results(folder / "results.parquet", results_table(result, stats))
377
+ write_json(folder / "summary.json", summary)
378
+ manifest.update(
379
+ {
380
+ "status": "finished",
381
+ "finished_at": now_iso(),
382
+ "wall_time_s": round(time.perf_counter() - t0, 3),
383
+ }
384
+ )
385
+ write_manifest(folder / "manifest.json", manifest)
386
+ emit("finished", {"run_id": run_id})
387
+ return RunOutcome(run_id, folder, "finished", summary, manifest, backend)
388
+ except Cancelled:
389
+ outcome = fail("cancelled", None)
390
+ emit("cancelled", {"run_id": run_id})
391
+ return outcome
392
+ except Exception as exc: # noqa: BLE001 - recorded in the manifest and reported
393
+ message = str(exc) or type(exc).__name__
394
+ outcome = fail("failed", message)
395
+ emit("failed", {"run_id": run_id, "error": message})
396
+ return outcome
logogram/runs.py ADDED
@@ -0,0 +1,98 @@
1
+ """Read finished runs: the per-prompt evidence behind one site's number."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+ import numpy as np
8
+
9
+ from logogram.datasets import DatasetError, file_sha256, load_dataset
10
+ from logogram.project import Project
11
+ from logogram.results import read_site_rows
12
+ from logogram.spec import Spec
13
+
14
+
15
+ class RunNotReady(ValueError):
16
+ pass
17
+
18
+
19
+ def load_summary(project: Project, run_id: str) -> dict[str, Any]:
20
+ path = project.run_dir(run_id) / "summary.json"
21
+ if not path.is_file():
22
+ raise RunNotReady("This run has no results yet.")
23
+ return project.read_json(path) or {}
24
+
25
+
26
+ def site_detail(
27
+ project: Project, run_id: str, site_index: int, extremes: int = 3
28
+ ) -> dict[str, Any]:
29
+ folder = project.run_dir(run_id)
30
+ summary = load_summary(project, run_id)
31
+ sites = summary["sites"]
32
+ if not 0 <= site_index < len(sites):
33
+ raise RunNotReady(f"Site {site_index} isn't part of this run.")
34
+ site = sites[site_index]
35
+ spec = Spec.from_path(project.readable(folder / "spec.json"))
36
+ rows = read_site_rows(project.readable(folder / "results.parquet"), site_index)
37
+
38
+ records = None
39
+ dataset_changed = False
40
+ try:
41
+ path = project.resolve_dataset(spec.dataset.path)
42
+ records = load_dataset(path)
43
+ if spec.dataset.limit:
44
+ records = records[: spec.dataset.limit]
45
+ if spec.dataset.sha256 and file_sha256(path) != spec.dataset.sha256:
46
+ dataset_changed = True
47
+ except (DatasetError, ValueError):
48
+ records = None
49
+
50
+ effects = np.asarray(rows["effect"], dtype=np.float64)
51
+ prompts = []
52
+ for i, prompt_index in enumerate(rows["prompt"]):
53
+ record = records[prompt_index] if records and prompt_index < len(records) else None
54
+ receiver = rows["receiver_logit_diff"][i]
55
+ patched = rows["patched_logit_diff"][i]
56
+ prompts.append(
57
+ {
58
+ "index": prompt_index,
59
+ "clean": record.clean if record else None,
60
+ "corrupt": record.corrupt if record else None,
61
+ "answer": record.answer if record else None,
62
+ "distractor": record.distractor if record else None,
63
+ "effect": _f(rows["effect"][i]),
64
+ "delta": _f(rows["delta"][i]),
65
+ "patched_logit_diff": _f(patched),
66
+ "receiver_logit_diff": _f(receiver),
67
+ "reference_logit_diff": _f(rows["reference_logit_diff"][i]),
68
+ "patched_answer_prob": _f(rows["patched_answer_prob"][i]),
69
+ "receiver_answer_prob": _f(rows["receiver_answer_prob"][i]),
70
+ "flipped": bool((receiver > 0 > patched) or (receiver < 0 < patched)),
71
+ }
72
+ )
73
+ mean = site["effect"]["mean"] or 0.0
74
+ direction = 1.0 if mean >= 0 else -1.0
75
+ finite = np.where(np.isfinite(effects), effects, 0.0)
76
+ order = np.argsort(-direction * finite, kind="mergesort")
77
+ k = min(extremes, len(order))
78
+ return {
79
+ "run_id": run_id,
80
+ "site": site,
81
+ "receiver": summary["receiver"],
82
+ "reference": summary["reference"],
83
+ "metric": summary["metric"],
84
+ "statistics": summary["statistics"],
85
+ "prompts": prompts,
86
+ "strongest": [int(rows["prompt"][j]) for j in order[:k]],
87
+ "weakest": [int(rows["prompt"][j]) for j in order[::-1][:k]],
88
+ "dataset_changed": dataset_changed,
89
+ "dataset_available": records is not None,
90
+ }
91
+
92
+
93
+ def _f(x: Any) -> float | None:
94
+ try:
95
+ value = float(x)
96
+ except (TypeError, ValueError):
97
+ return None
98
+ return value if np.isfinite(value) else None
logogram/sae.py ADDED
@@ -0,0 +1,161 @@
1
+ """Sparse autoencoders on a model's activations: encode, decode, edit one feature, and check fit.
2
+
3
+ An SAE writes an activation x as a sparse sum of decoder directions: x = f · W_dec + b_dec + e,
4
+ where f are the feature activations and e is what the SAE misses (its error). Editing a feature
5
+ changes x by (new - old) · W_dec[i] and keeps the error as it was, so only that feature changes.
6
+ Whether an SAE fits a loaded model at all depends on how it was trained (on which model, and with
7
+ or without TransformerLens's weight processing), so Logogram measures the fit on the project's
8
+ prompts instead of assuming it.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ from dataclasses import dataclass, field
14
+ from typing import Any
15
+
16
+ import torch
17
+
18
+ from logogram.backends.saes import SAEParams
19
+ from logogram.spec import SAERef
20
+
21
+ LN_EPS = 1e-5
22
+
23
+
24
+ @dataclass
25
+ class SAE:
26
+ """A loaded SAE, its parameters on the model's device, and where it came from."""
27
+
28
+ repo: str
29
+ path: str
30
+ revision: str
31
+ params: SAEParams
32
+ device: str = "cpu"
33
+ fit: dict[str, Any] = field(default_factory=dict)
34
+
35
+ @property
36
+ def site(self) -> str:
37
+ return self.params.site
38
+
39
+ @property
40
+ def layer(self) -> int:
41
+ return self.params.layer
42
+
43
+ @property
44
+ def d_sae(self) -> int:
45
+ return self.params.d_sae
46
+
47
+ def to(self, device: str) -> SAE:
48
+ p = self.params
49
+ for name in ("W_enc", "b_enc", "W_dec", "b_dec", "threshold"):
50
+ value = getattr(p, name)
51
+ if value is not None:
52
+ setattr(p, name, value.to(device))
53
+ self.device = device
54
+ return self
55
+
56
+ def describe(self) -> dict[str, Any]:
57
+ p = self.params
58
+ return {
59
+ "repo": self.repo,
60
+ "path": self.path,
61
+ "revision": self.revision,
62
+ "site": p.site,
63
+ "layer": p.layer,
64
+ "d_in": p.d_in,
65
+ "d_sae": p.d_sae,
66
+ "activation": p.activation,
67
+ "k": p.k,
68
+ "normalize": p.normalize,
69
+ "format": p.format,
70
+ "note": p.note,
71
+ "fit": self.fit or None,
72
+ }
73
+
74
+ # -- the map --------------------------------------------------------------------------------
75
+
76
+ def _standardize(
77
+ self, x: torch.Tensor
78
+ ) -> tuple[torch.Tensor, tuple[torch.Tensor, torch.Tensor] | None]:
79
+ if self.params.normalize != "layer_norm":
80
+ return x, None
81
+ mu = x.mean(dim=-1, keepdim=True)
82
+ centered = x - mu
83
+ std = centered.std(dim=-1, keepdim=True)
84
+ return centered / (std + LN_EPS), (mu, std)
85
+
86
+ def encode(self, x: torch.Tensor) -> tuple[torch.Tensor, Any]:
87
+ """Feature activations ``[..., d_sae]`` of activations ``[..., d_in]``, and what
88
+ decoding needs to restore a standardized input (or None)."""
89
+ p = self.params
90
+ x = x.float()
91
+ sae_in, stats = self._standardize(x)
92
+ if p.subtract_b_dec:
93
+ sae_in = sae_in - p.b_dec
94
+ pre = sae_in @ p.W_enc + p.b_enc
95
+ if p.activation == "relu":
96
+ f = torch.relu(pre)
97
+ elif p.activation == "jumprelu":
98
+ assert p.threshold is not None
99
+ f = torch.relu(pre) * (pre > p.threshold)
100
+ else:
101
+ assert p.k is not None
102
+ top = pre.topk(min(p.k, pre.shape[-1]), dim=-1, sorted=False)
103
+ f = torch.zeros_like(pre).scatter(-1, top.indices, torch.relu(top.values))
104
+ return f, stats
105
+
106
+ def decode(self, f: torch.Tensor, stats: Any) -> torch.Tensor:
107
+ p = self.params
108
+ out = f @ p.W_dec + p.b_dec
109
+ if stats is not None:
110
+ mu, std = stats
111
+ out = out * std + mu
112
+ return out
113
+
114
+ def feature_direction(self, feature: int, stats: Any = None) -> torch.Tensor:
115
+ """What one unit of the feature adds to the activation: its decoder row, scaled back by
116
+ each input's standard deviation for SAEs that standardize their inputs."""
117
+ row = self.params.W_dec[feature]
118
+ if stats is None:
119
+ return row
120
+ _, std = stats
121
+ return row * std
122
+
123
+ # -- fit ------------------------------------------------------------------------------------
124
+
125
+
126
+ def fit_on(sae: SAE, activations: torch.Tensor) -> dict[str, float]:
127
+ """How well the SAE reconstructs ``activations`` ``[N, d_in]``: the fraction of variance it
128
+ explains (1 is perfect, 0 is no better than the mean), and how many features fire per token."""
129
+ x = activations.float()
130
+ f, stats = sae.encode(x)
131
+ x_hat = sae.decode(f, stats)
132
+ residual = ((x - x_hat) ** 2).sum()
133
+ total = ((x - x.mean(dim=0, keepdim=True)) ** 2).sum().clamp_min(1e-12)
134
+ return {
135
+ "variance_explained": float(1.0 - residual / total),
136
+ "l0": float((f > 0).float().sum(-1).mean()),
137
+ "tokens": int(x.shape[0]),
138
+ }
139
+
140
+
141
+ def load_sae(
142
+ ref: SAERef,
143
+ device: str,
144
+ progress: Any = None,
145
+ cancel: Any = None,
146
+ ) -> SAE:
147
+ """Download (once) and read a published SAE at an exact revision, on ``device``."""
148
+ from logogram.backends.saes import download_sae, read_sae
149
+
150
+ revision, folder = download_sae(ref.repo, ref.path, ref.revision, progress, cancel)
151
+ params = read_sae(folder, ref.path)
152
+ return SAE(repo=ref.repo, path=ref.path, revision=revision, params=params).to(device)
153
+
154
+
155
+ def sae_matches(sae: SAE | None, ref: SAERef) -> bool:
156
+ return (
157
+ sae is not None
158
+ and sae.repo == ref.repo
159
+ and sae.path.strip("/") == ref.path.strip("/")
160
+ and (ref.revision is None or ref.revision == sae.revision)
161
+ )