拓冰建站拓冰建站
首页 / 资讯中心 / 正文

从卷积到注意力:Vision Transformer核心原理与代码实现详解

简介在计算机视觉领域卷积神经网络CNN长期以来是处理图像任务的主流架构其通过局部感受野和层次化结构提取特征。然而CNN在建模长距离依赖关系方面存在效率瓶颈。自注意力机制作为一种全局关系建模工具通过计算序列中所有元素间的关联强度实现了高效的上下文理解。这一机制在自然语言处理中取得突破后被成功迁移至视觉领域催生了Vision TransformerViT这一基础模型。ViT通过将图像分割为块序列并引入位置编码使Transformer架构能够直接处理图像数据实现了从局部卷积到全局注意力的范式转换。该技术在图像分类、目标检测等场景展现出强大性能并推动了Swin Transformer等高效变体的发展。本文以ViT为例深入解析其核心模块与实现细节帮助读者掌握这一现代视觉基础架构。1. 从卷积到注意力为什么我们需要Vision Transformer如果你在过去几年里接触过计算机视觉那么“Vision Transformer”这个名字你一定不陌生。它像一阵旋风彻底改变了我们处理图像的方式。但说实话我第一次看到VIT论文时心里是犯嘀咕的把Transformer这种为序列数据比如文本设计的模型直接用在图像上这听起来就像用螺丝刀去拧螺母工具不对口啊。传统的卷积神经网络CNN统治了视觉领域那么多年从AlexNet到ResNet我们习惯了它的局部感受野、平移不变性一切都显得那么“理所当然”。然而正是这种“理所当然”背后藏着CNN的局限性。卷积核是局部的它只能看到自己窗口里的一小片像素。为了理解整张图片CNN需要堆叠很多层让信息一层层传递和聚合。这个过程有点像拼拼图你一次只能看清手边几块需要反复比对、移动才能拼出全貌。这导致了两个问题一是模型对长距离依赖比如图片左上角的猫耳朵和右下角的猫尾巴之间的关系建模效率不高二是这种层次化的结构让模型更像一个“黑箱”我们很难直观理解它到底是如何做出决策的。Transformer的出现提供了一种全新的视角。它的核心是“自注意力机制”简单来说就是让序列中的每一个元素在NLP里是词在VIT里是图像块都能直接“看到”序列中的所有其他元素并计算它们之间的关联强度。这相当于拼拼图时你突然拥有了上帝视角能瞬间看清所有碎片以及它们之间的潜在联系。对于图像而言这意味着模型从一开始就能建立全局的上下文理解理论上能更高效地捕捉复杂的空间关系。所以VIT的诞生并非偶然而是一次对视觉基础模型的“范式转换”。它挑战了“图像必须用卷积来处理”的固有思维证明了纯注意力机制在视觉任务上同样强大甚至在大规模数据上能超越CNN。对于初学者而言理解VIT不仅是学习一个新模型更是打开一扇门去理解现代深度学习如何打破模态边界用统一的架构处理不同数据。接下来我们就抛开复杂的数学公式用代码和直觉亲手搭建一个属于你自己的VIT。2. 化整为零图像如何变成Transformer能“吃”的序列这是理解VIT最关键也最有趣的一步。Transformer生来是为处理像句子这样的“一维序列”而设计的。句子天然就是词或子词的序列。但一张图片是二维的网格高度×宽度×通道。我们怎么把方方正正的图片变成Transformer喜欢的“一串珠子”呢VIT的做法非常直观甚至有点“暴力美学”把图片切成小块Patch然后拉平Flatten。想象一下你有一张224x224像素的彩色图片3个通道。VIT会用一个固定大小的窗口比如16x16像素在图片上滑动但不重叠地切分。224除以16等于14所以你会得到14x14196个小图像块。每个小图像块是16x16x3768个像素值。然后我们把每个16x16x3的块沿着空间维度拉平变成一个长度为768的一维向量。这样我们就得到了196个向量每个向量代表原图的一个局部区域。这196个向量就构成了Transformer要处理的“序列”。序列长度是196每个序列元素的特征维度是768。注意这个“切块”操作本质上是一个步长stride等于块大小patch size的卷积操作。你可以把它理解为一个内核大小为16x16步长为16输出通道数为768的卷积层。这是VIT中唯一隐含了“卷积”思想的地方但它没有可学习的参数或者在后来的实现中用一个可学习的线性投影层替代。但是直接把这些向量扔给Transformer还有问题。第一Transformer本身对序列的顺序是不敏感的打乱这196个向量的顺序它计算出的注意力权重是一样的。但图像中块的位置信息至关重要。第二我们需要一个代表整个图像的“总指挥”来最终做分类。VIT巧妙地引入了两个特殊的标记Token来解决这两个问题位置编码Positional Encoding我们生成一组可学习的向量其数量等于序列长度196每个向量的维度也是768。然后把这些位置编码向量加到对应的图像块向量上。这样模型在计算注意力时就能感知到每个块在原始图像中的位置信息。这是将空间结构信息注入到无位置感知的注意力机制中的关键。分类标记[CLS] Token这是一个额外添加的可学习向量我们把它放在序列的最前面。这个标记本身不包含任何图像信息但在经过所有Transformer层的“信息聚合”后序列中所有图像块的信息都会通过注意力机制汇聚到这个[CLS]标记上。因此在模型最后我们只需要取出这个[CLS]标记对应的输出向量通过一个简单的分类头通常是MLP就能得到整个图像的分类结果。让我们用PyTorch代码来直观感受一下这个“图像转序列”的过程这是整个VIT的起点import torch import torch.nn as nn import torch.nn.functional as F class PatchEmbed(nn.Module): 将图像分割为块并嵌入向量。 def __init__(self, img_size224, patch_size16, in_chans3, embed_dim768): super().__init__() self.img_size img_size self.patch_size patch_size self.n_patches (img_size // patch_size) ** 2 # 计算块的数量如14x14196 # 使用一个卷积层来实现“切块拉平线性投影”三步操作 # 卷积核大小步长patch_size输出通道数embed_dim self.proj nn.Conv2d(in_chans, embed_dim, kernel_sizepatch_size, stridepatch_size) def forward(self, x): # x的形状: (batch_size, 3, 224, 224) x self.proj(x) # 应用卷积输出形状: (batch_size, 768, 14, 14) x x.flatten(2) # 将高和宽维度展平形状: (batch_size, 768, 196) x x.transpose(1, 2) # 调整维度最终形状: (batch_size, 196, 768) # 现在我们有了 batch_size 个序列每个序列有196个元素每个元素是768维的向量 return x3. 自注意力VIT模型如何“理解”图像内容拿到了形状为(batch_size, 196, 768)的序列数据后真正的魔法开始了——自注意力机制。这是Transformer也是VIT的灵魂。很多人觉得注意力机制很复杂其实它的直觉非常朴素当你看一张图片时你的目光会在不同的区域之间跳跃和关联。比如看一张“猫在沙发上”的图片你的大脑会瞬间关联“猫头”、“猫身”、“沙发纹理”这些区域并赋予它们不同的重要性。自注意力机制就是在模拟这个过程。具体来说对于我们的序列196个图像块向量自注意力层会为每一个块向量生成三个新的向量查询向量Query可以理解为这个块在“问”“其他块中哪些与我相关”键向量Key可以理解为这个块在“回答”“我是这样的这是我的身份标识。”值向量Value可以理解为这个块所携带的“实际信息内容”。计算过程分为四步计算注意力分数对于序列中的每一个块比如第i个块我们用它的Query向量去和序列中所有块包括它自己的Key向量做点积。点积的结果越大说明这两个向量的方向越相似关联性越强。这样就得到了一个分数表示第i个块对第j个块的“关注程度”。缩放与归一化这些点积分数可能会很大导致梯度不稳定。因此我们会除以一个缩放因子通常是Key向量维度的平方根。然后对所有分数应用Softmax函数将其转化为概率分布所有权重和为1。这个概率分布就是注意力权重它清晰地告诉我们对于当前块i应该以多大的比例去“吸收”其他每个块j的信息。加权求和将上一步得到的注意力权重与所有块的Value向量相乘并求和。这就生成了当前块i的新的表示向量。这个新向量不再是它自己孤立的信息而是融合了全局上下文中所有相关块信息的“增强版”表示。多头并行为了增强模型的能力我们不会只做一次上述计算而是并行地做多次例如12次每次使用不同的、可学习的线性投影矩阵来生成Q、K、V。这就是“多头注意力”。每个头可以关注不同方面的信息例如一个头关注颜色一个头关注边缘一个头关注纹理关联。最后把所有头的输出拼接起来再经过一个线性层融合。这个过程让每个图像块在每一层Transformer中都能与所有其他块直接“对话”。经过多层堆叠后信息在全局范围内充分流动和混合模型从而获得了强大的全局建模能力。下面是一个简化的多头自注意力模块的PyTorch实现核心class MultiHeadSelfAttention(nn.Module): 简化版的多头自注意力模块。 def __init__(self, embed_dim768, num_heads12, dropout0.0): super().__init__() assert embed_dim % num_heads 0, embed_dim 必须能被 num_heads 整除 self.num_heads num_heads self.head_dim embed_dim // num_heads self.scale self.head_dim ** -0.5 # 缩放因子 # 将输入投影到Q, K, V空间 self.qkv nn.Linear(embed_dim, embed_dim * 3) # 一次性计算出所有头的Q,K,V self.attn_drop nn.Dropout(dropout) self.proj nn.Linear(embed_dim, embed_dim) # 最后的输出投影 self.proj_drop nn.Dropout(dropout) def forward(self, x): B, N, C x.shape # B: batch_size, N: 序列长度(196), C: 嵌入维度(768) # 通过线性层得到qkv并重塑为多头形式 qkv self.qkv(x).reshape(B, N, 3, self.num_heads, self.head_dim).permute(2, 0, 3, 1, 4) q, k, v qkv[0], qkv[1], qkv[2] # 每个的形状: (B, num_heads, N, head_dim) # 计算注意力分数: (B, num_heads, N, N) attn (q k.transpose(-2, -1)) * self.scale attn attn.softmax(dim-1) # 在最后一个维度(N)上做Softmax得到注意力权重 attn self.attn_drop(attn) # 加权求和得到新的表示 x (attn v).transpose(1, 2).reshape(B, N, C) # (B, N, C) x self.proj(x) x self.proj_drop(x) return x4. 构建完整的Transformer编码器不只是注意力一个完整的Transformer编码器层在VIT中称为“Block”并不仅仅只有自注意力。为了让模型更有效、更稳定地学习VIT遵循了原始Transformer的设计在每个自注意力层后面接了一个前馈神经网络并且在整个过程中使用了残差连接和层归一化。这是一个非常经典且强大的组合。层归一化LayerNorm在注意力计算和前馈计算之前我们先对输入进行层归一化。这有助于稳定训练过程缓解梯度消失或爆炸问题。它与BatchNorm不同LayerNorm是对单个样本的所有特征进行归一化对batch大小不敏感更适合Transformer和变长序列。残差连接Residual Connection这是从ResNet借鉴来的思想。我们将模块注意力或前馈网络的输入直接加到模块的输出上即输出 模块(层归一化(输入)) 输入。这创建了一条“高速公路”让梯度可以直接回流极大地缓解了深层网络训练中的退化问题使得堆叠数十层Transformer成为可能。前馈神经网络Feed-Forward Network, FFN这是一个简单的两层MLP通常中间有一个扩展因子例如4倍。它的作用是对每个序列位置每个图像块的特征进行独立的、非线性的变换和增强。你可以把它理解为在每个“单词”层面上进行的一次特征精炼。公式通常是FFN(x) Linear(GELU(Linear(x)))。因此一个VIT Block的执行顺序是输入 - LayerNorm1 - 多头自注意力 - 残差相加 - LayerNorm2 - 前馈网络 - 残差相加 - 输出让我们把PatchEmbed、注意力、FFN等全部组装起来形成一个完整的VIT编码器块和微型VIT模型class TransformerBlock(nn.Module): 一个完整的Transformer编码器块。 def __init__(self, embed_dim768, num_heads12, mlp_ratio4.0, dropout0.0): super().__init__() self.norm1 nn.LayerNorm(embed_dim) self.attn MultiHeadSelfAttention(embed_dim, num_heads, dropout) self.norm2 nn.LayerNorm(embed_dim) mlp_hidden_dim int(embed_dim * mlp_ratio) self.mlp nn.Sequential( nn.Linear(embed_dim, mlp_hidden_dim), nn.GELU(), # VIT中使用GELU激活函数 nn.Dropout(dropout), nn.Linear(mlp_hidden_dim, embed_dim), nn.Dropout(dropout) ) def forward(self, x): # 第一部分自注意力 残差 x x self.attn(self.norm1(x)) # 先Norm再注意力再加回输入 # 第二部分前馈网络 残差 x x self.mlp(self.norm2(x)) return x class VisionTransformer(nn.Module): 一个极简的Vision Transformer模型。 def __init__(self, img_size224, patch_size16, in_chans3, num_classes1000, embed_dim768, depth12, num_heads12, mlp_ratio4.0): super().__init__() self.patch_embed PatchEmbed(img_size, patch_size, in_chans, embed_dim) num_patches self.patch_embed.n_patches # 可学习的分类标记和位置编码 self.cls_token nn.Parameter(torch.zeros(1, 1, embed_dim)) self.pos_embed nn.Parameter(torch.zeros(1, num_patches 1, embed_dim)) # 1 for cls_token self.pos_drop nn.Dropout(p0.1) # 堆叠Transformer块 self.blocks nn.ModuleList([ TransformerBlock(embed_dim, num_heads, mlp_ratio) for _ in range(depth) ]) self.norm nn.LayerNorm(embed_dim) # 分类头 self.head nn.Linear(embed_dim, num_classes) # 初始化参数 nn.init.trunc_normal_(self.pos_embed, std0.02) nn.init.trunc_normal_(self.cls_token, std0.02) def forward(self, x): B x.shape[0] # batch size # 1. 图像切块嵌入 x self.patch_embed(x) # (B, num_patches, embed_dim) # 2. 添加分类标记和位置编码 cls_tokens self.cls_token.expand(B, -1, -1) # 扩展到batch中每一份 x torch.cat((cls_tokens, x), dim1) # (B, 1num_patches, embed_dim) x x self.pos_embed x self.pos_drop(x) # 3. 通过Transformer编码器 for blk in self.blocks: x blk(x) # 4. 取出分类标记对应的输出并分类 x self.norm(x) cls_output x[:, 0] # 取第一个位置即[CLS]标记 out self.head(cls_output) return out5. 从零开始训练一个微型VIT实战代码与避坑指南理论说得再多不如亲手跑一遍代码。这里我们将在经典的CIFAR-10数据集上训练一个超小型的VIT。CIFAR-10图片尺寸是32x32为了适配我们的模型我们需要调整一些参数。同时我们会遇到VIT训练中几个典型的“坑”。第一步数据准备与模型调整CIFAR-10图像尺寸为32x32。如果我们还用16x16的块那么只能得到2x24个块序列太短模型能力受限。因此我们将块大小改为4x4这样可以得到8x864个块。同时为了减少计算量我们大幅缩小模型尺寸。import torchvision import torchvision.transforms as transforms from torch.utils.data import DataLoader # 数据预处理和加载 transform_train transforms.Compose([ transforms.RandomCrop(32, padding4), transforms.RandomHorizontalFlip(), transforms.ToTensor(), transforms.Normalize((0.4914, 0.4822, 0.4465), (0.2023, 0.1994, 0.2010)), ]) transform_test transforms.Compose([ transforms.ToTensor(), transforms.Normalize((0.4914, 0.4822, 0.4465), (0.2023, 0.1994, 0.2010)), ]) trainset torchvision.datasets.CIFAR10(root./data, trainTrue, downloadTrue, transformtransform_train) trainloader DataLoader(trainset, batch_size128, shuffleTrue, num_workers2) testset torchvision.datasets.CIFAR10(root./data, trainFalse, downloadTrue, transformtransform_test) testloader DataLoader(testset, batch_size100, shuffleFalse, num_workers2) # 定义一个微型VIT (Tiny-VIT) class TinyViT(VisionTransformer): def __init__(self, img_size32, patch_size4, num_classes10, embed_dim192, depth6, num_heads3): super().__init__(img_sizeimg_size, patch_sizepatch_size, num_classesnum_classes, embed_dimembed_dim, depthdepth, num_headsnum_heads, mlp_ratio2.0) # 使用更小的扩展比例 # 因为img_size和patch_size变了需要重新计算位置编码的参数数量 num_patches (img_size // patch_size) ** 2 self.pos_embed nn.Parameter(torch.zeros(1, num_patches 1, embed_dim)) nn.init.trunc_normal_(self.pos_embed, std0.02) model TinyViT() device torch.device(cuda if torch.cuda.is_available() else cpu) model.to(device)第二步训练循环与关键技巧VIT从零开始训练尤其是在中小型数据集上比CNN更困难更容易过拟合。以下是训练中的几个核心技巧和避坑点优化器与学习率调度使用AdamW优化器它比Adam对权重衰减的处理更正确。配合带热重启的余弦退火学习率调度CosineAnnealingWarmRestarts这在训练Transformer时非常有效。标签平滑Label Smoothing这是防止过拟合的一个简单而强大的正则化技术。它让模型的目标标签不再是绝对的0或1而是稍微平滑一下例如0.1和0.9这能减轻模型对训练数据的过度自信。梯度裁剪Gradient ClippingTransformer的深度和注意力机制有时会导致梯度爆炸裁剪梯度可以稳定训练。更长的预热期Warmup在训练开始时用一个很小的学习率线性增加到预设值这有助于模型在初期稳定地找到优化方向。import torch.optim as optim from torch.optim.lr_scheduler import CosineAnnealingWarmRestarts criterion nn.CrossEntropyLoss(label_smoothing0.1) # 使用标签平滑 optimizer optim.AdamW(model.parameters(), lr5e-4, weight_decay0.05) # 使用余弦退火热重启T_0是第一次重启的周期T_mult是周期增长因子 scheduler CosineAnnealingWarmRestarts(optimizer, T_010, T_mult2, eta_min1e-6) def train(epoch): model.train() running_loss 0.0 for batch_idx, (inputs, targets) in enumerate(trainloader): inputs, targets inputs.to(device), targets.to(device) optimizer.zero_grad() outputs model(inputs) loss criterion(outputs, targets) loss.backward() torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm1.0) # 梯度裁剪 optimizer.step() scheduler.step(epoch batch_idx / len(trainloader)) # 每个batch更新学习率 running_loss loss.item() return running_loss / len(trainloader)第三步常见问题与调试Loss不下降或为NaN首先检查数据归一化的均值标准差是否正确。其次尝试降低初始学习率增加Warmup的步数。梯度裁剪也是解决NaN的常用手段。验证集准确率远低于训练集过拟合除了使用标签平滑和权重衰减可以尝试更强的数据增强如CutMix, MixUp, RandAugment。对于微型VITDropout率可以适当调高例如在注意力层和MLP层使用0.1-0.2的dropout。如果数据量实在太小考虑使用预训练模型。训练速度慢注意力机制的计算复杂度与序列长度的平方成正比。对于CIFAR-1064个块问题不大但对于ImageNet196个块或更高分辨率图像计算量会剧增。这是VIT的固有缺点也是后续Swin Transformer等模型试图改进的方向。位置编码初始化位置编码是可学习的参数需要用较小的标准差如0.02进行截断正态初始化。错误的初始化可能导致训练不稳定。6. 超越分类VIT的潜力与现代视觉架构的演进虽然我们以图像分类为例介绍了VIT但它的能力远不止于此。一旦图像被转化为序列表示许多源自NLP的Transformer技术都可以迁移过来。目标检测DETRFacebook提出的DETR是首个将Transformer成功应用于目标检测的模型。它不再需要复杂的锚框anchor和非极大值抑制NMS而是将检测视为一个集合预测问题使用Transformer编码器-解码器结构直接输出一组目标框和类别。图像分割Segmenter, SETR可以将VIT输出的所有图像块特征而不仅仅是[CLS]标记进行上采样或与CNN特征融合为每个像素分配语义标签实现语义分割。多模态学习CLIPOpenAI的CLIP模型同时训练一个图像编码器通常是VIT和一个文本编码器让模型学会将图像和文本映射到同一个语义空间。这使得“以文搜图”、“零样本图像分类”成为可能。VIT的提出也暴露了其计算效率上的问题尤其是对高分辨率图像。这催生了一系列改进架构它们构成了现代视觉Transformer的生态Swin Transformer引入了“局部窗口注意力”和“跨窗口移动”的机制将计算复杂度从图像尺寸的平方级降低到线性级同时还能建模多尺度特征成为了许多视觉任务的强大骨干网络。DeiTData-efficient Image Transformer专注于解决VIT需要海量数据训练的问题。它通过引入一个“蒸馏令牌”和一位“教师模型”通常是CNN利用知识蒸馏技术让VIT能在ImageNet-1K这样的数据集上取得媲美CNN的效果。PVTPyramid Vision Transformer模仿CNN的金字塔结构在Transformer中构建特征金字塔使其更适合需要多尺度特征的下游任务如检测和分割。理解基础的VIT是理解这一切演进的起点。它告诉我们注意力机制作为一种强大的关系建模工具其适用性远超文本。当你亲手实现并训练了一个VIT之后再去看这些变体你会更清楚地理解它们每一个设计选择背后的动机是为了提升效率、增强局部性、降低数据依赖还是为了构建多尺度表征。这不再是阅读一篇篇令人望而生畏的论文而是在探索一个活跃、有趣且快速发展的技术领域的地图。本文还有配套的精品资源点击获取
分享:

看完干货,该让你的企业上线了

免费需求沟通 · 48 小时内出具建站方案 · 河南本地可上门