☰
Actor-Critic实战:优势函数、GAE与A2C训练循环拆解
2026/10/27 2:15:15 网站建设 项目流程

强化学习学到 Actor-Critic 这一节,基本算是从"会跑 demo"跨到"能理解现代 RL 工程"的分水岭。前面讲策略梯度时,REINFORCE 用一整条轨迹的回报去估计梯度,方差大到需要几十次采样才能勉强收敛;讲到值函数近似时,又发现只学 Q 值没法直接输出策略。Actor-Critic 把这两条线拧在一起:一个网络负责决定动作,一个网络负责评价动作好不好,用评价网络的输出给决策网络的梯度降方差。A2C、A3C、PPO、DDPG、TD3、SAC 这一整串现在还在被广泛使用的算法,骨架都是它。

这篇文章是"2026 新版强化学习从入门到精通"系列的第 42 篇,重点不再停留在概念解释上,而是把 Actor-Critic 的网络结构、优势函数、GAE、损失函数组合、训练循环这些真正影响能不能跑通的部分拆开讲。文中的每段代码都可以直接复制运行,环境用 PyTorch + Gymnasium,CartPole 这类任务在纯 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 python=3.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(seed=42) 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, max=2) std = log_std.exp() return mean, std

log_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, gain=1.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, gamma=0.99, n=5): """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_{t+1},其中 δ_t 就是单步 TD 误差。从后往前算一遍即可,复杂度 O(T)。

def compute_gae(rewards, values, dones, gamma=0.99, lam=0.95): """ rewards: [T] values: [T+1],最后一项是 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, hidden=128): 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, gain=0.01) nn.init.zeros_(self.actor.bias) nn.init.orthogonal_(self.critic.weight, gain=1.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(logits=logits) action = dist.sample() return action, dist.log_prob(action), value, dist.entropy() def collect_rollout(env, model, rollout_steps=20): 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, dtype=torch.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, dtype=torch.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(), lr=3e-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, dtype=torch.float32) adv_t = (adv_t - adv_t.mean()) / (adv_t.std() + 1e-8) ret_t = torch.tensor(rets, dtype=torch.float32) logp_t = torch.stack(logp_list) obs_t = torch.stack(obs_list) logits, values_pred = model(obs_t) dist = Categorical(logits=logits) 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(f"update {update+1:03d} | avg return {avg:.1f} | " f"entropy {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、游戏 AI
DDPG确定性策略异策略仅连续是确定性策略加目标网络连续控制基线
TD3确定性策略异策略仅连续是双 Critic、延迟更新、目标平滑连续控制常用
SAC随机策略异策略仅连续是最大熵框架,探索充分机器人、样本昂贵场景

选型的实操判断可以这样走:如果你能拿到大量并行仿真环境,同策略的 PPO 是最省心的选择,它的调参空间相对宽容,社区实现也最多;如果真实环境采样代价高、一步要花几秒,那就得走异策略路线,用经验回放提高样本利用率,连续动作优先考虑 SAC 或 TD3;如果动作是离散的且环境便宜,A2C 足够作为第一版基线。

从 A2C 迁移到 PPO 的改动量其实很小,核心是把"采一批数据更新一次"改成"采一批数据分多个 epoch 更新",同时加上重要性采样比率和 clip。下面这段是 PPO 的策略损失核心,可以对照第 6 章的 A2C 看差异:

def ppo_policy_loss(new_logp, old_logp, advantages, clip_ratio=0.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(f"sample: {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_location="cpu") 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 的骨架在这两条路上是共通的,先把它吃透,后面换算法只是替换几个模块的事。

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

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

立即咨询