☰
强化学习代码复现实战:从DQN到PPO的完整指南
2026/10/17 19:08:04 网站建设 项目流程

最近我注意到,不少学强化学习的同学卡在了同一个地方:看视频讲解的时候觉得自己懂了,一到自己动手复现代码就各种报错。这段时间我把那套很受欢迎的强化学习公开课的配套代码仓库完整复现了一遍,从环境搭建到算法调参,从DQN一路跑到PPO,前前后后踩了不少坑。这篇文章就记录一下我从零跑通这套代码的完整过程,包括仓库结构怎么理解、核心算法代码怎么读、训练曲线怎么判断好坏,以及那些最容易让人卡住的报错要怎么排查。无论你是刚学完强化学习基础理论、想动手实践的新手,还是看过代码但没跑通的老朋友,这篇内容应该都能给你一些实在的参考。

1. 复现前必须搞明白的事:仓库结构与环境准备

1.1 学代码前先看目录,别急着跑脚本

很多人拿到代码仓库的第一反应是直接运行主脚本,但这类教学性质较强的仓库,目录结构本身就是学习资料。我当时把整个仓库扫了一遍,发现它的组织方式很典型:每个经典算法一个独立文件夹,里面包含对应的网络定义、智能体定义和训练入口。这种结构最大的好处是,你可以单独拿出一个算法来读,不用被其他模块的耦合干扰。

另外,仓库里通常还有utils或common这类公共模块。我建议大家先读这里面的工具代码,比如经验回放(ReplayBuffer)、轨迹采样、噪声生成等自定义函数。因为很多看似复杂的算法,核心差异只是在数据流上做了不同的处理,而公共模块会反复用到这些基础能力。

我个人的习惯是:先画一张简单的“数据流图”,标注清楚状态、动作、奖励、下一状态这些变量在算法中的流转方向,再对照代码去读。这一步看似笨拙,却是我后面排查各种疑难问题的基础。与其先跑通再说,不如先大概理解代码在做什么。

1.2 环境安装的版本陷阱

这套代码的依赖不算复杂,无非是PyTorch和Gymnasium这类基础库。真正麻烦的是版本匹配问题。

我在环境准备阶段花的时间比预想中多很多,问题主要出在Gym的接口变化上。新版gymnasium中,env.reset()返回的是(obs, info)二元组,env.step(action)返回的是(obs, reward, terminated, truncated, info)五元组。但很多教学代码还是按老版gym写的,也就是env.reset()只返回obs,env.step()返回四元组。如果直接在新版本环境下运行老代码,第一行就会报“unpack failed”之类的错误。

我的建议是:先看看代码里调用env.reset()的方式,再决定使用哪个版本。如果是老式写法,可以在安装时固定版本号,比如gym==0.25.2;如果是新写法,就用gymnasium并手动调整环境适配层。我个人更推荐使用较新的整合包,因为后续支撑算法、调试工具都更完善,只需要做小幅兼容即可。

安装命令方面,我实测下来这样一套组合最省心:

conda create -n rl python=3.10 conda activate rl pip install torch pip install gymnasium pygame matplotlib

需要特别注意 PyTorch 的版本。如果本机是 Apple Silicon 芯片,安装包默认的 CPU 版本也能跑小型实验,只是速度偏慢。如果要使用 MPS 加速,需要额外确认 PyTorch 是否支持这个后端,并且在代码里手动把device参数改为mps。大部分教学代码默认使用cuda,如果你没有 NVIDIA GPU,程序会直接报错或回退到 CPU,这一点提前确认能省下不少时间。

1.3 跑通第一个脚本的完整流程

我建议按照仓库自带的顺序来运行,先从最简单的算法开始,而不是一上来就挑战重型的算法。比如仓库里面通常会把最基础的 DQN 或策略梯度作为第一个示例,这类算法收敛快、超参数少,调试起来也容易观察效果。

跑通第一个脚本后,我先不急着调参,而是确认下面三件事都正常:

  • 训练循环能完整运行若干回合
  • 每个回合结束时,关键指标(比如总奖励)正常打印
  • 训练完成后生成的曲线图里有明显上升趋势

这三件事确认下来,就说明核心的“采样-学习-更新”闭环是通的。这时候再去对比代码和理论公式,你会发现很多之前没理解的地方都变得具体起来。

2. DQN篇:第一个真正跑通的算法

2.1 网络结构与经验回放

DQN 是强化学习入门最经典的算法,也是我认认真真啃完的第一份代码。它解决的问题很直观:如何在复杂状态空间下,让智能体学会选择最优动作。核心思路是使用深度神经网络拟合 Q 函数,也就是“在某个状态下执行某个动作,预期能获得多少累计奖励”。

仓库里的网络结构通常很简单:

import torch import torch.nn as nn class DQN(nn.Module): def __init__(self, state_dim, action_dim, hidden_dim=128): super().__init__() self.net = nn.Sequential( nn.Linear(state_dim, hidden_dim), nn.ReLU(), nn.Linear(hidden_dim, hidden_dim), nn.ReLU(), nn.Linear(hidden_dim, action_dim) ) def forward(self, x): return self.net(x)

以 CartPole 环境为例,状态维度只有 4 维(小车位置、速度、杆角度、角速度),动作维度是 2 维(向左、向右)。这样一个两层的 MLP 就足够解决问题。但如果你跑 LunarLander 这类状态和动作空间更复杂的环境,就需要把隐藏层加大,或者换用更复杂的结构。

经验回放是 DQN 的另一个关键设计。它的核心思想是把智能体与环境交互产生的样本存到一个缓冲区里,训练时从中随机采样一小批来更新网络,而不是按时间顺序一个一个地学。这样做的目的是打破样本之间的时间相关性,避免网络被“连续的相似样本”带偏。

仓库里通常用deque来实现这个缓冲区:

from collections import deque import random class ReplayBuffer: def __init__(self, capacity=10000): self.buffer = deque(maxlen=capacity) def push(self, state, action, reward, next_state, done): self.buffer.append((state, action, reward, next_state, done)) def sample(self, batch_size): batch = random.sample(self.buffer, batch_size) return zip(*batch) def __len__(self): return len(self.buffer)

我个人调试时发现,缓冲区容量在 CartPole 这种小环境中取 5000 到 10000 就够用了,太大的容量反而会让训练后期学到太多“老样本”,更新方向容易摇摆。不同环境所需的缓冲区大小差异不小,这个参数值得多尝试几次。

2.2 核心更新流程与目标网络

DQN 真正讲究的地方在更新这一步。网络更新时的目标是 TD 目标,也就是当前奖励加上折现后的最大 Q 值。但这里有一个经典问题:如果用同一个网络同时计算 Q 值和更新 Q 值,二者会互相干扰,导致训练不稳定。所以代码里一般会维护两个结构相同的网络,一个是在线网络,一个是目标网络。

目标网络不参与梯度更新,只是每隔固定步数从在线网络直接复制参数过来。这一步看似简单,但却是 DQN 收敛的关键所在。我读代码时,花了很长时间才理解两个网络之间“分离但同步”的关系。

更新逻辑的伪代码如下:

# 采样一批经验 states, actions, rewards, next_states, dones = replay_buffer.sample(batch_size) # 当前 Q 值,只取选中动作的 Q q_values = online_net(states).gather(1, actions) # 目标值 with torch.no_grad(): max_next_q = target_net(next_states).max(dim=1, keepdim=True)[0] targets = rewards + gamma * max_next_q * (1 - dones) loss = nn.MSELoss()(q_values, targets) optimizer.zero_grad() loss.backward() optimizer.step()

上面的(1 - dones)非常值得留意。它表示当某个状态已经是终止状态时,后续不存在任何奖励,因此目标值就等于当前奖励,不再加上下一状态的 Q 值。很多新手第一次看这段代码时忽略了它,导致训练出来的智能体行为诡异。

关于参数,我在复现时最常用的一组配置是:学习率 0.001,折扣因子 gamma 0.99,目标网络硬更新频率 200 步,探索率 epsilon 从 1.0 开始,在训练过程中逐渐衰减到 0.01。整体训练下来效果稳定,在 CartPole 上大概 300-500 个回合就能跑出不错的效果。

2.3 epsilon 贪心策略与探索

DQN 本身是一个确定性策略,也就是网络输出哪个动作的 Q 值最大,就选哪个动作。但训练初期如果完全按照这个策略行动,智能体永远只会尝到一两种动作的甜头,根本无法发现更优的路径。这时就需要探索机制。

仓库里的常见实现是 epsilon-greedy:以概率 epsilon 随机选择动作,否则选择网络认为最优的动作,同时让 epsilon 随训练进度逐步减小。我实际测试下来,epsilon 的衰减方式对训练结果影响相当明显,衰减太快会导致探索不足,智能体停留在次优策略上;衰减太慢又会让训练后期仍有大量随机动作,破坏收敛效果。

这里有一个非常值得记下的经验:判断代码是否健康,可以看它打印输出的奖励曲线。如果奖励上升很快,然后陷入平台期,再往后很难提升,多半是 epsilon 衰减太快了。这时候把衰减结束时的最小值调大一点,或者把衰减步数延长到两倍,往往会有奇效。

3. 从 DQN 到策略梯度:算法差异与代码对照

3.1 为什么要学策略梯度类算法

把 DQN 跑通之后,我开始接着啃策略梯度算法。很多人在这个阶段感到困惑:既然 DQN 已经能解决不少问题,为什么还要学习其他算法?

答案在于动作空间的类型。DQN 依赖对所有可能动作的 Q 值进行最大化比较,所以它天然适用于离散动作空间,比如上下左右这种有限选择。但现实中的很多任务,比如机器人关节控制、车辆转向,动作是一个连续的数值,不可能把所有可能的动作全部枚举出来。策略梯度类算法直接学习“状态到动作”的映射,天然适配连续控制问题。

仓库里通常会引入策略网络,不再是输出每个动作的 Q 值,而是直接输出动作的概率分布。代码上最显著的区别是,策略网络的最后一层激活函数发生变化,离散动作空间通常用 Softmax,连续动作空间则用高斯分布采样。

3.2 策略梯度基本实现

基础策略梯度算法的核心目标是最大化累积奖励的期望。拿 REINFORCE 算法来说,它的实现比 DQN 更直接:先让智能体与环境交互完整回合,记录下所有状态、动作和奖励,然后使用“奖励作为权重”的方式更新策略网络,奖励越高的动作,对应概率的提升幅度就越大。

代码片段大概是这样的:

# 一个回合结束后 for step in range(running_steps): log_prob = policy_net.get_log_prob(states[step], actions[step]) # 该步之后的总折扣奖励 discounted_reward = sum(gamma ** i * rewards[step + i] for i in range(steps - step)) loss = -log_prob * discounted_reward

这种写法虽然简单,但有一个明显问题:每个回合的奖励数值波动很大,直接使用真实奖励会导致梯度方差过高,训练很不稳定。所以后续改进中引入了“基线”概念,也就是用一个值函数估计当前状态的期望奖励,然后用实际奖励减去基线,这个差值叫做优势函数。这样一来,只有那些明显优于平均水平的动作才会被强化,方差大大降低。

3.3 Actor-Critic 与 PPO 的核心逻辑

当我把基础策略梯度和 Actor-Critic 变体对照起来看时,才真正理解了代码仓库的编排逻辑。Actor-Critic 名字听起来玄乎,但说白了就是两个网络分工:Actor 决定怎么做,Critic 评价做得好不好。

Critic 网络输出的是状态的价值估计,它不直接参与动作选择,只负责给 Actor 提供改进方向。这一点和 DQN 中的价值网络很像,但角色位置不同。DQN 里价值网络是决策的直接依据,而 Actor-Critic 里价值网络只是辅助工具。

PPO 是 Actor-Critic 框架下的一种改良算法,也是我目前在实际项目中使用频率较高的方法。它通过限制单次更新步长,避免策略在一步更新中变化过大导致训练崩溃。代码中体现为截断的目标函数:

# old_log_prob 是更新前策略对动作的概率对数 ratio = (new_log_prob - old_log_prob).exp() clipped_ratio = torch.clamp(ratio, 1 - clip_eps, 1 + clip_eps) actor_loss = -torch.min(ratio * advantage, clipped_ratio * advantage).mean()

这段代码初看时有点绕,但理解之后会觉得非常巧妙。它把新旧策略的比值限制在一定范围内,避免了想当然地大步更新带来的不稳定性。仓库里 PPO 部分通常还搭配了 GAE(广义优势估计)来计算优势值,这部分逻辑更复杂,我建议先运行成功后再回头精读。

4. 实操记录:从 CartPole 到 LunarLander 的完整过程

4.1 CartPole 复现实录

CartPole 是强化学习中名副其实的“Hello World”,环境目标很简单:控制小车上的杆子保持竖直不倒。我在这套代码仓库里首先复现的就是 DQN 在 CartPole 上的表现。

整个过程非常直观。我设置了最大回合数 500,每个回合最多持续 200 步,训练完以后,发现智能体的表现在第 200-300 回合左右有明显跳变,总奖励从几十上升到接近 200。这个跳变点通常意味着探索已经足够,网络开始稳定地学到有效策略。

CartPole 环境调试有个很省心的特点:它的观测空间很小,即使网络设计得比较简单,只要超参数不离谱,基本都能学会。因此我非常建议第一实验用它来验证“代码是否写得正确”,而不是直接上复杂环境。如果 CartPole 都学不会,几乎可以肯定是代码逻辑有问题,而不是环境太难。

4.2 在 LunarLander 上遇到的挑战

LunarLander 是比 CartPole 难度高一个档次的经典环境。它的观测空间有 8 维,动作空间还是离散的 4 个动作。我第一次在这套代码上跑 LunarLander 时,明显感觉到需要区分训练过程中的优化策略和调参策略。

首先是网络容量。8 维输入 + 4 维输出,中间层的神经元数量如果还是 128,在复杂环境里表现并不理想。我后来把隐藏层扩大到 256,同时增加了一层的数量,训练效果才明显提升。

其次是经验回放的容量。CartPole 中 10000 的缓冲区够用,但 LunarLander 的状态空间更复杂,需要更多样本覆盖不同情况。我把缓冲区扩大到了 50000,这才看到奖励曲线稳定上升而不是反复震荡。

第三是训练回合数。CartPole 大约 500 回合就足够,但 LunarLander 想稳定爬到 200 分以上,至少要跑 1000-2000 个回合。这个训练时长对 CPU 设备来说不小,我建议至少在 GPU 或 MPS 环境下运行,否则要耐心等待一段时间。

下面是我复现时记录的几个环境的对比情况:

环境观测维度动作空间适用算法收敛回合数参考
CartPole4离散 2DQN、PPO200-500
LunarLander8离散 4DQN、PPO500-1500
Pendulum3连续 1PPO、DDPG300-800

这个表可以帮助新手快速判断自己该在哪个环境上验证哪个算法。如果你刚开始学习,优先跑CartPole和Pendulum,别一上来就挑战高难度。

4.3 训练曲线怎么读

训练曲线是判断算法是否健康的核心依据。仓库里的训练逻辑通常会打印两种指标:每个回合的总奖励和最近若干回合的平均奖励。

我自己的经验是,单回合奖励波动很剧烈,不应该直接用来判断模型好坏,必须看滑动平均。比如每隔 20 个回合计算一次平均奖励,曲线的平滑程度会大幅提升,趋势也更明显。

如果平均奖励持续上升,说明智能体确实在进步。如果上升后突然崩溃,比如从稳定 180 分掉到 30 分,最可能的原因是学习率过大导致网络参数震荡,或者是经验回放缓冲区被大量低质量样本污染。此时需要调小学习率,并适当加大缓冲区。

需要注意,训练曲线和评估曲线要区分开。训练过程中因为探索机制的存在,奖励往往偏低且波动大。仓库中有些代码在训练结束后会关闭探索机制,再跑若干回合进行评估,这时候的奖励才是模型真实能力的体现。我在复现时特意加了这段逻辑,发现它比看训练曲线本身更有参考价值。

5. 高频 Bug 与调试技巧实录

5.1 维度不匹配问题

复现强化学习代码时,维度报错是我遇到频率最高的错误类型,大概率占到了七成左右。新手常犯的一个错误是在把 numpy 数组转成 PyTorch 张量时忘了做维度变换。

例如,CartPole 的观测值是一个形状为(4,)的一维数组,但网络输入要求的是(batch_size, 4)形状。如果不加reshape,网络的前向传播就会直接报维度错误。正确的处理方式是:

state = torch.FloatTensor(np.array(obs)).unsqueeze(0)

如果是批量采样,则要确保从 ReplayBuffer 取出的样本堆叠后的形状是(batch_size, state_dim)。一个稳妥的调试办法是每步都打印states.shape、actions.shape、next_states.shape,把维度和代码注释里的定义对齐后再继续训练。

在实际排查中,我还有一个习惯,每当出现奇怪报错时,先把整个流程里每个张量的shape逐一打印出来,对照网络结构的输入输出推一遍逻辑,很快就能定位问题。

5.2 训练不收敛的排查顺序

训练不收敛是让人最头疼的情况。明明代码看起来没问题,但奖励曲线就是不动弹。我的排查顺序通常是这样的:

先确认环境本身可以解决,拿 CartPole 测试,标准算法应该可以收敛,如果它都跑不动,说明问题出在代码或超参数;接着检查奖励计算的逻辑,确认奖励值确实被正确传递了,可以打印当前回合的累计奖励和 TD 目标值;然后检查 epsilon 是否在正常衰减,如果衰减过慢,探索过多会一直学不好;最后检查梯度,如果某个时刻loss.backward()之后梯度值特别大,说明网络可能出现了梯度爆炸,可以尝试梯度裁剪。

仓库中很多代码都内置了梯度裁剪,但部分教学代码为了简化会省略这一步。遇到不收敛的情况,我给nn.utils.clip_grad_norm_加一个 1.0 的阈值,常常能起到立竿见影的效果。

5.3 Gym 版本 API 变化引发的兼容问题

前面提到过环境新旧接口的差异,这里我再补充一种常见情况。部分代码在编写时使用了done作为终止标志,但新版本的 Gymnasium 将终止和截断分开为terminated和truncated。如果代码只考虑了terminated,那么当回合因为步数上限被截断时,算法会误以为到达了终止状态,从而错误地停止回报累计,导致训练信号混乱。

正确写法是:

done = terminated or truncated

判断done用于决定是否重置环境,判断terminated用于决定是否计算未来回报。这个细节直接关系到经验回放中next_state价值的计算,值得反复检查。

仓库里的部分代码在适配新版环境时往往没有把所有入口都改完整,所以复现时保持“代码与依赖库版本对齐”的意识非常重要。我通常会在仓库根目录记录下自己使用的依赖版本,方便日后排查。

6. 从复现到自己改进:几条实践建议

复现别人代码的最终目标,是能够在自己遇到实际问题时举一反三。我自己的经验是,把别人的代码跑通只是完成了第一阶段,真正受益是从改写开始的。

比如把普通 DQN 换成 Double DQN,代码改动量其实非常小,只要在计算目标值时,用在线网络选择当前状态下的最大动作索引,再用目标网络取对应动作的 Q 值就可以了:

next_actions = online_net(next_states).argmax(dim=1, keepdim=True) max_next_q = target_net(next_states).gather(1, next_actions)

仅仅这样一行核心改动,就能缓解经典 DQN 中常见的高估问题,让训练稳定性提升不少。类似的小改动还包括把网络结构换成 Dueling DQN,在输出层前把价值流和优势流分开,效果也很明显。

再比如给经验回放加上优先级,经典的采样方式改为按 TD 误差大小排序采样。虽然逻辑复杂一些,但是训练早期对关键状态的学习效率会有可感知的提升。

所以我的建议是,跑通一套代码之后别急着翻篇,挑一个算法,先做一次小改动,看看训练曲线是否有变化,再决定要不要深入。这个“复现一遍,改动一次”的节奏,是我认为最能在短期内提升强化学习代码能力的方式。

最后再分享一个小技巧。读强化学习代码时,不要试图一次把每个细节都弄懂,那样反而容易卡在某一行上出不来。先把整个流程的主干跑通,再去抠细节,就像拼图一样,先搭框架再填细节,效率要高得多。我自己复现这套代码的过程中,最大的收获并不是学会多少种算法,而是掌握了如何快速定位问题、如何科学调整参数,这种调试能力才是真正通用的。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询