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
481 lines
19 KiB
Python
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
|