模型看见世界之前,先由 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。

可以按这个流程写:

  1. 把文本先切成字符或 byte。
  2. 统计所有相邻 pair 的频次。
  3. 找到最高频 pair,例如 ("t", "h")
  4. 把所有这个 pair 合并为一个新 token,例如 "th"
  5. 重复直到达到目标词表大小。

伪代码如下:

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 本身没有语义。4243 只是两个编号,不表示它们相似或相邻。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)

要做的实验:

  1. 训练前随机抽几个 token 的 embedding,计算 cosine similarity。
  2. 训练一个小 next-token 模型。
  3. 训练后再次计算 similarity。
  4. 看常一起出现的 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项目/

本文最后一次更新为 天前,文章中的某些内容可能已过时!