news 2026/10/7 7:11:21

基于模型的强化学习:用环境动力学模型提升连续控制样本效率

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
基于模型的强化学习:用环境动力学模型提升连续控制样本效率

1. 为什么做到第四篇,反而要回头啃"模型"这块硬骨头

先交代一下背景。前面三篇我们基本把无模型路线里的常见套路过了一遍:环境怎么搭、数据怎么采、策略网络怎么训、PPO和TD3这类算法怎么调。如果你一路跟过来,到了这一步应该已经能在一个连续控制环境里跑通一个像样的智能体了。但跑通归跑通,随着实验越做越多,你会越来越明显地撞到一个天花板——样本效率。

拿一张随机初始化的策略去碰连续动作环境,动辄几十万步交互才能看到像样的分数上升,这在仿真里还能忍,一旦换到真实机器人、真实工业场景,没有人愿意让机器在那里瞎试几十万次。就算你用的模拟器很快,每轮实验烧掉的GPU时间和等待时间也够喝一壶。这时候就轮到这篇的主角登场:基于模型的强化学习(Model-Based Reinforcement Learning,MBRL)。

它的核心思路其实特别朴素:与其让智能体在真实环境里一根筋地试错,不如先攒一批数据,学一个"环境动力学模型",也就是从**(状态, 动作)预测下一状态**的模型。有了这个模型,智能体就像多了个便宜的模拟器,可以在里面反复排练、提前规划,再把自己排练出来的动作拿回真实环境里去执行。整个过程可以用一句话概括:从真实数据里学出一套世界运行规律,再靠这套规律规划出连续动作。

这篇是系列第四篇,所以我不会从零科普什么是强化学习,而是直接把镜头拉到"数据→模型→连续动作规划"这条主线上,带你用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)。我用下来的经验是N=5是一个性价比很高的起点,条件允许可以上7个。规划时,同时用这N个模型推演未来轨迹,看它们的预测方差——方差小说明模型在这个区域有把握,方差大说明这个区域数据少,规划算法就不该太信任这里的"想象"。

import torch import torch.nn as nn class DynamicsEnsemble(nn.Module): def __init__(self, state_dim, action_dim, n_models=5, hidden=256): 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(dim=0), deltas.std(dim=0)

这里还有一个实操细节值得多说一句:输入一定要做归一化。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, horizon=30, K=200, lb=-1.0, ub=1.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, horizon=30, iterations=5, n_samples=200, elite_ratio=0.1): mean = torch.zeros(horizon, action_dim) std = torch.ones(horizon, action_dim) for _ in range(iterations): actions = torch.normal(mean=mean.expand(n_samples, -1, -1), std=std.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(dim=0) std = elites.std(dim=0) 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_models=5) self.replay_buffer = [] self.action_bounds = action_bounds def collect_random_data(self, steps=2000): 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, epochs=50, batch_size=256): # 从经验池采样,归一化后训练 dataset = torch.tensor(self.replay_buffer, dtype=torch.float32) optimizer = torch.optim.Adam(self.dynamics.parameters(), lr=1e-3) for _ in range(epochs): idxs = torch.randint(len(dataset), (batch_size,)) states = dataset[idxs, :state_dim] actions = dataset[idxs, state_dim:state_dim+action_dim] real_delta = dataset[idxs, state_dim+action_dim:2*state_dim+action_dim] - states pred_delta = self.dynamics.predict_delta_mean_std(states, actions)[0].mean(dim=0) loss = nn.MSELoss()(pred_delta, real_delta) optimizer.zero_grad() loss.backward() optimizer.step() def plan_action(self, state, horizon=30, iterations=5): # 用 CEM 返回第一步动作 return cem_plan(self.dynamics, state, action_dim, horizon, iterations) def run_mpc_loop(self, steps=1000): obs, _ = self.env.reset() total_reward = 0 for t in range(steps): action = self.plan_action(torch.tensor(obs, dtype=torch.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(epochs=20) 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、更高级的概率集成、或者把模型蒸馏成一个固定的策略网络。那条路对新手来说太陡峭,从这个小工具箱开始,你会对整个体系的收益和局限都看得更清楚。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!