Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

多模态与世界模型:看见以后,能不能预见

桌上的小车正向杯子滑去。

看懂照片的助手可能会说:“一辆小车和一个杯子。”会生成视频的模型,可能接着画出一段流畅的画面。但如果我们想让小车停下来,还得回答另一个问题:现在向左施力,下一刻会怎样?如果什么也不做呢?

这三个问题彼此相关,却需要不同的证据。识别出杯子,不代表能预测碰撞;画出合理的运动,也不代表能准确响应我们指定的动作。

本页先讨论文字、图像怎样进入同一个模型,再亲手学习一个小车的运动规律。我们会看到,为什么需要历史、为什么不能忽略动作,以及为什么模型预测正确了,规划器仍可能把事情办砸。最后再看视频潜表示、想象中的强化学习和跨动作物理一致性。

前置内容是跨模态表示、图像生成、音频与视频和强化学习基础。资料核查截至 2026 年 10 月 20 日。

1. 理解、生成、行动,分别在问什么

面对同一段小车视频,可以提出几类任务:

任务希望模型回答的问题
理解小车在靠近杯子,还是远离杯子?
生成根据文字描述,生成一段小车运动的视频
预测给定已经看到的几帧,接下来可能发生什么?
动作条件预测如果执行这一串控制命令,会发生什么?
决策为了停在杯子前面,现在应该执行哪个命令?

世界模型通常指对环境状态及其变化规律的学习模型。在这里,我们尤其关心它能否预测动作的后果,供规划或策略学习使用。它可以输出图像,也可以只预测供控制使用的内部表示;不必每次都画出完整视频。

策略则负责选择动作。一个输入图像和指令、直接输出机械臂命令的模型,可以是一种视觉—语言—动作策略。它可能具有丰富的内部表示,但仅凭“会输出动作”,不能断言它已经提供了可查询的未来环境模型。

反过来,一个预测很准的模型也未必知道我们要什么。它可能正确预测“小车继续前进会撞上杯子”,但还需要目标、代价函数或策略,才能决定怎样避免碰撞。

2. 统一多模态,不一定统一成同一种输出

同一个 Transformer,可以接不同的入口和出口

前面的图文模型通常先用视觉编码器处理图片,再把视觉向量交给语言模型。继续向生成扩展时,还有几种选择:把图像量化成离散符号,像文字一样逐个预测;或者保留连续的图像潜变量,让同一个主干承担文字预测和图像去噪。

Transfusion 采用后一种思路:文字走离散词元预测,图像走连续潜空间中的扩散,主要计算共享一个 Transformer。不同模态仍有各自的输入、输出映射;训练目标也分别计算后加权。Transfusion 第 3 节

一种便于理解的写法是:

L=Ltext+λLimage L=L_{\text{text}}+\lambda L_{\text{image}}

LtextL_{\text{text}} 衡量词元预测误差,LimageL_{\text{image}} 衡量图像去噪误差,λ\lambda 调节它们在总目标中的比例。这里先把每种损失各自怎样取平均说清楚,才有可能解释 λ\lambda 的作用。图像补丁变多以后,若改用求和而不调整权重,两个任务的相对影响就会改变。

“参数共享”并不意味着文字和图像变成同一种数据,更不意味着任意输入、任意输出的组合都自动学会了。训练材料、目标和测试任务仍然决定了它实际会做什么。

注意力也需要知道:哪些位置可以互相看

假设输入依次是:

文0  BOI  图0  图1  EOI  文1  BOI  图2  图3  EOI

BOI 和 EOI 表示图像的开始与结束;图0、图1 属于第一张图,图2、图3 属于第二张。

文字位置遵守从前往后的因果顺序。同一张图内部的补丁,则可以互相读取,因为它们共同参与当前图像的去噪。图0 能看见图1,却不能越过后面的文字去看图2。本页程序生成了这张十个位置的掩码:

图 1:橙框标出相对于普通因果掩码多出的两个连接。这是本书的十位置算例,不是论文的完整训练样本。图像输入在去噪训练时已经加噪,不能把“同图可见”理解为直接读取干净答案。

视频还多了一条时间轴。若一段视频有 16 帧,每帧 256×256,示意性地按“2 帧×16×16 像素”分块,那么共有:

N=162×25616×25616=2048 N=\frac{16}{2}\times\frac{256}{16}\times\frac{256}{16} =2048

个时空块。这里假定各维度恰好整除,不额外补齐。块的位置既有“在画面哪里”,也有“在第几段时间”。如果把时间顺序平均掉,“拿起杯子”和“放下杯子”可能留下几乎一样的表示。

这只是分块数量的算例,不是 Transfusion 的视频配置;该文实验集中在文字和图像。较长视频还会给注意力、缓存和生成速度带来额外负担。

3. 下一帧可能不止一种,平均以后却不一定像任何一种

设一个 32×32 的画面里只有一个 2×2 亮块。下一刻它可能出现在左边,也可能在右边,两者概率各一半。

如果要求模型只输出一张图,并用逐像素均方误差评价,最优答案是什么?

不是任选一边,而是把两种未来平均:左边出现一个半亮块,右边也出现一个半亮块。

图 2:显示的是 32×32 图像的中央区域,数值计算仍使用全部 1,024 个像素。平均图的平方误差更低,却不符合“只有一个亮块”的设定。

对每个在两种未来中取 0 或 1 的像素,输出 0.5 时,平方误差总是 0.25。两处亮块一共占 8 个不同像素,所以平均图的期望 MSE 为:

8×0.251024=0.001953125 \frac{8\times0.25}{1024} =0.001953125

若始终输出左边那张完整图片,一半时候完全正确,另一半时候有 8 个像素错 1,期望 MSE 则为 4/1024=0.003906254/1024=0.00390625。甚至全黑图片的误差也是这个数:整幅图绝大多数地方本来就是黑的。

这说明两件不同的事。第一,平方误差鼓励条件均值,面对多种未来时,可能产生重影;生成分布、采样多个未来或预测其他合适的表示,是另外的选择。第二,整图平均指标可能被大量不变化的背景稀释,需要同时检查任务关心的物体与运动。

如果亮块往哪边走取决于我们接下来施加的动作,提供动作能消除一部分不确定性。但遮挡后的状态、接触细节、外界扰动仍可能未知。加上动作条件,不会自动让世界变成完全确定的。

4. 一张照片里没有速度:先把小车的状态找齐

为了专心研究时间和动作,本书接下来把感知问题暂时拿开:程序直接读取小车的精确位置,没有训练视觉编码器,也没有使用真实摄像头。它是一个从自编轨迹学习的动力学模型,不是视频基础模型。

小车每隔 Δt=0.2\Delta t=0.2 秒接受一次命令。我们人为规定:

ut=clip⁡(at,−1,1)vt+1=0.8vt+0.4utxt+1=xt+0.2vt+1 \begin{aligned} u_t&=\operatorname{clip}(a_t,-1,1)\\ v_{t+1}&=0.8v_t+0.4u_t\\ x_{t+1}&=x_t+0.2v_{t+1} \end{aligned}

xtx_t 的单位是米,vtv_t 是米/秒。命令 ata_t 和实际执行量 utu_t 无单位;命令超过 ±1\pm1 时,执行器已经到达上限,不会继续加大输出。式中的 0.4 带有米/秒的单位,也可以理解为 2 m/s22\ \mathrm{m/s^2} 的加速度乘以 0.2 秒。

0.8 是每一步保留下来的速度比例,没有单位。它模拟阻力带来的速度衰减;这是一套离散教学规则,不是从某辆真实小车测出来的物理定律。

现在让两辆车都位于 xt=0x_t=0,一辆速度是 1 米/秒,另一辆是 −1 米/秒,命令都为 0。下一刻它们分别位于 0.16 米和 −0.16 米。只给当前位置和命令,答案就不唯一。

但若再保留前一次位置,按本环境的更新规则,可以恢复当前速度:

vt=xt−xt−10.2 v_t=\frac{x_t-x_{t-1}}{0.2}

这正是历史信息的价值。真实视频中,两帧也未必足够:相机可能移动,物体可能被遮挡,位置测量可能有噪声,接触状态还可能藏在画面之外。模型的“状态”需要保留对未来有用的信息,不能只留下容易识别的物体名称。

一般地,可以让编码器把一段观测历史变成 ztz_t,再由预测器处理动作:

zt=E(ot−k:t),z^t+1=fθ(zt,at) z_t=E(o_{t-k:t}),\qquad \hat z_{t+1}=f_\theta(z_t,a_t)

ot−k:to_{t-k:t} 表示从过去到现在的一段观测。若需要显示画面,可以再接解码器;若只是比较哪个动作更接近目标,也可能直接在表示空间中计算。这样的表示是否足够,最终还要由后续预测和任务表现检验。

5. 用九次拟合,检查模型到底缺了什么

数据按整段轨迹划分

我们使用三份独立生成的数据,种子为 11、22、33。每份都有 256 段训练轨迹、64 段开发轨迹和 64 段测试轨迹,轨迹 ID 不重叠。训练轨迹每段 8 步,开发和测试每段 16 步。

初始位置均匀取自 −2~2 米,初始速度取自 −0.6~0.6 米/秒。训练命令从 −1、0、1 中等概率选择。每段开始前的位置设为 x−1=x0−0.2v0x_{-1}=x_0-0.2v_0,让第一步也有两次位置可用。

没有测量噪声,也没有丢帧。不同测试条件复用相同的初始状态和随机命令符号,便于逐段比较;这不表示不同条件之间互相独立。

三个模型都预测下一次位置,区别只有输入。

单位置+动作知道现在在哪里、接下来做什么,却没有直接保留运动方向:

x^t+1=b0xt+b1at+b2 \hat x_{t+1}=b_0x_t+b_1a_t+b_2

两位置,无动作保留了运动历史,却不知道下一次控制命令:

x^t+1=b0xt+b1xt−1+b2 \hat x_{t+1}=b_0x_t+b_1x_{t-1}+b_2

两位置+动作同时保留历史和命令:

x^t+1=b0xt+b1xt−1+b2at+b3 \hat x_{t+1}=b_0x_t+b_1x_{t-1}+b_2a_t+b_3

每个模型使用同样的 2,048 条训练转移,用 float64 最小二乘求系数,目标是让下一位置的平方误差之和最小。三个模型的参数数目分别是 3、3、4;没有神经网络优化,也没有超参数搜索。开发记录保留用于观察,全部九组拟合都进入最终比较。

程序调用 NumPy 的 lstsq,并保存系数、矩阵秩、奇异值、残差和所有原始轨迹。这里“学习”的是数据中的动力学关系,求解器不会拿到环境函数中的 0.8 或 0.4。

完整输入恢复了规律,但这是有条件的

训练动作都没有超过执行上限,因此 ut=atu_t=a_t。把上一节的速度消去,可以得到:

xt+1=xt+0.2(0.8xt−xt−10.2+0.4at)=1.8xt−0.8xt−1+0.08at \begin{aligned} x_{t+1} &=x_t+0.2\left( 0.8\frac{x_t-x_{t-1}}{0.2}+0.4a_t \right)\\ &=1.8x_t-0.8x_{t-1}+0.08a_t \end{aligned}

实际求出的完整模型系数,在浮点误差范围内就是 (1.8,−0.8,0.08,0)(1.8,-0.8,0.08,0)。

这不是一个神奇的“看懂物理”成绩。环境本来就能用这个线性模型表示,输入又精确,训练数据也提供了足够变化。它给我们一个很有用的对照:当必要信息和模型形式都齐备时,预测可以做到什么程度;缺掉其中一项后,又会发生什么。

6. 预测一步准确,连续往前推还准确吗

单步评价每次都把真实的前两次位置交给模型。滚动预测则只提供开头两个位置,随后把模型自己预测的位置放回输入,一直推到第 16 步。后者仍然知道预先给定的动作序列,但不会取得中间的真实位置。

测试使用位置的均方根误差(RMSE),单位为米。下面是三个数据种子的 RMSE 均值:

模型单步第 16 步
单位置+动作0.07740.7792
两位置,无动作0.06530.9554
两位置+动作接近 0接近 0

完整模型在标准条件下,所有记录的逐步 RMSE 都小于 3×10−143\times10^{-14} 米,属于数值精度量级。另两个模型则明显不同:无动作模型的单步误差略小,推到第 16 步却更大。

一步一步纠正输入,和只靠自己往前想,是两种考法。训练时总能看见真实历史,推理时却不断接收自己的预测,这种差异会放大模型误差。训练多步滚动损失是一个应对方向,但也会改变计算量、优化难度和目标,不能只改评价名字就算解决了。

把命令放大,模型竟然预测得更差了

接着保留相同初始条件,把测试命令从 −1、0、1 换成 −3、0、3。真实执行器仍在 ±1\pm1 饱和,所以真实轨迹完全不变;模型却从未学过这种饱和。

完整模型把 3 直接代入 0.08at0.08a_t,相当于相信推力还能增大三倍。它的单步 RMSE 变成 0.1306 米,第 16 步变成 1.9126 米。

再把速度保留比例从 0.8 改成 0.3、命令恢复为 −1、0、1,同一个模型的单步与第 16 步误差分别为 0.0341 米、0.6625 米。地面或执行机构变了,旧规律没有自动跟着更新。

图 3:每条线先在每份数据的 64 条测试轨迹上计算 RMSE,再对三个种子取平均;阴影是三个值的最小值至最大值,不是置信区间。三种测试条件与全部模型在拟合前固定。

在强命令条件下,无动作模型的结果一点没变,因为它既没读取动作,真实轨迹也没变。它甚至比完整模型的长期误差更小。但我们不能因此说“不要动作更好”:换回正常命令,完整模型才是准确的那个。测试分布变了,模型的优劣次序也可能改变。

7. 让模型替小车想四步,再真正走一步

现在给小车一个目标位置 gg。规划器枚举未来四个命令,每步从 0、−1、1 中选,共有 34=813^4=81 条候选。

每条候选都由模型向前推四步,然后评价末尾位置、末尾速度和命令大小。为避免把不同单位直接相加,先用 1 米、1 米/秒分别把位置误差和速度归一化:

dH=x^H−g1 m,wH=v^H1 m/s d_H=\frac{\hat x_H-g}{1\ \mathrm{m}},\qquad w_H=\frac{\hat v_H}{1\ \mathrm{m/s}}

J=dH2+0.1wH2+0.01∑h=0H−1ah2,H=4 J=d_H^2+0.1w_H^2 +0.01\sum_{h=0}^{H-1}a_h^2,\qquad H=4

数值代码使用米和米/秒,所以这两个除数在代码里都是 1。JJ 是人为设计的无量纲代价,不是小车真实消耗的能量。最后一项让规划器不至于为了一点点位置改善就不停施加很大的命令。

规划器挑出 JJ 最小的一串命令,只执行其中第一个。随后读取实际位置,重新估计速度,再规划四步。这叫模型预测控制(MPC)。这里没有训练一个动作策略网络,也没有更新世界模型参数。

我们固定 18 个案例:三个初始位置、三个初始速度和两个目标位置的全部组合,每例执行 24 步。最终位置距目标小于 0.15 米、速度绝对值小于 0.2 米/秒,才算成功。

用什么预测未来三份拟合各自成功多少例,满分 18
单位置+动作6、8、8
两位置,无动作2、2、2
两位置+动作16、16、16
直接使用真实动力学16、16、16

三个种子反复使用的是同一组 18 个案例,不能把汇总的 54 次当成 54 个独立任务;真实动力学那一行也只是重复同一套确定性控制。

完整模型和真实动力学给出相同结果,却都没有满分。原因在于这套有限时域、动作集合、代价权重和终止标准的组合,未保证满足每个任务。预测器准确,不会自动让一个有限规划器变成最优控制器。

用同一把尺子检查“大胆一点”

再把规划器候选换成 0、−3、3。它依然只有 81 条候选,并没有保留原来的小命令,所以这是一套不同的控制选择,不是单纯增加搜索范围。

拟合模型每份只成功 6/18;直接使用真实动力学也只有 8/18。前者与后者的差距反映预测失配,而两者相对于原来 16/18 的下降,还包含动作集合与命令惩罚的影响。不能把所有下降都怪给世界模型。

图 4:预先固定数据种子 11、案例 0,初始位置 −1.5 米、速度 −0.6 米/秒、目标 −1 米。阴影仅标出位置容许范围,成功还要求末尾速度足够小。上图两条曲线重合,末尾距离为约 0.1506 米,刚好没达到 0.15 米的严格门槛;这张图保留了失败。

闭环反馈可以不断纠正位置估计,却不会自动让模型知道执行器的饱和规律。要解决后者,需要合适的数据、结构或更新方法。遇到模型不了解的动作区域时,搜索再积极,也可能只是在认真利用一个错误的预测。

8. 从坐标走向视频:三条研究路线

先压缩画面,再学习潜空间里的变化

Ha 与 Schmidhuber 的 World Models 把视觉压缩、循环动力学和控制器分开。作者还让控制器在学到的虚拟环境中学习,再到原任务中检查迁移。他们记录了控制器利用模型缺陷、让虚拟敌人不再发射火球的现象。作者交互论文的 VizDoom 与 Cheating the World Model 部分

这个例子提醒我们,模型生成的环境也是一个近似。策略会寻找高回报行为,却不保证那些行为在原环境中同样有效。图像重建看起来不错,也不足以排除可被控制器利用的漏洞。

在想象轨迹中学习策略

Dreamer 的第三代方法同时学习世界模型、价值估计和动作策略。世界模型结合循环状态与随机潜表示,预测未来表示、奖励和是否继续;策略与价值网络利用想象轨迹学习,并继续从环境交互中取得经验。Dreamer 的 Learning algorithm 部分

它与本页的 81 条候选穷举不同:不是每次都枚举全部动作序列,而是通过训练把决策能力写进策略。论文中的跨任务结果也来自分别在环境中学习的过程,不能理解成一套权重未经学习就掌握所有新世界。

不必重建每个像素,也可以预测有用表示

V-JEPA 2 先从图像和视频中学习表示,再用机器人交互资料训练动作条件预测器。其 V-JEPA 2-AC 阶段冻结视觉编码器,预测下一帧表示,并结合单步与两步滚动损失;控制时使用目标图像,在表示空间中规划。V-JEPA 2 第 3、4 节

文中所说的约 62 小时“无标签”机器人视频,并不是没有动作信息:它仍使用机械臂末端状态及相邻状态变化,只是不依赖奖励、任务名称或成功标注。论文也报告了相机位置敏感、长程预测误差和目标图像要求等局限。

三条路线都需要回答同一个问题:压缩时留下的表示,是否保留了动作效果、时间变化和任务真正需要的细节?省掉像素重建可以改变学习重点,却不是“已经理解物理”的自动证明。

9. 两段未来都像真的,它们属于同一个世界吗

还有一种更隐蔽的错误:模型对不同动作分别生成了合理的结果,但把它们放在一起看,就需要两套互相矛盾的物理参数。

回到小车,假设当前速度都是 1 米/秒,命令分别为 1 和 −1。某个手工构造的预测器给出的下一速度却是 1.2 和 0.1 米/秒。

保持增益 0.4 不变,分别反推速度保留比例 rr:

r1=1.2−0.4=0.8r2=0.1+0.4=0.5 \begin{aligned} r_1&=1.2-0.4=0.8\\ r_2&=0.1+0.4=0.5 \end{aligned}

单看每条预测,都能找到一套参数精确解释。可是两次只改了动作,没有换地面或小车,rr 不应该跟着换。用同一个 rr 同时拟合时,最佳值是 0.65。先计算两条速度残差,再取平方的平均:

e1=0.65+0.4−1.2=−0.15e2=0.65−0.4−0.1=0.15MSE=e12+e222=0.0225 \begin{aligned} e_1&=0.65+0.4-1.2=-0.15\\ e_2&=0.65-0.4-0.1=0.15\\ \mathrm{MSE}&=\frac{e_1^2+e_2^2}{2}=0.0225 \end{aligned}

单位是 (m/s)2(\mathrm{m/s})^2。分别拟合误差为零,共享参数却有误差,冲突就显现出来了。这里的两条预测是为了说明问题而手工设置的,不是前面线性模型或某个视频模型的实际输出。

2026 年的 OneWorld 正在研究这种跨动作一致性:不同动作可以带来不同未来,但这些未来应能由同一套世界条件解释。方法用学习到的机制解释器约束流模型;评价则另外用模拟器和状态提取器,比较各分支单独拟合与共享参数拟合。OneWorld 第 3、4 节

论文的主要实验是推方块、推绳子和倒水等受控环境,不能直接视为开放现实场景中的物理可靠性保证。本页只计算了上面的标量算例,没有实现其视频生成、机制解释器或评测器。

还要小心一个指标陷阱:如果每条预测都很糟,却糟得一致,共享参数相对于单独参数增加的误差仍可能很小。因此需要同时看单条物理拟合、共享拟合、画面与动作响应,不能只报“冲突变小了”。

10. 怎样检验一个世界模型,而不是只挑一段好视频

把前面的观察放在一起,一次有说服力的检查至少应当分开几件事:

检查本页提供的具体例子
观测是否包含必要状态同一位置、相反速度的两个未来
是否真的使用动作有动作与无动作输入的对照
是否能连续预测单步与第 16 步分别计分
是否理解执行范围命令三倍、执行器饱和
是否适应环境变化速度保留比例从 0.8 改成 0.3
预测是否帮助完成任务闭环控制,同时保留真实动力学对照
不同预测是否自洽同一初态、不同动作共享一套参数

真实多模态系统还要检查相机坐标、动作单位、时间戳和延迟。一段标成“动作之后”的视频,若实际混入动作之前的帧,训练目标就错了。遮挡、接触和物体离开画面,也不能靠挑选漂亮片段跳过。

同样,仿真任务成功不能自动证明真实机器人可用。本页有精确位置、已知时间间隔和非常简单的环境,是为了让错误来源可计算;把这三样便利拿走以后,感知、建模与规划需要一起重新检验。

11. 运行代码,把每条预测追到底

先在仓库根目录运行标准库脚本:

python3 src/examples/world_models.py

输出为:

standard:单步 / 第 16 步位置 RMSE(米)
  单位置+动作:0.0774 / 0.7792
  两位置,无动作:0.0653 / 0.9554
  两位置+动作:0.0000 / 0.0000
strong_commands:单步 / 第 16 步位置 RMSE(米)
  单位置+动作:0.1505 / 0.4600
  两位置,无动作:0.0653 / 0.9554
  两位置+动作:0.1306 / 1.9126
changed_retention:单步 / 第 16 步位置 RMSE(米)
  单位置+动作:0.0250 / 0.2855
  两位置,无动作:0.0738 / 0.4261
  两位置+动作:0.0341 / 0.6625
闭环控制成功次数(3 份拟合 × 18 个固定案例):
  position_action:22/54
  history_no_action:6/54
  history_action:48/54
  oracle:48/54
  history_action_wide:18/54
  oracle_wide:24/54
图像均值的期望 MSE:0.001953
共享惯性系数的拟合 MSE:0.0225

这条命令读取拟合记录,再重新计算图像、掩码和共享参数算例。显示为 0.0000 的数不一定精确为零;完整精度保留在记录中。三个小算例也可以独立保存:

python3 src/examples/world_models.py --small world-models-small-new.json

下载标准库脚本后,将拟合记录放入脚本旁边的 data 文件夹,就能得到相同汇总。小算例记录可供逐项比较。

展开环境、规划器和小算例的完整代码
"""标准库:小车环境、规划器和图像/注意力/跨动作一致性算例。"""
from argparse import ArgumentParser
from hashlib import sha256
from itertools import product
from pathlib import Path
import json

HERE = Path(__file__).resolve().parent
PLAN = HERE / "data/world-models-plan.json"
RECORD = HERE / "data/world-models-fit.json"
SMALL = HERE / "data/world-models-small.json"
MODES = ("position_action", "history_no_action", "history_action")
NAMES = ("单位置+动作", "两位置,无动作", "两位置+动作")
DT = 0.2


def digest(path):
    return sha256(Path(path).read_bytes()).hexdigest()


def save_new(path, data):
    path = Path(path)
    path.parent.mkdir(parents=True, exist_ok=True)
    with path.open("x") as f:
        json.dump(data, f, ensure_ascii=False, indent=2, allow_nan=False)
        f.write("\n")


def step(x, velocity, action, retention=0.8):
    """x 米、velocity 米/秒;命令 action 无单位,执行器在 ±1 饱和。"""
    applied = max(-1.0, min(1.0, action))
    next_velocity = retention * velocity + 0.4 * applied
    return x + DT * next_velocity, next_velocity


def features(mode, previous, current, action):
    if mode == "position_action":
        return [current, action, 1.0]
    if mode == "history_no_action":
        return [current, previous, 1.0]
    if mode == "history_action":
        return [current, previous, action, 1.0]
    raise ValueError(mode)


def predict(model, previous, current, action):
    if model["mode"] == "oracle":
        return step(current, (current - previous) / DT, action)[0]
    return sum(w * x for w, x in zip(
        model["weights"], features(model["mode"], previous, current, action),
    ))


def rollout(model, previous, current, actions):
    result = []
    for action in actions:
        following = predict(model, previous, current, action)
        result.append(following)
        previous, current = current, following
    return result


def choose(model, previous, current, goal, actions=(0, -1, 1), horizon=4):
    """穷举全部候选;目标仅用预测,不访问未来环境真值。"""
    best = None
    for index, sequence in enumerate(product(actions, repeat=horizon)):
        imagined = rollout(model, previous, current, sequence)
        velocity = (imagined[-1] - imagined[-2]) / DT
        cost = ((imagined[-1] - goal) ** 2 + 0.1 * velocity ** 2
                + 0.01 * sum(a * a for a in sequence))
        key = (round(cost, 12), index)  # 仅稳定近似相等的候选次序。
        if best is None or key < best[0]:
            best = (key, sequence, imagined, cost)
    return {"actions": list(best[1]), "imagined": best[2], "cost": best[3],
            "candidate_count": len(actions) ** horizon}


def control(model, case, actions=(0, -1, 1)):
    x, velocity, goal = case["x"], case["velocity"], case["goal"]
    previous = x - DT * velocity
    trace = []
    for t in range(24):
        plan = choose(model, previous, x, goal, actions)
        action = plan["actions"][0]
        new_x, new_velocity = step(x, velocity, action)
        trace.append({"t": t, "previous": previous, "x": x, "velocity": velocity,
                      "action": action, "next_x": new_x, "next_velocity": new_velocity,
                      "plan": plan})
        previous, x, velocity = x, new_x, new_velocity
    return {"case": case, "trace": trace, "final_error": abs(x - goal),
            "final_speed": abs(velocity),
            "success": abs(x - goal) < 0.15 and abs(velocity) < 0.2}


def image_toy():
    """两个等概率未来;每个未来只有一个 2×2 亮块。"""
    frames = []
    for column in (10, 22):
        frame = [[0.0] * 32 for _ in range(32)]
        for y in (15, 16):
            for x in (column, column + 1):
                frame[y][x] = 1.0
        frames.append(frame)
    mean = [[(frames[0][y][x] + frames[1][y][x]) / 2
             for x in range(32)] for y in range(32)]
    blank = [[0.0] * 32 for _ in range(32)]
    def expected_mse(prediction):
        return sum(sum((prediction[y][x] - actual[y][x]) ** 2
                       for y in range(32) for x in range(32)) / 1024
                   for actual in frames) / 2
    return {"futures": frames, "mean": mean,
            "expected_mse": {"mean": expected_mse(mean),
                             "one_valid_future": expected_mse(frames[0]),
                             "blank": expected_mse(blank)}}


def mixed_mask():
    labels = ["文0", "BOI", "图0", "图1", "EOI",
              "文1", "BOI", "图2", "图3", "EOI"]
    blocks = [None, None, 0, 0, None, None, None, 1, 1, None]
    mask = [[int(j <= i or (blocks[i] is not None and blocks[i] == blocks[j]))
             for j in range(len(labels))] for i in range(len(labels))]
    return {"labels": labels, "allowed": mask}


def shared_mechanism():
    """手工构造的两条冲突预测;不是已训练视频模型的输出。"""
    initial_velocity = 1.0
    actions, predicted_velocities = [1.0, -1.0], [1.2, 0.1]
    separate = [(v - 0.4 * a) / initial_velocity
                for a, v in zip(actions, predicted_velocities)]
    shared = sum(separate) / len(separate)
    error = sum((shared * initial_velocity + 0.4 * a - v) ** 2
                for a, v in zip(actions, predicted_velocities)) / 2
    return {"initial_velocity": initial_velocity, "actions": actions,
            "predictions": predicted_velocities, "separate_retentions": separate,
            "shared_retention": shared, "separate_mse": 0.0, "shared_mse": error}


def small_experiments():
    return {"image": image_toy(), "mask": mixed_mask(), "shared": shared_mechanism()}


def summary(record):
    rows = []
    for regime in ("standard", "strong_commands", "changed_retention"):
        rows.append(f"{regime}:单步 / 第 16 步位置 RMSE(米)")
        for mode, name in zip(MODES, NAMES):
            values = [r["evaluation"][regime][mode] for r in record["runs"]]
            one = sum(v["one_step_rmse"] for v in values) / len(values)
            last = sum(v["rollout_rmse"][-1] for v in values) / len(values)
            rows.append(f"  {name}:{one:.4f} / {last:.4f}")
    rows.append("闭环控制成功次数(3 份拟合 × 18 个固定案例):")
    for mode in record["control_methods"]:
        cases = [c for run in record["runs"] for c in run["control"][mode]]
        rows.append(f"  {mode}:{sum(c['success'] for c in cases)}/{len(cases)}")
    toy = small_experiments()
    rows.append(f"图像均值的期望 MSE:{toy['image']['expected_mse']['mean']:.6f}")
    rows.append(f"共享惯性系数的拟合 MSE:{toy['shared']['shared_mse']:.4f}")
    return "\n".join(rows)


def main():
    parser = ArgumentParser(description=__doc__)
    parser.add_argument("--small", type=Path)
    args = parser.parse_args()
    if args.small:
        save_new(args.small, small_experiments())
        print(args.small)
        return
    record = json.loads(RECORD.read_text())
    assert record["complete"]
    print(summary(record))


if __name__ == "__main__":
    main()

重做九次最小二乘拟合和全部控制案例,需要 NumPy;本书使用 Python 3.12 与 NumPy 2.5.4:

uv run --python 3.12 --with-requirements requirements.txt \
  python src/examples/world_models_fit.py \
  --run --out-dir runs/world-models-new

输出目录必须尚不存在。程序读取冻结方案,保存全部数据、预测、三份模型参数文件与控制轨迹,不挑选最好的一次。--freeze 新文件.json 可以另存当前代码对应的方案;若改变实验,要同时修改相应的数据生成或控制代码,再冻结新方案。

默认不进行拟合:

uv run --python 3.12 --with-requirements requirements.txt \
  python src/examples/world_models_fit.py

输出:

默认不拟合。使用 --run --out-dir 新目录,运行三份数据上的九次拟合。
展开数据生成、拟合和评价的完整代码
"""NumPy 最小二乘学习小车动力学;显式 --run 才拟合并保存完整记录。"""
from argparse import ArgumentParser
from pathlib import Path
import json
import platform
import random
import time
import numpy as np
from world_models import (
    DT, HERE, MODES, PLAN, control, digest, features, predict, rollout, save_new, step,
)


def make_plan():
    return {
        "schema": 1, "data_seeds": [11, 22, 33],
        "train_trajectories": 256, "dev_trajectories": 64, "test_trajectories": 64,
        "train_steps": 8, "test_steps": 16,
        "initial_x_range": [-2.0, 2.0], "initial_velocity_range": [-0.6, 0.6],
        "dt_seconds": DT, "retention": 0.8, "velocity_gain": 0.4,
        "actuator_saturation": [-1, 1], "train_commands": [-1, 0, 1],
        "models": list(MODES), "fit": "float64 numpy.linalg.lstsq rcond=None",
        "observation": "exact positions; no pixels, no noise, no learned encoder",
        "test_regimes": {
            "standard": {"retention": 0.8, "command_scale": 1},
            "strong_commands": {"retention": 0.8, "command_scale": 3},
            "changed_retention": {"retention": 0.3, "command_scale": 1},
        },
        "control": {
            "initial_x": [-1.5, 0.0, 1.5], "initial_velocity": [-0.6, 0.0, 0.6],
            "goals": [-1.0, 1.0], "steps": 24, "planning_horizon": 4,
            "commands": [0, -1, 1], "wide_commands": [0, -3, 3],
            "terminal_velocity_weight": 0.1, "command_cost": 0.01,
            "tie_round_digits": 12, "position_threshold": 0.15, "speed_threshold": 0.2,
        },
        "selection": "all seeds, all models, no hyperparameter search",
    }


def trajectory(seed, split, index, steps, scale=1, retention=0.8):
    offsets = {"train": 10000, "dev": 20000, "test": 30000}
    rng = random.Random(seed * 1000000 + offsets[split] + index)
    x, velocity = rng.uniform(-2, 2), rng.uniform(-0.6, 0.6)
    previous = x - DT * velocity
    initial = {"previous": previous, "x": x, "velocity": velocity}
    observations, velocities, actions = [previous, x], [velocity], []
    for _ in range(steps):
        action = scale * rng.choice([-1, 0, 1])
        x, velocity = step(x, velocity, action, retention)
        observations.append(x)
        velocities.append(velocity)
        actions.append(action)
    return {"id": f"{seed}-{split}-{index}", "initial": initial,
            "positions": observations, "velocities": velocities, "actions": actions}


def rows(trajectories, mode):
    inputs, targets = [], []
    for trajectory in trajectories:
        p = trajectory["positions"]
        for t, a in enumerate(trajectory["actions"]):
            inputs.append(features(mode, p[t], p[t+1], a))
            targets.append(p[t+2])
    return np.asarray(inputs, dtype=np.float64), np.asarray(targets, dtype=np.float64)


def evaluate(model, trajectories):
    one_errors, errors, predictions = [], [], []
    for tr in trajectories:
        p, actions = tr["positions"], tr["actions"]
        one = [predict(model, p[t], p[t+1], a) for t, a in enumerate(actions)]
        many = rollout(model, p[0], p[1], actions)
        one_errors.extend((np.array(one) - p[2:]) ** 2)
        errors.append((np.array(many) - p[2:]) ** 2)
        predictions.append({"id": tr["id"], "one_step": one, "rollout": many})
    return {"one_step_rmse": float(np.sqrt(np.mean(one_errors))),
            "rollout_rmse": np.sqrt(np.mean(errors, axis=0)).tolist(),
            "predictions": predictions}


def run(plan_path, out_dir):
    plan = json.loads(plan_path.read_text())
    assert plan == make_plan(), "配置与代码不一致;先用修改后的脚本重新冻结。"
    out_dir.mkdir(parents=True, exist_ok=False)
    start = time.perf_counter()
    methods = list(MODES) + ["oracle", "history_action_wide", "oracle_wide"]
    record = {"complete": False, "plan": plan, "plan_sha256": digest(plan_path),
              "source_sha256": {p.name: digest(p) for p in
                                [HERE/"world_models.py", Path(__file__)]},
              "environment": {"python": platform.python_version(),
                              "numpy": np.__version__, "platform": platform.platform()},
              "control_methods": methods, "runs": []}
    for seed in plan["data_seeds"]:
        training = [trajectory(seed, "train", i, 8) for i in range(256)]
        dev = [trajectory(seed, "dev", i, 16) for i in range(64)]
        tests = {name: [trajectory(seed, "test", i, 16, spec["command_scale"],
                                  spec["retention"]) for i in range(64)]
                 for name, spec in plan["test_regimes"].items()}
        models, development, evaluation = {}, {}, {k: {} for k in tests}
        for mode in MODES:
            x, y = rows(training, mode)
            w, residuals, rank, singular = np.linalg.lstsq(x, y, rcond=None)
            assert rank == x.shape[1]
            model = {"mode": mode, "weights": w.tolist(), "rank": int(rank),
                     "singular_values": singular.tolist(),
                     "fit_residual_sum": float(np.sum((x @ w - y)**2)),
                     "reported_residuals": residuals.tolist(), "samples": len(y)}
            models[mode] = model
            development[mode] = evaluate(model, dev)
            for name, trajectories in tests.items():
                evaluation[name][mode] = evaluate(model, trajectories)
        cases = [{"id": i, "x": x, "velocity": v, "goal": g}
                 for i, (x, v, g) in enumerate(
                     (x, v, g) for x in [-1.5, 0.0, 1.5]
                     for v in [-0.6, 0.0, 0.6] for g in [-1.0, 1.0])]
        controls = {}
        for name in methods:
            base = name.removesuffix("_wide")
            model = {"mode": "oracle"} if base == "oracle" else models[base]
            actions = (0, -3, 3) if name.endswith("_wide") else (0, -1, 1)
            controls[name] = [control(model, case, actions) for case in cases]
        model_path = out_dir / f"seed-{seed}-models.json"
        save_new(model_path, models)
        assert json.loads(model_path.read_text()) == models
        record["runs"].append({
            "seed": seed, "training": training, "dev": dev, "tests": tests,
            "models": models, "model_file": model_path.name,
            "model_sha256": digest(model_path), "development": development,
            "evaluation": evaluation, "control": controls,
        })
        print("完成数据种子", seed, flush=True)
    record["wall_seconds"] = time.perf_counter() - start
    record["complete"] = True
    save_new(out_dir/"record.json", record)
    print(out_dir/"record.json")


def main():
    parser = ArgumentParser(description=__doc__)
    parser.add_argument("--freeze", type=Path)
    parser.add_argument("--run", action="store_true")
    parser.add_argument("--plan", type=Path, default=PLAN)
    parser.add_argument("--out-dir", type=Path)
    args = parser.parse_args()
    if args.freeze:
        save_new(args.freeze, make_plan())
        print(args.freeze)
    elif args.run:
        if args.out_dir is None:
            parser.error("--run 需要 --out-dir 新目录")
        run(args.plan, args.out_dir)
    else:
        print("默认不拟合。使用 --run --out-dir 新目录,运行三份数据上的九次拟合。")


if __name__ == "__main__":
    main()

拟合脚本与标准库脚本放在同一目录即可;依赖安装见工具附录。读代码时可以先看 features 和 rollout,再看 choose:它们分别决定模型能看见什么、怎样使用自己的预测,以及规划究竟在优化什么。

12. 停下来试一试

  1. 为什么一个能回答“图片里有什么”的模型,还不能直接被视为可靠的动作条件世界模型?
  2. 图 1 中,图0 能读取图1,为什么不能读取图2?文0 能否读取图0?
  3. 如果两种亮块未来不重叠,各占 4 个像素,为什么平均图比固定选择一个未来的期望 MSE 更低?
  4. 当前位置都是 2 米,速度分别为 0.5 和 −0.5 米/秒,命令为 0。下一位置各是多少?
  5. 从小车更新规则推导 1.8xt−0.8xt−1+0.08at1.8x_t-0.8x_{t-1}+0.08a_t,并说明命令满足什么条件时这一写法才成立。
  6. 单步评价每次都使用真实历史,能否用它的分数声称 16 步开放滚动同样准确?为什么?
  7. 从静止位置 0 开始,命令为 3。真实下一位置与线性模型预测各是多少?
  8. 为什么真实动力学控制只有 16/18 成功,不能说“世界模型还没学好”?还可能需要改哪里?
  9. 共享参数拟合的误差与分别拟合的误差,哪一个不可能更小?差值为零是否说明预测一定正确?
  10. 保留原来的 0、−1、1,再加入 −3、3,四步穷举有多少候选?预先记录新增的预测计算量,同时比较拟合模型和真实动力学,观察更大的动作集合是否真的有帮助。
参考答案与实验提示
  1. 识别检查当前内容;动作条件预测还要处理时间、隐藏状态和指定动作的后果。需要另外评价动作响应和连续预测。
  2. 图0、图1 是同一张正在去噪的图片;图2 属于后面另一张图。文0 不能读取未来图0,否则会破坏文字的因果顺序。
  3. 八个位置分别预测 0.5,每处期望误差为 0.25,总 MSE 为 2/10242/1024;固定一个未来时,一半全对、一半八处错 1,总期望为 4/10244/1024。低 MSE 的平均图却有两个半亮块。
  4. 速度衰减为 ±0.4 米/秒,位置变化为 ±0.08 米,分别是 2.08 米和 1.92 米。
  5. 先用相邻位置差除以 0.2 得当前速度,再代入速度与位置更新。要求 at∈[−1,1]a_t\in[-1,1],否则应使用饱和后的 utu_t。
  6. 不能。开放滚动会把自己的预测送回输入,误差和输入分布都可能改变;单步评价有中间真实观测的帮助。
  7. 实际执行量为 1,下一速度为 0.4 米/秒,位置为 0.08 米;线性模型错误地使用 3,预测为 0.24 米。
  8. 这一行已经直接使用真实环境规则,失败可能来自有限时域、离散动作、代价权重、固定步数与成功门槛。修改它们时要保留原方案对照,不能只改门槛让分数变好。
  9. 共享参数增加了约束,最小误差不可能低于允许每条分支各选参数的结果。差值为零也可能是大家一起拟合得很差,要同时报告误差本身。
  10. 54=6255^4=625 条,是原来 81 条的约 7.72 倍。没有复用前缀时,每次决策要做 625×4=2500625\times4=2500 次模型转移,原来为 324 次。仅提高成功数还不够,需同时报告预测失配和额外计算。

13. 带着“哪一种未来可信”继续读

  1. 文字预测和图像去噪怎样共用主干? Zhou 等,Transfusion: Predict the Next Token and Diffuse Images with One Multi-Modal Model,v1,2024-08-20。读第 3、4.1 节,对照数据表示、注意力范围、损失与实际测试的模态组合。
  2. 能否在学出来的环境里训练控制器? Ha、Schmidhuber,World Models,2018。读作者交互论文的 VizDoom、迁移与模型漏洞部分,分清真实任务、模型环境和最终迁移评价。
  3. 世界模型怎样与策略共同学习? Hafner 等,Mastering diverse control tasks through world models,Nature 640:647–653,2025。读Learning algorithm 中的模型、价值与策略学习,不把跨任务学习当成无需交互的通用控制。
  4. 视频潜表示怎样接到真实动作上? Assran 等,V-JEPA 2: Self-Supervised Video Models Enable Understanding, Prediction and Planning,v1,2025-06-11。重点看第 3 节、4.3 节,核对动作来源、滚动损失、相机与目标条件。
  5. 不同动作的未来是否共享同一个世界? He、Ding、Yang,OneWorld: Learning Consistent Physics Across Actions in World Models,v1,2026-09-25。读第 3、4 节中的共享机制与独立评价。它仍是近期预印本,主要证据来自受控环境。

本页实际完成的是坐标动力学的九次最小二乘拟合、固定案例的闭环控制,以及三个数值算例;没有训练或运行这些论文的大型视频系统。前面第九篇的图文、图像与音视频实验,提供了另一部分可动手的多模态基础。

回到前沿专题库。当模型已经能表示和预测世界,接下来的问题是:它内部到底依据了什么,我们又怎样知道它什么时候不可靠?