Alopezcordero commited on
Commit
5cd3814
·
verified ·
1 Parent(s): 368c532

Upload 11 files

Browse files
.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()