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/extract_king_features.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/extract_king_features.py')
| -rw-r--r-- | src/eval_tune/extract_king_features.py | 186 |
1 files changed, 186 insertions, 0 deletions
diff --git a/src/eval_tune/extract_king_features.py b/src/eval_tune/extract_king_features.py new file mode 100644 index 0000000..509f823 --- /dev/null +++ b/src/eval_tune/extract_king_features.py @@ -0,0 +1,186 @@ +#!/usr/bin/env python3 +"""Feature extraction driver: takes the mined FENs in mate_training_data.csv +and runs each through the engine's `evalkingdump` command (KING_DEFECT_DUMP +build only) to pull _EvalKing's raw per-defect breakdown, joining it back +onto each row's label/metadata to produce the final regression-ready CSV. + +Sharded across N engine subprocesses (one `script` file each, run in +parallel) since 86k FENs through a single serial engine invocation would +be needlessly slow on this 24-core box -- same reasoning as +shard_mine.py's PGN sharding. + +Each engine invocation processes its shard via `script <file>` containing +alternating `setboard <fen>` / `evalkingdump` lines; evalkingdump always +emits exactly two `KINGDEFECTDUMP color=...` lines (black then white, in +that fixed loop order) per position, unconditionally -- so the Nth pair +of KINGDEFECTDUMP lines in the shard's output corresponds to the Nth row +of that shard's input, and we pick out whichever color each row's +"mated_color_or_sampled_color" column names. +""" +import argparse +import csv +import os +import re +import subprocess +import sys +import time + +KV_RE = re.compile(r"(\S+)=(\S+)") + +# Fixed schema, since an off-board ring square (king near an edge/corner) +# emits only "sqN_offboard=1" while an on-board square emits 9 separate +# keys -- different king positions otherwise produce entirely different +# column sets, which would silently misalign rows if we inferred columns +# from whichever row happened to be parsed first (caught by a per-shard +# "feature key mismatch" warning during testing). +PIECE_NAMES = ("pawn", "minor", "rook", "queen") +PER_SQUARE_ON_BOARD_KEYS = [f"atk_{p}" for p in PIECE_NAMES] + \ + ["undef", "def", "xray", "flight"] +GLOBAL_KEYS = ["filestorm", "filestormopenfile", "filestormpawn", + "queenproximity", "initloc", "countertotal", + "safetyvalue", "attackerarmyscaler", "castled", + "minorsathome", "misscastleshort", "misscastlelong"] + +FEATURE_KEYS = [] +for _sq in range(8): + FEATURE_KEYS.append(f"sq{_sq}_offboard") + for _k in PER_SQUARE_ON_BOARD_KEYS: + FEATURE_KEYS.append(f"sq{_sq}_{_k}") +FEATURE_KEYS += GLOBAL_KEYS + + +def parse_kingdefectdump_line(line): + """Returns (color, {key: value_str}) from a 'KINGDEFECTDUMP color=... ...' + line, normalized to FEATURE_KEYS' fixed schema -- off-board squares get + offboard=1 and all per-square on-board keys defaulted to 0 rather than + left absent.""" + parts = line.strip().split(None, 1) + assert parts[0] == "KINGDEFECTDUMP" + raw_kv = dict(KV_RE.findall(parts[1])) + color = raw_kv.pop("color") + + kv = {} + for sq in range(8): + offboard_key = f"sq{sq}_offboard" + if offboard_key in raw_kv: + kv[offboard_key] = raw_kv[offboard_key] + for k in PER_SQUARE_ON_BOARD_KEYS: + kv[f"sq{sq}_{k}"] = "0" + else: + kv[offboard_key] = "0" + for k in PER_SQUARE_ON_BOARD_KEYS: + full_key = f"sq{sq}_{k}" + kv[full_key] = raw_kv[full_key] + for k in GLOBAL_KEYS: + kv[k] = raw_kv[k] + assert set(kv.keys()) == set(FEATURE_KEYS), \ + f"parsed keys don't match fixed schema: {set(kv) ^ set(FEATURE_KEYS)}" + return color, kv + + +def run_shard(engine_path, rows, shard_dir, shard_idx): + script_path = os.path.join(shard_dir, f"extract_{shard_idx}.script") + out_log = os.path.join(shard_dir, f"extract_{shard_idx}.out") + with open(script_path, "w") as f: + f.write("force\n") + f.write("nopost\n") + for row in rows: + f.write(f"setboard {row['fen']}\n") + f.write("evalkingdump\n") + + cmd = [engine_path, "--batch", "--command", + f"script {script_path}"] + with open(out_log, "w") as logf: + rc = subprocess.run(cmd, stdout=logf, stderr=subprocess.STDOUT).returncode + if rc != 0: + raise RuntimeError(f"shard {shard_idx} engine exited {rc}, see {out_log}") + + dumps = [] + with open(out_log) as f: + for line in f: + if line.startswith("KINGDEFECTDUMP"): + dumps.append(parse_kingdefectdump_line(line)) + + if len(dumps) != 2 * len(rows): + raise RuntimeError( + f"shard {shard_idx}: expected {2 * len(rows)} KINGDEFECTDUMP " + f"lines (2 per row), got {len(dumps)} -- a setboard likely " + f"failed silently and evalkingdump re-used a stale position. " + f"See {out_log}.") + + out_rows = [] + for i, row in enumerate(rows): + black_color, black_kv = dumps[2 * i] + white_color, white_kv = dumps[2 * i + 1] + assert black_color == "black" and white_color == "white", \ + f"row {i}: unexpected color order {black_color}/{white_color}" + target = row["mated_color_or_sampled_color"] + kv = black_kv if target == "black" else white_kv + out_row = dict(row) + out_row.update(kv) + out_rows.append(out_row) + + os.remove(script_path) + os.remove(out_log) + return out_rows + + +def main(): + ap = argparse.ArgumentParser() + ap.add_argument("training_csv") + ap.add_argument("out_csv") + ap.add_argument("--engine", default="../typhoon_kingdump") + ap.add_argument("--num-shards", type=int, default=8) + ap.add_argument("--work-dir", default=None) + args = ap.parse_args() + + engine_path = os.path.abspath(args.engine) + if not os.path.isfile(engine_path): + print(f"Engine not found: {engine_path}", file=sys.stderr) + sys.exit(1) + + with open(args.training_csv, newline="") as f: + rows = list(csv.DictReader(f)) + print(f"Loaded {len(rows)} rows from {args.training_csv}", file=sys.stderr) + + work_dir = args.work_dir or os.path.dirname(os.path.abspath(args.out_csv)) + os.makedirs(work_dir, exist_ok=True) + + n = args.num_shards + shard_size = (len(rows) + n - 1) // n + shards = [rows[i:i + shard_size] for i in range(0, len(rows), shard_size)] + + t0 = time.time() + all_out_rows = [[] for _ in shards] + + import concurrent.futures + with concurrent.futures.ThreadPoolExecutor(max_workers=n) as pool: + futures = {} + for idx, shard_rows in enumerate(shards): + fut = pool.submit(run_shard, engine_path, shard_rows, work_dir, idx) + futures[fut] = idx + for fut in concurrent.futures.as_completed(futures): + idx = futures[fut] + out_rows = fut.result() + all_out_rows[idx] = out_rows + print(f"Shard {idx} done ({len(out_rows)} rows)", file=sys.stderr) + + print(f"All shards done in {time.time() - t0:.1f}s", file=sys.stderr) + + base_cols = ["label", "mated_color_or_sampled_color", "n_back", + "game_index", "control_source"] + fieldnames = base_cols + FEATURE_KEYS + + with open(args.out_csv, "w", newline="") as f: + w = csv.DictWriter(f, fieldnames=fieldnames) + w.writeheader() + for out_rows in all_out_rows: + for row in out_rows: + w.writerow({k: row.get(k) for k in fieldnames}) + + total = sum(len(r) for r in all_out_rows) + print(f"Wrote {total} feature rows to {args.out_csv}", file=sys.stderr) + + +if __name__ == "__main__": + main() |
