Download connectx/verifier.py from alextoti1/WorldModel-ConnectX: direct link, hf CLI and curl.
- Browser
- Download file 6.64 kB
-
https://huggingface.co/alextoti1/WorldModel-ConnectX/resolve/main/connectx/verifier.py
- Command line
-
hf download hf://alextoti1/WorldModel-ConnectX/connectx/verifier.py
-
curl -L -o verifier.py https://huggingface.co/alextoti1/WorldModel-ConnectX/resolve/main/connectx/verifier.py
6.64 kB
| """ | |
| On-policy Monte Carlo value-head training -- no oracle, no bootstrapping, | |
| no target network. Walk the environment with the current value head's own | |
| epsilon-greedy policy, and label every visited state along a walk that | |
| actually reached a solved state with its REALIZED return (what happened, | |
| not a network's own possibly-wrong estimate of what happens next). This is | |
| the only value-training method used for ConnectX, since the real 7x6 board | |
| has no tractable exact solver to regress against. | |
| `unsolved_penalty` is what makes this work for an ADVERSARIAL domain | |
| specifically: `env.step` can return `done=True` on a LOSS (the opponent | |
| won) or a draw, not just our own win. Discarding every one of those walks | |
| (the natural default for a single-agent puzzle, where "unsolved" just means | |
| "further away") would mean the value head never sees a single labeled | |
| example of "this leads to losing" -- exactly the signal an adversarial | |
| domain needs to learn to avoid bad moves. | |
| """ | |
| import collections | |
| import torch | |
| import torch.nn as nn | |
| from .train_utils import states_to_tensor, DEVICE | |
| def _observed_states_to_tensor(env, states): | |
| return states_to_tensor([env.observe(s) for s in states]) | |
| def _greedy_walk_action(env, model, normalizer, state, rng, epsilon): | |
| """Epsilon-greedy action choice using the value head's own 1-step | |
| lookahead, scored the same way `search.plan_action`'s depth=1 does | |
| (`-reward + value`, using the REAL reward/next-state from `env.step`, | |
| not an imagined one) -- so training optimizes for the exact decision | |
| rule actually used at inference.""" | |
| legal = [a for a in range(env.num_actions) if env.is_legal(state, a)] | |
| if rng.random() < epsilon: | |
| return rng.choice(legal) | |
| next_states, rewards = [], [] | |
| for a in legal: | |
| ns, r, _done = env.step(state, a) | |
| next_states.append(ns) | |
| rewards.append(r) | |
| z = model.encode(normalizer.normalize(_observed_states_to_tensor(env, next_states).to(DEVICE))) | |
| values = model.evaluate(z) | |
| rewards_t = torch.tensor(rewards, dtype=torch.float32, device=DEVICE) | |
| scores = -rewards_t + values | |
| return legal[torch.argmin(scores).item()] | |
| def generate_mc_walks(env, model, normalizer, rng, n_problems, max_steps, epsilon, | |
| unsolved_penalty=None, **problem_kwargs): | |
| """Complete walks (up to max_steps) using the current value head's | |
| epsilon-greedy policy. A walk that reaches `is_solved` labels every | |
| visited state with its real steps-remaining. A walk that doesn't | |
| (a loss, a draw, or simply running out of steps) is discarded UNLESS | |
| `unsolved_penalty` is set, in which case every state along it gets that | |
| fixed, uniform label instead -- deliberately not scaled by how early or | |
| late the walk went wrong; a state that's actually fine keeps | |
| reappearing in OTHER (solved) walks too, so its label self-corrects | |
| over many rounds rather than staying pinned to one bad walk's worst | |
| case.""" | |
| labeled = [] | |
| for _ in range(n_problems): | |
| state, _ = env.random_problem(rng, **problem_kwargs) | |
| path_states = [state] | |
| for _ in range(max_steps): | |
| if env.is_solved(state): | |
| break | |
| a = _greedy_walk_action(env, model, normalizer, state, rng, epsilon) | |
| state, _reward, done = env.step(state, a) | |
| path_states.append(state) | |
| if done: | |
| break | |
| if env.is_solved(path_states[-1]): | |
| T = len(path_states) - 1 | |
| for t, s in enumerate(path_states): | |
| labeled.append((s, float(T - t))) | |
| elif unsolved_penalty is not None: | |
| for s in path_states: | |
| labeled.append((s, float(unsolved_penalty))) | |
| return labeled | |
| def train_mc_value_onpolicy(env, model, normalizer, rng, n_rounds=15, n_problems_per_round=400, | |
| max_steps=10, epochs_per_round=40, lr=1e-3, | |
| epsilon_start=1.0, epsilon_end=0.05, warmup_rounds=3, | |
| replay_capacity=20000, min_replay_before_train=30, | |
| unsolved_penalty=None, verbose_every=5, **problem_kwargs): | |
| """`epsilon_start=1.0` + `warmup_rounds`: a freshly-initialized value | |
| head's "greedy" choice is pure noise, which can be WORSE than uniform | |
| random at stumbling into a solved state by chance. Holding epsilon at | |
| 1.0 (pure random walk) for the first `warmup_rounds` guarantees a | |
| baseline solve rate to bootstrap training from, before annealing toward | |
| exploitation.""" | |
| opt = torch.optim.Adam(model.value.parameters(), lr=lr) | |
| buffer = collections.deque(maxlen=replay_capacity) | |
| for round_idx in range(n_rounds): | |
| if round_idx < warmup_rounds: | |
| epsilon = 1.0 | |
| else: | |
| progress = (round_idx - warmup_rounds) / max(1, n_rounds - 1 - warmup_rounds) | |
| epsilon = epsilon_start + (epsilon_end - epsilon_start) * progress | |
| labeled = generate_mc_walks(env, model, normalizer, rng, n_problems_per_round, max_steps, epsilon, | |
| unsolved_penalty=unsolved_penalty, **problem_kwargs) | |
| buffer.extend(labeled) | |
| if len(buffer) < min_replay_before_train: | |
| if verbose_every: | |
| print(f" [mc-onpolicy] round {round_idx+1}/{n_rounds} epsilon={epsilon:.2f} " | |
| f"only {len(buffer)} labeled states so far (need {min_replay_before_train}) -- skipping fit") | |
| continue | |
| all_data = list(buffer) | |
| states_t = _observed_states_to_tensor(env, [s for s, _ in all_data]).to(DEVICE) | |
| returns_t = torch.tensor([r for _, r in all_data], dtype=torch.float32, device=DEVICE) | |
| model.value_target_mean.copy_(returns_t.mean()) | |
| model.value_target_std.copy_(returns_t.std().clamp(min=1e-3)) | |
| returns_norm = (returns_t - model.value_target_mean) / model.value_target_std | |
| with torch.no_grad(): | |
| z_states = model.encode(normalizer.normalize(states_t)) | |
| n = len(all_data) | |
| for _epoch in range(epochs_per_round): | |
| perm = torch.randperm(n, device=DEVICE) | |
| pred_norm = model.value(z_states[perm]) | |
| loss = nn.functional.mse_loss(pred_norm, returns_norm[perm]) | |
| opt.zero_grad() | |
| loss.backward() | |
| opt.step() | |
| if verbose_every and (round_idx + 1) % verbose_every == 0: | |
| print(f" [mc-onpolicy] round {round_idx+1}/{n_rounds} epsilon={epsilon:.2f} " | |
| f"buffer_size={n} loss={loss.item():.4f} return_mean={model.value_target_mean.item():.2f}") | |