PPO 代码实现记录(Pendulum)

这篇是 train.pyagent.py 的实现记录,原理部分在 NOTE.md 里已经写过了,这里只记代码怎么搭起来的、每块在干嘛、哪里踩过坑。环境是 Pendulum-v1,连续动作、单环境,固定 200 步 truncate。先看 train.py 的主循环,再拆 agent.py 里 actor/critic/buffer/update 那几块。

后面加 advantage 标准化和梯度裁剪两个 trick 是因为一开始训不稳,跑到后面会崩,这部分单独放最后记。


一、train.py:主循环

导包、建倒立摆环境

先导包。gymnasium 给环境,torch 存模型,time/os 拼日志路径,matplotlib 最后画曲线。PPOAgentdevice 从自己写的 agent.py 里拿,device 是 agent.py 里统一判好的 cuda/cpu。

建环境用 gym.make,Pendulum 的 reward 全是负的,立得越稳越接近 0,所以 0 是上限。

1
2
3
4
5
6
7
8
9
import gymnasium as gym
import torch
import time
import os
import matplotlib.pyplot as plt
from agent import PPOAgent, device

scenario = "Pendulum-v1" #这个环境最大reward就是0
env = gym.make(scenario)

日志目录

按时间戳建一个子目录,每次训练的模型分开放,不会互相覆盖。timestamp 是当前时间字符串,log_dir 拼成 ./logs/Pendulum-v1/20260629-105600 这种,exist_ok=True 是目录已存在也不报错。

1
2
3
4
current_path = "./"
timestamp = time.strftime("%Y%m%d-%H%M%S", time.localtime())
log_dir = os.path.join(current_path, "logs", scenario, timestamp)
os.makedirs(log_dir, exist_ok=True)

超参和记录用的变量

STATE_DIM/ACTION_DIM 直接从环境的 space 里读,不用手填,换环境也不用改。Pendulum 是 3 维状态、1 维动作。

MAX_STEPS=200 跟 Pendulum 一局 200 步对齐。BATCH_SIZE=50 是后面切 minibatch 的大小,传给 agent。

best_reward 初始设成负无穷,这样第一个 episode 一定能刷新它、触发存模型。三个 BUFFER 是 list,训练全程把每个 episode 的 reward 和两个 loss 攒起来,最后画曲线。

1
2
3
4
5
6
7
8
9
10
STATE_DIM = env.observation_space.shape[0]
ACTION_DIM = env.action_space.shape[0]
MAX_EPISODES = 1000
MAX_STEPS = 200
BATCH_SIZE = 50
PRINT_INTERVAL = 100 # 每隔多少个 episode 打印一次
best_reward = float('-inf')
REWARD_BUFFER = []
ACTOR_LOSS_BUFFER = []
CRITIC_LOSS_BUFFER = []

ACTION_LOW/HIGH 是环境允许的动作范围,Pendulum 是 [-2, 2]。采样出来的动作可能超出这个范围,送进环境前要 clip 一下。

1
2
3
# 环境的动作范围,用于送进 env 前 clip
ACTION_LOW = env.action_space.low
ACTION_HIGH = env.action_space.high

建 agent 全局实例

把上面读到的维度和超参传进去,建一个 PPOAgent 实例。它内部会建好 actor、critic、两个 optimizer 和 buffer(具体看后面 agent.py 部分)。这个 agent 是全局的,整个训练只建一次,后面采样、更新、存模型全调它。

1
agent = PPOAgent(STATE_DIM, ACTION_DIM, device=device, batch_size=BATCH_SIZE)

采样一整局

这版是”一局一更”:每个 episode 从头跑到尾(最多 200 步),边跑边往 buffer 里塞 transition,跑完再 update 一次。

每个 episode 开头 reset 拿初始状态,episode_reward 累计这一局的总回报,last_donenext_state 留给跑完之后 bootstrap 用。

1
2
3
4
5
for episode in range(MAX_EPISODES):
state, _ = env.reset()
episode_reward = 0
last_done = False
next_state = state

每一步先用 get_action 拿三样东西:采样的动作、当前状态的 value、这个动作的 log_prob。value 和 log_prob 现在不用,但更新时要拿来算 GAE 和 ratio,所以一起存。

1
2
for step in range(MAX_STEPS):
action, value, log_prob = agent.get_action(state)

这里第一个坑:送进环境的动作和存进 buffer 的动作不是同一个clipped_action 是 clip 到 [-2, 2] 后送进 env.step 的,但 add_memo 存的是没 clip 的原始 action。因为算 ratio 要用旧策略对”实际采的那个动作”的 log_prob,存了 clip 后的动作 log_prob 就对不上,ratio 算错。

1
2
3
4
# 送进环境前裁剪到合法范围,buffer 里仍存原始采样动作
clipped_action = action.clip(ACTION_LOW, ACTION_HIGH)
next_state, reward, terminated, truncated, _ = env.step(clipped_action)
done = terminated or truncated

第二个坑:存进 buffer 的 done 用 terminated 不是 done。Pendulum 跑到 200 步是 truncated(时间到被截断),不是真的失败终止。GAE 里 done 的作用是切断序列,truncation 不该当终止 —— 它后面其实还有价值,要靠 bootstrap 接上。所以 add_memo 传的是 terminatedlast_done 也记 terminated。循环的退出条件才用合并的 done(截断或终止都该停)。

1
2
3
4
5
6
7
# GAE 用的 done 只看真正的终止(truncation 不算终止,要 bootstrap)
agent.replay_buffer.add_memo(state, action, reward, terminated, value, log_prob)
state = next_state
episode_reward += reward
last_done = terminated
if done:
break

收尾 bootstrap + update

一局采完,最后那个 next_state 是没存进 buffer 的(它没对应动作),但 GAE 算最后一步的 delta 需要 V(s_T)。所以这里再 forward 一次拿 last_value 做 bootstrap,连同 last_done 一起传给 update。这正是 GAE 里 t == T-1 那个分支用到的值。

get_action 返回的 last_value 是 numpy 数组,取 [0]float 是因为单环境,只要那一个标量。

1
2
3
# 一整段 rollout 采完,对最后一个状态算 value 做 bootstrap,再更新
_, last_value, _ = agent.get_action(next_state)
actor_loss, critic_loss = agent.update(float(last_value[0]), last_done)

记录、存模型、打印

update 返回的平均 loss 和这局 reward 都塞进 BUFFER。reward 刷新 best 就存一次模型:save_model 存一份固定名字的(覆盖式,永远是最好的那个),再往 log_dir 里按 episode 存一份带编号的留档。

1
2
3
4
5
6
7
REWARD_BUFFER.append(episode_reward)
ACTOR_LOSS_BUFFER.append(actor_loss)
CRITIC_LOSS_BUFFER.append(critic_loss)
if episode_reward > best_reward:
best_reward = episode_reward
agent.save_model()
torch.save(agent.actor.state_dict(), os.path.join(log_dir, f"{episode}_actor.pth"))

PRINT_INTERVAL(100)个 episode 打印一次最近这 100 局的平均 reward。单局 reward 抖得厉害,看平均才看得出趋势。

1
2
3
4
# 每隔 PRINT_INTERVAL 个 episode 打印一次最近这段的平均 reward
if (episode + 1) % PRINT_INTERVAL == 0:
avg_reward = sum(REWARD_BUFFER[-PRINT_INTERVAL:]) / PRINT_INTERVAL
print(f"episode: {episode + 1}, avg reward: {avg_reward:.1f}, best reward: {best_reward:.1f}")

画曲线

训练跑完关环境,把 reward、actor loss、critic loss 三条曲线竖着画三张子图存成 png。纯记录用,看收敛情况。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
env.close()

# 训练结束,把 reward 和 loss 曲线画出来存到当前文件夹
fig, axes = plt.subplots(3, 1, figsize=(8, 10))

axes[0].plot(REWARD_BUFFER)
axes[0].set_title("Episode Reward")
axes[0].set_xlabel("Episode")
axes[0].set_ylabel("Reward")

axes[1].plot(ACTOR_LOSS_BUFFER, color="tab:orange")
axes[1].set_title("Actor Loss")
axes[1].set_xlabel("Episode")
axes[1].set_ylabel("Loss")

axes[2].plot(CRITIC_LOSS_BUFFER, color="tab:green")
axes[2].set_title("Critic Loss")
axes[2].set_xlabel("Episode")
axes[2].set_ylabel("Loss")

fig.tight_layout()
save_path = f"train_curve_{timestamp}.png"
fig.savefig(save_path)
print(f"曲线已保存到: {save_path}")

二、agent.py:网络

顶部 import 和 device

Normal 是高斯分布,actor 要用它采连续动作。device 在这里统一判一次,cuda 有就用 cuda,train.py 直接 import 这个变量,省得两边各判一遍。

1
2
3
4
5
6
7
from torch import nn
import torch
from torch.distributions import Normal
import numpy as np
from torch import optim

device = torch.device("cuda" if torch.cuda.is_available() else "cpu")

Actor

两层 256 的 MLP,最后分出两个头:fc_mean 出均值、fc_std 出标准差,凑成一个高斯分布去采动作。

__init__ 里把每层和要用的激活都先建好。注意分了两个独立的输出头 fc_meanfc_std,共享前面 fc1/fc2 的特征。

forward 是关键,两个细节:

  • meantanh 压到 [-1, 1] 再乘 2,正好对上 Pendulum 的动作范围 [-2, 2],让均值天生落在合法区间附近。
  • stdsoftplus 保证恒正(标准差不能是负数),后面再 + 1e-5 是兜底:softplus 在输入很负时输出会趋近 0,std 真到 0 的话 Normal 构造会报错、log_prob 也会炸,加个很小的数把它从 0 顶开一点。

另外这是 state-dependent std:std 跟着状态由网络算出来。很多复现用的是全局共享的 log_std(一个 nn.Parameter,跟状态无关),那种更稳一点,这里用的是前者。

get_action 包了 no_grad:采样阶段不需要梯度,动作和 log_prob 只是当数据存起来,梯度是更新阶段重新 forward 才有。dist.sample() 采一个动作,log_prob 对动作维 .sum(dim=-1) —— 多维连续动作,整个动作的 log_prob 是各维之和(这里动作 1 维,求不求和数值一样,但写成通用形式)。

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
class Actor(nn.Module):
def __init__(self, state_dim, action_dim,hidden_dim=256):
super(Actor, self).__init__()
self.fc1 = nn.Linear(state_dim, hidden_dim) # 共享特征层
self.fc2 = nn.Linear(hidden_dim, hidden_dim)
self.fc_mean = nn.Linear(hidden_dim, action_dim) # 输出均值的头
self.fc_std = nn.Linear(hidden_dim, action_dim) # 输出标准差的头
self.tanh = nn.Tanh()
self.relu = nn.ReLU()
self.softplus = nn.Softplus()

def forward(self, x):
x = self.relu(self.fc1(x))
x = self.relu(self.fc2(x))
mean = self.tanh(self.fc_mean(x))*2 # tanh 压到 [-1,1] 再 *2,对齐动作范围 [-2,2]
std = self.softplus(self.fc_std(x))+ 1e-5 # softplus 保证恒正,+1e-5 防 std=0 报错
return mean, std

def get_action(self, state):
with torch.no_grad(): # 采样不需要梯度,只是收集数据
mean, std = self.forward(state)
dist = Normal(mean, std) # 用 (mean,std) 组高斯分布
action = dist.sample() # 从分布里采一个动作
# 多维连续动作:对动作维求和,得到整个动作的 log_prob
log_prob = dist.log_prob(action).sum(dim=-1)
return action, log_prob

Critic

跟 actor 一样两层 256,区别是最后只出一个标量。输入状态,输出 V(s),估这个状态值多少分。没有激活函数收尾,value 可正可负(Pendulum 的 value 是负的)。

1
2
3
4
5
6
7
8
9
10
11
12
13
class Critic(nn.Module):
def __init__(self, state_dim, hidden_dim=256):
super(Critic, self).__init__()
self.fc1 = nn.Linear(state_dim, hidden_dim)
self.fc2 = nn.Linear(hidden_dim, hidden_dim)
self.fc_value = nn.Linear(hidden_dim, 1) # 输出单个标量 value
self.relu = nn.ReLU()

def forward(self, x):
x = self.relu(self.fc1(x))
x = self.relu(self.fc2(x))
value = self.fc_value(x) # 不加激活,value 可正可负
return value

三、agent.py:buffer

就是个普通 list 容器,存一整段 rollout 的六样东西:state、action、reward、done、value、log_prob。

add_memo 每步往六个 list 各 append 一项。

sample 把六个 list 整段转成 numpy 返回,按时间顺序、不在这里打乱 —— 因为 GAE 必须按时间从后往前递推,顺序乱了 advantage 就错了。打乱是后面 update 切 minibatch 才做的事。

clear 把六个 list 清空。PPO 是 on-policy,每批数据更新完就作废,旧数据不能留着重复用。

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
class ReplyMemory:  #用于算算GAE 和 更新网络
def __init__(self):
self.states_cap = []
self.actions_cap = []
self.rewards_cap = []
self.dones_cap = []
self.values = []
self.log_probs = []

def add_memo(self, state, action, reward, done, value, log_prob):
# 每步把六样东西各存一份
self.states_cap.append(state)
self.actions_cap.append(action)
self.rewards_cap.append(reward)
self.dones_cap.append(done)
self.values.append(value)
self.log_probs.append(log_prob)

def sample(self):
# 按采样的时间顺序整段返回,不在这里打乱(GAE 必须按时间顺序算)
return (np.array(self.states_cap),
np.array(self.actions_cap),
np.array(self.rewards_cap),
np.array(self.dones_cap),
np.array(self.values),
np.array(self.log_probs))

def clear(self):
# on-policy,每批用完就清空
self.states_cap = []
self.actions_cap = []
self.rewards_cap = []
self.dones_cap = []
self.values = []
self.log_probs = []

四、agent.py:PPOAgent 初始化和 get_action

init

超参都是主流默认值:GAMMA=0.99 折扣、LAMBDA=0.95 GAE 的 λ、EPSILON=0.2 clip 范围、EPOCHS=10 同一批数据训几遍、C1=0.5 value loss 权重、C2=0.01 entropy 权重。MAX_GRAD_NORM 是后来加梯度裁剪才有的,见最后一节。

actor 和 critic 各自建网络、各自一个 Adam optimizer。两个独立 optimizer 是有意的:这样 critic loss 再大也只流进 critic 自己的参数,不会顺着梯度污染 actor。最后建一个 buffer 挂上去。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
class PPOAgent:
def __init__(self, state_dim, action_dim, device, batch_size):
self.LR_ACTOR = 0.0002
self.LR_CRITIC = 0.0002
self.GAMMA = 0.99
self.LAMBDA = 0.95
self.EPSILON = 0.2
self.EPOCHS = 10
self.C1 = 0.5 # value loss 权重
self.C2 = 0.01 # entropy 权重
self.MAX_GRAD_NORM = 1 # 梯度裁剪阈值(兜底,挡掉偶发大梯度)

self.device = device
self.batch_size = batch_size

self.actor = Actor(state_dim, action_dim).to(device)
self.critic = Critic(state_dim).to(device)
# actor、critic 各用一个 optimizer,梯度互不干扰
self.actor_optimizer = optim.Adam(self.actor.parameters(), lr=self.LR_ACTOR)
self.critic_optimizer = optim.Adam(self.critic.parameters(), lr=self.LR_CRITIC)

self.replay_buffer = ReplyMemory()

get_action

给单个状态采动作。state 是一维 numpy,先转 tensor 再 unsqueeze(0) 凑出 batch 维(网络要的是 [batch, state_dim]),出来的结果再 [0] 把 batch 维取回去。

value 是另外单独 forward critic 拿的(也包 no_grad),跟动作分开算。最后三样都 detach().cpu().numpy() 转回 numpy 给 train.py 用。

1
2
3
4
5
6
7
def get_action(self, state):
state = torch.FloatTensor(state).unsqueeze(0).to(self.device) # 加 batch 维
action,log_prob = self.actor.get_action(state)
with torch.no_grad():
value = self.critic(state) # value 单独算
# 三样都转回 numpy,[0] 去掉 batch 维
return action.detach().cpu().numpy()[0], value.detach().cpu().numpy()[0], log_prob.detach().cpu().numpy()[0]

五、agent.py:update 算 GAE

update 是核心,分几步:取数据、算 GAE、转 tensor、minibatch 训练、清 buffer。这一节先到 GAE。

取数据并定型

先把 buffer 整段取出来,逐个转成指定 dtype 的 numpy。valueslog_probsreshape(-1) 拍平成一维 [T],因为它们从 buffer 出来可能带多余的维度,后面算 delta 要按一维标量对齐。T 是这段 rollout 的长度。

1
2
3
4
5
6
7
8
9
10
11
12
def update(self, last_value, last_done):
# 1. 取出整段 rollout(按时间顺序,不能打乱)
memo_states, memo_actions, memo_rewards, memo_dones, memo_values, memo_old_log_probs = \
self.replay_buffer.sample()

memo_states = np.array(memo_states, dtype=np.float32)
memo_actions = np.array(memo_actions, dtype=np.float32)
memo_rewards = np.array(memo_rewards, dtype=np.float32)
memo_dones = np.array(memo_dones, dtype=np.float32)
memo_values = np.array(memo_values, dtype=np.float32).reshape(-1) # [T]
memo_old_log_probs = np.array(memo_old_log_probs, dtype=np.float32).reshape(-1) # [T]
T = len(memo_rewards)

反向递推 GAE

从最后一步往前推。这段在 epoch 循环外面,整个 update 只算一次 —— GAE 用的是旧策略采样时存下的 value,不该在训练中途跟着网络变。

最后一步(t == T-1)的 next_value 用传进来的 last_value(train.py 里 bootstrap 那个),next_non_terminallast_done。其余步用 buffer 里下一步的 value 和当前步的 done。

next_non_terminal = 1 - done:done=1 时它是 0,delta 里 next_value * 0 就把后面的价值切掉了,递推式里 last_gae * 0 也归零,序列在终止处断开,不会把上一局的回报错误接到这一局。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
# 2. 反向递推算 GAE(在 epoch 循环之外,只算一次)
memo_advantages = np.zeros(T, dtype=np.float32)
last_gae = 0.0
for t in reversed(range(T)):
if t == T - 1: # 最后一步用 bootstrap 进来的值
next_value = last_value
next_non_terminal = 1.0 - float(last_done)
else: # 其余步用下一步的 value
next_value = memo_values[t + 1]
next_non_terminal = 1.0 - memo_dones[t]
# TD 误差
delta = memo_rewards[t] + self.GAMMA * next_value * next_non_terminal - memo_values[t]
# GAE 递推:done 处 next_non_terminal=0,自动切断序列
last_gae = delta + self.GAMMA * self.LAMBDA * next_non_terminal * last_gae
memo_advantages[t] = last_gae

returns 是 critic 的目标

returns = advantage + value,这是 critic 回归的目标。注意这里用的是旧 value(rollout 时存的),不能用更新中途的新 value,否则 value loss 会退化成 advantage 的平方、critic 学不到东西(这个坑 NOTE 里详细写过)。

1
memo_returns = memo_advantages + memo_values   # critic 的回归目标

六、agent.py:update —— minibatch 训练

转 tensor

GAE 在 numpy 里算完,这里统一转成 tensor 丢到 device 上,准备进网络。

1
2
3
4
5
6
# 3. 转成 tensor
memo_states = torch.FloatTensor(memo_states).to(self.device)
memo_actions = torch.FloatTensor(memo_actions).to(self.device)
memo_old_log_probs = torch.FloatTensor(memo_old_log_probs).to(self.device)
memo_advantages = torch.FloatTensor(memo_advantages).to(self.device)
memo_returns = torch.FloatTensor(memo_returns).to(self.device)

训练循环

对同一批数据训 EPOCHS=10 遍,每遍把下标打乱再切 minibatch。

算 GAE 时这些数据是一条有先后的轨迹,算完之后进训练就当成一堆可独立采样的样本,所以这里可以打乱。

1
2
3
4
5
6
7
# 4. 对同一批数据训练 EPOCHS 遍,每遍打乱后切 minibatch
actor_loss_sum, critic_loss_sum, n_batches = 0.0, 0.0, 0
for _ in range(self.EPOCHS):
indices = np.arange(T)
np.random.shuffle(indices)
for start in range(0, T, self.batch_size):
batch = indices[start:start + self.batch_size]

每个 minibatch 内部,先用当前 actor 重新 forward 算 new_log_probs。动作还是旧的、固定的,只是拿更新中的新策略重新评估它的概率。entropy 后面进 loss 鼓励探索。

1
2
3
4
5
# 用当前策略重新评估旧动作的 log_prob(动作固定,策略在变)
mean, std = self.actor(memo_states[batch])
dist = Normal(mean, std)
new_log_probs = dist.log_prob(memo_actions[batch]).sum(dim=-1)
entropy = dist.entropy().sum(dim=-1).mean()

ratio = exp(新 log_prob − 旧 log_prob),就是新旧策略对同一动作的概率比。surr1/surr2 是 PPO 的 clip 目标:一个不裁、一个把 ratio 夹在 [1−ε, 1+ε] 里,取 min 再取负。取负是因为 optimizer 是最小化、而 PPO 的目标要最大化。这个负号漏了策略会朝反方向更新,排查不收敛先看这里。

1
2
3
4
ratio = torch.exp(new_log_probs - memo_old_log_probs[batch])
surr1 = ratio * memo_advantages[batch]
surr2 = torch.clamp(ratio, 1.0 - self.EPSILON, 1.0 + self.EPSILON) * memo_advantages[batch]
actor_loss = -torch.min(surr1, surr2).mean() # 取负:optimizer 最小化,目标要最大化

critic 这边用当前网络重算 value,跟前面算好的 returns 做 MSE。squeeze(-1) 把 critic 输出的 [batch,1] 压成 [batch],跟 returns 对齐。

1
2
new_values = self.critic(memo_states[batch]).squeeze(-1)
critic_loss = ((new_values - memo_returns[batch]) ** 2).mean()

总 loss 三项合一:actor_loss + C1·critic_loss − C2·entropy。entropy 前面是减号,因为要鼓励熵大(探索),最小化 loss 等于最大化熵。

1
loss = actor_loss + self.C1 * critic_loss - self.C2 * entropy

两个 optimizer 各自清梯度,一次 backward 把整个 loss 的梯度都算出来(actor、critic 的参数各走各的图),再各自 step

1
2
3
4
5
6
7
8
9
self.actor_optimizer.zero_grad()
self.critic_optimizer.zero_grad()
loss.backward()
self.actor_optimizer.step()
self.critic_optimizer.step()

actor_loss_sum += actor_loss.item()
critic_loss_sum += critic_loss.item()
n_batches += 1

清 buffer、返回 loss

训完这批 clear 掉(on-policy 不留旧数据),返回平均 loss 给 train.py 画曲线。

1
2
3
4
5
# 5. 这批数据用完就丢
self.replay_buffer.clear()

# 返回这轮 rollout 的平均 loss,给训练脚本记录用
return actor_loss_sum / n_batches, critic_loss_sum / n_batches

save_model

actor、critic 各存一份 state_dict,train.py 里 reward 刷新 best 时调一次。

1
2
3
def save_model(self):
torch.save(self.actor.state_dict(), "ppo_actor.pth")
torch.save(self.critic.state_dict(), "ppo_critic.pth")

训练结果:
train_curve_20260618-154219

可以看到随着训练进行确实是收敛的趋势,尤其是loss上,但是reward在后期究极发散,这是什么原因呢:

最早跑 1000 代倒立摆立不住,以为轮次不够加到 5000,结果后面继续崩。episode增大对效果也没什么改进,如图:
train_curve_20260618-163318

七、改进,加两个 trick

这里参考了一下rsl_rl库里的做法
是工程实现上少了两个常见 trick:advantage 标准化梯度裁剪

advantage 标准化

把 advantage 减均值除标准差,压到稳定尺度,让每轮梯度大小可控。位置很关键 —— 要放在 memo_returns 算完之后做,因为 returns 得用原始尺度的 advantage(critic 目标尺度不能动),只把喂给 actor 的 advantage 缩放。除数加 1e-8 防标准差为 0 时除零。

1
2
# advantage 标准化(在 returns 算完之后做,returns 仍用原始 advantage)
memo_advantages = (memo_advantages - memo_advantages.mean()) / (memo_advantages.std() + 1e-8)

只标准化 advantage,不要标准化 returns:returns 的均值方差全程在漂,拿它当除数等于让 critic 追一个尺度一直变的移动靶,反而更不稳。

梯度裁剪

backward 之后、step 之前,把梯度范数限制在 MAX_GRAD_NORM 以内,方向不变只压步长。actor、critic 各裁一次。

阈值定多少参考了 rsl_rl 的代码,它用的是 1.0,所以这里也设成 1(一开始试过 0.5,但 critic 初期 loss 几万、梯度大,0.5 太紧会让 critic 学得慢,1.0 更合适)。

1
2
3
4
5
6
loss.backward()
# 梯度裁剪:把梯度范数限制在 MAX_GRAD_NORM 以内,方向不变、只压步长
nn.utils.clip_grad_norm_(self.actor.parameters(), self.MAX_GRAD_NORM)
nn.utils.clip_grad_norm_(self.critic.parameters(), self.MAX_GRAD_NORM)
self.actor_optimizer.step()
self.critic_optimizer.step()

加完的效果

这俩平时基本没副作用:梯度小于阈值时 clip 不生效,advantage 标准化只改尺度不改谁好谁坏。加完之后明显稳了,reward 100 来个 episode 就爬上来,不再有崩了缓不过来的情况。五次测试两次在打转,其余都立住了:

1
2
3
4
5
episode: 0, reward: -238.0    成功
episode: 1, reward: -242.7 成功
episode: 2, reward: -1527.3 没立起来
episode: 3, reward: -1547.4 没立起来
episode: 4, reward: -121.1 成功

train_curve_20260618-171147

剩下还有些尖刺没完全压平。单环境 batch 太小(一局才 200 步)。后续将会把他转化成多环境并行的,毕竟机器人训练也是如此。