3. 词汇表构建:从语料到令牌映射的第一步

词汇表是令牌化系统的心脏。没有它,你手里的文本就是一盘散沙。我最早做工业级 NLP 项目时,以为随便拿个词频表就能用——结果模型训练到一半报错说 " not found in vocabulary",整个管道直接炸了。从那以后我明白:词汇表构建,不能偷懒。

说白了,这一步就是把语料库里的原始文本,变成有序的、带特殊标记的令牌集合。我们不光要记录「哪些词出现了」,还得决定「哪些词值得保留」「哪些词需要归并」「哪些词必须拥有专用 ID」。让我们从头捋一遍流程。

3.1 语料收集与预处理

先说说语料。你得准备好至少一个大型文本集合,比如维基百科 dump、新闻语料、业务日志等。我习惯先做几件事:

  • 统一编码:全部转为 UTF-8,避免混合编码导致乱码
  • 去除噪音:比如 HTML 标签、URL(除非你专门建模 URL)
  • 大小写规整:大多数任务会转小写(英文)
  • 分词:中文用 jieba/BERT 自带的分词器,英文用空格 + .split()
我的小建议:数据量不够别硬撑。少于 100 万 tokens 的语料,构建出的词汇表覆盖度很有限。我试过用 50 万 tokens 去训一个 subword 模型,结果 [UNK] 占比超过 5%,训练损失根本下不去。

预处理后的结果是一系列 列表。举个例,假设我们有 3 条样本:

corpus = [
    "我 爱 自然 语言 处理",
    "深度学习 与 神经网络 是 热门",
    "Python 是 编程 语言"
]

3.2 频率统计与阈值截断

统计每个 出现的次数,这是最朴素也最必要的步骤。你可能会想:「全都保留不就得了?」——不行。原因有两个:

  1. 稀有词是噪声源:只出现一两次的词,模型根本学不到可靠表征,反而拉低 embedding 质量。
  2. 词汇表过大:softmax 层的参数量 = vocab_size × hidden_dim,太多 会让模型臃肿得不行。

在我的项目里,通常设一个 最小频率阈值,比如 10。低于这个数就归入 [UNK]。如果语料规模很大(上亿 tokens),阈值可以提到 50 甚至 100。怎么定?我习惯先跑一次全量统计,画一个词频累积曲线——看看低于阈值会丢弃多少 、影响多少语料覆盖率。

曾经踩过的坑:有一次我设了阈值 5,结果丢掉了一堆拼音缩写(比如 "AI", "API"),因为这些词在小说语料里出现次数少。后来我加了一条规则:长度 ≤ 4 的 即使频率低也保留,因为短 往往是功能词或术语。

下面是一个简化的统计与截断实现:

from collections import Counter

def build_vocab(corpus, min_freq=10):
    counter = Counter()
    for sentence in corpus:
        tokens = sentence.split()
        counter.update(tokens)
    
    # 过滤低频 
    vocab = {: idx for idx, (, freq) in enumerate(counter.items()) if freq >= min_freq}
    return vocab

# 示例
raw_vocab = build_vocab(corpus, min_freq=1)  # 不截断
print(len(raw_vocab))  # 输出 13(所有词都保留)

filtered_vocab = build_vocab(corpus, min_freq=2)  # 只保留出现≥2次的词
print(filtered_vocab)  # 只保留 "语言"(出现2次)

3.3 特殊令牌 — 为什么我们需要它们?

原始词汇表只有你语料里的词,但模型还需要一些特殊标记来辅助任务。这些令牌每个都有一个固定的 ID,不会从统计中产生。

令牌 解释 谁在用
[UNK] 未知词。遇到不在词汇表中的词时,用它替换 几乎所有 NLP 模型
[CLS] 分类令牌。BERT 系列用它作为序列表示的聚合输出 BERT、RoBERTa、ALBERT
[SEP] 分隔令牌。区分两个句子(比如问答、NLI) BERT 及变体
[PAD] 填充令牌。让 batch 内序列长度统一 几乎所有 Transformer
[MASK] 掩码令牌。MLM 预训练时遮盖部分 BERT、RoBERTa

这些特殊令牌必须放在词汇表的开头位置(ID 0, 1, 2…),这样 embedding 矩阵的对应行就固定了。有些框架要求 [PAD] 必须是 ID 0,因为注意力掩码默认忽略 0。我个人的习惯是:

SPECIAL_TOKENS = {
    "[PAD]": 0,
    "[UNK]": 1,
    "[CLS]": 2,
    "[SEP]": 3,
    "[MASK]": 4,
}
# 后续的常规  从 ID 5 开始

3.4 最终词汇表结构

组合在一起,完整的词汇表就是一个 令牌 → ID 的映射字典。除此之外,我们通常还要保留反向映射(ID → 令牌),方便解码。

核心原则:词汇表一旦构建并开始训练,就不能再随意增删。否则之前训练好的 embedding 权重就对不上了。所以我在正式训练前,一定会做一次覆盖率验证:拿开发集跑一边,看看 [UNK] 占比是否超过 1%。超过的话就得调整阈值。

下面这张图展示了从原始语料到最终词汇表的完整流程:

词汇表构建流程 语料文本 预处理 + 分词 频率统计 (Counter) 阈值截断 (≥10) 插入特殊令牌 完整词汇表 {→id} 注意:特殊令牌占据固定 ID(通常 0-4),常规 ID 从 5 开始。 最终词汇表还包含反向映射 id2token,用于解码输出序列。

3.5 实战避坑与最佳实践

最后总结几条我亲手趟过的坑:

  • 不要忘记 [PAD] 放 ID 0:有些实现把 [UNK] 放 0,[PAD] 放 1。结果注意力掩码默认把 0 当作填充,导致 [UNK] 被完全忽略,模型学不到未知词的处理。我吃过这个亏,训练了两个 epoch 才发现 loss 不对劲。
  • 特殊令牌的数量要一致:如果你在其他任务里用了 [SOS]、[EOS],记得在词汇表里也加上。千万不要预训练一套特殊令牌,微调时又加新的——之前预训练的 embedding 就白练了。
  • 对中文进行子词分词后再构建词汇表:直接对切好词的中文建 vocab,词汇量会很大(常用汉字 3500+,但分词后 数量会少很多)。如果你用 BPE 或 WordPiece,词汇表控制在 30K-50K 比较合适。

嗯,关于词汇表构建就聊这么多。你只要记住一个核心:词汇表是沟通文本和数字的唯一桥梁。桥没搭好,后面的模型再花哨也没用。