summaryrefslogtreecommitdiff
path: root/annotate_pgns/fit_lazy_probe.py
blob: 365711679dd3bd08522a462c4c3313bd979d0855 (plain)
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
#!/usr/bin/env python3
"""Fits the lazy-eval probe (docs/NNUE.md's "Lazy-evaluation tiering
plan"): a ridge regression on the trained net's own accumulator
(fc1's input -- the ReLU'd, concatenated 512-wide per-perspective
halves), targeting that SAME checkpoint's raw forward() output
(pre-calibration -- see export_nnue_weights.py's CalibrateScoreMapping;
NnueProbeScore applies the identical (CALIB_A, CALIB_B) correction
itself at runtime, so the probe should target raw, not the calibrated
cp value, to stay consistent with that).

Must be re-run for every new checkpoint -- these weights are fit
against one specific net's accumulator and are not portable across
generations (same rule as material/score calibration).

Writes the NPRB format NnueProbeLoadWeights (src/nnueeval.c) reads:
    magic: "NPRB"
    dim:   uint32 (2*L1)
    mu:    dim x float32
    sigma: dim x float32
    weight: dim x float32
    bias:  1x float32

Usage:
    python3 fit_lazy_probe.py \
        runs/sf_20261020_recal/nnue_ckpt/ckpt_epoch015.pt \
        runs/sf_20261020_recal/nnue_probe.bin \
        --train-tensor-dir runs/sf_20261020_recal/tensors_train \
        --holdout-tensor-dir runs/sf_20261020_recal/tensors_holdout
"""
import argparse
import struct
from pathlib import Path

import numpy as np
import torch

from nnue_model import NnueNet

RIDGE_LAMBDA = 1.0


def _batched_accumulator_and_raw(model, tensor_dir, rows, batch_size=5000):
    """Returns (accum [N, 2*L1] float64, raw [N] float64) for the given
    row indices into tensor_dir's CSR arrays."""
    d = Path(tensor_dir)
    white_idx = np.load(d / "white_idx.npy"); white_off = np.load(d / "white_off.npy")
    black_idx = np.load(d / "black_idx.npy"); black_off = np.load(d / "black_off.npy")
    stm = np.load(d / "stm.npy")

    accums, raws = [], []
    with torch.no_grad():
        for i in range(0, len(rows), batch_size):
            chunk = rows[i:i + batch_size]
            w_idx_list, w_off_list, b_idx_list, b_off_list = [], [], [], []
            off_w, off_b = 0, 0
            for r in chunk:
                wi = white_idx[white_off[r]:white_off[r + 1]]
                bi = black_idx[black_off[r]:black_off[r + 1]]
                w_idx_list.append(wi); w_off_list.append(off_w); off_w += len(wi)
                b_idx_list.append(bi); b_off_list.append(off_b); off_b += len(bi)
            w_idx = torch.from_numpy(np.concatenate(w_idx_list).astype(np.int64))
            w_off = torch.tensor(w_off_list, dtype=torch.int64)
            b_idx = torch.from_numpy(np.concatenate(b_idx_list).astype(np.int64))
            b_off = torch.tensor(b_off_list, dtype=torch.int64)
            stm_t = torch.from_numpy(stm[chunk].astype(np.int64))

            acc = model.accumulator(w_idx, w_off, b_idx, b_off, stm_t)
            raw = model.forward(w_idx, w_off, b_idx, b_off, stm_t)
            accums.append(acc.numpy())
            raws.append(raw.numpy())
    return (np.concatenate(accums).astype(np.float64),
            np.concatenate(raws).astype(np.float64))


def main():
    ap = argparse.ArgumentParser(description=__doc__,
                                  formatter_class=argparse.RawDescriptionHelpFormatter)
    ap.add_argument("checkpoint")
    ap.add_argument("outfile")
    ap.add_argument("--train-tensor-dir", required=True,
                     help="fit the ridge regression against positions from here")
    ap.add_argument("--holdout-tensor-dir", required=True,
                     help="report residual stats (not used for fitting) against here")
    ap.add_argument("--n-fit", type=int, default=80000)
    ap.add_argument("--n-report", type=int, default=20000)
    ap.add_argument("--seed", type=int, default=11)
    args = ap.parse_args()

    model = NnueNet()
    ck = torch.load(args.checkpoint, map_location="cpu", weights_only=True)
    model.load_state_dict(ck["model"])
    model.eval()

    rng = np.random.default_rng(args.seed)

    n_train = len(np.load(Path(args.train_tensor_dir) / "stm.npy"))
    fit_rows = np.sort(rng.choice(n_train, size=min(args.n_fit, n_train), replace=False))
    X, y = _batched_accumulator_and_raw(model, args.train_tensor_dir, fit_rows)

    mu = X.mean(axis=0)
    sigma = X.std(axis=0)
    sigma[sigma < 1e-6] = 1e-6  # guard against a dead/always-zero accumulator dim
    Xs = (X - mu) / sigma

    # Ridge regression, closed form: w = (X^T X + lambda*I)^-1 X^T y,
    # bias handled by centering y (mu/sigma already centers X).
    y_mean = y.mean()
    yc = y - y_mean
    dim = Xs.shape[1]
    A = Xs.T @ Xs + RIDGE_LAMBDA * np.eye(dim)
    w = np.linalg.solve(A, Xs.T @ yc)
    bias = y_mean  # Xs has zero mean per-column, so w contributes 0 at the mean point

    fit_pred = Xs @ w + bias
    fit_resid = y - fit_pred
    print(f"Fit on {len(fit_rows)} positions ({args.train_tensor_dir}): "
          f"mean|resid|={np.mean(np.abs(fit_resid)):.3f}, "
          f"p98={np.percentile(np.abs(fit_resid), 98):.3f}")

    n_holdout = len(np.load(Path(args.holdout_tensor_dir) / "stm.npy"))
    report_rows = np.sort(rng.choice(n_holdout, size=min(args.n_report, n_holdout), replace=False))
    Xh, yh = _batched_accumulator_and_raw(model, args.holdout_tensor_dir, report_rows)
    Xhs = (Xh - mu) / sigma
    holdout_pred = Xhs @ w + bias
    holdout_resid = yh - holdout_pred
    abs_resid = np.abs(holdout_resid)
    print(f"Held out on {len(report_rows)} positions ({args.holdout_tensor_dir}): "
          f"mean|resid|={np.mean(abs_resid):.3f}, "
          f"p50={np.percentile(abs_resid, 50):.3f}, "
          f"p98={np.percentile(abs_resid, 98):.3f}, "
          f"max={np.max(abs_resid):.3f}")
    print("NOTE: this is an OFFLINE, quiet-filtered-game-position sample -- "
          "NOT real search-node traffic. See the 2026-10-20 conversation on "
          "why that distinction matters before trusting these residual "
          "numbers as a safety bound for a real skip-gate.")

    with open(args.outfile, "wb") as f:
        f.write(b"NPRB")
        f.write(struct.pack("<I", dim))
        f.write(mu.astype(np.float32).tobytes())
        f.write(sigma.astype(np.float32).tobytes())
        f.write(w.astype(np.float32).tobytes())
        f.write(struct.pack("<f", float(bias)))
    print(f"Wrote {args.outfile}")


if __name__ == "__main__":
    main()