Skip to main content

entroptics-jlens

PyPI Python License CI Paper DOI Sponsor

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:

  • alpha is tr(J)/d, how much of the transport is a plain pass-through. Watch it climb from 0.26 to 1.14 with depth.
  • identity is 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 as mp/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.

Licensed under Apache-2.0 — see LICENSE and NOTICE.

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)

Source distribution for entroptics-jlens 0.1.3
File Size Uploaded
entroptics_jlens-0.1.3.tar.gz 175.6 kB Details

Built distribution (wheel)

Table of built distributions (wheels) for entroptics-jlens 0.1.3
File Interpreter ABI Platform
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 log

Release 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

Release history Release notifications | RSS feed

This release

0.1.3 This release

2 release files

0.1.2

2 release files

0.1.1

2 release files

0.1.0

2 release files

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page