diff options
| author | Scott Gasch <[email protected]> | 2026-09-13 23:18:07 -0700 |
|---|---|---|
| committer | Scott Gasch <[email protected]> | 2026-09-13 23:18:07 -0700 |
| commit | 4ff9170fd96e3da049c747bf5215e908a94ff145 (patch) | |
| tree | 6c8e6f846b42518d84357bb3b83014feddc83202 /src/eval_tune/match_controls.py | |
| parent | 78d1889fe38db1fd45b58509c8b3bd535ce691b7 (diff) | |
Add king-safety mate-mining regression pipeline; retune file-storm/
queen-proximity king-safety weights and add a back-flight bonus from it.
Mines real human-game checkmates and matched sound-draw controls
(eval_tune/mine_king_mates.py, shard_mine.py, extract_king_features.py,
match_controls.py, train_king_safety_model.py), then fits a logistic
regression against _EvalKing's raw per-defect breakdown (engine-side
support: chess.h's KING_DEFECT_DUMP_INFO, eval.c's KING_DEFECT_DUMP
instrumentation, command.c's evalkingdump command, all gated behind the
KING_DEFECT_DUMP=1 build flag) to see which defects actually predict
getting mated. See eval_tune/king_safety_data/HANDOFF.md for the full
methodology and findings.
Two rounds of retuning landed from this data so far, both validated via
sd10 curated-suite comparison plus per-position Crafty corroboration
(tests/ECM.md) and gated self-play:
- KingFileDefects/KingStormingPawnDefects (_ComputeFileStormDefects)
bumped ~1.75-2x relative to the rest of the ring-scan defects -- the
regression's most dominant, most stable tier-1 predictors, previously
weighted the same as every other hand-picked defect.
- KING_QUEEN_PROXIMITY_DANGER rescaled from -8/count to -32/count, and
a new KING_BACK_FLIGHT_BONUS (+1/square) term added for empty,
unattacked ring squares on the side of the king away from the
attacker -- a real, non-collinear protective signal the current
EmptyUnattackedSquare table didn't credit at all. Both calibrated by
unstandardizing the regression's coefficients and anchoring against
KING_MISSING_ONE_CASTLE_OPTION's already-live -23 value (same
direct-EVAL_TERM unit family).
Co-Authored-By: Claude Sonnet 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01P9EDZVcqu6E2bNRm4zDrTf
Diffstat (limited to 'src/eval_tune/match_controls.py')
| -rw-r--r-- | src/eval_tune/match_controls.py | 168 |
1 files changed, 168 insertions, 0 deletions
diff --git a/src/eval_tune/match_controls.py b/src/eval_tune/match_controls.py new file mode 100644 index 0000000..10bb821 --- /dev/null +++ b/src/eval_tune/match_controls.py @@ -0,0 +1,168 @@ +#!/usr/bin/env python3 +"""Case-control matching to remove the attacker-material/development +confound found in the naive full-population regression: attackerarmyscaler +alone got AUC 0.99 distinguishing mate-precursor positions from +draw-derived controls, causing quasi-complete separation that destabilizes +every other coefficient in the joint fit. Fix: match each positive to the +nearest available control by (attackerarmyscaler, minorsathome) instead of +using controls sampled freely from the draw pool. See CLAUDE.md's +king-safety discussion for the full methodology. + +Uses exact-bucket + expanding-radius matching, not a KDTree: the match +columns are low-cardinality integers, so exact-value clusters are huge and +a KDTree's k nearest neighbors are highly tied -- every positive in a big +cluster sees almost the same "nearest k" candidates, so the first few +positions processed exhaust that shared candidate set and everyone else in +the cluster fails to match (confirmed: a k=50 KDTree approach matched only +308 of 43181 positives). Bucketing by exact integer key, popping +candidates on use, and expanding search radius only when a bucket is +empty has no such collision problem and is O(1) amortized per positive. + +--exact-cols vs --radius-cols, NOT a single shared radius across all match +columns: a first version used one Chebyshev radius across both +attackerarmyscaler (range 0-31) and minorsathome (range 0-4) -- "radius <= +2" is tight for army-scaler (~6% of its range) but is HALF of +minorsathome's entire range, so positives kept matching against controls +with a wildly different minorsathome just to satisfy the harder +army-scaler dimension. Confirmed by inspecting matched pairs: mean +minorsathome gap of 1.3 survived a nominally "tight" radius-2 match, +leaving minorsathome as the regression's #1 feature for reasons that were +substantially still the sampling artifact, not real signal. Fix: columns +in --exact-cols must match exactly (no radius search at all -- if a +positive's exact value has no candidates left, it's unmatched, full +stop); only --radius-cols expand outward. minorsathome defaults to exact +since its range is small enough that "close" isn't actually close. +""" +import argparse +import csv +import itertools +import random +import sys +from collections import defaultdict + + +def main(): + ap = argparse.ArgumentParser() + ap.add_argument("features_csv") + ap.add_argument("out_csv") + ap.add_argument("--exact-cols", nargs="+", default=["minorsathome"], + help="Match columns that must match EXACTLY -- no " + "radius search. Use for low-cardinality columns " + "where 'close' isn't actually close.") + ap.add_argument("--radius-cols", nargs="+", default=["attackerarmyscaler"], + help="Match columns allowed to expand outward by " + "--max-radius if no exact match is available.") + ap.add_argument("--max-radius", type=int, default=30, + help="Max Chebyshev distance across --radius-cols to " + "search outward before giving up on a positive") + ap.add_argument("--seed", type=int, default=42) + args = ap.parse_args() + + rng = random.Random(args.seed) + + with open(args.features_csv, newline="") as f: + reader = csv.DictReader(f) + assert reader.fieldnames is not None + fieldnames = list(reader.fieldnames) + rows = list(reader) + + positives = [r for r in rows if r["label"] == "1"] + candidates = [r for r in rows if r["label"] == "0"] + print(f"Loaded {len(positives)} positives, {len(candidates)} control " + f"candidates", file=sys.stderr) + print(f"Exact-match columns: {args.exact_cols}", file=sys.stderr) + print(f"Radius-search columns (max radius {args.max_radius}): " + f"{args.radius_cols}", file=sys.stderr) + + def exact_key_of(r): + return tuple(int(round(float(r[c]))) for c in args.exact_cols) + + def radius_key_of(r): + return tuple(int(round(float(r[c]))) for c in args.radius_cols) + + # Two-level index: exact key -> radius key -> list of candidate rows. + # A positive only ever searches within its own exact-key bucket, and + # within that, expands outward over the radius-cols only. + buckets = defaultdict(lambda: defaultdict(list)) + for r in candidates: + buckets[exact_key_of(r)][radius_key_of(r)].append(r) + for ekey in buckets: + for rkey in buckets[ekey]: + rng.shuffle(buckets[ekey][rkey]) + + ndim = len(args.radius_cols) + + def offsets_at_radius(radius): + if radius == 0: + return [(0,) * ndim] + rng_vals = range(-radius, radius + 1) + return [combo for combo in itertools.product(rng_vals, repeat=ndim) + if max(abs(v) for v in combo) == radius] + + shells = [offsets_at_radius(r) for r in range(args.max_radius + 1)] + + matched_pairs = [] + unmatched = 0 + unmatched_no_exact_bucket = 0 + radius_hist = defaultdict(int) + + for pos_row in positives: + ekey = exact_key_of(pos_row) + exact_bucket = buckets.get(ekey) + if exact_bucket is None: + unmatched += 1 + unmatched_no_exact_bucket += 1 + continue + pkey = radius_key_of(pos_row) + found = None + found_radius = None + for radius, shell in enumerate(shells): + for off in shell: + cand_key = tuple(pkey[i] + off[i] for i in range(ndim)) + cand_list = exact_bucket.get(cand_key) + if cand_list: + found = cand_list.pop() + found_radius = radius + break + if found is not None: + break + if found is None: + unmatched += 1 + continue + radius_hist[found_radius] += 1 + matched_pairs.append((pos_row, found)) + + print(f"Matched {len(matched_pairs)} pairs, {unmatched} positives " + f"left unmatched ({unmatched_no_exact_bucket} had no candidates " + f"at all with that exact-column value, {unmatched - unmatched_no_exact_bucket} " + f"exhausted radius {args.max_radius} on the radius columns)", + file=sys.stderr) + print("Match radius histogram (0 = exact match on everything):", + file=sys.stderr) + for r in sorted(radius_hist): + print(f" radius {r}: {radius_hist[r]}", file=sys.stderr) + + with open(args.out_csv, "w", newline="") as f: + w = csv.DictWriter(f, fieldnames=fieldnames) + w.writeheader() + for pos_row, ctl_row in matched_pairs: + w.writerow(pos_row) + w.writerow(ctl_row) + + print(f"Wrote {2 * len(matched_pairs)} matched rows to {args.out_csv}", + file=sys.stderr) + + import numpy as np + all_cols = args.exact_cols + args.radius_cols + def full_key_of(r): + return tuple(int(round(float(r[c]))) for c in all_cols) + matched_pos_vals = np.array([full_key_of(p) for p, _ in matched_pairs]) + matched_ctl_vals = np.array([full_key_of(c) for _, c in matched_pairs]) + for i, col in enumerate(all_cols): + diff = np.abs(matched_pos_vals[:, i] - matched_ctl_vals[:, i]) + print(f" {col}: mean|diff|={diff.mean():.2f} max|diff|={diff.max():.2f}", + file=sys.stderr) + + +if __name__ == "__main__": + main() |
