跟着这个系列走到这一章,你已经掌握了张量运算、自动求导和梯度下降这些基础工具,也跑通了用线性回归做连续值预测的完整流程。接下来要进入一个全新的问题类型:分类。分类和回归的差别看起来只是一个字,但建模思路、损失函数、评估方式全都变了。而Logistic回归(逻辑回归)正是打开分类世界大门的第一把钥匙。这篇是PyTorch系列教程的第5章,我会从"为什么分类不能用线性回归硬套""sigmoid到底解决了什么""交叉熵为什么是分类任务的默认选择"这几个层面讲透原理,再给出完整可运行的代码和调参经验。适合已经熟悉PyTorch基本操作、但对分类问题还没有体系化认识的读者。
1. 线性回归解决不了分类问题:这一章要补的认知缺口
1.1 分类和回归的本质差别
先明确一个容易被忽略的事实:分类任务的输出不是一个连续数值,而是一个类别。垃圾邮件是"垃圾"或者"正常",图片里"有猫"或者"没猫",用户"会流失"或者"不会流失"——输出空间是离散的。
如果我们硬把类别当作连续值来回归,比如把正常邮件标成0、垃圾邮件标成1,然后训练一个线性模型去逼近这个数值,会立刻遇到三个问题:
- 线性模型的输出范围是负无穷到正无穷。模型可能预测出-0.3或者1.7这样毫无意义的分数,你却不知道它该归到哪一边,只能人为定一个阈值,而这个阈值没有任何理论支撑。
- 回归模型对异常值极度敏感。如果训练数据里恰好有几个特征值很大的样本,拟合直线会被拖拽,决策边界跟着歪掉。
- 回归输出的数值没有"概率"含义。0.8和0.9之间到底差多少?模型自己也不知道。你无法回答"这个预测有多确定"。
所以,做分类不能直接在输出层用线性值,需要一层转换,把任意实数压到0到1之间,让结果能解释成概率。这个转换就是sigmoid函数。Logistic回归从数学上讲其实仍然是个线性模型,只是输出端多了一个非线性压缩。
1.2 Logistic回归在整个学习路线里的位置
很多初学者觉得Logistic回归太简单,随手就能调库,没必要单独花一章。这个想法我当年也有,直到后来回头看才发现,这一章是整条学习路线的分水岭。
往浅了说,Logistic回归是第一个真正的分类模型;往深了说,它就是一个只有输入层和输出层的极简神经网络——线性变换、激活函数、损失函数、反向传播,神经网络需要的所有部件它一个都不少。后续学多层感知机、卷积网络,无非是在这个骨架上多加几层线性变换和激活函数,再把损失函数换成多分类版本而已。
所以我把这一章当成"承上启下"的关键章节:它承接了线性回归里梯度下降的做法,又开启了以后所有分类模型的共同范式。把这一章吃透,后面看任何分类网络都不会觉得陌生。
2. Sigmoid函数:从"分数"到"概率"的桥梁
2.1 sigmoid的数学形式和直觉理解
sigmoid函数的定义是:
σ(z) = 1 / (1 + e^(-z))
它做的事情,是把全体实数映射到(0, 1)开区间。z=0时输出0.5;z越来越大,输出无限接近1;z越来越小,输出无限接近0。整个过程平滑、单调,不会出现跳跃。
这个函数有三条性质值得记住:
- 有界。输出永远在(0,1)内,天然适合解释成"属于正类的概率"。
- 单调。z越大,p越大,排序关系保留,不会颠倒。
- 导数好算。σ'(z) = σ(z)(1-σ(z)),用模型自己的输出就能算出梯度,反向传播非常省事。
直观理解可以用"打分再归一"来类比:线性部分给每个样本打一个原始分,z越高说明越像正类,然后sigmoid把这个分数翻译成概率。分数0.5对应的原始分是多少?正好是z=0,也就是w·x+b=0。
2.2 决策边界:模型到底在学什么
把公式写完整:
p = σ(w·x + b)
模型学的就是权重w和偏置b。预测时,如果p大于0.5就判成正类,否则判成负类。而p=0.5等价于w·x+b=0,这是一个关于x的线性方程,在二维平面上是一条直线,在更高维空间里是一个超平面。
这就是Logistic回归被称为"线性分类器"的原因——决策边界是线性的。注意,这里的"线性"指的是边界形状,不是说模型整体是线性的。sigmoid是非线性函数,但它没有改变"w·x+b=0"这个边界的线性本质。
理解这一点有个实际用途:当你发现某个二分类问题的数据在特征空间中根本不能用一条直线分开,比如两类样本交错缠绕在一起,那单个Logistic回归模型的上限就已经锁死了。这时候要么做特征工程,要么就得换更复杂的模型。提前知道模型的能力边界,能帮你少做很多无用功。
2.3 一个常见的误解:sigmoid能解决一切线性不可分问题
网上经常有人把sigmoid说得神乎其神,好像加上它模型就"非线性"了。这是对Logistic回归最普遍的误解。sigmoid只负责把线性输出压缩成概率,方便做概率解释和损失计算,它并不会让决策边界变弯。
判断一个分类模型是不是线性分类器,看它的决策边界就行。Logistic回归的边界永远是超平面,所以它骨子里就是线性模型。真正让模型具备表达非线性边界能力的,是后一章要讲的隐藏层和激活函数的堆叠。这里先把这个预期管理好,学神经网络的时候就不会晕。
3. 交叉熵损失:分类任务的正确损失函数
3.1 为什么均方误差在分类任务里会失效
接触分类问题的第一个直觉反应是:继续用均方误差(MSE)不行吗?预测输出和真实标签都是0到1之间的数,算距离看起来也合理。但它确实不行,而且是数学上注定不行。
把sigmoid和MSE组合起来看梯度。假设样本的真实标签是y,模型输出ŷ=σ(z),MSE损失是:
L = (ŷ - y)²
对z求导:
dL/dz = 2(ŷ - y)·σ(z)(1-σ(z))
问题出在最后那个因子σ(z)(1-σ(z))。当预测完全错误时,比如y=1,ŷ却接近0时,σ(1-σ)也接近0,梯度被乘得极小。越是错得离谱,梯度越小,模型学得越慢。这就是所谓的"饱和区"问题。用MSE配合sigmoid,初期训练会非常痛苦,损失曲线像被摁住一样半天不动。
这个现象的通俗类比是:一个方向感很差的人走夜路,越走错路灯光越暗,越暗越看不清路牌,于是只能在原地打转。我们需要一个"偏离目标越远,修正信号越强"的损失函数。
3.2 从极大似然推导二元交叉熵
交叉熵不是哪个大佬拍脑袋发明的,它是从概率模型里严格推导出来的。把模型输出ŷ解释成"样本属于正类的概率",那么样本的真实分布服从一个伯努利分布:
- y=1的概率是ŷ
- y=0的概率是1-ŷ
合并写成一个式子,单个样本的似然:
p(y|x) = ŷ^y · (1-ŷ)^(1-y)
把所有训练样本的似然乘起来,再取负对数,就得到二元交叉熵(BCE)损失:
L = -[y·log(ŷ) + (1-y)·log(1-ŷ)]
取负对数的原因,是连乘在数值上容易下溢,而且优化乘法不如优化求和方便,对数运算把连乘变成连加,数值稳定得多。
3.3 梯度对比:为什么交叉熵是"正确"的选择
对BCE求z的导数,中间过程不展开,结果非常漂亮:
dL/dz = ŷ - y
这个梯度的含义太舒服了:误差越大,梯度越大;误差为零,梯度为零。模型预测ŷ=0.1而真实y=1时,梯度是-0.9,修正信号强烈;预测接近正确时,梯度自动变小,不会出现MSE那种"越错越不学"的怪现象。
很多人学到这里只是记住了"分类要用交叉熵",却没有真正体会过两者的训练差异。我建议你有空做个对比实验:同一个数据集、同一个模型,分别用MSE和BCE训练,打印loss曲线。MSE那条曲线前期几乎贴地,BCE那条则平滑下降,差别一目了然。亲手看过一次,比背十遍公式都管用。
4. 从零实现一个可训练的Logistic回归
4.1 构造带标签的模拟数据
为了把注意力集中在模型本身,我用一个可复现的模拟数据来演示。生成2000个样本,每个样本2个特征,真实标签由一个隐藏的线性关系决定,再经过sigmoid生成概率,按概率采样得到二分类标签。
import torch import torch.nn as nn import torch.optim as optim torch.manual_seed(42) num_samples = 2000 num_features = 2 X = torch.randn(num_samples, num_features) w_true = torch.tensor([2.0, -1.0]) b_true = 0.5 logits = X @ w_true + b_true prob = torch.sigmoid(logits) y_true = (torch.rand(num_samples) < prob).float().unsqueeze(1) # 切分训练集和验证集 n_train = int(num_samples * 0.8) X_train, X_val = X[:n_train], X[n_train:] y_train, y_val = y_true[:n_train], y_true[n_train:]注意y_true被reshape成了(num_samples, 1)的列向量,这是PyTorch里BCELoss对target形状的要求。形状不匹配是新手最容易踩的坑,后面还会细说。
4.2 定义模型与训练循环
模型结构极其简单:一个线性层,后面接sigmoid。
class LogisticRegression(nn.Module): def __init__(self, input_dim): super().__init__() self.linear = nn.Linear(input_dim, 1) def forward(self, x): return torch.sigmoid(self.linear(x))损失函数用BCELoss,优化器用最朴素的SGD,学习率设0.1。
model = LogisticRegression(num_features) loss_fn = nn.BCELoss() optimizer = optim.SGD(model.parameters(), lr=0.1) epochs = 200 for epoch in range(epochs): preds = model(X_train) loss = loss_fn(preds, y_train) optimizer.zero_grad() loss.backward() optimizer.step() if (epoch + 1) % 20 == 0: print(f"Epoch {epoch + 1:3d} | Loss {loss.item():.4f}")训练循环的四个动作——前向算损失、清零梯度、反向传播、更新参数——是PyTorch里所有模型训练的通用骨架,后面不管模型多复杂,循环体都是这个四步。其中optimizer.zero_grad()是最容易被新手漏掉的一行,漏掉的话梯度会在多轮迭代里累加,模型更新方向会变得乱七八糟。
4.3 评估指标:准确率怎么算才靠谱
训练完怎么知道模型好不好?二分类最常用的指标是准确率:预测类别和真实标签一致的样本占比。
def evaluate(model, X, y): model.eval() with torch.no_grad(): preds = model(X) preds_class = (preds >= 0.5).float() acc = (preds_class == y).float().mean().item() return acc train_acc = evaluate(model, X_train, y_train) val_acc = evaluate(model, X_val, y_val) print(f"Train Acc: {train_acc:.4f} | Val Acc: {val_acc:.4f}")这里有两个细节值得强调。
第一,务必在评估时用model.eval()和torch.no_grad()。虽然这个模型里没有dropout和batch normalization,用不用eval差别不大,但从一开始养成好习惯,以后训练深层网络时就不会因漏切模式而得到错误的评估结果。
第二,尽量用验证集而不是训练集报准确率。在训练集上模型永远表现得更好,如果验证集和训练集的准确率差距明显拉大,说明模型开始记忆数据而不是学习规律,这个信号在训练后期特别重要。
4.4 数值稳定性更好的BCEWithLogitsLoss
上面用的方案是"线性层 + sigmoid + BCELoss",逻辑清晰,适合理解原理。但实际工程里我更推荐另一种写法:模型输出原始logits,不套sigmoid,损失函数用BCEWithLogitsLoss。
model_logits = nn.Linear(num_features, 1) loss_fn_stable = nn.BCEWithLogitsLoss() for epoch in range(epochs): logits = model_logits(X_train) loss = loss_fn_stable(logits, y_train) # 其余步骤同上BCEWithLogitsLoss把sigmoid和BCE合并进一个函数,内部做了数值稳定性处理,避免sigmoid输出极度接近0或1时log(0)导致的下溢问题。以我的经验,除非你在写教学示例,否则生产代码一律用BCEWithLogitsLoss就对了。评估时记得手动套sigmoid,因为模型的输出现在是logits。
5. 训练Logistic回归时的踩坑记录与调试方法
5.1 特征不归一化,训练直接"原地踏步"
我在某次实验里造过一份数据,一个特征取值在0~1之间,另一个特征在0~10000之间。直接用SGD训练,损失下降极慢,几百轮过去准确率还在60%附近打转。原因在于:n维梯度下降的收敛速度受特征尺度影响很大。尺度大的特征对应的权重更新量也大,导致参数在最优解附近来回震荡,而小尺度特征却几乎学不动。
解决办法是特征标准化。常规做法是把每个特征减去均值、除以标准差,让所有特征落在差不多的量级。在PyTorch里可以这么写:
mean = X_train.mean(dim=0, keepdim=True) std = X_train.std(dim=0, keepdim=True) X_train = (X_train - mean) / std X_val = (X_val - mean) / std注意:均值、标准差只能用训练集计算,再用同一套参数去标准化验证集和测试集。用验证集自己的均值去标准化,属于数据泄露,会高估模型表现,这是我见过发生率非常高的隐蔽错误。
5.2 学习率过大,损失曲线像心电图
SGD的默认学习率是0.01,但这不是万能值。如果你想省事直接把学习率调到1.0,大概率会看到损失值在训练过程中剧烈上下跳动,永远降不下来。原因很简单:参数一步跨得太大,直接跳过最优点,在两侧反复横跳。
遇到这种情况,我的排查顺序是:
- 把学习率降到当前值的十分之一,重跑一遍,看损失是否变平滑。
- 如果下降还是慢,再逐步增大,找到一个"能稳定下降的临界值"。
- 观察前20轮的loss值,如果从第一轮开始就异常大,多半是学习率或者初始化出了问题,而不是数据问题。
一个朴素但实用的经验:把损失曲线打印出来,比盯着最终准确率猜来猜去高效得多。训练曲线会直接告诉你欠拟合、过拟合、学习率不合适这三大类问题各自长什么样。
5.3 三个高频新手错误
第一个是梯度清零遗漏。忘了zero_grad(),每轮的梯度会和上一轮累加,参数更新方向被历史梯度污染,表现就是损失随机波动。第二个是目标值形状不对。BCELoss要求target和prediction形状一致,如果你把y做成了一维向量(batch,),而模型输出是(batch, 1),广播机制会强行把形状对上,算出来的损失完全错乱,但程序不报错。最阴险的就是这类"不报错但结果错误"的问题。第三个是评估时忘记阈值。模型输出的是概率,比较时要先(preds >= 0.5).float(),直接拿概率和0/1标签比大小,准确率会低得离谱,而且程序没有任何提示。
5.4 类别不平衡时,准确率会骗人
假如数据里负类占90%、正类占10%,模型什么都不学、一律预测负类,准确率就已经有90%。这时候你盯着准确率会以为模型不错,实际上它对正类没有任何判别能力。
这个场景下准确率不是好指标,应该去看更细粒度的指标,比如精确率、召回率、F1分数。同时可以考虑调整决策阈值,不再固定用0.5,而是根据验证集上的精确率召回率平衡点来选阈值。Logistic回归输出的概率本身就支持这种灵活操作,这也是它作为概率模型的优势之一。
5.5 判断数据本身有没有信号
还有一种情况让人非常挫败:所有超参都调过了,损失就是不降。这时候别急着改模型,先确认数据里到底有没有可分性。我把训练好的模型在验证集上的表现和"随机猜测"做个对比,如果两者差不多,问题往往出在特征压根不含有效信息,或者数据生成过程就和标签无关。与其在模型上继续折腾,不如回头做特征工程、补充数据,方向才正确。
6. 从二分类到多分类:下一站的帷幕
6.1 Softmax回归:二分类的自然推广
实际项目里遇到的多半是多分类问题:手写数字识别要分10类,图像分类要分上百类。二分类的sigmoid无法直接搬到K分类场景,这时要用softmax函数,把K个原始分数转换成K个和为1的概率。PyTorch里做法是一层线性层输出K维logits,损失函数用CrossEntropyLoss:
model_multi = nn.Linear(num_features, num_classes) loss_fn_multi = nn.CrossEntropyLoss() # logits: (batch, num_classes) # target: (batch,) 每个元素是类别索引,int类型这里有个新手极易踩的坑:CrossEntropyLoss在PyTorch里已经内置了softmax,你喂给它原始logits就行,千万别在模型输出层自己再加softmax再算损失,那样等于softmax套softmax,数值和梯度都会出问题。另外target要的是类别索引的整数张量,不是one-hot编码。这些细节等到下一章扩展时我会专门演示。
6.2 Logistic回归的本质:最简神经网络
站在这一章末尾回头看,Logistic回归的架构是"输入层→单层线性变换→sigmoid→损失",这不就是一个没有任何隐藏层的神经网络吗?如果把sigmoid换成ReLU,把单层变成"输入→隐藏层→输出"的多层结构,就是多层感知机;把线性层换成卷积层,就是卷积网络。
所以我在给新人建议时总说:不要因为Logistic回归"古老"就轻视它。它是理解一切现代分类模型的起点,也是排查问题时的基线模型——遇到任何新分类任务,先跑一个Logistic回归做基准,如果连它都打不过,说明特征工程或数据质量还有大问题,别急着上大模型。
最后分享一个我自己的习惯:每次训练分类模型,我都会把训练集和验证集的损失曲线画在同一张图上,这是最快暴露欠拟合/过拟合的手段。这套方法从Logistic回归时代一直用到现在,从没坑过我。下一章开始,我们就要给这个最简单的骨架加上隐藏层,进入真正的神经网络世界,到时候你会发现,今天学的东西每一个都还在发挥作用。