词嵌入——word2vec 与真正有含义的向量
如今很多人接触 AI 都是从技术栈的顶端开始的——transformer、注意力头、微调,还有一个似乎什么都懂的聊天模型。 作为使用这项技术的起点,那很好。但作为学习它的起点,那太高了:大模型是五十多年间层层累积的想法叠成的深栈,而其中几乎每一个想法单独拿出来都更小、也更容易看清。 要理解它,与其从成品栈开始,不如从零件往上搭——而最要紧的那个零件,也就是 GPT 式模型、BERT 以及每个检索系统都不过是其变体的那个零件,就是**「词可以被变成几何编码含义的向量」这个想法**。
你多半见过那个著名的例子——king − man + woman ≈ queen,大概是 NLP 中被截图最多的结果。看看下面的小组件。
它用几何方式演示了这套算术:图的每一侧都是自己的一个小坐标系,
但「王室」这个方向——在系统 A 中算作 v(king) − v(man)——在嵌入空间中处处指向同一边。
把这个向量搬到系统 B,放到 woman 上,它的末端就落在了 queen。
所以 king − man 分离出一个方向,而 + woman 把这个方向施加到另一个锚点上。
点击 step 按钮,一步步看这个构造过程。
这个小组件展示的是词的稠密向量表示:每个词是高维空间中的一个点,而点与点之间的几何编码了语义关系。没有人手工设定这些数值,也没有人告诉模型「王室」或「性别」算是概念——这里没有标注过的坐标轴、没有类别标签,没有人把 king 标为「王室」或把 queen 标为「女性」。几何完全从训练目标中掉落出来,而弄清这是怎么发生的,就是本文的主要内容。
不过在 NLP 的大部分历史里,词根本不是向量——它们是独立的符号,是整数 ID 或独热指示向量,彼此之间没有任何内建关系。任何语义上的东西都得在下游硬装上去:人工设计的特征、基于计数的统计、WordNet 之类的词库。模型能用,但在词与词之间泛化得很差——系统学到的关于 cat 的东西,一点也传不到 kitten 上。
这个盲点被烘焙进了那个时代的主流配方。一份文档会被转成 TF-IDF 的文档-词项矩阵——每个文档一行稀疏向量,每个词表词项一列,每个单元格衡量某个词项对该文档有多具代表性——在此之前还有一条由分词、词干还原、停用词去除组成的预处理链。这个矩阵作为特征喂给经典机器学习分类器——SVM、逻辑回归、朴素贝叶斯、随机森林——去做情感分析、文档聚类或相关性排序之类的任务。这套流水线能用,但骨子里带着同样的缺陷:每个词项都是自己独立的一列,完全没有「哪些词项含义相近」的概念,而一个三万以上词项的词表会让每个文档向量既庞大又几乎全是零。
稠密嵌入取代了它。每个 token 变成几百个实数构成的向量,全部从数据中学出,而这个空间的几何与含义对齐:相近的词落在彼此附近,而相关词构成的词对处在相似的偏移上——这正是上面那套算术之所以成立的原因。
word2vec 就是在这里登场的。它于 2013 年提出,正是它让稠密嵌入变得实用:一个简单、浅层的架构,直接从原始文本中学习连续的词表示,却在一系列句法与语义任务上——情感分析、复述检测、关系抽取——胜过更重的模型。它的吸引力有一部分来自纯粹的效率——便宜到能在普通硬件上用数十亿词训练——而强结果与低成本的这种组合,把词嵌入从研究界的猎奇变成了现代 NLP 的默认构件。
赋予这些向量含义的是训练过程。初始化时,每个词只是一小串随机数——里面还没有任何语义。它们获得含义,纯粹是被迫在一个自监督预测任务上做好之后的副作用——预测一个词的上下文(skip-gram),或者从上下文预测一个词(CBOW)。没有人告诉模型「king 和 queen 是相关的」。但要准确预测 king 的上下文,又要准确预测 queen 的上下文,而这两个上下文分布高度重叠时,这个小网络唯一能成功的办法就是给 king 和 queen 相似的向量。嵌入空间中的相似性就是模型在行为相似的词之间共享预测证据的机制。几何是从优化中掉出来的。
从假设到几何
一个关于词义的假设,最终怎么会产出真正的向量几何——相近的词落在一起,一致的关系变成一致的向量偏移?这个推理有四步,每一步都逼出下一步:一个前提(分布假说本身)、一个直接建立在该前提上的自监督任务、一个约束该任务解法的表示瓶颈,以及在另一端浮现出来的几何。按顺序走一遍,从「词有含义」到「词变成有结构的向量」的这一跃就不再神秘了。
我们从分布假说开始:一个词的含义可以由它出现的上下文分布很好地近似。出现在同类句子里的词——couch 与 sofa、huge 与 enormous——含义相近,这悄悄把「一个词意味着什么」的问题重述成了「它出现在什么旁边」,而后者,只靠原始文本就能回答,不需要词典,也不需要人工标注。
由这个前提引出一个自监督任务:取一个词,预测环绕它的词——或者取周围的词,预测中间那个。既然假说说邻居承载着含义,那么把这个任务做好就迫使模型编码含义——而且搭建它零成本,因为正确答案早已躺在文本里。
接着是对该任务的一个约束——瓶颈。一个词的全部表示必须装进一个短向量里——比如三百个数——而这就是它的信息唯一能存放的地方。三百个数并不宽裕:远远不够给每个词一份独立的描述。 所以为了同时把所有词都预测好,模型必须省着用——用共享、可复用的特征来表示词,并把扮演相同角色的词放到几乎相同的坐标上。 相近的词最终挨在一起,不是因为谁要求如此,而是因为把它们挤在一起是让一切都装得下的唯一办法。把瓶颈拿掉——让向量宽到与词表本身一样,好让每个词都能占据自己专属的维度、永远不必共享——那种压力就消失了:模型只会一个词一个词地背下哪些词与哪些词共现,学不到任何能带到下一个词的东西。
从另一端出来的是几何:上下文分布相似的词得到相似的向量,而一致的关系模式——「谁的首都」、复数、过去式——变成一致的向量偏移。 含义最终被编码为空间中的位置与方向——这正是开头那张图所展示的。
你刚刚走过的这一套——预测上下文、挤过瓶颈、读出几何——并非 word2vec 独有。word2vec 之后的每一个嵌入模型都在打同一套战术。 通过表示瓶颈做自监督预测,正是 BERT 学到其表示的方式,是 RAG 背后的句子编码器学到其表示的方式,也是每个现代 LLM 内部那层嵌入被建起来的方式。
在今天的 NLP 架构里,「嵌入」可以指三种相当不同的东西——都由「通过瓶颈做自监督预测」以同样方式构建,但各自作用于比前一个更大的文本单元:
- 静态词嵌入——word2vec、GloVe、fastText。每个词一个固定向量,从查找表中取出,训练后冻结。词序被丢弃,而
bank拿到的是一个把它所有义项平均起来的单一向量。 - 上下文嵌入——ELMo,然后是 BERT 及其后代。每个词按它在句中出现的样子得到一个向量:「river bank」里的
bank与「bank account」里的bank出来的向量不同,因为周围的 token 重塑了这个向量。训练任务转向掩码语言建模——从双向上下文预测被藏起来的 token,并且过的是一个深层编码器而不是浅层平均——但底层原理,即通过瓶颈做自监督预测,没有变。 - 句子/段落嵌入——Sentence-BERT 和现代检索模型。把上下文模型池化成每句或每篇一个向量。这是检索增强生成的发动机:把语料嵌入一次,把每条查询嵌入,然后按余弦相似度返回最近的片段——正是上面那张图所演示的几何赌注。
你也许在想生成式 LLM——ChatGPT、Claude、Llama——处在哪一层。它们不占单独一层;它们三者兼有。它们每一个最底下的那层都是一张静态词嵌入表,和 word2vec 的一模一样——进去一个 token ID,出来一个学到的向量。其上的每一层 transformer 产出的是 BERT 式的上下文嵌入。让一个 LLM 成为 LLM 的并不是某种新的嵌入;而是你去读取它的下一个 token 分布并从中采样,把一台表示机器变成了一台生成机器。
本文讲的是第一层——静态词嵌入。 我们会从贯穿一切的那个想法(分布假说)开始,走一遍 skip-gram 与 CBOW 的内部机制,在真实的预训练词表上探索著名的 king–queen 类比,并终于走到静态嵌入不再奏效的地方——而那正是下一篇文章里 BERT 与第二层接手的地方。第三层——检索增强生成背后的句子与段落嵌入——会在系列的更后面出现。
分布假说
早在 1957 年,语言学家 J.R. Firth 就写下了那句名言:「观其伴,知其词。」这就是全部想法。出现在相似上下文中的词有相似的含义。要看出为什么,请看三句各缺一个词的话:
- ___ 冲着邮递员叫。
- ___ 在我腿上打呼噜。
- ___ 冬天飞去了南方。
你不需要知道缺失的词,就知道它们指的是不同种类的动物。上下文——空缺周围的词——缩小了什么能填进去。这是这个想法的一半:上下文预测词。
另一半反过来。把所有能稳定填进「___ 冲着……叫」这个空的词收集起来——狗、小狗、猎犬、寻回犬。它们都合适,因为它们都表示某种狗类的东西。任何能稳定填进「___ 打呼噜」的词——猫、小猫、狸花——都表示某种猫类的东西。所以,在同类句子里填同一个空的词,含义必定相近。对表示而言真正有回报的是这个方向:一个被训练去预测上下文的模型,必须给上下文相似的词以相似的向量,因为只有这样它才能把它们全都同时预测好。
分布假说说:用一个词的上下文来定义它。构建一种表示,让上下文分布相似的词彼此靠近。这样的表示会编码语义相似性、句法角色,以及数量惊人的世界知识——而这一切不需要任何人工标注,只需要原始文本。
问题在于如何高效地算出来。可以追溯到 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——按概率调整。 原始计数被频率所主导:在真实语料里 the 与 cat 共现很多,但那只是因为 the 与一切都共现。要提取真正的信号,每次共现都必须按纯粹偶然会预测出的量打折——高频词不该因为与什么都共现而记功。标准做法是 PMI(逐点互信息):一个对数比值,当两个词共现得比偶然预测的更多时它高,更少时它低,而当它们的联合频率恰好等于两个具有这些频率的独立词所产生的值时它为零。矩阵形状仍是 (V, V);变的只是数值,从原始计数变成按概率调整后的分数。
阶段 3——压缩。 即便做了概率调整,矩阵仍然是 (V, V)——宽得离谱,不可能当作可用的词向量表。标准做法是 SVD(奇异值分解):找出矩阵中方差最大的 d 个方向,把每一行从 V 维投影到 d 维——正是 PCA 对二维点云所做的「保留变化最主要的方向并投影上去」,这里作用在 V × V 的共现矩阵上:
raw cat vector (V long): [1, 0, 1, 1, 0]
PMI-weighted (V long): [−0.4, 0, 0.7, 0.3, 0] ← log-ratios per vocab word (illustrative)
SVD-compressed (d long): [0.21, -0.34, 0.18, …] ← d abstract latent dimensions压缩后的向量是 d 个实数(几百个),而这些维度不再表示「与某个特定词的共现」。每个维度都是全部 V 个词轴的线性组合——一个学到的潜在方向,捕捉语料中共现变化的一种主导模式。第 47 维不再表示 cat 出现在 snowboard 附近的频率;它现在衡量的是 cat 的整体共现模式与数据中第 47 显著的那个方向有多吻合。
那张 (V, d) 的压缩表——稠密实数、每词一行——与本文余下部分要讲的 word2vec 向量在结构上完全相同。不同的是抵达方式:先计数再分解,还是直接训练一个网络。两条路线基本落在同一个目的地——这正是 Levy 与 Goldberg(2014)在数学上形式化的东西。
这意味着「先计数再分解」这条路线产生的是同一类支撑 king − man + woman ≈ queen 的几何——著名的类比算术在两条路径上都会掉出来。
调好的 PPMI-SVD 模型在标准类比基准上与 word2vec 相差不过几个点;同一个目的地,不同的工程。
二十年间,这套「先计数再分解」的配方支撑了 LSA(潜在语义分析,1990——词-文档矩阵,用于信息检索)与 HAL(hyperspace analog to language,1996——词-词矩阵,用于认知科学中建模人类语义记忆)。两者都是把词向量当作其他目标的副产品;而 word2vec(2013)是第一个把词向量表本身当作明确目标的方法。
它们共同的代价是纯粹的规模,而且这个代价比词表本身涨得更快。在真实的 V 下,这个矩阵是个 V × V 的怪物:每边一百万个词就是总共一万亿个格子,而且几乎全是零,因为大多数词对从不共现。存储它很重;用经典 SVD 分解它更重。除了原始算力,这条流水线还是一串显式的、手工调参的步骤——计数、加权、分解、截断、缩放——每一步都有旋钮,而且它们之间以不显然的方式相互影响。到 2010 年代初,这已经成了人人都在用、却没人喜欢的配方。
2013 年,一个叫 word2vec 的突破性方法产出了几百维的稠密向量,具备同样的语义性质——而且从未构建过这个矩阵。 它不去统计共现再把结果压缩下来,而是通过在一个预测任务上训练一个极小的神经网络,直接学出这些向量。(一年后,GloVe 又回到构建矩阵并正面分解它;fastText 用字符 n-gram 扩展了 word2vec。静态嵌入家族那篇文章介绍了这些替代方案以及它们彼此的关系。
word2vec
word2vec 论文提出了两种完全跳过共现矩阵的做法——它们能产出同样的 (V, d) 嵌入表,却从头到尾没建过那张 V × V 的网格。与其统计共现再用 SVD 把矩阵压小,不如训练一个极小的神经网络去预测每个词周围的词(两个方向皆可),等训练停下时,再从网络的权重里把词向量读出来。这个预测是真实的,但你要的并不是它:它存在只是为了逼出网络里有结构的向量。你留下那张学到的词嵌入表,其余的丢掉。与 LSA/HAL 是同一个目的地,工程却大不相同:一次一对词地流式处理,边走边推动向量,而不是建一张万亿格的矩阵再去分解它。
这种训练设置也给了 word2vec 一个硬性的范围限制——它关注单个词,而不是句子。模型只产出每词一个固定向量,再无其他——没有句子表示、没有词序意识、没有短语内部词与词之间的组合性。在 word2vec 发布到 transformer 接管权力之间的那十年里,标准的 NLP 流水线用一个办法填补了这个空缺:在 word2vec 嵌入之上叠一个 LSTM(或类似的循环网络)——嵌入承载每个词的含义,LSTM 处理这些含义在句中如何拼合。这种分工最终为何坍塌,是结尾几节的主题。
word2vec 引入的两个算法是 skip-gram 和 CBOW——两个把预测方向反过来的替代配方。你用其中一个,绝不同时用两个:
- Skip-gram。 给定一个中心词,预测它周围小窗口内的词。我们把中心词作为输入喂进去,逐一预测周围的每个上下文窗口词——每个
(中心, 上下文)对都是一个独立的训练样本,所以同一个中心词会按邻居数量被复用多次。 - CBOW(continuous bag of words,连续词袋)。反过来:给定窗口内的词,预测中心词。我们把一组上下文词喂进去,预测中间那一个词。这种「填空」的形状,正是后来 BERT 的掩码语言建模所继承并放大的。
两者做的是同一件事,只是预测方向相反。我们会把 skip-gram 从头到尾走一遍,然后再回到 CBOW。
训练数据与独热输入
整条 skip-gram 流水线干净地分成两个阶段,边界分明:预处理把原始文本变成一串训练样本,然后训练把这些样本送过神经网络。
先看预处理——把原始文本变成一长串整数对。这里还没有任何神经网络参与:
- 对语料分词——把文本切成词 token 列表(按空白切分,通常转小写;word2vec 用的是词级 token,而非子词片段)。
- 构建词表——给每个不同的 token 分配一个整数 ID;这个 ID 之后既作为该 token 在独热向量中的索引,也作为它在嵌入矩阵中的行号。总数记作
V(词表大小)。 - 抽取
(中心, 上下文)对——在 ID 流上滑动窗口,每个邻居产出一个训练样本。
第 3 步是预处理的核心。概念上,你挑一个窗口大小——惯例是两侧各两个词,使窗口宽五个词——然后一次一个词地把它滑过文本。在每个位置,中间那个词是中心,周围的词构成它的上下文,模型的任务是从中心预测上下文词。在那个位置产出的每一个 (中心, 上下文) 配对都是一个训练样本。
以句子「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——又是四对。再走一步,再来四对,如此下去,直到整个语料坍缩成一长串 (中心, 上下文) 对,全部由文本自身生成,没有任何人标注过什么。
预处理结束时,你得到一串整数对 (c, t)——c 是中心词的 id,t 是目标(上下文)词的 id——可以直接喂进去了。这整个阶段在我们这个例子上大约十几行 NumPy 就写完了:
# 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 周围那一个窗口位置产出的四对,是四个独立的训练样本,输入同为 sat,而预测目标各不相同(the、cat、on、the)。只有输入会走前向传播;目标在网络的计算中完全不参与——只有到损失那一步才会被查阅,用来给预测出的分布与它的吻合程度打分。
这与 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]独热向量长度为 V——它的长度就是词表大小,真实的 word2vec 里约为一百万。所以每个词字面上变成了一个一百万个数的向量,其中 999,999 项是 0,只有一项是 1。一眼就能看出这是极其浪费的表示:几乎全部存储和几乎全部乘法都在跟毫无贡献的零打交道。
网络拿这个独热向量做什么?它把它直接交给第一层作为输入,第一层将其与该层的权重矩阵做点积——逐项相乘再求和。当 V 约为一百万时,这就是每个训练样本一次「一百万乘 d」的矩阵乘法,其中几乎全在乘零。真的照字面做,那是海量的算力浪费。
幸好,有一个巧妙的线性代数事实,让我们根本不必构造独热向量——独热向量乘以一个矩阵,等同于挑出该矩阵的某一行(按词的整数 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所以在代码里我们只存整数 3(词表索引),把它当作直接的行查找来用——在前向传播那节我们会具体看到。数学上,一个 V 维独热向量与一个整数 V 索引携带同样的信息;我们在数学里用独热,因为它让线性代数写得干净,在代码里用整数,因为它便宜 V 倍。
心智模型:我们究竟要做什么
在仔细看网络架构之前,值得把网络到底想达成什么钉死。
核心想法一句话:给定一串 (中心, 目标) 对,对每一对在各维度上计算中心词与词表中每个词的相似度——然后一对接一对地不断推动权重,使目标逐渐升到相似度排名的顶端。
相似度用词向量(嵌入)之间的余弦相似度衡量,而 softmax 按预测出的「是目标」的概率给每个词表词排名。 本节余下部分会拆开讲这些权重是什么、网络如何实现这一点,以及为什么在语料上跑这个循环会产出有意义的几何。
我们会有两个可训练的权重矩阵——各自位于网络的一对层之间——它们合起来给词表中每个词两个 d 维向量(每种角色一个嵌入):
E,形状(V, d)——每一行是某个词的输入嵌入,当该词作为训练对的中心(被条件化的那个词)时使用。E',形状(d, V)——每一列是某个词的输出嵌入,当该词作为被预测的目标/上下文(被当作候选来打分的那个词)时使用。
注意形状差异:E' 本质上是转置过的 E——每个矩阵同样是 V × d 个数,但排布成 (d, V),于是词沿列而不是沿行排列。
所以单个词 w 在 E 中有一行(当它是中心时),在 E' 中有一列(当它是目标时)——两个独立的、可训练的 d 维向量,每种角色一个。
它们得到各自独立的梯度更新,最终取到不同的值。
这种不对称是刻意的:在预测任务里中心与目标扮演不同角色(一个是条件化的上下文,另一个正在被评判是否合理),而当模型能为这两种角色学出不同的向量、而非被迫共享时,它更具表达力。训练结束后,E 是作为最终词嵌入表交付的东西,而 E' 被丢弃。
下面的小组件把这一步做实——在各维度上计算中心词与词表中每个词的相似度,也就是 E 与 E' 的互动。我们聚焦在 sat 作为中心(在 E 中以绿色高亮)。逐步走一遍,看矩阵乘法如何把它与词表中的每个词打分——每个词一次点积,一格一格地填满分数向量。(只有阶段 1 和 2——softmax 与损失要再过两节才来。)
一对 (c, t) 的分数就是 v_c · v'_t——c 的输入嵌入与 t 的输出嵌入的点积,也就是未归一化的余弦相似度。
这也正是 E' 以转置形式存储(列 = 输出嵌入)、而不是再存成一张 (V, d) 表的原因:按列存意味着 scores = v_c @ E' 只用一次矩阵-向量乘法,就算出了 v_c 与每个词的输出嵌入的点积。如果 E' 像 E 一样是 (V, d),你就得写循环或做转置才能得到同样的 V 个点积。(d, V) 这个形状恰恰是让「一次性算出与所有词的相似度」成为一行操作的那种布局。
这与 MNIST 给数字打分用的是同一个操作——每个输出类都有自己的特征模板,与隐藏向量做点积,衡量图像与该类的吻合程度。word2vec 打的是同一套战术,只不过把 E' 的各列当作逐词模板、把 v_c 当作中心词的特征向量;唯一的区别是这里的特征是学出来的语义维度(王室性、复数性、性别),而不是人能直接看懂的图块(边缘、笔画、圈)。
有了这些铺垫,下一节的架构不过是能完成以下事情的最简单神经网络:为每个候选对 (c, t) 计算相似度(v_c · v'_t),并通过梯度下降更新两个矩阵中的权重。
在整个语料上重复数十亿次,就会产出这样一种几何:每个词的输入嵌入靠近与它共现的那些词的输出嵌入——并且通过传递性,共享同样邻居的词最终彼此靠近。从没有人告诉 king 与 queen 要相似;它们变得相似,是因为二者都被拉向 royal、throne、crown、monarch。cat 与 dog 也一样:它们共享 pet、fur、tail 这些邻居。
架构
一旦收集好了这些对,我们本质上就在跑一个有监督的带标签预测任务——给定中心词,预测词表中哪个词会出现在附近——所以这个设置在关键处与 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:
from tensorflow import keras
from tensorflow.keras import layers
V = vocab_size # e.g. 1_000_000
d = embedding_dim # e.g. 300
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 与 Dense——都是线性的,中间没有激活。
Embedding(V, d) 就是 E——Keras 对「按整数索引从 (V, d) 表中取一行」的叫法。Dense(V, use_bias=False) 就是 E'——一个普通的 (d, V) 线性投影。
中间的 Reshape 去掉 Keras 的 Embedding 多加出来的那个长度为 1 的冗余维度。这是 Keras 的一个小怪癖——Embedding 是为 token 序列设计的,即使我们一次只喂一个 token,它也会加一根多余的轴。
Softmax 把 V 个原始分数变成词表上的概率分布,sparse_categorical_crossentropy 是其上的标准分类损失——与 MNIST 用的是同一套 softmax 加交叉熵,只是把 10 个数字类换成了 V 个词表类。损失及其梯度我们会在后面几节详细走一遍。
词嵌入的维数 d 是一个没有公式可选的超参数——大部分工作由惯例完成。
用得最熟的默认值是 d = 300,2013 年 word2vec 论文在 Google News 上用的就是它,GloVe 提供的最大预训练版本也是它。
实践中你会看到轻量/边缘场景用 d = 50–100,d = 200–300 是甜点区,更大的很少见。
d 必须远小于 V 的原因,就是前面那个瓶颈论证:如果每个词都能摊开进自己专属的维度,模型就会去背共现,而不是构建共享特征,任何几何都不会浮现。
我们刚刚描述的是基础版前向传播——输出端在整个词表上做 softmax。
前向传播——查表、打分、softmax
既然我们已经从高层讲过架构——输入独热、隐藏层查表、输出分数——现在放大来看,一个训练样本流过网络时到底发生了什么。对一个中心词 c,前向传播从左到右分三个阶段:查表、打分、softmax。
心智模型那一节里的小组件已经孤立地展示了前两个阶段——独热乘矩阵坍缩为一次行读取,然后行乘矩阵产出 V 个分数。这里我们给它们命名,在上面加上 softmax,并把数字从头跟到尾。
阶段 1:输入 × E → 隐藏(查表)。 数学上这是矩阵乘法 one_hot(c) @ E,产出一个 d 维隐藏向量。输入是稀疏的——V−1 个条目为零——所以几乎每次乘法都算成零,整个 (V, d) 的矩阵乘法坍缩为一次行读取:v_c = E[c]。
阶段 2:隐藏 × E’ → V 个分数。 第二层的前向传播:scores = v_c @ E',产出 V 个实数(每个词表词一个)。这是一次货真价实的 (d, V) 矩阵-向量乘法——没有捷径,没有可利用的稀疏性。每个分数 w 是 v_c · E'[:, w]——v_c 与 E' 第 w 列的点积——每个词表词一次这样的点积。矩阵乘法的大部分工作就发生在这里:E' 的每一列都参与前向传播,并在反向传播中拿到梯度,所以几乎全部的单步算力——以及几乎全部的学习——都住在隐藏 → 输出这一侧。
矩阵乘法从不看目标词。它只用 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 把它们变成概率。
五次点积、五个原始分数,然后 softmax。在分数阶段最高的是 on(+0.39),这说得通——在「the cat sat on the mat」里 on 确实紧挨着 sat——而经过 softmax 后,它也拿到了最大的一份概率质量。
在 skip-gram 中,这些分数就是对每个词表词 w 的点积 v_c · E'[:, w],而得到的概率就是 P(w | c)。把上面小组件里的 softmax 那一步单独拿出来,五个分数 [-0.19, 0.26, 0.39, -0.07, -0.45] 变成了一个总和为 1 的正规概率分布:
机制很简单:把每个分数取指数,求和得到归一化因子(这里约 5.17),然后把每个 exp(score) 除以这个和。结果是一个按构造总和恰为 1 的概率分布,无论输入是什么。关于 softmax 的种种细节——软 argmax 行为、平移不变性、温度——见 MNIST 那篇文章,那里有深入讨论。
损失函数
对目标词为 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 曲线上。点击不同的词,把它指定为目标——标记会沿曲线滑动,你能直接看到:模型本来就偏好的目标几乎不花代价,而被它低估的目标要付出陡峭的代价。
图表显示,当模型正确地给目标分配了较大概率时,损失微乎其微;而当它低估目标——只给正确答案一点点概率——损失就很大。 由于 −log(P) 的对数形状,两个极端之间的过渡是陡的而非渐进的——曲线越陡,梯度就越激进地把目标的概率往上推。
把真正的目标 cat 与均匀参照比一比。cat 位于 (0.16, 1.83)——它是 (sat, cat) 中的目标——而均匀参照点位于 (0.20, 1.61)。那条均匀基线是任何未训练模型的起点——初始化时 softmax 会把概率大致均匀地摊在词表所有词上。
cat 的概率低于均匀,所以它的损失高于基线——在这一对上模型表现略逊于随机,而这正是训练要纠正的情形。
梯度
梯度与 MNIST 类似:损失对每个 logit 的梯度是 P(w) − 𝟙[w == t]——预测概率减去独热目标。目标词的梯度是 P(t) − 1(负数——把它的分数往上推);其他每个词的是 P(w)(正数——把它的分数往下压,正比于它当前拿了多少概率)。模型本就正确认为不太可能的词几乎不动;它判断错了的词得到最多信号。
下面的小组件把这个减法在同样五个概率上做实。点击不同的目标,可以看到梯度那一行重绘——一根高高的蓝柱把目标的分数往上拉,四根短短的红柱把其余的往下压。
除目标外,每个词都得到一个小的正梯度——SGD 把它的分数(也就是它在 E' 中的那一列)往下压,压的力度正比于它当前偷走了多少概率。目标得到一个大的负梯度,大小为 P(target) − 1(对 cat 约为 -0.84)——SGD 把它的分数往上拉。模型离 P(target) = 1 越远,这股拉力就越接近 −1(单个样本单步所能产生的最强拉力)。把整行加起来是零:概率质量是在被搬动,而不是被创造。
反向传播按链式法则把这些分数梯度带进 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的一行拿到梯度——中心词那一行。其余 V−1 行梯度为零,因为v_c = E[c]只读了那一行。(对比 MNIST,那里W₁在每个样本上都更新全部权重,因为输入是稠密像素。) E'的每一列都拿到梯度。 真实目标那一列被拉向v_c;其他每个词的列被推开,缩放系数为P(w) − 𝟙[w == t]。
随后这些梯度被用来执行一次梯度下降步:以 E -= lr × ∂L/∂E 和 E' -= lr × ∂L/∂E' 施加到权重上。这是优化器的活儿,而优化器的选择(原味 SGD、动量、Adam、RMSprop、AdaGrad……)只在这一步才有影响——它们消费的是同样的梯度,只是用法不同。
小批量与轮次
上面的推演一次处理一对。大多数神经网络训练把这一点推广成小批量 SGD——把样本分成大小为 B 的批,一次前向 + 反向传播处理一整批,与 MNIST 用的是完全相同的小批量 SGD,只是每步在 B 个样本上取平均。这就是 BERT、GPT 以及几乎每个现代模型所用的配方,也是下面几条要点所描述的。
Word2vec 是个例外。2013 年的原始发布使用的是批大小 1——纯 SGD,一步一对,配上 CPU 上多线程的 Hogwild! 并行(每个线程流式处理自己的对,并无锁地写入共享的 E / E';偶发的更新冲突被悄悄吸收)。直到 2026 年 Gensim 仍是这么跑的,而且专门就 word2vec 而言这是正确的选择——后面关于 Gensim 的小节会讲原因。下面的要点描述的是更一般的小批量流程,因为那才是可迁移到 BERT 及以后的配方;只要记住,单就 word2vec 而言,B=1 加 Hogwild! 更胜一筹。
一个轮次是对全部训练对完整走一遍,与批大小无关。word2vec 通常训练 5–15 轮;Gensim 默认是 5。每一对会被看到多次,因为在一对上走一步 SGD 并不能把相关的行完全塑形——反复走过会让 E 和 E' 收敛到损失所偏好的几何。
按批来看,循环长这样:
- 在 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 取若干词作为上下文、预测中心词——那个缺失的词。
这种「填空」框架,正是 1950 年代阅读理解研究中的完形填空测验——藏起一些词,让模型从剩下的部分把它们补上——而这只有在你拥有一个能用的周边语言模型时才行得通。它也正是 BERT 掩码语言建模背后的目标,即自 2018 年以来推动每一个上下文编码器的预训练任务。 你可以把 CBOW 读作一个微型的、线性形状的 BERT,把 BERT 读作长大成人、配上注意力的 CBOW:同样的训练任务,浅层平均换成了深层 transformer 堆叠,对称窗口换成了整个句子,单个掩码换成了一次 15% 的 token,静态输出换成了逐 token 的上下文向量。BERT 那篇文章会详细讲这一切。
机制上,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 沿用过来。有三点差别值得记住:
- 每个窗口位置只做一次前向传播。 skip-gram 每个窗口产出 C 个独立的对,要跑 C 次前向传播。CBOW 每个窗口只产出一个样本——整个上下文袋去预测中心词。所以 CBOW 训练起来大约快 C 倍,这是它主要的实用优势。
- 每个样本的梯度更新触及
E中更多的行。 skip-gram 每对推动E的一行(中心词那行)。CBOW 每个样本推动 C 行(每个上下文词一行,梯度因平均而被缩放 1/C)。这就是为什么 CBOW 在高频词上表现更好、在低频词上更差——常见停用词在大多数窗口里都会被触及;罕见词很少作为上下文出现。 E'的更新形状相同,只是索引依据不同。 在 skip-gram 里,每一对(c, t)更新目标词在E'中的列。在 CBOW 里,每个样本更新中心词在E'中的列。同样的更新模式,只是由不同的词来挑出它。
实践中 word2vec 究竟怎么训练——Gensim
这在 2026 年也许令人意外——在一个 transformer 编码器统治 NLP、每个上头条的模型都是某种注意力变体的时代——但word2vec 至今仍在被训练、被部署、每天被送上生产。原因一部分是架构上的契合(word2vec 是一种 CPU 形状的工作负载,稍后会讲到),一部分是对不少任务而言,便宜的静态向量就已经足够好(而且查询起来比跑一次 BERT 前向传播快好几个数量级),还有一部分是某些流水线——推荐系统、搜索排序、向量数据库冷启动、轻量语义特征——想要的恰恰就是「查嵌入」这种行为,而不是一个深层上下文模型。所以「究竟怎么训练一个」并不是历史问题——对不少团队来说,它是当下的工程问题。
训练 word2vec 事实上的标准库是 Gensim——名字取自「Generate Similar」——开源 Python 项目,自 2009 年起持续开发,尽管 Keras、PyTorch 和 JAX 都在,它仍是 2026 年大多数生产 word2vec 流水线所依赖的东西。用该库自己的描述来说:
Gensim 是一个自由开源的 Python 库,用于把文档表示成语义向量,并尽可能地高效(对计算机而言)与省心(对人而言)。Gensim 被设计用无监督机器学习算法处理原始、无结构的数字文本(「纯文本」)。Gensim 中的算法——Word2Vec、FastText、潜在语义索引(LSI/LSA)、潜在狄利克雷分配(LDA)等——通过考察训练文档语料中的统计共现模式,自动发现文档的语义结构。这些算法是无监督的,也就是说不需要任何人工输入——你只需要一个纯文本文档语料。
从这段描述里有两点值得注意。
它涵盖的是一整族算法,而不只是 word2vec。 Word2Vec、FastText、LSA、LDA——本文讨论过的每一种基于统计共现的方法(还有几种没提到的)都住在同一个库里。本文专门走的是 word2vec;而 Gensim 才是你实际去跑其中任何一个的地方。
高效(对计算机而言)与省心(对人而言),正是 Gensim 十五年来一直在优化的那个取舍:在这些算法真正需要的硬件(CPU,稍后就会看到)上极快,同时用一行 API 藏起构建词表、下采样、采样表和训练循环的每一处繁琐细节。这两条性质正是人们至今仍会伸手去拿它的原因。
一行就能拿到训练好的向量:
from gensim.models import Word2Vec
model = Word2Vec(sentences, vector_size=300, window=5, min_count=5, sg=1, workers=8)
v_king = model.wv["king"]构建词表、高频词下采样、负采样的 alias 表、训练循环、保存/加载、相似度查询、类比算术——Gensim 全都包了。要在裸 Keras 或 PyTorch 里达到同样的效果,你得写十倍的代码、拿到更差的吞吐,最后得到的向量还可能无法精确复现 word2vec 的经典结果。
Gensim 之所以长盛不衰,一部分是惯性,一部分是人体工学——但最深层的原因是 word2vec 是一种 CPU 形状的工作负载,而 Gensim 是现存最好的 CPU 实现。
为什么 word2vec 吃 CPU 而不是 GPU
「深度学习 = GPU」的标准直觉,来自那些瓶颈在大张量稠密矩阵乘法的模型:卷积网络、transformer、大型 MLP。GPU 正是为此设计的——成千上万个核心并行地对不同元素执行同一操作,由为向量化访问而布局的高带宽显存供料。给 GPU 一个 (4096, 4096) 的矩阵乘法,它几微秒就啃完了。
word2vec 在结构上从不做大矩阵乘法。看看一个训练对实际计算了什么:
1. one lookup in E: read row E[center] ← 1 row out of V
2. lookup target + k negatives in E': read k+1 rows of E' ← k+1 rows out of V
3. dot products against v_c: k+1 dot products of d-dim vectors ← ~k+1 × d multiply-adds
4. gradient updates: update 1 row of E, k+1 rows of E' ← k+2 rows touched当 k = 5–20、d = 300 时,每个训练步大约做 10⁴ 次乘加——比一次通过小型图像分类器的前向传播每样本的计算量少上大约一千倍。这里的「计算」几乎不存在。
这与 「MNIST 在 CPU 上训练更快」那篇文章详细探讨的是同一种形状的问题:当每步计算量足够小时,内核启动开销与 PCIe 传输成本会盖过 GPU 在 FLOPs 上的任何优势,而 CPU 在墙钟时间上胜出。
这表明瓶颈是内存访问而非算术——而这恰恰正是 CPU 合适的地方。E 和 E' 加起来占 2 × V × d × 4 字节——在 V = 10⁶, d = 300 时约 2.4 GB——而每一步只在其中做少量分散得很开的行读取,这种模式缓存预取帮不上忙。L1/L2 缓存吸收这些随机读取,系统内存装下 2.4 GB 的模型还绰绰有余,而操作系统线程可以直接覆盖式地更新共享内存——即 Hogwild! 那个技巧——代价远低于 GPU 上的等价做法(跨数千核心的原子操作)。
Gensim 把这些全都用足了。
它的内层循环用 Cython 写成,单对前向加反向,并在 workers 个 CPU 线程之间做无锁的 Hogwild! 并行。
在一台性能不错的笔记本上,那就是 8 个线程、每秒几百万对、每小时数十亿训练对,全程不离开 CPU。这足以在不需要昂贵 GPU 的硬件上,用几个小时在完整的维基百科转储上训练出 word2vec。
softmax 瓶颈——以及那条没走的路
上面的一切都用的是完整 softmax:每个训练步都在全部 V 个词上做归一化,把 exp(score) 在整个词表上求和构成分母。这是每一个样本上的 O(V) 开销,而当 V 达到几十万时,它成了训练中占主导的支出——也正是朴素版本慢到无法在真实语料上跑起来的原因。
Mikolov 等人提出了两种绕开它的办法。一种是负采样——上面那些实用实现已经在用了,而且它自有一篇文章:干脆不做归一化,把每一步变成几次代价 O(k) 的「真实 vs 噪声」二分类。另一种在同一批论文里被提出,是层次化 softmax——之所以值得知道,恰恰因为它输了。
层次化 softmax 把 V 个词摆成一棵二叉树的叶子(word2vec 用的是霍夫曼树,所以高频词离根更近)。V − 1 个内部节点各自携带一个可学习的向量,取代了 E' 中逐词的输出向量。一个词的概率是沿着从根到叶那条路径上一连串左/右决策的乘积,每个决策都是一个 sigmoid σ(v_node · v_c)。因为路径长约 log₂V,一步只触及约 log₂V 个节点向量而不是全部 V 个——而且与负采样不同,它仍是一个真正归一化的分布:把 P(w | c) 在所有叶子上求和,正好得到 1。
那为什么今天不用它了?三个原因,而且它们能推广到 word2vec 之外:
- 负采样在大语料上就是训练出更好的嵌入,而且更简单——没有树要建、要维护——所以即便在 word2vec 内部的正面对决中它也赢了。
- 它对 GPU 不友好。 每个词走不同的路径,触及不同的节点向量——不规则、多分支、散乱的工作,正是 GPU 所擅长的稠密批量矩阵乘法的反面。在现代硬件上,「FLOPs 更多但规整」胜过「FLOPs 更少但不规整」。
- 它所解决的瓶颈消失了。 子词分词把词表缩到约 3 万–10 万个片段,这让朴素的
O(V)softmax 便宜到根本不需要任何近似。比起任何竞争性技巧,正是这一点让层次化 softmax 在语言模型中退了休。
它主要作为 fastText(继承了 word2vec 代码库)中的遗留选项存活下来,并作为极端分类和拥有数百万标签的推荐系统中至今仍在使用的树形 softmax 的祖先。不过就词嵌入而言,故事到负采样就结束了。
静态嵌入在哪里失效
word2vec 产出的是静态嵌入:每个词一个固定向量,与上下文无关。对最初表述的分布假说而言,这形状恰到好处;但它有三种失效模式,随着 NLP 转向更难的任务而愈发显眼。
一词多义
考虑这两句话:
- I deposited the cheque at the bank.(我把支票存进了银行。)
- We had a picnic on the river bank.(我们在河岸上野餐。)
英语中这两处用的是同一个词 bank。静态嵌入只给 bank 一个向量。那个向量是两种义项的某种平均,也就意味着它哪一种都表示得不好。
bank with two sense clustersglove-wiki-gigaword-300上面的小组件展示了 bank 与两簇上下文词之间的余弦相似度:一侧是 money、loan、account、deposit、interest;另一侧是 river、shore、water、creek、flood。两簇都拉到零以上——这个单一向量把两种义项都覆盖了——但金融那一簇胜出。这并不是关于这个词的事实;这是语料的偏斜。
下游代码没有任何办法还原出在某个具体句子里指的是哪一种义项,因为它拿到的是一个向量,外加周围一串同样对义项失明的单个向量。
对句法不敏感
「向量袋」式的表示把词序丢掉了。下面两句:
- 狗咬人。
- 人咬狗。
含有完全相同的词集合,因而有完全相同的向量袋表示,尽管含义相反。任何建立在静态嵌入之上的东西,都必须从别处恢复词序——通常是一个直接处理序列的循环层或卷积层。这行得通(2018 年以前的每个 NLP 模型都是这么搭的),但这意味着嵌入本身只做了一部分工作。
训练时被冻结
静态嵌入一旦训练完成就固定了。新的义项、新的复合词、新的领域词汇——向量不会更新。更糟的是,没有出现在训练语料中的词,压根就没有向量。
这三种失效表面看来不同,却共享一个原因:静态嵌入是词的函数,而不是句子的函数。任何依赖句子的东西——义项、角色、篇章位置——都得在嵌入之外处理。这个工作量大到足以约束整个 2018 年前时代的架构。
同样这三种失效也打在其他所有静态词嵌入上——GloVe 与 fastText 用不同的训练流程产出同样的 (V, d) 查找表,所以继承了同样的盲点。要解决它们,就得彻底放弃「每词一个向量」,而这正是 BERT 和那些上下文编码器所做的。
接下来是什么
修法说起来容易,做起来很难:让向量取决于周围的句子,而不只是那个词。bank 不该只有一个向量——它应该在「river bank」里挣到一个,在「bank account」里换成另一个。这正是 BERT 式架构所填上的缺口。
BERT 从 word2vec 继承的东西,比这架构鸿沟所暗示的要多。分布假说被沿用(从上下文预测缺失的 token);那个「用一个假的预测任务,只为逼出网络里好向量」的把戏也被沿用;「几何编码含义」这个赌注同样被沿用。训练任务本身——填空——正是 CBOW 早在 2013 年以微小规模跑过的那个。
改变的是架构与规模。ELMo(2018)用双向 LSTM 与逐位置的隐藏状态做出了第一个认真的版本。BERT(同样是 2018)把 LSTM 换成 Transformer 编码器,并引入了掩码语言建模——在结构上就是去掉了大部分限制的 CBOW:以覆盖整个句子的一叠双向 Transformer 层取代五词窗口上的浅层平均,每个样本多个掩码而非一个,输出是逐 token 的上下文向量而非单独一行冻结的查找表。BERT 那篇文章会详细讲这套机制。