Post

PyTorch Data Loading

整理 PyTorch 的数据加载与数据增强,包括 Dataset、DataLoader、变长样本的整理以及训练与验证时的图像变换

PyTorch Data Loading
深度学习系列:第 4 篇,共 22 篇

用 PyTorch 从搭建模型、训练、调试到部署的一组笔记。按下面的顺序阅读,后面的文章会直接引用前面讲过的内容

模型结构
  1. PyTorch CNN
  2. PyTorch Transformer
  3. Vision Transformer
训练
  1. PyTorch Data Loading
  2. PyTorch Training
  3. Optimizers and Learning Rate Schedules
  4. PyTorch Checkpoints and Reproducibility
  5. Normalization and Regularization
  6. PyTorch Debugging
  7. PyTorch Common Mistakes
  8. PyTorch Memory and Speed
  9. Evaluation Metrics
  10. TensorBoard
  11. Experiment Tracking
  12. PyTorch Distributed Training
任务
  1. PyTorch Fine-Tuning
  2. Object Detection
  3. Image Segmentation
  4. Keypoint Detection
  5. Generative Models
部署
  1. PyTorch Deployment
  2. Model Compression

训练的第一步是把数据组织成一个个批次送进模型。这里整理数据集、数据加载器和数据增强的写法,训练循环本身见PyTorch Training

数据加载

Dataset负责取出单个样本,DataLoader负责把样本打乱、组成批次、用多个进程并行加载

Dataset

自定义数据集需要实现__len__和__getitem__两个方法。下面用 scikit-learn 自带的手写数字数据集作为例子,PyTorch Training中的训练也使用它

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
import torch
from sklearn.datasets import load_digits
from torch.utils.data import DataLoader, Dataset, Subset

class DigitsDataset(Dataset):
    """8x8的手写数字灰度图,共1797张、10个类别"""

    def __init__(self, transform=None):
        digits = load_digits()
        self.images = torch.from_numpy(digits.images).float().unsqueeze(1) / 16  # (1797, 1, 8, 8),取值0到1
        self.labels = torch.from_numpy(digits.target).long()                     # (1797,)
        self.transform = transform

    def __len__(self):
        return len(self.labels)

    def __getitem__(self, index):
        image, label = self.images[index], self.labels[index]
        if self.transform is not None:
            image = self.transform(image)
        return image, label

dataset = DigitsDataset()
print(len(dataset))  # 1797
image, label = dataset[0]
print(image.shape, image.dtype, label)  # torch.Size([1, 8, 8]) torch.float32 tensor(0)

# 划分训练集和验证集:打乱下标后切开,固定随机种子使每次划分相同
indices = torch.randperm(len(dataset), generator=torch.Generator().manual_seed(0)).tolist()
train_set = Subset(DigitsDataset(), indices[:1437])  # 需要数据增强时在这里传入训练用的transform
val_set = Subset(DigitsDataset(), indices[1437:])
print(len(train_set), len(val_set))  # 1437 360

train_loader = DataLoader(train_set, batch_size=64, shuffle=True, drop_last=True)
val_loader = DataLoader(val_set, batch_size=256)
print(len(train_loader), len(val_loader))  # 22 2

images, labels = next(iter(train_loader))
print(images.shape, labels.shape)  # torch.Size([64, 1, 8, 8]) torch.Size([64])
print([len(labels) for _, labels in val_loader])  # [256, 104]
  • 划分时固定随机种子,否则每次运行验证集都不同,中断后恢复训练时还会把之前的训练样本混进验证集
  • len(loader)是批次数而不是样本数:训练集 1437 个样本、批大小 64、丢弃最后不满的一批,得到 22 个批次
  • 数据量很大时不要在__init__中把全部数据读入内存,只保存文件路径,在__getitem__中读取

random_split划分出的子集共用同一个底层数据集。 修改其中一个子集的transform,另一个也会跟着变,验证集就被加上了训练用的数据增强。所以上面为训练集和验证集各创建了一个数据集对象,再用Subset按下标取出各自的部分

1
2
3
4
5
6
7
8
9
10
import torch
from torch.utils.data import random_split

dataset = DigitsDataset()
train_set, val_set = random_split(dataset, [0.8, 0.2], generator=torch.Generator().manual_seed(0))
print(len(train_set), len(val_set))          # 1438 359
print(train_set.dataset is val_set.dataset)  # True,两个子集指向同一个数据集

train_set.dataset.transform = lambda image: image * 0  # 本意是只给训练集加变换
print(val_set[0][0].max())                   # tensor(0.),验证集也被改了

DataLoader

1
2
DataLoader(dataset, batch_size=1, shuffle=False, num_workers=0, collate_fn=None,
           pin_memory=False, drop_last=False, persistent_workers=False)
参数默认值作用
batch_size1每个批次的样本数
shuffleFalse每一轮开始时重新打乱顺序。训练集设为True,验证集不需要
num_workers0加载数据的工作进程数,0 表示在主进程中加载。读取文件或数据增强较慢时,设为不超过 CPU 核数的正数
pin_memoryFalse把取出的批次放入锁页内存,加快向 GPU 的拷贝,使用 GPU 训练时设为True,并配合.to(device, non_blocking=True)
drop_lastFalse丢弃最后一个不满的批次。训练集可以设为True,避免很小的批次使 BatchNorm 的统计量不稳定;验证集必须为False
persistent_workersFalse每一轮结束后保留工作进程,省去每轮重新创建进程的时间
collate_fnNone把多个样本合并成一个批次的函数,默认把各个样本堆叠成一个张量

num_workers大于 0 时,Windows 和 macOS 上创建DataLoader并迭代的代码必须放在if __name__ == "__main__":之下,否则工作进程启动时会重复执行整个脚本

变长样本

默认的合并方式要求每个样本的形状相同。句子、音频这类长度不一的样本需要自己写collate_fn,把一个批次内的样本补齐到相同长度

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
import torch
from torch.nn.utils.rnn import pad_sequence
from torch.utils.data import DataLoader

# 4个长度不同的样本,每个样本是(序列,标签)
samples = [(torch.tensor([5, 8, 2]), 0), (torch.tensor([7, 3]), 1),
           (torch.tensor([4, 9, 6, 1, 2]), 0), (torch.tensor([3]), 1)]

def collate(batch):
    sequences, labels = zip(*batch)
    lengths = torch.tensor([len(s) for s in sequences])
    tokens = pad_sequence(sequences, batch_first=True, padding_value=0)  # 按批次内最长的序列补齐
    return tokens, lengths, torch.tensor(labels)

loader = DataLoader(samples, batch_size=2, collate_fn=collate)
for tokens, lengths, labels in loader:
    print(tokens, lengths, labels)

try:
    next(iter(DataLoader(samples, batch_size=2)))  # 不指定collate_fn
except RuntimeError as error:
    print(error)
1
2
3
4
5
tensor([[5, 8, 2],
        [7, 3, 0]]) tensor([3, 2]) tensor([0, 1])
tensor([[4, 9, 6, 1, 2],
        [3, 0, 0, 0, 0]]) tensor([5, 1]) tensor([0, 1])
stack expects each tensor to be equal size, but got [3] at entry 0 and [2] at entry 1

每个批次只补齐到该批次内最长的长度,不同批次的长度可以不同。补上的位置没有实际含义,需要把长度或掩码一起返回,模型中用填充掩码屏蔽这些位置,损失中用ignore_index忽略它们

数据增强

数据增强在每次取样本时对图像做随机变换,使模型每一轮看到的都是略有不同的图像,以减轻过拟合。torchvision.transforms.v2是目前推荐使用的接口

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
import torch
from PIL import Image
from sklearn.datasets import load_sample_image
from torchvision.transforms import v2

mean, std = [0.485, 0.456, 0.406], [0.229, 0.224, 0.225]  # ImageNet的统计量

train_transform = v2.Compose([
    v2.ToImage(),                                   # numpy数组或PIL图像 -> (C, H, W)的uint8张量
    v2.RandomResizedCrop(224, scale=(0.5, 1.0)),    # 随机裁剪一块再缩放到224
    v2.RandomHorizontalFlip(p=0.5),
    v2.ColorJitter(brightness=0.2, contrast=0.2, saturation=0.2),
    v2.ToDtype(torch.float32, scale=True),          # uint8的0到255 -> float32的0到1
    v2.Normalize(mean, std),
])

eval_transform = v2.Compose([
    v2.ToImage(),
    v2.Resize(256),                                 # 短边缩放到256
    v2.CenterCrop(224),
    v2.ToDtype(torch.float32, scale=True),
    v2.Normalize(mean, std),                        # 与训练时相同
])

image = Image.fromarray(load_sample_image("china.jpg"))
print(image.size)                                   # (640, 427),scikit-learn自带的示例图片

torch.manual_seed(0)
a, b = train_transform(image), train_transform(image)
print(a.shape, a.dtype)                             # torch.Size([3, 224, 224]) torch.float32
print(torch.equal(a, b))                            # False,训练变换每次的结果都不同
print(torch.equal(eval_transform(image), eval_transform(image)))  # True,验证变换的结果固定

# scale=True不能省略
x = v2.ToImage()(image)
print(v2.ToDtype(torch.float32, scale=True)(x).max())  # tensor(1.)
print(v2.ToDtype(torch.float32)(x).max())              # tensor(255.)

原图、4 次训练变换的结果和验证变换的结果

变换作用
v2.ToImage()把 PIL 图像或 numpy 数组转为 (C, H, W) 的张量,不改变数值
v2.RandomResizedCrop(size)随机裁剪一块区域并缩放到指定大小
v2.RandomHorizontalFlip(p)以概率p水平翻转
v2.ColorJitter(...)随机改变亮度、对比度、饱和度
v2.Resize(size)、v2.CenterCrop(size)缩放、中心裁剪,没有随机性
v2.ToDtype(torch.float32, scale=True)转为浮点数并缩放到 0 到 1
v2.Normalize(mean, std)每个通道减去均值、除以标准差
  • 随机的变换只用于训练。验证和推理要使用固定的变换,否则同一个模型每次评估的结果都不一样
  • v2.ToDtype的scale=True不能省略,否则只转换类型,数值仍是 0 到 255,之后的Normalize就全错了
  • 旧代码中的ToTensor()相当于ToImage()加ToDtype(torch.float32, scale=True),在 v2 中已不推荐使用
  • 翻转并不总是合适:数字、文字翻转后含义会变,检测和分割任务中标注框、掩码要随图像一起变换

验证和推理时的归一化必须与训练时完全相同。 均值和标准差只用训练集统计,然后原样用于验证集、测试集和部署后的推理。使用预训练模型时,要用该模型训练时的统计量,而不是自己数据集的统计量。归一化不一致不会报错,只会让准确率大幅下降,下文的训练中有统计的方法和实际的对比

This post is licensed under CC BY 4.0 by the author.