这个系列写到第5篇总算可以聊点真正落地的东西了。前几篇都在跟张量、自动求导这些底层概念打交道很多朋友已经快睡着了。这篇拿图像二分类来扎扎实实走一遍流程数据怎么喂进去网络怎么搭模型怎么训练、怎么评估所有代码我都会贴出来并且尽力解释每一步背后的为什么。为什么把二分类放在基础知识系列里因为它虽然名字听着简单——就是判断一张图属于A还是B——但一个深度学习项目该有的核心环节它一个都不少。你学会了怎么组织数据、怎么写Dataset、怎么定损失函数、怎么读训练日志后面做多分类、目标检测思路和方法论都是直接平移的。这篇适合刚学完PyTorch基本操作、想用一个小项目把知识串起来的新手也适合跑通过一些代码但不太理解其中原理的读者。准备好了就开始有基础的朋友可以跳过部分解释按需取用。1. 把二分类思路捋顺任务定义与方案选型先别急着写代码把任务本身想清楚后面能省一多半的返工时间。1.1 二分类在学什么从决策边界到概率输出二分类的目标很简单给一张图片打标签是猫还是狗是正常还是异常是良性还是恶性。但模型内部真正学的是什么是特征分布和决策边界。拿分豆子来打比方要把红豆和绿豆分开你得观察颜色、形状、大小这些特征然后画一条线把两类豆子分开。CNN做的事类似只不过它不需要你手工设计特征而是自己从原始像素里一层层抽象出有用的东西——边缘、纹理、局部形状再到更全局的模式。那模型最终的输出是什么常见做法是让网络在最后一层输出一个实数这个数经过sigmoid函数被压缩到0到1之间表示属于正类的概率。sigmoid的公式是 σ(z) 1 / (1 e^(-z))z越大输出越接近1z越小越接近0z0时正好输出0.5。这个0.5就成了默认的分界线大于等于0.5判成正类小于0.5判成负类。理解这一点很重要因为后面所有关于损失函数、评估指标的讨论都建立在模型输出的是概率而不是硬标签这个基础上。1.2 方案选型自定义小网络还是迁移学习做图像二分类摆在面前的有两条路从零搭一个小CNN或者用预训练模型做迁移学习。前者适合理解原理模型轻、训练快、调试方便在一套几百张图片的小数据集上完全够用后者适合真实项目和比赛预训练模型已经在几百万张图片上学过通用的视觉特征你只需要把最后一层换掉、微调几下效果往往比从零训练好一大截。我的建议是分阶段来。学习阶段先老老实实自己搭网络搞清楚每一层在干什么、参数量是多是少、全连接层的输入尺寸怎么算。等这些基本功扎实了再动迁移学习你就会明白为什么要替换最后一层、为什么要冻结前面的层——因为你已经知道网络内部长什么样。有朋友一开始就迷信预训练模型结果遇到问题根本不知道往哪个方向排查这种体验其实比从零搭网络还痛苦。2. 数据处理三件套Dataset、DataLoader与数据增强数据这块是新手最容易翻车的地方但真不是技术难而是容易被一堆细节绕晕。我按实际使用顺序拆开讲。2.1 数据目录怎么组织最省心PyTorch官方工具里有一个叫ImageFolder的类专门处理按文件夹分类的图片数据。想让这个工具直接能用最省心的目录结构是这样data/ train/ cat/ 001.jpg 002.jpg dog/ 001.jpg 002.jpg val/ cat/ 003.jpg dog/ 003.jpg每个类名一个文件夹图片放在对应文件夹里。这样做的好处有三个一是ImageFolder可以直接加载不用写任何解析逻辑二是训练集和验证集物理隔离不会因为偷懒把验证集混进训练三是这个结构人在看也很直观排查数据问题时一眼就能找到位置。有些朋友喜欢把所有图片平铺在一个目录再用一个CSV文件记标签这种格式不是不行但等你需要手动检查某张图到底是哪一类时会非常痛苦。2.2 自定义Dataset把图片路径和标签绑在一起ImageFolder虽然方便但真实场景里你总会遇到需要特殊处理的情况比如标签存在数据库里、图片分布在多个目录、需要在加载时做额外的过滤。这时候就得继承torch.utils.data.Dataset写自己的数据集类。核心要搞清楚一件事Dataset类本身不负责批量取数它只定义了一条样本怎么从原始数据变成图片张量标签的配对。下面是一个典型的猫狗二分类Dataset实现import os from PIL import Image from torch.utils.data import Dataset class CatDogDataset(Dataset): def __init__(self, root_dir, transformNone): self.root_dir root_dir self.transform transform self.samples [] # 每个元素是 (图片路径, 标签) for label, cls_name in enumerate(sorted(os.listdir(root_dir))): cls_dir os.path.join(root_dir, cls_name) for fname in os.listdir(cls_dir): if fname.lower().endswith((.jpg, .jpeg, .png)): self.samples.append((os.path.join(cls_dir, fname), label)) def __len__(self): return len(self.samples) def __getitem__(self, idx): img_path, label self.samples[idx] img Image.open(img_path).convert(RGB) if self.transform: img self.transform(img) return img, label这里有几个细节值得展开说。第一个是sorted(os.listdir(root_dir))排序是为了保证类名和标签的映射关系是稳定的如果不排序文件名顺序在不同操作系统上可能不一样模型训练和保存的标签含义就对不上了。第二个是convert(RGB)有些图片虽然是jpg后缀但可能是灰度图或带透明通道的PNG强制转成RGB三通道后面做标准化时才不会报张量维度不匹配的错误。第三个是只把图片路径保存在内存里真正读取图片的动作放在__getitem__里——这样大数据集也不会撑爆内存因为每次只加载一个batch用到的图片。2.3 数据增强与标准化训练集和验证集必须分开处理数据增强是提升模型泛化能力的性价比之王。两张内容完全相同的图片一个做了随机裁剪一个做了水平翻转模型就会把它们当成两个不同的样本来学相当于免费扩大数据集。常见的增强手段各有各的用途我整理了一个表格增强方式作用建议设置RandomResizedCrop模拟不同尺度和位置的拍摄让模型学会关注目标本身尺寸224x224scale取0.8~1.0RandomHorizontalFlip对左右对称的物体有效打折拍摄角度差异概率0.5ColorJitter调整亮度、对比度、饱和度增强光照鲁棒性亮度/对比度±0.2Normalize将像素值标准化到近似零均值、单位方差用ImageNet统计值即可需要注意一个新手极其容易犯的错误训练集做数据增强、验证集也做数据增强。验证集是用来模拟真实预测场景的真实场景里你不可能对一张待预测的图片做随机裁剪和翻转所以验证集只做缩放和标准化不做任何随机操作。缩放到和训练集同样的尺寸是硬性要求否则网络输入尺寸不一致会直接报错标准化用训练集的均值方差是常规做法稳妥起见直接用ImageNet数据集的统计值均值0.485、0.456、0.406标准差0.229、0.224、0.225就足够了。这里顺带解释一下为什么标准化这么重要。原始图片的像素值在0到255之间分布范围很大网络一开始就会收到很大的输入值梯度计算容易不稳定。标准化后数据大致落在-1到1之间所有通道的尺度一致优化器走起来就平稳得多。很多人训练时loss曲线上下乱跳、模型不收敛查到最后发现是忘了做标准化这种坑我见得太多了。3. 动手写一个能跑的CNN网络结构与参数推理模型搭建这块我给出一份足够简单但又是完整可跑的CNN然后逐个部分拆开讲清楚。3.1 经典CNN结构拆解卷积、池化、全连接各司其职一个标准的图像分类CNN由三部分组成卷积层负责提取特征池化层负责降维全连接层负责把特征映射到类别空间。可以这样理解卷积层像一个滑动的放大镜每次盖住图片上一小块区域扫描出这一块的特征池化层则是在说这块区域最明显的特征是什么我帮你记下来把特征图的尺寸压缩一半全连接层在最后把所有局部特征汇总做出全局判断。下面这份代码是一个对新手相对友好的结构三个卷积块加上两个全连接层import torch.nn as nn import torch.nn.functional as F class SimpleCNN(nn.Module): def __init__(self, num_classes2): super().__init__() self.conv1 nn.Conv2d(3, 16, 3, padding1) self.conv2 nn.Conv2d(16, 32, 3, padding1) self.conv3 nn.Conv2d(32, 64, 3, padding1) self.pool nn.MaxPool2d(2, 2) self.fc1 nn.Linear(64 * 28 * 28, 128) self.fc2 nn.Linear(128, num_classes) def forward(self, x): x self.pool(F.relu(self.conv1(x))) x self.pool(F.relu(self.conv2(x))) x self.pool(F.relu(self.conv3(x))) x x.view(x.size(0), -1) x F.relu(self.fc1(x)) x self.fc2(x) return x逐行解释一下。nn.Conv2d(3, 16, 3, padding1)意思是输入通道3RGB、输出通道16、卷积核大小3、填充1。padding设为1是为了让卷积不改变特征图的宽高这样尺寸变化完全由池化层控制算起来省心。每个卷积后面接ReLU激活函数再接一个2x2的最大池化把特征图宽高各减半。连续三个这样的块之后如果输入是224x224的图片尺寸变化是224 - 112 - 56 - 28通道数则是3 - 16 - 32 - 64。3.2 通道数与全连接层输入尺寸的计算很多新手卡在fc1这一行nn.Linear(64 * 28 * 28, 128)里的28*28是怎么来的其实就是你输入图片尺寸经过池化层逐层减半的结果。以224x224为例经过三次池化尺寸变成28x28经过三个卷积层通道数变成64。所以在全连接层之前每个样本的特征是一个64通道、28x28尺寸的特征图展平后就是64×28×28共50176个数。这一行必须手动计算并写死算错了会在跑训练时直接报维度不匹配的错。x.view(x.size(0), -1)这行代码是把一个形状为(batch, 64, 28, 28)的四维张量展平成(batch, 50176)的二维矩阵相当于把每个样本的所有特征值拉平成一条向量喂给全连接层。-1的意思是让PyTorch自动推断这个维度的大小你只指定batch那一维保持不变就行。这里还要给大家吃一个定心丸前面的卷积和池化结构是可以自由改的比如加深、加宽、调整kernel大小但无论怎么改倒数第二层全连接层的输入尺寸都等于最后一层卷积的输出通道数 × 最后一次池化后的特征图宽 × 特征图高。只要你把这三者的计算关系搞清楚任何CNN结构都能自己推理出来不会再被网上各种神奇网络结构劝退。4. 训练循环实战损失函数、优化器与评估指标网络搭好了数据准备好了接下来就是把模型真正跑起来。这个阶段牵涉到的每个选择背后都有讲究。4.1 损失函数选择BCEWithLogitsLoss为什么是默认选项二分类最常用的损失函数是BCEWithLogitsLoss另一个类似的BCELoss在很多地方也被频繁提到但两者之间有本质区别。BCELoss需要你先把网络的原始输出过一层sigmoid再计算损失BCEWithLogitsLoss则把sigmoid和二元交叉熵的计算合并在一起了。为什么要合并因为单独算sigmoid再算log在极端情况下会出现数值不稳定的问题。sigmoid的输出可能非常接近0或1取对数之后变成负无穷或无穷大梯度也就没法算了。PyTorch在BCEWithLogitsLoss内部用了一种更稳的计算方式让logit很大或很小时依然能正确算出损失和梯度。对比项BCELossBCEWithLogitsLoss输入要求需要先手动过sigmoid直接接受原始logits数值稳定性极端输出时可能不稳定内部做了稳定处理推荐程度学习时可以演示原理用实际项目默认选它这里还要特别强调一个输出维度的坑。做二分类网络最后一层可以输出1个节点也可以输出2个节点两种写法对应不同的损失函数处理方式。我的推荐是做纯二分类时模型最后一层输出1个节点也就是把fc2设成nn.Linear(128, 1)然后配合BCEWithLogitsLoss使用。这样语义最清晰输出的这个logit经过sigmoid就是属于正类的概率。训练时标签需要转成float并且加一个维度形状从(batch,)变成(batch, 1)这样才能和模型的输出形状对上。如果你想用2个输出节点也不是不行但那就更自然地配合nn.CrossEntropyLoss使用——这本质上已经是多分类的写法了只是类别数恰好是2。两种方案都能收敛出不错的效果但代码的形态和标签的处理方式从一开始就不同混着用很容易出现维度不匹配的报错。4.2 优化器与学习率设置优化器我的建议简单粗暴新手直接用Adam学习率设1e-3如果发现loss不降或者梯度不太稳定降到3e-4。Adam的好处是自带自适应学习率对不同参数自动调整更新步长省去了手动调学习率的很多麻烦。SGD加上Momentum在某些任务上最终效果可能更好但对新手来说光诊断一个学习率该设多少就够头疼的。学习率设置不当有两种典型症状。学习率太大训练日志里loss很快变成nan或者一开始就剧烈震荡无法收敛学习率太小loss下降得像蜗牛爬训练了好几个epoch准确率还在原地踏步。如果你用的是Adam默认的1e-3是一个比较平衡的起点。训练过程中还可以接一个torch.optim.lr_scheduler.ReduceLROnPlateau当验证集loss几个epoch不降时自动把学习率乘一个0.1继续训练这是一个非常实用的操作比自己盯着曲线手动改要省心。4.3 评估指标准确率之外还要看什么新手最常见的评估误区是只看准确率。如果正负样本比例健康比如各占一半那准确率确实够用但如果数据不平衡比如恶意图片只占1%那模型什么都不预测、全部输出正常准确率也有99%看起来漂亮得离谱实际上毫无用处。所以在二分类任务里精确率、召回率、F1分数和AUC都是必须留意的指标。用混淆矩阵来说清楚这几个指标预测为正类且实际就是正类的叫真正例TP预测为正类但实际是负类的叫假正例FP预测为负类但实际是正类的叫假负例FN。精确率 TP/(TPFP)衡量的是模型预测为正类的样本中有多少猜对了召回率 TP/(TPFN)衡量的是所有正类样本中有多少被找出来了。F1是两者的调和平均样本不平衡时比准确率靠谱得多。AUC则是另一个角度它衡量的是模型对正类和负类样本打分排序的能力不依赖于你选哪个阈值所以在类别不平衡下特别有参考价值。评估代码不复杂关键是养成每个epoch都同时算一下验证集准确率、F1和AUC的习惯from sklearn.metrics import precision_recall_fscore_support, roc_auc_score # 收集所有验证集预测结果后计算 auc roc_auc_score(all_labels, all_probs) precision, recall, f1, _ precision_recall_fscore_support( all_labels, all_preds, averagebinary )4.4 完整训练循环训练一个epoch加验证下面是一段可以直接用的训练函数。我就按照最后一层输出1个节点、配合BCEWithLogitsLoss的方案来写import torch from tqdm import tqdm def train_one_epoch(model, dataloader, criterion, optimizer, device): model.train() running_loss 0.0 correct 0 total 0 for images, labels in tqdm(dataloader): images images.to(device) labels labels.to(device).float().unsqueeze(1) # (batch,) - (batch, 1) optimizer.zero_grad() outputs model(images) # (batch, 1) loss criterion(outputs, labels) loss.backward() optimizer.step() running_loss loss.item() * images.size(0) preds (torch.sigmoid(outputs) 0.5).float() correct (preds labels).sum().item() total labels.size(0) return running_loss / total, correct / total这里关键的一行是labels labels.to(device).float().unsqueeze(1)。原始标签是0和1的整型张量形状是(batch,)而模型输出的形状是(batch, 1)两者形状对不上。转成float是为了让BCEWithLogitsLoss正确计算交叉熵加维度是为了匹配输出的形状。这个细节写的时候很容易忽略报错时会提示Expected input batch_size to match target size找到了就迎刃而解。验证函数稍有不同。需要注意两点一是必须调用model.eval()二是要在torch.no_grad()下运行。model.eval()会让Dropout层关闭、BatchNorm层使用全局统计量保证验证时模型的行为是确定的torch.no_grad()则告诉PyTorch不需要记录梯度省内存也省时间。def evaluate(model, dataloader, criterion, device): model.train(False) # 等价于 model.eval() val_loss 0.0 correct 0 total 0 all_labels [] all_probs [] with torch.no_grad(): for images, labels in dataloader: images images.to(device) labels labels.to(device).float().unsqueeze(1) outputs model(images) loss criterion(outputs, labels) val_loss loss.item() * images.size(0) probs torch.sigmoid(outputs) preds (probs 0.5).float() correct (preds labels).sum().item() total labels.size(0) all_labels.extend(labels.cpu().numpy().flatten()) all_probs.extend(probs.cpu().numpy().flatten()) return val_loss / total, correct / total, all_labels, all_probs主训练循环就清晰了device torch.device(cuda if torch.cuda.is_available() else cpu) model SimpleCNN() model model.to(device) criterion nn.BCEWithLogitsLoss() optimizer torch.optim.Adam(model.parameters(), lr1e-3) num_epochs 30 best_val_acc 0.0 for epoch in range(num_epochs): 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}/{num_epochs} | train_loss: {train_loss:.4f} | train_acc: {train_acc:.4f} | val_loss: {val_loss:.4f} | val_acc: {val_acc:.4f}) if val_acc best_val_acc: torch.save(model.state_dict(), best_model.pth) best_val_acc val_acc保存模型时只保存state_dict而不是整个模型对象这是一个好习惯。state_dict是模型的参数和缓冲区的轻量级字典加载时只需要先创建同样结构的模型实例再调用load_state_dict就行。这样保存的文件小、跨版本兼容性好、代码也清晰。5. 新手必看训练不收敛与过拟合的排查实录这段内容是希望帮你省下几个晚上的排查时间。我把自己踩过的坑和帮别人看过的报错整理成一个速查表。5.1 模型不收敛先按这个清单排查模型不收敛的表现是loss不下降、准确率徘徊在50%附近、甚至直接nan。原因通常不复杂按照下面这个顺序排查比盲目调整网络结构有效得多症状常见原因解决方案loss一开始就nan学习率过大把学习率降到1e-4重试loss始终不降数据没有标准化或标签与输出形状不匹配检查Normalize和unsqueeze准确率一直50%左右模型可能根本没学到特征或数据标签反转了可视化验证集预测结果训练正常但loss震荡剧烈学习率偏大或batch_size偏小降学习率或增大batch标签搞反这个问题非常隐蔽。我用过一个数据集下载下来发现文件夹名字和内容对不上模型训练了十几个epoch还跟瞎猜一样最后是随机抽了十几张训练图片人工检查才发现的。所以任何新数据集上手第一件事一定是抽样可视化几张图片看着图、对照着标签检查一遍这个动作花不了五分钟但能省下后面几十个小时的无效训练。5.2 过拟合train_acc高但val_acc上不去过拟合的典型信号是训练集准确率一路涨到95%以上验证集却卡在70%左右不动两者差距越拉越大。这说明模型在训练集上死记硬背并没有学到可泛化的特征。应对手段有几个按性价比排序第一个是数据增强这是最推荐的。前文提到的随机裁剪、水平翻转、颜色抖动一套组合拳就能让模型见过的样本形态丰富很多泛化能力肉眼可见地提升。第二个是加Dropout在fc1后面加一层nn.Dropout(p0.5)训练时随机丢弃一半的全连接神经元迫使模型不依赖单个节点。第三个是早停让验证集loss不再下降时就停止训练防止后面纯粹在过拟合训练集。还有个隐藏选项是weight decay也就是L2正则优化器里设weight_decay1e-4轻微惩罚大权重也能起到抑制作用。需要注意的是Dropout和BatchNorm在训练和验证中的行为是不同的前者训练时随机丢弃、验证时全量保留后者训练时用batch统计量、验证时用全局统计量。这也是为什么每次切换训练和验证状态时都必须在model.train()和model.eval()之间明确切换漏掉这一步验证集的结果完全不可信。5.3 类别不平衡别让准确率骗了你二分类场景里十有八九会遇到数据不平衡的问题。正样本稀少时模型很容易偷懒把所有样本都预测成多数类这样准确率看着不错实际问题一揪一个准。应对办法分两条线评估线上别只看准确率以AUC、F1为主训练线上让模型更重视少数类样本。PyTorch里最简单的办法是用BCEWithLogitsLoss的pos_weight参数它会给正类的损失乘以一个权重相当于人为提升正类样本的地位。权重的粗略设定是负样本数除以正样本数比如正样本100张、负样本900张那pos_weight可以设成9.0pos_weight torch.tensor([9.0]).to(device) criterion nn.BCEWithLogitsLoss(pos_weightpos_weight)另一种思路是从采样角度动手对数量少的类别做过采样即重复使用少数类的样本或者对数量多的类别做欠采样随机丢掉一部分多数类样本。数据量允许的前提下过采样比丢数据更稳妥因为信息总量只增不减。5.4 训练日志的判读技巧loss曲线比单点数字更有信息量训练时不要只盯着终点的准确率要养成看曲线的习惯。Loss曲线能告诉你大量隐藏信息如果loss呈现平稳下降、波动很小说明学习率和batch_size配得不错如果loss像心电图上蹿下跳多半是学习率偏大试着除以10如果loss在某个值上长期横盘模型可能陷入了局部平坦区域需要调大学习率、换优化器或者干脆改网络结构。还有一个容易忽略的点训练loss和验证loss之间的差距变化趋势。最好的人工监视时机是训练初期——训练loss下降、验证loss同时下降说明模型在良性学习训练loss下降但验证loss开始拐头向上这就是过拟合开始的信号此刻应该考虑早停、增强或加Dropout两个loss从一开始就不降问题基本都在数据准备环节而非模型结构。把这条作为日常习惯时间久了你会发现自己对模型的判断力明显上来了。另外提一种很少人教但非常推荐的调试方式训练完之后抽一批验证集图片把模型预测的概率、真实标签、图片本身放在一起绘制出来。你会直观地看到模型对哪些图片模棱两可、对哪些图片自信满满但实际是错的这种视觉反馈比任何loss曲线都来得真实。我遇到过一张模糊到人类都难以分辨的图片模型给了0.51的概率纠结半天后判错了——看完这个case你就不会再轻易相信某个模型准确率很高这种话了。我自己最早学二分类的时候最大的坑是看了一堆理论还是不知道第一行代码从哪里写起后来干脆找了一套小数据从数据目录到Dataset到训练循环完完整整跑通一遍很多概念是跑完之后才真正融会贯通的。所以这篇里我刻意放了很多完整的代码片段目的就是让你能照着敲一遍、改一遍、跑起来。等你亲手跑通一个猫狗二分类后面再学ResNet、再学目标检测心态会完全不一样——因为你已经知道这些东西的底座是怎么工作的了。最后分享一个实用小习惯所有实验参数学习率、batch_size、数据增强方案、最终结果随手记在一个文本文件里。这个习惯会在你某天发现上次跑出来挺好但忘了用什么参数时救你一命。