前面两篇我们把离散动作空间折腾明白了,DQN那套玩起来也算得心应手。但现实世界里的控制问题,比如机械臂关节力矩、自动驾驶方向盘转角、无人机油门大小,动作输出本质上是连续的,靠几个离散按键没法精确描述。从第三篇开始,我决定把重心转到连续动作控制上,这也是深度强化学习从游戏走向工业控制的关键一步。
这篇实战记录,我打算以DDPG(Deep Deterministic Policy Gradient,深度确定性策略梯度)为主线,完整走一遍从算法原理到代码落地的过程。目标环境选的是经典的Pendulum-v1,也就是倒立摆起摆任务。这个环境动作是一维连续力矩,状态是角度和角速度,正好用来体会连续控制和离散控制从网络设计到训练调参的差异。整篇文章会包含完整可跑的PyTorch代码、每个关键模块的选型理由、以及我实际跑训练时踩过的那些坑,适合已经把DQN玩熟、想往连续控制方向进阶的读者。
1. 整体设计与思路拆解
1.1 为什么第三篇要从DQN跳到DDPG
DQN解决的是离散动作问题,核心是维护一张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-Uhlenbeck(OU)噪声或者简单的高斯噪声。我在后面的代码里用的是高斯噪声,实测效果不比OU差,而且实现简单,少掉一个超参。
2. 动手前的关键设计:网络结构与回放池搭建
2.1 Actor-Critic网络怎么搭
网络结构这块,我建议遵循"简单够用"原则,别一上来就堆大模型。DDPG的环境状态维度通常不高,Pendulum这里只有3维(角度sin值、角度cos值、角速度),一个两层MLP完全够了。我用的结构是:
Actor:状态输入(3) → 全连接256 → ReLU → 全连接256 → ReLU → 全连接1 → tanh → 乘以2(Pendulum动作范围是[-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], dim=1) x = torch.relu(self.fc2(x)) return self.fc3(x)理解Critic的拼接方式:状态特征先提取出来,动作特征也单独提取,然后合并送入后层网络。这种设计让Critic在评估Q值时,既知道"当前局面如何",又知道"这个动作是否适合当前局面",信息解耦得更干净。
# 经验回放池 class ReplayBuffer: def __init__(self, capacity): self.buffer = deque(maxlen=capacity) 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(), lr=LR_ACTOR) critic_optimizer = optim.Adam(critic.parameters(), lr=LR_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, size=action_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(f"Episode: {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步截断时truncated=True但terminated=False,这个掩码会错误地把目标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 超参数速查表与推荐配置
这里把我调参过程中认为最有价值的经验配置整理成表,新手可以直接抄作业:
| 参数 | 推荐值 | 调参方向 | 异常表现 |
|---|---|---|---|
| GAMMA | 0.99 | 越高越注重远期回报,但收敛变慢 | 过低则短视,摆不起来 |
| TAU | 0.005 | 调低更稳定,调高响应快 | 过高则Q值震荡 |
| LR_ACTOR | 1e-3 | 大则学得快但抖,小则稳但慢 | 过大则动作剧烈摆动 |
| LR_CRITIC | 1e-3 | 尽量不要超过1e-3 | 过大会Q值爆炸 |
| BATCH_SIZE | 128 | 64~256均可 | 过小则损失抖动 |
| NOISE_STD | 0.1 | 训练后期可衰减到0.01 | 过小则探索不足 |
| WARMUP | 1000 | 视环境复杂度调整 | 过短则初始样本质量差 |
我个人的建议是,先保持推荐值不动,跑一轮确认能收敛,再逐步微调。一上来就同时改三个参数,你会完全分不清是哪个改动起的作用。每轮只动一个变了,其他保持不变,这样才能积累出可复用的调参经验。
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(seed=seed)连续控制的绕路我算是走明白了,下一篇准备写TD3和SAC的实战对比,到时候把三者在同一环境下的训练曲线拉出来看看,你们就知道"DDPG够用了"和"TD3真香"之间到底差了多远。