| |
| """ |
| UGTC-PPO on Procgen Benchmark (hard mode) — 25M environment steps. |
| |
| Default configuration matches the paper: |
| - env: coinrun (hard mode, 500 training levels) |
| - evaluation: 500 unseen levels from level 10,000+ |
| - total timesteps: 25,000,000 |
| - vectorized envs: 64 |
| |
| UGTC hyperparameters (fixed across ALL benchmarks): |
| λ_fast = 0.80 (fast critic GAE lambda) |
| λ_slow = 0.99 (slow ensemble GAE lambda) |
| M = 3 (ensemble size) |
| β = 5.0 (gate temperature) |
| α_EMA = 0.99 (EMA momentum) |
| |
| This script is derived from the benchmark code used in the paper experiments. |
| Results may vary by hardware, random seed, and library version. |
| |
| Requirements: |
| pip install torch procgen gymnasium pandas psutil |
| |
| Usage: |
| python benchmarks/procgen/train_ugtc_ppo_procgen.py |
| python benchmarks/procgen/train_ugtc_ppo_procgen.py --env_name bigfish |
| """ |
|
|
| import json |
| import math |
| import os |
| import random |
| import time |
| import argparse |
| from pathlib import Path |
| from typing import Any, Dict |
|
|
| import numpy as np |
| import torch |
| import torch.nn as nn |
| import torch.optim as optim |
| from torch.distributions.categorical import Categorical |
|
|
| import gymnasium as gym |
|
|
| try: |
| import procgen2 |
| except ImportError: |
| try: |
| import procgen |
| except ImportError: |
| raise ImportError("Install procgen or procgen2: pip install procgen") |
|
|
| try: |
| import pandas as pd |
| HAS_PANDAS = True |
| except ImportError: |
| HAS_PANDAS = False |
|
|
| try: |
| import psutil |
| HAS_PSUTIL = True |
| except ImportError: |
| HAS_PSUTIL = False |
|
|
|
|
| |
|
|
| DEFAULT_CONFIG: Dict[str, Any] = { |
| "env_name": "coinrun", |
| "distribution_mode": "hard", |
| "num_levels": 500, |
| "start_level": 0, |
| "eval_start_level": 10000, |
| "eval_num_levels": 500, |
| "seed": 1, |
| "total_timesteps": 25_000_000, |
| "num_envs": 64, |
| "num_steps": 256, |
| "update_epochs": 3, |
| "num_minibatches": 8, |
| "gamma": 0.999, |
| |
| "gae_lambda_fast": 0.80, |
| "gae_lambda_slow": 0.99, |
| "slow_critics": 3, |
| "uncertainty_beta": 5.0, |
| "running_unc_momentum": 0.99, |
| |
| "clip_coef": 0.2, |
| "ent_coef": 0.01, |
| "vf_coef_fast": 0.5, |
| "vf_coef_slow": 0.5, |
| "max_grad_norm": 0.5, |
| "learning_rate": 5e-4, |
| "anneal_lr": True, |
| "feature_dim": 256, |
| "hidden_size": 64, |
| |
| "eval_freq": 1_000_000, |
| "n_eval_episodes": 20, |
| "log_freq": 10, |
| "save_model": True, |
| "root_dir": "runs_ugtc_ppo_procgen", |
| "device": "cuda" if torch.cuda.is_available() else "cpu", |
| } |
|
|
|
|
| |
|
|
| def set_seed(seed: int): |
| random.seed(seed) |
| np.random.seed(seed) |
| torch.manual_seed(seed) |
| if torch.cuda.is_available(): |
| torch.cuda.manual_seed_all(seed) |
|
|
|
|
| def make_procgen_env(env_name, distribution_mode, start_level, num_levels, seed): |
| for env_id in [f"procgen:procgen-{env_name}-v0", f"procgen-{env_name}-v0"]: |
| try: |
| env = gym.make(env_id, start_level=start_level, num_levels=num_levels, |
| distribution_mode=distribution_mode) |
| try: |
| env.reset(seed=seed) |
| except TypeError: |
| pass |
| return env |
| except Exception: |
| continue |
| raise RuntimeError(f"Cannot create Procgen env '{env_name}'") |
|
|
|
|
| def make_vector_envs(cfg, train=True): |
| start = cfg["start_level"] if train else cfg["eval_start_level"] |
| n_levels = cfg["num_levels"] if train else cfg["eval_num_levels"] |
| n_envs = cfg["num_envs"] if train else 1 |
|
|
| def thunk(rank): |
| def _init(): |
| return make_procgen_env(cfg["env_name"], cfg["distribution_mode"], |
| start_level=start, num_levels=n_levels, |
| seed=cfg["seed"] + rank) |
| return _init |
|
|
| return gym.vector.SyncVectorEnv([thunk(i) for i in range(n_envs)]) |
|
|
|
|
| def obs_to_tensor(obs, device): |
| x = np.asarray(obs) |
| if x.ndim == 3: |
| x = x[None, ...] |
| return torch.as_tensor(np.transpose(x, (0, 3, 1, 2)).astype(np.float32) / 255.0, |
| dtype=torch.float32, device=device) |
|
|
|
|
| |
|
|
| class ResidualBlock(nn.Module): |
| def __init__(self, ch): |
| super().__init__() |
| self.block = nn.Sequential( |
| nn.ReLU(), nn.Conv2d(ch, ch, 3, 1, 1), |
| nn.ReLU(), nn.Conv2d(ch, ch, 3, 1, 1), |
| ) |
|
|
| def forward(self, x): |
| return x + self.block(x) |
|
|
|
|
| class ImpalaBlock(nn.Module): |
| def __init__(self, in_ch, out_ch): |
| super().__init__() |
| self.conv = nn.Conv2d(in_ch, out_ch, 3, 1, 1) |
| self.pool = nn.MaxPool2d(3, 2, 1) |
| self.res = nn.Sequential(ResidualBlock(out_ch), ResidualBlock(out_ch)) |
|
|
| def forward(self, x): |
| return self.res(self.pool(self.conv(x))) |
|
|
|
|
| class VisualEncoder(nn.Module): |
| def __init__(self, feature_dim): |
| super().__init__() |
| self.net = nn.Sequential( |
| ImpalaBlock(3, 16), ImpalaBlock(16, 32), ImpalaBlock(32, 32), |
| nn.ReLU(), nn.Flatten(), |
| ) |
| with torch.no_grad(): |
| flat = self.net(torch.zeros(1, 3, 64, 64)).shape[1] |
| self.proj = nn.Sequential(nn.Linear(flat, feature_dim), nn.ReLU()) |
|
|
| def forward(self, x): |
| return self.proj(self.net(x)) |
|
|
|
|
| class ValueHead(nn.Module): |
| def __init__(self, feature_dim, hidden): |
| super().__init__() |
| self.net = nn.Sequential( |
| nn.Linear(feature_dim, hidden), nn.Tanh(), nn.Linear(hidden, 1), |
| ) |
| for m in self.modules(): |
| if isinstance(m, nn.Linear): |
| nn.init.orthogonal_(m.weight, math.sqrt(2)) |
| nn.init.constant_(m.bias, 0) |
| nn.init.orthogonal_(self.net[-1].weight, 1.0) |
|
|
| def forward(self, feat): |
| return self.net(feat).squeeze(-1) |
|
|
|
|
| class UGTCPPOAgent(nn.Module): |
| def __init__(self, n_actions, feature_dim, hidden, slow_critics): |
| super().__init__() |
| self.encoder = VisualEncoder(feature_dim) |
| self.policy_head = nn.Sequential( |
| nn.Linear(feature_dim, hidden), nn.Tanh(), nn.Linear(hidden, n_actions), |
| ) |
| self.fast_value = ValueHead(feature_dim, hidden) |
| self.slow_values = nn.ModuleList( |
| [ValueHead(feature_dim, hidden) for _ in range(slow_critics)] |
| ) |
| nn.init.orthogonal_(self.policy_head[-1].weight, 0.01) |
|
|
| def get_features(self, obs): |
| return self.encoder(obs) |
|
|
| def get_policy(self, feat): |
| return Categorical(logits=self.policy_head(feat)) |
|
|
| def get_fast_value(self, feat): |
| return self.fast_value(feat) |
|
|
| def get_slow_values(self, feat): |
| return torch.stack([h(feat) for h in self.slow_values], dim=0) |
|
|
| def get_action_and_values(self, obs, action=None): |
| feat = self.get_features(obs) |
| dist = self.get_policy(feat) |
| if action is None: |
| action = dist.sample() |
| return action, dist.log_prob(action), dist.entropy(), self.get_fast_value(feat), self.get_slow_values(feat) |
|
|
|
|
| |
|
|
| def compute_gae(rewards, dones, values, next_value, gamma, gae_lambda): |
| T, N = rewards.shape |
| adv = torch.zeros_like(rewards) |
| gae = torch.zeros(N, device=rewards.device) |
| for t in reversed(range(T)): |
| nxt = next_value if t == T - 1 else values[t + 1] |
| nt = 1.0 - (dones[t] if t == T - 1 else dones[t + 1]) |
| delta = rewards[t] + gamma * nxt * nt - values[t] |
| gae = delta + gamma * gae_lambda * nt * gae |
| adv[t] = gae |
| return adv, adv + values |
|
|
|
|
| |
|
|
| @torch.no_grad() |
| def evaluate(agent, cfg, device): |
| env = make_vector_envs(cfg, train=False) |
| returns = [] |
| for ep in range(cfg["n_eval_episodes"]): |
| obs, _ = env.reset(seed=cfg["seed"] + 100000 + ep) |
| done = np.array([False]) |
| ret = 0.0 |
| while not done[0]: |
| obs_t = obs_to_tensor(obs, device) |
| feat = agent.get_features(obs_t) |
| action = torch.argmax(agent.get_policy(feat).logits, dim=-1) |
| out = env.step(action.cpu().numpy()) |
| obs, reward = out[0], out[1] |
| done = np.logical_or(out[2], out[3]) if len(out) == 5 else out[2] |
| ret += float(reward[0]) |
| returns.append(ret) |
| env.close() |
| return {"eval_mean": float(np.mean(returns)), "eval_std": float(np.std(returns))} |
|
|
|
|
| |
|
|
| def train(cfg: Dict[str, Any]) -> None: |
| set_seed(cfg["seed"]) |
| device = torch.device(cfg["device"]) |
| root = Path(cfg["root_dir"]) |
| root.mkdir(parents=True, exist_ok=True) |
|
|
| envs = make_vector_envs(cfg, train=True) |
| n_actions = int(envs.single_action_space.n) |
| agent = UGTCPPOAgent(n_actions, cfg["feature_dim"], cfg["hidden_size"], cfg["slow_critics"]).to(device) |
| optimizer = optim.Adam(agent.parameters(), lr=cfg["learning_rate"], eps=1e-5) |
|
|
| num_updates = cfg["total_timesteps"] // (cfg["num_envs"] * cfg["num_steps"]) |
| mb_size = (cfg["num_envs"] * cfg["num_steps"]) // cfg["num_minibatches"] |
|
|
| |
| obs_buf = torch.zeros((cfg["num_steps"], cfg["num_envs"], 3, 64, 64), device=device) |
| actions_buf = torch.zeros((cfg["num_steps"], cfg["num_envs"]), dtype=torch.long, device=device) |
| logp_buf = torch.zeros((cfg["num_steps"], cfg["num_envs"]), device=device) |
| rew_buf = torch.zeros((cfg["num_steps"], cfg["num_envs"]), device=device) |
| done_buf = torch.zeros((cfg["num_steps"], cfg["num_envs"]), device=device) |
| fv_buf = torch.zeros((cfg["num_steps"], cfg["num_envs"]), device=device) |
| sv_buf = torch.zeros((cfg["num_steps"], cfg["slow_critics"], cfg["num_envs"]), device=device) |
|
|
| obs_raw, _ = envs.reset(seed=cfg["seed"]) |
| next_obs = obs_to_tensor(obs_raw, device) |
| next_done = torch.zeros(cfg["num_envs"], device=device) |
|
|
| global_step = 0 |
| next_eval = cfg["eval_freq"] |
| running_unc = 1.0 |
| start_time = time.time() |
| train_logs, eval_logs = [], [] |
|
|
| print("=" * 80) |
| print(f"UGTC-PPO | Procgen {cfg['env_name']} | {cfg['distribution_mode']} | {cfg['total_timesteps']:,} steps") |
| print(f"λ_fast={cfg['gae_lambda_fast']} λ_slow={cfg['gae_lambda_slow']} M={cfg['slow_critics']} β={cfg['uncertainty_beta']}") |
| print("=" * 80) |
|
|
| for update in range(1, num_updates + 1): |
| if cfg["anneal_lr"]: |
| frac = 1.0 - (update - 1) / num_updates |
| optimizer.param_groups[0]["lr"] = frac * cfg["learning_rate"] |
|
|
| for step in range(cfg["num_steps"]): |
| global_step += cfg["num_envs"] |
| obs_buf[step] = next_obs |
| done_buf[step] = next_done |
| with torch.no_grad(): |
| action, logp, _, fv, sv = agent.get_action_and_values(next_obs) |
| actions_buf[step], logp_buf[step] = action, logp |
| fv_buf[step], sv_buf[step] = fv, sv |
| out = envs.step(action.cpu().numpy()) |
| obs_raw, reward = out[0], out[1] |
| terminated, truncated = (out[2], out[3]) if len(out) == 5 else (out[2], out[2]) |
| rew_buf[step] = torch.as_tensor(reward, dtype=torch.float32, device=device) |
| next_obs = obs_to_tensor(obs_raw, device) |
| next_done = torch.as_tensor(np.logical_or(terminated, truncated), dtype=torch.float32, device=device) |
|
|
| with torch.no_grad(): |
| feat_next = agent.get_features(next_obs) |
| nfv = agent.get_fast_value(feat_next) |
| nsv = agent.get_slow_values(feat_next).mean(dim=0) |
| sv_mean = sv_buf.mean(dim=1) |
|
|
| adv_fast, ret_fast = compute_gae(rew_buf, done_buf, fv_buf, nfv, cfg["gamma"], cfg["gae_lambda_fast"]) |
| adv_slow, ret_slow = compute_gae(rew_buf, done_buf, sv_mean, nsv, cfg["gamma"], cfg["gae_lambda_slow"]) |
|
|
| sigma = sv_buf.var(dim=1).sqrt() |
| cur_unc = float(sigma.mean().item()) |
| running_unc = cfg["running_unc_momentum"] * running_unc + (1 - cfg["running_unc_momentum"]) * cur_unc |
| gate = torch.sigmoid(-cfg["uncertainty_beta"] * (sigma / (running_unc + 1e-8) - 1.0)) |
| blended_adv = gate * adv_slow + (1.0 - gate) * adv_fast |
|
|
| b_obs = obs_buf.reshape(-1, 3, 64, 64) |
| b_act = actions_buf.reshape(-1) |
| b_lp = logp_buf.reshape(-1) |
| b_fret = ret_fast.reshape(-1) |
| b_sret = ret_slow.reshape(-1) |
| b_adv = blended_adv.reshape(-1) |
| b_sv = sv_buf.permute(0, 2, 1).reshape(-1, cfg["slow_critics"]) |
| b_gate = gate.reshape(-1) |
| b_adv = (b_adv - b_adv.mean()) / (b_adv.std() + 1e-8) |
| inds = np.arange(cfg["num_envs"] * cfg["num_steps"]) |
|
|
| for _ in range(cfg["update_epochs"]): |
| np.random.shuffle(inds) |
| for start in range(0, len(inds), mb_size): |
| mb = inds[start:start + mb_size] |
| feat = agent.get_features(b_obs[mb]) |
| dist = agent.get_policy(feat) |
| new_lp = dist.log_prob(b_act[mb]) |
| entropy = dist.entropy() |
| fv_new = agent.get_fast_value(feat) |
| sv_new = agent.get_slow_values(feat).transpose(0, 1) |
|
|
| ratio = (new_lp - b_lp[mb]).exp() |
| mb_adv = b_adv[mb] |
| pg_loss = torch.max( |
| -mb_adv * ratio, |
| -mb_adv * torch.clamp(ratio, 1 - cfg["clip_coef"], 1 + cfg["clip_coef"]) |
| ).mean() |
| fv_loss = 0.5 * ((fv_new - b_fret[mb]) ** 2).mean() |
| sv_loss = 0.5 * ((sv_new - b_sret[mb].unsqueeze(-1)) ** 2).mean() |
| loss = pg_loss - cfg["ent_coef"] * entropy.mean() + cfg["vf_coef_fast"] * fv_loss + cfg["vf_coef_slow"] * sv_loss |
| optimizer.zero_grad(set_to_none=True) |
| loss.backward() |
| nn.utils.clip_grad_norm_(agent.parameters(), cfg["max_grad_norm"]) |
| optimizer.step() |
|
|
| if update % cfg["log_freq"] == 0: |
| elapsed = time.time() - start_time |
| log = { |
| "update": update, "timesteps": global_step, |
| "mean_gate": float(b_gate.mean().item()), |
| "running_unc": running_unc, |
| "sps": int(global_step / max(elapsed, 1e-9)), |
| } |
| train_logs.append(log) |
| print(f"[{update:5d}/{num_updates}] steps={global_step:>9,} gate={log['mean_gate']:.3f} unc={running_unc:.4f} sps={log['sps']}") |
|
|
| if global_step >= next_eval or update == num_updates: |
| ev = evaluate(agent, cfg, device) |
| eval_logs.append({"timesteps": global_step, **ev}) |
| print(f" ► EVAL mean={ev['eval_mean']:.3f} std={ev['eval_std']:.3f}") |
| next_eval += cfg["eval_freq"] |
|
|
| envs.close() |
|
|
| if HAS_PANDAS: |
| pd.DataFrame(eval_logs).to_csv(root / "evals.csv", index=False) |
| pd.DataFrame(train_logs).to_csv(root / "train.csv", index=False) |
|
|
| summary = { |
| "algo": "UGTC-PPO", "env_name": cfg["env_name"], |
| "lambda_fast": cfg["gae_lambda_fast"], "lambda_slow": cfg["gae_lambda_slow"], |
| "M": cfg["slow_critics"], "beta": cfg["uncertainty_beta"], |
| "total_timesteps": global_step, |
| "best_eval": max((e["eval_mean"] for e in eval_logs), default=float("nan")), |
| "elapsed_sec": time.time() - start_time, |
| } |
| with open(root / "summary.json", "w") as f: |
| json.dump(summary, f, indent=2) |
|
|
| if cfg["save_model"]: |
| torch.save({"state_dict": agent.state_dict(), "config": cfg}, root / "model.pt") |
|
|
| print("\nTraining complete.") |
| print(f" Best eval return: {summary['best_eval']:.3f}") |
| print(f" Results saved to: {root}/") |
|
|
|
|
| if __name__ == "__main__": |
| parser = argparse.ArgumentParser() |
| parser.add_argument("--env_name", type=str, default="coinrun") |
| parser.add_argument("--seed", type=int, default=1) |
| parser.add_argument("--total_timesteps", type=int, default=25_000_000) |
| parser.add_argument("--device", type=str, default="cuda" if torch.cuda.is_available() else "cpu") |
| args = parser.parse_args() |
| cfg = {**DEFAULT_CONFIG, **vars(args)} |
| train(cfg) |
|
|