首页 / 资讯中心 / 文章详情

DQN原理与PyTorch实现:从Q-Learning到经验回放+目标网络

DQN原理与PyTorch实现:从Q-Learning到经验回放+目标网络 ★ FEATURED ARTICLE
很多学习强化学习的读者在接触 DQN 之前多半已经能用 Q-Learning 在一个小网格、迷宫或者简单的表格环境里跑通一个 Agent。那一刻你会觉得强化学习好像也没那么复杂更新公式背下来迭代几百轮Q 表收敛策略就出来了。但一旦你把问题换成稍微真实一点的场景——比如输入是图像或者状态是连续值Q 表就立刻崩掉了。为什么因为 Q 表的规模随着状态空间呈指数级增长。国际象棋的状态数已经超过 10 的 40 次方围棋更是高到无法用表格索引。表格型 Q-Learning 能解决的问题本质上都停留在“玩具环境”层面。DQN 解决的就是这个问题。它的核心改动是用神经网络代替 Q 表但如果你以为 DQN 只是“Q-Learning 一个深度学习网络”那就漏掉了最关键的判断直接从表格更新切到神经网络训练算法根本不收敛。DQN 真正的贡献是围绕神经网络训练重新设计了一套稳定机制包括经验回放Experience Replay和目标网络Target Network。这篇文章会从 Q-Learning 的更新公式出发讲清楚 DQN 到底改了什么、为什么这样改然后给出一个可复制的 PyTorch 最小实现在 Gymnasium 的 CartPole 环境上完整跑通训练和验证。看完之后你就能回答三个问题DQN 的三个关键机制各自解决什么痛点训练不收敛时应该先检查哪里从 DQN 往后强化学习应该往哪些方向继续深入。1. 从 Q-Learning 到 DQN到底改了什么先回顾表格 Q-Learning 的更新公式Q(s, a) - Q(s, a) alpha * (r gamma * max(Q(s, a)) - Q(s, a))这个公式的意思是Agent 在状态 s 执行动作 a环境返回奖励 r 并转移到新状态 s然后用r gamma * max(Q(s, a))作为“真实价值”的估计去修正原来的 Q(s, a)。这个估计叫时序差分目标TD Target更新的幅度由学习率 alpha 控制。表格方法的逻辑非常清晰状态 s 和动作 a 作为下标Q 值直接存在表里。但表格方法有三个硬伤连续状态无法索引。如果状态是温度、位置、速度这种连续值不可能为每一个小数建一行。维度灾难。即使状态离散只要维度增加表的大小就会指数膨胀。无法泛化。表格里的每个状态都是独立的学过的状态对没见过的状态没有帮助。DQN 的思路是把查找表变成函数逼近用一个带参数 theta 的神经网络表示Q(s, a; theta)。网络输入状态 s输出所有动作的 Q 值。这样相近的状态会得到相近的输出天然具备泛化能力。但问题也随之而来。表格更新一次只影响一个格子神经网络参数更新一次会改变所有状态下的输出。这意味着训练信号之间会产生强烈的干扰。如果只是简单套用 Q-Learning 的更新公式把网络当表用训练过程会剧烈震荡甚至发散。DQN 能稳定工作的前提是另外两个设计——经验回放和目标网络——真正解决了样本相关性和目标漂移问题。2. Q-Learning 复习先明确一个学习闭环在进入 DQN 之前有必要把 Q-Learning 的完整逻辑再过一遍。因为后面所有 DQN 的改进都是围绕它的损失函数和采样过程展开的。强化学习问题通常建模为马尔可夫决策过程MDP由五元组(S, A, P, R, gamma)描述符号含义通俗解释S状态集合Agent 当前所处的局面A动作集合Agent 可以选择的行动P状态转移概率执行动作后环境如何变化R奖励函数每个状态动作组合获得的即时反馈gamma折扣因子未来奖励相对当前的价值衰减程度Agent 的目标是最大化长期累积回报。考虑折扣因子后从状态 s 出发的累积回报为G_t r_t gamma * r_{t1} gamma^2 * r_{t2} ...动作价值函数 Q(s, a) 表示在状态 s 执行动作 a 后按照某个策略继续行动所能获得的期望回报。最优动作价值函数 Q* 满足贝尔曼最优方程Q*(s, a) r gamma * max(Q*(s, a))这个方程的本质是当前状态动作的价值等于即时奖励加上下一个状态的最优价值。Q-Learning 的更新公式正是对这个方程的迭代逼近。Q-Learning 是离线策略Off-Policy算法它用行为策略采集数据却用贪心策略max Q(s, a)更新价值。这样做的优势是可以在探索过程中逐步逼近最优 Q 值而不需要当前策略已经是最优策略。整个过程可以用下面这段伪代码概括初始化 Q(s, a) 为任意值 重复每个 episode 初始化状态 s 重复每个 step 用 epsilon 贪心策略根据当前 Q 选择动作 a 执行 a获得奖励 r 和新状态 s 更新 Q(s, a) - Q(s, a) alpha * (r gamma * max Q(s, a) - Q(s, a)) s - s 直到 s 为终止状态看到这里你应该明白Q-Learning 的更新本身并不复杂。它的局限不在公式而在“Q 表”这个存储载体。DQN 恰恰是从载体上下手把表的存储和查找替换成神经网络的参数化和前向计算。3. DQN 的三个关键机制DQN 论文Nature 版本里最重要的改进有三个神经网络函数逼近、经验回放、目标网络。下面逐一分析每个机制解决的问题。3.1 神经网络函数逼近DQN 的网络结构很直接输入是状态的特征向量输出是每个动作的 Q 值。对于 CartPole 这种输入只有 4 个连续值的小环境两到三层全连接网络就足够对于 Atari 游戏输入是 84x84 的灰度图像则需要卷积神经网络。用神经网络做 Q 值逼近最大的好处是泛化。表格方法里状态 (0.1, 0.2) 和状态 (0.1, 0.3) 是完全没有关联的两个格子但在神经网络里它们会映射到相近的特征空间输出自然接近。这是 DQN 能应对连续和高维状态的根本原因。代价是训练不稳定。每次梯度下降都在改动网络的整体参数可能出现“学会一个状态、忘了另一个状态”的灾难性遗忘。所以单靠网络替代 Q 表远远不够。3.2 经验回放Experience ReplayQ-Learning 从环境中按时间顺序采样连续样本之间存在强烈的相关性。例如在 CartPole 中第 1 帧和第 2 帧的画面非常相似如果直接用这些连续样本做梯度更新梯度方向会偏向最近的经历导致训练震荡。经验回放的做法是把每次交互产生的转移样本(s, a, r, s, done)存进一个固定大小的缓冲区训练时从中随机采样一个小批量。这带来两个好处打破样本相关性。随机采样让每个 batch 里的样本尽量来自不同的时间点梯度估计更接近独立同分布。提高样本利用率。一条样本可以被多次使用尤其适合样本采集成本高的真实环境。经验回放并不复杂代码上就是一个带有最大容量的队列满员后自动丢弃旧样本。但它是 DQN 稳定训练的基础。3.3 目标网络Target Network如果网络同时扮演两个角色——“预测当前 Q 值”和“计算 TD 目标”就会出现一个问题每次更新参数后TD 目标也随之变化。这相当于一个移动靶模型追着不断漂移的目标训练很难收敛。DQN 的解决办法是维护两份网络参数策略网络policy_net负责输出当前动作的 Q 值也承担梯度更新。目标网络target_net负责计算 TD 目标中的max Q(s, a)参数每隔固定步数才从策略网络复制一次。训练过程中策略网络只更新自身参数目标网络保持相对稳定。这样 TD 目标在一段时间内是固定的训练变成了一个稳定的回归问题。目标网络不是只要更新越频繁越好太频繁会重新引入目标漂移太少则会让目标过时一般每 50 到 200 步同步一次是比较常用的选择。把三个机制合起来看DQN 的每一步更新都遵循这个流程从经验缓冲区随机采样一个 batch 用策略网络计算当前状态动作的 Q 值 用目标网络计算下一状态的最大 Q 值构造 TD 目标 计算两者之间的均方误差或 Huber 损失 反向传播更新策略网络 每隔 N 步将策略网络参数复制给目标网络4. 环境准备与依赖安装动手实现之前先把运行环境准备好。本文的示例代码基于 Python 和 PyTorch环境使用 Gymnasium。这里特别提醒一点如果你过去习惯使用gym请注意新版项目已经改名为gymnasiumAPI 也有一些变化。新版env.step()返回五个值observation、reward、terminated、truncated、info其中terminated表示智能体是否因达到终止条件而结束truncated表示是否因超过步数上限或环境超时而被截断。判断 episode 是否结束需要把两者取或。安装依赖只需两条命令# 创建虚拟环境推荐 python -m venv rl_env source rl_env/bin/activate # Windows 下使用 rl_env\Scripts\activate pip install torch gymnasium matplotlib numpy安装完成后可以用下面的代码验证环境是否可用import gymnasium as gym env gym.make(CartPole-v1) print(观测空间:, env.observation_space) print(动作空间:, env.action_space)CartPole-v1 的观测空间是 4 个连续值小车位置、速度、杆子角度、角速度动作空间只有两个向左和向右。目标是通过左右移动小车让杆子尽量保持垂直。每个 step 存活奖励为 1episode 默认在 500 步时截断。对于 DQN 入门这个环境是最合适的试验场。5. DQN 最小实现从零跑通 CartPole下面给出一个完整的 DQN 实现可以保存为dqn_cartpole.py后直接运行。代码刻意保持结构清晰没有做过度封装方便你逐行理解。5.1 定义 Q 网络import random from collections import deque import gymnasium as gym import matplotlib.pyplot as plt import numpy as np import torch import torch.nn as nn import torch.optim as optim class DQNNetwork(nn.Module): def __init__(self, state_dim, action_dim, hidden_size128): super(DQNNetwork, self).__init__() self.fc1 nn.Linear(state_dim, hidden_size) self.fc2 nn.Linear(hidden_size, hidden_size) self.fc3 nn.Linear(hidden_size, action_dim) def forward(self, x): x torch.relu(self.fc1(x)) x torch.relu(self.fc2(x)) return self.fc3(x)这个网络的结构非常简单输入 4 维状态经过两个 128 维的隐藏层输出 2 个动作的 Q 值。隐藏层的激活函数用 ReLU最后一层不接激活因为 Q 值理论上可以是任意实数。5.2 经验回放缓冲区class ReplayBuffer: def __init__(self, capacity): 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 np.array([b[0] for b in batch], dtypenp.float32) actions np.array([b[1] for b in batch], dtypenp.int64) rewards np.array([b[2] for b in batch], dtypenp.float32) next_states np.array([b[3] for b in batch], dtypenp.float32) dones np.array([b[4] for b in batch], dtypenp.float32) return states, actions, rewards, next_states, dones def __len__(self): return len(self.buffer)这里用 Python 标准库的deque实现固定容量队列容量满了之后最早的经验会自然丢弃。采样时通过random.sample随机选取一小批样本这正是“打破相关性”的关键。5.3 DQN Agentclass DQNAgent: def __init__(self, state_dim, action_dim, lr1e-3, gamma0.99, batch_size32, buffer_size10000, target_update_freq100): self.state_dim state_dim self.action_dim action_dim self.gamma gamma self.batch_size batch_size self.target_update_freq target_update_freq self.policy_net DQNNetwork(state_dim, action_dim) self.target_net DQNNetwork(state_dim, action_dim) self.target_net.load_state_dict(self.policy_net.state_dict()) # 目标网络只用于计算目标不参与梯度更新 self.target_net.eval() self.optimizer optim.Adam(self.policy_net.parameters(), lrlr) self.loss_fn nn.SmoothL1Loss() # Huber 损失比 MSE 对异常值更稳健 self.memory ReplayBuffer(buffer_size) self.total_steps 0 def choose_action(self, state, epsilon): 使用 epsilon 贪心策略选择动作 if random.random() epsilon: return random.randint(0, self.action_dim - 1) with torch.no_grad(): state_tensor torch.FloatTensor(state).unsqueeze(0) q_values self.policy_net(state_tensor) return int(q_values.argmax(dim1).item()) def store_transition(self, state, action, reward, next_state, done): self.memory.push(state, action, reward, next_state, done) def update(self): 从经验池采样一个 batch执行一次梯度更新 if len(self.memory) self.batch_size: return 0.0 states, actions, rewards, next_states, dones self.memory.sample(self.batch_size) states_t torch.FloatTensor(states) actions_t torch.LongTensor(actions).unsqueeze(-1) rewards_t torch.FloatTensor(rewards).unsqueeze(-1) next_states_t torch.FloatTensor(next_states) dones_t torch.FloatTensor(dones).unsqueeze(-1) # 策略网络输出当前状态动作的 Q 值 q_pred self.policy_net(states_t).gather(1, actions_t) # 用目标网络计算下一状态的最大 Q 值构造 TD 目标 with torch.no_grad(): q_next self.target_net(next_states_t).max(dim1, keepdimTrue).values q_target rewards_t self.gamma * q_next * (1 - dones_t) # 计算损失并反向传播 loss self.loss_fn(q_pred, q_target) self.optimizer.zero_grad() loss.backward() # 梯度的值不宜过大可以视情况引入梯度裁剪 torch.nn.utils.clip_grad_norm_(self.policy_net.parameters(), max_norm1.0) self.optimizer.step() self.total_steps 1 if self.total_steps % self.target_update_freq 0: self.target_net.load_state_dict(self.policy_net.state_dict()) return loss.item()这里有一个非常值得注意的细节q_target的计算被放进了torch.no_grad()里。因为目标网络的更新频率较低我们只需要用它计算目标值不希望梯度经过它传播。否则梯度会同时更新两个网络目标漂移问题会再次出现。损失函数选择了SmoothL1Loss也就是 Huber 损失。它结合了 MSE 在零点附近的平滑性和 MAE 在大误差时的鲁棒性当 TD 误差很大时不会产生过大的梯度。5.4 训练主循环def train(episodes500, seed42): env gym.make(CartPole-v1) agent DQNAgent(state_dim4, action_dim2) epsilon 1.0 epsilon_min 0.01 epsilon_decay 0.995 reward_history [] avg_reward_history [] for episode in range(1, episodes 1): state, _ env.reset(seedseed episode) total_reward 0 while True: action agent.choose_action(state, epsilon) next_state, reward, terminated, truncated, _ env.step(action) done terminated or truncated agent.store_transition(state, action, reward, next_state, done) agent.update() state next_state total_reward reward if done: break epsilon max(epsilon_min, epsilon * epsilon_decay) reward_history.append(total_reward) avg_reward np.mean(reward_history[-50:]) avg_reward_history.append(avg_reward) if episode % 20 0: print(fEpisode {episode}, Reward: {total_reward}, fAvg(last 50): {avg_reward:.2f}, Epsilon: {epsilon:.3f}) if avg_reward 450: print(f收敛条件达成于第 {episode} 个 episode平均奖励 {avg_reward:.2f}) break env.close() return reward_history, avg_reward_history训练过程中使用了 epsilon 贪心策略。刚开始 epsilon 设为 1.0Agent 几乎完全随机探索随着训练推进epsilon 按 0.995 的速率指数衰减逐渐减少随机动作的比例让 Agent 更多依赖已经学到的策略。这种方式保证了前期有足够多的探索样本后期有足够高的利用率。5.5 运行与绘图if __name__ __main__: rewards, avgs train(episodes500) plt.figure(figsize(10, 5)) plt.plot(rewards, alpha0.4, labelEpisode Reward) plt.plot(avgs, labelAvg Reward (last 50)) plt.xlabel(Episode) plt.ylabel(Reward) plt.legend() plt.title(DQN Training on CartPole-v1) plt.savefig(dqn_cartpole.png, dpi150) print(训练曲线已保存到 dqn_cartpole.png)运行方式python dqn_cartpole.pyCartPole 的状态向量只有 4 维网络非常小CPU 就能完成训练不需要 GPU。这个示例非常适合用来验证你对 DQN 的理解是否正确。6. 运行结果与收敛判断训练开始后你会在终端看到类似这样的输出具体数值会因随机种子和采样过程略有不同Episode 20, Reward: 22, Avg(last 50): 19.50, Epsilon: 0.905 Episode 40, Reward: 41, Avg(last 50): 24.75, Epsilon: 0.820 Episode 60, Reward: 45, Avg(last 50): 31.20, Epsilon: 0.742 Episode 80, Reward: 67, Avg(last 50): 44.35, Epsilon: 0.671从训练行为上看前几十轮 reward 会很低通常只有 20 到 50说明 Agent 还在大量随机探索。随着 epsilon 衰减和经验积累reward 会出现阶段性跳升。一个比较典型的现象是在第 100 到 200 轮之间Agent 突然学会稳定控制杆子一段时间reward 迅速升高到 200 以上之后可能在 200 到 500 之间波动最终稳定在 450 以上。收敛判断标准建议看最近 50 个 episode 的平均奖励。CartPole-v1 的 episode 上限是 500如果你看到平均奖励持续高于 450说明 Agent 已经非常稳定训练可以提前结束。代码中也设置了avg_reward 450的提前停止条件。如果训练结束跑完 500 个 episode后平均奖励仍然只有几十不要急着调神经网络结构优先检查下面几个环节epsilon 衰减是否太快导致后期探索不足经验回放缓冲区容量是否太小目标网络同步频率是否太慢或太快学习率是否设置过大。为了更严谨地评估最终策略建议单独写一个评估函数只用纯贪心策略去玩游戏不加入任何随机探索def evaluate(agent, episodes20, seed0): env gym.make(CartPole-v1) total 0.0 for i in range(episodes): state, _ env.reset(seedseed i) done False while not done: action agent.choose_action(state, epsilon0.0) state, reward, terminated, truncated, _ env.step(action) done terminated or truncated total reward env.close() return total / episodes评估时把epsilon固定为 0Agent 始终选择最大 Q 值对应的动作。这样得到的平均奖励比训练日志中的数值更真实地反映策略水平。7. DQN 常见问题与排查思路DQN 在入门阶段最常见的挫折不是看不懂代码而是“代码能跑但奖励曲线死活不上涨”。下面整理了一份排查表建议收藏备用。问题现象可能原因排查方式解决方案训练曲线一直贴地reward 极低epsilon 衰减太快探索不足打印 epsilon 变化观察后期是否已小于 0.05调大 epsilon_decay如 0.998reward 上不去且震荡剧烈学习率过大或目标网络同步过快观察 loss 是否忽大忽小降低学习率到 1e-4调大 target_update_freqLoss 变成 NaN输入或者梯度爆炸检查 reward 和观测是否有异常值加入梯度裁剪对观测做归一化训练前期 reward 能到 500后期反而下降过拟合或 epsilon 过小导致探索消失绘制最近 50 轮平均曲线引入更大的经验池或调小学习率目标网络同步后性能明显变差目标网络同步不频繁检查 total_steps 是否正常累加适当调小 target_update_freq训练非常慢每个 step 都执行 update但缓冲区还没满打印 len(memory)正常现象缓冲区满之前不更新即可在这些问题里最常见的原因是“经验池里还没有足够多样本就开始更新”以及“epsilon 衰减太快导致后期一直在利用”。需要记住一个基本原则DQN 是离线策略算法它的样本来自过去任意时刻的策略。经验池里的样本多样性才是训练稳定的基础。另外如果你发现代码能跑但是所有动作都选同一个基本可以确定 Q 网络没有学到有效信息。这时候先不要调网络把奖励曲线和 epsilon 曲线画出来看看到底是探索不够还是 TD 目标有问题。8. DQN 最佳实践与工程建议跑通一个 CartPole 示例只是第一步。如果把 DQN 用在实际项目中下面这些工程经验值得提前了解。8.1 超参数设置的基本盘DQN 对超参数比较敏感下面是一组在 CartPole 上常用的默认值可作为起点参数推荐值说明学习率 lr1e-3 到 1e-4过大容易震荡过小收敛慢折扣因子 gamma0.99任务越长gamma 越接近 1batch_size32 到 256影响梯度估计的稳定性经验池容量10000 到 100000与环境复杂度和采样成本成正比target_update_freq50 到 200更新越快目标变化越快epsilon_decay0.99 到 0.998需要保证中期还有一定探索隐藏层宽度64 到 256CartPole 128 足够复杂任务需要更大不要同时调多个超参数。每次只改一个对比训练曲线才能判断哪个因素真正影响效果。8.2 输入归一化CartPole 的四个观测维度数值范围不同小车位置在-4.8到4.8之间角度在约-0.42到0.42之间角速度范围更大。如果不归一化网络训练初期容易被数值范围大的维度主导。使用环境的observation_space.high和observation_space.low做归一化是一个稳健的工程习惯。8.3 梯度裁剪即使有经验回放和目标网络遇到极端奖励或离群样本时梯度依然可能爆炸。在每次loss.backward()之后加一行梯度裁剪是成本最低的稳定手段torch.nn.utils.clip_grad_norm_(self.policy_net.parameters(), max_norm1.0)这一行代码对于复杂环境尤其重要建议在写 DQN 时直接加上。8.4 模型保存与加载训练完成后需要把策略网络保存下来方便后续评估或在真实环境中部署torch.save(agent.policy_net.state_dict(), dqn_cartpole.pth)需要继续训练时可以直接加载权重再跑agent.policy_net.load_state_dict(torch.load(dqn_cartpole.pth)) agent.target_net.load_state_dict(agent.policy_net.state_dict())注意加载模型后目标网络也要同步一次否则目标网络和策略网络的初始参数不一致训练一开始就会不稳定。8.5 固定随机种子强化学习训练本身有大量随机性环境、经验采样、网络初始化都会影响结果。为了方便对比实验固定随机种子几乎是必须的import random import numpy as np import torch random.seed(42) np.random.seed(42) torch.manual_seed(42)在gym.make时也可以给环境指定 seed。这样出现问题时可以精确复现。8.6 评估优先于训练建议在训练循环之外单独维护一个评估函数每隔固定轮数执行一次纯贪心评估。训练时的高 reward 可能是因为 epsilon 较高导致的偶然结果评估曲线才是真实策略水平的体现。把评估曲线和训练曲线画在一张图上能帮你更早发现问题。9. DQN 的局限与进一步学习方向到此为止你已经理解了 DQN 的核心机制也亲手实现并运行了一个完整示例。但入门的终点恰恰是深入理解强化学习的起点。DQN 本身有几个非常明显的局限知道这些局限也就知道下一步该学什么。第一个局限是过估计问题。DQN 在计算 TD 目标时使用max Q(s, a)而最大值操作会引入正向偏差。即使真实的 Q 值相同噪声也会让最大值偏高。这会导致 Agent 对某些动作产生过高的估计。解决方案是 Double DQN用策略网络选择最优动作再用目标网络计算该动作的 Q 值从而降低过估计。第二个局限是样本效率低。虽然经验回放提升了样本利用率但 DQN 本质上还是需要大量交互。在真实机器人、自动驾驶这类采样成本高的场景里样本效率直接决定了项目可行性。后续的 Prioritized Experience Replay 通过按 TD 误差大小加权采样让更“意外”的样本得到更多训练机会这是提升样本效率的有效方向。第三个局限是只能处理离散动作。DQN 输出的是一组动作的 Q 值天然适合离散动作空间。但很多真实问题如机器人控制、无人机飞行动作本身是连续值。解决连续控制问题的主流算法是 Actor-Critic 家族的 DDPG、TD3、SAC以及基于策略梯度的 PPO。学习路径上建议先吃透 DQN 的价值函数逼近思路再过渡到 Actor-Critic 架构。如果按学习进度规划你的下一步可以是实现 Double DQN在 CartPole 上对比它与标准 DQN 的差异加入 Prioritized Experience Replay观察训练速度的变化换一个更复杂的环境比如 LunarLander-v2测试代码的通用性学习策略梯度方法理解价值学习和策略学习两条路线的差别。真正理解 DQN不在于背下三个机制的名字而在于当你面对一条不怎么上涨的训练曲线时能快速判断出问题出在经验采样、目标计算、还是探索策略。这也是从“会跑代码”走向“会调试算法”的关键一步。建议你先把今天的 CartPole 示例跑熟再动手修改一个超参数观察它对收敛速度的影响。有了这个基础后续的强化学习进阶之路会顺畅很多。
阅读完成 · 觉得有帮助?
咨询建站