← reports index
机制详解 · 2026-09-08

怎么给 SmolVLA 打这个补丁

state dropout 的全部机制:state 在模型里走哪条路、补丁挂在哪一行、置零之后模型实际收到的是什么、退火曲线怎么算、以及怎么确认它真的挂上了。看完应该能自己判断这个补丁对不对,而不是相信我说它对。

一、state 在 SmolVLA 里走哪条路

先弄清楚要动的是什么。SmolVLA 把观测拼成一串 token 喂给 VLM,整个本体状态只占其中一个 token:

图像 token …… ─┐
语言 token …… ─┼─→ 前缀序列 ─→ VLM ─→ 动作专家 ─→ 50 步动作块
state token ×1 ─┘

那一个 token 的来路(modeling_smolvla.py):

观测 17 维
   ↓  归一化处理器(在策略【外面】,是 preprocessor 的一步)
   ↓  (x − mean) / (std + 1e-8)
   ↓
SmolVLAPolicy.prepare_state(batch)          # :410
   state = batch[OBS_STATE]
   state = pad_vector(state, 32)            # max_state_dim,补零到 32
   ↓
VLAFlowMatching.embed_prefix()              # :620
   state_emb = self.state_proj(state)       # nn.Linear(32 → 960)
   embs.append(state_emb[:, None, :])       # 变成序列里的【一个】token
   att_masks += [1]                         # 图像/语言【不】回看 state
为什么这个结构对我们有利:state 是一个独立 token,不是混进图像特征里的。要「让模型看不到关节」,只要让这一个 token 变成常量就行 —— 不用碰视觉那一路,不用改模型结构,不用重建 optimizer。

二、补丁挂在 prepare_state,不是别的地方

prepare_state 是唯一同时被训练和推理两条路调用的地方:

调用点行什么时候
SmolVLAPolicy.forward():296训练,算 loss
SmolVLAPolicy.predict_action_chunk():209推理,真机上跑

挂在这里的好处是一个函数管住两条路,坏处是必须自己区分 —— 所以补丁第一件事就是查 self.training:

def prepare_state(self, batch):
    state = orig(self, batch)                 # 先让原函数做 padding
    if not self.training:                     # ★ 推理原样返回 ★
        return state                          #   真机上永远拿到真实 state
    ...                                       # 只有训练才置零
如果漏了这一句会怎样:真机上 state token 也被随机置零,等于让模型闭着眼睛开车,而且是随机地闭一半时间的眼。这个错误不会报任何错,只会表现为「模型在实机上比离线差很多」—— 极难查。

三、置零之后,模型实际收到的是什么

这一点值得说清楚,因为「置零」听起来像是喂了一个无意义的 0。实际不是:

归一化【之后】的 state 置零
   ↓  归一化是 (x − mean)/std,所以 0 对应的原始值就是 mean
   ↓  = 「训练集里最平均的那个姿态」
   ↓
state_proj(0) = W·0 + b = b                   # 只剩偏置
   ↓
一个【固定的、可学习的】向量
所以被置零的样本共享同一个 token,而那个 token 是模型自己学出来的。它天然会收敛成「我不知道胳膊在哪」这个含义的表示 —— 相当于免费得到一个 state-unknown 标记,不用自己设计特殊 token、不用改词表、不用加参数。
这也是为什么必须在归一化之后置零。在归一化之前置零的话,喂进去的是「所有关节角都等于 0°」这个具体且错误的姿态,模型会当真。

四、逐样本,不是整批

keep = (torch.rand(state.shape[0], device=state.device) >= p).to(state.dtype)
out  = state * keep.view(-1, *([1] * (state.ndim - 1)))

torch.rand 出的是 batch_size 个独立随机数,所以同一批里有的样本看得到 state、有的看不到。

为什么不能整批一起置零。如果整批同置零、整批同保留,模型会学到一个更简单的规律:「这一批有没有 state」是可以从批内一致性里推出来的,于是它可以分情况处理 —— 有 state 的时候照旧抄捷径,没有的时候才看图。捷径一点没断。
逐样本混在一起,模型无法预知这一个样本有没有 state,只能学一套两种情况都work的表示。

五、退火曲线

整个调度就是一个纯函数,好测也好读:

def p_at(step, total):
    vf, af = SD_VISION_FRAC, SD_ANNEAL_FRAC   # 0.3, 0.4
    p_end  = SD_P_END                          # 0.2
    frac = step / total
    if frac < vf:                    return 1.0                    # 阶段 A
    if frac >= vf + af:              return p_end                  # 阶段 C
    return 1.0 + (p_end - 1.0) * ((frac - vf) / af)                # 阶段 B 线性
阶段区间(默认)p在干什么
A 纯视觉0 – 30%1.0state token 恒为常量。模型除了看图没有别的办法压 loss,视觉那条通路被迫长出来
B 退火30 – 70%1.0 → 0.2慢慢把 state 还回去。此时视觉通路已经存在,state 是补充而不是替代
C 永久下限70 – 100%0.2五分之一的样本永远没有 state
永久下限是这个方案的关键,不是保险起见。
如果阶段 B 之后把 p 降到 0(完全放开),state → action 依然是损失下降最快的方向 —— 示范轨迹光滑,抄上一帧关节角的误差只有 0.9°(实测 state ↔ action 相关 0.99)。梯度会毫不犹豫地重新走回捷径,阶段 A 辛苦长出来的视觉依赖被冲掉。训练结束时你拿到的仍然是一个不看图的模型,而 loss 曲线全程漂亮。
留 20% 之后,任何一步的期望损失里都有五分之一来自「没有 state」的样本,只靠 state 在数学上就压不下去。
这不是「两段式冻结」。常见的写法是「前 N 个 epoch 冻住 state_proj,之后解冻」。那个做法在解冻之后没有任何东西阻止捷径回来,而且 state_proj 冻住时它的偏置照样在训练、输入照样是真实 state ——「冻参数」和「不给信息」是两件事。

六、怎么注入(不改 lerobot 源码)

训练入口是 lerobot-train 这个 console 脚本。要在它建模型之前打补丁,要么改 lerobot 源码(每次升级都要重打,而且改动在 git 之外),要么在同一个进程里先 import 再交棒。选后者:

# train_state_dropout.py
total = _steps_from_argv(sys.argv[1:])        # 从 --steps=N 里读总步数,用来算退火进度
state_dropout.install(total_steps=total)      # ← 打补丁

from lerobot.scripts.lerobot_train import main as train_main
train_main()                                  # 交棒,参数原样透传

install() 做的事:

orig = SmolVLAPolicy.prepare_state             # 存原函数
def prepare_state(self, batch): ...            # 包一层
SmolVLAPolicy.prepare_state = prepare_state    # 替换类方法

替换的是类方法,所以之后 lerobot-train 自己 from_pretrained 造出来的实例自动带补丁,不用把 policy 对象传来传去。install() 自带重复调用保护。

为什么这样更好:lerobot 可以随便升级;补丁是仓库里的三个普通文件;SD_ENABLE=0 就完全退化成原版训练,对照组和实验组跑的是同一份代码。

七、两个坑

步数计数器 vs 梯度累积

补丁自己数前向次数,而一次优化器更新 = ACCUM 次前向。当前配方 MICRO_BATCH=16, B_EFF=16 所以 ACCUM=1,两者相等。如果以后改成 ACCUM > 1,退火进度会按前向次数走,而 --steps 传的也是前向次数(脚本里 STEPS = UPDATES × ACCUM),所以仍然对得上 —— 但这是巧合般的一致,改配方时要重新确认。SD_TOTAL_STEPS 可以显式覆盖。

总步数读不到

命令行里没有 --steps 又没给 SD_TOTAL_STEPS 时,total=0,p_at 会全程返回 p_end —— 阶段 A 直接没了,而且不报错。所以 train_state_dropout.py 在这种情况下会打一条明确的警告到 stderr。

八、怎么确认它真的挂上了

这一步不能省。补丁没挂上的表现和挂上了一模一样:训练照跑,loss 照降。

1
自检(不需要 GPU、不需要数据集)
python state_dropout.py
退火各分段点全部 ok + 逐样本置零比例 p=0.5 -> 实测 0.4991 ok + 自检通过
2
40 步烟测,看 p 在不在动
BENCH=1 SD_DEBUG=1 ./train_smolvla_state_dropout.sh
先是三行阶段说明:
== state dropout【已启用】total_steps=40
阶段 A 纯视觉 p=1.0 : 0 ~ 30% …
然后每 200 次前向一行:
[state-dropout] step 200/40 p=0.200 置零 3/16
看不到这些行 = 补丁没挂上,停下来查,别开正式训练。
3
训完拿离线斜率验收
python 03-software/scripts/vision_sweep_phase.py <新ckpt> <数据集> "<task>" right 12
这才是真正的验收 —— 补丁挂上了不等于有效。拿 pan 斜率跟 state6 的 +0.086 比。

九、这个补丁可能怎么坏

症状可能原因怎么确认
阶段 A 的 loss 几乎不降前提不成立:不给 state 却要预测绝对关节角是欠定的。我们赌的是两路腕部相机隐含编码了臂的姿态看 SD_DEBUG=1 的分段 loss。真不降就把阶段 A 改成预测 delta action,而不是加长阶段 A
斜率没变,还是 0.08捷径不是唯一原因,数据量(40 集)本身不够加数据,或先跑 SD_P_END=0.3 更狠一档
elbow 斜率退化「伸多远」原本学得不错(+0.72),它可能一部分是靠 state 撑的每次验收都要一并看 elbow,别只盯 pan
实机比离线差很多self.training 那一句被改坏了推理日志里不该出现任何 [state-dropout] 行
代码:05-training/state_dropout.py · 05-training/train_state_dropout.py · 05-training/train_smolvla_state_dropout.sh
被打补丁的上游:lerobot/policies/smolvla/modeling_smolvla.py(prepare_state :410,embed_prefix :620)
行号对应本机 ~/lerobot310,升级 lerobot 后要重新核对。