上节课我们讲完了PPO的原理,这节课我们来从零开始实现PPO,使用月球着陆器来进行教学:
如果对理论还有不懂的,请点击以下链接学习PPO理论,我讲的非常详细:
还没弄懂 PPO?看这一篇就够了:OpenAI 默认算法详解-CSDN博客
很多同学在学习 PPO(Proximal Policy Optimization)时,往往止步于复杂的数学公式。理论看懂了,真要动手写代码时却无从下手。本文不谈晦涩的公式推导,而是聚焦于工程实现。我们将基于 PyTorch,从环境搭建、网络设计(Actor-Critic)、到核心的优势函数计算与 Clip 更新,一步步手写代码,最终训练出一个能完美降落的 LunarLander 智能体。如果你也想拥有"代码级"的算法理解力,这篇教程就是为你准备的。
第一步:准备工作与超参数
首先,我们需要导入必要的库。PPO 核心需要 torch 做计算,gym 做环境交互。
我们先定义所有的"超参数"(Hyperparameters)。在写代码时,把所有可调的数字放在最前面是一个好习惯,方便后续调整。
python
import gym
import torch
import torch.nn as nn
import torch.nn.functional as F
from torch.distributions import Categorical # 用于处理离散动作分布
# === 超参数设置 (Hyperparameters) ===
# 学习率,决定网络更新的快慢
lr = 0.0005
# 折扣因子,决定agent看得多远 (0.99是标准值)
gamma = 0.99
# GAE参数,用于平衡方差和偏差
lmbda = 0.95
# PPO特有的截断参数,限制更新幅度 (通常是0.1或0.2)
eps_clip = 0.1
# 更新的次数,每次收集完数据后网络训练几轮
K_epochs = 3
# 这是一个很小的数,防止除以0报错
eps = 1e-11
第二步:设计神经网络 (Actor-Critic)
PPO 通常使用 Actor-Critic 架构。我们需要两个网络(或者一个网络两个头):
-
Actor (演员): 输入状态
,输出动作的概率分布。因为月球着陆器是离散动作(比如:什么都不做、左喷射、主引擎、右喷射),我们输出 4个概率值。
-
Critic (评论家): 输入状态
,输出状态价值
。这是一个标量(由一个数字组成)。
为了代码简洁,我们将这两个功能写在一个 class 里。
代码教学重点:
-
nn.Linear: 全连接层,负责提取特征。 -
Categorical: 这是一个非常有用的工具。PPO 的 Actor 输出的是概率(比如[0.1, 0.7, 0.1, 0.1]),我们需要根据这个概率去采样 一个动作。Categorical可以帮我们自动完成采样,并且计算(这对 PPO 的损失函数计算至关重要)。
演员:
python
class ActorCritic(nn.Module):
def __init__(self, state_dim, action_dim):
super(ActorCritic, self).__init__()
# === Actor 网络 (策略网络) ===
# 它的任务是:看到状态 -> 决定动作
self.actor = nn.Sequential(
nn.Linear(state_dim, 64), # 输入层
nn.Tanh(), # 激活函数,RL中常用Tanh
nn.Linear(64, 64), # 隐藏层
nn.Tanh(),
nn.Linear(64, action_dim), # 输出层:输出每个动作的"分数"(logits)
nn.Softmax(dim=-1) # 将分数转化为概率,和为1
)
评论家:
python
# === Critic 网络 (价值网络) ===
# 它的任务是:看到状态 -> 打分 (即 V(s))
self.critic = nn.Sequential(
nn.Linear(state_dim, 64),
nn.Tanh(),
nn.Linear(64, 64),
nn.Tanh(),
nn.Linear(64, 1) # 输出层:输出一个标量值 V(s)
)
动作收集:
state → Actor网络 → 动作概率 p → Categorical → sample → action-> log_prob
python
def act(self, state):
"""
功能:主要在'收集数据'阶段使用
输入:当前状态 state
输出:选中的动作 action, 该动作的概率对数 action_logprob
"""
# 1. 获取动作概率
action_probs = self.actor(state)
# 2. 构建分布。例如概率是[0.1, 0.8, 0.1],它会依概率采样
dist = Categorical(action_probs)
# 3. 采样一个动作
action = dist.sample()
# 4. 计算这个动作的 log概率 (PPO公式里的 log π(a|s))
action_logprob = dist.log_prob(action)
return action.item(), action_logprob
给定单个状态,采样一个动作,并返回:动作(int)对应的 log_prob(以后算 PPO 损失要用)
为什么存 log_prob 而不是 prob?
因为概率太容易数值下溢,深度学习里几乎所有概率都在 log 空间处理。
绝大多数 PPO 的实现都是这样算的。
python
def evaluate(self, state, action):
"""
功能:主要在'网络更新'阶段使用
输入:一批状态 state, 一批已经执行过的动作 action
输出:这批动作新的 log概率, 状态价值, 分布熵
"""
# 1. 计算当前网络下的动作概率
action_probs = self.actor(state)
dist = Categorical(action_probs)
# 2. 计算给定动作的 log概率
action_logprobs = dist.log_prob(action)
# 3. 计算分布的熵 (Entropy),用于增加探索性(可选)
dist_entropy = dist.entropy()
# 4. 计算状态价值 V(s)
state_values = self.critic(state)
return action_logprobs, state_values, dist_entropy
教学复盘:
这一步我们完成了"大脑"的构建。
-
act函数:就像你在玩游戏时,看到屏幕(状态),决定按哪个键(动作)。 -
evaluate函数:这是 PPO 训练时的关键。训练时,我们要拿以前收集的数据 (旧的状态和旧的动作)再喂给网络,看看现在的网络 会给出多少概率,以及现在的 Critic 认为那个状态值多少分。这是为了计算比率。
第三步:编写 Memory(记忆库)
PPO 是一种 On-policy(同策略)算法,意味着它只能学习当前策略产生的数据。一旦更新了网络,旧的数据就没用了。
所以 Memory 类非常简单,就是一个临时的列表,存满了就用来训练,训练完就清空。
python
class Memory:
def __init__(self):
self.actions = [] # 存动作
self.states = [] # 存状态
self.logprobs = [] # 存动作的概率对数
self.rewards = [] # 存奖励
self.is_terminals = [] # 存游戏是否结束(done)
def clear_memory(self):
# 训练完一次后,必须把这些旧数据删掉
del self.actions[:]
del self.states[:]
del self.logprobs[:]
del self.rewards[:]
del self.is_terminals[:]
第四步:编写 PPO 算法主体
这是最复杂的一段代码,请仔细看注释。
我们在 __init__ 中会初始化两个网络:
-
policy: 现在的网络,时刻在学习更新。 -
policy_old: 旧的网络。这是 PPO 的关键,因为计算比率时,我们需要记住更新前的概率是多少。
PPO 类初始化
python
class PPO:
def __init__(self, state_dim, action_dim):
self.lr = lr
self.gamma = gamma
self.eps_clip = eps_clip
self.K_epochs = K_epochs
# 1. 初始化当前策略网络 (我们要训练的)
self.policy = ActorCritic(state_dim, action_dim)
# 2. 初始化旧策略网络 (用于计算比率)
self.policy_old = ActorCritic(state_dim, action_dim)
# 刚开始,旧网络和新网络的权重是一样的
self.policy_old.load_state_dict(self.policy.state_dict())
# 优化器:只更新 policy 的参数
self.optimizer = torch.optim.Adam(self.policy.parameters(), lr=lr)
# 损失函数:用于计算Critic预测价值的准确度 (均方误差)
self.MseLoss = nn.MSELoss()
根据奖励算"回报"(Return)并做归一化:
python
def update(self, memory):
# === 1. 蒙特卡洛回报计算 (Monte Carlo Estimate) ===
# 我们记录的是每一步的奖励 [1, 0, 0, 100...]
# 但我们需要的是"回报"(Return),即未来的累积奖励。
rewards = []
discounted_reward = 0
# 倒序遍历:从游戏结束那一刻往前推
for reward, is_terminal in zip(reversed(memory.rewards), reversed(memory.is_terminals)):
if is_terminal:
discounted_reward = 0
# 回报 = 当前奖励 + 折扣因子 * 未来回报
discounted_reward = reward + (self.gamma * discounted_reward)
rewards.insert(0, discounted_reward) # 插到最前面
# 归一化奖励:这虽然不是公式必须的,但在工程上非常重要!
# 它可以让训练更稳定,防止奖励数值波动太大。
rewards = torch.tensor(rewards, dtype=torch.float32)
rewards = (rewards - rewards.mean()) / (rewards.std() + 1e-7)
python
# === 2. 转换数据格式 ===
# 把 list 转换成 tensor 才能喂给神经网络
old_states = torch.squeeze(torch.stack(memory.states, dim=0)).detach()
old_actions = torch.squeeze(torch.stack(memory.actions, dim=0)).detach()
old_logprobs = torch.squeeze(torch.stack(memory.logprobs, dim=0)).detach()
# === 3. 循环训练 K 次 ===
for _ in range(self.K_epochs):
# 评估旧的状态和动作:
# 这里的 logprobs 是"新策略"产生的概率
# state_values 是"新Critic"产生的价值评估
logprobs, state_values, dist_entropy = self.policy.evaluate(old_states, old_actions)
# 去掉 state_values 的多余维度
state_values = torch.squeeze(state_values)
# 计算优势函数 Advantage
# Advantage = 实际回报 - 也就是Critic预测的价值
# 如果结果是正的,说明这个动作比预期的好;负的说明比预期的差。
advantages = rewards - state_values.detach()
python
# === PPO 核心公式部分 ===
# 1. 计算比率 (pi_new / pi_old)
# 因为我们拿到的是 log_prob,所以用 exp(new - old) 等价于 new/old
ratios = torch.exp(logprobs - old_logprobs)
# 2. 计算 Surrogate Loss (代理损失)
surr1 = ratios * advantages
# 3. 计算截断后的 Loss (Clip)
surr2 = torch.clamp(ratios, 1-self.eps_clip, 1+self.eps_clip) * advantages
# 4. 总损失 = -min(surr1, surr2) + Critic损失 + 熵奖励
# PPO Loss 取负号是因为我们想最大化它,但优化器是想最小化 Loss
# 0.5 * MseLoss 是 Critic 的损失(让预测更准)
# 0.01 * dist_entropy 是鼓励探索(熵越大,分布越混乱,探索越多),防止过早收敛
loss = -torch.min(surr1, surr2) + 0.5 * self.MseLoss(state_values, rewards) - 0.01 * dist_entropy
# === 梯度更新 ===
self.optimizer.zero_grad()
loss.mean().backward()
self.optimizer.step()
# === 4. 同步网络 ===
# 训练完后,把新网络的参数复制给旧网络,为下一轮收集数据做准备
self.policy_old.load_state_dict(self.policy.state_dict())
教学复盘
这段代码是 PPO 的心脏 。如果你的代码能力较弱,这里最容易晕的是 Tensor 的维度操作(比如 squeeze 和 stack)。
-
为什么要有 policy_old?
在 ratios = torch.exp(logprobs - old_logprobs) 这一行,logprobs 是现在的网络计算出来的,old_logprobs 是我们在玩游戏时存下来的(由 policy_old 产生的)。PPO 限制这两个分布不能差太远。
-
Advantage(优势)是什么?
代码里简单的写成 advantages = rewards - state_values。
意思就是:实际发生的结果 (
) 减去 评论家之前的预测 (
)。
如果实际结果比评论家预测的好,优势就是正的,我们就要增加这个动作的概率;反之则减小。
PPO 的策略更新核心三步:
① 计算策略比率:
r(θ)=πold / πnew
② 计算两个 surrogate:
-
未截断:
ratios * advantages -
截断后:
clamp(ratios) * advantages
并取其 min。
③ 加 Critic loss + 熵奖励
然后反向传播更新。
第五步:主循环 (Main Loop)
这部分代码负责与 Gym 环境进行交互。为了让你看清楚每一步在做什么,我把逻辑拆得很细。
核心逻辑如下:
-
收集数据 :让 Agent 在环境里跑,把看到的状态、做的动作、得到的奖励都存进
memory。 -
触发更新:PPO 不像 Q-learning 每一帧都更新。它通常是"攒一批数据"再更新。比如每 2000步更新一次。
-
记录成绩:看看飞船是不是飞得越来越稳。
main() 主要做四件事:
-
建环境、确定状态维度 / 动作维度
-
初始化 PPO 与 Memory
-
在一个大 while 循环里反复:
-
与环境交互 → 收集数据(存到 Memory)
-
每隔
update_timestep步 → 调用ppo.update(memory)学习
-
-
打日志、在表现好时保存模型
可以把它理解成:
"让飞船不停玩 LunarLander 这个游戏,一边玩一边学,越玩越聪明。"
python
def main():
# === 1. 环境配置 ===
env_name = "LunarLander-v2"
env = gym.make(env_name)
state_dim = env.observation_space.shape[0] # 状态维度 (8维: 坐标, 速度, 角度等)
action_dim = env.action_space.n # 动作维度 (4维: 不动, 左, 主, 右引擎)
# === 2. 训练参数设置 ===
max_training_timesteps = 3000000 # 总共训练多少步 (300万步通常能训练得很好)
max_ep_len = 1000 # 每一局游戏最多玩多久 (防止飞船一直悬浮不降落)
update_timestep = 2000 # 【重要】每隔多少步更新一次网络
log_interval = 10 # 每隔多少局打印一次成绩
# 初始化 PPO 算法
ppo = PPO(state_dim, action_dim)
# 初始化 记忆库
memory = Memory()
# 变量初始化
time_step = 0 # 记录当前总步数
i_episode = 0 # 记录当前是第几局游戏
running_reward = 0 # 记录平均分数 (用于打印日志)
python
# === 3. 开始训练主循环 ===
# 只要没达到最大步数,就一直训练
while time_step <= max_training_timesteps:
state = env.reset() # 每一局开始,重置环境
current_ep_reward = 0
# 每一局游戏内的循环 (最多 max_ep_len步)
for t in range(1, max_ep_len+1):
# -----------------------------------------
# A. 与环境交互 (Interaction)
# -----------------------------------------
# 1. 把 numpy 数组转换成 pytorch tensor,因为神经网络只能吃 tensor
# reshape(1, -1) 是为了把它变成 [1, 8] 的形状,即"一批"包含一个状态的数据
state_tensor = torch.FloatTensor(state.reshape(1, -1))
# 2. 让旧策略网络决定动作 (注意:收集数据时必须用 old_policy)
action, action_logprob = ppo.policy_old.act(state_tensor)
# 3. 执行动作
# 注意:env.step 在 gym 不同版本返回值数量不同
# 这里假设是标准的 gym (state, reward, done, info)
# 如果报错,可能是 gym 版本较新,变成了 5 个返回值,后面加个 _ 即可
state_new, reward, done, _ = env.step(action)
# -----------------------------------------
# B. 存储数据 (Storage)
# -----------------------------------------
# 把刚才发生的一切存进记忆库
memory.states.append(state_tensor)
memory.actions.append(torch.tensor(action))
memory.logprobs.append(action_logprob)
memory.rewards.append(reward)
memory.is_terminals.append(done)
# 更新计数器和状态
time_step += 1
current_ep_reward += reward
state = state_new
python
# -----------------------------------------
# C. 学习更新 (Update)
# -----------------------------------------
# 如果攒够了 update_timestep (比如2000步) 的经验,就开始学习
if time_step % update_timestep == 0:
print(f"系统提示: 正在进行第 {time_step} 步更新...")
ppo.update(memory)
memory.clear_memory() # 学完了,把旧经验清空
# 如果飞船坠毁或成功着陆 (done=True),这局结束
if done:
break
# 记录这局的总分
running_reward += current_ep_reward
i_episode += 1
# -----------------------------------------
# D. 打印日志 (Logging)
# -----------------------------------------
# 每隔 log_interval 局,输出一次平均分
if i_episode % log_interval == 0:
avg_reward = running_reward / log_interval
print(f"局数: {i_episode} \t 总步数: {time_step} \t 平均奖励: {avg_reward:.2f}")
running_reward = 0 # 清零,重新计算下一轮平均分
# 如果分数很高 (比如 > 200),说明已经学会了,可以保存模型
if avg_reward > 200:
print("########## 表现优异!保存模型!##########")
torch.save(ppo.policy.state_dict(), './PPO_lunar_lander.pth')
if __name__ == '__main__':
main()
教学复盘与常见坑点
代码写完了!如果你现在把这三段代码拼在一个 py 文件里运行,你的 AI 就开始从零学习登月了。
在运行前,我有几个特别针对初学者的提示:
-
关于 Tensor 转换:
在 main 函数里有一行 state_tensor = torch.FloatTensor(state.reshape(1, -1))。
- 原因 :
env.reset()给你的state是一个普通的 Numpy 数组(比如[0.1, 0.2...])。但 PyTorch 的神经网络不认识 Numpy,只认识 Tensor,而且它通常要求输入是"一批"数据。所以我们用reshape(1, -1)把它伪装成"只有一个样本的一批数据"。
- 原因 :
-
关于 update_timestep:
PPO 是"批量学习"的。千万不要把 update_timestep 设得太小(比如 10)。太小会导致数据相关性太强,网络学歪。通常 2000 到 4000 是一个比较好的范围。
-
关于 Gym 的版本:
-
如果你的代码报错
ValueError: not enough values to unpack (expected 4, got 5)。 -
解决方法 :把
state_new, reward, done, _ = env.step(action)改成state_new, reward, done, truncated, _ = env.step(action)。这是因为新版 Gym 区分了"任务完成(done)"和"超时截断(truncated)"。
-
怎么看结果
-
刚开始,
平均奖励可能会是 -200左右(飞船乱飞,疯狂坠毁)。 -
训练几万步后,分数会慢慢变成 -100。
-
当分数变成 正数 时,说明它学会悬停了。
-
当分数超过 200 时,恭喜你,它已经是一个成熟的飞行员了,可以平稳落地!
这就是从零手写 PPO 的全部过程。看似复杂的算法,拆解下来其实就是:建网络 -> 存数据 -> 算比率 -> 截断更新。