很多人学强化学习的路径都差不多:先用 Q-learning 在 FrozenLake 上跑通一个能看的结果,再手写一遍 REINFORCE 让 CartPole 立起来,然后就卡住了。卡住的原因往往不是公式看不懂,而是发现这两条路都走不远——Q-learning 一遇到连续动作空间就无从下手,REINFORCE 的梯度方差大到训练曲线像心电图,调一天参数不如换一个随机种子。
Actor Critic 就是这条分界线上的东西。它把"决定做什么"和"评价做得好不好"拆成两个网络,前者负责输出策略,后者负责估计价值,用价值的估计去压低策略梯度的方差。今天几乎所有能打的主流算法——A2C、A3C、GAE、PPO、DDPG、TD3、SAC——都是这个框架的变体。你把 Actor Critic 吃透,后面那一长串名字就只是同一套骨架上的不同配件。
这篇文章不打算把教材里的推导再抄一遍,而是想解决三个很实际的问题:第一,为什么 Actor Critic 能降方差,这个"降"具体发生在哪一步;第二,一个能跑的网络到底该怎么搭,共享主干还是分离网络,输出层怎么设计;第三,代码跑起来之后 loss 在动但 reward 不涨,到底是哪里错了。
读完之后,你应该能独立写出一个在 CartPole 上稳定收敛的 Actor Critic 实现,能说清楚 GAE 里那个done掩码为什么必须区分terminated和truncated,也能判断一个 Actor Critic 训练失败时该先看哪几个指标。
1. 为什么 Actor Critic 是强化学习的分水岭
要理解 Actor Critic 存在的意义,得先看清楚它前面的两条路各自死在哪。
基于价值的方法(Q-learning、DQN 一族)学的是 $Q(s,a)$,也就是"在状态 s 下做动作 a,之后能拿多少回报"。策略是隐式的:算出所有动作的 Q 值,挑最大的那个。这条路在离散、动作数量不多的场景下非常稳,DQN 在 Atari 上的表现就是证明。问题在于 $\arg\max_a Q(s,a)$ 这一步——动作是连续的(比如机械臂的 7 个关节力矩),你没法穷举,也没法简单取 max。有人用离散化硬扛,但维度一高就组合爆炸,而且离散化本身丢掉了动作空间的连续性,学出来的策略会有抖动。
基于策略的方法(REINFORCE 及其变体)直接参数化策略 $\pi_\theta(a|s)$,用采样得到的回报 $G_t$ 去估计梯度。这条路天然支持连续动作,也能学出随机策略。代价是方差。同一局游戏,同样的状态,采样到的回报可能差好几倍,梯度方向就跟着乱跳。想降方差就得采更多样本,样本效率又掉下去。
Actor Critic 的思路很朴素:既然 $G_t$ 这个蒙特卡洛估计噪声大,那就找一个偏差小一点、方差小一点的替代量。这个替代量就是 Critic 学出来的价值函数。Critic 用自举(bootstrap)的方式估计价值,虽然引入了偏差,但方差小得多,两者一权衡,训练稳定性和样本效率都上来了。
这里真正容易踩坑的地方是:很多人以为 Actor Critic 只是"两个网络拼一起",于是随便搭两个 MLP 就开始训。实际上 Actor 和 Critic 的学习目标、输出分布、损失量级完全不同,它们的耦合方式决定了训练能不能收敛。共享主干能省参数、加速特征学习,但也会让两个任务的梯度互相干扰;完全分离更稳,但样本利用率低一些。这个取舍在第 5 节会详细展开。
从工程角度看,Actor Critic 最值得关注的变化发生在训练循环的结构上。纯策略梯度只需要"采样—算回报—更新"三步,而 Actor Critic 多了一层:每一步都要 Critic 给出价值估计,这个估计既用于计算优势,也作为下一轮更新的目标。这意味着数据流、张量形状、设备一致性上的错误会被放大,一个unsqueeze用错就可能让 loss 看起来正常但策略完全不学。这也是后面代码部分要格外仔细的原因。
2. 核心概念:Actor、Critic 与优势函数
2.1 Actor 到底在学什么
Actor 就是策略网络,输入状态,输出动作分布。离散动作空间下它输出每个动作的 logits,过一个 softmax 得到概率;连续动作空间下它输出高斯分布的均值 $\mu$ 和标准差 $\sigma$(实践中通常让网络输出 $\log\sigma$ 再取指数,保证标准差恒正)。
需要强调的是,Actor 学的不是一个确定性的映射,而是一个分布。训练时从这个分布里采样动作,这是探索的来源;推理时可以选择采样,也可以直接取概率最大的动作(确定性策略)。这个区别很关键——如果你在训练时也用 argmax,策略很快就会塌缩到单一动作上,再也探索不到别的可能。
Actor 的损失函数长这样:$\mathcal{L}{actor} = -\log \pi\theta(a_t|s_t) \cdot \hat{A}_t$。其中 $\hat{A}_t$ 是优势估计,它扮演的是"这一步比平均水平好多少"的角色。优势为正,就提高这个动作的概率;优势为负,就压低。注意这里没有用回报的绝对值,用的是相对量,这本身就是降方差的一部分。
2.2 Critic 到底在学什么
Critic 是价值网络,输入状态,输出一个标量 $V(s)$,表示"从状态 s 出发,按当前策略走下去,预期能拿到的折扣回报"。它的训练目标是最小化 TD 误差:
$$\mathcal{L}{critic} = \left( r_t + \gamma V\phi(s_{t+1}) - V_\phi(s_t) \right)^2$$
这个形式叫半梯度 TD(0),注意目标里的 $V_\phi(s_{t+1})$ 通常要 detach,不让梯度回传到目标网络,否则目标自己在动,训练会不稳定。这是新手最常见的错误之一。
Critic 的存在意义不是"预测未来"这么抽象,它在训练中承担的是一个基线的功能。原始策略梯度用 $G_t$ 乘 $\nabla \log \pi$,如果所有 $G_t$ 都是正数(比如游戏里得分总是正的),那所有动作的概率都会被推高,只是推高的幅度不同。减掉一个基线 $b(s)$ 之后,正负就分开了,梯度方向更纯粹。理论上任何只依赖状态的基线都不改变梯度的期望,但方差会显著变化。$V(s)$ 就是方差最小的那个基线选择之一。
2.3 优势函数为什么好用
优势函数定义为:
$$A(s,a) = Q(s,a) - V(s)$$
直观理解:在状态 s 下,做动作 a 比"按平均策略随便做"好多少。Actor 需要的正是这个相对信号。
问题是 $Q$ 和 $V$ 都要估计,两个网络误差叠加,方差又回来了。实际做法是用 TD 误差近似优势:
$$\hat{A}t = r_t + \gamma V(s{t+1}) - V(s_t)$$
这个量只需要一个 $V$ 网络,而且它就是 Critic 的 TD 误差,训练 Critic 的时候顺手就算出来了。这就是"用 TD 误差当优势"的经典做法,也是单步 Actor Critic(A2C)的核心。
单步估计的偏差比较大,因为它只往前看一步。蒙特卡洛估计(看完整条轨迹)偏差小但方差大。GAE(Generalized Advantage Estimation)用一个参数 $\lambda$ 在这两者之间插值,$\lambda=0$ 退化成单步 TD,$\lambda=1$ 退化成蒙特卡洛。实践里 $\lambda$ 取 0.9 到 0.99 之间比较常见,具体要看任务的时间跨度。
2.4 三种方法族的对比
| 维度 | 基于价值(DQN 等) | 基于策略(REINFORCE 等) | Actor Critic |
|---|---|---|---|
| 学什么 | $Q(s,a)$,策略隐式导出 | 策略 $\pi_\theta$ 直接参数化 | 策略 + 价值同时学 |
| 连续动作 | 需要离散化,难扩展 | 原生支持 | 原生支持 |
| 随机策略 | 不支持(通常确定性) | 支持 | 支持 |
| 梯度方差 | 不涉及策略梯度 | 高 | 中等,可用 GAE 调节 |
| 样本效率 | 中等,依赖经验回放 | 低,同策略 | 中等,可并行采样 |
| 典型算法 | DQN、Double DQN、Rainbow | REINFORCE、VPG | A2C、PPO、SAC、TD3 |
| 主要调参难点 | 探索、过估计 | 方差、基线设计 | Actor/Critic 平衡、优势估计 |
这张表想说明的判断是:方法族的选择主要取决于动作空间形态和对样本效率的要求,而不是哪个"更先进"。如果你的任务动作是离散的、状态维度不高、样本采集便宜,DQN 系列依然是很务实的选择。Actor Critic 的优势在连续控制、随机策略需求、以及需要并行采样的大规模场景。
3. 从 REINFORCE 到 Actor Critic 的降方差改造
3.1 原始策略梯度的方差来自哪里
REINFORCE 的梯度估计是 $\nabla_\theta J \approx \frac{1}{N}\sum_{i=1}^{N} G_t^{(i)} \nabla_\theta \log \pi_\theta(a_t^{(i)}|s_t^{(i)})$。这里的 $G_t$ 是一条轨迹上的实际折扣回报。
方差有两个来源。一是轨迹本身的随机性:环境的转移概率、策略的采样都在引入噪声,同一起点跑十次,回报可能从 200 变到 500。二是回报的绝对量级:如果所有回报都是正的大数,那梯度的均值很大、方差也很大,归一化之后信噪比很低。
减基线解决的是第二个来源。用 $G_t - b(s_t)$ 替代 $G_t$,期望不变,但方差下降了。$b(s_t) = V(s_t)$ 是一个好的选择,因为它在某种意义上最接近 $G_t$ 的条件期望。
3.2 用 Critic 替代蒙特卡洛回报
减了基线之后,梯度里还剩下 $G_t - V(s_t)$。这个 $G_t$ 依然是完整采样出来的,方差还很大。Actor Critic 的进一步改造是:把 $G_t$ 也换掉,用 $r_t + \gamma V(s_{t+1})$ 这个单步估计代替。
于是优势变成 $\hat{A}t = r_t + \gamma V(s{t+1}) - V(s_t)$。这一步带来了偏差(因为 $V$ 是估计出来的,不准),但方差大幅下降。整个训练从"等一局结束再更新"变成了"每一步都能更新",这是 Actor Critic 能在线学习的根本原因。
这里有个细节值得说清楚:bootstrapping 的边界条件。当 $s_{t+1}$ 是终止状态时,$V(s_{t+1})$ 应该是 0,所以优势退化为 $\hat{A}_t = r_t - V(s_t)$。代码里必须用一个掩码把终止步的 bootstrap 项乘掉。忘掉这个掩码,训练前期可能看不出问题,后期策略会系统性地高估终止前的状态价值,表现为回合末行为异常。
3.3 GAE:在偏差和方差之间调一个旋钮
GAE 的定义是把多步 TD 误差做指数加权:
$$\hat{A}^{GAE(\gamma,\lambda)}t = \sum{l=0}^{\infty} (\gamma\lambda)^l \delta_{t+l}, \quad \delta_t = r_t + \gamma V(s_{t+1}) - V(s_t)$$
实现上不需要真的累加无穷项,用一个反向循环就够了:
# 文件路径:common/gae.py import torch def compute_gae(rewards, values, next_values, terminateds, gamma=0.99, lam=0.95): """ rewards: (T,) 每步奖励 values: (T,) V(s_t) next_values: (T,) V(s_{t+1}),最后一步用 rollout 结束时的 obs 算 terminateds: (T,) 1.0 表示真正终止,0.0 表示还在进行中 返回 adv (T,) 和 returns (T,) """ T = rewards.shape[0] adv = torch.zeros(T, dtype=torch.float32, device=rewards.device) last_gae = torch.zeros((), dtype=torch.float32, device=rewards.device) for t in reversed(range(T)): not_done = 1.0 - terminateds[t] delta = rewards[t] + gamma * next_values[t] * not_done - values[t] last_gae = delta + gamma * lam * not_done * last_gae adv[t] = last_gae returns = adv + values return adv, returns这段代码里not_done出现两次,位置都不能省。第一次乘在 bootstrap 项上,处理的是"这一步之后是不是真的结束了";第二次乘在 GAE 的递推项上,处理的是"跨边界的优势要不要继续累加"。如果只写一次,跨回合的轨迹会把上一局的信息带进下一局,训练曲线会出现周期性的抖动。
terminateds这里只放terminated,不放truncated。原因是truncated表示的是时间限制到了,环境本身并没有进入终止状态,此时 $V(s_{t+1})$ 依然有意义,应该继续 bootstrap。这一点在很多教程里被简化掉了,但在 CartPole 这种有 500 步上限的环境里,训练到后期几乎每个回合都是 truncated 结束,处理错了会明显拖慢收敛。
4. 环境准备与项目结构
4.1 依赖与版本说明
这个系列不绑定具体版本号,因为 PyTorch 和 Gymnasium 的 API 在小版本间会有调整,写死版本反而容易让读者踩坑。下面给出的是能力要求,具体版本以你本地实际安装为准,如果 API 对不上,优先查官方迁移说明。
- Python 3.9 及以上(需要支持类型注解和 dataclass)
- PyTorch 2.x(用到
torch.no_grad、torch.distributions) - Gymnasium(注意不是老版的
gym,reset()和step()的返回值已经变了) - NumPy
- TensorBoard(可选,用来画训练曲线)
# 文件路径:requirements.txt torch>=2.0 gymnasium>=0.28 numpy>=1.24 tensorboard>=2.13 pyyaml>=6.0安装:
python -m venv venv source venv/bin/activate # Windows 下用 venv\Scripts\activate pip install -r requirements.txt这里要提醒一个高频问题:Gymnasium 的step()返回五元组(obs, reward, terminated, truncated, info),而老版 gym 返回四元组。网上大量 Actor Critic 代码还是四元组写法,直接复制过来会报too many values to unpack。如果你看到某份代码里写next_state, reward, done, info = env.step(a),那基本可以判断它是旧版 API,需要改。
4.2 项目结构
rl-actor-critic/ ├── configs/ │ └── ppo_cartpole.yaml ├── models/ │ └── actor_critic.py ├── common/ │ ├── gae.py │ └── logger.py ├── train_ppo.py └── requirements.txt目录本身不复杂,但把网络定义、优势计算、训练入口分开,后期换成连续动作或者换算法时改动面会小很多。所有配置走 YAML,避免超参数散落在代码各处。
5. Actor Critic 网络架构设计与代码实现
5.1 共享主干还是分离网络
这是架构设计里第一个要做的决定。
共享主干的做法是:先用几层 MLP 把观测编码成特征,然后分叉出两个头,一个输出动作分布参数,一个输出价值。好处是特征提取只做一次,参数少、前向快,而且两个任务共享表示,在观测维度高(比如图像输入)时效果明显。
分离网络则是 Actor 和 Critic 各有一套完整的网络。好处是梯度互不干扰,Actor 的更新不会破坏 Critic 学到的特征表示,反之亦然。代价是参数翻倍。
实际项目中更推荐的做法是:观测是低维向量时用共享主干,观测是高维图像或者两个任务的特征需求差异很大时用分离网络。另外共享主干时有个技巧——给 Actor 头的输出层用很小的初始化增益(比如 0.01),这样初始策略接近均匀分布,探索充分;Critic 头保持正常增益。这个细节在 PPO 的实现里几乎是标配,能明显改善训练初期的稳定性。
5.2 离散与连续动作的输出层差异
离散动作空间下,Actor 头输出维度等于动作数的 logits,用Categorical分布采样。连续动作空间下,Actor 头输出均值 $\mu$,另外维护一个独立的可学习参数log_std(注意是与状态无关的全局参数,不是网络输出,这是 SAC 之前的常见做法;SAC 才把它做成状态相关的)。
# 文件路径:models/actor_critic.py import torch import torch.nn as nn from torch.distributions import Categorical, Normal def orthogonal_init(layer, gain=1.0): if isinstance(layer, nn.Linear): nn.init.orthogonal_(layer.weight, gain=gain) nn.init.constant_(layer.bias, 0.0) class ActorCriticDiscrete(nn.Module): """离散动作空间的 Actor Critic,共享主干 + 双头输出。""" def __init__(self, obs_dim, act_dim, hidden=64): super().__init__() self.trunk = nn.Sequential( nn.Linear(obs_dim, hidden), nn.Tanh(), nn.Linear(hidden, hidden), nn.Tanh(), ) self.actor_head = nn.Linear(hidden, act_dim) self.critic_head = nn.Linear(hidden, 1) for m in self.trunk: orthogonal_init(m, gain=1.0) # Actor 头小增益初始化,让初始策略接近均匀分布 orthogonal_init(self.actor_head, gain=0.01) orthogonal_init(self.critic_head, gain=1.0) def forward(self, obs): h = self.trunk(obs) logits = self.actor_head(h) value = self.critic_head(h).squeeze(-1) return logits, value @torch.no_grad() def act(self, obs, deterministic=False): logits, value = self.forward(obs) dist = Categorical(logits=logits) if deterministic: action = torch.argmax(logits, dim=-1) else: action = dist.sample() return action, dist.log_prob(action), value def evaluate(self, obs, actions): logits, value = self.forward(obs) dist = Categorical(logits=logits) return dist.log_prob(actions), dist.entropy(), value class ActorCriticContinuous(nn.Module): """连续动作空间的 Actor Critic,输出高斯策略。""" def __init__(self, obs_dim, act_dim, hidden=64, log_std_init=-0.5): super().__init__() self.trunk = nn.Sequential( nn.Linear(obs_dim, hidden), nn.Tanh(), nn.Linear(hidden, hidden), nn.Tanh(), ) self.mu_head = nn.Linear(hidden, act_dim) self.critic_head = nn.Linear(hidden, 1) # 与状态无关的全局 log_std,初始 std ≈ 0.6 self.log_std = nn.Parameter(torch.ones(act_dim) * log_std_init) for m in self.trunk: orthogonal_init(m, gain=1.0) orthogonal_init(self.mu_head, gain=0.01) orthogonal_init(self.critic_head, gain=1.0) def forward(self, obs): h = self.trunk(obs) mu = self.mu_head(h) value = self.critic_head(h).squeeze(-1) return mu, value @torch.no_grad() def act(self, obs, deterministic=False): mu, value = self.forward(obs) std = self.log_std.exp().expand_as(mu) dist = Normal(mu, std) if deterministic: action = mu else: action = dist.sample() return action, dist.log_prob(action).sum(-1), value def evaluate(self, obs, actions): mu, value = self.forward(obs) std = self.log_std.exp().expand_as(mu) dist = Normal(mu, std) log_prob = dist.log_prob(actions).sum(-1) entropy = dist.entropy().sum(-1) return log_prob, entropy, value几个容易忽略的点。
orthogonal_init里的nn.init.constant_(layer.bias, 0.0)不能省。默认的 bias 初始化在某些框架版本里不是零,配合正交初始化会让首层输出偏置,影响早期探索。
act()上面加了@torch.no_grad()。采样阶段不需要梯度,加上之后显存占用和计算量都会降下来。如果忘了加,在文件很大的时候可能直接 OOM。
连续版本里log_prob用了.sum(-1),把各维度的对数概率加起来。因为假设各维度独立,联合对数概率就是各自之和。漏掉这个求和,优势估计和 log_prob 的量级对不上,PPO 的 ratio 会算得离谱。
log_std用nn.Parameter单独维护,而不是从网络输出。这两种做法都常见,独立参数更容易控制初始探索强度,也不容易被主干特征的梯度带偏。实践中要给log_std加一个下界裁剪(比如clamp(-20, 2)),防止它在训练中漂到极端值导致数值下溢。
5.3 配置文件
# 文件路径:configs/ppo_cartpole.yaml env_id: CartPole-v1 seed: 42 total_updates: 300 # 更新轮数 rollout_steps: 2048 # 每轮采样的环境步数 num_envs: 4 # 并行环境数(只用单环境时设为 1) gamma: 0.99 gae_lambda: 0.95 lr_actor: 3.0e-4 lr_critic: 1.0e-3 hidden_dim: 64 ppo_epochs: 10 # 每轮数据复用次数 minibatch_size: 256 clip_eps: 0.2 entropy_coef: 0.01 value_coef: 0.5 max_grad_norm: 0.5 eval_interval: 20 log_dir: runs/ppo_cartpole配置里lr_critic比lr_actor大一个量级,这是一个经验性的做法:Critic 需要更快跟上策略的变化,否则优势估计滞后,Actor 拿到的信号就是错的。当然这不是铁律,如果你的任务 Critic 很难学(比如奖励极度稀疏),把两者拉平甚至让 Critic 慢一点也可以,关键是通过日志观察value_loss的量级。
6. 完整训练流程与可运行代码
6.1 采样阶段的实现
采样函数负责和环境交互,收集一批转移。这里要处理两个问题:并行环境的观测拼接,以及 truncated 的 bootstrap 处理。
# 文件路径:train_ppo.py(采样部分) import numpy as np import torch def collect_rollout(envs, model, obs, rollout_steps, device, gamma): """ envs: gymnasium 的向量化环境 obs: (num_envs, obs_dim) 的 numpy 数组 返回一个 dict,所有张量都是 (rollout_steps, num_envs, ...) 形状 """ num_envs = envs.num_envs obs_dim = envs.single_observation_space.shape[0] obs_buf = torch.zeros(rollout_steps, num_envs, obs_dim, device=device) act_buf = torch.zeros(rollout_steps, num_envs, dtype=torch.long, device=device) logp_buf = torch.zeros(rollout_steps, num_envs, device=device) rew_buf = torch.zeros(rollout_steps, num_envs, device=device) val_buf = torch.zeros(rollout_steps, num_envs, device=device) next_val_buf = torch.zeros(rollout_steps, num_envs, device=device) term_buf = torch.zeros(rollout_steps, num_envs, device=device) episode_returns = np.zeros(num_envs, dtype=np.float32) finished_returns = [] for t in range(rollout_steps): obs_t = torch.as_tensor(obs, dtype=torch.float32, device=device) with torch.no_grad(): action, logp, value = model.act(obs_t) next_obs, reward, terminated, truncated, _ = envs.step(action.cpu().numpy()) # 用真实的 next_obs 计算 bootstrap 价值,terminated 时后面会乘 0 with torch.no_grad(): next_obs_t = torch.as_tensor(next_obs, dtype=torch.float32, device=device) _, next_value = model.forward(next_obs_t) obs_buf[t] = obs_t act_buf[t] = action logp_buf[t] = logp rew_buf[t] = torch.as_tensor(reward, dtype=torch.float32, device=device) val_buf[t] = value next_val_buf[t] = next_value term_buf[t] = torch.as_tensor(terminated.astype(np.float32), device=device) episode_returns += reward for i in range(num_envs): if terminated[i] or truncated[i]: finished_returns.append(float(episode_returns[i])) episode_returns[i] = 0.0 obs = next_obs return { "obs": obs_buf, "actions": act_buf, "logp": logp_buf, "rewards": rew_buf, "values": val_buf, "next_values": next_val_buf, "terminateds": term_buf, "episode_returns": finished_returns, }next_val_buf单独存一份,是为了让terminated和truncated得到不同处理。如果为了省一次前向把next_value直接取自下一时刻的values,那么在回合边界上就会拿到新回合第一个状态的价值,逻辑上是错的。多一次前向的代价远小于训练不收敛的代价。
finished_returns在这里同时收集了terminated和truncated的回合,用于监控训练进度——这两种结束方式对"这一局表现如何"的统计来说是一样的。
6.2 优势计算与张量展平
采样得到的数据是(T, N, ...)形状,计算 GAE 时需要按环境维度分别回传。最省事的做法是把(T, N)摊平成(T*N,),但顺序必须和rewards.reshape(-1)一致,否则优势和状态会错位。
# 文件路径:train_ppo.py(优势计算部分) from common.gae import compute_gae def prepare_batch(rollout, gamma, lam, device): T, N = rollout["rewards"].shape # 摊平成 (T*N,),保持时间优先的顺序 rewards = rollout["rewards"].reshape(-1) values = rollout["values"].reshape(-1) next_values = rollout["next_values"].reshape(-1) terminateds = rollout["terminateds"].reshape(-1) adv, returns = compute_gae( rewards, values, next_values, terminateds, gamma=gamma, lam=lam, ) # 优势归一化,降低不同批次间的尺度差异 adv = (adv - adv.mean()) / (adv.std() + 1e-8) batch = { "obs": rollout["obs"].reshape(T * N, -1), "actions": rollout["actions"].reshape(-1), "logp": rollout["logp"].reshape(-1), "adv": adv, "returns": returns, } return batch优势归一化这一步值得单独说。不做归一化,不同 rollout 之间的优势量级可能差几倍,PPO 的 clip 阈值相对这个量级就失效了。做了归一化,clip 的 0.2 才有稳定的物理含义。这个操作会引入轻微的偏差(因为减掉了批次均值),但在实践中收益远大于代价。
6.3 PPO 更新与完整训练循环
PPO 的核心是把策略更新的幅度限制住,防止一步更新把策略推到采样分布之外。
# 文件路径:train_ppo.py(更新部分) import torch.nn as nn def ppo_update(model, optimizer, batch, cfg, device): T_total = batch["obs"].shape[0] indices = np.arange(T_total) stats = {"policy_loss": 0.0, "value_loss": 0.0, "entropy": 0.0, "clip_frac": 0.0} n_updates = 0 for _ in range(cfg["ppo_epochs"]): np.random.shuffle(indices) for start in range(0, T_total, cfg["minibatch_size"]): mb_idx = indices[start:start + cfg["minibatch_size"]] mb_obs = batch["obs"][mb_idx] mb_act = batch["actions"][mb_idx] mb_old_logp = batch["logp"][mb_idx] mb_adv = batch["adv"][mb_idx] mb_ret = batch["returns"][mb_idx] logp, entropy, value = model.evaluate(mb_obs, mb_act) ratio = torch.exp(logp - mb_old_logp) surr1 = ratio * mb_adv surr2 = torch.clamp(ratio, 1.0 - cfg["clip_eps"], 1.0 + cfg["clip_eps"]) * mb_adv policy_loss = -torch.min(surr1, surr2).mean() value_loss = nn.functional.mse_loss(value, mb_ret) loss = (policy_loss + cfg["value_coef"] * value_loss - cfg["entropy_coef"] * entropy.mean()) optimizer.zero_grad() loss.backward() nn.utils.clip_grad_norm_(model.parameters(), cfg["max_grad_norm"]) optimizer.step() stats["policy_loss"] += policy_loss.item() stats["value_loss"] += value_loss.item() stats["entropy"] += entropy.mean().item() stats["clip_frac"] += (torch.abs(ratio - 1.0) > cfg["clip_eps"]).float().mean().item() n_updates += 1 return {k: v / max(n_updates, 1) for k, v in stats.items()}clip_frac这个指标很有用但经常被忽略。它表示有多大比例的样本触发了裁剪:太低(接近 0)说明每轮更新幅度太小、数据没被充分利用;太高(超过 0.3)说明新旧策略差异过大、更新过猛。健康的训练中它通常稳定在 0.05 到 0.2 之间。
clip_grad_norm_是必须的。Actor Critic 的损失由三部分组成,量级不一致时梯度可能突然放大,尤其在 Critic 还没学准的早期。裁剪到 0.5 是个保守但通用的选择。
把采样和更新串起来就是主循环:
# 文件路径:train_ppo.py(主循环) import gymnasium as gym import yaml from models.actor_critic import ActorCriticDiscrete def main(): with open("configs/ppo_cartpole.yaml", "r", encoding="utf-8") as f: cfg = yaml.safe_load(f) torch.manual_seed(cfg["seed"]) np.random.seed(cfg["seed"]) device = torch.device("cuda" if torch.cuda.is_available() else "cpu") envs = gym.vector.make(cfg["env_id"], num_envs=cfg["num_envs"]) obs_dim = envs.single_observation_space.shape[0] act_dim = envs.single_action_space.n model = ActorCriticDiscrete(obs_dim, act_dim, cfg["hidden_dim"]).to(device) optimizer = torch.optim.Adam(model.parameters(), lr=cfg["lr_actor"]) obs, _ = envs.reset(seed=cfg["seed"]) for update in range(1, cfg["total_updates"] + 1): rollout = collect_rollout( envs, model, obs, cfg["rollout_steps"], device, cfg["gamma"] ) obs = rollout.pop("last_obs", obs) ep_returns = rollout.pop("episode_returns") batch = prepare_batch(rollout, cfg["gamma"], cfg["gae_lambda"], device) stats = ppo_update(model, optimizer, batch, cfg, device) if ep_returns and update % 5 == 0: print(f"[update {update:03d}] " f"ep_ret={np.mean(ep_returns):.1f} " f"pi_loss={stats['policy_loss']:.4f} " f"v_loss={stats['value_loss']:.4f} " f"entropy={stats['entropy']:.4f} " f"clip={stats['clip_frac']:.3f}") envs.close() if __name__ == "__main__": main()运行:
python train_ppo.pyCartPole-v1 的满分是 500。单环境、2048 步 rollout 的配置下,一般几十轮更新就能看到回合回报稳定在 400 以上,几百轮内能打满。如果你的曲线长期卡在 20 附近(随机策略的水平),先别急着调超参,按第 7 节的顺序排查。
7. 从 A2C 到 PPO:主流算法谱系与选型
Actor Critic 的骨架只有一套,但围绕"优势怎么估""更新幅度怎么控""样本怎么用"这三个问题,衍生出了一整族算法。理解它们的关系比逐个背公式有用得多。
| 算法 | 动作空间 | 关键改动 | 主要解决的问题 | 适用场景 |
|---|---|---|---|---|
| A2C | 离散/连续 | 同步多环境采样 + 单步 TD 优势 | 降低单环境采样的相关性 | 通用入门、并行采样便宜的场景 |
| A3C | 离散/连续 | 异步多线程采样 | 同上,但用异步换吞吐 | 单机多核、不想写向量化逻辑 |
| GAE | 通用 | 多步优势的指数加权 | 偏差-方差权衡可调 | 几乎所有现代 AC 算法都用 |
| PPO | 离散/连续 | clip 限制策略比率 | 单步更新过大导致崩溃 | 通用首选,稳定、易调 |
| DDPG | 连续 | 确定性策略 + 目标网络 + 经验回放 | 连续控制下的样本效率 | 低维连续控制 |
| TD3 | 连续 | 双 Critic 取小 + 延迟更新 + 目标平滑 | DDPG 的 Q 值过估计 | 连续控制,稳定性优先 |
| SAC | 连续 | 最大熵目标 + 自动温度调节 | 探索不足、超参敏感 | 连续控制,鲁棒性优先 |
选型上有个简单的判断路径:动作离散,直接上 PPO;动作连续、样本采集贵(比如真实机器人),考虑 SAC 或 TD3;任务极其简单、只是教学演示,A2C 就够,代码量比 PPO 少一半。
PPO 之所以成为默认选择,不是因为它在任何任务上都最优,而是因为它的超参容忍度最高。clip 阈值取 0.1 到 0.3 之间,学习率在 3e-4 上下一个数量级,一般都能跑出个结果。这对工程实践来说价值很大——你不需要为每个新任务重新做一轮超参搜索。
关于 SAC 的最大熵目标,这里补充一句理解方式:它在奖励里额外加了一项策略熵,鼓励策略保持一定的随机性。这解决的是连续控制里"探索靠高斯噪声"的固有缺陷——噪声是盲目加的,熵正则化则是学出来的、与状态相关的探索强度。配合自动温度调节(让熵维持在一个目标值附近),SAC 对超参的敏感度比 DDPG 低很多。
8. 运行验证与常见问题排查
8.1 怎么判断训练是健康的
不要只看奖励曲线,要同时看四个指标:
- episode return:主指标,CartPole 上应该从 20 左右逐步爬升到 400 以上。如果长期不动或者剧烈震荡,说明有问题。
- value_loss:应该在训练初期快速下降,之后在低位波动。如果持续上涨,通常是 Critic 学习率过大或者 returns 的尺度没处理好。
- entropy:从初始值(约 $\ln 2 \approx 0.69$)缓慢下降。如果几轮之内就掉到 0.05 以下,说明探索不够,策略过早塌缩。
- clip_frac:稳定在 0.05 到 0.2 之间。异常高说明更新太猛。
还有一个容易被忽略的检查:用确定性策略跑一次评估。训练时的采样策略带随机性,如果评估和训练的表现差距很大,说明策略本身还没学好,只是靠随机探索蒙对了。评估时用model.act(obs, deterministic=True),跑 10 局取平均。
8.2 常见问题与排查表
| 问题现象 | 可能原因 | 排查方式 | 解决方案 |
|---|---|---|---|
| 回合回报长期停在随机水平(约 20) | 优势估计符号错误,或 log_prob 用了错误的动作 | 打印一批优势的均值和符号分布;核对 actions 与 logp 是否来自同一次采样 | 检查evaluate()里传入的 actions 是否与采样时一致,确认 detach 位置正确 |
| reward 前期上涨后突然崩塌,不再恢复 | 策略更新步长过大,clip 未生效 | 看clip_frac是否超过 0.3;看ratio的最大值 | 降低lr_actor,减小ppo_epochs,确认 clip 用的是min而不是max |
value_loss持续上升或变成 nan | Critic 目标未 detach,或 returns 尺度爆炸 | 打印returns的最大最小值 | 目标端的 value 必须 detach;检查终止掩码是否漏乘导致回报累积成无限 |
| entropy 迅速跌到接近 0 | 熵正则系数太小,或 Actor 学习率过大 | 观察前 10 轮的 entropy 曲线 | 提高entropy_coef到 0.01~0.05,或降低lr_actor |
| 训练能跑但评估表现很差 | 训练与评估的策略模式不一致,或观测归一化不一致 | 对比 deterministic 与 stochastic 下的回报 | 评估统一用 deterministic;确认归一化参数只从训练数据统计 |
报错too many values to unpack | 用了旧版 gym 的四元组 API | 查看env.step返回值长度 | 改成obs, rew, terminated, truncated, info |
| 回合长度始终不超过某个固定值 | truncated 被当成 terminated,bootstrap 被错误截断 | 检查terminateds里是否有 truncated 的污染 | 只把terminated存进掩码,truncated 时继续 bootstrap |
| GPU 显存持续增长 | 采样时未加no_grad,计算图被保留 | 用torch.cuda.memory_allocated()观察 | 采样和评估函数加@torch.no_grad() |
| 多环境训练比单环境还慢 | 环境本身就是 CPU 密集型,向量化没带来收益 | 对比 env.step 的耗时占比 | 换用AsyncVectorEnv,或减少环境数、增大单环境 rollout |
8.3 一个真实的失败案例
有一种情况特别值得单独说:训练日志里policy_loss和value_loss都在正常波动,entropy也在缓慢下降,但 reward 就是不动。这时候最可能的问题不在算法,而在状态没被正确喂进去——比如observe的 dtype 是 float64 而网络权重是 float32,PyTorch 在某些路径下会静默转换,某些路径下会报错,还有的时候你在reshape时把(T, N, obs_dim)的顺序搞反了,导致网络看到的"状态"其实是不同环境同一时刻的拼接。
排查方式是:取一个 batch,把obs[0]打印出来,和env.reset()返回的第一帧对比,确认数值一致。如果对不上,问题就找到了。这类 bug 的特点是训练流程完全跑得通,只是学不到东西,比直接报错难发现得多。
9. 最佳实践与下一步方向
9.1 工程上必须做的几件事
固定随机种子但不要固定死。训练脚本里设种子是为了可复现,但如果你只在一个种子上验证算法有效,很可能只是运气好。建议至少跑 3 个种子,看均值和方差。Actor Critic 这类算法在不同种子下的表现差异可能比不同超参还大。
观测归一化要谨慎。用 running mean/std 归一化观测能显著加速训练,但统计量必须只从训练数据累积,评估时用固定的统计量。如果评估时也在更新统计量,就等于评估分布和训练分布不一致,结果不可信。另外,归一化后的观测要裁剪到合理范围(比如 [-10, 10]),防止早期统计不准时产生极端值。
奖励缩放要视任务而定。奖励量级过大(比如几千)会让 value_loss 爆炸,此时按一个固定系数缩放到 [-1, 1] 附近是有帮助的。但缩放系数一旦确定就不能中途改,否则 Critic 学到的价值尺度会失效。CartPole 这种奖励恒为 1 的环境不需要缩放。
日志要记全。除了上面说的四个指标,还应该记录:每轮的平均回合长度、梯度范数、各层权重的范数、以及ratio的分布。出问题时这些数据能把排查范围缩小到具体模块,比盲目调参高效得多。
安全边界。如果把 Actor Critic 用到真实系统(机器人、推荐系统、资源调度),先在仿真或影子模式下跑足够长时间,确认策略不会输出越界动作。连续动作空间下一定要做动作裁剪,并且在裁剪后重新计算 log_prob——直接用未裁剪动作的 log_prob 会导致重要性采样比率失真。生产环境的策略更新要有回滚机制,新策略上线前做 A/B 对比。
9.2 值得继续深入的方向
如果这篇的内容你已经跑通,下一步可以从三个方向延伸。
第一个是连续控制。把ActorCriticContinuous接到 Pendulum 或 MuJoCo 类环境上,感受一下连续动作空间下调参难度的变化。重点观察log_std的演化过程,它会告诉你策略的探索强度是如何自适应收缩的。
第二个是分布式与异步。当环境交互成为瓶颈时,单进程的采样速度会成为限制。这时候需要理解向量化环境和异步采样的区别,以及它们对 on-policy 算法数据新鲜度的影响。
第三个是离线强化学习。Actor Critic 在离线数据上的直接应用会遇到分布偏移问题,这催生了 CQL、IQL 等一系列工作。理解 Actor Critic 的在线假设在哪里被打破,是进入这个方向的前提。
回到最核心的判断:Actor Critic 的价值不在于它比谁更先进,而在于它把"策略优化"和"价值估计"这两个本质上不同的问题解耦开来,让它们各自用合适的方法去学。你后面遇到的每一个算法,不管是 PPO 的 clip、SAC 的熵正则、还是 TD3 的双 Critic,本质上都是在修补这个解耦结构里的某一处缺陷。把第 6 节那段训练循环真正跑通、跑出曲线、并且能解释曲线上每一个拐点,比读完十篇论文更有用。
建议把这篇文章里的代码和配置保存下来,作为后续实验的基线。改算法的时候,一次只动一个模块——换优势估计、换网络结构、还是换更新规则,分开验证,才说得清到底是哪一处改动起了作用。