
如果你正打算入门深度学习又不知道第一个项目该选什么我一般都会建议先完整跑一遍猫狗图分类。这个任务听起来特别简单——把图片分成猫和狗两类——但它几乎覆盖了训练流程里的所有环节数据准备、模型搭建、损失计算、参数更新、结果评估、保存推理。当年我第一次用PyTorch搭CNN做猫狗图分类的时候踩了一堆现在看起来很幼稚的坑normalize参数抄错、数据集路径带中文导致ImageFolder读不到文件、Windows下num_workers设置过大直接把内存吃满……这篇文章就把整个过程从头写一遍包括每个关键代码的意图和每个坑的排查思路给准备入坑的读者一条可以照着走的完整路线。1. 一个“简单”项目背后的三门课二分类、图片张量、CNN的工作方式1.1 猫狗分类本质上是二分类问题先把这个任务拆到底。猫狗图分类的输入是一张图片输出是“猫”或“狗”的预测结果。在深度学习里这属于典型的二分类问题类别数量为2模型最终会输出两个概率值比如[0.87, 0.13]意思是模型认为这张图有87%的可能是猫、13%的可能是狗我们取概率更大的那个类别作为最终预测结果。图片在计算机里的表示是一个三维数组高、宽、通道数。以我们常用的RGB彩色图为例一张224×224的图片在PyTorch里就是一个形状为(3, 224, 224)的张量第一个维度是通道数红绿蓝后面两个维度是空间尺寸。模型的本质就是把这个三维张量变换成一个二维概率向量(2,)。很多初学者会在这一步纠结为什么不直接让模型输出“1表示猫、0表示狗”问题在于这种硬编码方式无法表达模型的不确定性也不利于梯度下降优化。用概率输出来训练模型可以在“有点确信”和“非常确信”之间有连续过渡损失函数也能告诉模型“离正确答案还有多远”而不是只给一个非黑即白的结果。1.2 CNN不“看”图片它在“扫描”特征卷积神经网络CNN处理图片的方式和人类看画的时候很像。人不会逐像素地读图而是先看边缘、再看纹理、然后看局部形状最后组合成整体判断。CNN也是这个思路卷积核在图像上滑动每个位置的卷积操作相当于在局部区域提取一种特征。浅层卷积核提取的是边缘、颜色块这类低级特征随着层数加深特征逐步组合成眼睛、耳朵、脚掌这类语义特征最后一层全连接则把这些特征映射到“猫”或“狗”的类别得分上。这里有一个关键特性平移不变性。一只猫不管出现在图片左下角还是右上角CNN都应该识别它。卷积通过在整张图上共享同一个卷积核实现了这一点——卷积核在哪个位置扫到的特征都是同一个标准参数不会因为位置变化而不同。这也是CNN比传统全连接网络更适合图像分类的根本原因。1.3 为什么不直接摊平成向量丢进全连接网络这是很多入门者会问的问题。一张224×224×3的图片如果把它直接变成一维向量长度是224*224*3 150528。假设第一个全连接层有1000个神经元光这一层的权重就是150528*1000 ≈ 1.5亿个参数。在普通显卡上这个数量级的参数训练既慢又容易过拟合图像的一点点平移就可能让全连接网络完全认不出来。CNN通过两个手段解决了这个问题局部连接和权值共享。局部连接意味着每个神经元只和上一层的一小块区域相连而不是和全图相连权值共享意味着同一个卷积核在所有位置都用同一套参数。这两点叠加让模型的参数量降了好几个数量级同时保留了对图像局部特征的敏感程度。所以处理图片类任务CNN是比全连接网络更合理的起点这也是这个项目选用CNN的原因。2. 准备工作环境、数据目录、加载管线一处处说清2.1 PyTorch环境不用追求最新版本环境搭建这一步卡住了不少新手。我的建议是不要盲目追求最新版本稳定版本优先。安装PyTorch时先确认电脑有没有可用的NVIDIA显卡。如果要用GPU加速需要先装好显卡驱动再根据显卡驱动支持的CUDA版本选择对应的PyTorch版本如果电脑没有独立显卡或者显卡驱动死活装不上也不用慌这个猫狗分类项目数据量不大纯CPU训练也能跑只是每轮会慢一些。安装命令比较简单一般通过pip install torch torchvision就搞定了也可以去官方提供的版本选择页面把生成的安装命令直接复制下来执行。装好之后在Python里跑一句import torch; print(torch.__version__)能正常打印版本号就说明环境没问题。2.2 数据集怎么组织最省事某个基于PyTorch的图像分类项目数据通常按类别放到文件夹里PyTorch的torchvision.datasets.ImageFolder能根据这个目录结构自动给图片打标签。我建议的目录结构是这样的./data/ ├── train/ │ ├── cat/ │ │ ├── cat_0001.jpg │ │ └── ... │ └── dog/ │ ├── dog_0001.jpg │ └── ... └── val/ ├── cat/ └── dog/ImageFolder的规则是每个子文件夹的名字就是一个类别。比如train/cat/下所有图片都会被标记为0按文件夹名字母序排序cat在dog前面train/dog/下所有图片标记为1。这个0和1的对应关系一定要记清楚后面评估时需要对照。数据集本身怎么来公开渠道下载到的猫狗图片数据集通常有几万张没必要全部用。个人跑项目时我一般会切成训练集和验证集比例大概8:2到7:3。训练集用来更新模型参数验证集用来检验模型在没见过图片上的表现。千万别把训练集和验证集混在一起这会导致评估结果虚高模型真实水平被高估。2.3 ImageFolder和transforms数据进模型前发生了什么ImageFolder读取图片后我们还需要做两项处理统一尺寸和标准化。这就是transforms的职责。我常用的训练预处理和验证预处理分别如下from torchvision import transforms train_transform transforms.Compose([ transforms.Resize(256), transforms.RandomResizedCrop(224), transforms.RandomHorizontalFlip(), transforms.ToTensor(), transforms.Normalize(mean[0.485, 0.456, 0.406], std[0.229, 0.224, 0.225]) ]) val_transform transforms.Compose([ transforms.Resize((224, 224)), transforms.ToTensor(), transforms.Normalize(mean[0.485, 0.456, 0.406], std[0.229, 0.224, 0.225]) ])ToTensor会把图像的像素值从0到255的整数范围缩放到0到1的浮点数并把通道顺序调整成(C, H, W)。Normalize则对每个通道做标准化公式是(x - mean) / std。这里的mean和std用的是公开数据集的统计值简单说就是让输入数据服从均值为0、方差为1的分布这样梯度更新会更稳定。我自己第一次跑的时候偷懒没做Normalize和做了Normalize的结果对比收敛速度差了不是一点半点。注意一个细节训练集的Resize(256)之后还接了随机裁剪RandomResizedCrop(224)这是因为训练阶段要引入随机变化后面讲数据增强而验证集必须保持确定性所以直接用Resize((224, 224))把图片缩放到固定尺寸保证每次评估结果可复现。2.4 DataLoaderbatch、shuffle、num_workers数据准备好之后还需要一个DataLoader把数据打包成批次。batch_size决定每次往模型里塞多少张图shuffleTrue表示每个epoch都打乱顺序避免模型学到固定的数据排列顺序。from torch.utils.data import DataLoader from torchvision import datasets train_dataset datasets.ImageFolder(./data/train, transformtrain_transform) val_dataset datasets.ImageFolder(./data/val, transformval_transform) train_loader DataLoader(train_dataset, batch_size32, shuffleTrue, num_workers4) val_loader DataLoader(val_dataset, batch_size32, shuffleFalse, num_workers4)这里的num_workers是数据加载线程数量Linux/Mac上可以设大一点加快数据读取速度但在Windows上过大的num_workers可能导致内存占用飙升甚至报错。这是新手很容易踩的坑个人建议在Windows上先用num_workers0跑通流程再逐步调大找到内存可接受的临界值。3. 自己搭一个CNN三层卷积结构的参数与维度演算3.1 网络结构选型的思路很多教程一上来就给你一个几十层的预训练网络最后加载个别人训练好的权重。这样确实能拿到高准确率但模型内部发生了什么完全没搞明白。自己从零搭一个小型CNN虽然准确率不会特别夸张但每一步都清清楚楚适合建立对网络结构的直感。针对猫狗分类这种中等大小的数据集三层卷积加一个全连接层就够了。层数再多没有海量数据和大量训练时间很容易陷入过拟合。结构上我加了批量归一化层BatchNorm和Dropout这两个组件对训练的稳定性帮助很大后面详细说。3.2 模型代码从输入到输出逐层算一遍以下是这个项目的核心模型定义import torch import torch.nn as nn class CatDogCNN(nn.Module): def __init__(self): super().__init__() self.features nn.Sequential( nn.Conv2d(3, 32, 3, padding1), nn.BatchNorm2d(32), nn.ReLU(inplaceTrue), nn.MaxPool2d(2), nn.Conv2d(32, 64, 3, padding1), nn.BatchNorm2d(64), nn.ReLU(inplaceTrue), nn.MaxPool2d(2), nn.Conv2d(64, 128, 3, padding1), nn.BatchNorm2d(128), nn.ReLU(inplaceTrue), nn.MaxPool2d(2), ) self.classifier nn.Sequential( nn.AdaptiveAvgPool2d(1), nn.Flatten(), nn.Dropout(0.5), nn.Linear(128, 2) ) def forward(self, x): return self.classifier(self.features(x))输入一张(3, 224, 224)的图片逐层看维度变化第一个卷积层输入通道3输出通道32卷积核尺寸3×3padding1。因为padding1特征图尺寸不变输出为(32, 224, 224)。MaxPool2d(2)把高宽各减半输出(32, 112, 112)。第二个卷积层输入32通道输出64通道输出(64, 112, 112)池化后(64, 56, 56)。第三个卷积层输入64通道输出128通道输出(128, 56, 56)池化后(128, 28, 28)。AdaptiveAvgPool2d(1)不管输入尺寸多大都全局平均池化成1×1准确说是(128, 1, 1)。Flatten把(128, 1, 1)展平成128维向量。Dropout(0.5)训练时随机丢弃一半神经元降低过拟合。最后的Linear(128, 2)把128维特征映射成2个类别得分。用AdaptiveAvgPool2d(1)有一个好处模型对输入尺寸不敏感。哪怕测试图片不是224×224只要大于等于卷积核感受野模型都能跑。这比在最后强行展平固定尺寸然后接全连接的方式灵活很多。3.3 参数量估算与显存经验从零写模型时时刻关注参数量是个好习惯。逐个卷积层统计层输出通道卷核尺寸与输入通道该层参数量含偏置Conv1323×3×3896Conv2643×3×3218496Conv31283×3×6473856Linear2128258三层卷积加最后全连接总参数量大约9万出头。这个体量非常小CPU训练也可以接受同时不会因为模型过复杂而轻易过拟合。如果是两万张图片左右的数据集这个网络规模是合适的如果数据量更大可以把每层通道数翻倍但对应的训练时间也会变长。显存方面batch_size32、224×224输入用这个网络在普通显卡上跑显存占用通常不超过2GB。如果显存不够优先把batch_size降到16或8而不是去改网络结构。因为batch_size直接乘以特征图总量是最影响显存的因素。4. 训练三件套损失函数选型、优化器配置、训练循环写法4.1 CrossEntropyLoss已经帮你做了softmax模型最后输出的两个数字叫做logits可以理解为“类别得分”。得分越高模型越倾向于该类别。但得分本身不是概率需要经过softmax。很多初学者会在模型里写一个nn.Softmax(dim1)然后在训练损失里又用CrossEntropyLoss这是重复操作。PyTorch的nn.CrossEntropyLoss内部其实已经包含了 softmax 操作它直接接收logits和真实标签计算交叉熵损失。训练时不需要在模型里额外加softmax只有在推理阶段想得到概率值时才手动调用torch.softmax(logits, dim1)。这个区别我最早也分不清后来发现验证集准确率一直不对排查半天发现是训练时对输出做了两次softmax数值被压平了梯度传播受到很大影响。训练时的损失函数定义就一行criterion nn.CrossEntropyLoss()4.2 Adam优化器与学习率的搭配优化器的作用是更新模型参数。新手我用Adam它自带自适应学习率对学习率的敏感度比纯SGD低不容易因为学习率设置不好直接炸掉。一个比较稳妥的初始值是lr3e-4配合weight_decay1e-4。import torch.optim as optim optimizer optim.Adam(model.parameters(), lr3e-4, weight_decay1e-4)如果是经验比较丰富想追求更高上限可以用SGD(momentum0.9)但需要更精心地调学习率。Adam和SGD的选择没有一个绝对答案但对入门项目来说Adam更省心收敛也快。学习率是整个训练过程里最敏感的超参数。3e-4对大部分中小型CNN来说算一个“开局保险档位”如果loss下降缓慢可以适当放大到1e-3如果loss出现震荡或上升先把学习率降到1e-4再观察。我自己的习惯是先用3e-4训练20轮看曲线如果后期准确率上不去再加载之前的checkpoint把学习率降到1e-4继续训练。4.3 训练主循环三十个epoch的完整代码训练循环的完整写法如下。这里我把训练和验证都写进去了方便每轮epoch结束后直接看验证集表现。import torch device torch.device(cuda if torch.cuda.is_available() else cpu) model CatDogCNN().to(device) def train_one_epoch(model, loader, criterion, optimizer, device): model.train() total_loss 0.0 correct 0 total 0 for images, labels in loader: images, labels images.to(device), labels.to(device) optimizer.zero_grad() outputs model(images) loss criterion(outputs, labels) loss.backward() optimizer.step() total_loss loss.item() * images.size(0) preds outputs.argmax(dim1) correct (preds labels).sum().item() total labels.size(0) return total_loss / total, correct / total def evaluate(model, loader, criterion, device): model.eval() total_loss 0.0 correct 0 total 0 with torch.no_grad(): for images, labels in loader: images, labels images.to(device), labels.to(device) outputs model(images) loss criterion(outputs, labels) total_loss loss.item() * images.size(0) preds outputs.argmax(dim1) correct (preds labels).sum().item() total labels.size(0) return total_loss / total, correct / total for epoch in range(30): train_loss, train_acc train_one_epoch( model, train_loader, criterion, optimizer, device ) val_loss, val_acc evaluate(model, val_loader, criterion, device) print(fEpoch {epoch1:02d} | train loss: {train_loss:.4f} | ftrain acc: {train_acc:.4f} | val loss: {val_loss:.4f} | fval acc: {val_acc:.4f})几个细节值得注意optimizer.zero_grad()必须在loss.backward()之前否则梯度会在多个batch之间累加得到一个错误的大梯度loss曲线会出现诡异的上升。model.train()和model.eval()要切换。因为BatchNorm和Dropout在训练和推理时的行为不同不切换的话验证结果会不稳定。with torch.no_grad():用于验证阶段跳过梯度计算可以节省大量显存和计算时间。4.4 训练曲线怎么看训练过程中我最关注两条曲线的趋势。train loss每一轮都在下降说明模型在正常学习val loss或val acc的走势则反映了模型的泛化能力。常见情况有train loss下降、val acc同步上升健康状态继续训练就行。train loss下降但val acc停滞或下降过拟合信号需要提前终止或加强正则化。train loss完全不动优化器或学习率有问题先检查代码里有没有忘记zero_grad再把学习率调小试试。val acc一直在70%附近上不去猫狗识别难度不低但也别太慌通常检查数据增强、模型容量和学习率设置都有提升空间。有些读者可能会问为什么不用tensorboard画图刚入门的时候不用急着上那些工具每轮epoch直接打印数字就够了先建立起对数值变化的直觉。5. 过拟合这道必考题数据增强、Dropout和早停的实际操作5.1 一个典型现象训练集98%验证集72%几乎每个第一次完整跑猫狗分类的人都会遇见这个现象训练集准确率一路冲到98%看起来非常顺利可一放到验证集上准确率只有72%。原因并不神秘模型記住了训练集里的一些“作弊特征”。猫狗图片数据集往往来自不同的拍照场景猫的图片可能很多是居家环境狗的图片很多是户外环境。模型发现“只要背景是沙发就大概率是猫”于是它不再真正学习猫和狗本身的区别而是学习了背景的统计规律。这些规律在训练集上有效到了验证集上立刻失效。这背后就是过拟合模型容量足够大时它可以记住训练样本里的噪声而不是抽取可泛化的模式。解决过拟合的手段就是人为地给模型制造一些“困难”让它没办法依赖背景等无关特征。5.2 数据增强代码花式改图但不改标签数据增强的核心思路是不改变图片内容本质猫还是猫、狗还是狗但改变图片的呈现方式位置、亮度、方向、尺寸强迫模型学会对目标形状本身敏感而不是对像素位置敏感。我之前给出的train_transform已经包含了三个增强策略RandomResizedCrop(224)从原图里随机裁剪一块区域并缩放到224×224。这会让“猫在图片正中间”这种固定位置信息失效。RandomHorizontalFlip以50%概率水平翻转图片。猫和狗左右对称翻转不改变类别。Resize(256)预设一个稍大尺寸给随机裁剪留出裁剪空间。还可以继续加from torchvision import transforms train_transform transforms.Compose([ transforms.Resize(256), transforms.RandomResizedCrop(224), transforms.RandomHorizontalFlip(), transforms.ColorJitter(brightness0.2, contrast0.2, saturation0.2), transforms.RandomRotation(degrees15), transforms.ToTensor(), transforms.Normalize(mean[0.485, 0.456, 0.406], std[0.229, 0.224, 0.225]) ])ColorJitter调整亮度、对比度、饱和度模拟不同光线环境RandomRotation在正负15度范围内小幅旋转模拟拍照角度变化。增强的程度要适中转得太狠、颜色改得太过模型反而学不到真实分布loss会变得很怪。验证集不能做增强原因之前说过验证集的作用是公平评估如果它也和训练集一样随机变化每次评估的结果就会忽高忽低不方便比较。5.3 Dropout与早停轻量正则手段Dropout的思想很简单训练时随机“关闭”一部分神经元让模型的每一次前向传播都像一个不同的子网络在训练最后所有子网络的效果叠加相当于做了隐式的模型集成泛化能力更强。代码里我在全连接层前加了nn.Dropout(0.5)推理时Dropout自动失效所有神经元同时工作。早停是另一个成本很低的手段每个epoch记录验证集准确率如果连续5个epoch都没突破历史最高就停止训练并加载之前保存的最佳模型参数。这能避免在过拟合阶段做无用功也能省下不少训练时间。5.4 增强并不是万能的我还想提醒一句数据增强不是加得越多越好。比如RandomRotation(degrees15)对猫狗识别没问题但如果换成旋转角度超过30度、或者加上RandomPerspective(0.5)很多正常角度的测试图反而识别不准因为训练时模型看到的图片已经变形得偏离真实场景了。正确做法是观察验证集变化加了某种增强后验证集准确率下降多半就是这个增强强度过大了要降下来或者去掉。6. 评估与部署从验证集统计到单张图片推理6.1 写一个评估函数准确率、类别准确率、平均置信度第4节已经写了一个evaluate函数返回的是总体准确率。但总体准确率有时候会掩盖细节比如模型对狗的识别率很高、对猫的识别率很低总体数字却还不错。所以评估时我会额外统计每一类的准确率以及模型预测结果的平均置信度。def evaluate_detail(model, loader, device): model.eval() correct_by_class {0: 0, 1: 0} total_by_class {0: 0, 1: 0} with torch.no_grad(): for images, labels in loader: images, labels images.to(device), labels.to(device) outputs model(images) probs torch.softmax(outputs, dim1) preds outputs.argmax(dim1) for cls in [0, 1]: mask labels cls total_by_class[cls] mask.sum().item() correct_by_class[cls] (preds[mask] labels[mask]).sum().item() return { cat_acc: correct_by_class[0] / total_by_class[0], dog_acc: correct_by_class[1] / total_by_class[1], }标签编号对照一下0是猫1是狗。如果发现猫的识别率明显低于狗就要看一眼验证集的猫图片里是否有大量遮挡、过暗或者其他干扰因素这类样本对模型来说本来就难。也可以考虑在训练时做类别平衡采样保证每个batch里猫狗数量大致相当。6.2 单张图片推理的完整流程训练和评估结束后最兴奋的环节就是拿一张新图测试模型。单张推理的完整代码是这样from PIL import Image def predict_image(img_path, model, transform, device): image Image.open(img_path).convert(RGB) image transform(image) image image.unsqueeze(0).to(device) # (1, 3, 224, 224) model.eval() with torch.no_grad(): logits model(image) probs torch.softmax(logits, dim1) prob_cat probs[0, 0].item() prob_dog probs[0, 1].item() pred dog if prob_dog prob_cat else cat return pred, prob_cat, prob_dog这里最关键的一行是image.unsqueeze(0)。模型训练时接收的是四维张量(batch, channel, height, width)单张图片只有三维(channel, height, width)所以在第0维增加一维模拟一个大小为1的batch。这是新手做推理时最容易漏掉的操作漏了会直接报维度不匹配的错误。Image.open(img_path).convert(RGB)统一转成RGB三通道是为了防止出现4通道的PNG图或者单通道灰度图。写推理脚本时最好保留预处理的完整代码并且和训练时保持一致尤其是Normalize的mean和std不一致的话推理结果会明显变差。6.3 模型保存与加载state_dict才是核心训练完成后把模型保存下来是基本操作。这里强烈建议保存state_dict也就是模型的参数字典而不是保存整个模型对象。torch.save(model.state_dict(), catdog_cnn.pth)加载的时候先构造一个和原来结构相同的模型再把参数载入model CatDogCNN() model.load_state_dict(torch.load(catdog_cnn.pth, map_locationcpu)) model.to(device)map_locationcpu适用于在GPU上训练的模型拿到没有显卡的机器上去推理的场景。一开始我图省事直接torch.save(model)结果换环境复现时遇到各种类路径问题后来就规矩地只用state_dict了。保存state_dict还有一个额外好处如果你想在50轮后再接10轮训练直接加载参数、换一个学习率继续即可不需要重新训练。7. 调参过程踩过的坑和最终的稳定配置7.1 路径与系统相关的坑第一个坑是中文路径。ImageFolder读取图片时依赖系统文件系统编码如果数据集路径或图片文件名包含中文部分Windows环境会直接报错或者读不到文件。解决方案很简单所有目录和文件名统一用英文路径里不要出现空格和特殊符号。第二个坑是图片损坏或者格式异常。有些数据集的图片文件不完整Image.open能打开但解码时异常训练到一半就崩。稳妥的办法是在预处理前先跑一个清洗脚本把打不开的图片移除或者用Pillow库统一转换一遍格式。这个步骤看起来繁琐但能省下大量排查时间。7.2 训练异常的排查顺序训练中最怕遇到“loss不降”“准确率震荡”这类问题。我把自己经历过的问题整理成一张表遇到异常先按表排查现象首选排查方向loss一直不降检查学习率是否过大或过小检查是否忘记zero_grad检查标签是否错乱loss变成NaN学习率过大输入数据里有异常的像素值梯度爆炸训练集准确率高、验证集低过拟合加强数据增强、增大Dropout、考虑缩小模型验证集准确率来回震荡学习率太高batch_size太小训练集与验证集分布不一致显存或内存溢出降低batch_size减小图片尺寸降低num_workers一个排查技巧如果loss完全不降先拿出一小批数据比如32张把模型跑一遍前向和反向检查代码是否能完整执行然后单独打印loss数值确认它不是NaN也不为零。用极小数据量先做“冒烟测试”能快速把框架层面的问题暴露出来。7.3 相对稳定的最终配置如果照着本文做我建议先以这个配置为基准起步再根据自己的数据和机器情况调整超参数推荐值输入尺寸224×224训练预处理Resize(256) RandomResizedCrop(224) RandomHorizontalFlipbatch_size32显存小则16优化器Adamlr3e-4weight_decay1e-4损失函数nn.CrossEntropyLoss训练轮数30轮左右验证集连续5轮不升则早停模型保存state_dict形式记录最佳验证集准确率对应的参数在这个配置下公开的猫狗分类数据集通常能做到验证集90%以上的准确率。如果还想要更好的结果可以往两个方向走一是把自定义CNN换成预训练模型加Fine-tune二是从数据层面做更充分的清洗和增强。但在做这两步之前先把这个小型CNN的完整流程吃透价值更大。最后补充一点我自己坚持的习惯每次跑完一个项目把最终的超参数、验证集准确率、踩过的坑都写进项目里一个简单的README文档。下次再遇到类似任务时翻一下记录就能跳过一大半弯路。深度学习调参很大程度靠经验积累把经验存下来比临时翻博客高效得多。