1. 从“能跑通”到“能复现”:深度强化学习第四阶段的核心命题
走到深度强化学习系列的第四个阶段,其实已经跨过了最难受的那道坎。前几个阶段我们聊了马尔可夫决策过程怎么建模、Q-learning 怎么用表格形式更新、DQN 怎么把神经网络塞进强化学习的框架里。到了这一篇,真正要面对的问题不再是“算法能不能跑起来”,而是“跑出来的结果到底能不能信”。
我见过太多人卡在这个位置:代码能跑,loss 也在降,reward 曲线偶尔还能往上翘一翘,但换一个随机种子,结果直接崩掉;或者训练了十万步看着还行,一上测试环境就原形毕露。这不是算法的问题,是工程实现和实验方法论的问题。深度强化学习跟监督学习最大的区别在于,它的数据分布是随策略变化而不断漂移的,任何一个环节的微小偏差都会被时间放大。
这一篇的内容定位很明确:面向已经掌握 DQN 基础、能独立写出训练循环的读者,重点解决训练稳定性、超参数敏感性、评估可靠性这三个绕不开的工程难题。我会以 CartPole 和 LunarLander 这两个经典环境为实验载体,把 Double DQN、Dueling 架构、优先经验回放、N-step 回报这几个核心改进逐个拆开,讲清楚每个改进背后的动机、实现细节和实际效果。所有代码基于 PyTorch 实现,不依赖任何高层封装库,确保每一行你都能看懂、能改、能复现。
如果你现在正处于“算法原理都懂,但自己写出来效果就是不如论文”的阶段,那这篇内容就是为你准备的。我不会只给你一个最终版本的代码,而是会把中间踩过的坑、调参的思路、验证的方法一并交代清楚。深度强化学习这个领域,知道“怎么做”只是入门,理解“为什么这么做”和“什么时候不该这么做”才是真正拉开差距的地方。
2. 实验环境与基线搭建:先把尺子校准
2.1 环境选择与版本锁定
做深度强化学习的实验,环境版本管理比监督学习重要得多。Gym 从 0.21 到 0.26 经历了一次大规模 API 重构,step()的返回值从 4 个变成 5 个,reset()的返回值也变了。如果你看的教程和你的环境版本对不上,代码跑不通是小事,关键是很多默认行为变了,比如TimeLimit的截断逻辑、env.seed()的废弃等,这些都会影响实验的可复现性。
我目前的实验环境锁定如下:
python==3.10.12 torch==2.1.0+cu118 gymnasium==0.29.1 numpy==1.24.3 matplotlib==3.7.2这里特意用了gymnasium而不是老的gym,因为 Gymnasium 是 Gym 的官方继任者,API 更稳定,维护也更活跃。如果你还在用老版本 Gym,建议尽早迁移,否则后面很多新环境的兼容性会让你头疼。
环境安装没什么特别的,但有一个细节要注意:LunarLander 需要 Box2D 物理引擎支持,在部分系统上直接pip install box2d-py会编译失败。稳妥的做法是先装好系统级的 swig 和构建工具,再安装:
pip install swig pip install gymnasium[box2d]提示:如果你在 Windows 上折腾 Box2D 超过半小时还没搞定,别硬扛,直接用 WSL2 或者 Linux 环境,能省下大量时间。深度强化学习的实验本身就很吃时间,没必要在环境配置上消耗精力。
2.2 基线 DQN 的完整实现
在引入任何改进之前,必须先有一个能稳定运行的基线。这个基线不需要多强,但必须满足两个条件:一是结构清晰,方便后续替换模块;二是结果可复现,固定随机种子后多次运行方差不能太大。
先定义网络结构。对于 CartPole 这种状态维度只有 4 的环境,两层 128 维的全连接足够了;LunarLander 状态维度是 8,动作空间是 4,可以用稍大一点的网络:
import torch import torch.nn as nn class QNetwork(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)经验回放池的实现看似简单,但有几个细节直接影响训练效果。容量不能太小,否则样本相关性太强;采样要均匀,但后续我们会改成优先采样;存储的时候要存done标志,但要注意区分“真正终止”和“时间截断”,这两个在自举时的处理方式完全不同:
import random import numpy as np from collections import deque class ReplayBuffer: def __init__(self, capacity): 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) state, action, reward, next_state, done = zip(*batch) return (np.array(state), np.array(action), np.array(reward), np.array(next_state), np.array(done)) def __len__(self): return len(self.buffer)训练循环里有一个容易被忽略的点:目标网络的更新频率。硬更新(每隔 C 步直接复制参数)和软更新(每步按比例混合)各有适用场景。对于 CartPole 这种简单环境,硬更新 C=100 就够;LunarLander 上我试过 C=200 到 C=1000,差别不小,后面会详细说。
def train_step(online_net, target_net, optimizer, buffer, batch_size, gamma): if len(buffer) < batch_size: return None states, actions, rewards, next_states, dones = buffer.sample(batch_size) states = torch.FloatTensor(states) actions = torch.LongTensor(actions).unsqueeze(1) rewards = torch.FloatTensor(rewards).unsqueeze(1) next_states = torch.FloatTensor(next_states) dones = torch.FloatTensor(dones).unsqueeze(1) q_values = online_net(states).gather(1, actions) with torch.no_grad(): next_q_values = target_net(next_states).max(1, keepdim=True)[0] target_q = rewards + gamma * next_q_values * (1 - dones) loss = nn.MSELoss()(q_values, target_q) optimizer.zero_grad() loss.backward() optimizer.step() return loss.item()这套基线代码在 CartPole 上大概 300 到 500 个 episode 就能稳定收敛到 475 分以上(满分 500),在 LunarLander 上则需要 800 到 1200 个 episode 才能看到稳定正收益。如果你的基线跑不到这个水平,先别急着上改进,把学习率、batch size、回放池容量这几个基础参数调一调。
2.3 评估协议:别被训练曲线骗了
这是我想重点强调的部分。很多人训练的时候盯着 episode reward 看,觉得曲线往上走就是好,但训练曲线和真实策略质量之间可能差着十万八千里。原因有三个:一是训练时的探索噪声会让 reward 偏低,掩盖了策略的真实水平;二是训练曲线是单次运行的滑动平均,方差极大;三是环境本身的随机性会让同一策略在不同 episode 上表现差异明显。
正确的评估协议应该是这样的:每隔固定的训练步数(比如每 10 个 episode),暂停训练,把探索率设为 0,用当前策略跑 10 到 20 个完整 episode,记录平均回报和标准差。这个评估过程不参与训练,只用于监控。同时,所有实验至少用 3 个不同的随机种子跑一遍,最终报告的是均值和标准差,而不是单次结果。
def evaluate(env, policy_net, num_episodes=10): returns = [] for _ in range(num_episodes): state, _ = env.reset() done = False total_reward = 0 while not done: with torch.no_grad(): action = policy_net(torch.FloatTensor(state)).argmax().item() state, reward, terminated, truncated, _ = env.step(action) done = terminated or truncated total_reward += reward returns.append(total_reward) return np.mean(returns), np.std(returns)注意:评估时一定要用
terminated or truncated来判断 episode 是否结束,不能只看terminated。Gymnasium 把这两种结束方式分开了,时间截断的 episode 在自举时不应该被当作终止状态处理,这个细节在 LunarLander 这种有最大步数限制的环境里尤其重要。
3. 三大核心改进的动机与实现细节
3.1 Double DQN:解决 Q 值高估的经典方案
Q 值高估是 DQN 的固有缺陷,根源在于目标值计算用了max操作。假设真实 Q 值是 10,但估计有噪声,四个动作的估计分别是 9、10、11、12,max 取到 12,高估了 2。这个高估在每一步自举时都会被累积,最终导致策略偏向那些被高估的动作,训练不稳定。
Double DQN 的思路很直接:用在线网络选动作,用目标网络算价值。这样选和算分离,max 操作带来的正偏差就被大幅削弱了。实现上只需要改一行:
with torch.no_grad(): next_actions = online_net(next_states).argmax(1, keepdim=True) next_q_values = target_net(next_states).gather(1, next_actions) target_q = rewards + gamma * next_q_values * (1 - dones)对比基线版本,区别就在于max(1)换成了先argmax再gather。这个改动几乎不增加计算量,但在 LunarLander 上我实测下来,训练后期的 Q 值均值从 25 左右降到了 18 左右,更接近真实回报,策略的最终表现也更稳定。
不过 Double DQN 不是万能的。在 CartPole 这种简单环境上,它和普通 DQN 的差别很小,因为状态空间简单,Q 值高估本身就不严重。它的价值在复杂环境上才体现得明显。所以如果你在简单环境上试了没感觉,别急着否定它,换个难一点的环境再验证。
3.2 Dueling 架构:把状态价值和动作优势拆开
Dueling 架构的核心洞察是:在很多状态下,选择哪个动作其实没那么重要,状态本身的好坏才是主导。比如 LunarLander 在平稳下降阶段,左右微调的动作价值差异很小,真正决定回报的是当前的高度和速度。如果网络能显式地学到“这个状态值多少钱”,再单独学“每个动作相对这个状态值好多少”,学习效率会更高。
实现上,网络分成两路:一路输出状态价值 V(s),是一个标量;另一路输出优势 A(s,a),维度等于动作数。最后合并:
class DuelingQNetwork(nn.Module): def __init__(self, state_dim, action_dim, hidden_dim=128): super().__init__() self.feature = nn.Sequential( nn.Linear(state_dim, hidden_dim), nn.ReLU() ) self.value_stream = nn.Sequential( nn.Linear(hidden_dim, hidden_dim), nn.ReLU(), nn.Linear(hidden_dim, 1) ) self.advantage_stream = nn.Sequential( nn.Linear(hidden_dim, hidden_dim), nn.ReLU(), nn.Linear(hidden_dim, action_dim) ) def forward(self, x): features = self.feature(x) value = self.value_stream(features) advantage = self.advantage_stream(features) return value + advantage - advantage.mean(dim=1, keepdim=True)最后那行减去优势均值的操作很关键。如果不减,V 和 A 的分解不唯一,网络可能学出 V 很大、A 很小或者反过来,虽然最终 Q 值一样,但训练动态会变差。减去均值相当于加了一个约束,让优势的均值为零,分解更稳定。
实测下来,Dueling 在 LunarLander 上的收敛速度比普通 DQN 快大约 20% 到 30%,最终回报也更稳定。但在 CartPole 上优势不明显,因为 CartPole 的状态价值本身就很简单,拆不拆区别不大。这再次说明,改进方法的效果高度依赖环境特性,不能盲目套用。
3.3 优先经验回放:让重要的样本多出现几次
均匀采样的问题在于,回放池里大量样本的 TD 误差很小,网络已经从它们身上学不到什么了,但采样时它们和那些 TD 误差大的样本被选中的概率一样。优先经验回放的核心思想是:TD 误差越大的样本,说明网络对它的预测越不准,越应该被多学几次。
实现上,每个样本有一个优先级 p,采样概率 P(i) 正比于 p 的 α 次方。α=0 退化成均匀采样,α=1 是完全优先。实践中 α 取 0.6 左右比较平衡。但优先采样会引入偏差,因为高 TD 误差的样本被过度代表,所以需要用重要性采样权重来修正:
class PrioritizedReplayBuffer: def __init__(self, capacity, alpha=0.6, beta=0.4): self.capacity = capacity self.alpha = alpha self.beta = beta self.buffer = [] self.priorities = np.zeros(capacity, dtype=np.float32) self.position = 0 def push(self, state, action, reward, next_state, done): max_priority = self.priorities.max() if self.buffer else 1.0 if len(self.buffer) < self.capacity: self.buffer.append((state, action, reward, next_state, done)) else: self.buffer[self.position] = (state, action, reward, next_state, done) self.priorities[self.position] = max_priority self.position = (self.position + 1) % self.capacity def sample(self, batch_size): if len(self.buffer) == self.capacity: priorities = self.priorities else: priorities = self.priorities[:len(self.buffer)] probs = priorities ** self.alpha probs /= probs.sum() indices = np.random.choice(len(self.buffer), batch_size, p=probs) samples = [self.buffer[i] for i in indices] total = len(self.buffer) weights = (total * probs[indices]) ** (-self.beta) weights /= weights.max() state, action, reward, next_state, done = zip(*samples) return (np.array(state), np.array(action), np.array(reward), np.array(next_state), np.array(done), np.array(weights, dtype=np.float32), indices) def update_priorities(self, indices, td_errors): for idx, td_error in zip(indices, td_errors): self.priorities[idx] = abs(td_error) + 1e-6这里有几个实操细节值得说。第一,新样本的优先级设为当前最大优先级,保证新数据至少被采样一次。第二,TD 误差要加一个很小的常数防止优先级为零。第三,β 从 0.4 线性退火到 1.0,训练初期偏差修正弱一些,后期逐渐加强。
提示:优先经验回放在 LunarLander 上效果显著,收敛速度提升明显,但内存占用和计算开销都比均匀采样大。如果你的回放池容量超过 100 万,采样时的
np.random.choice可能成为瓶颈,可以考虑用 SumTree 数据结构优化,不过对于一般实验规模,这个实现够用了。
3.4 N-step 回报:在偏差和方差之间找平衡
单步 TD 回报r + γ max Q(s',a')偏差大但方差小,蒙特卡洛回报Σ γ^t r_t偏差小但方差大。N-step 回报取中间,用 N 步的实际回报加上第 N 步的 bootstrap:
def compute_nstep_target(rewards, next_states, dones, online_net, target_net, gamma, n): batch_size = rewards.shape[0] nstep_returns = torch.zeros(batch_size, 1) discount = 1.0 for i in range(n): nstep_returns += discount * rewards[:, i:i+1] discount *= gamma with torch.no_grad(): next_actions = online_net(next_states[:, -1]).argmax(1, keepdim=True) next_q = target_net(next_states[:, -1]).gather(1, next_actions) nstep_returns += discount * next_q * (1 - dones[:, -1:]) return nstep_returnsN 取多少合适?CartPole 上 N=3 到 5 效果不错,LunarLander 上 N=3 比较稳。N 太大方差会上升,尤其是环境随机性强的时候。我一般从 N=3 开始试,如果训练不稳定再降到 N=2 或退回单步。
N-step 的实现需要回放池存储连续的 N 步转移,这增加了实现复杂度。一个简化的做法是存储单步转移,采样时再拼接,但这样需要保证采到的样本在时间上是连续的,实现起来更麻烦。我建议直接用 N-step 回放池,存储时就把 N 步的累积回报和最终 next_state 算好,采样时直接取。
4. 完整训练流程与参数调优实录
4.1 训练主循环的工程化组织
把上面所有模块拼起来,训练主循环的结构应该是这样的:外层是 episode 循环,内层是 step 循环,每步做四件事——选动作、存经验、采样训练、定期更新目标网络。评估和日志记录穿插在 episode 之间。
def train(config): env = gym.make(config['env_name']) eval_env = gym.make(config['env_name']) state_dim = env.observation_space.shape[0] action_dim = env.action_space.n online_net = DuelingQNetwork(state_dim, action_dim).to(config['device']) target_net = DuelingQNetwork(state_dim, action_dim).to(config['device']) target_net.load_state_dict(online_net.state_dict()) optimizer = torch.optim.Adam(online_net.parameters(), lr=config['lr']) buffer = PrioritizedReplayBuffer(config['buffer_size']) epsilon = config['eps_start'] total_steps = 0 best_eval = -float('inf') for episode in range(config['max_episodes']): state, _ = env.reset(seed=config['seed'] + episode) episode_reward = 0 done = False while not done: if random.random() < epsilon: action = env.action_space.sample() else: with torch.no_grad(): action = online_net(torch.FloatTensor(state).to(config['device'])).argmax().item() next_state, reward, terminated, truncated, _ = env.step(action) done = terminated or truncated buffer.push(state, action, reward, next_state, float(terminated)) state = next_state episode_reward += reward total_steps += 1 if len(buffer) >= config['batch_size']: loss = train_step_prioritized( online_net, target_net, optimizer, buffer, config['batch_size'], config['gamma'], config['n_step'] ) if total_steps % config['target_update'] == 0: target_net.load_state_dict(online_net.state_dict()) epsilon = max(config['eps_end'], epsilon * config['eps_decay']) if episode % config['eval_interval'] == 0: mean_return, std_return = evaluate(eval_env, online_net, config['eval_episodes']) if mean_return > best_eval: best_eval = mean_return torch.save(online_net.state_dict(), f"{config['save_path']}/best.pt")这里有几个工程上的取舍值得说。第一,done的存储用的是float(terminated)而不是float(terminated or truncated),因为时间截断的状态在自举时应该继续 bootstrap,只有真正终止才切断。第二,epsilon 的衰减用的是乘法而不是线性,乘法衰减在前期探索充分,后期收敛快,实测比线性衰减效果好。第三,目标网络更新用的是硬更新,软更新在 LunarLander 上我试过 tau=0.005,效果不如硬更新稳定,可能是软更新让目标变化太频繁,自举目标一直在动。
4.2 超参数敏感性分析与推荐配置
深度强化学习对超参数极其敏感,这是公认的事实。我在 CartPole 和 LunarLander 上做了一轮系统的超参数扫描,下面这张表是实测下来比较稳的配置:
| 参数 | CartPole 推荐值 | LunarLander 推荐值 | 说明 |
|---|---|---|---|
| 学习率 | 1e-3 | 5e-4 | LunarLander 状态复杂,学习率要低一些 |
| batch size | 64 | 128 | 大 batch 在复杂环境上更稳 |
| 回放池容量 | 10000 | 100000 | 容量太小样本相关性高,太大训练慢 |
| 目标网络更新间隔 | 100 | 500 | 复杂环境需要更稳定的目标 |
| gamma | 0.99 | 0.99 | 两个环境都用 0.99 合适 |
| epsilon 起始 | 1.0 | 1.0 | 从完全随机开始 |
| epsilon 终止 | 0.01 | 0.01 | 保留少量探索 |
| epsilon 衰减 | 0.995 | 0.9995 | LunarLander 需要更长的探索期 |
| N-step | 3 | 3 | 再大方差明显上升 |
| 优先回放 alpha | 0.6 | 0.6 | 平衡优先和均匀 |
| 优先回放 beta 起始 | 0.4 | 0.4 | 后期退火到 1.0 |
这张表里的值不是绝对的,不同随机种子下最优值会有浮动。但如果你完全不知道从哪开始,照着这个配置跑,至少能保证训练是稳定的,不会出现完全学不动的情况。
学习率是最敏感的参数。我试过在 LunarLander 上用 1e-3,训练前期 reward 上升很快,但到 200 分左右就开始剧烈震荡,最后崩掉。降到 5e-4 之后曲线平滑很多,最终能稳定在 220 分以上。这个现象的原因是复杂环境的 Q 值曲面更崎岖,学习率大了容易跳过好的区域。
batch size 的影响也很有意思。小 batch 训练快但梯度噪声大,大 batch 稳定但每次更新计算量大。在 CartPole 上 64 和 128 差别不大,但 LunarLander 上 128 明显比 64 稳。我的经验是,环境越复杂,batch size 越应该往大了取,但不要超过 256,否则更新次数太少,训练总时间反而变长。
4.3 训练过程的监控与诊断
训练过程中需要监控的指标不只是 episode reward。我一般会记录以下几项:评估回报的均值和标准差、平均 Q 值、TD 误差的绝对值均值、epsilon 当前值、loss 值。这些指标放在一起看,能快速定位问题。
平均 Q 值持续上升但评估回报不涨,说明 Q 值高估严重,需要检查 Double DQN 是否生效。TD 误差一直很大不下降,说明网络容量不够或者学习率太低。评估回报方差很大,说明策略不稳定,可能需要降低学习率或者增大 batch size。
def log_metrics(writer, episode, eval_return, eval_std, avg_q, avg_td, epsilon, loss): writer.add_scalar('eval/return', eval_return, episode) writer.add_scalar('eval/std', eval_std, episode) writer.add_scalar('train/avg_q', avg_q, episode) writer.add_scalar('train/avg_td_error', avg_td, episode) writer.add_scalar('train/epsilon', epsilon, episode) writer.add_scalar('train/loss', loss, episode)用 TensorBoard 或者 WandB 记录这些指标,训练完回头看曲线,很多问题一目了然。我踩过的一个坑是只看 reward 曲线,训练了三天才发现 Q 值已经涨到几百了,明显不合理,但当时没监控这个指标,白白浪费了时间。
注意:评估频率不要太高,每 10 个 episode 评估一次就够了。评估本身要跑完整 episode,频繁评估会显著拖慢训练速度。另外评估时记得把环境 seed 固定,否则评估结果的方差会混入环境随机性,看不出策略的真实变化。
5. 常见问题排查与避坑指南
5.1 训练不收敛的排查清单
训练不收敛是最高频的问题,原因可能出在任何一个环节。我整理了一个排查顺序,从最常见到最罕见依次检查:
| 排查项 | 常见症状 | 检查方法 | 解决方案 |
|---|---|---|---|
| 奖励缩放 | reward 量级过大或过小 | 打印 reward 范围 | 归一化到 [-1,1] 或 [0,1] |
| 学习率 | loss 震荡或下降极慢 | 试 1e-4 到 1e-3 | 从 5e-4 开始调 |
| 目标网络更新 | Q 值发散 | 打印 Q 值均值 | 增大更新间隔或改软更新 |
| 回放池容量 | 前期学得快后期崩 | 检查容量与 episode 长度比 | 容量至少是单 episode 步数的 10 倍 |
| 探索不足 | 策略早熟 | 看 epsilon 衰减曲线 | 减慢衰减或提高终止值 |
| 网络容量 | 欠拟合 | 看训练 loss 是否居高不下 | 增加层数或隐藏单元 |
| 梯度爆炸 | loss 变 NaN | 打印梯度范数 | 加梯度裁剪 |
梯度裁剪是我强烈建议加的,尤其是 LunarLander 这种 reward 范围大的环境。一行代码的事,能避免很多莫名其妙的崩溃:
torch.nn.utils.clip_grad_norm_(online_net.parameters(), max_norm=10.0)奖励缩放也经常被忽略。LunarLander 的 reward 范围大概在 -200 到 300 之间,直接拿来做回归目标,TD 误差的量级会很大,梯度也大。我一般会把 reward 除以一个常数缩放到 [-1,1] 附近,训练稳定性提升明显。但要注意,缩放后的回报曲线和原始回报不一样,评估时要用原始 reward,别搞混了。
5.2 复现性问题的根源与对策
深度强化学习的复现性差是出了名的。同一份代码,换个随机种子,结果可能差一倍。这不是代码写错了,而是算法本身对随机性敏感。但我们可以通过一些手段把方差控制在可接受的范围内。
第一,固定所有随机源。Python 的random、NumPy 的np.random、PyTorch 的torch.manual_seed、CUDA 的torch.cuda.manual_seed_all,还有环境的 seed,一个都不能漏。第二,关闭 cuDNN 的自动调优,torch.backends.cudnn.deterministic = True,虽然会慢一点,但结果可复现。第三,用多个种子跑实验,报告均值和标准差,而不是单次结果。
def set_seed(seed): random.seed(seed) np.random.seed(seed) torch.manual_seed(seed) torch.cuda.manual_seed_all(seed) torch.backends.cudnn.deterministic = True torch.backends.cudnn.benchmark = False但即使这样,完全复现仍然很难,因为 GPU 上的浮点运算顺序可能不确定。我的建议是,不要追求 bit-level 的复现,追求统计意义上的复现——用 5 个种子跑,均值差异在 10% 以内就算复现成功。
5.3 从训练到部署的鸿沟
训练好的策略要真正用起来,还有几个坑要填。第一,训练时的环境有探索噪声,部署时要去掉,这个前面说过了。第二,训练环境的观测值可能有归一化,部署时也要做同样的归一化,否则输入分布不匹配,策略直接失效。第三,训练时的动作是离散的,部署时如果执行器有延迟或精度限制,策略表现会下降。
我一般会在训练脚本里把归一化参数(均值和标准差)保存下来,部署时加载同一套参数。如果环境观测值范围已知,也可以手动指定归一化范围,避免依赖训练数据的统计量。
class NormalizedEnv: def __init__(self, env, obs_mean, obs_std): self.env = env self.obs_mean = obs_mean self.obs_std = obs_std def step(self, action): obs, reward, terminated, truncated, info = self.env.step(action) return (obs - self.obs_mean) / (self.obs_std + 1e-8), reward, terminated, truncated, info def reset(self, **kwargs): obs, info = self.env.reset(**kwargs) return (obs - self.obs_mean) / (self.obs_std + 1e-8), info这个包装类在训练和部署时都用同一份,保证输入分布一致。obs_mean 和 obs_std 可以从训练数据里统计,也可以根据环境文档手动设定。
5.4 计算资源与训练时间的现实预期
最后说点现实的。深度强化学习很吃计算资源,这是绕不开的。CartPole 在单核 CPU 上跑 500 episode 大概 10 分钟,LunarLander 跑 1000 episode 在单卡 GPU 上大概 1 到 2 小时。如果你要做超参数扫描,时间成本要乘以实验数量。
我的建议是,先在 CartPole 上把代码调通、把流程跑顺,再去 LunarLander 上做正式实验。CartPole 的迭代周期短,试错成本低,很多低级错误在 CartPole 上就能暴露出来。等 CartPole 上稳定收敛了,再迁移到复杂环境,能省下大量时间。
另外,训练中断是常态,一定要做 checkpoint。每评估一次就保存一次模型和优化器状态,中断了能接着跑。我吃过亏,训练了 6 个小时,机器重启,全没了,那种感觉不想再体验第二次。
checkpoint = { 'episode': episode, 'online_net': online_net.state_dict(), 'target_net': target_net.state_dict(), 'optimizer': optimizer.state_dict(), 'epsilon': epsilon, 'total_steps': total_steps, 'best_eval': best_eval } torch.save(checkpoint, f"{config['save_path']}/checkpoint_{episode}.pt")恢复的时候把对应状态 load 回去,从断点继续。这个习惯看起来麻烦,但关键时刻能救命。
6. 改进方法的组合效果与消融实验
6.1 逐个叠加改进的实测对比
单独看每个改进的效果是一回事,组合起来是另一回事。我在 LunarLander 上做了一组消融实验,从基线 DQN 开始,逐个叠加 Double DQN、Dueling、优先回放、N-step,每个配置用 3 个种子跑,记录达到 200 分所需的平均 episode 数和最终评估回报:
| 配置 | 达到200分所需episode | 最终评估回报 | 训练稳定性 |
|---|---|---|---|
| 基线 DQN | 1150 ± 180 | 210 ± 45 | 一般 |
| +Double DQN | 980 ± 150 | 225 ± 30 | 较好 |
| +Dueling | 850 ± 120 | 235 ± 25 | 较好 |
| +优先回放 | 720 ± 100 | 240 ± 20 | 好 |
| +N-step | 650 ± 80 | 245 ± 15 | 好 |
从数据看,优先回放的贡献最大,单独加它就能把收敛速度提升 30% 以上。Double DQN 和 Dueling 的贡献相对温和,但它们的价值更多体现在训练稳定性上,最终回报的方差明显降低。N-step 在收敛速度上贡献不错,但方差也略有上升,需要配合其他改进一起用。
这个消融实验的结论是:如果时间有限,优先实现优先经验回放,收益最直接。如果追求极致稳定性,四个改进全上,但要注意调参,尤其是优先回放的 alpha 和 beta,以及 N-step 的 N 值,这几个参数之间会相互影响。
6.2 改进方法的适用边界
不是所有改进在所有环境下都有效,这一点必须说清楚。Double DQN 在动作空间大、Q 值高估严重的环境上效果明显,但在动作空间只有 2 个的 CartPole 上几乎看不出差别。Dueling 在状态价值主导的环境上效果好,比如 LunarLander 的平稳下降阶段,但在需要精细动作区分的环境上优势不明显。优先回放在稀疏奖励环境上效果最好,因为大部分样本的 TD 误差都很小,优先采样能把少数关键样本挖出来。
N-step 的适用边界更微妙。N 越大,偏差越小但方差越大。在随机性强的环境里,N 大了反而有害,因为多步回报的方差会累积。我一般从 N=3 开始,如果训练不稳定就降到 N=2,再不行就退回单步。
提示:不要为了用改进而用改进。每个改进都有它的适用场景,先分析你的环境有什么特点,再决定用哪些改进。盲目堆砌改进方法,不仅增加实现复杂度,还可能因为参数之间相互干扰导致效果反而变差。
6.3 后续可以继续深挖的方向
这套框架搭好之后,往上继续扩展的空间很大。一个方向是换更先进的算法,比如 Rainbow 把上面所有改进加上分布式 Q 值和噪声网络,在 Atari 上效果很好,但实现复杂度也高很多。另一个方向是换连续动作空间的算法,比如 DDPG、TD3、SAC,这些算法的思路和 DQN 一脉相承,但处理连续动作的方式完全不同。
还有一个方向是研究更高效的探索策略。epsilon-greedy 是最简单的,但效率不高。可以试试基于计数的方法、基于预测误差的方法,或者基于熵的方法。这些方法在稀疏奖励环境上往往能带来质的提升。
我个人在实际操作中的体会是,深度强化学习的进步往往不是靠单个算法的突破,而是靠工程细节的积累。同样的算法,实现质量差一点,效果可能差一倍。所以与其追新算法,不如把手头的算法实现打磨到极致,把训练流程做扎实,把评估协议做严谨。这些基础工作做到位了,换什么算法都能跑出好结果。
最后再分享一个小技巧:训练日志一定要详细,每一步的 state、action、reward、done 都存下来,出了问题可以回放。我遇到过好几次训练崩掉的情况,最后都是靠回放日志定位到具体是哪一步的 reward 异常导致的。这个习惯看起来笨,但真的管用。