☰
从零实现DQN:PyTorch强化学习实战与训练稳定性调优指南
2026/10/14 11:53:04 网站建设 项目流程

1. 从“打游戏”到“写代码”:DQN到底在解决一个什么问题

如果你之前接触过监督学习,脑子里大概率会有一个固定套路:准备一堆带标签的数据,喂给网络,算损失,反向传播,调参,收敛。这套流程很成熟,成熟到几乎可以闭着眼睛写。但强化学习完全是另一个世界——没有现成的标签,没有“正确答案”,模型只能靠自己跟环境互动,从一次次试错里慢慢摸出规律。而DQN(Deep Q-Network)就是把这个“试错学习”的过程,用神经网络给撑起来的经典方案。

我第一次接触DQN的时候,最困惑的不是代码怎么写,而是“它到底在学什么”。后来想明白了:它学的是一张“状态-动作价值表”。你可以把它想象成一本巨大的攻略手册,每翻到一页(一个状态),手册上会列出当前可以做的每个动作分别值多少分。分数越高,说明在这个状态下做这个动作,长期来看收益越大。DQN要做的,就是用神经网络去拟合这本手册,让它在没见过的状态上也能给出靠谱的估值。

这件事听起来简单,但真正动手实现的时候,坑一个接一个。为什么要有经验回放?为什么要有目标网络?为什么训练曲线会突然崩掉?这些问题如果不搞清楚,代码跑起来也是一团浆糊。所以这篇内容我不打算只丢一份代码出来,而是把DQN从“为什么这么设计”到“怎么用PyTorch落地”整条链路拆开讲,适合已经了解PyTorch基础、想迈入强化学习门槛的开发者,也适合之前跑过DQN但训练不稳定、想搞明白背后原因的读者。

2. 拆解DQN的三大支柱:为什么不能只用一张网络硬扛

2.1 Q值估计的本质:用神经网络替代Q表

传统的Q-Learning用的是表格法。状态空间小的时候没问题,比如一个4x4的格子世界,每个格子一个状态,一张表就能存下所有状态-动作对的价值。但现实场景里,状态往往是连续的、高维的,比如游戏画面是210x160的像素矩阵,你不可能为每一种像素组合都建一行表格。这时候就需要函数逼近——用一个参数化的函数(也就是神经网络)来近似Q值。

数学上,我们要拟合的是 $Q(s, a; \theta)$,其中 $\theta$ 是网络参数。输入是状态 $s$,输出是每个动作对应的Q值。训练的目标是让网络输出的Q值尽量逼近真实的Q值。但问题来了:真实Q值我们也不知道,那怎么算损失?

DQN的巧妙之处在于,它用贝尔曼方程构造了一个“自举”的目标值:

$$y = r + \gamma \cdot \max_{a'} Q(s', a'; \theta^-)$$

这里的 $r$ 是当前动作获得的即时奖励,$\gamma$ 是折扣因子,$s'$ 是执行动作后的下一个状态,$\theta^-$ 是目标网络的参数。也就是说,我用“即时奖励 + 下一步的最大Q值估计”来当作当前这一步的目标。虽然这个目标本身也是估计出来的,不一定准,但随着训练推进,它会逐渐逼近真实值。

注意:这个“自举”的过程是DQN训练不稳定的根源之一。因为目标值依赖于网络自己的输出,网络更新会导致目标值也跟着变,就像一个人在追自己的影子。

2.2 经验回放:打破数据之间的时间相关性

如果直接用连续的交互数据去训练,会有一个严重问题:相邻的样本高度相关。比如你在玩一个游戏,连续几帧画面几乎一样,网络会倾向于记住最近这几帧的模式,而不是从全局学规律。这在监督学习里叫“数据不独立同分布”,会严重干扰梯度下降的效果。

经验回放(Experience Replay)的思路很直接:把每次交互得到的 $(s, a, r, s', done)$ 存进一个缓冲区,训练的时候从缓冲区里随机采样一批数据。这样每次拿到的样本来自不同的时间点,相关性被大幅削弱,梯度更新更稳定。而且同一个样本可以被多次利用,数据效率也提高了。

缓冲区的大小是个需要权衡的参数。太小了,样本多样性不够;太大了,旧数据可能已经过时(因为策略在变),反而拖累训练。常见的设置是几万到一百万条之间,具体要看任务的复杂度和交互频率。

2.3 目标网络:给追逐的影子一个“冻结”的参照物

前面说了,如果目标值一直跟着网络变,训练就容易震荡甚至发散。目标网络(Target Network)就是来解决这个问题的。它的结构跟主网络一模一样,但参数不是每一步都更新,而是每隔一段时间(比如每C步)从主网络复制一次。

这样一来,在一段时间内,目标值相对稳定,主网络有了一个固定的“靶子”可以去逼近。等主网络追得差不多了,再把目标网络的参数更新一次,靶子往前挪一挪,继续追。这种“延迟更新”的策略,是DQN能稳定训练的关键设计之一。

组件作用更新频率常见参数
主网络输出当前Q值,参与梯度更新每步更新学习率1e-4~1e-3
目标网络计算TD目标中的max Q值每C步硬更新或软更新C=100~10000
经验回放缓冲区存储交互样本,随机采样每步写入容量1e4~1e6
折扣因子γ平衡即时与长期奖励固定0.95~0.99

3. 用PyTorch把DQN搭起来:从网络结构到训练循环

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

DQN的网络结构其实不需要太复杂。对于大多数离散动作任务,两到三层全连接层就够用了。输入维度是状态的特征数,输出维度是动作数量。中间层的激活函数用ReLU,最后一层不加激活(因为Q值可以是任意实数)。

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.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)

这里有个容易踩的坑:有些人一上来就堆很深的网络,觉得越深表达能力越强。但DQN的训练信号本身噪声就大,网络太深反而容易过拟合到最近的交互数据上,导致训练不稳定。我试过在CartPole任务上用5层网络,结果收敛速度比2层还慢,而且方差更大。所以建议从简单的结构开始,确认能跑通再考虑加深。

另外,输入状态的归一化很重要。如果状态特征的范围差异很大(比如一个维度是0到1,另一个是0到1000),网络很难学好。最好在送入网络之前做标准化处理,或者至少把数值缩放到相近的量级。

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

缓冲区用Python的collections.deque就能实现,容量满了之后自动丢弃最旧的数据。采样的时候用random.sample随机抽取一批。

import random from collections import deque 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(states), torch.LongTensor(actions), torch.FloatTensor(rewards), torch.FloatTensor(next_states), torch.FloatTensor(dones)) def __len__(self): return len(self.buffer)

这里有个细节值得注意:done标志的处理。当一步之后回合结束,下一状态其实不存在,TD目标里不应该加上后续的Q值。所以计算目标值时要用(1 - done)来屏蔽掉终止状态之后的估值。这个细节如果漏掉,网络会学到“死亡之后还有未来收益”的错误认知,训练效果会明显变差。

3.3 训练循环的骨架与关键参数

训练循环的核心逻辑是:跟环境交互收集数据,存入缓冲区,当缓冲区数据够多时采样一批做梯度更新,定期同步目标网络。

def train_step(main_net, target_net, optimizer, buffer, batch_size, gamma): if len(buffer) < batch_size: return None states, actions, rewards, next_states, dones = buffer.sample(batch_size) # 当前Q值 q_values = main_net(states).gather(1, actions.unsqueeze(1)).squeeze(1) # 目标Q值 with torch.no_grad(): next_q_values = target_net(next_states).max(1)[0] target_q = rewards + gamma * next_q_values * (1 - dones) loss = nn.MSELoss()(q_values, target_q) optimizer.zero_grad() loss.backward() # 梯度裁剪,防止梯度爆炸 torch.nn.utils.clip_grad_norm_(main_net.parameters(), max_norm=10) optimizer.step() return loss.item()

梯度裁剪这一步在原始DQN论文里没有特别强调,但在实际实现中非常有用。因为TD目标本身波动大,偶尔会产生很大的梯度,把网络参数一下子推飞。加上裁剪之后,训练曲线会平滑很多。

探索策略通常用ε-greedy:以ε的概率随机选动作,以1-ε的概率选Q值最大的动作。ε从1.0逐渐衰减到0.01或0.05,让智能体从“多探索”慢慢过渡到“多利用”。

4. 训练不稳定?这些坑我替你踩过了

4.1 损失突然爆炸或变成NaN

这是最常见的问题。原因可能有几个:学习率太大、梯度没有裁剪、奖励数值范围过大、网络输出层没有做适当的缩放。排查的时候可以先把学习率降到1e-4试试,加上梯度裁剪,再把奖励做一下归一化(比如除以一个常数或者用running mean标准化)。

还有一种情况是输入状态里包含了异常值,比如某个传感器偶尔返回一个极大的数,导致网络输出爆炸。这种要在数据预处理阶段就过滤掉。

4.2 训练曲线震荡剧烈,均值上不去

如果曲线像心电图一样上下跳,但整体趋势没有明显上升,通常是目标网络更新太频繁,或者缓冲区太小导致样本相关性太高。可以试试把目标网络的同步间隔调大(比如从100步改成1000步),或者增大缓冲区容量。

另外,batch size也有影响。太小的batch(比如16)会让梯度估计噪声很大,适当增大到64或128会稳一些,但也不要太大,否则每次更新计算量增加,而且可能降低样本的多样性。

4.3 智能体学会了“摆烂”策略

有时候你会发现智能体找到了一个局部最优的“摆烂”策略,比如一直往一个方向走,虽然拿不到高分但也不会很快结束回合。这种情况通常是探索不足导致的。可以检查ε的衰减速度是不是太快了,或者尝试提高探索的随机性。另一个思路是调整奖励函数,给“活着”本身一个微小的负奖励,迫使智能体尽快完成任务。

4.4 评估和训练的表现差距很大

训练的时候用ε-greedy,评估的时候应该用纯贪心策略(ε=0)。如果你发现训练回报很高但评估回报很低,说明网络过拟合到了探索噪声上。这时候可以试试在评估时多跑几个回合取平均,或者检查一下训练和评估的环境配置是否一致。

问题现象可能原因排查方向
损失变NaN学习率过大、梯度爆炸降学习率、加梯度裁剪
曲线震荡目标网络更新太频繁增大同步间隔
回报不上升探索不足、奖励设计问题调ε衰减、检查奖励
训练评估差距大过拟合探索噪声评估用贪心、多回合平均
收敛太慢网络太深、状态未归一化简化网络、标准化输入

5. 从跑通到跑好:几个提升训练效果的实用技巧

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

原始DQN有一个理论缺陷:用max操作选动作和估价值用的是同一个网络,会导致Q值被系统性地高估。因为max操作总是倾向于选那些被高估的动作,误差会累积。Double DQN的思路是把“选动作”和“估价值”分开:用主网络选动作,用目标网络估价值。

with torch.no_grad(): # 主网络选动作 best_actions = main_net(next_states).argmax(1) # 目标网络估价值 next_q_values = target_net(next_states).gather(1, best_actions.unsqueeze(1)).squeeze(1) target_q = rewards + gamma * next_q_values * (1 - dones)

这个改动只需要几行代码,但效果往往很明显,尤其是在动作空间较大的任务上。我在几个Atari风格的小游戏上对比过,Double DQN的收敛速度和最终回报都优于原始版本。

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

不是所有样本都同等重要。TD误差大的样本说明网络在这个状态上预测得不准,应该被更频繁地采样。优先经验回放(Prioritized Experience Replay)就是按TD误差给样本分配优先级,误差越大被采到的概率越高。

实现上需要用SumTree这样的数据结构来高效采样,代码量会增加不少。如果只是做入门项目,可以先不引入这个机制,等基础版本跑通了再考虑。

5.3 软更新:让目标网络平滑过渡

硬更新是每隔C步直接把主网络的参数复制过去,这会导致目标值突然跳变。软更新则是每一步都让目标网络向主网络靠近一点点:

$$\theta^- \leftarrow \tau \theta + (1 - \tau) \theta^-$$

τ通常取0.001到0.01之间。这样目标值的变化更平滑,训练曲线也会更稳定。很多现代DQN变体都默认用软更新。

5.4 奖励裁剪与归一化

不同任务的奖励范围差异很大。有的任务每步奖励是0.1,有的是100。如果直接拿原始奖励去算TD目标,大奖励任务的学习率相当于被放大了几百倍。常见的做法是把奖励裁剪到[-1, 1]区间,或者用运行均值做标准化。这个操作虽然简单,但对训练稳定性的影响很大。

6. 一个完整的训练脚本长什么样

把前面的模块拼起来,一个最小可用的DQN训练脚本大概是这样:

import gym import torch import torch.nn as nn import torch.optim as optim import random import numpy as np from collections import deque # 超参数 STATE_DIM = 4 ACTION_DIM = 2 HIDDEN_DIM = 128 LR = 1e-3 GAMMA = 0.99 BATCH_SIZE = 64 BUFFER_CAPACITY = 10000 TARGET_UPDATE = 200 EPSILON_START = 1.0 EPSILON_END = 0.05 EPSILON_DECAY = 500 MAX_EPISODES = 500 env = gym.make('CartPole-v1') main_net = QNetwork(STATE_DIM, ACTION_DIM, HIDDEN_DIM) target_net = QNetwork(STATE_DIM, ACTION_DIM, HIDDEN_DIM) target_net.load_state_dict(main_net.state_dict()) optimizer = optim.Adam(main_net.parameters(), lr=LR) buffer = ReplayBuffer(BUFFER_CAPACITY) steps_done = 0 for episode in range(MAX_EPISODES): state, _ = env.reset() total_reward = 0 done = False while not done: # ε-greedy探索 epsilon = EPSILON_END + (EPSILON_START - EPSILON_END) * \ np.exp(-steps_done / EPSILON_DECAY) if random.random() < epsilon: action = env.action_space.sample() else: with torch.no_grad(): q_vals = main_net(torch.FloatTensor(state).unsqueeze(0)) action = q_vals.argmax().item() next_state, reward, done, _, _ = env.step(action) buffer.push(state, action, reward, next_state, float(done)) state = next_state total_reward += reward steps_done += 1 # 训练 if len(buffer) >= BATCH_SIZE: train_step(main_net, target_net, optimizer, buffer, BATCH_SIZE, GAMMA) # 目标网络同步 if steps_done % TARGET_UPDATE == 0: target_net.load_state_dict(main_net.state_dict()) if episode % 20 == 0: print(f"Episode {episode}, Reward: {total_reward}, Epsilon: {epsilon:.3f}")

这个脚本在CartPole上大概跑两三百个回合就能稳定拿到满分(500步)。但如果你换个更复杂的任务,比如 LunarLander 或者 Atari 游戏,就需要调更多的参数,网络结构也要相应调整。

7. 一些关于调参和调试的个人体会

调DQN的参数,我的经验是“一次只动一个”。因为DQN对超参数很敏感,同时改好几个参数,你根本不知道是哪个起了作用。建议先固定一套默认参数跑通,然后每次只调一个,观察训练曲线的变化。

学习率是最关键的参数。1e-3在简单任务上通常没问题,但复杂任务可能需要降到1e-4甚至更低。如果发现损失在前几百步就爆炸,先降学习率准没错。

缓冲区容量和batch size要匹配。如果缓冲区只有1000条,batch size设256,那每次采样几乎覆盖了整个缓冲区,随机性就失去了意义。一般来说,缓冲区容量至少是batch size的50到100倍。

评估频率也很重要。不要只看训练回报,因为训练时带探索噪声,回报会偏低。每隔几十个回合跑一次纯贪心评估,用评估回报来判断模型是否真的在进步。

最后说一个心态问题:DQN训练不出来是常态,训练出来才是意外。有时候换个随机种子结果就完全不一样。所以不要因为一次失败就否定整个方案,多跑几次,多观察曲线,慢慢就能培养出对训练状态的直觉。

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

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

立即咨询