工作台课程

CS221 · 人工智能:原理与技术

Lecture 3 · 线性分类

Lecture 3 · 线性分类

CS221: Artificial Intelligence — Principles and Techniques · Stanford · Autumn 2025
📅 Sep 29 · 💻 linear_classification.py


承上启下

上一讲 Lecture 2 · 线性回归:预测任务是「输入 → 实数」,假设类是线性函数 $f(x) = w \cdot x + b$,损失用平方误差,优化用梯度下降。整套「假设类 → 损失函数 → 优化算法」的三段式框架来自 Lecture 1 · 张量、梯度与监督学习

本讲(线性分类):沿用完全相同的三段式框架,只改变一件事——输出从实数变成 K 个离散选项之一。这个看似微小的改动带来一个真正的难题:离散输出让最自然的损失(0-1 损失)梯度几乎处处为零,梯度下降直接失效。本讲的主线就是如何用「输出概率」的思路(logistic 损失、交叉熵)绕过这个障碍。此外还解决一个工程问题:文本是字符串,如何变成分类器能吃的张量。

下一讲 Lecture 4 · 深度学习:线性分类器只能画「直线」决策边界,下一讲用多层神经网络把假设类扩展为非线性函数。


1. 分类任务

1.1 典型任务

任务 输入 输出
图像分类 一张图片(H×W×3 张量) 物体类别(如 cat)
情感分类 一段文本(字符串) 情感(positive / negative)

1.2 输出类型

  • 二分类(binary classification):只有两个选项,通常用 $y \in \{-1, +1\}$ 表示。选这一对数字(而不是 0/1)是有意的——它让「预测正确与否」可以用一个乘积(margin,见第 3 节)优雅地表达。
  • 多分类(multiclass classification):有 K 个选项,通常用 $y \in \{0, 1, \dots, K-1\}$ 表示,即类别的整数索引。

提示

回归和分类共用同一套机器学习框架,唯一的区别在输出空间:回归输出连续值,可以直接比较「差多少」;分类输出离散选项,只有「对或错」。正因为「对或错」是阶跃式的,后面才需要费心构造可微的替代损失——这是本讲一切技术细节的根源。

1.3 决策边界

一个简单的二分类器:

def simple_binary_classifier(x: np.ndarray) -> int:
    logit = x[0] - x[1] - 1
    if logit > 0:
        return 1
    else:
        return -1

决策边界(decision boundary):使得 logit 恰好为 0 的点的集合。对上面的分类器就是 $x_0 - x_1 - 1 = 0$,即直线 $x_1 = x_0 - 1$。这条直线把平面切成两半:一侧全部预测 $+1$,另一侧全部预测 $-1$。分类器的「性格」完全由这条边界决定。

graph LR
    subgraph 决策空间
        A["x[0] - x[1] - 1 > 0<br/>预测为 +1"]
        B["x[0] - x[1] - 1 < 0<br/>预测为 -1"]
        C["x[0] - x[1] - 1 = 0<br/>决策边界"]
    end
    A -.-> C
    B -.-> C
    style C fill:#ffe0b2,stroke:#e65100

2. 假设类:线性分类器

2.1 参数化

@dataclass(frozen=True)
class Parameters:
    weight: np.ndarray    # (d,) 权重向量
    bias: float           # 偏置

def binary_classifier(params: Parameters, x: np.ndarray) -> int:
    logit = params.weight @ x + params.bias
    return 1 if logit > 0 else -1

写成数学形式,二分类线性分类器是

$$f_{w,b}(x) = \operatorname{sign}(w \cdot x + b)$$

其中 $x \in \mathbb{R}^d$ 是输入特征向量,$w \in \mathbb{R}^d$ 是权重向量,$b \in \mathbb{R}$ 是偏置,$\operatorname{sign}$ 取符号(正数 → $+1$,负数 → $-1$)。

  • logit:中间量 $z = w \cdot x + b$,一个实数。它的符号决定预测类别,绝对值大小可以理解为「置信度」。名字来源见 3.2 节——它恰好是概率的对数几率(log odds)。
  • 假设类(hypothesis class):所有可能的 $(w, b)$ 取值对应的分类器的集合。学习就是在这个集合里挑一个最好的。
  • 决策边界:$\{x : w \cdot x + b = 0\}$,是 $\mathbb{R}^d$ 中的一个超平面(hyperplane)——二维时是直线,三维时是平面。

提示

几何上,$w$ 是决策边界超平面的法向量:它垂直于边界,指向预测 $+1$ 的那一侧;$b$ 控制边界离原点的远近。调整 $w$ 就是旋转这块「切板」,调整 $b$ 就是平移它。线性分类器的全部能力,就是用一块平板把空间一切为二——这也预告了它的局限(无法切出弯曲边界,见 Lecture 4 · 深度学习 的 XOR 例子)。

2.2 多分类推广

对 K 类问题,为每个类 $k$ 各配一组参数 $(w_k, b_k)$,算出 K 个 logit,取最大者:

$$f_{W,b}(x) = \arg\max_{k \in \{0,\dots,K-1\}} \; (W x + b)_k$$

其中 $W \in \mathbb{R}^{K \times d}$ 的第 $k$ 行是类 $k$ 的权重向量,$b \in \mathbb{R}^K$ 是各类的偏置。

def multiclass_classifier(params: Parameters, x: np.ndarray) -> int:
    logits = params.weight @ x + params.bias  # (K,) 向量
    return np.argmax(logits)                  # 返回最大 logit 对应的类

说明

每个类各自给输入「打分」,谁分高听谁的。二分类其实是 K=2 的特例:两个 logit 只有差值 $z_{+1} - z_{-1}$ 起作用(见 3.3 节的推导),所以只需一组参数、一个 logit 就够了。


3. 损失函数

3.1 0-1 损失(zero-one loss)

最直观的损失:预测错了记 1,对了记 0。训练损失(所有样本的平均 0-1 损失)就是错误率

先引入一个贯穿本讲的关键量——间隔(margin)

$$m = y \cdot z = y\,(w \cdot x + b)$$

由于 $y \in \{-1, +1\}$,margin 的符号直接编码了对错:预测正确(logit 与 $y$ 同号)时 $m > 0$,预测错误时 $m < 0$;$|m|$ 越大表示分类器在这个样本上越「笃定」。于是 0-1 损失可以写成

$$\ell_{0\text{-}1}(x, y; w, b) = \mathbf{1}[m \le 0]$$

其中 $\mathbf{1}[\cdot]$ 是指示函数(条件成立取 1,否则取 0)。

def zero_one_loss(params: Parameters, example: Example) -> float:
    predicted_y = binary_classifier(params, example.x)
    return 0.0 if predicted_y == example.target_y else 1.0

注意

0-1 损失作为评估指标(错误率/准确率)完全没问题,问题出在把它当训练目标:它是 margin 的阶跃函数,除了 $m = 0$ 那一点外处处平坦,梯度恒为 0。参数微调后,错的样本还是错、对的还是对,损失纹丝不动——梯度下降拿不到任何「往哪走」的信号。这不是数值问题,是这个函数本质上不给方向信息。

graph TD
    A["0-1 损失"] --> B["梯度几乎处处为 0"]
    B --> C["无法用梯度下降优化"]
    C --> D["需要可微的替代损失"]
    style C fill:#ffcdd2,stroke:#c62828
    style D fill:#c8e6c9,stroke:#2e7d32

3.2 Logistic 函数:从硬决策到概率

核心思想:让分类器不再输出硬邦邦的 $\pm 1$,而是输出「属于 $+1$ 类的概率」。概率随参数连续变化,损失就处处可微了。

把 logit 压缩到 $(0, 1)$ 区间用的是 logistic 函数(也叫 sigmoid):

$$\sigma(z) = \frac{1}{1 + e^{-z}}$$

它的关键性质:

  • $z \to +\infty$ 时 $\sigma(z) \to 1$;$z \to -\infty$ 时 $\sigma(z) \to 0$;$z = 0$(正好在决策边界上)时 $\sigma(z) = 0.5$,即「五五开」。
  • 导数有优美的闭式:$\sigma'(z) = \sigma(z)\,(1 - \sigma(z))$,在 $z=0$ 处最大(0.25),两端趋于 0。
  • logit 就是对数几率:若 $p = \sigma(z)$,则 $z = \log\frac{p}{1-p}$。这就是「logit」这个名字的来历——线性函数 $w \cdot x + b$ 建模的是概率的对数几率。

于是概率模型为 $P(y = +1 \mid x) = \sigma(w \cdot x + b)$,等价地对任意 $y \in \{-1,+1\}$ 可统一写成 $P(y \mid x) = \sigma(m)$(margin 的 logistic 值),这利用了 $\sigma(-z) = 1 - \sigma(z)$ 的对称性。

3.3 Logistic 损失

有了概率,就可以用最大似然原则定义损失:好的参数应该给真实标签高概率,所以损失取真实标签的负对数概率

$$\ell_{\text{log}}(x, y; w, b) = -\log P(y \mid x) = -\log \sigma(m) = \log\left(1 + e^{-m}\right)$$

def logistic_loss(params: Parameters, example: Example) -> float:
    logit = params.weight @ example.x + params.bias
    # margin = y · logit(正确时为正,错误时为负)
    margin = example.target_y * logit
    return np.log(1 + np.exp(-margin))

性质

  • margin 很大(预测正确且置信度高)→ $e^{-m} \approx 0$ → 损失接近 0。
  • margin 为大负数(错得离谱)→ 损失近似 $-m$,随错误程度线性增长。
  • 处处可微、处处梯度非零 ⇒ 梯度下降可以工作。它可以看作 0-1 损失那道「阶跃悬崖」的平滑化版本。

对 $w$ 求导(链式法则,中间步骤:$\frac{\partial \ell}{\partial m} = -\sigma(-m)$,$\frac{\partial m}{\partial w} = y\,x$):

$$\nabla_w \ell_{\text{log}} = -\sigma(-m)\; y\, x, \qquad \frac{\partial \ell_{\text{log}}}{\partial b} = -\sigma(-m)\; y$$

提示

梯度里的系数 $\sigma(-m) = 1 - \sigma(m)$ 正是模型分给错误标签的概率——「错得越多,推得越狠」。样本已被高置信正确分类时($m$ 大),$\sigma(-m) \approx 0$,几乎不产生更新;错误样本产生接近满幅的更新。梯度下降的每一步都自动把力气花在当前最「冤枉」的样本上,这就是 logistic 损失优于 0-1 损失的实质:它不仅告诉你错没错,还告诉你错了多少、该往哪改。

例子

设 $w = (2, -1)$,$b = 0.5$,样本 $x = (1, 1)$,$y = -1$。
logit:$z = 2 \times 1 + (-1) \times 1 + 0.5 = 1.5$(预测 $+1$,错了);
margin:$m = y \cdot z = -1.5 < 0$,确认预测错误;
logistic 损失:$\log(1 + e^{1.5}) \approx 1.70$,而一个正确且 $m=+1.5$ 的样本损失仅 $\log(1+e^{-1.5}) \approx 0.20$。

3.4 多分类:Softmax 与交叉熵损失

对多分类,先用 softmax 把 K 个 logit 转成一个概率分布:

$$\operatorname{softmax}(z)_k = \frac{e^{z_k}}{\sum_{j=1}^{K} e^{z_j}}$$

分子保证每项非负,分母保证总和为 1。指数运算保序:logit 最大的类概率也最大,所以 softmax 是 argmax 的「软化」版本。

def softmax(logits: np.ndarray) -> np.ndarray:
    """将 logits 转成概率分布(和为 1,每项非负)"""
    exp_logits = np.exp(logits)
    return exp_logits / np.sum(exp_logits)

然后用交叉熵(cross-entropy)衡量预测分布与真实分布的差距。当真实分布是 one-hot(真实类概率 1,其余为 0)时,交叉熵退化为真实类的负对数概率:

$$\ell_{\text{CE}}(x, y; W, b) = -\log \operatorname{softmax}(z)_y = -z_y + \log \sum_{j=1}^{K} e^{z_j}$$

其中 $z = Wx + b$ 是 logit 向量,$y$ 是真实类的索引。

def cross_entropy_loss(params: Parameters, example: Example) -> float:
    logits = params.weight @ example.x + params.bias  # (K,)
    probs = softmax(logits)                           # (K,)
    return -np.log(probs[example.target_y])           # 真实类的负对数概率

例子

真实类的预测概率为 0.9 → 损失 $= -\log 0.9 \approx 0.11$(小);只有 0.01 → 损失 $= -\log 0.01 \approx 4.6$(大)。损失对「把真实类压到低概率」的惩罚是对数级陡增的。

说明

  1. 平移不变性:给所有 logit 同加常数 $c$,softmax 输出不变(分子分母同乘 $e^c$)。实现上常利用它减去 $\max_k z_k$ 防止 $e^{z_k}$ 上溢。
  2. 二分类是特例:K=2 时 $P(+1 \mid x) = \frac{e^{z_{+1}}}{e^{z_{+1}} + e^{z_{-1}}} = \sigma(z_{+1} - z_{-1})$——两个 logit 只有差值起作用,令这个差值为单一 logit,交叉熵就还原成 3.3 节的 logistic 损失。所以「logistic 损失 vs 交叉熵」不是两套理论,而是同一最大似然思想在 K=2 与一般 K 的两个面孔。

4. 优化算法

有了可微的损失函数,就能沿用 Lecture 2 · 线性回归梯度下降:反复计算训练损失(所有样本损失的平均)对参数的梯度,并沿负梯度方向小步更新,步长由学习率(learning rate)$\eta$ 控制:

$$w \leftarrow w - \eta\, \nabla_w \mathcal{L}_{\text{train}}, \qquad b \leftarrow b - \eta\, \frac{\partial \mathcal{L}_{\text{train}}}{\partial b}$$

params = Parameters(weight=np.zeros(d), bias=0.0)
learning_rate = 0.01

for step in range(num_steps):
    grad = compute_gradient_train_loss(params, training_data)
    params = Parameters(
        weight=params.weight - learning_rate * grad.weight,
        bias=params.bias - learning_rate * grad.bias,
    )

PyTorch 版本(自动求导 + 内置优化器):

model = nn.Linear(d, K)
optimizer = torch.optim.SGD(model.parameters(), lr=0.01)
loss_fn = nn.CrossEntropyLoss()

for epoch in range(num_epochs):
    for x, target_y in training_data:
        logits = model(x)
        loss = loss_fn(logits, target_y)
        loss.backward()
        optimizer.step()
        optimizer.zero_grad()

注意

nn.CrossEntropyLoss 接收的是原始 logits,不是 softmax 之后的概率——它内部用 log-sum-exp 技巧一次算完 softmax + log + 取负,数值上更稳定。如果先手动 softmax 再喂进去,等于做了两次 softmax,结果错误且难以察觉。

说明

logistic 损失和交叉熵都是凸函数,线性模型下梯度下降能收敛到全局最优。这个「凸性红利」在下一讲 Lecture 4 · 深度学习 引入多层网络后就没有了——那时损失面变得非凸,需要一整套额外技巧来稳定训练。


5. 文本表示:从字符串到张量

分类器的输入必须是张量,但文本是字符串。解决方案是一条流水线:分词 → 转索引 → one-hot / 词袋

5.1 分词(Tokenization)

string = "the cat in the hat"

# 简单分词:按空格切分
words = string.split()  # ['the', 'cat', 'in', 'the', 'hat']

# 实际应用:Byte-Pair Encoding(BPE)
# GPT-2/GPT-3/GPT-4 都用 BPE 分词器
import tiktoken
tokenizer = tiktoken.get_encoding("gpt2")
tokens = tokenizer.encode(string)  # [1169, 3797, 287, 262, 6877]

按空格切分只适合演示:它处理不了中文(没有空格)、标点、未见过的新词。工业界通用的 BPE 从字符级词表出发,反复合并语料中最高频的相邻对,自动得到大小可控、无未登录词问题的子词(subword)词表。BPE 的细节在 CS336 的 Lecture 1 · 概览与分词(未找到对应页面) 中深入展开;分好词的序列如何建模,则是 Lecture 17 · 语言模型 的主题。

🔗 互动体验:tiktokenizer.vercel.app

5.2 词表(Vocabulary)

建立一个词 ↔ 整数索引的双向映射,第一次见到的词分配新索引,重复出现的词复用旧索引:

class Vocabulary:
    def __init__(self):
        self.index_to_string: list[str] = []
        self.string_to_index: dict[str, int] = {}

    def get_index(self, string: str) -> int:
        if string not in self.string_to_index:
            idx = len(self.index_to_string)
            self.index_to_string.append(string)
            self.string_to_index[string] = idx
            return idx
        return self.string_to_index[string]

vocab = Vocabulary()
indices = [vocab.get_index(word) for word in words]
# indices = [0, 1, 2, 0, 3]  ('the' 重复出现,索引相同)

5.3 One-hot 编码

把每个索引转成一个稀疏向量(只有对应位置为 1,其余为 0):

def one_hot(index: int, vocab_size: int) -> np.ndarray:
    vector = np.zeros(vocab_size)
    vector[index] = 1
    return vector

# 'cat' (索引 1) → [0, 1, 0, 0](假设词表大小为 4)

说明

one-hot 向量与权重的点积 $\operatorname{onehot}(i) \cdot w$ 就是 $w_i$——直接按索引取数即可。所以实践中用 w[indices] 花式索引代替显式构造 one-hot 矩阵再做矩阵乘法,节省 vocab_size 倍的内存与计算。这也是深度学习中 embedding 查表操作的原型。

5.4 词袋模型(Bag of Words)

把整个文档表示成所有词的 one-hot 向量的平均

$$\phi_{\text{BoW}}(x) = \frac{1}{L} \sum_{i=1}^{L} \operatorname{onehot}(t_i)$$

其中 $t_1, \dots, t_L$ 是文档的 token 索引序列,$L$ 是文档长度。结果向量的第 $j$ 维就是词 $j$ 在文档中的出现频率

def bag_of_words(indices: list[int], vocab_size: int) -> np.ndarray:
    vector = np.zeros(vocab_size)
    for idx in indices:
        vector[idx] += 1
    return vector / len(indices)   # 取平均,使表示不随文本长度膨胀

# "the cat in the hat" → [0.4, 0.2, 0.2, 0.2]('the' 占 2/5,其余各 1/5)

注意

求和版和平均版的词袋都有人用,但讲义采用平均:求和版的向量范数随文本长度线性增长,同一个 $w$ 对长短文档打出的 logit 尺度不同;平均版消除了长度依赖,长文短文可比。

词袋更根本的局限是完全丢失词序:”dog bites man” 和 “man bites dog” 的词袋表示一模一样,情感截然相反的 “not good, bad” 与 “not bad, good” 也无法区分。要保留顺序信息,需要序列模型(见 Lecture 17 · 语言模型)。

flowchart LR
    A["文本字符串"] --> B["分词<br/>split / BPE"]
    B --> C["转索引<br/>Vocabulary"]
    C --> D["One-hot / BoW<br/>张量表示"]
    D --> E["输入分类器"]

    style D fill:#e1f5fe,stroke:#0277bd
    style E fill:#c8e6c9,stroke:#2e7d32

提示

这条流水线的每一步都在做同一件事:把「符号」翻译成「数」,同时尽量少丢信息。分词决定符号的粒度,词表给符号编号,one-hot 让编号之间没有虚假的大小关系(索引 3 并不比索引 1 「大」,one-hot 把它们放到相互正交的方向上),词袋再把变长序列压成定长向量。每一步都是一次有损压缩,词袋这一步丢掉的(词序)最多——后续所有 NLP 架构的演进,都可以看作在「定长可计算」与「保留更多结构」之间寻找更好的折中。


6. 总结

mindmap
  root((线性分类))
    任务
      输入映射到 K 个类别
      二分类与多分类
      决策边界
    假设类
      线性函数
      二分类取符号
      多分类取 argmax
    损失函数
      0-1 损失不可微
      margin 编码对错与置信度
      Logistic 损失
      Softmax 与交叉熵
    优化
      梯度下降
      PyTorch 自动求导
    文本表示
      分词
      词表
      one-hot 与词袋

要点

  • 线性分类器 $\operatorname{sign}(w \cdot x + b)$ 的决策边界是超平面,$w$ 是它的法向量。
  • margin $m = y \cdot (w \cdot x + b)$ 一个数同时编码「对错」(符号)与「置信度」(大小),是理解分类损失的钥匙。
  • 0-1 损失梯度几乎处处为零 → 让模型输出概率,用最大似然导出 logistic 损失(二分类)与交叉熵(多分类);后者在 K=2 时退化为前者。
  • Softmax 把 logits 变成概率分布,具有平移不变性;nn.CrossEntropyLoss 直接吃 logits。
  • 文本需要先分词 → 转索引 → 向量化才能输入分类器;词袋(平均 one-hot)不依赖文本长度,但完全丢失词序。

复习自测

题目

0-1 损失是 margin 的阶跃函数 $\mathbf{1}[m \le 0]$:除 $m=0$ 一点外处处平坦,梯度恒为 0。参数微调后每个样本的对错不变,损失值不动,梯度下降得不到任何改进方向。logistic 损失 $\log(1+e^{-m})$ 是它的平滑替代,处处给出非零梯度。

题目

logit $z = w \cdot x + b$ 是模型给出的原始打分,其符号是预测类别;margin $m = y \cdot z$ 把真实标签乘进来,符号直接表示「预测是否正确」(同号为正 = 正确),大小表示置信度。logit 单独只反映模型的意见,不知道对错;margin 才是损失函数应该作用的量。

题目

设两类 logit 为 $z_{+1}, z_{-1}$,则 $P(+1 \mid x) = \frac{e^{z_{+1}}}{e^{z_{+1}}+e^{z_{-1}}}$,分子分母同除 $e^{z_{-1}}$ 得 $\sigma(z_{+1}-z_{-1})$。令 $z = z_{+1} - z_{-1}$(两组参数之差合并为一组),真实标签 $y$ 的负对数概率就是 $-\log\sigma(y z) = \log(1+e^{-yz})$,正是 logistic 损失。

题目

logit $z = 2 - 1 + 0.5 = 1.5$;margin $m = -1.5$(预测错误);损失 $\log(1+e^{1.5}) \approx 1.70$。梯度系数 $\sigma(-m) = \sigma(1.5) \approx 0.82$ 很大,更新 $-\eta \cdot (-\sigma(-m)\,y\,x) = -\eta \cdot 0.82\,x$ 会压低 $w \cdot x$,把 logit 往负方向(即正确方向)推。

题目

丢失词序:所有词的排列共享同一表示,”dog bites man” 与 “man bites dog” 无法区分。用平均是为了消除文本长度的影响——求和版向量范数随长度线性增长,同一权重对长短文档的 logit 尺度不一致;平均版给出的是词频分布,长短可比。


参考资料