ugtc / benchmarks /procgen /train_ugtc_ppo_procgen.py
Ekrem-the-second's picture
Initial release: UGTC - Uncertainty-Gated Temporal Credit
d92d8cf verified
Raw
History Blame Contribute Delete
17.4 kB
#!/usr/bin/env python3
"""
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 # noqa
except ImportError:
try:
import procgen # noqa
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 ────────────────────────────────────────────────────────────
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,
# UGTC hyperparameters (fixed across all benchmarks)
"gae_lambda_fast": 0.80,
"gae_lambda_slow": 0.99,
"slow_critics": 3,
"uncertainty_beta": 5.0,
"running_unc_momentum": 0.99,
# PPO hyperparameters
"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 / logging
"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",
}
# ── Utils ─────────────────────────────────────────────────────────────────────
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)
# ── Model ─────────────────────────────────────────────────────────────────────
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) # [M, B]
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)
# ── GAE ───────────────────────────────────────────────────────────────────────
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
# ── Eval ──────────────────────────────────────────────────────────────────────
@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))}
# ── Train ─────────────────────────────────────────────────────────────────────
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"]
# Buffers
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)