模型看见世界之前,先由 tokenizer 决定世界如何被切开
项目目标
前两项任务处理同一个问题:文字如何进入模型。
第一步是 tokenizer。它把原始文本切成 token,并把 token 映射成整数 ID。第二步是 embedding。它把整数 ID 映射成可学习的向量。前者决定“世界被切成什么符号”,后者决定“这些符号在模型内部如何表示”。
本站 mini-gpt/02-把文字变成数字 已经实现了字符级 tokenizer。本篇把它升级成项目清单:先实现字符级,再实现 BPE,再对比 WordPiece、Unigram、SentencePiece、byte-level BPE 的设计取舍。
任务 A:实现字符级 tokenizer
最小版本只需要四件事:
chars = sorted(set(text))
stoi = {ch: i for i, ch in enumerate(chars)}
itos = {i: ch for ch, i in stoi.items()}
def encode(s: str) -> list[int]:
return [stoi[ch] for ch in s]
def decode(ids: list[int]) -> str:
return "".join(itos[i] for i in ids)
这版 tokenizer 足够用于 tiny GPT,因为它简单、透明、不会有未知词。但它也有明显缺点:序列长,中文还好,英文单词会被拆得很碎,模型需要花很多步才能学到常见词片段。
需要记录的指标:
| 指标 | 含义 |
|---|---|
vocab_size | 词表大小,决定 embedding table 和输出层宽度 |
| 平均字符/token 比 | 越接近 1,说明越细粒度 |
| 同一语料 token 总数 | 决定训练序列长度和计算量 |
| OOV 数量 | 字符级通常没有 OOV,词级容易出现 |
任务 B:实现一个最小 BPE
BPE 的核心思想是:从最小符号开始,不断合并最常出现的相邻 pair。
可以按这个流程写:
- 把文本先切成字符或 byte。
- 统计所有相邻 pair 的频次。
- 找到最高频 pair,例如
("t", "h")。 - 把所有这个 pair 合并为一个新 token,例如
"th"。 - 重复直到达到目标词表大小。
伪代码如下:
def get_stats(tokens):
counts = {}
for word in tokens:
for pair in zip(word, word[1:]):
counts[pair] = counts.get(pair, 0) + 1
return counts
def merge(tokens, pair, new_token):
out = []
for word in tokens:
merged = []
i = 0
while i < len(word):
if i < len(word) - 1 and (word[i], word[i + 1]) == pair:
merged.append(new_token)
i += 2
else:
merged.append(word[i])
i += 1
out.append(merged)
return out
这个实现不追求工业级速度,目的是理解:词表不是随便列出来的,它是数据频率、压缩效率、泛化能力之间的折中。
任务 C:做 tokenizer visualizer
tokenizer 会直接影响 prompt 成本、上下文长度、跨语言表现和难以排查的 bug。所以应该做一个小可视化工具,而不是只打印 ID。
最小 visualizer 可以显示:
- 原始文本
- token 列表
- token ID
- 每个 token 对应的字符跨度
- token 数 / 字符数比例
- 不同 tokenizer 的切分对比
对比样例建议包含:
Hello world
今天天气真好
我喜欢 retrieval-augmented generation
def hello_world(): print("你好")
尤其要看 CJK、代码、emoji、混合中英文。真实系统里,tokenizer 对这些输入的处理经常比模型本身更早暴露问题。
任务 D:从 one-hot 到 embedding
token ID 本身没有语义。42 和 43 只是两个编号,不表示它们相似或相邻。embedding table 的作用,是把每个 ID 映射成一行可学习向量。
import torch.nn as nn
token_embedding = nn.Embedding(vocab_size, n_embd)
x = token_embedding(idx) # (B, T) -> (B, T, C)
要做的实验:
- 训练前随机抽几个 token 的 embedding,计算 cosine similarity。
- 训练一个小 next-token 模型。
- 训练后再次计算 similarity。
- 看常一起出现的 token 是否更接近。
这不是为了证明 embedding 一定会形成“理想化语义空间”,而是为了观察:向量是被目标函数塑形的。next-token objective 会把有预测关系的 token 拉到某些结构里。
故意破坏实验
必须做几组破坏实验。
第一组:把词表缩得很小。观察 token 序列变长,训练变慢,长词被拆碎。
第二组:把词表放得很大。观察 embedding 参数量变大,低频 token 学不充分。
第三组:训练时用一个 tokenizer,推理时换另一个 tokenizer。输出通常会立刻变坏,因为 ID 与向量行的对应关系被破坏。
第四组:删除或错误处理 special tokens。观察 padding、eos、bos 泄漏到输出中的情况。
本项目交付物
- 一个字符级 tokenizer。
- 一个最小 BPE trainer。
- 一个 tokenizer visualizer。
- 一张不同 tokenizer 的 token 数对比表。
- 一张词表大小与序列长度/embedding 参数量的对比图。
- 一篇短复盘:我现在如何理解 tokenizer 对成本、上下文和质量的影响。
和本站已有内容的连接
mini-gpt/02-把文字变成数字:字符级 tokenizer 主线。ai-for-python/番外03-Token秘密:面向应用开发的 token 概念。ai-for-python/番外11-从One-Hot到LLM:从离散编号到向量表示。llm-app/Embedding嵌入向量:应用层 embedding 与模型内部 embedding 的区别。
延伸阅读
版权声明: 如无特别声明,本文版权归 sshipanoo 所有,转载请注明本文链接。
(采用 CC BY-NC-SA 4.0 许可协议进行授权)
本文标题:项目 1-2:Tokenizer、词表与 Embedding
本文链接:https://www.sshipanoo.com/blog/ai/llm-roadmap/02-Tokenizer与Embedding项目/
本文最后一次更新为 天前,文章中的某些内容可能已过时!