news 2026/10/22 8:06:10

ViT图像分类从原理到实战:Patch Embedding、训练与排坑指南

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
ViT图像分类从原理到实战:Patch Embedding、训练与排坑指南

图像分类这个任务,放在几年前大家第一反应基本就是CNN那一套:ResNet、EfficientNet、MobileNet,各种卷积结构轮番上场。但自从Vision Transformer(简称ViT)出现之后,整个视觉分类的思路被彻底翻了一遍——把图像切成一个个小方块,当成一串token丢给Transformer去跑,居然也能达到甚至超过CNN的效果。这篇就围绕ViT在图像分类任务上的原理、实现和排坑过程展开,结合我在实际项目里的使用心得,把每个环节拆开讲透。想搞懂ViT为什么能work、想自己从零写一个去训练、想复现这类结构的读者,这篇应该能帮上忙。

1. 项目思路拆解:为什么图像分类也能用Transformer

1.1 从CNN到Transformer:分类范式的变化

CNN统治图像分类的时间太长,以至于很多人默认“图像任务就该用卷积”。CNN之所以能work,靠的是三个非常重要的归纳偏置:局部性(卷积核只看相邻像素)、权值共享(同一卷积核滑遍全图)以及平移等变性(目标位置变化不影响识别结果)。这些先验让CNN在有限数据下也能学到不错的特征,本质上是在用结构约束降低模型复杂度。

但Transformer最开始是为NLP设计的,它压根没有这些先验。自注意力机制允许序列中的任意两个位置直接交互,位置信息完全靠额外加进去的位置编码来提供。当研究者把这种结构搬来处理图像时,等于是说:我不预设特征应该怎么组织,全靠数据自己说话。这在当时是一个非常大胆的选择,因为纯Transformer模型没有局部性约束,如果直接用在小型数据集上,很容易因为模型容量大、数据量不够而陷入过拟合。

ViT的意义在于它证明了纯Transformer在视觉任务上的可行性。不是用CNN提取特征后再接Transformer做融合,而是从原始像素直接切patch、直接建模全局关系。结果在大规模数据集上,ViT的表现追平甚至超过了当时的CNN结构。这件事当时确实让不少做视觉的人重新思考:我们到底需要多少“人工设计”的先验?后来大量实验也表明,只要数据量足够大、训练策略正确,Transformer完全能自己学会图像中的空间结构。

1.2 ViT的核心设计思路:把图像当“句子”读

理解ViT最简单的方式,就是把它和NLP里的句子处理对标。你在读一句话时,词和词的顺序本身有意义,而语义需要结合上下文来理解。ViT把一张图像当作一段“句子”,把图像划分成固定大小的patch(比如16x16像素一块),每一块相当于句子里的一个“词”。

每个patch有三个通道(RGB),拉直成一个向量后,通过一个线性映射变成定长的embedding向量。这个映射过程就叫Patch Embedding。随后,所有patch的embedding拼成序列,加上一个专门用于分类的CLS token,再叠加上位置编码,送入标准的Transformer Encoder。Encoder里的自注意力机制会让每个patch去关注图像里其他所有patch的信息,从而构建全局上下文。最后一层取CLS token的输出向量,过一层分类头就能得到类别概率。

这个思路最大的变化在于:图像不再是一张二维矩阵,而是一组一维token。空间结构不再由卷积核的局部窗口隐式表达,而是由位置编码显式告诉模型“你原来在哪”。这种设计的好处是,任何位置的patch都能直接与其他位置的patch交互,长距离依赖问题天然被解决,不需要像CNN那样通过堆叠几十层来扩大感受野。

1.3 什么时候选ViT,什么时候继续用CNN

在实际项目里选型,千万别抱着“ViT是新东西,所以一定更好”的心态。CNN和ViT的取舍核心是两个变量:数据规模和计算资源。

先看数据规模。ViT没有卷积那种强归纳偏置,从零训练的条件下,它对数据量的需求比CNN高不少。业界普遍经验是,如果你只有几万张图甚至更少,从零训一个ViT效果大概率不如同量级CNN。反过来,如果你手里有百万级别的数据,或者能从大规模预训练权重出发做迁移学习,那ViT的优势会被明显放大。

再看计算资源。ViT在相同参数量下计算量通常高于CNN,而且对训练配置更敏感。很多情况下,同样的预算和时间,CNN已经训完了,ViT还在调参。下列表格可以辅助判断:

维度CNNViT
归纳偏置强(局部性、平移等变)弱(全靠数据学习)
数据需求少样本也能凑合大数据或预训练才香
长距离依赖靠深层堆叠间接获取天然全局建模
计算效率小数据集上更省大规模场景更划算
训练难度相对低、好调对学习率、正则化敏感
迁移能力成熟但特征偏局部预训练后泛化性很强

日常项目里,如果任务是中小规模的图像分类,我通常先用CNN打底,快速出一版结果;如果发现数据量足够、硬件也够好,再换成ViT尝试涨点。或者反过来,直接加载预训练ViT做微调,这样能绕开从零训练的坑,通常效果也不错。

2. 核心细节解析:ViT的每一块拼图

2.1 Patch Embedding:图像如何变成Token

Patch Embedding是整个ViT里最容易被人忽略、却最基础的一步。假设输入图像大小是224x224,通道数是3,选定的patch大小是16x16。这时图像被均匀切分成14x14=196个patch,每个patch展开后的维度是16x16x3=768。这196个patch就是输入序列的长度,每个patch经过一次线性变换后得到的768维向量,就对应Transformer里token的embedding维度。

实际工程实现上,这个线性变换不会真的逐patch做矩阵乘法,而是直接用一层卷积核大小等于patch_size、步长也等于patch_size的卷积层来完成。比如nn.Conv2d(in_chans=3, embed_dim=768, kernel_size=16, stride=16)。这层卷积的输出正好是[B, 768, 14, 14],再展平成[B, 196, 768]。很多初看ViT代码的人会困惑“为什么用卷积实现embedding”,其实就是因为这种操作等价于对每个patch做线性映射,而且卷积在GPU上的效率更高。

关于patch_size的选择,它直接决定了序列长度和每个token的信息粒度。patch越小,序列越长,计算量越大,但能保留更细的空间细节;patch越大,序列越短,计算量少,但可能丢失局部细节。ViT-Base/16用的是16x16,ViT-Large/14用的14x14,实践下来在ImageNet级别的数据上都是合理的。如果换到小图数据集,比如32x32的CIFAR,直接用16x16的patch会产生很尴尬的情况:整张图只有2x2=4个patch,序列太短,Transformer很难发挥全局建模能力。这时候就要把patch_size调小,比如4x4,或者先把图resize到224x224再训练。

2.2 Position Embedding:让模型知道“顺序”

Transformer的自注意力机制本身是置换不变的——你把token的顺序打乱,计算出来的结果在数学上是一样的。这对NLP来说不可接受,对图像来说更不能接受,因为图像的空间结构本身就是重要信息。猫的眼睛长在鼻子上面,轮子在车身下面,这类位置关系必须被模型感知到。

ViT采用的做法非常直接:在输入序列进入Transformer之前,给每个token的embedding加上一个可学习的位置向量。因为序列里除了196个patch token,还有一个CLS token,所以位置编码的shape是[1, 197, 768]。这个位置编码是随机初始化、随训练更新的,模型会在训练过程中自己学会利用位置信息。

很多人问过一个问题:2D位置编码会不会比1D位置编码更好?毕竟图像是二维结构。我在实际实验里对比过这两种方案,结论是效果差别很小。原因在于ViT的位置编码是加在patch embedding上的,后续的多头注意力可以自行从这个向量的不同分量中解码出相对位置关系。既然1D够用,就省去了构造2D坐标映射表的麻烦。顺带一提,如果训练时固定输入分辨率,但推理时想用更高分辨率,位置编码就需要做插值处理,这是很多“换分辨率就变差”问题的根源。

2.3 Transformer Encoder:自注意力到底在看什么

ViT的主干就是堆叠多个结构相同的Transformer Encoder Block。每个Block包含两个核心子层:多头自注意力(Multi-Head Self-Attention)和MLP,每个子层外面都套了LayerNorm和残差连接。

自注意力的计算流程可以这么理解:对于序列里的每个token,我们生成三个向量,分别叫Query、Key、Value。其中Query是“我当前这个patch想找谁”,Key是“我是谁,我能提供什么信息”,Value是“我携带的实际内容”。注意力权重的计算方式是对所有token做Query和Key的点积,值越大说明相关性越强,再经过softmax归一化后作为加权系数,对Value做加权求和。这个过程的直觉很像在图书馆检索:你带着自己的问题(Query),去匹配每本书的标签(Key),然后从匹配到的书里提取内容(Value)。

具体公式是:

Attention(Q, K, V) = softmax(Q K^T / sqrt(d_k)) V

其中d_k是每个注意力头的维度,除以sqrt(d_k)非常关键。如果不除以这个缩放因子,当维度变大时,点积的数值会随之增大,落入softmax的饱和区,梯度会变得极小,训练几乎推不动。这也是Transformer训练里一个容易被忽略的细节。

多头注意力则是把768维的embedding分成12个64维的“头”,每个头独立做注意力计算,最后拼接起来。不同头会关注不同的关系,比如有的头倾向于关注颜色相似的区域,有的头关注形状特征,有的头关注目标边缘。这个机制给了模型更强的表征能力。

MLP部分是每个token独立经过的两层全连接网络,通常是4倍隐藏维度扩张再加GELU激活。它的作用是给每个token单独做特征变换和非线性映射。LayerNorm和残差连接保证了深层模型训练的稳定性,让ViT可以堆到12层甚至24层而不至于梯度消失。

2.4 CLS Token与分类头:怎么输出分类结果

ViT的分类头设计沿用了BERT里的做法:在输入序列的最前面拼接一个特殊的CLS token,它的初始向量是随机初始化的可学习参数。经过整个Transformer Encoder之后,把这个CLS token对应位置的输出向量取出来,作为整张图像的全局特征表示,再过一层线性分类器得到类别概率。

这种设计的巧妙之处在于,CLS token的初始向量不包含任何图像信息,但它是序列中的一个位置,因此会在注意力计算过程中不断聚合其他patch token的信息。训练完成后,CLS token的输出可以被理解为模型对整张图语义的浓缩表示。我实际还试过另一种更朴素的方案:不用CLS token,而是把所有patch token的最后一层输出做全局平均池化(GAP),再送入分类头。这个方案在某些数据集上的表现甚至更稳定,原因是GAP强制模型利用所有patch的信息,而不是依赖某个特定位置。开源代码里也常见这种变体,叫作ViT-GAP,效果不比CLS方案差。

分类头通常就是一个线性层,输入维度是768,输出维度是类别数。如果类别数很多,也可以先接一个Dropout再加线性层,防止过拟合。整个ViT的核心组件加起来就这么点东西:Patch Embedding、Position Embedding、Transformer Block、CLS Token、分类头。逻辑并不复杂,真正难的是把训练跑稳。

3. 实操过程:从零实现ViT图像分类器

3.1 环境准备与数据准备

实操部分我用PyTorch来实现,因为它在动态图和生态上最顺手。官方实现是JAX写的,但对大多数人来说PyTorch版本更容易修改和调试。环境方面只需要torch和torchvision,配合GPU训练即可。

数据方面,我用一个自建的图像分类数据集来演示,目录结构就是常见的train/类别名/图片.jpg和val/类别名/图片.jpg。直接调用torchvision.datasets.ImageFolder就能读进来,非常省事。数据增强部分和CNN训练时类似,但ViT对数据增强的依赖更强,所以会多加几项:

from torchvision import datasets, transforms train_transform = transforms.Compose([ transforms.RandomResizedCrop(224, scale=(0.8, 1.0)), transforms.RandomHorizontalFlip(), transforms.RandAugment(num_ops=2, magnitude=9), transforms.ColorJitter(0.3, 0.3, 0.3), transforms.ToTensor(), transforms.Normalize(mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225]), ]) val_transform = transforms.Compose([ transforms.Resize(256), transforms.CenterCrop(224), transforms.ToTensor(), transforms.Normalize(mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225]), ]) train_ds = datasets.ImageFolder("data/train", transform=train_transform) val_ds = datasets.ImageFolder("data/val", transform=val_transform)

这里有一个实操经验:RandAugment对ViT很有用。CNN有时候光靠随机裁剪和翻转就能训得不错,但ViT因为缺少归纳偏置,更需要多样化的输入来弥补这一点。数据增强是ViT从零训练时最重要的“免费午餐”之一。

3.2 模型实现:一个可运行的ViT完整结构

下面是我在实际项目里使用并踩过不少坑之后整理出的完整实现。为了讲清楚结构,我把整个网络拆成了四个部分:PatchEmbed、Attention、Mlp、Block,最后用VisionTransformer把它们组合起来。

import torch import torch.nn as nn import torch.nn.functional as F class PatchEmbed(nn.Module): """图像切块并映射到embedding维度""" def __init__(self, img_size=224, patch_size=16, in_chans=3, embed_dim=768): super().__init__() self.num_patches = (img_size // patch_size) ** 2 # 用卷积实现等价线性映射 self.proj = nn.Conv2d(in_chans, embed_dim, kernel_size=patch_size, stride=patch_size) def forward(self, x): # x: [B, C, H, W] x = self.proj(x) # [B, embed_dim, H/p, W/p] x = x.flatten(2) # [B, embed_dim, num_patches] x = x.transpose(1, 2) # [B, num_patches, embed_dim] return x class Attention(nn.Module): """多头自注意力""" def __init__(self, dim, num_heads=8, qkv_bias=True, attn_drop=0.0, proj_drop=0.0): super().__init__() self.num_heads = num_heads head_dim = dim // num_heads self.scale = head_dim ** -0.5 self.qkv = nn.Linear(dim, dim * 3, bias=qkv_bias) self.attn_drop = nn.Dropout(attn_drop) self.proj = nn.Linear(dim, dim) self.proj_drop = nn.Dropout(proj_drop) def forward(self, x): B, N, C = x.shape qkv = self.qkv(x).reshape(B, N, 3, self.num_heads, C // self.num_heads) qkv = qkv.permute(2, 0, 3, 1, 4) # [3, B, heads, N, head_dim] q, k, v = qkv[0], qkv[1], qkv[2] attn = (q @ k.transpose(-2, -1)) * self.scale attn = attn.softmax(dim=-1) attn = self.attn_drop(attn) x = (attn @ v).transpose(1, 2).reshape(B, N, C) x = self.proj(x) x = self.proj_drop(x) return x class Mlp(nn.Module): """每个token独立过MLP""" def __init__(self, in_features, hidden_features=None, out_features=None, drop=0.0): super().__init__() out_features = out_features or in_features hidden_features = hidden_features or in_features * 4 self.fc1 = nn.Linear(in_features, hidden_features) self.act = nn.GELU() self.fc2 = nn.Linear(hidden_features, out_features) self.drop = nn.Dropout(drop) def forward(self, x): x = self.fc1(x) x = self.act(x) x = self.drop(x) x = self.fc2(x) x = self.drop(x) return x class Block(nn.Module): """一个Transformer Encoder Block""" def __init__(self, dim, num_heads, mlp_ratio=4.0, drop=0.0, attn_drop=0.0): super().__init__() self.norm1 = nn.LayerNorm(dim) self.attn = Attention(dim, num_heads=num_heads, attn_drop=attn_drop, proj_drop=drop) self.norm2 = nn.LayerNorm(dim) self.mlp = Mlp(in_features=dim, hidden_features=int(dim * mlp_ratio), drop=drop) def forward(self, x): # Pre-LN结构:先做LayerNorm再进Attention/MLP x = x + self.attn(self.norm1(x)) x = x + self.mlp(self.norm2(x)) return x class VisionTransformer(nn.Module): """ViT完整结构""" def __init__(self, img_size=224, patch_size=16, in_chans=3, num_classes=1000, embed_dim=768, depth=12, num_heads=12, mlp_ratio=4.0, drop=0.1, attn_drop=0.0): super().__init__() self.patch_embed = PatchEmbed(img_size, patch_size, in_chans, embed_dim) num_patches = self.patch_embed.num_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)) self.pos_drop = nn.Dropout(drop) self.blocks = nn.Sequential(*[ Block(embed_dim, num_heads, mlp_ratio, drop, attn_drop) for _ in range(depth) ]) self.norm = nn.LayerNorm(embed_dim) self.head = nn.Linear(embed_dim, num_classes) # 初始化策略:正态分布初始化,LayerNorm置为1和0 nn.init.trunc_normal_(self.pos_embed, std=0.02) nn.init.trunc_normal_(self.cls_token, std=0.02) self.apply(self._init_weights) def _init_weights(self, m): if isinstance(m, nn.Linear): nn.init.trunc_normal_(m.weight, std=0.02) if m.bias is not None: nn.init.zeros_(m.bias) elif isinstance(m, nn.LayerNorm): nn.init.ones_(m.weight) nn.init.zeros_(m.bias) def forward(self, x): B = x.shape[0] x = self.patch_embed(x) # [B, N, embed_dim] cls_token = self.cls_token.expand(B, -1, -1) x = torch.cat((cls_token, x), dim=1) # [B, N+1, embed_dim] x = x + self.pos_embed x = self.pos_drop(x) x = self.blocks(x) x = self.norm(x) cls = x[:, 0] # 取CLS token输出 return self.head(cls)

这个实现用的结构是Pre-LN,也就是在进入注意力层之前先做LayerNorm,而不是在子层之后。千万别小看这个顺序,我一开始按Post-LN(先注意力再LayerNorm)写,结果12层Transformer很难训稳,换到Pre-LN后收敛速度明显提升。这是一个非常实用的经验。

3.3 训练配置与参数计算

训练配置上,我也踩过不少坑。ViT对学习率异常敏感,直接搬CNN那套SGD或者偏大的学习率基本都会让loss像过山车一样震荡。推荐使用AdamW优化器,配合学习率warmup和cosine退火。

import torch.optim as optim from torch.optim.lr_scheduler import CosineAnnealingLR, LinearLR, SequentialLR model = VisionTransformer(img_size=224, patch_size=16, in_chans=3, num_classes=10, embed_dim=768, depth=12, num_heads=12, drop=0.1, attn_drop=0.0) optimizer = optim.AdamW(model.parameters(), lr=5e-4, weight_decay=0.05) # warmup 3个epoch,然后cosine退火到很低的lr warmup_epochs = 3 total_epochs = 100 warmup_scheduler = LinearLR(optimizer, start_factor=0.01, end_factor=1.0, total_iters=warmup_epochs) cosine_scheduler = CosineAnnealingLR(optimizer, T_max=total_epochs - warmup_epochs, eta_min=1e-6) scheduler = SequentialLR(optimizer, schedulers=[warmup_scheduler, cosine_scheduler], milestones=[warmup_epochs])

这里有几个参数值得说道一下:

  • 学习率峰值5e-4是ViT-Base/16配合batch size 256时的常见配置。如果你的batch size明显更大或者更小,学习率可以按线性缩放原则调整,比如batch翻倍,学习率大致翻倍。
  • weight_decay设成0.05是Transformer系模型的常用做法,比CNN常用的1e-4大不少。它对抑制过拟合很有帮助,但也不能设太大,否则会欠拟合。
  • warmup的作用是让模型在训练初期不要剧烈更新参数。Transformer的embedding初始数值范围比较大,直接在峰值学习率下更新很容易导致早期不稳定,先用小学习率跑几个epoch,等分布稳定后再涨到峰值就顺滑很多。

参数计算方面,ViT-Base/16的结构是一目了然的:输入224x224,patch=16,序列长度196。embedding维度768,Transformer层数12,注意力头数12,每个头64维,MLP隐层3072。整体参数量大约8600万,这个体量跟ResNet101是一个级别。

计算量大概在17.6 GFLOPs左右,比ResNet50的4.1 GFLOPs大了四倍多。所以在硬件不太够的前提下,往往不是模型参数量卡你,而是计算量和显存占用卡你。

3.4 训练循环与评估

训练循环本身不复杂,关键在准确保留验证集评估逻辑。下面是我项目里的训练主循环:

import time from tqdm import tqdm def train_one_epoch(model, loader, optimizer, criterion, device, grad_clip=1.0): model.train() total_loss, correct, total = 0.0, 0, 0 for images, labels in tqdm(loader, leave=False): images, labels = images.to(device), labels.to(device) outputs = model(images) loss = criterion(outputs, labels) optimizer.zero_grad() loss.backward() nn.utils.clip_grad_norm_(model.parameters(), grad_clip) optimizer.step() total_loss += loss.item() * images.size(0) correct += outputs.argmax(dim=1).eq(labels).sum().item() total += images.size(0) return total_loss / total, correct / total def evaluate(model, loader, criterion, device): model.eval() total_loss, correct, total = 0.0, 0, 0 with torch.no_grad(): for images, labels in loader: images, labels = images.to(device), labels.to(device) outputs = model(images) loss = criterion(outputs, labels) total_loss += loss.item() * images.size(0) correct += outputs.argmax(dim=1).eq(labels).sum().item() total += images.size(0) return total_loss / total, correct / total criterion = nn.CrossEntropyLoss() device = torch.device("cuda" if torch.cuda.is_available() else "cpu") model.to(device) for epoch in range(total_epochs): train_loss, train_acc = train_one_epoch(model, train_loader, optimizer, criterion, device) val_loss, val_acc = evaluate(model, val_loader, criterion, device) scheduler.step() print(f"Epoch {epoch+1}/{total_epochs} | " f"train_loss {train_loss:.4f} | train_acc {train_acc:.4f} | " f"val_loss {val_loss:.4f} | val_acc {val_acc:.4f}")

梯度剪裁这一行是我强烈建议保留的。ViT在训练后期偶发会出现某个batch的loss突然飙高的情况,如果不做梯度剪裁,一次异常大幅更新就可能把整个模型参数推离正常区域,后续怎么训都回不来。clip_grad_norm设为1.0在多数情况下能在不影响收敛速度的同时避免这种灾难。

4. 常见问题与排查技巧实录

4.1 数据集太小,ViT直接翻车

很多第一次跑ViT的人都会遇到这个现象:同样是从零训练,ViT在几千张图上训了半天,准确率依然不如一个小ResNet。我一开始也中招了,以为是自己代码写错了,后来才意识到这是ViT的特性——缺乏归纳偏置,小数据集根本撑不起它的容量。

解决办法有三个方向。第一个是使用预训练权重微调,这是最有效的一条路。第二个是改用patch_size更小的变体,降低序列粒度,让模型更容易捕捉小尺寸数据里的局部特征,比如在CIFAR-10上把patch_size从16降到4。第三个是增大数据增强强度,用更激进的RandAugment和MixUp来弥补数据量不足。这些手段配合下来,小数据集上的表现可以提升不少。

4.2 训练loss震荡、不收敛

ViT最典型的训练问题就是loss震荡。表现是前几个epoch loss下降正常,到某个节点突然冲高,之后再降回来,反复几次,最终效果很差。这个问题我排查过很多次,最常见的根源有三个:

  • 学习率太高,注意力机制的softmax对梯度幅值很敏感。
  • warmup期太短,模型分布还没稳定就直接进入峰值学习率。
  • 某个batch的数据没处理好,比如存在全黑的图或者异常像素值。

处理方式就是刚才代码里的三板斧:AdamW、warmup、梯度裁剪。如果做了这三件事还震荡,可以继续降低峰值学习率,或者把batch size调大。batch size在Transformer里往往比学习率更影响稳定性,条件允许时优先扩大batch。

4.3 显存不足

ViT的显存占用比同参数量CNN要高,其中一个原因是自注意力机制需要保存QK^T的完整注意力矩阵,这个矩阵的大小是batch size乘以序列长度的平方。序列长度196时还好,但如果你把patch调小或者输入分辨率调大,序列长度会迅速膨胀,显存随之爆掉。

解决办法也有几条路:一是梯度累积,用时间换空间;二是混合精度训练,我用AMP之后显存基本能省一半;三是降低输入分辨率,比如把224改成160,序列长度保持不变,但每个patch的embedding计算量会下降。如果必须用高分辨率,就要认真考虑换更高效的变体了。

4.4 过拟合,验证集准确率上不去

ViT在小数据集上过拟合比CNN来得更快,具体表现是训练准确率很快逼近100%,验证集准确率却停滞不前。除了常规的Dropout和Weight Decay,我推荐重点检查两个位置:

  • 位置编码和CLS token的Dropout是不是默认太小。pre-training的时候dropout一般设0.1,但从零训练时我会把drop调到0.2甚至0.3。
  • MLP模块里的Dropout有没有真的加在两层之间。很多简化实现只在输出层drop一次,实际两层都drop效果更稳。

还有一个容易被忽略的细节:LayerNorm的初始化不要动,保持weight=1、bias=0。有人为了“提升表达能力”去调整LayerNorm的初始化,结果往往适得其反。

4.5 位置编码和CLS token的坑

自己手写ViT时,坑最多的地方就是位置编码和CLS token的初始化。初次接触时我踩过一个很经典的坑:忘记初始化cls_token和pos_embed,它们保持全0,训练出的模型和随机初始化差不了多少。还有一个坑是,预训练模型是在固定分辨率下做的位置编码,推理时如果你改成更大的分辨率,必须对位置编码做插值,否则位置信息错乱,准确率直接雪崩。这里贴一段插值代码的核心逻辑:

def interpolate_pos_embed(pos_embed, new_num_patches): # pos_embed: [1, old_N+1, embed_dim],去掉cls token后插值 old_pos = pos_embed[:, 1:, :] # [1, old_N, embed_dim] old_h = old_w = int(old_pos.shape[1] ** 0.5) new_h = new_w = int(new_num_patches ** 0.5) old_pos = old_pos.reshape(1, old_h, old_w, -1).permute(0, 3, 1, 2) # [1, C, h, w] new_pos = F.interpolate(old_pos, size=(new_h, new_w), mode="bilinear", align_corners=False) new_pos = new_pos.permute(0, 2, 3, 1).flatten(1, 2) # [1, new_N, C] return torch.cat([pos_embed[:, :1], new_pos], dim=1)

这个思路也适用于从预训练权重微调时,输入分辨率比预训练分辨率大的情况。如果不做插值,模型在更高分辨率输入下的表现会明显退化。别问我是怎么知道的,问就是深夜调参的眼泪。

5. 一点个人体会

最后说点我自己实际跑下来的感受。ViT这东西很挑训练配置,绝不是把CNN那套训练流程搬过来就能跑出效果。如果你手头数据不多,别急着从零训练,先去找预训练权重微调,很多时候你花几天调一个从零训练的ViT,还不如加载预训练模型跑一个晚上。再说一个小技巧:先用小patch、小分辨率把网络结构和训练流程跑通,再逐步放大输入尺寸迁移到目标分辨率,调试周期能缩短一大截。ViT虽然坑多,但当你第一次把注意力权重可视化出来,看到它真的能学会关注图像里那些关键区域的时候,你会觉得这些折腾都值了。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/22 8:05:55

Python编辑器怎么选?VS Code、PyCharm、Jupyter Lab场景化决策指南

1. 别再问“用哪个编辑器好”,先搞懂你到底在写什么Python“学习Python,用哪个编辑器比较好?”——这个问题我每天在技术社区、新手群、高校实验室里至少看到二十遍。但每次看到,我都忍不住先反问一句:你写的Python&am…

作者头像 李华
网站建设 2026/10/22 8:05:26

词法语法句法语义:中文NLP四层结构实战解析

1. 这不是语言学考卷,而是你每天都在用的底层操作系统 “词法,语法,句法,语义”这八个字,乍看像大学《现代汉语》期末复习提纲,或是某位导师在黑板上写下的抽象概念。但如果你今天发过朋友圈、改过一份项目…

作者头像 李华
网站建设 2026/10/22 8:03:38

手写日志框架:理解原理、设计管道与性能调优

1. 从一次“有日志但等于没日志”的线上排查看起 1.1 日志里只有心跳,没有真相 上一周我排查一个历史服务的偶发超时问题,翻了大半天日志,最后发现日志文件里满满当当全是心跳、健康检查和定时任务的记录,真正出问题的那个请求连…

作者头像 李华
网站建设 2026/10/22 8:02:32

Polars 自定义命名空间跨调用保存状态——contextvars 与上下文管理器

用 register_dataframe_namespace 给 Polars 挂一个自己的命名空间很方便,但想让 .mine.startlog() 记录的状态在后面的 .mine.endlog() 里还能读到,就会撞上对象生命周期的问题。 问题现象 设想实现一条链式日志:开头记录一次形状,链条跑完再记录一次,打印出这一步增删…

作者头像 李华
网站建设 2026/10/22 7:58:17

提示词设计实战:文生图、视频生成与数字人的稳定可控方案

前阵子帮人做一个线上读书栏目的视觉改版,对方给了句需求:一个“科技感、有温度”的主播形象,出图、出片、还要能开口说话。我那会儿文生图已经用了一段时间,自认提示词水平不算差,结果第一版生成出来,主播…

作者头像 李华
网站建设 2026/10/22 7:58:16

CANopen联调排障利器:CANtest报文分析从入门到实战

简介:CANtest是一款面向工业自动化、汽车电子及航空航天等领域工程师的CANopen配置与调试工具。它覆盖节点ID分配、对象字典编辑、PDO/SDO配置、NMT网络管理及故障检测等核心环节,能显著减少手动配置的时间与出错率。压缩包共81个文件,约48.9…

作者头像 李华