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/nnue_model.py | |
| 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/nnue_model.py')
| -rw-r--r-- | annotate_pgns/nnue_model.py | 84 |
1 files changed, 53 insertions, 31 deletions
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) |
