#!/usr/bin/env python3 """Logistic-regression calibration of king-safety ring-defect terms against material balance as an external, non-circular yardstick. Why material balance and not the engine's own iSafety: iSafety is mechanically a weighted sum of (a subset of) these same ring-defect features under the CURRENT baseline weights -- using it as a covariate regresses the model against itself, collinear by construction, not just "maybe wrong". Material balance is computed independently, straight from the FEN via python-chess with standard piece values -- it has nothing to do with king-ring geometry and is anchored to a genuinely external, centuries-old convention (pawn=100, knight/bishop=~320, rook=500, queen=900), the same scale eval.c's own PIECE_VALUE table already uses elsewhere in the engine, unrelated to the king-safety subsystem. Logistic (not linear) regression on the same curated mate/control label, material_balance included as one covariate: its fitted coefficient tells you how many logit-units one pawn (100cp) is worth in this exact dataset. Divide every king-safety feature's coefficient by that, times 100, and you get that feature's danger contribution in real centipawns, calibrated against an external ruler -- no Texel, no single hand-picked anchor constant. """ import csv import sys import chess import numpy as np from sklearn.linear_model import LogisticRegression sys.path.insert(0, "/usr/home/scott/typhoon/src/eval_tune") from fit_king_safety_ridge import tier_slots IN_CSV = sys.argv[1] if len(sys.argv) > 1 else "/tmp/typhoon/matched_combined_full.csv" PIECE_VALUES = { chess.PAWN: 1.0, chess.KNIGHT: 3.2, chess.BISHOP: 3.3, chess.ROOK: 5.0, chess.QUEEN: 9.0, chess.KING: 0.0, } NONPAWN_PIECES = ("minor", "rook", "queen") def material_balance_pawns(fen, color): """Material balance in pawns, from `color`'s point of view.""" board = chess.Board(fen) total = {chess.WHITE: 0.0, chess.BLACK: 0.0} for piece_type, value in PIECE_VALUES.items(): for c in (chess.WHITE, chess.BLACK): total[c] += value * len(board.pieces(piece_type, c)) my_color = chess.WHITE if color == "white" else chess.BLACK other = chess.BLACK if my_color == chess.WHITE else chess.WHITE return total[my_color] - total[other] 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 skipped = 0 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 NONPAWN_PIECES: 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["ring_atk_pawn"] = 0 feat["ring_xray"] = 0 for tier in ("front", "back", "side"): slots = tier_slots(color, tier) feat["ring_atk_pawn"] += sum( int(r[f"sq{s}_atk_pawn"]) for s in slots if not offboard(s) ) feat["ring_xray"] += sum( int(r[f"sq{s}_xray"]) for s in slots if not offboard(s) ) feat["front_flight"] = sum( int(r[f"sq{s}_flight"]) for s in tier_slots(color, "front") if not offboard(s) ) feat["back_flight"] = sum( int(r[f"sq{s}_flight"]) for s in tier_slots(color, "back") if not offboard(s) ) try: feat["material_balance"] = material_balance_pawns(r["fen"], color) except Exception: skipped += 1 continue if col_names is None: col_names = list(feat.keys()) feat_rows.append([feat[c] for c in col_names]) y.append(int(r["label"])) if skipped: print(f"skipped {skipped} rows with unparseable FENs", file=sys.stderr) return np.array(feat_rows, dtype=float), np.array(y, dtype=int), col_names 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 (incl. material_balance): {col_names}") C = float(sys.argv[2]) if len(sys.argv) > 2 else 0.05 m = LogisticRegression(penalty="l1", solver="liblinear", C=C, max_iter=5000) m.fit(X, y) coef = m.coef_[0] mat_idx = col_names.index("material_balance") beta_material = coef[mat_idx] print(f"\nC={C} intercept={m.intercept_[0]:.4f}") print(f"beta_material (logit per pawn of material) = {beta_material:.5f}") if abs(beta_material) < 1e-6: print("material_balance coefficient ~0 -- can't calibrate, " "try a larger C (less regularization).") return print(f"\n{'feature':20s} {'beta (logit/unit)':>18s} {'cp (calibrated)':>16s}") for name, b in zip(col_names, coef): if name == "material_balance": continue cp = 100.0 * b / beta_material flag = "" if abs(b) > 1e-9 else " (zeroed by L1)" print(f"{name:20s} {b:18.5f} {cp:16.2f}{flag}") if __name__ == "__main__": main()