Download scripts/lora_selfplay_finetune.py from alextoti1/WorldModel-ConnectX: direct link, hf CLI and curl.
- Browser
- Download file 6.01 kB
-
https://huggingface.co/alextoti1/WorldModel-ConnectX/resolve/main/scripts/lora_selfplay_finetune.py
- Command line
-
hf download hf://alextoti1/WorldModel-ConnectX/scripts/lora_selfplay_finetune.py
-
curl -L -o lora_selfplay_finetune.py https://huggingface.co/alextoti1/WorldModel-ConnectX/resolve/main/scripts/lora_selfplay_finetune.py
6.01 kB
| """ | |
| LoRA self-play fine-tune of an ALREADY-TRAINED checkpoint's value head -- | |
| this is the step that actually produced the deployed checkpoint, not a | |
| fresh training run. Starts from the real, working checkpoint (encoder/ | |
| dynamics/decoder frozen, matching `train.py`'s own self-play stage, which | |
| never touches them either) and fine-tunes the value head under self-play | |
| through a LoRA-constrained delta rather than an unconstrained further Adam | |
| update -- directly tests whether constraining HOW MUCH the value head can | |
| move (not just how much data/how many rounds) helps it absorb self-play | |
| signal without regressing calibration. | |
| `opponent_strong_epsilon` (the curriculum variant): mixes the stronger | |
| 1-ply-deeper heuristic into training ALONGSIDE the self-play snapshots and | |
| the original weak heuristic -- without this, self-play rounds only ever | |
| train against [self-play snapshots, weak heuristic], and never the harder | |
| fixed opponent at all. This is what produced this project's best-confirmed | |
| result (see the whitepaper's results table). | |
| rank=4/alpha=4.0: found to work well for a value head elsewhere in this | |
| project's development on this same architecture; not independently | |
| re-tuned for ConnectX specifically. | |
| """ | |
| import copy | |
| import random | |
| import torch | |
| from connectx.env import ConnectXEnv | |
| from scripts.train import make_selfplay_pool_opponent_fn, random_baseline_win_rate | |
| from connectx.verifier import train_mc_value_onpolicy | |
| from connectx.lora import LoRALinear, apply_lora | |
| from connectx.search import load_checkpoint, evaluate | |
| def _merge_and_unwrap(module): | |
| """Merge every LoRALinear's delta into its frozen base weight, then | |
| replace the wrapper with the plain (now-merged) nn.Linear -- so the | |
| saved checkpoint is an ORDINARY WorldModel state_dict, loadable by | |
| every existing caller with zero LoRA-awareness needed downstream.""" | |
| for name, child in list(module.named_children()): | |
| if isinstance(child, LoRALinear): | |
| child.merge_into_base() | |
| setattr(module, name, child.linear) | |
| else: | |
| _merge_and_unwrap(child) | |
| def main(ckpt_path="checkpoints/connectx_checkpoint.pt", seed=0, rank=4, alpha=4.0, | |
| selfplay_rounds=5, selfplay_epsilon=0.4, selfplay_mc_rounds_per_iter=15, | |
| mc_problems_per_round=400, selfplay_pool_size=5, | |
| opponent_epsilon=0.15, opponent_strong_epsilon=0.0, | |
| save_path="checkpoints/connectx_checkpoint_lora_selfplay.pt"): | |
| raw_ckpt = torch.load(ckpt_path, map_location="cpu", weights_only=False) | |
| model, normalizer = load_checkpoint(ckpt_path) | |
| rng = random.Random(seed) | |
| torch.manual_seed(seed) | |
| random.seed(seed) # env.py's opponent stochasticity reads the GLOBAL | |
| # random module, not this local rng -- see train.py's own note | |
| env = ConnectXEnv(width=raw_ckpt["board_width"], height=raw_ckpt["board_height"], | |
| win_len=raw_ckpt["win_len"]) | |
| max_steps = (env.width * env.height) // 2 + 2 | |
| lora_params = apply_lora(model.value, rank=rank, alpha=alpha) | |
| n_lora = sum(p.numel() for p in lora_params) | |
| n_frozen = sum(p.numel() for p in model.value.parameters()) - n_lora | |
| print(f"LoRA rank={rank} alpha={alpha} on model.value: " | |
| f"{n_lora} trainable params, {n_frozen} frozen (base value head)") | |
| 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 LoRA fine-tune round {sp_round + 1}/{selfplay_rounds} " | |
| f"(opponent_selfplay_epsilon={selfplay_epsilon}, pool_size={len(frozen_pool)})...") | |
| opponent_env = ConnectXEnv(width=env.width, height=env.height, win_len=env.win_len) | |
| selfplay_fn = make_selfplay_pool_opponent_fn(frozen_pool, normalizer, opponent_env) | |
| selfplay_train_env = ConnectXEnv(width=env.width, height=env.height, win_len=env.win_len, | |
| opponent_epsilon=opponent_epsilon, | |
| opponent_strong_epsilon=opponent_strong_epsilon, | |
| opponent_selfplay_epsilon=selfplay_epsilon, | |
| opponent_policy_fn=selfplay_fn) | |
| # Adam(model.value.parameters()) inside train_mc_value_onpolicy | |
| # naturally trains ONLY the LoRA deltas here: the wrapped base | |
| # linears have requires_grad=False (set by apply_lora), so their | |
| # .grad stays None and Adam's step() skips them. | |
| 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) | |
| _merge_and_unwrap(model.value) | |
| torch.save({ | |
| "model_state": model.state_dict(), | |
| "norm_mean": normalizer.mean.cpu(), | |
| "norm_std": normalizer.std.cpu(), | |
| "state_dim": raw_ckpt["state_dim"], | |
| "num_actions": raw_ckpt["num_actions"], | |
| "latent_dim": raw_ckpt["latent_dim"], | |
| "hidden_dim": raw_ckpt["hidden_dim"], | |
| "board_width": env.width, | |
| "board_height": env.height, | |
| "win_len": env.win_len, | |
| }, save_path) | |
| print(f"\nSaved LoRA-self-play-fine-tuned checkpoint to {save_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)") | |
| if __name__ == "__main__": | |
| main() | |