Vision Transformer(学习自用)


参考资源:

  1. ViT论文逐段精读
  2. 代码学习1
  3. 代码学习2

前言

背景与动机
背景:Transformers 在自然语言处理(NLP)任务中表现出色,并成为主流模型。受此启发,研究人员尝试将其应用于计算机视觉任务。
动机:传统的视觉任务主要依赖于卷积神经网络,而 Transformers 主要用于替代部分卷积层或增强特征表示。本文探讨了完全基于 Transformers 的模型在图像分类中的表现。

图示结构

Vision Transformer (ViT) 结构

  1. 图像划分为补丁:将输入图像划分为固定大小的补丁(如16x16),然后将这些补丁展平并线性嵌入到特定维度。
  2. 位置嵌入:为了保留位置信息,将可学习的位置嵌入添加到每个补丁嵌入中。
  3. Transformer 编码器:将这些嵌入作为序列输入到标准的 Transformer 编码器中,编码器由多层多头自注意力和前馈神经网络组成。
  4. 分类标记:在输入序列的开头添加一个可学习的分类标记,其最终的表示用于图像分类。
    结构图
    在这里插入图片描述

公式表示结构

在这里插入图片描述

初始输入表示

z 0 \mathbf{z}_0 z0 = [ x class \mathbf{x}_{\text{class}} xclass; x p 1 E ; x p 2 E \mathbf{x}^1_p\mathbf{E}; \mathbf{x}^2_p\mathbf{E} xp1E;xp2E; ⋯ \cdots ; x p N E ] \mathbf{x}^N_p\mathbf{E}] xpNE] + E pos \mathbf{E}_{\text{pos}} Epos

  • x class \mathbf{x}_{\text{class}} xclass:分类标记(classification token)
  • x p i \mathbf{x}^i_p xpi:第 i i i 个补丁(patch)的展平向量
  • E \mathbf{E} E:线性变换矩阵,将补丁向量映射到嵌入空间
  • E pos \mathbf{E}_{\text{pos}} Epos:位置嵌入,用于保留每个补丁的位置信息
  • z 0 \mathbf{z}_0 z0:初始嵌入表示,包含分类标记和所有补丁的嵌入表示以及位置嵌入的和

多头自注意力层

z ℓ ′ \mathbf{z}'_{\ell} z = MSA ( LN ( z ℓ − 1 ) ) \text{MSA}(\text{LN}(\mathbf{z}_{\ell-1})) MSA(LN(z1)) + z ℓ − 1 \mathbf{z}_{\ell-1} z1, ℓ = 1 … L \quad \ell = 1 \ldots L =1L

  • LN \text{LN} LN:Layer Normalization(层归一化)
  • MSA \text{MSA} MSA:Multi-Head Self-Attention(多头自注意力机制)
  • z ℓ − 1 \mathbf{z}_{\ell-1} z1:前一层的输出
  • z ℓ ′ \mathbf{z}'_{\ell} z:当前层多头自注意力后的输出,加上前一层的残差连接

前馈神经网络(MLP)层

z ℓ \mathbf{z}_{\ell} z = MLP ( LN ( z ℓ ′ ) ) \text{MLP}(\text{LN}(\mathbf{z}'_{\ell})) MLP(LN(z)) + z ℓ ′ \mathbf{z}'_{\ell} z, ℓ = 1 … L \quad \ell = 1 \ldots L =1L

  • MLP \text{MLP} MLP:包含两层线性变换和一个 GELU 非线性激活函数的前馈神经网络
  • z ℓ ′ \mathbf{z}'_{\ell} z:当前层多头自注意力后的输出
  • z ℓ \mathbf{z}_{\ell} z:当前层前馈神经网络后的输出,加上多头自注意力层的残差连接

最终输出表示

y \mathbf{y} y = LN ( z L 0 ) \text{LN}(\mathbf{z}^0_L) LN(zL0)

  • LN \text{LN} LN:Layer Normalization(层归一化)
  • z L 0 \mathbf{z}^0_L zL0:第 L L L 层的分类标记输出

各模块介绍

初始输入

分类标记的作用
分类标记的引入源自 Transformer 架构中用于分类任务的一个设计。在某些模型(例如 Vision Transformer, ViT)中,输入图像被划分成多个小补丁,每个补丁被嵌入到一个向量表示中。分类标记被添加到补丁序列的前面,其作用是聚合来自所有补丁的信息,并用于最终的分类任务。

位置嵌入目的
在图像处理的 Transformer 模型(如 Vision Transformer, ViT)中,位置嵌入(positional embeddings)用于保留输入图像中每个补丁的位置信息。因为 Transformer 模型本身不具备位置信息,所以需要显式地将这些信息添加到输入中。使用二维感知嵌入未能显著提高性能,所以使用一维。

self.positions = nn.Parameter(torch.randn((img_size // patch_size) ** 2 + 1, emb_size))
"""
img_size // patch_size 计算图像在每个维度上被划分成的补丁数。
(img_size // patch_size) ** 2 计算总的补丁数。
+ 1 用于包含分类标记(classification token)的嵌入。
emb_size 是嵌入向量的维度。
"""

一维原因

图像分割和补丁嵌入

  1. 在 ViT 模型中,输入图像首先被划分为多个固定大小的补丁(patch),例如每个补丁的大小为 16x16。每个补丁被展平为一个一维向量,并通过一个线性变换(全连接层)转换为一个高维嵌入向量。
  2. 线性变换(全连接层):假设输入图像的大小为 224x224,划分为 16x16 的补丁,则总共会有 (224 / 16) * (224 / 16) = 14 * 14 = 196 个补丁。每个补丁的像素数为 16 * 16 = 256,如果输入图像是 RGB 图像,则每个补丁的维度为 256 * 3 = 768。
  3. 嵌入维度的选择:通过线性变换,将每个补丁的展平向量转换为一个固定大小的嵌入向量。这个固定大小的嵌入维度通常设置为 768。选择 768 作为嵌入维度是一个经验性的选择,通常在实际应用中,嵌入维度的大小可以根据具体的任务和数据集进行调整。例如,可以选择 512、768、1024 等不同的嵌入维度。

嵌入维度的作用:

  1. 高维嵌入向量能够捕捉更多的特征和信息,有助于模型更好地表示和理解输入数据。
  2. 嵌入维度的大小直接影响模型的参数数量和计算复杂度。较大的嵌入维度通常意味着更多的参数和更高的计算复杂度,但也可能带来更好的模型性能。

掩码的作用
在多头注意力机制中,掩码的作用非常重要,特别是在处理不同长度的序列或需要忽略某些特定位置时。例如:

  • 填充掩码(Padding Mask):在处理不同长度的序列时,较短的序列会被填充到相同长度。填充部分不应该影响注意力权重计算,因此需要使用掩码将这些位置的注意力权重置为零。
  • 未来信息掩码(Future Mask):在自回归模型(如 Transformer 解码器)中,预测下一个标记时,不应看到未来的标记。未来信息掩码用于屏蔽未来位置的注意力权重。

下面给出相关代码:

class PatchEmbedding(nn.Module):
    def __init__(self, in_channels: int = 3, patch_size: int = 16, emb_size: int = 768, img_size: int = 224):
        super().__init__()
        self.patch_size = patch_size
        # 使用卷积层将图像划分为补丁并进行嵌入
        self.projection = nn.Sequential(
            nn.Conv2d(in_channels, emb_size, kernel_size=patch_size, stride=patch_size),
            Rearrange('b e (h) (w) -> b (h w) e') # 重排张量以适应 Transformer 输入
        )
        # 分类标记(可训练参数)
        self.cls_token = nn.Parameter(torch.randn(1, 1, emb_size))
        # 位置嵌入(可训练参数)
        self.positions = nn.Parameter(torch.randn((img_size // patch_size) ** 2 + 1, emb_size))

    def forward(self, x: Tensor) -> Tensor:
        b, _, _, _ = x.shape  # 提取输入张量的 batch 大小
        x = self.projection(x)  # 对输入图像进行补丁划分和嵌入
        # 重复分类标记,以适应 batch 大小
        cls_tokens = repeat(self.cls_token, '() n e -> b n e', b=b)
        # 将分类标记添加到补丁嵌入的前面
        x = torch.cat([cls_tokens, x], dim=1)
        # 添加位置嵌入
        x += self.positions
        return x

多头注意力

学到了一种新的写法:
e i n o p s \color{red}{einops} einops 是一个 Python 库,它提供了一种简洁易用的方式来重新排列多维数组的形状和轴。 r e a r r a n g e \color{red}{ rearrange } rearrange 函数是库中用于重新排列数组轴的一个函数。它允许你以一种非常直观的方式指定新的轴顺序和大小。

输入张量x

x 的形状是 torch.Size([1, 197, 768]),
表示:1 是批次大小(batch size),197 是序列长度(sequence length)(1414+1),768 是嵌入维度(embedding dimension)(1616*3)
以下面的语句为例:

queries = rearrange(self.queries(x), "b n (h d) -> b h n d", h=self.num_heads)

self.queries(x) 的形状仍然是 (1, 197, 768)。

模式字符串 “b n (h d) -> b h n d” 解释

b 表示 batch size,对应 1。
n 表示序列长度,对应 197。
(h d) 表示嵌入维度 768 被拆分为 h 和 d,其中 h 是注意力头的数量,d 是每个头的维度。
h=self.num_heads,假设 num_heads=8。

计算每个头的维度 d

嵌入维度 768 被拆分为 8 个头,每个头 d 的维度是 768 / 8 = 96。
新形状
b 仍然是 1。
h 是注意力头的数量,对应 8。
n 仍然是序列长度,对应 197。
d 是每个头的维度,对应 96。

输出形状

rearrange 操作将输出形状为 (1, 8, 197, 96)。

这样方便后续计算。

代码如下:

class MultiHeadAttention(nn.Module):
    def __init__(self, emb_size: int = 768, num_heads: int = 8, dropout: float = 0):
        super().__init__()
        self.emb_size = emb_size
        self.num_heads = num_heads
        self.scaling = (self.emb_size // num_heads) ** -0.5
        
        # 定义线性变换层,用于生成 keys、queries 和 values
        self.keys = nn.Linear(emb_size, emb_size)
        self.queries = nn.Linear(emb_size, emb_size)
        self.values = nn.Linear(emb_size, emb_size)
        
        # 定义 dropout 层
        self.att_drop = nn.Dropout(dropout)
        
        # 定义投影层
        self.projection = nn.Linear(emb_size, emb_size)

    def forward(self, x: Tensor, mask: Tensor = None) -> Tensor:
        # 将输入 x 通过线性变换层生成 queries、keys 和 values,并拆分成多个头
        queries = rearrange(self.queries(x), "b n (h d) -> b h n d", h=self.num_heads)
        keys = rearrange(self.keys(x), "b n (h d) -> b h n d", h=self.num_heads)
        values = rearrange(self.values(x), "b n (h d) -> b h n d", h=self.num_heads)
        
        # 计算注意力能量矩阵
        energy = torch.einsum('bhqd, bhkd -> bhqk', queries, keys) # b: 批量大小, h: 头数, q: 查询长度, k: 键长度
        
        # 如果存在掩码,应用掩码
        if mask is not None:
            fill_value = torch.finfo(torch.float32).min
            energy.masked_fill_(~mask, fill_value)
        
        # 计算注意力分数并进行缩放
        att = F.softmax(energy, dim=-1) * self.scaling
        att = self.att_drop(att)
        
        # 根据注意力分数加权 values,并将结果重排回原始形状
        out = torch.einsum('bhqk, bhvd -> bhqd', att, values)
        out = rearrange(out, "b h n d -> b n (h d)")
        
        # 通过线性投影层
        out = self.projection(out)
        return out

编码器

结构图
编码器
Transformer 编码器由交替的多头自注意力层(MSA,参见附录 A)和 MLP 块组成。每个块之前应用 Layernorm(LN),每个块之后应用残差连接。

残差连接块

使用ResidualAdd 类是一个用于实现残差连接(Residual Connection)的自定义 PyTorch 模块。它的目的是通过添加直接的快捷路径(shortcut path)来缓解深度神经网络训练中的梯度消失和梯度爆炸问题。这种连接方式允许信号绕过一个或多个层,从而促进信息和梯度的更好传播。

class ResidualAdd(nn.Module):
    def __init__(self, fn):
        super().__init__()
        self.fn = fn  # 将传入的函数或模块存储为类的一个属性
        
    def forward(self, x, **kwargs):
        res = x  # 保存输入 x 作为残差
        x = self.fn(x, **kwargs)  # 对输入 x 应用传入的函数或模块
        x += res  # 将残差添加回到输出
        return x  # 返回加了残差的输出

前馈神经网络模块

论文中介绍
在这里插入图片描述
使用如下代码

class FeedForwardBlock(nn.Sequential):
    def __init__(self, emb_size: int, expansion: int = 4, drop_p: float = 0.):
        super().__init__(
            nn.Linear(emb_size, expansion * emb_size),  # 第一层线性变换,将嵌入维度扩展
            nn.GELU(),  # GELU 激活函数     
            nn.Dropout(drop_p),  # Dropout 层,用于正则化
            nn.Linear(expansion * emb_size, emb_size),  # 第二层线性变换,将维度还原为原始嵌入维度
        )

expansion 参数定义了前馈神经网络中隐藏层的扩展倍数,即隐藏层的维度是输入嵌入维度的多少倍。通过设置这个参数,可以控制前馈神经网络的容量,从而影响模型的表示能力和性能。在上面的示例中,通过设置 expansion 参数,可以灵活地调整前馈神经网络块的规模。

层归一化

分类头部模块,用于将 Transformer 编码器的输出转换为最终的分类结果。这个模块包含三个主要组件:平均池化(Reduce),层归一化(LayerNorm),和全连接层(Linear)。

import torch.nn as nn
from einops.layers.torch import Reduce

class ClassificationHead(nn.Sequential):
    def __init__(self, emb_size: int = 768, n_classes: int = 1000):
        super().__init__(
            Reduce('b n e -> b e', reduction='mean'),  # 平均池化
            nn.LayerNorm(emb_size),  # 层归一化
            nn.Linear(emb_size, n_classes)  # 全连接层输出类别
        )

残差连接模块

对应多头注意力层前馈神经网络层

z ℓ ′ \mathbf{z}'_{\ell} z = MSA ( LN ( z ℓ − 1 ) ) \text{MSA}(\text{LN}(\mathbf{z}_{\ell-1})) MSA(LN(z1)) + z ℓ − 1 \mathbf{z}_{\ell-1} z1, ℓ = 1 … L \quad \ell = 1 \ldots L =1L

z ℓ \mathbf{z}_{\ell} z = MLP ( LN ( z ℓ ′ ) ) \text{MLP}(\text{LN}(\mathbf{z}'_{\ell})) MLP(LN(z)) + z ℓ ′ \mathbf{z}'_{\ell} z, ℓ = 1 … L \quad \ell = 1 \ldots L =1L

class TransformerEncoderBlock(nn.Sequential):
    def __init__(self,
                 emb_size: int = 768,
                 drop_p: float = 0.,
                 forward_expansion: int = 4,
                 forward_drop_p: float = 0.,
                 **kwargs):
        super().__init__(
            # 第一个残差连接模块:LayerNorm + MultiHeadAttention + Dropout
            ResidualAdd(nn.Sequential(
                nn.LayerNorm(emb_size),
                MultiHeadAttention(emb_size, **kwargs),
                nn.Dropout(drop_p)
            )),
            # 第二个残差连接模块:LayerNorm + FeedForwardBlock + Dropout
            ResidualAdd(nn.Sequential(
                nn.LayerNorm(emb_size),
                FeedForwardBlock(
                    emb_size, expansion=forward_expansion, drop_p=forward_drop_p),
                nn.Dropout(drop_p)
            ))
        )
        
class TransformerEncoder(nn.Sequential):
    def __init__(self, depth: int = 12, **kwargs):
        """
        *[TransformerEncoderBlock(**kwargs) for _ in range(depth)] 是一个列表解析,创建了 depth 个 TransformerEncoderBlock 实例,每个实例都传递了相同的参数 **kwargs。
        * 操作符将列表解析生成的多个 TransformerEncoderBlock 实例解包并传递给 nn.Sequential 的构造函数,从而构建一个包含多个编码器块的序列。
        """
        super().__init__(*[TransformerEncoderBlock(**kwargs) for _ in range(depth)])

整体结构

import torch.nn as nn

class ViT(nn.Sequential):
    def __init__(self,     
                in_channels: int = 3,  # 输入图像的通道数(RGB 图像为 3)
                patch_size: int = 16,  # 补丁的大小(16x16)
                emb_size: int = 768,  # 嵌入维度
                img_size: int = 224,  # 输入图像的大小(224x224)
                depth: int = 12,  # Transformer 编码器的层数
                n_classes: int = 200,  # 类别数,Tiny-ImageNet-200 数据集有 200 个类别
                **kwargs):  # 额外的关键字参数
        super().__init__(
            PatchEmbedding(in_channels, patch_size, emb_size, img_size),  # 图像补丁嵌入层
            TransformerEncoder(depth, emb_size=emb_size, **kwargs),  # Transformer 编码器
            ClassificationHead(emb_size, n_classes)  # 分类头部
        )

# 各组件的详细注释:

# PatchEmbedding:
# 将输入图像分割成固定大小的补丁(patch),并将每个补丁转换为一个高维嵌入向量。
# 输入:形状为 (batch_size, in_channels, img_size, img_size) 的张量。
# 输出:形状为 (batch_size, num_patches + 1, emb_size) 的嵌入向量,其中 +1 是分类标记。

# TransformerEncoder:
# 多层 Transformer 编码器,每层包含一个多头自注意力模块和一个前馈神经网络模块。
# 输入:形状为 (batch_size, num_patches + 1, emb_size) 的嵌入向量。
# 输出:形状为 (batch_size, num_patches + 1, emb_size) 的嵌入向量。

# ClassificationHead:
# 使用分类标记的输出,通过平均池化和全连接层进行最终的分类。
# 输入:形状为 (batch_size, emb_size) 的分类标记嵌入向量。
# 输出:形状为 (batch_size, n_classes) 的类别概率分布。

训练模型

超参数设置

论文中超参数设置
补丁大小 P:选择了 16 和 32 两种不同的补丁大小。
嵌入维度 D:在 192 到 1024 之间选择不同的嵌入维度。
Transformer 层数 L:我们实验了从 12 层到 24 层的不同设置。
注意力头数 h:选择了从 3 到 16 头的注意力机制。

数据处理

使用tinny-imagenet-200数据集,图片大小为64*64,为了适配 Vision Transformer 的输入要求(通常是 224x224),需要对图像进行重新调整和预处理。

数据集结构:
数据集

import os

import torchvision.transforms as transforms
import torchvision.datasets as datasets
from torch.utils.data import DataLoader

# 设置数据集路径
data_dir = 'tiny-imagenet-200'
train_dir = os.path.join(data_dir, 'train')
val_dir = os.path.join(data_dir, 'val')

# 定义数据增强和预处理
# transforms.RandomResizedCrop(224):这个操作会随机裁剪图像的一个子区域,并将其缩放到 224x224 大小。这种裁剪和缩放是随机的,有助于增加训练数据的多样性,从而增强模型的泛化能力。
# transforms.RandomHorizontalFlip():这个操作会随机水平翻转图像(以 50% 的概率)。这种数据增强方法进一步增加了训练数据的多样性。

transform_train = transforms.Compose([
    transforms.RandomResizedCrop(224),
    transforms.RandomHorizontalFlip(),
    transforms.ToTensor(),
    transforms.Normalize(mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225])
])

# transforms.Resize(256):这个操作会将图像的较短边调整到 256 像素,而较长边会按比例缩放。这个步骤确保所有图像的最短边都为 256 像素,从而为后续的中心裁剪做好准备。
# transforms.CenterCrop(224):这个操作会在图像的中心裁剪出一个 224x224 的区域。这种裁剪方法保证了输入图像的大小一致,有助于模型在验证或测试阶段的稳定性。

transform_val = 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_dataset = datasets.ImageFolder(train_dir, transform=transform_train)
val_dataset = datasets.ImageFolder(val_dir, transform=transform_val)

train_loader = DataLoader(train_dataset, batch_size=32, shuffle=True, num_workers=4)
val_loader = DataLoader(val_dataset, batch_size=32, shuffle=False, num_workers=4)

准备工作

import torch.optim as optim

# 设置设备
device = torch.device("cuda:0" if torch.cuda.is_available() else "cpu")

# 定义 ViT 模型
model = ViT().to(device)

# 定义损失函数和优化器
criterion = nn.CrossEntropyLoss()
learning_rate = 3e-4
optimizer = optim.Adam(model.parameters(), lr=learning_rate)

训练、验证、预测函数

from tqdm import tqdm
# 训练函数

def train(model, dataloader, criterion, optimizer, device):
    model.train()
    running_loss = 0.0
    correct = 0
    total = 0
    
    for inputs, labels in tqdm(dataloader):
        inputs, labels = inputs.to(device), labels.to(device)
        
        optimizer.zero_grad()
        outputs = model(inputs)
        loss = criterion(outputs, labels)
        loss.backward()
        optimizer.step()
        
        running_loss += loss.item() * inputs.size(0)
        _, predicted = torch.max(outputs, 1)
        total += labels.size(0)
        correct += (predicted == labels).sum().item()
    
    epoch_loss = running_loss / len(dataloader.dataset)
    epoch_acc = correct / total
    
    return epoch_loss, epoch_acc

# 验证函数
def validate(model, dataloader, criterion, device):
    model.eval()
    running_loss = 0.0
    correct = 0
    total = 0
    
    with torch.no_grad():
        for inputs, labels in tqdm(dataloader):
            inputs, labels = inputs.to(device), labels.to(device)
            
            outputs = model(inputs)
            loss = criterion(outputs, labels)
            
            running_loss += loss.item() * inputs.size(0)
            _, predicted = torch.max(outputs, 1)
            total += labels.size(0)
            correct += (predicted == labels).sum().item()
    
    epoch_loss = running_loss / len(dataloader.dataset)
    epoch_acc = correct / total
    
    return epoch_loss, epoch_acc

# 预测函数
def predict(model, dataloader, device):
    model.eval()
    predictions = []
    
    with torch.no_grad():
        for inputs in tqdm(dataloader):
            inputs = inputs.to(device)
            outputs = model(inputs)
            _, predicted = torch.max(outputs, 1)
            predictions.extend(predicted.cpu().numpy())
    
    return predictions

开始训练

# 训练和验证模型
num_epochs = 10
best_acc = 0

for epoch in range(num_epochs):
    print(f"Epoch {epoch+1}/{num_epochs}")
    print("-" * 10)
    
    train_loss, train_acc = train(model, train_loader, criterion, optimizer, device)
    val_loss, val_acc = validate(model, val_loader, criterion, device)
    
    print(f"Train Loss: {train_loss:.4f} Acc: {train_acc:.4f}")
    print(f"Val Loss: {val_loss:.4f} Acc: {val_acc:.4f}")
    
    if val_acc > best_acc:
        best_acc = val_acc
        torch.save(model.state_dict(), 'best_vit_model.pth')
        print("Model saved!")     

print("Training complete!")
# 加载最佳模型进行预测
model.load_state_dict(torch.load('best_vit_model.pth'))

# 创建一个测试数据加载器
test_dir = os.path.join(data_dir, 'test')
test_dataset = datasets.ImageFolder(test_dir, transform=transform_val)
test_loader = DataLoader(test_dataset, batch_size=64, shuffle=False, num_workers=4)

# 进行预测
predictions = predict(model, test_loader, device)

# 打印一些预测结果
print(predictions[:10])

现在手头只有一台四年前的笔记本电脑,训练一个epoch需要一个半小时左右,因为是学习所以只训练一次,学习流程即可。
在这里插入图片描述

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值