我第一次把DQN的论文公式照着敲进代码里是在一个周末。模型跑了整整一夜第二天早上打开日志奖励曲线像心电图一样上下乱跳最后停在了跟随机乱按差不多的水平。后来反复折腾才发现问题根本不在公式抄错而在我把强化学习硬塞进了监督学习的思维框架里——数据不是独立同分布的标签是会变的这两件事在普通分类任务里根本不存在。这篇文章就是想把那段时间踩过的坑、翻过的资料、以及最后跑通之后回看觉得早该知道的那些东西完整地摊开讲一遍。DQNDeep Q-Network深度Q网络是深度强化学习里最经典的入门算法之一它干的事情说穿了很简单用神经网络替代一张存不下的表格去估计在某个状态下做某个动作未来能拿到多少总回报。适合谁看如果你已经懂一点神经网络会写PyTorch或者TensorFlow的训练循环但一接触强化学习就发现损失函数不收敛奖励不涨Q值爆炸那这篇基本就是给你准备的。我会从表格法的天花板讲起一路讲到经验回放、目标网络、损失设计、调参排查链路最后给一份能直接跑起来的代码骨架。1. 表格法走不通的那天DQN到底在解决什么问题很多人学DQN是从Q-Learning开始的这没问题但如果没搞清楚Q-Learning为什么在真实问题上会失效后面所有设计的动机都会变成论文这么写的我就这么写调参的时候完全无从下手。1.1 从Q表说起状态一多内存先投降Q-Learning的核心思想极其朴素维护一张表行是状态列是动作格子里存的是在这个状态下做这个动作长期来看能拿多少分。这个分数就是Q值。训练过程就是不断地用实际观测到的回报去修正表格里的数字最后选动作时直接查表挑那一行里数字最大的。在格子世界这类问题里这张表小得可怜。一个四乘四的迷宫16个格子乘4个动作64个数字用个字典就存下了。但只要状态稍微复杂一点这张表就会以指数速度膨胀。假设状态由10个维度的变量描述每个维度只取10个离散值那状态总数就是10的10次方一百亿行。要是输入是图像比如一张210乘160的彩色画面那状态数得按像素值组合来算这个数字大到没有实际意义。这就是所谓的维度灾难。表格法要求每个状态都被单独存储、单独更新而它隐含的前提是状态可以被穷举。一旦状态连续或者高维这个前提就崩了。更难受的是即使你硬把连续状态离散化每个格子里的样本也会变得极其稀疏——大部分格子你可能一次都没访问过那里面的Q值永远是初始值学了个寂寞。所以表格法的天花板不在算法本身而在表示能力。它没有泛化能力在状态A学到的经验没办法迁移到长得差不多的状态B上。而神经网络恰好擅长这件事给它一堆输入输出对它能把相似输入映射到相似输出。1.2 神经网络当Q表输入输出到底怎么接明白了动机接下来的工程问题就来了神经网络怎么接进去有两条路。第一条是把状态和动作拼在一起作为输入输出一个标量Q值。第二条是只输入状态输出一个向量向量长度等于动作数每个位置对应一个动作的Q值。DQN用的是第二条原因很实在选动作的时候要对比所有动作的价值。如果用第一条路你得把每个动作都跟当前状态拼一遍跑N次前向传播才能选出最优动作用第二条路一次前向传播就拿到全部动作的分数直接取argmax效率差着数量级。网络结构上全连接层处理低维状态向量就够了如果是图像输入前面接几层卷积把画面压成特征向量再接全连接。输出层有个细节特别容易错不要加softmax或者sigmoid。Q值是未来累计折扣回报的期望它可以大于1可以是负数可以是任意实数加了激活函数把输出压到0到1之间等于给值域上了个笼子后面怎么训都训不对。还有一个新手常犯的错把输入归一化忘了。状态向量里如果有的一项取值范围是0到1另一项是0到10000网络会严重偏向后者。这跟普通神经网络训练没区别但在强化学习里更容易被忽略因为大家注意力都在算法上。1.3 三个随时能把训练炸掉的隐患把神经网络塞进Q-Learning之后表面上看问题解决了——不用存表了还能泛化。但我实测下来直接这么写代码十有八九是跑不通的。原因藏在三个地方。第一样本高度相关。强化学习的数据是一条轨迹上连续采出来的相邻两帧画面可能只差几个像素状态转移几乎是确定的。神经网络的训练默认假设样本是独立同分布的你喂给它一串强相关的样本梯度方向会连续朝同一个方向偏网络会疯狂地拟合最近的这几步经验把之前学到的东西忘掉。这个现象叫灾难性遗忘表现出来就是奖励先涨一点然后突然崩掉。第二目标在动。Q-Learning的更新公式里目标值是即时奖励加上下一状态的最大Q值。问题在于这个下一状态的最大Q值是你当前这个网络自己算出来的。你在用网络输出的结果去更新网络自己每次参数更新都会让目标跟着变。打个比方你在追一个靶子而这个靶子是用你自己的位置画出来的你往前一步靶子也往前一步永远追不上训练就在原地打转甚至发散。第三更新幅度失控。强化学习的奖励有时候量级很大再乘上折扣因子的幂次累加算出来的Q值可能到几千上万。用平方损失的时候梯度会被这个量级带着走一步更新就把参数打飞了。后续两节要讲的经验回放和目标网络本质上就是针对前两个问题开的药方。第三个问题靠奖励缩放和损失函数选择来解决。搞清楚了每个设计在治什么病看代码的时候就不会觉得那些繁琐的缓存和同步逻辑是多余的了。2. 经验回放为什么要把走过的路存起来反复嚼经验回放Experience Replay是我认为DQN里最漂亮的一个设计思路简单到有点土但效果立竿见影。它做的事情就是把智能体跟环境交互产生的每一步记录都存进一个池子训练的时候从这个池子里随机抽一批出来用而不是用完当前这一步就丢。2.1 在线更新为什么会在同一个坑里越陷越深先说不用回放会怎样。假设智能体在玩一个游戏连续几步都是向右移动场景大同小异。如果每走一步就立刻拿这一步的数据去更新网络那么在这连续几步里网络接收到的梯度信号高度一致参数会朝着向右移动好这个方向连续偏移好几次。这个过程有点像你连续看了十篇观点完全相同的文章看完之后你会觉得全世界都这么想——实际上只是你的信息源太单一了。网络也是一样它被最近的样本洗脑了之前学到的、关于其他状态的知识被覆盖掉。在强化学习里这个现象尤其致命因为策略一旦偏向某个方向后续采到的样本会更加偏向那个方向形成正反馈最后彻底跑偏。我的经验是即使是很简单的环境不用回放基本都跑不稳。曲线的典型形态是前期涨得挺快然后在某个点突然掉下去再也回不来。2.2 回放池的容量、采样与实现取舍回放池本质上就是个固定长度的队列新数据进队老数据出队。容量这个参数需要权衡。容量太小比如只存几百条那随机采样出来的批次里还是会有大量强相关样本回放的意义被削弱了大半。容量太大一是吃内存二是有个隐蔽的问题池子里会积压大量旧策略产生的数据。DQN是异策略off-policy算法理论上用旧数据没问题但当旧策略和新策略差太远的时候这些数据对当前网络的参考价值就很低了反而稀释了有效样本。实践中我一般这么设简单环境用一万到五万图像输入的中等复杂度任务用十万到一百万。图像回放池最吃内存每一帧如果是84乘84的灰度图按float32存就是二十八KB左右一百万条就是二十多GB一般扛不住。所以图像任务通常会做帧堆叠加降采样或者干脆把像素存成uint8采样的时候再转float能省四分之三的内存。采样策略上最基础的是均匀随机采样。实现里有个小细节容易忽略必须保证池子里有足够的数据再开始训练。我一般设一个warm-up阈值比如池子填到五千条之后再启动训练循环。否则一开始样本太少采样出来重复率极高等于又回到了在线更新的老路。下面这个最小实现可以参考用的是双端队列超容量自动挤掉最旧的import random from collections import deque import numpy as np import torch class ReplayBuffer: def __init__(self, capacity): self.buf deque(maxlencapacity) def push(self, state, action, reward, next_state, done): self.buf.append((state, action, reward, next_state, done)) def sample(self, batch_size): batch random.sample(self.buf, batch_size) s, a, r, s2, d zip(*batch) return ( torch.as_tensor(np.array(s), dtypetorch.float32), torch.as_tensor(a, dtypetorch.long).unsqueeze(1), torch.as_tensor(r, dtypetorch.float32).unsqueeze(1), torch.as_tensor(np.array(s2), dtypetorch.float32), torch.as_tensor(d, dtypetorch.float32).unsqueeze(1), ) def __len__(self): return len(self.buf)维度对齐这件事看着不起眼实际上是新手第一个卡壳的地方。动作索引要unsqueeze成列向量才能跟gather出来的Q值对齐奖励和done也要保持形状一致否则广播机制会悄悄给你算出个错误结果还不报错。2.3 一个容易忽略的细节存进去的到底是什么这里有个很多人会踩的坑done标志位必须存而且必须正确使用。为什么因为终止状态的下一状态其实是不存在的。如果智能体掉进悬崖游戏结束了你还在用下一状态的最大Q值来做自举等于告诉网络死了之后还有机会拿分网络就会高估那些导致死亡的动作为价值。正确做法是在计算目标值时把自举项截断target reward gamma * (1.0 - done) * next_q_max这个(1.0 - done)看起来就一行但漏了它训练出来的智能体往往会表现出不怕死的诡异行为奖励卡在一个很低的水平上不去。我调过一个环境加上这一行之后分数直接从两位数跳到接近满分前面折腾了大半天的网络结构和学习率全是白费功夫。另一个细节是有些环境在达到最大步数时也会返回done这跟真正终止是两回事。前者叫截断truncation后者叫终止termination。严格来说截断时不应该把自举项砍掉因为状态本身还有价值只是时间到了。早期的DQN实现基本不区分这两者但在一些对时序敏感的任务上区分开来会有明显收益。3. 目标网络给不断漂移的靶心钉一根钉子经验回放解决了样本相关性的问题目标网络解决的是另一个更隐蔽的问题自举导致的目标漂移。这两个设计合在一起才构成了DQN能跑起来的最小闭环。3.1 自举带来的追自己尾巴先把这个问题的本质讲清楚。时序差分学习的更新目标是y r γ · max Q(s, a)这里的Q(s, a)是网络对下一状态的估值。而你要更新的是同一个网络在Q(s, a)上的输出。也就是说损失函数的两端都来自同一个正在被优化的网络。这跟监督学习有本质区别。监督学习里标签是固定的模型预测往标签靠靠得越近损失越小是个有明确终点的过程。而这里标签本身会随着参数更新而移动你往它那儿走一步它也跟着走一步。数学上这叫移动目标问题表现出来就是训练极不稳定损失可能不降反升Q值可能整体往上飘或者往下掉。更麻烦的是这个问题会自我放大。假设某次更新让网络对某个状态的估值偏高了下一次算目标时会把这个偏高的值带进去目标变高训练又进一步推高估值一路滚雪球。这就是Q值爆炸的根源之一。3.2 硬同步还是软更新频率怎么定目标网络的思路很直接再复制一份网络冻结它的参数专门用来算目标值。在线网络负责选动作和更新目标网络负责提供稳定的标签。每隔一段时间把在线网络的参数复制给目标网络然后继续冻结。这样就人为地把目标漂移的频率降下来了。在一千步的窗口里目标网络是纹丝不动的在线网络可以安心地朝它收敛一千步之后目标往前挪一点再继续追。追的速度虽然慢但每一步都是踏实的。同步频率是个需要调的超参。同步太频繁比如每十步一次那目标网络几乎跟在线网络同步变化等于没加退回原来的问题。同步太稀疏比如十万步一次目标网络的信息严重过时在线网络追的是一个早就过期的估值训练效率会很低而且当在线网络的策略已经大幅改变时用老目标去引导会引入偏差。原版DQN用的是每万步左右硬同步一次这个数值在大多数中等规模任务上还算靠谱。我自己常用的起点是训练总步数的百分之一到千分之一之间然后根据曲线调整。如果看到损失曲线出现周期性的锯齿状波动同步周期一般就是那个锯齿的周期可以适当拉长。还有一种变体是软更新也叫Polyak平均不做整体复制而是每一步都让目标网络朝在线网络靠一点点for target_param, online_param in zip(target_net.parameters(), online_net.parameters()): target_param.data.copy_(tau * online_param.data (1 - tau) * target_param.data)这里的tau一般取0.001到0.01。软更新的好处是目标变化平滑没有硬同步那种阶跃训练曲线会更好看。代价是多了一份参数拷贝的开销而且tau这个值本身也需要调。我的建议是刚开始接触的时候用硬同步逻辑清晰好调试等熟悉了、追求更平滑的收敛时再换软更新。3.3 少一个目标网络会怎样一个对照实验我曾经在一个平衡杆类的经典控制环境上做过对照。两个版本代码完全一样唯一区别是目标网络是否存在。不开目标网络的版本前两百个回合奖励确实在涨一度到了三四百但之后就剧烈震荡最终稳定在一个很低的水位怎么改学习率都救不回来。开了目标网络的版本前期涨得慢一些因为目标滞后但曲线整体是单调向上的最后稳定收敛。这个对照给我的启发是强化学习的调试不能只看前几百步。很多配置在初期表现都差不多差别体现在中后期能不能稳住。所以我养成了一个习惯任何改动至少跑完训练预算的六七成再下结论看前半段就拍板往往会得出错误判断。还有个细节目标网络的参数更新方式必须是复制而不是共享。早期我图省事直接让两个变量指向同一个网络对象结果等于没有目标网络白折腾。用load_state_dict和deepcopy都行注意别只复制了引用。4. 把强化学习翻译成监督学习损失函数与训练节奏DQN最妙的地方在于它把一个看起来跟监督学习八竿子打不着的问题硬生生翻译成了一个回归任务。理解了这个翻译过程整个算法的代码结构就变得非常自然。4.1 Bellman方程给出的那个标签监督学习里每条样本是输入 正确答案。DQN里输入是状态正确答案是什么答案是贝尔曼最优方程给出的那个值Q*(s, a) E[ r γ · max Q*(s, a) ]翻译成人话在状态s做动作a的真正价值等于这一步拿到的即时奖励加上折扣之后下一状态最好动作的价值。这是个递归定义它把长期回报拆成了眼前收益 未来的估值。所以在训练时我们把r γ · max Q_target(s, a)当作标签把Q_online(s, a)当作预测两者求差算损失。网络输出的其他的动作的Q值不参与这个样本的损失计算——因为它们没有被执行没有实际观测结果可以对照。实现上这个只更新被执行动作的操作靠gather完成q_values online_net(states) # [B, n_actions] q_taken q_values.gather(1, actions) # [B, 1] with torch.no_grad(): next_q target_net(next_states).max(dim1, keepdimTrue)[0] target rewards gamma * (1.0 - done) * next_q loss F.smooth_l1_loss(q_taken, target)这里有个关键点target的计算必须在torch.no_grad()里。如果忘了加梯度会顺着target一路回传到目标网络把目标网络也一起更新了等于目标网络失去了冻结的意义。这个错误不会报错loss照样下降但训练效果会明显变差属于典型的静默bug。损失函数的选择上用均方误差也能跑但我更推荐Huber损失也就是smooth_l1_loss。它在误差小的时候表现得像平方损失误差大的时候退化成绝对值损失梯度被限制住了。强化学习里偶尔会冒出个别异常大的TD误差用平方损失的话这个样本会主导整个批次的梯度把参数带偏。Huber相当于给梯度加了个软性的裁剪稳定性提升很明显。4.2 训练循环的四个阶段与节奏控制DQN的主循环如果拆开看其实是四个动作在轮转交互用当前策略在环境里走一步拿到(s, a, r, s, done)存进回放池。采样从池子里随机抽一个批次。计算与反传算TD目标、算损失、反向传播、更新在线网络参数。同步每隔固定步数把在线网络的参数复制给目标网络。这里面有个节奏问题特别值得说交互和训练的比例不是一比一。原因在于环境交互往往是瓶颈。物理仿真还好如果环境本身很重比如需要渲染画面每走一步的开销可能比一次网络更新还大。所以常见的做法是每交互一步做若干次梯度更新。但反过来也不能更新太多次否则网络会在同一个批次上过拟合而且随着策略变化池子里的数据分布也在变更新太猛会导致网络跟不上。我一般的起点是每交互一步做一次更新等训练稳定后再根据情况调整。如果发现样本利用率太低奖励涨得慢但交互步数已经很多可以提高到每步更新两到四次如果发现训练震荡就降到每几步更新一次。另一个容易被忽视的点是评估频率。训练过程中要定期跑几个完整的回合用纯贪心策略epsilon设为0测一下真实表现。训练时的回合奖励因为带探索噪声会偏低且波动大直接看它容易误判。评估回合数不用太多三到五个就够但一定要跑它是判断到底学没学会的唯一可靠依据。4.3 ε-greedy探索不能停得太早DQN用ε-greedy策略来平衡探索和利用。以ε的概率随机选动作以1减ε的概率选当前估值最高的动作。ε的调度通常是线性衰减从1.0开始纯随机衰减到0.01或者0.05基本贪心。衰减的快慢很关键。衰减太快比如几千步就掉到底智能体还没探索到足够多的状态就开始固化了很容易陷进一个次优策略里出不来。表现出来就是奖励涨到一个还行的水平就卡住怎么训练都不动。衰减太慢全程都在大量随机探索学到的策略质量差而且浪费训练预算。比较稳妥的做法是让衰减过程覆盖总训练步数的百分之十到三十。另外即使衰减到底也不建议直接把ε设成0。保留一点点随机性比如0.01到0.05能让智能体在训练中偶尔跳出局部最优对最终性能有好处。只有在做最终评估的时候才把ε彻底关掉。还有个进阶技巧如果环境有明确的探索困难特征比如奖励极其稀疏只有走到终点才有分线性衰减往往不够用可以考虑用基于计数的探索奖励或者噪声网络。不过这些超出了基础DQN的范畴先把标准的跑通再考虑。5. DQN不收敛时的排查链路从奖励尺度开始往下查这一节是我觉得最有价值的部分。网上讲DQN的文章大多只讲原理和代码但真正用起来的时候九成时间都花在它为什么不work上面。我把自己踩过的坑整理成了一条排查链路按这个顺序查能省下大量瞎试的时间。5.1 先看奖励别急着改网络很多人一发现不收敛就开始调网络结构加层、改激活函数、换优化器折腾一圈回来发现没用。我的经验是奖励尺度是最常见、也最容易被忽略的元凶。网络输出的Q值量级直接受奖励影响。如果每步奖励是几百上千折扣因子0.99累积起来Q值能到几万这个量级的梯度足以让任何优化器失效。反过来如果每步奖励是零点零几Q值太小梯度信号微弱学习速度慢到看不出来。处理方法很简单把奖励缩放一下。常见做法是奖励除以一个固定常数或者做running mean/std归一化。比如把每步奖励裁到负一到正一之间或者整体除以最大可能奖励。我一般的习惯是先手动看一眼环境的奖励范围然后缩放到大致负一到正一的区间这个区间对神经网络最友好。还有一类更隐蔽的问题奖励符号搞反了。有些环境的奖励是惩罚形式数值越大越差如果你当成越大越好来处理智能体会拼命往坏的方向走。这种bug表现出来就是奖励曲线平稳下降或者一直卡在很低的水平看起来特别像学不会实际是学反了。第一次接触新环境务必花五分钟把奖励语义确认清楚。同理动作空间的映射也要核对。离散动作环境里网络输出的是动作索引如果你的动作列表顺序和环境的期望不一致智能体会做出完全无意义的动作。这种错误在简单环境里不明显因为随机也能拿点分在复杂环境里会让人抓狂。5.2 一批典型崩溃现象与对应病因下面这张表是我这些年总结的对照遇到问题先对号入座能快速缩小范围现象最可能的病因优先检查项奖励全程在随机水平附近抖动动作映射错误 / 奖励符号反了 / done截断缺失手动跑几个回合打印动作和奖励奖励先涨后断崖式下跌再没恢复学习率过大 / 目标网络同步过频 / 回放池太小学习率砍到十分之一重跑损失曲线剧烈震荡批次太小 / 奖励未缩放 / 未用Huber损失batch调到64以上加奖励缩放Q值持续单调上升直至溢出缺少目标网络或同步失效 / done未参与截断打印Q值均值确认target计算在no_grad里损失平稳下降但奖励不涨Q值整体塌缩 / 折扣因子过大 / 网络容量不足检查输出层是否有激活函数学到一个摆烂策略比如原地不动奖励设计问题 / 探索衰减过快拉长epsilon衰减区间拿奖励先涨后崩这条展开说。这个现象特别常见因为它的成因有好几个容易误诊。学习率过大是最直接的更新步子太大会把已经学好的策略打散。目标网络同步太频繁会导致目标一直在动训练失去锚点。回放池太小的话池子里的数据分布更新太快网络被最近的数据带着跑。我的排查顺序是先把学习率除以十跑一遍。如果曲线变得平缓且单调那就是学习率问题。如果还是崩再把目标网络同步周期拉长五到十倍。这两个试完还不行再去怀疑回放池容量和网络结构。这个顺序是按改动成本从低到高排的能最快排除最常见的原因。5.3 超参的优先级排序如果把所有超参按对结果的影响程度排序我的体感是这样的第一梯队影响巨大必须调奖励缩放系数、学习率、epsilon衰减方案、done截断是否正确。这四个决定了训练能不能收敛随便一个出问题都是灾难性的。第二梯队影响明显值得调目标网络同步周期、回放池容量、batch size、折扣因子gamma。这些影响收敛速度和最终性能但不至于让训练完全跑不起来。第三梯队影响有限先别动网络层数、隐藏单元数、优化器类型、激活函数选择。这些在入门阶段用默认值就行一两个隐藏层、一百多个单元、Adam优化器能解决绝大多数经典控制问题。等你把前面的都调好了还不满意再来考虑这些。这个排序的意义在于省时间。强化学习的单次实验往往要跑十几分钟到几小时乱试的成本极高。按优先级来能把有限的实验预算花在刀刃上。6. 过估计与几个经典补丁Double、Dueling、优先回放基础版DQN能跑但它有几个已经被研究透了的缺陷。了解这些缺陷一方面能在遇到对应现象时知道往哪改另一方面也能理解后续一系列改进算法到底在解决什么。6.1 max操作带来的系统性高估问题出在TD目标里的那个max。假设网络对每个动作的估值都带有随机误差有的是正偏有的是负偏。取最大值的时候你几乎总是会选中那个正偏最多的动作。也就是说max操作会系统性地把估值往上抬误差越大高估越严重。这个偏差会通过自举一路传播越滚越大。表现出来就是Q值整体虚高智能体对某些动作的价值判断过于乐观学出来的策略偏激进。在一些风险敏感的任务里这种乐观会导致智能体反复尝试明明会失败的操作。更麻烦的是这个偏差在训练早期尤其严重因为那时候网络估值误差最大。而训练早期恰恰是策略形成的关键期一开始就学歪了后面很难扭回来。6.2 Double DQN拆开选动作和算价值的那只手Double DQN的思路非常巧把选哪个动作和这个动作值多少这两件事交给两个不同的网络来做。具体做法是用在线网络去选出下一状态的最优动作a* argmax Q_online(s, a)然后用目标网络来评估这个动作的价值y r γ · Q_target(s, a*)为什么这样能缓解高估因为在线网络和目标网络的参数不同它们的高估误差不会完全同步。在线网络选出来的动作在目标网络那里未必是被高估最多的那个两个误差一交叉正偏就被抵消掉一部分。实现上的改动小到几乎看不出来with torch.no_grad(): next_online_q online_net(next_states) best_actions next_online_q.argmax(dim1, keepdimTrue) next_target_q target_net(next_states).gather(1, best_actions) target rewards gamma * (1.0 - done) * next_target_q就多了一次前向传播代码量增加不到五行但效果在大多数环境上都很明显。这是我个人最推荐的第一个改进性价比极高。6.3 Dueling与优先回放各自的适用场景Dueling DQN改的是网络结构。它把Q值拆成两部分相加状态价值V(s)这个状态本身有多好跟具体动作无关和优势A(s,a)在这个状态下某个动作比平均水平好多少。网络有两个输出分支最后合并Q(s, a) V(s) A(s, a) - mean_a A(s, a)减均值是为了解决可辨识性问题——V和A的组合有无穷多种不约束的话训练会不稳定。这个结构在什么情况下有用当环境中很多动作的效果差不多时。比如一个赛车游戏在直道上左打一点方向和右打一点方向结果几乎一样真正决定分数的是有没有撞墙这个状态层面的判断。Dueling让网络把注意力集中在评估状态本身上不用为每个动作都单独学一套东西样本效率会提高。但如果环境里每个动作的后果差异都很大Dueling的收益就不明显了。优先经验回放Prioritized Experience Replay改的是采样策略。均匀采样的问题在于有些样本的TD误差很大说明网络在这些地方还学得不好但被抽到的概率跟其他样本一样。优先回放按TD误差的绝对值来定采样概率误差大的样本被抽中的机会更多。这是很符合直觉的做法错得越多的地方越该多练。但它引入了一个偏差——采样分布变了会改变期望的估计所以要用重要性采样权重来修正。实现上比前两个改进复杂不少要维护一个优先级的树结构sum tree来高效采样还要处理新样本的优先级刚存进去的样本还没算过TD误差一般给个最大优先级保证至少被抽到一次。我的建议是先把基础DQN加Double DQN跑通并调好这两个的组合已经能解决大部分入门级任务。Dueling作为网络结构的改进可以顺手加上成本不高。优先回放留到确实遇到样本效率不够的问题时再上不要一上来就把所有技巧都堆上去——堆得越多出问题时越难定位是哪个部分的原因。7. 一份能跑起来的代码骨架与逐行注释原理讲完了最后落回到代码。下面这份骨架是围绕经典控制类任务连续状态、离散动作写的结构上刻意保持了最简方便对照前面的章节。7.1 网络结构与回放池的最小实现import torch import torch.nn as nn import torch.nn.functional as F import numpy as np import random from collections import deque class QNetwork(nn.Module): def __init__(self, obs_dim, n_actions, hidden128): super().__init__() self.net nn.Sequential( nn.Linear(obs_dim, hidden), nn.ReLU(), nn.Linear(hidden, hidden), nn.ReLU(), nn.Linear(hidden, n_actions) # 输出层不加激活 ) def forward(self, x): return self.net(x)网络这块唯一需要提醒的是输出层。我见过不止一个人在这里手滑加了nn.Sigmoid()然后怎么调都学不会。Q值的值域是全体实数任何压缩值域的激活函数都会造成伤害。回放池在2.2节已经给过这里补一个容易被忽略的点如果状态是图像存的时候用np.uint8采样的时候再除255转float内存能省一大截。低维向量状态直接存float32就行没必要折腾。7.2 主训练循环# 超参 GAMMA 0.99 LR 1e-3 BATCH 64 BUFFER_SIZE 50000 EPS_START 1.0 EPS_END 0.02 EPS_DECAY_STEPS 20000 TARGET_SYNC 500 WARMUP 2000 TOTAL_STEPS 100000 online QNetwork(obs_dim, n_actions) target QNetwork(obs_dim, n_actions) target.load_state_dict(online.state_dict()) # 初始参数必须一致 optimizer torch.optim.Adam(online.parameters(), lrLR) buffer ReplayBuffer(BUFFER_SIZE) state, _ env.reset() step 0 episode_reward 0.0 while step TOTAL_STEPS: # 1. epsilon衰减 eps max(EPS_END, EPS_START - (EPS_START - EPS_END) * step / EPS_DECAY_STEPS) # 2. 选动作 if random.random() eps: action env.action_space.sample() else: with torch.no_grad(): q online(torch.as_tensor(state, dtypetorch.float32).unsqueeze(0)) action int(q.argmax(dim1).item()) # 3. 交互 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 step 1 # 4. 回合结束处理 if done: state, _ env.reset() episode_reward 0.0 # 5. 训练 if len(buffer) WARMUP: s, a, r, s2, d buffer.sample(BATCH) q_taken online(s).gather(1, a) with torch.no_grad(): best_a online(s2).argmax(dim1, keepdimTrue) # Double DQN next_q target(s2).gather(1, best_a) y r GAMMA * (1.0 - d) * next_q loss F.smooth_l1_loss(q_taken, y) optimizer.zero_grad() loss.backward() nn.utils.clip_grad_norm_(online.parameters(), 10.0) # 梯度裁剪兜底 optimizer.step() # 6. 目标网络同步 if step % TARGET_SYNC 0 and len(buffer) WARMUP: target.load_state_dict(online.state_dict())有几处值得单独说明。buffer.push里存的是float(terminated)而不是float(done)。前面提过截断和终止的区别如果环境同时返回这两个标志用于自举截断的应该是纯粹的terminated。如果你的环境只有单一的done那就直接存done但在心里要清楚这引入了一点偏差。梯度裁剪是兜底措施。正常情况下用Huber损失加上奖励缩放梯度不会失控但万一某一批数据特别离谱裁剪能防止一次更新把网络打飞。裁剪阈值设10是个经验值不用太纠结。目标网络的初始参数必须跟在线网络一致。如果两个网络随机初始化成不同参数早期算出来的目标完全是错的训练会走很长一段弯路。7.3 从经典控制到更复杂环境的迁移注意点上面这份代码在低维状态、离散动作的任务上开箱即用。但要迁移到图像输入或者更复杂的场景有几个地方必须改。状态表示。图像输入要做帧堆叠一般是叠最近四帧这样才能从单张静态图里感知到运动方向和速度。堆叠的实现是在环境外面包一层每次返回的是四帧拼起来的张量。另外要做降采样和灰度化把84乘84乘4的输入送进网络前面接三层卷积8乘8步长4、4乘4步长2、3乘3步长1压成特征再接全连接。训练与交互的比例。图像环境下环境交互反而比网络更新快因为网络大了这时候可以适当提高每步的更新次数让样本利用率更高。回放池的内存管理。前面算过一百万条84乘84乘4的uint8数据大概是22GB普通机器扛不住。要么减小容量要么降低分辨率要么用一些共享内存的技巧。我一般会先把容量降到二十万左右跑起来看效果够用就不加了。奖励缩放要做成自适应的。复杂环境的奖励范围往往事先不知道用固定常数除不合适。可以维护一个奖励的滑动均值和标准差做在线归一化。这个做法的代价是会引入一点非平稳性但实践中收益大于风险。最后分享一个我调这类算法时一直用的笨办法先在极简环境上把整条链路跑通再迁移到复杂环境。极简环境跑一次只要几分钟能把数据流、维度对齐、梯度回传这些基础问题暴露出来等换到复杂环境时剩下的问题基本就只是超参和容量了定位效率高得多。我最初跳过这一步直接上图像任务结果在一个维度不匹配的bug上耗了两天回看真是一言难尽。 SEO 优化官网定制响应式建站教育培训建站