# SmolVLA 训练参数控制 · 根因分析、缩放规则、硬件档位与对照实验

> 2026-08-24。回答 `05-training/SMOLVLA-TRAINING-PARAMETER-GOAL.md`。
> 依据是本机仓库、训练脚本、训练日志、两个 checkpoint 的 `train_config.json`、
> Hub 上的 `training_state/`，以及两份 LeRobot 源码的逐文件 diff。
>
> **本文不启动任何训练。** 所有命令都要操作者自己执行。

---

## 〇、三句话

1. **代码版本混淆已排除。** 新旧两次训练的 183 个配置字段里只有 6 处不同，
   两份 LeRobot 源码在关键文件上只差一行 import。
2. **真正的差别只有三项**：`batch_size`（2 vs 64）、`eval_split`（0.2 vs 0.0，
   **导致老模型只训了 40 集、从没见过 ep40–49**）、以及**实际跑到第几步**（16000 vs 6000）。
3. **最可能的主因是第三项**：A100 那个 checkpoint 停在 20000 步曲线的 30% 处，
   保存下来的学习率还有 `7.99e-05`（峰值 `1.00e-04` 的 80%）。它不是一个训完的模型，
   是一个训练中途的快照。**但这是排名第一的假设，不是已证明的结论**（见 §1、§4）。

---

## 〇·五、如果现在只有两小时、只想训一版能用的

**结论：不要从头训，去把那个 A100 的 6000 步续下去。**

理由：前 6000 步的钱已经付过了；续跑 2 小时能到 **U≈16000**，正好等于目前唯一一个
能真的把杯子拿起来的模型的更新次数；而且它同时就是信息量最高的那次实验（§6 R1）。

### 前提：租到的卡要装得下 batch 64

`training_state/training_step.json` 记录的是 `batch_size: 64`、`dp_world_size: 1`、
`grad_accum_steps: 1`。resume 想干净，就得用同样的 B_eff，也就是需要 **A100 40/80G、
H100 或 A6000 48G 这一档**。租 4090（24G）装不下 batch 64，那就走下面的「备选」。

### 命令

```bash
lerobot-train \
  --resume=true \
  --config_path=Suyang99/xlerobot-smolvla-cup-grasp-right-a100-exp \
  --steps=20000 \
  --save_freq=2000 \
  --output_dir=outputs/train/a100_resume_20k
```

**一个字都不要多改。** 不要改 `--steps`、不要改 `--batch_size`、不要加增强、
不要改 `eval_split`。原因见 §6 R1。

### 两小时到点了没跑完怎么办 —— 不要紧

`save_checkpoint()` 会把 `pretrained_model/` 和 `training_state/` **同时**写进
**每一个**存档目录（`common/train_utils.py:243, 314`）。所以：

**2 小时到点直接停，把最后一个存档连 `training_state/` 一起拉下来，以后随时能再 resume 续到 20000。**
这跟当初「停在 6000」的区别在于：这次你知道它是中途点，而且留了续跑的钥匙。

⚠️ **下载时一定要带上 `training_state/`。** 上次就是只下了 `pretrained_model/`，
才让人以为不能续。

### 两小时能跑到哪

按官方唯一实测点（20000 步 / batch 64 / ~4 h → 0.72 s/step）估算：

| 时间 | 大约到第几步 | U | S |
|---|---|---|---|
| +1.0 h | 11,000 | 11,000 | 0.55 |
| +2.0 h | **16,000** | **16,000** | **0.80** ← 与老模型对齐 |
| +2.8 h | 20,000 | 20,000 | 1.00 |

**开跑 3 分钟先看日志里的 `step_s`**，`step_s × 剩余步数` 才是真实时长，
上表只是估算。

### 拿回来测什么

存档会在 8000 / 10000 / 12000 / 14000 / 16000。**至少测 8000、12000、16000 三个**，
每个按 §7 的统一测法跑 10 次。如果 8000 和 12000 完全没区别，说明 H1 不成立，
别把剩下的时间浪费在续跑上，直接去 §6 R2。

### 备选：只能租到 4090 / 24G

那就别 resume，做一次干净的新训练（这同时是 §6 的 R2）：

```bash
lerobot-train \
  --policy.path=lerobot/smolvla_base \
  --dataset.repo_id=Suyang99/xlerobot-cup-grasp-20260820-0230 \
  --rename_map='{"observation.images.head":"observation.images.camera1","observation.images.right_arm_wrist":"observation.images.camera2","observation.images.left_arm_wrist":"observation.images.camera3"}' \
  --batch_size=8 --num_workers=8 \
  --steps=20000 --save_freq=2000 \
  --dataset.eval_split=0.2 --eval_steps=0 \
  --policy.device=cuda --policy.push_to_hub=false \
  --output_dir=outputs/train/b8_eval02_20k --job_name=b8_eval02_20k
```

`--dataset.eval_split=0.2` 不是笔误：老模型用的就是 0.2，训练集是 ep0–39。
对齐这一项，这次训练才能和老模型构成「只差 batch」的对照（§1 H4）。
这和 §〇·六 里那条推荐命令的 `0.0` 不一样，是**故意的** —— 两者目的不同，理由见 §〇·七。
20000 步跑满、S=1.0，估计 1.5 h 左右，两小时够。

### 该期待什么（诚实说）

这一轮能不能到「非常好」，我不能保证。理由写在 §1：**H1 是排名第一的假设，不是已证明的结论**。
而且就算训练配方修对了，还有两个训练解决不了的瓶颈：
杯位覆盖在 0.70–0.82 之间是空的（§8.1），换动作块时肩部会跳几十度（§8.2）。

**这两小时买到的确定的东西是**：一个 U=16000 的、可继续的 checkpoint，
外加对「到底是不是没训完」这个问题的一个明确答案。

---

## 〇·六、两小时预算下，batch 该选几（2026-08-24 补）

**先说一个反直觉的事实：在 U=20000 固定的前提下，batch 完全不改变优化量。**

| batch | Σlr | U | S | 样本量 | **E** | 乐观时长 | 保守时长 | 2h 内 |
|---|---|---|---|---|---|---|---|---|
| 2 | 0.992 | 20000 | 1.0 | 40,000 | 2.1 | 24 m | 56 m | ✅ |
| **4** | 0.992 | 20000 | 1.0 | 80,000 | 4.1 | 31 m | 62 m | ✅ |
| 6 | 0.992 | 20000 | 1.0 | 120,000 | 6.2 | 38 m | 68 m | ✅ |
| **8** ← 推荐 | 0.992 | 20000 | 1.0 | 160,000 | 8.2 | 45 m | 74 m | ✅ |
| 12 | 0.992 | 20000 | 1.0 | 240,000 | 12.3 | 59 m | 86 m | ✅ |
| 16 | 0.992 | 20000 | 1.0 | 320,000 | 16.4 | 72 m | 98 m | ✅ |
| 24 | 0.992 | 20000 | 1.0 | 480,000 | 24.7 | 100 m | 121 m | ⚠️ |
| 32 | 0.992 | 20000 | 1.0 | 640,000 | 32.9 | 128 m | 145 m | ❌ |
| 64 | 0.992 | 20000 | 1.0 | 1,280,000 | 65.8 | 240 m | 240 m | ❌ |

`Σlr`、`U`、`S` 三列**完全相同** —— 因为它们只由 `--steps` 和调度器决定，与 batch 无关（§3）。
**batch 唯一改变的是 E（数据重复遍数）和梯度噪声。**

时长模型：`step_s(B) = f + (0.72 − f) × B/64`，锚点是官方唯一实测值
（batch 64 → 0.72 s/step，`docs/source/smolvla.mdx:50`）；`f` 是每步固定开销，
乐观取 0.05、保守取 0.15。**这是估算**，开跑 3 分钟看日志即可校准。
2 小时跑满 20000 步要求 `step_s ≤ 0.360`，留 20% 余量则 `≤ 0.288`。

### 为什么推荐 batch 8

- **加大 batch 在这里买不到任何优化上的好处**（上表前三列相同），只买到更高的 E 和更长的时间。
  所以「榨 A100 速度 = 把 batch 拉满」在这个预算下是反的。
- E 是我们仅有的两个数据点唯一分歧的轴：**E=2.02 → 实机 1/8（唯一非零）**；
  **E=19.74 → 0/20**。虽然后者与 S=0.30 混淆，但证据方向偏向低 E。
- batch 8 的 E=8.2，落在 2.02（有效）和 65.8（官方配方）之间，偏向已验证的一侧。
- 时长 45–74 分钟，两种模型下都有大余量。
- 2 的幂对 tensor core 利用率略好于 6。

**batch 16 也可以**（72–98 分钟），它更靠近官方配方。如果开跑后测出 `step_s ≤ 0.12`，
说明是乐观档，前 3 分钟杀掉换 16 重开，成本可忽略。

### 命令

```bash
lerobot-train \
  --policy.path=lerobot/smolvla_base \
  --dataset.repo_id=Suyang99/xlerobot-cup-grasp-20260820-0230 \
  --rename_map='{"observation.images.head":"observation.images.camera1","observation.images.right_arm_wrist":"observation.images.camera2","observation.images.left_arm_wrist":"observation.images.camera3"}' \
  --batch_size=8 --num_workers=8 \
  --steps=20000 --save_freq=2000 \
  --dataset.eval_split=0.0 \
  --policy.device=cuda --policy.push_to_hub=false \
  --output_dir=outputs/train/b8_u20k --job_name=b8_u20k
```

等价写法：`B_EFF=8 MICRO_BATCH=8 UPDATES=20000 WARMUP=666 EVAL_SPLIT=0.0 ./05-training/train_smolvla_v2.sh`

**`eval_split=0.0` 是刻意的**：老模型用 0.2，训练集只有 ep0–39，**第五个杯位（ep40–49）
从没训过**（§1 H4）。这次要的是一个能用的模型，所以 5 个位置全训。
代价是它与老模型多了一个变量 —— 想做干净的 batch 对照另开一轮（§6 R2）。

**不开增强**：这一轮相对老模型只改 batch 和 steps 两项，增强会变成第三项。

### 开跑后 3 分钟的决策

| 读到的 `step_s` | 20000 步要多久 | 怎么办 |
|---|---|---|
| ≤ 0.12 | ≤ 40 分钟 | 余量很大 —— 杀掉换 `--batch_size=16` 重开，更靠近官方配方 |
| 0.12 – 0.29 | 40–98 分钟 | **保持 batch 8，别动** |
| > 0.29 | > 98 分钟 | 杀掉换 `--batch_size=4` 重开 |

### 磁盘

每个存档 = model 906.7 MB + optimizer 412.7 MB ≈ **1.32 GB**；
`save_freq=2000` → 10 个 ≈ **13.2 GB**。租的机器盘小就改 `--save_freq=4000`（5 个 ≈ 6.6 GB）。
**下载时务必带上 `training_state/`**，否则以后不能续跑。

### 成功率能预期多少 —— 说实话，我给不出数字

现有的两个真实数据点是 **老模型 1/8**、**a100-6000 0/20**。
这一轮修的是**我们唯一确知坏掉的那一项**（S 从 0.30 变成 1.0），
但**位置覆盖的洞（§8.1）和换块跳变（§8.2）一点没动**，这两条重训解决不了。

**比成功率更早能看出苗头的指标**：闭爪时腰部转角的散布。
示范是 std 19.8、范围 −37…+27；a100-6000 是 std 6.4、**从没超过 +1.7**。
新模型只要在实机上开始出现 **腰角 > +6°** 的闭爪，就说明机制起效了 ——
这个信号会比成功率先出现。

---

## 〇·七、eval_split 该设 0.0 还是 0.2

**结论：这一轮（想要一个能用的模型）设 `0.0`。**
但先说清楚 —— **当初设 0.2 不是失误，是一个论证充分的决定**，只是它想要的回报没拿到。

### 先还原当初的理由（`SPLIT-DECISION.md`，2026-08-20）

那份文档明确选了 0.2，理由是：`datasets/factory.py:156` 按 task 取**最后** N%，
全部 50 集只有 1 个 task，杯位又是按顺序录的，于是 0.2 **恰好切出完整的第五个杯位**，
得到一个干净的 **leave-one-position-out**；而 0.3 会把第四个杯位劈成两半。
它还专门反驳了「按 5 个杯位分层、每位留 2 集」的方案：
分层 validation 的集来自同样那 5 个位置，**同分布的验证集恰恰看不见 position memorization**。

**这个推理是对的。** 它想要的是「不碰机器人就能回答：能不能应付没见过的位置」。

### 缺口在哪：那条验证曲线从来没被采集

- 两次训练的 `eval_steps` 都是 **0**，`max_eval_samples` 也是 0。
- `lerobot_train.py:748`：`is_eval_step = cfg.eval_steps > 0 and ...` —— **评估根本不会运行**。
- Jetson 训练日志里 eval loss 的行数是 **0**（唯一出现 "eval" 的地方是配置 dump）。

**所以：10 集被拿出去了，但 leave-one-position-out 的曲线一次也没算过。**
`eval_split` 只切分数据，**不会自己触发评估** —— 这是两个独立的开关，
当初的计划只拨了其中一个。

（这 10 集是哪些、影响是什么，`03-software/DATASET-LABELS.md` 第 47 行已经记过：
「整个『右近』点位没进训练 …… ep40–49 正好是右近」。本节是补上「为什么会这样」。）

### 事后又多了一条证据：这个指标即使采集了，也没有预测力

后来确实单独做过一次 leave-one-position-out 的离线评测：
未见位置 loss **0.2300** vs 训练位置 **0.2348**，比值 **0.98** —— 看起来完美。
**随后实机 0/10**（`CURRENT-STATUS.md`）。

也就是说，当初想用它回答的那个问题，它答错了。这不是当时能预知的，但现在知道了。

### 三种设法，按目的选

| 目的 | `eval_split` | `eval_steps` | 后果 |
|---|---|---|---|
| **要一个能用的模型** ✅ 本轮 | `0.0` | 0 | 50 集全训，5 个杯位都覆盖 |
| 真的要那条 leave-one-position-out 曲线 | `0.2` | 例如 1000 | 少 10 集，但这次曲线**真的会算**。注意它已被证明与实机成绩不相关 |
| 复刻 2026-08-20 那次 | `0.2` | 0 | 只在 §6 R2 那种对照实验里用 |
| ❌ 其它情况下的 `0.2` + `0` | — | — | 数据丢了，回报为零 |

### 为什么本轮选 0.0

1. 第五个杯位（ep40–49）**是你实机要测的位置之一**。不训它，那一位必然抓不到。
2. 那条验证曲线已被证明不预测实机成绩，**不值 10 集数据**。
3. 官方文档说同结构数据集 25 集就不够（`docs/source/smolvla.mdx:42`），从 50 砍到 40 是朝那个方向走。
4. 判据改用实机试验 —— 反正 `save_freq=2000` 会给 10 个存档，逐个测比一条 loss 曲线信息量大得多。

### 已做的改动

- **`train_smolvla_v2.sh` 默认改为 `EVAL_SPLIT=0.0`**，新增 `EVAL_STEPS`（默认 0）。
  加了守卫：`EVAL_SPLIT>0` 且 `EVAL_STEPS=0` 时**直接停下报错**，把三种设法打出来让人明选；
  确实要复刻历史就加 `ACK_WASTE=1`。**这道守卫正是为了堵住「只拨了一个开关」这个缺口。**
- **`train_smolvla.sh` 的默认值保持 `0.2` 不变** —— 它的作用就是复刻 2026-08-20 那次，
  改了默认就复刻不出来。但文件头加了显著警告，指向 v2 和本节。

### 两份旧文档的状态

- **`SPLIT-DECISION.md`（0.2 vs 0.3）**：推理仍然成立，**结论仅在"要做 leave-one-position-out"
  这个前提下成立**。本轮前提不同（要能用的模型），所以走 0.0。它没有被推翻，是适用范围被界定了。
- **`GOAL-NEXT.md` 第 62 行**写 v2 要用「`eval_split=0.2` 分层」：
  **`eval_split` 这个参数做不到分层** —— 它只会按 task 取最后 N 集（`factory.py:156`）。
  要按位置分层必须自己传 `--dataset.episodes` 显式指定训练集。
  而 `SPLIT-DECISION.md` 已论证分层对本问题无效。这一行应视为已被 `SPLIT-DECISION.md` 取代。

### 对 R2 的影响

§〇·五 的「备选」和 §6 的 R2 **仍然要 `eval_split=0.2` 且 `eval_steps=0`**，
因为它们的目的就是把训练集对齐到老模型的 ep0–39。
用 v2 脚本时写成 `EVAL_SPLIT=0.2 EVAL_STEPS=0 ACK_WASTE=1` —— **明知故犯，且是对的**。

---

## 一、根因分析（按可信度排序）

### H1 · 优化步数与曲线位置 —— 排名第一

| | 老 Jetson | 新 A100 |
|---|---|---|
| 实际优化更新次数 U | 16000 | **6000** |
| 曲线完成度 S | 0.80 | **0.30** |
| 停下时学习率 | 1.18e-05 | **7.99e-05** |
| Σlr（≈参数总位移预算） | 0.969 | 0.525 |

**实机结果（实测）**：老 `016000` 在 2026-08-24 03:33 那轮拿到 **1 成功 / 8 计次**
（`trials-20260824-033311.json`，另有 2 次不计）；新 `a100-6000` 两轮各 10 次
**全部失败（0/20）**（`trials-20260824-020220.json`、`-032029.json`）。
这是目前唯一一个非零成功率的 checkpoint。

**证据（实测）**：Hub 仓库 `Suyang99/xlerobot-smolvla-cup-grasp-right-a100-exp` 的
`checkpoints/006000/training_state/scheduler_state.json` 存的就是
`{"base_lrs":[0.0001], "last_epoch":6000, "_last_lr":[7.990453104925807e-05]}`。
老模型侧，`train_smolvla.log` 在 `step:18K` 处打的是 `lr:5.5e-06`。

**机制**：`optim/factory.py:41` 用 `cfg.scheduler.build(optimizer, cfg.steps)` 建调度器，
而 `optim/schedulers.py:149-153` 会在 `num_training_steps < num_decay_steps` 时
把曲线缩放到 `num_training_steps`。两次都写了 `--steps=20000`，于是整条 cosine 都摊在
20000 步上。**停在 6000 ≠ 完成了一次 6000 步训练，= 一条为 20000 步设计的曲线只走了前 30%。**

**与实机现象一致**：一个欠训练的策略会先学会所有示范的共同点、最后才学会必须看图才知道的
细节。实测的行为正是这个形状（§1.8 数字见下方 §2.5）。

**性质：假设。** 与全部现有数据一致，但只有两个样本、多个变量同时不同。

### H2 · batch 带来的梯度噪声差异（2 vs 64）—— 排名第二

**证据**：无直接证据。官方配方就是 batch 64（`docs/source/smolvla.mdx:60-61`），
所以 64 本身不是已知的坏值。

**机制（推理）**：固定 U 时，大 batch 的梯度更平滑，隐式正则更弱。
在 50 集、5 个位置、动作分布多峰的小数据集上，这可能让模型更倾向于回归条件均值。

**性质：假设，未测试。** §5 的 R2 就是为这条设计的。

### H3 · 数据重复遍数（2.02 vs 19.74 epoch）→ 过拟合

**证据（实测）**：老模型训练集 15,866 帧，`E = 2×16000/15866 = 2.02`；
新模型 19,453 帧，`E = 64×6000/19453 = 19.74`。

**反证**：新模型的失败形态是**输出坍缩到边缘均值**（欠定），不是**死板复刻训练轨迹**（记忆）。
腰部转角标准差被压到示范的 32%、手腕角落在全程均值上 —— 这更像欠拟合，不像过拟合。

**性质：可能性较低，但未排除。**

### H4 · 训练集组成不同（40 集/4 位置 vs 50 集/5 位置）—— 真实混淆变量

**证据（实测）**：`datasets/factory.py:156` —「每个 task 的**最后** `ceil(n×eval_split)` 集被留出」，
`eps[:len(eps)-n_eval]` 为训练集。数据集只有 1 个 task（`tasks.parquet`：
`"Pick up the cup and place it down"`），50 集、`eval_split=0.2` → `n_eval=10` →
**训练集 = ep0–39，留出 = ep40–49**。
训练日志原文佐证：`Train/eval split: 40 train, 10 eval (eval_split=0.2, 1 tasks)`。

结合本项目对示范数据的测量（§2.5），ep40–49 正是**最右侧那一组杯位**
（杯子在头部相机里的横向位置 0.84，抓取时腰部转角中位 +11.7°）。

**所以：表现更好的老模型，从来没有训练过第五个杯位。**

**方向判断**：新模型拥有**更多**数据、**更多**位置，却更差 —— 这个方向不支持
「数据不足导致新模型差」。但反向的读法也成立：多出来的第五个位置让动作分布更多峰，
在 U=6000 时更难分辨。两种读法都要留着。

**性质：混淆变量确实存在，但不太可能是主因。**
**后果：它让 §5 里「老模型 vs 新模型」的 batch 对照不再是单变量对照** —— 见 §5 的 R2。

### H5 · LeRobot 代码版本 —— 已排除

**证据（实测）**：
- 云端用的是 0.6.2 @ `22bd7a2f489b367d8df42de803b1e8c4ca63a3f9`（GOAL 文件给出）
- 本机 `/home/robomates/lerobot` 的 `git log` 正是 `22bd7a2f`（`v0.6.1-13-g22bd7a2f`）
- Jetson 训练脚本 `05-training/train_smolvla.sh` 用的是 `/home/robomates/lerobot310`，
  版本同为 0.6.2（非 git 检出，无 commit 号）

两棵源码树的关键文件逐字节比较：

| 文件 | 差异 |
|---|---|
| `optim/schedulers.py` | **0 行** |
| `optim/factory.py` | **0 行** |
| `policies/smolvla/configuration_smolvla.py` | **0 行** |
| `scripts/lerobot_train.py` | **0 行** |
| `configs/train.py` | **0 行** |
| `policies/smolvla/modeling_smolvla.py` | 3 行 —— 只是 `from typing_extensions import Unpack` vs `from typing import Unpack`（Python 3.10 兼容写法），无功能差异 |

**排除。**

### H6 · 部署/推理链差异 —— 作为「新旧差异的原因」已排除

两个 checkpoint 都经由同一个 `run_policy_trials.py` + `policy_safety.py` 执行；
同一条链上做的示范重放成功（`trials-20260824-014907.json`，`outcome: success`）。

**但**换动作块跳变（§6.2）对两个模型同等存在，是一个独立的、叠加的问题。

### H7 / H8 · 精度、种子、采样器 —— 已排除

`use_amp` 两边都是 `false`，`accelerator.mixed_precision` 两边都是 `"no"`，
`seed` 两边都是 1000，采样器代码逐字节相同。**均不在 183 字段 diff 的差异里。**

---

## 二、新旧配方逐字段对照

### 2.1 结构化 diff：183 个字段，6 处不同

| 字段 | 老 Jetson | 新 A100 | 性质 |
|---|---|---|---|
| `batch_size` | **2** | **64** | **影响优化行为** |
| `dataset.eval_split` | **0.2** | **0.0** | **影响训练集组成**（40 集 vs 50 集） |
| `num_workers` | 2 | 8 | 仅吞吐 |
| `eval.batch_size` | 4 | 50 | 无效（两边 `eval_steps=0`） |
| `job_name` | `smolvla_cup_grasp_right` | `ft` | 仅命名 |
| `output_dir` | 本机路径 | `/workspace/...` | 仅路径 |

**其余 177 个字段完全相同**，其中与优化直接相关的包括：

| 项 | 两边都是 |
|---|---|
| 基座 / 冻结 | `lerobot/smolvla_base`；`freeze_vision_encoder=True`、`train_expert_only=True` |
| 可训练规模 | 450.0M 总参数（446.8M BF16 + 3.3M F32），可训练 ≈ **51.6M（11.5%）**，由 `optimizer_state.safetensors` 413 MB ÷ 8 推得 |
| 优化器 | `adamw`，betas `[0.9, 0.95]`，eps `1e-8`，`weight_decay=1e-10`，`grad_clip_norm=10.0` |
| 学习率 | `1e-4` |
| 调度器 | `cosine_decay_with_warmup`，warmup 1000，decay 30000，peak 1e-4，end 2.5e-6（实际按 `--steps` 缩放到 warmup 666 / decay 20000） |
| `steps`（命令里写的） | 20000 |
| `save_freq` | 2000 |
| 梯度累积 | `accelerator.gradient_accumulation.steps = 1` |
| 精度 | `use_amp=false`，`mixed_precision="no"` |
| 种子 | 1000 |
| 图像增强 | `image_transforms.enable = false` |
| 解码器 | `video_backend = "pyav"` |
| rename_map | head→camera1、right_arm_wrist→camera2、left_arm_wrist→camera3 |
| 维度 | state 17 / action 17 |
| task | `"Pick up the cup and place it down"`（1 个 task） |
| chunk | `chunk_size=50`、`n_action_steps=50` |

### 2.2 两次都没跑到命令里写的 20000 步

- **老**：`train_smolvla.log` 末尾是
  `Training: 90%|█████████ | 18000/20000 [4:54:01<32:40, 1.02step/s]`，
  紧接 `SafetensorError: No space left on device`。**磁盘满崩的**。最后一个完整存档是
  `checkpoints/016000`（`last` 指向它）；`018000/pretrained_model/` 里只有 `config.json`，
  是崩在保存过程中的残骸，不可用。
- **新**：按 GOAL 文件，在 `006000` 完整存档后**主动停止**并送去实机测试。

### 2.3 派生量对照

| | 老 Jetson | 新 A100 |
|---|---|---|
| micro_batch × world × accum = **B_eff** | 2×1×1 = **2** | 64×1×1 = **64** |
| **U**（优化更新次数） | 16000 | 6000 |
| **samples_seen** = B_eff × U | 32,000 | 384,000 |
| 训练集帧数 | **15,866**（ep0–39） | 19,453（ep0–49） |
| **E**（effective epochs） | **2.02** | **19.74** |
| **S**（曲线完成度） | 0.80 | 0.30 |
| **Σlr** | 0.969 | 0.525 |
| 训练时长 | 4h54m（实测，1.02 step/s） | 未记录 |

### 2.4 LeRobot 的采样单位

`samples_seen` 的「sample」是**数据集的一帧**，不是一段轨迹：dataloader 的每个 item 对应
`data/chunk-000/file-000.parquet` 的一行（19,453 行），动作块通过 `delta_timestamps`
在该帧上向后取 `chunk_size` 步。所以上表的 `E = samples_seen / 训练集帧数` 用的是实现的真实定义。

### 2.5 实机行为的数字（用于判断失败形态）

来源：`trials-20260824-020220.json`、`-032029.json`（均为 a100-6000，各 0/10），
取每次闭爪瞬间的姿态，共 27 个样本。

| | 示范@抓取 | a100-6000@闭爪 |
|---|---|---|
| 腰部转角范围 | −37.2° … +26.9°（跨 64.2°） | −25.8° … **+1.7°** |
| 腰部转角标准差 | 19.8 | **6.4**（压到 32%） |
| 手腕转角均值 | −13.2° | **−3.7°**（≈全程均值 −5.2°） |

示范里 19/50 集抓取时腰部需转到 +6° 以上，模型 27 次一次都没到过。

**示范数据本身是干净的**：杯子在头部相机里的横向位置 vs 抓取时腰部转角，相关系数 **0.953**；
纵向位置 vs 肩部角度 **−0.846**；闭爪那 9 帧里肩/肘/腰的运动幅度中位数 0.42°/0.44°/0.43°。

五个杯位（按 episode 分组）：

| 组 | 杯子横向位置 | 杯子纵向位置 | 腰@抓取 | 老模型是否训过 |
|---|---|---|---|---|
| ep0–9 | 0.62 | 0.62（近） | −29.7° | 是 |
| ep10–21 | 0.56 | 0.46（远） | −27.7° | 是 |
| ep22–30 | 0.70 | 0.43 | −7.4° | 是 |
| ep31–39 | 0.82 | 0.53 | +10.5° | 是 |
| **ep40–49** | **0.84** | **0.66（近）** | **+11.7°** | **否（被 eval_split 留出）** |

---

## 三、量的定义（分开使用，不要混谈）

```text
micro_batch_size        每次前向的样本数（受显存限制）
world_size              数据并行进程数
accumulation_steps      累积多少个 micro-batch 才更新一次
effective_batch_size    = micro_batch_size × world_size × accumulation_steps
optimizer_updates  U    参数真正被更新的次数 = steps / accumulation_steps
samples_seen            = effective_batch_size × U
effective_epochs   E    = samples_seen / 训练集帧数
schedule_progress  S    调度器前进次数 / 曲线长度，跑满为 1.0
Sigma_lr                Σ lr(t)，≈ 参数空间总位移预算
```

**LeRobot 的两个实现细节，不知道会踩坑：**

1. **`--steps` 数的是 micro-batch，不是优化更新。**
   主循环 `scripts/lerobot_train.py:710` 是 `for _ in range(step, cfg.steps)`，
   一次迭代取一个 dataloader batch。所以 `U = steps / accumulation_steps`。

2. **调度器的长度用 `cfg.steps`（micro-batch 数）建，但只在优化更新时前进。**
   `optim/factory.py:41` 传的是 `cfg.steps`；而调度器被 `accelerator.prepare` 包装
   （`lerobot_train.py:553-559`），`AcceleratedScheduler.step()` 在 `sync_gradients`
   为假时直接返回（`accelerate/scheduler.py:61-64`）。
   而自动缩放**只会把曲线改短，不会改长**（`schedulers.py:149`：`if num_training_steps < num_decay_steps`）。
   所以准确的公式是：

   ```text
   曲线长度 = min(steps, scheduler_decay_steps)
   S        = U / 曲线长度  ，其中 U = steps / accum
   ```

   **结果：用累积 k 且不额外设置时 S 会小于 1**，具体是 `U / min(U×k, decay_steps)` ——
   只有在 `U×k < decay_steps` 时才刚好等于 `1/k`。修法见 §4.3。

**为什么 Adam 下 Σlr ≈ 参数总位移**：AdamW 的单步更新是
`lr × m̂/(√v̂+ε)`，其中 `m̂/(√v̂+ε)` 被二阶矩归一化到 O(1)，与梯度绝对大小基本无关。
所以「参数走了多远」主要由 Σlr 决定，而不是由看了多少样本决定。
这就是为什么 U 和 S 比 samples_seen 更能预测结果。

---

## 四、batch 缩放规则

### 4.1 学习率要不要跟着 batch 变

**不要照搬线性缩放。** 理由：

- `1e-4` 是 LeRobot 为 SmolVLA 微调设的默认值
  （`policies/smolvla/configuration_smolvla.py:74`），且官方示例正是拿它配 **batch 64**
  （`docs/source/smolvla.mdx:60-61`）。**它已经是在大 batch 上调好的值。**
- 线性缩放律（Goyal et al.）的适用前提是**从头训练、大数据集、SGD/带动量**。
  这里是 **51.6M 可训练参数的微调、19,453 帧的小数据集、AdamW**，三条前提都不满足。
- 本项目现有证据里，**唯一产出过可用模型的配置用的就是 `1e-4`**（batch 2）。

**规则**：`1e-4` 固定不动，**batch 往小调时继续用它是安全的**（梯度噪声略大，
小数据集上通常无害甚至有益）。**往大调时不要往上抬 lr** —— 官方已经在 batch 64 上验过 1e-4。

如果将来确实要搜 lr，**做保守搜索**：固定 U 和 S，只在 `{5e-5, 1e-4, 2e-4}` 三点上比，
用实机成功率判定，不用 loss。

### 4.2 缩放表（batch 2 / 4 / 8 / 16 / 64）

目标：**保持 U = 20000、S = 1.0 不变**，这样五行在优化行为上可比。

| micro_batch | accum | **B_eff** | lr | `--steps` | `--policy.scheduler_decay_steps` | warmup | **U** | samples_seen | **E**（/19453） | `--save_freq` | 显存类 |
|---|---|---|---|---|---|---|---|---|---|---|---|
| 2 | 1 | 2 | 1e-4 | 20000 | 不设（自动→20000） | 1000 | 20000 | 40,000 | 2.06 | 2000 | ~2 GB |
| 4 | 1 | 4 | 1e-4 | 20000 | 不设 | 1000 | 20000 | 80,000 | 4.11 | 2000 | 小 |
| 8 | 1 | 8 | 1e-4 | 20000 | 不设 | 1000 | 20000 | 160,000 | 8.22 | 2000 | 小 |
| 16 | 1 | 16 | 1e-4 | 20000 | 不设 | 1000 | 20000 | 320,000 | 16.4 | 2000 | 中 |
| 64 | 1 | 64 | 1e-4 | 20000 | 不设 | 1000 | 20000 | 1,280,000 | 65.8 | 2000 | 大 |

**注意**：这五行的 U 和 S 相同，但 **E 差 32 倍**。这是 batch 缩放**不可能同时**
对齐所有量的本质 —— 见 §4.4。

### 4.3 「micro-batch 2 + 累积 4」vs「物理 batch 8」

**梯度层面等价。** accelerate 的 `backward()` 会把 loss 除以
`gradient_accumulation_steps`（`accelerate/accelerator.py:2840`），所以 4 个
micro-batch 累出来的就是一个 8 样本 batch 的平均梯度。
剩下的差别只有 dropout 的随机性和每个 micro-batch 抽到的具体样本，
没有系统性偏差（SmolVLA 用 LayerNorm/RMSNorm，没有 BatchNorm 那种跨样本统计问题）。

**但两者的命令不一样**，因为 `--steps` 数的是 micro-batch：

| | 物理 batch 8 | micro 2 + 累积 4 |
|---|---|---|
| `--batch_size` | 8 | 2 |
| `accelerator.gradient_accumulation.steps` | 1 | **4** |
| `--steps` | 20000 | **80000** |
| `--policy.scheduler_decay_steps` | 不设 | **20000** ← 必须显式设 |
| 得到的 U | 20000 | 20000 |
| 得到的 S | 1.0 | 1.0（设了才是；不设只有 **0.67**） |

**不设 `scheduler_decay_steps` 的后果**：曲线长度按 80000 铺，却只前进 20000 次 →
**S = 0.667**，学习率停在 **2.69e-05**（峰值的 27%）—— **和 A100 那次失败是同一个形状，只是更隐蔽。**

> ⚠ 本文早先版本这里写的是「S = 0.25」，**那是错的**。`steps=80000` 不小于默认的
> `decay_steps=30000`，所以**不触发**自动缩放，曲线长度是 30000 而不是 80000。
> 0.25 只在 `steps < decay_steps` 时才成立（例如 micro 2 + 累积 4 + U=5000 →
> steps=20000 < 30000 → 曲线长 20000、前进 5000 次 → S 恰为 0.25）。一般式见 §3。
> 这个错误是在把计算逻辑写进面板计算器、逐条对数时发现的。
令 `--policy.scheduler_decay_steps=20000` 后：80000 > 20000 不触发缩放，
曲线长度 = 20000 = 实际更新次数 → **S = 1.000，终点 lr 2.50e-06，Σlr 0.975**。

`get_scheduler_preset()` 直接读这个字段（`configuration_smolvla.py:141-147`），
所以 CLI 能透传 —— **但仅限新训练；resume 时预设不重建，见 §6.2。**

### 4.4 四种「可比」的口径，选哪个

| 口径 | 含义 | 用在什么时候 |
|---|---|---|
| **A. 相同 U** | 参数被更新同样多次 | ✅ **本项目首选** |
| **B. 相同 samples_seen** | 看过同样多帧 | 数据是瓶颈时 |
| **C. 相同 E** | 数据重复同样多遍 | 关心过拟合时 |
| **D. 相同 S** | 曲线走到同样位置 | ✅ **必须和 A 同时满足** |

**本项目应该用 A + D。** 理由是本项目自己的数据：

- 老模型 **E = 2.02** 就产出了目前最好的结果；新模型 **E = 19.74** 反而更差。
  **说明 exposure（B/C）不是这里的限制轴。**
- Adam 下参数位移由 Σlr 决定（§3），而 Σlr 由 U 和 S 共同决定，与 batch 无关。
- 只对齐 A 不对齐 D，会把「更新够多」和「温度降到位」混在一起 —— 老模型 U=16000/S=0.80
  与「U=16000 但 S=1.0」是两个不同的点。

**只对齐 A+D 时，E 必然随 batch 变化**（§4.2 表里差 32 倍）。这是无法回避的，
所以 §5 的实验矩阵里 E 被显式列出来，作为已知的第二变量，而不是假装它不存在。

---

## 五、小数据集 / 模仿学习的特有风险

数据集只有 50 集 / 19,453 帧 / 5 个杯位。

| 风险 | 本项目的证据 | 判断 |
|---|---|---|
| 重复太多遍导致过拟合 | 新模型 E=19.74 vs 老 E=2.02 | 有风险，但新模型的失败形态是坍缩（欠定）不是复刻（记忆） |
| **大 batch 削弱梯度噪声 → 轨迹被平均** | 腰部转角标准差从 19.8 压到 6.4；手腕角落在全程均值 | **形态吻合**，但无法与 H1 分开 |
| 记住固定杯位而不是学会定位 | 示范里 corr(杯子图像位置, 关节角)=0.953，说明**信号存在**；离线留出位置评测比值 0.98 | 目前无位置记忆的直接证据 |
| **验证 loss 好看但实机差** | 留出位置 loss 0.2300 vs 训练位置 0.2348（比值 0.98），随后**实机 0/10** | **已证实。offline loss 在本任务上没有预测力** |
| 动作块策略对优化进度敏感 | 换块时肩部跳变 90 分位 23.8°、99 分位 64.2°，跳变间隔严格等于 `50/chunk_k` | 已证实，但这是部署问题（§8.2） |

**结论：验收指标只能是实机成功率。** 训练 loss 和留出 loss 都不能用来选 checkpoint。

---

## 六、推荐的接下来 3–5 次训练（按信息量/成本排序）

先说一条**零成本**的事实：那个 A100 checkpoint 的 `training_state/`（optimizer 413 MB、
scheduler、rng）**完整地留在 Hub 仓库里**（本地只下了 `pretrained_model/`，
所以看起来像没有）。所以 R1 只需付 14000 步的钱，不是 20000 步。

### R1 · resume 到 20000（**先做这个**）

```bash
lerobot-train \
  --resume=true \
  --config_path=Suyang99/xlerobot-smolvla-cup-grasp-right-a100-exp \
  --steps=20000 \
  --save_freq=2000 \
  --output_dir=outputs/train/a100_resume_20k
```

| 项 | 值 |
|---|---|
| micro_batch / accum / B_eff | 64 / 1 / 64 |
| lr | 1e-4（沿用 checkpoint） |
| U（终点） | 20000 |
| warmup / decay horizon | 666 / 20000（沿用） |
| samples_seen / E | 1,280,000 / 65.8 |
| 存档步 | 8000, 10000, …, 20000 |
| **实机测试** | **8000 · 12000 · 16000 · 20000** |
| 显存类 | A100 40/80 G |
| 成本 | 14000 步 ≈ +2.8 A100 小时（估） |

**信息量最高**：与已有的 `006000` 相比，**唯一变量是 U 和 S**（同 batch、同数据、同种子、
同代码）。它直接判定 H1。

**注意**：`--config_path` 可以直接写 Hub 仓库名，resume 会把最新 checkpoint 连同
`training_state/` 下载到新的本地 run 目录再续跑（`configs/train.py:222-258`）。

**resume 时什么都不要改**，`--steps` 也保持 20000：

- `configs/train.py:314` 是 `elif self.use_policy_training_preset and not self.resume:`
  —— **resume 时优化器/调度器预设不从 policy 配置重建**，`--policy.*` 改了不生效。
- 调度器仍由 `cfg.scheduler.build(optimizer, cfg.steps)` 构造，而 `--steps` 能被 CLI 覆盖。
  存下来的 `last_epoch=6000` 是这条长度 20000 的曲线上的点；**改了长度，
  恢复的位置就落到另一条曲线上，学习率会跳。**

**早停规则**：`12000` 存档实机测完如果与 `006000` 无差别，**停掉剩下的**，直接去 R3。

### R2 · batch 的干净对照（仅当 R1 显示 U/S 不是全部原因）

```bash
lerobot-train \
  --policy.path=lerobot/smolvla_base \
  --dataset.repo_id=Suyang99/xlerobot-cup-grasp-20260820-0230 \
  --rename_map='{"observation.images.head":"observation.images.camera1","observation.images.right_arm_wrist":"observation.images.camera2","observation.images.left_arm_wrist":"observation.images.camera3"}' \
  --batch_size=8 --num_workers=8 --steps=20000 --save_freq=2000 \
  --dataset.eval_split=0.2 \
  --policy.device=cuda --policy.push_to_hub=false \
  --output_dir=outputs/train/b8_eval02_20k --job_name=b8_eval02_20k
```

**关键：`--dataset.eval_split=0.2`（配 `--eval_steps=0`）。** 老模型用的就是这一组，训练集是 ep0–39。
这是全文**唯二**该用 0.2 的场合（另一处是 §〇·五 的备选），别处一律 0.0 —— 见 §〇·七。
只有把这项对齐，「老模型 vs 新模型」才是**只差 batch** 的对照（§1 H4）。

| 项 | 值 |
|---|---|
| B_eff | 8 |
| U / S | 20000 / 1.0 |
| samples_seen / E | 160,000 / 10.1（/15866） |
| **实机测试** | **16000（与老模型 U 对齐）· 20000** |
| 成本 | ≈ 0.5 A100 小时（估） |

选 batch 8 而不是 4：8 与老模型的 2 差 4 倍，足以看出梯度噪声的影响，
同时在 A100 上仍然很快。

### R3 · 补第五个杯位之外的数据（不是训练，是录制）

见 §8.1。**如果 R1 的所有存档都同样失败，直接跳到这里，不要再在超参上花钱。**

### R4 · 跑满官方配方（仅当 R1 和 R2 都指向「还能更好」）

`--batch_size=64 --steps=20000 --dataset.eval_split=0.0`，从 `smolvla_base` 从头训，
约 4 A100 小时（`docs/source/smolvla.mdx:50`）。这是官方原版，作为上限参考。

### 实验矩阵汇总

| # | 来源 | B_eff | U | S | E | eval_split | 与谁对照 · 唯一变量 | 成本 |
|---|---|---|---|---|---|---|---|---|
| **E0** | 老 Jetson `016000`（已有，实机 **1/8**） | 2 | 16000 | 0.80 | 2.02 | 0.2 | — 基线 | 0 |
| **E1** | `a100-6000`（已有，实机 **0/20**） | 64 | 6000 | 0.30 | 19.74 | 0.0 | — 基线 | 0 |
| **R1a** | resume → 12000 | 64 | 12000 | 0.60 | 39.5 | 0.0 | vs **E1**：只有 U/S | +1.2 h |
| **R1b** | resume → 20000 | 64 | 20000 | 1.00 | 65.8 | 0.0 | vs **E1**：只有 U/S | +2.8 h |
| **R2a** | batch8 · eval0.2 → 16000 | 8 | 16000 | 0.80 | 8.07 | **0.2** | vs **E0**：只有 batch 与 E | ≈0.4 h |
| **R2b** | batch8 · eval0.2 → 20000 | 8 | 20000 | 1.00 | 10.1 | 0.2 | vs **R2a**：只有 S | ≈0.5 h |

R1a/R1b 来自同一次训练；R2a/R2b 来自同一次训练。**总计两次训练，约 3.3 A100 小时。**

### 决策规则（先写死，避免事后找解释）

| 观察到 | 结论 | 下一步 |
|---|---|---|
| R1b ≫ E1，且随存档步数单调上升 | H1 成立：就是没训完 | 采用完整配方，转 R3 |
| R1a ≈ E1 且 R1b ≈ E1 | H1 不成立 | 做 R2 |
| R2a ≈ E0 | batch 无害 | 以后放心用大 batch 换速度 |
| R2a ≪ E0 | H2 成立：大 batch 有害 | 以后固定小 B_eff，用累积在大卡上复现 |
| **所有存档都同样失败** | 训练超参不是瓶颈 | **停止在训练上花钱**，转 §8 |

---

## 七、checkpoint 选择

**不要拿「batch 64 的第 6000 步」去比「batch 2 的第 16000 步」，因为 step 在两边不是同一个东西。**

**规则：存档节奏用 S（曲线完成度）表示，不用原始步数。**
设 `--save_freq = steps/10`，于是每个存档对应 S = 0.1, 0.2, …, 1.0。
跨 batch 比较时，比相同 S 的存档；跨 U 比较时，比相同 U 的存档。

| 比较对象 | 对齐什么 |
|---|---|
| 同一次训练的不同存档 | 天然对齐，直接看 U 的效应 |
| 不同 batch 的两次训练 | 对齐 **S**（都跑满则 S=1.0），并把 E 的差异写在旁边 |
| 与老模型 `016000` 比 | 对齐 **U=16000 且 S=0.80**，并且 `eval_split` 也要是 0.2 |

**每个存档的实机测法（统一）**：杯子摆训练用过的位置，每位 2 次；
记录成功/失败、**闭爪时腰部转角**、是否降到杯沿以下、失败类型。
腰部转角是重点 —— 它是目前唯一被量化证明坍缩的量（§2.5）。

---

## 八、训练超参之外的两个瓶颈

重训解决不了这两条。

### 8.1 位置覆盖有洞

五个杯位在头部相机画面里的横向位置：0.56 / 0.62 / 0.70 / **0.82** / 0.84。
**0.70 与 0.82 之间完全没有数据。** 实机试的「右边偏内」很可能正好落在这个区间。

而且老模型（`eval_split=0.2`）连 0.84 那组都没训过。

**补录建议**：位置连续采样而不是 5 个离散点，重点填 0.70–0.82 这一段。

**增强只能开颜色类，必须关掉 `affine`**：相邻杯位在画面里只差 **0.06**，
而 lerobot 默认 `affine` 的随机平移是 **±0.05**，量级相当。
平移增强教的是「画面挪了动作不变」，本任务要学的恰恰相反。

```
--dataset.image_transforms.enable=true \
--dataset.image_transforms.tfs.affine.weight=0
```

### 8.2 换动作块时的跳变

`n_action_steps=50` 意味着整块开环执行完才重新看画面。实测：模型每个控制周期肩部变化
中位数 3.98°，但 90 分位 **23.8°**、99 分位 **64.2°**，且大跳**严格周期性** ——
间隔恰为 `50 / chunk_k`，即换块时刻。示范里同样时长的窗口内，闭爪期间肩部只动 0.42°。

这是「一边抬一边夹」的直接来源。`config.json` 里 `rtc_config` 是 `null`；
已安装的 lerobot 有 `RTCProcessor`（`policies/rtc/modeling_rtc.py:38`），
重叠块混合是标准解法。

**顺序：先做 R1（训练配方），再动 8.1/8.2。** 顺序反了会分不清是谁的功劳。

---

## 九、每个模型必须存的元数据

以后不要再出现「不知道这个 checkpoint 是怎么训出来的」。至少存下：

```yaml
git_commit:            # 本仓库的 commit
lerobot_commit:        # 训练时 LeRobot 的 commit（非 git 检出就记版本号+来源）
base_model:            # lerobot/smolvla_base
base_revision:         # 基座的 revision/sha
dataset_repo_id:       # Suyang99/xlerobot-cup-grasp-20260820-0230
dataset_revision:      # 数据集的 commit sha
eval_split:            # 0.2 -> 训练集是 ep0-39，必须记下来
train_episodes:        # 实际训练的 episode 列表或范围
micro_batch:           #
accumulation_steps:    #
world_size:            #
effective_batch:       # = micro × accum × world
optimizer / lr / wd / betas / grad_clip:
scheduler / warmup / decay_horizon:
seed:
steps_configured:      # 命令里写的
steps_completed:       # 实际跑到
optimizer_updates_U:   # = steps_completed / accum
schedule_progress_S:   # = U / decay_horizon
samples_seen / effective_epochs:
precision:             # amp / dtype
image_transforms:      # 开了哪些、权重
hardware:              # 卡型号、显存
checkpoint_step:
physical_trials:       # 成功数/总数、每次的失败类型、闭爪腰角
```

**`train_config.json` 已经覆盖了大部分**（183 个字段），缺的是：
`steps_completed`、`U`、`S`、`lerobot_commit`、`dataset_revision`、
`train_episodes`、`hardware`、`physical_trials`。这几项要另外记。

---

## 十、标准流程（今后每次训练照这个走）

**开跑前（都是免费的）**

1. 看数据集大小和 task 数；确认 `eval_split` 会留出**哪些** episode（`factory.py:156`：每 task 的最后 n 集）
2. 定**优化配方**：`target_effective_batch`、`lr`、`target_U`、`warmup_ratio`、`scheduler`
3. 定**硬件执行档位**：`micro_batch`（按显存）、`accum`（补齐 B_eff）、`num_workers`、精度
4. 由 U 和 accum 推 `--steps = U × accum`；用了 accum 就**显式设 `--policy.scheduler_decay_steps = U`**
5. `--save_freq = steps/10`，让存档对应 S = 0.1…1.0
6. 确认磁盘 ≥ `存档数 × 0.9 GB`（上一轮就是磁盘满崩的）
7. 一次只改一个优化变量，并把改了什么写进 `job_name`

**开跑后前 3 分钟**

8. 读第一条完整日志：`step_s × steps` = 真实总时长。超预算就**现在**停掉重开
9. 看 `data_s` vs `updt_s` 判断瓶颈：`updt_s` 占大头 = 计算受限（加 batch 会线性变慢）；
   `data_s` 占大头 = 读数据受限（先加 `--num_workers`，此时加 batch 几乎不额外花时间）
10. 确认日志里的 `Train/eval split: N train, M eval` 与预期一致
11. 确认 warmup 结束后 `lr` 在 `~1e-4` 量级

**跑的过程中**

12. **不要中途停。** 停了就等于把一条为 N 步设计的曲线只走一部分 —— 这是本文全部问题的起点
13. 结束时确认最后一个存档里 `pretrained_model/` 和 `training_state/` 都在。
    只有 `training_state/` 齐全，以后才能 `--resume`

**跑完之后**

14. 每个存档都上机器测，**不要默认最后一个最好**
15. 按 §7 的统一测法记录；判据是实机成功率，**不是 loss**
16. 把 §9 的元数据连同结果一起存档
17. 按 §6 的决策规则决定：继续 / 停止 / 去补数据

---

## 十一、硬件档位

**同一份优化配方（B_eff=64、lr=1e-4、U=20000、S=1.0）在不同卡上跑，只改执行档位。**

| 档位 | 卡 | `--batch_size` | accum | B_eff | `--steps` | `--policy.scheduler_decay_steps` | `--num_workers` | 精度 | U / S |
|---|---|---|---|---|---|---|---|---|---|
| **A100 80G** | A100 / H100 | 64 | 1 | 64 | 20000 | 不设 | 8 | fp32（默认） | 20000 / 1.0 |
| **RTX 4090 24G** | 4090 / 3090 / A10 | 16 | 4 | 64 | **80000** | **20000** | 8 | 可试 bf16 | 20000 / 1.0 |
| **Jetson Orin 7.4G** | Orin Nano | 2 | 1 | **2** | 20000 | 不设 | 2 | fp32 | 20000 / 1.0 |
| **CPU-only** | — | — | — | — | — | — | — | — | 见下 |

**只影响吞吐/显存的参数**：`micro_batch`、`num_workers`、`prefetch_factor`、精度、
`video_backend`、`accum`（只要 B_eff 不变）。
**影响优化行为的参数**：`effective_batch`、`lr`、`U`、`warmup`、`decay_horizon`、
`seed`、`eval_split`、增强。
**换硬件时只准动第一组。**

配套脚本：`05-training/train_smolvla_v2.sh` 就是按这个分组写的 ——
`B_EFF / LR / UPDATES / WARMUP / EVAL_SPLIT / SEED` 是优化配方，
`MICRO_BATCH / WORKERS / WORLD` 是硬件档位，**`ACCUM` 由两者推导，不手填**；
`--steps`、`--save_freq`、`scheduler_decay_steps` 全部自动算出来并在开跑前打印。
`MICRO_BATCH` 不是 `B_EFF` 的因数时直接报错退出，不让它悄悄改掉 B_eff。

```bash
DRY=1 ./05-training/train_smolvla_v2.sh                 # A100:micro 64, accum 1, steps 20000
DRY=1 MICRO_BATCH=16 ./05-training/train_smolvla_v2.sh  # 4090: micro 16, accum 4, steps 80000
```

两条命令的 B_eff、U、S 完全相同 —— 这就是「换硬件不改学习问题」的操作保证。

**Jetson 档的诚实说明**：7.4 GB 共享内存下 `micro_batch=2`，累积到 32 步才能凑出
B_eff=64 —— 那意味着 `--steps=640000`，按实测 1.02 step/s 要 **170 多小时**，不现实。
**Jetson 上只能接受 B_eff=2**，即老模型那条路。这不是配置问题，是硬件上限。

**CPU-only**：**不现实，应只做推理。** 依据：Jetson 的 Orin GPU（sm_87）在
`micro_batch=2` 下 `updt_s=0.878`（`train_smolvla.log`），一次 20000 步的训练要 ~5 小时。
CPU 上 450M 参数模型的反向传播比这慢一到两个数量级，本机没有实测值，
但即使按最乐观的 10× 也要 50 小时以上。**结论：CPU 只跑推理，不训练。**

**显存参考点**：Jetson、`micro_batch=2`、`freeze_vision_encoder=True`、
`train_expert_only=True` 时日志报 `mem_gb:1.87`（全程恒定）；A100 上 `micro_batch=64` 可跑。
**中间档位没有实测值** —— 开跑后看头几条日志确认不 OOM 即可；
**OOM 就把 `micro_batch` 减半、`accum` 加倍**（B_eff 不变，训练结果不变，只是慢一点）。

---

## 十二、哪些是实测，哪些是推断

| 结论 | 依据 | 性质 |
|---|---|---|
| 183 字段里只有 6 处差异 | 两个 `train_config.json` 结构化 diff | 实测 |
| 代码版本混淆已排除 | 两棵源码树关键文件 diff：5 个文件 0 行差异，1 个文件仅 import 差异；`/home/robomates/lerobot` 的 commit 正是云端的 `22bd7a2f` | 实测 |
| 老模型只训了 ep0–39 | `datasets/factory.py:156` + 日志原文 `Train/eval split: 40 train, 10 eval` | 实测 |
| ep40–49 是最右侧杯位 | 50 集头部相机首帧的颜色检测 + 抓取帧关节角 | 实测 |
| 停下时 lr 7.99e-05 / S=0.30 | Hub 上 `scheduler_state.json` 的 `_last_lr` | 实测（保存的状态） |
| 老 run 因磁盘满崩在 18000 | `train_smolvla.log` 末尾 | 实测 |
| A100 checkpoint 可 resume | Hub 仓库文件清单含完整 `training_state/` | 实测 |
| 可训练参数 ≈51.6M / 450.0M | safetensors 头部统计 + optimizer_state 413MB÷8 | 实测（后者为推算） |
| 官方配方 batch 64 / 20000 步 / ~4 h | `docs/source/smolvla.mdx:50,60-61` | 实测（文档原文） |
| 官方参考数据集也是 50 集 / 5 位置 | 同上 :42 | 实测（文档原文） |
| `--steps` 数 micro-batch；累积会让曲线走不完，`S = U / min(U×k, decay_steps)` | `lerobot_train.py:710`、`:553-559`、`accelerate/scheduler.py:61-64`、`optim/factory.py:41`、`schedulers.py:149` | 实测（代码）+ 数值复算，**未实跑验证**。早先写成「恒为 1/k」是错的，已更正 |
| 累积在梯度层面等价于大 batch | `accelerate/accelerator.py:2840` | 实测（代码） |
| resume 时 policy 预设不重建 | `configs/train.py:314` | 实测（代码） |
| 闭爪时腰角散布压到 32%、从未超 +1.7° | 今天 20 次试验的 27 个闭爪时刻 | 实测 |
| 示范相关系数 0.953 / −0.846 | 50 集头部相机首帧 + 抓取帧关节角 | 实测 |
| 换块跳变周期 = 50 / chunk_k | 24 条 trace 的大跳间隔 | 实测 |
| offline loss 无预测力 | 留出位置比值 0.98，实机 0/10 | 实测 |
| **H1（没训完是主因）** | 与全部数字一致，但 n=2、三个变量同时不同 | **假设**，待 R1 裁决 |
| **H2（大 batch 有害）** | 形态吻合，无直接证据 | **假设**，待 R2 裁决 |
| batch 4/8/16 的时长与显存 | 从官方唯一实测点与 Jetson 实测点外推 | **估算**，开跑 3 分钟可校准 |
| CPU-only 不现实 | Jetson GPU `updt_s=0.878` 为唯一锚点，CPU 无实测 | **推断** |
| 平移增强有害 | 间距 0.06 vs 幅度 ±0.05 是实测；「因此有害」是推理 | 推断（依据实测） |
