DL · 章节 2
深度学习 2 — 分类
在前一章中,我们已经学习了如何用一个线性神经元解决回归问题:输入向量 经过一个仿射组合 ,直接将 作为对连续目标 的预测。然而,在大量真实场景中,我们关心的并不是某个连续值,而是一个类别归属问题:这封邮件是否为垃圾邮件?这张乳腺组织切片是良性还是恶性?这只企鹅属于哪个物种?
本章将沿着同样的思路,把线性神经元改造成一个分类器,然后逐步过渡到 PyTorch 的实现,并把它推广到多层网络与多类问题。在这条路径上,我们会接触到逻辑函数、二元交叉熵、softmax、BCEWithLogitsLoss 与 CrossEntropyLoss 等概念。这些工具看似分散,实际上都源自同一个原理:最大似然。
二元分类与逻辑神经元
在二元分类中,目标变量取两个值:
直觉上我们想"输出一个类别",但更精细的做法是估计条件概率
得到这个概率之后,最终的硬决策(0 还是 1)只是一个阈值化的事后步骤。这种"先估计概率,再做决定"的视角,是统计学习的核心立场。
伯努利分布与似然
二值随机变量自然由伯努利分布描述。给定参数 :
我们可以把两个公式合写成一个紧凑表达式:
这里 是模型需要学习的未知参数。在监督学习场景下,数据 已经被观测且固定下来,而 取决于模型参数,因此上式被视为似然:
对于由独立观测组成的数据集:
其中 是模型为第 个样本所赋予的概率。"学习"这件事,在最大似然框架下,就是选择 让观察到的数据尽可能地"合理"。
用神经元产生概率
像回归一样,我们从仿射组合开始:
但 不是概率,它是一个无界的实数。我们需要把它压缩到 区间。最经典的选择是 sigmoid (logistic) 函数:
于是:
这就是逻辑神经元:它本质上是一个概率估计器,而不是一个直接的分类器。
关键关系: 逻辑神经元 = 仿射变换 + sigmoid。 称为 logit(实数,无界), 是概率。
从似然到二元交叉熵
把似然 写成对数形式有两个动机:其一,概率的连乘很快会下溢到 0;其二,对数把乘积变成求和,便于对参数求导。最大化对数似然:
机器学习习惯最小化损失,因此取相反数,得到我们熟知的二元交叉熵 (Binary Cross-Entropy, BCE):
这个损失在不同社区有不同的名字:对数损失 (log-loss)、二元交叉熵、伯努利负对数似然。它们指的是同一个东西。
与感知器的对比
如果我们直接对 取符号,得到的是经典的感知器 (Perceptron):
感知器的决策是硬的、不可微的、不输出概率。Sigmoid 把这个阶跃函数平滑化为可微的概率版本,从而与梯度下降兼容。这一点是把"决策器"升级为"学习器"的关键。
梯度推导
逻辑神经元由三个串联的算子构成:
其中:
利用链式法则:
逐项计算这三个偏导:
第一项(损失对输出):
第二项(sigmoid 的著名导数):
第三项(线性部分对权重):
把三者相乘,中间项 与第一项的分母奇迹般地化简:
因此:
向量化并对一个 batch 取均值:
其中 。
与线性回归的形式相似性
把上式与第 1 章线性回归的梯度对比一下:
| 模型 | 输出 | 梯度 |
|---|---|---|
| 线性神经元 | ||
| 逻辑神经元 |
公式完全一样。唯一的差别是 的定义。这意味着,只要我们已经写好了线性神经元的训练循环 (fit、predict、batch / mini-batch / SGD 三种模式),把它改造成逻辑神经元只需要改两处:
forward方法中, 改成- 损失函数从 MSE 改成二元交叉熵
教学要点: 这一项在两种模型中扮演了完全相同的"误差信号"角色。这并非巧合,而是指数族 + 链接函数 (link function) 共同作用的数学结果。
NumPy 实现:从头开始的 LogisticNeuron
下面给出一个仅依赖 NumPy 的实现,作为概念清晰度的参照。它结构上与我们在第 1 章实现的 LinearNeuron 完全平行。
class LogisticNeuron: def __init__(self): self.w = None self.b = 0.0 self.history = [] def forward(self, X): z = X @ self.w + self.b return 1.0 / (1.0 + np.exp(-z)) def predict(self, X, threshold=0.5): u = self.forward(X) return (u >= threshold).astype(int) def fit(self, X, y, lr=0.1, epochs=100, mode="batch", batch_size=32): n, m = X.shape if self.w is None: self.w = np.random.uniform(size=m) self.history = [] for _ in range(epochs): # 选择 batch / sgd / minibatch 索引 ... u = self.forward(Xb) eps = 1e-12 loss = -np.mean(yb * np.log(u + eps) + (1 - yb) * np.log(1 - u + eps)) self.history.append(loss) grad_w = (Xb.T @ (u - yb)) / len(idx) grad_b = (u - yb).mean() self.w -= lr * grad_w self.b -= lr * grad_b return self
数值陷阱:
np.log(u)当 时趋于 。我们在 上加了一个小常数 以避免数值溢出。这是一种"打补丁"的写法;PyTorch 的BCEWithLogitsLoss用更优雅的 log-sum-exp 技巧 从根本上解决该问题,我们稍后会看到。
在 cancer_mini 数据集(乳腺癌良性 / 恶性二分类)上训练这个模型,典型流程是:train_test_split → StandardScaler.fit_transform / transform → model.fit(...) → model.predict(X_test) → accuracy_score。学习曲线 (history) 应单调下降并趋于平台。
PyTorch 版本的逻辑神经元
PyTorch 的优势在于:我们不再需要手写梯度,自动求导引擎会沿着计算图反向传播。逻辑神经元在 PyTorch 中可以用两种方式实现。
方案 1:Linear + Sigmoid + BCELoss
import torch import torch.nn as nn model = nn.Sequential( nn.Linear(m, 1), nn.Sigmoid() ) criterion = nn.BCELoss() optimizer = torch.optim.SGD(model.parameters(), lr=0.1)
这里模型直接输出概率 ,损失再对其做交叉熵。这种写法与教科书公式最直接对应,适合教学讲解。但在数值上不是最优的。
方案 2(推荐):Linear + BCEWithLogitsLoss
model = nn.Linear(m, 1) criterion = nn.BCEWithLogitsLoss() optimizer = torch.optim.SGD(model.parameters(), lr=0.1)
注意此时模型不带 sigmoid,直接输出 logit 。损失函数内部把 sigmoid 与交叉熵融合在一起,使用 log-sum-exp 技巧避免了 与 的灾难。
核心陷阱:
BCELossvsBCEWithLogitsLoss
BCELoss的输入必须是概率 ,需要在模型最后加Sigmoid。BCEWithLogitsLoss的输入必须是logit ,模型最后不能加Sigmoid,否则你会做两次 sigmoid,模型几乎无法训练。工业代码几乎一律选
BCEWithLogitsLoss,数值更稳。
数据准备与训练循环
PyTorch 的张量需要是 float32,目标 需要 reshape 成 (n, 1) 与模型输出对齐:
X_train = torch.tensor(X_train, dtype=torch.float32) y_train = torch.tensor(y_train, dtype=torch.float32).view(-1, 1)
训练循环结构与回归完全一致:
for _ in range(epochs): optimizer.zero_grad() logits = model(X_train) loss = criterion(logits, y_train) loss.backward() optimizer.step() loss_history.append(loss.item())
注意 optimizer.zero_grad() 必须在每个 epoch 开始时调用,否则梯度会累加到上一轮,导致更新错误。
概率与类别预测
在评估阶段,我们需要手动施加 sigmoid 把 logit 转回概率:
model.eval() with torch.no_grad(): logits = model(X_test) proba = torch.sigmoid(logits) y_hat = (proba >= 0.5).float()
model.eval() 关闭训练模式相关的行为(如 Dropout、BatchNorm 的滑动统计),torch.no_grad() 关闭自动求导节省显存。
阈值的选择并非理所当然 0.5。在医疗场景(如 cancer_mini)中,为了减少漏诊 (false negatives),可以把阈值降到 0.3,牺牲一些精确率换取更高召回率。这一调整不需要重训模型,只是后处理。
多层感知器:从单神经元到深度网络
单个逻辑神经元只能学到线性可分的决策边界。当类别边界扭曲时,我们需要堆叠多层非线性变换:
最后一层不带激活,它输出的是 logit。整个模型是一个仿射叠加 + 非线性激活的复合函数。
用 nn.Sequential 快速搭建
model = nn.Sequential( nn.Linear(m, 64), nn.ReLU(), nn.Linear(64, 32), nn.ReLU(), nn.Linear(32, 1) # 输出 logit ) criterion = nn.BCEWithLogitsLoss() optimizer = torch.optim.SGD(model.parameters(), lr=0.1)
Sequential 的优点是简洁,缺点是只能表达线性数据流(没有跳跃连接、没有多个分支)。对于复杂结构,我们改用 nn.Module 子类化:
class MyMLP(nn.Module): def __init__(self, m, C=1): super().__init__() self.fc1 = nn.Linear(m, 64) self.fc2 = nn.Linear(64, 32) self.out = nn.Linear(32, C) def forward(self, x): h = torch.relu(self.fc1(x)) h = torch.relu(self.fc2(h)) return self.out(h)
二者等价,但 nn.Module 提供了完整的灵活性,是 PyTorch 中编写复杂网络的标准方式。
深度网络对训练的影响
多层模型并不会改变训练循环的代码,但会改变其行为:
- 损失曲线可能更陡也可能更不稳;
- 学习率需要重新调节(过大会发散,过小会停滞);
- 收敛通常需要更多 epoch,或换用 Adam 等自适应优化器;
- 在小数据集上(如
cancer_mini,569 行)很容易过拟合,需注意验证集监控。
多类分类
把二元问题推广到 类,。我们想估计概率分布:
Softmax 与多类交叉熵
模型最后一层从 1 个神经元扩展到 个:
nn.Linear(h, C)
输出向量 ,称为 logits。把 logits 转化为概率分布,我们使用 softmax:
softmax 自动满足 。多类交叉熵就是单个观测真实类别的负对数概率:
在 PyTorch 中,这一切被 nn.CrossEntropyLoss 一手包办:
criterion = nn.CrossEntropyLoss()
核心陷阱:
CrossEntropyLoss期望的是 logits,不是 softmax 输出
nn.CrossEntropyLoss内部已经做了LogSoftmax + NLLLoss。如果你在模型最后一层加了Softmax或LogSoftmax,那就等于做了两次,梯度会出问题。正确做法: 模型最后一层是裸的
nn.Linear(h, C),直接喂给CrossEntropyLoss。
目标张量的形状与类型
二元分类我们用 float32 形状 (n, 1),但多类是不同的:
y_train_t = torch.tensor(y_train, dtype=torch.long) # (n,) 而非 (n, 1)
CrossEntropyLoss 期望:
- 输入 logits 形状
(n, C),float32; - 目标形状
(n,),torch.long,值在 中。
我们不需要 one-hot 编码 。损失函数会根据整数索引 自动选取对应类别的概率。
完整示例:penguins_mini
penguins_mini 数据集包含三种南极企鹅(Adelie, Gentoo, Chinstrap)的形态测量。建模流程:
df = pd.read_csv(".../penguins.csv") # 预处理:填补缺失,二元化 sex,one-hot 编码 island,数值化 species df["species"] = df["species"].map({"Adelie": 0, "Gentoo": 1, "Chinstrap": 2}) X = df.drop(columns="species").to_numpy() y = df["species"].to_numpy() C = 3 X_train, X_test, y_train, y_test = train_test_split( X, y, test_size=0.2, random_state=42, stratify=y ) scaler = StandardScaler() X_train = scaler.fit_transform(X_train).astype(np.float32) X_test = scaler.transform(X_test).astype(np.float32) X_train_t = torch.tensor(X_train, dtype=torch.float32) y_train_t = torch.tensor(y_train, dtype=torch.long) model = nn.Sequential( nn.Linear(X_train_t.shape[1], 64), nn.ReLU(), nn.Linear(64, 32), nn.ReLU(), nn.Linear(32, C) ) criterion = nn.CrossEntropyLoss() optimizer = torch.optim.Adam(model.parameters(), lr=1e-3) for _ in range(epochs): for Xb, yb in train_loader: optimizer.zero_grad() logits = model(Xb) loss = criterion(logits, yb) loss.backward() optimizer.step() # 预测:argmax 取最大 logit 对应类别 with torch.no_grad(): logits_test = model(X_test_t) y_hat = torch.argmax(logits_test, dim=1)
预测公式 在数学上等价于 ,因为 softmax 是单调的——我们不需要真的算 softmax 来做硬决策。
二元 vs 多类 — 速查表
| 维度 | 二元 (binary) | 多类 (multi-class) |
|---|---|---|
| 输出层 | nn.Linear(h, 1) | nn.Linear(h, C) |
| 损失 | BCEWithLogitsLoss | CrossEntropyLoss |
| 形状 | (n, 1), float32 | (n,), long |
| logit→概率 | torch.sigmoid | torch.softmax(dim=1) |
| 预测 | (proba >= 0.5).float() | argmax(logits, dim=1) |
| 类别概率分布 | 伯努利 | 类别分布 (categorical) |
评估:超越 accuracy
二元分类问题中,仅看 accuracy 会有误导性。考虑癌症检测:若 95% 样本为良性,一个永远预测"良性"的模型 accuracy 高达 95%,但漏诊全部恶性病例。我们需要更细致的指标:
- 混淆矩阵:展示 TP / FP / FN / TN 的分布;
- 精确率 (precision) = TP / (TP + FP),"被预测为正的样本中真正为正的比例";
- 召回率 (recall) = TP / (TP + FN),"真正为正的样本中被识别出的比例";
- F1 分数:精确率与召回率的调和平均;
- ROC-AUC:跨阈值的整体辨别能力。
在 PyTorch 中计算这些指标的常规做法是:把 tensor 转成 NumPy,用 sklearn.metrics:
y_hat_np = y_hat.cpu().numpy().reshape(-1) y_true = y_test.cpu().numpy().reshape(-1) print(accuracy_score(y_true, y_hat_np)) print(confusion_matrix(y_true, y_hat_np)) print(precision_score(y_true, y_hat_np)) print(recall_score(y_true, y_hat_np))
阈值是一个业务决策:在不同阈值 下计算指标,根据应用场景挑选(医疗追求高 recall,垃圾邮件追求高 precision)。
小结
本章把第 1 章的回归神经元改造为分类器:
- 在仿射组合后接一个 sigmoid,把无界 logit 压缩为 概率;
- 把 MSE 损失换成二元交叉熵——这在最大似然意义下与伯努利分布相符;
- 神奇的是,梯度公式 与线性情况完全一样,这是指数族的优雅产物;
- PyTorch 实现中,
BCEWithLogitsLoss(二元)和CrossEntropyLoss(多类)是数值稳定的工业标准——但要记得它们的输入是 logits 而非概率; - 多层感知器只是把这些层按
Sequential或nn.Module堆叠起来,训练循环结构不变; - 多类问题用 softmax 推广, 用整数索引而非 one-hot。
理解这些原理后,从二元到多类,从单神经元到深度网络,只是一些 API 配置上的差异。底层数学是统一的。
练习
-
手算梯度 — 不查公式,从 出发,用链式法则推导 。验证最终化简到 。
-
NumPy 逻辑神经元 — 把第 1 章的
LinearNeuron改造为LogisticNeuron,只修改两处:forward中加 sigmoid,fit中把 MSE 换为 BCE(注意 防溢出)。在cancer_mini上训练并对比测试 accuracy。 -
PyTorch 单神经元 — 用
nn.Linear(m, 1) + BCEWithLogitsLoss + SGD在cancer_mini上训练,记录学习曲线。计算阈值 下的 accuracy / precision / recall / 混淆矩阵,讨论医疗场景下应选哪个阈值。 -
故意踩坑 — 在 PyTorch 模型最后加上
nn.Sigmoid(),但仍然用BCEWithLogitsLoss。观察训练曲线,解释为什么模型几乎学不到东西。 -
多层网络 — 在
cancer_mini上用一个 2 隐层的 MLP(64 → 32 → 1),对比单层逻辑神经元的性能。是否有显著提升?学习率需要怎么调? -
Adam vs SGD — 把同一个 MLP 分别用 SGD 和 Adam 训练 200 epoch,绘制损失曲线,讨论收敛速度差异。
-
多类企鹅 — 在
penguins_mini上构建 PyTorch 多类分类器。注意: 必须是long形状(n,),模型最后一层不要加 softmax。报告 accuracy 和 3×3 混淆矩阵。 -
拓展:类别不平衡 —
BCEWithLogitsLoss(pos_weight=...)与CrossEntropyLoss(weight=...)允许给不同类别加权。在故意制造的不平衡子集上对比有无pos_weight的训练结果。
拓展阅读
-
Goodfellow, Bengio, Courville, Deep Learning, MIT Press, 2016 — 第 5 章(机器学习基础)与第 6.2 节(输出单元与损失函数)系统讲解了 sigmoid / softmax + 交叉熵的最大似然来源。 免费在线版
-
Bishop, Pattern Recognition and Machine Learning, Springer, 2006 — 第 4 章(分类的线性模型)从概率视角推导 logistic 回归与多类 softmax,是经典参考。
-
PyTorch 官方文档 — 必读条目:
torch.nn.BCEWithLogitsLoss(注意pos_weight参数处理类别不平衡)torch.nn.CrossEntropyLoss(注意weight、ignore_index、label_smoothing参数)torch.nn.functional.softmax与log_softmax的数值稳定性说明
-
scikit-learn metrics(
sklearn.metrics)— 详细文档涵盖 ROC 曲线、PR 曲线、classification_report等。配合 PyTorch 模型评估非常方便。 -
Andrew Ng, Machine Learning Specialization(Coursera, 2022 重制版)— 第二门课关于 logistic 回归与正则化的处理非常适合教学回顾。
-
数值稳定性专题 — 搜索 "log-sum-exp trick" 与 "numerical stability of softmax cross-entropy",理解
BCEWithLogitsLoss与CrossEntropyLoss内部为何把 sigmoid/softmax 与对数融合。