☰
强化学习策略梯度到A2C/A3C:理论脉络与TensorFlow2.0实现
2026/10/14 4:29:40 网站建设 项目流程

RL论文阅读:从策略梯度到A3C与A2C的理论脉络与TensorFlow2.0实现

我是从读经典论文开始入坑强化学习的。刚开始看PG(Policy Gradient)系列论文时,最直观的感受是"公式看得懂,代码写不出",尤其是从REINFORCE过渡到Actor-Critic时,总觉得中间隔了一层窗户纸。后来把论文一篇一篇串起来读,再用TensorFlow2.0把每个算法手写了一遍,才算真正把这条线打通。

这篇文章就是我整理出来的完整脉络:从策略梯度定理出发,推导出REINFORCE,再引入价值函数得到Actor-Critic,接着拓展到异步训练的A3C,最后落到更稳定的同步版本A2C。文中不仅讲清楚每一步"为什么这么改",还会给出TF2.0的核心实现代码,以及我实验过程中踩过的坑和调参经验。

适合两类读者:一类是刚开始读强化学习论文、被A3C原文里那个t_max和共享梯度搞懵的同学;另一类是已经会调库用算法、但想搞清楚内部机制、准备自己写实现的开发者。这篇文章默认你有基础的神经网络和反向传播概念,但不需要事先精通强化学习。

1. 策略梯度:一切的起点

1.1 为什么我们不直接"学"策略

强化学习的核心目标,是找到一个策略,让智能体在与环境交互的过程中获得的累积回报最大。理解策略梯度最基本的问题是:为什么我们不用Q-learning那套"先学价值、再导策略"的方案,而要直接对策略本身做梯度上升?

Q-learning这类价值方法有一个隐含假设:最优策略是确定性贪心策略,即在每个状态下选择价值最大的动作。这个假设在处理离散动作空间时很自然,但一旦动作空间是连续的,或者状态本身有高维连续特性,"先最大化价值再决定动作"就变得非常笨重。你想在连续动作空间里做argmax,每一步都要解一个最优化问题,计算代价极高。

策略梯度绕开了这个难点。它把策略参数化为一个概率分布,直接用梯度上升优化期望回报。这样动作的选择天然就是"从分布里采样",不再需要显式的argmax。另一个好处是策略天然具备随机性,适合处理部分可观测环境,比如打牌这种需要主动探索的场景。对一局开始前不确定对手牌面的情况,随机策略比确定策略更合理。

策略梯度的核心定理叫做策略梯度定理。它的推导过程几乎每篇论文都会重现一遍,但最直观的直观记忆是:在轨迹的概率分布下,期望回报对策略参数的梯度,可以写成"回报乘以对数策略似然梯度"的期望。

推导的起点是:假设一条轨迹的回报是R(τ),轨迹出现的概率是p_θ(τ),那么期望回报的梯度可以写成:

∇_θ J(θ) = ∇_θ ∫ p_θ(τ) R(τ) dτ = ∫ p_θ(τ) ∇_θ log p_θ(τ) R(τ) dτ

这个等式用的技巧叫"对数导数技巧",也叫似然比技巧。它是整个PG系列算法的基石。关键观察是:轨迹概率p_θ(τ)本身难以精确计算(因为环境转移概率未知),但∇_θ log p_θ(τ)中环境转移的部分会被消掉,最后只留下策略部分的梯度。也就是说,我们不需要知道环境动力学,只需要能算策略的梯度,就能估计策略梯度。

1.2 REINFORCE:最简单的策略梯度实现

把上面的公式变成可操作的算法,就是REINFORCE(也叫蒙特卡洛策略梯度)。它的做法非常朴素:用智能体实际交互采样若干条完整轨迹,把每条轨迹的真实累计回报R(τ)当作权重,乘以每一步动作的对数概率梯度,再求平均。

REINFORCE的更新公式可以写成一个很"接地气"的形式:如果这一步动作最终带来高回报,就把这个动作的概率调高;如果带来低回报,就把概率调低。这就是"表现好的动作被强化,表现差的动作被削弱"这一直觉的数学化。

PG系列的第一个关键参数在这里出现:学习率(learning_rate)。REINFORCE对学习率非常敏感。因为蒙特卡洛回报的方差很大,如果学习率太大,参数会剧烈震荡;太小的话收敛又慢得让人怀疑人生。我实验时的经验是,在简单环境比如CartPole上,0.001左右是一个比较稳的起点,但这个值换一个环境几乎必然要重调。

REINFORCE的问题在方差。同一状态下执行相同动作,由于环境随机性和后续轨迹随机性,累计回报可能相差很大。这会导致梯度估计的方向出现较大的随机波动,训练过程非常不稳。

降低方差的一个经典手段是引入基线(Baseline)。做法是从回报中减去一个与当前动作无关的基准值b(s),更新变成:

∇_θ J(θ) ≈ Σ_t ∇_θ log π_θ(a_t|s_t) * (R_t - b(s_t))

只要b(s)不依赖于动作,这个减法在期望上不改变梯度方向,却能显著降低方差。这个技巧看起来简单,但它是通往Actor-Critic最关键的一步,因为最自然的基线选择,就是状态的价值函数V(s)。当b(s)取V(s)时,PG算法就已经在往Actor-Critic的方向靠拢了。

注意:REINFORCE的实现里,每一条轨迹必须完整走完才能计算回报,这意味着和环境的交互是"回合制"的,无法在每一步之后立刻更新网络。这个特性限制了它在长回合任务或需要实时交互场景下的使用。

1.3 为什么方差问题躲不掉

我一开始不太理解,既然蒙特卡洛回报是"无偏估计",为什么方差会这么大?后来想明白了:一个轨迹的累计回报里,后期几步的随机性被累乘放大,任何一步出问题都会让整条轨迹的回报失真。这就像做长途旅行预算,路程越长,中间任何一次意外都会让总预算波动很大。

如果改用TD(时间差分)目标,也就是用估计值去bootstrapping(自助采样),方差会降低,但会引入偏差。这就是强化学习里最核心的权衡:偏差与方差的取舍。Actor-Critic从本质上说,就是在用这个取舍思路把REINFORCE改造得更实用。

2. 从策略梯度到Actor-Critic

2.1 用价值函数替换蒙特卡洛回报

REINFORCE的"完整轨迹"限制在实际工程中很痛苦。如果一个回合要持续几千步,需要等很久才能更新一次参数,样本利用率太低。Actor-Critic(AC)的核心改动是:不再等完整轨迹,而是每一步(或固定步数)就更新一次,用价值函数的估计值来代替真实的回报。

它的思路是引入一个Critic网络,用于估计状态价值V_π(s)或动作价值Q_π(s,a)。Actor网络负责生成策略,Critic网络负责给策略"打分"。更新时,Actor的梯度不再乘以真实回报R_t,而是乘以一个由Critic提供的信号。这个信号通常就是TD误差:

δ_t = r_t + γ V(s_{t+1}) - V(s_t)

如果Critic估计得足够准,TD误差在期望上等于当前动作相对平均水平的优势,即这个动作是否比平均水平更好。于是梯度的更新方向与REINFORCE很相似,但方差大幅降低,因为每一步都可以更新,不需要等待回合结束。

2.2 优势函数:AC拆掉的一堵墙

在A3C论文里,最常出现的公式是:

∇_θ log π(a_t|s_t) * A(s_t, a_t)

这个A就是优势函数(Advantage Function),定义为A(s,a) = Q(s,a) - V(s)。它衡量的是"在这个状态下采取动作a,相对于平均水平好多少"。如果优势为正,就加大这个动作的概率;为负,就减小。

直接同时估计Q和V需要两个网络头,开销大且不稳定。在实践中,通常只用V网络,用单步TD误差δ_t来近似优势函数。这是合理的:因为E[r_t + γV(s_{t+1})]本身是Q(s_t,a_t)的无偏估计,减去V(s_t),正好是优势的估计。

于是Actor的更新就变成对优势做梯度上升,Critic的更新则是对价值预测和实际回报之间的差距做回归(通常用MSE loss)。整个AC网络看起来就是两个"小网络"共享一部分底层特征,上面分叉成一个Actor头和一个Critic头。在TensorFlow2.0的Keras里写这个结构很直观,用函数式API把输入层接两个输出头就行。

需要注意:AC框架比REINFORCE更灵活,但两个网络一起训练,稳定性就成了新的难题。Actor希望策略快速改进,Critic希望价值估计准确,二者互相影响,学习率、网络规模、更新频率稍微不匹配就会出现震荡甚至发散。我经历过多次"loss突然冲到NaN然后整个训练报废"的情况,后面会专门讲排查经验。

2.3 AC的梯度流与实现直觉

我最早实现AC时犯过一个错误,就是把Actor和Critic当成完全独立的两个网络训练,结果收敛非常慢。后来想明白了,共享底层特征提取层是有益的——状态表示应该被两边共用。在CNN处理图像输入任务时,这种共享尤其重要,可以让两个网络一起学习有用的特征。

一个典型的AC网络设计是:输入状态 -> 两层128神经元全连接(ReLU激活)-> 一个分支接Actor输出层(输出动作概率分布参数),另一个分支接Critic输出层(输出一个标量V值)。

这里有个隐蔽但重要的问题:Actor输出的分布形式和形状。在CartPole这类离散动作环境中,Actor输出一个Softmax分布;在连续控制任务中,Actor输出高斯分布的均值和方差(log std),动作则从该分布中采样。采样过程不可导,需要用重参数化技巧(reparameterization trick),这也是后续代码实现里需要处理的细节。

想理解AC为什么在工作中有效,可以这样看:策略梯度提供了一个正确的前进方向,Critic则像是一个传感器,把这个方向的"信号"变得更清楚。没有Critic,信号噪声太大;只有Critic没有策略梯度,又无法精细优化策略。两者的配合关系,是AC框架的精髓。

3. A3C:异步并行背后的故事

3.1 并行不只为加速

A3C(Asynchronous Advantage Actor-Critic)在2016年提出时,主要动机是解决深度RL训练中数据关联性强、样本利用率低的问题。我在读论文时的第一个疑问也产生了:它看起来只是开了多个并行的环境副本,一个共享模型被多个worker更新而已,这为什么能改善训练?

关键在于"去相关"(decorrelation)。神经网络做梯度下降时,一个基本假设是训练样本近似独立同分布。但RL里的轨迹数据天生强相关:相邻时间步的状态非常相似,动作和回报也是相关的。用强相关数据做梯度更新,参数会沿着某个方向偏执地走,训练不稳定。

A3C的做法是让每个worker用不同的随机种子在各自的环境副本里交互。因为探索的随机性,各个worker采样到的数据天然不同,聚合起来的梯度就不那么"偏科"了。这相当于同时从多个角度观察环境,相当于用并行化解了相关性问题。

这种并行方式顺带解决了训练速度问题。在传统的单进程RL算法中,环境交互和模型更新串行执行,环境交互占用大量时间。A3C让多个worker并行交互,共享参数服务器(通常就是CPU主进程),训练效率大幅提升。要知道在2016年,GPU资源还远没有今天普及,很多实验室用CPU集群跑模型,A3C的CPU多线程方案非常契合当时的硬件条件。

3.2 n-step回报:单步与蒙特卡洛的折中

A3C的核心改进之一是引入n步回报(n-step return)。不等待整条轨迹,也不是只用一步TD误差,而是走固定步数n步之后,用V(s_{t+n})替换剩余部分的真实回报,表达式为:

R_t^{(n)} = r_t + γ r_{t+1} + γ² r_{t+2} + ... + γ^(n-1) r_{t+n-1} + γ^n V(s_{t+n})

这个设计很有讲究:单步TD方差小但偏差大(因为V估计不准),蒙特卡洛无偏但方差大,n步回报取一个中间值,在两者之间找平衡。在实验里,n选得太小训练容易发散(短视,只看局部奖励),选得太大又退化成REINFORCE(方差爆炸),需要结合环境的奖励稀疏程度去调。

n步回报还有一层作用:让梯度信息覆盖更长的因果链,让动作和后续奖励的关联不至于被单步bootstrap截断。

3.3 熵正则与探索

每一个A3C的更新公式里,几乎都带有一项熵正则(entropy regularization term),它的作用是增加策略的熵,避免策略过早收敛到某个确定性的次优行为。

公式大致是:

∇_θ J(θ) = ∇_θ log π(a_t|s_t) * A_t + β * ∇_θ H(π(·|s_t))

其中H是策略熵,β是权重系数。在CartPole这种简单环境里,β取0.01左右通常够用;在更复杂的任务中,β可能需要调大或者做退火(annealing),让策略在前期多探索、后期再固化。

我在实际训练中见过一个典型现象:去掉熵正则后,策略很容易陷入"来回摆动"的循环模式——在一个局部区域反复横跳,整体回报始终上不去。加上熵正则后,策略会保持一段时间的随机性,反而能跳出局部陷阱找到更好的策略。这里我想强调的是,探索不是运气,是需要显式设计的机制。

4. A3C与A2C:两个只有一字之差的算法

4.1 同步与异步的本质区别

A3C发布之后不久,有人意识到异步带来的复杂性和bug风险可能大于收益。A2C(Advantage Actor-Critic)看起来和A3C几乎一样,唯一区别是:A3C中每个worker独立与共享参数交互并异步更新,而A2C中多个worker并行交互、收集完各自的n步经验后,再同步计算梯度、统一更新一次共享参数。

不要小看这个区别。异步更新会造成"陈旧梯度"问题:worker A计算梯度时用的是旧参数,等它把梯度发回主进程时,参数已经被worker B更新过好几轮了,这个梯度指向的方向可能已经过时,会拖慢收敛甚至导致发散。A2C用同步方式避免了这个问题,代价是多个worker必须等最慢的那个跑完才能更新,存在一定的等待开销。

还有一个工程上的实际优势:A2C的训练逻辑更简单,不需要维护独立的参数服务器、不需要处理worker之间的锁和通信,适合在GPU上跑。A3C最初设计是为CPU多线程考虑的,而A2C在GPU上可以用更大的batch size(多个worker的经验拼成一个大batch)来训练,效率反而更高。我在实际项目中,除非是特殊的多机部署需求,否则更倾向于A2C。

4.2 为什么现在主流框架都选A2C

从论文数量上看,A3C的历史地位更高,但在工程实践中,A2C几乎成了替代品。原因归结为三个方面:

一、稳定性。同步更新让训练过程的置信度更高,梯度方向更一致,超参数调节相对容易。异步版本常常因为update锁竞争和梯度陈旧问题出现莫名的不稳定。

二、兼容性。A2C天然适配批量计算。多个worker采集的数据拼接为一个batch,可以充分利用向量化计算,在GPU上训练速度快且稳定。

三、调试友好。A2C的loss曲线更平滑,更容易判断训练状态。A3C由于异步更新,loss曲线上会有更多毛刺,让人难以分辨是正常探索还是出了问题。

4.3 A3C的"价值"在哪里

这么说并不代表A3C不值得读。恰恰相反,A3C论文里包含了许多宝贵的工程思想:在共享模型中保存全局参数,每个worker有自己独立的"影子"模型;用锁机制控制参数更新;通过n步回报+优势估计+熵正则的组合构建训练信号。这些设计在后续很多算法中都有影子,理解了A3C,再读后面那些结合了各种技巧的算法会轻松很多。

我个人的建议是,入门时先把A2C跑通,把训练曲线调稳,再去尝试A3C或进一步阅读PPO。阅读论文和实现代码的顺序可以"倒着来"——先实现A2C理解AC框架的基本套路,再回头读A3C论文,能一下子看懂作者为什么要设计那些看似多余的机制。

5. TensorFlow2.0代码实现:从零搭建AC框架

5.1 实验环境与任务选择

代码实现以A2C为例,分成三个核心部分:策略网络定义、训练数据采集、模型更新循环。我选择的环境是经典连续控制任务Pendulum-v0(在较新版本Gym中可能需要通过环境ID兼容处理或替换为Pendulum-v1),目的是让实现尽量简洁,同时保留连续动作和复杂奖励的设计,足以验证AC框架的基本能力。

环境信息如下:

  • 状态空间:3维(角度sin、角度cos、角速度)
  • 动作空间:1维连续(扭矩,范围[-2, 2])
  • 奖励:每步给予与角度、角速度相关的负值,目标是将摆锤立到竖直位置

在Gym新版环境中,环境名称可能需要调整,但数据结构基本一致。

5.2 模型定义:Keras函数式API

用TensorFlow2.0的Keras接口定义一个共享底座的Actor-Critic网络。选用的结构是:输入层 -> 两个隐藏层(各128个神经元,ReLU激活)-> Actor输出分支(均值、log标准差)和Critic输出分支(标量V值)。

import tensorflow as tf from tensorflow import keras from tensorflow.keras import layers class ActorCritic(keras.Model): def __init__(self, action_dim, hidden_dim=128): super(ActorCritic, self).__init__() self.fc1 = layers.Dense(hidden_dim, activation='relu') self.fc2 = layers.Dense(hidden_dim, activation='relu') self.mu_head = layers.Dense(action_dim) self.log_std_head = layers.Dense(action_dim) self.value_head = layers.Dense(1) def call(self, state): x = self.fc1(state) x = self.fc2(x) mu = self.mu_head(x) log_std = tf.clip_by_value(self.log_std_head(x), -2.0, 2.0) std = tf.exp(log_std) value = self.value_head(x) return mu, std, value # 返回value时保持其形状可传播 def sample_action(self, state): mu, std, value = self(state) dist = tfp.distributions.Normal(mu, std) action = dist.sample() # 动作被截断到合法范围 action = tf.clip_by_value(action, -2.0, 2.0) return action, value

等一下,上面sample_action里用到了tfp,这是TensorFlow Probability库。如果不想引入额外依赖,可以用tf.random.normal手动采样:

def sample_action(self, state): mu, std, value = self(state) noise = tf.random.normal(shape=tf.shape(mu), dtype=tf.float32) action = mu + noise * std return tf.clip_by_value(action, -2.0, 2.0), value

我实际实现时倾向于手动采样,少一个依赖,代码也更容易读。注意log_std的clip操作很重要,如果不做限制,log_std训练中可能变成很大的负数,导致策略过早变得确定性过强,失去了探索能力。这属于一个隐性超参数。

5.3 训练流程:n步收集与GAE实现

A2C的训练流程可以概括为:多个worker并行或串行地与环境交互,收集n步的(s, a, r, s_next, done)序列,然后在汇总的batch上计算损失,反向传播更新网络。

为了简化代码,我统一用单个worker跑多个并行环境的向量化版本(利用Gym的gym.vector.AsyncVectorEnv),batch size由并行环境数乘以n步决定。这种做法的好处是把"异步"彻底从代码中移除,但训练的稳定性一点也不差。

核心的训练循环代码如下:

def compute_gae(rewards, values, dones, gamma=0.99, lam=0.95): """ 计算GAE优势函数 rewards: shape [T, num_envs] values: shape [T+1, num_envs] dones: shape [T, num_envs] """ T = rewards.shape[0] advantages = [] gae = 0.0 next_value = values[-1] # V(s_{t+n}) for t in reversed(range(T)): delta = rewards[t] + gamma * next_value * (1 - dones[t]) - values[t] gae = delta + gamma * lam * (1 - dones[t]) * gae advantages.append(gae) next_value = values[t] advantages = tf.stack(list(reversed(advantages)), axis=0) return advantages

这段代码是A2C训练中最关键的"零件",它就是泛化的优势函数估计(GAE)。lam是GAE的衰减系数,控制多步优势的权重,作用与滑动平均类似。lam=0退化为单步TD优势,lam=1接近蒙特卡洛优势。实际操作中0.95是普遍好用的起点。

注意dones的处理:当一步结束时,next_value不应该参与累计,所以要乘(1 - dones[t])。漏掉这个mask是我早期代码中一个隐藏很深的问题,会导致回合边界上的价值估计错乱。在连续任务如Pendulum中其实很少遇到done,但一旦换到CartPole这类回合制任务,这个mask就至关重要。

5.4 actor_loss与value_loss的组装

有了优势函数,损失函数就很容易组装了。Actor损失是策略的负对数似然乘以优势,加一个熵正则。Critic损失是价值预测与n步回报之间的均方误差。总损失为两者加权求和。

def actor_critic_loss(model, states, actions, advantages, returns): # 预测 mu, std, values = model(states) # 对数概率 log_prob = -0.5 * tf.square((actions - mu) / std) - tf.math.log(std) - 0.5 * tf.math.log(2 * tf.constant(3.1415926)) log_prob = tf.reduce_sum(log_prob, axis=-1, keepdims=True) # Actor loss: 负对数似然 * 优势 actor_loss = -tf.reduce_mean(log_prob * advantages) # 熵: -0.5 * (1 + log(2π std^2)) entropy = 0.5 * (1 + tf.math.log(2 * tf.constant(3.1415926)) + tf.math.log(tf.square(std) + 1e-8)) entropy = tf.reduce_mean(entropy) # Critic loss: MSE value_loss = tf.reduce_mean(tf.square(returns - tf.squeeze(values))) return actor_loss - 0.01 * entropy + 0.5 * value_loss

几个值得注意的点:

  • log_prob的计算用的标准正态分布密度公式,计算方式都基于此推导,但如果环境动作维度多,这个平铺公式会显得笨重,可以考虑用tfp封装好的分布对象。
  • actor_loss前面的负号是必要的,因为我们做的是梯度下降优化loss,策略梯度原本是梯度上升。加负号后变成最小化负对数似然乘以优势。
  • 0.5 * value_loss里的0.5是一个权重系数,用于平衡两个loss的数量级。在Pendulum中,value loss的数值通常远大于actor loss,如果不加权,训练会被Critic主导,Actor更新变得无效。
  • 熵系数0.01在Pendulum上效果尚可,但不同任务差别很大,需要实验调整。一个经验法则是:如果策略过早收敛(训练后期熵接近0且回报停滞),就把熵系数调大。

5.5 完整训练循环

def train_a2c(env_fn, num_envs=8, steps_per_update=128, lr=3e-4, gamma=0.99, lam=0.95): # 创建并行环境 envs = gym.vector.AsyncVectorEnv([env_fn for _ in range(num_envs)]) model = ActorCritic(action_dim=envs.action_space.shape[0]) optimizer = keras.optimizers.Adam(learning_rate=lr) states = envs.reset() states = tf.convert_to_tensor(states, dtype=tf.float32) for episode in range(1000): reward_sum = 0.0 batch_states, batch_actions, batch_rewards, batch_dones = [], [], [], [] value_sum = 0.0 for step in range(steps_per_update): actions, values = model.sample_action(states) next_states, rewards, dones, infos = envs.step(actions.numpy()) # 收集数据 batch_states.append(states) batch_actions.append(actions) batch_rewards.append(rewards) batch_dones.append(dones) reward_sum += tf.reduce_mean(rewards).numpy() states = tf.convert_to_tensor(next_states, dtype=tf.float32) if step == steps_per_update - 1: # 计算最后一个状态的value用于bootstrap _, _, next_values = model(states) boot_values = next_values.numpy().flatten() else: _, _, next_values = model(states) value_sum += tf.reduce_mean(next_values).numpy() # 转成tensor,计算GAE和returns batch_states = tf.stack(batch_states) batch_actions = tf.stack(batch_actions) batch_rewards = tf.stack(batch_rewards) batch_dones = tf.stack(batch_dones) # values需要在所有states上重新预测 _, _, all_values = model(batch_states) all_values = tf.squeeze(all_values) # 拼接bootstrap的value value_with_final = tf.concat([all_values, tf.expand_dims(tf.convert_to_tensor(boot_values), 0)], axis=0) advantages = compute_gae(batch_rewards, value_with_final, batch_dones, gamma, lam) returns = advantages + all_values with tf.GradientTape() as tape: actor_loss, value_loss, entropy = actor_critic_loss(model, batch_states, batch_actions, advantages, returns) total_loss = actor_loss + 0.5 * value_loss - 0.01 * entropy grads = tape.gradient(total_loss, model.trainable_variables) grads, _ = tf.clip_by_global_norm(grads, 0.5) optimizer.apply_gradients(zip(grads, model.trainable_variables)) if episode % 50 == 0: print(f"Episode: {episode}, Reward: {reward_sum / steps_per_update:.3f}, " f"Value Loss: {value_loss:.4f}, Actor Loss: {actor_loss:.4f}") envs.close()

代码中梯度裁剪(tf.clip_by_global_norm)是保证训练稳定的关键操作。RL训练中梯度的范数经常突然变大,出现极端情况时一次更新就可能毁掉整个模型。把全局范数裁剪到0.5左右能有效防止这种"一步回到解放前"的现象。我测试过不裁剪的版本,在Pendulum上几乎必然在某轮训练爆炸。在更复杂的任务上,这个建议依然是第一优先级。

6. 训练效果分析与调参实录

6.1 训练曲线怎么看

Pendulum-v0任务的目标是把摆锤控制在竖直向上位置。奖励函数给的是负值,所以收敛的标准是"奖励越接近0越好"。初始随机策略下,每个episode的回报大约在-1500左右;训练收敛后,可以稳定在-300到-200之间,动作曲线应该是一条趋于平缓而略带抖动的稳定曲线。

我在实验中最关注三个指标:

  • 平均回报:整体趋势向上且波动逐渐减小。
  • 价值损失(value loss):如果持续下降但下降到某个数值后长时间不动,说明价值网络已经拟合得差不多,再往下压只会过拟合。
  • 策略熵:理想情况是缓慢下降,保持适度随机性。如果快速跌到接近0,说明探索能力消失;如果始终很大,说明策略没学到有用信息,可能是学习率太低或网络表达能力不足。

6.2 对收敛影响最大的几个超参数

第一是学习率。A2C对学习率极其敏感,尤其在共享参数结构中,Actor和Critic共用一个优化器,学习率过高会让策略在训练早期就乱跳,之后很难恢复。我在Pendulum上测试过:lr=1e-3很难稳定收敛,3e-4比较稳妥,1e-4收敛慢但曲线非常平滑。在更复杂的连续控制任务上,微调到1e-4甚至5e-5并不罕见。

第二是steps_per_update(即n步长度)。这个参数决定了优势估计的时间视野。太小(比如16步)会引入较大偏差,训练容易"只见树木不见森林";太大(比如2048步)计算开销增加,优势估计接近蒙特卡洛,方差相应增大。Pendulum这种短回合任务,128步是一个不错的中间值。

第三是GAE的lambda参数。lambda越接近1,优势估计越偏向蒙特卡洛,方差越大;越接近0,越偏向单步TD,偏差越大。0.95是很多实验验证过的通用值,但如果发现训练中后期奖励总有奇怪尖峰,可以考虑调低到0.9试试。

第四是并行环境数。并行环境数从4加到16,训练稳定性会明显改善,因为batch数据更加多样。但超过一定数量后收益递减,还占内存,我一般选8。

6.3 常见问题与定位思路

我在实现和调试过程中踩过不少坑,这里整理成一张速查表,方便按症状对号入座。

症状可能原因排查方向与对策
训练刚开始value loss就是NaN梯度爆炸检查是否做了梯度裁剪;尝试调低学习率;检查log_std是否被clip
回报曲线长期在-1500附近不涨探索不足或学习率太低增大熵正则系数;提高学习率;检查是否忘记在更新中加熵项
回报上涨后又突然崩掉超参数过大导致策略过度更新降低学习率;减小熵系数;增大steps_per_update,让更多数据参与平均
训练结束时策略熵几乎为0策略过拟合了某个局部最优增大熵正则系数;考虑对log_std设置下限,保留基础探索能力
换一个环境后完全不收敛环境特性不匹配优先检查奖励尺度,必要时对reward做归一化;检查动作边界是否与采样分布匹配
value loss很小但actor loss上升Actor和Critic失去平衡调整value loss权重;分别设置Actor和Critic的学习率

重要提示:RL训练失败的第一反应应该是"查训练曲线的形状、查打印的log和loss数值分布",而不是盲目相信某个最优超参数组合。推荐的做法是:先固定一个基准环境跑通流程,再逐步迁移到目标环境,每次只改动一个变量,做好记录。

6.4 后续可以扩展的方向

把A2C跑通之后,下一步的进阶路径非常清晰:在A2C的基础上加入重要性采样和clip机制,就得到了PPO;在AC架构中加入Q-learning的double估计,可以得到SAC;把Critic换成Q函数,用deterministic policy gradient,则成为DDPG。每条路都有大量经典论文可以细读。

如果你对代码的工程效率有更高要求,可以尝试把多个环境交互过程改成异步(回到A3C思路),或者把数据采样和训练分成两个线程,用消息队列解耦。这些优化做起来并不困难,本质上就是在打"吞吐量"和"同步等待"的算盘。

我个人在读完这条论文链并完成实现之后的最大感受是:RRL算法的公式推导是"骨架",考察的是一个公式到另一篇公式的连续性;而代码实现是"血肉",很多论文里轻描淡写一笔带过的技巧(比如梯度裁剪、熵正则、log_std下限),才是真正影响训练成败的关键。纸上得来终觉浅,绝知此事要躬行—— 这句话在强化学习里尤其适用。建议你读完推导后,亲手把这段代码敲一遍,调一遍参,再回头去看PPO论文的时候,会发现自己已经站在一个完全不同的高度上。

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

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

立即咨询