上一篇我们把Q-learning在网格环境上跑通了,最后得到一张完整的Q表,看着智能体沿着最优路径走到终点,确实很解压。但把同样的套路搬到稍微真实一点的控制任务上,我很快就碰了壁:状态变成了连续向量,Q表根本建不出来。这篇算是强化学习系列的第二篇,核心目的就是解决这个“表格崩溃”的问题,从深度Q网络,也就是经常听到的DQN入手,把经验回放、目标网络、Double、Dueling这些概念一次讲清楚,最后给出一份能直接跑的实验代码和我在调参过程中踩过的坑。
1. 为什么表格方法走不远:状态空间爆炸与泛化缺失
1.1 状态空间爆炸到底有多夸张
上一篇我们处理小网格迷宫时,状态就是几个离散格子,动作也只有上下左右,Q表的大小等于状态数乘动作数,几百个格子而已,填得满也存得下。但真实控制任务里,状态几乎都是连续向量,比如一个小车平衡杆子的经典任务,状态是四个浮点数:小车位置、速度、杆子角度和角速度。这四个维度每一个都是连续值,理论上取值可以有无限多种。
很多人会想:我把每个维度离散化成10档不就行了?四个维度就是10的四次方,一万个状态,听起来好像还可以接受。但问题是一旦维度变高,这个数字会以指数级别爆炸。如果换成视觉输入,比如直接把一帧画面当作状态,假设画面是84×84的灰度图,每个像素256种灰度,那这个状态的组合数量是256的7056次方,这个量级已经远超可观测宇宙中所有原子的数量。所以当我们说“Q表存不下”时,不是工程上内存不够,而是从数学上就根本不可能枚举完所有状态。
1.2 泛化能力是表格方法的死穴
即使我硬着头皮把连续状态离散化做了,表格方法还有一个更致命的问题:它对没见过的状态完全无能为力。表格里每个格子都是独立存储的,状态A学到的经验,对只差一点点的状态B没有任何帮助。
打个比方,你在迷宫的(2,3)位置学到了“向右走最好”,到了几乎一模一样的(2,4),表格方法还是从零开始盲目试探。真实环境里,状态是连续变化的,智能体不可能把所有状态都走一遍,它必须学会举一反三。这种能力叫泛化。搬出神经网络的原因就在这:神经网络可以拿当前状态作为输入、输出每个动作的Q值,训练过程中参数是共享的,相邻状态会学到相似的输出,天然具备泛化能力。
1.3 用函数Q(s,a;θ)替换那张Q表
从表格到函数逼近,本质上就是把“查表”变成了“拟合函数”。原来Q(S,A)是一张表里的一个格子,现在写成Q(S,A;θ),θ是神经网络的参数。训练方式也从“填表”变成了“梯度下降更新θ”。你可以这样理解:Q表像一个电话查号台,每个号码都要手动登记;函数逼近更像搜索引擎,你输入一个关键词,它根据训练时见过的相似内容给一个估计结果。这个估计可能不完全准,但相邻输入的结果会连续变化,所以哪怕它从没见过当前这个精确状态,也能给出一个大体靠谱的价值判断。
我最早学习DQN时的最大顿悟就在这里:强化学习不是为了记住什么状态做了什么,而是为了找到一个能把“状态”映射到“动作好坏”的可学习的函数。有了这个认知,后面所有技巧都变得顺理成章了。
2. 弄懂DQN的两个关键机制:经验回放与目标网络
2.1 经验回放:把连续的样本打乱再学
直接拿神经网络替换Q表之后,你立刻会发现一个训练问题:标准Q-learning的更新是用当前一步的经验立即更新,但神经网络要求训练数据满足独立同分布。强化学习的样本是序列产生的,前后相邻的样本高度相关。
举个例子,在平衡杆子任务里,如果智能体正在拼命往右推,那接下来几百步里它看到的画面几乎都一样,采样的动作也高度相似。如果拿着这一串相似样本连续训练网络,模型会短期内快速偏向某个方向,然后完全忘了之前的经验,训练曲线像过山车一样乱蹦。经验回放就是专门治这个问题的:建立一个缓存区,把每一步的(状态、动作、奖励、下一状态、是否结束)都存进去,训练时不去按顺序取,而是随机抽出一小批来更新网络。这样把样本的时间相关性打散,网络看到的每一批数据都来自各个时期,梯度更新更平稳。
还有一个隐藏好处:样本复用率大幅提升。强化学习的样本获取成本很高,尤其真实机器人,每一步都花钱花时间。经验回放让一个样本可以被反复抽到,相当于一份数据反复用,样本利用率高了很多。基于这些原因,我把经验回放看作DQN能稳定训练的第一大功臣。
2.2 目标网络:让训练目标慢下来
第二个关键机制是目标网络。为什么需要它?因为Q-learning在更新时用到了自举:算当前Q值的“标准答案”时,需要一个下一状态的Q值作为目标,而这个下一状态的Q值又是用正在更新的同一个网络算出来的。相当于一个人一边改自己的答案,一边拿改过的答案当标准答案,最后很容易原地打转甚至发散。
训练过程中的直观表现是:每一步算出来的TD目标(奖励加上下一状态的最大Q值)都在剧烈变化,网络把目标从10改成20,再从20改成8,神经网络就像在追一个不停蹦跳的靶,怎么也追不中。DQN的做法是额外维护一个参数基本冻结的网络,专门用来计算TD目标,而在线网络只负责输出当前Q值并更新。冻结的目标网络每隔一段时间同步一次在线网络的参数,让目标值以一个较慢的速度逼近真实值,训练过程就稳定多了。
我在实际代码里更习惯用软更新而不是隔N步硬拷贝。软更新的做法是每次更新后,让目标网络参数向在线网络挪动一小步,比例τ一般取0.005左右。这样目标网络的变化是连续且柔和的,实现起来也就一行循环的事,却比硬更新省心不少,后面讲踩坑时我会再说细。
2.3 DQN的损失函数与一组可靠的默认超参
DQN的训练损失不算复杂。每一批回放样本里,对每个样本计算:
target = r + gamma * max_a' Q_target(s', a') * (1 - done) loss = MSE(Q_online(s, a), target)其中done标志为1时,下一状态的Q值不应该参与计算,因为回合已经结束,后面没有东西可学了。MSE或者Smooth L1都可以,我一般用Smooth L1多一点,因为对异常目标值没那么敏感。
下面这组超参是我在中小型控制任务上比较放心的起点,照着设置基本不会翻车。
| 超参数 | 推荐取值 | 备注 |
|---|---|---|
| 经验回放缓冲区大小 | 50000到100000 | 太小容易被相关性影响,太大则训练初期待机太久 |
| 批次大小 | 64到128 | 不是越大越好,太大可能让更新过于平滑导致训练变慢 |
| 折扣因子gamma | 0.99 | 任务越长,gamma越接近1;短任务可以0.9 |
| 学习率 | 1e-4到3e-4 | 比监督学习常用的10倍小一点,强化学习对参数太敏感 |
| 目标网络软更新tau | 0.005 | 若用硬更新,一般每1000步拷一次参数 |
| epsilon衰减 | 大约占训练总步数的70%到80% | 探索不能衰减太快,前期探索不足会锁死在局部策略 |
这些默认值不是我拍脑袋写的,每一档都有它们的意义。比如epsilon衰减占比我建议放长一点,因为强化学习前期对环境的探索严重不足,你让它前百分之十的步数就闷头贪心,它很可能永远看不到足够多的奖励信号。
3. 从DQN到Double、Dueling、Noisy:升级路线图
3.1 Double DQN:拆掉max操作的正向偏差
原始DQN里计算TD目标用的是max,这个max看似天经地义,其实会引入系统性的过估计。原因是神经网络的输出总归有误差,误差方向有正有负。max操作会把所有动作输出的最大值当作“真实动作价值”,相当于在比较多个估计时,专门挑那个被噪声抬高最多的结果。偶尔一两次没问题,长期累积下来Q值会被越估越高,高到一定程度策略就飘了。
Double DQN的思路特别简单粗暴:把“选动作”和“算价值”分开。用在线网络选出下一状态的最优动作,再用目标网络计算该动作的价值。公式是这样:
a* = argmax_a Q_online(s', a) target = r + gamma * Q_target(s', a*) * (1 - done)由于“选择”和“评估”用的是两个网络,即使在线网络把某个动作高估了,目标网络不一定同样高估它,过估计的幅度就被压下去了。这段逻辑真的只是几行代码的改动,但效果非常稳定,最早看到实验结果时我都有点吃惊,同样的任务,Double DQN达到同样得分需要的步数明显少于原始DQN。
3.2 Dueling DQN:把Q值拆成V和优势
Dueling DQN的思路看起来更“结构主义”。它把Q值拆成两部分:
Q(s, a) = V(s) + A(s, a)其中V(s)是状态本身的价值,不管动作是什么,这个状态值多少钱;A(s,a)是优势函数,表示某个动作相对其他动作好多少。为了数值稳定性,一般会对优势减去该状态下所有动作优势的均值。
为什么这样拆会让网络更容易训练?因为很多场景下,状态好坏和动作选择是两码事。比如自动驾驶在空旷直道上,当前状态本身就很安全,选择直行还是轻微加速差异并不大;更关键的信息是“这条直道状态很不错”,而不是“某个动作比另一个好多少”。如果网络结构把Q值直接当作一个整体输出,它需要同时拟合“状态总体价值”和“动作间差异”两件事。而Dueling结构让价值流专注学V(s),优势流只负责学动作间的相对差异,训练压力分散了,收敛自然更快更稳。
3.3 Noisy Net与探索策略的平滑性
DQN系列最经典的探索策略是epsilon-greedy,以概率ε随机选动作,其余时间选当前最优。这个策略的探索噪声加在动作空间上,带来的一个尴尬是:训练后期即使策略已经很好了,仍有固定比例的跳变动作。比如ε已经衰减到0.02,也就是每100步还有2步是完全随机的,这会导致动作轨迹抖动,策略很难细化。
Noisy Net则把噪声加在参数空间,不直接随机选动作。实现方式是把每个线性层的参数从固定值变成μ+σ乘以一个噪声项,这个σ是可学习的。训练早期网络会产出较大的参数噪声,动作选择自然丰富;后期随着训练收敛,σ自动缩小,探索幅度变小,动作轨迹比epsilon-greedy顺滑得多,学习到的策略精细度明显提升。
这三个变体不是互斥的,完全可以叠加。把这套组合拳再加进优先级经验回放、n步回报和分布视角的价值输出,就是当年那个著名的Rainbow模型。对刚入门的朋友我不建议一次全上,先跑通原始DQN,再逐个加Double和Dueling,每加一个就观察收益曲线有没有变好,这样你能直观感受到每个技巧在做什么。
4. 首次让DQN跑起来:CartPole实验的完整记录
4.1 环境与网络:一个很克制的开局
这一步需要一个可以反复实验的控制类环境。我习惯用一个经典的“倒立摆”任务来测试DQN代码:小车在水平轨道上移动,顶端连接一根杆子,每一步小车只能选择向左推或向右推,目标就是让杆子尽量长时间保持直立。它的状态空间是四维连续值,动作空间是二维离散动作,正好是DQN最舒服的适用场景。
这种任务用现在的网络结构跑,一个两层的MLP就绰绰有余,每个隐藏层128个神经元,激活函数用ReLU,输出层输出两个动作的Q值。不要一上来就把网络做得很大,任务简单时大网络的收益很有限,反而更容易过拟合训练噪声。
4.2 训练循环:回放、采样、软更新
下面这份代码是我平时训练DQN的标准框架,核心部分就是回放、采样、软更新这三步,没有加额外花活。
import torch import torch.nn as nn import numpy as np import random from collections import deque class QNet(nn.Module): def __init__(self, state_dim, action_dim, hidden=128): super(QNet, self).__init__() self.fc1 = nn.Linear(state_dim, hidden) self.fc2 = nn.Linear(hidden, hidden) self.out = nn.Linear(hidden, action_dim) def forward(self, x): x = torch.relu(self.fc1(x)) x = torch.relu(self.fc2(x)) return self.out(x) class ReplayBuffer: def __init__(self, capacity): self.buf = deque(maxlen=capacity) def push(self, s, a, r, s2, done): self.buf.append((s, a, r, s2, done)) def sample(self, batch_size): batch = random.sample(self.buf, batch_size) s, a, r, s2, d = map(np.array, zip(*batch)) return ( torch.tensor(s, dtype=torch.float32), torch.tensor(a, dtype=torch.long), torch.tensor(r, dtype=torch.float32), torch.tensor(s2, dtype=torch.float32), torch.tensor(d, dtype=torch.float32), )训练主循环我通常这样组织:
def train_dqn( env, episodes=500, buffer_size=50000, batch_size=128, gamma=0.99, lr=3e-4, tau=0.005, start_eps=1.0, end_eps=0.02, decay_steps=200000, ): obs_dim = env.observation_space.shape[0] act_dim = env.action_space.n online_net = QNet(obs_dim, act_dim) target_net = QNet(obs_dim, act_dim) target_net.load_state_dict(online_net.state_dict()) optimizer = torch.optim.Adam(online_net.parameters(), lr=lr) buffer = ReplayBuffer(buffer_size) total_steps = 0 done = True eps = start_eps for _ in range(episodes): if done: state, _ = env.reset() episode_reward = 0.0 epsilon = max(end_eps, start_eps * (1.0 - total_steps / decay_steps)) if random.random() < epsilon: action = env.action_space.sample() else: with torch.no_grad(): q = online_net(torch.tensor(state, dtype=torch.float32).unsqueeze(0)) action = q.argmax().item() next_state, reward, terminated, truncated, info = env.step(action) done = terminated or truncated buffer.push(state, action, reward, next_state, float(done)) state = next_state episode_reward += reward total_steps += 1 if len(buffer.buf) >= batch_size: s, a, r, s2, d = buffer.sample(batch_size) q_values = online_net(s).gather(1, a.unsqueeze(1)).squeeze(1) with torch.no_grad(): next_actions = online_net(s2).argmax(dim=1, keepdim=True) next_q = target_net(s2).gather(1, next_actions).squeeze(1) target = r + gamma * next_q * (1.0 - d) loss = nn.functional.smooth_l1_loss(q_values, target) optimizer.zero_grad() loss.backward() torch.nn.utils.clip_grad_norm_(online_net.parameters(), 10.0) optimizer.step() for p, tp in zip(online_net.parameters(), target_net.parameters()): tp.data.copy_(tau * p.data + (1.0 - tau) * tp.data) if done: print(f"episode_reward={episode_reward:.1f}")这里注意一个细节:算下一状态的TD目标时,用在线网络选最优动作,用目标网络算值,这其实已经默认做了Double DQN的改造。这样写不需要额外维护两条逻辑,而且从实验效果看确实优于原始DQN。
4.3 观察reward曲线:怎么判断它真的在学
我第一次跑这个任务时,前几十个回合的得分一直在一百出头徘徊,看起来好像完全没进步,其实不用慌。前期网络在乱试,尝试过程本身就在积累回放样本,真正稳定的上升往往出现在缓冲区积累了足够多样本之后。跑了几百个回合之后,reward曲线会呈阶梯状上升,从一百多慢慢爬到三四百,最后稳定在四五百。
特别说明:单个回合得分波动极大是正常的,可能上一回合470,下一回合突然只有200。网络更新时目标本身在移动,策略偶尔会“犯浑”,所以不要拿单回合曲线给自己制造焦虑。我会额外打印一个最近100回合的滑动平均分,这个指标平滑得多。如果滑动平均分在持续抬升,就说明学习方向没问题;如果长时间横盘,那就得回头检查探索噪声是不是衰减太早、奖励尺度是不是不对。
5. DQN训练时的常见翻车点与我的调参顺序
5.1 奖励尺度:让TD目标失控的元凶
DQN训练里最容易被忽略的坑是奖励的绝对数值。如果任务每一步都给你+1分,而且一口气能跑一千步,那TD目标的期望值会轻松累积到几十甚至上百。神经网络输出层的回归要直接拟合这么大范围的数值,梯度很容易不稳,loss曲线可能一直下不来。
我处理这类问题的办法是先看奖励范围,再决定是否做裁剪。最简单的是把所有奖励裁到[-1, 1],代价是丢失了奖励大小之间的精细区分;更温和的做法是取一个缩放系数,把奖励除以某个常数,让典型TD目标落在个位数级别。还有更进阶的,比如把Q网络的输出层也做缩放或者归一化目标值,我入门阶段觉得先做奖励裁剪就足够稳了。
5.2 目标网络的更新方式:硬更新最容易中途崩盘
有段时间我懒得写软更新,就用最简单的硬更新,每1000步把在线网络参数整个拷给目标网络。结果跑起来前面几十万步还挺好,突然某个节点Q值就直线崩到负几千。排查了很久才发现,问题出在硬更新会让目标网络瞬间跳变,在线网络更新到一半时目标突然换了标准,正在收敛的梯度方向被猛地带偏,只要发生一次,后续会连锁发散。
换成软更新之后,同样一套代码跑了几天都没再崩。软更新的代价是目标网络变化慢,训练速度略降,但这换来的是稳定性上的收益,非常值。这也是为什么我推荐大家直接从软更新起步,而不是先写硬更新再踩一遍坑。
5.3 复现与调参:少看单次实验,多看中位数
强化学习对随机种子敏感是出了名的,同一个超参,换一个种子可能效果差出一大截。刚入门时我常常为一个好看的结果兴奋,换到别的任务又跑不出来,后来才学会一个简单原则:不要拿单次实验说事,至少固定五个随机种子,每个种子跑完整训练,最后比较reward的多条曲线时看中位数和上下四分位带,而不是只挑最好的一条。
调参顺序我也是吃过几次亏之后养成了固定习惯。先固定网络结构和批次大小,把奖励尺度和gamma调明白,这两个决定目标值范围;然后调epsilon退火的总步数,这个决定探索是否充分;最后才考虑加Double、Dueling或者改Noisy Net。每次只动一个变量,记录曲线,再动下一个,不然出现问题时你根本不知道是哪个改动拖了后腿。
5.4 样本效率与更复杂任务的边界提醒
如果你在强化学习这块打算继续往下走,我得提前泼盆冷水:DQN这类基于回放的经验方法在样本效率上并不高。在多彩色小游戏或者真实机器人控制任务里,动辄几百万步的采样才能勉强学会基础操作,这个成本靠纯软件模拟还能接受,真实环境就非常肉痛了。所以后来才有了很多面向样本效率的算法路线。但DQN承载的思想——经验回放、目标网络、价值分解——几乎是所有现代深度强化学习算法的底色,把它理解透,学后面任何方法都会快很多。我自己每次回看这个阶段,都觉得当时那些笨拙的调试和翻车经历,恰恰是把这些概念真正吃进脑子里的过程。