在 CartPole 这类经典控制任务上,很多同学第一次接触 Actor-Critic 时都会有类似的困惑:明明 Q-Learning 已经能跑通,为什么还要多搞一个网络出来?策略梯度里的 REINFORCE 也能训练,为什么它的曲线抖得厉害?本文就从这两个疑问出发,把 Actor-Critic 的网络架构、优势函数、GAE、A2C 与 A3C 的差异完整拆开,最后给出一份可以直接运行的 PyTorch 实现。读完之后,你应该能做到三件事:自己搭出一个 Actor-Critic 网络、理解策略损失和价值损失为什么要这样配、遇到训练不收敛时知道从哪里开始查。
1. 从策略梯度到 Actor-Critic:为什么需要两套网络
1.1 纯策略梯度方法的两个痛点
强化学习的本质是让智能体在与环境的交互中学会"在什么状态下做什么动作"。最直接的做法是把策略参数化成一个神经网络,输入状态、输出动作概率,然后用回合累计回报去加权更新这个网络,这就是 REINFORCE 代表的纯策略梯度方法。它的推导干净、实现简单,但落地时会碰到两个很难绕开的问题。
第一个问题是方差大。REINFORCE 用整条轨迹的回报 $G_t$ 去乘对数概率的梯度,而 $G_t$ 是随机采样的结果,同一个状态在不同回合里可能对应完全不同的回报值。这就导致梯度估计的噪声非常大,网络参数会被高方差的信号来回拉扯,训练曲线呈现出明显的锯齿状。虽然可以通过采很多条轨迹取平均来缓解,但样本效率会迅速下降。
第二个问题是只能学、不能评。纯策略梯度只关心"这个动作概率该调大还是调小",却不知道"这个状态本身到底值多少钱"。没有价值基准,就无法判断某次回报是因为动作选得好,还是因为这一局运气好。缺少这个参照系,策略更新就缺少稳定的方向感。
1.2 Actor-Critic 的核心思想
Actor-Critic 的思路非常直白:既然策略梯度缺少一个"打分器",那就再训练一个网络专门给状态估值,用它来充当基准。于是整个系统被拆成两个部分:Actor 负责输出动作分布,Critic 负责估计状态价值 $V(s)$。Critic 的输出不直接产生动作,但它提供的信息能让 Actor 的更新信号变得更加精准。
在实际更新时,Actor 不再使用原始回报 $G_t$,而是使用"回报减去基准值"得到的优势值。这个减法的意义在于:如果某个动作带来的回报比预期高,就提高它的概率;如果低于预期,就降低。正如一个学生在考试中拿到 80 分,单看分数很难判断好坏,但如果班级平均分是 60,那这 80 分就明显值得肯定。Critic 提供的就是这个"平均分"。
1.3 与 Q-Learning、REINFORCE 的关系对比
为了把定位说清楚,这里把三类方法放在一起做个对照。Q-Learning 属于值函数方法,它学的是动作价值 $Q(s,a)$,策略是从 Q 值里隐式导出的(取最大值或按 softmax 采样);REINFORCE 属于策略方法,直接优化策略参数;Actor-Critic 则是两者的结合体,既有显式的策略网络,又有显式的价值网络。
| 方法类型 | 学什么 | 策略来源 | 优点 | 主要缺点 |
|---|---|---|---|---|
| Q-Learning / DQN | $Q(s,a)$ | 由 Q 值导出 | 样本利用率较高 | 只能处理离散动作 |
| REINFORCE | 策略参数 $\theta$ | 直接输出分布 | 支持连续动作 | 方差极大 |
| Actor-Critic | 策略 + 价值 | 策略网络直接输出 | 方差较低、支持连续动作 | 两个网络耦合,调参更复杂 |
需要强调一点:Actor-Critic 不是一种具体算法,而是一类架构范式。A2C、A3C、PPO、SAC、DDPG 都可以看作这个范式下的不同变体,区别主要在于优势怎么算、更新怎么截断、数据怎么并行收集。
2. Actor 与 Critic 的概念拆解
2.1 Actor:负责"做决定"的策略网络
Actor 网络的结构取决于动作空间。如果动作是离散的(比如 CartPole 里的向左、向右),网络输出层就是动作数量个 logits,经过 softmax 后得到概率分布,采样时用Categorical分布即可;如果动作是连续的(比如机械臂的关节角度),输出层通常拆成均值和对数标准差两部分,用高斯分布采样。
这里有一个容易被忽略的设计细节:Actor 的输出层不应该做很强的初始化。因为初始策略越接近均匀分布,探索性越好;如果输出层权重初始得过大,softmax 会立刻饱和成几乎确定性的策略,智能体一开始就只会做同一个动作,很容易陷入局部最优。实践中常用的做法是把 Actor 输出层的正交初始化增益设得很小,比如 0.01。
2.2 Critic:负责"打分"的价值网络
Critic 网络的输出是一个标量,表示当前状态的价值估计。它的训练目标比较明确:让预测值逼近真实的折扣回报。这是一个标准的回归问题,损失函数用均方误差即可。相比 Actor 那边的策略梯度,Critic 的监督信号更直接,所以通常收敛得更快。
不过 Critic 也有自己的麻烦。它估计的目标本身是随着策略变化而变化的,策略一更新,价值分布就跟着漂移了。这就是所谓的"移动靶"问题。如果 Critic 学得太慢,优势估计就不准,Actor 会拿到错误的梯度;如果 Critic 学得太快、拟合过强,又容易过拟合到旧策略的价值上。实际训练中通常靠控制价值损失系数和更新轮数来平衡。
2.3 优势函数:让策略更新更有方向感
优势函数的定义是 $A(s,a) = Q(s,a) - V(s)$,表示"在状态 s 下选择动作 a 比平均水平好多少"。在 Actor-Critic 里,我们通常不去显式算 Q,而是用一步时序差分误差来近似:
delta_t = r_t + gamma * V(s_{t+1}) - V(s_t)这个 delta 就是优势的一个有偏估计。把它代进策略梯度,就得到最基础的 Actor-Critic 更新形式。相比用完整回报 $G_t$,delta 的方差小得多,因为它只依赖一步随机性,后面的不确定性都被 Critic 的估计吸收掉了。
代价是引入了偏差。如果 Critic 估得不准,delta 就是有偏的。这构成了强化学习中经典的偏差-方差权衡:用越多步的真实回报,偏差越小、方差越大;越依赖 Critic 的自举估计,方差越小、偏差越大。后面的 GAE 就是在这个权衡上做的一个可调参数化方案。
2.4 自举与偏差-方差权衡
"自举"(bootstrap)这个词来自统计学,在这里指的是用估计值去更新估计值。Critic 在计算目标时用到的 $V(s_{t+1})$ 本身也是网络输出,这就是自举。自举让价值学习可以单步进行,不必等整个回合结束,但它也带来了估计偏差累积的风险。
用一个极端例子来理解:如果 Critic 一开始把所有状态的价值都估成 0,那么 delta 就退化成即时奖励 $r_t$,此时优势估计完全不包含未来信息,Actor 学到的就是短视策略。反过来,如果完全不用自举、只用蒙特卡洛回报,偏差消失但方差飙升。GAE 用参数 lambda 在两者之间做线性插值,lambda 取 0 就退化成单步 TD,取 1 就退化成蒙特卡洛,实践中常取 0.95 左右。
3. 环境准备与版本说明
3.1 运行环境与依赖
本文示例使用 Python 和 PyTorch 实现,环境交互部分使用 Gymnasium。由于这些库的版本迭代较快,具体版本号请以你本地实际安装的为准,本文重点演示配置思路和代码结构,不锁定具体小版本号。
需要准备的基础组件包括:Python 3.9 及以上版本、PyTorch(CPU 版本即可跑通 CartPole)、Gymnasium、NumPy。如果你有可用的 GPU,代码无需修改,把张量放到对应设备上就行,但 CartPole 这种小任务用 CPU 完全够用,强行上 GPU 反而因为数据传输开销变慢。
# 创建虚拟环境(可选,但推荐) python -m venv rl_env source rl_env/bin/activate # Windows 下用 rl_env\Scripts\activate # 安装依赖 pip install torch numpy gymnasium安装完成后建议先做一次环境自检,确认 Gymnasium 的接口是较新的五元组返回形式,避免后面代码对不上。
3.2 安装命令与验证
# 文件路径:check_env.py import gymnasium as gym import torch print("torch:", torch.__version__) env = gym.make("CartPole-v1") obs, info = env.reset(seed=0) print("obs shape:", obs.shape) print("action space:", env.action_space) next_obs, reward, terminated, truncated, info = env.step(0) print("reward:", reward, "terminated:", terminated, "truncated:", truncated) env.close()预期输出中,obs shape应为(4,),action space为Discrete(2),说明环境的观测维度是 4、动作空间是 2。如果step只返回四个值,说明你用的是旧版 gym 接口,需要调整解包写法,或者直接升级到 Gymnasium。
3.3 示例项目结构
为了便于后续扩展,建议把代码拆成几个文件,而不是全部塞进一个脚本。下面是一个够用但不过度设计的结构:
actor_critic_demo/ ├── model.py # Actor-Critic 网络定义 ├── gae.py # 优势估计 ├── train_a2c.py # 训练主循环 └── README.md如果你只是想快速验证,把三个文件合并成一个脚本也完全没问题。本文下面的完整示例会按文件拆分的方式给出,方便你直接对照创建。
4. Actor-Critic 网络架构设计要点
4.1 共享主干还是两套独立网络
这是搭网络时第一个要做的决定。共享主干指的是 Actor 和 Critic 前面几层共用,只在最后分叉出两个输出头;独立网络则是两套完全分开的参数。两种做法各有适用场景。
共享主干的好处是参数量小、特征复用,训练早期收敛更快,因为 Critic 的梯度也能帮助主干学到有用的状态表示。缺点是两个目标的梯度会互相干扰,尤其是当策略损失和价值损失量级差很多时,主干参数会被一方主导。独立网络没有干扰问题,但参数量翻倍,小任务上容易过拟合,训练也更慢。
实践中的常见选择是:状态维度不高、任务简单时用共享主干;状态是图像、特征提取成本高时也倾向共享;如果发现训练震荡明显、两个损失量级差得离谱,就改成独立网络试试。本文的 CartPole 示例采用共享主干加双头输出的结构。
4.2 离散动作空间的输出层设计
离散动作空间的 Actor 输出层就是一个线性层,输出维度等于动作数。它的输出是 logits,不需要手动加 softmax,因为 PyTorch 的Categorical分布接受 logits 并在内部做归一化,这样在数值上更稳定。
# 文件路径:model.py(离散动作版本的核心片段) import numpy as np import torch import torch.nn as nn class ActorCritic(nn.Module): def __init__(self, obs_dim, act_dim, hidden_dim=128): super().__init__() self.backbone = nn.Sequential( nn.Linear(obs_dim, hidden_dim), nn.Tanh(), nn.Linear(hidden_dim, hidden_dim), nn.Tanh(), ) self.actor_head = nn.Linear(hidden_dim, act_dim) self.critic_head = nn.Linear(hidden_dim, 1) self._init_weights() def _init_weights(self): for layer in self.backbone: if isinstance(layer, nn.Linear): nn.init.orthogonal_(layer.weight, gain=np.sqrt(2)) nn.init.constant_(layer.bias, 0.0) # Actor 输出层小增益,保证初始策略接近均匀分布 nn.init.orthogonal_(self.actor_head.weight, gain=0.01) nn.init.constant_(self.actor_head.bias, 0.0) # Critic 输出层正常增益 nn.init.orthogonal_(self.critic_head.weight, gain=1.0) nn.init.constant_(self.critic_head.bias, 0.0) def forward(self, obs): h = self.backbone(obs) logits = self.actor_head(h) value = self.critic_head(h).squeeze(-1) return logits, value这里激活函数用的是Tanh而不是ReLU。原因在于策略梯度的稳定性:Tanh的输出有界,梯度不会因为状态数值范围波动而爆炸,这在连续控制任务里尤其重要。虽然ReLU在监督学习里更常用,但在策略网络里,有界激活往往训练更稳。
4.3 连续动作空间的高斯策略
连续动作的处理方式不同。网络不再输出每个动作的概率,而是输出一个高斯分布的均值和标准差,采样时从这个分布里取值。为了让标准差恒为正,通常让它以对数形式作为可学习参数。
# 文件路径:model.py(连续动作版本的核心片段) class GaussianActorCritic(nn.Module): def __init__(self, obs_dim, act_dim, hidden_dim=128): super().__init__() self.backbone = nn.Sequential( nn.Linear(obs_dim, hidden_dim), nn.Tanh(), nn.Linear(hidden_dim, hidden_dim), nn.Tanh(), ) self.mean_head = nn.Linear(hidden_dim, act_dim) self.critic_head = nn.Linear(hidden_dim, 1) # 对数标准差作为独立可学习参数,初始为 0 表示标准差为 1 self.log_std = nn.Parameter(torch.zeros(act_dim)) def forward(self, obs): h = self.backbone(obs) mean = self.mean_head(h) std = torch.exp(self.log_std).expand_as(mean) value = self.critic_head(h).squeeze(-1) return mean, std, value用这个网络采样时,构造torch.distributions.Normal(mean, std),然后调用sample()并取log_prob()即可。需要注意的是,连续动作通常需要按动作空间的范围做缩放,否则策略输出的动作可能超出环境允许的边界,导致物理仿真直接报错或行为异常。
4.4 参数初始化的细节
上面代码里的初始化方式值得单独说一下。正交初始化(orthogonal initialization)在强化学习中之所以流行,是因为它能保证初始权重矩阵的各行近似正交,前向传播时不会把输入的不同维度过度压缩到同一方向,梯度传播更均匀。
Actor 输出层增益设为 0.01,目的是让初始 logits 接近 0,softmax 后接近均匀分布。如果这里用默认初始化,logits 可能有 ±1 以上的差异,softmax 就会给出 70% 以上的偏向性,探索性直接受损。Critic 输出层用增益 1,保证初始价值估计有合理的数值范围,不会一开始就全部压到 0 附近。
5. 完整实战:在 CartPole 上实现 A2C
5.1 定义 Actor-Critic 网络
网络定义在 4.2 节已经给出,这里补充一个带动作采样的方法,方便训练循环调用。把采样逻辑封装进模型,可以让训练代码更干净,也便于后续替换成其他动作分布。
# 文件路径:model.py(续,接在 ActorCritic 类内部) @torch.no_grad() def act(self, obs): logits, value = self.forward(obs) dist = torch.distributions.Categorical(logits=logits) action = dist.sample() log_prob = dist.log_prob(action) return action, log_prob, value这个方法加了@torch.no_grad()装饰器,因为它只用于数据收集,不需要构建计算图。省掉这部分计算图能明显降低显存占用,对于长 rollout 尤其重要。
5.2 实现 GAE 优势估计
优势估计是整个训练流程里最容易写错的部分。GAE 的递推公式从后往前算,每一步依赖下一步的优势值,因此必须逆序遍历。这里给出一个针对单环境的实现,返回优势和回报两部分。
# 文件路径:gae.py import numpy as np def compute_gae(rewards, values, terminated, gamma=0.99, lam=0.95): """ rewards: 长度 T 的奖励列表 values: 长度 T+1 的价值列表,最后一个元素是 bootstrap 价值 terminated: 长度 T 的真实终止标记(1.0 表示回合真实结束) """ T = len(rewards) advantages = np.zeros(T, dtype=np.float32) last_gae = 0.0 next_value = values[T] for t in reversed(range(T)): # 只有真实终止才切断自举;时间截断仍然要用 bootstrap mask = 1.0 - terminated[t] delta = rewards[t] + gamma * next_value * mask - values[t] last_gae = delta + gamma * lam * mask * last_gae advantages[t] = last_gae next_value = values[t] returns = advantages + np.asarray(values[:T], dtype=np.float32) return advantages, returns这里有一个细节特别值得强调:terminated和truncated要区分对待。回合因为到达终止状态而结束时,未来的价值确实应该是 0,mask 取 0 是对的;但回合仅仅因为达到最大步数被截断时,未来其实还有价值,此时不应该切断自举。很多实现图省事直接用done = terminated or truncated,在截断时错误地把 bootstrap 置零,导致价值估计系统性偏低,训练后期表现不稳定。
5.3 经验收集与数据组织
数据收集阶段要做的事情是按固定步数收集一条轨迹,记录状态、动作、旧对数概率、奖励、终止标记和价值估计。为什么要记录旧对数概率?因为在 A2C 的单次更新里它其实用不上,但一旦你后面想升级到 PPO 做重要性采样比,这个值就是必需的,提前存下来可以少改一次代码。
# 文件路径:train_a2c.py(数据收集片段) import torch def collect_rollout(env, model, obs, rollout_len): obs_buf, act_buf, logp_buf = [], [], [] rew_buf, term_buf, val_buf = [], [], [] ep_return = 0.0 finished_returns = [] for _ in range(rollout_len): obs_t = torch.as_tensor(obs, dtype=torch.float32).unsqueeze(0) action, log_prob, value = model.act(obs_t) a = int(action.item()) next_obs, reward, terminated, truncated, _ = env.step(a) done = terminated or truncated obs_buf.append(obs) act_buf.append(a) logp_buf.append(float(log_prob.item())) rew_buf.append(float(reward)) term_buf.append(float(terminated)) val_buf.append(float(value.item())) ep_return += reward obs = next_obs if done: finished_returns.append(ep_return) ep_return = 0.0 obs, _ = env.reset() # 收集结束后补一个 bootstrap 价值 with torch.no_grad(): obs_t = torch.as_tensor(obs, dtype=torch.float32).unsqueeze(0) _, last_value = model(obs_t) val_buf.append(float(last_value.item())) return obs, (obs_buf, act_buf, logp_buf, rew_buf, term_buf, val_buf), finished_returns注意obs_buf里存的是原始 NumPy 数组,而不是张量。这样做的好处是内存占用小,等真正要更新时再一次性转成张量,避免在收集阶段反复分配小张量。
5.4 损失函数与参数更新
损失由三部分组成:策略损失、价值损失和熵正则项。策略损失用优势加权对数概率的负均值,价值损失用均方误差,熵正则鼓励策略保持一定的随机性。三者用系数加权求和。
# 文件路径:train_a2c.py(更新片段) import torch.nn.functional as F def update_model(model, optimizer, buffers, advantages, returns, value_coef=0.5, entropy_coef=0.01, max_grad_norm=0.5): obs_buf, act_buf, logp_buf, _, _, _ = buffers obs_t = torch.as_tensor(np.array(obs_buf), dtype=torch.float32) act_t = torch.as_tensor(act_buf, dtype=torch.long) adv_t = torch.as_tensor(advantages, dtype=torch.float32) ret_t = torch.as_tensor(returns, dtype=torch.float32) # 优势标准化,显著降低梯度方差 adv_t = (adv_t - adv_t.mean()) / (adv_t.std() + 1e-8) logits, values = model(obs_t) dist = torch.distributions.Categorical(logits=logits) new_logp = dist.log_prob(act_t) entropy = dist.entropy().mean() policy_loss = -(new_logp * adv_t).mean() value_loss = F.mse_loss(values, ret_t) loss = policy_loss + value_coef * value_loss - entropy_coef * entropy optimizer.zero_grad() loss.backward() nn.utils.clip_grad_norm_(model.parameters(), max_grad_norm) optimizer.step() return float(policy_loss.item()), float(value_loss.item()), float(entropy.item())优势标准化这一步看起来不起眼,但对训练稳定性的影响非常大。未标准化的优势值量级会随着奖励尺度变化,学习率稍大一点就会发散。标准化之后,优势的均值是 0、标准差是 1,梯度尺度被控制在一个稳定范围内,几乎成了标配操作。
梯度裁剪也是同理。Actor 和 Critic 共享主干时,某一次异常的 Critic 梯度可能通过主干传导到策略侧,把策略参数推离合理区间。裁剪到 0.5 左右可以兜住这种极端情况。
5.5 运行脚本与结果说明
把上面的片段组装起来,就得到完整的训练脚本。主循环的结构是"收集一段 → 算优势 → 更新一次",这也是 A2C 名字里"同步"的含义。
# 文件路径:train_a2c.py(主函数) import random from collections import deque import gymnasium as gym import numpy as np import torch from model import ActorCritic from gae import compute_gae def train(): torch.manual_seed(0) np.random.seed(0) random.seed(0) env = gym.make("CartPole-v1") obs_dim = env.observation_space.shape[0] act_dim = env.action_space.n model = ActorCritic(obs_dim, act_dim, hidden_dim=128) optimizer = torch.optim.Adam(model.parameters(), lr=7e-4, eps=1e-5) gamma, lam = 0.99, 0.95 rollout_len = 20 num_updates = 5000 recent_returns = deque(maxlen=20) obs, _ = env.reset(seed=0) global_step = 0 for update in range(1, num_updates + 1): obs, buffers, finished = collect_rollout(env, model, obs, rollout_len) global_step += rollout_len for r in finished: recent_returns.append(r) _, _, _, rew_buf, term_buf, val_buf = buffers advantages, returns = compute_gae(rew_buf, val_buf, term_buf, gamma, lam) p_loss, v_loss, ent = update_model(model, optimizer, buffers, advantages, returns) if update % 50 == 0: avg = np.mean(recent_returns) if recent_returns else 0.0 print(f"update {update:5d} | step {global_step:7d} | " f"avg_return {avg:6.1f} | pi_loss {p_loss:7.3f} | " f"v_loss {v_loss:7.3f} | entropy {ent:6.3f}") if len(recent_returns) == recent_returns.maxlen and np.mean(recent_returns) >= 475: print("最近 20 回合平均回报已达标,提前结束训练") break env.close() torch.save(model.state_dict(), "a2c_cartpole.pt") print("模型已保存到 a2c_cartpole.pt") if __name__ == "__main__": train()运行命令很简单:
python train_a2c.py预期输出会随着训练推进逐步变化。前期avg_return在 20 到 50 之间波动,v_loss从大值快速下降,entropy缓慢减小。中期回报开始爬升,出现 100 以上的回合。后期稳定在 400 以上,最终触发提前结束条件。如果跑到 5000 次更新还没收敛,说明超参数需要调整,具体排查方向见第 7 节。
有一点需要提前说明:CartPole 的随机性虽然不大,但不同随机种子下的收敛速度差异仍然明显。有时候一次运行 200 个回合就到 500 分,有时候要 500 个回合以上。这不代表代码有问题,而是强化学习训练本身的特性。建议至少跑三个不同种子,看平均表现再下结论。
6. 相关算法家族:A2C、A3C、GAE 与 PPO
6.1 A2C 同步优势 Actor-Critic
A2C 的核心特征是"同步":开多个环境实例,每个实例并行跑一段,收集完所有数据后统一计算优势、统一更新一次参数。相比单环境串行,它能在同样墙钟时间内获得更多样的经验,批次内的数据相关性也更低,梯度估计质量更高。
实现上,把单环境换成gym.vector.SyncVectorEnv,所有缓冲区多一个环境维度,GAE 计算时对每个环境独立逆序递推即可。其余逻辑完全不变。这个改动量不大,但能明显提升训练速度,是工程实践中值得优先做的优化。
6.2 A3C 异步并行
A3C 和 A2C 的差别在于并行方式:A3C 起多个工作线程,每个线程有自己的环境副本,各自算梯度并异步地更新同一个全局网络。它不需要等待所有环境同步,因此理论上吞吐更高。
但在实际工程里,A3C 的优势并不明显。Python 的 GIL 让多线程 CPU 并行效果有限;异步更新还会带来梯度陈旧的问题,也就是某个线程算梯度时用的参数已经过期了,这会引入额外的不稳定性。所以在 GPU 环境下,大家更倾向用 A2C 的同步多环境方案,或者干脆用 PPO。A3C 更多是作为理解异步思想的教学案例存在。
6.3 GAE 广义优势估计
GAE 的价值在于它把偏差-方差权衡变成了一个连续可调的参数。公式的递推形式是:
A_t^{GAE} = delta_t + (gamma * lambda) * A_{t+1}^{GAE} delta_t = r_t + gamma * V(s_{t+1}) - V(s_t)当 lambda 取 0 时,优势退化成单步 TD 误差,偏差最大、方差最小;取 1 时退化成蒙特卡洛优势,方差最大、偏差最小。中间取值在两者之间平滑过渡。
调 lambda 的经验是:任务奖励越稠密、回合越短,lambda 可以取小一些,因为单步信息已经足够;任务奖励越稀疏、回合越长,lambda 越要接近 1,否则信号传不到早期动作。CartPole 每步都有奖励,0.95 是个稳妥的默认值;如果是迷宫类任务只在终点给奖励,可能需要 0.98 甚至更高。
6.4 PPO 与 A2C 的血缘关系
PPO 常被当作独立算法讲,但从架构上看它就是 A2C 的加强版。它保留了 Actor-Critic 双网络结构和 GAE 优势估计,主要改动是在策略损失里加了重要性采样比率的截断:
ratio = exp(log_pi_new(a|s) - log_pi_old(a|s)) policy_loss = -min(ratio * A, clip(ratio, 1-eps, 1+eps) * A).mean()这个截断的作用是限制单次更新的策略变化幅度,防止一步更新把策略推得太远导致崩溃。也正因为有了这个保护,PPO 可以对同一批数据做多轮更新(比如 10 个 epoch),样本效率比 A2C 高不少,而 A2C 通常一批数据只更新一次。
理解了 A2C,PPO 的代码基本就是在它的基础上加几行。所以把 A2C 练熟是很划算的投入。
7. 常见问题与排查思路
训练不收敛是强化学习里最常见的问题,而且原因往往不止一个。下面这张表整理了 Actor-Critic 实现中最容易踩的坑,按出现频率排序。
| 问题现象 | 常见原因 | 排查与解决思路 |
|---|---|---|
| 回报长期在 10~20 徘徊不涨 | 策略过早收敛,熵过小;或 Actor 输出层初始化增益过大 | 提高 entropy_coef;检查 Actor 头是否用了小增益初始化 |
| 训练前期就崩,回报断崖下跌 | 学习率过大;未做优势标准化;未做梯度裁剪 | 降 lr 到 3e-4,加优势标准化和 0.5 的梯度裁剪 |
| 价值损失震荡不降 | Critic 学习率相对 Actor 过高或过低;奖励尺度太大 | 调整 value_coef;对奖励做缩放;检查回报计算是否有误 |
| 回合被截断后价值估计偏低 | 把 truncated 当成 terminated 处理,错误切断自举 | 收集时分别记录 terminated 和 truncated,mask 只用 terminated |
| 策略完全确定,不再探索 | 熵系数为 0 或过小;优势全为正/全为负 | 加熵正则;检查优势标准化是否生效 |
| 同一份代码换个种子就崩 | 随机性敏感,超参在边界上 | 固定多种子跑对照;把 lr 调保守一些;增加 rollout 长度 |
| GPU 上比 CPU 还慢 | 小任务数据传输开销大于计算收益 | CartPole 这类任务直接用 CPU |
| 训练中途 loss 变 NaN | 学习率过大;log_prob 出现 inf;未做数值稳定处理 | 加 eps 到优化器;检查 logits 是否溢出;降低 lr |
排查时建议按这个顺序来:先确认环境交互正确(奖励、终止标记、观测维度),再确认优势计算正确(可以打印几个样本手工核对),最后才动超参数。很多"调参调不动"的情况,根因其实是数据管线里的一个索引错位。
还有一个实用技巧:把value_loss、policy_loss、entropy、平均回合长度这四个量一起看。如果value_loss在降但回报不涨,问题多半在策略侧;如果value_loss不降,Critic 都没学好,策略侧的梯度就不可信,先修 Critic。
8. 最佳实践与工程建议
8.1 训练稳定性优先于收敛速度
初学者容易一开始就追求"快",把学习率调大、rollout 缩短,结果训练曲线剧烈震荡,根本分不清是算法问题还是超参问题。更稳妥的做法是先让训练稳定跑起来,再逐步提速。
具体来说:学习率从 3e-4 或 7e-4 起步,rollout 长度不要低于 16,优势一定做标准化,梯度一定做裁剪。这四条是底线,做到之后大部分小任务都能稳定收敛。等基线跑通了,再去做多环境并行、调大网络这类提速优化。
8.2 日志要记到能复现问题的程度
只打印平均回报是不够的。建议至少记录:每次更新的策略损失、价值损失、熵、平均回合长度、当前学习率。有条件的话把策略的动作分布熵和价值的均值也记下来。这些量在出问题时能快速定位是哪一部分失衡。
另外,随机种子、超参数配置、代码版本都要一起记录。强化学习对随机性极其敏感,没有种子记录的实验等于没做。建议把配置写成字典或 YAML,训练开始时打印一遍并存档。
8.3 代码组织与可复现性
网络定义、优势计算、训练循环分开放在不同文件里,看起来多写了几行 import,但收益在于:换算法时只需要改其中一个文件。比如你想从 A2C 换到 PPO,model.py和gae.py几乎不用动,只改训练循环里的损失部分。
另一个建议是把环境封装一层。直接在训练循环里调env.step会让代码和具体环境耦合,后面想换任务就得改多处。包一个make_env函数,统一处理种子、奖励缩放、回合统计,训练代码就能保持干净。
8.4 生产环境与安全边界
如果要把训练好的策略模型部署到真实系统,有几条底线必须守住。第一,模型输出必须经过动作边界检查,超出物理限制的动作要截断或拒绝执行,不能直接下发给执行机构。第二,任何在线学习或参数更新都必须有回滚机制和人工确认流程,绝不能让模型在无人监管的情况下直接修改生产参数。第三,训练数据和模型文件要做好版本管理,出现异常行为时能快速定位到是哪一版模型。
在仿真环境里验证充分之前,不要接入真实设备。强化学习策略的失效模式往往是"在训练分布内表现良好,一旦遇到分布外状态就出现完全不合理的行为",这类问题在仿真里很难完全暴露,必须有额外的安全兜底逻辑。
9. 总结与学习路线
回过头看,Actor-Critic 的核心其实就三件事:用一个策略网络决定动作,用一个价值网络提供基准,用优势值把两者串起来。网络结构、损失系数、GAE 参数都是在这三件事上做的工程调整,理解了主干,剩下的变体都是可以推导出来的。
如果你已经跑通了本文的 CartPole 示例,下一步建议按这个顺序推进。先把手写实现改成多环境并行版本,体会同步收集对训练速度的影响;然后把策略损失换成 PPO 的截断形式,观察同一批数据多轮更新的效果;接着把动作空间换成连续型,在 Pendulum 上跑一遍高斯策略版本,这会逼你处理动作缩放和标准差参数化的问题;最后再去看 SAC、DDPG 这类离线策略的 Actor-Critic 变体,理解它们如何通过经验回放提升样本效率。
每一步都不需要从头造轮子,但一定要自己动手改代码、看曲线、调参数。强化学习的很多直觉是看文档看不出来的,只有亲自经历过一次"改了某个系数训练就崩了",下次才知道那个系数的敏感边界在哪里。
最后提醒一句:训练日志和随机种子一定要留着。当你哪天想复现一个"当时跑得特别好"的结果,却发现自己没存种子的时候,那种感觉确实不太好受。