☰
基于模型的连续动作强化学习:Python工具箱实现与调优
2026/10/14 14:12:49 网站建设 项目流程

1. 从“数据”到“连续动作”:这个工具箱到底在解决什么问题

连续动作空间的强化学习,一直是让很多做控制、机器人、自动驾驶方向的朋友又爱又恨的东西。爱的是它天然贴合真实物理世界——机械臂的关节角度、无人机的推力分配、电机的电压输出,这些都不是“向左还是向右”这种离散选择,而是一个个连续的实数。恨的是,一旦动作从离散变成连续,Q-learning 那一套查表或者取max的做法就直接失效了,因为动作空间是无限维的,你没法枚举。

这个系列做到第四篇,核心要落地的就是一件事:把“基于模型”的思路和“连续动作”的控制需求,用一套 Python 工具箱串起来,让读者能真正跑通从数据采集、模型学习、策略优化到动作输出的完整闭环。前三篇我们铺垫了环境建模、数据收集、动力学模型的拟合,这一篇要解决的是最硬核的部分——怎么用学到的模型去生成连续动作,并且保证这个动作在真实环境里是稳的、可用的。

先说清楚适合谁看。如果你已经写过几行 Python,装过 numpy,对强化学习里“状态、动作、奖励”这三个词不陌生,那这篇就是给你准备的。如果你是从控制背景转过来的,习惯用 MATLAB 优化工具箱那一套,也没关系,我会把 Python 这边的对应做法讲透。但如果你连 Python 环境都还没搭好,建议先去看 python 安装教程、python 官网下载 那类基础内容,把 python 3.8 或者更高版本装好,numpy 装好,再回来跟这篇。

为什么强调“基于模型”?因为纯无模型的连续动作算法,比如 DDPG、TD3、SAC,样本效率是真的低。我实测过一个六自由度的机械臂抓取任务,纯无模型的方法跑了将近两百万步才勉强收敛,而基于模型的方法在二十万步左右就能出一个能用的策略。差了一个数量级。对于真实机器人这种一步几秒钟、还容易磨损设备的场景,样本效率就是命根子。基于模型强化学习的核心逻辑是:先用数据学一个环境动力学模型,然后在这个模型里“想象”大量轨迹,用这些想象出来的数据去训练策略。这样真实环境只需要提供少量数据,剩下的交给模型去推演。

但这里有个坑,也是这个系列反复强调的:模型误差会累积。你学出来的动力学模型不可能百分百准,一步错、步步错,想象出来的轨迹跑到后面就完全偏离真实情况了。所以工具箱里必须有一套机制来控制这个误差,比如短rollout、模型集成、不确定性惩罚。这些细节我会在后面的章节里一个个拆开讲。

2. 工具箱的整体架构与模块拆解

2.1 四个核心模块的职责划分

这套工具箱我把它拆成了四个模块,每个模块职责单一,方便你替换其中任何一个而不影响其他部分。

第一个是数据缓冲模块,负责存真实环境交互产生的 transition,也就是 (state, action, reward, next_state, done) 这五元组。别小看这个模块,连续动作下的数据维度高,存储和采样效率直接影响训练速度。我用的是 numpy 的环形缓冲区,配合预分配内存,避免频繁的 list append 导致内存碎片。

第二个是动力学模型模块,这是基于模型方法的心脏。输入当前状态和动作,输出预测的下一状态和奖励。连续动作场景下,我一般用三层全连接网络,每层 256 个神经元,激活函数用 SiLU 而不是 ReLU,因为 SiLU 在连续控制任务里梯度更平滑,实测收敛更稳。输出层要注意,状态预测用线性激活,奖励预测也用线性,因为奖励可能是负的。

第三个是策略优化模块,负责在模型生成的想象轨迹上更新策略网络。这里可以用 SAC 的框架,因为 SAC 的最大熵目标天然适合连续动作,探索和利用平衡得好。策略网络输出高斯分布的均值和标准差,动作从分布里采样,再经过 tanh 压缩到 [-1, 1] 区间,最后按动作空间的上下界缩放。

第四个是环境交互模块,负责和真实环境打交道,采集数据、执行动作、记录奖励。这个模块要处理的一个关键问题是动作的平滑性。连续动作如果抖动太大,真实电机受不了,机械臂会抖,无人机姿态会震荡。所以我在动作输出后加了一个一阶低通滤波,截止频率根据控制周期来定,一般取控制频率的十分之一左右。

2.2 为什么选择“模型集成”而不是单一模型

单一动力学模型有个致命问题:它在训练数据覆盖的区域预测准,一旦策略跑到数据稀疏的区域,模型就会给出离谱的预测,策略还傻乎乎地信了,结果就是真实环境里直接翻车。

解决办法是训练多个动力学模型,比如五个,每个用不同的数据子集和不同的初始化。预测的时候,五个模型各输出一个下一状态,取均值作为最终预测,取方差作为不确定性度量。策略优化的时候,把不确定性作为一个惩罚项加进奖励里,不确定性大的区域奖励就低,策略自然就不愿意往那儿跑。这个思路在 PETS 和 MBPO 这两篇经典工作里都有体现,我实测下来,五个模型的集成在大多数连续控制任务上已经够用,再多边际收益就很小了。

注意:模型集成会带来五倍的前向计算开销。如果你的控制周期很短,比如 1 毫秒,那推理时间要仔细算。我一般会把模型量化成 float16,再用 ONNX Runtime 加速,单次前向能压到 0.2 毫秒以内。

2.3 短rollout策略:控制误差累积的关键

前面说了模型误差会累积,短 rollout 就是最直接的应对手段。具体做法是:每次从真实数据里采一个状态作为起点,然后让策略在模型里往前推演,但只推 1 到 5 步,不推更长。推完这 1 到 5 步的数据就拿来更新策略,然后重新从真实数据里采起点。

为什么是 1 到 5 步?这是有讲究的。推演步数太短,比如 1 步,那模型提供的信息量有限,策略提升慢;推演步数太长,比如 20 步,模型误差累积到后面就完全不可信了,策略学到的是错误信号。我做过一组对比实验,在 Hopper 和 Walker2d 这两个标准连续控制任务上,rollout 长度取 1、3、5、10、20,结果 3 到 5 步的效果最好。当然这个值跟任务有关,动力学越复杂、模型越难学,rollout 就应该越短。

工具箱里我把 rollout 长度做成了可配置参数,默认是 3,你可以根据自己任务的实际情况调。调的依据是看模型在验证集上的多步预测误差:如果模型推 5 步的误差还在可接受范围内,那 rollout 就可以设 5;如果推 3 步误差就爆炸了,那就老老实实设 1。

3. 连续动作生成的核心细节与实操要点

3.1 动作空间的归一化与反归一化

连续动作最容易踩的坑就是量纲问题。机械臂的关节力矩可能是 [-100, 100] 牛米,无人机的推力可能是 [0, 50] 牛顿,电机的电压可能是 [-24, 24] 伏。如果直接把原始动作喂给神经网络,梯度会被大量纲数值主导,训练极不稳定。

标准做法是把动作归一化到 [-1, 1]。策略网络输出经过 tanh 之后天然就在 [-1, 1] 之间,然后按下面的公式反归一化到真实动作空间:

def denormalize_action(normalized_action, action_low, action_high): # normalized_action 在 [-1, 1] 之间 # action_low 和 action_high 是真实动作空间的上下界 return action_low + (normalized_action + 1.0) * 0.5 * (action_high - action_low)

反过来,真实动作要归一化再存进数据缓冲:

def normalize_action(real_action, action_low, action_high): return 2.0 * (real_action - action_low) / (action_high - action_low) - 1.0

这个正反变换必须严格配对,我在项目里见过有人存数据的时候归一化了,取出来用的时候忘了反归一化,结果策略输出的动作永远是 [-1, 1],机械臂纹丝不动,排查了半天才发现是这里的问题。

3.2 策略网络输出的标准差处理

SAC 这类算法里,策略网络输出高斯分布的均值 μ 和标准差 σ。σ 必须严格为正,所以网络输出的是 log σ,然后取 exp。但 exp 容易爆炸,log σ 稍微大一点,σ 就上天了,采样出来的动作完全随机。

我的做法是把 log σ 限制在 [-20, 2] 之间,用 clamp 操作:

log_std = torch.clamp(log_std, min=-20, max=2) std = torch.exp(log_std)

下限 -20 对应 σ 约等于 2e-9,基本就是确定性动作;上限 2 对应 σ 约等于 7.4,对于归一化到 [-1, 1] 的动作空间来说已经是非常大的探索噪声了。这个范围我试过很多任务,都够用。

还有一个细节是 tanh 压缩后的对数概率修正。动作经过 tanh 之后,概率密度会变,计算损失函数的时候必须加上这个修正项,否则策略梯度是有偏的。修正公式是:

# 对 tanh 变换的 log prob 修正 log_prob = log_prob - torch.log(1 - action_tanh.pow(2) + 1e-6) log_prob = log_prob.sum(dim=-1, keepdim=True)

那个 1e-6 是防止 action_tanh 正好等于 ±1 时 log(0) 出现负无穷。这个细节很多开源实现里都漏了,导致训练后期策略突然崩掉。

3.3 模型预测的多步展开与梯度回传

在模型里做多步展开的时候,有一个关键选择:梯度要不要穿过模型回传到策略?也就是说,策略的损失函数对动作的梯度,要不要经过动力学模型反向传播?

两种做法各有道理。如果梯度穿过模型,策略能“知道”自己的动作会如何影响未来的状态,理论上能学得更好。但问题是动力学模型本身也是神经网络,梯度穿过去计算量大,而且模型不准的时候梯度方向可能是错的,反而把策略带偏。

我的经验是:rollout 长度短的时候(1 到 3 步),梯度可以穿;rollout 长度长的时候(5 步以上),梯度不要穿,把模型预测的下一状态当作常数,用类似 Q-learning 的方式更新策略。工具箱里默认是梯度不穿过模型,因为这样更稳,而且计算快。如果你追求极致性能,可以试试梯度穿过,但要小心调学习率,一般要调小一个数量级。

4. 完整实操流程:从零跑通一个连续控制任务

4.1 环境准备与依赖安装

先把环境弄干净。我强烈建议用 conda 建一个独立环境,别把系统 Python 搞乱了。

conda create -n mbrl python=3.8 conda activate mbrl pip install numpy scipy matplotlib pip install torch torchvision pip install gymnasium pip install onnx onnxruntime

这里解释一下每个包的作用。numpy 和 scipy 是数值计算基础,matplotlib 用来画训练曲线,torch 是深度学习框架,gymnasium 提供标准连续控制环境(比如 Pendulum、Mujoco 系列),onnx 和 onnxruntime 用来加速模型推理。如果你用的是 Windows,torch 的安装命令可能要去 pytorch 官网查一下对应 CUDA 版本的命令,别直接 pip install torch,容易装成 CPU 版本。

提示:如果你之前装过 python 但不确定版本,用python --version查一下。这个工具箱要求 python 3.8 及以上,因为用到了一些较新的类型注解语法。

4.2 动力学模型的定义与训练

动力学模型我定义成一个 PyTorch 模块,输入是状态和动作拼接,输出是下一状态和奖励的预测。

import torch import torch.nn as nn class DynamicsModel(nn.Module): def __init__(self, state_dim, action_dim, hidden_dim=256): super().__init__() self.net = nn.Sequential( nn.Linear(state_dim + action_dim, hidden_dim), nn.SiLU(), nn.Linear(hidden_dim, hidden_dim), nn.SiLU(), nn.Linear(hidden_dim, hidden_dim), nn.SiLU(), nn.Linear(hidden_dim, state_dim + 1) # +1 是奖励 ) def forward(self, state, action): x = torch.cat([state, action], dim=-1) out = self.net(x) next_state = out[..., :self.state_dim] reward = out[..., -1:] return next_state, reward

训练的时候,损失函数是下一状态预测的均方误差加上奖励预测的均方误差。奖励的尺度通常比状态小,所以要给奖励损失乘一个权重,我一般取 1.0 到 10.0 之间,看具体任务。如果奖励范围是 [-1, 1] 而状态范围是 [-10, 10],那奖励权重取 10 比较合适,让两项损失量级相当。

模型集成就是把这个模型初始化五个,每个用不同的随机种子,训练数据也做 bootstrap 采样。预测的时候五个模型都跑一遍,算均值和方差。

4.3 策略网络的搭建与动作输出

策略网络输出均值和 log 标准差,动作从高斯分布采样后经过 tanh。

class PolicyNetwork(nn.Module): def __init__(self, state_dim, action_dim, hidden_dim=256): super().__init__() self.shared = nn.Sequential( nn.Linear(state_dim, hidden_dim), nn.SiLU(), nn.Linear(hidden_dim, hidden_dim), nn.SiLU(), ) self.mean_head = nn.Linear(hidden_dim, action_dim) self.log_std_head = nn.Linear(hidden_dim, action_dim) def forward(self, state): h = self.shared(state) mean = self.mean_head(h) log_std = torch.clamp(self.log_std_head(h), -20, 2) return mean, log_std def sample(self, state): mean, log_std = self.forward(state) std = torch.exp(log_std) normal = torch.distributions.Normal(mean, std) x = normal.rsample() # 重参数化采样 action = torch.tanh(x) log_prob = normal.log_prob(x) - torch.log(1 - action.pow(2) + 1e-6) log_prob = log_prob.sum(dim=-1, keepdim=True) return action, log_prob

注意这里用的是rsample()而不是sample(),因为 rsample 支持重参数化,梯度能回传到均值和标准差。这是 SAC 能训练的关键。

4.4 训练主循环的搭建

主循环的逻辑是:先随机采集一批真实数据,训练动力学模型;然后进入迭代,每轮从真实数据里采起点,在模型里 rollout,用 rollout 数据更新策略;每隔若干轮,用当前策略在真实环境里采一批新数据,更新模型。

# 伪代码,展示主循环结构 for iteration in range(num_iterations): # 1. 真实环境采集 if iteration % env_steps_interval == 0: collect_real_data(policy, env, buffer, num_steps=1000) train_dynamics_models(buffer, models, epochs=50) # 2. 模型内 rollout 更新策略 for _ in range(policy_updates_per_iter): start_states = buffer.sample_states(batch_size=256) imaginary_data = rollout_in_model(policy, models, start_states, horizon=3) update_policy(policy, imaginary_data)

这里有几个超参数要调。env_steps_interval控制多久采一次真实数据,我一般设成每轮迭代都采,但每次只采 1000 步,这样模型能持续更新。policy_updates_per_iter控制每轮策略更新多少次,一般设 20 到 50 次,让策略充分消化模型里的信息。horizon就是 rollout 长度,前面说了,3 到 5 之间。

4.5 动作平滑与部署注意事项

训练好的策略要部署到真实设备上,动作平滑是必须处理的。我在策略输出后面加了一个一阶低通滤波器:

class LowPassFilter: def __init__(self, alpha=0.1): self.alpha = alpha self.prev = None def __call__(self, action): if self.prev is None: self.prev = action return action filtered = self.alpha * action + (1 - self.alpha) * self.prev self.prev = filtered return filtered

alpha 越小,动作越平滑,但响应越慢。控制周期 10 毫秒的话,alpha 取 0.1 到 0.3 比较合适。如果控制周期是 1 毫秒,alpha 可以取大一点,0.5 左右,因为本身更新就快。

还有一个部署时的坑:训练时状态是归一化的,部署时也必须用同样的均值和方差归一化。这个均值和方差要从训练数据里统计出来,存成文件,部署时加载。我见过有人训练时归一化了,部署时忘了,结果策略完全失效。

5. 常见问题与排查技巧实录

5.1 模型预测误差大怎么办

这是最常见的问题。排查顺序是这样的:先看训练损失降没降,如果训练损失都降不下去,那是模型容量不够或者学习率不对,加层或者调学习率。如果训练损失降了但验证损失不降,那是过拟合,加正则化或者减模型容量。如果训练和验证损失都降了但多步预测误差大,那是模型本身没问题,是误差累积,缩短 rollout 长度。

我遇到过一次,模型单步预测很准,推 5 步就完全飞了。后来发现是状态里有角度信息,角度在 ±π 之间跳变,模型学不好这种跳变。解决办法是把角度转成 sin 和 cos 两个分量,跳变就没了,模型多步预测立刻变准。

5.2 策略在模型里表现好但真实环境里崩

这是典型的模型利用(model exploitation)问题。策略找到了模型的一个漏洞,在模型里能拿高奖励,但真实环境里完全不是那么回事。

解决办法有三个。一是加大不确定性惩罚,让策略不敢去模型不确定的区域。二是缩短 rollout,减少策略利用模型误差的机会。三是用多个模型集成,取最悲观的预测,也就是五个模型里预测奖励最低的那个作为优化目标。这三个方法可以叠加使用,我一般先加不确定性惩罚,不够再缩短 rollout。

5.3 训练曲线震荡不收敛

连续动作的强化学习训练曲线震荡是常态,但震荡太大就是不正常。先检查学习率,策略学习率一般 3e-4,模型学习率 1e-3,这两个差一个数量级是有道理的,模型学得快一点没关系,策略要稳。再检查 batch size,太小了梯度噪声大,我一般用 256。还检查奖励尺度,如果奖励范围是 [-1000, 1000],那要除以一个缩放因子,让奖励在 [-10, 10] 左右,否则价值函数学不准。

下面这张表是我整理的问题速查表,遇到问题可以对照着排查:

现象可能原因排查方法解决手段
模型单步准多步不准误差累积看多步预测误差曲线缩短 rollout,加模型集成
模型内好真实环境崩模型利用对比模型和真实奖励加不确定性惩罚,取悲观预测
训练曲线震荡大学习率或 batch 问题打印梯度范数调小学习率,增大 batch
动作抖动大无平滑或平滑不够看动作序列加低通滤波,调 alpha
策略输出恒定动作归一化问题检查动作范围检查反归一化是否正确
奖励不上升奖励尺度问题打印奖励统计缩放奖励到合理范围

5.4 推理速度慢影响控制频率

模型集成五个,每个三层 256 神经元,一次前向在 CPU 上大概 2 到 3 毫秒。如果控制周期是 10 毫秒,那还能接受;如果是 1 毫秒,那就跟不上了。

加速手段有几个。一是把模型转成 ONNX,用 onnxruntime 推理,速度能快一倍。二是量化成 float16,速度再快一倍,精度损失很小。三是减少集成数量,从五个减到三个,速度提升 40%,性能损失通常不到 5%。四是把策略网络也转 ONNX,因为策略网络每次都要跑,省下来的时间很可观。

我实测过一个六自由度机械臂的任务,原始 PyTorch 推理一次 3.2 毫秒,转 ONNX 加 float16 量化之后降到 0.8 毫秒,完全满足 1 毫秒的控制周期。

5.5 真实数据采集成本高怎么办

真实数据贵,所以要省着用。我的策略是:初始随机采集 5000 步,训练一个粗糙的模型;然后用模型内 rollout 大量更新策略;策略好一点之后,用策略在真实环境里采集 1000 步,更新模型;如此循环。整个训练过程真实环境交互不超过 5 万步,相比纯无模型的 200 万步,省了 40 倍。

还有一个技巧是优先采集“信息量大”的数据。比如在状态空间里均匀采样起点,而不是沿着一条轨迹采。这样模型能覆盖更广的状态空间,泛化更好。工具箱里我实现了一个基于状态空间覆盖的采样策略,比随机采样效率高不少。

6. 一些实操心得与后续扩展方向

这个工具箱我前后迭代了七八个版本,踩过的坑基本都写在上面的排查表里了。如果只让我说一条最重要的经验,那就是:基于模型的方法,模型的质量决定一切。模型不准,后面策略优化再花哨都是白搭。所以时间要花在模型上,数据要喂给模型,验证要看模型的预测误差,而不是只看策略的奖励曲线。

后续这个工具箱还可以往几个方向扩展。一个是加入因果推断的模块,把状态里真正影响奖励的因果变量识别出来,减少模型输入的维度,提升样本效率。另一个是支持多智能体场景,比如多 AGV 路径规划,每个 AGV 一个策略,共享一个动力学模型。还有一个方向是把模型做成概率图模型,显式建模状态之间的条件依赖,这样多步预测的误差累积能控制得更好。

最后分享一个小技巧:训练的时候把模型预测的下一状态和真实下一状态都存下来,每隔一段时间画一个散点图,横轴真实值纵轴预测值,理想情况是散点落在对角线上。这个图比损失曲线直观得多,模型哪里不准一眼就能看出来。我每次调模型都先看这个图,比看损失数值有用。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询