Reinforcement Learning
stable-baselines3
deep-reinforcement-learning
ppo
mujoco
humanoid
adversarial-motion-priors
amp
character-animation
motion-capture
Eval Results (legacy)
Instructions to use Alopezcordero/AMP-HumanoidDirection-V0 with libraries, inference providers, notebooks, and local apps. Follow these links to get started.
- Libraries
- stable-baselines3
How to use Alopezcordero/AMP-HumanoidDirection-V0 with stable-baselines3:
from huggingface_sb3 import load_from_hub checkpoint = load_from_hub( repo_id="Alopezcordero/AMP-HumanoidDirection-V0", filename="{MODEL FILENAME}.zip", ) - Notebooks
- Google Colab
- Kaggle
Upload 11 files
Browse files- .gitattributes +1 -0
- amp_callback.py +191 -0
- amp_discriminator.py +48 -0
- amp_env.py +130 -0
- amp_obs.py +85 -0
- humanoid_direction_env.py +78 -0
- humanoid_direction_evaluate.py +79 -0
- humanoid_direction_record.py +47 -0
- register_env.py +9 -0
- replay.mp4 +3 -0
- train_sb3_real_amp.py +164 -0
- train_sb3_real_amp_exp2.py +164 -0
.gitattributes
CHANGED
|
@@ -33,3 +33,4 @@ saved_model/**/* filter=lfs diff=lfs merge=lfs -text
|
|
| 33 |
*.zip filter=lfs diff=lfs merge=lfs -text
|
| 34 |
*.zst filter=lfs diff=lfs merge=lfs -text
|
| 35 |
*tfevents* filter=lfs diff=lfs merge=lfs -text
|
|
|
|
|
|
| 33 |
*.zip filter=lfs diff=lfs merge=lfs -text
|
| 34 |
*.zst filter=lfs diff=lfs merge=lfs -text
|
| 35 |
*tfevents* filter=lfs diff=lfs merge=lfs -text
|
| 36 |
+
replay.mp4 filter=lfs diff=lfs merge=lfs -text
|
amp_callback.py
ADDED
|
@@ -0,0 +1,191 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import os
|
| 2 |
+
from collections import deque
|
| 3 |
+
|
| 4 |
+
import numpy as np
|
| 5 |
+
import torch
|
| 6 |
+
from stable_baselines3.common.callbacks import BaseCallback
|
| 7 |
+
|
| 8 |
+
|
| 9 |
+
class AMPDiscriminatorCallback(BaseCallback):
|
| 10 |
+
"""Trains the AMP discriminator and syncs weights into the envs.
|
| 11 |
+
|
| 12 |
+
FIXES vs original:
|
| 13 |
+
- Least-squares (MSE) loss to +1/-1 targets, per the AMP paper
|
| 14 |
+
(was smooth_l1, which goes linear and weakens gradients exactly when
|
| 15 |
+
the discriminator is wrong).
|
| 16 |
+
- Robust trigger (`num_timesteps - last >= train_freq` instead of a
|
| 17 |
+
modulo that silently never fires if train_freq isn't a multiple of
|
| 18 |
+
n_envs).
|
| 19 |
+
- Pushes updated weights to all envs via env_method("load_disc_state")
|
| 20 |
+
-> required for SubprocVecEnv workers.
|
| 21 |
+
- Sensible default budget: updates_per_call=8 x batch 512 per rollout
|
| 22 |
+
(was 1 x 256 per 16384 steps at lr 3e-5 - the discriminator barely
|
| 23 |
+
moved over an entire run).
|
| 24 |
+
"""
|
| 25 |
+
|
| 26 |
+
def __init__(
|
| 27 |
+
self,
|
| 28 |
+
motion_lib,
|
| 29 |
+
discriminator,
|
| 30 |
+
optimizer,
|
| 31 |
+
batch_size=512,
|
| 32 |
+
updates_per_call=8,
|
| 33 |
+
train_freq=16384,
|
| 34 |
+
save_freq=500_000,
|
| 35 |
+
save_path="./models/checkpoints",
|
| 36 |
+
device="cpu",
|
| 37 |
+
amp_mean=None,
|
| 38 |
+
amp_std=None,
|
| 39 |
+
fake_replay_size=100_000,
|
| 40 |
+
gradient_penalty_weight=5.0,
|
| 41 |
+
score_reg_weight=1e-4,
|
| 42 |
+
max_grad_norm=1.0,
|
| 43 |
+
verbose=1,
|
| 44 |
+
):
|
| 45 |
+
super().__init__(verbose)
|
| 46 |
+
self.motion_lib = motion_lib
|
| 47 |
+
self.disc = discriminator
|
| 48 |
+
self.optimizer = optimizer
|
| 49 |
+
self.batch_size = batch_size
|
| 50 |
+
self.updates_per_call = updates_per_call
|
| 51 |
+
self.train_freq = train_freq
|
| 52 |
+
self.save_freq = save_freq
|
| 53 |
+
self.save_path = save_path
|
| 54 |
+
self.device = device
|
| 55 |
+
self.amp_mean = amp_mean
|
| 56 |
+
self.amp_std = amp_std
|
| 57 |
+
self.fake_replay = deque(maxlen=fake_replay_size)
|
| 58 |
+
self.gradient_penalty_weight = gradient_penalty_weight
|
| 59 |
+
self.score_reg_weight = score_reg_weight
|
| 60 |
+
self.max_grad_norm = max_grad_norm
|
| 61 |
+
self.last_train_step = 0
|
| 62 |
+
self.last_save_step = 0
|
| 63 |
+
os.makedirs(self.save_path, exist_ok=True)
|
| 64 |
+
|
| 65 |
+
# ------------------------------------------------------------------ #
|
| 66 |
+
|
| 67 |
+
def _on_training_start(self):
|
| 68 |
+
# Make sure every worker starts from the same weights as the master.
|
| 69 |
+
self.push_disc_weights()
|
| 70 |
+
|
| 71 |
+
def _on_step(self):
|
| 72 |
+
if self.num_timesteps - self.last_train_step >= self.train_freq:
|
| 73 |
+
self.train_discriminator()
|
| 74 |
+
self.last_train_step = self.num_timesteps
|
| 75 |
+
|
| 76 |
+
if self.num_timesteps - self.last_save_step >= self.save_freq:
|
| 77 |
+
self.save_discriminator()
|
| 78 |
+
self.last_save_step = self.num_timesteps
|
| 79 |
+
return True
|
| 80 |
+
|
| 81 |
+
def normalize_amp(self, x_np):
|
| 82 |
+
if self.amp_mean is not None and self.amp_std is not None:
|
| 83 |
+
return (x_np - self.amp_mean) / self.amp_std
|
| 84 |
+
return x_np
|
| 85 |
+
|
| 86 |
+
def push_disc_weights(self):
|
| 87 |
+
state = {k: v.detach().cpu() for k, v in self.disc.state_dict().items()}
|
| 88 |
+
self.training_env.env_method("load_disc_state", state)
|
| 89 |
+
|
| 90 |
+
# ------------------------------------------------------------------ #
|
| 91 |
+
|
| 92 |
+
def train_discriminator(self):
|
| 93 |
+
fake_lists = self.training_env.env_method("pop_fake_transitions")
|
| 94 |
+
fake_transitions = [t for lst in fake_lists for t in lst]
|
| 95 |
+
if fake_transitions:
|
| 96 |
+
self.fake_replay.extend(fake_transitions)
|
| 97 |
+
|
| 98 |
+
if len(self.fake_replay) < self.batch_size:
|
| 99 |
+
return
|
| 100 |
+
|
| 101 |
+
self.disc.train()
|
| 102 |
+
last_loss = None
|
| 103 |
+
|
| 104 |
+
for _ in range(self.updates_per_call):
|
| 105 |
+
idx = np.random.randint(0, len(self.fake_replay), size=self.batch_size)
|
| 106 |
+
fake_np = self.normalize_amp(
|
| 107 |
+
np.array([self.fake_replay[i] for i in idx], dtype=np.float32)
|
| 108 |
+
)
|
| 109 |
+
real_np = self.normalize_amp(
|
| 110 |
+
np.array(
|
| 111 |
+
[self.motion_lib.sample_amp_transition()
|
| 112 |
+
for _ in range(self.batch_size)],
|
| 113 |
+
dtype=np.float32,
|
| 114 |
+
)
|
| 115 |
+
)
|
| 116 |
+
|
| 117 |
+
real_x = torch.as_tensor(real_np, device=self.device)
|
| 118 |
+
fake_x = torch.as_tensor(fake_np, device=self.device)
|
| 119 |
+
|
| 120 |
+
real_scores = self.disc(real_x)
|
| 121 |
+
fake_scores = self.disc(fake_x)
|
| 122 |
+
|
| 123 |
+
# Least-squares GAN loss to targets +1 / -1 (AMP paper).
|
| 124 |
+
real_loss = torch.square(real_scores - 1.0).mean()
|
| 125 |
+
fake_loss = torch.square(fake_scores + 1.0).mean()
|
| 126 |
+
gp_loss = self.gradient_penalty(real_x)
|
| 127 |
+
score_reg = self.score_reg_weight * (
|
| 128 |
+
real_scores.pow(2).mean() + fake_scores.pow(2).mean()
|
| 129 |
+
)
|
| 130 |
+
|
| 131 |
+
loss = (
|
| 132 |
+
0.5 * (real_loss + fake_loss)
|
| 133 |
+
+ self.gradient_penalty_weight * gp_loss
|
| 134 |
+
+ score_reg
|
| 135 |
+
)
|
| 136 |
+
|
| 137 |
+
self.optimizer.zero_grad()
|
| 138 |
+
loss.backward()
|
| 139 |
+
torch.nn.utils.clip_grad_norm_(self.disc.parameters(), self.max_grad_norm)
|
| 140 |
+
self.optimizer.step()
|
| 141 |
+
last_loss = loss.item()
|
| 142 |
+
|
| 143 |
+
self.disc.eval()
|
| 144 |
+
self.push_disc_weights()
|
| 145 |
+
|
| 146 |
+
# ---- diagnostics on the last minibatch ---- #
|
| 147 |
+
with torch.no_grad():
|
| 148 |
+
real_scores = self.disc(real_x)
|
| 149 |
+
fake_scores = self.disc(fake_x)
|
| 150 |
+
real_score = real_scores.mean().item()
|
| 151 |
+
fake_score = fake_scores.mean().item()
|
| 152 |
+
real_reward = self.disc.amp_reward(real_x).mean().item()
|
| 153 |
+
fake_rewards = self.disc.amp_reward(fake_x)
|
| 154 |
+
fake_reward = fake_rewards.mean().item()
|
| 155 |
+
disc_acc = 0.5 * (
|
| 156 |
+
(real_scores > 0).float().mean()
|
| 157 |
+
+ (fake_scores < 0).float().mean()
|
| 158 |
+
).item()
|
| 159 |
+
|
| 160 |
+
if self.verbose:
|
| 161 |
+
print(
|
| 162 |
+
f"AMP Disc | loss={last_loss:.4f} acc={disc_acc:.2f} "
|
| 163 |
+
f"real_score={real_score:.3f} fake_score={fake_score:.3f} "
|
| 164 |
+
f"real_reward={real_reward:.3f} fake_reward={fake_reward:.3f}"
|
| 165 |
+
)
|
| 166 |
+
|
| 167 |
+
self.logger.record("amp/loss", last_loss)
|
| 168 |
+
self.logger.record("amp/disc_acc", disc_acc)
|
| 169 |
+
self.logger.record("amp/real_score", real_score)
|
| 170 |
+
self.logger.record("amp/fake_score", fake_score)
|
| 171 |
+
self.logger.record("amp/real_reward", real_reward)
|
| 172 |
+
self.logger.record("amp/fake_reward", fake_reward)
|
| 173 |
+
|
| 174 |
+
def gradient_penalty(self, real):
|
| 175 |
+
real = real.clone().detach().requires_grad_(True)
|
| 176 |
+
scores = self.disc(real)
|
| 177 |
+
gradients = torch.autograd.grad(
|
| 178 |
+
outputs=scores.sum(),
|
| 179 |
+
inputs=real,
|
| 180 |
+
create_graph=True,
|
| 181 |
+
retain_graph=True,
|
| 182 |
+
only_inputs=True,
|
| 183 |
+
)[0]
|
| 184 |
+
return gradients.pow(2).sum(dim=1).mean()
|
| 185 |
+
|
| 186 |
+
def save_discriminator(self):
|
| 187 |
+
path = os.path.join(
|
| 188 |
+
self.save_path, f"amp_discriminator_{self.num_timesteps}.pt"
|
| 189 |
+
)
|
| 190 |
+
torch.save(self.disc.state_dict(), path)
|
| 191 |
+
print("Saved discriminator checkpoint:", path)
|
amp_discriminator.py
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import torch
|
| 2 |
+
import torch.nn as nn
|
| 3 |
+
|
| 4 |
+
|
| 5 |
+
class AMPDiscriminator(nn.Module):
|
| 6 |
+
"""Least-squares AMP discriminator (predicts +1 real / -1 fake).
|
| 7 |
+
|
| 8 |
+
FIX vs original: `amp_reward` previously used exp(-0.05*(d-1)^2), which is
|
| 9 |
+
almost flat: a confidently-fake score of -1 still earned reward 0.82 vs 1.0
|
| 10 |
+
for perfectly-real. The style signal was ~constant, so PPO had nothing to
|
| 11 |
+
optimize. This restores the paper reward
|
| 12 |
+
|
| 13 |
+
r = clamp(1 - 0.25 * (d - 1)^2, 0, 1)
|
| 14 |
+
|
| 15 |
+
which gives r=1 at d=+1 (real) and r=0 at d<=-1 (fake) - a full-range,
|
| 16 |
+
informative reward.
|
| 17 |
+
"""
|
| 18 |
+
|
| 19 |
+
def __init__(self, input_dim=90, hidden_dim=512):
|
| 20 |
+
super().__init__()
|
| 21 |
+
self.input_dim = input_dim
|
| 22 |
+
|
| 23 |
+
self.net = nn.Sequential(
|
| 24 |
+
nn.Linear(input_dim, hidden_dim),
|
| 25 |
+
nn.ReLU(),
|
| 26 |
+
nn.Linear(hidden_dim, hidden_dim),
|
| 27 |
+
nn.ReLU(),
|
| 28 |
+
nn.Linear(hidden_dim, 1),
|
| 29 |
+
)
|
| 30 |
+
|
| 31 |
+
def forward(self, x):
|
| 32 |
+
if x.ndim == 1:
|
| 33 |
+
x = x.unsqueeze(0)
|
| 34 |
+
|
| 35 |
+
if x.shape[-1] != self.input_dim:
|
| 36 |
+
raise ValueError(
|
| 37 |
+
f"Expected AMP input dim {self.input_dim}, got {x.shape[-1]}"
|
| 38 |
+
)
|
| 39 |
+
|
| 40 |
+
return self.net(x).view(-1, 1)
|
| 41 |
+
|
| 42 |
+
def predict_score(self, x):
|
| 43 |
+
return self.forward(x)
|
| 44 |
+
|
| 45 |
+
def amp_reward(self, x):
|
| 46 |
+
d = self.forward(x)
|
| 47 |
+
r = 1.0 - 0.25 * torch.square(d - 1.0)
|
| 48 |
+
return torch.clamp(r, min=0.0, max=1.0)
|
amp_env.py
ADDED
|
@@ -0,0 +1,130 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import numpy as np
|
| 2 |
+
import torch
|
| 3 |
+
import gymnasium as gym
|
| 4 |
+
|
| 5 |
+
import register_env # noqa: F401 (registers HumanoidDirection-v0)
|
| 6 |
+
from amp_obs import build_amp_obs
|
| 7 |
+
|
| 8 |
+
|
| 9 |
+
class AMPHumanoidEnv(gym.Wrapper):
|
| 10 |
+
"""Wraps HumanoidDirection-v0 with an AMP style reward.
|
| 11 |
+
|
| 12 |
+
FIXES vs original:
|
| 13 |
+
- AMP features come from the shared, heading-invariant `build_amp_obs`
|
| 14 |
+
(was: raw world-frame qpos[2:]+qvel, letting the discriminator cheat on
|
| 15 |
+
global heading).
|
| 16 |
+
- Reward mixing is paper-style: total = w_task * r_task + w_style * r_style
|
| 17 |
+
with both rewards in [0, 1] and default weights 0.5 / 0.5
|
| 18 |
+
(was: r_task in ~[0, 7] plus 0.2 * near-constant style reward).
|
| 19 |
+
- `load_disc_state` lets the training callback push fresh discriminator
|
| 20 |
+
weights into each env. This is REQUIRED under SubprocVecEnv, where every
|
| 21 |
+
worker process holds its own copy of the discriminator; without syncing,
|
| 22 |
+
the style reward would be computed with the frozen initial weights
|
| 23 |
+
forever.
|
| 24 |
+
"""
|
| 25 |
+
|
| 26 |
+
def __init__(
|
| 27 |
+
self,
|
| 28 |
+
discriminator,
|
| 29 |
+
motion_lib=None,
|
| 30 |
+
env_id="HumanoidDirection-v0",
|
| 31 |
+
render_mode=None,
|
| 32 |
+
task_weight=0.5,
|
| 33 |
+
amp_weight=0.5,
|
| 34 |
+
device="cpu",
|
| 35 |
+
amp_mean=None,
|
| 36 |
+
amp_std=None,
|
| 37 |
+
reference_state_init_prob=0.5,
|
| 38 |
+
):
|
| 39 |
+
env = gym.make(env_id, render_mode=render_mode)
|
| 40 |
+
super().__init__(env)
|
| 41 |
+
|
| 42 |
+
self.disc = discriminator
|
| 43 |
+
self.disc.eval()
|
| 44 |
+
self.motion_lib = motion_lib
|
| 45 |
+
self.task_weight = task_weight
|
| 46 |
+
self.amp_weight = amp_weight
|
| 47 |
+
self.device = device
|
| 48 |
+
self.prev_amp_obs = None
|
| 49 |
+
self.fake_amp_transitions = []
|
| 50 |
+
self.amp_mean = amp_mean
|
| 51 |
+
self.amp_std = amp_std
|
| 52 |
+
self.reference_state_init_prob = reference_state_init_prob
|
| 53 |
+
|
| 54 |
+
# ------------------------------------------------------------------ #
|
| 55 |
+
|
| 56 |
+
def get_amp_obs(self):
|
| 57 |
+
data = self.env.unwrapped.data
|
| 58 |
+
return build_amp_obs(data.qpos.copy(), data.qvel.copy())
|
| 59 |
+
|
| 60 |
+
def load_disc_state(self, state_dict):
|
| 61 |
+
"""Called via VecEnv.env_method by the AMP callback after each
|
| 62 |
+
discriminator update."""
|
| 63 |
+
self.disc.load_state_dict(state_dict)
|
| 64 |
+
self.disc.eval()
|
| 65 |
+
return True
|
| 66 |
+
|
| 67 |
+
def _current_policy_obs(self):
|
| 68 |
+
unwrapped = self.env.unwrapped
|
| 69 |
+
humanoid_obs = unwrapped._get_obs()
|
| 70 |
+
if hasattr(unwrapped, "_get_obs_with_direction"):
|
| 71 |
+
return unwrapped._get_obs_with_direction(humanoid_obs)
|
| 72 |
+
return humanoid_obs
|
| 73 |
+
|
| 74 |
+
def maybe_reference_state_init(self):
|
| 75 |
+
if self.motion_lib is None or self.reference_state_init_prob <= 0.0:
|
| 76 |
+
return False
|
| 77 |
+
|
| 78 |
+
rng = self.env.unwrapped.np_random
|
| 79 |
+
if rng.random() >= self.reference_state_init_prob:
|
| 80 |
+
return False
|
| 81 |
+
|
| 82 |
+
qpos, qvel = self.motion_lib.sample_reference_state()
|
| 83 |
+
self.env.unwrapped.set_state(qpos, qvel)
|
| 84 |
+
return True
|
| 85 |
+
|
| 86 |
+
def reset(self, **kwargs):
|
| 87 |
+
obs, info = self.env.reset(**kwargs)
|
| 88 |
+
|
| 89 |
+
used_reference_state = self.maybe_reference_state_init()
|
| 90 |
+
if used_reference_state:
|
| 91 |
+
obs = self._current_policy_obs()
|
| 92 |
+
|
| 93 |
+
self.prev_amp_obs = self.get_amp_obs()
|
| 94 |
+
info["reference_state_init"] = used_reference_state
|
| 95 |
+
return obs, info
|
| 96 |
+
|
| 97 |
+
def step(self, action):
|
| 98 |
+
obs, task_reward, terminated, truncated, info = self.env.step(action)
|
| 99 |
+
|
| 100 |
+
current_amp_obs = self.get_amp_obs()
|
| 101 |
+
amp_transition = np.concatenate(
|
| 102 |
+
[self.prev_amp_obs, current_amp_obs]
|
| 103 |
+
).astype(np.float32)
|
| 104 |
+
self.fake_amp_transitions.append(amp_transition)
|
| 105 |
+
|
| 106 |
+
x_np = amp_transition
|
| 107 |
+
if self.amp_mean is not None and self.amp_std is not None:
|
| 108 |
+
x_np = (x_np - self.amp_mean) / self.amp_std
|
| 109 |
+
|
| 110 |
+
with torch.no_grad():
|
| 111 |
+
x = torch.tensor(
|
| 112 |
+
x_np, dtype=torch.float32, device=self.device
|
| 113 |
+
).unsqueeze(0)
|
| 114 |
+
amp_reward = self.disc.amp_reward(x).item()
|
| 115 |
+
|
| 116 |
+
total_reward = (
|
| 117 |
+
self.task_weight * task_reward + self.amp_weight * amp_reward
|
| 118 |
+
)
|
| 119 |
+
|
| 120 |
+
info["task_reward"] = task_reward
|
| 121 |
+
info["amp_reward"] = amp_reward
|
| 122 |
+
info["total_reward"] = total_reward
|
| 123 |
+
|
| 124 |
+
self.prev_amp_obs = current_amp_obs
|
| 125 |
+
return obs, total_reward, terminated, truncated, info
|
| 126 |
+
|
| 127 |
+
def pop_fake_transitions(self):
|
| 128 |
+
transitions = self.fake_amp_transitions
|
| 129 |
+
self.fake_amp_transitions = []
|
| 130 |
+
return transitions
|
amp_obs.py
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Shared, heading-invariant AMP feature construction.
|
| 2 |
+
|
| 3 |
+
WHY THIS FILE EXISTS (the main bug in the original code):
|
| 4 |
+
The discriminator was fed `concat[qpos[2:], qvel]`, which contains the root
|
| 5 |
+
quaternion in the WORLD frame and the root linear velocity in the WORLD frame.
|
| 6 |
+
Your task randomizes the walking direction every episode, but the mocap clips
|
| 7 |
+
walk in one fixed world direction. So the discriminator learns to separate
|
| 8 |
+
real/fake by *global heading*, not by gait quality -> the style reward
|
| 9 |
+
actively punishes the policy whenever it follows a target direction that
|
| 10 |
+
differs from the dataset's heading. Task reward and style reward fight each
|
| 11 |
+
other and neither wins.
|
| 12 |
+
|
| 13 |
+
Fix: express all root quantities in a local "heading frame" (yaw removed),
|
| 14 |
+
exactly like the AMP paper. Both the policy transitions (amp_env.py) and the
|
| 15 |
+
mocap transitions (motion_lib.py) MUST use this same function.
|
| 16 |
+
|
| 17 |
+
Feature layout (45 dims -> transition pair is 90 = discriminator input_dim):
|
| 18 |
+
root height 1
|
| 19 |
+
root orientation, yaw removed (quat, w>=0) 4
|
| 20 |
+
root linear velocity in heading frame 3
|
| 21 |
+
root angular velocity (MuJoCo body frame) 3
|
| 22 |
+
joint angles 17
|
| 23 |
+
joint velocities 17
|
| 24 |
+
"""
|
| 25 |
+
|
| 26 |
+
import numpy as np
|
| 27 |
+
|
| 28 |
+
AMP_OBS_DIM = 45
|
| 29 |
+
AMP_TRANSITION_DIM = 2 * AMP_OBS_DIM
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
def quat_mul(a, b):
|
| 33 |
+
"""Hamilton product, MuJoCo [w, x, y, z] convention."""
|
| 34 |
+
w1, x1, y1, z1 = a
|
| 35 |
+
w2, x2, y2, z2 = b
|
| 36 |
+
return np.array([
|
| 37 |
+
w1 * w2 - x1 * x2 - y1 * y2 - z1 * z2,
|
| 38 |
+
w1 * x2 + x1 * w2 + y1 * z2 - z1 * y2,
|
| 39 |
+
w1 * y2 - x1 * z2 + y1 * w2 + z1 * x2,
|
| 40 |
+
w1 * z2 + x1 * y2 - y1 * x2 + z1 * w2,
|
| 41 |
+
])
|
| 42 |
+
|
| 43 |
+
|
| 44 |
+
def yaw_from_quat(q):
|
| 45 |
+
"""ZYX-convention yaw (heading) of a [w, x, y, z] quaternion."""
|
| 46 |
+
w, x, y, z = q
|
| 47 |
+
return np.arctan2(2.0 * (w * z + x * y), 1.0 - 2.0 * (y * y + z * z))
|
| 48 |
+
|
| 49 |
+
|
| 50 |
+
def build_amp_obs(qpos, qvel):
|
| 51 |
+
"""Humanoid-v5 layout:
|
| 52 |
+
qpos = [x, y, z, qw, qx, qy, qz, 17 joint angles] (24,)
|
| 53 |
+
qvel = [vx, vy, vz (world), wx, wy, wz (body), 17 vels] (23,)
|
| 54 |
+
Returns float32 (45,) heading- and position-invariant features.
|
| 55 |
+
"""
|
| 56 |
+
quat = np.asarray(qpos[3:7], dtype=np.float64)
|
| 57 |
+
yaw = yaw_from_quat(quat)
|
| 58 |
+
|
| 59 |
+
c, s = np.cos(yaw), np.sin(yaw)
|
| 60 |
+
# Rotates a world-frame vector into the heading frame (inverse yaw).
|
| 61 |
+
R_inv = np.array([[c, s, 0.0],
|
| 62 |
+
[-s, c, 0.0],
|
| 63 |
+
[0.0, 0.0, 1.0]])
|
| 64 |
+
|
| 65 |
+
# Remove yaw from the root orientation: q_local = q_yaw^-1 (x) q
|
| 66 |
+
half = -0.5 * yaw
|
| 67 |
+
q_yaw_inv = np.array([np.cos(half), 0.0, 0.0, np.sin(half)])
|
| 68 |
+
quat_local = quat_mul(q_yaw_inv, quat)
|
| 69 |
+
if quat_local[0] < 0.0:
|
| 70 |
+
quat_local = -quat_local # canonical sign (quats double-cover)
|
| 71 |
+
|
| 72 |
+
lin_vel_local = R_inv @ np.asarray(qvel[0:3], dtype=np.float64)
|
| 73 |
+
# MuJoCo free-joint angular velocity (qvel[3:6]) is already expressed in
|
| 74 |
+
# the body-local frame, i.e. heading-invariant. Left untouched. Either
|
| 75 |
+
# way, expert and policy use the identical convention/transform.
|
| 76 |
+
ang_vel = qvel[3:6]
|
| 77 |
+
|
| 78 |
+
return np.concatenate([
|
| 79 |
+
qpos[2:3], # root height
|
| 80 |
+
quat_local, # roll/pitch information only
|
| 81 |
+
lin_vel_local, # forward/lateral/vertical speed relative to facing
|
| 82 |
+
ang_vel,
|
| 83 |
+
qpos[7:], # joint angles (already local)
|
| 84 |
+
qvel[6:], # joint velocities (already local)
|
| 85 |
+
]).astype(np.float32)
|
humanoid_direction_env.py
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import numpy as np
|
| 2 |
+
from gymnasium.envs.mujoco.humanoid_v5 import HumanoidEnv
|
| 3 |
+
from gymnasium.utils import EzPickle
|
| 4 |
+
from gymnasium import spaces
|
| 5 |
+
|
| 6 |
+
|
| 7 |
+
class HumanoidDirectionEnv(HumanoidEnv, EzPickle):
|
| 8 |
+
"""Humanoid-v5 with a random target heading each episode.
|
| 9 |
+
|
| 10 |
+
FIX vs original: the task reward is now BOUNDED in [0, 1], AMP-paper style.
|
| 11 |
+
Old reward: 5.0 * dot(v, dir) + 0.5 * healthy - ctrl_cost
|
| 12 |
+
- Unbounded in speed -> PPO maximizes it by sprinting/lunging, which is
|
| 13 |
+
exactly the behavior the motion prior punishes, so the two rewards
|
| 14 |
+
fight each other.
|
| 15 |
+
- Its scale (~2.5 to 7+ per step) dwarfed the [0, 0.2] style reward, so
|
| 16 |
+
the discriminator was effectively ignored.
|
| 17 |
+
New reward: full credit once velocity along the target reaches
|
| 18 |
+
`target_speed` (default 1.4 m/s, a normal walking pace present in mocap),
|
| 19 |
+
no extra credit for going faster. Falling is handled by termination and by
|
| 20 |
+
the style reward (mocap contains no falling), so the constant alive bonus
|
| 21 |
+
is gone.
|
| 22 |
+
"""
|
| 23 |
+
|
| 24 |
+
def __init__(self, direction=(1.0, 0.0), target_speed=1.4, **kwargs):
|
| 25 |
+
self.target_dir = np.array(direction, dtype=np.float32)
|
| 26 |
+
self.target_dir /= np.linalg.norm(self.target_dir)
|
| 27 |
+
self.target_speed = float(target_speed)
|
| 28 |
+
|
| 29 |
+
EzPickle.__init__(self, direction, target_speed, **kwargs)
|
| 30 |
+
super().__init__(**kwargs)
|
| 31 |
+
|
| 32 |
+
old_space = self.observation_space
|
| 33 |
+
self.observation_space = spaces.Box(
|
| 34 |
+
low=-np.inf,
|
| 35 |
+
high=np.inf,
|
| 36 |
+
shape=(old_space.shape[0] + 2,),
|
| 37 |
+
dtype=np.float64,
|
| 38 |
+
)
|
| 39 |
+
|
| 40 |
+
def reset_model(self):
|
| 41 |
+
obs = super().reset_model()
|
| 42 |
+
angle = self.np_random.uniform(-np.pi, np.pi)
|
| 43 |
+
self.target_dir = np.array(
|
| 44 |
+
[np.cos(angle), np.sin(angle)], dtype=np.float32
|
| 45 |
+
)
|
| 46 |
+
return self._get_obs_with_direction(obs)
|
| 47 |
+
|
| 48 |
+
def step(self, action):
|
| 49 |
+
xy_before = self.data.xpos[self.model.body("torso").id][:2].copy()
|
| 50 |
+
self.do_simulation(action, self.frame_skip)
|
| 51 |
+
xy_after = self.data.xpos[self.model.body("torso").id][:2].copy()
|
| 52 |
+
|
| 53 |
+
xy_velocity = (xy_after - xy_before) / self.dt
|
| 54 |
+
v_proj = float(np.dot(xy_velocity, self.target_dir))
|
| 55 |
+
|
| 56 |
+
# Only penalize the *deficit* below target speed; exceeding it earns
|
| 57 |
+
# nothing extra. Reward is in [0, 1].
|
| 58 |
+
speed_deficit = max(0.0, self.target_speed - v_proj)
|
| 59 |
+
direction_reward = float(np.exp(-2.0 * speed_deficit ** 2))
|
| 60 |
+
|
| 61 |
+
reward = direction_reward
|
| 62 |
+
terminated = not self.is_healthy
|
| 63 |
+
obs = self._get_obs_with_direction(self._get_obs())
|
| 64 |
+
|
| 65 |
+
info = {
|
| 66 |
+
"xy_velocity": xy_velocity,
|
| 67 |
+
"target_dir": self.target_dir,
|
| 68 |
+
"v_proj": v_proj,
|
| 69 |
+
"direction_reward": direction_reward,
|
| 70 |
+
}
|
| 71 |
+
|
| 72 |
+
if self.render_mode == "human":
|
| 73 |
+
self.render()
|
| 74 |
+
|
| 75 |
+
return obs, reward, terminated, False, info
|
| 76 |
+
|
| 77 |
+
def _get_obs_with_direction(self, humanoid_obs):
|
| 78 |
+
return np.concatenate([humanoid_obs, self.target_dir]).astype(np.float64)
|
humanoid_direction_evaluate.py
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import gymnasium as gym
|
| 2 |
+
import register_env
|
| 3 |
+
|
| 4 |
+
from stable_baselines3 import PPO
|
| 5 |
+
from stable_baselines3.common.vec_env import DummyVecEnv, VecNormalize
|
| 6 |
+
|
| 7 |
+
|
| 8 |
+
MODEL_PATH = (
|
| 9 |
+
"models_exp2/"
|
| 10 |
+
"ppo_humanoid_direction_amp_fixed.zip"
|
| 11 |
+
)
|
| 12 |
+
|
| 13 |
+
VECNORMALIZE_PATH = (
|
| 14 |
+
"models_exp2/"
|
| 15 |
+
"vecnormalize_amp_fixed.pkl"
|
| 16 |
+
)
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
def make_env():
|
| 20 |
+
return gym.make(
|
| 21 |
+
"HumanoidDirection-v0",
|
| 22 |
+
render_mode="human",
|
| 23 |
+
)
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
# Create the same vectorized environment structure used during training
|
| 27 |
+
env = DummyVecEnv([make_env])
|
| 28 |
+
|
| 29 |
+
# Load the saved observation-normalization statistics
|
| 30 |
+
env = VecNormalize.load(
|
| 31 |
+
VECNORMALIZE_PATH,
|
| 32 |
+
env,
|
| 33 |
+
)
|
| 34 |
+
|
| 35 |
+
# Evaluation settings
|
| 36 |
+
env.training = False
|
| 37 |
+
env.norm_reward = False
|
| 38 |
+
|
| 39 |
+
# Load the matching PPO model and attach the environment
|
| 40 |
+
model = PPO.load(
|
| 41 |
+
MODEL_PATH,
|
| 42 |
+
env=env,
|
| 43 |
+
device="cpu",
|
| 44 |
+
)
|
| 45 |
+
|
| 46 |
+
# DummyVecEnv.reset() returns only observations
|
| 47 |
+
obs = env.reset()
|
| 48 |
+
|
| 49 |
+
episode_reward = 0.0
|
| 50 |
+
episode = 0
|
| 51 |
+
num_episodes = 1000
|
| 52 |
+
|
| 53 |
+
while episode < num_episodes:
|
| 54 |
+
action, _ = model.predict(
|
| 55 |
+
obs,
|
| 56 |
+
deterministic=True,
|
| 57 |
+
)
|
| 58 |
+
|
| 59 |
+
# VecEnv.step() returns four values, not five
|
| 60 |
+
obs, rewards, dones, infos = env.step(action)
|
| 61 |
+
|
| 62 |
+
# rewards and dones are arrays because this is a vectorized environment
|
| 63 |
+
episode_reward += float(rewards[0])
|
| 64 |
+
|
| 65 |
+
if dones[0]:
|
| 66 |
+
episode += 1
|
| 67 |
+
|
| 68 |
+
print(f"Episode: {episode}")
|
| 69 |
+
print(f"Episode reward: {episode_reward:.2f}")
|
| 70 |
+
print(f"Episode info: {infos[0]}")
|
| 71 |
+
print()
|
| 72 |
+
|
| 73 |
+
episode_reward = 0.0
|
| 74 |
+
|
| 75 |
+
# DummyVecEnv normally resets automatically after done.
|
| 76 |
+
# The returned obs is already the next episode's initial observation.
|
| 77 |
+
|
| 78 |
+
env.close()
|
| 79 |
+
|
humanoid_direction_record.py
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import os
|
| 2 |
+
os.environ["MUJOCO_GL"] = "glfw"
|
| 3 |
+
|
| 4 |
+
import imageio
|
| 5 |
+
import gymnasium as gym
|
| 6 |
+
import register_env
|
| 7 |
+
from stable_baselines3 import PPO
|
| 8 |
+
|
| 9 |
+
model = PPO.load("models/ppo_humanoid_direction_20m.zip", device="cpu")
|
| 10 |
+
|
| 11 |
+
env = gym.make("HumanoidDirection-v0", render_mode="rgb_array")
|
| 12 |
+
obs, info = env.reset()
|
| 13 |
+
|
| 14 |
+
frames = []
|
| 15 |
+
num_episodes = 5
|
| 16 |
+
episode_count = 0
|
| 17 |
+
|
| 18 |
+
while episode_count < num_episodes:
|
| 19 |
+
action, _ = model.predict(obs, deterministic=True)
|
| 20 |
+
obs, reward, terminated, truncated, info = env.step(action)
|
| 21 |
+
|
| 22 |
+
frame = env.render()
|
| 23 |
+
frames.append(frame)
|
| 24 |
+
if terminated or truncated:
|
| 25 |
+
episode_count += 1
|
| 26 |
+
print(f"Episode: {episode_count}")
|
| 27 |
+
last_frame = frames[-1]
|
| 28 |
+
|
| 29 |
+
#pause using last frame
|
| 30 |
+
for _ in range(15):
|
| 31 |
+
frames.append(last_frame)
|
| 32 |
+
if episode_count < num_episodes:
|
| 33 |
+
obs, info = env.reset()
|
| 34 |
+
|
| 35 |
+
|
| 36 |
+
env.close()
|
| 37 |
+
|
| 38 |
+
print(f"number of frames: {len(frames)}")
|
| 39 |
+
print(f"frames shape: {frames[0].shape}")
|
| 40 |
+
writer = imageio.get_writer("humanoid_direction_20m.mp4", fps=30)
|
| 41 |
+
|
| 42 |
+
for frame in frames:
|
| 43 |
+
writer.append_data(frame)
|
| 44 |
+
|
| 45 |
+
writer.close()
|
| 46 |
+
|
| 47 |
+
print(f"saved humanoid_direction.mp4 with episodes: {num_episodes}")
|
register_env.py
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from gymnasium.envs.registration import register
|
| 2 |
+
|
| 3 |
+
print("registering environment")
|
| 4 |
+
|
| 5 |
+
register(
|
| 6 |
+
id="HumanoidDirection-v0",
|
| 7 |
+
entry_point="humanoid_direction_env:HumanoidDirectionEnv",
|
| 8 |
+
max_episode_steps=1000,
|
| 9 |
+
)
|
replay.mp4
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:827d7f4bc9fd4654913f2c2c53dcfd2cd6254620e7fd81cd5c6ccc4f3eddc240
|
| 3 |
+
size 8157631
|
train_sb3_real_amp.py
ADDED
|
@@ -0,0 +1,164 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from pathlib import Path
|
| 2 |
+
|
| 3 |
+
import gymnasium as gym
|
| 4 |
+
import torch
|
| 5 |
+
|
| 6 |
+
import register_env # noqa: F401
|
| 7 |
+
from stable_baselines3 import PPO
|
| 8 |
+
from stable_baselines3.common.callbacks import CallbackList, CheckpointCallback
|
| 9 |
+
from stable_baselines3.common.monitor import Monitor
|
| 10 |
+
from stable_baselines3.common.vec_env import SubprocVecEnv, VecNormalize
|
| 11 |
+
|
| 12 |
+
from amp_callback import AMPDiscriminatorCallback
|
| 13 |
+
from amp_discriminator import AMPDiscriminator
|
| 14 |
+
from amp_env import AMPHumanoidEnv
|
| 15 |
+
from motion_lib import MotionLib
|
| 16 |
+
|
| 17 |
+
# --------------------------------------------------------------------- #
|
| 18 |
+
TOTAL_TIMESTEPS = 50_000_000
|
| 19 |
+
N_ENVS = 8 # = --cpus-per-task in your slurm file
|
| 20 |
+
N_STEPS = 2048
|
| 21 |
+
TRAIN_FREQ = N_ENVS * N_STEPS # train the discriminator once per rollout
|
| 22 |
+
|
| 23 |
+
TASK_WEIGHT = 0.5 # both rewards are in [0, 1] now, so
|
| 24 |
+
AMP_WEIGHT = 0.5 # 0.5 / 0.5 mixing as in the AMP paper
|
| 25 |
+
REFERENCE_STATE_INIT_PROB = 0.5
|
| 26 |
+
|
| 27 |
+
DISCRIMINATOR_LR = 1e-4
|
| 28 |
+
DISCRIMINATOR_HIDDEN_DIM = 512
|
| 29 |
+
DISC_UPDATES_PER_ROLLOUT = 8
|
| 30 |
+
DISC_BATCH_SIZE = 512
|
| 31 |
+
GRADIENT_PENALTY_WEIGHT = 5.0
|
| 32 |
+
|
| 33 |
+
BASE_DIR = Path(__file__).resolve().parent
|
| 34 |
+
MODEL_DIR = BASE_DIR / "models"
|
| 35 |
+
CHECKPOINT_DIR = MODEL_DIR / "checkpoints"
|
| 36 |
+
TENSORBOARD_DIR = BASE_DIR / "tensorboard_real_amp_fixed"
|
| 37 |
+
CHECKPOINT_EVERY = 500_000
|
| 38 |
+
# --------------------------------------------------------------------- #
|
| 39 |
+
|
| 40 |
+
|
| 41 |
+
def make_env(motion_lib, amp_mean, amp_std):
|
| 42 |
+
def _init():
|
| 43 |
+
# Runs inside each SubprocVecEnv worker: keep torch single-threaded so
|
| 44 |
+
# 8 workers don't oversubscribe the 8-CPU slurm allocation.
|
| 45 |
+
torch.set_num_threads(1)
|
| 46 |
+
# Each worker holds its OWN cpu copy of the discriminator for reward
|
| 47 |
+
# evaluation; the callback pushes fresh weights after every update.
|
| 48 |
+
disc_local = AMPDiscriminator(
|
| 49 |
+
input_dim=90, hidden_dim=DISCRIMINATOR_HIDDEN_DIM
|
| 50 |
+
)
|
| 51 |
+
env = AMPHumanoidEnv(
|
| 52 |
+
discriminator=disc_local,
|
| 53 |
+
motion_lib=motion_lib,
|
| 54 |
+
task_weight=TASK_WEIGHT,
|
| 55 |
+
amp_weight=AMP_WEIGHT,
|
| 56 |
+
device="cpu",
|
| 57 |
+
amp_mean=amp_mean,
|
| 58 |
+
amp_std=amp_std,
|
| 59 |
+
reference_state_init_prob=REFERENCE_STATE_INIT_PROB,
|
| 60 |
+
)
|
| 61 |
+
return Monitor(env)
|
| 62 |
+
|
| 63 |
+
return _init
|
| 64 |
+
|
| 65 |
+
|
| 66 |
+
def main():
|
| 67 |
+
MODEL_DIR.mkdir(exist_ok=True)
|
| 68 |
+
CHECKPOINT_DIR.mkdir(exist_ok=True)
|
| 69 |
+
TENSORBOARD_DIR.mkdir(exist_ok=True)
|
| 70 |
+
|
| 71 |
+
device = "cuda" if torch.cuda.is_available() else "cpu"
|
| 72 |
+
print("Using device:", device)
|
| 73 |
+
|
| 74 |
+
# Expert transition spacing must match the env control timestep exactly.
|
| 75 |
+
_tmp = gym.make("HumanoidDirection-v0")
|
| 76 |
+
env_dt = float(_tmp.unwrapped.dt)
|
| 77 |
+
_tmp.close()
|
| 78 |
+
print("Environment dt:", env_dt)
|
| 79 |
+
|
| 80 |
+
motion_lib = MotionLib(
|
| 81 |
+
str(BASE_DIR / "retargeted_pkl"),
|
| 82 |
+
transition_dt=env_dt,
|
| 83 |
+
)
|
| 84 |
+
amp_mean, amp_std = motion_lib.compute_amp_stats(num_samples=10000)
|
| 85 |
+
|
| 86 |
+
# Master discriminator (the one actually trained, on GPU if available).
|
| 87 |
+
disc = AMPDiscriminator(
|
| 88 |
+
input_dim=90, hidden_dim=DISCRIMINATOR_HIDDEN_DIM
|
| 89 |
+
).to(device)
|
| 90 |
+
disc_optimizer = torch.optim.Adam(
|
| 91 |
+
disc.parameters(), lr=DISCRIMINATOR_LR, weight_decay=1e-4
|
| 92 |
+
)
|
| 93 |
+
|
| 94 |
+
# SubprocVecEnv: envs step in parallel processes. With DummyVecEnv all 8
|
| 95 |
+
# envs ran serially in one process - roughly an 8x throughput loss.
|
| 96 |
+
env = SubprocVecEnv(
|
| 97 |
+
[make_env(motion_lib, amp_mean, amp_std) for _ in range(N_ENVS)]
|
| 98 |
+
)
|
| 99 |
+
# Observation normalization matters a lot for Humanoid + PPO.
|
| 100 |
+
env = VecNormalize(env, norm_obs=True, norm_reward=False, clip_obs=10.0)
|
| 101 |
+
|
| 102 |
+
amp_callback = AMPDiscriminatorCallback(
|
| 103 |
+
motion_lib=motion_lib,
|
| 104 |
+
discriminator=disc,
|
| 105 |
+
optimizer=disc_optimizer,
|
| 106 |
+
batch_size=DISC_BATCH_SIZE,
|
| 107 |
+
updates_per_call=DISC_UPDATES_PER_ROLLOUT,
|
| 108 |
+
train_freq=TRAIN_FREQ,
|
| 109 |
+
save_freq=CHECKPOINT_EVERY,
|
| 110 |
+
save_path=str(CHECKPOINT_DIR),
|
| 111 |
+
device=device,
|
| 112 |
+
amp_mean=amp_mean,
|
| 113 |
+
amp_std=amp_std,
|
| 114 |
+
fake_replay_size=100_000,
|
| 115 |
+
gradient_penalty_weight=GRADIENT_PENALTY_WEIGHT,
|
| 116 |
+
score_reg_weight=1e-4,
|
| 117 |
+
max_grad_norm=1.0,
|
| 118 |
+
)
|
| 119 |
+
|
| 120 |
+
checkpoint_callback = CheckpointCallback(
|
| 121 |
+
save_freq=max(CHECKPOINT_EVERY // N_ENVS, 1),
|
| 122 |
+
save_path=str(CHECKPOINT_DIR),
|
| 123 |
+
name_prefix="ppo_amp_fixed",
|
| 124 |
+
save_replay_buffer=False,
|
| 125 |
+
save_vecnormalize=True, # needed to evaluate the model later!
|
| 126 |
+
)
|
| 127 |
+
|
| 128 |
+
policy_kwargs = dict(
|
| 129 |
+
activation_fn=torch.nn.ReLU,
|
| 130 |
+
net_arch=dict(pi=[1024, 512], vf=[1024, 512]),
|
| 131 |
+
)
|
| 132 |
+
|
| 133 |
+
model = PPO(
|
| 134 |
+
"MlpPolicy",
|
| 135 |
+
env,
|
| 136 |
+
device=device,
|
| 137 |
+
learning_rate=1e-4, # 5e-5 was very slow for 20M steps
|
| 138 |
+
n_steps=N_STEPS,
|
| 139 |
+
batch_size=512,
|
| 140 |
+
n_epochs=5,
|
| 141 |
+
target_kl=0.02,
|
| 142 |
+
gamma=0.99,
|
| 143 |
+
gae_lambda=0.95,
|
| 144 |
+
clip_range=0.2,
|
| 145 |
+
ent_coef=0.0, # 0.01 keeps the gaussian noisy -> jittery gait
|
| 146 |
+
policy_kwargs=policy_kwargs,
|
| 147 |
+
verbose=1,
|
| 148 |
+
tensorboard_log=str(TENSORBOARD_DIR),
|
| 149 |
+
)
|
| 150 |
+
|
| 151 |
+
model.learn(
|
| 152 |
+
total_timesteps=TOTAL_TIMESTEPS,
|
| 153 |
+
callback=CallbackList([amp_callback, checkpoint_callback]),
|
| 154 |
+
)
|
| 155 |
+
|
| 156 |
+
model.save(str(MODEL_DIR / "ppo_humanoid_direction_amp_fixed"))
|
| 157 |
+
env.save(str(MODEL_DIR / "vecnormalize_amp_fixed.pkl"))
|
| 158 |
+
torch.save(disc.state_dict(), MODEL_DIR / "amp_discriminator_fixed.pt")
|
| 159 |
+
env.close()
|
| 160 |
+
print("Saved final PPO model, VecNormalize stats, and discriminator.")
|
| 161 |
+
|
| 162 |
+
|
| 163 |
+
if __name__ == "__main__":
|
| 164 |
+
main()
|
train_sb3_real_amp_exp2.py
ADDED
|
@@ -0,0 +1,164 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from pathlib import Path
|
| 2 |
+
|
| 3 |
+
import gymnasium as gym
|
| 4 |
+
import torch
|
| 5 |
+
|
| 6 |
+
import register_env # noqa: F401
|
| 7 |
+
from stable_baselines3 import PPO
|
| 8 |
+
from stable_baselines3.common.callbacks import CallbackList, CheckpointCallback
|
| 9 |
+
from stable_baselines3.common.monitor import Monitor
|
| 10 |
+
from stable_baselines3.common.vec_env import SubprocVecEnv, VecNormalize
|
| 11 |
+
|
| 12 |
+
from amp_callback import AMPDiscriminatorCallback
|
| 13 |
+
from amp_discriminator import AMPDiscriminator
|
| 14 |
+
from amp_env import AMPHumanoidEnv
|
| 15 |
+
from motion_lib import MotionLib
|
| 16 |
+
|
| 17 |
+
# --------------------------------------------------------------------- #
|
| 18 |
+
TOTAL_TIMESTEPS = 50_000_000
|
| 19 |
+
N_ENVS = 8 # = --cpus-per-task in your slurm file
|
| 20 |
+
N_STEPS = 2048
|
| 21 |
+
TRAIN_FREQ = N_ENVS * N_STEPS # train the discriminator once per rollout
|
| 22 |
+
|
| 23 |
+
TASK_WEIGHT = 0.5 # both rewards are in [0, 1] now, so
|
| 24 |
+
AMP_WEIGHT = 0.5 # 0.5 / 0.5 mixing as in the AMP paper
|
| 25 |
+
REFERENCE_STATE_INIT_PROB = 0.3
|
| 26 |
+
|
| 27 |
+
DISCRIMINATOR_LR = 3e-5
|
| 28 |
+
DISCRIMINATOR_HIDDEN_DIM = 512
|
| 29 |
+
DISC_UPDATES_PER_ROLLOUT = 4
|
| 30 |
+
DISC_BATCH_SIZE = 512
|
| 31 |
+
GRADIENT_PENALTY_WEIGHT = 10.0
|
| 32 |
+
|
| 33 |
+
BASE_DIR = Path(__file__).resolve().parent
|
| 34 |
+
MODEL_DIR = BASE_DIR / "models_exp2"
|
| 35 |
+
CHECKPOINT_DIR = MODEL_DIR / "checkpoints"
|
| 36 |
+
TENSORBOARD_DIR = BASE_DIR / "tensorboard_real_amp_fixed"
|
| 37 |
+
CHECKPOINT_EVERY = 500_000
|
| 38 |
+
# --------------------------------------------------------------------- #
|
| 39 |
+
|
| 40 |
+
|
| 41 |
+
def make_env(motion_lib, amp_mean, amp_std):
|
| 42 |
+
def _init():
|
| 43 |
+
# Runs inside each SubprocVecEnv worker: keep torch single-threaded so
|
| 44 |
+
# 8 workers don't oversubscribe the 8-CPU slurm allocation.
|
| 45 |
+
torch.set_num_threads(1)
|
| 46 |
+
# Each worker holds its OWN cpu copy of the discriminator for reward
|
| 47 |
+
# evaluation; the callback pushes fresh weights after every update.
|
| 48 |
+
disc_local = AMPDiscriminator(
|
| 49 |
+
input_dim=90, hidden_dim=DISCRIMINATOR_HIDDEN_DIM
|
| 50 |
+
)
|
| 51 |
+
env = AMPHumanoidEnv(
|
| 52 |
+
discriminator=disc_local,
|
| 53 |
+
motion_lib=motion_lib,
|
| 54 |
+
task_weight=TASK_WEIGHT,
|
| 55 |
+
amp_weight=AMP_WEIGHT,
|
| 56 |
+
device="cpu",
|
| 57 |
+
amp_mean=amp_mean,
|
| 58 |
+
amp_std=amp_std,
|
| 59 |
+
reference_state_init_prob=REFERENCE_STATE_INIT_PROB,
|
| 60 |
+
)
|
| 61 |
+
return Monitor(env)
|
| 62 |
+
|
| 63 |
+
return _init
|
| 64 |
+
|
| 65 |
+
|
| 66 |
+
def main():
|
| 67 |
+
MODEL_DIR.mkdir(exist_ok=True)
|
| 68 |
+
CHECKPOINT_DIR.mkdir(exist_ok=True)
|
| 69 |
+
TENSORBOARD_DIR.mkdir(exist_ok=True)
|
| 70 |
+
|
| 71 |
+
device = "cuda" if torch.cuda.is_available() else "cpu"
|
| 72 |
+
print("Using device:", device)
|
| 73 |
+
|
| 74 |
+
# Expert transition spacing must match the env control timestep exactly.
|
| 75 |
+
_tmp = gym.make("HumanoidDirection-v0")
|
| 76 |
+
env_dt = float(_tmp.unwrapped.dt)
|
| 77 |
+
_tmp.close()
|
| 78 |
+
print("Environment dt:", env_dt)
|
| 79 |
+
|
| 80 |
+
motion_lib = MotionLib(
|
| 81 |
+
str(BASE_DIR / "retargeted_pkl"),
|
| 82 |
+
transition_dt=env_dt,
|
| 83 |
+
)
|
| 84 |
+
amp_mean, amp_std = motion_lib.compute_amp_stats(num_samples=10000)
|
| 85 |
+
|
| 86 |
+
# Master discriminator (the one actually trained, on GPU if available).
|
| 87 |
+
disc = AMPDiscriminator(
|
| 88 |
+
input_dim=90, hidden_dim=DISCRIMINATOR_HIDDEN_DIM
|
| 89 |
+
).to(device)
|
| 90 |
+
disc_optimizer = torch.optim.Adam(
|
| 91 |
+
disc.parameters(), lr=DISCRIMINATOR_LR, weight_decay=1e-4
|
| 92 |
+
)
|
| 93 |
+
|
| 94 |
+
# SubprocVecEnv: envs step in parallel processes. With DummyVecEnv all 8
|
| 95 |
+
# envs ran serially in one process - roughly an 8x throughput loss.
|
| 96 |
+
env = SubprocVecEnv(
|
| 97 |
+
[make_env(motion_lib, amp_mean, amp_std) for _ in range(N_ENVS)]
|
| 98 |
+
)
|
| 99 |
+
# Observation normalization matters a lot for Humanoid + PPO.
|
| 100 |
+
env = VecNormalize(env, norm_obs=True, norm_reward=False, clip_obs=10.0)
|
| 101 |
+
|
| 102 |
+
amp_callback = AMPDiscriminatorCallback(
|
| 103 |
+
motion_lib=motion_lib,
|
| 104 |
+
discriminator=disc,
|
| 105 |
+
optimizer=disc_optimizer,
|
| 106 |
+
batch_size=DISC_BATCH_SIZE,
|
| 107 |
+
updates_per_call=DISC_UPDATES_PER_ROLLOUT,
|
| 108 |
+
train_freq=TRAIN_FREQ,
|
| 109 |
+
save_freq=CHECKPOINT_EVERY,
|
| 110 |
+
save_path=str(CHECKPOINT_DIR),
|
| 111 |
+
device=device,
|
| 112 |
+
amp_mean=amp_mean,
|
| 113 |
+
amp_std=amp_std,
|
| 114 |
+
fake_replay_size=100_000,
|
| 115 |
+
gradient_penalty_weight=GRADIENT_PENALTY_WEIGHT,
|
| 116 |
+
score_reg_weight=1e-4,
|
| 117 |
+
max_grad_norm=1.0,
|
| 118 |
+
)
|
| 119 |
+
|
| 120 |
+
checkpoint_callback = CheckpointCallback(
|
| 121 |
+
save_freq=max(CHECKPOINT_EVERY // N_ENVS, 1),
|
| 122 |
+
save_path=str(CHECKPOINT_DIR),
|
| 123 |
+
name_prefix="ppo_amp_fixed",
|
| 124 |
+
save_replay_buffer=False,
|
| 125 |
+
save_vecnormalize=True, # needed to evaluate the model later!
|
| 126 |
+
)
|
| 127 |
+
|
| 128 |
+
policy_kwargs = dict(
|
| 129 |
+
activation_fn=torch.nn.ReLU,
|
| 130 |
+
net_arch=dict(pi=[1024, 512], vf=[1024, 512]),
|
| 131 |
+
)
|
| 132 |
+
|
| 133 |
+
model = PPO(
|
| 134 |
+
"MlpPolicy",
|
| 135 |
+
env,
|
| 136 |
+
device=device,
|
| 137 |
+
learning_rate=5e-5, # 5e-5 was very slow for 20M steps
|
| 138 |
+
n_steps=N_STEPS,
|
| 139 |
+
batch_size=512,
|
| 140 |
+
n_epochs=5,
|
| 141 |
+
target_kl=0.02,
|
| 142 |
+
gamma=0.99,
|
| 143 |
+
gae_lambda=0.95,
|
| 144 |
+
clip_range=0.2,
|
| 145 |
+
ent_coef=0.0, # 0.01 keeps the gaussian noisy -> jittery gait
|
| 146 |
+
policy_kwargs=policy_kwargs,
|
| 147 |
+
verbose=1,
|
| 148 |
+
tensorboard_log=str(TENSORBOARD_DIR),
|
| 149 |
+
)
|
| 150 |
+
|
| 151 |
+
model.learn(
|
| 152 |
+
total_timesteps=TOTAL_TIMESTEPS,
|
| 153 |
+
callback=CallbackList([amp_callback, checkpoint_callback]),
|
| 154 |
+
)
|
| 155 |
+
|
| 156 |
+
model.save(str(MODEL_DIR / "ppo_humanoid_direction_amp_fixed"))
|
| 157 |
+
env.save(str(MODEL_DIR / "vecnormalize_amp_fixed.pkl"))
|
| 158 |
+
torch.save(disc.state_dict(), MODEL_DIR / "amp_discriminator_fixed.pt")
|
| 159 |
+
env.close()
|
| 160 |
+
print("Saved final PPO model, VecNormalize stats, and discriminator.")
|
| 161 |
+
|
| 162 |
+
|
| 163 |
+
if __name__ == "__main__":
|
| 164 |
+
main()
|