| """ |
| Trains a WorldModel checkpoint on the real Kaggle ConnectX board (7x6, |
| win_len=4). No oracle exists at this scale (see env.py's BFS_MAX_CELLS), so |
| this is a genuinely no-oracle recipe throughout: |
| |
| 1. Stage 1 (dynamics + decoder): self-supervised, `train_utils.train_stage1` |
| on random transitions/rollouts -- never depends on an oracle for any |
| domain. |
| 2. Stage 2 (value head): `verifier.train_mc_value_onpolicy`, on-policy |
| Monte Carlo, `unsolved_penalty=max_steps` -- this is an adversarial |
| domain, where a training walk can end in a LOSS, not just run out of |
| steps; without this penalty every losing walk is silently discarded and |
| the value head never learns to avoid losing moves at all (confirmed as |
| the direct cause of a real 0%, worse-than-random win rate before this |
| was added). |
| 3. Self-play fine-tune: each round freezes the current model as an |
| opponent (viewed from the opponent's side, via `_swap_agent_opponent`) |
| and trains further against a small, capped POOL of past frozen |
| snapshots (not just the latest one) mixed with the original fixed |
| heuristic -- every opponent used above is fixed and non-learning, which |
| is the actual ceiling on how strong a non-self-play policy can get. |
| |
| Evaluation has no oracle-ceiling comparison to report against (none exists |
| at this scale) -- instead: win rate against the ORIGINAL fixed opponent |
| (deterministic, `opponent_epsilon=0`) the model is ultimately graded |
| against, plus a random-legal-play win rate as the required floor. |
| """ |
| import copy |
| import random |
|
|
| import torch |
|
|
| from connectx.env import ConnectXEnv, AGENT, OPPONENT, EMPTY, _encode_board |
| from connectx.model import WorldModel |
| from connectx.train_utils import DEVICE, StateNormalizer, generate_transitions, generate_rollout_sequences, \ |
| train_stage1, eval_stage1, eval_multistep_rollout |
| from connectx.verifier import train_mc_value_onpolicy |
| from connectx.search import evaluate |
| from connectx.adversarial_search import real_adversarial_plan_action |
|
|
|
|
| def _swap_agent_opponent(cells): |
| """AGENT<->OPPONENT relabeling, EMPTY unchanged -- lets a model that |
| was only ever trained to play as AGENT evaluate a position from the |
| OTHER side's perspective, by pretending that side is AGENT instead.""" |
| return [c if c == EMPTY else (OPPONENT if c == AGENT else AGENT) for c in cells] |
|
|
|
|
| def make_selfplay_pool_opponent_fn(frozen_pool, frozen_normalizer, opponent_env): |
| """Builds an `opponent_policy_fn(cells) -> column` (see env.py's |
| `opponent_policy_fn` extension point) that plays using a frozen |
| snapshot of this same architecture's own trained judgment, not a |
| hand-written heuristic. Each call picks a snapshot uniformly at random |
| from `frozen_pool` (a small population of past snapshots, not just the |
| latest one -- a coarse approximation of real population-based self- |
| play/fictitious play, so the live policy can't narrowly overfit to |
| counter-play against whatever the single latest snapshot happens to |
| do) and uses the real adversarial search (rounds=1 -- this function is |
| called on the order of 100K+ times across a training run, so a slower |
| multi-round search would balloon total training time).""" |
|
|
| def opponent_policy_fn(cells): |
| frozen_model = random.choice(frozen_pool) |
| swapped_state = tuple(_encode_board(_swap_agent_opponent(cells))) |
| with torch.no_grad(): |
| a = real_adversarial_plan_action(opponent_env, frozen_model, frozen_normalizer, swapped_state, rounds=1) |
| return a |
|
|
| return opponent_policy_fn |
|
|
|
|
| def random_baseline_win_rate(env, problems, max_steps, rng): |
| """Required control: an agent picking uniformly among its legal |
| non-PASS columns. The floor the trained model needs to beat -- not a |
| certified ceiling (no oracle exists at this scale), just the honest |
| "did training do anything at all" check.""" |
| wins = 0 |
| for state, _ in problems: |
| cur = state |
| for _ in range(max_steps): |
| if env.is_solved(cur): |
| break |
| legal = [a for a in range(env.num_actions) if env.is_legal(cur, a)] |
| non_pass = [a for a in legal if a != env.width] |
| a = rng.choice(non_pass or legal) |
| cur, _r, done = env.step(cur, a) |
| if done: |
| break |
| if env.is_solved(cur): |
| wins += 1 |
| n = len(problems) |
| print(f"{'Random-legal-play baseline':30s} win_rate={wins/n:.3f} ({wins}/{n})") |
| return wins / n |
|
|
|
|
| def main(seed=0, ckpt_path="checkpoints/connectx_checkpoint.pt", latent_dim=96, hidden_dim=256, |
| n_problems=2000, walk_len=8, mc_rounds=25, mc_problems_per_round=400, |
| opponent_epsilon=0.15, opponent_strong_epsilon=0.0, |
| selfplay_rounds=5, selfplay_epsilon=0.4, selfplay_mc_rounds_per_iter=15, |
| selfplay_pool_size=5): |
| |
| |
| |
| |
| |
| |
| |
| |
| env = ConnectXEnv(width=7, height=6, win_len=4) |
| train_env = ConnectXEnv(width=7, height=6, win_len=4, opponent_epsilon=opponent_epsilon, |
| opponent_strong_epsilon=opponent_strong_epsilon) |
| rng = random.Random(seed) |
| torch.manual_seed(seed) |
| |
| |
| |
| |
| |
| random.seed(seed) |
| max_steps = (env.width * env.height) // 2 + 2 |
|
|
| print(f"Domain: ConnectX (real board), {env.width}x{env.height}, win_len={env.win_len}, " |
| f"num_actions={env.num_actions}, state_dim={env.state_dim}, " |
| f"train opponent_epsilon={opponent_epsilon}, opponent_strong_epsilon={opponent_strong_epsilon}\n") |
| assert env.bfs_solve(env.random_problem(rng)[0]) is None, \ |
| "expected no oracle at real-board scale -- see env.py's BFS_MAX_CELLS" |
|
|
| print("Generating stage-1 (dynamics) data...") |
| train_transitions = generate_transitions(train_env, rng, n_problems=n_problems, walk_len=walk_len) |
| val_transitions = generate_transitions(env, rng, n_problems=300, walk_len=walk_len) |
| print(f" {len(train_transitions)} train transitions, {len(val_transitions)} val transitions") |
|
|
| unroll_k = 4 |
| train_sequences = generate_rollout_sequences(train_env, rng, n_problems=n_problems, k=unroll_k) |
| val_sequences = generate_rollout_sequences(env, rng, n_problems=300, k=unroll_k) |
| print(f" {len(train_sequences)} train sequences, {len(val_sequences)} val sequences (k={unroll_k})") |
|
|
| all_states_for_norm = [t[0] for t in train_transitions] + [t[2] for t in train_transitions] |
| normalizer = StateNormalizer(all_states_for_norm).to(DEVICE) |
|
|
| model = WorldModel(env.state_dim, env.num_actions, latent_dim=latent_dim, hidden_dim=hidden_dim).to(DEVICE) |
|
|
| print("\nStage 1: training encoder + dynamics + decoder...") |
| train_stage1(model, normalizer, train_transitions, sequences=train_sequences, k=unroll_k) |
|
|
| print("\nStage-1 val metrics:") |
| print(" ", eval_stage1(model, normalizer, val_transitions)) |
| print(" ", eval_multistep_rollout(model, normalizer, val_sequences, k=unroll_k)) |
|
|
| print("\nStage 2: no-oracle value training (on-policy Monte Carlo, bfs_solve never called)...") |
| train_mc_value_onpolicy(train_env, model, normalizer, rng, n_rounds=mc_rounds, |
| n_problems_per_round=mc_problems_per_round, max_steps=max_steps, |
| unsolved_penalty=max_steps) |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| frozen_pool = [] |
| for sp_round in range(selfplay_rounds): |
| frozen_model = copy.deepcopy(model).eval() |
| for p in frozen_model.parameters(): |
| p.requires_grad_(False) |
| frozen_pool.append(frozen_model) |
| if len(frozen_pool) > selfplay_pool_size: |
| frozen_pool.pop(0) |
| print(f"\nSelf-play fine-tune round {sp_round + 1}/{selfplay_rounds} " |
| f"(opponent_selfplay_epsilon={selfplay_epsilon}, pool_size={len(frozen_pool)})...") |
| opponent_env = ConnectXEnv(width=7, height=6, win_len=4) |
| selfplay_fn = make_selfplay_pool_opponent_fn(frozen_pool, normalizer, opponent_env) |
| selfplay_train_env = ConnectXEnv(width=7, height=6, win_len=4, |
| opponent_epsilon=opponent_epsilon, |
| opponent_selfplay_epsilon=selfplay_epsilon, |
| opponent_policy_fn=selfplay_fn) |
| train_mc_value_onpolicy(selfplay_train_env, model, normalizer, rng, n_rounds=selfplay_mc_rounds_per_iter, |
| n_problems_per_round=mc_problems_per_round, max_steps=max_steps, |
| unsolved_penalty=max_steps) |
|
|
| if ckpt_path: |
| torch.save({ |
| "model_state": model.state_dict(), |
| "norm_mean": normalizer.mean.cpu(), |
| "norm_std": normalizer.std.cpu(), |
| "state_dim": env.state_dim, |
| "num_actions": env.num_actions, |
| "latent_dim": latent_dim, |
| "hidden_dim": hidden_dim, |
| "board_width": env.width, |
| "board_height": env.height, |
| "win_len": env.win_len, |
| }, ckpt_path) |
| print(f"\nSaved checkpoint to {ckpt_path}") |
|
|
| print("\n" + "=" * 20 + " EVALUATION (no oracle -- vs. random-legal-play baseline only) " + "=" * 20) |
| eval_rng = random.Random(999) |
| problems = [env.random_problem(eval_rng) for _ in range(150)] |
| random_baseline_win_rate(env, problems, max_steps, random.Random(1000)) |
| evaluate(env, model, normalizer, problems, depth=1, beam_width=8, max_total_steps=max_steps, |
| label="Baseline A (model, depth=1)") |
| evaluate(env, model, normalizer, problems, depth=3, beam_width=8, max_total_steps=max_steps, |
| label="Search (model, depth=3)") |
|
|
| return model, normalizer |
|
|
|
|
| if __name__ == "__main__": |
| main() |
|
|