简介:这份PDF资料聚焦深度学习算法Q-learning原理,面向强化学习入门者与需要夯实价值函数基础的算法工程师,帮助厘清value-based方法中critic网络与actor策略的分工。内容围绕价值函数展开,系统讲解Monte-Carlo与Temporal-Difference两种计算方式的差异,并延伸到Q-function的输入输出形式、如何借助Q值挑选更优动作,以及目标网络与exploration等训练技巧,对连续状态动作空间下的策略迭代也有涉及。资源包共1个PDF文件,约999KB,篇幅紧凑,适合作为强化学习专题的速查与复习材料。目前已有1314人学习,读者可从中获得Q-learning核心公式推导、MC与TD对比示例及训练稳定性处理思路,便于对照理解深度强化学习中的价值评估与策略改进流程。
1. Q-learning 到底“深”在哪:从查表到神经网络的临界点
很多人第一次听到“深度学习算法 Q-learning”,脑子里浮现的是两套东西硬拼在一起:一边是 Q-learning 那张 Q 表,一边是 CNN、反向传播。实际上这个标题真正指向的,是用深度神经网络替代 Q 表来逼近动作价值函数这条路线,也就是 DQN 及其衍生算法。它解决的核心痛点是:当状态空间连续或维度爆炸时,传统 Q-learning 的表格根本存不下、也查不过来。
我见过太多人在格子世界(GridWorld)里跑通 Q-learning 后,直接把它搬到 Atari 或机械臂控制上,结果 Q 表内存瞬间爆掉,或者状态离散化后泛化能力几乎为零。这个标题适合两类人:一类是已经理解 Bellman 方程、想搞明白“深度”到底加在哪里的强化学习入门者;另一类是手上有连续状态控制任务、需要判断该不该上 DQN 的工程师。接下来我会把 Q-learning 的数学内核、深度化的具体改造点、可复现的最小代码,以及 Target network、经验回放这些必调参数一次讲透。
2. 从 Bellman 方程到 DQN:深度化改造的三个关键决策
2.1 为什么表格 Q-learning 在连续状态上必然翻车
传统 Q-learning 的更新公式是:
Q(s, a) ← Q(s, a) + α [ r + γ · maxₐ' Q(s', a') − Q(s, a) ]
这张 Q 表是一个二维结构,行是状态、列是动作。格子世界只有几十个状态时,它工作得很好。但一旦状态变成图像像素、关节角度、传感器读数,状态数量就是连续的或者组合爆炸的。你可能会想:那我离散化不就行了?血泪经验是,离散化粒度粗了,不同物理状态被映射到同一个格子,策略直接失效;粒度细了,Q 表大小指数增长,内存和采样效率都撑不住。
更本质的问题是泛化。表格 Q-learning 对没见过的状态没有任何推理能力,每个状态都要单独采样足够多次才能学到合理值。而深度神经网络天然具有函数逼近和泛化能力:相似的状态输入会产生相似的 Q 值输出,这正是我们需要的。
所以深度化的第一个决策就是:用一个参数为 θ 的神经网络 Q(s, a; θ) 来替代 Q 表。输入是状态(可以是向量、图像),输出是每个动作对应的 Q 值。更新目标变成最小化以下损失:
L(θ) = E[ ( r + γ · maxₐ' Q(s', a'; θ⁻) − Q(s, a; θ) )² ]
这里 θ⁻ 是目标网络的参数,这就是第二个关键决策。
2.2 Target network:为什么需要一份“滞后”的参数副本
如果你直接用同一个网络同时计算预测值和目标值,训练会非常不稳定。原因很简单:每次梯度更新后,目标值 r + γ · max Q(s', a'; θ) 也跟着变了,相当于你在追一个自己也在跑的目标,数学上这叫“移动目标问题”(moving target problem),极易导致发散。
Target network 的做法是:复制一份结构完全相同但参数为 θ⁻ 的网络,专门用来计算 TD 目标。θ⁻ 不参与梯度更新,而是每隔 C 步从当前网络 θ 硬拷贝一次,或者用 Polyak 平滑更新:
θ⁻ ← τ·θ + (1−τ)·θ⁻
我一般会先用硬更新,C 取 1000 到 10000 之间,看任务复杂度。软更新 τ 通常取 0.001 到 0.01。这个机制带来的稳定性提升是立竿见影的,不加 Target network 的 DQN 在 Atari 上几乎学不动。
2.3 经验回放:把“连续决策”拆成“独立样本”
强化学习的原始数据是高度时序相关的:相邻两步的状态几乎一样,直接拿来做 SGD 会破坏样本独立同分布假设,导致网络在局部数据上过拟合、灾难性遗忘。
经验回放的思路很直接:用一个容量为 N 的缓冲区存 (s, a, r, s', done) 五元组,训练时从中随机采样一个 mini-batch。这样既打破了时序相关性,又提高了样本利用率——一条经验可以被多次抽取。缓冲区大小通常取 10⁵ 到 10⁶,太小了容易过拟合近期经验,太大了早期旧策略的数据会拖后腿。
提示:经验回放和 Target network 是 DQN 能工作的两个支柱,缺一不可。很多“DQN 不收敛”的问题,排查到最后都是这两个机制没配对。
3. 用 PyTorch 跑通 DQN 的最小闭环:代码逐段拆解
3.1 网络定义与动作选择:从 Q 值到实际动作
先定义一个简单的 MLP 作为 Q 网络。输入维度是状态维度,输出维度是动作数。
import torch import torch.nn as nn import torch.optim as optim import numpy as np from collections import deque import random class QNetwork(nn.Module): def __init__(self, state_dim, action_dim, hidden=128): super().__init__() # 三层 MLP,ReLU 激活,输出每个动作的 Q 值 self.net = nn.Sequential( nn.Linear(state_dim, hidden), nn.ReLU(), nn.Linear(hidden, hidden), nn.ReLU(), nn.Linear(hidden, action_dim) ) def forward(self, x): return self.net(x)逻辑说明:输入 state_dim 维状态向量,经过两层 128 维隐藏层,输出 action_dim 个 Q 值。隐藏层宽度 128 对多数低维控制任务够用,Atari 那种图像输入需要换成 CNN。参数初始化默认用 PyTorch 的 Kaiming 初始化,一般不需要手动改。
动作选择用 ε-greedy:
def select_action(q_net, state, epsilon, action_dim): if random.random() < epsilon: return random.randint(0, action_dim - 1) # 探索 with torch.no_grad(): q_values = q_net(torch.FloatTensor(state).unsqueeze(0)) return q_values.argmax().item() # 利用ε 从 1.0 线性衰减到 0.01 或 0.05,衰减步数一般取总训练步数的 10% 到 30%。衰减太快会过早陷入局部最优,太慢则收敛慢。
3.2 经验回放缓冲区与训练循环
缓冲区用 deque 实现,存满后自动弹出最旧的经验:
class ReplayBuffer: def __init__(self, capacity=100000): self.buffer = deque(maxlen=capacity) def push(self, s, a, r, s_next, done): self.buffer.append((s, a, r, s_next, done)) def sample(self, batch_size): batch = random.sample(self.buffer, batch_size) s, a, r, s_next, done = zip(*batch) return (torch.FloatTensor(np.array(s)), torch.LongTensor(a), torch.FloatTensor(r), torch.FloatTensor(np.array(s_next)), torch.FloatTensor(done)) def __len__(self): return len(self.buffer)训练循环的核心是计算 TD 目标并做梯度下降:
def train_step(q_net, target_net, optimizer, buffer, batch_size, gamma): if len(buffer) < batch_size: return s, a, r, s_next, done = buffer.sample(batch_size) # 当前网络的 Q(s, a) q_values = q_net(s).gather(1, a.unsqueeze(1)).squeeze(1) # 目标网络的 max Q(s', a'),done 时目标就是 r with torch.no_grad(): next_q = target_net(s_next).max(1)[0] target = r + gamma * next_q * (1 - done) loss = nn.MSELoss()(q_values, target) optimizer.zero_grad() loss.backward() # 梯度裁剪,防止梯度爆炸 torch.nn.utils.clip_grad_norm_(q_net.parameters(), max_norm=10) optimizer.step()参数说明:batch_size 通常取 32 到 256,太小梯度噪声大,太大显存吃紧且更新次数少。gamma 是折扣因子,0.99 是常见起点,任务步数越长越接近 1。梯度裁剪 max_norm 取 10 或 1,对稳定性帮助很大。
3.3 Target network 同步与完整训练骨架
每 C 步把当前网络参数拷贝到目标网络:
def sync_target(q_net, target_net): target_net.load_state_dict(q_net.state_dict()) # 训练骨架 state_dim, action_dim = 4, 2 # 以 CartPole 为例 q_net = QNetwork(state_dim, action_dim) target_net = QNetwork(state_dim, action_dim) sync_target(q_net, target_net) optimizer = optim.Adam(q_net.parameters(), lr=1e-3) buffer = ReplayBuffer(capacity=100000) epsilon = 1.0 epsilon_min = 0.05 epsilon_decay = 0.995 target_update_freq = 500 total_steps = 0 for episode in range(1000): state = env.reset() done = False while not done: action = select_action(q_net, state, epsilon, action_dim) next_state, reward, done, _ = env.step(action) buffer.push(state, action, reward, next_state, float(done)) train_step(q_net, target_net, optimizer, buffer, 64, 0.99) state = next_state total_steps += 1 if total_steps % target_update_freq == 0: sync_target(q_net, target_net) epsilon = max(epsilon_min, epsilon * epsilon_decay)逻辑说明:每个 episode 结束后衰减 ε,每 500 步同步一次目标网络。学习率 1e-3 是 Adam 的常用起点,如果 loss 震荡厉害可以降到 1e-4。这套骨架在 CartPole 上通常几百个 episode 就能稳定到 200 步以上。
4. 调参避坑:DQN 训练不收敛的五个排查方向
4.1 现象:Q 值爆炸或变成 NaN
原因:学习率过大、奖励尺度没归一化、或者没有梯度裁剪。TD 目标里的 max 操作本身就会放大估计误差,奖励如果是几十上百的量级,Q 值很容易冲到 1e6 以上。
解决:先把奖励缩放到 [-1, 1] 或 [0, 1] 区间;学习率从 1e-3 降到 1e-4 试试;加上梯度裁剪;检查 Target network 是否真的在同步,如果 θ⁻ 一直不更新,目标值会越来越大。
4.2 现象:训练前期 reward 上升,后期突然崩掉
原因:经验回放缓冲区里旧策略的数据占比过高,网络被拉回到旧策略的 Q 值分布上。或者 ε 衰减太快,探索不足导致陷入局部最优。
解决:缩小缓冲区容量,比如从 10⁶ 降到 10⁵;检查 ε 衰减曲线,确保在性能稳定前 ε 不低于 0.1;可以尝试优先经验回放(Prioritized Experience Replay),给 TD 误差大的样本更高采样概率。
4.3 现象:Q 值整体偏高,策略却很差
原因:这是 DQN 的经典问题——max 操作带来的过估计偏差(overestimation bias)。因为 maxₐ' Q(s', a') 总是倾向于选被高估的动作,误差正向累积。
解决:换 Double DQN,用当前网络选动作、目标网络算 Q 值:
with torch.no_grad(): best_actions = q_net(s_next).argmax(1) next_q = target_net(s_next).gather(1, best_actions.unsqueeze(1)).squeeze(1) target = r + gamma * next_q * (1 - done)这一改动几乎不增加计算量,但对稳定性提升明显。
4.4 现象:训练速度极慢,GPU 利用率低
原因:经验回放采样和网络前向传播在 CPU 上串行执行,或者 batch_size 太小导致 GPU 等数据。
解决:把缓冲区采样放到单独线程;batch_size 提到 128 或 256;如果状态是图像,用帧堆叠(frame stacking)而不是单帧输入,减少网络推理次数。
4.5 现象:换了环境后所有超参数都要重调
原因:DQN 对奖励尺度、状态归一化、网络结构都很敏感,这不是你的错,是算法本身的局限。
解决:固定一套状态归一化流程(比如 running mean/std);奖励裁剪到固定范围;网络最后一层不加激活函数;先用小网络(64 维隐藏层)快速验证流程,再放大。
注意:不要同时改多个超参数。每次只动一个,记录 reward 曲线和 Q 值均值,否则出了问题根本不知道是哪个参数导致的。
5. 从 DQN 到进阶变体:判断该不该继续深挖的信号
当你把基础 DQN 跑通后,下一步该往哪走?我的习惯是先看三个信号:如果 Q 值过估计严重,上 Double DQN;如果训练方差大、不同随机种子结果差异明显,上 Dueling DQN 或 Noisy Net;如果是离线数据、不能和环境交互,转 CQL 或 IQL 这类离线强化学习算法。
Dueling DQN 的改动很小,把 Q 网络拆成状态价值 V(s) 和优势函数 A(s, a) 两路输出,最后合并:
Q(s, a) = V(s) + A(s, a) − meanₐ' A(s, a')
这个结构让网络在有些状态下不需要精确区分每个动作的价值,学起来更稳。代码上只是把最后一层拆成两个分支,训练逻辑完全不变。
验证方法上,我一般会画三条曲线:每 episode 的累计 reward、平均 Q 值、TD loss。reward 上升但 Q 值同步暴涨,说明过估计;reward 震荡但 Q 值平稳,说明探索不够;loss 下降但 reward 不涨,说明网络在拟合噪声。用 origin 或 matplotlib 画置信区间时,至少跑 5 个随机种子,取均值和标准差,单次实验的曲线没有说服力。
最后一个具体技巧:优先经验回放的 α 和 β 参数。α 控制优先级程度,取 0.6 是常用值;β 控制重要性采样权重,从 0.4 线性退火到 1.0。这两个参数不调,PER 的效果可能还不如均匀采样。我踩过的坑是 β 一直设 0.4,结果重要性采样偏差太大,训练后期直接发散。
说到底,Q-learning 的深度化不是把表格换成网络就完事了,Target network、经验回放、ε 衰减、梯度裁剪、奖励缩放,每一个都是让这套算法从“能跑”到“能用”的必经环节。我自己的习惯是每接一个新任务,先用 64 维隐藏层、batch 64、lr 1e-3 跑 200 个 episode,看 reward 有没有上升趋势,有就继续调,没有就先查数据预处理和奖励设计。希望帮到你。
本文还有配套的精品资源,点击获取