对数几率回归(逻辑回归)原理与手写实现:从极大似然到梯度下降

发布时间:2026/9/16 3:09:16
对数几率回归(逻辑回归)原理与手写实现:从极大似然到梯度下降 这篇是《机器学习》系列的第五篇主线就一件事对数几率回归也就是大家常说的逻辑回归Logistic Regression以及如何用极大似然法把它的参数求出来。先别被名字骗了虽然它挂着“回归”的招牌干的却是分类的活儿而且是所有分类模型里最基础、最值得手推一遍的那个。这篇文章适合三类人正在入门机器学习、想搞懂分类器底层原理的同学马上要考“西电机器学习期末”这类考试、正在复习对数几率回归的同学以及那些觉得“sklearn调包我也会但让我手写一个就懵了”的人。我尽量不堆公式吓人但该有的推导一步都不会省——尤其梯度推导那一段建议你拿纸跟着画一遍很多东西一下子就通了。1. 为什么线性模型解决不了分类问题1.1 线性回归在分类任务上的三个尴尬之处上一篇文章我们聊了线性回归它的核心是用一条直线或者一个超平面去拟合连续数值比如预测房价、预测气温。现在问题来了如果我想判断一封邮件是不是垃圾邮件、一个病人有没有糖尿病、一个用户会不会流失这些标签是离散的通常是 0 和 1。能不能直接拿线性回归的预测值来做分类理论上你可以说输出大于等于 0.5 判为正类否则判为负类。但实际一跑就会发现问题。第一线性回归的输出范围是整个实数域预测房价 30 万、50 万能理解但预测“垃圾邮件概率”输出 1.8 是什么意思概率不可能大于 1。第二线性回归的最小二乘目标函数本质上是假设误差服从高斯分布但分类标签显然不是高斯分布的——它不是连续变量。第三也是最直观的问题线性回归对离群点极其敏感。你在一堆点旁边多放几个异常点整条拟合直线会被硬生生拉偏决策边界跟着平移原本能分干净的类别全乱了。这几个问题合在一起说明线性回归的“输出”形式和“目标”之间是有本质错位的。1.2 需要一个把实数映射到 0 到 1 的函数既然直接输出实数不行那很自然的想法就是能不能找这样一个函数把线性回归的输出 z w^T x b 压缩到 (0, 1) 区间这样我就可以把它当成概率来解读。你可能会先想到单位阶跃函数z 大于 0 输出 1小于 0 输出 0等于 0 输出 0.5。但这个函数有两个致命伤它不连续更不可导。后面所有梯度下降的优化方法都需要求导一个不可导的函数根本没法用梯度去更新参数。所以我们需要一个“平滑版的阶跃函数”这就是 sigmoid 函数也叫 Logistic 函数σ(z) 1 / (1 e^(-z))这个函数的性质非常漂亮当 z 趋近正无穷时σ(z) 趋近 1当 z 趋近负无穷时σ(z) 趋近 0z 0 时恰好是 0.5。而且它处处可导导数还有一个极其好用的形式σ(z) σ(z)(1 - σ(z))这个性质在后面推导梯度时会让代码简化到令人发指的地步你可以先记住这个结论后面会反复用到。现在我们把线性回归的输出塞进 sigmoidp σ(w^T x b)p 就落在 0 到 1 之间表示样本属于正类的概率。这也解释了为什么叫“对数几率回归”——你把式子倒过来算一下ln(p / (1 - p)) w^T x b这个 p/(1-p) 叫“几率”odds再取对数就是“对数几率”log odds。也就是说对数几率回归本质上还是在做线性回归只是它回归的目标不是 y而是 y 的对数几率。这么一看“回归”两个字也就不那么违和了。2. 极大似然法怎么搭起损失函数2.1 极大似然思想找到让“已经发生的事”出现概率最大的参数在动手求参数之前得先解决一个问题什么才算“好”的参数在线性回归里我们追求预测值和真实值的平方误差最小但在分类任务里我们希望模型给出的概率尽量贴近真实标签。这个目标可以用一个经典的思想来落地——极大似然估计。我用自己的话解释一下极大似然你现在手里有一枚不知道是否均匀的硬币扔了 10 次结果是 9 次正面、1 次反面。请问你认为这枚硬币正面朝上的概率 p 更接近 0.5 还是 0.9正常人都会觉得是 0.9因为如果 p 0.5扔出“9 正 1 反”这种结果的概率很小而如果 p 0.9这个结果就合理多了。极大似然法就是把这种直觉严格数学化先写出在当前参数下观测到这一组数据的概率 L(θ)然后找一组让 L(θ) 最大的参数 θ。这个 L(θ) 就叫“似然函数”。一个特别容易踩的坑是似然和概率在概念上容易混淆。概率是在参数已知的情况下预测某个结果出现的可能性似然则是在结果已经出现的情况下反过来评估不同参数“解释”这个结果的合理程度。逻辑回归用的正是后者我们手上的训练集已经是事实了要找一组参数让这组事实出现的可能性最大化。2.2 从似然函数到我们熟悉的交叉熵损失现在把极大似然用到逻辑回归上。设训练集有 N 个样本第 i 个样本的特征是 x_i标签 y_i 取 0 或 1。模型给出的概率是P(y_i 1 | x_i) σ(w^T x_i b)为了让式子能同时处理 y_i 0 和 y_i 1 两种情况有一个很经典的写法P(y_i | x_i) p_i^{y_i} (1 - p_i)^(1 - y_i)你可以验证一下当 y_i 1 时这个式子只剩下 p_i当 y_i 0 时只剩下 1 - p_i。这个写法在推导中会反复出现一定要看习惯。假设各样本独立那么整个训练集的似然函数就是所有样本概率的连乘L(w, b) ∏_{i1}^N p_i^{y_i} (1 - p_i)^(1 - y_i)连乘有个问题乘的数一多结果会变得特别小计算机算着算着就下溢变成 0了。所以常规操作是取对数把连乘变成连加而且对数函数是单调递增的不会改变最大值对应的参数位置ln L(w, b) Σ_{i1}^N [y_i ln p_i (1 - y_i) ln(1 - p_i)]这个叫“对数似然”。机器学习里我们习惯最小化损失函数于是加个负号再除以 N 求平均就得到J(w, b) -1/N Σ_{i1}^N [y_i ln p_i (1 - y_i) ln(1 - p_i)]眼熟吗这就是交叉熵损失。信息论里交叉熵衡量的是两个概率分布的差异这里衡量的是模型预测分布和真实标签分布之间的差距。所以逻辑回归用极大似然推出交叉熵不是偶然的它本质上是在最小化预测分布和真实分布之间的“信息距离”。2.3 一个关键问题为什么不能用最小二乘看到这里可能有同学会问上一篇线性回归用的最小二乘不是挺好的吗逻辑回归能不能接着用平方误差我建议你不要这么做原因有两层。第一层从概率视角看分类标签是伯努利分布不是高斯分布最小二乘对应的概率假设从一开始就不成立。第二层从优化视角看把 sigmoid 的输出套进平方误差得到的损失函数是非凸的里面会有很多局部极小值梯度下降很容易陷进去而交叉熵损失是凸函数理论上能够收敛到全局最优。当年我为了验证这一点专门画过不同参数的损失曲线——平方误差的线是波浪形的像一个一个小坑交叉熵的线是平滑的碗状一眼就能看出来哪个好优化。所以结论很明确极大似然法给逻辑回归“规定”了正确的损失函数那就是交叉熵不是拍脑袋定的是推出来的。3. 梯度下降求解参数手推一遍你就全明白了3.1 关键推导损失函数对参数的梯度长什么样损失函数搭好了接下来就用梯度下降去迭代求解参数。这里我强烈建议你跟着手推一遍因为这一套链式法则在神经网络里还会反复出现推一次保底用三年。为方便起见我们令 z_i w^T x_i bp_i σ(z_i)。损失函数对 w 求梯度用链式法则拆成三步∂J/∂w ∂J/∂p_i · ∂p_i/∂z_i · ∂z_i/∂w逐项看。第一项∂J/∂p_i -(y_i / p_i) (1 - y_i)/(1 - p_i) (p_i - y_i) / (p_i(1 - p_i))第二项用 sigmoid 导数的性质∂p_i/∂z_i p_i(1 - p_i)第三项∂z_i/∂w x_i三项一乘后面两个分母约掉了只剩∂J/∂w (1/N) Σ_{i1}^N (p_i - y_i) x_i就这么干净。偏置 b 的梯度也一样差别只在最后一项 ∂z_i/∂b 1∂J/∂b (1/N) Σ_{i1}^N (p_i - y_i)我想特别强调一下这个结果的直观含义梯度里最关键的量是 (p_i - y_i)也就是“预测概率减真实标签”。预测对了一个样本p_i 接近 y_i这一项就接近 0它对参数的贡献就小预测错了这一项就大参数就往正确的方向修正一下。这跟线性回归的梯度形式有异曲同工之妙——线性回归的梯度是 (预测值 - 真实值) × 特征逻辑回归的梯度是 (预测概率 - 真实标签) × 特征。唯一区别就是一个用的是连续预测值一个用的是 sigmoid 压缩后的概率。这也是为什么很多框架底层实现里逻辑回归和线性回归的代码可以共用同一套梯度框架只是前面的“模型函数”不同。3.2 参数更新公式与实现视角的简化有了梯度参数更新就顺理成章了w ← w - η · (1/N) Σ_{i1}^N (p_i - y_i) x_ib ← b - η · (1/N) Σ_{i1}^N (p_i - y_i)其中 η 是学习率控制每次迈的步子大小。注意我们这里用的是批量梯度下降也就是每轮迭代把全部 N 个样本都算一遍再更新一次。实际工程里数据量很大的时候一般会用小批量梯度下降mini-batch每次随机抽一小批样本来算梯度原理完全一样只是迭代的节奏更快。为了效率实现的时候通常把整个训练集向量化计算。也就是把所有样本的特征矩阵 X 一次性传进去算出所有 p_i再统一更新参数。这个操作比 for 循环逐样本算快非常多尤其在你的特征维度和样本量稍微上来一点之后差距是两个数量级的。不要觉得“反正代码能跑就行”手写模型是一次非常好的思维训练但写出来的东西也得有工程项目的基本素养。4. 实战只用 NumPy 手写一个对数几率回归4.1 准备数据与预处理标准化为什么不能省理论讲完动手写代码。这里我用鸢尾花数据集做二分类取前两个类别setosa 和 versicolor只保留两个特征花萼长度和花瓣长度方便可视化。整个流程就四步加载数据、标准化特征、训练模型、画决策边界。第一步必须先做特征标准化。很多初学者不理解sklearn 里跑逻辑回归不标准化也经常能出结果啊那是因为 sklearn 内部帮你做了很多数值上的兜底而且样例数据本身量级还不算太离谱。但如果你自己手写梯度下降不标准化就会踩大坑两个特征一个量级是 1另一个量级是 100损失函数在参数空间里会变成一个很扁的椭圆梯度下降走起来像在滑冰一会儿往东跑一会儿往西跑半天到不了最低点。标准化之后特征均值变成 0、方差变成 1损失函数的“碗”就接近圆形了梯度下降的路径会直很多。这一步在所有基于梯度的模型里都是标配不是可选项。4.2 核心代码训练、预测与损失监控下面这段代码就是完整的核心逻辑我建议你复制到 Jupyter 里跑一遍import numpy as np from sklearn.datasets import load_iris from sklearn.model_selection import train_test_split def sigmoid(z): # 防止 exp 溢出做了一个小保护 # 更严谨可以分段处理但这样写对大多数场景够用 return 1 / (1 np.exp(-np.clip(z, -500, 500))) def compute_loss(X, y, w, b): p sigmoid(X w b) # 防止 log(0) 导致 nan加一个极小值 eps 1e-12 loss -np.mean(y * np.log(p eps) (1 - y) * np.log(1 - p eps)) return loss def train_logistic_regression(X, y, lr0.1, epochs1000): n_samples, n_features X.shape w np.zeros(n_features) b 0.0 loss_history [] for epoch in range(epochs): p sigmoid(X w b) # 梯度向量化的写法一次算完 dw (1 / n_samples) * (X.T (p - y)) db (1 / n_samples) * np.sum(p - y) w - lr * dw b - lr * db if epoch % 100 0: loss compute_loss(X, y, w, b) loss_history.append(loss) print(fepoch {epoch}, loss: {loss:.6f}) return w, b, loss_history # 加载数据只取前两类和两个特征 iris load_iris() X iris.data[:100, :2] y iris.target[:100] # 0 和 1 # 标准化 mean X.mean(axis0) std X.std(axis0) X_std (X - mean) / std w, b, loss_history train_logistic_regression(X_std, y, lr0.1, epochs1000)跑完之后你会看到 loss 在逐步下降前几百轮降得很快后面慢慢趋于平缓。这就是梯度下降的正常节奏——最开始离最佳点远梯度大步子也大越靠近底部梯度越小参数更新越细腻。如果你看到 loss 一路飙到 nan别慌八成是学习率太大或者特征没标准化去查一遍这两项基本能解决。4.3 决策边界与模型评估训练完之后我习惯把决策边界画出来看一眼。因为 sigmoid 是单调函数决策边界就是 z 0 那条线也就是 w1·x1 w2·x2 b 0。这是一条直线所以逻辑回归本质上是线性分类器。你在二维平面上把散点图画出来再画这条直线会看到它正好把两类点分在两侧。如果数据本身线性不可分比如一个圆形的类别包围另一个类别逻辑回归的直线边界就不够用了。这时候要么做特征工程比如加入 x1^2、x1·x2 这样的组合特征要么换非线性模型。记住一句话逻辑回归的“线性”是特征空间里的线性不是数据分布的线性。评估分类模型当然要看准确率但对类别不平衡的数据准确率会骗人。更合理的评估指标是精确率、召回率、F1以及 ROC-AUC。在鸢尾花这个例子上这两类完全可分准确率会接近 100%看不出来什么门道但你心里要清楚真正工业级项目里极少有这么干净的数据。5. 常见问题与排查技巧实录5.1 损失震荡、发散或不下降怎么办这是手写逻辑回归时遇到最多的问题。我的排查顺序通常是先打印每个 epoch 的 loss看曲线形态。如果 loss 忽高忽低像过山车第一件事是把学习率调小 10 倍再试。学习率本质上是控制“步子大小”步子太大就会一步迈过头从一个坑沿跳到另一个坑沿看起来就是在震荡。如果 loss 前几轮就变成 nan大概率是梯度数值爆炸了这时候除了调小学习率还要检查特征是否做了标准化。很多人在 i 的特征上忘了标准化然后大数相乘直接把梯度推到无穷大——这是新手最容易踩的坑没有之一。另外有个小技巧如果损失曲线下降得很慢不一定是坏了可能是学习率太小。我见过有人 lr 设成 1e-6训练了 5000 轮 loss 还在原地缓慢爬行。默认值建议从 0.1 开始观察曲线再逐步调整比凭空猜一个数靠谱得多。5.2 参数初始化为什么全 0 也能训用神经网络的同学可能会疑惑神经网络里参数不能全 0 初始化因为有对称性问题。但逻辑回归没有隐藏层模型就是一个直观的线性映射压缩到 sigmoid它不存在对称性问题所以全 0 初始化完全没毛病。我上面的代码就是这么干的。当然你随机初始化也行得到的最终结果基本一样因为交叉熵损失是凸函数不管你从哪儿出发理论上都会收敛到同一个全局最优解。这点跟后面学深度学习是完全不同的心态学的时候感受一下这个对比也挺有意思。5.3 类别不平衡换阈值比换模型更快真实业务里正负样本比例经常是很离谱的比如 1000 个用户只有 3 个会付费。这时候直接拿 0.5 当分类阈值会让你预测出一堆“负类”看起来准确率很高但正类几乎全漏。一个比换模型更快的补救办法是调整分类阈值预测概率超过 0.3 甚至 0.2 就判为正类然后去看精确率和召回率如何权衡。还可以给少数类样本加大权重sklearn 的 LogisticRegression 里有个 class_weightbalanced 参数做的事情就是按类别频率自动放大少数类的损失贡献。手写实现的时候你只要在损失函数和梯度里给正类样本的损失乘一个系数就行改动不超过三行。5.4 过拟合与正则化加一个惩罚项就完事逻辑回归虽然是线性模型但当特征维度特别高、或者样本量很少的时候照样会过拟合。最直接的信号就是训练准确率接近 100%测试准确率却掉得厉害。常规解法是在损失函数后面加 L2 正则项J_reg J (λ / 2N) ||w||^2梯度里多一项 (λ/N) w。λ 越大对“大权重”的惩罚越狠模型就越“保守”。这个改动在手写代码里几乎不费劲效果却很显著。我自己的经验是在特征上百个、样本只有几百个的任务里加不加正则的差距肉眼可见。5.5 多分类从二分类推广到 Softmax这篇文章讲的是二分类逻辑回归但你迟早会遇到三分类甚至更多的情况。两种主流做法一是 One-vs-Rest一对多也就是训练 K 个二分类器每个分类器负责判断“是不是第 k 类”最后取得分最高的类二是直接用 Softmax 回归把 sigmoid 推广到多类输出一个概率分布。Softmax 这名字你在深度学习里肯定见过——神经网络最后一层接的就是它。所以你会发现搞懂二分类逻辑回归其实是理解深度学习的半条腿另一半是反向传播和表示学习。5.6 概率校准别把你的 0.9 太当回事最后聊一个进阶但很重要的话题逻辑回归输出的概率虽然比一般模型准但严格来说它未必是“真实的概率”。在某些领域比如风控、医疗你需要的是校准良好的概率也就是“预测 0.8 的样本实际也真的有大约 80% 是正类”。这时候可以额外做一步概率校准常用的两个方案是 Platt Scaling在模型的 logit 输出上再拟合一个逻辑回归和 Isotonic Regression保序回归不假设单调形式。判断概率是否校准的常用工具是校准曲线calibration curve。我的建议是如果你的应用场景只是一个排序问题比如给用户推荐 ABCD 排个序那校准不校准无所谓如果要做风险决策比如判断要不要给这笔贷款放款那就别省这一步。写到最后还是想分享一个我在实操里的小习惯每学一个新模型我都逼自己关掉 sklearn先用 NumPy 从零写一遍训练流程再打开框架源码做对照。做逻辑回归这一步时你会第一次体验到“原来一个分类器本质上就是一条线加一个压缩函数加一个交叉熵”这种极简美感。这个底子打牢了后面学 Softmax、学 MLP、哪怕是学 Transformer 的分类头你都会觉得异常亲切——因为它们求梯度的核心思路和这篇文章里推的东西是同一条脉络。