Loading...

文件结构总览

modeling_pi05.py 约 1305 行,分为四大块:

行范围内容核心类/函数
A. 工具函数1–280兼容性、位置编码、注意力掩码、图像缩放、梯度检查点 wrappercreate_sinusoidal_pos_embedding, make_att_2d_masks, resize_with_pad_torch, compute_layer_complete
B. 双塔模型280–460PaliGemma + Gemma Expert 的联合模型,处理三种前向模式PaliGemmaWithExpertModel
C. 核心 PI05 模型460–920训练/推理流程:加噪、去噪、嵌入、采样PI05Pytorch
D. Policy 接口920–1305LeRobot 封装:权重加载、图像预处理、批量推理、训练 lossPI05Policy

推理流程总览

步骤操作描述核心函数输入输出
1. 图像预处理多视角图像 resize+pad → 归一化到 [-1,1],缺失视角用 -1 填充_preprocess_images()batch[obs_images] (B,C,H,W)images: list[Tensor], img_masks: list[Tensor]
2. Prefix 嵌入图像经 SigLIP → Projector;语言 token 经 Embedding → 拼接为 prefix 序列embed_prefix()embed_image() / embed_language_tokens()images, tokens, masksprefix_embs (B,N,D), pad_masks, att_masks
3. Prefix 预填充PaliGemma 对 prefix 序列做双向全注意力前向,缓存所有层的 KV Cachepaligemma_with_expert.forward() (Prefix-Only)prefix_embs, att_2d_masks_4dpast_key_values (DynamicCache)
4. 噪声初始化从 N(0,I) 采样初始噪声动作sample_noise()(B, chunk_size, max_action_dim)noise: Tensor (fp32)
5. 去噪循环N 步 Euler 积分,将噪声逐步去噪为干净动作(循环体内)
5a. 时间步t 从 1.0 线性递减到 0.0(Flow Matching 时间方向)step ∈ [0,N)time ∈ [1,0]
5b. Suffix 嵌入时间步经正弦编码 → 2层 MLP → AdaRMS 条件;动作经 action_in_proj 投影embed_suffix()x_t, timestepsuffix_embs, adarms_cond
5c. 单步去噪Suffix attend to Prefix KV Cache(只读),经 Gemma Expert 各层 → 预测速度场denoise_step()x_t, timestep, past_key_valuesv_t (B, chunk, act_dim)
5d. Euler 步进x_{t+Δt} = x_t + Δt · v_tx_t, v_t, dt=-1/Nx_t (更新后)
6. 动作输出截断 max_action_dim → 真实动作维度x_t (B, chunk, max_dim)actions (B, chunk, real_dim)

推理数据流图

点击左侧流程图节点即可跳转到对应函数详解。

flowchart TD
    A["观测图像<br/><i>[list[Tensor(B,C,H,W)]]</i>"] -->|"_preprocess_images()"| B["预处理图像<br/><i>[list[Tensor(B,C,H,W)]]</i>"]
    C["语言指令<br/><i>[Tensor(B,seq)]</i>"] -->|"embed_language_tokens()"| D["语言嵌入<br/><i>[Tensor(B,seq,D)]</i>"]
    B -->|"embed_image()<br/>SigLIP+Projector"| E["图像嵌入<br/><i>[Tensor(B,N_img,D)]</i>"]

    D --> F["Prefix 预填充<br/><i>PaliGemma (Prefix-Only)</i>"]
    E --> F

    F -->|"past_key_values"| G["KV Cache<br/><i>DynamicCache</i>"]

    H["随机噪声<br/><i>N(0,I)</i>"] -->|"sample_noise()"| I["x_t<br/><i>[Tensor(B,chunk,max_dim)] fp32</i>"]

    J["timestep<br/><i>float ∈ [1,0]</i>"] -->|"embed_suffix()<br/>sin-cos + MLP"| K["adarms_cond<br/><i>[Tensor(B,D)]</i>"]
    I -->|"action_in_proj"| K

    K --> L["denoise_step()<br/><i>Gemma Expert + Prefix KV Cache</i>"]
    G --> L
    J --> L

    L -->|"v_t [B,chunk,max_dim]"| M["Euler 步进<br/><i>x_t += dt · v_t</i>"]
    M -->|"× N 循环"| I

    M -->|"final x_t (≈ clean action)"| N["解填充<br/><i>截断到 real_dim</i>"]
    N --> O["输出动作<br/><i>[Tensor(B,chunk,real_act_dim)]</i>"]
编号节点负责函数点击跳转
①②观测图像 → 预处理图像_preprocess_images() + resize_with_pad_torch()点击左侧 A/B 节点
③④⑤图像+语言 → Prefix 嵌入embed_prefix()embed_image() / embed_language_tokens()点击左侧 B/C/D/E 节点
Prefix 预填充 → KV CachePaliGemmaWithExpertModel.forward() (Prefix-Only)点击左侧 F/G 节点
随机噪声 → x_tsample_noise()点击左侧 H/I 节点
⑧⑨timestep + x_t → suffix 嵌入 + AdaRMSembed_suffix()create_sinusoidal_pos_embedding()time_mlp点击左侧 J/K 节点
单步去噪:Suffix attend to Prefix KVdenoise_step()clone_past_key_values()点击左侧 L 节点
Euler 步进循环体内 x_t = x_t + dt * v_t点击左侧 M 节点
⑫⑬解填充 → 最终动作截断 [:, :, :real_dim]点击左侧 N/O 节点

训练流程总览

步骤操作描述核心函数输入输出
1. 加噪x_t = t·ε + (1-t)·a(直线插值)PI05Pytorch.forward()actions, noise, timex_t
2. 目标速度场u_t = ε − a(噪声减干净动作)noise, actionsu_t
3. Prefix 嵌入图像 SigLIP + 语言 Embedding → 拼接embed_prefix()images, tokens, masksprefix_embs, pad_masks, att_masks
4. Suffix 嵌入时间正弦编码+2层MLP → AdaRMS 条件;动作线性投影 → suffix tokenembed_suffix()x_t, timesuffix_embs, adarms_cond
5. 联合前向Prefix + Suffix 逐层联合 attention (compute_layer_complete × 18)PaliGemmaWithExpertModel.forward() (联合模式)prefix_embs, suffix_embs, adarms_condsuffix_out
6. 速度场预测suffix_out 截断 + action_out_proj → v_taction_out_proj()suffix_out[:, -chunk:]v_t
7. MSE LossL = ‖u_t − v_t‖²F.mse_loss(reduction="none")u_t, v_tloss (B, chunk, act_dim)

训练数据流图

flowchart TD
    A["干净动作 a<br/><i>[B,chunk,act_dim]</i>"] -->|"x_t = t·ε + (1-t)·a"| B["加噪动作 x_t<br/><i>[B,chunk,act_dim]</i>"]
    C["噪声 ε<br/><i>N(0,I)</i>"] --> B
    D["时间 t<br/><i>Beta采样</i>"] --> B

    A -->|"u_t = ε - a"| E["目标速度场 u_t<br/><i>[B,chunk,act_dim]</i>"]
    C --> E

    F["Prefix 嵌入<br/><i>图像+语言</i>"] --> G["联合 Attention<br/><i>compute_layer_complete × 18层</i>"]
    B -->|"embed_suffix()"| H["Suffix 嵌入<br/><i>动作+时间</i>"]
    H --> G
    D -->|"adarms_cond"| G

    G -->|"suffix_out"| I["action_out_proj"]
    I -->|"v_t"| J["MSE Loss<br/><i>L = ‖u_t - v_t‖²</i>"]
    E --> J
编号节点负责函数点击跳转
干净动作+噪声+时间 → x_t + u_tPI05Pytorch.forward() 前半段点击左侧 A/B/C/D/E 节点
图像+语言 → Prefix 嵌入embed_prefix()点击左侧 F 节点
x_t+时间 → Suffix 嵌入embed_suffix()点击左侧 H 节点
Prefix+Suffix 联合 attentioncompute_layer_complete() + PaliGemmaWithExpertModel.forward()点击左侧 G 节点
suffix_out → v_taction_out_proj点击左侧 I 节点
u_t vs v_t 比较F.mse_loss点击左侧 J 节点

A. 工具函数

A1. ActionSelectKwargs — 类型标注

1
2
3
4
class ActionSelectKwargs(TypedDict, total=False):
    inference_delay: int | None
    prev_chunk_left_over: Tensor | None
    execution_horizon: int | None

RTC(Real-Time Chunking)推理时传入的额外参数。total=False 表示所有字段可选:

  • inference_delay:推理延时步数,用于控制动作执行的时间窗口;
  • prev_chunk_left_over:前一个 chunk 的剩余动作(跨 chunk 拼接);
  • execution_horizon:执行时域长度,限制预测动作被实际执行的步数。

这些参数仅在 RTC 模式启用时被消费,普通推理会直接忽略。RCT 模式下,模型每次只预测一个 chunk 的动作序列,剩余动作会被缓存到 prev_chunk_left_over,在下一次推理时作为 prefix 继续使用。


A2. get_safe_dtype() — 跨平台 dtype 兼容

1
2
3
4
5
6
7
8
9
def get_safe_dtype(target_dtype, device_type):
    if device_type == "mps" and target_dtype == torch.float64:
        return torch.float32
    if device_type == "cpu":
        if target_dtype == torch.bfloat16:
            return torch.float32
        if target_dtype == torch.float64:
            return torch.float64
    return target_dtype

确保 dtype 在特定设备上可用:

  • MPS(Apple Silicon):不支持 float64(双精度),自动降级为 float32;
  • CPU:不支持 bfloat16(PyTorch CPU 没有 bf16 内核),降级为 float32;float64 则保留原样(CPU 双精度性能可接受);
  • 其余情况直接返回目标 dtype。

这是一个防御性适配,主要服务于 create_sinusoidal_pos_embedding 的中间计算(需要 float64 精度做 torch.linspace)。


A3. create_sinusoidal_pos_embedding() — 正弦-余弦时间编码(~L65)

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
def create_sinusoidal_pos_embedding(
    time: torch.Tensor, dimension: int,
    min_period: float, max_period: float, device="cpu"
) -> Tensor:
    if dimension % 2 != 0:
        raise ValueError(f"dimension ({dimension}) must be divisible by 2")
    if time.ndim != 1:
        raise ValueError("The time tensor is expected to be of shape `(batch_size, )`.")

    dtype = get_safe_dtype(torch.float64, device.type)
    fraction = torch.linspace(0.0, 1.0, dimension // 2, dtype=dtype, device=device)
    period = min_period * (max_period / min_period) ** fraction

    scaling_factor = 1.0 / period * 2 * math.pi
    sin_input = scaling_factor[None, :] * time[:, None]
    return torch.cat([torch.sin(sin_input), torch.cos(sin_input)], dim=1)

这是时间步到 Transformer 维度 embedding 的核心编码函数。逐行分析:

  1. 输入校验time shape 必须是 (B,) 一维,dimension 必须是偶数(sin/cos 成对);

  2. 频率生成(对数均匀分布):

    1
    2
    
    fraction = torch.linspace(0.0, 1.0, dimension // 2)       # [0, 1/(D/2-1), 2/(D/2-1), ..., 1]
    period = min_period * (max_period / min_period) ** fraction # 从 min_period 到 max_period
    

    fraction 在 [0,1] 之间均匀采样,period 在对数空间中从 min_period 指数增长到 max_period。例如 min_period=2, max_period=10000 时,period 序列为 [2, 2.3, 2.7, ..., 10000]——低频到高频全覆盖。

  3. 外积计算

    1
    2
    
    scaling_factor = 1.0 / period * 2 * math.pi    # [D/2] 频率向量 ω_j
    sin_input = scaling_factor[None, :] * time[:, None]  # [B, 1] × [1, D/2] → [B, D/2]
    

    对 batch 中每个标量时间 $t_i$,乘以每个频率 $\omega_j$,得到相位矩阵 $\phi_{ij} = t_i \cdot \omega_j$。

  4. 拼接 sin/cos[sin(ϕ), cos(ϕ)] → shape [B, D]。最终每个时间步 $t$ 被编码为一个 D 维向量:

    $$e(t) = [\sin(\omega_1 t), \cos(\omega_1 t), \sin(\omega_2 t), \cos(\omega_2 t), \dots]$$

为什么用对数均匀频率? 与 Transformer 的 RoPE 原理类似——低频编码长期依赖(大 period),高频编码短期精细变化,对数尺度保证频域覆盖均匀。


A4. sample_beta() — Beta 分布时间采样(~L80)

1
2
3
4
5
def sample_beta(alpha, beta, bsize, device):
    alpha_t = torch.tensor(alpha, dtype=torch.float32)
    beta_t = torch.tensor(beta, dtype=torch.float32)
    dist = torch.distributions.Beta(alpha_t, beta_t)
    return dist.sample((bsize,)).to(device)

标准 Beta 分布采样。时序说明:

  • MPS fallback:Beta 分布的 _sample_dirichlet 在 Apple Silicon 上未实现,源码注释建议 CPU 采样后搬回。此处通过 .to(device) 隐式处理;
  • 为何用 Beta 而非均匀分布? Beta(α, β) 可以控制采样密度偏向 [0,1] 的哪一端。例如 α < β 时密度偏向左侧(低时间步/低噪声阶段),让模型更多训练在"精细去噪"阶段。PI05 配置中 time_sampling_beta_alphatime_sampling_beta_beta 正是控制这一偏置。

A5. make_att_2d_masks() — 构造二维注意力掩码(~L90)

1
2
3
4
5
def make_att_2d_masks(pad_masks, att_masks):
    cumsum = torch.cumsum(att_masks, dim=1)
    att_2d_masks = cumsum[:, None, :] <= cumsum[:, :, None]
    pad_2d_masks = pad_masks[:, None, :] * pad_masks[:, :, None]
    return att_2d_masks & pad_2d_masks

这是灵活构造多种注意力模式(因果 / prefix-lm / block-causal)的核心函数。

核心算法:每个 token 有一个 mask_ar 值(来自 att_masks),规则是——token i 可以 attend to token j 当且仅当 cumsum_att_masks[j] ≤ cumsum_att_masks[i] 且两者均非 padding。

具体例子(来自 big_vision 注释):

att_masks含义
[1,1,1,1,1,1]纯因果注意力:cumsum=[1,2,3,4,5,6],对角及以下全可见
[0,0,0,1,1,1]Prefix-LM:前 3 个 token 互相可见(cumsum 均为 0,0≤0 为 True);后 3 个因果 + 可见前缀
[1,0,1,0,1,0,0,1,0,0]Block-Causal:每 2 个 token 组成一个 block,block 内全连接,block 间因果

实现细节

1
2
3
4
cumsum = torch.cumsum(att_masks, dim=1)                   # [B, N] 累积和
att_2d_masks = cumsum[:, None, :] <= cumsum[:, :, None]   # [B, N, N] 广播比较
pad_2d_masks = pad_masks[:, None, :] * pad_masks[:, :, None]  # padding token 完全不可见
return att_2d_masks & pad_2d_masks                         # 交集

广播 [B, 1, N] <= [B, N, 1] 得到 [B, N, N] 的 bool 矩阵。

在 PI05 中的使用场景

  • Prefixatt_masks 全 0 → cumsum 全 0 → 矩阵全 True → Prefix 内部全注意力;
  • Suffix(推理时):第一个 token att_mask=1,其余 att_mask=0 → 第一个 token 可见 Prefix + 自己,后续 token 因果链。

A6. clone_past_key_values() — KV Cache 深拷贝(~L115)

1
2
3
4
5
6
7
def clone_past_key_values(past_key_values):
    return DynamicCache(
        tuple(
            (keys.clone(), values.clone(), sliding_window)
            for keys, values, sliding_window in past_key_values
        )
    )

去噪循环中每一次 denoise_step 需要独立的 KV Cache 副本。原因:

  • past_key_values 存储 Prefix 预填充后的所有层 K/V;
  • denoise_step 中,Suffix 序列的 attention 会追加新的 K/V 到缓存(修改 past_key_values 内部状态);
  • 如果下一轮去噪复用同一个缓存对象,会将前一轮的 Suffix K/V 也一并 attend 到,造成信息泄露和序列长度递增。

因此每轮深拷贝一份干净的 Prefix KV Cache,确保 Suffix 每步都只能看到 Prefix + 自己。sliding_window 是 Gemma 2B 滑动窗口注意力的配置,直接透传。


A7. pad_vector() — 动作维度填充(~L125)

1
2
3
4
def pad_vector(vector, new_dim):
    if vector.shape[-1] >= new_dim:
        return vector
    return F.pad(vector, (0, new_dim - vector.shape[-1]))

简单地对最后一维右侧补零。用于将不同机器人(不同动作维度)统一 pad 到 max_action_dim。设计选择 F.pad 而非 nn.Linear 升维是因为补零不引入学习参数,且 action_out_proj 的最终线性层已经处理了维度映射。


A8. resize_with_pad_torch() — 无失真图像缩放(~L130)

1
def resize_with_pad_torch(images, height, width, mode="bilinear"):

这个函数以保持宽高比的方式缩放图像,不足部分用 0(黑色)填充。

逐行逻辑

  1. 通道格式检测

    1
    2
    3
    
    if images.shape[-1] <= 4:  # 最后一维 ≤ 4 → channels-last [H,W,C]
        channels_last = True
        images = images.permute(0, 3, 1, 2)  # → [B, C, H, W]
    

    启发式判据:RGB/RGBA 图像的通道数 ≤ 4,而 channels-first 格式的最后一维(Width)几乎肯定 > 4。

  2. 等比缩放

    1
    2
    3
    
    ratio = max(cur_width / width, cur_height / height)  # 取宽高中较大的缩放比
    resized_height = int(cur_height / ratio)
    resized_width = int(cur_width / ratio)
    

    max 确保缩放后没有任何一边超过目标尺寸(短边会被 pad,长边刚好匹配)。

  3. dtype 特定裁剪

    1
    2
    
    if images.dtype == torch.uint8:    resized_images = torch.round(resized_images).clamp(0, 255).to(torch.uint8)
    elif images.dtype == torch.float32: resized_images = resized_images.clamp(0.0, 1.0)
    

    uint8 图像需要 round() 取整,float32 图像直接裁剪。注意此时图像仍在 [0,1](或 [0,255])范围内,尚未归一化到 [-1,1](那个步骤在 _preprocess_images 中完成)。

  4. 居中填充

    1
    2
    3
    
    pad_h0, remainder_h = divmod(height - resized_height, 2)
    pad_h1 = pad_h0 + remainder_h               # 奇数差时右边/下边多 1px
    padded_images = F.pad(resized_images, (pad_w0, pad_w1, pad_h0, pad_h1), mode="constant", value=0)
    

    divmod 保证居中(上左优先,多余像素给下右)。

  5. 恢复通道格式:若输入是 channels-last,permute 回去。


A9. compute_layer_complete() — 联合层计算(核心)(~L195)

1
def compute_layer_complete(inputs_embeds, attention_mask, position_ids, adarms_cond, layers, rotary_emb):

这是 PI05 最核心的计算单元——Prefix 和 Suffix 对应的 Transformer 层在同一个 attention 矩阵中联合计算。

函数签名说明

  • inputs_embeds[prefix_embs, suffix_embs],两者的 hidden states(在不同层之间传递时会更新);
  • layers(paligemma_layer_i, gemma_expert_layer_i),两者的第 i 个 DecoderLayer;
  • adarms_cond[None, time_emb],Prefix 的 AdaRMS 条件为 None,Suffix 的 AdaRMS 条件为时间嵌入;
  • rotary_emb:共享的 RoPE 模块(取 PaliGemma 的 rotary_emb)。

阶段 1:并行 QKV 投影 + AdaRMS(~L200-215)

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
query_states = []; key_states = []; value_states = []; gates = []
for i, hidden_states in enumerate(inputs_embeds):
    layer = layers[i]
    hidden_states, gate = layernorm_forward(layer.input_layernorm, hidden_states, adarms_cond[i])
    gates.append(gate)
    input_shape = hidden_states.shape[:-1]
    hidden_shape = (*input_shape, -1, layer.self_attn.head_dim)
    query_state = layer.self_attn.q_proj(hidden_states).view(hidden_shape).transpose(1, 2)
    key_state   = layer.self_attn.k_proj(hidden_states).view(hidden_shape).transpose(1, 2)
    value_state = layer.self_attn.v_proj(hidden_states).view(hidden_shape).transpose(1, 2)
    query_states.append(query_state); key_states.append(key_state); value_states.append(value_state)

逐行分析:

  • layernorm_forward(layer.input_layernorm, hidden_states, adarms_cond[i])

    • 对 Prefix 层(adarms_cond=None):等价于标准 RMSNorm → hidden_states * weight
    • 对 Suffix 层(adarms_cond=time_emb):AdaRMS——权重和偏置由时间嵌入动态调制: $$\text{AdaRMS}(h, t) = \gamma(t) \cdot \frac{h}{\text{RMS}(h)} + \beta(t)$$ 其中 $\gamma(t) = W_\gamma \cdot \text{SiLU}(t) + b_\gamma$(从 adarms_cond 线性投影得到)。gate 返回值用于后续 gated residual。
  • view(hidden_shape).transpose(1, 2):将 [B, seq, num_heads * head_dim] 重整为 [B, num_heads, seq, head_dim]。注意这里 head_dim 来自各自 layer 的配置。

  • 并行处理:Prefix 和 Suffix 的 QKV 在各自的线性层执行——参数独立,仅结构对称。


阶段 2:拼接 + 联合 RoPE(~L218-232)

1
2
3
query_states = torch.cat(query_states, dim=2)   # cat on seq dim → [B, num_heads, prefix_len + suffix_len, head_dim]
key_states   = torch.cat(key_states,   dim=2)
value_states = torch.cat(value_states, dim=2)

Prefix 和 Suffix 的 QKV 在序列维度拼接,构成一个统一的 [B, H, total_len, D] 张量。

1
2
3
dummy_tensor = torch.zeros(query_states.shape[0], query_states.shape[2], query_states.shape[-1], ...)
cos, sin = rotary_emb(dummy_tensor, position_ids)
query_states, key_states = modeling_gemma.apply_rotary_pos_emb(query_states, key_states, cos, sin, unsqueeze_dim=1)
  • dummy_tensor 的 shape [B, total_seq_len, head_dim],仅用于触发 rotary_emb 的 shape 推断(Gemma 的 rotary_emb 不关心 content,只取 dim);
  • position_ids 是 prefix 和 suffix 拼接后的全局位置 id(后续在 PI05Pytorch.forward 中由 torch.cumsum(pad_masks, dim=1) - 1 生成);
  • 联合 RoPE 确保 Prefix 和 Suffix 在同一个位置编码空间中——Prefix 的图像 token 占位置 0255,语言 token 续接位置 256511,Suffix 续接位置 512+。

阶段 3:联合 Attention(~L235-242)

1
2
3
4
5
6
7
8
batch_size = query_states.shape[0]
paligemma_layer = layers[0]
scaling = paligemma_layer.self_attn.scaling
att_output, _ = modeling_gemma.eager_attention_forward(
    paligemma_layer.self_attn, query_states, key_states, value_states, attention_mask, scaling,
)
head_dim = paligemma_layer.self_attn.head_dim
att_output = att_output.reshape(batch_size, -1, 1 * 8 * head_dim)
  • eager_attention_forward 执行 $\text{Softmax}\left(\frac{QK^T}{\sqrt{d}} + \text{mask}\right)V$;
  • attention_mask 的 shape 是 [B, 1, total_len, total_len]_prepare_attention_masks_4d 已将 2D mask 升维),控制 Prefix 不可 attend to Suffix;
  • 注意 scaling 取自 PaliGemma 层——两塔共享同一个 attention scale(实际都是 $1/\sqrt{\text{head\\_dim}}$,但明确从 PaliGemma 取是防御性写法);
  • reshape(batch_size, -1, 1 * 8 * head_dim)1*8*head_dimnum_heads * head_dim 的硬编码,此处假设 PaliGemma 和 Expert 都是 8 头。这是一个已知的局限性——如果使用非 8 头配置会出错。

阶段 4:分别 O 投影 + MLP + Gated Residual(~L244-262)

1
2
3
4
5
6
7
8
outputs_embeds = []
start_pos = 0
for i, hidden_states in enumerate(inputs_embeds):
    layer = layers[i]
    end_pos = start_pos + hidden_states.shape[1]
    if att_output.dtype != layer.self_attn.o_proj.weight.dtype:
        att_output = att_output.to(layer.self_attn.o_proj.weight.dtype)
    out_emb = layer.self_attn.o_proj(att_output[:, start_pos:end_pos])

Attention 输出在序列维度拆分回 Prefix 和 Suffix 部分,各自通过独立的 O 投影。注意精度的显式转换:如果 attention 输出是 fp32 但 O 投影权重是 bf16,先转换再乘法。

1
2
3
    out_emb = _gated_residual(hidden_states, out_emb, gates[i])  # 第一个残差
    after_first_residual = out_emb.clone()
    out_emb, gate = layernorm_forward(layer.post_attention_layernorm, out_emb, adarms_cond[i])

Gated Residual(门控残差)替代了标准 x = x + sublayer(x)

$$\text{output} = (1 - g) \cdot x + g \cdot \text{sublayer}(x)$$

其中 $g$ 由 AdaRMS 的 layernorm_forward 返回(是 sigmoid 激活后的门控值)。gate 控制残差连接的力度——$g=1$ 时完全使用 sublayer 输出,$g=0$ 时保持输入不变。这是 Pi0/PI05 对标准 Transformer 的关键改动,允许模型学习"跳过"某些层。

1
2
3
4
5
6
    if layer.mlp.up_proj.weight.dtype == torch.bfloat16:
        out_emb = out_emb.to(dtype=torch.bfloat16)
    out_emb = layer.mlp(out_emb)
    out_emb = _gated_residual(after_first_residual, out_emb, gate)  # 第二个残差
    outputs_embeds.append(out_emb)
    start_pos = end_pos

MLP 结构:gate_proj(x) * up_proj(x) → down_proj(Gemma 的 GeGLU 变体,激活函数 gelu_pytorch_tanh)。

两个残差连接分别围绕 Attention block 和 MLP block,且各自有独立的 gate(分别来自 input_layernormpost_attention_layernorm 的 AdaRMS 输出)。

返回 outputs_embeds(两塔更新后的 hidden states),作为下一层的 inputs_embeds 输入。


阶段 5:梯度检查点包装

PaliGemmaWithExpertModel.forward() 中,compute_layer_completetorch.utils.checkpoint.checkpoint() 包裹:

1
2
3
4
5
6
if use_gradient_checkpointing:
    inputs_embeds = torch.utils.checkpoint.checkpoint(
        compute_layer_complete, inputs_embeds, attention_mask,
        position_ids, adarms_cond, use_reentrant=False,
        preserve_rng_state=False, layers=layers, rotary_emb=rotary_emb,
    )

use_reentrant=False 使用 PyTorch 新版非重入检查点(更安全),preserve_rng_state=False 跳过 RNG 保存以减少开销(对推理过程无影响,训练中可能略微影响 dropout 但通常可接受)。这允许在 18 层 × 2 塔的配置下大幅节省显存。



B. 核心模型类详解

B1. GemmaConfig — 纯数据类(~L268)

1
2
3
4
class GemmaConfig:
    def __init__(self, width, depth, mlp_dim, num_heads, num_kv_heads, head_dim):
        self.width = width; self.depth = depth; self.mlp_dim = mlp_dim
        self.num_heads = num_heads; self.num_kv_heads = num_kv_heads; self.head_dim = head_dim

与 HuggingFace 的 GemmaConfig 不同,这是 PI05 自用的简化配置类。两种预定义变体:

参数gemma_300mgemma_2b
width10242048
depth1818
mlp_dim409616384
num_heads88
num_kv_heads11
head_dim256256

关键点:两塔都是 GQA(Grouped-Query Attention)num_kv_heads=1 意味着每个 attention 层只有 1 个 KV 头、8 个 Q 头——极大降低了 KV Cache 显存(推理时 Prefix KV Cache 约占总显存的 30-40%,GQA 将其压缩 8 倍)。


B2. PaliGemmaWithExpertModel.__init__() — 双塔初始化(~L280)

1
2
3
4
class PaliGemmaWithExpertModel(nn.Module):
    def __init__(self, vlm_config, action_expert_config, use_adarms=None,
                 precision="bfloat16", image_size=DEFAULT_IMAGE_SIZE,
                 freeze_vision_encoder=False, train_expert_only=False):

参数说明

参数默认含义
vlm_configPaliGemma 的 GemmaConfig(Gemma 的语言部分)
action_expert_configExpert 的 GemmaConfig
use_adarms[False, True][Prefix是否用AdaRMS, Suffix是否用AdaRMS]
precision"bfloat16"整体精度,视觉路径强制 fp32
freeze_vision_encoderFalse冻结 SigLIP 视觉编码器
train_expert_onlyFalse仅训练 Expert 塔(冻结整个 PaliGemma)

HuggingFace Config 构造

1
2
3
4
5
6
7
8
vlm_config_hf = CONFIG_MAPPING["paligemma"]()
vlm_config_hf._vocab_size = 257152              # Gemma tokenizer vocab size
vlm_config_hf.image_token_index = 257152        # <image> 特殊 token
vlm_config_hf.text_config.hidden_size = vlm_config.width
vlm_config_hf.text_config.intermediate_size = vlm_config.mlp_dim
# ...
vlm_config_hf.text_config.use_adarms = use_adarms[0]
vlm_config_hf.text_config.adarms_cond_dim = vlm_config.width if use_adarms[0] else None

GemmaConfig 的字段一一映射到 HF 的 PaliGemmaConfigadarms_cond_dim = width 表明 AdaRMS 的条件向量与 hidden_size 同维度(来自 MLP 后的时间嵌入)。

1
2
3
action_expert_config_hf = CONFIG_MAPPING["gemma"](...)
action_expert_config_hf.use_adarms = use_adarms[1]
action_expert_config_hf.adarms_cond_dim = action_expert_config.width if use_adarms[1] else None

Expert 塔也构造了 HF 兼容的 config,但 Suffix 塔不使用 PaliGemma 而直接使用纯 Gemma(因为不需要视觉编码器)。

1
2
3
self.paligemma = PaliGemmaForConditionalGenerationWithPiGemma(config=vlm_config_hf)
self.gemma_expert = PiGemmaForCausalLM(config=action_expert_config_hf)
self.gemma_expert.model.embed_tokens = None   # Expert 不需要 embedding 层

Expert 的 embed_tokens 置为 None——因为 Suffix 嵌入由 action_in_proj 生成(动作空间投影),不需要查字典。PiGemmaForCausalLM 的前向在 embed_tokens=None 时应直接接受 inputs_embeds


B3. to_bfloat16_for_selected_params() — 混合精度策略

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
def to_bfloat16_for_selected_params(self, precision):
    if precision == "bfloat16":
        self.to(dtype=torch.bfloat16)
    elif precision == "float32":
        self.to(dtype=torch.float32); return

    params_to_keep_float32 = [
        "vision_tower", "multi_modal_projector",
        "input_layernorm", "post_attention_layernorm", "model.norm",
    ]
    for name, param in self.named_parameters():
        if any(selector in name for selector in params_to_keep_float32):
            param.data = param.data.to(dtype=torch.float32)

设计逻辑:

  1. 先全局设为 bf16(节省 50% 显存/带宽);
  2. 再将视觉路径和所有归一化层恢复为 fp32。

为什么视觉路径必须 fp32?

  • SigLIP 的 patch embedding 和 projector 涉及 trunc_normal 初始化的小数值,bf16 的动态范围不足(最小正数 ~9.2e-41 vs fp32 的 ~1.4e-45)会导致梯度消失;
  • 注释中明确写道 “never toggle”——训练时如果视觉路径在 fp32 和 bf16 之间切换,PyTorch 优化器会报 “same dtype” 错误。

为什么归一化层(RMSNorm)也保持 fp32?

  • RMSNorm 计算 $\frac{x}{\sqrt{\frac{1}{d}\sum x_i^2}}$,其中分母 RMS 值可能极小(尤其在深层的残差流中),bf16 精度不足会导致 NaN。

B4. _set_requires_grad()train() — 冻结策略

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
def _set_requires_grad(self):
    if self.freeze_vision_encoder:
        for param in self.paligemma.model.vision_tower.parameters():
            param.requires_grad = False
    if self.train_expert_only:
        for param in self.paligemma.parameters():
            param.requires_grad = False

def train(self, mode=True):
    super().train(mode)
    if self.freeze_vision_encoder: self.paligemma.model.vision_tower.eval()
    if self.train_expert_only:     self.paligemma.eval()

train(mode) 的覆盖很关键:super().train(mode) 会将所有子模块设回 training 模式,但冻结部分需要额外强制 eval()——确保 BN/Dropout 在冻结模块中不激活。


B5. embed_image()embed_language_tokens()

1
2
3
4
5
6
7
8
9
def embed_image(self, image):
    out_dtype = image.dtype
    if image.dtype != torch.float32:
        image = image.to(torch.float32)
    image_outputs = self.paligemma.model.get_image_features(image)
    features = image_outputs.pooler_output
    if features.dtype != out_dtype:
        features = features.to(out_dtype)
    return features
  • 输入 image 强制转为 fp32 → SigLIP 前向 → pooler_output(即经过 multi-modal projector 后的 [B, N_patches, width] 特征);
  • 输出转回输入 dtype(如果外部是 bf16,将 fp32 特征转回 bf16),节省后续计算带宽。
1
2
def embed_language_tokens(self, tokens):
    return self.paligemma.model.language_model.get_input_embeddings()(tokens)

标准 token embedding 查表,返回 [B, seq_len, width]


B6. PaliGemmaWithExpertModel.forward() — 三种前向模式

这是双塔模型的调度中心。根据 inputs_embeds 的内容分为三种模式:

模式 1:Prefix-Only(推理时预填充)

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
if inputs_embeds[1] is None:
    prefix_output = self.paligemma.model.language_model.forward(
        inputs_embeds=inputs_embeds[0],
        attention_mask=attention_mask,
        position_ids=position_ids,
        past_key_values=past_key_values,
        use_cache=use_cache,              # use_cache=True → 返回 KV Cache
        adarms_cond=adarms_cond[0],       # None(Prefix 塔无 AdaRMS)
    )
    prefix_past_key_values = prefix_output.past_key_values
    prefix_output = prefix_output.last_hidden_state
    suffix_output = None
  • 触发条件:inputs_embeds = [prefix_embs, None]
  • 仅走 PaliGemma 的语言模型,不使用 Expert;
  • use_cache=True 返回 DynamicCache,后续推理复用;
  • adarms_cond[0] = None(PI05 中 Prefix 塔 use_adarms=False)。

模式 2:Suffix-Only(推理时去噪步骤)

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
elif inputs_embeds[0] is None:
    suffix_output = self.gemma_expert.model.forward(
        inputs_embeds=inputs_embeds[1],
        attention_mask=attention_mask,
        position_ids=position_ids,
        past_key_values=past_key_values,  # 携带 Prefix KV Cache
        use_cache=False,                   # 不需要再缓存
        adarms_cond=adarms_cond[1],        # time_emb → AdaRMS
    )
    suffix_output = suffix_output.last_hidden_state
    prefix_output = None
    prefix_past_key_values = None
  • 触发条件:inputs_embeds = [None, suffix_embs]
  • 仅走 Gemma Expert,通过 past_key_values 携带 Prefix 的 KV Cache → Suffix 在 attention 中可以看到 Prefix 的所有 token
  • use_cache=False:每轮去噪后不需要缓存 Suffix 的 KV(被 clone_past_key_values 刷新);
  • adarms_cond[1] = time_emb:时间嵌入注入 Expert 的 AdaRMS。

模式 3:联合模式(训练)

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
else:
    # 逐层联合计算
    for layers in zip(paligemma_layers, gemma_expert_layers, strict=True):
        inputs_embeds = compute_layer_complete(
            inputs_embeds, attention_mask, position_ids,
            adarms_cond, layers=layers, rotary_emb=rotary_emb,
        )
    # 分别最终 norm
    outputs_embeds = []
    for i, hidden_states in enumerate(inputs_embeds):
        out_emb, _ = layernorm_forward(final_norms[i], hidden_states, adarms_cond[i])
        outputs_embeds.append(out_emb)
    prefix_output = outputs_embeds[0]
    suffix_output = outputs_embeds[1]
    prefix_past_key_values = None
  • 触发条件:inputs_embeds = [prefix_embs, suffix_embs] 两者都非 None;
  • 18 层(gemma_300m)或 18 层(gemma_2b)逐层交替计算;
  • compute_layer_complete 在上面已详细分析——每层先拼接两塔的 hidden states 做联合 attention,再分别 MLP;
  • 最终 Norm 也使用 AdaRMS(Prefix 塔的 adarms_cond=None 退化为普通 RMSNorm,Suffix 塔的 adarms_cond=time_emb 正常注入);
  • prefix_past_key_values = None:训练时不缓存 KV Cache。

返回值统一格式([prefix_output, suffix_output], prefix_past_key_values)


B7. PI05Pytorch.__init__() — 核心模型初始化

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
class PI05Pytorch(nn.Module):
    def __init__(self, config: PI05Config, rtc_processor=None):
        self.config = config
        paligemma_config = get_gemma_config(config.paligemma_variant)
        action_expert_config = get_gemma_config(config.action_expert_variant)

        self.paligemma_with_expert = PaliGemmaWithExpertModel(
            paligemma_config, action_expert_config,
            use_adarms=[False, True],      # ← PI05 硬编码:Prefix无AdaRMS,Suffix有
            precision=config.dtype,
            image_size=config.image_resolution[0],
            freeze_vision_encoder=config.freeze_vision_encoder,
            train_expert_only=config.train_expert_only,
        )

use_adarms=[False, True] 是 PI05 区别于 PI0 的核心标志。PI0 使用 FiLM(use_adarms=[True, True]),PI05 将 Prefix 塔的 FiLM 替换为普通 RMSNorm,仅 Suffix 塔保留 AdaRMS。

1
2
3
4
        self.action_in_proj  = nn.Linear(config.max_action_dim, action_expert_config.width)
        self.action_out_proj = nn.Linear(action_expert_config.width, config.max_action_dim)
        self.time_mlp_in     = nn.Linear(action_expert_config.width, action_expert_config.width)
        self.time_mlp_out    = nn.Linear(action_expert_config.width, action_expert_config.width)

四组可学习投影:

投影维度变换作用
action_in_projmax_action_dim → D将动作向量嵌入到 Expert 的 hidden 空间
action_out_projD → max_action_dim将 Expert 输出映射回动作空间(预测速度场)
time_mlp_inD → D时间嵌入 MLP 第一层
time_mlp_outD → D时间嵌入 MLP 第二层

注意:PI05 没有 state_proj——这与 PI0 不同。PI0 将机器人状态(关节角度等)通过单独的投影并入,PI05 将状态信息视为动作维度的一部分统一处理。

1
2
3
4
        if config.compile_model:
            torch.set_float32_matmul_precision("high")
            self.sample_actions = torch.compile(self.sample_actions, mode=config.compile_mode)
            self.forward = torch.compile(self.forward, mode=config.compile_mode)

torch.set_float32_matmul_precision("high") 允许 PyTorch 使用 TF32 tensor cores(A100/H100 上),加速 fp32 矩阵乘法约 2-3×。torch.compile 对训练前向和推理函数做 JIT 编译。


B8. gradient_checkpointing_enable/disable / _apply_checkpoint

1
2
3
4
5
def gradient_checkpointing_enable(self):
    self.gradient_checkpointing_enabled = True
    self.paligemma_with_expert.paligemma.model.language_model.gradient_checkpointing = True
    self.paligemma_with_expert.paligemma.model.vision_tower.gradient_checkpointing = True
    self.paligemma_with_expert.gemma_expert.model.gradient_checkpointing = True

三层梯度检查点:Prefix 语言模型、视觉塔、Expert 模型。配合 compute_layer_complete 中的 HF checkpoint wrapper,训练时每层只保存输入,反向传播时重计算。

1
2
3
4
5
def _apply_checkpoint(self, func, *args, **kwargs):
    if self.gradient_checkpointing_enabled and self.training:
        return torch.utils.checkpoint.checkpoint(
            func, *args, use_reentrant=False, preserve_rng_state=False, **kwargs)
    return func(*args, **kwargs)

训练时启用检查点的统一接口。非训练或未启用时直接调用。


B9. _prepare_attention_masks_4d() — 2D→4D 掩码转换

1
2
3
def _prepare_attention_masks_4d(self, att_2d_masks):
    att_2d_masks_4d = att_2d_masks[:, None, :, :]
    return torch.where(att_2d_masks_4d, 0.0, OPENPI_ATTENTION_MASK_VALUE)
  • att_2d_masks 是 bool 矩阵 [B, N, N](True=可见,False=不可见);
  • 升维到 [B, 1, N, N] 适配多头 attention;
  • OPENPI_ATTENTION_MASK_VALUE 是一个极小值(通常 ~-2.381e38 或 torch.finfo(torch.float32).min),加到 attention logits 上使 Softmax 后不可见位置的权重变为 0。

B10. sample_noise() / sample_time()

1
2
def sample_noise(self, shape, device):
    return torch.normal(mean=0.0, std=1.0, size=shape, dtype=torch.float32, device=device)

标准高斯噪声。shape 是 (B, chunk_size, max_action_dim)强制 fp32 保证去噪数值精度。

1
2
3
4
5
6
7
def sample_time(self, bsize, device):
    time_beta = sample_beta(
        self.config.time_sampling_beta_alpha,
        self.config.time_sampling_beta_beta, bsize, device
    )
    time = time_beta * self.config.time_sampling_scale + self.config.time_sampling_offset
    return time.to(dtype=torch.float32, device=device)

时间从 Beta 分布采样后线性变换:

$$t = t_{\text{beta}} \cdot \text{scale} + \text{offset}$$

典型配置(来自 openpi):alpha=1.5, beta=1.0, scale=1.0, offset=0.0 → 采样偏向较小时刻(更多训练在低噪声精细去噪阶段)。scale < 1 时限制 t ∈ [offset, offset+scale]。


B11. embed_prefix() — Prefix 嵌入拼接

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
def embed_prefix(self, images, img_masks, tokens, masks):
    embs = []; pad_masks = []; att_masks = []

    # 逐个图像编码
    for img, img_mask in zip(images, img_masks, strict=True):
        img_emb = self._apply_checkpoint(image_embed_func, img)    # SigLIP + Projector
        bsize, num_img_embs = img_emb.shape[:2]
        embs.append(img_emb)
        pad_masks.append(img_mask[:, None].expand(bsize, num_img_embs))  # [B] → [B, N_img]
        att_masks += [0] * num_img_embs                                   # 图像 token 全在同一个 attn group

img_mask[B] 的 bool(0=缺失视角,1=有效视角),扩展为 [B, N_img]att_masks 全部填 0 → 所有图像 token 互相可见。

1
2
3
4
5
    # 语言 token 编码
    lang_emb = self._apply_checkpoint(lang_embed_func, tokens)
    embs.append(lang_emb)
    pad_masks.append(masks)                            # 来自 tokenizer 的 attention_mask
    att_masks += [0] * num_lang_embs                   # 语言 token 也在同一个 attn group

语言 token 的 att_masks 同样为 0 → Prefix 内部所有 token(图像 + 语言)共享同一个 attention group,实现双向全注意力

1
2
3
4
5
    embs = torch.cat(embs, dim=1)        # [B, N_img1 + N_img2 + ... + N_lang, D]
    pad_masks = torch.cat(pad_masks, dim=1)
    att_masks = torch.tensor(att_masks, dtype=torch.bool, device=pad_masks.device)
    att_masks = att_masks[None, :].expand(bsize, len(att_masks))  # [1, N] → [B, N]
    return embs, pad_masks, att_masks

B12. embed_suffix() — Suffix 嵌入(动作+时间)

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
def embed_suffix(self, noisy_actions, timestep):
    # 1. 时间步 → 正弦编码
    time_emb = create_sinusoidal_pos_embedding(
        timestep,
        self.action_in_proj.out_features,   # = Expert hidden size D
        min_period=self.config.min_period,
        max_period=self.config.max_period,
        device=timestep.device,
    )
    time_emb = time_emb.type(dtype=timestep.dtype)

时间编码的维度 = Expert 的 hidden size(不是 action dim),因为时间嵌入需要和 hidden states 交互(作为 AdaRMS 条件和残差流相加)。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
    # 2. 动作 → 线性投影
    action_emb = self._apply_checkpoint(action_proj_func, noisy_actions)  # [B, chunk, D]

    # 3. 时间嵌入 → 2层 MLP
    def time_mlp_func(time_emb):
        x = self.time_mlp_in(time_emb)    # D → D
        x = F.silu(x)
        x = self.time_mlp_out(x)          # D → D
        return F.silu(x)                  # 最终激活
    time_emb = self._apply_checkpoint(time_mlp_func, time_emb)
    adarms_cond = time_emb                # ← 这就是 AdaRMS 的条件向量

时间嵌入经过 time_mlp_in → SiLU → time_mlp_out → SiLU 的双层 MLP。第二个 SiLU 是关键的——它使 adarms_cond 始终非负(SiLU 最小值 ≈ -0.278),限制 AdaRMS 门控的动态范围。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
    # 4. Suffix = 动作嵌入(不含时间嵌入的显式拼接!)
    action_time_emb = action_emb
    embs.append(action_time_emb)

    bsize, action_time_dim = action_time_emb.shape[:2]
    action_time_mask = torch.ones(bsize, action_time_dim, dtype=torch.bool, device=timestep.device)
    pad_masks.append(action_time_mask)

    # 5. Attention mask: 第一个 token att_mask=1(可看到 Prefix),其余 att_mask=0(因果链)
    att_masks += [1] + ([0] * (self.config.chunk_size - 1))

关键设计: Suffix 的 embedding 仅包含 action_emb(不拼接 time_emb)。时间信息完全通过 AdaRMS 注入每一层——这比直接拼接更优雅,因为时间信息以乘法形式调制归一化参数,而非作为 token 参与 attention。

att_masks = [1, 0, 0, ..., 0](chunk_size 个元素):

  • 第一个 token 的 att_mask=1cumsum 从 1 开始 → 可以 attend 到 cumsum ≤ 1 的所有前缀 token;
  • 后续 token att_mask=0cumsum 保持 1 → 因果注意力(每个 token 可 attend 到前缀 + 当前 chunk 内之前的所有 token)。

B13. PI05Pytorch.forward() — 训练前向

1
2
3
4
5
def forward(self, images, img_masks, tokens, masks, actions, noise, time) -> Tensor:
    # Flow Matching 加噪
    time_expanded = time[:, None, None]                    # [B] → [B, 1, 1]
    x_t = time_expanded * noise + (1 - time_expanded) * actions   # 线性插值
    u_t = noise - actions                                  # 目标速度场

Flow Matching 定义:从干净动作 $a$ 到噪声 $\epsilon$ 的直线路径:

$$x_t = t \cdot \epsilon + (1-t) \cdot a, \quad u_t = \epsilon - a$$
  • $t=0$:$x_0 = a$(干净动作),$u_0 = \epsilon - a$(从干净指向噪声);
  • $t=1$:$x_1 = \epsilon$(纯噪声),$u_1 = \epsilon - a$(不变)。

速度场 $u_t$ 在整个路径上是常数——这正是 Flow Matching 的 Straight Line Flow 性质。

1
2
3
4
5
6
7
8
    # Prefix + Suffix 嵌入
    prefix_embs, prefix_pad_masks, prefix_att_masks = self.embed_prefix(images, img_masks, tokens, masks)
    suffix_embs, suffix_pad_masks, suffix_att_masks, adarms_cond = self.embed_suffix(x_t, time)

    # 精度对齐
    if self.paligemma_with_expert.paligemma.model.language_model.layers[0].self_attn.q_proj.weight.dtype == torch.bfloat16:
        suffix_embs = suffix_embs.to(dtype=torch.bfloat16)
        prefix_embs = prefix_embs.to(dtype=torch.bfloat16)

检查第一层 Q 投影权重的 dtype:如果是 bf16,将 embeddings 也转为 bf16(减少计算开销)。如果视觉路径保持 fp32(to_bfloat16_for_selected_params 的副作用),此处不转换。

1
2
3
4
5
6
    # 拼接 Pad/Att masks + 构造全局 attention mask
    pad_masks = torch.cat([prefix_pad_masks, suffix_pad_masks], dim=1)
    att_masks = torch.cat([prefix_att_masks, suffix_att_masks], dim=1)
    att_2d_masks = make_att_2d_masks(pad_masks, att_masks)
    position_ids = torch.cumsum(pad_masks, dim=1) - 1
    att_2d_masks_4d = self._prepare_attention_masks_4d(att_2d_masks)

position_ids 通过累积有效 token 数生成:padding 位置的 cumsum 不变(mask=0 → 不加),保证位置 id 连续。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
    # 联合前向(Prefix + Suffix 同时进模型)
    def forward_func(prefix_embs, suffix_embs, att_2d_masks_4d, position_ids, adarms_cond):
        (_, suffix_out), _ = self.paligemma_with_expert.forward(
            attention_mask=att_2d_masks_4d,
            position_ids=position_ids,
            past_key_values=None,                       # 训练不缓存
            inputs_embeds=[prefix_embs, suffix_embs],    # 联合模式
            use_cache=False,
            adarms_cond=[None, adarms_cond],             # Prefix无AdaRMS, Suffix有
        )
        return suffix_out

    suffix_out = self._apply_checkpoint(forward_func, ...)
    suffix_out = suffix_out[:, -self.config.chunk_size :]   # 只取 suffix 部分
    suffix_out = suffix_out.to(dtype=torch.float32)          # 恢复到 fp32 计算 loss
  • past_key_values=None + use_cache=False:训练时不缓存 KV;
  • inputs_embeds=[prefix_embs, suffix_embs]:触发联合模式;
  • [:, -chunk_size:]:从输出中截取 Suffix 部分——模型输出包含 prefix 和 suffix token,只取最后 chunk_size 个;
  • 预测前转回 fp32(loss 计算需要高精度)。
1
2
    v_t = self._apply_checkpoint(action_out_proj_func, suffix_out)
    return F.mse_loss(u_t, v_t, reduction="none")   # [B, chunk, action_dim]

返回未归约的 MSE——由上层 PI05Policy.forward() 根据 reduction 参数决定是否取均值。


B14. PI05Pytorch.sample_actions() — 推理采样(核心)

1
2
3
4
5
6
7
@torch.no_grad()
def sample_actions(self, images, img_masks, tokens, masks, noise=None, num_steps=None, **kwargs):
    bsize = tokens.shape[0]; device = tokens.device

    if noise is None:
        actions_shape = (bsize, self.config.chunk_size, self.config.max_action_dim)
        noise = self.sample_noise(actions_shape, device)

阶段 1:Prefix 预填充

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
    prefix_embs, prefix_pad_masks, prefix_att_masks = self.embed_prefix(images, img_masks, tokens, masks)
    prefix_att_2d_masks = make_att_2d_masks(prefix_pad_masks, prefix_att_masks)
    prefix_position_ids = torch.cumsum(prefix_pad_masks, dim=1) - 1
    prefix_att_2d_masks_4d = self._prepare_attention_masks_4d(prefix_att_2d_masks)

    # 强制 eager attention(torch.compile 下 SDPA 可能与 DynamicCache 不兼容)
    self.paligemma_with_expert.paligemma.model.language_model.config._attn_implementation = "eager"

    _, past_key_values = self.paligemma_with_expert.forward(
        attention_mask=prefix_att_2d_masks_4d,
        position_ids=prefix_position_ids,
        past_key_values=None,
        inputs_embeds=[prefix_embs, None],  # ← Prefix-Only 模式
        use_cache=True,
    )

关键细节:

  • _attn_implementation = "eager":torch.compile 默认使用 sdpa/flash_attention_2,但这些实现与 DynamicCache 的某些路径不兼容(特别是 use_cache=True 时的 KV 追加),强制回退到 eager;
  • inputs_embeds=[prefix_embs, None]:触发 Prefix-Only 模式,仅计算 Prefix KV Cache;
  • use_cache=True:返回 past_key_values(DynamicCache)。

阶段 2:去噪循环

1
2
3
4
5
    dt = -1.0 / num_steps                       # 负步长:从 t=1 走到 t=0
    x_t = noise
    for step in range(num_steps):
        time = 1.0 + step * dt                  # 1.0, 1-dt, 1-2dt, ..., 0
        time_tensor = torch.tensor(time, dtype=torch.float32, device=device).expand(bsize)

时间线性递减:从 t=1(纯噪声)到 t=0(干净动作),步长 $\Delta t = -1/N$。

1
2
3
4
5
6
7
        def denoise_step_partial_call(input_x_t, current_timestep=time_tensor):
            return self.denoise_step(
                prefix_pad_masks=prefix_pad_masks,
                past_key_values=past_key_values,
                x_t=input_x_t,
                timestep=current_timestep,
            )

闭包捕获 past_key_valuesprefix_pad_masks,暴露简洁的 (x_t) → v_t 接口。这个包装是为了 RTC 兼容——RTC 需要调用 original_denoise_step_partial 作为 fallback。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
        if self._rtc_enabled():
            v_t = self.rtc_processor.denoise_step(
                x_t=x_t, prev_chunk_left_over=..., inference_delay=...,
                time=time, original_denoise_step_partial=denoise_step_partial_call,
                execution_horizon=...,
            )
        else:
            v_t = denoise_step_partial_call(x_t)

        x_t = x_t + dt * v_t                   # Euler 步进 {#fn-euler-step}

RTC 模式下,rtc_processor.denoise_step 可能在 chunk 边界上拼接前一个 chunk 的剩余动作,或在执行时域限制下截断预测——这是一个实时控制的扩展功能,普通推理直接走 else 分支。

1
2
3
4
        if self.rtc_processor is not None and self.rtc_processor.is_debug_enabled():
            self.rtc_processor.track(time=time, x_t=x_t, v_t=v_t)

    return x_t                                  # 形状 [B, chunk_size, max_action_dim]

调试模式记录每一步的中间态,用于可视化去噪过程。


B15. PI05Pytorch.denoise_step() — 单步去噪

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
def denoise_step(self, prefix_pad_masks, past_key_values, x_t, timestep):
    # 1. Suffix 嵌入
    suffix_embs, suffix_pad_masks, suffix_att_masks, adarms_cond = self.embed_suffix(x_t, timestep)

    # 2. 构造 full attention mask(Suffix attend to Prefix + Suffix causal)
    batch_size = prefix_pad_masks.shape[0]
    suffix_len = suffix_pad_masks.shape[1]
    prefix_len = prefix_pad_masks.shape[1]

    prefix_pad_2d_masks = prefix_pad_masks[:, None, :].expand(batch_size, suffix_len, prefix_len)
    suffix_att_2d_masks = make_att_2d_masks(suffix_pad_masks, suffix_att_masks)
    full_att_2d_masks = torch.cat([prefix_pad_2d_masks, suffix_att_2d_masks], dim=2)

full_att_2d_masks 的 shape 是 [B, suffix_len, prefix_len + suffix_len]

  • 左半部分 [B, suffix_len, prefix_len]:来自 prefix_pad_masks 的广播——Suffix 的每个 token 可以看到 Prefix 的所有有效(padding=1)token;
  • 右半部分 [B, suffix_len, suffix_len]:来自 make_att_2d_masks 的 Suffix 因果注意力。
1
2
3
    # 3. Position IDs = Prefix 总长度 + Suffix 内的 cumsum
    prefix_offsets = torch.sum(prefix_pad_masks, dim=-1)[:, None]   # [B, 1]
    position_ids = prefix_offsets + torch.cumsum(suffix_pad_masks, dim=1) - 1

Suffix 的位置 id 从 Prefix 的总 token 数开始递增,保证全局位置编码连续。

1
2
3
    # 4. 强制 eager attention + 深拷贝 KV Cache
    self.paligemma_with_expert.gemma_expert.model.config._attn_implementation = "eager"
    past_key_values = clone_past_key_values(past_key_values)

每次 denoise_step 都深拷贝 Prefix KV Cache——防止 Suffix 的新 KV 污染 Prefix 缓存(分析见 A6)。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
    # 5. Suffix-Only 前向
    outputs_embeds, _ = self.paligemma_with_expert.forward(
        attention_mask=full_att_2d_masks_4d,
        position_ids=position_ids,
        past_key_values=past_key_values,        # 携带 Prefix KV Cache
        inputs_embeds=[None, suffix_embs],       # Suffix-Only 模式
        use_cache=False,                         # 不需要缓存 Suffix KV
        adarms_cond=[None, adarms_cond],
    )
    suffix_out = outputs_embeds[1]               # Suffix 塔的输出
    suffix_out = suffix_out[:, -self.config.chunk_size :]   # 截取 suffix 序列
    suffix_out = suffix_out.to(dtype=torch.float32)
    return self.action_out_proj(suffix_out)       # D → action_dim

C. Policy 接口类详解

C1. PI05Policy.__init__() — Policy 初始化

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
class PI05Policy(PreTrainedPolicy):
    config_class = PI05Config
    name = "pi05"

    def __init__(self, config):
        require_package("transformers", extra="pi")
        super().__init__(config)
        config.validate_features()
        self.init_rtc_processor()
        self.model = PI05Pytorch(config, rtc_processor=self.rtc_processor)
        if config.gradient_checkpointing:
            self.model.gradient_checkpointing_enable()
        self.model.to(config.device)
        self.reset()

标准 LeRobot Policy 初始化协议。reset() 初始化动作队列(用于 n_action_steps > 1 时的 action chunk 调度)。


C2. PI05Policy.from_pretrained() — 权重加载

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
@classmethod
def from_pretrained(cls, pretrained_name_or_path, *, config=None, force_download=False, ..., strict=True, **kwargs):
    # 1. 打印 disclaimer
    print("The PI05 model is a direct port of the OpenPI implementation...")

    # 2. 获取/创建 config
    if config is None:
        config = PreTrainedConfig.from_pretrained(pretrained_name_or_path, ...)

    # 3. 创建模型实例(随机权重)
    model = cls(config, **kwargs)

    # 4. 下载 safetensors 文件
    from transformers.utils import cached_file
    resolved_file = cached_file(pretrained_name_or_path, "model.safetensors", ...)
    from safetensors.torch import load_file
    original_state_dict = load_file(resolved_file)

步骤 A:Key Fix(_fix_pytorch_state_dict_keys

1
    fixed_state_dict = model._fix_pytorch_state_dict_keys(original_state_dict, model.config)

处理 checkpoint 与当前代码键名不一致的多种情况(详见下文 C3)。

步骤 B:Remap Prefix

1
2
3
4
5
6
7
    remapped_state_dict = {}
    for key, value in fixed_state_dict.items():
        if not key.startswith("model."):
            new_key = f"model.{key}"          # ← 加 "model." 前缀
            remapped_state_dict[new_key] = value
        else:
            remapped_state_dict[key] = value

所有不含 model. 前缀的 key 都加上——因为 PI05PolicyPI05Pytorch 存为 self.model,HF 的 save_pretrained 不会自动加前缀,但 LeRobot 的加载需要。

1
    missing_keys, unexpected_keys = model.load_state_dict(remapped_state_dict, strict=strict)

C3. _fix_pytorch_state_dict_keys() — 键名兼容性修复

处理 OpenPI checkpoint 到 PI05 的多种键名差异:

Case 1: AdaRMS 不兼容 → 跳过

1
2
3
4
5
6
if re.match(r"paligemma_with_expert\.gemma_expert\.model\.layers\.\d+\."
            r"(input_layernorm|post_attention_layernorm)\.weight", key):
    expert_uses_adarms = getattr(self.model.paligemma_with_expert.gemma_expert.config, "use_adarms", False)
    if expert_uses_adarms:
        logging.warning(f"Skipping layer norm key (adaRMS mismatch): {key}")
        continue

如果 checkpoint 中的 Expert 层是 .weight 格式(普通 RMSNorm),但当前模型使用 AdaRMS(use_adarms=True),则跳过——因为 AdaRMS 的权重结构不同(W_gamma + W_beta vs 单个 weight)。同样处理 final_norm

Case 2: MLP 命名差异

1
2
3
4
if key.startswith("action_time_mlp_in."):
    new_key = key.replace("action_time_mlp_in.", "time_mlp_in.")
elif key.startswith("action_time_mlp_out."):
    new_key = key.replace("action_time_mlp_out.", "time_mlp_out.")

PI05 将 PI0 中的 action_time_mlp_* 重命名为 time_mlp_*

Case 3: state_proj 不存在

1
2
3
if key.startswith("state_proj."):
    logging.warning(f"Skipping state_proj key in pi05 mode: {key}")
    continue

PI05 没有 state_proj——如果 checkpoint 包含(来自 PI0),直接丢弃。

Case 4: lm_head.weightembed_tokens.weight

1
2
3
if key in ("model.paligemma_with_expert.paligemma.lm_head.weight",
           "paligemma_with_expert.paligemma.lm_head.weight"):
    fixed_state_dict["model.paligemma_with_expert.paligemma.model.language_model.embed_tokens.weight"] = value.clone()

PaliGemma 的 lm_head 权重与 embed_tokens 共享(tied weights),checkpoint 可能只存了一份。这里将其复制到 embedding 位置(推理不需要 lm_head)。


C4. _preprocess_images() — 图像预处理

1
2
3
4
def _preprocess_images(self, batch):
    device = next(self.parameters()).device
    present_img_keys = [key for key in self.config.image_features if key in batch]
    missing_img_keys = [key for key in self.config.image_features if key not in batch]

有效视角处理

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
    for key in present_img_keys:
        img = batch[key].to(device).to(torch.float32)

        # 格式检测 + 转换
        is_channels_first = img.shape[1] == 3
        if is_channels_first:
            img = img.permute(0, 2, 3, 1)        # [B,C,H,W] → [B,H,W,C]

        # Resize + Pad
        if img.shape[1:3] != self.config.image_resolution:
            img = resize_with_pad_torch(img, *self.config.image_resolution)

        # 归一化: [0,1] → [-1,1]
        img = img * 2.0 - 1.0

        if is_channels_first:
            img = img.permute(0, 3, 1, 2)        # [B,H,W,C] → [B,C,H,W]

        images.append(img)
        img_masks.append(torch.ones(bsize, dtype=torch.bool, device=device))

img.shape[1] == 3 是 heuristic 判据——假设只有 channels-first 格式的第二维是 3(C=3),但这也可能是 H=3 的极小图像。LeRobot 数据集几乎都是 [B,C,H,W],风险可忽略。

缺失视角处理

1
2
3
4
5
    for _num_empty_cameras in range(len(missing_img_keys)):
        img = torch.ones_like(img) * -1          # 全 -1 填充(SigLIP 预处理后的"黑")
        mask = torch.zeros_like(mask)            # mask = 0(不可见)
        images.append(img)
        img_masks.append(mask)

-1 并非非法输入:SigLIP 归一化后 [-1, 1] 范围中,-1 对应原始 [0,1] 范围中的 0(纯黑)。模型训练时应包含缺失视角的数据增强(随机 dropout 相机),因此推理时遇到缺失视角可以合理处理。


C5. prepare_action() / select_action() / predict_action_chunk()

1
2
def prepare_action(self, batch):
    return pad_vector(batch[ACTION], self.config.max_action_dim)

训练时动作维度 pad 到 max_action_dim

1
2
3
4
5
6
7
8
@torch.no_grad()
def select_action(self, batch):
    assert not self._rtc_enabled()  # RTC 不兼容单步动作选择

    if len(self._action_queue) == 0:
        actions = self.predict_action_chunk(batch)[:, :self.config.n_action_steps]
        self._action_queue.extend(actions.transpose(0, 1))  # (B, steps, dim) → 按 step 拆入队列
    return self._action_queue.popleft()

Action Chunk 调度策略:

  • 模型预测 50 步动作,取前 10 步(n_action_steps)入队;
  • 每次 select_action 弹出 1 步;
  • 队列空后重预测——这意味着每 10 步触发一次模型推理,大幅降低推理频率(50 步才需要 5 次推理)。
1
2
3
4
5
6
7
@torch.no_grad()
def predict_action_chunk(self, batch, **kwargs):
    images, img_masks = self._preprocess_images(batch)
    tokens, masks = batch["observation_language_tokens"], batch["observation_language_attention_mask"]
    actions = self.model.sample_actions(images, img_masks, tokens, masks, **kwargs)
    actions = actions[:, :, :original_action_dim]   # 解 pad 到真实动作维度 {#fn-action-proj}
    return actions

C6. PI05Policy.forward() — 训练 Loss

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
def forward(self, batch, reduction="mean"):
    images, img_masks = self._preprocess_images(batch)
    tokens, masks = batch["observation_language_tokens"], batch["observation_language_attention_mask"]
    actions = self.prepare_action(batch)

    noise = self.model.sample_noise(actions.shape, actions.device)
    time = self.model.sample_time(actions.shape[0], actions.device)

    losses = self.model.forward(images, img_masks, tokens, masks, actions, noise, time)  # → MSE
    losses = losses[:, :, :original_action_dim]   # 截断到真实维度

    if reduction == "none":
        per_sample_loss = losses.mean(dim=(1, 2))  # [B]
        return per_sample_loss, {"loss": per_sample_loss.mean().item()}
    else:
        loss = losses.mean()
        return loss, {"loss": loss.item()}

reduction="none" 返回 per-sample loss 用于 RA-BC(Reward-Augmented Behavioral Cloning)等需要逐样本权重的训练方法。


C7. _get_default_peft_targets() — PEFT/LoRA 默认目标

1
2
3
4
def _get_default_peft_targets(self):
    common_projections = "state_proj|action_in_proj|action_out_proj|action_time_mlp_in|action_time_mlp_out"
    target_modules = rf"(.*\.gemma_expert\..*\.self_attn\.(q|v)_proj|model\.({common_projections}))"
    return {"target_modules": target_modules, "modules_to_save": []}

LoRA 默认只微调 Expert 塔的 Q/V 投影 + 输入输出投影,Prefix 塔保持冻结。


总结:关键数据流

训练:
  图像 + 语言 + 动作 → embed_prefix + embed_suffix
  → 联合 attention (compute_layer_complete × 18层)
  → suffix_out → action_out_proj → v_t
  → MSE(u_t, v_t)

推理 (sample_actions):
  图像 + 语言 → embed_prefix → Prefix KV Cache (预填充)
  → noise = N(0,I); x_t = noise
  → 循环 N 步:
      x_t, timestep → embed_suffix → denoise_step (Suffix attend to Prefix KV)
      → action_out_proj → v_t
      → x_t = x_t + dt * v_t
  → 返回 x_t (≈ 干净动作)