# Lecture 3 · 线性分类

> **CS221: Artificial Intelligence — Principles and Techniques** · Stanford · Autumn 2025
> 📅 Sep 29 · 💻 [`linear_classification.py`](https://github.com/stanford-cs221/autumn2025-lectures/blob/main/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\}$ 表示，即类别的整数索引。

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

### 1.3 决策边界

一个简单的二分类器：

```python
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$。分类器的「性格」完全由这条边界决定。

```mermaid
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 参数化

```python
@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）——二维时是直线，三维时是平面。

> [!tip] 直觉
> 几何上，$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$ 是各类的偏置。

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

> [!note]
> 每个类各自给输入「打分」，谁分高听谁的。二分类其实是 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）。

```python
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
```

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

```mermaid
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)$$

```python
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$$

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

> [!example] 数值例子
> 设 $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 的「软化」版本。

```python
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$ 是真实类的索引。

```python
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])           # 真实类的负对数概率
```

> [!example] 数值感受
> 真实类的预测概率为 0.9 → 损失 $= -\log 0.9 \approx 0.11$（小）；只有 0.01 → 损失 $= -\log 0.01 \approx 4.6$（大）。损失对「把真实类压到低概率」的惩罚是对数级陡增的。

> [!note] 两个值得记住的性质
> 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}$$

```python
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 版本（自动求导 + 内置优化器）：

```python
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()
```

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

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

---

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

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

### 5.1 分词（Tokenization）

```python
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](https://tiktokenizer.vercel.app/?encoder=gpt2)

### 5.2 词表（Vocabulary）

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

```python
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）：

```python
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）
```

> [!note] 实现技巧：不必真的构造 one-hot
> 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$ 在文档中的出现**频率**。

```python
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）
```

> [!warning] 易错点
> 求和版和平均版的词袋都有人用，但讲义采用**平均**：求和版的向量范数随文本长度线性增长，同一个 $w$ 对长短文档打出的 logit 尺度不同；平均版消除了长度依赖，长文短文可比。
>
> 词袋更根本的局限是**完全丢失词序**："dog bites man" 和 "man bites dog" 的词袋表示一模一样，情感截然相反的 "not good, bad" 与 "not bad, good" 也无法区分。要保留顺序信息，需要序列模型（见 [[Lecture 17 · 语言模型]]）。

```mermaid
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
```

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

---

## 6. 总结

```mermaid
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）不依赖文本长度，但完全丢失词序。

---

## 复习自测

> [!question]- Q1：为什么 0-1 损失无法用梯度下降优化？请用 margin 的语言解释。
> 0-1 损失是 margin 的阶跃函数 $\mathbf{1}[m \le 0]$：除 $m=0$ 一点外处处平坦，梯度恒为 0。参数微调后每个样本的对错不变，损失值不动，梯度下降得不到任何改进方向。logistic 损失 $\log(1+e^{-m})$ 是它的平滑替代，处处给出非零梯度。

> [!question]- Q2：margin 和 logit 各是什么？为什么判断对错要看 margin 而不是 logit？
> logit $z = w \cdot x + b$ 是模型给出的原始打分，其符号是预测类别；margin $m = y \cdot z$ 把真实标签乘进来，符号直接表示「预测是否正确」（同号为正 = 正确），大小表示置信度。logit 单独只反映模型的意见，不知道对错；margin 才是损失函数应该作用的量。

> [!question]- Q3：推导：证明 K=2 的 softmax 交叉熵等价于二分类 logistic 损失。
> 设两类 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 损失。

> [!question]- Q4：算一算：$w=(2,-1)$，$b=0.5$，$x=(1,1)$，$y=-1$。求 logit、margin 与 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 往负方向（即正确方向）推。

> [!question]- Q5：词袋表示丢失了什么信息？为什么讲义用平均而不是求和？
> 丢失词序：所有词的排列共享同一表示，"dog bites man" 与 "man bites dog" 无法区分。用平均是为了消除文本长度的影响——求和版向量范数随长度线性增长，同一权重对长短文档的 logit 尺度不一致；平均版给出的是词频分布，长短可比。

---

## 参考资料

- 💻 [linear_classification.py](https://github.com/stanford-cs221/autumn2025-lectures/blob/main/linear_classification.py)
- 📖 [Autumn 2023 讲义（图文版）](https://stanford-cs221.github.io/autumn2023/modules/module.html#include=machine-learning%2Flinear-classification.js&mode=print6pp)
- 🔗 [Tiktoken 在线体验](https://tiktokenizer.vercel.app/?encoder=gpt2)
- 📄 [Byte-Pair Encoding 论文](https://arxiv.org/pdf/1508.07909)
