summaryrefslogtreecommitdiff
path: root/annotate_pgns/run_nnue_pipeline.py
diff options
context:
space:
mode:
authorScott Gasch <[email protected]>2026-10-20 09:26:44 -0700
committerScott Gasch <[email protected]>2026-10-20 09:26:44 -0700
commitc8c6bbc2c1a103e5b07b079a0f8bdf66c7724d80 (patch)
treeb6c2137c9ac577fa83b1e948fa0311d53dfe445d /annotate_pgns/run_nnue_pipeline.py
parent3b5d3677bb14ae14728a1c4b221176bb83667d92 (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/run_nnue_pipeline.py')
-rw-r--r--annotate_pgns/run_nnue_pipeline.py382
1 files changed, 382 insertions, 0 deletions
diff --git a/annotate_pgns/run_nnue_pipeline.py b/annotate_pgns/run_nnue_pipeline.py
new file mode 100644
index 0000000..39bc487
--- /dev/null
+++ b/annotate_pgns/run_nnue_pipeline.py
@@ -0,0 +1,382 @@
+#!/usr/bin/env python3
+"""End-to-end NNUE training pipeline driver: feed it a glob of PGN
+files that already carry per-move score comments (match_play.py's
+"{+-}NN.NN/DEPTH PV..." format -- e.g. pgn/stockfish_self_play_training_data.pgn,
+pgn/self_play_games.pgn) and (after a long time -- extract is cheap,
+but a from-scratch training run over millions of positions is not)
+it drives every remaining stage through to a .nnue weights file the
+C engine can load.
+
+This does NOT run the engine's own sd-N search (that is annotate.py's
+job, for PGNs that do NOT already have scores -- see that script and
+extract_positions.py/build_games.py instead if that is what's needed).
+Every input PGN here already has its own score/depth/PV comment on
+each move, so the only extraction step is parsing those comments --
+extract_selfplay_positions.py, despite its name, is a general reader
+for this comment format regardless of which engine(s) produced it
+(see that script's own MATE_CLAMP_THRESHOLD comment, added
+specifically to handle a foreign-engine/Stockfish-sourced corpus).
+
+Chains the existing, already-individually-tested pipeline scripts in
+this order:
+
+ 1. extract_selfplay_positions.py each input PGN -> games.jsonl
+ (one run per file, since that
+ script takes a single PGN;
+ outputs concatenated)
+ 2. split_holdout.py games.jsonl -> train/holdout
+ split (by game, not position)
+ 3. fit_k_and_label.py fits the WDL-sigmoid K on
+ train, labels both splits
+ (sharded, numpy-vectorized)
+ 4. quiet_filter_games.py drops in-check / SEE-positive-
+ capture positions from both
+ labeled splits
+ 5. build_training_tensors.py quiet-filtered games -> cached
+ CSR feature tensors (sharded)
+ 6. nnue_train.py trains a checkpoint per epoch
+ 7. export_nnue_weights.py latest checkpoint -> flat
+ .nnue binary
+
+Each stage is skipped if its expected output already exists (pass
+--force to re-run everything regardless) -- stages 5-6 here can run
+for hours, so a crash/interrupt partway through should not mean
+starting over from stage 1.
+
+This is a thin orchestrator: it shells out to the existing, individually
+tested scripts rather than reimplementing any of their logic, and
+assumes they are run from this directory (annotate_pgns/).
+
+Usage:
+ python3 run_nnue_pipeline.py \\
+ --pgn-glob '../pgn/stockfish*.pgn' \\
+ --rundir runs/stockfish_labeled \\
+ --outfile runs/stockfish_labeled/nnue_weights.nnue
+"""
+import argparse
+import glob
+import json
+import subprocess
+import sys
+import time
+from pathlib import Path
+
+HERE = Path(__file__).resolve().parent
+
+
+def split_pgn(path, n, outdir):
+ """Splits one PGN file into n roughly-equal-game-count chunk files
+ under outdir (named <path.stem>.chunk000.pgn etc.), returning their
+ paths. Game boundaries are found by scanning for lines starting
+ "[Event " (every game in this corpus starts with one -- see
+ extract_selfplay_positions.py's own game headers,
+ build_pgn_game() in match_play.py), tracked as raw byte offsets via
+ f.buffer.tell() rather than parsed/re-rendered by python-chess --
+ avoids any risk of a PGN round-trip through its writer subtly
+ reformatting a comment or header in a way
+ extract_selfplay_positions.py's own parse wouldn't expect, and
+ avoids the text-mode tell()-vs-byte-offset mismatch a
+ chess.pgn.read_game-based scan would hit. A single-PGN-file run
+ (98k games in one file, 2026-10-19's first real pipeline attempt)
+ was otherwise stuck running extract_selfplay_positions.py
+ single-threaded for well over an hour -- that parse is purely
+ per-game, embarrassingly parallel, just never sharded because the
+ pipeline only ever sharded across *separate input files*, not
+ within one big one."""
+ offsets = []
+ with open(path, "rb") as f:
+ pos = 0
+ for raw_line in f:
+ if raw_line.startswith(b"[Event "):
+ offsets.append(pos)
+ pos += len(raw_line)
+ end = pos
+
+ n_games = len(offsets)
+ if n_games == 0:
+ return [path]
+ n = min(n, n_games)
+ boundaries = [offsets[round(i * n_games / n)] for i in range(n)] + [end]
+
+ outdir = Path(outdir)
+ outdir.mkdir(parents=True, exist_ok=True)
+ stem = Path(path).stem
+ chunk_paths = []
+ with open(path, "rb") as f:
+ for i in range(n):
+ start, stop = boundaries[i], boundaries[i + 1]
+ f.seek(start)
+ data = f.read(stop - start)
+ chunk_path = outdir / f"{stem}.chunk{i:03d}.pgn"
+ with open(chunk_path, "wb") as out:
+ out.write(data)
+ chunk_paths.append(chunk_path)
+ return chunk_paths
+
+
+def run_stage(name, outputs, cmd, force):
+ """Runs cmd (a list of str/Path args) unless every path in
+ outputs already exists and force is False. Exits the whole
+ pipeline on a non-zero return code -- no point starting a later
+ stage against a broken earlier one."""
+ outputs = [Path(p) for p in outputs]
+ if not force and all(p.exists() for p in outputs):
+ print(f"[skip] {name}: all outputs already exist "
+ f"({', '.join(str(p) for p in outputs)})")
+ return
+
+ print(f"[run] {name}")
+ print(f" {' '.join(str(c) for c in cmd)}")
+ t0 = time.time()
+ subprocess.run([str(c) for c in cmd], check=True)
+ print(f"[done] {name} ({time.time() - t0:.0f}s)")
+
+
+def best_checkpoint(ckpt_dir):
+ """Returns the best-holdout checkpoint (nnue_train.py's best.json),
+ not just the highest epoch number -- a run that overfits past its
+ best epoch would otherwise silently export the worst-of-the-run
+ checkpoint just because it happened to be trained last."""
+ ckpt_dir = Path(ckpt_dir)
+ best_path = ckpt_dir / "best.json"
+ if best_path.exists():
+ best = json.loads(best_path.read_text())
+ ckpt_path = ckpt_dir / best["ckpt"]
+ print(f"[info] best checkpoint: epoch {best['epoch']}, "
+ f"holdout_mse={best['holdout_mse']:.5f} ({ckpt_path})")
+ return ckpt_path
+ # Fallback for a ckpt_dir produced before best.json existed.
+ ckpts = sorted(ckpt_dir.glob("ckpt_epoch*.pt"))
+ if not ckpts:
+ sys.exit(f"no checkpoints found in {ckpt_dir} -- nnue_train.py "
+ f"stage did not produce any?")
+ print(f"[warn] no best.json in {ckpt_dir} -- falling back to the "
+ f"last-trained epoch, not necessarily the best holdout one")
+ return ckpts[-1]
+
+
+def main():
+ ap = argparse.ArgumentParser(
+ description=__doc__,
+ formatter_class=argparse.RawDescriptionHelpFormatter)
+ ap.add_argument("--pgn-glob", action="append", required=True,
+ help="glob pattern (quoted, so this script expands "
+ "it rather than the shell) for input PGN "
+ "files, each already carrying per-move "
+ "score/depth/PV comments; repeatable")
+ ap.add_argument("--rundir", required=True,
+ help="directory for all intermediate/output "
+ "artifacts of this run")
+ ap.add_argument("--outfile", required=True,
+ help="final .nnue weights file path")
+ ap.add_argument("--event", default=None,
+ help="extract_selfplay_positions.py: only extract "
+ "games with this Event header (default: any)")
+ ap.add_argument("--dates", nargs="*", default=None,
+ help="extract_selfplay_positions.py: only extract "
+ "games with Date header in this list "
+ "(default: any)")
+ ap.add_argument("--exclude-movers", nargs="*", default=[],
+ help="extract_selfplay_positions.py: skip plies "
+ "whose mover's PGN name is in this list")
+ ap.add_argument("--holdout-frac", type=float, default=0.1,
+ help="split_holdout.py's game-level holdout "
+ "fraction")
+ ap.add_argument("--workers", type=int, default=16,
+ help="worker processes for every sharded Python "
+ "stage (fit_k_and_label.py, "
+ "build_training_tensors.py). Default 16, "
+ "kept below this box's 24 cores to leave "
+ "other jobs some breathing room")
+ ap.add_argument("--threads", type=int, default=16,
+ help="torch CPU thread count for nnue_train.py, "
+ "same reasoning as --workers (ignored if "
+ "--device is not cpu)")
+ ap.add_argument("--device", default="cpu",
+ help="nnue_train.py's --device, e.g. 'cuda' on a "
+ "rented GPU box")
+ ap.add_argument("--lambda", dest="lam", type=float, default=0.9,
+ help="build_training_tensors.py's "
+ "target = lambda*p_search + (1-lambda)*r_outcome "
+ "for non-exact rows")
+ ap.add_argument("--epochs", type=int, default=20)
+ ap.add_argument("--patience", type=int, default=0,
+ help="nnue_train.py's --patience (0 disables early "
+ "stopping, always runs the full --epochs)")
+ ap.add_argument("--batch-size", type=int, default=8192)
+ ap.add_argument("--lr", type=float, default=1e-3)
+ ap.add_argument("--loss-exponent", type=float, default=2.0)
+ ap.add_argument("--material-loss-weight", type=float, default=0.0)
+ ap.add_argument("--force", action="store_true",
+ help="re-run every stage even if its outputs "
+ "already exist")
+ args = ap.parse_args()
+
+ pgn_paths = sorted({p for pattern in args.pgn_glob
+ for p in glob.glob(pattern)})
+ if not pgn_paths:
+ sys.exit(f"--pgn-glob matched no files: {args.pgn_glob!r}")
+ print(f"Input PGNs ({len(pgn_paths)}): {pgn_paths}")
+
+ rundir = Path(args.rundir)
+ rundir.mkdir(parents=True, exist_ok=True)
+
+ games_path = rundir / "games.jsonl"
+ train_path = rundir / "games.train.jsonl"
+ holdout_path = rundir / "games.holdout.jsonl"
+ train_labeled_path = rundir / "games.train.labeled.jsonl"
+ holdout_labeled_path = rundir / "games.holdout.labeled.jsonl"
+ train_quiet_path = rundir / "games.train.labeled.quiet.jsonl"
+ holdout_quiet_path = rundir / "games.holdout.labeled.quiet.jsonl"
+ tensors_train_dir = rundir / "tensors_train"
+ tensors_holdout_dir = rundir / "tensors_holdout"
+ ckpt_dir = rundir / "nnue_ckpt"
+
+ event_flag = ["--event", args.event] if args.event is not None else []
+ dates_flag = ["--dates", *args.dates] if args.dates else []
+ exclude_flag = (["--exclude-movers", *args.exclude_movers]
+ if args.exclude_movers else [])
+
+ # 1. Extract already-scored positions. extract_selfplay_positions.py
+ # is purely per-game, embarrassingly parallel -- shard each input
+ # PGN into args.workers game-aligned chunks (split_pgn, raw byte
+ # copy, no PGN re-render) and run one extraction process per chunk
+ # concurrently, rather than one single-threaded process per whole
+ # input file (which is what left a 98k-game single-file run stuck
+ # for over an hour on 2026-10-19).
+ split_dir = rundir / "pgn_splits"
+ all_chunks = [] # (chunk_path, part_path) pairs, in output order
+ for pgn_path in pgn_paths:
+ chunks = split_pgn(pgn_path, args.workers, split_dir)
+ for chunk_path in chunks:
+ part_path = rundir / f"{chunk_path.stem}.jsonl"
+ all_chunks.append((chunk_path, part_path))
+
+ pending = [(c, p) for c, p in all_chunks if args.force or not p.exists()]
+ if pending:
+ print(f"[run] extract_selfplay_positions.py: {len(pending)} chunk(s) "
+ f"across {len(pgn_paths)} input file(s), up to {args.workers} "
+ f"concurrent")
+ t0 = time.time()
+ procs = []
+ for chunk_path, part_path in pending:
+ cmd = [sys.executable, HERE / "extract_selfplay_positions.py",
+ chunk_path, "--out", part_path,
+ *event_flag, *dates_flag, *exclude_flag]
+ while len(procs) >= args.workers:
+ procs = [p for p in procs if p.poll() is None]
+ if len(procs) >= args.workers:
+ time.sleep(0.5)
+ procs.append(subprocess.Popen([str(c) for c in cmd]))
+ for p in procs:
+ p.wait()
+ failed = [p for p in procs if p.returncode != 0]
+ if failed:
+ sys.exit(f"extract_selfplay_positions.py: {len(failed)} chunk(s) "
+ f"failed -- aborting pipeline")
+ print(f"[done] extract_selfplay_positions.py ({time.time() - t0:.0f}s)")
+ else:
+ print(f"[skip] extract_selfplay_positions.py: all "
+ f"{len(all_chunks)} chunk outputs already exist")
+
+ if args.force or not games_path.exists():
+ print(f"[run] concatenating {len(all_chunks)} parts -> {games_path}")
+ with open(games_path, "w") as out:
+ for _, part_path in all_chunks:
+ with open(part_path) as f:
+ out.write(f.read())
+ else:
+ print(f"[skip] concatenate: {games_path} already exists")
+
+ # 2. Holdout split (by game).
+ run_stage(
+ "split_holdout.py", [train_path, holdout_path],
+ [sys.executable, HERE / "split_holdout.py",
+ "--in", games_path, "--train-out", train_path,
+ "--holdout-out", holdout_path,
+ "--holdout-frac", args.holdout_frac],
+ args.force)
+
+ # 3. Fit K + label both splits (sharded, numpy-vectorized).
+ run_stage(
+ "fit_k_and_label.py", [train_labeled_path, holdout_labeled_path],
+ [sys.executable, HERE / "fit_k_and_label.py",
+ "--train", train_path, "--holdout", holdout_path,
+ "--train-out", train_labeled_path,
+ "--holdout-out", holdout_labeled_path,
+ "--workers", args.workers],
+ args.force)
+
+ # 4. Quiet-filter both labeled splits.
+ run_stage(
+ "quiet_filter_games.py (train)", [train_quiet_path],
+ [sys.executable, HERE / "quiet_filter_games.py",
+ "--in", train_labeled_path, "--out", train_quiet_path,
+ "--workers", args.workers],
+ args.force)
+ run_stage(
+ "quiet_filter_games.py (holdout)", [holdout_quiet_path],
+ [sys.executable, HERE / "quiet_filter_games.py",
+ "--in", holdout_labeled_path, "--out", holdout_quiet_path,
+ "--workers", args.workers],
+ args.force)
+
+ # 5. Build cached CSR feature tensors (sharded).
+ run_stage(
+ "build_training_tensors.py (train)",
+ [tensors_train_dir / "target.npy"],
+ [sys.executable, HERE / "build_training_tensors.py",
+ "--in", train_quiet_path, "--outdir", tensors_train_dir,
+ "--lambda", args.lam, "--workers", args.workers],
+ args.force)
+ run_stage(
+ "build_training_tensors.py (holdout)",
+ [tensors_holdout_dir / "target.npy"],
+ [sys.executable, HERE / "build_training_tensors.py",
+ "--in", holdout_quiet_path, "--outdir", tensors_holdout_dir,
+ "--lambda", args.lam, "--workers", args.workers],
+ args.force)
+
+ # 6. Train. Marker: train_complete.marker, written by nnue_train.py
+ # itself right at the end of main() -- NOT the final epoch's
+ # checkpoint file, which --patience early stopping may legitimately
+ # mean never gets written, and which would otherwise make a clean
+ # early-stopped run look unfinished forever to this existence
+ # check. If a previous attempt left partial epoch checkpoints
+ # without that marker (killed partway, or crashed), resume from the
+ # latest one instead of silently restarting at epoch 0 and
+ # discarding completed epochs.
+ train_complete_marker = ckpt_dir / "train_complete.marker"
+ resume_flag = []
+ if not args.force and not train_complete_marker.exists():
+ existing_ckpts = sorted(ckpt_dir.glob("ckpt_epoch*.pt"))
+ if existing_ckpts:
+ resume_flag = ["--resume", existing_ckpts[-1]]
+ print(f"[info] nnue_train.py: resuming from "
+ f"{existing_ckpts[-1]} (no train_complete.marker found)")
+ run_stage(
+ "nnue_train.py", [train_complete_marker],
+ [sys.executable, HERE / "nnue_train.py",
+ "--train-dir", tensors_train_dir,
+ "--holdout-dir", tensors_holdout_dir,
+ "--outdir", ckpt_dir, *resume_flag,
+ "--epochs", args.epochs, "--batch-size", args.batch_size,
+ "--lr", args.lr, "--loss-exponent", args.loss_exponent,
+ "--material-loss-weight", args.material_loss_weight,
+ "--threads", args.threads, "--device", args.device,
+ "--patience", args.patience],
+ args.force)
+
+ # 7. Export the latest checkpoint to the flat .nnue binary format.
+ ckpt = best_checkpoint(ckpt_dir)
+ run_stage(
+ "export_nnue_weights.py", [args.outfile],
+ [sys.executable, HERE / "export_nnue_weights.py",
+ ckpt, args.outfile, "--tensor-dir", tensors_train_dir],
+ args.force)
+
+ print(f"\nPipeline complete: {args.outfile}")
+
+
+if __name__ == "__main__":
+ main()