File size: 11,933 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 | #!/usr/bin/env python3
"""Forgetting training: after overwrite, queries must return the UPDATED value.
Protocol (CONTINUE.md step 2): sequences where 50% of keys appear twice (first
the original value, later an updated value). The loss is on the query positions
AFTER the updates; the delta rule must learn that the LAST value wins.
Warm-started from checkpoints/capacity8k.pt (the capacity curriculum already
mixes 25% overwrite batches, so this run specialises an existing skill rather
than bootstrapping one).
Curriculum: 64 -> 256 -> 1024 -> 4096 pairs, overwrite fraction cycling through
{0.3, 0.5, 0.7}, both dense (f=0) and 131K-protocol (f=14) batches. Final phase
trains the exact acceptance protocol (N=5,461, f=14, 50% overwrite) and quits
when the held-out probe (500 updated + 500 untouched queries) is >= 0.998 on
both query classes.
"""
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 (SynthCfg, build_model, eval_batch, force_fast_scan, logits_at,
spread_loss, synth_batch)
DEVICE = os.environ.get("DEVICE", "cuda")
CFG = SynthCfg()
CKPT_WARM = os.environ.get("WARM", os.path.join(ROOT, "checkpoints", "capacity8k.pt"))
CKPT_OUT = os.path.join(ROOT, "checkpoints", "forgetting.pt")
HEARTBEAT = os.path.join(ROOT, "logs", "forgetting.heartbeat")
HISTORY = os.path.join(ROOT, "results", "forgetting_training.json")
LEVELS = [
dict(pairs=64, steps=1500),
dict(pairs=256, steps=2000),
dict(pairs=1024, steps=2500),
dict(pairs=4096, steps=4000),
]
FINAL = dict(pairs=5461, steps=6000, filler=14) # the acceptance protocol
OW_CYCLE = [0.3, 0.5, 0.7, 0.5]
TOKEN_BUDGET = 20000
if os.environ.get("SMOKE"): # fast local validation of the exact code path
LEVELS = [dict(pairs=8, steps=5), dict(pairs=16, steps=5)]
FINAL = dict(pairs=24, steps=6, filler=3)
def save(m, path, extra=None):
# record the LIVE mixer scale (the FORK ramp mutates it; writing the stale
# CFG value made scale-128 weights masquerade as scale 96 on reload)
live = float(m.blocks[0].mixer.cfg.addr_scale)
payload = {"model": m.state_dict(), "proto_cfg": {
"NK": CFG.NK, "NV": CFG.NV, "d_model": CFG.d_model, "n_heads": CFG.n_heads,
"d_k": CFG.d_k, "d_v": CFG.d_v, "addr_scale": live,
"retention_init": CFG.retention_init}, "cfg": "forgetting overwrite curriculum"}
if extra:
payload.update(extra)
torch.save(payload, path)
def held_out(m, pairs, filler, n_u=200, n_k=200, seed=777):
"""Acceptance-shaped probe: half queries on updated, half on untouched keys.
Query counts are clamped to the pool sizes (n_u <= n_upd, n_k <= n_pairs-n_upd)."""
g = torch.Generator().manual_seed(seed + pairs + filler)
n_upd = pairs // 2
perm = torch.randperm(pairs, generator=g)
upd_idx = perm[:n_upd]
upd_set = set(upd_idx.tolist())
updated_pool = [i for i in perm.tolist() if i in upd_set]
untouched_pool = [i for i in perm.tolist() if i not in upd_set]
qpos = torch.tensor(updated_pool[:min(n_u, len(updated_pool))]
+ untouched_pool[:min(n_k, len(untouched_pool))]).unsqueeze(0)
b = synth_batch(1, pairs, qpos.shape[1], CFG, filler=filler, overwrite_frac=0.5,
generator=g, qpos=qpos, updated_idx=upd_idx.unsqueeze(0))
return eval_batch(m, b, DEVICE)
def main():
dev = DEVICE
torch.manual_seed(0)
m = build_model(CFG).to(dev)
warm = CKPT_WARM if os.path.exists(CKPT_WARM) else os.path.join(
ROOT, "checkpoints", "capacity8k_latest.pt")
if os.path.exists(warm):
ck = torch.load(warm, map_location="cpu", weights_only=False)
m.load_state_dict(ck["model"])
# restore the TRAINED grid scale (addr_scale is config, not weights).
# Without this the forgetting lineage ran at scale 64 while its
# capacity ancestor was trained at 96. At N=5,461 + 50% overwrites the
# sequence stores 8,192 pair-blocks; at scale 64 this exceeds the
# density ceiling (see LIMITATIONS.md).
saved_scale = ck.get("proto_cfg", {}).get("addr_scale")
if saved_scale:
m.blocks[0].mixer.cfg.addr_scale = float(saved_scale)
CFG.addr_scale = float(saved_scale)
print(f"warm-start from {warm} (scale {m.blocks[0].mixer.cfg.addr_scale:.0f})",
flush=True)
else:
print("WARNING: no warm-start checkpoint at all, training from scratch", flush=True)
force_fast_scan(m)
opt = torch.optim.AdamW(m.parameters(), lr=1e-3)
sched = torch.optim.lr_scheduler.CosineAnnealingLR(
opt, T_max=sum(l["steps"] for l in LEVELS) + FINAL["steps"], eta_min=1e-4)
g = torch.Generator().manual_seed(2)
step = 0
t0 = time.time()
history = []
def train_steps(pairs, n_steps, filler_cycle, probe_f=None):
nonlocal step
good = 0
for s in range(n_steps):
ow = OW_CYCLE[step % len(OW_CYCLE)]
f = filler_cycle[step % len(filler_cycle)]
Q = 8 if step % 4 else 64
T_est = int(pairs * (1 + ow) * (f + 2)) + 2 * Q
B = max(1, min(16, TOKEN_BUDGET // T_est))
b = synth_batch(B, pairs, Q, CFG, filler=f, overwrite_frac=ow, generator=g)
tok = b["tokens"].to(dev)
logits = logits_at(m, tok, b["ans_pos"].to(dev))
ce = F.cross_entropy(logits.reshape(-1, CFG.V), b["gold"].to(dev).reshape(-1))
kp = b["key_pos"]
if b["upd_key_pos"] is not None:
kp = torch.cat([kp, b["upd_key_pos"]], 1)
sl = spread_loss(m, tok, kp.to(dev), CFG, 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 % 100 == 0 or s == n_steps - 1:
sc = m.blocks[0].mixer.scan
print(f" p{pairs:5d} f{f:2d} ow{ow:.1f} step {step:6d} ce {float(ce.detach()):.3f} "
f"spread {float(sl.detach()):.3f} rounds {sc.last_rounds} "
f"{time.time()-t0:6.0f}s", flush=True)
with open(HEARTBEAT, "w") as fh:
fh.write(f"{step}\n")
if (s + 1) % 500 == 0 or s == n_steps - 1:
r = held_out(m, pairs, filler_cycle[-1])
history.append({"step": step, "pairs": pairs, "f": filler_cycle[-1],
"acc_updated": round(r["acc_updated"], 4),
"acc_untouched": round(r["acc_untouched"], 4)})
with open(HISTORY, "w") as fh:
json.dump(history, fh, indent=2)
print(f" EVAL p{pairs} upd {r['acc_updated']:.4f} keep {r['acc_untouched']:.4f}",
flush=True)
print(f"FORGETTING CURRICULUM warm={warm} device={dev}", flush=True)
START = int(os.environ.get("START_LEVEL", "0"))
ladder = [lv for lv in LEVELS if lv["pairs"] >= START]
for lv in ladder:
train_steps(lv["pairs"], lv["steps"], [0, 0, 14])
save(m, os.path.join(ROOT, "checkpoints", f"forgetting_lvl{lv['pairs']}.pt"), {"step": step})
# FINAL phase: a LADDER, not a jump. Measured: the overwrite skill scales
# down with pair count (1.0000 @64/256, ~0.98 @1024, ~0.95 @4096) exactly
# like capacity did before its ladder; and f=14 steps cost ~10s (T=131K) so
# a single-N final is both too shallow and too slow. Train at f in {6,8,14}
# (the mechanism is filler-agnostic; the acceptance distribution gets its
# own align steps), N ladder 2048->3548->5461, probe on the exact protocol.
# FORK envs (fork-and-flush, cf. arXiv:2610.07447): a parallel trajectory
# with DIFFERENT basin assumptions -- a bigger grid for double-writes
# (FORK_SCALE ramps during the final ladder) and long-gap erasure training
# (FORK_FILLERS raises the filler mix, stretching store->update gaps).
FS = float(os.environ.get("FORK_SCALE", "0")) or None
FF = os.environ.get("FORK_FILLERS", "6,8,14,8")
fcyc_fork = [int(x) for x in FF.split(",")]
print(f"FINAL phase: ladder 2048->3548->5461, filler mix {fcyc_fork}"
+ (f", scale ramp ->{FS:.0f}" if FS else ""), flush=True)
FO = os.environ.get("FINAL_ONLY", "")
if FO:
FINAL_LADDER = [(5461, int(FO))]
else:
FINAL_LADDER = [(2048, 1200), (3548, 1500), (5461, 3000)]
scale0_f = float(m.blocks[0].mixer.cfg.addr_scale)
LR0 = float(os.environ.get("FINAL_LR", "0")) # entry LR for the final
# ladder; without it the phase runs at the optimizer's construction LR
# (1e-3), which EXPLODES a converged model at B=1 (measured: probes
# 0.988 -> 0.754 in 200 steps -- Law 3 again). Warm up over 100 steps.
WARM = 100
import math as _m
for FL_N, FL_STEPS in FINAL_LADDER:
for s in range(FL_STEPS):
if LR0:
frac = min(1.0, (s + 1) / WARM)
cur = LR0 * (0.05 + 0.95 * frac)
tail = LR0 * (1.0 - 0.75 * min(1.0, s / max(1, FL_STEPS - 1)))
lr = min(cur, tail) if s >= WARM else cur
for pg in opt.param_groups:
pg["lr"] = lr
if FS:
frac = min(1.0, step / 2000.0)
m.blocks[0].mixer.cfg.addr_scale = scale0_f + (FS - scale0_f) * frac
ow = OW_CYCLE[s % len(OW_CYCLE)]
f = fcyc_fork[s % len(fcyc_fork)]
b = synth_batch(1, FL_N, 64, CFG, filler=f,
overwrite_frac=ow, generator=g)
tok = b["tokens"].to(dev)
logits = logits_at(m, tok, b["ans_pos"].to(dev))
ce = F.cross_entropy(logits.reshape(-1, CFG.V), b["gold"].to(dev).reshape(-1))
kp = torch.cat([b["key_pos"], b["upd_key_pos"]], 1)
sl = spread_loss(m, tok, kp.to(dev), CFG, 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 % 100 == 0 or s == FL_STEPS - 1:
print(f" final N{FL_N} f{f} {s:4d} ce {float(ce.detach()):.3f} T{b['L']} "
f"{time.time()-t0:6.0f}s", flush=True)
if (s + 1) % 200 == 0 or s == FL_STEPS - 1:
r = held_out(m, 5461, 14, n_u=500, n_k=500, seed=777001)
history.append({"step": step, "final": FINAL["pairs"] if False else FL_N,
"acc_updated": round(r["acc_updated"], 4),
"acc_untouched": round(r["acc_untouched"], 4)})
with open(HISTORY, "w") as fh:
json.dump(history, fh, indent=2)
print(f" FINAL PROBE upd {r['acc_updated']:.4f} keep {r['acc_untouched']:.4f}",
flush=True)
save(m, CKPT_OUT, {"step": step})
if r["acc_updated"] >= 0.998 and r["acc_untouched"] >= 0.998:
print(f"TARGET REACHED step {step}", flush=True)
break
else:
continue
break # TARGET REACHED inside the inner loop -> stop the ladder too
save(m, CKPT_OUT, {"step": step})
r = held_out(m, 64, 0, n_u=256, n_k=256, seed=777003)
print(f"SELF-CHECK dense N=64: upd {r['acc_updated']:.4f} keep {r['acc_untouched']:.4f}",
flush=True)
print(f"saved {CKPT_OUT} total {time.time()-t0:.0f}s", flush=True)
if __name__ == "__main__":
main()
|