第 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 | 下弯区间占比 |
|---|---|---|
| MSE | 0.12 | 63% |
| 交叉熵 | 0.14 | 0% |
交叉熵全程凸,从任意起点滚进同一个最小值;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=0z=-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 实测输出。