summaryrefslogtreecommitdiff
path: root/annotate_pgns
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
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')
-rw-r--r--annotate_pgns/GNUmakefile54
-rw-r--r--annotate_pgns/annotate.py52
-rw-r--r--annotate_pgns/build_training_tensors.py226
-rw-r--r--annotate_pgns/compare_vs_stockfish_net.py183
-rw-r--r--annotate_pgns/export_nnue_weights.py326
-rw-r--r--annotate_pgns/export_nnue_weights_v1_baseline.py23
-rw-r--r--annotate_pgns/extract_positions.py13
-rw-r--r--annotate_pgns/extract_selfplay_positions.py247
-rw-r--r--annotate_pgns/fit_k_and_label.py257
-rw-r--r--annotate_pgns/nnue_model.py98
-rw-r--r--annotate_pgns/nnue_train.py221
-rw-r--r--annotate_pgns/overfit_sanity_check.py104
-rw-r--r--annotate_pgns/parse_stockfish_net.py117
-rw-r--r--annotate_pgns/quiet_filter_games.py43
-rw-r--r--annotate_pgns/run_nnue_pipeline.py382
-rw-r--r--annotate_pgns/sample_egtb_positions.py202
-rwxr-xr-xannotate_pgns/sanity_check_net.py306
-rw-r--r--annotate_pgns/shard_quiet_filter.py93
18 files changed, 2803 insertions, 144 deletions
diff --git a/annotate_pgns/GNUmakefile b/annotate_pgns/GNUmakefile
new file mode 100644
index 0000000..29fd78d
--- /dev/null
+++ b/annotate_pgns/GNUmakefile
@@ -0,0 +1,54 @@
+# Packages just the NNUE training pipeline's Python driver + its
+# dependencies (NOT the input PGN -- that's passed to
+# run_nnue_pipeline.py's --pgn-glob at runtime, shipped separately) into
+# a single tar.bz2 for copying to a rented GPU box. See docs/NNUE.md.
+#
+# Usage:
+# make -C annotate_pgns bundle
+# make -C annotate_pgns clean-bundle
+
+BUNDLE := nnue_pipeline_bundle.tar.bz2
+
+# This directory's own driver + everything it imports.
+PIPELINE_FILES := \
+ run_nnue_pipeline.py \
+ extract_selfplay_positions.py \
+ split_holdout.py \
+ fit_k_and_label.py \
+ quiet_filter_games.py \
+ quiet_filter.py \
+ quiet_common.py \
+ build_training_tensors.py \
+ nnue_feature_spec.py \
+ nnue_model.py \
+ nnue_train.py \
+ export_nnue_weights.py \
+ sample_egtb_positions.py
+
+# quiet_common.py reaches into ../src/eval_tune for its SEE logic
+# (tune_eval_dna.py's static_exchange_eval/has_hanging_material), and
+# dna_diff.py parses ../src/eval.c at import time for the DNA term
+# list -- all three are genuine runtime dependencies, not optional
+# extras, even though they live outside this directory. See
+# quiet_common.py's own docstring.
+EXTERNAL_FILES := \
+ ../src/eval_tune/tune_eval_dna.py \
+ ../src/eval_tune/dna_diff.py \
+ ../src/eval.c
+
+ALL_FILES := $(PIPELINE_FILES) $(EXTERNAL_FILES)
+
+.PHONY: bundle clean-bundle
+
+bundle: $(BUNDLE)
+
+$(BUNDLE): $(ALL_FILES)
+ tar -cjf $@ -C .. \
+ $(addprefix annotate_pgns/,$(PIPELINE_FILES)) \
+ $(patsubst ../%,%,$(EXTERNAL_FILES))
+ @echo "Wrote $@ ($$(du -h $@ | cut -f1)) -- the input PGN is NOT" \
+ "included; pass it separately via run_nnue_pipeline.py's" \
+ "--pgn-glob on the rented box."
+
+clean-bundle:
+ rm -f $(BUNDLE)
diff --git a/annotate_pgns/annotate.py b/annotate_pgns/annotate.py
index 240b55f..1f8d02d 100644
--- a/annotate_pgns/annotate.py
+++ b/annotate_pgns/annotate.py
@@ -29,8 +29,13 @@ import chess.pgn
from quiet_common import position_is_quiet
TELLOTHERS_RE = re.compile(
- r"^tellothers d(\d+), ([+-]?[\d.]+), [\d.]+s,\s*\d+knps, PV=(.*)$"
+ r"^tellothers d(\d+), ([+-]?[\w.]+), [\d.]+s,\s*\d+knps, PV=(.*)$"
)
+FLOAT_RE = re.compile(r"^[+-]?\d+\.\d+$")
+MATE_RE = re.compile(r"^([+-])MATE(\d*)$") # digit(s) optional -- the
+# "FORCED MATE --> stop searching now" immediate-short-circuit path
+# (search.c) prints a bare "+MATE"/"-MATE" with no distance number at
+# all, not just the ordinary "+MATE<N>" case.
def load_games(input_paths, max_games=None):
@@ -122,17 +127,52 @@ def run_shard(engine_path, script_path, out_path, hash_size, cpus):
def parse_shard_output(out_path):
- """Return a list of (depth, score, pv_string) in script order."""
+ """Return a list of (depth, score_token, pv_string) in script
+ order. score_token is kept as the engine's raw tellothers token
+ (an ordinary "+1.23"-style float string, or "+MATE<N>"/"-MATE<N>")
+ rather than eagerly parsed to float here -- a MATE token has no
+ pawns magnitude to parse in the first place, and silently dropping
+ any line that doesn't match a plain float is exactly the bug that
+ used to desync this function's output from `index` (every position
+ after a dropped mate/EGTB line in a shard got the wrong score) --
+ see docs/NNUE.md's "Bug found and fixed in annotate.py's output
+ parser" status entry, previously patched only in the separate
+ extract_positions.py reader, not here."""
results = []
with open(out_path, encoding="utf-8", errors="replace") as f:
for line in f:
m = TELLOTHERS_RE.match(line.rstrip("\n"))
if m:
- depth, score, pv = m.groups()
- results.append((int(depth), float(score), pv.strip()))
+ depth, score_tok, pv = m.groups()
+ results.append((int(depth), score_tok, pv.strip()))
return results
+def _format_comment(depth, score_tok, pv):
+ """Render one (depth, score_token, pv) result as a PGN comment in
+ the same "{+-}SCORE/DEPTH PV" convention match_play.py's
+ _format_eval_comment uses for self-play games (and
+ extract_selfplay_positions.py's COMMENT_RE expects on read) --
+ translates the engine's own raw "+MATE<N>"/"-MATE<N>" tellothers
+ token into that convention's "+M"/"-M" (no distance digit, matching
+ match_play.py exactly -- mate distance isn't independently verified
+ here either). The PV's own sentinel token (<EGTB>/<REC>/<=>/<#>),
+ when present, is passed through verbatim since that is exactly what
+ both extract_positions.py and extract_selfplay_positions.py key
+ their exactness detection off of."""
+ m = MATE_RE.match(score_tok)
+ if FLOAT_RE.match(score_tok):
+ score_str = score_tok
+ elif m:
+ score_str = "+M" if m.group(1) == "+" else "-M"
+ else:
+ score_str = score_tok # unrecognized token: pass through as-is
+ comment = f"{score_str}/{depth}"
+ if pv:
+ comment += f" {pv}"
+ return comment
+
+
def annotate_shard(shard_games_list, index, results):
"""Attach comments to each game's mainline nodes in place. index and
results are both in script order and the same length; index maps
@@ -148,8 +188,8 @@ def annotate_shard(shard_games_list, index, results):
while node.variations:
node = node.variations[0]
if pi in scores:
- depth, score, pv = scores[pi]
- node.comment = f"{score:+.2f}/{depth} {pv}"
+ depth, score_tok, pv = scores[pi]
+ node.comment = _format_comment(depth, score_tok, pv)
pi += 1
diff --git a/annotate_pgns/build_training_tensors.py b/annotate_pgns/build_training_tensors.py
index 60e51e6..0f69d6c 100644
--- a/annotate_pgns/build_training_tensors.py
+++ b/annotate_pgns/build_training_tensors.py
@@ -12,26 +12,81 @@ index array + a row-offset array per perspective), exactly the input
shape torch.nn.EmbeddingBag expects -- no fixed-width padding needed
since bag sizes vary (3-32 active features per position).
-Skips exact rows (no p_search/r_outcome label -- see NNUE.md: a
-mate/EGTB/draw-claim score is a game-theoretic fact, not something to
-fit a sigmoid-WDL target against).
+Exact rows (mate/EGTB/draw-claim positions) are never fit against the
+sigmoid-WDL p_search target -- see NNUE.md: that score is a
+game-theoretic fact, not noisy evidence to calibrate a sigmoid
+against. But as of the EGTB-ground-truth-mixing decision, an exact row
+that extraction could assign a direct r_outcome (1.0/0.0/0.5,
+mover-relative) to is NOT dropped -- it is written to the tensors with
+target = r_outcome directly, lambda-blend bypassed entirely, which is
+exactly what the "mix TB ground truth into training" literature
+describes. Only rows with neither p_search nor a determinable
+r_outcome (unparseable comments, the "only"-move placeholder, an
+exact row from data predating this fix) are still skipped.
Usage:
python3 build_training_tensors.py \
--in runs/sd10_twic_2M/games.train.labeled.quiet.jsonl \
--outdir runs/sd10_twic_2M/tensors_train \
--lambda 1.0
+
+Per-position feature-index computation (chess.Board(fen) + SEE-free
+active_feature_indices) is the expensive, embarrassingly-parallel part
+-- positions have no cross-dependency. --workers (default 16) shards
+the input across that many processes via multiprocessing.Pool,
+stitching results back together in input order so output is
+byte-for-byte identical to the old single-threaded path. --workers 1
+restores that original sequential path; --limit (smoke testing) always
+forces --workers 1 since it depends on exact file-order position
+counting.
"""
import argparse
import array
import json
import sys
+from multiprocessing import Pool
from pathlib import Path
import numpy as np
from nnue_feature_spec import active_feature_indices, chess
+# Standard piece values for the auxiliary material-probe target
+# (nnue_model.py's material_probe, added 2026-10-17 after a measured
+# failure -- see that module's docstring). Plain material count,
+# mover-relative, NOT run through the WDL sigmoid -- this is a
+# straight regression target, deliberately non-saturating. Kings
+# excluded (always 1-1 on a legal board, carries no signal).
+_PIECE_VALUES = {
+ chess.PAWN: 1, chess.KNIGHT: 3, chess.BISHOP: 3,
+ chess.ROOK: 5, chess.QUEEN: 9,
+}
+
+
+def position_target(pos, lam):
+ """Returns the training target for one position, or None if it's
+ unusable (no p_search and no determinable r_outcome). Exact rows
+ with a direct r_outcome bypass p_search/lambda entirely -- see
+ this module's docstring and NNUE.md's EGTB-ground-truth-mixing
+ decision."""
+ if pos.get("exact"):
+ return pos.get("r_outcome")
+ if "p_search" not in pos:
+ return None
+ p_search = pos["p_search"]
+ r_outcome = pos.get("r_outcome", p_search)
+ return lam * p_search + (1.0 - lam) * r_outcome
+
+
+def material_balance(board):
+ """Mover-relative material balance in pawns, +ve = side to move
+ is materially ahead."""
+ total = 0
+ for piece_type, value in _PIECE_VALUES.items():
+ total += value * len(board.pieces(piece_type, chess.WHITE))
+ total -= value * len(board.pieces(piece_type, chess.BLACK))
+ return total if board.turn == chess.WHITE else -total
+
def iter_positions(path):
with open(path) as f:
@@ -41,20 +96,100 @@ def iter_positions(path):
yield pos
+def iter_chunks(iterable, chunk_size):
+ """Batches an iterable into lists of up to chunk_size items -- the
+ unit of work handed to one worker process at a time."""
+ chunk = []
+ for item in iterable:
+ chunk.append(item)
+ if len(chunk) >= chunk_size:
+ yield chunk
+ chunk = []
+ if chunk:
+ yield chunk
+
+
+def process_chunk(args):
+ """Runs in a worker process: the same per-position feature-index /
+ target / material computation main() used to do inline, just over
+ one chunk. Returns flat, already-concatenated arrays for the chunk
+ (not yet offset-adjusted -- that happens once, cheaply, back in
+ the main process when chunks are stitched together in order) plus
+ a per-position length list so the caller can rebuild the CSR
+ offset array without re-deriving it from the index array."""
+ positions, lam = args
+ white_idx = array.array("i")
+ black_idx = array.array("i")
+ white_lens = array.array("i")
+ black_lens = array.array("i")
+ stm = []
+ target = []
+ material = []
+ n_total = len(positions)
+ n_skipped = 0
+
+ for pos in positions:
+ t = position_target(pos, lam)
+ if t is None:
+ n_skipped += 1
+ continue
+
+ board = chess.Board(pos["fen"])
+ w = active_feature_indices(board, chess.WHITE)
+ b = active_feature_indices(board, chess.BLACK)
+
+ white_idx.extend(w)
+ white_lens.append(len(w))
+ black_idx.extend(b)
+ black_lens.append(len(b))
+
+ stm.append(1 if board.turn == chess.WHITE else 0)
+ target.append(t)
+ material.append(material_balance(board))
+
+ return (white_idx, white_lens, black_idx, black_lens, stm, target,
+ material, n_total, n_skipped)
+
+
def main():
ap = argparse.ArgumentParser(description=__doc__)
ap.add_argument("--in", dest="infile", required=True)
ap.add_argument("--outdir", required=True)
- ap.add_argument("--lambda", dest="lam", type=float, default=1.0,
+ ap.add_argument("--lambda", dest="lam", type=float, default=0.9,
help="target = lambda*p_search + (1-lambda)*r_outcome "
- "(Phase A default 1.0, pure p_search -- see NNUE.md)")
+ "for non-exact rows (exact rows always use "
+ "r_outcome directly, ignoring lambda). Default "
+ "0.9: with a Stockfish-sourced self-play corpus, "
+ "p_search (SF's own WDL-calibrated search score) "
+ "is a much lower-variance per-position signal "
+ "than one game's actual outcome, so most of the "
+ "weight stays on it -- see NNUE.md)")
ap.add_argument("--limit", type=int, default=None,
- help="stop after this many usable positions (smoke testing)")
+ help="stop after this many usable positions (smoke "
+ "testing) -- forces --workers 1, since the limit "
+ "is applied to the global used-position count in "
+ "file order, which only a single sequential pass "
+ "can honor exactly")
+ ap.add_argument("--workers", type=int, default=16,
+ help="worker processes for the per-position feature-"
+ "index computation (the expensive, embarrassingly"
+ "-parallel part -- each position is independent). "
+ "1 disables multiprocessing and runs the original "
+ "sequential path")
+ ap.add_argument("--chunk-size", type=int, default=20000,
+ help="positions per unit of work handed to a worker; "
+ "only relevant when --workers > 1")
args = ap.parse_args()
outdir = Path(args.outdir)
outdir.mkdir(parents=True, exist_ok=True)
+ workers = args.workers
+ if args.limit is not None and workers != 1:
+ print("--limit given: forcing --workers 1 for exact, "
+ "file-order-respecting smoke-test behavior", file=sys.stderr)
+ workers = 1
+
# array.array('i') is a packed C int buffer -- avoids the ~28
# bytes/element overhead of a Python list of boxed ints, which
# matters once nnz reaches tens of millions.
@@ -64,39 +199,73 @@ def main():
black_off = [0]
stm = [] # 1 = white to move, 0 = black
target = []
+ material = [] # auxiliary material-probe target, mover-relative pawns
n_total = 0
n_used = 0
n_skipped = 0
- for pos in iter_positions(args.infile):
- n_total += 1
- if args.limit is not None and n_used >= args.limit:
- break
- if pos.get("exact") or "p_search" not in pos:
- n_skipped += 1
- continue
+ if workers == 1:
+ for pos in iter_positions(args.infile):
+ n_total += 1
+ if args.limit is not None and n_used >= args.limit:
+ break
+ t = position_target(pos, args.lam)
+ if t is None:
+ n_skipped += 1
+ continue
- board = chess.Board(pos["fen"])
- w = active_feature_indices(board, chess.WHITE)
- b = active_feature_indices(board, chess.BLACK)
+ board = chess.Board(pos["fen"])
+ w = active_feature_indices(board, chess.WHITE)
+ b = active_feature_indices(board, chess.BLACK)
- white_idx.extend(w)
- white_off.append(white_off[-1] + len(w))
- black_idx.extend(b)
- black_off.append(black_off[-1] + len(b))
+ white_idx.extend(w)
+ white_off.append(white_off[-1] + len(w))
+ black_idx.extend(b)
+ black_off.append(black_off[-1] + len(b))
- stm.append(1 if board.turn == chess.WHITE else 0)
- p_search = pos["p_search"]
- r_outcome = pos.get("r_outcome", p_search)
- target.append(args.lam * p_search + (1.0 - args.lam) * r_outcome)
+ stm.append(1 if board.turn == chess.WHITE else 0)
+ target.append(t)
+ material.append(material_balance(board))
+
+ n_used += 1
+ if n_used % 200000 == 0:
+ print(f" ...{n_used} positions processed", file=sys.stderr)
+ else:
+ # Chunked across worker processes, but stitched back together
+ # in input order (Pool.imap preserves task order, unlike
+ # imap_unordered) -- output is byte-for-byte identical to the
+ # sequential path, just computed faster on an idle multi-core
+ # box. Each chunk's own CSR lengths get turned into this
+ # process's running cumulative offsets here, cheaply, since
+ # that's the one step that has to stay sequential.
+ chunks = ((chunk, args.lam)
+ for chunk in iter_chunks(iter_positions(args.infile),
+ args.chunk_size))
+ with Pool(processes=workers) as pool:
+ for (w_idx, w_lens, b_idx, b_lens, chunk_stm, chunk_target,
+ chunk_material, chunk_total,
+ chunk_skipped) in pool.imap(process_chunk, chunks):
+ n_total += chunk_total
+ n_skipped += chunk_skipped
+
+ white_idx.extend(w_idx)
+ for length in w_lens:
+ white_off.append(white_off[-1] + length)
+ black_idx.extend(b_idx)
+ for length in b_lens:
+ black_off.append(black_off[-1] + length)
+
+ stm.extend(chunk_stm)
+ target.extend(chunk_target)
+ material.extend(chunk_material)
- n_used += 1
- if n_used % 200000 == 0:
- print(f" ...{n_used} positions processed", file=sys.stderr)
+ n_used += len(chunk_stm)
+ print(f" ...{n_used} positions processed "
+ f"({n_total} read)", file=sys.stderr)
print(f"Read {n_total} rows ({args.infile})")
- print(f" {n_skipped} skipped (exact / no p_search label)")
+ print(f" {n_skipped} skipped (no p_search and no determinable r_outcome)")
print(f" {n_used} usable positions")
np.save(outdir / "white_idx.npy", np.frombuffer(white_idx, dtype=np.int32))
@@ -105,6 +274,7 @@ def main():
np.save(outdir / "black_off.npy", np.array(black_off, dtype=np.int64))
np.save(outdir / "stm.npy", np.array(stm, dtype=np.uint8))
np.save(outdir / "target.npy", np.array(target, dtype=np.float32))
+ np.save(outdir / "material.npy", np.array(material, dtype=np.float32))
nnz_white = len(white_idx)
nnz_black = len(black_idx)
diff --git a/annotate_pgns/compare_vs_stockfish_net.py b/annotate_pgns/compare_vs_stockfish_net.py
new file mode 100644
index 0000000..0fe7514
--- /dev/null
+++ b/annotate_pgns/compare_vs_stockfish_net.py
@@ -0,0 +1,183 @@
+#!/usr/bin/env python3
+"""Compares our trained checkpoints against a real, matching-commit
+Stockfish build's NNUE evaluation on sampled real positions.
+
+NOT a weight-reuse step (see conversation: the goal is a from-scratch
+net) -- this is purely a diagnostic to see how far our net's score
+scale/ranking diverges from a known-strong reference, and whether
+feature factorization measurably closes that gap vs. the unfactored
+run.
+
+Stockfish's "NNUE evaluation" line (not "Final evaluation", which
+blends in a classical/hybrid term we have no equivalent of) is the
+directly comparable quantity -- both it and our forward() are pure
+NNUE output, no classical blending, both white-side-relative after
+sign conversion.
+
+Usage:
+ python3 compare_vs_stockfish_net.py \
+ --sample-file runs/stockfish_selfplay/games.holdout.labeled.quiet.jsonl \
+ --n-positions 1000 \
+ --sf-binary /tmp/sf_38e616/src/stockfish \
+ --sf-net nn-fdc1d0fe6455.nnue \
+ --ckpt-unfactored runs/stockfish_selfplay/nnue_ckpt_lambda075/ckpt_epoch033.pt \
+ --ckpt-factored runs/stockfish_selfplay/nnue_ckpt_lambda075_factored/ckpt_epoch033.pt
+"""
+import argparse
+import json
+import random
+import re
+import subprocess
+import sys
+
+import chess
+import numpy as np
+import torch
+
+from nnue_feature_spec import active_feature_indices
+from nnue_model import NnueNet
+
+NNUE_EVAL_RE = re.compile(r"NNUE evaluation\s+([+-]?\d+\.\d+) \(white side\)")
+
+
+def sample_fens(sample_file, n, seed):
+ rng = random.Random(seed)
+ reservoir = []
+ with open(sample_file) as f:
+ seen = 0
+ for line in f:
+ game = json.loads(line)
+ for pos in game["positions"]:
+ if pos.get("exact"):
+ continue
+ seen += 1
+ if len(reservoir) < n:
+ reservoir.append(pos["fen"])
+ else:
+ j = rng.randint(0, seen - 1)
+ if j < n:
+ reservoir[j] = pos["fen"]
+ return reservoir
+
+
+def load_model(ckpt_path):
+ ckpt = torch.load(ckpt_path, map_location="cpu", weights_only=True)
+ model = NnueNet()
+ missing, unexpected = model.load_state_dict(ckpt["model"], strict=False)
+ bad_missing = [k for k in missing if k != "ft_p.weight"]
+ if bad_missing or unexpected:
+ sys.exit(f"{ckpt_path}: load_state_dict mismatch -- "
+ f"missing={bad_missing}, unexpected={unexpected}")
+ model.eval()
+ return model
+
+
+def our_score_white_relative(model, fen):
+ board = chess.Board(fen)
+ w = active_feature_indices(board, chess.WHITE)
+ b = active_feature_indices(board, chess.BLACK)
+ w_idx = torch.tensor(w, dtype=torch.long)
+ w_off = torch.tensor([0], dtype=torch.long)
+ b_idx = torch.tensor(b, dtype=torch.long)
+ b_off = torch.tensor([0], dtype=torch.long)
+ stm = torch.tensor([1 if board.turn == chess.WHITE else 0], dtype=torch.long)
+ with torch.no_grad():
+ raw = model(w_idx, w_off, b_idx, b_off, stm).item()
+ return raw if board.turn == chess.WHITE else -raw
+
+
+def stockfish_scores_white_relative(sf_binary, sf_net, fens):
+ """One persistent Stockfish process, one 'position fen ...; eval'
+ round-trip per FEN, parsing the 'NNUE evaluation' line. Much
+ cheaper than spawning a fresh process per position."""
+ proc = subprocess.Popen(
+ [sf_binary], stdin=subprocess.PIPE, stdout=subprocess.PIPE,
+ stderr=subprocess.DEVNULL, text=True, bufsize=1)
+
+ def send(cmd):
+ proc.stdin.write(cmd + "\n")
+ proc.stdin.flush()
+
+ send(f"setoption name EvalFile value {sf_net}")
+ send("isready")
+ while True:
+ line = proc.stdout.readline()
+ if not line or "readyok" in line:
+ break
+
+ scores = []
+ for fen in fens:
+ send(f"position fen {fen}")
+ send("eval")
+ score = None
+ while True:
+ line = proc.stdout.readline()
+ if not line:
+ sys.exit("Stockfish process died mid-eval -- net likely "
+ "failed to load (check EvalFile path/error output)")
+ m = NNUE_EVAL_RE.search(line)
+ if m:
+ score = float(m.group(1))
+ if "Final evaluation" in line:
+ break
+ scores.append(score)
+ send("quit")
+ proc.wait(timeout=5)
+ return scores
+
+
+def main():
+ ap = argparse.ArgumentParser(description=__doc__,
+ formatter_class=argparse.RawDescriptionHelpFormatter)
+ ap.add_argument("--sample-file", required=True)
+ ap.add_argument("--n-positions", type=int, default=1000)
+ ap.add_argument("--seed", type=int, default=0)
+ ap.add_argument("--sf-binary", required=True)
+ ap.add_argument("--sf-net", required=True)
+ ap.add_argument("--ckpt-unfactored", required=True)
+ ap.add_argument("--ckpt-factored", required=True)
+ args = ap.parse_args()
+
+ print(f"Sampling {args.n_positions} non-exact positions from {args.sample_file} ...")
+ fens = sample_fens(args.sample_file, args.n_positions, args.seed)
+ print(f" got {len(fens)} FENs")
+
+ print("Loading checkpoints ...")
+ model_unfactored = load_model(args.ckpt_unfactored)
+ model_factored = load_model(args.ckpt_factored)
+
+ print(f"Running {args.sf_binary} (net={args.sf_net}) ...")
+ sf_scores = stockfish_scores_white_relative(args.sf_binary, args.sf_net, fens)
+
+ print("Running our checkpoints ...")
+ ours_unfactored = [our_score_white_relative(model_unfactored, fen) for fen in fens]
+ ours_factored = [our_score_white_relative(model_factored, fen) for fen in fens]
+
+ sf = np.array(sf_scores, dtype=np.float64)
+ uf = np.array(ours_unfactored, dtype=np.float64)
+ fa = np.array(ours_factored, dtype=np.float64)
+
+ def report(name, ours):
+ diff = ours - sf
+ corr = np.corrcoef(ours, sf)[0, 1]
+ # best-fit scale (least squares, no intercept) -- how far off is
+ # our net's overall SCALE from Stockfish's, independent of the
+ # per-position noise captured by correlation/residual.
+ scale = float(np.dot(ours, sf) / np.dot(ours, ours))
+ resid_after_scale = ours * scale - sf
+ print(f"\n--- {name} vs Stockfish (n={len(sf)}) ---")
+ print(f" correlation: {corr:.4f}")
+ print(f" raw mean|diff|: {np.abs(diff).mean():.3f} pawns")
+ print(f" raw median diff: {np.median(diff):.3f} pawns")
+ print(f" best-fit scale (ours->sf): {scale:.3f}")
+ print(f" mean|resid| after scale-fit: {np.abs(resid_after_scale).mean():.3f} pawns")
+
+ report("Unfactored", uf)
+ report("Factored", fa)
+
+ np.savez("/tmp/compare_vs_sf.npz", sf=sf, unfactored=uf, factored=fa, fens=np.array(fens))
+ print("\nSaved raw arrays to /tmp/compare_vs_sf.npz")
+
+
+if __name__ == "__main__":
+ sys.exit(main())
diff --git a/annotate_pgns/export_nnue_weights.py b/annotate_pgns/export_nnue_weights.py
new file mode 100644
index 0000000..4c8cb99
--- /dev/null
+++ b/annotate_pgns/export_nnue_weights.py
@@ -0,0 +1,326 @@
+#!/usr/bin/env python3
+"""Exports a trained nnue_model.py checkpoint into a flat binary file
+the C engine (src/nnueeval.c's NnueLoadWeights) can read directly --
+no parsing, just a fixed-order sequence of arrays behind a small
+header.
+
+File format (all little-endian), version "NNU3":
+ magic: 4 bytes, ASCII "NNU3"
+ dims: 4x uint32 -- NNUE_DIMENSIONS, L1, L2, L3 (sanity-checked
+ against the C side's own compile-time constants on
+ load, so a mismatched build can't silently misread the
+ file)
+ ft_scale: 1x float32 -- fixed-point scale for ft.weight/ft_bias
+ (int16). NnueForward divides the int32 accumulator sum
+ by this to get the float "fc1 input" value.
+ act_scale: 3x float32 (AS1, AS2, AS3) -- fixed-point scales used
+ to quantize each stage's post-ReLU activation to uint8
+ before feeding the next layer's int8 dot product:
+ AS1 quantizes fc1's input (the ReLU'd, concatenated
+ accumulator halves)
+ AS2 quantizes fc1's ReLU'd output (fc2's input)
+ AS3 quantizes fc2's ReLU'd output (fc3's input)
+ Derived from real sampled activations (see
+ CalibrateActivationScales) with headroom, NOT from a
+ theoretical max -- see docs/NNUE.md's "Quantization
+ plan" / the int8 float-vs-quantized correctness check.
+ w_scale: 3x float32 (WS1, WS2, WS3) -- fixed-point scales for
+ fc1.weight/fc2.weight/fc3.weight (int8), one per layer,
+ each derived from that layer's own max|.| (exact, not
+ sampled -- weights are fixed, known precisely from the
+ checkpoint).
+ then, in this exact order:
+ ft.weight [NNUE_DIMENSIONS x L1] int16 (quantized @ ft_scale)
+ ft_bias [L1] int16 (quantized @ ft_scale)
+ fc1.weight [L2 x (2*L1)] int8 (quantized @ WS1)
+ fc1.bias [L2] float32 (unquantized --
+ added post-dequant, tiny
+ array, not worth it)
+ fc2.weight [L3 x L2] int8 (quantized @ WS2)
+ fc2.bias [L3] float32
+ fc3.weight [1 x L3] int8 (quantized @ WS3)
+ fc3.bias [1] float32
+
+Both the embedding table (int16) and the FC head (int8) are quantized
+now -- see docs/NNUE.md's "Quantization plan". Measured accuracy cost
+of the FC-head int8 step, 5000 real holdout positions vs the exact
+float reference: mean |delta| 1.55cp, median 0.99cp, max 20.91cp, zero
+clipping -- an order of magnitude below the sd10-vs-sd16 search-depth
+noise (mean 19cp) the project already tolerates in its training
+labels. See the conversation/commit history for the simulation that
+produced those numbers; CalibrateActivationScales below reproduces the
+scale derivation deterministically from a real checkpoint + its
+training tensors.
+
+Usage:
+ python3 export_nnue_weights.py \
+ runs/sd10_twic_2M/nnue_ckpt/ckpt_epoch019.pt \
+ runs/sd10_twic_2M/nnue_ckpt/weights.nnue \
+ --tensor-dir runs/sd10_twic_2M/tensors_train
+"""
+import argparse
+import struct
+import sys
+from pathlib import Path
+
+import numpy as np
+import torch
+
+from nnue_feature_spec import DIMENSIONS, PS_NB
+from nnue_model import L1, L2, L3, NnueNet
+
+# Fixed-point scale for ft.weight/ft_bias. Checked against
+# ckpt_epoch019.pt (ft.weight max|.|=5.01, ft_bias max|.|=0.52): at
+# this scale, 5.01*4096 ~= 20502, comfortably under int16's 32767
+# ceiling (~1.6x headroom for a larger Phase B net before this needs
+# re-deriving). See docs/NNUE.md's "Quantization plan".
+FT_SCALE = 4096.0
+
+# Headroom multiplier applied to the largest value seen in a real
+# sample before deriving each activation scale -- protects against
+# rare tail values in the full corpus that a finite sample might not
+# have hit. 1.3x chosen as a round, conservative margin; revisit if a
+# larger/differently-distributed Phase B net ever reports clipping.
+ACT_HEADROOM = 1.3
+
+
+def CalibrateActivationScales(model, tensor_dir, n_sample=50000, seed=42):
+ """Runs n_sample real positions from tensor_dir (one of
+ build_training_tensors.py's output directories) through the float
+ model, capturing each stage's real post-ReLU activation
+ distribution via forward hooks, and returns (AS1, AS2, AS3) --
+ uint8 fixed-point scales with ACT_HEADROOM margin over the
+ observed max. Must run against the SAME checkpoint being
+ exported -- these scales are not portable across checkpoints."""
+ 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")
+ n = len(stm)
+ rng = np.random.default_rng(seed)
+ rows = rng.choice(n, size=min(n_sample, n), replace=False)
+ rows.sort()
+
+ captured = {}
+ h1 = model.fc1.register_forward_hook(
+ lambda m, i, o: captured.setdefault("fc1_in", []).append(i[0].detach()))
+ h2 = model.fc2.register_forward_hook(
+ lambda m, i, o: captured.setdefault("fc2_in", []).append(i[0].detach()))
+ h3 = model.fc3.register_forward_hook(
+ lambda m, i, o: captured.setdefault("fc3_in", []).append(i[0].detach()))
+
+ with torch.no_grad():
+ for i in range(0, len(rows), 5000):
+ chunk = rows[i:i + 5000]
+ 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))
+ model(w_idx, w_off, b_idx, b_off, stm_t)
+
+ h1.remove(); h2.remove(); h3.remove()
+
+ scales = []
+ for key in ("fc1_in", "fc2_in", "fc3_in"):
+ t = torch.cat(captured[key]).float()
+ m = t.max().item()
+ scales.append(255.0 / (m * ACT_HEADROOM))
+ return tuple(scales)
+
+
+def QuantizeFt(arr):
+ """Rounds + clamps a float32 array to int16 at FT_SCALE. Returns
+ (int16 ndarray, number of values that clipped)."""
+ scaled = np.round(arr.astype(np.float64) * FT_SCALE)
+ clipped = int(np.count_nonzero((scaled < -32768) | (scaled > 32767)))
+ scaled = np.clip(scaled, -32768, 32767)
+ return scaled.astype(np.int16), clipped
+
+
+def QuantizeWeightS8(arr, scale):
+ """Rounds + clamps a float32 array to int8 at the given scale.
+ Returns (int8 ndarray, number of values that clipped)."""
+ scaled = np.round(arr.astype(np.float64) * scale)
+ clipped = int(np.count_nonzero((scaled < -128) | (scaled > 127)))
+ scaled = np.clip(scaled, -128, 127)
+ return scaled.astype(np.int8), clipped
+
+
+def CoalesceFactorization(sd, tensor_dir, n_check=4096, seed=123):
+ """If the checkpoint has a trained ft_p factorization table (see
+ nnue_model.py's 2026-10-18 note), folds it into ft.weight in
+ place (idx % PS_NB is the P-factor's row for real feature idx)
+ and returns a modified copy of sd with ft_p dropped -- the
+ coalesced ft alone must reproduce the factored forward pass, so
+ ft_p carries zero inference-time cost. No-op (returns sd
+ unchanged) if ft_p isn't present (an older, unfactored
+ checkpoint).
+
+ Required correctness check, per NNUE.md: a bad coalesce silently
+ produces a WORSE net (real rows learn less because the factor
+ absorbed shared signal) with no sign of it in the training loss
+ curve. Verified here by comparing the pre-coalesce (factored,
+ two-table) forward pass against the post-coalesce (plain,
+ ft-only) forward pass on n_check real positions sampled from
+ tensor_dir -- must agree to ~1e-5 or this refuses to export."""
+ if "ft_p.weight" not in sd:
+ return sd
+
+ ft_p_weight = sd["ft_p.weight"]
+ if tuple(ft_p_weight.shape) != (PS_NB, L1):
+ sys.exit(f"ft_p.weight: expected shape {(PS_NB, L1)}, checkpoint "
+ f"has {tuple(ft_p_weight.shape)} -- wrong model version?")
+
+ idx_mod = torch.arange(DIMENSIONS) % PS_NB
+ coalesced_ft_weight = sd["ft.weight"] + ft_p_weight[idx_mod]
+
+ sd_factored = dict(sd)
+ model_factored = NnueNet()
+ model_factored.load_state_dict(sd_factored)
+ model_factored.eval()
+
+ sd_plain = dict(sd)
+ sd_plain["ft.weight"] = coalesced_ft_weight
+ sd_plain["ft_p.weight"] = torch.zeros_like(ft_p_weight)
+ model_plain = NnueNet()
+ model_plain.load_state_dict(sd_plain)
+ model_plain.eval()
+
+ 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")
+ rng = np.random.default_rng(seed)
+ rows = rng.choice(len(stm), size=min(n_check, len(stm)), replace=False)
+ rows.sort()
+
+ w_idx_list, w_off_list, b_idx_list, b_off_list = [], [], [], []
+ off_w, off_b = 0, 0
+ for r in rows:
+ 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[rows].astype(np.int64))
+
+ with torch.no_grad():
+ out_factored = model_factored(w_idx, w_off, b_idx, b_off, stm_t)
+ out_plain = model_plain(w_idx, w_off, b_idx, b_off, stm_t)
+ max_delta = (out_factored - out_plain).abs().max().item()
+ if max_delta > 1e-4:
+ sys.exit(f"Coalesce check FAILED: max |factored - coalesced| = "
+ f"{max_delta:.6g} over {n_check} real positions (expected "
+ f"~1e-5) -- refusing to export a bad coalesce. Check "
+ f"ft_p's shape/init and the idx % PS_NB formula.")
+ print(f"Coalesce check OK: max |factored - coalesced| = {max_delta:.3g} "
+ f"over {n_check} real positions")
+
+ sd_out = dict(sd)
+ sd_out["ft.weight"] = coalesced_ft_weight
+ del sd_out["ft_p.weight"]
+ return sd_out
+
+
+def main():
+ ap = argparse.ArgumentParser(description=__doc__)
+ ap.add_argument("checkpoint")
+ ap.add_argument("outfile")
+ ap.add_argument("--tensor-dir", required=True,
+ help="a build_training_tensors.py output dir, used "
+ "to calibrate the FC head's activation scales "
+ "against real positions (e.g. tensors_train)")
+ args = ap.parse_args()
+
+ ckpt = torch.load(args.checkpoint, map_location="cpu", weights_only=True)
+ sd = ckpt["model"]
+ sd = CoalesceFactorization(sd, args.tensor_dir)
+
+ expected_shapes = {
+ "ft.weight": (DIMENSIONS, L1),
+ "ft_bias": (L1,),
+ "fc1.weight": (L2, 2 * L1),
+ "fc1.bias": (L2,),
+ "fc2.weight": (L3, L2),
+ "fc2.bias": (L3,),
+ "fc3.weight": (1, L3),
+ "fc3.bias": (1,),
+ }
+ for name, shape in expected_shapes.items():
+ if tuple(sd[name].shape) != shape:
+ sys.exit(f"{name}: expected shape {shape}, checkpoint has "
+ f"{tuple(sd[name].shape)} -- wrong model version?")
+
+ model = NnueNet()
+ # strict=False: CoalesceFactorization deliberately drops ft_p.weight
+ # (folded into ft.weight above) when the checkpoint had it, but
+ # NnueNet always declares the ft_p parameter regardless -- missing_keys
+ # is expected to be exactly ["ft_p.weight"] in that case, or empty for
+ # an older unfactored checkpoint CoalesceFactorization no-op'd on.
+ missing, unexpected = model.load_state_dict(sd, strict=False)
+ bad_missing = [k for k in missing if k != "ft_p.weight"]
+ if bad_missing or unexpected:
+ sys.exit(f"load_state_dict mismatch beyond the expected ft_p.weight "
+ f"drop -- missing={bad_missing}, unexpected={unexpected}")
+ model.eval()
+
+ AS1, AS2, AS3 = CalibrateActivationScales(model, args.tensor_dir)
+
+ ft_weight_f32 = sd["ft.weight"].detach().contiguous().to(torch.float32).numpy()
+ ft_bias_f32 = sd["ft_bias"].detach().contiguous().to(torch.float32).numpy()
+ fc1_w_f32 = sd["fc1.weight"].detach().contiguous().to(torch.float32).numpy()
+ fc2_w_f32 = sd["fc2.weight"].detach().contiguous().to(torch.float32).numpy()
+ fc3_w_f32 = sd["fc3.weight"].detach().contiguous().to(torch.float32).numpy()
+
+ ft_weight_q, clipped_ftw = QuantizeFt(ft_weight_f32)
+ ft_bias_q, clipped_ftb = QuantizeFt(ft_bias_f32)
+
+ WS1 = 127.0 / np.abs(fc1_w_f32).max()
+ WS2 = 127.0 / np.abs(fc2_w_f32).max()
+ WS3 = 127.0 / np.abs(fc3_w_f32).max()
+
+ fc1_w_q, clipped_w1 = QuantizeWeightS8(fc1_w_f32, WS1)
+ fc2_w_q, clipped_w2 = QuantizeWeightS8(fc2_w_f32, WS2)
+ fc3_w_q, clipped_w3 = QuantizeWeightS8(fc3_w_f32, WS3)
+
+ total_clipped = (clipped_ftw + clipped_ftb + clipped_w1 + clipped_w2 +
+ clipped_w3)
+ if total_clipped:
+ sys.exit(f"Quantize: {total_clipped} value(s) clipped -- "
+ f"re-check headroom/scales before shipping this export "
+ f"(see docs/NNUE.md)")
+
+ with open(args.outfile, "wb") as f:
+ f.write(b"NNU3")
+ f.write(struct.pack("<4I", DIMENSIONS, L1, L2, L3))
+ f.write(struct.pack("<f", FT_SCALE))
+ f.write(struct.pack("<3f", AS1, AS2, AS3))
+ f.write(struct.pack("<3f", WS1, WS2, WS3))
+ f.write(ft_weight_q.tobytes())
+ f.write(ft_bias_q.tobytes())
+ f.write(fc1_w_q.tobytes())
+ f.write(sd["fc1.bias"].detach().contiguous().to(torch.float32).numpy().tobytes())
+ f.write(fc2_w_q.tobytes())
+ f.write(sd["fc2.bias"].detach().contiguous().to(torch.float32).numpy().tobytes())
+ f.write(fc3_w_q.tobytes())
+ f.write(sd["fc3.bias"].detach().contiguous().to(torch.float32).numpy().tobytes())
+
+ print(f"Wrote {args.outfile} from {args.checkpoint}")
+ print(f" ft_scale={FT_SCALE}")
+ print(f" act_scales (AS1,AS2,AS3)=({AS1:.4f},{AS2:.4f},{AS3:.4f})")
+ print(f" weight_scales (WS1,WS2,WS3)=({WS1:.4f},{WS2:.4f},{WS3:.4f})")
+
+
+if __name__ == "__main__":
+ main()
diff --git a/annotate_pgns/export_nnue_weights_v1_baseline.py b/annotate_pgns/export_nnue_weights_v1_baseline.py
new file mode 100644
index 0000000..57ac7e7
--- /dev/null
+++ b/annotate_pgns/export_nnue_weights_v1_baseline.py
@@ -0,0 +1,23 @@
+#!/usr/bin/env python3
+"""One-off: re-create the OLD plain-float32 "NNU1" export format for a
+same-moment, apples-to-apples bench comparison against the new int16
+"NNU2" quantized format. Not the real exporter (export_nnue_weights.py
+is the real one now) -- throwaway, delete after use.
+"""
+import struct
+import sys
+import torch
+from nnue_feature_spec import DIMENSIONS
+from nnue_model import L1, L2, L3
+
+ckpt = torch.load(sys.argv[1], map_location="cpu", weights_only=True)
+sd = ckpt["model"]
+names = ["ft.weight", "ft_bias", "fc1.weight", "fc1.bias",
+ "fc2.weight", "fc2.bias", "fc3.weight", "fc3.bias"]
+with open(sys.argv[2], "wb") as f:
+ f.write(b"NNU1")
+ f.write(struct.pack("<4I", DIMENSIONS, L1, L2, L3))
+ for name in names:
+ arr = sd[name].detach().contiguous().to(torch.float32).numpy()
+ f.write(arr.tobytes())
+print(f"wrote {sys.argv[2]}")
diff --git a/annotate_pgns/extract_positions.py b/annotate_pgns/extract_positions.py
index 539419a..507f956 100644
--- a/annotate_pgns/extract_positions.py
+++ b/annotate_pgns/extract_positions.py
@@ -132,6 +132,19 @@ def main():
}
if reason:
rec["exact_reason"] = reason
+ # Mover-relative exact outcome, straight from the sign
+ # of the already-mover-relative score (unlike
+ # extract_selfplay_positions.py's foreign-engine path,
+ # typhoon's own "score" field here is never nulled out
+ # for mate/EGTB/REC rows -- only "only" (unsearched) and
+ # genuinely unparseable tokens carry no usable score).
+ # draw_recognizer ("<=>") is 0.5 regardless of sign.
+ if exact and reason == "draw_recognizer":
+ rec["r_outcome"] = 0.5
+ elif exact and score is not None and reason not in (
+ "unsearched_only_move",) and not (
+ reason and reason.startswith("unrecognized")):
+ rec["r_outcome"] = 1.0 if score > 0 else 0.0
out.write(json.dumps(rec) + "\n")
total += 1
if exact:
diff --git a/annotate_pgns/extract_selfplay_positions.py b/annotate_pgns/extract_selfplay_positions.py
new file mode 100644
index 0000000..5682b41
--- /dev/null
+++ b/annotate_pgns/extract_selfplay_positions.py
@@ -0,0 +1,247 @@
+#!/usr/bin/env python3
+"""Extract game-shaped records (game_id, result, positions[]) directly
+from a self-play PGN written by eval_tune/match_play.py's
+build_pgn_game -- a different source format from extract_positions.py
+(which mines annotate.py's raw shard_NN.script/.out logs), so this is
+a sibling script, not a variant of that one: the per-move score here
+already lives as a PGN comment on each move
+("{+-}NN.NN/DEPTH PV..." or "{+-}M/DEPTH PV..." for mate), not a
+tellothers log line, and needs a PGN parser (python-chess) rather than
+a log-line regex. Output schema matches build_games.py's games.jsonl
+exactly (that script's own docstring anticipated this: "a future
+loader for self-play games can produce the same games.jsonl schema
+and both sources mix freely at training-set-assembly time") -- drops
+straight into fit_k_and_label.py -> quiet_filter_games.py ->
+build_training_tensors.py unchanged, no separate split_holdout.py/
+build_games.py step needed since python-chess already gives real
+per-game grouping directly.
+
+2026-10-16, see docs/NNUE.md and the same day's qsearch-futility/NNUE-
+calibration investigation: this is the tool for pulling trainable
+positions out of the self-play corpus (pgn/self_play_games.pgn)
+specifically to fill the "decisive material, not yet mate" gap the
+TWIC/human-game corpus structurally can't supply (strong humans
+resign long before that point; these self-play games, generated with
+ResignThreshold disabled -- see match_play.py -- are deliberately
+played to real checkmate/stalemate/50-move/EGTB conclusions instead).
+
+Score sign convention matches extract_positions.py's output: each
+row's "score" is relative to the side to move AT "fen" (not a fixed
+White-POV), matching the training pipeline's existing convention
+elsewhere. match_play.py's own comments are stored White-relative
+(_format_eval_comment's score_white) -- converted back to mover-
+relative here.
+
+Per-move "mover" field records exactly which PGN White/Black header
+name played that move (e.g. "candidate", "baseline",
+"candidate_nnue_loose") -- lets a caller filter out any evaluator
+whose own self-reported scores shouldn't be trusted as training
+labels (see relabel_selfplay_pgn.py and the --exclude-movers flag
+below) without needing to know which games/dates those came from.
+
+Usage:
+ python3 extract_selfplay_positions.py \\
+ ../pgn/self_play_games.pgn \\
+ --out runs/selfplay_1013_1014/games.jsonl \\
+ --dates 2026-10-13 2026-10-14 \\
+ --event "typhoon self-play match" \\
+ --exclude-movers candidate_nnue_loose
+"""
+import argparse
+import json
+import re
+import sys
+
+import chess
+import chess.pgn
+
+COMMENT_RE = re.compile(
+ r"^([+-]M|[+-]?\d+\.\d+)(?:/(\d+))?(?:\s+(.*))?$"
+)
+
+# match_play.py's own mate-token path ("+M"/"-M", _format_eval_comment)
+# only fires when |score_white| >= NMATE_THRESHOLD=32000 (centipawns),
+# a threshold keyed to typhoon's own NMATE sentinel. It is never reached
+# when the mover is a *foreign* engine driven through a UCI<->xboard
+# adapter (see src/stockfish_xboard -- Stockfish via PolyGlot), because
+# PolyGlot's own UCI mate-score-to-xboard-cp conversion caps out far
+# below that: empirically, mate-in-N on this corpus (35,295
+# Stockfish-vs-Stockfish games, pgn/stockfish_self_play_training_data.pgn)
+# always lands as a plain float in [+90.00, +99.99] (or the negated
+# range), one per decisive game at depth 1 -- 5528 "+99.99/1" rows vs
+# exactly 5528 "1-0" results, 2728 "-99.99/1" vs 2729 "0-1". A direct
+# histogram of |score| confirms a clean gap: every value is either
+# below 70.00 or in [99.00, 99.99] (51,488 rows total, matching the
+# count of |score| >= 90 exactly) -- no ambiguous values near the
+# boundary. So any foreign-engine corpus needs this magnitude check in
+# addition to the literal "+M"/"-M" token case; MATE_CLAMP_THRESHOLD is
+# set well below the observed 99.00 floor (and well above any real
+# positional/material evaluation) to catch this with margin.
+MATE_CLAMP_THRESHOLD = 90.0
+
+
+def parse_comment(comment):
+ """Returns (score_white_pawns_or_None, depth_or_None, pv_or_None,
+ exact, exact_reason, outcome_white_or_None). score_white is None
+ for mate ("+M"/"-M", distance not recoverable from this comment
+ format -- see match_play.py's _format_eval_comment) -- exact=True
+ covers that case the same way extract_positions.py treats MATE
+ tokens, just without a distance number to carry. Also None for a
+ foreign-engine mate score that was clamped into an ordinary-looking
+ float instead of using match_play.py's own "+M"/"-M" convention
+ (see MATE_CLAMP_THRESHOLD above) -- same treatment, different
+ detection path.
+
+ outcome_white is the exact, White-relative game-theoretic result
+ (1.0/0.0/0.5) for exact rows where it can be read straight off this
+ comment, independent of score_white (which is deliberately nulled
+ out above for the mate/clamped cases -- the sign is still known
+ even though the magnitude isn't, and that sign is exactly the
+ label this field exists to carry). None when the comment doesn't
+ determine an outcome on its own (ordinary non-exact rows, and the
+ "unparseable" case) -- see NNUE.md's EGTB-ground-truth-mixing
+ decision: this is deliberately NOT derived from the game's final
+ result, since an exact-at-this-position verdict (TB/mate-forced)
+ is a fact about the position, not about whether the game later
+ actually reached that forced outcome."""
+ m = COMMENT_RE.match(comment.strip())
+ if not m:
+ return None, None, None, True, f"unparseable:{comment!r}", None
+ score_tok, depth_tok, pv = m.groups()
+ depth = int(depth_tok) if depth_tok else None
+ pv = pv.strip() if pv else None
+
+ exact, reason = False, None
+ if pv:
+ last_move = pv.rsplit(" ", 1)[-1]
+ if last_move in ("<EGTB>", "<REC>", "<=>", "<#>"):
+ exact = True
+ reason = {"<=>": "draw_recognizer",
+ "<#>": "mate_recognizer"}.get(
+ last_move, last_move.strip("<>").lower())
+
+ if reason == "draw_recognizer":
+ return None, depth, pv, True, reason, 0.5
+
+ if score_tok in ("+M", "-M"):
+ outcome_white = 1.0 if score_tok == "+M" else 0.0
+ return None, depth, pv, True, (reason or "mate"), outcome_white
+
+ score_white = float(score_tok)
+ if abs(score_white) >= MATE_CLAMP_THRESHOLD:
+ outcome_white = 1.0 if score_white > 0 else 0.0
+ return None, depth, pv, True, (reason or "mate_clamped"), outcome_white
+
+ if reason == "mate_recognizer":
+ outcome_white = 1.0 if score_white > 0 else 0.0
+ return score_white, depth, pv, True, reason, outcome_white
+
+ return score_white, depth, pv, exact, reason, None
+
+
+def main():
+ ap = argparse.ArgumentParser(description=__doc__,
+ formatter_class=argparse.RawDescriptionHelpFormatter)
+ ap.add_argument("pgn_file")
+ ap.add_argument("--out", required=True)
+ ap.add_argument("--event", default=None,
+ help="only extract games with this Event header "
+ "(default: any)")
+ ap.add_argument("--dates", nargs="*", default=None,
+ help="only extract games with Date header in this "
+ "list (default: any)")
+ ap.add_argument("--exclude-movers", nargs="*", default=[],
+ help="skip any ply whose mover's PGN name is in "
+ "this list (e.g. candidate_nnue_loose) -- the "
+ "move/FEN sequence for the rest of that same "
+ "game is still usable, only that one side's "
+ "own score annotations are dropped")
+ args = ap.parse_args()
+
+ target_dates = set(args.dates) if args.dates else None
+ exclude_movers = set(args.exclude_movers)
+
+ total_games = 0
+ games_in_scope = 0
+ total_rows = 0
+ total_exact = 0
+ total_excluded_mover = 0
+
+ with open(args.pgn_file, encoding="utf-8", errors="surrogateescape") as fin, \
+ open(args.out, "w") as fout:
+ while True:
+ game = chess.pgn.read_game(fin)
+ if game is None:
+ break
+ total_games += 1
+ if args.event is not None and game.headers.get("Event") != args.event:
+ continue
+ if target_dates is not None and game.headers.get("Date") not in target_dates:
+ continue
+ games_in_scope += 1
+
+ white_name = game.headers.get("White", "")
+ black_name = game.headers.get("Black", "")
+ game_result = game.headers.get("Result", "*")
+ board = game.board()
+ node = game
+ game_positions = []
+ ply_num = 0
+ while node.variations:
+ next_node = node.variations[0]
+ move = next_node.move
+ mover_is_white = board.turn == chess.WHITE
+ mover_name = white_name if mover_is_white else black_name
+ fen_before = board.fen()
+ comment = next_node.comment
+ board.push(move)
+ node = next_node
+ ply_num += 1
+ if not comment:
+ continue
+ if mover_name in exclude_movers:
+ total_excluded_mover += 1
+ continue
+ (score_white, depth, pv, exact, reason,
+ outcome_white) = parse_comment(comment)
+ score = None
+ if score_white is not None:
+ score = score_white if mover_is_white else -score_white
+ rec = {
+ "fen": fen_before,
+ "depth": depth,
+ "score": score,
+ "score_raw": comment,
+ "pv": pv,
+ "exact": exact,
+ "mover": mover_name,
+ }
+ if reason:
+ rec["exact_reason"] = reason
+ if outcome_white is not None:
+ rec["r_outcome"] = (outcome_white if mover_is_white
+ else 1.0 - outcome_white)
+ game_positions.append(rec)
+ total_rows += 1
+ if exact:
+ total_exact += 1
+
+ if not game_positions:
+ continue
+ game_rec = {
+ "game_id": f"selfplay_{games_in_scope}_{game.headers.get('Date', '?')}_{ply_num}ply",
+ "result": game_result,
+ "positions": game_positions,
+ }
+ fout.write(json.dumps(game_rec) + "\n")
+
+ print(f"{total_games} games scanned, {games_in_scope} in scope "
+ f"(event={args.event!r}, dates={sorted(target_dates) if target_dates else None})",
+ file=sys.stderr)
+ print(f"{total_rows} positions written ({total_exact} exact), "
+ f"{total_excluded_mover} plies skipped for excluded mover",
+ file=sys.stderr)
+
+
+if __name__ == "__main__":
+ sys.exit(main())
diff --git a/annotate_pgns/fit_k_and_label.py b/annotate_pgns/fit_k_and_label.py
index 9fd09b0..11b2928 100644
--- a/annotate_pgns/fit_k_and_label.py
+++ b/annotate_pgns/fit_k_and_label.py
@@ -19,6 +19,16 @@ holdout data would leak information into what's supposed to be an
untouched generalization check). The same fitted K is then applied
(never refit) to label holdout positions too.
+Both the calibration-pair collection (--train) and the labeling pass
+(--train/--holdout) are sharded across --workers processes via
+multiprocessing.Pool, each worker vectorizing its own chunk of games
+with numpy -- this file used to run ~80 full Python-loop passes over
+every pair during the ternary search (2 mse() evals x 40 iterations),
+which was the slowest step in the post-annotation pipeline relative to
+how little actual compute it does. Per-game-chunk numpy vectorization
+plus Pool.imap (order-preserving, like build_training_tensors.py's
+existing pattern) turns that into array ops.
+
Usage:
python3 fit_k_and_label.py --train runs/sd10_twic_2M/games.train.jsonl \
--holdout runs/sd10_twic_2M/games.holdout.jsonl \
@@ -27,7 +37,9 @@ Usage:
"""
import argparse
import json
-import math
+from multiprocessing import Pool
+
+import numpy as np
def result_to_white_score(result):
@@ -35,77 +47,160 @@ def result_to_white_score(result):
return {"1-0": 1.0, "0-1": 0.0, "1/2-1/2": 0.5}.get(result)
-def stm_relative_outcome(white_score, fen):
- """Flip a White-perspective game result to the side-to-move's own
- perspective at this FEN, matching the score field's own
- side-to-move-relative convention."""
- stm_is_white = fen.split()[1] == "w"
- return white_score if stm_is_white else (1.0 - white_score)
-
-
-def collect_calibration_pairs(games_path):
- """(score, r_outcome) for every non-exact position across all
- games in this file."""
- pairs = []
- n_unknown_result = 0
- with open(games_path) as f:
- for line in f:
- g = json.loads(line)
- white_score = result_to_white_score(g["result"])
- if white_score is None:
- n_unknown_result += 1
- continue
- for pos in g["positions"]:
- if pos.get("exact"):
- continue
- r = stm_relative_outcome(white_score, pos["fen"])
- pairs.append((pos["score"], r))
- return pairs, n_unknown_result
+def sigmoid(scores, k):
+ return 1.0 / (1.0 + np.exp(-scores / k))
-def mse(pairs, k):
- err = 0.0
- for score, r in pairs:
- p = 1.0 / (1.0 + math.exp(-score / k))
- err += (r - p) ** 2
- return err / max(len(pairs), 1)
+def mse(scores, outcomes, k):
+ p = sigmoid(scores, k)
+ return float(np.mean((outcomes - p) ** 2)) if len(scores) else 0.0
-def fit_k(pairs, lo=0.05, hi=20.0, iterations=40):
+def fit_k(scores, outcomes, lo=0.05, hi=20.0, iterations=40):
for _ in range(iterations):
m1 = lo + (hi - lo) / 3
m2 = hi - (hi - lo) / 3
- if mse(pairs, m1) < mse(pairs, m2):
+ if mse(scores, outcomes, m1) < mse(scores, outcomes, m2):
hi = m2
else:
lo = m1
return (lo + hi) / 2
-def label_file(in_path, out_path, k):
+def iter_chunks(path, chunk_size):
+ """Batches raw lines (one game per line) of an open jsonl file into
+ lists of up to chunk_size lines -- the unit of work handed to one
+ worker process at a time. Text, not parsed JSON, so the parse
+ itself happens in the worker, same division of labor as
+ build_training_tensors.py's iter_chunks."""
+ chunk = []
+ with open(path) as f:
+ for line in f:
+ chunk.append(line)
+ if len(chunk) >= chunk_size:
+ yield chunk
+ chunk = []
+ if chunk:
+ yield chunk
+
+
+def _non_exact_scores_and_outcomes(game):
+ """(scores, outcomes) numpy arrays for one game's non-exact
+ positions, or None if the game's result is unknown or it has no
+ non-exact positions."""
+ white_score = result_to_white_score(game["result"])
+ if white_score is None:
+ return None
+ non_exact = [p for p in game["positions"] if not p.get("exact")]
+ if not non_exact:
+ return None
+ scores = np.fromiter((p["score"] for p in non_exact), dtype=np.float64,
+ count=len(non_exact))
+ is_white = np.fromiter(
+ (p["fen"].split()[1] == "w" for p in non_exact), dtype=bool,
+ count=len(non_exact))
+ outcomes = np.where(is_white, white_score, 1.0 - white_score)
+ return scores, outcomes
+
+
+def collect_chunk(lines):
+ """Runs in a worker process: parses a chunk of game lines and
+ returns (scores, outcomes, n_unknown_result) for every non-exact
+ position across those games."""
+ score_pieces = []
+ outcome_pieces = []
+ n_unknown_result = 0
+ for line in lines:
+ g = json.loads(line)
+ result = _non_exact_scores_and_outcomes(g)
+ if result is None:
+ if result_to_white_score(g["result"]) is None:
+ n_unknown_result += 1
+ continue
+ scores, outcomes = result
+ score_pieces.append(scores)
+ outcome_pieces.append(outcomes)
+ if not score_pieces:
+ return np.empty(0), np.empty(0), n_unknown_result
+ return (np.concatenate(score_pieces), np.concatenate(outcome_pieces),
+ n_unknown_result)
+
+
+def collect_calibration_pairs(games_path, workers, chunk_size):
+ """(scores, outcomes) numpy arrays for every non-exact position
+ across all games in this file, built by sharding games across
+ --workers processes."""
+ score_pieces = []
+ outcome_pieces = []
+ n_unknown_result = 0
+ chunks = iter_chunks(games_path, chunk_size)
+ with Pool(processes=workers) as pool:
+ for scores, outcomes, n_unk in pool.imap(collect_chunk, chunks):
+ if len(scores):
+ score_pieces.append(scores)
+ outcome_pieces.append(outcomes)
+ n_unknown_result += n_unk
+ if not score_pieces:
+ return np.empty(0), np.empty(0), n_unknown_result
+ return (np.concatenate(score_pieces), np.concatenate(outcome_pieces),
+ n_unknown_result)
+
+
+def label_chunk(args):
+ """Runs in a worker process: labels every non-exact position in a
+ chunk of game lines (vectorized per game with numpy) and returns
+ the re-serialized lines plus counts, in the same order they came
+ in."""
+ lines, k = args
+ out_lines = []
n_games = 0
n_positions = 0
n_exact_skipped = 0
n_unknown_result = 0
- with open(in_path) as fin, open(out_path, "w") as fout:
- for line in fin:
- g = json.loads(line)
- white_score = result_to_white_score(g["result"])
- if white_score is None:
- n_unknown_result += 1
- fout.write(json.dumps(g) + "\n")
- continue
- for pos in g["positions"]:
- n_positions += 1
- if pos.get("exact"):
- n_exact_skipped += 1
- continue
- r = stm_relative_outcome(white_score, pos["fen"])
- p_search = 1.0 / (1.0 + math.exp(-pos["score"] / k))
- pos["p_search"] = p_search
- pos["r_outcome"] = r
- fout.write(json.dumps(g) + "\n")
- n_games += 1
+ for line in lines:
+ g = json.loads(line)
+ white_score = result_to_white_score(g["result"])
+ positions = g["positions"]
+ n_positions += len(positions)
+ if white_score is None:
+ n_unknown_result += 1
+ out_lines.append(json.dumps(g))
+ continue
+
+ idxs = [i for i, p in enumerate(positions) if not p.get("exact")]
+ n_exact_skipped += len(positions) - len(idxs)
+ if idxs:
+ scores = np.fromiter((positions[i]["score"] for i in idxs),
+ dtype=np.float64, count=len(idxs))
+ is_white = np.fromiter(
+ (positions[i]["fen"].split()[1] == "w" for i in idxs),
+ dtype=bool, count=len(idxs))
+ outcomes = np.where(is_white, white_score, 1.0 - white_score)
+ p_search = sigmoid(scores, k)
+ for j, i in enumerate(idxs):
+ positions[i]["p_search"] = float(p_search[j])
+ positions[i]["r_outcome"] = float(outcomes[j])
+
+ out_lines.append(json.dumps(g))
+ n_games += 1
+ return out_lines, n_games, n_positions, n_exact_skipped, n_unknown_result
+
+
+def label_file(in_path, out_path, k, workers, chunk_size):
+ n_games = 0
+ n_positions = 0
+ n_exact_skipped = 0
+ n_unknown_result = 0
+ chunks = ((chunk, k) for chunk in iter_chunks(in_path, chunk_size))
+ with Pool(processes=workers) as pool, open(out_path, "w") as fout:
+ for (out_lines, chunk_games, chunk_positions, chunk_exact,
+ chunk_unknown) in pool.imap(label_chunk, chunks):
+ for line in out_lines:
+ fout.write(line + "\n")
+ n_games += chunk_games
+ n_positions += chunk_positions
+ n_exact_skipped += chunk_exact
+ n_unknown_result += chunk_unknown
return n_games, n_positions, n_exact_skipped, n_unknown_result
@@ -115,26 +210,56 @@ def main():
ap.add_argument("--holdout", required=True)
ap.add_argument("--train-out", required=True)
ap.add_argument("--holdout-out", required=True)
+ ap.add_argument("--workers", type=int, default=16,
+ help="worker processes for both the calibration-pair "
+ "collection and the labeling pass (default 16, "
+ "kept below this box's 24 cores to leave other "
+ "jobs some breathing room)")
+ ap.add_argument("--chunk-size", type=int, default=2000,
+ help="games per unit of work handed to a worker")
+ ap.add_argument("--fixed-k", type=float, default=None,
+ help="use this K directly instead of re-fitting it "
+ "from --train. 2026-10-16: needed when labeling "
+ "a small supplementary slice (e.g. a self-play "
+ "augmentation batch) to continue training an "
+ "EXISTING checkpoint -- that checkpoint's "
+ "weights were fit against labels built with its "
+ "own K (nnue_model.py's DEFAULT_K unless noted "
+ "otherwise), and re-fitting K from a small, "
+ "distributionally skewed slice (e.g. one "
+ "deliberately overweighted toward decisive-"
+ "material positions) would silently relabel "
+ "everything on a different, inconsistent scale "
+ "-- the exact mismatch this module's own closing "
+ "warning describes. Omit for a normal from-"
+ "scratch fit on a large, representative pool.")
args = ap.parse_args()
- print(f"Collecting calibration pairs from {args.train} (TRAIN only) ...")
- pairs, n_unknown = collect_calibration_pairs(args.train)
- print(f" {len(pairs)} (score, outcome) pairs "
- f"({n_unknown} games skipped: unknown result)")
+ if args.fixed_k is not None:
+ k = args.fixed_k
+ print(f"Using fixed K = {k:.4f} (not re-fit from {args.train!r})")
+ else:
+ print(f"Collecting calibration pairs from {args.train} (TRAIN only) ...")
+ scores, outcomes, n_unknown = collect_calibration_pairs(
+ args.train, args.workers, args.chunk_size)
+ print(f" {len(scores)} (score, outcome) pairs "
+ f"({n_unknown} games skipped: unknown result)")
- print("Fitting K (ternary search, minimizing sigmoid-MSE against "
- "actual game outcomes) ...")
- k = fit_k(pairs)
- final_mse = mse(pairs, k)
- print(f" K = {k:.2f} (MSE at fit = {final_mse:.5f})")
+ print("Fitting K (ternary search, minimizing sigmoid-MSE against "
+ "actual game outcomes) ...")
+ k = fit_k(scores, outcomes)
+ final_mse = mse(scores, outcomes, k)
+ print(f" K = {k:.2f} (MSE at fit = {final_mse:.5f})")
print(f"\nLabeling {args.train} -> {args.train_out} ...")
- n_g, n_p, n_exact, n_unk = label_file(args.train, args.train_out, k)
+ n_g, n_p, n_exact, n_unk = label_file(
+ args.train, args.train_out, k, args.workers, args.chunk_size)
print(f" {n_g} games, {n_p} positions, {n_exact} exact (unlabeled), "
f"{n_unk} unknown-result games (unlabeled)")
print(f"Labeling {args.holdout} -> {args.holdout_out} (same K, not refit) ...")
- n_g, n_p, n_exact, n_unk = label_file(args.holdout, args.holdout_out, k)
+ n_g, n_p, n_exact, n_unk = label_file(
+ args.holdout, args.holdout_out, k, args.workers, args.chunk_size)
print(f" {n_g} games, {n_p} positions, {n_exact} exact (unlabeled), "
f"{n_unk} unknown-result games (unlabeled)")
diff --git a/annotate_pgns/nnue_model.py b/annotate_pgns/nnue_model.py
index acdc8fd..c483ce7 100644
--- a/annotate_pgns/nnue_model.py
+++ b/annotate_pgns/nnue_model.py
@@ -16,11 +16,54 @@ activations / quantization-aware scaling (this is a float32-only
Phase A prototype, no export-for-C-inference step exists yet), the
lazy-eval PSQT-term fast path, and the super-lazy material-only
prefilter. All irrelevant to proving the training pipeline works.
+
+2026-10-17: added `material_probe`, a small auxiliary linear head on
+the same concatenated accumulator `fc1` reads, trained jointly with
+the main WDL loss (see nnue_train.py's --material-loss-weight) against
+each position's raw material balance (mover-relative pawns, no
+sigmoid). Added after a real, measured failure: a net trained with
+plain MSE (and separately, exponent=2.6) against WDL-sigmoid targets
+alone could not tell a lost pawn from a lost queen in crowded midgame
+positions (see sanity_check_net.py) -- directionally correct but
+magnitude-blind. The probe supplies a non-saturating gradient straight
+at material, forcing `ft`'s embedding weights to keep material
+linearly recoverable even where the main WDL loss has little pressure
+to care. It's training-only scaffolding, like the SF doc's "virtual
+features" factorization trick -- dropped before export, zero
+inference-time or quantization-format cost.
+
+2026-10-18: added `ft_p`, the actual Stockfish-doc "P" factor (see
+NNUE.md's "feature factorization" status entry) -- a second,
+king-bucket-INDEPENDENT EmbeddingBag over PS_NB=704 rows
+(piece_square x piece_type x piece_color only), summed into the same
+accumulator `ft` feeds. Motivation: HalfKAv2_hm's real 22528-row
+table is king-bucket-conditioned, so a (piece, square, color) tuple
+that's common in general chess but rare in a specific king bucket
+gets its own mostly-untrained row -- measured directly on the
+Stockfish self-play training pool: 9.9% of rows never fire at all,
+23.6% see fewer than 50 examples. `ft_p`'s 704 rows see ~32x more
+observations each (one per king bucket collapsed into one), supplying
+dense gradient to a king-bucket-independent piece-value prior that
+`ft`'s sparser rows can then specialize away from. Zero-initialized
+(nn.init.zeros_) specifically so it contributes nothing at step 0 --
+a default (unit-variance) init would silently double the
+accumulator's effective scale at init, confounding the factorization
+effect with an incidental learning-rate change. Deliberately no bias
+of its own -- `ft_bias` stays the only bias on this sum, which is
+also what keeps the post-training coalescing step (see
+export_nnue_weights.py) exact rather than approximate. Training-only,
+like material_probe: coalesced into `ft.weight` and dropped before
+export, since `idx % PS_NB` (the P-factor's index for real feature
+`idx`) is recoverable from the real index alone, needing no change to
+build_training_tensors.py's cached tensors. Deliberately NOT adding
+Stockfish's other two documented factors ("K", "HalfRelativeKP") --
+HalfKAv2_hm already encodes the king directly via the shared PS_KING
+plane, making a K factor redundant here; one variable at a time.
"""
import torch
import torch.nn as nn
-from nnue_feature_spec import DIMENSIONS
+from nnue_feature_spec import DIMENSIONS, PS_NB
L1 = 256 # feature-transformer output width (per perspective)
L2 = 32
@@ -30,7 +73,8 @@ DEFAULT_K = 1.3123 # pawns; fit_k_and_label.py's Phase-A train-split fit
class NnueNet(nn.Module):
- def __init__(self, dimensions=DIMENSIONS, l1=L1, l2=L2, l3=L3):
+ def __init__(self, dimensions=DIMENSIONS, l1=L1, l2=L2, l3=L3,
+ ps_nb=PS_NB):
super().__init__()
# Single shared feature transformer: one [dimensions, l1]
# weight matrix, applied once per perspective to that
@@ -40,15 +84,37 @@ class NnueNet(nn.Module):
self.ft = nn.EmbeddingBag(dimensions, l1, mode="sum")
self.ft_bias = nn.Parameter(torch.zeros(l1))
+ # Training-only "P" factorization table -- see module
+ # docstring's 2026-10-18 note. King-bucket-independent
+ # (ps_nb=704 rows, vs ft's full 22528), summed into the same
+ # accumulator. Zero-init so it contributes nothing at step 0.
+ self.ft_p = nn.EmbeddingBag(ps_nb, l1, mode="sum")
+ nn.init.zeros_(self.ft_p.weight)
+ self.ps_nb = ps_nb
+
self.fc1 = nn.Linear(2 * l1, l2)
self.fc2 = nn.Linear(l2, l3)
self.fc3 = nn.Linear(l3, 1)
- def forward(self, white_idx, white_off, black_idx, black_off, stm):
- """stm: float/long tensor of shape [B], 1.0 if white to move
- else 0.0. Returns raw score in pawn-ish units, shape [B]."""
- white_acc = self.ft(white_idx, white_off) + self.ft_bias
- black_acc = self.ft(black_idx, black_off) + self.ft_bias
+ # Auxiliary, training-only head -- see module docstring's
+ # 2026-10-17 note. Reads the same concatenated accumulator
+ # fc1 does; not used at inference/export time.
+ self.material_probe = nn.Linear(2 * l1, 1)
+
+ def accumulator(self, white_idx, white_off, black_idx, black_off, stm):
+ """The ReLU'd, own-perspective-first concatenated accumulator
+ (shape [B, 2*l1]) -- shared input to both fc1 (the real eval
+ head) and material_probe (the auxiliary head). ft_p's
+ contribution uses the identical bag offsets as ft -- same
+ positions, same active-feature counts, just a second lookup
+ of each index modulo ps_nb into the smaller, denser table."""
+ white_idx_p = white_idx % self.ps_nb
+ black_idx_p = black_idx % self.ps_nb
+
+ white_acc = (self.ft(white_idx, white_off)
+ + self.ft_p(white_idx_p, white_off) + self.ft_bias)
+ black_acc = (self.ft(black_idx, black_off)
+ + self.ft_p(black_idx_p, black_off) + self.ft_bias)
white_acc = torch.relu(white_acc)
black_acc = torch.relu(black_acc)
@@ -57,13 +123,29 @@ class NnueNet(nn.Module):
# own-perspective-first concatenation, per side to move.
own = stm * white_acc + (1.0 - stm) * black_acc
enemy = stm * black_acc + (1.0 - stm) * white_acc
- x = torch.cat([own, enemy], dim=1)
+ return torch.cat([own, enemy], dim=1)
+ def forward(self, white_idx, white_off, black_idx, black_off, stm):
+ """stm: float/long tensor of shape [B], 1.0 if white to move
+ else 0.0. Returns raw score in pawn-ish units, shape [B]."""
+ x = self.accumulator(white_idx, white_off, black_idx, black_off, stm)
x = torch.relu(self.fc1(x))
x = torch.relu(self.fc2(x))
x = self.fc3(x)
return x.view(-1)
+ def forward_with_material(self, white_idx, white_off, black_idx, black_off, stm):
+ """Like forward(), but also returns the auxiliary material
+ probe's prediction (mover-relative pawns, no sigmoid) off the
+ same accumulator -- one extra Linear(2*l1, 1), training-only.
+ Returns (raw_score, material_pred), both shape [B]."""
+ x = self.accumulator(white_idx, white_off, black_idx, black_off, stm)
+ material_pred = self.material_probe(x).view(-1)
+ y = torch.relu(self.fc1(x))
+ y = torch.relu(self.fc2(y))
+ y = self.fc3(y)
+ return y.view(-1), material_pred
+
def win_probability(self, white_idx, white_off, black_idx, black_off, stm, k=DEFAULT_K):
raw = self.forward(white_idx, white_off, black_idx, black_off, stm)
return torch.sigmoid(raw / k)
diff --git a/annotate_pgns/nnue_train.py b/annotate_pgns/nnue_train.py
index 6112ab3..ea3d0a9 100644
--- a/annotate_pgns/nnue_train.py
+++ b/annotate_pgns/nnue_train.py
@@ -1,6 +1,6 @@
#!/usr/bin/env python3
"""Phase A training loop: local CPU, PyTorch, over the cached CSR
-tensors build_training_tensors.py produces. Goal is pipeline/
+stensors build_training_tensors.py produces. Goal is pipeline/
framework validation (per NNUE.md) -- prove the data loader, model,
loss, and checkpointing work end-to-end and get fast local iteration
when something breaks, not to produce the best possible net. The
@@ -17,7 +17,7 @@ Usage:
--train-dir runs/sd10_twic_2M/tensors_train \
--holdout-dir runs/sd10_twic_2M/tensors_holdout \
--outdir runs/sd10_twic_2M/nnue_ckpt \
- --epochs 20 --batch-size 8192 --threads 8
+ --epochs 20 --batch-size 8192 --threads 16
"""
import argparse
import json
@@ -26,7 +26,6 @@ from pathlib import Path
import numpy as np
import torch
-import torch.nn as nn
from nnue_model import NnueNet, DEFAULT_K
@@ -43,10 +42,17 @@ class CsrTensors:
self.black_off = np.load(tensor_dir / "black_off.npy")
self.stm = np.load(tensor_dir / "stm.npy")
self.target = np.load(tensor_dir / "target.npy")
+ # material.npy added 2026-10-17 (see nnue_model.py's
+ # material_probe) -- optional for backward compat with
+ # tensor dirs built before this field existed.
+ material_path = tensor_dir / "material.npy"
+ self.material = (np.load(material_path) if material_path.exists()
+ else np.zeros_like(self.target))
self.n = len(self.target)
assert len(self.white_off) == self.n + 1
assert len(self.black_off) == self.n + 1
assert len(self.stm) == self.n
+ assert len(self.material) == self.n
def __len__(self):
return self.n
@@ -68,6 +74,16 @@ def _gather_batch(idx_arr, off_arr, row_ids):
return flat, batch_off
+def _to_device(batch, device):
+ """Moves one iterate_batches() tuple onto device. A no-op (same
+ tensors returned) when device is "cpu", since CsrTensors already
+ hands back CPU tensors directly -- only a CUDA run pays this
+ transfer cost."""
+ if device == "cpu":
+ return batch
+ return tuple(t.to(device, non_blocking=True) for t in batch)
+
+
def iterate_batches(data: CsrTensors, batch_size, shuffle, rng):
order = rng.permutation(data.n) if shuffle else np.arange(data.n)
for start in range(0, data.n, batch_size):
@@ -77,6 +93,7 @@ def iterate_batches(data: CsrTensors, batch_size, shuffle, rng):
b_idx, b_off = _gather_batch(data.black_idx, data.black_off, rows_sorted)
stm = data.stm[rows_sorted]
target = data.target[rows_sorted]
+ material = data.material[rows_sorted]
yield (
torch.from_numpy(w_idx.astype(np.int64, copy=False)),
torch.from_numpy(w_off),
@@ -84,23 +101,51 @@ def iterate_batches(data: CsrTensors, batch_size, shuffle, rng):
torch.from_numpy(b_off),
torch.from_numpy(stm.astype(np.int64, copy=False)),
torch.from_numpy(target.copy()),
+ torch.from_numpy(material.astype(np.float32, copy=True)),
)
-def evaluate(model, data, batch_size, k):
+def powered_loss(pred, target, exponent, reduction="mean"):
+ """|pred - target|^exponent, exponent=2 is plain MSE. Stockfish's
+ own training notes (nnue-pytorch wiki) report good results with
+ 2.6 -- widens the loss ratio between small and large WDL-space
+ errors versus plain MSE, pushing the optimizer harder to fix
+ decisively-wrong (e.g. material-blind) predictions relative to
+ subtle positional ones. 2026-10-17: tried after plain MSE (exp=2)
+ showed near-identical score drops whether a pawn or a queen was
+ removed from a position (see sanity_check_net.py)."""
+ err = (pred - target).abs().pow(exponent)
+ return err.sum() if reduction == "sum" else err.mean()
+
+
+def evaluate(model, data, batch_size, k, loss_exponent, device="cpu"):
+ """Returns (wdl_loss, material_mse) -- kept separate rather than
+ pre-combined so the log/checkpoint can show whether the material
+ probe is actually converging, independently of whatever weight
+ the caller gives it in the training loss."""
model.eval()
- total_loss = 0.0
+ total_wdl_loss = 0.0
+ total_material_loss = 0.0
total_n = 0
rng = np.random.default_rng(0)
with torch.no_grad():
- for w_idx, w_off, b_idx, b_off, stm, target in iterate_batches(
+ for w_idx, w_off, b_idx, b_off, stm, target, material in iterate_batches(
data, batch_size, shuffle=False, rng=rng):
- pred = model.win_probability(w_idx, w_off, b_idx, b_off, stm, k=k)
- loss = nn.functional.mse_loss(pred, target, reduction="sum")
- total_loss += loss.item()
+ w_idx, w_off, b_idx, b_off, stm, target, material = _to_device(
+ (w_idx, w_off, b_idx, b_off, stm, target, material), device)
+ raw, material_pred = model.forward_with_material(w_idx, w_off, b_idx, b_off, stm)
+ pred = torch.sigmoid(raw / k)
+ wdl_loss = powered_loss(pred, target, loss_exponent, reduction="sum")
+ material_loss = nn_mse_sum(material_pred, material)
+ total_wdl_loss += wdl_loss.item()
+ total_material_loss += material_loss.item()
total_n += len(target)
model.train()
- return total_loss / max(total_n, 1)
+ return total_wdl_loss / max(total_n, 1), total_material_loss / max(total_n, 1)
+
+
+def nn_mse_sum(pred, target):
+ return (pred - target).pow(2).sum()
def main():
@@ -112,16 +157,65 @@ def main():
ap.add_argument("--batch-size", type=int, default=8192)
ap.add_argument("--lr", type=float, default=1e-3)
ap.add_argument("--k", type=float, default=DEFAULT_K)
- ap.add_argument("--threads", type=int, default=8,
- help="torch CPU thread count -- kept modest by default "
- "since this is a shared multi-user box")
+ ap.add_argument("--loss-exponent", type=float, default=2.0,
+ help="exponent on |pred-target| in the loss (2.0 = "
+ "plain MSE). Stockfish's own training notes "
+ "report good results around 2.6 -- widens the "
+ "loss ratio between small and large WDL-space "
+ "errors, pushing harder to fix decisively-wrong "
+ "predictions. See powered_loss()'s docstring.")
+ ap.add_argument("--material-loss-weight", type=float, default=0.0,
+ help="weight on the auxiliary material_probe MSE "
+ "term (plain pawns^2, NOT WDL-space -- so this "
+ "is typically orders of magnitude larger in raw "
+ "scale than the WDL loss; start small, e.g. "
+ "0.001-0.01, and check sanity_check_net.py "
+ "rather than assume a value works). 0.0 "
+ "(default) disables it entirely -- the probe "
+ "head still exists but gets no gradient, "
+ "equivalent to not having added it.")
+ ap.add_argument("--threads", type=int, default=16,
+ help="torch CPU thread count -- kept below this box's "
+ "24 cores (not maxed out) since this is a shared "
+ "multi-user box, matching the --workers default "
+ "used elsewhere in this pipeline. Ignored when "
+ "--device is not cpu.")
+ ap.add_argument("--device", default="cpu",
+ help="torch device, e.g. 'cpu' (default, local "
+ "iteration -- see this module's docstring), "
+ "'cuda' (a rented GPU box, Phase B), or 'mps' "
+ "(Apple Silicon). 'cuda'/'mps' with no matching "
+ "torch/driver support available fails fast at "
+ "startup rather than silently falling back to "
+ "cpu.")
ap.add_argument("--eval-every-steps", type=int, default=2000)
+ ap.add_argument("--patience", type=int, default=0,
+ help="stop early if holdout_mse (the end-of-epoch "
+ "value, not the more frequent mid-epoch one) "
+ "fails to improve for this many consecutive "
+ "epochs. 0 (default) disables early stopping -- "
+ "always runs the full --epochs. Overfitting is "
+ "still tracked either way (see best.json); "
+ "this only controls whether training keeps "
+ "burning wall-clock time after it starts.")
ap.add_argument("--resume", default=None,
help="checkpoint .pt to resume from")
ap.add_argument("--seed", type=int, default=0)
args = ap.parse_args()
- torch.set_num_threads(args.threads)
+ if args.device.startswith("cuda") and not torch.cuda.is_available():
+ raise SystemExit(f"--device {args.device!r} requested but "
+ f"torch.cuda.is_available() is False -- check the "
+ f"CUDA driver/torch build on this box rather than "
+ f"silently training on CPU")
+ if args.device == "mps" and not torch.backends.mps.is_available():
+ raise SystemExit(f"--device 'mps' requested but "
+ f"torch.backends.mps.is_available() is False -- "
+ f"needs Apple Silicon + a torch build with MPS "
+ f"support, rather than silently training on CPU")
+
+ if args.device == "cpu":
+ torch.set_num_threads(args.threads)
torch.manual_seed(args.seed)
outdir = Path(args.outdir)
@@ -133,15 +227,43 @@ def main():
holdout_data = CsrTensors(args.holdout_dir)
print(f" train: {len(train_data)} positions, holdout: {len(holdout_data)} positions")
- model = NnueNet()
+ model = NnueNet().to(args.device)
optimizer = torch.optim.Adam(model.parameters(), lr=args.lr)
start_epoch = 0
global_step = 0
+ best_holdout_mse = float("inf")
+ best_epoch = None
+ epochs_without_improvement = 0
+
+ best_path = outdir / "best.json"
+ if best_path.exists():
+ # Resuming (or re-running against an outdir that already has
+ # history): load prior best-epoch state rather than restarting
+ # the "best so far" search from scratch, which would otherwise
+ # let a later, worse epoch overwrite best.json just because
+ # this process forgot what the real best was.
+ prior_best = json.loads(best_path.read_text())
+ best_holdout_mse = prior_best["holdout_mse"]
+ best_epoch = prior_best["epoch"]
if args.resume:
+ # map_location="cpu" always on load, then .to(device) below --
+ # a checkpoint saved from a CUDA run must still be resumable on
+ # a CPU-only box (and vice versa) without the load itself
+ # depending on CUDA being available.
ckpt = torch.load(args.resume, map_location="cpu", weights_only=True)
model.load_state_dict(ckpt["model"])
+ model.to(args.device)
optimizer.load_state_dict(ckpt["optimizer"])
+ if args.device != "cpu":
+ # Adam's per-parameter state (exp_avg/exp_avg_sq) loads as
+ # whatever device it was saved from (map_location="cpu"
+ # above) -- move it to match the model's new device, or
+ # the first optimizer.step() throws a device-mismatch error.
+ for state in optimizer.state.values():
+ for key, val in state.items():
+ if torch.is_tensor(val):
+ state[key] = val.to(args.device)
start_epoch = ckpt["epoch"] + 1
global_step = ckpt.get("global_step", 0)
print(f"Resumed from {args.resume} at epoch {start_epoch}")
@@ -155,40 +277,53 @@ def main():
epoch_n = 0
t_epoch = time.time()
- for w_idx, w_off, b_idx, b_off, stm, target in iterate_batches(
+ for w_idx, w_off, b_idx, b_off, stm, target, material in iterate_batches(
train_data, args.batch_size, shuffle=True, rng=rng):
+ w_idx, w_off, b_idx, b_off, stm, target, material = _to_device(
+ (w_idx, w_off, b_idx, b_off, stm, target, material), args.device)
optimizer.zero_grad()
- pred = model.win_probability(w_idx, w_off, b_idx, b_off, stm, k=args.k)
- loss = nn.functional.mse_loss(pred, target)
+ raw, material_pred = model.forward_with_material(w_idx, w_off, b_idx, b_off, stm)
+ pred = torch.sigmoid(raw / args.k)
+ wdl_loss = powered_loss(pred, target, args.loss_exponent)
+ material_loss = nn_mse_sum(material_pred, material) / len(target)
+ loss = wdl_loss + args.material_loss_weight * material_loss
loss.backward()
optimizer.step()
bs = len(target)
- epoch_loss += loss.item() * bs
+ epoch_loss += wdl_loss.item() * bs
epoch_n += bs
global_step += 1
if global_step % args.eval_every_steps == 0:
- holdout_mse = evaluate(model, holdout_data, args.batch_size, args.k)
+ holdout_wdl, holdout_material = evaluate(
+ model, holdout_data, args.batch_size, args.k,
+ args.loss_exponent, args.device)
elapsed = time.time() - t_start
rec = {
"step": global_step, "epoch": epoch,
"train_mse_running": epoch_loss / max(epoch_n, 1),
- "holdout_mse": holdout_mse,
+ "holdout_mse": holdout_wdl,
+ "holdout_material_mse": holdout_material,
"elapsed_s": round(elapsed, 1),
"positions_seen": epoch_n + epoch * len(train_data),
}
print(f" step {global_step} epoch {epoch} "
f"train_mse={rec['train_mse_running']:.5f} "
- f"holdout_mse={holdout_mse:.5f} "
+ f"holdout_mse={holdout_wdl:.5f} "
+ f"holdout_material_mse={holdout_material:.4f} "
f"elapsed={elapsed:.0f}s")
log_f.write(json.dumps(rec) + "\n")
log_f.flush()
- holdout_mse = evaluate(model, holdout_data, args.batch_size, args.k)
+ holdout_wdl, holdout_material = evaluate(
+ model, holdout_data, args.batch_size, args.k,
+ args.loss_exponent, args.device)
+ holdout_mse = holdout_wdl
epoch_elapsed = time.time() - t_epoch
print(f"Epoch {epoch} done: train_mse={epoch_loss / max(epoch_n, 1):.5f} "
- f"holdout_mse={holdout_mse:.5f} ({epoch_elapsed:.0f}s)")
+ f"holdout_mse={holdout_wdl:.5f} "
+ f"holdout_material_mse={holdout_material:.4f} ({epoch_elapsed:.0f}s)")
ckpt_path = outdir / f"ckpt_epoch{epoch:03d}.pt"
torch.save({
@@ -201,7 +336,43 @@ def main():
}, ckpt_path)
print(f" wrote {ckpt_path}")
- print(f"Training done in {time.time() - t_start:.0f}s")
+ # Track the best-holdout epoch regardless of --patience, so
+ # a run that does nothing special still records which
+ # epoch to actually export -- see run_nnue_pipeline.py's
+ # best_checkpoint(), which reads this instead of just
+ # taking the highest epoch number (previously: always the
+ # LAST epoch trained, overfitting or not).
+ if holdout_mse < best_holdout_mse:
+ best_holdout_mse = holdout_mse
+ best_epoch = epoch
+ epochs_without_improvement = 0
+ best_path.write_text(json.dumps({
+ "epoch": epoch,
+ "holdout_mse": holdout_mse,
+ "ckpt": ckpt_path.name,
+ }))
+ print(f" new best (holdout_mse={holdout_mse:.5f})")
+ else:
+ epochs_without_improvement += 1
+ print(f" not an improvement over epoch {best_epoch}'s "
+ f"holdout_mse={best_holdout_mse:.5f} "
+ f"({epochs_without_improvement} epoch(s) without "
+ f"improvement)")
+ if args.patience and epochs_without_improvement >= args.patience:
+ print(f"Stopping early: no holdout improvement for "
+ f"{epochs_without_improvement} epochs "
+ f"(--patience {args.patience})")
+ break
+
+ print(f"Training done in {time.time() - t_start:.0f}s "
+ f"(best epoch: {best_epoch}, holdout_mse={best_holdout_mse:.5f})")
+
+ # Completion marker for run_nnue_pipeline.py's run_stage() --
+ # NOT the final epoch's checkpoint file, which --patience early
+ # stopping may legitimately mean never gets written.
+ (outdir / "train_complete.marker").write_text(json.dumps({
+ "best_epoch": best_epoch, "best_holdout_mse": best_holdout_mse,
+ }))
if __name__ == "__main__":
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()
diff --git a/annotate_pgns/parse_stockfish_net.py b/annotate_pgns/parse_stockfish_net.py
new file mode 100644
index 0000000..ad70eb9
--- /dev/null
+++ b/annotate_pgns/parse_stockfish_net.py
@@ -0,0 +1,117 @@
+#!/usr/bin/env python3
+"""Extracts the FeatureTransformer's psqtWeights table from a real
+Stockfish .nnue file (nn-fdc1d0fe6455.nnue, added to Stockfish 2023-06-12,
+rev 38e61663d836e062af0bc002814ad5149c4b7729 -- see
+https://github.com/official-stockfish/networks).
+
+Parsed against that exact commit's header-reading code (fetched and
+read directly, not guessed/reverse-engineered by eye -- see
+src/nnue/evaluate_nnue.cpp:94-125, src/nnue/nnue_feature_transformer.h:254-258,
+src/nnue/nnue_architecture.h:44, src/nnue/features/half_ka_v2_hm.h):
+
+Top-level header (evaluate_nnue.cpp read_header):
+ version uint32 LE
+ hashValue uint32 LE
+ size uint32 LE (length of description string)
+ description `size` bytes
+
+FeatureTransformer section (Detail::read_parameters + its own
+read_parameters):
+ ft_header_hash uint32 LE, must equal
+ HalfKAv2_hm::HashValue (0x7f234cb8) XOR (2*L1)
+ biases L1 int16 values
+ weights L1 * InputDimensions int16 values
+ psqtWeights PSQTBuckets * InputDimensions int32 values,
+ laid out psqtWeights[index*PSQTBuckets + k]
+
+InputDimensions = SQUARE_NB*PS_NB/2 = 22528, IDENTICAL formula/indexing
+to typhoon's own HalfKAv2_hm spec (docs/NNUE.md) -- same king buckets,
+same orientation table, same piece-square planes. PSQTBuckets=8
+(nnue_architecture.h). L1 for this net is NOT assumed -- derived below
+from the stored ft hash, since Stockfish's own loader treats that
+hash as the authoritative check, not file size or guessed offsets.
+
+This script ONLY extracts psqtWeights (a literal per-feature-index
+scalar linear value, one per material-count bucket) for comparison/
+diagnostic purposes against our own training coverage gaps -- NOT for
+copying into our net's weights. See the conversation: the goal is a
+from-scratch net, this is a sanity check on whether our dusty corners
+are in the right ballpark, not a transplant.
+
+Usage:
+ python3 parse_stockfish_net.py nn-fdc1d0fe6455.nnue --out sf_psqt.npy
+"""
+import argparse
+import struct
+import sys
+
+import numpy as np
+
+HALFKAV2_HM_HASH = 0x7F234CB8
+PS_NB = 11 * 64 # 704
+INPUT_DIMENSIONS = (64 * PS_NB) // 2 # 22528
+PSQT_BUCKETS = 8
+
+
+def read_u32(f):
+ return struct.unpack("<I", f.read(4))[0]
+
+
+def main():
+ ap = argparse.ArgumentParser(description=__doc__,
+ formatter_class=argparse.RawDescriptionHelpFormatter)
+ ap.add_argument("nnue_file")
+ ap.add_argument("--out", required=True,
+ help="output .npy, shape [INPUT_DIMENSIONS, PSQT_BUCKETS] int32")
+ args = ap.parse_args()
+
+ with open(args.nnue_file, "rb") as f:
+ version = read_u32(f)
+ top_hash = read_u32(f)
+ desc_len = read_u32(f)
+ desc = f.read(desc_len).decode("utf-8", errors="replace")
+ print(f"version=0x{version:08x} top_hash=0x{top_hash:08x}")
+ print(f"description: {desc!r}")
+
+ ft_hash = read_u32(f)
+ print(f"ft_hash=0x{ft_hash:08x}")
+
+ # ft_hash = FeatureSet::HashValue ^ (OutputDimensions * 2)
+ # (feature_transformer.h:250) -- solve for L1 (OutputDimensions).
+ l1_times_2 = ft_hash ^ HALFKAV2_HM_HASH
+ if l1_times_2 % 2 != 0:
+ sys.exit(f"ft_hash XOR HalfKAv2_hm hash gave an odd value "
+ f"({l1_times_2}) -- this net's feature set isn't "
+ f"HalfKAv2_hm, or the hash constant is wrong for "
+ f"this Stockfish version. Refusing to guess further.")
+ l1 = l1_times_2 // 2
+ print(f"Derived L1 (TransformedFeatureDimensions) = {l1}")
+
+ n_biases = l1
+ n_weights = l1 * INPUT_DIMENSIONS
+ n_psqt = PSQT_BUCKETS * INPUT_DIMENSIONS
+
+ biases = np.frombuffer(f.read(2 * n_biases), dtype="<i2")
+ assert len(biases) == n_biases, "short read on biases -- bad offset math"
+ print(f"biases: read {len(biases)}, "
+ f"range [{biases.min()}, {biases.max()}]")
+
+ # Skip the weights array -- not needed for this diagnostic, and
+ # it's ~66MB (l1 * 22528 * 2 bytes), the bulk of the file.
+ f.seek(2 * n_weights, 1)
+
+ psqt_raw = np.frombuffer(f.read(4 * n_psqt), dtype="<i4")
+ if len(psqt_raw) != n_psqt:
+ sys.exit(f"short read on psqtWeights: got {len(psqt_raw)}, "
+ f"expected {n_psqt} -- offset math above must be "
+ f"wrong (L1 derivation or file truncated)")
+
+ psqt = psqt_raw.reshape(INPUT_DIMENSIONS, PSQT_BUCKETS)
+ print(f"psqtWeights: shape {psqt.shape}, "
+ f"range [{psqt.min()}, {psqt.max()}]")
+ np.save(args.out, psqt)
+ print(f"Wrote {args.out}")
+
+
+if __name__ == "__main__":
+ sys.exit(main())
diff --git a/annotate_pgns/quiet_filter_games.py b/annotate_pgns/quiet_filter_games.py
index f4de31e..6b1fc58 100644
--- a/annotate_pgns/quiet_filter_games.py
+++ b/annotate_pgns/quiet_filter_games.py
@@ -24,6 +24,7 @@ Usage:
import argparse
import json
import sys
+from multiprocessing import Pool
from quiet_common import chess, position_is_quiet
from quiet_filter import pv_first_move
@@ -54,10 +55,30 @@ def filter_game_positions(game_rec):
return kept, n_excluded_check, n_excluded_capture, n_indeterminate
+def filter_line(line):
+ """Runs in a worker process: filters one game-record JSON line,
+ returning the re-serialized output line (None if the game was
+ dropped entirely) plus counts, in the same order lines came in."""
+ game_rec = json.loads(line)
+ n_pos_in = len(game_rec["positions"])
+ kept, c, p, i = filter_game_positions(game_rec)
+ if not kept:
+ return None, n_pos_in, 0, c, p, i
+ game_rec["positions"] = kept
+ return json.dumps(game_rec), n_pos_in, len(kept), c, p, i
+
+
def main():
ap = argparse.ArgumentParser(description=__doc__)
ap.add_argument("--in", dest="infile", required=True)
ap.add_argument("--out", dest="outfile", required=True)
+ ap.add_argument("--workers", type=int, default=16,
+ help="worker processes for the per-game filtering "
+ "pass (each game is independent); 1 disables "
+ "multiprocessing")
+ ap.add_argument("--chunk-size", type=int, default=2000,
+ help="games per unit of work handed to a worker; "
+ "only relevant when --workers > 1")
args = ap.parse_args()
n_games_in = n_games_out = n_games_dropped = 0
@@ -65,24 +86,24 @@ def main():
n_excluded_check = n_excluded_capture = n_indeterminate = 0
with open(args.infile) as fin, open(args.outfile, "w") as fout:
- for line in fin:
- game_rec = json.loads(line)
+ if args.workers == 1:
+ results = (filter_line(line) for line in fin)
+ else:
+ with Pool(processes=args.workers) as pool:
+ results = list(pool.imap(filter_line, fin,
+ chunksize=args.chunk_size))
+ for out_line, pin, pout, c, p, i in results:
n_games_in += 1
- n_pos_in += len(game_rec["positions"])
-
- kept, c, p, i = filter_game_positions(game_rec)
+ n_pos_in += pin
n_excluded_check += c
n_excluded_capture += p
n_indeterminate += i
-
- if not kept:
+ if out_line is None:
n_games_dropped += 1
continue
-
- game_rec["positions"] = kept
- fout.write(json.dumps(game_rec) + "\n")
+ fout.write(out_line + "\n")
n_games_out += 1
- n_pos_out += len(kept)
+ n_pos_out += pout
if n_games_in % 2000 == 0:
print(f" ...{n_games_in} games processed "
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()
diff --git a/annotate_pgns/sample_egtb_positions.py b/annotate_pgns/sample_egtb_positions.py
new file mode 100644
index 0000000..f4cfa45
--- /dev/null
+++ b/annotate_pgns/sample_egtb_positions.py
@@ -0,0 +1,202 @@
+#!/usr/bin/env python3
+"""Generate direct Syzygy-ground-truth training positions for the
+EGTB-mixing idea in NNUE.md: random LEGAL positions for a curated set
+of endgame piece configurations, each probed directly against the
+Syzygy tablebase for its exact WDL result and labeled with that result
+(1.0/0.0/0.5, mover-relative) -- NOT derived from any engine search
+score, so there's no sigmoid/lambda involved at all for these rows
+(see build_training_tensors.py's position_target: exact rows with an
+r_outcome use it directly).
+
+Why this exists as a separate generator rather than just trusting
+Stockfish self-play games to supply this signal: a self-play game's
+score field, even when the search is quietly influenced by a TB probe
+a few plies down, is NOT a reliable way to detect "this exact position
+is a TB-determined result" after the fact -- see parse_comment's
+MATE_CLAMP_THRESHOLD handling in extract_selfplay_positions.py, which
+can only catch the unambiguous extreme case (|score| close to the
+engine's own mate-score cap). An ordinary-looking, e.g., "+0.30"
+6-piece position could just as easily be a genuine TB draw as a normal
+balanced middlegame-ish score, and there is no way to tell the two
+apart from the log alone. Rather than guess, this script sidesteps the
+ambiguity entirely: it builds positions whose piece count and
+side-to-move are known up front, and asks the tablebase directly,
+which is unambiguous by construction.
+
+Curated piece-set list: endgames that are either common in real games
+(KPK, KRK, KQK, basic mating/drawing technique) or specifically
+instructive for a net's positional judgment near the piece-trade
+boundary (KRPKR, KBPKB, KNPKN -- same-minor-vs-pawn fortress/win
+distinctions; KPPKPP -- connected/outside-passer races). Deliberately
+excludes same-type-heavy, essentially-never-arising-from-real-play
+configurations like KNNNKB (three knights) or KQQKQQ -- these exist in
+the 6-piece Syzygy set for completeness but add no realistic training
+signal, only wasted sampling effort chasing positions the net will
+never see at inference time.
+
+Usage:
+ python3 sample_egtb_positions.py \\
+ --egtb-path /zscratch/egtb \\
+ --out runs/egtb_direct/positions.jsonl \\
+ --per-config 2000
+"""
+import argparse
+import json
+import random
+import sys
+
+import chess
+import chess.syzygy
+
+# Each entry: (white_pieces, black_pieces), piece letters excluding the
+# king (every side always has exactly one king, added automatically).
+# Ordered white-majority-first by convention; sampling itself places
+# White/Black and side-to-move uniformly at random regardless.
+USEFUL_CONFIGS = [
+ # 3-piece: the universal basics.
+ ("P", ""), # KPK
+ ("R", ""), # KRK
+ ("Q", ""), # KQK
+ ("B", "B"), # KBKB (same-color-bishop draw / opposite-color technique)
+ ("N", "N"), # KNKN
+ # 4-piece: one side up material, or pawn-down defensive technique.
+ ("R", "P"), # KRKP
+ ("Q", "P"), # KQKP
+ ("Q", "R"), # KQKR
+ ("R", "B"), # KRKB
+ ("R", "N"), # KRKN
+ ("B", "N"), # KBKN
+ ("P", "P"), # KPKP (opposition/zugzwang pawn races)
+ # 5-piece: minor+pawn fortress/technique boundary cases, specifically
+ # called out as instructive in the discussion that motivated this.
+ ("RP", "R"), # KRPKR (the single most common rook-endgame skeleton)
+ ("BP", "B"), # KBPKB (same/opposite-color bishop-vs-pawn technique)
+ ("NP", "N"), # KNPKN
+ ("QP", "Q"), # KQPKQ
+ ("PP", "P"), # KPPKP
+ # 6-piece: the specific "KPPKPP, not KNNNKB" case from the discussion.
+ ("PP", "PP"), # KPPKPP (connected/outside passer races)
+ ("RP", "RP"), # KRPKRP
+ ("BP", "BP"), # KBPKBP
+ ("RR", "R"), # KRRKR
+]
+
+PIECE_SYMBOL = {
+ "P": chess.PAWN, "N": chess.KNIGHT, "B": chess.BISHOP,
+ "R": chess.ROOK, "Q": chess.QUEEN,
+}
+
+
+def random_board(white_pieces, black_pieces, rng):
+ """Attempts one random legal placement for this piece configuration
+ (kings included). Returns a chess.Board, or None if this attempt
+ didn't produce a legal position (retried by the caller) -- most
+ retries are from kings ending up adjacent or the side not on move
+ being left in check, both of which are just rejection-sampled away
+ rather than avoided by construction, since legal-position rejection
+ sampling is simple, fast for this few-piece count, and impossible
+ to get subtly wrong the way a hand-built adjacency/legality check
+ could."""
+ squares = rng.sample(range(64), 2 + len(white_pieces) + len(black_pieces))
+ board = chess.Board.empty()
+ board.set_piece_at(squares[0], chess.Piece(chess.KING, chess.WHITE))
+ board.set_piece_at(squares[1], chess.Piece(chess.KING, chess.BLACK))
+ idx = 2
+ for letter in white_pieces:
+ sq = squares[idx]
+ idx += 1
+ if letter == "P" and chess.square_rank(sq) in (0, 7):
+ return None
+ board.set_piece_at(sq, chess.Piece(PIECE_SYMBOL[letter], chess.WHITE))
+ for letter in black_pieces:
+ sq = squares[idx]
+ idx += 1
+ if letter == "P" and chess.square_rank(sq) in (0, 7):
+ return None
+ board.set_piece_at(sq, chess.Piece(PIECE_SYMBOL[letter], chess.BLACK))
+ board.turn = chess.WHITE if rng.random() < 0.5 else chess.BLACK
+ board.castling_rights = chess.BB_EMPTY
+ if not board.is_valid():
+ return None
+ return board
+
+
+def sample_config(tablebase, white_pieces, black_pieces, n, rng, max_attempts):
+ """Returns up to n (fen, r_outcome) pairs for one piece configuration,
+ r_outcome mover-relative (1.0/0.5/0.0), cursed-win/blessed-loss
+ collapsed to the practical 1.0/0.0 (not 0.5) -- under the engine's
+ own 50-move-rule handling a cursed win is still winnable with
+ correct play short of the counter running out, so it is a much
+ better training label as "winning" than as "drawn"; collapsing it
+ the other way would teach the net to misjudge a real winning
+ chance as hopeless."""
+ out = []
+ attempts = 0
+ while len(out) < n and attempts < max_attempts:
+ attempts += 1
+ board = random_board(white_pieces, black_pieces, rng)
+ if board is None:
+ continue
+ try:
+ wdl = tablebase.probe_wdl(board)
+ except chess.syzygy.MissingTableError:
+ return out # this exact configuration has no table -- give up
+ if wdl > 0:
+ r_outcome = 1.0
+ elif wdl < 0:
+ r_outcome = 0.0
+ else:
+ r_outcome = 0.5
+ out.append((board.fen(), r_outcome))
+ return out
+
+
+def main():
+ ap = argparse.ArgumentParser(description=__doc__,
+ formatter_class=argparse.RawDescriptionHelpFormatter)
+ ap.add_argument("--egtb-path", default="/zscratch/egtb")
+ ap.add_argument("--out", required=True)
+ ap.add_argument("--per-config", type=int, default=2000,
+ help="target sample count per piece configuration "
+ "(each configuration is also sampled with both "
+ "colors' roles swapped, so total yield per entry "
+ "in USEFUL_CONFIGS is up to 2x this)")
+ ap.add_argument("--max-attempts-factor", type=int, default=50,
+ help="give up on a configuration after "
+ "per-config * this many random placements, in "
+ "case legal-position rejection sampling is "
+ "pathologically slow for some piece set")
+ ap.add_argument("--seed", type=int, default=0)
+ args = ap.parse_args()
+
+ rng = random.Random(args.seed)
+ tablebase = chess.syzygy.open_tablebase(args.egtb_path)
+
+ total = 0
+ with open(args.out, "w") as fout:
+ for white_pieces, black_pieces in USEFUL_CONFIGS:
+ for wp, bp in ({(white_pieces, black_pieces),
+ (black_pieces, white_pieces)}):
+ rows = sample_config(tablebase, wp, bp, args.per_config, rng,
+ args.per_config * args.max_attempts_factor)
+ for fen, r_outcome in rows:
+ game_rec = {
+ "game_id": f"egtb_direct_K{wp}K{bp}_{total}",
+ "result": "*",
+ "positions": [{
+ "fen": fen,
+ "exact": True,
+ "exact_reason": "egtb_direct",
+ "r_outcome": r_outcome,
+ }],
+ }
+ fout.write(json.dumps(game_rec) + "\n")
+ total += 1
+ print(f" K{wp}vK{bp}: {len(rows)}/{args.per_config} sampled",
+ file=sys.stderr)
+
+ print(f"Wrote {args.out}: {total} direct-EGTB positions", file=sys.stderr)
+
+
+if __name__ == "__main__":
+ main()
diff --git a/annotate_pgns/sanity_check_net.py b/annotate_pgns/sanity_check_net.py
new file mode 100755
index 0000000..fd3b240
--- /dev/null
+++ b/annotate_pgns/sanity_check_net.py
@@ -0,0 +1,306 @@
+#!/usr/bin/env python3
+"""Material-sensitivity sanity check for a trained NNUE checkpoint.
+
+Loads a raw PyTorch checkpoint (nnue_train.py's ckpt_epochNNN.pt) and
+runs it, via nnue_model.py's forward() directly -- no export/C
+round-trip needed, so this is the earliest point in the pipeline a
+checkpoint can be checked -- against a battery of hand-picked base
+positions, each with one piece removed at a time. Reports the raw
+score delta from the base position for each removal and flags
+whether the deltas are monotonic in material value (pawn < knight ~
+bishop < rook < queen), which a net with a real concept of material
+should satisfy even though nothing forces it to structurally.
+
+This exists because of a real, measured failure found 2026-10-17:
+removing a queen, a rook, a knight, and a pawn from the same base
+position produced nearly indistinguishable score drops (-0.56 to
+-0.70) -- directionally correct (material loss always hurts) but
+with no real sense of HOW MUCH. Likely root cause: training loss is
+MSE against sigmoid(raw/K), and sigmoid's gradient vanishes once a
+position is already clearly lost/won, so a decisively-bad position
+gets little pressure to become MORE decisively bad regardless of
+how much additional material disappears. This script exists to make
+that failure mode visible at a glance on any future checkpoint,
+rather than rediscovering it by hand each time.
+
+Base positions deliberately avoid the opening setup: deleting a
+queen from the starting position is itself wildly out-of-distribution
+(no real game ever reaches that exact shape), so a net's response to
+it isn't a trustworthy read -- these use realistic midgame/endgame-ish
+material counts instead.
+
+2026-10-17: added --cross-sectional, a population-level version of the
+same idea. The hand-picked BASE_POSITIONS checks above are useful as a
+fast smoke test, but a single removal tests one specific piece on one
+specific square in one specific position -- its result is confounded
+by that piece's concrete role there (an active bishop vs. a boxed-in
+rook can legitimately score differently for positional reasons having
+nothing to do with the net's general sense of material), as observed
+directly: bishop-removal outscored rook-removal in one hand-picked
+position and the reverse held in another. --cross-sectional instead
+samples many REAL positions from a holdout pool, removes one piece of
+each type per position (when present), and pools deltas by piece
+type across the whole sample -- so per-position context should average
+out, leaving a cleaner read on the net's actual learned material
+prior (mean/median/stdev per piece type, not a single number).
+
+Usage:
+ python3 sanity_check_net.py --ckpt runs/pooled_gen2/nnue_ckpt/ckpt_epoch032.pt
+ python3 sanity_check_net.py --ckpt ... --cross-sectional \
+ --sample-file runs/pooled_gen2/games.holdout.labeled.quiet.jsonl \
+ --n-positions 2000
+"""
+import argparse
+import glob
+import json
+import random
+
+import chess
+import numpy as np
+import torch
+
+from nnue_feature_spec import active_feature_indices
+from nnue_model import NnueNet
+
+# (piece_type, approximate pawn value) -- value only used to order
+# the monotonicity check, not fed to the net.
+PIECE_VALUES = [
+ (chess.PAWN, 1),
+ (chess.KNIGHT, 3),
+ (chess.BISHOP, 3),
+ (chess.ROOK, 5),
+ (chess.QUEEN, 9),
+]
+
+BASE_POSITIONS = {
+ "midgame_balanced":
+ "r2qk2r/ppp2ppp/2n1bn2/2b1p3/4P3/2N1BN2/PPP2PPP/R2QK2R w KQkq - 0 1",
+ "midgame_balanced_black_to_move":
+ "r2qk2r/ppp2ppp/2n1bn2/2b1p3/4P3/2N1BN2/PPP2PPP/R2QK2R b KQkq - 0 1",
+ "endgame_rook_pawns":
+ "8/5pk1/6p1/8/4R3/5PK1/6P1/8 w - - 0 1",
+ "queen_endgame":
+ "6k1/5ppp/8/8/8/8/4QPPP/6K1 w - - 0 1",
+}
+
+
+def load_model(ckpt_path):
+ ck = torch.load(ckpt_path, map_location="cpu", weights_only=True)
+ model = NnueNet()
+ model.load_state_dict(ck["model"])
+ model.eval()
+ return model, ck.get("holdout_mse")
+
+
+def score(model, board):
+ w = active_feature_indices(board, chess.WHITE)
+ b = active_feature_indices(board, chess.BLACK)
+ w_idx = torch.tensor(w, dtype=torch.long)
+ w_off = torch.tensor([0], dtype=torch.long)
+ b_idx = torch.tensor(b, dtype=torch.long)
+ b_off = torch.tensor([0], dtype=torch.long)
+ stm = torch.tensor([1 if board.turn == chess.WHITE else 0], dtype=torch.long)
+ with torch.no_grad():
+ return model.forward(w_idx, w_off, b_idx, b_off, stm).item()
+
+
+def one_sided_removals(board, color):
+ """Yields (piece_type, value, square, board_with_piece_removed)
+ for every piece of `color` on the board, one at a time -- skips
+ kings (removing a king produces an illegal/meaningless position)."""
+ for piece_type, value in PIECE_VALUES:
+ squares = list(board.pieces(piece_type, color))
+ if not squares:
+ continue
+ sq = squares[0]
+ b2 = board.copy()
+ b2.remove_piece_at(sq)
+ yield piece_type, value, sq, b2
+
+
+def run_base_position(model, name, fen):
+ board = chess.Board(fen)
+ mover_color = board.turn
+ mover_name = "White" if mover_color == chess.WHITE else "Black"
+ base_score = score(model, board)
+ print(f"\n=== {name} ({mover_name} to move) ===")
+ print(f" base score: {base_score:+.3f}")
+
+ print(f" -- removing {mover_name}'s OWN pieces (score should drop, "
+ f"and drop MORE for more valuable pieces) --")
+ rows = []
+ for piece_type, value, sq, b2 in one_sided_removals(board, mover_color):
+ s = score(model, b2)
+ delta = s - base_score
+ rows.append((value, chess.piece_name(piece_type), delta))
+ print(f" remove own {chess.piece_name(piece_type):8s} "
+ f"(val={value}): score={s:+.3f} delta={delta:+.3f}")
+
+ # Monotonicity check: sort by piece value, deltas should be
+ # non-increasing (more valuable piece removed -> more negative
+ # delta), allowing ties between knight/bishop (same nominal value).
+ rows.sort(key=lambda r: r[0])
+ deltas = [d for _, _, d in rows]
+ monotonic = all(deltas[i] >= deltas[i + 1] - 1e-6 for i in range(len(deltas) - 1))
+ spread = max(deltas) - min(deltas) if deltas else 0.0
+ print(f" monotonic (pawn..queen delta non-increasing)? {monotonic} "
+ f"(spread between least/most valuable piece's delta: {spread:.3f})")
+ if not monotonic:
+ print(" *** FAIL: removing a MORE valuable piece did not hurt "
+ "the score MORE -- net may not have learned material. ***")
+ if deltas and spread < 0.5:
+ print(f" *** WARNING: spread of only {spread:.3f} across pawn..queen "
+ f"removal is small -- net may be material-insensitive even if "
+ f"technically monotonic. ***")
+
+ print(f" -- removing opponent's pieces (score should RISE) --")
+ opp_color = not mover_color
+ for piece_type, value, sq, b2 in one_sided_removals(board, opp_color):
+ s = score(model, b2)
+ delta = s - base_score
+ flag = "" if delta > 0 else " *** FAIL: should have risen ***"
+ print(f" remove opp {chess.piece_name(piece_type):8s} "
+ f"(val={value}): score={s:+.3f} delta={delta:+.3f}{flag}")
+
+
+def sample_fens(sample_file, n_positions, seed):
+ """Reservoir-sample n_positions FENs out of every position in
+ every game in a games.jsonl-shaped file (quiet_filter_games.py
+ output). Loads all FENs into memory first (a string per position,
+ cheap even at a few hundred thousand rows) rather than true
+ streaming reservoir sampling -- simpler, and the holdout pools
+ this is meant to run against are a few hundred MB at most."""
+ fens = []
+ with open(sample_file) as f:
+ for line in f:
+ game = json.loads(line)
+ for pos in game["positions"]:
+ fens.append(pos["fen"])
+ rng = random.Random(seed)
+ if n_positions < len(fens):
+ fens = rng.sample(fens, n_positions)
+ return fens
+
+
+def one_random_removal_per_piece_type(board, color, rng):
+ """Yields (piece_type, value, board_with_one_piece_removed) --
+ exactly one randomly-chosen piece per type present for `color`
+ (not every piece, unlike one_sided_removals -- across thousands
+ of sampled positions this still covers many squares per type in
+ aggregate without each position contributing >1 sample per type,
+ which would overweight positions with e.g. two bishops)."""
+ for piece_type, value in PIECE_VALUES:
+ squares = list(board.pieces(piece_type, color))
+ if not squares:
+ continue
+ sq = rng.choice(squares)
+ b2 = board.copy()
+ b2.remove_piece_at(sq)
+ yield piece_type, value, b2
+
+
+def run_cross_sectional(model, sample_file, n_positions, seed):
+ print(f"\n=== cross-sectional population check ===")
+ print(f" sampling {n_positions} positions from {sample_file} ...")
+ fens = sample_fens(sample_file, n_positions, seed)
+ print(f" got {len(fens)} FENs")
+
+ rng = random.Random(seed)
+ # own_deltas[piece_type] = list of deltas from removing the MOVER's
+ # own piece of that type (expected negative, more negative for
+ # more valuable pieces). opp_deltas = removing the opponent's
+ # piece instead (expected positive).
+ own_deltas = {pt: [] for pt, _ in PIECE_VALUES}
+ opp_deltas = {pt: [] for pt, _ in PIECE_VALUES}
+ n_skipped = 0
+
+ for i, fen in enumerate(fens):
+ try:
+ board = chess.Board(fen)
+ except ValueError:
+ n_skipped += 1
+ continue
+ mover = board.turn
+ base_score = score(model, board)
+ for piece_type, value, b2 in one_random_removal_per_piece_type(board, mover, rng):
+ own_deltas[piece_type].append(score(model, b2) - base_score)
+ for piece_type, value, b2 in one_random_removal_per_piece_type(board, not mover, rng):
+ opp_deltas[piece_type].append(score(model, b2) - base_score)
+ if (i + 1) % 500 == 0:
+ print(f" ...{i + 1}/{len(fens)} positions processed")
+
+ if n_skipped:
+ print(f" ({n_skipped} FENs failed to parse, skipped)")
+
+ def report(deltas_by_type, label, expect_sign):
+ print(f"\n -- {label} (expect sign: {'negative' if expect_sign < 0 else 'positive'}) --")
+ means = []
+ for piece_type, value in PIECE_VALUES:
+ d = np.array(deltas_by_type[piece_type])
+ if len(d) == 0:
+ print(f" {chess.piece_name(piece_type):8s} (val={value}): no samples")
+ continue
+ means.append((value, chess.piece_name(piece_type), d.mean()))
+ print(f" {chess.piece_name(piece_type):8s} (val={value}): "
+ f"n={len(d):6d} mean={d.mean():+.4f} median={np.median(d):+.4f} "
+ f"std={d.std():.4f} p25={np.percentile(d, 25):+.4f} "
+ f"p75={np.percentile(d, 75):+.4f}")
+ means.sort(key=lambda r: r[0])
+ mean_vals = [m for _, _, m in means]
+ if expect_sign < 0:
+ monotonic = all(mean_vals[i] >= mean_vals[i + 1] - 1e-6 for i in range(len(mean_vals) - 1))
+ else:
+ monotonic = all(mean_vals[i] <= mean_vals[i + 1] + 1e-6 for i in range(len(mean_vals) - 1))
+ print(f" population means monotonic in piece value? {monotonic}")
+ if not monotonic:
+ order = ", ".join(f"{name}={m:+.4f}" for _, name, m in means)
+ print(f" *** FAIL: population-level mean deltas not ordered "
+ f"by piece value: {order} ***")
+
+ report(own_deltas, "removing MOVER's own piece", expect_sign=-1)
+ report(opp_deltas, "removing OPPONENT's piece", expect_sign=+1)
+
+
+def main():
+ ap = argparse.ArgumentParser(description=__doc__,
+ formatter_class=argparse.RawDescriptionHelpFormatter)
+ ap.add_argument("--ckpt", default=None,
+ help="checkpoint path. Default: latest "
+ "ckpt_epoch*.pt under --ckpt-glob-dir")
+ ap.add_argument("--ckpt-glob-dir", default="runs/pooled_gen2/nnue_ckpt",
+ help="directory to find the latest checkpoint in, "
+ "when --ckpt is not given")
+ ap.add_argument("--cross-sectional", action="store_true",
+ help="run the population-level check (see module "
+ "docstring's 2026-10-17 note) instead of the "
+ "hand-picked single-position checks")
+ ap.add_argument("--sample-file",
+ default="runs/pooled_gen2/games.holdout.labeled.quiet.jsonl",
+ help="games.jsonl-shaped file to sample real "
+ "positions from, for --cross-sectional")
+ ap.add_argument("--n-positions", type=int, default=2000,
+ help="number of positions to sample, for --cross-sectional")
+ ap.add_argument("--seed", type=int, default=0)
+ args = ap.parse_args()
+
+ ckpt_path = args.ckpt
+ if ckpt_path is None:
+ candidates = sorted(glob.glob(f"{args.ckpt_glob_dir}/ckpt_epoch*.pt"))
+ if not candidates:
+ raise SystemExit(f"No checkpoints found under {args.ckpt_glob_dir}")
+ ckpt_path = candidates[-1]
+
+ print(f"Loading {ckpt_path} ...")
+ model, holdout_mse = load_model(ckpt_path)
+ if holdout_mse is not None:
+ print(f" (checkpoint's recorded holdout_mse: {holdout_mse:.5f})")
+
+ if args.cross_sectional:
+ run_cross_sectional(model, args.sample_file, args.n_positions, args.seed)
+ else:
+ for name, fen in BASE_POSITIONS.items():
+ run_base_position(model, name, fen)
+
+
+if __name__ == "__main__":
+ main()
diff --git a/annotate_pgns/shard_quiet_filter.py b/annotate_pgns/shard_quiet_filter.py
new file mode 100644
index 0000000..fb1c8cc
--- /dev/null
+++ b/annotate_pgns/shard_quiet_filter.py
@@ -0,0 +1,93 @@
+#!/usr/bin/env python3
+"""Shard quiet_filter.py's input across N worker subprocesses and
+recombine their outputs -- quiet_filter.py itself is single-threaded
+(a plain line-by-line loop doing chess.Board() + SEE per row), and
+with 2.5M+ rows that's the one CPU-bound step in the post-annotation
+pipeline with no parallelism of its own, unlike build_training_tensors.py's
+--workers Pool right next to it.
+
+Deliberately wraps quiet_filter.py as a subprocess rather than
+reimplementing its filter logic here -- one definition of the filter,
+reused unmodified; this script only owns the sharding/recombining.
+
+Splits the input round-robin (line i -> shard i % --workers) in one
+pass, so no second pass to count lines first is needed. Row order in
+the combined output does NOT match the input's order (shard outputs
+are concatenated shard-by-shard) -- fine for this file's downstream
+use (positions.quiet.jsonl is a flat, order-independent training-set
+input; quiet_filter_games.py, which does care about preserving a
+game's own position order, filters the game-shaped file directly and
+does not go through this wrapper).
+
+Usage:
+ python3 shard_quiet_filter.py --in runs/sd10_twic_2M/positions.jsonl \
+ --out runs/sd10_twic_2M/positions.quiet.jsonl --workers 16
+"""
+import argparse
+import subprocess
+import sys
+import tempfile
+from pathlib import Path
+
+_QUIET_FILTER = Path(__file__).resolve().parent / "quiet_filter.py"
+
+
+def shard_input(infile, shard_paths):
+ handles = [open(p, "w") for p in shard_paths]
+ n = len(handles)
+ try:
+ with open(infile) as fin:
+ for i, line in enumerate(fin):
+ handles[i % n].write(line)
+ finally:
+ for h in handles:
+ h.close()
+
+
+def main():
+ ap = argparse.ArgumentParser(description=__doc__)
+ ap.add_argument("--in", dest="infile", required=True)
+ ap.add_argument("--out", dest="outfile", required=True)
+ ap.add_argument("--workers", type=int, default=16,
+ help="number of quiet_filter.py subprocesses to run "
+ "concurrently (default 16, kept below this box's "
+ "24 cores to leave other jobs some breathing room)")
+ args = ap.parse_args()
+
+ with tempfile.TemporaryDirectory(prefix="shard_quiet_filter_") as tmpdir_str:
+ tmpdir = Path(tmpdir_str)
+ shard_ins = [tmpdir / f"shard_{i:02d}.in.jsonl" for i in range(args.workers)]
+ shard_outs = [tmpdir / f"shard_{i:02d}.out.jsonl" for i in range(args.workers)]
+
+ print(f"Sharding {args.infile} across {args.workers} workers ...",
+ file=sys.stderr)
+ shard_input(args.infile, shard_ins)
+
+ print(f"Launching {args.workers} quiet_filter.py workers ...",
+ file=sys.stderr)
+ procs = [
+ subprocess.Popen([sys.executable, str(_QUIET_FILTER),
+ "--in", str(sin), "--out", str(sout)])
+ for sin, sout in zip(shard_ins, shard_outs)
+ ]
+ returncodes = [p.wait() for p in procs]
+ if any(rc != 0 for rc in returncodes):
+ sys.exit(f"one or more quiet_filter.py workers failed "
+ f"(return codes: {returncodes})")
+
+ print(f"Combining {args.workers} shard outputs -> {args.outfile} ...",
+ file=sys.stderr)
+ n_rows = 0
+ with open(args.outfile, "w") as fout:
+ for sout in shard_outs:
+ with open(sout) as fin:
+ for line in fin:
+ fout.write(line)
+ n_rows += 1
+
+ print(f"Wrote {args.outfile}: {n_rows} quiet positions "
+ f"(combined from {args.workers} shards)")
+
+
+if __name__ == "__main__":
+ main()