本文根据前一篇博文《重磅发现:LLM产生意识了么》提到的开源项目 anthropics/jacobian-lens,完整试验对 Qwen3.5-4B 开源模型的透镜拟合估算、源码解析与推理可视化观察,落地复现LLM 全局工作空间 核心实验。
Jacobian Lens(雅可比透镜,简称J-Lens) 是当前 LLM 可解释性领域的里程碑工具,彻底解决了传统透镜解码不准、无法追踪中间推理、无因果效力的痛点,能够无创读取模型内部隐式思维,是观测 LLM 类意识全局工作空间(J-Space)的核心工具。
一 环境准备工作
本次试验基于轻量化Python环境搭建,采用uv管理依赖,兼顾速度与环境纯净度,适配本地GPU/CPU推理,兼容主流开源Decoder模型,本次试验选用 Qwen3.5 4B 基础模型。
1.1 仓库部署
1# 官方仓库: https://github.com/anthropics/jacobian-lens
2# Fork 官方仓库后克隆个人仓库
3git clone https://github.com/你的用户名/jacobian-lens.git
4cd jacobian-lens
1.2 Python 环境
1# 安装uv工具
2pip install uv
3
4# 创建虚拟环境
5uv venv
6
7# 激活环境
8# Windows
9.venv\Scripts\activate
10# Mac/Linux
11source .venv/bin/activate
12
13# 安装核心依赖
14uv pip install torch transformers accelerate datasets matplotlib
15
16# 本地可编辑安装项目依赖
17pip install -e .
1.3 模型准备
直接通过HuggingFace(HF国内镜像你知道的)自动拉取 Qwen/Qwen3.5-4B 预训练权重,全程无需手动下载,自动缓存本地:
1from transformers import AutoModelForCausalLM, AutoTokenizer
2
3# 加载 Qwen3.5-4B 模型与分词器
4model_name = "Qwen/Qwen3.5-4B"
5model = AutoModelForCausalLM.from_pretrained(
6 model_name,
7 torch_dtype="auto",
8 device_map="auto"
9)
10tokenizer = AutoTokenizer.from_pretrained(model_name)
二 Jacobian Lens 源码分析
2.1 核心原理回顾
Jacobian-Lens 是论文 Verbalizable Representations Form a Global Workspace in Language Models 的官方配套源代码。
它的核心定位是 大语言模型内部表征无创解码工具 ,目标是精准还原模型每一层、每一个位置的隐式推理概念,完整复刻模型思维链条。 也就是模型中间某一层、某个位置的内部状态,到底倾向于输出什么词?
传统 Logit Lens 直接提取模型中间层激活向量,通过顶层解码矩阵直接输出词汇概率,但模型不同网络层的表征 坐标系 完全不统一,浅层、中层激活向量无法直接适配输出层解码规则,最终解码噪声极大、语义错乱,无法捕捉中间推理步骤,只能看到最终输出结果。
Jacobian Lens 创新性地引入 全局平均雅可比矩阵 ,构建各层(如l层)表征到输出层的固定线性映射J_l,把第 l 层的激活向量 搬运 到最后一层的坐标系,再用模型自己的 unembedding 数据表解码,相当于给模型每一层都安装专属的 语义翻译器,彻底对齐坐标系。其核心公式:
即如下的Python代码:
1lens_l(h) = unembed( J_l @ h )
其中:
h:模型第l层的残差流激活向量J_l:第l层专属平均雅可比矩阵,描述第 l 层的小变化,会怎样影响最后一层,由海量语料、多Prompt反向传播统计平均得到。unembed:模型原生解码矩阵,将对齐后的向量映射为词汇概率分布
简单来说:J_l 记录了模型第l层微小表征变化,对最终输出词汇的影响规律,通过全局平均过滤上下文噪声,实现通用、无监督、高精度的内部思维解码。
2.2 项目代码结构
| 文件 | 作用 |
|---|---|
jlens/fitting.py | 拟合核心:学习/估计 J_l |
jlens/lens.py | 拟合完成后应用 lens |
jlens/hooks.py | 前向 hook,抓取各层激活 |
jlens/hf.py | HuggingFace 模型适配器 |
jlens/protocol.py | 模型接口协议(任意模型都能接入) |
jlens/vis.py | 可视化(layer × position 热力图) |
2.3 拟合透镜的算法和代码
拟合不是模型训练和微调
要特别强调的是,这里的 fit(拟合) 不是微调权重,而是:
- 模型权重完全冻结
- 用大量文本跑前向 + 反向传播
- 统计 第
l层 $\to$ 最后一层 的梯度关系 - 对很多
prompt取平均,得到雅克比平均矩阵J_l
可以理解为:给模型装一个翻译器,把各层内部表示翻译成最终会说什么的语言。
拟合算法流程图
用户入口很简单:
1lens = jlens.fit(model, prompts=my_prompts, checkpoint_path="out/ckpt.pt")
2lens.save("out/jacobian_lens.pt")
论文建议约 1000 条、每条 128 token 的网页文本;README 说 ~100 条也能用,质量会随数据量快速饱和。
jacobian_for_prompt 是单条 prompt 上估计 J_l 的函数,位于 jlens/fitting.py。
如果把 Transformer 看作一条流水线:
输入 $\to$ Layer 0 $\to$ Layer 1 $\to$ … $\to$ Layer L-1(最后一层)
考察 Layer l 的激活向量动一点点,最后一层会怎么变,这是典型的多元多值函数求变化率矩阵的场景,也就是雅可比矩阵:
对应代码里的J_l(形状 d_model × d_model)。
1# fitting.py
2def jacobian_for_prompt(
3 model: LensModel,
4 prompt: str,
5 source_layers: Sequence[int],
6 *,
7 target_layer: int | None = None,
8 dim_batch: int = 8,
9 max_seq_len: int = 128,
10 skip_first: int = SKIP_FIRST_N_POSITIONS,
11) -> tuple[dict[int, torch.Tensor], int, int]:
12 """Compute the per-layer Jacobian estimator ``J_l`` for one prompt.
13
14 Runs one forward pass on the prompt replicated ``dim_batch`` times along
15 the batch axis, retains the graph, then runs ``ceil(d_model / dim_batch)``
16 backward passes against it. Each backward computes ``dim_batch`` rows of
17 ``J_l`` at once: batch element ``b`` carries a one-hot cotangent at output
18 dimension ``dim_start + b``, set at every valid target position. See the
19 module docstring for the resulting estimator and how it relates to
20 a strict per-position Jacobian.
21
22 Args:
23 model: The model to compute Jacobians for.
24 prompt: Input text.
25 source_layers: Layer indices ``l`` to compute ``J_l`` at.
26 target_layer: Layer to take gradients with respect to. Defaults to the
27 final layer; negative indices count from the end. In some cases,
28 targeting the penultimate layer can give a better-conditioned
29 ``J_l``.
30 dim_batch: Output dimensions computed per backward pass. Higher uses
31 more GPU memory (the prompt is replicated this many times); total
32 backward FLOPs are unchanged.
33 max_seq_len: Truncate the prompt to this many tokens.
34 skip_first: Leading positions to exclude; see :func:`valid_position_mask`.
35
36 Returns:
37 ``(jacobians, seq_len, n_valid_positions)``. ``jacobians`` maps each
38 source layer to a ``[d_model, d_model]`` fp32 CPU tensor.
39 """
40 n_layers, d_model = model.n_layers, model.d_model
41 source_layers, target_layer = _check_layer_indices(
42 source_layers, target_layer, n_layers
43 )
44
45 input_ids = model.encode(prompt, max_length=max_seq_len)
46 seq_len = input_ids.shape[1]
47 position_mask = valid_position_mask(seq_len, skip_first=skip_first)
48 n_valid_positions = int(position_mask.sum())
49 ...
函数参数说明
| 参数 | 说明 | 关键约束 |
|---|---|---|
model | 待拟合大模型的模型ID,提供编码、层激活提取接口 | 内置属性 n_layers 层数、d_model隐藏维度 |
prompt | 待计算的文本输入 | 会截断至 max_seq_len |
source_layers | 需要输出雅可比的层列表 | 梯度输入侧:$\frac{\partial h_t}{\partial h_l}$ 中 $l$ |
target_layer | 求导目标层,梯度输出侧 $h_t$ | 默认最后一层,支持负索引;提前校验合法性 |
dim_batch | 单次反向并行计算的雅可比行数 | 越大显存占用越高,总计算量不变 |
max_seq_len | prompt token 截断上限 | 控制序列长度降低计算量 |
skip_first | 跳过前 N 个 token 位置 | 过滤起始 padding/BOS,仅在有效 token 上平均梯度 |
函数返回值
返回值为包含3个元素的元组,第一个元素为字典类型,后两个元素为整型值。
| 元组的元素 | 说明 |
|---|---|
jacobians | 键是层编号,值是[d_model, d_model]形状的张量 (雅可比矩阵) |
seq_len | 编码后输入 token 长度 |
n_valid_positions | 剔除前skip_first个位置后,参与梯度平均的有效 token 数量 |
雅克比矩阵初始化
1jacobians = {layer: torch.zeros(d_model, d_model, dtype=torch.float32) for layer in source_layers}
2n_passes = math.ceil(d_model / dim_batch)
预分配零矩阵存储最终结果,避免循环中频繁迁移显存;
n_passes:反向传播总轮次,完整遍历全部d_model个输出维度(每轮次计算 dim_batch个行向量)。
前向传播和激活记录
1# L154-L169
2with (
3 ActivationRecorder(
4 model.layers,
5 at=[*source_layers, target_layer],
6 start_graph_at=min(source_layers),
7 ) as recorder,
8 torch.enable_grad(),
9):
10 # One forward on the prompt replicated dim_batch times. The retained
11 # graph is reused for every backward pass below.
12 replicated_ids = input_ids.expand(dim_batch, -1)
13 model.forward(replicated_ids)
14 target_activation = recorder.activations[
15 target_layer
16 ] # [dim_batch, seq_len, d_model]
17 source_activations = [recorder.activations[layer] for layer in source_layers]
ActivationRecorder:在指定层挂 forward hook,记录残差流激活start_graph_at=min(source_layers):从最早源层开始保留计算图,使autograd能回传到源层replicated_ids:把同一条 prompt 在 batch 维复制dim_batch份,一次前向传播得到[dim_batch, seq_len, d_model](一个 prompt 里的每个有效 token 对应一张雅克比矩阵)target_activation:目标层激活,后向传播的输出source_activations:各源层激活,后向传播的输入
只做一次前向传播,计算图保留,后续多次后向传播复用。
准备 VJP 余切向量与有效位置
1# L171-L175
2valid_positions = position_mask.nonzero(as_tuple=True)[0].to(
3 target_activation.device
4)
5batch_indices = torch.arange(dim_batch, device=target_activation.device)
6cotangent = torch.zeros_like(target_activation)
valid_positions:参与 Jacobian 平均的序列位置索引batch_indices:[0, 1, …, dim_batch-1]cotangent:与target_activation同形的梯度“种子”,每次 backward 前会清零再填入 one-hot
核心循环:分批反向传播
1# L177-L221
2for pass_idx, dim_start in enumerate(range(0, d_model, dim_batch)):
3 n_dims_this_pass = min(dim_batch, d_model - dim_start)
4 # One-hot cotangent at dim (dim_start + b) for batch element b,
5 # at every valid target position. Yields rows dim_start..+n of J_l.
6 cotangent.zero_()
7 cotangent[
8 batch_indices[:n_dims_this_pass, None],
9 valid_positions[None, :],
10 dim_start + batch_indices[:n_dims_this_pass, None],
11 ] = 1.0
12 grads = torch.autograd.grad(
13 outputs=target_activation,
14 inputs=source_activations,
15 grad_outputs=cotangent,
16 retain_graph=(pass_idx < n_passes - 1),
17 )
18 for layer, grad in zip(source_layers, grads, strict=True):
19 # grad: [dim_batch, seq_len, d_model] on whatever device this
20 # layer lives on; mean over the valid positions -> dim_batch rows.
21 positions_on_device = valid_positions.to(grad.device, non_blocking=True)
22 rows = (
23 grad[:n_dims_this_pass, positions_on_device, :].float().mean(dim=1)
24 )
25 jacobians[layer][dim_start : dim_start + n_dims_this_pass, :] = (
26 rows.cpu()
27 )
每次 pass 在做什么
- 构造独热余切向量(one-hot cotangent)
对每个批次(batch)的元素 b,在所有 valid_positions 上,对目标层维度 dim_start + b 置 1。
相当于同时对多个输出维度求梯度。
- 执行反向传播梯度计算
torch.autograd.grad
计算 $\frac{\partial h_{target}}{\partial h_{source}}$
retain_graph=True(除最后一次)保留图供下次反向传播使用。
- 聚合梯度 $\to$ Jacobian 行
- grad 形状:
[dim_batch, seq_len, d_model] - 在
valid_positions上取子集,再mean(dim=1)$\to$ 得到dim_batch行 - 写入
jacobians[layer][dim_start : dim_start + n_dims_this_pass, :]
- grad 形状:
对每个输出维度,在所有有效目标位置同时注入 one-hot cotangent 再反传。
在源位置 $p$ 的梯度是:
$$\Sigma_{p^{'} \in {valid}} \frac{\partial h_{final}[p^{'}]}{\partial h_l[p^{'}]}$$再对源位置取平均,得到 $J_l$ 的一行。这是论文用的压缩平均,不是严格的逐位置 Jacobian。
拟合的雅克比矩阵保存为文件
jacobian_for_prompt函数返回的雅克比矩阵张量通过lens.py文件里定义的save函数保存为透镜模型文件。
1# lens.py L52-L64
2def save(self, path: str, *, dtype: torch.dtype = torch.float16) -> None:
3 """Save to ``path``. Jacobians are stored as ``dtype`` (default fp16:
4 halves file size; entries are O(1) so the range is not a constraint
5 and fp16's extra mantissa bits beat bf16 here)."""
6 torch.save(
7 {
8 "J": {layer: J.to(dtype) for layer, J in self.jacobians.items()},
9 "n_prompts": self.n_prompts,
10 "source_layers": self.source_layers,
11 "d_model": self.d_model,
12 },
13 path,
14 )
雅克比透镜文件的用法:
1import jlens
2
3# 拟合
4lens = jlens.fit(model, prompts=my_prompts, checkpoint_path="out/ckpt.pt")
5
6# 保存最终 lens(默认 fp16,体积约为 fp32 一半)
7lens.save("out/jacobian_lens.pt")
8
9# 加载
10lens = jlens.JacobianLens.load("out/jacobian_lens.pt")
11
12# 或从 HuggingFace
13lens = jlens.JacobianLens.from_pretrained("org/lens-repo", filename="model/lens.pt")
jacobian_for_prompt函数用自动微分 + 分批独热余切向量计算,估计每层从源残差到目标残差的线性映射$J_l$,供雅克比透镜把早期层激活搬运到最终层语义空间再解码。
2.4 透镜分析的算法和代码
应用阶段只做一次前向传播计算 + 矩阵乘 + 解码,不再做反向传播。
雅克比透镜应用流程图
端到端用法
1import transformers
2import jlens
3
4# 1. 加载模型
5hf = transformers.AutoModelForCausalLM.from_pretrained("org/model").cuda()
6tok = transformers.AutoTokenizer.from_pretrained("org/model")
7model = jlens.from_hf(hf, tok)
8
9# 2. 加载已拟合透镜
10lens = jlens.JacobianLens.load("out/jacobian_lens.pt")
11# 或
12lens = jlens.JacobianLens.from_pretrained("org/lens-repo", filename="model/lens.pt")
13
14# 3. 对新文本应用
15lens_logits, model_logits, input_ids = lens.apply(
16 model,
17 "Fact: The currency used in the country shaped like a boot is",
18 layers=[0, 10, 20], # 可选:指定层;默认全部 source_layers
19 positions=[-2], # 可选:指定位置;默认全部位置
20 max_seq_len=512,
21 use_jacobian=True, # False 则退化为普通 logit lens(不做 J 运输)
22)
23
24# 4. 解读结果
25for layer, logits in sorted(lens_logits.items()):
26 top5 = logits[0].topk(5).indices # positions=[-2] 时只有 1 行
27 print(f"Layer {layer}:", [tok.decode([t]) for t in top5])
对新文本应用的返回结果含义:
| 变量 | 形状 | 含义 |
|---|---|---|
lens_logits[layer] | [n_positions, vocab_size] | 该层经$J_l$运输后的透镜读数 |
model_logits | [n_positions, vocab_size] | 模型最终层真实 logits(对照基线) |
input_ids | [1, seq_len] | tokenized 输入 |
加载透镜文件
1# lens.py L66-L79
2@classmethod
3def load(cls, path: str) -> JacobianLens:
4 """Load a lens previously written by :meth:`save`."""
5 checkpoint = torch.load(path, map_location="cpu", weights_only=True)
6 if "J" not in checkpoint:
7 raise ValueError(
8 f"{path} is not a JacobianLens file "
9 f"(found keys {sorted(checkpoint)!r}; a fit() checkpoint?)"
10 )
11 return cls(
12 jacobians=checkpoint["J"],
13 n_prompts=checkpoint["n_prompts"],
14 d_model=checkpoint["d_model"],
15 )
load()函数用于加载本地透镜文件.pt, 另有from_pretrained从 HF 加载不表- 文件里必须有
"J"键(fit()的checkpoint_path存的是jacobian_sum,不能直接load) - 加载后得到 JacobianLens,内含
jacobians、source_layers、n_prompts、d_model
应用透镜 apply()
- 参数校验
1# lens.py L182-L194
2if layers is None:
3 layers = self.source_layers
4out_of_range = sorted(l for l in set(layers) if not 0 <= l < model.n_layers)
5if out_of_range:
6 raise ValueError(...)
7unknown = set(layers) - set(self.source_layers)
8if use_jacobian and unknown:
9 raise ValueError(
10 f"layers {sorted(unknown)} not in source_layers; "
11 f"fitted layers are {self.source_layers}"
12 )
layers=None$\to$ 使用拟合时的全部source_layersuse_jacobian=True时,请求的层必须在透镜文件里拟合过use_jacobian=False时可用任意模型层(普通 logit lens 基线)
- 前向传播并记录激活
1# lens.py L195-L201
2final_layer = model.n_layers - 1
3record_at = sorted(set(layers) | {final_layer})
4
5input_ids = model.encode(prompt, max_length=max_seq_len)
6with ActivationRecorder(model.layers, at=record_at) as recorder:
7 model.forward(input_ids)
8 activations = {i: recorder.activations[i].detach() for i in record_at}
流程:
- 文本 $\to$
[1, seq_len]token ids - 在 layers + 最终层挂 hook,只做一次前向(
@torch.no_grad(),无梯度) - 得到各层残差流
activations[layer],形状[1, seq_len, d_model]
与拟合的区别:拟合要多次反向传播估计 $J_l$;应用只读激活,用已存好的$J_l$。
- 选取位置
1# lens.py L203-L206
2def select(layer: int) -> torch.Tensor:
3 """Residuals at the requested positions: ``[n_positions, d_model]``."""
4 full = activations[layer][0] # [seq_len, d_model]
5 return (full if positions is None else full[list(positions)]).float()
positions=None:所有 token 位置positions=[0, -1]:第 0 个和最后一个(支持负索引)- 输出
[n_positions, d_model]
- 运输解码
1# lens.py L208–L216
2lens_logits: dict[int, torch.Tensor] = {}
3for layer in layers:
4 residual = select(layer)
5 if use_jacobian:
6 residual = self.transport(residual, layer)
7 lens_logits[layer] = model.unembed(residual).float().cpu()
8
9model_logits = model.unembed(select(final_layer)).float().cpu()
10return lens_logits, model_logits, input_ids
对每个请求层:
- 取该层残差
h - 若
use_jacobian=True:h' = J_l @ h unembed(h')$\to$ 词表 logits
同时用最终层残差算 model_logits,作为真实输出对照。
- 线性运输
transport()
1# lens.py L135-L143
2def transport(self, residual: torch.Tensor, layer: int) -> torch.Tensor:
3 """Map a residual at ``layer`` into the final-layer basis: ``J_l @ h``."""
4 J_bar = self.jacobians[layer].to(residual.device)
5 return residual @ J_bar.T
residual: [..., d_model]J_bar: [d_model, d_model](从透镜文件加载,默认fp16,用时转fp32/device)- 返回:
[..., d_model],已在最终层基下的表示
数学上:residual @ J.T 等价于对每个位置做 $J_lh$。
两种模式对比
use_jacobian=True(雅克比透镜) | use_jacobian=False(Logit透镜) | |
|---|---|---|
| 残差处理 | J_l @ h 运输到最终层基 | 直接用该层残差 |
| 层限制 | 必须在 source_layers 内 | 任意有效层 |
| 语义 | 估计该激活会导向什么输出 | 该层直接 unembed(通常较差) |
应用透镜需特别注意的几点
- 模型需与拟合时一致:同一架构、
d_model、unembedding;透镜的$J_l$是在特定模型上估计的。 d_model必须匹配:lens.d_model == model.d_model。- 层索引对应同一套
blocks:source_layers是拟合时记录的层号。 - 文件类型别混用:
lens.save()的文件用JacobianLens.load();fit(checkpoint_path=...)的 ckpt 只用于续跑拟合。 - 应用很快:一次前向 + 小矩阵乘;瓶颈在模型前向,不在 $J_l$。
三 观察透镜
四 动手拟合估算透镜
根据前面介绍的jlens库,编写针对 Qwen3.5-4B 模型的雅克比透镜拟合和应用代码。
1. 导入相关模型块
1import argparse
2import gzip
3import json
4import os
5import sys
6from pathlib import Path
7
8import torch
9import transformers
10
11import jlens
12from jlens.examples import EXAMPLES, load_wikitext_prompts, resolve_prompt
13from jlens.vis import build_page, compute_slice
14from jlens.data.zh_prompts import ZH_PROMPTS
15
16# ---------- 配置 ----------
17MODEL_NAME = "Qwen/Qwen3.5-4B"
18LENS_PATH = "qwen4b_jacobian_lens.pt"
19CHECKPOINT_PATH = "checkpoints/qwen4b_ckpt.pt"
2. 定义拟合函数
1def cmd_fit(args):
2 """拟合透镜(支持断点恢复)。"""
3 jlens.configure_logging()
4
5 print(f"[1/3] 加载模型 {MODEL_NAME} ...")
6 hf_model = transformers.AutoModelForCausalLM.from_pretrained(
7 MODEL_NAME,
8 dtype=torch.bfloat16,
9 #device_map="auto",
10 #device="mps",
11 ).to("mps")
12 tokenizer = transformers.AutoTokenizer.from_pretrained(MODEL_NAME)
13 model = jlens.from_hf(
14 hf_model, tokenizer, compile=args.compile, force_bos=True
15 )
16 print(f" {model}")
17
18 print(f"[2/3] 加载 {args.prompts} 条 prompt ...")
19 prompts = load_wikitext_prompts(n_prompts=args.prompts, min_chars=600)
20 print(f" 已加载 {len(prompts)} 条")
21
22 print(f"[3/3] 拟合透镜 (dim_batch={args.dim_batch}, max_seq_len={args.max_seq_len}) ...")
23 print(f" 检查点: {args.checkpoint}")
24 print(f" 层选择: {args.layers or '所有层(除最后一层)'}")
25 print()
26
27 source_layers = None
28 if args.layers:
29 source_layers = [int(l) for l in args.layers.split(",")]
30
31 lens = jlens.fit(
32 model,
33 prompts,
34 #ZH_PROMPTS,
35 dim_batch=args.dim_batch,
36 max_seq_len=args.max_seq_len,
37 source_layers=source_layers,
38 checkpoint_path=args.checkpoint,
39 checkpoint_every=1,
40 resume=True,
41 )
42
43 out = args.output or LENS_PATH
44 lens.save(out)
45 print(f"\n完成!透镜已保存到 {out}")
46 print(f"文件信息: {lens}")
3. 定义透镜应用函数
1def cmd_apply(args):
2 """加载已拟合透镜并测试几条 prompt。"""
3 print(f"加载模型 {MODEL_NAME} ...")
4 hf_model = transformers.AutoModelForCausalLM.from_pretrained(
5 MODEL_NAME, dtype=torch.bfloat16, device_map="auto"
6 )
7 tokenizer = transformers.AutoTokenizer.from_pretrained(MODEL_NAME)
8 model = jlens.from_hf(hf_model, tokenizer)
9
10 lens_path = args.lens or LENS_PATH
11 print(f"加载透镜 {lens_path} ...")
12 lens = jlens.JacobianLens.load(lens_path)
13 print(f" {lens}")
14
15 # 选择要观察的层:1/4, 1/2, 3/4, 最后第二层
16 layers = [
17 model.n_layers // 4,
18 model.n_layers // 2,
19 model.n_layers // 4 * 3,
20 model.n_layers - 2,
21 ]
22
23 examples_to_run = EXAMPLES if args.all else [EXAMPLES[0]]
24 for example in examples_to_run:
25 print(f"\n{'='*60}")
26 print(f"示例: {example.section}")
27 prompt = resolve_prompt(example, tokenizer)
28 display = prompt[:80] + "..." if len(prompt) > 80 else prompt
29 print(f"Prompt: {display}")
30
31 jl_logits, model_logits, _ = lens.apply(
32 model, prompt, layers=layers, positions=[-2]
33 )
34 ll_logits, _, _ = lens.apply(
35 model, prompt, layers=layers, positions=[-2], use_jacobian=False
36 )
37
38 def top5(logits):
39 return [
40 tokenizer.decode([t], clean_up_tokenization_spaces=False)
41 for t in logits.topk(5).indices
42 ]
43
44 for layer in sorted(jl_logits):
45 print(f" L{layer:>3} J-lens: {top5(jl_logits[layer][0])}")
46 print(f" logit: {top5(ll_logits[layer][0])}")
47 print(f" model output: {top5(model_logits[0])}")
4. 定义可视化切片函数
1def cmd_slice(args):
2 """生成交互式切片 HTML 页面。"""
3 print(f"加载模型 {MODEL_NAME} ...")
4 hf_model = transformers.AutoModelForCausalLM.from_pretrained(
5 MODEL_NAME, dtype=torch.bfloat16, device_map="auto"
6 )
7 tokenizer = transformers.AutoTokenizer.from_pretrained(MODEL_NAME)
8 model = jlens.from_hf(hf_model, tokenizer)
9
10 lens_path = args.lens or LENS_PATH
11 print(f"加载透镜 {lens_path} ...")
12 lens = jlens.JacobianLens.load(lens_path)
13
14 gloss = {}
15 gloss_path = Path("assets/qwen_gloss.json.gz")
16 if gloss_path.exists():
17 gloss = {
18 int(k): v
19 for k, v in json.load(gzip.open(gloss_path)).items()
20 }
21 print(f" 已加载 {len(gloss)} 条 token gloss")
22
23 example_map = {e.slug: e for e in EXAMPLES}
24 slug = args.example or "multihop"
25 example = example_map.get(slug, EXAMPLES[0])
26 prompt = resolve_prompt(example, tokenizer)
27 print(f"示例: {example.section}")
28 print(f"Prompt 长度: {len(prompt)} 字符")
29
30 slice_data = compute_slice(
31 model, lens, prompt,
32 layer_stride=2,
33 mask_display=True,
34 )
35
36 out_dir = Path(args.out_dir or f"slices/{slug}")
37 page, _, _ = build_page(
38 slice_data, prompt,
39 title=example.section,
40 description=example.description,
41 alt_token=gloss,
42 mode=args.mode,
43 out_dir=out_dir,
44 )
45
46 if args.mode == "embed":
47 out_file = out_dir / "index.html"
48 out_dir.mkdir(parents=True, exist_ok=True)
49 out_file.write_text(page)
50 print(f"页面已保存: {out_file}")
51 print("直接用浏览器打开或 notebook_iframe 加载")
52 else:
53 (out_dir / "index.html").write_text(page)
54 print(f"页面已保存: {out_dir / 'index.html'}")
55 print("启动方式: cd slices && python -m http.server 8000")
5. 定义命令行入口
1def main():
2 parser = argparse.ArgumentParser(description="Qwen3.5-4B Jacobian Lens 拟合工具")
3 sub = parser.add_subparsers(dest="command")
4
5 p_fit = sub.add_parser("fit", help="拟合透镜")
6 p_fit.add_argument("--prompts", type=int, default=100)
7 p_fit.add_argument("--dim-batch", type=int, default=8)
8 p_fit.add_argument("--max-seq-len", type=int, default=128)
9 p_fit.add_argument("--compile", action="store_true")
10 p_fit.add_argument("--layers", type=str, default=None)
11 p_fit.add_argument("--checkpoint", type=str, default=CHECKPOINT_PATH)
12 p_fit.add_argument("--output", "-o", type=str, default=None)
13
14 p_apply = sub.add_parser("apply", help="应用透镜测试 prompt")
15 p_apply.add_argument("--lens", type=str, default=None)
16 p_apply.add_argument("--all", action="store_true")
17
18 p_slice = sub.add_parser("slice", help="生成交互式切片页面")
19 p_slice.add_argument("--lens", type=str, default=None)
20 p_slice.add_argument("--example", type=str, default="multihop")
21 p_slice.add_argument("--mode", type=str, default="embed", choices=["embed", "fetch"])
22 p_slice.add_argument("--out-dir", type=str, default=None)
23
24 args = parser.parse_args()
25 if args.command == "fit":
26 cmd_fit(args)
27 elif args.command == "apply":
28 cmd_apply(args)
29 elif args.command == "slice":
30 cmd_slice(args)
31 else:
32 parser.print_help()
33
34
35if __name__ == "__main__":
36 main()
6. 演示结果
拟合结果
1jacobian-lens ❯ uv run python fit_qwen4b.py fit --prompts 100
2[1/3] 加载模型 Qwen/Qwen3.5-4B ...
3Fetching 2 files: 100%|█████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████| 2/2 [00:00<00:00, 3442.19it/s]
4Download complete: : 0.00B [00:00, ?B/s] [transformers] The fast path is not available because one of the required library is not installed. Falling back to torch implementation. To install follow https://github.com/fla-org/flash-linear-attention#installation and https://github.com/Dao-AILab/causal-conv1d
5Download complete: : 0.00B [00:00, ?B/s]
6Loading weights: 100%|█████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████| 426/426 [00:00<00:00, 23670.58it/s]
7 HFLensModel(Qwen3_5ForCausalLM, n_layers=32, d_model=2560)
8[2/3] 加载 100 条 prompt ...
9 已加载 100 条
10[3/3] 拟合透镜 (dim_batch=8, max_seq_len=128) ...
11 检查点: checkpoints/qwen4b_ckpt.pt
12 层选择: 所有层(除最后一层)
13
14[ 2m46s +166.51s] fit: n_layers=32 d_model=2560, fitting 31 source layers (target=L31) on 100 prompts
15[ 12m00s +553.91s] prompt 1/100 seq_len=128 n_valid=111 554s max||J||/sqrt(d)=6.291 max_d_mean=nan
16[ 20m57s +536.89s] prompt 2/100 seq_len=128 n_valid=111 536s max||J||/sqrt(d)=4.775 max_d_mean=5.65e-01
在Apple M3 Max,开启 MPS 加速的环境下,100条提示词,拟合成功用时约 17 小时左右。
透镜应用

参考链接: