
图像分类可以说是计算机视觉里最基础、也最能检验“动手能力”的任务。很多初学者在跑通 MNIST 之后都会产生一种错觉感觉卷积神经网络CNN不过就是几个conv、几个pooling搭起来跑一下就能出结果。但一旦把任务换成彩色图像、复杂背景、多类别数据各种问题就会接连出现loss 不下降、准确率上不去、显存超限、数据加载卡死、模型保存后无法正常加载……最后只能一遍又一遍修改网络结构却始终找不到一个稳定的训练流程。这篇文章就从 PyTorch 出发用经典的 CIFAR-10 数据集走一遍“卷积神经网络执行图像分类”的完整流程。我不会只贴一堆代码而是把每一步背后的原理、容易踩的坑、以及工程上更稳妥的做法一并讲清楚。读完之后你不仅能跑通一个可复现的小型 CNN 图像分类项目还能从数据加载、模型设计、训练调参、评估验证到模型保存建立起一套属于自己的 PyTorch 实战框架。1. 这篇文章真正要解决的问题先想一个问题图像分类任务到底难在哪里从数学上看图像分类就是把一张图片映射成一个类别标签。这个过程并不复杂但工程落地时你会遇到很多具体麻烦。第一层麻烦是数据。真实图像尺寸不一、亮度不一、目标位置不一。模型不能只记住“像素坐标”它需要具备一定的平移不变性和尺度鲁棒性。所以我们需要卷积层这种能提取局部特征的结构。第二层麻烦是训练。神经网络参数量很大需要大量数据反复迭代。如果数据加载方式不对、batch size 设置不当、学习率不合适训练很容易发散或者收敛极慢。很多人把问题归结为“模型结构不行”其实大多数时候是训练流程不科学。第三层麻烦是验证。训练完模型之后怎么判断模型真的“学会了”在训练集上准确率很高不代表测试集上效果也好。只有建立正确的验证流程才能知道模型是否过拟合、是否需要调整数据、是否需要改进结构。这篇文章要解决的就是这三层麻烦。我会用 CIFAR-10 数据集作为例子搭建一个结构简单但有效的 CNN 模型让读者跟着从零完成数据加载、模型定义、训练循环、测试评估和模型持久化。读者不需要先掌握复杂的深度学习理论只要了解 Python 基础就可以把整套流程跑起来。如果你正在学习 PyTorch或者正在准备图像分类项目、毕业论文、竞赛这篇文章尤其适合你。你不需要从零研究各种 API 文档直接复用本文的代码框架再针对自己的数据集做替换即可。2. 卷积神经网络的核心概念与图像分类原理2.1 图像分类的本质图像分类的输入是一张图片输出是这张图片属于每个类别的概率。以 CIFAR-10 为例类别包括飞机、汽车、鸟、猫、鹿、狗、青蛙、马、船、卡车。模型最终要输出一个 10 维向量每个元素表示属于对应类别的概率取最大概率位置作为预测标签。传统方法在做图像分类时需要人工设计特征比如颜色直方图、纹理特征、边缘检测算子等。这些特征好不好直接决定分类效果。CNN 的出现改变了这一点它可以从原始像素中自动学习到从低级边缘、纹理到高级语义的分层特征。底层卷积核学习边缘、颜色块高层卷积核可以学习到眼睛、轮子、“猫耳朵”这类抽象概念。2.2 CNN 的三个核心机制CNN 之所以适合图像分类主要是因为它引入三个机制。局部连接每个神经元只连接输入图像的局部区域。图像中相邻像素之间相关性最强局部连接大幅减少参数量同时符合视觉感知的局部性。权值共享同一个卷积核在整张图像上滑动使用同一组权重。这意味着无论目标出现在左上角还是右下角卷积核都能提取到同样的局部特征。这带来平移不变性。池化降采样池化层对特征图进行下采样保留主要特征同时减少计算量和参数量。常见的最大池化取局部区域最大值平均池化取局部区域均值。池化也让模型对局部位置轻微变化更鲁棒。2.3 常见层的角色对比层类型核心作用输出尺寸变化需要注意的问题卷积层提取局部特征长宽通常缩小或不变通道数由卷积核数量决定卷积核大小、步长、填充需要匹配输入尺寸激活函数层引入非线性尺寸不变推荐 ReLU避免梯度消失池化层下采样、压缩特征长宽缩小常用 2x2、步长2会丢失部分位置信息全连接层将特征映射到类别空间变为一维向量参数量大容易过拟合常配合 Dropout 使用Dropout 层随机失活神经元抑制过拟合尺寸不变训练时开启评估时关闭2.4 为什么不用全连接网络做图像分类把一张 32x32 的彩色图片展平得到 3072 个像素值。如果第一层全连接有 1024 个神经元那么这层权重数量大约是 300 万很容易过拟合且计算量巨大。更关键的是全连接层会破坏像素之间的空间结构无法利用图像局部相关性。CNN 通过局部连接和权值共享把参数量降到可接受范围同时保留了二维空间结构信息。这就是为什么当前几乎所有图像分类模型都基于卷积网络结构从早期的 LeNet、AlexNet到 VGG、ResNet再到近年来的 MobileNet、EfficientNet主体框架都由卷积层组成区别主要在深度、残差连接、注意力机制和轻量化设计上。3. 环境准备与数据说明3.1 运行环境本文所有代码基于 Python 和 PyTorch 实现。版本方面建议使用 Python 3.8 以上版本PyTorch 使用 1.10 以上稳定版即可本文演示的是通用思路不同版本间 API 变化不大。如果本机有 NVIDIA GPU建议安装对应 CUDA 版本的 PyTorch训练速度会快很多。如果没有 GPU也可以使用 CPU 版本跑完整个流程只是 CIFAR-10 训练时间会相对较长。本文示例代码可以同时在 CPU 和 GPU 上运行代码会自动选择设备。创建虚拟环境是一种好习惯避免不同项目之间依赖冲突。以 conda 为例conda create -n cnn-classify python3.9 conda activate cnn-classify然后安装 PyTorch。CPU 版本pip install torch torchvisionGPU 版本请按照 PyTorch 官网给出的对应 CUDA 版本安装。不要盲目安装最新版先确认本机显卡驱动支持的 CUDA 版本。一个常见方法是使用nvidia-smi查看驱动对应最高 CUDA 版本然后选择不超过该版本的 PyTorch 安装命令。3.2 CIFAR-10 数据集CIFAR-10 是一个非常经典的图像分类数据集由 60000 张 32x32 彩色图像组成共有 10 个类别每个类别 6000 张。其中 50000 张作为训练集10000 张作为测试集。这个数据集的难点在于图像分辨率低、物体占比小、背景复杂非常考验模型提取特征的能力。如果直接使用原始数据训练一个简单 CNN只要训练流程正确测试准确率通常可以达到 70% 左右。再通过数据增强、调参、更深的网络可以提高到 80% 以上。这组数字来自工程实践中的常见水平供你心里有个预期。3.3 依赖说明除了 torch 和 torchvision我们会用到 matplotlib 进行图像可视化numpy 作为数组操作工具。安装命令pip install matplotlib numpy4. 数据加载与预处理实战在 PyTorch 中数据加载有一套标准流程定义数据集 - 数据变换 - 使用 DataLoader 批量加载。4.1 定义数据变换图像预处理的核心操作包括转为 Tensor、归一化。ToTensor()会把 PIL Image 或 numpy 数组转换为[0, 1]范围的张量并且把通道顺序转换成[C, H, W]这是 PyTorch 默认的通道顺序。归一化使用均值和标准差将数据缩放到以 0 为中心的分布有助于模型稳定训练。CIFAR-10 数据集的全局均值大约为每个通道(0.4914, 0.4822, 0.4465)标准差约为(0.2023, 0.1994, 0.2010)。实际项目中也可以直接计算自己数据集的均值和标准差。# 文件路径data_utils.py import torch from torchvision import datasets, transforms from torch.utils.data import DataLoader transform transforms.Compose([ transforms.ToTensor(), transforms.Normalize(mean[0.4914, 0.4822, 0.4465], std[0.2023, 0.1994, 0.2010]) ])这里需要注意Normalize的 mean 和 std 必须和ToTensor()之后的数值范围匹配。如果手动实现归一化要先把像素值除以 255再用均值方差归一化否则数值范围会错误。4.2 加载数据集torchvision 自带 CIFAR-10 下载和加载功能第一次运行时会自动从网络下载压缩包到指定目录。# 文件路径data_utils.py继续 train_dataset datasets.CIFAR10( root./data, trainTrue, downloadTrue, transformtransform ) test_dataset datasets.CIFAR10( root./data, trainFalse, downloadTrue, transformtransform )root参数表示数据存放目录downloadTrue表示如果本地不存在就自动下载。如果网络环境不稳定可以先手动下载数据集压缩包放到对应目录再跑代码。4.3 创建 DataLoaderDataLoader 负责将数据集切分成 batch、打乱顺序、多进程加载。训练数据集需要shuffleTrue避免模型连续看到同一类样本而产生偏差。测试集不需要打乱。# 文件路径data_utils.py继续 batch_size 64 train_loader DataLoader( train_dataset, batch_sizebatch_size, shuffleTrue, num_workers2, pin_memoryTrue ) test_loader DataLoader( test_dataset, batch_sizebatch_size, shuffleFalse, num_workers2, pin_memoryTrue )num_workers表示使用几个子进程加载数据。在 Windows 系统上如果num_workers设置过大有时反而会报错或卡死。如果遇到和 DataLoader 相关的运行问题可以先把它设为 0。pin_memoryTrue在 GPU 训练时可以提速把数据缓存在页锁定内存中减少 CPU 到 GPU 的传输时间。CPU 训练时也可以保留影响不大。5. 搭建一个轻量级 CNN 模型下面定义一个适合 CIFAR-10 的简单 CNN 结构。它包含两个卷积块加一个全连接分类头参数规模不大在 CPU 上也能训练适合作为入门基线模型。# 文件路径model.py import torch.nn as nn import torch.nn.functional as F class SimpleCNN(nn.Module): def __init__(self, num_classes10): super(SimpleCNN, self).__init__() # 第一个卷积块卷积 - ReLU - 池化 self.conv1 nn.Conv2d(in_channels3, out_channels32, kernel_size3, padding1) self.bn1 nn.BatchNorm2d(32) self.pool1 nn.MaxPool2d(kernel_size2, stride2) # 第二个卷积块卷积 - ReLU - 池化 self.conv2 nn.Conv2d(in_channels32, out_channels64, kernel_size3, padding1) self.bn2 nn.BatchNorm2d(64) self.pool2 nn.MaxPool2d(kernel_size2, stride2) # 第三个卷积块加深特征提取 self.conv3 nn.Conv2d(in_channels64, out_channels128, kernel_size3, padding1) self.bn3 nn.BatchNorm2d(128) self.pool3 nn.MaxPool2d(kernel_size2, stride2) # 全连接分类器 self.fc1 nn.Linear(128 * 4 * 4, 256) self.dropout nn.Dropout(0.5) self.fc2 nn.Linear(256, num_classes) def forward(self, x): x self.pool1(F.relu(self.bn1(self.conv1(x)))) x self.pool2(F.relu(self.bn2(self.conv2(x)))) x self.pool3(F.relu(self.bn3(self.conv3(x)))) x x.view(x.size(0), -1) # 展平 x F.relu(self.fc1(x)) x self.dropout(x) x self.fc2(x) return x这个模型的关键点如下。输入是[batch_size, 3, 32, 32]。经过第一个卷积块后输出为[batch_size, 32, 16, 16]。经过第二个卷积块后输出为[batch_size, 64, 8, 8]。经过第三个卷积块后输出为[batch_size, 128, 4, 4]。展平后得到[batch_size, 128 * 4 * 4] [batch_size, 2048]再进入全连接层最终输出 10 个类的 logits。这里使用了BatchNorm2d批归一化层。它会在每个 batch 内对特征做归一化让网络在训练初期更稳定收敛更快。如果模型不使用 BatchNorm学习率稍大一点就可能出现 loss 震荡。Dropout(0.5)在全连接层前随机丢弃一半神经元可以有效缓解过拟合。在测试阶段PyTorch 的 dropout 会自动关闭不需要手动处理。实际项目中如果图像尺寸不是 32x32需要重新计算展平后的维度。一个通用办法是在__init__里先做一次 dummy forward或者用nn.Flatten配合AdaptiveAvgPool2d这样可以避免手工计算尺寸。不过对于固定输入尺寸的数据集直接按公式计算更直观。6. 训练流程实现模型搭建完成后接下来是训练核心逻辑。训练循环本质上是取一个 batch 数据 - 前向传播得到输出 - 计算损失 - 反向传播计算梯度 - 更新参数。重复这个过程直到完成所有 epoch。6.1 定义超参数、损失函数和优化器# 文件路径train.py import torch import torch.nn as nn import torch.optim as optim from model import SimpleCNN from data_utils import train_loader, test_loader device torch.device(cuda if torch.cuda.is_available() else cpu) print(fUsing device: {device}) model SimpleCNN(num_classes10).to(device) criterion nn.CrossEntropyLoss() optimizer optim.Adam(model.parameters(), lr0.001) num_epochs 20分类任务通常使用CrossEntropyLoss。它内部已经包含了 Softmax 计算所以网络最后一层不需要手动加 Softmax。如果希望在测试阶段获取概率值可以单独调用torch.softmax(output, dim1)。优化器这里选用 Adam它是很多任务的默认选择优点是自适应学习率、收敛速度快。如果追求更高的精度也可以在后期切换到 SGD Momentum配合学习率调整策略效果可能更好。6.2 训练一个 Epoch训练过程建议封装成一个函数方便代码组织。关键点在训练阶段调用model.train()让 dropout 和 batchnorm 启用清理梯度使用optimizer.zero_grad()。# 文件路径train.py继续 def train_one_epoch(model, train_loader, criterion, optimizer, device): model.train() running_loss 0.0 correct 0 total 0 for inputs, labels in train_loader: inputs, labels inputs.to(device), labels.to(device) # 前向传播 outputs model(inputs) loss criterion(outputs, labels) # 反向传播 optimizer.zero_grad() 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 / total epoch_acc correct / total return epoch_loss, epoch_acc熟悉loss.item()很重要loss是一个 Tensor参与计算图直接用loss.item()获取它的 Python 数值避免计算图一直累积。torch.max(outputs, 1)返回每一行最大值的索引也就是预测类别。6.3 测试模型准确率测试时不需要计算梯度所以用torch.no_grad()包住整个循环节省显存和计算时间。测试过程需要调用model.eval()这会关闭 dropout 随机失活并让 batchnorm 使用全局统计量而不是当前 batch 的统计量。# 文件路径train.py继续 def evaluate(model, test_loader, criterion, device): model.eval() test_loss 0.0 correct 0 total 0 with torch.no_grad(): for inputs, labels in test_loader: inputs, labels inputs.to(device), labels.to(device) outputs model(inputs) loss criterion(outputs, labels) test_loss loss.item() * inputs.size(0) _, predicted torch.max(outputs, 1) total labels.size(0) correct (predicted labels).sum().item() avg_loss test_loss / total accuracy correct / total return avg_loss, accuracy6.4 完整训练循环# 文件路径train.py继续 best_acc 0.0 for epoch in range(1, num_epochs 1): train_loss, train_acc train_one_epoch( model, train_loader, criterion, optimizer, device ) val_loss, val_acc evaluate(model, test_loader, criterion, device) print(fEpoch {epoch:02d}/{num_epochs} | fTrain Loss: {train_loss:.4f} | Train Acc: {train_acc:.4f} | fVal Loss: {val_loss:.4f} | Val Acc: {val_acc:.4f}) # 保存验证集上表现最好的模型 if val_acc best_acc: best_acc val_acc torch.save(model.state_dict(), best_model.pth) print(f - Save best model, val_acc {val_acc:.4f})你可能会问为什么每个 epoch 都在完整测试集上做一次评估因为只统计训练集上的准确率无法反映模型泛化能力。在训练中观察验证集准确率变化也能及时发现过拟合。如果训练准确率持续升高而验证准确率停滞甚至下降说明模型开始过拟合可以提前停止训练或增加正则化手段。7. 运行结果与效果验证7.1 运行命令把data_utils.py、model.py、train.py放在同一目录下在虚拟环境终端执行python train.py第一次运行时会自动下载 CIFAR-10 数据集到./data目录下载完成后开始训练。7.2 预期输出以 CPU 环境为例输出可能类似Using device: cpu Epoch 01/20 | Train Loss: 1.6812 | Train Acc: 0.3765 | Val Loss: 1.4078 | Val Acc: 0.4881 Epoch 02/20 | Train Loss: 1.2752 | Train Acc: 0.5388 | Val Loss: 1.1132 | Val Acc: 0.5625 ... Epoch 10/20 | Train Loss: 0.6421 | Train Acc: 0.7731 | Val Loss: 0.7924 | Val Acc: 0.7215 ... Epoch 20/20 | Train Loss: 0.3934 | Train Acc: 0.8681 | Val Loss: 0.6995 | Val Acc: 0.7542从输出可以看到随着 epoch 增加训练准确率不断升高验证准确率在前几个 epoch 提升明显后期提升速度放缓。这个简单 CNN 的最终测试准确率通常在 70% 到 78% 之间具体数值会受到随机种子、数据下载版本和训练环境的影响。7.3 如何判断训练是否成功判断训练是否成功不只是看最终准确率还要看整个训练曲线是否健康训练损失应该稳定下降没有明显反弹。训练准确率和验证准确率之间的差距不应过大。如果训练准确率 95% 而验证准确率只有 70%说明过拟合严重。验证准确率在刚开始训练时快速上升然后进入平台期这是正常现象。如果损失一直不下降可以检查数据预处理、学习率、模型结构是否出错。如果损失下降很快但验证准确率迟迟不涨也要怀疑数据标签或者 DataLoader 打乱顺序是否存在问题。7.4 模型保存与加载训练过程中已经使用torch.save(model.state_dict(), best_model.pth)保存了验证准确率最高的模型参数。使用模型时需要先创建相同结构的模型实例再加载状态字典# 文件路径inference.py import torch from model import SimpleCNN device torch.device(cuda if torch.cuda.is_available() else cpu) model SimpleCNN(num_classes10) model.load_state_dict(torch.load(best_model.pth, map_locationdevice)) model.to(device) model.eval() # 此时模型可以用于预测新的图像需要注意保存模型的推荐方式是只保存state_dict而不是整个模型对象。这样模型结构定义和参数分离兼容性更好。如果以后修改了模型结构加载参数时键名可能不匹配因此尽量不要随意改名或去掉层。8. 常见问题与排查思路我在实际项目中整理过一些高频问题这里以表格形式汇总。问题现象可能原因排查方式解决方案loss 不下降始终在 2.3 附近学习率过大或过小数据未归一化模型初始化有问题打印学习率和数据范围检查输入张量是否在合理范围改用 Adam设置lr0.001确认 transform 中包含Normalizeloss 突然变成 NaN学习率过大导致梯度爆炸数据存在异常值查看训练到第几个 batch 开始的打印梯度范数降低学习率添加梯度裁剪检查数据中是否包含无效图片GPU 显存不够batch size 太大模型过大输入分辨率太高观察显存占用情况减小 batch size简化模型使用混合精度训练DataLoader 卡住或报错Windows 下num_workers设置问题数据路径权限问题把num_workers0再试降低 workers 数检查root路径是否可写训练准确率很高测试准确率很低模型过拟合对比训练/验证准确率曲线添加 Dropout使用数据增强增加 L2 正则化早停加载模型报错size mismatch当前模型结构和保存参数时不一致打印state_dict的键和形状使用相同的模型定义检查分类数量是否一致其中loss 始终在 2.3 附近是图像分类非常典型的现象。CIFAR-10 是 10 分类随机猜测的交叉熵损失约为-ln(1/10) ≈ 2.3026。如果损失停在这个值说明模型完全没有学到任何有效特征优先检查输入数据是否归一化、数据加载顺序是否正确、模型 forward 是否把数据正确传到了分类层。如果你在 Windows 环境训练num_workers报错是比较常见的问题。一个稳妥做法是脚本入口处加上if __name__ __main__:保护避免多进程递归执行如果还不行就把num_workers减小为 0。9. 最佳实践与工程建议9.1 添加数据增强上面代码里预处理只做了归一化这是最基础的流程。真实项目中只做归一化很容易过拟合。建议在训练集上增加随机水平翻转、随机裁剪、颜色抖动等增强操作进一步提升模型泛化能力。# 文件路径data_utils.py新增 train_transform transforms.Compose([ transforms.RandomHorizontalFlip(), transforms.RandomCrop(32, padding4), transforms.ToTensor(), transforms.Normalize(mean[0.4914, 0.4822, 0.4465], std[0.2023, 0.1994, 0.2010]) ])测试集只做ToTensor和Normalize不做数据增强。这是最常见、也最稳妥的处理方式。9.2 使用学习率调整策略固定学习率的训练在后期容易进入平台期。推荐先使用一个略高的学习率让模型快速收敛再按照 epoch 或验证指标下降学习率。PyTorch 自带的ReduceLROnPlateau可以监控验证损失scheduler optim.lr_scheduler.ReduceLROnPlateau( optimizer, modemin, factor0.5, patience3 )每个 epoch 结束后把验证损失传给 schedulerscheduler.step(val_loss)如果验证损失连续 3 个 epoch 没有下降学习率就会减半这比手动调整更可控。9.3 设置随机种子保证实验可复现深度学习中随机性来源很多模型初始化、数据打乱顺序、GPU 运算。如果需要复现结果可以在代码开头固定随机种子def set_seed(seed): import random import numpy as np random.seed(seed) np.random.seed(seed) torch.manual_seed(seed) torch.cuda.manual_seed_all(seed) set_seed(42)需要注意的是即使设置了随机种子GPU 上的某些运算仍然不完全可复现但可以通过设置torch.backends.cudnn.deterministic True和torch.backends.cudnn.benchmark False来进一步缩小差异。9.4 使用 TensorBoard 记录训练过程在复杂项目中只在控制台打印 loss 不够直观。建议使用torch.utils.tensorboard.SummaryWriter记录训练指标然后在浏览器中查看曲线。from torch.utils.tensorboard import SummaryWriter writer SummaryWriter(runs/experiment_1) # 在每个 epoch 结束后 writer.add_scalar(Loss/train, train_loss, epoch) writer.add_scalar(Accuracy/val, val_acc, epoch)训练完成后执行tensorboard --logdirruns浏览器打开http://localhost:6006就能看到训练曲线。9.5 从预训练模型开始迁移学习本文搭建的是一个小型 CNN适合学习原理。实际项目里如果数据量不够大更推荐使用在 ImageNet 上预训练好的模型比如 ResNet18、ResNet50、MobileNetV3然后在自己的数据集上微调。这样即使只有几千张图片也能获得不错的效果。import torchvision.models as models model models.resnet18(pretrainedTrue) num_features model.fc.in_features model.fc nn.Linear(num_features, num_classes)迁移学习的关键是把前面的层冻结只训练新增的分类头然后逐步解冻全部层做微调。这个过程通常需要更小的学习率比如1e-4或5e-5。9.6 工程安全提醒在真实业务中训练数据可能包含敏感信息模型文件也可能部署在外部环境。以下几条建议值得留意数据下载和模型保存路径要提前规划避免把临时文件写到系统目录或权限不足的位置。删除或覆盖历史模型文件前确认有备份或版本管理。在测试环境验证代码后再放到生产环境训练不要直接在生产目录运行未经验证的脚本。使用第三方预训练模型时确认模型来源可靠避免未知模型文件带来安全风险。GPU 资源属于公共资源时训练任务要设置合理的资源限制和释放机制。10. 总结与后续学习方向这篇文章围绕“PyTorch 使用卷积神经网络执行图像分类”这一个完整任务展开从数据加载、模型定义、训练循环到模型评估与保存给出了一个可以立即运行的代码框架。我们重点讲清楚了几个关键点CIFAR-10 数据集的处理方式、CNN 中卷积、池化、全连接各层的作用、为什么训练集和测试集要使用不同的模式、如何判断模型是否过拟合以及实际训练中最常见的几个坑应该怎么排查。如果你已经跑通了这篇文章的代码下一步可以继续做三件事。第一把你的训练脚本改成命令行工具支持传入数据集路径、batch size、epoch 等参数方便复现实验。第二把模型结构换成 ResNet 或 MobileNet对比不同网络在 CIFAR-10 上的效果这能帮助你理解网络深度和残差连接的价值。第三学习如何把训练好的模型部署成 API 服务让图像分类能力真正进入业务系统。PyTorch 本身只是一把工具真正决定图像分类效果的是你对数据的理解、对模型结构的把握以及系统化调参的能力。希望你把这篇文章当作一份可复用的工程模板而不是一段一次性代码以后遇到新的图像分类任务时能在此基础上快速搭建出属于你自己的流程。