强化学习里有一类算法,学的时候觉得公式都看懂了,上手写代码就各种不收敛,Actor-Critic 大概是最典型的代表。它不像 Q-Learning 那样有个明确的表格可以填,也不像纯策略梯度那样一条道走到黑,它是把"决策"和"评价"这两件事拆开、再拼起来的一套框架。我见过不少人卡在这里:明明照着公式写完了,CartPole 上跑了几百个 episode 回报还在 20 出头,调学习率、调网络宽度都不管用,最后发现是 done 标志位处理错了,或者优势函数没有做归一化。这类问题在文档里基本不会写,但实际项目里天天遇到。
这篇内容想做的事情很直接:把 Actor-Critic 这条线从"为什么需要它"开始,一路讲到能跑起来的代码、能复现的参数、能定位问题的排查表。核心关键词是强化学习、Actor-Critic、策略梯度、优势函数、价值网络、GAE、PPO,这些词会自然地出现在各个章节里,不会刻意堆。面向的读者是已经了解马尔可夫决策过程、看得懂贝尔曼方程、写过几行 PyTorch 的人;如果你是刚入门的小白,前三章慢慢看也能跟上,因为我会尽量用生活化的类比把偏数学的部分翻译一遍。整个内容基于我自己的实践和常见工程做法展开,涉及具体参数的地方都会说明取值理由,而不是甩一组数字让你照抄。
1. 先把问题摆清楚:为什么单靠一条腿走路不行
在动手写代码之前,有必要先搞清楚 Actor-Critic 到底在解决谁的痛点。强化学习的算法大致分两派:一类是值函数方法,比如 Q-Learning 及其深度版本,学的是"在某个状态下做某个动作值多少分";另一类是策略梯度方法,直接学"在某个状态下该出什么动作"。这两派各有各的硬伤,Actor-Critic 的本质就是把两派的优势拼在一起,同时想办法压住各自的毛病。这一章我们把两条路各自的坑挖清楚,后面再看拼接方案就有味道了。
1.1 策略梯度的先天缺陷:方差大到没法用
纯策略梯度的思路很朴素:跑一批轨迹,看哪些动作最后带来了高回报,就提高这些动作的概率。数学上就是 ∇J(θ) = E[∇log π_θ(a|s) · G_t],其中 G_t 是从当前时刻开始的累计折扣回报。这个公式本身没问题,问题是 G_t 这个东西太"野"了。
打个比方,你在一个陌生的城市找餐厅,每次随机进一家店吃饭,吃完给个评分。如果你要评的是"进这家店这个行为好不好",却用"这一整顿饭从进门到出门的总体验"来打分,那这个分数里混进了太多跟你这次选择无关的因素——服务员态度、当天食材、隔壁桌吵闹。单次评分的噪声极大,你得吃上几百顿才能看出规律。策略梯度里的 G_t 就是这顿"总体验",它把后续所有随机性都算进来了,方差随回合长度线性甚至更糟地增长。
实际表现就是:同一份策略,跑两遍采样得到的两批梯度方向可能差很远,参数更新像喝醉了走路,左右摇摆。CartPole 这种简单任务还能靠大量样本磨出来,一旦换到动作空间大、回合长的任务,样本效率低到无法接受。这是策略梯度最要命的地方,也是后面所有改进的出发点。
1.2 值函数的局限:只能评价,不会行动
另一条路是值函数方法。它学 Q(s,a) 或者 V(s),学好了之后按贪心策略选动作。这条路样本效率通常更高,因为每一次交互的 TD 误差都能立刻拿来做一次更新,不用等一整条轨迹跑完。但它也有绕不过去的坎。
第一个坎是连续动作空间。Q-Learning 选动作要做 argmax,动作是离散的还好,挨个算一遍就行;动作是连续的机械臂关节力矩,argmax 变成一个每步都要解的优化问题,代价高得离谱。第二个坎是随机策略。有些场景最优策略本身就是随机的,比如猜拳、比如某些博弈场景,确定性策略永远学不会"以 30% 概率出石头"这种打法。第三个坎是函数逼近带来的不稳定,Q 值和目标 Q 值互相追着跑,容易发散,后来的一堆技巧(目标网络、双 Q、延迟更新)都是在给这个坑打补丁。
说到底,值函数方法解决的是"评价"问题,它告诉你某个状态值多少分,但"怎么出招"这一步是外挂上去的贪心规则,不是学出来的。当动作空间复杂到贪心没法算的时候,这个外挂就失效了。
1.3 Actor-Critic 的缝合逻辑:一手出招,一手打分
Actor-Critic 的做法是保留两个组件:Actor 负责出招,就是一个参数化的策略网络 π(a|s; θ),输出动作或动作分布;Critic 负责打分,就是一个参数化的价值网络 V(s; w) 或者 Q(s,a; w),评估当前状态(或状态动作对)有多好。训练时 Critic 先给个评价,Actor 拿这个评价来更新自己。
关键的一步在于:Actor 更新时不再用那条噪声巨大的完整回报 G_t,而是用 Critic 给出的估计。原来公式里那个 G_t 被替换成了优势函数 A(s,a) = Q(s,a) - V(s) 或者它的估计量。这么一换,等于把"这一整顿饭的总体验"换成了"这家店相对这条街平均水平的溢价",噪声立刻小了一大截。
用一个更贴近开发场景的类比:你带一个新人写代码,纯策略梯度的做法是等项目上线三个月后看整体业务指标来评价他某一次代码提交的好坏,反馈周期长、噪声大;Actor-Critic 的做法是让一位有经验的师兄(Critic)随时 review,直接告诉你"这段代码比团队平均水平好还是差"。师兄的判断当然也可能不准,但他能给即时反馈,而且随着他自己经验增长,判断会越来越准。这就是 Actor-Critic 的核心价值:用 Critic 的即时估计换取 Actor 的学习效率。
代价也很明确:你多了一个网络要训,多了一套超参要调,而且 Critic 估不准的时候会把 Actor 带偏。后面所有的工程技巧,本质上都是在处理"Critic 不准怎么办"这个问题。
2. Actor-Critic 的核心结构拆解
理解了动机,接下来要把这个框架里的每个零件拆开看。很多人写代码出问题,根源不在数学,而在于没搞清楚每个张量代表什么、形状该是多大、梯度从哪流到哪。这一章我按"策略怎么参数化、价值怎么估计、优势怎么算"的顺序讲,每个部分都会说明形状和实现层面的注意点。
2.1 Actor 到底在学什么:策略的参数化
Actor 是一个从状态到动作分布的映射。离散动作空间下,最常见的是输出每个动作的 logits,过一个 softmax 得到概率,然后从类别分布里采样;连续动作空间下,通常假设高斯分布,网络输出均值 μ(s) 和标准差 σ(s)(或者输出 log σ² 再取指数,数值上更稳),训练时从 N(μ, σ²) 采样,测试时可以直接用 μ 或者继续采样。
这里有两个实现层面的坑值得提前说。第一个是标准差要不要跟状态相关。输出与状态相关的 σ(s) 表达能力更强,但早期训练容易塌缩到接近 0,导致探索彻底消失;很多实现选择用一个与状态无关的可学习参数 log_std,初始化为 0 附近,训练更稳。第二个是动作范围。环境的动作通常有上下界,高斯采样出来的值可能越界,标准做法是采样后做 tanh 压缩再按比例缩放到动作区间,同时要在计算 log 概率时做雅可比修正,否则策略梯度会算错。这个修正很多人会漏,漏了之后训练看起来能跑,但学出来的策略明显偏。
Actor 的更新目标是最大化期望回报,落到损失函数上就是最小化 -log π(a|s) · A(s,a)。注意这里的负号:我们要让"优势为正的动作"概率上升,"优势为负的动作"概率下降。如果符号写反了,训练会以肉眼可见的速度崩掉——回报曲线一路下滑,这是新手最常犯的错误之一。
2.2 Critic 到底在评什么:价值函数与优势函数
Critic 的职责是估计价值。有两种常见形态:状态价值 V(s),输入只有状态,输出一个标量;状态动作价值 Q(s,a),输入是状态动作对。选择哪种取决于你的算法家族:A2C、PPO 这类同策略方法通常用 V(s),因为只需要在当前策略采样的动作上评估;DDPG、SAC 这类异策略方法用 Q(s,a),因为它们要用到回放缓冲区里的历史数据。
训练 Critic 的目标是最小化 TD 误差的平方:L = (r + γV(s') - V(s))²,其中 r + γV(s') 是自举得到的目标值。这里有一个必须处理的细节:回合终止时的自举。如果 s' 是真实终止状态(任务结束、失败),那目标值就是 r,不能再加 γV(s');如果 s' 是因为达到时间上限被截断的,那它其实还有后续,应该照常自举。很多环境只给一个 done 标志,把这两种情况混在一起,直接用 (1-done) 乘上去,会导致 Critic 对"时间到了"和"真失败了"给出同样的估值,长回合任务上偏差会累积得很明显。稳妥的做法是环境支持的话,额外拿一个 truncated 标志,只在真正终止时才切断自举。
值网络的结构通常和策略网络共享一部分特征提取层,尤其在图像输入的任务里共享能省不少算力;但对简单的向量输入任务,分离两套网络往往更容易调,因为两者的学习目标和梯度尺度差别挺大的。
2.3 基线为什么能降方差:从数学到直觉
优势函数 A(s,a) = Q(s,a) - V(s) 里那个减掉的 V(s),术语叫基线。为什么减去一个与动作无关的量不会引入偏差,却能降低方差?数学上的解释是:对任意只依赖状态的函数 b(s),有 E_{a~π}[∇log π(a|s) · b(s)] = b(s) · ∇Σ_a π(a|s) = b(s) · ∇1 = 0。也就是说,减去 b(s) 不改变梯度的期望,只改变它的波动幅度。选 V(s) 作为基线,恰好让"平均水平"被抵消掉,剩下的就是"这个动作比平均好多少"。
直觉上更好理解。假设一个任务里所有动作的回报都在 100 分上下浮动,直接用回报做权重,所有动作的概率都会被推高,只是推的幅度略有差别,有效信号被巨大的公共偏移淹没。减去基线之后,信号变成"好于平均的往上推,差于平均的往下压",方向清晰多了。
实操中还有一个几乎必做的技巧:对优势做标准化。在一个 batch 内做 (A - mean(A)) / (std(A) + 1e-8),能让不同任务、不同训练阶段的梯度尺度趋于一致。这个操作在很多论文的伪代码里被省略,但工程实现里基本都会加,效果相当显著。注意标准化只在 batch 内做,不要跨 batch 累积统计量,也不要对值函数目标做同样处理。
3. 数学推导与关键公式的落地理解
这一章把前面的直觉落到公式上。我不会逐行推完整证明,那对写代码没帮助,重点是把每个公式里"哪一项可以动手改、改了会怎样"讲清楚。毕竟调参的本质,就是在这些可调项上做取舍。
3.1 策略梯度定理回顾
策略梯度的标准形式是 ∇J(θ) = E_{s,a~π}[∇log π_θ(a|s) · Ψ_t],Ψ_t 是某种衡量动作好坏的量。历史上 Ψ_t 可以是:
| Ψ_t 的取法 | 偏差 | 方差 | 说明 |
|---|---|---|---|
| 完整回报 G_t | 无偏 | 很高 | 最原始的 REINFORCE |
| 回报减基线 G_t - b(s) | 无偏 | 中 | b 常取 V(s) |
| 状态动作价值 Q(s,a) | 有偏 | 中 | 依赖 Critic 准确度 |
| 优势函数 A(s,a) | 有偏 | 较低 | 主流选择 |
| TD 误差 δ_t | 有偏 | 最低 | 单步自举,收敛快但有偏 |
这张表值得反复看。它揭示了 Actor-Critic 里一切取舍的本质:你越依赖 Critic 的自举估计,方差越低、样本效率越高,但偏差越大;你越依赖真实采样回报,越无偏,但方差爆炸。所谓"调参",很大程度上就是在调这个平衡点。
3.2 优势函数的三种算法
从公式到代码,优势有三种常见估法,实现复杂度递增。
第一种是单步 TD 误差:A_t ≈ δ_t = r_t + γV(s_{t+1}) - V(s_t)。写起来最简单,一步交互就能更新,方差最小。缺点是偏差最大,因为完全信任 Critic 的单步判断,Critic 稍微不准,Actor 就跟着跑偏。
第二种是 n 步回报:A_t ≈ Σ_{k=0}^{n-1} γ^k r_{t+k} + γ^n V(s_{t+n}) - V(s_t)。往前看 n 步真实奖励,再接上 Critic 的估计。n 越大越无偏,方差也越大。实践中 n 取 5 到 20 之间比较常见。
第三种是 GAE(广义优势估计),把不同 n 的结果做指数加权平均,权重由 λ 控制。它是目前同策略方法里的事实标准。
3.3 GAE:把 λ 当成一个旋钮
GAE 的递推形式非常简洁:
δ_t = r_t + γ V(s_{t+1}) - V(s_t) A_t = δ_t + γ λ A_{t+1}从轨迹末尾往前倒着算,A_T 的初始值取 0(或者用自举值)。λ = 0 时退化成单步 TD,λ = 1 时退化成蒙特卡洛回报减基线。所以 λ 就是一个从"低方差高偏差"到"高方差低偏差"的连续旋钮。
工程上,γ = 0.99、λ = 0.95 是一组被反复验证的默认值,绝大多数离散动作任务上都能用。如果是回合特别长、需要长期规划的任务,可以试试 γ = 0.995 配上 λ = 0.95 到 0.98。反过来,如果训练明显不稳、曲线抖得厉害,先把 λ 往下调,比调学习率更直接。
实现 GAE 的时候有一个坑必须提:倒序计算的循环里,如果遇到 done 标志,要把累计量清零,否则会把下一回合的优势串到这一回合来。写起来就是 if done[t]: last_gae = 0。这个细节漏了,训练不会立刻崩,但会莫名其妙地学不快,非常难查。
3.4 熵正则:给探索留一扇窗
策略在训练早期很容易塌缩:某个动作碰巧先被采到并拿到正回报,概率迅速升到接近 1,其他动作再也没机会被试。加熵正则就是为了防止这件事。损失函数变成:
loss_actor = -(log_prob * advantage).mean() - entropy_coef * entropy.mean()熵 H(π(·|s)) 衡量策略的随机程度,均匀分布的熵最大。减去它(等价于在最大化目标里加上它)就是在鼓励策略保持一定的随机性。entropy_coef 通常从 0.01 起步,任务越简单可以越小,复杂的探索型任务可以到 0.05 甚至更高。训练后期如果发现策略还是过于随机、性能上不去,可以按线性衰减把系数降到接近 0。
这里有个经验:熵系数和优势标准化要一起看。如果优势被标准化到方差为 1,熵项的梯度相对就变小了;反过来如果优势没标准化,熵项可能被淹没。两者最好成对调整,别单独改一个。
4. 手把手实现一个最小可用的 Actor-Critic
前面铺垫够了,这一章直接上代码。目标是在一个经典的控制任务上把单步 Actor-Critic(也就是 A2C 的简化版)跑通,能稳定收敛,并且把每一处容易出错的地方标注出来。这套骨架换成别的环境,改一下输入输出维度就能用。
4.1 环境与依赖准备
依赖只有三样:PyTorch 做网络和自动求导,NumPy 处理数组,一个强化学习环境库提供交互接口。环境方面,CartPole 是最合适的起点——状态 4 维连续、动作 2 维离散、每回合最多 500 步、奖励简单,能在几分钟内看出算法是否正常工作。
pip install torch numpy gymnasium先明确几个环境的基本量:状态维度 4,动作数 2,单回合最大步数 500。这几个数字后面会直接写进网络定义。我建议第一次实现时不要加任何花哨的技巧,先把最朴素的版本跑通,看到回报曲线从 20 涨到 400 以上,再去加 GAE、加并行环境、加梯度裁剪,这样每一步改动带来的影响你都能感知到。
提示:环境版本差异会导致 API 不完全一致,返回值的个数可能是 4 个也可能是 5 个(多一个 truncated)。动手前先打印一次 step 的返回值,确认结构,比事后排查半天要省事得多。
4.2 网络结构设计
我倾向于把 Actor 和 Critic 写成两个独立的小网络。共享主干在图像任务上省算力,但在这种低维输入上,分离能避免两个目标的梯度互相干扰,调起来更省心。
import torch import torch.nn as nn import torch.nn.functional as F from torch.distributions import Categorical class Actor(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 Categorical(logits=logits) class Critic(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).squeeze(-1)网络宽度 128、两层隐藏层,对这个任务绰绰有余。再大容易过拟合到早期采样的偶然性上,反而学得慢。输出层不要加激活函数,logits 直接用原始值过 softmax,数值上更稳定。Critic 的输出记得 squeeze 掉最后一维,否则后面算 TD 误差时形状对不上,广播会静默出错——这种错误不报异常,只会让训练莫名其妙地不收敛。
4.3 核心训练循环
整个流程是:采样一批轨迹,算回报和优势,更新 Critic,更新 Actor。批量的概念很重要,单步更新噪声太大,通常是采若干个完整回合或者固定步数作为一个 batch。
def compute_gae(rewards, values, dones, gamma=0.99, lam=0.95): advantages = [] gae = 0.0 # values 长度比 rewards 多 1,最后一个是 bootstrap 值 for t in reversed(range(len(rewards))): if dones[t]: delta = rewards[t] - values[t] gae = delta else: delta = rewards[t] + gamma * values[t + 1] - values[t] gae = delta + gamma * lam * gae advantages.insert(0, gae) returns = [a + v for a, v in zip(advantages, values[:-1])] return torch.tensor(advantages, dtype=torch.float32), \ torch.tensor(returns, dtype=torch.float32)上面这段是关键。注意 done 的处理:遇到终止时不算自举项,且把 gae 清零重新累计。returns 是优势加价值,作为 Critic 的回归目标,这样比直接用蒙特卡洛回报稳定得多。values 需要多算一步,也就是最后一个状态的价值,用于自举,这是很多人漏掉的地方。
主循环长这样:
def train_step(actor, critic, opt_a, opt_c, trajectories): states = torch.tensor(trajectories['states'], dtype=torch.float32) actions = torch.tensor(trajectories['actions'], dtype=torch.long) rewards = trajectories['rewards'] dones = trajectories['dones'] values_next = trajectories['values'] with torch.no_grad(): values = critic(states) all_values = list(values.numpy()) + [values_next] adv, ret = compute_gae(rewards, all_values, dones) # 优势标准化 adv = (adv - adv.mean()) / (adv.std() + 1e-8) # Critic 更新 loss_c = F.mse_loss(critic(states), ret) opt_c.zero_grad(); loss_c.backward() nn.utils.clip_grad_norm_(critic.parameters(), 0.5) opt_c.step() # Actor 更新 dist = actor(states) log_prob = dist.log_prob(actions) entropy = dist.entropy().mean() loss_a = -(log_prob * adv).mean() - 0.01 * entropy opt_a.zero_grad(); loss_a.backward() nn.utils.clip_grad_norm_(actor.parameters(), 0.5) opt_a.step()优势标准化的位置要在 Critic 更新之前算好,因为它是用更新前的价值算出来的。梯度裁剪阈值 0.5 是个保守但好用的取值,能挡住偶发的梯度爆炸,又不至于让学习变慢。
4.4 几个不写就会踩的实现细节
第一,log_prob 的顺序对应。采样时是分布 dist 采出 action,计算损失时必须用同一个分布对同一个 action 求 log_prob。如果采样和计算之间策略更新了一次,或者用错了批次的 logits,梯度就完全错了。稳妥的做法是在采样时就把 log_prob 存下来,计算时直接用,虽然新版本策略会有偏差,但 A2C 这种一遍过的算法影响可控;用 PPO 的话则必须用新策略重新算。
第二,回报的量级。CartPole 每步奖励是 1,回报最大 500,这个量级直接拿去算梯度还行。但如果任务奖励是几百上千的量级,优势会很大,梯度跟着变大,很容易训崩。解决办法有两个:训练时把奖励乘一个缩放系数(比如 0.1),或者在算完优势之后做标准化。我一般两个都用,缩放放在环境交互那一层,标准化放在损失计算前,双保险。
第三,优化器分开设置。Actor 和 Critic 的学习率不必相同。经验上 Critic 可以设得稍大一点,比如 Actor 3e-4、Critic 1e-3,因为它是在做一个回归任务,收敛目标更明确。当然也有观点认为两者应该一致,具体看任务,先按分离的来,出问题再调。
第四,评估和训练要分开。评估时用 argmax 或者直接用分布均值,不要采样。同时评估时关掉梯度、切到 eval 模式,虽然对这个网络影响不大,但养成习惯有好处。
5. 从 A2C 到 PPO:工程上的演进路线
上面那套代码跑通了,回报能到 400 以上,说明框架是对的。但真实任务里 A2C 有两个绕不过的短板:样本利用率低(每条数据只用一次就扔)、更新步长敏感(学习率稍大就崩)。这一章梳理一下工程上的几条改进路线,不是说每一条你都要用,而是知道遇到什么问题该往哪个方向找武器。
5.1 并行采样与 A3C 的教训
A3C 的思路是开多个环境副本并行采样,用异步梯度更新共享参数。想法很好,但异步更新会带来梯度陈旧问题,而且多线程在 Python 里受 GIL 限制,实际加速有限。现在的实践里,更常见的做法是同步并行:开 8 个或 16 个环境,各自采样,攒成一个大批量,一次前向反向。这样既提高了数据多样性,又不会引入异步的不确定性。
在我的经验里,环境数从 1 增加到 8,样本效率的提升最明显;从 8 加到 32,收益就递减了,反而增加显存和协调开销。所以起步建议 8 个,视任务复杂度调整。批大小也要跟着调,通常总批量的步数控制在几千步这个量级。
5.2 重要性采样比与 PPO 的裁剪
PPO 解决的问题是"一条数据想用多次"。A2C 采完一批就更新一次,数据浪费;如果能用同一批数据更新 4 次甚至 10 次,样本效率会高很多。但多次更新会让新策略偏离采样时的旧策略太多,优势估计就不准了。重要性采样比 r_t(θ) = π_new(a|s) / π_old(a|s) 就是衡量这个偏离度的量。
PPO 的做法很粗暴但有效:把损失改成
loss = -min(r_t * A, clip(r_t, 1-eps, 1+eps) * A).mean()当 r_t 超出 [1-eps, 1+eps] 区间时,梯度被截断,策略不会为了追逐单条样本的巨大优势而走太远。eps 常取 0.2。这个裁剪的好处是实现简单,坏处是它只是个"软约束",理论上仍可能有较大偏离,但实践中足够用。
关于 PPO 的实现细节,有几个数字值得记:clip 系数 0.2、每批数据更新 3 到 10 个 epoch、批大小 2048 到 8192 步、学习率 3e-4 配线性衰减。这套配置在很多任务上能直接开跑。
5.3 连续动作:DDPG、TD3 与 SAC 的分岔
前面讨论的多是离散动作。到了连续动作空间,Actor 输出高斯分布的均值和标准差,Critic 从 V(s) 换成 Q(s,a)。这一个改动牵扯出整个异策略家族。
DDPG 用确定性策略加 Q 网络,配目标网络和软更新,思路直接但很脆,超参敏感、容易过估计。TD3 打了三个补丁:双 Q 网络取较小值抑制过估计、延迟更新 Actor、给目标动作加噪声平滑。SAC 走得更远,在目标里加入熵项,变成"最大化回报加熵",探索能力更强,也逐渐成为连续控制的默认选择。
下面这张表是我自己选型时会参考的对照:
| 算法 | 策略类型 | 数据来源 | 样本效率 | 调参难度 | 典型场景 |
|---|---|---|---|---|---|
| A2C | 随机 | 同策略 | 低 | 低 | 快速验证、简单控制 |
| PPO | 随机 | 同策略 | 中 | 中 | 通用、离散连续都行 |
| DDPG | 确定性 | 异策略 | 高 | 高 | 连续控制(已较少单独用) |
| TD3 | 确定性 | 异策略 | 高 | 中高 | 连续控制、对稳定性有要求 |
| SAC | 随机 | 异策略 | 高 | 中 | 连续控制、需要探索 |
选型上的个人建议:先别纠结哪个更强,拿 PPO 把任务跑通,作为基线。如果对样本效率有硬要求(比如真实机器人上采样很贵),再考虑 SAC。A2C 更多是教学和理解框架用的,真上项目很少作为最终方案。
6. 调参与排错实录
理论讲完了,代码也给全了,但真正耗时间的永远是"跑不起来"和"跑不好"。这一章把我在实际项目里遇到过的典型问题整理成排查路径,按"从现象找原因"的顺序组织,而不是按算法模块。因为出问题的时候你看到的是一个反常的曲线,得从现象倒推。
6.1 回报曲线不涨的排查顺序
遇到曲线平在地上,我一般按这个顺序查,从最可能的开始:
第一步,看 Critic 的损失。如果价值损失一直不降,说明 Critic 根本没学到东西,问题可能在目标值算错了、学习率太小、或者状态输入没归一化。可以先单独把 Critic 摘出来,用固定策略采一批数据,离线训练它拟合回报,看能不能拟合上。拟合不上就是网络或目标的问题。
第二步,看策略的熵。如果熵迅速掉到接近 0,说明策略早早就塌缩了,这是典型的探索不足。解决办法是提高熵系数、提高初始学习率、或者检查动作采样是不是写成了 argmax。
第三步,看动作分布。打印一批状态下输出的概率,如果所有状态下的概率分布几乎一样,说明 Actor 没有接收到有效梯度,可能是优势全为正或全为负,或者优势没标准化导致梯度尺度失衡。
第四步,看数据本身。画一下每回合的步数和奖励,如果步数一直卡在很小值,说明智能体在早期就失败终止,可能是初始状态分布有问题,或者是 done 判断写错了导致提前终止。
这个顺序背后的逻辑是:先确认评价系统(Critic)是否可信,再确认探索是否充分,最后才怀疑更新公式。很多人在第一步都没做的情况下就去调学习率,纯属碰运气。
6.2 常见问题速查表
下面这张表是我自己整理的,基本都是踩过的坑:
| 现象 | 可能原因 | 处理办法 |
|---|---|---|
| 曲线缓慢下降 | 策略损失符号写反 | 检查是否为 -(log_prob * adv).mean() |
| 训练初期就崩、损失变 NaN | 学习率过大 / 未做梯度裁剪 | 降到 1e-4 起步,加 clip_grad_norm_ |
| 学得快但最终性能差 | 熵系数过小,探索不足 | 提高 entropy_coef 到 0.01~0.05 |
| 回报波动巨大 | 优势未标准化 / 批太小 | 加优势标准化,增大批量 |
| 长回合任务始终学不会 | 截断与终止未区分 | 引入 truncated 标志,只在真终止时切断自举 |
| 训练指标好、评估指标差 | 评估时仍在采样 / 未切 eval | 评估用 argmax 或分布均值,关闭梯度 |
| Critic 损失长期不降 | 奖励量级过大 | 奖励缩放 0.1,或对回报做归一化 |
| 某些 episode 优势异常大 | GAE 未在 done 处清零 | 倒序计算时遇到 done 重置累计量 |
表格里最值得展开的是"截断与终止"那一条。这个问题在 CartPole 上看不出来,因为它的回合结束只有"杆子倒了"一种情况。一旦换到有时间上限的任务,比如机械臂在固定步数内完成任务,就会出现"时间到了但任务还没失败"的情况,这时候如果按终止处理,Critic 会认为"这里价值为 0",从而低估了状态价值,策略会学得畏手畏脚。这个 bug 极其隐蔽,因为曲线还在涨,只是涨得慢、上限低。
6.3 几个只有实际跑过才知道的细节
关于价值网络的初始化。输出层初始化为 0 附近(比如权重乘 0.01)能让 Critic 一开始输出接近 0,优势就近似等于累计奖励,训练前期相当于退化成一个带基线的策略梯度,反而更稳。等 Critic 慢慢学起来,优势估计才逐渐接管。这个技巧在不少开源实现里能看到,但很少有人解释为什么。
关于批内的状态相关性。如果批量是按时间顺序攒起来的连续轨迹,相邻状态高度相关,梯度方向会有系统性偏差。解决办法要么是打乱批内顺序(对 A2C 这种一遍过的算法可行),要么是并行多个环境采样来打散。后者更干净,我一般优先选并行环境。
关于日志记录。一定要记录几个量:策略损失、价值损失、熵、平均回报、平均回合长度、梯度范数。梯度范数特别有用,它能在训练崩之前就给出预警——如果范数突然从 0.5 涨到 50,基本下一两个 epoch 就要炸,这时候可以提前降学习率。这些指标我通常每 10 个更新打一次,存成 csv,出问题的时候画出来一看就知道是哪一环先坏的。
关于随机种子。强化学习的方差大到超出很多人的想象,同一个算法换三个种子,最终性能可能差 30%。所以任何一次"改进了"的判断,至少要跑三个种子取平均,否则很可能是在跟噪声较劲。我在早期吃过太多这个亏,以为某次调参带来了提升,实际上只是这次运气好。
关于超参搜索的优先级。如果要系统调参,我的优先级是:学习率 > 批量大小 > 熵系数 > GAE 的 λ > 网络宽度。学习率的影响通常比其他所有参数加起来还大,先把它扫一遍,从 1e-4 到 1e-3 取三到四个点,比在别处精雕细琢划算得多。
最后说一句我自己在长期实践里的体会:Actor-Critic 这套框架真正的难点不在公式,而在于建立一套"从曲线反推问题"的直觉。公式是死的,写完就不会变;而出问题时你面对的永远是一堆反常的数字,能不能快速定位到哪一环坏了,取决于你对每个中间量的正常范围有没有概念。我的建议是,第一次实现的时候就把所有中间量都打出来,哪怕看着很乱,跑通几个任务之后,这些量的正常区间自然就刻在脑子里了,之后再遇到异常,一眼就能看出不对。这套直觉的建立,比多背几个算法变体有用得多。