1. 范围与记号

这次把 Encoder2D/Decoder2D 内部展开到每个残差块、注意力块、采样层和首尾卷积,依据当前 config/train_vqvae_mini.py 继承的配置,不使用类定义的默认参数代替真实配置。

  • B=1 是样本数,F=6 是帧数,N=B×F=6。下文用 N 表示逐帧运算的批量维。
  • 四维张量统一写成 [N,C,H,W],C是每个空间位置的特征维数,H/W是水平网格尺寸。这里图中的H/W是两个水平轴,物理高度是输入的最后16层。
  • RB(a→b) 表示残差块,把通道a变成b;其两层主支卷积都为3×3、stride=1、padding=1。
  • Attn(C) 是此VQVAE内部的全图空间注意力,不是后续世界模型的时间注意力。
  • 配置:base_channel=64、ch_mult=(1,2,4)、num_res_blocks=2、attn_resolutions=(50,)、z_channels=128、double_z=False、dropout=0。

验证证据: 本次在OccWorld 环境与 GPU 上,用真实mini配置构建VAERes2D,随机类别输入 [1,6,200,200,16],执行编码→量化→解码的无梯度前向,并用hooks记录下文逐层形状。没有加载训练权重,不代表重建质量验证。

2. 输入向量如何形成

原始类别ID,整数
1 × 6 × 200 × 200 × 16

class_embeds:查18×8的类别表
每个格子一个8维向量
1 × 6 × 200 × 200 × 16 × 8

reshape:16个高度格各8维,按顺序拼接
6 × 200 × 200 × 128

permute:把特征维移到通道轴
6 × 128 × 200 × 200

同一水平位置的128维向量,最初可理解为:

[高度0的8个数 | 高度1的8个数 | … | 高度15的8个数]

查表改变“一个ID”的表示形式;reshape/permute只调整组织方式,不学习参数、不平均高度,也不改变对应元素的数值。从后面的卷积开始,通道被学习性混合,不能再把某个通道简单对应回某一固定高度或语义。

6帧在这条网络中是6个逐帧样本,卷积和空间注意力不会跨帧读取信息;时间关系交给后续世界模型。

3. Encoder2D:所有层与维度

下面每个图框标出真实模块名,能直接在debugger的模型对象里找到。图中N=6;未标明改变尺寸的步骤均保留当前H/W。

编码输入
N × 128 × 200 × 200

encoder.conv_in
3×3 Conv:128→64,stride=1,padding=1
N × 64 × 200 × 200

down.0.block.0:RB 64→64
N × 64 × 200 × 200

down.0.block.1:RB 64→64
N × 64 × 200 × 200

down.0.downsample
3×3 Conv:64→64,stride=2,padding=1
N × 64 × 100 × 100

down.1.block.0:RB 64→128
主支与shortcut均改变通道
N × 128 × 100 × 100

down.1.block.1:RB 128→128
N × 128 × 100 × 100

down.1.downsample
3×3 Conv:128→128,stride=2,padding=1
N × 128 × 50 × 50

down.2.block.0:RB 128→256
N × 256 × 50 × 50

down.2.attn.0:Attn 256
每帧2500个位置相互读取
N × 256 × 50 × 50

down.2.block.1:RB 256→256
N × 256 × 50 × 50

down.2.attn.1:Attn 256
N × 256 × 50 × 50

mid.block_1:RB 256→256
N × 256 × 50 × 50

mid.attn_1:Attn 256
N × 256 × 50 × 50

mid.block_2:RB 256→256
N × 256 × 50 × 50

encoder.norm_out:GroupNorm,32组
Swish激活
N × 256 × 50 × 50

encoder.conv_out
3×3 Conv:256→128,stride=1,padding=1
z:N × 128 × 50 × 50

编码器共8个RB:三个尺度各2个,加mid里的2个;共3个空间注意力块,都在50×50处。200×200和100×100尺度没有实例化该配置的stage注意力。

下采样由stride=2卷积完成,没有在当前配置使用池化。编码器实际记录 shapes=[[200,200],[100,100]],供解码器恢复尺寸;不是把编码特征保存给解码器拼接。配置文件的另一个全局shapes变量不应当作这里forward实际返回的结果。

每个空间位置的向量经历了什么

  1. conv_in:某个输出位置从邻近3×3水平位置、每处128个通道读取信息,学出64维特征。卷积不是简单截取前64维。
  2. RB:多次组合空间邻域和通道,学习更复杂的模式,并用残差支路保留输入信息。
  3. stride=2卷积:采样位置变稀,200→100→50;通道仍携带混合后的空间与高度信息。它是可学习压缩,通常不能无损反推原输入。
  4. 50×50处注意力:每个位置按内容读取同帧所有2500个位置的信息,获得全图上下文。
  5. conv_out:把256维上下文特征投影成128维潜在特征,准备和码本比较。

50×50网格相邻采样位置间距对应原网格的4倍,但不能把一个latent位置理解为仅代表一个互不重叠的4×4原始小块:卷积邻域会重叠,注意力和归一化还引入更大范围的依赖。

4. ResnetBlock:输入向量具体怎样变化

以 encoder.down.1.block.0 为例,输入64通道、输出128通道,空间保持100×100。

输入 x
N × 64 × 100 × 100

norm1:GroupNorm
32组,每组2个通道
形状不变

Swish:逐元素 x乘sigmoid(x)
形状不变

conv1:3×3,64→128
stride=1,padding=1
N × 128 × 100 × 100

norm2:GroupNorm
32组,每组4个通道
形状不变

Swish → Dropout,当前p=0
形状不变

conv2:3×3,128→128
N × 128 × 100 × 100

shortcut:1×1 Conv,64→128
N × 128 × 100 × 100

逐元素相加:主支输出 + shortcut输出
N × 128 × 100 × 100

  • GroupNorm(分组归一化):按每个样本、每组通道及其空间位置统计均值和方差,再缩放平移,帮助数值稳定。本配置各RB通道均大于32,使用32组;归一化不是简单对当前位置的向量单独操作。
  • Swish:非线性函数,改变数值而不改形状,让多层网络能表达复杂关系。
  • 3×3卷积:同时混合邻近位置和通道。输出某个通道的数是对3×3×输入通道数个值加权求和,再加偏置。
  • residual(残差):输出为shortcut(x)+主支(x),让网络学习在输入基础上应当补充/调整什么。
  • 通道相同的RB,shortcut是原输入;通道不同,当前默认用1×1卷积匹配通道。不是沿通道拼接,和世界模型U-Net的skip拼接不同。
  • temb=None且temb_channels=0:本配置没有额外时间步嵌入加入该块。Swish也不要误读为世界模型UnetBlock里的ReLU。

5. AttnBlock:2500个位置怎样相互读取

以输入 [N,256,50,50] 为例。这里是单组缩放点积空间注意力,代码没有像后续MultiheadAttention那样显式划分多个头,也没有时间因果遮罩。

输入 x
N × 256 × 50 × 50

GroupNorm
N × 256 × 50 × 50

q:1×1 Conv,256→256
展平并转置
Q:N × 2500 × 256

k:1×1 Conv,256→256
展平
K:N × 256 × 2500

v:1×1 Conv,256→256
展平
V:N × 256 × 2500

bmm:Q乘K,再除以sqrt(256)
分数:N × 2500 × 2500

softmax,沿最后一个维度
A:N × 2500 × 2500
每个查询位置对全部位置的读取权重

代码用 V乘A的转置
等价于逐查询对V做加权求和
N × 256 × 2500

恢复空间布局
N × 256 × 50 × 50

proj_out:1×1 Conv,256→256
形状不变

与原输入x逐元素相加
N × 256 × 50 × 50

Q可以理解为“我想找什么”,K是“我有哪些可匹配的特征”,V是“真正提供的内容”。某个位置通过Q/K匹配得到2500个权重,再加权读取所有位置的V。

形状不变不代表向量没变化: 输入与输出都是256维,但每个位置的输出已经混入整帧的相关空间信息。权重矩阵每帧有2500×2500=625万项,所以把注意力放在50×50而不是200×200会明显减小开销。

6. VQ:连续向量在哪里变成离散编号

z:N × 128 × 50 × 50

quant_conv:1×1 Conv,128→128
学习特征投影,形状不变

rearrange:N × 50 × 50 × 128
展平:2500N × 128

每个向量与512行码本比较平方距离
码本:512 × 128
距离:2500N × 512

argmin:每行选一个最近编号
2500N个整数,范围0~511
N=6时为15000个token

embedding查表
2500N × 128
再恢复 N × 50 × 50 × 128

计算量化损失
直通估计:z加上detach后的差值
前向数值仍是选中的码本向量

恢复布局
N × 128 × 50 × 50

post_quant_conv:1×1 Conv,128→128
适配解码器,形状不变

这里最核心的数值变化是:连续特征被一个有限候选向量替代。reshape只重排;quant_conv/post_quant_conv学习性改变数值;argmin产生整数ID;查表将ID重新变回连续向量。

直通估计z + (z_q-z).detach()的前向等于z_q,但反向把此路径对z的梯度当作恒等映射,帮助编码器学习;码本另外通过量化损失里的未detach部分更新。不能理解成argmin本身可直接求导。

7. Decoder2D:所有层与维度

量化与post_quant_conv后的特征
N × 128 × 50 × 50

decoder.conv_in
3×3 Conv:128→256,stride=1,padding=1
N × 256 × 50 × 50

mid.block_1:RB 256→256
N × 256 × 50 × 50

mid.attn_1:Attn 256
N × 256 × 50 × 50

mid.block_2:RB 256→256
N × 256 × 50 × 50

up.2.block.0:RB 256→256
N × 256 × 50 × 50

up.2.attn.0:Attn 256
N × 256 × 50 × 50

up.2.block.1:RB 256→256
N × 256 × 50 × 50

up.2.attn.1:Attn 256
N × 256 × 50 × 50

up.2.upsample
nearest插值:50→100;按shapes对齐
再3×3 Conv:256→256
N × 256 × 100 × 100

up.1.block.0:RB 256→128
N × 128 × 100 × 100

up.1.block.1:RB 128→128
N × 128 × 100 × 100

up.1.upsample
nearest插值:100→200;按shapes对齐
再3×3 Conv:128→128
N × 128 × 200 × 200

up.0.block.0:RB 128→64
N × 64 × 200 × 200

up.0.block.1:RB 64→64
N × 64 × 200 × 200

decoder.norm_out:GroupNorm,32组
Swish激活
N × 64 × 200 × 200

decoder.conv_out
3×3 Conv:64→128,stride=1,padding=1
N × 128 × 200 × 200

解码器也有8个RB和3个空间注意力块。真正运行顺序是mid → up[2] → up[1] → up[0];名称中的up[0]不是先执行的第一层。

上采样先用最近邻插值复制值:一个位置扩为2×2,空间尺寸翻倍;随后3×3卷积重新混合邻域、学习恢复细节。此处没有用转置卷积,也没有把编码器特征通过U-Net跳跃连接接回来。nearest本身不学习新细节,后面的网络负责学习;整个有损压缩也不保证精确恢复原输入。

当前尺寸正好是50→100→200,不需要额外补边;源码仍根据shapes计算pad,兼顾其他形状。shapes在解码中pop,解码后这个列表已被消耗。

8. 解码最后的128维向量如何恢复18种类别

解码器输出 [N,128,200,200] 尚不是18类分数,需要由VAERes2D.forward_decoder()继续处理:

Decoder2D输出
6 × 128 × 200 × 200

permute:6 × 200 × 200 × 128
将128拆回16层,每层8维

源码reshape为
240000 × 16 × 8
240000 = 6 × 200 × 200

共享class_embeds类别表
18 × 8 → 转置并加批量维
1 × 8 × 18

matmul:每格8维向量与18个类别向量做点积
240000 × 16 × 18

恢复原样本和帧布局
1 × 6 × 200 × 200 × 16 × 18
18个值是logits,未做softmax

argmax最后一维
1 × 6 × 200 × 200 × 16
每个三维格子一个预测类别ID

8维向量不是直接四舍五入为类别编号,而是与18行类别向量分别做点积,取最高分对应的类别。输入和输出使用同一个class_embeds权重表。

argmax只在该类的非训练分支自动执行。训练返回logits与embed_loss,外部loss函数计算重建交叉熵、Lovasz与量化损失。当前loss标签使用inputs:第一阶段学习重建当前帧,不是预测未来帧。

9. 按层调试:看值、看形状、看哪个操作改变信息

暂停位置观察对应问题
vae_2d_resnet.py:208x.shape;F10后变成6维类别查表如何展开
:211x.shape为[6,128,200,200]高度与embedding如何并到通道
:335F10前后看h.shape第一个卷积如何128→64
:340i_level、i_block、h.shape;F10前后比较每个RB是否改变通道
:343h.shape,进入AttnBlock哪个stage有注意力
:348F10前后h.shapestride=2怎样缩小空间
:161q.shape、k.shape;F10后w_.shape2500×2500注意力矩阵怎样形成
:168v.shape、w_.shape;F10后h_.shape权重怎样聚合所有位置的向量
:360F10前后h.shape编码器尾部256→128
quantizer.py:98d.shape;F10后min_encoding_indices连续特征怎样选成整数token
vae_2d_resnet.py:444F10前后h.shape解码器入口128→256
:455i_level、i_block、h.shape解码RB的通道变化
:459F10前后h.shape插值与卷积如何恢复分辨率
:220logits.shape、template.shape;F10后similarity8维如何变18类分数

断点停下时当前行未执行。相同ResnetBlock/AttnBlock函数会被很多实例共用,先从调用者的i_level/i_block或调用栈确认实例;不要只看函数名判断层级。比如ResnetBlock.forward第98行可观察self.in_channels,self.out_channels,x.shape。

10. 三个loss:重建、区域重叠与码本学习

Loss(损失)是训练时的“扣分规则”:模型计算预测与目标的差别,反向传播算出怎样改参数能减少扣分,再由optimizer.step更新参数。三个loss在同一个batch中计算、加权求和,最后一次backward;不是三个独立模型,也不是轮流训练三次。

10.1 对照总表与当前权重

模块在检查什么使用什么数据当前外层权重
ReconLoss每个三维格子的类别预测是否正确、有信心解码后的18类logits与原始输入类别ID10
LovaszLoss每个类别预测区域与真实区域是否重叠,兼顾漏检/误检同一组logits,经softmax后与原始类别ID比较1
VQVAEEmbedLoss编码连续特征是否适合有限码本,码本是否能代表特征quant_conv后的特征与被选中的码本向量1

当前 config/train_vqvae.py:68–89 定义:

L t o t a l = 10 L r e c o n + L L o v a s z + L e m b e d . L_{total}=10L_{recon}+L_{Lovasz}+L_{embed}. Ltotal​=10Lrecon​+LLovasz​+Lembed​.

mini配置继承这组权重。权重10是训练设计的比例,不表示Recon天然比其他项重要10倍;实际贡献还依赖各项数值与梯度尺度。

原始输入占据类别ID
1 × 6 × 200 × 200 × 16

类别embedding + Encoder + quant_conv

连续特征z
6 × 128 × 50 × 50

VQ码本512×128
选出最近码本向量e

直通估计 + post_quant_conv + Decoder
再与18类向量计算分数

重建logits
1 × 6 × 200 × 200 × 16 × 18

ReconLoss
逐格交叉熵

LovaszLoss
softmax后优化类别区域重叠

EmbedLoss
特征向码本靠近 + 码本向特征靠近

10×Recon + Lovasz + Embed
train.py统一backward和更新参数

这里两个重建损失的目标都是inputs,不是未来帧;这一步训练的是“压缩后还原当前占据”,后续Transformer阶段另用token交叉熵与自车轨迹损失。

10.2 ReconLoss:每个格子的类别交叉熵

Recon是reconstruction(重建)的缩写。源码 loss/recon_loss.py:25–36 直接调用 F.cross_entropy,不是RGB像素MSE,也不是比较两张整数编号图的差值。

每个三维格子输出18个logits,真实类别是其中一个ID。交叉熵等价于先softmax取真实类别概率,再取负对数;PyTorch直接接收logits,在函数内部稳定计算:

L r e c o n = 1 ∣ V ∣ ∑ i ∈ V − log ⁡ p i ( y i ) . L_{recon}=\frac{1}{|\mathcal V|}\sum_{i\in\mathcal V}-\log p_i(y_i). Lrecon​=∣V∣1​i∈V∑​−logpi​(yi​).

V \mathcal V V为未被ignore_index忽略的格子集合。当前ignore_label=-100、use_weight=False:不做类别频率加权,17=空闲也作为正常类别参与。

例如真实类别为car:

模型给car的概率单格未加权交叉熵含义
0.9约0.105对真实类别有较高信心,扣分较少
0.1约2.303给真实类别的概率低,扣分较多

为什么需要:只让编码特征接近码本,不保证保留了汽车、道路等信息。重建损失把训练目标连接回真实三维语义,要求压缩表示能够支持还原每格类别。

张量实际变换:[B,F,H,W,D,18] permute成 [B,18,F,H,W,D],与 [B,F,H,W,D] 标签做交叉熵,默认mean归约成标量。一个batch的多个帧与所有格子都参与。

10.3 LovaszLoss:更贴近IoU的类别区域目标

IoU(交并比)衡量一个类别的预测区域与真实区域重叠得怎样:

I o U c = T P c T P c + F P c + F N c . IoU_c=\frac{TP_c}{TP_c+FP_c+FN_c}. IoUc​=TPc​+FPc​+FNc​TPc​​.

  • TP:真实是车,预测也是车的格子。
  • FP:真实不是车,却多预测成车的格子。
  • FN:真实是车,却漏预测成车的格子。

为何已有交叉熵还要加它? 占据地图有大量空闲格。比如1000格里990格空闲、10格汽车,全部猜空闲的逐格准确率仍有99%,但汽车IoU为0。交叉熵会惩罚这10个错误;只是当前按格子平均且没有类别权重,这些少数格子在平均中的占比小。按类别考虑区域重叠的目标,可以补充这种训练信号。这不意味着Lovasz保证小物体一定恢复,也不意味着交叉熵不会惩罚漏检。

不能直接argmax后计算1-IoU当普通可微损失,因为硬分类和集合计数不利于梯度优化。Lovasz-Softmax使用适合梯度优化的IoU替代目标,而不是简单把硬IoU结果拿来反向传播。原理来源:Lovasz-Softmax原论文。

当前代码的具体步骤(loss/recon_loss.py:53–56,109–139):

  1. 把B和F合并,将18类维放到通道位置,再softmax得到概率。
  2. 将所有格子展平为 [P,18];mini的P为1×6×200×200×16=3840000。
  3. 对每个在这批真实标签中出现的类别c,构造fg=1或0,表示每格是否属于该类。
  4. 计算该类的概率误差 abs(fg-p_c),从大到小排序。
  5. 依据排序后真实前景的累计数量计算Jaccard/IoU变化权重,将误差加权求和。
  6. 对本批出现的类别取平均,得到一个标量。

默认classes=‘present’、per_image=False,所以是跨当前batch及其所有帧汇总,按出现的类别平均,不是单独对每帧算后平均。当前没有传ignore或可见性mask;17空闲与其他出现的类别一样可以参与。它是类别区域损失,不能直接描述成显式边界距离损失或专门的轮廓损失。

10.4 VQVAEEmbedLoss:实际上包装了两项量化损失

loss/emb_loss.py:18–19 只返回输入embed_loss,没有自己重新计算embedding或分类目标。真正的公式在 model/VAE/quantizer.py:103–109。

记:

  • z z z:quant_conv之后、量化之前的连续特征。
  • e e e:最近邻选中的128维码本向量。
  • s g ( ⋅ ) sg(\cdot) sg(⋅):stop-gradient,对应detach,前向值不变,反向不更新这一侧。

当前legacy=True,源码公式是:

L e m b e d = m e a n [ ( s g ( e ) − z ) 2 ] ⏟ L c o m m i t + β m e a n [ ( e − s g ( z ) ) 2 ] ⏟ L c o d e b o o k . L_{embed}=\underbrace{mean[(sg(e)-z)^2]}_{L_{commit}}+ \beta\underbrace{mean[(e-sg(z))^2]}_{L_{codebook}}. Lembed​=Lcommit​ mean[(sg(e)−z)2]​​+βLcodebook​ mean[(e−sg(z))2]​​.

当前beta=1,所以两项系数都为1。

第一项:commitment loss,承诺/贴近码本损失。 把被选码本向量看成暂时固定的目标,只改编码器侧,让z靠近e。直觉是:编码器既然选了这个词条,就不要离它太远,减少量化前后的差距。这里会更新Encoder、quant_conv,以及编码路径中的class_embeds。

第二项:codebook loss,码本拟合损失。 把当前连续特征看成固定目标,只移动被选中的码本行,让e靠近z。码本因此能逐渐代表训练中实际产生的特征;当前使用梯度更新,不是EMA码本更新。一次计算中未选中的行没有由该次选中向量产生的梯度。

两项的前向平方误差数值相同,但detach不同,反向更新对象不同;不能因为数值相同就删掉其中一项。

一些VQVAE资料把beta乘在commitment项上;OccWorld 开源实现默认legacy=True,将beta乘在codebook项上。源码也有注释说明这一历史实现差异。当前beta=1时数值一致;将来调beta时必须核对legacy分支,不能照搬另一份公式。

示意:若z=[0.2,0.4]、e=[0,0],每项mean平方误差为0.1,总embed_loss=0.2。反向时z被推向e,e被推向z。该数值例子已用小型合成输入调用当前量化器验证。

它解决的是“压缩特征和词典能否配合”,不直接判断汽车预测对不对。 它也不是普通VAE的KL散度,不是在要求潜在变量服从标准正态分布,更不保证512个码本词条被均匀使用或自动避免码本坍塌。

10.5 三个loss分别更新谁?

当前量化器用直通估计:

z_q_for_decoder = z + (e-z).detach()

前向数值是e,反向重建梯度传给z。因此:

参数部分Recon / Lovasz重建梯度Embed梯度
class_embeds类别表有,来自输出类别匹配和编码输入路径有,来自编码输入路径
Encoder2D、quant_conv有,经直通估计返回有,来自commitment项
VQ码本embedding这条重建路径不直接更新它有,来自codebook项
post_quant_conv、Decoder2D有无,量化损失在它们之前计算

若没有直通估计,不能指望argmin自动把分类损失正常传回编码器;它本身的整数选择不可这样求导。当前代码用直通估计和额外码本损失配合训练。VQ背景可参考 原始VQ-VAE论文,以上梯度结论以OccWorld 开源实现detach实现为准。

10.6 loss日志怎么看、断点打哪里

MultiLoss.forward()先通过BaseLoss乘权重,再把三项相加;但它返回的loss_dict又除以weight,因此打印的各分项是未乘外层权重的原始值。

例如原始分项为Recon=0.2、Lovasz=0.4、Embed=0.1,则total=10×0.2+0.4+0.1=2.5,不是0.7。这是计算示例,不是模型训练结果。

断点观察
train.py:220loss_input里logits、inputs、embed_loss的来源;F10后看loss和loss_dict
loss/recon_loss.py:35Recon输入logits与labels;F10后rec_loss是未乘10的标量
同文件 :54F10后logits变量已被改成softmax概率
同文件 :133–137fg、class_pred、errors_sorted、fg_sorted,看某个类别怎么排序加权
model/VAE/quantizer.py:108z和z_q是连续特征/码本向量;F10后loss包含两项
loss/emb_loss.py:19包装层只原样返回embed_loss
loss/multi_loss.py:25–29每项weight、乘权重后的loss、tot_loss与返回的原始分项
train.py:222总loss一次backward,之后optimizer.step更新参数

10.7 本次补充的验证边界

已读当前源码与继承配置,并执行小型合成输入检查:

  • 给真实类概率0.9/0.1时,Recon原始损失分别约0.105361/2.302585。
  • 2维特征[0.2,0.4]选中码本[0,0]时,embed_loss约0.2;编码侧梯度[0.2,0.4]、所选码本行梯度[-0.2,-0.4]。
  • 对直通输出求和验证:梯度传到z为[1,1],该重建路径的码本梯度为None。
  • 使用当前配置的MultiLoss处理10格合成标签,原始分项约Recon=1.495717、Lovasz=0.602706、Embed=0.2;加权总值约15.759875,核对与10×Recon+Lovasz+Embed一致。

这些只验证公式、组合与所测梯度路径,没有重新训练或验证真实数据上的重建效果,也没有验证去掉某项loss后的指标变化。

源码:Recon/Lovasz实现 · Embed包装 · 量化损失与直通估计 · MultiLoss组合。

11. 本次实际前向记录

输入是随机类别ID,模型随机初始化,eval模式与no_grad。dropout=0,所以形状与当前训练结构一致;没有验证梯度训练、真实数据重建质量或未来预测。

记录中出现的尺寸都是实际hook输出,首列是net.named_modules中的模块路径。

模块输入形状输出形状
class_embeds[1, 6, 200, 200, 16][1, 6, 200, 200, 16, 8]
encoder.conv_in[6, 128, 200, 200][6, 64, 200, 200]
encoder.down.0.block.0[6, 64, 200, 200][6, 64, 200, 200]
encoder.down.0.block.1[6, 64, 200, 200][6, 64, 200, 200]
encoder.down.0.downsample[6, 64, 200, 200][6, 64, 100, 100]
encoder.down.1.block.0[6, 64, 100, 100][6, 128, 100, 100]
encoder.down.1.block.1[6, 128, 100, 100][6, 128, 100, 100]
encoder.down.1.downsample[6, 128, 100, 100][6, 128, 50, 50]
encoder.down.2.block.0[6, 128, 50, 50][6, 256, 50, 50]
encoder.down.2.attn.0[6, 256, 50, 50][6, 256, 50, 50]
encoder.down.2.block.1[6, 256, 50, 50][6, 256, 50, 50]
encoder.down.2.attn.1[6, 256, 50, 50][6, 256, 50, 50]
encoder.mid.block_1[6, 256, 50, 50][6, 256, 50, 50]
encoder.mid.attn_1[6, 256, 50, 50][6, 256, 50, 50]
encoder.mid.block_2[6, 256, 50, 50][6, 256, 50, 50]
encoder.norm_out[6, 256, 50, 50][6, 256, 50, 50]
encoder.conv_out[6, 256, 50, 50][6, 128, 50, 50]
vqvae.quant_conv[6, 128, 50, 50][6, 128, 50, 50]
vqvae.post_quant_conv[6, 128, 50, 50][6, 128, 50, 50]
decoder.conv_in[6, 128, 50, 50][6, 256, 50, 50]
decoder.mid.block_1[6, 256, 50, 50][6, 256, 50, 50]
decoder.mid.attn_1[6, 256, 50, 50][6, 256, 50, 50]
decoder.mid.block_2[6, 256, 50, 50][6, 256, 50, 50]
decoder.up.2.block.0[6, 256, 50, 50][6, 256, 50, 50]
decoder.up.2.attn.0[6, 256, 50, 50][6, 256, 50, 50]
decoder.up.2.block.1[6, 256, 50, 50][6, 256, 50, 50]
decoder.up.2.attn.1[6, 256, 50, 50][6, 256, 50, 50]
decoder.up.2.upsample[6, 256, 50, 50][6, 256, 100, 100]
decoder.up.1.block.0[6, 256, 100, 100][6, 128, 100, 100]
decoder.up.1.block.1[6, 128, 100, 100][6, 128, 100, 100]
decoder.up.1.upsample[6, 128, 100, 100][6, 128, 200, 200]
decoder.up.0.block.0[6, 128, 200, 200][6, 64, 200, 200]
decoder.up.0.block.1[6, 64, 200, 200][6, 64, 200, 200]
decoder.norm_out[6, 64, 200, 200][6, 64, 200, 200]
decoder.conv_out[6, 64, 200, 200][6, 128, 200, 200]

最终token编号形状 [15000],最终logits形状 [1,6,200,200,16,18]。PyTorch峰值已分配显存约0.565 GiB,仅代表这次无梯度合成前向,不能当作训练显存需求。

初始化日志打印的Working with z of shape (1,128,50,50,50)来自Decoder2D残留的五维z_shape属性,不是本二维网络实际forward张量;本次hook确认输入decoder为四维 [6,128,50,50]。以运行张量和实际操作为准。

12. 固定版本源码

仓库 ,HEAD 1ee7f77ecc4c984a4f7f6411d95c2e6e73806b6e。mini配置是本地覆盖;模型实现与当前读到的源码核对。

输入来源追踪:图像与占据输入的区别、gts文件来源及数据加载调用链。

13. 调试问答:211行self.encoder里有没有attention?

有。forward_encoder() 的211行调用 Encoder2D.forward(),它是残差卷积和空间自注意力混合的编码器。当前配置 attn_resolutions=(50,),因此down[2]的两个残差块后各接一个AttnBlock,另外mid中还有一个,总共3个。mid注意力直接在构造函数中创建,不依赖attn_resolutions的判断。

输入 [N,128,200,200],经过初始卷积、三个尺度的残差块和两次下采样,输出 [N,128,50,50]。完整逐层图见第3节,注意力内部Q/K/V图见第5节。注意力执行时通道为256,并非编码器最后输出的128。

每个AttnBlock让同帧50×50=2500个空间位置相互读取,Q形状为 [N,2500,256],K为 [N,256,2500],QK分数为 [N,2500,2500];加权读取V、经过输出投影并加回原输入,输出仍为 [N,256,50,50]。这里没有时间注意力、没有时间因果遮罩,也没有显式多头划分。

建议从211行直接跳到335行看编码器入口,再在340行看RB、343行看stage注意力、354行看mid注意力;进入AttnBlock后在161行看QK矩阵、168行看V加权聚合。编码器forward里331行的旧注释写bs,F,H,W,D,但本调用实际输入是四维 [bs×F,C,H,W],以运行张量为准。

14. 调试问答:GroupNorm的32组、Swish与Q/K/V权重

14.1 为什么使用GroupNorm?

归一化可以理解为先把特征数值调整到较容易训练的尺度,再交给后续卷积/注意力。它改变数值,保持张量形状,并通过可学习的缩放和平移保留表达能力。不是把通道数压缩为组数。

GroupNorm按单个样本内部的通道分组,分别计算每组的均值和方差。对于 [N,C,H,W],可用以下布局理解其统计维度:

[N,C,H,W] → [N,G,C/G,H,W]
对每个N、每个G,统计后面C/G × H × W个数

本代码 N=bs×F,因此每帧独立归一化,没有混合不同帧的统计量。BatchNorm2d通常按每通道在N、H、W维统计;小批量或高度相关的样本可能使batch统计不可靠。GroupNorm不依赖batch中其他样本,适合小batch视觉任务。Group Normalization原论文

结合本工程,batch_size=1、同一序列帧合并为N且彼此相关,GN是合理的设计选择;但这只是结合机制的解释,源码没有证明作者为何做此选择,也没有本工程GN/BN对照实验来证明GN一定更优。

统计后,对通道c中的每个元素做:

y n , c , h , w = γ c x n , c , h , w − μ n , g ( c ) σ n , g ( c ) 2 + ϵ + β c . y_{n,c,h,w}=\gamma_c\frac{x_{n,c,h,w}-\mu_{n,g(c)}}{\sqrt{\sigma^2_{n,g(c)}+\epsilon}}+\beta_c. yn,c,h,w​=γc​σn,g(c)2​+ϵ ​xn,c,h,w​−μn,g(c)​​+βc​.

其中g©是通道c所属组;gamma、beta是每通道可训练参数,默认初始化为1和0,OccWorld 开源实现epsilon=1e-6。计算均值/方差覆盖该组的全部空间位置,不是仅对同一水平位置的8个通道计算。PyTorch GroupNorm定义

14.2 为什么是32组?是不是每组32个通道?

源码Normalize:

if in_channels <= 32:
    num_groups = in_channels // 4
else:
    num_groups = 32

32是当前实现沿用的组数超参数,不是理论规定,也不是证明最优的数字。它需要能整除通道数;当前64/128/256通道均满足。

通道总数C组数G每组通道数C/G
64322
128324
256328

例如Attention输入 [6,256,50,50],GN等价于按 [6,32,8,50,50] 组织,每帧每组用8×50×50=20000个数统计。每帧有32套均值/方差,6帧互不混合;输出仍为 [6,256,50,50]。

组按通道顺序划分,例如第0组是通道0~7、第1组8~15;不是学出来的“汽车组/道路组”,也不是按物理高度分组。卷积后通道已是混合特征。

组数体现归一化统计范围的选择:G=1时统计覆盖全部通道与空间,G=C时每个通道分别统计空间。32选择介于二者之间的分组范围;这里讨论的是统计方式,不能简单断言某组数必然更准确。GroupNorm的分组也不等于注意力的head数,当前AttnBlock没有显式多头。

14.3 Swish是什么意思?

本代码的Swish是逐元素激活函数:

f ( x ) = x ⋅ sigmoid ⁡ ( x ) = x 1 + e − x . f(x)=x\cdot\operatorname{sigmoid}(x)=\frac{x}{1+e^{-x}}. f(x)=x⋅sigmoid(x)=1+e−xx​.

这里等价于PyTorch的SiLU(Sigmoid Linear Unit);当前形式没有额外可训练参数。PyTorch SiLU说明

可以把sigmoid(x)理解为由输入自己决定的、0~1之间的“软门”:大的正数大体保留,大的负数接近0,小负数仍能保留一部分。它不是概率输出层,Swish结果可以为负,也可以大于1。

xsigmoid(x)约值Swish(x)约值
-20.119-0.238
-10.269-0.269
00.50
10.7310.731
20.8811.762

作用是提供平滑的非线性变化。仅靠线性卷积的连续叠加,表达能力会受限;Swish让网络能够表达更复杂的输入输出关系。与ReLU直接把负数置零相比,它对负数的处理更平滑,但不能据此推断在本模型里必然比ReLU更好。注意它不是全程单调函数,负值区间会有轻微下凹。

它不混合空间位置、不混合通道、不改张量形状,只逐元素改变数值。残差块中顺序是GroupNorm→Swish→卷积;AttnBlock本身是GroupNorm→Q/K/V投影→注意力→输出投影→残差,并没有在Q/K/V前再调用Swish。

14.4 Q/K/V的1×1卷积是不是同一套权重?

三套独立权重,也有三套独立偏置。 self.q、self.k、self.v分别通过三次Conv2d构造,没有参数共享。相同的是输入、卷积核大小、通道数和输出形状,不代表参数相同。

在Attention的256通道处:

同一个归一化特征h
 ├─ q的1×1卷积:Wq、bq → Q
 ├─ k的1×1卷积:Wk、bk → K
 └─ v的1×1卷积:Wv、bv → V

每套weight形状都是 [256,256,1,1],等价于在每个位置对256维向量做一次独立学习的线性映射:

q_i = Wq h_i + bq     学习查询表示:我想读取什么
k_i = Wk h_i + bk     学习匹配表示:我能匹配什么
v_i = Wv h_i + bv     学习内容表示:实际提供什么

注意力权重由q_i与k_j的点积决定,输出是对各位置v_j的加权汇总。三套权重让“匹配依据”和“传递内容”可以分别学习。它们并不是三种人工写死的语义,只是计算角色不同。

同一套Wq会在全部空间位置和样本之间共享,这是卷积的权重共享;Wq与Wk/Wv之间则独立。不同AttnBlock实例各自也有自己的Q/K/V参数。后面的proj_out是第四套独立1×1卷积。

14.5 断点表达式与验证

在AttnBlock.forward入口149行暂停,self是当前注意力实例:

self.norm.num_groups               # 32
self.norm.num_channels             # 256
self.q.weight.shape               # [256,256,1,1]
self.q.weight is self.k.weight     # False
self.q.weight.data_ptr() == self.k.weight.data_ptr()  # False

本次用当前代码实例化AttnBlock(256),确认Q/K/V参数存储互不共享;数值计算验证Swish上表,并用手动每样本/每组均值方差计算核对GN,最大差约4.77e-7(浮点误差量级)。这验证定义和实现,不是归一化/激活选择的训练效果对照。

15. 调试问答:200×200变成50×50后,表示的物理范围是多少?

整体仍然编码原来的80m×80m水平区域,没有因为特征图缩小而裁掉外圈。 原始Occ3D网格水平两个轴为[-40,40]m,各200格、每格0.4m。编码器两次stride=2下采样,因此总stride=4:200→100→50。

阶段水平特征图尺寸相对原网格stride相邻特征位置对应的原空间采样间隔
输入200×20010.4m
第一次下采样100×10020.8m
第二次下采样/编码输出50×5041.6m

为直观理解,可说同样80m×80m区域被表达为50×50个特征位置,名义空间尺度为1.6m;但不要把一个特征严格当作独立的1.6m×1.6m物理体素。

需要区分两件事:

  • 采样间隔:相邻latent位置的名义输入坐标间隔是4×0.4=1.6m。
  • 感受野/信息依赖范围:一个latent向量的数值由哪些输入位置影响。3×3卷积的范围重叠,多层卷积扩大范围;本编码器还有全图空间attention,因此某个latent位置可以融合整个80m×80m输入的上下文。GroupNorm的组内空间统计也引入跨空间位置的依赖。

因此“每个位置独占原始4×4格”是不准确的,“采样更稀、特征更抽象,并能利用全图信息”更合适。

同理,不能直接把latent网格当作新的规则体素地图,套用原点加0.8m得到中心坐标;卷积stride/padding与特征中心对齐需要另行推导。这里1.6m描述stride对应的采样间隔,不是为latent声明新的精确物理格子边界。

高度范围也没有被主动裁掉:原始垂直范围[-1,5.4]m的16层信息先拼到输入128通道中,之后由编码器混合压缩。最终z为 [bs×F,128,50,50],128是学习到的特征维,已经不再显式保留16个高度格。

数据规格来源:Occ3D-nuScenes官方说明。当前源码的Downsample是3×3、stride=2、padding=1卷积;50×50处有空间注意力。上述结论是数据规格、当前网络结构与此前实际形状前向的结合,不表示已验证模型能无损保留整个区域的每个物体。

16. 调试问答:quantizer.py第94~96行在计算什么?

目标:计算每个输入特征向量到每个码本向量的平方欧氏距离,然后选最近的码本行作为token编号。这里不是attention的QK打分,也没有softmax。

当前VQVAE mini为6帧,每帧50×50个128维向量,展平后:

z_flattened              [15000,128],15000=6×50×50
self.embedding.weight   [512,128],512个候选码本向量
最终d                   [15000,512],每个输入到每个候选的距离

数学上,对于输入第i个向量z_i、码本第j行e_j:

d i j = ∑ k = 1 128 ( z i k − e j k ) 2 = ∑ k z i k 2 + ∑ k e j k 2 − 2 ∑ k z i k e j k . d_{ij}=\sum_{k=1}^{128}(z_{ik}-e_{jk})^2 =\sum_k z_{ik}^2+\sum_k e_{jk}^2-2\sum_k z_{ik}e_{jk}. dij​=k=1∑128​(zik​−ejk​)2=k∑​zik2​+k∑​ejk2​−2k∑​zik​ejk​.

这就是熟悉的平方展开 (a-b)^2=a^2+b^2-2ab,只是最后对128个分量求和。这里不做平方根,因为平方距离与距离选出的最近候选相同。

逐行对应:

表达式在算什么形状
torch.sum(z_flattened ** 2, dim=1, keepdim=True)每个输入向量自身的平方和[15000,1]
torch.sum(self.embedding.weight**2, dim=1)每个码本向量自身的平方和[512]
rearrange(weight,'n d -> d n')码本从每行一个向量,转为每列一个向量[128,512]
einsum('bd,dn->bn',z_flattened,转置码本)所有输入与所有码本的点积,对128维累加[15000,512]
输入平方和 + 码本平方和 - 2×点积通过广播拼出两两平方距离[15000,512]

einsum中的字母只是局部维度记号:b在这里是15000个展平特征位置,不是原始bs=1或帧数6;d是向量维128;n是码本候选数512。输出中没有d,表示沿该维乘积求和。等价于:

inner_products = z_flattened @ self.embedding.weight.T

广播表示 [15000,1] 的每个输入平方和对512列复用,[512] 的每个码本平方和对15000行复用,并非手动写循环或必须实际复制这两张表。

可以临时在Debug Console把原式拆成:

input_norm = (z_flattened ** 2).sum(dim=1, keepdim=True)
code_norm = (self.embedding.weight ** 2).sum(dim=1)
products = z_flattened @ self.embedding.weight.T
# d = input_norm + code_norm - 2 * products

2维教学例子(非真实模型特征):输入[1,2],码本行0=[1,1]、行1=[4,6],距离分别是1和25,因此这一行d为[1,25],argmin选编号0。第二个距离也可展开成 (1²+2²)+(4²+6²)-2×(1×4+2×6)=5+52-32=25。

紧接着第98行:argmin(d,dim=1)在每个输入对应的512个距离中选最小值所在列号,得到15000个token编号;第99行用编号查回128维码本向量。注意d存距离、argmin返回编号,两者都还不是类别分数或18种语义类别ID。

这种展开可利用矩阵乘法,避免显式构造 [15000,512,128] 的两两差向量中间张量。平方距离在精确数学中非负;浮点消减误差可能产生极小负值,原代码没有clamp,不应把微小负值自动当成公式错误。

断点建议放第98行,此时d已经计算完成:

z_flattened.shape
self.embedding.weight.shape
d.shape
# 第一个输入选择了哪个码本行
j = d[0].argmin().item()
j, d[0,j].item()
# 按直接相减定义复核,可能有浮点误差
((z_flattened[0]-self.embedding.weight[j])**2).sum().item()

17. 逐行接着看:quantizer.py第99行到量化器返回

99行:根据token编号查回码本向量

z_q = self.embedding(min_encoding_indices).view(z.shape)

此时min_encoding_indices是15000个整数编号,self.embedding是512×128的码本。每个编号替换为对应行的128维浮点向量:

编号:[37,5,37,...]                  [15000]
查表:[码本第37行,第5行,第37行,...] [15000,128]
view成当前z.shape                 [6,50,50,128]

编号示意,不是本次实测结果。前面88行已经将z从通道优先变为通道最后,所以这里view(z.shape)是 [6,50,50,128],不是 [6,128,50,50]。

z是编码器经quant_conv得到的连续向量;z_q是用最近码本代表向量替换后的结果。相同编号查到同一行向量,但不同位置仍通过排列保持其空间和时间顺序。

100~101行:两个兼容返回字段留空

perplexity和min_encodings均设为None。此路径不计算码本使用困惑度,也不生成每个位置选择哪行码本的one-hot矩阵;离散选择结果在min_encoding_indices中。None不表示没有量化。

104~109行:量化误差相同,反向更新方向不同

当前legacy=True,走else:

loss = ((z_q.detach()-z)**2).mean() + self.beta * ((z_q-z.detach())**2).mean()

detach()是“数值保留,但反向时不沿这一侧更新参数”,不是把值设为0。beta当前为1。

部分哪一侧固定哪一侧更新目的
(z_q.detach()-z)^2选中的码本向量编码器侧,包括quant_conv及输入类别表连续特征靠近码本,commitment
(z_q-z.detach())^2连续特征被选中的码本行码本靠近实际特征,codebook

mean覆盖当前全部帧、位置及128个分量,返回一个标量。此标量就是后续VQVAEEmbedLoss包装的embed_loss;现在只是算出loss,没有立刻修改参数,训练循环的backward与optimizer.step才完成梯度计算和更新。

112行:前向用码本,反向给编码器传递梯度

z_q = z + (z_q-z).detach()

这是直通估计(straight-through estimator)。前向数值近似按代数式 z+(z_q-z)=z_q 理解,仍使用最近码本向量;实际浮点相加减可能有微小舍入误差。

反向时 (z_q-z).detach()整项作为常数,梯度只从前面的z返回,因此解码重建损失能沿连续编码器路径传播。它没有让argmin变得可导,而是人为采用了这样的梯度近似。重建损失不会通过此表达式直接更新码本,码本由前面独立计算的codebook项更新。前面的loss计算图不因这里重新绑定z_q变量而消失。

2维示意:z=[0.2,0.4]、查到的向量z_q=[0,0],detach后的差为[-0.2,-0.4];前向输出约[0,0],但重建梯度仍能传给z。与本笔记10.5节的梯度表对应。

115~118行:把通道轴移回去

当前is_voxel=False:

z_q = rearrange(z_q, 'b h w c -> b c h w').contiguous()

[6,50,50,128] → [6,128,50,50],只是改布局以适配Conv2d;contiguous整理内存为连续布局,保持数值对应关系。这里的b是合并后的bs×F,不是原始bs。

122~133行:可选编号映射与形状恢复

  • 当前VQVAE配置remap=None:不走编号映射分支。
  • 当前第一阶段sane_index_shape=False:编号保持 [15000]。
  • 第二阶段世界模型配置设置sane_index_shape=True:返回编号形状为 [bs×F,50,50],方便重新整理时间维。

这些选项影响token编号组织方式,当前没有改变码本向量输出的 [bs×F,128,50,50] 布局。

135行返回后,还要经过79行的post_quant_conv

forward_quantizer返回:
  z_q:[6,128,50,50],用于解码的量化特征
  loss:标量,约束编码特征和码本
  info:(None,None,token编号)

回到VectorQuantizer.forward:
  post_quant_conv,1×1卷积128→128,布局不变但数值再变换
  返回外层VAERes2D

VAERes2D.forward:
  loss保存为output_dict['embed_loss']
  变换后的量化特征命名为z_sampled,传入forward_decoder
  解码回[1,6,200,200,16,18]的类别分数

post_quant_conv之后的向量通常不再恰好等于某行原始码本向量,这是解码前的可学习适配;token编号仍表示该投影之前选中了哪行。当前这条路径不做随机token采样。

断点顺序可用99→108→112→116→135→79→VAERes2D的241行。断点停下时当前行未执行,F10后再看赋值结果;同时观察z.shape、z_q.shape、loss和min_encoding_indices.shape。

18. 再解释直通估计:为什么z加一个差值,梯度就能回来?

先给右边旧的z_q改个名字e,避免等号两边同名造成混淆:

e = z_q                   # 已经查到的码本向量
correction = (e-z).detach()
y = z + correction        # 原代码把y重新命名为z_q

例子只看一个数:编码器输出z=2,查到的码本值e=3。

前向计算只关心数值: correction=3-2=1,detach后仍然是1,所以y=2+1=3。解码器看到的是码本值3,不是原连续值2。detach不是置零,也不是让这一项不参与前向计算。

反向传播还关心计算路径: detach把correction这整条差值路径切断。对本次反向传播而言,y的计算可看成“z加一个数值为1的常数”,因此传到y的梯度原样传给z。差值里那个负z已经在被切断的支路中,不会再产生负梯度来抵消前面的正z。

编码器z ───────────────→ 加法 → y → 解码器 → 重建损失
    └─ 与e算差 → detach ─→ 加法
                   ↑
           前向值能通过,反向梯度不能通过

没有detach时,y=z+(e-z)=e,正z和负z的梯度会抵消。加入detach的目的正是:前向保留e的值,反向保留到z的梯度路径。

例如下游损失为loss=(y-5)^2,y=3时loss=4,对y的梯度为-4。当前表达式会把-4传给z,而不经这条重建路径传给e。若学习率是0.1,仅就这个独立标量例子,z会从2更新为2.4;实际模型更新的是产生z的网络参数,z是中间结果,不是直接被优化器保存的参数。

这里采用的是人为指定的梯度近似:反向把量化操作近似看成恒等映射,而不是真正让argmin可导。每次新的前向仍会重新计算差值,不是把correction永久固定;“常数”只描述它在本次反向中的角色。

本次实际执行最小PyTorch例子确认:z=2、e=3、y=3、loss=4、z.grad=-4、e.grad=None。e的None只说明这条重建路径没有梯度;完整VQVAE里前面另算的codebook loss仍会更新码本。

19. 直通梯度只到z,码本怎么优化?

“梯度只到z”只指直通估计那一条重建损失路径,不是总损失的全部路径。当前代码先在108~109行计算包含codebook项的loss,再在112行重绑z_q;前面已创建的损失计算图仍然存在。

把查表值命名为e,可写成:

e = embedding(indices)
commit_loss = ((e.detach()-z)**2).mean()     # 更新编码器侧
codebook_loss = ((e-z.detach())**2).mean()   # 更新码本侧
y = z + (e-z).detach()                     # 重建路径的直通估计
# 解码y得到logits
# 总损失还包括Recon和Lovasz
loss = 10*recon_loss + lovasz_loss + commit_loss + codebook_loss
loss.backward()
optimizer.step()

上式对应当前beta=1;实际网络还有post_quant_conv和类别输出匹配等步骤,省略不改变这里的梯度分工。

损失路径z编码器侧e码本侧
Recon/Lovasz,经直通估计有梯度该路径没有梯度
commitment:固定e,让z靠近e有梯度无
codebook:固定z,让e靠近z无有梯度

例如只看一个标量,z=2、e=3,codebook_loss=(e-z.detach())²=1,对e的梯度为2;若学习率0.1,SGD示意会把e从3更新到2.8,使码本靠近编码特征。实际代码使用AdamW且对许多向量/分量求mean,因此这个更新值只是说明方向,不是实际训练数值。

同一行码本可能被很多位置选择,它收到这些位置贡献的梯度累加;由当前查表损失直接得到梯度的是被选中的行。未选行没有由本次查表产生的数据梯度,不代表在带weight decay的优化器下参数一定绝对不变。

最终由train.py的总loss.backward计算各条路径的梯度,再由optimizer.step更新模型参数。这个分工保证两件事:编码器学习产生能还原原场景、又接近码本的表示;码本学习为这些表示提供更好的代表向量。

20. 从444行继续:Decoder怎样恢复尺寸和类别?

解码不是一开始就上采样;先在50×50处增加通道、做残差和空间注意力,再逐级放大。以当前6帧为例,输入是post_quant_conv后的 [6,128,50,50]。

444~449行:先处理低分辨率潜在特征

  • 444行conv_in是3×3卷积:128→256,得到 [6,256,50,50],此时没有改变空间尺寸。
  • 447~449行是RB256→256、空间Attention256、RB256→256,形状不变,数值与上下文表达发生变化。

452~459行:先在当前尺度处理,再放大

循环按i_level=2、1、0运行:

进入up[2]:50×50,256通道
  RB256→256 → Attention → RB256→256 → Attention
  nearest插值50→100,再3×3卷积256→256
输出:[6,256,100,100]

进入up[1]:100×100
  RB256→128 → RB128→128
  nearest插值100→200,再3×3卷积128→128
输出:[6,128,200,200]

进入up[0]:200×200
  RB128→64 → RB64→64
  没有再次上采样
输出:[6,64,200,200]

nearest插值只是复制邻近数值使尺寸翻倍,新增细节由后续卷积与训练学习;不是自动逆转编码器的有损压缩。shapes是Encoder保存的 [[200,200],[100,100]],pop先取100×100,再取200×200,用来校准上采样尺寸;当前两次无需额外pad。它只存尺寸,没有存可恢复原输入的数值或U-Net的skip特征。

465~468行:得到解码特征,还没得到类别

GroupNorm→Swish→3×3卷积64→128,返回 [6,128,200,200]。

这里128个数是网络学习出的输出表示;后面按16层高度、每层8维解释。不能把它当成128类,也不能直接沿128通道argmax来恢复18种类别。

回到215~220行:把128通道拆回16层,并匹配类别表

Decoder2D.forward返回后,继续执行VAERes2D.forward_decoder:

Decoder输出                     [6,128,200,200]
permute                         [6,200,200,128]
reshape                         [240000,16,8]
共享类别表转置并unsqueeze         [1,8,18]
matmul                          [240000,16,18]
最后reshape                     [1,6,200,200,16,18]

240000=6×200×200,每个水平位置有16个高度格,每格8维输出特征。将每个8维向量u与18种类别的8维向量e_c逐一做点积:score_c=sum(u*e_c),得到18个分数。这里是未归一化的点积分类分数,不是softmax概率,也不是VQ阶段的平方欧氏距离。

类别表仍是输入时使用的 class_embeds.weight,形状18×8,输入/输出共享权重。可理解为输出特征被训练成能通过这张表识别类别的表示;不是简单查逆表,也不要求输出特征与原类别embedding严格相等。

最后怎样得到汽车、道路等类别?

pred = logits.argmax(dim=-1)

把每格18个分数换成最大分数对应的类别ID,形状变成 [1,6,200,200,16]。例如编号4对应car,17对应空闲。类内部在非训练分支自动做这一步;训练保留logits给Recon/Lovasz,避免对硬argmax后的整数直接求分类梯度。

高度结构与类别内容由重建监督学出来。reshape只恢复“哪个输出位置对应哪个高度格”的组织方式,并不会单靠形状就恢复汽车、道路等信息。

建议断点:444→459→467→218→220→223;在218行先F10看从通道128变成高度16×特征8,在220行F10看每格8维变18个类别分数。现有第7、8节含逐层Mermaid图。

两阶段衔接:VQVAE训练结束后还学什么、冻结哪些参数,以及为何不是Diffusion。

21. 为什么使用VQ-VAE和codebook,而不直接用普通VAE?

补充日期:2026-10-21。核心是后续世界模型的接口选择:OccWorld希望把场景变成有限词典里的离散token,再做下一帧token分类预测。不是普通VAE过时,也不是VQ-VAE在所有任务上都更好。

连续潜变量和离散编号的区别

典型的高斯VAE由编码器输出均值/方差,从连续潜变量分布采样z,经解码器重建;训练通常包含重建项和KL项。VQ-VAE先输出连续特征,再从可学习码本选最近代表向量,得到离散ID,解码时查回该向量。背景:VAE原论文 · VQ-VAE原论文。

连续VAE:输入→连续潜变量z→Decoder
VQ-VAE: 输入→连续特征→码本最近邻ID→查表得到代表向量→Decoder

“普通VAE”并不是所有可能VAE的严格全集定义,VAE也可有离散潜变量变体;这里比较的是常见连续高斯VAE与本工程的VQ实现。代码类名VAERes2D不表示当前执行的是高斯采样分支,use_vq=True时实际走码本量化。

codebook给当前OccWorld带来的三个直接作用

  1. 把连续特征预测接口换成离散分类接口。 每位置不直接预测128个连续特征值,而是预测512项码本的分数,再由选中的ID确定128维代表向量。因此CeLoss可以用“真实下一帧token ID”作为清楚的监督目标。不是128维代表值消失了,而是通过共享码本索引它。
  2. 为场景建立可复用的表示词典。 相近编码特征可以使用同一代表向量。第一阶段联合训练Encoder/Decoder与码本,使这些词条适合占据重建;第二阶段冻结词典,Transformer学习词条随时间怎样变化。词条不是人工规定的一种物体,且不能假定完全语义一致。
  3. 方便预测、查表与回灌。 当前推理对512类分数argmax,查回码本向量,追加为下一轮历史输入。进入解码器前的量化表示被限定在已学的码本候选中;这限制单位置的向量选择,但不保证多个token组合的整帧、未来运动或驾驶行为物理正确。

OccWorld官方项目页明确采用CNN+可学习码本获得离散token,并用GPT式架构做下一场景预测。项目说明。

是否更能表达多个未来?

离散预测可以给多个码本候选分配概率,而不是必须用单个连续向量回归损失来拟合全部可能结果。例如不同候选可以对应不同潜在空间模式。但这不是VAE不能表达多模态,也不是当前系统必然生成多样未来;当前实际滚动路径使用argmax,只选择一个候选。连续模型也可以设计概率分布或扩散目标来建模多种未来。

压缩收益来自哪里?

200×200压到50×50的空间压缩主要来自Encoder下采样,连续自编码器也能做到。codebook额外把每位置128个浮点数表示为一个有限ID,加上共享码本即可恢复代表向量。

512种编号理论上9bit可表示;128个float32是4096bit。这只是未考虑熵编码/码本开销等因素的表示比较,不是本工程实测文件压缩率。当前PyTorch编号通常用int64存放,计算时还会查回128维浮点向量;训练预测头还输出512类分数,因此不能据此说运行显存或计算量必然减少。

代价和适用边界

  • 量化误差: 连续特征必须靠近有限代表向量,部分细节可能损失。
  • 码本利用率: 可能许多词条很少被使用,形成码本坍塌/利用不足,需要关注训练表现。
  • 训练复杂度: 增加最近邻查找、码本与commitment损失、直通估计;并非直接可微的argmin。
  • 重建与预测瓶颈: 码本/解码器表达不好的内容,第二阶段准确预测ID也不能完整恢复。
  • 分类的代价: CE不直接按两个码本向量间的几何距离区别“错得近/远”。512类输出也并非天然比128维连续输出省算力。

VQ-VAE原论文讨论在其设置下缓解强自回归解码器忽略潜变量的posterior collapse(后验坍塌),但这不是使用码本就保证没有训练问题;posterior collapse与“少量词条垄断使用”的codebook collapse也不是同一个概念。当前OccWorld选择的最直接解释仍是离散场景token接口,不能把所有效果都归为防坍塌。

如果保留连续VAE,能不能做世界模型?

可以。Transformer也能处理连续向量,自回归不要求离散token。不过需要配套选择连续潜变量的预测输出、概率分布/回归损失及推理采样方式;不能直接把当前512类交叉熵原封不动套上。VQ-VAE是OccWorld这条“场景词典→下一帧token预测”路线的设计选择,不是世界模型的必要条件。

本次依据原论文背景、官方项目页及当前源码接口解释,没有重新训练连续VAE对照模型,也没有证明VQ在该 mini 配置数据上一定优于连续方案。

CE与两阶段复习:交叉熵曲线与公式推导、VQVAE质量瓶颈以及6帧/11帧训练关系。

Logo

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

更多推荐