diff options
| author | Scott Gasch <[email protected]> | 2026-10-20 09:26:44 -0700 |
|---|---|---|
| committer | Scott Gasch <[email protected]> | 2026-10-20 09:26:44 -0700 |
| commit | c8c6bbc2c1a103e5b07b079a0f8bdf66c7724d80 (patch) | |
| tree | b6c2137c9ac577fa83b1e948fa0311d53dfe445d /annotate_pgns/overfit_sanity_check.py | |
| parent | 3b5d3677bb14ae14728a1c4b221176bb83667d92 (diff) | |
Wire NNUE eval into search, build the training pipeline, and add a fast
move/eval-agreement benchmark against Stockfish (tests/lop)
Implements the architecture decisions in src/docs/NNUE.md: HalfKAv2_hm
feature set, int16/int8 quantization, per-ply accumulator backup for
king moves, and a lazy-eval-ready integration into search.c/eval.c.
Adds the full annotate_pgns/ training pipeline (extraction, K-fit/
labeling, quiet filtering, tensor building, training, export) plus an
end-to-end orchestrator (run_nnue_pipeline.py) and validation tooling
(sanity_check_net.py, compare_vs_stockfish_net.py). Adds tests/lop:
a 10k-position list-of-positions benchmark comparing engine eval rank
and move choice against pre-computed Stockfish/lc0 references, much
cheaper than a full self-play match for day-to-day progress checks.
First sd10-matched self-play result against classic HCE: LLR +2.28
(H1=+2.94), -72=119+169 over 360 games, confirming the net is
"smarter per node" at matched depth. A real-time (--st 4) match is
currently weaker (candidate loses ~3 plies of depth to a ~3.3-3.7x
nps gap against an unrecalibrated search), consistent with
NNUE.md's adoption-criterion section -- pruning/reduction constants
still need retuning for the new eval's score scale before a fair
real-time comparison is possible.
Co-Authored-By: Claude Sonnet 5 <[email protected]>
Diffstat (limited to 'annotate_pgns/overfit_sanity_check.py')
| -rw-r--r-- | annotate_pgns/overfit_sanity_check.py | 104 |
1 files changed, 104 insertions, 0 deletions
diff --git a/annotate_pgns/overfit_sanity_check.py b/annotate_pgns/overfit_sanity_check.py new file mode 100644 index 0000000..81d0a5f --- /dev/null +++ b/annotate_pgns/overfit_sanity_check.py @@ -0,0 +1,104 @@ +#!/usr/bin/env python3 +"""First-principles pipeline sanity check, 2026-10-17: can the net +even OVERFIT a small, fixed, noiseless target -- the engine's own +classic Eval() score on a few thousand real positions -- regressed +directly (plain MSE on raw pawn-ish units, no WDL sigmoid, no K, no +lambda, no quiet filter, no quantization)? + +Motivation: five independent head-to-head matches today (two nets, +two time controls, with and without the lazy probe) all showed NNUE +losing decisively to classic HCE, including at matched search depth +where ECM tactical-suite results had suggested NNUE should do well. +Before concluding anything about net/data quality, rule out a +structural pipeline bug the hard way: if this architecture, this +feature-indexing, and this training loop cannot drive error to +~zero on a target it should be trivially able to memorize (a few +thousand noiseless (position, scalar) pairs, same units/sign +convention as everything else in this pipeline), something is +broken in feature indexing, accumulator math, or the training loop +itself -- not a "real chess judgment is hard" problem. + +If this DOES overfit cleanly, the pipeline is vindicated at the +mechanical level, and today's match losses are a real net-quality +question, not a bug hunt. + +Usage: + python3 overfit_sanity_check.py --tensor-dir /tmp/overfit_tensors \ + --epochs 400 +""" +import argparse +import time +from pathlib import Path + +import numpy as np +import torch + +from nnue_model import NnueNet + + +def main(): + ap = argparse.ArgumentParser(description=__doc__, + formatter_class=argparse.RawDescriptionHelpFormatter) + ap.add_argument("--tensor-dir", required=True) + ap.add_argument("--epochs", type=int, default=400) + ap.add_argument("--lr", type=float, default=1e-3) + ap.add_argument("--seed", type=int, default=0) + args = ap.parse_args() + + torch.manual_seed(args.seed) + d = Path(args.tensor_dir) + # nn.EmbeddingBag's `offsets` wants one START index per bag (length + # N) -- the CSR files store N+1 entries (with a trailing end + # sentinel, per build_training_tensors.py), so the last entry + # must be dropped here (nnue_train.py's _gather_batch does the + # equivalent by only ever building N-length offset arrays). + white_idx = torch.from_numpy(np.load(d / "white_idx.npy").astype(np.int64)) + white_off = torch.from_numpy(np.load(d / "white_off.npy")[:-1]) + black_idx = torch.from_numpy(np.load(d / "black_idx.npy").astype(np.int64)) + black_off = torch.from_numpy(np.load(d / "black_off.npy")[:-1]) + stm = torch.from_numpy(np.load(d / "stm.npy").astype(np.int64)) + target = torch.from_numpy(np.load(d / "target_raw.npy")) + n = len(target) + print(f"Loaded {n} (position, classic_eval) pairs -- target range " + f"[{target.min():.2f}, {target.max():.2f}] pawns") + + model = NnueNet() + optimizer = torch.optim.Adam(model.parameters(), lr=args.lr) + + t0 = time.time() + for epoch in range(args.epochs): + optimizer.zero_grad() + pred = model.forward(white_idx, white_off, black_idx, black_off, stm) + loss = torch.nn.functional.mse_loss(pred, target) + loss.backward() + optimizer.step() + if epoch % 20 == 0 or epoch == args.epochs - 1: + rmse_cp = (loss.item() ** 0.5) * 100 + print(f" epoch {epoch:4d} train MSE (pawns^2) = {loss.item():.5f} " + f"RMSE = {rmse_cp:.1f}cp ({time.time() - t0:.0f}s)") + + with torch.no_grad(): + pred = model.forward(white_idx, white_off, black_idx, black_off, stm) + final_mse = torch.nn.functional.mse_loss(pred, target).item() + final_rmse_cp = (final_mse ** 0.5) * 100 + max_err_cp = (pred - target).abs().max().item() * 100 + print(f"\nFinal: train MSE = {final_mse:.5f} pawns^2, " + f"RMSE = {final_rmse_cp:.1f}cp, max |error| = {max_err_cp:.1f}cp") + print("Verdict: ", end="") + if final_rmse_cp < 10: + print("CLEAN OVERFIT -- pipeline mechanically sound. " + "Today's match losses are a net-quality/data question, not a bug.") + elif final_rmse_cp < 50: + print("PARTIAL overfit -- some signal getting through, but not " + "cleanly memorizing a noiseless target. Worth investigating " + "further (LR, architecture capacity, or a real bug) before " + "trusting any training result built on this pipeline.") + else: + print("FAILED to overfit even a tiny noiseless target -- strong " + "evidence of a structural bug (feature indexing, accumulator " + "math, or training loop), not a net-quality/data problem. " + "Do not trust any prior training result until this is found.") + + +if __name__ == "__main__": + main() |
