基于模型的强化学习:用环境动力学模型提升连续控制样本效率
1. 为什么做到第四篇反而要回头啃模型这块硬骨头先交代一下背景。前面三篇我们基本把无模型路线里的常见套路过了一遍环境怎么搭、数据怎么采、策略网络怎么训、PPO和TD3这类算法怎么调。如果你一路跟过来到了这一步应该已经能在一个连续控制环境里跑通一个像样的智能体了。但跑通归跑通随着实验越做越多你会越来越明显地撞到一个天花板——样本效率。拿一张随机初始化的策略去碰连续动作环境动辄几十万步交互才能看到像样的分数上升这在仿真里还能忍一旦换到真实机器人、真实工业场景没有人愿意让机器在那里瞎试几十万次。就算你用的模拟器很快每轮实验烧掉的GPU时间和等待时间也够喝一壶。这时候就轮到这篇的主角登场基于模型的强化学习Model-Based Reinforcement LearningMBRL。它的核心思路其实特别朴素与其让智能体在真实环境里一根筋地试错不如先攒一批数据学一个环境动力学模型也就是从**(状态, 动作)预测下一状态**的模型。有了这个模型智能体就像多了个便宜的模拟器可以在里面反复排练、提前规划再把自己排练出来的动作拿回真实环境里去执行。整个过程可以用一句话概括从真实数据里学出一套世界运行规律再靠这套规律规划出连续动作。这篇是系列第四篇所以我不会从零科普什么是强化学习而是直接把镜头拉到数据→模型→连续动作规划这条主线上带你用Python把这个闭环搭起来。内容上我会按下面这条脉络走先讲清楚为什么连续动作空间里有模型和没模型差别这么大然后手把手教你把采样回来的原始数据训练成一个能用的环境动力学模型再给出连续动作下的规划算法和完整工具箱代码骨架最后把我反复踩过的几个坑摊开来讲。2. 从零收集的数据如何训练一个能预知未来的环境模型2.1 数据侧先造一批够用的过渡数据很多人在这一步容易犯一个认知错误——以为动力学模型一定要在最优轨迹上才能学。其实不是。MBRL里最关键的假设是模型只需要在智能体当前可能到达的状态分布里足够准就行不需要全局准确。所以第一步反而是轻装上阵拿一个随机策略或者上一代很差的策略去环境里交互几千步把(state, action, next_state, reward)全部存下来。我习惯用1000到5000步作为第一批数据量。不用贪多因为后面每一轮训练都会往经验池里补新数据模型会跟着策略一起成长这就是MBRL里数据飞轮的起点。# 一个极简的数据采集循环示意 import numpy as np import gymnasium as gym env gym.make(HalfCheetah-v4) obs, _ env.reset() buffer [] for step in range(2000): action env.action_space.sample() # 随机探索 next_obs, reward, terminated, truncated, _ env.step(action) buffer.append((obs, action, next_obs, reward)) if terminated or truncated: obs, _ env.reset() else: obs next_obs注意一个细节如果环境本身是稀疏奖励或者特别容易死的随机策略可能采集到的大部分是死亡边缘的状态这时候模型学了也白学。我的经验是优先选连续控制benchmark里相对稳定的环境比如Hopper、Walker2d、HalfCheetah这类等整条链路跑通了再上难环境。2.2 模型侧为什么我选delta预测而不是直接预测绝对状态有了数据接下来是建模。这里有一个非常影响训练效果的决策模型应该直接预测下一时刻的状态还是预测状态的变化量delta直接预测绝对状态听起来更直观输入 (state, action)输出 next_state损失函数用MSE。但实际跑起来你会发现很多环境里状态数值本身就是很大的绝对值比如位置坐标、关节角度模型要把这些绝对值拟合得准等于要去记忆每一个位置的精确数值这对MLP来说负担很重。更聪明的做法是让模型预测delta next_state - state。原因很简单在连续控制里相邻两帧的状态变化往往是小数值、近似平滑的学习一个这一时刻应该往哪个方向偏移多少要比记忆我现在在哪容易得多。训练之后只要把预测的delta加到当前状态上就能恢复出next_state。我在Pendulum、Hopper、HalfCheetah上都做过对比同样的网络结构delta预测的验证误差能低30%到50%。2.3 不确定性建模五个网络只是起步接下来是MBRL里最重要、也最常被新手忽略的一个点不确定性。环境动力学模型永远是有误差的。误差来源至少有三种数据本身有噪声、模型容量不够、训练不充分。如果你只在规划时用一个最准的模型那它犯的错误就会被多步推演不断放大最后给出的动作序列完全是幻觉。所以几乎所有的实用MBRL系统都会用**集成模型ensemble**来估计不确定性。具体做法是独立初始化N个结构相同的神经网络每个用不同的随机种子训练数据上也可以做不同的重采样类似bootstrap。我用下来的经验是N5是一个性价比很高的起点条件允许可以上7个。规划时同时用这N个模型推演未来轨迹看它们的预测方差——方差小说明模型在这个区域有把握方差大说明这个区域数据少规划算法就不该太信任这里的想象。import torch import torch.nn as nn class DynamicsEnsemble(nn.Module): def __init__(self, state_dim, action_dim, n_models5, hidden256): super().__init__() self.models nn.ModuleList([ nn.Sequential( nn.Linear(state_dim action_dim, hidden), nn.SiLU(), nn.Linear(hidden, hidden), nn.SiLU(), nn.Linear(hidden, state_dim) # 直接输出 delta ) for _ in range(n_models) ]) def forward(self, state, action): x torch.cat([state, action], dim-1) return torch.stack([m(x) for m in self.models]) def predict_delta_mean_std(self, state, action): deltas self.forward(state, action) return deltas.mean(dim0), deltas.std(dim0)这里还有一个实操细节值得多说一句输入一定要做归一化。state、action的数值尺度可能差出几个量级关节角度可能是0.01关节速度可能是几十不归一化的话MLP的损失会被大数值特征主导小数值特征几乎学不到。我一般会在采集完第一批数据后用经验池里的均值和标准差做一次running normalization并且随着新数据进来不断更新。3. 连续动作空间的在线规划随机采样、MPC与CEM一步步递进3.1 从random shooting入门莽撞但有效模型训练好之后就到了标题里的重头戏如何利用模型在连续动作空间里做出决策。离散动作空间比如上下左右四个方向我们可以枚举遍历穷举所有动作序列然后挑累计奖励最高的那个。但连续动作空间里动作是一个实数向量比如机器人的关节力矩取值是无穷的没法直接穷举。这时候最朴素的方法是random shooting随机采样K条长度为H的动作序列把每条序列依次喂给动力学模型推演出H步未来的状态和奖励算一下每条序列的累计奖励挑最高的那条执行。听起来很莽但实际效果比我预期好得多尤其在动作维度不高2到6维的情况下。def random_shooting(model_ensemble, state, action_dim, horizon30, K200, lb-1.0, ub1.0): # 随机采样 K 条长度为 H 的动作序列 actions torch.rand(K, horizon, action_dim) * (ub - lb) lb # 每条序列都从同一个初始状态出发 states state.repeat(K, 1) returns torch.zeros(K) for t in range(horizon): pred_delta, _ model_ensemble.predict_delta_mean_std(states, actions[:, t]) states states pred_delta # 奖励函数在这里用一个简化的形式 returns compute_reward(states).sum(dim-1) best_idx returns.argmax() return actions[best_idx]这个算法有两个重要缺陷需要头脑清醒。第一它完全没利用上一次规划的结果每个step都从零开始随机采样搜索效率极低。第二它对奖励函数的平滑性很敏感如果奖励很稀疏随机采样的K条序列可能全军覆没。3.2 滚动时域执行MPC的工程价值real-world里几乎没人用一次性开环的random shooting因为模型误差会随推演步数急剧累积。更好的做法是只执行规划出的动作序列里的第一个动作等环境返回真实的状态后再重新规划。这就是**模型预测控制Model Predictive Control, MPC**的核心思想。为什么这样能救命因为模型的不确定性是在多步推演中累积的你只走一步模型误差还没来得及爆炸真实反馈就修正了方向。代价是每个step都要重新跑一次规划计算开销变大但换来的是稳健性。我在实验室里经常拿MPC和一个训练到收敛的无模型PPO agent对比模型哪怕只有70%的预测精度MPC的实际表现也经常比无模型agent更稳因为它每走一步都在重新看路而不是闭着眼睛按一条固定的策略走。3.3 CEM给采样过程安一个瞄准镜为提高连续动作轨迹的搜索质量**交叉熵方法Cross-Entropy Method, CEM**是一个工程界验证过的改进方案。CEM的思路不再是傻乎乎地均匀采样K条序列而是迭代多轮采样→评估→筛选每轮先从当前分布通常假设为高斯分布采样一组动作序列评估后取累计奖励最高的top 10%作为精英用这些精英重新估计动作序列的均值和方差下一轮采样就围绕这个更优的高斯分布展开。几轮之后采样分布就逐渐聚焦到了高奖励区域。def cem_plan(model_ensemble, state, action_dim, horizon30, iterations5, n_samples200, elite_ratio0.1): mean torch.zeros(horizon, action_dim) std torch.ones(horizon, action_dim) for _ in range(iterations): actions torch.normal(meanmean.expand(n_samples, -1, -1), stdstd.expand(n_samples, -1, -1)) actions torch.clamp(actions, -1, 1) # 推演并计算累计奖励与 random_shooting 相同 returns evaluate_trajectories(model_ensemble, state, actions) k int(n_samples * elite_ratio) elites actions[returns.topk(k).indices] mean elites.mean(dim0) std elites.std(dim0) return mean[0].numpy() # 只返回第一步动作CEM在连续控制里的实际表现要明显好于纯random shooting尤其是在动作维度升高之后比如 10维它的搜索效率优势会越来越大。代价是每个step的规划时间变长作为折中记得给CEM设置迭代次数上限和动作序列长度上限。4. 把工具箱搭起来训练循环、代码骨架与工程组织4.1 整个训练循环还是一个典型的四段式前面几段把零件都讲清楚了这里把它们组装成一个完整的工具箱。我把这个工具箱组织成四个模块数据采集器collector、动力学集成模型dynamics ensemble、规划器planner、策略执行器actor/controller。主循环用伪代码来描述初始化空经验池 用随机策略采集 N_0 步数据写入经验池 while 训练轮数 max_rounds: 用经验池训练/微调动力学集成模型 对每条真实轨迹的每一步 用 CEM/MPC 规划出当前状态下的动作序列 只执行序列里的第一个动作到真实环境 把 (s, a, s, r) 存进经验池 可选每累积 M 步新数据重新微调一次模型循环里的每一步都直接对应我们前面讲过的模块。这个循环的妙处在于数据飞轮一旦转起来模型会随着新数据不断变准规划器在更准的模型上做出更好的动作更好的动作又产生更高质量的数据。早期你可能还会看到模型预测误差反复震荡但只要数据向优质区域汇聚总体趋势一定是逐步收敛的。4.2 一份可以直接跑的控制流骨架下面我把上面的循环用Python代码骨架写出来。这里的关键设计是所有推演都在PyTorch的batch维度上进行把K条候选动作序列放在一起推演而不是一条一条循环否则性能会慢到没法用。class MBRLToolbox: def __init__(self, env, state_dim, action_dim, action_bounds(-1, 1)): self.env env self.dynamics DynamicsEnsemble(state_dim, action_dim, n_models5) self.replay_buffer [] self.action_bounds action_bounds def collect_random_data(self, steps2000): obs, _ self.env.reset() for _ in range(steps): action self.env.action_space.sample() next_obs, reward, terminated, truncated, _ self.env.step(action) self.replay_buffer.append((obs, action, next_obs, reward)) if terminated or truncated: obs, _ self.env.reset() else: obs next_obs def train_dynamics(self, epochs50, batch_size256): # 从经验池采样归一化后训练 dataset torch.tensor(self.replay_buffer, dtypetorch.float32) optimizer torch.optim.Adam(self.dynamics.parameters(), lr1e-3) for _ in range(epochs): idxs torch.randint(len(dataset), (batch_size,)) states dataset[idxs, :state_dim] actions dataset[idxs, state_dim:state_dimaction_dim] real_delta dataset[idxs, state_dimaction_dim:2*state_dimaction_dim] - states pred_delta self.dynamics.predict_delta_mean_std(states, actions)[0].mean(dim0) loss nn.MSELoss()(pred_delta, real_delta) optimizer.zero_grad() loss.backward() optimizer.step() def plan_action(self, state, horizon30, iterations5): # 用 CEM 返回第一步动作 return cem_plan(self.dynamics, state, action_dim, horizon, iterations) def run_mpc_loop(self, steps1000): obs, _ self.env.reset() total_reward 0 for t in range(steps): action self.plan_action(torch.tensor(obs, dtypetorch.float32)) next_obs, reward, terminated, truncated, _ self.env.step(action) self.replay_buffer.append((obs, action, next_obs, reward)) total_reward reward # 每 50 步微调一次模型 if t % 50 0 and len(self.replay_buffer) 256: self.train_dynamics(epochs20) if terminated or truncated: obs, _ self.env.reset() else: obs next_obs return total_reward这段代码的意图不是让你直接跑出SOTA成绩而是把一个最小可用的MBRL闭环立起来。实际使用时你会需要把归一化、评估器、日志等都补上但这些都不影响整体结构的正确性。4.3 训练时该盯哪些数字工具箱搭好之后很多人会陷入一个困惑模型训练的在loss曲线很好看但agent就是不涨分。我的建议是别只看训练loss要盯三张表。第一张是验证集one-step预测误差把经验池按9:1切训练和验证看模型在没见过的数据上的delta预测MSE。这个数字能告诉你模型是否良性过拟合。第二张是multi-step rollout误差从验证集里随机挑几个初始状态让模型自己推演50步把推演出的轨迹和真实轨迹画在一起算均方误差。这个数字比one-step误差重要得多因为规划用的是多步推演误差的累积速度才是真实性能的预演。我见过很多模型one-step误差感人、multi-step却跑飞的情况问题基本出在数据分布覆盖不全和模型过度依赖上一步误差补偿。第三张是真实环境里执行规划动作后的平均累计奖励这个没什么好说的直接反映工具箱整体是不是在进步。5. 我在这类实验里反复踩到的几个坑5.1 模型一旦学会撒谎规划就会跟着起飞这是我掉进去最深的一个坑值得单独拿出来讲。动力学模型是在有限数据集上训练的它只会对自己见过的数据区域负责。一旦CEM在规划时探索到训练数据覆盖薄弱的区域模型的输出就完全不受约束了——它可能预测出位置直接跳到十万八千公里外这类荒谬的结果而规划器如果只看累计奖励往往会觉得哇这条路奖励爆表实际上全是幻觉。解决办法有两个层次。第一个是硬约束推演出来的状态只要超出合理范围就直接截断或者给一个很大的惩罚让规划器不敢往那里去。第二个是不确定性惩罚利用ensemble模型给出的方差在评估动作序列时如果推演过程中模型方差过大就给这条轨迹的回报打个折扣。我最后使用的是第二个方案因为这等于把模型自己都不确定的信息主动喂给了规划器。5.2 奖励尺度的暗雷MBRL比无模型方法对奖励的数值尺度更敏感原因在于规划器是在做搜索而不是梯度上升。如果某些维度上的奖励数值远大于其他维度比如速度奖励是位置惩罚的100倍CEM的精英选择会被大数值的维度主导agent学到的动作就会畸形。我的做法是在计算累计回报前对奖励做一个标准化让每个时间步的奖励大致落在[-1, 1]区间。这一步看起来不起眼实际对最终收敛质量影响非常显著。5.3 超参里的玄学horizon、采样数与重启频率最后聊点超参经验这些数字没有绝对最优但我踩出来的规律可以帮你少走弯路。horizon推演步长太短模型看不了多远动作很短视太长误差累积严重规划出的序列全是幻觉。控制在总训练时长的5%到10%比较合理。我常设30步对应0.3秒到0.5秒的真实时间范围。CEM采样数追求稳妥就设200到500想加快速度就降到100。迭代轮次3到5轮足够再往上提升很小但耗时成倍增加。模型更新频率每50步微调一次是我常用节奏不建议每次都全量重训——既慢又容易让模型在近期数据上过拟合然后突然忘了老数据里的知识。数据集大小经验池建议至少留2万到5万条太少了模型很容易在数据稀疏区乱飞。我甚至见过一个案例把数据集从2万扩到5万后agent的最终分数直接翻倍。写在最后的一点个人体会这篇文章讲到的整个闭环——随机数据起步、集成动力学模型、CEM/MPC连续动作规划、在线数据回流——并不是一个需要复杂理论支撑才能上手的东西。只要你把组件逐个落实一个小型但真正能用的基于模型的强化学习Python工具箱就立在桌面上了。以我自己的使用经验来说这套东西最难的部分不是算法本身而是接受规划器没必要完美模型也没必要全局准这个事实。很多人在第一个版本上反复调参试图让模型一步不差却在整体飞轮上花了太少时间。直角坐标系里有一个点划到外太空不要紧模型在已覆盖区域里够准、规划器知道什么时候该怀疑模型这两点才是整个系统的胜负手。如果你正准备在连续控制的benchmark上尝试MBRL我的建议很直接先照这篇文章把最小闭环跑通拿到一条真实环境中的得分上升曲线再去考虑SAC这类无模型baseline、更高级的概率集成、或者把模型蒸馏成一个固定的策略网络。那条路对新手来说太陡峭从这个小工具箱开始你会对整个体系的收益和局限都看得更清楚。