资讯动态

DQN从Q-Learning到深度强化学习:经验回放与目标网络拆解

发布时间:2026/10/20 9:45:49 来源:尧图企业网站定制
《强化学习从入门到精通》2026 版系列写到第 49 篇这次我们把 DQN 重新完整拆一遍。标题里专门写了“再聊 Q Learning 方法”不是说 DQN 又多难而是要强调它的根基就是 Q Learning把原来查表的 Q 表格换成一个深度神经网络来做函数逼近。这个替换解决了高维状态空间下表格存不下的问题但同时也带来两个经典的新麻烦——样本相关性和训练目标不稳定。DQN 当年能成功核心就是靠经验回放Experience Replay和目标网络Target Network两个机制把这两个麻烦按住了。这篇文章不是单纯念公式而是按“为什么需要 DQN - 关键机制拆解 - 最小可运行代码 - 训练效果观察 - 参数调优”的顺序走一遍。如果你已经学完 Q Learning正处在“表格法到深度强化学习”的过渡阶段这一篇可以直接收藏。文章最后会给一套在 CartPole-v1 上能直接跑起来的最小实现以及训练过程中最容易踩的坑和排查思路。1. 核心知识点速览能力项说明算法定位基于价值Value-Based的深度强化学习算法深度化后的 Q Learning算法来源DeepMind 在 2015 年发表的 DQN 工作核心贡献是经验回放与目标网络适合状态空间高维连续状态输入比如游戏画面、传感器数据、环境特征向量适合动作空间离散动作动作数量不多时可以枚举 Q 值前置知识Q Learning 表格更新、神经网络基础、PyTorch 基础训练环境CartPole-v1单 CPU 即可完成训练不需要 GPU本文代码框架Python PyTorch gymnasium是否覆盖 API本篇不涉及 HTTP API重点是训练算法本身是否支持批量任务不支持批量推理但支持批量调参实验和并行训练多个种子最容易踩的坑gym 与 gymnasium 的 API 差异、目标网络更新节奏、epsilon 衰减过快这篇的内容重点不在“背公式”而在建立一条清晰的逻辑链Q Learning 哪里不行 - DQN 怎么改 - 改完以后为什么能稳定训练 - 代码里哪个参数对应哪个机制。2. 从 Q Learning 到 DQN为什么要换一张“Q 表”2.1 Q Learning 的表格假设经典的 Q Learning 维护一张 Q 表格表格的行是状态 s列是动作 a单元格存对应的动作价值 Q(s, a)。更新公式是Q(s, a) - Q(s, a) alpha * [r gamma * max_a Q(s, a) - Q(s, a)]这个式子统计了 Q Learning 的核心逻辑用下一步所有动作里最大的那个 Q 值加上即时奖励去修正当前动作的 Q 值估计。表格法的优点是干净、直观、理论性质清楚状态空间和动作空间都是有限离散时收敛性有保证。但它有一个天然前提状态必须能被枚举出来而且数量不能太大。一旦状态是一个连续值比如小车的位置、速度、角度就没法直接塞进表格里如果状态是游戏画面每个像素都有 256 种取值任何一张表都存不下。对 CartPole 这种 4 维连续状态来说强行用 Q Learning 也不是不行办法是把每个维度手动分桶离散化比如角度分 10 个区间、速度分 10 个区间组合出来的状态就有上万种。这样处理能做但有两个问题离散化的粒度很难拍太粗会丢失关键信息太细表格会迅速膨胀换一个环境又得重新设计分桶方案完全没有通用性。2.2 用神经网络替代 Q 表格DQN 的思路非常直接不再维护表格而是训练一个神经网络输入状态 s输出每个动作的 Q 值估计也就是 Q(s, a; theta)其中 theta 是网络参数。这样面对连续高维状态不用再手动分桶网络自己提取特征。但“把 Q Learning 的表格换成神经网络”听起来简单直接在原来的更新公式上套用一个非线性网络训练很容易发散。原因有两个第一个是样本相关性问题。强化学习产生的样本是时序相关的当前状态和下一个状态只差一个动作前后样本非常相似。训练神经网络时假设输入的样本是独立同分布的如果拿一串高度相关的状态连续更新网络参数的梯度方向会被这批样本带偏训练极不稳定。第二个是目标不稳定问题。看 Q Learning 的更新公式等号右边用到了 max_a Q(s, a)在表格法里这是一个查表操作表本身更新是渐进式的。但换成神经网络后如果直接用同一个网络去计算目标值那么每一步更新都在“追逐一个会移动的目标”。网络参数一变目标值也变于是损失函数的靶心一直在动训练自然震荡。DQN 文章里提出的两个关键机制就是分别对应这两个问题。后面两节逐个拆。3. DQN 的两个关键机制与损失函数3.1 经验回放打断样本相关性经验回放的做法是维护一个固定容量的缓冲区 Replay Buffer。训练时每一步环境交互产生的s, a, r, s, done元组先放进缓冲区而不是立刻用于更新网络。更新网络时从缓冲区里随机均匀抽一小批样本出来计算梯度。随机抽样的效果是一批样本来自不同时间点的交互记录它们之间的时序相关性被打散了更接近神经网络训练要求的独立同分布假设。同时一条样本可以被多次抽到样本利用率比“用一次就扔”的方式高很多这在训练早期数据量少的时候尤其重要。实践中要注意缓冲区容量和抽样方式。容量太小缓冲区里都是最近几局的数据相关性还是很大容量太大早期随机探索产生的旧样本占比太高又会拖慢新策略的学习。常见做法是设一个较大的容量比如 10000 到 100000并且只在缓冲区样本数超过 batch size 之后再开始训练。3.2 目标网络固定一个稳定的靶心目标不稳定的解决办法是准备两个结构一模一样的网络一个是在线网络 online_net负责选择动作和计算当前 Q 值梯度只更新这个网络另一个是目标网络 target_net负责计算目标值里的 max_a Q(s, a)。目标网络的参数是从在线网络复制过来的但不是每一步都复制而是每隔固定步数同步一次。这样在一个同步周期内目标网络是固定的损失函数里的 target 稳定不变在线网络就有机会朝着一个平稳的目标去拟合。等到同步的时候再把在线网络的最新参数复制过去刷新一次靶心。两个网络的同步有两种常见方式同步方式做法特点硬更新Hard Update每 N 步直接令 target_net 参数等于 online_net 参数简单直接N 通常取 100 到 1000实现成本低软更新Soft Update / Polyak每步都做 tau 比例的融合更新参数更平滑tau 常取 0.005 到 0.01适合对稳定性要求更高的场景对于入门实现硬更新最简单。先用硬更新跑通训练流程再改成软更新看效果差异这个对比本身就能加深理解。3.3 DQN 的损失函数DQN 的损失函数就是把 Q Learning 的更新差值改成均方误差L E[(r gamma * max_a Q(s, a; theta_target) - Q(s, a; theta_online))^2]注意右边第一项里的 Q 用的是目标网络参数 theta_target而且梯度不回传到这一路左边的 Q 才是在线网络输出梯度从这里反传。用 PyTorch 实现时目标值部分要加 detach()否则会把梯度传回到目标网络上破坏固定目标机制。完整的一次更新流程可以概括成五步在环境中执行动作收集样本s, a, r, s, done。把样本写入 Replay Buffer。从 Buffer 随机采样一个 batch。用在线网络计算当前动作的 Q 值。用目标网络计算下一状态的最大 Q 值拼出 target做一次梯度下降。这五步会在每个训练 step 里反复执行直到训练结束。理解了这五步DQN 的代码结构就清楚了。4. 环境准备与版本说明本文代码基于 Python 3.10、PyTorch 2.x、gymnasium。这里单独说一下环境库的选择gymnasium 是 OpenAI Gym 维护权移交后的社区维护版本也是目前大多数强化学习教程在用的新接口。老版本 gym 的 API 在新的 Python 版本上容易报兼容问题所以 2026 版系列统一建议使用 gymnasium。安装依赖pip install torch gymnasium numpy如果本机已经装了老版 gym建议把它卸载或隔离到独立虚拟环境避免两个库的接口混在一起。刚安装完可以先跑一个环境自检脚本python -c import gymnasium as gym; env gym.make(CartPole-v1); print(env.observation_space); print(env.action_space)正常输出会显示观察空间形状为 Box(4,)动作空间为 Discrete(2)。硬件方面CartPole-v1 是经典小型控制环境只有 4 维状态输入和 2 个离散动作单 CPU 训练完全足够不需要 GPU。如果后续要跑图像类环境比如 Atari 游戏才需要认真考虑显存和算力。运行训练脚本时建议在项目根目录建三个文件夹models 保存网络权重logs 保存训练日志results 保存评估结果。这样后面做多组参数实验时每一组实验的产物都能对应到具体目录不会一锅乱放。5. DQN 最小可运行实现下面是一份可以直接复制运行的 DQN 实现按“网络定义 - 经验回放 - 训练主循环”三块组织。这个实现不做任何花哨包装目的是看清 DQN 的核心逻辑。5.1 Q 网络定义输入是状态维度输出是每个动作的 Q 值。用两层隐藏层加 ReLU 激活对 CartPole 来说容量足够。import random from collections import deque import numpy as np import torch import torch.nn as nn import torch.optim as optim import gymnasium as gym class DQN(nn.Module): def __init__(self, state_dim, action_dim, 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)5.2 经验回放缓冲区缓冲区用 deque 实现容量固定自动覆盖最老的样本。class ReplayBuffer: def __init__(self, capacity10000): 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) states torch.FloatTensor(np.array(states)) actions torch.LongTensor(np.array(actions)).unsqueeze(1) rewards torch.FloatTensor(np.array(rewards)).unsqueeze(1) next_states torch.FloatTensor(np.array(next_states)) dones torch.FloatTensor(np.array(dones)).unsqueeze(1) return states, actions, rewards, next_states, dones def __len__(self): return len(self.buffer)dones 用 0/1 表示是否终止计算 target 时直接乘上 (1 - done)终止状态的下一状态 Q 值不参与计算。5.3 训练主循环env gym.make(CartPole-v1) state_dim env.observation_space.shape[0] action_dim env.action_space.n online_net DQN(state_dim, action_dim) target_net DQN(state_dim, action_dim) target_net.load_state_dict(online_net.state_dict()) target_net.eval() optimizer optim.Adam(online_net.parameters(), lr1e-3) buffer ReplayBuffer(capacity10000) gamma 0.99 batch_size 64 target_update_freq 200 epsilon_start 1.0 epsilon_end 0.01 epsilon_decay 0.995 num_episodes 400 step_count 0 for episode in range(num_episodes): state, _ env.reset() total_reward 0 done False while not done: epsilon epsilon_end (epsilon_start - epsilon_end) * (epsilon_decay ** step_count) if random.random() epsilon: action env.action_space.sample() else: with torch.no_grad(): q_values online_net(torch.FloatTensor(state).unsqueeze(0)) action q_values.argmax().item() next_state, reward, terminated, truncated, _ env.step(action) done terminated or truncated buffer.push(state, action, reward, next_state, float(done)) state next_state total_reward reward step_count 1 if len(buffer) batch_size: states, actions, rewards, next_states, dones buffer.sample(batch_size) q_values online_net(states).gather(1, actions) with torch.no_grad(): next_q_values target_net(next_states).max(1, keepdimTrue).values targets rewards gamma * next_q_values * (1 - dones) loss nn.MSELoss()(q_values, targets) optimizer.zero_grad() loss.backward() optimizer.step() if step_count % target_update_freq 0: target_net.load_state_dict(online_net.state_dict()) if (episode 1) % 20 0: print(fEpisode {episode 1}, reward: {total_reward:.1f}, epsilon: {epsilon:.3f}) torch.save(online_net.state_dict(), models/dqn_cartpole.pt)训练结束后权重会保存到 models 目录下。epoch 循环里的 epsilon 计算取了 step_count也可以换成 episode 维度但步数维度的衰减更平滑在长 episode 里不会衰减太快。6. 训练效果验证与观察点6.1 正常训练应该看到什么CartPole-v1 每一局最多持续 500 步步数越多代表策略越好。第一次运行训练脚本时不要只盯着最后的数字看应该重点观察哪几个阶段第一阶段是随机探索期。大约前几十个 episodereward 会在几十步上下波动这是 epsilon 还很大、策略接近随机的情况。这个阶段损失函数也会忽高忽低不要因为 loss 不降就认为代码写错。第二阶段是快速学习期。reward 会从几十步开始快速爬升甚至出现连胜。这时候能明显看到损失函数整体下降但噪声仍然很大。第三阶段是收敛平台期。reward 会接近 500 的上限中间偶尔会出现一个很差的 episode把平均分数拉下来。这种现象在 DQN 里非常正常因为 epsilon 没有完全衰减到 0探索仍然存在目标网络每 200 步同步一次刷新后还会带来一段短暂的不稳定。判断训练成功的标准是最近 100 个 episode 的平均 reward 稳定超过 450并且持续观察到多个接近 500 的 episode。如果你看到某个 episode 一次性达到 500先别急着下结论继续训练几十局确认不是偶然。训练过程中还可以在环境可视化窗口打开的情况下运行观察小车是否能在杆子接近失衡时快速反向修正角度。这个直观的画面比 scalar 曲线更容易判断策略是否“真的学会了”。6.2 DQN 与 Q Learning 的对比验证这一节回到标题里的“再聊 Q Learning”。如果你想亲眼确认“为什么 DQN 值得存在”可以做一个对比实验在同一份 CartPole-v1 环境里写一个最朴素的表格 Q Learning 实现把 4 维连续状态手动离散化。最常见的做法是把位置、速度、角度、角速度各分 6 到 10 个格子然后查表更新。实验做下来你会发现几个现象第一表格 Q Learning 也能学习但需要精心调离散化的边界。分桶太粗策略会忽略细微状态差异很难稳定到 500 分分桶太细需要探索的状态组合变成几十万训练时间明显拉长。第二DQN 不需要任何状态处理直接把原始 4 维浮点输入交给网络代码结构完全通用。换一个环境只改网络输入维度和动作维度训练主循环不用动。第三目标网络的作用可以和 Q Learning 对照理解。表格法里的 Q(s, a) 更新是单步小幅修正可以理解为天然有一个“惰性目标”DQN 如果不做目标网络在线网络自己追自己训练就容易震荡甚至发散。这个对比实验不需要写很多代码但它能帮你在同一个环境里同时看清两个算法的成本和收益比单纯背公式更有用。7. 常见问题与排查方法DQN 训练类代码的排查方向和普通深度学习不太一样要区分“网络结构问题”和“RL 训练问题”。下面表格列出 CartPole 训练中最高频的几类情况。问题现象可能原因排查方式解决方案reward 一直停留在 20 步以下epsilon 衰减太慢或动作选择逻辑错误打印每个 episode 的 epsilon确认是否在下降调大 epsilon_decay 的衰减速度检查是否在 done 后用 argmax 选择动作训练初期 loss 突然爆炸学习率过大或 reward 未归一化观察 loss 是否超过初始值几个数量级降低 lr 到 3e-4 或 1e-4检查 reward 是否有异常大值reward 稳步上升后突然回退到低位目标网络更新频率太高目标移动过快记录 reward 回退发生的位置是否与 target 更新对齐调大 target_update_freq或者改用软更新训练很久卡在 200 分上不去网络容量不足或 epsilon 衰减过早终止看策略是否在动作边缘反复犹豫增加隐藏层宽度到 256重新放开末尾段 epsilon运行报 reset() 相关错误老 gym 接口与新 gymnasium 冲突检查环境库版本与调用代码统一使用 gymnasium并确认代码里 reset 接收两个返回值模型保存后无法复现分数加载模型后忘记设 eval 模式在推理代码里检查是否调用了 eval()推理时调用 model.eval()并关闭梯度计算loss 下降但 reward 不涨Q 值估计和策略改进存在滞后用随机策略跑 100 局看 baseline确认随机 baseline 分数再判断模型是否有效其中最容易忽略的是老 gym 的接口差异。新版 gymnasium 的 env.reset() 返回observation, info两个值env.step() 返回observation, reward, terminated, truncated, info五个值。如果你从 2020 年左右的教程复制代码很有可能会写错这一步导致 terminated 没有正确读取最后一局的终止状态被当成正常状态反复训练策略会变得非常差。环境安装方面如果 torch 安装时显卡驱动异常建议先切到 CPU 跑通流程python -c import torch; print(torch.cuda.is_available())输出 False 也能正常训练 CartPole因为这篇代码里的网络规模非常小GPU 不是必须条件。等后续换到图像类环境再回头处理 CUDA 环境。8. 参数调优与工程化建议训练跑通以后下一步不是直接加复杂算法而是把 DQN 的几个核心参数逐个做对照实验。这里给一套适合入门的调参顺序。参数默认值调参方向经验说明学习率 lr1e-31e-4 到 3e-4 更稳学习率过大是 DQN 训练爆炸的常见原因gamma0.99连接近 1 表示更看重长线收益任务周期长时提高 gamma噪声大时适当降低epsilon_decay0.9950.99 到 0.999衰减过快会导致探索不足reward 停在低位batch_size6432 到 256batch 太大训练慢太小梯度噪声明显回放容量1000010000 到 100000容量小相关性大容量大旧样本占比高隐藏层宽度128128 到 256CartPole 用 128 足够复杂环境再加大target_update_freq200100 到 1000更新太频繁目标不稳太慢学习滞后工程化上建议做三件事。第一每次实验固定一个随机种子。RL 训练的随机性远大于监督学习同一个参数跑两次可能得到明显不同的曲线。固定种子后才能公平地比较不同参数的优劣。第二按“一组参数一个目录”的方式管理实验。比如参数目录下分 models、logs、results 三个子目录训练脚本自动写入 config.json 记录当前超参数。这样复盘的时候不用猜“这次实验到底用的是哪个 lr”。第三用脚本批量跑多组参数实验。常见的做法是写一个参数扫描器循环生成多个配置文件逐个启动训练进程。CartPole 训练一次只需要十几分钟完全可以在本机按顺序跑完。注意每个实验要单独指定日志文件避免多个进程同时写同一个 log。接口 API 方面本片算法本身不涉及 HTTP 接口。如果你后面想把训练好的 DQN 策略部署成服务最简单的做法是保留训练脚本里的模型加载逻辑再写一个很薄的推理接口输入状态向量输出动作编号。这个部署方式只适合教学演示在真实控制场景中还需要考虑动作平滑限制、安全约束、环境异常恢复等额外机制不能直接把仿真环境的策略原样接进物理设备。9. 总结与下一步这篇你应该重点掌握的不是 DQN 网络的某层结构而是两条逻辑主线一是 Q Learning 的表格更新如何一步步演变成 DQN 的均方误差损失二是经验回放和目标网络分别解决了时序相关性和训练目标不稳定性。这两条线串起来DQN 就不再是一堆零散代码而是一个有明确设计动机的算法框架。建议你先做的最小验证是把第 5 节的代码原样跑一遍确认能看到 reward 从随机水平爬升到接近 500并把训练过程打印的 episode 日志画成曲线。这一步跑通了再依次截图记录一组 lr、epsilon_decay 的对照实验就不难看出每个参数的影响。最容易踩的坑按优先级排列gymnasium 的 reset 返回值写错、epsilon 衰减过快导致探索不足、target 网络更新太频繁导致训练回退。这三类问题在入门阶段出现频率最高排查时先看这三个位置。下一步可以在 DQN 主干上做四个方向的扩展Double DQN把目标值里的 max 拆成“在线网络选动作、目标网络算值”缓解 Q 值过估计。Dueling DQN把 Q 值拆成状态价值和动作优势两部分在动作空间大的任务里收敛更稳。优先经验回放PER让 TD 误差大的样本有更高概率被抽中提高样本利用率。Noisy DQN用带噪声的网络层替代 epsilon-greedy 探索让探索本身变成可学习的一部分。这四个方向每一个都可以在现有代码框架上做一个多小时就能完成的改造也是后续扩展到连续动作控制类算法PPO、SAC、TD3之前值得打的底座。下一篇我们接着从 Double DQN 入手重点看 Q 值过估计的问题是怎么被缓解的。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑