☰
DQN强化学习实战:从Q表到神经网络,手把手实现与调参避坑指南
2026/10/14 10:52:42 网站建设 项目流程

1. 从Q表到神经网络:为什么DQN是强化学习落地的分水岭

如果你跟着这个系列一路看下来,前面几篇应该已经把Q-Learning、SARSA这类经典表格法摸得差不多了。表格法有个硬伤:状态空间稍微大一点,Q表就炸了。比如一个简单的图像输入,哪怕只有100个像素、每个像素两种取值,状态数就是2的100次方,这个数量级别说存表,宇宙里的原子数都不够用。所以当问题从"迷宫寻路"升级到"像素级游戏画面"时,表格法直接宣告退役。

DQN(Deep Q-Network)要解决的就是这个问题。它的核心思路非常直接:既然Q表存不下,那就用一个神经网络来拟合Q函数。输入状态,输出每个动作对应的Q值,网络参数就是"压缩后的Q表"。这个想法听起来简单,但真正让DQN能稳定训练,靠的是三个关键设计——经验回放、目标网络、奖励裁剪。缺了任何一个,训练曲线都会像心电图一样上下乱跳,甚至直接发散。

这篇内容适合谁?如果你已经理解了Q-Learning的更新公式,但一看到"用神经网络逼近Q值"就不知道从哪下手,那这篇就是写给你的。我会把DQN的原理拆到能手动推导的程度,然后给出一份可以直接跑的Agent实现,最后聊聊我在调试过程中踩过的那些坑——这些坑在教科书里基本不会写,但实际训练时几乎每个人都会遇到。

提示:DQN不是"Q-Learning加个网络"这么简单。如果只是把Q表换成网络而不做任何额外处理,训练几乎必然失败。原因后面会详细展开。

2. DQN的三大支柱:每个设计背后都是血泪教训

2.1 经验回放:打破样本之间的时间相关性

在标准的Q-Learning里,Agent每走一步就更新一次Q值,样本是严格按照时间顺序产生的。这种序列有一个致命问题:相邻的状态高度相关。比如你在玩一个游戏,连续两帧画面几乎一模一样,网络在这两帧上学到的东西也几乎一样。如果按顺序喂给网络,梯度更新会朝着同一个方向反复推,网络很容易"记住"最近的经验而忘掉之前的,这在深度学习里叫灾难性遗忘。

经验回放的解法很朴素:搞一个固定大小的缓冲区,把Agent和环境交互产生的(状态, 动作, 奖励, 下一状态, 是否终止)五元组全部存进去。训练的时候不从最新经验里取,而是从缓冲区里随机采样一批。这样一批数据里可能混着几百步之前的经验和刚刚产生的经验,样本之间近似独立同分布,梯度更新的方向就不会被最近的几步带偏。

缓冲区大小是个需要调的参数。太小了,样本多样性不够,相当于没起到回放的作用;太大了,里面可能存着大量过时的、由旧策略产生的经验,反而拖累学习。实践中,对于CartPole这种简单任务,10000到50000的缓冲区就够用;对于Atari级别的任务,通常要开到100万。我一般会先设一个较大的值,然后观察训练曲线,如果收敛太慢再往下调。

2.2 目标网络:给追逐的目标一个"冷静期"

DQN的更新目标是r + γ * max(Q_target(s', a'))。注意这里有个问题:计算目标用的Q网络和正在更新的Q网络是同一个。这意味着每次更新参数,目标值也跟着变。就像你在追一个影子,你动它也动,而且它动的方式还依赖于你怎么动。这种"自己追自己"的更新方式极易导致震荡甚至发散。

目标网络的思路是:复制一份Q网络的参数,固定住不训练,专门用来计算目标值。每隔C步,把当前Q网络的参数同步给目标网络。这样在C步之内,目标值是相对稳定的,Q网络在追一个"不动"的目标,训练就稳多了。C的取值通常在100到10000之间,具体取决于任务。CartPole这种快速任务,C设100左右就行;Atari这种复杂任务,C通常设10000。

这里有个细节值得注意:目标网络不是越稳定越好。如果C设得太大,目标网络太旧,Q网络追的是一个过时的目标,学习效率会很低。我一般会先设一个中等值(比如1000),然后根据训练曲线的平滑程度来调整——曲线抖动厉害就减小C,收敛太慢就增大C。

2.3 奖励裁剪:把不同量级的奖励拉到同一尺度

不同任务的奖励量级差异巨大。有的任务每步奖励是+1或-1,有的任务可能是+100或-100。如果奖励量级太大,Q值也会跟着变大,进而导致梯度爆炸。奖励裁剪的做法很简单:把奖励限制在[-1, 1]区间内。超过1的按1算,小于-1的按-1算。

这个操作看起来粗暴,但效果出奇地好。它把所有任务的奖励拉到同一尺度,网络不用去适应不同量级的数值,训练稳定性大幅提升。当然,裁剪也有代价:如果奖励的绝对值大小本身包含重要信息(比如"得100分"和"得1分"有本质区别),裁剪会丢失这个信息。但在大多数任务里,我们更关心的是"哪个动作更好"而不是"好多少",所以这个代价是可以接受的。

注意:奖励裁剪不是必须的。如果你的任务奖励本身就在合理范围内(比如-10到10),可以不裁剪。但如果发现训练时Q值动辄几百上千,那大概率需要加上这个操作。

3. 手把手实现DQN Agent:从网络结构到训练循环

3.1 网络结构设计:不是越深越好

DQN的网络结构不需要太复杂。对于CartPole这种状态维度只有4的任务,两层全连接、每层64个神经元就足够了。对于Atari这种图像输入,通常用三个卷积层加两个全连接层。关键原则是:网络容量要匹配任务复杂度。网络太大,训练慢还容易过拟合;网络太小,拟合能力不够,Q值估计不准。

我见过不少人一上来就堆一个很深的网络,结果训练半天不收敛,还以为是算法有问题。其实对于简单任务,一个浅层网络反而学得更快。下面是一个通用的网络结构,输入维度根据任务调整,输出维度等于动作数:

import torch import torch.nn as nn class QNetwork(nn.Module): def __init__(self, state_dim, action_dim, hidden_dim=64): super(QNetwork, self).__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)

输出层不加激活函数,因为Q值可以是任意实数,加激活函数反而会限制取值范围。隐藏层用ReLU就够了,没必要上更复杂的激活函数。

3.2 经验回放缓冲区的实现细节

缓冲区用collections.deque实现最方便,指定maxlen后会自动淘汰旧数据。采样的时候用random.sample随机取一批。这里有个小技巧:存的时候把numpy数组转成tensor,取的时候直接堆叠,比每次采样后再转换要快。

import random from collections import deque import numpy as np import torch class ReplayBuffer: def __init__(self, capacity): self.buffer = deque(maxlen=capacity) 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.FloatTensor(np.array(states)), torch.LongTensor(actions), torch.FloatTensor(rewards), torch.FloatTensor(np.array(next_states)), torch.FloatTensor(dones) ) def __len__(self): return len(self.buffer)

done标志位很关键。如果下一状态是终止状态,那么目标Q值就是r本身,不需要加上γ * max(Q(s', a'))。这个细节如果漏掉,终止状态附近的Q值会被高估,影响策略质量。

3.3 训练循环:每一步都在做什么

训练循环是DQN的核心。每一步的流程是:用当前策略选动作、执行动作、存经验、从缓冲区采样、计算目标Q值、算损失、反向传播、定期同步目标网络。下面把关键部分拆开说。

动作选择用ε-greedy策略。ε从1.0开始逐渐衰减到0.01或0.05,前期多探索,后期多利用。衰减方式可以用线性衰减或指数衰减,我一般用线性衰减,简单可控。

def select_action(self, state, epsilon): if random.random() < epsilon: return random.randrange(self.action_dim) else: with torch.no_grad(): state_tensor = torch.FloatTensor(state).unsqueeze(0) q_values = self.q_net(state_tensor) return q_values.argmax().item()

计算目标Q值时,用目标网络对下一状态做前向传播,取最大Q值。注意这里要加torch.no_grad(),因为目标值不需要梯度。

with torch.no_grad(): next_q_values = self.target_net(next_states) max_next_q = next_q_values.max(dim=1)[0] target_q = rewards + self.gamma * max_next_q * (1 - dones)

当前Q值的计算是:把状态输入Q网络,得到所有动作的Q值,然后根据实际执行的动作取出对应的那个Q值。

q_values = self.q_net(states) current_q = q_values.gather(1, actions.unsqueeze(1)).squeeze(1)

损失函数用MSE,优化器用Adam,学习率通常设1e-3到1e-4。学习率太大会导致Q值震荡,太小则收敛慢。我一般从1e-3开始试,如果训练曲线抖动厉害就降到1e-4。

目标网络的同步用硬更新:每隔C步,把Q网络的参数直接复制给目标网络。

if step % self.target_update == 0: self.target_net.load_state_dict(self.q_net.state_dict())

3.4 完整的Agent封装

把上面的部分拼起来,就是一个完整的DQN Agent。下面给出完整代码,可以直接拿去跑CartPole。

class DQNAgent: def __init__(self, state_dim, action_dim, lr=1e-3, gamma=0.99, buffer_size=10000, batch_size=64, target_update=100, epsilon_start=1.0, epsilon_end=0.01, epsilon_decay=500): self.state_dim = state_dim self.action_dim = action_dim self.gamma = gamma self.batch_size = batch_size self.target_update = target_update self.epsilon_start = epsilon_start self.epsilon_end = epsilon_end self.epsilon_decay = epsilon_decay self.step_count = 0 self.q_net = QNetwork(state_dim, action_dim) self.target_net = QNetwork(state_dim, action_dim) self.target_net.load_state_dict(self.q_net.state_dict()) self.target_net.eval() self.optimizer = torch.optim.Adam(self.q_net.parameters(), lr=lr) self.buffer = ReplayBuffer(buffer_size) def select_action(self, state): epsilon = self.epsilon_end + (self.epsilon_start - self.epsilon_end) * \ max(0, 1 - self.step_count / self.epsilon_decay) self.step_count += 1 if random.random() < epsilon: return random.randrange(self.action_dim) with torch.no_grad(): state_tensor = torch.FloatTensor(state).unsqueeze(0) return self.q_net(state_tensor).argmax().item() def update(self): if len(self.buffer) < self.batch_size: return states, actions, rewards, next_states, dones = self.buffer.sample(self.batch_size) with torch.no_grad(): max_next_q = self.target_net(next_states).max(dim=1)[0] target_q = rewards + self.gamma * max_next_q * (1 - dones) current_q = self.q_net(states).gather(1, actions.unsqueeze(1)).squeeze(1) loss = nn.MSELoss()(current_q, target_q) self.optimizer.zero_grad() loss.backward() self.optimizer.step() if self.step_count % self.target_update == 0: self.target_net.load_state_dict(self.q_net.state_dict())

这份代码跑CartPole,大概200到300个episode就能稳定在200分以上。如果跑不出来,大概率是超参数没调好,可以对照下一节的排查思路来定位。

4. 训练不收敛?先查这几个地方

4.1 损失曲线不降反升:检查目标值计算

如果损失曲线一开始下降,然后突然飙升,最常见的原因是目标Q值计算有误。重点检查两个地方:一是done标志位有没有正确使用,终止状态的目标值不应该加上γ * max(Q(s', a'));二是目标网络有没有正确同步,如果目标网络一直不更新,Q网络追的是一个固定的旧目标,损失会先降后升。

还有一种可能是学习率太大。Adam的默认学习率是1e-3,对DQN来说有时候偏大。可以试着降到1e-4,观察损失曲线是否变得平滑。

4.2 奖励上不去但损失在降:探索不够或网络容量不足

损失在降说明网络在拟合目标值,但奖励上不去说明策略没有变好。这种情况通常是探索不够——ε衰减太快,Agent还没来得及探索到好的动作就已经开始利用次优策略了。解决办法是把epsilon_decay调大,让探索持续更久。

另一个可能是网络容量不足。如果状态维度很高但隐藏层只有32个神经元,网络拟合能力不够,Q值估计不准,策略自然好不了。可以试着把隐藏层加到128或256。

4.3 训练后期性能突然崩塌:灾难性遗忘

有时候训练到一半,奖励突然从高分掉到低分,然后再也回不去。这是典型的灾难性遗忘——网络在学新经验的时候把旧经验忘了。解决办法是增大缓冲区容量,让旧经验保留更久;或者减小学习率,让每次更新幅度小一点。

还有一个容易被忽略的点:如果缓冲区太小,旧经验被快速淘汰,网络只能看到最近的数据,遗忘会特别严重。我一般会把缓冲区设成至少10000,简单任务甚至设到50000。

提示:DQN对超参数比较敏感,没有一套参数能通吃所有任务。建议先用小规模任务(比如CartPole)把流程跑通,确认代码没问题后再迁移到复杂任务上。

5. 从DQN出发:哪些改进值得一试

5.1 Double DQN:解决Q值高估问题

标准DQN在计算目标值时,用目标网络选动作也用目标网络评估动作,这会导致Q值被系统性高估。Double DQN的改法很简单:用Q网络选动作,用目标网络评估动作。具体来说,把max(Q_target(s', a'))换成Q_target(s', argmax(Q(s', a')))。改动只有一行代码,但效果在很多任务上都很明显。

with torch.no_grad(): next_actions = self.q_net(next_states).argmax(dim=1, keepdim=True) max_next_q = self.target_net(next_states).gather(1, next_actions).squeeze(1) target_q = rewards + self.gamma * max_next_q * (1 - dones)

5.2 Dueling DQN:把状态价值和动作优势分开

Dueling DQN把Q值拆成两部分:状态价值V(s)和动作优势A(s, a),即Q(s, a) = V(s) + A(s, a)。网络结构上,最后一层分成两个分支,一个输出标量V,一个输出向量A,然后合并成Q值。这样做的好处是,网络可以单独学习"这个状态好不好"和"这个动作比平均好多少",在有些任务上收敛更快。

5.3 优先经验回放:让重要的样本多出现几次

标准经验回放是均匀采样,每个样本被抽到的概率相同。但有些样本的TD误差很大,说明网络在这些样本上预测得不准,应该多学几次。优先经验回放就是按TD误差的大小给样本赋权,误差大的采样概率高。实现上需要用SumTree来高效采样,代码量比均匀采样大不少,但在复杂任务上提升明显。

这三个改进可以叠加使用,也可以单独用。我的建议是先把标准DQN跑通,然后按Double DQN、Dueling DQN、优先回放的顺序逐个加上去,每加一个观察效果,这样能清楚知道每个改进到底有没有用。

6. 一些教科书不会写的实操心得

调DQN最让人头疼的地方在于:它不像监督学习那样有明确的收敛指标。损失降了不代表策略好,奖励高了也不代表训练稳定。我自己的经验是,同时盯三个指标:平均奖励、平均Q值、损失。平均奖励看策略质量,平均Q值看价值估计是否合理,损失看网络拟合情况。三个指标都健康,训练才算稳。

还有一个细节:环境重置后的第一步很关键。如果环境有随机初始状态,Agent在第一步的决策会影响整个episode的走向。我一般会在训练前先跑几个随机episode,看看随机策略的奖励范围,这样后面判断Agent有没有学到东西就有参照了。

最后说一个关于复现性的问题。DQN对随机种子很敏感,不同的种子可能跑出完全不同的结果。如果你在对比两个算法,一定要跑多个种子取平均,单次实验的结果说明不了问题。我一般至少跑5个种子,然后看均值和方差。如果两个算法的均值差在方差范围内,那基本可以认为没有显著差异。

代码写到最后,你会发现DQN的骨架其实很简单,难的是调参和排查问题。把标准DQN跑通、把每个超参数的作用搞清楚,比急着上各种改进版本要重要得多。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询