☰
词向量训练与Embedding避坑:Skip-gram、负采样、稀疏梯度
2026/10/26 6:44:34 网站建设 项目流程

简介:这份面向自然语言处理入门者与PyTorch实践者的词向量学习材料,围绕将词汇映射为连续向量并捕捉语义、语法关系展开,覆盖CBOW、Skip-gram等经典思路及小规模英文语料上的训练探索,适合希望理解词嵌入原理并动手复现的开发者。压缩包共6个文件,以Python源码为主,辅以缓存文件和一份英文文本数据,整体约10KB,体量轻巧,便于快速阅读和本地运行。已有3111人学习,说明其在入门群体中具有一定参考价值。代码部分展示了文本预处理、词汇表构建、索引映射、模型定义与训练流程,读者可据此观察向量维度、窗口大小、迭代次数等超参数对结果的影响,学习保存权重及将词向量用于文本分类、情感分析等下游任务的思路,并借助PyTorch动态图与自动梯度机制降低调试门槛。配合简短数据集,也适合作为课堂教学或初步实验的起点。

1. 词向量不是查表:为什么没人写过一个数字,语义却能算出来

词向量表里没有一个数字是人写进去的。训练开始前它是一堆随机浮点,训练结束后「国王」减「男人」加「女人」的最近邻落在「女王」附近——没有人告诉过程序国王是男的、女王是女的。这个反直觉的结果,是自然语言处理里最便宜也最容易被误用的东西:一份几百 MB 的静态词向量,能把一个词压成 300 维向量,让下游的分类、召回、聚类直接从距离起步。PyTorch 里nn.Embedding的核心只有十几行,真正的设计全压在训练目标上——为什么用「猜邻居」这种看似和语义无关的辅助任务,为什么要把全词表 Softmax 换掉,为什么高频词反而要故意丢掉一部分。这几步的机制搞清楚了,才分得清哪些相似度是模型真学到的,哪些只是词频的副产品。

2. Skip-gram 与 CBOW:一个猜邻居的辅助任务怎么就挤出了语义

2.1 分布式假设:语义来自共现,不来自定义

词向量模型的全部合法性建立在一条假设上:一个词的意思,由经常和它一起出现的词决定。「火锅」附近总是「麻辣」「锅底」「蘸料」,「签约」附近总是「合同」「条款」「违约」,这些共现模式被压缩进向量后,语义就变成了几何关系。这条假设的关键在于它是可计算的——不需要词典、不需要标注,只要有足够多的文本,共现统计就能自动生成训练信号。

所以词向量训练从来不是「教模型理解语言」,而是让模型完成一个非常具体的填空任务,然后把它为了完成任务而学到的中间表示拿走用。这个中间表示就是 Embedding 矩阵,任务本身(预测邻居)训练完其实可以扔掉。理解这一点很重要,因为后面所有设计取舍——负采样、下采样、窗口大小——本质都是在问同一个问题:怎么让这个辅助任务逼出来的表示更干净。

共现信号的强度不是均匀的。距离中心词 1 个位置的词,和距离 5 个位置的词,语义关联度差很多;「的」「是」这类高频功能词几乎和所有词共现,带来的是噪声不是信号。这两个问题分别对应窗口设计和下采样,后面会展开。

2.2 Skip-gram:用中心词预测窗口内每个词

Skip-gram 的方向是「给定中心词,预测上下文」。一句话「模型 训练 需要 大量 语料」,取「训练」为中心词、窗口为 2,就得到 (训练, 模型)、(训练, 需要)、(训练, 大量) 三组正样本对。注意是每一对单独算一次损失,而不是把三个上下文当成一个多分类——这个细节决定了它的梯度行为。

Skip-gram 的偏好在实践里很明确:低频词表现更好。因为每个词作为中心词时会被反复使用(窗口内每个位置都贡献一次),低频词的向量更新次数被放大了;而 CBOW 是把上下文平均后预测一个中心词,低频词被平均掉之后就很难留下痕迹。词表里长尾词占绝大多数,这就是很多场景默认选 Skip-gram 的原因。

代价是训练慢。同样一句话,Skip-gram 产生的样本对数量大约是 CBOW 的窗口大小倍。常见的工程折中是缩小窗口、加大批大小、配合下采样砍掉高频词,把有效样本密度提上去。

2.3 CBOW:把上下文平均掉,省下时间丢了什么

CBOW 反过来:给定窗口内的上下文词,预测中心词。实现上把上下文词的向量取平均(或求和)得到一个定长表示,再走输出层。平均这一步是线性的,所以反向传播时梯度会被平均分配到每个上下文词上——窗口越大,单个词分到的梯度越小。

这里有个容易忽略的后果:平均操作让 CBOW 对词序不敏感。「A 打 B」和「B 打 A」在 CBOW 眼里是同一个输入(只是平均顺序不同),语义相反但向量一致。Skip-gram 因为每组对独立计算,反而保留了更多局部顺序信息。做句法相关任务时这个差别会被放大。

工业界的选择通常是:语料大、要快,用 CBOW;要词向量的细腻度、要处理长尾,用 Skip-gram。两者共享同一套输出层和负采样逻辑,代码上切换成本很低。

2.4 数据管道:窗口怎么切、样本怎么流

真正写起来,词向量模型翻车最多的不是模型结构,是数据管道。下面这段是一个可用的 Skip-gram 样本构造器,用生成器流式产出,避免把整份语料的样本对全展开进内存:

import random def skipgram_batches(token_ids, window=5, dynamic=True, seed=0, batch_size=1024): """流式产出 (center, context) 样本对,不预展开,避免大语料 OOM""" rng = random.Random(seed) n = len(token_ids) buf_c, buf_o = [], [] for i, center in enumerate(token_ids): # 动态窗口:实际窗口在 [1, window] 里随机,近的词永远被采到,远的词偶尔被采到 w = rng.randint(1, window) if dynamic else window left = max(0, i - w) right = min(n, i + w + 1) for j in range(left, right): if j == i: continue buf_c.append(center) buf_o.append(token_ids[j]) if len(buf_c) == batch_size: yield buf_c, buf_o buf_c, buf_o = [], [] if buf_c: yield buf_c, buf_o

三个参数值得单独说。window是最大窗口半径,语料里常见的经验值是 5,语料越小窗口可以越小,否则共现信号会被稀释。dynamic=True开启动态窗口,让距离近的词以高概率被采样、距离远的词以低概率被采样——常见的实现思路是给距离 d 的词一个 1/d 的保留概率,这里用「随机半径」做了个廉价近似,效果接近且实现更简单。batch_size控制的是样本对数量而不是句子数量,因为它直接决定一次反向传播里 Embedding 矩阵有多少行被更新,太小则梯度抖,太大会撑爆显存。

token_ids是已经映射成整数下标的词序列,构造词表时按词频从高到低编号,把0留给<pad>、1留给<unk>。这个约定在后面配padding_idx时会用上。

3. 负采样:为什么把全词表 Softmax 换成二分类反而更准

3.1 全词表 Softmax 的真实代价:不是算不动,是梯度太散

原始 Word2Vec 的输出层是一个 V 分类 Softmax,V 是词表大小。词表 10 万时,每步要算 10 万次点积再归一化,单步计算量已经够呛,但更麻烦的是梯度:Softmax 的梯度会分摊到所有类别上,对每个负类(不相关的词)都要做一次「往下压」的更新。一次更新要动 10 万个向量,而其中真正有意义的只有正样本那一个。

这带来两个后果。一是慢,10 万维的矩阵乘在 CPU 上就是瓶颈;二是更新信噪比低,绝大多数负类词的梯度贡献微弱且互相抵消,等于在做无用功。层次 Softmax 是一种解法,用一棵 Huffman 树把 V 分类降到 log(V) 次二分类,但它引入了树结构、实现复杂,且高频词路径短、低频词路径长,会加剧长尾词训练不足。

3.2 负采样把 V 类分类改写成 K+1 个二分类

负采样的思路很直接:不要求模型在所有词里挑出正确答案,只要求它区分「真实共现的词对」和「随机抽出来的假词对」。每个正样本配 K 个随机负样本(K 通常 5 到 15),模型对每一对做一次二分类:这对是真的还是假的。

数学上这叫噪声对比估计。它的妙处在于,最优解下模型学到的词向量之间的点积,与全 Softmax 学到的内积只差一个常数偏移——也就是说,你用便宜得多的二分类,逼近了原本昂贵的目标。这个结论值得记住,它解释了为什么负采样不是「近似偷懒」而是「等价替换」。

代价是每次更新只动 K+1 行输出向量,而不是全表。原来一步要看 10 万行,现在只看 6 行。训练速度提升几十倍,且负类的更新集中在真正有区分度的样本上。

3.3 0.75 次方:负样本分布为什么不是词频本身

负样本怎么抽,直接决定模型学到什么。按均匀分布抽,高频词和低频词被抽中的概率一样,但高频词本来就该被压得更狠——结果是对「的」这种词的判别能力不足。按原始词频抽,又矫枉过正:高频词被抽中的概率过高,模型把大量更新花在区分功能词上,实义词学不好。

常见的做法是取词频的 0.75 次方再归一化。这个指数是个经验值,效果是把高频词的采样概率往下压、低频词往上抬,让负样本分布更接近「有信息量的词」的分布。实现上就是一行:

import torch # freq: 每个词在语料中的出现次数(长度 V 的 LongTensor) freq = torch.tensor([...], dtype=torch.float) p = freq.pow(0.75) # 0.75 次方压高频抬低频 p = p / p.sum() # 归一化成采样分布 # 一次抽 K 个负样本,replace=True 允许重复,避免低频词不够抽 neg_ids = torch.multinomial(p, num_samples=K, replacement=True)

replacement=True不能省。词表里长尾词占了绝大多数,如果不放回抽样,K 稍大就可能抽不出来。另外注意p里要提前把<pad>、<unk>这类特殊符号的频次置零,否则它们会作为负样本参与训练,白白污染向量空间。

3.4 损失写法与数值稳定性

负采样的损失是「正样本走 sigmoid 取正类 + 负样本走 sigmoid 取负类」,两者取对数后相加。写法上有讲究:

import torch import torch.nn.functional as F def nce_loss(v_c, u_o, u_neg): """ v_c: [B, D] 中心词向量 u_o: [B, D] 正样本(真实上下文)向量 u_neg: [B, K, D] 负样本向量 返回标量损失 """ pos = (v_c * u_o).sum(-1, keepdim=True) # [B, 1] 正样本得分 neg = torch.bmm(u_neg, v_c.unsqueeze(-1)).squeeze(-1) # [B, K] 负样本得分 # 用 logsigmoid 而不是 log(sigmoid(x)),避免 x 很负时下溢成 log(0) loss = -(F.logsigmoid(pos).mean() + F.logsigmoid(-neg).mean()) return loss

两个细节。第一,logsigmoid不能换成torch.log(torch.sigmoid(x)),因为得分在训练初期可能到 -30 这种量级,sigmoid直接下溢成 0,梯度变成 NaN,训练当场崩掉——这是血泪经验里最常见的一条。第二,正负两部分分别取mean再相加,而不是先拼起来再mean,因为负样本数量 K 通常远大于 1,拼起来平均会让正样本的权重被稀释成 1/(K+1)。

v_c、u_o、u_neg三份向量来自两个不同的矩阵:中心词矩阵和上下文词矩阵。训练结束后一般只保留中心词矩阵作为最终词向量,或者把两个矩阵相加/拼接,后者在小语料上通常略好一点。

4. nn.Embedding 的里面:一次 gather 前向,一次 scatter-add 反向

4.1 前向是 gather,反向是 scatter-add

nn.Embedding常被当成黑匣子,其实它的行为可以用三行 PyTorch 复现出来。前向就是按下标取行,反向就是把梯度按同样的下标加回去:

import torch V, D = 10, 4 weight = torch.randn(V, D, requires_grad=True) ids = torch.tensor([3, 3, 7]) # 注意 3 出现了两次 out = weight[ids] # 等价于 nn.Embedding(V, D)(ids) loss = out.sum() loss.backward() print(weight.grad[:, 0]) # tensor([0., 0., 0., 2., 0., 0., 0., 1., 0., 0.])

第 3 行拿到 2.0,第 7 行拿到 1.0,其余全是 0。这说明了 Embedding 反向传播的本质:同一行被取了几次,梯度就累加几次。这就是「稀疏梯度」的来源——一个批次里通常只涉及词表的一小部分行,梯度矩阵里 99% 以上是零。

理解这一点,后面的优化器选择、sparse=True、学习率设置就都顺理成章了。

4.2 sparse=True 与优化器:为什么 Adam 会毁掉稀疏性

默认情况下 PyTorch 会把梯度物化成一个完整的 V×D 稠密张量。词表 50 万、维度 300 时,这个张量是 6 亿个浮点数,光梯度就 2.4 GB,还没算 Adam 的一阶二阶动量——每个都是同样大小。三份加起来 7 GB 起步,大部分位置还是零。

nn.Embedding(V, D, sparse=True)让梯度以稀疏张量形式返回,只存非零行。但代价是它不能直接喂给torch.optim.Adam,必须换成SparseAdam。原因在于 Adam 的更新公式里有m / (sqrt(v) + eps),如果只更新部分行,那些没被更新的行的动量会一直保留旧的衰减值,等你下次碰到它时,用的是一个「过期」的动量估计,更新方向会偏。SparseAdam的做法是只对本次出现的行更新动量,未出现的行保持原状——这在数学上不等价于标准 Adam,但在 embedding 这种极端稀疏场景下实践效果可接受。

方案显存占用更新正确性适用场景
稠密梯度 + Adam3 倍 V×D严格小词表(< 5 万)
稀疏梯度 + SparseAdam仅非零行近似大词表训练
稀疏梯度 + SGD仅非零行严格追求稳定复现
分片/分桶参数服务器按需视实现超大规模多卡

如果词表不大,稠密梯度反而省心,因为SparseAdam不支持权重衰减、不支持梯度裁剪,调参空间被砍掉一截。这个取舍在实际项目里经常被忽略。

4.3 初始化尺度、padding_idx 与权重共享

nn.Embedding默认用 N(0, 1) 初始化。对词向量来说这个尺度偏大:初始向量的模长和训练后的模长不在一个量级,前期梯度会被大幅度的初始值主导。常见的做法是改成 N(0, 0.1²) 或者用均匀分布 U(-0.5/D, 0.5/D)——后者是原始实现的默认值,思路是让初始向量的点积量级接近 0,避免一开始就出现极端得分。

padding_idx是个容易被误用的参数。设了它之后,该行在前向时输出全零、反向时梯度恒为零,相当于永久冻结。这适合<pad>,但不适合<unk>——未登录词是需要学习的,把它冻住等于放弃处理 OOV。见过不少实现把两个都设成padding_idx,结果模型对生僻词永远输出零向量,下游相似度计算直接拿到一堆零。

权重共享是另一个技巧:输出层如果也用 Embedding 表示(而不是单独的线性层),可以让输入矩阵和输出矩阵指向同一个Parameter。这样参数量减半,且两个视角的共现信息被强制对齐。小语料上这个做法通常能带来可见的提升,大语料上差别不大。

4.4 手写一遍最小实现,看梯度落在哪

把上面几块拼起来,一个最小的 Skip-gram + 负采样训练循环大概长这样:

import torch import torch.nn as nn from torch.optim import SparseAdam class SkipGramNS(nn.Module): def __init__(self, vocab_size, dim=128): super().__init__() # sparse=True:梯度以稀疏张量返回,大词表必备 self.in_emb = nn.Embedding(vocab_size, dim, sparse=True, padding_idx=0) self.out_emb = nn.Embedding(vocab_size, dim, sparse=True) # 初始化尺度压小,避免前期点积过大导致 logsigmoid 饱和 nn.init.uniform_(self.in_emb.weight, -0.5 / dim, 0.5 / dim) nn.init.zeros_(self.out_emb.weight) def forward(self, center, pos, neg): v_c = self.in_emb(center) # [B, D] u_o = self.out_emb(pos) # [B, D] u_n = self.out_emb(neg) # [B, K, D] pos_score = (v_c * u_o).sum(-1, keepdim=True) neg_score = torch.bmm(u_n, v_c.unsqueeze(-1)).squeeze(-1) return -(F.logsigmoid(pos_score).mean() + F.logsigmoid(-neg_score).mean()) model = SkipGramNS(vocab_size=50000, dim=128) opt = SparseAdam([p for p in model.parameters() if p.requires_grad], lr=2.5e-3)

SparseAdam的学习率经验值比普通 Adam 大一个量级,2.5e-3 到 5e-3 之间比较常见,原因是稀疏更新下每个词被打到的次数少,步子大一点才能在小样本上走出来。但步子太大会让高频词震荡,所以配合第 5 章的下采样一起用效果更稳。

padding_idx=0只设在输入矩阵上,输出矩阵不设——因为输出矩阵的第 0 行如果被冻结,作为负样本出现时就学不到「该被压低」这件事。这个细节在多数教程里被略过了。

5. 训练链路避坑:损失在降,相似度却不涨,卡在哪一步

5.1 高频词下采样:把「的」的梯度压下去

语料里「的」「是」「一个」这类词的频次可能是实义词的几十倍。它们几乎和所有词共现,带来的结果是:训练时大量样本对以它们为中心词,梯度被反复灌进这几行,而它们的语义贡献接近于零。更糟的是,它们作为上下文出现时会稀释实义词之间的共现信号。

下采样的做法是按词频给每个词一个保留概率,高频词大概率被丢掉,低频词全保留。公式是p_keep = (sqrt(f/t) + 1) * t / f,其中 f 是词频占比、t 是阈值(常用 1e-4 到 1e-5):

import math, random def subsample(tokens, counter, t=1e-5, seed=0): """按词频丢弃高频词,返回过滤后的序列""" rng = random.Random(seed) total = sum(counter.values()) out = [] for w in tokens: f = counter[w] / total if f <= t: out.append(w) # 低频词全保留 continue p_keep = (math.sqrt(f / t) + 1) * t / f if rng.random() < p_keep: # 高频词按概率保留 out.append(w) return out

t调小,丢弃得更狠,训练更快但可能丢掉部分语法信息;t调大,保留更多功能词,适合做句法相关任务。经验上1e-4是个平衡点,语料上亿时往1e-5走。

这段代码有个坑:它对每条句子独立做随机采样,同一句话在多个 epoch 里被丢掉的词不一样,等于做了数据增强。如果要求完全可复现,随机种子必须按 epoch 重置,否则不同进程跑出来的词表统计会对不上。

5.2 动态窗口与学习率退火

窗口大小不是固定值就最好。前面提的动态窗口,本质是给不同距离的共现对不同的权重。固定窗口等于认为距离 1 和距离 5 的共现一样重要,这显然不对。

学习率退火在词向量训练里同样有用,但策略和常规分类任务不同:常见做法是线性衰减到初始值的万分之一。前期大步长让向量快速分开,后期小步长让相似词收敛到相近位置。如果不退火,训练末期的损失会在一个平台上来回跳,相似度评估的方差很大——同一个模型跑两次,最近邻结果能差出三成。

5.3 排查清单:四条典型的翻车记录

现象一:任意两个词的余弦相似度都在 0.9 以上,最近邻基本没区分度。原因通常是初始化尺度过大,或者没有做向量归一化就计算余弦。另一个高频原因是负采样数量太少、训练轮次不够,向量还挤在初始位置附近没有散开。解决:把初始化改成 U(-0.5/D, 0.5/D),确认相似度计算前做了 L2 归一化,负样本数提到 10 以上,并检查损失是否真的降到了合理区间(Skip-gram + 负采样一般在 2 以下,居高不下说明管道有问题)。

现象二:损失平稳下降,但拿近义词去查最近邻,出来的全是高频词。这是高频词没做下采样的典型症状。高频词因为在大量样本对里出现,向量模长被推得很大,做点积时天然占优,无论查什么词都排前面。解决:加下采样,或者在相似度计算时对向量做归一化(归一化后点积退化为余弦,模长的影响被消除)。另外检查负采样分布是否正确用了 0.75 次方,用均匀分布也会加重这个问题。

现象三:验证集里出现大量<unk>,OOV 比例高得离谱。词表是按训练语料的词频截断的,验证集里天然会有未登录词。如果截断阈值设得太高(比如只保留频次 10 以上的词),OOV 会非常多。解决:词表规模按覆盖率达到 99.9% 来定,而不是拍一个固定值;<unk>不要设padding_idx,让它参与训练,至少在向量空间里能占一个「泛化位」。更彻底的做法是引入子词 n-gram,把词拆成字符片段,OOV 时用片段向量求和兜底——这也是后续模型演化的方向。

现象四:训练完发现两个语义完全相反词的相似度很高。分布式假设只捕捉共现,不捕捉极性。「喜欢」和「讨厌」出现在几乎相同的上下文里,向量自然接近。这不是 bug,是方法的固有局限。解决:如果任务对极性敏感,词向量只能当初始化,必须在带标签的下游任务上微调;或者引入情感词典做后处理。指望纯无监督词向量区分反义,是在跟假设本身较劲。

6. 设计取舍:静态词向量还剩多少价值,怎么验证它真的学到了东西

6.1 静态与上下文的取舍

词向量最根本的局限是「一个词一个向量」。多义词在静态表示里被强行平均成一个点,「苹果」的水果义和公司义挤在同一处,谁也分不开。上下文相关表示(用深层模型按句编码)解决了这个问题,代价是推理时每个词都要过一次模型,无法预先算好存成表。

维度静态词向量上下文相关表示
存储一张 V×D 表,查表 O(1)需实时前向,无预计算表
多义词无法区分按上下文区分
OOV需要子词兜底子词天然支持
训练成本单机 CPU 小时级GPU 天级
适用场景召回、聚类、冷启动、特征工程精排、理解类任务

这份资源里的实现走的是静态路线,判断它值不值得用,看场景:做向量召回、做文本聚类、给下游模型提供初始化、做冷启动兜底,静态词向量依然是性价比最高的选择,因为它的推理成本几乎为零,一张表塞进内存就能查。反过来,如果任务是细粒度的语义判断,静态表示的天花板很快就会撞到。

6.2 怎么验证一个嵌入真的学到了东西

不要只看损失曲线。损失降只说明模型在拟合辅助任务,不代表向量空间有意义。三个可操作的验证手段,按成本从低到高:

第一,近邻检查。挑 50 个高频实义词,打印每个词的 top-10 最近邻,人工扫一遍。这个方法很土但极其有效,能立刻暴露高频词霸榜、语义簇混乱、向量塌缩这几类问题。

第二,类比任务。用「A 之于 B 等于 C 之于 ?」的形式做批量测试,计算vec(B) - vec(A) + vec(C)的最近邻,统计命中率。注意要排除输入词本身,否则会命中自己。

第三,下游任务微调对比。把词向量冻结作为 embedding 初始化,和一个随机初始化的 baseline 跑同一个分类任务,看收敛速度和最终指标。这是最有说服力的验证——如果冻结的词向量带来的提升还不如随机初始化,说明训练链路某处出了问题,回到第 5 章的清单逐条排查。

从那以后我每次训完一份词向量,都会先跑一遍近邻检查再谈效果,哪怕是赶时间。因为损失曲线永远在降,只有最近邻列表会诚实地告诉你,那些向量到底学到了语义,还是只学到了词频。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询