简介:这份资源围绕深度学习算法 Q-learning 原理展开,面向强化学习入门者与需要夯实价值函数基础的开发者,帮助理解 value-based 方法中 critic 网络如何评估 actor 策略,而非直接训练 policy。内容涵盖 Value Function 的输入输出含义、蒙特卡洛与时间差分两种计算方式的差异、Q-function 对状态-动作对的估值逻辑,以及目标网络与探索策略等实用技巧,并配有游戏场景示例辅助理解。资源包共 1 个 PDF 文件,约 999KB,以图文讲义形式系统梳理从 MC、TD 到 Q-learning 的推导脉络,适合作为课堂笔记或自学参考。目前已有 1314 人学习,读者可借此建立对 Q 值迭代、策略改进与训练稳定性问题的整体认知,为后续深入 DQN 等算法打下基础。
1. 从「深度学习算法 Q-learning 原理」说起:为什么一个 1989 年的表格法,今天还在被 DQN、离线强化学习反复翻出来
很多人第一次搜「深度学习算法 Q-learning 原理」,脑子里其实混着两个东西:一个是 Q-learning 本身,一个是 DQN。前者是 1989 年 Watkins 提出的表格型时序差分算法,后者是 2013 年 DeepMind 用神经网络替掉 Q 表之后跑通 Atari 的那套东西。把这两个混为一谈,后面调参就会一路玄学。
Q-learning 要解决的问题很朴素:一个智能体在环境里反复试错,怎么学出一张「在状态 s 下做动作 a 到底值多少」的表。它的核心是那个带 max 的更新式,用下一步的最优估计来修正当前估计,属于 off-policy。深度学习进来之后,Q 表换成参数为 θ 的网络,于是有了 DQN,也才有了 Target network、经验回放这些工程补丁。
这篇写给两类人:一类是想把 Q-learning 的数学和代码真正跑通的新手,一类是已经在用 DQN 但被不收敛、Q 值爆炸折磨的从业者。下面从原理推到可复现的最小实现,再讲清楚 DQN 里那几个必调参数和踩坑点。
2. Q-learning 原理拆解:更新式里每个符号到底在干什么
2.1 从贝尔曼最优方程到那个带 max 的更新式
Q-learning 的理论根子是贝尔曼最优方程。定义动作价值函数 Q(s,a) 为「在状态 s 执行动作 a,之后一直按最优策略走能拿到的期望累计回报」。写成递归形式:
Q*(s,a) = E[ r + γ · max_{a'} Q*(s',a') ]
右边那个 max 是关键,它假设下一步一定选当前估计里最好的动作,所以 Q-learning 学的是最优策略的价值,跟你实际探索时用的策略无关。这就是 off-policy 的来源,也是它能配合经验回放的原因——回放池里的老数据,哪怕来自一个很差的旧策略,照样能用来更新最优 Q。
实际算法不直接解方程,而是用采样做随机逼近。每走一步拿到 (s, a, r, s'),就朝目标值挪一小步:
Q(s,a) ← Q(s,a) + α · [ r + γ · max_{a'} Q(s',a') − Q(s,a) ]
方括号里那项叫 TD error(时序差分误差)。α 是学习率,γ 是折扣因子。整个算法就这一行,剩下的全是围绕它做工程。
2.2 表格实现:一个 20 行的 numpy 版本先跑通
在写神经网络之前,先用表格版把逻辑跑通,能省掉后面大量「到底是算法错还是网络错」的排查时间。下面是一个在 FrozenLake 这类离散小环境上能直接跑的骨架:
import numpy as np # n_states: 状态数, n_actions: 动作数 # alpha: 学习率, gamma: 折扣因子, eps: 探索率 def q_learning(env, n_states, n_actions, alpha=0.1, gamma=0.99, eps=0.1, episodes=5000): Q = np.zeros((n_states, n_actions)) # Q 表初始化,全 0 是常见起点 for ep in range(episodes): s = env.reset() done = False while not done: # epsilon-greedy:以 eps 概率随机探索,否则取当前最优 if np.random.rand() < eps: a = np.random.randint(n_actions) else: a = np.argmax(Q[s]) # 平局时 argmax 取第一个 s_next, r, done, _ = env.step(a) # 核心更新:用下一步的最大 Q 作为目标 best_next = np.max(Q[s_next]) td_target = r + gamma * best_next * (1 - done) # 终止态无未来 Q[s, a] += alpha * (td_target - Q[s, a]) s = s_next return Q逻辑说明:td_target里乘(1 - done)是必须的,终止状态之后没有未来回报,如果不屏蔽,智能体会把「到达终点」当成还能继续拿分,学出来的策略会绕圈。np.argmax在多个动作 Q 值相等时固定返回第一个,早期 Q 表全 0 时会导致智能体一直选动作 0,所以探索率 eps 在前期不能太小。
参数说明:α 取 0.1 是离散小环境的稳妥起点,太大震荡、太小收敛慢;γ 取 0.99 适合回合较长的任务,如果任务本身很短(比如 10 步内结束),0.9 反而更稳;eps 固定 0.1 是偷懒做法,工程上一般用从 1.0 线性衰减到 0.01 的 schedule。
2.3 为什么表格法一定会撞墙:状态爆炸的量化感受
表格法的存储是 O(|S| × |A|)。FrozenLake 是 16 个状态,随便存。但换成 Atari 的一帧 210×160×3 的 RGB 图像,状态空间是 256 的十万次方级别,Q 表连一行都存不下。哪怕把图像降采样成 84×84 灰度,也是 256^7056 种状态。
这就是深度学习介入的动机:不去枚举状态,而是用一个参数化函数 Q(s,a;θ) 去拟合,输入连续状态,输出每个动作的价值。网络参数量固定,泛化能力来自函数逼近本身。代价是原来那套收敛保证没了——表格 Q-learning 在合适条件下能证明收敛到最优,换成神经网络之后,收敛性、稳定性全变成工程问题,DQN 的三个补丁就是为此而生。
3. 从 Q 表到 DQN:Target network 和经验回放到底补了哪个洞
3.1 直接拿网络替 Q 表会死在哪:两个致命相关性
把更新式里的 Q 换成 Q(s,a;θ),损失函数写成:
L(θ) = E[ ( r + γ · max_{a'} Q(s',a';θ) − Q(s,a;θ) )² ]
问题出在目标值r + γ · max Q(s',a';θ)里也含 θ。每次梯度下降,你既在改预测值,又在改目标值,相当于追一个自己也在跑的靶子。这是第一个洞:目标不稳定。
第二个洞来自采样。强化学习的数据是按时间顺序产生的,相邻的 (s,a) 高度相关。如果按顺序喂给网络,就违反了深度学习「样本独立同分布」的假设,梯度方向会剧烈摆动,网络容易灾难性遗忘。
3.2 Target network:把目标值冻结成一个旧副本
Target network 的思路很直接:复制一份参数 θ⁻,专门用来算目标值,隔一段时间才把当前网络的参数同步过去。
import torch import torch.nn as nn class QNet(nn.Module): def __init__(self, obs_dim, n_actions): super().__init__() self.net = nn.Sequential( nn.Linear(obs_dim, 128), nn.ReLU(), nn.Linear(128, 128), nn.ReLU(), nn.Linear(128, n_actions) ) def forward(self, x): return self.net(x) # 两份网络:在线网络负责选动作和被更新,目标网络只负责算 target online = QNet(obs_dim, n_actions) target = QNet(obs_dim, n_actions) target.load_state_dict(online.state_dict()) # 初始参数必须一致 # 每 C 步硬同步一次,C 常见取 1000~10000 def sync_target(online, target): target.load_state_dict(online.state_dict())逻辑说明:目标值改用target(s')计算,θ⁻ 在一段窗口内是常量,损失函数对 θ 的梯度就干净了。同步周期 C 是核心参数:太小等于没冻结,太大目标过时、学习滞后。Atari 上经典取值是 10000 步,小任务上 500~2000 更合适。
参数说明:还有一种 soft update(Polyak 平均),θ⁻ ← τθ + (1−τ)θ⁻,τ 取 0.001~0.01,每步都轻微同步。硬同步适合回合制任务,soft update 在连续控制里更常见。
3.3 经验回放:把时间相关性打散
经验回放维护一个固定容量的缓冲区,存 (s, a, r, s', done) 五元组,训练时随机采样一个 batch。
import random from collections import deque class ReplayBuffer: def __init__(self, capacity=100000): self.buf = deque(maxlen=capacity) # 满了自动丢最老的 def push(self, s, a, r, s_next, done): self.buf.append((s, a, r, s_next, done)) def sample(self, batch_size): batch = random.sample(self.buf, batch_size) # 均匀随机,打散相关性 s, a, r, s_next, done = zip(*batch) return (torch.tensor(s, dtype=torch.float32), torch.tensor(a, dtype=torch.long), torch.tensor(r, dtype=torch.float32), torch.tensor(s_next, dtype=torch.float32), torch.tensor(done, dtype=torch.float32))逻辑说明:random.sample的均匀采样让一个 batch 里的样本来自不同时间点,梯度方向更接近真实期望。容量 capacity 决定「记忆长度」:太小,老经验丢得快,容易在局部打转;太大,早期差策略的数据长期污染训练。Atari 用 100 万,小任务 1 万到 10 万足够。
参数说明:采样前要保证缓冲区里至少有 batch_size 条数据,否则random.sample直接报错。工程上一般先随机探索若干步填满最小容量再开始训练。
3.4 一个能跑通的最小 DQN 训练循环
把上面几块拼起来,核心训练步长这样:
import torch.nn.functional as F optimizer = torch.optim.Adam(online.parameters(), lr=1e-3) buffer = ReplayBuffer(capacity=50000) batch_size = 64 gamma = 0.99 sync_every = 1000 for step in range(total_steps): # 1. 选动作:epsilon-greedy,eps 从 1.0 衰减到 0.05 eps = max(0.05, 1.0 - step / 20000) if random.random() < eps: a = env.action_space.sample() else: with torch.no_grad(): a = online(torch.tensor(s, dtype=torch.float32)).argmax().item() # 2. 执行并存入回放池 s_next, r, done, _ = env.step(a) buffer.push(s, a, r, s_next, done) s = s_next if not done else env.reset() # 3. 攒够数据再训练 if len(buffer.buf) < batch_size: continue bs, ba, br, bs_next, bd = buffer.sample(batch_size) # 4. 用目标网络算 target,切断梯度 with torch.no_grad(): next_q = target(bs_next).max(dim=1)[0] td_target = br + gamma * next_q * (1 - bd) # 5. 只对选中的动作算 loss q_values = online(bs).gather(1, ba.unsqueeze(1)).squeeze(1) loss = F.mse_loss(q_values, td_target) optimizer.zero_grad() loss.backward() # 梯度裁剪,防止 Q 值爆炸,这是血泪经验 torch.nn.utils.clip_grad_norm_(online.parameters(), max_norm=10.0) optimizer.step() # 6. 定期同步目标网络 if step % sync_every == 0: sync_target(online, target)逻辑说明:第 4 步的torch.no_grad()不能省,否则 target 的梯度会顺着 θ⁻ 回传,破坏冻结的意义。第 5 步用gather只取实际执行动作对应的 Q 值,因为损失只关心那一个动作的估计,其他动作的 Q 值这一步不参与。第 6 步的梯度裁剪是 DQN 里最容易被忽略但最救命的一行,Q 值一旦发散,loss 会直接变 nan。
参数说明:lr 取 1e-3 是 Adam 的常规起点,用 SGD 的话要降到 1e-4 量级;batch_size 64 是显存和稳定性的折中,32 也能跑但梯度噪声大;gamma 0.99 适合长回合,短任务用 0.95。
4. 避坑与排查:DQN 不收敛时先看这 5 个地方
4.1 现象:loss 一路下降但回报不涨
原因:最常见的是 target 计算里漏了(1 - done),或者 done 的语义搞反了。另一个可能是评估时还在用 epsilon-greedy,探索噪声把真实性能盖住了。
解决:先确认终止态处理,再单独跑一次纯贪心评估(eps=0),看回报曲线。如果贪心评估涨而训练回报不涨,就是探索率的问题,不是算法问题。
4.2 现象:Q 值越来越大,最后变成 nan
原因:TD 目标里的 max 操作会系统性地高估 Q 值(maximization bias),加上自举(bootstrapping),误差会被反复放大。学习率偏大时尤其明显。
解决:先加梯度裁剪,把 max_norm 设到 10 或 5。如果还炸,把 lr 降到 1e-4,或者上 Double DQN——用在线网络选动作、目标网络算价值,把选择和价值评估解耦,能显著压住高估。
4.3 现象:训练初期完全不动,几千步后突然起飞
原因:回放池里全是随机策略产生的垃圾数据,网络在拟合噪声。这是正常现象,不是 bug。
解决:给一个最小预热步数(比如 5000 步)再开始训练,或者用较小的学习率熬过前期。别在 2000 步没动静就改代码,很多翻车都是改早了。
4.4 现象:换个随机种子结果天差地别
原因:DQN 对初始化、探索顺序、回放采样都敏感,单次实验的方差很大。
解决:固定所有随机源(numpy、torch、环境),跑至少 3~5 个种子取均值和置信区间。origin 画强化学习置信区间曲线那套做法在这里是刚需,别拿一条曲线下结论。
4.5 现象:显存够但训练极慢
原因:每步都在 CPU 和 GPU 之间搬小 tensor,或者回放池用 Python list 存 numpy 数组,采样时逐个转换。
解决:回放池底层用预分配的 numpy 数组而不是 deque,采样后一次性转 tensor 再上 GPU。batch_size 适当加大能提高 GPU 利用率,但别超过 256,否则更新频率下降反而拖慢收敛。
5. 进阶:Double DQN 与离线场景下 Q-learning 的边界
把 Q-learning 用到真实项目里,绕不开两个问题:高估和不能在线试错。前者用 Double DQN 解决,后者就是离线强化学习(offline RL)要处理的事。
Double DQN 的改动只有一行。原来 target 是target(s').max(),现在改成用在线网络挑动作、目标网络给价值:
with torch.no_grad(): # 在线网络选动作,目标网络评估该动作的价值 best_a = online(bs_next).argmax(dim=1, keepdim=True) next_q = target(bs_next).gather(1, best_a).squeeze(1) td_target = br + gamma * next_q * (1 - bd)这一行改动在 Atari 上能把中位数人类归一化得分拉高一大截,代价几乎为零,属于必上项。我现在的习惯是:只要用 DQN,默认就写 Double DQN,没有理由用原始版本。
离线场景更麻烦。你手上只有一份固定数据集,不能和环境交互,Q-learning 的 max 操作会去查询数据分布之外的动作,估出来的值没有任何依据,这叫外推误差。IQL、CQL 这类离线算法本质上都在给这个 max 加约束。判断要不要上离线算法,看一个指标就够:数据集覆盖的动作分布有多窄。如果数据来自单一策略、动作多样性差,直接套 DQN 基本必崩。
验证方法上,我一般分三层:先看 TD error 是否稳定收敛,再看贪心评估回报,最后看 Q 值和真实回报的相关性。第三层最容易被跳过,但它是判断「网络是不是在自欺欺人」的唯一手段——把一批轨迹的真实折扣回报和网络预测的 Q 值画散点,如果相关性低于 0.5,说明 Q 值已经不可信,调参救不回来,得回去查数据或网络结构。
一个具体技巧:训练中途定期存 checkpoint,然后拿每个 checkpoint 跑固定种子的评估回合。DQN 的性能曲线经常是「涨上去又掉下来」,最终模型未必是最好的那个。我吃过这个亏,现在一律按评估回报挑模型,而不是按训练步数。
希望帮到你。
本文还有配套的精品资源,点击获取