☰
PyTorch猫狗二分类实战:从数据增强到迁移学习全流程解析
2026/10/14 6:25:16 网站建设 项目流程

做完这个猫狗二分类项目之后,我最大的感受是:它看似只是一个入门级Demo,但把数据加载、预处理、模型选型、训练调参、评估诊断这一整条链路全部走通了。很多人卡在“看懂了理论但跑不出结果”这一步,其实不是模型多难,而是细节没处理到位。这篇文章我就用PyTorch把猫狗二分类从零到落地拆开讲,把每一步的“为什么这么做”说清楚,附上可直接抄走的代码和经验教训。

适合谁看?如果你刚开始学深度学习,想用一个具体任务把CNN、迁移学习、数据增强这些概念串起来;或者你已经跑过不少教程,但自己独立做分类项目时总是过拟合、精度上不去,那么这篇内容会很对你胃口。

1. 项目拆解:猫狗二分类到底在解决什么问题

1.1 任务本质与核心难点

猫狗二分类的本质是一个二分类图像识别任务:给定一张图片,模型输出它是猫还是狗的概率。用数学语言描述,就是学习一个映射 f: 图像像素矩阵 → {0, 1},其中0代表猫、1代表狗(或者反过来,看你怎么定义标签)。

听起来很简单,但实际做起来有几个坎:

第一,猫和狗在外形上有大量重叠特征。比如都是四条腿、都有毛发、都有耳朵眼睛,甚至有些照片里猫和狗的姿态几乎一样。模型不能靠“看到一个特征就判断”,必须学习到足够有区分力的特征组合。

第二,图片的拍摄条件极其多样。光线强弱、角度、遮挡、模糊、背景杂乱、宠物是否在动……这些都会干扰模型。如果训练数据里没有覆盖这些情况,模型一到真实场景就翻车。

第三,数据集本身可能有噪声。有些标注是错的,有些图片里猫狗同框,有些图片分辨率极低。这些脏数据会拉低模型上限,需要提前清洗或者让模型有足够的鲁棒性去容忍。

理解了这三个难点,你就能明白后面每一步操作都是有明确目的的:数据增强是为了解决拍摄条件多样性的问题,迁移学习是为了解决小数据集下特征学习不充分的问题,加入Dropout和正则化是为了解决模型记住噪声而不是学习本质规律的问题。

1.2 技术选型:为什么是 PyTorch 加预训练模型

现在做图像分类,PyTorch基本是首选。原因很实在:

  • 动态计算图让调试变得直观,打印中间张量、断点调试都很顺手;
  • torchvision内置了大量经典模型和预训练权重,几行代码就能加载ResNet、VGG、EfficientNet;
  • 生态成熟,DataLoader、TensorBoard、AMP混合精度这些工具都开箱即用。

模型选型上,我强烈建议不要从零训练一个CNN。不是说从零训练不行,而是对于猫狗二分类这个任务,公开数据集规模通常在2万到5万张左右,从零训练的CNN很容易过拟合,而且训练时间很长。更聪明的做法是加载在ImageNet上预训练好的模型,把它的特征提取能力迁移过来,只需要替换最后的全连接层。

理解迁移学习的一个生活化类比:预训练模型就像一个已经会识别各种纹理、边缘、形状的“实习生”,你只需要教他“猫和狗的特征组合规律”,而不是从“什么是像素”“什么是边缘”开始教。这能省下大量时间和算力。

依据我的经验,ResNet18是这个任务性价比最高的选择:参数量适中(约1100万),单张显卡几分钟就能跑一个epoch,精度却足以达到95%以上。后面所有代码都基于ResNet18展开。

2. 数据准备与预处理:决定训练成败的第一关

2.1 数据集划分与目录组织

很多人拿到数据集后直接一股脑丢进DataLoader,这是个隐患。正确的做法是先把数据划分成三个互不重叠的子集:训练集、验证集、测试集。

三者分工不同:

  • 训练集:用于更新模型参数;
  • 验证集:用于调超参数、选模型、判断是否过拟合,每次epoch结束后都要在它上面评估;
  • 测试集:最终衡量模型泛化能力的“裁判”,整个训练过程中绝对不能碰它。

我习惯按 8:1:1 的比例划分。具体操作上,先把数据按文件夹组织好,再用脚本分开:

import os import shutil import random from pathlib import Path # 假设原始数据位于 raw/ 下,cat/ 和 dog/ 两个子文件夹 raw_dir = Path('raw') train_dir = Path('data/train') val_dir = Path('data/val') test_dir = Path('data/test') for split_dir in [train_dir, val_dir, test_dir]: (split_dir / 'cat').mkdir(parents=True, exist_ok=True) (split_dir / 'dog').mkdir(parents=True, exist_ok=True) random.seed(42) for animal in ['cat', 'dog']: images = list((raw_dir / animal).glob('*.jpg')) random.shuffle(images) n = len(images) n_train = int(n * 0.8) n_val = int(n * 0.1) for i, img in enumerate(images): if i < n_train: dest = train_dir / animal / img.name elif i < n_train + n_val: dest = val_dir / animal / img.name else: dest = test_dir / animal / img.name shutil.copy(img, dest)

注意一个细节:划分前一定要固定随机种子(我这里固定为42),否则每次运行划分结果都不同,结果就没法复现,也不能确定是模型改进还是数据变化带来的精度提升。

2.2 数据增强:让模型见过更多世面

猫狗数据里图片尺寸五花八门,直接喂给模型会出问题。ResNet18要求输入224×224,所以必须先做Resize。但如果你只是把所有图片硬拉到224×224,会破坏长宽比,导致宠物变形,模型学到错误的空间关系。

标准做法是先用Resize把短边缩放到256,再RandomResizedCrop随机裁剪出224×224,这样既统一了尺寸,又模拟了不同视角和缩放级别。配合随机水平翻转,因为猫狗照片左右对称是合理的,不会改变分类含义。把这些变化组合起来,等于用1张图变出了多种“拍摄版本”,显著缓解过拟合。

下面是训练集和验证集/测试集不同的transform配置,这也是一个容易踩坑的点:

from torchvision import transforms train_transform = transforms.Compose([ transforms.Resize(256), transforms.RandomResizedCrop(224), transforms.RandomHorizontalFlip(), transforms.ColorJitter(brightness=0.2, contrast=0.2), transforms.ToTensor(), transforms.Normalize(mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225]) ]) val_transform = transforms.Compose([ transforms.Resize(256), transforms.CenterCrop(224), transforms.ToTensor(), transforms.Normalize(mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225]) ])

Normalize的mean和std用的是ImageNet统计值,因为预训练模型就是在这些统计值上训练的,输入分布保持一致模型才能正常工作。这是个很容易忽略但极其重要的细节:加载预训练模型,却没有用对应的归一化参数,精度可能直接掉5个百分点。

在实际项目里我吃过这个亏。第一次做迁移学习时直接用的mean=0.5、std=0.5,结果模型在训练集上表现还行,验证集上一直上不去,排查了半天才发现是归一化参数和预训练权重不匹配。

ColorJitter可加可不加,但在猫狗任务里我建议加上,因为真实照片的色温、亮度变化很大。不过幅度别太大,我这里的0.2是试出来的安全值,再大的话颜色失真反而干扰学习。

2.3 DataLoader 封装与批次设计

数据准备完就要封装成DataLoader。这里batch size的选择有讲究:太小的话梯度估计不稳定,训练震荡;太大会把显存撑爆。在常见的8GB~12GB显存环境下,batch size取32是稳妥选择。如果你显卡好一点,可以试试64,通常训练更快且精度不会有明显损失。

workers这个参数也值得说一下。它控制数据加载用的子进程数,经验值是显卡或CPU核数的一半左右。Windows用户要小心,worker数超过一定阈值会报BrokenPipeError,设成0或2最省心。

from torch.utils.data import DataLoader from torchvision import datasets train_dataset = datasets.ImageFolder(train_dir, transform=train_transform) val_dataset = datasets.ImageFolder(val_dir, transform=val_transform) test_dataset = datasets.ImageFolder(test_dir, transform=test_transform) train_loader = DataLoader(train_dataset, batch_size=32, shuffle=True, num_workers=4, pin_memory=True) val_loader = DataLoader(val_dataset, batch_size=32, shuffle=False, num_workers=4, pin_memory=True) test_loader = DataLoader(test_dataset, batch_size=32, shuffle=False, num_workers=4, pin_memory=True)

ImageFolder会自动根据子文件夹名生成类别标签,省去了手动写标签的麻烦。训练集必须shuffle=True,否则每个epoch内数据顺序不变,模型会学到批次间的顺序相关性。验证集和测试集不需要shuffle,因为评估结果不应该受数据顺序影响。

3. 模型构建与训练实现:让代码跑起来的核心环节

3.1 迁移学习:加载预训练权重并替换分类头

加载ResNet18预训练模型,只需要两行代码,然后用一个具有2个输出的全连接层替换最后原本的1000类分类层:

import torch import torch.nn as nn from torchvision import models device = torch.device('cuda' if torch.cuda.is_available() else 'cpu') model = models.resnet18(weights=models.ResNet18_Weights.IMAGENET1K_V1) num_features = model.fc.in_features model.fc = nn.Linear(num_features, 2) model = model.to(device)

这里有个细节值得展开:替换分类头时,model.fc.in_features先取出原始全连接层的输入维度(ResNet18是512),再用它构建新的输出层。这个写法比硬编码512更通用,以后换ResNet34、ResNet50都不用改代码。

关于冻结与不冻结问题,我建议项目初期让整个网络全部参与训练(不冻结任何层)。因为预训练模型是在ImageNet上学的,那1000类里并没有“猫”和“狗”的专门语义区分(虽然类别里有相关条目,但特征并不专门为此服务)。如果你只训练最后一层,相当于只让一个“实习生”微调判断标准,特征提取部分还是按照ImageNet的理解在做,精度上限偏低。

我的做法是:前几个epoch用较大学习率训练全网络,后面再用小学习率精调。具体会在下一节讲。

3.2 损失函数与优化器选择:数学与直觉

二分类问题的标准损失函数是交叉熵损失(CrossEntropyLoss)。很多教程会建议用BCEWithLogitsLoss,这在逻辑上没有问题,但CrossEntropyLoss是更省事的选择——它内置了Softmax,模型输出的2个原始分数会被转成两个类别的概率分布,然后与one-hot标签计算损失。

CrossEntropyLoss支持直接传入整数标签(0表示猫、1表示狗),不需要自己手动one-hot,也很适合配合ImageFolder自动生成的标签。它的数学含义是:真实分布与预测分布之间的KL散度,数值越小代表预测越接近真实。

优化器选择上,Adam是通用首选,因为它自适应调整每个参数的学习率,对初始学习率的敏感度低,收敛速度快。SGD配合动量在训练充分时会得到更平滑的收敛结果,但需要更精细的学习率调度,新手容易翻车。

我推荐这样一个组合配置,运行稳定且效果良好:

import torch.optim as optim criterion = nn.CrossEntropyLoss() optimizer = optim.Adam(model.parameters(), lr=1e-4, weight_decay=1e-4) scheduler = optim.lr_scheduler.StepLR(optimizer, step_size=5, gamma=0.1)

这个配置里的参数都是有考量的:

  • lr=1e-4:迁移学习场景下,这个学习率不会破坏预训练学到的好特征,又足够让新分类头快速适应;
  • weight_decay=1e-4:这就是L2正则化,防止权重过大,对过拟合有抑制作用;
  • StepLR:每5个epoch学习率乘0.1,后期用小学习率让模型在最优解附近精细收敛。

3.3 训练循环与超参数:每个epoch都在做什么

训练循环是代码的核心,写清楚每一段逻辑,你才能知道训练中每一步在干什么:

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 = images.to(device) labels = labels.to(device) optimizer.zero_grad() outputs = model(images) loss = criterion(outputs, labels) loss.backward() optimizer.step() total_loss += loss.item() * images.size(0) _, predicted = torch.max(outputs, 1) correct += (predicted == labels).sum().item() total += labels.size(0) avg_loss = total_loss / total accuracy = correct / total return avg_loss, accuracy

这里有个新手容易忘记的点:optimizer.zero_grad()必须在loss.backward()之前调用。因为PyTorch的梯度是累加的,如果不清零,每个batch的梯度会叠加到前一个batch上,导致更新方向完全错误。细节虽小,但影响巨大。

验证循环逻辑上类似,但要加上model.eval()并配合torch.no_grad():

def validate(model, loader, criterion, device): model.eval() total_loss = 0.0 correct = 0 total = 0 with torch.no_grad(): for images, labels in loader: images = images.to(device) labels = labels.to(device) outputs = model(images) loss = criterion(outputs, labels) total_loss += loss.item() * images.size(0) _, predicted = torch.max(outputs, 1) correct += (predicted == labels).sum().item() total += labels.size(0) return total_loss / total, correct / total

model.eval()和torch.no_grad()两者缺一不可。前者会关闭Dropout、BatchNorm使用训练时的统计量,保证验证结果是模型真实推理水平;后者告诉PyTorch不需要计算梯度,省显存且加速。有的同学只在其中一个地方加了,结果要么显存溢出,要么验证精度偏高、跟测试对不上。

整体训练流程固定跑20个epoch,每个epoch结束后打印训练和验证的损失、精度。如果验证精度连续3个epoch不再提升,就保存当前权重,这样可以避免覆盖掉最佳模型:

best_val_acc = 0.0 for epoch in range(20): train_loss, train_acc = train_one_epoch( model, train_loader, criterion, optimizer, device) val_loss, val_acc = validate( model, val_loader, criterion, device) print(f'Epoch {epoch+1:02d} | ' f'Train Loss: {train_loss:.4f} | Train Acc: {train_acc:.4f} | ' f'Val Loss: {val_loss:.4f} | Val Acc: {val_acc:.4f}') if val_acc > best_val_acc: best_val_acc = val_acc torch.save(model.state_dict(), 'best_model.pth') scheduler.step()

4. 评估与常见问题排查:从95%到99%的那几步

4.1 评估指标:只看Accuracy是不够的

训练完成后,很多人习惯只报告准确率。但猫狗二分类数据均衡的话,准确率能说明问题;可一旦数据不平衡,准确率就会骗人。举个极端例子:如果数据里90%是狗,模型把所有图片都判成狗也有90%的准确率,但这个模型毫无用处。

所以我会额外计算召回率(Recall)、精确率(Precision)和F1分数,再看混淆矩阵:

from sklearn.metrics import confusion_matrix, classification_report import numpy as np def evaluate_model(model, loader, device): model.eval() all_preds = [] all_labels = [] with torch.no_grad(): for images, labels in loader: images = images.to(device) outputs = model(images) _, predicted = torch.max(outputs, 1) all_preds.extend(predicted.cpu().numpy()) all_labels.extend(labels.numpy()) cm = confusion_matrix(all_labels, all_preds) print('混淆矩阵:') print(cm) print(classification_report(all_labels, all_preds, target_names=['cat', 'dog']))

对于猫狗项目,混淆矩阵里最有价值的信息是:模型会把哪种动物误判成另一种。我做完之后发现,误判的图片里相当一部分是“猫蜷缩成球状”的照片——这种姿态下猫的外形轮廓和狗非常接近。这说明模型并没有真的理解“猫和狗的区别”,而是在学习“轮廓和纹理的组合规律”。想继续提升,可以从这里入手:给模型看更多正脸、动态姿态的照片。

4.2 过拟合:训练精度高但验证精度低的诊断与对策

过拟合是猫狗分类中最常见的问题。判断标准很简单:训练集精度持续上升接近100%,但验证集精度停滞甚至下降,同时两者差距越拉越大。

我在实践中最先遇到的过拟合信号是:第8个epoch时训练精度已经到98%,验证精度却卡在89%。排查思路是逐一检查以下环节。

第一,确认数据增强是否生效。检查代码是否真的把transform应用到了训练集,一个常见的低级错误是把验证集的transform错接到了训练集上。

第二,加Dropout和正则化。在替换分类头时,可以多加一层Dropout,虽然ResNet18本身在分类头前有BatchNorm和Global Average Pooling,但显式加一个Dropout仍然有助于稳定分类层的训练:

model = models.resnet18(weights=models.ResNet18_Weights.IMAGENET1K_V1) num_features = model.fc.in_features model.fc = nn.Sequential( nn.Dropout(0.3), nn.Linear(num_features, 2) )

第三,降低学习率或增加weight_decay。如果过拟合不严重,只调大weight_decay到1e-3通常就有效。

第四,如果以上都没效果,那就是数据量真的太少。此时可以考虑收集更多数据,或者用更强的数据增强策略,例如增加RandomRotation(±15度)和RandomAffine平移。

提示:不要一上来就怀疑模型结构。在猫狗二分类这个任务上,ResNet18的容量绝对够用,过拟合几乎不可能是因为“模型太笨”,而更可能是数据加载、transform配置、超参数里的某个环节出了偏差。

4.3 精度上不去的排查清单与实战经验

我在独立做完几轮训练后总结了一份排查清单,遇到问题按顺序逐条检查,效率很高。

现象排查方向常见原因与对策
Loss不下降学习率是否过大/过小尝试lr=1e-3~1e-5区间,观察前几个batch的损失变化
训练和验证精度都很低归一化参数是否匹配确认Normalize的mean/std与预训练模型一致
训练精度高、验证精度极低过拟合加强数据增强、增大weight_decay、加Dropout
验证精度正常、测试精度暴跌数据划分泄露检查测试集是否混入训练/验证数据,或预处理不一致
训练Loss震荡剧烈batch size太小或lr过大增大batch size、降低lr、加入学习率warmup
显存溢出batch size过大减半batch size,或图片尺寸从224降到192

除了表格里这些,我还有一个经验值得单独说:记录每个epoch的推理结果,不要只看数字,肉眼看一下错分的图片。有一次我发现模型把大量白底照片里的猫判成狗,排查后发现是ColorJitter的brightness参数设得过大,训练集里出现了过曝的“假照片”,模型学到了“亮=狗”这种错误关联。只盯数值指标永远发现不了这类问题。

def save_misclassified(model, loader, device, save_dir='errors'): import os os.makedirs(save_dir, exist_ok=True) model.eval() count = 0 with torch.no_grad(): for images, labels in loader: images_cpu = images.to('cpu') outputs = model(images.to(device)) _, predicted = torch.max(outputs, 1) for i in range(len(labels)): if predicted[i] != labels[i]: # 反归一化回可显示的RGB范围 img = images_cpu[i].permute(1, 2, 0).numpy() img = (img * torch.tensor([0.229, 0.224, 0.225]) + torch.tensor([0.485, 0.456, 0.406])).numpy() img = (img * 255).clip(0, 255).astype('uint8') save_name = os.path.join( save_dir, f'pred_{predicted[i].item()}_true_{labels[i].item()}_{count}.jpg') # 这里用PIL保存 from PIL import Image Image.fromarray(img).save(save_name) count += 1

这个脚本的价值在于:它能输出一个“错误集”,我从中总结出模型容易搞混的图片类型,再针对性补充训练数据或调整增强策略,比盲目调参有效得多。

5. 边界情况处理:让模型推理更稳健

训练完成不代表项目结束。实际部署或测试时,你会遇到各种训练时没见到的情况。比如模型收到单通道灰度图、某个方向旋转了90度的图片、或者图片里同时有猫和狗。

几个关键的处理建议:

  • 输入通道处理:如果收到灰度图,用PIL的convert('RGB')在数据加载时统一转成三通道;
  • 渲染尺寸处理:类别预测对模糊、小尺寸图片一般还算稳定,但要确保推理时的transform和验证时完全一致,尤其是Resize和CenterCrop的参数;
  • 置信度阈值:如果输出概率接近0.5,说明模型不确定。实际应用里可以设置置信度阈值,比如低于0.6就不作判断,返回“无法确认”,这比硬猜一个答案要可靠得多。
import torch.nn.functional as F def predict_single(image, model, transform, device, threshold=0.6): model.eval() image = transform(image).unsqueeze(0).to(device) with torch.no_grad(): outputs = model(image) probs = F.softmax(outputs, dim=1) confidence, pred = torch.max(probs, 1) if confidence.item() < threshold: return -1, confidence.item() # -1 表示不确定 return pred.item(), confidence.item()

这样一个简单的置信度判断,在实际体验里能把误报率降下来不少。特别适合后续做小工具、小应用的人参考。

6. 下一步扩展:把二分类延伸成多分类与细粒度任务

猫狗二分类做完之后,如果你愿意再往前走几步,它会变成一个很好的跳板。

最直接的方向是多分类。把分类头从nn.Linear(512, 2)改成nn.Linear(512, N),把数据集换成多类别的动物图片,整个训练流程几乎不用改。你会重新理解交叉熵损失的定义——它天然支持多类别,二分类只是特例。

再进一步是细粒度分类,比如区分不同品种的猫,或者柯基和柴犬这种同属犬科但外观差异大的任务。这才是真正挑战模型能力的场景。细粒度分类通常需要更大的输入分辨率(如384×384),更强的模型(ResNet50以上),以及更精细的数据增强策略,比如随机裁剪局部区域让模型关注耳朵、尾巴、花纹等判别性部位。

如果你对模型可解释性感兴趣,还可以把训练好的模型接上Grad-CAM可视化,看看模型到底在看图片的哪些区域做判断。我做过一次,发现模型对“脸”的关注度极高,而对身体区域几乎不看。这说明在猫狗识别里,面部特征是决定性信号,与人类判断直觉基本一致。这类可视化分析无论对调试还是写报告都很有价值。

我的建议是:把这次猫狗二分类当做一个完整项目去复盘,而不是当成一个“跑通的作业”。把数据增强的每一条、超参数的每一次调整、错误图片的每一类都记录下来,你会发现这些经验在下一个视觉项目里几乎全部可以复用。

最后再分享一个小技巧:训练过程中的所有关键指标记得用TensorBoard或简单日志文件记录下来。隔几天回头对比不同版本模型时,这些记录能帮你快速定位到底是数据变了、代码变了还是参数变了。这个习惯在长期项目中省下的时间,远超你当时记录的那几分钟。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询