ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

基于PyTorch的深度强化学习复现:DDPG、SAC、TD3统一框架与避坑指南

基于PyTorch的深度强化学习复现:DDPG、SAC、TD3统一框架与避坑指南 简介这是一份基于PyTorch的深度强化学习算法研究与对比实践资源聚焦DDPG、SAC、TD3三种主流连续控制算法完整实现了网络构建、经验回放、训练与评估流程。资源面向具备一定深度学习基础、希望深入理解连续动作空间DRL算法的研究人员、学生及开发者适用于机器人控制、自动驾驶和游戏AI等场景的算法验证与二次开发。压缩包共23个文件以Python源码为主含10个py脚本和9个pyc编译文件另有yaml环境配置、md/txt说明文档及docx附赠资料整体仅89KB结构清晰紧凑。目前已吸引143人学习下载。借助其中的训练模块与可视化分析工具使用者可直观对比三种算法的奖励曲线、损失变化和策略表现掌握DDPG的确定性策略、SAC的熵正则化以及TD3的延迟更新与目标噪声等关键机制是一份适合快速上手和横向评测的实用代码库。1. 深度强化学习复现项目先把DDPG、SAC、TD3放进同一个框架再说如果你是做机器人控制、自动驾驶或者游戏策略研究的大概率体会过这种场面网上找的深度强化学习代码跑在 CartPole 上没问题一换到连续控制任务就开始奖励崩溃又或者 DDPG、SAC、TD3 三个算法代码来自不同仓库超参数口径不一致最后根本没法对比实验。基于 PyTorch 框架的深度强化学习算法实现项目核心价值就是把这类主流算法收敛到同一套代码框架里统一经验回放、统一网络结构、统一评测方式。它解决的不是“算法原理看不看得懂”的问题而是“为什么我的复现总是跑不出论文效果”的问题。适合入门但被玄学调参折磨的研究生也适合想快速评估某个想法能否落地的工程师。2. 搭建PyTorch训练底座环境配置与连续控制任务接入2.1 用conda配好PyTorch GPU环境这一步别贪快复现深度强化学习算法第一步不是写网络而是先把训练环境固定下来。很多跑不通的实验根源出在机器上有两套 Python 环境torch 版本不一致或者 CUDA 与显卡驱动不匹配。我一般用 conda 隔离一个独立环境避免把系统 Python 搞乱。conda create -n rl python3.9 conda activate rl pip install torch torchvision --index-url https://download.pytorch.org/whl/cu118 pip install gymnasium matplotlib这里--index-url指向 PyTorch 官方维护的 whl 索引cu118对应 CUDA 11.8这个版本对绝大多数 NVIDIA 显卡驱动都比较友好。如果nvidia-smi显示驱动支持更高版本可以换成cu121但没必要盲目追新因为算法复现对 CUDA 版本不敏感稳定压倒一切。装完后在终端里跑一句python -c import torch; print(torch.cuda.is_available())输出 True 再继续下一步。没有 NVIDIA 显卡的话去掉--index-url会安装 CPU 版 torch代码逻辑完全一样只是训练 HalfCheetah 这类高维任务会慢得让人失去耐心。单机实验我建议至少一张 6GB 显存的卡显存放在连续控制任务上基本不是瓶颈瓶颈在步数。2.2 接入Pendulum-v1与HalfCheetah-v4先确认空间连续控制算法要跑环境接口必须先用手动随机动作走一遍。很多复现代码第一步就错在把 observation 和 action 的维度搞错或者没搞清动作范围。Gymnasium 是当前主流任务接口下面的代码用 Pendulum-v1 做冒烟测试import gymnasium as gym import numpy as np env gym.make(Pendulum-v1) print(obs space:, env.observation_space) print(act space:, env.action_space) obs, info env.reset(seed42) for step in range(200): action np.array([0.0], dtypenp.float32) obs, reward, terminated, truncated, info env.step(action) if terminated or truncated: breakenv.step返回四个值其中terminated表示任务真正结束truncated表示到达最大步数被截断这两个标志在强化学习里语义不同后面避坑章节会专门讲。Pendulum 的 observation 是 3 维action 是 1 维力矩范围在 -2 到 2HalfCheetah-v4 的 observation 是 17 维action 6 维训练步数需求高一个量级适合做算法差距放大的验证任务。常见任务选型对照如下新拿到项目先按这个表确认接口任务obs维度action维度奖励范围适合用途Pendulum-v131[-16, 0]快速验证算法能不能跑通HalfCheetah-v4176无界正数验证连续控制算法差距LunarLanderContinuous-v282[-200, 200]验证稀疏奖励下的稳定性2.3 项目目录怎么摆三个算法共用一套框架对比实验最怕的就是三种算法各写各的网络结构、缓冲区、logger 全不一致。我的习惯是把公共组件抽出来三个 agent 只保留算法核心差异目录结构如下rl_compare/ agents/ ddpg.py sac.py td3.py networks/ actor.py critic.py buffer/ replay_buffer.py utils/ seed.py logger.py train.py evaluate.py visualize.py这个结构里train.py只和 agent 的接口打交道不管具体是哪个算法。每个 agent 类都实现select_action、update、save、load四个方法训练主循环只调这些接口换算法只换一行。这样做的好处是当你想增加一个 PPO 或者 DQN 变体时不需要改动任何公共代码新增一个 agent 文件即可。3. 三种算法完整复现Actor-Critic网络结构与其更新逻辑差异3.1 网络基座Actor与Critic的PyTorch定义DDPG、SAC、TD3 都属于 Actor-Critic 架构网络基座完全可以共用。差别集中在两个地方Actor 是输出确定性动作还是随机策略参数Critic 是一个还是两个。先把公共 MLP 和两种 Actor 定义写出来import torch import torch.nn as nn import torch.nn.functional as F class MLP(nn.Module): def __init__(self, in_dim, out_dim, hidden_dim256): super().__init__() self.net nn.Sequential( nn.Linear(in_dim, hidden_dim), nn.ReLU(), nn.Linear(hidden_dim, hidden_dim), nn.ReLU(), nn.Linear(hidden_dim, out_dim), ) def forward(self, x): return self.net(x) class DeterministicActor(nn.Module): def __init__(self, obs_dim, action_dim, action_limit2.0): super().__init__() self.mlp MLP(obs_dim, action_dim) self.action_limit action_limit def forward(self, obs): return torch.tanh(self.mlp(obs)) * self.action_limit class StochasticActor(nn.Module): def __init__(self, obs_dim, action_dim, log_std_min-20, log_std_max2): super().__init__() self.mlp MLP(obs_dim, action_dim * 2) self.log_std_min log_std_min self.log_std_max log_std_max def forward(self, obs): mean, log_std torch.chunk(self.mlp(obs), 2, dim-1) log_std log_std.clamp(self.log_std_min, self.log_std_max) return mean, log_stdDeterministicActor用 tanh 把输出限制在 ±action_limit 内DDPG 和 TD3 直接取这个输出作为动作。StochasticActor输出的是高斯分布的均值和 log 标准差SAC 在采样时还要经过一个 tanh 变换把动作限制到合法范围这部分逻辑放在 agent 的select_action里。两层 256 神经元是连续控制任务的常见起点维度低的小任务可以缩到 128任务复杂可以加到 512但复现阶段保持统一。3.2 DDPG确定性策略与目标网络问题卡在Q值过估计DDPG 是理解 TD3 和 SAC 的基石。它的核心更新分两步先更新 Critic 拟合 Q 值再更新 Actor 让 Q 值变大。PyTorch 实现里最典型的 update 片段如下def update(self, batch): state, action, reward, next_state, done batch with torch.no_grad(): target_q self.target_critic(next_state, self.target_actor(next_state)) target_y reward self.gamma * (1 - done) * target_q q self.critic(state, action) critic_loss F.mse_loss(q, target_y) self.critic_optimizer.zero_grad() critic_loss.backward() self.critic_optimizer.step() actor_loss -self.critic(state, self.actor(state)).mean() self.actor_optimizer.zero_grad() actor_loss.backward() self.actor_optimizer.step() soft_update(self.target_actor, self.actor, self.tau) soft_update(self.target_critic, self.critic, self.tau)Critic 更新用的是标准的 TD 误差target_q由目标网络计算done为 1 时直接丢弃未来奖励。Actor 更新是 DDPG 最特别的地方-self.critic(state, self.actor(state)).mean()表示让当前策略输出的动作在 Critic 眼里获得更高的 Q 值。优化器做梯度下降所以取负号。这里的过估计隐患在于 target_q 本身由 target_critic 给出而 target_critic 没有独立的纠偏机制Q 值会越滚越大这也是 TD3 出现的原因。3.3 SAC最大熵与自动熵调节log_prob的符号别搞反SAC 在标准实现里往往不单独创建目标网络因为 clipped double Q 本身已经对 target 做了平滑当前策略重采样又提供了随机性。SAC 的目标值里多了熵项这也是它和 DDPG/TD3 最本质的区别def update(self, batch): state, action, reward, next_state, done batch with torch.no_grad(): next_action, next_log_prob self.actor.sample(next_state) target_q1 self.critic1(next_state, next_action) target_q2 self.critic2(next_state, next_action) target_q torch.min(target_q1, target_q2) target_y reward self.gamma * (1 - done) * ( target_q - self.alpha * next_log_prob ) q1 self.critic1(state, action) q2 self.critic2(state, action) critic_loss F.mse_loss(q1, target_y) F.mse_loss(q2, target_y) mean, log_std self.actor(state) sampled_action, log_prob reparam_sample(mean, log_std) actor_loss ( self.alpha * log_prob - torch.min(self.critic1(state, sampled_action), self.critic2(state, sampled_action)) ).mean()alpha是熵系数log_prob是当前策略下动作的对数概率。当 alpha 较大时策略更倾向于探索因为目标值里减掉的熵项惩罚了确定性行为。这个式子里的符号经常有人搞反如果写成target_q alpha * next_log_prob和 Actor 损失里的符号不一致训练出来的策略要么完全随机要么完全确定。我建议统一按Q - alpha * log_prob这一套公式写不要混用。自动熵调节单独维护一个log_alpha梯度上升目标熵一般设为-action_dim。Pendulum 是 1 维动作目标熵就是 -1HalfCheetah 是 6 维动作目标熵 -6。3.4 TD3双Critic加延迟更新和DDPG就差这几行TD3 是 DDPG 的直接改进改动点只有三个但每一个都针对 DDPG 的一个具体病根。代码差别很小逻辑却完全不同def update(self, batch, step): state, action, reward, next_state, done batch with torch.no_grad(): noise (torch.randn_like(action) * self.policy_noise).clamp( -self.noise_clip, self.noise_clip ) next_action (self.target_actor(next_state) noise).clamp( -self.action_limit, self.action_limit ) target_q1 self.target_critic1(next_state, next_action) target_q2 self.target_critic2(next_state, next_action) target_q torch.min(target_q1, target_q2) target_y reward self.gamma * (1 - done) * target_q critic_loss F.mse_loss(self.critic1(state, action), target_y) \ F.mse_loss(self.critic2(state, action), target_y) if step % self.policy_delay 0: actor_loss -self.critic1(state, self.actor(state)).mean() self.actor_optimizer.zero_grad() actor_loss.backward() self.actor_optimizer.step() soft_update(self.target_actor, self.actor, self.tau) soft_update(self.target_critic1, self.critic1, self.tau) soft_update(self.target_critic2, self.critic2, self.tau)policy_delay通常取 2意思是 Critic 每步都更新Actor 每两步才更新一次这样 Critic 有更多时间把 Q 值变准确Actor 再顺着一个更可靠的梯度去改进。目标策略平滑是另一个关键在目标动作上加一个 clip 后的高斯噪声迫使 Critic 在动作空间局部区域保持光滑不容易被尖峰误导。policy_noise取 0.2noise_clip取 0.5这三个参数在不同任务里基本不用动。三种算法的核心超参数对比如下复现时直接照这个表设初值超参数DDPGTD3SACactor学习率1e-41e-43e-4critic学习率1e-31e-33e-4gamma0.990.990.99tau0.0050.0050.005policy_delay无2无目标策略平滑无0.2 / 0.5无熵调节无无自动目标熵 -action_dim4. 训练模块落地经验回放、主循环与对比实验设计4.1 经验回放缓冲区先写对存储和采样再谈效率经验回放是 off-policy 算法的命脉存储格式和采样方式直接影响训练稳定性和 GPU 利用率。用 numpy 数组做环形缓冲区是最稳的方案先不追求优先队列或者 Nvidia 的加速版本逻辑正确优先class ReplayBuffer: def __init__(self, capacity, obs_dim, action_dim): self.capacity capacity self.obs np.zeros((capacity, obs_dim), dtypenp.float32) self.actions np.zeros((capacity, action_dim), dtypenp.float32) self.rewards np.zeros((capacity, 1), dtypenp.float32) self.next_obs np.zeros((capacity, obs_dim), dtypenp.float32) self.dones np.zeros((capacity, 1), dtypenp.float32) self.idx 0 self.size 0 def add(self, obs, action, reward, next_obs, done): self.obs[self.idx] obs self.actions[self.idx] action self.rewards[self.idx] reward self.next_obs[self.idx] next_obs self.dones[self.idx] done self.idx (self.idx 1) % self.capacity self.size min(self.size 1, self.capacity) def sample(self, batch_size): indices np.random.choice(self.size, batch_size, replaceFalse) return ( torch.as_tensor(self.obs[indices], dtypetorch.float32), torch.as_tensor(self.actions[indices], dtypetorch.float32), torch.as_tensor(self.rewards[indices], dtypetorch.float32), torch.as_tensor(self.next_obs[indices], dtypetorch.float32), torch.as_tensor(self.dones[indices], dtypetorch.float32), )capacity一般设 1e6在 Pendulum 这种小任务上 1e5 也够用。注意rewards和dones都存成(capacity, 1)形状这是为了后续和target_y做 shape 对齐避免广播错误。idx用取模实现环形覆盖当缓冲区满时最早的数据被逐步挤掉。采样用无放回抽样batch_size 设在 256 左右比较平衡。需要特别强调的是这里的done参数到底传terminated还是terminated or truncated。很多现成仓库直接把两值或起来在 HalfCheetah 这类有最大步数限制的任务上会在truncated时给价值函数一个虚假的终止信号导致状态价值被低估。正确做法是存float(terminated)truncated不作为终止处理。4.2 训练主循环warmup、更新节奏和重置逻辑主循环负责驱动整个训练过程它的核心职责是保持数据流的连续性和更新节奏的一致性。下面的循环同时覆盖了 DDPG、SAC、TD3def train(agent, env, buffer, args): obs, _ env.reset(seedargs.seed) episode_reward 0.0 episode_steps 0 episode_num 0 for step in range(args.total_steps): if step args.warmup_steps: action env.action_space.sample() else: action agent.select_action(obs, evaluateFalse) next_obs, reward, terminated, truncated, info env.step(action) done terminated buffer.add(obs, action, reward, next_obs, float(done)) episode_reward reward episode_steps 1 if step args.warmup_steps and step % args.update_every 0: for _ in range(args.gradient_steps): batch buffer.sample(args.batch_size) agent.update(batch, step) if terminated or truncated: log_episode(episode_num, episode_reward, episode_steps) obs, _ env.reset(seedargs.seed episode_num) episode_reward 0.0 episode_steps 0 episode_num 1 else: obs next_obswarmup_steps是让缓冲区先积累一定数量样本再开始学习DDPG 和 TD3 一般设 1000SAC 可以设 1000 到 5000。update_every取 1即每个环境步都做一次更新gradient_steps也取 1保证采样频率和更新频率 1:1这是 off-policy 算法的标准配置。重置时seedargs.seed episode_num让每个 episode 的随机序列不同但整次实验可以通过固定args.seed复现。4.3 对比实验设计种子、任务、评估间隔三者必须统一算法对比最怕的就是跑完发现口径不一致。三种算法在同一个任务上对比必须在三个层面严格统一环境 seed、评估间隔、评估 episode 数。写一个独立于训练的评估函数def evaluate(agent, env_name, seed, num_episodes10): eval_env gym.make(env_name) total_reward 0.0 for ep in range(num_episodes): obs, _ eval_env.reset(seedseed ep) episode_reward 0.0 done False while not done: with torch.no_grad(): action agent.select_action(obs, evaluateTrue) obs, reward, terminated, truncated, _ eval_env.step(action) done terminated or truncated episode_reward reward total_reward episode_reward return total_reward / num_episodes评估时evaluateTrue的意义在于DDPG 和 TD3 要去掉探索噪声SAC 要输出均值而不是采样随机动作。这样评估出来的奖励才是算法策略的真实水平而不是带探索干扰的噪声值。评估间隔固定为每 1 万环境步一次记录平均回报。同一个 seed 下DDPG、SAC、TD3 各自跑一遍再把平均奖励和标准差画出来。每个 seed 至少 3 个报告 mean ± std单次实验结果不能说明任何问题。5. 复现过程中的避坑排查五个让实验翻车的高频问题5.1 DDPG训练几十万步奖励还是平的先看奖励有没有归一化现象训练曲线在零附近徘徊偶尔冒一个尖峰又立刻掉下去几十万步毫无收敛迹象。原因Pendulum 的奖励本身是负值范围约在 -16 到 0如果直接喂给 Critic 且学习率偏高Q 值会迅速发散。加上 DDPG 的过估计特性Critic 会在负奖励区间里把少数高估的 (state, action) 当成宝物策略被迫往错误方向移动。解决进入target_y之前对 reward 做一次线性缩放比如reward * 0.1把量级压到 -1.6 到 0 之间。更通用的做法是记录 reward 的滑动均值把奖励归一化到零均值附近再用但先在固定任务里用固定缩放因子更省事。5.2 SAC的alpha变成负值策略崩掉现象训练的日志里 alpha 从正数一路降到负数动作方差迅速变小前期奖励还在涨后期突然回撤。原因alpha 是熵惩罚的权重理论上应该保持非负。自动熵调节的 loss 是alpha.detach() * (log_prob target_entropy)方向的学习如果初始log_alpha设成了负数或者 alpha 学习率太大它会被推到负区间。负 alpha 意味着鼓励确定性策略与 SAC 的设计初衷完全相反。解决log_alpha初始值设 0alpha 学习率和算法其他部分一致设为 3e-4并在更新后加一行self.alpha self.alpha.clamp(min0)。日志里多打印 alpha 值一旦逼近零就人工干预降低其学习率。5.3 TD3在同一任务上不如DDPG检查policy_delay是否真正生效现象理论上看 TD3 应该全面优于 DDPG实际对比跑下来两者几乎一样甚至 TD3 更差。原因TD3 的改进依赖三个组件同时作用最常见的翻车是把if step % self.policy_delay 0写在 update 里但外层仍然每个 step 都调用了update之外的 Actor 更新代码。或者 policy_delay 虽然写了 2但中间的 Adam optimizer 没有正确传递 step 计数实际上每步都更新了。解决在 TD3 的 update 方法里只保留上面代码片段的结构Critic 一定更新Actor 和目标网络放在if step % policy_delay 0分支内。写一个调试断言记录actor_update_count训练结束后验证它约等于总步数的二分之一。我自己的习惯是在每一步 update 里打印一次 current step肉眼确认节奏。5.4 换台机器结果就翻车缺确定性设置现象同一份代码、同一个 seed在 A 机器上收敛在 B 机器上训练曲线就是不对。原因PyTorch 端到端训练涉及 GPU 浮点运算顺序、cuDNN 自动调优、CPU 多线程竞争这些都会引入数值差异。A 机器和 B 机器哪怕都是同一代显卡结果也可能不完全一致。解决训练入口固定下面这段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 torch.backends.cudnn.benchmark Falsecudnn.deterministic True会让 cuDNN 选择确定性的卷积算法benchmark False关闭自动调优这两个组合会让速度损失 5% 到 15%但换来的是跨机器可复现性。对比实验必须在同一套 seed 配置下运行这也是项目对比研究的基本前提。5.5 truncated被当成done价值函数学到错误信号现象SAC 在 HalfCheetah 上训练到某个奖励平台后一直震荡怎么调学习率都突破不了。原因HalfCheetah 每个 episode 最多 1000 步到点后truncatedTrue但这不是任务真正的终止状态。如果把truncated or terminated都当成 done 存进 buffer价值函数会把一个普通截断误判为终止认为未来的奖励不再到来导致目标值偏低。解决缓冲区里存float(terminated)而不是两者布尔或的结果。truncated时只做env.reset但过渡本身保持 done0让价值函数把该状态看作可以继续延续的普通状态。这一改在长 horizon 任务上经常能带来立竿见影的收益。6. 可视化分析工具的三种用法从曲线到行为真正看懂算法差距6.1 训练曲线要平滑处理否则看不出收敛趋势原始 episode reward 的震荡幅度非常大直接画图只能看到一团乱麻。用指数滑动平均做平滑是强化学习实验的常规操作import matplotlib.pyplot as plt def smooth_curve(values, weight0.95): smoothed [] last values[0] for v in values: last last * weight v * (1.0 - weight) smoothed.append(last) return smoothed for name, rewards in [(DDPG, ddpg_rewards), (SAC, sac_rewards), (TD3, td3_rewards)]: plt.plot(smooth_curve(rewards), labelname) plt.xlabel(Environment Steps) plt.ylabel(Smoothed Episode Reward) plt.legend() plt.savefig(compare_three_algorithms.png, dpi150)weight0.95平滑强度较高适合看整体趋势想观察训练中后期的小幅度波动可以降到 0.9。保存 dpi 150 的 png 比默认的 100 更清晰放进论文或报告里也够用。对比时看三条曲线之间的间距和方差SAC 通常方差小收敛快DDPG 前期可能冲得高但后期容易掉TD3 相对平稳。6.2 动作分布直方图探索程度一眼看出评估阶段把每个 episode 的动作全部收集起来画直方图能直接看到三种算法的探索策略差异。DDPG 和 TD3 因为策略确定性动作会集中在少数几个值附近SAC 的随机策略分布更宽。如果 SAC 的动作分布和 DDPG 一样窄说明熵系数调节失效了。动作分布还能暴露奖励曲线看不到的问题一个算法 reward 高但动作分布集中在动作空间边界说明它学到了一个极端的 bang-bang 控制对机器人控制这类场景并不健康。这一步判断成本极低三行代码即可。6.3 轨迹回放行为的最终裁判奖励曲线是标量对机器人和自动驾驶这类任务轨迹的连续性、平滑性比奖励数字更重要。把evaluateTrue跑一次将每一步的 obs、action、reward 保存下来用 matplotlib 画成轨迹图或者存成逐帧图合成 gif。我自己以前只看 reward 曲线结果在两个算法之间选错了方向后来把动作分布和轨迹回放放在一起看才发现某个算法奖励高是因为每步都在剧烈抖动量。后来这个文件夹里所有对比实验都强制自己三步走平滑曲线看趋势直方图看探索回放看行为。现在回头看光靠曲线判断算法优劣是复现项目里最容易踩的隐性坑。希望这段可视化经验帮你在同样的地方少走一次弯路。本文还有配套的精品资源点击获取
返回列表