ARTICLE DETAIL

资讯详情

深耕网站建设、视觉设计与SEO优化的一线实战洞察。

DQN简单理解:经验回放与目标网络实战CartPole

DQN简单理解:经验回放与目标网络实战CartPole DQN 这三个字母很多人第一次见到是在强化学习的入门清单上紧接着就被一堆名词绕晕——Q 值、贝尔曼方程、自举、经验回放、目标网络书翻了三遍还是不知道代码从哪一行开始写。我当年也是这样理论看得似懂非懂直到把手里的 CartPole 从小车乱晃两秒就倒训到稳稳撑满 500 步才算真正摸到它的骨架。DQNDeep Q-Network简单理解这件事难的从来不是公式本身而是搞明白为什么要这么设计。简单讲DQN 就是用神经网络去替代传统 Q-Learning 里那张查不完的表格让智能体能够在状态空间巨大甚至连续的场景下学会做决策。它能干的事很具体玩雅达利游戏、控制倒立摆、做简单的资源调度、给推荐系统做排序策略的离线评估。适合谁来参考只要你会一点 Python、知道神经网络的训练流程哪怕强化学习零基础这篇内容都能让你从知道 DQN 这个名字走到自己跑出一张收敛曲线。我会把设计动机、核心机制、完整可跑的代码、调参经验和踩坑记录都摊开讲重点是那些教科书上不会写的细节。1. DQN 到底在解决什么难题1.1 传统 Q-Learning 的表格困境Q-Learning 的核心思想朴素得像个记账本维护一张表行是状态列是动作格子里填写在某个状态下做某个动作未来能拿到多少总回报这个数就是Q 值。智能体每次决策就查表挑当前状态下 Q 值最大的动作执行然后用实际拿到的奖励去修正这个格子。数学上是可证明收敛的条件也清楚每个状态动作对要被访问足够多次。问题出在状态这两个字上。倒立摆的状态是四个连续实数小车位置、速度、杆子角度、角速度组合起来是无穷多雅达利游戏的一帧画面是 210×160 的彩色像素就算压成灰度再降采样状态数也是天文数字。这种情况下你连表都建不起来更别提填满。我试过用离散化硬做四个维度每维切 20 份就是 16 万个格子加上动作维度接近百万训练初期每个格子平均访问不到一次Q 值全是初始噪声智能体的行为基本等同于随机乱动。这就是表格法的天花板它不是不好是规模撑不住。1.2 把查表换成拟合的直觉既然表填不满那就换个思路不再精确记录每个状态的 Q 值而是学一个函数输入状态、输出各个动作的 Q 值让它在见过的状态上尽量准在没见过的状态上靠泛化给出合理估计。参数化的方式可以有很多线性函数、决策树都行但神经网络在拟合高维非线性关系上的表现明显更强于是Q(s, a) ≈ Q(s, a; θ)就成了自然选择θ 是网络权重。这个转变带来的好处是直观的相邻或相似的状态会得到相近的输出智能体在没走过的路上也能做出大致合理的判断。代价也很大——表格法那种每个格子独立更新、理论上一定收敛的保证没了。神经网络是全局耦合的你更新一个状态的估计会顺带扰动其他所有状态的输出更麻烦的是训练数据和训练目标都在随策略变化而漂移整个优化过程变成了追着自己尾巴跑。所以 DQN 真正的工作量不在用神经网络这一步而在怎么把训练过程稳住。1.3 两个补丁经验回放和目标网络2013 年那篇用深度网络玩雅达利的论文关键贡献其实是两个工程上的补丁而不是网络结构本身。第一个是经验回放Experience Replay把智能体与环境交互产生的四元组(状态, 动作, 奖励, 下一状态)全部存进一个缓冲区训练时从里面随机抽一批出来。第二个是目标网络Target Network额外复制一份结构相同的网络专门用来算训练目标隔一段时间才从主网络同步一次参数。这两个设计听起来平平无奇但去掉任何一个DQN 在雅达利上就训练不起来这不是夸张是原论文里做过的消融实验。理解了它们为什么必需你才算真正理解了 DQN。所以接下来我不急着贴代码先把这两块讲透因为后面所有调参动作本质上都是在和这两个机制打交道。2. 核心机制拆解为什么这两块缺一不可2.1 经验回放打破样本之间的时间关联神经网络训练有个默认假设——样本是独立同分布的。可强化学习产生的数据完全不满足这一点第 100 步的状态是从第 99 步演变来的两者高度相关一整个回合的数据都来自同一条轨迹分布极度集中。如果按产生顺序喂给网络你会遇到一个很典型的现象网络刚在这批样本上拟合好策略一更新下一批样本的分布就变了梯度方向来回摆动损失曲线像心电图。经验回放把这个问题缓解了两个层面。一是打散相关性从几万条历史记录里随机抽样一个批次内的样本来自不同时间、不同轨迹近似满足独立同分布。二是提高样本利用率一条珍贵的经验可以被反复采样多次而不是用完就扔。对样本效率本来就低的强化学习来说这点非常值钱。实操里有个细节值得注意回放池的容量是个真实存在的权衡。太小比如只有几千条池子里全是近期数据相关性又回来了太大比如上百万条里面塞满了早期随机策略产生的垃圾经验采样时经常抽到过时数据学习信号的新鲜度被稀释。我在 CartPole 上做过对比容量 5 万和 10 万的差别不大但缩到 1000 就明显不稳。一般从 10 万起步任务越复杂适度加大。注意回放池存的是状态而不是整局游戏别顺手把整个回合存进去。另外终止状态也要存它是让网络学会判断这一步之后没有未来了的唯一依据。2.2 目标网络给移动的靶子钉个钉子DQN 的更新目标长这样目标 即时奖励 γ × 下一状态的最大 Q 值。要命的地方在于这个下一状态的最大 Q 值是由网络自己算出来的。也就是说你在用网络当前的输出当标签去训练网络本身一边调整参数一边标签也跟着变这叫自举Bootstrapping。打个比方你在打靶靶子会随着你每一次开枪而移动。梯度下降本来就需要一个相对稳定的目标才能收敛现在目标自己也在跑而且跑的方向还和参数更新的方向耦合非常容易出现正反馈式的发散——Q 值估计越来越大损失爆炸策略崩溃。目标网络的解决办法很直接拿一份冻结参数的副本来算标签主网络照着这个固定的靶子练每隔 C 步比如 200 步或 1000 步才把主网络的权重复制过去靶子挪一次位置。这样在两个同步点之间训练目标就是恒定的回归问题退化成普通的监督学习稳定性一下子就有了。除了硬同步每 C 步全量复制还有一种更平滑的做法叫软更新Polyak Averaging每一步都做θ_target ← τθ (1-τ)θ_targetτ 通常取 0.001 到 0.01。硬同步实现简单、行为可预测软更新过渡更顺滑在连续控制和部分游戏任务上表现更好。我一般先用硬同步跑通确认流程没问题再换软更新对比。2.3 损失函数与参数更新到底在算什么把上面两块拼起来DQN 的损失函数就顺理成章了L(θ) E[ ( r γ · max_a Q(s, a; θ⁻) − Q(s, a; θ) )² ]其中 θ 是主网络参数θ⁻ 是目标网络参数r 是即时奖励γ 是折扣因子。括号里前半部分是目标值后半部分是当前估计值两者的差叫TD 误差损失就是 TD 误差的平方期望。整个网络被当成一个回归器来训输入是状态输出是每个动作的 Q 值只有被执行的那个动作对应的输出会参与损失计算其他输出的梯度为零。为什么只用被选中的动作因为其他动作的真实回报你并不知道硬拿估计值当标签就是自己骗自己。这一点和分类任务很不一样初学者常常在这里写错把整行 Q 值都拿去算损失结果训练完全跑偏。另外两个工程细节值得单独说。一是奖励裁剪原论文把所有正奖励截成 1、负奖励截成 -1因为不同游戏的分数量级差了几个数量级不裁剪的话梯度尺度极不稳定。二是Huber 损失也就是smooth_l1_loss它在误差小的时候等价于平方损失误差大的时候退化成绝对值损失对异常大的 TD 误差没那么敏感能有效防止个别样本把网络带崩。我在 CartPole 上实测换成 Huber 之后训练曲线的毛刺明显变少。3. 从零搭一个能跑通 CartPole 的 DQN3.1 环境准备与依赖版本先说明一下CartPole 是最适合入门的任务状态只有四维动作只有两个向左推、向右推奖励是每坚持一步 1杆子倒下或车子跑出边界就结束。目标是把平均回报拉到 475 以上。它足够简单能让你在几分钟内看到结果又足够真实包含了连续状态、延迟奖励这些强化学习的核心难点。依赖方面需要三样东西pip install torch numpy gymnasium这里有个版本坑必须提前说清楚。老版本的gym在env.step()里返回四个值(next_state, reward, done, info)新版的gymnasium返回五个值(next_state, reward, terminated, truncated, info)把任务完成和超时截断拆开了。网上大量教程还是老写法你照着抄会直接报ValueError这不是你的问题。我的建议是统一用gymnasium然后用done terminated or truncated把两者合起来语义上等同于老版本的 done。import gymnasium as gym import torch import torch.nn as nn import torch.optim as optim import numpy as np import random from collections import deque SEED 42 random.seed(SEED) np.random.seed(SEED) torch.manual_seed(SEED)固定随机种子这件事很多人会忽略结果每次跑出来的曲线都不一样调参时根本分不清是参数起作用了还是运气好。我在排查一个训练不稳定的问题时光是加了种子就发现波动来源其实是环境初始化的随机性不是算法本身。3.2 网络结构简单但别踩这几个坑网络本身没什么花哨的两层 128 维的全连接加 ReLU输出维度等于动作数。输入是四维状态向量不需要归一化得太讲究CartPole 的四个量本身量纲就差得多但网络能自己适应。class QNet(nn.Module): def __init__(self, obs_dim, act_dim, hidden128): super().__init__() self.net nn.Sequential( nn.Linear(obs_dim, hidden), nn.ReLU(), nn.Linear(hidden, hidden), nn.ReLU(), nn.Linear(hidden, act_dim) ) def forward(self, x): return self.net(x)几个容易出问题的地方。第一最后一层不要加激活函数。Q 值可以是负数可以是任意实数加个 ReLU 会把所有负的估计砍成 0网络学不到这个动作很糟这种信息。第二输出维度是动作数而不是 1。有些实现写成输出单个标量再拼动作输入那也能跑但效率低每次前向都要算 n 次。第三隐藏层别太小。用 32 维隐藏层我也跑通过 CartPole但收敛慢不少128 是性价比比较高的选择。如果要处理图像输入前面要接卷积层通常是三层卷积加一层全连接具体结构可以参考原论文。但入门阶段我强烈建议别从图像开始先把低维状态跑通否则你会同时面对算法没调对和网络没调对两个变量排查起来非常痛苦。3.3 回放缓冲区的实现细节回放池用deque加maxlen是最省事的写法超过容量自动丢掉最老的记录不用手写环形数组。class ReplayBuffer: def __init__(self, capacity100000): self.buffer deque(maxlencapacity) def push(self, s, a, r, s2, done): self.buffer.append((s, a, r, s2, done)) def sample(self, batch_size): batch random.sample(self.buffer, batch_size) s, a, r, s2, done zip(*batch) return (np.array(s, dtypenp.float32), np.array(a, dtypenp.int64), np.array(r, dtypenp.float32), np.array(s2, dtypenp.float32), np.array(done, dtypenp.float32)) def __len__(self): return len(self.buffer)这里我特意用了random.sample而不是np.random.choice。两者都能随机抽样但random.sample是无放回抽样一个批次内不会出现重复样本对梯度的贡献更均匀np.random.choice默认有放回偶尔会抽到同一条数据多次虽然影响不大但在小容量缓冲区上会放大偏差。另外注意所有的数据类型转换都写死了float32因为 PyTorch 默认就是 float32用 float64 会在计算损失时触发类型不匹配的报错。3.4 智能体动作选择与一次完整的更新把网络、优化器、回放池、超参数全塞进一个类里主循环会清爽很多。class DQNAgent: def __init__(self, obs_dim, act_dim, lr1e-3, gamma0.99, batch_size64, buffer_size100000, eps_start1.0, eps_end0.02, eps_decay800, target_update200, double_dqnFalse): self.q QNet(obs_dim, act_dim) self.q_target QNet(obs_dim, act_dim) self.q_target.load_state_dict(self.q.state_dict()) self.q_target.eval() self.optimizer optim.Adam(self.q.parameters(), lrlr) self.buffer ReplayBuffer(buffer_size) self.gamma gamma self.batch_size batch_size self.act_dim act_dim self.eps_start eps_start self.eps_end eps_end self.eps_decay eps_decay self.target_update target_update self.double_dqn double_dqn self.step_count 0 def act(self, state, greedyFalse): if greedy: eps 0.0 else: eps self.eps_end (self.eps_start - self.eps_end) * \ np.exp(-1.0 * self.step_count / self.eps_decay) self.step_count 1 if random.random() eps: return random.randrange(self.act_dim) with torch.no_grad(): s torch.as_tensor(state, dtypetorch.float32).unsqueeze(0) return int(self.q(s).argmax(dim1).item()) def update(self): if len(self.buffer) self.batch_size: return None s, a, r, s2, done self.buffer.sample(self.batch_size) s torch.as_tensor(s) a torch.as_tensor(a) r torch.as_tensor(r) s2 torch.as_tensor(s2) done torch.as_tensor(done) q_values self.q(s).gather(1, a.unsqueeze(1)).squeeze(1) with torch.no_grad(): if self.double_dqn: next_actions self.q(s2).argmax(dim1, keepdimTrue) next_q self.q_target(s2).gather(1, next_actions).squeeze(1) else: next_q self.q_target(s2).max(dim1)[0] target r self.gamma * next_q * (1.0 - done) loss nn.functional.smooth_l1_loss(q_values, target) self.optimizer.zero_grad() loss.backward() nn.utils.clip_grad_norm_(self.q.parameters(), max_norm10.0) self.optimizer.step() return loss.item() def sync_target(self): self.q_target.load_state_dict(self.q.state_dict())gather那一行是这段代码里最值得停下来看的地方。self.q(s)输出形状是(batch, act_dim)a.unsqueeze(1)形状是(batch, 1)gather(1, ...)按列索引取出每个样本实际执行动作对应的 Q 值结果是(batch, 1)再squeeze成(batch,)。如果不做这一步直接把整行拿去做损失梯度会灌到所有动作上训练会变得非常迟钝甚至学不会——这是新手最常犯的错误之一。(1.0 - done)这一项也很关键。终止状态的下一状态其实不存在它的未来回报应该严格为零乘上这个掩码就等于把自举项砍掉了。不写这一项网络会认为游戏结束之后还能继续拿奖励Q 值会被系统性高估表现出来就是训练后期策略变得保守或者干脆不动。3.5 训练主循环与超参数def train(): env gym.make(CartPole-v1) obs_dim env.observation_space.shape[0] act_dim env.action_space.n agent DQNAgent(obs_dim, act_dim, lr1e-3, gamma0.99, batch_size64, buffer_size100000, eps_start1.0, eps_end0.02, eps_decay800, target_update200, double_dqnTrue) rewards [] for episode in range(600): state, _ env.reset(seedepisode) total 0.0 while True: action agent.act(state) next_state, reward, terminated, truncated, _ env.step(action) done terminated or truncated agent.buffer.push(state, action, reward, next_state, float(done)) agent.update() state next_state total reward if done: break rewards.append(total) if episode % agent.target_update 0: agent.sync_target() if episode % 10 0: avg np.mean(rewards[-20:]) print(fep {episode:3d} reward {total:6.1f} avg20 {avg:6.1f}) if len(rewards) 20 and np.mean(rewards[-20:]) 475: print(fconverged at episode {episode}) break return rewards跑起来大概是这样前 40 到 80 个回合基本都在 20 到 30 分之间因为 ε 还是 0.5 以上智能体大部分时候在随机乱动回放池里全是垃圾数据。100 回合之后开始有起色能撑到 80、150 步。200 回合左右曲线上蹿到 300 以上之后偶尔会掉回 150这是正常的波动。300 到 400 回合之间通常能稳定在 450 以上。整个训练在普通笔记本上大概三到八分钟取决于 CPU 性能。这里有个实操细节env.reset(seedepisode)用的是每回合不同的种子保证训练过程中的初始状态足够多样。如果不设种子环境本身也会随机初始化但固定下来便于复现对照。另外target_update我同时在两个地方用到了——既当同步间隔又当同步的回合条件这是简化写法更严格的做法是按训练步数计数每 200 次update()同步一次因为一个回合可能有几百步按回合同步会导致同步频率随性能变化而漂移。4. Double DQN一行改动带来的稳定性提升4.1 过估计是怎么来的Q-Learning 的目标里有个max操作这个 max 是所有问题的源头。假设某个状态下三个动作的真实 Q 值分别是 10、10、10由于函数逼近误差网络给出的估计可能是 11.2、9.8、10.5。取最大值得到 11.2比真实的 10 高出一截。这种高估不是偶然的它是系统性的max 操作会优先选中被高估的那个估计值估计噪声越大高估越严重。在表格法里这个问题也存在但每个格子的估计是独立的误差不会互相传染。到了神经网络这里过估计会通过参数共享扩散到相邻状态再通过自举传回自身形成累积放大。表现出来就是 Q 值越来越大智能体对某些动作过度自信策略变得激进训练后期容易崩。4.2 解耦选择和评估Double DQN 的思路非常优雅既然过估计来自用同一个网络既选动作又算价值那就把这两件事拆给两个网络做。用主网络选出下一状态里 Q 值最大的动作再用目标网络去评估这个动作的价值。因为目标网络的参数是滞后的它对噪声的响应不同步选出来的动作和评估出来的数值就不容易一起偏。代码上只改了这几行我已经在前面的update()里用double_dqn开关包起来了# 普通 DQN next_q self.q_target(s2).max(dim1)[0] # Double DQN next_actions self.q(s2).argmax(dim1, keepdimTrue) next_q self.q_target(s2).gather(1, next_actions).squeeze(1)就这几行的差别。我在 CartPole 上对比跑过十组普通 DQN 的平均收敛回合是 380 左右Double DQN 是 320 左右方差也明显更小。在更复杂的任务上差距会拉得更大。这个改动的性价比高到没道理只要你在写 DQN就应该默认带上它。4.3 其他值得知道的变体除了 Double DQN还有几个改进方向经常被提到。Dueling DQN把网络输出拆成两部分一个只和状态有关的状态价值 V(s)一个和状态动作都有关的优势 A(s,a)最后合成Q V (A - A的均值)。它的好处是在很多状态下不同动作的价值差不多此时网络只需要学好 VA 部分可以更专注地区分真正有差异的动作样本效率更高。优先经验回放Prioritized Experience Replay则是针对回放池里所有样本被抽到的概率相同这个假设。TD 误差大的样本说明网络在这里预测得不准更值得学于是按 TD 误差的大小给样本加权采样概率。效果通常不错但实现上要注意重要性采样权重的修正否则会引入偏差而且维护优先级的堆结构比较麻烦。N-step Return是另一个低成本的改动不再只用一步的即时奖励而是累积 n 步再自举目标 r1 γr2 ... γⁿ⁻¹rn γⁿ max Q(s_n)。n 取 3 到 5 通常有明显提升因为它减小了自举误差的影响范围同时加快了奖励信息的传播速度。代价是回放池里要存 n 步的轨迹而不是单步实现复杂度上升一点。我的建议是学习顺序上按这个优先级来先把基础 DQN 跑通加上 Double DQN 稳定住然后再考虑 N-step最后才碰优先经验回放。一上来就堆所有技巧出了问题你根本不知道是哪个环节的锅。5. 常见问题与排查技巧实录5.1 训练不收敛按这个顺序查排查问题的原则是从简单的、影响大的地方开始别一上来就怀疑算法。我整理的顺序是第一确认数据流没问题。打印几个样本看看状态、动作、奖励、下一状态、done 是不是都合理。我遇到过一次done存成了 Python 的True/False布尔值转成 tensor 后类型不对自举掩码完全失效查了两个小时。还有一次是奖励忘了累加、next_state忘了赋值这种低级错误在调试期非常常见。第二确认 ε 的衰减速度和训练总量匹配。如果 ε 衰减步数是 500但总训练才几百回合那探索早就停了智能体在次优策略上锁死。反过来ε 衰减到 0.02 的速度太慢前 200 回合都在乱动也会让人觉得没进展。我一般让 ε 衰减步数大致等于预期的总训练步数的 1/10 到 1/5。第三检查目标网络的同步频率。同步太频繁比如每 10 步等于没有目标网络同步太稀疏比如每 5000 步目标严重滞后学习速度极慢。200 到 1000 步是比较常见的区间。第四看损失曲线和 Q 值量级。损失震荡是正常的但如果 Q 值涨到几百上千说明过估计失控检查有没有开 Double DQN、有没有做梯度裁剪、γ 是不是设得过高0.995 以上在奖励稀疏的任务上很危险。如果损失一直是 0那说明梯度根本没传进去检查zero_grad和backward的调用顺序。5.2 超参数速查表超参数常用取值作用调整方向学习率 lr1e-4 ~ 1e-3控制参数更新幅度不收敛先降到 1e-4折扣因子 γ0.99可到 0.999决定看多远奖励稀疏且长程才调高回放池容量1e5 ~ 1e6决定经验多样性太小不稳太大稀释新数据批大小32 ~ 64梯度估计的噪声水平越大越稳但越慢目标网络同步间隔200 ~ 1000 步目标稳定性越小越快但越不稳ε 起始值1.0初始探索强度一般不用改ε 结束值0.02 ~ 0.05后期保留的探索量太小容易卡死ε 衰减步数总步数的 1/10 ~ 1/5探索退火速度进展慢就延长梯度裁剪阈值10防止梯度爆炸有爆炸迹象就降到 1这张表我贴在显示器边上用了很久实际调参时九成以上的问题都能在里面找到对应的旋钮。5.3 常见问题速查表现象可能原因处理办法奖励曲线完全平躺学习率过大、ε 衰减过快、回放池未积满就训练降 lr、延长衰减、设最小样本数训练中期突然崩塌过估计累积、梯度爆炸开 Double DQN、加梯度裁剪损失下降但奖励不涨目标被高估学的是错的 Q 值检查 done 掩码、开 Double DQN智能体总是选同一个动作探索不足或 Q 值过早收敛提高 ε 结束值、加大网络初始化扰动前几回合表现尚可后来越来越差灾难性遗忘回放池数据分布漂移增大回放池、适当降低学习率曲线剧烈震荡批太小、同步太频繁批大小调到 64 以上同步间隔拉长训练到一半 loss 变成 nan梯度爆炸或奖励量级过大奖励裁剪、梯度裁剪、换 Huber 损失同一份代码每次结果差很多随机种子未固定、环境初始化随机固定种子多跑几组取平均5.4 几个我踩过的坑第一个坑是在回放池还没填够的时候就着急更新。我在update()里加了个len(buffer) batch_size的判断但早期样本太少时即使能采样分布也非常偏。更稳妥的做法是设一个learning_starts比如攒够 1000 条再开始训练前面纯做数据收集。这个改动看起来会让训练变慢实际上收敛速度反而更快因为早期网络不会被垃圾梯度带偏。第二个坑是把评估和训练混在一起。想验证策略好不好就得用贪心策略ε0单独跑几回合而不是看训练回合的分数。训练时的分数带着随机探索的噪声波动很大用它判断收敛会误导你。我通常每 20 个回合评估 5 次取平均虽然多花一点时间但对趋势的判断准确得多。第三个坑是盲目照搬论文超参数。原论文那套参数是针对雅达利游戏调的学习率、回放池容量、更新频率都是为图像输入和大规模训练设计的。直接搬到 CartPole 这种小任务上回放池 100 万条意味着智能体训练几千回合池子都填不满学习信号极其稀疏。参数要跟着任务规模走小任务就该用小配置。第四个坑是网络太深。一开始我总觉得深度强化学习嘛网络得深一点才对于是堆了五层全连接。结果 CartPole 上跑了几百回合毫无起色换成两层 128 维之后二十分钟就收敛了。小任务的 Q 函数本身很简单网络容量过剩反而导致过拟合噪声、梯度传播变差。6. 我个人的一些体会DQN 这套东西我觉得最有价值的部分不是它多强而是它把强化学习中几个根本性的矛盾暴露得非常清楚数据相关性与独立同分布假设的矛盾经验回放、目标漂移与稳定优化的矛盾目标网络、探索与利用的矛盾ε 贪心、估计偏差与方差的矛盾Double DQN、N-step。你把这四对矛盾想明白了再看 DDPG、SAC、TD3 这些后续算法会发现它们无非是在同一组矛盾上换不同的解法。如果让我给刚入门的人一条建议那就是别急着上复杂环境。CartPole 收敛一次比在雅达利上跑十次没结果的收获大得多。你可以在 CartPole 上做的实验非常多把 Double DQN 关掉对比曲线、把回放池调到 1000 看崩不崩、把 ε 固定在 0.5 看能不能学会、把目标网络同步间隔改成 1 看会发生什么。每一个消融实验都会让你对某个机制的理解加深一层而且单次实验只要几分钟迭代成本极低。等你把这些变量的脾气都摸清了再换 LunarLander 或者更复杂的任务心里就有底了。最后分享一个调试小技巧在训练循环里加一行代码定期统计最近一个批次里 Q 值的平均值和最大值把这条曲线和奖励曲线画在一起看。正常训练时 Q 值和奖励应该是同向缓慢上升的如果 Q 值涨得飞快而奖励没动基本可以确定是过估计或者 done 掩码写错了。这个观察指标比单看损失有用得多因为损失在强化学习里的可解释性其实很弱。
返回列表