前言
上一篇我们用线性回归预测了一个连续数值。现实中的机器学习任务却不总是在问“具体是多少”,还经常在问“属于哪一类”:一封邮件是不是垃圾邮件、一笔交易是否异常、一位客户会不会流失。这类问题叫作分类任务。
本文从最常见的二分类开始,使用逻辑回归(Logistic Regression)判断模拟客户是否流失。我们会从线性得分、Sigmoid 函数和分类阈值出发,再用混淆矩阵、准确率、精确率、召回率与 F1 分数评价模型。重点不是背公式,而是把“数据怎样变成概率,概率又怎样变成类别”这条链路连起来。
本文数据全部由代码在本地生成,只用于教学演示,不代表真实业务客户。示例里的概率是模型在这批模拟数据上的估计,不应直接当成真实世界中已经校准的流失风险。
分类任务与回归任务的区别
回归
回归任务预测的是连续数值。例如根据面积、位置和房龄预测房价,根据广告投入预测销售额。相邻两个结果之间通常有可解释的数值距离:预测 80 元和 100 元,相差 20 元。
分类
分类任务预测的是离散类别。例如“正常/异常”“未流失/流失”。本文把未流失记为 0,把流失记为 1,但 0 和 1 只是类别编码,不表示流失是未流失的“一倍”,也不能像连续数值那样任意做加减。
只有两个类别时叫二分类(Binary Classification);类别多于两个时叫多分类(Multiclass Classification)。本文集中讨论二分类,后文会简单说明多分类的处理方式。
为什么叫逻辑回归,却用于分类
“逻辑回归”这个名字容易让初学者误会。它确实先像线性回归一样,把各个特征乘以相应权重再相加,得到一个线性得分;但它不会把这个任意大小的得分直接当作最终预测,而是再通过一个函数把得分压缩到 0 和 1 之间。
这个位于 0 到 1 之间的结果可以解释为模型对正类,也就是类别 1 的估计概率。最后再设置一个阈值,例如 0.5,把概率转换成类别。因此,逻辑回归虽然名字带有“回归”,实际最常见的用途是分类。
从线性得分到概率
假设一个客户有 5 个特征,逻辑回归先计算:
z = w1 × x1 + w2 × x2 + … + w5 × x5 + b
其中,x是特征值,w是模型学习到的系数,b是截距,z是线性得分。z可以是任意实数,可能为 -3、0.8 或 12,不能直接作为概率。
接下来,模型把z交给 Sigmoid 函数,得到p。当p大于或等于分类阈值时预测为 1,否则预测为 0。完整过程可以概括为:
输入特征 → 线性组合 z → Sigmoid → 类别 1 的估计概率 → 阈值判断 → 预测类别
Sigmoid 函数是什么
Sigmoid 是一个把任意实数映射到 0 和 1 之间的函数:
σ(z) = 1 / (1 + e⁻ᶻ)
当z很小,结果接近 0;当z = 0,结果等于 0.5;当z很大,结果接近 1。曲线中间变化较快,两端逐渐变平,因此常被画成 S 形。
需要特别注意:模型输出 0.8,并不等于“现实中一定有 80% 的流失概率”。它只是模型基于训练数据、特征与假设给出的估计。训练数据如果有偏差,输出也可能有偏差;若业务需要可信概率,还应在独立数据上检查概率校准,而不只是看分类对不对。
分类阈值
scikit-learn 的逻辑回归在二分类时通常使用 0.5 作为默认阈值。若类别 1 的估计概率不低于 0.5,predict返回 1,否则返回 0。
阈值并不是不可改变的。降低阈值往往会让更多样本被预测为正类,可能提高召回率,同时也可能带来更多误报,使精确率下降;提高阈值通常会减少正类预测,可能降低误报,但也可能漏掉更多真正的正类。
阈值应根据错误成本和任务目标选择。例如,初筛场景更在意不要漏掉潜在正类,可能偏向较低阈值;人工复核成本很高时,可能更在意每个告警是否可靠。正式项目应在验证集或交叉验证中选择阈值,测试集只留作最终一次评估。本文展示测试集上不同阈值的结果只是帮助理解,不把它当成调参依据。
本文的模拟客户流失数据
示例通过固定随机种子生成 800 个模拟客户,每行是一个样本,共有 5 个输入特征:
monthly_fee:月费用;contract_months:合约持续月数;support_calls:联系客服次数;usage_hours:使用时长;late_payments:逾期付款次数。
标签churn表示是否流失:0 为未流失,1 为流失。生成规则让月费用、客服联系次数和逾期次数增加时,流失倾向通常上升;合约月数和使用时长增加时,流失倾向通常下降。不过代码还加入随机噪声,并依据概率做伯努利抽样,因此两类会有重叠,模型无法靠一条简单规则完美分类。这更接近学习分类模型时应面对的情况。
实际运行得到的数据形状为(800, 6),没有缺失值和重复行。完整数据中类别 0 有 441 个,占 55.12%;类别 1 有 359 个,占 44.88%。这不是来自真实公司的调查结果,而是当前代码和随机种子生成的可复现结果。
特征矩阵 X 和标签 y
特征矩阵X只包含 5 个输入列,形状为(800, 5);标签y是单独的一列,形状为(800,)。训练时模型从X学习如何预测y,因此不能把churn本身放进特征矩阵,否则就相当于提前把答案交给模型,形成数据泄漏。
代码在训练前检查列顺序、缺失值、无穷大和标签集合。标签必须同时包含 0 和 1。若训练数据只有一个类别,逻辑回归无法学习两个类别之间的区别。
拆分训练集和测试集
我们只拆分一次:
X_train, X_test, y_train, y_test = train_test_split( X, y, test_size=0.25, random_state=42, stratify=y, )test_size=0.25表示 25% 样本作为测试集,random_state=42固定拆分结果。stratify=y是分层抽样,让训练集和测试集中的类别比例尽量接近完整数据。
实际拆分后,训练集有 600 个样本,其中类别 0 为 331 个、类别 1 为 269 个;测试集有 200 个样本,其中类别 0 为 110 个、类别 1 为 90 个。后续标准化和模型参数都只能从训练集学习,测试集不能参与训练。
为什么逻辑回归通常需要标准化
5 个特征的量纲不同:月费用可能是几十到上百,逾期次数通常只有几个。如果直接训练,数值范围差异可能影响优化过程,也会让正则化对不同特征产生不均衡的约束。
StandardScaler会使用训练集的均值和标准差把特征转换到相近尺度。我们把它和逻辑回归放进Pipeline(流水线),调用fit时只在训练集上学习标准化参数,调用predict时再自动用相同参数处理新数据。这样既减少重复代码,也降低把测试集信息泄漏给训练过程的风险。
标准化并不改变样本标签,也不代表所有算法都必须标准化。例如树模型通常对特征尺度不敏感。但对逻辑回归、K 近邻和支持向量机等模型,标准化经常是值得保留的步骤。
建立分类基线模型
在评价逻辑回归之前,先建立一个简单基线。DummyClassifier(strategy="most_frequent")不学习特征规律,只把所有测试样本预测成训练集中数量最多的类别。
本次运行的多数类基线在测试集上的结果为:准确率 0.5500,精确率 0.0000,召回率 0.0000,F1 分数 0.0000;混淆矩阵为TN=110、FP=0、FN=90、TP=0。
它的准确率看起来有 55%,却一个流失客户都没有找到。这正说明分类任务不能只看准确率,尤其当类别比例不均衡时,“总猜多数类”也可能得到不低的准确率。
训练逻辑回归
模型使用标准化与逻辑回归组成流水线:
model = Pipeline( steps=[ ("scaler", StandardScaler()), ( "classifier", LogisticRegression(max_iter=1000, random_state=42), ), ] ) model.fit(X_train, y_train)max_iter=1000为优化过程提供足够的最大迭代次数。代码不会粗暴屏蔽收敛警告,而是捕获并检查ConvergenceWarning;如果没有正常收敛,就明确报错,而不是把未完成的模型当成成功结果。
predict 与 predict_proba
predict(X_test)直接返回类别 0 或 1。predict_proba(X_test)返回每个类别的估计概率,每行概率之和为 1。例如classes_为[0, 1]时,两列分别对应类别 0 和类别 1。
不要永远假定“第二列就是正类概率”。示例先读取classifier.classes_,再查找类别 1 所在的列,避免类别编码或顺序变化后取错概率。
本次测试集前几个样本的类别 1 估计概率包括 0.2444、0.1575、0.5999 和 0.4591。默认阈值为 0.5 时,0.5999 对应类别 1,0.4591 对应类别 0。概率接近阈值的样本通常也是模型更犹豫的样本。
混淆矩阵
混淆矩阵把真实类别与预测类别交叉统计。代码固定labels=[0, 1],因此confusion_matrix(...).ravel()的顺序始终是 TN、FP、FN、TP。
TN
TN(True Negative,真正例)表示真实为 0、预测也为 0。本文中就是未流失客户被正确判断为未流失。
FP
FP(False Positive,假正例)表示真实为 0、却预测为 1,也叫误报。它可能让运营人员把资源花在实际上不会流失的客户身上。
FN
FN(False Negative,假负例)表示真实为 1、却预测为 0,也叫漏报。若任务目标是尽早发现潜在流失客户,FN 往往是需要重点关注的错误。
TP
TP(True Positive,真正例)表示真实为 1、预测也为 1,即成功找到的流失客户。
本次逻辑回归在默认阈值下得到TN=90、FP=20、FN=38、TP=52。四个数相加为 200,正好等于测试集样本数。
准确率
准确率(Accuracy)是所有预测中,预测正确的比例:
Accuracy = (TP + TN) / (TP + TN + FP + FN)
本次逻辑回归准确率为 0.7100,表示 200 个测试样本中有 142 个分类正确。但如果正负类别极不均衡,准确率可能掩盖模型完全忽略少数类的问题,所以不能单独使用。
精确率
精确率(Precision)回答:“被模型预测为正类的样本中,有多少确实是正类?”
Precision = TP / (TP + FP)
本次结果为 0.7222。默认阈值下模型预测了 72 个正类,其中 52 个确实为正类。精确率越高,通常意味着误报相对越少。
召回率
召回率(Recall)回答:“所有真实正类中,模型找出了多少?”
Recall = TP / (TP + FN)
本次结果为 0.5778,即 90 个真实正类中找到 52 个,还有 38 个漏报。更重视“不漏掉正类”的任务通常会重点观察召回率。
F1 分数
F1 分数(F1 Score)是精确率和召回率的调和平均:
F1 = 2 × Precision × Recall / (Precision + Recall)
本次 F1 为 0.6420。只有精确率和召回率都不太差时,F1 才会较高。它适合需要在误报和漏报之间取得平衡的场景,但仍不能替代对具体业务成本的分析。
分类指标对比
同一测试集、默认阈值 0.5 下的实际结果如下:
| 模型 | 准确率 | 精确率 | 召回率 | F1 | TN | FP | FN | TP |
|---|---|---|---|---|---|---|---|---|
| 多数类基线 | 0.5500 | 0.0000 | 0.0000 | 0.0000 | 110 | 0 | 90 | 0 |
| 逻辑回归 | 0.7100 | 0.7222 | 0.5778 | 0.6420 | 90 | 20 | 38 | 52 |
逻辑回归不仅提高了准确率,也真正识别出一部分正类,因此比多数类基线有意义。不过 0.5778 的召回率说明仍有不少漏报,不能因为整体准确率超过基线就宣称模型已经足够好。
调整分类阈值
使用同一个已训练模型,只改变把概率转换成类别的阈值,实际得到:
| 阈值 | 预测为正类的数量 | 精确率 | 召回率 | F1 |
|---|---|---|---|---|
| 0.35 | 111 | 0.5856 | 0.7222 | 0.6468 |
| 0.50 | 72 | 0.7222 | 0.5778 | 0.6420 |
| 0.65 | 43 | 0.7442 | 0.3556 | 0.4812 |
在这次运行中,阈值从 0.50 降到 0.35,正类预测从 72 个增加到 111 个,召回率从 0.5778 升到 0.7222,但精确率降到 0.5856。阈值提高到 0.65 后,精确率略升到 0.7442,召回率却降到 0.3556。
这些变化符合阈值的一般直觉,但具体数字只属于当前模拟数据和固定拆分。不能先查看测试集结果,再挑一个看起来最好的阈值并把它当作无偏成绩;正式流程应在验证集或交叉验证中完成阈值选择,然后只在测试集评估一次。
逻辑回归的系数
逻辑回归为每个特征学习一个系数。系数为正,通常表示在其他输入保持不变时,该特征增大与类别 1 的更高模型倾向相关;系数为负则相反。
本例在标准化后的特征上得到截距-0.252296,系数如下:
| 特征 | 标准化系数 |
|---|---|
| monthly_fee | 0.444903 |
| contract_months | -0.663760 |
| support_calls | 0.522662 |
| usage_hours | -0.216267 |
| late_payments | 0.510185 |
由于流水线先做标准化,这里的“增加 1 个单位”指增加 1 个训练集标准差,而不是月费用增加 1 元或合约增加 1 个月。系数大小还会受特征相关性、正则化、取值范围和预处理方式影响,不应只按绝对值机械地给特征排重要性。
更重要的是,系数描述的是模型在当前数据中学到的关联,不是因果结论。正系数不能证明“提高月费用一定导致客户流失”,还可能存在未记录的混杂因素或数据采集偏差。
对数几率与优势比
概率p对应的几率(Odds,也译作优势)为:
odds = p / (1 - p)
逻辑回归对几率取自然对数,得到对数几率(Log-odds),再让它与特征的线性组合相等。将某个系数取指数exp(coef),可以得到该特征增加一个单位时对应的优势比(Odds Ratio)。
本例的标准化特征优势比如下:
| 特征 | 优势比 |
|---|---|
| monthly_fee | 1.560338 |
| contract_months | 0.514912 |
| support_calls | 1.686511 |
| usage_hours | 0.805520 |
| late_payments | 1.665599 |
例如support_calls的优势比约为 1.6865,含义是在模型其他输入不变的条件下,标准化后的客服联系次数增加 1 个标准差,模型估计的正类几率乘以约 1.6865。它不是“概率增加 68.65%”,更不是因果效应。优势比是否稳定、是否有业务意义,还需要更严格的数据设计和统计检查。
预测一个新客户
最后构造一个新样本:月费用 88.0、合约 8 个月、联系客服 4 次、使用时长 22.0、逾期付款 2 次。流水线会自动使用训练集学到的标准化参数,再交给逻辑回归预测。
本次运行读取到classes_=[0, 1],预测类别为 1,类别 1 的模型估计概率为0.903283。这个结果只说明当前教学模型如何处理这一组模拟输入,不能作为真实客户决策依据,也不能解释为已经验证过的真实流失概率。
多分类问题怎么办
逻辑回归也能处理三个及以上类别。scikit-learn 会根据求解器和参数使用适合的多分类策略,predict_proba会为每个类别返回一列概率,所有列之和为 1。此时必须查看classes_确认每列对应哪个类别。
多分类评价还要说明指标的平均方式,例如 macro、micro 或 weighted。本文先把二分类中的概率、阈值和混淆矩阵掌握清楚,再学习多分类会更自然。
逻辑回归常见问题
1. 出现收敛警告
ConvergenceWarning表示在给定迭代次数内优化没有正常结束。可以检查是否需要标准化、提高max_iter、清理异常数据或调整求解器。不要简单屏蔽所有警告,否则可能把训练问题藏起来。
2. 训练数据只有一个类别
如果y全为 0 或全为 1,模型没有可学习的类别差异。应先检查数据筛选、拆分和标签生成流程,不能靠修改模型参数绕过根本问题。
3. 字符串特征无法直接训练
城市、套餐名称等字符串需要编码为数值。无序类别通常使用独热编码,并把编码器放进流水线,避免训练集和测试集采用不同规则。
4. 特征尺度差异很大
不同量纲可能让优化和正则化表现不理想。对数值特征使用StandardScaler是常见做法,但标准化参数只能从训练集学习。
5. 类别不平衡
如果正类非常少,只看准确率容易误判。应同时查看混淆矩阵、精确率、召回率、F1,必要时再讨论类别权重、重采样和适合任务的评价指标。本文主模型没有使用class_weight="balanced",避免把额外策略混入入门主线。
6. 数据几乎完全可分
如果某个特征或规则几乎直接暴露标签,系数可能非常大,也可能暗示数据泄漏。应先检查特征是否在预测时真实可用,而不是因为测试分数很高就立即接受结果。
7. 概率未经校准
分类正确不代表概率可靠。若业务真的依赖“概率是多少”,应使用独立数据检查校准曲线或适当的校准方法。本文没有做概率校准,因此只把输出称为模型估计概率。
8. 数据泄漏
先用全量数据做标准化、把事后才知道的信息放入特征,或根据测试集表现反复选阈值,都会让评估过于乐观。流水线能解决部分预处理泄漏,但不能自动发现所有业务泄漏。
9. 线性决策边界能力有限
逻辑回归学习的是特征空间中的线性决策边界。真实关系高度弯曲或依赖复杂交互时,它可能欠拟合。不过它训练快、解释路径清晰,仍然是很有价值的分类基线。
逻辑回归适合什么情况
逻辑回归适合需要快速建立二分类基线、特征数量适中、希望输出概率并希望理解特征方向的任务。它对高维稀疏特征也常有良好表现,并且训练与预测成本通常不高。
如果数据关系明显非线性、类别边界非常复杂,或者原始数据是图片、音频等非结构化内容,单纯逻辑回归可能不够。但即使最终使用复杂模型,逻辑回归仍能提供一个清晰的比较起点:复杂模型至少应该在统一数据拆分和评价口径下超过这个基线。
把完整流程串起来
回看这次实践,第一步不是急着创建模型,而是明确任务的正类含义,并检查标签确实为 0 和 1。正类的定义会直接影响精确率、召回率、FP 和 FN 的解释;如果团队中有人把 1 理解成“未流失”,同一组数字就会得到完全相反的业务含义。
第二步是先固定测试集,再把标准化与逻辑回归放进流水线。标准化器只在训练集上学习参数,新客户和测试数据只能使用已经学到的变换。这样,模型在测试时接触的是训练阶段真正没有看过的数据。
第三步是建立多数类基线。基线虽然简单,却能回答一个关键问题:模型的表现是否真的超过“不看特征也能做到”的水平。若一个复杂模型连多数类基线都没有超过,应该优先检查数据、评价口径和代码,而不是继续增加复杂度。
第四步是同时观察类别预测、正类概率和混淆矩阵。概率保留了模型判断的强弱,阈值负责把这种连续输出变成离散决策;混淆矩阵则把决策错误拆成误报和漏报。三者结合,才能解释指标为什么变化。
第五步是把阈值、参数解释和最终测试成绩分开。阈值属于需要在验证阶段确定的决策规则,系数描述模型中的关联,测试集负责给出一次相对客观的最终检查。把三者混在一起反复调整,会让测试结果失去可信度。
最后,新客户必须使用与训练时相同的列名、列顺序和预处理流程。流水线能保存这条处理链,但真实项目仍应额外检查输入类型、取值范围和缺失值。模型能返回结果不代表输入一定合理,工程检查和模型检查同样重要。
完整实践代码
下面代码与本地文件08_logistic_regression.py一致,使用 Python 3、NumPy、Pandas 和 scikit-learn。本次实际验证环境为 Python 3.11.4、NumPy 1.24.3、Pandas 1.5.3、scikit-learn 1.3.0。
"""用模拟客户数据演示逻辑回归二分类。""" from __future__ import annotations import sys import warnings from typing import Any try: import numpy as np import pandas as pd import sklearn from sklearn.base import ClassifierMixin from sklearn.dummy import DummyClassifier from sklearn.exceptions import ConvergenceWarning from sklearn.linear_model import LogisticRegression from sklearn.metrics import ( accuracy_score, confusion_matrix, f1_score, precision_score, recall_score, ) from sklearn.model_selection import train_test_split from sklearn.pipeline import Pipeline from sklearn.preprocessing import StandardScaler except ImportError as exc: print(f"缺少运行依赖:{exc}") print("请确认当前 Conda 环境已安装 NumPy、Pandas 和 scikit-learn。") raise SystemExit(1) from exc RANDOM_SEED = 42 FEATURE_NAMES = [ "monthly_fee", "contract_months", "support_calls", "usage_hours", "late_payments", ] def check_environment() -> None: """输出本次运行使用的解释器与核心依赖版本。""" print("=== 运行环境 ===")print(f"Python: {sys.version.split()[0]}") print(f"NumPy: {np.__version__}") print(f"Pandas: {pd.__version__}") print(f"Scikit-learn: {sklearn.__version__}") print(f"Interpreter: {sys.executable}") def sigmoid(values: np.ndarray) -> np.ndarray: """数值稳定的 Sigmoid:先限制极端输入,再计算概率。""" safe_values = np.clip(values, -500.0, 500.0) return 1.0 / (1.0 + np.exp(-safe_values)) def generate_dataset(n_samples: int = 800) -> pd.DataFrame: """构造带噪声的模拟客户数据,标签由伯努利抽样得到。""" if n_samples < 600: raise ValueError("为保证示例有足够样本,n_samples 不能少于 600。") rng = np.random.default_rng(RANDOM_SEED) monthly_fee = np.clip(rng.normal(75.0, 18.0, n_samples), 25.0, 130.0) contract_months = rng.integers(1, 49, n_samples) support_calls = np.clip(rng.poisson(2.2, n_samples), 0, 9) usage_hours = np.clip(rng.normal(35.0, 12.0, n_samples), 2.0, 80.0) late_payments = np.clip(rng.poisson(1.1, n_samples), 0, 6) # 分数只用于生成教学数据:正号提高流失倾向,负号降低流失倾向。 # 随机噪声和伯努利抽样使两类有重叠,不会形成可被完美分开的数据。 score = ( -0.25 + 0.035 * (monthly_fee - 75.0) - 0.050 * (contract_months - 24.0) + 0.38 * (support_calls - 2.0) - 0.030 * (usage_hours - 35.0) + 0.55 * (late_payments - 1.0) + rng.normal(0.0, 0.65, n_samples) ) churn_probability = sigmoid(score) churn = rng.binomial(1, churn_probability, n_samples) return pd.DataFrame( { "monthly_fee": monthly_fee.round(2), "contract_months": contract_months, "support_calls": support_calls, "usage_hours": usage_hours.round(2),"late_payments": late_payments, "churn": churn, } ) def validate_dataset(data: pd.DataFrame) -> None: """检查列、缺失值、有限数值和二分类标签。""" expected_columns = FEATURE_NAMES + ["churn"] if data.columns.tolist() != expected_columns: raise ValueError(f"数据列不符合预期:{data.columns.tolist()}") if data.isna().any().any(): raise ValueError("数据中存在缺失值。") if not np.isfinite(data[FEATURE_NAMES].to_numpy(dtype=float)).all(): raise ValueError("特征中存在无穷大或非有限数值。") labels = set(data["churn"].unique().tolist()) if labels != {0, 1}: raise ValueError(f"标签必须同时包含 0 和 1,当前为:{sorted(labels)}") def split_dataset( data: pd.DataFrame, ) -> tuple[pd.DataFrame, pd.DataFrame, pd.Series, pd.Series]: """按 75%/25% 分层拆分一次,保持两部分的类别比例接近。""" X = data[FEATURE_NAMES] y = data["churn"] return train_test_split( X, y, test_size=0.25, random_state=RANDOM_SEED, stratify=y, ) def build_models( X_train: pd.DataFrame, y_train: pd.Series ) -> tuple[DummyClassifier, Pipeline]: """只用训练集拟合多数类基线和标准化后的逻辑回归。""" baseline = DummyClassifier(strategy="most_frequent") baseline.fit(X_train, y_train) model = Pipeline( steps=[("scaler", StandardScaler()), ( "classifier", LogisticRegression(max_iter=1000, random_state=RANDOM_SEED), ), ] ) with warnings.catch_warnings(record=True) as caught_warnings: warnings.simplefilter("always") model.fit(X_train, y_train) convergence_messages = [ str(item.message) for item in caught_warnings if issubclass(item.category, ConvergenceWarning) ] if convergence_messages: raise RuntimeError("模型未正常收敛:" + " | ".join(convergence_messages)) for item in caught_warnings: warnings.warn(item.message, item.category) return baseline, model def _positive_probabilities( model: ClassifierMixin, X: pd.DataFrame ) -> tuple[np.ndarray, np.ndarray]: """根据 classes_ 定位类别 1 的概率列,不假定列顺序。""" classes = np.asarray(model.classes_) positive_columns = np.flatnonzero(classes == 1) if positive_columns.size != 1: raise ValueError(f"无法唯一定位类别 1,classes_={classes.tolist()}") probabilities = model.predict_proba(X)[:, int(positive_columns[0])] return probabilities, classes def evaluate_classifier( name: str, model: ClassifierMixin, X_test: pd.DataFrame, y_test: pd.Series, ) -> tuple[dict[str, Any], np.ndarray, np.ndarray]: """用同一测试集计算四项指标和固定标签顺序的混淆矩阵。""" predictions = model.predict(X_test) probabilities, _ = _positive_probabilities(model, X_test)tn, fp, fn, tp = confusion_matrix( y_test, predictions, labels=[0, 1] ).ravel() metrics = { "model": name, "accuracy": accuracy_score(y_test, predictions), "precision": precision_score(y_test, predictions, zero_division=0), "recall": recall_score(y_test, predictions, zero_division=0), "f1": f1_score(y_test, predictions, zero_division=0), "TN": int(tn), "FP": int(fp), "FN": int(fn), "TP": int(tp), } return metrics, np.asarray(predictions), probabilities def evaluate_threshold( y_true: pd.Series, positive_probabilities: np.ndarray, threshold: float ) -> dict[str, Any]: """在指定阈值下计算正类数量、精确率、召回率和 F1。""" predictions = (positive_probabilities >= threshold).astype(int) return { "threshold": threshold, "positive_predictions": int(predictions.sum()), "precision": precision_score(y_true, predictions, zero_division=0), "recall": recall_score(y_true, predictions, zero_division=0), "f1": f1_score(y_true, predictions, zero_division=0), } def extract_model_details(model: Pipeline) -> tuple[np.ndarray, pd.DataFrame]: """提取标准化特征对应的截距、系数与优势比。""" classifier = model.named_steps["classifier"] intercept = classifier.intercept_.copy() details = pd.DataFrame( { "feature": FEATURE_NAMES, "coefficient": classifier.coef_[0], "odds_ratio": np.exp(classifier.coef_[0]), } ) return intercept, detailsdef predict_new_customer(model: Pipeline) -> tuple[pd.DataFrame, int, float, np.ndarray]: """预测一个仅用于演示的新客户样本。""" customer = pd.DataFrame( [ { "monthly_fee": 88.0, "contract_months": 8, "support_calls": 4, "usage_hours": 22.0, "late_payments": 2, } ] ) predicted_class = int(model.predict(customer)[0]) positive_probability, classes = _positive_probabilities(model, customer) return customer, predicted_class, float(positive_probability[0]), classes def _class_distribution(values: pd.Series) -> str: counts = values.value_counts().sort_index() total = len(values) return ", ".join( f"类别 {int(label)}: {int(count)} ({count / total:.2%})" for label, count in counts.items() ) def main() -> None: check_environment() data = generate_dataset(n_samples=800) validate_dataset(data) X_train, X_test, y_train, y_test = split_dataset(data) print("\n=== 数据检查 ===") print(f"完整数据形状: {data.shape}") print(f"特征矩阵 X 形状: {data[FEATURE_NAMES].shape}") print(f"标签 y 形状: {data['churn'].shape}") print(f"缺失值总数: {int(data.isna().sum().sum())}") print(f"重复行数: {int(data.duplicated().sum())}") print(f"完整数据类别分布: {_class_distribution(data['churn'])}") print(f"训练集样本数: {len(X_train)}") print(f"训练集类别分布: {_class_distribution(y_train)}") print(f"测试集样本数: {len(X_test)}") print(f"测试集类别分布: {_class_distribution(y_test)}")baseline, model = build_models(X_train, y_train) baseline_metrics, _, _ = evaluate_classifier( "多数类基线", baseline, X_test, y_test ) model_metrics, model_predictions, model_probabilities = evaluate_classifier( "逻辑回归", model, X_test, y_test ) metrics_table = pd.DataFrame([baseline_metrics, model_metrics]) print("\n=== 测试集指标(阈值 0.50) ===") print(metrics_table.to_string(index=False, float_format=lambda x: f"{x:.4f}")) threshold_table = pd.DataFrame( [ evaluate_threshold(y_test, model_probabilities, threshold) for threshold in (0.35, 0.50, 0.65) ] ) print("\n=== 不同分类阈值 ===") print(threshold_table.to_string(index=False, float_format=lambda x: f"{x:.4f}")) classifier = model.named_steps["classifier"] intercept, coefficient_table = extract_model_details(model) print("\n=== 模型参数(特征已标准化) ===") print(f"classes_: {classifier.classes_.tolist()}") print(f"intercept: {intercept[0]:.6f}") print(coefficient_table.to_string(index=False, float_format=lambda x: f"{x:.6f}")) preview = X_test.head(8).copy() preview["actual_churn"] = y_test.head(8).to_numpy() preview["predicted_churn"] = model_predictions[:8] preview["probability_class_1"] = model_probabilities[:8] print("\n=== 测试集预测预览 ===") print(preview.to_string(index=False, float_format=lambda x: f"{x:.4f}")) customer, predicted_class, probability, classes = predict_new_customer(model) print("\n=== 新客户预测(仅为教学演示) ===") print(customer.to_string(index=False)) print(f"classes_: {classes.tolist()}") print(f"预测类别: {predicted_class}") print(f"类别 1 的模型估计概率: {probability:.6f}") if __name__ == "__main__":try: main() except (ValueError, RuntimeError) as exc: print(f"程序检查未通过:{exc}", file=sys.stderr) raise SystemExit(1) from exc except Exception as exc: # 保留未知异常的具体类型,便于定位问题。 print(f"程序发生未预期错误:{type(exc).__name__}: {exc}", file=sys.stderr) raise运行命令:
# 在项目根目录下使用当前 Conda 环境的 Python 运行 python 08_logistic_regression.py程序会依次输出环境、数据检查、类别分布、基线与逻辑回归指标、不同阈值结果、系数、优势比、测试集预测预览和新客户预测。固定随机种子后,在相同依赖版本下可以复现本文数字;若库版本或平台不同,末尾小数可能出现轻微差异,应以自己的实际输出为准。
本文总结
逻辑回归先对输入特征做线性组合,再通过 Sigmoid 得到类别 1 的模型估计概率,最后用分类阈值把概率转换成类别。predict给出类别,predict_proba给出各类别概率,读取概率列前应核对classes_。
评价二分类模型不能只看准确率。混淆矩阵说明错误发生在哪里,精确率关注误报,召回率关注漏报,F1 在两者之间做平衡。阈值会改变这些指标,应该依据任务成本并在验证数据上选择,而不是反复利用测试集。
系数和优势比能帮助理解模型学到的方向,但它们受到标准化、正则化、特征相关性与数据质量影响,只表示当前模型中的关联,不能直接解释为因果关系。概率输出同样需要独立校准验证,不能仅凭一个小数就做高风险决策。
下一篇预告
下一篇将学习《K 近邻算法:从距离理解分类》。逻辑回归通过线性得分建立分类边界,K 近邻则从“附近样本通常更相似”出发做判断。我们会继续关注特征尺度、超参数选择和公平的模型评价。