PAWN / scripts /compute_theoretical_ceiling.py
thomas-schweich's picture
Fix outcome array dtype in ceiling computation, np.asarray in script
75cf8a6
#!/usr/bin/env python3
"""Compute theoretical maximum top-1 accuracy for random chess play.
Two ceilings computed via Monte Carlo rollouts in the Rust engine:
1. Unconditional: E[1/N_legal] — best accuracy without knowing the outcome.
2. Outcome-conditioned: E[max_m P(m|outcome, history)] — best accuracy when
the outcome token is known. Estimated by playing out random continuations
from each legal move and measuring which outcomes result.
The "adjusted accuracy" normalizes model accuracy against these ceilings:
adjusted = model_accuracy / ceiling
Usage:
uv run python scripts/compute_theoretical_ceiling.py
uv run python scripts/compute_theoretical_ceiling.py --n-games 5000 --rollouts 64
uv run python scripts/compute_theoretical_ceiling.py --model-accuracy 0.070
"""
from __future__ import annotations
import argparse
import json
import time
from pathlib import Path
import numpy as np
import chess_engine as engine
def main():
parser = argparse.ArgumentParser(
description="Compute theoretical accuracy ceilings for random chess"
)
parser.add_argument("--n-games", type=int, default=2000,
help="Number of random games to generate")
parser.add_argument("--rollouts", type=int, default=32,
help="Monte Carlo rollouts per legal move")
parser.add_argument("--sample-rate", type=float, default=0.02,
help="Fraction of positions to sample (1.0=all, 0.02=2%%)")
parser.add_argument("--seed", type=int, default=77777)
parser.add_argument("--output", type=str, default="data/theoretical_ceiling.json")
parser.add_argument("--model-accuracy", type=float, default=None,
help="Model top-1 accuracy to compute adjusted score")
args = parser.parse_args()
output_path = Path(args.output)
output_path.parent.mkdir(parents=True, exist_ok=True)
print(f"Computing theoretical accuracy ceilings")
print(f" Games: {args.n_games:,}")
print(f" Rollouts/move: {args.rollouts}")
print(f" Sample rate: {args.sample_rate:.0%}")
print(f" Seed: {args.seed}")
print()
t0 = time.time()
result = engine.compute_accuracy_ceiling(
n_games=args.n_games,
max_ply=255,
n_rollouts=args.rollouts,
sample_rate=args.sample_rate,
seed=args.seed,
)
elapsed = time.time() - t0
uncond = result["unconditional_ceiling"]
naive_cond = result["naive_conditional_ceiling"]
cond = result["conditional_ceiling"]
boost_naive = naive_cond / uncond if uncond > 0 else 0
boost = cond / uncond if uncond > 0 else 0
print(f"Positions sampled: {result['n_positions']:,}")
print(f"Unconditional ceiling: {uncond:.4f} ({uncond*100:.2f}%)")
print(f"Naive conditional ceiling: {naive_cond:.4f} ({naive_cond*100:.2f}%) {boost_naive:.2f}x")
print(f"MCTS conditional ceiling: {cond:.4f} ({cond*100:.2f}%) {boost:.2f}x")
print(f"Time: {elapsed:.0f}s")
print()
# Per-outcome breakdown
outcomes = np.asarray(result["outcome"])
conditionals = np.asarray(result["conditional"])
naive_conditionals = np.asarray(result["naive_conditional"])
unconditionals = np.asarray(result["unconditional"])
outcome_names = [
"W checkmated", "B checkmated", "Stalemate", "75-move",
"5-fold rep", "Insuff mat", "Ply limit",
]
print("Per-outcome breakdown:")
outcome_data = {}
for oi in range(7):
mask = outcomes == oi
n = int(mask.sum())
if n > 0:
uc = float(unconditionals[mask].mean())
nc = float(naive_conditionals[mask].mean())
cc = float(conditionals[mask].mean())
print(f" {outcome_names[oi]:>12}: uncond={uc:.4f} naive={nc:.4f} "
f"mcts={cc:.4f} (n={n})")
outcome_data[outcome_names[oi]] = {
"unconditional": uc, "naive_conditional": nc,
"conditional": cc, "n_positions": n,
}
print()
# Per-ply-from-end breakdown
plies = np.asarray(result["ply"])
game_lengths = np.asarray(result["game_length"])
plies_from_end = game_lengths - plies
print("Ceiling by distance from game end:")
distance_data = {}
for dist in range(1, 21):
mask = plies_from_end == dist
n = int(mask.sum())
if n > 10:
uc = float(unconditionals[mask].mean())
nc = float(naive_conditionals[mask].mean())
cc = float(conditionals[mask].mean())
bar = "#" * int(cc * 200)
print(f" {dist:>3} plies from end: uncond={uc:.4f} naive={nc:.4f} mcts={cc:.4f} {bar}")
distance_data[dist] = {"unconditional": uc, "naive_conditional": nc, "conditional": cc, "n": n}
print()
# Model adjusted accuracy
if args.model_accuracy is not None:
ma = args.model_accuracy
adj_uncond = ma / uncond if uncond > 0 else 0
adj_naive = ma / naive_cond if naive_cond > 0 else 0
adj_cond = ma / cond if cond > 0 else 0
print(f"Model accuracy: {ma:.4f} ({ma*100:.2f}%)")
print(f" vs unconditional ceiling: {adj_uncond:.1%} of theoretical max")
print(f" vs naive conditional ceiling: {adj_naive:.1%} of theoretical max")
print(f" vs MCTS conditional ceiling: {adj_cond:.1%} of theoretical max")
print()
# Save results
data = {
"unconditional_ceiling": float(uncond),
"naive_conditional_ceiling": float(naive_cond),
"conditional_ceiling": float(cond),
"naive_conditioning_boost": float(boost_naive),
"mcts_conditioning_boost": float(boost),
"n_positions": int(result["n_positions"]),
"n_games": args.n_games,
"n_rollouts": args.rollouts,
"sample_rate": args.sample_rate,
"seed": args.seed,
"elapsed_seconds": elapsed,
"per_outcome": outcome_data,
"per_distance_from_end": {str(k): v for k, v in distance_data.items()},
}
if args.model_accuracy is not None:
data["model_accuracy"] = args.model_accuracy
data["adjusted_vs_unconditional"] = adj_uncond
data["adjusted_vs_naive_conditional"] = adj_naive
data["adjusted_vs_conditional"] = adj_cond
with open(output_path, "w") as f:
json.dump(data, f, indent=2)
print(f"Saved to {output_path}")
if __name__ == "__main__":
main()