#!/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()