ARTICLE DETAIL

资讯详情

深耕商务建站与企业官网运营的一线实战洞察。

Actor-Critic实战:优势函数、GAE与A2C训练循环拆解

Actor-Critic实战:优势函数、GAE与A2C训练循环拆解 强化学习学到 Actor-Critic 这一节基本算是从会跑 demo跨到能理解现代 RL 工程的分水岭。前面讲策略梯度时REINFORCE 用一整条轨迹的回报去估计梯度方差大到需要几十次采样才能勉强收敛讲到值函数近似时又发现只学 Q 值没法直接输出策略。Actor-Critic 把这两条线拧在一起一个网络负责决定动作一个网络负责评价动作好不好用评价网络的输出给决策网络的梯度降方差。A2C、A3C、PPO、DDPG、TD3、SAC 这一整串现在还在被广泛使用的算法骨架都是它。这篇文章是2026 新版强化学习从入门到精通系列的第 42 篇重点不再停留在概念解释上而是把 Actor-Critic 的网络结构、优势函数、GAE、损失函数组合、训练循环这些真正影响能不能跑通的部分拆开讲。文中的每段代码都可以直接复制运行环境用 PyTorch GymnasiumCartPole 这类任务在纯 CPU 上几分钟就能看到曲线抬起来不需要显卡。读者对象很明确已经写过 REINFORCE 或 DQN、知道策略梯度和贝尔曼方程大概在讲什么、但自己动手写 Actor-Critic 时总是卡在为什么 loss 是这三项加在一起优势函数到底怎么算critic 学崩了怎么办这几个问题上的人。看完之后应该能独立写出一个可训练的 A2C并且知道把它换成 PPO 需要动哪几行。1. Actor-Critic 核心能力速览先把这套架构的关键信息压缩成一张表后面所有内容都是围绕这张表展开的细节。需要注意Actor-Critic 不是某一个算法而是一类架构范式表里列的是这个范式的共性以及它在主流算法上的具体投影。能力项说明架构组成Actor 策略网络输出动作分布Critic 价值网络输出状态价值或动作价值解决的核心问题策略梯度方差过大、单步更新效率低、无法在线学习梯度估计方式用 Critic 给出的价值作为基线或优势替代蒙特卡洛回报支持的动作空间离散动作Categorical与连续动作Gaussian / Tanh-Gaussian均可典型代表算法A2C、A3C、PPO、TRPO、DDPG、TD3、SAC网络结构可共享主干shared trunk也可完全分离separate networks更新方式同策略A2C/PPO或异策略DDPG/TD3/SAC需要经验回放采样效率同策略版本中等异策略版本较高训练稳定性依赖优势归一化、梯度裁剪、价值损失系数等工程手段硬件门槛小型任务纯 CPU 可跑Atari 级别建议单卡 GPU并行环境吃 CPU 核数依赖环境Python 3.10、PyTorch 2.x、Gymnasium 1.x版本以实际安装为准典型调试周期CartPole 级别分钟级MuJoCo 级别小时级适合场景连续控制、机器人仿真、游戏 AI、推荐策略优化、对话策略学习这里要提前说清楚一个容易混淆的点很多教程把 Actor-Critic 当成一个具体算法讲实际上它更像一个模板。你在模板里换掉优势估计方式就得到 A2C换掉更新约束就得到 PPO把 Critic 从状态价值换成动作价值并加经验回放就得到 DDPG。理解模板比背算法名有用得多因为真实项目里你几乎总要改一点东西。2. 从策略梯度到 Actor-Critic为什么必须有一个 Critic先把动机讲透否则后面看到三项 loss 相加会觉得是拍脑袋凑出来的。最原始的策略梯度定理给出的是这样一个式子梯度等于策略对数概率乘以回报的期望。用采样估计时我们用一整条轨迹的累计回报 G_t 去乘 log π(a|s)。问题在于 G_t 是一个随机变量同一状态下采样十次可能得到十个差别很大的回报梯度方向跟着剧烈抖动。为了让这个估计稳定下来一个很自然的想法是减去一个与动作无关的基线 b(s)因为减去它不会改变期望只会改变方差。数学上可以证明E[∇log π(a|s) · b(s)] 0所以基线是免费的降方差工具。那么最优的基线是什么理论上是状态价值函数 V(s)。把 G_t 换成 G_t - V(s)方差会显著下降。而这个 G_t - V(s) 恰好就是优势函数 A(s,a) 的一个无偏估计它衡量的是在状态 s 下采取动作 a比平均水平的动作好多少。到这里Actor 的更新方向就变成了往优势为正的动作上加概率往优势为负的动作上减概率。接下来的问题是怎么拿到 V(s)。用蒙特卡洛方式统计回报当然可以但那样必须等回合结束学习是离线的、慢的。Critic 网络的作用就是用函数近似的方式直接学 V(s)而且可以只依赖单步的 TD 误差来更新实现真正的在线学习。这就是 Actor-Critic 的核心交换用 Critic 引入偏差bias换来方差的大幅下降和在线更新能力。用一句话概括两者分工Actor 负责试Critic 负责评。Critic 的评价越准Actor 的更新方向越稳Actor 的策略越好Critic 面对的数据分布越集中学起来也越容易。这种互相依赖也带来了这套架构最典型的失败模式——两个网络一起崩后面第 9 章会专门讲怎么拆解。关于偏差和方差的取舍还有一个重要的工程变量用 TD(0) 的单步误差偏差大但方差小用蒙特卡洛回报方差大但无偏中间的 n-step 回报和 GAE 就是在两者之间滑动。这个滑动的旋钮直接决定了训练曲线是平滑爬升还是剧烈震荡是 Actor-Critic 调参里性价比最高的一个参数。3. 环境准备与最小可运行工程骨架Actor-Critic 对环境的依赖其实很轻它不需要 CUDA 编译、不需要下载大模型权重甚至不需要显卡。真正容易出问题的地方在于包版本不匹配导致的 API 变更尤其是 Gymnasium 从 Gym 分叉出来之后step 的返回值从四项变成五项很多老教程直接复制过来就会报解包错误。基础依赖如下。版本号这里不写死因为不同机器上的 CUDA 与 PyTorch 对应关系不同写死反而容易踩坑原则是 PyTorch 官方安装向导给出的组合优先。# 创建独立环境避免和已有项目冲突 conda create -n actor_critic python3.10 -y conda activate actor_critic # 安装核心依赖具体版本以本机可用的 CUDA 组合为准 pip install torch --index-url https://download.pytorch.org/whl/cpu pip install gymnasium numpy matplotlib tensorboard如果机器上有显卡并且想跑图像输入的任务把第一行的 CPU 源换成官方对应的 CUDA 源即可。对于 CartPole、Pendulum、MountainCarContinuous 这类低维状态任务CPU 训练速度快过 GPU因为数据传输开销会盖过并行计算收益这一点在后面的资源占用章节还会提到。建议的工程目录结构如下把网络定义、算法实现、训练脚本、配置分离后面要换成 PPO 或者换环境时改动量会小很多actor_critic_demo/ ├── configs/ │ └── a2c_cartpole.yaml ├── models/ │ └── networks.py # Actor / Critic 定义 ├── algos/ │ ├── a2c.py # A2C 更新逻辑 │ └── gae.py # 优势估计 ├── train.py # 训练主循环 ├── evaluate.py # 评估与可视化 └── requirements.txt验证环境是否装好跑一段最小检查脚本就够了。这段脚本同时能确认 Gymnasium 的 API 版本因为reset返回(obs, info)、step返回五项这个约定是从 Gymnasium 开始统一的如果你的环境返回四项说明装的是老版本 Gym后面的代码需要相应调整。import gymnasium as gym import torch print(torch:, torch.__version__) print(cuda available:, torch.cuda.is_available()) env gym.make(CartPole-v1) obs, info env.reset(seed42) print(obs shape:, obs.shape) print(action space:, env.action_space) obs, reward, terminated, truncated, info env.step(env.action_space.sample()) print(step 返回项数正确reward , reward) env.close()运行结果里如果打印出step 返回项数正确环境就没问题了。如果这里报too many values to unpack说明装的是老版 Gym最简单的处理是统一升级到 Gymnasium而不是在代码里写兼容分支后者会让后面所有逻辑都变脏。4. Actor 与 Critic 的网络结构设计网络结构是 Actor-Critic 里最容易被轻视的部分。很多人直接把别人的 MLP 抄过来层数、激活函数、初始化全都不动结果在某个任务上怎么调都学不动。实际上 Actor 和 Critic 对输出的要求完全不同一个是概率分布参数一个是标量价值两者的输出层设计必须分开考虑。4.1 离散动作空间的 Actor离散动作下Actor 输出的是每个动作的 logit经过 softmax 得到概率再用Categorical分布采样。输出层不要加 softmax因为 PyTorch 的Categorical内部会做 log_softmax提前做一次会导致数值不稳定这是新手最常犯的错误之一。import torch import torch.nn as nn class DiscreteActor(nn.Module): def __init__(self, obs_dim: int, action_dim: int, hidden: int 128): super().__init__() self.body nn.Sequential( nn.Linear(obs_dim, hidden), nn.Tanh(), nn.Linear(hidden, hidden), nn.Tanh(), ) self.head nn.Linear(hidden, action_dim) def forward(self, obs: torch.Tensor): logits self.head(self.body(obs)) return logits激活函数这里选 Tanh 而不是 ReLU原因是 ReLU 在状态输入归一化不理想时容易产生大片死区而策略网络对梯度方向很敏感死区会导致某些动作永远采不到探索直接退化。这不是绝对规则但在连续控制和低维状态任务上Tanh 的鲁棒性明显更好代价是训练稍慢一点。4.2 连续动作空间的 Actor连续动作要输出一个高斯分布的均值和对数标准差。均值通常经过 tanh 压到 [-1, 1]再按动作上下限缩放对数标准差用可学习参数并且要 clamp 到一个合理区间否则训练中期可能出现标准差指数爆炸采样出来的动作全是边界值。class ContinuousActor(nn.Module): def __init__(self, obs_dim: int, action_dim: int, hidden: int 256): super().__init__() self.body nn.Sequential( nn.Linear(obs_dim, hidden), nn.Tanh(), nn.Linear(hidden, hidden), nn.Tanh(), ) self.mean_head nn.Linear(hidden, action_dim) self.log_std nn.Parameter(torch.zeros(action_dim)) def forward(self, obs: torch.Tensor): mean torch.tanh(self.mean_head(self.body(obs))) log_std torch.clamp(self.log_std, min-20, max2) std log_std.exp() return mean, stdlog_std用独立参数而不是网络输出是因为在大部分连续控制任务里探索噪声的水平与具体状态关系不大独立参数收敛更稳。如果你做的是状态相关性很强的任务比如机械臂在接近目标时需要精细动作可以让网络输出 log_std但要额外做 clamp。4.3 Critic 的输出与价值尺度Critic 输出一个标量 V(s)结构比 Actor 简单但初始化很关键。如果 Critic 初始输出偏离真实回报的量级太远第一批优势估计会全是噪声Actor 会朝错误方向更新一大步之后再想纠正就很困难。class Critic(nn.Module): def __init__(self, obs_dim: int, hidden: int 128): super().__init__() self.body nn.Sequential( nn.Linear(obs_dim, hidden), nn.Tanh(), nn.Linear(hidden, hidden), nn.Tanh(), ) self.head nn.Linear(hidden, 1) # 输出层用小初始化让初始 V(s) 接近 0 nn.init.orthogonal_(self.head.weight, gain1.0) nn.init.zeros_(self.head.bias) def forward(self, obs: torch.Tensor): return self.head(self.body(obs)).squeeze(-1)这里的squeeze(-1)很重要不写的话价值张量是[batch, 1]后面和回报张量相减会发生广播形状悄悄变成[batch, batch]损失看起来在下降但学的是完全错误的东西。这类形状 bug 不会报错只会让训练莫名其妙地不收敛排查成本极高。4.4 共享主干还是分离主干经典实现里常见两种写法一种让 Actor 和 Critic 共享前面的特征提取层只分开输出头另一种完全独立。共享主干的优势是参数少、特征复用、在小任务上收敛快缺点是梯度会互相干扰如果两个损失量级差异大容易一方主导把另一方的特征压掉。完全分离则没有干扰但参数量翻倍。我的建议是CartPole、Acrobot 这种几十维状态的任务可以共享图像输入或高维连续控制一律分离。判断标准很简单如果训练曲线出现策略熵快速塌缩但回报不涨很可能是共享主干被价值损失带偏了改成分离往往立刻好转。5. 优势函数与 GAE 实现优势函数的估计质量直接决定 Actor-Critic 的表现上限。这一节从最简单的单步 TD 误差一路推到 GAE每一步都给出可运行的代码。5.1 TD 误差与 n-step 回报最基础的优势估计就是 TD 误差A r γV(s) - V(s)。它的偏差来自 Critic 本身不准但因为只用一步真实奖励方差极小。另一个极端是蒙特卡洛A G_t - V(s)无偏但方差大。中间的 n-step 回报用 n 步真实奖励加上第 n 步的价值估计def compute_nstep_returns(rewards, values, dones, gamma0.99, n5): rewards/values/dones 均为长度 T 的列表values 多一个 bootstrap 项 T len(rewards) returns [] for t in range(T): R 0.0 for k in range(n): if t k T: break R (gamma ** k) * rewards[t k] if dones[t k]: break else: # 未终止则加上第 n 步的价值估计作为 bootstrap idx min(t n, T) R (gamma ** n) * values[idx] returns.append(R) return returns这段代码里有个细节值得注意for...else结构只有在循环正常走完没有被break时才执行 bootstrap。如果轨迹中途终止就不该再加 bootstrap 项否则会把下一回合的价值错误地算进来。这类终止处理错误在外行看来只是曲线抖一点实际上是系统性偏差。5.2 GAE 的递推实现GAE 的做法是把所有 n-step 优势做指数加权平均用一个参数 λ 控制偏差方差权衡。λ0 退化成单步 TDλ1 退化成蒙特卡洛。工程上最常用的是 0.95 左右。递推公式是A_t δ_t γλ(1-done_t)A_{t1}其中 δ_t 就是单步 TD 误差。从后往前算一遍即可复杂度 O(T)。def compute_gae(rewards, values, dones, gamma0.99, lam0.95): rewards: [T] values: [T1]最后一项是 bootstrap 价值 dones: [T]float终止为 1 返回 advantages [T] 和 returns [T] T len(rewards) advantages [0.0] * T last_adv 0.0 for t in reversed(range(T)): if t T - 1: next_value values[T] else: next_value values[t 1] delta rewards[t] gamma * next_value * (1 - dones[t]) - values[t] last_adv delta gamma * lam * (1 - dones[t]) * last_adv advantages[t] last_adv returns [advantages[t] values[t] for t in range(T)] return advantages, returns两个输出都要用advantages拿去算策略损失returns拿去算价值损失。有些人图省事直接用 advantages 当价值目标这是错的因为价值网络学的是 V(s) 而不是优势两者差了一个基线。5.3 优势归一化拿到优势后必须做一步归一化这是 A2C/PPO 里最廉价也最有效的稳定手段import torch def normalize_advantages(adv: torch.Tensor, eps: float 1e-8): return (adv - adv.mean()) / (adv.std() eps)归一化的作用是把梯度步长控制在合理范围。不做这一步时如果某批次优势全是正数且数值很大一次更新就能把策略推得很远直接破坏探索归一化之后优势的均值是 0、方差是 1更新量级与回报绝对大小解耦换环境时不用重新调学习率。这个技巧在 reward 尺度差异大的任务上几乎是必须的。6. 完整 A2C 训练实现把前面的模块拼起来就是一段可以直接跑的 A2C。选择 A2C 而不是 PPO 作为第一个完整实现是因为它没有重要性采样比率和 clip逻辑干净出错时定位容易。import gymnasium as gym import numpy as np import torch import torch.nn as nn import torch.optim as optim from torch.distributions import Categorical class ActorCritic(nn.Module): def __init__(self, obs_dim, action_dim, hidden128): super().__init__() self.shared nn.Sequential( nn.Linear(obs_dim, hidden), nn.Tanh(), nn.Linear(hidden, hidden), nn.Tanh(), ) self.actor nn.Linear(hidden, action_dim) self.critic nn.Linear(hidden, 1) nn.init.orthogonal_(self.actor.weight, gain0.01) nn.init.zeros_(self.actor.bias) nn.init.orthogonal_(self.critic.weight, gain1.0) nn.init.zeros_(self.critic.bias) def forward(self, x): h self.shared(x) return self.actor(h), self.critic(h).squeeze(-1) def act(self, obs): logits, value self.forward(obs) dist Categorical(logitslogits) action dist.sample() return action, dist.log_prob(action), value, dist.entropy() def collect_rollout(env, model, rollout_steps20): obs_list, act_list, logp_list [], [], [] rew_list, done_list, val_list [], [], [] obs, _ env.reset() ep_return, ep_returns 0.0, [] for _ in range(rollout_steps): obs_t torch.as_tensor(obs, dtypetorch.float32) with torch.no_grad(): action, logp, value, _ model.act(obs_t) next_obs, reward, terminated, truncated, _ env.step(action.item()) done terminated or truncated obs_list.append(obs_t) act_list.append(action) logp_list.append(logp) rew_list.append(reward) done_list.append(float(done)) val_list.append(value.item()) ep_return reward obs next_obs if done: ep_returns.append(ep_return) ep_return 0.0 obs, _ env.reset() with torch.no_grad(): _, last_value model(torch.as_tensor(obs, dtypetorch.float32)) return (obs_list, act_list, logp_list, rew_list, done_list, val_list, last_value.item(), ep_returns) def train(): env gym.make(CartPole-v1) obs_dim env.observation_space.shape[0] action_dim env.action_space.n model ActorCritic(obs_dim, action_dim) optimizer optim.Adam(model.parameters(), lr3e-3) gamma, lam 0.99, 0.95 value_coef, entropy_coef 0.5, 0.01 returns_history [] for update in range(200): (obs_list, act_list, logp_list, rew_list, done_list, val_list, last_val, ep_returns) collect_rollout(env, model) returns_history.extend(ep_returns) values val_list [last_val] advs, rets [], [] last_adv 0.0 for t in reversed(range(len(rew_list))): delta rew_list[t] gamma * values[t 1] * (1 - done_list[t]) - values[t] last_adv delta gamma * lam * (1 - done_list[t]) * last_adv advs.append(last_adv) advs.reverse() rets [advs[i] values[i] for i in range(len(advs))] adv_t torch.tensor(advs, dtypetorch.float32) adv_t (adv_t - adv_t.mean()) / (adv_t.std() 1e-8) ret_t torch.tensor(rets, dtypetorch.float32) logp_t torch.stack(logp_list) obs_t torch.stack(obs_list) logits, values_pred model(obs_t) dist Categorical(logitslogits) entropy dist.entropy().mean() policy_loss -(logp_t * adv_t.detach()).mean() value_loss nn.functional.mse_loss(values_pred, ret_t) loss policy_loss value_coef * value_loss - entropy_coef * entropy optimizer.zero_grad() loss.backward() nn.utils.clip_grad_norm_(model.parameters(), 0.5) optimizer.step() if (update 1) % 10 0 and returns_history: avg np.mean(returns_history[-20:]) print(fupdate {update1:03d} | avg return {avg:.1f} | fentropy {entropy.item():.3f} | value loss {value_loss.item():.3f}) env.close() if __name__ __main__: train()这段代码里有几个设计选择值得单独说。策略损失用采样时记录的logp_list而不是重新前向计算这是同策略算法的标准做法但要记得 detach 优势否则梯度会流进价值网络导致双重反传。价值损失用 MSE如果奖励尺度很大可以换成 Huber避免离群回报把 Critic 拉飞。熵奖励的符号是减去因为我们希望最大化熵也就是让损失里包含负的熵项。梯度裁剪的 0.5 是个经验值。A2C 在 CartPole 上即使不裁剪一般也能跑但在连续控制任务里一次异常大的优势就能让策略参数跳变裁剪是最省事的保险。裁剪阈值不要设得太小0.1 以下会让学习明显变慢因为大部分正常梯度也会被削。在普通笔记本 CPU 上这段代码大概 100 到 200 次 update 就能让平均回报爬到 400 以上200 次全满。如果跑到 500 次还在 20 左右徘徊基本可以判定是环境 API 或优势计算出了问题直接跳到第 9 章排查。7. 算法族对比与选型Actor-Critic 这个模板衍生出的算法很多实际项目里选型的关键不是哪个最新而是样本从哪来、动作空间是什么、允许的调参时间有多少。下面这张表把常用的几个放在一起对比。算法策略类型采样方式动作空间经验回放主要特点典型用途A2C随机策略同策略同步多环境离散/连续否结构简单易于调试教学、基线对比A3C随机策略同策略异步多线程离散/连续否异步更新现已较少使用历史参考PPO随机策略同策略多轮复用离散/连续否clip 约束稳定工业界主力大模型 RLHF、游戏 AIDDPG确定性策略异策略仅连续是确定性策略加目标网络连续控制基线TD3确定性策略异策略仅连续是双 Critic、延迟更新、目标平滑连续控制常用SAC随机策略异策略仅连续是最大熵框架探索充分机器人、样本昂贵场景选型的实操判断可以这样走如果你能拿到大量并行仿真环境同策略的 PPO 是最省心的选择它的调参空间相对宽容社区实现也最多如果真实环境采样代价高、一步要花几秒那就得走异策略路线用经验回放提高样本利用率连续动作优先考虑 SAC 或 TD3如果动作是离散的且环境便宜A2C 足够作为第一版基线。从 A2C 迁移到 PPO 的改动量其实很小核心是把采一批数据更新一次改成采一批数据分多个 epoch 更新同时加上重要性采样比率和 clip。下面这段是 PPO 的策略损失核心可以对照第 6 章的 A2C 看差异def ppo_policy_loss(new_logp, old_logp, advantages, clip_ratio0.2): ratio torch.exp(new_logp - old_logp) surr1 ratio * advantages surr2 torch.clamp(ratio, 1.0 - clip_ratio, 1.0 clip_ratio) * advantages return -torch.min(surr1, surr2).mean()这里old_logp必须是采样时记录的、并且在多个 epoch 内保持不变。很多人把它每个 epoch 重新算一遍clip 就失效了算法退化成没有约束的策略梯度训练曲线会突然崩掉。这个坑在复现 PPO 时出现频率极高。8. 训练监控、资源占用与常见问题排查Actor-Critic 的调试核心是分而治之先把 Critic 单独看好再看 Actor。因为 Actor 的更新方向完全依赖 Critic 给出的优势如果价值估计本身在乱飘后面所有调参都是在错误的地基上做微调。8.1 必须监控的四个指标第一是平均回合回报看趋势不看单点用最近 20 到 100 回合的滑动平均。第二是策略熵它反映探索程度CartPole 上健康的曲线是从 0.69 左右缓慢下降到 0.3 附近如果十几轮就掉到 0.05说明策略过早确定通常需要加大熵系数或降低学习率。第三是价值损失它应该整体下降但允许波动如果持续上升说明价值目标尺度有问题。第四是梯度范数裁剪前的梯度范数突然放大到几十上百往往是某批次优势异常或出现了数值问题。把这几项写进日志接 TensorBoard 是最省事的做法from torch.utils.tensorboard import SummaryWriter writer SummaryWriter(runs/a2c_cartpole) # 在每个 update 结束时记录 writer.add_scalar(train/avg_return, avg_return, update) writer.add_scalar(train/entropy, entropy.item(), update) writer.add_scalar(train/value_loss, value_loss.item(), update) writer.add_scalar(train/grad_norm, grad_norm, update) writer.close()8.2 资源占用怎么观察低维状态任务上瓶颈几乎全在环境交互而不在网络计算。一个 128×128 的 MLP 单次前向在 CPU 上是微秒级而env.step往往要几十微秒到毫秒级。所以在 CartPole 上把模型搬到 GPU 反而更慢因为每次都要做一次主机到设备的拷贝。判断标准很直接用time.perf_counter()把采样耗时和更新耗时分别打出来如果采样占 80% 以上优化方向就是并行环境而不是换硬件。import time t0 time.perf_counter() rollout collect_rollout(env, model) t1 time.perf_counter() # ... 更新逻辑 ... t2 time.perf_counter() print(fsample: {t1-t0:.3f}s | update: {t2-t1:.3f}s)当任务换成图像输入情况就反过来了。Atari 的 84×84×4 帧堆叠输入特征提取网络的前向和反向会成为主要开销这时候 GPU 才有意义同时显存占用主要取决于批量大小和网络宽度。观测这一点最方便的方式是torch.cuda.max_memory_allocated()在训练循环里定期打印比看任务管理器准确得多。并行环境是另一个资源维度。A2C 原本就设计成多环境同步采样用gymnasium.vector.SyncVectorEnv起 8 个环境采样吞吐能提升数倍但 CPU 占用也会同步上升。这里要注意并行环境的数量不是越多越好超过物理核心数之后上下文切换开销会吃掉收益一般取物理核心数的一半到全部之间。8.3 常见问题与排查表问题现象可能原因排查方式解决方案回报长期停在随机水平优势符号写反或未 detach打印单批次优势均值检查是否恒正或恒负修正优势计算确保adv.detach()策略熵几轮内塌到接近 0学习率过大或熵系数过小观察 entropy 曲线下降速度降 lr 到 1e-3 以下熵系数提到 0.01 以上价值损失不降反升回报尺度与初始化不匹配打印values_pred.mean()与ret_t.mean()输出层小初始化或对回报做缩放张量形状报错或结果异常Critic 输出未 squeeze检查values_pred.shape加.squeeze(-1)训练前期正常后期突然崩溃梯度爆炸记录裁剪前梯度范数开启梯度裁剪阈值 0.5 左右换环境后完全不学观测未归一化检查 obs 数值范围加 running mean/std 归一化多环境并行后变慢环境数超过物理核数对比不同 env 数的采样耗时降到物理核心数以内PPO 复现后曲线不如 A2Cold_logp 每个 epoch 重算检查 old_logp 是否在 epoch 内固定采样时记录一次全程复用连续动作全落在边界log_std 无约束打印 std 数值clamp log_std 到 [-20, 2]这张表里最值得展开的是观测未归一化这一条。CartPole 的观测数值都在个位数不归一化也能跑所以很多人养成了不归一化的习惯。一旦换到 Pendulum 或者 MuJoCo观测里同时存在 0.01 量级和 10 量级的维度网络会几乎只对大量级维度敏感小量级维度相当于被屏蔽表现为怎么调都不学。加一层 running 均值方差归一化通常一次就解决。9. 最佳实践与下一步Actor-Critic 这一套东西写对一次之后复用价值很高。下面是几条从实际调试里总结出来的、能明显减少踩坑时间的做法。第一条永远先在 CartPole 上跑通再换环境。这不是偷懒而是建立一条已知正确的基线。当你在 MuJoCo 上调了两天没结果时把同一份代码切回 CartPole 跑 50 轮如果也不学问题在算法实现如果正常问题在超参或环境适配。这个二分能把排查范围瞬间砍掉一半。第二条把 Critic 的学习率和 Actor 分开设置。共享一个优化器看似简洁但两者对步长的敏感度不同Critic 通常需要更快地跟上回报变化Actor 则需要更保守。实践里 Critic 学习率取 Actor 的 2 到 3 倍是常见起点尤其在价值损失下降明显滞后于策略改进的时候。第三条保留一套最小可运行配置。把能跑通的那组超参写进configs/目录并加注释说明适用环境之后每次实验都从它 fork 出来而不是在当前脚本上反复改数字。Actor-Critic 调参很容易陷入改了 A 又影响 B的循环有基线配置才能判断某次改动是真的有效还是随机波动。第四条批量任务和长训练要加断点保存。训练脚本里定期保存模型参数、优化器状态和当前 update 序号配合日志落盘。强化学习的训练曲线经常在某个阶段突然变好或变差如果没保存中间状态想回到那个点重新实验就只能从头再来这在需要跑几小时的任务上代价很大。def save_checkpoint(path, model, optimizer, update, best_return): torch.save({ model: model.state_dict(), optimizer: optimizer.state_dict(), update: update, best_return: best_return, }, path) def load_checkpoint(path, model, optimizer): ckpt torch.load(path, map_locationcpu) model.load_state_dict(ckpt[model]) optimizer.load_state_dict(ckpt[optimizer]) return ckpt[update], ckpt[best_return]第五条评估阶段一定要关掉探索。训练时用随机策略采样是为了探索评估时应该用分布的均值或 argmax并且固定随机种子跑多个回合取平均。用带噪声的策略去评估得到的回报会明显偏低且波动很大容易误判模型没学好。关于下一步的扩展方向比较自然的路径有两条。一条是往 PPO 走把第 6 章的采样和更新拆成两段加上重要性采样比率和 clip这套代码几乎可以直接用在语言模型的 RLHF 微调流程里那边的 reward model 扮演的就是 Critic 的角色只是评价对象从状态变成了整段回复。另一条是往异策略走引入经验回放缓冲区、目标网络和双 Critic去处理真实采样代价高的连续控制问题。如果要在这两条路上做选择判断依据就一句话你的环境能不能便宜地并行采样。能就走同策略不能就走异策略。这个判断比看任何算法排行榜都可靠。Actor-Critic 的骨架在这两条路上是共通的先把它吃透后面换算法只是替换几个模块的事。
返回列表
PREV
查看更多资讯
NEXT
返回资讯列表