
最近在折腾强化学习拿PyTorch在Pendulum环境上跑A2C算法发现网上教程要么只给理论公式要么给一堆跑不通的代码真正把调参经验和收敛技巧讲透的实在不多。这个环境虽然只有3维状态和1维动作看起来简单但A2C这种on-policy算法放上去学习率、步长、网络结构、熵权重、优势估计方式每一个环节拿出来都能让训练曲线彻底变脸。这篇博文就围绕A2C在Pendulum里的实战调参展开从环境特性、算法原理到代码实现再把我试错总结的参数范围、收敛判断和问题排查记录下来。适合已经把DQN玩过一遍、想转policy gradient方向、或者想在连续控制任务上跑通baseline的读者照着做基本能在一个小时内看到reward曲线往上升的趋势。1. A2C算法与Pendulum环境的核心理解1.1 为什么选Pendulum作为A2C的入门环境Pendulum是OpenAI Gym里一个连续控制任务目标是让一根从随机角度静止释放的摆杆通过施加力矩把它甩到竖直向上的位置并稳定住。动作空间是一个连续值范围在-2到2之间代表施加在关节上的力矩大小。跟CartPole那种离散动作不同Pendulum的动作是连续的这正好撞上A2C这类actor-critic算法的核心应用场景——actor输出的是一个概率分布的参数而不是一系列离散动作的logits。在Pendulum上通常假设动作服从高斯分布网络输出均值和标准差采样得到实际执行的力矩。这个环境用来入门A2C有几个好处状态维度低只有角度和角速度网络不用搞得很复杂就能拟合训练速度够快单机CPU跑几百个episode就能看到明显趋势但又不至于简单到失去参考价值——它存在局部最优、奖励尺度敏感、熵项容易崩塌这些真实问题跟你后面迁移到更复杂的连续控制任务时踩的坑是同一个套路。1.2 A2C的核心运行机制A2C全称Advantage Actor-Critic是A3C的同步版本。它的基本逻辑是同时维护两个网络actor负责根据状态输出动作分布critic负责评估当前状态的价值。每次交互采集一批经验后用GAE或者简单的n步回报计算优势函数再去更新两个网络。这里有个容易搞混的点A2C里的critic不是用来输出Q值的而是输出状态价值V(s)所以它的训练目标是最小化预测价值和实际return之间的MSE。Advantage则定义为Q(s,a)减去V(s)在实操中通常用r gamma * V(s) - V(s)来估计或者更平滑地用GAE累加多步的TD误差。用生活化类比解释actor和critic的分工actor像一个正在学骑自行车的人不断尝试踩踏板的力度和方向critic像一个在旁边观察的教练告诉actor刚才那脚踩得怎么样比预期好还是比预期差。优势函数就是教练给出的反馈信号——如果这个动作比教练预期的结果更好优势为正actor就加大这个动作的出现概率。1.3 为什么调参在A2C里如此关键A2C是典型的on-policy算法采样效率低这意味着每一批数据的质量直接决定了模型能不能往前走。学习率设大了策略更新太激进性能直接崩塌设小了几百个episode内什么都学不到。Pendulum还有一个特别坑的地方奖励函数本身带有一个平方项惩罚角度差越大惩罚越大所以奖励值天然处于一个负的数值区间。很多人第一步就栽在这里——reward直接喂给网络不做任何处理导致critic的价值估计方差极大优势函数震荡严重。后面你会看到A2C调参的实质是弄清楚每个参数在算法的哪个环节起作用、影响的是方差还是偏差、改大改小分别会带来什么后果。参数之间往往有耦合关系不是单独调一个就能解决的。2. 环境搭建与基线代码实现2.1 PyTorch环境配置我这里用的是PyTorch 2.x版本搭配Python 3.9操作系统是Windows 11。如果你从头搭建环境推荐直接用Anaconda管理虚拟环境避免依赖冲突。conda create -n rl python3.9 conda activate rl pip install torch pip install gymnasium pip install numpy matplotlib需要注意gym的版本问题。老教程里用的可能是gym 0.21或者0.26接口有变动。我建议直接用gymnasium它是gym的维护分支API更稳定。Pendulum在gymnasium的注册名是Pendulum-v1。截止到写这篇内容时PyTorch 2.x在CPU和GPU上跑这个小任务差距不大因为网络非常小瓶颈主要在环境交互和策略采样的循环上。所以新手阶段用CPU版本完全够能省去配置CUDA的烦恼。等以后跑PPO大模型或者图像输入类任务时再考虑GPU也不迟。2.2 Pendulum环境的接口细节Pendulum的状态空间是3维向量分别代表cos(theta)、sin(theta)和角速度thetadot。这里有个隐藏信息环境没有直接给你角度值而是给了角和角速度的正弦余弦组合目的是避免角度在pi和-pi之间的跳变问题。动作是一个浮点数范围[-2, 2]代表力矩。每一步的奖励定义为reward -(theta^2 0.1 * thetadot^2 0.001 * torque^2)其中theta是归一化到[-pi, pi]的角度偏差0表示摆杆在竖直向上的目标位置。这个奖励函数的特点决定了它永远是负的最小接近-16.27最大是0完全平衡状态。在训练过程中看到reward是负的很正常关键是看它能不能稳定上升并收敛到一个较小的负值区间。import gymnasium as gym env gym.make(Pendulum-v1) env.reset(seed42) for _ in range(3): action env.action_space.sample() obs, reward, terminated, truncated, info env.step(action) print(fobs: {obs}, reward: {reward}, done: {terminated or truncated})2.3 最小可运行的A2C骨架在动手调参之前先给出一份最基础的A2C代码框架。这个版本不做任何trick纯按原版算法实现后面所有优化和调参都是在这个基础上逐步叠加。import torch import torch.nn as nn import torch.optim as optim import torch.nn.functional as F import numpy as np from torch.distributions import Normal class ActorCritic(nn.Module): def __init__(self, state_dim3, action_dim1, hidden128): super().__init__() self.actor nn.Sequential( nn.Linear(state_dim, hidden), nn.Tanh(), nn.Linear(hidden, hidden), nn.Tanh(), ) self.mean nn.Linear(hidden, action_dim) self.log_std nn.Parameter(torch.zeros(action_dim)) self.critic nn.Sequential( nn.Linear(state_dim, hidden), nn.Tanh(), nn.Linear(hidden, hidden), nn.Tanh(), nn.Linear(hidden, 1), ) def forward(self, x): value self.critic(x) mu self.mean(torch.tanh(self.actor(x))) std self.log_std.exp() return mu, std, value def compute_gae(rewards, values, dones, gamma0.99, lam0.95): advantages [] gae 0 values values [0] for t in reversed(range(len(rewards))): if dones[t]: delta rewards[t] - values[t] gae delta else: delta rewards[t] gamma * values[t 1] - values[t] gae delta gamma * lam * gae advantages.insert(0, gae) returns [adv val for adv, val in zip(advantages, values[:-1])] return advantages, returns这个骨架里actor输出mean和log_stdlog_std是一个可学习的参数意味着标准差会随着训练自动调整。这种方式比固定std更灵活但也带来额外的不稳定因素——如果std降得太快探索能力会过早丧失这在后面调参时会反复碰到。3. 调参核心环节与优化技巧3.1 学习率A2C的命门学习率是A2C算法中影响最大的超参数这点我在Pendulum上验证了很多次。使用Adam优化器时actor和critic的学习率建议在1e-4到3e-4之间。我做过一组对照实验同一个种子下把学习率从3e-4调到1e-3结果在100个episode内reward曲线就出现了断崖式下跌之后再也没恢复过来。原因是学习率过大导致策略更新步长太大actor一次更新就把策略推到了reward非常差的区域而on-policy算法再想走出来非常困难。一个实用技巧是把actor和critic的学习率分开设置。critic的损失函数相对平滑可以用稍大一点的学习率比如3e-4加快价值函数收敛actor对学习率更敏感用1e-4更稳妥。这在实践中确实能减少训练初期的震荡。注意A2C不像DQN有经验回放缓冲每一批数据用完就扔。所以学习率一旦设置不合适没有机会用历史数据来纠错只能重头再训。3.2 GAE与折扣因子的配合使用Pendulum的回合长度默认是200步但强化学习任务中更科学的做法是不完全依赖回合结束而是用折扣因子gamma来平衡短期和长期收益。gamma取0.99时算法会把50步以后的奖励打5折以上0.99^50约等于0.605优化重心放在近期的动作选择上。GAE里的lam参数控制偏差和方差的权衡lam越接近1方差越大但偏差越小能捕捉到更长远的奖励影响lam越接近0越像单步TD偏差大但方差小。在Pendulum上我一般取lam0.95这个值在很多连续控制任务中都是不错的起点。如果你发现训练震荡剧烈可以试着降到0.9如果训练太平滑但最终性能不够好则试着升到0.98。def compute_gae(rewards, values, dones, gamma0.99, lam0.95): advantages [] gae 0 values values [0] for t in reversed(range(len(rewards))): if dones[t]: delta rewards[t] - values[t] gae delta else: delta rewards[t] gamma * values[t 1] - values[t] gae delta gamma * lam * gae advantages.insert(0, gae) returns [adv val for adv, val in zip(advantages, values[:-1])] return advantages, returns这段代码里有个容易忽略的细节dones[t]的地方把gae直接重置为单步TD误差因为回合结束后的状态价值是0。如果这里不重置超长片段的GAE计算会把终止状态的价值错加到下一个回合开头造成价值估计偏差。我见过不少人优化时把dones漏掉导致训练完全不收敛。3.3 优势归一化稳定训练的关键一步Pendulum的reward是负值计算出来的advantage也基本是负数或者围绕负数的某个区间波动。直接把这样一组advantage喂给actor做梯度计算梯度方差会非常大。一个非常简单又有效的处理方式是对advantage做标准化advantages torch.tensor(advantages, dtypetorch.float32) advantages (advantages - advantages.mean()) / (advantages.std() 1e-8)这步操作不改变样本之间的相对大小关系只是把尺度统一到了均值为0、方差为1的分布上。它的意义在于无论当前这批数据的advantage绝对值多大actor的更新幅度都被约束在一个合理的范围内不会因为某批数据特别差就导致策略剧烈退化。3.4 熵权重探索与利用的平衡A2C的actor损失中通常会加入一项策略熵的惩罚项目的是鼓励actor保持一定的随机性避免过早收敛到某个确定性策略而丧失探索能力。Pendulum因为动作空间连续探索全靠高斯分布的方差熵项的作用比离散动作环境更明显。熵项的形式是policy_loss -(log_prob * advantage.detach()).mean() - entropy_coef * entropy.mean()entropy_coef的取值在Pendulum上需要特别小心。设成0.1以上时训练初期的探索很充足action的std一直降不下来reward曲线的上升速度肉眼可见地变慢设成0.01左右算法既保留了探索能力又不至于让熵损失主导整个更新方向。我训练时的经验是前期用0.02等reward开始稳定上升后降到0.005后期再降到0.001。这种退火策略能有效避免策略在收敛后依然过度随机导致最终性能不高的问题。3.5 网络容量与初始化技巧Pendulum状态维度只有3动作维度只有1理论上网络不需要很宽。实践中我试过64、128、256三种宽度的隐藏层128和256在最终表现上差别不大但128的收敛速度明显更快。这说明在小规模连续控制任务中把网络做得过宽反而拖慢了学习速度。激活函数建议用Tanh而不是ReLU。原因有两个一是Pendulum的状态值本身在有限区间内波动Tanh的饱和特性让输出更稳定二是ReLU在actor输出层之前容易产生死神经元一旦某个神经元输出恒为常量策略的某些维度就失去了梯度来源。初始化的细节也值得注意。actor的最后一层mean输出一般初始化为0这样初始策略就会选择动作0也就是不施加力矩相当于从随机摆动开始学习。log_std初始化为-1到0之间的值对应标准差0.37到1既不会太小导致初始探索不足也不会太大导致初期动作非常混乱。很多实现直接初始化为0对应标准差1在Pendulum上也可以但前期稳定性稍微差一点。4. 收敛优化与训练稳定性提升4.1 奖励归一化处理负奖励的技巧Pendulum的奖励天然为负单纯把reward加起来作为回报数值本身没有绝对意义关键在于相对大小。但critic网络的输出是无界的去拟合一坨集中在[-1500, 0]区间的return时初期很容易预测得极其不准间接导致advantage信号充满噪声。处理这类问题最常见的做法是维护一个reward的running mean和std在计算GAE之前对reward做减去均值除以标准差的处理。不过在Pendulum上我实测发现直接对reward做标准化会让DQN类的经验回放训练出问题但在A2C这种on-policy算法里效果不错因为它只是对当前这批数据做了相对化处理不破坏时序关系。另一个替代方案是把reward先做clip操作限制在[-5, 1]之类的区间。但是Pendulum的奖励中大部分信息在接近0的部分clip过狠可能会丢失区分度。我建议先用原始的负reward训练观察曲线趋势如果震荡明显再考虑归一化。4.2 状态归一化处理不同尺度的输入特征Pendulum的3维状态中cos(theta)和sin(theta)天然在[-1, 1]区间但thetadot角速度的变化范围明显更大尤其在摆杆高速甩动时可能到[-8, 8]甚至更广。不同特征尺度差异大对网络训练不利。最简单的做法是在环境交互时对obs做标准化obs (obs - obs_mean) / (obs_std 1e-8)obs_mean和obs_std通过在训练初期收集一定数量样本估算之后固定下来。很多完整项目的做法是用RunningMeanStd类动态更新但Pendulum这种低维稳定环境固定统计量就够了。这里有一个很多人忽略的坑normalize obs的时候actor和critic的输入都要用同一个标准化参数否则critic看到的分布和actor不一致优势估计就乱了。如果做obs normalization建议把它设计成一个独立的预处理模块训练和推理时统一调用。4.3 多环境并行提升数据效率A2C在实现上和A3C差异不大只是一个同步一个异步。同步版本的A2C很适合用多环境并行来提升数据收集速度——同时开N个环境实例每个环境用当前策略收集一步或n步数据汇总成一个batch去更新网络。Pendulum 200步一个episode单环境跑200步才能得到一个n-step样本序列数据效率偏低。我推荐同时开8到16个环境让每个环境独立跑n步这样一次能收集到8到16倍的数据量训练速度和稳定性都能明显提升。这其实相当于一个小型的parallel environment pool。def collect_rollout(envs, policy, n_steps5): obs_batch, act_batch, rew_batch, done_batch [], [], [], [] obs [env.reset()[0] for env in envs] for _ in range(n_steps): obs_tensor torch.as_tensor(np.array(obs), dtypetorch.float32) mu, std, _ policy(obs_tensor) actions Normal(mu, std).sample() next_obs, rewards, dones, _, _ zip(*[env.step(a.numpy()) for env, a in zip(envs, actions)]) obs_batch.append(obs) act_batch.append(actions.numpy()) rew_batch.append(rewards) done_batch.append(dones) obs list(next_obs) return obs_batch, act_batch, rew_batch, done_batch, obs多环境并行还能带来一个额外好处数据多样性增加批次之间的相关性降低相当于一种隐式的正则化。我实测在8个并行环境下学习率可以从1e-4适当提高到2e-4左右收敛速度更快但并不会导致崩溃。4.4 梯度裁剪杜绝梯度爆炸A2C在训练过程中偶尔会遇到梯度爆炸的问题尤其是GAE的lam接近1且某个TD误差异常大的时候critic的MSE损失可能直接飙升到几十上百反向传播之后梯度范数大得离谱。解决办法是在每个优化步骤之前对梯度做裁剪for param in policy.parameters(): param.grad None loss.backward() nn.utils.clip_grad_norm_(policy.parameters(), max_norm0.5) optimizer.step()max_norm取0.5到1.0之间都可以。设置太大会失去保护作用太小会限制网络的正常更新。我在Pendulum上实测max_norm0.5能有效防止偶发的性能崩溃而且几乎不影响正常的收敛速度。4.5 固定随机种子与可复现性调参时最怕的是同一个参数组合跑两次结果完全不同导致你根本判断不了改动到底有用没用。固定随机种子是强化学习实验的基本素养。import random import numpy as np import torch def set_seed(seed): random.seed(seed) np.random.seed(seed) torch.manual_seed(seed) torch.cuda.manual_seed_all(seed) torch.backends.cudnn.deterministic True在Pendulum这种低维环境里固定种子之后两次训练的结果几乎完全一致可能只有浮点运算层面的微小偏差。这意味着你可以放心地做消融实验只改一个参数重跑一遍对比reward曲线就能明确知道这个参数的贡献。4.6 训练曲线的解读与收敛判断A2C在Pendulum上的reward曲线不会一路平滑上升而是会出现阶梯式上升和平台期。如果在tensorboard里看到reward从-1500快速升到-800然后又回到-1200这是正常的说明策略在尝试新的方向但还没稳定。判断是否收敛的标准我建议看200步平均return的滑动窗口如果最近50个episode的平均reward稳定在-300以内且波动幅度小于±50基本可以认为策略已经收敛到较好水平。Pendulum的最大reward是0但中间要经过复杂的甩摆过程一般能到-200到-350就属于表现优秀的策略。关注actor的std变化也很有用如果std已经降到一个极小的值比如0.01以下说明策略已经很确定此时再继续训练也很难有显著提升。如果std一直不降说明探索可能过剩值得把熵权重调低或者增加训练步数。5. 常见问题与调参实录5.1 训练初期reward骤降甚至完全学不起来这是A2C新手最常遇到的问题表现为前几十个episode的reward不但不上升反而从-1200跌到-2000以下。原因通常是学习率太大或者初始策略把动作推到边界值导致采样出来的行为特别差。我遇到这种问题时第一件事是把学习率从3e-4降到1e-4同时把log_std初始值设成一个相对大的数比如0.5对应std1.65让模型初期有充足的探索能力。两个调整组合起来通常20个episode内就能看到reward开始往上走。还有一个小概率原因环境reset返回的obs没有转成float32在网络计算时出现类型不匹配。这类低级错误会导致网络训练完全无效但报错信息不明显。建议在数据采集后统一做torch.as_tensor(obs, dtypetorch.float32)。5.2 reward卡在某个区间不再上升如果训练中期reward一直卡在-600左右不上不下最可能的原因是探索不足策略陷入了局部最优——可能一直用一种比较保守的方式摆动但找不到甩上去的节奏。这时我建议的排查顺序是先看actor的std值如果std已经很小小于0.05说明策略基本确定但确定的策略不是最优需要人为加大探索。把entropy_coef从0.005调到0.02重塑探索能力同时把学习率稍微调大一点比如从1e-4调到2e-4让策略有机会跳出局部最优。如果调熵项之后还是没有改善另一个方向是检查GAE的lam值把lam从0.95改成0.98增加算法的远见性有时候能帮助策略发现更长远的最优解。现象可能原因排查方法调整建议前100episode无上升趋势学习率过大或过小打印loss和梯度范数学习率调至1e-4附近reward曲线剧烈震荡优势估计方差过大检查优势值分布优势归一化或调低lam中期卡平台期探索不足查看std值提高entropy_coef后期性能不达标策略过于保守尝试更长的n步或增加GAE lam调高lam至0.98reward偶发暴跌梯度爆炸查看梯度范数梯度裁剪max_norm0.55.3 训练时间过长如何提升速度Pendulum单环境跑200步很快但A2C需要很多轮迭代才能收敛全部加起来也要跑不少时间。如果觉得训练慢优先考虑并行环境的数量这个是性价比最高的方案。把环境数量从1提升到8训练时间能缩短到原来的五分之一左右。其次可以减小batch size和n步长度的计算量。Pendulum的时序相关性不强n步取5就够没必要追求很长的序列。网络宽度从256降到128也能提升每步的更新速度且性能损失很小。5.4 关于超参数组合的最优搭配根据我自己在Pendulum上的大量测试一个比较稳健的A2C超参组合是这样学习率actor 1e-4critic 3e-4gamma 0.99lam 0.95entropy_coef 0.01n步长度 5并行环境数 8隐藏层宽度 128激活函数 Tanh梯度裁剪 0.5优化器 Adam这套参数在固定随机种子下训练500到1000个episodereward通常能稳定收敛到-300左右。你不需要精确照抄但可以用它作为基准线每次只调整一个变量来做对照实验很快就能形成自己的调参感觉。5.5 模型保存与后续扩展训练到后期可以把模型权重保存下来方便测试和继续训练。PyTorch的标准做法torch.save(policy.state_dict(), a2c_pendulum.pth) # 加载 policy.load_state_dict(torch.load(a2c_pendulum.pth)) policy.eval()加载后评估时记得把actor的方差设成0或者直接取mean作为动作这样得到的是一个确定性策略更容易观察到实际控制效果。如果还想继续训练可以保留训练模式用已有权重作为初始化继续跑几百个episode往往能在已有基础上再提高一点。最后说点个人的体会。A2C是一个对超参数相当敏感的算法初学者很容易因为调参不顺就放弃转而追求更复杂的PPO。但我觉得Pendulum这个小环境恰恰是练手A2C的最佳场所——环境不听话、奖励不好看、曲线震荡这些问题在其他任务里都会被上百倍地放大。与其在难环境里被虐得体无完肤不如先在这个小场景里把每一个超参数的作用机理摸清楚。等你真正理解了为什么学习率太大会崩溃、为什么优势要归一化、为什么熵权重不能太大再往PPO或SAC这类更复杂的算法迁移时你会发现自己对超参数的理解完全是另一层境界。