一、第二课标准答案:Behavior Cloning
1. Behavior Cloning 为什么属于监督学习?
因为训练数据里已经有“输入”和“正确答案”。
对 BC 来说,一个最基本训练样本就是:
\[ (o_t,a_t) \]
其中:
- \(o_t\):Observation;
- \(a_t\):专家实际执行的 Action。
模型根据 \(o_t\) 预测:
\[ \hat a_t=\pi_\theta(o_t) \]
再把预测值:
\[ \hat a_t \]
和专家答案:
\[ a_t \]
比较。
所以本质就是:
\[ \boxed{\text{输入}\rightarrow\text{正确答案}} \]
和图像分类:
\[ \text{Image}\rightarrow\text{Class Label} \]
本质上属于同一种监督学习逻辑。
区别只是 BC 的标签不是“猫/狗”,而是机器人动作。
2. \(a_t\) 和 \(\hat a_t\) 有什么区别?
\[ a_t \]
是:
数据集中由专家真正执行过的 Action。
而:
\[ \hat a_t \]
是:
当前神经网络根据 Observation 预测出来的 Action。
例如专家动作是:
\[ a_t=[0.10,-0.05] \]
模型预测:
\[ \hat a_t=[0.07,-0.01] \]
训练的目标就是让:
\[ \hat a_t \]
越来越接近:
\[ a_t \]
3. 下面这条式子是什么意思?
\[ \tau=(o_0,a_0,o_1,a_1,\ldots,o_T,a_T) \]
它表示一条完整的:
Trajectory
也就是:
从任务开始到结束,按时间顺序记录下来的一整段 Observation 和 Action。
例如:
t = 0 o0 → a0 t = 1 o1 → a1 t = 2 o2 → a2 ... t = T oT → aT它不是一个样本,而是一串连续样本。
4. 为什么 Training Loss 很低,但 Rollout 可能很差?
因为训练时模型看到的主要是:
\[ D_{\text{expert}} \]
也就是专家操作产生的数据。
但真正运行时,机器人执行的是:
\[ \hat a_t \]
也就是模型自己的预测。
一旦模型出现一点点误差,机器人下一时刻所处的位置就可能和专家示范不同。
于是:
\[ o_{t+1} \]
开始偏离训练数据中的情况。
再继续预测,可能进一步偏离。
所以可能出现:
训练数据上预测很准 ↓ 第一次执行稍微有误差 ↓ 进入训练数据很少出现的状态 ↓ 模型预测变差 ↓ 进一步偏离因此:
\[ \boxed{\text{Training Loss低}\not\Rightarrow\text{Rollout一定成功}} \]
5. Distribution Shift 怎么理解?
最直观地说:
模型自己的动作,把自己带到了训练时没见过的位置。
例如专家示范永远非常稳定:
夹爪正对物体 ↓ 向前移动但模型第一次偏了 1 cm。
于是下一帧变成:
夹爪已经偏了 1 cm如果训练集中几乎没有这种情况,模型就不知道应该如何纠正。
可能继续偏:
1 cm ↓ 2 cm ↓ 4 cm ↓ 最终失败这就是:
Distribution Shift
6. Observation、Predicted Action、Expert Action、Loss 的关系是什么?
真正的数据流应该理解成:
Observation ↓ Policy ↓ Predicted Action ↘ 与 Expert Action 比较 ↓ Loss数学上:
\[ o_t \rightarrow \pi_\theta \rightarrow \hat a_t \]
然后:
\[ L(\hat a_t,a_t) \]
注意:
Expert Action 不是预测以后才生成的。
它本来就在 Dataset 里,只是在计算 Loss 时拿出来作为正确答案。
7. 如果
\[ obs.shape=[32,10] \]\[ action.shape=[32,7] \]
分别是什么意思?
这里:
\[ 32 \]
表示:
Batch Size
一次训练 32 个样本。
\[ 10 \]
表示每个 Observation 用 10 个数表示。
所以单个 Observation:
\[ o_i\in\mathbb R^{10} \]
\[ 7 \]
表示每个 Action 有 7 个数。
所以单个 Action:
\[ a_i\in\mathbb R^7 \]
整个 Batch 就是:
\[ O\in\mathbb R^{32\times10} \]\[ A\in\mathbb R^{32\times7} \]
二、插一个必须彻底弄懂的 Python 点:定义类、继承以后,__getitem__()为什么会“自己被调用”?
这个问题非常重要,因为你以后看 PyTorch、Dataset、模型代码,会遇到大量:
__init____len____getitem____call____iter__这种名字。
它们不是普通函数名。
Python 把这一类:
__xxx__称为:
Special Method
常被中文叫:
- 特殊方法;
- 魔术方法;
- 双下划线方法;
- dunder method。
其中 dunder 就是:
double underscore
1. 先从普通类开始
例如:
class Dog: def bark(self): print("wang")这里我们只是:
定义了一个类。
还没有真正创建对象。
真正创建对象:
dog = Dog()这里:
dog才是一个真正的对象。
然后:
dog.bark()Python 会执行:
Dog 类里的 bark()这是普通方法调用。
2.__init__()特殊在哪里?
例如:
class Dog: def __init__(self, name): self.name = name我们并不会通常这样写:
dog.__init__("Tom")而是:
dog = Dog("Tom")你写:
Dog("Tom")以后,Python 在对象创建过程中会自动调用初始化逻辑。
所以你可以先简单理解成:
Dog("Tom") ↓ Python创建Dog对象 ↓ 自动触发 __init__() ↓ 把 name 初始化进去这就是特殊方法的核心思想:
你通常不是直接调用它,而是进行某种 Python 语法操作,由 Python 自动寻找并调用对应的特殊方法。
3.__len__()也是一样
假设:
class MyDataset: def __len__(self): return 100我们一般不会写:
dataset.__len__()虽然这样通常也能执行。
更自然的是:
len(dataset)Python看到:
len(dataset)以后,会去找这个对象对应的:
__len__()所以可以粗略理解:
len(dataset)相当于触发:
dataset.__len__()于是:
len(dataset) ↓ Python检查对象 ↓ 发现类定义了 __len__ ↓ 调用 __len__ ↓ 返回1004. 那__getitem__()呢?
这就是 Robot Dataset 最重要的一个。
假设:
class MyDataset: def __getitem__(self, index): return index * 10创建对象:
dataset = MyDataset()现在写:
x = dataset[3]你看起来只是用了:
[3]但 Python 会把这种:
对象[index]语法解释为:
去调用这个对象的
__getitem__()。
也就是大致等价于:
x = dataset.__getitem__(3)因此:
dataset[3]会得到:
30完整调用关系:
dataset[3] ↓ Python发现你在对对象使用 [] ↓ 寻找 dataset 所属类中的 __getitem__ ↓ 调用 __getitem__(self, 3) ↓ 返回结果5. 为什么 PyTorch Dataset 要这样设计?
因为如果没有__getitem__(),你可能只能写:
dataset.get_sample(10)但定义__getitem__()后,就可以像操作普通列表一样:
dataset[10]这非常自然。
普通 Python list 也是:
numbers = [10, 20, 30]numbers[1]得到:
20所以 PyTorch Dataset 实际上是在让你的自定义 Dataset 具备:
“像一个数据容器一样按索引访问”
的能力。
6. 这和“继承”是什么关系?
PyTorch 中我们通常写:
from torch.utils.data import Datasetclass RobotDataset(Dataset): ...这里不是:
RobotDataset 里面创建了一个 Dataset 对象。
而是:
我们定义了一个新的类
RobotDataset,它继承Dataset。
也就是说:
Dataset ↑ RobotDatasetRobotDataset可以继承父类已有的一些行为和约定,同时自己实现:
__len__()和:
__getitem__()例如:
class RobotDataset(Dataset): def __len__(self): return ... def __getitem__(self, index): return ...这意味着:
我们按照 PyTorch Dataset 所期望的接口,告诉它“长度怎么算”和“某个索引的数据怎么取”。
7. DataLoader 怎么利用__getitem__()?
假设:
dataloader = DataLoader( dataset, batch_size=4)DataLoader 需要获取类似:
第7条 第18条 第2条 第31条数据。
于是内部逻辑可以先粗略理解成:
sample1 = dataset[7]sample2 = dataset[18]sample3 = dataset[2]sample4 = dataset[31]而每一个:
dataset[index]又会触发:
dataset.__getitem__(index)然后 DataLoader 再把这些 Sample 拼成一个 Batch。
所以整个关系是:
DataLoader ↓ 需要某几个索引的数据 ↓ dataset[index] ↓ __getitem__(index) ↓ 返回一个Sample ↓ 多个Sample组合成Batch这就是为什么以后看机器人 Dataset,我会让你重点找:
__getitem__()因为它经常直接决定:
模型训练时到底吃到了什么。
8. 再看self
例如:
class RobotDataset(Dataset): def __getitem__(self, index): obs = self.observations[index] return obs假设:
dataset = RobotDataset(...)再执行:
dataset[10]Python 实际上会把:
dataset这个对象本身传给:
self所以此时:
self就是:
dataset因此:
self.observations就是:
这个
dataset对象自己保存的observations。
你可以暂时理解:
dataset[10]触发:
RobotDataset.__getitem__(dataset, 10)这样你会更容易理解self。
9. 常见特殊方法先认识这几个
现在只需要掌握:
| 你写的语法 | Python 会触发 |
|---|---|
obj = MyClass(...) | 对象构造/初始化过程,常见__init__() |
len(obj) | obj.__len__() |
obj[index] | obj.__getitem__(index) |
obj(...) | obj.__call__(...) |
str(obj) | obj.__str__() |
以后还会遇到:
__iter__()__next__()__enter__()__exit__()但现在不用一次学完。
我们的原则还是:
真正遇到的时候,再结合代码讲。
VLA 系统学习第 3 课:从一次机器人示范,到真正送进神经网络的 Batch
前两课我们已经知道,Behavior Cloning 做的事情可以写成:
\[ o_t \rightarrow \pi_\theta \rightarrow \hat a_t \]
训练时,再把模型预测的 \(\hat a_t\) 和专家真正执行的 \(a_t\) 比较:
\[ L(\hat a_t,a_t) \]
但这里还有一个关键问题没有解决:
训练代码中的 \(o_t\) 和 \(a_t\),到底是怎么得到的?
它们不是凭空出现在model(obs)里的。
真正的数据链条是:
\[ \boxed{ \text{机器人示范} \rightarrow \text{Episode} \rightarrow \text{时间步数据} \rightarrow \text{Dataset} \rightarrow \text{Sample} \rightarrow \text{DataLoader} \rightarrow \text{Batch} \rightarrow \text{Policy} } \]
这一课只把这条链弄明白。
一、先从“一次完整操作”开始,而不是从 Dataset 类开始
假设人类遥操作机器人完成一次任务:
从桌面上拿起一个物体,再放到目标位置。
整个过程持续 5 秒。
假设控制系统每秒记录 10 次数据,也就是:
\[ f=10\text{ Hz} \]
那么 5 秒大约会得到:
\[ 5\times10=50 \]
个时间点。
可以把它想象成:
\[ t=0,1,2,\ldots,49 \]
在每一个时间点,系统都可能记录当前相机画面、机器人自身状态,以及操作者此时给出的动作。
于是某一个时间点 \(t\) 的数据可以写成:
\[ (I_t,s_t,a_t) \]
其中:
\[ I_t \]
表示这一时刻看到的图像,
\[ s_t \]
表示机器人这一时刻自己的状态,
\[ a_t \]
表示专家此时真正执行的动作。
所以,一个时间点实际上就是:
\[ \boxed{ \text{当前看到了什么} + \text{机器人现在是什么状态} \rightarrow \text{专家现在怎么操作} } \]
这正是 Behavior Cloning 需要的监督数据。
二、Episode 本质上就是这一整段时间序列
刚才那 5 秒从任务开始到任务结束的完整记录,可以称为一个:
Episode
也可以把它理解为一次完整任务示范。
如果这一次任务包含 50 个时间点,那么:
\[ \text{Episode}_0 = \{ (o_0,a_0), (o_1,a_1), \ldots, (o_{49},a_{49}) \} \]
这里为了简化,把:
\[ o_t \]
理解成整个 Observation,例如:
\[ o_t=(I_t,s_t) \]
于是:
\[ \text{Episode}_0 = \{ (o_0,a_0), (o_1,a_1), \ldots \} \]
如果操作者重新把环境复位,再完成一次任务,就是:
\[ \text{Episode}_1 \]
再做一次:
\[ \text{Episode}_2 \]
最终我们得到:
\[ \mathcal D = \{ \text{Episode}_0, \text{Episode}_1, \ldots, \text{Episode}_{N-1} \} \]
这整个集合就是机器人训练数据集。
这里要特别注意:
100 个 Episode,并不等于只有 100 个训练样本。
因为每个 Episode 里面还有几十、几百甚至更多个时间点。
例如每个 Episode 平均有 100 帧,那么:
\[ 100\text{ episodes}\times100\text{ frames} = 10000\text{ time steps} \]
最简单的 BC 完全可以把这些时间点分别作为训练样本。
所以真正的训练单位通常不是:
\[ \text{一个 Episode} \]
而可能是:
\[ (o_t,a_t) \]
三、为什么 Observation 和 Action 必须严格对应同一个时间点?
现在假设:
\[ t=17 \]
时,相机看到物体在夹爪右侧,同时机器人当前关节状态为 \(s_{17}\)。
专家看到这个情况之后,执行:
\[ a_{17} \]
那么正确的监督学习样本就是:
\[ (o_{17},a_{17}) \]
模型学习的是:
当我看到 \(o_{17}\) 这种情况时,专家会执行 \(a_{17}\)。
如果数据错位,变成:
\[ (o_{17},a_{30}) \]
模型学到的东西就彻底错了。
因为 \(a_{30}\) 是机器人后来处于另一个位置时执行的动作。
所以机器人数据不是简单的:
有图片,有动作就行。
而是必须保证:
\[ \boxed{ \text{Observation}_t \leftrightarrow \text{Action}_t } \]
这种时间对应关系非常重要。
这也是为什么机器人数据中经常能看到:
timestamp frame_index episode_index这些信息。
它们不是装饰性的元数据,而是在告诉程序:
这条数据属于哪一次任务、哪一个时间点。
四、现在才轮到 Dataset:它负责把磁盘数据变成“一个训练样本”
前面讲的都是数据在概念上的组织方式。
现在进入代码。
磁盘里的真实数据可能存成:
images/ video/ data.parquet metadata.json ...不同项目存法不同。
但是神经网络不能直接拿:
.jpg .mp4 .json去计算。
神经网络真正需要的是:
\[ \text{Tensor} \]
所以程序中通常需要一个 Dataset 类,负责完成:
\[ \boxed{ \text{磁盘中的原始数据} \rightarrow \text{读取} \rightarrow \text{预处理} \rightarrow \text{Tensor} \rightarrow \text{返回一个 Sample} } \]
先看最简单的教学版本:
import torchfrom torch.utils.data import Datasetclass RobotDataset(Dataset): def __init__(self, observations, actions): self.observations = observations self.actions = actions def __len__(self): return len(self.observations) def __getitem__(self, index): obs = self.observations[index] action = self.actions[index] return { "observation": obs, "action": action }这段代码现在不要孤立地背。
它实际上就是把前面的数学数据:
\[ \{(o_i,a_i)\}_{i=1}^{N} \]
包装成了一个 Python 对象。
dataset[index]到底发生了什么?
假设执行:
sample = dataset[10]Python 会调用:
dataset.__getitem__(10)然后拿出第 10 个样本:
\[ (o_{10},a_{10}) \]
最终返回:
{ "observation": obs, "action": action}所以:
__getitem__()以后是我们看真实 Robot Dataset 时非常关键的地方。
因为它能回答:
模型训练时真正拿到的到底是什么?
比如真实代码中可能返回:
{ "image": image, "state": state, "action": action}那么你就已经知道模型的数据入口至少包含:
\[ I_t,\quad s_t,\quad a_t \]
五、为什么原始图片还不能直接成为模型输入?
假设磁盘里有:
image_001.jpg它只是一个图片文件。
模型真正需要的可能是:
\[ I_t\in\mathbb R^{3\times H\times W} \]
所以 Dataset 读取图片以后,还要做预处理。
数据流可能是:
JPEG ↓ Decode ↓ Image Array ↓ Resize ↓ Normalize ↓ Tensor例如最后得到:
\[ I_t\in\mathbb R^{3\times224\times224} \]
这里的 224 只是教学示例,不代表所有 VLA 都是这个尺寸。
Robot State 也是一样。
原始状态可能是:
\[ s_t= [0.32,-0.41,1.08,\ldots] \]
代码可能对它做归一化:
\[ s'_t= \frac{s_t-\mu}{\sigma} \]
然后模型真正吃进去的是:
\[ s'_t \]
而不一定是原始 \(s_t\)。
Action 也可能做同样处理。
这件事情为什么重要?
因为训练时如果模型学习的是归一化 Action:
\[ a'_t \]
那么模型推理时输出的:
\[ \hat a'_t \]
也还是归一化空间里的数值。
真实执行之前可能必须再变回:
\[ \hat a_t \]
也就是:
\[ \hat a_t = \hat a'_t\sigma+\mu \]
所以以后不能看到模型输出:
0.42就直接说:
机器人移动 0.42 米。
必须先确认:
这个 Action 有没有经过 normalization?
六、一个 Sample 和一个 Batch 是两回事
假设 Dataset 中取出一个样本:
sample = dataset[10]它可能包含:
\[ image:[3,H,W] \]\[ state:[D_s] \]\[ action:[D_a] \]
注意这里没有:
\[ B \]
因为现在只有一个样本。
比如:
image.shape = [3, 224, 224] state.shape = [10] action.shape = [7]这代表:
一张图片,一份机器人状态,一个专家动作。
但是训练神经网络时,一般不会每次只训练一个样本。
比如我们希望一次拿:
\[ 32 \]
个样本。
这就需要:
DataLoader
七、DataLoader 的作用,是把多个 Sample 拼成一个 Batch
代码可能是:
from torch.utils.data import DataLoaderdataloader = DataLoader( dataset, batch_size=32, shuffle=True)这里:
batch_size=32表示:
每次取 32 个 Sample。
于是原来一个样本的:
\[ [3,H,W] \]
现在会变成:
\[ [32,3,H,W] \]
Robot State:
\[ [D_s] \]
变成:
\[ [32,D_s] \]
Action:
\[ [D_a] \]
变成:
\[ [32,D_a] \]
所以 Batch Dimension 并不是模型突然创造出来的。
它来源于:
把很多个独立 Sample 堆在一起。
也就是:
\[ \boxed{ \text{Single Sample} \rightarrow \text{Stack B Samples} \rightarrow \text{Batch} } \]
八、现在终于能看懂训练循环的数据是从哪里来的了
我们前面曾经看过:
for batch in dataloader: obs = batch["observation"] action = batch["action"] pred_action = model(obs) loss = loss_fn(pred_action, action)现在这几行不应该再像“突然出现的 PyTorch 代码”。
因为它前面实际上有完整的数据链:
专家操作机器人 ↓ 产生很多 Episode ↓ Episode 中记录 Observation 和 Action ↓ 数据保存到磁盘 ↓ Dataset 读取一条数据 ↓ __getitem__ 返回一个 Sample ↓ DataLoader 取 32 个 Sample ↓ 组成一个 Batch ↓ 送入 Policy于是:
obs = batch["observation"]实际上就是:
\[ O= \begin{bmatrix} o_1\\ o_2\\ \vdots\\ o_{32} \end{bmatrix} \]
而:
action = batch["action"]就是:
\[ A= \begin{bmatrix} a_1\\ a_2\\ \vdots\\ a_{32} \end{bmatrix} \]
模型一次预测:
\[ \hat A=\pi_\theta(O) \]
然后:
\[ L=L(\hat A,A) \]
这就是一整个 Batch 的 BC 训练。
九、把 Image 和 Robot State 都放进来
前面的obs还是一个抽象概念。
实际机器人 Policy 可能拿到:
\[ I_t \]
和:
\[ s_t \]
那么一个 Sample 可以写成:
\[ \text{Sample}_t= \{ I_t,s_t,a_t \} \]
例如一个样本:
\[ I_t:[3,H,W] \]\[ s_t:[D_s] \]\[ a_t:[D_a] \]
DataLoader 组成 Batch:
\[ I:[B,3,H,W] \]\[ S:[B,D_s] \]\[ A:[B,D_a] \]
然后模型:
\[ \hat A=\pi_\theta(I,S) \]
输出:
\[ \hat A:[B,D_a] \]
再和专家动作:
\[ A:[B,D_a] \]
计算 Loss。
于是 Shape 并不是一张独立的表格,而是自然来自数据流:
\[ \boxed{ \text{磁盘中的一个时间点} \rightarrow \text{Sample} \rightarrow \text{Batch} \rightarrow \text{Model Input} \rightarrow \text{Model Output} } \]
这才是以后真正应该使用的 Tensor Shape 思维。
十、为什么训练和真正运行时的数据来源不同?
这是这一课最后一个必须弄清的问题。
训练时:
\[ o_t \]
来自已经保存好的 Dataset。
也就是:
Disk Dataset ↓ Dataset ↓ DataLoader ↓ Policy而真正运行时:
\[ o_t \]
不再来自磁盘中的专家示范。
而是来自实时环境:
Camera / Robot Sensors ↓ Preprocessing ↓ Policy ↓ Predicted Action ↓ Execute所以训练阶段有:
\[ (o_t,a_t) \]
其中 \(a_t\) 是专家答案。
但推理阶段只有:
\[ o_t \]
然后模型自己产生:
\[ \hat a_t \]
这一点也解释了为什么部署不能简单写一句:
action = model(obs)就认为结束了。
因为真正部署时,你还必须重新实现训练阶段 Dataset 曾经帮你完成的那些事情:
读取图像 ↓ Resize ↓ Normalize ↓ 读取 Robot State ↓ Normalize ↓ 构造 Tensor ↓ Policy如果训练时用了:
\[ x'=\frac{x-\mu}{\sigma} \]
部署时却直接把原始 \(x\) 输入模型,
模型看到的数据分布就和训练阶段不同。
即使网络权重完全没变,结果也可能明显变差。
所以:
\[ \boxed{ \text{Training Preprocessing} \approx \text{Inference Preprocessing} } \]
这是一条非常重要的工程原则。
十一、这一课先不要继续扩展时间序列
现在你可能会继续想到:
如果模型不是只看当前一帧呢?
如果一次输出未来很多个 Action 呢?
这时候确实会出现:
\[ [B,T,3,H,W] \]
以及:
\[ [B,K,D_a] \]
还会逐渐遇到:
- Observation History;
- Action Chunk;
- Padding;
- Mask。
但这些概念这节课先不展开。
因为现在更重要的是先把最简单的:
\[ (o_t,a_t) \]
数据链彻底弄稳。
等进入 ACT 时,我们再从:
\[ a_t \]
自然扩展到:
\[ [a_t,a_{t+1},\ldots,a_{t+K-1}] \]
那时候 Action Chunk 就不会显得突然。
十二、整章链路回看
现在把这一课压成一条连续链:
\[ \boxed{ \text{Human Demonstration} \rightarrow \text{Episode} \rightarrow (o_t,a_t) \rightarrow \text{Save to Disk} \rightarrow \text{Dataset} \rightarrow \_\_getitem\_\_() \rightarrow \text{Sample} \rightarrow \text{DataLoader} \rightarrow \text{Batch} \rightarrow \text{Policy} } \]
如果一个 Sample 是:
\[ I_t:[3,H,W] \]\[ s_t:[D_s] \]\[ a_t:[D_a] \]
那么经过 DataLoader 组成 Batch 后:
\[ I:[B,3,H,W] \]\[ S:[B,D_s] \]\[ A:[B,D_a] \]
Policy 接收:
\[ (I,S) \]
输出:
\[ \hat A:[B,D_a] \]
然后计算:
\[ L(\hat A,A) \]
这就是 Behavior Cloning 中,从真实机器人示范一直走到神经网络训练输入的完整数据链。
第三课自测
1. 为什么 100 个 Episode 不代表 Dataset 只有 100 个训练样本?
2.dataset[10]和dataloader分别负责什么?
3. 如果单个图像 Shape 是:
\[ [3,224,224] \]
并且:
\[ B=32 \]
为什么进入模型以后会变成:
\[ [32,3,224,224] \]
?
4. 为什么训练时 Action 可以从 Dataset 里直接获得,而真正 Rollout 时却没有 Expert Action?
5. 为什么训练时做过 normalization,推理时通常也必须使用同样的 normalization?
6. 请尝试自己从头说一遍:
\[ \text{机器人示范} \]
究竟是怎样一步步变成:
pred_action = model(obs)中的obs的?