Skip to content

ACDC circuit discovery as a driver loop

ACDC (Conmy et al., 2023) finds a circuit by pruning edges: walk the receivers from the output back to the input, and for each receiver try removing each incoming edge (patching it to its value on a corrupted input); keep the removal when the metric moves by less than a threshold tau. TorchLens does not ship a circuit-search loop. It enumerates the edges (torchlens.mechinterp.component_graph), executes each patch (torchlens.mechinterp.patch_edges, edge substitution on the replay engine); the loop, every prune decision and their record are yours, and live on this page.

Why each edge test goes through edge substitution and not sweep_sites: sweep_sites applies one edit at every candidate site, but the value an edge patch writes depends on the edge's sender (receiver t reads input - s + s_corrupt), and every edge into t writes at the same site, t's input. One edit per site cannot express that, so the loop calls patch_edges for every test. sweep_sites runs once, beside the loop, as a head-knockout pass whose table is reported next to the circuit; it never changes which edges are pruned.

The page runs on a two-layer GPT-2 stand-in with random weights (two heads per layer, eight dimensions, 57 graph edges of which 46 are not bias edges) and walks only its last two receivers, logits and m1 (13 edge tests), so it finishes in seconds on CPU. The full walk is the same loop over reversed(list(graph.receivers.values())); on GPT-2 small that is 32,491 edge tests, each a multi-receiver patch: run it as a background job.

Minimal example on the running model

This page uses the decoder on the IOI prompt. ACDC's unit step is one ablation and the change it makes to the logits; here it is on one MLP of the running decoder. The recipe below builds its own model for the method.

# recipe-test: tool=transformers expected=True
import torch
import transformers

import torchlens as tl

PROMPT = "When Mary and John went to the store, John gave a drink to"
words = PROMPT.replace(",", " ,").split()
vocab = sorted(set(words))
x = torch.tensor([[vocab.index(word) for word in words]])
torch.manual_seed(0)
config = transformers.GPT2Config(
    vocab_size=len(vocab),
    n_positions=32,
    n_embd=64,
    n_layer=2,
    n_head=4,
    use_cache=False,
    attn_implementation="eager",
)
model = transformers.GPT2LMHeadModel(config).eval()

trace = tl.trace(model, x)

site = tl.module("transformer.h.1.mlp")
ablated = trace.fork().do(site, tl.zero_ablate(), model=model, inputs=x)
clean_logits = trace["lm_head"].out[0, -1]
ablated_logits = ablated["lm_head"].out[0, -1]
RESULT = not torch.equal(clean_logits, ablated_logits)

Setup: the stand-in, the two inputs and the metric

The metric is ACDC's default: the KL divergence from the full model's next-token distribution to the circuit's, at the last position, averaged over the batch. The full model has KL 0; pruning raises it.

# recipe-test: tool=transformers expected=True
import torch
from transformers import GPT2Config, GPT2LMHeadModel

import torchlens as tl
import torchlens.mechinterp

config = GPT2Config(n_layer=2, n_head=2, n_embd=8, vocab_size=32, n_positions=16, use_cache=False)
config._attn_implementation = "eager"
torch.manual_seed(0)
model = GPT2LMHeadModel(config).eval().double()
with torch.no_grad():
    for param in model.parameters():
        param.mul_(3.0)  # sharper attention, so the edges differ in importance
clean_ids = torch.tensor([[3, 7, 1, 9, 4, 3], [2, 6, 1, 8, 5, 2]])
corrupt_ids = torch.tensor([[5, 7, 2, 9, 8, 5], [4, 6, 3, 8, 1, 4]])

ready = tl.options.CaptureOptions(replayable=True)
clean = tl.trace(model, clean_ids, capture=ready, save="all")
corrupt_trace = tl.trace(model, corrupt_ids, capture=ready, save="all")
graph = torchlens.mechinterp.component_graph(clean)
corrupt = torchlens.mechinterp.component_graph(corrupt_trace)
full_logp = torch.log_softmax(clean.modules["lm_head"].out[:, -1], dim=-1)


def kl_to_full(logits):
    """KL(full || circuit) at the last position, averaged over the batch."""
    logp = torch.log_softmax(logits[:, -1], dim=-1)
    return float((full_logp.exp() * (full_logp - logp)).sum(dim=-1).mean())


def circuit_kl(pruned):
    """Patch every pruned edge to its corrupted sender in one call; return the KL."""
    patched = torchlens.mechinterp.patch_edges(clean, pruned, corrupted_graph=corrupt)
    return kl_to_full(patched.modules["lm_head"].out)


edges = [e for e in graph.edges.values() if e.sender.kind != "bias"]
print(len(graph.edges), len(edges), circuit_kl([]))
RESULT = (len(graph.edges), len(edges), circuit_kl([])) == (57, 46, 0.0)

The head-knockout pass (sweep_sites, reported beside the circuit)

Each candidate zero-ablates one head's value vectors on a real forward. The sweep's Bundle is its own record: the per-candidate effect table and the operation chronology persist with it.

# recipe-test: tool=transformers expected=True
from torchlens.experiment import head_ablation_candidates, sweep_sites

candidates = {}
for layer in range(2):
    plan = head_ablation_candidates(clean, f"transformer.h.{layer}.attn", heads=2)
    candidates.update({f"a{layer}.{key}": site for key, site in plan.items()})
knockout = sweep_sites(
    clean,
    candidates=candidates,
    edit=tl.zero_ablate(),
    metric=lambda member: kl_to_full(member[member.output_layers[0]].out),
    retain="none",
    model=model,
    inputs=clean_ids,
)
knockout_kl = {row.candidate_id: round(row.value, 4) for row in knockout.effects().rows
               if row.candidate_id != "__baseline__"}
print(knockout_kl)
RESULT = sorted(knockout_kl) == ["a0.h0", "a0.h1", "a1.h0", "a1.h1"]

The ACDC loop

Receivers in reverse topological order (logits first; here only the last two), each receiver's senders latest first; bias edges are skipped, since a bias is the same on both inputs and patching it changes nothing. Every test appends one decision row with the observed KL change and the decision: pruned (the edge is not needed) or kept.

# recipe-test: tool=transformers expected=True
tau = 0.002
pruned = []
current = circuit_kl(pruned)
walk = [graph.receivers["logits"], graph.receivers["m1"]]  # full walk: reversed(graph.receivers)
tested = []
decisions = []  # the record: one row per edge test, written by the loop
for receiver in walk:
    for sender in reversed(receiver.senders):
        if sender.kind == "bias":
            continue
        edge = graph.edges[f"{sender.name}->{receiver.name}"]
        tested.append(edge.name)
        trial = circuit_kl(pruned + [edge])
        change = trial - current
        decisions.append({"edge": edge.name, "kl_before": current, "kl_after": trial,
                          "change": change, "decision": "pruned" if change < tau else "kept"})
        if change < tau:
            pruned.append(edge)
            current = trial

pruned_names = {e.name for e in pruned}
kept = [name for name in tested if name not in pruned_names]
print(f"kept {len(kept)} of {len(tested)} tested edges, final KL {current:.6f}")
print(kept)
RESULT = (len(tested), 0 < len(kept) < len(tested)) == (13, True)

On IOI with GPT-2 small

The same loop runs unchanged on GPT-2 small: load openai-community/gpt2 with attn_implementation="eager", take the clean prompts and their corrupted counterparts from the MIB IOI task (mib-bench/ioi, the s2_io_flip_counterfactual column, padded to one width), and keep the metric (or use minus the logit difference of the indirect object over the subject). Each test patches every pruned edge in one multi-receiver call, so the cost of a test grows with the number of receivers already touched; budget a background job. The measured cost on 20 rows and 4 CPU cores (0.9 s for a test into logits, 138 s for one that touches every receiver, about 450 hours for the full walk) is in the mechinterp reference, under the circuit test cases on GPT-2 small.

The record

The loop writes its own record: one row per edge test with the KL before and after the patch, the change and the decision, and the kept set is exactly the edges whose row reads kept. Re-running the circuit the record describes (its pruned edges patched, in record order) in one call reproduces the loop's last KL exactly. Save the rows next to the head-knockout Bundle (knockout.save(...)) and the experiment is reproducible from files.

# recipe-test: tool=transformers expected=True
import json
from pathlib import Path

Path("acdc_decisions.json").write_text(json.dumps(decisions, indent=1))
record = json.loads(Path("acdc_decisions.json").read_text())
from_record_kept = [row["edge"] for row in record if row["decision"] == "kept"]
from_record_pruned = [graph.edges[row["edge"]] for row in record if row["decision"] == "pruned"]
again = circuit_kl(from_record_pruned)
print(len(record), sorted(from_record_kept) == sorted(kept), again == current)
RESULT = (len(record), sorted(from_record_kept) == sorted(kept), again == current) == (13, True, True)

The page's test (tests/test_circ3_acdc_recipe.py) runs these fences in order, re-runs the pruning loop independently of the record and checks that it prunes the same edges, and checks that every tested edge has exactly one decision row.