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
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
|
#!/usr/bin/env python3
"""Fit a logistic regression on king_features.csv's raw per-defect
breakdown against the mate/control label, to see whether _EvalKing's
defects should be weighted differently than the hand-picked constants
currently baked into KING_SAFETY_BY_COUNTER / KING_COUNTER_BY_ATTACK_PATTERN
/ EmptyAttackedSquare etc. See CLAUDE.md's king-safety discussion for the
full methodology this is built on.
Deliberately excludes `countertotal` and `safetyvalue` from the feature
set -- those are the CURRENT engine's own derived output (the thing we're
trying to second-guess), not a raw defect. Using them as inputs would be
circular. `initloc` is kept as a feature (a compact king-square exposure
signal) since, unlike the other two, it doesn't already fold in this
sample's own defect counts.
Attacker material confound (conversation context: _EvalKing scales its
final score by REDUCED_MATERIAL_DOWN_SCALER[armyscaler], but the raw
defect counts here are unscaled, and a real mate is naturally rarer under
a weak attacker regardless of what the defects say) is handled two ways:
included as a plain covariate (attackerarmyscaler), and separately
validated by refitting on the top-tercile-army-strength subset and
diffing coefficients against the full-population fit.
Ring-square bucketing: slots 3/4 (the KingSafetyDeltas -1/+1 deltas -- the
two SAME-RANK side squares, not "front" despite an earlier mislabeling in
conversation) are kept individually resolved since they carried real,
stable signal in initial runs. The other six slots (the diagonal +
vertical squares one rank away) are collapsed into "front"/"back" bucket
sums, color-dependent per EmptyAttackedSquare's own existing asymmetry
(WHITE's front is slots 0/1/2, BLACK's is 5/6/7 -- see
KingRingBucket-equivalent logic below) -- these six raw per-slot
coefficients were the noisiest, most sign-flipping entries in every
full-vs-top-tercile drift table, and bucketing trades a little resolution
for a lot more stability at the sample sizes actually available here.
`filestorm` itself is dropped as a feature in favor of its two already-
independent sub-components (filestormopenfile, filestormpawn) which map
directly onto separate hand-picked tables in eval.c (KingFileDefects /
KingStormingPawnDefects) -- no reason to force a regression to re-derive
their relative importance from one pre-summed number.
"""
import argparse
import csv
import sys
import numpy as np
from sklearn.linear_model import LogisticRegression
from sklearn.model_selection import train_test_split
from sklearn.preprocessing import StandardScaler
from sklearn.metrics import roc_auc_score, accuracy_score
PIECE_NAMES = ("pawn", "minor", "rook", "queen")
PER_SQUARE_KEYS = ["offboard"] + [f"atk_{p}" for p in PIECE_NAMES] + \
["undef", "def", "xray", "flight"]
SIDE_SLOTS = (3, 4) # kept individually resolved
# Color-dependent diagonal/vertical bucket membership, mirroring
# EmptyAttackedSquare's own asymmetry (chess.h: WHITE=1, BLACK=0).
BUCKET_SLOTS = {
"white": {"front": (0, 1, 2), "back": (5, 6, 7)},
"black": {"front": (5, 6, 7), "back": (0, 1, 2)},
}
FEATURE_COLUMNS = []
for _sq in SIDE_SLOTS:
for _k in PER_SQUARE_KEYS:
FEATURE_COLUMNS.append(f"sq{_sq}_{_k}")
for _bucket in ("front", "back"):
for _k in PER_SQUARE_KEYS:
FEATURE_COLUMNS.append(f"{_bucket}_{_k}")
FEATURE_COLUMNS += ["filestormopenfile", "filestormpawn", "queenproximity",
"initloc", "castled", "minorsathome",
"misscastleshort", "misscastlelong", "attackerarmyscaler"]
# Reference-only columns: the engine's OWN current derived output for this
# position, kept for comparison but never fed into the model as inputs.
REFERENCE_COLUMNS = ["countertotal", "safetyvalue", "filestorm"]
def _row_to_features(r):
color = r["mated_color_or_sampled_color"]
bucket_slots = BUCKET_SLOTS[color]
out = []
for sq in SIDE_SLOTS:
for k in PER_SQUARE_KEYS:
out.append(float(r[f"sq{sq}_{k}"]))
for bucket_name in ("front", "back"):
slots = bucket_slots[bucket_name]
for k in PER_SQUARE_KEYS:
# Sum across the 3 slots in this bucket -- offboard included,
# since a king near the edge legitimately has fewer on-board
# ring squares and that's itself signal, not noise to hide.
out.append(sum(float(r[f"sq{s}_{k}"]) for s in slots))
for c in ["filestormopenfile", "filestormpawn", "queenproximity",
"initloc", "castled", "minorsathome",
"misscastleshort", "misscastlelong", "attackerarmyscaler"]:
out.append(float(r[c]))
return out
def load_data(csv_path):
with open(csv_path, newline="") as f:
reader = csv.DictReader(f)
rows = list(reader)
y = np.array([int(r["label"]) for r in rows], dtype=int)
X = np.array([_row_to_features(r) for r in rows], dtype=float)
ref = {c: np.array([float(r[c]) for r in rows]) for c in REFERENCE_COLUMNS}
return X, y, ref
def fit_and_report(X, y, label, top_n=25):
scaler = StandardScaler()
X_scaled = scaler.fit_transform(X)
X_train, X_test, y_train, y_test = train_test_split(
X_scaled, y, test_size=0.2, random_state=42, stratify=y)
clf = LogisticRegression(max_iter=2000, C=1.0)
clf.fit(X_train, y_train)
y_pred = clf.predict(X_test)
y_prob = clf.predict_proba(X_test)[:, 1]
acc = accuracy_score(y_test, y_pred)
auc = roc_auc_score(y_test, y_prob)
print(f"\n=== {label} (n={len(y)}, train={len(y_train)}, test={len(y_test)}) ===")
print(f"Test accuracy: {acc:.4f} Test AUC: {auc:.4f}")
# Standardized coefficients are directly comparable to each other
# (same units: change in log-odds per one std-dev of that feature)
# regardless of the feature's raw scale (a 0/1 flag vs. armyscaler's
# 0-62 range) -- that comparability is the entire point of this
# exercise, so report these, not raw unscaled coefficients.
coefs = clf.coef_[0]
order = np.argsort(-np.abs(coefs))
print(f"\nTop {top_n} features by |standardized coefficient| "
f"(positive = more mate-predictive):")
for i in order[:top_n]:
print(f" {FEATURE_COLUMNS[i]:28s} {coefs[i]:+.4f}")
return dict(zip(FEATURE_COLUMNS, coefs)), scaler, clf
def main():
ap = argparse.ArgumentParser()
ap.add_argument("features_csv")
ap.add_argument("--top-n", type=int, default=25)
args = ap.parse_args()
X, y, ref = load_data(args.features_csv)
print(f"Loaded {len(y)} rows, {X.shape[1]} features, "
f"{y.sum()} positives / {(y == 0).sum()} controls", file=sys.stderr)
print("\n--- Reference: current engine's own derived score ---")
for c in REFERENCE_COLUMNS:
pos_mean = ref[c][y == 1].mean()
ctl_mean = ref[c][y == 0].mean()
print(f" {c}: mean(mate)={pos_mean:.1f} mean(control)={ctl_mean:.1f} "
f"ratio={pos_mean / max(ctl_mean, 1e-9):.2f}x")
full_coefs, _, _ = fit_and_report(X, y, "Full population", top_n=args.top_n)
# Attacker-material confound check: refit on the top tercile of
# attacker army strength only, where REDUCED_MATERIAL_DOWN_SCALER is
# near its ceiling and roughly constant across the subset -- if
# coefficients move a lot vs. the full-population fit, the raw defect
# weights aren't material-invariant and the interaction should be
# modeled explicitly rather than left as a plain covariate.
army_idx = FEATURE_COLUMNS.index("attackerarmyscaler")
army_vals = X[:, army_idx]
top_tercile_cutoff = np.percentile(army_vals, 66.7)
mask = army_vals >= top_tercile_cutoff
print(f"\nTop-tercile army strength cutoff: attackerarmyscaler >= "
f"{top_tercile_cutoff:.0f} ({mask.sum()} rows)", file=sys.stderr)
strong_coefs, _, _ = fit_and_report(
X[mask], y[mask], "Top-tercile attacker army strength", top_n=args.top_n)
print(f"\n=== Coefficient drift: full population vs. top-tercile army strength ===")
print(f"(large drift on a feature means its true weight is NOT "
f"material-invariant -- REDUCED_MATERIAL_DOWN_SCALER alone may "
f"not be enough to capture that interaction)")
drift = [(c, full_coefs[c], strong_coefs.get(c, 0.0),
abs(full_coefs[c] - strong_coefs.get(c, 0.0)))
for c in FEATURE_COLUMNS if c != "attackerarmyscaler"]
drift.sort(key=lambda t: -t[3])
for c, fv, sv, d in drift[:args.top_n]:
print(f" {c:28s} full={fv:+.4f} top_tercile={sv:+.4f} drift={d:.4f}")
if __name__ == "__main__":
main()
|