1. 从“打游戏”到“做决策”:DQN到底在解决什么问题
第一次接触强化学习的人,大概率是从DQN开始的。原因很简单:它足够直观,又足够强大。2013年DeepMind用DQN在Atari 2600游戏上打出超越人类水平的表现,2015年论文登上Nature,直接把“深度强化学习”这个词推到了台前。但很多人学DQN的过程是这样的:看公式觉得懂了,打开代码发现懵了,跑起来不收敛,调参调到怀疑人生。
DQN,全称Deep Q-Network,中文叫深度Q网络。拆开看就三个词:深度、Q、网络。深度指的是用深度神经网络做函数逼近;Q指的是Q值,也就是在某个状态下采取某个动作能获得的长期回报;网络就是那个用来拟合Q值的神经网络。合起来一句话:用神经网络来近似Q函数,让智能体学会在什么状态下该做什么动作。
它解决的核心问题是:当状态空间大到无法用表格存储时,传统的Q-Learning就废了。比如Atari游戏的画面,每一帧是210×160的像素,三通道RGB,状态组合数是天文数字,你不可能建一个表格把每个状态对应的Q值都存下来。DQN的思路是用一个神经网络吃进状态,输出每个动作的Q值,然后用Q-Learning的更新规则来训练这个网络。
适合谁看?如果你已经了解基本的神经网络和Q-Learning,想搞明白DQN的实现细节和踩坑经验,这篇内容就是给你写的。如果你完全没接触过强化学习,建议先补一下马尔可夫决策过程和Bellman方程的基础,不然看下去会比较吃力。
我下面会从整体设计思路、核心机制拆解、完整实操流程、常见问题排查四个维度展开,把DQN从原理到代码到调参的每个环节都讲透。不是教科书式的复述,而是我在实际项目中反复踩坑之后总结出来的经验。
2. DQN整体设计思路与核心机制拆解
2.1 为什么不能直接用Q-Learning加神经网络
最朴素的想法是:既然Q-Learning用表格存Q值,那我把表格换成一个神经网络不就行了?损失函数就是预测Q值和目标Q值的均方误差,梯度下降走起。听起来很合理,但实际跑起来会直接崩掉。
问题出在两个地方。第一,样本之间存在强时序相关性。强化学习的数据是按时间顺序产生的,连续的几帧画面高度相似,如果直接拿这些数据训练神经网络,网络会倾向于记住最近的样本,导致灾难性遗忘。第二,目标Q值在不断变化。Q-Learning的更新目标是 r + γ·max Q(s‘, a’),但这个Q(s‘, a’)是用同一个网络算出来的。网络每更新一次参数,目标就跟着变,相当于你在追一个自己也在跑的靶子,训练极不稳定。
DQN的两个核心创新就是针对这两个问题:经验回放和目标网络。这两个机制是DQN能work的关键,缺一不可。
2.2 经验回放:把“连续剧”变成“随机拼图”
经验回放的思路很直接:既然连续样本有相关性,那我就把所有的经验 (s, a, r, s‘, done) 都存到一个缓冲区里,训练的时候从里面随机采样一批。这样每次拿到的数据是打散的,样本之间近似独立同分布,神经网络的训练就稳定多了。
具体实现上,缓冲区通常用一个固定大小的队列(比如容量10万条),新的经验进来,旧的经验出去。每次训练时从里面随机抽一个batch,比如32条或64条。这里有个细节:缓冲区不能太小。如果容量只有几百条,那随机采样的多样性不够,还是容易过拟合到最近的经验。一般建议至少几万条起步,Atari级别的任务通常用100万条。
还有一个容易被忽略的点:采样策略。最基础的是均匀随机采样,但后来有人提出了优先经验回放(Prioritized Experience Replay),给TD误差大的样本更高的采样概率。这个改进在不少任务上确实有效,但实现复杂度也上去了。我的建议是:先把均匀采样跑通,确认整体流程没问题,再考虑加优先回放。
2.3 目标网络:给追逐的靶子按下暂停键
目标网络的逻辑也很朴素:既然目标Q值一直在变导致训练不稳定,那我就用一个单独的网络来计算目标Q值,这个网络的参数不是每步都更新,而是每隔一段时间从当前网络复制过来。
具体做法是:维护两个结构完全相同的网络,一个叫在线网络(Online Network),一个叫目标网络(Target Network)。在线网络每步都更新,目标网络每隔C步(比如1000步)把在线网络的参数硬拷贝过来。计算目标Q值时用目标网络,计算当前Q值时用在线网络。这样在一段时间内,目标Q值是相对固定的,训练就稳定多了。
后来有人提出了软更新(Soft Update),也叫Polyak平均:不是每隔C步硬拷贝,而是每一步都让目标网络的参数缓慢逼近在线网络:θ_target = τ·θ_online + (1-τ)·θ_target,τ通常取0.001或0.005。软更新比硬更新更平滑,在很多任务上表现更好。我个人的经验是:离散动作空间的任务用硬更新就够了,连续控制或者对稳定性要求高的场景可以试试软更新。
2.4 网络结构怎么设计
DQN的网络结构取决于输入是什么。如果是Atari游戏,输入是图像,那就用卷积神经网络(CNN)提取特征,后面接全连接层输出每个动作的Q值。如果是低维状态向量(比如机器人关节角度、速度),那就直接用全连接网络。
以Atari为例,DeepMind原始论文用的结构是:输入84×84的灰度图,经过三层卷积(卷积核数量分别是32、64、64,大小分别是8×8、4×4、3×3,步长分别是4、2、1),然后接一个256维的全连接层,最后输出动作数量的Q值。激活函数用的是ReLU。
如果是低维状态,比如CartPole只有4维状态,用两层128维的全连接层就够了。网络不是越大越好,太大会导致训练慢、容易过拟合。我试过在CartPole上用三层512维的网络,结果训练时间翻了三倍,效果反而没提升。
输出层的设计有个讲究:不要加softmax。因为Q值可以是任意实数,不像概率需要归一化。输出层直接线性输出就行。
3. DQN实操全流程:从零搭建到跑通训练
3.1 环境选择和依赖安装
先选一个简单的环境验证流程。CartPole是最经典的选择:状态4维,动作2个,每回合最多200步,训练几分钟就能看到效果。等CartPole跑通了,再上Atari或者更复杂的任务。
依赖方面,核心就是PyTorch或TensorFlow,加上gym(现在叫gymnasium)。我习惯用PyTorch,下面也以PyTorch为例。安装命令很简单:
pip install gymnasium torch numpy matplotlib如果想跑Atari,还需要安装Atari环境支持:
pip install gymnasium[atari] ale-py提示:gym和gymnasium的API有些差异,新项目建议直接用gymnasium,它是gym的维护版本,接口更规范。
3.2 经验回放缓冲区的实现
先写经验回放缓冲区,这是DQN的基础组件。核心就是一个固定大小的数组,支持存入和随机采样。
import random import numpy as np class ReplayBuffer: def __init__(self, capacity): self.capacity = capacity self.buffer = [] self.position = 0 def push(self, state, action, reward, next_state, done): if len(self.buffer) < self.capacity: self.buffer.append(None) self.buffer[self.position] = (state, action, reward, next_state, done) self.position = (self.position + 1) % self.capacity def sample(self, batch_size): batch = random.sample(self.buffer, batch_size) state, action, reward, next_state, done = zip(*batch) return (np.array(state), np.array(action), np.array(reward), np.array(next_state), np.array(done)) def __len__(self): return len(self.buffer)这段代码有几个细节值得说。用list而不是numpy数组存经验,因为每条经验的形状可能不一样(尤其是图像输入),list更灵活。position用取模运算实现循环覆盖,当缓冲区满了之后,新经验自动覆盖最旧的经验。采样用random.sample,保证不重复采样同一条经验。
注意:如果状态是图像,存原始图像会非常占内存。100万条84×84的灰度图,大概需要7GB内存。实际项目中通常会做帧堆叠和降采样,或者用uint8存储、采样时再转float32。
3.3 Q网络的定义
CartPole的状态是4维向量,网络不用太复杂:
import torch import torch.nn as nn class QNetwork(nn.Module): def __init__(self, state_dim, action_dim, hidden_dim=128): super(QNetwork, self).__init__() self.fc1 = nn.Linear(state_dim, hidden_dim) self.fc2 = nn.Linear(hidden_dim, hidden_dim) self.fc3 = nn.Linear(hidden_dim, action_dim) def forward(self, x): x = torch.relu(self.fc1(x)) x = torch.relu(self.fc2(x)) return self.fc3(x)两层128维的全连接,ReLU激活,输出层线性。这个结构在CartPole上足够用了。如果是Atari,把前面的全连接换成卷积层就行。
3.4 训练主循环的完整实现
训练循环是DQN的核心,把前面所有组件串起来:
import gymnasium as gym import torch.optim as optim # 超参数 GAMMA = 0.99 LR = 1e-3 BATCH_SIZE = 64 BUFFER_SIZE = 100000 EPS_START = 1.0 EPS_END = 0.01 EPS_DECAY = 500 TARGET_UPDATE = 1000 NUM_EPISODES = 500 env = gym.make('CartPole-v1') state_dim = env.observation_space.shape[0] action_dim = env.action_space.n online_net = QNetwork(state_dim, action_dim) target_net = QNetwork(state_dim, action_dim) target_net.load_state_dict(online_net.state_dict()) target_net.eval() optimizer = optim.Adam(online_net.parameters(), lr=LR) buffer = ReplayBuffer(BUFFER_SIZE) steps_done = 0 def select_action(state): global steps_done eps = EPS_END + (EPS_START - EPS_END) * \ np.exp(-1. * steps_done / EPS_DECAY) steps_done += 1 if random.random() < eps: return env.action_space.sample() else: with torch.no_grad(): state_tensor = torch.FloatTensor(state).unsqueeze(0) q_values = online_net(state_tensor) return q_values.argmax().item() for episode in range(NUM_EPISODES): state, _ = env.reset() total_reward = 0 done = False while not done: action = select_action(state) next_state, reward, terminated, truncated, _ = env.step(action) done = terminated or truncated buffer.push(state, action, reward, next_state, done) state = next_state total_reward += reward if len(buffer) >= BATCH_SIZE: states, actions, rewards, next_states, dones = buffer.sample(BATCH_SIZE) states = torch.FloatTensor(states) actions = torch.LongTensor(actions).unsqueeze(1) rewards = torch.FloatTensor(rewards).unsqueeze(1) next_states = torch.FloatTensor(next_states) dones = torch.FloatTensor(dones).unsqueeze(1) current_q = online_net(states).gather(1, actions) with torch.no_grad(): max_next_q = target_net(next_states).max(1)[0].unsqueeze(1) target_q = rewards + GAMMA * max_next_q * (1 - dones) loss = nn.MSELoss()(current_q, target_q) optimizer.zero_grad() loss.backward() optimizer.step() if steps_done % TARGET_UPDATE == 0: target_net.load_state_dict(online_net.state_dict()) print(f"Episode {episode}, Reward: {total_reward}, Epsilon: {eps:.3f}")这段代码可以直接跑。几个关键点解释一下。
epsilon的衰减策略用的是指数衰减,比线性衰减更平滑。EPS_DECAY控制衰减速度,500步左右从1.0降到0.01附近。这个值需要根据任务调整:任务越复杂,探索需要的时间越长,EPS_DECAY应该越大。
目标网络的更新用的是硬更新,每1000步拷贝一次参数。注意更新频率不能太高也不能太低:太高了目标网络变化太快,失去稳定作用;太低了目标网络太滞后,学习效率低。
损失函数用的是均方误差,但有个细节:target_q的计算里乘了(1 - dones)。这是因为当回合结束时,没有后续状态,目标Q值就是即时奖励,不应该加上后续的最大Q值。这个细节如果漏了,训练会出问题。
gather操作用来取出对应动作的Q值。online_net(states)输出的是每个动作的Q值,shape是(batch_size, action_dim),gather(1, actions)按照actions的索引取出对应的Q值,shape变成(batch_size, 1)。
3.5 训练过程监控和调参
跑起来之后,你需要盯着几个指标:每回合的总奖励、epsilon值、损失值。CartPole的满分是500,通常训练200到400个回合就能稳定在400分以上。
如果奖励曲线一直不涨,先检查这几个地方:学习率是不是太大了(试试1e-4)、epsilon衰减是不是太快了(增大EPS_DECAY)、目标网络更新频率是不是太高了(增大TARGET_UPDATE)、缓冲区是不是太小了(增大BUFFER_SIZE)。
如果奖励曲线波动很大,可能是batch size太小,试试从64加到128或256。也可能是目标网络更新太频繁,试试从1000步改成2000步。
我自己的调参顺序是:先固定其他参数,调学习率;学习率确定后调epsilon衰减;最后调目标网络更新频率和batch size。一次只调一个参数,不然你根本不知道是哪个参数起了作用。
4. DQN常见问题与排查技巧实录
4.1 训练不收敛的五大原因
DQN训练不收敛是最常见的问题,我整理了五个最可能的原因和对应的排查方法:
| 问题现象 | 可能原因 | 排查方法 | 解决方案 |
|---|---|---|---|
| 奖励一直不涨 | 学习率过大 | 打印损失值,看是否震荡 | 降低学习率到1e-4或1e-5 |
| 奖励先涨后崩 | 目标网络更新太频繁 | 检查TARGET_UPDATE设置 | 增大更新间隔到2000-5000步 |
| 奖励波动剧烈 | batch size太小 | 检查batch size | 增大到128或256 |
| 早期就卡住 | epsilon衰减太快 | 打印epsilon值 | 增大EPS_DECAY |
| 完全无学习 | 奖励缩放问题 | 检查奖励范围 | 归一化奖励到[-1, 1] |
奖励缩放这个问题特别容易被忽略。有些环境的奖励值很大(比如1000),直接拿去做MSE损失,梯度会非常大,网络直接飞掉。解决办法是把奖励除以一个常数,或者用Huber损失代替MSE损失。Huber损失在误差大的时候梯度是常数,不会爆炸,稳定性更好。
4.2 经验回放的内存和效率问题
经验回放缓冲区是内存大户。100万条经验,每条包含状态、动作、奖励、下一状态、结束标志,如果状态是84×84的float32图像,一条经验大概28KB,100万条就是28GB,普通机器根本扛不住。
解决办法有几个。第一,用uint8存储图像,采样时再转float32,内存直接降到7GB。第二,降低图像分辨率,比如从210×160降到84×84。第三,用帧堆叠代替存多帧,只存当前帧,采样时从缓冲区里取连续几帧拼起来。第四,减小缓冲区容量,10万条在很多任务上已经够用了。
采样效率方面,Python的random.sample在缓冲区很大时会比较慢。可以用numpy的随机索引代替:
indices = np.random.randint(0, len(self.buffer), size=batch_size) batch = [self.buffer[i] for i in indices]这样比random.sample快不少,尤其是在缓冲区接近容量上限的时候。
4.3 过估计问题与Double DQN
Q-Learning有一个固有缺陷:过估计。因为目标Q值用的是max操作,而max对噪声是正偏的,导致估计的Q值系统性地偏高。这个问题在动作空间大的时候尤其严重。
Double DQN的解决方案很巧妙:用在线网络选动作,用目标网络算Q值。具体来说,原来目标Q值是:
target_q = reward + gamma * max(target_net(next_state))改成:
best_action = online_net(next_state).argmax() target_q = reward + gamma * target_net(next_state)[best_action]这样就把动作选择和动作评估解耦了,有效缓解过估计。改动很小,但效果在很多任务上都很明显。我的建议是:直接用Double DQN,没有额外成本,效果只会更好。
4.4 训练速度太慢怎么优化
DQN训练慢是出了名的。Atari上一个完整的训练要几千万帧,单卡跑好几天。加速的手段有几个。
第一,用GPU。把网络和batch数据都放到GPU上,前向和反向传播能快几十倍。注意每次采样后要把数据转到GPU,这个传输本身也有开销,所以batch size不能太小。
第二,并行环境。同时跑多个环境实例,把经验汇总到一个缓冲区里。这样单位时间内产生的经验更多,训练更快。gym支持向量化环境,可以一次step多个环境。
第三,减小网络规模。如果任务不复杂,没必要用大网络。CartPole用两层64维的网络就能跑,比128维快一倍。
第四,调整训练频率。不是每一步都要训练,可以每4步训练一次。这样单位时间内环境交互更多,经验产生更快。DeepMind的原始实现就是每4步训练一次。
4.5 从CartPole到Atari的迁移注意事项
CartPole跑通之后,很多人想直接上Atari。但Atari和CartPole的差异很大,直接套用CartPole的超参数大概率跑不出来。
状态处理:Atari是图像输入,需要做灰度化、降采样、帧堆叠。通常是把最近4帧堆叠起来作为状态,这样网络能感知运动信息。
网络结构:需要换成CNN。DeepMind原始论文的结构是三层卷积加一层全连接,参数量大概170万。
超参数:学习率要降低(1e-4),缓冲区要增大(100万),目标网络更新间隔要增大(10000步),epsilon衰减要变慢(100万步)。
训练时间:Atari一个游戏通常需要1000万到5000万帧,单卡跑几天到一周。建议先用Pong这种简单游戏验证,再上Breakout或Space Invaders。
提示:如果只是想验证算法,不建议直接上Atari。可以先在CartPole、MountainCar、LunarLander这些轻量环境上把流程跑通,确认代码没问题再迁移。
4.6 独家避坑经验汇总
最后分享几个我在实际项目中踩过的坑,都是文档里不会写的。
坑一:环境reset的返回值。gymnasium的reset返回的是(state, info),不是单独的state。如果还用老版本gym的写法,会直接报错。step返回的是(next_state, reward, terminated, truncated, info),五个值,不是四个。terminated表示任务失败或成功,truncated表示超时,两个要分开处理。
坑二:done的处理。计算目标Q值时,如果done是True,目标Q值就是即时奖励,不加后续Q值。但要注意terminated和truncated的区别:terminated是真正的结束,truncated是超时截断,后者理论上还有后续状态,只是被强制截断了。严格来说,truncated的情况不应该把后续Q值置零,但很多实现为了简单都统一处理了。影响不大,但知道这个细节能帮你理解为什么有些实现效果更好。
坑三:随机种子的设置。DQN对随机种子很敏感,不同的种子可能一个跑出400分一个跑出200分。做对比实验时一定要固定种子,而且最好跑多个种子取平均。我通常跑5个种子,取均值和标准差。
坑四:损失值的监控。DQN的损失值不像监督学习那样单调下降,它可能会先降后升再降,波动很大。不要看到损失上升就以为训练出问题了,关键看奖励曲线。损失值只用来排查异常(比如变成NaN),不用来判断训练效果。
坑五:模型保存和加载。保存的时候要同时保存在线网络和目标网络的参数,还有优化器的状态。只保存在线网络的话,恢复训练时目标网络是随机初始化的,会导致训练崩溃。加载的时候要先创建相同结构的网络,再load_state_dict。
坑六:epsilon的下限。epsilon不能降到0,要保留一个最小值(比如0.01或0.05),保证智能体始终有一点点探索。如果epsilon降到0,智能体完全依赖当前策略,一旦陷入局部最优就出不来了。
坑七:奖励归一化。不同环境的奖励范围差异很大,CartPole每步奖励是1,Atari的奖励可能是几十甚至几百。训练前最好把奖励归一化到差不多的范围,这样超参数在不同环境之间迁移会容易很多。简单的做法是除以一个常数,或者用运行均值做标准化。
坑八:网络初始化的影响。DQN对网络初始化也比较敏感。默认的初始化在简单任务上没问题,但在复杂任务上可能导致早期Q值估计偏差太大。可以试试用较小的初始化方差,或者用正交初始化。这个不是必须的,但如果你发现训练早期就崩了,可以往这个方向排查。
4.7 常见问题速查表
| 问题 | 排查顺序 | 快速修复 |
|---|---|---|
| 奖励不涨 | 学习率→epsilon→目标网络 | 降学习率,增EPS_DECAY |
| 奖励波动大 | batch size→目标网络 | 增batch size,增TARGET_UPDATE |
| 损失变NaN | 奖励范围→学习率 | 归一化奖励,降学习率 |
| 内存溢出 | 缓冲区大小→图像存储 | 减BUFFER_SIZE,用uint8 |
| 训练太慢 | GPU→网络规模→训练频率 | 用GPU,减小网络,每4步训练 |
| 过估计严重 | 是否用Double DQN | 换成Double DQN |
| 恢复训练崩溃 | 目标网络是否保存 | 同时保存在线和目标网络 |
| 不同种子差异大 | 种子数量 | 跑5个种子取平均 |
这套排查流程基本能覆盖90%以上的DQN训练问题。剩下的10%通常是环境本身的bug或者代码逻辑错误,需要逐行检查。
我个人在实际操作中的体会是:DQN的坑大多不在算法本身,而在工程细节。公式看懂了不代表代码能跑通,代码跑通了不代表能收敛,能收敛了不代表稳定。每一个环节都有无数个小细节在等着你。最有效的学习方式不是看论文,而是自己从头写一遍,然后反复调试。调通一个CartPole,比看十篇论文收获都大。