File size: 10,898 Bytes
5c6c847
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
"""预注册实验:查 ZeroPoint 的三个问题 —— 长序列变差的原因、ema 为什么更差、槽为什么塌缩。

    python3 experiments/diagnose.py

设计在跑之前写定(PREREG),跑完只追加结果。训练好的模型保存在 experiments/out/,供事后再分析。
结果:experiments/diagnose.md + results/ 存档。
"""
import os

import numpy as np

from common import CFG, HERE, ITERS, T, B, batches, prepare_data, save_result, train
from models import ZeroX
import zerocg as zc

SEEDS = [0, 1, 2]
LR = 6e-3
LONG_T, LONG_N = 4096, 32
SEGMENTS = [(0, 64), (64, 256), (256, 1024), (1024, 4096)]
OUT = os.path.join(HERE, "out")
CONDITIONS = {   # 名字 -> (ema, 训练长度, batch);每步的字数都是 2048
    "T64":     (False, 64, 32),
    "T64-ema": (True, 64, 32),
    "T256":    (False, 256, 8),
}

PREREG = """# 预注册实验:查三个问题

> 写于实验之前(2026-10-18)。跑完只在下面追加结果,不改这一节。

**背景**:ema_longrun.md 否定了"槽值无界累积"假设。无 ema 的模型在 1024~4095 字处 loss 比 64~255 字处高约 0.22;
有 ema 的高约 1.77。compare.md 里 16 个槽只有 3~5 个被写入。

## 假设和预测

**H2(训练长度)**:长序列变差是因为训练只见过 64 字;模型在更长的位置上遇到的槽状态分布是训练时没见过的。
- 预测:训练长度改为 256(每步字数不变、其余相同)后,1024~4095 与 64~255 的 loss 差 **缩小到 T64 的一半以下**。
- 不成立时会看到:差距没有缩小(< 2 × 标准差的变化)。

**H3(ema 为什么更差)**:ema 把长期槽的写入乘上 (1-λ),训练时(64 字)长期槽几乎不起作用,模型学会只用短期槽;
到了长序列,读地址仍指向短期槽,但短期槽里的内容和训练时的统计不同。
- 预测 ③a:ema 模型的**写地址**集中在半衰期短的槽上:半衰期 > 16 字的槽,写入占比 < T64 模型的一半。
- 预测 ③b:ema 模型学到的长期槽 λ 更接近 1(半衰期更长),或者长期槽的读占比在远处位置上升。
- 不成立时会看到:两组的写入分布没有明显差别。

**H4(槽塌缩)**:塌缩不是训练失败,而是写地址的 softmax 竞争让少数槽赢者通吃;在不同序列位置上"赢的槽"不同,
所以整体统计(compare.md 的 3~5 个有效槽)低估了实际用到的槽数。
- 预测 ④:按位置分段(0~63 / 64~255 / 256~1023 / 1024~4095)分别统计有效写槽数,各段的并集 > 整体有效槽数 × 1.5;
  同时各段的读/写分布之间有明显差别(任意两段的 JS 散度 > 0.1)。
- 不成立时会看到:各段的分布几乎相同,用的始终是同几个槽。

**判定**:每条按上面的数值阈值判,3 个种子均值;差别和 2 × 种子间标准差比。
"""


def long_nll(model, val_data):
    out = []
    for x, y in batches(val_data, 4, LONG_T, 7, batch=LONG_N // 4):
        with zc.no_grad():
            logits = model(x).data
        logp = logits - logits.max(-1, keepdims=True)
        logp = logp - np.log(np.exp(logp).sum(-1, keepdims=True))
        out.append(-np.take_along_axis(logp, y[..., None], -1)[..., 0])
    nll = np.concatenate(out)
    return np.array([nll[:, a:b].mean() for a, b in SEGMENTS])


def address_stats(model, val_data):
    """递推跑 4096 字,按位置分段统计每层每头的平均读/写地址分布。返回 (层, 段, H, M) 两个数组。"""
    x, _ = next(batches(val_data, 1, LONG_T, 7, batch=4))
    states = model.init_state(4)
    L = len(model.blocks)
    H, M = model.blocks[0].mix.H, model.blocks[0].mix.M
    reads = np.zeros((L, len(SEGMENTS), H, M))
    writes = np.zeros((L, len(SEGMENTS), H, M))
    for t in range(LONG_T):
        _, states = model.step(x[:, t], states, t)
        seg = next(i for i, (a, b) in enumerate(SEGMENTS) if a <= t < b)
        for l, blk in enumerate(model.blocks):
            a, w = blk.mix.last_addr
            reads[l, seg] += a.mean(0)
            writes[l, seg] += w.mean(0)
    for i, (a, b) in enumerate(SEGMENTS):
        reads[:, i] /= b - a
        writes[:, i] /= b - a
    return reads, writes


def eff(p):
    """exp(熵):有效槽数。p 最后一维是槽。"""
    p = p / p.sum(-1, keepdims=True)
    return np.exp(-(p * np.log(p + 1e-12)).sum(-1))


def js(p, q):
    p, q = p / p.sum(), q / q.sum()
    m = (p + q) / 2
    kl = lambda a, b: (a * np.log((a + 1e-12) / (b + 1e-12))).sum()  # noqa: E731
    return 0.5 * kl(p, m) + 0.5 * kl(q, m)


def half_lives(model):
    """每层每头每槽的半衰期(字)。"""
    return np.array([np.log(0.5) / np.log(b.mix.retention().data) for b in model.blocks])


def main():
    text, V, train_data, val_data = prepare_data()
    os.makedirs(OUT, exist_ok=True)
    print(PREREG, flush=True)
    res = {}
    for name, (ema, seq_len, batch) in CONDITIONS.items():
        rows = []
        for seed in SEEDS:
            np.random.seed(seed)
            model = ZeroX(V, n_slot=16, ema=ema, **CFG)
            val = train(name, model, train_data, val_data, seed, LR, seq_len=seq_len, batch=batch)
            model.eval()
            model.save(os.path.join(OUT, f"diag_{name}_s{seed}.npz"))
            ll = long_nll(model, val_data)
            reads, writes = address_stats(model, val_data)
            hl = half_lives(model)
            rows.append(dict(val=val, long=ll, reads=reads, writes=writes, hl=hl))
            print(f"  => {name} seed={seed}: val {val:.4f}; long {np.round(ll, 4)}", flush=True)
        res[name] = rows

    def mean(name, key):
        return np.mean([r[key] for r in res[name]], axis=0)

    def std(name, key):
        return np.std([r[key] for r in res[name]], axis=0)

    lines = [PREREG, "\n## 结果(实验之后写)\n",
             "### 验证 loss(64 字)和长度外推(32 条 × 4096 字;均值 ± 标准差,3 个种子)\n",
             "| 条件 | 64 字验证 | " + " | ".join(f"{a}~{b - 1}" for a, b in SEGMENTS) + " | 远−近 差 |",
             "|---|---|" + "---|" * (len(SEGMENTS) + 1)]
    gaps = {}
    for name in res:
        lm, ls = mean(name, "long"), std(name, "long")
        g = np.array([r["long"][3] - r["long"][1] for r in res[name]])
        gaps[name] = g
        lines.append(f"| {name} | {mean(name, 'val'):.4f} ± {std(name, 'val'):.4f} | " +
                     " | ".join(f"{m:.4f} ± {s:.4f}" for m, s in zip(lm, ls)) + f" | {g.mean():+.4f} ± {g.std():.4f} |")

    # ---- H2 ----
    g64, g256 = gaps["T64"], gaps["T256"]
    # T64 的远−近差必须本来就是正的(确实变差了),H2 才有意义
    h2 = g64.mean() > 0 and g256.mean() < 0.5 * g64.mean() and (g64.mean() - g256.mean()) > 2 * max(g64.std(), g256.std())

    # ---- H3:写入在长期槽上的占比;学到的半衰期 ----
    def long_slot_write_share(name):
        out = []
        for r in res[name]:
            share = []
            for l in range(r["hl"].shape[0]):
                mask = r["hl"][l] > 16                                   # (H, M)
                w = r["writes"][l, 0]                                     # 训练长度内(0~63 段)的写分布 (H, M)
                share.append((w * mask).sum() / w.sum())
            out.append(np.mean(share))
        return np.array(out)
    s64, sema = long_slot_write_share("T64"), long_slot_write_share("T64-ema")
    h3a = sema.mean() < 0.5 * s64.mean()
    hl64, hlema = mean("T64", "hl"), mean("T64-ema", "hl")
    far_read_long = {}
    for name in ("T64", "T64-ema"):
        vals = []
        for r in res[name]:
            v = []
            for l in range(r["hl"].shape[0]):
                mask = r["hl"][l] > 16
                v.append((r["reads"][l, 3] * mask).sum() / r["reads"][l, 3].sum() -
                         (r["reads"][l, 0] * mask).sum() / r["reads"][l, 0].sum())
            vals.append(np.mean(v))
        far_read_long[name] = np.array(vals)
    h3b = np.max(hlema) > np.max(hl64) * 1.5 or far_read_long["T64-ema"].mean() > far_read_long["T64"].mean() + 0.1

    lines += ["", "### H3:写入落在半衰期 > 16 字的槽上的占比(0~63 段),和学到的最长半衰期\n",
              "| 条件 | 长期槽写入占比 | 最长半衰期(字) | 远处读长期槽占比的变化 |", "|---|---|---|---|"]
    for name in ("T64", "T64-ema"):
        sh = long_slot_write_share(name)
        lines.append(f"| {name} | {sh.mean():.3f} ± {sh.std():.3f} | {np.max(mean(name, 'hl')):.0f} | "
                     f"{far_read_long[name].mean():+.3f} |")

    # ---- H4:分段的有效写槽数、并集、JS 散度 ----
    lines += ["", "### H4:槽的使用(T64,写地址;各层各头平均)\n",
              "| 段 | 有效写槽数 |", "|---|---|"]
    union_ratio, js_min = [], []
    for r in res["T64"]:
        w = r["writes"]                                     # (L, seg, H, M)
        overall = eff(w.mean(1))                            # (L, H)
        per_seg = eff(w)                                    # (L, seg, H)
        used = (w / w.sum(-1, keepdims=True)) > 1.0 / w.shape[-1]   # 用得比平均多的槽 (L, seg, H, M)
        union = used.any(1).sum(-1)                         # (L, H)
        union_ratio.append((union / overall).mean())
        js_vals = [js(w[l, i, h], w[l, j, h]) for l in range(w.shape[0]) for h in range(w.shape[2])
                   for i in range(len(SEGMENTS)) for j in range(i + 1, len(SEGMENTS))]
        js_min.append(np.max(js_vals))
    w_mean = mean("T64", "writes")
    for i, (a, b) in enumerate(SEGMENTS):
        lines.append(f"| {a}~{b - 1} | {eff(w_mean[:, i]).mean():.1f} |")
    lines.append(f"| 整体 | {eff(w_mean.mean(1)).mean():.1f} |")
    union_ratio, js_min = np.array(union_ratio), np.array(js_min)
    h4 = union_ratio.mean() > 1.5 and js_min.mean() > 0.1
    lines += [f"\n各段用到的槽的并集 ÷ 整体有效槽数 = {union_ratio.mean():.2f};各段分布之间最大的 JS 散度 = {js_min.mean():.3f}"]

    lines += ["", "## 判定(按预注册的标准)\n",
              f"- H2 训练长度:远−近差 T64 {g64.mean():+.4f} → T256 {g256.mean():+.4f} → **{'成立' if h2 else '不成立'}**",
              f"- H3a ema 写入避开长期槽:占比 {s64.mean():.3f} → {sema.mean():.3f} → **{'成立' if h3a else '不成立'}**",
              f"- H3b ema 的长期槽更长或远处更依赖长期槽 → **{'成立' if h3b else '不成立'}**",
              f"- H4 槽塌缩是分段使用不同的槽:并集比 {union_ratio.mean():.2f},JS {js_min.mean():.3f} → **{'成立' if h4 else '不成立'}**",
              "", "模型已保存在 experiments/out/diag_*.npz,可以事后再分析。"]
    save_result("diagnose", lines)


if __name__ == "__main__":
    main()