1. 为什么Pytorch入门要单独给Softmax回归开一篇
学完张量操作和线性回归之后,很多人会陷入一个错觉:神经网络不过就是一堆矩阵乘法和求导,线性回归已经是基础里的基础,后面无非是堆层数、换激活函数。但真开始用Pytorch写分类任务时才发现情况没那么简单——线性回归输出的是连续数值,用MSE当损失函数训练还能跑起来;而分类任务要输出的是“属于每个类别的概率”,背后牵扯到Softmax归一化、交叉熵损失、标签编码方式、模型输出层的语义设计,每一环都容易踩坑。
这篇博文要解决的就是这个问题:用最朴素的Softmax回归作为桥梁,把“从回归到分类”这层窗户纸捅破。整个实现不引入卷积、不引入循环神经网络,只用单个线性层加Softmax就在标准数据集上做到接近90%的准确率。看似入门,但里面涉及的数值稳定性、损失函数选择、训练循环写法,是后续所有复杂模型都要复用的底层逻辑。适合已经跑通了线性回归、但对分类任务还比较陌生的Pytorch学习者,也适合那些想弄清楚CrossEntropyLoss和Softmax到底是什么关系的人。
2. Softmax回归的数学直觉与代码设计思路
2.1 为什么输出“概率”这件事不能直接用普通线性层
线性回归的输出层就一个神经元,想输出什么就训练什么。但到了分类任务里,比如10类手写数字识别,模型最后一层如果直接输出10个浮点数,那这些数是什么含义?没含义。它们可能是负数、可能是0、可能是100,彼此之间的大小关系虽然能反映“哪个类别更有可能”,但数值本身不可解释,也没法直接当成概率来用。
所以需要一种变换,把任意一组实数映射成一组非负且总和为1的数值。Softmax干的就是这件事:对输入向量的每个分量取指数,再除以所有分量指数之和。指数函数保证输出恒大于0,除以和保证总和恒等于1。这样10个输出就变成了10个互斥类别的概率估计,最后取概率最大的那个类别作为预测结果。
公式听起来简单,实操里有个细节容易被忽略:指数函数的增长速度极快,如果某一个分量数值特别大,比如100,那exp(100)直接溢出成inf,整个计算就废了。Pytorch的Softmax内部会先减去输入向量中的最大值再做指数运算,因为同时减去一个常数不改变Softmax的结果。数学上这个操作叫数值稳定性处理,以后自己写损失函数或者蒸馏、对比学习之类的代码时,这个手法会经常用到。
2.2 交叉熵损失:分类任务的事实标准
模型输出了概率分布,接下来要定义“模型输出分布”与“真实标签分布”之间的距离。最常见的度量是交叉熵。对单个样本来说,交叉熵损失就是真实类别对应的模型输出概率取负对数。
这里有一个关键问题:为什么分类任务不用均方误差?用MSE训练分类模型时,反向传播的梯度跟输出层激活函数的导数有关。Softmax的导数在概率接近0或1的时候会变得很小,梯度消失,模型学得极慢。交叉熵配Softmax就不一样,Pytorch在实现CrossEntropyLoss时,会把Softmax和负对数损失融合在一起,推导出来的梯度形式很干净,恰好就是“模型预测概率减去真实标签的one-hot向量”。这个差值的梯度信号在预测错误时很大,在预测正确时趋近于0,训练效率远高于MSE。
另外一个隐藏点:Pytorch的nn.CrossEntropyLoss内部已经包含了Softmax操作,所以模型的最后一层不需要手动加Softmax,直接把线性层的原始输出(logits)丢给损失函数就行。很多人第一次接触时想当然地在模型里写个self.softmax = nn.Softmax(dim=1),然后又把softmax之后的输出传给CrossEntropyLoss,结果就是训练时概率分布被套了两层Softmax,数值和梯度全都不对。多个项目里见过这个坑,建议记在心里。如果你需要的是模型推理时的概率输出,那是另一回事,后面评估部分会专门说。
2.3 标签编码:one-hot是理论需求,索引是实际写法
理论推导交叉熵时,真实标签通常写成one-hot向量,比如5这个数字就写成[0, 0, 0, 0, 0, 1, 0, 0, 0, 0]。但实际写Pytorch代码时,数据加载出来的标签图片是一串整数索引,比如tensor(7)。你不必手动转成one-hot,CrossEntropyLoss要求传入的target是类别索引,类型是整数型;如果手贱把它转成one-hot浮点数,反而会报维度不匹配或者计算错误的错误。
刚开始照着教程写时报过ValueError: Expected target size [256, 10], got [256],就是没搞懂这个约定。损失函数内部会自己判断每个样本的target索引对应哪个位置,然后只在那个位置计算负对数概率。这个约定让代码更简洁,也省去了存储one-hot矩阵的额外开销。
3. 数据准备:把真实数据集改造成模型能吃的样子
3.1 数据集选型:Fashion-MNIST是比MNIST更好的练手选择
Softmax回归演示最常用的是MNIST手写数字数据集,28x28的灰度图,10个类别。MNIST问题在于太简单了,它的数字结构非常规整,用线性模型就能拿到90%以上的准确率,练手可以,但对特征表达的学习体验会打折扣。Fashion-MNIST是MNIST的现代替代品,同样是28x28灰度图、10个类别、共7万张图片,但内容换成了T恤、裤子、包之类的服装物品。由于衣物类别的像素模式更复杂,线性模型的准确率天花板比MNIST低不少,训练过程中观察“为什么有些类别永远分不好”反而更能加深对模型能力的理解。
我这里两个数据集都跑过,下面的代码直接按Fashion-MNIST来写,想换MNIST的话只需要把datasets.FashionMNIST换成datasets.MNIST,其他代码完全不动。
3.2 Transform的写法与归一化参数的来由
Pytorch的torchvision.datasets默认返回PIL图像,只有把它转成Tensor才能喂给模型。这里用到transforms.ToTensor(),它会把图像的HWC排列转成CHW,同时把像素值从0~255缩放到0~1。随后加一个标准化:transforms.Normalize((0.5,), (0.5,)),均值0.5、标准差0.5,这样像素值从[0,1]变成[-1,1]。
有些教程用mean=0.1307, std=0.3081,这是MNIST全数据集的真实统计值。用真实统计值做标准化是最标准的做法,但初次练手用0.5/0.5也不影响学习效果。关键是理解标准化的意义:让所有特征的数值尺度接近,避免某些像素因为数值范围大而在损失函数中占据主导地位。对于图像这种本身就是0~255的均匀分布数据,不做标准化其实也能训练,但收敛速度和稳定性会差一些,养成随时加Normalize的习惯对以后处理非图像数据也会有帮助。
一个容易踩的坑:transforms.ToTensor()和transforms.Normalize的组合顺序不能反。必须先转Tensor再Normalize,因为Normalize要求输入是浮点Tensor,不能直接作用在PIL图像上。如果顺序写反了,程序会报类型错误。
3.3 DataLoader参数与训练/测试集的划分
from torchvision import datasets, transforms from torch.utils.data import DataLoader batch_size = 256 transform = transforms.Compose([ transforms.ToTensor(), transforms.Normalize((0.5,), (0.5,)) ]) train_dataset = datasets.FashionMNIST( root='./data', train=True, download=True, transform=transform ) test_dataset = datasets.FashionMNIST( root='./data', train=False, download=True, transform=transform ) train_loader = DataLoader(train_dataset, batch_size=batch_size, shuffle=True) test_loader = DataLoader(test_dataset, batch_size=batch_size, shuffle=False)batch_size=256是一个对入门任务比较合理的取值。显存和内存压力不大,梯度更新方向相对稳定,训练速度也够快。shuffle=True只在训练集上开启,目的是让每个batch的数据分布尽量均匀,避免模型学到样本顺序里的规律;测试集不需要打乱,因为评估结果和样本顺序无关。数据如果没下载过,Pytorch下载完成后会自动放在指定的root目录下,重复运行时不需要再下载。
这里有一个深度学习常见的比例问题:训练集6万张、测试集1万张是固定划分好的,但在自己处理实际业务数据时,往往需要手动划分。新手容易犯的错误是把所有数据全拿来训练,最后没有独立评估集,模型好坏全凭训练损失判断——这会导致过拟合风险被严重低估。即使是在入门阶段,也要养成“训练集和评估集严格分离”的意识。
4. 模型定义与训练循环
4.1 线性层为什么不需要隐藏层就能做10分类
Softmax回归对应的是一个没有隐藏层的单层神经网络。输入是28x28=784维的展平像素向量,输出是10维。矩阵形状上就是[784, 10]的权重矩阵再叠加[10]的偏置向量。
import torch from torch import nn class SoftmaxRegression(nn.Module): def __init__(self, num_inputs, num_outputs): super().__init__() self.linear = nn.Linear(num_inputs, num_outputs) def forward(self, X): X = X.reshape(X.shape[0], -1) return self.linear(X)为什么不需要隐藏层?因为Softmax回归本身就是线性模型,输入和输出之间只有一次线性变换。它存在的意义不是为了取得多么高的准确率,而是让你理解“从输入特征到类别概率”的完整链路。后续任何深度模型都没必要去掉前面加隐藏层后的非线性表达能力。
reshape(X.shape[0], -1)这一步经常被忽略。数据加载器传给模型的X形状是[batch_size, 1, 28, 28],其中1是通道数,后两个是宽高。nn.Linear只接受二维输入(样本数,特征数),所以必须先展平。这里需要提醒一件事:reshape操作不要写到类外面去,而是放进forward函数里,这样无论传入的是单张图片还是batch,都能按统一逻辑处理,代码更健壮。
4.2 初始化策略:不调也行,但要知道默认值是什么
nn.Linear创建后,Pytorch会按照某种默认策略自动初始化权重。线性层的权重默认按均匀分布初始化,范围取决于输入输出维度。为了使训练能有效进行,通常还需要设置一个接近0的初始均值。在实际操作中,入门阶段不用手动改初始化,靠默认值就能正常训练。
但如果你是那种喜欢控制每个细节的人,也可以像下面这样手动设成均值为0、标准差为0.01的正态分布:
def init_weights(m): if isinstance(m, nn.Linear): nn.init.normal_(m.weight, std=0.01) nn.init.zeros_(m.bias) model = SoftmaxRegression(num_inputs=784, num_outputs=10) model.apply(init_weights)点乘权重初始化能使初始输出概率更接近均匀分布,softmax输出的初始化置信度比较平稳。对Softmax回归这类浅层模型,它的增益没那么明显;但在后面训练较深网络时,这个问题会越来越重要。这里提前种个种子,到时候学了ResNet之类结构就不会对初始化感到陌生。
4.3 训练循环:简版和高可读版对比
Pytorch为Softmax回归提供了非常简洁的官方写法,但为了把训练流程的所有细节看清,我建议先自己手动写一个“傻瓜版”,跑通后再去看官方写法,理解就会通透很多。
optimizer = torch.optim.SGD(model.parameters(), lr=0.1) criterion = nn.CrossEntropyLoss() epochs = 10 for epoch in range(epochs): total_loss = 0 total_correct = 0 total_samples = 0 for X, y in train_loader: # 前向计算 logits = model(X) # 计算损失 loss = criterion(logits, y) # 反向传播前必须先清空历史梯度 optimizer.zero_grad() # 反向传播,计算各参数梯度 loss.backward() # 用梯度更新参数 optimizer.step() # 统计本batch的loss和准确率 total_loss += loss.item() * X.shape[0] total_correct += (logits.argmax(dim=1) == y).sum().item() total_samples += X.shape[0] avg_loss = total_loss / total_samples train_acc = total_correct / total_samples if (epoch + 1) % 2 == 0: print(f"Epoch {epoch + 1}/{epochs} | Loss: {avg_loss:.4f} | Train Acc: {train_acc:.2%}")逻辑顺序是固定的:前向算loss、清空梯度、反向传播、更新参数。这四步一个都不能乱。
optimizer.zero_grad()这个步骤经常被新手忘了写,或者不理解为什么每次迭代都要调用。原因是Pytorch的梯度是累积式的,也就是说反向传播计算出的梯度会累加到Parameter的grad属性上——不手动清零的话,下一个batch的梯度会和上一个加在一起,loss曲线会原地乱飘。还有一种常见写法是model.zero_grad(),效果等价,因为Module的zero_grad方法递归调用了所有Parameter的清零逻辑。两者用哪个都行,团队的代码风格按项目来定就好。
另外注意loss.item()的应用场景。如果直接total_loss += loss,那么整个total_loss会被当作一个计算图节点,训练过程中会保存很多无用的中间梯度,内存越占越大。.item()是从这个只含一个标量的张量里取出普通Python数值,彻底断开计算图,是训练循环里统计loss的标准姿势。这条经验每个Pytorch项目里都会用到。
4.4 一个容易忽略的问题:模型.train()到底要不要写
上面的代码里没有出现model.train(),因为对单层线性模型来说,它内部没有Dropout或BatchNorm层,train模式和eval模式的行为没有区别,所以不写也能正常训练。
但这是一条极易在日常工程代码里积累坏习惯的地方。后续很快会接触到Dropout和BatchNorm,它们的共同特点是:训练阶段和推理阶段的行为不同。比如BatchNorm在训练时用当前batch的统计量来归一化,推理时用训练阶段累计的全局统计量。如果推理前忘了写model.eval(),批归一化还在用当前batch的统计量,导致输出的分布偏歪,性能大幅下降。
所以从这篇起,我就刻意每次训练前设置model.train()、评估前设置model.eval()。虽然对Softmax回归可有可无,但它能帮助你建立不可省略的好习惯。
5. 模型评估与预测结果可视化
5.1 评估阶段为什么要包在with torch.no_grad()里
训练阶段反向传播需要保存中间激活值,这会占一部分显存或内存。但在评估阶段,我们只需要正向计算结果去统计准确率,根本不需要反向传播。把评估代码包在with torch.no_grad():这个上下文里,等于告诉Pytorch“这块别给我建立计算图”,计算速度会明显提升,内存占用也更小。
def evaluate(model, data_loader): model.eval() correct = 0 total = 0 with torch.no_grad(): for X, y in data_loader: logits = model(X) preds = logits.argmax(dim=1) correct += (preds == y).sum().item() total += y.numel() return correct / totallogits.argmax(dim=1)是因为Softmax函数的单调性,取概率最大值等价于取logits最大值。所以评估时不需要再对logits做一次softmax,直接用argmax即可。
total += y.numel()统计的是标签张量中的元素总数。对分类任务来说,一个元素就是一个样本。也可以写成total += X.shape[0],效果一样。这两种写法都行,但numel()在样本维度为多维时更稳妥,这里习惯了以后处理语义分割之类的任务时会少踩很多坑。
5.2 准确率是一个不够用的指标
Fashion-MNIST的10个类别是均衡分布的,每个类别占比10%,所以准确率这种全局指标大致够用。可一旦处理类别不均衡的数据,比如信用卡欺诈检测中欺诈样本只有0.1%,模型只要永远预测“非欺诈”,准确率就有99.9%。这就需要用精准率、召回率、F1值,甚至混淆矩阵来评估。
顺着混淆矩阵去看Softmax回归,有个很有价值的体验:在Fashion-MNIST里,线性模型通常分不清“上衣”和“衬衫”,因为它们像素分布太接近。通过查看哪个类别对哪个类别的误判最多,却能帮我们理解特征表达能力的上限在哪里。Softmax回归本身没有特征提取能力,它看到的就是一个扁平像素向量,所以当两个类别在原始像素空间上很相似时,无论训练多少次都难以分开。后续引入卷积神经网络后,同样的骨架换掉模型结构,准确率会大幅提升,原因正是卷积网络能自动提取局部纹理特征。
5.3 可视化预测结果:让模型输出变得直观
训练完模型后,通过可视化查看几个预测结果,比单纯看loss曲线更直观。可以用matplotlib画出测试集前若干个样本的真实标签和预测标签:
import matplotlib.pyplot as plt import numpy as np model.eval() sample_count_per_figure = 8 fig, axes = plt.subplots(1, sample_count_per_figure, figsize=(12, 3)) with torch.no_grad(): for i in range(sample_count_per_figure): X, y = test_dataset[i] logit = model(X.unsqueeze(0)) pred = logit.argmax(dim=1).item() axes[i].imshow(X.squeeze(), cmap='gray') axes[i].set_title(f"label: {y}, pred: {pred}") axes[i].axis('off') plt.tight_layout() plt.show()X.unsqueeze(0)能把形状从[1, 28, 28]变成[1, 1, 28, 28],这样才满足模型batch维度的要求。刚开始会忘记这些维度细节,多报个无所谓的RuntimeError: Expected 2D or 3D之类的维数错误,长长记性也是好事。
6. 超参数调优与训练效果对照
6.1 学习率:训练成败最关键的一个数
SGD的更新公式是参数 = 参数 - 学习率 * 梯度。学习率太大,每一步跨度过大,损失值可能在最小值附近来回震荡甚至发散;学习率太小,参数更新和蜗牛爬一样,迭代很多轮还在原地。
我用同一份数据分别测试了lr=0.01、0.1和1.0,效果对比如下:
| 学习率 | 10轮后的训练loss | 测试准确率 | 现象 |
|---|---|---|---|
| 0.01 | 0.62左右 | 82%左右 | 收敛偏慢,10轮还未到最佳点 |
| 0.1 | 0.42左右 | 86%左右 | 收敛快,结果理想 |
| 1.0 | 不降反升 | 10%左右 | 梯度爆炸,loss出现inf/nan |
从这张表可以看到,学习率差一个数量级,模型表现差距巨大。在实际训练中,如果loss曲线一直抖动不下降,首先检查的不是模型结构对不对,而是学习率是不是设得太大了。如果loss曲线稳定下降但在某处平缓,可以尝试慢慢调低学习率固定步长后继续训练。
6.2 batch size:影响收敛速度和梯度的稳定程度
batch size决定了每次参数更新前看到的样本数量。batch size越大,梯度方向越接近全体样本的“真实”梯度,更新方向越稳定;batch size越小,梯度噪声越大,但有时候这种噪声能帮模型跳出局部最优点。对小规模数据集而言,batch设为10~512影响不大;但在ImageNet规模下,batch size动辄上千,这时候学习率和batch size之间还有一套调参配合策略,入门阶段知道概念就行。
对于本文的Softmax回归,batch_size=256很合适。如果只想做一轮带学效果的演示,用128也行,差别主要在训练时间上。不建议一个batch直接放进全部6万张图,内存压力大且收敛行为并不一定更好。
6.3 epochs设置与早停的朴素思想
epochs代表整个训练集被完整遍历的次数。随epochs增加,loss下降速度会越来越慢,最终趋于稳定。以我跑Fashion-MNIST的经验,lr=0.1、batch=256、10个epoch的准确率大概86%,15个epoch能到87%左右,再往后提升微乎其微。
训练到某个阶段后,训练集loss还在下降,但测试集loss反而开始上升——这说明模型开始“死记硬背”训练数据的细节,也就是过拟合。虽然Softmax回归的容量很小,过拟合不明显,但很多人在更长训练轮次里会偶然看到这种现象。规范做法是每次epoch结束后都在验证集上计算一次指标,一旦验证集指标连续若干个epoch不再改善,就算还有剩余epochs配额也提前结束训练,这种策略叫早停。
6.4 完整调参实验记录参考
下面是一份我在入门阶段常用的实验对照记录方式,写完代码后把不同lr和epochs的结果记录下来,非常有助于培养做实验的扎实习惯:
lr=0.1, batch=256, epochs=10 -> test_acc 86.82% lr=0.1, batch=256, epochs=15 -> test_acc 87.41% lr=0.1, batch=512, epochs=10 -> test_acc 85.90% lr=0.01, batch=256, epochs=20 -> test_acc 85.63%如果看到类似“lr=0.1比lr=0.01更好”这样的结论,别急着当成金科玉律,它只对当前模型结构和数据集规模有效。换个数据集或者换个模型结构,同样的结论可能完全不同。记录和复盘实验,比记住某个具体数值有用得多。
7. 常见Bug与排查实录
7.1 维度不匹配与标签张量的形状
报错一:Size mismatch between tensors。多半是模型最后一层输出维度与标签类别数不一致,比如把nn.Linear(784, 10)写成了nn.Linear(784, 1),检查num_outputs即可。
报错二:Expected target size [256, 10], got [256]。原因是把target转成了one-hot或者错误地传入了2D张量。CrossEntropyLoss的target应当是一维整数索引,形状是[batch_size]。
报错三:模型输出形状是[256, 1, 28, 28]而不是[256, 10]。绝大多数情况是忘了在forward里做一个reshape(X.shape[0], -1),Linear层吃到了四维输入。
7.2 Loss是nan,但代码看着没问题
出现nan最常见的原因是学习率过大造成的梯度膨胀。调低学习率往往能解决。其次是数据里混入缺失值或无穷大的数,这个在图像数据里少见,但在真实业务数据里很常见,需要检查数据清洗步骤。再一个容易忽视的原因出现在自定义模型初始化时:如果手动把权重初始值调太大,比如标准差为1,反向传播算出来的梯度和更新量也可能让loss迅速发散到nan,这时把标准差调回0.01或者干脆不手动初始化的默认值即可。
7.3 训练准确率一直不高于随机猜测
10分类问题随机猜测的准确率是10%,如果训练了半天准确率还在10%到20%区间徘徊,甚至上下波动没有上升趋势,优先排查三个地方:是不是训练标签和模型输出类别数量对不上;是不是数据没有shuffle导致同类别样本连续出现;是不是学习率设太小导致收敛极慢。把这三处都检查一遍,通常能发现问题。如果还是没有改善,直接跑官方教程的原始版本做对照实验,看看是不是自己的实现细节有问题。
7.4 验证集准确率比训练集高一大截的迷思
这往往是数据分布问题而不是模型问题。比如训练集和测试集采集条件不同,或者评估时使用了不合适的预处理。还有一类情况是误把某个很简单的子集当成了验证集。模型在训练集上过拟合得很严重时,验证集准确率一般会远低于训练集,而不是反过来。所以看到这个现象时,先检查评估流程本身是否引入了隐藏的偏差,比急着调整模型结构更有意义。
7.5 “为什么别人的loss曲线比我光滑”的问题
训练loss曲线是否光滑与batch size、学习率都有关系。小batch size等价于引入大量随机梯度噪声,曲线锯齿感明显;大batch size噪声小,曲线更光滑。Pytorch官方教程里那种光滑的平滑下降曲线,往往是lr和batch size配合得比较好的结果,不代表你的代码有问题。重点是看整体趋势是否下降,而不是要求每一个点都比上一个点低。
8. 从Softmax回归向下一个阶段延伸的思路
写完Softmax回归后能做很多有意思的扩展,这些扩展会带着你一步步走向真正的深度学习:
- 把
nn.Linear(784, 10)改成带隐藏层的nn.Sequential(nn.Linear(784, 256), nn.ReLU(), nn.Linear(256, 10)),这就会一下子理解为什么激活函数是模型能力的来源。一个推荐的练手方向是:分别对比有无ReLU在训练速度上的差异,你会直观看到线性堆叠带来的表达能力上限。 - 给模型加一个隐藏层之后,尝试在MNIST/Fashion-MNIST上把准确率从86%左右提升到90%以上,之后再引入卷积层能看到准确率往98%以上走。
- 把model.train()、model.eval()、torch.no_grad()配合使用写进自己的“训练模板”里,以后每次开新项目都能直接复用。
从我个人的经验来说,Softmax回归是整个深度学习体系里最适合“反复徒手实现”的模型:数学不复杂、代码量小、却覆盖了数据加载、模型定义、损失函数、训练循环、评估体系、调参与排错的全流程闭环。一个模型你能不靠抄教程默写出来,才算真的会了。如果你能顺着这篇的内容,把训练循环的每一步为什么要有、去掉会发生什么,都回答上那么几句,那恭喜,你的Pytorch地基已经打牢了。