ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

PyTorch实战A2C调参:在Pendulum-v1上实现稳定收敛的完整指南

PyTorch实战A2C调参:在Pendulum-v1上实现稳定收敛的完整指南 PyTorch实战 A2C 调参这是个老话题但每次写都感觉有新的东西可以挖。尤其是 Pendulum-v1 这个环境看着简单——就一个倒立摆动作空间也就一维扭矩但真把 A2C 丢进去跑起来你会发现它比 CartPole 刁钻得多。CartPole 你随便给个策略梯度都能稳定收敛Pendulum 动辄几千 episode 不降反升、loss 变 NaN、reward 困在 -300 附近出不来这些问题我在不同项目里反复踩过。这篇文章不做那种跑通就好的演示而是把我实际调参过程中的判断依据、排查链路和最终验证过的收敛参数组合全部摊开来讲涉及到代码的部分会用 PyTorch 给出可直接改的运行版本。无论你是刚入门强化学习、在 anaconda PyCharm 里刚搭好 pytorch 环境还是已经被连续动作空间折磨了一遍的老手这篇都值得你花十分钟看完少走几周弯路。1. 为什么选 Pendulum一个能逼出算法问题的强化学习试验台1.1 Pendulum-v1 的物理模型与奖励机制拆解Pendulum-v1 在 Gym 里的设定是一根杆子一端固定在原点另一端可以自由旋转你的目标是通过给杆子施加扭矩让它从任意随机初始角度竖起来并停在正上方。状态空间是三维的cos(theta)、sin(theta)、theta_dot动作空间是一维连续值范围[-2, 2]单位是扭矩。奖励函数长这样reward -(theta_normalized**2 0.1 * theta_dot**2 0.001 * action**2)这里theta_normalized是把角度归一到[-pi, pi]。换句话说每步的奖励都是负数最大也就接近 0最差大概 -16 左右角度偏差接近 pi角速度接近 8 rad/s扭矩打满。单 episode 是 200 步所以累计奖励的理论区间大致在[-1600, 0]但实际上一个训练失败的模型长期在 -300 到 -800 之间徘徊是很常见的。这套奖励设计的微妙之处在于它把位置偏差的平方 角速度的平方 控制代价的平方揉在了一起。和 CartPole 那种 dense reward 不同Pendulum 的奖励在接近目标点时变化平缓、在远离时惩罚梯度很大这会让策略梯度算法在早期很难获得稳定的提升信号。很多人上来就用 CartPole 那套超参数直接跑结果自然崩。1.2 连续动作空间对 A2C 的真正考验A2CAdvantage Actor-Critic的核心思路并不复杂Actor 根据状态输出动作分布Critic 估计状态价值然后用优势函数Advantage作为 Actor 更新方向的权重。离散动作空间里Actor 输出的是分类分布用 CrossEntropy 甚至直接用log_prob就能算但到了 Pendulum 这种连续动作空间Actor 必须输出一个高斯分布的均值和对数标准差通过重参数化采样得到动作。麻烦就出在这个对数标准差上。它对超参数极其敏感初始值太大比如 1.0策略方差过大动作随机性太强Critic 的价值估计被噪声带偏初始值太小比如 -2.0策略过早确定性化探索不足容易陷入局部最优——杆子停在某个偏角上永远学不出来。这是连续控制任务独有的也是 A2C 在 Pendulum 上收敛缓慢的根源之一。从算法选择的视角看为什么不用 DDPG 或 TD3DDPG 在这类单摆任务上确实快但依赖经验回放缓冲区、目标网络、软更新这些组件本质上属于 off-policy 的套路A2C 是 on-policy逻辑更直接计算开销小不需要维护大 buffer对超参数的敏感度虽然高但一旦调通收敛过程非常干净特别适合用来理解策略梯度类方法的行为特征。这也是我坚持用 A2C 做实验、并愿意花力气调参的核心理由。2. 从零搭建 A2C网络结构与代码实现的关键决策2.1 Actor 与 Critic 的分工设计共享底层还是完全独立网络结构我前后试过三种方案Actor 和 Critic 完全共享一个两层 MLP输出头分叉Actor 和 Critic 各自独立的 MLP互不干扰共享第一层第二层分头。实测结论很明确在 Pendulum 这种低维输入环境下完全独立的网络收敛最稳定共享网络在训练早期会有一段互相拉扯的过程loss 曲线会多出一个明显的平台期。原因也好理解Actor 需要的是哪个方向更优的梯度Critic 需要的是纯价值回归的梯度两者对特征空间的需求侧重不同。共享底层相当于强行让两条梯度流共享特征在任务简单时弊大于利。我最终用的 Actor 是[3, 64, 64]的 ReLU MLP输出层是两个分支——一个输出均值mu一个输出对数标准差log_stdCritic 是独立的[3, 64, 64]ReLU MLP直接输出标量。这里有一个很多人忽略的细节log_std的输出层不应该带激活函数初始值我用-0.5而不是 0。原因是在 Pendulum 里扭矩范围只有[-2, 2]如果初始探索方差太大采样出来的动作几乎全是[-2, 2]两端策略梯度会被极端样本支配。初始 log_std 取-0.5意味着标准差约 0.6动作大多落在[-1.2, 1.2]既保留探索又不至于太疯。2.2 环境搭建与 PyTorch 安装里容易被忽略的坑网上 pytorch 安装教程一大堆但强化学习项目真正会卡住的往往不是安装本身而是版本组合。我个人踩过最典型的一个坑是用pip install gym装了老版本的 Gym0.21 及以下再配合某些教程里的env.unwrapped.state访问方式在Pendulum-v0上跑了半天后来才发现新版 Gym 已经把Pendulum-v0改名成Pendulum-v1并且 step 返回值的格式也从(state, reward, done, info)变成了(obs, reward, terminated, truncated, info)。建议直接用 gymnasiumGym 的官方继任库代码这样写import gymnasium as gym import torch import torch.nn as nn import torch.optim as optim env gym.make(Pendulum-v1) obs_dim env.observation_space.shape[0] # 3 action_dim env.action_space.shape[0] # 1另外一个跟版本强相关的问题是 PyTorch 2.x 里torch.distributions.Normal的rsample()和sample()在自动求梯度上的差异。策略梯度更新时建议用sample()得到动作去 env 里跑然后单独用log_prob求梯度如果误用rsample()且后面又对log_prob反向传播PyTorch 2.x 下会出现重参数化路径带来的额外梯度项导致 loss 曲线诡异漂移。这个小坑我查了两天才定位到这里先帮你排掉。2.3 核心训练循环GAE 优势估计的实现细节A2C 的标准做法是收集n_steps比如 128 或 256长度的轨迹然后用 Generalized Advantage EstimationGAE计算优势再统一更新。GAE 的公式是# delta_t r_t gamma * V(s_{t1}) - V(s_t) # A_t delta_t gamma * lambda * A_{t1}用 PyTorch 实现时要特别注意不要用纯 Python for 循环从后往前算而是利用张量反向切片或直接循环步数只有 128其实 for 循环不会慢太多但写法要正确。核心代码如下def compute_gae(rewards, values, dones, gamma0.99, lam0.95): advantages torch.zeros_like(rewards) gae 0.0 next_value 0.0 # 注意 rewards/values 的长度都是 n_steps for t in reversed(range(len(rewards))): if t len(rewards) - 1: next_value 0.0 # 轨迹结束后的价值按 0 算 else: next_value values[t 1] delta rewards[t] gamma * next_value * (1 - dones[t]) - values[t] gae delta gamma * lam * (1 - dones[t]) * gae advantages[t] gae returns advantages values return advantages, returns这里有三个我自己反复验证过的细节。第一dones在 Pendulum 里其实永远为假Gymnasium 默认max_episode_steps200截断但这种 truncated 不算真正的环境终止所以(1 - dones)这一项在 Pendulum 里基本等于 1但写上没坏处换别的环境能直接复用。第二values应该是critic(obs)的输出要在环境里跑轨迹时实时算好存下来而不是事后重新算——省一次前向是小事关键是避免梯度图交叉导致的内存泄漏。第三GAE 算完优势后要把advantages做标准化减均值除以标准差这一步对 A2C 的稳定性影响极大下面调参部分会展开。训练循环的最后就是一次性用优势值同时更新 Actor 和 Criticpolicy_loss -(log_probs * advantages.detach()).mean() entropy_loss -entropy_coef * dist.entropy().mean() critic_loss F.mse_loss(values, returns.detach()) total_loss policy_loss entropy_loss critic_loss optimizer.zero_grad() total_loss.backward() nn.utils.clip_grad_norm_(actor.parameters(), max_norm0.5) nn.utils.clip_grad_norm_(critic.parameters(), max_norm0.5) optimizer.step()advantages.detach()必须加否则梯度会从策略网络绕一圈回流到 Critic造成双重更新这是我早期代码里最隐蔽的一个 bug。3. 调参实战这些超参数才是收敛快慢的分水岭3.1 学习率Actor 和 Critic 该不该共用一套很多 A2C 开源代码为了省事用同一个 Adam 优化器、同一个学习率同时更新 Actor 和 Critic。在 CartPole 上这种做法没问题但在 Pendulum 上我做了多组对照结论是Critic 需要比 Actor 更激进的学习率。原因在于Critic 做的是回归任务目标是拟合真实的回报期望它的误差收敛速度直接决定了优势函数的质量而 Actor 的学习率过大会导致策略分布骤变采集到的轨迹分布快速飘移产生严重的 on-policy 方差。我推荐的组合是Actor 学习率3e-4Critic 学习率1e-3两个网络用独立的 Adam 优化器。这个比例不是玄学——我在同样条件下把 Critic 学习率也降到3e-4收敛速度明显变慢把 Actor 学习率提到1e-3训练到中段会出现明显的 reward 回撤。如果你喜欢一个总优化器带两个参数组也可以用 PyTorch 的分组学习率写法optimizer optim.Adam([ {params: actor.parameters(), lr: 3e-4}, {params: critic.parameters(), lr: 1e-3}, ])这种写法最省事也方便后续按组做学习率衰减。3.2 GAE 中的 gamma 与 lambda时序差分视野的取舍Pendulum 的奖励是每一步都有的稠密惩罚没有稀疏的成功标志。这种情况下gamma取 0.99 是主流选择意味着 Critic 大约能看到未来 100 步的量级基本覆盖了一个完整 episode200 步的大半。我之前试着把gamma降到 0.95结果训练初期 divergence 更快因为在 200 步的单摆任务里动量累积效应需要较长时间尺度才能体现折扣太快会让 Critic 根本学不到先反向摆、再顺势甩上去这种链式策略。lambda的作用是平衡偏差和方差。lambda0时退化为一步 TD偏差大、方差小lambda1时退化为蒙特卡洛偏差小、方差大。我的实测经验是lambda0.95是一个很稳的甜点。当训练到中后期、策略已经接近最优时我会把lambda稍微调到 0.98让优势估计更多地利用真实回报此时 reward 曲线会有一个小幅跳升。这个后期提高 lambda的小技巧在多个随机种子下都复现了正收益。一个容易被忽略的配套参数是 rollout 长度n_steps。如果n_steps太短比如 32GAE 在截断处的优势估计依赖最后的next_value0假设偏差很大如果太长比如 512一次收集轨迹的时间变长更新频率降低小批量训练的反而不如短轨迹高效。我在 128 和 256 之间做过对比n_steps256对 Pendulum 的效果略好特别是配合lambda0.95时优势估计更平滑。最终建议组合是n_steps256, gamma0.99, lambda0.95。3.3 熵系数探索与收敛的平衡木在连续动作空间里熵正则项控制的是高斯策略的方差保持程度。标准做法是在策略损失上加一项entropy_coef * dist.entropy()熵越大策略越鼓励随机探索越强。Pendulum 上这个系数我从0.001到0.1全测过一遍0.1探索太猛训练 3000 个 episode 后 reward 还在 -500 附近波动策略方差始终降不下来0.01收敛稳定大约 800 个 episode 后能稳定在 -200 以上0.001前期收敛最快大概 500 episode 就能摸到 -200但中后期容易卡在 -250 附近的局部最优方差已经坍缩很难再跳出来0彻底去掉熵正则训练到 1000 episode 时策略崩溃过一次log_std 疯狂增长动作变成纯随机噪声。我的结论是初始熵系数用0.01最稳但如果想追求更快的前期收敛可以用0.005并配上稍大的初始log_std比如 -0.3。另一种做法是熵系数衰减前 500 个 episode 用 0.02 强制探索之后每 200 个 episode 衰减到 0.8 倍。这种方法跑出来的最终策略往往比固定系数更好因为它对冲了训练后期的方差问题。3.4 batch size、更新轮次与 rollout 数量的搭配逻辑有些人把 A2C 理解成收集一条轨迹、更新一次这在理论上没问题但实际中单条轨迹的梯度噪声巨大收敛效率很低。我在 PyTorch 里采用的做法是每次收集一个 batch 的轨迹数据然后对这批数据做多次 minibatch 更新。这个思路从 PPO 借鉴过来但在 A2C 里也成立只要更新轮次别太多否则就成了 off-policy 更新。具体配置rollout_steps 256 # 每个环境收集 256 步 num_envs 4 # 并行 4 个环境可以用 gymnasium.vector 实现 minibatch_size 128 # 每次从 buffer 里抽 128 条 transition update_epochs 4 # 同一批数据重复更新 4 轮num_envs4并行环境是我强烈建议的。A2C 的 A 代表 Asynchronous虽然现在的同步版本 A2C 其实主要是靠多环境并行去相关但增加环境数能显著降低同一时刻样本时间上的相关性。我用 1 个环境跑reward 曲线抖动非常厉害4 个环境并行后曲线肉眼可见地平滑了至少 40%。很多人没有意识到的是Pendulum 对探索噪声很敏感而并行环境的初始状态多样化本身就是一种天然的探索增强。gymnasium.vector.SyncVectorEnv的用法很简单但要注意新版中env.reset()返回的是(obs, info)格式env.step(action)返回的是五元组代码在 2.3 节的基础上要做适配——这也是 gymnasium 用户最多踩的接口坑没有之一。4. 收敛优化的三板斧归一化、梯度裁剪与奖励塑形4.1 观测归一化为什么 Pendulum 的角度输入需要单独处理Pendulum 的状态是(cos(theta), sin(theta), theta_dot)。前两个维度天然在[-1, 1]看起来不需要归一化但theta_dot的范围通常在[-8, 8]之间极端情况下能到[-15, 15]。如果不做处理神经网络第一层的梯度会被theta_dot这一维主导导致模型对角度信息的敏感度不足——这就像一个人只盯着车速表开车完全不看方向盘偏角。我试过三种方案不归一化、用固定常数除比如obs[2] / 8、用 running mean/std 动态归一化。最终固定使用 running normalization因为 Pendulum 的训练过程中theta_dot的分布会随着策略变化而发生剧烈迁移固定除数无法适应。实现上可以维护一个简易的运行统计器class RunningMeanStd: def __init__(self, dim): self.mean torch.zeros(dim) self.var torch.ones(dim) self.count 1e-4 def update(self, x): batch_mean x.mean(dim0) batch_var x.var(dim0) batch_count x.shape[0] self.count batch_count delta batch_mean - self.mean new_mean self.mean delta * batch_count / self.count m_a self.var * (self.count - batch_count) m_b batch_var * batch_count delta2 batch_mean - new_mean self.var (m_a m_b delta2**2 * self.count * batch_count) / self.count self.mean new_mean def normalize(self, x): return (x - self.mean) / (torch.sqrt(self.var) 1e-8)注意归一化只作用于 Critic 的输入和 Actor 的输入不要对奖励做这种 running 归一化奖励的分布更不稳定容易引发策略震荡。观测归一化在训练早期尤其重要它能显著缓解 A2C 的初始发散问题。4.2 梯度裁剪救回训练崩溃的最后一根稻草A2C 在连续控制环境中梯度爆炸是家常便饭。原因在于优势估计的方差在策略变化剧烈时会飙升乘以log_prob的梯度后参数更新量可能出现几个数量级的跳跃。我在 2.3 节代码里已经写了clip_grad_norm_的调用这个必须坚持加。梯度裁剪的核心参数是max_norm。我实测max_norm0.5和max_norm1.0差异不大但max_norm5.0基本等于没裁剪。经验法则是如果训练中 loss 出现尖锐的尖峰把 max_norm 减半。有一次我在项目里把max_norm从 0.5 改成 0.1训练直接从频繁 NaN 变得稳定虽然单步更新变保守了但整体收敛速度反而更快。代价是训练时间略微增加换来的是不需要频繁重启实验。建议在更新后打印一次梯度的全局 norm 指标一旦发现超过 10就基本可以断定是某个超参数设置过分了。这个打印习惯能帮你把排查范围从玄学缩小到哪个组件在爆。4.3 奖励缩放被低估的 -16 到 0 难题Pendulum 的奖励常数项不是 0 对称的永远为负。这带来一个麻烦最优策略下的预期回报仍然是负值Critic 的网络输出也始终是负数。虽然理论上不影响策略梯度的正确性但实际训练中负奖励会拖慢 Critic 的收敛速度因为它的损失函数在逼近一个小范围负数时梯度方向会被持续的负反馈主导。我做了个简单实验把 reward 统一除以 10得到[-1.6, 0]的量级训练 500 episode 后 reward 曲线明显上移但中后期抖动加大后来又试了减去一个常数偏置比如reward 5让奖励变成有正有负结果训练稳定性反而下降。最终证明最有效的做法是保持原始奖励但在 Critic 的损失函数上做一点文章——把returns和values都减去一个滑动平均基线等效于对价值函数做中心化。returns_centered returns - returns.mean() values_centered values - values.mean() critic_loss F.mse_loss(values_centered, returns_centered.detach())这种价值中心化的思路对 A2C 在 Pendulum 上的训练稳定效果非常显著比改 reward 本身要安全得多。记住一句话不要轻易改环境给的奖励要改就改学习过程中的信号处理。奖励函数改差了策略会学到你完全意料之外的退化行为。5. 踩坑实录训练不收敛时我是怎么排查的5.1 症状一loss 变成 NaN 的完整排查链路NaN 问题我遇到不下五次每次的根因都不同这里把最重要的排查顺序写下来你可以当作 checklist 来用先查 log_std 是否存在溢出。高斯策略的log_prob计算中如果log_std变得极大比如超过 5torch.exp(log_std)直接变成 inf。预防方法是在 Actor 输出后对log_std做 clamp我限制在[-2, 2]之间。查优势值是否出现 inf。GAE 递归计算里如果values出现 inf优势也会 inf。这里要确认 Critic 输出没有经过激活函数——有些新手会在 Critic 输出层误加 Sigmoid导致价值被压到[0, 1]然后 GAE 计算得到的优势范围完全错乱。查奖励是否存在 NaN。Pendulum 环境理论上不会返回 NaN但如果你对观测做了归一化且期间除数为 0输入到网络的观测会变成 NaN回传后全链路崩掉。所以归一化的分母要加小常数1e-8。查优化器状态。如果训练到一半改变学习率调度比如用了torch.optim.lr_scheduler.ReduceLROnPlateau在某些 PyTorch 2.x 小版本里和自定义 loss 组合会产生诡异的inf梯度。换用手动 lr 衰减最保险。定位到问题的关键技巧是在 loss 里每个组件policy_loss、entropy_loss、critic_loss后面加 hook 打印各自的数值。如果只有某一个组件爆掉就能直接锁定对应网络的问题不需要逐行读代码。5.2 症状二reward 长期停留在 -300 附近毫无起色这个症状几乎每个人都遇到过。Pendulum 的随机策略平均回报大约在 -650 左右所以 -300 说明策略已经学会了一部分但卡住了。排查思路要分两步走。第一步看你用的 entropy 系数是否过大。熵正则太强时策略会一直维持高方差动作噪声大杆子即使在顶部附近也会因为持续抖动而拿不到高分。我自己的经验是entropy_coef0.01在 Pendulum 上前期没问题如果 800 episode 后 still -300可以把熵系数下调到0.003通常 100 episode 内就能看到明显改善。第二步看 Critic 的 loss 曲线。如果 Critic loss 在 2000 步后不再下降说明价值函数进入平台期这时候优势估计的准确度已经限制住了策略提升。可以临时加大 Critic 学习率到3e-3再跑 200 episode 试一下。我遇到过几次这种策略卡住、价值也卡住的死锁靠加大 Critic 学习率打破的。还有一种容易被忽略的情况batch size太大而update_epochs太小。rollout 里积累了 4 个环境 × 256 步 1024 条 transition如果 minibatch_size 取 512、只用 1 个 epoch 更新实际上梯度只覆盖了很有限的样本组合策略更新不够充分。把 minibatch_size 降到 128、epochs 提到 4卡住的局面会明显缓解。5.3 症状三前期正常后期突然发散这种玄学崩溃通常和策略方差坍缩、以及优势估计的误差累积有关。策略在后期接近最优时高斯分布的方差会越来越小动作基本确定此时如果 Critic 对某个状态的价值估得不准优势值会异常大Actor 被一个大优势推动自动跳到另一个劣化区域。这个区域中策略重新变得随机于是进入恶性循环。我遇到这个问题的解法是对 Actor 的 log_std 下限做硬约束不要让标准差完全坍缩到 0。实现上给log_std加一个偏置约束log_std torch.clamp(log_std, min-2.0, max2.0)同时后期的熵系数不要降为 0保留entropy_coef0.002左右的探索底量。这两条组合之后我的实验再没出现过2000 episode reward 已经 -1503000 episode 突然掉回 -400的崩溃现象。另一个后期发散的原因是 GAE 的lambda在训练后期没有调整。固定lambda0.95时优势估计的偏差始终存在到后期策略已经很接近最优时这个偏差会被放大。实践做法是逐步把lambda提升到0.98让真实回报在优势估计中占据更重比例具体实现就是每 500 episode 更新一次 lambda这个做法大大平滑了收敛曲线。6. 实测数据对比不同参数组合下的收敛曲线6.1 三组典型参数组合的横向对比为了让你直观理解调参的影响力我把三组参数在同一随机种子、同一网络结构下各跑 3000 个 episode记录每 100 episode 的平均回报测试时不加探索噪声参数组Actor LRCritic LRentropy_coefn_stepslambda500ep 平均回报1500ep 平均回报3000ep 平均回报基线组CartPole 套用1e-31e-301280.99-580-450-430均衡组3e-41e-30.012560.95-390-220-155极限收敛组3e-41e-30.005 衰减2560.95→0.98-370-190-120结论很清晰基线组的参数是网上最常见的一套但它在 Pendulum 上表现很差——0 熵系数配合较大学习率导致策略快速坍缩成确定性策略探索不足永远停在劣质局部最优。均衡组是我推荐的起点选项稳定性和速度平衡最好。极限收敛组适合你已经有了一定的调试经验、想冲最优策略分数的场景。最终在极限收敛组的配置下我观察到一个有趣的现象最优策略已经学会小幅摆动——蓄力——甩起——保持的模态动作杆子能在 -120 平均回报附近维持稳定。这是 Pendulum 上接近可接受水平的成绩受限于 A2C 本身的方差纯 on-policy 方法很难做到 -50 以下那种完美水平。6.2 稳定收敛后 Actor 的策略行为观察调参成功后值得做一件事把训练好的 Actor 拿出来在无探索噪声条件下跑一个完整 episode打印每个动作的均值和方差。你会看到初始状态远离目标时mu会给出一个大的正向或负向扭矩视角度方向而定log_std仍然保持一定非零值策略并没有完全锁死接近直立时mu逐渐变小动作趋于精细调整整个过程中log_std从初始的 -0.5 逐渐下降到 -1.5 附近收敛在一个合理范围。这个行为验证了一个结论恒定小熵系数配合方差下界约束让 A2C 学到的不是机械地输出确定动作而是输出带适度随机性的策略这正是 on-policy 算法在连续控制任务上应该有的样子。我在实践中还有一个习惯每 500 episode 保存一次模型 checkpoint并列出一个参数变化表格学习率、熵系数、lambda、优势标准化开关等这样每次实验后回溯都能清楚知道是哪一次改动带来了收益。调参这项工作本质上就是控制变量下的经验积累比起玄学碰运气系统性的记录和耐心观察要靠谱得多。如果你也打算在 Pendulum 上用 A2C 练手或做基线实验建议先把n_steps256、Actor LR3e-4、Critic LR1e-3、gamma0.99、lam0.95、entropy0.01、grad_clip0.5这组参数跑通再根据你的实际观察微调。接下来如果你感兴趣还可以在这个基础上尝试把 GAE 换成更激进的 n-step return或者在 Critic 上接入一个小的 target network 平滑价值目标——不过那已经是一篇新文章的话题了。
返回列表