PyTorch 深度强化学习实战:从零手写 PPO 算法训练你的月球着陆器智能体

上节课我们讲完了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 架构。我们需要两个网络(或者一个网络两个头):

  1. Actor (演员): 输入状态 ,输出动作的概率分布。因为月球着陆器是离散动作(比如:什么都不做、左喷射、主引擎、右喷射),我们输出 4个概率值。

  2. 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

教学复盘:

这一步我们完成了"大脑"的构建。

  1. act 函数:就像你在玩游戏时,看到屏幕(状态),决定按哪个键(动作)。

  2. 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 的维度操作(比如 squeezestack)。

  • 为什么要有 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 环境进行交互。为了让你看清楚每一步在做什么,我把逻辑拆得很细。

核心逻辑如下:

  1. 收集数据 :让 Agent 在环境里跑,把看到的状态、做的动作、得到的奖励都存进 memory

  2. 触发更新:PPO 不像 Q-learning 每一帧都更新。它通常是"攒一批数据"再更新。比如每 2000步更新一次。

  3. 记录成绩:看看飞船是不是飞得越来越稳。

main() 主要做四件事:

  1. 建环境、确定状态维度 / 动作维度

  2. 初始化 PPO 与 Memory

  3. 在一个大 while 循环里反复:

    • 与环境交互 → 收集数据(存到 Memory)

    • 每隔 update_timestep 步 → 调用 ppo.update(memory) 学习

  4. 打日志、在表现好时保存模型

可以把它理解成:

"让飞船不停玩 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 就开始从零学习登月了。

在运行前,我有几个特别针对初学者的提示:

  1. 关于 Tensor 转换:

    在 main 函数里有一行 state_tensor = torch.FloatTensor(state.reshape(1, -1))。

    • 原因env.reset() 给你的 state 是一个普通的 Numpy 数组(比如 [0.1, 0.2...])。但 PyTorch 的神经网络不认识 Numpy,只认识 Tensor,而且它通常要求输入是"一批"数据。所以我们用 reshape(1, -1) 把它伪装成"只有一个样本的一批数据"。
  2. 关于 update_timestep:

    PPO 是"批量学习"的。千万不要把 update_timestep 设得太小(比如 10)。太小会导致数据相关性太强,网络学歪。通常 2000 到 4000 是一个比较好的范围。

  3. 关于 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 的全部过程。看似复杂的算法,拆解下来其实就是:建网络 -> 存数据 -> 算比率 -> 截断更新

相关推荐
还不秃顶的计科生2 小时前
如何快速用cmd知道某个文件夹下的子文件以及子文件夹的这个目录分支具体的分支结构
人工智能
九河云2 小时前
不同级别华为云代理商的增值服务内容与质量差异分析
大数据·服务器·人工智能·科技·华为云
Elastic 中国社区官方博客2 小时前
Elasticsearch:Microsoft Azure AI Foundry Agent Service 中用于提供可靠信息和编排的上下文引擎
大数据·人工智能·elasticsearch·microsoft·搜索引擎·全文检索·azure
大模型真好玩3 小时前
Gemini3.0深度解析,它在重新定义智能,会是前端工程师噩梦吗?
人工智能·agent·deepseek
机器之心3 小时前
AI终于学会「读懂人心」,带飞DeepSeek R1,OpenAI o3等模型
人工智能·openai
AAA修煤气灶刘哥3 小时前
从Coze、Dify到Y-Agent Studio:我的Agent开发体验大升级
人工智能·低代码·agent
陈佬昔没带相机3 小时前
MiniMax M2 + Trae 编码评测:能否与 Claude 4.5 扳手腕?
前端·人工智能·ai编程
美狐美颜SDK开放平台3 小时前
从0到1开发直播美颜SDK:算法架构、模型部署与跨端适配指南
人工智能·架构·美颜sdk·直播美颜sdk·第三方美颜sdk·美狐美颜sdk
小陈phd3 小时前
RAG从入门到精通(四)——结构化数据读取与导入
人工智能·langchain
玖日大大3 小时前
Trae:字节跳动 AI 原生 IDE 的技术革命与实战指南
ide·人工智能