基于模型的强化学习实战:Python工具箱实现连续动作控制 这几年一直在折腾连续运动控制最头疼的是把一堆采集下来的数据变成能在真实或仿真环境里稳定跑起来的连续动作。基于模型的强化学习MBRL之所以让我彻底转向是因为它把“学环境动态”和“优化策略”拆成两个相对独立的环节在样本受限的工况下数据利用效率比纯无模型的思路高一截。连续动作不像离散动作那样按按键选择油门、关节力矩、电机转速全是连续向量策略稍微不稳系统马上开始抖动或者发散。这篇文章是我基于模型的强化学习 Python 工具箱实战的第一篇目标是完整讲清楚一条链路怎么整理数据、怎么训练环境动态模型、怎么把模型拿来规划并输出连续动作。整个实践过程我已经跑过几轮里面涉及到的模块都保持框架无关纯 Python 组织能平移到你自己的工具箱里。适合刚接触 MBRL、想在连续控制场景里落地的朋友参考。下面直接进入正题。1. 为什么在连续动作场景里选择基于模型的强化学习1.1 连续动作空间到底难在哪连续动作空间的标准定义并不复杂每一步动作a是一个向量每个分量的取值范围通常是某个区间比如关节力矩限制在[-1.0, 1.0]之间油门开度在[0, 1]之间。离散动作可以枚举连续动作却有无穷多个取值策略网络输出维度不高但对精度和稳定性很敏感。我一开始用常见的无模型算法做连续控制比如采用 actor-critic 结构理论上能覆盖连续动作但实际落地时有两个痛点第一样本需求非常大连续空间探索效率低很多状态根本没去过策略只能靠大量试错覆盖第二策略梯度对奖励尺度、网络初始化和超参数敏感稍微调得不好动作就会高频振荡看起来像“电风扇式”抖动。这时候基于模型的路线优势就体现出来了。它先把“环境怎么响应”这件事单独学出来再用学出来的模型做规划或者虚拟轨迹生成。相当于你先建一座“风洞”再让策略在风洞里试飞而不是每次都在真机上试错。对样本量有限、动作维度又连续的任务来说这条路明显更合理。1.2 基于模型路线的核心思路基于模型的强化学习核心是训练一个关于环境动态的预测函数。给定当前状态s_t和动作a_t模型输出下一状态s_{t1}同时最好也预测即时奖励r_t和终止标志done_t。有了这个预测函数我就能在当前状态下“想象”未来一段轨迹。这个思路的关键并不在模型本身而在于怎么用它。一种方式是把模型当作模拟器用模型生成海量轨迹来训练策略另一种方式是我在这次工具箱里更常用的方式用模型做模型预测控制MPC每个控制周期规划一组连续动作序列只执行第一个动作然后滚动重规划。这种方式不需要单独维护价值函数也不需要让策略网络直接输出连续动作规划过程天然保证了动作合理性。我选择 MPC 的另一个原因是连续动作任务通常有明确的物理约束比如速度限制、力矩限制。MPC 可以在规划时显式限制动作范围甚至把动作平滑度写进代价函数。相比让神经网络“隐式”学会约束这种显式处理更可靠调试也直观。1.3 工具箱的三个模块和整体流程这个 Python 工具箱我拆成三个大模块数据模块、模型模块、规划模块。数据模块负责与环境的交互、经验缓冲区的读写和归一化预处理模型模块负责训练环境动态模型、评估模型精度、处理集成的不确定性规划模块负责从当前状态出发采样连续动作序列用模型前推轨迹计算代价值并返回最优动作。整体流程可以用六个步骤概括与环境交互收集初始数据数据来源于随机探索策略或者混合策略对状态、动作、奖励做归一化统计写入经验缓冲区用缓冲区的小批量数据训练动态模型通常训练多个模型组成集成在真实环境中采样当前真实状态用训练好的模型做滚动时域规划搜索最优连续动作序列执行第一个动作等下一时刻获得新状态后重新规划。这样每个环节都能单独拿出来测试。数据模块检测数据分布是否覆盖完整模型模块测试预测误差规划模块测试连续动作是否平滑。我在实战中遇到的绝大多数问题最后都能定位到这三层之间的接口上。2. 从数据到可学习样本数据收集与预处理的细节2.1 数据从哪里来探索策略与样本覆盖基于模型的算法对数据的要求和普通监督学习不太一样。监督学习希望样本独立同分布而强化学习的数据来自环境交互存在强时序相关性。初始阶段我一般用随机策略和一个小收益导向策略混合跑数据。随机策略能充分探索动作空间的边界比如关节力矩的上限和下限收益导向策略则能让系统靠近一些“有用”的动力学区域比如维持平衡、追踪轨迹的附近区域。如果只用随机策略数据里大量是无意义的高速度高力矩状态模型很难学会低速段的行为如果只用已训练的策略模型又容易在未见过的状态上“睁眼瞎”。我现在的做法是先跑一批随机数据打底然后用最朴素的零阶规划器生成少量相对平滑的轨迹混进缓冲区。这样数据里既有高探索多样性的样本也有接近真实控制序列的局部结构训练出的模型泛化会好很多。另外数据量不是越大越好。动态模型需要的数据规模通常在几十万条转移样本量级但如果你把采样时间步设得太短连续两步的样本几乎完全相同模型会过度拟合低频部分。我一般会根据任务周期设置一个最小采样间隔确保相邻样本在状态空间上有一定间隔。2.2 样本组织经验缓冲区的设计与时序对齐经验缓冲区是数据和模型之间的桥梁设计不好会直接污染训练。每一个转移样本至少包含五个字段当前状态、当前动作、下一状态、即时奖励、终止标志。在连续控制环境里我建议额外保存动作的实际执行值因为有些环境会存在动作延迟或饱和保存原始指令和执行结果能帮你排查模型误差来源。我实现了一个很朴素的循环缓冲区类容量设置成可配置的默认是最近五十万条转移样本。循环缓冲区的好处是内存可控且足够新的数据会覆盖老旧分布保证模型始终贴近最近环境状态。但也要注意如果环境本身是时变的比如温度变化导致机械特性偏移旧样本占比过大反而会让模型预测不准。这时可以给样本加上时间戳在采样时给近期样本更高权重。代码层面不依赖任何特定框架核心逻辑很直白class ExperienceBuffer: def __init__(self, capacity): self.capacity capacity self.states [] self.actions [] self.next_states [] self.rewards [] self.dones [] self.index 0 def push(self, s, a, s_next, r, done): if len(self.states) self.capacity: self.states.append(s) self.actions.append(a) self.next_states.append(s_next) self.rewards.append(r) self.dones.append(done) else: self.states[self.index] s self.actions[self.index] a self.next_states[self.index] s_next self.rewards[self.index] r self.dones[self.index] done self.index (self.index 1) % self.capacity注意终止标志done必须准确。很多连续控制任务里系统走出稳定区间后会被环境强制终止如果不记录这个标志模型会把“被外部重置”误认为正常的动力学转移导致预测下一状态时产生巨大偏差。2.3 预处理关键点状态、动作、奖励与异常转移动态模型的输入输出尺度直接影响训练稳定性。连续控制的状态量纲各异比如角度可能在0.01级别角速度却在10级别如果直接喂进神经网络模型会倾向于忽略小量纲特征。我在实践中会维护一组运行均值和方法差对所有状态、动作、下一状态做标准化模型学习的目标也同步标准化。奖励处理更值得注意。原始奖励的尺度有时候非常大比如某些任务为了鼓励远距离前进奖励会达到100以上有时候又小到0.001级别。如果直接把奖励作为回归目标损失很容易被极端值主导。我通常会做一次奖励裁剪或者奖励缩放让奖励目标落在[-1, 1]附近。这个预处理不是简单的归一化还要考虑任务目标如果奖励存在稀疏长尾可以拆解成“距离项存活项”的加权组合让模型学到更稳定的奖励面。异常转移样本也必须处理。比如环境因为物理穿透或数值发散输出了一个 NaN 状态或者某一步奖励远超正常范围这种样本要直接剔除不能进缓冲区。我踩过一次坑仿真环境在某个特殊姿态下产生了超大加速度导致状态出现极端值模型训练时损失直接爆炸。后来加了异常检测对每个状态分量的绝对值上限做校验超过阈值就丢弃并打印警告。3. 环境动态模型网络结构、不确定性处理与训练策略3.1 预测什么从“转移”到“差分”环境动态模型最常见的输出是直接预测下一状态s_{t1}。但直接预测绝对状态有一个问题状态变化通常比较小网络很容易把输出收敛到“复制输入”的状态这在动作对状态影响微弱时尤其明显。更好的做法是预测状态增量也就是delta s_{t1} - s_t模型实际输出的目标变成这个差值。状态增量预测的意义在于它让网络只需要学习“当前状态下动作引起的改变量”。对于大多数连续控制任务系统的惯性使得delta的幅度远小于状态本身预测难度更低。我的模型输出维度通常等于状态维度加上一个标量奖励预测再加上一个标量终止概率。终止概率用二分类输出在训练时和回归损失一起计算。代码骨架如下# 伪代码动态模型前向传播 def forward(self, state, action): normalized_state self.state_norm(state) normalized_action self.action_norm(action) features self.encoder(torch.cat([normalized_state, normalized_action], dim-1)) delta self.delta_head(features) reward self.reward_head(features) done_logit self.done_head(features) next_state state delta return next_state, reward, done_logit注意这里我用了torch.cat这样的写法实际工具类里可以替换成任意你习惯的张量库。整体结构是框架无关的只要你保留“状态增量”这个设计就能规避很多训练陷阱。3.2 网络结构怎么搭输入输出、激活、残差与归一化动态模型的主体网络不需要很花哨。我试过简单的多层感知机也试过带卷积编码器的结构。对于状态维度在几十以内的连续控制任务四到五层全连接网络已经足够。每一层设置 256 到 512 个隐单元中间使用 Swish 或者 ReLU 激活函数。不要在一开始就堆大网络数据量可能撑不起参数量。一个非常有效的技巧是残差连接。我在编码器输出之后加一个残差块让网络既可以选择直接传递输入特征也可以在此基础上叠加非线性变换。这在动态模型训练里能明显减少“模式坍缩”的情况也就是模型倾向于输出均值状态而忽略动作的影响。残差连接让“动作是否真的影响下一状态”变成一个可以学习的偏移量。输入层需要把状态和动作拼接起来。如果状态和动作的维度相差很大比如状态是 20 维、动作只有 2 维建议分别做特征提取再融合避免动作信息被淹没。我的工具箱里有一个可配置的稠密编码层对状态和动作分别映射到相同维度再拼接后送入主网络。这在实际调试中让模型对动作的响应灵敏了许多。3.3 用集成网络量化不确定性动态模型天生有误差但在连续动作规划里我们最怕的不是误差本身而是“模型不知道自己哪里不准”。解决办法就是训练多个模型组成集成用多个模型的预测差异来估计不确定性。经典做法是训练 N 个网络每个网络用不同随机种子或者不同数据子集初始化。预测时让每个模型输出下一状态然后计算均值和方差。我一般训练 5 个网络。数量太少估计不稳定数量太多训练和预测成本线性增长。高维连续动作任务中预测均值大概在 5 个模型时已经足够平滑再多提升有限。方差可以用作规划时的惩罚项如果多个模型预测的下一状态差异大说明当前状态一动作对是模型不熟悉的区域代价函数可以加一个不确定性惩罚项促使规划器避开这些危险区域。另一个细节是集成模型的初始化差异必须足够大。如果所有模型从相同初始权重开始训练即使数据分组不同也会因为梯度下降路径相似而收敛到相近结果不确定性估计失效。我在每个模型前加不同的正交初始化扰动效果比只换随机种子更明显。3.4 训练细节损失函数、早停、批次与设备动态模型的训练本质上是多任务回归下一状态增量、奖励、终止概率三个输出一起优化。状态增量损失和奖励损失都使用均方误差终止概率使用二分类交叉熵。这三个损失需要加权求和。我在实战中的默认权重是1.0 : 0.5 : 0.1理由是终止预测只是辅助信号权重太大容易让网络忽略状态演变。训练批次大小通常取 256 或 512。动态模型不像图像模型那样需要很大批次但批次太小会增加方差。优化器我用自适应矩估计类优化器初始学习率1e-3每若干轮衰减 0.5。早停非常关键我会保留一份验证集当验证集状态增量损失连续十轮不下降时停止训练。这份验证集必须从数据收集阶段就独立出来不能和训练样本混合。还要注意训练数据里相邻时间步样本高度相关。如果直接随机打乱模型会看到太多重复信息。我通常在缓冲区里先按 episode 切块再在块内随机采样这样既保留时序结构又避免两个连续样本同时进批次。4. 从模型到连续动作MPC 与交叉熵方法实战4.1 为什么规划优于直接输出动作有了环境动态模型之后下一个问题是怎么把它变成连续动作指令一种思路是拿模型生成虚拟轨迹再训练一个策略网络直接输出动作。这种思路很常见但实现复杂而且策略网络本身会引入新的误差。我更喜欢模型预测控制MPC每一步都规划未来固定长度的一段动作序列用动态模型前推轨迹计算这段轨迹的累计代价然后选择代价最小的动作序列。MPC 的优势在于动作是显式搜索出来的不是网络的一次前向传播“猜”出来的。规划过程可以穷举很多候选动作然后在里面挑最好的天然具有更强的鲁棒性。我最初以为 MPC 计算量很大实际上对于控制周期 10 到 20 赫兹的任务现代计算设备完全能承担。如果控制频率更高可以把规划器挂到单独进程或者用 GPU 并行采样。代价函数也比直接优化策略网络容易调。比如我想让机械臂平滑移动到目标点代价设置为目标距离平方加上动作变化量平方就可以同时抑制超调和高频抖动。这些规则在规划器里是显式而透明的换成策略网络就很难这样直观调参。4.2 CEM连续动作采样的可靠选择连续动作规划的核心是在高维连续空间里找一个低代价动作序列。最朴素的方法是随机采样一堆动作序列前推模型选代价最小的那个。但纯随机采样在高维空间效率很低。交叉熵方法CEM通过迭代地拟合精英样本的分布逐步把采样的重心移到低代价区域是实际工程里很可靠的优化方法。CEM 的完整流程是这样的设定规划时域H比如未来 10 步设定动作维度d初始化高斯分布的均值mean为零向量标准差std为动作上限的一半从高斯分布里采样K组动作序列每组形状是H x d对每一组候选序列用动态模型从当前状态开始滚动仿真累计代价选出代价最低的K_elite组序列用这些精英序列重新估计均值和标准差然后回到第 3 步迭代 5 到 10 次取最终得到的最优序列的第一个动作作为当前控制指令。伪代码可以写得很短def cem_plan(model, init_state, horizon, action_dim, iterations, num_samples, num_elites): mean torch.zeros(action_dim * horizon) std torch.ones(action_dim * horizon) * 0.5 for _ in range(iterations): candidates sample_gaussian(mean, std, num_samples) costs evaluate_trajectories(model, init_state, candidates) elites select_lowest(candidates, costs, num_elites) mean elites.mean(dim0) std elites.std(dim0) 1e-6 best_action mean[:action_dim] return best_action每轮迭代里标准差要加一个极小量防止精英样本分布退化到零方差导致采样停在一个局部点出不去。这个极小量相当于给探索性留了一个“安全阀”。4.3 重规划与动作平滑的工程细节MPC 不是规划一次就完事。真实环境会受模型误差、外部扰动的影响每一步执行完动作后需要拿真实感知到的当前状态重新规划。这个“滚动”机制能让控制更稳定。我建议控制周期稳定在一个频率上比如每 100 毫秒规划一次。如果规划耗时太长宁可减小规划时域H也不要让控制频率抖动。有一个容易被忽略的细节动作序列的第一段和第二段之间的衔接。CEM 每次重新规划都会得到一个全新的动作序列但两次规划动作可能在边界处跳变导致执行机构冲击。我的处理方式是保存上一时刻实际执行的动作a_prev在代价函数中追加一项动作变化惩罚形式为lambda_smooth * ||a_t - a_prev||^2。这个惩罚会促使规划器输出平稳连续的连续动作序列。连续动作的上下限也必须约束。CEM 采到的动作可能超出执行器范围我会在仿真前做一次 clamp同时在拟合精英分布时把超出边界的样本丢弃或者映射回边界附近。如果直接硬 clip会造成分布积累在边界上影响后续采样的多样性。更稳妥的办法是把采样空间映射到一个无约束区间比如用 tanh 变换让 CEM 在无约束空间里搜索再在仿真时映射回物理动作范围。另外代价函数里的每个惩罚项都需要做好权重调参。目标跟踪项权重太大会导致动作剧烈平滑项权重太大会导致动作响应迟钝。我一般从零开始逐个加项先只放目标误差等系统能接近目标再加平滑惩罚然后微调系数。5. 真实调试记录常见问题与排查建议5.1 模型越推越飘规划效果崩溃我最常遇到的问题是动态模型在一步预测上误差很小但滚动多步后状态就飞到离谱范围。原因很典型单步误差在迭代过程中不断累积模型训练时没有充分考虑多步一致性。解决思路有两个方向。第一个方向是多步训练。我试过在训练过程中随机从真实轨迹中取一段状态让模型连续预测多步把每步预测误差都累加进损失。这会让模型更重视长期动态一致性但实现复杂且有可能放大终端附近的误差。第二个方向更像工程修复在 MPC 中把规划时域缩短同时给代价函数增加状态边界惩罚一旦预测状态越界就施加重惩罚。这个方案简单有效适合优先尝试。如果误差累积还是严重我建议检查训练数据的动作分布。模型在规划时会频繁遇到它没见过的“好动作”而这些动作可能离训练数据分布很远。解决方法是让数据收集阶段多跑一些 CEM 规划产生的动作让模型见一见规划器的动作分布这也是我前面提到混合数据的重要原因。5.2 连续动作高频抖动连续动作高频抖动是规划加执行场景里最常见的问题。现象是系统在目标点附近来回震荡动作序列呈锯齿状。我排查时发现两个主要原因一是代价函数缺少平滑项二是 CEM 每次从零开始初始化分布导致前后两步规划结果不连续。平滑项我刚才已经提到加lambda_smooth * ||a_t - a_prev||^2后会明显改善。除此之外还可以对最终执行动作做一次移动平均用历史两步动作的加权平均替代当前指令。但移动平均不能太激进否则会引入相位延迟让系统反应变得迟钝。我的经验是平滑系数在0.1到0.3之间具体根据控制频率微调。还有一个小技巧CEM 每一代的标准差下界不要设得太小。如果标准差过早收缩到接近零动作多样性丧失规划器就会陷入局部极小点表现为动作卡在一个固定值附近抖。我把标准差下界设为动作幅度的 1% 到 2%相当于给探索留一个最小随机性。5.3 模型在训练集表现好、规划时却失效这种情况通常意味着模型过拟合了训练数据里某些特定状态区域。我在数据模块里要求保存训练验证集就是为了这个问题。当模型验证集损失很低但规划时动作乱飞我首先会画一张预测误差的热力图看模型在哪些状态区域误差特别大。如果误差集中在状态空间的边界区域我会增加数据收集时随机探索的比例。如果误差集中在某个动作值周围比如动作接近零时误差反而大说明数据里动作零附近样本不足。连续控制任务里零动作往往对应“保持不动”的工况很重要必须专门补采样。另一个隐蔽原因是标准化参数没对齐。训练时用的状态均值和方差来自训练集但在线规划时真实状态可能分布不同。如果在线状态超出了原标准化范围模型预测会失真。我会定期在线收集新的状态统计量并更新标准化层这个过程要在模型中动态更新相当于做了一次简单的在线适应。5.4 奖励或代价函数过于敏感代价函数的尺度直接影响 CEM 精英样本的分布结果。如果代价项之间量级差异过大比如目标距离项的数值远远大于平滑项规划器会忽略平滑项动作就会剧烈。我调试时会把每个代价项除以它的经验均值让各项量级都落在0.1到1.0之间。奖励预测模型如果训练不好MPC 的轨迹评价也会失真。常见做法是让奖励预测只作为辅助信号主要靠任务本身的先验代价函数也就是不依赖模型学到的奖励直接根据预测状态计算代价。这样模型只需要预测下一状态奖励模型是否精确影响不大。我实际用下来这种“模型只预测状态代价外部计算”的解耦方式更稳。当然如果环境中没有显式可写的代价函数只能用模型奖励那我建议把奖励模型的输出做一点低通滤波避免单步奖励的噪声被 CEM 当成有效信号。滤波的目的是让规划器更关注轨迹的整体收益而不是单步瞬时波动。5.5 常见问题速查表我把实战中容易踩的问题整理成一个速查表方便大家对照。表现可能原因建议处置多步预测发散单步误差累积缩短规划时域加状态边界惩罚动作高频抖动代价缺平滑项 / 前后规划不连续加动作变化惩罚输出移动平均训练好但规划失效数据分布覆盖不足 / 标准化漂移补探索数据在线更新标准化统计量代价函数调不动各项量级差异大按经验均值归一化每个代价项模型输出 NaN数据本身有异常或网络学习率过高清洗异常样本降低学习率集成模型不确定性失效初始化差异不足使用更多样的权重初始化CEM 收敛到局部解标准差收缩过快标准差加下界增加迭代次数在线状态偏移环境动态变化定期重置模型或做在线微调这张表是我最近十几轮调试经验的浓缩。如果你也遇到表里没覆盖的问题优先去看数据分布和模型预测误差大多数问题都能顺着这条线找到根因。最后再分享一个我个人的感受基于模型的连续动作控制最大的瓶颈不在算法理论而在工程细节。数据怎么组织、状态增量怎么预测、CEM 标准差下界怎么设这些看起来不起眼的细节决定了整个工具箱能不能稳定工作。很多次我以为换一个更聪明的规划算法就能解决问题最后发现只是某一层数据标准化没对齐。先把手头这条数据到动作的链路打磨顺再琢磨更高级的算法会省掉大量返工时间。这个系列后续我还会继续拆解模型校准、在线适应和更复杂的策略优化有具体问题欢迎在实际调试中多交流。