Independent open-source implementation of R-lens: LRP rules for the Jacobian lens, validated against the authors' released lenses at cosine 0.996
2
stars
1
commits
Python
primary language
Sep 1, 2026
updated
An independent open-source implementation of R-lens — the Jacobian lens with layer-wise-relevance-propagation rules in the backward pass, from R-lens: Making J-lens More Faithful on Early Layers (Blank, Bhatia & Nanda, August 2026).
Not affiliated with or endorsed by the authors of that post. The authors released fitted lens tensors (
camilablank/workspace-lenses) but not the fitting code. This repo is that missing piece.
| Reproduces the released R-lens | cosine 0.9960 on Qwen3.5-4B; rule-delta cosine 0.9949 (median); no outlier layer |
| Forward pass untouched | 0.0 relative drift, bitwise, on a bf16 model |
| Backward pass genuinely changed | 0.93 relative, cosine 0.38 vs the true Jacobian |
| Scale claim reproduced | early-layer gain +0.047 → +0.070 → +0.090 across 4B → 9B → 27B |
| Cost | ~1 GPU-hour for a matched 4B pair (25 prompts, 5 shards) |
What you get: the three rules as a verified reversible model patch, a sharded
Modal fitting pipeline, a pass@k evaluator that keeps per-layer ranks, and
tooling that checks the result against the authors' released lenses.
flowchart LR
C["pile-10k<br/>25 prompts"] --> F["jlens.fit"]
M["HF model"] --> F
M --> P["lrp_rules()<br/>3 stop-gradients"]
P --> F
F -->|unpatched| JL["J-lens"]
F -->|patched| RL["R-lens"]
JL --> E["pass@k harness<br/>per-layer ranks"]
RL --> E
JL --> X["released_compare<br/>2×2 vs HF lenses"]
RL --> X
R-lens is J-lens with three stop-gradients inserted into the backward pass used
to estimate the Jacobian. The forward pass is untouched, so the only thing that
changes is J_l:
| Rule | Where | Effect on the backward pass |
|---|---|---|
| LN-rule | residual-stream RMSNorm / LayerNorm | detach the 1/rms denominator, so the norm's Jacobian becomes diag(g)/rms(x) instead of the true projection that annihilates the radial direction |
| Identity-rule | SiLU / GELU in the MLP | write act(z) = z · (act(z)/z) and detach the second factor; for SiLU the gradient becomes exactly sigmoid(z) rather than sigmoid(z) + z·sigmoid'(z) |
| Half-rule | gated MLP's act(gate(x)) * up(x) | evaluate β·(a·b.detach()) + (1−β)·(a.detach()·b) with β = 0.5, so each branch receives half the relevance instead of the product rule sending a full copy down both |
All three are algebraically value-preserving, which gives a strong invariant:
patching must not change the forward pass at all. lrp_rules verifies this
per module against the dtype's rounding budget and raises if a rewrite drifts,
so an unrecognised norm convention fails loudly rather than silently producing
a wrong lens.
The identity rule is a custom autograd.Function that passes the real fused
kernel's output through and overrides only the backward. Writing it the obvious
way — z * sigmoid(z).detach() — recomputes SiLU from unfused primitives and
rounds differently, which measures as 2.9% relative drift in the final hidden
state of a bf16 Qwen3-0.6B accumulated over 28 layers. With the
autograd.Function the drift is exactly zero, so a J-lens/R-lens
comparison differs in the backward pass and nothing else.
verify patches a real model and asserts the forward pass is unchanged while
the layer-0 gradient moves. Measured on Qwen/Qwen3-0.6B:
| fp32 | bf16 | |
|---|---|---|
| norms patched | 57 | 57 |
norms out of scope (q_norm/k_norm) | 56 | 56 |
| gated MLPs patched | 28 | 28 |
| forward drift (relative) | 5.4e-7 | 0.0 |
| gradient change (relative) | 0.93 | 0.93 |
| gradient cosine vs J-lens | 0.38 | 0.38 |
Note the last row. The rules leave the forward pass bitwise identical but rotate the backward pass a long way — cosine 0.38 against the true Jacobian. So R-lens is not an approximation of the first-order causal effect that J-lens is defined as; it is a different readout, which has to earn its keep empirically.
Because the rules are a model patch, fitting is upstream's jlens.fit called
unchanged:
import jlens
from rlens.rules import RuleConfig, lrp_rules
j_lens = jlens.fit(model, prompts) # baseline
with lrp_rules(hf_model, RuleConfig()):
r_lens = jlens.fit(model, prompts) # same call, patched backward
Requires Python 3.12+ and a Modal account. pytest needs no GPU.
uv sync
uv run pytest # 25 tests, no GPU
# gate: cheap, run before paying for a fit
uv run modal run modal_app.py::verify --model-id Qwen/Qwen3-0.6B
# fit a matched J/R pair, sharded
uv run modal run modal_app.py::fit --model-id Qwen/Qwen3-0.6B --variant both
# score it (writes per-layer ranks to the volume)
uv run modal run modal_app.py::evaluate --model-id Qwen/Qwen3-0.6B
# fit under the released lenses' exact provenance, then compare
uv run modal run modal_app.py::replicate --model-id Qwen/Qwen3.5-4B
uv run modal run modal_app.py::released_compare --model-id Qwen/Qwen3.5-4B \
--released-slug qwen3.5-4b
Where analysis runs matters. Anything touching a lens.pt (400 MB – 3.5 GB)
stays on Modal, where the file already is. Only the small per-layer rank JSONs
come down:
for v in j-lens r-lens; do
modal volume get rlens-outputs \
"Qwen__Qwen3-0.6B/$v/eval-ranks.json" "results/Qwen__Qwen3-0.6B/$v/"
done
uv run python scripts/compare.py results/Qwen__Qwen3-0.6B # k-sweep, per-layer curves
rlens/rules.py the three rules, as a reversible verified model patch
rlens/evaluate.py pass@k with per-layer ranks retained
modal_app.py verify / fit / replicate / evaluate / inspect / diagnostics
scripts/compare.py J-vs-R tables, k-sweep, per-layer curves, first-hit shift
scripts/compare_to_released.py offline 2×2 (prefer modal_app::released_compare)
tests/test_rules.py analytic checks that each rule is the specified map
data/evaluations/ the six upstream eval sets (551 items), vendored, Apache-2.0
Everything below is our measurement, with our harness, of the authors' public artifacts. None of it is a figure quoted from the post, and absolute values are not directly comparable to the post's (see caveats).
Each released lens carries a provenance dict. For qwen3.5-4b:
{'model_id': 'Qwen/Qwen3.5-4B', 'dataset_id': 'NeelNanda/pile-10k',
'target_layer': 30, 't_max': 128, 'n_prompts': 25, 'docs_consumed': 25,
'skip_first': 4, 'weighting': 'uniform', 'corpus_mode': 'pretrain',
'config_json': '{"estimator": "relp", "rules": {"ln_rule": true,
"identity_rule": true, "half_rule": true, "half_rule_beta": 0.5,
"include_qk_norms": false, "gated_norms": false}}'}
Three settings differ from the upstream J-lens defaults and would silently
produce a non-comparable lens: the corpus is pile-10k (not WikiText),
skip_first is 4 (not 16), and n_prompts is 25. target_layer: 30
on a 32-block model is the penultimate block, and their source_layers run
0–30 with J_30 exactly the identity.
RuleConfig.as_provenance() emits our settings in the same shape.
include_qk_norms: false corresponds to this repo's ln_scope="residual"
default (Qwen3-style per-head q_norm/k_norm excluded), and
half_rule_beta: 0.5 to the even split. gated_norms has no counterpart here;
it is off in every released lens.
Fitting Qwen3.5-4B under that exact provenance, over 30 comparable layers (the identity target layer excluded, since including it inflates every score):
| theirs J | theirs R | |
|---|---|---|
| ours J | 0.9867 | 0.8790 |
| ours R | 0.8798 | 0.9960 |
Clean diagonal dominance: our R matches their R far better than their J, and
vice versa. The sharpest single number is the rule-delta test — cosine
between our R − J and their R − J, which cancels the shared J baseline and
isolates the backward-pass rules from the corpus: median 0.9949, mean
0.9804.
Per-layer relative error falls monotonically with depth — J from 0.43 at layer 0 to 0.012 at layer 29, R from 0.204 to 0.009 — with no layer flagged as an outlier (R relerr never exceeds 4× the median) and Frobenius-norm ratios inside 0.7% from layer 3 on.
Is the early-layer residual just corpus sampling noise? Testable for free:
the five shards were each fitted on five disjoint prompts, so sampling noise
predicts shard-vs-shard (n=5) exceeds ours-vs-theirs (n=25) by exactly
√5 = 2.24×. Measured: 6.5×. Our lens agrees with theirs about 3×
better than independent corpus draws would allow, so the same documents are
substantially being drawn; the remaining gap points at document selection or
chunking rather than the estimator.
Matrix agreement shows the weights match. This shows the whole chain matches:
| category | our J | their J | our R | their R | our Δ | their Δ |
|---|---|---|---|---|---|---|
| typo | 0.760 | 0.792 | 0.812 | 0.823 | +0.052 | +0.031 |
| order-ops | 0.627 | 0.655 | 0.682 | 0.673 | +0.055 | +0.018 |
| multilingual | 0.495 | 0.486 | 0.509 | 0.507 | +0.014 | +0.021 |
| multihop | 0.455 | 0.461 | 0.509 | 0.520 | +0.054 | +0.059 |
| association | 0.049 | 0.059 | 0.069 | 0.059 | +0.020 | +0.000 |
| poetry | 0.020 | 0.010 | 0.020 | 0.020 | +0.000 | +0.010 |
| mean | +0.033 | +0.023 | ||||
| mean, early layers | +0.051 | +0.047 |
Every absolute value agrees within 0.01–0.03 and the early-layer means agree to 0.004 — two separately fitted lens pairs converging on the same behaviour, not merely the same matrices.
evaluate_released needs only inference, so the post's central quantitative
claim — that gains grow with model scale — is testable without fitting
anything. Mean pass@10 delta (R minus J) over the six categories, from the
released lenses:
| model | all layers | early layers | early/all |
|---|---|---|---|
| Qwen3.5-4B | +0.023 | +0.047 | 2.0× |
| Qwen3.5-9B | +0.019 | +0.070 | 3.7× |
| Qwen3.5-27B | +0.038 | +0.090 | 2.4× |
Early-layer gains grow monotonically with scale, roughly doubling from 4B to 27B. That is the post's claim, reproduced independently. The all-layer metric does not show it — it dips at 9B — so the choice of window is what makes the effect visible. Reporting only the aggregate would understate R-lens at scale.
Lenses fitted in this repo, for comparison (--rules-preset all, paper recipe
for the 0.6B):
| model | all layers | early layers |
|---|---|---|
| Qwen3-0.6B | +0.039 | +0.049 |
| Qwen3.5-4B | +0.033 | +0.051 |
At 27B the single largest effect is typo on early layers, +0.333 (0.083 → 0.417), followed by multihop +0.104 (0.014 → 0.118). No category is negative at 27B in either window.
Both survive a k-sweep, so neither is a metric artefact:
pass@10 saturates, and it hides large effects. Where the model is far
from the answer, a top-10 cutoff registers a big improvement as +0.000. On
Qwen3-0.6B, poetry's target sits at median rank 1778 under J-lens and 319
under R-lens — R-lens better on 95 of 98 items — yet pass@10 reports
0.000 vs 0.010. Sweeping k:
| category | d@10 | d@100 | d@500 | d@1000 |
|---|---|---|---|---|
| poetry | +0.010 | +0.184 | +0.378 | +0.449 |
| association | +0.000 | +0.049 | +0.118 | +0.098 |
scripts/compare.py prints a k-sweep by default and warns when the headline k
is saturated. Do not read a single k.
The logit-lens floor. The floor any fitted lens must clear is doing no
transport at all (use_jacobian=False). On Qwen3-0.6B under the paper recipe,
R-lens clears it on every category at every k, with the single exception of
poetry at k=10 — which is the saturation artefact above:
| category | @10 | @100 | @500 | @1000 |
|---|---|---|---|---|
| typo | +0.365 | +0.146 | +0.031 | +0.021 |
| order-ops | +0.182 | +0.045 | +0.082 | +0.109 |
| multihop | +0.106 | +0.118 | +0.075 | +0.032 |
| multilingual | +0.051 | +0.089 | +0.180 | +0.175 |
| association | +0.000 | +0.049 | +0.118 | +0.147 |
| poetry | −0.020 | +0.031 | +0.224 | +0.235 |
Fitting corpus matters here: with WikiText instead of pile-10k, the J-lens sat below the floor on order-ops (−0.064), which inflated the apparent R-lens gain. Under the paper's corpus it clears (+0.018).
R-lens is a lower-variance estimator at early layers. Per-shard ||J_l||
over five disjoint 5-prompt shards (Qwen3.5-4B), coefficient of variation:
| layer | J-lens CV | R-lens CV |
|---|---|---|
| 0 | 0.244 | 0.104 |
| 1 | 0.238 | 0.113 |
| 2 | 0.176 | 0.115 |
| 3 | 0.118 | 0.079 |
| 10 | 0.046 | 0.046 |
R-lens more than halves shard-to-shard variance at early layers and converges to identical variance by layer 10. This is a candidate mechanism for why the rules help exactly where they do — a lower-variance estimator, not only less error accumulation — and it is measurable from artifacts already on disk.
Fitting cost is 2 · N_active · d_model · seq_len FLOPs per prompt — one
backward pass per residual dimension, so d_model is a linear multiplier and
dominates the choice of target model.
| Model | FLOPs/prompt | 25 prompts | Accumulator (fp32) |
|---|---|---|---|
| Qwen3-0.6B | ~1.6e14 | minutes on an L4 | 0.12 GB |
| Qwen3.5-4B | ~2.6e15 | ~11 min on an H100, 5 shards | 0.8 GB |
| Qwen3.5-27B | ~3.5e16 | ~13× the 4B | 6.7 GB |
Budget 2× for a matched pair. The accumulator is
len(source_layers) · d_model² · 4 bytes and stays resident; trimming
source_layers saves memory and disk but not compute, since the backward
pass reaches layer 0 either way. Fitting is sharded over disjoint prompt slices
and merged with JacobianLens.merge, so wall-clock scales with shard count.
dim_batch replicates the prompt along the batch axis and does not change
total FLOPs, but it does drive peak memory: Qwen3.5-4B at dim_batch=32 OOMs a
48 GB L40S, because the retained graph spans 31 blocks and intermediate_size
is 9216. dim_batch=8 on an H100 is comfortable.
All eight models ship matched j-lens/r-lens pairs. A lens file is
n_layers · d_model² · 2 bytes, so its size pins L·d²:
| slug | model | L | d_model | MoE | GB/lens | fit cost vs 4B |
|---|---|---|---|---|---|---|
qwen3.6-35b-a3b | Qwen3.6-35B-A3B | 40 | 2048 | 3B active | 0.33 | 0.6× |
qwen3.5-4b | Qwen3.5-4B | 32 | 2560 | — | 0.41 | 1× |
qwen3.5-122b-a10b | Qwen3.5-122B-A10B | 48 | 3072 | 10B active | 0.89 | 3× |
qwen3.5-9b | Qwen3.5-9B | 32 | 4096 | — | 1.04 | 3.6× |
deepseek-v4-flash | DeepSeek-V4-Flash | 43 | 4096 | 13B active | 1.41 | 5.2× |
qwen3.5-27b | Qwen3.5-27B | 64 | 5120 | — | 3.30 | 13.5× |
qwen3.6-27b | Qwen3.6-27B | 64 | 5120 | — | 3.30 | 13.5× |
gemma-3-27b-it | gemma-3-27b-it | ~61 | 5376 | — | 3.53 | 14× |
qwen3.5-27b and qwen3.6-27b are dimensionally identical — two model
generations at matched size, both with released pairs.
Each rule toggles independently, tests/test_rules.py asserts each one moves
the backward pass on its own, and --rules-preset fits any subset:
uv run modal run modal_app.py::fit --variant r-lens --rules-preset ln
uv run modal run modal_app.py::fit --variant r-lens --rules-preset identity
uv run modal run modal_app.py::fit --variant r-lens --rules-preset half
uv run modal run modal_app.py::evaluate --variant j-lens,r-lens-ln
--ln-scope switches the LN-rule between residual (the default: only norms
at d_model, so Qwen3's per-head q_norm/k_norm are left alone) and all
(every norm, as AttnLRP does). On Qwen3-0.6B that is 57 norms versus 113.
RuleConfig also declares attn_half_rule and router_identity_rule, which
AttnLRP would apply to the attention softmax and the MoE router. These are
not implemented; doing so needs an eager attention path to patch, since
SDPA/flash fuse the tensors away. lrp_rules raises NotImplementedError
rather than ignoring them, because a silently-inert flag would make an ablation
return the baseline's numbers and read as a null result.
Compare lenses where they already live. modal_app.py::released_compare
runs the 2×2 against the released lenses inside Modal, reading ours from the
volume and theirs from an HF cache hop. scripts/compare_to_released.py does
the same locally and is kept for offline analysis, but the Modal path is the
default for a reason:
torch.load accepts a partially-written .pt as long as the zip central
directory is intact, and the tensors it returns look entirely plausible.
modal volume get returns before all bytes have landed, and a byte-size check
is necessary but not sufficient. A partially-downloaded 786 MB lens produced
a confident, reproducible, and completely spurious 20% norm deficit at a single
layer — reproducible because the corruption was baked into the local file, so
re-reading it agreed with itself. Moving the comparison onto Modal removes the
failure mode instead of trying to detect it. verify_merge provides the
independent check: a merged lens must equal a fresh mean of its own shards at
every layer.
Modal volumes are not coherent across container reuse. fit_shard commits
each shard, but a merge_shards container reused from an earlier merge holds
the volume state it saw at mount time and fails with FileNotFoundError on
shards that modal volume ls lists. merge_shards calls outputs.reload()
first; merge_existing merges shards already on the volume so a merge failure
never costs a refit.
ORDER_OPS_SYNONYMS) is reconstructed from the
eval README's description, since the upstream table is not published. It is
applied identically to every lens under comparison, so it cannot manufacture
a J-vs-R delta, but absolute order-ops numbers are not comparable to the
post's.pass@k implementation reproduces no published number directly. Its
external check is that the released lenses show the paper's early-layer
claim under it.gated_norms counterpart, and anything above 27B.MIT — see LICENSE. The vendored evaluation sets in
data/evaluations/ are Apache-2.0 and remain under that license; see
NOTICE and data/evaluations/LICENSE.
1 commits
Hacker News (1)
Python
100.0%
Independent open-source implementation of R-lens: LRP rules for the Jacobian lens, validated against the authors' released lenses at cosine 0.996
2
stars
1
commits
Python
primary language
Sep 1, 2026
updated
An independent open-source implementation of R-lens — the Jacobian lens with layer-wise-relevance-propagation rules in the backward pass, from R-lens: Making J-lens More Faithful on Early Layers (Blank, Bhatia & Nanda, August 2026).
Not affiliated with or endorsed by the authors of that post. The authors released fitted lens tensors (
camilablank/workspace-lenses) but not the fitting code. This repo is that missing piece.
| Reproduces the released R-lens | cosine 0.9960 on Qwen3.5-4B; rule-delta cosine 0.9949 (median); no outlier layer |
| Forward pass untouched | 0.0 relative drift, bitwise, on a bf16 model |
| Backward pass genuinely changed | 0.93 relative, cosine 0.38 vs the true Jacobian |
| Scale claim reproduced | early-layer gain +0.047 → +0.070 → +0.090 across 4B → 9B → 27B |
| Cost | ~1 GPU-hour for a matched 4B pair (25 prompts, 5 shards) |
What you get: the three rules as a verified reversible model patch, a sharded
Modal fitting pipeline, a pass@k evaluator that keeps per-layer ranks, and
tooling that checks the result against the authors' released lenses.
flowchart LR
C["pile-10k<br/>25 prompts"] --> F["jlens.fit"]
M["HF model"] --> F
M --> P["lrp_rules()<br/>3 stop-gradients"]
P --> F
F -->|unpatched| JL["J-lens"]
F -->|patched| RL["R-lens"]
JL --> E["pass@k harness<br/>per-layer ranks"]
RL --> E
JL --> X["released_compare<br/>2×2 vs HF lenses"]
RL --> X
R-lens is J-lens with three stop-gradients inserted into the backward pass used
to estimate the Jacobian. The forward pass is untouched, so the only thing that
changes is J_l:
| Rule | Where | Effect on the backward pass |
|---|---|---|
| LN-rule | residual-stream RMSNorm / LayerNorm | detach the 1/rms denominator, so the norm's Jacobian becomes diag(g)/rms(x) instead of the true projection that annihilates the radial direction |
| Identity-rule | SiLU / GELU in the MLP | write act(z) = z · (act(z)/z) and detach the second factor; for SiLU the gradient becomes exactly sigmoid(z) rather than sigmoid(z) + z·sigmoid'(z) |
| Half-rule | gated MLP's act(gate(x)) * up(x) | evaluate β·(a·b.detach()) + (1−β)·(a.detach()·b) with β = 0.5, so each branch receives half the relevance instead of the product rule sending a full copy down both |
All three are algebraically value-preserving, which gives a strong invariant:
patching must not change the forward pass at all. lrp_rules verifies this
per module against the dtype's rounding budget and raises if a rewrite drifts,
so an unrecognised norm convention fails loudly rather than silently producing
a wrong lens.
The identity rule is a custom autograd.Function that passes the real fused
kernel's output through and overrides only the backward. Writing it the obvious
way — z * sigmoid(z).detach() — recomputes SiLU from unfused primitives and
rounds differently, which measures as 2.9% relative drift in the final hidden
state of a bf16 Qwen3-0.6B accumulated over 28 layers. With the
autograd.Function the drift is exactly zero, so a J-lens/R-lens
comparison differs in the backward pass and nothing else.
verify patches a real model and asserts the forward pass is unchanged while
the layer-0 gradient moves. Measured on Qwen/Qwen3-0.6B:
| fp32 | bf16 | |
|---|---|---|
| norms patched | 57 | 57 |
norms out of scope (q_norm/k_norm) | 56 | 56 |
| gated MLPs patched | 28 | 28 |
| forward drift (relative) | 5.4e-7 | 0.0 |
| gradient change (relative) | 0.93 | 0.93 |
| gradient cosine vs J-lens | 0.38 | 0.38 |
Note the last row. The rules leave the forward pass bitwise identical but rotate the backward pass a long way — cosine 0.38 against the true Jacobian. So R-lens is not an approximation of the first-order causal effect that J-lens is defined as; it is a different readout, which has to earn its keep empirically.
Because the rules are a model patch, fitting is upstream's jlens.fit called
unchanged:
import jlens
from rlens.rules import RuleConfig, lrp_rules
j_lens = jlens.fit(model, prompts) # baseline
with lrp_rules(hf_model, RuleConfig()):
r_lens = jlens.fit(model, prompts) # same call, patched backward
Requires Python 3.12+ and a Modal account. pytest needs no GPU.
uv sync
uv run pytest # 25 tests, no GPU
# gate: cheap, run before paying for a fit
uv run modal run modal_app.py::verify --model-id Qwen/Qwen3-0.6B
# fit a matched J/R pair, sharded
uv run modal run modal_app.py::fit --model-id Qwen/Qwen3-0.6B --variant both
# score it (writes per-layer ranks to the volume)
uv run modal run modal_app.py::evaluate --model-id Qwen/Qwen3-0.6B
# fit under the released lenses' exact provenance, then compare
uv run modal run modal_app.py::replicate --model-id Qwen/Qwen3.5-4B
uv run modal run modal_app.py::released_compare --model-id Qwen/Qwen3.5-4B \
--released-slug qwen3.5-4b
Where analysis runs matters. Anything touching a lens.pt (400 MB – 3.5 GB)
stays on Modal, where the file already is. Only the small per-layer rank JSONs
come down:
for v in j-lens r-lens; do
modal volume get rlens-outputs \
"Qwen__Qwen3-0.6B/$v/eval-ranks.json" "results/Qwen__Qwen3-0.6B/$v/"
done
uv run python scripts/compare.py results/Qwen__Qwen3-0.6B # k-sweep, per-layer curves
rlens/rules.py the three rules, as a reversible verified model patch
rlens/evaluate.py pass@k with per-layer ranks retained
modal_app.py verify / fit / replicate / evaluate / inspect / diagnostics
scripts/compare.py J-vs-R tables, k-sweep, per-layer curves, first-hit shift
scripts/compare_to_released.py offline 2×2 (prefer modal_app::released_compare)
tests/test_rules.py analytic checks that each rule is the specified map
data/evaluations/ the six upstream eval sets (551 items), vendored, Apache-2.0
Everything below is our measurement, with our harness, of the authors' public artifacts. None of it is a figure quoted from the post, and absolute values are not directly comparable to the post's (see caveats).
Each released lens carries a provenance dict. For qwen3.5-4b:
{'model_id': 'Qwen/Qwen3.5-4B', 'dataset_id': 'NeelNanda/pile-10k',
'target_layer': 30, 't_max': 128, 'n_prompts': 25, 'docs_consumed': 25,
'skip_first': 4, 'weighting': 'uniform', 'corpus_mode': 'pretrain',
'config_json': '{"estimator": "relp", "rules": {"ln_rule": true,
"identity_rule": true, "half_rule": true, "half_rule_beta": 0.5,
"include_qk_norms": false, "gated_norms": false}}'}
Three settings differ from the upstream J-lens defaults and would silently
produce a non-comparable lens: the corpus is pile-10k (not WikiText),
skip_first is 4 (not 16), and n_prompts is 25. target_layer: 30
on a 32-block model is the penultimate block, and their source_layers run
0–30 with J_30 exactly the identity.
RuleConfig.as_provenance() emits our settings in the same shape.
include_qk_norms: false corresponds to this repo's ln_scope="residual"
default (Qwen3-style per-head q_norm/k_norm excluded), and
half_rule_beta: 0.5 to the even split. gated_norms has no counterpart here;
it is off in every released lens.
Fitting Qwen3.5-4B under that exact provenance, over 30 comparable layers (the identity target layer excluded, since including it inflates every score):
| theirs J | theirs R | |
|---|---|---|
| ours J | 0.9867 | 0.8790 |
| ours R | 0.8798 | 0.9960 |
Clean diagonal dominance: our R matches their R far better than their J, and
vice versa. The sharpest single number is the rule-delta test — cosine
between our R − J and their R − J, which cancels the shared J baseline and
isolates the backward-pass rules from the corpus: median 0.9949, mean
0.9804.
Per-layer relative error falls monotonically with depth — J from 0.43 at layer 0 to 0.012 at layer 29, R from 0.204 to 0.009 — with no layer flagged as an outlier (R relerr never exceeds 4× the median) and Frobenius-norm ratios inside 0.7% from layer 3 on.
Is the early-layer residual just corpus sampling noise? Testable for free:
the five shards were each fitted on five disjoint prompts, so sampling noise
predicts shard-vs-shard (n=5) exceeds ours-vs-theirs (n=25) by exactly
√5 = 2.24×. Measured: 6.5×. Our lens agrees with theirs about 3×
better than independent corpus draws would allow, so the same documents are
substantially being drawn; the remaining gap points at document selection or
chunking rather than the estimator.
Matrix agreement shows the weights match. This shows the whole chain matches:
| category | our J | their J | our R | their R | our Δ | their Δ |
|---|---|---|---|---|---|---|
| typo | 0.760 | 0.792 | 0.812 | 0.823 | +0.052 | +0.031 |
| order-ops | 0.627 | 0.655 | 0.682 | 0.673 | +0.055 | +0.018 |
| multilingual | 0.495 | 0.486 | 0.509 | 0.507 | +0.014 | +0.021 |
| multihop | 0.455 | 0.461 | 0.509 | 0.520 | +0.054 | +0.059 |
| association | 0.049 | 0.059 | 0.069 | 0.059 | +0.020 | +0.000 |
| poetry | 0.020 | 0.010 | 0.020 | 0.020 | +0.000 | +0.010 |
| mean | +0.033 | +0.023 | ||||
| mean, early layers | +0.051 | +0.047 |
Every absolute value agrees within 0.01–0.03 and the early-layer means agree to 0.004 — two separately fitted lens pairs converging on the same behaviour, not merely the same matrices.
evaluate_released needs only inference, so the post's central quantitative
claim — that gains grow with model scale — is testable without fitting
anything. Mean pass@10 delta (R minus J) over the six categories, from the
released lenses:
| model | all layers | early layers | early/all |
|---|---|---|---|
| Qwen3.5-4B | +0.023 | +0.047 | 2.0× |
| Qwen3.5-9B | +0.019 | +0.070 | 3.7× |
| Qwen3.5-27B | +0.038 | +0.090 | 2.4× |
Early-layer gains grow monotonically with scale, roughly doubling from 4B to 27B. That is the post's claim, reproduced independently. The all-layer metric does not show it — it dips at 9B — so the choice of window is what makes the effect visible. Reporting only the aggregate would understate R-lens at scale.
Lenses fitted in this repo, for comparison (--rules-preset all, paper recipe
for the 0.6B):
| model | all layers | early layers |
|---|---|---|
| Qwen3-0.6B | +0.039 | +0.049 |
| Qwen3.5-4B | +0.033 | +0.051 |
At 27B the single largest effect is typo on early layers, +0.333 (0.083 → 0.417), followed by multihop +0.104 (0.014 → 0.118). No category is negative at 27B in either window.
Both survive a k-sweep, so neither is a metric artefact:
pass@10 saturates, and it hides large effects. Where the model is far
from the answer, a top-10 cutoff registers a big improvement as +0.000. On
Qwen3-0.6B, poetry's target sits at median rank 1778 under J-lens and 319
under R-lens — R-lens better on 95 of 98 items — yet pass@10 reports
0.000 vs 0.010. Sweeping k:
| category | d@10 | d@100 | d@500 | d@1000 |
|---|---|---|---|---|
| poetry | +0.010 | +0.184 | +0.378 | +0.449 |
| association | +0.000 | +0.049 | +0.118 | +0.098 |
scripts/compare.py prints a k-sweep by default and warns when the headline k
is saturated. Do not read a single k.
The logit-lens floor. The floor any fitted lens must clear is doing no
transport at all (use_jacobian=False). On Qwen3-0.6B under the paper recipe,
R-lens clears it on every category at every k, with the single exception of
poetry at k=10 — which is the saturation artefact above:
| category | @10 | @100 | @500 | @1000 |
|---|---|---|---|---|
| typo | +0.365 | +0.146 | +0.031 | +0.021 |
| order-ops | +0.182 | +0.045 | +0.082 | +0.109 |
| multihop | +0.106 | +0.118 | +0.075 | +0.032 |
| multilingual | +0.051 | +0.089 | +0.180 | +0.175 |
| association | +0.000 | +0.049 | +0.118 | +0.147 |
| poetry | −0.020 | +0.031 | +0.224 | +0.235 |
Fitting corpus matters here: with WikiText instead of pile-10k, the J-lens sat below the floor on order-ops (−0.064), which inflated the apparent R-lens gain. Under the paper's corpus it clears (+0.018).
R-lens is a lower-variance estimator at early layers. Per-shard ||J_l||
over five disjoint 5-prompt shards (Qwen3.5-4B), coefficient of variation:
| layer | J-lens CV | R-lens CV |
|---|---|---|
| 0 | 0.244 | 0.104 |
| 1 | 0.238 | 0.113 |
| 2 | 0.176 | 0.115 |
| 3 | 0.118 | 0.079 |
| 10 | 0.046 | 0.046 |
R-lens more than halves shard-to-shard variance at early layers and converges to identical variance by layer 10. This is a candidate mechanism for why the rules help exactly where they do — a lower-variance estimator, not only less error accumulation — and it is measurable from artifacts already on disk.
Fitting cost is 2 · N_active · d_model · seq_len FLOPs per prompt — one
backward pass per residual dimension, so d_model is a linear multiplier and
dominates the choice of target model.
| Model | FLOPs/prompt | 25 prompts | Accumulator (fp32) |
|---|---|---|---|
| Qwen3-0.6B | ~1.6e14 | minutes on an L4 | 0.12 GB |
| Qwen3.5-4B | ~2.6e15 | ~11 min on an H100, 5 shards | 0.8 GB |
| Qwen3.5-27B | ~3.5e16 | ~13× the 4B | 6.7 GB |
Budget 2× for a matched pair. The accumulator is
len(source_layers) · d_model² · 4 bytes and stays resident; trimming
source_layers saves memory and disk but not compute, since the backward
pass reaches layer 0 either way. Fitting is sharded over disjoint prompt slices
and merged with JacobianLens.merge, so wall-clock scales with shard count.
dim_batch replicates the prompt along the batch axis and does not change
total FLOPs, but it does drive peak memory: Qwen3.5-4B at dim_batch=32 OOMs a
48 GB L40S, because the retained graph spans 31 blocks and intermediate_size
is 9216. dim_batch=8 on an H100 is comfortable.
All eight models ship matched j-lens/r-lens pairs. A lens file is
n_layers · d_model² · 2 bytes, so its size pins L·d²:
| slug | model | L | d_model | MoE | GB/lens | fit cost vs 4B |
|---|---|---|---|---|---|---|
qwen3.6-35b-a3b | Qwen3.6-35B-A3B | 40 | 2048 | 3B active | 0.33 | 0.6× |
qwen3.5-4b | Qwen3.5-4B | 32 | 2560 | — | 0.41 | 1× |
qwen3.5-122b-a10b | Qwen3.5-122B-A10B | 48 | 3072 | 10B active | 0.89 | 3× |
qwen3.5-9b | Qwen3.5-9B | 32 | 4096 | — | 1.04 | 3.6× |
deepseek-v4-flash | DeepSeek-V4-Flash | 43 | 4096 | 13B active | 1.41 | 5.2× |
qwen3.5-27b | Qwen3.5-27B | 64 | 5120 | — | 3.30 | 13.5× |
qwen3.6-27b | Qwen3.6-27B | 64 | 5120 | — | 3.30 | 13.5× |
gemma-3-27b-it | gemma-3-27b-it | ~61 | 5376 | — | 3.53 | 14× |
qwen3.5-27b and qwen3.6-27b are dimensionally identical — two model
generations at matched size, both with released pairs.
Each rule toggles independently, tests/test_rules.py asserts each one moves
the backward pass on its own, and --rules-preset fits any subset:
uv run modal run modal_app.py::fit --variant r-lens --rules-preset ln
uv run modal run modal_app.py::fit --variant r-lens --rules-preset identity
uv run modal run modal_app.py::fit --variant r-lens --rules-preset half
uv run modal run modal_app.py::evaluate --variant j-lens,r-lens-ln
--ln-scope switches the LN-rule between residual (the default: only norms
at d_model, so Qwen3's per-head q_norm/k_norm are left alone) and all
(every norm, as AttnLRP does). On Qwen3-0.6B that is 57 norms versus 113.
RuleConfig also declares attn_half_rule and router_identity_rule, which
AttnLRP would apply to the attention softmax and the MoE router. These are
not implemented; doing so needs an eager attention path to patch, since
SDPA/flash fuse the tensors away. lrp_rules raises NotImplementedError
rather than ignoring them, because a silently-inert flag would make an ablation
return the baseline's numbers and read as a null result.
Compare lenses where they already live. modal_app.py::released_compare
runs the 2×2 against the released lenses inside Modal, reading ours from the
volume and theirs from an HF cache hop. scripts/compare_to_released.py does
the same locally and is kept for offline analysis, but the Modal path is the
default for a reason:
torch.load accepts a partially-written .pt as long as the zip central
directory is intact, and the tensors it returns look entirely plausible.
modal volume get returns before all bytes have landed, and a byte-size check
is necessary but not sufficient. A partially-downloaded 786 MB lens produced
a confident, reproducible, and completely spurious 20% norm deficit at a single
layer — reproducible because the corruption was baked into the local file, so
re-reading it agreed with itself. Moving the comparison onto Modal removes the
failure mode instead of trying to detect it. verify_merge provides the
independent check: a merged lens must equal a fresh mean of its own shards at
every layer.
Modal volumes are not coherent across container reuse. fit_shard commits
each shard, but a merge_shards container reused from an earlier merge holds
the volume state it saw at mount time and fails with FileNotFoundError on
shards that modal volume ls lists. merge_shards calls outputs.reload()
first; merge_existing merges shards already on the volume so a merge failure
never costs a refit.
ORDER_OPS_SYNONYMS) is reconstructed from the
eval README's description, since the upstream table is not published. It is
applied identically to every lens under comparison, so it cannot manufacture
a J-vs-R delta, but absolute order-ops numbers are not comparable to the
post's.pass@k implementation reproduces no published number directly. Its
external check is that the released lenses show the paper's early-layer
claim under it.gated_norms counterpart, and anything above 27B.MIT — see LICENSE. The vendored evaluation sets in
data/evaluations/ are Apache-2.0 and remain under that license; see
NOTICE and data/evaluations/LICENSE.
Hacker News (1)
1 commits
Python
100.0%