File size: 12,458 Bytes
2f06744
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
#!/usr/bin/env python3
"""Real-data 259K retrieval at the 0.999 level (test_realtext.py).

v3 design (after two measured failures, documented below):

  * d_model=64 -- the PROVEN architecture.  A d_model=128 rebuild failed to
    transition twice (uniform keys: frozen at h@64 0.3516 for 3.4K steps; the
    faithful Zipf recipe: frozen at 0.2422 for 3.6K steps -- while the d=64
    original transitioned in <400 steps).  Width changes re-roll the
    transition dice; we don't take that gamble.
  * warm-started from checkpoints/realtext_base.pt (our own 0.875 model) -- no
    transition needed at all; this run repairs and extends it.
  * kernel-D spread loss at the TRUE key-token positions (the 0.875 run applied
    it at `0::2` of the whole sequence -- mostly filler -- so it never acted).
  * growth to the full 3,548-pair protocol (the old run grew only to 512 pairs,
    then evaluated at 3,548 -- out of distribution).
  * the address scale stays at 181 (the checkpoint's final scale; its
    degradation came from hitting 181 in the last ~150 steps of training, not
    from the scale itself -- we re-stabilise at 181 for thousands of steps).
  * long cosine polish at the full pair count, then an alignment phase at the
    exact eval distribution (filler=70, Q=2,048, depth-stratified, T~=259K).

Protocol identical to experiments/eval_realdata.py: wikitext-103 slice,
VS=4,094 word vocab, key->bigram-successor pairs (count>=4), real-text filler.
"""
from __future__ import annotations

import json
import os
import sys
import time

import torch
import torch.nn.functional as F

HERE = os.path.dirname(os.path.abspath(__file__))
ROOT = os.path.join(HERE, "..")
sys.path.insert(0, os.path.join(ROOT, "src"))

from mulattice.protocol import RealData, logits_at, stratified_qpos
from mulattice.models import MQARModel, MixerCfg

DEVICE = os.environ.get("DEVICE", "cuda")
CORPUS = os.path.join(ROOT, "data", "wikitext103_slice.txt")
CKPT_WARM = os.environ.get("WARM", os.path.join(ROOT, "checkpoints", "realtext_base.pt"))
CKPT_OUT = os.path.join(ROOT, "checkpoints", "realtext.pt")
HEARTBEAT = os.path.join(ROOT, "logs", "realtext.heartbeat")
HISTORY = os.path.join(ROOT, "results", "realtext_training.json")

D_MODEL = 64
SCALE = 181.0
P_EVAL = 3548          # protocol pair cap (mining yields 3,804; eval_realdata caps at 3,548)
F_EVAL = 70            # 3548*72 + 2*2048 = 259,552 ~= 259K
Q_EVAL = 2048
TOKEN_BUDGET = 16384

RESTAB = [(64, 400), (128, 400), (256, 800)]          # re-stabilise the warm model
GROW = [(512, 1500), (1024, 2000), (2048, 2500), (3548, 2500)]
POLISH_STEPS = 1500
ALIGN_STEPS = 60
if os.environ.get("SMOKE"):            # fast local validation of the exact code path
    RESTAB = [(8, 3), (16, 3)]
    GROW = [(32, 3), (64, 3)]
    POLISH_STEPS = 3
    ALIGN_STEPS = 2


def beat(step, txt=""):
    with open(HEARTBEAT, "w") as f:
        f.write(f"{step} {txt}\n")


def spread_loss_real(m, tokens, key_pos, scale, gate_subsample=2048):
    from mulattice.protocol import _gated_spread
    mx = m.blocks[0].mixer
    xc = mx.conv(m.blocks[0].n1(m.emb(tokens)))
    kaddr = scale * torch.tanh(mx.Wa(xc))
    ar = torch.arange(tokens.shape[0], device=tokens.device).unsqueeze(1)
    addr = kaddr[ar, key_pos]
    B, N, _ = addr.shape
    if N > gate_subsample:
        addr = addr[:, torch.randperm(N, device=addr.device)[:gate_subsample]]
    return _gated_spread(addr, addr, scale)


def main():
    rd = RealData(CORPUS)
    V = rd.V
    print(f"corpus: {rd.n_pairs} kv-pairs (cap {P_EVAL}), filler stream {len(rd.FILLER)}",
          flush=True)
    dev = DEVICE
    torch.manual_seed(0)
    m = MQARModel(V, kind="mulattice", n_layers=1,
                  cfg=MixerCfg(d_model=D_MODEL, n_heads=2, d_k=D_MODEL // 2, d_v=D_MODEL // 2,
                               addr_scale=SCALE, learn_addr_scale=False,
                               bound_address=True, retention_init=9.0)).to(dev)
    if os.path.exists(CKPT_WARM):
        ck = torch.load(CKPT_WARM, map_location="cpu", weights_only=False)
        m.load_state_dict(ck["model"])
        print(f"warm-start from {CKPT_WARM}", flush=True)
    else:
        print(f"WARNING: {CKPT_WARM} missing, training from scratch", flush=True)
    mx = m.blocks[0].mixer
    mx.scan.crossover = 1e9            # cell-parallel from the start (T is large)
    opt = torch.optim.AdamW(m.parameters(), lr=1e-3)
    g = torch.Generator().manual_seed(1)
    step = 0
    t0 = time.time()
    history = []

    def held_out(pairs, filler, Q=64, B=2, seed=991, zipf=False):
        m.eval()
        c = t = 0
        with torch.no_grad():
            for _ in range(B):
                b = rd.batch(pairs, filler, Q, seed=seed + _, zipf=zipf)
                pred = logits_at(m, b["tokens"].to(dev), b["ans_pos"].to(dev)).argmax(-1).cpu()
                c += int((pred == b["gold"]).sum()); t += pred.numel()
        m.train()
        return c / t

    def ce_step(pairs, filler, Q, lr_decay=None, zipf=False, use_spread=True):
        nonlocal step
        T_est = pairs * (filler + 2) + 2 * Q
        B = max(1, min(8, TOKEN_BUDGET // T_est))
        b = rd.batch(pairs, filler, Q, seed=int(g.initial_seed()) + step, zipf=zipf)
        tok = b["tokens"].to(dev)
        logits = logits_at(m, tok, b["ans_pos"].to(dev))
        ce = F.cross_entropy(logits.reshape(-1, V), b["gold"].to(dev).reshape(-1))
        if use_spread:
            sl = spread_loss_real(m, tok, b["key_pos"].to(dev), SCALE)
            loss = ce + sl
        else:
            sl = 0.0
            loss = ce
        opt.zero_grad(set_to_none=True)
        loss.backward()
        torch.nn.utils.clip_grad_norm_(m.parameters(), 1.0)
        opt.step()
        if lr_decay:
            lr_decay.step()
        step += 1
        return float(ce.detach()), (float(sl) if use_spread else 0.0), b["L"], B

    # ---------------- re-stabilise the warm model (its last run ended degraded).
    # f=2: light filler, close to the warm model's original training distribution.
    print(f"RESTAB (pairs,steps): {RESTAB}  zipf, f=2, no spread, lr 1e-3", flush=True)
    for pairs, n in RESTAB:
        for s in range(n):
            ce, sl, L, B = ce_step(pairs, 2, 4, zipf=True, use_spread=False)
            if s % 200 == 0 or s == n - 1:
                a = held_out(64, 2, zipf=True)
                print(f"  r p{pairs:4d} step {step:6d} ce {ce:.3f} h@64 {a:.4f} "
                      f"{time.time()-t0:5.0f}s", flush=True)
                beat(step, f"restab p{pairs}")
                history.append({"step": step, "phase": "restab", "pairs": pairs,
                                "h64": round(a, 4)})

    # ---------------- growth to the full protocol pair count, true-key spread
    print(f"GROWTH (pairs,steps): {GROW}  zipf/uniform alternation + true-key spread",
          flush=True)
    for pairs, n in GROW:
        good = 0
        for s in range(n):
            ce, sl, L, B = ce_step(pairs, 2, 8, zipf=(step % 2 == 0), use_spread=True)
            if s % 200 == 0 or s == n - 1:
                a = held_out(pairs, 2)
                a64 = held_out(64, 0, zipf=True)
                print(f"  g p{pairs:4d} step {step:6d} ce {ce:.3f} spread {sl:.3f} "
                      f"h@{pairs} {a:.4f} h@64 {a64:.4f} rounds {mx.scan.last_rounds} "
                      f"{time.time()-t0:5.0f}s", flush=True)
                beat(step, f"grow p{pairs}")
                history.append({"step": step, "phase": "grow", "pairs": pairs,
                                "acc": round(a, 4), "h64": round(a64, 4)})
                with open(HISTORY, "w") as fh:
                    json.dump(history, fh, indent=2)
                torch.save({"model": m.state_dict(),
                            "proto_cfg": {"V": V, "d_model": D_MODEL, "addr_scale": SCALE,
                                          "n_heads": 2, "d_k": D_MODEL // 2, "d_v": D_MODEL // 2},
                            "cfg": "realtext periodic", "step": step},
                           os.path.join(ROOT, "checkpoints", "realtext_latest.pt"))
                good = good + 1 if a >= 0.99 else 0
                if good >= 2 and pairs < P_EVAL:
                    print(f"  PROMOTE p{pairs} at step {step}", flush=True)
                    break

    # ---------------- polish: full protocol pair count, cosine decay, uniform keys
    print(f"POLISH {POLISH_STEPS} steps at P={P_EVAL}, filler cycle [2,2,4,8], cosine 1e-3->1e-4",
          flush=True)
    sched = torch.optim.lr_scheduler.CosineAnnealingLR(opt, T_max=POLISH_STEPS + ALIGN_STEPS,
                                                       eta_min=1e-4)
    fcyc = [2, 2, 4, 8]
    for s in range(POLISH_STEPS):
        ce, sl, L, B = ce_step(P_EVAL, fcyc[s % len(fcyc)], 16, lr_decay=sched,
                               zipf=False, use_spread=True)
        if s % 100 == 0 or s == POLISH_STEPS - 1:
            print(f"  polish {s:4d} step {step} ce {ce:.3f} T{L} {time.time()-t0:6.0f}s", flush=True)
            beat(step, "polish")
        if (s + 1) % 250 == 0:
            a = held_out(P_EVAL, 8, Q=128, B=1)
            print(f"  POLISH EVAL acc {a:.4f}", flush=True)
            history.append({"step": step, "phase": "polish", "acc": round(a, 4)})
            with open(HISTORY, "w") as fh:
                json.dump(history, fh, indent=2)
            torch.save({"model": m.state_dict(),
                        "proto_cfg": {"V": V, "d_model": D_MODEL, "addr_scale": SCALE,
                                      "n_heads": 2, "d_k": D_MODEL // 2, "d_v": D_MODEL // 2},
                        "cfg": "realtext periodic", "step": step},
                       os.path.join(ROOT, "checkpoints", "realtext_latest.pt"))

    # ---------------- align: exact eval distribution (filler=70, Q=2048, stratified)
    print(f"ALIGN {ALIGN_STEPS} steps at filler={F_EVAL} (Q=64 train-safe; "
          f"Q={Q_EVAL} only under no_grad)", flush=True)
    qpos = stratified_qpos(P_EVAL, 64, 4, seed=4242)
    for s in range(ALIGN_STEPS):
        b = rd.batch(P_EVAL, F_EVAL, 64, seed=777000 + s, qpos_idx=qpos)
        tok = b["tokens"].to(dev)
        logits = logits_at(m, tok, b["ans_pos"].to(dev))
        ce = F.cross_entropy(logits.reshape(-1, V), b["gold"].to(dev).reshape(-1))
        sl = spread_loss_real(m, tok, b["key_pos"].to(dev), SCALE, gate_subsample=1024)
        loss = ce + sl
        opt.zero_grad(set_to_none=True)
        loss.backward()
        torch.nn.utils.clip_grad_norm_(m.parameters(), 1.0)
        opt.step()
        sched.step()
        step += 1
        if s % 10 == 0 or s == ALIGN_STEPS - 1:
            torch.save({"model": m.state_dict(),
                        "proto_cfg": {"V": V, "d_model": D_MODEL, "addr_scale": SCALE,
                                      "n_heads": 2, "d_k": D_MODEL // 2, "d_v": D_MODEL // 2},
                        "cfg": "realtext periodic", "step": step},
                       os.path.join(ROOT, "checkpoints", "realtext_latest.pt"))
            print(f"  align {s:3d} step {step} ce {ce:.3f} T{b['L']} {time.time()-t0:6.0f}s",
                  flush=True)
            beat(step, "align")

    torch.save({"model": m.state_dict(),
                "proto_cfg": {"V": V, "d_model": D_MODEL, "addr_scale": SCALE,
                              "n_heads": 2, "d_k": D_MODEL // 2, "d_v": D_MODEL // 2},
                "cfg": "realdata_v2 d64 warm256k spread-true-keys growth3548 polish align"},
               CKPT_OUT)
    print(f"saved {CKPT_OUT}", flush=True)

    # ---------------- final self-check: the acceptance protocol, both seeds
    m.eval()
    for seed in (4242, 4243):
        qp = stratified_qpos(P_EVAL, Q_EVAL, 4, seed=seed)
        b = rd.batch(P_EVAL, F_EVAL, Q_EVAL, seed=seed, qpos_idx=qp)
        with torch.no_grad():
            pred = logits_at(m, b["tokens"].to(dev), b["ans_pos"].to(dev)).argmax(-1).cpu()
        ok = (pred == b["gold"]).squeeze(0)
        per = Q_EVAL // 4
        strata = [round(ok[i * per:(i + 1) * per].float().mean().item(), 4) for i in range(4)]
        overall = ok.float().mean().item()
        print(f"SELF-CHECK seed {seed}: overall {overall:.4f} strata {strata} T={b['L']}",
              flush=True)
        history.append({"step": step, "final": True, "seed": seed,
                        "overall": round(overall, 4), "strata": strata})
    with open(HISTORY, "w") as fh:
        json.dump(history, fh, indent=2)


if __name__ == "__main__":
    main()