Files
coorl-lost-cities/src/lost_cities_jax/match_ppo.py
T
coolguyandClaude Opus 4.8 4d1c4473b6 Restore the privileged critic: its ablation flips with scale
The 39.3M ablation said the privileged critic hurt (switching it off won 0.5160
[0.505, 0.527]). Head to head at 131M, both sides trained identically, it says the
opposite: off *loses*, 0.4633 [0.453, 0.474]. Against league the critic-on model
scores 0.6094 and the critic-off one 0.5526.

The critic earns its keep once there is enough data to fit it -- at 39.3M the
privileged value trunk is underfit and only adds advantage noise. Defaulted back
on, with the small-scale number kept in the docstring as a warning: an ablation at
a budget you do not intend to ship can invert.

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

481 lines
19 KiB
Python

"""PPO over three-round matches.
Three things differ from the single-round trainer beyond the env swap:
**Asymmetric actor-critic.** The critic never plays, so it is fed the hidden
state (opponent's hand, deck in order) while the actor keeps the masked view. A
state-value baseline may condition on anything action-independent without biasing
the policy gradient, and deal luck is exactly what makes a match-terminal reward
hard to learn from. The trunks are separate so the privileged features cannot
reach the logits.
**Both seats train.** In self-play one network picks both players' moves, but the
single-round trainer stop_gradients the opponent seat and throws those plies away
-- half of every game. Here every ply emits a transition for both seats, each
with its own seat-relative view, value and reward; the actor mask keeps the
policy loss on the seat that actually moved, while the critic learns from both.
Folding the seat axis into the batch keeps each seat's GAE chain independent.
**Reward is the match total, paid densely.** Each ply pays the points by which it
moved the running match difference; at gamma=1 that telescopes to the final total
difference, so the objective is exactly the rulebook's -- score more across the
three rounds.
This used to be ``tanh(total / terminal_scale)`` at the end of round three, to
optimise P(win match) rather than expected total. That was over-engineered. The
two differ only in risk attitude, and risk attitude is worth almost nothing here:
a policy told to gamble when it trails by 20 going into round three *loses* to a
greedy clone over 6144 duplicate matches (0.482), and one that gambles only when
it trails by 40 breaks even (0.498). Lost Cities' variance levers -- a marginal
wager, a marginal expedition -- cost more expected margin than the convexity they
buy. Measured ceiling on the whole carry-conditioning idea: under one win-rate
point. The dense linear reward also gives credit assignment every ply instead of
one bounded number per ~160.
``carry`` stays in the observation: it costs nothing, it is the state the
start-player rule keys off, and the critic reads it cleanly.
"""
from __future__ import annotations
import json
import time
from dataclasses import asdict, dataclass
from pathlib import Path
from typing import NamedTuple
import flax.linen as nn
import jax
import jax.numpy as jnp
import optax
from lost_cities_jax.match import (
MatchState,
match_legal_action_mask,
match_reset,
match_score,
match_step,
)
from lost_cities_jax.match_obs import (
MATCH_CRITIC_OBS_DIM,
MATCH_OBS_DIM,
match_critic_observation,
match_observation,
)
from lost_cities_jax.obs import observation
from lost_cities_jax.ppo import (
EpisodeEnd,
JaxPPOConfig,
TrainState,
_append_jsonl,
_broadcast_done,
_create_run_dir,
_write_json,
action_log_prob,
categorical_entropy,
compute_gae,
mask_logits,
masked_mean,
restore_checkpoint,
save_checkpoint,
shaping_coefficient,
)
from lost_cities_jax.types import MAX_STEPS, N_ACTIONS, OBS_DIM, PLAY
N_SEATS = 2
@dataclass
class Ablation:
"""Switches for measuring what each piece of the match stack is worth."""
privileged_critic: bool = True
"""On: the critic also sees the opponent's hand and the deck in order.
Its worth flips with scale, so do not trust a cheap ablation here. Ablated at
39.3M learner actions, switching it OFF *won* 0.5160 [0.505, 0.527] -- the
obvious reading being that a critic fitting V(full state) rather than
E[return | masked obs] hands the actor advantage noise it cannot act on. But
head to head at 131M, both sides trained identically, OFF *loses*: 0.4633
[0.453, 0.474]. The privileged critic earns its keep once there is enough data
to fit it. Defaults on for that reason, and the small-scale result is kept as
a warning about ablating at a budget you do not intend to ship.
"""
both_seats: bool = True
"""Off: the opponent seat's plies are discarded, as the old trainer did."""
match_obs: bool = True
"""Off: the actor gets the plain single-round observation, carry and all the
rest of the match features withheld."""
def actor_obs_dim(self) -> int:
return MATCH_OBS_DIM if self.match_obs else OBS_DIM
def critic_obs_dim(self) -> int:
if not self.privileged_critic:
return self.actor_obs_dim()
return MATCH_CRITIC_OBS_DIM
def label(self) -> str:
parts = []
if self.privileged_critic:
parts.append("privileged_critic")
if not self.both_seats:
parts.append("no-both_seats")
if not self.match_obs:
parts.append("no-match_obs")
return "default" if not parts else "+".join(parts)
FULL = Ablation()
class MatchTransition(NamedTuple):
obs: jax.Array
critic_obs: jax.Array
legal_mask: jax.Array
action: jax.Array
log_prob: jax.Array
value: jax.Array
reward: jax.Array
done: jax.Array
active: jax.Array
actor_mask: jax.Array
entropy: jax.Array
play_action: jax.Array
class MatchActorCritic(nn.Module):
"""Separate trunks, so the critic's privileged input cannot reach the logits."""
hidden_size: int = 512
num_layers: int = 3
@nn.compact
def __call__(self, obs: jax.Array, critic_obs: jax.Array) -> tuple[jax.Array, jax.Array]:
x = obs
for _ in range(self.num_layers):
x = nn.relu(nn.Dense(self.hidden_size)(x))
logits = nn.Dense(N_ACTIONS)(x)
v = critic_obs
for _ in range(self.num_layers):
v = nn.relu(nn.Dense(self.hidden_size)(v))
value = nn.Dense(1)(v)
return logits, jnp.squeeze(value, axis=-1)
def create_match_train_state(
cfg: JaxPPOConfig, rng: jax.Array, ablation: Ablation = FULL
) -> TrainState:
model = MatchActorCritic(cfg.network.hidden_size, cfg.network.num_layers)
params = model.init(
rng,
jnp.zeros((1, ablation.actor_obs_dim()), dtype=jnp.float32),
jnp.zeros((1, ablation.critic_obs_dim()), dtype=jnp.float32),
)
tx = optax.chain(
optax.clip_by_global_norm(cfg.ppo.max_grad_norm),
optax.adam(cfg.ppo.learning_rate),
)
return TrainState.create(apply_fn=model.apply, params=params, tx=tx)
def match_lead(env: MatchState) -> jax.Array:
"""Running match total from seat 0's side, in raw points."""
totals = match_score(env)
return (totals[0] - totals[1]).astype(jnp.float32)
def reset_done_matches(env: MatchState, rng: jax.Array, batch: int) -> MatchState:
fresh = jax.vmap(match_reset)(jax.random.split(rng, batch))
return jax.tree_util.tree_map(
lambda old, new: jnp.where(_broadcast_done(env.done, old), new, old), env, fresh
)
def _seat_views(env: MatchState, seats: jax.Array, ablation: Ablation = FULL):
actor_fn = match_observation if ablation.match_obs else (lambda m, p: observation(m.round, p))
obs = jax.vmap(lambda s: jax.vmap(actor_fn, in_axes=(0, None))(env, s))(seats)
if not ablation.privileged_critic:
return obs, obs
critic = jax.vmap(lambda s: jax.vmap(match_critic_observation, in_axes=(0, None))(env, s))(
seats
)
return obs, critic
def _episode_end(env: MatchState, terminal: jax.Array, episode_return: jax.Array) -> EpisodeEnd:
lead = jax.vmap(match_lead)(env)
opened = jnp.sum(env.round.col_len[:, 0, :] > 0, axis=-1)
return EpisodeEnd(
done=terminal,
episode_return=episode_return,
length=env.round.step_count,
opened_colors=opened,
positive_expeditions=lead.astype(jnp.int32), # final match lead, in points
hit_max_steps=env.round.step_count >= MAX_STEPS,
)
def match_episode_metrics(transitions: MatchTransition, episodes: EpisodeEnd) -> dict:
done = episodes.done.astype(jnp.float32)
completed = jnp.sum(done)
denom = jnp.maximum(completed, 1.0)
actor_count = jnp.sum(transitions.actor_mask)
lead = episodes.positive_expeditions.astype(jnp.float32)
return {
"return_mean": jnp.sum(episodes.episode_return * done) / denom,
"match_lead_mean": jnp.sum(lead * done) / denom,
"match_win_rate": jnp.sum((lead > 0).astype(jnp.float32) * done) / denom,
"final_round_length_mean": jnp.sum(episodes.length.astype(jnp.float32) * done) / denom,
"max_steps_rate": jnp.sum(episodes.hit_max_steps.astype(jnp.float32) * done) / denom,
"play_action_rate": jnp.sum(transitions.play_action) / jnp.maximum(actor_count, 1),
"entropy_mean": masked_mean(
transitions.entropy, transitions.actor_mask.astype(jnp.float32)
),
"learner_actions": actor_count,
"matches_completed": completed,
}
def make_match_rollout_fn(cfg: JaxPPOConfig, ablation: Ablation = FULL):
batch = cfg.ppo.batch_games
seats = jnp.arange(N_SEATS, dtype=jnp.int32)
@jax.jit
def rollout_fn(state: TrainState, env: MatchState, rng: jax.Array, shaping_coef: jax.Array):
def body(carry, _):
env, episode_return, key = carry
key, act_key, reset_key = jax.random.split(key, 3)
obs, critic_obs = _seat_views(env, seats, ablation)
legal = jax.vmap(match_legal_action_mask)(env)
legal_both = jnp.broadcast_to(legal, (N_SEATS, batch, N_ACTIONS))
logits, value = state.apply_fn(state.params, obs, critic_obs)
masked = mask_logits(logits, legal_both)
sampled = jax.random.categorical(act_key, masked, axis=-1).astype(jnp.int32)
log_prob = jax.vmap(action_log_prob)(masked, sampled)
entropy = jax.vmap(categorical_entropy)(masked)
to_move = env.round.to_move.astype(jnp.int32)
active = ~env.done
actor_mask = (seats[:, None] == to_move[None, :]) & active[None, :]
if not ablation.both_seats:
# The old trainer kept one seat and stop_gradiented the other.
keep = (seats[:, None] == 0) & jnp.ones_like(active)[None, :]
actor_mask = actor_mask & keep
actions = jnp.take_along_axis(sampled, to_move[None, :], axis=0)[0]
before = jax.vmap(match_lead)(env)
next_env, _, _ = jax.vmap(match_step, in_axes=(0, 0))(env, actions)
after = jax.vmap(match_lead)(next_env)
done = next_env.done
terminal = active & done
# Every ply pays the points by which it moved the match total. At
# gamma=1 that telescopes to the final total difference, so the
# objective is exactly "score more across the three rounds" -- just
# handed out densely instead of once, 150 plies later.
reward0 = jnp.where(active, (after - before) / cfg.reward.terminal_scale, 0.0)
# Zero-sum: seat 1 sees exactly the negation.
reward = jnp.stack([reward0, -reward0], axis=0)
played = ((actions % 12) // 6 == PLAY)[None, :] & actor_mask
transition = MatchTransition(
obs=obs,
critic_obs=critic_obs,
legal_mask=legal_both,
action=jnp.broadcast_to(actions, (N_SEATS, batch)),
log_prob=log_prob,
value=value,
reward=reward,
done=jnp.broadcast_to(done, (N_SEATS, batch)),
active=(
jnp.broadcast_to(active, (N_SEATS, batch))
if ablation.both_seats
else jnp.broadcast_to(active, (N_SEATS, batch)) & (seats[:, None] == 0)
),
actor_mask=actor_mask,
entropy=entropy,
play_action=played,
)
episode_return = episode_return + reward0
episode = _episode_end(next_env, terminal, episode_return)
next_env = reset_done_matches(next_env, reset_key, batch)
episode_return = jnp.where(done, 0.0, episode_return)
return (next_env, episode_return, key), (transition, episode)
init_return = jnp.zeros((batch,), dtype=jnp.float32)
(next_env, _, rng), (transitions, episodes) = jax.lax.scan(
body, (env, init_return, rng), xs=None, length=cfg.ppo.rollout_steps
)
# Fold the seat axis into the batch: each column is then an independent
# episode chain, which is exactly what GAE wants.
transitions = jax.tree_util.tree_map(
lambda x: x.reshape((x.shape[0], N_SEATS * batch, *x.shape[3:])), transitions
)
final_obs, final_critic = _seat_views(next_env, seats, ablation)
_, last_value = state.apply_fn(state.params, final_obs, final_critic)
last_value = last_value.reshape((N_SEATS * batch,))
return next_env, transitions, last_value, match_episode_metrics(transitions, episodes)
return rollout_fn
def match_ppo_update(
state: TrainState,
transitions: MatchTransition,
advantages: jax.Array,
returns: jax.Array,
rng: jax.Array,
cfg: JaxPPOConfig,
) -> tuple[TrainState, dict[str, jax.Array]]:
batch_size = int(transitions.reward.size)
minibatch_size = batch_size // cfg.ppo.minibatches
flat = jax.tree_util.tree_map(lambda x: x.reshape((-1, *x.shape[2:])), transitions)
flat_advantages = advantages.reshape((batch_size,))
flat_returns = returns.reshape((batch_size,))
actor_mask = flat.actor_mask.astype(jnp.float32)
adv_mean = masked_mean(flat_advantages, actor_mask)
adv_std = jnp.sqrt(masked_mean((flat_advantages - adv_mean) ** 2, actor_mask) + 1.0e-8)
flat_advantages = (flat_advantages - adv_mean) / adv_std
def loss_fn(params, mb, mb_adv, mb_returns):
logits, value = state.apply_fn(params, mb.obs, mb.critic_obs)
masked_logits = mask_logits(logits, mb.legal_mask)
log_prob = action_log_prob(masked_logits, mb.action)
entropy = categorical_entropy(masked_logits)
ratio = jnp.exp(log_prob - mb.log_prob)
actor_weight = mb.actor_mask.astype(jnp.float32)
active_weight = mb.active.astype(jnp.float32)
unclipped = ratio * mb_adv
clipped = jnp.clip(ratio, 1.0 - cfg.ppo.clip_epsilon, 1.0 + cfg.ppo.clip_epsilon) * mb_adv
actor_loss = -masked_mean(jnp.minimum(unclipped, clipped), actor_weight)
value_loss = masked_mean((mb_returns - value) ** 2, active_weight)
entropy_loss = masked_mean(entropy, actor_weight)
loss = actor_loss + cfg.ppo.value_coef * value_loss - cfg.ppo.entropy_coef * entropy_loss
return loss, {
"loss": loss,
"actor_loss": actor_loss,
"value_loss": value_loss,
"entropy_loss": entropy_loss,
"approx_kl": masked_mean(mb.log_prob - log_prob, actor_weight),
}
def epoch_update(carry, key):
train_state = carry
permutation = jax.random.permutation(key, batch_size)
def minibatch_update(carry, idx):
train_state, accum = carry
mb_idx = jax.lax.dynamic_slice(permutation, (idx * minibatch_size,), (minibatch_size,))
mb = jax.tree_util.tree_map(lambda x: x[mb_idx], flat)
(_, metrics), grads = jax.value_and_grad(loss_fn, has_aux=True)(
train_state.params, mb, flat_advantages[mb_idx], flat_returns[mb_idx]
)
train_state = train_state.apply_gradients(grads=grads)
accum = jax.tree_util.tree_map(lambda a, b: a + b, accum, metrics)
return (train_state, accum), None
zeros = {
key: jnp.array(0.0)
for key in ("loss", "actor_loss", "value_loss", "entropy_loss", "approx_kl")
}
(train_state, metrics), _ = jax.lax.scan(
minibatch_update, (train_state, zeros), jnp.arange(cfg.ppo.minibatches)
)
metrics = jax.tree_util.tree_map(lambda x: x / cfg.ppo.minibatches, metrics)
return train_state, metrics
keys = jax.random.split(rng, cfg.ppo.epochs)
state, metrics = jax.lax.scan(epoch_update, state, keys)
return state, jax.tree_util.tree_map(lambda x: jnp.mean(x, axis=0), metrics)
def make_match_train_iteration(cfg: JaxPPOConfig, ablation: Ablation = FULL):
rollout_fn = make_match_rollout_fn(cfg, ablation)
@jax.jit
def train_iteration(
state: TrainState, env: MatchState, rng: jax.Array, shaping_coef: jax.Array
):
rng, rollout_key, update_key = jax.random.split(rng, 3)
env, transitions, last_value, stats = rollout_fn(state, env, rollout_key, shaping_coef)
advantages, returns = compute_gae(
transitions.reward,
transitions.value,
transitions.done,
cfg.ppo.gamma,
cfg.ppo.gae_lambda,
last_value,
)
state, update_metrics = match_ppo_update(
state, transitions, advantages, returns, update_key, cfg
)
return state, env, rng, {**stats, **update_metrics}
return train_iteration
def match_train(cfg: JaxPPOConfig, *, resume: str | None = None, ablation: Ablation = FULL) -> Path:
run_dir = _create_run_dir(cfg)
_write_json(run_dir / "config.json", asdict(cfg))
metrics_path = run_dir / "metrics.jsonl"
rng = jax.random.PRNGKey(cfg.run.seed)
rng, init_key, reset_key = jax.random.split(rng, 3)
state = create_match_train_state(cfg, init_key, ablation)
if resume:
state = restore_checkpoint(Path(resume), state)
env = jax.jit(jax.vmap(match_reset))(jax.random.split(reset_key, cfg.ppo.batch_games))
train_iteration = make_match_train_iteration(cfg, ablation)
start = time.perf_counter()
learner_actions_seen = 0
for update in range(cfg.run.total_updates):
shaping_coef = jnp.asarray(shaping_coefficient(cfg, learner_actions_seen), jnp.float32)
iter_start = time.perf_counter()
state, env, rng, metrics = train_iteration(state, env, rng, shaping_coef)
jax.tree_util.tree_leaves(metrics)[0].block_until_ready()
row = {
key: float(value) if getattr(value, "shape", ()) == () else value.tolist()
for key, value in metrics.items()
}
learner_actions_seen += int(row["learner_actions"])
row.update(
update=update,
shaping_coef=float(shaping_coef),
learner_actions_total=learner_actions_seen,
iteration_seconds=time.perf_counter() - iter_start,
elapsed_seconds=time.perf_counter() - start,
)
_append_jsonl(metrics_path, row)
if update % cfg.run.log_every == 0:
print(
json.dumps(
{
"update": update,
"match_win_rate": row["match_win_rate"],
"match_lead_mean": row["match_lead_mean"],
"matches_completed": row["matches_completed"],
"entropy_mean": row["entropy_mean"],
},
sort_keys=True,
),
flush=True,
)
if cfg.run.checkpoint_every and (update + 1) % cfg.run.checkpoint_every == 0:
save_checkpoint(run_dir / "latest", state, cfg)
save_checkpoint(run_dir / "latest", state, cfg)
return run_dir