词嵌入——word2vec 与真正有含义的向量

词嵌入用一个简短的可学习数值向量表示一个词。Word2vec 的训练任务足够简单,适合逐步观察这些数值如何从上下文窗口和梯度更新中形成结构。

常见类比 king − man + woman ≈ queen 展示了一种可能的向量关系。下图使用手工放置的二维点,并非实际测量的 word2vec 向量。偏移量精确相等是绘图设计的结果;真实类比只是近似关系,取决于语料、模型和比较方法。

点击 step 按钮,一步步看这个构造过程。

Schematic: hand-placed 2D points illustrate vector arithmetic.

mankingwomanv(man), v(king)v(woman)

训练得到的坐标来自优化,而不是预先命名的语义轴。某些关系可以表现为有用的方向,但不存在适用于所有词的通用“王室”坐标或偏移量。

One-hot 和 TF-IDF 为每个词表项分配独立坐标。稠密嵌入则使用更少的一组共享维度。两种表示都有用途;嵌入增加了一种经过学习的比较方式,不再只看词是否完全相同。

Word2vec于 2013 年提出,通过浅层预测模型提高了大规模词向量训练的效率。它建立在此前分布式表示的基础上,并非学习词向量这一思想的起点。

Skip-gram 根据中心词预测上下文词,CBOW 根据上下文预测中心词。用法相似的词提供相似的预测信息,因此可能形成相关表示。这是一种统计训练效果,并不保证每种语义关系都成为整齐的向量偏移。

从假设到几何

分布假设将相似上下文与相关含义或语法角色联系起来。这是有用线索,而不是意义的完整定义:像 hot 和 cold 这样的反义词也可能共享大量上下文。

较小的嵌入维度限制了分数矩阵的秩,促进共享预测结构,但不会强迫词使用相同坐标。实际上,对于可逆矩阵 A,将 E 替换为 E A、E' 替换为 A⁻¹ E' 会保留所有点积分数,却可能改变输入向量之间的距离和夹角。

“嵌入”有三种值得区分的用法:

静态词嵌入让一个词在不同上下文中使用相同向量。上下文嵌入,例如 BERT 的隐藏状态,取决于周围词元。句子或段落嵌入将较长文本汇总为便于比较或检索的表示,通常还经过额外的对比训练。这些表示不一定使用相同训练目标。

自回归 Transformer 也从词元嵌入查表开始,通常使用子词词元。后续隐藏状态依赖先前上下文。对这些状态进行池化并不会自动得到优质检索向量,这需要另行设计。

本文从共现计数讲到 skip-gram 和 CBOW,然后说明表示变为上下文相关时发生了什么变化。

分布假说

早在 1957 年,语言学家 J.R. Firth 就写下了那句名言:「观其伴,知其词。」这就是全部想法。出现在相似上下文中的词有相似的含义。要看出为什么,请看三句各缺一个词的话:

  • ___ 冲着邮递员叫。
  • ___ 在我腿上打呼噜。
  • ___ 冬天飞去了南方。

你不需要知道缺失的词,就知道它们指的是不同种类的动物。上下文——空缺周围的词——缩小了什么能填进去。这是这个想法的一半:上下文预测词。

观察许多句子后,可以发现 dog、puppy 和 hound 共享有用的上下文模式。预测模型可以复用处理这些模式的参数。共享上下文可能表示相似,也可能表示主题关联或语法兼容。

问题在于如何高效地算出来。可以追溯到 1990 年代初的经典答案,是非常大的稀疏共现矩阵——潜在语义分析及其同类。对句子「the cat sat on the mat」,取 2 词窗口(在每个中心词左右各看至多 2 个 token),矩阵长这样:

                the     cat     sat     on     mat
the         [    0,      1,      2,      1,     1 ]
cat         [    1,      0,      1,      1,     0 ]
sat         [    2,      1,      0,      1,     0 ]
on          [    1,      1,      1,      0,     1 ]
mat         [    1,      0,      0,      1,     0 ]

M[i,j] 统计词 j 在词 i 的窗口中出现的次数。排除的是中心位置,不是所有相同词的出现。这个例子的对角线恰好为零;窗口内重复出现的词可以产生非零对角线元素。

这个矩阵是通过在语料上滑动窗口并计数建起来的。

window half-width:(5 words wide)
accumulated matrix · 2 of 18 co-occurrences tallied
the
cat
sat
on
mat
the
0
1
1
0
0
cat
0
0
0
0
0
sat
0
0
0
0
0
on
0
0
0
0
0
mat
0
0
0
0
0

在每个以词 i 为中心的位置,看它 ±W 窗口内的词,对窗口中的每个邻居 j,把 M[i][j] 加一。把小组件从头拖到尾,矩阵就会一格一格填满——扫一遍语料,所有共现计数都被记录下来。

这是「先计数再分解」这条流水线的第一步,其上还有两步。词向量的形状在每个阶段都发生显著变化:

阶段 1——原始计数。 就是上面那个矩阵。每一行有 V 个数(简称「V 长」),词表中每个词一个槽位——这已经是这个词的向量了,只是大得离谱。每个条目字面意思是「在整个语料中,这个特定的词表词在该词的 ±2 窗口内出现了多少次」。cat 那一行是 [1, 0, 1, 1, 0]。这个玩具语料只有六个 token,所以数字很小,但真正要紧的是它在规模上的结构。

阶段 2:频率校正。 PMI 将共现概率与边缘概率之积进行比较。未观测词对的原始 PMI 未定义或为负无穷,因此实际矩阵常采用正 PMI:PPMI = max(PMI, 0),并将未观测项设为零。平滑和加权方式会影响结果。

阶段 3:压缩。 截断 SVD 使用最大的奇异值及对应奇异向量近似加权矩阵。常用的词向量表为 U_d Σ_d。这是低秩近似,并不自动等同于 PCA:PCA 会先将数据中心化,而这里的矩阵分解不一定如此。

raw cat counts:     [1, 0, 1, 1, 0]
PPMI (rounded):     [0.182, 0, 0.405, 0.405, 0]
SVD representation: a row of U_d @ Sigma_d

得到的 d 个坐标综合了许多上下文词的信息。单独坐标不一定对应“王室”或“性别”这样的可命名属性。

计数矩阵分解和 word2vec 都会生成稠密向量表,但优化不同目标。与平移 PMI 的联系具体适用于特定假设下的负采样 skip-gram,并不表示所有 word2vec 版本都等价于 SVD。负采样文章会推导这一联系。

词与上下文矩阵有 V×V 个可能条目,但稀疏存储无需为每个零分配空间。大型语料上的计数和分解仍可能昂贵。Word2vec 提供了另一条路径:直接根据训练样本更新向量,无需构建这张矩阵。

word2vec

Word2vec 通过预测局部窗口中的词来学习向量表。我们先用完整 softmax 模型明确展示前向传播和梯度,再区分实际实现采用的更低成本目标。

查表本身会让一个词无论处于哪个句子都得到相同向量。下游序列模型仍然可以结合这些向量、词序和上下文。因此,静态嵌入是起始表示,不是完整的句子模型。

Word2vec 提供两种可选的训练架构:

  • Skip-gram。 给定一个中心词,预测它周围小窗口内的词。我们把中心词作为输入喂进去,逐一预测周围的每个上下文窗口词——每个 (中心, 上下文) 对都是一个独立的训练样本,所以同一个中心词会按邻居数量被复用多次。
  • CBOW(continuous bag of words,连续词袋)。反过来:给定窗口内的词,预测中心词。我们把一组上下文词喂进去,预测中间那一个词。这种「填空」的形状,正是后来 BERT 的掩码语言建模所继承并放大的。

两者做的是同一件事,只是预测方向相反。我们会把 skip-gram 从头到尾走一遍,然后再回到 CBOW。

训练数据与独热输入

整条 skip-gram 流水线干净地分成两个阶段,边界分明:预处理把原始文本变成一串训练样本,然后训练把这些样本送过神经网络。

先看预处理——把原始文本变成一长串整数对。这里还没有任何神经网络参与:

  1. 对语料分词——把文本切成词 token 列表(按空白切分,通常转小写;word2vec 用的是词级 token,而非子词片段)。
  2. 构建词表——给每个不同的 token 分配一个整数 ID;这个 ID 之后既作为该 token 在独热向量中的索引,也作为它在嵌入矩阵中的行号。总数记作 V(词表大小)。
  3. 抽取 (中心, 上下文) 对——在 ID 流上滑动窗口,每个邻居产出一个训练样本。

本例选择中心词左右各两个词元作为窗口。窗口大小是超参数;实际实现还可能随机选择较小的有效半径,并对高频词进行下采样。

以句子「the cat sat on the mat」为例,我们把窗口设为 ±2,一个词一个词地滑。窗口以 sat 为中心时,邻居是 the、cat、on、the——这一个位置就产出四对:(sat, the)、(sat, cat)、(sat, on)、(sat, the)。前进到 on,窗口找到 cat、sat、the、mat——又是四对。再走一步,再来四对,如此下去,直到整个语料坍缩成一长串 (中心, 上下文) 对,全部由文本自身生成,没有任何人标注过什么。

skip-gram windowing · slide the window over a passage, collect the training examples
window half-width:(5 words wide)
The green word is the center. The blue words are its neighbours inside the window. Every center → neighbour pairing is one training example. Step the window across the text (the controls below, or click any word) and they pile up into the training set.
pairs from this center
(the → cat)(the → sat)
training set so far — 2 of 18 pairs
the→catthe→sat

下面的简短 Python 循环即可生成这些整数索引对:

# Step 1: tokenize.
corpus = "the cat sat on the mat"
tokens = corpus.split()
# ['the', 'cat', 'sat', 'on', 'the', 'mat']

# Step 2: build vocabulary and convert tokens to integer IDs.
vocab = sorted(set(tokens))                       # ['cat', 'mat', 'on', 'sat', 'the']
word2id = {w: i for i, w in enumerate(vocab)}     # {'cat': 0, 'mat': 1, 'on': 2, 'sat': 3, 'the': 4}
V = len(vocab)                                    # 5
ids = [word2id[w] for w in tokens]                # [4, 0, 3, 2, 4, 1]

# Step 3: slide a ±2 window over the ID stream, emit (center, context) pairs.
window = 2
pairs = []
for i, c in enumerate(ids):
    for j in range(max(0, i - window), min(len(ids), i + window + 1)):
        if i != j:
            pairs.append((c, ids[j]))

len(pairs)  # 18 — exactly the (center_id, context_id) pairs the widget above emits.

无论你接下来训练哪个 word2vec 变体,预处理本质上都一样——分词、词表、开窗完全相同。不同的只是产出样本的格式:skip-gram 把它们打包成对,而 CBOW 每个窗口产出一个上下文袋加上它的中心词。算法真正的所在是训练,本节余下部分会详细走一遍。

每一对都是一个训练样本

预处理完成,我们来看训练算法如何使用这些对。

每个词对提供一个输入词和一个目标标签。sat 周围的窗口产生四个样本,目标为 the、cat、on、the:共有三个不同词,其中 the 出现两次。在完整 softmax 模型中,目标用于损失计算,而不参与前向评分步骤。

这与 MNIST 类似:每个 MNIST 样本把一张图像和它的数字标签配成一对,而这里每个 skip-gram 对 (c, t) 把中心词 c(输入)与一个上下文词 t(目标)配成一对。对 (sat, cat):喂进 sat,拿回一个覆盖词表的预测分布,与 cat 比对,走一步 SGD。然后是下一对。

在把任何东西喂进网络之前,我们得把词表示成一个数字向量。 在 MNIST 里这一步基本是白送的——图像本身就是一格一格的像素强度,我们只要把它拉平成 784 个数的向量。 词没有内在的数值内容,所以我们在上面构建词表那一步发明了一个——每个词已经有了一个从 0 到 V−1 的整数索引。要把它喂进网络,我们把这个索引展开成一个独热 V 维向量——长度为 V 的向量,除了该词索引处的一个 1,其余全为零。

对我们这个 5 词的示例词表(V=5),编码如下:

"cat" → [1, 0, 0, 0, 0]
"mat" → [0, 1, 0, 0, 0]
"on"  → [0, 0, 1, 0, 0]
"sat" → [0, 0, 0, 1, 0]
"the" → [0, 0, 0, 0, 1]

幸好,有一个巧妙的线性代数事实,让我们根本不必构造独热向量——独热向量乘以一个矩阵,等同于挑出该矩阵的某一行(按词的整数 ID 查表)。对我们这个 5 词词表,sat 在索引 3,某个形状为 (5, d) 的矩阵 M:

       one-hot for "sat"            M  (5 rows × 3 cols)            result
[ 0  0  0  1  0 ]    ·    [ row 0:  0.21  -0.43   0.15 ]    =    [ 0.33  -0.27   0.84 ]
                          [ row 1:  0.07   0.62  -0.31 ]              (just row 3)
                          [ row 2: -0.55   0.18   0.40 ]
                          [ row 3:  0.33  -0.27   0.84 ]
                          [ row 4: -0.12   0.49  -0.06 ]

凡是碰到独热向量中 0 的项都消失了,只剩下第 3 行的贡献——所以答案就是 M 的第 3 行。

# dot product, column by column:
  col 0:  0·0.21  + 0·0.07  + 0·(-0.55) + 1·0.33  + 0·(-0.12)  =  0.33
  col 1:  0·(-0.43) + 0·0.62 + 0·0.18   + 1·(-0.27) + 0·0.49   = -0.27
  col 2:  0·0.15  + 0·(-0.31) + 0·0.40  + 1·0.84  + 0·(-0.06)  =  0.84

心智模型:我们究竟要做什么

对于每个 (center, target) 词对,比较目标词与其他词表候选的分数,再调整两张向量表以减小预测损失。

训练使用未经归一化的点积,而不是余弦相似度。余弦相似度将点积除以两个向量的范数,常用于训练后的向量比较。Softmax 将训练分数转换为概率分布,同时保留其排序。

我们会有两个可训练的权重矩阵——各自位于网络的一对层之间——它们合起来给词表中每个词两个 d 维向量(每种角色一个嵌入):

  • E,形状 (V, d)——每一行是某个词的输入嵌入,当该词作为训练对的中心(被条件化的那个词)时使用。
  • E',形状 (d, V)——每一列是某个词的输出嵌入,当该词作为被预测的目标/上下文(被当作候选来打分的那个词)时使用。

E 和 E' 是独立参数矩阵,并非彼此的转置。不同布局只是将输入向量放在行中、输出向量放在列中。每个词针对两种角色各有一个可学习向量。

两张独立表让词可以分别承担输入和输出角色。训练后通常使用 E,也可以采用输出向量或组合两张表。哪种表示有用取决于下游任务。

下面的小组件把这一步做实——在各维度上计算中心词与词表中每个词的相似度,也就是 E 与 E' 的互动。我们聚焦在 sat 作为中心(在 E 中以绿色高亮)。逐步走一遍,看矩阵乘法如何把它与词表中的每个词打分——每个词一次点积,一格一格地填满分数向量。(只有阶段 1 和 2——softmax 与损失要再过两节才来。)

1 of 5 scores computed
E (V=5 × d=3)
row sat = v_c
cat
0.21
-0.43
0.15
mat
0.07
0.62
-0.31
on
-0.55
0.18
0.40
sat
0.33
-0.27
0.84
the
-0.12
0.49
-0.06
E' (d=3 × V=5)
one column per word — v_c is dotted with each in turn
cat
mat
on
sat
the
0.45
-0.31
0.18
-0.22
0.07
0.62
0.15
-0.40
0.33
0.55
-0.20
0.48
0.27
0.11
-0.39
scores (V=5)
cat
mat
on
sat
the
-0.19
·
·
·
·
dot product for cat
score[cat] = E[sat, :] · E'[:, cat]
= 0.33 · 0.45 + (-0.27) · 0.62 + 0.84 · (-0.20)
= 0.149 + (-0.167) + (-0.168)
= -0.187

分数为 v_c · v'_t。输出向量按列存储时,scores = v_c @ E' 一次计算整个词表的分数;按 Ep 的行存储时,等价表达式是 scores = Ep @ v_c。两种布局都无需 Python 循环。

与分类器输出层类似,每个候选项都有一个权重向量,用于给隐藏表示评分。嵌入维度是联合学习的,通常不对应可单独命名的语义特征。

架构

一旦收集好了这些对,我们本质上就在跑一个有监督的带标签预测任务——给定中心词,预测词表中哪个词会出现在附近——所以这个设置在关键处与 MNIST 相似:一个隐藏层、在输出类上做 softmax、对着标签算交叉熵。不同的是规模(这里是词表大小 V,MNIST 是 10 个数字类)、输入格式(独热 vs 稠密实值像素)以及目标(我们要的是训练好的嵌入,而不是预测本身)。

从架构上看,word2vec(2013)是一个两层前馈神经网络——由两个全连接层(两个权重矩阵 E 和 E')构成的前馈网络,数据沿输入 → 隐藏 → 输出流动,没有回路。它和 MNIST 分类器同属一个家族,只有一处转折:隐藏层是纯线性的,所以它学的是词的编码,而不是充当非线性特征提取器。

具体来说,输入是中心词的 V 维独热向量;隐藏层有 d 个神经元(例如 300),且是纯线性的(无偏置、无非线性);输出有 V 个神经元,在全部 V 上做 softmax,得到 P(w | c)——给定中心词时每个词表词的概率。上一节引入的两个矩阵 E 和 E' 就住在这些层之间:E 是输入 → 隐藏的权重矩阵(形状 (V, d)),E' 是隐藏 → 输出的那个(形状 (d, V))。它们是网络仅有的可学习参数;两者都从随机开始,训练之后只有 E 作为最终的词嵌入表交付——E' 被丢弃。

下面用前文的小词表,在 Keras 中实现完整 softmax 架构:

from tensorflow import keras
from tensorflow.keras import layers

V = len(vocab)  # five words from the example above
d = 3

model = keras.Sequential([
    keras.Input(shape=(1,), dtype='int32'),         # integer ID of the center word
    layers.Embedding(input_dim=V, output_dim=d),    # E:  shape (V, d), the lookup
    layers.Reshape((d,)),                           # (1, d) → (d,)
    layers.Dense(V, use_bias=False),                # E': shape (d, V), the linear layer
    layers.Softmax(),                               # softmax over V vocab scores
])

model.compile(optimizer='sgd', loss='sparse_categorical_crossentropy')

Embedding(V, d) 查找输入向量,Dense(V, use_bias=False) 存储输出权重。输入形状为 (batch, 1),嵌入输出因此是 (batch, 1, d)。Reshape 在评分前移除长度为一的序列轴。两层可学习层之间没有非线性激活。

Softmax 把 V 个原始分数变成词表上的概率分布,sparse_categorical_crossentropy 是其上的标准分类损失——与 MNIST 用的是同一套 softmax 加交叉熵,只是把 10 个数字类换成了 V 个词表类。损失及其梯度我们会在后面几节详细走一遍。

嵌入维度 d 是控制容量、内存和计算量的超参数。几百维很常见,但合适的大小取决于语料和任务,而非仅由词表大小决定。

我们刚刚描述的是基础版前向传播——输出端在整个词表上做 softmax。

前向传播——查表、打分、softmax

既然我们已经从高层讲过架构——输入独热、隐藏层查表、输出分数——现在放大来看,一个训练样本流过网络时到底发生了什么。对一个中心词 c,前向传播从左到右分三个阶段:查表、打分、softmax。

心智模型那一节里的小组件已经孤立地展示了前两个阶段——独热乘矩阵坍缩为一次行读取,然后行乘矩阵产出 V 个分数。这里我们给它们命名,在上面加上 softmax,并把数字从头跟到尾。

阶段 1:输入 × E → 隐藏(查表)。 数学上这是矩阵乘法 one_hot(c) @ E,产出一个 d 维隐藏向量。输入是稀疏的——V−1 个条目为零——所以几乎每次乘法都算成零,整个 (V, d) 的矩阵乘法坍缩为一次行读取:v_c = E[c]。

阶段 2:隐藏向量 × E’ → 词表分数。 scores = v_c @ E' 为每个输出词计算一次点积,成本为 O(Vd)。反向传播可以为所有输出列和选中的输入行提供梯度。

矩阵乘法从不看目标词。它只用 v_c = E[sat],把 sat 与整个词表打分,一次性产出全部 V 个分数。所以这五个分数——cat -0.19、mat 0.26、on 0.39、sat -0.07、the -0.45——对每一个共享中心词 sat 的训练对都完全相同:(sat, cat)、(sat, on) 和 (sat, the) 跑的是同一个矩阵乘法,落到同样这五个数上。目标词只在后面、在损失那里才登场;前向传播从未见过它。

阶段 3:softmax → 概率。 这 V 个分数——称为 logits——是任意实数:可以为负、无上界、加起来也不等于什么特定的值。softmax 把它们变成 P(w | c)——V 个非负数、总和为 1,即模型预测词 w 出现在 c 上下文中的概率。

在同样的 5 词词表上,下面的小组件走完全部三个阶段。挑一个 (center, context) 训练对,然后按 step 一次一个点积地填满分数向量;等 V 个分数都到齐,softmax 把它们变成概率。

training pair
E (V=5 × d=3)stage 1 · lookup
row sat = v_c
cat
0.21
-0.43
0.15
mat
0.07
0.62
-0.31
on
-0.55
0.18
0.40
sat
0.33
-0.27
0.84
the
-0.12
0.49
-0.06
E' (d=3 × V=5)stage 2 · score
column cat = v'_w
cat
mat
on
sat
the
0.45
-0.31
0.18
-0.22
0.07
0.62
0.15
-0.40
0.33
0.55
-0.20
0.48
0.27
0.11
-0.39
scores (V=5)
cat
mat
on
sat
the
-0.19
0.26
0.39
-0.07
-0.45
P (V=5)stage 3 · softmax
cat
mat
on
sat
the
0.16
0.25
0.29
0.18
0.12
sum = 1.00

在这个手工选择的数值示例中,on 得分最高,约为 0.39。这些数值用于说明计算过程,并不证明这个小模型已经学会了句子。

在 skip-gram 中,这些分数就是对每个词表词 w 的点积 v_c · E'[:, w],而得到的概率就是 P(w | c)。把上面小组件里的 softmax 那一步单独拿出来,五个分数 [-0.19, 0.26, 0.39, -0.07, -0.45] 变成了一个总和为 1 的正规概率分布:

softmax · the operation that turns scores into a probability distribution
1. raw scores sw (any real number — these are the V scores from the matmul above)
cat
-0.19
mat
0.26
on
0.39
sat
-0.07
the
-0.45
2. apply exp: esw (all positive; large scores blow up, negative scores shrink toward 0)
cat
0.83
mat
1.30
on
1.48
sat
0.93
the
0.64
3. divide by the sum: esw / Σ es (probabilities — non-negative, sum to 1)
cat
0.160
mat
0.251
on
0.286
sat
0.180
the
0.123

Softmax 对分数取指数,再除以总和。实现时会先减去最大分数以保证数值稳定,这不会改变概率。输出相加为一,但归一化本身并不保证预测概率经过良好校准。

损失函数

对目标词为 t 的单个训练样本,损失是模型分配给该目标的负对数概率:

loss = −log P(t | c)

这给出每个训练对 (c, t) 一个数——当 softmax 把概率堆到真实目标上时它很小,没堆上去时它很大。这就是带独热标签的交叉熵——MNIST 用的是同一个损失,穿过 softmax 的梯度也在那里推导。

具体地,对 (sat, cat) 这一对,用上面的分数和概率:

                  cat    mat    on    sat    the
scores        = [-0.19,  0.26,  0.39, -0.07, -0.45]
probabilities = [ 0.16,  0.25,  0.29,  0.18,  0.12]
target        = cat
P(cat | sat)  = 0.16
loss          = −log(0.16) ≈ 1.83

# what-ifs — how the loss responds to different P(cat):
P(cat) = 0.90  →  loss = −log(0.90) ≈ 0.11   (good prediction)
P(cat) = 0.01  →  loss = −log(0.01) ≈ 4.6    (bad prediction)

下面的小组件把上面那五个 softmax 概率搬过来,画在 −log 曲线上。点击不同的词,把它指定为目标——标记会沿曲线滑动,你能直接看到:模型本来就偏好的目标几乎不花代价,而被它低估的目标要付出陡峭的代价。

loss · how a single probability becomes a single number
plug P(cat) into −log: large probability → tiny loss, tiny probability → huge loss
01234500.250.50.751P(target)loss = −log Puniform · (1/V, log V)(0.16, 1.83)
loss = −log P(cat) = −log(0.16) = 1.83

目标概率接近零时,损失会增大。但经过 softmax 求导后,损失对 logit 的梯度为 P(w) − 1[w=t],位于 −1 到 1 之间。参数梯度还取决于参与计算的向量。

五个词上的均匀分布为任意目标分配 0.20 的概率,损失为 log(5) ≈ 1.61。示例中 cat 的概率约为 0.16,所以损失更高。均匀输出是有用的参考,并非每次随机初始化的必然结果。

梯度

梯度与 MNIST 类似:损失对每个 logit 的梯度是 P(w) − 𝟙[w == t]——预测概率减去独热目标。目标词的梯度是 P(t) − 1(负数——把它的分数往上推);其他每个词的是 P(w)(正数——把它的分数往下压,正比于它当前拿了多少概率)。模型本就正确认为不太可能的词几乎不动;它判断错了的词得到最多信号。

下面的小组件把这个减法在同样五个概率上做实。点击不同的目标,可以看到梯度那一行重绘——一根高高的蓝柱把目标的分数往上拉,四根短短的红柱把其余的往下压。

gradient · subtracting the one-hot target from the softmax distribution
pick a target word — the model is trying to push P(cat) toward 1
∂loss / ∂score[w] = P(w) − 𝟙[w == t]
P(w)
probability the softmax assigned
cat
0.16
mat
0.25
on
0.29
sat
0.18
the
0.12
−
𝟙[w == t]
one-hot for the true target word
cat
1.00
mat
0.00
on
0.00
sat
0.00
the
0.00
=
gradient
positive → SGD pushes score down · negative → SGD pulls score up
cat
-0.84
mat
0.25
on
0.29
sat
0.18
the
0.12

反向传播按链式法则把这些分数梯度带进 E' 和 E:

scores = v_c @ E'                       (the forward step we're differentiating)

∂loss / ∂E'[:, w]  =  ( P(w) − 𝟙[w == t] ) · v_c       ← gradient on column w of E'
∂loss / ∂v_c       =  E' @ ( P − one_hot_t )           ← gradient into the hidden vector
∂loss / ∂E[c]      =  ∂loss / ∂v_c                     ← because v_c = E[c]

有两个后果值得记住,都源自独热输入:

查表只会向 E[c] 行传递梯度;其他输入行在这个样本上的梯度为零。完整 softmax 涉及 E' 的所有列。这些梯度描述点积分数,并不保证所有成对距离都按“拉近或推远”的比喻变化。

随后这些梯度被用来执行一次梯度下降步:以 E -= lr × ∂L/∂E 和 E' -= lr × ∂L/∂E' 施加到权重上。这是优化器的活儿,而优化器的选择(原味 SGD、动量、Adam、RMSprop、AdaGrad……)只在这一步才有影响——它们消费的是同样的梯度,只是用法不同。

小批量与轮次

上面的推演一次处理一对。大多数神经网络训练把这一点推广成小批量 SGD——把样本分成大小为 B 的批,一次前向 + 反向传播处理一整批,与 MNIST 用的是完全相同的小批量 SGD,只是每步在 B 个样本上取平均。这就是 BERT、GPT 以及几乎每个现代模型所用的配方,也是下面几条要点所描述的。

原始 word2vec 实现和 Gensim 使用经过优化的 CPU 训练,并由工作线程异步更新参数。下方完整 softmax 的小批量描述是通用参考模型,不是 Gensim 内部循环的具体实现。

一个 epoch 表示遍历一次训练语料或预先准备的样本。如果实现会重新采样窗口或丢弃高频词元,那么不同 epoch 不一定包含完全相同的词对。

按批来看,循环长这样:

  1. 在 B 个样本的一批上做前向传播——每个 (c, t) 对流过网络。向量化之后,查表把 B 行从 E 堆成一个 (B, d) 矩阵,打分步变成一次 (B, d) @ (d, V) → (B, V) 的矩阵乘法,softmax 按行运行。
  2. 损失——逐样本计算交叉熵损失(公式与 B=1 相同),然后在批上取平均得到一个标量。
  3. 反向传播(backprop)。 按链式法则计算梯度——从损失一路回到每个权重的纯微积分,此时还不更新权重。输出:梯度张量 ∂L/∂E 与 ∂L/∂E',形状与权重张量相同。
  4. 梯度下降步——通过优化器把梯度施加到权重上,与上面单对的情形完全一样。然后处理下一批。

在每轮的许多批上跑这个循环,再在语料上跑上几轮,E 和 E' 的行就会沉降到损失所偏好的几何中。

CBOW——通往 BERT 的桥

CBOW(continuous bag of words,连续词袋)是 word2vec 的第二个算法,作为 skip-gram 的镜像来运行。skip-gram 取一个中心词、预测一个作为其上下文的目标词,而 CBOW 取若干词作为上下文、预测中心词——那个缺失的词。

CBOW 和 BERT 的掩码语言建模都根据周围上下文预测词或词元,但表示方式不同:CBOW 对小型无序上下文词袋取平均,BERT 则通过 Transformer 构建包含位置信息的上下文状态。BERT 并不只是扩大窗口的 CBOW。

机制上,CBOW 与 skip-gram 的差别恰好是输入端的一次平均;训练循环的其余部分完全相同。所以上面关于 skip-gram 的大部分内容原样适用——这里我们只聚焦 CBOW 不同的地方。

预处理——上下文袋而非词对

预处理流水线与 skip-gram 相同:分词、建词表、在 token 流上滑窗。变的是每个窗口位置所产出内容的形状:

Position centered on:    Skip-gram emits (per position):       CBOW emits (per position):
─────────────────────    ──────────────────────────────       ──────────────────────────
the      (pos 0)         (the, cat), (the, sat)                ({cat, sat}, the)
cat      (pos 1)         (cat, the), (cat, sat), (cat, on)     ({the, sat, on}, cat)
sat      (pos 2)         (sat, the), (sat, cat),               ({the, cat, on, the}, sat)
                         (sat, on), (sat, the)
on       (pos 3)         (on, cat), (on, sat),                 ({cat, sat, the, mat}, on)
                         (on, the), (on, mat)
the      (pos 4)         (the, sat), (the, on), (the, mat)     ({sat, on, mat}, the)
mat      (pos 5)         (mat, on), (mat, the)                 ({on, the}, mat)
                         ─────────────────────                  ────────────────
                         total: 18 pairs                         total: 6 examples

同样的语料、同样的窗口——skip-gram 产出 18 个独立的 (中心, 邻居) 训练对,CBOW 产出 6 个 (上下文袋, 中心) 样本。在小组件里一步步看:

CBOW windowing · slide the window over a passage, collect the training examples
window half-width:(5 words wide)
The blue words are the context; their vectors get averaged into one input vector. The green word is the center — the prediction target. Each window position emits one example: context → center. Step the window across the text (the controls below, or click any word) and they pile up into the training set.
training example at this position
(cat, sat) → the
training set so far — 1 of 6 examples
(cat sat)→the

架构

CBOW 的架构就是把 skip-gram 的两端对调——输入是上下文词,输出是中心词。同样的两个权重矩阵 E 和 E',同样的维度 d,同样在 V 上做 softmax。唯一的架构差别在输入端:skip-gram 读 E 的一行(中心),CBOW 读 C 行(每个上下文词一行)并把它们平均成一个 d 维隐藏向量 h:

Skip-gram:  h = E[c]                                  (one row read)
CBOW:       h = mean(E[c_1], E[c_2], ..., E[c_C])     (C rows read, then averaged)

下面做了可视化——C 个独热输入、在 E 中对应的 C 次行查找,以及产出 h 的平均步骤:

CBOW · dim = d, vocab = V
input
the
1
0
0
…
cat
0
1
0
…
on
0
0
1
…
the
1
0
0
…
the C context words, one-hot each
shape: C × V
lookup C rows
input embedding E
learned table
shape: V × d
the C rows
average them → h
mean ( + )
↓
0.05
-0.11
0.22
0.31
-0.04
0.18
-0.09
0.12
h = mean(vthe, vcat, …) ∈ ℝd
shape: d

正是这次平均给了「词袋」这个名字——窗口内的词序被丢弃,上下文成了一个多重集。在每个训练步上,查表读取的是 E 当前、正在飞行中的值——你在前向传播里读的那些行,就是你在反向传播里更新的那些行,与任何神经网络的 SGD 训练一样。一旦算出 h,下游的一切——scores = h @ E'、softmax、交叉熵损失、反向传播、对 E 和 E' 的 SGD,以及负采样这条捷径——都与 skip-gram 完全相同。

训练——与 skip-gram 的三点小差别

前向传播、损失、梯度、小批量以及负采样捷径全都原封不动地从 skip-gram 沿用过来。有三点差别值得记住:

CBOW 每个窗口进行一次预测,skip-gram 则对每个上下文词的出现分别预测,因此 CBOW 通常需要更少的输出层计算。采用上下文均值时,每个输入词出现接收 grad_h / C;重复词索引必须累加贡献。完整 softmax 仍更新所有输出列,负采样则更新选中的输出向量。相对速度和质量取决于数据与设置。

实践中 word2vec 究竟怎么训练——Gensim

Gensim 的 Word2Vec负责构建词表、上下文采样、负采样和向量查找。sentences 接收可迭代的分词后句子。这个小例子用于展示 API,数据远不足以学到有用的语义类比。

from gensim.models import Word2Vec

sentences = [
    "the king sat in the palace".split(),
    "the queen sat in the palace".split(),
    "the king and the queen ruled".split(),
]
model = Word2Vec(sentences, vector_size=50, window=2, min_count=1,
                 sg=1, negative=5, hs=0, workers=1, seed=42, epochs=20)
v_king = model.wv["king"]

CPU 训练与内存访问

使用负采样时,一个词对只涉及少量向量。许多实现将两张表都按行存储;按照前文的列布局,输出访问写作 E'[:, w]。每个词对的大致工作量如下:

1. Read the input vector:       E[center]
2. Read output vectors:        target + k sampled negatives
3. Compute scores:             k+1 dot products, each length d
4. Accumulate gradients:       one input row and sampled output vectors
5. Apply the SGD update:       repeated indices receive summed contributions

两张 float32 向量表需要 2 × V × d × 4 字节:一百万个词、300 维时约为 2.4 GB,尚未计入词表和优化器开销。小型、不规则更新可能适合 CPU 实现,但 GPU 表现取决于批处理、内存布局和实现方式。不存在 word2vec 在任何硬件上都必须由 CPU 更快的规则。

完整 softmax 的替代方案

前文用于讲解的模型会为每个词评分,每个样本成本为 O(Vd)。Gensim 示例则使用负采样。避免遍历整个词表的两种成熟方法如下:

负采样将词表分类改为采样得到的二分类标签,一个正样本加 k 个负样本的成本为 O((k+1)d)。它优化的是不同目标,并不是完整 softmax 梯度的无偏捷径。下一篇文章会推导损失并给出可运行更新。

层次 softmax将词放在二叉树叶节点,为每个内部节点分配向量。一个词的概率是其路径上各次左右分支选择概率的乘积,每个节点需要一次点积。平衡树路径长度为 log₂V 量级;word2vec 的 Huffman 树为高频词提供更短路径。叶节点概率构成归一化分布。

Gensim仍支持层次 softmax,例如设置 hs=1, negative=0。应根据训练成本和任务所需表示进行选择,而不是将它视为普遍败给负采样的过时算法。

静态嵌入在哪里失效

word2vec 产出的是静态嵌入:每个词一个固定向量,与上下文无关。对最初表述的分布假说而言,这形状恰到好处;但它有三种失效模式,随着 NLP 转向更难的任务而愈发显眼。

一词多义

考虑这两句话:

  • I deposited the cheque at the bank.(我把支票存进了银行。)
  • We had a picnic on the river bank.(我们在河岸上野餐。)

静态嵌入让两个句子中的 bank 使用同一个向量。它混合不同用法的信息,却不指出当前这次出现的具体含义。这种混合也不一定是不同词义向量的算术平均。

cosine similarity of bank with two sense clusters
"bank" as financial institution
money
0.436
loan
0.418
account
0.403
deposit
0.451
interest
0.409
"bank" as river bank
river
0.273
shore
0.284
water
0.159
creek
0.145
flood
0.199
the financial sense dominates — that's the corpus skew, not a deep fact about the word. but both senses pull above zero from the same vector, because there's only one vector to give. a static embedding can't tell the model which sense is meant in any specific sentence; that's left to whatever sits on top.
cosine similarities computed on glove-wiki-gigaword-300

组件使用一个预训练模型,将 bank 与金融和河流相关词进行比较。相似度反映的是该模型与语料。能够读取周围词的下游模型仍可消歧,只是单独的静态向量不能提供这种区分。

对句法不敏感

「向量袋」式的表示把词序丢掉了。下面两句:

  • 狗咬人。
  • 人咬狗。

包含相同词袋,但词元顺序不同。对静态向量取平均会得到相同表示;若保留向量序列,词序仍然存在,RNN、卷积或 Transformer 可以利用它。

词表覆盖与适应

“静态”意味着与上下文无关,而不是永久冻结。词向量可以微调或重新训练。普通 word2vec 查找表没有未见词的条目,因此需要未知词策略,或采用其他表示来应对词表增长。

fastText通过字符 n-gram 组合向量,解决了部分未见词问题。但其词表示仍与上下文无关,因此处理新拼写和判断句中词义是两个不同问题。

接下来是什么

ELMo使用双向语言模型状态表示上下文中的词元;BERT则使用通过掩码词元预测训练的 Transformer 编码器。若要继续理解这里的训练目标,可阅读负采样与对比学习,其中区分了 word2vec 的 sigmoid 损失和 CLIP 的 softmax 损失。