Qwen3-1.7B-Base · Qwen-Scope 第 14 层 SAE · IHC 6 子类 · 2026-10-30

Implicit Hate × SAE 笔记

根据两页手写笔记整理。原笔记内容照原样保留,绿色底色的“补充”是后来加上的修正和说明。

总览:从推文到 6 个子类

Qwen3 28 个 Transformer 块 (代码里编号 0–27) 取第 14 块的输出 xₜ(残差流) 编码器 Top-50 + ReLU 解码器 W_dec·a + b_dec 推文的 t 个 token 每行 2048 维(稠密) 每行 32,768 维,最多 50 个非零(稀疏) x̂ₜ:2048 维 只用于训练 SAE、 算 FVU / ΔCE max-pool:每一列取 t 个 token 里的最大值 z 1 × 32,768 示例推文有 479 个非零 缩放:z̃ᵢ = zᵢ / 这一列在训练集里的最大值 逻辑回归(探针) W:6 × 32,768,b:6 score_c = b_c + Σᵢ W_c,i · z̃ᵢ 5,012 条带标签的训练推文 标签只在这一步用到 训练 softmax 预测:威胁 threatening P = 0.989
深绿色箭头是分类时走的路:编码器 → max-pool → 缩放 → 逻辑回归 → softmax。灰色虚线的解码器分支在分类时不用,只在训练 SAE 和算 FVU、ΔCE 时用到。层号按代码从 0 开始,所以“第 14 块”从 1 数起是第 15 块。图里的概率是测试集里第 5490 条推文的真实结果。
00

实验设置

模型
基座模型Qwen3-1.7B-Base:28 个 Transformer 块,残差维度 2048,词表 151,936,bf16 推理;从 ModelScope 镜像下载
SAE(原版)Qwen-Scope SAE-Res-Qwen3-1.7B-Base-W32K-L0_50 的第 14 层:TopK(k = 50),32,768 个 latent,接在第 14 块输出(resid_post)上;编码时不减 b_dec,解码器每列是单位向量
SAE(自己微调)ft-L14-1M 和 ft-L14-20M:从 Qwen-Scope 初始化,在 FineWeb 33% / Civil Comments 45% / Reddit tldr-17 22% 的混合语料上继续训练 1M / 20M token;Adam,lr 3e-5,bf16 autocast,每步 4,096 个 token,AuxK 系数 1/32;20M 用时 89 分钟
数据
分类数据IHC(HF SALT-NLP/ImplicitHate),6,266 条隐性仇恨推文,6 个子类;每条推文单独作为一个序列输入模型,最长 128 个 token,丢掉第 0 个 token
留出集FineWeb、Civil Comments、Reddit 的留出部分和 IHC 各取 500 条,最长 256 个 token,用来算 FVU 和 ΔCE
分类
特征第 14 层 SAE latent,对 token 做 max-pool → 32,768 维,再用 MaxAbsScaler 缩放到 0~1
探针scikit-learn LogisticRegression(多分类,lbfgs,max_iter 3000),L2 正则,C 在 0.002–0.2 之间扫 6 个值,最好的是 0.05;class_weight="balanced"
对照组同一层的原始残差:推文所有 token 的 xₜ 取平均(不含第 0 个)→ 2048 维,StandardScaler,C 在 0.0002–0.02 之间扫,最好的是 0.001;另有 max-pool 版和 TF-IDF 基线
评估分层 5 折交叉验证 × 3 次重复,共 15 次,看 macro-F1;不同 SAE 之间用同一组折逐折配对比较,t 值用 Nadeau–Bengio 校正
环境
硬件本机 MacBook,M5 Pro,24GB 统一内存,15 个 CPU 核心;模型和 SAE 跑在 GPU(MPS)上,探针跑在 CPU 上
软件Python 3.12,PyTorch 2.14.1,transformers 5.21.0,scikit-learn 1.9.1
代码~/research/implicit-hate-sae/scripts/:sae_features.py(编码 + 探针)、compare_probes.py(对比)、eval_sae.py(FVU / ΔCE)、train_sae.py(训练 SAE)
01

三个指标,一句话

02

实验与数据

补充 6 · 几处细节
  • HF 上的 IHC 原本有 6,346 条,去掉 “other” 类的 80 条后剩 6,266 条。
  • 随机乱猜的 macro-F1 只是“约” 1/6。各类样本数不一样(见下表),所以不严格等于 1/6。
子类中文条数
white_grievance白人受害叙事1,538
incitement煽动1,269
stereotypical刻板印象1,133
inferiority贬低为低等863
irony讽刺797
threatening威胁666
03

SAE:编码与重建

pret = W_enc · xt + b_enc // 32768 维
at = ReLU(pret 里只保留最大的 50 个,其余置 0)

顺序是先挑出最大的 50 个,再对这 50 个做 ReLU;其中如果有负数会被置 0,所以严格说是“最多 50 个”。先做 ReLU 再挑最大的 50 个,结果完全一样。

补充 1 · 缺了解码器

pre = W_enc·x + b_enc 算出的是 32,768 个分数,还不是 x̂。完整过程还要加一步:

x̂t = W_dec · at + b_dec // 从 32768 维变回 2048 维

这样才有“输入 x → 输出 x̂”。另外,分类只用到 a,完全不用解码器;解码器只用于训练 SAE 和计算 FVU / ΔCE。

04

FVU:SAE 还原激活时丢了多少信息

FVU = Σ‖x − x̂‖² / Σ‖x − x̄‖²

拖动滑块:FVU 怎么算出来

75%
到平均值的距离(分母) 到重建点的距离(分子) 真实激活 x SAE 重建 x̂ 平均值 x̄
FVU(越低越好)0.06
解释掉的方差 1 − FVU94%
示意图把 2048 维压成了 2 维。“重建质量”表示每个重建点从平均值往真实点走了多远。勾上 massive activation 后会多一个离群很远、又重建得很差的点:误差是平方后再求和,所以这一个点就能把 FVU 拉高一大截。项目里真实发生过这件事:IHC 的 13.3 万个 token 里有 7 个范数约为中位数 70 倍的 token,单靠它们就把 FVU 从 0.29 拉到 0.43。现在统计 FVU 时会跳过范数超过 batch 中位数 10 倍的 token。
补充 6 · x̄ 不是全局的

x̄ 是在每个评估集上分别算的,比如 IHC 一个、FineWeb 一个,不是所有数据共用一个。第 0 个 token(attention sink)和范数异常的 token 不参与计算。

留出集上的 FVU(第 14 层,每组 500 条)

留出集Qwen-Scope微调 1M微调 20M
FineWeb(通用文本)0.2520.2470.238
Civil Comments0.2710.2560.233
Reddit0.2800.2680.250
IHC 推文(训练中未见)0.2890.2710.243
Qwen-Scope 在 IHC 上还原了约 71% 的方差,微调 20M token 之后约 76%。
补充 7 · FVU 只在稀疏度相同时可比

FVU 单看并不是越低越好。不限制每个 token 能用多少个 latent(比如 k ≥ 2048),SAE 可以学成恒等映射,FVU ≈ 0,但每个 latent 就是原始方向的翻版,没有可解释性。所以只有在稀疏度相同的前提下比较 FVU 才有意义。这里所有 SAE 都是 k = 50、32,768 个 latent,而且都在没参与训练的留出集上测。

05

macro-F1

  1. 对每个子类分别算 F1。以 threatening 这一类为例:假设有 100 条 threatening,分类器把 80 条判为 threatening,其中 60 条是真的。
    • precision:80 条里有 60 条是真的,60 / 80 = 0.75
    • recall:100 条真的里找回 60 条,60 / 100 = 0.60
    • F1:precision 和 recall 的调和平均,2 × 0.75 × 0.60 / (0.75 + 0.60) ≈ 0.67,两者都高才高。
  2. 算平均。6 个子类的 F1 直接平均,每类权重一样,这就是 “macro”。样本多的大类不会盖过小类。
补充 4 · 对照组

原始残差基线就是跳过 SAE,直接把 17 个 xt 取平均,得到 2048 维向量,再训练同样的逻辑回归,结果是 0.633。SAE 版高出的 1.3 个点,是 SAE 带来的;15 折里 15 折都更好。

方法macro-F1
随机乱猜≈ 0.17
TF-IDF(只看词频)0.546
原始残差 mean-pool(对照组)0.633
Qwen-Scope SAE,max-pool0.6465
微调 1M SAE0.6464
微调 20M SAE0.6461

5 折 × 3 次重复的平均,每种特征各自选最好的 C。微调后的 SAE 重建更好,但分类成绩和原版一样。

06

ΔCE:从模型的角度看还原得好不好

ΔCE = CE_spliced − CE_clean CE = −ln p(y)
补充 5 · CE_spliced 更精确的说法

在第 14 块的输出处把每个 x 换成 x̂,再让后面第 15–27 块接着算到输出,然后在和 CE_clean 同样的位置上求平均。第 0 个 token 和那几个范数异常的 token 不替换,保留原值。ΔCE = 0.108 相当于困惑度上升约 11%(e0.108 ≈ 1.11)。

07

分类流水线

补充 2 · 整条流水线

SAE 本身不分类,也从没见过标签。判断推文属于哪一类的,是另外在 SAE 输出上训练的逻辑回归(探针)。

① 编码:at = ReLU(Top-50(W_enc · xt + b_enc)) // 每个 token:1 × 32768
② max-pool:zi = maxt at,i // 整条推文合成一个向量:1 × 32768,示例推文 479 个非零
③ 缩放到 0~1:z̃i = zi / maxi // max_i 是第 i 列在训练集里的最大值
④ 打分:scorec = bc + Σi Wc,i · z̃i // c 是 6 个子类之一
⑤ 概率:P(c) = softmax(score)c // 6 个概率加起来等于 1,取最大的那一类
08

训练探针

训练时 minimize: ½ · Σ Wc,i² + C · Σn sn · CEn
sn = 训练推文总条数 / (6 × 第 n 条推文所属类 c 的推文数) // 原因:6 类一样重要,但总数不同
补充 3 · 评估方式
  • 5 折交叉验证:每次用 4/5 带标签的推文(这一折 5,012 条)训练逻辑回归,在剩下 1/5 没见过的推文(1,254 条)上算 macro-F1。
  • 换 3 种划分再重复,共 15 次。
  • 0.6465 是这 15 次的平均。C 也是在这 15 次上选的,所以结果略偏乐观;写论文时要用嵌套交叉验证或单独的测试集。

C 的作用:在同一折上换不同的 C 重新训练

第一项希望所有权重都小,第二项希望把训练集分对,C 决定第二项的分量。C 越小,权重被压得越小。W 有 6 × 32,768 = 196,608 个数,训练集只有 5,012 条;有 2,426 个 latent 只在 1 条训练推文里激活过,给它一个大权重就能把那条推文的标签“背”下来。

训练集(5,012 条,模型见过)测试集(1,254 条,模型没见过)
第 1 折。C 太小会欠拟合,测试集只有 0.563;C ≥ 5 时训练集达到 1.0,等于全背下来了,测试集反而掉到 0.617。‖W‖ 是全部权重的平方和开根号。不管 C 取多少,非零权重都是 24,159 个:L2 只把权重压小,不会压到 0。

sn 的作用:class_weight = "balanced"

子类训练条数权重 s不加权:召回加权:召回
white_grievance1,2300.6790.6690.633
incitement1,0150.8230.6540.654
stereotypical9060.9220.5810.586
inferiority6901.2110.5320.543
irony6381.3090.6540.660
threatening5331.5670.7220.752
macro-F10.63960.6353

第 1 折,C = 0.05。分错一条 threatening 的代价是分错一条 white_grievance 的 2.3 倍,效果是把一部分推文从大类挪到小类。但这份数据只是轻度不平衡,macro-F1 几乎没变;以后做“仇恨 vs 非仇恨”时两类可能差好几倍,它会更重要。

为什么有 24,159 个非零权重,而 z 只有 479 个非零

W 是所有推文共用的投票表,z 是每条推文自己的。一条推文打分时只用到它自己那 479 列,其余列都乘以 0。5,012 条训练推文合起来点亮过 24,159 个不同的 latent,每个都需要一个权重;剩下 8,609 个在训练集里一次都没激活过,梯度永远是 0,所以权重一直是 0。

在本机上跑了多久

所有实验都在这台 Mac 上跑:M5 Pro,24GB 统一内存,15 个 CPU 核心,GPU 通过 PyTorch 的 MPS 调用。Qwen3-1.7B 跑到第 14 层约 8–10k tok/s;32K SAE 的训练步 bf16 约 15k tok/s,fp32 约 6k tok/s。

步骤硬件用时
训练 SAE(从 Qwen-Scope 微调 20M token)GPU89 分钟
留出集评估(3 个 SAE × 4 组 × 500 条)GPU约 5 分钟
编码 6,266 条推文,并跑 4 个探针GPU + CPU约 45 秒 / 每个 SAE
对比实验:450 次探针训练CPU,5 个进程约 3 分钟
训练一次探针(5,012 条 × 32,768 维,稀疏)CPU0.5–2.4 秒

探针快,是因为训练矩阵是稀疏的(每条推文平均 611 个非零,总共约 300 万个),逻辑回归又是凸优化,几十次迭代就收敛。瓶颈始终在 GPU 上:生成模型激活和训练 SAE。从头训练 1 亿 token 的 SAE 在本机约 6 小时。