circuitcheck 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.
- circuitcheck/__init__.py +16 -0
- circuitcheck/cli.py +236 -0
- circuitcheck/diff/__init__.py +27 -0
- circuitcheck/diff/core.py +502 -0
- circuitcheck/diff/graphs.py +213 -0
- circuitcheck/labels/__init__.py +42 -0
- circuitcheck/labels/autointerp.py +383 -0
- circuitcheck/labels/neuronpedia.py +148 -0
- circuitcheck/labels/providers.py +200 -0
- circuitcheck/py.typed +0 -0
- circuitcheck/report.py +290 -0
- circuitcheck/tracer.py +165 -0
- circuitcheck/validate/__init__.py +33 -0
- circuitcheck/validate/backend.py +41 -0
- circuitcheck/validate/controls.py +81 -0
- circuitcheck/validate/core.py +330 -0
- circuitcheck/validate/metrics.py +49 -0
- circuitcheck/validate/sandbox.py +129 -0
- circuitcheck/validate/toy.py +139 -0
- circuitcheck/validate/types.py +142 -0
- circuitcheck-0.1.0.dist-info/METADATA +171 -0
- circuitcheck-0.1.0.dist-info/RECORD +25 -0
- circuitcheck-0.1.0.dist-info/WHEEL +4 -0
- circuitcheck-0.1.0.dist-info/entry_points.txt +3 -0
- circuitcheck-0.1.0.dist-info/licenses/LICENSE +21 -0
circuitcheck/__init__.py
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
"""circuitcheck: tools on top of circuit-tracer.
|
|
2
|
+
|
|
3
|
+
- `circuitcheck.diff`: compare attribution graphs before and after fine-tuning or RL;
|
|
4
|
+
- `circuitcheck.validate`: test whether graph features are a switch or a readout, against random and matched-norm
|
|
5
|
+
controls;
|
|
6
|
+
- `circuitcheck.labels`: cheap automatic feature labels with detection and simulation scores;
|
|
7
|
+
- `circuitcheck.tracer`: the circuit-tracer adapter (needs torch and circuit-tracer);
|
|
8
|
+
- `circuitcheck.report`: HTML pages for all three.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from importlib.metadata import PackageNotFoundError, version
|
|
12
|
+
|
|
13
|
+
try:
|
|
14
|
+
__version__ = version("circuitcheck")
|
|
15
|
+
except PackageNotFoundError: # pragma: no cover
|
|
16
|
+
__version__ = "0.0.0+local"
|
circuitcheck/cli.py
ADDED
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
"""The `circuitcheck` command: `diff`, `summarize`, `validate`, `label` and `version`.
|
|
2
|
+
|
|
3
|
+
circuitcheck diff graphs.json.gz --before installed/before --after installed/after --html diff.html
|
|
4
|
+
circuitcheck summarize graph_*.pt --group after --out graphs.json.gz
|
|
5
|
+
circuitcheck validate --demo --html validate.html
|
|
6
|
+
circuitcheck label --neuronpedia gemma-2-2b/20-gemmascope-transcoder-16k --features 15589,4012 --html labels.html
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import json
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import Annotated
|
|
14
|
+
|
|
15
|
+
import typer
|
|
16
|
+
|
|
17
|
+
from circuitcheck import __version__
|
|
18
|
+
|
|
19
|
+
app = typer.Typer(add_completion=False, no_args_is_help=True, help="Diff, validate and label circuit-tracer circuits.")
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _write(obj_json: str, html_text: str, json_path: Path | None, html_path: Path | None) -> None:
|
|
23
|
+
from circuitcheck.report import save_html
|
|
24
|
+
|
|
25
|
+
if json_path:
|
|
26
|
+
json_path.write_text(obj_json, encoding="utf-8")
|
|
27
|
+
typer.echo(f"wrote {json_path}")
|
|
28
|
+
if html_path:
|
|
29
|
+
save_html(html_text, html_path)
|
|
30
|
+
typer.echo(f"wrote {html_path}")
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@app.command()
|
|
34
|
+
def version() -> None:
|
|
35
|
+
"""Print the installed version."""
|
|
36
|
+
typer.echo(__version__)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@app.command()
|
|
40
|
+
def summarize(
|
|
41
|
+
graphs: Annotated[list[Path], typer.Argument(help="circuit-tracer .pt graphs or Neuronpedia graph .json files")],
|
|
42
|
+
out: Annotated[Path, typer.Option(help="summary file to create or extend (.json or .json.gz)")],
|
|
43
|
+
group: Annotated[str, typer.Option(help="group name for these graphs, e.g. 'before'")],
|
|
44
|
+
) -> None:
|
|
45
|
+
"""Turn graphs into the position-free summaries `diff` reads (one group per call, appended to OUT)."""
|
|
46
|
+
from circuitcheck.diff.graphs import (
|
|
47
|
+
GraphSummary,
|
|
48
|
+
from_circuit_tracer,
|
|
49
|
+
from_neuronpedia,
|
|
50
|
+
load_summaries,
|
|
51
|
+
save_summaries,
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
groups: dict[str, list[GraphSummary]] = load_summaries(out) if out.exists() else {}
|
|
55
|
+
items = []
|
|
56
|
+
for g in graphs:
|
|
57
|
+
items.append(from_circuit_tracer(g, name=g.stem) if g.suffix == ".pt" else from_neuronpedia(g, name=g.stem))
|
|
58
|
+
groups[group] = items
|
|
59
|
+
save_summaries(out, groups)
|
|
60
|
+
typer.echo(f"{out}: group {group!r} has {len(items)} graphs")
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
@app.command()
|
|
64
|
+
def diff(
|
|
65
|
+
file: Annotated[Path, typer.Argument(help="summary file from `summarize` (or the bundled demo data)")],
|
|
66
|
+
before: Annotated[str, typer.Option(help="group of graphs before training")],
|
|
67
|
+
after: Annotated[str, typer.Option(help="group of graphs after training, same prompts in the same order")],
|
|
68
|
+
control_before: Annotated[
|
|
69
|
+
str | None, typer.Option(help="control run (did not learn the behaviour), before")
|
|
70
|
+
] = None,
|
|
71
|
+
control_after: Annotated[str | None, typer.Option(help="control run, after")] = None,
|
|
72
|
+
replication_before: Annotated[str | None, typer.Option(help="another seed, before")] = None,
|
|
73
|
+
replication_after: Annotated[str | None, typer.Option(help="another seed, after")] = None,
|
|
74
|
+
top_k: int = 10,
|
|
75
|
+
min_present: int = 6,
|
|
76
|
+
seed: int = 0,
|
|
77
|
+
html: Annotated[Path | None, typer.Option(help="write the HTML report here")] = None,
|
|
78
|
+
json_out: Annotated[Path | None, typer.Option("--json", help="write the JSON report here")] = None,
|
|
79
|
+
) -> None:
|
|
80
|
+
"""Which features and edges did training add or remove? With a stability check, a null and validity checks."""
|
|
81
|
+
from circuitcheck.diff.core import Settings, diff_graphs
|
|
82
|
+
from circuitcheck.diff.graphs import load_summaries
|
|
83
|
+
from circuitcheck.report import render_diff
|
|
84
|
+
|
|
85
|
+
g = load_summaries(file)
|
|
86
|
+
fb = g[before][0].meta.get("fvu_by_layer")
|
|
87
|
+
fa = g[after][0].meta.get("fvu_by_layer")
|
|
88
|
+
rep = diff_graphs(
|
|
89
|
+
g[before],
|
|
90
|
+
g[after],
|
|
91
|
+
settings=Settings(top_k=top_k, min_present=min_present, seed=seed),
|
|
92
|
+
control=(g[control_before], g[control_after]) if control_before and control_after else None,
|
|
93
|
+
replication=(g[replication_before], g[replication_after]) if replication_before and replication_after else None,
|
|
94
|
+
fvu_before=fb,
|
|
95
|
+
fvu_after=fa,
|
|
96
|
+
before_name=before,
|
|
97
|
+
after_name=after,
|
|
98
|
+
)
|
|
99
|
+
s = rep.summary
|
|
100
|
+
typer.echo(f"gained {s.n_gained}, lost {s.n_lost}, up {s.n_up}, down {s.n_down}; total change p = {s.change_p:.3f}")
|
|
101
|
+
for c in rep.top:
|
|
102
|
+
typer.echo(
|
|
103
|
+
f" {c.unit:>12} {c.status:7} Δ={c.delta:+.4f} "
|
|
104
|
+
f"graphs {c.present_before}->{c.present_after} p={c.p_null:.3f}"
|
|
105
|
+
)
|
|
106
|
+
typer.echo("validity: " + ("ok" if rep.validity.ok else "; ".join(rep.validity.warnings)))
|
|
107
|
+
_write(rep.model_dump_json(indent=1), render_diff(rep), json_out, html)
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
@app.command()
|
|
111
|
+
def validate(
|
|
112
|
+
demo: Annotated[bool, typer.Option(help="run on the built-in toy model (no GPU, no downloads)")] = False,
|
|
113
|
+
model: Annotated[str | None, typer.Option(help="HF model id for circuit-tracer")] = None,
|
|
114
|
+
transcoders: Annotated[str | None, typer.Option(help="transcoder set (HF id or local path)")] = None,
|
|
115
|
+
prompt: Annotated[str | None, typer.Option(help="the prompt")] = None,
|
|
116
|
+
counterfactual: Annotated[
|
|
117
|
+
str | None, typer.Option(help="a prompt with a different answer, for the patch test")
|
|
118
|
+
] = None,
|
|
119
|
+
features: Annotated[str | None, typer.Option(help="comma-separated layer:feature@pos, e.g. 20:15589@9")] = None,
|
|
120
|
+
target: Annotated[str | None, typer.Option(help="answer token, e.g. ' Austin'")] = None,
|
|
121
|
+
contrast: Annotated[str | None, typer.Option(help="contrast token, e.g. ' Dallas'")] = None,
|
|
122
|
+
n_controls: int = 20,
|
|
123
|
+
steer_scale: float = 3.0,
|
|
124
|
+
seed: int = 0,
|
|
125
|
+
device: Annotated[str | None, typer.Option(help="cuda, mps or cpu")] = None,
|
|
126
|
+
html: Annotated[Path | None, typer.Option(help="write the HTML report here")] = None,
|
|
127
|
+
json_out: Annotated[Path | None, typer.Option("--json", help="write the JSON report here")] = None,
|
|
128
|
+
) -> None:
|
|
129
|
+
"""Is each group of features a switch or a readout? Ablation, steering and patching against controls."""
|
|
130
|
+
from circuitcheck.report import render_validate
|
|
131
|
+
from circuitcheck.validate import FeatureNode, logit_diff
|
|
132
|
+
from circuitcheck.validate import validate as run
|
|
133
|
+
|
|
134
|
+
if demo:
|
|
135
|
+
from circuitcheck.validate.toy import demo as toy
|
|
136
|
+
from circuitcheck.validate.toy import demo_nodes
|
|
137
|
+
|
|
138
|
+
b = toy()
|
|
139
|
+
metric = logit_diff(b.token_id(" Austin"), b.token_id(" Sacramento"), " Austin", " Sacramento")
|
|
140
|
+
rep = run(b, "dallas", demo_nodes(b), metric=metric, counterfactual="oakland", n_controls=n_controls, seed=seed)
|
|
141
|
+
else:
|
|
142
|
+
if not (model and transcoders and prompt and features and target and contrast):
|
|
143
|
+
raise typer.BadParameter(
|
|
144
|
+
"give --demo, or all of --model --transcoders --prompt --features --target --contrast"
|
|
145
|
+
)
|
|
146
|
+
import torch
|
|
147
|
+
from circuit_tracer import ReplacementModel
|
|
148
|
+
|
|
149
|
+
from circuitcheck.tracer import TracerBackend
|
|
150
|
+
|
|
151
|
+
dev = torch.device(device) if device else None
|
|
152
|
+
rm = ReplacementModel.from_pretrained(model, transcoders, device=dev, dtype=torch.bfloat16)
|
|
153
|
+
b2 = TracerBackend(rm)
|
|
154
|
+
nodes = []
|
|
155
|
+
for spec in features.split(","):
|
|
156
|
+
lf, pos = spec.strip().split("@")
|
|
157
|
+
layer, feat = lf.split(":")
|
|
158
|
+
nodes.append(FeatureNode(layer=int(layer), pos=int(pos), feature=int(feat), activation=0.0))
|
|
159
|
+
metric = logit_diff(b2.token_id(target), b2.token_id(contrast), target, contrast)
|
|
160
|
+
rep = run(
|
|
161
|
+
b2,
|
|
162
|
+
prompt,
|
|
163
|
+
nodes,
|
|
164
|
+
metric=metric,
|
|
165
|
+
counterfactual=counterfactual,
|
|
166
|
+
n_controls=n_controls,
|
|
167
|
+
steer_scale=steer_scale,
|
|
168
|
+
seed=seed,
|
|
169
|
+
progress=typer.echo,
|
|
170
|
+
)
|
|
171
|
+
for g in rep.groups:
|
|
172
|
+
typer.echo(f" {g.name:>20} {g.verdict.value}")
|
|
173
|
+
_write(rep.model_dump_json(indent=1), render_validate(rep), json_out, html)
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
@app.command()
|
|
177
|
+
def label(
|
|
178
|
+
neuronpedia: Annotated[
|
|
179
|
+
str | None, typer.Option(help="model/source on Neuronpedia, e.g. gemma-2-2b/20-gemmascope-transcoder-16k")
|
|
180
|
+
] = None,
|
|
181
|
+
features: Annotated[str | None, typer.Option(help="comma-separated feature indices (with --neuronpedia)")] = None,
|
|
182
|
+
records: Annotated[Path | None, typer.Option(help="JSON list of {unit, records:[{tokens, acts}]} instead")] = None,
|
|
183
|
+
provider: Annotated[str, typer.Option(help="anthropic (Claude Haiku 5.5) or openai")] = "anthropic",
|
|
184
|
+
model_name: Annotated[str | None, typer.Option("--model", help="override the provider's default model")] = None,
|
|
185
|
+
score: Annotated[str, typer.Option(help="detection, simulation, both or none")] = "detection",
|
|
186
|
+
compare_neuronpedia: Annotated[bool, typer.Option("--compare", help="also score Neuronpedia's labels")] = False,
|
|
187
|
+
cache: Annotated[Path | None, typer.Option(help="LLM cache file (JSON lines)")] = None,
|
|
188
|
+
html: Annotated[Path | None, typer.Option(help="write the HTML report here")] = None,
|
|
189
|
+
json_out: Annotated[Path | None, typer.Option("--json", help="write the labels as JSON here")] = None,
|
|
190
|
+
export: Annotated[Path | None, typer.Option(help="write Neuronpedia-format explanation rows here")] = None,
|
|
191
|
+
) -> None:
|
|
192
|
+
"""Label features from their top activating examples and score each label on held-out examples."""
|
|
193
|
+
from circuitcheck.labels import FeatureData, calibration, label_features, make_llm, score_labels
|
|
194
|
+
from circuitcheck.labels.neuronpedia import compare, export_explanations, feature_data
|
|
195
|
+
from circuitcheck.report import render_labels
|
|
196
|
+
|
|
197
|
+
llm = make_llm(provider, model_name, cache)
|
|
198
|
+
data: list[FeatureData] = []
|
|
199
|
+
theirs: dict[str, str] = {}
|
|
200
|
+
theirs_model: dict[str, str] = {}
|
|
201
|
+
model_id = source = ""
|
|
202
|
+
if neuronpedia:
|
|
203
|
+
model_id, source = neuronpedia.split("/", 1)
|
|
204
|
+
for idx in (features or "").split(","):
|
|
205
|
+
fd, labs = feature_data(model_id, source, int(idx))
|
|
206
|
+
data.append(fd)
|
|
207
|
+
if labs:
|
|
208
|
+
theirs[fd.unit], theirs_model[fd.unit] = labs[0]["description"], labs[0]["model"]
|
|
209
|
+
elif records:
|
|
210
|
+
data = [FeatureData.model_validate(x) for x in json.loads(records.read_text(encoding="utf-8"))]
|
|
211
|
+
else:
|
|
212
|
+
raise typer.BadParameter("give --neuronpedia with --features, or --records")
|
|
213
|
+
ours = label_features(llm, data, score=score)
|
|
214
|
+
comps = []
|
|
215
|
+
if compare_neuronpedia and theirs:
|
|
216
|
+
comps = compare(ours, score_labels(llm, data, theirs, score=score), theirs_model)
|
|
217
|
+
for lab in ours:
|
|
218
|
+
typer.echo(
|
|
219
|
+
f" {lab.unit:>10} {lab.score if lab.score is not None else float('nan'):.2f} {lab.label or lab.error}"
|
|
220
|
+
)
|
|
221
|
+
u = llm.usage
|
|
222
|
+
typer.echo(
|
|
223
|
+
f"API calls: {u.calls} (cache hits: {u.cached_calls}), {u.input_tokens}+{u.output_tokens} tokens, ${u.usd:.4f}"
|
|
224
|
+
)
|
|
225
|
+
if export and neuronpedia:
|
|
226
|
+
export_explanations(ours, model_id, source, export)
|
|
227
|
+
_write(
|
|
228
|
+
json.dumps([x.model_dump() for x in ours], indent=1),
|
|
229
|
+
render_labels(ours, calibration(ours), comps),
|
|
230
|
+
json_out,
|
|
231
|
+
html,
|
|
232
|
+
)
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
if __name__ == "__main__": # pragma: no cover
|
|
236
|
+
app()
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
"""Diff attribution graphs across training. See `circuitcheck.diff.core` for the method."""
|
|
2
|
+
|
|
3
|
+
from circuitcheck.diff.core import CAVEAT, DiffReport, EdgeChange, Settings, UnitChange, Validity, diff_graphs
|
|
4
|
+
from circuitcheck.diff.graphs import (
|
|
5
|
+
GraphSummary,
|
|
6
|
+
from_circuit_tracer,
|
|
7
|
+
from_feature_lists,
|
|
8
|
+
from_neuronpedia,
|
|
9
|
+
load_summaries,
|
|
10
|
+
save_summaries,
|
|
11
|
+
)
|
|
12
|
+
|
|
13
|
+
__all__ = [
|
|
14
|
+
"CAVEAT",
|
|
15
|
+
"DiffReport",
|
|
16
|
+
"EdgeChange",
|
|
17
|
+
"GraphSummary",
|
|
18
|
+
"Settings",
|
|
19
|
+
"UnitChange",
|
|
20
|
+
"Validity",
|
|
21
|
+
"diff_graphs",
|
|
22
|
+
"from_circuit_tracer",
|
|
23
|
+
"from_feature_lists",
|
|
24
|
+
"from_neuronpedia",
|
|
25
|
+
"load_summaries",
|
|
26
|
+
"save_summaries",
|
|
27
|
+
]
|