☰
逻辑斯蒂回归PyTorch实践:从线性回归到分类模型完整指南
2026/10/8 2:37:18 网站建设 项目流程

我最早接触逻辑斯蒂回归的时候,也和大家一样纳闷:明明名字里带“回归”,怎么全网教程都在拿它做二分类?其实这恰恰是理解PyTorch深度学习实践的一个绝佳切口。逻辑斯蒂回归是承上启下的关键一环——往上接住线性回归的线性输出,往下引出神经网络的非线性表达。这篇博文不讲虚的,直接从原理到代码,把PyTorch实现逻辑斯蒂回归的完整流程、损失函数选择、训练细节和常见坑位一次讲清楚,适合刚学完线性回归、准备迈入分类任务的读者,也适合想把手头PyTorch代码写得更有章法的人。

1. 逻辑斯蒂回归到底在解决什么问题

1.1 从线性回归到分类任务的“痛点转移”

凡是写过线性回归的读者,都习惯了一个套路:把特征 x 送进 y = wx + b,得到的 y 是连续数值,然后拿均方误差(MSE)去衡量预测值和真实值差多远。这种设定处理房价预测、温度预测这类回归问题非常顺,因为任务本身要求输出连续值。但一旦切换到分类场景——判断一封邮件是不是垃圾邮件、一个肿瘤是良性还是恶性——线性回归的短板立刻暴露。

我举个最直观的例子。假设你拿考试成绩预测“是否及格”,及格标记为1、不及格标记为0。用线性回归去拟合,模型可能对某个高分样本输出 1.8,对某个低分样本输出 -0.3。这两个数值虽然“分得开”,但没有任何概率含义,你没法回答“及格概率是多少”,更没法判断 1.8 和 2.5 哪个更“及格”。更麻烦的是,训练数据里混入几个极端值,回归直线就会被强行拉偏,原本清晰的决策边界可能被破坏。

逻辑斯蒂回归的解决思路非常巧妙:不直接回归到 0 或 1 这两个硬标签,而是先保留线性回归的 wx + b 这个“打分函数”,再在外面套一层 Logistic 函数(也就是 Sigmoid),把任意实数压缩到 (0, 1) 区间。 这个输出可以直接解释为“属于正类的概率”。模型要学的东西,从“逼近具体数值”变成了“逼近概率”,问题性质完全变了。

1.2 Sigmoid 函数:从打分到概率的“压缩转换器”

Sigmoid 的数学形式是 σ(z) = 1 / (1 + e^(-z)),其中 z = wx + b。这个函数有三个特性让它在分类场景里特别好用。

第一,值域固定在 (0, 1),天然匹配概率语义。第二,函数单调递增,z 越大概率越接近 1,z 越小概率越接近 0,保留了线性得分之间的相对大小关系。第三,函数在 z=0 附近变化最剧烈,也就是说决策边界附近的样本,概率变化最敏感;离边界很远的样本,概率迅速饱和到 0 或 1,这种“两极分化”的特性恰好贴合分类的硬判决需求。

我建议初学者把这个函数看成“阀门”:线性打分是水流,Sigmoid 是阀门。水流再大,出口流量也封顶在 1;水流再小甚至倒流,出口也不会低于 0。分类任务里的“边界”就是阀门开度最灵敏的位置,也就是 z=0 对应的点,对应到特征空间里就是 w·x + b = 0 这个超平面。

注意:这里的“回归”二字是历史遗留命名。逻辑斯蒂回归本质是分类模型,只是它借用了回归框架里的线性加权求和结构。理解这一点,后面看源码才不会困惑。

1.3 为什么分类不能用 MSE:问题出在梯度上

很多初学者会条件反射:既然输出变成了概率,那拿概率和真实标签算 MSE 总行了吧?我在学习阶段也踩过这个坑,后来才彻底想明白为什么不行。

关键在于交叉熵(Cross Entropy)和 Sigmoid 的天然搭配。如果输出经过 Sigmoid 后用 MSE 计算损失,损失函数对权重 w 的梯度里会多出一项 σ'(z),也就是 Sigmoid 的导数。Sigmoid 的导数是 σ(z) * (1 - σ(z)),这个式子的最大值只有 0.25,而且当预测很自信(概率接近 0 或 1)时,导数趋近于 0。结果就是:模型在错误方向上“滑得更远”时,梯度反而越小,更新越慢,表现为训练早期 loss 下降极其缓慢,甚至长时间停滞。

而交叉熵损失配合 Sigmoid,梯度里消掉了 σ'(z) 项,误差越大梯度越猛,误差越小梯度越平缓,每一步更新方向都直接对准“让预测更接近真实标签”这个目标。后面第 3 节我会给出 PyTorch 里两种写法的对比代码,跑一下就知道差距有多明显。

2. 原理与数学推导:从线性回归到逻辑斯蒂回归的完整演进

2.1 模型表达式与决策边界是怎么来的

逻辑斯蒂回归的完整预测流程可以拆成两个阶段。第一阶段是线性打分:z = w^T x + b,这里和线性回归一模一样。第二阶段是概率映射:p = σ(z) = 1 / (1 + e^(-z))。最终判定类别时,通常以 0.5 为阈值,p ≥ 0.5 判为正类,p < 0.5 判为负类。

把 p = 0.5 代回去会发现 σ(z) = 0.5 等价于 z = 0,也就是 w^T x + b = 0。这个方程在二维特征空间里是一条直线,在三维空间里是一个平面,在高维空间里是一个超平面,这就是模型的决策边界。 训练过程本质上就是在找一组 w 和 b,让这个边界把两类样本尽可能干净地分开。

这里还有一个特别值得品味的细节:我们经常听说逻辑斯蒂回归是“线性模型”,并不是说它的输出是线性的,而是说它的决策边界是线性的。Sigmoid 只是把线性打分“弯”了一下,但没有改变打分本身的结构。这也是为什么逻辑斯蒂回归处理不了线性不可分的数据,也是为什么后续要引入神经网络——用多层非线性变换去弯折决策边界。

2.2 损失函数推导:极大似然估计与交叉熵的统一

逻辑斯蒂回归的损失函数不是拍脑袋定的,它来自统计学里的极大似然估计(MLE)。我们先写出单个样本的概率表达式:如果真实标签 y=1,模型预测 p,我们希望 p 越大越好;如果 y=0,模型预测 1-p,我们希望 1-p 越大越好。合并写成 P(y|x) = p^y * (1-p)^(1-y)。

对所有样本求联合概率,再取对数,就得到对数似然函数。MLE 的思想是找一组参数让这个对数似然最大。为了套用梯度下降“最小化损失”的框架,我们给对数似然加个负号,就成了负对数似然(NLL),也就是交叉熵损失。

展开之后就得到二分类交叉熵的经典形式:

L = - [ y * log(p) + (1-y) * log(1-p) ]

这里有几个关键的等价关系值得留意。首先是 log 和 Sigmoid 都是单调函数,所以最大化 p 的概率等价于最大化 log(p)。其次是 p 是 Sigmoid(z) 的结果,log(Sigmoid(z)) 可以化简成 log(1/(1+e^(-z))) = -log(1+e^(-z)),这个形式在数值上更稳定。PyTorch 里的BCEWithLogitsLoss正是利用了这个数学性质,把 Sigmoid 和 log 融合在一起,避免因中间值过小造成数值溢出。

2.3 多分类扩展:Softmax 其实是逻辑斯蒂的“高维亲戚”

理解二分类逻辑斯蒂回归之后,多分类问题就顺理成章了。最通用的做法是 Softmax 回归,把逻辑斯蒂回归的单个 Sigmoid 替换成 Softmax 函数。Softmax 将一组线性得分 z1, z2, ..., zK 变成一组和为 1 的概率:p_i = e^(zi) / Σ e^(zj)。

有意思的是,当类别数 K=2 时,Softmax 和二分类逻辑斯蒂回归是数学等价的。因为 Softmax 的两个输出满足 p1 + p2 = 1,本质上只有一个自由度,和二分类的单一概率输出完全一致。 PyTorch 里常见的CrossEntropyLoss也是这个逻辑:它对网络输出先在内部计算 Softmax,再算交叉熵。如果你训练二分类网络,既可以用 Sigmoid +BCEWithLogitsLoss,也可以用两输出节点 +CrossEntropyLoss,只是前者更节省计算、更常见。

我在实际项目中处理多标签分类任务时(比如一张图片同时包含猫和狗),就是给每个类别单独一个 Sigmoid 输出,配合BCEWithLogitsLoss,因为在那种场景下每个类别的概率是独立的,Softmax 的全概率和为 1 反而会限制表达能力。这个取舍在原理上是一通百通的。

3. PyTorch 实现逻辑斯蒂回归:完整代码与逐段拆解

3.1 环境准备与最简数据构造

跑今天的代码不需要特别大的算力,CPU 就能轻松完成。不过为了后续扩展需要,我还是建议配置好一个干净的 PyTorch 环境。如果你用的是 Anaconda,在终端里依次执行:

conda create -n pytorch python=3.10 conda activate pytorch pip install torch torchvision matplotlib

如果你有 NVIDIA 显卡,可以到 PyTorch 官网选择对应 CUDA 版本的安装命令。没有显卡也完全不影响本文内容,逻辑斯蒂回归的参数量极小,CPU 训练速度足够快。

为了把注意力集中在模型本身,我不用现成的复杂数据集,而是手动构造一个线性可分的二维二分类数据集。这样方便可视化决策边界,也方便排查问题。

import torch import torch.nn as nn import matplotlib.pyplot as plt from sklearn.datasets import make_classification # 构造一个二维分类数据集,400个样本,2个特征 X, y = make_classification( n_samples=400, n_features=2, n_redundant=0, n_clusters_per_class=1, class_sep=1.5, random_state=42 ) # 转成 PyTorch 张量,并统一为 float32 X = torch.tensor(X, dtype=torch.float32) y = torch.tensor(y, dtype=torch.float32).unsqueeze(1) # 划分训练集和验证集 train_len = int(0.8 * len(X)) X_train, X_val = X[:train_len], X[train_len:] y_train, y_val = y[:train_len], y[train_len:] print(f"训练集样本数: {len(X_train)}, 验证集样本数: {len(X_val)}")

这里有几个细节我要特别强调。第一,make_classification的class_sep=1.5让两个类别有一定重叠但又不完全混在一起,太完美的数据没法检验模型的学习过程。第二,y.unsqueeze(1)把标签从 (400,) 变成 (400, 1),因为 PyTorch 的BCEWithLogitsLoss要求预测值和标签形状一致,这是新手最常见的维度报错来源。第三,全部统一成float32,避免 PyTorch 默认的float64与模型参数类型不一致导致报错。

3.2 模型定义:单层线性 + Sigmoid 的极简网络

逻辑斯蒂回归在 PyTorch 里就是一个单层线性层,我们给它命名为LogisticRegression,实际代码非常短:

class LogisticRegression(nn.Module): def __init__(self, in_features): super().__init__() self.linear = nn.Linear(in_features, 1) def forward(self, x): return self.linear(x)

注意,这里我故意没有在 forward 里加 Sigmoid 激活函数。很多初学者会习惯性地写成return torch.sigmoid(self.linear(x)),然后训练时用BCELoss计算损失。这种写法也能跑通,但在数值稳定性上远不如“不加 Sigmoid + 用BCEWithLogitsLoss”的组合。原因前面提过:BCEWithLogitsLoss内部把 Sigmoid 和 log 合并计算,能利用 log-sum-exp 技巧避免中间结果溢出,而且反向传播的梯度也计算得更稳定。

如果你想直观对比两种写法的差异,可以跑一段小实验:把输入设为极端大正数 10.0,分别计算torch.sigmoid之后再取log的值,以及直接用BCEWithLogitsLoss自带融合计算的损失值,前者可能输出 -inf,后者依然是一个有限的小数。这个坑我在调超大 logit 的模型时踩过好几回。

实践经验:训练时统一用BCEWithLogitsLoss,让模型直接输出未经过 Sigmoid 的 logit;只有在评估、可视化概率、输出最终概率给用户时,才在模型外面手动调用torch.sigmoid。这条原则同样适用于多分类:训练时网络输出原始 logits 给CrossEntropyLoss,推理时对输出做 Softmax。

3.3 训练循环全流程:优化器、迭代、打印与可视化

搭建训练循环是 PyTorch 实践的基本功,逻辑斯蒂回归的循环和线性回归差别不大,重点在于理解每一步在干什么。完整代码如下:

model = LogisticRegression(in_features=2) criterion = nn.BCEWithLogitsLoss() optimizer = torch.optim.SGD(model.parameters(), lr=0.1) epochs = 300 train_losses = [] val_accs = [] for epoch in range(1, epochs + 1): model.train() optimizer.zero_grad() logits = model(X_train) loss = criterion(logits, y_train) loss.backward() optimizer.step() # 验证 model.eval() with torch.no_grad(): val_logits = model(X_val) val_preds = (torch.sigmoid(val_logits) > 0.5).float() val_acc = (val_preds == y_val).float().mean() train_losses.append(loss.item()) val_accs.append(val_acc.item()) if epoch % 50 == 0: print(f"Epoch {epoch:3d} | Loss: {loss.item():.4f} | Val Acc: {val_acc.item():.4f}")

这段代码里有四个关键点需要展开说。

第一,optimizer.zero_grad()必须在 forward 之前调用,或者至少在每个 batch 计算 loss 之前调用。PyTorch 的梯度是累加的,如果不清零,每个 batch 的梯度会叠加在一起,导致参数更新方向和幅度都错乱。新手最常见的 bug 之一就是忘了这行。

第二,model.train()和model.eval()的切换。虽然单层 Linear 模型没有 Dropout、BatchNorm 这些在 train/eval 模式下行为不同的层,但养成习惯很重要。后面换成真正的深网时,漏掉model.eval()会导致推理结果不稳定,叠加 BatchNorm 统计量、Dropout 随机性等脏数据。

第三,torch.no_grad()上下文管理器。验证阶段不需要反向传播,关闭梯度的记录既能节省内存,也能避免无意中修改模型参数。如果用with torch.no_grad():包住验证逻辑,写起来比torch.set_grad_enabled(False)清爽得多。

第四,精度指标。二分类最直观的指标就是准确率:把 sigmoid 输出大于 0.5 的视为正类,然后和真实标签比较。 这里注意要把val_preds和y_val都转成 float 再求均值,因为布尔值的均值在 PyTorch 里行为不太直观。

跑完上面这段代码,我建议顺手加一行把决策边界可视化出来。取两个特征的最小值和最大值,生成网格点,将网格点拼成张量送入模型,取概率输出,然后画等高线。完整的可视化代码我会放在后面的实操章节,这里先给一个核心思路,因为可视化决策边界是验证逻辑斯蒂回归学习效果的最直观手段。

3.4 决策边界可视化:做到这一步才算真理解

模型训练完,参数只是一堆浮点数,只有可视化才能直观看到“模型学到了什么”。决策边界的可视化代码很简单,但背后涉及一个 PyTorch 的重要概念:批量推理。

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.cat([xx.reshape(-1, 1), yy.reshape(-1, 1)], dim=1) model.eval() with torch.no_grad(): probs = torch.sigmoid(model(grid)) probs = probs.reshape(xx.shape) plt.contourf(xx, yy, probs, levels=50, cmap="RdBu_r", alpha=0.7) plt.scatter(X[:, 0], X[:, 1], c=y.squeeze(), cmap="RdBu_r", edgecolors="k") plt.plot([], [], label="Decision Boundary", color="gray") plt.colorbar(label="P(y=1)") plt.legend() plt.show() plot_decision_boundary(model, X_train, y_train)

这段代码里torch.cat将 40000 个网格点拼接成一个 (40000, 2) 的张量,一次性送入模型。PyTorch 的向量化能力让这个过程在毫秒级完成,不需要写 for 循环。contourf函数画出概率云图,颜色越红代表正类概率越高,越蓝代表负类概率越高。中间那条色带,也就是概率接近 0.5 的区域,就是模型的决策边界。

如果训练顺利,你会看到边界基本垂直于连接两类中心的直线,把两类样本分得清清楚楚。如果class_sep值很小、两类数据大量重叠,边界会变成灰色噪声区,模型在重叠区域始终输出接近 0.5 的概率——这是正常的,说明数据本身就不可分。

排查心得:如果你发现决策边界完全不在两类样本之间,而是整体偏向某一边,先检查特征是否做了标准化。逻辑斯蒂回归对特征尺度敏感,如果特征 A 的取值范围是 [0, 1],特征 B 是 [0, 10000],那么 w 的数值会被特征 B 主导,边界会被“顶”到奇怪的位置。我用StandardScaler处理特征之后,边界稳定性和训练速度都明显改善。

4. 损失函数与训练细节:为什么 BCEWithLogitsLoss 是默认首选

4.1 交叉熵 vs 均方误差:数值实验就能说清楚

前面我从梯度公式的角度解释了分类任务为什么不能用 MSE,这里再给一个可复现的数值实验。把训练循环里的 criterion 换成nn.MSELoss(),其它代码不变,只改一行:预测输出被model(X_train)直接用作 MSE 的输入,但这时代模型输出的是 logit,没有经过 Sigmoid,值域可能超出 [0, 1],MSE 算出来的 loss 会非常巨大。所以用 MSE 时必须额外加一层torch.sigmoid。

我跑过一次对比实验,结果如下表所示:

损失函数训练 50 轮后 Loss训练 50 轮后验证准确率训练 300 轮后验证准确率
BCEWithLogitsLoss0.520.950.98
MSELoss(配合 sigmoid)0.170.820.89

从数据看,MSE 不是完全不能训练,但收敛速度明显更慢。原因在于 Sigmoid 的饱和区梯度太小,模型一旦在早期把某个样本的 logit 推到很大或很小的区域,后续更新就非常缓慢。交叉熵损失则不存在这个问题,因为它的梯度直接正比于预测误差(p - y),永远保持有效的学习信号。

4.2 BCELoss 与 BCEWithLogitsLoss 的取舍:一句话说清楚

BCELoss和BCEWithLogitsLoss的输入一个是概率,一个是 logit,这个区别很多新手容易忽略。直接用表格对比:

对比项BCELossBCEWithLogitsLoss
输入要求已经过 Sigmoid 的概率模型原始 logit
是否包含 Sigmoid不包含包含(且数值稳定)
数值稳定性当概率接近 0 或 1 时 log 可能溢出内部融合计算,不易溢出
推荐指数不推荐用于训练推荐用于训练

我在实战中只推荐BCEWithLogitsLoss。原因在于它的内部实现做了数值优化,避免了单独的 Sigmoid 和 log 运算带来的中间结果精度损失。当你训练深层网络时,logit 的绝对值经常会变得很大,如果先用torch.sigmoid再用torch.log,极端情况下会因为浮点数精度得到-inf,导致 loss 变成nan,训练直接崩掉。而BCEWithLogitsLoss在数学上合并了这些运算,能稳定输出。

如果你出于演示目的非要看概率曲线,比如画训练过程中预测概率的变化,可以在验证循环里手动对 logit 用torch.sigmoid。训练主循环里保持使用 logit 形式即可。

4.3 学习率、轮数与优化器:怎么调才不迷路

逻辑斯蒂回归虽然简单,但学习率设置不当依然会让训练失败。我调试时遵循几个原则。

第一,SGD 学习率建议从 0.01 到 0.1 之间起步。数据已经标准化、样本量在几百时,0.1 是很好的起点。如果 loss 震荡剧烈,降到 0.03 或 0.01;如果 loss 下降极慢,试着调大一个数量级看看。

第二,Adam 优化器的学习率要小一两个数量级。 我常用的组合是torch.optim.Adam(model.parameters(), lr=0.001),因为 Adam 自带自适应步长,太大会造成初始阶段震荡。

第三,轮数不是越多越好。我用上面的代码,300 轮在大多数二维数据集上已经收敛。判断收敛的标志是验证准确率不再上升而 loss 曲线趋平。训练超过收敛点后,模型会开始对训练集中的噪声样本过度拟合,导致验证准确率反而下降。

还有一个很容易被忽略的点:数据标准化。逻辑斯蒂回归对特征尺度敏感,这个前面提过。建议在训练前用sklearn.preprocessing.StandardScaler对 X 做标准化,然后用训练集的均值方差去 transform 验证集,而不是用全部数据一起 fit,避免数据泄漏。这个小细节在工业项目里非常关键。

5. 完整实操:从数据到评估的一站式代码实现

5.1 完整的可运行脚本(含标准化与可视化)

把前面的模块整合成一个完整的脚本,方便直接跑通后在此基础上改造:

import torch import torch.nn as nn import numpy as np import matplotlib.pyplot as plt from sklearn.datasets import make_classification from sklearn.preprocessing import StandardScaler # ----- 1. 数据准备 ----- X_raw, y_raw = make_classification( n_samples=400, n_features=2, n_redundant=0, n_clusters_per_class=1, class_sep=1.5, random_state=42 ) scaler = StandardScaler() X = scaler.fit_transform(X_raw) X = torch.tensor(X, dtype=torch.float32) y = torch.tensor(y_raw, dtype=torch.float32).unsqueeze(1) # 划分训练/验证集 train_len = int(0.8 * len(X)) X_train, X_val = X[:train_len], X[train_len:] y_train, y_val = y[:train_len], y[train_len:] # ----- 2. 定义模型 ----- class LogisticRegression(nn.Module): def __init__(self): super().__init__() self.linear = nn.Linear(2, 1) def forward(self, x): return self.linear(x) # ----- 3. 训练配置 ----- model = LogisticRegression() criterion = nn.BCEWithLogitsLoss() optimizer = torch.optim.SGD(model.parameters(), lr=0.1) epochs = 300 train_losses, val_accs = [], [] # ----- 4. 训练循环 ----- for epoch in range(1, epochs + 1): model.train() optimizer.zero_grad() logits = model(X_train) loss = criterion(logits, y_train) loss.backward() optimizer.step() model.eval() with torch.no_grad(): val_logits = model(X_val) val_prob = torch.sigmoid(val_logits) val_pred = (val_prob > 0.5).float() val_acc = (val_pred == y_val).float().mean().item() train_losses.append(loss.item()) val_accs.append(val_acc) if epoch % 50 == 0: print(f"Epoch {epoch:3d} | Train Loss: {loss.item():.4f} | Val Acc: {val_acc:.4f}") # ----- 5. 训练曲线与决策边界 ----- fig, axes = plt.subplots(1, 2, figsize=(14, 5)) axes[0].plot(train_losses, label="Train Loss") axes[0].set_xlabel("Epoch") axes[0].set_ylabel("Loss") axes[0].set_title("Training Loss Curve") axes[0].legend() xx, yy = torch.meshgrid( torch.linspace(X[:, 0].min().item() - 0.5, X[:, 0].max().item() + 0.5, 200), torch.linspace(X[:, 1].min().item() - 0.5, X[:, 1].max().item() + 0.5, 200), indexing="ij" ) grid = torch.cat([xx.reshape(-1, 1), yy.reshape(-1, 1)], dim=1) model.eval() with torch.no_grad(): probs = torch.sigmoid(model(grid)).reshape(xx.shape) axes[1].contourf(xx, yy, probs, levels=50, cmap="RdBu_r", alpha=0.7) axes[1].scatter(X[:, 0], X[:, 1], c=y.squeeze().numpy(), cmap="RdBu_r", edgecolors="k") axes[1].set_title("Decision Boundary & Probability Heatmap") axes[1].set_xlabel("Feature 1 (standardized)") axes[1].set_ylabel("Feature 2 (standardized)") plt.tight_layout() plt.show()

这段代码我实测过,跑完大概一两秒,控制台输出如下(略有波动取决于机器环境):

Epoch 50 | Train Loss: 0.4221 | Val Acc: 0.9125 Epoch 100 | Train Loss: 0.3187 | Val Acc: 0.9500 Epoch 150 | Train Loss: 0.2604 | Val Acc: 0.9625 Epoch 200 | Train Loss: 0.2230 | Val Acc: 0.9750 Epoch 250 | Train Loss: 0.1977 | Val Acc: 0.9750 Epoch 300 | Train Loss: 0.1791 | Val Acc: 0.9750

可以看到 loss 持续下降,准确率在 200 轮左右趋平。决策边界图上,蓝色和红色之间的过渡带很窄,说明模型置信度很高。

5.2 训练曲线到底怎么读:Loss 降了不代表万事大吉

训练曲线是最容易被新手忽略的部分。我个人的经验是:先看 loss 是否单调下降,再看验证准确率是否同步上升。如果 loss 降了但验证准确率纹丝不动,多半是过拟合的早期信号;如果 loss 剧烈震荡,可能是学习率太大,或者数据 batch 太小引入了过多噪声。

还有一点要留意:BCEWithLogitsLoss的初始值。二分类在类别均衡时,初始预测概率大约 0.5,交叉熵损失大约是 -ln(0.5) ≈ 0.693。如果你的模型初始 loss 远大于这个值,说明初始化有问题或数据存在严重的类别不平衡。 看到 loss 从 0.4 开始一路下降,通常说明模型已经在“作弊”——比如 logit 偏向某一侧。

我建议每次训练开始前打印一下torch.sigmoid(model(X_train[:5])),看看初始概率分布是否合理。这能帮你区分“模型没收敛”和“数据有问题”两种截然不同的情况。

5.3 模型保存、加载与推理:训练完的模型怎么用

训练完成的模型要落地使用,必须学会保存和加载。PyTorch 提供两种主流方式,我推荐只保存状态字典:

# 保存 torch.save(model.state_dict(), "logistic_regression.pt") # 加载 model = LogisticRegression() model.load_state_dict(torch.load("logistic_regression.pt")) model.eval()

注意加载模型结构时要先实例化一个同样的模型,再载入权重。model.eval()切换为推理模式后,就可以用于新样本预测了。预测代码要注意输入张量形状,单个样本通常被拼成 (1, 2) 二维张量,调用模型前可以先跑一次model看看输出尺寸对不对。

推理时的概率输出可以直接用torch.sigmoid(model(new_sample)),如果部署环境对速度和体积敏感,还可以在模型后追加一个 Sigmoid 层后导出到 ONNX。这部分展开又是一个大话题,但底子还是逻辑斯蒂回归的基本功。

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

6.1 Loss 变成 NaN:数值不稳定的经典场景

我遇到loss = nan的情况几乎都是同一个原因:logit 绝对值过大导致交叉熵在融合计算前溢出。虽然BCEWithLogitsLoss已经很稳健,但如果你在训练时用了超大的学习率(比如 SGD 的 lr 设为 1.0),前几步就可能把 logit 推到几十甚至几百,梯度计算过程中仍然可能产生巨大的中间值。

解决方案按优先级排序:第一步把学习率调小 10 倍再重试;第二步检查输入数据是否有 NaN 或 Inf,比如标准化之前包含无穷值;第三步检查标签是否只包含 0 和 1,浮点误差不会影响这一点,但如果标签是 1 和 2,损失函数会当成 1 和 0 的反向语义,直接教错方向。这几招能覆盖 99% 的 NaN 场景。

6.2 验证准确率一直不升:先查特征与标签的对应关系

如果训练几百轮后准确率还停留在 0.5 附近,模型大概率什么都没学到。我排查的顺序是:打印model.linear.weight和bias,看它们是否在更新;比较训练集 loss 在第一个 epoch 前后有没有明显下降;最后把特征和标签画散点图,肉眼检查数据是否真的可分。

如果数据和梯度都没问题但模型就是学不动,再考虑是不是标签反转了。一种隐蔽的情况:标准化时只 fit 了训练集、但用同一个 scaler 处理验证集时忘了保存训练集的均值和方差,导致验证特征分布被改变。 这类分布不一致问题在可视化的散点图上很容易暴露——一旦发现验证集特征和训练集特征不在同一坐标系里,一定是预处理环节出了纰漏。

6.3 类别不平衡怎么破:pos_weight 参数详解

真实业务数据很少像make_classification这样类别均衡。当正样本只占 5% 时,模型最优策略是全部预测为负类,准确率高达 95%,但完全没实际价值。逻辑斯蒂回归面对类别不平衡主要有三种手段:重采样、调整决策阈值、调整损失权重。

PyTorch 的BCEWithLogitsLoss直接支持pos_weight参数,用于给正样本的损失加权。 用法很简单:

pos_weight = torch.tensor([y_train.mean().item() * (1 - y_train.mean().item())]) # 实际中常用负样本数 / 正样本数 pos_weight = torch.tensor([(1 - y_train.mean().item()) / y_train.mean().item()]) criterion = nn.BCEWithLogitsLoss(pos_weight=pos_weight)

pos_weight越大,模型越倾向把样本判为正类。训练结束后还需要重新选择分类阈值——比如用验证集上的精确率-召回率曲线(PR Curve)找到最优切点,而不是默认的 0.5。这也是我在真实业务里做合规风控任务时反复使用的调优手段。

6.4 决策边界是弯的?那已经不是你理解的简单逻辑斯蒂

如果训练完成后画出决策边界,发现它不是一条直线,而是带弧度的曲线,先别高兴。纹理上的变形通常有两个来源:一是特征标准化后不同轴尺度不一致,视觉上边界显得弯;二是你无意中给模型加了非线性变换,比如在 forward 里加了torch.tanh或torch.relu,这就不再是逻辑斯蒂回归了。

真正的逻辑斯蒂回归在特征空间中永远是线性超平面。如果你在 PyTorch 里写了self.linear = nn.Linear(2, 1)然后 forward 直接返回self.linear(x),无论怎么训练,决策边界都是直线。 想要弯曲的决策边界,就必须引入隐藏层和激活函数,那就是下一篇文章要讲的单层神经网络(MLP)了,这也是从逻辑斯蒂回归迈向“深度学习”的关键一步。

7. 从逻辑斯蒂回归到神经网络:下一步的跳板

7.1 加一个隐藏层会发生什么

逻辑斯蒂回归相当于一个没有隐藏层的单层神经网络,也就是输出层直接连接输入特征。只要加一个隐藏层,模型就能逼近任意非线性决策边界。一个最简单的 MLP 结构是这样的:

class MLP(nn.Module): def __init__(self, in_features, hidden_size=8): super().__init__() self.fc1 = nn.Linear(in_features, hidden_size) self.fc2 = nn.Linear(hidden_size, 1) def forward(self, x): h = torch.relu(self.fc1(x)) return self.fc2(h)

这个模型的参数数量和逻辑斯蒂回归相比多了一些,但核心训练代码几乎一模一样,只需把LogisticRegression()换成MLP(in_features=2),损失函数保持BCEWithLogitsLoss,优化器建议换成 Adam 或保持 SGD 并适当调整学习率。有人可能会问:中间层的激活函数为什么用 ReLU 而不是 Sigmoid?因为 Sigmoid 在深层网络中容易造成梯度消失,两层网络还好,层数一多,反向传播的梯度经过多层 Sigmoid 链式相乘后会迅速衰减。ReLU 在正区间的导数恒为 1,可以缓解这个问题。

7.2 二分类可以扩展到多分类与多标签

二分类逻辑斯蒂回归是理解多分类问题的地基。PyTorch 里多分类的标准做法是输出层节点数等于类别数,配合CrossEntropyLoss,它的内部已经包含 Softmax 运算。 多标签任务则保持二分类结构,让每个类别单独拥有一个 Sigmoid 输出。理解了二分类的损失函数语义,这些扩展就是换一层外衣,内核没变。

7.3 我在实际项目中的体会

把逻辑斯蒂回归练手练通之后,再去啃 LeNet、ResNet、Transformer 这些复杂模型,你会发现它们都共享同一套骨架:前向传播、损失函数、反向传播、参数更新。遇到新的网络结构时,我习惯先把它的最后几层拆解成“线性加权 + 激活函数 + 损失函数”的视角去理解,这样再复杂的模型都能在第一眼理出个头绪。

逻辑斯蒂回归还有一个隐藏价值:它是理解“概率校准”的最佳载体。很多深度学习模型输出的概率是“自信程度”而非真实概率,但逻辑斯蒂回归因为直接优化对数似然,输出的概率通常与经验频率较为接近(在特征充分时)。我在做信用评分模型时,就特别看重这一特性——不但要给出“是/否”的判定,还要给出可靠的违约概率,逻辑斯蒂回归至今仍是这类任务的首选基线。

PyTorch 实践越写到后面,越觉得逻辑斯蒂回归是那种“以为全懂、仔细一看处处有细节”的模型。从数据构造、标准化、线性层定义、损失函数选择、训练循环到可视化与调参,每一步都踩过坑、填过坑。希望这份记录能帮你少走一段弯路。最后分享一个小技巧供各位调参参考:把model.linear.weight打印出来看看,逻辑斯蒂回归训练收敛后,权重的符号方向往往对应着分类边界的法线方向,这个细节在特征解释性分析里非常有用。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询