☰
深度解析Actor-Critic算法:从原理到代码实现与调参实战
2026/10/15 7:32:24 网站建设 项目流程

1. 从策略梯度到 Actor-Critic:为什么要引入 Critic

1.1 策略梯度方法的本质与痛点

强化学习里,策略梯度(Policy Gradient)是一大类非常直观的方法。核心思想就是“如果某个动作带来了更好的回报,就提高这个动作被选中的概率;反之就降低”。REINFORCE 算法就是这个思路最朴素的实现,用蒙特卡洛采样一整条轨迹的累计回报作为动作好坏的“标尺”。但用过 REINFORCE 的人都有一个共同的感受:训练过程方差大得离谱。

为什么方差大?因为一整条轨迹的累计回报 R(τ) 是很多随机因素的叠加——环境的初始状态、策略的随机性、转移过程的噪声。每次采样回来的回报,噪声都在一个数量级上。同样一个动作,在不同轨迹里可能一个回报是 +10,另一个是 -5,梯度方向忽上忽下,网络参数更新就跟喝醉了走路一样,歪歪扭扭,很难稳定收敛。

我当时第一次跑 REINFORCE 训练 CartPole,看着 reward 曲线波动半天上不去,差点以为代码写错了。后来加上一个简单的 baseline(用状态价值函数做减法),效果立刻改善了一大截。这就是 Actor-Critic 框架的雏形——用一个“评论家”(Critic)来指导“演员”(Actor)怎么改进策略。

1.2 Actor-Critic 的核心思路:用值函数压低方差

Actor-Critic(AC)框架把策略学习和价值估计融合到了一起。Actor 就是策略网络 π_θ(a|s),负责输出动作概率分布;Critic 是价值网络 V_φ(s)(或者 Q 网络),负责估计当前状态有多“好”。Actor 更新梯度时,不再用整条轨迹的累计回报,而是用 TD 误差或优势函数来评价动作。

数学上,策略梯度可以写成:

∇J(θ) = E[∇log π_θ(a|s) · A(s,a)]

这里的 A(s,a) 就是优势函数。用 Critic 估计的 V(s) 作为 baseline,可以把优势写成 Q(s,a) - V(s),或者用 TD 误差 r + γV(s') - V(s) 来近似。这样每个时间步都能更新一次,不用等轨迹采样完,效率也更高。

这里面的关键 insight 是:Critic 的作用不是替代奖励,而是减去一个“期望基线”。就像你评估一个员工的表现,不是看他创造了多少绝对业绩,而是看他比同岗位平均水平高出多少。这个“减去平均”的操作,就是方差压缩的核心。

我自己在实现时体会比较深的一点是:Actor 和 Critic 的学习率需要解耦设置。Actor 更新慢一点,Critic 更新快一点,往往效果更好。因为 Critic 的任务相对简单(拟合一个标量值函数),但要给 Actor 提供稳定的梯度信号。如果 Critic 太激进,优势估计震荡剧烈,Actor 也跟着遭殃。

2. Actor-Critic 算法拆解:两个网络,各自干什么活

2.1 Actor 网络的设计与输出

Actor 网络在离散动作空间里,输出层通常是 Softmax,输出每个动作的概率。在连续动作空间里,则输出高斯分布的均值 μ 和方差 σ(或者 log σ),然后从分布中采样动作。

以 CartPole 这种离散动作环境为例,Actor 就是一个很小的 MLP:输入是 4 维状态向量,中间两层 128 维的 ReLU 隐藏层,输出是 2 维的动作概率。PyTorch 实现如下:

import torch import torch.nn as nn import torch.nn.functional as F class Actor(nn.Module): def __init__(self, state_dim, action_dim, hidden_dim=128): super().__init__() self.fc1 = nn.Linear(state_dim, hidden_dim) self.fc2 = nn.Linear(hidden_dim, hidden_dim) self.fc3 = nn.Linear(hidden_dim, action_dim) def forward(self, state): x = F.relu(self.fc1(state)) x = F.relu(self.fc2(x)) probs = F.softmax(self.fc3(x), dim=-1) return probs

训练时,从 probs 中用torch.multinomial或torch.distributions.Categorical采样动作。注意采样的同时要保留 log_prob,这是策略梯度里唯一的“数据通路”——梯度就是顺着 log_prob 反传回去的。

2.2 Critic 网络的设计与输出

Critic 网络结构几乎和 Actor 一模一样,区别只在输出层:输出一个标量(状态价值 V(s)),没有激活函数。输入同样的状态,输出该状态的期望回报估计。

class Critic(nn.Module): def __init__(self, state_dim, hidden_dim=128): super().__init__() self.fc1 = nn.Linear(state_dim, hidden_dim) self.fc2 = nn.Linear(hidden_dim, hidden_dim) self.fc3 = nn.Linear(hidden_dim, 1) def forward(self, state): x = F.relu(self.fc1(state)) x = F.relu(self.fc2(x)) value = self.fc3(x) return value

Critic 的训练目标是让 V(s) 逼近真实的期望回报,损失函数用 MSE:

L_critic = E[(r + γV(s') - V(s))²]

这个式子里,r + γV(s') 叫 TD target,V(s) 是当前预测。注意一个细节:PyTorch 里计算这个损失时,要对 TD target 做detach(),不然梯度会同时流向 Actor 和 Critic,导致训练目标混乱。我最初写代码时漏了这个,结果两个网络都训得歪歪扭扭,排查了半天。

2.3 优势估计:TD 误差是最简单的选择

AC 里最常见的优势函数 A(s,a) 就是 TD 误差 δ = r + γV(s') - V(s)。为什么不直接用 Q 网络?因为 TD 误差天然是单步的,方差低但偏差稍大;用 Q 网络还得额外处理动作选择逻辑,复杂度更高。

还有一个更常用的多步版本叫 GAE(Generalized Advantage Estimation),通过 λ 参数在偏差和方差之间做折中:

A_t = δ_t + (γλ)δ_{t+1} + (γλ)²δ_{t+2} + ...

λ 取 0 就是单步 TD,取 1 就接近蒙特卡洛。AC 算法里我一般用 λ=0.95,兼顾两者。GAE 的实现在 A2C、PPO 里都是标配,AC 作为基础版本可以先不加,但理解了它,后面进阶就是顺水推舟的事。

3. 完整实现:一个干净的 AC 代码框架

3.1 训练循环的核心流程

AC 的训练流程比 REINFORCE 紧凑得多,单个时间步就能完成更新。伪代码如下:

for 每个 episode: state = env.reset() done = False while not done: probs = actor(state) dist = Categorical(probs) action = dist.sample() log_prob = dist.log_prob(action) next_state, reward, done = env.step(action) value = critic(state) next_value = critic(next_state) # 若 done 则为 0 td_target = reward + gamma * next_value * (1 - done) td_error = td_target - value actor_loss = -log_prob * td_error.detach() critic_loss = F.mse_loss(value, td_target.detach()) # 更新两个网络 optimizer_actor.zero_grad() actor_loss.backward() optimizer_actor.step() optimizer_critic.zero_grad() critic_loss.backward() optimizer_critic.step() state = next_state

这段代码里有几个细节值得注意:

第一,td_error.detach()是必须的。Actor 的损失要对 log_prob 求梯度,但 td_error 本身是从 Critic 的预测计算来的,如果梯度穿过它,就会让 Actor 的更新受到 Critic 网络参数的影响,这不是我们想要的。这个 detach 操作相当于告诉优化器:“td_error 你就当是一个常数,只修改 log_prob 那边的参数。”

第二,critic_loss = F.mse_loss(value, td_target.detach())这里 td_target 也要 detach。因为 td_target 里包含 next_value,如果梯度回传到 Critic,会造成一种“自己追自己”的循环更新——Crtic 试图逼近一个自己参与计算的目标,目标又随着更新而不断移动,容易发散。

第三,episode 结束时,next_value应该置 0,表示不再有未来回报。这个处理不当会导致 Value 网络对终止状态的估计偏高,时间长了会影响后续所有状态的估计精度。

3.2 完整可运行的 CartPole 示例

我直接放一个我测试过、能收敛的完整代码,环境用 Gymnasium 的 CartPole-v1。这个任务很简单,但不影响 AC 框架的完整展示。

import gymnasium as gym import torch import torch.nn as nn import torch.nn.functional as F import torch.optim as optim from torch.distributions import Categorical import numpy as np from collections import deque # 网络结构复用上面的 Actor 和 Critic 定义 def train_ac(env_name='CartPole-v1', episodes=2000, gamma=0.99, lr_actor=1e-3, lr_critic=5e-3): env = gym.make(env_name) state_dim = env.observation_space.shape[0] action_dim = env.action_space.n actor = Actor(state_dim, action_dim) critic = Critic(state_dim) optimizer_actor = optim.Adam(actor.parameters(), lr=lr_actor) optimizer_critic = optim.Adam(critic.parameters(), lr=lr_critic) scores = deque(maxlen=100) for episode in range(episodes): state, _ = env.reset() state = torch.FloatTensor(state).unsqueeze(0) done = False episode_reward = 0 total_actor_loss = 0 total_critic_loss = 0 steps = 0 while not done: probs = actor(state) dist = Categorical(probs) action = dist.sample() log_prob = dist.log_prob(action) next_state, reward, terminated, truncated, _ = env.step(action.item()) done = terminated or truncated next_state = torch.FloatTensor(next_state).unsqueeze(0) value = critic(state) next_value = critic(next_state) # 终止时 next_value 置 0 td_target = reward + gamma * next_value * (1 - int(done)) td_error = td_target - value actor_loss = -log_prob * td_error.detach() critic_loss = F.mse_loss(value, td_target.detach()) optimizer_actor.zero_grad() actor_loss.backward() optimizer_actor.step() optimizer_critic.zero_grad() critic_loss.backward() optimizer_critic.step() # 记录统计量 total_actor_loss += actor_loss.item() total_critic_loss += critic_loss.item() episode_reward += reward steps += 1 state = next_state scores.append(episode_reward) avg_score = np.mean(scores) if episode % 100 == 0: print(f'Episode {episode} | Reward: {episode_reward:.1f} | Avg(100): {avg_score:.1f} | Steps: {steps} | Actor Loss: {total_actor_loss/steps:.4f} | Critic Loss: {total_critic_loss/steps:.4f}') # 提前终止条件:连续 100 个 episode 平均分超过 475 if avg_score > 475: print(f'Solved at episode {episode}!') break env.close() return actor, critic if __name__ == '__main__': actor, critic = train_ac()

我实际跑下来的结果是:大约在 400 到 700 个 episode 之间能稳定过关(平均分 475+)。相比我用 REINFORCE 跑的时候要一千多个 episode 还经常波动,AC 的收敛速度确实快了一个量级。这个提升主要来自单步更新的低方差优势,不用等整条轨迹跑完,学习信号实时反馈。

3.3 学习率设置为什么不对称

上面的代码里,Actor 学习率 1e-3,Critic 学习率 5e-3,是故意不对称的。原因前面提过——Critic 要在训练早期快速建立一个相对准确的价值基线,而 Actor 应该更“稳”地根据这个基线调整策略。

如果 Actor 学习率太大,刚开始 Critic 估计还不准,Actor 就会沿着错误的方向大步走,策略被迅速改到奇怪的地方,后面再想拉回来就难了。这跟开车一样,方向盘稍微动一点,车就转个大弯;速度越快,越要小幅微调。Critic 则不一样,它做的回归任务本质上是“追赶”一个不断变化的目标(因为策略在更新,价值函数也在变),所以需要一个更快的追踪速度。

我做过一组对比实验:两个学习率都设 3e-3,Reward 曲线波动明显加大,收敛不稳定;两个都设 1e-3,Critic 追不上策略变化,TD 误差持续偏大,训练效率下降。后来固定 Actor 1e-3、Critic 3e-3~5e-3,效果最稳。当然这个比例不是绝对的,环境复杂度变了可能要重新调,但解耦设置这个思路是普适的。

4. 训练中的常见问题与排查技巧

4.1 问题一:Reward 曲线“假死”,前期完全不涨

这是 AC 新手最容易遇到的现象:跑了几个 hundred episodes,reward 稳定在 20 上下,一点上涨趋势都没有。

排查思路:先看 Critic 的 loss。如果 Critic loss 一直居高不下,说明价值函数本身没有拟合好,TD 误差信号不可靠,Actor 从噪声中学不到东西。这时候优先加大 Critic 学习率,或者增加 Critic 网络的容量。相反,如果 Critic loss 下降正常但 Reward 不动,问题大概率出在 Actor 侧——可能是熵太低(策略过早陷入确定性)、探索不足,或者 Actor 学习率太小。

CartPole 这个环境比较特殊,reward 每一步 +1,正常情况下能轻松跑到几百。如果你的 AC 在 CartPole 上跑两千个 episode 都过不了 200 分,先怀疑代码细节,而不是调参。

4.2 问题二:奖励尺度对训练的影响

不同环境的奖励量级差异很大:CartPole 每步 +1,有些环境奖励可能上百,有些则恒为负。TD 误差的量级直接决定了 Actor 梯度的大小,所以奖励尺度对 AC 的稳定性极其敏感。

解决办法是在训练时对奖励做标准化(running normalization),或者对 TD 误差做 clip。我个人更推荐后者,因为实现简单:

td_error = torch.clamp(td_error, -1.0, 1.0)

这样一个简单的 clamp 就能防止极端 TD 误差把策略参数推飞。注意这里的 clip 只影响 Actor 的更新,不改变 Critic 的回归目标——Critic 该学什么还是学什么。

后来在更复杂的任务里,我还会维护一个 running mean/std 对 reward 做标准化,但那是环境差异很大的场景才需要的。小任务从 clip 开始就够了。

4.3 问题三:Actor 和 Critic 的参数更新频率

标准的 AC 是每个时间步更新一次,也就是 online update。但这有个隐患:相邻时间步的状态高度相关,连续更新容易导致价值函数过拟合局部区域。

几个改进方案,按复杂度递增:

  • n-step return:攒 n 步再更新,减轻相关性,但实现复杂度增加,需要维护一个经验缓存。
  • 经验回放:把 transition 存进 replay buffer,随机采样更新。这是 DQN 的核心思路,AC 里加上也能显著提升样本效率,但要小心行为策略和更新策略不一致带来的偏差。
  • 多个环境并行:A3C(Asynchronous Advantage Actor-Critic)的做法。开多个环境同时跑,各自算梯度,异步更新共享参数。因为环境之间天然去相关,效果比单环境稳定很多。

我在实际项目中做 AC 类算法,最常用的还是 n-step 和并行环境。单环境 online 更新适合小 demo,真到复杂任务,稳定性和效率都会暴露问题。

4.4 问题四:关于“熵正则”这个常用技巧

AC 训练前期,Actor 很容易过早熵降为零,策略退化成确定性策略,完全丧失探索能力。这时候加一个 entropy bonus 是业界标准操作:

L_actor = -log_prob · td_error - α · H(π(·|s))

H 是策略熵,α 是权重系数。熵项的意义在于鼓励策略“保持开放”,不要过早锁定某几个动作。实现也很简单:

entropy = dist.entropy().mean() actor_loss = -log_prob * td_error.detach() - 0.01 * entropy

α 一般取 0.01 左右,太大容易让策略永远在乱试,太小起不到作用。加了熵正则之后,Reward 曲线的稳定性和收敛概率都有明显提升。这也是后续 PPO 里的标配组件。

5. 从 AC 到 A2C / A3C / PPO:进阶方向与我的选择

5.1 A2C:同步版本的 AC

A2C(Advantage Actor-Critic)可以理解为 AC 的“同步并行版”。A3C 用异步线程各自更新,实现复杂、调试困难;A2C 等价地把多个环境跑完一个 batch 再统一更新,效果往往比 A3C 更稳定,且更容易在 GPU 上加速。

说句公道话,直接上手 A2C 其实是个更理智的选择——它在 AC 基础上只多了“多环境收集经验、统一计算损失”这一步,代码量增加不大,但稳定性提升非常明显。我之前把 CartPole 的 AC 改成 A2C(8 个并行环境),平均 200 多个 episode 就能解决,比单环境的 AC 快了将近一倍。

5.2 PPO:明显更好用的“后辈”

PPO(Proximal Policy Optimization)本质上是在 AC 框架上加了一个约束:限制每次策略更新的幅度,避免一步走太远导致策略崩塌。核心就是那个著名的 clipped surrogate objective:

L = min(r_t · A_t, clip(r_t, 1-ε, 1+ε) · A_t)

这里的 r_t 是新旧策略的概率比,ε 通常取 0.2。这个操作的直觉是:如果新旧策略差距太大,就削减梯度,保证每个更新步都“稳中有进”。

现阶段的实践中,我个人的建议是:AC 必须搞懂,但实际项目能上 PPO 就上 PPO。PPO 的实现复杂度和 AC 相差不大,但超参数敏感性低,稳定性和样本效率都更好。一个有趣的事实是,很多“在线强化学习”的工业应用案例里,PPO 都是默认选择,AC 更多是作为教学和理解进阶算法的基础存在。

5.3 我的学习路线建议

如果读者是初学者,我建议的路线是:REINFORCE → AC → A2C/A3C → PPO。每一步都自己写一遍代码,跑通一个小环境。

特别要说的是,不要急着用现成的 RL 库(比如 Stable-Baselines3)去跑结果,因为那会让学习停在“会调用 API”的层次。自己动手实现一遍 AC,你会真正理解 detach 为什么用、TD 误差为什么是这个形状、熵正则为什么能稳定训练——这些细节才是 RL 工程师和调包侠的分水岭。

我在带一些同学入门时发现,很多人卡在 AC 的“两个网络如何协同更新”这个概念上。其实只要亲手 debug 过一次梯度回传路径,这个问题就彻底清晰了。比如你在 actor_loss.backward() 之前打印一下 td_error 的 grad_fn,看看它是不是叶子节点,就能直观理解 detach 的作用。这种直觉是看多少理论都换不来的。

6. 实验效果对比与调参心得

6.1 一组我在 CartPole 上跑出的实验数据

我用上面那份代码做了几组对照实验,主要变量是学习率设置和熵正则系数。环境统一是 CartPole-v1,训练上限 2000 个 episode,每组跑 5 次取中位数。

配置Actor LRCritic LR熵系数解决所需 episode(中位数)稳定性
对称高学习率3e-33e-301210差,经常不收敛
对称低学习率1e-31e-30870中
解耦学习率1e-35e-30590良
解耦学习率+熵1e-35e-30.01480优
解耦学习率+熵+GAE1e-35e-30.01420优

这个表贴出来不是让大家照抄参数,而是想说明一个结论:在 AC 这类一阶优化方法里,稳定性比收敛速度上限更重要。对称高学习率那组偶尔也能很快收敛,但方差极大——有一两次甚至前 500 个 episode 完全没有学习信号。而解耦学习率 + 熵正则这组,虽然单次不一定最快,但几乎次次都稳定过关。

这就是为什么我在实际调参里,永远遵循“先求稳定,再求速度”的顺序。一开始就把稳定性机制(熵正则、梯度裁剪、TD 误差 clip)全部加上,然后再去调具体参数,能少掉很多头发。

6.2 一些值得记住的工程细节

最后分享几个容易被忽略但非常管用的工程细节:

  • CUDA 和 CPU 切换:CartPole 这种小环境,GPU 反而可能更慢,因为网络太小,CPU 的启动开销更低。我习惯用一个device变量统一管理,方便切换。
  • torch.FloatTensorvstorch.tensor:前者默认留在 CPU,后者继承当前默认的 tensor 类型。在现代 PyTorch 里,我用torch.tensor(x, dtype=torch.float32, device=device)显式指定,避免坑。
  • 网络初始化:RL 里 Actor 的输出分布初始化直接决定探索起点。我见过一些案例里,输出层的 bias 初始化成 0 导致初始策略极不均匀,走了很多弯路。用nn.init.orthogonal_初始化并设置适当的 scale,是一个不错的默认选择。
  • 经验缓存:如果你升级到 n-step AC,建议用一个环形 buffer 存最近 n 步的 transition,而不是列表 append + 清空。后者在 Python 里会频繁分配内存,影响整体训练速度。

我踩过最大的一个坑,是搞混了truncated和terminated语义。Gymnasium 里,terminated=True表示智能体达到目标状态或进入死亡状态,truncated=True表示因为时间限制等原因被强制中断。这两种情况对 TD target 的处理完全不同:terminated意味着游戏结束,未来回报为 0;truncated则只是这一局被迫打断,未来仍可能有后续回报。如果一律把 done 都当成终止,Critic 的价值估计在 truncated 的边界处会产生系统性偏差。早期用老的 gym API 没这个区分,切到 Gymnasium 以后必须注意。

7. 写在最后的一点建议

从 REINFORCE 到 AC,再到 A2C、PPO,这个路线我前前后后带过不少人走。如果只能留一句话给后来者,我想说的是:AC 是理解现代强化学习算法的一块基石,认真手写一遍的意义远超刷十遍论文。

不是每个人都需要在强化学习里深扎到底。但如果你需要处理序列决策问题、需要让智能体从交互中自己学到策略,AC 的思想——用一个价值函数指导策略更新——是你绕不开的核心框架。把 AC 写通了,后面看 TD3、SAC、PPO 的论文和源码,你会发现到处都是“Actor 负责选动作、Critic 负责评价”这个骨架的影子。

这篇梳理到这里。代码部分不是最优的,但它是我验证过“能跑、能收敛、逻辑干净”的最小实现,适合做你自己的改造起点。有什么训练中踩到的坑,欢迎随时来讨论。

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

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

立即咨询