Actor-Critic强化学习:策略梯度、优势函数与训练排错

发布时间:2026/10/26 12:03:14

Actor-Critic强化学习:策略梯度、优势函数与训练排错 先说一个我踩过的坑第一次把 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_{t1}) − 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, hidden128, continuousFalse): 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(logitsself.logits_head(h)) class Critic(nn.Module): def __init__(self, obs_dim, hidden128): 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_{t1}) − 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直接乘进去而不 detachactor_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_{t1} ... γ^{n-1} r_{tn-1} γ^n V(s_{tn})n1 退化成一步 TDn∞ 且回合结束时退化成蒙特卡洛。n 越大用真实奖励的部分越多偏差越小、方差越大。实践里 n 一般取 5 到 20。这个范围不是拍脑袋的太小了偏差压不住太大了方差回归。而且 n 的选择和任务的决策影响时长有关——如果一个动作的影响在十来步内就体现完了n 取 10 左右就够如果是长周期的稀疏奖励任务n 得取大一些否则你永远等不到奖励信号传回来。3.3 GAE用 λ 把 n 步的离散选择变成连续旋钮GAEGeneralized Advantage Estimation做的事很聪明它不选某一个 n而是把所有 n 步的优势按指数权重加权求和Â_t^{GAE(γ,λ)} Σ_{l≥0} (γλ)^l · δ_{tl}其中 δ_t r_t γV(s_{t1}) − V(s_t)。λ ∈ [0,1] 就是那个旋钮λ 0只剩 l0 那一项退化成一步 TD 误差低方差高偏差。λ 1加权系数变成 γ^l等价于用完整回报减基线高方差低偏差。λ 0.95业界最常用的默认值方差可控、偏差不大适合大多数任务起步。代码实现要注意GAE 是逆序递推的从轨迹末尾往前算因为 Â_t 依赖 Â_{t1} 的 TD 误差链。def compute_gae(rewards, values, next_values, dones, gamma0.99, lam0.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, gamma0.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, dtypetorch.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, dtypetorch.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, dtypetorch.float32), val: torch.stack(val_buf), done: torch.tensor(done_buf, dtypetorch.float32), last_val: last_val, }terminated和truncated必须分开处理这是新手最容易踩的坑之一。terminatedTrue表示任务真的结束了杆子倒了此后没有未来价值自举要切断truncatedTrue表示只是达到时间上限被强制中断环境本身还能继续理论上仍有未来价值不应该切断自举。早期版本的接口把它们合成一个done导致很多实现把超时也当成真终止学出来的 V 会系统性偏低——因为模型学到到了 200 步价值就归零而实际不是。如果你用的环境只返回一个done那就没法区分只能接受这个偏差或者手动加时间计数来判断。4.3 更新一次前向、两条损失、两次反向def update(actor, critic, batch, actor_opt, critic_opt, gamma0.99, lam0.95, ent_coef0.01, max_grad_norm0.5): obs, act batch[obs], batch[act] old_val batch[val] rew, done batch[rew], batch[done] # 构造 next_values除最后一步外都是 val[t1]最后一步用 last_val next_vals torch.cat([old_val[1:], batch[last_val].unsqueeze(0)], dim0) 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_norm0.5是 PPO 实现里的常见值。Actor-Critic 的梯度偶尔会突然爆掉尤其是 Critic 估出一个离谱的 V 值时advantage 会被放大到几百一步更新就把策略推到不可恢复的区域。裁剪不能解决根因但能给你时间观察。裁剪阈值不要设得太大比如 10那基本等于没裁也不要太小比如 0.01会让学习速度慢到看不出来。两个优化器分开。别图省事用一个优化器管所有参数。Actor 和 Critic 的最优学习率经常差三倍以上共用一个会把其中一个坑了。4.4 一份可以直接抄的超参起点超参起步值调整方向与理由rollout 长度 T128 ~ 256太小学不到长期结构太大更新次数少、样本旧gamma0.99任务越需要长视野越接近 0.999但方差随之上升GAE lambda0.950.9~0.97 之间微调波动大就往 0.9 调actor 学习率3e-4崩了先降到 1e-4学不动再升到 1e-3critic 学习率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 某个时刻给出了一个错误的强正 advantageActor 把某个动作的概率推到 0.99 以上之后再也采不到别的动作也就再也拿不到纠正信号。这是自举结构下最难恢复的一种状态因为探索这件事本身已经被杀死了。修法熵系数从 0.01 提到 0.02 或 0.05加一个概率下限不太可行softmax 出来的更实用的做法是给策略加一点人为噪声或者用更大的 rollout 长度让样本更多样。还有一招是在崩溃点之前 checkpoints用更小的学习率续训。5.3 症状三loss 出现 NaN 或者突然变成极大的数怎么确认把torch.isnan(loss)和梯度范数都打出来。梯度范数超过 100 基本就是爆了。根因最多的是log_prob遇到概率为 0 的动作。离散情况下如果某个动作的 logit 被推到 -50softmax 出来的概率是 0log(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同步和异步的分岔A2CAdvantage 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, eps1e-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, unbiasedFalse) 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。如果你原来单环境 T2048改成 8 环境 T256总样本量反而少了。别改错了。实现上最简单的是用向量化环境包装器它把多个环境的状态堆成一个批一次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 曲线猜半小时高效得多。
延伸阅读

更多相关文章

2026/10/26 11:58:13

VS Code 中文 LaTeX 配置:UTF-8、xelatex 与 ctex 排查指南

1. 问题定位:先搞清楚是“编译报错”还是“PDF 不显示”这个话题看起来像是一个小问题,实际上它包含两类完全不同的故障:一类是编译阶段直接抛出错误,导致 PDF 根本生成不出来;另一类是编译成功了,但 PDF 里…

2026/10/26 11:58:13

AI赋能网络安全:智能运营、自动化渗透与知识图谱设计

简介:《AI赋能网络安全.pptx》是一份面向网络安全从业者、安全运营与渗透测试人员及相关专业学习者的主题演示文稿,聚焦人工智能在威胁检测、安全运营、渗透测试与知识决策中的落地路径。压缩包内仅含1个pptx文件,约14.99MB,结构按…

2026/10/26 12:58:24

AI芯片选型避坑指南:读懂TOPS、能效比与带宽的真实含义

1. 这不是参数表,是AI芯片的“体检报告单” 你手头那张标着“算力XX TOPS”“能效比YY W/TOPS”的芯片参数页,真能当采购依据用?我见过太多团队拿着这张纸就拍板下单,结果模型一跑起来,显存爆了、温度飙到95℃、推理延…

2026/10/26 12:58:24

C语言AES-256-CBC实现:与Java JCE严格对齐的嵌入式加密方案

简介:本资源是一份面向信息安全开发者与嵌入式工程师的C语言AES-256-CBC加解密实现库,解决跨平台加密结果一致性难题,尤其适用于需与Java服务端协同加解密的物联网、金融终端或本地数据保护场景。压缩包共3个文件(2个C源码1个头文…

2026/10/26 12:58:24

AI芯片选型实战:避开TOPS陷阱的5个硬指标

1. 项目概述:当AI芯片清单从57种膨胀到120多种,我们到底在选什么? “一份AI芯片调查从57种写到120多种”——这句话不是夸张,而是我去年参与某高校边缘智能平台选型时的真实记录。起初团队只打算对比主流GPU和几款国产加速卡&…

2026/10/26 12:58:24

卷积核权重在具身智能中的动态学习与应用

1. 卷积核权重的本质:从手工设计到数据驱动 在具身智能系统的视觉感知模块中,卷积神经网络(CNN)承担着环境理解的核心任务。传统图像处理中的Sobel、Gauss等卷积核采用手工设计的固定权重,例如: # 手工设…

2026/10/26 12:58:24

AI芯片选型不是比参数,而是签五层计算契约

1. 这份AI芯片调查为什么从57种涨到120多种?——不是数据膨胀,是行业在裂变 “一份AI芯片调查从57种写到120多种”,这个标题乍看像数据新闻的常规迭代,但如果你真去翻过早期那份57款的清单,会发现它基本只覆盖了三类东…

2026/10/26 12:53:23

昇腾CANN Runtime架构与AIGC模型优化实践

1. CANN Runtime技术架构解析CANN(Compute Architecture for Neural Networks)作为专为神经网络计算设计的架构体系,其Runtime仓库承载着模型在昇腾NPU上高效执行的核心功能。这个"动力引擎"主要由以下几个关键组件构成&#xff1a…

2026/10/26 0:20:20

全国计算机二级C语言真题PDF高效刷题指南

简介:这份PDF是全国计算机二级C语言考试的试题与答案解析,专为备考二级C语言的考生准备。内容涵盖C语言特点与设计理念、程序基本组成单位、main函数执行流程、函数嵌套调用规则、标识符命名规范、整型实型字符型数据类型、八进制十六进制数合法形式、运…

2026/10/26 1:53:47

requests+BeautifulSoup:爬虫抓取h1标题的入门实战指南

简介:一份以实战为导向的Python爬虫入门资源,面向具备基本编程基础、希望快速掌握网络数据抓取的初学者,可解决从零编写简单爬虫的常见困惑。实例以requests库发送HTTP请求、BeautifulSoup解析HTML文档,完整演示从网页中提取 标题…

2026/10/26 0:20:17

递推最小二乘参数辨识MATLAB编程:二阶系统与渐消记忆实现

简介:这是一篇面向控制系统、信号处理与机器学习等领域学习者的最小二乘参数辨识程序说明文档。资源共包含一个docx格式文档,压缩包大小七十八KB,内容围绕递推最小二乘算法和渐消记忆最小二乘算法展开:先构造一个极点位于单位圆内…

2026/10/26 0:00:48

Nested Learning:解决AI灾难性遗忘的分层记忆架构

1. 为什么AI需要"记忆"能力?在传统深度学习模型中,我们常遇到一个根本性问题:模型在处理新任务时,往往会完全遗忘之前学到的知识。这种现象被业界称为"灾难性遗忘"(Catastrophic Forgetting)。想象一下&#…

2026/10/26 0:00:48

企业级大模型应用:OCR预处理、Dify编排与DeepSeek落地实战

1. 这不是“用大模型”,而是重构工作流的起点“大模型的应用和工具”——这八个字现在被贴在无数技术分享、招聘JD、内部立项书的标题栏里,但绝大多数人点进去后看到的,是零散的API调用示例、几个开源UI截图、或者一句“接入LLM就能降本增效”…

还想了解更多?直接咨询顾问

免费诊断 + 免费方案 + 透明报价。

全国咨询热线400-8866-253
免费获取方案
☎咨询二维码 ☎ ↑