summaryrefslogtreecommitdiff
path: root/annotate_pgns/export_nnue_weights.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/export_nnue_weights.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/export_nnue_weights.py')
-rw-r--r--annotate_pgns/export_nnue_weights.py95
1 files changed, 91 insertions, 4 deletions
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())