summaryrefslogtreecommitdiff
path: root/annotate_pgns
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
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')
-rw-r--r--annotate_pgns/build_training_tensors.py64
-rw-r--r--annotate_pgns/export_nnue_weights.py95
-rw-r--r--annotate_pgns/nnue_model.py84
-rw-r--r--annotate_pgns/nnue_train.py139
-rw-r--r--annotate_pgns/run_nnue_pipeline.py57
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__":