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
149 lines
5.2 KiB
Python
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()
|