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.
@@ -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
+ ]