从REINFORCE到PPO:Actor Critic原理与CartPole实战

发布时间:2026/10/27 10:44:59
从REINFORCE到PPO:Actor Critic原理与CartPole实战 很多人学强化学习的路径都差不多先用 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_{t1}) - V_\phi(s_t) \right)^2$$这个形式叫半梯度 TD(0)注意目标里的 $V_\phi(s_{t1})$ 通常要 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{t1}) - V(s_t)$$这个量只需要一个 $V$ 网络而且它就是 Critic 的 TD 误差训练 Critic 的时候顺手就算出来了。这就是用 TD 误差当优势的经典做法也是单步 Actor CriticA2C的核心。单步估计的偏差比较大因为它只往前看一步。蒙特卡洛估计看完整条轨迹偏差小但方差大。GAEGeneralized Advantage Estimation用一个参数 $\lambda$ 在这两者之间插值$\lambda0$ 退化成单步 TD$\lambda1$ 退化成蒙特卡洛。实践里 $\lambda$ 取 0.9 到 0.99 之间比较常见具体要看任务的时间跨度。2.4 三种方法族的对比维度基于价值DQN 等基于策略REINFORCE 等Actor Critic学什么$Q(s,a)$策略隐式导出策略 $\pi_\theta$ 直接参数化策略 价值同时学连续动作需要离散化难扩展原生支持原生支持随机策略不支持通常确定性支持支持梯度方差不涉及策略梯度高中等可用 GAE 调节样本效率中等依赖经验回放低同策略中等可并行采样典型算法DQN、Double DQN、RainbowREINFORCE、VPGA2C、PPO、SAC、TD3主要调参难点探索、过估计方差、基线设计Actor/Critic 平衡、优势估计这张表想说明的判断是方法族的选择主要取决于动作空间形态和对样本效率的要求而不是哪个更先进。如果你的任务动作是离散的、状态维度不高、样本采集便宜DQN 系列依然是很务实的选择。Actor Critic 的优势在连续控制、随机策略需求、以及需要并行采样的大规模场景。3. 从 REINFORCE 到 Actor Critic 的降方差改造3.1 原始策略梯度的方差来自哪里REINFORCE 的梯度估计是 $\nabla_\theta J \approx \frac{1}{N}\sum_{i1}^{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_{t1})$ 这个单步估计代替。于是优势变成 $\hat{A}t r_t \gamma V(s{t1}) - V(s_t)$。这一步带来了偏差因为 $V$ 是估计出来的不准但方差大幅下降。整个训练从等一局结束再更新变成了每一步都能更新这是 Actor Critic 能在线学习的根本原因。这里有个细节值得说清楚bootstrapping 的边界条件。当 $s_{t1}$ 是终止状态时$V(s_{t1})$ 应该是 0所以优势退化为 $\hat{A}_t r_t - V(s_t)$。代码里必须用一个掩码把终止步的 bootstrap 项乘掉。忘掉这个掩码训练前期可能看不出问题后期策略会系统性地高估终止前的状态价值表现为回合末行为异常。3.3 GAE在偏差和方差之间调一个旋钮GAE 的定义是把多步 TD 误差做指数加权$$\hat{A}^{GAE(\gamma,\lambda)}t \sum{l0}^{\infty} (\gamma\lambda)^l \delta_{tl}, \quad \delta_t r_t \gamma V(s_{t1}) - V(s_t)$$实现上不需要真的累加无穷项用一个反向循环就够了# 文件路径common/gae.py import torch def compute_gae(rewards, values, next_values, terminateds, gamma0.99, lam0.95): rewards: (T,) 每步奖励 values: (T,) V(s_t) next_values: (T,) V(s_{t1})最后一步用 rollout 结束时的 obs 算 terminateds: (T,) 1.0 表示真正终止0.0 表示还在进行中 返回 adv (T,) 和 returns (T,) T rewards.shape[0] adv torch.zeros(T, dtypetorch.float32, devicerewards.device) last_gae torch.zeros((), dtypetorch.float32, devicerewards.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_{t1})$ 依然有意义应该继续 bootstrap。这一点在很多教程里被简化掉了但在 CartPole 这种有 500 步上限的环境里训练到后期几乎每个回合都是 truncated 结束处理错了会明显拖慢收敛。4. 环境准备与项目结构4.1 依赖与版本说明这个系列不绑定具体版本号因为 PyTorch 和 Gymnasium 的 API 在小版本间会有调整写死版本反而容易让读者踩坑。下面给出的是能力要求具体版本以你本地实际安装为准如果 API 对不上优先查官方迁移说明。Python 3.9 及以上需要支持类型注解和 dataclassPyTorch 2.x用到torch.no_grad、torch.distributionsGymnasium注意不是老版的gymreset()和step()的返回值已经变了NumPyTensorBoard可选用来画训练曲线# 文件路径requirements.txt torch2.0 gymnasium0.28 numpy1.24 tensorboard2.13 pyyaml6.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, gain1.0): if isinstance(layer, nn.Linear): nn.init.orthogonal_(layer.weight, gaingain) nn.init.constant_(layer.bias, 0.0) class ActorCriticDiscrete(nn.Module): 离散动作空间的 Actor Critic共享主干 双头输出。 def __init__(self, obs_dim, act_dim, hidden64): 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, gain1.0) # Actor 头小增益初始化让初始策略接近均匀分布 orthogonal_init(self.actor_head, gain0.01) orthogonal_init(self.critic_head, gain1.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, deterministicFalse): logits, value self.forward(obs) dist Categorical(logitslogits) 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(logitslogits) return dist.log_prob(actions), dist.entropy(), value class ActorCriticContinuous(nn.Module): 连续动作空间的 Actor Critic输出高斯策略。 def __init__(self, obs_dim, act_dim, hidden64, 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, gain1.0) orthogonal_init(self.mu_head, gain0.01) orthogonal_init(self.critic_head, gain1.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, deterministicFalse): 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, devicedevice) act_buf torch.zeros(rollout_steps, num_envs, dtypetorch.long, devicedevice) logp_buf torch.zeros(rollout_steps, num_envs, devicedevice) rew_buf torch.zeros(rollout_steps, num_envs, devicedevice) val_buf torch.zeros(rollout_steps, num_envs, devicedevice) next_val_buf torch.zeros(rollout_steps, num_envs, devicedevice) term_buf torch.zeros(rollout_steps, num_envs, devicedevice) episode_returns np.zeros(num_envs, dtypenp.float32) finished_returns [] for t in range(rollout_steps): obs_t torch.as_tensor(obs, dtypetorch.float32, devicedevice) 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, dtypetorch.float32, devicedevice) _, 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, dtypetorch.float32, devicedevice) val_buf[t] value next_val_buf[t] next_value term_buf[t] torch.as_tensor(terminated.astype(np.float32), devicedevice) 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, gammagamma, lamlam, ) # 优势归一化降低不同批次间的尺度差异 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, encodingutf-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_envscfg[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(), lrcfg[lr_actor]) obs, _ envs.reset(seedcfg[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}] fep_ret{np.mean(ep_returns):.1f} fpi_loss{stats[policy_loss]:.4f} fv_loss{stats[value_loss]:.4f} fentropy{stats[entropy]:.4f} fclip{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, deterministicTrue)跑 10 局取平均。8.2 常见问题与排查表问题现象可能原因排查方式解决方案回合回报长期停在随机水平约 20优势估计符号错误或 log_prob 用了错误的动作打印一批优势的均值和符号分布核对 actions 与 logp 是否来自同一次采样检查evaluate()里传入的 actions 是否与采样时一致确认 detach 位置正确reward 前期上涨后突然崩塌不再恢复策略更新步长过大clip 未生效看clip_frac是否超过 0.3看ratio的最大值降低lr_actor减小ppo_epochs确认 clip 用的是min而不是maxvalue_loss持续上升或变成 nanCritic 目标未 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 被当成 terminatedbootstrap 被错误截断检查terminateds里是否有 truncated 的污染只把terminated存进掩码truncated 时继续 bootstrapGPU 显存持续增长采样时未加no_grad计算图被保留用torch.cuda.memory_allocated()观察采样和评估函数加torch.no_grad()多环境训练比单环境还慢环境本身就是 CPU 密集型向量化没带来收益对比 env.step 的耗时占比换用AsyncVectorEnv或减少环境数、增大单环境 rollout8.3 一个真实的失败案例有一种情况特别值得单独说训练日志里policy_loss和value_loss都在正常波动entropy也在缓慢下降但 reward 就是不动。这时候最可能的问题不在算法而在状态没被正确喂进去——比如observe的 dtype 是 float64 而网络权重是 float32PyTorch 在某些路径下会静默转换某些路径下会报错还有的时候你在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 节那段训练循环真正跑通、跑出曲线、并且能解释曲线上每一个拐点比读完十篇论文更有用。建议把这篇文章里的代码和配置保存下来作为后续实验的基线。改算法的时候一次只动一个模块——换优势估计、换网络结构、还是换更新规则分开验证才说得清到底是哪一处改动起了作用。

关于本文作者

来自尧图内容编辑团队

尧图内容编辑团队 内容团队

尧图内容编辑团队

本文由尧图网络内容编辑团队执笔。团队由资深项目经理、前端工程师与设计师组成,所有内容均来自亲手交付的真实项目,先讲清问题、再给出可落地的解法。尧图深耕北京网站建设十年,服务过京华建材集团、智造科技等各行业客户,把一线经验沉淀为可复用的行业观察。

  • 十年建站经验,覆盖建材、制造、服务、文创等
  • 项目经理把关选题与事实准确性
  • 工程师与设计师联合撰写专业细节
  • 统一编辑规范,保证文风与排版一致
  • 每月复盘转化数据,迭代选题方向

延伸阅读

相关资讯与近期热门内容

深度阅读推荐

建站决策前值得细读的三篇

网站改版的5个关键决策
2024-08-12

网站改版的5个关键决策

什么时候该改版、改到什么程度、如何避免流量掉光,京华建材集团改版复盘给出答案。

获取专属建站方案

看完文章,把您的行业与预算告诉我们,免费获取一份量身定制的官网建设方案与报价。

立即免费咨询