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/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
|
+
)
|