1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
|
#!/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()
|