前言

  • 上一期我们在【模仿学习+强化学习】BC+PPO:基于A* 专家行为克隆下的无地图雷达导航探索 提到过,当今比较火热的几个方向就有Diffusion相关的生成式模型,于是便有了本系列
  • 本系列我们将从生成式模型的基础 VAE 开始,再到生成式模型的鼻祖 DDPM,再到 Diffusion Policy、Flow Matching 等一系列的算法解读与说明,我们会配合论文以及源码进行说明
  • 本文将使用 LIBERO 机器人操作数据集,分别从轨迹重构、潜空间可视化、随机生成、潜空间插值四个方面验证 VAE请添加图片描述

文章目录


1 生成式模型

1-1 什么是生成式模型

请添加图片描述

  • 机器学习模型按建模对象可以分成两类,这个划分几乎决定了模型能做什么、不能做什么
    • 判别模型(Discriminative Model)直接建模条件概率 p ( y ∣ x ) p(y\mid x) p(y∣x),输入 x x x 得到标签 y y y,它关心的是决策边界在哪
    • 生成模型(Generative Model)建模数据本身的分布 p ( x ) p(x) p(x),它关心的是数据长什么样
  • 两者的优化目标写成最大似然的形式,差别一目了然

判别模型: θ ⋆ = arg ⁡ max ⁡ θ ∑ i = 1 N log ⁡ p θ ( y i ∣ x i ) \text{判别模型:} \quad \theta^\star = \arg\max_\theta \sum_{i=1}^{N} \log p_\theta(y_i \mid x_i) 判别模型:θ⋆=argθmax​i=1∑N​logpθ​(yi​∣xi​)

生成模型: θ ⋆ = arg ⁡ max ⁡ θ ∑ i = 1 N log ⁡ p θ ( x i ) \text{生成模型:} \quad \theta^\star = \arg\max_\theta \sum_{i=1}^{N} \log p_\theta(x_i) 生成模型:θ⋆=argθmax​i=1∑N​logpθ​(xi​)

  • 一旦手里有了 p θ ( x ) p_\theta(x) pθ​(x),"生成一个新样本"这件事就变成了从分布里采样

x new ∼ p θ ( x ) x_{\text{new}} \sim p_\theta(x) xnew​∼pθ​(x)

说人话:判别模型学的是"怎么把东西分开",生成模型学的是"东西本身长什么样"。学会后者之后,我们就能照着学到的那套"长相",造一个原来不存在的新样本出来。

  • 这两个模型的能力差异,落到具体任务上非常直观
能力判别模型生成模型
分类 / 回归支持,这是主场需要绕道,通常不如判别模型直接
生成新样本做不到支持,这是主场
估计 p ( x ) p(x) p(x)不建模支持
检测异常样本要靠阈值凑天然支持,低概率即异常
数据补全 / 去噪要专门设计天然支持,在条件分布下采样
  • 我们这里的"生成"是概率意义上的生成,不是"复制训练集里的某一条"
  • 一个只会在训练样本之间做插值、甚至直接背下训练集的模型,不能算学会了分布
  • 判断标准是:从模型里采样出来的东西,放进真实数据里应当分辨不出来
1-2 核心思想
  • 生成模型的核心思想只有一句话:用一个参数化的分布去逼近真实数据分布
    • 真实数据分布 p data ( x ) p_{\text{data}}(x) pdata​(x) 是未知的,我们手上只有从它里面抽出来的有限个样本 { x 1 , x 2 , … , x N } \{x_1, x_2, \dots, x_N\} {x1​,x2​,…,xN​}
    • 我们要构造一个带参数 θ \theta θ 的分布 p θ ( x ) p_\theta(x) pθ​(x),让它在这些样本上"看起来"和 p data p_{\text{data}} pdata​ 一样
  • "看起来一样"最常用的度量是最大似然(MLE,Maximum Likelihood Estimation)

θ ⋆ = arg ⁡ max ⁡ θ ∏ i = 1 N p θ ( x i ) = arg ⁡ max ⁡ θ ∑ i = 1 N log ⁡ p θ ( x i ) \theta^\star = \arg\max_\theta \prod_{i=1}^{N} p_\theta(x_i) = \arg\max_\theta \sum_{i=1}^{N} \log p_\theta(x_i) θ⋆=argθmax​i=1∏N​pθ​(xi​)=argθmax​i=1∑N​logpθ​(xi​)

  • 取对数不改变极值点,但把连乘变成连加,数值上稳定得多
  • 条件生成把 p ( x ) p(x) p(x) 换成 p ( x ∣ c ) p(x\mid c) p(x∣c), c c c 是条件,可以是文本、图像、任务指令

θ ⋆ = arg ⁡ max ⁡ θ ∑ i = 1 N log ⁡ p θ ( x i ∣ c i ) \theta^\star = \arg\max_\theta \sum_{i=1}^{N} \log p_\theta(x_i \mid c_i) θ⋆=argθmax​i=1∑N​logpθ​(xi​∣ci​)

  • 举个例子:让模型根据任务指令 c = "把杯子放到盘子上" c = \text{"把杯子放到盘子上"} c="把杯子放到盘子上" 生成一段动作轨迹 x x x,建模的对象就是 p ( x ∣ c ) p(x \mid c) p(x∣c)
    • 难点在于 p θ ( x ) p_\theta(x) pθ​(x) 必须是一个"合法的概率分布",它的积分为 1,这个约束并不好直接施加在神经网络上

说人话:为了构造这个参数化的分布,使用最大似然去衡量这个分布对真实样本的准确性,我们需要建模对象 p ( x ∣ c ) p(x \mid c) p(x∣c),而通常我们使用神经网络来处理这个问题,但是神经网络训练出来的 p ( x ∣ c ) p(x \mid c) p(x∣c) 可能不一定满足积分为 1,因此有了下面几个方法

  • 各流派的分歧,全在于怎么表示 p θ ( x ) p_\theta(x) pθ​(x)
路线怎么表示 p θ ( x ) p_\theta(x) pθ​(x)代表方法
隐变量模型引入潜变量 z z z,对 z z z 积分得到 p ( x ) = ∫ p θ ( x ∣ z ) p ( z )   d z p(x) = \int p_\theta(x\mid z)p(z)\,dz p(x)=∫pθ​(x∣z)p(z)dz变分自编码器
对抗博弈不显式写 p θ ( x ) p_\theta(x) pθ​(x),用一个判别器间接逼它生成对抗网络
自回归分解用链式法则拆成 p ( x ) = ∏ t p θ ( x t ∣ x < t ) p(x) = \prod_t p_\theta(x_t \mid x_{<t}) p(x)=∏t​pθ​(xt​∣x<t​)生成式预训练变换器
扩散去噪学一个从噪声逐步还原数据的过程,反向过程即采样去噪扩散概率模型
  • 这一期我们走第一条路线,因为它最接近"概率"这两个字的字面含义,也最容易看清生成模型与自编码器的分界
1-3 常用的算法
  • 生成模型的家族谱系大致如下

生成模型

自编码器族

对抗族

自回归族

扩散族

AE
Autoencoder,自编码器

VAE
Variational Autoencoder,变分自编码器

GAN
Generative Adversarial Network,生成对抗网络

GPT
Generative Pre-trained Transformer,生成式预训练变换器

DDPM
Denoising Diffusion Probabilistic Model,去噪扩散概率模型

DDIM
Denoising Diffusion Implicit Model,去噪扩散隐式模型

Diffusion Policy
扩散策略

  • 上面这张图同时也是缩写对照表,后文一律只用缩写,不再重复展开

  • 这几个家族在"生成质量"、“训练稳定性”、"采样速度"三个维度上各有取舍

方法生成质量训练稳定性采样速度是否能算似然
VAE偏模糊稳定,单阶段一次前向,极快有下界
GAN锐利差,易崩一次前向,极快没有
自回归高稳定逐 token,慢精确
DDPM最高稳定几十到上千步,慢有下界
  • 这一期的 VAE 在生成质量上确实不如后面的 DDPM,但它有两个别人替代不了的价值
    • 它的潜空间是连续、可插值的,这一点在机器人领域比"图像清不清晰"重要得多
    • 它的训练目标是可解释的,重构项与正则项各管一件事,出了问题能定位到是哪一项
  • 换句话说,VAE 不是一个会被淘汰的过渡方案,它是后面所有隐变量生成模型的地基
1-4 生成式模型与VLA、具身智能
  • VLA(Vision-Language-Action,视觉-语言-动作)模型要解决的是"看懂指令、看懂画面、输出动作"这一整条链路,其中输出动作那一段,本质上是一个生成问题
  • 为什么不能把动作预测当成普通的回归问题?关键在动作分布是多峰的
  • 举个例子:机械臂前方有一个障碍物,绕左边和绕右边都能完成任务
    • 两条轨迹都是正确的,动作分布上就出现了两个峰
    • 如果用 MSE(Mean Squared Error,均方误差)去训一个回归网络,模型会输出两峰之间的平均值
    • 而平均值恰好是"从正中间撞上去",它不是任何一个峰,是一条根本不能执行的轨迹
  • 这不是调参能解决的问题,是目标函数本身的性质:MSE 的极小值点是条件均值,而我们要的是分布的一个样本
动作建模方式输出多峰分布下的行为
确定性回归 + MSE单个动作输出两峰平均,撞上障碍物
混合密度网络若干高斯能表达多峰,但峰数要预先定
隐变量生成模型(VAE)从一个潜变量解码采样到哪个峰就执行哪个峰
扩散模型(Diffusion Policy)从噪声迭代去噪采样到哪个峰就执行哪个峰
  • 这正是 Diffusion Policy 这一类工作把"动作预测"重新表述成"条件生成"的原因
  • 而要想真正读懂 Diffusion Policy 的损失函数、为什么它能采样、为什么潜空间/噪声空间是可插值的,最省事的入口就是 VAE
  • 本期我们就用一套真实可跑的实验把这条路走一遍,选用的载体不是图像,而是机器人动作轨迹
    • 数据用 LIBERO 数据集的 state / action,完全不使用视觉输入
    • 把连续动作序列切成固定长度窗口,当成 VAE 的建模对象
    • 用四个实验分别回答:能不能重构、潜空间有没有结构、能不能凭空采样、潜空间能不能插值
    • 最后把动作再积分回笛卡尔空间,看看机械臂末端到底走出了什么形状
  • 这四个实验合起来,回答一个问题:VAE 为什么是生成模型,而不是一个自编码器

2 AE

请添加图片描述

2-1 介绍
  • AE(Autoencoder,自编码器)是最简单的一类"编码-解码"结构,它的目标非常朴素:让输出等于输入

说人话:AE 是两个网络拼起来的一台机器,编码器负责把数据压成一段摘要,解码器负责照着摘要把数据复原;训练时两个网络一起调,目标是复原出来的尽量贴合原来的数据。生成的时候只用得上解码器——给它一段摘要,它就吐出一条数据。

  • 结构上由两部分组成,中间夹一个维度远小于输入的低维向量

z = f θ ( x ) , x ^ = g ϕ ( z ) z = f_\theta(x), \qquad \hat{x} = g_\phi(z) z=fθ​(x),x^=gϕ​(z)

  • 以防你忘记:
    • x x x:输入,比如一条 140 维的动作轨迹
    • z z z:潜变量(latent variable),编码器输出的低维表示,比如 16 维
    • x ^ \hat{x} x^:重构结果,维度与 x x x 相同
    • f θ f_\theta fθ​:编码器(Encoder),参数为 θ \theta θ
    • g ϕ g_\phi gϕ​:解码器(Decoder),参数为 ϕ \phi ϕ
  • 训练目标就是让 x ^ \hat{x} x^ 尽量等于 x x x

min ⁡ θ , ϕ ∑ i = 1 N ∥ x i − g ϕ ( f θ ( x i ) ) ∥ 2 \min_{\theta,\phi} \sum_{i=1}^{N} \lVert x_i - g_\phi(f_\theta(x_i)) \rVert^2 θ,ϕmin​i=1∑N​∥xi​−gϕ​(fθ​(xi​))∥2

注意这里没有标签, x x x 自己既是输入又是监督信号,属于自监督学习

  • 一个最小可跑的 AE,二十行就能写完
import numpy as np
import torch
import torch.nn as nn

# 先用一族正弦曲线合成 4000 条 (H=20, D=7) 的动作轨迹,
# 这样这一章的代码不依赖任何数据集,复制出去就能跑
rng = np.random.default_rng(0)
H, D, N = 20, 7, 4000
t = np.linspace(0, 1, H)[None, :, None]              # (1, H, 1)
amp = rng.uniform(0.3, 1.0, size=(N, 1, D))
freq = rng.uniform(1.0, 3.0, size=(N, 1, D))
phase = rng.uniform(0, 2 * np.pi, size=(N, 1, D))
traj = amp * np.sin(2 * np.pi * freq * t + phase)    # (N, H, D)

X = traj.reshape(N, -1).astype(np.float32)           # (N, 140),拉平
print(X.shape)                                       # (4000, 140)


class AE(nn.Module):
    def __init__(self, in_dim=140, hidden=64, latent=16):
        super().__init__()
        self.encoder = nn.Sequential(
            nn.Linear(in_dim, hidden), nn.ReLU(),
            nn.Linear(hidden, latent),                       # 压到 16 维
        )
        self.decoder = nn.Sequential(
            nn.Linear(latent, hidden), nn.ReLU(),
            nn.Linear(hidden, in_dim),                       # 还原回 140 维
        )

    def forward(self, x):
        z = self.encoder(x)
        return self.decoder(z), z


ae = AE()
opt = torch.optim.Adam(ae.parameters(), lr=1e-3)
xb = torch.from_numpy(X)

for step in range(2000):
    x_hat, _ = ae(xb)
    loss = nn.functional.mse_loss(x_hat, xb)
    opt.zero_grad(); loss.backward(); opt.step()
    if step % 500 == 0:
        print(f"step {step:4d}  mse {loss.item():.5f}")
  • 上面 traj.reshape(N, -1) 这一步就是后面反复出现的"拉平"操作,(N, 20, 7) 变成 (N, 140)
  • 反过来 x_hat.reshape(-1, H, D) 就还原成"20 步、每步 7 维"的动作序列
  • 反复用到的切片写法还有这几种,语义完全不同,别混
a = traj[0]              # (20, 7),一条完整的轨迹
a[:, :3]                 # (20, 3),每一步的前三维,即末端位移增量 dpos
a[:, 3:6]                # (20, 3),中间三维,即末端姿态增量 drot
a[:, 6]                  # (20,),最后一维,夹爪开合,切片后维度被压掉
a[:, 6:7]                # (20, 1),用冒号保住这一维,不压缩
a[0]                     # (7,),第 0 步的动作
a[:, 0]                  # (20,),所有步的第 0 维
  • 注意 a[:, 6] 与 a[:, 6:7] 的区别:前者把最后一维压掉了,形状从 (20, 7) 变成 (20,),再想拼回去就得用 a[:, None] 补一维
  • 这个坑在拼接多路特征时非常常见,后面第 3 章划分 mu 与 logvar 时还会再遇到一次

说人话:AE 就是"先把东西压缩成一小段摘要,再照着摘要把它复述出来"。中间那段摘要就是潜变量,压缩得越狠,摘要里留下的就越只是最关键的骨架。

2-2 应用场景
  • AE 本身很少作为最终产品,但它是很多实用方法的内核
场景做法依赖 AE 的哪一部分
数据压缩只传潜变量,接收端解码编码器 + 解码器
降维可视化把潜变量降到二维画散点只用编码器
去噪输入加噪样本,监督信号用干净样本编码器 + 解码器
异常检测重构误差大的样本判为异常重构误差本身
特征预训练用编码器初始化下游网络只用编码器
缺失补全只对缺失位置算重构损失重构损失的位置加权
  • 在机器人领域,AE 最常见的用法是把高维观测压成低维状态,再用这个低维状态做控制或规划
  • 但要注意:这种用法只用到编码器,用的是 AE 的降维能力,而不是生成能力
2-3 Encoder / Decoder
  • 编码器把输入映射到潜空间,解码器把潜空间映射回输入空间

f θ : R d x → R d z , g ϕ : R d z → R d x f_\theta: \mathbb{R}^{d_x} \to \mathbb{R}^{d_z}, \qquad g_\phi: \mathbb{R}^{d_z} \to \mathbb{R}^{d_x} fθ​:Rdx​→Rdz​,gϕ​:Rdz​→Rdx​

  • 在我们的轨迹任务里, d x = 140 d_x = 140 dx​=140, d z = 16 d_z = 16 dz​=16,压缩比接近 9 比 1
    • 两个网络通常用对称的 MLP(Multi-Layer Perceptron,多层感知机),但不要求对称,可以各自独立设计
    • 具体到代码,编码器与解码器都是两层线性加 ReLU(Rectified Linear Unit,修正线性单元)
  • 有一处工程细节值得单独说:解码器的最后一层不加激活函数
# 对应对上文的 f_theta 与 g_phi
class Encoder(nn.Module):
    def __init__(self, in_dim=140, hidden=64, latent=16):
        super().__init__()
        self.net = nn.Sequential(
            nn.Linear(in_dim, hidden), nn.ReLU(),        # 中间层必须有非线性
            nn.Linear(hidden, latent),
        )

    def forward(self, x):
        return self.net(x)


class Decoder(nn.Module):
    def __init__(self, latent=16, hidden=64, out_dim=140):
        super().__init__()
        self.net = nn.Sequential(
            nn.Linear(latent, hidden), nn.ReLU(),
            nn.Linear(hidden, out_dim),                  # 最后一层不加激活
        )

    def forward(self, z):
        return self.net(z)
  • 这里要强调一个容易被忽略的性质:AE 的编码是确定性的
    • 同一个输入 x x x,无论编码多少次,得到的 z z z 一模一样,没有任何随机性
    • 这个性质在第 2-7 节会变成 AE 生成能力的致命伤
2-4 Latent Space(潜空间)

请添加图片描述

  • 潜空间就是所有可能的 z z z 构成的 d z d_z dz​ 维空间,它是 AE 对数据的一种内部坐标系
  • 为什么要在意它?因为生成模型的所有操作都在这个空间里发生
  • 一个 16 维的 z 我们没法直接看,但可以切出前两维画散点图,这是唯一能肉眼观察潜空间的手段
with torch.no_grad():
    Z = ae.encoder(xb).numpy()          # (4000, 16)

pca_x, pca_y = Z[:, 0], Z[:, 1]         # 切片取前两维,降成二维散点
# 更规范的做法是先做 PCA 再取前两维,见第 4 章的潜空间实验
  • 切片的语义在这里很关键:Z[:, 0] 取的是所有样本的第 0 维,Z[0, :] 取的是第 0 个样本的所有维
    • 前者是"某一维潜变量在数据集上的取值分布",后者是"某一条轨迹的潜编码",两个完全不同的东西
    • 如果这 16 维里有一维对重构几乎没有贡献,它在潜空间里就会退化成一个常数,Z[:, k] 切出来是一条水平线
  • 这个现象叫维度塌缩,第 3-9 节会看到它在 VAE 里同样存在,而且更隐蔽

说人话:潜空间就是 AE 给自己建的一套"坐标系"。原本 140 个数字描述一条轨迹,现在用 16 个数字描述,这 16 个数字就是这条轨迹在这套坐标系里的坐标。

2-5 Reconstruction Loss
  • 最常用的重构损失是 MSE,也就是逐元素的平方误差求和

L recon = ∥ x − x ^ ∥ 2 = ∑ j = 1 d x ( x j − x ^ j ) 2 \mathcal{L}_{\text{recon}} = \lVert x - \hat{x} \rVert^2 = \sum_{j=1}^{d_x} (x_j - \hat{x}_j)^2 Lrecon​=∥x−x^∥2=j=1∑dx​​(xj​−x^j​)2

  • 它可以从概率角度解释:假设 p θ ( x ∣ z ) p_\theta(x \mid z) pθ​(x∣z) 是各维独立的高斯分布,且方差固定,那么负对数似然就是 MSE

− log ⁡ p θ ( x ∣ z ) = 1 2 σ 2 ∥ x − x ^ ∥ 2 + const -\log p_\theta(x \mid z) = \frac{1}{2\sigma^2}\lVert x - \hat{x}\rVert^2 + \text{const} −logpθ​(x∣z)=2σ21​∥x−x^∥2+const

  • 也就是说,用 MSE 训 AE,等价于在做一个固定方差的高斯极大似然
  • 如果数据是二值的,比如黑白像素,独立高斯的假设就不对了,应该换成 Bernoulli 分布,损失相应变成二元交叉熵
数据类型似然假设损失函数常见用途
连续实数高斯,方差固定MSE动作、坐标、图像回归
连续实数高斯,方差可学高斯负对数似然需要模型自己权衡各维
二值Bernoulli二元交叉熵黑白图像、掩码
计数Poisson泊松负对数似然脉冲计数、点云点数
  • 有一处细节必须说清楚:MSE 要在哪些维度上求平均、在哪些维度上求和,不是随意的
  • 我们的实现是在 140 个输出维度上求和,再对 batch 求平均
# x_hat 与 x 的形状都是 (B, 140)
rec = nn.functional.mse_loss(x_hat, x, reduction="none").sum(dim=-1).mean()
#                                                        ^^^^^^^^^^^ 在 140 维上求和
#                                                                    ^^^^^^ 对 batch 求平均
  • 之所以在 140 维上求和而不是求平均,是为了让两项损失的量纲可比(见第 3-7 节)
  • 这里提前用到了 KL(Kullback-Leibler divergence,KL 散度)这一项,它的作用是把潜空间往先验上拉,具体推导在第 3-6 节
  • 如果这里写成 .mean(dim=-1),重构项会被缩小 140 倍,正则项就会在整个损失里占据压倒性地位,模型直接塌缩
2-6 AE为什么可以降维

请添加图片描述

  • 核心假设是流形假设:虽然数据住在 140 维空间里,但它们实际上只分布在一个低维曲面上
    • 140 维空间里随便取一个点,几乎一定不是合法动作轨迹,因为相邻两步的动作必须连续、各维之间还有物理约束
    • 真正合法的轨迹集合,其内在维度远小于 140
  • 编码器的任务就是把这个低维曲面参数化, z z z 是曲面上的一组合适坐标
    • 在最简单的情形下,这个结论可以写得很干净
  • 如果编码器和解码器都是线性的,并且用 MSE 训练,那么 AE 学到的子空间与 PCA 张成的子空间一致
    • 设 Z = X W Z = XW Z=XW, W W W 是 d x × d z d_x \times d_z dx​×dz​ 的投影矩阵
    • 重构是 X ^ = Z W ⊤ = X W W ⊤ \hat{X} = ZW^\top = XWW^\top X^=ZW⊤=XWW⊤
    • 最小化 ∥ X − X W W ⊤ ∥ F 2 \lVert X - XWW^\top\rVert_F^2 ∥X−XWW⊤∥F2​ 的解,正是取 X X X 的前 d z d_z dz​ 个主成分
  • 这就解释了一件事:不做非线性的话,AE 就只是 PCA
    • 反过来说,AE 相对 PCA 的全部优势,都来自中间那几层非线性激活
  • 举个数:同样压到 16 维,线性 PCA 的重构 R 2 R^2 R2 是 0.957,非线性 AE 是 0.974,多出来的这 1.7 个点就是非线性的贡献
    • 作为对照, β = 1 \beta = 1 β=1 的 VAE 只有 0.948,比 PCA 还低。这也正常:VAE 额外背着一项 KL,是拿重构精度去换潜空间的规整(第 3-7 节)

说人话:140 维听起来很大,但真正合法的动作轨迹只占其中很小很小的一块。AE 干的事就是找出这块"合法区域",然后用 16 个坐标把它标出来。

2-7 AE为什么不能很好地生成
2-7-1 分析
  • 这是全篇最关键的一节,因为它解释了 VAE 存在的全部理由
  • AE 的训练目标里,只有"让 x ^ \hat{x} x^ 接近 x x x"这一条,它从来没要求潜空间本身有什么形状
  • 于是训练完之后,潜空间会变成一个很怪的样子
    • 训练样本的编码点 z i z_i zi​ 密密麻麻地贴在某个曲面上
    • 但这些点之间是空的,没有任何约束,解码器在那里没被训练过
    • 更糟的是,这些点可能分布得极不均匀,有的区域挤成一团,有的区域稀稀拉拉
  • 这带来一个直接后果:从潜空间随便采一个点解码,得到的多半是垃圾
2-7-2 验证
  • 我们可以直接跑一段代码验证,用第 2-1 节训好的 ae
with torch.no_grad():
    z_rand = torch.randn(8, 16)                    # 直接按 N(0, I) 采样
    x_rand = ae.decoder(z_rand).numpy().reshape(8, H, D)

print("AE 采样的动作幅度  :", np.abs(x_rand).std())
print("真实数据的动作幅度  :", np.abs(traj).std())
  • 在合成数据上跑这段代码,两个数字会差出一个量级,采样出来的"轨迹"要么幅度爆炸,要么塌成一条直线
  • 原因很直白:AE 没有定义过 p ( z ) p(z) p(z)
    • 训练完之后,潜空间上的点确实有一个分布,但那是训练样本的编码点堆出来的经验分布
    • 这个分布既没有解析形式,也没有被任何一项损失约束过
    • 我们甚至不知道它是不是高斯的,更不知道它的均值和方差
  • 所以 AE 想生成,只能退而求其次,去潜空间里插值,或者用训练集的编码点拟合一个分布再采样,两步走、还不可靠
  • 还有一个隐性的问题:AE 的潜空间是不连续的
    • 编码器是一般的非线性函数,它没有理由把相邻的 x x x 映射到相邻的 z z z
    • 两个语义上很像的轨迹,编码点可能隔得很远;反过来,两个编码点很近,中间解码出来可能是完全无意义的动作
  • 把这几条放在一起,AE 的三处硬伤就很清楚了
问题具体表现根因
没有先验不知道该从哪里采样损失里没有 p ( z ) p(z) p(z) 这一项
潜空间有空洞采样点落在未训练区域,解码出垃圾只约束了离散的编码点,没约束点与点之间
潜空间不连续相邻 z z z 解码出的轨迹可能突变编码器无平滑性约束
2-7-3 解决方法
  • 那怎么办?思路很自然:给潜空间加约束,逼它长得像一个我们能采样的分布
    • 最省事的选择就是标准正态分布 p ( z ) = N ( 0 , I ) p(z) = \mathcal{N}(0, I) p(z)=N(0,I),因为它好采样、有解析形式、还是各维独立的
    • 但"加约束"具体怎么加?如果只是加一项 ∥ z ∥ 2 \lVert z \rVert^2 ∥z∥2 的正则,那还是只约束了单个点,点与点之间的空洞依然存在
  • 真正的解法是把编码从"一个点"换成"一个分布",这就是 VAE

说人话:AE 把每条数据在潜空间里钉了一个钉子,但它不管钉子之间的空地。我们从空地上随便取一个点,解码器根本没见过这种输入,输出的自然是垃圾。VAE 要做的事,就是把这些钉子换成一片连续的"云雾",让整片区域都有定义。


3 VAE

请添加图片描述

3-1 介绍
  • VAE(Variational Autoencoder,变分自编码器)由 Kingma 与 Welling 在 2013 年提出,论文是 Auto-Encoding Variational Bayes
  • 它要解决的就是上一节末尾留下的问题:让潜空间变成一个可以被采样的概率分布

一句话概括 VAE 与 AE 的区别:AE 把 x x x 编码成一个点,VAE 把 x x x 编码成一个分布

  • 结构上的改动其实很小,只是编码器的输出从一路变成两路

输入 X
(140,)
20 步 x 7 维拉平

Encoder
140 - 64

隐层 h
(64,)

fc_mu
得到 mu (16,)

fc_logvar
得到 logvar (16,)

重参数化
z = mu + sigma * eps
(16,)

Decoder
16 - 64 - 140

输出 X_hat
(140,)
重塑回 (20, 7)

  • 但因为输出从"一个点"变成了"一个分布",整个模型的性质跟着变了
对比项AEVAE
编码器输出一个确定的 z z z一个分布 q ϕ ( z ∣ x ) q_\phi(z\mid x) qϕ​(z∣x),由 μ \mu μ 与 σ \sigma σ 给出
潜空间有先验吗没有有, p ( z ) = N ( 0 , I ) p(z) = \mathcal{N}(0, I) p(z)=N(0,I)
损失函数只有重构项重构项 + KL 正则项(3-6 讲到)
采样前向编码器编码器加采样
采样后向(生成)不可以可以, z ∼ N ( 0 , I ) z \sim \mathcal{N}(0,I) z∼N(0,I) 直接解码
潜空间连续性无保证由 KL 项逼出来
是不是生成模型不是是
3-2 应用场景
  • VAE 在工业界的落地场景比想象中多,因为它最大的优势是潜空间规整、可插值、可解释
场景为什么用 VAE 而不是 AE
人脸属性编辑潜空间有语义方向,可以沿某个方向移动
分子生成化学空间连续,插值出来的分子大多仍然合法
图像超分 / 修复缺失区域可以从先验采样补全
语音转换内容与音色可以在潜空间解耦
异常检测用 ELBO 而不是重构误差,判别更稳
机器人技能学习把一段演示压成技能潜变量,再用强化学习在这个低维空间里学
动作轨迹生成与我们这一期做的事完全一致
  • 在机器人领域,VAE 最典型的用法是把高维动作序列压成低维技能向量,也就是常说的"skill latent"
  • 这么做的收益很直接:
    • 强化学习要在动作空间里探索,维度越高越难;换成 16 维的 z z z 之后,探索空间一下小了两个数量级
    • 解码器天然保证了输出的动作是连贯的轨迹,不会出现单步乱跳的情况
    • 不同任务的轨迹在潜空间里是连续过渡的,意味着技能之间可以插值出中间技能
3-3 为什么需要概率潜变量
  • 回到出发点,问一个问题:为什么非得是分布,给 z z z 加个正则不行吗?
    • 答案是不行,原因在于"点"和"区域"的差别
    • 一个确定性的 z z z 只定义了潜空间里的一个点,训练时也只有这些点被梯度触碰过
    • 一个分布 q ϕ ( z ∣ x ) = N ( μ ϕ ( x ) , σ ϕ ( x ) 2 ) q_\phi(z\mid x) = \mathcal{N}(\mu_\phi(x), \sigma_\phi(x)^2) qϕ​(z∣x)=N(μϕ​(x),σϕ​(x)2) 定义的是以 μ \mu μ 为中心、 σ \sigma σ 为半径的一整块区域
    • 每一个训练样本,都在训练它周围的一整片区域,而不是一个孤立的点

说人话:我们需要数据之间的分布是有关联的,因此训练的时候最好一个点也会影响到周围的点,而不是只为了满足一个点去加正则(所谓正则,就是在损失函数里额外加一项惩罚,用来约束模型的行为,不让它只顾着拟合手上的数据)

  • 这个差别带来三个直接后果
    • 潜空间里不再有空洞,每个训练点的邻域都被它的 σ \sigma σ 覆盖到了
    • 相邻的训练点如果 μ \mu μ 靠得近,它们的分布会互相重叠,重叠区域的解码结果被两边的样本同时约束,自然就平滑了
    • 因为每个 x x x 都对应一片区域,我们可以从任意位置采样,采样出来的 z z z 大概率落在某个样本的覆盖范围里
  • 再加一项约束,把全体编码分布都往标准正态上拉,整个潜空间就被"压"成了我们想要的样子(这一项就是 KL 散度,3-6 讲到)

L KL = D K L ( q ϕ ( z ∣ x )   ∥   p ( z ) ) , p ( z ) = N ( 0 , I ) \mathcal{L}_{\text{KL}} = D_{KL}\big(q_\phi(z\mid x)\,\Vert\,p(z)\big), \qquad p(z) = \mathcal{N}(0, I) LKL​=DKL​(qϕ​(z∣x)∥p(z)),p(z)=N(0,I)

  • 注意这里的作用是双向的
    • 它把每个 q ϕ ( z ∣ x ) q_\phi(z\mid x) qϕ​(z∣x) 往先验上拉,逼潜空间填满先验所在的区域,而不是散成一堆孤岛
    • 它也把先验往各个编码分布上拉,逼先验的每个角落都对应一个合法的解码结果
  • 这就是 VAE 能采样的全部秘密:不是训练完之后碰巧能采样,而是损失函数里写明了要求它能采样

说人话:AE 只在潜空间里插了若干个孤立的钉子,钉子之间是空地。VAE 给每个钉子配了一个"势力范围",并规定所有势力范围加起来必须正好铺满整个潜空间。这样我们闭着眼睛随便点一个位置,都一定落在某个势力范围里,解码出来的东西才有意义。

3-4 μ、σ
  • VAE 的编码器输出两路参数,分别描述这个正态分布的均值与方差

q ϕ ( z ∣ x ) = N ( z ; μ ϕ ( x ) , diag ⁡ ( σ ϕ 2 ( x ) ) ) q_\phi(z\mid x) = \mathcal{N}\big(z; \mu_\phi(x), \operatorname{diag}(\sigma_\phi^2(x))\big) qϕ​(z∣x)=N(z;μϕ​(x),diag(σϕ2​(x)))

  • 协方差取对角阵,意思是各潜维度之间条件独立
    • 全协方差需要 16 × 17 / 2 = 136 16 \times 17 / 2 = 136 16×17/2=136 个参数,而对角只需要 16 个
    • 更重要的是,对角形式让 KL 散度有闭式解,不用做数值积分(3-6 讲到)
  • 实践中编码器不是输出 σ \sigma σ,而是输出 log ⁡ σ 2 \log \sigma^2 logσ2,也就是 logvar,原因是数值稳定性
    • σ \sigma σ 必须为正,直接让网络输出 σ \sigma σ 就得额外加约束,比如 softplus 或者取绝对值
    • 而 log ⁡ σ 2 \log \sigma^2 logσ2 的取值范围是整个实数轴,网络可以无约束地输出,取指数之后自动为正
    • 顺便还把乘法变成了加法,梯度尺度更友好
  • 这里终于用上了第 2-1 节提到的那个切片坑,划分两路输出的标准写法是
# 方案一:两个独立的全连接层,推荐,两路的初始化互不干扰
mu = self.fc_mu(h)           # (B, 16)
logvar = self.fc_logvar(h)   # (B, 16)

# 方案二:一个 32 维输出,再用切片劈成两半
h2 = self.fc_head(h)         # (B, 32)
mu, logvar = h2[:, :16], h2[:, 16:]     # 切片的经典用法

# 反例:把中间维度压掉了,形状从 (B, 16) 变成 (B,)
mu, logvar = h2[:, 15], h2[:, 31]       # 错误:只剩最后一列,信息全丢
  • 切片 h2[:, :16] 的含义是"所有样本的前 16 维",h2[:, 16:] 是"所有样本的后 16 维",正好对应两路输出
  • 这个写法在本项目的实现里没有采用,用的是方案一,但两者完全等价
  • 还要注意初始化的一个细节:fc_logvar 的偏置最好初始化成 0 甚至略小于 0
    • 如果初始 logvar 太大, σ \sigma σ 很大,后验一开始就退化成先验,KL 项直接是 0,梯度消失
    • 这个现象就是后验塌缩的早期形态,训练一开始就掉进去,后面很难救回来
  • 把两路输出代进 KL 的闭式解,就是 VAE 损失里的正则项,推导见第 3-6 节
3-5 Reparameterization Trick
3-5-1 问题分析
  • 有了 μ \mu μ 和 σ \sigma σ,理论上就可以从 q ϕ ( z ∣ x ) q_\phi(z\mid x) qϕ​(z∣x) 里采样了: z ∼ N ( μ , σ 2 ) z \sim \mathcal{N}(\mu, \sigma^2) z∼N(μ,σ2)
  • 但这一采样,整个网络就没法训练了
  • 原因在于采样这个操作本身是随机的,无法反向传播
    • 前向: x x x 经过编码器得到 μ , σ \mu, \sigma μ,σ,采样得到 z z z,解码得到 x ^ \hat{x} x^,算出损失
    • 反向:损失对解码器参数的梯度没问题;但损失对 μ \mu μ 和 σ \sigma σ 的梯度要经过 z z z
    • 而 z z z 是从分布里抽出来的,抽签这个动作的导数是 0,梯度到 z z z 就断了
3-5-2 重参数化技巧
  • 重参数化技巧(Reparameterization Trick)用一个恒等变形解决这个问题
  • 关键观察是:如果 z ∼ N ( μ , σ 2 ) z \sim \mathcal{N}(\mu, \sigma^2) z∼N(μ,σ2),那么它可以等价地写成

z = μ + σ ⊙ ε , ε ∼ N ( 0 , I ) z = \mu + \sigma \odot \varepsilon, \qquad \varepsilon \sim \mathcal{N}(0, I) z=μ+σ⊙ε,ε∼N(0,I)

  • 以防你忘记:
    • μ \mu μ:编码器输出的均值,形状 ( B , 16 ) (B, 16) (B,16)
    • σ \sigma σ:编码器输出的标准差,由 log ⁡ σ 2 \log \sigma^2 logσ2 取指数再开方得到
    • ε \varepsilon ε:标准正态噪声,与 μ \mu μ 同形状,这一步才是有随机性的地方
    • ⊙ \odot ⊙:逐元素相乘
  • 变形之后,随机性被移到了 ε \varepsilon ε 上,而 ε \varepsilon ε 与参数 θ , ϕ \theta, \phi θ,ϕ 无关
  • 于是 z z z 变成了 μ \mu μ 和 σ \sigma σ 的确定性函数,梯度可以正常回传

∇ ϕ E q ϕ ( z ∣ x ) [ f ( z ) ] = E ε ∼ N ( 0 , I ) [ ∇ ϕ f ( μ ϕ ( x ) + σ ϕ ( x ) ⊙ ε ) ] \nabla_\phi \mathbb{E}_{q_\phi(z\mid x)}\big[f(z)\big] = \mathbb{E}_{\varepsilon \sim \mathcal{N}(0,I)}\Big[\nabla_\phi f\big(\mu_\phi(x) + \sigma_\phi(x) \odot \varepsilon\big)\Big] ∇ϕ​Eqϕ​(z∣x)​[f(z)]=Eε∼N(0,I)​[∇ϕ​f(μϕ​(x)+σϕ​(x)⊙ε)]

  • 这个式子值得多看一眼:左边要对一个依赖 ϕ \phi ϕ 的分布求期望的梯度,右边只对一个固定分布求期望的梯度
    • 右边可以直接用单次采样估计,而且是无偏的,这就是 VAE 能端到端训练的原因
  • 代码只有三行
def reparameterize(self, mu, logvar):
    std = torch.exp(0.5 * logvar)     # sigma = exp(0.5 * log(sigma^2))
    eps = torch.randn_like(std)       # eps ~ N(0, I),随机性全在这里
    return mu + std * eps             # 逐元素相乘,形状 (B, 16)
  • 有一处细节容易写错:std 是 exp ⁡ ( 0.5 × logvar ) \exp(0.5 \times \text{logvar}) exp(0.5×logvar)
    • 因为 logvar 存的是 log ⁡ σ 2 \log \sigma^2 logσ2,要得到 σ \sigma σ 得先除以 2 再取指数
    • 写成 torch.exp(logvar) 得到的是 σ 2 \sigma^2 σ2,尺度就错了,训练初期就会出现很大的方差
  • 还有一处:这里的加法和乘法都是逐元素的,mu、std、eps 三者形状完全相同,都是 (B, 16)
  • 不需要任何广播,也不该有 mu[:, None] 这类补维操作,看到就说明形状设计错了
  • 采样的时候用 torch.randn_like(std) 而不是 torch.randn(std.shape),前者自动继承设备与数据类型,写 CUDA 代码时不会踩到设备不一致的坑

说人话:本来要"抽一个签",抽签这个动作没法求导。重参数化把抽签改写成"固定抽一个标准正态的数,再拿它去乘、去加"。乘和加都能求导,梯度就通了。

  • 至此,我们使用编码器输出 μ \mu μ、 σ \sigma σ 去描述一个正态分布,同时引入参数 ε \varepsilon ε 来对采样这一操作进行参数化,保证了采样这一过程能够顺利使用梯度进行反向传播请添加图片描述

  • 接下来,我们需要对解码的分布进行评估,而 KL 散度正是这个损失里的一项

3-6 KL Divergence
3-6-1 定义
  • KL 散度衡量两个分布的差异,定义是

D K L ( q ( z )   ∥   p ( z ) ) = ∫ q ( z ) log ⁡ q ( z ) p ( z )   d z D_{KL}\big(q(z)\,\Vert\,p(z)\big) = \int q(z) \log \frac{q(z)}{p(z)}\, dz DKL​(q(z)∥p(z))=∫q(z)logp(z)q(z)​dz

  • 它有三个性质要记住
    • 非负,当且仅当两个分布几乎处处相等时取 0,这是 Gibbs 不等式
    • 不对称, D K L ( q ∥ p ) ≠ D K L ( p ∥ q ) D_{KL}(q \Vert p) \ne D_{KL}(p \Vert q) DKL​(q∥p)=DKL​(p∥q),所以它不是距离
    • 可以理解成"用 p p p 去编码来自 q q q 的样本时,平均多付出的信息量"

在 VAE 里,我们要算的是对角高斯 q = N ( μ , diag ⁡ ( σ 2 ) ) q = \mathcal{N}(\mu, \operatorname{diag}(\sigma^2)) q=N(μ,diag(σ2)) 与标准正态 p = N ( 0 , I ) p = \mathcal{N}(0, I) p=N(0,I) 之间的 KL

3-6-2 求解
  • 这个 KL 有闭式解(所谓闭式解,就是能把结果写成一行只含加减乘除、平方、对数这类基本运算的公式,把 μ \mu μ、 σ \sigma σ 代进去直接算出数,不用真的去积那个分),不用做积分

D K L ( N ( μ , σ 2 )   ∥   N ( 0 , I ) ) = 1 2 ∑ j = 1 L ( μ j 2 + σ j 2 − log ⁡ σ j 2 − 1 ) D_{KL}\big(\mathcal{N}(\mu, \sigma^2)\,\Vert\,\mathcal{N}(0, I)\big) = \frac{1}{2}\sum_{j=1}^{L}\Big(\mu_j^2 + \sigma_j^2 - \log \sigma_j^2 - 1\Big) DKL​(N(μ,σ2)∥N(0,I))=21​j=1∑L​(μj2​+σj2​−logσj2​−1)

  • 这个式子可以一项一项读
    • μ j 2 \mu_j^2 μj2​:均值偏离 0 就要付代价,把编码分布往原点拉
    • σ j 2 \sigma_j^2 σj2​:方差比 1 大要付代价,防止编码分布摊得太开
    • − log ⁡ σ j 2 -\log \sigma_j^2 −logσj2​:方差比 1 小也要付代价,防止编码分布缩成一个尖峰
    • − 1 -1 −1:常数,保证当 μ j = 0 , σ j = 1 \mu_j = 0, \sigma_j = 1 μj​=0,σj​=1 时整个括号正好为 0
  • 后两项目一起,就是"方差必须接近 1"的软约束,一个负责压、一个负责撑,两边都不让它跑
  • 换成 logvar 的写法,代码就一行
# 逐样本、逐维度计算,形状 (B, L)
kl_per_sample = -0.5 * (1 + logvar - mu.pow(2) - logvar.exp())

# 在 L 个潜维度上求和,再对 batch 求平均,得到标量
kld = kl_per_sample.sum(dim=-1).mean()
  • 注意 dim=-1 是潜维度,.mean() 才是 batch 维度,两个方向不能搞反
  • 保留逐维度的结果很有用,它直接告诉我们哪些潜维度被真正用上了,第 3-9 节要用

K L sample = E x [ D K L ( q ϕ ( z ∣ x )   ∥   p ( z ) ) ] KL_{\text{sample}} = \mathbb{E}_x\Big[D_{KL}\big(q_\phi(z\mid x)\,\Vert\,p(z)\big)\Big] KLsample​=Ex​[DKL​(qϕ​(z∣x)∥p(z))]

  • 这个量就是损失里的那一项,它的含义是"平均而言,每个样本的后验离先验有多远"
  • 如果一个维度的 K L sample KL_{\text{sample}} KLsample​ 掉到 0,说明这个维度对任何输入都输出 μ ≈ 0 , σ ≈ 1 \mu \approx 0, \sigma \approx 1 μ≈0,σ≈1
  • 也就是后验等于先验,解码器从这一维拿不到任何关于 x x x 的信息,这一维塌缩了
  • 所以 K L sample KL_{\text{sample}} KLsample​ 是判断维度是否塌缩的标准,这个判据在后面的实验中会反复用到
3-7 VAE Loss
3-7-1 定义
  • 把重构项和 KL 项拼起来,就是 VAE 的损失,也就是负的 ELBO(3-8 拆解这个名字)

L VAE = ∥ x − x ^ ∥ 2 ⏟ 重构项 + β ⋅ D K L ( q ϕ ( z ∣ x )   ∥   p ( z ) ) ⏟ 正则项 \mathcal{L}_{\text{VAE}} = \underbrace{\lVert x - \hat{x} \rVert^2}_{\text{重构项}} + \beta \cdot \underbrace{D_{KL}\big(q_\phi(z\mid x)\,\Vert\,p(z)\big)}_{\text{正则项}} LVAE​=重构项 ∥x−x^∥2​​+β⋅正则项 DKL​(qϕ​(z∣x)∥p(z))​​

  • β \beta β 是 KL 项的权重, β = 1 \beta = 1 β=1 时就是标准的 VAE 目标,这个形式叫 β \beta β-VAE
  • 两项分别管两件事,方向恰好相反
项想干什么如果把它去掉
重构项让输出尽量还原输入解码器收不到信息,输出常数
KL 项让潜空间长得像标准正态退化成普通 AE,潜空间散开、没法采样
  • 这两项是在互相拉扯,这也是 VAE 生成结果偏模糊的根本原因
    • 重构项希望 σ \sigma σ 越小越好,最好趋近于 0,这样 z z z 就退化成确定值,重构最精确
    • KL 项希望 σ \sigma σ 接近 1、 μ \mu μ 接近 0,这样才能填满先验
    • 最终的 σ \sigma σ 是两者妥协的结果,于是潜变量里残留了噪声,解码出来就带一点模糊

说人话:回到一开始的问题,我们需要用 VAE 对数据的分布进行估计,同时要让这个潜空间分布长得像标准正态(也就是用 KL 散度去约束),因此损失里除了要求数据还原得像(重构项),还要要求这个分布有多像标准正态(正则项)

3-7-2 重构项权衡量纲
  • 用 MSE 做重构时,这两项的量纲需要手动平衡,所以"在哪些维度上求和"这件事很关键
    • 我们的实现里,重构项在 140 个输出维度上求和,KL 项在 16 个潜维度上求和
    • 两边都是"求和",量级可比, β = 1 \beta = 1 β=1 就是一个合理的起点
    • 如果重构项按维度求了平均,KL 项就相对放大了 140 倍,模型会直接塌缩
  • 实现上,两个损失项必须分开返回,因为它们的含义完全不同
def loss(self, x, beta=1.0):
    x_hat, mu, logvar, _ = self(x)   # forward 返回四元组,最后一个用不上

    # 重构项:在 140 个输出维度上求和,再对 batch 求平均
    rec = nn.functional.mse_loss(x_hat, x, reduction="none").sum(dim=-1).mean()

    # KL 项:闭式解,在 16 个潜维度上求和,再对 batch 求平均
    kld = -0.5 * torch.sum(1 + logvar - mu.pow(2) - logvar.exp(), dim=-1).mean()

    # 返回三个值:总损失用于反向传播,后两个用于监控
    return rec + beta * kld, rec, kld
  • 训练时把 rec 和 kld 分别记录下来,这一点非常重要
    • 只看总损失,我们无法判断模型是"重构变差了"还是"潜空间变规整了"
    • 这两件事的诊断方法和处理手段完全不同
    • 本项目的训练日志里 val_recon 与 val_kld 是分开打印的,就是为了这个
3-8 ELBO
3-8-1 解释
  • 上一节那个损失是直接给出来的,这一节回答的是:凭什么一定是这两项,而不是别的
    • 起点是我们真正想最大化的东西:数据对数似然 log ⁡ p θ ( x ) \log p_\theta(x) logpθ​(x)
  • ELBO(Evidence Lower Bound,证据下界)这个名字里有"证据"和"下界"两半
    • "证据"是 log ⁡ p θ ( x ) \log p_\theta(x) logpθ​(x) 的另一个叫法,来自贝叶斯模型比较里的 marginal likelihood
    • "下界"是说 ELBO 永远不超过 log ⁡ p θ ( x ) \log p_\theta(x) logpθ​(x),严格证明放在 3-8-3
  • 推导只有三步:把 z z z 积分进去、用 Jensen 不等式、把期望拆成两部分,而第三步拆出来的恰好就是第 3-7 节那两项
3-8-2 推导
  • 把隐变量 z z z 积分进去,再用一个技巧:乘一个 q ϕ ( z ∣ x ) q_\phi(z\mid x) qϕ​(z∣x) 再除掉它,值不变

log ⁡ p θ ( x ) = log ⁡ ∫ p θ ( x ∣ z ) p ( z )   d z = log ⁡ E q ϕ ( z ∣ x ) [ p θ ( x ∣ z )   p ( z ) q ϕ ( z ∣ x ) ] \log p_\theta(x) = \log \int p_\theta(x\mid z) p(z)\, dz = \log \mathbb{E}_{q_\phi(z\mid x)}\left[\frac{p_\theta(x\mid z)\, p(z)}{q_\phi(z\mid x)}\right] logpθ​(x)=log∫pθ​(x∣z)p(z)dz=logEqϕ​(z∣x)​[qϕ​(z∣x)pθ​(x∣z)p(z)​]

  • 第二步用 Jensen 不等式。 log ⁡ \log log 是凹函数,所以 log ⁡ E [ ⋅ ] ≥ E [ log ⁡ ⋅ ] \log \mathbb{E}[\cdot] \ge \mathbb{E}[\log \cdot] logE[⋅]≥E[log⋅]

log ⁡ p θ ( x ) ≥ E q ϕ ( z ∣ x ) [ log ⁡ p θ ( x ∣ z )   p ( z ) q ϕ ( z ∣ x ) ] \log p_\theta(x) \ge \mathbb{E}_{q_\phi(z\mid x)}\left[\log \frac{p_\theta(x\mid z)\, p(z)}{q_\phi(z\mid x)}\right] logpθ​(x)≥Eqϕ​(z∣x)​[logqϕ​(z∣x)pθ​(x∣z)p(z)​]

  • 第三步把右边的期望拆开。对数里是一个商,可以拆成两项之和: log ⁡ p θ ( x ∣ z )   p ( z ) q ϕ ( z ∣ x ) = log ⁡ p θ ( x ∣ z ) + log ⁡ p ( z ) q ϕ ( z ∣ x ) \log\frac{p_\theta(x\mid z)\, p(z)}{q_\phi(z\mid x)} = \log p_\theta(x\mid z) + \log\frac{p(z)}{q_\phi(z\mid x)} logqϕ​(z∣x)pθ​(x∣z)p(z)​=logpθ​(x∣z)+logqϕ​(z∣x)p(z)​
    • 前一半 E q [ log ⁡ p θ ( x ∣ z ) ] \mathbb{E}_{q}\big[\log p_\theta(x\mid z)\big] Eq​[logpθ​(x∣z)] 就是重构项
    • 后一半 E q [ log ⁡ p ( z ) q ϕ ( z ∣ x ) ] \mathbb{E}_{q}\big[\log\frac{p(z)}{q_\phi(z\mid x)}\big] Eq​[logqϕ​(z∣x)p(z)​] 按 3-6 的定义就是 − D K L ( q ϕ ( z ∣ x )   ∥   p ( z ) ) -D_{KL}\big(q_\phi(z\mid x)\,\Vert\,p(z)\big) −DKL​(qϕ​(z∣x)∥p(z)),也就是正则项
  • 两项不是拼上去的,是这一个期望天然分成的两半,这就是 ELBO 的标准形式

L ELBO = E q ϕ ( z ∣ x ) [ log ⁡ p θ ( x ∣ z ) ] − D K L ( q ϕ ( z ∣ x )   ∥   p ( z ) ) \mathcal{L}_{\text{ELBO}} = \mathbb{E}_{q_\phi(z\mid x)}\big[\log p_\theta(x\mid z)\big] - D_{KL}\big(q_\phi(z\mid x)\,\Vert\,p(z)\big) LELBO​=Eqϕ​(z∣x)​[logpθ​(x∣z)]−DKL​(qϕ​(z∣x)∥p(z))

  • 这两项和第 3-7 节的损失是逐项对上的
    • 第一项 E q [ log ⁡ p θ ( x ∣ z ) ] \mathbb{E}_{q}\big[\log p_\theta(x\mid z)\big] Eq​[logpθ​(x∣z)] 是重构项: p θ ( x ∣ z ) p_\theta(x\mid z) pθ​(x∣z) 取方差固定的高斯时,它的对数似然就是负的 MSE(2-5 讲过),最大化这一项等于最小化 ∥ x − x ^ ∥ 2 \lVert x - \hat{x}\rVert^2 ∥x−x^∥2
    • 第二项 − D K L ( q ϕ ( z ∣ x )   ∥   p ( z ) ) -D_{KL}\big(q_\phi(z\mid x)\,\Vert\,p(z)\big) −DKL​(qϕ​(z∣x)∥p(z)) 是正则项:负号来自上一步拆分时的 log ⁡ p ( z ) q ϕ ( z ∣ x ) \log\frac{p(z)}{q_\phi(z\mid x)} logqϕ​(z∣x)p(z)​,它的闭式解正是 3-6 算过的那一个
  • 把最大化 ELBO 翻成最小化负 ELBO,就得到第 3-7 节那个损失函数
3-8-3 “下界”
  • 3-8-1 里"下界"那半句,这里补一个严格证明
  • 可以精确地算出 log ⁡ p θ ( x ) \log p_\theta(x) logpθ​(x) 与 ELBO 之间的差距

log ⁡ p θ ( x ) − L ELBO = D K L ( q ϕ ( z ∣ x )   ∥   p θ ( z ∣ x ) ) ≥ 0 \log p_\theta(x) - \mathcal{L}_{\text{ELBO}} = D_{KL}\big(q_\phi(z\mid x)\,\Vert\,p_\theta(z\mid x)\big) \ge 0 logpθ​(x)−LELBO​=DKL​(qϕ​(z∣x)∥pθ​(z∣x))≥0

  • 因为 KL 散度非负,所以 ELBO 永远不超过 log ⁡ p θ ( x ) \log p_\theta(x) logpθ​(x),它是下界

  • 差距那一项是真实后验 p θ ( z ∣ x ) p_\theta(z\mid x) pθ​(z∣x) 与我们的近似后验 q ϕ ( z ∣ x ) q_\phi(z\mid x) qϕ​(z∣x) 之间的距离

  • 这个式子解释了两件事

    • 我们想让 ELBO 尽量大,同时也就想让 q ϕ q_\phi qϕ​ 尽量接近真实后验,两个目标是一致的
    • 真实后验 p θ ( z ∣ x ) = p θ ( x ∣ z ) p ( z ) / p θ ( x ) p_\theta(z\mid x) = p_\theta(x\mid z)p(z) / p_\theta(x) pθ​(z∣x)=pθ​(x∣z)p(z)/pθ​(x) 里的分母 p θ ( x ) p_\theta(x) pθ​(x) 要积分才能算,这正是它不可解、必须做变分近似的原因,也是"变分"两个字的出处
  • 两项的分工可以再总结一遍

项数学形式等价于作用
重构项 E q [ log ⁡ p θ ( x ∣ z ) ] \mathbb{E}_{q}[\log p_\theta(x\mid z)] Eq​[logpθ​(x∣z)]最小化 MSE让解码器能还原输入
正则项 − D K L ( q ϕ ( z ∣ x ) ∥ p ( z ) ) -D_{KL}(q_\phi(z\mid x) \Vert p(z)) −DKL​(qϕ​(z∣x)∥p(z))最小化到先验的 KL让潜空间规整、可采样

说人话:ELBO 是一个"够得着的目标"。我们真正想最大化的 log ⁡ p ( x ) \log p(x) logp(x) 算不出来,但可以证明有一个值永远不超过它,而且这个值能算、能求导。那就先把这个值推到最高, log ⁡ p ( x ) \log p(x) logp(x) 也就跟着被顶上去了。

3-9 为什么 VAE 可以采样生成
  • 到这里可以正面回答第 2-7 节那个问题了
    • VAE 能采样,靠的不是训练完之后碰巧发现潜空间很规整,而是损失函数里就写了一项逼它规整
3-9-1 采样
  • 采样时我们完全不看输入,直接从先验抽

z ∼ p ( z ) = N ( 0 , I ) , x ^ = g ϕ ( z ) z \sim p(z) = \mathcal{N}(0, I), \qquad \hat{x} = g_\phi(z) z∼p(z)=N(0,I),x^=gϕ​(z)
请添加图片描述

说人话:从结构图来看, ε \varepsilon ε 是直接从 N ( 0 , I ) \mathcal{N}(0, I) N(0,I) 里抽出来的,抽出来的数与输入数据无关,再和 μ \mu μ、 σ \sigma σ 组合成 z z z

  • 代码就一行,注意这里没有编码器参与
@torch.no_grad()
def sample(self, n):
    z = torch.randn(n, 16)      # 直接从先验采样,与任何输入无关
    return self.decoder(z)      # 解码即生成
3-9-2 分析-聚合后验
  • 为什么这么采样是合法的?因为 KL 项的作用是把聚合后验推向先验
  • 聚合后验(aggregate posterior)的定义是,把数据集里所有样本的后验平均起来

q ( z ) = 1 N ∑ i = 1 N q ϕ ( z ∣ x i ) = 1 N ∑ i = 1 N N ( μ i , σ i 2 ) q(z) = \frac{1}{N}\sum_{i=1}^{N} q_\phi(z\mid x_i) = \frac{1}{N}\sum_{i=1}^{N} \mathcal{N}\big(\mu_i, \sigma_i^2\big) q(z)=N1​i=1∑N​qϕ​(z∣xi​)=N1​i=1∑N​N(μi​,σi2​)

  • 它描述的才是"从数据集里随便挑一条轨迹,它的编码在潜空间里落在哪"
    • 采样时我们抽的是 z ∼ p ( z ) z \sim p(z) z∼p(z),解码器见到的输入分布是 p ( z ) p(z) p(z),而训练时它见到的输入分布是 q ( z ) q(z) q(z)
    • 这两个分布一致,采样才有效。KL 项正是在做这件事

说人话:之所以能脱离原数据、直接从 N ( 0 , I ) \mathcal{N}(0, I) N(0,I) 里采样,是因为损失里 KL 散度那一项在训练时不停地把每条轨迹的后验往 N ( 0 , I ) \mathcal{N}(0, I) N(0,I) 上拽;所有轨迹一起被拽,聚合起来的那团编码也就贴上了标准正态,于是生成时随便抽一个 z z z,解码器见到的仍是自己熟悉的区域,出来的才是一条像样的轨迹

  • 注意 q ( z ) q(z) q(z) 是一个高斯混合,不是单个高斯,算它的方差要按全方差公式分成两项

E [ z ] = 1 N ∑ i = 1 N μ i , Var ⁡ [ z ] = 1 N ∑ i = 1 N σ i 2 ⏟ 组内方差 + 1 N ∑ i = 1 N ( μ i − μ ˉ ) 2 ⏟ 组间方差 \mathbb{E}[z] = \frac{1}{N}\sum_{i=1}^{N} \mu_i, \qquad \operatorname{Var}[z] = \underbrace{\frac{1}{N}\sum_{i=1}^{N} \sigma_i^2}_{\text{组内方差}} + \underbrace{\frac{1}{N}\sum_{i=1}^{N} \big(\mu_i - \bar{\mu}\big)^2}_{\text{组间方差}} E[z]=N1​i=1∑N​μi​,Var[z]=组内方差 N1​i=1∑N​σi2​​​+组间方差 N1​i=1∑N​(μi​−μˉ​)2​​

  • 两项缺一不可
    • 组内方差:每个样本自己的 σ i 2 \sigma_i^2 σi2​ 求平均
    • 组间方差:各样本均值 μ i \mu_i μi​ 之间的离散程度
3-9-3 逐样本 KL 与聚合后验 KL
  • 最后要说清楚一个最容易混淆的点:逐样本 KL 与聚合后验 KL,回答的是两个不同的问题
记号定义回答的问题
K L sample KL_{\text{sample}} KLsample​ E x [ D K L ( q ϕ ( z ∣ x ) ∥ p ( z ) ) ] \mathbb{E}_x[D_{KL}(q_\phi(z\mid x) \Vert p(z))] Ex​[DKL​(qϕ​(z∣x)∥p(z))],逐样本算再平均,就是损失里那一项解码器用不用这一维
K L agg KL_{\text{agg}} KLagg​聚合后验的矩匹配高斯对先验的 KL边缘形状像不像先验
  • 本项目实测,这两个指标给出了相反的印象:16 个维度的边缘全部贴合先验( μ \mu μ 落在 − 0.06 -0.06 −0.06 到 + 0.05 +0.05 +0.05, σ \sigma σ 落在 0.99 到 1.07, K L agg KL_{\text{agg}} KLagg​ 共 0.025 nats),但 z[7] 这一维的 K L sample = 0.000 KL_{\text{sample}} = 0.000 KLsample​=0.000,已经塌缩,解码器完全不读它
    • 不矛盾:塌缩维度的后验本来就等于先验,边缘分布看起来当然完美,所以判断塌缩只能看 K L sample KL_{\text{sample}} KLsample​
    • 反向也不成立: K L sample KL_{\text{sample}} KLsample​ 很大不代表采样会失败,只要聚合后验还贴合先验,先验采样依然落在解码器见过的区域里
  • 把这一节的结论收拢成三句话
    • VAE 是生成模型,因为它的潜空间上有一个显式写出来的先验分布 p ( z ) = N ( 0 , I ) p(z) = \mathcal{N}(0,I) p(z)=N(0,I)
    • 采样之所以有效,是因为训练逼着聚合后验 q ( z ) q(z) q(z) 去贴近这个先验
    • 潜维度是否被使用,看 K L sample KL_{\text{sample}} KLsample​;能不能采样,看 K L agg KL_{\text{agg}} KLagg​,两者不能互相替代

说人话:AE 学完之后,潜空间是一盘散沙,我们不知道从哪下嘴。VAE 在训练时就被要求"把潜空间整理成标准正态的样子",所以训完之后,闭着眼睛按标准正态抽一个 z z z,解码出来就是一条像模像样的轨迹。这就是"生成模型"这四个字的具体含义。


4 机器人轨迹 VAE 代码实现

4-1 介绍
  • 这一节我们使用机器人轨迹数据集,对 VAE 进行部署
  • 很简单:拿到的数据集是 LIBERO 里的机器人操作轨迹,每条样本是一条 20 步、每步 7 维的动作序列
    • 输入:把 (20, 7) 拉平成 140 维向量
    • VAE:编码器压到 16 维潜变量,解码器再还原回 140 维
    • 输出:同样是 140 维,重塑回 (20, 7) 就是一条轨迹
  • 总共做四个实验,分别回答四个问题
    • 能不能重构:把留出的验证轨迹编码再解码,看还原得像不像
    • 潜空间有没有结构:把 16 维潜编码投到二维,看同类动作会不会聚在一起
    • 能不能凭空采样:只从先验 N ( 0 , I ) \mathcal{N}(0, I) N(0,I) 抽 z z z,看解码出来的是不是像样的动作
    • 潜空间能不能插值:在两个潜编码之间走直线,看解码结果是不是连续变化
  • 这四个做完,再把动作积分回笛卡尔空间看末端走出了什么形状,最后用 β \beta β 消融看一眼 KL 权重到底在权衡什么
4-2 机器人轨迹数据集

请添加图片描述

4-2-1 介绍
  • 数据用 lerobot/libero,托管在 HuggingFace,格式是 LeRobot v3.0
  • 数据集链接:lerobot/libero
  • LeRobot 是一套机器人数据集的统一格式规范,一个数据集由 meta/ 与 data/ 两部分组成
    • meta/info.json 记录数据集的整体信息:帧率、特征名、各特征形状、episode 划分
    • meta/tasks.parquet 记录全部任务的语言指令
    • data/chunk-*/file-*.parquet 是真正的数据分片,每一行是一帧
  • LIBERO 是一个机器人操作任务基准,全部是桌面级的抓取与放置,任务指令是自然语言
  • 说句题外话:后续会更新一个关于lerobot的实战教程,就在我们的VLA系列的教程之后
4-2-2 数据集分析
  • 本项目只使用 state 与 action,不使用任何视觉输入,这一点是刻意的
    • 视觉输入会让问题变成"图像加动作",VAE 的结构反而被图像编码器盖住了
    • 只留动作,模型小到 21300 个参数,训练 100 个 epoch 只要 141 秒,全部结构都看得清
  • 数据集的关键统计如下
项目值
episode 数1693
task 数40
总帧数273465
单个 action 维度7,即 3 平移加 3 旋转加 1 夹爪
控制频率10 Hz
episode 长度75 到 505 帧,中位数 140
切窗后样本数241298,取 H=20、stride=1
  • 下载只取 data/ 与 meta/ 两个目录。同一个仓库里还有 1.9 GB 的相机视频,本实验一个字节都不需要,所以实际下载量只有 22 MB
hf download lerobot/libero \
    --repo-type dataset \
    --include "data/**" --include "meta/**" \
    --local-dir data/libero
  • 限流量的就是那两个 --include
  • 动作 action 与状态 observation.state 的语义完全不同,不能混用
    • action 是 7 维增量:[dpos(3), drot(3), gripper(1)],dpos 是"这一步末端要往哪个方向挪一点",不是绝对坐标
    • observation.state 是 8 维:[eef_pos(3), eef_axis_angle(3), gripper_qpos(2)],前三维是真实的末端位置
  • 这个区别在第 4-8 节把动作积分回末端轨迹时会变得非常关键
4-2-3 为什么必须做按维归一化
  • 七个动作维度的量纲差了 25.5 倍,直接对 140 维算 MSE 会出问题
维度a[0]a[1]a[2]a[3]a[4]a[5]a[6]
std0.3360.3790.4450.0390.0630.0781.000
  • 平移量级约 0.4,旋转量级约 0.04,夹爪是正负 1 的开关量
    • MSE 是平方误差,旋转那三维的误差会被夹爪维度的平方项淹掉,模型会干脆放弃学旋转
  • 所以按维度做 z-score 归一化

X ~ = X − μ dim σ dim \tilde{X} = \frac{X - \mu_{\text{dim}}}{\sigma_{\text{dim}}} X~=σdim​X−μdim​​

  • 这里的 μ dim \mu_{\text{dim}} μdim​ 与 σ dim \sigma_{\text{dim}} σdim​ 只在训练集 episode 上统计,避免用到验证集的信息
  • 两个统计量会存进 checkpoint,所有报出来的指标都会反归一化回物理动作单位,数字才有意义
class ActionScaler:
    def __init__(self, mean, std):
        self.mean, self.std = mean, std          # 形状都是 (7,)

    @classmethod
    def fit(cls, episodes, episode_ids):
        # 只在训练集 episode 上统计,累积用 float64 避免精度损失
        stacked = np.concatenate([episodes.actions[i] for i in episode_ids]).astype(np.float64)
        std = stacked.std(axis=0)
        std = np.where(std < 1e-6, 1.0, std)     # 常量通道不动它,避免除零
        return cls(stacked.mean(axis=0), std)

    def transform(self, x):
        # x 的形状是 (N, H, 7),mean/std 是 (7,),靠广播自动对齐到最后 1 维
        return (x - self.mean) / self.std

    def inverse(self, x):
        return x * self.std + self.mean
  • 注意广播是怎么对齐的:(N, H, 7) 减 (7,),numpy 与 PyTorch 都会把 (7,) 看成 (1, 1, 7) 再逐元素运算
  • 这也是为什么统计量要按最后一维存,而不是按 (140,) 存
  • 除以零的防护放在 fit 里做(std < 1e-6 的通道直接置 1),而不是在每次变换时加 eps。加 eps 会偷偷改变所有维度的尺度,1.0 的夹爪维会变成 1 / 1.000001 1/1.000001 1/1.000001,反归一化时又不能完全还原
4-2-4 切分按 episode,不按窗口
  • 这是整个数据管线里最容易出错、后果也最严重的一步
    • 相邻两个窗口共享 19 个动作,如果按窗口随机切分,验证集里几乎每个窗口都能在训练集里找到"只差一步"的兄弟
    • 这样测出来的验证损失会好得离谱,但完全不代表泛化能力
  • 正确做法是按 episode 切,90% 的 episode 进训练集,10% 进验证集,验证集 episode 里的所有窗口整段进验证集
# 先按 episode 划分 id,再各自切窗
rng = np.random.default_rng(seed)
perm = rng.permutation(len(episodes))
n_val = max(1, int(round(len(perm) * val_ratio)))
val_ids, train_ids = perm[:n_val], perm[n_val:]

train_ws = build_windows(episodes, window=H, stride=1, episode_ids=train_ids)
val_ws = build_windows(episodes, window=H, stride=1, episode_ids=val_ids)
  • 最终得到训练窗口 218277 条、验证窗口 23021 条
4-3 核心思路
  • 整个方法可以用一行式子概括

Action Trajectory ⟶ VAE ⟶ Action Trajectory \text{Action Trajectory} \longrightarrow \text{VAE} \longrightarrow \text{Action Trajectory} Action Trajectory⟶VAE⟶Action Trajectory

  • 具体地,取一个 LIBERO episode
episode
├── t=0   action[7]
├── t=1   action[7]
├── t=2   action[7]
├── ...
└── t=T   action[7]
  • 按固定长度 H = 20 H = 20 H=20 切成窗口,得到

X ∈ R 20 × 7 X \in \mathbb{R}^{20 \times 7} X∈R20×7

  • 拉平成 140 维

X ∈ R 140 X \in \mathbb{R}^{140} X∈R140

  • 然后过 VAE

X ∈ R 140    →  Encoder     h ∈ R 64    →   fc μ ,   fc log ⁡ σ 2      ( μ , log ⁡ σ 2 ) ∈ R 16 →  重参数化     z ∈ R 16    →  Decoder     X ^ ∈ R 140 \begin{aligned} X \in \mathbb{R}^{140} \;&\xrightarrow{\ \text{Encoder}\ } \; h \in \mathbb{R}^{64} \;\xrightarrow{\ \text{fc}_\mu,\ \text{fc}_{\log\sigma^2}\ }\; \big(\mu, \log\sigma^2\big) \in \mathbb{R}^{16} \\ &\xrightarrow{\ \text{重参数化}\ } \; z \in \mathbb{R}^{16} \;\xrightarrow{\ \text{Decoder}\ }\; \hat{X} \in \mathbb{R}^{140} \end{aligned} X∈R140​ Encoder  ​h∈R64 fcμ​, fclogσ2​  ​(μ,logσ2)∈R16 重参数化  ​z∈R16 Decoder  ​X^∈R140​

  • 最后 reshape 回动作序列的形状

140 ⟶ 20 × 7 140 \longrightarrow 20 \times 7 140⟶20×7

4-3-1 张量形状速查
  • 整条链路上每个张量的形状如下,后面所有的实验都建立在这几个形状上,写代码时对不上就是这一步错了
阶段形状含义
原始 episode(T, 7)T 帧,每帧 7 维动作,T 因 episode 而异
切窗后(N, 20, 7)N 条样本,每条 20 步,每步 7 维
拉平(N, 140)VAE 的输入,20 乘 7
编码隐层(N, 64)编码器中间层输出
后验参数(N, 16) 两份mu 与 logvar,形状完全相同
潜变量(N, 16)重参数化采样得到的 z
解码输出(N, 140)与输入同形状,才能算重构损失
重塑回轨迹(N, 20, 7)出图与积分用这个形状
  • 有一处形状约定很容易搞混:批大小 N 永远在第 0 维,所以沿着 dim=-1 求和得到的是"每条样本一个标量",沿着 dim=0 求和得到的是"每个维度一个统计量"
  • 第 3-6 节算逐样本 KL 用的是 dim=-1,第 4-2-3 节算各维 std 用的是 dim=0,两者不能互换
4-3-2 切片与形状操作
  • 上面表格里的每一次形状变化,都对应一个具体的切片操作,值得单独写一遍
import numpy as np

# 1. 从 episode 里切出一个窗口:(T, 7) 取第 k 到 k+20 步
win = actions[k : k + 20]                 # (20, 7)

# 2. 拉平成一维,这就是 VAE 的输入
x = win.reshape(-1)                       # (140,)
# 等价写法:win.flatten(),但 flatten 总是拷贝,reshape 能省则省

# 3. 解码回来之后重塑成轨迹
traj = x_hat.reshape(20, 7)               # (20, 7)

# 4. 按语义切开七维动作,验证模型有没有学歪
dpos, drot, grip = traj[:, :3], traj[:, 3:6], traj[:, 6]

# 5. 整个数据集用滑窗一次性切完,(T, 7) 变成 (T-19, 7, 20)
windows = np.lib.stride_tricks.sliding_window_view(actions, 20, axis=0)
# 新轴在末尾,要搬到中间才是 (T-19, 20, 7)
windows = np.ascontiguousarray(windows.transpose(0, 2, 1))
  • 第 5 步有个坑必须提醒:sliding_window_view 把新轴放在最后
    • 输入形状 (T, 7),指定 axis=0,输出是 (T-19, 7, 20) 而不是 (T-19, 20, 7)
    • 它是在原轴上滑动,滑出来的窗口维度追加到末尾,不是插在 axis 的位置
    • 少了这一步转置,切出来的"窗口"里混的是不同维度的数据,模型照样能训,但学到的东西完全不对,而且不报错
  • 另外,sliding_window_view 返回的是视图不是拷贝,写操作会污染原数组,需要可写副本时记得 .copy()

说人话:这一章做的事就是"把机器人连续动作切成 20 步一段,压成 16 个数字,再还原回 20 步"。前面三章讲的所有公式,落到代码里就是这段切片。

4-3-3 完整模型代码
  • 把第 3 章讲的所有东西拼起来,就是这个项目的模型定义
  • 下面这份代码一共 21292 个参数,比项目里跑的版本少 1 个 —— 那个参数是高斯似然重构模式下用于学习观测噪声的标量 log_sigma2,本项目的 recon 默认走 MSE,用不到它,为了不干扰阅读这里略去了
import torch
import torch.nn as nn
import torch.nn.functional as F


def mlp(sizes, activate_last=True):
    """按 sizes 串一串全连接层,中间夹 ReLU"""
    layers = []
    for i in range(len(sizes) - 1):
        layers.append(nn.Linear(sizes[i], sizes[i + 1]))
        # 最后一层是否加激活由 activate_last 决定
        if i < len(sizes) - 2 or activate_last:
            layers.append(nn.ReLU())
    return nn.Sequential(*layers)


class TrajVAE(nn.Module):
    """动作轨迹上的变分自编码器,输入输出都是拉平的 (B, H*D)"""

    def __init__(self, obs_dim, hidden=64, latent=16, layers=1):
        super().__init__()
        self.obs_dim = obs_dim
        self.latent = latent

        # 编码器把 140 维压到 64 维隐层
        self.encoder = mlp([obs_dim] + [hidden] * layers)
        # 两路独立的头,对应第 3-4 节的 mu 与 logvar
        self.fc_mu = nn.Linear(hidden, latent)
        self.fc_logvar = nn.Linear(hidden, latent)
        # 解码器把 16 维还原回 140 维,最后一层不加激活
        self.decoder = mlp([latent] + [hidden] * layers + [obs_dim], activate_last=False)

        self.reset_parameters()

    def reset_parameters(self):
        for module in self.modules():
            if isinstance(module, nn.Linear):
                nn.init.kaiming_normal_(module.weight, nonlinearity="relu")
                nn.init.zeros_(module.bias)
        # 输出头初始化为 0,训练从"预测均值"开始,起步更稳
        nn.init.zeros_(self.decoder[-1].weight)
        nn.init.zeros_(self.decoder[-1].bias)

    def encode(self, x):
        h = self.encoder(x)
        return self.fc_mu(h), self.fc_logvar(h)

    def reparameterize(self, mu, logvar):
        # 对应第 3-5 节:z = mu + sigma * eps
        std = torch.exp(0.5 * logvar)
        return mu + std * torch.randn_like(std)

    def decode(self, z):
        return self.decoder(z)

    def forward(self, x):
        mu, logvar = self.encode(x)
        z = self.reparameterize(mu, logvar)
        return self.decode(z), mu, logvar, z

    @torch.no_grad()
    def sample(self, n, device=None):
        # 对应第 3-9 节:直接从先验采样再解码,不经过编码器
        device = device or next(self.parameters()).device
        z = torch.randn(n, self.latent, device=device)
        return self.decode(z)

    def loss(self, x, beta=1.0):
        """负 ELBO,返回 (总损失, 重构项, KL 项) 三个值"""
        x_hat, mu, logvar, _ = self(x)

        # 重构项:在 140 个输出维度上求和,再对 batch 求平均
        rec = F.mse_loss(x_hat, x, reduction="none").sum(dim=-1).mean()

        # KL 项:第 3-6 节的闭式解,在 16 个潜维度上求和
        kld = -0.5 * torch.sum(1 + logvar - mu.pow(2) - logvar.exp(), dim=-1).mean()

        return rec + beta * kld, rec, kld

    @torch.no_grad()
    def reconstruct(self, x, sample=False):
        """重构。sample=True 时用采样得到的 z,否则用后验均值 mu"""
        mu, logvar = self.encode(x)
        return self.decode(self.reparameterize(mu, logvar) if sample else mu)
  • 有两处实现细节值得单独说
    • reconstruct 默认用后验均值 mu 而不是采样。重构实验要看的是"模型学到了什么",用均值能去掉采样噪声,指标更干净
    • 输出头零初始化。如果输出头随机初始化,训练第一步的预测是随机的大数,重构损失极大,梯度会把编码器冲坏,起步阶段很容易直接塌缩
4-3-4 训练循环与两项损失的监控
  • 训练循环本身没有什么特别的,关键在两项损失必须分开记录
model = TrajVAE(obs_dim=140, hidden=64, latent=16).to(device)
optimizer = torch.optim.Adam(model.parameters(), lr=1e-3)

for epoch in range(1, 101):
    model.train()
    for (batch,) in train_loader:
        batch = batch.to(device)
        loss, rec, kld = model.loss(batch, beta=1.0)   # 三个值都要

        optimizer.zero_grad(set_to_none=True)
        loss.backward()
        torch.nn.utils.clip_grad_norm_(model.parameters(), 5.0)   # 梯度裁剪
        optimizer.step()

    # 验证集上同样把两项分开算,evaluate 就是把 model.loss 在 val_loader 上跑一遍
    # 再顺手把重构误差反归一化回物理动作单位
    val_metrics = evaluate(model, val_loader, device)
    print(f"[epoch {epoch:3d}] val={val_metrics['loss']:9.2f} "
          f"recon={val_metrics['recon']:9.2f} kld={val_metrics['kld']:8.2f}")
  • 每训完一个 epoch 就把 checkpoint 存一次,只保留验证损失最低的那一份
  • 本项目还会在每个 epoch 结束时把 history.csv 落盘一次,这样一次长训练可以从外部观察进度,也能中途安全终止
  • 本文实验用到的超参数如下,复现时直接照抄
超参数值超参数值
窗口长度 H H H20潜维度 L L L16
窗口步长 stride1隐层宽度64
验证集 episode 比例0.1隐层数1
epoch 数100优化器Adam
batch size256学习率 10 − 3 10^{-3} 10−3
随机种子0权重衰减0
β \beta β1.0梯度裁剪5.0
  • 分开记录的理由在第 3-7 节说过,这里落到实践上:
    • val_recon 涨,说明模型表达能力不够,或者 β 太大把重构压死了
    • val_kld 掉到接近 0,说明后验塌缩了,解码器已经不看 z z z
    • 只看总损失,这两个完全相反的问题会表现出同一种症状,根本没法定位
  • 本次训练的实测曲线如下,两条曲线分别在左右两个子图

请添加图片描述

  • 最优验证损失是 30.080,出现在第 93 个 epoch,全程 141 秒,单卡 CUDA
4-4 Trajectory Reconstruction
  • 第一个实验最直接:取验证集里没训过的 episode,编码再解码,看还原得怎么样
真实: a1, a2, a3, ..., a20
VAE : a1', a2', a3', ..., a20'
  • 结果如下,七个动作维度各一个子图,实线是真实、虚线是重构,第八个子图是各维 RMSE

请添加图片描述

  • 再换一个角度看,真实与重构的 20×7 热力图对照

请添加图片描述

4-4-1 为什么用 R² 而不是只看 RMSE
  • 量化指标用每维 RMSE 与 R 2 R^2 R2,两者回答的问题不一样

RMSE = 1 n ∑ i = 1 n ( y i − y ^ i ) 2 , R 2 = 1 − ∑ i ( y i − y ^ i ) 2 ∑ i ( y i − y ˉ ) 2 \text{RMSE} = \sqrt{\frac{1}{n}\sum_{i=1}^{n}(y_i - \hat{y}_i)^2}, \qquad R^2 = 1 - \frac{\sum_i (y_i - \hat{y}_i)^2}{\sum_i (y_i - \bar{y})^2} RMSE=n1​i=1∑n​(yi​−y^​i​)2 ​,R2=1−∑i​(yi​−yˉ​)2∑i​(yi​−y^​i​)2​

  • 说人话,两者的差别在于"有没有把数据的自身尺度除掉"

    • RMSE 是绝对误差,单位就是动作本身的单位,数值大不代表模型差,可能只是这一维动作本来就大
    • R 2 R^2 R2 是相对误差,分母是这一维自身的方差,等于在问"模型解释了这一维多少的波动"
  • 所以 R 2 R^2 R2 比 RMSE 更能说明"结构有没有被保住",而不同维度的 RMSE 不能直接横向比较

  • 举个例子:夹爪的 RMSE 是 0.197,旋转的 RMSE 只有 0.011,差了将近 18 倍,但两者的 R 2 R^2 R2 分别是 0.960 和 0.936,模型对它们的建模水平其实差不多

  • R 2 R^2 R2 还有两个好用的性质

    • 恒等映射的 R 2 R^2 R2 是 1,永远输出均值的"傻子模型" R 2 R^2 R2 是 0,所以 0 是一条天然的分界线
    • R 2 R^2 R2 可以是负数,负得越多说明模型比直接输出均值还差,通常意味着训练方向反了
  • 逐维结果

维度dpos xdpos ydpos zdrot xdrot ydrot zgripper
R²0.9400.9480.9590.9360.9400.9530.960
RMSE(原始单位)0.0800.0870.0930.0110.0160.0180.197
  • 平均 R 2 = 0.948 R^2 = 0.948 R2=0.948,七个维度没有一个被放弃,最低的 drot x 也有 0.936
    • 这正好验证了第 4-2-3 节的判断:不做按维归一化,旋转那三维的 R 2 R^2 R2 会明显掉下去
    • 夹爪的 RMSE 0.197 看起来最大,但它的量纲本来就是正负 1 的开关量, R 2 R^2 R2 反而是最高的 0.960
    • 验证集重构 RMSE 反归一化之后是 0.096,而动作本身的池化 std 是 0.46,误差大约两成
4-5 Latent Space Visualization
  • 把验证集轨迹编码成 z ∈ R 16 z \in \mathbb{R}^{16} z∈R16,再用 PCA 与 t-SNE 降到二维,按 LIBERO task 上色
  • t-SNE(t-distributed Stochastic Neighbor Embedding,t 分布随机邻域嵌入)是一种非线性降维方法,比 PCA 更擅长展示局部邻域结构
  • 40 个 task 的图例只标 T00 到 T39,它们是 LIBERO 任务列表里的编号

请添加图片描述

请添加图片描述

4-5-1 潜空间散点怎么读
  • 这两张图要看的不是"有没有分开",而是同色点有没有聚在一起、不同色点之间有没有连续过渡
  • 判断标准可以拆成三条
    • 同一个 task 的窗口应该聚成一团,因为同一个任务的动作风格是稳定的
    • 相邻的两团之间应当有过渡点,而不是一刀切开的鸿沟,有过渡才说明潜空间是连续的
    • 不同 task 的点互相交叠是正常的,因为底层动作原语本来就重叠
  • PCA 前两维只解释了 18.7% 的方差,看起来不高,但这不等于失败
  • 40 个 task 的底层动作原语高度重叠,都是"伸过去、夹住、抬起来、放下"这四步,差别只在目标物体与位置
  • 潜空间本来就不该按 task 分成 40 坨,我们也没有理由期待它分得开
  • 这个实验真正要看的是:VAE 是否学到了一个能描述动作轨迹的低维表示,以及这个表示的连续性

说人话:这两张散点图回答两个问题:动作轨迹的低维表示连不连续,同一个任务聚不聚得起来。前者看团与团之间是平滑过渡还是一刀切开的鸿沟,后者看同色点有没有抱成一团。

4-5-2 逐维 KL 与塌缩判据
  • 真正能说明问题的是第三张图,也是 AE 完全没有的一张图:每个潜维度的聚合后验分布,与标准正态先验的对照

请添加图片描述

  • 每个子图上方的标题标的是该维的 K L sample KL_{\text{sample}} KLsample​,横轴标签标的是矩匹配的 K L agg KL_{\text{agg}} KLagg​,两者含义不同
  • 每个子图的读法是
    • 灰色直方图是从验证集所有窗口采样出的 z z z 在该维上的分布,也就是聚合后验的经验版本
    • 黑色曲线是标准正态先验的密度
    • 两者贴合说明这一维的边缘像先验,标题里的 K L sample KL_{\text{sample}} KLsample​ 才说明解码器用不用这一维
  • 实测结果
量值
逐样本总 KL(损失里那一项)16.72 nats,16 维平均约 1.04 nats 每维
边缘总 KL(矩匹配)0.025 nats
边缘 μ \mu μ 范围 − 0.06 -0.06 −0.06 到 + 0.05 +0.05 +0.05
边缘 σ \sigma σ 范围0.99 到 1.07
塌缩维度1 个,即 z[7], K L sample = 0.000 KL_{\text{sample}} = 0.000 KLsample​=0.000
活跃维度的 K L sample KL_{\text{sample}} KLsample​ 范围0.42 到 2.06
  • 把这几个数字放在一起,正好复现第 3-9 节那个反直觉的结论
    • 16 个维度的边缘分布全都贴合先验, σ \sigma σ 全落在 0.99 到 1.07 之间, K L agg KL_{\text{agg}} KLagg​ 总共 0.025 nats
    • 但 z[7] 已经塌缩了,所有输入的 μ \mu μ 都挤在 0.019, σ \sigma σ 约等于 1.0,解码器完全不读它
  • 边缘分布像不像先验,完全不能告诉我们解码器用没用这一维。 一个塌缩维度就是先验本身,边缘看起来当然完美
  • 判断塌缩只能看 K L sample KL_{\text{sample}} KLsample​,本项目用它作为唯一的判据
4-6 Random Generation
  • 这是从自编码器迈向生成模型的关键一步:不输入任何真实轨迹,直接从先验采样 z ∼ N ( 0 , I ) z \sim \mathcal{N}(0, I) z∼N(0,I),解码得到全新的动作轨迹
@torch.no_grad()
def sample(self, n, device=None):
    device = device or next(self.parameters()).device
    z = torch.randn(n, self.latent, device=device)   # 16 维,直接从先验抽
    return self.decode(z)                            # 解码即生成
  • 采样 8 条,七个动作维度各一个子图

请添加图片描述

4-6-1 判断生成质量的三项对照
  • 只看"曲线长得像不像"是不够的。曲线画得像,可能只是因为它长得像一条平均轨迹,而平均轨迹本身往往不是任何一条真实轨迹
  • 所以还要做三项定量对照

请添加图片描述

  • 三项对照分别是动作幅度、时间连续性、维度协调关系,各自对应生成模型的一个失效模式
    • 动作幅度对应"输出向均值收缩",生成的东西幅度普遍比真实小
    • 时间连续性对应"动作抖动",相邻两步之间跳变过大
    • 维度协调关系对应"维度解耦",每一维单独看都对,但组合起来不像人做的
  • 动作幅度,逐维对比生成与真实的 std
维度dpos xdpos ydpos zdrot xdrot ydrot zgripper
生成 / 真实 std 比0.990.630.880.940.800.460.89
  • 时间连续性,统计平均的相邻步变化量 ∣ a ( t + 1 ) − a ( t ) ∣ \lvert a(t+1) - a(t) \rvert ∣a(t+1)−a(t)∣,越大越抖

    • 平移与旋转各维是真实的 0.6 到 0.8 倍,比真实更平滑
    • 但夹爪维反而是真实的 2.2 倍,0.063 对 0.028,采样出的夹爪开合比真实更频繁
    • 这是生成质量最明显的一处缺陷,夹爪是开关量,模型对它的多峰结构拟合得不好
  • 维度协调关系,算 7×7 的跨维相关矩阵,真实与生成逐点对比

    • 非对角元素与真实的相关系数是 r = 0.657
    • 维度之间的协调关系大体学到了,但没有完全复现
  • 生成样本幅度普遍偏小,尤其 drot z 只有真实的 46%

  • 这不是玄学,原因在下一节的 β 消融里能定量地看出来:部分潜维度塌缩了,解码器能拿到的信息变少,输出就向均值收缩

  • 这也是生成模型特有的分布覆盖问题,AE 根本没有 β 这一项可选,也就谈不上权衡

4-7 Latent Interpolation
  • 取两条不同 task 的真实轨迹 A 1 , A 2 A_1, A_2 A1​,A2​,编码得到 z 1 , z 2 z_1, z_2 z1​,z2​,在线段上线性插值

z α = ( 1 − α ) z 1 + α z 2 , α ∈ [ 0 , 1 ] z_\alpha = (1 - \alpha) z_1 + \alpha z_2, \qquad \alpha \in [0, 1] zα​=(1−α)z1​+αz2​,α∈[0,1]

  • 代码就是两行加权,注意 alpha 是标量时可以直接广播
z1, z2 = encode(A1), encode(A2)                  # 各是 (16,)
z_alpha = (1 - alpha) * z1 + alpha * z2          # 标量乘向量,广播
traj = decode(z_alpha).reshape(20, 7)
  • 各 α 解码出的轨迹叠在一起,颜色即 α,虚线是两条真实端点轨迹

请添加图片描述

  • 把插值路径投影到 PCA 平面上,灰色背景是验证集潜变量分布

请添加图片描述

  • 选的两条轨迹是 task T00(放杯子)与 task T20(拿橙汁)
量值
∥ z 1 − z 2 ∥ \lVert z_1 - z_2 \rVert ∥z1​−z2​∥2.768
相邻 α 之间的解码变化量0.605 / 0.633 / 0.626 / 0.639 / 0.650
端点重构 RMSE(A / B)0.0653 / 0.0432
末端路径长度随 α72 / 77 / 90 / 110 / 132 / 156 mm
  • 解码变化量的最大值与最小值之比是 1.074,几乎是均匀的
4-7-1 插值检验的是潜空间的什么性质
  • 这个指标检验的是潜空间的连续性与凸性
    • 如果潜空间是"碎"的,点与点之间是空洞,中间 α 解码出来的轨迹会变得毫无意义,动作乱跳、幅度爆炸或者塌成一条直线
    • 变化量突变则说明潜空间里存在断层,跨过断层时解码结果会跳变
  • 实测变化量基本均匀,说明两点之间没有断层,中间 α 解码出来的都是合法的动作序列,不是噪声
  • 末端路径长度随 α 单调递增,说明从 A 的动作形状到 B 的动作形状是连续变形过去的
  • 这一条性质在机器人领域价值很高:它是"技能插值"与"技能组合"的前提,也是后面 Diffusion Policy 能被用作数据增强的原因
4-8 把动作积分成末端空间轨迹
  • 前面五组图画的都是 action 对 t,但一条轨迹好不好,最终要看机械臂在空间里怎么动
  • 所以每个实验都额外出一张笛卡尔空间的末端轨迹图

请添加图片描述

请添加图片描述

4-8-1 动作尺度 k 为什么必须拟合
  • 这里遇到第一个真正的方法问题:LIBERO 的 action 是增量,不是位姿
  • action[t] = [dpos(3), drot(3), gripper(1)],dpos 是"这一步末端要往哪个方向挪一点",不是绝对坐标
  • 所以画轨迹必须做一次累加

p t + 1 = p t + k ⋅ a t , : 3 p_{t+1} = p_t + k \cdot a_{t,:3} pt+1​=pt​+k⋅at,:3​

  • 难点在 k k k,也就是"一个动作单位等于多少米"
  • 这个数数据集里没有给,因为它是控制器的性质,不是这个场景的性质
  • 所以不能写死一个常数,正确的做法是从训练集 episode 上用最小二乘拟合

k ⋆ = arg ⁡ min ⁡ k ∑ t ∥ ( p t + 1 − p t ) ⏟ 真实位移 − k ⋅ a t , : 3 ∥ 2 = ∑ t a t , : 3 ⊤ ( p t + 1 − p t ) ∑ t a t , : 3 ⊤ a t , : 3 k^\star = \arg\min_k \sum_{t} \big\lVert \underbrace{(p_{t+1} - p_t)}_{\text{真实位移}} - k \cdot a_{t,:3} \big\rVert^2 = \frac{\sum_t a_{t,:3}^\top (p_{t+1} - p_t)}{\sum_t a_{t,:3}^\top a_{t,:3}} k⋆=argkmin​t∑​ ​真实位移 (pt+1​−pt​)​​−k⋅at,:3​ ​2=∑t​at,:3⊤​at,:3​∑t​at,:3⊤​(pt+1​−pt​)​

# trajvae/data.py: fit_action_scale
num += (cmd * delta).sum()      # cmd 是真实动作的前三维,delta 是真实位置差
den += (cmd * cmd).sum()
return num / den                # k 约等于 0.011346 m/unit
  • 起点 p 0 p_0 p0​ 直接取 observation.state 的前三维,那是真实的末端位置,只对增量做积分

  • 拟合出的 k = 0.011346 k = 0.011346 k=0.011346 m/unit,这个值会存进 checkpoint 与 metrics.json

  • 这个近似有多准? 用真实动作积分出来的路径,和真实末端位置对一下,训练前会打印

  • 漂移是随步数累积的,所以必须分尺度看,混在一起讲会得出完全相反的结论

尺度积分漂移加上 VAE 误差参照长度
单窗口(H=20)13.5 mm15.0 mm路径 136 mm
整段 episode,逐窗口重锚定11.1 mm14.5 mm无
整段 episode,从头连续积分87.5 mm无净位移 326 mm
  • 整段 episode 连续积分时,RMSE 87.5 mm,端点误差 97.5 mm,占净位移 326 mm 的 30%,单步漂移率 0.54 mm 每步
  • 也就是说:积分本身就有可观漂移,来源是增量控制的量化误差与夹爪开合期间的零点漂移
  • 一个 20 步窗口内约占路径的 10%,但跨几十步就会累积到净位移的 30%

这正是不把整段 episode 的原始积分直接画出来的原因:漂移会把图彻底画歪。h20_z16_12_trajectory_episode.png 里每个窗口都用自己的真实起点重新锚定,才把漂移压回窗口尺度,让 VAE 的误差显出来。

4-8-2 三条控制线分别隔离了什么
  • 上面那个表说明了一件事:画出来的红线偏了,不能直接算成 VAE 的错
  • 误差有两个来源,必须设计一组对照才能分开,所以每张轨迹图都同时画三条线
线含义
实线 ground truth stateobservation.state 里的真实末端位置
虚线 integrate real actions对真实动作积分,隔离出积分漂移
虚线 integrate reconstructed actions对 VAE 重构的动作积分,漂移与模型误差都在里面
  • 虚线与实线的差距是积分漂移,两条虚线的差距才是 VAE 自己的误差

    • 没有中间这条控制线,我们无法判断一条画歪了的线上到底是模型的问题还是积分的问题
    • 实测结论很明确:单窗口上积分漂移 13.5 mm,VAE 只额外贡献 1.5 mm,约占总误差的十分之一
    • 空间轨迹的误差主要来自积分,不是来自 VAE
  • 把一整个 episode 的 17 个窗口拼起来,单个 20 步窗口只有几厘米,看不出任务结构,拼完能看见完整的"伸过去、夹住、抬起来、放下"

请添加图片描述

  • 先验采样出的 8 条轨迹,叠在 80 条真实窗口上,都从同一个起点画起,形状可以直接比较

请添加图片描述

  • 沿 z α z_\alpha zα​ 插值出来的末端路径,可见从 A 的动作形状连续变形成 B 的
    请添加图片描述

  • 采样的末端路径长度 129.8 mm,真实是 165.2 mm,同样反映第 4-6 节说的幅度偏小

因为 k k k 是拟合出来的,integrate_paths 的绝对尺度依赖它,换数据集要重新拟合。不要把这个数当成常数写进代码,本项目把它存进了 checkpoint。

4-9 β 消融:KL 权重到底在权衡什么
  • 这是全文最能说明"VAE 为什么是生成模型"的一组实验
  • 同数据、同随机种子、同架构,只改 model.beta,各训一次
./run_beta_sweep.sh                          # beta 取 0.1 与 4.0,各训加评测加对比,约 8 分钟
BETAS="0.01 0.1 1 10" ./run_beta_sweep.sh    # 换成别的 beta 组合
  • β 等于 1.0 不重训,它就是默认配置,已经在 runs/h20_z16 训好了,脚本直接复用

请添加图片描述

β重构 R 2 R^2 R2 K L sample KL_{\text{sample}} KLsample​塌缩维度生成 / 真实幅度跨维相关 r采样路径长度
0.10.97338.50/160.890.532159 mm
1.00.94816.71/160.800.657130 mm
4.00.8687.76/160.650.510117 mm
真实无无无1.00无165 mm
  • 三个趋势
    • β 变大,重构变差, R 2 R^2 R2 从 0.973 掉到 0.868。β 趋近 0 就退化成普通自编码器AE
    • β 变大,潜空间更"规矩", K L sample KL_{\text{sample}} KLsample​ 从 38.5 降到 7.7,后验更贴近先验
    • β 变大,塌缩维度变多,从 0 个到 1 个再到 6 个

说人话:这两项是此消彼长的。你想要潜空间更像标准正态,数据的还原就不准;你想还原得更准,潜空间就不像标准正态。 β \beta β 就是拧这两个方向的旋钮。

  • 第三条解释了幅度那一行:β 等于 4 时 16 维里有 6 维是死的,解码器能用的信息变少,于是它对任何 z z z 都输出"平均轨迹"
    • 幅度收缩到真实的 0.65,末端路径只剩 117 mm
  • 所以"生成得不够多样"不是玄学,是后验塌缩的可测量后果
  • β 等于 0.1 时 0 维塌缩,幅度 0.89、路径 159 mm,最接近真实
    • 代价是 K L sample = 38.5 KL_{\text{sample}} = 38.5 KLsample​=38.5,潜空间离先验很远,理论上 z ∼ N ( 0 , I ) z \sim \mathcal{N}(0,I) z∼N(0,I) 采样已经"出分布"了
    • 但它仍然采得出合理轨迹,因为边缘分布依然贴合先验, K L agg KL_{\text{agg}} KLagg​ 只有 0.033
    • 这正好再次说明第 3-9 节那个结论:能不能采样,看的是聚合后验,而不是单个后验有多贴近先验
  • 没有免费的午餐:β 等于 1 在跨维协调性上最好, r = 0.657 r = 0.657 r=0.657,另外两个 β 都更差
  • β 也影响插值的端点保真度,方向和重构一致
β0.11.04.0
插值两端点偏离真实路径2.9 / 2.6 mm6.4 / 5.3 mm11.9 / 18.5 mm
  • 这组实验也证明了可复现性:删掉两个 β 的运行目录后从头重跑,验证损失 8.795 与 61.850、 R 2 R^2 R2、KL、幅度、路径长度全部逐位重现
4-10 评估代码
  • 4-4 到 4-8 报出来的每一个数字,都是下面这几段算出来的。方法本身在上面各节都讲过了,这一节只把代码补齐,让这些数字能被独立复算
    • 4-9 的 β 消融没有新代码,它就是把这套评估在四个 run 上各跑一遍(第 4-10-6 节)
  • 先说一条贯穿始终的约定:所有指标都在验证集上算,编码时用后验均值 μ \mu μ,不用采样出来的 z z z
    • 用均值是确定性的,同一个输入永远给同一个输出,图和数都能复现
    • 也把采样噪声从指标里摘了出去:这里要量的是"模型学到了什么",不是"这一次抽签抽得怎么样"
4-10-1 编码与解码
@torch.no_grad()
def encode_mu(model, x_norm, device, batch=4096):
    """编码到后验均值 mu,确定性,所以图和数都可复现"""
    model.eval()
    out = []
    for s in range(0, len(x_norm), batch):
        chunk = torch.from_numpy(x_norm[s : s + batch]).to(device)
        mu, _ = model.encode(chunk)
        out.append(mu.cpu().numpy())
    return np.concatenate(out, axis=0)


@torch.no_grad()
def encode_stats(model, x_norm, device, batch=4096):
    """返回每个样本后验的均值与方差,算逐维 KL 要用"""
    mus, vars_ = [], []
    for s in range(0, len(x_norm), batch):
        chunk = torch.from_numpy(x_norm[s : s + batch]).to(device)
        mu, logvar = model.encode(chunk)
        mus.append(mu.cpu().numpy())
        vars_.append(logvar.exp().cpu().numpy())
    return np.concatenate(mus), np.concatenate(vars_)


@torch.no_grad()
def decode_raw(model, z, scaler, device, window, batch=4096):
    """解码成物理单位的轨迹,形状 (N, H, D)"""
    out = []
    for s in range(0, len(z), batch):
        chunk = torch.from_numpy(np.asarray(z[s : s + batch], dtype=np.float32)).to(device)
        out.append(model.decode(chunk).cpu().numpy())
    return scaler.inverse_traj(np.concatenate(out, axis=0), window)
  • 分 batch 是因为验证集有两万多个窗口,一次前向会占满显存,分批和不分批的结果完全一样
  • decode_raw 里 inverse_traj 那一步把拉平的 (N, 140) 先拆回 (N, 20, 7),这样 (7,) 的 mean/std 才能广播到最后那一维
4-10-2 重构指标
def per_dim_r2(real, recon):
    """R^2 per action dimension over the whole evaluation set."""
    ss_res = ((real - recon) ** 2).sum(axis=0)
    ss_tot = ((real - real.mean(axis=0)) ** 2).sum(axis=0)
    return 1.0 - ss_res / np.maximum(ss_tot, 1e-12)


# real_all / recon_all 都是 (N, H, D) = (20000, 20, 7),且都已反归一化
rmse_raw_per_dim = np.sqrt(((real_all - recon_all) ** 2).mean(axis=0))    # (H, D)
r2_per_dim = per_dim_r2(real_all, recon_all)                            # (H, D)
rmse_raw_mean = float(np.sqrt(((real_all - recon_all) ** 2).mean()))     # 一个标量
r2_mean = float(r2_per_dim.mean())
  • real_all 是验证集里随机抽的 20000 个窗口,recon_all 是它们过模型再反归一化的结果,两者形状都是 (20000, 20, 7)
  • 求和只压掉了 axis=0 也就是样本维,所以两个结果是 (20, 7) 而不是 (7,):20 个时间步各 7 个动作维度,一共 140 格
  • 4-4 表里那两行是再沿时间步取一次平均得到的,即 r2_per_dim.mean(axis=0) 与 rmse_raw_per_dim.mean(axis=0),各有 7 个数
  • r2_mean 是 140 格的总平均,也就是表里"平均 R 2 = 0.948 R^2 = 0.948 R2=0.948"那个数。它和"逐维 R 2 R^2 R2 再平均"不完全相等,因为每个时间步的方差不一样
  • 两个 RMSE 都在物理动作单位上算,因为 inverse_traj 已经把归一化撤掉了
  • 4-4 正文里"反归一化之后是 0.096"那句,就是这里的 rmse_raw_mean
  • 顺带说清楚另一处容易混的:训练收尾时写进 summary.json 的 final_val_rmse_raw 大约是 0.072,它不是这个数
    • 那个数在训练循环里另算:先把归一化空间的逐格 RMSE 乘回该维的 std,再对 140 格取平均,见 trajvae/train.py 的 evaluate
    • 它是对整块验证集的逐格平均,这里是子采样两万窗口后的池化 RMSE,两者不是同一个量,不要拿来互相核对
4-10-3 潜空间指标
  • 逐样本 KL 就是损失里那一项,逐维算完在样本维取平均
mu_s, var_s = encode_stats(model, val_norm, device)          # 都是 (N, L)

# KL_sample:每个样本的后验对先验的 KL,再对样本取平均
kl_sample = 0.5 * (mu_s**2 + var_s - np.log(np.maximum(var_s, 1e-12)) - 1.0).mean(axis=0)
  • 聚合后验 q ( z ) q(z) q(z) 是高斯混合,它的矩要按全方差公式算,两行都不能少
# E[z]  = mean(mu_i)
# Var[z] = mean(var_i) + var(mu_i)      <- 组内 + 组间,缺了第一项就全错
mu_all  = mu_s.mean(axis=0)
var_all = var_s.mean(axis=0) + mu_s.var(axis=0)

# 高斯混合没有闭式 KL,这里算的是矩匹配高斯对先验的 KL
kl_agg = 0.5 * (mu_all**2 + var_all - np.log(np.maximum(var_all, 1e-12)) - 1.0)

# 画直方图要从每个样本自己的后验里抽一次,这才是聚合后验的真实样本
z_agg = mu_s + np.sqrt(var_s) * rng.standard_normal(mu_s.shape)
  • var_s.mean(axis=0) 是组内方差,mu_s.var(axis=0) 是组间方差。漏掉第一项,每个维度都会变成一根尖峰,塌缩维的 KL 会被算成无穷大
  • 直方图这里有个更容易踩的坑:不能直接对 mu_s 做直方图。那样得到的是"后验均值"的分布,宽度比真实的聚合后验小了 E [ σ ] \mathbb{E}[\sigma] E[σ],每一格的图都会画错
  • PCA 散点是对 encode_mu 出来的 16 维 z 做的,解释方差比 18.7% 指的是这一层,不是对 140 维动作做的 PCA
4-10-4 生成质量指标
# 时间连续性:相邻步变化量的平均,越大越抖。生成和真实各调一次
def smoothness(traj):
    """Mean absolute first difference per action dimension."""
    return np.abs(np.diff(traj, axis=1)).mean(axis=1)        # (N, H, D) -> (N, D)


torch.manual_seed(int(cfg.train.seed))                       # 固定种子,换台机器也是同一批样本
with torch.no_grad():
    z = torch.randn(n, model.latent, device=device)          # n = eval.n_generate = 8
    gen = scaler.inverse_traj(model.decode(z).cpu().numpy(), window)   # (n, H, D)

real = bundle["val"].x.astype(np.float32)                    # (23021, H, D)
gen_flat, real_flat = gen.reshape(-1, dim), real.reshape(-1, dim)

# 1. 动作幅度
std_real, std_generated = real_flat.std(axis=0), gen_flat.std(axis=0)
amplitude_ratio = std_generated / np.maximum(std_real, 1e-9)

# 2. 时间连续性
step_real, step_gen = smoothness(real).mean(axis=0), smoothness(gen).mean(axis=0)

# 3. 维度协调关系:7 乘 7 的跨维相关矩阵,真实与生成逐点对比
corr_real, corr_gen = np.corrcoef(real_flat.T), np.corrcoef(gen_flat.T)
off = ~np.eye(dim, dtype=bool)                               # 去掉对角线
corr_offdiag_r = float(np.corrcoef(corr_real[off], corr_gen[off])[0, 1])
  • 三项各对应一个失效模式,缺一项就可能漏掉一类问题:只看幅度,看不出动作抖;只看幅度和平滑度,看不出维度之间配合得对不对
  • 这里有一处统计上的注意事项:真实那一侧用了全部两万多个验证窗口,生成那一侧只有 n = 8 条轨迹。8 条轨迹内部 20 步是相关的,等效样本量接近 8 而不是 160,所以幅度比这类数字的相对不确定度在两成左右
    • 换句话说,0.63 与 0.46 这种单个维度的比值,只能当"明显偏小"来读,不能当成两位有效数字
    • β 消融那张表里的 0.89 / 0.80 / 0.65 是三条曲线各自的均值,趋势可信,但相邻两档之间的差距不宜过度解读
    • 要让这几个数真正稳,把 eval.n_generate 从 8 提到 256 再重跑 run_experiments.py 即可,其余代码不用动
4-10-5 插值指标
# 两条端点轨迹取自两个不同 task:task 列表里的第一个和中间那个
z_a = encode_mu(model, val_norm[idx_a : idx_a + 1], device)[0]       # (L,)
z_b = encode_mu(model, val_norm[idx_b : idx_b + 1], device)[0]

alphas = np.linspace(0.0, 1.0, steps)                                # steps = eval.interp_steps = 6
z_path = np.stack([(1 - a) * z_a + a * z_b for a in alphas])         # (steps, L)
decoded = decode_raw(model, z_path, scaler, device, window)          # (steps, H, D)

# 相邻 alpha 之间的解码变化量:突变说明潜空间里有断层
step_change = np.linalg.norm(np.diff(decoded, axis=0), axis=(1, 2))
# 实测 0.605 / 0.633 / 0.626 / 0.639 / 0.650,最大最小之比 1.074

# 末端路径长度,单位 mm。所有曲线都从同一个 origin 积分出来,
# 否则各自的起点不同,形状就没法直接比
interp_paths = integrate_paths(decoded, origin, scale)               # (steps, H+1, 3)
path_len = [np.linalg.norm(np.diff(p, axis=0), axis=1).sum() * 1000 for p in interp_paths]

# 解码路径偏离真实路径多少,同样在末端空间量。动作空间的 RMSE 说不出这件事,
# 单步很小的误差也能积分成肉眼可见的偏移
for tag, real_path, rec_path in (("A", path_a, interp_paths[0]), ("B", path_b, interp_paths[-1])):
    err = np.linalg.norm(rec_path - real_path, axis=1) * 1000        # (H+1,),单位 mm
    endpoint_error[tag] = {"mean_mm": err.mean(), "final_mm": err[-1], "max_mm": err.max()}
  • step_change 那五个数就是 4-7 表里"相邻 α 之间的解码变化量"那一行,最大最小之比 1.074 也是从它来的
  • path_len 是 4-7 表里"末端路径长度随 α"那一行:72 / 77 / 90 / 110 / 132 / 156 mm
  • 这两个指标要一起看:step_change 几乎均匀,说明动作是平滑变形过去的;path_len 单调变长,说明变的是形状而不是幅度。只看其中一个会得出相反的印象
  • 端点误差必须在末端路径上量,不能在动作空间量,原因就是第 4-8 节那条:单步误差会累积
4-10-6 β 消融
  • 4-9 没有单独的评估代码。它就是把上面四个实验在每一个 run 上各跑一遍,再把这些标量并排
  • 所以 compare_betas.py 做的事只有两件:读每个 run 目录下的 metrics.json,按 β \beta β 排序后画成对比图
  • 这也是为什么那组实验敢说"只改了 β":评估代码完全共用,任何一个数字的差异都只能来自训练

总结

  • 本文从生成模型的基本概念出发,先讲清楚 AE 的结构与它为什么不能生成,再由这个缺口引出 VAE 的概率潜变量、重参数化、KL 散度与 ELBO 推导,最后用一套完整的机器人轨迹实验把这套理论跑通,并配合切片代码逐段对照了张量形状
  • 核心要点回顾:
    • 判别模型建模 p ( y ∣ x ) p(y \mid x) p(y∣x),生成模型建模 p ( x ) p(x) p(x),后者一旦学到,生成就是从分布里采样
    • AE 的三处硬伤:损失里没有 p ( z ) p(z) p(z),潜空间有空洞,潜空间不连续。所以它只能重构,不能生成
    • VAE 的关键改动只有一处:编码器输出从"一个点"变成"一个分布",即 μ \mu μ 与 log ⁡ σ 2 \log \sigma^2 logσ2 两路,再由切片 h2[:, :16]、h2[:, 16:] 劈开
    • 重参数化 z = μ + σ ⊙ ε z = \mu + \sigma \odot \varepsilon z=μ+σ⊙ε 把随机性挪到与参数无关的 ε \varepsilon ε 上,让采样这一步变得可导
    • 损失是负 ELBO,由重构项与 KL 项组成,两项量纲要手动对齐,本项目是在 140 个输出维度与 16 个潜维度上分别求和
    • 聚合后验 q ( z ) q(z) q(z) 是高斯混合,方差有组内与组间两项,缺一项会算出违反 Jensen 不等式的荒谬结果
    • K L sample KL_{\text{sample}} KLsample​ 判断维度是否塌缩, K L agg KL_{\text{agg}} KLagg​ 判断能否采样,实测 16 维边缘全部贴合先验,但 z[7] 依然塌缩
    • β 消融给出了完整因果链:β 变大导致塌缩维度变多,塌缩维度变多导致生成幅度收缩,从 0.89 掉到 0.65
    • 动作积分回笛卡尔空间时误差主要来自积分而非模型,单窗口漂移 13.5 mm,VAE 只额外贡献 1.5 mm
  • 下一期我们会在此基础上进入 DDPM,看扩散模型是怎么把"一次采样"换成"逐步去噪"的,以及它为什么能在生成质量上超过 VAE
  • 如有错误,欢迎指出!
  • 感谢观看!
Logo

DAMO开发者矩阵,由阿里巴巴达摩院和中国互联网协会联合发起,致力于探讨最前沿的技术趋势与应用成果,搭建高质量的交流与分享平台,推动技术创新与产业应用链接,围绕“人工智能与新型计算”构建开放共享的开发者生态。

更多推荐