summaryrefslogtreecommitdiff
path: root/annotate_pgns/nnue_model.py
diff options
context:
space:
mode:
authorScott Gasch <[email protected]>2026-10-20 18:37:20 -0700
committerScott Gasch <[email protected]>2026-10-20 18:37:20 -0700
commit9cf14e14e6999420f234cda951894337e034f57c (patch)
treed4845d0cf127ef4226a5cd2e60472ee750213614 /annotate_pgns/nnue_model.py
parentc8c6bbc2c1a103e5b07b079a0f8bdf66c7724d80 (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.py84
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)