Actor-Critic、A2C与A3C:从策略梯度到优势函数的强化学习实战解析
深度强化学习这几年从学术界火到工业界但很多初学者一上来就被一堆名词劝退策略梯度、Q-Learning、Actor-Critic、A2C、A3C……一个个看文档都认识连起来就不知道它们在解决什么问题。我最早踩进这个坑时也是这样读了十几篇博客逛了无数论坛最后还是在实现CartPole的时候被方差爆炸的loss和飘忽不定的reward曲线搞得怀疑人生。这篇东西我想用一种更实在的方式把这些算法串一遍不说废话不堆公式就讲清楚Actor-Critic、A2C、A3C到底各自干了什么、为什么这样设计、实际跑的时候会碰到哪些问题。如果你已经掌握了基础的DQN、策略梯度正在试图把这些进阶算法啃下来或者已经在实验了但对训练不稳定的情况很头疼那这篇文章值得你花点时间看完。先说一句总的定调Actor-Critic是一套思想框架A2C是这个框架落地时最经典的一种“带优势函数、同步多环境”的实现方式而A3C是A2C的前身核心差别在于“异步”两个字。理解了这条线你就能看懂这个领域的绝大部分论文和开源代码。1. 为什么Actor必须配一个Critic从REINFORCE的高方差说起很多教程上来就直接扔出Actor-Critic的网络结构但我觉得不问清楚“为什么需要两个网络”就上手完全学不到精髓。要搞清楚这件事得先看一眼纯粹的策略梯度REINFORCE是怎么工作的。1.1 REINFORCE是怎么死在高方差上的REINFORCE的核心思想简单到可以用一句话概括如果某个动作让累积回报变高了就提高这个动作被选中的概率如果累积回报变低了就降低这个概率。它的更新公式可以写成θ ← θ α * ∇θ log π(a|s) * R其中R是整个回合的累积回报π(a|s)是当前策略下在状态s选择动作a的概率。这个公式看着很合理但实际操作起来问题巨大——R的方差极大。举个最简单的例子。假设你在一个环境里执行了一条完整的轨迹这条轨迹碰到了一个加分非常多的“幸运事件”最后R 1000。但实际上这个R的高分可能纯属随机运气跟当前策略好不好没啥关系。REINFORCE不管这些它会把这条轨迹里所有出现过的动作都“归功于”那个高分导致这些动作的概率被强行拉高。反之如果一条轨迹运气差明明策略不错R却很低那么策略又会被带着往坏方向猛冲。我在实现REINFORCE时就吃过这个亏。训练一个简单的连续控制任务loss曲线在一轮里能从0.2跳变到5以上reward曲线更是像心电图。这不是网络结构的问题而是REINFORCE在数学上的先天缺陷用整个回合的回报R去评价每个动作携带的噪声太大了。1.2 减去一个基线直观理解而不是背公式解决这个问题的标准思路是给R减去一个“基本盘”让评价标准从“绝对值”变成“相对值”。这个基本盘在强化学习里叫基线baseline更新公式变成θ ← θ α * ∇θ log π(a|s) * (R - b)b就是基线。关键问题是b应该选多少。最简单的一种做法是用整个训练过程中的平均回报当作b稍微进阶一点的做法是用当前状态的价值V(s)当作b。用V(s)有什么好处因为它考虑了“你本来在这个状态下就应该能拿到多少回报”。打个比方。你在一家不错的公司工作月薪3万你觉得还行但后来你发现同部门的同事能力和你差不多的普遍月薪4万你就不爽了。月薪3万这个绝对值没有意义意义在于“你面对同等级任务时比你该拿的少拿了一万”。这个“实际上应该拿多少”就是价值函数V(s)实际拿到的回报和应该拿到的回报之间的差值才是真正应该归因于你“额外动作”的部分。1.3 两个网络各司其职Actor管策略Critic管判断REINFORCE需要一张价值函数表来算“该拿多少”但在复杂环境下状态空间太大不可能查表。这时候深度学习就派上用场了用一个神经网络来拟合这个价值函数V(s)这就是Critic。而原本那个策略网络π(a|s)换了个更形象的名字叫Actor。Actor-Critic的核心逻辑到这里就清清楚楚了Actor负责“干活”输出每个动作的概率Critic负责“挑刺”估计当前状态的价值用来算“这次这个动作比平均水平好多少”。两个网络共用一套经验数据交替训练互相促进。我自己的理解是REINFORCE像一个没有老师的自学者考完试看到分数R就瞎改错也不知道哪道题该负责Actor-Critic则配了一个辅导老师Critic老师告诉你“你这道题不该错因为你现在这个水平这道题本来能得多少分”这样学习的方向就清晰多了。2. Actor-Critic的训练逻辑与工程实现细节理解了为什么需要两个网络接下来就必须落地到代码层面。这一章我会把Actor-Critic的训练循环拆开讲重点是损失函数怎么组装、网络怎么设计以及训练过程中最容易出现的坑。2.1 网络搭建从输入到输出两个头的取舍Actor和Critic可以共用底层的特征提取层只在最后一层分叉。这样设计的好处是状态特征可以被两个任务共享训练效率更高。简单的场景用两三层MLP就行输入是你的状态向量中间加个ReLU激活最后一层分出两个头Actor头输出动作的概率分布。离散动作空间用softmax输出各动作的概率连续动作空间一般输出高斯分布的均值和标准差或者对数标准差。Critic头输出一个标量代表状态价值V(s)。有一个被很多人忽视的细节Actor的最后一层建议把权重初始化设小一点。我的经验是用均值为0、标准差为0.01的正态分布来初始化Actor的最后一层这样初始策略不会太极端前期训练更稳定。如果初始策略输出太“自信”几乎确定性选择某个动作模型探索空间会变得很小。2.2 损失函数怎么组装策略损失加价值损失加熵奖励Actor-Critic的损失函数由三部分组成这是实现代码时最容易一头雾水的地方。第一部分是策略损失。它的本质还是策略梯度只是把回报R换成了优势函数A后面会详细讲A的算法形式是L_policy -log π(a|s) * A加负号的目的是把最大化问题转成梯度下降问题因为深学习惯最小化loss。第二部分是价值损失。Critic的目标是让网络输出的V(s)尽可能贴近真实的回报。一般用均方误差L_value (V(s) - target_value)²target_value最常见的是TD目标TD target就是一步实际奖励加上下一状态的折扣价值r γV(s)。当然也可以用n步回报这个后面在A2C部分会展开说。第三部分是熵正则项。它的作用是什么鼓励动作分布不要太极端保持一定探索性。做法是在策略分布上算一个熵让熵尽可能大一点。最终策略损失里减掉一个β倍的熵L_total L_policy c_v * L_value - β * Entropy这里的β系数通常在0.001到0.01之间太大会影响主任务收敛太小探索性又不足。我在做一些复杂的控制任务时会让熵系数随训练轮数线性衰减先鼓励探索后期专注利用。2.3 为什么训练中常常出现“loss降了但效果崩了”这是Actor-Critic实现里最磨人的问题。我见过太多新手包括我自己看到loss一直在下降兴高采烈结果环境回报曲线却在下跌智能体表现越来越烂。回头排查发现是这么几个原因第一个原因是策略损失和价值损失被直接相加但它们量纲差距巨大。如果价值损失算出来是几十策略损失只有零点几那梯度更新时策略网络根本学不进去。解决办法是给价值损失乘一个系数c_v常见是0.5或1.0甚至在训练初期让c_v小一点让策略集中更新。第二个原因是估计target_value时直接用了整个回合的回报Monte Carlo回报又退回了REINFORCE的高方差陷阱。这也是很多错误示例的常见写法。正确做法是用TD方法或n步回报让Critic学习一个“对未来逐步预测”的价值而不是等到回合结束才拿到最终结果。第三个原因是CVsis掉线了。。我是说critic跑飞了。如果Critic完全不收敛输出的V(s)就成了一个乱跳的噪声优势函数也跟着爆炸整个训练就完蛋了。这一般归结为网络初始化不当、reward范围太大没做归一化、或者学习率设置不合理。我之前排查过一个具体案例环境给的正奖励很大累计回报上千但Critic的MSE loss在前几十轮几乎不变原因就是reward量级太大MSE梯度撞到了比较大的数值范围训练速度极慢。把reward压缩到[-1,1]区间后效果立竿见影。3. A2CAdvantage不只是换个名字是数学上的关键跳跃A2C的全称是Advantage Actor-Critic在Actor-Critic的基础上核心变化是把“优势函数”这件事做了最正式的表述同时定义了多环境同步并行的训练方式。这一章我会把优势函数和同步运行机制讲透。3.1 优势函数A(s,a)的定义和直觉优势函数A(s,a)的严格定义是在状态s下动作a相对于平均水平来说到底好多少。数学形式是A(s,a) Q(s,a) - V(s)Q(s,a)表示“在状态s采取动作a后的期望回报”V(s)表示“在状态s的策略下的平均期望回报”。如果A大于0说明这个动作比平均好应当提高概率如果A小于0说明这个动作比平均差应当降低概率。问题来了我们并不直接知道Q(s,a)的值怎么算优势这时候就轮到TD误差登场。经典的估计方式是A(s,a) ≈ r γV(s) - V(s)这个式子有个很直观的名字叫TD误差δ。它其实是用一步的即时反馈加上对未来的一个前瞻来估计“这个动作在某个状态下究竟强在哪”。相比直接拿整条轨迹的R来算TD误差的方差小了很多因为每一步的实际奖励 r 是一个短期的确定反馈而未来的长期回报交给Critic预测牺牲了一点点偏差换来了巨大的方差缩减。简单说就是用一点偏差换巨大的方差缩减这也是A2C相比朴素Actor-Critic最主要的改进点。3.2 GAE多步估计与λ参数怎么调只看一步TD误差有另一个问题偏差偏大。因为V(s)是由Critic预测的而预测总有不准确的地方。如果Critic估计得不好那么每一步的优势函数都带有系统性偏差。于是有了GAEGeneralized Advantage Estimation也就是广义优势估计。GAE的核心思路是把1步、2步、3步……直到n步的TD误差都算出来然后按系数加权求和。权重由参数λ控制λ的值在0到1之间。当λ 0时GAE退化成1步TD误差当λ 1时GAE接近蒙特卡洛回报的优势版本。λ越大引入的方差越大但偏差越小λ越小方差越小但偏差越大。实际调参的经验是λ取0.95到0.99比较常见。我做Atari游戏时用0.95做连续控制任务时用0.98效果都不错。要注意的是λ太大时优势函数可能还是会突然变大如果配合上梯度裁剪grad clipping会更稳妥。GAE的具体计算过程是递归的从轨迹最后一步往前倒推每步维护一个累计项把当前步的TD误差与下一个步的累计项按γλ加权相加。这一点在实现了多次之后我的体会是不要试图一次把所有步骤外的项都显式算出来用递归式写代码会简洁得多。3.3 A2C的同步并行多环境收集是怎么组织的A2C中的2C指两个CCritic和Consistency——不对其实就是两个C的缩写重复全称是Advantage Actor-Critic没有额外含义。但是A2C相比单环境的Actor-Critic还有一个工程层面的提升并行采样。A2C同时开N个环境实例每个环境独立跑各自跑一小段轨迹比如n步然后把收集到的数据集中起来合并成一个batch统一更新一次模型。这个设计解决了两个问题一是增大了batch size梯度估计更稳定二是各个环境初始状态不同、探索路径不同数据更多样相关性更低。注意A2C是同步的N个worker都跑完各自的n步汇总之后大家再同步更新模型参数再继续下一轮。整个流程像军训齐步走每个人必须等队友走完一步才迈下一步。我最初写A2C时用一个简单的环境向量化包装N设成8每个worker跑5步总共40条数据更新一次模型。这个配置在CartPole上几分钟就能收敛在稍微复杂一点的任务上也比单环境Actor-Critic稳定得多。4. A3C异步并行的理想与现实以及你搜到的那些缺点A3C全称是Asynchronous Advantage Actor-Critic。它在2016年由DeepMind提出当时在Atari游戏上取得了很震撼的效果。这一章我想认真说说它的设计动机、工作流程还有为什么后来大家反而不太用A3C、更愿意用A2C了。4.1 异步的出发点探索、效率和多样性在A3C之前的深度学习强化学习很多方法用经验回放来打破数据相关性。但A3C提出了一个更激进的思路不上经验回放直接用异步并行来做多样性和解相关。具体做法是开多个线程比如16个每个线程维护自己的环境实例和一份模型参数拷贝。每个线程独立地和环境交互算梯度然后把梯度异步地推送到一个全局模型上更新参数。之后该线程再从全局模型拉取最新参数继续采样、算梯度如此循环。这个设计的精妙之处同时也藏着它的隐患不同线程各自处于不同的状态分布里有的在环境的早期阶段有的在后期阶段有的刚好碰上了稀有事件——这种天然的非相关性替代了经验回放的作用。而且由于各线程异步推进整体数据采集效率很高单机多核CPU就能跑像Atari这种观测是视觉输入的任务用CPU多线程并行就取得了当时非常亮眼的成绩。4.2 A3C的训练流程全局参数与worker线程怎么协作把A3C拆解成流程大概是这么一套逻辑每个线程循环执行以下步骤从全局网络拉取最新参数Actor头、Critic头、共享层。在本地环境中跑不定长度的轨迹通常是t_max步比如20步或者一直跑到回合结束。计算n步TD误差得到每一步的优势估计。计算策略梯度、价值损失、熵正则得到本地累积梯度。把梯度异步推送到全局网络更新全局参数。这里有个细节很关键异步情况下不同线程更新时用的参数版本是不一样的。线程A可能正在基于第100版的参数算梯度而全局网络已经被线程B更新到了第105版。这个“梯度陈旧”正是A3C最大的痛点之一。我之前在自己的项目里复现过A3C16线程跑CartPole。刚开始以为能轻松吊打A2C结果训练曲线震荡得厉害最终收敛速度和稳定性还不如同配置的同步版本A2C。后来查了一些资料和实践经验才明白异步机制在工程实现上制造了不小的麻烦这也是为什么后来的研究大多朝着同步方向走包括OpenAI等团队也逐渐用A2C和后续的PPO替代A3C。4.3 A3C实战缺点梯度陈旧、训练不稳定、复现困难为什么大家最终回到A2C要理解为什么A3C逐步被A2C取代那还得看看A3C在实际使用时暴露的种种问题。第一个显著问题是梯度陈旧。线程在推梯度时用了旧参数但这些梯度被直接应用在最新参数上可能会导致更新方向不准确甚至相互抵消。当线程数量越多这种陈旧问题就越容易出现。第二个问题是训练不稳定。异步设置的超参数对线程数、环境复杂度、网络大小都很敏感。线程数太多时更新频率过高模型容易震荡线程数太少又体现不出异步优势。在不同的任务上你往往需要重新调整线程数等配置调参成本很高昂。第三个问题是复现困难。异步本质上引入了大量不可控的时序因素线程调度顺序、系统负载、甚至CPU核心数都会影响结果。同一个代码、同一个随机种子换一台机器跑出来的结果可能差别很大。这种“玄学”对研究和工程项目而言都是噩梦。第四个问题是更新频率不均衡。有些线程可能因为环境状态复杂跑一步耗时较长有些线程却很快。这样导致快线程和慢线程对全局参数的更新之间存在错位纠缠损失函数耦合严重。想要调试清晰的损失曲线在A3C里比较困难。这些缺点综合在一起让后来的研究者意识到A3C的异步并行的“随机性”并不完全是个好东西。反而同步版的A2C虽然看起来每个worker可能浪费一点等待时间但胜在稳定、可复现、调参友好。很多论文开始改用A2C或PPO之后训练曲线一下子正常多了。5. 我把这些算法跑出最好效果的几个调试心得这一章我想写点更贴近实际的东西代码结构、超参选择、以及我在大量训练后总结出来的排错经验。如果你正准备开始动手实现A2C或A3C这部分应该能帮你少走很多弯路。5.1 学习率、网络初始化这些不起眼但致命的设置很多人都栽在最平凡的设置上。学习率是个典型Actor和Critic虽然是共用一个优化器更新但两者适合的学习率其实不太一样。Critic的回归任务通常可以承受更大的学习率Actor的策略更新则需要小心的学习率。如果二者必须共用一个学习率我的建议是宁小勿大太大会让策略跳变太剧烈训练直接崩盘。网络初始化上前面提到的Actor最后一层小标准差初始化非常重要。我自己实验过两种初始化的对比共用一个优化器、均方差初始化的情况下训练经常在前1000步就遇到NaN改成小方差初始化后同样任务稳定跑完。此外梯度裁剪是一个性价比超高的稳定性兜底方案。用Adam优化器配合梯度裁剪比如max_norm设置为0.5或1.0能很大程度避免“一两个极端样本毁掉整个模型”的悲剧。5.2 unroll长度、熵系数、GAE参数怎么搭配这几个超参数决定了训练样本的“时间分辨率”和“探索节奏”。unroll长度也就是每个worker连续采样多少步才做一次更新。太短比如1步训练频率高但偏差大不过这偏差来自价值估计不准确太长比如50步更接近蒙特卡洛方差又上来了。我的经验是10~20步是多数任务的甜点区间。熵系数的初始值和衰减策略也很关键。我见过一些人从头到尾固定熵系数为0.01结果前期探索不足后期又过于随机。比较好的做法是设定一个初始值比如0.01训练到某个阈值后乘一个衰减因子如0.995甚至动态观察熵值来调整。熵值本身如果掉到接近0就意味着策略收敛到“一条路走到黑”了再不干预可能就陷入局部最优。GAE的λ参数前面提过我再补充一个调参技巧如果训练过程中经常出现巨大的梯度更新适当减小λ比如从0.98降到0.95利用价值函数的偏差来抵消优势函数的长尾噪声比单纯调学习率更有效。5.3 如何判断你的实现是否正确从结果倒推问题实现这些算法时最让人崩溃的是程序没有报错但智能体就是学不会。判断是代码逻辑出错还是超参不对我有几个实用方法。第一个方法是先拿一个非常简单的环境跑通全流程不要一上来就挑战复杂环境。CartPole或者一个小型GridWorld环境是第一选择。简单环境里如果模型能在几千步内达到接近满分的reward说明核心代码逻辑是通的。如果简单环境都跑不动大概率是更新逻辑写错了优先检查损失函数符号是否写反、优势估计是否算对。第二个方法是观察价值网络的收敛曲线。一个健康的训练过程Critic的loss应该呈下降趋势同时环境中平均reward应该缓慢攀升。如果reward没升但Critic的loss掉得很快说明Critic在拟合一个固定不变甚至退化中的策略此时要怀疑探索能力是否不足。如果Critic loss震荡剧烈多半是优势估计的方差太大回到GAE参数或reward归一化上找问题。第三个方法是做一个“随机策略对照实验”。把你的模型替换成随机策略跑一遍如果随机策略和环境随机性带来的基线reward很高那你的任务本身就简单到不值得用深度强化学习从侧面反映训练结果可能具有迷惑性。第四个方法是用reward scale排查。假如你的环境奖励范围是[-1,1]你发现训练前期平均reward一直在0附近不必慌张但如果你发现reward在训练早期突然闪烁到极大值比如500那你的优势估计很可能被某个极端样本带偏了应该检查回报计算和折扣因子的实现。最后我想说一个我自己的体会这些算法没有一个能真正“开箱即用”每个环境都有自己的“脾气”。把代码搭起来只是第一步真正拉开差距的是你怎么观察训练曲线、怎么根据现象调整配置。A2C和A3C都只是一套框架它们不能替你解决问题但它们能帮你更高效地逼近问题的答案。