summaryrefslogtreecommitdiff
path: root/src/eval_tune/relabel_selfplay_pgn.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 /src/eval_tune/relabel_selfplay_pgn.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 'src/eval_tune/relabel_selfplay_pgn.py')
-rw-r--r--src/eval_tune/relabel_selfplay_pgn.py112
1 files changed, 112 insertions, 0 deletions
diff --git a/src/eval_tune/relabel_selfplay_pgn.py b/src/eval_tune/relabel_selfplay_pgn.py
new file mode 100644
index 0000000..be14e22
--- /dev/null
+++ b/src/eval_tune/relabel_selfplay_pgn.py
@@ -0,0 +1,112 @@
+#!/usr/bin/env python3
+"""
+One-off (but reusable) maintenance tool: retroactively rename the
+White/Black PGN header value for games matching a given Event + Date
+list, in place, without touching anything else in the file (moves,
+comments, other headers byte-for-byte unchanged).
+
+Why this exists, 2026-10-16: self_play_games.pgn is reused as NNUE
+training data (see docs/NNUE.md). match_play.py recorded every
+candidate engine as literally "candidate" regardless of what that
+candidate actually was, so the 2026-10-15/2026-10-16 batches (an NNUE
+candidate vs the HCE baseline, scores on the NNUE side compressed/
+uncalibrated -- see the engine-vs-engine eval discussion that same
+day) are indistinguishable from any other "candidate" batch by header
+alone. match_play.py now takes --candidate-name/--baseline-name to
+avoid this going forward; this script fixes the games written before
+that existed.
+
+Deliberately stream-edits the file line by line rather than round-
+tripping through python-chess's PGN writer -- re-serializing ~190k
+games risks reformatting move/comment text (whitespace, line wrapping)
+that downstream positional parsers (extract_positions.py) may depend
+on being byte-identical to how it was originally written. This script
+only ever rewrites a `[White "..."]`/`[Black "..."]` header line, and
+only when that game's own `[Date ...]`/`[Event ...]` headers already
+matched the target.
+
+Usage:
+ python3 relabel_selfplay_pgn.py <pgn-file> \\
+ --dates 2026-10-15 2026-10-16 \\
+ --event "typhoon self-play match" \\
+ --old-name candidate --new-name candidate_nnue_loose \\
+ [--dry-run]
+
+Writes to a sibling temp file and atomically replaces the original
+only after a full pass succeeds -- a failure partway through never
+leaves the original file half-rewritten.
+"""
+import argparse
+import os
+import re
+import sys
+
+
+HEADER_RE = re.compile(r'^\[(\w+)\s+"((?:[^"\\]|\\.)*)"\]\s*$')
+
+
+def main():
+ ap = argparse.ArgumentParser(description=__doc__,
+ formatter_class=argparse.RawDescriptionHelpFormatter)
+ ap.add_argument("pgn_file")
+ ap.add_argument("--dates", nargs="+", required=True,
+ help="Date header values (as PGN prints them, e.g. "
+ "2026-10-15) whose games are in scope")
+ ap.add_argument("--event", required=True,
+ help="Event header value a game must also match")
+ ap.add_argument("--old-name", required=True,
+ help="White/Black header value to rename")
+ ap.add_argument("--new-name", required=True,
+ help="replacement header value")
+ ap.add_argument("--dry-run", action="store_true",
+ help="report what would change, write nothing")
+ args = ap.parse_args()
+
+ target_dates = set(args.dates)
+ tmp_path = args.pgn_file + ".relabel_tmp"
+
+ cur_date = None
+ cur_event = None
+ in_scope = False # true once we know this game's headers match
+ games_seen = 0
+ games_touched = 0
+ renames = 0
+
+ with open(args.pgn_file, "r", encoding="utf-8", errors="surrogateescape") as fin, \
+ open(tmp_path, "w", encoding="utf-8", errors="surrogateescape") as fout:
+ for line in fin:
+ m = HEADER_RE.match(line)
+ if m:
+ tag, value = m.group(1), m.group(2)
+ if tag == "Event":
+ # New header block -> new game starting.
+ games_seen += 1
+ cur_event = value
+ cur_date = None
+ in_scope = False
+ elif tag == "Date":
+ cur_date = value
+ in_scope = (cur_event == args.event and cur_date in target_dates)
+ elif tag in ("White", "Black") and in_scope and value == args.old_name:
+ renames += 1
+ games_touched += 1 if tag == "White" else 0 # count once/game is enough
+ if not args.dry_run:
+ line = f'[{tag} "{args.new_name}"]\n'
+ fout.write(line)
+
+ if args.dry_run:
+ os.remove(tmp_path)
+ print(f"[dry run] {games_seen} games scanned, {renames} header "
+ f"values would be renamed ({args.old_name!r} -> "
+ f"{args.new_name!r}) for Event={args.event!r} Date in "
+ f"{sorted(target_dates)}")
+ return
+
+ os.replace(tmp_path, args.pgn_file)
+ print(f"{games_seen} games scanned, {renames} header values renamed "
+ f"({args.old_name!r} -> {args.new_name!r}) for Event={args.event!r} "
+ f"Date in {sorted(target_dates)}")
+
+
+if __name__ == "__main__":
+ sys.exit(main())