从策略梯度到A2C:强化学习演进路线与TensorFlow 2.0实现
这几年强化学习的新算法层出不穷从 DQN 到 DDPG再到 PPO、SAC、TD3每隔一阵就有新的 SOTA。不过说句实在话大部分当代算法都能追溯到一条非常清晰的演进线策略梯度Policy Gradient, PG奠定了直接优化策略的思路Actor-CriticAC引入价值函数来压缩方差A3C 用异步并行把训练效率带了上去后来被大家更常使用的 A2C 则是它的同步版本。这篇文章就是我重读相关论文时整理的一份完整笔记从 PG 的推导讲起一步步推到 AC再解释 A3C 和 A2C 的设计动机最后给一套用 TensorFlow 2.0 从零实现的代码。适合刚入门 RL、想搞懂策略梯度底层逻辑的读者也适合那些跑过 PPO 但回头想补基础算法的同学。1. 先看 PG策略梯度到底在优化什么1.1 为什么需要直接从策略下手强化学习要解决的核心问题是让智能体在一个马尔可夫决策过程MDP中最大化长期回报。决策由策略 π_θ(a|s) 描述θ 是策略参数整个优化目标可以写成一个期望回报J(θ) E_{τ ~ π_θ} [ Σ_{t0}^T γ^t r_t ]其中 τ (s0, a0, r1, s1, a1, ..., sT) 是一条完整轨迹。如果走价值函数路线典型做法是先学 Q*(s,a)再让策略选择能最大化 Q 值的动作但这里有一个很实际的问题当动作空间是连续的时候贪心策略里面的 max_a 没法做你不可能在连续空间里枚举每一个动作再取最大值。正是这个困境把策略梯度方法推到了台前——它绕开价值函数直接对 J(θ) 关于 θ 求梯度然后朝梯度上升方向更新参数。为什么这种直接优化在实践里可行因为梯度上升只要求策略分布可微不要求动作空间可枚举。连续控制场景里策略输出通常是一个高斯分布的均值与方差照样能采样出动作也能对 θ 求梯度。与此同时随机策略天然带有探索属性它输出的是一整个分布而不是一个确定动作前期可以到处尝试后期通过调节熵逐步收缩到接近确定性策略这一点比动作选择总是选最大 Q的贪心方式平滑得多。另一个容易被忽略的好处是和 Q-learning 的探索机制对比。Q-learning 训练时靠 ε-greedy 探索学到的却是一个确定性策略而 PG 框架下策略本身就是一个随机分布探索内嵌在策略里不需要额外设计探索噪声。对初学者来说理解这个差异比记住公式更重要因为后续 AC、A3C、PPO 的所有改进本质上都在围绕如何让这个梯度估计更准、方差更小做文章。1.2 REINFORCE 推导从期望回报的梯度开始经典策略梯度算法是 REINFORCE它的推导核心叫 log 导数技巧也叫似然比技巧。把目标函数展开成对轨迹分布的期望然后对参数求梯度∇J(θ) ∇ ∫ p_θ(τ) R(τ) dτ ∫ p_θ(τ) ∇log p_θ(τ) R(τ) dτ E_{τ ~ p_θ} [ ∇log p_θ(τ) * R(τ) ]这里的变换∇p p ∇log p是整条推导的支点。接下来要把轨迹分布拆开。轨迹概率由初始状态分布、每一步的策略、每一步的环境动力学组成p_θ(τ) p(s0) Π_t π_θ(a_t|s_t) p(s_{t1}|s_t, a_t)对上面这个乘积取对数再求梯度只有策略项 π_θ 和参数 θ 有关初始状态分布和环境动力学项全部变成常数梯度为 0。所以∇log p_θ(τ) Σ_t ∇log π_θ(a_t|s_t)代入之后策略梯度定理在 episode 情形下的形式就出来了∇J(θ) E[ Σ_t ∇log π_θ(a_t|s_t) * R(τ) ]这个式子看起来简单但它藏着一个实现上的细节。我们在写 REINFORCE 时到底是用整条轨迹的累计回报 R(τ)还是用从 t 时刻开始的回报 G_t严格推一下期望动作 a_t 只影响后续时刻的回报前面时刻的回报和当前策略参数无关所以乘上整条轨迹回报也不改变梯度的期望但样本只有一条轨迹前面那些无关回报实际上会引入额外噪声体现为方差居高不下。因此在代码实现里几乎所有人都用 return-to-go也就是从 t 时刻开始的折扣回报G_t Σ_{k0}^{T-t} γ^k r_{tk}这一点在论文推导里经常被一笔带过但对训练稳定性影响非常大。我自己第一次实现时图省事用了总回报结果同一份代码换了个随机种子收敛速度差别巨大后来改成 G_t 才稳定下来。1.3 高方差问题REINFORCE 为什么难调REINFORCE 的问题所有跑过的人都清楚完整的蒙特卡洛回报方差太高。同一份策略跑两条 episode拿到的总回报可能一个天上一个地下如果某条轨迹碰巧走出高分梯度幅度就会异常大让策略参数发生剧烈偏移。这个毛病在奖励比较稀疏或者 episode 很长的时候尤其致命训练曲线经常出现涨一波然后暴跌的锯齿形。缓解高方差的手段大致有三类。第一类是用基线 b(s)从回报里减去一个不依赖动作的量期望不变方差却大幅缩小第二类是用价值函数估计替代蒙特卡洛回报让模型学习某个状态大概值多少分而不是依赖单条轨迹的偶然结果第三类是用 n-step 折中介于蒙特卡洛和单步 TD 之间兼顾方差和偏差。这三条路线正好引出了 AC 的两个关键概念价值函数作为 Critic以及优势函数。注意PG 虽然不是唯一能处理连续动作的方法但它是理解后面 AC、PPO 的基石。如果这里对为什么梯度里乘的是 log 概率还有模糊建议拿离散动作为例手推一遍梯度对动作分布求期望的过程比死记公式有用得多。2. 价值函数介入从 PG 到 AC2.1 用 Critic 替代完整回报Actor-Critic 的核心思想是同时训练两个模块Actor 负责输出动作策略Critic 负责估计状态价值或状态-动作价值。Actor 更新时需要的方向信号由 Critic 提供而不是等一条完整轨迹跑完才拿到一个总回报。简单说PG 里面的回报 R 被换成了 Critic 对回报的估计 Q(s,a)∇J(θ) E[ ∇log π_θ(a|s) * Q(s,a) ]这样有两个立竿见影的好处。第一个是不再需要等 episode 结束才能更新Critic 可以用 TD 方法逐步学习训练可以变成单步或 n 步在线模式。第二个是价值估计是在大量样本上做回归得到的结果它的方差天然比单条轨迹的蒙特卡洛回报小很多。特别是在奖励稀疏的环境里REINFORCE 可能跑完一整局还是零奖励梯度毫无信号而 Critic 至少能对未见过的状态给出一个平滑的预测值让 Actor 不至于原地踏步。打个生活化的比方REINFORCE 像每次考试结束后才看总分根据总分反思整场答题策略一个学期只能反思几次AC 则像每道题做完就有老师给一个预估分你可以根据预估分及时调整后面的做题方式。老师的打分不一定完全准确但至少比等期末再看总分要稳定得多。不过这里有个必须警惕的问题Critic 本身会产生偏差。训练初期 Q(s,a) 的估计基本是错的Actor 依据错误的评价更新策略会引入偏置。所以 AC 类算法里 Critic 的学习率、是否用 target network、n 步长度这些超参都会直接影响最终效果。这也是为什么很多 AC 代码看起来简单真正调到稳定却要花不少时间。2.2 基线函数与优势估计光用 Q(s,a) 还有个问题Q 值的绝对数值可能很大尤其在回合制环境中每条轨迹累计几百上千分直接乘到 log-prob 梯度上会使更新幅度过大。真正需要关心的是这个动作比平均水平好多少于是引入状态价值 V(s) 作为基线A(s,a) Q(s,a) - V(s)这里 A 就是优势函数Advantage Function。在期望上减去一个和动作无关的 V(s) 不会改变梯度方向。用离散动作简单验证一下E[∇log π(a|s) * V(s)] 对动作概率求和时Σ_a ∇π(a|s) ∇1 0所以那一项整块消失。而 A(s,a) 的方差通常会显著小于 Q梯度更新的步长也就更稳定。实际代码里 A(s,a) 是怎么算的不需要真的维护两个价值网络。单步情形用 TD 误差做估计A_t r_t γ V(s_{t1}) - V(s_t)把若干步展开就得到 n-step advantageA_t (Σ_{i0}^{n-1} γ^i r_{ti}) γ^n V(s_{tn}) - V(s_t)这是后面 AC 和 A2C 代码里真正会用到的形式。n 取 1 就是单步 TD取很大就接近蒙特卡洛。后面 A3C 的 n-step 优势也是同一个式子只是把异步并行加了进来。2.3 AC 的整体结构与损失设计在 TensorFlow 里Actor 和 Critic 可以共享特征提取层输入状态经过两层全连接变成共享特征再分两个头分别输出策略 logits 和状态价值标量。这种设计节省参数特征学习也能互相促进但也带来梯度竞争的问题后面调参部分会细说。离散动作空间下Actor 和 Critic 的损失函数通常这样设计actor_loss mean( -log π(a|s) * advantage ) critic_loss mean( advantage^2 ) total_loss actor_loss 0.5 * critic_loss - 0.01 * entropy这里的熵项是 A3C 论文里就有的技巧用来惩罚过于确定的策略鼓励探索。CartPole 这种小环境里熵项能让策略不会过早收敛到单一动作在更复杂的环境里通常需要在 0.01 到 0.1 之间调节。critic_loss 系数取 0.5 是从数学上方便求导——advantage^2 的导数是 2 倍 advantage乘 0.5 之后梯度幅度和 actor 更匹配。有一点需要在实现时特别留意advantage 的梯度不能回流到 Critic 的 actor 分支。实践中的做法是tf.stop_gradient(advantage)相当于把优势当作一个固定权重使用。如果不加这个 stop_gradientActor 的更新梯度会通过 advantage 反向传到 Critic 的参数上两个网络的优化目标搅在一起训练行为非常奇怪而且极难排查。3. 从 AC 到 A3C、A2C异步与同步的取舍3.1 异步训练的动机打破数据相关性AC 框架解决了方差问题但训练层面还有一个隐患单条环境序列之间的数据高度相关。一个 episode 内部相邻时间步的状态强相关如果每一步更新只用当前轨迹的一小段梯度方向很容易被局部的状态分布带偏。DQN 用经验回放来解决这个问题但 AC 是 on-policy 算法不能从旧策略的经验池里采样——旧经验对应旧策略不满足 on-policy 的梯度前提。A3C 给出的解法是多线程异步。多个 worker 各自跑自己的环境互不相干每个 worker 按本地策略跑 n 步算梯度然后去更新一个全局共享网络。因为各 worker 的采样轨迹在时间上错开、初始状态也不同它们对全局参数的梯度更新天然就被去相关了。这个设计还有一个附带好处A3C 基本不需要经验回放缓冲区省去了存储和管理海量经验的工程负担。这里有个很关键的设计选择值得多说一句。为什么 A3C 不用 target network因为在异步框架里全局参数本身就在不断变化不同 worker 在各自时间点看到的参数版本不同这已经构成一种天然的软 target机制。固定周期的 target 更新反而可能破坏异步带来的随机性。理解了这个设计你才会明白 A3C 不是简单地把 AC 放到多线程里跑而是从去相关这个角度重新思考了训练架构。3.2 A3C 的 n-step 回报与算法流程A3C 每个 worker 的执行流程可以概括成六步从全局网络拉取最新参数到本地网络用本地网络与环境交互 n 步或一直跑到 episode 终止对末尾状态用本地 Critic 估计 V(s_{tn})构造 n-step advantage计算本地 Actor、Critic 和熵正则项的梯度把梯度提交给全局优化器做参数更新回到第 1 步继续循环。n 的取值通常选 5、10 或 20经典连续控制环境里 n5 效果就不错。n 越大越接近蒙特卡洛方差会变大n 越小越接近单步 TD偏差会变大。这就是强化学习里最经典的偏差-方差权衡。实际调参时你会发现n 值对结果的影响往往比学习率还直观值得单独做一组实验观察。A3C 的核心梯度公式和图 2 里的 AC 完全一致只是把单步 TD 换成了 n-stepA_t (Σ_{i0}^{n-1} γ^i r_{ti}) γ^n V(s_{tn}) - V(s_t)用这个优势更新 Actor用 TD 误差更新 Critic。异步在这里的作用不是改变数学形式而是改变样本的分布特性。3.3 A2C同步版本为什么更常用A2C 在网络结构、损失函数上和 A3C 几乎一样唯一重要的差别在于训练方式所有 worker 先一起跑完 n 步把经验汇总到主进程统一计算平均梯度并更新全局参数然后再把新参数同步给所有 worker。这个版本的更新严格同步没有 worker 会在某个时间点插队更新。从工程角度看A2C 比 A3C 好调得多。异步线程的梯度提交顺序不可控全局参数更新行为像一个随机进程虽然期望方向合理但极难复现同步版本则可以用向量化环境同时跑多个环境并返回一批数据让模型一次吃一个 batch在 GPU 上运算效率更高。同步模式还天然适合 TensorFlow 的多设备策略数据并行或分布式训练都好做。不少开源强化学习库实现的 A2C 都长得很像。你要是在网上搜优势演员评论家 代码绝大多数都是同步版本。另外PPO 本质上可以理解为 A2C 的稳定升级版它把 A2C 里脆弱的学习率问题用一个裁剪目标解决掉了。所以把 A2C 吃透后面看 PPO 论文会轻松很多。注意A3C 和 A2C 在单机小规模场景下效果差距不大。如果你只是为了学习算法原理A2C 的实现成本低得多如果你研究分布式训练或需要更高的吞吐再回头研究 A3C 风格的异步更新不迟。4. TensorFlow 2.0 从零搭建PG、AC、A2C 代码实现4.1 环境与网络模型准备我用的是 Python 3.9 加 TensorFlow 2.x环境方面用经典的小规模仿真环境做验证比如 CartPole 这类离散动作控制任务状态维度很低能快速看到算法差异不需要 GPU。先定义一个共享特征的双头模型这是 AC 和 A2C 的基础结构。两层 128 神经元全连接输出两个头一个输出策略 logits一个输出状态价值。离散动作空间直接用 logits不接 softmax因为 TensorFlow 的sparse_softmax_cross_entropy_with_logits会在计算交叉熵的同时做 softmax数值稳定性更好。import tensorflow as tf import numpy as np class ActorCritic(tf.keras.Model): def __init__(self, action_dim): super().__init__() self.fc1 tf.keras.layers.Dense(128, activationtanh) self.fc2 tf.keras.layers.Dense(128, activationtanh) self.policy_logits tf.keras.layers.Dense(action_dim, namepolicy_logits) self.value tf.keras.layers.Dense(1, namevalue) def call(self, state): x self.fc1(state) x self.fc2(x) logits self.policy_logits(x) value self.value(x) return logits, value这里激活函数我用了 tanh 而不是 relu。小规模强化学习网络里 tanh 通常比 relu 稳定因为输入状态范围变化可能较大relu 容易让某些神经元死亡特别是在价值函数分支上。CartPole 里 relu 也能跑但我在更复杂的连续控制环境里吃过亏所以默认写成 tanh。采样动作时需要注意模型输出的是 logits要先用 softmax 转成概率再采样def sample_action(model, state): state tf.convert_to_tensor([state], dtypetf.float32) logits, _ model(state) probs tf.nn.softmax(logits).numpy()[0] return np.random.choice(len(probs), pprobs)4.2 REINFORCE 核心代码REINFORCE 只需要策略网络不需要价值头。结构上和上面的双头模型共享大部分层但输出层只有一个。训练过程是跑完一个 episode把每一步的状态、动作、奖励存下来然后统一算 return-to-go 并做一次更新。class PolicyNet(tf.keras.Model): def __init__(self, action_dim): super().__init__() self.fc1 tf.keras.layers.Dense(128, activationtanh) self.fc2 tf.keras.layers.Dense(128, activationtanh) self.logits tf.keras.layers.Dense(action_dim) def call(self, state): return self.logits(self.fc2(self.fc1(state)))return-to-go 的计算很简单从后往前做折扣累计。注意这里一定是从 t 时刻开始的累计回报不是整条轨迹总回报前面推导部分已经解释过原因def compute_returns(rewards, gamma0.99): returns [] g 0 for r in reversed(rewards): g r gamma * g returns.append(g) return list(reversed(returns))策略更新函数是 REINFORCE 最核心的部分损失直接用负对数概率乘上回报。在 TensorFlow 里sparse_softmax_cross_entropy_with_logits返回的正是负对数概率def reinforce_update(policy, optimizer, states, actions, returns): states tf.convert_to_tensor(np.array(states), dtypetf.float32) actions tf.convert_to_tensor(np.array(actions), dtypetf.int32) returns tf.convert_to_tensor(np.array(returns), dtypetf.float32) with tf.GradientTape() as tape: logits policy(states) neg_log_prob tf.nn.sparse_softmax_cross_entropy_with_logits( labelsactions, logitslogits) loss tf.reduce_mean(neg_log_prob * returns) grads tape.gradient(loss, policy.trainable_variables) optimizer.apply_gradients(zip(grads, policy.trainable_variables))训练主循环就是反复执行采样和更新。经验上 REINFORCE 的学习率要调得比 AC 小一般在 1e-3 附近否则很容易震荡又因为返回值的量纲直接影响梯度大小有些实现会额外做回报归一化效果不错但会引入轻微偏差。4.3 AC 核心代码AC 使用双头模型其中 Critic 对每个 transition 的下一状态也做一次价值估计。我比较推荐的写法是把若干条 transition 组成一个 batch 再做更新这样代码结构清晰也方便后面扩展成 A2C。首先构造训练数据每一个 transition 包含当前状态、动作、奖励、下一状态、结束标志。def collect_transitions(env, model, num_steps): state, _ env.reset() transitions [] for _ in range(num_steps): action sample_action(model, state) next_state, reward, done, truncated, _ env.step(action) transitions.append((state, action, reward, next_state, float(done or truncated))) state next_state if done or truncated: state, _ env.reset() return transitions更新时把 batch 里的状态、动作、奖励、下一状态、done 分别整理成张量。优势用 TD 误差计算r γ V(s) - V(s)终止状态处把引导项 V(s) 置零。Actor 损失和 Critic 损失在同一个GradientTape里计算但优势部分要用tf.stop_gradient隔断梯度def ac_update(model, optimizer, transitions, gamma0.99): states tf.convert_to_tensor([t[0] for t in transitions], dtypetf.float32) actions tf.convert_to_tensor([t[1] for t in transitions], dtypetf.int32) rewards tf.convert_to_tensor([t[2] for t in transitions], dtypetf.float32) next_states tf.convert_to_tensor([t[3] for t in transitions], dtypetf.float32) dones tf.convert_to_tensor([t[4] for t in transitions], dtypetf.float32) with tf.GradientTape() as tape: logits, values model(states) values tf.squeeze(values, axis-1) _, next_values model(next_states) next_values tf.squeeze(next_values, axis-1) td_targets rewards gamma * next_values * (1 - dones) advantages tf.stop_gradient(td_targets - values) actor_loss tf.reduce_mean( tf.nn.sparse_softmax_cross_entropy_with_logits(labelsactions, logitslogits) * advantages ) critic_loss tf.reduce_mean(tf.square(td_targets - values)) probs tf.nn.softmax(logits) entropy -tf.reduce_mean(tf.reduce_sum(probs * tf.math.log(probs 1e-8), axis-1)) total_loss actor_loss 0.5 * critic_loss - 0.01 * entropy grads tape.gradient(total_loss, model.trainable_variables) optimizer.apply_gradients(zip(grads, model.trainable_variables))这里有个细节必须强调advantages tf.stop_gradient(td_targets - values)看起来只是一行实际决定了 Actor 和 Critic 两个分支的训练是否干净。如果不做 stop_gradientActor 的损失会通过td_targets中的next_values反向传播到 Critic 参数训练行为立刻变诡异。AC 的更新频率可以调。CartPole 这种小环境下每步更新或每若干步更新都能收敛但每步更新时梯度噪声较大我习惯攒一个小 batch 再更新既减少更新次数也让梯度方向更平稳。4.4 A2C 核心代码与多环境训练A2C 和上面 AC 最大的区别在于引入了多个并行环境并且每次更新用的是 n-step 优势。多环境并行一方面提供了多样化的样本另一方面让模型在同一个梯度步骤里看到更多不同的状态训练曲线会更平滑。多环境采集数据的逻辑也不复杂。先创建 N 个环境实例每个环境独立 reset然后在每一步给 N 个环境分别采样动作、执行 step、收集 reward 和 done。注意 done 的环境要立刻 reset 并保留新状态作为下一轮的状态否则这条轨迹就断了。def create_envs(n_envs, seed_offset0): envs [] for i in range(n_envs): env make_env(seed_offset i) envs.append(env) return envs def collect_rollouts(model, envs, n_steps, gamma0.99): n_envs len(envs) states np.array([e.reset() for e in envs], dtypenp.float32) batch_states, batch_actions [], [] batch_rewards, batch_masks [], [] for _ in range(n_steps): logits, _ model(tf.convert_to_tensor(states, dtypetf.float32)) probs tf.nn.softmax(logits).numpy() actions [np.random.choice(len(p), pp) for p in probs] next_states [] for i in range(n_envs): next_state, reward, done, truncated, _ envs[i].step(actions[i]) batch_states.append(states[i]) batch_actions.append(actions[i]) batch_rewards.append(reward) batch_masks.append(1.0 - float(done or truncated)) if done or truncated: next_state envs[i].reset() next_states.append(next_state) states np.array(next_states, dtypenp.float32) return batch_states, batch_actions, batch_rewards, batch_masks, states上面收集好的数据严格来说还不是标准的 n-step 组织方式。标准 A2C 需要把数据按时间序整理成[n_steps, n_envs]的张量然后做折扣累计再加最后的 bootstrap 价值。我这里为了可读性做了简化每个 batch 元素都带了一个 mask表示从该 transition 之后是否继续。计算 target 时用反向循环加折扣累计碰到 mask0 的位置自动截断得到的就是从每个时刻开始的 n-step 回报def compute_n_step_returns(rewards, masks, last_values, gamma0.99): rewards np.array(rewards, dtypenp.float32) masks np.array(masks, dtypenp.float32) T len(rewards) returns np.zeros_like(rewards, dtypenp.float32) g np.array(last_values, dtypenp.float32) for t in reversed(range(T)): g rewards[t] gamma * g * masks[t] returns[t] g return returns这个函数是 A2C 实现里最容易写错的地方务必记住在反向循环里下一时刻的累计回报必须乘上当前 mask也就是只有非终止状态的后续回报才需要继续累计。如果 mask 漏乘终止状态之后的环境 reset 数据会被算进当前轨迹价值估计会变得偏乐观训练中期可能出现 loss 很小但回报上不去的情况。更新函数和 AC 几乎一样只是其中td_targets换成了 n-step return优势则是returns - values。在多环境并行下一个 batch 里同时包含多个环境的数据全部 flatten 成一个大 batch 处理即可。def a2c_update(model, optimizer, states, actions, returns, gamma0.99): states tf.convert_to_tensor(np.array(states), dtypetf.float32) actions tf.convert_to_tensor(np.array(actions), dtypetf.int32) returns tf.convert_to_tensor(np.array(returns), dtypetf.float32) with tf.GradientTape() as tape: logits, values model(states) values tf.squeeze(values, axis-1) advantages tf.stop_gradient(returns - values) actor_loss tf.reduce_mean( tf.nn.sparse_softmax_cross_entropy_with_logits(labelsactions, logitslogits) * advantages ) critic_loss tf.reduce_mean(tf.square(returns - values)) probs tf.nn.softmax(logits) entropy -tf.reduce_mean( tf.reduce_sum(probs * tf.math.log(probs 1e-8), axis-1) ) total_loss actor_loss 0.5 * critic_loss - 0.01 * entropy grads tape.gradient(total_loss, model.trainable_variables) optimizer.apply_gradients(zip(grads, model.trainable_variables))A2C 主循环就是反复执行收集 rollout → 计算 n-step returns → 更新权重。每轮 rollout 的长度由 n_steps 决定我一般在 5 到 20 之间选。环境数量建议 8 个起太少体现不出并行优势太多时 CartPole 这种简单问题反而是浪费。4.5 A3C 的多线程实现思路如果一定想在 TensorFlow 2 里复现 A3C 的异步风格可以用多线程加锁的思路。每个 worker 有一个本地模型定期从全局模型拉取权重跑够 n 步后计算梯度在全局锁保护下把梯度交给一个共享优化器更新全局模型。代码骨架大约是这样import threading global_lock threading.Lock() def worker_fn(worker_id, global_model, optimizer, n_steps5): local_model ActorCritic(action_dim) env make_env(100 worker_id) while True: # 从全局模型拉取当前参数 local_model.set_weights(global_model.get_weights()) # 用本地模型采样 n 步 states, actions, rewards, masks, last_state collect_rollouts( local_model, [env], n_steps ) _, last_values local_model( tf.convert_to_tensor(np.array([last_state[0]]), dtypetf.float32) ) returns compute_n_step_returns( rewards, masks, last_values.numpy().reshape(-1) ) # 计算梯度 with tf.GradientTape() as tape: logits, values local_model( tf.convert_to_tensor(np.array(states), dtypetf.float32) ) values tf.squeeze(values, axis-1) returns tf.convert_to_tensor(returns, dtypetf.float32) advantages tf.stop_gradient(returns - values) actor_loss tf.reduce_mean( tf.nn.sparse_softmax_cross_entropy_with_logits( labelstf.convert_to_tensor(actions, dtypetf.int32), logitslogits ) * advantages ) critic_loss tf.reduce_mean(tf.square(returns - values)) total_loss actor_loss 0.5 * critic_loss grads tape.gradient(total_loss, local_model.trainable_variables) # 全局锁保护下更新全局模型 with global_lock: optimizer.apply_gradients(zip(grads, global_model.trainable_variables))在实际工程里这种线程式异步更新不建议用在正式项目里原因主要有两个。一是 Python 的多线程受全局锁限制并行计算收益不大二是多个线程共享优化器时apply_gradients的时序完全不可控调试和复现都很痛苦。更推荐的路线是先跑通 A2C需要分布式时再研究多进程加参数服务器的方案。提示A3C 和 A2C 的数学目标几乎一样差别主要在调度方式。写代码时先实现 A2C再把收集和更新拆成独立 worker就是一个结构完整的 A3C。理解了这一点你就能把握两个算法的本质联系。5. 训练实录、调参与避坑清单5.1 三种算法的收敛表现观察我在 CartPole 和 LunarLander 上分别跑过三套实现先说结论REINFORCE 适合教学AC 适合小规模问题A2C 是最稳的默认选择。CartPole 上REINFORCE 大约几百个 episode 后能到达稳定高分但中间波动非常大同一份代码换随机种子收敛速度能差几倍。把学习率从 1e-3 降到 3e-4 后稳定性明显改善但会牺牲前期收敛速度。AC 大概少一半的 episode 就能逼近稳定区间单步 TD 版本在小规模问题上效果已经不错但在 LunarLander 这类需要延迟奖励的任务上容易陷入局部最优。A2C 用 8 个环境并行时通常在几千步内就能看到明显的上升曲线平滑度比前两者好太多最终策略也更稳定。LunarLander 上REINFORCE 基本不可用这是因为单条轨迹太长、回报范围太大不归一化的话梯度很容易爆炸。AC 可以跑通但单步 TD 的优势估计在奖励稀疏的区域信号偏弱需要调大 n_step 或增加熵系数。A2C 调整好 n_step 和熵系数后可以达到稳定着陆的成绩。这也说明了为什么新环境上直接上 A2C 是更稳妥的策略。5.2 高频问题速查表这里整理一份我在实际跑代码过程中遇到过的高频问题按现象归类方便以后排查。问题现象可能原因建议处理Reward 起伏很大loss 剧烈震荡学习率过大回报没有归一化REINFORCE 使用了总回报而不是 return-to-go调小学习率到 1e-4~3e-4为回报做标准化统一改用折扣累计回报AC 训练长时间没有进展Actor 和 Critic 共用特征层时梯度互相拉扯忘记 stop_gradient给 advantage 加 stop_gradient必要时将 actor 和 critic 网络分支分开调大 Critic 学习率试一下A2C 训练时单个环境数据的 target 错乱n-step return 计算时 mask 漏乘检查反向循环里是否乘了masks[t]用简单环境验证 np.sum(rewards) 是否符合预期sparse_softmax_cross_entropy_with_logits报 shape 错误labels 传成了一维 one-hot 或形状不对labels 必须是[batch]的整数张量logits 是[batch, action_dim]最终策略价值估计偏乐观终止状态后的 bootstrap 没有清零计算 target 和 n-step return 时结束标志必须乘到引导项上多环境数据 dtype 混乱从一个 env 拿到的 reward 是 Python float另一个是 numpy float在收集阶段统一转成 float32避免在张量拼接时出现隐式转换警告其中 mask 问题是最隐蔽的。有一次我在 LunarLander 上看到折扣回报算错整个训练却还能缓慢收敛困惑了很久才发现是 mask 在反向循环里放错了位置。建议你在写完compute_n_step_returns后先用一个假数据做单元测试手动验证几个边界 case比直接丢进训练循环里省时间得多。5.3 一点调参心得训练强化学习模型和训练普通的分类模型感受很不一样。分类模型 loss 下降基本就代表有进展强化学习里 loss 和最终目标不是简单的对应关系尤其 AC 类算法Actor 的 loss 数值本身没有意义。我调试时主要看两个指标一是最近若干个测试回合的平均回报二是策略熵。如果策略熵掉得太快说明探索不足往往后面回报会卡在某个局部最优如果熵始终很大说明策略没有学到东西可能是优势信号太弱或者学习率太小。CartPole 这种任务上熵系数 0.01 够用LunarLander 我调到 0.05 才比较顺滑。另外多环境并行时不要忘记把随机种子分开。所有环境共用同一个种子会让采样数据高度相关A2C 的去相关优势直接减半。这个坑很隐蔽因为代码不会报错只是训练曲线莫名变慢排查起来特别费时。最后再分享一个小技巧如果你在一个新环境上跑 A2C 效果不理想先不要急着堆算法复杂度优先尝试调整 n_step 和熵系数这两个参数。我在几个任务上的经验是n_step 从 5 调到 20或者熵系数从 0.01 调到 0.1效果差别往往比换网络结构还明显。强化学习算法之间真正的鸿沟很多时候不在理论而在这些细节参数上。把 PG 到 AC、A3C、A2C 这条线完整跑通之后你会对 PPO 中裁剪目标、GAE 优势估计这些改进有更直观的理解。后面我打算接着整理 PPO 和 GAE 的推导与实现把这条主线继续延伸下去到时候再和大家聊聊新一版的踩坑记录。