强化学习代码实战:从PPO最小实现到Stable-Baselines3调试指南 📅 发布时间:2026/9/10 2:31:44 👁 浏览次数: 简介这份强化学习算法代码大全汇集了当前主流强化学习算法的实现覆盖从基础的Q学习、SARSA到深度强化学习中的DQN、Dueling DQN、策略梯度、演员评论家、A3C、SAC、DDPG、TD3、TRPO、PPO及多智能体DDPG等十余种算法从表格型方法到深度强化学习从单智能体到多智能体内容覆盖全面。每种算法均提供可运行的Python源码并配有实验图表、Markdown说明文档与YAML配置便于读者直接复现与对比不同算法的收敛表现。资源共168个文件其中Python脚本89个图片40张文档与配置文件30余个压缩包整体仅11.19MB轻量便携。目前已有411人学习下载适合强化学习入门者、算法研究者和工程开发人员快速搭建实验环境、深入理解各算法原理并应用于实际任务。包内还包含优先经验回放示例与PDF资料可结合源码和图表逐行拆解关键机制是一份高性价比的强化学习算法参考库。1. RL代码大全最大的坑代码不等于能跑很多人在GitHub上搜“强化学习算法RL代码大全”下回来一打开就懵了文件几十个main.py跑起来报维度不匹配把DDQN改成Dueling DQN时发现loss对不上最后又回到教程版代码慢慢改。这个现象很常见所谓代码大全真正能给的不是“每一行怎么写”而是主流的DQN、PPO、SAC、TD3这批算法共性在哪、差异在哪、各自实现里最容易写错哪一行。下面按做算法复现和实验的习惯把代码拆成三层来讲先讲所有算法都绕不开的训练主循环再给一个能直接跑通的PPO最小实现然后用Stable-Baselines3批量对比主流算法并给参数最后谈监控和验证技巧。手头需要快速跑一个RL基线的人会比翻论文附录更快拿到能用的东西。2. 先看框架再看代码主流RL算法都在同一套训练循环里“代码大全”不是拿来背的是用来对照的。很多同学把DQN、PPO、TD3的源码按文件夹分开背背完照样写不出自己的变体。原因是忽略了它们共享的部分所有现代深度RL算法无论是value-based还是policy gradient训练主循环都长成“采样 → 算target → 更新 → 再采样”的样子。把这层看透再打开任何一份RL代码第一眼就该知道它在哪一步。2.1 训练主循环是RL代码的“总纲”几乎所有主流实现包括Stable-Baselines3、Tianshou、CleanRL主循环都能浓缩成下面这段伪Python代码for iteration in range(total_iterations): # 采样阶段用当前策略或行为策略收集 transition transitions collect_rollout(env, policy, buffer) # 更新阶段先算目标值再按各自算法更新网络参数 loss compute_loss(policy, transitions, target_net) optimizer.zero_grad() loss.backward() optimizer.step() # 定期同步 target 网络或执行 polyak 软更新 update_target_network(policy, target_net, tau)这段代码里collect_rollout就是常说的rollout过程智能体把当前策略丢进环境里跑若干步拿到(state, action, reward, next_state, done)序列这是所有估计的原料。compute_loss则是算法的分水岭DQN的loss是TD误差PPO的loss是带裁剪的策略比例项加value lossTD3的loss里额外有对目标动作的噪声正则。update_target_network的快慢差别很大DQN用硬更新每隔N步直接拷贝TD3和SAC用软更新tau通常取0.005PPO根本不用target网络只用多轮update来限制策略变化幅度。看懂这段再看开源代码第一件事就是在文件里找for循环入口。找到之后自然知道该把关注点放在loss怎么组装而不是网络层怎么堆。很多人花大量时间调网络层数效果却不如把tau从0.005调成0.01原因就在框架理解偏了。2.2 离散与连续、on-policy与off-policy的代码差异这层差异决定了你拿到的代码到底长什么样分类维度代表算法代码上的关键标志动作空间DQN离散、DDPG/TD3/SAC连续输出层用softmax还是tanhscale采样策略on-policyPPO、A2C、TRPO必须用当前策略采样旧数据只留一个版batch数据复用off-policyDQN、TD3、SAC有replay buffer按batch_size随机抽样旧经验可反复用目标网络DQN、DDPG、TD3有SAC有两个代码里有target_model.load_state_dict(...)或polyak赋值on-policy和off-policy在代码上最大的区别是buffer长度。PPO的rollout buffer通常只存一轮完整采样的数据更新完直接清空TD3和SAC的replay buffer容量动辄1e6因为环境交互成本高要最大化利用历史经验。看代码时先看buffer的声明和清空时机就能猜出算法归属。连续动作的代码有个容易踩的坑。TD3代码PyTorch版里actor输出层常见写法是tanh接在mean之后做动作限制如果你直接在DDPG上套用SAC的双均值和熵正则会触发动作越界的报错。稳妥做法是先确认环境的action_space.low/high再选择用tanh还是scale。我一般会写一个apply_action_bound工具函数来统一处理避免在环境边界上反复出bug。2.3 三种loss形式决定算法归属与其背“DQN是value-basedPPO是policy-based”不如直接记loss长什么样。最常见的三类lossQ-learning家族的TD loss(r gamma * (1-done) * max_a Q_target(s, a) - Q(s, a))^2。DQN、Double DQN、Dueling DQN都在这基础上改区别只在max_a这一项怎么算或者Q网络拆成优势流和价值流。Policy Gradient家族的代理目标-E[ratio * A]以及PPO改进后的-E[min(ratio*A, clip(ratio, 1-eps, 1eps)*A)]。最大熵家族的软Q目标SAC在Q loss里引入alpha * log_pi代码里多一个entropy_coef的自动调节逻辑。我会拿一家成熟实现把这三类loss分别打开对照公式抄一遍抄完就理解了为什么PPO代码里总有一个clip_range超参而SAC代码里总有一个不断变化的log_alpha。这个“对着公式看loss”的过程比把网络结构抄十遍更接近RL实现的核心。3. 从零写一个能跑通的PPO最小PyTorch实现下面不是某个大赛项目而是平时用来验证新思路的最小骨架。它去掉工程装饰只保留“能训练、能复现、能加trick”的部分。用CartPole-v1验证最快单卡CPU约两三分钟就能看到策略明显进步。想改到连续控制把actor的logits层换成tanh的mean/log_std输出即可。3.1 网络与rollout采样部分# ppo_minimal.py import gymnasium as gym import torch import torch.nn as nn from torch.distributions import Categorical class ActorCritic(nn.Module): def __init__(self, obs_dim, act_dim): super().__init__() self.shared nn.Sequential(nn.Linear(obs_dim, 128), nn.Tanh(), nn.Linear(128, 128), nn.Tanh()) self.policy nn.Linear(128, act_dim) self.value nn.Linear(128, 1) def forward(self, x): h self.shared(x) return Categorical(logitsself.policy(h)), self.value(h)这里把actor和critic放在同一个网络里共用特征提取层是PPO实现里最常见的做法。Categorical输出离散动作分布value输出状态价值的标量估计。如果看到别家实现把actor和critic拆成两个单独MLP也是合法的只是参数多一倍训练速度稍慢收益不明显。def rollout(env, model, gamma0.99, lam0.95): states, actions, rewards, dones, old_log_probs [], [], [], [], [] dr, dl [], [] obs, _ env.reset() done False while not done: obs_t torch.as_tensor(obs, dtypetorch.float32).unsqueeze(0) dist, value model(obs_t) action dist.sample() next_obs, reward, terminated, truncated, _ env.step(action.item()) done terminated or truncated states.append(obs_t); actions.append(action) rewards.append(reward); dones.append(1.0 if done else 0.0) old_log_probs.append(dist.log_prob(action)) dr.append(reward) dl.append(value.item()) obs next_obs # 计算GAE advantages torch.zeros(len(rewards), dtypetorch.float32) gae 0.0 next_value 0.0 for t in reversed(range(len(rewards))): delta rewards[t] gamma * (1.0 - dones[t]) * next_value - dl[t] gae delta gamma * lam * (1.0 - dones[t]) * gae advantages[t] gae next_value dl[t] returns advantages torch.as_tensor(dl, dtypetorch.float32) return (torch.cat(states), torch.stack(actions), advantages, returns, torch.stack(old_log_probs), rewards)GAE的计算是逐时间步从后往前推的。delta是TD残差gae累加时乘上lam0.95通常是个好起点系数(1-done)保证轨迹在终止时截断。这里CartPole每轮最多500步状态量和计算量都小完全够用。returns是后面训练用的回归目标等于advantages values。3.2 经验池构造与GAE的“为什么”很多新手把GAE当成黑盒代码里直接抄。我建议至少明白两件事第一GAE是对优势函数A(s,a)的多步加权估计lam越接近1方差越低但偏差越高越接近0就越像一步TD。PPO里lam0.95几乎成了默认值真正需要动它的是稀疏奖励环境通常要调大到0.98甚至1.0。第二GAE尽量在采样完一轮后就立刻算不要存原始reward再异步算否则环境截断的边界很容易写错。returns的计算也值得注意这里用的是advantages value而不是直接用discounted cumulative reward。PPO的critic训练目标是“状态价值”用advantages value做回归等价于让critic去拟合V(s)。如果直接用累积回报做target在长轨迹上方差会大很多这也是新手写的PPO经常出现value loss曲线震荡的原因。3.3 用clip目标更新策略三个必调参数def train_ppo(states, actions, returns, advantages, old_log_probs, model, optimizer, clip_range0.2, epochs4, batch_size64, ent_coef0.01, gamma0.99, lam0.95): adv (advantages - advantages.mean()) / (advantages.std() 1e-8) dataset_size states.shape[0] for _ in range(epochs): perm torch.randperm(dataset_size) for i in range(0, dataset_size, batch_size): idx perm[i:ibatch_size] dist, values model(states[idx]) entropy dist.entropy().mean() log_probs dist.log_prob(actions[idx]) ratio torch.exp(log_probs - old_log_probs[idx]) surr1 ratio * adv[idx] surr2 torch.clamp(ratio, 1.0 - clip_range, 1.0 clip_range) * adv[idx] policy_loss -torch.min(surr1, surr2).mean() value_loss nn.functional.mse_loss(values.squeeze(), returns[idx]) loss policy_loss 0.5 * value_loss - ent_coef * entropy optimizer.zero_grad() loss.backward() nn.utils.clip_grad_norm_(model.parameters(), 0.5) optimizer.step() env gym.make(CartPole-v1, max_episode_steps256) model ActorCritic(4, 2) optimizer torch.optim.Adam(model.parameters(), lr3e-4) for iteration in range(2000): states, actions, adv, returns, old_log_probs, rewards rollout(env, model) train_ppo(states, actions, adv, returns, old_log_probs, model, optimizer) if iteration % 50 0: print(fiter {iteration}, episode_return{sum(rewards):.1f})主循环里先rollout再train是on-policy算法的标准结构。adv做了标准归一化这项处理在PPO里几乎必加能显著缓解reward scale在不同环境间的差异。clip_grad_norm_限到0.5是为了防止GAE偶尔出现的极端优势值把网络一步推飞。参数推荐初值影响调整方向clip_range0.2限制策略比例变化幅度0.1更保守0.3更激进lam0.95优势估计的偏差-方差权衡稀疏奖励环境调到0.98~1.0epochs4每个batch重复更新次数数据量少时调到2防止过拟合batch_size64更新稳定性大batch降方差但慢ent_coef0.01探索熵奖励局部最优时加到0.05这段代码能跑通但如果你拿它做完整实验会发现有三处和论文公式不完全一致没有value clip、没有双clip、advantage normalization用的是简单标准归一化。这些不影响理解先跑通再逐步加。4. 用Stable-Baselines3把主流算法全部跑一遍自己手写的PPO适合学习但做基线对比时我更愿意用现成库。Stable-Baselines3SB3是PyTorch生态里维护最勤的RL库之一DQN、PPO、A2C、DDPG、TD3、SAC都有实现代码风格统一超参默认值基本能复现论文成绩。把它当作“可运行的代码大全”来用比逐个翻单个项目源码效率高得多。4.1 安装与最小训练脚本pip install stable-baselines3[extra][extra]会连带装tensorboard、wandb这类监控工具。装完用三个API就能跑一个完整实验from stable_baselines3 import PPO from stable_baselines3.common.env_util import make_vec_env env make_vec_env(CartPole-v1, n_envs4) model PPO(MlpPolicy, env, n_steps512, batch_size64, verbose1) model.learn(total_timesteps20_000) model.save(ppo_cartpole)make_vec_env会创建多个并行环境n_envs4时一次采样得到4条独立轨迹batch内数据多样性更好。n_steps是每个环境采样步数实际buffer大小是n_steps * n_envs。verbose1会在终端打印训练进度verbose0则保持干净。save之后可以用PPO.load(ppo_cartpole)直接加载来评估或续训。换成SAC或TD3跑连续控制只改几行from stable_baselines3 import SAC, TD3 env make_vec_env(HalfCheetah-v4, n_envs1) model TD3(MlpPolicy, env, buffer_size1_000_000, learning_starts10_000, train_freq1, gradient_steps1)这里的关键转移TD3是off-policy必须给足replay buffer和learning_starts。learning_starts是开始更新前随机探索采样的步数设太小策略会在数据极少时被过早优化。train_freq1配合gradient_steps1表示每采样1步就更新1次是TD3最常见的节奏。SAC同样如此只是多了一个ent_coefauto的默认值。4.2 主流算法的核心参数对照表把几个常用算法的高频超参拿出来做对照方便快速判断代码在调什么参数DQNDDPGTD3SACPPO动作类型离散连续连续连续两者均可buffer_size1e61e61e61e6无learning_starts1e41e41e41e4无train_freq4111无tau1.05e-35e-35e-3无特有项无无policy noiseentropy coefclip range表格背后对应着实现层面的硬区别有tau1.0的DQN是硬拷贝有tau5e-3的算法是软更新PPO整列空白是因为它不建target网络。train_freq很低的大多是想用更少交互换来稳定代价是墙钟时间变长。跑实验前把这几项先按上表对准比调网络层数收益大得多。4.3 从SB3源码里反推各算法实现细节SB3源码适合当对照手册。以TD3为例搜target_policy_noise和target_noise_clip这两个参数就能看到它和DDPG的核心区别给critic的目标动作加了一个裁剪过的高斯噪声。在连续控制里这个trick能让Q值估计不过度乐观是很多人搜“TD3代码PyTorch”最想找的关键点。同样SAC里搜log_alpha会看到熵系数通过一个带target_entropy的loss自动更新这是它与PPO手动设ent_coef的最大不同。理解这点后就能解释为什么同一个环境里SAC的探索曲线经常看起来更“躁”。关于“RL中BC是什么”RLHF相关的强化学习实现常把BCbehavior cloning行为克隆作为初始策略的预训练loss代码上表现为对离线专家数据做监督学习后再进入PPO训练。SB3本身不直接提供BC模块但Imitation库做这个事很常见。要理解SFT和RL的区别本质也就是BC/监督微调与在线决策优化的区别。5. RL代码调参前的调试回报曲线之外先看这四个指标代码能跑不代表训练正常。RL训练里回报曲线会骗人前200轮可能是随机探索恰好拿高分后500轮策略熵崩了但reward却没涨。经过大量实验我更相信一组更细的指标。5.1 用TensorBoard监控熵、KL散度与梯度范数写自定义RL代码时我至少会在每个epoch记录以下量from torch.utils.tensorboard import SummaryWriter writer SummaryWriter(runs/ppo_debug) # 在 train_ppo 中记录 writer.add_scalar(train/policy_loss, policy_loss.item(), global_step) writer.add_scalar(train/approx_kl, (ratio - 1.0 - torch.log(ratio)).mean().item(), global_step) writer.add_scalar(train/entropy, entropy.item(), global_step) writer.add_scalar(train/grad_norm, total_grad_norm, global_step)approx_kl是PPO论文里提出的近似KL散度用ratio - 1 - log ratio估计反映策略每次更新的变化幅度。如果单次更新后approx_kl超过0.03说明clip_range太小或学习率太高熵则反映策略的动作分布有多确定。CartPole上熵从0.7降到0.2左右是正常的如果第一个迭代就掉到0.05多半是优势估计或策略梯度符号写反了。梯度范数也需要打印。RL的reward尺度会随环境剧烈变化同样的lr3e-4在CartPole和HalfCheetah上产生的梯度量级差几十倍。下面是判断健康度的参考指标健康范围出现问题时的信号approx_kl0.003-0.03超过0.1时先降lr或clipentropy从高到低平滑下降断崖式下降表示探索崩溃grad_norm1-10上下浮动几十以上时先检查reward scalereward std约为mean的一半突然接近0表示策略可能退化5.2 最常见的三个静默失败场景第一种是done信号处理错误。Gymnasium的env.step现在返回(obs, reward, terminated, truncated, info)很多人只拿前四个值导致truncated比如超过最大步数也被当成真正的终止bootstrapping计算错误策略学的东西全是错的。修正方法是done terminated or truncated但计算n-step或GAE时对两者要区别对待。提示truncated和terminated不能混用。GAE里对truncated的下一状态价值应继续用value函数估计对terminated则直接置0。第二种是reward scale不一致导致的“调参幻觉”。同一个PPO代码跑CartPolereward1和HalfCheetahreward约1000-5000lr3e-4在CartPole上正常在HalfCheetah上可能出现loss飞到NaN。更隐蔽的是离线强化学习比如IQL对reward scale更敏感很多人用在线算法的经验去调IQL奖励值差一个数量级整条学习曲线就废了。遇到这种情况先做reward scaling除以滑动平均的绝对值再跑。第三种是目标网络更新时机错误。DQN类的几种变体DDQN、Dueling DQN代码里target_net.load_state_dict(policy_net.state_dict())写在哪里决定了实验能不能复现论文结果。常见的魔改是把硬更新间隔从10_000改到5_000差别在简单环境不大但在Atari这类像素输入上会明显影响。5.3 给RL代码做“实现正确性”检查一个朴素的验证是把手写算法和SB3默认实现同一组超参放在同一个简单环境跑对比回报曲线形状。如果手写PPO在CartPole上完全不涨先查GAE和clip两处如果在MountainCar上不稳定再查有没有把terminated和truncated混在一起处理。另一个可执行的检查是让环境步数固定比如把max_episode_steps设成256跑同一seed记录每一步的value输出和实际return的相关性。critic的预测value如果长期偏离真实return说明value loss或GAE回溯方向有问题。6. 验证RL代码写好没一个有效的“三跑实验”最后给一个每次换环境、换算法后必做的“三跑实验”。它不复杂但能筛掉一大半“看起来正常”的坏实现。6.1 用固定seed跑三次看期望和方差import subprocess, sys, numpy as np results [] for seed in [1, 2, 3]: out subprocess.check_output( [sys.executable, train.py, --seed, str(seed)], stderrsubprocess.STDOUT, textTrue) ep_returns [float(x) for x in out.splitlines() if x.startswith(RETURN)] results.append(ep_returns[-1]) mean, std np.mean(results), np.std(results) print(fmean{mean:.2f} std{std:.2f} cv{std / (abs(mean) 1e-8):.3f})这个脚本把训练脚本包装成子进程逐seed跑最终输出均值和变异系数。如果cv 0.3说明这个实现的结果很不稳定可能不是算法本身的问题而是随机种子策略、网络初始化方差或buffer清空位置写得不对。连续控制里我通常要求同一超参下两次独立实验差距不超过10%才算这个实现可复现。“三跑”比“单run调参”更接近RL实验的真相调参结论如果只在某一个seed上成立换一个环境就失效那大概率是运气而不是能力。想压缩时间可以三个seed并行跑但代码里记得把torch.manual_seed(seed)、np.random.seed(seed)和env.reset(seedseed)都设置一致只set其中一个等于没设。6.2 把环境维度缩到最小时做单元测试另一个技巧是把环境换成手工可解的任务。比如用仅有3个状态的链式环境测试DQN的Q值是否逼近理论值用1维连续动作的“爬山”任务验证TD3的policy更新方向。这类测试不需要大型算力几秒钟内就能看出loss和value是收敛还是发散。准备一个固定的--env small_chain入口把这类环境写进代码库每次改了核心loss逻辑就跑一遍比反复开大环境看曲线高效得多。最后一道检查很简单看reward量纲。所有环境的reward数值先打印出来scale是1、是几千、还是浮点噪声量纲接不住后面所有clip、batch、learning rate都没有意义。先用print(env.reward_range)或采样一轮手动算mean/std确认量纲再谈网络结构。本文还有配套的精品资源点击获取