先说一个我踩过的坑:第一次把 Actor-Critic 跑起来的时候,训练曲线画出来,actor 的 loss 一路往上涨、critic 的 loss 一路往下跌,看着像两条老死不相往来的线。我以为是代码写错了,翻了两天,最后发现根本没写错——强化学习里这两个损失本来就没有"都要下降"这种约定,actor 的 loss 甚至可以是负的、可以震荡、可以在某一个区间里长期横盘,只要平均回报在涨,它就是对的。这个认知上的偏差,比任何一行代码都更耽误时间。
Actor-Critic 是强化学习里绕不开的一块地基。说它基础,是因为几乎所有现代策略梯度算法——A2C、A3C、PPO、SAC、DDPG——都是它的变体或者改进;说它容易踩坑,是因为它把"策略"和"价值"两套东西揉在了一起,两个模块互相依赖、互相拖累,任何一边出问题都会在另一边体现成莫名其妙的症状。这篇内容我把 Actor-Critic 从动机、结构、数学、代码到排错完整拆一遍,适合刚学完策略梯度想往下走的人,也适合已经能跑通 demo、但一换环境就崩、说不清为什么崩的人。不需要你精通变分推断,但最好知道什么是策略、什么是折扣回报、什么是梯度上升。
1. 纯策略梯度的软肋:为什么"整局打完再看总分"不够用
1.1 REINFORCE 的方差账:一次采样估一条轨迹的期望
要理解 Actor-Critic 存在的必要性,得先搞清楚它的前身 REINFORCE 到底卡在哪。
REINFORCE 的思路极其干净:跑完一整局,拿到每个时刻之后的累计回报 G_t,然后用 ∇θ log π(a_t|s_t) 乘以 G_t 作为梯度的无偏估计,梯度上升更新策略。G_t 大,说明这个动作后面带来了一堆好处,就加大它的概率;G_t 小或者为负,就减小它的概率。数学上没问题,无偏、简单、不需要价值函数。
问题出在"一整局"这三个字上。G_t 是一整条轨迹里所有随机性叠加起来的结果——环境的随机转移、策略本身的随机采样、奖励的随机性,全部被累加进这一个标量里。你用这个标量去评判"当下这一步动作好不好",噪声大得离谱。
举个具体例子,CartPole 里杆子多撑了 50 步才倒,那前面每一个动作分到的 G_t 都会被这 50 步的奖励堆高,哪怕中间有几个动作其实是瞎选的。反过来,前 30 步走得极漂亮,第 31 步手滑了一下,整局的 G_t 全被拉低,前 30 步的"功劳"也跟着被否定。这就是典型的信用分配问题:回报只告诉你"整局结果如何",不告诉你"哪一步该负责"。
方差带来的直接后果是:你要用极多的样本才能把梯度方向估准。在实际工程里表现就是——训练曲线抖得像心电图,学得慢,同一个超参换个随机种子结果能差一倍。这不是玄学,是方差。
1.2 基线的直觉:把"及格线"从分数里减掉
有一个非常实用的改进叫基线(baseline)。做法是从 G_t 里减去一个只依赖状态、不依赖动作的量 b(s_t):
∇θ J ≈ E[∇θ log π(a_t|s_t) · (G_t − b(s_t))]
这个减法是合法的,因为 E_{a~π}[∇θ log π(a|s) · b(s)] = b(s) · ∇θ Σ_a π(a|s) = b(s) · ∇θ 1 = 0。也就是说,减去任何与动作无关的基线都不改变梯度的期望,但能大幅降低方差。
直觉是这样的:假设某门课全班平均分 60,你考了 75。如果只看绝对分 75,你觉得自己考得不错;但如果告诉你班级平均是 85,那你其实是拖后腿的。基线就是那个"平均分"。策略梯度真正关心的不是"这个动作带来了多少绝对回报",而是"这个动作比平均水平好多少"。
那么什么东西最天然地适合当基线?就是当前状态下的平均价值 V(s)。因为 V(s) = Σ_a π(a|s) Q(s,a) 正好是"在策略 π 下,处于状态 s 时的期望回报"。
把 V(s) 当成基线代进去,括号里就变成了 Q(s,a) − V(s)。这个量有一个专门的名字:优势函数(Advantage),记作 A(s,a)。它的含义就是字面意思——这个动作相对于平均水平有多大优势。A > 0 就该提概率,A < 0 就该压概率。
到这一步,Actor-Critic 的骨架其实已经浮现了:你需要一个东西来估计 V(s) 或者 Q(s,a),这就是 Critic;你还需要一个策略来采样动作、被更新,这就是 Actor。
1.3 从"打完再说"到"边打边评":在线学习的诉求
REINFORCE 还有一个更隐蔽的限制:它必须是**回合制(episodic)**的,而且必须等一局结束才能更新一次。这在很多场景下不可接受。
比如一个连续控制任务,一局可能跑几千步;比如一个没有终止状态的持续性任务,压根就没有"一局"这个概念;再比如实际系统里你希望边收集边学、快速迭代,而不是攒一大坨数据再统一算。
Actor-Critic 天然解决了这个问题。因为有了 Critic 估计的 V(s),你在第 t 步就能拿到一个近似的一步目标:
δ_t = r_t + γ V(s_{t+1}) − V(s_t)
这个 δ_t 叫TD 误差(Temporal Difference Error),它本身就是对优势函数的一个估计。整个更新不需要等回合结束,采一步、更新一步(或者采一小批、更新一次)就行。这就是"自举(bootstrapping)"——用自己当前的估计去更新自己,用未来的猜测修正现在的猜测。
代价也很明确:V 是估出来的,估得不准,δ_t 就是有偏的。REINFORCE 用完整回报,无偏但方差大;Actor-Critic 用一步 TD 误差,方差小但有偏。这是强化学习里最核心的一组权衡,几乎所有的算法变体都是在这条轴上找位置。
2. Actor 与 Critic 的分工:谁负责出手,谁负责打分
2.1 两个网络的输入输出契约
很多教程把 Actor-Critic 讲成"两个网络拼一起",这个说法容易让人以为它们是对等的两半。实际上它们的分工非常不对称,把各自的输入输出契约画清楚,后面写代码就不会乱。
Actor(策略网络 π_θ):输入状态 s,输出动作分布。离散动作空间输出每个动作的 logit,过 softmax 得到概率,用Categorical采样;连续动作空间输出高斯分布的均值 μ 和标准差 σ(或者 log σ),用Normal采样。它只关心"在当前状态下该怎么选",不关心"这个状态本身值多少分"。
Critic(价值网络 V_φ):输入状态 s,输出一个标量,表示"从状态 s 出发,按当前策略走下去的期望折扣回报"。它只看状态,不看动作。注意这里是 V 而不是 Q——用 V 的好处是输出维度只有 1,不需要对每个动作都输出一个值,网络简单、样本效率相对高,代价是只能给 Actor 提供"相对优势"而不是精确的 Q 值。
两者共享同一个观测输入,但输出空间完全不同,损失函数也完全不同。这个"输入相同、输出异构"的结构,后面会引出一个很关键的工程决策:到底共享底层特征提取层,还是各练各的。
用代码把契约固定下来:
import torch import torch.nn as nn from torch.distributions import Categorical, Normal class Actor(nn.Module): def __init__(self, obs_dim, act_dim, hidden=128, continuous=False): super().__init__() self.continuous = continuous self.body = nn.Sequential( nn.Linear(obs_dim, hidden), nn.Tanh(), nn.Linear(hidden, hidden), nn.Tanh(), ) if continuous: self.mu_head = nn.Linear(hidden, act_dim) self.log_std = nn.Parameter(torch.zeros(act_dim)) # 状态无关的标准差 else: self.logits_head = nn.Linear(hidden, act_dim) def forward(self, obs): h = self.body(obs) if self.continuous: mu = self.mu_head(h) std = self.log_std.exp().expand_as(mu) return Normal(mu, std) return Categorical(logits=self.logits_head(h)) class Critic(nn.Module): def __init__(self, obs_dim, hidden=128): super().__init__() self.net = nn.Sequential( nn.Linear(obs_dim, hidden), nn.Tanh(), nn.Linear(hidden, hidden), nn.Tanh(), nn.Linear(hidden, 1), ) def forward(self, obs): return self.net(obs).squeeze(-1)激活函数用Tanh而不是ReLU,这是 RL 里的一个惯例,不是随便选的。ReLU的死区在监督学习里问题不大,但在 RL 里,价值函数的输出范围可能跨越很大尺度,ReLU网络学出来的 V(s) 容易在分段线性边界上产生不平滑的梯度,配合 TD 目标这种"移动靶"更容易失稳。Tanh有界、平滑,实践中更稳。代价是深网络里梯度容易饱和,所以 RL 的网络一般不会太深,两三层隐藏层足够了。
2.2 损失函数各自为政:一个做梯度上升,一个做回归
这是最容易搞混的地方。Actor 和 Critic 的"loss"不是同一类东西,优化方向甚至相反。
Critic 的损失是标准的回归问题,最小化 TD 误差的平方:
L_V(φ) = 1/2 · E[(r_t + γ V_φ(s_{t+1}) − V_φ(s_t))²]
注意这里有一个工程细节:目标值 r + γV(s') 里的 V(s') 也来自 φ 本身。如果直接对 L_V 求梯度,梯度会同时流经"预测值"和"目标值"两个地方,这就变成了在追一个自己也在动的靶子,训练会非常不稳定。
标准做法是对目标做 detach,把它当成常数:
v = critic(obs) # [B] with torch.no_grad(): v_next = critic(next_obs) # [B] td_target = reward + gamma * (1 - done) * v_next td_error = td_target - v # 这就是优势的估计 critic_loss = 0.5 * td_error.pow(2).mean()(1 - done)这一项不能漏。终止状态之后没有未来,V(s') 必须被清零,否则你会凭空给终止状态之后塞进一个虚假的价值,学出来的 V 会系统性偏高。我在一个自定义环境里忘了这一项,训练了三个小时,agent 学出了一套"拖到超时也不结束"的诡异行为——因为只要不结束,它就能一直拿到未来价值的加成。
Actor 的损失本质上是梯度上升,要让好动作的对数概率变大。但 PyTorch 的优化器只做最小化,所以实现上取负号:
L_π(θ) = − E[ log π_θ(a_t|s_t) · Â_t ]
其中 Â_t 就是前面算出来的 td_error,实践中会 detach 掉,因为它只作为"权重"使用,不参与对 Actor 的求导:
dist = actor(obs) log_prob = dist.log_prob(action) actor_loss = -(log_prob * td_error.detach()).mean()这里有个概念陷阱值得强调:log_prob * advantage这个乘法的符号决定了行为。advantage 为正,-log_prob * A在 log_prob 增大时变小,最小化就让 log_prob 增大,动作概率提升——对的。advantage 为负,同理,log_prob 增大会让 loss 变大,最小化就逼着 log_prob 减小——也对。
但如果你看到 actor_loss 是负的,别慌。因为-log_prob本身在概率接近 1 时趋近 0,乘以正的 advantage 之后整体是负的,mean 之后仍然是负的。Actor 的 loss 绝对值没有物理意义,看它涨跌基本等于看噪声。
2.3 梯度是怎么在两张网之间流动的
搞清楚梯度流向,才不会被.detach()放错位置这种低级错误拖几天。
- Critic 的反向传播:从
critic_loss出发,只更新 φ。td_target被 detach,所以梯度不会从目标端回传,只从预测端v回传。这是正确且必要的。 - Actor 的反向传播:从
actor_loss出发,只更新 θ。td_error被 detach,所以梯度只走log_prob这一条路,不会去动 Critic。这也是必要的,否则一次更新里 Critic 会被两种完全不同的目标同时拉扯。 - 如果两个网络共享了底层 body,那就要小心了:虽然
td_errordetach 了,但共享 body 的参数会同时收到来自两条 loss 的梯度,累加起来。这是下一节要专门讲的坑。
一个常见的错误写法是把td_error直接乘进去而不 detach:
actor_loss = -(log_prob * td_error).mean() # 错误:梯度会漏到 critic 里这么写不会报错,loss 也能下降,但 Critic 的更新目标就和它的回归目标打架了,结果是 Critic 学出的 V(s) 既不像真实价值,也不好给 Actor 用。症状是训练前期看着还行,几十万步之后开始莫名其妙地退化。这类 bug 极难从曲线看出来,只能靠代码审查。
3. 优势函数的三种估法与偏差方差那条钢丝
3.1 一步 TD 误差:最省样本,但眼睛近视
前面提到 Â = δ = r + γV(s') − V(s)。这是所有估计里方差最低的——它只用了一次随机转移,随机性来源少。但它对 V 的依赖是最重的:V(s') 完全靠网络猜,如果 V 本身偏差 0.3,这个偏差直接就变成了优势估计的误差。
打个比方,一步 TD 误差就像只拿"明天和今天比"。如果这个"明天"的预测本身就不靠谱,你的判断就会系统性跑偏。极端情况下,如果 V 一直高估所有状态,那 δ 会经常是负的,Actor 会觉得"我干啥都不够好",策略退化得很保守。
3.2 n 步回报:多走几步再回头
折中方案是往前走 n 步,用真实的 n 步奖励加上第 n 步之后的 V 估计:
R_t^{(n)} = r_t + γ r_{t+1} + ... + γ^{n-1} r_{t+n-1} + γ^n V(s_{t+n})
n=1 退化成一步 TD,n=∞ 且回合结束时退化成蒙特卡洛。n 越大,用真实奖励的部分越多,偏差越小、方差越大。
实践里 n 一般取 5 到 20。这个范围不是拍脑袋的:太小了偏差压不住,太大了方差回归。而且 n 的选择和任务的"决策影响时长"有关——如果一个动作的影响在十来步内就体现完了,n 取 10 左右就够;如果是长周期的稀疏奖励任务,n 得取大一些,否则你永远等不到奖励信号传回来。
3.3 GAE:用 λ 把 n 步的离散选择变成连续旋钮
GAE(Generalized Advantage Estimation)做的事很聪明:它不选某一个 n,而是把所有 n 步的优势按指数权重加权求和:
Â_t^{GAE(γ,λ)} = Σ_{l≥0} (γλ)^l · δ_{t+l}
其中 δ_t = r_t + γV(s_{t+1}) − V(s_t)。λ ∈ [0,1] 就是那个旋钮:
- λ = 0:只剩 l=0 那一项,退化成一步 TD 误差,低方差高偏差。
- λ = 1:加权系数变成 γ^l,等价于用完整回报减基线,高方差低偏差。
- λ = 0.95:业界最常用的默认值,方差可控、偏差不大,适合大多数任务起步。
代码实现要注意:GAE 是逆序递推的,从轨迹末尾往前算,因为 Â_t 依赖 Â_{t+1} 的 TD 误差链。
def compute_gae(rewards, values, next_values, dones, gamma=0.99, lam=0.95): # 全部是 [T, N] 形状:T 步、N 个并行环境 T = rewards.shape[0] adv = torch.zeros_like(rewards) last_gae = torch.zeros_like(rewards[0]) for t in reversed(range(T)): mask = 1.0 - dones[t] # 终止则切断自举 delta = rewards[t] + gamma * next_values[t] * mask - values[t] last_gae = delta + gamma * lam * mask * last_gae adv[t] = last_gae returns = adv + values # 给 Critic 当回归目标 return adv, returns这段代码里有三个地方特别容易写错。第一,mask必须同时作用在next_values和last_gae上——终止之后不光没有未来价值,也没有"下一步的 GAE",两处都要断。第二,循环必须从后往前,正着算就是错的。第三,returns = adv + values是给 Critic 的回归目标,等价于"TD(λ) 回报",用它代替纯 TD 目标能让 Critic 学得更稳一些。
3.4 优势归一化:一行代码解决大半的方差问题
算完 advantage 之后,几乎所有人都要做这一步:
adv = (adv - adv.mean()) / (adv.std() + 1e-8)这行代码在 PPO 的实现里几乎是标配,很多人是抄过来的但说不清为什么。理由是:advantage 的绝对尺度会随奖励尺度变化,而 Actor 的学习率是固定的。如果一批样本里的 advantage 全都在 +100 附近(因为环境奖励给得很大),那log_prob * adv的梯度幅度就会很大,等效于放大了学习率,一步更新就能把策略推到崩溃。
归一化之后,advantage 变成了均值为 0、标准差为 1 的相对信号,梯度尺度就稳定了。注意分母要加一个小常数防零,尤其是在训练早期 Critic 还没学到东西、整批 advantage 几乎相同时,标准差会接近 0。
有个副作用要知道:归一化会让每一批的 advantage 均值变成 0,意味着"这一批里平均而言没有动作是绝对好的"。对于纯 on-policy 的小批量更新,这没影响,因为基线本来就是可以任意平移的;但如果你的批次里有大量重复轨迹,或者批次太小(比如只有几十个样本),归一化反而会引入额外噪声。经验是批次样本数在几百以上时归一化稳稳有利,批次很小时要慎用。
4. 手写一个能跑通的版本:从采样到更新完整链路
4.1 为什么坚持同步、单环境、固定长度 rollout
在动手写之前,先说清楚这个最小实现的设计取舍,因为这决定了它能不能复现、能不能调试。
同步而非异步:A3C 那种多线程异步更新的方式,理论上样本效率高,但实际跑起来的结果极难复现——线程调度、梯度覆盖顺序都不确定,你在本地跑出的曲线和在别处跑出的曲线可以完全不同。作为学习版本,同步收集、同步更新,结果可复现,出错也好定位。
固定长度 rollout 而非整回合:每个环境采固定 T 步(比如 128 或 256),然后无论回合有没有结束都做一次更新。好处是每次更新的样本量恒定,超参好调;而且天然支持没有终止状态的持续任务。代价是要处理"rollout 截断"的情况——这一步没结束,但我们不再往后采了,最后一步的 next_value 还是要用 V(s_T) 自举,不能当成 done。
单环境起步:并行环境能显著降低梯度方差,但会掩盖很多单环境下的问题。先用单环境跑通、确认能稳定学习,再上并行,这个顺序别反。
4.2 采样循环:把状态、动作、奖励、价值存成对齐的几条数组
def collect_rollout(env, actor, critic, rollout_len, gamma=0.99): obs_buf, act_buf, logp_buf = [], [], [] rew_buf, val_buf, done_buf, nextobs_buf = [], [], [], [] obs, _ = env.reset() for _ in range(rollout_len): obs_t = torch.as_tensor(obs, dtype=torch.float32) with torch.no_grad(): dist = actor(obs_t) action = dist.sample() logp = dist.log_prob(action) value = critic(obs_t) next_obs, reward, terminated, truncated, _ = env.step(action.item()) done = terminated or truncated obs_buf.append(obs_t) act_buf.append(action) logp_buf.append(logp) rew_buf.append(reward) val_buf.append(value) done_buf.append(float(done)) nextobs_buf.append(torch.as_tensor(next_obs, dtype=torch.float32)) obs = next_obs if done: obs, _ = env.reset() # 关键:rollout 尾部要用 V(s_T) 自举,而不是 0 with torch.no_grad(): last_val = critic(torch.stack(nextobs_buf[-1:])).squeeze(0) return { "obs": torch.stack(obs_buf), "act": torch.stack(act_buf), "logp": torch.stack(logp_buf), "rew": torch.tensor(rew_buf, dtype=torch.float32), "val": torch.stack(val_buf), "done": torch.tensor(done_buf, dtype=torch.float32), "last_val": last_val, }terminated和truncated必须分开处理,这是新手最容易踩的坑之一。terminated=True表示任务真的结束了(杆子倒了),此后没有未来价值,自举要切断;truncated=True表示只是达到时间上限被强制中断,环境本身还能继续,理论上仍有未来价值,不应该切断自举。
早期版本的接口把它们合成一个done,导致很多实现把超时也当成真终止,学出来的 V 会系统性偏低——因为模型学到"到了 200 步价值就归零",而实际不是。如果你用的环境只返回一个done,那就没法区分,只能接受这个偏差,或者手动加时间计数来判断。
4.3 更新:一次前向、两条损失、两次反向
def update(actor, critic, batch, actor_opt, critic_opt, gamma=0.99, lam=0.95, ent_coef=0.01, max_grad_norm=0.5): obs, act = batch["obs"], batch["act"] old_val = batch["val"] rew, done = batch["rew"], batch["done"] # 构造 next_values:除最后一步外都是 val[t+1],最后一步用 last_val next_vals = torch.cat([old_val[1:], batch["last_val"].unsqueeze(0)], dim=0) adv, returns = compute_gae(rew, old_val, next_vals, done, gamma, lam) adv = (adv - adv.mean()) / (adv.std() + 1e-8) # ---- Critic ---- v = critic(obs) critic_loss = 0.5 * (returns.detach() - v).pow(2).mean() critic_opt.zero_grad() critic_loss.backward() nn.utils.clip_grad_norm_(critic.parameters(), max_grad_norm) critic_opt.step() # ---- Actor ---- dist = actor(obs) logp = dist.log_prob(act) entropy = dist.entropy().mean() actor_loss = -(logp * adv.detach()).mean() - ent_coef * entropy actor_opt.zero_grad() actor_loss.backward() nn.utils.clip_grad_norm_(actor.parameters(), max_grad_norm) actor_opt.step() return actor_loss.item(), critic_loss.item(), entropy.item()几个细节值得单独拎出来说。
entropy前面的符号。熵是"策略的随机程度",我们希望在训练早期保持一定随机性以维持探索,所以要让熵变大。PyTorch 的优化器最小化,所以写成- ent_coef * entropy。如果符号写反了,策略会在几千步内迅速坍缩成确定性输出,然后卡死在某个次优动作上再也不动——症状是熵曲线一路降到接近 0,回报停在低水平不动。
梯度裁剪。max_grad_norm=0.5是 PPO 实现里的常见值。Actor-Critic 的梯度偶尔会突然爆掉,尤其是 Critic 估出一个离谱的 V 值时,advantage 会被放大到几百,一步更新就把策略推到不可恢复的区域。裁剪不能解决根因,但能给你时间观察。裁剪阈值不要设得太大(比如 10),那基本等于没裁;也不要太小(比如 0.01),会让学习速度慢到看不出来。
两个优化器分开。别图省事用一个优化器管所有参数。Actor 和 Critic 的最优学习率经常差三倍以上,共用一个会把其中一个坑了。
4.4 一份可以直接抄的超参起点
| 超参 | 起步值 | 调整方向与理由 |
|---|---|---|
| rollout 长度 T | 128 ~ 256 | 太小学不到长期结构,太大更新次数少、样本旧 |
| gamma | 0.99 | 任务越需要长视野越接近 0.999,但方差随之上升 |
| GAE lambda | 0.95 | 0.9~0.97 之间微调,波动大就往 0.9 调 |
| actor 学习率 | 3e-4 | 崩了先降到 1e-4,学不动再升到 1e-3 |
| critic 学习率 | 1e-3 | 通常是 actor 的 2~3 倍,让 V 学得比 π 快 |
| 熵系数 | 0.01 | 熵掉太快加大,策略太随机减小;连续控制常更小 |
| 梯度裁剪 | 0.5 | 出现 loss 尖峰时下调到 0.3 |
| 并行环境数 | 1 → 8 | 单环境稳了再并行,能明显降方差 |
| 网络宽度 | 64 或 128 | 观测维度低时 64 够用,别上 512 |
这张表不是教条,但它覆盖了 80% 的入门场景。真正需要凭经验判断的是"critic 学习率是 actor 的几倍"这件事,理由在下一节。
5. 训练崩掉的五种典型表象与逐层排查路径
这一节是全篇最值钱的部分。下面每一种症状我都按"看到什么 → 怎么确认 → 根因在哪 → 怎么修"的顺序写,方便你直接对着现场排查。
5.1 症状一:critic loss 一路降,回报死活不涨
这是最迷惑人的情况,因为一切看起来"在正常工作"。
怎么确认:把 critic 输出的 V 值和实际算出来的 returns 画在同一张图上,看两条曲线是不是贴得很近。如果贴得很近,说明 Critic 拟合没问题,问题在别处;如果 V 几乎是一条水平线、returns 剧烈震荡,说明 Critic 根本没学到东西,只是学到了"输出一个常数"。
根因与修法,按发生概率排序:
第一种,奖励尺度太小导致梯度信号微弱。比如奖励全在 ±0.01 量级,那 TD 误差也是这个量级,Critic 的损失小到约等于零,梯度几乎不动。修法是做奖励缩放,把奖励除以一个估计的滑动标准差,或者干脆整体乘 10。
第二种,actor 学习率相对 critic 太快。Critic 还没估准,Actor 已经按噪声优势跑偏了,然后 Critic 又得去追一个已经变了的策略,形成恶性循环。修法很简单:把 actor lr 降到 1e-4,或者反过来把 critic lr 提到 2e-3,让 V 先追上。
第三种,优势归一化把信号抹平了。如果每批 advantage 都接近常数(因为 Critic 输出全部相同),归一化后就是纯粹的噪声,Actor 学到的全是随机方向。停掉归一化试试,如果这时候才开始有反应,说明根因在 Critic 没学到东西。
5.2 症状二:前几万步还行,之后断崖式下跌再也回不来
这是典型的策略坍缩。
怎么确认:看熵曲线。如果熵在崩之前就开始加速下降,最后贴到接近 0(离散动作空间下),基本坐实。
根因:熵系数没设或者设太小,策略过早收敛到一个局部最优;或者 Critic 某个时刻给出了一个错误的强正 advantage,Actor 把某个动作的概率推到 0.99 以上,之后再也采不到别的动作,也就再也拿不到纠正信号。这是自举结构下最难恢复的一种状态,因为"探索"这件事本身已经被杀死了。
修法:熵系数从 0.01 提到 0.02 或 0.05;加一个概率下限不太可行(softmax 出来的),更实用的做法是给策略加一点人为噪声,或者用更大的 rollout 长度让样本更多样。还有一招是在崩溃点之前 checkpoints,用更小的学习率续训。
5.3 症状三:loss 出现 NaN 或者突然变成极大的数
怎么确认:把torch.isnan(loss)和梯度范数都打出来。梯度范数超过 100 基本就是爆了。
根因:最多的是log_prob遇到概率为 0 的动作。离散情况下,如果某个动作的 logit 被推到 -50,softmax 出来的概率是 0,log(0)就是 -inf,乘上一个负 advantage 变成 +inf,再 mean 就是 NaN。连续情况下,如果log_std被优化到极小,Normal 的 log_prob 会数值爆炸。
修法:离散情况用Categorical(logits=...)而不是Categorical(probs=...),PyTorch 内部会用 log_softmax 计算,数值更稳;连续情况给log_std做 clamp,比如log_std.clamp(-20, 2);再加梯度裁剪兜底。
还有一个隐蔽来源:adv.std()在整批 advantage 完全相同时是 0,除法就炸。那个+1e-8绝对不能省。
5.4 症状四:共享网络时 critic 学得好,actor 学不动
这是共享参数特有的坑,值得单独讲。
当你把底层特征提取层共享给 Actor 和 Critic、只在最后分两个头时,共享层的参数会同时收到两条 loss 的梯度。这两条梯度的量级经常差好几个数量级——Critic 的 MSE 损失可能只有 0.01 量级,Actor 的 PG 损失可能只有 0.001 量级,但方向经常冲突。结果是共享层被迫在两个目标间妥协,哪边都学不好。更糟的是如果两条 loss 用同一个优化器,学习率对两边都不合适。
怎么确认:把共享网络拆成两个独立网络跑同样的超参。如果独立版本明显更好,就是这个问题。
真正的权衡:共享不是没有价值,它在观测维度高(比如像素输入)时能显著减少参数、加快学习,因为底层视觉特征对两个任务都通用。所以判断标准是:低维观测(几个到几十维的物理量),分开;高维观测(图像、长序列),共享,但要给两个 loss 分别加权,或者用独立的优化器配合不同的学习率。
我自己在低维观测上折腾了很久共享网络,希望它能互相促进,结果拆开之后回报直接涨了 30% 多。这个结论不一定普适,但值得先试。
5.5 症状五:换一个随机种子结果天差地别
单次训练的结果没有任何参考价值,这句话听起来像废话,但它是最容易被忽略的一条。
根因:三个种子来源——网络初始化、环境初始状态分布、动作采样。三者叠加,早期差异会被自举放大,最后收敛到完全不同的解。
修法:至少跑 5 个种子,报中位数和四分位区间,不要报最好的那一次。评估的时候固定种子、固定初始状态,最好跑多个评估回合取平均。另外,env.reset(seed=...)和torch.manual_seed(...)要一起设,只设一个等于没设。
6. 把 Actor-Critic 放进家族图谱:几个关键分身的关系
6.1 A2C 与 A3C:同步和异步的分岔
A2C(Advantage Actor-Critic)几乎是前面代码的直译:多环境并行采样、算出优势、同步更新。A3C 把"同步"改成"异步"——多个工作线程各自跑环境,各自算梯度,异步往全局参数上累加。
A3C 当年的卖点是不需要经验回放就能打破样本相关性(多线程天然提供多样样本),但它的问题在现代硬件上很明显:单机多线程在 Python 里有 GIL 限制,实际加速有限;异步更新让结果不可复现;梯度覆盖的顺序影响最终解。所以现在的工程实践里,A2C 加并行环境是更常见的选择,A3C 更多是教学和历史上的意义。
从 A2C 往后,所有改进几乎都在解决同一个问题:策略更新的步长该怎么控制。策略梯度有个天然毛病——一次更新太猛,策略就崩了,而且崩了之后采不到好样本,越采越差,回不来。
6.2 PPO:把"更新幅度"写进损失函数
PPO 的核心思路是限制新旧策略的比率 r_t(θ) = π_θ(a|s) / π_old(a|s) 不要偏离太远,用 clip 把比率截断在 [1−ε, 1+ε] 内:
L = − E[ min( r_t · Â_t, clip(r_t, 1−ε, 1+ε) · Â_t ) ]
这个改动让 PPO 能用同一批数据做多轮(epoch)更新而不至于崩溃,样本效率比单次更新的 A2C 高不少。但要注意,PPO 的稳定是"延迟崩溃",不是"不会崩溃"——如果超参不对,它会在第 200 次迭代突然全线崩掉,而且比 A2C 崩得更彻底,因为多轮更新把策略推得更远。
从 Actor-Critic 的视角看,PPO 没有改变 Actor 和 Critic 的分工,只改变了 Actor 的损失形式。所有前面讲的 Critic 相关问题——TD 目标 detach、终止自举、优势归一化、奖励缩放——在 PPO 里一模一样存在。这也是为什么我觉得把 Actor-Critic 的底子打牢比直接上手 PPO 更划算:PPO 的 clip 只是外壳,里面的发动机还是这两张网。
6.3 连续控制与 off-policy 分支:DDPG、TD3、SAC
离散动作空间的 Critic 只需要估 V(s),因为它可以遍历所有动作。连续动作空间不行,所以这一支的 Critic 改成了估 Q(s,a),并且需要 Actor 提供一个动作来查表。这个结构上的改动带来了一整套连锁反应:
- DDPG:确定性策略 + Q 网络,用 target 网络软更新来稳定自举。
- TD3:发现 DDPG 会系统性高估 Q 值,于是用两个 Q 网络取最小值(clipped double Q),并给目标动作加噪声做平滑。
- SAC:把熵正则写进目标函数本身,目标是最大化"回报 + 熵",并用双 Q 抑制高估,同时用随机策略而不是确定性策略。
这几个都在往 off-policy 走,意味着要用经验回放。回放池带来样本效率的飞跃,但也引入一个新问题:回放池里的数据是旧策略采的,Critic 学的是旧策略的价值,而 Actor 已经变了。这就是为什么 off-policy 的 AC 必须用 target 网络、必须软更新——本质上是在用"慢半拍"的目标来抑制自举的发散。
一个实用的判断:样本便宜(仿真环境能跑几千 FPS)就用 on-policy 的 A2C/PPO;样本昂贵(真实设备、需要人操作)就上 SAC 这类 off-policy。别在样本便宜的场景硬上 off-policy,回放池的调参复杂度会白白吃掉你的时间。
7. 那些文档里不写但很影响结果的细节
7.1 观测归一化比网络结构重要得多
我做过一个对比:同一个任务,只改观测归一化,其他全不动,最终回报能差一倍以上。
物理量的观测值尺度经常差异巨大——位置可能是 0.001 到 0.01 量级,速度可能是 -10 到 10,角度是 ±3.14。直接喂进网络,第一层的权重梯度会被大量纲的那几维主导,小量纲的特征基本学不到。
做法是跑一个滑动统计(running mean / running std),用它的均值和标准差做归一化,并且统计量要持续更新,因为随着策略变化,观测分布也在漂移。代码大致是:
class RunningNorm: def __init__(self, shape, eps=1e-8): self.mean = torch.zeros(shape) self.var = torch.ones(shape) self.count = eps self.eps = eps def update(self, x): batch_mean, batch_var = x.mean(0), x.var(0, unbiased=False) n = x.shape[0] delta = batch_mean - self.mean total = self.count + n self.mean = self.mean + delta * n / total m2 = self.var * self.count + batch_var * n + delta.pow(2) * self.count * n / total self.var = m2 / total self.count = total def __call__(self, x): return ((x - self.mean) / (self.var.sqrt() + self.eps)).clamp(-10, 10)末尾的clamp(-10, 10)很有用。归一化后的极端值(比如某个传感器偶尔抽风)会产生巨大输入,进而产生巨大梯度。裁剪把这种离群点的影响封顶,代价很小。
有一个陷阱:评估时也要用同一套统计量,但不能更新统计量。我在一个项目里评估时也调了update,导致评估过程污染了归一化参数,几次评估之后模型表现越来越差,排查了很久才定位到。
7.2 奖励缩放:让 TD 误差落在舒服的量级
前面提过一嘴,值得展开。TD 误差的理想量级大概在 0.1 到 10 之间。太大,梯度过猛;太小,梯度消失。
一个好用的经验做法是维护一个折扣回报的滑动标准差,把奖励除以它:
reward_scale = 1.0 / (running_std_of_return + 1e-8) scaled_reward = reward * reward_scale但注意,缩放会改变 V 的尺度,所以统计量必须在训练全程一致,不能中途切换。切换会导致 Critic 学到的 V 突然和新的目标对不上,出现一次假崩溃。
还有,缩放只改变优化的数值条件,不改变最优策略本身(因为所有奖励同比例缩放,最优动作不变)。这一点可以放心。
7.3 并行环境到底降的是什么方差
很多人以为并行环境是为了"跑得快",其实它的主要价值是降梯度方差。
一批数据里如果来自多个独立环境的轨迹,优势估计的随机性会互相抵消,均值的估计误差按 1/√N 下降。8 个并行环境理论上能把优势估计的标准误降到 1/2.83。收益是实打实的,但要注意两点:
第一,并行环境用的是同一份策略参数,所以它们采集的仍旧是 on-policy 数据,可以直接用同一个批算梯度,不需要重要性采样。
第二,rollout 长度要按"每个环境 T 步"算,总样本数是 T×N。如果你原来单环境 T=2048,改成 8 环境 T=256,总样本量反而少了。别改错了。
实现上,最简单的是用向量化环境包装器,它把多个环境的状态堆成一个批,一次step全部推进。这块不难,但要处理"某些环境提前结束"的情况——结束的环境自动 reset,并且记录 done 标记,GAE 里用 done 切断自举。
7.4 什么时候该停下来调参,什么时候该改结构
这是一个元问题,但它决定了你能否跑完项目。
我的经验线是这样的:如果 20 万步内回报完全没有上涨趋势(连噪声都算不上,就是平的),改结构;如果有上涨但很慢,调超参;如果涨到某个平台反复横跳,加并行环境和熵;如果涨了又崩,查数值稳定性。
这条线的依据是,20 万步在大多数入门任务里已经足够让一个配置合理的 AC 学到明显优于随机的策略。跑满还完全没动静,通常不是超参的问题,而是结构性问题——比如 Critic 根本没接上梯度、优势符号反了、奖励没传过来。这类问题调参永远调不出来。
反过来,如果回报已经在涨但很慢,别急着改架构。先看 critic loss 是不是降得比 actor loss 慢太多,先调学习率配比。改结构应该是最后的手段,因为它会引入新的不确定性,让你失去对照。
7.5 分开的 Actor 和 Critic 网络,参数效率真的低吗
最后聊一个我纠结过的点。前面说低维观测建议分开,但分开之后参数翻倍,看起来"浪费"。实际测量下来,两三层 128 隐层的网络也就几万个参数,和现代模型比微不足道。真正影响效率的不是参数量,而是训练信号的纯度。
共享网络时,共享层要同时满足两个目标,它们的梯度在方向上经常有冲突成分。有实验观察过,在训练后期两条梯度夹角的余弦值常常是负的,意味着它们在互相拉扯。分成两个网络,各自只优化自己的目标,收敛路径更直白。
代价也有:共享时的样本效率确实更高一些,因为一个样本同时更新两个目标。这个收益在样本极其昂贵时是值得的。所以最终的判断标准还是那句老话——观测维度高、样本贵,就共享并小心调权重;观测低维、样本便宜,就分开,省心。
如果你现在正在被某个 Actor-Critic 的实现折磨,我建议你先做一件事:把一个训练回合里 actor 的 log_prob、critic 的 V、算出来的 advantage、实际的回报,四个数打印出来看一遍。这四行数字比任何曲线都能更快告诉你哪里错了——比如 advantage 全正、V 全是 0、log_prob 全是同一个值,每一个都对应一个明确的结构性 bug。这个过程我做过太多次,基本上五分钟就能定位,比盯着 loss 曲线猜半小时高效得多。