DL · 章节 4
深度学习 4 —— 卷积网络与图像处理(第二部分)
课程章节 · 中央理工里尔学院 · Pascal Yim · 2026 年
在第三章里,我们已经把卷积网络的骨架搭起来了:
Conv2d、MaxPool2d、Flatten、Linear,加上 ReLU 和交叉熵,就足以让一个简单的 CNN 在 MNIST 上跑出像样的结果。然而真实的图像识别任务并不止步于 28×28 的灰度数字。本章我们把视野拓宽:第一,学会从磁盘上的 PNG 文件夹直接读取真实数据集 (Fashion-MNIST、CIFAR-10);第二,学会让 GPU 不再"挨饿"——通过DataLoader的并行选项把数据流喂得更快;第三,引入两个让深层网络真正能训练起来的标准技术——批归一化 (Batch Normalization) 与 Dropout。这一章的代码片段以 PyTorch 写成,只用作示意;真正动手仍要在配套的 notebook 里完成。
1. 从磁盘读取图像:ImageFolder
1.1 类目录约定
在 PyTorch 的图像生态里,数据组织遵循一个非常简单的约定——按类目录 (one folder per class) 摆放文件:
dataset/
├── train/
│ ├── 0/
│ │ ├── img_001.png
│ │ ├── img_002.png
│ ├── 1/
│ │ ├── img_101.png
│ │ ├── ...
│ └── ...
└── test/
├── 0/
├── 1/
└── ...
每个子目录对应一个类,目录名即作为标签。文件可以是 .png、.jpg、.jpeg 等任意主流图像格式。这种约定的好处显而易见:它把"标签"信息直接编码进文件系统,任何工程师只要看一眼目录树就明白数据集结构,不必再额外维护一个 CSV 索引文件。
1.2 三层抽象:Dataset、DataLoader、Model
PyTorch 在数据流上严格区分三层抽象:
- Dataset:负责回答"第 个样本是什么?"——给出一对
(image_tensor, label); - DataLoader:负责回答"如何把样本组合成批量,并迭代地交给模型?"——做小批量、随机打乱、并行加载、内存固定;
- Model:负责把张量映射到 logits。
对图像而言,PyTorch 已经提供了一个开箱即用的 Dataset 实现——torchvision.datasets.ImageFolder,它会自动遍历子目录,把每张图与一个整数标签对应起来:
from torchvision import datasets, transforms from torch.utils.data import DataLoader train_dataset = datasets.ImageFolder( root="dataset/train", transform=transform, ) test_dataset = datasets.ImageFolder( root="dataset/test", transform=transform, )
1.3 转换管道 (transforms)
从磁盘读出的图片是 PIL 对象,像素是 [0, 255] 的整数,尺寸还可能不一致。在它们能被神经网络消费之前,必须统一三件事——通道、尺寸、数据类型。这正是 transforms.Compose 的职责:
transform = transforms.Compose([ transforms.Grayscale(num_output_channels=1), # 若原图为 RGB transforms.Resize((28, 28)), transforms.ToTensor(), # [0, 255] -> [0, 1] ])
ToTensor() 做了三件好事:把 PIL 转成 float32 张量、把维度从 (H, W, C) 重排到 PyTorch 习惯的 (C, H, W)、把像素压到 [0, 1] 区间。配上 ImageFolder 之后,我们再不必在代码里手动 unsqueeze(1) 或 reshape——通道维度由 ToTensor 直接安排好。
陷阱:
Resize和Grayscale都会消耗 CPU。如果原始数据已经是目标尺寸或目标通道,不要多此一举地再加这两步——后面我们讨论性能时会看到,转换管道往往就是数据流瓶颈。
1.4 与早先 CSV 版本的对比
回顾第三章的 MNIST CSV 加载方式,我们曾经:
- 用
pandas.read_csv读入数值; - 手动
reshape(-1, 28, 28)还原图像形状; - 还要
unsqueeze(1)才能给Conv2d喂一个 4 维张量(B, C, H, W)。
而采用 ImageFolder + ToTensor 之后,这三步全部消失。代码更短,可读性更高,且天然支持任意大小的真实数据集——只要文件按类目录摆好。
2. Fashion-MNIST:第一个真实的图像分类
2.1 数据集简介
Fashion-MNIST 是 Zalando Research 发布的一个时尚商品图像数据集,作为经典 MNIST 的"直接替代品"出现。它保留了 MNIST 的便利属性——28×28 灰度、10 类、60000 训练 + 10000 测试——但视觉上比手写数字困难得多。10 个类分别是:T 恤/上衣、裤子、套头衫、连衣裙、外套、凉鞋、衬衫、运动鞋、包、踝靴。本章我们使用其 PNG 版本,数据按 train/<label>/*.png、test/<label>/*.png 组织,正好贴合 ImageFolder 的约定。
2.2 一个最小的 CNN
直接拿第三章末尾的卷积块改用就行。注意输入通道数为 1:
class SimpleCNN(nn.Module): def __init__(self, num_classes=10): super().__init__() self.conv1 = nn.Conv2d(1, 16, 3, padding=1) # (B, 16, 28, 28) self.conv2 = nn.Conv2d(16, 32, 3, padding=1) # (B, 32, 14, 14) self.conv3 = nn.Conv2d(32, 64, 3, padding=1) # (B, 64, 14, 14) self.pool = nn.MaxPool2d(2) self.relu = nn.ReLU() self.flatten = nn.Flatten() self.fc = nn.Linear(64 * 7 * 7, num_classes) def forward(self, x): x = self.pool(self.relu(self.conv1(x))) # 28 -> 14 x = self.relu(self.conv2(x)) x = self.pool(self.relu(self.conv3(x))) # 14 -> 7 x = self.flatten(x) return self.fc(x)
把它放到 GPU 上,配 Adam(lr=1e-3) 加 CrossEntropyLoss,几个 epoch 就能跑到 90% 上下的测试准确率。但如果你像最初那样用 num_workers=0,你会发现:GPU 利用率低得可怜,等待数据的时间远多于真正算梯度的时间。下一节我们就来解决这件事。
3. 让数据流喂饱 GPU
当 GPU 上训练"看起来很慢",元凶几乎从不是模型,而是数据管道:磁盘读取、PNG 解码、转换计算、CPU→GPU 拷贝。DataLoader 提供了几个调节阀,把这条管线拓宽。
3.1 并行加载:num_workers
默认 num_workers=0:所有解码和转换都跑在主进程,显然撑不起一张 V100。把它设成大于 0,会创建一组子进程并行准备批次:
train_loader = DataLoader( train_dataset, batch_size=256, shuffle=True, num_workers=4, )
实践经验:
- CPU 较弱或数据集很小:2;
- Kaggle/Colab 常用:4 到 8;
- 太大反而不划算:进程切换开销可能压倒并行收益。
目标只有一个——别让 GPU 等数据。
3.2 复用工人:persistent_workers
默认情况下,每个 epoch 结束 worker 就被销毁,下一个 epoch 重新创建,启动开销显著。如果 num_workers > 0,加一句:
DataLoader(..., persistent_workers=True)
worker 就会在整个训练过程中保持存活。当 epoch 较短、数据集很大、转换成本较高时,这个开关能省下可观的"启动空转"时间。
3.3 预取深度:prefetch_factor
每个 worker 可以提前装好几个批次,塞进队列等模型来取:
DataLoader(..., num_workers=4, prefetch_factor=2)
2 是大多数版本的默认值,通常足够;如果 GPU 仍偶尔卡顿,可以试 4,代价是更高的内存占用。
3.4 锁页内存与异步拷贝:pin_memory + non_blocking=True
当训练在 GPU 上进行时,我们希望 CPU→GPU 的拷贝尽可能快。把 DataLoader 的输出张量分配在锁页内存 (page-locked memory) 中,CUDA 就能用 DMA 直接搬运:
DataLoader(..., pin_memory=True)
随后在训练循环里,把 .to(device) 改成异步形式:
for Xb, yb in train_loader: Xb = Xb.to(device, non_blocking=True) yb = yb.to(device, non_blocking=True) ...
这样"准备下一个批次"和"在 GPU 上前向/反向传播"就能更好地重叠。
陷阱:
pin_memory=True仅在device='cuda'下有意义。纯 CPU 环境上,它什么都不会加速,反而徒增内存压力。non_blocking=True也只有在源张量已锁页时才生效——单独写一个non_blocking=True而不打开pin_memory等于白写。
3.5 不要让 transforms 拖后腿
转换默认在 CPU 上跑。某些操作的代价远比想象中大:
Resize(尤其是大图缩到小图);- 颜色空间转换、
Normalize; - 各种随机增强 (
RandomCrop、ColorJitter)。
最佳实践:先用最小的 transform 跑通训练流水线,再逐步加增强;不要为已经合规的图像再加一遍 Resize 或 Grayscale。
3.6 一个"快速配置"模板
把上面的旋钮一齐拧到位,典型的 Kaggle GPU 配置长这样:
train_loader = DataLoader( train_dataset, batch_size=256, shuffle=True, num_workers=4, pin_memory=True, persistent_workers=True, )
配上 non_blocking=True 的传输,几乎所有"训练慢"问题都能消除一大半。
4. CIFAR-10:迈向真正的彩色图像
4.1 数据集简介
CIFAR-10 是计算机视觉里另一个经典基准:32×32 RGB 彩色图,10 类(飞机、汽车、鸟、猫、鹿、狗、蛙、马、船、卡车),50000 训练 + 10000 测试。它比 MNIST 难许多,因为类内变化大、背景复杂、分辨率又低,人眼分辨某些类(比如鹿和狗)都不一定容易。CIFAR-10 是一个理想的"中等规模"实验场:模型要够大才学得动,但又不至于像 ImageNet 那样需要一晚上才能训完。
4.2 适配彩色图像的 CNN
只要把 Conv2d 的 in_channels 从 1 改成 3,再重新算一下展平后的特征数,就能把 Fashion-MNIST 上的网络迁移过来:
class SimpleCNN(nn.Module): def __init__(self, num_classes=10): super().__init__() self.conv1 = nn.Conv2d(3, 16, 3, padding=1) # (B, 16, 32, 32) self.conv2 = nn.Conv2d(16, 32, 3, padding=1) # (B, 32, 16, 16) self.conv3 = nn.Conv2d(32, 64, 3, padding=1) # (B, 64, 16, 16) self.pool = nn.MaxPool2d(2) self.relu = nn.ReLU() self.flatten = nn.Flatten() self.fc = nn.Linear(64 * 8 * 8, num_classes) def forward(self, x): x = self.pool(self.relu(self.conv1(x))) # 32 -> 16 x = self.relu(self.conv2(x)) x = self.pool(self.relu(self.conv3(x))) # 16 -> 8 x = self.flatten(x) return self.fc(x)
注意展平后的尺寸推导:32×32 -> pool -> 16×16 -> conv -> 16×16 -> conv+pool -> 8×8,因此 Linear 的输入维度是 64 × 8 × 8 = 4096。
4.3 标准化:别忘了 Normalize
对彩色图,我们通常在 ToTensor 之后再叠一层 Normalize,把每个通道按训练集的均值和方差中心化:
transform = transforms.Compose([ transforms.Resize((32, 32)), transforms.ToTensor(), transforms.Normalize( mean=(0.4914, 0.4822, 0.4465), std =(0.2470, 0.2435, 0.2616), ), ])
这三组数字是 CIFAR-10 训练集上各通道的统计量,直接抄即可。标准化让优化曲面更"圆",收敛更快;它和后面要讲的批归一化是互补关系——一个在数据进入网络前做,一个在网络内部做。
5. 批归一化 (Batch Normalization)
5.1 直觉
随着网络层数加深,前层一动,后层接收到的激活分布就跟着变。这种内部协变量漂移 (internal covariate shift) 让训练既慢又难调:学习率稍大就发散,稍小又永远收敛不了。批归一化给出了一个外科手术般直接的解法——把每层激活的分布强行拉回均值 0、方差 1,然后再叠一层可学习的仿射变换 把表达能力还回来。
具体做法对每个特征通道(在 CNN 里是每张特征图)独立进行:对一个小批量 个样本:
效果:
- 训练更稳——激活分布始终温和,梯度不易爆炸或消失;
- 收敛更快——可用更大的学习率;
- 对初始化更宽容——糟糕的初始权重也能恢复过来。
5.2 用在哪里
惯用搭配是:
Conv -> BatchNorm -> ReLU
即放在卷积之后、激活函数之前。在 PyTorch 里对应 nn.BatchNorm2d(num_channels),参数是上一层卷积的输出通道数:
self.conv = nn.Conv2d(32, 64, kernel_size=3, padding=1) self.bn = nn.BatchNorm2d(64) self.relu = nn.ReLU() def forward(self, x): x = self.conv(x) x = self.bn(x) x = self.relu(x) return x
5.3 训练 vs. 评估的两副面孔
关键陷阱:BatchNorm 在
model.train()和model.eval()下行为不同。
- 训练时,它用当前批次的 做归一化,同时用滑动平均 (running mean/var) 累积全局统计;
- 评估时,它用累积的全局统计而非当前批次的统计——这正是为什么必须调
model.eval()才能拿到稳定的预测。忘记切换
eval()是 CNN 部署阶段最常见的 bug:测试集准确率忽高忽低,根源就是 BN 用了"测试小批"的统计而不是训练阶段累积出来的均值方差。
6. Dropout
6.1 原理
Dropout 是 Hinton 团队在 2012 年提出的一个简单到不可思议的正则化:训练时,以概率 把一部分神经元的输出随机置零。它逼迫网络学会不依赖任何单一路径——任何一个特征都可能被丢掉,所以系统必须分散地编码信息,从而显著减小过拟合。从集成学习的角度看,Dropout 相当于在指数多个"子网络"上同时训练,推断时取它们的平均。
6.2 用在哪里
Dropout 主要部署在全连接层之间。在很深的卷积块之后偶尔也加,但不要滥用——卷积层本身已经天然带有"权重共享"这种正则化结构,叠太多 Dropout 反而损害收敛。典型搭配:
Linear -> ReLU -> Dropout
PyTorch 里就一行:
self.fc1 = nn.Linear(4096, 256) self.relu = nn.ReLU() self.dropout = nn.Dropout(p=0.5) self.fc2 = nn.Linear(256, 10)
6.3 评估时自动失效
陷阱:Dropout 也对
train()/eval()敏感。
model.train()下,Dropout 真正按概率丢弃神经元;model.eval()下,Dropout 自动跳过——不再随机失活,且输出按 缩放(在 PyTorch 内部实现里其实是训练时除以 ,推断时直通,效果等价)。如果你忘了切
eval(),推断结果会带随机抖动,模型表现像被人为打了折扣。
陷阱:Dropout 不要放在 BatchNorm 之前。BN 的统计依赖完整激活,Dropout 把一半数值置零会污染滑动均值。常见安全顺序是
Conv -> BN -> ReLU(卷积块) 与Linear -> ReLU -> Dropout(全连接块)分开管理。
7. 一个完整的"BN + Dropout"CNN
把上面两件武器集成到 CIFAR-10 网络里:
class SimpleCNN_BN_DO(nn.Module): def __init__(self, num_classes=10, dropout_p=0.5): super().__init__() # 卷积块 1 self.conv1 = nn.Conv2d(3, 16, 3, padding=1) self.bn1 = nn.BatchNorm2d(16) # 卷积块 2 self.conv2 = nn.Conv2d(16, 32, 3, padding=1) self.bn2 = nn.BatchNorm2d(32) # 卷积块 3 self.conv3 = nn.Conv2d(32, 64, 3, padding=1) self.bn3 = nn.BatchNorm2d(64) self.relu = nn.ReLU() self.pool = nn.MaxPool2d(2) self.flatten = nn.Flatten() self.fc1 = nn.Linear(64 * 8 * 8, 256) self.dropout = nn.Dropout(p=dropout_p) self.fc2 = nn.Linear(256, num_classes) def forward(self, x): x = self.pool(self.relu(self.bn1(self.conv1(x)))) # 32 -> 16 x = self.relu(self.bn2(self.conv2(x))) x = self.pool(self.relu(self.bn3(self.conv3(x)))) # 16 -> 8 x = self.flatten(x) x = self.relu(self.fc1(x)) x = self.dropout(x) x = self.fc2(x) return x
训练循环本身不需要修改——只要把 model.train() 放在循环开头,model.eval() 放在评估前,BN 与 Dropout 就会自动在两种模式下正确切换。
经验上,加了 BN 之后,你会发现:
- 学习率可以从
1e-3提到3e-3甚至更高而不发散; - 收敛速度大约提升 1.5 到 2 倍;
- 训练曲线更平滑,出现"震荡"的概率显著下降。
加了 Dropout 之后:
- 训练集准确率会略微下降(这是好事——它代表模型不再死记硬背);
- 测试集准确率上升,训练-测试差距收窄;
- 当 设得太大(比如 0.7 以上),模型会欠拟合,需要回调。
8. 性能调优:从理论到实测
8.1 用 time.perf_counter 标定瓶颈
任何优化都要建立在测量的基础上。在 notebook 里给训练循环和评估循环各设一个时钟,而不是只盯着总时间:
import time t_data, t_train = 0.0, 0.0 for epoch in range(epochs): t0 = time.perf_counter() model.train() for Xb, yb in train_loader: t1 = time.perf_counter() t_data += t1 - t0 Xb = Xb.to(device, non_blocking=True) yb = yb.to(device, non_blocking=True) optimizer.zero_grad() loss = criterion(model(Xb), yb) loss.backward() optimizer.step() t0 = time.perf_counter() t_train += t0 - t1
如果 t_data 远大于 t_train,说明数据流是瓶颈,该回去拧 num_workers 和 pin_memory;如果 t_train 占大头,瓶颈才在模型本身,这时考虑减小批量、降低分辨率或简化网络。没有测量的优化都是猜——这是工程师必须养成的纪律。
8.2 GPU 利用率诊断
在 Kaggle 或自己的服务器上,可以另开一个终端运行 nvidia-smi -l 1,观察 GPU-Util 一栏。理想状态是 90% 以上;如果只有 30% 到 50%,基本可以断定是数据没跟上。再不行就把 batch_size 翻倍,看是显存不够还是 CPU 不够——前者会爆显存,后者 GPU 利用率仍然上不去,那就要回到 DataLoader 的并行选项上想办法。
8.3 混合精度训练 (AMP) 速览
PyTorch 自带 自动混合精度 (Automatic Mixed Precision, AMP) 工具,把大部分前向计算用 float16、累加器保持 float32,在保持精度的同时近乎免费地提速 1.5 至 2 倍:
from torch.amp import autocast, GradScaler scaler = GradScaler() for Xb, yb in train_loader: Xb = Xb.to(device, non_blocking=True) yb = yb.to(device, non_blocking=True) optimizer.zero_grad() with autocast(device_type="cuda"): loss = criterion(model(Xb), yb) scaler.scale(loss).backward() scaler.step(optimizer) scaler.update()
本章不要求强制使用 AMP,但如果你的 GPU 是 Volta、Turing 及之后的架构 (V100、T4、A100、3090、4090...),不开 AMP 等于浪费一半算力。下一章会在迁移学习的场景下系统介绍。
9. 常见错误清单
把容易犯的错统一归档,作为本章的"急救手册":
-
忘记
model.eval()。BN 用了批次统计、Dropout 仍在丢神经元——测试准确率一路飘忽。在评估函数最开头一律写一行model.eval(),并在评估完返回训练前写model.train()。 -
用
torch.no_grad()但没切eval()。no_grad只关闭梯度计算,不影响 BN/Dropout 的运行模式,两者职责不同,推断时都得加。 -
Linear输入维度算错。每加一层Pool、改一次padding,展平后的尺寸都会变。一个调试技巧:在forward里临时打印x.shape,跑一个批次看看,然后再决定Linear第一个参数。 -
shuffle=True用在测试集。测试集只评估准确率和混淆矩阵,洗牌没意义,而且会让"按样本编号导出预测"变得困难。 -
冻结层时漏掉
requires_grad = False。这一点在下一章迁移学习时再细讲——这里先记着:把权重冻住的正确做法是在加载预训练模型后,遍历model.parameters()把对应的requires_grad置为False,而不是只把它们从optimizer里挑出来。后者依然会在.backward()时计算梯度,白白浪费显存。 -
训练前没设
torch.manual_seed(0)。结果不可复现,你和同学跑出的曲线完全对不上,讨论无从开始。 -
把 BN 用在批量过小的场景。当 batch_size 小于 8 甚至更小时,批次统计 噪声极大,BN 反而损害训练。这种情形可以改用 Group Normalization 或 Layer Normalization。
10. 本章小结
到此为止,我们的 CNN 工具箱基本完整:
- 数据:
ImageFolder + transforms.Compose把磁盘上的 PNG 文件夹直接接进训练流; - 吞吐:
num_workers、pin_memory、persistent_workers、non_blocking让 GPU 不再挨饿; - 稳定性与泛化:
BatchNorm2d让深层网络可训,Dropout让网络不过拟合。
下一章 (DL 5) 我们将进一步引入数据增强 (data augmentation)、学习率调度 (learning rate scheduling),并迈入迁移学习 (transfer learning) 的世界——直接拿在 ImageNet 上预训练好的 ResNet/VGG,微调几层就能在小数据集上拿到惊人的准确率。
练习
-
复刻 Fashion-MNIST 流水线。下载 PNG 版的 Fashion-MNIST(类目录布局),用
ImageFolder与三种不同的DataLoader配置训练 4 个 epoch:num_workers=0(基线);num_workers=4;num_workers=4, pin_memory=True, persistent_workers=True并在循环里使用non_blocking=True。
用
time.perf_counter()测每种配置的总耗时,作图比较。 -
CIFAR-10 基线 CNN。改写第 4 节的
SimpleCNN,使其接受 32×32 RGB 输入;训练 5 个 epoch;输出测试集准确率与混淆矩阵。检查Xb.shape一定是(B, 3, 32, 32)。 -
加入 Normalize。在第 2 题的基础上,在
ToTensor之后追加transforms.Normalize(mean=(0.4914,0.4822,0.4465), std=(0.2470,0.2435,0.2616)),重新训练,比较收敛速度与最终准确率的差异。 -
加入 BatchNorm。把每个
Conv2d后面塞一个BatchNorm2d(对应通道数),再接 ReLU。把学习率从1e-3调到3e-3,观察:(a) 训练曲线是否更平滑?(b) 收敛是否更快?(c) 测试准确率是否提升? -
加入 Dropout。在
fc1与fc2之间加nn.Dropout(p=0.5)。比较加 Dropout 前后,训练集和测试集准确率的差距 (gap),它应当变小。再尝试p=0.2与p=0.7,讨论欠拟合与过拟合的取舍。 -
train/eval 模式实验。故意"忘记"在评估前调用
model.eval(),观察测试准确率是否变得不稳定;然后调用model.eval()重新评估,对比差异。请在报告里说明:这种不稳定性主要来自 BN 还是 Dropout?为什么? -
错误的 BN 位置。把
BatchNorm2d移到 ReLU 之后(即Conv -> ReLU -> BN),重新训练。准确率会下降还是上升?试着解释你观察到的现象。 -
基准时间。用
time.perf_counter()在每个 epoch 开始与结束时打点,把"加 BN 之前"与"加 BN 之后"达到 70% 测试准确率所需的 epoch 数与挂钟时间画成对比图。
拓展阅读
- PyTorch 官方教程 — Training a Classifier:
https://pytorch.org/tutorials/beginner/blitz/cifar10_tutorial.html,以 CIFAR-10 为例完整走一遍Dataset / DataLoader / Model / Training流水线。 torchvision.transforms文档:https://pytorch.org/vision/stable/transforms.html,详尽列出Resize、Normalize、RandomCrop、ColorJitter、RandAugment等;v2 API 更快也更灵活,值得学习。torchvision.models模型动物园:https://pytorch.org/vision/stable/models.html,提供 ResNet、VGG、EfficientNet、ViT 等预训练权重;下一章迁移学习正是从这里取模型。- Ioffe & Szegedy (2015), Batch Normalization: Accelerating Deep Network Training by Reducing Internal Covariate Shift。BN 的开山论文,数学推导清晰。
- Srivastava et al. (2014), Dropout: A Simple Way to Prevent Neural Networks from Overfitting。Dropout 的原始论文,用直观图示解释了"指数多个子网络"的视角。
- Albumentations 库:
https://albumentations.ai/,工业界主流的图像增强库,比torchvision.transforms更快、增强种类更丰富,可与 PyTorch 无缝衔接。 - Kornia:
https://kornia.readthedocs.io/,把图像处理算子写成可微的 PyTorch 模块,可以直接放进 GPU 上的训练图。 - He et al. (2015), Delving Deep into Rectifiers。介绍 He 初始化,与 BN 一道是深层 ReLU 网络稳定训练的两大支柱。
- 吴恩达 Deep Learning Specialization 第四门课 Convolutional Neural Networks:Coursera 上的视频课程,对 BN、数据增强、迁移学习的图解非常友好。