approaches/value-policy-learning/oneply-q-prune/analysis/fit_leaf.pyPython159 lines · 7.0 KB
#!/usr/bin/env python3
"""One TreeStrap step for the linear fair leaf.

Fits the eighteen fair-leaf terms (plus intercept), and a variant with the
best immediate six-feature drop value as a nineteenth term, to the exact
depth-4 root value on non-death-dominated fit roots; reports held-out R^2
against the frozen-leaf affine baseline; writes LinearLeaf weight files.

  fit_leaf.py --panel panel.ndjson --terms root-terms.ndjson --cem-weights weights-cem.txt
              --train-games 32 --floor -50000 --out fit-leaf.json
              --weights18-out weights-leaf18.txt --weights19-out weights-leaf19.txt
"""
import argparse
import json
import math
import sys

sys.path.insert(0, __import__("os").path.dirname(__file__))
from fit_q import LAMBDAS, load_kf_weights, ridge, ridge_from, gram_of, dot  # noqa: E402

LEAF_NAMES = ["open_columns", "height_load", "solid_cells", "cracked_cells", "numbered_cells",
              "high_low_numbers", "direct_potential", "latent_chain_potential", "cracked_exposure",
              "solid_exposure", "adjacent_ones", "triple_twos", "dead_low_numbers",
              "covered_height_risk", "low_number_height_risk", "danger_height_squared",
              "rise_pressure", "next_disc_vertical_options"]
FROZEN = [180.0, -20.0, -620.0, -220.0, -18.0, -90.0, 1600.0, 700.0, 100.0, 40.0, -550.0,
          -750.0, -120.0, -95.0, -85.0, -1250.0, -35.0, 220.0]
KF_NAMES = ["min_eq_elem_True", "row_dets", "col_dets", "max_eq_elem", "1_dets", "elem_det"]


def load_ndjson(path):
    rows = []
    with open(path) as handle:
        for line in handle:
            line = line.strip()
            if line:
                rows.append(json.loads(line))
    return rows


def r2(y, yhat):
    m = sum(y) / len(y)
    sst = sum((v - m) ** 2 for v in y)
    sse = sum((v - w) ** 2 for v, w in zip(y, yhat))
    return 1.0 - sse / sst if sst > 0 else None


def fit_with_cv(X, y, gids):
    n = len(X)
    p = len(X[0])
    mean = [sum(r[j] for r in X) / n for j in range(p)]
    sd = []
    for j in range(p):
        var = sum((r[j] - mean[j]) ** 2 for r in X) / n
        sd.append(math.sqrt(var) if var > 0 else 1.0)
    Xs = [[(r[j] - mean[j]) / sd[j] for j in range(p)] for r in X]
    ym = sum(y) / n
    yc = [v - ym for v in y]
    folds = {}
    for i, g in enumerate(gids):
        folds.setdefault(g % 4, []).append(i)
    cv = {}
    fold_grams = {}
    for fold, members in folds.items():
        held = set(members)
        fold_grams[fold] = gram_of([Xs[i] for i in range(n) if i not in held], [yc[i] for i in range(n) if i not in held])
    for lam in LAMBDAS:
        sse, cnt = 0.0, 0
        for fold, members in folds.items():
            g, xty, nf = fold_grams[fold]
            w = ridge_from(g, xty, nf, lam)
            for i in members:
                sse += (yc[i] - dot(w, Xs[i])) ** 2
                cnt += 1
        cv[str(lam)] = sse / cnt
    lam = min(LAMBDAS, key=lambda l: cv[str(l)])
    w_std = ridge(Xs, yc, lam)
    w = [w_std[j] / sd[j] for j in range(p)]
    bias = ym - sum(w[j] * mean[j] for j in range(p))
    return w, bias, lam, cv


def main():
    parser = argparse.ArgumentParser()
    parser.add_argument("--panel", required=True)
    parser.add_argument("--terms", required=True)
    parser.add_argument("--cem-weights", required=True)
    parser.add_argument("--train-games", type=int, default=32)
    parser.add_argument("--floor", type=float, default=-50000.0)
    parser.add_argument("--out", required=True)
    parser.add_argument("--weights18-out", required=True)
    parser.add_argument("--weights19-out", required=True)
    args = parser.parse_args()

    panel = {(r["game"], r["move"]): r for r in load_ndjson(args.panel)}
    terms = load_ndjson(args.terms)
    cem = load_kf_weights(args.cem_weights)
    rows = []
    for t in terms:
        root = panel[(t["game"], t["move"])]
        v4 = max(root["v4"])
        rows.append({"game": t["game"], "v4": v4, "terms": t["terms"], "leaf": t["fairLeaf"], "kf": t["kfBest"],
                     "keep": v4 > args.floor})
    fit = [r for r in rows if r["game"] < args.train_games and r["keep"]]
    held = [r for r in rows if r["game"] >= args.train_games and r["keep"]]
    dropped_fit = sum(1 for r in rows if r["game"] < args.train_games and not r["keep"])
    dropped_held = sum(1 for r in rows if r["game"] >= args.train_games and not r["keep"])
    print("fit roots %d (dropped %d), held-out %d (dropped %d)" % (len(fit), dropped_fit, len(held), dropped_held), file=sys.stderr)

    # baseline: affine map of the frozen leaf
    Xb = [[r["leaf"]] for r in fit]
    yb = [r["v4"] for r in fit]
    wb, bb, lamb, cvb = fit_with_cv(Xb, yb, [r["game"] for r in fit])
    r2_base = r2([r["v4"] for r in held], [bb + wb[0] * r["leaf"] for r in held])
    # refit-18
    X18 = [r["terms"] for r in fit]
    w18, b18, lam18, cv18 = fit_with_cv(X18, yb, [r["game"] for r in fit])
    r2_18 = r2([r["v4"] for r in held], [b18 + dot(w18, r["terms"]) for r in held])
    # refit-19
    X19 = [r["terms"] + [r["kf"]] for r in fit]
    w19, b19, lam19, cv19 = fit_with_cv(X19, yb, [r["game"] for r in fit])
    r2_19 = r2([r["v4"] for r in held], [b19 + dot(w19, r["terms"] + [r["kf"]]) for r in held])
    # in-sample for reference
    r2_18_fit = r2(yb, [b18 + dot(w18, r["terms"]) for r in fit])

    def write(path, w, bias, beta):
        with open(path, "w") as handle:
            for name, value in zip(LEAF_NAMES, w):
                handle.write("%s %r\n" % (name, value))
            handle.write("bias %r\n" % bias)
            handle.write("kf_beta %r\n" % beta)
            for name, value in zip(KF_NAMES, cem):
                handle.write("kf_%s %r\n" % (name, value))

    write(args.weights18_out, w18, b18, 0.0)
    write(args.weights19_out, w19[:18], b19, w19[18])
    # frozen-leaf disagreement diagnostics: how far the refit moved each weight
    ratios = {n: (w18[i] / FROZEN[i] if FROZEN[i] else None) for i, n in enumerate(LEAF_NAMES)}
    out = {
        "format": "drop7-oneply-q-fit-leaf-v1",
        "floor": args.floor,
        "fitRoots": len(fit), "fitRootsDropped": dropped_fit,
        "heldOutRoots": len(held), "heldOutRootsDropped": dropped_held,
        "baselineAffine": {"slope": wb[0], "intercept": bb, "lambda": lamb, "cv": cvb, "r2HeldOut": r2_base},
        "refit18": {"weights": dict(zip(LEAF_NAMES, w18)), "bias": b18, "lambda": lam18, "cv": cv18,
                    "r2HeldOut": r2_18, "r2Fit": r2_18_fit, "weightOverFrozen": ratios},
        "refit19": {"weights": dict(zip(LEAF_NAMES, w19[:18])), "kfBeta": w19[18], "bias": b19, "lambda": lam19,
                    "cv": cv19, "r2HeldOut": r2_19},
        "gainOverBaseline": (r2_18 - r2_base) if (r2_18 is not None and r2_base is not None) else None,
        "gain19Over18": (r2_19 - r2_18) if (r2_19 is not None and r2_18 is not None) else None,
    }
    with open(args.out, "w") as handle:
        json.dump(out, handle, indent=2)
    print("R2 held-out: baseline %.4f refit18 %.4f refit19 %.4f (fit18 in-sample %.4f)" % (
        r2_base, r2_18, r2_19, r2_18_fit), file=sys.stderr)


if __name__ == "__main__":
    main()