查看全课大纲(第 35 / 40 节)
- 1.从规则到学习:深度学习解决什么问题
- 2.表示学习与端到端训练
- 3.张量、计算图与自动微分
- 4.训练三要素:数据、模型、目标函数
- 5.从感知机到多层感知机
- 6.反向传播:手推一个两层网络
- 7.激活函数与初始化
- 8.优化器:SGD → Momentum → Adam
- 9.正则化:权重衰减、Dropout、BN/LN
- 10.卷积的直觉与参数共享
- 11.池化、感受野与下采样
- 12.经典网络演进:LeNet → AlexNet → VGG → ResNet
- 13.残差连接与归一化为什么有效
- 14.迁移学习与特征可视化
- 15.RNN 与 BPTT
- 16.LSTM/GRU:门控如何缓解梯度问题
- 17.Seq2Seq 与编码器-解码器
- 18.注意力前史:Bahdanau 加性注意力
- 19.Self-Attention:QKV 与缩放点积
- 20.多头注意力:并行的多种关系
- 21.位置编码:正弦与可学习
- 22.残差、LayerNorm 与前馈层
- 23.从零实现一个最小 Transformer block
- 24.训练稳定性、复杂度与常见误区
- 25.自监督与语言建模目标
- 26.BERT:掩码语言模型与双向编码
- 27.GPT:自回归生成与缩放定律
- 28.指令微调与对齐:SFT → RLHF/DPO
- 29.参数高效微调:LoRA 与适配器
- 30.自编码器与 VAE
- 31.GAN:对抗训练与模式崩塌
- 32.扩散模型:前向加噪与反向去噪
- 33.扩散模型的应用与采样加速
- 34.对比学习与 CLIP
- 35.ViT:把图像当序列
- 36.多模态架构:对齐、融合与生成
- 37.能力边界、幻觉与评测
- 38.PyTorch 2.x 训练循环与混合精度
- 39.实验管理、评测与复现
- 40.推理部署与成本:量化/蒸馏概览
ViT:把图像当序列
小象实战讲义 · 深度学习基础:从神经网络到 Transformer
上一节的 CLIP 用对比学习把图像和文本拉进同一个表征空间,但它用的图像编码器仍是 ResNet。这一节换个方向:能不能干脆不用卷积,让 Transformer 直接看图片?我们将详解 Vision Transformer(ViT) 如何把图像“切割”成“单词”序列并用于图像分类,重点是 patch embedding 的形状变换、[class] token 与位置编码的拼接,以及它与 CNN 在“归纳偏置”上的根本差异。先修:第 5 章的自注意力与 Transformer 编码器、张量形状变换。后续:下一节把这些组件拼成完整的视觉-语言架构。
💡 核心导读
- 核心思想:ViT 将一张图像分割成固定大小的图像块(patch),并将每个块线性投影为向量(patch embedding),从而将 2D 图像转换为 1D 向量序列,作为 Transformer 编码器的输入。
- 关键组件:除了 patch embedding,ViT 还引入了可学习的分类令牌(
[class] token)和位置编码,以聚合全局信息和保留空间位置。 - 与 CNN 的对比:ViT 缺乏 CNN 固有的平移等变性和局部性等归纳偏置,因此在中等规模数据集(如 ImageNet)上训练时,效果可能不如同等规模的 ResNet。
- 数据规模是关键:论文《An Image is Worth 16x16 Words》(Dosovitskiy et al., 2020,arXiv:2010.11929)的核心发现是:当在超大规模数据集(如 JFT-300M)上进行预训练后,ViT 可以超越当时最先进的 CNN 模型,证明“大规模训练可以战胜归纳偏置”。
- 实现要点:理解图像到序列的形状变换过程,以及如何用 PyTorch 实现 patch embedding 和位置编码。
🧭 前置知识
- Transformer 编码器:了解多头自注意力(MSA)和前馈网络(MLP)的堆叠结构。
- 张量形状:熟悉
(batch, channel, height, width)和(batch, sequence_length, feature_dim)等形状表示法。 - 线性投影:理解矩阵乘法
X @ W如何改变张量的特征维度。 - softmax 与归一化:知道 softmax 函数的作用及 layernorm 的原理(不展开数学推导)。
从 2D 图像到 1D 序列:Patch Embedding
卷积神经网络(CNN)通过卷积核在图像的 2D 网格上滑动来提取特征,其设计天然包含了局部性(一个神经元只感受野内的像素)和平移等变性(物体移动,其特征也随之移动)。Transformer 最初为序列设计,其自注意力机制是全局的。要让 Transformer 处理图像,首要任务是将 2D 结构“序列化”。
ViT 的做法直观而大胆:将图像视为一系列不重叠的图像块(patch)的集合,每个 patch 被视为一个“词”。
形状变换详解: 假设输入图像张量 x 的形状为 (batch, channels, height, width),记为 (B, C, H, W)。 我们设定每个 patch 的大小为 P x P(例如 16x16)。那么:
- 每张图像将被分割成
N = (H * W) / (P * P)个 patch。 - 每个 patch 包含
P * P * C个像素值。
我们需要将这些 patch 展平,并通过一个可学习的线性投影(全连接层)映射到一个固定的维度 D(即 Transformer 的 d_model)。
这个过程可以用以下代码清晰地演示:
import torch
import torch.nn as nn
torch.manual_seed(0)
# 1. 模拟输入图像数据
B, C, H, W = 2, 3, 32, 32 # 批次2,RGB三通道,32x32分辨率
P = 8 # Patch 大小 8x8
x = torch.randn(B, C, H, W)
print(f"输入图像形状: x.shape = {x.shape}")
# 2. 将图像分割成 patches
# 使用 torch.nn.Unfold 可以方便地实现,但为了清晰,我们分步手动计算
# 方法:使用 .view 和 .permute 进行重塑
# 计算 patch 数量
N = (H // P) * (W // P) # (32/8)*(32/8) = 4*4 = 16
print(f"每张图像 patch 数量: N = {N}")
# 重塑步骤: (B, C, H, W) -> (B, C, num_patches_h, P, num_patches_w, P)
# -> (B, num_patches_h, num_patches_w, C, P, P) -> (B, N, C*P*P)
x_reshaped = x.view(B, C, H//P, P, W//P, P)
x_reshaped = x_reshaped.permute(0, 2, 4, 1, 3, 5) # (B, H/P, W/P, C, P, P)
patches = x_reshaped.reshape(B, N, -1) # (B, N, C*P*P)
print(f"展平后的 patches 形状: patches.shape = {patches.shape}")
# 3. 线性投影 (Patch Embedding)
D = 64 # Transformer 隐藏层维度
patch_embed = nn.Linear(C * P * P, D) # 投影矩阵 E
patch_embeddings = patch_embed(patches) # (B, N, D)
print(f"embeddings 形状: patch_embeddings.shape = {patch_embeddings.shape}")
print(f"投影矩阵权重形状: patch_embed.weight.shape = {patch_embed.weight.shape}")输入图像形状: x.shape = torch.Size([2, 3, 32, 32])
每张图像 patch 数量: N = 16
展平后的 patches 形状: patches.shape = torch.Size([2, 16, 192])
embeddings 形状: patch_embeddings.shape = torch.Size([2, 16, 64])
投影矩阵权重形状: patch_embed.weight.shape = torch.Size([64, 192])关键点:
patches.shape = (2, 16, 192):2 张图,每张 16 个 patch,每个 patch 有3*8*8=192个特征。patch_embeddings.shape = (2, 16, 64):经过线性投影,每个 patch 被映射为 64 维向量。这 16 个 64 维向量就构成了 Transformer 的输入序列。- 投影矩阵
E的形状为(D, C*P*P),即(64, 192),它是可学习的参数。
在实际的 ViT 实现中,步骤 2 和 3 通常通过一个卷积层高效完成:使用 kernel_size=P, stride=P 的卷积,其输出特征图的空间维度 (H/P, W/P) 对应 patch 网格,通道数 D 对应投影维度(nn.Conv2d(C, D, kernel_size=P, stride=P) 与上面两步在数学上等价)。早期实现多用 view/permute 重塑,现在更常用卷积以利用底层优化。
ViT 的完整输入:加入 [class] token 和位置编码
仅有 patch embeddings 还不够。Transformer 是排列不变的(permutation-invariant),打乱输入序列顺序,输出序列的顺序也会被打乱,但内容不变。然而图像具有重要的空间结构信息,必须显式地告诉模型每个 patch 的位置。
此外,对于分类任务,我们需要一个代表整个图像的单一向量。在 CNN 中,通常通过全局平均池化得到。在 Transformer 中,ViT 借鉴 BERT 的做法,引入一个额外的可学习嵌入向量,称为 [class] token。
输入序列的构建:
[class] token:初始化一个可学习的向量x_class,形状为(1, 1, D)。对于批次中的每张图像,都前置(prepend)一个相同的该向量到其 patch embeddings 序列前。- 序列长度从
N变为N+1。 - 这个特殊的 token 经过 Transformer 层层传递后,其最终状态 被用作整个图像的表示,送入分类头。
- 序列长度从
- 位置编码:为序列中的每个位置(包括
[class] token)添加一个可学习的位置编码向量E_pos。ViT 论文发现,简单的 1D 位置编码(即根据序列索引分配向量)效果已经很好,无需复杂的 2D 编码。
import torch
import torch.nn as nn
torch.manual_seed(0)
# 接上一段代码,假设已有 patch_embeddings: (B, N, D)
B, N, D = 2, 16, 64
patch_embeddings = torch.randn(B, N, D)
# 1. 创建并添加 [class] token
cls_token = nn.Parameter(torch.randn(1, 1, D)) # 可学习的参数
# 为批次中每个样本复制 cls_token
cls_tokens = cls_token.expand(B, -1, -1) # (B, 1, D)
# 将 cls_token 拼接到 patch embeddings 序列的开头
sequence = torch.cat((cls_tokens, patch_embeddings), dim=1) # (B, N+1, D)
print(f"添加 [class] token 后的序列形状: sequence.shape = {sequence.shape}")
# 2. 创建并添加位置编码
num_tokens = N + 1
pos_embedding = nn.Parameter(torch.randn(1, num_tokens, D)) # 可学习的 1D 位置编码
# 添加到序列上
sequence_with_pos = sequence + pos_embedding # (B, N+1, D)
print(f"添加位置编码后的序列形状: sequence_with_pos.shape = {sequence_with_pos.shape}")
print(f"位置编码形状: pos_embedding.shape = {pos_embedding.shape}")添加 [class] token 后的序列形状: sequence.shape = torch.Size([2, 17, 64])
添加位置编码后的序列形状: sequence_with_pos.shape = torch.Size([2, 17, 64])
位置编码形状: pos_embedding.shape = torch.Size([1, 17, 64])关键点:
sequence.shape = (2, 17, 64):序列长度变为 17(16个patch + 1个class token)。- 位置编码
pos_embedding也是可学习的参数,其形状为(1, 17, 64),会被广播加到批次中所有样本上。 - 至此,我们得到了 Transformer 编码器的标准输入:
(batch_size, sequence_length, hidden_dim)。
Transformer 编码器与归纳偏置
得到输入序列 后,ViT 把它送入由 L 个相同层堆叠成的编码器。每层含两个子层:多头自注意力(MSA)与前馈网络(MLP),每个子层前先做 LayerNorm(即 pre-LN),外面套残差连接——与下面三条公式一一对应。注意这跟原始 Transformer 的 post-LN(LayerNorm 放在残差相加之后)不同:pre-LN 不需要学习率 warmup 也能稳定训练深层堆叠,这正是 ViT 能一路堆到 24 层以上的前提。
公式表示如下(与论文一致):
其中 是第 L 层输出序列的第一个向量(即 [class] token 对应的向量), 即为图像表示。
核心讨论:归纳偏置(Inductive Bias) 这是理解 ViT 与 CNN 差异的关键。归纳偏置是模型为学习问题所做的一组假设,它引导模型以某种特定方式泛化。
- CNN 的归纳偏置:局部性(卷积核只关注局部邻域)和平移等变性(物体平移,其特征图也相应平移)。这些偏置非常契合自然图像的统计规律,使得 CNN 在数据量有限时也能高效学习。
- ViT 的归纳偏置:非常有限。主要来自两点:
- Patch 划分:将图像分割成块,隐含了块内像素相关的假设。
- 位置编码:模型知道序列的顺序,但需要从头学习 2D 空间关系的概念(例如,位置 3 和位置 7 在图像上可能相邻,也可能很远)。 ViT 的自注意力机制是全局的,从第一层起,每个 patch 就能看到所有其他 patch。它没有内置的局部性假设。
带来的结果:
- 数据量少时:ViT 由于缺乏强归纳偏置,容易在中等规模数据集(如 ImageNet-1K,约 128 万张)上过拟合,表现通常不如同等规模的 ResNet。
- 数据量极大时:当在超大规模数据集(如 JFT-300M,包含 3 亿张图像)上预训练时,ViT 能够从海量数据中学习到比 CNN 手工设计的偏置更通用、更强大的特征表示。此时,ViT 的性能可以超越最先进的 CNN。
这正是论文 An Image is Worth 16x16 Words(Dosovitskiy et al., 2020,arXiv:2010.11929)的核心结论:大规模训练可以战胜归纳偏置。
📝 动手练一练
问题:假设我们有一张 224x224 的 RGB 图像,采用 ViT-B/16 的配置(patch size=16,隐藏维度 D=768)。请计算:
- 每张图像被分成多少个 patch(N)?
- 线性投影层(Patch Embedding)的输入和输出维度分别是多少?
- 加上
[class] token和位置编码后,输入 Transformer 编码器的张量形状是什么?(假设 batch_size=1)
👉 点击查看参考答案
思路:根据公式 N = H * W / P^2 计算 patch 数量。投影层的输入维度是每个 patch 的像素数 C * P * P,输出维度是 D。最终序列长度是 N+1。
import torch
torch.manual_seed(0)
# 给定参数
H, W, C = 224, 224, 3
P = 16
D = 768
batch_size = 1
# 1. 计算 patch 数量 N
N = (H * W) // (P * P)
print(f"1. 每张图像 patch 数量 N = {N}")
# 2. 投影层维度
input_dim = C * P * P
output_dim = D
print(f"2. 线性投影层: 输入维度={input_dim}, 输出维度={output_dim}")
print(" nn.Linear 的 weight 形状为 (out_features, in_features)")
# 3. 最终输入形状
final_seq_len = N + 1
final_shape = (batch_size, final_seq_len, D)
print(f"3. 输入编码器的张量形状: {final_shape}")1. 每张图像 patch 数量 N = 196
2. 线性投影层: 输入维度=768, 输出维度=768
nn.Linear 的 weight 形状为 (out_features, in_features)
3. 输入编码器的张量形状: (1, 197, 768)答案:
- N = 196(224/16 = 14,14×14)。
- 投影层输入维度
C*P*P = 3×16×16 = 768,输出维度D = 768。这里两者恰好相等是 ViT-B/16 的巧合;换成 ViT-L/16(D=1024)就是 768 → 1024,能看出维度确实变了。PyTorch 的nn.Linear权重形状为(out_features, in_features)。 - 最终输入形状为
(1, 197, 768)(196 个 patch + 1 个[class] token)。
常见错误
| 错误理解或写法 | 现象或报错 | 原因 | 正确做法 |
|---|---|---|---|
忘记添加位置编码,或错误地将位置编码加到 [class] token 之前。 | 模型收敛缓慢,性能显著下降,因为 Transformer 无法感知 patch 的空间顺序。 | 误解了 Transformer 的排列不变性。图像 patch 的顺序至关重要。 | 确保在拼接 [class] token 和 patch embeddings 得到完整序列 z_0 后,再将位置编码 E_pos 加到整个序列上。 |
错误计算 patch 数量 N,或 P 不能整除 H 或 W。 | 张量重塑(reshape)或卷积操作时形状不匹配,导致运行时错误。 | 对输入图像尺寸和 patch 尺寸的关系理解不清。 | 在预处理时,确保将图像调整到 H 和 W 能被 P 整除的尺寸,或者使用自适应方法处理。计算 N = (H // P) * (W // P)。 |
| 在微调(fine-tuning)更高分辨率的图像时,直接使用预训练的位置编码。 | 位置编码长度与新的序列长度不匹配,无法相加。 | 预训练的位置编码是针对固定序列长度(如 197)学习的。分辨率提高,patch 数量 N 增加。 | 需要对预训练的位置编码进行 2D 插值(例如双线性插值),以适应新的序列长度。这是 ViT 论文中 fine-tuning 到高分辨率的关键步骤之一。 |
将 [class] token 的位置编码视为可选项。 | 可能影响不大,但非最优。最佳实践是包含。 | [class] token 也是一个序列元素,需要有自己的位置信息。 | 位置编码 E_pos 的长度应为 N+1,其第一个向量就是分配给 [class] token 的。 |
本章小结
- ViT 的核心创新在于将图像分割为固定大小的 patch,并线性投影为向量序列,从而将图像分类问题转化为序列处理问题,使 Transformer 得以直接应用。
- 输入构建三部曲:Patch Embedding(线性投影) +
[class] token(用于聚合全局信息) + 位置编码(注入空间信息)。 - 与 CNN 的根本区别在于归纳偏置:CNN 内置了强局部性和平移等变性,而 ViT 的偏置很弱,主要依赖数据驱动学习。
- 数据规模决定性能:在中等数据量上,ViT 可能不如 CNN;但在超大规模数据(如 JFT-300M)上预训练后,ViT 展现出超越 SOTA CNN 的性能,证明了“大规模训练战胜归纳偏置”。
- 实现关键是准确计算形状变换:
(B,C,H,W)->(B,N,C*P*P)->(B,N,D)->(B,N+1,D)。
📋 行动清单
- 使用 PyTorch 编写一个完整的
PatchEmbedding模块,包含通过卷积层实现的分块和投影,并验证输入输出形状。 - 对比
nn.Unfold、手动view/permute和卷积三种实现 patch 分割的方式,理解其等价性。 - 尝试将位置编码从可学习的 1D 编码改为固定的正弦余弦编码(如原始 Transformer),观察对简单分类任务(如 CIFAR-10)训练初期 loss 下降曲线的影响(需自行搭建简易训练循环)。
延伸阅读
本段 PyTorch 官方文档/教程条目的框架出处:PyTorch: An Imperative Style, High-Performance Deep Learning Library(Paszke et al., 2019,arXiv:1912.01703);页面内容以 PyTorch 2.14.1 为准。
- An Image is Worth 16x16 Words: Transformers for Image Recognition at Scale (Dosovitskiy et al., 2020, arXiv:2010.11929) —— 本课核心论文。
- Attention Is All You Need (Vaswani et al., 2017, arXiv:1706.03762) —— Transformer 原始论文,ViT 的编码器部分基于此。
- PyTorch 官方文档 Conv2d:https://pytorch.org/docs/stable/generated/torch.nn.Conv2d.html —— 用卷积实现 patch embedding 的写法。
—— 小象教研组