做机器学习这行,逻辑回归几乎是绕不过去的坎。面试要问,项目要用,很多看似复杂的分类问题,第一版方案大概率也会先落在它身上。原因很简单:它足够简单、可解释性强、训练又快,而且只要特征工程到位,效果往往并不差。很多人觉得逻辑回归只是“线性模型加了个Sigmoid”,听起来简单,但真要自己动手用Python实现一遍,还要把梯度下降策略真正吃透,就会发现里面藏着不少细节和坑。
这篇就以一个完整案例为主线,白手起家写一个逻辑回归模型。不用现成的机器学习库,只用Python基础语法加NumPy,手推梯度下降,然后拿一份经典数据集做实战验证。在实现过程中,我会把每一步的数学原理和代码逻辑对应起来讲清楚,包括损失函数为什么长那样、梯度是怎么推出来的、学习率选多少合适、特征要不要标准化,以及遇到收敛问题时怎么排查。文章定位是给那些已经懂点Python、知道什么是模型训练,但没亲手实现过算法细节的人,看完之后你能自己独立写出一版可用的逻辑回归,也能明白调参时那些经验法则背后的原因。
1. 案例设计与思路拆解
先明确一下我们要做什么。这个案例的目标不是调用现成的sklearn.linear_model.LogisticRegression然后完事,而是从零实现一个逻辑回归分类器,核心包括三个部分:Sigmoid函数的实现、损失函数(对数似然损失)的构造、梯度下降优化策略的编写。
1.1 为什么是逻辑回归而不是其他模型
分类任务里可选模型太多了,决策树、随机森林、支持向量机,甚至神经网络,为什么案例偏偏选逻辑回归?
我自己的体会是,逻辑回归是理解“广义线性模型”和“梯度下降”之间关系的最佳桥梁。它比线性回归多了一个Sigmoid变换,导致损失函数不再是简单的平方误差,这就引出了“交叉熵”的概念。同时它又足够简单,梯度推导不会像神经网络那样涉及复杂的链式法则和高维矩阵运算。
从实际业务角度来说,逻辑回归的输出是概率,这个性质非常宝贵。比如风控场景里,你不仅要知道一个用户“会不会违约”,还要知道他违约的“概率是多少”,这样才能根据概率阈值做不同的策略。逻辑回归天然提供这种能力,而SVM输出的是距离,决策树输出的是类别,都不如逻辑回归直接。
1.2 案例数据选择
手写算法验证的时候,数据规模不用大,但特征维度要有区分度。这里选一个经典的数据集:乳腺癌诊断数据(某公开数据集,包含细胞核特征与良性/恶性标签)。它有30个特征,样本量几百条,属于典型的小样本高维场景,非常适合检验逻辑回归的稳定性。
如果数据集不是特别方便获取,用sklearn.datasets.load_breast_cancer()一行就能加载。但因为我们不依赖sklearn做模型,只把它当数据源用,问题不大。当然,你也可以自己造一份模拟数据,比如两个特征加上一个线性可分的标签,能更直观地看到决策边界。
提示:真正上手时,建议先在小数据上跑通完整流程,再切换到你实际业务里的数据。先确认代码正确,再考虑效果,这个顺序不要反了。
1.3 功能拆解与架构
整个实现我拆成几个模块:
| 模块 | 功能 | 对应代码 |
|---|---|---|
| 数据加载 | 读取并切分训练集/测试集 | load_data() |
| 特征处理 | 标准化、添加偏置项 | standardize() |
| 模型主体 | Sigmoid、损失、梯度计算 | sigmoid()、compute_loss()、compute_gradient() |
| 优化器 | 批量梯度下降 / 随机梯度下降 | gradient_descent() |
| 评估 | 准确率、AUC等指标 | evaluate() |
这样拆的好处是各函数职责单一,后续要换优化策略、改损失函数,只需要动对应模块,不会牵一发动全身。
2. 核心原理与数学推导
这个章节是整个实现的理论基础。很多教程会直接甩公式,但公式和代码之间的映射关系才是真正需要搞明白的地方。
2.1 Sigmoid函数与概率的桥梁
逻辑回归的核心思路是:把线性回归的输出z = wx + b压缩到(0, 1)区间,作为正类的概率估计。Sigmoid函数的定义是:
\[ \sigma(z) = \frac{1}{1 + e^{-z}} \]
它有几个很好的性质:值域在(0,1)之间,单调递增,且导数可以表示为σ(z)(1 - σ(z))。最后一个性质在推导梯度时非常重要,它让梯度表达式变得极其简洁。
用代码实现就是:
import numpy as np def sigmoid(z): # 防止数值溢出,对z做截断 z = np.clip(z, -500, 500) return 1 / (1 + np.exp(-z))这里做了一个防溢出的处理。np.exp(-z)在z很大时会导致数值溢出变成inf,所以先截断到合理范围。这个细节在训练初期参数较大时很容易踩到。
2.2 损失函数:为什么不是平方误差
线性回归用均方误差(MSE)简直天经地义,但逻辑回归如果继续用MSE,优化会变得非常困难。原因有两个:
第一,MSE对概率输出是平滑的,但它是非凸的。逻辑回归的优化目标是求全局最优,非凸意味着可能收敛到局部最优。
第二,从概率角度看,分类问题应该最大化似然函数,也就是让预测正确的概率尽可能大。取负对数之后,得到的就是交叉熵损失(对数损失):
\[ J(w) = -\frac{1}{m} \sum_{i=1}^{m} \left[ y^{(i)} \log(h(x^{(i)})) + (1 - y^{(i)}) \log(1 - h(x^{(i)})) \right] \]
其中h(x)就是Sigmoid输出的预测概率,y是真实标签(0或1)。这个式子看起来有点凶,但拆开理解就是:当y=1时,损失是-log(h),如果h接近1,损失接近0,如果h接近0,损失趋向无穷大;当y=0时,对称地看-log(1-h)。一句话总结:预测越离谱,惩罚越狠。
这个损失函数的梯度推导是个经典结果。先记个中间变量a = σ(z),然后利用Sigmoid导数的性质:
\[ \frac{\partial J}{\partial w_j} = \frac{1}{m} \sum_{i=1}^{m} (a^{(i)} - y^{(i)}) x_j^{(i)} \]
形式上和线性回归的梯度一模一样,都是“误差乘以特征”,区别在于这个误差是概率差而不是回归差。这也是为什么逻辑回归梯度下降实现起来非常清爽。
2.3 梯度下降三种策略对比
梯度下降的核心思路是:沿着损失函数下降最快的方向(负梯度方向)更新参数。但每次更新用多少样本算梯度,有三种策略:
| 策略 | 每步使用样本数 | 特点 | 适用场景 |
|---|---|---|---|
| 批量梯度下降(BGD) | 全部样本 | 稳定、收敛方向准、计算慢 | 小数据集 |
| 随机梯度下降(SGD) | 1个样本 | 收敛快、震荡大、容易跳出局部最优 | 大数据集 |
| 小批量梯度下降(Mini-Batch GD) | 部分样本(如32/64/128) | 两者折中,最常用 | 绝大多数场景 |
这个案例里,我会同时实现BGD和SGD,因为小批量梯度下降本质上就是两者的结合,理解了极端情况,中间态自然就通了。
参数更新公式是:
w = w - learning_rate * gradient学习率是控制步长大小的超参数。步长太大会导致震荡甚至发散,步长太小则收敛极慢。后面我会专门讲学习率怎么调。
3. 完整实现与代码解析
下面进入正题,给出完整代码并逐段说明。我尽量保持代码简洁,但每一步的意图都写清楚。
3.1 数据准备与标准化
import numpy as np from sklearn.datasets import load_breast_cancer from sklearn.model_selection import train_test_split from sklearn.preprocessing import StandardScaler def load_data(): data = load_breast_cancer() X = data.data y = data.target # 把标签转为0/1,数据集默认是0/1,这里无需额外处理 X_train, X_test, y_train, y_test = train_test_split( X, y, test_size=0.2, random_state=42, stratify=y ) return X_train, X_test, y_train, y_test这里有两个细节值得注意。第一,stratify=y表示切分时保持训练集和测试集的类别比例一致,防止运气不好导致某一类在测试集里占比过高或过低。第二,数据标准化要先在训练集上计算均值和标准差,再用同样的参数去变换测试集。如果在测试集上重新计算均值和方差,相当于把测试集的信息泄露给了模型,严格来说是不对的。
def standardize(X_train, X_test): scaler = StandardScaler() X_train_scaled = scaler.fit_transform(X_train) X_test_scaled = scaler.transform(X_test) return X_train_scaled, X_test_scaled标准化后的特征均值接近0,方差接近1。这样做的原因是,梯度下降对特征的尺度非常敏感。如果一个特征范围是[0, 1000],另一个是[0, 1],那损失函数等高线会呈狭长椭圆状,梯度方向会偏向大尺度的特征,导致收敛路径呈锯齿状,非常慢。标准化之后等高线更接近圆形,梯度方向直指圆心,收敛速度快得多。
3.2 模型核心类
我习惯用一个类封装逻辑回归,这样代码组织更清晰,也方便以后扩展其他优化器。
class LogisticRegression: def __init__(self, lr=0.01, num_iter=1000, fit_intercept=True, verbose=False): self.lr = lr self.num_iter = num_iter self.fit_intercept = fit_intercept self.verbose = verbose self.loss_history = [] def _add_intercept(self, X): if self.fit_intercept: intercept = np.ones((X.shape[0], 1)) return np.concatenate((intercept, X), axis=1) return X def _sigmoid(self, z): z = np.clip(z, -500, 500) return 1 / (1 + np.exp(-z)) def _loss(self, h, y): # 加一个极小值防log(0) eps = 1e-15 return -np.mean(y * np.log(h + eps) + (1 - y) * np.log(1 - h + eps)) def _gradient(self, X, h, y): m = X.shape[0] return np.dot(X.T, (h - y)) / m def fit(self, X, y): if self.fit_intercept: X = self._add_intercept(X) self.w = np.zeros(X.shape[1]) for i in range(self.num_iter): h = self._sigmoid(np.dot(X, self.w)) grad = self._gradient(X, h, y) self.w -= self.lr * grad loss = self._loss(h, y) self.loss_history.append(loss) if self.verbose and i % 100 == 0: print(f"iter {i}, loss {loss:.4f}") def predict_proba(self, X): if self.fit_intercept: X = self._add_intercept(X) return self._sigmoid(np.dot(X, self.w)) def predict(self, X, threshold=0.5): return (self.predict_proba(X) >= threshold).astype(int)这段代码里最核心的就是fit方法里的循环体:前向计算预测概率、求梯度、更新参数、记录损失值。整个逻辑非常紧凑,没有任何多余的装饰。
很多初学者会问:为什么要加偏置项?因为线性决策面w^T x是过原点的,如果数据分布不过原点,模型能力会受限。加上偏置项后,决策边界可以在特征空间里任意平移。实现上通过在X前面拼接一列全1来完成,权重向量里第一个元素就是偏置。
3.3 梯度计算的推导验证
为了确保梯度计算正确,这里提供一个通用的检查方法。用数值梯度来验证解析梯度的正确性,理论依据是导数的定义:
def numerical_gradient(f, X, y, w, eps=1e-6): grad = np.zeros_like(w) for i in range(len(w)): w_plus = w.copy() w_plus[i] += eps w_minus = w.copy() w_minus[i] -= eps grad[i] = (f(w_plus) - f(w_minus)) / (2 * eps) return grad假设f是给定权重计算loss的函数,那么数值梯度就是函数在权重点附近的切线斜率。如果解析梯度和数值梯度的差在1e-6量级以内,说明推导正确。这个技巧在你自己设计新模型时非常救命,很多bug藏在梯度里而表面看不出来,数值梯度校验是第一道防线。
3.4 训练与评估流程
def evaluate(y_true, y_pred): accuracy = np.mean(y_true == y_pred) return accuracy X_train, X_test, y_train, y_test = load_data() X_train_s, X_test_s = standardize(X_train, X_test) model = LogisticRegression(lr=0.1, num_iter=3000, verbose=True) model.fit(X_train_s, y_train) y_train_pred = model.predict(X_train_s) y_test_pred = model.predict(X_test_s) train_acc = evaluate(y_train, y_train_pred) test_acc = evaluate(y_test, y_test_pred) print(f"Train Accuracy: {train_acc:.4f}") print(f"Test Accuracy: {test_acc:.4f}")在标准化的乳腺癌数据集上,3000轮迭代后测试集准确率一般能到95%以上,和sklearn的LogisticRegression结果非常接近。这就说明手写版本的核心逻辑是对的。如果准确率出现明显偏差,那大概率是某个实现细节出了问题,优先检查特征标准化、梯度符号、学习率设置。
4. 梯度下降策略深入与调参实战
代码跑通了只是第一步。真正到了工作里,模型不收敛或者收敛太慢才是常态。这一章详细讲讲梯度下降的调参策略和踩坑经验。
4.1 学习率的选择与自适应调整
学习率是整个优化过程最敏感的超参数。lr=0.1在标准化数据上通常没问题,但如果特征没标准化,同样的学习率可能直接导致loss变成nan。原因很好理解,大尺度特征对应的梯度也大,一步迈出去可能直接越过最优点,甚至越走越远发散。
我实操中的经验是:先用lr=0.01跑100次迭代,看loss曲线的下降趋势。如果loss在某个值附近震荡下不去,说明学习率偏大;如果loss下降得很缓慢,像是蜗牛爬,说明学习率偏小。然后按10倍步长去调整,很快就能找到合适区间。
还有一种常见做法是学习率衰减:前期大步快跑,后期小步精调。简单实现就是在每轮迭代后乘以一个衰减系数:
lr = lr * 0.99这种方式的好处是兼顾了前期收敛速度和后期的稳定性。当然,还有更高级的自适应优化器比如Adam、RMSProp,但它们的核心思想也是动态调整学习率,本质并无差别。
4.2 随机梯度下降与批量梯度下降的对比实验
在乳癌数据集上,我可以预判一下实验结果:批量梯度下降的loss曲线平滑稳定,每轮计算全部样本的梯度,代价是每轮耗时稍长;随机梯度下降因为每次只用一个样本计算梯度,梯度噪声很大,loss曲线像锯齿一样波动,但整体趋势还是下降的。如果任务数据集特别大,比如上百万条样本,BGD每轮都要全量扫描,根本不现实;SGD虽然噪声大,但每轮的计算量极小,往往几十轮下来就已经接近最优解区域了。
如果想把SGD稳定下来,常见技巧是逐步降低学习率。比如先设lr=0.1,然后每num_iter/10轮乘上0.5。这种策略本质上就是模拟退火的思想,前期利用噪声跳出不良区域,后期用小步长收敛到精细位置。
4.3 训练曲线的解读方法
loss_history不是白记录的,它是最直观的调试工具。我每次训练完第一件事就是画loss曲线:
import matplotlib.pyplot as plt plt.plot(model.loss_history) plt.xlabel("Iteration") plt.ylabel("Loss") plt.title("Loss Curve") plt.show()不同曲线形态对应不同问题:
| 曲线特征 | 诊断 |
|---|---|
| 不断下降,最终平稳 | 正常收敛 |
| 迅速降到接近0 | 可能过拟合,或学习率过大 |
| 剧烈震荡、不下降 | 学习率过大或特征未标准化 |
| 平稳不降,像条平线 | 学习率过小,或梯度计算有误 |
| 先降后升,最终发散 | 学习率过大,需要减小 |
Loss曲线在3000轮迭代内如果已经趋于稳定,说明可以提前结束训练了,没必要死等num_iter跑完。有时候实际跑业务会加一个早停策略:当连续多少轮loss下降幅度小于阈值就停止,节省算力还能防止过拟合。
5. 经典案例实战与结果分析
理论讲再多,不如一套完整流程走一遍。这里用乳腺癌数据集做全链路实战。
5.1 特征分布与预处理观察
拿到数据第一件事不是傻乎乎训练,而是先看数据长什么样。用y.value_counts()查看类别分布,用X.describe()查看特征的量纲和范围。发现30个特征的单位、量级差异很大,有的均值几十,有的零点几,这就非常有必要做标准化了。如果不标准化直接丢进模型,大尺度特征会主导梯度,小尺度特征等于没参与学习。
我之前带过不少新人,他们最容易犯的错就是把标准化这一步省了,然后开始怀疑人生:为什么loss一直是nan?为什么准确率只有50%?其实多半就是把标准化漏了。
5.2 训练过程实时记录
我习惯在训练时打印几个关键信息:迭代轮数、当前loss、当前学习率。
iter 0, loss 0.6931 iter 100, loss 0.3218 iter 200, loss 0.2319 ... iter 2900, loss 0.0473loss从初始的0.693(正好是ln2,对应随机猜测的熵)一路下降到0.04左右,说明模型学得很好。很多人只关注最终准确率,其实观察loss下降过程能让你更早发现问题。
5.3 测试集结果与sklearn基准对比
用测试集评估得到的准确率大概在0.96到0.97之间。对比sklearn的LogisticRegression(solver='lbfgs'),两者的准确率差异通常不超过1个百分点。这说明手写batch gradient descent的逻辑回归已经达到了工业级实现的水平——在这个数据集上,算法内核的作用已经发挥到位,剩下的差异主要来自求解器的细节优化。
不过有句话得说明白:数据集不同,结论可能完全不同。在小样本线性可分场景中,手写逻辑回归和sklearn效果几乎一样;但到了大规模稀疏特征场景,比如广告点击率预估,sklearn里那些工程优化就很关键了。手写版本的意义在于理解原理,实际生产用成熟库更放心。
6. 常见问题与避坑经验
最后把这些年踩过的坑集中整理一下,很多问题不是看文档能发现的,得真动手才会碰到。
6.1 数值稳定性相关问题
问题一:梯度爆炸/数值溢出
症状是loss打印出来是nan或者inf。原因通常有两个:一是学习率太大导致参数发散;二是np.exp在z很大时溢出。解决办法:学习率调小,同时Sigmoid实现里做np.clip处理。
问题二:log(0)报错
Sigmoid输出的值可能极其接近0或1,np.log(0)直接是负无穷。解决办法是在log里面加一个极小值eps=1e-15,数学上等价于截断,效果不会受影响。
6.2 收敛性能问题
问题三:loss震荡不收敛
最常见原因是特征没标准化。把特征标准化后,大部分情况下震荡都会消失。其次就是学习率太大,尤其是使用SGD时,噪声本身就大,再叠加过大的学习率就成了纯随机游走。
问题四:用准确率评估模型不够全面
乳腺癌数据集正负样本比例大约1.57:1,不算特别失衡。但如果换成正负比为9:1的数据,即便模型全部预测负类,准确率也有90%,这时候准确率毫无意义。需要关注更好的指标,比如AUC、F1-Score、Precision、Recall。
6.3 代码层面的坑
问题五:权重初始化
这里初始化为零向量在逻辑回归里没问题,因为损失函数是凸的,从哪开始都能收敛到全局最优。但如果在神经网络里全部初始化为0,会导致对称性问题,所有神经元学到同样的特征。习惯上还是用随机初始化比较稳妥,比如np.random.randn(n) * 0.01。
问题六:训练集和测试集处理不一致
标准化参数必须在训练集上拟合,测试集只做变换。很多人代码里一个StandardScaler从头用到尾,在测试集上也调了fit_transform,这就相当于偷偷让模型看到了测试集的信息,测试集准确率会虚高,上线后效果立刻打回原形。
7. 一点心里话
整套代码实现下来,最大的感受就是:纸上谈兵很容易,手推一遍才会真正理解模型的边界在哪里。逻辑回归虽然简单,但从数学推导到代码落地,中间隔着信息泄露、数值稳定性、特征工程这些沟壑,每一步都在考验工程判断力。
如果你现在正在学机器学习,我特别建议你找个数据集,把手写逻辑回归这个流程走完——不要直接调库。训练一次看完loss曲线,再手动调一次学习率,感受那种被震荡折磨又最终看到收敛曲线平稳下来的成就感,对后续学习神经网络的梯度下降会非常有帮助。毕竟,神经网络那套反向传播,本质上也是梯度下降的延伸,只是链式法则和网络结构让表达变得复杂了而已。基础打牢了,后面学什么都是举一反三的事。