☰
AI 学习第 5 课:手推逻辑回归与交叉熵,L2 把可分数据的权重按住
2026/11/5 5:44:54 网站建设 项目流程

第 4 课用 L2 把岭回归的验证误差从 23.96 拉回 0.25,那是回归任务,损失是平方误差。这一课把标签换成 0/1,输出变成概率,损失换成交叉熵(log loss)。换完会碰见岭回归里没有的事:数据完全线性可分时,不加 L2 权重会一直往无穷长,loss 趋近 0 却永远取不到,极大似然解不存在。

L2 在分类里的第一作用不是防过拟合,而是让解存在且有界。全程只用 numpy,把 sigmoid、交叉熵、梯度、λ 扫描手推并真跑一遍。求解器一直在变(scikit-learn 1.9 给 LogisticRegression 加了 callbacks、原生 float32 与 LBFGS 的 GPU 支持),但目标函数和梯度形式没变。

环境准备

只需要 numpy,不需要装 sklearn。本机实测环境 Python 3.13.14 / Windows 10 / numpy 2.5.1。

python -c "import numpy, sys, platform; print(sys.version.split()[0], platform.platform(), numpy.__version__)" pip install "numpy>=2.5"

NumPy 2.5.0 于 2026-06-21 发布,停止支持 Python 3.11,复现时别混用 1.x。

先造一份带噪声的二分类数据

真实边界取 w* = [-0.3, 1.6, -0.9],叠加标准差 0.25 的噪声后二值化。训练集 200 条、验证集 600 条,种子不同,验证集不参与拟合。

import numpy as np def sigmoid(z): # exp(-logaddexp(0,-z)) 等价于 1/(1+exp(-z)),但不会溢出 return np.exp(-np.logaddexp(0.0, -z)) def bce_from_logit(z, y): # softplus(z) - y*z,直接对 logit 算交叉熵,全程不取 log(0) return np.logaddexp(0.0, z) - y * z def make_data(n, seed, noise=0.25): r = np.random.default_rng(seed) X = r.normal(size=(n, 2)) z = X @ np.array([1.6, -0.9]) - 0.3 + r.normal(scale=noise, size=n) return X, (z > 0).astype(float) X_tr, y_tr = make_data(200, 11) X_va, y_va = make_data(600, 12) print("train", X_tr.shape, "正类占比", round(float(y_tr.mean()), 3)) print("val ", X_va.shape, "正类占比", round(float(y_va.mean()), 3))
train (200, 2) 正类占比 0.475 val (600, 2) 正类占比 0.428

为什么分类不能用平方误差

sigmoid 套上后仍能用 MSE 训练,但它「错得越离谱、学得越慢」。取 z = -8、y = 1,此时 p = 0.00033535,属于严重错判。两种损失对 logit 的梯度:

import numpy as np def sigmoid(z): return np.exp(-np.logaddexp(0.0, -z)) z = np.array([-8.0]) p = sigmoid(z) g_bce = p - 1.0 # 交叉熵对 logit 的梯度 g_mse = 2 * (p - 1.0) * p * (1 - p) # MSE 对 logit 的梯度,多出 p(1-p) 因子 print("p =", round(float(p[0]), 8)) print("交叉熵对 z 的梯度:", round(float(g_bce[0]), 8)) print("MSE 对 z 的梯度 :", round(float(g_mse[0]), 8)) print("相差倍数:", round(float(g_bce[0] / g_mse[0]), 1))
p = 0.00033535 交叉熵对 z 的梯度: -0.99966465 MSE 对 z 的梯度 : -0.00067025 相差倍数: 1491.5

差 1491.5 倍。MSE 梯度多了个 p(1-p),p 贴近 0 或 1 时把梯度掐死;交叉熵梯度是 p - y,接近 ±1,不随置信度衰减。再看曲面凸性:t 从 -4 扫到 4,统计二阶差分为负的区间占比。

损失最低点 t下弯区间占比
MSE0.1263%
交叉熵0.140%

交叉熵全程凸,从任意起点滚进同一个最小值;MSE 有 63% 区间下弯,两侧还有平坦平台。第 3 课的平方损失搬不到分类上,原因就在这。

踩坑一:naive sigmoid 会把 loss 算成 inf 和 nan

第 1 课实测过np.exp([1000, 999, 998])的溢出。这里更隐蔽:z 无论正负,朴素写法1/(1+np.exp(-z))都会出事。

import warnings import numpy as np def sigmoid_naive(z): return 1.0 / (1.0 + np.exp(-z)) def sigmoid(z): return np.exp(-np.logaddexp(0.0, -z)) def bce_from_logit(z, y): return np.logaddexp(0.0, z) - y * z y1 = np.ones(1) for zval in (-800.0, 800.0): z = np.array([zval]) with warnings.catch_warnings(record=True) as caught: warnings.simplefilter("always") p = sigmoid_naive(z) naive = -np.mean(y1 * np.log(p) + (1 - y1) * np.log(1 - p)) msgs = [f"{type(r.message).__name__}: {r.message}" for r in caught] print(f"z={zval:>7.0f} p_naive={p[0]:.6g} loss_naive={naive}") for m in msgs: print(" ", m) print(f" p_stable={sigmoid(z)[0]:.6g} loss_stable={float(bce_from_logit(z, y1)[0]):.6g}")
z= -800 p_naive=0 loss_naive=inf RuntimeWarning: overflow encountered in exp RuntimeWarning: divide by zero encountered in log p_stable=0 loss_stable=800 z= 800 p_naive=1 loss_naive=nan RuntimeWarning: divide by zero encountered in log RuntimeWarning: invalid value encountered in multiply p_stable=1 loss_stable=0

z=-800 时exp(800)溢出,p 变成 0,log(0)再报除零,loss 是 inf;z=800 时 p 变成 1,log(1-p)得 -inf,乘 0 变成0 * -inf,结果 nan。用np.logaddexp重写后同样输入给出 800 和 0,都是有限值:错得离谱罚 800,对得离谱不罚。这也是 PyTorch 的BCEWithLogitsLoss直接吃 logit 而不吃概率的原因。

手推梯度:只比线性回归多一个 sigmoid

目标函数取「平均交叉熵 + ½λ‖w₁₋‖²」,截距 w₀ 不进惩罚项(第 4 课坑二讲过):

J(w) = mean[ softplus(z) − y·z ] + ½·λ·‖w₁₋‖²,其中 z = Xw、p = sigmoid(z)

对 w 求导得 ∇J = Xᵀ(p − y)/n + λw₁₋,和第 3 课的 Xᵀ(Xw − y)/n 几乎一样,只是把线性预测换成 sigmoid 输出。没有闭式解,只能迭代:

import numpy as np def sigmoid(z): return np.exp(-np.logaddexp(0.0, -z)) def add_bias(X): return np.column_stack([np.ones(len(X)), X]) def loss_grad(w, A, y, lam): z = A @ w p = sigmoid(z) J = float(np.mean(np.logaddexp(0.0, z) - y * z) + 0.5 * lam * (w[1:] @ w[1:])) g = A.T @ (p - y) / len(y) g[1:] += lam * w[1:] # 截距不惩罚 return J, g def train(A, y, lam, steps=6000, lr=0.5): w = np.zeros(A.shape[1]) for _ in range(steps): _, g = loss_grad(w, A, y, lam) w -= lr * g return w

给X_tr / X_va补上偏置列后扫一遍 λ:

A_tr, A_va = add_bias(X_tr), add_bias(X_va) print(f"{'lambda':<9}{'train_loss':>12}{'val_loss':>11}{'val_acc':>10}{'|w|':>9}") for lam in [0.0, 1e-4, 1e-3, 1e-2, 1e-1, 1.0]: w = train(A_tr, y_tr, lam) z = A_va @ w J, _ = loss_grad(w, A_va, y_va, lam) acc = float(np.mean((z >= 0) == (y_va == 1))) nw = float(np.linalg.norm(w[1:])) Jtr, _ = loss_grad(w, A_tr, y_tr, lam) print(f"{lam:<9}{Jtr:>12.4f}{J:>11.4f}{acc:>10.3f}{nw:>9.3f}")
lambda train_loss val_loss val_acc |w| 0.0 0.1168 0.1104 0.955 10.943 0.0001 0.1224 0.1178 0.955 10.133 0.001 0.1539 0.1552 0.955 7.206 0.01 0.2588 0.2620 0.948 3.676 0.1 0.4571 0.4543 0.945 1.388 1.0 0.6352 0.6305 0.898 0.305

这份数据上 val_loss 单调上升,λ=0 反而最好,没有第 4 课的 U 型谷底。不是算错:200 样本对 2 特征本来就不易过拟合,交叉熵又是凸的,加惩罚只会把权重往 0 拽、抬高 loss。第 4 课的 U 型来自 25 样本拟合 21 列多项式,「样本少、特征多」的前提在这里不成立。λ 该不该加看数据是否病态,不是习惯性给默认值。

真正需要 L2 的场景:完全可分数据

换一份严格线性可分的数据:两簇高斯各 50 个点,中心 (-2,-2) 和 (2,2),用 w = [0, 1, 1] 直接判就有 100% 准确率。λ 设为 0,跑 20 万步:

import numpy as np def sigmoid(z): return np.exp(-np.logaddexp(0.0, -z)) def make_separable(n, seed): r = np.random.default_rng(seed) X = np.vstack([r.normal([-2, -2], 0.5, (n // 2, 2)), r.normal([2, 2], 0.5, (n // 2, 2))]) A = np.column_stack([np.ones(n), X]) y = np.r_[np.zeros(n // 2), np.ones(n // 2)] return A, y A, y = make_separable(100, 5) def loss_grad(w, A, y, lam): z = A @ w p = sigmoid(z) J = float(np.mean(np.logaddexp(0.0, z) - y * z) + 0.5 * lam * (w[1:] @ w[1:])) g = A.T @ (p - y) / len(y) g[1:] += lam * w[1:] return J, g for lam in (0.0, 0.01): w = np.zeros(A.shape[1]) print(f"--- lambda = {lam} ---") for step in range(1, 200001): _, g = loss_grad(w, A, y, lam) w -= 0.5 * g if step in (1, 1000, 10000, 50000, 100000, 200000): J, _ = loss_grad(w, A, y, lam) print(f"step={step:<7} loss={J:.8f} |w|={np.linalg.norm(w):.4f}")
--- lambda = 0.0 --- step=1 loss=0.11454533 |w|=0.7353 step=1000 loss=0.00036165 |w|=3.0544 step=10000 loss=0.00004150 |w|=4.0377 step=50000 loss=0.00000898 |w|=4.7660 step=100000 loss=0.00000462 |w|=5.0889 step=200000 loss=0.00000237 |w|=5.4169 --- lambda = 0.01 --- step=1 loss=0.11724894 |w|=0.7353 step=1000 loss=0.02342776 |w|=1.8037 step=10000 loss=0.02342775 |w|=1.8038 step=50000 loss=0.02342775 |w|=1.8038 step=100000 loss=0.02342775 |w|=1.8038 step=200000 loss=0.02342775 |w|=1.8038

λ=0 那组 loss 从 0.1145 掉到 0.00000237,20 万步后仍在降;|w| 从 0.7353 涨到 5.4169,也没停。权重整体放大会让每个样本被推得更「确信」,loss 无限接近 0 却永远到不了 0——最优解在无穷远处,数值上就是解不存在。λ=0.01 那组 1000 步就收敛,loss 稳在 0.02342775,|w| 稳在 1.8038,10 万步后一位小数没变。

岭回归没这问题:完全拟合时训练误差能取到 0,解是有限值。所以 L2 在分类里的意义比第 4 课更基础一层:把「没有解」变成「有唯一解」。这也解释了 scikit-learn 的 LogisticRegression 为何默认带正则(C=1.0),要关掉得显式写C=np.inf——C 是正则强度倒数,越小惩罚越重。版本变化也记一下:penalty自 1.8 弃用、1.10 移除,改用l1_ratio和 C;1.9 的 callbacks(ProgressBar、ScoringMonitor)最先落在 LBFGS 求解器上,示例监控的正是d2_log_loss_score。求解器在换,手推的目标函数没换。

踩坑记录

坑二:把 0/1 硬标签喂给 log loss。评估时若传predict出来的类别而不是predict_proba的概率,一个错判就是-log(0):

import warnings import numpy as np with warnings.catch_warnings(record=True) as caught: warnings.simplefilter("always") loss = -np.mean(np.ones(1) * np.log(0.0)) for r in caught: print(f"{type(r.message).__name__}: {r.message}") print("loss =", loss)
RuntimeWarning: divide by zero encountered in log loss = inf

坑三:特征量纲和截距。第 4 课实测过,固定 λ 把某列缩小 100 倍,验证误差从 0.4387 涨到 0.6702。逻辑回归同理:‖w‖² 优先砍需要大系数的列,等于凭空改掉 λ 的含义。罚之前先标准化,并把 w₀ 排除在惩罚项外。

小结

这一课换了三样:标签变 0/1、输出过 sigmoid、损失换成平均交叉熵;梯度仍是 Xᵀ(p − y)/n + λw₁₋,和第 3 课几乎一样。新知识是「完全可分 + 无正则 ⇒ 解不存在」,λ 在这里是存在性条件;样本充足、特征少时加 λ 反而抬高 loss。

建议把make_separable两簇中心距离从 4 改成 1.5 重跑,看 λ=0 的发散速度怎么变。第 6 课进入分类评估指标:准确率在类别不平衡时怎么骗人,混淆矩阵、精确率召回率与 log loss 该看哪个。

文内所有数字均为本机 Python 3.13.14 + numpy 2.5.1 实测输出。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询