teach.pascalyim.com
目录

DL · 章节 2

深度学习 2 — 分类

在 Kaggle 上打开

在前一章中,我们已经学习了如何用一个线性神经元解决回归问题:输入向量 XX 经过一个仿射组合 z=Xw+bz = Xw + b,直接将 zz 作为对连续目标 yy 的预测。然而,在大量真实场景中,我们关心的并不是某个连续值,而是一个类别归属问题:这封邮件是否为垃圾邮件?这张乳腺组织切片是良性还是恶性?这只企鹅属于哪个物种?

本章将沿着同样的思路,把线性神经元改造成一个分类器,然后逐步过渡到 PyTorch 的实现,并把它推广到多层网络与多类问题。在这条路径上,我们会接触到逻辑函数、二元交叉熵、softmax、BCEWithLogitsLossCrossEntropyLoss 等概念。这些工具看似分散,实际上都源自同一个原理:最大似然

二元分类与逻辑神经元

在二元分类中,目标变量取两个值:

y{0,1}y \in \{0, 1\}

直觉上我们想"输出一个类别",但更精细的做法是估计条件概率

P(y=1X)P(y = 1 \mid X)

得到这个概率之后,最终的硬决策(0 还是 1)只是一个阈值化的事后步骤。这种"先估计概率,再做决定"的视角,是统计学习的核心立场。

伯努利分布与似然

二值随机变量自然由伯努利分布描述。给定参数 p[0,1]p \in [0, 1]:

P(y=1)=p,P(y=0)=1pP(y = 1) = p, \qquad P(y = 0) = 1 - p

我们可以把两个公式合写成一个紧凑表达式:

P(yp)=py(1p)1yP(y \mid p) = p^{y}(1 - p)^{1 - y}

这里 pp 是模型需要学习的未知参数。在监督学习场景下,数据 (Xi,yi)(X_i, y_i) 已经被观测且固定下来,而 pp 取决于模型参数,因此上式被视为似然:

L(py)=P(yp)\mathcal{L}(p \mid y) = P(y \mid p)

对于由独立观测组成的数据集:

L(w,b)=i=1nP(yipi)\mathcal{L}(w, b) = \prod_{i=1}^{n} P(y_i \mid p_i)

其中 pip_i 是模型为第 ii 个样本所赋予的概率。"学习"这件事,在最大似然框架下,就是选择 w,bw, b 让观察到的数据尽可能地"合理"

用神经元产生概率

像回归一样,我们从仿射组合开始:

z=Xw+bz = Xw + b

zz 不是概率,它是一个无界的实数。我们需要把它压缩到 (0,1)(0, 1) 区间。最经典的选择是 sigmoid (logistic) 函数:

σ(z)=11+ez\sigma(z) = \frac{1}{1 + e^{-z}}

于是:

p=P(y=1X)=σ(Xw+b)p = P(y = 1 \mid X) = \sigma(Xw + b)

这就是逻辑神经元:它本质上是一个概率估计器,而不是一个直接的分类器。

关键关系: 逻辑神经元 = 仿射变换 + sigmoid。zz 称为 logit(实数,无界),u=σ(z)u = \sigma(z) 是概率。

从似然到二元交叉熵

把似然 L\mathcal{L} 写成对数形式有两个动机:其一,概率的连乘很快会下溢到 0;其二,对数把乘积变成求和,便于对参数求导。最大化对数似然:

logL(w,b)=i=1n[yilog(pi)+(1yi)log(1pi)]\log \mathcal{L}(w, b) = \sum_{i=1}^{n} \left[y_i \log(p_i) + (1 - y_i)\log(1 - p_i)\right]

机器学习习惯最小化损失,因此取相反数,得到我们熟知的二元交叉熵 (Binary Cross-Entropy, BCE):

LCE(w,b)=1ni=1n[yilog(pi)+(1yi)log(1pi)]\mathcal{L}_{\mathrm{CE}}(w, b) = -\frac{1}{n}\sum_{i=1}^{n}\left[y_i \log(p_i) + (1 - y_i)\log(1 - p_i)\right]

这个损失在不同社区有不同的名字:对数损失 (log-loss)、二元交叉熵、伯努利负对数似然。它们指的是同一个东西。

与感知器的对比

如果我们直接对 zz 取符号,得到的是经典的感知器 (Perceptron):

y^={1若 Xw+b00否则\hat{y} = \begin{cases} 1 & \text{若 } Xw + b \ge 0 \\ 0 & \text{否则} \end{cases}

感知器的决策是硬的、不可微的、不输出概率。Sigmoid 把这个阶跃函数平滑化为可微的概率版本,从而与梯度下降兼容。这一点是把"决策器"升级为"学习器"的关键。

梯度推导

逻辑神经元由三个串联的算子构成:

w    zi    ui    Eiw \;\longrightarrow\; z_i \;\longrightarrow\; u_i \;\longrightarrow\; E_i

其中:

zi=Xiw+b,ui=σ(zi),Ei=[yilog(ui)+(1yi)log(1ui)]z_i = X_i w + b, \qquad u_i = \sigma(z_i), \qquad E_i = -\bigl[y_i \log(u_i) + (1 - y_i)\log(1 - u_i)\bigr]

利用链式法则:

Eiwj=Eiuiuiziziwj\frac{\partial E_i}{\partial w_j} = \frac{\partial E_i}{\partial u_i} \cdot \frac{\partial u_i}{\partial z_i} \cdot \frac{\partial z_i}{\partial w_j}

逐项计算这三个偏导:

第一项(损失对输出):

Eiui=yiui+1yi1ui\frac{\partial E_i}{\partial u_i} = -\frac{y_i}{u_i} + \frac{1 - y_i}{1 - u_i}

第二项(sigmoid 的著名导数):

uizi=ui(1ui)\frac{\partial u_i}{\partial z_i} = u_i(1 - u_i)

第三项(线性部分对权重):

ziwj=xij\frac{\partial z_i}{\partial w_j} = x_{ij}

把三者相乘,中间项 ui(1ui)u_i(1 - u_i) 与第一项的分母奇迹般地化简:

(yiui+1yi1ui)ui(1ui)=yi(1ui)+(1yi)ui=uiyi\left(-\frac{y_i}{u_i} + \frac{1 - y_i}{1 - u_i}\right) u_i(1 - u_i) = -y_i(1 - u_i) + (1 - y_i)u_i = u_i - y_i

因此:

Eiwj=(uiyi)xij\frac{\partial E_i}{\partial w_j} = (u_i - y_i)\, x_{ij}

向量化并对一个 batch 取均值:

Ew=1nX(uy),Eb=1ni=1n(uiyi)\boxed{\frac{\partial E}{\partial w} = \frac{1}{n} X^{\top}(u - y), \qquad \frac{\partial E}{\partial b} = \frac{1}{n}\sum_{i=1}^{n}(u_i - y_i)}

其中 u=σ(Xw+b)u = \sigma(Xw + b)

与线性回归的形式相似性

把上式与第 1 章线性回归的梯度对比一下:

模型输出 uu梯度 E/w\partial E / \partial w
线性神经元u=Xw+bu = Xw + b1nX(uy)\tfrac{1}{n} X^{\top}(u - y)
逻辑神经元u=σ(Xw+b)u = \sigma(Xw + b)1nX(uy)\tfrac{1}{n} X^{\top}(u - y)

公式完全一样。唯一的差别是 uu 的定义。这意味着,只要我们已经写好了线性神经元的训练循环 (fitpredict、batch / mini-batch / SGD 三种模式),把它改造成逻辑神经元只需要改两处:

  1. forward 方法中,u=Xw+bu = Xw + b 改成 u=σ(Xw+b)u = \sigma(Xw + b)
  2. 损失函数从 MSE 改成二元交叉熵

教学要点: (uy)(u - y) 这一项在两种模型中扮演了完全相同的"误差信号"角色。这并非巧合,而是指数族 + 链接函数 (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)u0u \to 0 时趋于 -\infty。我们在 uu 上加了一个小常数 ϵ=1012\epsilon = 10^{-12} 以避免数值溢出。这是一种"打补丁"的写法;PyTorch 的 BCEWithLogitsLoss 用更优雅的 log-sum-exp 技巧 从根本上解决该问题,我们稍后会看到。

cancer_mini 数据集(乳腺癌良性 / 恶性二分类)上训练这个模型,典型流程是:train_test_splitStandardScaler.fit_transform / transformmodel.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)

这里模型直接输出概率 u(0,1)u \in (0, 1),损失再对其做交叉熵。这种写法与教科书公式最直接对应,适合教学讲解。但在数值上不是最优的。

方案 2(推荐):Linear + BCEWithLogitsLoss

model = nn.Linear(m, 1) criterion = nn.BCEWithLogitsLoss() optimizer = torch.optim.SGD(model.parameters(), lr=0.1)

注意此时模型不带 sigmoid,直接输出 logit zz。损失函数内部把 sigmoid 与交叉熵融合在一起,使用 log-sum-exp 技巧避免了 log(0)\log(0)log(11)\log(1 - 1) 的灾难。

核心陷阱: BCELoss vs BCEWithLogitsLoss

  • BCELoss 的输入必须是概率 u(0,1)u \in (0, 1),需要在模型最后加 Sigmoid
  • BCEWithLogitsLoss 的输入必须是logit zRz \in \mathbb{R},模型最后不能Sigmoid,否则你会做两次 sigmoid,模型几乎无法训练。

工业代码几乎一律选 BCEWithLogitsLoss,数值更稳。

数据准备与训练循环

PyTorch 的张量需要是 float32,目标 yy 需要 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,牺牲一些精确率换取更高召回率。这一调整不需要重训模型,只是后处理。

多层感知器:从单神经元到深度网络

单个逻辑神经元只能学到线性可分的决策边界。当类别边界扭曲时,我们需要堆叠多层非线性变换:

X  Linear+ReLU  h1  Linear+ReLU  h2  Linear  zX \;\xrightarrow{\text{Linear+ReLU}}\; h_1 \;\xrightarrow{\text{Linear+ReLU}}\; h_2 \;\xrightarrow{\text{Linear}}\; z

最后一层不带激活,它输出的是 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 行)很容易过拟合,需注意验证集监控。

多类分类

把二元问题推广到 CC 类,y{0,1,,C1}y \in \{0, 1, \dots, C-1\}。我们想估计概率分布:

P(y=cX),c=0,1,,C1P(y = c \mid X), \quad c = 0, 1, \dots, C-1

Softmax 与多类交叉熵

模型最后一层从 1 个神经元扩展到 CC 个:

nn.Linear(h, C)

输出向量 z=(z0,z1,,zC1)RCz = (z_0, z_1, \dots, z_{C-1}) \in \mathbb{R}^C,称为 logits。把 logits 转化为概率分布,我们使用 softmax:

P(y=cX)=ezck=0C1ezkP(y = c \mid X) = \frac{e^{z_c}}{\sum_{k=0}^{C-1} e^{z_k}}

softmax 自动满足 cP(y=cX)=1\sum_c P(y = c \mid X) = 1。多类交叉熵就是单个观测真实类别的负对数概率:

Li=logP(y=yiXi)\mathcal{L}_i = -\log P(y = y_i \mid X_i)

在 PyTorch 中,这一切被 nn.CrossEntropyLoss 一手包办:

criterion = nn.CrossEntropyLoss()

核心陷阱: CrossEntropyLoss 期望的是 logits,不是 softmax 输出

nn.CrossEntropyLoss 内部已经做了 LogSoftmax + NLLLoss。如果你在模型最后一层加了 SoftmaxLogSoftmax,那就等于做了两次,梯度会出问题。

正确做法: 模型最后一层是裸的 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,值在 {0,,C1}\{0, \dots, C-1\} 中。

我们不需要 one-hot 编码 yy。损失函数会根据整数索引 yiy_i 自动选取对应类别的概率。

完整示例: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)

预测公式 y^=argmaxczc\hat{y} = \arg\max_c z_c 在数学上等价于 argmaxcP(y=cX)\arg\max_c P(y = c \mid X),因为 softmax 是单调的——我们不需要真的算 softmax 来做硬决策。

二元 vs 多类 — 速查表

维度二元 (binary)多类 (multi-class)
输出层nn.Linear(h, 1)nn.Linear(h, C)
损失BCEWithLogitsLossCrossEntropyLoss
yy 形状(n, 1), float32(n,), long
logit→概率torch.sigmoidtorch.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))

阈值是一个业务决策:在不同阈值 {0.3,0.5,0.7}\{0.3, 0.5, 0.7\} 下计算指标,根据应用场景挑选(医疗追求高 recall,垃圾邮件追求高 precision)。

小结

本章把第 1 章的回归神经元改造为分类器:

  1. 在仿射组合后接一个 sigmoid,把无界 logit 压缩为 (0,1)(0, 1) 概率;
  2. 把 MSE 损失换成二元交叉熵——这在最大似然意义下与伯努利分布相符;
  3. 神奇的是,梯度公式 X(uy)/nX^{\top}(u - y)/n 与线性情况完全一样,这是指数族的优雅产物;
  4. PyTorch 实现中,BCEWithLogitsLoss(二元)和 CrossEntropyLoss(多类)是数值稳定的工业标准——但要记得它们的输入是 logits 而非概率;
  5. 多层感知器只是把这些层按 Sequentialnn.Module 堆叠起来,训练循环结构不变;
  6. 多类问题用 softmax 推广,yy 用整数索引而非 one-hot。

理解这些原理后,从二元到多类,从单神经元到深度网络,只是一些 API 配置上的差异。底层数学是统一的。

练习

  1. 手算梯度 — 不查公式,从 Ei=[yilogui+(1yi)log(1ui)]E_i = -[y_i \log u_i + (1 - y_i)\log(1 - u_i)] 出发,用链式法则推导 Ei/wj\partial E_i / \partial w_j。验证最终化简到 (uiyi)xij(u_i - y_i) x_{ij}

  2. NumPy 逻辑神经元 — 把第 1 章的 LinearNeuron 改造为 LogisticNeuron,只修改两处:forward 中加 sigmoid,fit 中把 MSE 换为 BCE(注意 ϵ\epsilon 防溢出)。在 cancer_mini 上训练并对比测试 accuracy。

  3. PyTorch 单神经元 — 用 nn.Linear(m, 1) + BCEWithLogitsLoss + SGDcancer_mini 上训练,记录学习曲线。计算阈值 {0.3,0.5,0.7}\{0.3, 0.5, 0.7\} 下的 accuracy / precision / recall / 混淆矩阵,讨论医疗场景下应选哪个阈值。

  4. 故意踩坑 — 在 PyTorch 模型最后加上 nn.Sigmoid(),但仍然用 BCEWithLogitsLoss。观察训练曲线,解释为什么模型几乎学不到东西。

  5. 多层网络 — 在 cancer_mini 上用一个 2 隐层的 MLP(64 → 32 → 1),对比单层逻辑神经元的性能。是否有显著提升?学习率需要怎么调?

  6. Adam vs SGD — 把同一个 MLP 分别用 SGD 和 Adam 训练 200 epoch,绘制损失曲线,讨论收敛速度差异。

  7. 多类企鹅 — 在 penguins_mini 上构建 PyTorch 多类分类器。注意:yy 必须是 long 形状 (n,),模型最后一层不要加 softmax。报告 accuracy 和 3×3 混淆矩阵。

  8. 拓展:类别不平衡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(注意 weightignore_indexlabel_smoothing 参数)
    • torch.nn.functional.softmaxlog_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",理解 BCEWithLogitsLossCrossEntropyLoss 内部为何把 sigmoid/softmax 与对数融合。