diff options
| author | Scott Gasch <[email protected]> | 2026-10-20 18:37:20 -0700 |
|---|---|---|
| committer | Scott Gasch <[email protected]> | 2026-10-20 18:37:20 -0700 |
| commit | 9cf14e14e6999420f234cda951894337e034f57c (patch) | |
| tree | d4845d0cf127ef4226a5cd2e60472ee750213614 /annotate_pgns | |
| parent | c8c6bbc2c1a103e5b07b079a0f8bdf66c7724d80 (diff) | |
NNUE: re-fit raw-to-score calibration, NNU4 weight format, super-lazy default-on, lazy probe measured net-negative
Fits an affine (a + b*raw) correction into every exported .nnue file
(export_nnue_weights.py's CalibrateScoreMapping) so the engine's
consumed centipawn output tracks Stockfish's real score scale rather
than the net's nominal raw WDL-sigmoid output -- training variance
alone shifts this mapping across retrains even with an identical
recipe, so it's re-fit and baked in per export rather than assumed
constant. Weight format bumped to "NNU4" to carry the two new floats;
older NNU1/2/3 files are no longer readable.
Super-lazy (material-only) pre-filter promoted to default-on
(g_fUseNnueSuperLazy = TRUE) with a recalibrated per-material-bucket
margin table (measured against the recalibrated net, 16-shard
cross_sectional_200k.ep_ sample) -- real, measured nps win across all
three curated suites. The separate lazy-probe gate (accumulator-based
early exit) is measured net-negative on real search traffic at this
net's probe-vs-forward latency ratio and stays off by default
(g_fUseNnueLazyProbe = FALSE); code kept live since margin tuning
could still flip this, per docs/NNUE.md.
Also: direct per-call latency instrumentation for NnueProbeScore/
NnueForward (root.c, EVAL_TIME build) to make the lazy-probe
breakeven math measurable directly instead of inferred; QSearch
futility margins given real-data-derived NNUE_FULL/NNUE_LAZY
starting points.
Co-Authored-By: Claude Sonnet 5 <[email protected]>
Diffstat (limited to 'annotate_pgns')
| -rw-r--r-- | annotate_pgns/build_training_tensors.py | 64 | ||||
| -rw-r--r-- | annotate_pgns/export_nnue_weights.py | 95 | ||||
| -rw-r--r-- | annotate_pgns/nnue_model.py | 84 | ||||
| -rw-r--r-- | annotate_pgns/nnue_train.py | 139 | ||||
| -rw-r--r-- | annotate_pgns/run_nnue_pipeline.py | 57 |
5 files changed, 328 insertions, 111 deletions
diff --git a/annotate_pgns/build_training_tensors.py b/annotate_pgns/build_training_tensors.py index 0f69d6c..ff9e772 100644 --- a/annotate_pgns/build_training_tensors.py +++ b/annotate_pgns/build_training_tensors.py @@ -51,16 +51,14 @@ 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, -} +# Clip bound for the auxiliary score-probe target (nnue_model.py's +# score_probe, added 2026-10-20 -- see that module's docstring). A +# handful of blowout-but-not-forced-mate scores (see the user's own +# +13.67-before-mate example) would otherwise dominate the probe's +# MSE; exact/mate rows are excluded entirely (see SCORE_VALID below), +# not just clipped, since a mate score isn't "the Stockfish score" in +# the pawn sense this probe targets. +SCORE_CLIP_PAWNS = 20.0 def position_target(pos, lam): @@ -78,14 +76,18 @@ def position_target(pos, lam): 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 score_probe_target(pos): + """Returns (score, valid) for the auxiliary score-probe target -- + Stockfish's own raw per-position score (pos["score"], already + mover-relative pawns per extract_selfplay_positions.py), clipped + to +-SCORE_CLIP_PAWNS, or (0.0, False) for exact/mate rows (no + "score" field, or not a real Stockfish-judged centipawn-ish value + -- same exclusion reasoning as p_search's, see NNUE.md).""" + if pos.get("exact") or "score" not in pos: + return 0.0, False + s = pos["score"] + s = max(-SCORE_CLIP_PAWNS, min(SCORE_CLIP_PAWNS, s)) + return s, True def iter_positions(path): @@ -124,7 +126,8 @@ def process_chunk(args): black_lens = array.array("i") stm = [] target = [] - material = [] + score_probe_val = [] + score_probe_valid = [] n_total = len(positions) n_skipped = 0 @@ -145,10 +148,12 @@ def process_chunk(args): stm.append(1 if board.turn == chess.WHITE else 0) target.append(t) - material.append(material_balance(board)) + s, valid = score_probe_target(pos) + score_probe_val.append(s) + score_probe_valid.append(valid) return (white_idx, white_lens, black_idx, black_lens, stm, target, - material, n_total, n_skipped) + score_probe_val, score_probe_valid, n_total, n_skipped) def main(): @@ -199,7 +204,8 @@ def main(): black_off = [0] stm = [] # 1 = white to move, 0 = black target = [] - material = [] # auxiliary material-probe target, mover-relative pawns + score_probe_val = [] # auxiliary score-probe target, clipped pawns + score_probe_valid = [] # False for exact/mate rows -- excluded from that loss n_total = 0 n_used = 0 @@ -226,7 +232,9 @@ def main(): stm.append(1 if board.turn == chess.WHITE else 0) target.append(t) - material.append(material_balance(board)) + s, valid = score_probe_target(pos) + score_probe_val.append(s) + score_probe_valid.append(valid) n_used += 1 if n_used % 200000 == 0: @@ -244,7 +252,7 @@ def main(): 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_score_val, chunk_score_valid, chunk_total, chunk_skipped) in pool.imap(process_chunk, chunks): n_total += chunk_total n_skipped += chunk_skipped @@ -258,7 +266,8 @@ def main(): stm.extend(chunk_stm) target.extend(chunk_target) - material.extend(chunk_material) + score_probe_val.extend(chunk_score_val) + score_probe_valid.extend(chunk_score_valid) n_used += len(chunk_stm) print(f" ...{n_used} positions processed " @@ -274,7 +283,10 @@ 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)) + np.save(outdir / "score_probe_val.npy", + np.array(score_probe_val, dtype=np.float32)) + np.save(outdir / "score_probe_valid.npy", + np.array(score_probe_valid, dtype=np.uint8)) nnz_white = len(white_idx) nnz_black = len(black_idx) diff --git a/annotate_pgns/export_nnue_weights.py b/annotate_pgns/export_nnue_weights.py index 4c8cb99..d26ab93 100644 --- a/annotate_pgns/export_nnue_weights.py +++ b/annotate_pgns/export_nnue_weights.py @@ -4,8 +4,8 @@ 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" +File format (all little-endian), version "NNU4": + magic: 4 bytes, ASCII "NNU4" 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 @@ -24,6 +24,15 @@ File format (all little-endian), version "NNU3": CalibrateActivationScales) with headroom, NOT from a theoretical max -- see docs/NNUE.md's "Quantization plan" / the int8 float-vs-quantized correctness check. + calib: 2x float32 (CALIB_A, CALIB_B) -- affine correction so + the engine's consumed centipawn output tracks + Stockfish's own real score, not the net's nominal raw + WDL-sigmoid scale (which drifts run-to-run even with + an identical K/lambda/loss-exponent recipe -- see + CalibrateScoreMapping and the 2026-10-20 "keeping this + stable across retrains" conversation). NnueForward + applies `(CALIB_A + CALIB_B*fOut) * 100.0f` instead of + a bare `fOut * 100.0f`. 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 @@ -56,7 +65,8 @@ 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 + --tensor-dir runs/sd10_twic_2M/tensors_train \ + --holdout-tensor-dir runs/sd10_twic_2M/tensors_holdout """ import argparse import struct @@ -136,6 +146,74 @@ def CalibrateActivationScales(model, tensor_dir, n_sample=50000, seed=42): return tuple(scales) +def CalibrateScoreMapping(model, holdout_tensor_dir, n_sample=20000, seed=7): + """Fits a simple affine correction (a, b) so that + a + b*raw tracks Stockfish's own real score (pawns, mover- + relative) -- not raw's nominal WDL-sigmoid scale. See the + 2026-10-20 "keeping this stable across retrains" conversation: + a fixed K/lambda/loss-exponent recipe is NOT enough on its own to + guarantee the engine's consumed cp output stays on a stable scale + across generations -- training variance (data, convergence) still + shifts the empirical raw-to-score mapping even with an identical + recipe. Re-fitting (a, b) every generation and baking the + correction into the exported format, applied in NnueForward + itself, decouples engine search-tuning from needing the raw net's + internal scale to stay perfectly constant forever. + + Fit against the HOLDOUT tensor dir specifically (not train) -- + an honest estimate needs positions the checkpoint wasn't fit on. + Uses score_probe_val.npy/score_probe_valid.npy (Stockfish's own + clipped per-position score, and a validity mask excluding exact/ + mate rows -- see build_training_tensors.py), which + build_training_tensors.py already writes for every tensor dir + regardless of whether --score-loss-weight was ever used during + training. Ordinary least squares, free intercept (not forced + through the origin).""" + d = Path(holdout_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") + score_val = np.load(d / "score_probe_val.npy") + score_valid = np.load(d / "score_probe_valid.npy").astype(bool) + + valid_rows = np.flatnonzero(score_valid) + if len(valid_rows) == 0: + sys.exit(f"CalibrateScoreMapping: {d} has no valid score-probe rows " + f"(all exact/mate?) -- can't fit a calibration against it") + + rng = np.random.default_rng(seed) + rows = rng.choice(valid_rows, size=min(n_sample, len(valid_rows)), replace=False) + rows.sort() + + raws = [] + 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)) + raws.append(model(w_idx, w_off, b_idx, b_off, stm_t).numpy()) + raw = np.concatenate(raws).astype(np.float64) + score = score_val[rows].astype(np.float64) + + # score ~= a + b*raw, ordinary least squares, free intercept. + b, a = np.polyfit(raw, score, 1) + resid = score - (a + b * raw) + print(f"CalibrateScoreMapping: fit on {len(rows)} holdout positions -- " + f"a={a:.4f}, b={b:.4f}, mean|resid|={np.mean(np.abs(resid)):.3f} pawns, " + f"resid p98={np.percentile(np.abs(resid), 98):.3f} pawns") + return float(a), float(b) + + def QuantizeFt(arr): """Rounds + clamps a float32 array to int16 at FT_SCALE. Returns (int16 ndarray, number of values that clipped).""" @@ -241,6 +319,13 @@ def main(): help="a build_training_tensors.py output dir, used " "to calibrate the FC head's activation scales " "against real positions (e.g. tensors_train)") + ap.add_argument("--holdout-tensor-dir", required=True, + help="a build_training_tensors.py HOLDOUT output dir " + "(not the same as --tensor-dir), used to fit the " + "raw-to-real-Stockfish-score affine correction " + "(a, b) baked into the exported format -- see " + "CalibrateScoreMapping. Must be holdout, not " + "train, for an honest (non-overfit) fit.") args = ap.parse_args() ckpt = torch.load(args.checkpoint, map_location="cpu", weights_only=True) @@ -276,6 +361,7 @@ def main(): model.eval() AS1, AS2, AS3 = CalibrateActivationScales(model, args.tensor_dir) + CALIB_A, CALIB_B = CalibrateScoreMapping(model, args.holdout_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() @@ -302,11 +388,12 @@ def main(): f"(see docs/NNUE.md)") with open(args.outfile, "wb") as f: - f.write(b"NNU3") + f.write(b"NNU4") 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(struct.pack("<2f", CALIB_A, CALIB_B)) f.write(ft_weight_q.tobytes()) f.write(ft_bias_q.tobytes()) f.write(fc1_w_q.tobytes()) diff --git a/annotate_pgns/nnue_model.py b/annotate_pgns/nnue_model.py index c483ce7..47dca76 100644 --- a/annotate_pgns/nnue_model.py +++ b/annotate_pgns/nnue_model.py @@ -19,18 +19,58 @@ 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. +the main WDL loss 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. + +2026-10-20: replaced by `score_probe` (same mechanism, different +target -- see that section below) and removed. Once `score_probe` +regresses Stockfish's actual raw score directly, `material_probe`'s +target (piece count) is a strict special case of what `score_probe` +already has to recover -- material dominates Stockfish's own score in +almost any position, so a net that fits the real score well has +implicitly recovered material too. Kept as one objective instead of +two overlapping ones; re-add material_probe alongside score_probe only +if a future sanity check shows score_probe alone is still +material-blind in some case it doesn't already cover. + +2026-10-20: tried `score_probe`, same mechanism as material_probe +(small Linear head on the same accumulator, trained jointly, dropped +before export) targeting Stockfish's own raw per-position score +directly via plain MSE -- motivated by a measured failure of a +different kind from material_probe's: in clearly-losing positions, +the main WDL head's score magnitude did not reflect how lost the +position actually was (-2 for "lost" and -3 for "about to be mated", +no meaningful gradation), because the WDL loss only ever supervises +`raw` through `sigmoid(raw/K)` against a win-probability target, which +saturates hard once a position is clearly decided (K=2.0659 on the +2026-10-20 corpus -- sigmoid(8/K)=0.9796 vs sigmoid(13/K)=0.9982, a +target spread of under 0.02 for a 5-pawn difference in the real +score). **Measured, then reverted**: a medium-scale (500k positions, +~15 epochs) controlled comparison (score_probe-weighted run vs. an +otherwise-identical control) showed only a small improvement in how +well `forward()`'s own raw output tracked the real score at the +losing extremes -- compression was still clearly present in both. +Root cause: score_probe's gradient only ever reaches the *shared +accumulator* (`ft`/`ft_p`/`ft_bias`), never `fc1`/`fc2`/`fc3` -- +nothing forces the actually-exported path to widen its own range, it +can only optionally make use of a richer upstream representation if +doing so happens to help the (still-saturating) WDL loss too. + +2026-10-20: replaced score_probe with a direct, unsaturated regression +term on `raw` itself: `MSE(raw, clipped_score)`, added straight +alongside the WDL loss in nnue_train.py, no auxiliary head at all. +This puts gradient directly on `fc1`/`fc2`/`fc3` -- the actual +exported path -- rather than hoping a richer accumulator happens to +propagate through. Not a competing objective with the WDL loss, either: +since `p_search = sigmoid(score/K)`, the WDL loss's own implied ideal +`raw` is already approximately `score` (especially at lambda close to +1.0) -- this term is an unsaturated restatement of the same target, +not a different one, so no separate probe/model-surface-area is +needed to get its benefit. See nnue_train.py's --score-loss-weight. 2026-10-18: added `ft_p`, the actual Stockfish-doc "P" factor (see NNUE.md's "feature factorization" status entry) -- a second, @@ -96,15 +136,9 @@ class NnueNet(nn.Module): self.fc2 = nn.Linear(l2, l3) self.fc3 = nn.Linear(l3, 1) - # 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 + (shape [B, 2*l1]) -- input to fc1. 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.""" @@ -134,18 +168,6 @@ class NnueNet(nn.Module): 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 ea3d0a9..e413aa9 100644 --- a/annotate_pgns/nnue_train.py +++ b/annotate_pgns/nnue_train.py @@ -42,17 +42,22 @@ 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)) + # score_probe_val.npy/score_probe_valid.npy added 2026-10-20 + # (see nnue_model.py's score_probe, replacing material_probe) + # -- optional for backward compat with tensor dirs built before + # this field existed. + score_path = tensor_dir / "score_probe_val.npy" + valid_path = tensor_dir / "score_probe_valid.npy" + self.score_probe_val = (np.load(score_path) if score_path.exists() + else np.zeros_like(self.target)) + self.score_probe_valid = (np.load(valid_path) if valid_path.exists() + else np.zeros_like(self.target, dtype=np.uint8)) 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 + assert len(self.score_probe_val) == self.n + assert len(self.score_probe_valid) == self.n def __len__(self): return self.n @@ -93,7 +98,8 @@ 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] + score_probe_val = data.score_probe_val[rows_sorted] + score_probe_valid = data.score_probe_valid[rows_sorted] yield ( torch.from_numpy(w_idx.astype(np.int64, copy=False)), torch.from_numpy(w_off), @@ -101,7 +107,8 @@ 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)), + torch.from_numpy(score_probe_val.astype(np.float32, copy=True)), + torch.from_numpy(score_probe_valid.astype(np.float32, copy=True)), ) @@ -118,34 +125,63 @@ def powered_loss(pred, target, exponent, reduction="mean"): 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 +def score_loss_fn(raw, score_val, score_valid, score_loss_type, huber_delta): + if score_loss_type == "huber": + return masked_huber_sum(raw, score_val, score_valid, huber_delta) + return masked_mse_sum(raw, score_val, score_valid) + + +def evaluate(model, data, batch_size, k, loss_exponent, device="cpu", + score_loss_type="mse", huber_delta=1.0): + """Returns (wdl_loss, score_mse) -- kept separate rather than + pre-combined so the log/checkpoint can show whether `raw` is + actually tracking the real score, independently of whatever weight the caller gives it in the training loss.""" model.eval() total_wdl_loss = 0.0 - total_material_loss = 0.0 + total_score_loss = 0.0 total_n = 0 + total_score_valid = 0 rng = np.random.default_rng(0) with torch.no_grad(): - for w_idx, w_off, b_idx, b_off, stm, target, material in iterate_batches( + for w_idx, w_off, b_idx, b_off, stm, target, score_val, score_valid in iterate_batches( data, batch_size, shuffle=False, 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), device) - raw, material_pred = model.forward_with_material(w_idx, w_off, b_idx, b_off, stm) + w_idx, w_off, b_idx, b_off, stm, target, score_val, score_valid = _to_device( + (w_idx, w_off, b_idx, b_off, stm, target, score_val, score_valid), device) + raw = model.forward(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) + score_loss = score_loss_fn(raw, score_val, score_valid, score_loss_type, huber_delta) total_wdl_loss += wdl_loss.item() - total_material_loss += material_loss.item() + total_score_loss += score_loss.item() total_n += len(target) + total_score_valid += int(score_valid.sum().item()) model.train() - return total_wdl_loss / max(total_n, 1), total_material_loss / max(total_n, 1) + return (total_wdl_loss / max(total_n, 1), + total_score_loss / max(total_score_valid, 1)) + + +def masked_mse_sum(pred, target, valid): + """Sum of squared error over only the rows where valid != 0 -- + exact/mate rows carry no real score target (see + build_training_tensors.py's score_probe_target) and must not pull + `raw` toward an arbitrary 0.0 placeholder.""" + return ((pred - target).pow(2) * valid).sum() -def nn_mse_sum(pred, target): - return (pred - target).pow(2).sum() +def masked_huber_sum(pred, target, valid, delta): + """Sum of Huber loss over only the rows where valid != 0 (same + masking as masked_mse_sum). Quadratic for |error| < delta + (preserves fine resolution on close, contested positions -- the + ones that actually matter for move choice), linear beyond it + (bounds how much a handful of large-magnitude blowout positions + can dominate the loss/gradient, unlike plain MSE). delta is in the + same pawn units as `pred`/`target`.""" + err = (pred - target).abs() + quad = 0.5 * err.pow(2) + lin = delta * (err - 0.5 * delta) + loss = torch.where(err < delta, quad, lin) + return (loss * valid).sum() def main(): @@ -164,16 +200,26 @@ def main(): "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 " + ap.add_argument("--score-loss-type", choices=["mse", "huber"], default="mse", + help="loss shape for the direct raw-vs-score term. " + "'mse': plain squared error (simple, but a " + "handful of large-magnitude blowout positions " + "can dominate it). 'huber': quadratic below " + "--huber-delta, linear beyond it -- bounds " + "blowout influence while keeping fine " + "resolution on close/contested positions.") + ap.add_argument("--huber-delta", type=float, default=1.0, + help="Huber loss transition point, in pawns -- " + "only used when --score-loss-type huber.") + ap.add_argument("--score-loss-weight", type=float, default=0.0, + help="weight on a direct MSE(raw, clipped Stockfish " + "score) term, straight on fc3's own output " + "(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.") + "0.001, and check both holdout losses in " + "train_log.jsonl rather than assume a value " + "works). 0.0 (default) disables it entirely.") 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 " @@ -277,16 +323,17 @@ def main(): epoch_n = 0 t_epoch = time.time() - for w_idx, w_off, b_idx, b_off, stm, target, material in iterate_batches( + for w_idx, w_off, b_idx, b_off, stm, target, score_val, score_valid 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) + w_idx, w_off, b_idx, b_off, stm, target, score_val, score_valid = _to_device( + (w_idx, w_off, b_idx, b_off, stm, target, score_val, score_valid), args.device) optimizer.zero_grad() - raw, material_pred = model.forward_with_material(w_idx, w_off, b_idx, b_off, stm) + raw = model.forward(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 + score_loss = score_loss_fn(raw, score_val, score_valid, + args.score_loss_type, args.huber_delta) / len(target) + loss = wdl_loss + args.score_loss_weight * score_loss loss.backward() optimizer.step() @@ -296,34 +343,36 @@ def main(): global_step += 1 if global_step % args.eval_every_steps == 0: - holdout_wdl, holdout_material = evaluate( + holdout_wdl, holdout_score = evaluate( model, holdout_data, args.batch_size, args.k, - args.loss_exponent, args.device) + args.loss_exponent, args.device, + args.score_loss_type, args.huber_delta) elapsed = time.time() - t_start rec = { "step": global_step, "epoch": epoch, "train_mse_running": epoch_loss / max(epoch_n, 1), "holdout_mse": holdout_wdl, - "holdout_material_mse": holdout_material, + "holdout_score_probe_mse": holdout_score, "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_wdl:.5f} " - f"holdout_material_mse={holdout_material:.4f} " + f"holdout_score_probe_mse={holdout_score:.4f} " f"elapsed={elapsed:.0f}s") log_f.write(json.dumps(rec) + "\n") log_f.flush() - holdout_wdl, holdout_material = evaluate( + holdout_wdl, holdout_score = evaluate( model, holdout_data, args.batch_size, args.k, - args.loss_exponent, args.device) + args.loss_exponent, args.device, + args.score_loss_type, args.huber_delta) 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_wdl:.5f} " - f"holdout_material_mse={holdout_material:.4f} ({epoch_elapsed:.0f}s)") + f"holdout_score_probe_mse={holdout_score:.4f} ({epoch_elapsed:.0f}s)") ckpt_path = outdir / f"ckpt_epoch{epoch:03d}.pt" torch.save({ diff --git a/annotate_pgns/run_nnue_pipeline.py b/annotate_pgns/run_nnue_pipeline.py index 39bc487..6e51416 100644 --- a/annotate_pgns/run_nnue_pipeline.py +++ b/annotate_pgns/run_nnue_pipeline.py @@ -199,6 +199,21 @@ def main(): help="build_training_tensors.py's " "target = lambda*p_search + (1-lambda)*r_outcome " "for non-exact rows") + ap.add_argument("--k", type=float, default=None, + help="nnue_train.py's --k (the WDL-sigmoid scale, " + "pred=sigmoid(raw/k)) -- previously NOT passed " + "through at all, silently falling back to " + "nnue_train.py's own DEFAULT_K (1.3123, a Phase-A " + "placeholder), regardless of what K " + "fit_k_and_label.py actually fit for this corpus's " + "own p_search labels (stage 3 below) -- a real " + "label/loss K mismatch, fixed 2026-10-20. Required " + "now (no default) specifically so this can't go " + "silently unmatched again: read fit_k_and_label.py's " + "own printed 'Fitted K = ...' line for this corpus " + "and pass that (times whatever saturation-" + "correction factor empirical testing supports -- " + "see docs/NNUE.md's K-recalibration status).") 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 " @@ -206,12 +221,21 @@ def main(): 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("--score-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() + if args.k is None: + sys.exit("--k is required (no default, deliberately -- see its " + "help text): pass the K you actually want nnue_train.py's " + "loss to use. Do not guess -- run fit_k_and_label.py " + "first (or check its log from stage 3 below) to see what " + "it fits for THIS corpus, and apply whatever saturation-" + "correction factor has been validated empirically before " + "trusting its raw fit.") + pgn_paths = sorted({p for pattern in args.pgn_glob for p in glob.glob(pattern)}) if not pgn_paths: @@ -361,8 +385,8 @@ def main(): "--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, + "--lr", args.lr, "--k", args.k, "--loss-exponent", args.loss_exponent, + "--score-loss-weight", args.score_loss_weight, "--threads", args.threads, "--device", args.device, "--patience", args.patience], args.force) @@ -372,10 +396,33 @@ def main(): run_stage( "export_nnue_weights.py", [args.outfile], [sys.executable, HERE / "export_nnue_weights.py", - ckpt, args.outfile, "--tensor-dir", tensors_train_dir], + ckpt, args.outfile, "--tensor-dir", tensors_train_dir, + "--holdout-tensor-dir", tensors_holdout_dir], + args.force) + + # 8. Refit the lazy-eval probe (nnue_vs_head.sh's NNUE_PROBE_WEIGHTS / + # command.c's "nnue probeload") against THIS checkpoint -- it's a + # separate offline ridge regression, not part of the exported .nnue + # file, and must be re-fit every generation (same rule as the + # calibration inside export_nnue_weights.py: not portable across + # checkpoints). Written alongside outfile, not into it. See + # fit_lazy_probe.py and the 2026-10-20 "keeping this stable across + # retrains" / lazy-eval conversation -- this is the regression fit + # only; the real search-node-traffic margin-safety calibration + # (CALIBRATE_MARGIN_SAFETY) is a separate, not-yet-automated step, + # deliberately not run here every generation (see that conversation + # for why it may not need to be). + probe_outfile = Path(args.outfile).with_name( + Path(args.outfile).stem + "_probe.bin") + run_stage( + "fit_lazy_probe.py", [probe_outfile], + [sys.executable, HERE / "fit_lazy_probe.py", + ckpt, probe_outfile, + "--train-tensor-dir", tensors_train_dir, + "--holdout-tensor-dir", tensors_holdout_dir], args.force) - print(f"\nPipeline complete: {args.outfile}") + print(f"\nPipeline complete: {args.outfile} (lazy probe: {probe_outfile})") if __name__ == "__main__": |
