这个系列写到第五篇,前面我们把环境搭起来、把张量玩明白、把自动求导搞懂,又用线性回归把完整的训练流程走了一遍。今天这篇要解决的是一个比连续数值预测更常见、更贴近真实业务场景的问题——二分类。标题里写的Logistic Regression,中文叫逻辑回归,很多人刚听到这个名字就懵了:明明是分类问题,为什么叫“回归”?别急,这个误会搞清楚之后,你对它的理解反而会提升一大截。这篇的主角就是它,场景锁定在最基础也最刚需的二分类:输出只有两个选项,比如邮件是不是垃圾邮件、用户明天会不会下单、一张图片里到底有没有目标物体。
这篇文章适合正在按系列顺序学到这里的读者,也适合那些已经把线性回归跑通、想试试分类任务但不知道从哪里入手的朋友。我会从为什么线性回归不能直接用来分类讲起,然后手写一份可以用在你自己的数据上的完整PyTorch代码,最后把我在实操里反复踩过的坑都列出来。保证你看完能跑通一个真正意义上的二分类训练流程,而不只是复制粘贴完事。
1. 逻辑回归名字里的“回归”到底在回归什么
1.1 分类和回归:输出空间的本质差异
回归问题的输出是连续数值,比如预测明天的温度是23.5度还是23.6度,预测房价是300万还是310万,这种场景下输出值有明确的数学距离含义——23.6和23.5差0.1度,这个差值是真实可解释的。
分类问题的输出则不是数值,而是类别标签。二分类就是判断“是”或“否”,比如这笔交易是否有风险、这个患者是否患病。你输出一个1.7或者-0.3都没法直接对应到“有风险”或“没风险”上面去。你要的是一个概率——模型告诉你有风险的概率是0.97,然后你再根据这个概率做判断,比如超过0.5就拦截。
这里就出现了一个矛盾:线性回归的输出范围是整个实数轴,从负无穷到正无穷。但概率天然被限制在0到1之间。那怎么办?给线性回归的输出套上一层“压缩”就行了。
1.2 sigmoid函数:把任意实数压缩成概率
这层压缩用的就是sigmoid函数,公式长这样:
σ(z) = 1 / (1 + e^(-z))
它的输入z是任意实数,输出永远落在(0,1)区间内。z越大,输出越接近1;z越小,输出越接近0;z=0时输出正好是0.5。这个函数图像是条平滑的S形曲线,所以叫sigmoid,S形函数的意思。
我一般喜欢拿阀门做类比:不管你往管道里灌多少水,阀门出口的水位最高只能到某个上限、最低也只能到某个下限,水位永远在这两个值之间变化。sigmoid就是这个阀门,它把“灌进来的数值”压缩成了“一个范围内的概率”,你就能把它解读为“模型认为样本属于类别1的可能性”。
逻辑回归的完整数学表达就是一句话:先算z = w^T x + b,再把z丢进sigmoid得到p = σ(z)。如果你那天手痒非要把sigmoid换成别的函数,比如tanh,也不是完全不行,但tanh输出范围是(-1,1),你有负概率,解释起来非常别扭——这是过来人的教训。
1.3 决策边界:一条直线如何划分两个类别
既然p = σ(w^T x + b),那么“模型判定为类别1”的条件就是p > 0.5。把p > 0.5代回去算一下:
σ(z) > 0.5 => z > 0 => w^T x + b > 0
也就是说,真正决定分类结果的不是sigmoid本身,而是w^T x + b = 0这条直线(在高维情况下是超平面)。在二维平面上,这条线就是决策边界,它把整个平面切成了两半,一边预测为类别0,另一边预测为类别1。sigmoid只是在决策边界的基础上加了一个“软”的置信度——离边界越远的样本,预测概率越接近0或1,说明模型越有把握。
这也顺便解释了逻辑回归的一个特点:它是线性分类器。它只能画一条直线来分割两个类别,如果你的数据分布是那种环形包着一个中心点、或者互相缠绕成太极图的样子,逻辑回归再怎么训练都做不到彻底分开,因为线性边界就是它的能力边界。遇到那种问题,后面学到的神经网络非线性拟合能力才有用武之地。到这里你应该明白两件事:逻辑回归解决的是分类问题,它叫“回归”是因为数学形式上跟线性回归一脉相承,只是多了一步sigmoid压缩。
2. 训练逻辑回归的损失函数,为什么选择交叉熵
2.1 均方误差在分类任务里的别扭之处
如果你按照线性回归的老套路,把损失函数写成MSE,就是0.5*(y - p)^2这种形式,训练起来会非常难受。原因要从梯度说起。
sigmoid函数有一个性质:它两端的导数趋近于0,就是当z特别大或特别小时,函数曲线几乎已经平了。MSE对w的梯度表达式里包含σ'(z)这个因子,误差很大的时候σ'(z)反而接近0,这就导致了一个悖论:模型错得越离谱,梯度越小,参数更新越慢。实际表现就是loss下降极慢,训练半天还在原地打转。
这个问题在深度网络里还会被进一步放大,变成梯度消失的核心原因之一。所以分类问题几乎都采用交叉熵损失,而不是简单套用回归的MSE。
2.2 最大似然视角下的交叉熵损失
交叉熵损失在二分类问题里长这样:
L = -[y log(p) + (1-y) log(1-p)]
y是真实标签,取0或1;p是模型输出并且已经过了sigmoid的概率。这个公式不用死记,你可以从直觉上理解它:
当y=1时,损失是 -log(p)。如果模型预测p=0.9,损失就是 -log(0.9)≈0.105,很小,说明模型“很自信且正确”;如果模型预测p=0.1,损失是 -log(0.1)≈2.3,非常大,说明模型“非常自信但是错了”,要狠狠惩罚它。
当y=0时对称,损失是 -log(1-p)。预测p接近0时损失小,p接近1时损失大。
这个损失函数的本质是从最大似然估计推导出来的:我们假设每个样本的标签服从以模型输出概率为参数的伯努利分布,然后最大化所有样本的似然,取负对数就得到交叉熵。跟MSE相比,它有一个非常宝贵的性质:在训练初期的随机状态下模型输出通常在0.5附近,此时梯度不会过小,能保证正常学习。而且交叉熵对“错误但自信”的预测惩罚极大,这逼着模型不断往正确方向校准概率,而不是满足于“比0.5多一点就行”。
2.3 PyTorch里BCELoss与BCEWithLogitsLoss的正确选择
PyTorch给二分类提供了两个现成的损失函数,很多新手在这里翻车。
BCELoss期望你输入的是已经过sigmoid的概率值p。BCEWithLogitsLoss则期望你输入的是原始的logits,也就是z = w^T x + b,它在内部帮你计算sigmoid再把结果套进交叉熵公式。
重点来了:由于它在内部做了sigmoid,如果外面模型中又手动加了一层sigmoid,就等于算了两次sigmoid,输出会被二次压缩,概率值被压得偏小,梯度表现也会变得很奇怪,训练出来效果一团糟。我在代码里一贯的写法是:模型forward只输出logits,损失函数用BCEWithLogitsLoss;只有在做预测评估或者画图时,才单独对模型输出调用sigmoid。这样做的另一个好处是数值稳定性更好——BCEWithLogitsLoss把logit和交叉熵做了合并计算,避免了分别计算sigmoid再取对数时的数值下溢问题。
写代码时如果发现两种loss结果差异很大,先检查是不是把sigmoid重复写了。
3. 从零训练一个二分类模型:完整PyTorch实现
3.1 构造一份可复现的实验数据
为了把注意力全部放在逻辑回归本身而不是数据预处理上,我直接用torch造一份二维的可分数据集,像两个小圆团,一个团在左下,一个团在右上。这样训练完之后还能画一条清晰的决策边界出来,效果非常直观。
import torch import torch.nn as nn import torch.optim as optim import matplotlib.pyplot as plt torch.manual_seed(42) # 参数设定 n_samples = 400 center0 = torch.tensor([-1.5, -1.5]) center1 = torch.tensor([1.5, 1.5]) # 生成两类数据 X0 = center0 + torch.randn(n_samples, 2) * 0.8 X1 = center1 + torch.randn(n_samples, 2) * 0.8 X = torch.cat([X0, X1], dim=0) y = torch.cat([torch.zeros(n_samples), torch.ones(n_samples)]).unsqueeze(1) # 打乱 perm = torch.randperm(X.size(0)) X, y = X[perm], y[perm] # 切分训练集与测试集 ratio = 0.8 split = int(len(X) * ratio) X_train, X_test = X[:split], X[split:] y_train, y_test = y[:split], y[split:] print("训练集大小:", X_train.shape, "测试集大小:", X_test.shape)数据里故意加了0.8的标准差,让两个簇的边缘有一点重叠,这样模型做不到100%准确率,反而更真实。如果你把标准差调成0.1,任何模型都能轻松分到99%以上的准确率,反而不容易看到训练loss下降的过程。
标签shape这里重点提醒:y我用的是.unsqueeze(1),把形状从[800]变成了[800,1],原因后面讲BCEWithLogitsLoss的输入要求时会说明。
3.2 定义模型、损失函数和优化器
逻辑回归的模型定义比线性回归还要简单,一个nn.Linear就够了,唯一要注意的是forward里不接sigmoid。
class LogisticRegressionModel(nn.Module): def __init__(self, input_dim): super(LogisticRegressionModel, self).__init__() self.linear = nn.Linear(input_dim, 1) def forward(self, x): # 输出的是logits,不是概率 return self.linear(x) model = LogisticRegressionModel(input_dim=2) criterion = nn.BCEWithLogitsLoss() optimizer = optim.SGD(model.parameters(), lr=0.1)为什么这里用SGD而不是Adam?对逻辑回归这种简单模型,SGD加一个合适的学习率就能稳定收敛,你还能更清楚地观察到梯度下降的实际过程。换成Adam也没问题,但很多教程直接用Adam掩盖了学习率调参的基本功。我建议你训练逻辑回归时先用SGD体验标准流程,等到后续课程处理更复杂的网络结构时再换Adam不迟。
3.3 训练循环里的关键细节
训练循环骨架和线性回归一模一样,但有几个细节容易踩坑,我一条条说。
epochs = 600 train_losses = [] for epoch in range(epochs): model.train() # 前向传播 logits = model(X_train) loss = criterion(logits, y_train) # 反向传播 optimizer.zero_grad() loss.backward() optimizer.step() train_losses.append(loss.item()) if (epoch + 1) % 100 == 0: # 计算训练准确率 with torch.no_grad(): probs = torch.sigmoid(model(X_train)) preds = (probs > 0.5).float() acc = (preds == y_train).float().mean().item() print(f"Epoch {epoch+1:03d} | Loss: {loss.item():.4f} | Acc: {acc:.4f}")第一个细节是optimizer.zero_grad()的位置。PyTorch不会自动清零梯度,如果不手动调用,每个batch的梯度会累加到上一次的梯度上,参数更新方向被带偏。标准顺序一定是:清梯度、前向、算loss、反向、更新。
第二个细节是preds == y_train的形状匹配。y_train是[640,1],preds也是[640,1],比较结果才能正确计算mean。如果你当初生成y的时候没加unsqueeze,这里就要reshape或者用.view(-1,1)对齐,否则很容易出现一个[640]一个[640,1]的广播错误,虽然不一定会报错,但计算出来的acc一定是错的。
第三个细节是在评估时用with torch.no_grad()包起来。评估阶段不需要计算梯度,这样既能避免意外修改梯度状态,也能省内存、加快速度。虽然对这个规模的模型影响不大,但好习惯要从第一课养成。
实测之下,这个模型在100轮左右loss已经从0.7降到0.3附近,300轮以后基本收敛,最终训练准确率在95%左右。测试集准确率也在这个数字附近,说明模型没有明显过拟合。
3.4 决策边界可视化与模型评估
学深度学习最兴奋的瞬间就是把训练结果画出来看,逻辑回归的直接画决策边界有天然优势。我一般画两种图:一种是样本点的预测概率分布,另一种是概率等于0.5的边界线。
def plot_decision_boundary(model, X, y): x_min, x_max = X[:, 0].min().item() - 0.5, X[:, 0].max().item() + 0.5 y_min, y_max = X[:, 1].min().item() - 0.5, X[:, 1].max().item() + 0.5 xx, yy = torch.meshgrid( torch.linspace(x_min, x_max, 200), torch.linspace(y_min, y_max, 200), indexing="ij", ) grid = torch.stack([xx.reshape(-1), yy.reshape(-1)], dim=1) with torch.no_grad(): probs = torch.sigmoid(model(grid)).reshape(xx.shape) plt.contourf(xx.numpy(), yy.numpy(), probs.numpy(), levels=20, cmap="RdBu_r", alpha=0.6) plt.scatter(X[:, 0], X[:, 1], c=y.squeeze().numpy(), cmap="RdBu_r", edgecolors="k") plt.title("Decision Boundary & Probability Heatmap") plt.xlabel("Feature 1") plt.ylabel("Feature 2") plt.show() plot_decision_boundary(model, X_test, y_test)图上可以看到一条明显的斜线划过平面,一侧偏向蓝色、另一侧偏向红色,过渡区域颜色渐变就是模型“不确定”的区域,也就是预测概率在0.5附近的地方。理想情况下,这条线的位置应该刚好穿过两个簇的间隙。
这里我强烈建议你训练完以后,再单独打印一下模型参数:
for name, param in model.named_parameters(): print(name, param.data)linear.weight和linear.bias直接对应决策边界的法向量和偏置,你可以在草稿纸上手动算一下这条边界和两个簇的相对位置关系。逻辑回归本身不复杂,花两分钟把参数和几何图形对应起来,对你后面理解神经网络隐藏层帮助巨大。
4. 实操中的常见坑与排查记录
4.1 Loss不下降、准确率停留在50%怎么排查
我在带人做项目时遇到最多的问题就是这个:代码跑起来了,但loss像一潭死水,准确率永远在50%附近打转。排查思路要按顺序走,别上来就改模型。
先检查数据分布。拿我上面造的这份数据举例,如果你标准差改成5.0,两个簇完全重叠在一起,任何线性模型都不可能分得开,准确率在50%就是正常的。这一步验证方法最简单:画个散点图看看。
然后检查学习率。lr设置太小,参数几乎不动,loss当然不降;lr设置太大,loss会震荡甚至变成NaN。我的经验是,对这种零均值、单位方差附近的标准化数据,SGD的学习率从0.01起步,最大不要超过1,每失败一次除以10再试。
最后检查归一化。如果你的特征范围是比如[0, 10000]这种量级,b和w的梯度计算会非常不稳定,数据直接送进模型很容易梯度爆炸。标准化是所有深度学习训练的前提,逻辑回归虽然模型简单,但对该做的归一化免疫不了。
4.2 标签形状和数值类型引发的隐藏Bug
这个坑的隐蔽性极强。y的shape如果不对,PyTorch不一定会报错,而是通过广播机制默默给你算出一个“看似合理”的损失,实际上结果完全错乱。BCEWithLogitsLoss要求target和predicted有相同的shape,如果一个是[640,1]另一个是[640],在多数版本会直接报错;但如果你手动做过某些reshape操作,偶尔能碰巧通过广播对齐,导致精度始终异常。
另外就是y的数据类型。如果标签是整型,传给BCEWithLogitsLoss前要转成float类型:
y = y.float()CrossEntropyLoss可以接受整型标签,但BCEWithLogitsLoss不行。我见过不少新手在生成数据时用整数做标签,丢进loss函数后报了一堆看不懂的类型错误,其实就是这一行缺了。
4.3 模型输出的解释:logits与概率别搞混
模型在训练时输出的是logits,评估时才套sigmoid转成概率。这里有个细节:如果你用model(X_test)直接输出数值,很可能是正负几十上百这种大数,因为logits没有上界,套上sigmoid后会看到概率被完美压进0~1区间。
我见过有人在推理时忘了套sigmoid,直接把>0当类别1,其实是等效的——因为logit>0等价于概率>0.5。这在逻辑回归的数学推导下等价,但不能推广到其他模型场景,最好养成套sigmoid看概率的好习惯。概率的绝对值才是你信心的量化指标,一个接近0.8的概率跟一个接近0.51的概率,虽然分类结果一样,但它们的置信度天差地别。
4.4 样本不均衡时,阈值和准确率都要动态调整
如果你用的是真实业务数据,大概率会碰到正样本只占5%的情况。这时候默认的0.5分类阈值往往不是最优解,模型会倾向于把一切都判成负类,因为那样loss最小。准确率看着挺高,比如95%,但你仔细想,全预测负类也能拿95%的准确率,等于这个模型完全没用。
正确的做法是:训练完成后画一下验证集上的概率分布,或者尝试不同阈值下的精确率与召回率。如果你更看重把正样本都找出来,就以召回率为优先,调低阈值,比如设到0.3;如果更看重预测出来的正样本别出错,就调高阈值,比如0.7。阈值完全取决于你的业务诉求,没有一把通用的标尺。
我每次做二分类项目都会顺手画一张模型的ROC曲线,算一下AUC。这个值不受阈值影响,能反映模型本身的区分能力。逻辑回归模型AUC那怕只有0.75,在业务上也可能有实际价值,关键看你怎么理解它输出的概率。
最后分享一点我自己的习惯:训练任何二分类模型,我都不会只盯着训练集准确率看。先画loss下降曲线,再单独打印测试集上的logits分布,最后看一眼学到的权重数值。这三件事做完,你的模型基本上就没有什么秘密了。如果想趁热打铁继续往下走,下一步可以尝试把逻辑回归的线性层换成一个两层的小神经网络,你马上就会发现它能把今天这种线性不可分的数据也分得干干净净,那是模型表达力的第一步飞跃。