entroptics-jlens
The claim
A Jacobian lens carries a large identity component at depth, and a spectral floor derived from the matrix it is judging is inflated by it. Removing the identity first changes the resolved count by 2x to 21x, of which 1.15x to 1.87x is attributable to the transport rather than to the floor.
Eleven lens files, ten of the twenty-two on the Neuronpedia mirror, at each model's deepest
fitted layer, mp null at far=0.05:
| model | width | identity share | K(J) | K(J−αI) | change |
|---|---|---|---|---|---|
| qwen3-1.7b | 2048 | 0.798 | 3 | 64 | 21.3× |
| qwen3-4b | 2560 | 0.876 | 7 | 149 | 21.3× |
| gemma-3-4b | 2560 | 0.703 | 16 | 165 | 10.3× |
| qwen3.5-4b | 2560 | 0.790 | 25 | 183 | 7.3× |
| gpt2 | 768 | 0.422 | 6 | 39 | 6.5× |
| qwen3.5-0.8b | 1024 | 0.778 | 12 | 68 | 5.7× |
| gemma-3-1b | 1152 | 0.660 | 17 | 64 | 3.8× |
| llama3.1-8b | 4096 | 0.720 | 74 | 269 | 3.6× |
| pythia-70m | 512 | 0.458 | 2 | 4 | 2.0× |
| gemma-3-270m | 640 | 0.107 | 39 | 42 | 1.1× |
9 of 10 models. Five families. Widths 512 to 4096. Reproduce it with
exp51_the_claim.py.
Read this table before quoting that one. A derived floor rises with any added identity,
whatever is underneath it, so most of the raw range is the estimator's response to it. Two
structure-free surrogates measure the part that belongs to the transport, differing in one
invariant -- whether the floor's own variance input (a median row energy) is held fixed
(research/experiments/exp55_structure_free_control.py, five draws each, median reported):
| model | change | spectrum surrogate | excess | rotation surrogate | excess |
|---|---|---|---|---|---|
| gemma-3-270m | 1.1x | 1.03x | 1.04x | 1.08x | 1.00x |
| gpt2 | 6.5x | 2.00x | 3.25x | 5.57x | 1.17x |
| pythia-70m | 2.0x | 2.33x | 0.86x | 2.00x | 1.00x |
| gemma-3-1b | 3.8x | 2.69x | 1.40x | 4.00x | 0.94x |
| gemma-3-4b | 10.3x | 4.31x | 2.39x | 9.17x | 1.12x |
| llama3.1-8b | 3.6x | 1.97x | 1.85x | 2.10x | 1.73x |
| qwen3.5-0.8b | 5.7x | 3.00x | 1.89x | 4.00x | 1.42x |
| qwen3.5-4b | 7.3x | 4.06x | 1.80x | 4.82x | 1.52x |
| qwen3-1.7b | 21.3x | 3.67x | 5.82x | 21.33x | 1.00x |
| qwen3-4b | 21.3x | 7.88x | 2.70x | 16.56x | 1.29x |
| median | 1.87x | 1.15x |
spectrum holds the singular values and alpha and redraws both subspaces, so the median row energy
moves and the floor moves with it. rotation holds the singular values, alpha and every row
norm -- verified to 2e-16, so the floor's variance estimate is identical by construction -- while
destroying the alignment between the transport's input and output directions.
The excess attributable to the transport lies between 1.15x and 1.87x. The bracket's width is how far a surrogate is allowed to move the statistic the floor is made of.
There is an eleventh lens file, a second independent fit of qwen3.5-4b over 417 sequences rather than 1000. It gives 7.28× against the other's 7.32×, so the measurement replicates across two fits of one model as well as across ten models.
Why it holds
A transformer's residual stream adds each layer's output to what came before, so a map from layer l to the final layer contains a copy of the identity by construction — and it grows with depth, because less transformation is left above it. At the deepest layer of every model here above 1B parameters, 66% to 88% of the transport is that pass-through.
The identity flattens the spectrum. The noise floor is estimated from that spectrum. So the floor rises with the identity and buries the real modes underneath it. Read qwen3-1.7b's deepest layer as it stands and you conclude it resolves 3 directions. It resolves 64.
Why it is not a coincidence across models
The size of the change is predicted by the identity share: Spearman +0.773 across the eleven lens files, and +0.745 across the ten models. The one lens where the count barely moves — gemma-3-270m at 1.1× — is the one with almost no identity to remove, at 0.107. A structure-free surrogate reproduces that same association (§1.2 of the paper), which places it with the floor.
What this is about
Measured here: the published lens files carry the identity — it is in the matrices, at the shares tabulated above, because a faithful Jacobian of a residual stream contains it — and a spectral read whose threshold is derived from the matrix it is judging resolves a different count depending on whether it is removed first.
The lens files are not at issue: they are a correct measurement of the map they claim to measure, and nothing here inspects anyone else's code, and this package cannot tell you what a given paper did downstream of the checkpoint. The claim is about what is in the files and what happens if you read them directly, which is what the tooling around them does by default.
Also not claimed: that subtracting αI is a new idea. It is the obvious step once you notice
the identity is there, and tr(J)/d is the first thing anyone would write down. The contribution
is the measurement — that it is 66–88% of the matrix at depth, that the resolved count moves by a
factor of 2 to 21, how much of that survives a structure-free surrogate, and that it settles below
relative depth 0.6. Those numbers did not exist before this, and without them there is no reason to bother.
Why the larger number is the right one
The obvious objection: you removed something and got a bigger number — why is bigger correct?
Because on a matrix of known rank, the read on J-alpha*I does not move when you add identity, and
the raw read collapses. Plant rank 20 in a 256-wide matrix, then add α·I for growing α:
| α=0 | α=0.5 | α=1.5 | α=3.0 | α=6.0 | |
|---|---|---|---|---|---|
| identity share | 0.000 | 0.032 | 0.230 | 0.543 | 0.826 |
| K(J) | 20 | 20 | 20 | 17 | 5 |
| K(J−αI) | 20 | 20 | 20 | 20 | 20 |
The planted rank is 20 throughout. The raw read reports 5 of it once the identity dominates; the read on J-alpha*I reports 20 at every identity strength.
Over a sweep of planted ranks 5–40 against five identity strengths, 20 cases: the read on J-alpha*I recovers the planted rank exactly in 15 of 20, mean absolute error 2.50; the raw read recovers 11 of 20 at mean error 5.70, and every one of its misses is an understatement that grows with the identity share.
The read on M has five misses, all at planted rank 40, where it saturates at 30 — that is the
mp floor's own documented tendency to under-count signal-dense matrices, present with or without
an identity. It is flat across α: 30 at every strength, while the read on J falls 30 → 5.
So removing αI does not make the number bigger. It makes the number invariant to a component the architecture guarantees is present.
Does it apply to the layer you are reading?
The table above is each model's deepest layer. Across every layer of the five lenses narrow
enough to sweep whole — 81 layers — bucketed by relative depth
(research/experiments/exp54_sweep_and_depth.py):
| relative depth | median identity | median change | layers over 1.5× |
|---|---|---|---|
| 0.0 – 0.2 | 0.040 | 0.9× | 0 / 17 |
| 0.2 – 0.4 | 0.017 | 1.0× | 0 / 15 |
| 0.4 – 0.6 | 0.080 | 1.0× | 0 / 16 |
| 0.6 – 0.8 | 0.281 | 1.2× | 5 / 15 |
| 0.8 – 1.0 | 0.474 | 3.6× | 14 / 18 |
Below relative depth 0.6 the two reads agree. Read the raw transport there and you get the same answer. Above 0.8 the change is large in 14 of 18 layers measured.
What tracks the change is the identity share, not the depth — and stated without a cut anywhere, over those 81 layers the share and the change rank together at Spearman ρ = +0.530 (p = 3.5e-07).
The share orders the layers; no value of it separates them. Split the 81 layers at any change cut and the two share ranges overlap, and where they overlap is a property of the cut:
| change cut | layers above | share above | share below | overlap |
|---|---|---|---|---|
| 1.1× | 26 | 0.095–0.778 | 0.003–0.281 | 0.095–0.281 |
| 1.5× | 19 | 0.348–0.778 | 0.003–0.433 | 0.348–0.433 |
| 2.0× | 12 | 0.422–0.778 | 0.003–0.478 | 0.422–0.478 |
| 3.0× | 10 | 0.422–0.778 | 0.003–0.555 | 0.422–0.555 |
The lower edge moves from 0.095 to 0.422 across that sweep, so a share quoted as a dividing line
reports the cut behind it. None is built in: audit and screen print the share itself, and you
read your layer off it rather than guessing from its index.
The deep layers are where the change matters, and they are also where claims about a model's workspace and capacity are made.
It does not depend on the free parameters
K is read against a noise floor at a false-alarm rate you choose. If the effect only existed at
the value in the table it would be an artefact of that choice, so it was swept across 5.7 orders
of magnitude:
| far | models over 1.5× | range |
|---|---|---|
| 0.5 | 9/10 | 1.1× – 21.7× |
| 0.05 | 9/10 | 1.1× – 21.3× |
| 0.005 | 9/10 | 1.1× – 21.1× |
| 0.0005 | 9/10 | 1.1× – 21.0× |
| 1e-06 | 9/10 | 1.1× – 20.9× |
Nine of ten at every setting, and the per-model factors barely move — qwen3-1.7b runs 21.7× to 20.3×, gpt2 stays at 6.5×, and gemma-3-270m sits at 1.1× throughout. The exception is as stable as the rule.
The floor's other input is the variance estimate, which mp takes as a de-biased median row
energy — also a choice, so also swept. Reading the row energies at the p-th quantile instead, with
the matching χ²_F de-bias so every p estimates the same variance:
| σ̂ estimator | models over 1.5× | factor range | Spearman |
|---|---|---|---|
| p = 0.10 | 10/10 | 1.66× – 40.33× | +0.564 |
| p = 0.25 | 9/10 | 1.25× – 33.67× | +0.655 |
| p = 0.50 (published) | 9/10 | 1.08× – 21.33× | +0.773 |
| p = 0.75 | 9/10 | 1.00× – 16.57× | +0.791 |
| p = 0.90 | 9/10 | 1.04× – 9.43× | +0.615 |
| arithmetic mean | 8/10 | 1.00× – 6.86× | +0.788 |
Nine of ten at every quantile. The absolute count does move with the estimator — it is the one
input K is genuinely sensitive to — but both readings move together, because αI adds α² to
every row's energy and shifts the whole distribution rather than a tail of it.
The published median is also the conservative member: at p = 0.10 every one of the eleven files reads a larger factor, and 10 of 10 models clear 1.5×. The arithmetic mean is the non-robust end and is degenerate rather than competing — dominated by the signal it is meant to be a null for, it resolves 3 of 768 directions at gpt2 layer 10 on both sides.
The fix
Exact, one line, nothing fitted:
M = je.decompose(J).residual # alpha = tr(J)/d, then J - alpha*I
alpha = tr(J)/d is the orthogonal projection of J onto the span of the identity — the unique
least-squares coefficient against a basis element the architecture guarantees is present. No
threshold, no null, no training. That is why this replicates: there is nothing in it to fail.
In your existing loop, that is one inserted line and nothing else changes:
for l in layers:
J = lens.jacobian(l)
M = je.decompose(J).residual # <- the line
rank = your_effective_rank(M) # everything downstream unchanged
Measured on Qwen3.5-4B, the number that loop produces before and after:
| layer | before | after | off by |
|---|---|---|---|
| 0 | 94 | 93 | 1.0× |
| 12 | 203 | 178 | 0.9× |
| 24 | 49 | 131 | 2.7× |
| 30 | 25 | 183 | 7.3× |
Apply it unconditionally. It is exact at every layer, costs O(d²) — milliseconds at d=2560,
against seconds for the spectrum that follows — and is never wrong to have done. There is no
decision to make and no read to take first.
screen is a separate convenience, and not a prerequisite. It answers "can I skip the expensive
spectrum on this layer entirely", which is a question about your compute budget rather than about
correctness:
entroptics-jlens screen lens.pt
layer alpha identity needs the full read?
0 0.181 0.008 no
19 0.632 0.324 no
29 1.063 0.742 yes
30 0.994 0.790 yes
So: remove the identity always; screen only when you want to avoid paying for spectra you do not need.
Which half is which
decompose is not an entroptics read. It is two lines of numpy —
alpha = float(np.trace(A) / d) # <J, I> / <I, I>
R = A - alpha * np.eye(d)
— and it carries the domain knowledge: a residual stream adds, so the transport contains an
identity. What entroptics supplies is the measurement, noise_floor under the mp provider, which
is the only thing here that turns a matrix into a number.
That matters for reading the claim. The mp floor estimates its variance from the matrix it is
judging, so the identity raises the floor by the same amount it flattens the spectrum. This is
therefore not a lens fact that happens to use entroptics — it is a correction to an entroptics
read, supplied by the domain, and it applies wherever a floor derives its own scale.
pip install "entroptics-jlens[lens]"
entroptics-jlens fetch gpt2 # 13 MB, transports only
entroptics-jlens audit lenses/gpt2-small/jlens/Salesforce-wikitext/gpt2_jacobian_lens.pt
Under eight seconds on gpt2, no GPU, no corpus, no labels, no forward pass — and it prints both columns so the gap is visible rather than inferred.
What it actually measures
Four reads. Three of them need only the checkpoint.
| read | question it answers | needs | cost |
|---|---|---|---|
screen |
which layers even need the expensive read? | the file | O(d²), no SVD |
audit |
at this layer, how much of the map is the architecture rather than the model? how many directions does it resolve above its own noise floor? | the file | O(d³) per layer |
compare |
how far into the spectrum do these two readouts agree? | two files | O(d³) per layer |
coverage |
how much of a real residual stream does this readout reach, against what a random readout of the same size would reach? | the files, plus streams you collect | O(d³) per layer |
screen — do not pay for a spectrum you do not need
The identity share is tr(J)/d and one Frobenius norm. No SVD, O(d²) against a spectrum's
O(d³) — at d=2560 on one laptop, 11.9 ms against 1.96 s, medians over repeated runs — and it
is the quantity that predicts whether removing the identity changes the count, rather than a
proxy for it. The exponents are what carry that, not the wall-clock.
entroptics-jlens screen qwen3.5-4b.pt
layer alpha identity
30 0.994 0.790
24 1.037 0.583
20 0.724 0.390
12 0.280 0.070
Ordered by share, no verdict. Pass --threshold to add one and own the number:
entroptics-jlens screen qwen3.5-4b.pt --threshold 0.3
layer alpha identity needs the full read?
30 0.994 0.790 yes
24 1.037 0.583 yes
20 0.724 0.390 yes
12 0.280 0.070 no
On Qwen3.5-4B's 31 layers: auditing everything is 82 s; screening (1.0 s) then auditing the 10 that flagged is 27 s. The layers it flags are exactly the ones a full audit calls identity-dominated — asserted end to end in tests/test_screen.py, so the cheap route reaches the same layers as the expensive one.
audit — what a transport is, and what it is not
Here is the real output on the published gpt2 lens — 13 MB, and 7.6 s wall including interpreter start:
d_model 768 fitted layers 11 n_prompts 277
reading 11 layer(s) at far=0.05
layer alpha identity PR(J) PR(J-aI) K(J) K(J-aI) reads as
0 0.263 0.097 41.6 36.3 71/86 67/104 transport
1 0.375 0.135 71.8 60.5 69/56 64/80 transport
2 0.495 0.185 83.0 63.9 63/26 59/51 transport
3 0.575 0.220 75.1 51.7 55/16 55/36 transport
4 0.679 0.254 57.8 35.0 46/16 52/32 transport
5 0.801 0.278 41.6 22.9 41/19 51/33 transport
6 0.936 0.348 39.5 17.3 31/14 48/26 transport
7 1.024 0.405 32.7 11.6 24/11 48/22 transport
8 1.108 0.471 24.0 6.6 12/5 53/18 transport
9 1.138 0.519 15.3 3.4 6/6 46/13 identity-dominated
10 1.112 0.422 4.7 1.5 6/7 39/15 transport
Reading across a row:
alphaistr(J)/d, how much of the transport is a plain pass-through. Watch it climb from 0.26 to 1.14 with depth.identityis the share of the matrix's total energy that pass-through accounts for. At gpt2 layer 9, more than half.PR(J)is the effective rank of the transport as it stands — how many directions it meaningfully uses, with no threshold anywhere in it.PR(J-aI)is the same read after the pass-through is removed.K(J)/K(J-aI)are how many directions stand above the matrix's own noise floor, before and after that removal, each shown asmp/fence.
mp is the calibrated null: it answers how many modes would noise alone have produced, at the false-alarm rate you set with --far. fence is the Tukey outlier fence Q3 + 1.5·IQR of the singular spectrum — a useful second view of where the spectrum breaks, but not a null. It carries no false-alarm rate, --far does not move it at all (measured: sweeping far from 0.5 to 1e-6 leaves it at 2.40679 to five decimals while mp moves), and because a few dominant directions raise Q3 and the IQR together, it reads fewer modes the more structured the spectrum is — 104 against mp's 67 at gpt2 layer 0, but 13 against 46 by layer 9. Read the gap as a description of the spectrum's shape, never as an interval on K.
Now look down the K columns. K(J) falls from 71 to 6 — the transport appears to resolve less and less as you go deeper. K(J-aI) does not: 67, then 46, then 39. Nearly all of that collapse is the identity, and the mechanism is mechanical rather than mysterious. The identity flattens the spectrum; the default floor estimates its variance from that spectrum; so the floor rises with the identity and buries the real modes beneath it. Same matrix, same null, same false-alarm rate.
On Qwen3.5-4B the same effect is larger. At layer 30 the transport resolves 25 directions read on J and 183 read on J-aI. On the identity-cored test fixture it is total: rank 8 planted, 0 resolved on J, exactly 8 on J-aI.
The identity, layer by layer
The claim at the top reads one layer per model. Here is the whole depth profile for one of them,
Qwen3.5-4B, --layers 0,6,12,18,24,26,28,30:
| L0 | L6 | L12 | L18 | L24 | L26 | L28 | L30 | |
|---|---|---|---|---|---|---|---|---|
| identity | 0.008 | 0.026 | 0.070 | 0.236 | 0.583 | 0.629 | 0.706 | 0.790 |
| PR(J) | 12.4 | 69.3 | 88.7 | 240.3 | 987.0 | 1115.2 | 1249.1 | 1328.5 |
| PR(J-aI) | 12.3 | 67.8 | 80.3 | 163.2 | 326.1 | 345.0 | 262.6 | 124.4 |
alpha = tr(J)/d is the unique least-squares coefficient against a basis element the architecture guarantees is present. Nothing is tuned and nothing is trained.
If you take one thing from this package: when you read the spectrum of a Jacobian-style transport, subtract the identity first, or you are describing the residual stream rather than the model.
compare — did this readout drift?
Two independent fits of one model should be the same map. A checkpoint that has moved, or a fit that has degenerated, should not be.
entroptics-jlens compare fit_a.pt fit_b.pt --k 400
There are two published fits of Qwen3.5-4B, one over 417 sequences and one over 1000, so the answer is measurable rather than assumed. Over their top 400 directions:
| layer | agree_to | cos_mean | cos_min |
|---|---|---|---|
| 0 | 268 | 0.8587 | 0.0004 |
| 12 | 395 | 0.9909 | 0.5966 |
| 26 | 397 | 0.9960 | 0.7592 |
| 30 | 399 | 0.9985 | 0.8844 |
cos_min is the trap. Two genuine fits of one model disagree completely on their 400th direction — it sits in the spectral tail where the fit is noise — so a rule reading the minimum would have called two fits of one model different maps at every layer. Read agree_to instead: the two agree on 268 of their leading 400 directions at layer 0 and on 399 of them at layer 30. That is the question principal angles exist to answer, and no scalar summary gives it.
compare prints the cosine and the scale it is read against, and stops there: these fits of one model span 0.8587 to 0.9985 across depth, and two unrelated maps of the same construction read 0.1657 and 0.1908. Nothing has been measured between 0.19 and 0.86, so a named band over that interval would be a category with no observation in it.
This read sees the map only. It says whether two readouts are the same object. It cannot say which of them is better — for that you need the model.
coverage — how much of a real stream does it reach?
The other two reads never touch the model. This one does, because the question is about the model's actual activations.
Given a residual stream and the same stream after the readout has acted on it, coverage is the overlap of the two resolved subspaces, ||V_s^T V_t||_F^2 / k_s. What makes it a measurement rather than a number is that it has an analytic chance level: a randomly-oriented k_t-dimensional subspace covers k_t/d of anything at all. So the answer has the shape "this readout reaches 22% of the stream's structure, where chance is 0.9%".
The table prints a random map of the same rank on the same frame beside the real one, every time, because that control is what a read has to beat to mean anything. Collect the streams yourself:
import numpy as np, torch, transformers
model = transformers.AutoModelForCausalLM.from_pretrained("gpt2").eval().float()
tok = transformers.AutoTokenizer.from_pretrained("gpt2")
# At least d_model tokens. A (T, d) frame has rank at most T, so a short prompt caps what
# can be resolved at its own length -- see the note below.
ids = tok(your_long_text, return_tensors="pt").input_ids[:, :1024]
with torch.no_grad():
out = model(ids, output_hidden_states=True)
np.savez("streams.npz", a=np.stack([h[0].numpy() for h in out.hidden_states]))
entroptics-jlens coverage lens.pt --streams streams.npz --layers 0,5,10
layer k_sig k_read coverage chance x chance random map x chance
0 13 22 0.1606 0.0286 5.6 0.0286 1.0
5 20 12 0.1757 0.0156 11.2 0.0157 1.0
10 15 7 0.2185 0.0091 24.0 0.0095 1.0
The real transport reaches 16-22% of the stream's resolved structure, at 6 to 24 times chance. The random map of the same rank sits at 1.0x chance at every layer — which is what tells you the first column is measuring the transport rather than the frame.
Give it enough tokens. k_sig is the number of directions the stream itself resolves, and a (T, d) frame has rank at most T. Feed it a three-token prompt and k_sig is 1, and the tool will happily report a one-dimensional overlap at 679 times chance — a spectacular-looking number that is a fact about your prompt. coverage says so out loud whenever a stream is shorter than d, but it is easier not to do it.
Layer l's transport pairs with hidden state l+1, and the tool checks that rather than assuming it — reading s[l] instead produces a curve that looks entirely plausible and is off by one.
How it works
Everything here is linear algebra on the readout's own matrix, plus a null to score it against.
The rank a matrix resolves. Take the singular values. A matrix of pure noise has a predictable spectrum, and its largest singular value has a known distribution (Tracy-Widom) — so there is a level above which a singular value is not something noise would produce. Count what stands above it and you have a resolved rank with a stated false-alarm rate, no threshold chosen by anyone. That floor comes from entroptics, which this package is a domain wrapper over.
Two floors, because one is a lower bound. The default estimator reads the per-cell variance off the whole matrix, signal included, so a matrix carrying a lot of structure lifts its own floor and buries its weakest modes. Measured on planted ranks: 30 modes planted, 19 recovered by the default and 29 by the robust estimator. Both columns are printed; the gap between them is diagnostic.
Effective rank without any floor at all. Participation ratio, (sum s^2)^2 / sum s^4, equals r exactly for a spectrum with r equal non-zero values. It needs no null and carries no false-alarm rate, which is what makes it usable on a corpus-averaged transport where there is no noise bulk left for a floor to find an edge of — and what stops it answering "how many of these directions are real". Read it next to K, never instead of it. A pure-noise matrix has the flattest spectrum there is and therefore the highest participation ratio in the file; audit reports its peak only over layers that resolve something, for exactly that reason.
Subspace overlap with a chance level. The sum of squared canonical correlations between two subspaces, normalised by the smaller dimension. 1 when one contains the other, 0 when orthogonal, k_t/d when the second one is randomly oriented.
What a control has to preserve
The mp floor estimates its per-cell variance from the matrix it is judging. Anything that concentrates a matrix's energy therefore raises the bar that same matrix has to clear, which makes the choice of control part of the measurement.
Take gpt2's transports against their own entry-shuffle, which permutes every cell:
| layer | K(J) | K(shuffled J) | K(J−αI) | K(shuffled J−αI) | |
|---|---|---|---|---|---|
| 7 | 24 | 25 | 48 | 13 | |
| 8 | 12 | 28 | 53 | 15 | |
| 9 | 6 | 34 | 46 | 21 | |
| 10 | 6 | 43 | 39 | 44–51 |
Shuffles are at seed 0; the layer-10 range is over 8 seeds, drawn because that is the row the argument turns on.
The entry shuffle moves the diagonal off the diagonal, so the identity is present on the real side and absent on the control side, and the two are being read against different floors. Comparing J−αI against its shuffle puts both sides on the same footing, and at layers 7–9 the real transport resolves more than its control, robustly across seeds.
Layer 10 is the same mechanism in another form: PR(J−αI) is 1.5 there, so the transport is nearly rank-one and a single dominant direction concentrates energy exactly as an identity does. The row is reported because the mechanism predicts it.
The rule that follows: a control must preserve what the architecture guarantees. Read a raw transport against a null that keeps its identity, or remove the identity from both sides first.
Which null suits which matrix
nulls.py carries surrogates that keep more of the matrix. sign_flip preserves |J| entrywise, and so every row and column energy exactly, varying only sign coherence. On a clean planted rank-6 it recovers all 6, calibrated at an exceedance of 0.045 against a nominal 0.05.
The regime that decides between them is how the energy is spread. Set one cell of that same matrix to 400, against an rms of 1.12:
| surrogate | K, clean | K, with one outlier cell |
|---|---|---|
sign_flip |
6 | 1 |
within_col_shuffle |
6 | 1 |
within_row_shuffle |
6 | 0 |
mp (analytic) |
6 | 7 |
Planted rank is 6. Preserving the magnitude profile exactly carries the dominant cell into every surrogate draw, and one such cell holds a large top singular value by itself, which places the sampled floor above the planted structure; the analytic mp floor reads 7 there. Real transports sit in that second column — removing the identity leaves the massive activations, and on gpt2's J−αI the largest cell runs from 70× the rms at layer 0 to 599× at layer 10, at (447,138) throughout. sign_flip reads K = 2, 1, 1, 1 across layers 0/5/9/10 where mp reads 67, 51, 46, 39.
The two are informative about different regimes, and each is exact about its own. mp sets its variance from a flattened bulk, so on a concentrated spectrum it is a lower bound on the count. The sampled surrogates hold the magnitude profile fixed, so they report the largest entries: a sampled-floor K of 0 or 1 on a transport is a statement about its dominant cell.
So: use the sampled surrogates where energy is spread across cells, and on a real transport decompose first, read mp as a lower bound, and read PR next to K so concentration is visible directly rather than inferred from the count.
Other things it will do
Neither of these is the claim above. Both are real capabilities with measured limits, and the limits are stated because they decide whether the capability is worth using.
Pointing it at ordinary weights: what did quantisation change?
Nothing above is specific to a lens. compare works on any two matrices of the same shape, so
point it at a model's own weights before and after a change.
gpt2's attention output projections, original against symmetric per-tensor quantisation, showing how much of each layer's map survived:
| layer | int8 | int4 | int3 |
|---|---|---|---|
| 0 | 0.9991 | 0.8669 | 0.7681 |
| 3 | 0.9981 | 0.6674 | 0.3485 |
| 6 | 0.9993 | 0.8506 | 0.4292 |
| 9 | 0.9985 | 0.7502 | 0.4065 |
| 11 | 0.9621 | 0.3893 | 0.3341 |
int8 is clean everywhere; at int4 the damage is uneven — 0.87 at layer 0 against 0.39 at layer 11 — and layer 11's effective rank collapses from 27.2 to 4.2, from 27 usable directions down to 4.
It does not tell you which layer to protect. Quantising one layer at a time and measuring what it costs the model gives Spearman +0.070 against these scores — no relationship. Layer 0 has the highest agreement and the highest cost (+0.368 loss); layer 1 has the lowest agreement and costs almost nothing (+0.007). The score says which weights moved, not which weights mattered.
So this is an exact description of what changed and a poor predictor of what it will cost. Use it to answer "did this transformation preserve the map, and where did it not", not to allocate bits.
import entroptics_jlens as je
before = model.transformer.h[11].attn.c_proj.weight.detach().numpy()
after = quantized.transformer.h[11].attn.c_proj.weight.detach().numpy()
je.principal_angles(before, after, k=64).mean() # 0.3893
je.participation_ratio(je.energy_spectrum(after)) # 4.2, from 27.2
The same comparison answers did my fine-tune change anything structural, and where? and did distillation preserve the map? — a weight-norm delta says "the weights moved 0.3%", which is not an answer to either.
What this is not: a replacement for your eval run. These numbers say precisely what changed in the weights. Whether a given agreement score predicts task loss is an experiment nobody here has run, and it is the one that would turn a measurement into a decision procedure.
At inference time: 0.93 microseconds a token
The reads above are offline, on a weight file. The runtime half is a projection, because the
SVD is preprocessing — it happens once when you load the model, not per token. Measured on gpt2
layer 6, d=768, k=48:
| build the workspace, once per model | 1058 ms |
| extract, per token | 0.93 µs (48 coordinates) |
| inject, per token | 4.48 µs |
| gpt2's own forward pass | 2242 µs/token |
Extract is 0.054% of the forward pass. This is affordable on every token of every request.
ws = je.workspace(lens.jacobian(6), layer=6) # once, at load
coords = ws.extract(h) # (T, 48) — where these tokens sit
h2 = ws.inject(h, direction=0, amount=5.0) # move one coordinate
Inject is surgical. The basis is orthonormal, so moving direction 0 by 5.0 moves that coordinate by exactly 5.0 and every other one by at most 1.8e-15 — machine precision rather than approximation. That is what makes it a controlled write instead of a perturbation, and the tests assert it.
Two things to know before building on it.
Where you write matters more than what you write. A computed vector written at the embedding
reproduces a real token 100% of the time on a copy task; the same vector written four blocks later
reproduces it 0% of the time. Depth is where writes stop carrying. inject returns a vector
and does not choose a site — you do.
The basis is barely better than a random one at summarising a stream. Measured task-free, as the share of a real residual stream's variance that survives projection onto k directions:
| layer | k | random | workspace | stream's own PCA |
|---|---|---|---|---|
| 2 | 59 | 0.0767 | 0.0770 | 0.9409 |
| 6 | 48 | 0.0686 | 0.0820 | 0.8652 |
| 10 | 39 | 0.0511 | 0.0802 | 0.7600 |
1.0–1.6× a random subspace, and 8–11% of the optimal one. So the coordinates extract returns are not a compressed summary of what the model is carrying — an arbitrary projection of the same width carries nearly as much.
This does not contradict the coverage result above, which reads the overlap of resolved subspaces and finds the real transport at 6–24× chance. Both hold because the stream's resolved directions are not where its variance is. What it rules out is using these coordinates as a low-dimensional summary. What it does not rule out is that one particular direction in the basis is individually meaningful — that needs a labelled concept and is a different experiment.
What it measures, and at what level
Every read here characterises a map — a transport, a pair of fits, a truncation — at the level of a frame. That is where the numbers hold and where they are exact: how much of a transport is the skip connection, whether two independently fitted lenses are the same map, whether a rank truncation preserved the structure it was meant to keep. None of these needs a predictive link to behaviour, and every read prints its control beside it.
Scoring an individual token is a different question, and the reads here are frame-level
instruments. coverage compares two frames; audit describes one matrix; compare decides
whether two maps agree. Use them at that level.
A mean-Jacobian lens is a weak per-token linearisation in the lower half of a model. Measured as explained variance of the token-varying part of the final residual, a shared linearisation accounts for under 5% at and below layer 8 on Qwen3.5-4B, crossing half the variance only at relative depth 0.90. The same profile appears on Qwen3.5-0.8B at 2.5× less width, so it is a property of the method rather than of one network. Where a lens is most wanted — early and middle depth — a linear transport reproduces little of the token-to-token variation, and the paper's §7 gives the whole curve.
The identity result applies where the identity is. Below relative depth 0.6 the raw and
residual reads agree, so the screen is worth running before the spectrum: identity_share is
O(d²) and answers it in 9.5 ms at d=2560 against 3.5 s for the SVD.
Using it as a library
The CLI is a thin front on an API you can call directly.
import numpy as np
import entroptics_jlens as je
lens = je.load_lens("lenses/gpt2-small/jlens/Salesforce-wikitext/gpt2_jacobian_lens.pt")
J = lens.jacobian(5) # float64, one layer at a time
# Always first. Everything below reads M, not J.
d = je.decompose(J)
M = d.residual
d.alpha, d.removed_energy, d.identity_dominated # 0.801, 0.278, False
je.participation_ratio(je.energy_spectrum(J)) # 41.6 -- effective rank, with the identity
je.participation_ratio(je.energy_spectrum(M)) # 22.9 -- and without it
je.transport_spectrum(M, null="mp", far=0.05).K # 51 -- a lower bound, at this far
# How far into the spectrum two readouts agree. Cosines descend; find where they fall below 0.9.
c = je.principal_angles(M, je.decompose(lens.jacobian(6)).residual, k=64)
int(np.flatnonzero(c < 0.9)[0]) # 25 of the leading 64 directions
# H is a (T, d) residual stream you collected; give it at least d tokens.
cov = je.coverage(H, H @ M.T)
cov.coverage, cov.null, cov.k_signal # 0.1557, 0.0156, 23
Those are the real values from that lens, so a copy-paste that disagrees is telling you something.
Two things the API will not stop you doing, and should not be done: reading a spectrum on J rather than M, and reading null="robust" as though it carried a false-alarm rate. Both are covered above.
A ~100-layer lens at d_model = 5120 is about 5 GB stored and 21 GB upcast, so LensFile memory-maps the checkpoint and upcasts one transport on request. Nothing here materialises them all.
Which file answers which question
Each module's docstring carries the measurement behind its design.
decompose.py |
the identity the architecture puts there, and why it comes off first |
transport.py |
resolved rank against a derived floor — and why mp and the fence are different questions |
spectra.py |
the threshold-free reads: participation ratio, Shannon rank, principal angles |
coverage.py |
subspace overlap and its analytic chance level, with the coverage read it supports |
nulls.py |
distribution-free floors, the magnitude-preserving surrogates, and where they collapse |
controls.py |
the entropy-matched nulls, and the one caveat behind all of them |
lenses.py |
the transport as an Entroptics Lens; truncated_pair, and why a plain pinv is not a projector |
io.py |
reading a lens.pt, one transport at a time, at float64 |
frames.py |
the boundary: what is upcast, and what is refused |
targets.py |
comparing a transport against the model's own final residual, on the same footing |
bench.py |
the sealed half that opens once |
results.py |
results files that say whether their run finished |
cli.py · catalog.py |
the three commands, and the published-lens table |
je.Bench is separate and worth knowing about if you are measuring an effect rather than a matrix: it seals half your items at construction and opens them once, and it records claims as rates rather than magnitudes. Rates transferred across sealed halves five times in this work where the corresponding means halved, doubled, and once reversed sign.
Everything refuses rather than guesses
A missing lens file, a fitting checkpoint mistaken for a lens, a non-square transport, a transport that resolves nothing, an unknown null, streams of the wrong width or depth, a coverage read on a frame that resolves nothing: each stops with a message naming what was found. There are 55 such refusals, and tests/test_refusals.py exercises the ones nothing else reached — asserting the message rather than the exception type, because the message is what a refusal is for. Nothing truncates, imputes, or substitutes a plausible value, and no nan is ever printed under a real heading. A run that quietly read a subset of layers would report a curve with holes in it as if it were the curve.
The prose is linted like the code: tests/test_docs.py checks every markdown file for literal tabs, stray carriage returns, ragged tables and dead relative links.
Where the numbers come from
- research/PAPER.pdf — the paper as a PDF.
- research/PAPER.md — the same write-up in markdown, and the record of what every number was measured on. §1 is the claim, the four checks that bound it, and what the recovered directions contain; §2 is what Entroptics supplies; §9 is validation against planted truth.
- research/experiments/ — every figure above, as a script that re-runs it.
The reads themselves come from entroptics, which is the engine: the noise floors, the null providers, the screen. This package is the domain wrapper — it knows what a Jacobian lens is, and entroptics does not.
Citation
Sessford, I. J. The identity component of a Jacobian lens inflates the noise floor computed from it. 2026. doi:10.5281/zenodo.22293929.
That is the concept DOI and it resolves to the newest deposit; each release also gets its own
version DOI, listed on the Zenodo record.
CITATION.cff carries the
machine-readable form, which GitHub's "Cite this repository" reads.
This work is built on Entroptics, which supplies every resolved-rank count in it — cite that too:
Sessford, I. J. Entroptics: Reading any 2-D signal as a finite optical aperture at its own entropy-matched resolution. 2026. doi:10.5281/zenodo.21273400. Software: https://pypi.org/project/entroptics/ (this work uses 0.2.1).
Security issues: email connect@agience.ai rather than opening a public issue.
Metadata
Release files for entroptics-jlens 0.1.3
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | |
|---|---|---|---|
| entroptics_jlens-0.1.3.tar.gz | 175.6 kB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| entroptics_jlens-0.1.3-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 258.3 kB
Release files / entroptics_jlens-0.1.3.tar.gz
| Download URL | entroptics_jlens-0.1.3.tar.gz |
|---|---|
| Size | 175.6 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
214494c37c62b52e5906c0eab74b573e8a68bafa0e6b41eeaa95a9e49b80f1ad
|
|
BLAKE2b-256 checksum How to use checksums |
253c65db4660236e98c93cc6b5bda2be69a9b1d6677f78a728467dd0240e8282
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Sep 8, 2026.
Transparency logRelease files / entroptics_jlens-0.1.3-py3-none-any.whl
| Download URL | entroptics_jlens-0.1.3-py3-none-any.whl |
|---|---|
| Size | 82.8 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
0abc422c2a018973a6d49d98c128f72ee21f2051489ffc9fefca54c4865e0379
|
|
BLAKE2b-256 checksum How to use checksums |
f4b9b2fba5dce7cfb394718c96d5df569c08bbe4428eff718d288fc24b8ff71c
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Sep 8, 2026.
Transparency log