diff options
| author | Scott Gasch <[email protected]> | 2026-10-15 13:11:22 -0700 |
|---|---|---|
| committer | Scott Gasch <[email protected]> | 2026-10-15 13:11:22 -0700 |
| commit | 993b6eeb2a9ac7b659c473b6883fa53af1ad3a0b (patch) | |
| tree | c5388824dec15a7691ce9952a6eb4af66e837127 /annotate_pgns | |
| parent | 53b380c658f828c4c0e096be2763e6b012c23abd (diff) | |
Phase A NNUE: feature-indexing spec, annotation/training pipeline, dump nnuefeatures
Builds out the NNUE Phase A prototype per docs/NNUE.md:
- src/nnuefeatures.c: standalone HalfKAv2_hm feature-index reference
implementation (new `dump nnuefeatures` command), sourced from
Stockfish's half_ka_v2_hm.{h,cpp} (sf_16 tag), including the 0x88->64
square conversion typhoon's own board representation needs.
- annotate_pgns/: sharded TWIC/self-play annotation harness
(annotate.py), position extraction, quiet-position filtering (flat
and game-shaped, plus an upstream --prefilter-quiet option),
train/holdout game-level stratified split, WDL-sigmoid K fitting,
the Python HalfKAv2_hm reference encoder (nnue_feature_spec.py) and
its bit-for-bit cross-checker against the C side
(cross_check_features.py), and a first PyTorch training pipeline
(nnue_model.py, nnue_train.py, build_training_tensors.py) -- a
256x2-32-32-1-ish net trained locally on CPU against ~1.85M quiet
TWIC positions as a pipeline/framework validation pass.
- Finish an in-progress InitMagic/InitLMRTable/InitEval ->
InitializeBitboardMagic/InitializeLMRTable/InitializeEval rename:
chess.h's prototypes hadn't been updated to match the already-renamed
definitions/call sites, leaving 4 implicit-function-declaration
warnings.
- tests/arasan.epd: new curated EPD suite.
- docs/: add the project's design docs (previously untracked).
Co-Authored-By: Claude Sonnet 5 <[email protected]>
Diffstat (limited to 'annotate_pgns')
| -rw-r--r-- | annotate_pgns/annotate.py | 246 | ||||
| -rw-r--r-- | annotate_pgns/build_games.py | 124 | ||||
| -rw-r--r-- | annotate_pgns/build_training_tensors.py | 117 | ||||
| -rw-r--r-- | annotate_pgns/check_see_against_engine.py | 160 | ||||
| -rw-r--r-- | annotate_pgns/cross_check_features.py | 122 | ||||
| -rw-r--r-- | annotate_pgns/extract_positions.py | 148 | ||||
| -rw-r--r-- | annotate_pgns/fit_k_and_label.py | 146 | ||||
| -rw-r--r-- | annotate_pgns/nnue_feature_spec.py | 93 | ||||
| -rw-r--r-- | annotate_pgns/nnue_model.py | 69 | ||||
| -rw-r--r-- | annotate_pgns/nnue_train.py | 208 | ||||
| -rw-r--r-- | annotate_pgns/quiet_common.py | 68 | ||||
| -rw-r--r-- | annotate_pgns/quiet_filter.py | 124 | ||||
| -rw-r--r-- | annotate_pgns/quiet_filter_games.py | 102 | ||||
| -rw-r--r-- | annotate_pgns/split_holdout.py | 113 |
14 files changed, 1840 insertions, 0 deletions
diff --git a/annotate_pgns/annotate.py b/annotate_pgns/annotate.py new file mode 100644 index 0000000..240b55f --- /dev/null +++ b/annotate_pgns/annotate.py @@ -0,0 +1,246 @@ +#!/usr/bin/env python3 +"""Shard a PGN file across N single-threaded typhoon engine instances, +score every position in every game at a fixed search depth, and +reassemble an annotated PGN with { +score/depth PV } comments matching +the self-play annotation format already used in +../pgn/self_play_games.pgn. + +Reusable across depths: --depth is a plain CLI argument, not baked into +any file name or code path, so re-running at sd12/sd16/etc. is just a +different --depth and --outdir. + +Usage: + python3 annotate.py --input ../pgn/twic_filtered.pgn \ + --depth 10 --shards 16 --hash 32m --outdir runs/sd10_twic + +See the module-level functions below for the four stages: shard, build +engine scripts, run engines in parallel, parse + reassemble. +""" +import argparse +import re +import subprocess +import sys +import time +from pathlib import Path + +import chess +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=(.*)$" +) + + +def load_games(input_paths, max_games=None): + """Load games, skipping ones with an unknown/unterminated result + ('*') -- can't derive a WDL training label without a real outcome. + Short-agreed-draw games are not separately filtered here: the + >40-ply / >=2400-Elo filter already applied when twic_filtered.pgn + was built (eval_tune/filter_pgn.py) rules those out by construction + (confirmed empirically: no games below 40 plies in a 5000-game + sample).""" + games = [] + for path in input_paths: + with open(path, encoding="utf-8", errors="replace") as f: + while max_games is None or len(games) < max_games: + game = chess.pgn.read_game(f) + if game is None: + break + if game.headers.get("Result", "*") == "*": + continue + games.append(game) + if max_games is not None and len(games) >= max_games: + break + return games + + +def shard_games(games, n_shards): + shards = [[] for _ in range(n_shards)] + for i, g in enumerate(games): + shards[i % n_shards].append(g) + return shards + + +def positions_for_game(game, prefilter_quiet=False): + """Yield the FEN *before* each move is played, skipping any ply at + or after the game is already over (no legal moves to search). + + If prefilter_quiet, also skip any ply whose position fails the + quiet-position test (quiet_common.position_is_quiet) -- checked + against the move actually played in this game, since that's the + only move available before a search has run. This is the same + check/exclude criteria as quiet_filter.py's post-hoc filter, but + SEE-checked against the *played* move rather than the engine's own + PV first move -- see quiet_common.py's module docstring for why + those are different training distributions, not interchangeable. + + ply numbering (pi, used by write_shard_script/annotate_shard/ + build_games.py to re-align results) still counts only the plies + actually yielded, so this must be called with the same + prefilter_quiet value everywhere a run's ply indices are + recomputed (build_games.py in particular).""" + board = game.board() + for move in game.mainline_moves(): + if board.is_game_over(): + break + if not prefilter_quiet or position_is_quiet(board, move): + yield board.fen() + board.push(move) + + +def write_shard_script(shard_games_list, script_path, depth, prefilter_quiet=False): + """Write the engine command script for one shard, plus the parallel + index recording which (game_idx_in_shard, ply_idx) each 'go' line + corresponds to -- the engine processes 'go' commands strictly in + script order with no interleaving, so a flat index list zips back + onto the flat sequence of tellothers output lines.""" + index = [] + with open(script_path, "w") as out: + for gi, game in enumerate(shard_games_list): + for pi, fen in enumerate(positions_for_game(game, prefilter_quiet)): + out.write(f"setboard {fen}\n") + out.write(f"sd {depth}\n") + out.write("go\n") + index.append((gi, pi)) + return index + + +def run_shard(engine_path, script_path, out_path, hash_size, cpus): + cmd = [ + str(engine_path), + "--cpus", str(cpus), + "--hash", hash_size, + "--logfile", "-", + "--batch", + "--command", + f"force; book name /nonexistent.book.bin; script {script_path}", + ] + with open(out_path, "w") as out: + return subprocess.Popen(cmd, stdout=out, stderr=subprocess.STDOUT) + + +def parse_shard_output(out_path): + """Return a list of (depth, score, pv_string) in script order.""" + 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())) + return results + + +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 + each result back to (game_idx_in_shard, ply_idx).""" + by_game = {} + for (gi, pi), res in zip(index, results): + by_game.setdefault(gi, {})[pi] = res + + for gi, game in enumerate(shard_games_list): + scores = by_game.get(gi, {}) + node = game + pi = 0 + while node.variations: + node = node.variations[0] + if pi in scores: + depth, score, pv = scores[pi] + node.comment = f"{score:+.2f}/{depth} {pv}" + pi += 1 + + +def main(): + ap = argparse.ArgumentParser(description=__doc__) + ap.add_argument("--input", nargs="+", required=True, + help="one or more input PGN files") + ap.add_argument("--depth", type=int, required=True, + help="fixed search depth (sd N) for every position") + ap.add_argument("--shards", type=int, default=16) + ap.add_argument("--hash", default="32m", + help="hash table size per shard engine, e.g. 32m") + ap.add_argument("--cpus", type=int, default=1, + help="--cpus passed to each shard's engine") + ap.add_argument("--engine", default="../src/typhoon") + ap.add_argument("--outdir", required=True) + ap.add_argument("--max-games", type=int, default=None, + help="limit to the first N games total, for smoke testing") + ap.add_argument("--prefilter-quiet", action="store_true", + help="skip in-check positions and SEE-positive-capture " + "played moves before ever sending them to the " + "engine, instead of searching everything and " + "filtering after the fact (see quiet_filter.py / " + "quiet_filter_games.py). Changes the quiet " + "criterion's move source from the engine's own PV " + "to the move actually played -- see quiet_common.py.") + args = ap.parse_args() + + outdir = Path(args.outdir) + outdir.mkdir(parents=True, exist_ok=True) + engine_path = Path(args.engine).resolve() + if not engine_path.exists(): + sys.exit(f"engine not found: {engine_path}") + + print(f"Loading games from {args.input} ...") + games = load_games(args.input, args.max_games) + print(f" {len(games)} games loaded") + + shards = shard_games(games, args.shards) + + if args.prefilter_quiet: + print("Pre-filtering: skipping in-check / SEE-positive-played-move " + "positions before search.") + print(f"Writing {args.shards} shard scripts at sd {args.depth} ...") + indices = [] + for i, shard in enumerate(shards): + script_path = outdir / f"shard_{i:02d}.script" + idx = write_shard_script(shard, script_path, args.depth, + args.prefilter_quiet) + indices.append(idx) + print(f" shard {i:02d}: {len(shard)} games, {len(idx)} positions") + + print("Launching shard engines in parallel ...") + t0 = time.time() + procs = [] + for i in range(args.shards): + script_path = outdir / f"shard_{i:02d}.script" + out_path = outdir / f"shard_{i:02d}.out" + if indices[i]: + procs.append((i, run_shard(engine_path, script_path, out_path, + args.hash, args.cpus))) + + # Wait for the slowest shard. + for i, p in procs: + rc = p.wait() + if rc != 0: + print(f" WARNING: shard {i:02d} exited with code {rc}", file=sys.stderr) + elapsed = time.time() - t0 + total_positions = sum(len(idx) for idx in indices) + print(f"All shards done in {elapsed:.1f}s " + f"({total_positions / elapsed:.2f} positions/sec aggregate)") + + print("Parsing output and reassembling annotated PGN ...") + final_path = outdir / "annotated.pgn" + total_annotated = 0 + with open(final_path, "w") as out: + for i, shard in enumerate(shards): + out_path = outdir / f"shard_{i:02d}.out" + results = parse_shard_output(out_path) if indices[i] else [] + if len(results) != len(indices[i]): + print(f" WARNING: shard {i:02d} expected {len(indices[i])} " + f"results, got {len(results)} -- some positions may be " + f"unannotated", file=sys.stderr) + annotate_shard(shard, indices[i], results) + total_annotated += len(results) + for game in shard: + print(game, file=out, end="\n\n") + + print(f"Wrote {final_path} ({len(games)} games, " + f"{total_annotated} annotated positions)") + + +if __name__ == "__main__": + main() diff --git a/annotate_pgns/build_games.py b/annotate_pgns/build_games.py new file mode 100644 index 0000000..8cac092 --- /dev/null +++ b/annotate_pgns/build_games.py @@ -0,0 +1,124 @@ +#!/usr/bin/env python3 +"""Reassemble an annotate.py run's flat per-position output back into +game-shaped containers: one record per source game, holding its +ordered list of annotated positions. + +Why this exists: extract_positions.py's positions.jsonl (fen, score, +depth, pv, exact per row) is a flat list -- the row order follows each +shard's script order, which happens to group consecutive rows by game +(write_shard_script writes one game's positions fully before moving to +the next), but there's no game_id recorded, so that grouping is +unrecoverable from positions.jsonl alone. That matters for anything +that needs to avoid splitting one game's (highly correlated, +near-duplicate) positions across a train/holdout boundary. + +This script recovers it deterministically, without re-running any +search: re-load the same PGN file with the same max_games (pure +parsing, no engine involved -- cheap), re-shard it the same way +annotate.py did (same n_shards, same deterministic round-robin), and +walk each game's known position count (positions_for_game) to slice +the already-extracted flat rows back into per-game chunks. + +Output shape -- deliberately the same "a game containing an ordered +list of annotated positions" shape a self-play PGN with per-move +score/PV comments already has, so a future loader for self-play games +can produce the same games.jsonl schema and both sources mix freely at +training-set-assembly time: + + {"game_id": "...", "result": "1-0", "positions": [ + {"fen": ..., "depth": ..., "score": ..., "score_raw": ..., + "pv": ..., "exact": ..., "exact_reason": ...}, + ... + ]} + +Usage: + python3 build_games.py --rundir runs/sd10_twic_2M \ + --pgn ../pgn/twic_filtered.pgn --max-games 30000 --shards 16 \ + --out runs/sd10_twic_2M/games.jsonl +""" +import argparse +import json +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent)) +from annotate import load_games, shard_games, positions_for_game + + +def main(): + ap = argparse.ArgumentParser(description=__doc__) + ap.add_argument("--rundir", required=True, + help="directory holding positions.jsonl (extract_positions.py output)") + ap.add_argument("--positions", default=None, + help="defaults to <rundir>/positions.jsonl") + ap.add_argument("--pgn", nargs="+", required=True, + help="same PGN file(s) annotate.py was originally run against") + ap.add_argument("--max-games", type=int, required=True, + help="same --max-games (or games actually used) as the original run") + ap.add_argument("--shards", type=int, default=16, + help="same --shards as the original run") + ap.add_argument("--prefilter-quiet", action="store_true", + help="must match whether the original annotate.py run " + "used --prefilter-quiet -- changes positions_for_game's " + "per-game ply count, which this script relies on to " + "re-slice the flat positions.jsonl") + ap.add_argument("--out", required=True) + args = ap.parse_args() + + rundir = Path(args.rundir) + positions_path = Path(args.positions) if args.positions else rundir / "positions.jsonl" + + print(f"Loading all rows from {positions_path} ...") + flat_rows = [] + with open(positions_path) as f: + for line in f: + flat_rows.append(json.loads(line)) + print(f" {len(flat_rows)} rows") + + print(f"Re-loading {args.pgn} (max_games={args.max_games}) to recover game boundaries ...") + games = load_games(args.pgn, args.max_games) + print(f" {len(games)} games") + shards = shard_games(games, args.shards) + + # Per-shard row ranges: shard_NN's rows are a contiguous slice of + # flat_rows in the same order extract_positions.py wrote them + # (shard 00 first, then 01, ...), each shard's own length equal to + # its total position count. + shard_lengths = [] + for shard in shards: + shard_lengths.append(sum( + 1 for g in shard + for _ in positions_for_game(g, args.prefilter_quiet))) + if sum(shard_lengths) != len(flat_rows): + sys.exit(f"row count mismatch: recomputed shards expect " + f"{sum(shard_lengths)} total positions, " + f"positions.jsonl has {len(flat_rows)} -- wrong --pgn/" + f"--max-games/--shards for this run?") + + n_games_written = 0 + n_positions_written = 0 + with open(args.out, "w") as out: + row_cursor = 0 + for shard_idx, shard in enumerate(shards): + for game_idx, game in enumerate(shard): + n_positions = sum(1 for _ in positions_for_game(game, args.prefilter_quiet)) + game_rows = flat_rows[row_cursor:row_cursor + n_positions] + row_cursor += n_positions + + rec = { + "game_id": f"{shard_idx:02d}_{game_idx:05d}", + "result": game.headers.get("Result", "*"), + "positions": game_rows, + } + out.write(json.dumps(rec) + "\n") + n_games_written += 1 + n_positions_written += len(game_rows) + + assert row_cursor == len(flat_rows) + + print(f"Wrote {args.out}: {n_games_written} games, " + f"{n_positions_written} total positions") + + +if __name__ == "__main__": + main() diff --git a/annotate_pgns/build_training_tensors.py b/annotate_pgns/build_training_tensors.py new file mode 100644 index 0000000..60e51e6 --- /dev/null +++ b/annotate_pgns/build_training_tensors.py @@ -0,0 +1,117 @@ +#!/usr/bin/env python3 +"""Precompute HalfKAv2_hm feature tensors from a game-shaped labeled +file (quiet_filter_games.py output) into a cached, CSR-style on-disk +format that nnue_train.py can mmap and sample from directly -- +avoids re-parsing FEN / recomputing SEE / recomputing feature indices +on every training epoch (the expensive part, done once here). + +Each position's active feature indices (per nnue_feature_spec.py, +already cross-checked bit-for-bit against the C reference -- see +cross_check_features.py) are stored CSR-style (one flat concatenated +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). + +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 +""" +import argparse +import array +import json +import sys +from pathlib import Path + +import numpy as np + +from nnue_feature_spec import active_feature_indices, chess + + +def iter_positions(path): + with open(path) as f: + for line in f: + game = json.loads(line) + for pos in game["positions"]: + yield pos + + +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, + help="target = lambda*p_search + (1-lambda)*r_outcome " + "(Phase A default 1.0, pure p_search -- see NNUE.md)") + ap.add_argument("--limit", type=int, default=None, + help="stop after this many usable positions (smoke testing)") + args = ap.parse_args() + + outdir = Path(args.outdir) + outdir.mkdir(parents=True, exist_ok=True) + + # 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. + white_idx = array.array("i") + black_idx = array.array("i") + white_off = [0] + black_off = [0] + stm = [] # 1 = white to move, 0 = black + target = [] + + 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 + + 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)) + + 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) + + n_used += 1 + if n_used % 200000 == 0: + print(f" ...{n_used} positions processed", file=sys.stderr) + + print(f"Read {n_total} rows ({args.infile})") + print(f" {n_skipped} skipped (exact / no p_search label)") + print(f" {n_used} usable positions") + + np.save(outdir / "white_idx.npy", np.frombuffer(white_idx, dtype=np.int32)) + np.save(outdir / "white_off.npy", np.array(white_off, dtype=np.int64)) + np.save(outdir / "black_idx.npy", np.frombuffer(black_idx, dtype=np.int32)) + 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)) + + nnz_white = len(white_idx) + nnz_black = len(black_idx) + print(f"Wrote tensors to {outdir}: {n_used} positions, " + f"{nnz_white} white / {nnz_black} black nonzero feature entries " + f"({nnz_white / max(n_used, 1):.1f} avg features/position)") + + +if __name__ == "__main__": + main() diff --git a/annotate_pgns/check_see_against_engine.py b/annotate_pgns/check_see_against_engine.py new file mode 100644 index 0000000..4f6aab3 --- /dev/null +++ b/annotate_pgns/check_see_against_engine.py @@ -0,0 +1,160 @@ +#!/usr/bin/env python3 +"""Cross-check eval_tune/tune_eval_dna.py's Python static_exchange_eval() +(a classic swap-list SEE that admittedly ignores pins/x-rays/ +discoveries -- see its docstring) against the engine's own real SEE(), +on real positions from an annotate.py run. + +This has never been done before: static_exchange_eval() has been used +by the eval-tuning pipeline's quiet-position filter since that +existed, but nothing has ever actually verified it agrees with the +engine it's trying to approximate. Worth doing once, as reusable +tooling, since this pipeline (annotate -> quiet-filter -> train) is +going to be re-run many times as the project continues. + +Mechanism: the engine already has a bulk oracle for exactly this, +`seescores <filename>` (command.c) -- takes a file of "setboard <fen>" +lines (the same format shard_NN.script already uses) and prints +"SEESCORES <fen> | <san> | <see-value>" for every legal capture in +every position in the file, no search involved. Sample N FENs from an +existing positions.jsonl, write them to a scratch "setboard"-lines +file, run `seescores` on it, and diff every (fen, move) pair's engine +SEE value against the Python one. + +Usage: + python3 check_see_against_engine.py --positions runs/sd10_twic_2M/positions.jsonl \ + --sample 2000 +""" +import argparse +import json +import os +import random +import re +import subprocess +import sys +import tempfile +from collections import Counter +from pathlib import Path + +_EVAL_TUNE_DIR = Path(__file__).resolve().parent.parent / "src" / "eval_tune" +sys.path.insert(0, str(_EVAL_TUNE_DIR)) +import chess + +# See quiet_filter.py's comment: dna_diff (imported by tune_eval_dna) +# reads "../eval.c" relative to CWD at import time, not to its own file. +_cwd = os.getcwd() +os.chdir(_EVAL_TUNE_DIR) +try: + from tune_eval_dna import static_exchange_eval +finally: + os.chdir(_cwd) + +SEESCORES_RE = re.compile(r"^SEESCORES (\S+(?: \S+)*?) \| (\S+) \| (-?\d+)$") + + +def sample_fens(positions_path, n, seed): + fens = [] + with open(positions_path) as f: + for line in f: + rec = json.loads(line) + if rec.get("exact"): + continue + fens.append(rec["fen"]) + random.Random(seed).shuffle(fens) + return fens[:n] + + +def run_seescores(engine_path, fens, scratch_dir): + script_path = Path(scratch_dir) / "see_check.script" + out_path = Path(scratch_dir) / "see_check.out" + with open(script_path, "w") as f: + for fen in fens: + f.write(f"setboard {fen}\n") + + cmd = [str(engine_path), "--batch", "--command", f"seescores {script_path}"] + with open(out_path, "w") as out: + subprocess.run(cmd, stdout=out, stderr=subprocess.STDOUT, check=True) + return out_path + + +def main(): + ap = argparse.ArgumentParser(description=__doc__) + ap.add_argument("--positions", required=True, + help="positions.jsonl to sample FENs from") + ap.add_argument("--sample", type=int, default=2000) + ap.add_argument("--seed", type=int, default=0) + ap.add_argument("--engine", default="../src/typhoon") + args = ap.parse_args() + + engine_path = Path(args.engine).resolve() + if not engine_path.exists(): + sys.exit(f"engine not found: {engine_path}") + + print(f"Sampling {args.sample} non-exact positions from {args.positions} ...") + fens = sample_fens(args.positions, args.sample, args.seed) + print(f" got {len(fens)} FENs") + + with tempfile.TemporaryDirectory() as scratch: + print("Running engine seescores ...") + out_path = run_seescores(engine_path, fens, scratch) + + n_compared = 0 + n_exact_match = 0 + n_sign_match = 0 + n_sign_mismatch = 0 + worst = [] # (abs diff, fen, san, engine_val, python_val) + diff_histogram = Counter() + + board_cache = {} + with open(out_path) as f: + for line in f: + m = SEESCORES_RE.match(line.rstrip("\n")) + if not m: + continue + fen, san, engine_val = m.groups() + engine_val = int(engine_val) + + board = board_cache.get(fen) + if board is None: + board = chess.Board(fen) + board_cache[fen] = board + try: + move = board.parse_san(san) + except ValueError: + continue + python_val = static_exchange_eval(board, move) + + n_compared += 1 + diff = engine_val - python_val + if diff == 0: + n_exact_match += 1 + bucket = "0" if diff == 0 else ( + "<=50" if abs(diff) <= 50 else + "<=200" if abs(diff) <= 200 else ">200") + diff_histogram[bucket] += 1 + + engine_sign = (engine_val > 0) - (engine_val < 0) + python_sign = (python_val > 0) - (python_val < 0) + if engine_sign == python_sign: + n_sign_match += 1 + else: + n_sign_mismatch += 1 + + worst.append((abs(diff), fen, san, engine_val, python_val)) + + worst.sort(reverse=True) + + print(f"\nCompared {n_compared} (fen, capture) pairs") + print(f" exact match: {n_exact_match} ({n_exact_match/max(n_compared,1):.1%})") + print(f" sign match: {n_sign_match} ({n_sign_match/max(n_compared,1):.1%})") + print(f" sign MISmatch:{n_sign_mismatch} ({n_sign_mismatch/max(n_compared,1):.1%})") + print(f" |diff| histogram: {dict(diff_histogram)}") + + if worst: + print(f"\nWorst {min(10, len(worst))} disagreements " + f"(|engine - python|, fen, san, engine_see, python_see):") + for d, fen, san, ev, pv in worst[:10]: + print(f" {d:5d} {fen} {san} engine={ev} python={pv}") + + +if __name__ == "__main__": + main() diff --git a/annotate_pgns/cross_check_features.py b/annotate_pgns/cross_check_features.py new file mode 100644 index 0000000..f336cda --- /dev/null +++ b/annotate_pgns/cross_check_features.py @@ -0,0 +1,122 @@ +#!/usr/bin/env python3 +"""Bit-for-bit cross-checker: diffs nnue_feature_spec.py's (Python) +HalfKAv2_hm feature indices against ../src/nnuefeatures.c's (C, +`dump nnuefeatures`) indices, for a set of hand-picked FENs chosen to +exercise every piece type, both king-file halves (a-d / e-h), both +perspectives, and non-symmetric positions (the starting position +alone can't catch a perspective-swap bug, since it's symmetric under +the exact transform between perspectives -- see NNUE.md). + +Must pass before any training data is trusted to be feature-indexed +consistently between the Python trainer and the C inference code. + +Usage: + python3 cross_check_features.py [--engine ../src/typhoon] +""" +import argparse +import re +import subprocess +import sys +from pathlib import Path + +from nnue_feature_spec import active_feature_indices, chess + +NNUE_RE = re.compile(r"^NNUE_FEATURES (white|black) (.*)$") + +# (label, FEN) -- deliberately includes: +# - the symmetric starting position (sanity baseline only, see above) +# - both kings on the queenside half (a-d files) and kingside half (e-h) +# - a mixed case: White king queenside, Black king kingside (and the +# reverse), to catch a perspective/orientation mixup +# - every piece type present at least once, including promoted/extra +# queens and multiple same-type pieces +# - a near-empty king-and-pawns endgame (sparse feature set, edge of +# the active-feature-count range) +TEST_POSITIONS = [ + ("startpos", + "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1"), + ("both_kings_queenside", + "2kr4/8/8/8/8/8/8/2KR4 w - - 0 1"), + ("both_kings_kingside", + "4rk2/8/8/8/8/8/8/4RK2 w - - 0 1"), + ("white_queenside_black_kingside", + "4rk2/8/8/8/8/8/8/2KR4 w - - 0 1"), + ("white_kingside_black_queenside", + "2kr4/8/8/8/8/8/8/4RK2 w - - 0 1"), + ("midgame_mixed_material", + "r1bqk2r/pp1nbppp/2p1pn2/3p4/2PP4/2N1PN2/PP2BPPP/R1BQK2R w KQkq - 0 8"), + ("sparse_king_pawn_endgame", + "8/5k2/8/3K4/8/8/5P2/8 w - - 0 1"), + ("extra_queens", + "3qk3/3q4/8/8/8/8/3Q4/3QK3 w - - 0 1"), + ("black_to_move_midgame", + "r2q1rk1/ppp2ppp/2n1bn2/2b1p3/4P3/2NPB3/PPP2PPP/R2Q1RK1 b - - 0 10"), +] + + +def run_engine_dump(engine, fen): + cmd = [ + str(engine), "--cpus", "1", "--hash", "8m", "--logfile", "-", + "--batch", "--command", + f"force; setboard {fen}; dump nnuefeatures", + ] + out = subprocess.run(cmd, capture_output=True, text=True, timeout=30).stdout + result = {} + for line in out.splitlines(): + m = NNUE_RE.match(line.strip()) + if m: + side, rest = m.groups() + result[side] = sorted(int(x) for x in rest.split()) if rest else [] + return result + + +def main(): + ap = argparse.ArgumentParser(description=__doc__) + ap.add_argument("--engine", default="../src/typhoon") + args = ap.parse_args() + + engine = Path(args.engine).resolve() + if not engine.exists(): + sys.exit(f"engine not found: {engine} (build it first -- see CLAUDE.md)") + + n_ok = 0 + n_fail = 0 + for label, fen in TEST_POSITIONS: + board = chess.Board(fen) + py = { + "white": active_feature_indices(board, chess.WHITE), + "black": active_feature_indices(board, chess.BLACK), + } + c_out = run_engine_dump(engine, fen) + + ok = True + for side in ("white", "black"): + if side not in c_out: + print(f"[{label}] FAIL: engine produced no '{side}' line at all") + ok = False + continue + if py[side] != c_out[side]: + ok = False + py_set, c_set = set(py[side]), set(c_out[side]) + print(f"[{label}] FAIL ({side}): " + f"python={len(py[side])} idx, C={len(c_out[side])} idx") + only_py = sorted(py_set - c_set) + only_c = sorted(c_set - py_set) + if only_py: + print(f" only in python: {only_py}") + if only_c: + print(f" only in C: {only_c}") + + if ok: + n_ok += 1 + print(f"[{label}] OK ({len(py['white'])} features/perspective)") + else: + n_fail += 1 + print(f" FEN: {fen}") + + print(f"\n{n_ok} passed, {n_fail} failed, out of {len(TEST_POSITIONS)} positions") + sys.exit(1 if n_fail else 0) + + +if __name__ == "__main__": + main() diff --git a/annotate_pgns/extract_positions.py b/annotate_pgns/extract_positions.py new file mode 100644 index 0000000..539419a --- /dev/null +++ b/annotate_pgns/extract_positions.py @@ -0,0 +1,148 @@ +#!/usr/bin/env python3 +"""Extract (FEN, score, depth, PV, exact-flag) tuples directly from a +completed annotate.py run's raw shard logs -- shard_NN.script (ordered +FENs) zipped positionally against shard_NN.out (ordered tellothers +results), bypassing PGN reconstruction entirely. + +Why this exists: annotate.py's TELLOTHERS_RE only matched plain-float +scores, silently dropping +MATE<N>/-MATE<N> lines from the parsed +results list while leaving them in the position index -- a positional +zip() between the two then misaligned every later position in that +shard. This script fixes the parse (captures MATE and <EGTB>/<REC> +lines as real, index-aligned results, tagged exact=True) and reads +straight from the logs already on disk, so the 12-hour run does not +need to be repeated. + +Exact positions (mate-distance scores, or any <EGTB>/<REC> PV -- +EGTB/REC lines are game-theoretic/exact regardless of what their score +field looks like, e.g. the engine prints +0.00 or +-MATE100 for EGTB +hits, never a real centipawn value) are written out but flagged +exact=True rather than silently dropped, per NNUE.md's existing +decision to exclude exact-value positions from the sigmoid-fit WDL +loss at training-set-assembly time -- not at extraction time, so the +raw corpus keeps standalone analysis value. + +Usage: + python3 extract_positions.py --rundir runs/sd10_twic_2M \ + --out runs/sd10_twic_2M/positions.jsonl +""" +import argparse +import json +import re +import sys +from pathlib import Path + +TELLOTHERS_RE = re.compile( + 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+)$") + + +def parse_score(token): + """Returns (numeric_score_or_None, exact, reason).""" + if FLOAT_RE.match(token): + return float(token), False, None + m = MATE_RE.match(token) + if m: + sign, n = m.groups() + return (1 if sign == "+" else -1) * float(n), True, f"mate{n}" + return None, True, f"unrecognized:{token}" + + +def extract_shard(script_path, out_path): + fens = [] + with open(script_path, encoding="utf-8") as f: + for line in f: + if line.startswith("setboard "): + fens.append(line[len("setboard "):].strip()) + + 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_tok, pv = m.groups() + pv = pv.strip() + score, exact, reason = parse_score(score_tok) + # <EGTB>/<REC>/<=>/<#> are terminal sentinel moves appended + # to the PV (UpdatePV(), search.c) -- the PV may carry real + # moves before the sentinel (e.g. + # "Kf6 Bc3+ Kxg6 Kf8 Kxh5 <EGTB>"), and in that case the + # score field often looks like an ordinary centipawn number, + # but the position is still exact/game-theoretic, not + # sigmoid-fit training signal -- check the PV's last token, + # not the whole PV string. <=> (DRAWMOVE) specifically is a + # claimed repetition/50-move draw -- a fact about the game's + # move history, not the position itself (the same position + # reached a different way isn't a draw at all), so it's + # excluded for the same reason <EGTB>/<REC> are: not + # position-dependent training signal. + last_move = pv.rsplit(" ", 1)[-1] if pv else "" + if last_move in ("<EGTB>", "<REC>", "<=>", "<#>"): + exact = True + reason = {"<=>": "draw_recognizer", + "<#>": "mate_recognizer"}.get( + last_move, last_move.strip("<>").lower()) + elif pv == "only": + # root.c: exactly one legal move at the root skips the + # search entirely (iRootScore/uRootDepth hardcoded to + # 0), so depth/score here are placeholders, not a real + # evaluation -- not indeterminate, definitely not + # trainable. + exact = True + reason = "unsearched_only_move" + results.append((int(depth), score, score_tok, pv, exact, reason)) + + if len(fens) != len(results): + print(f" MISALIGNED: {script_path.name} has {len(fens)} positions " + f"but {out_path.name} produced {len(results)} tellothers lines " + f"-- skipping this shard, needs manual inspection", + file=sys.stderr) + return [] + + return list(zip(fens, results)) + + +def main(): + ap = argparse.ArgumentParser(description=__doc__) + ap.add_argument("--rundir", required=True) + ap.add_argument("--out", required=True) + ap.add_argument("--shards", type=int, default=16) + args = ap.parse_args() + + rundir = Path(args.rundir) + total = 0 + total_exact = 0 + with open(args.out, "w") as out: + for i in range(args.shards): + script_path = rundir / f"shard_{i:02d}.script" + out_path = rundir / f"shard_{i:02d}.out" + if not script_path.exists() or not out_path.exists(): + continue + rows = extract_shard(script_path, out_path) + for fen, (depth, score, score_tok, pv, exact, reason) in rows: + rec = { + "fen": fen, + "depth": depth, + "score": score, + "score_raw": score_tok, + "pv": pv, + "exact": exact, + } + if reason: + rec["exact_reason"] = reason + out.write(json.dumps(rec) + "\n") + total += 1 + if exact: + total_exact += 1 + print(f" shard {i:02d}: {len(rows)} positions " + f"({sum(1 for _, r in rows if r[4])} exact)") + + print(f"Wrote {args.out}: {total} positions total, " + f"{total_exact} exact (mate/EGTB/REC), " + f"{total - total_exact} usable for sigmoid-fit training") + + +if __name__ == "__main__": + main() diff --git a/annotate_pgns/fit_k_and_label.py b/annotate_pgns/fit_k_and_label.py new file mode 100644 index 0000000..9fd09b0 --- /dev/null +++ b/annotate_pgns/fit_k_and_label.py @@ -0,0 +1,146 @@ +#!/usr/bin/env python3 +"""Fit the WDL-sigmoid scale K on the TRAIN split only, then label +every non-exact position in both train and holdout with: + - p_search: sigmoid(score / K) -- the search-score-derived win + probability estimate. + - r_outcome: that position's own game's actual final result, + converted to side-to-move-relative (matching the score's own + convention: 1.0 = the side to move at this FEN eventually won + their game, 0.0 = eventually lost, 0.5 = drawn). + +Does NOT blend p_search/r_outcome into a single training target -- +that mixing weight (lambda) is left as a free parameter to sweep +later; storing both components separately means that sweep never +needs to re-derive anything from here. + +K is fit by ternary search minimizing MSE(sigmoid(score/K), r_outcome) +over every non-exact TRAIN position (not holdout -- fitting K on +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. + +Usage: + python3 fit_k_and_label.py --train runs/sd10_twic_2M/games.train.jsonl \ + --holdout runs/sd10_twic_2M/games.holdout.jsonl \ + --train-out runs/sd10_twic_2M/games.train.labeled.jsonl \ + --holdout-out runs/sd10_twic_2M/games.holdout.labeled.jsonl +""" +import argparse +import json +import math + + +def result_to_white_score(result): + """1.0/0.5/0.0 from White's perspective, or None if unknown ('*').""" + 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 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 fit_k(pairs, 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): + hi = m2 + else: + lo = m1 + return (lo + hi) / 2 + + +def label_file(in_path, out_path, k): + 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 + return n_games, n_positions, n_exact_skipped, n_unknown_result + + +def main(): + ap = argparse.ArgumentParser(description=__doc__) + ap.add_argument("--train", required=True) + ap.add_argument("--holdout", required=True) + ap.add_argument("--train-out", required=True) + ap.add_argument("--holdout-out", required=True) + 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)") + + 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(f"\nLabeling {args.train} -> {args.train_out} ...") + n_g, n_p, n_exact, n_unk = label_file(args.train, args.train_out, k) + 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) + print(f" {n_g} games, {n_p} positions, {n_exact} exact (unlabeled), " + f"{n_unk} unknown-result games (unlabeled)") + + print(f"\nFitted K = {k:.4f} -- record this; applying a different K " + f"later (e.g. after labeling more data) is not the same model.") + + +if __name__ == "__main__": + main() diff --git a/annotate_pgns/nnue_feature_spec.py b/annotate_pgns/nnue_feature_spec.py new file mode 100644 index 0000000..b121c82 --- /dev/null +++ b/annotate_pgns/nnue_feature_spec.py @@ -0,0 +1,93 @@ +#!/usr/bin/env python3 +"""Python reference implementation of the HalfKAv2_hm feature index +formula -- see ../src/docs/NNUE.md's "Canonical HalfKAv2_hm +feature-indexing spec" section, sourced verbatim from Stockfish's +src/nnue/features/half_ka_v2_hm.{h,cpp} (git tag sf_16). + +This module's only job is to be an independent, from-scratch +implementation of the same math as ../src/nnuefeatures.c +(`dump nnuefeatures`), using python-chess's own (already +rank*8+file, 0-63, a1=0) square numbering directly -- no 0x88 +conversion needed on this side, that's purely a typhoon-C-side +concern. + +See cross_check_features.py for the actual Python-vs-C diff driver. +""" +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "src" / "eval_tune")) +import chess # noqa: E402 + +SQUARE_NB = 64 + +PS_W_PAWN, PS_B_PAWN = 0 * SQUARE_NB, 1 * SQUARE_NB +PS_W_KNIGHT, PS_B_KNIGHT = 2 * SQUARE_NB, 3 * SQUARE_NB +PS_W_BISHOP, PS_B_BISHOP = 4 * SQUARE_NB, 5 * SQUARE_NB +PS_W_ROOK, PS_B_ROOK = 6 * SQUARE_NB, 7 * SQUARE_NB +PS_W_QUEEN, PS_B_QUEEN = 8 * SQUARE_NB, 9 * SQUARE_NB +PS_KING = 10 * SQUARE_NB +PS_NB = 11 * SQUARE_NB # 704 + +DIMENSIONS = (SQUARE_NB * PS_NB) // 2 # 22528 + +_OWN_PLANE = { + chess.PAWN: PS_W_PAWN, + chess.KNIGHT: PS_W_KNIGHT, + chess.BISHOP: PS_W_BISHOP, + chess.ROOK: PS_W_ROOK, + chess.QUEEN: PS_W_QUEEN, +} +_ENEMY_PLANE = { + chess.PAWN: PS_B_PAWN, + chess.KNIGHT: PS_B_KNIGHT, + chess.BISHOP: PS_B_BISHOP, + chess.ROOK: PS_B_ROOK, + chess.QUEEN: PS_B_QUEEN, +} + + +def king_bucket(perspective, sq): + """sq, perspective: python-chess 0-63 square / chess.WHITE|BLACK.""" + file = chess.square_file(sq) + rank0 = chess.square_rank(sq) # 0..7 + file_quartet = file if file < 4 else (7 - file) + rank_group = (7 - rank0) if perspective == chess.WHITE else rank0 + return rank_group * 4 + file_quartet + + +def orient_mask(perspective, ksq): + file = chess.square_file(ksq) + if perspective == chess.WHITE: + return 7 if file < 4 else 0 # SQ_H1 : SQ_A1 + return 63 if file < 4 else 56 # SQ_H8 : SQ_A8 + + +def piece_plane(perspective, piece_color, piece_type): + if piece_type == chess.KING: + return PS_KING + table = _OWN_PLANE if piece_color == perspective else _ENEMY_PLANE + return table[piece_type] + + +def active_feature_indices(board, perspective): + """board: chess.Board. perspective: chess.WHITE or chess.BLACK. + Returns a sorted list of active feature indices (len <= 32).""" + ksq = board.king(perspective) + mask = orient_mask(perspective, ksq) + bucket_offset = king_bucket(perspective, ksq) * PS_NB + + indices = [] + for sq, piece in board.piece_map().items(): + idx = (sq ^ mask) + piece_plane(perspective, piece.color, piece.piece_type) + bucket_offset + assert 0 <= idx < DIMENSIONS, idx + indices.append(idx) + indices.sort() + return indices + + +if __name__ == "__main__": + # Quick manual smoke test against the starting position. + board = chess.Board() + print("white:", active_feature_indices(board, chess.WHITE)) + print("black:", active_feature_indices(board, chess.BLACK)) diff --git a/annotate_pgns/nnue_model.py b/annotate_pgns/nnue_model.py new file mode 100644 index 0000000..acdc8fd --- /dev/null +++ b/annotate_pgns/nnue_model.py @@ -0,0 +1,69 @@ +#!/usr/bin/env python3 +"""The Phase A prototype net: a small, known HalfKAv2_hm architecture +per NNUE.md's architecture decisions -- 256x2-32-32-1-ish, a single +shared feature-transformer weight matrix applied independently to +each perspective's active-feature set (that sharing, not two +separate tables, is what makes the two 256-wide "accumulator" halves +comparable before concatenation), own-perspective-half-first +concatenation keyed by side-to-move so the net's single scalar +output is side-to-move-relative by construction (per the "Output +convention" architecture decision), and a sigmoid-WDL loss against +the already-fitted K=1.3123 (per "WDL-sigmoid target conversion +built and run", fit_k_and_label.py) rather than raw centipawn MSE. + +Deliberately NOT doing yet (explicitly deferred, see NNUE.md): clipped +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. +""" +import torch +import torch.nn as nn + +from nnue_feature_spec import DIMENSIONS + +L1 = 256 # feature-transformer output width (per perspective) +L2 = 32 +L3 = 32 + +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): + super().__init__() + # Single shared feature transformer: one [dimensions, l1] + # weight matrix, applied once per perspective to that + # perspective's own active-feature set. mode="sum" matches + # Stockfish's own feature-transformer semantics (accumulate + # one weight row per active feature). + self.ft = nn.EmbeddingBag(dimensions, l1, mode="sum") + self.ft_bias = nn.Parameter(torch.zeros(l1)) + + 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 + + white_acc = torch.relu(white_acc) + black_acc = torch.relu(black_acc) + + stm = stm.view(-1, 1).float() + # 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) + + x = torch.relu(self.fc1(x)) + x = torch.relu(self.fc2(x)) + x = self.fc3(x) + return x.view(-1) + + 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 new file mode 100644 index 0000000..6112ab3 --- /dev/null +++ b/annotate_pgns/nnue_train.py @@ -0,0 +1,208 @@ +#!/usr/bin/env python3 +"""Phase A training loop: local CPU, PyTorch, over the cached CSR +tensors 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 +real, larger Phase B run moves to a rented GPU against more data. + +CSR batching: each position is a variable-length "bag" of feature +indices (3-32 of them). To build a batch for nn.EmbeddingBag without +padding, gather the relevant CSR row-slices for a batch of position +indices, concatenate them, and recompute offsets -- see +_gather_batch() below. + +Usage: + python3 nnue_train.py \ + --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 +""" +import argparse +import json +import time +from pathlib import Path + +import numpy as np +import torch +import torch.nn as nn + +from nnue_model import NnueNet, DEFAULT_K + + +class CsrTensors: + """mmap-backed CSR feature tensors for one split (train or + holdout), as written by build_training_tensors.py.""" + + def __init__(self, tensor_dir): + tensor_dir = Path(tensor_dir) + self.white_idx = np.load(tensor_dir / "white_idx.npy", mmap_mode="r") + self.white_off = np.load(tensor_dir / "white_off.npy") + self.black_idx = np.load(tensor_dir / "black_idx.npy", mmap_mode="r") + 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") + 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 + + def __len__(self): + return self.n + + +def _gather_batch(idx_arr, off_arr, row_ids): + """Build (flat_idx, batch_offsets) for an nn.EmbeddingBag call + covering exactly the bags named by row_ids (a 1D array of row + indices into off_arr). row_ids need not be sorted or contiguous.""" + pieces = [] + batch_off = np.empty(len(row_ids), dtype=np.int64) + cursor = 0 + for i, r in enumerate(row_ids): + start, end = off_arr[r], off_arr[r + 1] + pieces.append(idx_arr[start:end]) + batch_off[i] = cursor + cursor += end - start + flat = np.concatenate(pieces) if pieces else np.empty(0, dtype=np.int32) + return flat, batch_off + + +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): + rows = order[start:start + batch_size] + rows_sorted = np.sort(rows) # sequential mmap reads, not required but friendlier + w_idx, w_off = _gather_batch(data.white_idx, data.white_off, rows_sorted) + b_idx, b_off = _gather_batch(data.black_idx, data.black_off, rows_sorted) + stm = data.stm[rows_sorted] + target = data.target[rows_sorted] + yield ( + torch.from_numpy(w_idx.astype(np.int64, copy=False)), + torch.from_numpy(w_off), + torch.from_numpy(b_idx.astype(np.int64, copy=False)), + torch.from_numpy(b_off), + torch.from_numpy(stm.astype(np.int64, copy=False)), + torch.from_numpy(target.copy()), + ) + + +def evaluate(model, data, batch_size, k): + model.eval() + total_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( + 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() + total_n += len(target) + model.train() + return total_loss / max(total_n, 1) + + +def main(): + ap = argparse.ArgumentParser(description=__doc__) + ap.add_argument("--train-dir", required=True) + ap.add_argument("--holdout-dir", required=True) + ap.add_argument("--outdir", required=True) + ap.add_argument("--epochs", type=int, default=20) + 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("--eval-every-steps", type=int, default=2000) + 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) + torch.manual_seed(args.seed) + + outdir = Path(args.outdir) + outdir.mkdir(parents=True, exist_ok=True) + log_path = outdir / "train_log.jsonl" + + print("Loading tensors ...") + train_data = CsrTensors(args.train_dir) + holdout_data = CsrTensors(args.holdout_dir) + print(f" train: {len(train_data)} positions, holdout: {len(holdout_data)} positions") + + model = NnueNet() + optimizer = torch.optim.Adam(model.parameters(), lr=args.lr) + start_epoch = 0 + global_step = 0 + + if args.resume: + ckpt = torch.load(args.resume, map_location="cpu", weights_only=True) + model.load_state_dict(ckpt["model"]) + optimizer.load_state_dict(ckpt["optimizer"]) + start_epoch = ckpt["epoch"] + 1 + global_step = ckpt.get("global_step", 0) + print(f"Resumed from {args.resume} at epoch {start_epoch}") + + rng = np.random.default_rng(args.seed) + t_start = time.time() + + with open(log_path, "a") as log_f: + for epoch in range(start_epoch, args.epochs): + epoch_loss = 0.0 + epoch_n = 0 + t_epoch = time.time() + + for w_idx, w_off, b_idx, b_off, stm, target in iterate_batches( + train_data, args.batch_size, shuffle=True, rng=rng): + 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) + loss.backward() + optimizer.step() + + bs = len(target) + epoch_loss += 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) + elapsed = time.time() - t_start + rec = { + "step": global_step, "epoch": epoch, + "train_mse_running": epoch_loss / max(epoch_n, 1), + "holdout_mse": holdout_mse, + "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"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) + 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)") + + ckpt_path = outdir / f"ckpt_epoch{epoch:03d}.pt" + torch.save({ + "model": model.state_dict(), + "optimizer": optimizer.state_dict(), + "epoch": epoch, + "global_step": global_step, + "holdout_mse": holdout_mse, + "args": vars(args), + }, ckpt_path) + print(f" wrote {ckpt_path}") + + print(f"Training done in {time.time() - t_start:.0f}s") + + +if __name__ == "__main__": + main() diff --git a/annotate_pgns/quiet_common.py b/annotate_pgns/quiet_common.py new file mode 100644 index 0000000..77544ef --- /dev/null +++ b/annotate_pgns/quiet_common.py @@ -0,0 +1,68 @@ +#!/usr/bin/env python3 +"""Shared quiet-position predicate, per NNUE.md: exclude positions +where the side to move is in check, or where a given move is an +SEE-positive (>=50cp) capture. + +Used two ways by this pipeline, with a deliberately different choice +of *which* move gets SEE-checked in each case: + +- Pre-search filtering (annotate.py): checked against the move actually + played in the source human game, since that's the only move known + before the engine has searched anything -- the whole point is to + skip the sd-N search entirely for positions that would be discarded + anyway. +- Post-hoc filtering (quiet_filter.py, quiet_filter_games.py): checked + against the first move of the already-recorded sd-N search PV (the + engine's own considered-best reply), per NNUE.md's original filter + design. This remains the definition used for data already annotated + before pre-filtering existed, and is NOT the same training + distribution as the game-move variant above -- do not mix rows + produced under the two definitions into one training run without + accounting for that. + +Both call the same `position_is_quiet` predicate once a concrete move +is in hand; only how that move is obtained differs. + +Reuses eval_tune/tune_eval_dna.py's existing static_exchange_eval() -- +a classic swap-list SEE (ignores pins/x-rays/discoveries, a +documented, accepted simplification; see check_see_against_engine.py +for a cross-check against the engine's real SEE()) -- rather than +reimplementing it. +""" +import os +import sys +from pathlib import Path + +_EVAL_TUNE_DIR = Path(__file__).resolve().parent.parent / "src" / "eval_tune" +sys.path.insert(0, str(_EVAL_TUNE_DIR)) +import chess # noqa: E402 + +# tune_eval_dna imports dna_diff, which reads "../eval.c" relative to +# the *current working directory* at import time, not relative to its +# own file -- chdir there just for the import, then restore, so this +# module can be imported from any directory. +_cwd = os.getcwd() +os.chdir(_EVAL_TUNE_DIR) +try: + from tune_eval_dna import static_exchange_eval, has_hanging_material # noqa: E402,F401 +finally: + os.chdir(_cwd) + +SEE_THRESHOLD = 50 # matches has_hanging_material's own default + + +def is_see_positive_capture(board, move, threshold=SEE_THRESHOLD): + """board is the position *before* move. Does not mutate board.""" + if not board.is_capture(move): + return False + return static_exchange_eval(board, move) >= threshold + + +def position_is_quiet(board, move, threshold=SEE_THRESHOLD): + """board is the position *before* move; move is whichever move the + caller has decided to SEE-check (played move or PV's first move -- + see module docstring). True means: keep this position in the + training set.""" + if board.is_check(): + return False + return not is_see_positive_capture(board, move, threshold) diff --git a/annotate_pgns/quiet_filter.py b/annotate_pgns/quiet_filter.py new file mode 100644 index 0000000..177452e --- /dev/null +++ b/annotate_pgns/quiet_filter.py @@ -0,0 +1,124 @@ +#!/usr/bin/env python3 +"""Apply the quiet-position filter (NNUE.md: exclude positions where +the side to move is in check, or where the best/played move is an +SEE-positive capture) to an already-extracted positions.jsonl, after +the fact -- this run already paid the full sd-N search cost regardless +of whether a position turns out to be quiet, so filtering now just +trims the training set. A *future* annotate.py run should apply this +filter before writing a position into a shard script at all, so search +time is never spent on positions that would be discarded anyway. + +Reuses eval_tune/tune_eval_dna.py's existing static_exchange_eval() -- +a classic swap-list SEE (ignores pins/x-rays/discoveries, a documented, +accepted simplification) -- rather than reimplementing it. That +function has never been cross-checked against the engine's own real +SEE(); see check_see_against_engine.py for that. + +"Best move" here means the first move of the already-recorded sd-N +PV (positions.jsonl's "pv" field), not the move actually played in the +source human game -- annotate.py doesn't currently record the latter, +and the PV's first move is the engine's own considered-best reply, +which is what NNUE.md's filter is actually trying to exclude (a +position captured mid-exchange shouldn't carry a settled-looking +label). + +Usage: + python3 quiet_filter.py --in runs/sd10_twic_2M/positions.jsonl \ + --out runs/sd10_twic_2M/positions.quiet.jsonl +""" +import argparse +import json +import sys + +from quiet_common import chess, has_hanging_material, is_see_positive_capture, SEE_THRESHOLD + + +def pv_first_move(board, pv): + """None if indeterminate (empty PV, illegal/unparseable SAN -- + shouldn't happen for a non-exact row but don't crash the whole run + over one bad line), else the parsed Move.""" + if not pv: + return None + san = pv.split(" ", 1)[0] + try: + return board.parse_san(san) + except ValueError: + return None + + +def first_move_is_see_positive_capture(board, pv): + """None if indeterminate, else True/False.""" + move = pv_first_move(board, pv) + if move is None: + return None + return is_see_positive_capture(board, move) + + +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("--limit", type=int, default=None, + help="stop after this many input rows (smoke testing)") + args = ap.parse_args() + + n_total = 0 + n_exact_skipped = 0 + n_indeterminate = 0 + n_excluded_check = 0 + n_excluded_capture = 0 + n_hanging_material = 0 # diagnostic only, not an exclusion criterion here + n_quiet = 0 + + with open(args.infile) as fin, open(args.outfile, "w") as fout: + for line in fin: + if args.limit is not None and n_total >= args.limit: + break + n_total += 1 + if n_total % 200000 == 0: + print(f" ...{n_total} rows processed " + f"({n_quiet} kept so far)", file=sys.stderr) + + rec = json.loads(line) + if rec.get("exact"): + n_exact_skipped += 1 + continue + + board = chess.Board(rec["fen"]) + + if board.is_check(): + n_excluded_check += 1 + continue + + is_see_positive = first_move_is_see_positive_capture( + board, rec.get("pv", "")) + if is_see_positive is None: + n_indeterminate += 1 + # Can't evaluate the filter criterion -- err on the + # side of excluding rather than silently keeping a row + # that was never actually checked. + continue + if is_see_positive: + n_excluded_capture += 1 + continue + + if has_hanging_material(board, threshold=SEE_THRESHOLD): + n_hanging_material += 1 # reported, not excluded + + fout.write(json.dumps(rec) + "\n") + n_quiet += 1 + + print(f"Read {n_total} rows ({args.infile})") + print(f" {n_exact_skipped} already exact (mate/EGTB/REC) -- skipped") + print(f" {n_indeterminate} indeterminate (empty/unparseable PV) -- excluded") + print(f" {n_excluded_check} excluded: side to move in check") + print(f" {n_excluded_capture} excluded: PV's first move is an " + f"SEE-positive (>={SEE_THRESHOLD}cp) capture") + print(f" {n_quiet} quiet positions written to {args.outfile}") + print(f" (of those, {n_hanging_material} still have *some* other " + f"SEE-positive capture available on the board -- reported for " + f"visibility, not excluded by this filter)") + + +if __name__ == "__main__": + main() diff --git a/annotate_pgns/quiet_filter_games.py b/annotate_pgns/quiet_filter_games.py new file mode 100644 index 0000000..f4de31e --- /dev/null +++ b/annotate_pgns/quiet_filter_games.py @@ -0,0 +1,102 @@ +#!/usr/bin/env python3 +"""Apply the same post-hoc quiet-position filter as quiet_filter.py, +but to a game-shaped labeled file (build_games.py / fit_k_and_label.py +output: games.train.labeled.jsonl, games.holdout.labeled.jsonl) rather +than the flat positions.jsonl. + +Deliberately does NOT join against positions.quiet.jsonl -- that file +has no row index or game_id, and FENs can repeat across different +games/plies, so matching by FEN content would silently misattribute +rows. Instead this re-applies the identical predicate +(quiet_common.position_is_quiet, checked against each row's own PV's +first move) directly to each position already embedded in the game +record, so the two filtered views (flat and game-shaped) are +guaranteed consistent by construction rather than by a fragile join. + +A game that loses all of its positions to filtering is dropped +entirely (an empty "positions": [] list is not useful downstream). + +Usage: + python3 quiet_filter_games.py \ + --in runs/sd10_twic_2M/games.train.labeled.jsonl \ + --out runs/sd10_twic_2M/games.train.labeled.quiet.jsonl +""" +import argparse +import json +import sys + +from quiet_common import chess, position_is_quiet +from quiet_filter import pv_first_move + + +def filter_game_positions(game_rec): + """Returns a new list of kept position rows (exact rows are always + kept -- the quiet filter only applies to ordinary, non-exact + search scores, same as quiet_filter.py).""" + kept = [] + n_excluded_check = n_excluded_capture = n_indeterminate = 0 + for pos in game_rec["positions"]: + if pos.get("exact"): + kept.append(pos) + continue + board = chess.Board(pos["fen"]) + move = pv_first_move(board, pos.get("pv", "")) + if move is None: + n_indeterminate += 1 + continue + if board.is_check(): + n_excluded_check += 1 + continue + if not position_is_quiet(board, move): + n_excluded_capture += 1 + continue + kept.append(pos) + return kept, n_excluded_check, n_excluded_capture, n_indeterminate + + +def main(): + ap = argparse.ArgumentParser(description=__doc__) + ap.add_argument("--in", dest="infile", required=True) + ap.add_argument("--out", dest="outfile", required=True) + args = ap.parse_args() + + n_games_in = n_games_out = n_games_dropped = 0 + n_pos_in = n_pos_out = 0 + 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) + n_games_in += 1 + n_pos_in += len(game_rec["positions"]) + + kept, c, p, i = filter_game_positions(game_rec) + n_excluded_check += c + n_excluded_capture += p + n_indeterminate += i + + if not kept: + n_games_dropped += 1 + continue + + game_rec["positions"] = kept + fout.write(json.dumps(game_rec) + "\n") + n_games_out += 1 + n_pos_out += len(kept) + + if n_games_in % 2000 == 0: + print(f" ...{n_games_in} games processed " + f"({n_pos_out} positions kept so far)", file=sys.stderr) + + print(f"Read {args.infile}: {n_games_in} games, {n_pos_in} positions") + print(f" {n_excluded_check} positions excluded: side to move in check") + print(f" {n_excluded_capture} positions excluded: PV's first move is " + f"an SEE-positive capture") + print(f" {n_indeterminate} positions excluded: indeterminate " + f"(empty/unparseable PV)") + print(f" {n_games_dropped} games dropped entirely (lost all positions)") + print(f"Wrote {args.outfile}: {n_games_out} games, {n_pos_out} positions") + + +if __name__ == "__main__": + main() diff --git a/annotate_pgns/split_holdout.py b/annotate_pgns/split_holdout.py new file mode 100644 index 0000000..277c1bf --- /dev/null +++ b/annotate_pgns/split_holdout.py @@ -0,0 +1,113 @@ +#!/usr/bin/env python3 +"""Stratified game-level train/holdout split over a games.jsonl +(build_games.py output). + +Splits by GAME, not by individual position -- every position from one +game lands on the same side of the split, avoiding the leakage a flat +position-level shuffle would have (adjacent plies / recurring +middlegame structures from the same game are highly correlated, not +independent samples; see eval_tune/tune_eval_dna.py's own holdout +split, which doesn't do this). + +Stratified on two axes so the holdout's composition mirrors the full +pool's rather than a flat 10% sample risking (by chance) too many +decisive games, too few draws, or skewing toward short/long games: + - result: White win / Black win / Draw + - length: quartile bin of the game's annotated ply count (quartiles + of the actual corpus, not guessed fixed cutoffs) +10% (configurable) is sampled independently within each of the +resulting (result, length-quartile) strata, not 10% of the pool +overall. + +Usage: + python3 split_holdout.py --in runs/sd10_twic_2M/games.jsonl \ + --train-out runs/sd10_twic_2M/games.train.jsonl \ + --holdout-out runs/sd10_twic_2M/games.holdout.jsonl \ + --holdout-frac 0.1 --seed 0 +""" +import argparse +import json +import math +import random +from collections import defaultdict + + +def result_bucket(result): + if result == "1-0": + return "white_win" + if result == "0-1": + return "black_win" + if result == "1/2-1/2": + return "draw" + return "unknown" + + +def quartile_bin(length, cuts): + for i, c in enumerate(cuts): + if length <= c: + return i + return len(cuts) + + +def main(): + ap = argparse.ArgumentParser(description=__doc__) + ap.add_argument("--in", dest="infile", required=True) + ap.add_argument("--train-out", required=True) + ap.add_argument("--holdout-out", required=True) + ap.add_argument("--holdout-frac", type=float, default=0.1) + ap.add_argument("--seed", type=int, default=0) + args = ap.parse_args() + + games = [] + with open(args.infile) as f: + for line in f: + games.append(json.loads(line)) + print(f"Loaded {len(games)} games from {args.infile}") + + lengths = sorted(len(g["positions"]) for g in games) + def pct(p): + idx = min(int(p * len(lengths)), len(lengths) - 1) + return lengths[idx] + cuts = [pct(0.25), pct(0.50), pct(0.75)] + print(f"Length quartile cutpoints (annotated plies): {cuts}") + + strata = defaultdict(list) + for g in games: + key = (result_bucket(g["result"]), + quartile_bin(len(g["positions"]), cuts)) + strata[key].append(g) + + rng = random.Random(args.seed) + train_games = [] + holdout_games = [] + print(f"\n{'stratum':<24} {'total':>7} {'holdout':>8} {'train':>8}") + for key in sorted(strata.keys()): + bucket = strata[key] + rng.shuffle(bucket) + n_holdout = math.ceil(len(bucket) * args.holdout_frac) + holdout_games.extend(bucket[:n_holdout]) + train_games.extend(bucket[n_holdout:]) + label = f"{key[0]}/q{key[1]}" + print(f"{label:<24} {len(bucket):>7} {n_holdout:>8} " + f"{len(bucket) - n_holdout:>8}") + + print(f"\n{'TOTAL':<24} {len(games):>7} {len(holdout_games):>8} " + f"{len(train_games):>8}") + + with open(args.train_out, "w") as f: + for g in train_games: + f.write(json.dumps(g) + "\n") + with open(args.holdout_out, "w") as f: + for g in holdout_games: + f.write(json.dumps(g) + "\n") + + n_train_pos = sum(len(g["positions"]) for g in train_games) + n_holdout_pos = sum(len(g["positions"]) for g in holdout_games) + print(f"\nWrote {args.train_out}: {len(train_games)} games, " + f"{n_train_pos} positions") + print(f"Wrote {args.holdout_out}: {len(holdout_games)} games, " + f"{n_holdout_pos} positions") + + +if __name__ == "__main__": + main() |
