☰
词向量不是查表:Skip-gram负采样与梯度机制
2026/10/26 15:04:52 网站建设 项目流程

简介:这份基于PyTorch的词向量模型面向自然语言处理初学者与希望动手理解词嵌入的机器学习开发者,围绕小规模英文语料展开词向量训练实践,可用于文本分类、情感分析等任务的预处理入门。压缩包共6个文件,以3个Python源码、2个pyc字节码和1个txt数据文件为主,整体约10KB,分别承担模型与数据处理逻辑、运行缓存及英文词表语料等用途。现有3111人学习,适合作为低成本的教学与初步探索样本。读者可借助配套代码走通分词、构建词汇表、词索引映射、模型搭建与训练评估流程,并尝试调整向量维度、窗口大小和迭代次数等超参数,观察语义相近词的向量距离变化;同时可参考权重保存与调用示例,把词向量用于下游NLP任务的输入表示,从而加深对PyTorch动态计算图和自动求导机制的理解。

1. 词向量不是一张查表:语义是怎么被梯度“压”进矩阵里的

在自然语言处理的学习路径上,用 PyTorch 写一个词向量模型常被当成练手活:几十行代码,几十分钟训练,看起来就完事了。但真跑过的人大多踩过同一个坑——loss 从 8 降到 2,兴致勃勃查近邻,排在第一的仍然是“的”“了”“是”。这张矩阵里到底存了什么、又是谁把它写进去的,如果不搞清楚,调参就全靠玄学。真正让语义浮现的,不是那张 V×D 的查找表,而是反向传播过程中一次次被累加的梯度;表只是载体,梯度才是内容。搞懂这条链路,才能明白为什么窗口大小会改变相似度的性质、为什么负采样要故意“压平”高频词、为什么换个随机种子结果就散了。

2. 从 one-hot 到条件概率:Skip-gram 到底在优化什么

2.1 两种架构的目标函数差在哪

one-hot 的问题不在于稀疏,而在于它把任意两个词的内积都定义成 0——在它眼里,“猫”和“狗”的距离与“猫”和“微积分”完全一样。分布式假设换了个判据:一个词的含义由它出现的上下文决定,于是把“词义”这件事从静态属性变成了条件概率。词向量模型要学的,就是让 P(上下文 | 中心词) 或 P(中心词 | 上下文) 在真实语料上最大。

这两条路对应两种架构。CBOW 把窗口内的上下文向量取平均,去预测中心词;Skip-gram 反过来,拿中心词去预测窗口里的每一个上下文词。这里有个常被误解的点:CBOW 的隐藏层没有任何非线性激活,它只是把若干行向量相加再除以窗口大小,所谓“神经网络”其实退化成了一次加权查表加一层 softmax。Skip-gram 更极端,它对窗口内每个位置单独算一次预测,同一个中心词在一句话里会被更新多次。

用公式写清楚,Skip-gram 的单个样本目标是最大化 $\log \sigma(u_o^\top v_c) + \sum_{k=1}^{K}\log \sigma(-u_k^\top v_c)$,其中 $v_c$ 是中心词在输入矩阵里的行,$u_o$ 是正样本上下文在输出矩阵里的行,$u_k$ 是负样本的行。注意这里有两套参数矩阵:输入矩阵和输出矩阵。训练结束后通常只保留输入矩阵,输出矩阵在多数实现里直接丢弃——它更像一个临时的“打分器”,不承担词表示的职责。

两种架构的选择不是口味问题。语料小、训练快,CBOW 更稳,因为它对每个窗口做了一次平均,噪声被平滑掉;语料大、想照顾低频词,Skip-gram 更好,因为低频词在 CBOW 里会被上下文的平均稀释,而在 Skip-gram 里它自己是条件,会被单独更新好几次。这个差异在长尾词上非常明显,血泪经验是:小语料上跑 Skip-gram,低频词的近邻经常是一堆噪声。

2.2 窗口大小决定了“语义半径”

窗口取 2 和取 10,得到的不是同一个东西的好坏版本,而是两种不同性质的相似度。窗口小,模型只能看到紧邻的词,学到的偏向句法功能上的可替换性——“电脑”和“计算机”在句子里位置相同,向量就会靠近;窗口大,模型能看到同一段话里共现的主题词,“键盘”“屏幕”“显卡”会聚到一起。换句话说,窗口是在定义“用什么范围来定义这个词”,范围一变,定义就变。

原版实现里还有一个容易被忽略的细节:窗口是动态的。每个中心词采样时,实际窗口从 1 到最大值之间随机取,越近的上下文被采到的概率越高。这个设计很朴素,但它让同一个词在不同轮次看到不同的上下文范围,相当于给训练加了正则,近邻词的权重天然更大。很多复现版本把它写成固定窗口,能跑,但相似度列表会明显更“糊”。

提示:如果下游任务是句法相关的(比如词性标注的特征),用小窗口;如果是主题聚类或推荐里的物品共现,用大窗口。这不是超参搜索能替代的先验。

2.3 把语料滑窗变成训练样本的最小实现

理论讲完,落到具体代码上其实只有一步:把句子变成 (中心词, 上下文词) 的索引对。这一步做错,后面全白搭。

import random def window_pairs(tokens, max_window=5): """把一个句子切成 (center, context) 索引对""" pairs = [] for i, center in enumerate(tokens): # 动态窗口:每次随机取 1..max_window,近的上下文概率更高 w = random.randint(1, max_window) lo = max(0, i - w) hi = min(len(tokens), i + w + 1) for j in range(lo, hi): if j == i: # 跳过中心词自己 continue pairs.append((center, tokens[j])) return pairs

max_window是窗口上限,random.randint实现的是原版里那个线性收缩的动态窗口;lo和hi用max/min截断,保证句首句尾不越界。返回的是索引而不是词本身,因为后续要走nn.Embedding的查表,全程保持整型张量比维护字符串字典快一个量级。

样本对生成之后还要过两道工序:按词频做下采样丢掉部分高频词,再按 batch 组织成 center、context、negatives 三个张量。负样本不在这一步采,通常放到训练循环里按预先构建的采样表抽,这样能复用同一张表,也方便固定随机性。

3. 负采样:全词表 softmax 为什么在工程上不可行

3.1 softmax 的分母是训练成本的元凶

Skip-gram 原始形式是多分类:给定中心词,从 V 个词里选出正确的上下文。softmax 的分母要对全部 V 个词做 exp 再求和。V 取十万、向量维度 300,一条样本就要做 3×10^7 次乘加,而语料里可能有上亿条样本。这不是慢一点的问题,是根本跑不完。

层次 softmax 是一种解法,把 V 个词组织成哈夫曼树,把一个 V 分类拆成 log V 次二分类,代价降到 17 次左右。负采样的思路更彻底:既然目的是让模型区分“真上下文”和“假上下文”,那就不必知道完整的概率分布,只要随机抽 K 个词当反例,把多分类改成 K+1 个二分类就行。K 通常取 5 到 20,计算量与 V 彻底解耦。

代价是目标变了。负采样优化的不再是原始 softmax 的似然,而是一个与其渐近等价的目标:正样本打高分,负样本打低分。这个词——“渐近等价”——很重要,它意味着 K 太小时梯度估计的方差会很大,训练曲线会很抖。工程上的经验区间是 K 在 5 到 15 之间,语料越大可以取得越小。

3.2 3/4 次方:负采样分布为什么刻意“压平”高频词

如果按原始词频抽负样本,抽到的大概率是“的”“是”“在”这类词。它们在任何上下文里都出现,模型学不到任何区分度,等于白算。原版给了一个很巧的处理:采样概率正比于词频的 0.75 次方。

import numpy as np def build_neg_table(counter, table_size=1_000_000, power=0.75): words = list(counter.keys()) freqs = np.array([counter[w] for w in words], dtype=np.float64) weights = freqs ** power # 3/4 次方:抬高低频,压低高频 weights /= weights.sum() # 展开成定长查表数组,采样时 O(1) 而不是每次走多项式分布 table = np.random.choice(words, size=table_size, p=weights) return table

power=0.75是原版试出来的经验值,没有严格理论推导;table_size取一百万是精度和内存的折中,越大越接近真实分布。把离散分布预先展开成一张数组,采样就退化成一次随机下标访问,这在 Python 里比反复调用np.random.choice(p=...)快几十倍——训练循环里每一次采样都走这条路,累积起来相当可观。

这个 0.75 的直觉是:让负样本分布更平,避免负样本过度集中在少数几个虚词上。极端情况下 power 取 0 就是均匀采样,虚词不再霸占负样本,但稀有词被抽中的概率过高,反而引入了噪声。0.75 是在两端之间找到的一个平衡点。

3.3 PyTorch 实现:logsigmoid 与批量负样本打分

真正写模型时,数值稳定性比公式好看更重要。直接写torch.log(torch.sigmoid(x)),当 x 是很大的负数时 sigmoid 会下溢成 0,log 变成 -inf,一次梯度爆炸就把整张表污染了。

import torch import torch.nn as nn import torch.nn.functional as F class SkipGramNS(nn.Module): def __init__(self, vocab_size, embed_dim): super().__init__() self.in_embed = nn.Embedding(vocab_size, embed_dim) # 中心词表 self.out_embed = nn.Embedding(vocab_size, embed_dim) # 上下文/负样本表 bound = 0.5 / embed_dim # 原版用小方差均匀分布初始化,避免初始点积过大导致 sigmoid 饱和 self.in_embed.weight.data.uniform_(-bound, bound) self.out_embed.weight.data.uniform_(-bound, bound) def forward(self, center, context, negatives): # center: (B,) context: (B,) negatives: (B, K) v = self.in_embed(center) # (B, D) u_pos = self.out_embed(context) # (B, D) u_neg = self.out_embed(negatives) # (B, K, D) pos_score = torch.sum(v * u_pos, dim=1) # (B,) pos_loss = -F.logsigmoid(pos_score) # -log σ(u_o^T v_c) # (B, K, D) x (B, D, 1) -> (B, K, 1) -> (B, K) neg_score = torch.bmm(u_neg, v.unsqueeze(2)).squeeze(2) neg_loss = -F.logsigmoid(-neg_score).sum(dim=1) # -Σ log σ(-u_k^T v_c) return (pos_loss + neg_loss).mean()

B是 batch 大小,D是向量维度,K是每个正样本配的负样本数。logsigmoid内部用 log-sum-exp 的稳定形式计算,等价于-log σ(x)但不会溢出。负样本那一步用bmm一次算完整个 (B, K),比在 Python 里循环 K 次要快得多,也更容易被 CUDA kernel 合并。

梯度可以手推一下,验证实现是否符合预期:$\partial L/\partial v_c = (\sigma(s_{pos}) - 1),u_o + \sum_k \sigma(s_{neg,k}),u_k$。第一项是正样本的“欠打分”修正,第二项是负样本的“过打分”修正,两者方向相反,这就是为什么负采样能让中心词向量被推向正样本、推离负样本。

4. nn.Embedding 的底层:前向索引、反向 scatter-add

4.1 前向是一次 gather,反向是一次 index_add

nn.Embedding常被当成黑匣子,其实它只做两件事:持有一个形状为 (V, D) 的Parameter,前向时按索引取行。反向时情况稍微复杂一点——同一个 batch 里同一个词可能出现多次,梯度不能覆盖,必须累加。这正是index_add_的语义。

import torch class ManualEmbedding(torch.autograd.Function): @staticmethod def forward(ctx, weight, idx): ctx.save_for_backward(idx) ctx.weight_shape = weight.shape return weight[idx] # gather:按行取 @staticmethod def backward(ctx, grad_out): idx, = ctx.saved_tensors grad_w = torch.zeros(ctx.weight_shape, dtype=grad_out.dtype) # scatter-add:同一行被索引多次时梯度累加,而不是覆盖 grad_w.index_add_(0, idx.view(-1), grad_out.view(-1, grad_out.size(-1))) return grad_w, None

这段代码解释了训练中的一个常见现象:一个 batch 里某个词出现 8 次,那一行这一轮的更新幅度就是出现 1 次时的 8 倍。所以 batch 组织方式会直接影响词频高低的相对学习速度——按句子顺序组 batch 和随机打散组 batch,得到的向量分布会有可观测的差异。

也解释了为什么梯度是稠密张量时代价很大:即使一个 batch 只涉及 256 个不同的词,反传也要分配一个完整的 (V, D) 梯度矩阵并对其清零。V 是十万、D 是 300,那就是 1.2 亿个浮点数,每次反传都来一遍,内存带宽直接被吃满。

4.2 sparse=True:省内存的代价是什么

PyTorch 提供了sparse=True开关,打开后反向只返回一个 COO 稀疏张量,只记录被索引到的行和对应的梯度值,不需要分配整个稠密矩阵。对大词表来说这是数量级的节省。

emb = nn.Embedding(vocab_size, embed_dim, sparse=True) # 注意:稀疏梯度只能配少数优化器,Adam 会直接报错 opt = torch.optim.SparseAdam(emb.parameters(), lr=1e-3)

代价有三条。第一,优化器受限:SparseAdam、SGD、Adagrad支持稀疏梯度,常用的Adam不支持,直接把稀疏参数丢进去会抛异常。第二,算子受限:稀疏梯度在部分操作上会退化成稠密,比如做梯度裁剪或梯度范数统计时。第三,小词表上未见得更快:稀疏 kernel 本身有额外开销,V 只有几千时反而不如稠密。

踩坑记录里最常见的一条是:为了省内存打开sparse=True,结果在优化器那一步才炸,报错信息还很不直观。稳妥做法是先把sparse当成一个显式决策写在配置里,而不是随手加。

4.3 初始化与归一化:小方差均匀分布的由来

PyTorch 的nn.Embedding默认用 N(0, 1) 初始化。对普通网络层这没问题,但对词向量模型有隐患:维度 300 时两个随机向量的内积标准差大约在 17 左右,sigmoid 早就饱和了,初始梯度接近 0,训练前期几乎不动。

原版实现用的是均匀分布 $U(-0.5/D, 0.5/D)$,方差约 $1/(12D)$,内积的量级被压到 1 附近,sigmoid 处在敏感区,梯度能正常回传。这就是上面代码里bound = 0.5 / embed_dim的由来,不是拍脑袋的常数。

训练结束后的 L2 归一化是另一件事:它不改变向量的方向,只是让所有向量落在单位球面上,余弦相似度退化成内积,检索时少一次除法。这一步不影响模型质量,纯粹是给下游用起来方便。但要注意,归一化之后欧氏距离和余弦相似度的排序完全一致,如果你在做距离阈值的判断,归一化前后的阈值不能混用。

5. 训练排查:loss 在降,词向量却“不听话”

5.1 现象一:近邻列表被虚词霸占

现象很典型:查任意一个实词的 top-5 近邻,里面总有一两个是“的”“了”“在”。

原因是高频词在语料里出现的次数是低频词的成千上万倍,梯度更新次数也差着数量级,它们的向量被反复拉动,最终收敛到一个“跟谁都很像”的位置。负采样的 0.75 次方只缓解了负样本侧的偏差,正样本侧没管。

解决手段是下采样:按词频以一定概率直接丢弃高频词,公式是 $P_{discard}(w) = 1 - \sqrt{t/f(w)}$,$t$ 通常取 $10^{-4}$ 到 $10^{-5}$。

import numpy as np def subsample_keep_prob(counter, total, t=1e-4): """返回每个词的保留概率,丢弃概率 = 1 - sqrt(t / f)""" keep = {} for w, c in counter.items(): f = c / total keep[w] = min(1.0, (np.sqrt(t / f) + t / f)) return keep

t越大丢得越狠。这个公式的效果是:词频高于 $t$ 的词按比例丢弃,低于 $t$ 的词完整保留。实际实现里还有一种等价写法是先算丢弃概率再取反,两种写法结果一致,但要注意t/f大于 1 时要截断,否则保留概率会超过 1。

5.2 现象二:训练几轮后 loss 卡在某个值不降

原因通常不是模型容量不够,而是有效学习率塌了。词向量模型的梯度是稀疏的,每个词一轮只被更新几次,如果学习率一开始就设得很大(比如 0.1),前期 loss 掉得飞快,几轮之后大部分词已经跑到饱和区,梯度接近 0,loss 曲线就平了。

解决分两步。学习率从 0.025 起步并线性衰减到 0.0001 量级,这是原版的做法,衰减让后期微调不被大步伐破坏。同时检查 batch 的组织方式:按句子数组 batch 会让长句主导梯度,按 token 数组 batch 更均匀。

还有个隐蔽原因:负样本数太少。K 取 2 时梯度方差极大,loss 会在一个较高的值附近震荡,看起来像“卡住”。把 K 提到 5 到 15,曲线通常会重新往下走。

5.3 现象三:换台机器结果复现不出来

现象是同一份代码、同一份语料,两次训练的相似度列表差异明显,甚至类比任务准确率差好几个点。

原因是随机性来源太多:动态窗口每次randint、负采样表构建、batch 打乱、DataLoader 的多进程。任何一处没固定,结果都会漂。更麻烦的是多进程下numpy和random的种子不会自动同步到子进程。

解决方式是把种子固定成显式配置项,在训练入口统一设置random、numpy、torch三处,DataLoader的worker_init_fn里再设一次,调试阶段把num_workers设成 0。负采样表构建完之后序列化存下来,下次直接加载,避免每次重建带来的分布差异。

5.4 现象四:拿两次训练的向量直接比余弦相似度

这个坑很隐蔽:模型 A 里“猫”和“狗”的余弦是 0.72,模型 B 里是 0.51,就断言 B 学得差。

真实原因是两次训练得到的向量空间只保证在旋转和缩放意义下等价,坐标轴本身没有对齐。直接跨模型比较相似度数值没有意义。要比较,得先做正交对齐(Procrustes 方法),或者干脆不比数值,只比 top-k 近邻的重合率。

还有一个相关的误用:拿 t-SNE 图判断模型好坏。降维图只反映局部邻域关系,簇的形状和间距受 perplexity 影响极大,两张图看起来“分得更开”不代表向量更好。判断质量还是得靠近邻抽查加下游任务。

6. 设计取舍:静态词向量的边界与仍然值得复现的理由

6.1 三个绕不过去的边界

第一个边界是一词多义。“苹果”在水果和品牌两个语境下的向量是同一个,这是静态词向量的结构性缺陷,不是训练不够。第二个是未登录词:词表里没有的词只能落到<unk>,而fasttext那类用子词信息的方案正是为了补这个洞。第三个是上下文敏感:同一个词在不同句子里应该有不同表示,静态模型给不出来,这是后来上下文相关模型要解决的问题。

知道边界在哪,才知道它适合放在什么位置。作为下游模型的 Embedding 初始化、作为冷启动阶段的物品或文本表示、作为检索系统里的粗排特征,静态词向量依然够用而且便宜。非要拿它去做需要精细语义区分的任务,翻车是迟早的。

6.2 一次可复现的验证:别只看 loss

验证词向量质量有一套轻量的流程,比盯着 loss 曲线靠谱得多。近邻抽查是最直接的:挑十几个高频实词,看 top-10 里有没有明显的垃圾词。类比任务是第二个抓手,king - man + woman ≈ queen这种,用向量加减后取最近邻,统计命中率。

import torch.nn.functional as F def topk_similar(word, weight, w2i, i2w, k=10): vecs = F.normalize(weight.data, dim=1) # L2 归一化,内积即余弦 q = vecs[w2i[word]] sims = vecs @ q idx = torch.topk(sims, k + 1).indices[1:] # 跳过词自己 return [(i2w[i.item()], round(sims[i].item(), 3)) for i in idx] def analogy(a, b, c, weight, w2i, i2w, k=1): vecs = F.normalize(weight.data, dim=1) target = vecs[w2i[b]] - vecs[w2i[a]] + vecs[w2i[c]] sims = vecs @ target for w in (a, b, c): # 排除三个输入词 sims[w2i[w]] = -1e9 return i2w[torch.argmax(sims).item()]

F.normalize沿维度 1 做归一化,保证相似度就是余弦;topk取k+1再切掉第一个,是因为查询词自己永远排第一。类比函数里把三个输入词的分数置为负无穷,避免它们自己被返回。

6.3 维度、窗口、负样本数的取舍

参数常见取值变大的影响变小的代价
向量维度100 / 200 / 300表达力强,内存和训练时间线性涨语义容量不够,近邻区分度差
窗口2 到 10偏向主题相似偏向句法可替换性
负样本数 K5 到 15梯度方差小,训练稳方差大,loss 抖
下采样阈值 t1e-4 到 1e-5高频词丢得多,低频词更突出虚词仍然霸榜
最小词频5 到 20词表小,未登录多词表大,低频词向量质量差

这张表里的每一行都不是独立生效的。维度提高而窗口不变,易出现“维度足够但语义半径太窄”的尴尬;负样本数提高而学习率不降,前期容易冲过头。调参的顺序一般是先定窗口和最小词频,再定维度和 K,最后扫学习率。

复现这类模型,我养成的习惯是每次开训前先把种子、词表规模、有效窗口分布、负采样表哈希值打印到日志第一屏——这四行信息能挡掉八成的“结果对不上”的争论。静态词向量的原理不复杂,难的是每个选择背后的因果链能不能说清;把这条链走通一次,再去看后来那些上下文相关的模型,很多设计就不再是黑匣子了。希望帮到你。

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

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

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

立即咨询