File size: 15,920 Bytes
97f78c9
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
#!/usr/bin/env python3
"""Train a Jevon decision model on procedurally generated maze and Snake states."""
from __future__ import annotations

import argparse
import json
import math
import random
import sys
import time
from pathlib import Path

import torch

ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT))

from jevon.data import SamplerConfig, TaskSampler, build_corpus, collate, load_frozen
from jevon.losses import LossWeights, decision_loss, field_loss
from jevon.modeling import JevonConfig, JevonModel
from jevon.planner import default_iterations
from jevon.tokenizer import JevonTokenizer


def parse_args() -> argparse.Namespace:
    p = argparse.ArgumentParser(description=__doc__)
    p.add_argument("--out", default="runs/jevon-s")
    p.add_argument("--steps", type=int, default=6000)
    p.add_argument("--states-per-batch", type=int, default=12)
    p.add_argument("--lr", type=float, default=3e-4)
    p.add_argument("--warmup", type=int, default=300)
    p.add_argument("--weight-decay", type=float, default=0.01)
    p.add_argument("--grad-clip", type=float, default=1.0)
    p.add_argument("--seed", type=int, default=17)
    p.add_argument("--device", default="mps")
    p.add_argument("--d-model", type=int, default=384)
    p.add_argument("--planner-dim", type=int, default=96)
    p.add_argument("--planner-grad-steps", type=int, default=8)
    p.add_argument("--planner-lr-mult", type=float, default=10.0,
                   help="learning-rate multiplier for the planner and field head")
    p.add_argument("--field-target", choices=("log", "linear"), default="log",
                   help="shape of the distance target; linear keeps neighbour "
                        "contrast uniform, which is what greedy descent needs")
    p.add_argument("--descent-weight", type=float, default=0.0,
                   help="hinge the field's 4-neighbour ordering toward the true "
                        "downhill step; this is what greedy descent reads")
    p.add_argument("--descent-margin", type=float, default=1.0,
                   help="required neighbour separation, in units of one true step")
    p.add_argument("--score-smoothing", type=float, default=0.0,
                   help="spread score targets over neighbouring levels (0 = one-hot)")
    p.add_argument("--min-plus-field", action="store_true",
                   help="read the distance off a min-plus recurrence, making the "
                        "field traversable by construction")
    p.add_argument("--expose-field", action="store_true",
                   help="let the text side read the planner's own distance read-out")
    p.add_argument("--field-weight", type=float, default=1.0,
                   help="weight on the dense planner distance-field loss")
    p.add_argument("--iteration-jitter", type=float, default=0.5,
                   help="planner budget is scaled by U(1-j, 1+j) during training")
    p.add_argument("--snake-fraction", type=float, default=0.45)
    p.add_argument("--eval-every", type=int, default=250)
    p.add_argument("--eval-file", default="data/frozen/val.jsonl")
    p.add_argument("--log-every", type=int, default=25)
    p.add_argument("--resume", default=None)
    p.add_argument("--no-planner", action="store_true")
    return p.parse_args()


def lr_at(step: int, args) -> float:
    if step < args.warmup:
        return args.lr * (step + 1) / args.warmup
    progress = (step - args.warmup) / max(1, args.steps - args.warmup)
    return args.lr * (0.05 + 0.95 * 0.5 * (1 + math.cos(math.pi * min(progress, 1.0))))


def bucket_batch(sampler: TaskSampler, states: int, rng: random.Random) -> list:
    """One batch holds a single game and board size: exact planner budget, no padding."""
    cfg = sampler.cfg
    snake = rng.random() < cfg.snake_fraction
    size = rng.choice(list(cfg.snake_sizes if snake else cfg.maze_sizes))
    topology = None if snake else rng.choice(list(cfg.maze_topologies))
    out = []
    guard = 0
    while len(out) < states and guard < states * 40:
        guard += 1
        seed = sampler._draw_seed()
        if snake:
            from jevon.data import sample_snake_state
            sample = sample_snake_state(seed, size, sampler.rng, cfg.augment)
        else:
            from jevon.data import sample_maze_state
            sample = sample_maze_state(seed, size, topology, sampler.rng, cfg.augment)
        if sample is not None:
            out.append(sample)
    return out


@torch.no_grad()
def evaluate(model, states, tokenizer, device, args) -> dict:
    model.eval()
    totals: dict[str, list[float]] = {}
    by_game: dict[str, list[float]] = {}
    for start in range(0, len(states), args.states_per_batch):
        group = states[start:start + args.states_per_batch]
        batch = collate(group, tokenizer, device=device)
        steps = default_iterations(int(batch["board_size"].max()))
        cells = None
        if model.planner is not None and "boards" in batch:
            cells = model.encode_boards(batch["boards"], batch["passable"], steps, 0)
        logits = model(batch, iterations=steps, grad_steps=0, cells=cells)
        _, stats = decision_loss(logits, batch)
        if cells is not None and "dist_field" in batch:
            # Scored against the model's own target shape, so field_r2 means
            # "how well did it fit what it was asked to fit". It is therefore
            # NOT comparable across --field-target settings; descent accuracy
            # from probe.py is the shape-independent metric for that.
            _, f_stats = field_loss(model.supervised_field(cells),
                                    batch["dist_field"],
                                    batch["passable"], batch["board_size"],
                                    args.field_target,
                                    args.descent_weight, args.descent_margin)
            stats.update(f_stats)
        for key, value in stats.items():
            v = float(value)
            if v == v:
                totals.setdefault(key, []).append(v)
        target, valid = batch["target_probs"], batch["candidate_valid"]
        picked = logits.argmax(-1)
        hit = target.gather(1, picked.unsqueeze(1)).squeeze(1)
        correct = (hit >= target.max(-1).values - 1e-6).float()
        for meta, ok, qtype in zip(batch["meta"], correct.tolist(),
                                   batch["question_type"].tolist()):
            if qtype == 0:                       # choice == the gameplay decision
                by_game.setdefault(f"{meta['game']}_action", []).append(ok)
        del valid
    model.train()
    out = {k: sum(v) / len(v) for k, v in totals.items() if not k.startswith("_")}
    if "_f_n" in totals:                       # pool R^2 instead of averaging it
        n = sum(totals["_f_n"])
        sse, tsum, tsq = (sum(totals[k]) for k in ("_f_sse", "_f_sum", "_f_sumsq"))
        centred = max(tsq - tsum * tsum / max(n, 1.0), 1e-9)
        out["field_r2"] = 1.0 - sse / centred
        out["field_mae"] = sum(totals["_f_abs"]) / max(n, 1.0)
    out.update({k: sum(v) / len(v) for k, v in by_game.items() if v})
    return out


def balanced_score(metrics: dict) -> float:
    """How well the checkpoint plays *both* games, for model selection.

    Eval loss is dominated by the ~970 boolean questions in the frozen split,
    so the ~170 action questions every headline number comes from barely move
    it -- a checkpoint that stopped playing snake can still own the lowest
    loss, and one did. ``min`` rather than the mean because the failure being
    guarded against is exactly the trade of one game for the other, and a mean
    would average it away.

    A missing metric scores zero rather than being skipped: a run that reports
    no snake_action has not earned a balanced checkpoint.
    """
    return min(metrics.get("maze_action", 0.0), metrics.get("snake_action", 0.0))


def main() -> None:
    args = parse_args()
    torch.manual_seed(args.seed)
    rng = random.Random(args.seed)
    out_dir = Path(args.out)
    out_dir.mkdir(parents=True, exist_ok=True)
    device = torch.device(args.device if torch.backends.mps.is_available()
                          or args.device != "mps" else "cpu")

    sampler_cfg = SamplerConfig(snake_fraction=args.snake_fraction)
    train_sampler = TaskSampler(sampler_cfg, "train", args.seed)

    tok_path = out_dir / "tokenizer.json"
    if tok_path.exists():
        tokenizer = JevonTokenizer.load(tok_path)
    else:
        print("building tokenizer vocabulary ...", flush=True)
        tokenizer = JevonTokenizer.build(build_corpus(train_sampler, 300),
                                         max_vocab=4096, min_count=1)
        tokenizer.save(tok_path)
    print(f"vocab={len(tokenizer)} device={device}")

    cfg = JevonConfig(vocab_size=len(tokenizer), d_model=args.d_model,
                      planner_dim=args.planner_dim,
                      planner_grad_steps=args.planner_grad_steps,
                      expose_field=args.expose_field,
                     min_plus_field=args.min_plus_field,
                      use_planner=not args.no_planner)
    model = JevonModel(cfg).to(device)
    if args.resume:
        model.load_state_dict(torch.load(args.resume, map_location=device))
    print(f"parameters: {model.num_parameters()/1e6:.2f}M")
    (out_dir / "config.json").write_text(json.dumps(cfg.to_dict(), indent=2))
    # config.json is the *architecture*; this is the *recipe*. Without it a run
    # directory records what was built but not how it was trained, and the
    # flags that distinguish these runs from each other -- --min-plus-field,
    # --planner-lr-mult, --field-weight, --seed -- live only in the shell
    # history of whoever launched them. argv is recorded verbatim alongside the
    # parsed values because the two disagree in the way that matters: argv says
    # what was asked for, the parsed dict says what the defaults turned it into.
    (out_dir / "args.json").write_text(json.dumps(
        {"argv": sys.argv, "args": vars(args)}, indent=2, default=str))

    # The planner is one small block applied several hundred times, and only the
    # last `grad_steps` applications carry gradient, so its updates arrive at a
    # completely different scale from the transformer's. Measured on a fixed set
    # of 11x11 mazes, 200 steps of field-only training: lr 3e-4 reaches R^2 0.24,
    # lr 3e-3 reaches 0.64. It needs its own learning rate.
    planner_names = {n for n, _ in model.named_parameters()
                     if n.startswith("planner.") or n.startswith("field_head.")}
    groups = []
    for is_planner in (False, True):
        for wants_decay in (True, False):
            params = [p for n, p in model.named_parameters()
                      if (n in planner_names) == is_planner
                      and (p.dim() >= 2) == wants_decay]
            if params:
                groups.append({
                    "params": params,
                    "weight_decay": args.weight_decay if wants_decay else 0.0,
                    "lr_mult": args.planner_lr_mult if is_planner else 1.0})
    opt = torch.optim.AdamW(groups, lr=args.lr, betas=(0.9, 0.95))

    eval_states = load_frozen(args.eval_file) if Path(args.eval_file).exists() else []
    print(f"frozen eval states: {len(eval_states)}")

    weights = LossWeights(score_smoothing=args.score_smoothing)
    history, best = [], float("inf")
    # Eval loss is dominated by the ~970 boolean questions in the split; the
    # 170 action questions that every headline number is computed from barely
    # move it. Selecting on loss alone therefore picked a checkpoint that had
    # traded one game away -- a step reached maze_action 0.979 while snake_action
    # fell to 0.297, below its own uniform control, and still took best.pt
    # because the aggregate loss was the lowest so far. `min` rather than the
    # mean because the failure being guarded against is exactly that trade.
    best_balanced = -float("inf")
    running: dict[str, float] = {}
    start_time = time.time()

    for step in range(args.steps):
        for group in opt.param_groups:
            group["lr"] = lr_at(step, args) * group["lr_mult"]
        states = bucket_batch(train_sampler, args.states_per_batch, rng)
        batch = collate(states, tokenizer, device=device)
        size = int(batch["board_size"].max())
        # Jitter skews upward: too few iterations teaches the model to decide
        # before the distance field has arrived, which is the failure we are fixing.
        jitter = 1.0 + rng.uniform(-0.25 * args.iteration_jitter, args.iteration_jitter)
        steps_t = max(8, int(default_iterations(size) * jitter))

        cells = None
        if model.planner is not None and "boards" in batch:
            cells = model.encode_boards(batch["boards"], batch["passable"],
                                        steps_t, args.planner_grad_steps)
        logits = model(batch, iterations=steps_t, cells=cells)
        loss, stats = decision_loss(logits, batch, weights)
        # Dense planner supervision. Without it the recurrence gets gradient
        # from one distance question per state, which measurably fails to
        # teach it distances (linear probe R^2 ~= 0.21).
        if cells is not None and args.field_weight > 0 and "dist_field" in batch:
            f_loss, f_stats = field_loss(model.supervised_field(cells),
                                         batch["dist_field"], batch["passable"],
                                         batch["board_size"], args.field_target,
                                         args.descent_weight, args.descent_margin)
            loss = loss + args.field_weight * f_loss
            stats.update(f_stats)
        opt.zero_grad(set_to_none=True)
        loss.backward()
        torch.nn.utils.clip_grad_norm_(model.parameters(), args.grad_clip)
        opt.step()

        for key, value in stats.items():
            v = float(value)
            if v == v:
                running[key] = 0.9 * running.get(key, v) + 0.1 * v

        if (step + 1) % args.log_every == 0:
            rate = (step + 1) / (time.time() - start_time)
            print(f"step {step+1:5d}/{args.steps} loss {running['loss']:.4f} "
                  f"acc {running['acc']:.3f} choice {running.get('acc_choice', float('nan')):.3f} "
                  f"bool {running.get('acc_boolean', float('nan')):.3f} "
                  f"tvd {running['tvd']:.3f} T={steps_t} {rate:.2f} it/s", flush=True)

        if eval_states and ((step + 1) % args.eval_every == 0 or step + 1 == args.steps):
            metrics = evaluate(model, eval_states, tokenizer, device, args)
            metrics["step"] = step + 1
            history.append(metrics)
            print("  eval " + " ".join(
                f"{k}={v:.4f}" for k, v in metrics.items() if k != "step"), flush=True)
            (out_dir / "history.json").write_text(json.dumps(history, indent=2))
            if metrics["loss"] < best:
                best = metrics["loss"]
                torch.save(model.state_dict(), out_dir / "best.pt")
                (out_dir / "best.json").write_text(json.dumps(metrics, indent=2))
            balanced = balanced_score(metrics)
            if balanced > best_balanced:
                best_balanced = balanced
                torch.save(model.state_dict(), out_dir / "balanced.pt")
                (out_dir / "balanced.json").write_text(
                    json.dumps({**metrics, "balanced": balanced}, indent=2))
        if (step + 1) % 500 == 0:
            torch.save(model.state_dict(), out_dir / "last.pt")

    torch.save(model.state_dict(), out_dir / "last.pt")
    print(f"done in {(time.time()-start_time)/60:.1f} min; best eval loss {best:.4f}; "
          f"best balanced action acc {best_balanced:.4f}")


if __name__ == "__main__":
    main()