Skip to content

Jacobian lens

The logit lens reads a layer's residual through the final norm and unembedding as if the layers after it did nothing. The Jacobian lens (Anthropic's Verbalizable Representations Form a Global Workspace, 2026) first transports the residual into the final layer's basis with a pre-fitted average Jacobian J_l = E[∂h_final / ∂h_l], then unembeds — reading out what the model is disposed to say from layer l, not what it would say if it stopped there.

Fitting J_l needs a backward pass, which vLLM does not have; Neuronpedia publishes fitted lenses for many models, including Qwen/Qwen3-8B. The readout is a matmul, so it runs inside the worker like the logit lens.

Load the lens

Every layer's J_l is a [d_model, d_model] matrix (32 MB in fp16 for Qwen3-8B), and whatever the block references travels to the worker with it — so load only the layers you will read.

import torch
import nnsight
from huggingface_hub import hf_hub_download
from nnsight.modeling.vllm import VLLM

model = VLLM("Qwen/Qwen3-8B", dispatch=True, enable_prefix_caching=False)   # the edit below needs it off
tok = model.tokenizer

path = hf_hub_download("neuronpedia/jacobian-lens",
                       "qwen3-8b/jlens/Salesforce-wikitext/Qwen3-8B_jacobian_lens.pt")
lens = torch.load(path, map_location="cpu")
print(lens["source_layers"][:3], "...", lens["source_layers"][-1], "fit on", lens["n_prompts"], "prompts")
# [0, 1, 2] ... 34 fit on 461 prompts

LAYERS = [20, 26, 32]
J = {l: lens["J"][l].to("cuda", torch.bfloat16) for l in LAYERS}    # [4096, 4096] each

Read it out

x @ J_l.T moves the residual into the final basis; the model's own norm and logits_processor do the rest, exactly as in the logit lens. Top-k is taken on the worker.

prompt = "The Eiffel Tower is located in the city of"
K = 5

with model.trace(prompt, temperature=0.0):
    jlens, plain = {}, {}
    for l in LAYERS:
        h = sum(model.model.layers[l].output)                           # [T, d_model]
        for name, x in (("jlens", h @ J[l].T), ("plain", h)):
            logits = model.logits_processor(model.lm_head, model.model.norm(x))
            top = logits.float().softmax(-1).topk(K, dim=-1)
            (jlens if name == "jlens" else plain)[l] = (top.indices, top.values)
    jlens, plain = nnsight.save(jlens), nnsight.save(plain)

ids = tok(prompt)["input_ids"]
for l in LAYERS:
    print(f"\nlayer {l}   (top-1 at every position)")
    for name, res in (("J-lens", jlens), ("logit lens", plain)):
        print(f"  {name:>10}: " + " ".join(f"{tok.decode(t)!r:>10}" for t in res[l][0][:, 0].tolist()))
# layer 20   (top-1 at every position)
#       J-lens: '/Internal'   ' Space'      '建筑师'       '巴黎'  ' French'     '____'     '____'     '____'    ' city'    ' city'     '____'
#   logit lens:        '玿'        '‐'      'ian'   ' Tower' ' famously' ' famously'    ' ____'  ' ______'  ' center'   ' _____'   ' _____'
# layer 26   (top-1 at every position)
#       J-lens:    'gMaps'    'lixir'    'stadt'   ' Tower'      '是一座' ' tallest'     '____'     '____'       '法国'    '(city'     '____'
#   logit lens:        '玿'    'ureka'   'icient'   ' Tower' ' famously'  ' taller'    ' ____'    ' ____'  ' famous'      ' of'  ' ______'
# layer 32   (top-1 at every position)
#       J-lens:      ' ",'    'ulers'       'el'   ' Tower'      ' is' ' located'     '____'   ' Paris'    ' city'      ' of'   ' Paris'
#   logit lens:        '玿'    'ureka'       'el'   ' Tower'      ' is'    ' made'      ' in'   ' Paris'    ' city'      ' of'   ' Paris'

At layer 20 the J-lens already reads 巴黎 (Paris) over the subject and French after it, where the logit lens reads Tower and famously; by 32 both say Paris.

The last position, top-k

for l in LAYERS:
    ids_l, p_l = jlens[l]
    print(f"layer {l}:", [(tok.decode(i), round(p, 3)) for i, p in zip(ids_l[-1].tolist(), p_l[-1].tolist())])
# layer 20: [('____', 0.415), (' ______', 0.135), (' ____', 0.135), (' __', 0.072), ('________', 0.03)]
# layer 26: [('____', 0.726), (' ______', 0.126), (' __', 0.046), (' ____', 0.032), ('________', 0.022)]
# layer 32: [(' Paris', 0.979), ('巴黎', 0.018), ('Paris', 0.001), ('____', 0.001), (' ______', 0.0)]

Every step of a generation

Under tracer.iter the readout follows the running token: what the model is disposed to say at layer 26 while it writes.

with model.trace(prompt, temperature=0.0, max_tokens=8) as tracer:
    disposed = list().save()
    for _ in tracer.iter[:8]:
        h = sum(model.model.layers[26].output)[-1:]
        logits = model.logits_processor(model.lm_head, model.model.norm(h @ J[26].T))
        disposed.append(logits.argmax(-1).item())
    out = tracer.result.save()

print(repr(out.outputs[0].text))
# ' Paris, France. It is one of'
print([tok.decode(t) for t in disposed])
# ['____', ' France', ' France', '.', ' Its', '是一座', ' famous', ' famous']

Installing it for every request

A served model can carry the readout permanently: put the same block in model.edit() and the per-request top-k rides every output, traced or not — the "live J-space chat" pattern, without a per-request upload of the lens.

with model.edit() as (tracer, edit):
    readout = nnsight.save([])
    for _ in tracer.all():
        h = sum(model.model.layers[26].output)[-1:]
        logits = model.logits_processor(model.lm_head, model.model.norm(h @ J[26].T))
        readout.append(logits.float().softmax(-1).topk(3, dim=-1).indices[0].tolist())

outputs = model.generate(["The capital of Japan is", "Water boils at"], temperature=0.0, max_tokens=4)
for o in outputs:
    print(repr(o.outputs[0].text), [[tok.decode(t) for t in step] for step in o.saves["readout"][:2]])
# ' Tokyo. The capital' [['____', ' ____', ' ______'], ['.', '。', '.",']]
# ' 100' [['温度', ' temperatures', 'Temperature'], ['温度', ' boiling', '高温']]
edit.clear()

The lens is shipped once, with the edit; each request pays only its own readout.