Drop7 Research
approaches/afterstate-learning/distributional-afterstate/d4q.pyPython283 lines · 11.3 KB
#!/usr/bin/env python3
"""Scale-out stage 1: successor-closed fair-D4 search-value distillation.

Experiment EX-20260821-afterstate-d4q-stage1-40136e9e, under theory
TH-20260821-search-guided-self-play-at-scale-299ed02f.

Trains the action-free afterstate evaluator on the exact values the pinned
fair-D4 reference assigns to every legal sibling's resolved afterstate
(label-d4q.cpp output), then measures held-out agreement between the model's
one-ply chance-averaged action ordering and D4's own ordering on fresh roots.

The model input is strictly public: afterstate board, next disc, moves until
rise. The value target is rescaled by a fixed constant (1e4).
"""

import argparse
import json
import os
import time

import numpy as np
import torch

from train import (AfterstateNet, IN_PLANES, MAX_ACTIONS, N_QUANTILES, mix32)

VALUE_SCALE = 10_000.0
STRATA = 5


def load_labels(path):
    rows = []
    with open(path) as handle:
        handle.readline()
        for line in handle:
            (uid, fold, action, stratum, board, next_disc, moves, terminal,
             delta, value) = line.rstrip("\n").split("\t")
            rows.append({
                "uid": uid, "fold": fold, "action": int(action),
                "stratum": int(stratum), "board": board,
                "next": int(next_disc), "moves": int(moves),
                "terminal": terminal == "1", "delta": float(delta),
                "value": float(value),
            })
    return rows


def encode_planes(board, next_disc, moves):
    planes = np.zeros((IN_PLANES, 7, 7), dtype=np.uint8)
    for c, ch in enumerate(board):
        planes[int(ch), c // 7, c % 7] = 1
    planes[10 + next_disc - 1] = 1
    planes[17 + moves - 1] = 1
    return planes


def pack_roots(rows):
    """Groups rows by root, padding to [G, MAX_ACTIONS, STRATA]."""
    by_root = {}
    for r in rows:
        by_root.setdefault(r["uid"], []).append(r)
    roots = []
    for uid, group in by_root.items():
        actions = sorted({r["action"] for r in group})
        if len(actions) < 2:
            continue
        a_index = {a: i for i, a in enumerate(actions)}
        n_actions = len(actions)
        planes = np.zeros((MAX_ACTIONS, STRATA, IN_PLANES, 7, 7),
                          dtype=np.uint8)
        delta = np.zeros((MAX_ACTIONS, STRATA), dtype=np.float64)
        value = np.zeros((MAX_ACTIONS, STRATA), dtype=np.float64)
        mask = np.zeros((MAX_ACTIONS, STRATA), dtype=bool)
        for r in group:
            i = a_index[r["action"]]
            s = r["stratum"]
            if s >= STRATA:
                continue
            planes[i, s] = encode_planes(r["board"], r["next"], r["moves"])
            delta[i, s] = r["delta"] / VALUE_SCALE
            value[i, s] = r["value"] / VALUE_SCALE
            mask[i, s] = True
        # True action values: mean over strata of delta + value.
        q_true = ((delta + value) * mask).sum(axis=1) / mask.sum(axis=1)
        roots.append({
            "uid": uid, "planes": planes, "delta": delta, "value": value,
            "mask": mask, "q_true": q_true[:n_actions], "n_actions": n_actions,
        })
    return roots


def model_q(model, planes_t, delta_t, mask_t):
    """Per-action model value: mean over strata of delta + V(afterstate)."""
    g, a, s = mask_t.shape
    out = model(planes_t.reshape(g * a * s, IN_PLANES, 7, 7))
    v = out["quantiles"].mean(dim=-1).reshape(g, a, s)
    q = ((v + delta_t) * mask_t).sum(dim=2) / mask_t.sum(dim=2).clamp(min=1)
    return q, v


def ranking_metrics_from_q(q_pred, q_true, n_actions):
    p = q_pred[:n_actions]
    t = q_true[:n_actions]
    spread = float(t.max() - t.min())
    best = int(t.argmax())
    pick = int(p.argmax())
    pairs = 0
    correct = 0.0
    for i in range(n_actions):
        for j in range(i + 1, n_actions):
            if t[i] == t[j]:
                continue
            pairs += 1
            correct += float((p[i] - p[j]) * (t[i] - t[j]) > 0)
    return {
        "top1": float(pick == best),
        "top2": float(best in np.argsort(-p)[:2]),
        "pairwise": (correct / pairs) if pairs else None,
        "regret": float((t[best] - t[pick]) / spread) if spread > 0 else 0.0,
    }


def main():
    parser = argparse.ArgumentParser()
    parser.add_argument("--train-labels", required=True)
    parser.add_argument("--heldout-labels", required=True)
    parser.add_argument("--heldout-roots-tsv", default=None,
                        help="roots.tsv carrying origin_seed per root for "
                             "origin-hash half-folds")
    parser.add_argument("--out", required=True)
    parser.add_argument("--epochs", type=int, default=30)
    parser.add_argument("--lr", type=float, default=3e-4)
    parser.add_argument("--channels", type=int, default=96)
    parser.add_argument("--blocks", type=int, default=6)
    parser.add_argument("--roots-per-batch", type=int, default=64)
    parser.add_argument("--max-train-seconds", type=float, default=3 * 3600)
    parser.add_argument("--seed", type=int, default=20260821)
    args = parser.parse_args()

    os.makedirs(args.out, exist_ok=True)
    torch.manual_seed(args.seed)
    np.random.seed(args.seed)
    device = "cuda" if torch.cuda.is_available() else "cpu"
    started = time.time()

    train_rows = load_labels(args.train_labels)
    heldout_rows = load_labels(args.heldout_labels)
    print(f"train label rows {len(train_rows)} held-out rows {len(heldout_rows)}")

    train_roots = pack_roots(train_rows)
    heldout_roots = pack_roots(heldout_rows)

    # Origin-game half-folds: prefer the origin seed from the gate roots.tsv;
    # fall back to a public-state hash only if it is unavailable.
    origin_of = {}
    if args.heldout_roots_tsv:
        with open(args.heldout_roots_tsv) as handle:
            handle.readline()
            for line in handle:
                parts = line.rstrip("\n").split("\t")
                if len(parts) >= 3:
                    origin_of[parts[0]] = int(parts[2], 16)

    def half_of(uid):
        if uid in origin_of:
            return mix32(origin_of[uid]) & 1
        return mix32(int.from_bytes(uid.encode()[:4].ljust(4, b"\0"),
                                    "little")) & 1
    print(f"train roots {len(train_roots)} held-out roots {len(heldout_roots)} "
          f"origin-mapped {len(origin_of)}")

    model = AfterstateNet(args.channels, args.blocks).to(device)
    print(f"model parameters {sum(p.numel() for p in model.parameters())} "
          f"device {device}")
    opt = torch.optim.AdamW(model.parameters(), lr=args.lr, weight_decay=1e-4)
    sched = torch.optim.lr_scheduler.CosineAnnealingLR(opt, T_max=args.epochs)

    def batch_tensors(roots, indices):
        planes = torch.from_numpy(
            np.stack([roots[i]["planes"] for i in indices]).astype(np.float32))
        delta = torch.from_numpy(
            np.stack([roots[i]["delta"] for i in indices]).astype(np.float32))
        mask = torch.from_numpy(np.stack([roots[i]["mask"] for i in indices]))
        return planes.to(device), delta.to(device), mask.to(device)

    log = []
    for epoch in range(args.epochs):
        model.train()
        order = np.random.permutation(len(train_roots))
        totals = {"mse": 0.0, "rank": 0.0, "n": 0}
        for start in range(0, len(order), args.roots_per_batch):
            idx = order[start:start + args.roots_per_batch]
            planes, delta, mask = batch_tensors(train_roots, idx)
            q_true = torch.from_numpy(np.stack(
                [np.pad(train_roots[i]["q_true"],
                        (0, MAX_ACTIONS - train_roots[i]["n_actions"]),
                        constant_values=0.0)
                 for i in idx]).astype(np.float32)).to(device)
            n_actions = torch.tensor([train_roots[i]["n_actions"]
                                      for i in idx], device=device)
            q_pred, v = model_q(model, planes, delta, mask)
            # Value regression on every labeled afterstate.
            val_target = torch.from_numpy(np.stack(
                [train_roots[i]["value"] for i in idx]).astype(np.float32)
                ).to(device)
            mse = (((v - val_target) ** 2) * mask).sum() / mask.sum().clamp(min=1)
            # Ranking loss over action means within each root.
            amask = torch.arange(MAX_ACTIONS, device=device).unsqueeze(0) < \
                n_actions.unsqueeze(1)
            dq_p = q_pred.unsqueeze(2) - q_pred.unsqueeze(1)
            dq_t = q_true.unsqueeze(2) - q_true.unsqueeze(1)
            sign = torch.sign(dq_t)
            valid = (sign != 0) & amask.unsqueeze(2) & amask.unsqueeze(1)
            pair = torch.nn.functional.softplus(-dq_p * sign)
            l_rank = (pair * valid).sum() / valid.sum().clamp(min=1)
            loss = mse + l_rank
            opt.zero_grad()
            loss.backward()
            opt.step()
            totals["mse"] += float(mse) * len(idx)
            totals["rank"] += float(l_rank) * len(idx)
            totals["n"] += len(idx)
        sched.step()
        line = {"epoch": epoch,
                "mse": totals["mse"] / totals["n"],
                "rank": totals["rank"] / totals["n"],
                "elapsed": time.time() - started}
        log.append(line)
        print(json.dumps(line))
        if time.time() - started > args.max_train_seconds:
            print("training time budget reached")
            break

    torch.save({"model": model.state_dict(), "channels": args.channels,
                "blocks": args.blocks, "quantiles": N_QUANTILES,
                "valueScale": VALUE_SCALE},
               os.path.join(args.out, "model-d4q.pt"))
    with open(os.path.join(args.out, "training-log.json"), "w") as handle:
        json.dump(log, handle, indent=1)

    # Gate: held-out agreement with D4's ordering, per half-fold by root hash.
    model.eval()
    results = []
    with torch.no_grad():
        for start in range(0, len(heldout_roots), 256):
            idx = np.arange(start, min(start + 256, len(heldout_roots)))
            planes, delta, mask = batch_tensors(heldout_roots, idx)
            q_pred, _ = model_q(model, planes, delta, mask)
            q_pred = q_pred.cpu().numpy()
            for j, i in enumerate(idx):
                root = heldout_roots[i]
                m = ranking_metrics_from_q(q_pred[j], root["q_true"],
                                           root["n_actions"])
                m["half"] = half_of(root["uid"])
                results.append(m)

    def summarize(subset):
        out = {}
        for key in ("top1", "top2", "pairwise", "regret"):
            vals = [m[key] for m in subset if m[key] is not None]
            out[key] = float(np.mean(vals)) if vals else None
        out["n"] = len(subset)
        return out

    half1 = [m for m in results if m["half"] == 0]
    half2 = [m for m in results if m["half"] == 1]
    report = {
        "heldoutRoots": len(results),
        "pooled": summarize(results),
        "half1": summarize(half1),
        "half2": summarize(half2),
        "wallSeconds": time.time() - started,
        "device": torch.cuda.get_device_name(0) if device == "cuda" else "cpu",
        "peakGpuBytes": (int(torch.cuda.max_memory_allocated(0))
                         if device == "cuda" else None),
    }
    with open(os.path.join(args.out, "d4q-gate-report.json"), "w") as handle:
        json.dump(report, handle, indent=1, sort_keys=True)
        handle.write("\n")
    print(json.dumps(report, indent=1, sort_keys=True))


if __name__ == "__main__":
    main()