手撕PPO(二)代码实现记录
PPO 代码实现记录(Pendulum)
这篇是 train.py 和 agent.py 的实现记录,原理部分在 NOTE.md 里已经写过了,这里只记代码怎么搭起来的、每块在干嘛、哪里踩过坑。环境是 Pendulum-v1,连续动作、单环境,固定 200 步 truncate。先看 train.py 的主循环,再拆 agent.py 里 actor/critic/buffer/update 那几块。
后面加 advantage 标准化和梯度裁剪两个 trick 是因为一开始训不稳,跑到后面会崩,这部分单独放最后记。
一、train.py:主循环
导包、建倒立摆环境
先导包。gymnasium 给环境,torch 存模型,time/os 拼日志路径,matplotlib 最后画曲线。PPOAgent 和 device 从自己写的 agent.py 里拿,device 是 agent.py 里统一判好的 cuda/cpu。
建环境用 gym.make,Pendulum 的 reward 全是负的,立得越稳越接近 0,所以 0 是上限。
1 | import gymnasium as gym |
日志目录
按时间戳建一个子目录,每次训练的模型分开放,不会互相覆盖。timestamp 是当前时间字符串,log_dir 拼成 ./logs/Pendulum-v1/20260629-105600 这种,exist_ok=True 是目录已存在也不报错。
1 | current_path = "./" |
超参和记录用的变量
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 | STATE_DIM = env.observation_space.shape[0] |
ACTION_LOW/HIGH 是环境允许的动作范围,Pendulum 是 [-2, 2]。采样出来的动作可能超出这个范围,送进环境前要 clip 一下。
1 | # 环境的动作范围,用于送进 env 前 clip |
建 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_done 和 next_state 留给跑完之后 bootstrap 用。
1 | for episode in range(MAX_EPISODES): |
每一步先用 get_action 拿三样东西:采样的动作、当前状态的 value、这个动作的 log_prob。value 和 log_prob 现在不用,但更新时要拿来算 GAE 和 ratio,所以一起存。
1 | for step in range(MAX_STEPS): |
这里第一个坑:送进环境的动作和存进 buffer 的动作不是同一个。clipped_action 是 clip 到 [-2, 2] 后送进 env.step 的,但 add_memo 存的是没 clip 的原始 action。因为算 ratio 要用旧策略对”实际采的那个动作”的 log_prob,存了 clip 后的动作 log_prob 就对不上,ratio 算错。
1 | # 送进环境前裁剪到合法范围,buffer 里仍存原始采样动作 |
第二个坑:存进 buffer 的 done 用 terminated 不是 done。Pendulum 跑到 200 步是 truncated(时间到被截断),不是真的失败终止。GAE 里 done 的作用是切断序列,truncation 不该当终止 —— 它后面其实还有价值,要靠 bootstrap 接上。所以 add_memo 传的是 terminated,last_done 也记 terminated。循环的退出条件才用合并的 done(截断或终止都该停)。
1 | # GAE 用的 done 只看真正的终止(truncation 不算终止,要 bootstrap) |
收尾 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 | # 一整段 rollout 采完,对最后一个状态算 value 做 bootstrap,再更新 |
记录、存模型、打印
update 返回的平均 loss 和这局 reward 都塞进 BUFFER。reward 刷新 best 就存一次模型:save_model 存一份固定名字的(覆盖式,永远是最好的那个),再往 log_dir 里按 episode 存一份带编号的留档。
1 | REWARD_BUFFER.append(episode_reward) |
每 PRINT_INTERVAL(100)个 episode 打印一次最近这 100 局的平均 reward。单局 reward 抖得厉害,看平均才看得出趋势。
1 | # 每隔 PRINT_INTERVAL 个 episode 打印一次最近这段的平均 reward |
画曲线
训练跑完关环境,把 reward、actor loss、critic loss 三条曲线竖着画三张子图存成 png。纯记录用,看收敛情况。
1 | env.close() |
二、agent.py:网络
顶部 import 和 device
Normal 是高斯分布,actor 要用它采连续动作。device 在这里统一判一次,cuda 有就用 cuda,train.py 直接 import 这个变量,省得两边各判一遍。
1 | from torch import nn |
Actor
两层 256 的 MLP,最后分出两个头:fc_mean 出均值、fc_std 出标准差,凑成一个高斯分布去采动作。
__init__ 里把每层和要用的激活都先建好。注意分了两个独立的输出头 fc_mean、fc_std,共享前面 fc1/fc2 的特征。
forward 是关键,两个细节:
mean过tanh压到 [-1, 1] 再乘 2,正好对上 Pendulum 的动作范围 [-2, 2],让均值天生落在合法区间附近。std过softplus保证恒正(标准差不能是负数),后面再+ 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 | class Actor(nn.Module): |
Critic
跟 actor 一样两层 256,区别是最后只出一个标量。输入状态,输出 V(s),估这个状态值多少分。没有激活函数收尾,value 可正可负(Pendulum 的 value 是负的)。
1 | class Critic(nn.Module): |
三、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 | class ReplyMemory: #用于算算GAE 和 更新网络 |
四、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 | class PPOAgent: |
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 | def get_action(self, state): |
五、agent.py:update 算 GAE
update 是核心,分几步:取数据、算 GAE、转 tensor、minibatch 训练、清 buffer。这一节先到 GAE。
取数据并定型
先把 buffer 整段取出来,逐个转成指定 dtype 的 numpy。values 和 log_probs 用 reshape(-1) 拍平成一维 [T],因为它们从 buffer 出来可能带多余的维度,后面算 delta 要按一维标量对齐。T 是这段 rollout 的长度。
1 | def update(self, last_value, last_done): |
反向递推 GAE
从最后一步往前推。这段在 epoch 循环外面,整个 update 只算一次 —— GAE 用的是旧策略采样时存下的 value,不该在训练中途跟着网络变。
最后一步(t == T-1)的 next_value 用传进来的 last_value(train.py 里 bootstrap 那个),next_non_terminal 用 last_done。其余步用 buffer 里下一步的 value 和当前步的 done。
next_non_terminal = 1 - done:done=1 时它是 0,delta 里 next_value * 0 就把后面的价值切掉了,递推式里 last_gae * 0 也归零,序列在终止处断开,不会把上一局的回报错误接到这一局。
1 | # 2. 反向递推算 GAE(在 epoch 循环之外,只算一次) |
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 | # 3. 转成 tensor |
训练循环
对同一批数据训 EPOCHS=10 遍,每遍把下标打乱再切 minibatch。
算 GAE 时这些数据是一条有先后的轨迹,算完之后进训练就当成一堆可独立采样的样本,所以这里可以打乱。
1 | # 4. 对同一批数据训练 EPOCHS 遍,每遍打乱后切 minibatch |
每个 minibatch 内部,先用当前 actor 重新 forward 算 new_log_probs。动作还是旧的、固定的,只是拿更新中的新策略重新评估它的概率。entropy 后面进 loss 鼓励探索。
1 | # 用当前策略重新评估旧动作的 log_prob(动作固定,策略在变) |
ratio = exp(新 log_prob − 旧 log_prob),就是新旧策略对同一动作的概率比。surr1/surr2 是 PPO 的 clip 目标:一个不裁、一个把 ratio 夹在 [1−ε, 1+ε] 里,取 min 再取负。取负是因为 optimizer 是最小化、而 PPO 的目标要最大化。这个负号漏了策略会朝反方向更新,排查不收敛先看这里。
1 | ratio = torch.exp(new_log_probs - memo_old_log_probs[batch]) |
critic 这边用当前网络重算 value,跟前面算好的 returns 做 MSE。squeeze(-1) 把 critic 输出的 [batch,1] 压成 [batch],跟 returns 对齐。
1 | new_values = self.critic(memo_states[batch]).squeeze(-1) |
总 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 | self.actor_optimizer.zero_grad() |
清 buffer、返回 loss
训完这批 clear 掉(on-policy 不留旧数据),返回平均 loss 给 train.py 画曲线。
1 | # 5. 这批数据用完就丢 |
save_model
actor、critic 各存一份 state_dict,train.py 里 reward 刷新 best 时调一次。
1 | def save_model(self): |
训练结果:
可以看到随着训练进行确实是收敛的趋势,尤其是loss上,但是reward在后期究极发散,这是什么原因呢:
最早跑 1000 代倒立摆立不住,以为轮次不够加到 5000,结果后面继续崩。episode增大对效果也没什么改进,如图:
七、改进,加两个 trick
这里参考了一下rsl_rl库里的做法
是工程实现上少了两个常见 trick:advantage 标准化和梯度裁剪。
advantage 标准化
把 advantage 减均值除标准差,压到稳定尺度,让每轮梯度大小可控。位置很关键 —— 要放在 memo_returns 算完之后做,因为 returns 得用原始尺度的 advantage(critic 目标尺度不能动),只把喂给 actor 的 advantage 缩放。除数加 1e-8 防标准差为 0 时除零。
1 | # advantage 标准化(在 returns 算完之后做,returns 仍用原始 advantage) |
只标准化 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 | loss.backward() |
加完的效果
这俩平时基本没副作用:梯度小于阈值时 clip 不生效,advantage 标准化只改尺度不改谁好谁坏。加完之后明显稳了,reward 100 来个 episode 就爬上来,不再有崩了缓不过来的情况。五次测试两次在打转,其余都立住了:
1 | episode: 0, reward: -238.0 成功 |

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







