GloVe、FastText 与子词嵌入 本节摘要:Word2Vec 给每个词训练一个向量。GloVe 分解了共现矩阵。FastText 嵌入了「零件」。BPE 则架起了通往 Transformer 的桥。Word2Vec 留下了两个未解的问题:矩阵分解那条平行线是否本质上更优?对从未见过的词该怎么办?GloVe 用精心选择的损失做矩阵分解,匹配甚至超越 Word2Vec 且训练更省。FastText 通过嵌入字符 n-gram 修掉了 OOV——一个词是它各部分之和,即便词表外的词也能得到合理向量。Transformer 到来后,字节对编码(BPE)学一套覆盖一切的高频子词词表,成了所有现代 LLM 分词器的根基。本节从零走完这三条路,再说清何时伸手拿哪一个。
本节摘要:Word2Vec 给每个词训练一个向量。GloVe 分解了共现矩阵。FastText 嵌入了「零件」。BPE 则架起了通往 Transformer 的桥。Word2Vec 留下了两个未解的问题:矩阵分解那条平行线是否本质上更优?对从未见过的词该怎么办?GloVe 用精心选择的损失做矩阵分解,匹配甚至超越 Word2Vec 且训练更省。FastText 通过嵌入字符 n-gram 修掉了 OOV——一个词是它各部分之和,即便词表外的词也能得到合理向量。Transformer 到来后,字节对编码(BPE)学一套覆盖一切的高频子词词表,成了所有现代 LLM 分词器的根基。本节从零走完这三条路,再说清何时伸手拿哪一个。
对应原课程:Phase 5 · Lesson 04 ·
glove-fasttext-subword(原英文phases/05-nlp-foundations-to-advanced/04-glove-fasttext-subword/docs/en.md)。前置依赖:第 03 节(Word2Vec 从零实现)。
阅读完本节,你应当能够:
Word2Vec 留下了三个未解的问题。
第一,有一条平行的研究脉络——直接分解共现矩阵(LSA、HAL),而非做在线 skip-gram 更新。Word2Vec 的迭代法本质上更优,还是差异只是两者处理计数方式不同造成的假象?GloVe 回答了:用精心选择的损失做矩阵分解,匹配甚至超越 Word2Vec,而且训练更省。
第二,两种方法对没见过的词都无话可说。Zoomer-approved、dogecoin、上周才造的任何专有名词、罕见词根的每一种屈折形式。FastText 修掉了这个,通过嵌入字符 n-gram——一个词是它各部分之和,包括语素,所以即便词表外的词也能得到一个合理向量。
第三,Transformer 到来后,问题再次转移。词级词表上限约百万条,真实语言比这开放得多。字节对编码(BPE) 及其同族通过学一套覆盖一切的高频子词词表解决了这个。每一个现代 LLM 的每一个现代分词器,都是子词分词器。
三种思路,各有职责:
X,X[i][j] 是词 j 出现在词 i 上下文里的频次。训练向量,使 v_i · v_j + b_i + b_j ≈ log(X[i][j])。给损失加权,免得高频对一家独大。完工。where 变成 <wh, whe, her, ere, re>, <where>。词向量是这些分量向量之和。像 Word2Vec 那样训练。好处:未见词(whereupon)能从已知 n-gram 组合出来。k 次。结果是一套 k + 256 个 token 的词表,高频序列(ing、tion、the)是单 token,罕见词拆成熟悉的碎片。任何句子都能被分词成「某种东西」。import numpy as np from collections import Counter def build_cooccurrence(docs, window=5): pair_counts = Counter() vocab = {} for doc in docs: for token in doc: if token not in vocab: vocab[token] = len(vocab) for doc in docs: indexed = [vocab[t] for t in doc] for i, center in enumerate(indexed): for j in range(max(0, i - window), min(len(indexed), i + window + 1)): if i != j: distance = abs(i - j) pair_counts[(center, indexed[j])] += 1.0 / distance return vocab, pair_counts def glove_train(vocab, pair_counts, dim=16, epochs=100, lr=0.05, x_max=100, alpha=0.75, seed=0): n = len(vocab) rng = np.random.default_rng(seed) W = rng.normal(0, 0.1, size=(n, dim)) W_tilde = rng.normal(0, 0.1, size=(n, dim)) b = np.zeros(n) b_tilde = np.zeros(n) for epoch in range(epochs): for (i, j), x_ij in pair_counts.items(): weight = (x_ij / x_max) ** alpha if x_ij < x_max else 1.0 diff = W[i] @ W_tilde[j] + b[i] + b_tilde[j] - np.log(x_ij) coef = weight * diff grad_W_i = coef * W_tilde[j] grad_W_tilde_j = coef * W[i] W[i] -= lr * grad_W_i W_tilde[j] -= lr * grad_W_tilde_j b[i] -= lr * coef b_tilde[j] -= lr * coef return W + W_tilde
两个要点值得一说。加权函数 f(x) = (x/x_max)^alpha 压低极频繁对(如 (the, and)),免得它们在损失里一家独大。最终嵌入是 W(中心)与 W_tilde(上下文)两表之和——求和是已发表的小技巧,通常比单用一表更好。
def char_ngrams(word, n_min=3, n_max=6): wrapped = f"<{word}>" grams = {wrapped} for n in range(n_min, n_max + 1): for i in range(len(wrapped) - n + 1): grams.add(wrapped[i:i + n]) return grams
>>> char_ngrams("where") {'<where>', '<wh', 'whe', 'her', 'ere', 're>', '<whe', 'wher', 'here', 'ere>', '<wher', 'where', 'here>'}
每个词用它 n-gram 的集合表示(通常 3 到 6 个字符)。词嵌入是其 n-gram 嵌入之和。做 skip-gram 训练时,把这一套插到 Word2Vec 原本用单一向量的位置。
def fasttext_vector(word, ngram_table): grams = char_ngrams(word) vecs = [ngram_table[g] for g in grams if g in ngram_table] if not vecs: return None return np.sum(vecs, axis=0)
对未见词,只要它的部分 n-gram 是已知的,你仍能得到一个向量。whereupon 与 where 共享 <wh、her、ere、<where,所以两者落在附近。
def learn_bpe(corpus, k_merges): vocab = Counter() for word, freq in corpus.items(): tokens = tuple(word) + ("</w>",) vocab[tokens] = freq merges = [] for _ in range(k_merges): pair_freq = Counter() for tokens, freq in vocab.items(): for a, b in zip(tokens, tokens[1:]): pair_freq[(a, b)] += freq if not pair_freq: break best = pair_freq.most_common(1)[0][0] merges.append(best) new_vocab = Counter() for tokens, freq in vocab.items(): new_tokens = [] i = 0 while i < len(tokens): if i + 1 < len(tokens) and (tokens[i], tokens[i + 1]) == best: new_tokens.append(tokens[i] + tokens[i + 1]) i += 2 else: new_tokens.append(tokens[i]) i += 1 new_vocab[tuple(new_tokens)] = freq vocab = new_vocab return merges def apply_bpe(word, merges): tokens = list(word) + ["</w>"] for a, b in merges: new_tokens = [] i = 0 while i < len(tokens): if i + 1 < len(tokens) and tokens[i] == a and tokens[i + 1] == b: new_tokens.append(a + b) i += 2 else: new_tokens.append(tokens[i]) i += 1 tokens = new_tokens return tokens
>>> corpus = Counter({"low": 5, "lower": 2, "newest": 6, "widest": 3}) >>> merges = learn_bpe(corpus, k_merges=10) >>> apply_bpe("lowest", merges) ['low', 'est</w>']
第一轮合并最频繁的相邻对。足够多轮后,高频子串(low、est、tion)成了单 token,罕见词则干净地拆开。真正的 GPT / BERT / T5 分词器学 3 万到 10 万次合并。结果:任何文本都能分词成一段长度有界的已知 ID 序列,永远没有 OOV。
实践中你很少自己训任何一个,而是加载预训练检查点。
import fasttext.util fasttext.util.download_model("en", if_exists="ignore") ft = fasttext.load_model("cc.en.300.bin") print(ft.get_word_vector("whereupon").shape) print(ft.get_word_vector("zoomerapproved").shape)
Transformer 时代的 BPE 式子词分词:
from transformers import AutoTokenizer tok = AutoTokenizer.from_pretrained("gpt2") print(tok.tokenize("unbelievably tokenized"))
['un', 'bel', 'iev', 'ably', 'Ġtoken', 'ized']
Ġ 前缀标记词边界(GPT-2 的约定)。每个现代分词器都是 BPE 变体、WordPiece(BERT)或 SentencePiece(T5、LLaMA)。
| 场景 | 选什么 |
|---|---|
| 预训练通用词向量,不容忍 OOV | GloVe 300 维 |
| 预训练通用词向量,要处理拼写错误/新词/形态丰富语言 | FastText |
| 任何要进 Transformer 的(训练或推理) | 模型自带的分词器,永不替换 |
| 从零训自己的语言模型 | 先在语料上训一个 BPE 或 SentencePiece 分词器 |
| 生产文本分类配线性模型 | 仍用 TF-IDF(第 02 节) |
保存为 outputs/skill-embeddings-picker.md:
--- name: tokenizer-picker description: Pick a tokenization approach for a new language model or text pipeline. version: 1.0.0 phase: 5 lesson: 04 tags: [nlp, tokenization, embeddings] --- Given a task and dataset description, you output: 1. Tokenization strategy (word-level, BPE, WordPiece, SentencePiece, byte-level). One-sentence reason. 2. Vocabulary size target (e.g., 32k for an English-only LM, 64k-100k for multilingual). 3. Library call with the exact training command. Name the library. Quote the arguments. 4. One reproducibility pitfall. Tokenizer-model mismatch is the single most common silent production bug; call out which pair must be used together. Refuse to recommend training a custom tokenizer when the user is fine-tuning a pretrained LLM. Refuse to recommend word-level tokenization for any model targeting production inference. Flag non-English / multi-script corpora as needing SentencePiece with byte fallback.
char_ngrams("playing") 和 char_ngrams("played"),算两个 n-gram 集合的 Jaccard 重叠。应能看到大量共享碎片(pla、lay、play),这正是 FastText 跨形态变体迁移良好的原因。learn_bpe 追踪词表增长。画出「每语料字符的 token 数」随合并次数的变化曲线,应看到起初快速压缩,渐近到约每 token 2~3 字符。v_i·v_j + b_i + b_j ≈ log(X[i][j]),加权函数压低高频对,两表求和作最终嵌入。(x/x_max)^alpha 让 (the, and) 这类极频繁对不主导损失。playing/played 落得近。下一节,我们把向量喂给分类器,进入「情感分析」——看正负情绪如何从词袋一路演进到 Transformer。