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/export_nnue_weights.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/export_nnue_weights.py')
| -rw-r--r-- | annotate_pgns/export_nnue_weights.py | 95 |
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()) |
