☰
Actor-Critic强化学习:从原理实现到训练排查
2026/10/26 3:42:41 网站建设 项目流程

强化学习学到策略梯度这一层,很多人会卡在同一个地方:REINFORCE 能跑通,但方差大得离谱,同一份代码换个随机种子就从 500 分掉到 200 分;回头去看值函数那一套,DQN 在离散动作上挺稳,一碰到连续控制就没了脾气。Actor-Critic这个框架就是在这种两头受气的处境下被反复拎出来的——它把"选动作"和"评价值"拆成两个角色,让两者互相喂养。这篇文章不聊空泛的概念,重点讲清楚三件事:Actor-Critic 凭什么能把方差压下来、一份能直接跑通的实现代码长什么样、以及训练崩掉的时候该按什么顺序去查。适合已经把 Q-learning 和策略梯度各写过一遍、想把这套东西真正用起来的人,也适合刚接触强化学习、想找个能落地骨架的读者。

1. 为什么单靠策略梯度或值函数都会卡住

在讲 Actor-Critic 的结构之前,得先把它的两个"前身"各自的毛病说透,不然很难理解它为什么要长成现在这样。这不是为了讲历史,而是因为后面调参时遇到的绝大多数问题,根源都能追回到这两套方法的固有缺陷上。你把这两块短板记牢,后面看到优势函数、基线、TD 误差这些名词时,就不会觉得是凭空冒出来的补丁。

1.1 REINFORCE 的方差到底大在哪

策略梯度的核心公式是拿整条轨迹的回报去乘对数概率的梯度,形式上是无偏的,但这个"无偏"是有代价的。一条轨迹从起点走到结束,中间可能二十步,也可能五百步,每一步的回报都是同一串随机数累加出来的,用这串数去给每一个动作定"好坏",噪声非常大。

举个具体的例子。假设某个环境里,一个动作在当下做对了,但后面因为探索走了两步臭棋,最终整条轨迹的回报是负的。按照蒙特卡洛的算法,这个正确动作会被判定为"坏动作",梯度方向就是反的。单次看这是噪声,可如果这种误判大量出现,策略就会被拽着往错误方向走,表现出来就是训练曲线剧烈震荡、学半天不涨。

更麻烦的是方差随轨迹长度增长。设每一步回报的方差是 σ²,T 步累加后方差大致按 T 的量级放大,标准差按 √T 增长。序列越长,梯度估计越不可靠。这解释了一个很常见的现象:同一个算法,在回合长度二三十步的环境里跑得挺好,换到一个回合几百步的环境里就彻底废掉。

降方差最直接的手段就是引入基线。把"回报"换成"回报减去一个基准值",这个基准值只要不依赖当前动作,期望上就不改变梯度,但能大幅削掉公共的那部分波动。而 Actor-Critic 里的 Critic,本质上就是在学这个基准值。所以后面看到"优势函数"这个词,脑子里应该立刻浮现出"回报减基线"这个朴素的动作,它没那么玄。

1.2 值函数方法在连续动作上的尴尬

Q-learning 那一套的逻辑是:把每个状态动作对的长期价值估出来,然后选价值最高的动作。离散动作空间下这很自然,动作就那么几个,挨个算一遍取最大值就行。但一旦动作变成连续的,比如机械臂的关节角度、车辆的转向幅度,这个 argmax 就没法直接算了。

常见的补救办法有几种。一是把连续动作离散化成若干档,代价是精度损失,而且维度一高就组合爆炸——七自由度的机械臂,每维分十档就是千万级的动作组合。二是把 Q 网络对动作的输入做成可微的,然后对动作求梯度反解出最优动作,这要求整个值函数对动作是光滑的,实现起来对网络结构限制很大。三就是干脆放弃"先估值再取最大"这条路,改成直接学一个策略分布。

策略梯度这条路天然适配连续动作:网络输出一个高斯分布的均值和标准差,采样就得到动作,不需要任何 argmax。但它又回到了方差问题。所以两边的优势恰好互补——策略梯度擅长处理动作空间,值函数擅长压低方差。把它们拼起来,就是 Actor-Critic 最原始的动机。

1.3 Actor-Critic 的组合逻辑与代价

组合的方式很直白:让 Actor 负责输出动作分布,让 Critic 负责估计状态的价值,然后用 Critic 的估计去构造 Actor 的更新信号。Actor 拿到的不再是原始的蒙特卡洛回报,而是一个经过 Critic 加工过的、噪声小得多的信号;Critic 则用实际经历到的回报来校正自己的估计。

这个结构带来的好处不只是降方差,还有一个隐性的收益:它支持单步更新和自举。纯策略梯度必须等一条轨迹走完才能算回报,在线学习的场景下这很不灵活;有了 Critic,每走一步就能用r + γV(s')去算 TD 误差,立刻更新一次参数。这让它在交互式、长时运行的任务里更实用。

代价也很清楚,主要有三条。第一是引入了偏差,因为 Critic 的估计永远不完美,用它当基线或者当优势,梯度就不再是无偏的,偏差的大小取决于 Critic 学得好不好。第二是训练变成了两个网络互相耦合,Actor 的策略在变,Critic 面对的数据分布也在变,稳定性问题变复杂。第三是超参数变多,两个学习率、两个网络结构、更新频率的配比,任何一个没调好都会崩。

注意:Actor 和 Critic 之间是"互相依赖但不共享目标"的关系。Actor 的目标是最大化期望回报,Critic 的目标是准确预测回报。一旦你把两者的学习率调成一样,或者让 Critic 学得太慢,Actor 就会在错误的评价体系里越走越偏。

理解了这三条代价,后面看各种改进版本就顺了。A2C 是加并行降相关,GAE 是在偏差和方差之间找更细的调节旋钮,PPO 是限制策略更新幅度防止一步走崩。所有花活都是在跟这三条代价博弈。

2. Actor-Critic 的核心构件拆解

这一节把两个网络的职责、输入输出、梯度路径彻底拆开看。很多实现跑不通,不是公式抄错了,而是没搞清楚"哪一步该 detach、哪一步该回传",所以我会把梯度流向讲得细一些。

2.1 Actor:把策略参数化成概率分布

Actor 的输出必须是一个合法的概率分布,这决定了它的网络结构和损失函数形式。离散动作下,最后一层输出动作数个 logits,过一遍 softmax 得到概率,采样用分类分布;连续动作下,输出均值和对数标准差两路,标准差取指数保证为正,采样用高斯分布。

这里有个新手容易踩的点:不要在连续动作的 Actor 里对标准差做 softmax 或者加别的归一化,直接在原始输出上做 clamp 更稳妥。常见的做法是把对数标准差限制在 [-20, 2] 这个区间内,防止训练初期标准差爆成极大值导致动作完全随机,或者塌成极小值导致策略过早失去探索能力。

Actor 的损失函数写起来是-(log_prob * advantage).mean()。这个负号容易被漏。原因是梯度上升要最大化期望回报,而优化器默认做梯度下降,所以取负。如果你发现训练时策略越训越差,先检查这个符号。

还有一个实践里必须加的东西是熵正则。纯粹按优势函数更新,策略会越来越确定,最后塌到一个次优解上不再探索。加一个- entropy_coef * entropy的项,让策略保留一点随机性,通常能明显改善最终表现。系数取值在 0.001 到 0.05 之间比较常见,简单环境取小一点,复杂环境可以大一些。

2.2 Critic:用值函数给动作打分

Critic 的职责相对单纯,输入状态,输出一个标量,代表从这个状态开始的期望累计折扣回报。损失函数就是均方误差(V(s) - target)²,其中 target 有两种算法。

一种是蒙特卡洛式,用整条轨迹实际累加出来的折扣回报当 target,也就是所谓带基线的 REINFORCE。这样 target 是无偏的,但方差大,而且必须等回合结束。另一种是自举式,用r + γV(s')当 target,方差小但引入了偏差,因为 V(s') 本身也是估计值。两种都可以,选哪种取决于你对偏差和方差的容忍度。

我个人在回合较短、能快速结束的环境里倾向用蒙特卡洛 target,实现简单、不容易出玄学问题;在回合长或者需要在线更新的场景里用自举。混合的折中方案就是 TD(λ),用一个 λ 参数插值,但 λ 本身又是一个新超参,工程量会上去。

还有一个必须做的细节是目标值的 detach。算r + γV(s')的时候,V(s')必须从计算图里切断,否则你在优化 Critic 时会不小心把梯度也传回到"预测下一次的输入"上,逻辑就乱了。这个 bug 不会报错,只会让训练结果莫名其妙地差。

2.3 优势函数与基线:方差与偏差的平衡

优势函数的定义是 A(s,a) = Q(s,a) - V(s),含义是"这个动作比平均情况好多少"。直接用 Q 值当权重的问题是,如果某个状态本身价值很高,那所有动作的 Q 值都很大,梯度全被这个公共部分放大了,噪声也就跟着放大。减掉 V 之后,只有"高于平均"的部分保留下来,公共项被消掉。

用 TD 误差来估计优势是实践中最常见的做法:δ = r + γV(s') - V(s)。这个量恰好是优势的一个有偏估计,但因为只需要一次转移的经验、方差很小,实际效果往往比无偏的蒙特卡洛估计更好。这也是为什么几乎所有现代实现都在用 TD 残差当优势。

更进一步的是广义优势估计,把多步的 TD 残差做指数加权:

A_t = δ_t + (γλ)δ_{t+1} + (γλ)²δ_{t+2} + ...

λ 取 0 时退化成单步 TD 误差,方差最小偏差最大;λ 取 1 时退化成蒙特卡洛回报减基线,偏差最小方差最大。中间值就是在两者之间做连续调节。这个公式的妙处在于它把"用几步的信息"变成了一个可以调的旋钮,比硬性规定用几步要灵活得多。

注意:优势函数在送进损失之前做一次标准化,也就是减去均值除以标准差,实践中几乎总是有益的。它把不同回合的梯度尺度拉平,避免某一批样本因为奖励量级大而主导更新。加上一个小常数防止除零即可。

2.4 两套网络的梯度流向

这一块是纯实现问题,但坑最多。完整的单次更新流程是这样走的:先把状态送进 Actor 得到分布、采样动作、算 log_prob 并保存;同时把状态送进 Critic 得到 V(s),也保存;环境返回奖励和下个状态,Critic 再算一次 V(s')。

算损失的时候,Actor 的损失只应该对 Actor 的参数产生梯度。也就是说,advantage这一项必须 detach 掉,因为它来自 Critic 的输出,你不希望"为了让优势变大而修改 Critic"。同理,Critic 的损失只应该对 Critic 参数产生梯度,target 里的 V(s') 必须 detach。

如果两个网络共享底层特征提取层,情况会更微妙。共享层会同时收到两个方向的梯度,好处是特征被两个任务共同塑造,坏处是当两个目标冲突时训练不稳定。共享与否是个经验判断:状态维度高、需要复杂特征提取时共享通常有益;状态简单、任务明确时分开更省心。

另外一个常被忽略的点是优化器的更新顺序和频率。理论上先更新谁都可以,但实践中常见两种做法:一是每次同时更新,二是让 Critic 多更新几次再更新 Actor。后者的逻辑是先把评价标准校准好,再让 Actor 照着走,有点像先量好尺子再裁布。在奖励稀疏或者环境噪声大的场景里,第二种做法往往更稳。

3. 手把手实现一个能跑通的 Actor-Critic

理论讲完,下面直接上能跑的代码。我选经典倒立摆环境作为验证平台,因为它的状态维度低、回合长度可控、成功与否一眼能看出来,非常适合作为调试骨架。

3.1 环境选择与网络结构设计

选择这个环境有两个理由。一是状态只有四维(位置、速度、角度、角速度),两个隐藏层就够,不用纠结网络容量;二是奖励每步加一,总分直接反映存活步数,评估指标非常直观,不需要额外设计。

网络结构我采用完全分离的两套 MLP,各带一个 128 单元的隐藏层,激活用 ReLU。没有共享底层,原因是四维状态实在太简单,共享带来的收益微乎其微,反而会引入耦合的不确定性。如果你换到图像输入或者高维连续控制,共享一个特征提取器是值得考虑的。

Actor 的输出维度等于动作数,过 softmax;Critic 的输出维度是 1,不加激活。这个配置在大量入门环境里都能直接用,可以当作默认起点。

import torch import torch.nn as nn import torch.nn.functional as F import torch.optim as optim from torch.distributions import Categorical class ActorNet(nn.Module): def __init__(self, state_dim, action_dim, hidden=128): super().__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 = F.relu(self.fc1(x)) x = F.relu(self.fc2(x)) logits = self.out(x) return F.softmax(logits, dim=-1) class CriticNet(nn.Module): def __init__(self, state_dim, hidden=128): super().__init__() self.fc1 = nn.Linear(state_dim, hidden) self.fc2 = nn.Linear(hidden, hidden) self.out = nn.Linear(hidden, 1) def forward(self, x): x = F.relu(self.fc1(x)) x = F.relu(self.fc2(x)) return self.out(x)

3.2 完整训练循环代码

下面这段是蒙特卡洛式的 Actor-Critic,也就是等整条轨迹走完再统一更新。选这个版本作为起点,是因为它的回报计算最直白、不涉及自举,出错点最少,非常适合第一次把流程跑通。

def compute_returns(rewards, gamma): returns = [] R = 0.0 for r in reversed(rewards): R = r + gamma * R returns.insert(0, R) return returns def train_episode(env, actor, critic, actor_opt, critic_opt, gamma=0.99, entropy_coef=0.01): state, _ = env.reset() log_probs, values, rewards, entropies = [], [], [], [] done = False while not done: s = torch.as_tensor(state, dtype=torch.float32).unsqueeze(0) probs = actor(s) dist = Categorical(probs) action = dist.sample() log_probs.append(dist.log_prob(action)) entropies.append(dist.entropy()) values.append(critic(s).squeeze(-1)) state, reward, terminated, truncated, _ = env.step(action.item()) rewards.append(reward) done = terminated or truncated returns = torch.tensor(compute_returns(rewards, gamma), dtype=torch.float32) values = torch.cat(values) log_probs = torch.cat(log_probs) entropies = torch.cat(entropies) advantage = returns - values.detach() advantage = (advantage - advantage.mean()) / (advantage.std() + 1e-8) actor_loss = -(log_probs * advantage).mean() - entropy_coef * entropies.mean() critic_loss = F.mse_loss(values, returns) actor_opt.zero_grad() actor_loss.backward() actor_opt.step() critic_opt.zero_grad() critic_loss.backward() critic_opt.step() return len(rewards), actor_loss.item(), critic_loss.item()

几个关键点在代码里已经体现出来了:values.detach()切断了优势函数回传到 Critic 的路径;优势做了一次标准化;熵正则用减号加进损失(因为要最大化熵,而优化器做最小化);两个优化器分开清零、分开反向传播。这四处只要错一处,训练结果就会明显变差。

主循环里我用两个不同的学习率,Actor 用 1e-3,Critic 用 3e-3。这个配比不是拍脑袋,原因在后面 3.3 讲。

# 主循环骨架 env = make_env() # 任一离散动作环境的构造函数 state_dim = env.observation_space.shape[0] action_dim = env.action_space.n actor = ActorNet(state_dim, action_dim) critic = CriticNet(state_dim) actor_opt = optim.Adam(actor.parameters(), lr=1e-3) critic_opt = optim.Adam(critic.parameters(), lr=3e-3) for ep in range(2000): length, a_loss, c_loss = train_episode( env, actor, critic, actor_opt, critic_opt) if ep % 20 == 0: print(f"ep={ep}\tsteps={length}\ta_loss={a_loss:.4f}\tc_loss={c_loss:.4f}")

3.3 关键参数的计算与取值依据

参数不是随便填的,每一个都有对应的计算或者经验依据。下面逐个说明。

折扣因子 γ = 0.99。这个值决定了智能体往前看多远。有效视野大致可以用 1/(1-γ) 估算,0.99 对应 100 步左右。倒立摆的回合上限是 500 步,看起来 100 步不太够,但实际不需要看那么远——只要能把杆子稳定住,后面的奖励自然会来。可以验证一下末尾衰减有多大:0.99 的 200 次方约等于 0.134,300 次方约 0.049,500 次方只有 0.0066。也就是说 500 步之外的奖励对当前决策的影响已经可以忽略,这个折扣率是合理的。如果换成 γ=0.9,视野缩到 10 步,智能体会变得短视,学会"先把杆子扶起来但不管后面";如果换成 0.999,视野拉到 1000 步,方差会增大,收敛变慢。

Actor 学习率 1e-3。策略网络直接决定行为,步子大了容易一步迈崩。经验上 Actor 的学习率应该小于等于 Critic。

Critic 学习率 3e-3。值函数需要更快地跟上策略的变化。想象你在调整射击姿势(Actor)和校准瞄准镜(Critic),如果镜子校准得比姿势变化还慢,你永远不知道自己打得准不准。给 Critic 更大的学习率,就是让它先追上。这个 3 倍的比例在很多任务里都work,可以作为一个默认起点。如果你发现 Critic 的损失一直降不下去,可以继续加大;如果 Critic 损失剧烈震荡,说明太大了。

熵系数 0.01。太小起不到探索作用,太大会让策略一直保持随机、收敛不到最优。0.01 是个保守的起点,训练后期如果想让它更确定,可以做线性衰减到 0.001。

隐藏层 128。四维状态输入,128 已经相当宽裕。经验规则是隐藏单元数取输入维度和输出维度的几倍到几十倍之间,太窄学不动,太宽容易过拟合到特定轨迹。判断方法很简单:如果训练损失降不下去,试试加宽;如果训练很好但换种子就崩,试试收窄。

回合数上限 2000。这个环境通常在 300 到 800 个回合内收敛到满分,给 2000 是留余量。判断是否收敛看的是滑动平均步数而不是单回合步数,单回合波动很大,看单点没有意义。

3.4 训练过程记录与曲线解读

我按上面的配置跑了几轮,记录下典型的表现形态,方便你对照自己跑的结果。

前 50 个回合,步数基本在 10 到 30 之间波动,Actor 损失在 0 附近小幅震荡,Critic 损失从较大值快速下降。这个阶段 Critic 在快速学习"这个状态大概值多少分",Actor 的更新信号还比较弱,因为优势被标准化之后每批的差异不明显。

第 50 到 200 回合是爬坡期,步数开始出现明显波动,有的回合能到 100 多,下一回合又掉回 20。这个阶段不用慌,是正常的策略探索。此时如果去看 Critic 损失的曲线,应该是整体下降但带毛刺。

第 200 到 400 回合,滑动平均开始稳定上升,逐步逼近满分。这时候 Critic 损失已经降到很小,Actor 的策略分布也变得尖锐。

超过 400 回合之后,如果还没有达到稳定高分,通常不是训练不够,而是某个配置有问题。这时候别急着加回合数,先去看 4.1 的排查表。

有一个细节值得单独说:如果用固定种子复现,你会看到不同种子之间收敛速度差异能到两三倍。这不是代码有 bug,是这类算法的固有随机性。评估的时候至少跑三到五个种子取平均,否则很容易被单次结果误导——包括误以为自己调出了一个特别好的配置。

4. 训练不收敛的排查手册

这一节是我认为最有价值的部分。网上讲原理的文章很多,但真正卡住人的往往是"代码跑起来了,曲线不对,但不知道从哪查"。下面按症状组织,给出排查顺序。

4.1 症状与原因速查表

症状最可能的原因优先排查项
步数完全不涨,始终在随机水平Actor 损失符号写反、动作采样取错检查损失前的负号、action.item()是否传对
前几百回合正常,之后突然崩到零学习率过大、策略塌缩降 Actor 学习率到 3e-4,加熵正则
曲线锯齿严重,方差极大优势未标准化、批次太小加优势标准化,增大批量
Critic 损失长期不降目标值未 detach、学习率太小检查V(s')是否切断梯度
训练分数高但评估分数低评估时仍在采样、未用确定性策略评估时取 argmax 而非采样
换种子后表现差异巨大单次结果偶然性、初始化敏感多种子平均、加梯度裁剪

排查顺序我建议从"符号和维度"开始,再到"超参数",最后才怀疑"算法本身不合适"。这个顺序的原因很实际:符号和维度错误是确定性的、必然导致失败,而超参数问题往往只是表现不佳;先排除确定性错误,能省掉大量瞎调参的时间。

一个非常实用的习惯是:拿一个极小的状态空间做冒烟测试。比如把环境换成一个只有两三个状态、奖励固定的玩具问题,看算法能不能学会。如果连玩具问题都学不会,那就是实现层面的硬错误,跟超参数没关系。这个办法帮我省过很多次时间。

4.2 学习率与更新频率的联动坑

Actor 和 Critic 的学习率不是独立的两个数字,它们的比例关系才是关键。我试过一组配置:Actor 用 1e-2、Critic 用 1e-3,结果训练到中途策略突然崩掉,然后再也起不来。原因很清楚——Actor 步子太大,一步迈到一个很差的策略上,Critic 还没来得及重新评估,优势信号全是错的,Actor 继续沿着错误信号走,越走越偏。

反过来如果 Actor 1e-4、Critic 1e-2,训练会变得极其缓慢,因为 Critic 早就把评价标准校准好了,Actor 却挪不动步子。表现是 Critic 损失很快降到接近零,但步数纹丝不动。这时候你会误以为是 Critic 学得太好导致优势没信号了,其实是 Actor 太慢。

一个我常用的调试手段是:固定 Actor 学习率,把 Critic 学习率在一个范围内扫一遍,看哪个值让"步数增长最快同时不崩"。通常这个范围很宽,找到之后就不用再动。

另外一个联动点是更新频率。如果你的实现是每收集一批就更新一次,那批量大小和更新频率也是耦合的。批量小、更新频繁,梯度噪声大,需要更小的学习率;批量大、更新稀疏,可以用大一点的学习率。这两者的乘积大致应该保持在一个合理区间内。

4.3 从单环境到批量采样的改造

原始版的 Actor-Critic 有个隐含问题:相邻的轨迹之间高度相关,因为策略刚更新完,下一条轨迹就是在新策略下产生的,样本分布变化剧烈。这会加剧训练的不稳定。

解决方案是并行多个环境同时采样,然后合并成一批一起更新。这就是所谓同步优势 Actor-Critic 的核心想法。改造起来并不复杂,主要变化有三处:一是维护 N 个环境实例,二是每步对 N 个环境各采一个动作,三是把 N 条轨迹的数据拼成一个大张量再算损失。

批量变大之后,梯度的方差会显著下降,训练曲线明显平滑。我用 8 个并行环境试过,同样的超参数下收敛所需的回合数减少了差不多一半,曲线抖动也小了很多。代价是内存和计算开销上升,但对于小网络来说这点开销几乎可以忽略。

改造时有个坑要注意:不同环境的轨迹长度不一样,终止时间不同。拼接张量时必须用 mask 把已经终止的环境排除掉,否则会用无效数据算损失。简单做法是让所有环境都跑到最长的那个结束,或者手动维护每个环境的done标志并在计算时过滤。后者更省算力,但代码复杂度会上升一些。

注意:并行环境数量不是越多越好。超过一定数量后,方差下降带来的收益会被"策略更新粒度变粗"抵消掉,因为一批数据里的轨迹都来自基本相同的策略版本。实践中 4 到 16 是个比较合适的范围。

5. 从基础版到现代变体的演进路线

把基础版跑通之后,下一步通常是理解那些在论文里高频出现的变体。它们不是全新的算法,而是在基础框架上针对具体痛点打的补丁。搞清楚每个补丁解决什么问题,选型时才不会盲从。

5.1 同步并行版:把方差压下去

前一小节提到的多环境并行,就是最直接的改进。它不改变算法逻辑,只改变数据采集方式,通过增加一批数据的多样性来降低梯度方差。这个思路简单粗暴但极其有效,几乎是所有后续变体的默认配置。

实现上的关键是把环境的随机种子分开。如果所有并行环境用同一个种子,采出来的轨迹会高度相似,并行就白做了。正确的做法是给每个环境分配不同的种子,且这些种子之间要有足够的差异。

另一个细节是观测归一化。多环境并行时,不同环境可能观测到不同尺度的状态,如果直接用原始观测,网络会被大数值主导。用运行时统计的均值和标准差做归一化,能明显提升稳定性。这个技巧在状态维度高、数值范围差异大的任务里几乎是必须的。

5.2 广义优势估计:一个旋钮调偏差方差

前面提过的那个指数加权公式,实现起来需要考虑轨迹终止的边界处理。当轨迹在中间被截断时,后续的 TD 残差不存在,公式的尾部要正确处理,否则会引入系统性偏差。

具体的迭代写法是从后往前算:

def compute_gae(rewards, values, dones, gamma=0.99, lam=0.95): advantages = [] gae = 0.0 # values 长度比 rewards 多 1,最后一个是终止状态的估计值 for t in reversed(range(len(rewards))): mask = 1.0 - dones[t] delta = rewards[t] + gamma * values[t + 1] * mask - values[t] gae = delta + gamma * lam * mask * gae advantages.insert(0, gae) return advantages

这里的mask是关键。如果某一步是终止步,那么下一步的价值应该视为 0,残差里的自举项要被消掉。漏掉这个细节,在回合较长的环境里会累积出可观的偏差,表现为价值估计系统性偏高。

λ 的典型取值是 0.95。这个值的意思是"大约看 20 步的有效视野",因为 (γλ)^k = (0.99×0.95)^k ≈ 0.94^k,衰减到 0.1 大概需要 37 步。相比单步 TD,它保留了更多信息;相比蒙特卡洛,它把方差压下来了。这个 0.95 在大量任务上都是好起点,几乎可以当默认值用。

5.3 策略更新幅度限制:把步子迈小

基础版如果学习率没调好,策略可能在一次更新中变化过大,把之前学到的东西全丢掉。这个现象的根源是策略空间不是线性的,参数空间走一小步,策略分布可能变化巨大。

应对思路是给更新加约束,让新旧策略的差异不要超过一个阈值。最朴素的实现是在损失里加一个新旧策略概率比的裁剪项,把比值限制在 1±ε 的范围内,ε 通常取 0.2。这个改动的效果非常显著——同一个任务,加裁剪之后几乎不再出现"训到一半崩掉"的情况,超参数的容忍范围也变宽了很多。

代价是实现复杂度和计算量上升,因为要保存旧策略的概率并在更新时计算比值。但从工程角度,这点开销换来的是稳定性,非常值得。如果你的任务里反复出现"好不容易学到点东西又崩了",优先考虑上这个改动,比反复调学习率靠谱得多。

5.4 什么情况下仍然值得用最朴素的版本

虽然各种改进版本效果更好,但朴素版仍有它的位置,主要是两类场景。

一是教学和验证。朴素版的分支最少,调试时能快速定位问题。你在朴素版上把梯度流向、损失符号、数据拼接这些基础问题全部搞对,后面换任何变体都不会在同样的地方栽跟头。

二是极简任务和嵌入式部署。有些场景状态维度极低、环境确定性很强,朴素版完全够用,引入复杂的裁剪和优势估计反而增加代码体积和调试成本。我见过一些控制类的小项目,用最基础的两层网络加朴素更新就跑得很稳,没必要上重型武器。

判断标准很简单:如果你的任务在朴素版上能稳定收敛,且对超参数不敏感,那就没必要升级;只有当出现"训练不稳、超参敏感、收敛慢"这三类症状之一时,才需要考虑引入改进项。

6. 实际调试中积累的几个经验

最后这部分是纯经验,都是文档里不会写、但实际动手一定会撞上的东西。

6.1 随机种子与环境版本的双重不确定性

强化学习的可复现性比监督学习差得多,这一点必须接受。即使固定了所有随机种子,不同环境库版本间的行为也可能有细微差异,导致同代码同配置跑出不同曲线。

我的做法是把种子固定成一组而不是一个,比如每次实验跑 5 个种子,看中位数和分布,而不是看单个最优结果。如果某个配置只在 5 个种子里成功了 1 个,那它本质上就是不可用的,哪怕那条曲线很漂亮。

另外一个做法是在训练开始前把环境的所有随机源都重置一遍,包括动作采样用的随机数生成器。如果两者共用同一个全局种子,会出现"换一个种子就同时改变环境和策略的随机性"的情况,定位问题时很难区分到底是谁的影响。

6.2 归一化与梯度裁剪这两个保险

观测归一化我前面提过,这里补充一个具体做法:用运行时的均值和方差做在线更新,每步用新观测更新统计量,然后用当前统计量归一化。注意方差的更新要用无偏估计并且加一个下限防止除零。

梯度裁剪也几乎是必加项。原因是价值函数的损失在某些异常样本上会突然变得极大,产生一个巨大的梯度,一步把网络参数推到很糟糕的位置。裁剪的做法是算完梯度后把总范数限制在一个阈值内,常见取 0.5 到 5 之间。这个操作成本极低,但能挡掉大量偶发的崩溃。

还有一个小习惯:把每步的奖励尺度归一化。如果环境奖励动辄几百上千,价值网络的输出尺度会很大,损失也跟着大,训练不稳。把奖励缩放到 0 到 1 或者 -1 到 1 的区间,能让超参数的通用性大幅提升。这个改动不改变最优策略,只是让优化过程更友好。

我在实际使用中最大的体会是,这套算法里"配置"的重要性不亚于"算法"。同一个算法,配置调好了能从零分到满分,配置差了就一直原地踏步。所以每次换新任务,我都会先花时间把基础的几项对齐:奖励尺度、观测归一化、优势标准化、梯度裁剪、学习率比例。这五项做完,大部分任务都能跑出个像样的结果,剩下的才是精细化调优。

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

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

立即咨询