第 2 章:Token、词表与 Embedding
1. 为什么不直接把字符串交给模型
神经网络的基本运算对象是数值张量。它不理解:
- 字符
- 单词
- 中文词
- 英文词根
它只理解固定维度的向量。因此第一步永远是:
\[\text{text} \rightarrow \text{token sequence} \rightarrow \text{integer ids} \rightarrow \text{vectors}\]现代 LLM 的“原子单位”叫 token。它可能是:
- 一个英文字符
- 一个英文子词,例如
transform、##er - 一个常见汉字
- 一个标点
- 一个控制符号,例如
<|begin_of_text|>
2. 字符级、词级、子词级
字符级
优点:
- 词表极小
- 不存在未登录词
- 适合教学和小型 demo
缺点:
- 序列很长
- 同样语义被拆得很散
词级
优点:
- 序列短
- 每个 token 语义完整
缺点:
- 词表巨大
- 遇到新词、错拼、罕见语言会很差
子词级
现代 LLM 通常采用子词,例如 BPE、WordPiece、Unigram、SentencePiece。
核心思想是:
- 从字符开始
- 统计相邻符号共现频率
- 把高频对合并成新 token
- 重复直到达到目标词表大小
这样:
- 常见词保持完整
- 罕见词拆成子词
- 永远可以退回到字节或字符
3. 词表和 token id
假设词表是:
["<pad>", "你", "好", "transform", "er"]
那么文本 "你好 transformer" 可能被编码成:
[1, 2, 3, 4]
注意三点:
- token id 只是词表下标,本身没有语义
- 语义主要来自训练后的 embedding
- 不同模型的词表不能混用
4. One-hot 与 Embedding:token id 如何变成向量
4.1 一个句子的处理顺序
假设句子是:
I love transformers
第一步,tokenizer 先切成 token:
["I", "love", "transformers"]
第二步,查词表,把 token 变成 id:
"I" -> 40
"love" -> 952
"transformers" -> 37167
得到:
[40, 952, 37167]
第三步,Embedding 层用这些 id 查表。
如果:
vocab_size = 50257
embedding_dim = 768
那么 embedding 表 (E) 的形状是:
(50257, 768)
含义是:
- 表有 50257 行
- 每一行对应一个 token
- 每一行是一个 768 维向量
查表结果是:
id 40 -> E[40] -> 768 维向量
id 952 -> E[952] -> 768 维向量
id 37167 -> E[37167] -> 768 维向量
最终整句话不是变成一个 768 维向量,而是变成一个矩阵:
(3, 768)
也就是:
3 个 token,每个 token 一个 768 维向量
4.2 为什么叫“查找表”
可以把 embedding 想象成一张二维表:
| token id | 第 0 维 | 第 1 维 | 第 2 维 | 第 3 维 | … |
|---|---|---|---|---|---|
| 0 | 0.12 | -0.43 | 0.08 | 0.77 | … |
| 1 | -0.25 | 0.31 | -0.19 | 0.04 | … |
| 2 | 0.66 | 0.02 | -0.51 | 0.23 | … |
| 3 | -0.17 | 0.58 | 0.29 | -0.36 | … |
| 4 | 0.41 | -0.12 | 0.63 | 0.09 | … |
如果 token id 是 3,Embedding 就取:
第 3 行
得到:
[-0.17, 0.58, 0.29, -0.36, ...]
注意:
- 这里的
3是行号 - 行号从 0 开始
E[3]表示第 4 行,因为 0、1、2、3 中 3 是第 4 个
4.3 为什么 one-hot 乘矩阵等价于查表
假设词表大小是 5:
0, 1, 2, 3, 4
token id 3 的 one-hot 表示是:
只有第 3 个位置是 1。
令 embedding 矩阵为 \(E\),形状是:
\[(5, embedding_dim)\]那么:
\[[0,0,0,1,0]E\]会选中 \(E\) 的第 3 行,其他行全部乘 0,被过滤掉。
所以:
\[[0,0,0,1,0]E=E[3]\]这就是“one-hot 乘法”和“查表”的等价关系。
区别在于:
- one-hot 乘法要把整个 50257 维向量都乘一遍
- 直接查表只需要取第
id行
因此实际实现中一定用查表。
4.4 一个具体小例子
为了方便手算,把维度缩小。
假设:
vocab_size = 5
embedding_dim = 4
构造 embedding 表:
\[E= \begin{bmatrix} 0.1 & 0.2 & 0.3 & 0.4\\ -0.1 & 0.5 & 0.2 & -0.3\\ 0.7 & -0.2 & 0.1 & 0.6\\ -0.4 & 0.3 & 0.8 & 0.2\\ 0.5 & 0.1 & -0.6 & 0.4 \end{bmatrix}\]如果 token id 是 3,则它的向量是:
如果一个句子是:
token ids = [4, 0, 3]
那么查表结果是:
\[\begin{bmatrix} 0.5 & 0.1 & -0.6 & 0.4\\ 0.1 & 0.2 & 0.3 & 0.4\\ -0.4 & 0.3 & 0.8 & 0.2 \end{bmatrix}\]形状:
(3, 4)
也就是:
3 个 token,每个 token 4 维向量
4.5 nn.Embedding 做了什么
PyTorch 代码:
embedding = nn.Embedding(50257, 768)
等价于创建:
50257 行
768 列
的可学习参数表。
调用时:
input_ids = torch.tensor([[40, 952, 37167]])
vectors = embedding(input_ids)
形状变化:
input_ids: (1, 3)
vectors: (1, 3, 768)
含义:
1:批里有 1 个句子3:句子有 3 个 token768:每个 token 的向量维度
如果是批处理:
input_ids: (batch_size, seq_len)
vectors: (batch_size, seq_len, embedding_dim)
4.6 为什么 embedding 可学习
初始时,embedding 表里的数值通常是随机初始化的。
例如:
token "love" -> [0.12, -0.33, 0.08, ...]
这些数字刚开始没有语义。
训练时,模型会根据损失函数反传梯度,不断更新这张表:
训练前:love -> [0.12, -0.33, 0.08, ...]
训练后:love -> [-0.21, 0.65, 0.14, ...]
经过大量语料训练后,语义相近的 token 往往会得到相近的向量。
例如:
king -> [...]
queen -> [...]
可能在某些维度上呈现相似的Pattern。
所以:
Embedding 的语义不是人工写进去的,而是训练出来的。
4.7 为什么不直接用 token id 本身
如果直接把 token id 40 当成数值输入模型,会出问题:
- 数值大小没有语义
40和41不代表更相似- 模型难以处理离散符号
Embedding 的作用是:
把一个离散符号,映射成连续空间中的一个点。
这样模型才能在这个连续空间里做矩阵乘法、加法、距离计算和注意力。
4.8 小结
Tokenizer 先把文本切成 token 并映射成整数 id;Embedding 把每个 id 当作行号,从可学习矩阵中取出对应行,得到该 token 的稠密向量。
5. 简化 BPE
本章代码实现了一个非常小的 BPE:
- 输入一段文本
- 先切成字符
- 统计相邻 pair
- 合并出现最多的 pair
- 重复
--merges次
#!/usr/bin/env python
"""Chapter 2: character tokenizer, a tiny BPE, and embeddings."""
from __future__ import annotations
import argparse
from collections import Counter
from pathlib import Path
import torch
from torch import nn
class CharacterTokenizer:
def __init__(self) -> None:
self.tokens: list[str] = []
self.token_to_id: dict[str, int] = {}
def fit(self, text: str) -> None:
self.tokens = sorted(set(text))
self.token_to_id = {token: index for index, token in enumerate(self.tokens)}
def encode(self, text: str) -> list[int]:
return [self.token_to_id[token] for token in text]
def decode(self, ids: list[int]) -> str:
return "".join(self.tokens[index] for index in ids)
@property
def vocab_size(self) -> int:
return len(self.tokens)
class TinyBPE:
"""A deliberately small byte/character-level BPE implementation."""
def __init__(self, num_merges: int = 50) -> None:
self.num_merges = num_merges
self.merges: list[tuple[str, str]] = []
self.vocab: list[str] = []
self.token_to_id: dict[str, int] = {}
@staticmethod
def _count_pairs(tokens: list[str]) -> Counter[tuple[str, str]]:
return Counter(zip(tokens[:-1], tokens[1:]))
@staticmethod
def _merge(tokens: list[str], pair: tuple[str, str], replacement: str) -> list[str]:
output: list[str] = []
index = 0
while index < len(tokens):
if index + 1 < len(tokens) and (tokens[index], tokens[index + 1]) == pair:
output.append(replacement)
index += 2
else:
output.append(tokens[index])
index += 1
return output
def fit(self, text: str) -> None:
tokens = list(text)
base_vocab = sorted(set(tokens))
for _ in range(self.num_merges):
pairs = self._count_pairs(tokens)
if not pairs:
break
best_pair = max(pairs.items(), key=lambda item: (item[1], item[0]))[0]
replacement = best_pair[0] + best_pair[1]
tokens = self._merge(tokens, best_pair, replacement)
self.merges.append(best_pair)
if replacement not in base_vocab:
base_vocab.append(replacement)
self.vocab = base_vocab
self.token_to_id = {token: index for index, token in enumerate(self.vocab)}
def encode(self, text: str) -> list[str]:
tokens = list(text)
for pair in self.merges:
replacement = pair[0] + pair[1]
tokens = self._merge(tokens, pair, replacement)
return tokens
def encode_ids(self, text: str) -> list[int]:
return [self.token_to_id[token] for token in self.encode(text)]
@property
def vocab_size(self) -> int:
return len(self.vocab)
def main() -> None:
parser = argparse.ArgumentParser()
parser.add_argument("--text-file", type=Path, default=None)
parser.add_argument("--merges", type=int, default=50)
parser.add_argument("--embedding-dim", type=int, default=16)
parser.add_argument("--seed", type=int, default=1337)
args = parser.parse_args()
torch.manual_seed(args.seed)
if args.text_file is not None:
text = args.text_file.read_text(encoding="utf-8")[:50000]
else:
text = (
"The transformer architecture uses self-attention. "
"Self-attention lets each token attend to other tokens. "
"Tokens become vectors, and vectors become context-aware representations. "
"现代大语言模型把文本切成 token,再把 token 映射成向量。"
)
char_tokenizer = CharacterTokenizer()
char_tokenizer.fit(text)
bpe = TinyBPE(num_merges=args.merges)
bpe.fit(text)
char_ids = char_tokenizer.encode(text)
bpe_ids = bpe.encode_ids(text)
compression = len(char_ids) / max(1, len(bpe_ids))
print(f"characters: {len(text)}")
print(f"character vocab size: {char_tokenizer.vocab_size}")
print(f"BPE vocab size: {bpe.vocab_size}")
print(f"character token count: {len(char_ids)}")
print(f"BPE token count: {len(bpe_ids)}")
print(f"compression ratio: {compression:.3f}x")
print(f"first 10 BPE merges: {bpe.merges[:10]}")
embedding = nn.Embedding(bpe.vocab_size, args.embedding_dim)
sample = torch.tensor([bpe_ids[:12]], dtype=torch.long)
vectors = embedding(sample)
print(f"sample ids: {sample.tolist()}")
print(f"embedding output shape: {tuple(vectors.shape)}")
print(f"embedding table shape: {tuple(embedding.weight.shape)}")
if __name__ == "__main__":
main()
输出:
characters: 212
character vocab size: 46
BPE vocab size: 96
character token count: 212
BPE token count: 91
compression ratio: 2.330x
first 10 BPE merges: [('e', 'n'), ('t', 'o'), ('k', 'en'), ('e', 'c'), ('e', ' '), (' ', 'to'), ('s', ' '), ('o', 'n'), ('a', 't'), (' to', 'ken')]
sample ids: [[5, 12, 50, 22, 20, 79, 21, 11, 18, 20, 16, 77]]
embedding output shape: (1, 12, 16)
embedding table shape: (96, 16)
6. 和 Transformer 的关系
Transformer 不直接处理字符串。它处理的是:
\[X=[e_{t_1}, e_{t_2}, \dots, e_{t_n}]\]其中每个 \(e_{t_i}\) 是 embedding 向量。下一章的核心问题是:
每个位置应该如何利用其他位置的信息?
7. 符号与名词详解
| 符号 / 名词 | 详细含义 |
|---|---|
| token | 模型处理文本的基本单位,可以是字符、子词、词或标点。 |
| tokenizer | 把字符串切成 token 序列,并把 token 映射成整数 id 的组件。 |
| vocabulary / 词表 | 所有可表示 token 的集合。词表大小通常记作 vocab_size。 |
| token id | token 在词表中的下标,本身只是整数,不包含语义。 |
| 子词 | 介于字符和完整词之间的单位,例如 transform + er。 |
| BPE | Byte Pair Encoding,从高频相邻符号开始逐步合并的子词算法。 |
| merge | BPE 中一次合并操作,把两个相邻 token 变成一个新 token。 |
| byte-level | 以字节为最小单位,保证任何字符串都可以编码,不出现未登录字符。 |
| one-hot 向量 | 只有一个位置为 1、其余为 0 的离散表示。 |
| \(E\) | embedding 矩阵,形状为 (vocab_size, embedding_dim)。 |
| \(e_t\) | 第 \(t\) 个 token 对应的 embedding 向量。 |
| embedding | 把离散 token id 映射成稠密实数向量的可学习查找表。 |
| embedding dimension | 每个 token 向量的维度,常记作 n_embd 或 d_model。 |
| lookup | 查表操作。embedding[id] 等价于 one-hot 向量乘 embedding 矩阵。 |
| 未登录词 OOV | Out-of-Vocabulary,词表中不存在的词。 |
| 特殊 token | 例如 <pad>、<bos>、<eos>,用于控制、填充或标记边界。 |
| 压缩率 | 字符数除以 token 数,用来衡量 tokenizer 缩短序列的能力。 |
nn.Embedding |
PyTorch 中可学习的 embedding 查找层。 |
| 文本向量化 | 把字符串转换成数值张量的过程。 |