1. 从一个求最大的游戏说起:DRL到底在学什么
第一次接触深度强化学习的人,往往会被一大堆概念砸晕。策略、状态空间、奖励函数、价值迭代、策略梯度、经验回放……每个术语看起来都对,串在一起又不知道它们在解决什么问题。我一开始也是这样,直到我把整个 DRL 想明白成一件事,才豁然开朗:它本质上就是在学一个"怎么选才能让长期回报最大"的决策函数。
你看,强化学习的套路很简单。一个智能体(agent)在某个状态(state)下,做出一个动作(action),环境返回一个奖励(reward),然后进入下一个状态。循环往复。这不是什么新鲜玩意儿,股票交易、游戏对战、机器人控制、推荐系统,本质上都是这么个闭环。真正麻烦的是:动作的后果往往不是立即兑现的,而是延迟显现的。你今天吃多了,明天不会立刻胖,但三个月后你会看到后果。智能体最大的难题就是在这种延迟回报下,搞清楚"到底哪一步走对了"。
这时候就需要值函数登场了。值函数回答的问题非常朴素:站在当前这个位置,我到底有多赚?如果把强化学习比作爬山,策略是"往哪走"的指南针,那值函数就是一张"海拔图"——告诉你每个山头有多高。有了这张海拔图,指南针才能规划出最优路线。这也是为什么我想先写清楚值函数、贝尔曼方程和达尔文进化式的 DQN,它们共同构成了 DRL 的地基。
这篇文章面向两类人。一类是刚接触强化学习、打算从零搭建认知框架的新手;另一类是已经跑过一些代码,但面对"为什么 DQN 要搞目标网络""为什么要经验回放"这类问题依然说不清原理的开发朋友。我会尽量用贴近实际的语言把概念讲透,配合最小可运行的代码示例,让你对从 Q-learning 到 DQN 的这条演进线形成完整直觉。
2. 值函数与贝尔曼方程:强化学习的数学引擎
2.1 状态值函数和动作值函数,到底在算哪笔账
值函数分两种。第一种叫状态值函数,记作 V(s),它衡量的是"从状态 s 出发,按照某个策略走下去,预期能拿到的累计奖励总和"。注意关键词:按照某个策略。同一个状态,换一个策略,值就不同。你站在山顶,如果策略是"直接跳下去",那值可能很低;如果策略是"沿缆车下山",值就高很多。
第二种叫动作值函数,记作 Q(s, a),它衡量的是"在状态 s 下先执行动作 a,之后按照策略继续走,预期能拿到的累计奖励总和"。它比 V(s) 多了一层"动作的条件",所以更像一张"状态-动作对照表",这也是 Q-learning 里那个 Q 的由来。
这两个函数的核心差别,用一句话总结:V 问的是"这里好不好",Q 问的是"在这里做某件事好不好"。做决策的时候,光知道 V 还不够,因为你在状态 s 下至少得选一个动作才能前进;只有 Q 能直接告诉你每个动作的收益预期,你挑 Q 值最大的那个动作执行就行。这也是为什么在控制任务中,Q 函数更常用。
2.2 贝尔曼方程:把一个长期问题拆成递归问题
现在问题来了:怎么算这些值函数?
如果从头计算"从这个状态走到终点的累计奖励",你得遍历所有可能的轨迹,这在稍微复杂一点的环境里就是天文数字。贝尔曼方程的精妙之处在于,它把"长期累计"拆成了"即时奖励 + 下一步的期望值",硬生生把一个无穷长的链条变成了一步的递归:
V(s) = R(s) + γ * V(s')
用大白话讲:当前状态的价值 = 当下拿到的奖励 + 折扣后的下一状态价值。为什么能这么拆?因为"从当前状态出发的长期回报"和"下一步状态的长期回报"在本质上是同一类问题,只是状态变了。这种自我指涉的递归形式,是强化学习所有算法的理论根基。
Q 函数也有同样的递归形式:
Q(s, a) = R(s, a) + γ * max(Q(s', a'))
这个式子更关键,它引入了 max 操作。什么意思呢?智能体在下一步会做一个动作,我们假设它会选未来收益最大的那个动作,这就是最优价值函数的定义。有了这个递归式,理论上我们就能从终点逐步往回推,算出每个状态的价值,这就是所谓的动态规划思想。但在实际情况中,环境的状态转移概率我们往往不知道,而且状态空间太大,根本没法从终点往回推,于是才演化出了一套靠采样驱动的学习方法。
2.3 折扣因子 γ:为什么未来的钱不如现在的钱值钱
贝尔曼方程里的 γ(gamma)是个 0 到 1 之间的数,叫折扣因子。它的作用很多人理解成"让未来收益打个折",但这个折扣不是随便拍的,它背后有几个实打实的考虑。
第一是规避不确定性。现实里你的模型是近似提供的 PPT,未来的状态和奖励都有噪声,距离越远,预测越不可靠,所以遥远的收益不应该和眼前的收益一样被信任。第二是数学上的完备性。如果任务没有明确的终点(这种任务叫持续任务),累计奖励可能无限增长,没法收敛;有了 0<γ<1 的折扣,无穷级数就能收敛到一个有限值。第三是行为上的引导。γ 越接近 1,智能体越"高瞻远瞩";γ 越小,它越"急功近利"。
在实际项目里,γ 怎么选?游戏类任务通常取 0.95~0.99,因为游戏往往有长达几百步的时序依赖。如果是推荐系统这种即时反馈强的场景,0.9 就够用了。记住一句话:γ 是你对"远见"的定价,不同的定价会直接改变控制策略的行为风格。
3. 从 Q-learning 到 DQN:函数近似的关键一跃
3.1 表格法的天花板:状态一多,表就炸了
理论讲完了,落到实现上。经典的 Q-learning 把 Q 函数存成一张表,行是状态,列是动作,更新公式长这样:
Q(s, a) ← Q(s, a) + α * (R(s, a) + γ * max(Q(s', a')) - Q(s, a))
新Q = 旧Q + 学习率 × (目标值 - 旧Q)
括号里那一坨叫什么?叫时序差分误差(TD error)。它衡量的是"当前估计"和"实际看到的奖励 + 下一步最优估计"之间的差距,然后沿着差距的方向修正一点。这是一个非常漂亮的增量式算法,不需要环境模型,只需要不断采样试错,就能逐渐逼近正确的 Q 表。
但问题出在"表"字上。表格能覆盖的状态-动作对是有限的。一个国际象棋棋盘,状态数大约是 10 的四十多次方;一个视频游戏,屏幕像素组合出来的状态更是天文数字。你想把这么多状态全部列成一张表,内存直接报警,而且大部分状态你可能永远都访问不到。这就是表格法最致命的短板:它不懂泛化。
3.2 函数近似:用参数化模型代替查表
既然查不了表,那就换个思路——用一组参数来"拟合"Q 函数。这就是函数近似:把 Q(s, a) 从一张表,替换成 Q(s, a; θ),其中 θ 是一组可学习的参数。线性回归是一种简单的近似,神经网络是更强大的近似器。输入是状态,输出是每个动作的 Q 值估计,参数用梯度下降来更新。
这种做法的本质是什么?是压缩和泛化。表里存的是每一个具体状态的独立估值,参数化模型存的是"状态的规律"。打个比方:表格法像你背联系人电话本,每加一个号码你记一行;函数近似像你记住了"区号+号码"的规律,陌生号码你也能猜个八九不离十。这就是深度强化学习相对传统强化学习最大的突破——把"记住"变成了"理解"。
但函数近似也带来代价:非稳定性。表格法更新一个格子不影响其他格子;参数模型更新一组参数,可能同时影响所有状态的输出。这就像你调一个旋钮,整个房间的音量都在变,很可能调完一个地方,另一个本来好的地方反而坏了。
3.3 DQN 的三员大将:回放、固定目标、硬编码
2015 年 DQN(Deep Q-Network,深度 Q 网络)在游戏任务上惊艳登场,核心思想就是"用神经网络做 Q 函数的近似器 + 经验回放 + 目标网络",这三板斧每一刀都切在函数近似不稳定性的要害上。
第一板斧叫经验回放(Experience Replay)。把交互产生的样本(当前状态、动作、奖励、下一状态)存进一个循环缓冲区,训练时随机抽一批小批量来更新网络。这解决了什么问题?两个。一是打破样本间的相关性。强化学习的样本是时序串行的,前后高度相关,如果按顺序更新,网络会被"牵着走";随机抽取能打乱时序,让样本独立同分布。二是样本复用。一条经验能被反复学习,数据利用率大幅提升。这也是为什么同样的模型,配上回放缓冲区,训练速度能快好几倍。
第二板斧叫目标网络(Target Network)。我们算 TD 目标时,用的是 R + γ * max(Q(s', a')),如果这个目标本身就是当前的 Q 网络算出来的,那么它每一轮都在变,相当于你在追赶一个不断移动的箭靶,网络很难收敛。DQN 的做法是维护一份"冷冻副本"——目标网络,它的参数每隔若干步才从主网络同步一次。这样在一段时间内,TD 目标是相对稳定的,主网络的更新就有了明确的"方向感"。
第三板斧是把回报压缩到固定区间。原版 DQN 把奖励 clip 到 [-1, 1],看似简单粗暴,其实非常聪明。因为不同游戏的奖励尺度千差万别,有的奖励是几百,有的只有零点几,如果不用统一的尺度,梯度大小就没法控制,训练极不稳定。压缩之后,不管游戏多复杂,误差信号的大小始终有界,算法稳定性大幅提升。你在自己项目里复现 DQN 时,即使不完整复刻这一步,也要考虑对奖励做归一化。
4. 动手实现一个最小 DQN:把纸面公式跑起来
理论说了这么多,得写点能跑的东西。下面我用一个极简的 DQN 实现,把手上的概念串起来。注意,这不是一个工程级的完整框架,而是一个便于你观察核心机制的教学实例。
4.1 环境与网络定义
我选一个很简单的离散控制环境:状态是低维浮点向量,动作是离散的(只能往上或往下)。你手头没有对应环境也没关系,核心代码逻辑是通用的,能直接迁移到任何离散动作空间的任务。
import random import numpy as np import torch import torch.nn as nn import torch.optim as optim from collections import deque class QNetwork(nn.Module): def __init__(self, state_dim, action_dim, hidden_dim=32): super().__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 值,而不是输入一个动作输出一个值。这比逐动作预测高效得多,而且在选择最优动作时,直接取输出向量的最大值索引就行,省掉了遍历动作的循环。
4.2 经验回放缓冲区的实现
经验回放本身不难,难在理解它为什么要这样设计。缓冲区就是一个 deque,设置了最大容量,满了就会挤出旧经验。这本身是个先进先出的队列,但采样时我们完全随机抽取,跟顺序无关。
class ReplayBuffer: def __init__(self, capacity=10000): 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) state, action, reward, next_state, done = zip(*batch) return ( torch.FloatTensor(state), torch.LongTensor(action).unsqueeze(1), torch.FloatTensor(reward).unsqueeze(1), torch.FloatTensor(next_state), torch.FloatTensor(done).unsqueeze(1) ) def __len__(self): return len(self.buffer)我特别说一下done这个字段。很多人在写 DQN 时会忽略它,导致训练出来的智能体表现诡异。想想看:如果下一步是终止状态,那它后面就没有任何未来回报了,TD 目标应该是 R 本身,而不是 R + γ * max(Q(s', a'))。如果不特殊处理终止状态,网络就会"幻想"出一个不存在的未来收益。这个小细节直接影响训练质量,务必在计算目标时乘上 (1 - done):
target = R + γ * (1 - done) * max_Q_next
4.3 训练主循环:探索、采样、更新
接下来是核心的训练循环。我加了三段跟前面理论一一对应的代码逻辑:选取动作时用 ε-贪心策略平衡探索与利用;训练时从回放缓冲区随机采样;周期性地把参数拷贝到目标网络。
def select_action(q_net, state, epsilon): if random.random() < epsilon: return random.randrange(action_dim) with torch.no_grad(): state_tensor = torch.FloatTensor(state).unsqueeze(0) q_vals = q_net(state_tensor) return q_vals.argmax().item() def soft_update target def update(q_net, target_net, optimizer, batch): state, action, reward, next_state, done = batch q_value = q_net(state).gather(1, action) # 当前估计 with torch.no_grad(): max_q_next = target_net(next_state).max(1, keepdim=True).values target = reward + 0.99 * (1 - done) * max_q_next loss = nn.MSELoss()(q_value, target) optimizer.zero_grad() loss.backward() optimizer.step() # target 网络同步 def update_target(q_net, target_net): target_net.load_state_dict(q_net.state_dict())这里最关键的一行是gather(1, action)。它做的事情是:从 Q 网络的输出里,把里面对应动作的那个 Q 值提取出来。比如网络输出了 [1.2, 0.8],动作是 0,那就取 1.2;动作是 1,就取 0.8。这一步把"网络对状态的完整预测"和"实际执行的动作"对应起来,不难,但很容易写错。写错的话,梯度就不对了,训练出来的网络会在后台半死不活地乱飘。
4.4 训练循环里的那几个超参数怎么定
主循环的逻辑不复杂,但超参数很多时候决定了训练成败。我把我常用的初始配置放出来,作为一个安全的起点:
| 超参数 | 推荐值 | 理由 |
|---|---|---|
| 学习率 lr | 1e-3 | 过大容易震荡,过小收敛太慢 |
| 折扣因子 γ | 0.99 | 任务长度几百步时,长期回报占比合适 |
| ε 初始值 | 1.0 | 初期完全随机探索 |
| ε 衰减率 | 0.995(每步) | 探索率逐步降低,让智能体逐渐"收心" |
| ε 最小值 | 0.01 | 保留一点随机性,防止彻底保守 |
| 回放缓冲区大小 | 10000 | 需要足够多样化的样本,又不能太老 |
| 批次大小 | 32 | 兼顾梯度稳定性和内存开销 |
| 目标网络更新频率 | 每 500 步 | 太频繁等于没有目标网络,太慢可能导致目标过时 |
ε 衰减我特别提一句。你可以把 ε 理解为智能体的"好奇心",初始时好奇心拉满,什么动作都试;随着学习推进,好奇心逐渐收敛,行为越来越依赖已有的 Q 估计。但别把它衰减到 0,留一个 0.01 的尾巴,防止智能体永远走老路、失去发现新机会的可能。这种"留尾巴"的思想在强化学习里随处可见,本质是防止过早收敛到次优解。
4.5 一个训练里常被忽略的动作:目标网络参数延迟同步
我见过很多新手把目标网络当成"每隔 N 步直接把主网络参数复制过去",这样没问题。但还有个更平滑的变种叫软更新(soft update):
θ_target ← τ * θ_main + (1 - τ) * θ_target
也就是每次训练一小步时,目标网络的参数朝主网络的方向挪一小点。τ 通常取 0.005。硬更新简单粗暴,软更新更平滑,训练曲线一般也更稳。我个人在项目里偏好在训练早期用硬更新(每 1000 步一次),让目标网络快速跟上;训练后期切到软更新,减缓震荡。当然你也可以一刀切只用软更新,它同样能跑得很好,只是调参时注意 τ 别太大,否则目标网络跟着主网络一起飘,问题就回来了。
5. 常见问题与排查技巧实录
这一段是实操经验的精华所在,都是我踩过的坑、或者帮别人调代码时反复见到的问题。先看两张最能说明问题的表,后面我挑关键的展开讲。
5.1 训练不收敛的四张"体检报告单"
| 现象 | 可能原因 | 排查方向 |
|---|---|---|
| Q 值一路飙升,几万步后变成天文数字 | 奖励未归一化 / 目标网络未固定 / 学习率过大 | 检查奖励尺度,检查目标网络同步 |
| 策略学了也白学,永远在一个动作附近打转 | 探索率衰减过快 / 奖励太稀疏 | 调低 ε 衰减,增加奖励塑形 |
| 训练前几百步涨得很快,之后突然崩盘 | 缓冲区里混入了过旧且冲突的经验 | 检查回放缓冲区大小,清空后重训 |
| 表现平平,换了随机种子结果差很多 | 网络初始化影响 / 超参数鲁棒性差 | 多跑几个种子,找方差大的超参 |
5.2 梯度方向是对的,为什么不收敛?
这是 DQN 新手最大的困惑:公式都写对了,损失也在下降,但智能体水平就是上不去。这个现象的根源往往不在单个更新步骤,而在目标的不一致性。想想看,DQN 的训练目标不是固定的标签,而是由当前策略产生的新样本。策略变了,样本分布就变了,网络学到的"预期"也随之变化。这形成了一个闭环,环里任何一环不稳定,整个训练就会来回震荡。
解决这个问题,最直接的手段就是前面反复提到的经验回放和目标网络。如果这两样都做了还是不稳定,我的经验是:先检查奖励归一化和梯度裁剪。torch.nn.utils.clip_grad_norm_(model.parameters(), 1.0)这一行代码能避免极端梯度带来的参数爆炸,便宜又好用。
5.3 探索和利用的平衡怎么掌握?
探索多了,智能体像个无头苍蝇,浪费大量时间在无效动作上;探索少了,它又容易困在局部最优里出不来。我常用的做法是动态调节 ε:训练初期设 1.0,大量随机探索;等每一局的平均回报开始上升时,再加快 ε 的衰减。
但比 ε 衰减更重要的,是奖励函数的设计。我强烈建议你在动手调参之前,先花时间审视你的奖励函数。奖励过于稀疏,智能体可能训练几万步都没见过一次正奖励,梯度信号直接消失;奖励过于密集,又可能诱导它走捷径。强化学习领域有一句老话:"Reward is what you get, not what you want."你设计的奖励,必须精确反映"你希望智能体达成的最终行为",而不是"你猜测能引导它达成目标的行为"。这个差别,调试之后你会体会很深。
5.4 亲测有效的调参顺序
遇到训练效果差,不要一股脑把所有超参数都试一遍,那样你根本没法归结出哪个参数起的作用。我推荐这个顺序:
先固定网络结构(两层隐藏层、每层 32 到 64 个神经元就够用),把奖励归一化做好,确认目标网络参数能正常同步。然后调 γ 和学习率,一个控制长远眼光,一个控制更新步幅,这两个定下来之后,整个训练的大框架就稳了。最后才去细化 ε 的衰减策略和回放缓冲区的大小。缓冲区太小,样本多样性不够;太大,老样本和新策略完全不匹配,一样是噪声。一般情况下,缓冲区容量设成单局步数的 10 到 50 倍,是一个经验法则。
提示:如果你训练到一半发现 Q 值和平均回报一起持续上升,但测试时策略却很差,先别急着怀疑网络结构,去检查一下测试环境里有没有引入训练时没有的随机因素。评估环境的不一致,比模型本身的 bug 更容易掩蔽问题。
6. 走上 DRL 这条路,我踩过最值的一跤
从 Q-learning 到 DQN 的演进,说到底是"从查表到理解规律"的演进。Q-learning 用一张表记住状态的价值,DQN 用一组参数泛化状态的价值。理解了这一跃,后面所有的进阶算法——Double DQN 缓解 Q 值过估计,Dueling DQN 拆分状态价值和动作优势,优先经验回放重新分配样本权重——全都是围绕"如何让函数近似更准、更稳"展开的修补。
我最后的体会是:强化学习跟监督学习最大的不同,在于它没有一个固定不变的"正确答案"。你训练一个图像分类器,标签是死的;你训练一个智能体,目标是自己定义出来的,数据是自己跑出来的。所以在 DRL 里,调试的耐心和系统思维比数学直觉更稀缺。我见过不少人在第一步就卡住,不是因为他们不懂贝尔曼方程,而是忘了"DQN 的原理拆开看只有三步:采样、回放、更新"。
如果你手头已经有了一个 DQN 能稳定跑通的小任务,下一步我建议你做一个实验:删掉经验回放,看看训练曲线会发生什么变化;再把目标网络去掉,看看 Q 值会不会越涨越离谱。亲手触碰到那股"崩坏"的滋味,你对这三个组件背后的动机,会比看任何教程都理解得更深刻。这就是 DRL 这条路上的第一道分水岭,迈过去之后,后面的 Double DQN、PPO、SAC 都会亲切得多。