光看公式觉得逻辑回归不难,真正动手写代码就会踩到一堆细节:特征要不要归一化、数据不平衡的时候准确率为什么骗人、梯度下降为什么不收敛。这篇文章我直接以 Logistic Regression 代码为主线,从原理讲到实现,再带你把一个完整 Demo 跑起来。刚入门的新手可以按顺序看,写过但没吃透的可以直接跳到代码拆解和踩坑那两章。
1. Logistic Regression 到底在做什么:原理拆解
1.1 先搞清楚它是个分类模型
逻辑回归名字里带着“回归”,但它主力解决的是二分类问题:判断一个样本属于 A 类还是 B 类。和线性回归预测连续值不同,它输出的是一个 0 到 1 之间的概率,我们再用概率和阈值做判断。比如用户会不会流失、邮件是不是垃圾邮件、请求是不是异常请求,这类问题都是它的主场。
我在实际项目里的习惯是,逻辑回归永远作为第一个尝试的模型。原因很直接:它快、稳、可解释,几分钟就能出一个 baseline,后面再决定要不要上更复杂的模型。这个“先跑逻辑回归”的习惯帮我避免了很多次过度设计,很多时候业务上要的只是一个可靠的概率输出,并不需要一颗巨型神经网络。
逻辑回归能输出概率,这个特性比单纯输出类别有用得多。在风控场景里,概率可以直接用于额度决策;在推荐场景里,概率可以当作排序分数;在运营场景里,我们还可以调整阈值来控制召回和精确率的平衡。理解了这一点,再看代码里为什么到处是predict_proba而不是predict,就顺理成章了。
1.2 sigmoid 函数如何把线性输出变成概率
线性回归算出来的是z = w·x + b,这个值范围从负无穷到正无穷。可概率必须落在 0 和 1 之间,直接拿z去当概率显然不成立。于是我们用 sigmoid 函数把它压一下:
σ(z) = 1 / (1 + exp(-z))这个函数特别像一个收音机音量旋钮,转盘转动范围有限,但实际输出的音量永远在 0 到 1 之间。当z很大时,输出无限接近 1;当z很小时,输出无限接近 0;z = 0时,输出正好是 0.5。
注意一个关键点:z = w·x + b = 0这条线,就是逻辑回归的**决策边界”。sigmoid(0) 等于 0.5,所以边界两侧的方向就是分类方向。这也是逻辑回归的一个本质限制:它在原始特征空间里只能画出线性边界。如果样本天然是“弯”的,线性边界分类效果就会很差,除非我们手动做特征组合、多项式扩展或者换用其他模型。
1.3 交叉熵损失为什么是标配
逻辑回归训练的核心是让预测概率尽量贴合真实标签。如果简单套用线性回归的均方误差(MSE),会遇到两个实际麻烦:
第一,sigmoid 加 MSE 会让损失函数变成非凸的,曲面像山地一样坑坑洼洼,梯度下降很容易停在局部低谷。第二,sigmoid 两端趋于饱和,预测错得离谱时梯度反而很小,模型纠错速度极慢。
所以逻辑回归几乎总是搭配交叉熵损失,对二分类来说就是二元交叉熵:
L = -1/n * Σ [ y_i * log(p_i) + (1 - y_i) * log(1 - p_i) ]这个公式不需要死记,理解它的行为就够了:当真实标签y=1时,第一项起作用,预测概率越接近 1 损失越小;当真实标签y=0时,第二项起作用,预测概率越接近 0 损失越小。换句话说,预测越离谱,梯度越大,模型被推着快速纠正。这就是交叉熵比 MSE 更适合分类任务的根本原因。
1.4 决策边界与系数解释
逻辑回归训练完,系数w的含义非常直观。某个特征对应的系数是正数,说明该特征值越大,样本越可能被预测为正类;系数是负数则相反。系数绝对值越大,代表该特征对判断的影响越强。
具体到概率层面,可以理解为“特征每增加一个单位,对数几率的变化量就等于对应的系数值”。这里不展开全部推导,你只需要知道:利用系数的正负和大小,就能向业务方解释模型在关注什么。这也是逻辑回归在风控、医疗、营销等强解释性场景里长期占有一席之地的原因。
2. 代码结构拆解:手写实现与调库实现
2.1 两种实现路线怎么选
我把逻辑回归的实现分成两条路线:
| 对比维度 | 调库实现 | 手写实现 |
|---|---|---|
| 开发速度 | 快,几行代码完成 | 慢,要自己处理梯度 |
| 稳定性 | 高,包含大量工程优化 | 依赖你的实现质量 |
| 可解释性 | 依赖文档理解 | 全流程都在自己手里 |
| 适用场景 | 生产交付、快速迭代 | 学习原理、论文复现、自定义损失 |
我的判断标准很简单:如果是上线项目,直接用现成库,没必要重复造轮子;如果是为了理解算法本质,手写一遍的价值远超调库十次。很多人在调库时遇到“为什么不收敛”“特征要不要归一化”这类问题,查半天文档都找不到答案,就是因为对内部的梯度更新没有体感。手写一遍之后,这些问题基本不看文档也能定位。
2.2 基于 scikit-learn 的实现细节
先看最标准的调库写法:
from sklearn.datasets import make_classification from sklearn.model_selection import train_test_split from sklearn.preprocessing import StandardScaler from sklearn.linear_model import LogisticRegression X, y = make_classification( n_samples=1000, n_features=5, n_informative=5, n_redundant=0, random_state=42 ) X_train, X_test, y_train, y_test = train_test_split( X, y, test_size=0.2, random_state=42, stratify=y ) scaler = StandardScaler() X_train = scaler.fit_transform(X_train) X_test = scaler.transform(X_test) model = LogisticRegression(max_iter=1000) model.fit(X_train, y_train) print("系数:", model.coef_) print("截距:", model.intercept_)这里有几个细节值得注意:
fit_transform和transform不能混用。scaler必须在训练集上fit,然后再作用到测试集。如果你对全部数据先fit_transform再去切分,会造成数据泄露,验证出来的指标会虚高。
class平衡很重要,如果y的类别比例差异大,建议在LogisticRegression里设置class_weight='balanced'。这个参数会自动给少数类更高的权重,避免模型只顾着预测多数类。
max_iter报 Warning 时不要无脑调大,先检查特征是否标准化。绝大多数“迭代次数不足”的警告,根源是特征尺度过大导致收敛变慢,标准缩放之后往往就恢复正常了。
2.3 从零手写的梯度下降核心
接下来是我认为理解逻辑回归最关键的部分:手写实现。这里不依赖 sklearn 的模型类,只用 NumPy 就能完成一套完整的梯度下降训练。
import numpy as np def sigmoid(z): return 1 / (1 + np.exp(-z)) def binary_cross_entropy(y, y_hat): eps = 1e-12 return -np.mean(y * np.log(y_hat + eps) + (1 - y) * np.log(1 - y_hat + eps)) class LogisticRegressionManual: def __init__(self, lr=0.01, epochs=1000): self.lr = lr self.epochs = epochs self.w = None self.b = 0.0 self.losses = [] def fit(self, X, y): n, m = X.shape self.w = np.zeros(m) for epoch in range(self.epochs): z = X @ self.w + self.b y_hat = sigmoid(z) grad_w = (X.T @ (y_hat - y)) / n grad_b = np.mean(y_hat - y) self.w = self.w - self.lr * grad_w self.b = self.b - self.lr * grad_b loss = binary_cross_entropy(y, y_hat) self.losses.append(loss) return self def predict_proba(self, X): return sigmoid(X @ self.w + self.b) def predict(self, X, threshold=0.5): return (self.predict_proba(X) >= threshold).astype(int)这段代码里最核心的一步是grad_w = (X.T @ (y_hat - y)) / n。它的来源是交叉熵对w求导的结果,形式上非常简洁:真实值和预测值的差,乘上特征,再取平均。正因为梯度表达式这么漂亮,手写实现才能在十几行代码内完成。
eps = 1e-12是为了防止log(0)出现负无穷。实际中预测概率很少严格等于 0,但加上它成本很低,属于稳妥写法。
这个手写版没有加正则化。如果要加 L2,只需要在损失里加一项lambda * np.sum(w**2) / (2*n),梯度里对应加lambda * w / n。在数据维度高、特征噪声大的时候,这一项能明显抑制过拟合。
2.4 梯度检查:验证手写代码的第一道关
手写梯度最容易出错,但我有个很实用的自检方法:数值梯度对比。用差分近似求出梯度,再和解析梯度比较,差别很小就说明推导没错。
def numerical_gradient(f, w, eps=1e-6): grad = np.zeros_like(w) for i in range(len(w)): wp = w.copy() wm = w.copy() wp[i] += eps wm[i] -= eps grad[i] = (f(wp) - f(wm)) / (2 * eps) return grad def loss_func(w, X, y): z = X @ w + model.b return binary_cross_entropy(y, sigmoid(z)) # 用一小批数据验证 X_small, y_small = X_train[:100], y_train[:100] model_manual = LogisticRegressionManual(epochs=1) model_manual.w = np.random.randn(X_small.shape[1]) * 0.01 analytic_grad = (X_small.T @ (sigmoid(X_small @ model_manual.w + model_manual.b) - y_small)) / len(y_small) numeric_grad = numerical_gradient(lambda w: loss_func(w, X_small, y_small), model_manual.w) print("解析梯度范数:", np.linalg.norm(analytic_grad)) print("数值梯度范数:", np.linalg.norm(numeric_grad)) print("最大差值:", np.max(np.abs(analytic_grad - numeric_grad)))如果最大差值在1e-5量级左右,说明梯度计算基本正确。我每次改完损失函数或者模型结构,都会先跑这个检查再继续训练,能省掉很多无意义的调参时间。
3. 从数据到上线:一个可复现的 Demo 全流程
3.1 构造示例数据集并完成预处理
为了演示方便,我构造一个二维的模拟分类数据。二维的好处是后面可以直接把决策边界画出来,直观看到模型到底学到了什么。
import numpy as np import pandas as pd from sklearn.model_selection import train_test_split from sklearn.preprocessing import StandardScaler rng = np.random.default_rng(42) n = 800 X = np.vstack([ rng.normal(loc=[4, 4], scale=1.2, size=(n // 2, 2)), rng.normal(loc=[7, 8], scale=1.2, size=(n // 2, 2)), ]) y = np.hstack([np.zeros(n // 2), np.ones(n // 2)]) # 打乱顺序 shuffle_idx = rng.permutation(n) X, y = X[shuffle_idx], y[shuffle_idx] X_train, X_test, y_train, y_test = train_test_split( X, y, test_size=0.3, random_state=42, stratify=y ) scaler = StandardScaler() X_train = scaler.fit_transform(X_train) X_test = scaler.transform(X_test) print("训练集形状:", X_train.shape, "测试集形状:", X_test.shape) print("正类占比:", round(y_train.mean(), 4))stratify=y这个参数我特别建议加上,它保证切分后训练集和测试集的类别比例和原数据一致。如果数据量小且类别不平衡,不设置这个参数,可能某一次切分就把所有正类都切到了测试集,训练出来的模型完全没有正类样本可学。
标准化放在切分之后、训练之前。先用训练集fit得到均值和标准差,再对测试集做同样的transform。这保证了测试集完全以“旁观者”的身份参与评估。
3.2 训练模型与评估指标解读
训练直接用一个手写实现,一个调库实现,两边对比看看结果是否接近:
from sklearn.linear_model import LogisticRegression from sklearn.metrics import classification_report, roc_auc_score # 手写实现 manual_model = LogisticRegressionManual(lr=0.1, epochs=2000) manual_model.fit(X_train, y_train) manual_proba = manual_model.predict_proba(X_test) manual_pred = manual_model.predict(X_test) # 调库实现 sk_model = LogisticRegression(max_iter=1000) sk_model.fit(X_train, y_train) sk_proba = sk_model.predict_proba(X_test)[:, 1] sk_pred = sk_model.predict(X_test) print("手写实现 AUC:", roc_auc_score(y_test, manual_proba)) print("调库实现 AUC:", roc_auc_score(y_test, sk_proba)) print("\n手写实现分类报告:") print(classification_report(y_test, manual_pred)) print("\n调库实现分类报告:") print(classification_report(y_test, sk_pred))结果里最值得关注的不只是准确率,而是查准率、查全率和 AUC。查准率衡量“模型说正类的样本里有多少真的正类”,查全率衡量“真正的正类里模型找回了多少”。这两个指标经常互相拉扯,具体业务是更在乎误伤还是更在乎漏掉,决定了你调阈值的方向。
AUC 这个指标更综合,它衡量的是模型把正类排在负类前面的概率。哪怕两类极其不平衡,AUC 也能稳定反映模型的区分能力。AUC 到 0.9 以上说明模型在 Demo 数据上区分能力很强。
3.3 可视化决策边界与收敛曲线
跑完训练,我建议把两个图打出来:决策边界图和损失收敛图。前者看模型学得怎么样,后者看训练过程有没有异常。
import matplotlib.pyplot as plt def plot_decision_boundary(model, X, y, title): x_min, x_max = X[:, 0].min() - 0.5, X[:, 0].max() + 0.5 y_min, y_max = X[:, 1].min() - 0.5, X[:, 1].max() + 0.5 xx, yy = np.meshgrid( np.linspace(x_min, x_max, 200), np.linspace(y_min, y_max, 200) ) Z = model.predict_proba(np.c_[xx.ravel(), yy.ravel()])[:, 1] Z = Z.reshape(xx.shape) plt.contourf(xx, yy, Z, alpha=0.6, cmap='RdBu') plt.scatter(X[:, 0], X[:, 1], c=y, edgecolors='k', cmap='RdBu') plt.xlabel("feature_1") plt.ylabel("feature_2") plt.title(title) plt.show() plot_decision_boundary(manual_model, X_train, y_train, "Manual LR Decision Boundary") plot_decision_boundary(sk_model, X_train, y_train, "Sklearn LR Decision Boundary") # 手写模型的损失曲线 plt.figure() plt.plot(manual_model.losses) plt.xlabel("epoch") plt.ylabel("binary cross entropy") plt.title("Training Loss Curve") plt.show()决策边界图里,颜色深浅代表模型预测的概率大小,中间那条 0.5 概率线就是决策边界。如果训练的 Demo 数据重叠不是特别严重,你看到的边界会是一条干净的直线,斜着把两类分开。
损失曲线我重点看三点:第一,整体是否持续下降;第二,有没有突然的尖峰;第三,末端是否进入平台期。如果损失曲线像锯齿一样上下乱跳,通常是学习率太大;如果一路平滑下降然后稳住,那就是健康的训练信号。手写实现里的losses列表就是为这个图留的接口,不建议删掉。
3.4 模型落盘与预测服务脚本
模型训练完要落地,不能只留在 Jupyter 里。我习惯把训练好的模型和标准化器一起保存,预测时一起加载:
import joblib joblib.dump({ "model": manual_model, "scaler": scaler }, "logistic_demo.pkl")然后写一个独立的预测脚本,方便被接口调用:
import joblib import numpy as np def load_model(path="logistic_demo.pkl"): bundle = joblib.load(path) return bundle["model"], bundle["scaler"] def predict_one(features, model, scaler, threshold=0.5): features = np.array(features, dtype=float).reshape(1, -1) features = scaler.transform(features) prob = model.predict_proba(features)[0, 1] if hasattr(model, "predict_proba") else sigmoid_model_proba(model, features) label = 1 if prob >= threshold else 0 return {"label": int(label), "prob": float(prob)} model, scaler = load_model() print(predict_one([5.0, 6.0], model, scaler, threshold=0.5))这里要注意,scaler在离线训练时已经保存好,线上预测必须使用同一套均值和标准差,不能重新计算。否则输入特征的范围跟训练时不一致,模型输出会直接偏掉。这个细节我见过太多次了,很多初学的朋友预测结果异常,最后定位到是加载模型时把标准化器漏了。
4. 实操中最容易踩的坑与排查经验
4.1 类别不平衡:准确率虚高的假象
逻辑回归在类别不平衡数据上特别容易被“骗”。比如正类只占 1%,模型只要把所有样本都预测成负类,准确率就能到 99%。看起来指标非常漂亮,但业务上一个正类都没找出来,完全不可用。
我遇到类似情况时,第一件事是看分类报告里的召回率和 AUC,而不是只盯着准确率。第二件事是设置class_weight='balanced',让少数类获得更高权重。第三件才是考虑采样,比如对多数类降采样或少数类过采样。但过采样要谨慎,盲目对少数类复制样本很容易造成过拟合,实际在线上泛化反而变差。
评估时还有一个细节:用分层抽样切分数据。train_test_split里加stratify=y能保证训练和测试集的类别比例一致,评估结果才可靠。
4.2 特征尺度:不标准化会带来哪些连锁问题
手写逻辑回归在没有特征标准化的时候,很容易出现损失曲线剧烈震荡。原因很简单:如果某个特征取值范围是 0 到 10000,另一个特征取值范围是 0 到 1,那么在梯度下降里,数值大的特征对应的梯度也会放大很多倍,权重更新时每一步都像在跳台阶。
用学习率去凑不标准化的数据,非常痛苦。学了 0.0001 太慢,调到 0.1 就发散。正确做法是先把所有特征归一化到相近的量级。
另外,如果加了 L2 正则化,特征尺度不一致还会让正则项对所有特征“一刀切”地惩罚,完全没有考虑到不同特征的数值范围差异。这样得到的稀疏性和权重解释都会失真。所以不管用哪种库,逻辑回归前面接一个StandardScaler几乎是我的固定动作。
4.3 学习率与迭代轮数的配合经验
手写实现里,学习率和迭代轮数是一对需要配合的参数。根据我反复尝试的经验值,可以给你一组起步参考:
| 学习率 | 迭代轮数 | 效果 |
|---|---|---|
| 0.01 | 2000 | 稳定收敛,适合大部分标准化后的数据 |
| 0.1 | 1000 | 收敛更快,但偶见初期震荡 |
| 0.5 | 500 | 仅适合特征非常干净的数据 |
| 0.001 | 5000 | 稳定但慢,适合调试用 |
我的方法不是硬背参数,而是先打印前 50 轮的损失值。如果损失在下降,继续跑;如果在震荡或上涨,立即停掉,把学习率调低一个数量级。迭代轮数不足的症状是损失还没到平台期就被截断,欠拟合明显;迭代轮数过多但损失不变,说明模型已收敛,继续训练没有意义。
调库时如果收到 “ConvergenceWarning”,先看是不是迭代轮数问题,然后确认特征归一化,最后才考虑调高max_iter。调参的顺序很重要,乱调会把问题搞复杂。
4.4 从二分类到多分类的扩展要点
逻辑回归虽然基础形态是二分类,但实际业务里经常遇到三分类以上问题。scikit-learn 的LogisticRegression默认支持多分类,机制是ovr或multinomial两种,不需要你写额外代码。
手写实现扩展多分类,通常的做法是把输出层从 sigmoid 换成 softmax,损失函数从二元交叉熵换成多元交叉熵。softmax 的作用是把多个输出分数转换成一个和为 1 的概率分布,这样每个类别的概率可以直接比较。
不过在实际项目中,我的建议是多分类优先调库。因为多分类逻辑回归里的损失函数、梯度矩阵、正则化项写起来容易出错,而且调试成本比自己写二分类高一个量级。理解了二分类的手写实现,再去读多分类源码,收获比直接硬写大得多。
4.5 特征工程对线性模型的加成效果
逻辑回归的线性边界容易“不够用”,但这不代表它能力弱。它真正的上限取决于特征表达。很多人在数据上直接丢原始特征进去,发现逻辑回归效果不如树模型,于是认为逻辑回归不行,其实问题出在特征没有加工。
我的经验里,对逻辑回归最有效的两类特征工程是:数值特征做分箱后传入模型,以及构造交叉特征。比如年龄直接当数值变量,模型只能学到单调影响;把年龄分成“18-25、26-35、36-45、45+”四段,模型就能捕捉非单调的关系。交叉特征则是把“高活跃度”和“近 7 天下单”这种组合信息显式地表示出来,给线性模型创造非线性表达的机会。
用一句话总结就是:树模型靠特征自动搜索规则,逻辑回归靠特征设计和解释能力立足。谁都没有被谁淘汰,只是适用场景不同。
写在最后
老实说,我在生产项目里大多数时候直接调 sklearn 的LogisticRegression,但手写一遍之后,对模型各种行为的判断速度快了很多。以前遇到“不收敛”“准确率异常高”“预测概率全飘到一端”这类现象,我只能上网查,现在基本能直接猜到是数据尺度、类别权重还是阈值选择的问题。
如果你时间有限,至少把 2.3 节那段手写代码在本地跑一遍,再看一眼损失收敛曲线。这一层理解会跟着你很久,后面学逻辑回归扩展、学其他广义线性模型,都会轻松很多。