逻辑回归三步:线性打分、sigmoid 压成概率、交叉熵量误差。 梯度是「预测减真值,乘特征,取平均」——三千轮把损失从 ln2 的 0.693 降到 0.062。 学习率没有通用答案,它跟特征量级绑在一起;特征放大十倍,同一个学习率就卡死。
这一篇对应教材 16.6 到 16.6.6:以 Python 程式库实作逻辑迴归,含逻辑迴归、S 型函數的數學式、 交叉熵誤差的數學式、梯度下降法的數學式,以及 NumPy 版与 scikit-learn 版两种写法。 上一篇讲了记号,这一篇把它们串成一个完整的训练过程——这是全书唯一一次把模型的内部拆开看。
一、三步:打分、压概率、量误差
| 步 | 算什么 | 输出 |
|---|---|---|
| 1 线性打分 | z = X @ w + b |
任意实数 |
| 2 压成概率 | p = sigmoid(z) |
0 到 1 之间 |
| 3 量误差 | -mean(y*ln p + (1-y)*ln(1-p)) |
一个非负数 |
三步做完,梯度一求,就往回挪一步。挪几千次,参数就定了。 整个训练过程就这么点内容,剩下的都是工程细节。 数据是自造的两类点:300 个,每类 150 个,中心分别在 (2, 2) 和 (5, 5),各自带标准差 1 的散布。 选这份数据是因为它完全可分——一条直线就能把两类分开。 后面会看到,完全可分会引出一个反直觉的结论。
二、sigmoid:把任意实数压到 0 到 1
| z | sigmoid(z) |
|---|---|
| -6.0 | 0.002473 |
| -3.0 | 0.047426 |
| 0.0 | 0.500000 |
| +3.0 | 0.952574 |
| +6.0 | 0.997527 |
0 对应 0.5,两头趋近 0 和 1 但永远到不了。 这个函数的用处只有一个:把「算出来的分数」翻译成「概率」。 为什么不用「大于 0.5 就算 1」这种阶跃函数:它到处导数为 0,梯度下降没法走。 sigmoid 光滑、处处可导,才让下一步成立。 这也是整个机器学习里反复出现的套路:要优化,就得先让函数可导。很多看起来奇怪的设计(softmax、 GELU、hinge 的变体),根子都在这里。
三、交叉熵:一开始错得有多离谱
| 参数 | 损失 |
|---|---|
w = (0, 0)、b = 0 |
0.6931 |
ln 2 |
0.6931 |
w 和 b 全是 0,模型对每条数据都输出 0.5,所以交叉熵正好是 ln2。 跟标签怎么分布无关——150 比 150 也好,110 比 90 也好,都是这个数。 这个数是个标尺。 训练之后损失降到多少,跟它比才有意义:光看「0.06」你不知道好坏,看「从 0.69 降到 0.06」就清楚了。 丢掉这个标尺,就会犯一个常见错误:看到损失 0.3 觉得「还行」, 其实比瞎猜还差——0.3 意味着模型给的判断跟真值反着走。
四、梯度下降:三千轮
| 轮次 | 损失 | w1 | w2 | b |
|---|---|---|---|---|
| 0 | 0.6931 | 0.000 | 0.000 | 0.000 |
| 500 | 0.0956 | 1.176 | 1.253 | -8.305 |
| 1000 | 0.0768 | 1.461 | 1.543 | -10.403 |
| 1500 | 0.0695 | 1.643 | 1.724 | -11.718 |
| 2000 | 0.0656 | 1.778 | 1.856 | -12.685 |
| 2500 | 0.0632 | 1.886 | 1.960 | -13.448 |
| 3000 | 0.0615 | 1.975 | 2.045 | -14.077 |
三千轮之后损失 0.0615,比开头的 0.6931 低了 91%。 梯度那个式子是推出来的,但它的形状很好记:(预测值减真值) 乘上特征,再取平均。猜高了就往低推, 猜低了就往高推,推多少由特征大小决定。 有个细节值得看:第 0 轮 b 方向的梯度正好是 0。因为两类各 150 个、预测又全是 0.5,正负刚好抵消。 换个不平衡的数据集,这一步就不会是 0。
还有一处:最后四行每 500 轮只降 0.001 到 0.004。 这就是「还没收敛」的样子——继续降,但每步的收益越来越小。 损失曲线的尾巴是不是平的,决定你还要不要接着跑。
五、和 sklearn 对一遍
| 做法 | w | b |
|---|---|---|
| 手写 3000 轮(无正则) | [1.975 2.046] |
-14.078 |
| sklearn 默认(带 L2) | [2.067 2.135] |
-14.806 |
| sklearn 关掉正则 | [2.945 2.949] |
-20.757 |
手写和 sklearn 默认版差得不多(权重差 0.092 与 0.089),方向完全一致。
这一步就够证明手写的那条链路是对的。
但关掉正则之后,sklearn 的权重一下子涨到 [2.95 2.95],比手写的还大。
这不是 bug,是数学结论:
两类点完全分得开的时候,「分得越开越好」没有上限,权重可以一直变大。
正则项的作用就是给它一个上限。
所以三组数对不上,不是谁算错,是目标函数和迭代次数都不一样。
要比就得比同一个目标、同一个停止条件。
这条经验在调包时特别有用: 用别人写的模型之前,先知道它在优化什么。不然模型给出的结果和你期待的差很远, 你连往哪查都不知道。
六、准确率,以及它骗人的地方
| 版本 | 准确率 |
|---|---|
| 手写 3000 轮 | 0.9767 |
| sklearn 默认 | 0.9767 |
两边完全一样。这一格数字比权重那一列更有说服力:参数可以有无数组合,分类结果只有一个。 但也要看清这个数是怎么来的:两类点分得这么开,随便画条线都到九十几。 它不能说明模型好,只能说明数据简单。 真实的分类问题里,0.85 就已经很不错了。 看准确率之前先问一句:这份数据本来有多难?没有基准的准确率是没有信息量的。
七、学习率与特征尺度
| 学习率 | 1 步后 | 50 步后 | 300 步后 |
|---|---|---|---|
| 0.001 | 0.6921 | 0.6534 | 0.6117 |
| 0.5 | 0.8614 | 0.2682 | 0.1164 |
| 5.0 | 7.2967 | 0.0562 | 0.0559 |
0.001 那个跑三百轮只降了一点,还得再跑几千轮; 5.0 那个第一步虽然跳到 7.30,但 50 步内就降回 0.0562。在这批数据上,大步长没出问题。 然后做一个实验:把特征乘 10,学习率还是 5.0。
| 情形 | 1 步后 | 2 步后 | 300 步后 |
|---|---|---|---|
| 原尺度 | 7.2967 | — | 0.0559 |
| 特征乘 10 | 13.8155 | 13.8155 | 13.8155 |
放大之后第一步就跳到 13.82,然后一直停在那个数上不动了。 停住不是收敛,是卡死了。 一步跳过头,模型把每条数据都判成 0 或 1,sigmoid 进了饱和区, 梯度变成 0——再跑三万步也是这个数。 原因很直接:梯度里带着特征,特征大十倍,梯度就大十倍,等效步长也大十倍。 所以调学习率之前先看特征的量级,或者干脆把特征标准化到同一量级。
「学习率设多少」没有通用答案,它跟数据尺度绑在一起。任何一本教材给你一个数, 都是在那本书的数据上试出来的——包括这一篇的 0.5。
八、这一篇之后
逻辑回归的核心就三样:sigmoid、交叉熵、梯度下降。手写下来不到二十行。 第 4 篇到这里把「模型是什么」讲完了。 第 17 章要把它做成 API: 你会看到,模型那部分代码只占整个项目的一小块,剩下的全是接口、校验和错误处理。 这也回答了很多人第一次做机器学习项目时的困惑:「为什么我照着教程跑通了模型, 却做不出一个能用的东西」。因为能跑通的模型和能用的服务之间,隔着第 12 到 15 章那一整套东西。
九、动手 Demo:逻辑回归:NumPy 手写一遍,再和 sklearn 对一遍
这个 Demo 从造点开始,一路手写 sigmoid、交叉熵、梯度下降,最后拿 sklearn 对三组数。 第七节最后那个实验最值得看:把特征乘十,同一个学习率的损失就再也不动了。
for step in range(3001):
p = sigmoid(Xs @ w + b)
gw = Xs.T @ (p - ys) / m
gb = float(np.mean(p - ys))
if step % 500 == 0:
print(step, round(loss_of(w, b), 4))
w = w - LR * gw
b = b - LR * gb
实跑输出:
--------------------------------------------------------------------
逻辑回归:NumPy 手写一遍,再和 sklearn 对一遍
--------------------------------------------------------------------
① 造两类点
--------------------------------------------------------------------
两类各 150 个点,X 的形状 (300, 2)
第一类中心 (2.0, 2.0)/第二类中心 (5.0, 5.0)
② sigmoid:把任意实数压到 0 到 1
--------------------------------------------------------------------
sigmoid(-6.0) -> 0.002473
sigmoid(-3.0) -> 0.047426
sigmoid(+0.0) -> 0.500000
sigmoid(+3.0) -> 0.952574
sigmoid(+6.0) -> 0.997527
! 0 对应 0.5,两头趋近 0 和 1 但永远到不了。
这个函数的用处只有一个:把「算出来的分数」翻译成「概率」。
! 为什么不用「大于 0.5 就算 1」这种阶跃函数:它到处导数为 0,梯度下降没法走。
sigmoid 光滑、处处可导,才让下面那一步成立。
③ 交叉熵:一开始错得有多离谱
--------------------------------------------------------------------
w = (0, 0)、b = 0 时的损失 -> 0.6931
ln2 -> 0.6931
! w 和 b 全是 0,模型对每条数据都输出 0.5,所以交叉熵正好是 ln2。
跟标签怎么分布无关——150 比 150 也好,110 比 90 也好,都是这个数。
! 这个数是个标尺:训练之后损失降到多少,跟它比才有意义。
光看「0.08」你不知道好坏,看「从 0.69 降到 0.08」就清楚了。
④ 梯度下降:三千轮,每五百轮看一眼
--------------------------------------------------------------------
第 0 轮梯度 -> w 方向 [-0.753 -0.7293],b 方向 0.000000
| 轮次 | 损失 | w1 | w2 | b |
|---:|---:|---:|---:|---:|
| 0 | 0.6931 | 0.000 | 0.000 | 0.000 |
| 500 | 0.0956 | 1.176 | 1.253 | -8.305 |
| 1000 | 0.0768 | 1.461 | 1.543 | -10.403 |
| 1500 | 0.0695 | 1.643 | 1.724 | -11.718 |
| 2000 | 0.0656 | 1.778 | 1.856 | -12.685 |
| 2500 | 0.0632 | 1.886 | 1.960 | -13.448 |
| 3000 | 0.0615 | 1.975 | 2.045 | -14.077 |
三千轮之后损失 0.0615,比开头那个 0.6931 低了 91%
! 梯度那个式子是推出来的,但它的形状很好记:(预测值减真值) 乘上特征,再取平均。
猜高了就往低推,猜低了就往高推,推多少由特征大小决定。
! 第 0 轮 b 方向的梯度正好是 0,因为两类各 150 个、预测又全是 0.5,正负刚好抵消。
换个不平衡的数据集,这一步就不会是 0。
⑤ 和 sklearn 对一遍
--------------------------------------------------------------------
手写 3000 轮(无正则) -> w [1.975 2.046],b -14.078
sklearn 默认(带 L2) -> w [2.067 2.135],b -14.806
sklearn 关掉正则 -> w [2.945 2.949],b -20.757
! 手写和 sklearn 默认版差得不多(权重差 0.092 与 0.089),方向完全一致。
! 上面那张表最后四行,每 500 轮只降 0.001 到 0.004——这就是「还没收敛」的样子。
手写版跑三千轮,sklearn 的优化器走的步数不一样,所以对不齐。
! 再看关掉正则那一行:权重是 [2.95 2.95],比手写的还大。
! 这不是 bug,是数学结论:两类点完全分得开的时候,「分得越开越好」这件事没有上限,
权重可以一直变大。正则项的作用就是给它一个上限。
! 所以三组数对不上不是谁算错,是目标函数和迭代次数都不一样。
要比就得比同一个目标、同一个停止条件——这也是调包之前必须先知道它在优化什么的原因。
⑥ 准确率
--------------------------------------------------------------------
手写版准确率 -> 0.9767
sklearn 默认版 -> 0.9767
! 两类点分得这么开,随便画条线都到九十几。这个数不能说明模型好,只能说明数据简单。
真实的分类问题里,0.85 就已经很不错了。
⑦ 学习率与特征尺度
--------------------------------------------------------------------
学习率 0.001 -> 1 步后 0.6921,50 步后 0.6534,300 步后 0.6117
学习率 0.500 -> 1 步后 0.8614,50 步后 0.2682,300 步后 0.1164
学习率 5.000 -> 1 步后 7.2967,50 步后 0.0562,300 步后 0.0559
! 0.001 那个跑三百轮只降一点,还得再跑几千轮;5.0 那个反而更快。在这批数据上,大步长没出问题。
把特征乘 10,还是 5.0 -> 1 步后 13.8155,2 步后 13.8155,300 步后 13.8155
原尺度同一组参数第 300 轮的损失是 0.0559
! 把特征乘 10、还是 5.0:第一步就跳到 13.82,之后一直停在那儿。
原尺度同一个学习率也跳(第一步 7.30),但它在 50 步内就降回 0.0562。
! 停住不是收敛,是卡死了。
一步跳过头,模型把每条数据都判成 0 或 1,sigmoid 进了饱和区,梯度变成 0——再跑三万步也是这个数。
! 原因很直接:梯度里带着特征,特征大十倍,梯度就大十倍,等效步长也大十倍。
所以调学习率之前先看特征的量级,或者干脆把特征标准化到同一量级——这是下一章真正会做的事。
! 「学习率设多少」没有通用答案,它跟数据尺度绑在一起。
任何一本教材给你一个数,都是在那本书的数据上试出来的。
⑧ 这一篇之后
--------------------------------------------------------------------
逻辑回归的核心就三样:sigmoid、交叉熵、梯度下降
手写三千轮的损失 0.0615,sklearn 默认版 0.0600
! 第 4 篇到这里把「模型是什么」讲完了。
第 17 章要把它做成 API:你会看到,模型那部分代码只占整个项目的一小块,剩下的全是接口、
校验和错误处理。
第四节那个表最右一列一直在变,说明还没收敛;损失曲线的尾巴平不平,就看这里。 第五节三组数对不上,差别来自正则和迭代次数,不是谁算错。 第七节把特征乘十,同一个学习率的结果是卡在 13.8155 不动,不是继续降。
30 秒实验
- 把第七节的
LR改成 0.05,看三千轮之后损失还能到多少。 - 把两类点的标准差从 1.0 改成 2.5(两类开始重叠),看准确率掉到多少。
- 想一想:为什么两类完全可分的时候,权重会一直变大?
常见误区
- 忘了先 sigmoid 就套交叉熵:
ln里出现负数,结果全是 nan。 - 没有基准就判断损失好坏:0.3 比瞎猜还差,因为瞎猜是 0.69。
- 学习率照抄教程:它跟数据尺度绑着,换个量级就卡死或发散。
- 拿训练集准确率当结论:数据简单的时候,随便画条线都九十几。
- 以为权重必须和 sklearn 一致:目标函数不同,数值本来就不同。
自测三问(点开对答案)
1. 逻辑回归的三步是什么? —— 线性打分、sigmoid 压成概率、交叉熵量误差。 2. 梯度长什么样?为什么好记? —— 预测值减真值,乘特征,取平均;猜高了往低推,猜低了往高推。 3. 为什么特征放大十倍就会卡死? —— 梯度跟着放大,等效步长也放大,一步跳进 sigmoid 饱和区,梯度归零。第 16 章到此结束。下一篇把这些装成一个真的服务:定接口、写校验、接模型。
- ☐ (本篇完,下一篇 17.1 选定模型与实作程序)