1. 从一行代码到完整数据流水线
先问一个直击灵魂的问题:你训练模型的时候,给网络的输入到底长什么样?
如果只是把图片读进来、直接扔给model(img),那你会踩到一连串的坑——img是 PIL 还是 numpy?值域是 0-255 还是 0-1?通道是 HWC 还是 CHW?要不要做归一化?要不要随机裁剪做数据增强?
这些问题,PyTorch 的torchvision.transforms模块就是为了统一解决它们而存在的。
简单说,transforms就是一组图像预处理和数据增强的工具函数,你只需要把它们组合成一条流水线,数据在进入模型之前就会被自动整理成张量、归一化、增强后的标准形态。它能解决的问题包括:把 PIL 图片转成 Tensor、调整尺寸、归一化到指定均值方差、随机裁剪翻转做数据增强,以及把一堆操作串联成一条完整的预处理管道。
这篇文章适合正在用 PyTorch 训练图像模型、但对数据预处理还停留在"网上抄一段 Compose 就开跑"的朋友。我会把transforms里常用的、容易踩坑的、文档里含糊的部分全部拆开讲清楚,确保你看完能自己写、自己改、自己调,而不是只会复制粘贴。
2. 设计思路拆解:为什么需要 transforms,以及它是怎么工作的
2.1transforms解决的问题
在深度学习训练里,图像数据进入网络之前通常要经历几个阶段:读取、解码、格式转换、尺寸调整、值域归一化、数据增强、批量化。
如果不借助transforms,你得自己写一堆.resize()、np.transpose()、astype(np.float32) / 255这样的代码,而且每换一个数据集就要重写一遍。更麻烦的是,训练和推理时的预处理逻辑如果写得不一样,模型效果就会打折扣。
transforms把这些问题抽象成了一组可复用的、可组合的组件。它只做一件事:输入一张图像(PIL、numpy 或 Tensor),输出一张处理后的图像,至于它是用在训练、验证还是测试,由你自己决定怎么组合。
这个设计的核心思想是函数式组合——每个 transform 都是一个纯函数,有输入有输出,不修改原图,可以链式调用,像搭积木一样拼出任意复杂的预处理流程。
拿生活打个比方:transforms就像一条送餐流水线。原始食材(图片)进来,先切块(Resize),再腌制(Normalize),再雕花(RandomCrop),最后摆盘(ToTensor)。每个环节只管自己那一件事,但串联起来就成了一套标准工序。你不需要每次都重新设计工序,只需要把环节顺序排好。
2.2torchvision.transforms核心组成
整个模块可以按功能分成几个大类:
| 类别 | 代表操作 | 作用 |
|---|---|---|
| 结构组合 | Compose | 将多个 transform 按顺序组合 |
| 类型转换 | ToTensor, ToPILImage | 在 PIL/numpy/Tensor 之间互转 |
| 尺寸调整 | Resize, CenterCrop, Pad | 统一图片尺寸 |
| 归一化 | Normalize | 按通道均值/方差缩放 |
| 数据增强 | RandomCrop, RandomHorizontalFlip, RandomRotation, ColorJitter | 随机变换增加样本多样性 |
| 高级增强 | RandomResizedCrop, AutoAugment, TrivialAugment | 更复杂或自动化的增强策略 |
在 PyTorch 最新版本里,transforms还多了一套基于torch.Tensor的函数式 API(torchvision.transforms.functional),以及可以接受 Tensor 输入的新版本。老版本里许多 transform 只能吃 PIL 图片,新版本已经可以同时处理 Tensor 了,后面我会详细讲这个差异。
2.3 为什么要注意 transform 的组合顺序
组合顺序不是随意的,它直接决定数据处理结果的正确性。
第一个原则:ToTensor应该在Normalize之前,因为Normalize需要在张量值域(0-1)上计算,而不是在 0-255 的整数上。
第二个原则:几何变换(如 Resize、RandomCrop)尽可能放在值域变换之前,因为你希望先改变空间结构,再对像素值做归一化,这样计算量也小一些。
第三个原则:数据增强只应用在训练集,验证集和测试集只做尺寸调整和归一化,不要用随机增强,否则影响评估的稳定性。
举个常见的错误组合:
transforms.Compose([ transforms.Normalize(...), transforms.ToTensor(), transforms.Resize((224, 224)), ])这个顺序的问题在于:Normalize要求输入是张量且值域是 0-1,但此时图片还是 PIL 格式,直接报错;Resize放最后又白费了一次计算。正确的做法是ToTensor先转张量,Normalize再归一化,Resize在转换前完成。
2.4 为什么选择 Compose 而不是自由散布处理
有人会问:我能不能不用 Compose,直接在__getitem__里一行一行调用 transform?
能,但 Compoose 有它独特的价值:
- 可读性好,一眼看出整个预处理流程
- 便于复用,你可以定义一套
train_transform、val_transform、test_transform - 便于调试,因为每个 transform 都包装成了统一接口
- 与 PyTorch 的
Dataset、DataLoader天然配合,不需要额外逻辑
我个人经验的体会是:写transforms.Compose的时候,顺手给每个组合取个变量名,比如train_transform,后面改实验配置会非常方便。调试的时候如果想看中间结果,也可以临时去掉某个 transform 对比效果。
3. 核心细节解析与实操要点
3.1ToTensor的实际行为
ToTensor是我们最常用的一个 transform,它把 PIL 图片或 numpy 数组转成torch.FloatTensor,并做了两件关键的事:
- 将形状从
(H, W, C)变成(C, H, W)——也就是把通道维度挪到最前,这是 PyTorch 卷积网络默认要求的格式。 - 将像素值从 [0, 255] 缩放到 [0.0, 1.0]——直接除以 255,并转成浮点数。
需要特别注意的点:如果输入是 numpy 数组且 dtype 是uint8,ToTensor会自动除以 255;但如果 numpy 数组的 dtype 是float32,它就不会帮你缩放,只会把轴调换顺序和转成 Tensor。这是一个非常隐蔽的坑。
调试建议:写一个快速测试验证你的输入是不是预期格式:
from PIL import Image import torchvision.transforms as T img = Image.open("test.jpg") print(type(img), img.size) # PIL.Image.Image, (w, h) ten = T.ToTensor()(img) print(ten.shape, ten.dtype, ten.min(), ten.max()) # 期望输出: torch.Size([3, h, w]) torch.float32 tensor(0.) tensor(1.)如果 min 是 0,max 是 255,说明你的输入不是 uint8,检查一下数据来源。
3.2Normalize的均值方差为什么是标配
Normalize(mean, std)做的事情非常简单:对每个通道,将像素值减去 mean,再除以 std。公式如下:
output = (input - mean) / std它的目的不是说"让模型更准",而是加速收敛、稳定训练。因为输入数据经过去均值后,梯度更新方向不会被某个均值很大的通道带偏。
但这里有个经典问题:为什么 ImageNet 的 mean=[0.485, 0.456, 0.406],std=[0.229, 0.224, 0.225] 被全民使用?
因为这些值就是 ImageNet 数据集所有图片各通道的统计均值/标准差,是在那个数据集上算出来的。如果你用的是一个完全不同分布的数据集(比如医学影像、红外图),直接套 ImageNet 的 mean/std 不一定最优,但它通常也不会让训练崩掉,因为归一化本质上是一个线性变换。
我的建议是:如果是标准自然图像任务,直接沿用 ImageNet 的 mean/std 没问题;如果你的数据分布差异极大,可以计算自己数据集的 mean/std,然后替换进去。计算方式可以参考下面这段代码:
import numpy as np from tqdm import tqdm means = [0., 0., 0.] stds = [0., 0., 0.] n_samples = 0 for img, _ in your_dataset: # img 是 Tensor 形式 # img shape: (C, H, W) img = img.float() / 255. # 如果没做 ToTensor means[0] += img[0].mean().item() means[1] += img[1].mean().item() means[2] += img[2].mean().item() n_samples += 1 mean = [m / n_samples for m in means] # 再遍历一次算 std for img, _ in tqdm(your_dataset): img = img.float() / 255. var = [((img[c] - mean[c]) ** 2).mean().item() for c in range(3)] for c in range(3): stds[c] += var[c] std = [math.sqrt(s / n_samples) for s in stds]不过说实话,在你的任务里,每当不确定应该用什么 mean/std 时,先直接套 ImageNet,等模型有 basline 后想提升再考虑统计自己的数据集,性价比更高。
3.3Resize与CenterCrop的组合细节
目标检测、图像分类、分割里最常见的操作之一,是把不同尺寸的图片统一成固定的输入尺寸。
其中有两种主流策略:
- 先
Resize到固定尺寸(如 256x256),再用CenterCrop裁成 224x224。 - 直接用
Resize((224, 224))拉伸/压缩。
第 1 种策略的好处是:保证图片大部分内容还在,不会因为过度拉伸而变形。第 2 种实现简单、计算量小,但图像比例不对时会产生明显的几何形变,影响模型学到的特征。
如果你的任务不要求保持原始比例,直接Resize((224, 224))也是可以的。但如果你的数据集长宽比差异大,我更建议使用Resize(size)然后组合CenterCrop。
举个常见的检测场景配置:
train_transform = T.Compose([ T.Resize((256, 256)), T.RandomCrop(224), T.RandomHorizontalFlip(), T.ToTensor(), T.Normalize(mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225]), ]) val_transform = T.Compose([ T.Resize((256, 256)), T.CenterCrop(224), T.ToTensor(), T.Normalize(mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225]), ])这里RandomCrop和CenterCrop都裁 224,但训练时随机裁剪的位置不同,能起到一定的数据增强作用。
另外注意,Resize默认插值方式是Image.BILINEAR,如果你在处理分割任务的掩膜(label),应该改成Image.NEAREST,因为最近邻不会产生新的值,避免标签类别被插值污染。
3.4 数据增强的选择原则
数据增强的目的不是"让代码看起来更复杂",而是在有限数据集上人为制造更多变化,让模型学到更鲁棒的特征。
常用且效果稳定的增强包括:
RandomHorizontalFlip:随机水平翻转。适合自然图像,但如果有方向性(比如道路标志、文字识别)就要慎用。RandomResizedCrop:先随机裁剪,再 resize 到固定尺寸,相当于同时做裁剪、缩放、尺度变换。分类任务里是最经典的开源标配。ColorJitter:随机调整亮度、对比度、饱和度、色相。对颜色敏感的任务(如医学病理染色图)要小心。RandomRotation:随机旋转一定角度。适合无明显方向性的图像。RandomAffine、RandomPerspective:做仿射和透视变换,注意背景填充值需要合理设置,否则会产生大量不自然的黑色区域。
在实际操作中,数据增强参数的设定没有绝对标准,需要靠实验来判断。比如ColorJitter(brightness=0.2, contrast=0.2, saturation=0.2, hue=0.1)是常见的中等强度;如果数据充足可以增强弱一点,如果数据少可以增强强一点。
这里有一个非常重要但容易被忽略的原则:数据增强应该与测试时的预处理保持一致。训练时把图片拉伸/裁剪到 224x224,测试时也需要做同样的尺寸调整,否则输入分布不一致,性能会受影响。
3.5Compose的实战组合方式
最基础的组合:
transform = T.Compose([ T.Resize(256), T.CenterCrop(224), T.ToTensor(), T.Normalize([0.485, 0.456, 0.406], [0.229, 0.224, 0.225]), ])带数据增强的组合:
train_transform = T.Compose([ T.RandomResizedCrop(224, scale=(0.08, 1.0), ratio=(3/4, 4/3)), T.RandomHorizontalFlip(), T.ColorJitter(brightness=0.2, contrast=0.2, saturation=0.2, hue=0.1), T.ToTensor(), T.Normalize(mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225]), ])如果数据加载速度成为瓶颈,还可以考虑只在 CPU 上做必要的 transform,把更多工作放到 GPU 上;或者开启DataLoader的num_workers、pin_memory来提高吞吐。
3.6 为什么要有ToPILImage和functionalAPI
ToPILImage通常用在调试时——你想把 Tensor 转回 PIL 图片,用Image.show()看看预处理后的效果。这在排查问题时非常实用。
而torchvision.transforms.functional里提供了一组不经过类包装、直接调用的函数,比如resize(img, size)、normalize(tensor, mean, std)。如果你需要在自定义 Dataset 里做更精细的控制,可以直接调用这些函数,而不是硬套在 Compose 里。
举个自定义 Dataset 的简单示例:
from torchvision.transforms import functional as F class MyDataset(Dataset): def __getitem__(self, idx): img = self.images[idx] label = self.labels[idx] img = F.resize(img, (224, 224)) img = F.to_tensor(img) img = F.normalize(img, mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225]) return img, label这样做的优点是当你有特殊需求时更灵活,缺点是少了统一的封装,可读性差一些。我的建议是:常规任务用 Compose,特殊逻辑才用 functional。
4. 实操过程与核心环节实现
4.1 数据集定义与 transform 的衔接方式
transforms的真正发力点其实是和torch.utils.data.Dataset、DataLoader配合使用。
一个常见流程是:
- 定义
Dataset类,在__init__里接收transform参数 - 在
__getitem__里读取图片后,调用self.transform(img)完成预处理 - 用
DataLoader加载,batch_size和num_workers按需配置
代码长这样:
import torch from torch.utils.data import Dataset, DataLoader from PIL import Image import os import torchvision.transforms as T class ImageFolderDataset(Dataset): def __init__(self, root_dir, transform=None): self.root_dir = root_dir self.transform = transform self.image_paths = [] self.labels = [] # 遍历 root_dir 下子目录,目录名就是类别 self.classes = sorted(os.listdir(root_dir)) for label, cls in enumerate(self.classes): cls_path = os.path.join(root_dir, cls) for fname in os.listdir(cls_path): if fname.endswith(('.jpg', '.png', '.jpeg')): self.image_paths.append(os.path.join(cls_path, fname)) self.labels.append(label) def __len__(self): return len(self.image_paths) def __getitem__(self, idx): img_path = self.image_paths[idx] img = Image.open(img_path).convert('RGB') label = self.labels[idx] if self.transform: img = self.transform(img) return img, label train_transform = T.Compose([ T.Resize((256, 256)), T.RandomCrop(224), T.RandomHorizontalFlip(), T.ToTensor(), T.Normalize(mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225]), ]) val_transform = T.Compose([ T.Resize((256, 256)), T.CenterCrop(224), T.ToTensor(), T.Normalize(mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225]), ]) train_ds = ImageFolderDataset("path/to/train", transform=train_transform) val_ds = ImageFolderDataset("path/to/val", transform=val_transform) train_loader = DataLoader(train_ds, batch_size=64, shuffle=True, num_workers=4, pin_memory=True) val_loader = DataLoader(val_ds, batch_size=64, shuffle=False, num_workers=4, pin_memory=True)有一个细节:Image.open(img_path).convert('RGB')这一步很关键。因为有些图片是灰度图或者 RGBA,如果你的网络 expecting 3 通道输入,不convert('RGB')就会在ToTensor后得到 1 通道或 4 通道,导致维度不匹配报警。这是新手最容易踩的坑之一。
4.2 训练与验证时的 transform 选择
训练和验证的 transform 必须区分开,这是数据增强的基本原则。训练集需要更强的增强来防止过拟合;验证集只需要保持和测试一致,确保评估指标真实可靠。
具体到代码层面,可以通过定义两个变量来实现,也可以写成一个函数根据train=True/False返回不同的 Compose:
def get_transform(train=True): if train: return T.Compose([ T.RandomResizedCrop(224, scale=(0.08, 1.0)), T.RandomHorizontalFlip(), T.ColorJitter(brightness=0.2, contrast=0.2, saturation=0.2, hue=0.1), T.ToTensor(), T.Normalize([0.485, 0.456, 0.406], [0.229, 0.224, 0.225]), ]) else: return T.Compose([ T.Resize(256), T.CenterCrop(224), T.ToTensor(), T.Normalize([0.485, 0.456, 0.406], [0.229, 0.224, 0.225]), ])训练时你计划做多少 epoch,其实和数据增强强度是联动的。数据增强越强,模型能看到的变化越多,训练的 epoch 就可以适当增加,否则靠少量增强很容易在几个 epoch 内过拟合。
4.3 调试中间结果的技巧
很多次我训练到一半发现效果不好,第一反应是模型结构或者超参数,但最后排查下来问题往往出在数据预处理上。
所以我强烈建议你在写训练代码之前,先跑一段可视化调试代码,把 transform 后的图片和标签打印出来看看:
import matplotlib.pyplot as plt import numpy as np transform = get_transform(train=True) sample_idx = 0 img, label = train_ds[sample_idx] # img 是 Tensor print("Shape:", img.shape, "Label:", label) # 把 Tensor 转回可显示的 numpy 图 img_np = img.numpy().transpose(1, 2, 0) # (C,H,W) -> (H,W,C) # 反归一化 mean = np.array([0.485, 0.456, 0.406]) std = np.array([0.229, 0.224, 0.225]) img_np = img_np * std + mean img_np = np.clip(img_np, 0, 1) plt.imshow(img_np) plt.axis('off') plt.show()查看多个样本,确认是否出现:图片被过度裁剪导致核心物体丢失、色相变换过度导致颜色失真、标签错位等问题。这一步只要做过一次,就能避免后续大量盲目的训练调试。
顺便说一句,如果一条 transform 组合跑出来的图片看着就很别扭,那基本不用指望模型能从中学到有用的东西。看到不正常的结果一定要回到 transform 本身找问题,千万不要硬训练下去。
4.4 transform 链上的性能优化
transforms大部分操作都是 CPU 计算,在数据量大的时候会拖慢整体训练速度。常见的优化手段有:
- 提高
DataLoader的num_workers:多个子进程并行加载数据,让预处理不再成为瓶颈。 - 开启
pin_memory=True:把数据固定在页锁定内存,GPU 拷贝速度会提升,尤其在 GPU 训练时效果明显。 - 适当增大
batch_size:可以减少数据集加载的总次数,但我建议先看一下 GPU 显存是否够用。 - 避免重复计算:如果某些图片不需要增强,可以在 Dataset 里缓存处理后的结果(内存够用的情况下)。
- 在 GPU 上做部分 transform:新版 PyTorch 允许部分 transform 输入 Tensor,可以在 GPU 上执行一部分操作,但这要求你的训练代码更精细,使用场景较少。
如果你的数据加载 CPU 占用很高,可以把部分图片预处理好存成 Tensor 或者.npy文件,训练时直接读取。不过这会牺牲灵活性,只能作为特定场景的优化方案。
4.5 完整训练脚本中 transform 的落地位置
最后把上面这些拼成一个最小可运行的训练框架,方便你理解 transform 在整个流程中的位置:
import torch import torch.nn as nn import torch.optim as optim import torchvision.transforms as T from torch.utils.data import DataLoader # 1. 定义 transform train_tf = T.Compose([ T.RandomResizedCrop(224), T.RandomHorizontalFlip(), T.ColorJitter(0.2, 0.2, 0.2, 0.1), T.ToTensor(), T.Normalize([0.485, 0.456, 0.406], [0.229, 0.224, 0.225]), ]) val_tf = T.Compose([ T.Resize(256), T.CenterCrop(224), T.ToTensor(), T.Normalize([0.485, 0.456, 0.406], [0.229, 0.224, 0.225]), ]) # 2. 数据集与 DataLoader train_ds = ImageFolderDataset("data/train", transform=train_tf) val_ds = ImageFolderDataset("data/val", transform=val_tf) train_loader = DataLoader(train_ds, batch_size=64, shuffle=True, num_workers=4, pin_memory=True) val_loader = DataLoader(val_ds, batch_size=64, shuffle=False, num_workers=4, pin_memory=True) # 3. 模型、损失、优化器 model = torchvision.models.resnet18(pretrained=True) model.fc = nn.Linear(model.fc.in_features, 10) criterion = nn.CrossEntropyLoss() optimizer = optim.Adam(model.parameters(), lr=1e-3) # 4. 训练循环 for epoch in range(30): model.train() for imgs, labels in train_loader: imgs, labels = imgs.cuda(), labels.cuda() outputs = model(imgs) loss = criterion(outputs, labels) optimizer.zero_grad() loss.backward() optimizer.step() # 验证 model.eval() correct = 0 total = 0 with torch.no_grad(): for imgs, labels in val_loader: imgs, labels = imgs.cuda(), labels.cuda() outputs = model(imgs) _, predicted = torch.max(outputs, 1) total += labels.size(0) correct += (predicted == labels).sum().item() acc = 100.0 * correct / total print(f"Epoch {epoch}: val acc {acc:.2f}%")这个框架里,transform 只负责数据的形态变换,模型本身完全不管输入来自哪里。你只需要保证训练和验证阶段的数据形态一致,模型就能正常工作。
5. 常见问题与排查技巧实录
5.1ToTensor后图片变黑白或特别亮/暗
这种问题一般不是 transform 本身错了,而是你在显示的时候没有反归一化。Normalize之后像素值不再是 0-1,可能出现了负值,直接imshow就会显示异常。
解决办法就是上面提到的:显示前先做img_np = img_np * std + mean,再clip(0, 1)。
如果你看到的是黑白噪点,还有一种可能是ToTensor之前通道顺序错误,比如你用了numpy数组但没有正确从 HWC 换成 CHW。检查你的原始数组形状,必要时用np.transpose(img, (2, 0, 1))手动调整。
5.2 尺寸不对:expected input报错、dimension mismatch
这是最常见的运行时报错之一:
RuntimeError: Expected 4-dimensional input for 4-dimensional weight [64, 3, 7, 7], but got 3-dimensional input of size [3, 224, 224]原因是你把单张图片直接喂给了网络,但网络期望的是一个批次(batch)。在DataLoader里没问题,但如果你在单图调试时不加unsqueeze(0),就会报这个错。
单图推理时要这样写:
img_tensor = transform(img).unsqueeze(0) # (1, C, H, W) output = model(img_tensor)而如果报错说的是通道数不一致,比如Expected 3 channels, but got 1 channels,多半是图片本身是灰度图,没有convert('RGB')。
5.3 归一化参数的选择是否影响精度
我实测下来,直接影响不大,但确实存在少量影响。如果你数据分布和 ImageNet 差异很大,用自定义 mean/std 理论上更好。
但要注意抽样统计的合理性:不要只用几张图算均值,最好能覆盖你的训练集和验证集的整体分布。如果你的数据量很大,可以在训练前跑一次全量统计,记录下 mean 和 std,再固定用这两个值。
一个容易犯的错误:使用ToTensor后像素值已经是 0-1,统计 mean 时不要再除以 255;用 PIL 原始数值(0-255)统计时则要除以 255,两者必须一致,否则 mean 和 std 完全错位。
5.4 数据增强对过拟合的作用有多大
数据增强是缓解过拟合最直接有效的手段之一,但也不是越多越好。增强太强,模型可能学不到真实数据的关键特征;增强太弱,过拟合依旧存在。
我的经验是:先用中等的增强强度跑一遍 baseline,然后观察训练集和验证集准确率的差距。如果训练集准确率远高于验证集,说明过拟合严重,这时候优先增强数据增强强度,比如增加RandomResizedCrop的尺度范围、加入RandomRotation、增加ColorJitter的幅度;如果训练集准确率本身就很低,那问题可能出在模型容量或训练策略上,而不是增强。
另外要特别注意RandomResizedCrop的scale参数。默认是(0.08, 1.0),意思是裁剪区域最少只占原图面积的 8%。如果你发现模型总是关注局部特征,可以把这个值调大,比如(0.5, 1.0),保证更多全局信息可见。
5.5 新版 API 与旧版兼容问题
PyTorch 版本升级之后,torchvision.transforms的一些行为发生了变化。最典型的是:
- 旧版里
RandomResizedCrop主要要求输入 PIL 图片;新版也支持 Tensor 输入 - 旧版
ToTensor只能转PIL.Image或numpy.ndarray;新版在内部会把整数 0-255 正确缩放 - 有些 transform 加入了
interpolation、antialias参数,新版本Resize(..., antialias=True)可能明显改变插值效果
我的建议是:代码跑在哪个环境不重要,重要的是torchvision.__version__。可以在实验一开始就固定好版本,避免不同 worker 之间行为不一致。如果从网上看到一段 transform 代码不 work,先确认它是不是基于旧版本 API 写的。
5.6 一张图多次进DataLoader但结果不一样
这听起来像 bug,其实不是——如果 transform 里有随机的操作(RandomHorizontalFlip、RandomCrop),同一个索引拿到的图片可能每次都不一样。这是设计好的行为,目的是让模型每轮看到不同增强版本。
但如果你是做调试,需要可复现的结果,可以在训练时设置随机种子:
import random import numpy as np import torch def set_seed(seed=42): random.seed(seed) np.random.seed(seed) torch.manual_seed(seed) torch.cuda.manual_seed_all(seed) set_seed(42)这样固定下来后,DataLoader 的随机顺序和 transform 的随机增强都会变成一致的,方便复现实验。
6. 进阶玩法:扩展 transform 的常见方案
6.1 自定义 Transform 组件
当内置 transform 不能满足需求时,可以自己写一个 class。核心要求是:实现__call__方法,输入 PIL 图像/Tensor,输出同类型图像/Tensor。
比如写一个简单的自定义色彩抖动:
import torch import random from PIL import ImageEnhance class RandomGrayscaleCustom: def __init__(self, p=0.2): self.p = p def __call__(self, img): if random.random() < self.p: # 转灰度再转回 RGB if isinstance(img, torch.Tensor): # Tensor 转 PIL img = T.ToPILImage()(img) gray = img.convert("L").convert("RGB") return gray return img自定义 transform 需要注意类型一致性。如果你在 Compose 里把ToTensor放在前面,你的自定义 transform 可能需要处理 Tensor;放在后面则处理 PIL。
6.2 自动增强策略:AutoAugment、TrivialAugment
新版torchvision提供了T.AutoAugment、T.TrivialAugmentWide、T.RandAugment等自动增强策略。它们通过预定义的策略随机选择一组增强操作,可以有效提升小数据集上的泛化能力,而且几乎不需要调参。
简单用法:
train_transform = T.Compose([ T.Resize((256, 256)), T.RandomCrop(224), T.RandomHorizontalFlip(), T.AutoAugment(policy=T.AutoAugmentPolicy.IMAGENET), T.ToTensor(), T.Normalize([0.485, 0.456, 0.406], [0.229, 0.224, 0.225]), ])注意AutoAugment应当放在ToTensor之前,因为它操作的是 PIL 图片。这类策略最直观的收益是:省去手动摸索增强参数的精力,而且效果通常不会比手动调参差。
如果你的任务对颜色扰动敏感(比如分割、医学影像),谨慎使用自动增强,因为ColorJitter等操作可能破坏关键特征。
6.3 在 GPU 上做变换的探索
较新的torchvision支持部分 transform 直接输入 GPU Tensor,这意味着你可以少一点 CPU-GPU 间的数据复制。比如:
img_tensor = img_tensor.cuda() img_tensor = T.Normalize([0.485, 0.456, 0.406], [0.229, 0.224, 0.225])(img_tensor)不过实际中,从磁盘读图、解码、转 Tensor 这些操作仍然在 CPU 上,GPU 上只能做归一化、几何变换等。只有当你的数据集本身就缓存为 Tensor 时,这种优化才有明显收益。对常规流程,优先优化num_workers更实际。
6.4 适合应用在离线数据增强的方案
如果训练集特别大,你也可以在离线阶段先做一次增强,把图片保存到磁盘,再直接用增强后的图片喂给模型。这种做法的缺点是需要额外存储空间,优点是在线训练时数据处理几乎无成本。
推荐用torchvision.transforms的 functional 部分写离线脚本,比如每张原图生成 5 个增强版本:
import torchvision.transforms.functional as F def generate_augmentations(img_path, save_dir, n=5): img = Image.open(img_path).convert('RGB') base_name = os.path.splitext(os.path.basename(img_path))[0] for i in range(n): aug = img # 随机增强 aug = F.adjust_brightness(aug, brightness_factor=random.uniform(0.8, 1.2)) aug = F.adjust_contrast(aug, contrast_factor=random.uniform(0.8, 1.2)) aug = F.rotate(aug, angle=random.uniform(-15, 15)) aug = F.crop(aug, top=random.randint(0, 10), left=random.randint(0, 10), height=img.height - 20, width=img.width - 20) aug.save(os.path.join(save_dir, f"{base_name}_aug{i}.jpg"))这类方案特别适合数据集不大、但希望模型更强的场景。
7. 一些实际操作中的体会
把transforms用熟之后,你会发现模型训练前 80% 的报错都集中在这一环:图片通道不对、尺寸不一致、归一化参数错位、增强策略过度。调试这类问题,我的习惯是先可视化一两个 batch,确认数据形态完全符合预期,再进入模型训练。
还有一个很小的技巧:每次写Compose之前,先在脑子里把数据流从原始图片到最终 Tensor 完整走一遍,标注好每一步的类型(PIL / numpy / Tensor)和值域(0-255 / 0-1 / 任意实数),这基本能避免绝大多数低级错误。训练集和验证集的 transform 一定要分开定义,不要图省事共用一套。
如果你使用的是最新版本,最好看一眼torchvision.transforms的文档更新,比如antialias参数在Resize中的行为变化、RandomResizedCrop的默认插值方式调整,这些细节往往会影响你复现别人的实验结果。
按照这条路子走下来,从最基础的Compose([ToTensor(), Normalize()])到复杂的自定义增强、离线缓存,整个数据预处理环节都会变得可控、可调试、可复现。数据管好了,后面的模型训练才能真正跑得稳。