Files
coorl-lost-cities/scripts/ablate_match.py
T
coolguyandClaude Opus 4.8 1f51fb8149 Ablate the match stack: both-seat training carries it, the privileged critic hurts
Each piece switched off in turn, trained at identical compute, then played against
the full stack over 8192 duplicate matches. Below 0.5 means the removed piece was
doing work.

- both seats: 0.3317 [0.322, 0.342]. The biggest single contributor. Half of it is
  simply sample count -- dropping the opponent seat halves the learner actions per
  update -- but that is the point: self-play already produced those plies with the
  same network, and the old trainer stop_gradiented them away.
- match observation: 0.4751 [0.464, 0.486]. Small but real. Since carry itself
  contributes almost nothing (rounds decompose), most of this is likely the
  single-round observation defects being fixed: to_move, the deck clock, and the
  score_diff scale.
- privileged critic: 0.5160 [0.505, 0.527] -- turning it OFF makes the agent
  significantly STRONGER. Fable called this the biggest missing idea; it is wrong.
  A critic that knows the deck fits V(full state), which is not
  E[return | masked obs], so the advantage picks up a component the actor cannot
  act on. From the actor's side that is noise, not variance reduction. Asymmetric
  critics hurting under partial observability is a known failure mode.

Defaulted off accordingly. (Reusing it as a PIMC leaf evaluator may still stand --
that is a separate claim from using it to train the policy.)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XBQKgvBbxbheiTF1AVy1Sh
2026-07-15 03:49:24 +09:00

149 lines
5.2 KiB
Python

#!/usr/bin/env python3
"""What is each piece of the match stack worth?
Train one variant per switch at identical compute, then play each against the full
stack in duplicate matches -- same three deals, same coins, both seats -- so deal
luck cancels and only the policy difference is left.
The variants do not share an observation shape (dropping the privileged critic or
the match features changes the input dims), so this carries its own head-to-head
rather than reusing match_eval's, which assumes one architecture.
"""
from __future__ import annotations
import json
from pathlib import Path
import jax
import jax.numpy as jnp
import numpy as np
from lost_cities_jax.match import (
match_legal_action_mask,
match_reset_from,
match_score,
match_step,
)
from lost_cities_jax.match_eval import MATCH_SCAN_STEPS, _wilson, match_bank
from lost_cities_jax.match_ppo import (
Ablation,
MatchActorCritic,
_seat_views,
create_match_train_state,
match_train,
)
from lost_cities_jax.ppo import load_config, mask_logits, restore_checkpoint
VARIANTS = [
Ablation(privileged_critic=False),
Ablation(both_seats=False),
Ablation(match_obs=False),
]
MATCHES = 4096
FULL_CHECKPOINT = Path("runs/jax-ppo-match/2026-07-15_030152_match-linear/latest")
def _load(cfg, checkpoint: Path, ablation: Ablation):
state = create_match_train_state(cfg, jax.random.PRNGKey(0), ablation)
return restore_checkpoint(checkpoint, state).params
def _existing_run(label: str) -> Path | None:
runs = sorted(Path("runs/jax-ppo-match").glob(f"*ablate-{label}"))
for run in reversed(runs):
if (run / "latest").exists():
return run
return None
def duel(cfg, params_a, ablation_a, params_b, ablation_b, *, matches: int) -> dict:
"""A vs B over duplicate matches. Each side sees the world its own way."""
decks, coins = match_bank(20260719, matches)
model = MatchActorCritic(cfg.network.hidden_size, cfg.network.num_layers)
seats = jnp.arange(2, dtype=jnp.int32)
@jax.jit
def run(env, a_seat):
def body(carry, _):
env, _unused = carry
to_move = env.round.to_move.astype(jnp.int32)
mask = jax.vmap(match_legal_action_mask)(env)
def act(params, ablation):
obs, critic = _seat_views(env, seats, ablation)
idx = to_move[None, :, None]
seat_obs = jnp.take_along_axis(obs, idx, axis=0)[0]
seat_critic = jnp.take_along_axis(critic, idx, axis=0)[0]
logits, _ = model.apply(params, seat_obs, seat_critic)
return jnp.argmax(mask_logits(logits, mask), axis=-1).astype(jnp.int32)
action = jnp.where(
to_move == a_seat, act(params_a, ablation_a), act(params_b, ablation_b)
)
env, _, _ = jax.vmap(match_step, in_axes=(0, 0))(env, action)
return (env, _unused), None
(env, _), _ = jax.lax.scan(body, (env, jnp.int32(0)), xs=None, length=MATCH_SCAN_STEPS)
return env
leads = []
for seat in (0, 1):
env = jax.vmap(match_reset_from)(decks, coins)
final = run(env, jnp.full((matches,), seat, dtype=jnp.int32))
totals = np.asarray(jax.vmap(match_score)(final))
leads.append(totals[:, seat] - totals[:, 1 - seat])
lead = np.concatenate(leads)
games = float(lead.size)
wins = float((lead > 0).sum())
low, high = _wilson(wins, games)
return {
"matches": games,
"a_win_rate": wins / games,
"wilson_low": low,
"wilson_high": high,
"a_mean_lead": float(lead.mean()),
}
def main() -> None:
cfg = load_config("configs/jax_ppo/match-selfplay.yaml")
full = _load(cfg, FULL_CHECKPOINT, Ablation())
rows = []
for ablation in VARIANTS:
label = ablation.label()
run_dir = _existing_run(label)
if run_dir is None:
print(f"\n===== training {label} =====", flush=True)
cfg = load_config("configs/jax_ppo/match-selfplay.yaml")
cfg.run.experiment_name = f"ablate-{label}"
run_dir = match_train(cfg, ablation=ablation)
else:
print(f"\n===== reusing {run_dir} =====", flush=True)
cfg = load_config("configs/jax_ppo/match-selfplay.yaml")
params = _load(cfg, run_dir / "latest", ablation)
# The ablated net plays seat A, the full stack answers. Below 0.5 means
# the piece we removed was carrying weight.
result = duel(cfg, params, ablation, full, Ablation(), matches=MATCHES)
rows.append({"ablation": label, **result})
print(f" {label}: win rate vs full = {result['a_win_rate']:.4f}", flush=True)
print("\n\n==================== ablation ====================")
print(f"{'removed':<26}{'win rate vs full':>18}{'95% CI':>22}{'mean lead':>12}")
print("-" * 78)
for row in rows:
ci = f"[{row['wilson_low']:.3f}, {row['wilson_high']:.3f}]"
print(
f"{row['ablation']:<26}{row['a_win_rate']:>18.4f}{ci:>22}{row['a_mean_lead']:>+12.1f}"
)
print("\nbelow 0.5 = the removed piece was carrying weight")
Path("runs/jax-ppo-match/ablation.json").write_text(json.dumps(rows, indent=2, sort_keys=True))
if __name__ == "__main__":
main()