
今天聊聊我在复现强化学习经典算法时的一些实操心得这里选的项目是用PyTorch复现Double DQN让AI自己学会玩打砖块Breakout。关于PyTorch、Double DQN、强化学习、AI这几个词但凡在深度学习圈子里混过的应该都不陌生——它们是入门强化学习绕不开的组合。我选择打砖块作为实验环境的一个很直接的原因是这个游戏的状态空间和动作空间都足够简单但又不至于简单到一眼就能看穿视觉特征、时间序列、稀疏奖励它都占全了。换句话说你在这个环境上调通的训练流程换到更复杂的游戏上依然成立。这篇内容不会只停留在原理层面重点放在三件事为什么Double DQN能解决普通DQN的高估问题、训练过程中如何设计网络和回放机制、以及我在调参时踩过的一些坑和对应的排查思路。代码和参数都是可直接复制运行的版本适合已经有PyTorch基础但不熟悉强化学习的读者也适合那些在训练中奖励不涨、反复发散然后怀疑人生的朋友。1. 项目背景与整体设计思路1.1 为什么拿打砖块当强化学习的第一站打砖块这游戏规则一句话就能说完挡板接球球反弹上去撞砖块砖块碎了得分没接住球就丢一条命命没了游戏结束。看起来比围棋简单了不知道多少个量级但对AI来说它其实一点都不“简单”。你要让智能体学会的不是“看到砖块就往右走”而是从连续的像素帧里推断出球的速度和方向提前落到球的落点位置等着接球。这涉及时间维度的信息整合也涉及长短期目标的权衡是很典型的POMDP部分可观测马尔可夫决策过程问题。选Breakout还有一个现实原因训练成本相对可控。和Atari里那些复杂游戏相比Breakout的状态转移模式比较规则动作也只有4个无操作、发射、右移、左移用一块普通的消费级显卡比如我用的3060Ti就能在可接受的时间内跑出不错的效果。如果一上来就拿Dota 2或者星际争霸练手那不光代码复杂连环境配置都要折腾掉半条命。对于想验证算法想法、熟悉训练调试流程的人来说Breakout就是个“既能练手又能练脑”的理想场地。另外从学习曲线来看强化学习的核心知识点——状态表示、动作选择策略、奖励信号设计、经验回放、目标网络、探索与利用的平衡——在这个项目里全都用得上而且每一项都能直观地看到它对训练结果的影响。比如你调低学习率训练曲线可能变慢但更稳你把目标网络更新频率调得太勤Q值就可能开始震荡。这种直观的因果反馈在复杂环境里反而不容易观察到。1.2 Double DQN相比DQN解决了什么问题聊Double DQN之前得先说说普通DQN的毛病。DQN的基本思路是用神经网络近似Q函数通过贝尔曼方程不断逼近真实的价值函数更新目标由当前奖励 折扣的未来最大Q值构成。问题在于这个“最大值”的引入天生带有正向偏差——在估计存在噪声的情况下取最大值会倾向于选中那个被高估的动作。也就是说系统会越来越乐观地认为某个动作比它实际表现更好最终导致Q值膨胀策略随之偏离最优解。Double DQN的修正方式非常直接把“选择动作”和“评估动作价值”这两个环节解耦。在选择下一步动作的时候用当前主网络online net来挑选最优动作但计算目标值时用目标网络target net去评估这个动作的Q值。这样即使某个动作被主网络高估了目标网络给出的评估也是独立的不会因为主网络的噪声而同步膨胀。你可以理解为一个人负责做决定另一个人负责给决定打分两者分开作弊的空间就小了。这么做不会增加太多计算负担代码改动也就几行但效果上通常比原生DQN稳定不少。在我自己的实验里同样的超参数配置下普通DQN在Breakout上的Q值会慢慢涨到一个离谱的数字比如几千而策略却还是很蠢Double DQN的Q值虽然也偏高但涨幅温和得多且与真实回报的相关性明显更强。1.3 技术选型为什么用PyTorch加gymnasium现在的强化学习框架其实不少像TensorFlow系、JAX系还有各种高度封装的库比如Stable-Baselines3但我个人还是推荐用PyTorch自己手写核心逻辑。原因很简单手写一遍你会真正理解每个模块为什么存在。Stable-Baselines3一个接口就把训练流程全跑通了新手很容易陷入“只会改参数”的瓶颈一旦遇到bug完全不知道从哪下手。而用PyTorch从零搭起来你对网络结构、梯度流向、损失函数、更新时机都会有体感排查问题时也能做到“哪里有毛病一目了然”。环境方面我用的是gymnasium这是原来的OpenAI Gym维护的继任版本接口干净Atari环境的支持也很完善。现在很多教程还在用老版gym接口差异会导致一些代码跑不通这点后面讲环境配置的时候我会专门提醒。为什么要选PyTorch而不是别的框架对我来说最大的理由是它的动态图机制。强化学习里网络结构经常需要微调比如在输入里增加辅助信息、给loss加正则项动态图环境下写起来就像写普通Python代码一样自然不需要关心静态图编译的约束。调试体验也是我比较看重的一点PyTorch的报错信息通常能直接定位到哪一行张量计算出了问题这对反复试错调参的阶段来说太重要了。2. 核心原理拆解DQN与Double DQN2.1 DQN是怎么让神经网络学会玩游戏的DQN把Q-Learning和深度神经网络结合核心创新其实就三个经验回放、目标网络、用卷积神经网络直接处理原始像素。这三个东西缺了任何一个整个训练过程都会崩。先说经验回放它的做法是把智能体与环境互动产生的数据状态、动作、奖励、下一状态存到一个内存池里训练时随机采样一小批数据来更新网络而不是按时间顺序一条条地用。为什么要这么做因为时序相邻的数据是高度相关的。比如打砖块时好几帧内的画面变化很连续如果你按顺序学网络会反复看到相似的数据导致训练不稳定随机采样则可以打破这种相关性让训练更像是在独立同分布的数据上学习收敛过程稳得多。目标网络解决的是“追着移动靶心打”的问题。如果你每步都直接更新网络并用它来生成训练目标那目标值每次都在变网络会追着一个不断移动的目标跑很难收敛。所以做法是额外维护一份目标网络的副本参数定期比如每10000步才从主网络同步一次。这样在一段时间内训练目标相对固定学习过程就稳定了很多。至于卷积网络处理像素这个思路算是对“输入是图像”这一类环境最常见的解法。CNN天然具备局部感知和参数共享的特性能提取出画面中的关键空间特征比如球的位置、挡板的位置、砖块的分布。把连续4帧画面拼在一起输入网络还能让AI感知到运动趋势这就像你看视频要连续好几帧才能判断球的飞行方向一样。2.2 Double DQN的两步走先选动作再算Q值刚才提到普通DQN的目标值计算是r γ * max_a Q(s, a; θ⁻)这里max本质上被拆成了两步先用目标网络计算所有动作的Q值再取最大值。问题在于如果目标网络对某个动作的Q值估计偏高那这个偏高就会被max直接选中并放大。如果换成Double DQN目标值的计算变成了r γ * Q(s, argmax_a Q(s, a; θ); θ⁻)你先用主网络θ在所有动作里挑出一个最优动作再去目标网络θ⁻里查这个动作对应的Q值。为什么要用主网络来选动作、用目标网络来算值因为主网络实时更新对当前策略的反映更准确目标网络相对滞后给出的评估更稳定。两者参数错开就减少了“高估自激”的可能。你可以把这个理解成一个经验丰富的老手负责判断哪个方案更好另一个沉稳的审计师负责给这个方案做预算评估两个角色不重叠就不会出现“自己给自己批预算”的虚高问题。一开始看到这个改动我也有点怀疑就两行代码的事真能有那么大的区别吗从实验结果来看答案是肯定的。在Breakout上同样的网络结构和超参普通DQN在训练后期Q值会冲到上千但平均回报只有一两百而Double DQN的Q值曲线和真实回报曲线的走势基本一致没有出现Q值远远甩开实际表现的情况。这也是为什么后来很多算法比如Dueling DQN、Rainbow都沿用了Double的机制——它是一个几乎没有副作用的小改动换成任何项目都不亏。2.3 打砖块观测状态的处理从原始像素到张量环境返回的原始图像尺寸是210x160x3的RGB像素值如果你直接扔给神经网络计算量不小而且很多信息对决策来说是冗余的比如上下方的黑色区域以及背景的装饰元素。所以标准的预处理流程是这样的先转成灰度图丢掉颜色信息然后裁剪掉画面顶部和底部的无用区域只保留游戏主体部分最后缩放到84x84的小尺寸再把像素值归一化到0到1之间。在打砖块这个环境里有个关键细节需要注意单帧画面无法体现球的速度和方向。球在静止画面中的位置无法推断它下一帧会飞向哪里所以必须把连续的4帧画面叠加成一个状态输入。我的实现是用一个deque容器保存最近4帧的灰度图每次环境返回新帧就把它压进去、弹出最老的那一帧最后用torch.stack沿通道维度拼成(4, 84, 84)的张量作为网络输入。另外原始环境的观测值还要加上一步很重要的一致性处理如果球恰好卡在一个来回弹跳的状态画面可能在两帧之间完全一样这时候如果直接用前后帧差作为运动特征可能会判断出错。所以更稳妥的做法是尽量让网络自己从多帧堆叠里学运动特征不要在预处理器里做太激进的差分否则容易丢失原始信息。3. 实操过程与完整代码3.1 环境安装与预处理细节如果要跟着我的环境复现你需要的依赖主要是这几个PyTorch、gymnasium、ale-py。在Windows或Linux上创建conda环境后执行conda create -n dqn python3.10 -y conda activate dqn pip install torch torchvision --index-url https://download.pytorch.org/whl/cu118 pip install gymnasium[atari] gymnasium[accept-rom-license] ale-py很多人在Atari环境上卡住是因为ale-py只是加载器还需要一套ROM文件。安装了gymnasium[accept-rom-license]之后会自动下载合法的Atari ROM包这个包兼容ALE的版权许可不会出现ROM缺失的问题。如果你用的是很老的gym版本很多教程里还可能让你手动去下载某个rom文件路径现在有了官方自动下载方式省事不少。环境创建我推荐用这样的配置import gymnasium as gym from gymnasium.wrappers import AtariPreprocessing, FrameStack env gym.make( ALE/Breakout-v5, frameskip1, repeat_action_probability0.0, full_action_spaceFalse, ) env AtariPreprocessing(env, noop_max30, frame_skip4, terminal_on_life_lossTrue, grayscale_obsTrue, grayscale_new_shape(84, 84)) env FrameStack(env, num_stack4)关于frameskip参数说明一下在gymnasium里如果设置了frame_skip4且frameskip1意味着环境内部每次执行一个动作会持续4帧这样能显著提升训练速度。设置repeat_action_probability0.0是为了关闭环境的随机性Atari环境默认有5%的概率随机重复动作这对复现和调试更友好。terminal_on_life_lossTrue是把“丢一条命”也当作一次episode终止信号这个设计对Breakout这种多生命游戏尤其重要——它能让AI更快感知到“失误”的后果而不是把注意力分散到整局游戏的最终结束上。处理完的环境输出是84x84的灰度图FrameStack之后得到的观测shape为(4, 84, 84)直接就能送进PyTorch的卷积网络。3.2 神经网络结构和初始化网络结构我沿用了DQN论文里的经典CNN设计针对84x84的输入做三组卷积再加两个全连接层。具体代码如下import torch import torch.nn as nn class DQN(nn.Module): def __init__(self, input_shape, n_actions): super().__init__() self.conv nn.Sequential( nn.Conv2d(input_shape[0], 32, kernel_size8, stride4), nn.ReLU(), nn.Conv2d(32, 64, kernel_size4, stride2), nn.ReLU(), nn.Conv2d(64, 64, kernel_size3, stride1), nn.ReLU(), ) self.flatten nn.Flatten() conv_out_size self._get_conv_out(input_shape) self.fc nn.Sequential( nn.Linear(conv_out_size, 512), nn.ReLU(), nn.Linear(512, n_actions), ) self._initialize_weights() def _get_conv_out(self, shape): o self.conv(torch.zeros(1, *shape)) return int(torch.numel(o)) def _initialize_weights(self): for module in self.modules(): if isinstance(module, nn.Conv2d): nn.init.kaiming_normal_(module.weight, nonlinearityrelu) module.bias.data.zero_() elif isinstance(module, nn.Linear): nn.init.kaiming_normal_(module.weight, nonlinearityrelu) module.bias.data.zero_() def forward(self, x): return self.fc(self.flatten(self.conv(x)))_get_conv_out这个方法是我特别写来动态计算卷积层之后张量尺寸的这样就不用手动算84x84经过三层卷积会变成多少维。初始化的部分我用了kaiming初始化这对ReLU激活函数来说是一个比较合适的默认选择。如果初始化效果不好训练初期的梯度很容易消失或者爆炸表现为第一个1000步里loss就出现NaN。理论上这个网络可以做得更复杂比如加残差连接、加BatchNorm但对Breakout这种输入很简单的问题过大的模型反而会导致过拟合和训练时间增长。我在实验中用的这个配置已经能跑到相当不错的成绩了核心目的不是堆算力而是把训练流程跑通。3.3 经验回放池与训练主循环经验回放池是整个训练流程里容易被忽略但极其重要的部分。我的实现里用一个双端队列存储最近的经验满了就弹出最旧的。新增经验时把dtype压成float32或uint8尽量节省内存采样时用random.sample随机抽取一个batch。import random from collections import deque import numpy as np class ReplayBuffer: def __init__(self, capacity): self.buffer deque(maxlencapacity) 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) states, actions, rewards, next_states, dones zip(*batch) return ( np.array(states, dtypenp.float32), np.array(actions, dtypenp.int64), np.array(rewards, dtypenp.float32), np.array(next_states, dtypenp.float32), np.array(dones, dtypenp.uint8), ) def __len__(self): return len(self.buffer)经验池的容量要在占用内存和样本多样性之间取平衡。我这边设的是50万条每条经验包含4帧84x84的灰度图内存占用大概在几个GB级别一般PC可以跑。如果内存吃紧可以降到20万条但样本多样性会下降尤其到了训练后期可能采样到大量重叠状态影响更新效果。训练主循环的代码我精简过保留的每个环节都是跑通实验的必要部分。注意看epsilon衰减和目标网络同步的位置from tqdm import tqdm from torch import optim import torch.nn.functional as F def train(): device torch.device(cuda if torch.cuda.is_available() else cpu) net DQN((4, 84, 84), env.action_space.n).to(device) target_net DQN((4, 84, 84), env.action_space.n).to(device) target_net.load_state_dict(net.state_dict()) target_net.eval() buffer ReplayBuffer(capacity500_000) optimizer optim.Adam(net.parameters(), lr1e-4) epsilon 1.0 epsilon_min 0.01 epsilon_decay 0.99995 gamma 0.99 batch_size 32 target_update_freq 10_000 step_count 0 for episode in range(2000): state, _ env.reset() state np.array(state, dtypenp.float32) / 255.0 episode_reward 0 done False while not done: if np.random.random() epsilon: action env.action_space.sample() else: with torch.no_grad(): state_t torch.as_tensor(state, dtypetorch.float32, devicedevice).unsqueeze(0) q_vals net(state_t) action q_vals.argmax(dim1).item() next_state, reward, terminated, truncated, _ env.step(action) done terminated or truncated next_state np.array(next_state, dtypenp.float32) / 255.0 # 奖励压缩 reward np.clip(reward, -1.0, 1.0) buffer.push(state, action, reward, next_state, done) state next_state episode_reward reward step_count 1 if len(buffer) 10_000: states, actions, rewards, next_states, dones buffer.sample(batch_size) states torch.as_tensor(states, devicedevice) actions torch.as_tensor(actions, devicedevice).unsqueeze(1) rewards torch.as_tensor(rewards, devicedevice).unsqueeze(1) next_states torch.as_tensor(next_states, devicedevice) dones torch.as_tensor(dones, devicedevice).unsqueeze(1) q_values net(states).gather(1, actions) # Double DQN 核心用主网络选动作目标网络评估 with torch.no_grad(): next_actions net(next_states).argmax(dim1, keepdimTrue) next_q_values target_net(next_states).gather(1, next_actions) expected_q_values rewards gamma * next_q_values * (1 - dones) loss F.smooth_l1_loss(q_values, expected_q_values) optimizer.zero_grad() loss.backward() torch.nn.utils.clip_grad_norm_(net.parameters(), max_norm10) optimizer.step() if step_count % target_update_freq 0: target_net.load_state_dict(net.state_dict()) epsilon max(epsilon_min, epsilon * epsilon_decay) print(fEpisode {episode}, Reward: {episode_reward:.2f}, Epsilon: {epsilon:.3f}) if __name__ __main__: train()这里有几个细节值得单独说。第一我用了F.smooth_l1_loss也就是Huber Loss而不是MSE。Huber Loss的好处是当预测和目标的差值很大的时候梯度是一个常数不会导致单批数据把网络参数带飞。第二梯度裁剪clip_grad_norm_设置了最大范数10这是防止梯度爆炸的一个保险丝尤其是在训练初期或者环境给出极端奖励信号时能让训练稳定性提高很多。第三奖励压缩到[-1, 1]这个对DQN训练极为关键否则reward的幅度变化过大会让Q值的目标也在一个动态范围里震荡网络很难学稳。你可能注意到我在训练早期buffer长度小于10000就跳过更新这就是标准的warmup阶段目的是先攒一些经验避免在经验池近乎空的时候采样出高度重复的数据同时也能让初始阶段的探索经验不至于被立即用过量的更新淹没。经验池的采样更新比例也很重要按照我这里的配置大概每与环境交互4步就会有一次梯度更新这个频率比较适中。如果你发现训练太快发散可以增大batch_size或降低学习率如果训练太慢可以把更新频率提高。3.4 训练指标监控与评估方式训练过程中光看episode奖励是不够的因为它波动非常大——一个episode可能因为球的方向导致一两分钟都是垃圾操作但偶尔一次好运气也能拿高分。为了更稳定地判断模型是否在变好我每跑5000步就会用当前网络在epsilon0.01的纯贪婪策略下评估5到10个episode取平均分作为评估指标。评估的时候必须关梯度、开inference模式不然推理会占用不必要的显存和计算时间。我通常还会实时记录两个额外的曲线当前Q值的均值以及当前loss的值。Q值均值如果出现持续上涨但奖励不涨那多半是高估问题在恶化loss如果出现那种找不到原因的突然飙升要检查是不是奖励信号里混入了很大的异常值或者网络出现了NaN。合理的训练曲线形态是loss在几千步内快速下降到一个低位然后缓慢波动Q值中位数缓慢上升评估奖励在某一个临界点突然起飞这往往是AI“顿悟”了接球技巧的标志。打砖块这个游戏的奖励曲线前期会非常难看初始阶段可能每一个episode只有0到20分基本全靠瞎碰。这个阶段最容易让人放弃但实际上是在收集探索经验。只要配置正确通常训练到30到50万步的时候评估奖励会开始有上升趋势。如果超过100万步还是完全纹丝不动那基本可以确定是网络结构或超参数出了问题别硬等。3.5 调参心得与参数速查表直接上结论以下几个超参数对最终效果的影响最大我也在打砖块环境上做了对比实验。参数名我的推荐值影响说明学习率 lr1e-4太大训练发散太小收敛极慢。尝试过5e-5效果略稳但明显变慢2.5e-4则容易在训练后期出现Q值震荡折扣因子 gamma0.99强化学习里最常用的值换到其他Atari环境基本不用改目标网络更新频率10000步太勤(1000步)会让网络过于追逐最新参数失去稳定性太疏(50000步)则可能让目标值长期失准经验池容量500000越大越稳定但内存压力大如果显存和内存都有限可以降一半batch size32加大到64训练会更稳但对变参敏感小数据量下反而可能变差epsilon 衰减系数0.99995从1.0衰减到0.01大概需要90万步保证前期有充分的探索奖励裁剪[-1, 1]对训练稳定性帮助极大几乎是必需项梯度裁剪max_norm10防止单次更新幅度过大关于学习率我遇到过最典型的场景是从1e-4改成5e-4前5000步QLoss确实下降很快但到了20万步左右奖励突然停滞然后Q值曲线开始向上发散。这说明学习率太高造成的过拟合和高估在后期暴露出来了。所以现在我的习惯是先按1e-4跑一个基线等到奖励曲线出现平台期再考虑用学习率退火比如在训练后期降到2e-5来微调。epsilon衰减系数同样需要关注。衰减太快会让AI过早进入“只利用不探索”的状态后期会陷入局部最优衰减太慢则会让前期充满无效探索训练时间拖长。我用的0.99995指数衰减相当于每1000步epsilon衰减到原来的约0.95整体衰减曲线比较平滑。4. 常见问题与修复实录4.1 训练半天奖励不涨到底哪里出了问题这是我被问得最多的一个问题。奖励不涨的排查顺序在我这里基本是固定的。第一步先看loss如果loss能正常下降说明网络还在学问题很可能出在奖励信号或探索策略上面。第二步看Q值如果Q值在上涨但奖励不涨高估问题大概率存在可以尝试切换到Double DQN的写法排除是不是误用成了普通DQN。第三步看epsilon如果exploration已经降到了0.01以下而AI还没学会有效策略说明它已经被困在了局部最优里这时候只能回退到较早的检查点或者调大epsilon重新探索。如果loss从一开始就不降那问题大概率出在网络结构或数据流上。常见的坑包括输入数据没有归一化导致梯度异常、卷积输出维度计算错误导致forward直接报错、或者奖励数值类型不对比如整型溢出了。我建议在训练前写个小的smoke test单独跑几批数据让网络更新一次确认loss在下降、梯度没有NaN再开始长训练。4.2 环境API差异老教程为什么跑不通我写这篇代码时用的gymnasium版本已经和几年前的gym API有了明显的差异。最典型的变化是环境重置接口老版本是state env.reset()gymnasium里是state, info env.reset()奖励的done也被拆成了terminated和truncated两个布尔值分别表示“游戏真正结束”和“因为超过步数限制而截断”。如果你照着老教程敲代码经常会感谢这两个小改动换来的一堆报错。还有一点是关于Atari环境的可复现性。新版gymnasium的Atari环境在frameskip和repeat_action_probability上有很多预设的变体frameskip1, repeat_action_probability0配合AtariPreprocessing里的frame_skip4是我调出来的最可控组合。如果你在别的教程里看到BreakoutNoFrameskip-v4之类的环境名那也差不多是一个意思。我的经验是为了保持训练信号的确定性repeat_action_probability务必设为0至少在你验证算法阶段要这样否则每次跑出来的结果偏差会很大调参时你根本分不清是算法变好了还是随机噪声导致的效果变化。4.3 训练过程中的数值稳定性问题训练过程中我最怕的不是loss高而是突然出现NaN。这种问题一旦出现整个训练进程基本就报废了。最常见的触发点有两个一是输入图像里出现了非法值比如直接从uint8转float32时没有归一化导致像素值到了几百的级别卷积之后数值爆炸二是奖励信号里偶尔混入了极大值配合较大的学习率梯度步长直接冲出了有效范围。建议的防御措施有三个输入数据在进入网络前必须统一除以255归一化reward做裁剪梯度裁剪一定要加。这三招如果都做了NaN出现的概率会大大降低。如果还是出现NaN可以在每步优化前检查一次参数的数值torch.isnan(net.fc[2].weight).any()看到底是哪个层先出了问题再针对性地调整初始化方式或学习率。灰度化和归一化也不要做得太随意。灰度化能显著降低输入维度省内存省计算而且颜色信息对打砖块这个游戏没有额外决策价值因为砖块的颜色和得分的关系在这个环境中并不影响最优策略。归一化到0到1区间卷积网络的权重初始化才能在一个合理的范围内工作否则第一层卷积输出可能过大ReLU后一片死区梯度传不下去。5. 实测效果与后续扩展5.1 训练曲线解读烧了50万步才看清的规律我自己的环境大概跑了120万步。直观的观察是前30万步基本属于“瞎打”阶段评估奖励始终在个位数徘徊到40到60万步之间会有一个明显的跃升AI开始懂得左右移动去接球再往后就是策略精修阶段分数逐步上升但偶尔会有回撤。关于这个“顿悟时刻”其实挺有意思的。强化学习里高维状态下的策略往往是非线性的前期网络一直在调节参数逼近价值函数但价值函数的形状平滑度不够一旦某个关键神经元被“激活”策略会发生相变式的突变对应到游戏表现就是突然会玩了。这也是为什么不能因为早期几百步的成绩差就直接放弃给模型足够的时间去探索才是关键。我还统计了Double DQN和普通DQN在同配置下的对比普通DQN在50万步时评估奖励大约在30分上下但Q值已经到了一两百Double DQN同阶段评估奖励五六十Q值六七十。Q值和真实奖励之间的偏差差距非常直观地说明了Double机制在抑制高估方面的价值。训练达到100万步之后我碰到的新问题是过拟合模型会逐渐固化在某一条固定的挡板移动模式里一旦球的角度偏离常规范围就反应不过来。解决办法是适度加入随机动作噪声比如把epsilon最低值从0.01提升到0.05或者对环境的初始状态做更强的扰动。这种边际上的小改动常常能再抬一抬成绩。5.2 从Double DQN还能往哪走代码跑通之后这个框架就是你的试验田了。顺着同样的训练架构你可以很自然地加入几个改进。第一个是Dueling DQN网络从输出单路Q值改成输出状态价值和动作优势值两条支路这样做能让网络在感知到某个状态本身“很危险”或“赚到了”的时候更快地传递学习信号。第二个是Prioritized Experience Replay让网络更新时更频繁地采样那些学习价值高的经验训练效率会有明显提升。第三个是NoisyNet把epsilon-greedy的探索改成在网络的参数上直接注入可学习的噪声减少人工调探索率的痛苦。这些改进的方向都建立在“你已经理解DQN怎么工作”的基础上。而手写了这套代码之后再去看Rainbow等算法的论文你会产生完全不同的感觉论文里那些改进项的动机和实现细节在你脑中会直接对应到具体的代码模块上而不只是抽象的数学符号。这个认知升级恰恰是手写复现最大的收获。5.3 最后分享一个训练中的小习惯在训练流程之前我一定会先把torch.manual_seed(0)、np.random.seed(0)以及环境的seed都固定下来并且在代码里记录下配置文件的哈希值。这不是为了追求“可复现”这个词本身而是因为在强化学习里存在太多随机性来源如果seed不固定你根本分不清参数改动是产生了真实影响还是纯粹的随机波动。另外我强烈建议你隔一段时间就把当前模型参数保存为一个带步数标识的checkpoint比如dqn_500k.pt、dqn_1000k.pt。训练后期如果发现策略退化或者想尝试新的微调方案随时都可以回退到之前还不错的版本继续调整而不用从头再跑一遍。这个习惯帮我省下过好几个晚上的重训时间。其实写到这里我觉得这套项目最吸引人的地方在于它完美地把深度学习、概率论、优化理论、工程调试揉在了一起每一个环节都可能成为瓶颈但每一个瓶颈也都对应着一条可以深挖的学习路径。如果你能把这份代码调到自己满意的效果那你对强化学习的理解绝对不会只停留在“会调库”的层面。继续跑下去你会踩到更多新坑但也会慢慢建立起属于自己的那套实验直觉。