PyTorch实战:用猫狗分类打通DNN、CNN与RNN,轻松入门NLP
1. 从猫狗分类任务理解NLP入门的第一课很多人看到自然语言处理和猫狗分类放在一起会觉得奇怪——猫狗图片分类不是计算机视觉的经典任务吗跟NLP有什么关系这个问题我在刚开始接触这个作业的时候也困惑过。实际上这个作业的设计逻辑非常清晰用图像分类这个肉眼可见、结果直观的任务来打通深度学习中最核心的三类网络结构——全连接网络DNN、卷积神经网络CNN和循环神经网络RNN——的代码实现和训练流程。当你把这三套代码跑通之后再迁移到文本分类、情感分析、序列标注等NLP任务上会发现底层框架几乎一模一样区别只在于数据预处理和输入张量的形状。这个作业适合谁如果你已经看完了PyTorch的基础教程知道Tensor、Dataset、DataLoader、nn.Module这些概念但还没有完整地从零写过一遍训练循环那这个任务就是为你准备的。它不要求你理解反向传播的数学推导也不要求你手写卷积核但它要求你真正把数据加载、模型定义、损失计算、梯度更新、验证评估这一整条链路跑通。我见过太多人卡在教程看懂了但自己写就报错的阶段根本原因就是缺少这种端到端的完整练习。在展开具体实现之前先把这个作业的整体框架说清楚。数据集是猫狗二分类图片通常有几千到上万张不等分为训练集和验证集。三类模型分别对应三种不同的建模思路DNN把图片展平成一维向量用全连接层做分类CNN利用卷积核提取局部空间特征是图像任务的标准方案RNN把图片按行或按列看作序列用循环单元处理时序依赖。虽然RNN在图像任务上不是最优解但用它来跑一遍可以帮你理解序列建模的思维方式这对后续做文本任务至关重要。提示这个作业的核心价值不在于刷多高的准确率而在于理解不同网络结构对同一份数据的处理方式差异。建议先把三类模型都跑通再回头对比它们的参数量、训练速度和最终效果。2. 数据管道的搭建从原始图片到可训练张量2.1 目录结构与数据集组织拿到猫狗图片数据集之后第一件事不是急着写模型而是把目录结构整理清楚。我推荐的组织方式是这样的dataset/ ├── train/ │ ├── cat/ │ │ ├── cat_001.jpg │ │ ├── cat_002.jpg │ │ └── ... │ └── dog/ │ ├── dog_001.jpg │ ├── dog_002.jpg │ └── ... └── val/ ├── cat/ └── dog/这种按类别分文件夹的结构可以直接配合torchvision.datasets.ImageFolder使用省去自己写标签映射的麻烦。ImageFolder会自动扫描每个子文件夹把文件夹名作为类别标签按字母顺序编号。所以cat对应0dog对应1这个映射关系要记清楚后面看混淆矩阵的时候用得上。如果你的数据全部混在一个文件夹里文件名带标签前缀比如cat_001.jpg那就需要自己写一个自定义Dataset类来解析文件名。我个人的建议是花十分钟把文件整理成ImageFolder能直接读的格式比写自定义类省事得多而且不容易出错。2.2 图像预处理与数据增强的取舍预处理这块有几个关键决策。首先是图片尺寸的统一CNN要求输入尺寸固定常见的选择是224×224或128×128。224×224是ImageNet的标准输入尺寸如果你打算用预训练模型做迁移学习就用这个尺寸如果从零训练128×128能显著降低显存占用和训练时间精度损失在猫狗这种二分类任务上并不明显。训练集的变换transform和验证集的变换必须区分开。训练集需要做数据增强来提升泛化能力验证集只需要做确定性的预处理from torchvision import transforms train_transform transforms.Compose([ transforms.Resize((128, 128)), transforms.RandomHorizontalFlip(p0.5), transforms.RandomRotation(15), transforms.ColorJitter(brightness0.2, contrast0.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((128, 128)), transforms.ToTensor(), transforms.Normalize(mean[0.485, 0.456, 0.406], std[0.229, 0.224, 0.225]) ])这里的Normalize参数用的是ImageNet的均值和标准差这是一个通用做法。即使你的数据集不是ImageNet用这组参数做归一化通常也没问题因为它只是把像素值调整到一个合理的分布范围。如果你想更精确可以自己算一下训练集的均值和标准差但对于入门作业来说直接用ImageNet的参数完全够用。数据增强的力度需要根据数据量来调整。如果训练集只有一两千张图片增强力度可以大一些旋转、翻转、颜色抖动、随机裁剪都加上如果数据量已经上万增强太猛反而会拖慢收敛速度。我一般先用中等力度跑一轮看看训练集和验证集的准确率差距差距大就加大增强差距小就减小。2.3 DataLoader的批大小与并行加载DataLoader有两个参数直接影响训练效率batch_size和num_workers。batch_size的选择跟显存大小直接相关128×128的图片用batch_size32或64都比较稳妥。num_workers控制数据加载的并行进程数在Linux环境下设成4或8通常能明显加快数据读取速度Windows环境下如果遇到问题就设成0主进程加载。from torch.utils.data import DataLoader from torchvision.datasets import ImageFolder train_dataset ImageFolder(rootdataset/train, transformtrain_transform) val_dataset ImageFolder(rootdataset/val, transformval_transform) train_loader DataLoader(train_dataset, batch_size32, shuffleTrue, num_workers4, pin_memoryTrue) val_loader DataLoader(val_dataset, batch_size32, shuffleFalse, num_workers4, pin_memoryTrue)注意验证集的shuffle必须设为False因为评估的时候不需要打乱顺序而且保持顺序一致方便后续分析哪些样本分错了。pin_memoryTrue在GPU训练时能加速CPU到GPU的数据传输这是一个小但实用的优化。注意num_workers设得过大比如超过CPU核心数反而会拖慢速度因为进程切换本身有开销。一般设成CPU核心数的一半到全部之间比较合适。3. 三种网络结构的代码实现与设计动机3.1 DNN把图片展平之后会发生什么DNN是最直观的方案把3×128×128的图片张量展平成49152维的向量然后接几层全连接层最后输出2维的logits。代码写起来很简单import torch.nn as nn class DNNClassifier(nn.Module): def __init__(self, input_dim3*128*128, num_classes2): super().__init__() self.net nn.Sequential( nn.Flatten(), nn.Linear(input_dim, 512), nn.ReLU(), nn.Dropout(0.5), nn.Linear(512, 256), nn.ReLU(), nn.Dropout(0.5), nn.Linear(256, num_classes) ) def forward(self, x): return self.net(x)但这里有一个必须理解的问题为什么DNN在图像任务上效果通常不如CNN核心原因在于展平操作破坏了图像的空间结构。原本相邻的像素在展平后被分配到完全不同的位置全连接层需要为每个位置单独学习权重参数量巨大且无法利用局部特征在图像不同位置出现这一先验知识。举个例子猫的耳朵可能出现在图片的左上角也可能出现在右下角CNN通过卷积核的权重共享可以同时检测这两个位置而DNN需要分别学习两组不同的权重。实测下来DNN在猫狗分类上大概能跑到70%到80%的验证准确率训练速度比CNN快因为全连接层的计算比卷积更规整但收敛需要的epoch更多。这个结果对于入门理解来说已经足够了——你能亲眼看到同样的数据不同的网络结构效果差距有多大。3.2 CNN卷积、池化与特征图尺寸计算CNN是图像分类的标准方案。一个典型的入门级CNN结构是这样的class CNNClassifier(nn.Module): def __init__(self, num_classes2): super().__init__() self.features nn.Sequential( nn.Conv2d(3, 32, kernel_size3, padding1), nn.BatchNorm2d(32), nn.ReLU(), nn.MaxPool2d(2), nn.Conv2d(32, 64, kernel_size3, padding1), nn.BatchNorm2d(64), nn.ReLU(), nn.MaxPool2d(2), nn.Conv2d(64, 128, kernel_size3, padding1), nn.BatchNorm2d(128), nn.ReLU(), nn.MaxPool2d(2), nn.Conv2d(128, 256, kernel_size3, padding1), nn.BatchNorm2d(256), nn.ReLU(), nn.MaxPool2d(2), ) self.classifier nn.Sequential( nn.AdaptiveAvgPool2d(1), nn.Flatten(), nn.Linear(256, num_classes) ) def forward(self, x): x self.features(x) return self.classifier(x)这里有几个设计决策需要解释。为什么每层卷积后面都跟BatchNormBatchNorm把每层的输出归一化到均值0、方差1的分布能显著加速收敛并允许使用更大的学习率。在入门作业里加上BatchNorm训练稳定性会好很多不容易出现loss爆炸的情况。为什么用AdaptiveAvgPool2d(1)而不是直接Flatten因为直接Flatten需要你手动计算特征图的最终尺寸一旦输入图片尺寸变了就要重新算。AdaptiveAvgPool2d(1)把每个通道的特征图压缩成一个值输出维度固定为通道数跟输入尺寸无关代码更健壮。关于特征图尺寸的计算这里给一个通用公式。对于卷积层输出尺寸 (输入尺寸 2×padding - kernel_size) / stride 1。当padding1, kernel_size3, stride1时输出尺寸等于输入尺寸。池化层MaxPool2d(2)把尺寸减半。所以128×128的输入经过4次池化后变成8×8再经过AdaptiveAvgPool2d(1)变成1×1。理解这个计算过程很重要因为如果你自己写Flatten版本算错尺寸就会报维度不匹配的错误。3.3 RNN把图片当作序列来处理RNN在图像分类上不是主流方案但这个作业要求实现它目的是让你理解序列建模的思路。具体做法是把图片的每一行或每一列看作一个时间步每个时间步的输入是一个长度为128×3384的向量如果是RGB图片然后用LSTM或GRU处理这个序列最后用最后一个时间步的隐藏状态做分类class RNNClassifier(nn.Module): def __init__(self, input_size128*3, hidden_size256, num_layers2, num_classes2): super().__init__() self.hidden_size hidden_size self.num_layers num_layers self.lstm nn.LSTM(input_size, hidden_size, num_layers, batch_firstTrue, bidirectionalTrue) self.fc nn.Linear(hidden_size * 2, num_classes) def forward(self, x): # x: (batch, 3, 128, 128) - (batch, 128, 384) x x.permute(0, 2, 1, 3).reshape(x.size(0), 128, -1) h0 torch.zeros(self.num_layers * 2, x.size(0), self.hidden_size).to(x.device) c0 torch.zeros(self.num_layers * 2, x.size(0), self.hidden_size).to(x.device) out, _ self.lstm(x, (h0, c0)) out out[:, -1, :] return self.fc(out)这里用双向LSTM的原因很简单图片的行序列不像文本那样有严格的因果顺序从下往上和从上往下看都是合理的双向能捕捉更全面的信息。permute操作把通道维换到最后一维然后reshape把通道和宽度合并这样每一行就变成了一个长度为384的向量。RNN在猫狗分类上的效果通常介于DNN和CNN之间训练速度比CNN慢因为LSTM的计算有序列依赖无法完全并行但比DNN更能捕捉长距离依赖。这个结果本身就是一个很好的教学素材不同的归纳偏置inductive bias适合不同的数据结构CNN的局部性和权重共享适合图像RNN的序列建模能力适合文本选对结构比调参更重要。4. 训练循环的细节与常见报错处理4.1 损失函数、优化器与学习率调度二分类任务用CrossEntropyLoss就够了它内部已经包含了Softmax操作所以模型输出不需要加Softmax层。优化器首选Adam学习率设1e-3起步这个组合在大多数入门任务上都能稳定收敛import torch.optim as optim model CNNClassifier(num_classes2).to(device) criterion nn.CrossEntropyLoss() optimizer optim.Adam(model.parameters(), lr1e-3, weight_decay1e-4) scheduler optim.lr_scheduler.StepLR(optimizer, step_size10, gamma0.5)weight_decay1e-4是L2正则化能抑制过拟合。StepLR每10个epoch把学习率乘以0.5让模型在训练后期用更小的步长精细调整。这两个技巧在入门作业里加上验证准确率通常能提升2到5个百分点。4.2 完整的训练与验证循环训练循环的骨架是固定的前向传播、计算损失、反向传播、更新参数。验证循环只需要前向传播并且要加上torch.no_grad()来关闭梯度计算节省显存和时间def train_one_epoch(model, loader, criterion, optimizer, device): model.train() running_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() running_loss loss.item() * images.size(0) _, predicted outputs.max(1) total labels.size(0) correct predicted.eq(labels).sum().item() return running_loss / total, correct / total def validate(model, loader, criterion, device): model.eval() running_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) running_loss loss.item() * images.size(0) _, predicted outputs.max(1) total labels.size(0) correct predicted.eq(labels).sum().item() return running_loss / total, correct / total注意model.train()和model.eval()的切换。这两个方法会影响Dropout和BatchNorm的行为训练时Dropout随机丢弃神经元、BatchNorm用当前batch的统计量验证时Dropout关闭、BatchNorm用训练阶段累积的全局统计量。忘记切换会导致验证结果不稳定这是一个非常常见的坑。4.3 那些年我们踩过的维度不匹配的坑维度报错是入门阶段最高频的问题。我整理了几个典型场景和排查方法报错信息根本原因解决方案RuntimeError: mat1 and mat2 shapes cannot be multiplied全连接层输入维度与展平后的特征维度不匹配打印x.shape确认实际维度调整nn.Linear的in_featuresRuntimeError: Expected 4D input, got 3D卷积层要求输入是(batch, channel, H, W)检查DataLoader是否返回了正确的形状必要时用unsqueezeRuntimeError: Given groups1, weight of size [32, 3, 3, 3], expected input with 3 channels输入通道数与卷积层期望的不一致确认图片是RGB3通道还是灰度1通道CUDA out of memory显存不足减小batch_size或改用更小的输入尺寸排查维度问题的通用方法是在forward函数里加一行print(x.shape)逐层打印找到第一个维度不符合预期的地方。这个方法看起来笨但比盯着报错信息猜要快得多。提示如果用的是自己写的Dataset类务必确认__getitem__返回的图片张量形状是(C, H, W)而不是(H, W, C)。torchvision的ToTensor()会自动做这个转换但如果你用numpy手动处理很容易搞反。5. 实验结果对比与调参经验5.1 三类模型的性能对比在相同的训练集和验证集上三类模型的典型表现如下具体数值会因数据集规模和超参数不同而有差异模型验证准确率单epoch训练时间参数量收敛所需epochDNN72%-80%较快约2500万20-30CNN88%-94%中等约30万10-15RNN双向LSTM80%-86%较慢约200万15-25CNN用最少的参数量拿到了最高的准确率这个结果直观地展示了卷积结构在图像任务上的优势。DNN参数量最大但效果最差说明参数多不等于效果好关键还是网络结构要匹配数据的特性。RNN的表现居中训练速度最慢但它的价值在于为后续的文本任务打基础。5.2 学习率与批大小的调参心得学习率是影响训练最敏感的超参数。我的经验是先用1e-3跑5个epoch观察训练loss的变化。如果loss震荡剧烈或者变成nan说明学习率太大降到1e-4如果loss下降非常缓慢说明学习率太小升到3e-3试试。Adam对学习率的容忍度比SGD高但也不是随便设都能收敛。批大小的影响相对温和但有一个经验规律batch_size翻倍时学习率也可以适当增大大约乘以sqrt(2)这样能保持梯度的方差大致不变。不过对于入门作业来说batch_size32配合lr1e-3是一个很稳的起点不需要过度纠结。5.3 过拟合的识别与应对过拟合的典型信号是训练准确率持续上升但验证准确率在某个epoch之后开始下降或停滞。应对手段按优先级排列加数据增强这是最有效的手段尤其是随机裁剪和颜色抖动加Dropout在全连接层后面加nn.Dropout(0.5)卷积层后面可以加nn.Dropout2d(0.25)加weight_decay1e-4到1e-3之间调整早停验证准确率连续5个epoch不提升就停止训练减小模型容量减少卷积核数量或全连接层神经元数量我一般先加数据增强和Dropout这两个手段组合起来通常能把过拟合压下去。如果还不行再考虑减小模型或加正则化。6. 从猫狗分类迁移到真正的NLP任务6.1 文本分类与图像分类的代码结构对比跑完这个作业之后你会发现文本分类的代码结构和图像分类几乎一样区别只在数据预处理和模型的第一层。图像分类用Conv2d或Linear处理像素文本分类用Embedding层把词索引映射成词向量然后用LSTM或Transformer处理序列。训练循环、损失函数、优化器的写法完全一致。举个例子情感分析任务的模型定义大概是这样的class TextClassifier(nn.Module): def __init__(self, vocab_size, embed_dim128, hidden_size256, num_classes2): super().__init__() self.embedding nn.Embedding(vocab_size, embed_dim, padding_idx0) self.lstm nn.LSTM(embed_dim, hidden_size, batch_firstTrue, bidirectionalTrue) self.fc nn.Linear(hidden_size * 2, num_classes) def forward(self, x): embedded self.embedding(x) out, _ self.lstm(embedded) out out[:, -1, :] return self.fc(out)对比一下RNN图像分类的代码你会发现结构几乎一模一样只是把permutereshape换成了Embedding。这就是这个作业的设计意图用图像任务练手把训练流程刻进肌肉记忆然后迁移到NLP任务时只需要关注数据预处理和模型第一层的变化。6.2 序列建模思维在NLP中的价值RNN那部分虽然在这个作业里效果一般但它培养的序列建模思维在NLP中至关重要。文本是一个变长序列每个词的含义依赖于上下文这种依赖关系可能是短距离的不和好组成不好也可能是长距离的代词指代前文出现的实体。LSTM的门控机制就是为了捕捉这种长距离依赖而设计的。理解了RNN的hidden_state如何在时间步之间传递你就能理解为什么Transformer要用自注意力机制来替代循环结构——因为循环结构无法并行计算而且长距离依赖的梯度传播路径太长。这些知识点在后续学习BERT、GPT等模型时会反复用到。6.3 这个作业没覆盖但值得补充的内容这个作业是一个起点不是终点。跑通之后我建议按以下顺序继续深入迁移学习用预训练的ResNet或VGG做特征提取只训练最后的分类层通常能把准确率推到95%以上学习率预热与余弦退火比StepLR更精细的学习率调度策略混合精度训练用torch.cuda.amp加速训练并减少显存占用TensorBoard可视化把loss曲线和准确率曲线画出来直观监控训练过程混淆矩阵与错误分析看看模型把哪些猫分成了狗哪些狗分成了猫找出数据的潜在问题这些内容每一个都值得单独写一篇但前提是你先把基础的三类模型跑通、跑明白。基础不牢后面学得越多越混乱。最后分享一个我在带新人时反复强调的习惯每次跑实验之前先用一个很小的子集比如100张图片跑2个epoch确认代码没有维度错误、loss能正常下降再换全量数据跑。这个习惯能帮你节省大量等待时间因为全量数据跑一个epoch可能要几分钟而小数据集几秒钟就能暴露问题。踩过几次跑了半小时才发现维度写错了的坑之后你就会明白这个习惯有多重要。