深度强化学习DQN实战:用迷宫问题演示Q值网络训练与避坑
简介一份面向深度学习与强化学习初学者的DQN实战讲解PDF系统说明如何用神经网络替代传统Q表来解决迷宫寻路问题。内容从Q-Learning原理入手指出状态动作空间增大后Q表存储与计算困难进而引入Experience Replay经验回放与目标Q值逼近机制并详细拆解损失函数、epsilon-greedy探索策略和TensorFlow实现流程。迷宫例子中定义了6种状态与6种动作通过状态矩阵、动作矩阵与记忆库随机采样完整呈现从试错探索到策略收敛的训练细节还给出可直接参考的源码与逐步解释。资源仅有1个PDF文件大小约205KB适合想理解DQN网络结构并对照代码动手实验的开发者与研究者。目前已有1774人学习可帮助读者打通从强化学习基本概念到深度Q网络落地的关键环节。1. 深度强化学习DQN的第一次实战用迷宫问题验证Q值网络到底行不行先泼一盆冷水不要从 Atari 开始学深度强化学习 DQN。很多人把第一行 DQN 代码献给打砖块结果被画面预处理、帧堆叠、奖励裁剪三座大山压垮最后只记得 loss 在降、游戏分数纹丝不动。迷宫问题才是更合适的起点——状态就是坐标动作只有上下左右奖励稀疏但规则清晰恰好把 DQN 的经验回放、目标网络、探索策略全部逼出来。这篇笔记会把在 4x4 网格迷宫里用 PyTorch 写 DQN 的完整源码拆开讲从环境定义到训练主循环再到参数表和避坑记录。适合有 Python 基础、想第一次动手验证深度强化学习算法的人也适合打算用深度强化学习做路径规划的人先跑通最小闭环。2. 从Q-Learning到深度强化学习DQNQ值函数换一种存法迷宫问题就活了2.1 Q-Learning的最小闭环记忆、更新、后悔药DQN 全称 Deep Q-Network本质是把 Q-Learning 里的 Q 表换成一个深度网络。想彻底看懂 DQN得先看 Q-Learning 在迷宫这种离散场景里做了什么。所谓 Q 值就是在状态 s 执行动作 a之后一直按最优策略走下去能拿到的累计回报期望。迷宫问题里状态就是格子坐标动作就是上下左右四选一Q 表可以整理成一张“状态-动作”对照表。更新公式长这样Q(s,a) - Q(s,a) lr * (r gamma * max_a Q(s,a) - Q(s,a))。括号里那一项是时间差分目标也叫 TD 目标r 是这一步拿到的奖励gamma 是折扣因子max_a Q(s,a) 是对下一步“最好情况”的估计。当前 Q 值是事前估计TD 目标是事后观察到的更优估计两者之差就是 TD 误差。优化过程就是让当前的 Q 值不断向这个事后估计靠近。我习惯把它叫“后悔药”。因为 r gamma * max Q(s,a) 这句话的意思是如果你刚才这个动作走错了现在给你一个修正信号告诉你“从这一步开始以后还有更好的走法”。迷宫这个场景很适合学 Q-Learning因为状态完全是离散的更新逻辑可以直接落在表格上。但 4x4 迷宫只有 16 个格子换成 60x60 的迷宫Q 表就要存 14400 个状态动作对光是查表和更新就已经难看更别提状态连续化之后根本没有表格可查。2.2 DQN的两个关键机制经验回放与目标网络DQN 把 Q 表换成神经网络输入是状态输出是该状态下每个动作的 Q 值。迷宫这里输入就是归一化后的坐标输出是四个动作的 Q 值。网络本身不复杂真正让训练能站住的是两个配套机制经验回放与目标网络。经验回放解决的是样本相关性问题。智能体在迷宫里连续走相邻状态高度相似如果每步拿到数据立刻训练网络会被一串强相关的样本带偏。回放池把每次转移存成一条经验训练时随机抽一批相当于把时间顺序打散让网络每轮看到的是来自不同时刻、不同路线的样本。这样做还有一个附带收益同一条经验可以反复参与训练数据利用率更高。目标网络解决的是训练目标不断漂移的问题。如果用一个网络同时算当前 Q 值和 TD 目标里的 max Q(s,a)那每更新一次参数目标也跟着变训练就像在追自己影子。DQN 的做法是准备一份滞后同步的网络参数用来算 TD 目标每隔 C 步再把当前网络参数复制过去。更新目标公式变成 y r gamma * max_a Q(s,a; θ-)其中 θ- 是目标网络参数。这样在两次同步之间网络面对的是一个相对固定的监督信号。2.3 为什么迷宫问题适合做DQN的第一课迷宫问题是 DQN 很好的“最小可复现单元”。它没有图像感知不需要卷积网络和帧堆叠没有连续控制动作就是四个离散方向也不像倒立摆那样奖励稠密、问题本身偏线性。迷宫恰恰把 DQN 最核心的三个机制全部暴露出来要在稀疏奖励下探索到终点才能让 Q 值产生区分度要借助经验回放才能稳定学要依赖目标网络才能让 TD 误差收敛。对比一下就知道。Atari 游戏的输入是 84x84 的灰度帧还得做奖励裁剪、帧堆叠、跳帧环境封装比算法本身还长倒立摆用最简单的表格 Q-Learning 也能解决体现不出深度网络的价值。而 4x4 迷宫用 DQN 训练CPU 上几分钟就能看到成功率爬升很适合把网络结构、更新频率、探索衰减这些因素逐个拆开验证。网络结构也不需要多复杂两层隐藏层就够。下面是完整源码按三个文件拆好迷宫环境、Q 网络与回放池、训练主循环。3. 迷宫问题DQN源码落地用PyTorch从环境到训练主循环一次跑通3.1 自定义MazeEnv把墙、奖励和终止条件写成Gymnasium接口训练任何一个强化学习算法先把环境接口定清楚。这里用 Gymnasium 的接口写一个 4x4 迷宫环境支持 reset 和 step 两个核心方法。迷宫布局用一个二维列表表示0 是路1 是墙。# maze_env.py —— 自定义 4x4 迷宫环境接口对齐 Gymnasium import numpy as np from gymnasium import spaces class MazeEnv: def __init__(self, mazeNone, max_steps100): self.maze maze if maze is not None else [ [0, 0, 0, 0], [0, 1, 0, 0], [0, 0, 1, 0], [0, 0, 0, 0], ] self.start (0, 0) self.target (3, 3) self.max_steps max_steps self.action_space spaces.Discrete(4) # 0上 1下 2左 3右 self.observation_space spaces.Box( low0.0, high1.0, shape(2,), dtypenp.float32 ) self.rng np.random.default_rng(42) self.pos list(self.start) self.steps 0 def reset(self, seedNone): if seed is not None: self.rng np.random.default_rng(seed) self.pos list(self.start) self.steps 0 return self._observe(), {} def _observe(self): # 坐标归一化到 [0,1]避免原始坐标数值过大影响网络收敛 h, w len(self.maze), len(self.maze[0]) return np.array( [self.pos[0] / (h - 1), self.pos[1] / (w - 1)], dtypenp.float32, ) def step(self, action): h, w len(self.maze), len(self.maze[0]) nr, nc self.pos[0], self.pos[1] if action 0: nr - 1 elif action 1: nr 1 elif action 2: nc - 1 elif action 3: nc 1 reward -0.01 # 每走一步一个小惩罚逼智能体走短路径 terminated False # 撞墙或越界位置不变额外惩罚 if nr 0 or nr h or nc 0 or nc w or self.maze[nr][nc] 1: reward - 0.1 else: self.pos [nr, nc] if tuple(self.pos) self.target: reward 10.0 terminated True self.steps 1 truncated self.steps self.max_steps info {success: tuple(self.pos) self.target} return self._observe(), reward, terminated, truncated, info奖励设计是这个环境里最关键的部分。到达终点给 10这是唯一的大额正反馈普通移动给 -0.01是为了让智能体倾向短路径撞墙给 -0.1是让它在探索早期就学会躲墙。如果只留 10DQN 在完全随机的时候可能几百轮都碰不到终点回放池里全是负样本Q 值拉不开差距这是迷宫问题最常见的翻车点后面避坑章节还会展开。状态用归一化坐标而不是原始行列号是因为神经网络的输入量级不要差太大坐标除网格尺寸后落在 [0,1] 区间。3.2 DQN网络与ReplayBuffer用两个类解决样本相关性问题Q 网络用一个简单的多层感知机输入是 2 维坐标输出是 4 个动作的 Q 值。ReplayBuffer 用 deque 实现容量满时自动丢弃最老的经验。# model.py —— Q 网络与经验回放池 import numpy as np import torch import torch.nn as nn from collections import deque import random class QNetwork(nn.Module): def __init__(self, state_dim2, action_dim4, hidden64): super().__init__() self.net nn.Sequential( nn.Linear(state_dim, hidden), nn.ReLU(), nn.Linear(hidden, hidden), nn.ReLU(), nn.Linear(hidden, action_dim), ) def forward(self, x): return self.net(x) class ReplayBuffer: def __init__(self, capacity10000): self.buffer deque(maxlencapacity) def push(self, state, action, reward, next_state, terminated): self.buffer.append((state, action, reward, next_state, terminated)) def sample(self, batch_size): batch random.sample(self.buffer, batch_size) states torch.tensor(np.array([b[0] for b in batch]), dtypetorch.float32) actions torch.tensor([b[1] for b in batch], dtypetorch.long) rewards torch.tensor([b[2] for b in batch], dtypetorch.float32) next_states torch.tensor(np.array([b[3] for b in batch]), dtypetorch.float32) terminateds torch.tensor([b[4] for b in batch], dtypetorch.float32) return states, actions, rewards, next_states, terminateds def __len__(self): return len(self.buffer)网络结构不用再加宽4x4 这个规模 64 个隐藏单元足够。输出维度对应动作数这是 DQN 的标准设计网络不输出单个 Q 值而是输出一整组动作的 Q 值方便直接用 max 取最优动作。经验回放池的容量先给 10000batch_size 用 32。容量太小会让新旧经验比例失衡容量太大会让采样里包含过多早期无效经验训练前期拖慢收敛。3.3 训练主循环epsilon衰减、目标网络同步与成功率记录训练主循环是整个源码的核心。它要做的事有六件按 epsilon 贪婪策略选动作把转移存入回放池从回放池采样更新 Q 网络每隔 target_update 步同步一次目标网络记录当前回合是否成功每回合结束后衰减一次 epsilon。# train_dqn.py —— 训练主循环 import random import numpy as np import torch import torch.nn.functional as F from collections import deque from maze_env import MazeEnv from model import QNetwork, ReplayBuffer def train(): env MazeEnv(max_steps100) state_dim 2 action_dim env.action_space.n policy_net QNetwork(state_dim, action_dim, hidden64) target_net QNetwork(state_dim, action_dim, hidden64) target_net.load_state_dict(policy_net.state_dict()) buffer ReplayBuffer(capacity10000) optimizer torch.optim.Adam(policy_net.parameters(), lr1e-3) gamma 0.99 batch_size 32 target_update 200 # 每 200 步同步一次目标网络 epsilon 1.0 epsilon_min 0.01 epsilon_decay 0.995 episodes 1000 step_count 0 recent_success deque(maxlen100) for episode in range(episodes): state, _ env.reset() done False success False while not done: # epsilon 贪婪随机动作保证探索之后逐渐收敛到利用 if random.random() epsilon: action env.action_space.sample() else: with torch.no_grad(): state_t torch.tensor(state, dtypetorch.float32).unsqueeze(0) action policy_net(state_t).argmax(dim1).item() next_state, reward, terminated, truncated, info env.step(action) # 注意这里存的是 terminated截断不是真正的终止价值还要往后看 buffer.push(state, action, reward, next_state, terminated) state next_state success | info[success] done terminated or truncated # 经验足够后开始训练 if len(buffer) batch_size: states, actions, rewards, next_states, terminateds buffer.sample(batch_size) q_current policy_net(states).gather(1, actions.unsqueeze(1)).squeeze(1) with torch.no_grad(): q_next target_net(next_states).max(dim1).values # 真正的终止状态不再往后看截断状态仍然 bootstrap q_target rewards gamma * q_next * (1 - terminateds) loss F.mse_loss(q_current, q_target) optimizer.zero_grad() loss.backward() optimizer.step() step_count 1 if step_count % target_update 0: target_net.load_state_dict(policy_net.state_dict()) recent_success.append(success) # 每个 episode 结束后衰减一次探索率 epsilon max(epsilon_min, epsilon * epsilon_decay) if (episode 1) % 100 0: print( fEpisode {episode 1}, fRecentSuccess {np.mean(recent_success):.2f}, fEpsilon {epsilon:.3f} ) if __name__ __main__: train()这里有个容易被忽略的细节buffer.push 存的是 terminated不是 terminated or truncated。两者的区别在于走到终点是真正的 episode 结束后续没有回报所以 TD 目标里不需要往后看而 max_steps 截断只是人为切断后续状态依然存在、依然有潜在的未来回报如果把它也当终止处理Q 值会被系统性压低导致智能体倾向早停。代码里的 done 才用 terminated or truncated那是控制循环结束用的与价值学习无关。3.4 参数表与第一次运行预期先跑通再谈调优第一次跑直接用下面这组参数就行不需要改。参数起点值说明learning_rate1e-3Adam 优化器适合小型网络gamma0.99折扣因子接近 1 表示重视长期回报batch_size32每次采样的经验数量buffer_capacity10000经验回放池容量target_update200目标网络同步间隔单位是 stepepsilon_init1.0初始探索率全部随机epsilon_min0.01最低探索率保留少量随机epsilon_decay0.995每回合衰减系数episodes1000总训练回合数正常情况下你会看到前一两百回合 RecentSuccess 基本是 0因为智能体在随机撞墙很少能走到终点。之后随着经验积累第 400 到 600 回合左右会出现第一个接近 0.1 的窗口然后逐步爬升到 0.7 以上。如果 800 回合后成功率还是 0先不要动网络结构优先检查 epsilon 衰减是不是太快、奖励里撞墙惩罚是不是被设成了正数。这些排查动作放在第 5 章细说。4. 迷宫DQN收敛的三个必调参数先看成功率再动参数4.1 epsilon衰减节奏探索不够一切白搭epsilon 贪婪是 DQN 里“探索与利用”的开关。训练初期 epsilon 高智能体到处乱撞积累不同路线的经验后期 epsilon 低动作基本由 Q 网络决定。迷宫问题里探索不够最典型的症状是智能体一直沿着墙边打转永远到不了终点。衰减公式是epsilon max(epsilon_min, epsilon * epsilon_decay)关键在 epsilon_decay。0.995 意味着 1000 回合后衰减到约 0.005会被下限 0.01 截住前期探索足够。如果把 decay 调成 0.99300 回合后 epsilon 就已经接近 0.049随机动作非常少而这个时候 Q 值可能还没分化出“哪条路通向终点”策略就固化了。反过来如果 1000 回合后成功率还在爬升说明探索还没结束可以把 decay 调成 0.998 或者把 episodes 加到 1500。一个常见误解是 epsilon 越低越好。实际上 epsilon 掉到 0.01 之后策略几乎完全由 Q 网络决定这时候如果 Q 值本身是错的它就失去了通过随机动作自我修正的机会。所以我一般会保留至少 0.01 的探索让训练后期偶尔还能跳出局部死路。4.2 学习率与批次大小的配合稳定训练的先决条件学习率直接决定 Q 网络参数每次朝 TD 目标迈多大的步子。迷宫这种小方案1e-3 是比较稳的起点。调高到 5e-3 时 loss 曲线会出现明显的锯齿严重时训练中期 loss 直接飙升策略也跟着崩溃。调低到 1e-4 能学但收敛速度肉眼可见地变慢1000 回合可能刚学到一半。批次大小影响梯度的稳定性。迷宫状态只有 2 维用 32 的 batch 已经能给出足够稳定的梯度。如果把 batch 降到 8每次更新看到的样本太少TD 目标方差大训练曲线会很吵把 batch 拉到 256一个 batch 里要采样很多旧经验梯度变得过于平滑学习效率和最终成功率反而可能下降。经验是先固定 batch 为 32、lr 为 1e-3跑通之后再用学习率衰减或 Adam 的 weight decay 做微调。4.3 目标网络更新频率CDQN里最容易忽略的玄学按钮target_update 就是之前提到的 C 步同步间隔。这个参数在不少教程里被一笔带过但它对训练稳定性的影响非常大。C 太小目标网络和当前网络参数几乎同步变化目标网络就失去了“固定目标”的意义训练重新变成追影子C 太大目标长期不更新网络会在一段时间里向着一个已经过时的目标逼近中期容易出现“明明快收敛了更新一次目标后成功率突然掉下来”的情况。迷宫这个规模C200 是一个比较均衡的起点对应每训练 200 步同步一次。如果你发现训练中后段成功率反复震荡可以把 C 加到 500让目标网络更稳定如果发现网络学得很慢可以在前 200 回合用 C50 加快学习后期再调大。调 C 比调学习率的收益更直接因为它是“换了一个监督信号”的生效节奏而不是放大或缩小更新幅度。4.4 判断训练有效性的标准成功率曲线而不是loss曲线在深度学习实战里把 loss 当唯一健康指标是最容易踩的误区。DQN 的 loss 是 TD 误差的均方误差它下降只能说明当前 Q 值和 TD 目标在靠近不能说明策略变好了。一个很反直觉的场景是Q 值整体被压低所有状态动作对的输出都偏小TD 误差同样会变小但策略依然在乱走。所以在迷宫这个项目里我只看两个数最近 100 回合的成功率和策略固定的测试成功率。训练循环里已经用 recent_success 做了滑动窗口统计这是最接近“智能体是否真的学会走到终点”的指标。记录方式建议每 100 回合打印一次保存到列表里画曲线比盯 loss 直观得多。如果成功率在爬即使 loss 偶尔抬升也无所谓如果成功率纹丝不动再回头查 epsilon、奖励、目标网络同步这些环节不要一上来就动网络宽度。5. 迷宫DQN实战避坑5个来回踩过的问题与排查方法5.1 loss在下降但成功率一直为零先怀疑评估方式现象训练日志里 loss 从 2 慢慢降到 0.5看起来一切正常但 RecentSuccess 始终是 0智能体还是到不了终点。原因loss 下降并不代表策略在变好。迷宫早期大量经验是撞墙和乱走TD 目标本身很小Q 值整体往 0 收敛一样能把 loss 压下来。这时候如果把 loss 当作训练进度很容易白调一周参数。解决把成功率当成唯一主指标loss 只能当作参考。具体做法是每 100 个 episode 统计一次“到达终点的次数”再把这个窗口值打印出来。这个数值大于 0 之前不需要过度关注 loss 曲线。5.2 1000回合不收敛只在起点附近打转探索不足或奖励太稀疏现象训练到最后智能体从起点出发总是反复上下移动或者贴着墙走偶尔走远一点又退回来。原因最常见的是 epsilon 衰减过快。训练不到 500 回合 epsilon 就降到接近 0随机探索消失Q 网络只能靠之前积累的少量有效经验学习而那些经验里“到达终点”的正样本可能只有一两条梯度完全被负样本淹没。另一个原因是奖励函数里没有给撞墙足够的惩罚智能体撞墙后没有形成回避行为。解决先把 epsilon_decay 从 0.995 调成 0.998让探索期拉长。同时检查奖励普通步 -0.01、撞墙 -0.1 的组合对 4x4 迷宫够用如果把普通步惩罚设成 -1智能体会倾向站在原地不动因为任何移动都是亏的。碰到奖励设计拿不准的情况可以在 reset 里固定随机起点附近的位置做小规模调试快速验证训练曲线能不能起来。5.3 中途一度能到终点后面又“失忆”目标网络与经验混合的问题现象训练到第 500 回合成功率到 0.5再往后续练成功率反而跌回 0.2 甚至更低看起来像模型“失忆”了。原因一是 target_update 设置太小目标网络跟着当前网络频繁变动价值估计始终不稳二是回放池里混合了大量早期无效经验每隔一段时间采样的分布发生明显变化导致 Q 值重排。训练中后期出现轻微波动是正常的但如果大幅回落优先查目标网络同步频率。解决把 target_update 从 200 调到 500 或 800降低目标更新带来的冲击。另外建议每 200 个 episode 保存一次当前 policy_net 的权重训练结束后在验证集上选成功率最高的那一版而不是用最后一版。这个“保存中间模型”的习惯在强化学习里非常重要很多项目最终拿出去展示的模型并不是训练日志里的最后一个。5.4 程序能跑但训练速度很慢别把小型DQN放到GPU上现象明明开了 GPU训练一个 4x4 迷宫却比 CPU 还慢显存占用也不高训练速度几十秒一个 episode。原因迷宫的状态只有 2 维网络是两层 64 隐藏单元的 MLP计算量极小。GPU 把数据从 CPU 拷贝到显存、再从显存拷回来的开销远大于网络本身的计算量遇到这种小模型GPU 不仅帮不上忙反而成为瓶颈。解决迷宫这个规模直接强制 CPU 训练。把模型和张量都保持在 CPU 上不加.to(device)逻辑训练速度会明显更快。真正值得上 GPU 的场景是 Atari 这类图像输入任务网络里有卷积层状态预处理也重。做 DQN 调试时先想清楚计算瓶颈在哪里再决定要不要上 GPU。5.5 换一个迷宫布局同样的参数就不收敛状态表示与训练任务边界现象把 maze 列表换成 5x5 的新布局其他代码不动重新训练后成功率一直上不去甚至完全无法到达终点。原因4x4 迷宫的路径长度短探索相对容易5x5 迷宫路径更长终点稀疏同样的奖励设置下正样本比例更低。另外归一化方式是按当前迷宫尺寸做的换尺寸后状态分布变化很小网络结构不用动但训练难度确实变了。解决换布局时先按网格数放大训练回合数比如 5x5 迷宫从 1000 episodes 起调必要时把 max_steps 从 100 加到 200。如果仍然不收敛可以尝试给单步奖励加一点“接近终点”的正反馈比如曼哈顿距离惩罚这会牺牲一定的通用性但能极大缓解稀疏奖励问题。要记住一个边界DQN 学到的是“这张图上从起点到终点”的策略想让它具备跨布局的泛化能力需要在训练阶段随机切换多种迷宫布局那是另一个层面的问题。6. 用固定策略跑一百轮DQN泛化能力验证与随机起点测试6.1 成功率测试脚本把探索关掉再看真实水平训练时策略里混着随机动作日志里的成功率并不是策略的真实能力。验证要单独写一段评估代码把 epsilon 置 0让策略完全按 Q 值最大选动作连续跑 100 个完整 episode 统计成功率。# test_dqn.py —— 固定策略成功率测试 import torch from maze_env import MazeEnv from model import QNetwork def evaluate(policy_net, env, episodes100): success 0 for _ in range(episodes): state, _ env.reset() done False while not done: with torch.no_grad(): state_t torch.tensor(state, dtypetorch.float32).unsqueeze(0) action policy_net(state_t).argmax(dim1).item() state, reward, terminated, truncated, info env.step(action) if info[success]: success 1 break done terminated or truncated return success / episodes训练结束后拿最优权重跑一遍如果成功率低于训练窗口的最近 100 回合均值说明训练日志里的数字有“探索红利”真实策略并不稳。另一个值得做的验证是随机起点测试把 MazeEnv 的 reset 改成随机选择一个非终点格子作为起点再跑同一段 evaluate。如果成功率断崖式下降说明策略只是记住了从固定起点到终点的一条路径并没有学会“迷宫导航”。这个结果不丢人它只是帮你划清当前模型的能力边界。想继续提升可以考虑在训练时随机起点、多迷宫布局混合训练或者引入优先经验回放。6.2 下一步从迷宫走向更深的深度强化学习场景迷宫 DQN 跑通之后扩展方向很明确。可以把 Q 网络从 MLP 换成带卷积的版本输入改成迷宫的局部栅格图考验特征提取能力也可以把标准 DQN 升级成 Double DQN用当前网络选动作、目标网络估值解决 Q 值高估问题修改量很小。还有 Dueling DQN把 Q 值拆成状态价值和动作优势两部分在回报稀疏时更稳。我自己早期调 DQN 时第一周几乎都在调学习率后来才意识到成功率才是最诚实的指标。训练曲线像一团毛线但只要你把评估脚本写清楚把模型多存几个中间版本问题总能定位到某一个具体环节。迷宫只是验证这些思路的最小场地花半天跑通它比在大型任务里反复“翻车”再回头补课划算得多。希望帮到你。本文还有配套的精品资源点击获取