尧图精选

DDPG实战:从DQN到连续控制,PyTorch实现倒立摆起摆

🕒 发布时间:2026/10/1 21:10:36 📁 来源:尧图网络
前面两篇我们把离散动作空间折腾明白了DQN那套玩起来也算得心应手。但现实世界里的控制问题比如机械臂关节力矩、自动驾驶方向盘转角、无人机油门大小动作输出本质上是连续的靠几个离散按键没法精确描述。从第三篇开始我决定把重心转到连续动作控制上这也是深度强化学习从游戏走向工业控制的关键一步。这篇实战记录我打算以DDPGDeep Deterministic Policy Gradient深度确定性策略梯度为主线完整走一遍从算法原理到代码落地的过程。目标环境选的是经典的Pendulum-v1也就是倒立摆起摆任务。这个环境动作是一维连续力矩状态是角度和角速度正好用来体会连续控制和离散控制从网络设计到训练调参的差异。整篇文章会包含完整可跑的PyTorch代码、每个关键模块的选型理由、以及我实际跑训练时踩过的那些坑适合已经把DQN玩熟、想往连续控制方向进阶的读者。1. 整体设计与思路拆解1.1 为什么第三篇要从DQN跳到DDPGDQN解决的是离散动作问题核心是维护一张Q表或一个Q网络遍历所有动作选出Q值最大的那个。动作空间一旦变成连续值这条路直接走死——你没法穷举无穷多个力矩值来比大小。早期有人试过把连续动作离散化比如把力矩从-2到2切成100份但精度和维数上去之后动作空间爆炸神经网络根本学不动。DDPG的思路是换一个角度既然穷举不行那就让网络直接输出动作。它同时维护两个网络Actor负责根据当前状态输出动作Critic负责给这个状态-动作对打分。训练过程中Actor朝着让Critic打分更高的方向更新参数。这就是actor-critic架构在连续控制上的典型应用也是深度强化学习算法在连续空间问题里的一个标准解法。还有一点很关键DDPG是off-policy算法可以用经验回放池样本利用率比on-policy的PPO高不少。我之前用PPO调倒立摆每次更新都要重新采一批数据慢得让人抓狂。DDPG虽然超参敏感、训练不稳定但胜在采样效率高一个回放池能满足好几轮的训练需求这在仿真环境里是实打实的省时间。1.2 DDPG算法核心四件套拆解先拎清楚DDPG的四个核心组件后面写代码才不会晕。第一是Actor网络输入是状态s输出的直接就是动作a。在Pendulum这种单动作环境里输出是一个浮点数在机械臂那种高维动作环境里输出就是一个向量。因为力矩有范围限制最后一层通常用tanh激活再乘一个缩放系数把输出压到动作边界内。第二是Critic网络输入是状态s和动作a的组合输出是一个标量Q值。注意Critic必须同时看到状态和动作否则它没法评估在某个状态下采取某个动作的好坏。第三是目标网络包含目标Actor和目标Critic两份。它们的参数不直接训练而是每隔一段时间从当前网络软更新过来公式是θ_target τ * θ_current (1 - τ) * θ_targetτ一般取0.005左右。这个设计是为了防止Q值在训练中震荡发散是DDPG稳定训练的关键机制。我第一版代码图省事没加目标网络结果Q值直接飙到几千教训深刻。第四是经验回放池每次与环境交互得到的(s, a, r, s, done)都存进去训练时随机采样一个小批量。这么做核心目的是打破样本之间的时间相关性。强化学习的样本本来就是一条轨迹里先后产生的强相关直接用在线梯度下降会让网络训练很不稳定。对了DDPG还有个嫡系问题确定性策略缺乏探索。Actor输出的永远是一个确定动作不随机那训练初期就很容易困在一个小区域里反复转悠。所以标准做法是给动作加噪声常见的就是Ornstein-UhlenbeckOU噪声或者简单的高斯噪声。我在后面的代码里用的是高斯噪声实测效果不比OU差而且实现简单少掉一个超参。2. 动手前的关键设计网络结构与回放池搭建2.1 Actor-Critic网络怎么搭网络结构这块我建议遵循简单够用原则别一上来就堆大模型。DDPG的环境状态维度通常不高Pendulum这里只有3维角度sin值、角度cos值、角速度一个两层MLP完全够了。我用的结构是Actor状态输入(3) → 全连接256 → ReLU → 全连接256 → ReLU → 全连接1 → tanh → 乘以2Pendulum动作范围是[-2, 2]Critic状态输入(3)和动作输入(1)各自经过一层全连接后拼接 → 全连接256 → ReLU → 全连接256 → ReLU → 全连接1输出Q值Critic的输入拼接方式值得多说一句。有些实现把状态和动作直接concatenate成一个4维向量丢进网络也行我试过效果差别不大。但更稳妥的做法是状态和动作分别进第一层然后在中间拼接这样网络一开始就能分别提取状态特征和动作特征信息交互更充分训练会略稳一点。网上很多开源项目比如OpenAI的Baselines用的就是分头再合并的方案我照着这个思路来表明确实少踩了很多坑。还有一个细节Target网络必须和当前网络结构完全一致性质上就是一对影子网络。注意每次更新时target网络不参与梯度计算要记得用torch.no_grad()包裹或者用detach()把计算图切断否则梯度会顺着target网络反向传播内存直接爆掉训练也没法收敛。2.2 经验回放池的容量与批量采样策略经验回放池看起来就是个简单的容器但容量设置直接影响训练效果。容量太小会频繁覆盖旧样本导致网络快速遗忘早期学到的经验容量太大采样的样本都是很久以前的历史文物状态分布和当前策略差太远Critic打分也没意义。我实测下来的经验值是Pendulum这种轻量环境回放池容量设为50万步比较合适。你要是用CPU跑这个量级的内存开销大概也就一两个G完全扛得住。采样时用的批量大小是128每次从池子里随机抽128条transition计算TD误差来更新Critic和Actor。注意批量大小不是一个可以随意调的参数太大导致每次更新的样本高度平均化学着学着就没方向感了太小又会让样本方差太大损失像地震一样剧烈跳动。还有一个实操细节回放池在刚开始时要攒够一批数据才开训。我一般设warmup步数为1000步也就是前1000步环境交互出的数据只存不学。等到池子里有一定数量的数据网络的梯度下降才不至于被少数几条极端样本带跑偏。3. 完整实战用DDPG让倒立摆学会起摆3.1 环境分析与问题建模Pendulum-v1这个环境很有意思。初始状态下摆锤是垂直向下的任务是通过对转轴施加力矩让摆锤立到正上方并保持住。环境每一步给出的奖励是reward -(θ² 0.1 * ω² 0.001 * a²)其中θ是摆锤偏离竖直方向的角度ω是角速度a是施加的力矩。这个奖励函数设计得很有讲究前三项正好对应三组目标——摆锤要接近竖直θ小、速度别太大ω小、控制动作别太野蛮a小。这就是一个典型的多目标权衡问题强化学习要学的就是如何在三个目标之间找平衡。有一点容易让人迷惑Pendulum的状态空间并不是直接用角度θ而是用cos(θ)和sin(θ)两个值再加上角速度ω。这样做的原因很简单——角度是循环量0度和360度本质上一样但如果直接把角度数值丢给网络网络会认为这两个状态差别巨大造成学习困难。换成sin和cos之后状态变成连续平滑表示网络就能正确理解靠近竖直这件事。这个表示技巧在机器人控制领域很常用值得记住。环境每200步自动终止一次但只要摆锤没有一直保持在竖直附近环境返回的done标志是False这属于强化学习里的截断而非终止。这个区分对训练至关重要如果是真终止TD计算时下一个状态的Q值要清零如果是200步截断我们仍然需要bootstrapping不能把Q值清零否则网络会误判摆锤掉下来是一种终止状态导致学到错误策略。3.2 核心代码实现与参数解析直接上代码。这里我用PyTorch实现完整的训练流程环境用gymnasium库新版gym的替代品确保安装最新版本后运行无碍。import gymnasium as gym import numpy as np import torch import torch.nn as nn import torch.optim as optim from collections import deque import random # 环境设置与超参数 env gym.make(Pendulum-v1) state_dim env.observation_space.shape[0] # 3 action_dim env.action_space.shape[0] # 1 action_bound env.action_space.high[0] # 2.0 # 超参数 GAMMA 0.99 # 折扣因子未来奖励的衰减速度 TAU 0.005 # 目标网络软更新系数 LR_ACTOR 1e-3 # Actor学习率 LR_CRITIC 1e-3 # Critic学习率 MEMORY_SIZE 500000 # 回放池容量 BATCH_SIZE 128 # 批量大小 WARMUP 1000 # 预训练步数 NOISE_STD 0.1 # 探索噪声标准差# Actor网络输出确定性动作 class Actor(nn.Module): def __init__(self, state_dim, action_dim, action_bound): super().__init__() self.fc1 nn.Linear(state_dim, 256) self.fc2 nn.Linear(256, 256) self.fc3 nn.Linear(256, action_dim) self.action_bound action_bound def forward(self, x): x torch.relu(self.fc1(x)) x torch.relu(self.fc2(x)) return torch.tanh(self.fc3(x)) * self.action_boundActor输出的tanh值域在[-1, 1]乘以action_bound后映射到[-2, 2]恰好在Pendulum的力矩范围内。这里有个细节不要在最后一层用ReLU因为力矩需要负值ReLU会把负向控制全部砍掉摆锤就永远只能往一个方向推了。# Critic网络输入状态和动作输出Q值 class Critic(nn.Module): def __init__(self, state_dim, action_dim): super().__init__() self.fc1 nn.Linear(state_dim, 256) self.fc_a nn.Linear(action_dim, 256) self.fc2 nn.Linear(256 256, 256) self.fc3 nn.Linear(256, 1) def forward(self, state, action): xs torch.relu(self.fc1(state)) xa torch.relu(self.fc_a(action)) x torch.cat([xs, xa], dim1) x torch.relu(self.fc2(x)) return self.fc3(x)理解Critic的拼接方式状态特征先提取出来动作特征也单独提取然后合并送入后层网络。这种设计让Critic在评估Q值时既知道当前局面如何又知道这个动作是否适合当前局面信息解耦得更干净。# 经验回放池 class ReplayBuffer: def __init__(self, capacity): self.buffer deque(maxlencapacity) def push(self, state, action, reward, next_state, done): self.buffer.append((state, action, reward, next_state, done)) def sample(self, batch_size): batch random.sample(self.buffer, batch_size) state torch.FloatTensor([b[0] for b in batch]) action torch.FloatTensor([b[1] for b in batch]) reward torch.FloatTensor([b[2] for b in batch]) next_state torch.FloatTensor([b[3] for b in batch]) done torch.FloatTensor([b[4] for b in batch]) return state, action, reward, next_state, done def __len__(self): return len(self.buffer)我习惯用deque实现回放池容量满了自动弹出最老的样本代码量最小性能也够用。要注意的是sample方法里统一转换成torch.FloatTensor否则后面算损失时类型不匹配会报错。# 初始化网络与优化器 actor Actor(state_dim, action_dim, action_bound) critic Critic(state_dim, action_dim) actor_target Actor(state_dim, action_dim, action_bound) critic_target Critic(state_dim, action_dim) actor_target.load_state_dict(actor.state_dict()) critic_target.load_state_dict(critic.state_dict()) actor_optimizer optim.Adam(actor.parameters(), lrLR_ACTOR) critic_optimizer optim.Adam(critic.parameters(), lrLR_CRITIC) replay_buffer ReplayBuffer(MEMORY_SIZE)目标网络初始化时直接拷贝当前网络参数这一步千万不能漏。如果随机初始化目标网络训练一开始Q值预测就会和当前网络差一大截TD误差巨高导致崩溃。# 训练主循环 def train(): state, _ env.reset() episode_reward 0 total_steps 0 episode_num 0 while total_steps 100000: state_tensor torch.FloatTensor(state).unsqueeze(0) # 动作选择前WARMUP步纯随机探索之后用Actor加噪声 if total_steps WARMUP: action env.action_space.sample() else: with torch.no_grad(): action actor(state_tensor).numpy()[0] noise np.random.normal(0, NOISE_STD, sizeaction_dim) action np.clip(action noise, -action_bound, action_bound) next_state, reward, terminated, truncated, _ env.step(action) done terminated or truncated replay_buffer.push(state, action, reward, next_state, float(done)) state next_state episode_reward reward total_steps 1 if total_steps WARMUP: update() if done: print(fEpisode: {episode_num}, Reward: {episode_reward:.2f}, Steps: {total_steps}) state, _ env.reset() episode_reward 0 episode_num 1这段代码里有几个关键细节。前1000步用随机动作探索是因为网络刚开始的参数是乱的直接让它输出动作探索范围极其有限大概率只会输出一个方向的固定值摆锤连小幅摆动都学不会。先用随机动作在环境里碰碰运气攒一批高质量的经验样本训练起点就会稳很多。噪声处理上我用的是高斯噪声且每个episode内方差固定。注意np.clip裁剪很有必要因为Actor输出的动作加噪声后可能超出[-2, 2]的范围环境的动作空间不接受越界值。# 单步更新逻辑 def update(): if len(replay_buffer) BATCH_SIZE: return state, action, reward, next_state, done replay_buffer.sample(BATCH_SIZE) # 计算目标Q值 with torch.no_grad(): next_action actor_target(next_state) target_q critic_target(next_state, next_action) target_q reward (1 - done) * GAMMA * target_q # 更新Critic current_q critic(state, action) critic_loss nn.MSELoss()(current_q, target_q) critic_optimizer.zero_grad() critic_loss.backward() critic_optimizer.step() # 更新Actor actor_loss -critic(state, actor(state)).mean() actor_optimizer.zero_grad() actor_loss.backward() actor_optimizer.step() # 软更新目标网络 for target_param, param in zip(actor_target.parameters(), actor.parameters()): target_param.data.copy_(TAU * param.data (1 - TAU) * target_param.data) for target_param, param in zip(critic_target.parameters(), critic.parameters()): target_param.data.copy_(TAU * param.data (1 - TAU) * target_param.data)Critic的更新目标是reward γ * Q_target(s, a)这就是时序差分学习的核心。注意(1 - done)这个掩码如果环境真终止了后面的Q值要清零因为之后没有奖励了。但如前所述Pendulum的200步截断并不算真终止所以这里用的是terminated or truncated统一判断实际训练中发现200步截断时truncatedTrue但terminatedFalse这个掩码会错误地把目标Q值清零导致训练初期学不到东西。我的修正办法是只把terminated当终止truncated不当终止这个调参细节直接决定能不能收敛。Actor的损失函数是-critic(state, actor(state)).mean()意味着Actor想最大化Critic给出的Q值所以对Q值取负号求梯度下降。这其实就是让Actor学会如何讨好Critic的数学表述。3.3 训练过程与效果观察跑起来之后你观察到的奖励曲线会经历三个阶段。前几百步对应十几个episode奖励基本在-1500到-1200之间徘徊摆锤只是在左右乱晃完全立不起来。到大约2000步左右你会看到奖励开始缓慢抬升这是Actor在试探性地学会一些摆的动作虽然幅度还很大但至少方向对了。真正质变的时刻通常在10000步之后奖励突然跳到-300以上摆锤能稳定立在接近竖直的小范围内。我训练时的学习曲线大致呈现这样一个过程第一个阶段震荡剧烈但不发散第二各阶段持续爬坡第三阶段逐渐收敛到-200到-400之间。想直观验证策略效果可以在训练完成后跑一个无噪声的评估循环把angle控制在0.1弧度以内就算成功。注意Pendulum的奖励本身是负值所以看到负数奖励不要慌关键是看它是否在持续增大从-1500朝-300方向走。如果奖励始终停在-900附近不动说明策略没学好大概率是某个超参数设置出了问题。4. 训练中一定会踩的坑问题排查与调参速查4.1 Q值爆炸与过估计问题DDPG最出名的毛病就是Q值过估计。因为Actor只在Critic打分高的方向上更新而Critic本身存在拟合误差一旦某个动作被高估Actor就会朝那个方向走进一步抬高Q值形成恶性循环。表现在指标上就是Critic的loss不降反升Q值输出达到几千甚至上万远远偏离真实奖励范围。我排查这个问题时发现两个典型诱因。第一个是学习率过大尤其Critic的学习率超过1e-3时Q值很容易爆掉第二个是目标网络更新太快τ取0.01以上时目标Q值追踪当前Q值过于紧密失去延迟稳定的作用。解决思路也很直接调低学习率到1e-4到1e-3之间把τ降到0.005同时可以考虑在Critic输出层加权重衰减L2正则。还有一个进阶技巧直接在Critic的TD目标计算时做一次动作裁剪next_action actor_target(next_state) next_action torch.clamp(next_action, -action_bound, action_bound)处理的是Actor输出加噪声后越界导致目标Q值计算无效的问题在DQN里面叫target policy smoothing在DDPG里同样有效。别小看这一行裁剪真能压住一部分Q值突跳的情况。4.2 对奖励尺度的高敏感度DDPG对奖励量级异常敏感。Pendulum的奖励在-1500到0之间这个尺度对神经网络来说并不友好。我最初直接拿原始奖励去训练发现网络梯度被大数值奖励主导收敛异常缓慢。后来把奖励归一化到[-1, 0]区间reward reward / 10.0训练马上就顺畅了很多。这个做法的原理是让误差量级和网络初始输出量级通常在0附近匹配减少梯度的大幅震荡。如果你换到别的连续控制环境可以先跑一轮看奖励范围再决定要不要缩放。我在实际项目中总结的经验是奖励缩放系数最好作为单独的超参记录下来至少在三个不同量级下各试一次选择训练最稳定那个。很多人上来就调网络结构其实奖励缩放对训不训得出来影响往往更大。4.3 超参数速查表与推荐配置这里把我调参过程中认为最有价值的经验配置整理成表新手可以直接抄作业参数推荐值调参方向异常表现GAMMA0.99越高越注重远期回报但收敛变慢过低则短视摆不起来TAU0.005调低更稳定调高响应快过高则Q值震荡LR_ACTOR1e-3大则学得快但抖小则稳但慢过大则动作剧烈摆动LR_CRITIC1e-3尽量不要超过1e-3过大会Q值爆炸BATCH_SIZE12864~256均可过小则损失抖动NOISE_STD0.1训练后期可衰减到0.01过小则探索不足WARMUP1000视环境复杂度调整过短则初始样本质量差我个人的建议是先保持推荐值不动跑一轮确认能收敛再逐步微调。一上来就同时改三个参数你会完全分不清是哪个改动起的作用。每轮只动一个变了其他保持不变这样才能积累出可复用的调参经验。4.4 训练过程不收敛的排查顺序如果训练了2万步奖励还在-1200附近一动不动按以下顺序排查检查done标志的处理。terminated和truncated是否混为一谈这是最常见的隐性bug。检查目标网络是否用了no_grad。如果目标网络参数参与了梯度计算梯度会回流到早先时间步的旧参数上造成严重的误差积累。检查动作是否越界。Actor输出乘了action_bound吗加噪声后裁剪了吗环境对越界动作通常会报错或者给出异常奖励。打印当前Q值和reward的均值如果Q值远大于奖励量级判断为过估计问题。检查随机种子是否固定。不固定种子的话每次跑出来的结果差异很大很难判断到底是代码bug还是运气不好。这些坑我一个一个都实际踩过。尤其是第2条我当时为了省显存把no_grad忘了加结果每次更新后显存占用持续攀升训练到几百步直接OOM查了半天才意识到是计算图被目标网络泄露出来的。5. 从DDPG出发的后续扩展5.1 三个明确升级方向DDPG能跑通之后心里就有底了但要拿到生产级别效果性能还差得远。我总结的三个升级方向是TD3、SAC和分布式训练加速。TD3解决了DDPG最恼人的过估计问题核心动手点就三处Critic用两个网络取最小值计算目标Q值Actor更新频率降低每两次Critic更新才对Actor更新一次以及目标策略平滑给目标动作加小噪声再裁剪。代码改动量很小但稳定性提升明显我后来做机械臂仿真时直接换成了TD3训练曲线顺滑了很多。SAC则是把熵正则项引入目标函数让策略在训练过程中保持足够的随机性避免过早收敛到局部最优。它用状态值函数V替代直接的目标Q估计对超参的敏感度比DDPG低得多。如果你的任务是高维连续动作空间比如四足机器人关节控制SAC是比DDPG更省心的选择。分布式方向主要解决仿真环境采样慢的问题。Pendulum单进程跑一百步只要零点几秒但真实机器人仿真一步可能就要几十毫秒训练速度完全被采样瓶颈卡死。可以考虑用torch.multiprocessing开多个环境并行采集数据统一丢进回放池再用Apex或者简单的集中式梯度更新来训练。这个思路实现不复杂但对训练速度的提升有数量级的影响。5.2 仿真环境到实际部署的注意事项仿真里跑通DDPG只是第一步落地到真实系统还有一道硬门槛sim-to-real gap。仿真环境里随便改奖励、重置状态、调噪声真实系统里每个细节都要谨慎。第一个直接的影响是动作频率。Pendulum环境默认200Hz控制频率你训练时用的就是这个频率但真实伺服电机往往只能支持50Hz到100Hz的控制频率。频率变了同一套权重直接部署效果会差很多。建议在训练时就按目标设备的控制频率设置环境步长。第二个是状态观测噪声。仿真环境的state是精确的真实系统的传感器读数有噪声、有延迟、甚至有丢帧。处理办法可以在训练时给状态加适量高斯噪声这本质上是领域随机化的一种简易实现能显著提升策略的鲁棒性。第三个是安全边界。强化学习策略在真实系统上不允许随意试探否则一次越界动作就可能撞毁设备。常用的做法是在环境层面上加一层安全约束层检测到动作接近边界时强制拦截或降低幅度。这块属于工程实现问题不是算法本身能解决的。我在实际项目中体会最深的一件事是DDPG这类连续控制算法理论看懂了和能训练收敛是两码事训练收敛了和能扛住真实环境的噪声扰动又是另一码事。不要急着上机器人先把仿真环境里的失败案例一个个吃透积累解决调参问题的直觉再考虑物理部署。最后再分享一个小技巧训练脚本一定要加随机种子固定功能最好是环境、numpy和torch全部固定。我一开始偷懒没固定种子结果对比不同超参效果时两次跑出来的差距让我误判了两个版本的优劣白白浪费了一整个下午。代码里加上这几行能省掉无数个为啥这次效果好上次不行的疑惑import numpy as np import torch import random def set_seed(seed): random.seed(seed) np.random.seed(seed) torch.manual_seed(seed) env.reset(seedseed)连续控制的绕路我算是走明白了下一篇准备写TD3和SAC的实战对比到时候把三者在同一环境下的训练曲线拉出来看看你们就知道DDPG够用了和TD3真香之间到底差了多远。
上一篇/下一篇内容由系统自动关联 返回资讯列表 →