news 2026/10/17 5:55:53

PyTorch深度学习实践第九讲:多分类与图像任务入门

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
PyTorch深度学习实践第九讲:多分类与图像任务入门

1. 从“第九讲”说起:为什么这个节点值得单独拎出来聊

如果你正在跟着一套PyTorch深度学习实践课程自学,学到“第九讲”这个位置,大概率已经跨过了张量操作、自动求导、线性回归、逻辑回归、多层感知机、数据集加载、模型保存这些基础关卡。换句话说,你已经不是那个连requires_grad和backward()都搞不清楚的新手了。但与此同时,你很可能正卡在一个非常典型的瓶颈上:单个模型能跑通了,但不知道怎么做多分类、怎么处理图像数据、怎么把模型结构写得更像“正经项目”而不是玩具代码。

“PyTorch深度学习实践代码 第九讲”这个标题,放在大多数同类课程的语境里,通常对应的是多分类问题(Multi-class Classification),而且极大概率会引入两个关键角色:Softmax函数和交叉熵损失(CrossEntropyLoss),并且会第一次真正意义上接触图像数据集,比如手写数字识别这类经典任务。这一讲之所以重要,是因为它是从“二分类思维”切换到“多分类思维”的分水岭,也是你第一次需要认真处理“输出层到底该怎么设计”这个问题的节点。

我见过太多人学到这里就卡住了。前面的二分类逻辑回归,输出一个概率值,大于0.5判为正类,小于0.5判为负类,逻辑非常直观。但到了多分类,输出变成了一个向量,比如10个类别就输出10个值,这时候问题来了:这10个值分别代表什么?怎么把它们变成概率?损失函数又该怎么选?为什么不能继续用Sigmoid加BCELoss?这些问题如果没人讲透,代码抄一遍能跑,但换个数据集立刻抓瞎。

这篇文章就是冲着这个节点来的。我会把第九讲涉及的核心代码、设计思路、常见坑点、参数选择逻辑全部拆开讲清楚,让你不仅能把代码跑通,还能理解每一行为什么这么写,换一个多分类任务时能自己独立搭出来。适合已经掌握PyTorch基础、正在往多分类和图像任务过渡的读者,也适合那些代码能跑但心里没底、想回头把逻辑理顺的人。

2. 第九讲的核心任务拆解与整体设计思路

2.1 多分类问题到底难在哪里

二分类和多分类的本质区别,不在于类别数量,而在于输出层的表达方式和损失函数的计算方式。二分类时,一个Sigmoid输出就能表示“正类的概率”,负类概率用1减一下就有了,两者是绑定的。但多分类不行,10个类别需要10个独立的输出值,而且这10个值必须满足两个条件:每个值在0到1之间,所有值加起来等于1。这就是Softmax要解决的问题。

很多人第一次看到Softmax公式会觉得有点绕:

Softmax(z_i) = exp(z_i) / sum(exp(z_j))

用大白话解释:先把每个输出值取指数,把负数变成正数、把差距放大,然后再除以所有指数值的和做归一化。这样得到的向量,每个元素都是正数,且总和为1,天然就是概率分布。为什么取指数而不是别的?因为指数函数单调递增,不会改变原来输出值之间的大小关系,同时能保证结果恒正,这是数学上最自然的选择。

但这里有个非常关键的工程细节:PyTorch的CrossEntropyLoss内部已经包含了Softmax操作。也就是说,你的模型最后一层直接输出原始logits就行,不需要手动加Softmax,否则就重复计算了。这个坑我踩过不止一次,手动Softmax之后再送进CrossEntropyLoss,损失值会一直偏高,训练效果明显变差,排查半天才发现是重复归一化。

2.2 为什么第九讲通常选MNIST或类似数据集

多分类的入门任务,几乎都会选手写数字识别这类数据集。原因很实际:10个类别,图像尺寸小(28x28),数据量适中(训练集6万张),CPU上也能跑,不需要GPU就能完成实验。更重要的是,它的输入是图像,这就逼着你第一次认真处理“图像怎么变成模型能吃的张量”这个问题。

图像数据在PyTorch里需要经过一系列变换才能送进网络。原始图像是PIL格式或者numpy数组,像素值在0到255之间,通道顺序可能是HWC(高、宽、通道)。而PyTorch的模型期望的输入是CHW(通道、高、宽)的浮点张量,并且数值最好归一化到0到1甚至更小的范围。这一套流程在第九讲里会第一次完整出现,包括transforms.ToTensor()、transforms.Normalize()以及DataLoader的配合使用。

我个人的经验是,这一讲的数据处理部分比模型定义部分更容易出错。模型结构无非是全连接层堆叠,但数据变换和加载如果搞错了,模型训练时损失不下降,你根本不知道是模型问题还是数据问题。所以下面我会把数据处理单独拎出来讲透。

2.3 整体代码架构的设计逻辑

第九讲的代码通常遵循一个非常清晰的四段式结构:数据准备、模型定义、训练循环、测试评估。这个结构看起来简单,但每一段都有讲究。

数据准备阶段,核心是构建Dataset和DataLoader。Dataset负责单条数据的读取和变换,DataLoader负责批量组装、打乱顺序、并行加载。这种分工的好处是解耦:换数据集只需要改Dataset,批量大小和加载策略在DataLoader里调,互不影响。

模型定义阶段,通常会用nn.Module的方式组织,而不是用nn.Sequential一把梭。为什么?因为第九讲开始,模型结构会稍微复杂一点,可能需要自定义前向传播过程,比如在某些层之间加激活函数、Dropout或者特殊的维度变换。用类的方式写,扩展性更好,也更容易调试。

训练循环阶段,标准流程是:前向传播算输出、算损失、梯度清零、反向传播、参数更新。这五步的顺序不能乱,尤其是梯度清零必须放在反向传播之前,否则梯度会累加。这个坑我在早期写过无数次错误代码,后来养成了固定写法的习惯才不再犯。

测试评估阶段,关键是要切换模型的训练/评估模式,并且用torch.no_grad()关闭梯度计算。这两个操作一个影响Dropout和BatchNorm的行为,一个影响内存占用和计算速度,缺一不可。

3. 核心细节解析与实操要点

3.1 Softmax与CrossEntropyLoss的配合关系

这是第九讲最核心的知识点,也是最容易出错的地方。我先给结论:在PyTorch中做多分类,模型最后一层不要加Softmax,直接输出原始值,然后使用nn.CrossEntropyLoss()作为损失函数。

为什么这么设计?因为CrossEntropyLoss在数学上等价于LogSoftmax加上NLLLoss(负对数似然损失)。PyTorch把这两步合并在一起,内部做了数值稳定性的优化。如果你手动先做Softmax,再送进CrossEntropyLoss,相当于做了两次Softmax,概率分布被过度平滑,梯度信号变弱,训练会变慢甚至不收敛。

我实测过一个对比:同一个模型,手动Softmax加CrossEntropyLoss,训练10个epoch后测试准确率大概在85%左右徘徊;去掉手动Softmax后,同样10个epoch能到92%以上。差距非常明显。

那什么时候需要手动Softmax?只有在推理阶段,你想把模型输出转成概率值展示给用户看的时候。训练阶段完全不需要。推理时用torch.softmax(output, dim=1)即可,注意dim=1是因为第0维是batch维度,第1维才是类别维度。

3.2 图像数据的标准化处理

transforms.Normalize()这个操作,很多人第一次见不知道在干嘛。它的作用是对每个通道做标准化,公式是:

output = (input - mean) / std

对于MNIST这类单通道灰度图,常见的参数是mean=0.1307, std=0.3081,这两个值是该数据集全体像素的均值和标准差。为什么要做这一步?因为神经网络对输入数据的尺度很敏感,如果输入值都在0到1之间但分布不均匀,梯度更新会不稳定。标准化之后,数据分布接近标准正态分布,优化器工作起来更顺畅,收敛更快。

但这里有个细节:transforms.ToTensor()已经把像素值从0到255缩放到0到1了,Normalize是在此基础上再做一次平移和缩放。两步的顺序不能反,必须先ToTensor再Normalize。我见过有人直接对PIL图像做Normalize,结果报错,因为Normalize只接受张量输入。

另外,如果你用的是自定义数据集,均值和标准差需要自己算。计算方法很简单:把训练集所有图像加载进来,按通道求均值和标准差。但注意,测试集和验证集必须用训练集算出来的均值和标准差,不能各自算各自的,否则数据分布不一致,评估结果没有意义。

3.3 全连接网络的层设计与维度匹配

第九讲的模型通常是几层全连接网络。以MNIST为例,输入是28x28的图像,需要先展平成784维的向量。展平操作可以用x.view(-1, 28*28)或者x.flatten(start_dim=1),两者效果一样,后者可读性更好。

隐藏层的设计没有固定公式,但有几个经验规则:第一层隐藏层神经元数量通常比输入维度小,比如512或256;后面逐层递减,比如256到128到64;最后一层输出维度等于类别数,MNIST就是10。激活函数用ReLU,因为它计算简单、梯度不会饱和,是目前最主流的选择。

维度匹配是新手最容易翻车的地方。每一层的输入维度必须等于上一层的输出维度,最后一层的输出维度必须等于类别数。我建议在写模型时,把每一层的维度变化写在注释里,比如:

class Net(nn.Module): def __init__(self): super(Net, self).__init__() self.fc1 = nn.Linear(784, 512) # 输入784,输出512 self.fc2 = nn.Linear(512, 256) # 输入512,输出256 self.fc3 = nn.Linear(256, 128) # 输入256,输出128 self.fc4 = nn.Linear(128, 10) # 输入128,输出10(类别数) def forward(self, x): x = x.view(-1, 784) # 展平 x = F.relu(self.fc1(x)) x = F.relu(self.fc2(x)) x = F.relu(self.fc3(x)) x = self.fc4(x) # 最后一层不加激活 return x

注意最后一层没有激活函数,这是配合CrossEntropyLoss的要求。如果你在这里加了ReLU或者Softmax,损失计算就会出问题。

3.4 批量大小与学习率的初步选择

第九讲通常不会深入讲超参数调优,但批量大小和学习率的选择直接影响你能不能跑出好结果。批量大小方面,MNIST这种小数据集,64或128都是常见选择。批量太小(比如8)会导致梯度噪声大、训练不稳定;批量太大(比如1024)会导致内存占用高、泛化性能下降。我一般从64开始试,如果训练太慢就加到128。

学习率方面,配合SGD优化器,0.01是一个比较安全的起点。如果损失下降太慢,可以试0.05或0.1;如果损失震荡甚至发散,就降到0.001。配合Adam优化器的话,0.001是默认值,通常不需要大改。但要注意,Adam虽然收敛快,但在某些任务上泛化性能不如SGD加动量,所以第九讲通常还是用SGD来教学,目的是让你理解优化器的基本行为。

还有一个细节:学习率调度。第九讲可能不会涉及,但你可以手动加一个简单的策略,比如每10个epoch把学习率乘以0.1。这个操作能让模型在后期更精细地收敛,测试准确率通常能再涨一两个百分点。

4. 完整实操流程与关键环节实现

4.1 数据准备阶段的完整代码与注释

数据准备是整条流水线的起点,也是最容易被忽视的环节。下面这段代码我加了详细注释,你可以直接参考:

import torch from torchvision import transforms, datasets from torch.utils.data import DataLoader # 定义数据变换:先转张量,再标准化 transform = transforms.Compose([ transforms.ToTensor(), # PIL图像转张量,像素值缩放到[0,1] transforms.Normalize((0.1307,), (0.3081,)) # 按通道标准化 ]) # 加载训练集 train_dataset = datasets.MNIST( root='./data', # 数据存放路径 train=True, # 训练集 download=True, # 自动下载 transform=transform # 应用变换 ) # 加载测试集 test_dataset = datasets.MNIST( root='./data', train=False, # 测试集 download=True, transform=transform ) # 构建DataLoader train_loader = DataLoader( dataset=train_dataset, batch_size=64, # 每批64条 shuffle=True, # 训练集打乱顺序 num_workers=2 # 2个进程加载数据 ) test_loader = DataLoader( dataset=test_dataset, batch_size=64, shuffle=False, # 测试集不需要打乱 num_workers=2 )

这里有几个实操要点。num_workers在Windows上有时会出问题,如果报错就改成0,用主进程加载数据,速度慢一点但稳定。shuffle=True只对训练集开,测试集必须保持固定顺序,否则每次评估的结果没法对比。download=True第一次运行会下载数据,如果网络环境不好,可以手动下载后放到./data目录下。

4.2 模型定义与前向传播的写法

模型定义我推荐用类的方式,结构清晰,方便后续加层或改结构:

import torch.nn as nn import torch.nn.functional as F class Net(nn.Module): def __init__(self): super(Net, self).__init__() self.fc1 = nn.Linear(784, 512) self.fc2 = nn.Linear(512, 256) self.fc3 = nn.Linear(256, 128) self.fc4 = nn.Linear(128, 10) self.dropout = nn.Dropout(0.2) # 防止过拟合 def forward(self, x): x = x.view(-1, 784) # 展平 x = F.relu(self.fc1(x)) x = self.dropout(x) x = F.relu(self.fc2(x)) x = self.dropout(x) x = F.relu(self.fc3(x)) x = self.fc4(x) # 输出层不加激活 return x

Dropout加在隐藏层之后、激活函数之后,比例0.2到0.5之间。MNIST任务比较简单,0.2就够了,太高会导致欠拟合。注意Dropout在训练时生效、测试时自动关闭,这是PyTorch内部处理的,你只需要在测试前调用model.eval()。

4.3 训练循环的逐步拆解

训练循环是整段代码的心脏,每一步都有明确目的:

import torch.optim as optim model = Net() criterion = nn.CrossEntropyLoss() optimizer = optim.SGD(model.parameters(), lr=0.01, momentum=0.9) def train(epoch): model.train() # 切换到训练模式 for batch_idx, (data, target) in enumerate(train_loader): optimizer.zero_grad() # 梯度清零 output = model(data) # 前向传播 loss = criterion(output, target) # 计算损失 loss.backward() # 反向传播 optimizer.step() # 更新参数 if batch_idx % 100 == 0: print(f'Epoch {epoch} [{batch_idx * len(data)}/{len(train_loader.dataset)}]' f' Loss: {loss.item():.6f}')

model.train()和model.eval()的切换非常重要。训练模式下,Dropout会随机丢弃神经元,BatchNorm会更新运行统计量;评估模式下,这两者都会关闭或使用固定统计量。如果你忘了切换,测试结果会不稳定,甚至比训练结果差很多。

optimizer.zero_grad()必须放在loss.backward()之前。PyTorch默认会累加梯度,如果不清零,第二次迭代的梯度会叠加第一次的,导致参数更新错误。这个坑我在早期代码里踩过,表现是损失下降异常快然后突然爆炸。

4.4 测试评估与准确率计算

测试阶段的核心是关闭梯度计算,节省内存和计算时间:

def test(): model.eval() # 切换到评估模式 test_loss = 0 correct = 0 with torch.no_grad(): # 关闭梯度计算 for data, target in test_loader: output = model(data) test_loss += criterion(output, target).item() pred = output.argmax(dim=1) # 取概率最大的类别 correct += pred.eq(target).sum().item() test_loss /= len(test_loader) accuracy = 100. * correct / len(test_loader.dataset) print(f'Test Loss: {test_loss:.4f}, Accuracy: {accuracy:.2f}%')

output.argmax(dim=1)返回每行最大值的索引,也就是模型预测的类别。pred.eq(target)逐元素比较预测和真实标签,返回布尔张量,.sum()统计正确数量。最后除以测试集总数得到准确率。

这里有个细节:test_loss += criterion(output, target).item()累加的是每个批次的平均损失,最后除以批次数量得到整体平均损失。但更准确的做法是按样本数加权平均,因为最后一个批次可能不满64条。不过对于MNIST这种6万条整除了64的数据集,两种算法结果一样,不用纠结。

5. 常见问题与排查技巧实录

5.1 损失不下降的排查思路

损失不下降是第九讲最高频的问题。排查顺序我建议这样走:先检查数据变换是否正确,再检查模型输出维度是否匹配,最后检查损失函数和优化器配置。

数据变换方面,最常见的问题是忘了ToTensor(),导致输入还是PIL图像,模型直接报错。或者Normalize的参数写错了,比如把mean和std搞反了,数据分布被拉偏,模型学不动。你可以打印一个batch的数据看看,print(data.shape)应该是[64, 1, 28, 28],print(data.min(), data.max())应该在-0.5到2.5左右(标准化后的范围)。

模型输出维度方面,最后一层必须是10,如果写成1或者别的数字,损失计算会报错或者结果完全错误。损失函数方面,确认用的是CrossEntropyLoss而不是BCELoss,后者是二分类用的,多分类用它会报维度不匹配。

优化器方面,学习率太大导致震荡,太小导致下降缓慢。你可以先试0.001,如果损失几乎不动,再逐步加大到0.01、0.05。

5.2 测试准确率远低于训练准确率

这个现象通常是过拟合或者模式切换问题。先确认测试时调用了model.eval(),如果忘了,Dropout还在随机丢弃神经元,测试结果会偏低且不稳定。再确认torch.no_grad()加了,虽然不加不影响准确率,但会浪费内存。

如果这两个都没问题,那就是过拟合。解决办法有三个:加Dropout层、加L2正则化(在优化器里设weight_decay=1e-4)、减少模型参数量。MNIST任务上,一个三层全连接网络加0.2的Dropout,通常能把测试准确率拉到97%以上。

5.3 常见报错与快速修复对照表

报错信息可能原因解决方法
RuntimeError: expected scalar type Long but found Float标签类型不对标签需要是torch.long,CrossEntropyLoss要求
RuntimeError: size mismatch层维度不匹配检查每层输入输出维度,确保首尾相接
RuntimeError: CUDA out of memory显存不足减小batch_size,或改用CPU训练
ValueError: num_samples should be a positive integer数据集为空检查数据路径和download参数
损失为nan学习率过大或数据有nan降低学习率,检查数据是否有异常值

标签类型这个问题特别隐蔽。MNIST的标签默认是torch.int64,也就是Long,一般不会错。但如果你自己构造数据集,标签可能是float,送进CrossEntropyLoss就会报错。解决方法是target = target.long()。

5.4 提升准确率的几个实操技巧

第一个技巧是加学习率调度。在训练循环里每10个epoch把学习率乘以0.1,让模型后期更精细地收敛。实测在MNIST上能提升0.5到1个百分点。

第二个技巧是用验证集做早停。从训练集里切出10%作为验证集,每个epoch结束后在验证集上评估,如果连续5个epoch验证损失不下降就停止训练。这样能防止过拟合,也能节省训练时间。

第三个技巧是数据增强。MNIST可以加随机旋转、随机平移、随机缩放,让模型看到更多样的样本。但注意增强只对训练集做,测试集保持原始变换。transforms.RandomRotation(10)和transforms.RandomAffine(degrees=0, translate=(0.1, 0.1))都是常用操作。

第四个技巧是集成多个模型。训练3到5个结构相同但初始化不同的模型,测试时把它们的输出平均一下,准确率通常能再涨0.3到0.5个百分点。代价是训练时间和推理时间成倍增加,看你的实际需求取舍。

6. 从第九讲往后走的扩展方向

第九讲把多分类的基本框架搭起来了,但真正的深度学习项目远不止这些。如果你已经把这一讲的代码跑通、准确率也到了97%以上,接下来可以往几个方向扩展。

第一个方向是把全连接网络换成卷积神经网络。MNIST上卷积网络能轻松到99%以上,而且参数量更少。核心改动是把nn.Linear换成nn.Conv2d和nn.MaxPool2d,前向传播里加卷积、池化、展平的操作。这个过渡是自然的,也是必须的,因为真实图像任务几乎都用卷积网络。

第二个方向是处理自定义数据集。torchvision.datasets里只有一些经典数据集,实际项目中你需要自己写Dataset类,继承torch.utils.data.Dataset,实现__len__和__getitem__两个方法。这是从教学代码走向工程代码的关键一步。

第三个方向是模型保存与加载。训练好的模型需要保存下来,下次直接加载使用,而不是每次重新训练。PyTorch提供了torch.save(model.state_dict(), 'model.pth')和model.load_state_dict(torch.load('model.pth'))两套接口,前者只存参数,后者存整个模型结构。推荐用前者,更灵活也更安全。

第四个方向是可视化训练过程。用TensorBoard或者Matplotlib把损失曲线和准确率曲线画出来,能直观看到模型是在收敛还是在震荡。这个习惯越早养成越好,因为后面调参时,曲线比数字更能说明问题。

我个人在实际操作中的体会是,第九讲的价值不在于代码本身有多复杂,而在于它第一次把多分类的完整流程串起来了。数据怎么处理、模型怎么设计、损失怎么选、训练怎么跑、结果怎么评估,这一整套流程走通一遍,后面换任何多分类任务都是在这个骨架上改细节。所以这一讲值得多花时间,把每个环节都搞透,而不是抄一遍代码看到准确率还行就跳过。后面卷积网络、循环网络、注意力机制,底层逻辑都是这套东西,基础打牢了,后面才走得快。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/17 5:55:31

直方图归一化详解:从灰度直方图到OpenCV代码实现

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/17 5:55:16

渗透测试文件操作实战:从上传到痕迹清理的攻防全解析

渗透测试下的文件操作:攻防视角下的"最后一公里"实战拆解干安全这行久了你会发现一个规律:无论是拿下一台边界服务器,还是打穿一套内网系统,真正决定"能不能成事"的环节,往往不是最开头那一下漏洞…

作者头像 李华
网站建设 2026/10/17 5:55:13

具身智能入门:从感知决策到控制闭环的工程实践

几个月前,我在实验室里盯着一台机械臂反复抓取同一个矿泉水瓶,它试了十几次才成功了一次。旁边有个同事说了一句:“这玩意要是能像人一样,看一眼就懂得怎么拿,就好了。”我当时没接话,但那句话确实戳中了一…

作者头像 李华
网站建设 2026/10/17 5:54:15

C++堆实战:从priority_queue到手写堆的完整指南

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/17 5:54:09

Claude Code知识工作插件实战:slash commands机制与效率提升指南

1. 从零认识 knowledge-work-plugins:它到底解决什么问题第一次看到knowledge-work-plugins这个仓库名,很多人会下意识以为它又是一个“插件合集”,点进去发现是一堆 Markdown 和 JSON 配置,然后一脸问号地关掉。我最初也是这个反…

作者头像 李华