diff options
| author | Scott Gasch <[email protected]> | 2026-09-18 09:43:17 -0700 |
|---|---|---|
| committer | Scott Gasch <[email protected]> | 2026-09-18 09:43:17 -0700 |
| commit | f698c02de213d0ed8968fb7cca5f70df51fa0763 (patch) | |
| tree | 080d6443fbe62d71de092f9acde61da387dff408 /src/eval_tune/fit_king_safety_ridge.py | |
| parent | 4a7ec9ac91939957fb3d3e5564fc24a385b422ff (diff) | |
Redesign lazy/super-lazy eval margins with position-specific percentile
tables, and fix four real search/recognizer bugs found while validating it
Main theme: replace the flat, heavily-padded LAZY_EVAL_BASE_MARGIN /
SUPER_LAZY_MARGIN_BY_ARMY constants with per-material-bucket percentile
tables (king safety by attacker army, residual by own army, separate
tables for the lazy vs super-lazy baselines) derived from a 30k-position
calibration pool, targeting a measured ~1-1.5% exceeded-margin rate
instead of an unmeasured "add lots of headroom" floor. QSearch's
FUTILITY_BASE_MARGIN_LAZY/SUPERLAZY were re-derived to match the new,
now-meaningful (p50-based) iPositional this produces. Self-play confirms
the working tree beats both HEAD, a more liberal (~5% miss target)
variant, and a more conservative (near-zero miss) variant of the same
margin mechanism.
Along the way, an initial self-play regression (this margin work bundled
with several changes in one pass, one of which broke things) led to a
day of ZFS-snapshot bisection and then targeted DEBUG-build fuzzing
against a stratified 200k-position corpus (tests/cross_sectional_200k.ep_,
new), which is what actually found and fixed four distinct, real,
pre-existing engine bugs -- none of them related to the margin redesign
itself:
- searchsup.c (CommonSearchInit): three early-return paths (timer
abort, draw, too-deep bailout) left a node's own PV[] row stale
instead of clearing it, letting an ancestor's UpdatePV splice a
leftover move from a completely unrelated node into the displayed
PV -- root cause of a WalkPV/MakeMove assert ("moving a piece to
capture, but nothing was on the target square").
- dynamic.c (RecordEnprisePieceAtPly): the two-slot en-prise dedup
compared (square, piece) instead of square alone, so a same-square
re-record with a different piece (legitimate -- a fail-high
capture's victim is restored by UnmakeMove and can differ across
calls at the same ply) shifted a duplicate square into both slots,
tripping ValueOfMaterialInTroubleDespiteMove's two-distinct-squares
assert.
- recogn.c (_RecognizeKBKP): the wrong-colored-bishop corner-fortress
draw shortcut fired even when the weak side has its own pawn(s),
which can hand the strong side real zugzwang resources the fortress
logic doesn't account for -- produced a false hard draw bound where
EGTB shows a near-forced loss.
- eval.c (_EvalPasserRacesAgainstLoneKings): a rook pawn's race was
credited as winning even when the strong side's own king sits
exactly on its own pawn's queening square (distance 0 specifically --
adjacent is fine, the square is empty) -- a classic stalemate
resource the race heuristic can't see. Only reachable through
_RecognizeKPK's pawn-hash-cache-hit path, which is why
testrecogn.c's TestRecognExhaustiveKPK reported "0 wrong" the whole
time; extended it to cover both color assignments and the full board
(no mirror-shortcut) and documented the remaining cache-hit coverage
gap for a future session to close.
Also: match_play.py gains a local_ebf metric (geometric-mean of
depth-normalized node-growth ratios between consecutive completed
iterations) alongside the existing cumulative nodes**(1/depth) ebf,
which turned out to be misleading when comparing engines with
different lazy-exit aggressiveness -- and probe.c/hash.c pick up two
small, independently-found correctness fixes (TB root-probe promotion
piece missing its color bit; ASSERT(g_pHashTable) crashing on the
legitimate "--hash none" config) noticed along the way.
Co-Authored-By: Claude Sonnet 5 <[email protected]>
Diffstat (limited to 'src/eval_tune/fit_king_safety_ridge.py')
| -rw-r--r-- | src/eval_tune/fit_king_safety_ridge.py | 202 |
1 files changed, 202 insertions, 0 deletions
diff --git a/src/eval_tune/fit_king_safety_ridge.py b/src/eval_tune/fit_king_safety_ridge.py new file mode 100644 index 0000000..072c972 --- /dev/null +++ b/src/eval_tune/fit_king_safety_ridge.py @@ -0,0 +1,202 @@ +#!/usr/bin/env python3 +"""Sanity-check alternative to Texel for the king-safety terms: fit Ridge +and ElasticNet regression directly against the mate/control label (1=mated +from this position, 0=survived-to-a-real-draw control), using the same +matched_combined_small.csv the curated-Texel run consumed (see +build_curated_texel_pool.py / eval_tune's 2026-09-15 king-safety session). + +Unlike Texel, this fits a real regularized linear model in one shot (no +coordinate descent over a live engine), so it can't reproduce Texel's +sign-flip pathology from correlated coordinate updates stepping on each +other -- if Ridge/ElasticNet still flip signs on physically-obvious terms +(e.g. an undefended ring square should never be a *bonus*), that is strong +evidence the flips are a real property of the feature collinearity, not an +artifact of Texel's optimizer. + +Features are built to mirror the actual _EvalKing ring-tier terms as +closely as the KING_DEFECT_DUMP per-square dump allows: slots 0-2 and 5-7 +swap front/back meaning by color (see data.c's InitializeKingAttackTables +iFrontDeltas/iBackDeltas -- white front = deltas -17/-16/-15, black front = ++15/+16/+17), slots 3-4 are always the side squares. Each row's dump was +already collapsed to the mated/sampled color's own perspective (see +extract_king_features.py), so slot->tier mapping only depends on that row's +color column, not board color. +""" +import csv +import sys +import numpy as np +from sklearn.linear_model import Ridge, ElasticNet +from sklearn.model_selection import train_test_split +from sklearn.preprocessing import StandardScaler + +IN_CSV = sys.argv[1] if len(sys.argv) > 1 else "/tmp/typhoon/matched_combined_small.csv" + +PIECE_TYPES = ("pawn", "minor", "rook", "queen") +WHITE_FRONT_SLOTS = (0, 1, 2) +WHITE_BACK_SLOTS = (5, 6, 7) +SIDE_SLOTS = (3, 4) + + +def tier_slots(color, tier): + if tier == "side": + return SIDE_SLOTS + is_front = (tier == "front") + use_white_front_slots = is_front == (color == "white") + return WHITE_FRONT_SLOTS if use_white_front_slots else WHITE_BACK_SLOTS + + +def load_rows(path): + with open(path, newline="") as f: + return list(csv.DictReader(f)) + + +def build_features(rows_in): + feat_rows = [] + y = [] + col_names = None + for r in rows_in: + color = r["mated_color_or_sampled_color"] + + def offboard(s): + return int(r.get(f"sq{s}_offboard", 0) or 0) != 0 + + feat = {} + for tier in ("front", "back", "side"): + slots = tier_slots(color, tier) + for p in PIECE_TYPES: + feat[f"{tier}_atk_{p}"] = sum( + int(r[f"sq{s}_atk_{p}"]) for s in slots if not offboard(s) + ) + feat[f"{tier}_undef"] = sum( + int(r[f"sq{s}_undef"]) for s in slots if not offboard(s) + ) + feat[f"{tier}_xray"] = sum( + int(r[f"sq{s}_xray"]) for s in slots if not offboard(s) + ) + feat[f"{tier}_flight"] = sum( + int(r[f"sq{s}_flight"]) for s in slots if not offboard(s) + ) + feat["queenproximity"] = int(r["queenproximity"]) + feat["filestorm"] = int(r["filestorm"]) + feat["filestormopenfile"] = int(r["filestormopenfile"]) + feat["filestormpawn"] = int(r["filestormpawn"]) + feat["castled"] = int(r["castled"]) + feat["minorsathome"] = int(r["minorsathome"]) + feat["misscastleshort"] = int(r["misscastleshort"]) + feat["misscastlelong"] = int(r["misscastlelong"]) + if col_names is None: + col_names = list(feat.keys()) + feat_rows.append([feat[c] for c in col_names]) + y.append(float(r["label"])) + return np.array(feat_rows, dtype=float), np.array(y, dtype=float), col_names + + +def report(name, model, X_cols, coefs, intercept): + print(f"\n=== {name} ===") + print(f"intercept: {intercept:.4f}") + order = np.argsort(-np.abs(coefs)) + for i in order: + if abs(coefs[i]) < 1e-6: + continue + print(f" {X_cols[i]:28s} {coefs[i]:+.4f}") + + +SANITY_KEYS = ("front_undef", "back_undef", "side_undef", + "front_xray", "back_xray", "side_xray", + "queenproximity", "filestorm") + + +def sign_flips(coefs, col_names): + n = 0 + for key in SANITY_KEYS: + if key in col_names: + if coefs[col_names.index(key)] < -1e-9: + n += 1 + return n + + +def sweep(X_train, y_train, X_test, y_test, col_names): + print("\n=== Ridge alpha sweep ===") + print(f"{'alpha':>10} {'train MSE':>10} {'test MSE':>10} {'sign flips':>11}") + for alpha in (0.1, 1, 3, 10, 30, 100, 300, 1000): + m = Ridge(alpha=alpha).fit(X_train, y_train) + tr = np.mean((m.predict(X_train) - y_train) ** 2) + te = np.mean((m.predict(X_test) - y_test) ** 2) + flips = sign_flips(m.coef_, col_names) + print(f"{alpha:>10} {tr:>10.4f} {te:>10.4f} {flips:>11}") + + print("\n=== ElasticNet alpha/l1_ratio sweep ===") + print(f"{'alpha':>8} {'l1_ratio':>9} {'train MSE':>10} {'test MSE':>10} " + f"{'nnz':>4} {'sign flips':>11}") + for alpha in (0.002, 0.005, 0.01, 0.02, 0.05, 0.1, 0.2): + for l1_ratio in (0.1, 0.3, 0.5, 0.7, 0.9, 1.0): + m = ElasticNet(alpha=alpha, l1_ratio=l1_ratio, max_iter=20000) + m.fit(X_train, y_train) + tr = np.mean((m.predict(X_train) - y_train) ** 2) + te = np.mean((m.predict(X_test) - y_test) ** 2) + nnz = int(np.sum(np.abs(m.coef_) > 1e-9)) + flips = sign_flips(m.coef_, col_names) + print(f"{alpha:>8} {l1_ratio:>9} {tr:>10.4f} {te:>10.4f} " + f"{nnz:>4} {flips:>11}") + + +def main(): + rows_in = load_rows(IN_CSV) + print(f"Loaded {len(rows_in)} rows from {IN_CSV}") + X, y, col_names = build_features(rows_in) + print(f"Built {X.shape[1]} features") + + X_train, X_test, y_train, y_test = train_test_split( + X, y, test_size=0.15, random_state=42 + ) + scaler = StandardScaler().fit(X_train) + X_train_s = scaler.transform(X_train) + X_test_s = scaler.transform(X_test) + + if "--sweep" in sys.argv: + sweep(X_train_s, y_train, X_test_s, y_test, col_names) + return + + ridge = Ridge(alpha=10.0) + ridge.fit(X_train_s, y_train) + ridge_train_err = np.mean((ridge.predict(X_train_s) - y_train) ** 2) + ridge_test_err = np.mean((ridge.predict(X_test_s) - y_test) ** 2) + print(f"\nRidge(alpha=10): train MSE={ridge_train_err:.4f} test MSE={ridge_test_err:.4f}") + report("Ridge coefficients (standardized X)", ridge, col_names, ridge.coef_, ridge.intercept_) + + enet_alpha = float(sys.argv[sys.argv.index("--enet-alpha") + 1]) if "--enet-alpha" in sys.argv else 0.02 + enet_l1_ratio = float(sys.argv[sys.argv.index("--enet-l1-ratio") + 1]) if "--enet-l1-ratio" in sys.argv else 0.5 + enet = ElasticNet(alpha=enet_alpha, l1_ratio=enet_l1_ratio, max_iter=20000) + enet.fit(X_train_s, y_train) + enet_train_err = np.mean((enet.predict(X_train_s) - y_train) ** 2) + enet_test_err = np.mean((enet.predict(X_test_s) - y_test) ** 2) + print(f"\nElasticNet(alpha={enet_alpha}, l1_ratio={enet_l1_ratio}): train MSE={enet_train_err:.4f} test MSE={enet_test_err:.4f}") + report("ElasticNet coefficients (standardized X)", enet, col_names, enet.coef_, enet.intercept_) + + # Sign sanity check: undefended-attack terms and any "flight" term for + # the front tier should be non-negative-danger (i.e. push toward + # label==1/mate, positive coefficient here since label=1 is mate) -- + # a *negative* coefficient on front_undef/back_undef/side_undef would + # mean "more undefended attacked squares near the king make mate LESS + # likely", which is not physically sane. + print("\n=== Sign sanity check (positive = more mate-associated, as expected) ===") + for name, coefs in (("Ridge", ridge.coef_), ("ElasticNet", enet.coef_)): + cols = col_names + bad = [] + for key in ("front_undef", "back_undef", "side_undef", + "front_xray", "back_xray", "side_xray", + "queenproximity", "filestorm"): + if key in cols: + c = coefs[cols.index(key)] + flag = "" if c >= 0 else " <-- SIGN FLIP (should be >= 0)" + print(f" [{name}] {key:16s} {c:+.4f}{flag}") + if c < 0: + bad.append(key) + if bad: + print(f" [{name}] {len(bad)} suspicious sign(s): {bad}") + else: + print(f" [{name}] all checked terms have the expected sign") + + +if __name__ == "__main__": + main() |
