| |
| """ |
| ================================================================================ |
| additive_test.py — 检验槽位栈的 gate 读出是否超出"加性残差可表达类" |
| ================================================================================ |
| |
| 一. 要证的命题 |
| -------------- |
| 加性残差流里 consumer 读到的是 W(Σ_j α_j s_j) = Σ_j α_j·W s_j |
| 展开成分块矩阵就是 [α_0 W, α_1 W, …, α_{k-1} W] |
| → 每一行都是同一个 u 按同一组 α 复制 k 份 |
| → 全部 dff 个输出坐标共用一组混合比 α |
| |
| 槽位栈是 [W^(0), W^(1), …, W^(k-1)] 各块彼此无关 |
| → 每个输出坐标可以有自己的混合比 |
| |
| ★ 所以"加性可表达"的精确含义 = W_g 沿块维度是秩1的。 |
| 这给出一个可判定的检验, 而不是靠聚合占比去猜。 |
| |
| ★ 为何"聚合贡献占比偏离范数比例"证明不了: |
| 1) 标准残差里 W 可以挑子空间, 把某个源投到零 —— 占比与范数比例无关是 |
| 加性残差本来就能做到的事; |
| 2) 聚合占比是在全部输出坐标上平均的, 恰好把"逐坐标混合比不同"这个真正 |
| 的差别平均掉。两个模型可以聚合占比完全相同, 一个逐坐标高度分化, |
| 一个完全均一。 |
| |
| 二. 三个测试 |
| ------------ |
| T1 逐坐标混合比离散度 纯权重分析。对每个输出坐标算它对各槽的行范数占比 |
| p_m ∈ 单纯形; 加性预测所有 p_m 相同。测散度。 |
| (这是"聚合占比"的正确版本: 看离散度不看均值) |
| T2 加性投影 沿块维度 SVD, 秩r 截断后量 VAL。r=1 即"投影到加性 |
| 可表达类"。★必须配同 Frobenius 幅度的随机扰动对照, |
| 否则分不清"结构被破坏"与"权重被改动太多"。 |
| T3 块置换 各块读的槽做错排。加性下各块只差标量, 置换近乎无损。 |
| |
| 三. 判读 |
| -------- |
| T2 是决定性的: 若 VAL(秩1) ≫ VAL(同幅度随机扰动), 则被破坏的是结构本身, |
| 加性残差确实表达不了当前的读出方式。若两者相当, 说明这些层的 gate 实际上 |
| 停留在加性可表达的范围内 —— 那是个真实的负结果, 该认。 |
| |
| T1/T3 是支持性证据: 它们能证明"分化了", 但不能单独证明"加性做不到"。 |
| |
| 四. 用法 |
| -------- |
| python additive_test.py --ckpt /workspace/data/d1280_h10_l10_out/latest.pt \ |
| --model delta_d1280_h10_l10.py --batches 20 |
| SMOKE=1 python additive_test.py --model delta_d1280_h10_l10.py --batches 2 |
| (无 --ckpt 时用随机初始化, 只验证脚本通路; 随机初始化下各块本就正交, |
| 结论无意义) |
| ================================================================================ |
| """ |
| import argparse, importlib.util, math, os, sys |
| import numpy as np |
| import torch |
|
|
|
|
| def load_module(path): |
| spec = importlib.util.spec_from_file_location("deltamod", path) |
| m = importlib.util.module_from_spec(spec) |
| spec.loader.exec_module(m) |
| return m |
|
|
|
|
| |
| def coordwise_mixing(W, k, d): |
| """W: (dff, k*d)。返回 (每坐标混合比矩阵 (dff,k), 平均占比 (k,), 离散度)。 |
| |
| 对输出坐标 m, 它对槽 j 的读出强度 = 该行在块 j 上的 L2 范数。 |
| 归一化成单纯形上的点 p_m。加性残差预测: 所有 p_m 完全相同。 |
| |
| 离散度用平均总变差距离 (到全局均值) 表示, 单位 pp: |
| disp = mean_m (1/2)·Σ_j |p_m[j] - p̄[j]| × 100 |
| 0pp = 所有坐标混合比一致 (加性形态); 越大 = 逐坐标越分化。 |
| """ |
| B = W.reshape(W.shape[0], k, d) |
| s = B.norm(dim=-1) |
| p = s / s.sum(-1, keepdim=True).clamp_min(1e-12) |
| pbar = p.mean(0) |
| disp = (0.5 * (p - pbar).abs().sum(-1)).mean().item() * 100 |
| return p, pbar, disp |
|
|
|
|
| |
| def block_spectrum(W, k, d): |
| """把 k 个块各自拉直成向量, 求 k×k Gram 的特征谱。 |
| |
| 加性可表达 ⟺ 这些向量共线 ⟺ 谱只有一个非零值。 |
| 返回 (特征值降序, 秩1能量占比)。 |
| 随机初始化基线: 各块独立随机 → 谱近似平坦 → 秩1占比 ≈ 1/k。 |
| """ |
| V = W.reshape(W.shape[0], k, d).permute(1, 0, 2).reshape(k, -1).double() |
| G = V @ V.T |
| ev = torch.linalg.eigvalsh(G).flip(0).clamp_min(0) |
| return ev, (ev[0] / ev.sum().clamp_min(1e-30)).item() |
|
|
|
|
| def matrix_rank_matched(W, eps): |
| """沿【普通矩阵秩】截断, 挑 rank 使 ‖ΔW‖_F 最接近 eps。 |
| |
| ★ 这是比"同幅度随机扰动"严得多的对照。各向同性噪声在 dff×(k·d) 的大空间 |
| 里落到模型实际使用的子空间上的分量极小 —— 它匹配了幅度, 却没匹配"命中 |
| 率", 于是任何结构化删除都天然更疼, 与块结构无关。 |
| 本对照同样是结构化低秩删除、同样的 Frobenius 幅度, 唯一区别是沿哪个轴, |
| 从而把"块维度"这一个因素单独隔离出来。 |
| """ |
| U, S, Vh = torch.linalg.svd(W.double(), full_matrices=False) |
| tail = torch.flip(torch.cumsum(torch.flip(S**2, [0]), 0), [0]).sqrt() |
| r = int((tail - eps).abs().argmin().item()) |
| r = max(1, min(r, len(S) - 1)) |
| Wr = (U[:, :r] * S[:r]) @ Vh[:r] |
| return Wr.to(W.dtype), r, tail[r].item() |
|
|
|
|
| def random_subspace_keep(W, frac_keep, gen): |
| """对照D: 只保留输入空间里一个【随机】子空间, 丢掉其余。 |
| |
| 为何需要它: 对照B(普通矩阵秩截断)砍掉的是奇异值最小的方向 —— 同等能量下 |
| 最良性的一种删除, 只给出损伤下界。对照D 同样是结构化删除、同样的能量, |
| 但方向随机挑, 不偏袒"最没用的"。它才是"一般性结构化损伤"的公允基线。 |
| |
| W P, P = G(GᵀG)⁻¹Gᵀ 投影到随机 q 维子空间, q = frac_keep · (k·d)。 |
| 各向同性下期望保留能量 ≈ q/(k·d), 故 frac_keep 直接取"块维秩1 保留的能量比"。 |
| """ |
| kd = W.shape[1] |
| q = max(1, min(kd - 1, int(round(frac_keep * kd)))) |
| G = torch.randn(kd, q, generator=gen).to(W.device).float() |
| A = W.float() @ G |
| M = G.T @ G |
| B = torch.linalg.solve(M + 1e-6 * torch.eye(q, device=W.device), A.T).T |
| return (B @ G.T).to(W.dtype) |
|
|
|
|
| def rotated_block_rank1(W, k, d, gen): |
| """对照C: 每块输入空间先乘一个随机正交阵 R_j, 做块维秩1, 再转回来。 |
| |
| 结果形如 W^(j) = α_j · U R_jᵀ |
| · 仍是"秩1 + 全局标量 α" —— 逐坐标幅度混合比与加性残差同样受限 |
| · 但每个槽被读的方向不同 —— 加性残差表达不了这一点 |
| |
| 于是它把两个自由度拆开了: |
| 对照C 也很疼 ⇒ 关键自由度是"逐坐标幅度混合比" |
| 对照C 良性 ⇒ 关键自由度是"从各源读不同方向", 与幅度分配无关 |
| |
| ⚠ 实测判定本对照【无效】, 保留仅供复现: 随机旋转等于"先把各槽内容打乱 |
| 再做加性合并", 是"加性残差 + 输入被搅烂", 严格劣于加性本身, 拿它对照 |
| 证明不了任何事。更根本的问题是"加性可表达" ⟺ "块维秩1" 是同一个集合 |
| (V 秩1 ⟹ W^(j)=α_j U 强制成立), 无法固定秩而只改加性与否。 |
| 公允对照请用 random_subspace_keep (对照D)。 |
| """ |
| dff = W.shape[0] |
| B = W.reshape(dff, k, d) |
| Rs, Bt = [], torch.empty_like(B) |
| for j in range(k): |
| Q, _ = torch.linalg.qr(torch.randn(d, d, generator=gen).to(W.device)) |
| Rs.append(Q.to(W.dtype)) |
| Bt[:, j, :] = B[:, j, :] @ Rs[j] |
| B1 = rank_truncate(Bt.reshape(dff, k * d), k, d, 1).reshape(dff, k, d) |
| out = torch.empty_like(B) |
| for j in range(k): |
| out[:, j, :] = B1[:, j, :] @ Rs[j].T |
| return out.reshape(dff, k * d) |
|
|
|
|
| def rank_truncate(W, k, d, r): |
| """沿块维度做秩 r 截断。r=1 即投影到加性残差可表达类。""" |
| dff = W.shape[0] |
| V = W.reshape(dff, k, d).permute(1, 0, 2).reshape(k, -1).double() |
| U, S, Vh = torch.linalg.svd(V, full_matrices=False) |
| Vr = (U[:, :r] * S[:r]) @ Vh[:r] |
| return Vr.reshape(k, dff, d).permute(1, 0, 2).reshape(dff, k * d).to(W.dtype) |
|
|
|
|
| |
| @torch.no_grad() |
| def val_loss(m, model, loader, nb): |
| model.eval() |
| loader.reset() |
| t = 0.0 |
| with m.amp_ctx(): |
| for _ in range(nb): |
| x, y = loader.next_batch(m.device) |
| t += model(x, y)[1].item() |
| return t / nb |
|
|
|
|
| def main(): |
| ap = argparse.ArgumentParser() |
| ap.add_argument("--model", default="delta_d1280_h10_l10.py") |
| ap.add_argument("--ckpt", default=None) |
| ap.add_argument("--batches", type=int, default=20) |
| ap.add_argument("--ranks", default="1,2,3") |
| ap.add_argument("--seed", type=int, default=0) |
| ap.add_argument("--per-layer", action="store_true", |
| help="每次只对一层做块维秩1截断, 跳出饱和区并做逐层归因") |
| a = ap.parse_args() |
|
|
| m = load_module(a.model) |
| model = m.DeltaLM().to(m.device) |
| tag = "随机初始化 (仅验证通路, 结论无意义)" |
| if a.ckpt: |
| ck = torch.load(a.ckpt, map_location=m.device, weights_only=False) |
| model.load_state_dict(ck["model"]) |
| tag = f"{a.ckpt} step={ck.get('step','?')}" |
| model.eval() |
|
|
| if m.SMOKE: |
| loader = m.SyntheticLoader(m.MICRO_BATCH, m.SEQ_LEN, 2) |
| else: |
| loader = m.ShardLoader(m.DATA_DIR, "val", m.MICRO_BATCH, m.SEQ_LEN, |
| insert_p=0.0) |
|
|
| L, d, dff = model.L, model.d, m.DFF_ |
| print("=" * 100) |
| print(f" 加性可表达性检验 {tag}") |
| print(f" d={d} dff={dff} L={L} VAL 用 {a.batches} 个 batch") |
| print("=" * 100) |
|
|
| base = val_loss(m, model, loader, a.batches) |
| print(f"\n 基线 VAL = {base:.4f}\n") |
|
|
| |
| print(" 【T1】逐坐标混合比离散度 & 块维谱") |
| print(" " + "-" * 96) |
| print(f" {'层':<5}{'k':<4}{'离散度(pp)':<13}{'秩1能量占比':<14}" |
| f"{'随机基线1/k':<13}{'谱(前4, 归一化)'}") |
| for i in range(1, L): |
| W = model.ffns[i].Wg.weight.data.float() |
| k = i + 1 |
| _, pbar, disp = coordwise_mixing(W, k, d) |
| ev, r1 = block_spectrum(W, k, d) |
| evn = (ev / ev.sum()).tolist()[:4] |
| print(f" f{i+1:<4}{k:<4}{disp:<13.1f}{r1:<14.4f}{1.0/k:<13.4f}" |
| + " ".join(f"{v:.3f}" for v in evn)) |
| print("\n 离散度 0pp = 所有输出坐标共用一组混合比 = 加性形态") |
| print(" 秩1能量占比 →1 = 各块共线 = 加性可表达; →1/k = 各块正交") |
| print(" ★ 随机初始化时秩1占比本就≈1/k, 故该列低不能单独当作学到了东西") |
|
|
| |
| ranks = [int(r) for r in a.ranks.split(",")] |
| print(f"\n 【T2】加性投影 (沿块维秩r截断) + 同幅度随机扰动对照") |
| print(" " + "-" * 96) |
| orig = {i: model.ffns[i].Wg.weight.data.clone() for i in range(L)} |
| g = torch.Generator(device="cpu").manual_seed(a.seed) |
|
|
| for r in ranks: |
| |
| dfro = 0.0 |
| for i in range(1, L): |
| W = orig[i].float() |
| Wr = rank_truncate(W, i + 1, d, r) |
| dfro += (Wr - W).pow(2).sum().item() |
| model.ffns[i].Wg.weight.data.copy_(Wr.to(orig[i].dtype)) |
| lo = val_loss(m, model, loader, a.batches) |
| for i in range(L): |
| model.ffns[i].Wg.weight.data.copy_(orig[i]) |
|
|
| |
| for i in range(1, L): |
| W = orig[i].float() |
| Wr = rank_truncate(W, i + 1, d, r) |
| eps = (Wr - W).norm() |
| n = torch.randn(W.shape, generator=g).to(W.device) |
| n = n / n.norm() * eps |
| model.ffns[i].Wg.weight.data.copy_((W + n).to(orig[i].dtype)) |
| lc = val_loss(m, model, loader, a.batches) |
| for i in range(L): |
| model.ffns[i].Wg.weight.data.copy_(orig[i]) |
|
|
| |
| rs = [] |
| for i in range(1, L): |
| W = orig[i].float() |
| eps = (rank_truncate(W, i + 1, d, r) - W).norm() |
| Wm, rr, _ = matrix_rank_matched(W, eps) |
| rs.append(rr) |
| model.ffns[i].Wg.weight.data.copy_(Wm.to(orig[i].dtype)) |
| lm = val_loss(m, model, loader, a.batches) |
| for i in range(L): |
| model.ffns[i].Wg.weight.data.copy_(orig[i]) |
|
|
| print(f" 块维秩{r}: VAL {lo:.4f} (Δ{lo-base:+.4f}) ‖ΔW‖_F={math.sqrt(dfro):.1f}") |
| print(f" 对照A 同幅度随机扰动 VAL {lc:.4f} (Δ{lc-base:+.4f}) [弱对照]") |
| print(f" 对照B 同幅度普通矩阵秩截断 VAL {lm:.4f} (Δ{lm-base:+.4f}) " |
| f"[严对照, matrix rank≈{int(np.mean(rs))}]") |
| if lo > lm + 0.05: |
| print(f" ⇒ 块维删除比同幅度普通秩删除更疼 (Δ差 {lo-lm:+.3f}), " |
| f"块结构被单独隔离出来了") |
| else: |
| print(f" ⇒ 两者相当, 损伤不能归因于块结构本身") |
| print("\n ★ 判据以【对照B】为准: 块维秩1 显著疼于同幅度普通矩阵秩截断") |
| print(" ⇒ 损伤来自块结构本身, 加性残差表达不了当前读出方式。") |
| print(" ★ 注意各截断若都落在'已损坏'区间(VAL≈ln(vocab)), 秩阶梯之间的排序") |
| print(" 是噪声, 只有'秩1 vs 对照'这一比较有效。") |
| print(" ★ 本测试证明的是: 该已训练模型重度使用了加性表达不了的成分。它") |
| print(" 没有证明'原生训练一个加性模型也达不到同样 loss' —— 后者需要重训") |
| print(" tied-block 基线, 且有严重参数量混淆。这是不重训能拿到的最强证据。") |
|
|
| |
| if a.per_layer: |
| print(f"\n 【T2b】逐层块维秩1截断 (一次只动一层, 跳出饱和区)") |
| print(" " + "-" * 96) |
| print(f" {'层':<6}{'k':<4}{'块维秩1(加性)':<16}{'对照B 最良性删除':<20}" |
| f"{'对照D 随机子空间':<20}{'‖Δ‖块':<9}{'‖Δ‖D':<9}{'保留能量'}") |
| rows = [] |
| for i in range(1, L): |
| W = orig[i].float() |
| Wr = rank_truncate(W, i + 1, d, 1) |
| eps = (Wr - W).norm() |
| model.ffns[i].Wg.weight.data.copy_(Wr.to(orig[i].dtype)) |
| lb = val_loss(m, model, loader, a.batches) |
| Wm, rr, _ = matrix_rank_matched(W, eps) |
| model.ffns[i].Wg.weight.data.copy_(Wm.to(orig[i].dtype)) |
| lc2 = val_loss(m, model, loader, a.batches) |
| model.ffns[i].Wg.weight.data.copy_(orig[i]) |
| fk = 1.0 - (eps / W.norm()) ** 2 |
| Wd_ = random_subspace_keep(W, float(fk), g) |
| epsd = (Wd_ - W).norm().item() |
| model.ffns[i].Wg.weight.data.copy_(Wd_.to(orig[i].dtype)) |
| lc4 = val_loss(m, model, loader, a.batches) |
| model.ffns[i].Wg.weight.data.copy_(orig[i]) |
| rows.append((i + 1, lb - base, lc2 - base, lc4 - base)) |
| print(f" f{i+1:<5}{i+1:<4}{lb-base:<16.4f}{lc2-base:<20.4f}" |
| f"{lc4-base:<20.4f}{eps.item():<9.1f}{epsd:<9.1f}{fk*100:.0f}%") |
| bb = np.array([r[1] for r in rows]); dd = np.array([r[3] for r in rows]) |
| print(f"\n 合计 ΔVAL: 块维秩1(加性) {bb.sum():.2f} | 对照B(最良性) " |
| f"{np.array([r[2] for r in rows]).sum():.2f} | 对照D(随机子空间) {dd.sum():.2f}") |
| print("\n ★ 判据看【对照D】: 同样丢掉同等能量, 但丢的方向随机挑。") |
| if bb.sum() > 1.5 * dd.sum(): |
| print(" 块维秩1 明显更疼 ⇒ 损伤不是'丢了这么多能量'的一般后果,") |
| print(" 而是特定于'塌成加性形态'这个结构。结论成立。") |
| elif dd.sum() > 1.5 * bb.sum(): |
| print(" 随机子空间反而更疼 ⇒ 加性形态其实保住了不少功能,") |
| print(" 之前对照B 得出的差距是假象。结论需推翻。") |
| else: |
| print(" 两者相当 ⇒ 损伤主要来自'丢了这么多能量', 与是否加性无关。") |
| print(" 这是真实的负结果, 该认。") |
| print(" ★ 对照B 砍的是奇异值最小的方向, 是同能量下最良性的删除, 只给") |
| print(" 损伤下界; 与它的差距被系统性放大, 倍数别当真。") |
|
|
| |
| print(f"\n 【T3】块置换 (错排, 各块改读别的槽)") |
| print(" " + "-" * 96) |
| rng = np.random.default_rng(a.seed) |
| for trial in range(3): |
| for i in range(1, L): |
| k = i + 1 |
| while True: |
| perm = rng.permutation(k) |
| if not (perm == np.arange(k)).any(): |
| break |
| W = orig[i].reshape(dff, k, d) |
| model.ffns[i].Wg.weight.data.copy_( |
| W[:, torch.from_numpy(perm.copy()), :].reshape(dff, k * d)) |
| lp = val_loss(m, model, loader, a.batches) |
| for i in range(L): |
| model.ffns[i].Wg.weight.data.copy_(orig[i]) |
| print(f" 错排 #{trial+1}: VAL {lp:.4f} (Δ{lp-base:+.4f})") |
| print("\n ★ 加性形态下各块只差一个标量, 置换应近乎无损。VAL 大幅上升") |
| print(" ⇒ 每块已专门化到自己那个槽。(支持性证据, 不能单独证明加性做不到)") |
| print("=" * 100) |
|
|
|
|
| if __name__ == "__main__": |
| main() |
|
|