Series · NLP · Chapter 2

自然语言处理(二):词向量与语言模型

深入理解Word2Vec、GloVe和FastText如何将词语转化为捕获语义的向量。掌握数学原理,用Gensim训练自己的词嵌入,理解嵌入与语言模型的关系。

很长一段时间里,机器眼中的"国王"和"王后"只是词表里两个不同的编号,彼此之间不存在任何可计算的关系。直到一个想法改变了这一切:让每个词都住进一个连续的向量空间,让语义沿着方向自然浮现。一旦接受了这个想法,模型就能算出

$$\vec{\text{king}} - \vec{\text{man}} + \vec{\text{woman}} \approx \vec{\text{queen}}$$

NLP 的整个走向也从此转向"表示学习"。本文沿着这条主线展开:先看清独热编码为什么走不通,再走过 Word2Vec 的浅层网络、GloVe 利用的全局统计、FastText 用字符 n-gram 解决未登录词的思路,最后把词嵌入和真正催生它们的语言模型连起来。

你将学到什么

  • 为什么独热编码不可行,稠密嵌入是如何解决问题的
  • Skip-gram 与 CBOW:从局部上下文学习的两种方式
  • 负采样:让大词表训练真正可行的关键技巧
  • GloVe:从全局共现统计中学习
  • FastText:用子词处理罕见词与未登录词
  • 词嵌入与语言模型的关系,以及神经语言模型为什么能"越大越好"
  • 如何用 Gensim 训练、评估和可视化词嵌入

前置知识:第 1 部分(文本预处理基础),基础线性代数(点积、矩阵乘法),以及对 softmax 与随机梯度下降的基本概念。


从稀疏到稠密:为什么需要词嵌入

独热编码的死胡同

设词表大小为 $V$,独热编码把每个词映射成一个 $V$ 维指示向量:

$$\text{cat} = [1, 0, 0, 0, \ldots], \quad \text{dog} = [0, 1, 0, 0, \ldots]$$

这种表示有三个致命问题:

  • 极度稀疏。当 $V = 50{,}000$ 时,每个向量 99.998% 是零,存储与计算几乎都浪费在空位上。
  • 没有相似度。任意两个不同独热向量的点积恒为零,模型根本无从知道"猫"比"量子"更接近"狗"——在它眼中,所有不同的词都是"等距"的。
  • 无法泛化。线性分类器对独热输入的每个类别都需要 $V$ 个独立权重。即使学到了"movie"是正面情感,对"film"也毫无帮助。

嵌入的解法

词嵌入把每个词映射到一个 $d \ll V$ 维(通常 100–300 维)的稠密向量:

$$\text{cat} = [0.21, -0.34, 0.78, \ldots], \quad \text{dog} = [0.18, -0.29, 0.71, \ldots]$$

此时 $\text{cat} \cdot \text{dog} \gg \text{cat} \cdot \text{quantum}$,参数在语义相近的词之间共享,下游模型所需的标注数据量也随之骤降一两个数量级。剩下的问题是:这些数字到底从哪里来?

分布式假设

本文涉及的所有方法都建立在语言学家 J. R. Firth 的一句话上:“一个词的含义由它的上下文决定。” 通俗地说,经常出现在相似上下文中的词,往往含义相近。

  • “The cat sat on the mat” vs. “The dog sat on the mat”
  • “The king ruled the kingdom” vs. “The queen ruled the kingdom”

如果我们让任何一个模型去预测词的上下文(或反过来),那么能完成这个任务的参数必然吸收了大量分布式规律——词嵌入就是这个预测任务的副产物

把这个假设当真的回报是巨大的:训练好的词嵌入会把"语义关系"编码成空间里几乎恒定的方向,这正是经典类比算术能成立的原因。

词嵌入把语义关系编码成空间中近似恒定的方向


Word2Vec:从局部上下文学习

Word2Vec(Mikolov 等,2013)是第一个能在数十亿 token 上廉价训练出高质量词嵌入的方法。它有两种结构——Skip-gramCBOW——都是单隐藏层、无非线性的神经网络,简单到极致正是它的精髓。

Skip-gram:从目标词预测上下文

给定一个目标词,预测窗口内的每一个上下文词。例如句子 “the quick brown fox jumps”,窗口为 2 时,目标词 “brown” 会产生 4 个正样本对:

(brown, the)   (brown, quick)   (brown, fox)   (brown, jumps)

网络由三层构成:独热输入、嵌入查表矩阵 $W \in \mathbb{R}^{V \times d}$、输出矩阵 $W' \in \mathbb{R}^{d \times V}$ 加 softmax。由于输入是独热向量,嵌入层退化为一次行查找,是常数级开销。

Skip-gram 架构:独热输入、嵌入查表、对整个词表做 softmax

训练目标是真实上下文词的对数似然平均:

$$J = \frac{1}{T}\sum_{t=1}^{T} \sum_{-m \le j \le m,\, j \neq 0} \log P(w_{t+j} \mid w_t)$$

其中

$$P(c \mid w) = \frac{\exp(\mathbf{v}_w^\top \mathbf{v}'_c)}{\sum_{i=1}^{V} \exp(\mathbf{v}_w^\top \mathbf{v}'_i)}.$$

注意一个常被忽略的细节:$\mathbf{v}_w$ 来自输入矩阵 $W$(“目标词嵌入”),而 $\mathbf{v}'_c$ 来自输出矩阵 $W'$(“上下文嵌入”)。多数管线训练完只保留 $W$,而 GloVe 后面会告诉我们:把两者相加效果更好。

为什么有效:如果"猫"和"狗"在上下文中都频繁预测出"坐"、“垫子”、“跑”,那么梯度下降就必须把 $\mathbf{v}_{\text{猫}}$ 与 $\mathbf{v}_{\text{狗}}$ 推向相似方向,否则它们无法产生相似的输出分布。“分布相似性"被强制变成了"几何相似性”。

CBOW:从上下文预测目标词

CBOW 把 Skip-gram 翻了个面:先把上下文词嵌入取平均,再用这个平均向量预测中心词:

$$\mathbf{h} = \frac{1}{2m} \sum_{j=-m,\, j \neq 0}^{m} \mathbf{v}_{w_{t+j}}, \qquad P(w_t \mid \text{上下文}) = \mathrm{softmax}(W'\mathbf{h}).$$

实践差异:

  • Skip-gram 每个中心词产生 $2m$ 个训练样本,相当于罕见词被"训练"了 $2m$ 次,因此在低频词上表现更好;代价是更慢。
  • CBOW 通过平均"平滑"了上下文,训练更快,对高频词的嵌入往往略好。

在 Web 量级语料上做通用嵌入,业界默认是"Skip-gram + 负采样"。

Softmax 瓶颈

无论 Skip-gram 还是 CBOW,都被同一面墙挡住:softmax 分母要在整个词表上求和。当 $V = 100{,}000$、训练样本上十亿时,每一步梯度的代价是 $O(Vd)$,根本跑不完。Word2Vec 的第一招就是——干脆不算这个 softmax。

负采样:让一切跑得动

负采样把"在 $V$ 个词中找出真正的上下文"这个多分类问题,换成了一个简单得多的二分类问题:“我给你一个 (词,上下文) 对,它是真的还是我编出来的?” 对每个正样本对 $(w, c)$,从噪声分布 $P_n$ 中采 $k$ 个"负样本"词,最小化:

$$J = \log \sigma(\mathbf{v}_w^\top \mathbf{v}'_c) + \sum_{i=1}^{k} \mathbb{E}_{n_i \sim P_n}\!\left[\log \sigma(-\mathbf{v}_w^\top \mathbf{v}'_{n_i})\right]$$

其中 $\sigma$ 是 sigmoid。几何上:梯度把目标词与真实上下文拉近,把目标词与 $k$ 个无关词推远。

负采样:把 1 个正样本拉近,把 k 个随机负样本推开

两个工程上影响很大的细节:

  • 噪声分布。Word2Vec 从 $P_n(w) \propto f(w)^{0.75}$ 采样,$f(w)$ 是词的语料频率。这个 0.75 指数会"压平"分布:如果直接用 $f(w)$,“the” 被采为负样本的概率大约是 “zebra” 的 100 倍,梯度全被无信息的高频词淹没。加上 0.75,罕见词作为负样本的频率提升,每一步的信息量更高。
  • 速度提升。取 $k = 5$–$15$,每步代价从 $O(Vd)$ 降到 $O((k+1)d)$。对 $V = 10^5$、$k = 10$ 这种典型设置,加速大约四个数量级——这正是 Word2Vec 能在单机上吞下数十亿 token 的根本原因。

另一个常被一同启用的技巧是频率下采样:训练时按概率丢弃一部分高频词。负采样 + 下采样这两个看似工程化的小动作,让 Word2Vec 的训练时间从"周"压缩到"小时"。


GloVe:全局矩阵分解

Word2Vec 永远只看局部窗口,看不到语料的全局结构。GloVe(Pennington 等,2014)走的是另一条路:先把整个"词-词共现矩阵"建出来,再做一次低秩分解。

为什么是共现"比值"?

考虑两个目标词 “ice” 与 “steam”,以及若干探测词的共现概率:

探测词$P(\text{word} \mid \text{ice})$$P(\text{word} \mid \text{steam})$比值
solid$\gg 1$
gas$\ll 1$
water$\approx 1$
fashion$\approx 1$

单看概率本身,里面混杂了两个信号:探测词与目标词的语义相关性,以及探测词的整体频率。比值 把第二个信号约掉,只留下纯粹的相关性。GloVe 的核心主张是:词嵌入应当直接编码这种比值。

GloVe 的目标函数

设 $X_{ij}$ 表示词 $j$ 出现在词 $i$ 上下文中的次数。GloVe 寻找词向量 $\mathbf{w}_i$ 与上下文向量 $\tilde{\mathbf{w}}_j$(外加偏置)满足:

$$\mathbf{w}_i^\top \tilde{\mathbf{w}}_j + b_i + \tilde{b}_j \;\approx\; \log X_{ij}.$$

完整损失是对数共现的加权最小二乘回归:

$$J = \sum_{i,j=1}^{V} f(X_{ij}) \left(\mathbf{w}_i^\top \tilde{\mathbf{w}}_j + b_i + \tilde{b}_j - \log X_{ij}\right)^2,$$

其中

$$f(x) = \begin{cases} (x/x_{\max})^\alpha & \text{if } x < x_{\max} \\ 1 & \text{otherwise} \end{cases}$$

(论文里 $x_{\max} = 100$,$\alpha = 0.75$。)这个权重函数同时做了两件事:限制极高频对(避免 “the the” 主导损失),以及压低罕见对(它们的计数本身就很嘈杂)。

整个过程本质上就是一次低秩分解:把一个 $V \times V$ 的对数共现矩阵,用一个 $V \times d$ 的词矩阵乘上一个 $d \times V$ 的上下文矩阵来近似。

GloVe 把对数共现矩阵分解为词向量矩阵与上下文向量矩阵的乘积

官方发布的 GloVe 向量取的是 $\mathbf{w}_i + \tilde{\mathbf{w}}_i$——把输入向量与上下文向量相加,实测略优于只取其中之一。

GloVe vs. Word2Vec

对比维度Word2VecGloVe
看待语料的方式局部窗口(在线)全局共现矩阵
优化方式负采样 + SGD加权最小二乘(实践中用 AdaGrad)
内存占用低(不存全局统计)共现矩阵可能很大
优势流式数据;小语料更稳健类比任务略有优势;结果可复现

实际效果两者相当,选择往往取决于:你的语言/领域有没有现成的预训练向量集。


FastText:子词嵌入

Word2Vec 与 GloVe 都是"一个词一个向量",这意味着它们在两类常见场景下会失效:训练时没见过的词,以及形态丰富的语言(同一个词根衍生出几十种形态)。FastText(Bojanowski 等,2017)用一招同时解决两个问题:把每个词表示成字符 n-gram 的集合

工作原理

对单词 “where”,FastText 在两端加上边界符 <>,然后枚举所有 3–6 长度的字符 n-gram,外加完整词作为一个特殊 token:

  • 3-gram:<whwhehererere>
  • 4-gram:<whewherhereere>
  • 5-gram、6-gram:依此类推
  • 完整词:<where>

每个 n-gram 都有自己的嵌入 $\mathbf{z}_g$,词嵌入定义为它们的求和:

$$\mathbf{v}_w = \sum_{g \in G(w)} \mathbf{z}_g$$

训练目标和 Skip-gram + 负采样一致——只是把"目标词嵌入"替换成了"该词所有 n-gram 嵌入之和"。

FastText:每个词的向量是其字符 n-gram 嵌入之和,由此天然支持未登录词

子词的好处

  • 未登录词。推理时遇到从未训练过的 “wherever” 完全不慌:它和 “where”、“ever” 共享 whehereere 等 n-gram,求和结果依然合理。
  • 形态丰富的语言。土耳其语、德语、芬兰语里,一个名词可能有几十种屈折形态,FastText 通过共享 n-gram 在它们之间传递参数,罕见形态也能从常见形态那里"继承"信息。
  • 错别字。打错的 “teh” 仍然与 “the” 共享多个 n-gram,落点大致正确。

代价是模型更大(要为每个 n-gram 存嵌入,而不是每个词),训练略慢。在词表固定的英文上提升有限;在形态丰富的语言上提升显著。

场景推荐 FastText推荐 Word2Vec / GloVe
形态丰富的语言(德/土/芬)
含大量错别字与口语的 UGC 文本
推理时需要处理未登录词
词表干净固定的英文都可是(模型更小)
内存极其紧张

把结果"看"出来

如果嵌入真的捕获到了分布式语义,那么把它们投影到二维平面后,理应能看到清晰的语义聚类——尽管投影算法本身对"词义"一无所知。对几百个训练好的向量做 t-SNE 或 UMAP,结果通常恰如所料:动物挤在一起,国家自成一片,皇室相关词汇独占一角。

t-SNE 投影:同一语义场的词自然聚成一团

这是工程上极好用的诊断手段。如果你训练出来的嵌入对一组明显相关的词都聚不起来,那么数据、分词或超参一定有问题——不必等下游评估告诉你。


语言模型与词嵌入

语言模型给一段词序列分配概率。要训练得好,必须能在"相似上下文"之间共享统计强度——而这正是嵌入提供的能力。

N-gram 语言模型

经典 n-gram 模型用计数估计下一个词的分布:

$$P(w_t \mid w_{t-n+1}, \ldots, w_{t-1}) = \frac{\text{count}(w_{t-n+1}, \ldots, w_t)}{\text{count}(w_{t-n+1}, \ldots, w_{t-1})}.$$

问题是数据稀疏。词表 5 万的 4-gram 模型有 $50{,}000^4 \approx 6 \times 10^{18}$ 种可能上下文,绝大多数从未在任何语料中出现过。Kneser-Ney、Witten-Bell、改良回退等几十年的平滑技巧逐步缓解了这个问题,但它们无法在"语义相似的上下文"之间共享信息——“the cat ate” 和 “the kitten ate” 在 n-gram 看来是两个完全独立的桶。

神经语言模型

Bengio 等人 2003 年提出的神经语言模型一招破局:用一个参数化函数取代计数,而这个函数的第一层就是词嵌入查表。

$$\mathbf{h} = \tanh\left(W_h \cdot [\mathbf{v}_{w_{t-n+1}}; \ldots; \mathbf{v}_{w_{t-1}}] + \mathbf{b}_h\right), \quad P(w_t \mid \text{上下文}) = \mathrm{softmax}(W_o \mathbf{h} + \mathbf{b}_o).$$

由于 $\mathbf{v}_{\text{cat}} \approx \mathbf{v}_{\text{kitten}}$,“the cat ate” 与 “the kitten ate” 会产生几乎相同的隐藏状态,模型自然能泛化到训练中没见过的组合。

实证结果非常戏剧性。随着语料增长,n-gram 的困惑度很快趋于平稳——计数再也撑不住更多提升;神经语言模型则一路下降,Transformer 语言模型下降得更快:

困惑度 vs. 语料规模:n-gram 趋于平稳,神经/Transformer 语言模型持续改进

这就是关键论点:正是词嵌入让语言模型能"越大越好"。没有嵌入,每一个新上下文都是一个待估计的新桶;有了嵌入,每一个新上下文都能从模型已经学好的几何结构里"借力"。

Word2Vec 是"压扁"的语言模型

回过头看,Word2Vec 本质上就是被剥到只剩骨架的神经语言模型:

  • Skip-gram 对应"独立预测目标词周围每一个词"的语言模型。
  • CBOW 对应"用一袋上下文词预测中心词"的语言模型。

那些简化——浅层网络、无非线性、忽略词序、用负采样替代完整 softmax——全是为了同一个目标:跑得足够快,看得足够多,让几何结构在足够多的数据上沉淀下来。完整的语言模型在它的上一层。

静态嵌入 vs. 上下文嵌入(预告)

Word2Vec、GloVe 和 FastText 产生的都是静态嵌入——“bank” 无论出现在 “river bank” 还是 “bank account” 中都是同一个向量。这是它们的根本局限。下一波模型——ELMo、BERT、GPT——产生的是上下文嵌入:每一次出现都会得到一个不同的向量。我们将在第 5、第 6 部分详细展开。


用 Gensim 实战训练

安装

1
pip install gensim numpy matplotlib scikit-learn

训练 Word2Vec

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
from gensim.models import Word2Vec
from gensim.utils import simple_preprocess

sentences = [
    "the cat sat on the mat",
    "the dog sat on the log",
    "cats and dogs are animals",
    "the quick brown fox jumps over the lazy dog",
    "a cat and a dog are playing in the garden",
]
tokenized = [simple_preprocess(s) for s in sentences]

# Skip-gram + 负采样
model = Word2Vec(
    sentences=tokenized,
    vector_size=100,   # 嵌入维度 d
    window=5,          # 上下文窗口半径 m
    min_count=1,       # 词频阈值
    sg=1,              # 1 = Skip-gram, 0 = CBOW
    negative=5,        # 每个正样本配 k 个负样本
    ns_exponent=0.75,  # 经典的 0.75 平滑指数
    epochs=100,
    workers=4,
    seed=42,
)

print(f"shape: {model.wv['cat'].shape}")            # (100,)
print(model.wv.most_similar('cat', topn=3))
print(f"cat-dog similarity: {model.wv.similarity('cat', 'dog'):.4f}")

注意:上面这个玩具语料远小到训不出可用的嵌入,把它当 API 演示就好。真正想训练,至少要喂入数千万 token 量级的语料;更划算的做法是直接加载预训练向量。

训练 FastText

Gensim 故意把 API 设计得几乎一致:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
from gensim.models import FastText

model_ft = FastText(
    sentences=tokenized,
    vector_size=100,
    window=5,
    min_count=1,
    sg=1,
    min_n=3,           # 最小字符 n-gram 长度
    max_n=6,           # 最大字符 n-gram 长度
    epochs=100,
    seed=42,
)

# 即使 'kitty' 从未在语料里出现,FastText 也能给出一个向量
print(model_ft.wv['kitty'].shape)

加载预训练嵌入

生产环境下,在数十亿 token 上训练好的预训练向量,几乎总是优于你在小语料上自己训的:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
import gensim.downloader as api

# Google News Word2Vec(300 万词,300 维)
w2v = api.load('word2vec-google-news-300')

# Wikipedia + Gigaword 上训练的 GloVe(40 万词,300 维)
glove = api.load('glove-wiki-gigaword-300')

# 带子词的 FastText(100 万词,300 维)
fasttext = api.load('fasttext-wiki-news-subwords-300')

print(w2v.most_similar('computer', topn=5))

测试类比推理

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
def analogy(model, a, b, c):
    """求解 a : b :: c : ?"""
    try:
        result = model.most_similar(positive=[b, c], negative=[a], topn=1)
        return result[0][0]
    except KeyError:
        return "OOV"

print(analogy(w2v, 'man', 'woman', 'king'))     # -> queen
print(analogy(w2v, 'France', 'Paris', 'Italy')) # -> Rome

用 t-SNE 可视化

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
import numpy as np
import matplotlib.pyplot as plt
from sklearn.manifold import TSNE

words = list(w2v.index_to_key[:200])
vectors = np.array([w2v[w] for w in words])

projection = TSNE(n_components=2, random_state=42, perplexity=20).fit_transform(vectors)

plt.figure(figsize=(12, 8))
plt.scatter(projection[:, 0], projection[:, 1], s=12, alpha=0.6)
for i, word in enumerate(words):
    plt.annotate(word, projection[i], fontsize=8)
plt.title('Word embeddings (t-SNE)')
plt.tight_layout()
plt.savefig('embeddings_tsne.png', dpi=150)

评估词嵌入

拿到一组向量,下面三类评估能告诉你它们到底好不好。

内在评估 1:类比任务

Google Analogy Dataset 收录了 19,544 个形如 “Athens 之于 Greece,如同 Beijing 之于?” 的题目。在十亿词量级的语料上训练的 300 维 Word2Vec 或 GloVe,top-1 准确率通常在 60–80% 之间。这是一个有效的健全性检查,但要注意类比准确率对数据集选择比较敏感。

内在评估 2:词相似度

WordSim-353、SimLex-999 等数据集收集了人类对词对相似度的打分。计算嵌入下每对词的余弦相似度,再与人类评分做 Spearman 秩相关 $\rho$。质量过关的嵌入通常能达到 $\rho \approx 0.6$–$0.8$。

外在评估:下游任务

最终决定一切的指标:嵌入有没有让真实任务(情感分类、NER、检索…)变得更好?标注数据越少,预训练嵌入带来的收益越大,常见区间是 2–10 个百分点。

一分钟健全性检查

在跑任何评估之前,先手动看看几个词的最近邻。“cat” 应该挨着 “dog”、“kitten”、“feline”,最多再混点 “pet”。如果排在最前面的是 “the”、“and”、“is”——这是没启用高频词下采样的典型症状——别等指标告诉你,问题已经出现了。


嵌入维度怎么选

维度 $d$适用场景
50 – 100小语料(< 100 万 token),简单下游模型
100 – 300中等语料,通用嵌入;实战中的甜点区
300 – 1000大语料(> 10 亿 token),对质量要求极高的应用

边际收益下降很快:从 50 升到 100 提升明显,从 300 升到 600 对大多数任务几乎看不出差别。建议先用 100 或 300,必要时再依据下游评估调整。


核心要点

  • 嵌入编码了分布式语义。共享上下文的词共享几何邻居,而这种邻居结构正是下游模型泛化能力的来源。
  • Word2Vec、GloVe、FastText 是同一问题的三种回答。Word2Vec 扫描局部窗口;GloVe 分解全局共现矩阵;FastText 把词拆成字符 n-gram。三条路径殊途同归,得到的嵌入质量相当。
  • 负采样让大词表训练真正可行:用对 $k$ 个随机负样本的二分类替代完整 softmax,配合 $f(w)^{0.75}$ 的频率平滑。
  • 嵌入让语言模型能"越大越好"。没有它,n-gram 的计数随语料增长越来越无力;有了它,神经语言模型能源源不断地从已经学到的几何结构中借力。
  • 静态嵌入有硬上限:“bank” 在"河岸"和"账户"两种语境下永远是同一个向量。下两轮浪潮——ELMo、BERT、GPT(第 5、6 部分)——会用上下文相关的向量取而代之。

词嵌入打开了神经 NLP 的大门。一旦词可以像真实向量那样相加、相减、聚类,从 RNN 到 Transformer 的所有后续架构才有了可能。下一篇,我们沿着这条线进入序列建模。


系列导航

部分主题链接
1NLP入门与文本预处理上一篇
2词向量与语言模型(本文)
3RNN与序列建模下一篇
4注意力机制与Transformer阅读
5BERT与预训练模型阅读
6GPT与生成式语言模型阅读

Liked this piece?

Follow on GitHub for the next one — usually one a week.

GitHub