3. Tokenization与Embedding:分词器原理、词嵌入与位置编码的推理时行为
好,咱们进入第三章。说实话,很多同学学Transformer,注意力机制背得滚瓜烂熟,但一问他分词器怎么工作的,立马卡壳。我当年刚入行时也犯过这毛病——模型跑通了,但输入稍微变个格式就崩。后来才明白,Tokenizer和Embedding才是模型理解世界的入口。
3.1 分词器:把文本切成模型能吃的块
模型不认识单词,只认识数字。分词器干的事,说白了就是把文本变成一串整数ID。但怎么切,学问大了。
3.1.1 BPE(Byte Pair Encoding)——最主流的分词算法
BPE是目前大模型最常用的分词方法。它的核心思想很简单:从字符开始,逐步合并最常见的字符对。
举个例子:
初始词汇表:['l', 'o', 'w', 'e', 'r', 'n', 'w', 'a', 'd']
统计频率最高的相邻对:('l','o') 出现2次 → 合并为 'lo'
新词汇表:['lo', 'w', 'e', 'r', 'n', 'w', 'a', 'd']
继续合并:('lo','w') 出现2次 → 合并为 'low'
... 直到达到预设的词汇表大小
我在项目中遇到过一个问题:用BPE分词时,同一个词在不同上下文可能被切成不同的子词。比如“playing”可能被切成“play”+“ing”,也可能被切成“playing”本身——这取决于训练时的合并频率。嗯,这里要注意,推理时的分词行为必须和训练时完全一致,否则ID序列就乱了。
3.1.2 特殊Token的处理
分词器里有一类特殊的Token,比如[CLS]、[SEP]、[PAD]、[UNK]。推理时,这些Token的行为需要特别注意:
- [CLS]:通常放在序列开头,用于分类任务。推理时一定要加,位置不能错。
- [SEP]:分隔句子。如果你做的是单句推理,也得加一个在末尾——很多新手会漏掉。
- [PAD]:填充Token。推理时如果batch size > 1,短句需要padding到相同长度。但注意,padding的位置会影响注意力掩码。
- [UNK]:未登录词。如果输入里有词汇表里没有的字符,就会被映射成[UNK]。我曾经踩过坑:用户输入了一个emoji,分词器直接变成了[UNK],模型输出完全跑偏。
3.2 词嵌入:把离散ID变成连续向量
分词器输出的是整数ID,比如[101, 2057, 2003, 102]。但模型内部处理的是浮点数向量。词嵌入层(Embedding Layer)就是干这个的——把每个ID映射成一个固定维度的稠密向量。
3.2.1 嵌入矩阵的推理时行为
词嵌入本质上是一个查找表。假设词汇表大小是V,嵌入维度是d,那么嵌入矩阵的形状就是[V, d]。推理时,输入ID为i,就直接取矩阵的第i行:
# 伪代码
embedding_matrix = nn.Embedding(vocab_size, d_model)
input_ids = [101, 2057, 2003, 102]
embeddings = embedding_matrix(input_ids) # 形状: [4, d_model]
这里有个细节:推理时嵌入矩阵是冻结的,不会更新。但如果你用的是预训练模型,嵌入矩阵里已经包含了丰富的语义信息——比如“king”和“queen”的向量距离很近,而“king”和“apple”距离很远。
3.3 位置编码:告诉模型词与词的相对位置
Transformer没有循环结构,它天生不知道词的顺序。你想想看,如果把“我打你”和“你打我”的Token序列调换顺序,模型看到的嵌入向量完全一样——这显然不行。位置编码就是来解决这个问题的。
3.3.1 绝对位置编码 vs 相对位置编码
| 类型 | 代表模型 | 推理时行为 | 优缺点 |
|---|---|---|---|
| 绝对位置编码 | 原始Transformer、BERT | 每个位置有固定的编码向量,直接加到词嵌入上 | 简单直观,但无法处理超过训练时最大长度的序列 |
| 相对位置编码 | GPT-2、T5、RoBERTa | 在注意力计算时动态引入位置偏移信息 | 可以外推到更长的序列,但实现更复杂 |
我最早用BERT做文本分类时,一直没搞明白位置编码到底怎么工作的。后来自己手写了一个简化版,才真正理解:
# 正弦位置编码(原始Transformer用)
def sinusoidal_position_encoding(max_len, d_model):
pe = torch.zeros(max_len, d_model)
position = torch.arange(0, max_len).unsqueeze(1)
div_term = torch.exp(torch.arange(0, d_model, 2) *
-(math.log(10000.0) / d_model))
pe[:, 0::2] = torch.sin(position * div_term)
pe[:, 1::2] = torch.cos(position * div_term)
return pe # 形状: [max_len, d_model]
推理时,位置编码要么是预计算好的固定矩阵(绝对位置编码),要么是在注意力计算时动态生成(相对位置编码)。前者简单,后者灵活。
3.3.2 推理时的长度外推问题
这是个大坑。绝对位置编码的模型,训练时最大长度是512,推理时输入超过512个Token怎么办?
- 直接截断:丢掉后面的Token。简单粗暴,但会丢失信息。
- 位置编码插值:把位置编码向量“拉伸”到更长序列。比如把[0, 511]的编码映射到[0, 1023]。我在项目中试过,效果不稳定。
- 改用相对位置编码的模型:比如GPT-2、LLaMA,它们天然支持更长的推理序列。
3.4 推理时的完整流程
把上面三个步骤串起来,推理时的Tokenization与Embedding流程就是:
输入文本: "我今天很开心"
↓
分词器(BPE): ["我", "今天", "很", "开心"]
↓
映射到ID: [101, 2769, 1518, 1962, 102] # 假设[CLS]和[SEP]
↓
词嵌入层: 每个ID查表得到d维向量
↓
加上位置编码: 每个位置的向量加上对应的位置编码
↓
输出: [seq_len, d_model] 的浮点数矩阵 → 送入Transformer层
这里有个容易被忽略的点:词嵌入和位置编码是相加还是拼接? 原始Transformer是相加,但有些变体(比如ALBERT)是拼接。推理时一定要确认你的模型用的是哪种方式。
3.5 本章小结
Tokenization和Embedding,看似简单,但推理时的每一个细节都可能影响最终结果。总结几个我踩过的坑:
- 分词器版本必须固定,否则ID序列会变
- 特殊Token的位置不能错,[CLS]和[SEP]该加就得加
- 位置编码决定了最大长度,长文本推理要选对模型
- 嵌入矩阵的范数分布可以作为数据质量的快速检查手段
下一章我们会深入注意力机制的推理时行为——那才是Transformer真正“烧脑”的地方。