0. 简介

EgoWAM 研究的是双臂机器人操作里一个很具体的落地矛盾:Project Aria 眼镜采集的人类第一视角数据便宜且海量,但把人手轨迹重定向成机器人动作再做行为克隆(BC)协同训练,往往让策略变差而不是变好,因为动作通道同时把可迁移的物体、场景、任务语义和不可迁移的人体形态、头部运动、行为习惯打包塞进了同一个解码器。它没有继续在动作空间上做更精细的重定向,而是给策略挂了第二条监督通道——世界模型头,要求策略除了预测动作还要预测场景如何演化,然后在完全固定主干、动作头和数据配比的前提下,只替换世界预测目标,横向对比像素 VAE、DINO 语义特征和相机稳定 3D 运动流三种世界表征。三个真机双臂任务、1800 次真实 rollout 的结果是:DINO 把未见物体与未见场景的泛化最多提到 4 倍,3D 流把域内成绩提升 20–30%,而像素目标几乎不迁移。

1. 动作层协同训练怎么做

近几年"用人类视频教机器人"这条路线已经分出两大派。一派把人手 21 个关键点重定向到机器人末端执行器空间,和机器人遥操作数据混在一起训一个模仿策略,EgoMimic、Humanoid Policy ~ Human Policy、EgoScale 都在这条线上;另一派干脆绕开动作解码器,从人类视频里抽 2D 点轨迹或 3D 运动场,推理时再从运动场反解动作,Motion Tracks、Flow as the Cross-Domain Interface、NovaFlow 属于这一类。EgoWAM 的位置比较特别:它既不在推理时依赖运动场,也不满足于动作层协同训练,而是把场景运动当成训练期的主干塑形信号,推理时整条世界通道直接丢掉。

动作层协同训练的苦涩教训在于,共享动作解码器是人类数据触达策略的唯一通道。这条通道被迫把可迁移的内容(物体、场景、语义)和不可迁移的执行方式(形态、行为风格)纠缠在一组参数里,而后者的具身差异会反过来堵住前者的迁移。论文给的具体例子很直观:人手习惯从侧面横握杯子,平口夹爪只能从正面抓;BC 把这种握法学下来后产生的是机器人根本执行不了的动作。这里的关键是,问题不在于重定向算得不够准,而在于通道本身的容量被两种互斥的信息抢占。

World Action Model(WAM)这个词就是为了标记第二条通道而立的。世界模型本身是老概念,从 Ha 和 Schmidhuber 的 World Models 到 DreamerV3 都在做"给定动作预测环境演化";WAM 的差别是把这个预测器耦合到策略上,让动作预测被任务相关动力学接地。这条通道作用在观测而非动作上,因此天然对形态和行为风格不敏感,这正是 EgoWAM 假设它能承载人类数据的理由。

直觉理解(两条通道的分工):想象教人开车。动作通道相当于要求学员完全复制教练的手法——教练手长、座椅位置不同,照抄反而更糟。世界通道相当于只要求学员预测"这样打方向,车头三秒后会指向哪里"。后者与身体尺寸无关,所以在不同学员之间可以共享。

1.1 三条设计准则

论文没有停在"WAM 比 BC 好"这个层面,而是往前追了一步:既然世界通道有用,那么世界表征本身就是下一个关键设计轴。它提出了三条准则,后面所有对比都围绕这三条展开。这里要厘清的是,这三条准则不是事后为实验结果编的解释,而是在设计对照实验之前就给出的筛选标准,正因如此它们才能承担"预测哪个目标会赢"的功能,而不只是描述"哪个目标赢了"。

第一条是外观抽象(D1)。奖励光度重建的目标会迫使主干去编码具身相关的外观细节——人手的肤色、袖口、房间墙纸——这些细节会挤占真正决定任务结果的结构信息。第二条是跨具身一致性(D2)。目标应该表示物理效果而不是执行者,让人手和夹爪产生相似物理变化时得到相似监督。第三条是自我运动剥离(D3)。图像坐标系下的目标会把头部旋转和场景变化混为一谈,同一个事件在"人戴眼镜边转头边操作"和"机器人头部固定"两种情形下会得到完全不同的监督。

1.2 现有 WAM 恰好三条全违反

绝大多数已有 WAM——World Action Models are Zero-shot Policies、Cosmos Policy、Unified Video Action Model、Unified World Models——都用预训练视频 VAE 预测像素。这个潜空间是为光度重建优化的,把运动和外观纠缠在一起。论文把这一点认定为像素级 WAM 协同训练的主要失效模式,D1、D2、D3 一条都不满足。DINO 特征满足前两条但仍然锚在图像网格上,D3 只部分缓解;3D 运动流通过相机坐标系对齐的几何接地,三条全部满足。

2. 整体框架

EgoWAM 建在 Heterogeneous Pretrained Transformer(HPT)主干上,这个选择不是随手做的。HPT 的归纳偏置是"具身相关的浅层 stem 把各模态 token 化进共享潜空间,然后一个 transformer trunk 处理统一 token 流",这套结构天然可以扩展到 WAM:观测 token、动作 token、未来 token 共存于同一条流、在同一个 trunk 里互相注意,于是未来动力学和动作预测就成了共享潜表征的两个读出头。

在这里插入图片描述

输入侧有三个 stem。共享的 ego 视觉 stem 用 ResNet-18 或预训练 DINO 编码第一视角 RGB,机器人批次额外走一个同构的腕部视觉 stem,本体感知 stem 用每具身独立的 MLP 编码 14 维末端执行器位姿。三个 stem 输出都被投到 256 维,通过 16 个学习到的 query、8 头交叉注意力压成定长 token。这里最关键的不对称是:腕部相机只有机器人有,人类没有,所以腕部 stem 只在机器人批次上前向,而 ego 视觉是唯一真正共享的模态。

这段话里最难在脑子里成像的是"三类 token 共存于同一条流"这件事。读者容易误以为观测、动作、未来是三个独立分支,实际上它们被拼成一个序列送进同一个 transformer,注意力矩阵是全连通的,所以未来 token 学到的东西会通过注意力直接渗到动作 token 上。下面这张图把 token 流的构成、长度和人机两条批次的差异画出来。

在这里插入图片描述

2.1 两个读出头与被固定的变量

trunk 之后挂两个头。动作头是条件流匹配的多块 transformer 解码器,读的是 trunk 输出的 action token;世界模型头是可插拔的,读的是 future token,训练目标是在各自的目标空间里重建未来观测。obs token 的输出作为两个头的共享条件。整套实验的控制变量设计就在这里:trunk、动作头、数据配比、优化器全部锁死,只换世界模型头和它的目标,于是任何 rollout 差异都只能归因于世界表征本身教给 trunk 的东西。

“只换一个变量"这句话听起来简单,落到实验矩阵上却是整篇论文可信度的来源。任何一处没锁死——比如给 DINO 分支多训了几百个 epoch,或者给 3D 流分支调了学习率——结论就会退化成"某组超参碰巧更好”。下面这张图把哪些部件被锁死、哪一个部件被替换、以及五个候选头在这张矩阵上的位置画清楚。

在这里插入图片描述

2.2 数据的三个来源

每个任务混三份数据。机器人遥操作数据 300–360 条;域内人类数据与机器人 1:1,场景和物体完全相同但视角与行为不匹配;EgoVerse 野外人类数据约 10:1,场景、物体、示范者全都发散,且刻意不做任何场景或物体对齐。这个 10:1 的比例是全文最有价值的压力测试——它逼问的是"当人类数据规模上去但对齐度下来时,两条通道谁还能吃得下"。

工程价值(为什么 10:1 才是真问题):1:1 的对齐数据任何方法都能吃,可它需要为每个机器人任务专门去采人类数据,成本没降下来。真正有意义的是 EgoVerse 这种"从世界各地随便采来的第一视角视频",它才是所谓数据飞轮。BC 在这个规模上会过拟合到机器人数据,等于把 20 小时人类视频白喂。

3. 第一条核心机制:把动作通道推到对齐极限

论文做了一件方法论上很讲究的事——先把 BC 基线做强,而不是立一个稻草人。这一步的全部工作是把跨具身动作尽可能对齐,等对齐手段用尽之后剩下的迁移缺口,才是世界通道要解释的东西。核心问题在于,如果 BC 基线没做到位,后面所有"WAM 更好"的结论都可以被质疑成"你只是没把动作对齐做好",整篇文章的论证就失效了。所以这一节看起来是背景铺垫,实际上是全文最关键的方法论承重墙。

统一动作空间取 14 维:每臂 6 自由度 S E ( 3 ) \mathrm{SE}(3) SE(3) 位姿加 1 维夹爪指令。机器人动作 a t : t + k R ∈ R k × d a a^{R}_{t:t+k}\in\mathbb{R}^{k\times d_a} at:t+kR​∈Rk×da​ 由关节角经正运动学算出,再用标定外参重表达到静态 ego 相机系。人手位姿 p t + i H ∈ S E ( 3 ) p^{H}_{t+i}\in\mathrm{SE}(3) pt+iH​∈SE(3) 原生表达在移动的设备系里,论文把它重表达到时刻 t t t 的瞬时设备系:

a t : t + k H = [ ( T t device ) − 1   T t + i device    p t + i H ] i = 1 k a^{H}_{t:t+k}=\Big[\big(T^{\text{device}}_{t}\big)^{-1}\,T^{\text{device}}_{t+i}\;p^{H}_{t+i}\Big]_{i=1}^{k} at:t+kH​=[(Ttdevice​)−1Tt+idevice​pt+iH​]i=1k​

其中 T t device T^{\text{device}}_{t} Ttdevice​ 是 Aria 视觉惯性 SLAM 给出的设备位姿, k k k 是重采样后的 chunk 长度。这个逆变换的作用是把头部自我运动约掉,让同一段物理动作在人和机器人身上产生数值可比的轨迹。直观理解是,人在伸手拿杯子的过程中头会跟着手转,如果不做这一步,同一个伸手动作会因为转头幅度不同而被记成完全不同的数值序列,机器人那边头部相机是固定的,两边的动作标签根本不在一个尺度上。

这个逆变换是第 3 节最容易读错的一步。 ( T t device ) − 1 T t + i device \big(T^{\text{device}}_{t}\big)^{-1}T^{\text{device}}_{t+i} (Ttdevice​)−1Tt+idevice​ 这个连乘不是在算相机怎么动,而是在问"如果把观察者钉在时刻 t t t 的位置不许动,那么时刻 t + i t+i t+i 的手会出现在哪里"。下面这张图用同一段伸手动作在两个坐标系下的轨迹对比来说明。

在这里插入图片描述

3.1 剩下的两处残差

坐标对齐之后还有两个错配。速度上人比遥操作机器人快,论文用具身特定的时间窗口来对齐语义进度: T H = 1   s T_H=1\,\mathrm{s} TH​=1s、 T R = 1.5   s T_R=1.5\,\mathrm{s} TR​=1.5s,两者都离散成 k = 100 k=100 k=100 步,于是两条轨迹覆盖的是"可比的任务进展"而非可比的物理时长。工作空间范围上用分位数归一化,把每个动作维度的 1% 和 99% 分位映到 [ − 1 , 1 ] [-1,1] [−1,1],对手部追踪的离群点更稳。

这两处残差的处理方式很容易被当成普通的数据预处理跳过,实际上它们各自解决的是一个独立的错配维度:时间窗口解决速度差,分位数归一化解决幅度差。下面这张图把两条时间轴怎么被压到同一个 k = 100 k=100 k=100、以及两种归一化在离群点下的差别放在一起对比。

在这里插入图片描述

3.2 BC 协同训练目标

对齐做完之后,共享编码器 f ϕ : O H ∪ O R → Z f_\phi:\mathcal{O}_H\cup\mathcal{O}_R\to\mathcal{Z} fϕ​:OH​∪OR​→Z 和共享动作解码器 π θ ( a ∣ z ) \pi_\theta(a\mid z) πθ​(a∣z) 端到端训练。注意这里编码器的定义域是两个具身观测空间的并集,解码器只有一份,这个"一份"就是前面说的唯一通道——人类数据想影响策略,只能通过挤进这同一组解码器参数来实现:

L BC-cotrain ( ϕ , θ ) = ∑ D ∈ { D H , D R } E ( o , a ) ∼ D    L BC ( π θ ( a ∣ f ϕ ( o ) ) ,   a ) \mathcal{L}_{\text{BC-cotrain}}(\phi,\theta)=\sum_{\mathcal{D}\in\{\mathcal{D}_H,\mathcal{D}_R\}}\mathbb{E}_{(o,a)\sim\mathcal{D}}\;\mathcal{L}_{\text{BC}}\big(\pi_\theta(a\mid f_\phi(o)),\,a\big) LBC-cotrain​(ϕ,θ)=D∈{DH​,DR​}∑​E(o,a)∼D​LBC​(πθ​(a∣fϕ​(o)),a)

落地形式是两个具身各自的条件流匹配损失之和, L BC-cotrain = L CFM robot + L CFM human \mathcal{L}_{\text{BC-cotrain}}=\mathcal{L}^{\text{robot}}_{\text{CFM}}+\mathcal{L}^{\text{human}}_{\text{CFM}} LBC-cotrain​=LCFMrobot​+LCFMhuman​。论文明确说这是它这套系统能支持的最强动作对齐基线,剩下的迁移缺口才是引入世界模型接口的动机。下面是按论文 §3.1 复现的动作对齐代码,两个函数分别对应上面公式里的坐标重表达和分位数归一化:

# 复现自论文 §3.1(非官方仓库代码):人手位姿去自我运动 + 分位数归一化
import numpy as np

def align_human_actions(T_device, hand_poses, t, k, horizon_frames):
    """把移动设备系下的人手位姿重表达到时刻 t 的瞬时设备系。

    T_device:    (N, 4, 4) Aria VIO 头部位姿序列
    hand_poses:  (N, 2, 4, 4) 左右手 SE(3) 位姿
    horizon_frames: 人类 1.0s = 30 帧;机器人 1.5s = 45 帧
    返回 (k, 14):每臂 6-DoF (xyz + ZYX 欧拉) + 1 维夹爪
    """
    T_inv = np.linalg.inv(T_device[t])                      # 约掉头部自我运动
    idx = np.linspace(t, t + horizon_frames, k).astype(int)  # 重采样到统一 chunk 长度
    out = []
    for i in idx:
        rel = T_inv @ T_device[i] @ hand_poses[i]           # (2, 4, 4)
        xyz = rel[:, :3, 3]
        rpy = np.stack([mat_to_euler_zyx(rel[a, :3, :3]) for a in range(2)])
        out.append(np.concatenate([xyz, rpy], axis=-1).reshape(-1))
    return np.stack(out)                                     # (k, 12),再拼夹爪成 14 维

def quantile_normalize(actions, lo_q=1.0, hi_q=99.0):
    """逐维把 1%/99% 分位映到 [-1, 1],对手部追踪离群点鲁棒。"""
    lo = np.percentile(actions, lo_q, axis=0)
    hi = np.percentile(actions, hi_q, axis=0)
    scale = np.maximum(hi - lo, 1e-6)
    return np.clip(2.0 * (actions - lo) / scale - 1.0, -1.0, 1.0)

这段代码里有两个容易被忽略的工程细节。第一是 np.linalg.inv(T_device[t]) 必须用当前时刻而不是序列首帧,否则长片段里累积的头部漂移会全部灌进动作标签。第二是 np.linspace 的重采样让人类 30 帧和机器人 45 帧对齐到同一个 k = 100 k=100 k=100,这一步把速度差异吸收进了索引而不是数值。

难点提示(为什么分位数而不是 min-max):手部追踪偶尔会蹦出一个飞到房间另一头的关键点。min-max 归一化会被这一个离群点把整个量程拉扁,真实动作全挤在 0 附近;取 1%/99% 分位相当于先把两头的极端值截掉再定标尺。

4. 第二条核心机制:世界通道作为迁移接口

动作对齐做到极限之后,EgoWAM 给策略加了一个未来预测头,于是 BC 变成 WAM。共享编码器把当前观测映成潜变量 z t = f ϕ ( o t ) z_t=f_\phi(o_t) zt​=fϕ​(ot​),两个并行头从同一个 z t z_t zt​ 读出各自关心的模态:一个是动作头 π θ ( a ∣ z ) \pi_\theta(a\mid z) πθ​(a∣z) 产出 chunk a t : t + k ∈ R k × d a a_{t:t+k}\in\mathbb{R}^{k\times d_a} at:t+k​∈Rk×da​,一个是世界模型头 g ψ ( s ∣ z ) g_\psi(s\mid z) gψ​(s∣z) 产出视界 T T T 处的未来状态 s t + T s_{t+T} st+T​,两者在 trunk 内部融合:

p θ , ψ ( a t : t + k ,   s t + T   ∣   o t ) = p ψ ( s t + T ∣ z t )    p θ ( a t : t + k ∣ z t ) , z t = f ϕ ( o t ) p_{\theta,\psi}\big(a_{t:t+k},\,s_{t+T}\,\big|\,o_t\big)=p_\psi\big(s_{t+T}\mid z_t\big)\;p_\theta\big(a_{t:t+k}\mid z_t\big),\qquad z_t=f_\phi(o_t) pθ,ψ​(at:t+k​,st+T​ ​ot​)=pψ​(st+T​∣zt​)pθ​(at:t+k​∣zt​),zt​=fϕ​(ot​)

两个头联合训练,总损失是 L WAM = L action ( a t : t + k ) + λ   L world ( s t + T ) \mathcal{L}_{\text{WAM}}=\mathcal{L}_{\text{action}}(a_{t:t+k})+\lambda\,\mathcal{L}_{\text{world}}(s_{t+T}) LWAM​=Laction​(at:t+k​)+λLworld​(st+T​),论文取 λ = 1 \lambda=1 λ=1。这里的关键是两个损失都监督同一个共享 trunk f ϕ f_\phi fϕ​: L action \mathcal{L}_{\text{action}} Laction​ 走的是第 3 节已经榨干的对齐动作, L world \mathcal{L}_{\text{world}} Lworld​ 走的是两个具身都能产生的动力学。于是当人类动作标签迁移不可靠时,未来场景预测仍然能塑造 z t z_t zt​。

4.1 这个重构把问题变成了" s t + T s_{t+T} st+T​ 该是什么"

一旦承认世界通道有效,全部争议就压缩成一个问题:预测目标选什么。世界目标决定了人类和机器人数据是收敛到共享表征,还是被具身特定信号拉开。论文接下来做的就是把三个候选放到同一个 trunk 上做对照。这里值得留意的是,绝大多数已有 WAM 工作直接默认了"预测像素"这个选项,从来没把它当成一个需要论证的设计决策,EgoWAM 的贡献相当程度上就在于把这个默认值重新变成了一个问题。

在这里插入图片描述

上图是 EgoWAM 的模型架构:HPT 主干 + 流匹配动作头 + 三种可插拔世界模型头(VAE / RAE DINO / 3D 流)。三个头共用同一条线性流路径 s τ = ( 1 − τ ) ϵ + τ s s^\tau=(1-\tau)\epsilon+\tau s sτ=(1−τ)ϵ+τs, τ ∈ [ 0 , 1 ] \tau\in[0,1] τ∈[0,1], ϵ ∼ N ( 0 , I ) \epsilon\sim\mathcal{N}(0,I) ϵ∼N(0,I),差别只在目标 s t + T s_{t+T} st+T​ 和消费 trunk 嵌入的头结构。共用同一条流路径这件事本身也是控制变量的一部分——如果三个头连噪声调度都不一样,那么对比结果里就混进了训练动力学的差异。下面是复现的双头联合前向,可以看到两个头怎么从同一次 trunk 前向里分头读出:

# 复现自论文 §3.2 / §4.1(非官方仓库代码):单主干 + 双读出头
import torch, torch.nn as nn

class EgoWAM(nn.Module):
    def __init__(self, stems, trunk, action_head, world_head,
                 n_action_tokens=64, n_future_tokens=16, dim=256):
        super().__init__()
        self.stems = nn.ModuleDict(stems)          # ego / wrist / proprio
        self.trunk = trunk                          # 256-d, 16 blocks, 8 heads
        self.action_head = action_head              # 条件流匹配
        self.world_head = world_head                # 可插拔:VAE / RAE / Flow
        self.action_tokens = nn.Parameter(torch.randn(n_action_tokens, dim) * 0.02)
        self.future_tokens = nn.Parameter(torch.randn(n_future_tokens, dim) * 0.02)

    def forward(self, obs, embodiment, action_gt=None, world_gt=None, query=None):
        # 腕部相机只有机器人有,人类批次不走这个 stem
        keys = ["ego", "proprio"] + (["wrist"] if embodiment == "robot" else [])
        obs_tokens = torch.cat([self.stems[k](obs[k]) for k in keys], dim=1)

        B = obs_tokens.shape[0]
        stream = torch.cat([
            obs_tokens,
            self.action_tokens.expand(B, -1, -1),
            self.future_tokens.expand(B, -1, -1),
        ], dim=1)
        h = self.trunk(stream)                                   # 三类 token 同流互注意

        n_obs, n_act = obs_tokens.shape[1], self.action_tokens.shape[0]
        h_obs, h_act, h_fut = h[:, :n_obs], h[:, n_obs:n_obs + n_act], h[:, n_obs + n_act:]

        l_action = self.action_head.loss(h_act, h_obs, action_gt)
        l_world  = self.world_head.loss(h_fut, h_obs, world_gt, query=query)
        return l_action, l_world                                  # 总损失 = l_action + λ·l_world

代码里三处设计值得单独说。第一,action_tokens 和 future_tokens 都是可学习参数,它们是 query 而不是数据,所以 batch 维靠 expand 广播。第二,两组 token 在同一次 trunk 前向里互相注意,这是"动力学能塑形动作表征"的物理载体——如果分成两次前向,两个头就只共享 stem 而不共享推理过程。第三,h_obs 同时喂给两个头做共享条件,避免动作头看不到世界头依赖的上下文。

一句话理解(双头的分工):动作头回答"我该怎么动",世界头回答"世界会怎么变"。两个答案共用同一份对场景的理解,所以把世界看明白这件事会顺带把动作学好。

4.2 动作头的流匹配细节

动作头是 6 块 CrossTransformer,隐藏宽度 128、4 头。训练时给定干净 chunk a t : t + k a_{t:t+k} at:t+k​ 和噪声 ϵ ∼ N ( 0 , I ) \epsilon\sim\mathcal{N}(0,I) ϵ∼N(0,I),采 τ ∼ B e t a ( 1.5 , 1.0 ) \tau\sim\mathrm{Beta}(1.5,1.0) τ∼Beta(1.5,1.0) 构造 a t : t + k τ = ( 1 − τ ) ϵ + τ   a t : t + k a^\tau_{t:t+k}=(1-\tau)\epsilon+\tau\,a_{t:t+k} at:t+kτ​=(1−τ)ϵ+τat:t+k​ 作为动作 token 初值, τ \tau τ 沿隐藏维嵌入。自注意力和交叉注意力交替去噪,同时注入 trunk 上下文,最后一个线性层投到 14 维。采样用 50 步 v v v-prediction,部署时整条策略跑 30 Hz,与 ARX5 平台的控制率对齐。

B e t a ( 1.5 , 1.0 ) \mathrm{Beta}(1.5,1.0) Beta(1.5,1.0) 这个先验偏向大 τ \tau τ,也就是更多训练在"接近干净动作"的区间。这是流匹配在机器人策略上的常见取法,理由是动作 chunk 的分布远比自然图像集中,噪声很大的区段学不到有用的方向场。换句话说,图像生成需要模型从一片纯噪声里逐步长出结构,动作生成更像是在一个已经很窄的可行轨迹带里做微调,训练预算花在接近解的区间收益更高。

5. 训练期的三个世界目标:从像素到 3D 流

三个目标按满足准则的程度排成一条谱线。像素 VAE 三条全违反,DINO 满足 D1、D2 且部分缓解 D3,3D 流三条全满足。这一节把三者的目标构造、头结构和失效方式讲清楚。这里的关键是三个头的输入条件完全一样,都是 trunk 输出的 future token 加 obs token 的均值池化,所以它们之间的差别纯粹来自"要求主干去预测什么",而不是"给主干看了什么"。

在这里插入图片描述

5.1 像素 VAE:重建基线

目标是未来 ego 帧在预训练 Wan 视频 VAE 潜空间里的编码, s = V A E ( I t + T ego ) s=\mathrm{VAE}(I^{\text{ego}}_{t+T}) s=VAE(It+Tego​),帧缩到 128 × 128 128\times128 128×128,潜张量 16 × 16 × 16 16\times16\times16 16×16×16。头是无文本条件的 DiT,论文给了两个实例:Pixel 从零初始化,6 块、宽 384、6 头,patch stride 2 得 64 token;Pixel-PT 用预训练 Wan 1.3B transformer 初始化,30 层、宽 1536、12 头、FFN 8960。训练都用 v v v-prediction、50 步采样:

L world VAE = E ∥ ϵ − ϵ ψ ( s τ , τ , f ϕ ( o ) ) ∥ 2 \mathcal{L}^{\text{VAE}}_{\text{world}}=\mathbb{E}\big\|\epsilon-\epsilon_\psi\big(s^\tau,\tau,f_\phi(o)\big)\big\|^2 LworldVAE​=E ​ϵ−ϵψ​(sτ,τ,fϕ​(o)) ​2

Pixel-PT 在 bag-grocery 上反而比 Pixel 和 BC 都差,这是全文最有意思的失效案例。论文附录 A.3 追出了原因:预训练视频先验里"袋子通常是开着的",于是 Pixel-PT 渲染出的未来帧在夹爪还没碰到提手之前袋子就已经张开了,自然图像先验压过了实际场景状态,策略于是提前跳到取物阶段。从零训练的 Pixel 画面更模糊,但忠实跟踪袋子的开合状态,策略反而更可靠地完成开袋。

难点提示(先验越强反而越坏事):这个现象和"作文写太顺"一个道理。大视频模型见过几十万个开着的购物袋,它的先验强到会替你把袋子画开。策略把这张幻觉帧当成对未来的预测,就会误以为开袋这一步已经完成。有意思的是,加 EgoVerse 人类协同训练之后 Pixel-PT 反而修好了——数据量把先验压回去了。

5.2 DINO 特征:语义抽象

目标换成未来 ego 帧的 DINOv2-B patch 特征, s = D I N O ( I t + T ego ) s=\mathrm{DINO}(I^{\text{ego}}_{t+T}) s=DINO(It+Tego​),是 16 × 16 16\times16 16×16 的 token 网格,每个 token 768 维。头沿用 Representation Autoencoder(RAE)的 DiT DH \text{DiT}^{\text{DH}} DiTDH 设计:标准 DiT 主干(6 块、384 维、6 头)配一个浅而宽的 DDT 风格头(2 块、2048 维)。这个"宽"不是调参调出来的,而是 RAE 给出的一条硬约束:在语义潜空间里做扩散,去噪器宽度必须不小于 token 维度(这里是 768),否则流匹配损失可证明地不收敛。预处理上丢掉 [CLS] 与 register token,逐 token 做 layer norm。

# 复现自论文 §4.2 / 附录 B.2(非官方仓库代码):RAE DINO 世界头
import torch, torch.nn as nn

class RAEDinoWorldHead(nn.Module):
    """目标 = DINOv2-B patch 特征 (16×16, 768-d)。
    关键约束:wide head 宽度 (2048) 必须 ≥ token 维度 (768),否则 flow matching 不收敛。
    """
    def __init__(self, feat_dim=768, backbone_dim=384, wide_dim=2048,
                 backbone_blocks=6, wide_blocks=2, trunk_dim=256):
        super().__init__()
        self.in_proj  = nn.Linear(feat_dim, backbone_dim)
        self.cond     = nn.Linear(trunk_dim, backbone_dim)     # mean-pool 的 trunk 条件
        self.tau_emb  = nn.Sequential(nn.Linear(1, backbone_dim), nn.SiLU(),
                                      nn.Linear(backbone_dim, backbone_dim))
        self.backbone = nn.ModuleList([DiTBlock(backbone_dim, heads=6)
                                       for _ in range(backbone_blocks)])
        self.wide     = nn.ModuleList([DiTBlock(wide_dim, heads=8)
                                       for _ in range(wide_blocks)])
        self.to_wide  = nn.Linear(backbone_dim, wide_dim)
        self.out_proj = nn.Linear(wide_dim, feat_dim)

    def loss(self, h_fut, h_obs, target, query=None):
        # target: (B, 256, 768),已丢掉 [CLS]/register 并逐 token LayerNorm
        eps = torch.randn_like(target)
        tau = torch.rand(target.shape[0], 1, device=target.device)
        s_tau = (1 - tau)[:, :, None] * eps + tau[:, :, None] * target

        c = self.cond(h_obs.mean(dim=1)) + self.tau_emb(tau)    # 条件与噪声尺度合并
        x = self.in_proj(s_tau)
        for blk in self.backbone:
            x = blk(x, cond=c)
        x = self.to_wide(x)                                      # 384 → 2048,跨过 768 门槛
        for blk in self.wide:
            x = blk(x, cond=self.to_wide(c))
        return ((eps - self.out_proj(x)) ** 2).mean()

to_wide 这一层是整段代码的要害。如果去掉它、直接在 384 维上出 768 维预测,按 RAE 的分析损失会卡住不降。这也解释了为什么不能把像素 DiT 的超参直接搬到语义目标上——两者的潜空间维度差了一个量级:像素潜是 16 × 16 × 16 16\times16\times16 16×16×16,每个位置只有 16 个通道,而 DINO 每个 patch token 有 768 维。这意味着复现这套方法时如果照抄 Pixel 那一行的 6/384/6,DINO 分支会静默地什么都学不到,而损失曲线看上去只是"降得慢",很难归因。

5.3 3D 流:空间抽象

目标是 [ t , t + T ] [t,t+T] [t,t+T] 上的稠密 3D 运动场,表达在时刻 t t t 的相机稳定系里, s = F [ t , t + T ] s=F_{[t,t+T]} s=F[t,t+T]​。第一视角视频上的原始 3D 位移几乎完全被自我运动主导——人一转头,静止物体也会产生巨大表观流。论文的处理是把 Aria VIO 相机位姿喂给前馈稠密 3D 点追踪器 Track4World,让返回的点位置 X t , X t + T X_t,X_{t+T} Xt​,Xt+T​ 共享一个世界系,再把未来位置映回时刻 t t t 的相机系:

X ~ t + T = ( T t cam ) − 1   T t + T cam   X t + T , s = F [ t , t + T ] = X ~ t + T − X t \tilde{X}_{t+T}=\big(T^{\text{cam}}_{t}\big)^{-1}\,T^{\text{cam}}_{t+T}\,X_{t+T}, \qquad s=F_{[t,t+T]}=\tilde{X}_{t+T}-X_{t} X~t+T​=(Ttcam​)−1Tt+Tcam​Xt+T​,s=F[t,t+T]​=X~t+T​−Xt​

稳定之后静态背景的流接近零,被操作物体保留与物理位移成正比的运动,于是这个目标把动力学同时从外观和视角里抽了出来。头是交替自/交叉注意力的流匹配解码器(4 块、宽 256、4 头),把从当前 ego 帧均匀采的 query 点 q q q 作为额外条件,回归这些点上的速度。把 query 点作为条件而不是固定输出布局,是让同一个头能适应不同锚点采样的关键:

L world Flow = E ∥ u ψ ( s τ , τ , f ϕ ( o ) , q ) − ( s q − ϵ q ) ∥ 2 \mathcal{L}^{\text{Flow}}_{\text{world}}=\mathbb{E}\big\|u_\psi\big(s^\tau,\tau,f_\phi(o),q\big)-\big(s_q-\epsilon_q\big)\big\|^2 LworldFlow​=E ​uψ​(sτ,τ,fϕ​(o),q)−(sq​−ϵq​) ​2

工程上有三处降噪处理值得抄:锚点取固定 28 × 40 = 1120 28\times40=1120 28×40=1120 点网格,不做下采样,目标形状 100 × 1120 × 3 100\times1120\times3 100×1120×3;位移低于阈值的锚点直接丢掉,机器人轨迹阈值 2 mm、人类轨迹 10 mm(人类那条带残余头部运动);每段人类片段首尾各 20 帧跳过,因为片段边界处示范者往往还在调整姿势或已经收手,追踪出来的流与任务无关。这三条加起来决定了流目标的信噪比,也是这条路线最吃工程细节的地方。

# 复现自论文 §4.2 / 附录 B.2(非官方仓库代码):3D 流目标构造
import numpy as np

def build_flow_target(X, T_cam, t, horizon, grid=(28, 40),
                      move_thresh_m=0.002, is_human=False):
    """X: (N, H, W, 3) Track4World 世界系点位;T_cam: (N, 4, 4) VIO 相机位姿。
    返回 (1120, 3) 相机稳定后的 3D 位移,以及有效锚点掩码。
    """
    if is_human:
        move_thresh_m = 0.010          # 人类片段带残余头动,阈值放大 5 倍

    # 把未来点位映回时刻 t 的相机系;机器人头部固定时该变换退化为单位矩阵
    T_rel = np.linalg.inv(T_cam[t]) @ T_cam[t + horizon]
    Xf = X[t + horizon].reshape(-1, 3)
    Xf_h = np.concatenate([Xf, np.ones((Xf.shape[0], 1))], axis=-1)
    Xf_stab = (T_rel @ Xf_h.T).T[:, :3]

    X0 = X[t].reshape(-1, 3)
    flow = Xf_stab - X0                                   # 静态背景 → 近零

    hh, ww = grid
    idx = np.linspace(0, X0.shape[0] - 1, hh * ww).astype(int)
    flow, X0 = flow[idx], X0[idx]                          # 固定 1120 锚点,不下采样

    valid = np.linalg.norm(flow, axis=-1) > move_thresh_m  # 丢掉追踪噪声级位移
    return flow, X0, valid                                 # X0 即 query 条件 q

T_rel 那一行是整个 D3 准则的落地形式。机器人头部相机固定, T rel T_{\text{rel}} Trel​ 退化成单位矩阵,代码路径完全一致;人类戴着眼镜边转头边操作,同一段物理位移经过这个变换之后和机器人对齐。这意味着,别的目标要靠采数据时人工对齐视角才能拿到的收益,3D 流是白送的——附录 A.1 的消融正好验证了这点。

工程价值(2 mm 与 10 mm 的差别):这两个阈值不是随手填的。机器人头部固定,追踪残差只有传感器噪声量级,2 mm 足够;人戴眼镜时 VIO 位姿本身有误差,稳定之后静态背景还会剩几毫米的假流,阈值不放大到 10 mm 就会把噪声当成物体运动喂进损失。

6. 推理时把世界头整个扔掉

这是 EgoWAM 在定位上最讨巧的一手。近期工作(Fast-WAM、Unified Video Action Model)已经指出 WAM 的收益主要来自训练期的表征塑形,而不是测试期的想象。EgoWAM 顺着这个结论把世界模型头在部署时冻结并从计算图里摘掉,只展开共享 trunk 和流匹配动作头,从单帧观测解出 a t : t + k a_{t:t+k} at:t+k​。

整条策略跑在一张 RTX 4090 上,30 Hz,与 ARX5 平台控制率一致,延迟和同尺寸 BC 策略完全相同。这一点在方法论上很重要:丢掉世界头之后四个世界表征变体的部署开销一模一样,所以第 9 节里 rollout 的任何差异只能反映"训练期各个目标教给 trunk 了什么",而不是测试期算力差异。

6.1 推理路径的代码形态

# 复现自论文 §4.3 / 附录 B.3(非官方仓库代码):动作-only 推理
import torch

@torch.no_grad()
def infer_action_chunk(model, obs, k=100, steps=50):
    """世界头被摘掉,只跑 trunk + 流匹配动作头。30 Hz on a single RTX 4090。"""
    obs_tokens = torch.cat([model.stems[key](obs[key])
                            for key in ("ego", "proprio", "wrist")], dim=1)
    B = obs_tokens.shape[0]
    stream = torch.cat([obs_tokens,
                        model.action_tokens.expand(B, -1, -1),
                        model.future_tokens.expand(B, -1, -1)], dim=1)
    h = model.trunk(stream)
    n_obs, n_act = obs_tokens.shape[1], model.action_tokens.shape[0]
    h_obs, h_act = h[:, :n_obs], h[:, n_obs:n_obs + n_act]
    # future token 仍然参与 trunk 注意力,但 world_head 不再被调用

    a = torch.randn(B, k, 14, device=h.device)          # 从纯噪声起步
    for i in range(steps):                              # 50 步 Euler,v-prediction
        tau = torch.full((B, 1), i / steps, device=h.device)
        v = model.action_head.velocity(a, tau, h_act, h_obs)
        a = a + v / steps
    return denormalize_quantile(a)                       # 反归一化回物理量纲

有一个细节容易读漏:future_tokens 仍然拼进 token 流参与 trunk 注意力,只是 world_head 不再被调用。这些 token 在训练期承载了未来预测的梯度,它们学到的 query 模式本身就是 trunk 表征的一部分,推理时保留它们与训练分布一致;如果推理时把 future token 也摘掉,token 流长度变了,注意力模式会偏移。

一句话理解(为什么可以扔掉):世界模型在这里的角色是教练而不是导航。训练期它逼着主干去理解场景怎么变,主干一旦学会了,考试时就不需要教练在旁边站着。

…详情请参照古月居

Logo

DAMO开发者矩阵,由阿里巴巴达摩院和中国互联网协会联合发起,致力于探讨最前沿的技术趋势与应用成果,搭建高质量的交流与分享平台,推动技术创新与产业应用链接,围绕“人工智能与新型计算”构建开放共享的开发者生态。

更多推荐