WALL-OSS 代码导读 Notebook

跟着一次前向传播的数据流读 wall-x 源码(commit 97406f2, 仓库 ~/learn/02_wall-x/wall-x)。每个代码 cell 都标注了真实文件与行号, 可以直接跳过去继续下钻。配套的结果展示页在 这里

📥 要真正跑代码? 下载可执行版 .ipynb —— 每个模块配一段纯 PyTorch 的等价复现(CPU 秒级跑出数字和图), 并用 show() 从仓库实时读取完整原始源码。本页是它的只读快照。

🔬 逐行精读版 —— 本页是按数据流走的导读(节选)。要看每一行代码的中文讲解 (做什么 / 为什么 / shape / 坑,4600 行全覆盖无跳行),进 源码逐行精读vla_mixin.py · 987 行 · action_head.py · 808 行 · modeling_qwen2_5_vl_act.py · 2716 行 · train_libero_wrapper.py · 89 行。 每个行号可直链(如 vla_mixin.html#L34)。

模型只有三个文件是主战场:

wall_x/model/
├── qwen2_5_based/
│   └── modeling_qwen2_5_vl_act.py   # 2716 行主模型:MoE 化的 Qwen2.5-VL + 训练/推理入口
├── vla_mixin.py                     # 987 行:路由/MoE block/embedding 注入等积木
└── action_head.py                   # 808 行:ActionProcessor(flow matching 的进出投影)

§1 入口:模型是一个"会出动作的 Qwen2.5-VL"

整个模型类叫 Qwen2_5_VLMoEForAction,加载方式和普通 HF 模型一样。 决定它"长成 VLA"的是 config 里三个开关(抄自权重的 config.json):

In [1]:
用法 + config.json 关键字段 · 逐行精读 →
from wall_x.model.qwen2_5_based.modeling_qwen2_5_vl_act import Qwen2_5_VLMoEForAction

model = Qwen2_5_VLMoEForAction.from_pretrained("x-square-robot/wall-oss-flow")

# config.json 的三个关键开关:
#   "num_experts": 2,          # 每层 2 个 FFN 专家
#   "attention_moe": false,    # 注意力不分家 —— 图文/动作 token 同一个注意力
#   "mlp_moe": true,           # FFN 分家 —— 按 token 类型各走各的
# 以及动作空间定义(20 维跨本体布局):
#   "dof_config": { follow_left_ee_cartesian_pos: 3, follow_left_ee_rotation: 3,
#                   follow_left_gripper: 1, ...right 同构 7 维..., head_actions: 2,
#                   height: 1, car_pose: 3 }   # 共 20

§2 序列怎么拼:图像、状态、动作占位符逐个"缝"进 token 流

前向的第一步是把多模态输入拼成一条序列。文本先正常过词嵌入;然后三次 masked scatter——把图像 patch 特征、本体状态 embedding、带噪动作 embedding 分别替换进序列里对应的占位 token 位置(<|image_pad|>、proprio 占位、 <|action|> × chunk 长度):

In [2]:
modeling_qwen2_5_vl_act.py:1380-1422(节选) · 逐行精读 →
# 图像: ViT 输出替换 <|image_pad|> 占位符
image_embeds = self.visual(pixel_values, grid_thw=image_grid_thw)
inputs_embeds = inputs_embeds.masked_scatter(image_mask, image_embeds)

# 本体状态: 20 维 propri + 20 维 mask -> propri_proj -> 1 个 token
inputs_embeds = self.scatter_proprioception_embeddings(
    input_ids, inputs_embeds, proprioception, dataset_names, agent_pos_mask
)

# 动作: 训练时对 GT action chunk 加噪后投影成 N 个 token(见 §5),
# 同时返回本次的监督目标 flow
inputs_embeds, flow, adarms_cond = self.scatter_flow_action_embeddings(
    input_ids, inputs_embeds, action_chunk, dataset_names, dof_mask
)

§3 路由:不是学出来的,是查表

常规 MoE 的 router 是个可学习的打分网络;这里的 TokenTypeRouter 退化成一行取模——token 进序列时就已经带了类型标记(0=图文,1=动作/状态), 路由只是按类型分拣。没有负载均衡问题、没有路由抖动,因为分工是先验固定的:

In [3]:
vla_mixin.py:34-49 · 逐行精读 →
class TokenTypeRouter(nn.Module):
    def __init__(self, num_experts: int):
        super().__init__()
        self.num_experts = num_experts

    def forward(self, token_types: torch.Tensor) -> torch.Tensor:
        # token_types: (batch, seq) 每个 token 的类型标记
        experts_indices = token_types % self.num_experts
        return experts_indices

§4 MoE block:两个 SwiGLU 专家,一胖一瘦

每层 transformer 的 FFN 被替换成 SparseMoeBlock:先把整个 batch 的 token 按专家序号 permute 重排(同专家的 token 变成连续内存,一次矩阵乘算完), 各专家分头过自己的 SwiGLU,再 unpermute 放回原位。专家 0(图文)中间维 11008, 专家 1(动作)中间维 2048——这就是"0.45B 动作专家"的全部主体:

In [4]:
vla_mixin.py:52-144(节选) · 逐行精读 →
class BlockSparseMLP(nn.Module):        # 单个专家 = 标准 SwiGLU
    def _full_mlp(self, hidden_state):
        gate_out = self.gate_proj(hidden_state)
        up_out = self.up_proj(hidden_state)
        act_out = self.act_fn(gate_out) * up_out    # SiLU(gate) ⊙ up
        return self.down_proj(act_out)

class SparseMoeBlock(nn.Module):
    def forward(self, hidden_states, experts_indices, start_indices, end_indices):
        # 按专家序号重排:同专家 token 连续排布
        permuted_inputs, row_id_map = ops.permute(flat_hidden, experts_indices)
        final_output = torch.zeros_like(permuted_inputs)
        for expert_idx, expert in enumerate(self.experts):
            start, end = start_indices[expert_idx], end_indices[expert_idx]
            if start == end:
                continue
            dim_input = self.dim_inputs[expert_idx]   # 专家各自的输入维度
            expert_input = permuted_inputs[start:end, :dim_input]
            partial_output = expert(expert_input)
            final_output[start:end, :dim_input] = partial_output[:, :dim_input]
        # 放回原顺序
        final_output = ops.unpermute(final_output, row_id_map, probs)
        return final_output.reshape(batch_size, seq_length, hidden_dim)

注意力侧(attention_moe=false)完全不动——36 层里 图文 token 和动作 token 做的是同一场注意力,Q/K/V 权重共享。动作 token 因此能逐层 读到视觉表征,这是"深度融合"的实现位置。

§5 训练:给动作加噪,让模型猜"流"

ActionProcessor.forward 是 flow matching 的训练侧入口。四步: 采样噪声强度 t(Beta 分布偏向高噪端)→ 沿直线插值构造带噪动作 → 记下监督目标 flow = action − noise → 拼上 dof_mask 和时间步编码投影成 token:

In [5]:
action_head.py:616-738(节选) · 逐行精读 →
def sample_time(self, batch_size, device, dtype):
    sample = self.beta_dist.sample([batch_size])   # Beta(1.5, 1) 偏向 1
    time = (1 - sample) * self.s                   # 翻转 × 0.999 -> 偏向 0(高噪端)
    return time

def forward(self, action_chunk, dataset_names, dof_mask=None):
    # 1. 加噪:t=0 纯噪声, t=1 真动作, 直线插值
    noise = torch.randn_like(action_chunk)
    time = self.sample_time(batch_size, device, dtype)
    noisy_action = (1 - time_expanded) * noise + time_expanded * action_chunk
    flow = action_chunk - noise            # 监督目标:插值直线的"速度"

    # 2. 时间步 -> 正弦位置编码
    time_embed = self.time_embed(time)

    # 3. [带噪动作 20 维 | dof_mask 20 维] -> w1 -> 2048 维动作 token
    noisy_action = torch.cat([noisy_action, dof_mask], dim=-1)
    action_embed = self.w1(noisy_action)

    # 4. 与时间步编码融合(w2/w3 小 MLP)
    concat_embed = torch.cat([action_embed, time_embed], dim=-1)
    action_time_embed = self.w3(self.act_fn(self.w2(concat_embed)))
    return action_time_embed, flow, adarms_cond

动作 token 过完 36 层 transformer 后,最后一层隐状态被 action_proj_back 投回 20 维,与 flow 做 MSE。 dof_mask 在这里第二次出场:LIBERO 只占 20 维中的前 7 维, 后 13 个空槽的预测误差不产生任何梯度:

In [6]:
action_head.py:783-807(节选) · 逐行精读 →
def flow_loss(self, action_hidden_states, flow, action_chunk, dof_mask=None, ...):
    action_pred = self.action_proj_back(
        action_hidden_states[:, : self.action_hidden_size]
    )
    v_pred = action_pred
    loss = self.mse_loss(v_pred, flow)     # 逐元素 MSE
    if dof_mask is not None:
        loss = loss * dof_mask             # 空槽维度不计损失

汇合点在主模型 forward 的结尾:语言头照常算 next-token 交叉熵(VQA/CoT 能力靠它), 动作侧算 flow loss,两个 loss 加权相加——这就是"语言和动作在同一个可微框架里"的字面意思:

In [7]:
modeling_qwen2_5_vl_act.py:1427-1464(节选) · 逐行精读 →
outputs = self.model(..., moe_token_types=moe_token_types,
                     adarms_conds=[None, adarms_cond])
hidden_states = outputs[0]
logits = self.lm_head(hidden_states)

loss, cross_entropy_loss, flow_loss, ... = self.compute_loss(
    hidden_states=hidden_states, logits=logits, input_ids=input_ids,
    labels=labels, action_chunk=action_chunk,
    dof_mask=dof_mask, flow=flow, flow_loss_mask=flow_loss_mask,
)   # loss = CE(文本) + flow_loss_weight × MSE(动作流场)

§6 推理:5 步 Euler 去噪 + 图文前缀 KV-cache 只算一次

generate_flow_action 是闭环控制时每 10 步调用一次的函数。 最值得学的是它的性能设计:序列 = [图文前缀 | 动作后缀],去噪迭代只改动作 token, 图文前缀的 KV 第一次前向后就缓存复用——5 步去噪只有第 1 步付了全序列的钱:

In [8]:
modeling_qwen2_5_vl_act.py:1959-2347(大幅节选) · 逐行精读 →
@torch.no_grad()
def generate_flow_action(self, input_ids, action_horizon, action_dim,
                         num_inference_timesteps: int = 10, ...):
    # 从纯高斯噪声出发
    noise = torch.randn(size=(batch_size, action_horizon, action_dim), ...)
    noisy_action = noise.clone()
    times = torch.linspace(0.0, 1.0, num_inference_timesteps + 1)   # 部署配置 = 5 步
    dt = times[1] - times[0]

    # —— 第一次前向:全序列,顺便建 KV cache ——
    prefetch_output = self.model(..., use_cache=True)
    prefix_kv_cache = prefetch_output.past_key_values
    # 只保留图文前缀的 KV(动作 token 每步都变,不能缓存)
    for layer_i in range(len(prefix_kv_cache.key_cache)):
        prefix_kv_cache.key_cache[layer_i] = \
            prefix_kv_cache.key_cache[layer_i][:, :, :prefix_length, :]

    # —— 之后每步:只重算动作后缀,attend 到缓存的前缀 ——
    def step_with_kvcache(timestep, noisy_action):
        action_embed, adarms_cond = self.action_preprocessor.step(
            timestep=timestep, noisy_action=noisy_action, dof_mask=dof_mask)
        temp_inputs_embeds[action_mask] = action_embed
        transformer_outputs = self.model(..., past_key_values=prefix_kv_cache,
                                         inputs_embeds=temp_inputs_embeds)
        action_pred = self.action_preprocessor.action_proj_back(
            transformer_outputs.last_hidden_state[action_mask][:, :2048])
        return action_pred.reshape(batch_size, action_horizon, action_dim)  # 速度场 v_t

    # Euler 积分:噪声沿速度场流成动作
    action_trajectory = odeint(step_with_kvcache, noisy_action,
                               times[1:], method="euler")
    predict_action = action_trajectory[-1]
    predict_action = self.normalizer_action.unnormalize_data(predict_action, ...)

§7 我们的实战补丁:PROPRI_DROPOUT 治"捷径学习"

上面全是官方代码。这一段是本项目自己的关键改动(docker 挂载覆盖,不改仓库): 微调时模型会走捷径——忽略图像、只从本体状态外推动作(官方 issue #58 同款,开环指标完美、 闭环全灭)。解法是在 Dataset 层随机隐藏本体状态,逼模型从图像找答案:

In [9]:
train_libero_wrapper.py:30-46(本项目) · 逐行精读 →
PROPRI_DROPOUT = float(os.environ.get("PROPRI_DROPOUT", "0"))   # 训练用 0.5

def _padded_getitem(self, index):
    r = _orig_getitem(self, index)
    r["action"] = _pad_nan(r["action"], 7)      # 7 维 pad 进 20 维布局前 7 槽
    # 以概率 p 隐藏本体状态(全 NaN -> agent_pos_mask=0),对抗捷径学习:
    # 没有这一条,微调后模型忽略图像只回归 proprio,闭环必败
    if PROPRI_DROPOUT > 0 and torch.rand(1).item() < PROPRI_DROPOUT:
        r["agent_pos"] = torch.full((*r["agent_pos"].shape[:-1], MAX_DOF),
                                    float("nan"))
    else:
        r["agent_pos"] = _pad_nan(r["agent_pos"], 7)
    return r

PreprocessedDataset.__getitem__ = _padded_getitem   # 猴子补丁进数据管线

§8 继续往下读

数字与代码均核对自本地权重 config.json / safetensors header / 仓库源码 (commit 97406f2)。生成于 wall-x LIBERO 微调项目,结果展示页 → ../wallx-libero/