DQN实战:LunarLander登月器降落完整教程与调参经验
直接进入正题。这篇记录我自己跑 DQN 登月器降落LunarLander的完整过程和一些思考包括环境理解、算法拆解、代码搭建、以及训练过程中踩过的坑。适合已经跑通 CartPole 之类入门 demo、想进阶到稍复杂控制场景的强化学习初学者。我不是理论派写的东西基本都来自实际训练观察能帮你少走很多弯路。1. 先吃透登月器环境状态、动作与奖励背后的设计意图不少人在这一步偷懒直接gym.make(LunarLander-v2)就开跑代码结果训练一天都学不会。问题往往不在网络结构而在对任务本身一无所知。环境是强化学习的“另一半代码”它决定了算法要解的问题边界。不把环境吃透后面所有曲线异常你都不知道从哪查起。1.1 状态空间这 8 个数字到底在描述什么LunarLander 的状态向量是 8 维浮点数含义分别为x、y 坐标飞行器中心位置相对着陆点的水平、垂直位置vx、vy水平与垂直速度角度飞行器机体的俯仰角角速度机体转动的角速度两个腿的接触标志左腿是否触地、右腿是否触地第八个维度记不牢没问题但一定要知道这是“连续状态空间”每个维度的数值是归一化后的范围。比如 x 通常在 -1 到 1 之间y 在 0 到 1.5 左右。我强烈建议第一次训练前先把环境状态打印出来看一眼哪怕是像下面这样写一小段import gymnasium as gym env gym.make(LunarLander-v2, render_modergb_array) obs, _ env.reset() print(obs.shape) # (8,) print(obs) # 看一下每个维度的大致数值范围为什么要这么做因为你有机会直观感受什么是“归一化后看轨迹”比如位置坐标变化快速度也在变但角度和角速度变化相对平缓。这种尺度差异在后面设计网络输入、选择激活函数时都会有微妙影响。另一个重点是环境状态是足够可感知的——也就是说理想情况下一个有经验的玩家只凭状态就能判断出该喷哪个推进器。这让 Q 网络的拟合目标变得合理不会出现“状态信息不足导致根本学不出来”的情况。1.2 四个离散动作喷口选择的物理次序LunarLander 的动作空间是离散四值0 是什么都不做、1 是向左喷、2 是主喷向下抬升了飞行器、3 是向右喷。从工程角度看直接使用离散动作的好处是 DQN 天然擅长输出层做简单的 argmax 选动作就行不需要像连续控制问题那样引入复杂的 actor-critic 架构。但从训练策略上看动作次序有讲究如果某个时刻状态显示飞行器向右漂移且向右倾斜你自然想让左喷口工作以产生反向力矩。这类规则没法手写成一个完备的策略但强化学习能学出来。这也就解释了为什么经验样本里“左喷”和“右喷”出现的频率会直接决定模型在不同阶段的表现——如果 epsilon 衰减太快动作探索不充分模型很可能永远学不到“如何用反向喷口修正姿态”导致整条训练曲线一直上不去。1.3 奖励机制这个环境的难点不在稀疏而在“组合”LunarLander 不是稀疏奖励环境它每一步窗口的奖励由多部分组成接近目标区给一点正奖励远离则给负垂直速度过大有惩罚水平速度大也惩罚主喷口点火会给负奖励作为燃料代价触地时根据姿态和速度给大额正负奖励。可以简单理解为奖励信号既要引导飞行器向 flag 区靠近又要约束飞行姿态、速度、耗油和着陆冲击。换句话说环境相当于在连续几个小时内同时教你“接近目标”和“干净落地”。CartPole 的奖励只是单维度的“保持直立”而 LunarLander 需要智能体理解多个目标之间的取舍。所以你会发现前期随机探索阶段智能体往往会在“向中心飞”上表现不错但一到姿态矫正或着陆减速就崩溃。我在第一次调参时犯过一个低级错误给 y 坐标的接近奖励设了一个很大的权重当然是改奖励函数结果智能体很快就学会了飞到目标高度后悬停不动——奖励到手动作消失变成了一架“不敢落地的飞机”。这提醒我在理解环境和奖励结构之前随意调权重是灾难性的。后续我会对这一点再展开。2. DQN 的三个核心机制它们分别解决了什么问题DQN 已经不新了但在 LunarLander 这类问题中它依然是一个很优质的教学范本。与其背公式不如理解它为什么长这个样子。搞清楚这三个机制你才可能在训练有问题的时候做出有针对性的判断。2.1 Q 网络从“查表”到“函数拟合”传统 Q-learning 里Q 表直接存放每个状态动作对的预估价值。但 LunarLander 的状态是连续的状态数量是天文数字根本没法用表格存。DQN 的核心就是用神经网络去拟合 Q 函数输入是状态输出是四个动作各自的 Q 值。这里的输出不是概率是“价值估计”。训练时Q 网络要对当前策略下的“折扣累积回报”做回归。gamma越大智能体越看重长远gamma越小越短视。LunarLander 一个 episode 大概几百步我最终用 0.99因为要兼顾早期探索和最终着陆目标。这时候一个容易混淆的点是如果输出层不加激活函数直接输出原始数值初始阶段 Q 值的规模可能很大比如几百甚至上千。这会让梯度更新非常不稳定后面的损失直接爆炸。所以我的做法是在网络最后一层不加任何激活函数但用 Huber loss 来减小异常样本的影响同时对 Q 值的量级做约束。2.2 经验回放打破相邻样本的相关性也是效率之源在线更新时同一回合相邻帧之间状态极度相似如果不做处理网络会被连续“重复灌输”同一方向的信息形成记忆震荡甚至灾难性遗忘。经验回放的做法是把所有(s, a, r, s)四元组存进一个固定容量的回放缓冲区训练时从中随机抽一批样本再更新。这个做法有两点价值打破时间相关性。随机抽取让相邻样本不被连续使用梯度更新更稳定。样本复用。同一份数据可以在不同训练阶段被反复利用夸张地说相当于你把昂贵的高质量经验反复“咀嚼”。缓冲区大小的选择很有意思。我试过从 10k 到 500k 的不同容量区间最终稳定在 100k 附近。太小的缓冲区会让训练经常看到旧策略的数据模型容易在同一个策略附近反复波动太大的缓冲区又会因为旧样本比例过高导致新策略的经验迟迟无法通过采样显现出来。常规建议是先设一个中等大小比如 50k 和 200k 之间再结合曲线微调。提示如果你发现训练前段非常稳定但中后段开始阶梯式下跌可以优先怀疑缓冲区里旧样本比例是否过高——我亲测这是最容易被忽略的原因之一。2.3 目标网络为什么不能“自己追自己”DQN 有一个 TD 更新目标那么问题来了这个公式里如果更新 Q 网络的同时也更新了用于计算目标值的目标网络那么训练目标本身每步都在漂移。直观类比你在跑步机上追自己的影子影子也在跟着动永远差一步。DQN 的做法是引入一个独立的、参数周期性同步的目标网络平时主网络继续更新参数目标网络用旧参数给 TD 目标站稳脚跟每隔多少步比如每 500 步把主网络参数直接复制给目标网络。这样训练目标在一段时间内是固定的更新曲线更稳定。后来有 Double DQN 的概念把动作选择和动作评估分开到两个网络进一步抑制过高估计问题。我从实际经验说如果在 LunarLander 上已经有标准 DQN 能跑到 250 分左右Double DQN 的后半程稳定性通常会稍好一些但从“学会降落”这个目标来看标准 DQN 配合好超参已经足够。我倾向于先把标准 DQN 跑通再谈其他变体否则一旦曲线异常你会多一个变量需要排查。3. 训练代码搭建时的关键决策选型、超参与主循环这一节我直接讲“我当时是怎么搭的”以及为什么这么搭。代码本身不复杂重点在于每个决策都要有理由。3.1 网络结构不是越深越好我用的网络结构是三层全连接import torch import torch.nn as nn class DQN(nn.Module): def __init__(self, state_dim8, action_dim4, hidden_dim128): 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)为什么是两层 128 的隐藏层而不是像 ResNet 一样堆很深因为任务本身的状态只有 8 维函数复杂度有限太深的网络只会增加过拟合风险和训练震荡不会带来精度提升。我试过 256 和 512训练速度变慢奖励曲线并没有本质区别试过单层 64则在着陆后期的精细控制上明显吃力。最终折中到了 128。激活函数选择 ReLU 是因为梯度传播好、计算快。注意最后一层不要用 ReLU 或者 tanh 限幅否则会限制 Q 值的取值范围影响价值估计能力。3.2 超参数设置与它们的影响力下面是最终使用的超参数清单超参数取值作用与潜在风险学习率lr1e-3太高容易发散太低收敛慢批大小batch_size64反映采样稳定性越大梯度越稳但更新慢折扣因子gamma0.99影响长期决策设置过低会短视经验池容量buffer_size100000过大旧样本占比高过小不稳定ε 初始值1.0初始充分探索ε 最低值0.01保证后期仍有少量探索ε 衰减0.995探索衰减太慢会让后期策略不稳定太快则可能学不全目标网络同步间隔500 步太频繁等同于不用目标网络太慢目标长期失效优化器Adam对初始学习率敏感适合多数控制问题学习率是我训练中最常调的一个参数。如果设成 1e-2训练到中后段时 Q 值整体规模会发生剧烈跳跃奖励曲线会像心电图一样。降回 1e-3 后训练立刻变得平滑。乐观地说在 LunarLander 这种任务上1e-3 是“初学者也能猜中的数”。ε 的衰减策略特别值得注意。我用的是每一轮 episode 结束后乘 0.995而不是每步衰减。步级衰减会让前期探索时间太短因为同一回合内探索率会迅速掉到很低。回合级衰减更符合“让 agent 在一个环境里充分感知”的逻辑。3.3 训练主循环中容易被忽视的细节基本流程是重置环境 → 根据当前 ε 选择贪婪动作或随机动作 → 与环境交互得到下一状态和奖励 → 四元组存入 buffer → 从 buffer 采样更新主网络 → 定期同步目标网络 → 更新 ε。一个很重要的细节是存进 buffer 的四元组必须是“早一步的状态、动作、奖励、之后的状态”而不是“带叠加帧的连续 4 帧”之类——那是 Atari 类视觉输入才需要做的事。LunarLander 的状态已经足够完整不需要额外做帧叠加。叠加帧在这个环境里纯属无用功还会增加状态维度拖慢训练。另外一个细节是关于 episode 终止时的处理如果doneTrue下一状态其实是一个“终态”那么 TD 目标里不需要包含下一状态的价值部分直接就是奖励本身。写训练循环的时候要特别注意这一点否则终态的价值会被错误地计算成r gamma * Q(s, a)导致价值估计偏高。我写一个简化但完整的训练循环示意def train_one_episode(env, q_net, target_net, buffer, optimizer, \ batch_size, gamma, epsilon): state, _ env.reset() done False total_reward 0 while not done: action choose_action(q_net, state, epsilon) next_state, reward, terminated, truncated, _ env.step(action) done terminated or truncated buffer.add(state, action, reward, next_state, done) if len(buffer) batch_size: batch buffer.sample(batch_size) update_q_network(batch, q_net, target_net, optimizer, gamma) state next_state total_reward reward if done: break return total_reward注意truncated和terminated的区别terminated表示飞行器真的着陆或坠毁truncated表示达到了步数上限。这两种情况下的 TD 计算里next_state的价值都应该为 0或者说不能从截断状态继续往前估计价值。代码里用done terminated or truncated统一处理。不过我见过的很多教程会把这两者混在一起在 LunarLander 上因为步数上限通常不会触发所以影响不大但养成区分习惯对以后处理其他环境很有帮助。4. 训练异常排查我实际遇到的四种“怪现象”及对策本部分是我在这类项目上最有价值的一段不是理论是实测结果。每个现象都对应一个真实存在的问题排查顺序基本也是按照复杂度从低到高排列。4.1 奖励曲线长时间是一条“平线”训练了 500 个 episode平均奖励一直停留在 -100 左右像一块钢板。这种情况通常出现在训练最早期大概率是经验池里的样本太稀疏模型在还没有见过足够多“成功”或“接近成功”的样本前学出来的策略和随机策略差别不大奖励曲线自然平坦。对策不是调大学习率而是耐心。只要奖励曲线没有持续下降或出现散布极广的异常跳变平线在前期是正常的。我是在 buffer 数量大于 batch size 之后才开始正式更新模型相当于强制先 warm-up 一段时间。这样训练的初始损失函数更加稳健不会因为前几步样本太少而出现剧烈的梯度震荡。另一种是“中后期平线”比如训练到 800 个 episode奖励一直在 -50 附近波动。这往往意味着探索不足或能力不足——agent 已经学会了一些基本操控但从来没有触发过“成功着陆”这类高奖励场景所以它永远不知道怎样到达终点。这时候我会检查ε 是否衰减得太快是不是在随机阶段就过早进入了纯贪心如果是就把 ε 衰减速度调慢到 0.998 或 0.999给它更多探索去发现成功轨迹。4.2 前期成绩上升很快中期突然“归零式回落”这是我最常被问到的问题之一开跑 100 个 episode 时平均奖励已经跑到 150 以上但到 300 个 episode 时奖励曲线突然跌到 -100甚至开始发散。看起来“训练崩了”。从我的经验看这种“崩了”多数不是算法逻辑错误而是学习率太大导致 Q 值爆炸。前期模型学到一些有效策略但估计的 Q 值整体偏大目标网络的同步间隔一长新阶段的更新目标就会依赖已经过时的估计反复迭代后价值网络发散表现在曲线上就是断崖式下跌。对策有三条路降低学习率、将 loss 换成 Huber loss、或者提高目标网络的同步频率。实操中我先确认主网络没有出现梯度爆炸通过打印梯度的范数再把 Huber loss 的阈值设为 1.0并将目标同步间隔从 1000 降到 500。仅这两项训练曲线就从大起大落变成了稳定爬升。提示当你在中期看到奖励从正数骤降为负数时千万别急着认为是“策略遗忘”去加强 epsilon 探索。先画一张 Q 值的规模折线图如果 Q 值中位数是持续放大状态优先处理梯度不稳定。4.3 智能体只会在空中抖动完全不知道对准目标这种病状特别像强化学习里的“局部最优”agent 学会了悬停和修正姿态但永远不会飞向目标区。深层原因无外乎两点第一奖励设计中对“接近目标”的引导不够导致智能体认为保持原地漂浮也是不错的方案第二探索阶段根本没有出现过成功接近目标的有利样本它只是没机会学到而已。我在处理这类问题时会先做一件事观察随机策略的轨迹分布。如果随机策略下 agent 基本都落在地图边缘那么它的早期经验库中“接近中心”的样本本身就很少模型很难从这些低质量样本中学到方向感。更直接的做法是调整探索策略比如前期用较高的 ε后期再把 ε 衰减到位。我曾在训练初期用 1.0 的 ε 一直持续到 200 个 episode 后再衰减效果比直接 0.995 逐轮衰减要好一点因为前期能拿到更丰富的轨迹。当然如果你是自定义 reward也可以适当增加靠近中心的奖励权重。但记住这不是免费的。你给“靠近中心”奖励太高的同时也给了智能体一个“停下不动”的动机我前面提到的“不敢降落”就是代价。4.4 奖励波动巨大但平均值在缓慢上升大量 episode 的奖励能在 -100 到 200 之间来回跳平均线却在向上走。这是训练后期比较常见的“高方差”现象本质上说明策略本身有随机性或者环境存在一定随机性initial state 每次不同初始位置、速度都不同。我们关心的是平均奖励能否稳定上升而不是每一步都平滑。处理高方差的思路是用指数滑动平均EMA来看趋势而不是用原始均值。ema alpha * current_reward (1 - alpha) * emaalpha 取 0.1 左右比较合适。在画图时绘出 EMA 曲线就好否则曲线锯齿会严重干扰判断。另一个导致方差大的原因是epsilon最低值设得过高。如果保留 0.1 的随机动作最终策略依然会频繁做出无意义动作真正到了测试阶段我们又希望动作是完全贪心的。所以训练阶段可以把 ε 最小值设成 0.01测试阶段直接关闭探索。这样方差能显著下降。5. 从“能跑”到“成功着陆”评估手段与模型保存许多资料的终点是“模型训练完成”但真正有价值的项目结论应该在“评估与部署”上。你以为达到 200 奖励分就是成功着陆吗不一定。LunarLander 环境在奖励 200 分以上通常意味着着陆成功且没有坠毁但奖励分数高也可能出现“连续弹跳后停在平台上”的丑陋轨迹。评估要细致一点。5.1 用多次测试平均值代替单次展示不要在训练刚成功后马上截图动图发朋友圈。强化学习策略天生带有随机性即使固定了 epsilon 为 0即完全贪心环境初始状态也是随机的单次表现不能代表真实能力。我的做法是训练过程中每 50 个 episode 保存一次当前模型然后在测试环境里连续跑 20 次取平均奖励和成功率。如果这个值稳定在 230 以上我会认为策略已经具备实用性。如果只是某一次偶然得分 300后续全部坠毁那很可能是训练方差导致的假象还需要继续训练或调整超参。测试过程中最好关闭 epsilon 探索并对环境做一次 reset确保在同一批初始状态下比较模型新旧版本的表现差异。5.2 关键检查点能否应对不同的初始条件LunarLander 的初始位置和速度都是随机的比如有时飞行器出现在地图左侧有时在右侧有时从很高处落下。一个优秀策略应当对所有初始条件都表现出一定鲁棒性。我见过一些模型在“左侧初始位置”下表现极好但一旦从右侧开始就完全失控这种不对称性通常跟探索样本中左右分布不均有关或跟网络激活函数的输出分布不平衡有关。一个简单但有效的检测方法是人为在 reset 后修改状态中的初始 x 坐标然后看智能体的行为差异。如果动作模式发生剧烈变化说明状态空间覆盖不充分。这时可以考虑在训练提前期使用更密集的探索或者直接增加初始状态随机性——不过要注意这样做会增大学习难度训练时间相应拉长。5.3 动图与可视化并不是最终目标很多人以为把模型保存下来渲染一个 GIF发个朋友圈项目就结束了。但如果做的是学习项目我更建议把 checkpoint 保存下来包括网络结构和 optimizer 状态方便从上次中断点恢复训练。我自己的习惯是每 200 个 episode 保存一次checkpoint.pt文件名里带 episode 序号和当前平均奖励万一下一次调参失败还能回滚到表现最好的版本。提示在 LunarLander 这种任务里不同 seed 的初始随机性会造成训练结果的显著差异。如果你发现某次训练怎么都不收敛不妨把随机种子换一下重跑一次而不是在同一个失败配置上死磕。这听起来不太“科学”但实践中非常有效。6. 最终体会DQN 实战中那些教科书不会写的事跑完一个完整的 DQN 登月器项目我的最大体会是相比算法理论真正拉开水平差距的往往是对环境和超参的“手感”。一个看似不起眼的 epsilon 衰减率可以直接决定你的模型停在 -100 还是升到 250。训练过程中也别忘了记录实验版本。我推荐在代码里内置一个 config 字典把任何超参数、环境设置都记录下来随 checkpoint 一起保存不然三天后你看着一条奖励曲线根本想不起来当时用的 batch size 是多少。这算是老生常谈但我确实在几次隔天的复盘里吃过亏。最后分享一个我自己觉得很实用的小技巧训练到中后段时把 epsilon 最小值从 0.01 调成 0.001甚至为 0并观察训练曲线。如果曲线不降反而继续上升说明策略已经趋于稳定如果明显下降则说明它还在依赖探索可能是当前奖励信号对“失败经验”惩罚不足需要重新审视环境奖励结构。这个项目本身没有太深的算法创新但作为强化学习入门的第一个“腿型控制”任务它的价值在于逼你把环境的物理意义、算法原理、超参权衡和排查手段全部打通。跑通之后你会发现再回头去看 PPO、SAC 等更复杂的算法也不会觉得无从下手了。