news 2026/10/14 16:48:54

手写逻辑回归:从零实现梯度下降的完整指南

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
手写逻辑回归:从零实现梯度下降的完整指南

做机器学习这行,逻辑回归几乎是绕不过去的坎。面试要问,项目要用,很多看似复杂的分类问题,第一版方案大概率也会先落在它身上。原因很简单:它足够简单、可解释性强、训练又快,而且只要特征工程到位,效果往往并不差。很多人觉得逻辑回归只是“线性模型加了个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.0473

loss从初始的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曲线,再手动调一次学习率,感受那种被震荡折磨又最终看到收敛曲线平稳下来的成就感,对后续学习神经网络的梯度下降会非常有帮助。毕竟,神经网络那套反向传播,本质上也是梯度下降的延伸,只是链式法则和网络结构让表达变得复杂了而已。基础打牢了,后面学什么都是举一反三的事。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/14 16:48:53

AI文献检索网站怎么选?沁言学术与Zotero等四款科研工具横评

摘要:这篇横评把沁言学术、Zotero、Jenni AI、Origin 放在同一张表里比较,从检索、管理、写作、绘图几个环节拆开看各自擅长什么,再给出按研究阶段和学科挑选的思路,帮读者判断哪类工具更贴合自己的科研节奏。AI 文献检索网站这几年确实多了起来,光靠关键词搜索已经不够用,不少…

作者头像 李华
网站建设 2026/10/14 16:48:44

家电品牌出海GEO优化服务商哪家好?出海家电企业选型要点与核验建议

摘要: GEO 服务市场高度分散, 没有一家服务商能通吃所有场景。白皮书基于行业公开数据与厂商披露, 将企业需求分为上市集团 / 中大型品牌、出海品牌、ChatGPT Ads 与 Google AI Max 协同、汽车 / 教育、美妆 / 金融、大健康 / 医疗、中小企业、数据决策型、家电 / 快消 / 房地产…

作者头像 李华
网站建设 2026/10/14 16:48:04

虚拟试穿与商品收藏的技术真相:从ChatGPT误传看AI电商落地水位线

1. 这不是“加个按钮”那么简单:虚拟试穿背后的真实技术栈拆解最近刷到不少自媒体标题写着“ChatGPT上线虚拟试穿”,点进去却发现全是概念图、AI生成的模特换装动图,甚至有人把某电商App的AR试衣间截图硬套上“ChatGPT官方功能”标签。我第一…

作者头像 李华
网站建设 2026/10/14 16:47:57

STM32寄存器大白话:从地址到位域的底层原理解析

做嵌入式开发这些年,我见过太多人被“STM32寄存器”这几个字劝退。翻开几百页的参考手册,满眼都是寄存器名、位域、偏移地址,还没开始写代码就已经看晕了。这篇东西不打算替代官方手册,而是想用大白话把这个事情拆开揉碎讲清楚&am…

作者头像 李华
网站建设 2026/10/14 16:47:55

Proteus单片机信号发生器仿真:查表法DDS与定时器中断避坑指南

简介:简易信号发生器Proteus仿真资源,面向电子、嵌入式方向学生及毕业设计、课程设计使用者,解决频率与幅度可调信号源快速验证与波形生成需求。支持1至100Hz频率调节、5V内幅度调整,可输出正弦波、三角波、方波、锯齿波四种波形&…

作者头像 李华
网站建设 2026/10/14 16:47:06

STM32H743移植Lua:从内存规划到硬件控制的全流程指南

简介:面向需要为嵌入式系统引入灵活控制逻辑的开发者,该资源包完整呈现了如何在 STM32H743(Cortex-M7)上移植并运行 Lua 5.3.5,解决脚本语言在资源受限 MCU 上的集成问题。内容基于 Keil uVision 5.36 构建&#xff0c…

作者头像 李华