资讯动态

从策略梯度到A2C:Actor-Critic家族推导与TensorFlow 2.0实现

发布时间:2026/10/14 17:50:32 来源:尧图企业网站定制
这段时间集中刷了几篇强化学习论文越看越觉得很多同学一上来就扑向PPO或者SAC反而少了一条关键的推导链策略梯度Policy Gradient是怎么一步一步变成Actor-Critic再到A3C和A2C的。我自己也是踩了不少坑才把这条主线串起来。这次打算开一个论文阅读系列第一篇就用这个标题把从PG到AC、A3C、A2C的理论推导和TensorFlow 2.0代码实现一起整理出来。如果你是刚接触深度强化学习、已经看过DQN但被策略梯度一堆公式劝退或者想跑通一个能用的AC类算法但不知道从哪下手那这篇文章基本就是按你的需求写的。我会尽量把“每个公式为什么长这样”讲清楚而不是只丢结论。代码部分用TensorFlow 2.0实现了一个可以跑在CartPole上的A2C完整可复现。1. 为什么我选择这条“论文阅读”路线1.1 从DQN转向策略梯度的原因我最早接触深度RL是从DQN开始的网络输出每个动作的Q值然后选最大那个。这套逻辑在离散动作空间里挺好用但一旦遇到连续控制问题就卡住了动作空间太大甚至无限argmax根本没法做。更麻烦的是DQN虽然能通过经验回放稳定训练但它在策略上天然是off-policy的后续想加一点策略约束各种不适应就冒出来了。于是我把目光转回策略梯度这条线。策略梯度不显式估计动作值函数然后取max而是让网络直接输出动作分布、在这个分布上采样再根据回报好坏调整概率。这种方式天生适合连续控制而且policy本身就在网络结构里后续加熵正则、KL约束都顺理成章。可以说从PG到AC、A3C、A2C甚至到PPO和SAC这条线把深度RL的一大半算法都串起来了。1.2 这条主线的知识地图很多教程喜欢按年份陈述2014年出了什么2016年又出了什么。但我更推荐“缺陷驱动”的读法也就是每一步都先搞清楚上一个方法哪里不够用下一个方法到底修了什么。PGPolicy Gradient直接用轨迹总回报加权无偏但方差爆炸。Baseline引入状态相关基线在不改变期望的前提下压低方差。Actor-Critic用价值网络充当基线优势估计器方差进一步降低。A3C/A2C解决on-policy训练时的采样效率问题前者异步后者同步。GAE给优势估计加了一个可以调节偏差方差权衡的旋钮。读完这条线再去看PPO你会发现PPO其实就是A2C/GAE再加一个对策略更新幅度的约束理解成本会低非常多。所以这篇系列第一篇我先把地基打扎实。2. PG推导REINFORCE估计量、baseline与高方差困局2.1 目标函数与一句关键的log-derivative trick策略梯度要优化的目标一般写成期望回报$$ J(\theta) \mathbb{E}{\tau \sim \pi\theta} \left[ \sum_{t0}^{T-1} \gamma^t r_t \right] $$这里 $\tau$ 是轨迹也就是状态、动作、奖励的序列。我们希望让 $\theta$ 朝着增大 $J(\theta)$ 的方向移动所以要求梯度。但问题在于轨迹是从环境采样得到的里面有环境转移的随机性也有策略自身的随机性没法直接对采样过程求导。解决办法是大家熟知的log-derivative trick。对策略概率求导时做一步变换$$ \nabla_\theta \pi_\theta(a|s) \pi_\theta(a|s) \nabla_\theta \log \pi_\theta(a|s) $$这个式子看起来就是简单的导数恒等式但它把“对策略函数求导”变成了“对log概率求导”而log概率是网络可以直接给的采样时也能算出来。把整个轨迹的展开写进去经过几行推导可以得到$$ \nabla_\theta J(\theta) \mathbb{E}{\tau \sim \pi\theta} \left[ \sum_{t0}^{T-1} \nabla_\theta \log \pi_\theta(a_t|s_t) \cdot R(\tau) \right] $$这里的 $R(\tau)$ 是整个轨迹的累计折扣回报。这个形式就是REINFORCE估计量的基础也是策略梯度所有后续变体的出发点。2.2 baseline为什么能压方差而无偏REINFORCE直接用 $R(\tau)$ 做权重理论上没问题但实践上方差大到没法收敛。原因是 $R(\tau)$ 是一连串随机变量的和每一步的策略随机性和环境随机性都会叠加进去。同一个状态下即使策略完全相同跑两条轨迹得到的回报也可能天差地别。这时候梯度方向基本被噪声主导网络根本学不出稳定信号。一个经典修法是为每个状态减去一个基线 $b(s)$$$ \nabla_\theta J(\theta) \mathbb{E}{\tau \sim \pi\theta} \left[ \sum_{t0}^{T-1} \nabla_\theta \log \pi_\theta(a_t|s_t) \cdot \left(R_t - b(s_t)\right) \right] $$为什么减去基线不改变期望因为$$ \mathbb{E}{a \sim \pi\theta} \left[ \nabla_\theta \log \pi_\theta(a|s) \cdot b(s) \right] b(s) \sum_a \nabla_\theta \pi_\theta(a|s) b(s) \nabla_\theta 1 0 $$也就是说基线项对梯度的期望贡献是零它不会让策略往错误方向偏移但能大幅降低不同轨迹之间的波动幅度。这一步其实就是AC的雏形如果不想要方差就引入一个和状态相关、能预测“大概能拿多少回报”的基线函数。2.3 从R到Q留给AC的伏笔继续往下想$R_t$ 本质上是对“当前状态下采取某个动作后能拿到多少回报”的一次采样估计。如果我们能直接估计这个量的期望那就更稳了。这个期望就是动作价值函数 $Q^\pi(s,a)$。于是策略梯度可以改写成$$ \nabla_\theta J(\theta) \mathbb{E}\left[ \nabla_\theta \log \pi_\theta(a|s) \cdot Q^\pi(s,a) \right] $$但 $Q^\pi(s,a)$ 本身也是未知的。该怎么办最直接的想法是用另一个网络去拟合它。到这里AC框架的逻辑就自然浮出水面了一个网络负责策略一个网络负责价值估计。下一章详细讲这个切换过程。3. Actor-Critic登场用价值函数改写策略梯度3.1 Critic的“裁判”定位Actor-Critic这个命名非常形象。Actor就是策略网络 $\pi_\theta$它负责决定动作像一个上场比赛的选手。Critic就是价值网络 $V_\omega(s)$它负责打分像一个站在场边的裁判“这个状态下你平均能拿多少分我心里有数。”关键是Critic给出的分数不是用来直接选动作的而是作为PG公式里的baseline和优势估计。前面说了baseline应该取 $V(s)$ 最好那么优势函数自然定义为$$ A^\pi(s,a) Q^\pi(s,a) - V^\pi(s) $$它的含义是“在状态 $s$ 下选动作 $a$相对平均水平到底好多少”。如果好就让这个动作的概率增大如果不好就压低概率。这个直觉比直接用回报加权要清晰得多训练也稳定得多。此时策略梯度写成$$ \nabla_\theta J(\theta) \mathbb{E}\left[ \nabla_\theta \log \pi_\theta(a|s) \cdot A^\pi(s,a) \right] $$3.2 优势函数和TD误差的连接问题来了$A^\pi(s,a)$ 需要同时知道 $Q$ 和 $V$如果各用一个网络结构复杂且方差更大。有没有办法只用一个价值网络就近似优势有答案就是TD误差。定义$$ \delta_t r_t \gamma V_\omega(s_{t1}) - V_\omega(s_t) $$对它取条件期望$$ \mathbb{E}\left[\delta_t \mid s_t, a_t\right] r_t \gamma \mathbb{E}\left[V_\omega(s_{t1})\right] - V_\omega(s_t) \approx Q^\pi(s_t, a_t) - V^\pi(s_t) A^\pi(s_t, a_t) $$这个近似非常实用Critic只需要维护一个价值网络 $V_\omega$它一边充当baseline一边通过TD误差充当优势估计器。Actor拿这个优势值去更新策略Critic自己则用TD目标去回归。训练时两个网络交替优化整体就是一个标准的AC结构。这里要特别注意一个细节TD误差使用了bootstrap也就是拿 $V_\omega(s_{t1})$ 去估计未来回报。bootstrap的好处是方差低、学习快坏处是引入了估计偏差尤其是价值网络还不准的时候。这也是后来GAE等一堆改进要解决的问题。3.3 偏差与方差的权衡GAE的思路如果完全走蒙特卡洛用整条轨迹的真实回报作为目标无偏但方差大如果完全走一步TD方差小但有偏。怎么在两者之间挑一个平衡点A3C论文里用的办法是n-step回报也就是向前看 $n$ 步剩下的用价值函数bootstrap$$ R_t \sum_{i0}^{n-1} \gamma^i r_{ti} \gamma^n V_\omega(s_{tn}) $$它能综合多步真实奖励和最终的价值估计效果比单步TD好很多。后来的GAEGeneralized Advantage Estimation更进一步把1-step、2-step、一直到无穷步的优势估计用 $\lambda$ 指数加权组合起来$$ A_t \sum_{l0}^{\infty} (\gamma\lambda)^l \delta_{tl} $$$\lambda0$ 时退化成一步TD$\lambda1$ 时接近蒙特卡洛。理解这条线之后你会发现GAE其实就是给AC加了一个可调节的偏差方差旋钮。这也是为什么后来PPO、TRPO都离不开GAE。4. A3C与A2C异步并行和同步更新的设计哲学4.1 论文动机相关性与采样效率的双重压力AC框架解决了方差问题但on-policy算法还有一个原生痛点每次更新完策略刚才采的那些轨迹就“过期”了不能像DQN那样存进经验回放池反复用。这就导致训练时每更新一步都要重新采样环境交互速度直接成为瓶颈。你可能会问DQN的经验回放不是能解决相关问题吗确实可以但它有一个隐性代价回放池里的数据来自旧策略而更新用的梯度来自当前策略两者不一致会让更新方向产生偏差。AC这类on-policy算法如果强行用回放训练很容易不稳定。A3C的核心思路是不用回放池而是开多个并行环境。每个worker有自己独立的环境副本各采各的样各算各的梯度然后异步更新到同一个全局模型上。并行带来的数据多样性天然打散相关性同时采样效率也提上去了。4.2 A3C的三个核心设计n-step、异步更新与熵正则A3C全称是Asynchronous Advantage Actor-Critic字面上就把关键点写完了异步、优势、AC。除了并行框架它还带了两个细节设计。第一个是n-step回报。每个worker在环境中连续采样 $n$ 步然后计算目标值$$ R_t \sum_{i0}^{n-1} \gamma^i r_{ti} \gamma^n V_\omega(s_{tn}) $$这比单步TD信息更丰富比完整蒙特卡洛方差更小而且因为每 $n$ 步才更新一次通信和更新频率也降下来了。第二个是熵正则项。在loss里加上一项 $-\beta H(\pi_\theta(\cdot|s))$其中 $H$ 是策略分布的信息熵。加熵的目的是防止策略过早变成一个“确定性的闷头策略”鼓励它继续探索。如果策略完全确定熵项就会一直惩罚它。论文里还有一个被反复提及的观点异步更新会造成全局模型参数滞后但这种延迟反而像一种隐式的正则化让不同worker能探索到更不一样的数据区域整体训练鲁棒性反而好。不过这个说法更多是经验观察理论保证并不强。4.3 A2C去掉异步后的收益与代价A2CAdvantage Actor-Critic常常被误解为A3C的升级版其实它是A3C的同步简化版。做法很简单多个worker依然并行采样但每个worker算完梯度之后不直接更新全局模型而是等所有worker的梯度都到齐取平均后再统一更新一次。这样一来更新每一步对应的是多个环境并行采样的一批数据相当于一个天然的大batch。梯度经过平均后噪声更小训练曲线更平滑也更容易复现。异步版本虽然理论上更快但实际调试时你会被各种问题折磨有的worker更新快有的慢、梯度互相覆盖、结果跑几次不一致这些问题在同步版里基本不存在。我在实际对比中倾向于这样总结如果硬件资源足够直接用A2C配合多环境并行采样稳定性和复现性都比A3C好A3C更适合在分布式、多机环境下压榨采样吞吐。下面是两者的一个粗略对比维度A3CA2C更新方式异步各自push梯度同步梯度取平均后更新数据效率并行采样吞吐高并行采样吞吐高梯度噪声较大但有时有正则效果较小训练更平滑调试难度较高复现性一般较低容易调参适用场景分布式、带宽受限单机多环境、研究实验5. TensorFlow 2.0代码实现一个能跑的A2C与调参笔记5.1 网络结构与损失函数三个loss如何拼在一起代码部分我用TensorFlow 2.0实现A2C。环境用gym库的CartPole-v1倒立摆任务动作空间是离散的但算法流程对连续动作空间同样适用后续换网络输出层即可。先定义Actor-Critic网络。我选择共享两个全连接层然后分叉输出策略分布和价值这种结构在CartPole这种小任务上收敛很快import numpy as np import tensorflow as tf import gym GAMMA 0.99 LR 1e-4 ENTROPY_BETA 0.01 N_STEPS 5 N_EPOCHS 400 CLIP_NORM 0.5 env gym.make(CartPole-v1) n_actions env.action_space.n class ActorCritic(tf.keras.Model): def __init__(self, n_actions): super().__init__() self.common tf.keras.Sequential([ tf.keras.layers.Dense(128, activationrelu), tf.keras.layers.Dense(128, activationrelu), ]) self.policy tf.keras.layers.Dense(n_actions, activationsoftmax) self.value tf.keras.layers.Dense(1) def call(self, obs, trainingNone): x self.common(obs, trainingtraining) probs self.policy(x) value self.value(x) return probs, value网络输出两部分probs是动作概率分布value是当前状态的估计价值。之所以共享底层是因为状态特征对策略和价值的初步提取是一致的在小任务里足够用。如果任务更复杂也可以拆成两个完全独立的网络。总损失由三部分组成actor_loss -tf.reduce_mean(chosen_log_probs * tf.stop_gradient(advantages)) critic_loss tf.reduce_mean(tf.square(targets - values)) entropy_loss -tf.reduce_mean(tf.reduce_sum(probs * log_probs, axis1)) total_loss actor_loss 0.5 * critic_loss - ENTROPY_BETA * entropy_lossactor_loss用优势值加权策略的log概率目标是让好动作概率变大、坏动作概率变小。critic_loss是价值网络和TD目标之间的MSE回归。entropy_loss乘以一个负号最大化策略熵鼓励探索。critic loss系数取0.5是常见做法避免价值网络的梯度压过策略网络。5.2 rollout采集与n-step目标的计算A2C每次更新前需要采一段rollout。我每次向前走N_STEPS步把这5步的状态、动作、奖励、结束标志收集起来最后再额外计算最后一个状态的价值作为bootstrap值def run_rollout(model, env, n_steps): states, actions, rewards, dones [], [], [], [] state env.reset() episode_rewards [] episode_total 0 for _ in range(n_steps): obs np.array([state], dtypenp.float32) probs, _ model(obs) action np.random.choice(n_actions, pprobs.numpy()[0]) next_state, reward, done, _ env.step(action) states.append(state) actions.append(action) rewards.append(reward) dones.append(done) episode_total reward if done: state env.reset() episode_rewards.append(episode_total) episode_total 0 else: state next_state _, last_value model(np.array([state], dtypenp.float32)) last_value last_value[0, 0].numpy() return states, actions, rewards, dones, last_value, episode_rewards这里有一个非常容易踩的坑如果某一时刻环境已经donen-step回报就不能再往后bootstrap了否则会把一个已经结束的episode的后续价值硬塞进来污染目标值。所以计算目标时要乘上(1 - done)def compute_targets(rewards, dones, last_value): n len(rewards) targets np.zeros(n, dtypenp.float32) running last_value for i in reversed(range(n)): running rewards[i] GAMMA * running * (1 - dones[i]) targets[i] running return targets从后往前跑running会逐步把未来折扣回报叠上来。因为N_STEPS只有5这里的bootstrap尾巴其实不长偏差不会太大实现也简单。5.3 训练主循环与梯度裁剪接下来是训练主循环。每次用tf.GradientTape计算总loss对模型参数的梯度然后应用到模型上optimizer tf.keras.optimizers.Adam(learning_rateLR) model ActorCritic(n_actions) for epoch in range(N_EPOCHS): states, actions, rewards, dones, last_value, ep_rewards run_rollout(model, env, N_STEPS) targets compute_targets(rewards, dones, last_value) states_t tf.convert_to_tensor(np.array(states, dtypenp.float32)) actions_t tf.convert_to_tensor(np.array(actions, dtypenp.int32)) targets_t tf.convert_to_tensor(targets, dtypetf.float32) with tf.GradientTape() as tape: probs, values model(states_t, trainingTrue) values tf.squeeze(values, axis1) log_probs tf.math.log(tf.clip_by_value(probs, 1e-10, 1.0)) action_mask tf.one_hot(actions_t, depthn_actions) chosen_log_probs tf.reduce_sum(action_mask * log_probs, axis1) advantages targets_t - values advantages tf.stop_gradient((advantages - tf.reduce_mean(advantages)) / (tf.math.reduce_std(advantages) 1e-8)) actor_loss -tf.reduce_mean(chosen_log_probs * advantages) critic_loss tf.reduce_mean(tf.square(targets_t - values)) entropy -tf.reduce_sum(probs * log_probs, axis1) total_loss actor_loss 0.5 * critic_loss - ENTROPY_BETA * tf.reduce_mean(entropy) grads tape.gradient(total_loss, model.trainable_variables) grads, _ tf.clip_by_global_norm(grads, CLIP_NORM) optimizer.apply_gradients(zip(grads, model.trainable_variables)) if epoch % 20 0: avg_reward np.mean(ep_rewards) if ep_rewards else 0 print(fepoch {epoch}, actor_loss {actor_loss.numpy():.3f}, avg_episode_reward {avg_reward:.1f})两点需要特别说明。一是advantages计算出来后要用tf.stop_gradient包住确保优势值不会把梯度传回价值网络再混入策略更新否则actor和critic的梯度路径会纠缠训练会变怪。二是梯度裁剪这一步在RL里几乎是必需品PG类算法对更新步长很敏感一次梯度爆炸就可能毁掉几十轮训练的成果。5.4 实验观察、踩坑记录与扩展方向这个A2C在CartPole上通常几百轮训练后平均回合奖励就能冲到500分环境满分。我手上跑过几组随机种子收敛速度略有差别但整体都能稳定到满分的阈值。几个关键超参数的经验值学习率建议在1e-4到3e-4之间太大会震荡熵系数0.01比较合适太大策略始终随机太小容易早熟N_STEPS5到20之间都算合理步数多了目标偏差小但更新频率低。一个小观察如果发现训练曲线时高时低、reward波动特别大优先检查advantage有没有做标准化和stop_gradient而不是急着调模型结构。这两个细节对稳定性的影响比换网络层数大得多。如果想把这段代码改成真正的A3C核心改动就两个每个worker维护一份local model副本开始采样前从global model拉取参数采样结束后计算梯度直接apply_gradients到global model。TensorFlow 2.0里可以靠多线程配合tf.Variable实现但你大概率会遇到Python GIL带来的性能瓶颈多线程加速有限而多进程又需要序列化传参数反而更慢。这也是我在实际项目里更推荐A2C的原因单机场景下向量化多环境采样配合同步更新的性价比远比强行异步要高。5.5 一些想补充的实战建议如果这系列后面几篇要接PPO或者SAC我的建议是你现在就把A2C的代码多跑几遍试着改改GAMMA、N_STEPS、ENTROPY_BETA观察训练曲线的变化。强化学习的算法结构看起来都挺简单但只有亲手调过超参才会理解为什么GAE、熵正则这些看似小零件的设计能对训练产生这么大影响。我自己经常用的一个习惯是在跑任何AC系算法之前先在纸上手写一遍优势函数和TD目标的关系再打开代码对着看。写多了之后即使换到新框架核心逻辑也基本能一眼看穿。毕竟A3C和A2C真正难的地方不在代码而在于理解它们各自在样本相关性和更新稳定性之间做出的取舍。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑