迷宫中的DQN:深度强化学习原理、代码与调参全解析
简介深度强化学习 DQN 是深度神经网络与 Q-Learning 结合的经典算法借助神经网络逼近 Q 函数解决状态与动作空间增大时 Q 表存储和计算开销过大的问题。这份 PDF 面向机器学习初学者和人工智能方向学习者以迷宫寻路为具体场景逐步讲解状态与动作的定义、经验回放机制、损失函数构建、epsilon 贪心探索策略等关键环节并给出基于 TensorFlow 的代码实现流程帮助读者走通 DQN 训练与调参流程。文档在原理部分解释了 Q 表随状态-动作对数量增长而难以存储的瓶颈并说明经验回放为何要随机抽取历史样本以消除相邻样本相关性训练阶段则以目标 Q 值与当前 Q 值的误差作为损失反复更新网络权重最终让智能体学会从起点走向终点的最优路径。压缩包共 1 个 PDF 文件约 205KB。目前已有 1774 人学习下载适合刚接触强化学习、想理解神经网络替代 Q 表并完成迷宫任务的读者。1. 从迷宫看DQN没有标签的深度学习是怎么学会走路的你大概率见过这种画面一个红色方块在迷宫里乱撞大部分时间卡在墙角偶尔碰巧摸到终点下一轮又从入口开始莽。如果只看深度学习原理.pdf会觉得深度学习必须有标签才能学但深度强化学习DQN偏要打破这个直觉它让神经网络从零开始和环境反复试错把“哪一步值得走”学成一张可查询的动作价值表。迷宫问题就是最小的验证场状态少、动作固定、奖励来源清晰能直接看到Q值是怎么从一团乱麻长成合理分布的。这篇笔记会讲清DQN的原理给出能复刻出迷宫解法的源码骨架列出参数表再把最常翻车的几个场景逐个拆开。适合想用一个小例子把深度强化学习真正跑起来、而不是只读一堆公式的人。2. 迷宫问题的MDP建模状态、动作、奖励三件套定不好后面全是白练强化学习的第一步不是写网络而是把问题装进马尔可夫决策过程MDP的框架里。迷宫的目标很单纯从起点走到终点但“怎么走到”这件事必须翻译成状态、动作、奖励三类信号。这三件套里任何一件定得不合理后面的DQN代码再标准也训练不出能走通迷宫的模型。2.1 为什么迷宫是DQN最合适的入门题离散状态与稀疏奖励的第一课MDP四元组是“状态集、动作集、转移概率、奖励函数”。在迷宫场景里状态集就是所有格子坐标动作集是上、下、左、右四种转移概率在当前设定下是确定性的奖励函数则要回答“走到哪一步给多少分”。DQN不直接建模转移概率它只从环境产生的转移样本里学习状态和动作的价值所以环境代码只需负责生成“状态、动作、奖励、下一状态、是否结束”这样的五元组。5x5迷宫只有25个状态、4个动作所有状态动作对加起来才100个。这个规模足够小让训练在几分钟内结束又不会小到让“神经网络”失去意义。更重要的是迷宫是最典型的稀疏奖励问题绝大多数格子反馈都是0或很小的惩罚只有终点才出现明显正奖励。这种场景天然会放大DQN对探索策略的敏感度新手能借此直观感受到“随机探索”和“经验利用”的对抗关系。还需要想清楚的是迷宫里的状态是离散坐标不是图像。很多教程一上来就把“迷宫图片”塞进卷积网络那是另一个量级的问题。坐标输入让DQN的价值函数更容易可视化也能把训练不稳定因素限制在算法本身而不是特征提取上。先用坐标迷宫把DQN机制跑通再考虑视觉输入才是更可控的学习路线。2.2 用一段Python把迷宫环境写出来坐标状态与边界动作我一般会用一个最小的GridWorld类来承担环境职责。它要完成三件事记录当前坐标、根据动作更新坐标、返回奖励和是否终止。这里动作用整数索引而非字符串便于后面神经网络直接输出动作编号。class GridWorld: def __init__(self, width5, height5, obstacles(), start(0, 0), goal(4, 4)): self.width width self.height height self.obstacles set(obstacles) self.start start self.goal goal self.agent start self.actions [up, down, left, right] def reset(self): self.agent self.start return self.agent def step(self, action): # action: 0up, 1down, 2left, 3right x, y self.agent if action 0: x max(0, x - 1) elif action 1: x min(self.height - 1, x 1) elif action 2: y max(0, y - 1) elif action 3: y min(self.width - 1, y 1) nxt (x, y) if nxt in self.obstacles: nxt self.agent # 撞障碍物停在原地不额外惩罚 self.agent nxt done (self.agent self.goal) reward 1.0 if done else -0.01 return self.agent, reward, done这段代码里有两个设计点很容易被忽略。第一撞障碍物时是“停在原地”而不是“退回上一步”这避免了模型学到“撞一下也能换位置”的错误映射。第二每一步都给-0.01的小惩罚目的是让智能体不要绕远路同时不会因为每步惩罚太重而害怕移动。reset()返回起点坐标step()返回标准四元组这样的接口可以直接接入后续的训练循环也方便替换成随机障碍地图。如果想把难度调大把width/height从5改成8或者构造一个有死胡同的地图。此时goal要确保可达否则训练多久都不会有正奖励日志里也看不出是算法问题还是环境问题。检查一点在训练前用随机策略跑几百局统计平均每局能拿到多少奖励。如果随机策略完全摸不到终点说明地图对随机探索太不友好后续DQN的收敛压力也会大很多。2.3 奖励塑形是做还是不做从“到达终点1”到每一步小惩罚上一节代码已经做了最简单的奖励塑形终点给1.0其他步给-0.01。如果不加每步惩罚模型确实也能通过折扣因子gamma学会走短路径因为越早到达终点未来的累计奖励折扣损失越小。但训练初期随机探索路径很长“每步0奖励”会让Q值更新信号非常稀疏曲线看起来像一条直线在慢慢蠕动。另一种更主动的塑形是“曼哈顿距离势函数”每走一步计算当前位置到终点的曼哈顿距离如果距离比上一步小给一个小的正奖励否则给负奖励。这种方案在长迷宫里能大幅加速探索但也容易制造“只朝终点走近、不绕开障碍”的局部最优。我自己的取舍是小规模迷宫先不做距离塑形让模型靠终点奖励自然学会如果训练了几百局还摸不到终点再加reward - 0.1 * (manhattan_distance / max_distance)作为辅助信号。撞墙惩罚是另一个常见的坑。有人在离散网格里设置“撞墙给-1”模型很快会发现原地不动每步只有-0.01撞墙却要付出-1于是站在墙边再也不动。这属于奖励设计把最优策略带偏了。正确的做法是撞墙不产生移动、不额外惩罚让每步时间成本自然约束探索。只有在连续动作或者带速度的场景里撞墙惩罚才有明确物理意义。3. DQN原理拆解从查表的Q-Learning到神经网络拟合环境定义清楚后接下来要解决“网络学什么”的问题。DQN的起点是Q-Learning那张表但表的容量撑不住大规模状态于是换成神经网络来做函数逼近。理解这一章的关键是抓住一个公式和两个机制贝尔曼公式负责给出学习目标经验回放和目标网络负责让这个目标不被单步噪声带偏。3.1 贝尔曼方程与Q值DQN要拟合的那张表到底长什么样Q-Learning维护一张表Q(s, a)表示在状态s下执行动作a之后按照某个策略继续走能拿到的累计奖励期望。更新公式是Q(s, a) - Q(s, a) alpha * (r gamma * max_a Q(s, a) - Q(s, a))其中r是当前步拿到的奖励gamma是折扣因子max_a Q(s, a)是下一个状态里所有动作中最大的Q值。这个更新想让当前Q值向“当前奖励 下一状态的最优未来价值”靠拢。gamma越大模型越看重远期收益迷宫比较短通常取0.9就够用取0.99也不会带来明显提升。DQN把表换成了神经网络Q_theta(s, a)输入状态输出所有动作的Q值。更新时不再直接改写某个格子而是通过梯度下降让网络输出逼近目标值。这里最容易产生误解的地方是目标值本身不是固定标签它带着当前模型的预测。也就是说网络在追一个每次迭代都可能变化的靶子。如果直接拿在线网络自己算目标更新会因为目标跟着参数一起动而震荡甚至发散。在5x5迷宫里其实可以用表存储所有Q值并做经典Q-Learning但那样就完全体会不到深度学习的含义。正确的态度是把迷宫当作一个“价值函数可视化工具”网络就是参数化的逼近器。训练结束后把每个坐标的最大Q值画成热力图能清楚看到终点附近值高、墙角死角值低这就说明网络真的学会了价值分布而不是背下了一条固定路线。3.2 经验回放打破相邻样本相关性的重点设计如果按时间顺序拿样本在线更新前后几步的样本往往高度相关。比如在一段笔直走廊里连续几次动作几乎都朝同一个方向网络会被反复灌输同一种状态分布导致对某个区域的Q值估计突然膨胀随后又被另一批样本拉回来形成灾难性遗忘。DQN的一项重要设计是经验回放把每步转移存入一个固定容量的缓冲池训练时从中均匀随机采样一小批。from collections import deque import random import torch class ReplayBuffer: def __init__(self, capacity20000): 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 (torch.tensor(states, dtypetorch.float32), torch.tensor(actions, dtypetorch.long).unsqueeze(1), torch.tensor(rewards, dtypetorch.float32).unsqueeze(1), torch.tensor(next_states, dtypetorch.float32), torch.tensor(dones, dtypetorch.float32).unsqueeze(1))deque(maxlencapacity)在超出容量时自动丢弃最老的样本让经验池保持最近一段时间内的转移数据。sample里的均匀随机采样打乱了相邻样本在时间上的相关性也让训练分布更接近独立同分布。容量capacity是一个需要根据状态空间调整的参数迷宫这种几十个状态的问题几千条经验就足够如果地图扩大到20x20经验池最好覆盖更多不同区域才有足够的样本来拟合整个价值平面。这里要特别提一个参数陷阱经验池并不是越大越好。池子过大时早期随机探索产生的低质量样本占比太高正样本会被稀释模型迟迟学不到终点附近的价值信号。在小迷宫上我通常从20000开始如果发现后期收敛变慢再降到5000左右对比一下。3.3 目标网络与损失函数别让模型追着一个移动靶子练目标网络是DQN相对Q-Learning的另一项核心改动。它维护一份滞后更新的参数副本参与目标值计算但不受当前反向传播影响。损失函数可以写成Loss MSE( Q_theta(s, a), r gamma * max_a Q_target(s, a) )由于Q_target在一段时间内保持不变网络更新面对的是一个稳定目标不会出现“参数动一步目标也跟着动一步”的正反馈循环。每隔target_update_freq步把在线网络参数整体复制到目标网络这个频率就是DQN里最需要手感的超参数之一。同步频率太小时目标网络追得太紧单步误差会立刻传导成下一轮目标训练曲线会像锯齿一样剧烈抖动。同步频率太大目标长期不更新模型可能在已经过时的目标上反复拟合等到大版本更新时一次性释放偏差。以5x5迷宫为参考200步同步一次通常表现稳定复杂任务里可以放到1000甚至5000。在代码实现里目标网络必须使用with torch.no_grad()或者直接由它算出的值不挂载梯度。不然反向传播时会把目标值的梯度也带回来相当于又让目标跟着在线网络漂移失去冻结的意义。3.4 搭一个最小DQN网络PyTorch代码与关键参数迷宫的状态是二维坐标用标准多层感知机就够了。两个隐藏层、每层64个神经元对这种小任务已经算得上宽裕。下面这段代码定义了网络结构和一次损失计算也是整个训练循环里唯一涉及神经网络的部分。import torch import torch.nn as nn class DQN(nn.Module): def __init__(self, state_dim2, num_actions4): super().__init__() self.net nn.Sequential( nn.Linear(state_dim, 64), nn.ReLU(), nn.Linear(64, 64), nn.ReLU(), nn.Linear(64, num_actions) ) def forward(self, x): return self.net(x) loss_fn nn.MSELoss() def compute_loss(online_net, target_net, batch, gamma0.9): states, actions, rewards, next_states, dones batch q_values online_net(states).gather(1, actions) with torch.no_grad(): max_next_q target_net(next_states).max(dim1, keepdimTrue)[0] targets rewards gamma * max_next_q * (1 - dones) return loss_fn(q_values, targets)gather(1, actions)的作用是从网络输出的4个动作Q值中取出当前样本里真实执行的那个动作所对应的Q值结果形状是(batch, 1)。max(dim1, keepdimTrue)[0]取下一个状态里最大的Q值代表乐观估计的后续收益。(1 - dones)很关键如果当前状态已经终止下一状态不存在目标值应该只剩reward否则会把幽灵状态的价值也累加进去。状态特征的表示虽然只有两个数字但也有讲究。直接输入(x, y)原始坐标可以跑通但地图尺寸变化时坐标范围会变。更常见的做法是把坐标除以宽高归一化到[0, 1]区间这样在线性层前的数值尺度更稳定。另一个选择是转成one-hot向量在几十个格子的规模下同样可行只是网络输入维度会随地图尺寸线性增长。迷宫这种小问题归一化坐标通常就足够了。4. 跑通迷宫DQN的完整源码训练循环、参数表与曲线判读原理说再多不如把训练循环跑一遍。这一章把前面所有模块合到一起给出一段能直接运行的训练代码并解释每个参数为什么这样配。手头如果有那份深度学习原理.pdf建议先跟着代码跑通一次再回头对照公式理解会更扎实。4.1 单文件还是分模块一份可直接复现的代码骨架工程上我不太推荐把所有代码塞进一个文件但也不需要在Demo里引入复杂框架。一个简洁的分模块结构是这样maze_dqn/ ├── environment.py # GridWorld ├── dqn.py # DQN网络 ReplayBuffer compute_loss ├── train.py # 训练主循环 └── eval.py # 验证脚本environment.py只负责环境逻辑换地图、换障碍物都不影响网络和训练代码。dqn.py里放网络、经验回放、损失计算这是与算法相关的稳定部分。train.py是训练主循环里面包含探索率调度、目标网络同步频率、日志打印。eval.py则是评估脚本测试时把探索关闭统计成功率。这样分文件后续想换成Double DQN或者Dueling DQN时只需改dqn.py里的少量代码不用动训练流程。4.2 训练主循环里最容易写错的顺序问题训练循环的骨架如下。注意代码中state的更新放到replay.push之后这个顺序一错经验池里相邻两条样本就会因为状态被提前覆盖而对不上号。import torch import torch.optim as optim import random from environment import GridWorld from dqn import DQN, ReplayBuffer, compute_loss def train(): env GridWorld(width5, height5) online_net DQN(state_dim2, num_actions4) target_net DQN(state_dim2, num_actions4) target_net.load_state_dict(online_net.state_dict()) replay ReplayBuffer(capacity20000) optimizer optim.Adam(online_net.parameters(), lr1e-3) gamma 0.9 epsilon 1.0 epsilon_min 0.05 epsilon_decay_steps 20000 batch_size 32 target_update_freq 200 max_episodes 1000 max_steps 200 step_count 0 for episode in range(max_episodes): state torch.tensor(env.reset(), dtypetorch.float32) total_reward 0.0 for t in range(max_steps): step_count 1 epsilon max(epsilon_min, epsilon - (1.0 - epsilon_min) / epsilon_decay_steps) if random.random() epsilon: action random.randint(0, 3) else: with torch.no_grad(): q online_net(state.unsqueeze(0)) action int(q.argmax(dim1).item()) next_state, reward, done env.step(action) next_state torch.tensor(next_state, dtypetorch.float32) replay.push(state.numpy(), action, reward, next_state.numpy(), done) state next_state total_reward reward if len(replay.buffer) batch_size: batch replay.sample(batch_size) loss compute_loss(online_net, target_net, batch, gamma) optimizer.zero_grad() loss.backward() optimizer.step() if step_count % target_update_freq 0: target_net.load_state_dict(online_net.state_dict()) if done: break if episode % 50 0: print(fepisode {episode}, reward {total_reward:.3f}, epsilon {epsilon:.3f})这段代码里有一个很容易被忽略的细节目标网络同步发生在step_count达到200、400、600这样固定步数时而不是每个episode结束后同步。前一种方式与训练步数绑定频率更均匀后一种方式在小迷宫里会因为每个episode长度不均导致同步间隔忽长忽短。启动训练前我习惯先打印一行step_count和len(replay.buffer)确认数据已经进入经验池再开始看损失曲线能省掉不少定位问题的时间。4.3 一张参数表learning_rate、epsilon、batch_size怎么配迷宫场景下以下参数组合是我反复验证过比较稳的起点。不同地图尺寸不要直接照搬但可以从这组参数开始微调。参数建议值作用调参方向gamma0.9折扣因子越大越看重远期奖励迷宫短0.9够用地图变大可试0.99learning_rate1e-3 ~ 3e-4网络参数更新步长训练震荡时优先降到1e-4batch_size32每轮更新用的样本数经验池较大时64更稳但32足够epsilon_min0.05最小探索率想保留更多随机性可设为0.1epsilon_decay_steps20000从1.0衰减到0.05所需步数前期不收敛就调大后期收敛慢就调小target_update_freq200目标网络同步间隔曲线震荡调大到500长期不收敛调小replay_capacity20000经验池容量状态空间小则几千复杂场景再增大这些参数不是互相独立的。learning_rate和target_update_freq共同决定稳定性学习率大而目标网络同步频时网络很容易在最近一批样本上过拟合下一批样本又把参数拉回来。epsilon_decay_steps则和总训练步数相关。一个1000局、每局约50步的训练总步数约5万衰减步数设在2万左右比较合理如果设在5000模型在探索还没充分时就进入贪心阶段容易卡在一条次优路径上。4.4 训练曲线怎么看loss降不代表模型会走迷宫训练日志里的episode reward是最应该盯的指标。5x5迷宫里随机策略大约有概率在200步内摸到终点因此初始reward可能在零点几附近也可能因为每一步小惩罚而变成负数。随着训练推进total_reward应逐步上升并稳定在接近0.9的位置。如果一局很快结束reward约等于1 - 0.01 * 最短步数所以看到“reward越接近0.9”通常意味着路径越接近最短。loss反而容易误导人。训练初期探索多目标值本身波动大loss可能先升后降这是正常现象。如果loss很低但reward一直为负问题大概率出在奖励设计或探索率安排上而不是反向传播算错了。只看loss不看reward是我见过最多新手翻车的点网络确实在拟合目标但目标本身没有引导出有效策略。训练结束后把epsilon固定为0用训练好的网络跑若干局并打印每个坐标的Q值矩阵。终点附近Q值高、墙边死角Q值低说明价值函数已经长成合理形状如果Q值矩阵看起来像噪声说明训练还没有真正收敛再回头检查经验池里正样本的占比。5. 迷宫DQN的5个高频坑与排查记录死循环、震荡、过拟合这一章放踩坑记录。每一条都是我在实际调试里遇到过的现象按“现象、原因、解决”拆开写。如果你在训练时发现曲线不对劲优先对照这几条比盲目调学习率高效得多。5.1 现象loss一路下降却连3x3迷宫都走不出去网络看起来在训练loss也在降但智能体不会走迷宫。检查日志会发现每个episode都跑满max_steps200reward停在负值。原因主要有两类一类是奖励太稀疏随机探索在200步内很难碰到终点经验池里几乎没有正样本另一类是网络把所有状态拟合到同一个近似值因为每步都是-0.01模型只要预测“到处都一样差”就能降低loss。解决方法是先分清楚是“没探索到”还是“探索到了学不会”。在env.step前后打印一下当前坐标如果连续几十局连终点区域都没靠近说明探索不够需要放大epsilon_decay_steps或max_steps如果已经到达过终点但reward上不去则要考虑奖励塑形比如加入曼哈顿距离信号。也可以临时把max_steps提升到1000给随机策略更多机会发现终点奖励模型一旦见过正样本学习曲线会有明显拐点。5.2 现象训练曲线像过山车同一个种子两次结果完全不同DQN本身就有随机性但两个种子跑出来的曲线差异过大往往不是“运气问题”。最常见的原因是目标网络同步太频繁。我刚开始做迷宫时把target_update_freq设成50结果每几十步目标就刷新一次单批样本的噪声立刻变成下一轮的学习目标loss曲线上下乱跳reward始终无法稳定。解决思路是把目标网络同步频率至少放到200以上同时固定随机种子。迷宫这种小规模任务建议在程序入口设置random.seed(42)和torch.manual_seed(42)这样至少能复现同一个版本的训练过程。如果固定种子后依然剧烈震荡下一步把learning_rate从1e-3降到3e-4看看是否参数步长太大导致越过最优区域。5.3 现象换一张迷宫图就废了之前跑的几小时白费在一张固定地图上训练出来的模型换到另一张障碍分布不同的地图上成功率可能直接掉到接近0。这是典型的“过拟合到地图”。迷宫的状态是坐标网络确实可以背下每条能走到终点的路径但这不等于学会了通用避障能力。我的处理办法是让训练时每个episode随机生成一组障碍物同时严格检查起点到终点的连通性。可以用BFS提前判断不连通就重新生成。这样经验池里覆盖的路径更丰富网络学到的价值函数对不同障碍分布更有适应性。评估阶段则用训练时没见过的地图测试成功率超过80%再谈切换场景否则训练日志再漂亮也只能说明“背题成功”。5.4 现象epsilon已经衰减到0.05模型还在乱走探索率降到最低后模型按理说应该进入“纯贪心”阶段但行为看起来仍然像在乱走。排除网络没有学习的情况后最可能的原因是epsilon_decay_steps设得太短探索信号在模型还没见过足够多正样本时就提前熄火。假设总训练步数只有2万衰减步数也设成2万那么训练结束时探索率恰好到最低相当于模型从头到尾几乎没有系统性利用过已经学到的知识。更合理的做法是把衰减周期设为总训练步数的三分之一到一半并且让衰减曲线稍微平滑一些。迷宫训练1000局、每局平均50步时epsilon_decay_steps20000是合理的如果每局跑满200步总步数接近20万衰减步数可以放到50000甚至80000。5.5 现象经验池里全是失败样本训练起来像黑匣子迷宫扩大到8x8并且加入随机障碍后随机策略很难在200步内碰到终点经验池里的正样本占比可能不足百分之一。此时DQN几乎变成黑匣子loss在变reward却毫无起色也无法判断是探索不够还是网络容量不够。这时候最简单的办法不是继续调参而是回到小迷宫。先用4x4跑通确认代码没有隐藏问题再逐步放大到6x6、8x8每换一次地图都记录成功率。另一种思路是适当缩小经验池容量因为容量过大会让稀少的正样本被海量负样本淹没模型对“终点附近”的敏感度大幅下降。若实在需要在8x8上直接训练可以临时加入一个人工引导每局随机把起点放在离终点较近的位置让智能体先学会短距离冲刺再逐步拉长起点距离。6. 迷宫之外DQN的验证方法、变体选择和网格搜索的小建议6.1 三种指标验证模型真的学会了训练结束后别拿训练日志当成绩单。把epsilon设为0跑50局并统计三件事到达终点的成功率、平均步数、终点附近的Q值形态。成功率80%以上平均步数在最短路径1.5倍以内Q值矩阵从终点向四周平滑下降这三条都满足才算真正学会。6.2 Double DQN与Dueling DQN在迷宫上值不值得做5x5迷宫用普通DQN就足够。Double DQN的核心是解决Q值过估计迷宫规模小、Q值绝对值低收益不明显。如果地图变大、障碍随机值得把目标公式改成Double形式with torch.no_grad(): best_action online_net(next_states).argmax(dim1, keepdimTrue) max_next_q target_net(next_states).gather(1, best_action) targets rewards gamma * max_next_q * (1 - dones)它把“选动作”和“估价值”拆开选动作用在线网络估值用目标网络减少乐观偏差。Dueling结构把Q值拆成状态价值和动作优势在动作数很少的迷宫里几乎体现不出优势。真要往上走先把Double DQN加上再把MSE换成Huber loss就已经够应付多数随机迷宫场景了。6.3 调参顺序与我的习惯我调迷宫DQN的顺序是先改奖励设计再改epsilon衰减最后才动学习率和网络宽度。奖励信号不对后面所有参数都在补偿一个错误目标。我自己的习惯是每次训练前固定随机种子训练后保存一份最优权重再换一张地图验证泛化。以前因为不固定种子浪费过整个下午调一个本来就不稳的模型后来才明白“可复现”本身就是调试的前提。希望这个参数表和几条踩坑记录能帮你在迷宫里少走一段弯路也希望帮到你。本文还有配套的精品资源点击获取