词嵌入——word2vec 与真正有含义的向量
词嵌入用一个简短的可学习数值向量表示一个词。Word2vec 的训练任务足够简单,适合逐步观察这些数值如何从上下文窗口和梯度更新中形成结构。
常见类比 king − man + woman ≈ queen 展示了一种可能的向量关系。下图使用手工放置的二维点,并非实际测量的 word2vec 向量。偏移量精确相等是绘图设计的结果;真实类比只是近似关系,取决于语料、模型和比较方法。
点击 step 按钮,一步步看这个构造过程。
Schematic: hand-placed 2D points illustrate vector arithmetic.
训练得到的坐标来自优化,而不是预先命名的语义轴。某些关系可以表现为有用的方向,但不存在适用于所有词的通用“王室”坐标或偏移量。
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 的窗口中出现的次数。排除的是中心位置,不是所有相同词的出现。这个例子的对角线恰好为零;窗口内重复出现的词可以产生非零对角线元素。
这个矩阵是通过在语料上滑动窗口并计数建起来的。
在每个以词 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 流水线干净地分成两个阶段,边界分明:预处理把原始文本变成一串训练样本,然后训练把这些样本送过神经网络。
先看预处理——把原始文本变成一长串整数对。这里还没有任何神经网络参与:
- 对语料分词——把文本切成词 token 列表(按空白切分,通常转小写;word2vec 用的是词级 token,而非子词片段)。
- 构建词表——给每个不同的 token 分配一个整数 ID;这个 ID 之后既作为该 token 在独热向量中的索引,也作为它在嵌入矩阵中的行号。总数记作
V(词表大小)。 - 抽取
(中心, 上下文)对——在 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——又是四对。再走一步,再来四对,如此下去,直到整个语料坍缩成一长串 (中心, 上下文) 对,全部由文本自身生成,没有任何人标注过什么。
下面的简短 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 与损失要再过两节才来。)
分数为 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 把它们变成概率。
在这个手工选择的数值示例中,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 对分数取指数,再除以总和。实现时会先减去最大分数以保证数值稳定,这不会改变概率。输出相加为一,但归一化本身并不保证预测概率经过良好校准。
损失函数
对目标词为 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 曲线上。点击不同的词,把它指定为目标——标记会沿曲线滑动,你能直接看到:模型本来就偏好的目标几乎不花代价,而被它低估的目标要付出陡峭的代价。
目标概率接近零时,损失会增大。但经过 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)(正数——把它的分数往下压,正比于它当前拿了多少概率)。模型本就正确认为不太可能的词几乎不动;它判断错了的词得到最多信号。
下面的小组件把这个减法在同样五个概率上做实。点击不同的目标,可以看到梯度那一行重绘——一根高高的蓝柱把目标的分数往上拉,四根短短的红柱把其余的往下压。
反向传播按链式法则把这些分数梯度带进 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 不一定包含完全相同的词对。
按批来看,循环长这样:
- 在 B 个样本的一批上做前向传播——每个
(c, t)对流过网络。向量化之后,查表把 B 行从E堆成一个(B, d)矩阵,打分步变成一次(B, d) @ (d, V) → (B, V)的矩阵乘法,softmax 按行运行。 - 损失——逐样本计算交叉熵损失(公式与 B=1 相同),然后在批上取平均得到一个标量。
- 反向传播(backprop)。 按链式法则计算梯度——从损失一路回到每个权重的纯微积分,此时还不更新权重。输出:梯度张量
∂L/∂E与∂L/∂E',形状与权重张量相同。 - 梯度下降步——通过优化器把梯度施加到权重上,与上面单对的情形完全一样。然后处理下一批。
在每轮的许多批上跑这个循环,再在语料上跑上几轮,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 的架构就是把 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 的平均步骤:
shape: C × V
shape: V × 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 使用同一个向量。它混合不同用法的信息,却不指出当前这次出现的具体含义。这种混合也不一定是不同词义向量的算术平均。
bank with two sense clustersglove-wiki-gigaword-300组件使用一个预训练模型,将 bank 与金融和河流相关词进行比较。相似度反映的是该模型与语料。能够读取周围词的下游模型仍可消歧,只是单独的静态向量不能提供这种区分。
对句法不敏感
「向量袋」式的表示把词序丢掉了。下面两句:
- 狗咬人。
- 人咬狗。
包含相同词袋,但词元顺序不同。对静态向量取平均会得到相同表示;若保留向量序列,词序仍然存在,RNN、卷积或 Transformer 可以利用它。
词表覆盖与适应
“静态”意味着与上下文无关,而不是永久冻结。词向量可以微调或重新训练。普通 word2vec 查找表没有未见词的条目,因此需要未知词策略,或采用其他表示来应对词表增长。
fastText通过字符 n-gram 组合向量,解决了部分未见词问题。但其词表示仍与上下文无关,因此处理新拼写和判断句中词义是两个不同问题。
接下来是什么
ELMo使用双向语言模型状态表示上下文中的词元;BERT则使用通过掩码词元预测训练的 Transformer 编码器。若要继续理解这里的训练目标,可阅读负采样与对比学习,其中区分了 word2vec 的 sigmoid 损失和 CLIP 的 softmax 损失。