这篇记录继续整理 TienKung-Lab 里的 AMP 实现,主要围绕 AmpOnPolicyRunnerAMPPPO 展开。普通 rsl_rl 训练使用 OnPolicyRunner,这个工程注册的是 AmpOnPolicyRunner,在原有 PPO 采样和更新之外增加了 AMP expert data、policy AMP replay buffer 和 discriminator。

首先是最终的训练效果:

tk 00_00_00-00_00_30

AmpOnPolicyRunner.__init__()

初始化阶段主要创建这些对象:

1
2
3
4
5
6
7
8
并行仿真环境 env
Actor-Critic policy
AMP discriminator
专家数据 amp_data
AMP 特征 normalizer
AMPPPO 算法对象 self.alg
PPO rollout storage
日志和模型保存相关对象

critic 不是单独创建的类,它和 actor 一起包含在 ActorCritic 对象中。

runner 先保存训练配置、算法配置、policy 配置、设备和环境,然后配置多 GPU:

1
2
3
4
5
6
self.cfg = train_cfg
self.alg_cfg = train_cfg["algorithm"]
self.policy_cfg = train_cfg["policy"]
self.device = device
self.env = env
self._configure_multi_gpu()

然后根据训练类型判断当前是强化学习还是蒸馏,并从 extras["observations"] 中确定特权观测类型:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
if self.training_type == "rl":
if "critic" in extras["observations"]:
self.privileged_obs_type = "critic" # actor-critic reinforcement learnig, e.g., PPO
else:
self.privileged_obs_type = None
if self.training_type == "distillation":
if "teacher" in extras["observations"]:
self.privileged_obs_type = "teacher" # policy distillation
else:
self.privileged_obs_type = None

# resolve dimensions of privileged observations
if self.privileged_obs_type is not None:
num_privileged_obs = extras["observations"][self.privileged_obs_type].shape[1]
else:
num_privileged_obs = num_obs

当前使用 PPO 时,actor 接收普通观测,critic 接收 extras["observations"]["critic"]。如果环境没有提供 critic observation,critic 就和 actor 共用普通观测。

创建 Actor-Critic

1
2
3
4
5
# evaluate the policy class
policy_class = eval(self.policy_cfg.pop("class_name"))
policy: ActorCritic | ActorCriticRecurrent | StudentTeacher | StudentTeacherRecurrent = policy_class(
num_obs, num_privileged_obs, self.env.num_actions, **self.policy_cfg
).to(self.device)

处理 RND 配置

1
2
3
4
5
6
7
8
9
10
11
if "rnd_cfg" in self.alg_cfg and self.alg_cfg["rnd_cfg"] is not None:
# check if rnd gated state is present
rnd_state = extras["observations"].get("rnd_state")
if rnd_state is None:
raise ValueError("Observations for the key 'rnd_state' not found in infos['observations'].")
# get dimension of rnd gated state
num_rnd_state = rnd_state.shape[1]
# add rnd gated state to config
self.alg_cfg["rnd_cfg"]["num_states"] = num_rnd_state
# scale down the rnd weight with timestep (similar to how rewards are scaled down in legged_gym envs)
self.alg_cfg["rnd_cfg"]["weight"] *= env.unwrapped.step_dt

处理 symmetry 配置

代码把 env 本身放进 symmetry 配置。镜像函数需要环境中的观测结构和关节映射,所以这里不是直接完成左右腿交换,只是把后续镜像函数需要的环境对象传进去。

1
2
3
4
# if using symmetry then pass the environment config object 对称
if "symmetry_cfg" in self.alg_cfg and self.alg_cfg["symmetry_cfg"] is not None:
# this is used by the symmetry function for handling different observation terms
self.alg_cfg["symmetry_cfg"]["_env"] = env

创建 AMP expert loader

1
2
3
4
5
6
7
amp_data = AMPLoader(
device,
time_between_frames=self.env.step_dt,
preload_transitions=True,
num_preload_transitions=train_cfg["amp_num_preload_transitions"],
motion_files=train_cfg["amp_motion_files"],
)

time_between_frames 使用 env.step_dt。当前 TienKung 配置中物理步长是 0.005 s,decimation 是 4,所以 policy 和 expert transition 的时间跨度都是 0.02 s

AMP 特征标准化器处理的是判别器使用的运动特征,同时用于 expert 数据和 policy 生成的数据:

1
amp_normalizer = Normalizer(amp_data.observation_dim)

AMP 判别器就是这里的 D 网络:

1
2
3
4
5
6
7
discriminator = Discriminator(
amp_data.observation_dim * 2, # 输入维度
train_cfg["amp_reward_coef"], # AMP reward 系数
train_cfg["amp_discr_hidden_dims"],
device,
train_cfg["amp_task_reward_lerp"], # task reward 的插值系数
).to(self.device)

当前 AMP state 是 52 维,discriminator 判断的是相邻状态组成的 transition,所以输入是:

1
2
[state, next_state]
[B, 52] + [B, 52] = [B, 104]

现在已经有了 policydiscriminatoramp_dataamp_normalizer,可以创建 AMPPPO

1
2
3
4
5
6
7
8
9
10
self.alg: AMPPPO = alg_class(
policy,
discriminator,
amp_data,
amp_normalizer,
device=self.device,
min_std=min_std,
**self.alg_cfg,
multi_gpu_cfg=self.multi_gpu_cfg,
)

普通观测和特权观测使用另一套 EmpiricalNormalization。它们服务 actor/critic,不是 AMP discriminator 的 Normalizer

1
2
3
4
5
6
7
8
9
10
if self.empirical_normalization:
self.obs_normalizer = EmpiricalNormalization(
shape=[num_obs],
until=1.0e8,
).to(self.device)

self.privileged_obs_normalizer = EmpiricalNormalization(
shape=[num_privileged_obs],
until=1.0e8,
).to(self.device)

其余部分是 rollout storage、日志和模型保存相关初始化。

AmpOnPolicyRunner.learn()

下面保留主循环,略去日志相关代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
for it in range(start_iter, tot_iter):
start = time.time()
# Rollout
with torch.inference_mode():
for _ in range(self.num_steps_per_env):
# Sample actions
actions = self.alg.act(obs, privileged_obs, amp_obs)
# Step the environment
# 对于 reset 的 env,obs 是 reset 后新 episode 的初始观测,
# 不是终止时刻的观测
obs, rewards, dones, infos = self.env.step(actions.to(self.env.device))
# 取得当前仿真状态对应的 AMP observation
next_amp_obs = self.env.get_amp_obs_for_expert_trans()
# Move to device
obs, rewards, dones, next_amp_obs = (
obs.to(self.device),
rewards.to(self.device),
dones.to(self.device),
next_amp_obs.to(self.device),
)
# perform normalization
obs = self.obs_normalizer(obs)
if self.privileged_obs_type is not None:
privileged_obs = self.privileged_obs_normalizer(
infos["observations"][self.privileged_obs_type].to(self.device)
)
else:
privileged_obs = obs
# Account for terminal state transitions
# AMP transition 需要 done 前状态和 terminal state,
# 不能把 reset 后的新初始状态当成 terminal state
next_amp_obs_with_term = torch.clone(next_amp_obs)
reset_env_ids = self.env.reset_env_ids
terminal_amp_states = self.env.get_amp_obs_for_expert_trans()[reset_env_ids]
next_amp_obs_with_term[reset_env_ids] = terminal_amp_states

# 混合 AMP reward 和 task reward
rewards = self.alg.discriminator.predict_amp_reward(
amp_obs, next_amp_obs_with_term, rewards, normalizer=self.alg.amp_normalizer
)[0]
amp_obs = torch.clone(next_amp_obs)
# 写入 PPO rollout storage 和 policy AMP replay buffer
self.alg.process_env_step(rewards, dones, infos, next_amp_obs_with_term)
# Extract intrinsic rewards (only for logging)
intrinsic_rewards = self.alg.intrinsic_rewards if self.alg.rnd else None

stop = time.time()
collection_time = stop - start
start = stop

# compute returns
if self.training_type == "rl":
self.alg.compute_returns(privileged_obs)

# update policy
loss_dict = self.alg.update()

stop = time.time()
learn_time = stop - start
self.current_learning_iteration = it


rollout 开始前,amp_obsenv.get_amp_obs_for_expert_trans() 创建,shape 是 [num_envs, 52]AMPPPO.act() 把它暂存在 self.amp_transition.observations。环境执行一步后,process_env_step() 将旧的 amp_obs 和传入的 next_amp_obs_with_term 写进 AMP replay buffer。

task reward 和 AMP reward 在 Discriminator.predict_amp_reward() 中混合:

其中 $\alpha$ 对应 amp_task_reward_lerp。当前 walk/run 配置为 0.7,即最终 reward 中 task reward 权重为 0.7,AMP reward 权重为 0.3。混合后的 reward 写入 PPO rollout storage,随后参与 return 和 advantage 计算。AMP 对 actor/critic 的影响是通过 PPO reward 产生的。

terminal AMP state 记录

这里保留一个源码阅读时发现的问题。TienKungEnv.step() 检测到 done 后先执行 reset(self.reset_env_ids),再计算并返回 observation。runner 随后第一次调用 get_amp_obs_for_expert_trans() 得到的是 reset 后状态;下面再次调用同一个函数,期间没有恢复 terminal state:

1
2
3
4
next_amp_obs_with_term = torch.clone(next_amp_obs)
reset_env_ids = self.env.reset_env_ids
terminal_amp_states = self.env.get_amp_obs_for_expert_trans()[reset_env_ids]
next_amp_obs_with_term[reset_env_ids] = terminal_amp_states

按当前调用顺序,terminal_amp_states 仍然来自 reset 后的机器人状态,所以这段替换没有拿到真正的 terminal AMP state。这是根据当前 step()reset() 和 runner 调用顺序得到的代码行为判断,不是只根据变量名判断。

AMPPPO 和两个 storage

当前算法同时维护两个 storage。

PPO rollout storage 保存 policy 更新需要的数据:

1
2
3
observations、critic_observations、actions
rewards、dones、values、returns、advantages
old_log_prob、old_mu、old_sigma

AMP replay buffer 只保存 policy 产生的 AMP transition:

1
2
state       [buffer_size, 52]
next_state [buffer_size, 52]

两个 storage 不能合并。PPO rollout storage 是按本轮采样时间组织的 on-policy 数据,还要计算 GAE;AMP replay buffer 只给 discriminator 提供 policy 样本,可以跨 rollout 循环覆盖和重复采样。expert transition 来自 AMPLoader,不写入这两个 storage。

policy 和 discriminator 不使用两个独立 optimizer,而是共同放进 self.optimizer 的不同参数组:

1
2
3
4
5
6
params = [
{"params": self.policy.parameters(), "name": "policy"},
{"params": self.discriminator.trunk.parameters(), "weight_decay": 10e-4, "name": "amp_trunk"},
{"params": self.discriminator.amp_linear.parameters(), "weight_decay": 10e-2, "name": "amp_head"},
]
self.optimizer = optim.Adam(params, lr=learning_rate)

当前没有启用 RND 时,总损失可以写成:

共用 optimizer 不等于每一项 loss 都会更新所有网络。PPO loss 的计算图连接 actor/critic;AMP loss 和 gradient penalty 的计算图连接 discriminator。discriminator 的 reward 在 rollout 阶段影响 PPO,但 update 中的 amp_loss 不会直接反向传播到 actor。

AMPPPO.update()

下面是略去部分日志代码后的主要结构。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
def update(self):  # noqa: C901

# generator for mini batches 三个minibatch生成器
if self.policy.is_recurrent:
generator = self.storage.recurrent_mini_batch_generator(self.num_mini_batches, self.num_learning_epochs)
else:
generator = self.storage.mini_batch_generator(self.num_mini_batches, self.num_learning_epochs)

amp_policy_generator = self.amp_storage.feed_forward_generator(
self.num_learning_epochs * self.num_mini_batches,
self.storage.num_envs * self.storage.num_transitions_per_env // self.num_mini_batches,
)
amp_expert_generator = self.amp_data.feed_forward_generator(
self.num_learning_epochs * self.num_mini_batches,
self.storage.num_envs * self.storage.num_transitions_per_env // self.num_mini_batches,
)

# iterate over batches 这里的一个 sample 就是一个 mini-batch
for sample, sample_amp_policy, sample_amp_expert in zip(generator, amp_policy_generator, amp_expert_generator):
(
obs_batch,
critic_obs_batch,
actions_batch,
target_values_batch,
advantages_batch,
returns_batch,
old_actions_log_prob_batch,
old_mu_batch,
old_sigma_batch,
hid_states_batch,
masks_batch,
rnd_state_batch,
) = sample

# number of augmentations per sample
# we start with 1 and increase it if we use symmetry augmentation
num_aug = 1
# original batch size 一个batch有多少数据
original_batch_size = obs_batch.shape[0]

# check if we should normalize advantages per mini batch 优势函数标准化
if self.normalize_advantage_per_mini_batch:
with torch.no_grad():
advantages_batch = (advantages_batch - advantages_batch.mean()) / (advantages_batch.std() + 1e-8)

# Perform symmetric augmentation 对称数据增强
if self.symmetry and self.symmetry["use_data_augmentation"]:
# augmentation using symmetry
# 它负责根据机器人的左右对称关系,完成对传入的部分观测和动作的对称变换,生成新的观测和动作数据。这个函数通常会根据机器人的结构和任务的要求来实现具体的对称变换逻辑。
data_augmentation_func = self.symmetry["data_augmentation_func"]
# returned shape: [batch_size * num_aug, ...]
obs_batch, actions_batch = data_augmentation_func(
obs=obs_batch, actions=actions_batch, env=self.symmetry["_env"], obs_type="policy"
)
critic_obs_batch, _ = data_augmentation_func(
obs=critic_obs_batch, actions=None, env=self.symmetry["_env"], obs_type="critic"
)
# compute number of augmentations per sample
num_aug = int(obs_batch.shape[0] / original_batch_size)
# repeat the rest of the batch 把其他的张量也重复 num_aug 次,保证每个增强样本都有对应的标签,且其他张量在镜像前后一致,所以直接复制就行
# 扩容前大家都是b行,扩容后刚刚说的obs_batch actions_batch critic_obs_batch 变成2B行,
# -- actor
old_actions_log_prob_batch = old_actions_log_prob_batch.repeat(num_aug, 1)
# -- critic
target_values_batch = target_values_batch.repeat(num_aug, 1)
advantages_batch = advantages_batch.repeat(num_aug, 1)
returns_batch = returns_batch.repeat(num_aug, 1)

# Recompute actions log prob and entropy for current batch of transitions
# Note: we need to do this because we updated the policy with the new parameters
# -- actor
self.policy.act(obs_batch, masks=masks_batch, hidden_states=hid_states_batch[0])
actions_log_prob_batch = self.policy.get_actions_log_prob(actions_batch)
# -- critic
value_batch = self.policy.evaluate(critic_obs_batch, masks=masks_batch, hidden_states=hid_states_batch[1])
# -- entropy
# we only keep the entropy of the first augmentation (the original one) 只用原始样本计算新旧策略的 KL和entropy regularization
# 注意这三个的更新步骤,他们在actor_critic.py里定义了@property,实际上返回的是self.distribution的相关计算结果,
# 而self.distribution是在self.policy.act里面执行update_distribution的时候更新的
mu_batch = self.policy.action_mean[:original_batch_size]
sigma_batch = self.policy.action_std[:original_batch_size]
entropy_batch = self.policy.entropy[:original_batch_size]

# KL
if self.desired_kl is not None and self.schedule == "adaptive":
with torch.inference_mode():
kl = torch.sum(
torch.log(sigma_batch / old_sigma_batch + 1.0e-5)
+ (torch.square(old_sigma_batch) + torch.square(old_mu_batch - mu_batch))
/ (2.0 * torch.square(sigma_batch))
- 0.5,
axis=-1,
)
kl_mean = torch.mean(kl)

# Reduce the KL divergence across all GPUs
if self.is_multi_gpu:
torch.distributed.all_reduce(kl_mean, op=torch.distributed.ReduceOp.SUM)
kl_mean /= self.gpu_world_size

# Update the learning rate
# Perform this adaptation only on the main process
# TODO: Is this needed? If KL-divergence is the "same" across all GPUs,
# then the learning rate should be the same across all GPUs.
if self.gpu_global_rank == 0:
if kl_mean > self.desired_kl * 2.0:
self.learning_rate = max(1e-5, self.learning_rate / 1.5)
elif kl_mean < self.desired_kl / 2.0 and kl_mean > 0.0:
self.learning_rate = min(1e-2, self.learning_rate * 1.5)

# 多 GPU 中,rank 0 是主进程。这里只让主进程执行学习率判断,避免多个进程分别修改学习率。然后修改完后同步
# Update the learning rate for all GPUs
if self.is_multi_gpu:
lr_tensor = torch.tensor(self.learning_rate, device=self.device)
torch.distributed.broadcast(lr_tensor, src=0)
self.learning_rate = lr_tensor.item()

# Update the learning rate for all parameter groups
for param_group in self.optimizer.param_groups:
param_group["lr"] = self.learning_rate


#下面一部分是在更新ppo_loss symmetryloss rndloss amploss

# Surrogate loss PPO-clip
ratio = torch.exp(actions_log_prob_batch - torch.squeeze(old_actions_log_prob_batch))
surrogate = -torch.squeeze(advantages_batch) * ratio
surrogate_clipped = -torch.squeeze(advantages_batch) * torch.clamp(
ratio, 1.0 - self.clip_param, 1.0 + self.clip_param
)
surrogate_loss = torch.max(surrogate, surrogate_clipped).mean()

# Value function loss 也加了裁剪机制
if self.use_clipped_value_loss:
value_clipped = target_values_batch + (value_batch - target_values_batch).clamp(
-self.clip_param, self.clip_param
)
value_losses = (value_batch - returns_batch).pow(2)
value_losses_clipped = (value_clipped - returns_batch).pow(2)
value_loss = torch.max(value_losses, value_losses_clipped).mean()
else:
value_loss = (returns_batch - value_batch).pow(2).mean()

#ppo的总损失
loss = surrogate_loss + self.value_loss_coef * value_loss - self.entropy_coef * entropy_batch.mean()

# Symmetry loss 镜像损失
if self.symmetry:
# obtain the symmetric actions
# if we did augmentation before then we don't need to augment again 前面做过就不需要再做一次镜像(数据增强)了

if not self.symmetry["use_data_augmentation"]:
data_augmentation_func = self.symmetry["data_augmentation_func"]
obs_batch, _ = data_augmentation_func(
obs=obs_batch, actions=None, env=self.symmetry["_env"], obs_type="policy"
)
# compute number of augmentations per sample
num_aug = int(obs_batch.shape[0] / original_batch_size)

# actions predicted by the actor for symmetrically-augmented observations
# act_inference() 通常输出 deterministic action,也就是 actor 的均值动作,不采样。
mean_actions_batch = self.policy.act_inference(obs_batch.detach().clone())

# compute the symmetrically augmented actions
# note: we are assuming the first augmentation is the original one.
# We do not use the action_batch from earlier since that action was sampled from the distribution.
# However, the symmetry loss is computed using the mean of the distribution.
action_mean_orig = mean_actions_batch[:original_batch_size]
_, actions_mean_symm_batch = data_augmentation_func(
obs=None, actions=action_mean_orig, env=self.symmetry["_env"], obs_type="policy"
)

# compute the loss (we skip the first augmentation as it is the original one)
mse_loss = torch.nn.MSELoss()
symmetry_loss = mse_loss(
mean_actions_batch[original_batch_size:], actions_mean_symm_batch.detach()[original_batch_size:]
)
# add the loss to the total loss
if self.symmetry["use_mirror_loss"]:
loss += self.symmetry["mirror_loss_coeff"] * symmetry_loss
else:
symmetry_loss = symmetry_loss.detach()

# Random Network Distillation loss 注意这里它使用独立的self.rnd_optimizer,所以没有加到ppo的loss里
#
if self.rnd:
# predict the embedding and the target
predicted_embedding = self.rnd.predictor(rnd_state_batch)
target_embedding = self.rnd.target(rnd_state_batch).detach()
# compute the loss as the mean squared error
mseloss = torch.nn.MSELoss()
rnd_loss = mseloss(predicted_embedding, target_embedding)

# Discriminator loss.
# 提取两个trans
policy_state, policy_next_state = sample_amp_policy
expert_state, expert_next_state = sample_amp_expert
# 归一化
if self.amp_normalizer is not None:
with torch.no_grad():
policy_state = self.amp_normalizer.normalize_torch(policy_state, self.device)
policy_next_state = self.amp_normalizer.normalize_torch(policy_next_state, self.device)
expert_state = self.amp_normalizer.normalize_torch(expert_state, self.device)
expert_next_state = self.amp_normalizer.normalize_torch(expert_next_state, self.device)
# 计算判别器输出
policy_d = self.discriminator(torch.cat([policy_state, policy_next_state], dim=-1))
expert_d = self.discriminator(torch.cat([expert_state, expert_next_state], dim=-1))

expert_loss = torch.nn.MSELoss()(expert_d, torch.ones(expert_d.size(), device=self.device))
policy_loss = torch.nn.MSELoss()(policy_d, -1 * torch.ones(policy_d.size(), device=self.device))

amp_loss = 0.5 * (expert_loss + policy_loss)
grad_pen_loss = self.discriminator.compute_grad_pen(*sample_amp_expert, lambda_=10)
loss += self.amploss_coef * amp_loss + self.amploss_coef * grad_pen_loss

# 开始更新
# Compute the gradients
# -- For PPO
self.optimizer.zero_grad()
loss.backward()
# -- For RND
if self.rnd:
self.rnd_optimizer.zero_grad() # type: ignore
rnd_loss.backward()

# Collect gradients from all GPUs
if self.is_multi_gpu:
self.reduce_parameters()

# Apply the gradients
# -- For PPO
nn.utils.clip_grad_norm_(self.policy.parameters(), self.max_grad_norm)
self.optimizer.step()
# -- For RND
if self.rnd_optimizer:
self.rnd_optimizer.step()

if self.amp_normalizer is not None:
self.amp_normalizer.update(policy_state.cpu().numpy())
self.amp_normalizer.update(expert_state.cpu().numpy())



return loss_dict

三个 generator 每次各给一个 mini-batch。设并行环境数为 $N$、rollout 长度为 $T$、mini-batch 数量为 $M$,那么未做 symmetry augmentation 时:

1
2
3
4
5
6
7
8
9
10
mini_batch_size = N * T / M

obs_batch [B, actor_obs_dim]
critic_obs_batch [B, critic_obs_dim]
actions_batch [B, action_dim]
policy_state [B, 52]
policy_next_state [B, 52]
expert_state [B, 52]
expert_next_state [B, 52]
discriminator input [B, 104]

PPO、policy AMP 和 expert AMP 三组数据只要求当前 mini-batch 大小一致,不要求它们来自同一时刻。PPO 和 policy AMP 数据来自策略与环境交互,expert AMP 数据由 AMPLoader 独立随机采样。

参数更新关系按计算图来看是:

1
2
3
4
5
surrogate loss、entropy loss     更新 actor
value loss 更新 critic
mirror loss 更新 actor
AMP loss、gradient penalty 更新 discriminator
RND loss 使用独立 optimizer 更新 RND predictor

当前代码在归一化 policy_stateexpert_state 后,又把这两个已经归一化的局部变量传给 amp_normalizer.update()。这是当前源码的实际执行顺序,和常见的“用原始样本更新统计量”写法不一样,后面如果检查 normalizer 统计量需要把这一点单独拿出来看。

RND 探索奖励

RND = Random Network Distillation,常用于“探索奖励”。

它有两个网络:

  • target network:随机初始化后固定不动;
  • predictor network:学习预测 target network 的输出。

如果智能体进入一个很少见过的状态,predictor 预测不准,误差大,于是给它一个 intrinsic reward。

例子:

机器人训练走路时,如果一直在原地小幅抖动,它很熟悉这些状态,RND 奖励低。

如果它尝试迈出一步,进入一个新的身体姿态,RND 预测误差大,于是给额外奖励:

1
“这个状态比较新,值得探索一下”

所以 RND 不是任务奖励,而是鼓励探索的新奇奖励。

self.alg_cfg["rnd_cfg"]["weight"] *= env.unwrapped.step_dt 是按仿真时间步长缩放 RND 奖励权重。

如果每一步都给同样 reward,100 Hz 环境一秒拿到的总 reward 会天然翻倍。乘以 step_dt 后,reward 更接近“按真实时间积分”,不同频率之间更可比。

这里的 rnd_state 是环境专门构造的 observation group,本质上是用于判断状态新奇程度的输入特征。

固定一个随机 target network,然后读取 rnd_state,计算内在奖励,并把对应状态放进 transition:

1
2
3
rnd_state = infos["observations"]["rnd_state"]
self.intrinsic_rewards, rnd_state = self.rnd.get_intrinsic_reward(rnd_state)
self.transition.rnd_state = rnd_state.clone()

RND 的预测误差一方面转化成 intrinsic reward,加入每一步的总奖励;另一方面作为 MSE loss,用独立优化器训练 predictor。当前 TienKung 的 walk/run 配置都是 rnd_cfg=None,所以默认训练不会进入这部分。

Symmetry:data augmentation 和 mirror loss

symmetry 是左右镜像增强或约束,主要用于机器人这种左右对称结构。当前代码包含两种独立用法。

它有两种可能用途:

Data augmentation

1
2
obs_batch, actions_batch = data_augmentation_func(...)
critic_obs_batch, _ = data_augmentation_func(...)

把一批数据镜像扩增。例如:

1
左脚迈步的数据,生成右脚迈步的镜像数据

这样 PPO 更新时等于多看了一些对称样本。

Mirror loss

1
2
3
mean_actions_batch = self.policy.act_inference(obs_batch)
symmetry_loss = MSE(镜像 obs 下的动作, 原动作镜像后的动作)
loss += mirror_loss_coeff * symmetry_loss

它鼓励 policy 满足:

1
镜像后的观察,应当输出镜像后的动作

也就是让策略左右行为更一致。

当前 TienKung 的 walk、run 和带传感器任务配置都是:

1
symmetry_cfg=None

所以这部分默认没有启用。

data augmentation 作用在 PPO actor/critic 的更新数据上。

data_augmentation_func 根据机器人的左右对称关系,对观测和动作进行镜像变换。具体交换哪些维度、哪些量需要改变符号,由传入的镜像函数决定,不是 AMPPPO.update() 自己推断。

它先处理:

1
obs_batch, actions_batch,critic_obs_batch

这几项进行镜像扩容。

如果原 batch 是 $B$ 行,镜像后 obs_batchactions_batchcritic_obs_batch 变为 $2B$ 行,其他 PPO 标签也需要同步扩容。

后续 PPO 要计算:

1
2
3
4
5
6
7
每一个 obs
都需要匹配:
一个 action
一个 old log prob
一个 advantage
一个 return
一个 old value

因此,需要把这些“标签”也扩充到 2B

repeat(num_aug, 1) 表示沿 batch 维复制 num_aug 次,特征维不复制。

obsaction 需要真正镜像,因为每一维都有具体物理语义。old log probability、advantage、return 和 value target 在当前实现中直接复制。

这和 mirror loss 不是一回事。data augmentation 扩充 PPO 训练样本;mirror loss 额外构造动作均值的一致性损失。两个开关可以独立配置,也可以同时启用。

GMR 到 AMP expert transition

这里按 TienKung-Lab README 给出的处理方式记录。外部 GMR 读取 AMASS 或 OMOMO 的 SMPL-X 文件,把人体动作重定向到 TienKung,求出机器人 root pose 和 20 个关节角,最后生成 robot_data.pkl

当前仓库已经附带 motion_amp_expert/walk.txtrun.txt,walk/run 任务配置会直接加载这两个文件,所以正常训练不会在线调用 GMR,也不要求本地存在 robot_data.pkl。只有更换或增加 expert motion 时才需要重新走下面的数据处理过程。

GMR 论文和工程:

README 中记录的命令是:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
# 在外部 GMR 工程中生成 robot_data.pkl
python scripts/smplx_to_robot.py \
--smplx_file <path_to_smplx_data> \
--robot tienkung \
--save_path <path_to_save_robot_data.pkl>

# 在 TienKung-Lab 中生成 visualization 数据
python legged_lab/scripts/gmr_data_conversion.py \
--input_pkl <path_to_save_robot_data.pkl> \
--output_txt legged_lab/envs/tienkung/datasets/motion_visualization/motion.txt \
--fps 30

# 播放 visualization,并保存训练用 expert state
python legged_lab/scripts/play_amp_animation.py \
--task=walk \
--num_envs=1 \
--fps 30 \
--save_path legged_lab/envs/tienkung/datasets/motion_amp_expert/motion.txt

生成 visualization 后要把任务配置中的 amp_motion_files_display 指到新文件;生成 expert 后再把 amp_motion_files 指到新 expert 文件。两个配置对应两个不同阶段,不能混用。

robot_data.pkl

Pickle 是 Python 自带的对象序列化格式,可以把字典和 NumPy array 直接保存成二进制文件。当前 gmr_data_conversion.py 至少要求这三个 key:

1
2
3
root_pos = motion_data["root_pos"]  # [T, 3]
root_rot = motion_data["root_rot"] # [T, 4],xyzw
dof_pos = motion_data["dof_pos"] # [T, 20]

当前 GMR upstream 还会保存 fpslocal_body_poslink_body_list,但本项目的 conversion 脚本没有读取这些字段。fps 由命令行参数传入,所以运行转换时要保证 --fps 和 PKL 的实际帧率一致。

20 个关节的输入顺序按当前环境下游切片是:

1
左腿 6、右腿 6、左臂 4、右臂 4

motion_visualization/*.txt

TienKung-Lab/gmr_data_conversion.py 用相邻帧差分计算 root 和关节速度,把 root 四元数转换成 Euler XYZ,再将字段拼成每帧 52 维。输入有 $T$ 帧,差分后只剩 $T-1$ 帧。

root 角速度不是直接对四元数分量做差,而是先算相对旋转:

conversion 输出的 52 维布局是:

1
2
3
4
5
6
7
8
9
10
11
12
0:3    root_pos            3
3:6 root_euler_XYZ 3
6:12 left_leg_pos 6
12:18 right_leg_pos 6
18:22 left_arm_pos 4
22:26 right_arm_pos 4
26:29 root_lin_vel 3
29:32 root_ang_vel 3
32:38 left_leg_vel 6
38:44 right_leg_vel 6
44:48 left_arm_vel 4
48:52 right_arm_vel 4

文件扩展名虽然是 .txt,内容实际是 JSON。这个文件只用于 play_amp_animation.pyvisualize_motion() 播放、检查重定向效果,不会被训练时的 AMPLoader 直接当作 expert 数据。

motion_amp_expert/*.txt

play_amp_animation.py 逐帧调用 env.visualize_motion(time)。环境把 visualization 中的 root、关节位置和速度强制写入仿真,执行一次 sim.step(),然后读取左右手和左右脚相对 root 的位置。返回值重新组织成另一种 52 维数据:

1
2
3
4
5
6
7
8
9
10
11
12
0:4    right_arm_pos       4
4:8 left_arm_pos 4
8:14 right_leg_pos 6
14:20 left_leg_pos 6
20:24 right_arm_vel 4
24:28 left_arm_vel 4
28:34 right_leg_vel 6
34:40 left_leg_vel 6
40:43 left_hand_pos 3
43:46 right_hand_pos 3
46:49 left_foot_pos 3
49:52 right_foot_pos 3

下面这组数据来自 motion_amp_expert/walk.txt,不是 gmr_data_conversion.py 直接生成的 visualization 帧:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
{
"LoopMode": "Wrap",
"FrameDuration": 0.033,
"EnableCycleOffsetPosition": true,
"EnableCycleOffsetRotation": true,
"MotionWeight": 0.5,

"Frames":
[
[1.665109992027282715e-01, -2.995870113372802734e-01, 5.194820165634155273e-01, -8.727329969406127930e-01, 1.425890028476715088e-01, 3.485980033874511719e-01, -8.257740139961242676e-01, -4.664500057697296143e-01, 1.188289970159530640e-01, -8.954299986362457275e-02, -7.063999772071838379e-02, 1.726649999618530273e-01, 9.724999777972698212e-03, -6.934099644422531128e-02, 1.775089949369430542e-01, -3.537440001964569092e-01, 1.253779977560043335e-01, 1.244233012199401855e+00, 5.458000116050243378e-03, -1.903460025787353516e-01, 4.995326042175292969e+00, -8.987608909606933594e+00, 1.558444976806640625e+01, -2.618200111389160156e+01, 4.277681827545166016e+00, 1.045794296264648438e+01, -2.477322387695312500e+01, -1.399349117279052734e+01, 3.564877033233642578e+00, -2.686276912689208984e+00, -2.119208097457885742e+00, 5.179965019226074219e+00, 2.917360067367553711e-01, -2.080219030380249023e+00, 5.325274944305419922e+00, -1.061231994628906250e+01, 3.761337041854858398e+00, 3.732698059082031250e+01, 1.637270003557205200e-01, -5.710369110107421875e+00, 2.785266004502773285e-02, 3.752682805061340332e-01, -1.887412369251251221e-01, 1.359958648681640625e-01, -2.611318528652191162e-01, -1.481383591890335083e-01, -1.643556505441665649e-01, 2.446372658014297485e-01, -7.309403419494628906e-01, 8.713128976523876190e-03, -1.792019233107566833e-02, -9.365547895431518555e-01],
...

]
}

visualization 和 expert 都是 52 维,但字段含义不一样。前者保留 root pose 和 root velocity,目的是把完整动作写回仿真;后者去掉 root 的绝对位姿,换成四个末端在 root 局部坐标系中的位置,目的是让 discriminator 比较机器人的运动形态。

当前仓库现有数据是:

1
2
3
4
motion_visualization/walk.txt   [736, 52],FrameDuration=0.033 s
motion_amp_expert/walk.txt [735, 52],FrameDuration=0.033 s
motion_visualization/run.txt [345, 52],FrameDuration=0.033 s
motion_amp_expert/run.txt [344, 52],FrameDuration=0.033 s

play_amp_animation.py 当前保存 motion_len - 1 帧,所以 expert 比对应 visualization 少一帧。

expert 文件仍然只保存单帧 state,不直接保存 (state, next_state)。训练启动时 AMPLoader 使用:

1
2
state      = motion(t)
next_state = motion(t + env.step_dt)

当前仿真 dt=0.005 s、decimation 为 4,因此 env.step_dt=0.02 s。expert 文件的 FrameDuration0.033 s,所以 loader 会在线性插值后取得相差 0.02 s 的两帧,而不是简单取文本中的相邻两行。

runner 配置了:

1
2
preload_transitions=True
num_preload_transitions=200000

所以训练初始化时会在训练 device 上创建:

1
2
preloaded_s       [200000, 52]  float32
preloaded_s_next [200000, 52] float32

update 时 expert generator 再随机抽出 [mini_batch_size, 52]expert_stateexpert_next_state。两者拼接成 [mini_batch_size, 104],送进 discriminator,并以 +1 作为 expert target。policy AMP replay buffer 同样提供 [policy_state, policy_next_state],target 是 -1

补充

为什么要用 pop() 删除 class_name

这句:

1
policy_class = eval(self.policy_cfg.pop("class_name"))

同时做了两件事:

1
2
从 self.policy_cfg 取出 class_name,例如 "ActorCritic"
从 self.policy_cfg 字典中删除 class_name

class_name 用于让 runner 选择要实例化的 Python 类:

1
policy_class = eval("ActorCritic")

后面还会把剩余配置解包给构造函数:

1
2
3
4
5
6
policy_class(
num_obs,
num_privileged_obs,
self.env.num_actions,
**self.policy_cfg,
)

ActorCritic.__init__() 不需要 class_name。如果这里只读取而不删除,调用在概念上会变成:

1
2
3
4
5
6
7
8
ActorCritic(
num_obs,
num_privileged_obs,
num_actions,
class_name="ActorCritic",
init_noise_std=1.0,
# ...
)

对于不接受这个关键字参数的构造函数,Python 会报:

1
TypeError: __init__() got an unexpected keyword argument 'class_name'

所以 class_name 是 runner 用来选类的配置,不是网络构造参数。这里还要注意,pop() 会原地修改 self.policy_cfg,执行后这个字典里就不再有 class_name

to(device)torch.inference_mode()

一般把要参与神经网络 forward、loss 和 backward 的 Tensor 移到训练设备;把要传给环境 step() 的 action 移到环境设备。

这份 runner 里有两个 device:

1
2
self.device      PPO、actor、critic、discriminator 和 storage 使用的训练设备
self.env.device Isaac Lab 环境状态和物理仿真使用的设备

多数情况下两者相同,例如都使用:

1
cuda:0

代码仍然显式区分它们,因为 runner 和环境在接口上是两个对象,不能直接假设二者永远位于同一设备。多 GPU 时,每个训练进程也可能使用自己的 local rank device。

rollout 里的 action 由训练设备上的 actor 产生,送进环境前执行:

1
2
3
obs, rewards, dones, infos = self.env.step(
actions.to(self.env.device)
)

环境返回的 Tensor 再移动到 runner 的训练设备:

1
2
3
4
5
6
obs, rewards, dones, next_amp_obs = (
obs.to(self.device),
rewards.to(self.device),
dones.to(self.device),
next_amp_obs.to(self.device),
)

训练设备上主要包括:

1
2
3
4
5
6
actor / critic / discriminator 的参数和 buffer
PPO rollout storage
AMP replay buffer 和预加载 expert transition
obs、critic_obs、AMP obs
returns、advantages 和 loss 相关 Tensor
optimizer state

这里不能简单说“Python 类对象在 CPU 上,网络在 GPU 上”。更准确的说法是:Python 对象本身由 Python 进程管理,没有 PyTorch 意义上的 .device;对象内部的 Tensor、nn.Parameter 和 registered buffer 才真正分配在 CPU 或 CUDA 上。

例如:

1
policy = ActorCritic(...).to("cuda:0")

policy 仍然是一个普通 Python 变量,但 policy.parameters() 和 registered buffers 已经移动到 cuda:0。optimizer 本身也是 Python 对象,它维护的状态 Tensor 会跟随对应参数参与设备上的计算。

还要注意 Tensor 的 .to() 通常不是原地修改:

1
2
obs.to(self.device)          # 返回转换后的 Tensor,但这里没有接住
obs = obs.to(self.device) # obs 才指向转换后的 Tensor

如果原 Tensor 已经在目标 device 且 dtype 不变,PyTorch 可以直接返回原 Tensor;否则会创建转换后的 Tensor。nn.Module.to() 会递归移动模块中的参数和 buffer,并返回模块自身,所以常写成:

1
policy = policy.to(self.device)

rollout 为什么使用 torch.inference_mode()

runner 的 rollout 被包在:

1
2
3
4
with torch.inference_mode():
actions = self.alg.act(obs, privileged_obs, amp_obs)
obs, rewards, dones, infos = self.env.step(actions.to(self.env.device))
# collect transitions and compute returns

rollout 阶段只需要当前 policy 产生动作、critic 估计 value,并把结果写进 storage,不在这一阶段调用 backward()。使用 inference_mode() 可以关闭 autograd 记录,避免为这些 forward 构建计算图,减少显存占用和额外开销。

采样时保存的是旧策略的数据:

1
2
3
4
5
actions
values
old action log probability
old action mean
old action standard deviation

这些量作为固定的 rollout 数据参与后续 PPO update,不需要保留 rollout forward 的梯度图。真正需要梯度的是 self.alg.update() 中重新执行的 policy forward,所以 update 位于 torch.inference_mode() 之外。

torch.inference_mode() 不等于 model.eval()

1
2
torch.inference_mode()  控制 autograd 是否记录计算图
model.eval() 切换 dropout、batch normalization 等模块的运行模式

当前 runner 在进入训练前调用 self.train_mode(),rollout 即使在 inference_mode() 中,module 仍保持 training mode。inference_mode() 只是不记录梯度,不会自动把网络切换成 eval mode。

它和 torch.no_grad() 的目的接近,但 inference_mode() 关闭的 autograd 跟踪更多,推理开销更低,限制也更强。这里 rollout 产生的 Tensor 后面只作为 storage 中的固定数据使用,所以适合放在 inference_mode() 中。