1. 从线性到逻辑:为什么这两个模型是绕不开的起点
很多人学深度学习,一上来就冲着卷积神经网络、Transformer这些“大件”去,结果训练跑通了,却说不清楚损失函数为什么这么设计、梯度到底往哪个方向传。我见过不少同学,调包调得飞起,但一旦模型不收敛,就完全不知道从哪儿下手排查。说到底,还是回归这一关没过扎实。
线性回归和逻辑回归,看起来简单,甚至有人觉得“这有什么好讲的”。但恰恰是这两个模型,把深度学习的几个核心机制——前向传播、损失函数、梯度计算、参数更新——用最干净的形式展示了一遍。你把这俩吃透了,后面看任何复杂网络,都能一眼看穿它的骨架。
这篇内容我打算接着上一部分往下聊,重点放在逻辑回归的实现细节,以及从线性回归过渡到逻辑回归时,那些容易踩坑的地方。适合已经看过线性回归基础、想真正把这两个模型用PyTorch手写一遍的读者。不管你是刚入门的新手,还是想回头补基础的老手,这里面的细节应该都能给你一些参考。
2. 线性回归回顾与逻辑回归的引入逻辑
2.1 线性回归到底在做什么
线性回归的目标很直接:找一条直线(或者超平面),让预测值和真实值之间的差距尽可能小。数学形式就是 y = wx + b,其中 w 是权重,b 是偏置。训练的过程,就是不断调整 w 和 b,让损失函数——通常是均方误差(MSE)——降到最低。
在PyTorch里,这件事被拆成几个标准步骤:定义模型、定义损失函数、定义优化器、然后循环迭代。我个人的习惯是,哪怕再简单的模型,也严格按照这个流程走,因为后面换成复杂网络时,流程是一模一样的,只是模型部分变复杂了而已。
这里有个细节值得注意:线性回归的输出是连续值,范围是负无穷到正无穷。这个特性决定了它适合做预测房价、温度、销量这类任务。但如果你要解决的是分类问题,比如判断一封邮件是不是垃圾邮件,输出只有“是”或“否”两种可能,线性回归就不够用了——它的输出没法直接表示概率。
2.2 为什么需要逻辑回归
分类问题的核心需求是:给定输入,输出它属于某个类别的概率。概率有个硬性约束——必须在0到1之间。线性回归的输出显然不满足这个条件,所以我们需要一个函数,把线性输出“压缩”到0到1的区间里。
这个函数就是Sigmoid函数,形式是 σ(z) = 1 / (1 + e^(-z))。它的图像是一条S形曲线,当z趋近正无穷时输出趋近1,z趋近负无穷时输出趋近0,z等于0时输出0.5。这个性质完美契合了概率的需求。
所以逻辑回归的本质是:先做一次线性变换 z = wx + b,再把z送进Sigmoid函数,得到概率值。虽然名字里带“回归”,但它解决的是分类问题,这个命名是历史遗留,不用纠结。
2.3 从回归到分类的关键转折点
从线性回归到逻辑回归,最大的变化不在模型结构,而在损失函数。线性回归用MSE,逻辑回归用的是交叉熵(Cross Entropy)。为什么不能继续用MSE?这里涉及一个很实际的问题。
如果用MSE作为逻辑回归的损失函数,损失函数会变成非凸的,存在多个局部极小值,梯度下降很容易卡在不好的位置。而交叉熵损失函数是凸的,优化起来更稳定。另外,从信息论的角度看,交叉熵衡量的是两个概率分布之间的差异,和分类任务的本质更匹配。
我在实际教学中发现,很多人能背出“逻辑回归用交叉熵”,但说不清楚为什么。上面这两点——凸性和概率分布的匹配——是我觉得最核心的解释。
3. PyTorch实现逻辑回归的完整流程
3.1 数据准备与预处理要点
逻辑回归虽然简单,但数据准备环节一点都不能马虎。我一般会按下面的顺序来处理:
- 生成或加载数据:如果是教学演示,可以用sklearn的make_classification生成二分类数据;如果是真实项目,就直接加载业务数据。
- 特征标准化:这一步经常被忽略,但对逻辑回归影响很大。因为Sigmoid函数在输入接近0时梯度最大,如果特征尺度差异太大,梯度下降会走得很慢。我通常用StandardScaler把特征均值归到0、方差归到1。
- 转换为Tensor:PyTorch只认Tensor,所以要把numpy数组转成torch.FloatTensor。注意特征用float32,标签在二分类时也用float32(配合BCELoss),多分类时用long类型。
- 划分训练集和测试集:一般按8:2或7:3划分,确保测试集能反映模型的泛化能力。
提示:特征标准化时,一定要用训练集的均值和方差去标准化测试集,而不是各自算各自的。否则测试集的信息会“泄露”到训练过程中,导致评估结果虚高。
3.2 模型定义的两种写法
PyTorch里定义逻辑回归模型,我常用两种写法,各有适用场景。
第一种是直接用nn.Linear加Sigmoid:
import torch.nn as nn class LogisticRegression(nn.Module): def __init__(self, n_features): super().__init__() self.linear = nn.Linear(n_features, 1) self.sigmoid = nn.Sigmoid() def forward(self, x): z = self.linear(x) return self.sigmoid(z)这种写法结构清晰,适合教学和理解。nn.Linear内部自动处理了权重和偏置的初始化,省事。
第二种是手动定义参数:
class LogisticRegressionManual(nn.Module): def __init__(self, n_features): super().__init__() self.w = nn.Parameter(torch.randn(n_features, 1) * 0.01) self.b = nn.Parameter(torch.zeros(1)) def forward(self, x): z = x @ self.w + self.b return torch.sigmoid(z)这种写法能让你看清楚权重和偏置到底长什么样,适合想深入理解参数更新过程的场景。我建议初学者先用第二种写一遍,再用第一种,体会一下框架帮你做了什么。
3.3 损失函数与优化器的选择
二分类逻辑回归的标准损失函数是BCELoss(Binary Cross Entropy Loss),公式是:
L = -[y * log(ŷ) + (1 - y) * log(1 - ŷ)]
其中y是真实标签(0或1),ŷ是预测概率。这个公式的直觉是:当真实标签是1时,如果预测概率接近0,损失会非常大;反之亦然。
在PyTorch里,更推荐用BCEWithLogitsLoss,它把Sigmoid和BCELoss合在了一起,数值上更稳定。因为单独算Sigmoid再算log,在极端值附近容易出现数值问题(比如log(0))。BCEWithLogitsLoss内部用了log-sum-exp技巧,避免了这个问题。
criterion = nn.BCEWithLogitsLoss()注意,用了BCEWithLogitsLoss之后,模型里就不要再加Sigmoid了,直接输出线性结果z就行。
优化器方面,SGD和Adam都可以。我一般先用Adam,学习率设0.01或0.001,因为它对学习率不那么敏感,收敛快。如果要做精细调优,再换SGD加动量。
3.4 训练循环的细节拆解
训练循环是整个过程的核心,我把它拆成几个关键动作:
- 前向传播:把特征送进模型,得到预测值。
- 计算损失:用损失函数比较预测值和真实标签。
- 梯度清零:optimizer.zero_grad(),这一步必须做,否则梯度会累加。
- 反向传播:loss.backward(),自动计算所有参数的梯度。
- 参数更新:optimizer.step(),根据梯度调整参数。
这五步是PyTorch训练任何模型的通用模板,逻辑回归也好,ResNet也好,骨架都一样。区别只在于模型结构和损失函数。
for epoch in range(n_epochs): model.train() optimizer.zero_grad() y_pred = model(X_train) loss = criterion(y_pred, y_train) loss.backward() optimizer.step() if (epoch + 1) % 100 == 0: print(f'Epoch [{epoch+1}/{n_epochs}], Loss: {loss.item():.4f}')注意:zero_grad()的位置很关键。如果放在backward()之后、step()之前,效果是一样的,但放在循环开头更符合直觉,也不容易出错。
4. 从二分类到多分类:Softmax的引入
4.1 多分类问题的本质变化
二分类只需要一个输出节点,表示“是”的概率,剩下就是“否”的概率。但多分类不一样,比如手写数字识别有10个类别,你需要输出10个概率值,而且这10个值加起来必须等于1。
这时候Sigmoid就不够用了,因为它对每个输出独立处理,没法保证总和为1。解决方案是Softmax函数:
softmax(z_i) = e^(z_i) / Σ e^(z_j)
它的作用是:先把每个输出取指数(保证为正),再除以所有指数之和(保证总和为1)。这样得到的每个值都在0到1之间,且总和为1,正好符合概率分布的要求。
4.2 交叉熵损失在多分类中的形式
多分类的交叉熵损失是二分类的推广:
L = -Σ y_i * log(ŷ_i)
其中y_i是真实标签的one-hot编码,ŷ_i是预测概率。因为y_i只有一个位置是1,其他都是0,所以实际上只计算了真实类别对应的那个log概率。
PyTorch里对应的是nn.CrossEntropyLoss,它把Softmax和交叉熵合在了一起,输入直接是线性输出z,不需要手动加Softmax。这一点和二分类的BCEWithLogitsLoss是一样的设计思路。
criterion = nn.CrossEntropyLoss()4.3 多分类逻辑回归的实现差异
多分类逻辑回归在代码上和二分类主要有三处不同:
- 输出维度从1变成类别数
- 损失函数从BCEWithLogitsLoss换成CrossEntropyLoss
- 标签类型从float32变成long
class MultiClassLogisticRegression(nn.Module): def __init__(self, n_features, n_classes): super().__init__() self.linear = nn.Linear(n_features, n_classes) def forward(self, x): return self.linear(x) # 不需要手动Softmax训练循环的结构完全一样,这就是PyTorch设计得好的地方——换模型和损失函数,训练流程不用动。
5. 实操中容易踩的坑与排查技巧
5.1 损失不下降的常见原因
这是我最常被问到的问题。损失不下降,通常有以下几个原因:
| 问题现象 | 可能原因 | 排查方法 |
|---|---|---|
| 损失一直在高位震荡 | 学习率太大 | 把学习率调小10倍试试 |
| 损失几乎不变 | 学习率太小 | 把学习率调大10倍试试 |
| 损失先降后升 | 过拟合 | 加正则化或减少训练轮数 |
| 损失为NaN | 数值溢出 | 检查是否有log(0)或除以0 |
| 损失下降但准确率不涨 | 类别不平衡 | 检查各类样本比例 |
我自己的经验是,先检查学习率,再检查数据预处理,最后检查损失函数和模型输出是否匹配。这三步能解决80%的问题。
5.2 梯度消失与梯度爆炸的应对
逻辑回归本身很少出现梯度消失或爆炸,因为网络只有一层。但如果你把多个逻辑回归层堆叠起来,就会遇到这个问题。Sigmoid函数的导数最大只有0.25,多层叠加后梯度会指数级衰减。
应对方法有几个:
- 用ReLU替代Sigmoid作为隐藏层激活函数
- 使用Batch Normalization
- 用残差连接
- 减小网络深度
不过在单层逻辑回归里,这些都不需要,知道有这回事就行。
5.3 评估指标的选择误区
很多人训练完逻辑回归,只看准确率。但准确率在类别不平衡时会严重误导。比如99%的样本是负类,模型全预测负类,准确率也有99%,但毫无意义。
我一般会同时看这几个指标:
- 精确率(Precision):预测为正的样本中,真正为正的比例
- 召回率(Recall):真正为正的样本中,被预测出来的比例
- F1分数:精确率和召回率的调和平均
- AUC-ROC:衡量模型在不同阈值下的分类能力
在PyTorch里,可以用sklearn的metrics模块来计算这些指标,把模型输出的概率转成预测类别即可。
6. 几个提升模型效果的小技巧
6.1 特征工程仍然重要
虽然深度学习能自动学特征,但在逻辑回归这种线性模型里,特征工程的作用非常明显。我常用的手法包括:
- 多项式特征:把x1、x2扩展成x1、x2、x1²、x2²、x1*x2,能拟合非线性边界
- 分箱:把连续特征离散化成几个区间,增强鲁棒性
- 交叉特征:把两个类别特征组合成一个新特征
这些在sklearn里都有现成的工具,比如PolynomialFeatures。做完之后再把特征送进PyTorch模型。
6.2 正则化的正确使用
逻辑回归默认容易过拟合,尤其是特征多、样本少的时候。PyTorch的优化器自带weight_decay参数,可以实现L2正则化:
optimizer = torch.optim.Adam(model.parameters(), lr=0.01, weight_decay=1e-4)weight_decay的值一般从1e-5到1e-2之间调。太大模型会欠拟合,太小起不到正则化效果。我通常先用1e-4试,然后根据验证集表现调整。
L1正则化在PyTorch里没有直接支持,需要手动在损失里加参数的绝对值之和。不过实际项目中L2用得更多,因为L1会让参数变成稀疏的,解释性好但优化起来麻烦。
6.3 学习率调度策略
固定学习率往往不是最优的。训练初期用大学习率快速下降,后期用小学习率精细调整,效果通常更好。PyTorch提供了lr_scheduler:
scheduler = torch.optim.lr_scheduler.StepLR(optimizer, step_size=50, gamma=0.5)每50个epoch把学习率乘以0.5。还有CosineAnnealingLR、ReduceLROnPlateau等策略,可以根据验证集损失自动调整。我一般先用StepLR,简单有效。
7. 从逻辑回归延伸到神经网络的思考
逻辑回归本质上是一个没有隐藏层的神经网络。它的输入直接连到输出,中间没有非线性变换(Sigmoid算输出层的激活)。如果你在输入和输出之间加一个隐藏层,再加一个激活函数,就变成了一个多层感知机。
这个视角很重要,因为它意味着你学逻辑回归时掌握的技能——定义模型、选损失函数、调学习率、看评估指标——全都可以直接迁移到深度网络上。区别只是网络更深、参数更多、需要更多的技巧来训练。
我自己在带新人的时候,会让他们先用逻辑回归把整个流程跑通,包括数据加载、训练循环、模型保存、推理部署。等这套流程熟了,再换成CNN或RNN,他们就不会觉得无从下手。因为变的只是模型部分,其他都是肌肉记忆。
另外一个值得思考的点是:逻辑回归的决策边界是线性的。如果数据本身是非线性可分的,逻辑回归无论怎么训练都达不到好效果。这时候要么做特征工程,要么换非线性模型。理解这个局限性,能帮你在实际项目中快速判断该用什么方法。
8. 完整代码示例与参数说明
8.1 二分类逻辑回归完整实现
import torch import torch.nn as nn import torch.optim as optim from sklearn.datasets import make_classification from sklearn.model_selection import train_test_split from sklearn.preprocessing import StandardScaler from sklearn.metrics import accuracy_score, roc_auc_score # 1. 生成数据 X, y = make_classification(n_samples=1000, n_features=20, n_informative=10, n_redundant=5, random_state=42) # 2. 划分数据集 X_train, X_test, y_train, y_test = train_test_split( X, y, test_size=0.2, random_state=42) # 3. 标准化 scaler = StandardScaler() X_train = scaler.fit_transform(X_train) X_test = scaler.transform(X_test) # 4. 转Tensor X_train = torch.FloatTensor(X_train) y_train = torch.FloatTensor(y_train).reshape(-1, 1) X_test = torch.FloatTensor(X_test) y_test = torch.FloatTensor(y_test).reshape(-1, 1) # 5. 定义模型 class LogisticRegression(nn.Module): def __init__(self, n_features): super().__init__() self.linear = nn.Linear(n_features, 1) def forward(self, x): return self.linear(x) # 输出logits model = LogisticRegression(n_features=20) # 6. 损失函数和优化器 criterion = nn.BCEWithLogitsLoss() optimizer = optim.Adam(model.parameters(), lr=0.01, weight_decay=1e-4) # 7. 训练 n_epochs = 200 for epoch in range(n_epochs): model.train() optimizer.zero_grad() logits = model(X_train) loss = criterion(logits, y_train) loss.backward() optimizer.step() if (epoch + 1) % 50 == 0: print(f'Epoch [{epoch+1}/{n_epochs}], Loss: {loss.item():.4f}') # 8. 评估 model.eval() with torch.no_grad(): logits = model(X_test) probs = torch.sigmoid(logits) preds = (probs > 0.5).float() acc = accuracy_score(y_test.numpy(), preds.numpy()) auc = roc_auc_score(y_test.numpy(), probs.numpy()) print(f'Accuracy: {acc:.4f}, AUC: {auc:.4f}')8.2 关键参数说明
| 参数 | 推荐值 | 说明 |
|---|---|---|
| learning_rate | 0.01 ~ 0.001 | Adam常用0.001,SGD常用0.01 |
| weight_decay | 1e-5 ~ 1e-2 | 根据过拟合程度调整 |
| batch_size | 32 ~ 256 | 小数据集可以全量训练 |
| n_epochs | 100 ~ 500 | 看损失曲线决定何时停止 |
| threshold | 0.5 | 可根据精确率和召回率需求调整 |
提示:threshold不一定非要0.5。如果更看重召回率(比如疾病筛查),可以调低到0.3;如果更看重精确率(比如垃圾邮件拦截),可以调高到0.7。
8.3 模型保存与加载
训练完的模型要保存下来,方便后续推理或继续训练:
# 保存 torch.save(model.state_dict(), 'logistic_regression.pth') # 加载 model_new = LogisticRegression(n_features=20) model_new.load_state_dict(torch.load('logistic_regression.pth')) model_new.eval()state_dict只保存参数,不保存模型结构。所以加载时需要先实例化一个相同结构的模型。这种方式比保存整个模型更灵活,也更安全。
9. 一些个人体会与后续学习方向
逻辑回归这个模型,我用了很多年,每次回头看都有新的理解。刚开始觉得它太简单,没什么可讲的;后来发现,正是因为它简单,才能把机器学习的核心概念——假设空间、损失函数、优化算法、泛化能力——用最纯粹的形式呈现出来。
如果你现在正在学PyTorch,我建议不要急着跳过这部分。把线性回归和逻辑回归手写几遍,把每个参数的作用搞清楚,把损失曲线的形状和原因对应起来。这些基本功,在后面调复杂模型时会变成你的直觉。
后续可以往这几个方向延伸:一是把逻辑回归扩展到多标签分类(一个样本可以属于多个类别),用多个Sigmoid输出;二是尝试用逻辑回归做特征选择,通过L1正则化把不重要的特征权重压到0;三是把逻辑回归作为神经网络的最后一层,理解它在整个架构中的角色。
最后分享一个小技巧:训练逻辑回归时,把权重和偏置的初始值设为0,而不是随机值。因为逻辑回归的损失函数是凸的,从任何初始值出发都能收敛到全局最优,设0反而更稳定。这个和深度网络需要随机初始化是不一样的,原因在于凸和非凸的区别。