# 勤奋的凯尔森同学10000h刻意练习Day7:机器学习基础 —— 逻辑回归到底"回归"了个啥?为什么神经网络最后一层都长它这样

所属专栏: 凯尔森的10000h刻意练习
Slogan: 一个36岁程序员, 研究生(学硕),前世界500强top20软件开发Java工程师,用10000小时深耕AI, 目标Top10 AI资深工程师, 全程日更记录。
tips: 文章末尾附有jupyter完整代码

线性回归负责预测连续值——房价、温度这种实数。今天把视角从"预测一个数"切换到"判一个类":邮件是不是垃圾、肿瘤是良性还是恶性、用户会不会点击。完成这个切换的主角叫逻辑回归(Logistic Regression)——名字里带"回归",干的却是分类的活。它的核心招式,是把线性输出 套进sigmoid,把任意实数压到 (0,1) 区间当正类概率,再用交叉熵损失配合梯度下降求解。这条"线性 + 非线性激活 + 概率输出"的链路,就是后面神经网络最后一层sigmoid/softmax的原型。另外,分类任务不能只盯着准确率——类别不平衡时它会骗人,精确率、召回率、F1、ROC-AUC才是更可靠的尺子。

# 1.1 逻辑回归原理

# 1.1.1 从线性回归到分类

线性回归输出线性得分 ,取值范围是 的任意实数,但分类任务需要的是0或1。最直接的想法是套一个阶跃函数:

注意阈值是0——因为它直接作用在线性得分 上;等价地,如果先把 变成概率再判,概率那条路的阈值才是0.5(下一节会看到这两种说法是同一件事)。

阶跃函数有个致命问题:它在 处不可导,其余位置导数恒为0,梯度下降根本无法使用。但历史上第一个能"从数据中学习"的线性分类器——感知机(Perceptron)——恰恰基于阶跃函数,它用了一个聪明的绕路:既然梯度下降走不通,就让"被误分类的样本"直接驱动更新。下一节1.1.2先看感知机是怎么做到的,1.1.3再回到"如何用梯度下降"这条主线,引入sigmoid。

# 1.1.2 感知机:用阶跃函数的线性分类器

感知机是1957年Rosenblatt提出的线性分类器,也是神经网络的直接祖先。它的模型形式:

是符号函数( 取 , 取 )。和1.1.4的逻辑回归假设函数 长得很像——都是 的线性得分,差别只在激活函数:感知机用阶跃 sign,输出 ;逻辑回归用sigmoid,输出概率。

损失函数:误分类驱动

感知机的损失函数是所有误分类样本到决策边界的距离总和。对误分类样本(即 ):

是误分类点集合。关键特点:正确分类的样本对损失贡献为零,梯度根本不碰它们——这和逻辑回归(所有样本都参与梯度)截然不同。

学习算法:随机梯度下降

  1. 初始化
  2. 任取一个样本
  3. 若 (误分类):
  4. 全部分类正确则停止,否则回到2

这就是梯度下降! 损失对 的梯度是 ,随机梯度下降每次取一个误分类样本更新,方向恰好就是 。

手推小例子:4个二维样本,;;;。学习率 。

轮次 误分类样本 更新
0 :
0 :
0 :
1 :
2 :
2 全部正确 ✓ 收敛

(实际迭代顺序取决于遍历;sklearn的Perceptron给出 ,也是正确解——感知机解不唯一。)

收敛定理

Novikoff定理:若数据线性可分,感知机在有限步内收敛。设 , 是所有样本到最优超平面的最大间隔,则最多 次更新后收敛。收敛步数和数据"有多可分"( 越大越快)以及"有多大"( 越大越慢)有关。

但注意:线性不可分时感知机不收敛,会无限震荡。这是感知机最大的局限,也是后来SVM(最大间隔保证泛化,见Day9)和神经网络(非线性激活突破线性限制)出现的动力。

sklearn Perceptron实战

from sklearn.linear_model import Perceptron
from sklearn.datasets import make_classification
from sklearn.model_selection import train_test_split
from sklearn.preprocessing import StandardScaler

X, y = make_classification(n_samples=200, n_features=2, n_informative=2,
                           n_redundant=0, n_clusters_per_class=1, random_state=42)
X_train, X_test, y_train, y_test = train_test_split(X, y, test_size=0.3, random_state=42)

scaler = StandardScaler()
X_train_s = scaler.fit_transform(X_train)
X_test_s = scaler.transform(X_test)

clf = Perceptron(max_iter=1000, tol=1e-3, random_state=42)
clf.fit(X_train_s, y_train)
print("权重:", clf.coef_)
print("偏置:", clf.intercept_)
print("准确率:", f"{clf.score(X_test_s, y_test):.4f}")
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
权重: [[-0.4  -0.06]]
偏置: [-0.3]
准确率: 0.8167
1
2
3

感知机 vs 逻辑回归 vs SVM

三种模型同一个目标——找一个线性决策边界,但哲学完全不同:

维度 感知机 SVM 逻辑回归
损失函数 误分类数(0-1损失的代理) 铰链损失 交叉熵
决策边界 任意一条能分开的超平面 间隔最大的那条 概率最优的那条
解的唯一性 不唯一(依赖遍历顺序) 唯一(最大间隔确定) 唯一(凸优化)
概率输出 无 无(需Platt缩放) 有
线性不可分 不收敛 软间隔/核技巧 自动处理
适用场景 历史意义/在线学习 中小数据、高维 通用基线

感知机准确率通常最低——它只要求"分开就行",不追求最优间隔也不估概率;SVM用最大间隔保证泛化(Day9);逻辑回归用交叉熵估概率(1.2节)。三者各有定位,不是"谁替代谁"的关系。

感知机走的是"阶跃函数+误分类驱动"的路线,没法用梯度优化。要回到梯度下降这条主流,得把阶跃函数换成处处可导的sigmoid——下一节正式登场。

# 1.1.3 sigmoid函数

sigmoid的公式很简单,画出来是一条S形曲线:

用NumPy把这条曲线画出来,形状一目了然(注意这里变量名用 sigmoid_vals 而不是 sigmoid,因为1.2.2我们还要定义同名函数,在同一个notebook里数组和函数不能重名):

import numpy as np
import matplotlib.pyplot as plt

z = np.linspace(-10, 10, 200)
sigmoid_vals = 1 / (1 + np.exp(-z))

plt.plot(z, sigmoid_vals)
plt.axhline(0.5, color='r', linestyle='--', alpha=0.5)
plt.axvline(0, color='r', linestyle='--', alpha=0.5)
plt.xlabel('z')
plt.ylabel('sigmoid(z)')
plt.title('Sigmoid 函数')
plt.show()
1
2
3
4
5
6
7
8
9
10
11
12
13

sigmoid的几个关键性质:

  • 当 ,
  • 当 ,
  • 当 ,
  • 导数:,这让梯度下降的实现非常简洁

导数的最大值只有0.25: 是一个开口向下的二次函数,在 (即 )处取到最大值 ,两端迅速趋于0。这正是后面1.2.1要讲的"梯度消失"的量化解释——sigmoid一旦进入饱和区,梯度就被压到极小。

**工程细节:数值稳定。**直接写 1 / (1 + np.exp(-z)),当 时 np.exp(-z) 会向上溢出成 inf。两种稳妥写法:① 用 scipy.special.expit(z),内部已做稳定化处理;② 手动分支,正负数分别算:

from scipy.special import expit
proba = expit(z)          # 推荐,自动处理溢出

# 或手写稳定版
def stable_sigmoid(z):
    out = np.empty_like(z, dtype=float)
    pos = z >= 0
    out[pos] = 1 / (1 + np.exp(-z[pos]))
    exp_z = np.exp(z[~pos])
    out[~pos] = exp_z / (1 + exp_z)
    return out
1
2
3
4
5
6
7
8
9
10
11

# 1.1.4 逻辑回归的假设函数

把sigmoid套在线性得分 上,输出就解释为"样本属于正类的概率",这就是逻辑回归的假设函数:

判定规则:

等价于: 判1,否则判0。所以决策边界是 这条直线(或超平面)。

# 1.1.5 为什么叫"回归":对数几率(logit)

开篇第1问来了:逻辑回归到底"回归"的是什么?为什么它的输出是概率,名字里却有"回归"二字?关键藏在对sigmoid公式做一次反解里。

设正类概率为 ,则负类概率为 。两者之比 称为几率(odds)——"发生的可能性是不发生的多少倍"。对几率取对数,得到对数几率(log-odds,也叫logit):

把 代进去化简,会得到一个惊人的等式:

这一行就是"回归"二字的真正来源。逻辑回归并不是用线性函数直接回归概率,而是用线性函数回归对数几率:

  • 左边 的取值范围是 ——任意实数;
  • 右边 也是任意实数的线性函数。

两边都是实数域上的线性关系,这就是"回归";而概率 只是把这条线性关系用sigmoid反解回 (0,1) 的结果。sigmoid不是随便挑的压缩函数,它正是logit函数的逆函数。

**再抬一层:广义线性模型(GLM)视角。**线性回归和逻辑回归其实是同一个框架的两个实例,差别只在「响应变量服从什么分布」和「用什么连接函数(link function)」:

模型 响应分布 连接函数 建模对象
线性回归 高斯分布(正态) identity: 直接回归均值
逻辑回归 伯努利分布(0/1) logit: 回归对数几率

看懂这张表,逻辑回归和线性回归的关系就彻底通了:它们都是"线性 + 一个连接函数",只是高斯族配恒等连接、伯努利族配logit连接。

# 1.1.6 逻辑回归的前提假设(什么时候不该用它)

知道模型"什么时候会失效"比会调用它更值钱。逻辑回归有三个隐含前提:

  1. 特征与对数几率呈线性关系:1.1.5已经看到 ,它假设log-odds是特征的线性组合。如果真实关系是弯曲的(比如概率随年龄先升后降),需要加入 、交叉项等非线性特征,否则模型欠拟合。
  2. 样本之间相互独立:一条样本的标签不影响另一条。强时序相关的数据(如同一用户连续点击)需要专门处理。
  3. 特征之间避免严重多重共线性:高度相关的特征不会破坏预测,但会让各自的系数极不稳定——系数在相关特征间"来回分摊",方差爆炸、可解释性失真。可以用L2正则化缓解,或先做特征筛选/PCA。

还要记住它的能力边界:决策边界永远是线性的(1.5.1会画给你看),学不到"特征A × 特征B"这种交互,除非你手动把交互项造出来喂给它。

# 1.2 交叉熵损失函数

模型形式有了,接下来要回答:用什么损失来训练它?分类任务若照搬线性回归的MSE,会踩到一个大坑。

# 1.2.1 为什么不用MSE

不用MSE有两个理由,教科书通常把第一条排在最前面:

① MSE + sigmoid的损失函数是非凸的。线性回归用MSE时,损失是关于 的凸函数(碗状),梯度下降一定能走到全局最低点。但sigmoid是高度非线性的,把它接到MSE前面后,损失曲面会出现大量局部极小值——梯度下降连"能不能收敛到全局最优"都没有保证。而sigmoid + 交叉熵的损失是凸的,任何局部最优都是全局最优,优化结果稳定可复现。这是两者被教科书"绑定"在一起的头号理由。

② 饱和区梯度消失。 sigmoid在两端的导数趋于0(1.1.2说过最大值才0.25),用MSE时梯度里会带着这个趋零因子,梯度下降在饱和区"卡住"不动。交叉熵的设计恰好把这个因子约掉了(1.2.3的链式法则会完整演示),即使预测错得离谱、处在饱和区,梯度依然很大、更新依然有力。

交叉熵损失(也叫对数损失Log Loss)定义如下:

直觉理解:

  • 当真实 时,损失是 。预测越接近1,损失越小;预测接近0,损失趋于无穷。
  • 当真实 时,损失是 。预测越接近0,损失越小。

# 1.2.2 用NumPy手推逻辑回归

这里把逻辑回归的"前向算概率 → 算损失 → 求梯度 → 更新参数"四步手算一遍。数据量极小(只有单个样本),目的是把公式和代码逐项对上号。你会看清楚: 从"线性预测值"变成"sigmoid概率"之后,梯度公式为什么还能保持 这么干净的形式。

# 1. 设定数据

只有一个样本,2个特征,真实标签为正类。为了手算最简,本例先省略偏置项(截距): 只有2个分量、正好对应 的2个特征,此时模型是 ,决策边界被强制过原点。完整写法(带截距 )见下面的NumPy代码——给 补一列常数1后, 就变成3维:

量 值
输入特征 (2个特征,无常数列)
参数 (无截距,2维)
真实标签
学习率

# 2. 前向:线性得分 → sigmoid概率

线性得分:

套上sigmoid:

样本数 ,sigmoid在 处输出0.5,正好是"啥也不会、抛硬币"的概率——参数全0时模型对正负类一视同仁。

# 3. 计算损失(交叉熵,单样本)

把 代入交叉熵:

这个0.693不是巧合,它等于 ——两类均匀分布的交叉熵。模型当前对正负类一视同仁,损失恰好就是"猜硬币"的信息熵。

# 4. 求梯度

交叉熵配上sigmoid之后,对 的梯度形式特别干净,直接就是 乘以 :

背后的关键,是sigmoid的导数 恰好抵消了交叉熵里的 因子。完整的链式法则推导放在1.2.3,这里先用结论。

注意:这里梯度的形式和Day6线性回归的梯度公式 完全同形,只是 从"线性预测值 "变成了"sigmoid概率 "。这就是逻辑回归虽然叫"回归"、梯度公式却如此干净的原因——也是它能无缝衔接神经网络最后一层的原因。

# 5. 更新参数

朝负梯度方向走一步:

分量 旧值 梯度 新值
0 -0.5 0.05
0 -1.0 0.10

更新后 涨得比 快——因为 比 大,误差通过 反传时被放大了,和线性回归梯度下降里" 越大的样本对斜率修正越大"是同一回事。再算一轮:, ,从0.5朝着真实标签 靠近了一点点,损失继续下降——这就是逻辑回归梯度下降收敛的全过程。

下面的NumPy代码,就是把上面的手算过程向量化、循环化——一次性处理100个样本,但每一轮里发生的"算 → 套sigmoid → 算梯度 → 更新 "和上面单样本完全一致。

import numpy as np

# 造数据:两类可分
np.random.seed(42)
X = np.random.randn(100, 2)
y = (X[:, 0] + X[:, 1] > 0).astype(int).reshape(-1, 1)

# 加偏置列
X_b = np.c_[np.ones((100, 1)), X]

def sigmoid(z):
    return 1 / (1 + np.exp(-z))

# 梯度下降
lr = 0.1
n_iter = 1000
m = 100
theta = np.zeros((3, 1))
loss_history = []  # 记录每轮交叉熵,稍后画损失曲线

for i in range(n_iter):
    y_pred = sigmoid(X_b.dot(theta))
    gradients = (1 / m) * X_b.T.dot(y_pred - y)
    theta -= lr * gradients
    # 当前轮的平均交叉熵(加1e-15防止log(0))
    loss = -np.mean(y * np.log(y_pred + 1e-15)
                    + (1 - y) * np.log(1 - y_pred + 1e-15))
    loss_history.append(loss)

print("参数 θ:", theta.ravel())

# 预测
y_pred_proba = sigmoid(X_b.dot(theta))
y_pred = (y_pred_proba >= 0.5).astype(int)
accuracy = (y_pred == y).mean()
print(f"准确率: {accuracy:.4f}")
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
参数 θ: [-0.1746  3.7336  3.7424]
准确率: 0.9900
1
2

准确率为什么是0.99而不是1.00?这是一个反直觉、但极有教学价值的结果——我们造数据用的是 x1+x2>0 这条严格分界,数据明明是完美线性可分的,为什么还会错1个?

因为逻辑回归在线性可分数据上的最优解在"无穷远":只要还有样本被分错,把参数 整体放大就能让概率更极端、损失继续下降,最优解要求 。1000轮迭代远没走到无穷远,学到的边界是 ,即 ,而不是真正的分界 。这0.046的偏移,恰好让一个紧贴 的样本翻到了错误一侧。

把记录下来的损失画成曲线,可以直观看到"快速下降 → 缓慢逼近"的收敛过程:

plt.plot(loss_history)
plt.xlabel('迭代轮数'); plt.ylabel('交叉熵损失')
plt.title('逻辑回归损失下降曲线')
plt.grid(alpha=0.3)
plt.show()
1
2
3
4
5

曲线在前100轮陡峭下降,之后变平——但注意它永远不会真正触底,这正好引出下面的现象。

**参数发散实验:无正则 + 线性可分,参数会一直涨。**把迭代轮数从1000提到5000,重跑后观察参数:

迭代轮数 (截距)
1000 -0.17 3.73 3.74
5000 -0.15 6.88 6.67

参数随着轮数一路增大、没有要停下来的意思(损失也在继续缓慢下降)。这就是为什么逻辑回归实际使用时几乎总要带正则化——正则化给参数大小加上"刹车",阻止它在可分数据上冲向无穷远,模型才能稳定、泛化。1.5的sklearn实战会看到 penalty 和 C 这两个控制正则化的开关。

交叉熵对 的梯度推导(结果和线性回归形式一样,只是 的含义不同):

# 1.2.3 为什么结果这么干净?链式法则完整推导

上面这个梯度公式 看起来和线性回归一模一样,这不是巧合,而是sigmoid和交叉熵"联手抵消"的结果。下面用链式法则把全过程拆开,看清楚中间那项 是怎么被消掉的。

记单个样本的损失 ,其中 ,。要算 ,链式法则拆成三段:

第一段:损失对 的偏导

第二段:sigmoid对 的偏导(利用 的性质)

第三段:线性得分对 的偏导

三段相乘,见证奇迹:

中间的 分子分母正好约掉——这就是sigmoid配交叉熵最妙的地方。如果改用MSE损失,第一段会变成 ,但第二段还是 ,约不掉,梯度里会多出一个 因子。当 接近0或1(饱和区)时,这个因子趋于0、梯度消失——这就是1.2.1说的"MSE在分类里会卡住"的数学根因。

对 个样本取平均,就得到完整梯度:

和线性回归的梯度 逐字相同,唯一区别只是 从 变成了 。正因为梯度公式同形,梯度下降的更新规则也完全一样:

这就是为什么1.2.2手推时只算到 就停了——后面那一步链式法则,数学上已经替我们消干净了。这条"线性层 + sigmoid + 交叉熵"的链路,梯度形式干净、不易消失,也是后面神经网络最后一层用sigmoid/softmax + 交叉熵的标准配方。

# 1.3 分类评估指标

准确率在类别不平衡时会骗人。比如1000条样本里只有10条是正例,模型全部预测负类也有99% 准确率——但这模型毫无用处。

# 1.3.1 混淆矩阵

以二分类为例,正类为1,负类为0:

预测正(1) 预测负(0)
实际正(1) TP(真正例) FN(假负例)
实际负(0) FP(假正例) TN(真负例)

用sklearn配合seaborn,可以把这张表画成带数值的热力图。数据直接复用1.2.2手写梯度下降得到的预测结果(y 是真实标签,y_pred 是预测标签,y_test 要到1.5划分数据集后才存在):

import matplotlib.pyplot as plt
import seaborn as sns
from sklearn.metrics import confusion_matrix

cm = confusion_matrix(y, y_pred)
sns.heatmap(cm, annot=True, fmt='d', cmap='Blues',
            xticklabels=['负类', '正类'],
            yticklabels=['负类', '正类'])
plt.xlabel('预测')
plt.ylabel('实际')
plt.show()
1
2
3
4
5
6
7
8
9
10
11

实测输出:cm = [[56 0], [1 43]],即TN=56、FP=0、FN=1、TP=43——100个样本只错1个(把1个实际正类判成了负类),和1.2.2打印的0.9900准确率对得上:(56+43)/100 = 0.99。

# 1.3.2 四个核心指标

四个指标都用混淆矩阵里的TP/FP/TN/FN计数直接定义:

sklearn一行就能算出这四个指标(继续用1.2.2的预测结果 y / y_pred,y_test 要到1.5才出现):

from sklearn.metrics import precision_score, recall_score, f1_score, accuracy_score

print(f"准确率: {accuracy_score(y, y_pred):.4f}")
print(f"精确率: {precision_score(y, y_pred):.4f}")
print(f"召回率: {recall_score(y, y_pred):.4f}")
print(f"F1:     {f1_score(y, y_pred):.4f}")
1
2
3
4
5
6
准确率: 0.9900
精确率: 1.0000
召回率: 0.9773
F1:     0.9885
1
2
3
4

精确率1.0而召回率0.9773,正是混淆矩阵里FP=0、FN=1的直接换算:预测为正的43个全部正确,但44个真正类里漏掉了1个。

场景选择:

场景 优先优化 原因
垃圾邮件过滤 精确率 正常邮件被判为垃圾(FP)代价高
癌症筛查 召回率 漏诊(FN)代价远高于误诊
综合评估 F1 兼顾精确率和召回率

# 1.3.3 ROC曲线与AUC

ROC曲线横轴是假正率(FPR),纵轴是真正率(TPR = 召回率):

通过调整分类阈值(从0到1),画出FPR-TPR的曲线。AUC是曲线下面积,越接近1模型越好,0.5是随机猜。

用sklearn画ROC曲线并计算AUC,只需几行:

from sklearn.metrics import roc_curve, roc_auc_score

# 正类概率沿用1.2.2 NumPy训练得到的y_pred_proba;
# 换成任何已训练好的模型时,这一行就是模板:
# y_proba = model.predict_proba(X_test)[:, 1]
y_proba = y_pred_proba.ravel()  # (100,1) 展平成 (100,),roc_curve要求一维
fpr, tpr, thresholds = roc_curve(y, y_proba)
auc = roc_auc_score(y, y_proba)

plt.plot(fpr, tpr, label=f'AUC = {auc:.4f}')
plt.plot([0, 1], [0, 1], 'k--')  # 随机猜测的对角线
plt.xlabel('假正率 FPR')
plt.ylabel('真正率 TPR')
plt.title('ROC 曲线')
plt.legend()
plt.show()
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16

实测输出:图例显示 AUC = 1.0000。

AUC的直觉:AUC = 随机抽一个正样本和一个负样本,模型给正样本打分高于负样本的概率。

注意这个反直觉的组合:准确率是0.99,AUC却是1.0。AUC衡量的是排序能力——44个正样本的得分确实全部高于56个负样本,排序完美;那个唯一的错误,只是正类样本的概率(实测0.454)恰好被0.5这条默认阈值卡在了门外。这也为1.3.5"调阈值"埋下伏笔:排序没坏,换个阈值就能救回来。

# 1.3.4 极度不平衡时:ROC也会骗人,要看PR曲线

ROC-AUC并非万能。在正例极度稀少时(如欺诈检测,正例占0.1%),它同样会偏乐观:FPR的分母是 ,而 数量巨大,即使误报一堆 ,FPR仍然很小,ROC曲线看起来贴着左上角很漂亮,但模型可能已经淹没业务方在误报里。

这时候应该看PR曲线(Precision-Recall Curve):横轴召回率、纵轴精确率,用**PR-AUC(平均精度Average Precision)**打分。它的分母直接含 和 ,不受海量 影响,对不平衡敏感得多:

from sklearn.metrics import precision_recall_curve, average_precision_score

# y_proba是模型给出的正类概率(1.3.3已从y_pred_proba得到)
prec, rec, pr_thresh = precision_recall_curve(y, y_proba)
ap = average_precision_score(y, y_proba)

plt.plot(rec, prec, label=f'PR-AUC = {ap:.4f}')
plt.xlabel('召回率 Recall'); plt.ylabel('精确率 Precision')
plt.title('PR 曲线(不平衡数据首选)')
plt.legend(); plt.grid(alpha=0.3); plt.show()
1
2
3
4
5
6
7
8
9
10

实测输出:PR-AUC = 1.0000。注意1.2.2生成的数据两类数量接近(44正 / 56负),所以ROC和PR都接近满分;两者的差距,要在这节开头说的那种极度不平衡场景里才会拉开。

经验法则:正负类比例接近时用ROC-AUC;正例稀少、误报代价高时用PR-AUC(欺诈、罕见病诊断、工业缺陷检测)。

从模型侧应对不平衡:class_weight='balanced'。除了换指标,还可以在训练时让模型自动给少数类更高的损失权重(权重反比于类别频率),这比事后采样更省事:

from sklearn.linear_model import LogisticRegression

clf = LogisticRegression(class_weight='balanced', max_iter=1000)
1
2
3

# 1.3.5 阈值怎么定:别死守0.5

model.predict() 永远用0.5做阈值,但0.5几乎从不是业务最优点。既然1.3.3已经能拿到每个阈值对应的TPR/FPR/Precision/Recall,就可以按目标选阈值。三种常用选法:

方法 目标 选取方式
Youden's J统计量 整体区分度最大 最大的阈值
最大F1 精确率召回率平衡 最大的阈值
KS统计量 正负样本分离度最大 (风控常用)

用Youden's J选一个阈值的代码:

import numpy as np
from sklearn.metrics import roc_curve

fpr, tpr, thresholds = roc_curve(y, y_proba)
j_scores = tpr - fpr  # 每个阈值对应的Youden's J
best_idx = np.argmax(j_scores)
best_thresh = thresholds[best_idx]
print(f"最优阈值: {best_thresh:.3f}, 此时 TPR={tpr[best_idx]:.3f}, FPR={fpr[best_idx]:.3f}")

# 用自定义阈值(而不是predict() 的0.5)出最终标签
y_pred_custom = (y_proba >= best_thresh).astype(int)
1
2
3
4
5
6
7
8
9
10
11
最优阈值: 0.454, 此时 TPR=1.000, FPR=0.000
1

阈值从0.5降到0.454,那个被卡在门外的正类(概率0.454)被找了回来:TPR从0.9773升到1.000,FPR仍是0,准确率从0.99变成1.0——"别死守0.5"在这100个样本的模拟数据上就能兑现。

更严谨的做法是结合业务代价矩阵:漏诊一个癌症赔多少、误诊一次活检成本多少,把代价写进目标函数直接求代价最小的阈值。1.3.2那张"垃圾邮件看精确率、癌症筛查看召回率"的表,落地操作就是这一节。

# 1.4 多分类逻辑回归

逻辑回归原生是二分类,扩展到多分类有三种常见做法:OvR、OvO,以及直接推广成softmax回归。

# 1.4.1 一对多(One-vs-Rest,OvR)

对 个类别训练 个二分类器,每个分类器判断"是不是第 类",预测时取概率(或得分)最大的那个类。 时只训练3个分类器,开销小,但当类别很多时会出现"正负样本严重不平衡"。

在sklearn中用 OneVsRestClassifier 显式包一层:

from sklearn.datasets import load_iris
from sklearn.linear_model import LogisticRegression
from sklearn.multiclass import OneVsRestClassifier

iris = load_iris()
X, y = iris.data, iris.target

ovr = OneVsRestClassifier(LogisticRegression(max_iter=1000))
ovr.fit(X, y)
print("OvR 内部分类器个数:", len(ovr.estimators_))   # 3
print("OvR 准确率:", ovr.score(X, y))
1
2
3
4
5
6
7
8
9
10
11

# 1.4.2 一对一(One-vs-One,OvO)

对每两个类别训练一个二分类器, 个类别共训练 个,预测时让所有分类器投票、得票最多的类胜出。 时要训练3个, 时就要训练45个。

  • 优点:每个分类器只用两个类别的数据,在小样本、类别边界复杂时往往更准;
  • 缺点:分类器数量随类别数平方增长,大类数下训练和预测都慢。
from sklearn.multiclass import OneVsOneClassifier

ovo = OneVsOneClassifier(LogisticRegression(max_iter=1000))
ovo.fit(X, y)
print("OvO 内部分类器个数:", len(ovo.estimators_))   # 3 = 3×2/2
1
2
3
4
5

# 1.4.3 Softmax回归

前两种是"拆成多个二分类",softmax则是直接为所有类别一起建模:每个类别 有一组参数 ,归一化指数函数给出属于每一类的概率:

分母对所有类别求和,保证 个概率加起来正好等于1,适合类别互斥的多分类。

softmax和sigmoid的关系:当 时,softmax会退化为sigmoid(两套参数只差一个可互相抵消的冗余项)。所以记住一句话——sigmoid是softmax在二分类时的特例,softmax是sigmoid向多分类的推广。

数值稳定:减去最大值。直接算 ,当得分很大时指数会溢出。利用softmax的平移不变性(分子分母同乘一个常数不改变结果),工程上先减去最大得分:令 ,用 计算,最大值那一项变成 ,既不溢出结果又完全相同。

sklearn的 LogisticRegression 在多分类时默认就走softmax(旧版本的 multi_class='multinomial' 参数自1.5起弃用、计划移除,现在直接省略即可;需要OvR时用1.4.1的包装器)。拿鸢尾花三分类试一下:

from sklearn.linear_model import LogisticRegression
from sklearn.datasets import load_iris

# 鸢尾花数据集:3分类
iris = load_iris()
X = iris.data
y = iris.target

# n_classes >= 3时默认即softmax,无需再写multi_class
clf = LogisticRegression(solver='lbfgs', max_iter=1000)
clf.fit(X, y)
print("系数形状:", clf.coef_.shape)  # (3, 4):3个类,4个特征
print("准确率:", clf.score(X, y))  # 约0.973
1
2
3
4
5
6
7
8
9
10
11
12
13

# 1.5 sklearn逻辑回归完整实战

把今天学的内容串成一条完整流程——加载数据、划分训练测试集、特征缩放、训练、评估——用sklearn自带的乳腺癌(二分类)数据集走一遍:

import numpy as np
from sklearn.datasets import load_breast_cancer
from sklearn.model_selection import train_test_split
from sklearn.preprocessing import StandardScaler
from sklearn.linear_model import LogisticRegression
from sklearn.metrics import classification_report, roc_auc_score

# 1. 加载数据(乳腺癌数据集,二分类)
data = load_breast_cancer()
X = data.data
y = data.target
print("特征数:", X.shape[1])
print("类别分布(恶性=0, 良性=1):", np.bincount(y))  # np.bincount(y):统计每个类别的样本数量。

# 2. 划分数据集
X_train, X_test, y_train, y_test = train_test_split(
    X, y, test_size=0.2, random_state=42, stratify=y  # stratify=y:分层抽样。按照原始数据的类别比例来划分,保证训练集和测试集中恶性/良性的占比和全集完全一致。
)

# 3. 特征缩放(理由见下方说明)
scaler = StandardScaler()
X_train_s = scaler.fit_transform(X_train)
X_test_s = scaler.transform(X_test)

# 4. 训练(默认就带L2正则化,C=1.0)
clf = LogisticRegression(C=1.0, max_iter=1000, random_state=42)
clf.fit(X_train_s, y_train)

# 5. 评估
y_pred = clf.predict(X_test_s)  # 输出预测的类别标签,默认以0.5为阈值,概率≥0.5判为良性(1),否则判为恶性(0)。
y_proba = clf.predict_proba(X_test_s)[:, 1]  # 输出每个样本属于「类别0,类别1」的概率,形状为 (样本数, 2)。[:, 1]:取出所有样本属于正类(良性=1)的概率,用来计算AUC。

print(classification_report(y_test, y_pred, target_names=['恶性', '良性']))
print(f"AUC: {roc_auc_score(y_test, y_proba):.4f}")
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
特征数: 30
类别分布(恶性=0, 良性=1): [212 357]
              precision    recall  f1-score   support
          恶性       0.98      0.95      0.96        42
          良性       0.97      0.99      0.98        72
    accuracy                           0.97       114
   macro avg       0.97      0.97      0.97       114
weighted avg       0.97      0.97      0.97       114
AUC: 0.9954
1
2
3
4
5
6
7
8
9

测试集准确率0.9825(114个样本错2个),AUC高达0.9954——逻辑回归在这个数据集上已经表现得非常强,因为肿瘤特征与良恶性之间大致就是单调的线性关系。

这里为什么要做特征缩放?(注意:不是因为"梯度下降")

一个常见的误解是"逻辑回归基于梯度下降,不缩放就不收敛"。其实sklearn默认的 lbfgs 求解器是拟牛顿法,对尺度不那么敏感,不缩放通常也能收敛。真正应该缩放的理由是另外两条:

  1. L2正则对所有系数一视同仁地惩罚。如果一个特征量纲是另一个的1000倍,同样的预测贡献下,小量纲特征需要更大的系数,正则项就会不公平地重点压制它,把模型带偏。缩放到同一尺度,惩罚才公平。
  2. 缩放后系数大小可以直接比较(可解释性),优化也更稳定。

反过来说,如果你用 penalty=None 完全关闭正则、又用 lbfgs 求解,缩放并非数学上的必需;但只要带着正则(默认行为),就应该缩放。

正则化的两个开关:penalty 和 C

  • penalty 选择正则类型,默认 'l2'(对应Ridge风格,把系数整体压小);
  • 设为 'l1' 则是Lasso风格,会把不重要特征的系数直接压成0,顺带做特征选择,但要搭配支持它的求解器(solver='liblinear' 或 'saga');
  • C 是正则化强度的倒数:C 越小,正则化越强、系数被压得越狠、越不容易过拟合;C 越大,正则化越弱、模型越复杂。还记得1.2.2那个参数一路涨、停不下来的实验吗?调小 C 就是给它踩刹车。
# L1正则:做特征选择,注意换solver
clf_l1 = LogisticRegression(penalty='l1', solver='liblinear', C=0.5, max_iter=1000)
clf_l1.fit(X_train_s, y_train)
print("被压成 0 的系数个数:", (clf_l1.coef_ == 0).sum(), "/", X.shape[1])
1
2
3
4

# 1.5.1 可视化决策边界:线性边界长什么样

1.1.3说过逻辑回归的决策边界是 这条直线(超平面),但文字描述不如画出来直观。我们取乳腺癌数据集前两个特征(平均半径、平均纹理),在二维平面上画出决策边界,看逻辑回归是怎么把两类点分开的:

import numpy as np
import matplotlib.pyplot as plt
from sklearn.datasets import load_breast_cancer
from sklearn.linear_model import LogisticRegression
from sklearn.preprocessing import StandardScaler
from sklearn.model_selection import train_test_split

# 取前两个特征,方便二维可视化
data = load_breast_cancer()
# 为什么只取前2个特征:只有二维特征才能在平面上画图,这里选了临床意义最直观的两个指标:平均半径和平均纹理。代价是舍弃了其余28个特征,分类准确率会比全特征版本低
X2 = data.data[:, :2]          # mean radius, mean texture
y2 = data.target

# 关键:用与1.5完全相同的切分,两边的准确率才是同一批测试样本上的同口径对比
X2_tr, X2_te, y2_tr, y2_te = train_test_split(
    X2, y2, test_size=0.2, random_state=42, stratify=y2
)

# scaler只在训练集fit(和1.5同一条铁律),再transform全量点用于画图
scaler2 = StandardScaler()
X2_tr_s = scaler2.fit_transform(X2_tr)
X2_te_s = scaler2.transform(X2_te)
X2_all_s = scaler2.transform(X2)  # 全量点仅用于散点展示

clf2 = LogisticRegression(C=1.0, max_iter=1000)
clf2.fit(X2_tr_s, y2_tr)
print(f"仅 2 个特征的测试集准确率: {clf2.score(X2_te_s, y2_te):.4f}")   # 0.8772

# 生成网格,对每个网格点预测概率
# 先拿到两个特征的最小值、最大值,各向外扩1个单位,让图像四周留边,样本点不会贴边。
x_min, x_max = X2_all_s[:, 0].min() - 1, X2_all_s[:, 0].max() + 1
y_min, y_max = X2_all_s[:, 1].min() - 1, X2_all_s[:, 1].max() + 1
# np.linspace(x_min, x_max, 300):在x轴范围内均匀取300个点;y轴同理。
# np.meshgrid:把x轴和y轴的点两两配对,生成一个300×300的二维网格,相当于把整个二维平面切成了9万个细密的小格子,每个格子都有自己的(x,y)坐标。xx和yy都是300×300的矩阵,分别存储每个网格点的x坐标和y坐标
xx, yy = np.meshgrid(np.linspace(x_min, x_max, 300),
                     np.linspace(y_min, y_max, 300))
# xx.ravel()、yy.ravel():把二维网格拉平成长度为9万的一维数组
# np.c_[xx.ravel(), yy.ravel()]:拼接成(90000,2)的矩阵,每一行是一个网格点的坐标,和模型输入维度匹配
# predict_proba(...)[:, 1]:输出每个网格点属于“良性(类别1)”的概率
# .reshape(xx.shape):把拉平的概率结果还原回300×300的二维矩阵Z,每个位置的值对应该坐标点的预测概率
Z = clf2.predict_proba(np.c_[xx.ravel(), yy.ravel()])[:, 1].reshape(xx.shape)

# 画等高线:决策边界(0.5)是实线,概率等高线(0.25/0.75)是虚线
plt.figure(figsize=(8, 6))
plt.contourf(xx, yy, Z, alpha=0.3, cmap='RdBu')  # 概率底色
plt.contour(xx, yy, Z, levels=[0.5], colors='k', linestyles='-')  # 决策边界
plt.contour(xx, yy, Z, levels=[0.25, 0.75], colors='gray',
           linestyles='--', linewidths=0.8)  # 概率等高线
plt.scatter(X2_all_s[y2==0, 0], X2_all_s[y2==0, 1], c='red', s=20,
            edgecolor='k', label='恶性')
plt.scatter(X2_all_s[y2==1, 0], X2_all_s[y2==1, 1], c='blue', s=20,
            edgecolor='k', label='良性')
plt.xlabel('平均半径(标准化)'); plt.ylabel('平均纹理(标准化)')
plt.title('逻辑回归二维决策边界(直线)')
plt.legend(); plt.show()
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55

读图:

  • 黑色实线就是 即 的决策边界——一条笔直的直线,这正是逻辑回归"只能学线性边界"的直观体现。边界一侧判良性、另一侧判恶性。
  • 灰色虚线是 和 的等概率线,它们和决策边界平行——离边界越远,模型对判断越自信(底色越深)。平行正是因为 只依赖线性得分 ,等概率面就是等 面,自然是平行直线。
  • 两个类别并不能被一条直线完美分开(有错分点),说明仅用前两个特征时类别是线性不可分的。注意这里要用同一批测试样本做对比:仅2个特征时测试集准确率是87.72%,而1.5用全部30个特征时是98.25%——同样是线性模型,高维空间里"直线"变成了超平面,有更多维度可以利用,这10.5个百分点的差距就是"信息量"的价值(切忌拿全量训练集的0.891去和测试集的0.9825比,那是不同口径)。

**这条直线就是逻辑回归的全部表达力。**想象特征空间被一条直线一刀切成两半——如果数据本身需要曲线边界(比如同心圆数据),逻辑回归无论怎么调都学不好,这时候就得换Day8的决策树,它能学出任意形状的边界。

# 1.6 最大熵:为什么用交叉熵

1.1.5看到逻辑回归用sigmoid把线性得分压成概率,1.2又用了交叉熵做损失。一个自然的问题:这两个选择是拍脑袋来的,还是有什么更深的理由?这一节从信息论的视角回答——最大熵原理。结论先放这儿:在满足约束的所有分布中选熵最大的那个,它的解恰好就是softmax(二分类就是sigmoid),对应的损失恰好就是交叉熵。所以逻辑回归不是"碰巧好用",而是最大熵原理的数学必然。

# 1.6.1 什么是熵

信息熵衡量随机变量的"不确定性":

均匀分布的熵最大(最不确定),确定性分布的熵为零(完全确定)。直觉:知道得越少,熵越大;约束越少,分布越"平"。

# 1.6.2 最大熵原理

最大熵原理说:在满足已知约束的所有概率分布中,应该选择熵最大的那个。

类比:你要猜一个骰子的点数分布。有人告诉你"这个骰子的均值是3.5"——正六面骰子的均值恰好是3.5。但满足"均值=3.5"的分布有无数个,比如把所有概率压在3.5附近也满足约束。你会选哪个?最大熵原理说:选均匀分布,因为它是"在满足约束的前提下,做最少额外假设"的分布。

这个原理的哲学:不要往分布里塞你没有的信息。已知约束是"事实",除此之外的均匀是"诚实"——承认无知,不编造信念。

# 1.6.3 约束:特征函数的期望

在分类问题中,约束通常表示为特征函数的期望。特征函数 是一个从 到 的指示函数,比如"是晴天且是出门"。约束要求:模型分布下特征函数的期望等于经验(数据)期望:

即 。直白说:模型在数据上看到的特征统计,必须和数据本身一致——这是"约束";在此之外,模型对未观测到的事情保持最大的不确定性——这是"最大熵"。

# 1.6.4 最大熵模型的解

在上述约束下最大化 ,用拉格朗日乘子法求解,得到的分布是一个指数族(softmax)形式:

其中 是归一化因子, 是拉格朗日乘子(最终成为模型参数)。

这个形式就是softmax! 最大熵模型的解天然是指数族分布——这不是巧合,而是变分优化给出的必然结果。

# 1.6.5 最大熵=逻辑回归

二分类场景下,定义一个特征函数 ,最大熵模型的解变成:

这就是1.1.4节的逻辑回归!所以sigmoid不是拍脑袋选的——它是最大化熵的必然结果。

多分类(标签 ),最大熵模型的解:

这就是1.4节讲过的softmax回归。sklearn的LogisticRegression默认就是多项逻辑回归,即最大熵模型:

from sklearn.linear_model import LogisticRegression
from sklearn.datasets import load_iris
from sklearn.model_selection import train_test_split
from sklearn.preprocessing import StandardScaler
import numpy as np

iris = load_iris()
X, y = iris.data, iris.target
X_train, X_test, y_train, y_test = train_test_split(X, y, test_size=0.3, random_state=42)

scaler = StandardScaler()
X_train_s = scaler.fit_transform(X_train)
X_test_s = scaler.transform(X_test)

clf = LogisticRegression(solver='lbfgs', max_iter=1000, random_state=42)
clf.fit(X_train_s, y_train)
print("准确率:", f"{clf.score(X_test_s, y_test):.4f}")
print("权重形状:", clf.coef_.shape)
print("前3个样本的softmax概率:")
for i, p in enumerate(clf.predict_proba(X_test_s[:3])):
    print(f"  样本{i}: {np.round(p, 4)} -> 预测 {iris.target_names[np.argmax(p)]}")
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
准确率: 1.0000
权重形状: (3, 4)
前3个样本的softmax概率:
  样本0: [0.0126 0.8618 0.1255] -> 预测 versicolor
  样本1: [0.9682 0.0318 0.    ] -> 预测 setosa
  样本2: [0.     0.0037 0.9963] -> 预测 virginica
1
2
3
4
5
6

权重形状 :3个类别各有一组4维参数,每组参数对应一个特征函数。三个样本的概率向量都接近"一头扎进某一类"——setosa/virginica几乎确定(0.97/0.99),versicolor稍弱(0.86)但依然压倒另两类。这就是softmax把线性得分拉开成概率分布的样子。

# 1.6.6 三视角看同一模型

视角 叙述 关心什么
逻辑回归 用sigmoid/softmax做概率输出,交叉熵做损失 概率建模
最大熵 满足约束,选熵最大的分布 信息论原则
指数族 统计分布族

三者描述的是同一个模型,只是出发点不同。这个等价性揭示了一个深刻事实:交叉熵损失不是"碰巧好用",而是最大熵原理的数学必然——你在用交叉熵训练逻辑回归时,实质上就是在求解一个最大熵优化问题。

# 小结

概念 核心公式 关键直觉
感知机 误分类驱动更新,解不唯一
感知机学习 随机梯度下降的特例
Novikoff定理 线性可分必收敛
逻辑回归 对数几率的线性回归
交叉熵 凸优化+抗梯度消失
熵 不确定性的度量
最大熵原理 约束下选熵最大的分布 不做没有根据的假设
最大熵=逻辑回归 sigmoid是最大熵的解

今天把三件事拧成一条线:感知机是线性分类器的起点,用阶跃函数+误分类驱动,解不唯一但收敛有保证;逻辑回归把阶跃换成sigmoid、把误分类驱动换成交叉熵,既得到概率输出又能用梯度下降稳定优化;最大熵原理从信息论角度解释了为什么是sigmoid和交叉熵——满足约束选熵最大的分布,解就是softmax,损失就是交叉熵。三个视角最终指向同一个模型,这就是Day7的"统一"含义。

逻辑回归的短板也很清楚:决策边界永远是线性的 ,学不到特征交互。1969年Minsky证明单层感知机连XOR都分不开,叠加隐藏层得MLP()才突破线性限制——感知机是起点,逻辑回归给概率视角,最大熵给信息论视角,三条线在深度学习汇合。但在跳进深度学习之前,先看看非线性模型能做什么:下一天(Day8)进入第一个非线性模型——决策树与集成学习,它能自动学到特征之间的交互、不需要特征缩放,正好补上这块短板。

最近更新: 2026/9/30
 |  Copyright © -Present Kelsen All Rights Reserved. | Author KelsenJing