手写 Skip-Gram 这件事,我前后折腾过三遍:第一遍照着论文公式抄,跑出来 loss 不降;第二遍加上了负采样,向量能出来但相似词全是高频词;第三遍才把窗口采样、噪声分布、稀疏更新这几处捋顺,最终拿到的词向量在相似词和类比任务上才算能用。所以这篇不讲“Word2vec 是什么”这种百度百科内容,而是直接按我实际写代码的顺序,把 Skip-Gram 从数据构造到训练收敛的每一段拆开,代码全部给到可运行的程度,同时在每个关键选择上说明为什么这么写、不这么写会出什么问题。如果你手上有几百 MB 到几 GB 的文本语料,想自己训一份词向量又不想被各种封装库的黑盒困住,这篇内容基本可以当模板套。
1. 先搞清楚 Skip-Gram 到底在学什么
很多人一上来就抄 softmax 公式,抄完发现词表几万个,分母算不动,于是又慌慌张张去找负采样,中间那层“为什么”一直是空的。我先把这个逻辑补上,后面写代码时才不会乱。
1.1 从中心词猜邻居这件事说起
Skip-Gram 的原始假设非常朴素:一个词的意思,由它周围经常出现的词决定。所以训练目标就是给定中心词,让模型去预测它窗口内的上下文词。反过来 CBOW 是拿上下文预测中心词,两者是镜像关系。我选 Skip-Gram 做主线,原因很实际:它对低频词更友好。CBOW 会把多个上下文词向量做平均,低频词的信息很容易被高频邻居稀释;Skip-Gram 每个(中心词, 上下文词)对都是独立样本,一个低频词出现一次就能贡献多次梯度。实测在分词粒度较细、长尾词多的语料上,Skip-Gram 出来的低频词向量质量明显更稳。
这里有个容易被忽略的点:Skip-Gram 输出的“词向量”其实有两套矩阵,输入矩阵 W_in 和输出矩阵 W_out。真正拿去用的是 W_in 的行,W_out 一般在训练结束后丢掉。原因在于 W_out 的每一行是作为“分类器权重”被优化的,它和 W_in 并不共享参数,两者在几何上并不等价。有些实现会把两者相加或拼接,我试过,在某些任务上确实略好,但收益不稳定,不建议作为默认做法。
1.2 一个样本的损失是怎么算出来的
先看最朴素的版本。给定中心词 c 和上下文词 o,我们希望 P(o|c) 尽可能大:
P(o|c) = exp(u_o · v_c) / Σ_w exp(u_w · v_c)其中 v_c 是 W_in 的第 c 行,u_o 是 W_out 的第 o 行,维度都是 D(一般 100 到 300)。损失就是负对数似然 -log P(o|c)。
问题立刻出现:分母要对整个词表求和。词表 5 万、维度 200,每个样本光分母就要做 5 万次 200 维点积,一个 epoch 几十万样本,纯 NumPy 跑一晚上都出不来。这就是全 softmax 跑不动的根本原因,不是什么“计算复杂度高”这种空话,而是实打实的算力账。
我做过一个粗略估算,方便你判断规模:
| 词表大小 | 向量维度 | 单样本 softmax 计算量 | 10 万样本单轮耗时(NumPy,粗略) |
|---|---|---|---|
| 5,000 | 100 | 5×10^5 次乘加 | 十几秒 |
| 50,000 | 200 | 1×10^7 次乘加 | 十几分钟 |
| 500,000 | 300 | 1.5×10^8 次乘加 | 数小时以上 |
表格里的数字只是量级参考,实际还受内存带宽影响。但结论很清楚:词表一过万,全 softmax 就不能作为默认方案了。
1.3 负采样把“多分类”改成了“二分类”
负采样的核心思想是:不去算真实分布在所有词上的概率,而是训练一个二分类器,判断“这个(中心词, 上下文词)对是真实出现的,还是我随机编的”。每个正样本配 K 个负样本(K 一般取 5 到 15),损失变成:
L = -log σ(u_o · v_c) - Σ_{k=1..K} log σ(-u_{n_k} · v_c)σ 是 sigmoid。第一项让正样本得分高,第二项让负样本得分低。这样每个样本只算 K+1 次点积,计算量和词表大小彻底解耦。
这里我要强调一个实践中的取舍:K 取多少。论文里说小数据集取 5 到 20,大数据集取 2 到 5。我自己的经验是,语料在千万词级别以下,K=10 到 15 比较稳;语料上亿词、词表又大,K=5 就够了,再大只是拖慢训练,向量质量提升很有限。另外负样本不能从均匀分布采,要按词频的 0.75 次方采,这个细节下一节展开。
2. 数据侧:样本对是怎么被“造”出来的
我发现很多人卡住不是卡在模型,而是卡在数据。语料进来是一整段字符串,怎么变成(center, context)的批次,中间有好几个决定会直接影响最终效果。
2.1 分词与词表的最小闭环
先给一个能跑的最小实现。假设语料已经按句子切好,每个句子是一个词列表:
from collections import Counter def build_vocab(sentences, min_count=5): counter = Counter() for sent in sentences: counter.update(sent) # 按频次降序,过滤低频词 words = [w for w, c in counter.most_common() if c >= min_count] w2i = {w: i for i, w in enumerate(words)} i2w = {i: w for w, i in w2i.items()} counts = [counter[w] for w in words] return words, w2i, i2w, countsmin_count这个参数别乱设。英文语料常用 5,中文分词后建议用 3 到 5,但如果你的语料很小(比如几十万词),设 5 会把大量有用词直接扔掉,词表缩得只剩高频虚词,训出来的向量毫无区分度。我踩过这个坑:一份两百万词的中文语料,min_count 设 5,最后词表只剩 8000 多个词,专业术语全没了。
还有一个细节:词表索引最好按频次降序排。原因是在负采样查表和后续的一些优化里,高频词索引靠前,缓存局部性更好;更重要的是调试时你能一眼看出索引 0、1、2 大概率是“的”“是”“在”这类词,方便快速判断预处理有没有出问题。
2.2 滑动窗口的采样细节
窗口生成看起来简单,坑却不少:
import random def make_pairs(sent_ids, window=5, dynamic=True): pairs = [] n = len(sent_ids) for i, center in enumerate(sent_ids): if dynamic: w = random.randint(1, window) # 动态窗口 else: w = window lo = max(0, i - w) hi = min(n, i + w + 1) for j in range(lo, hi): if j != i: pairs.append((center, sent_ids[j])) return pairs三个关键点:
第一,动态窗口。Word2vec 原实现里窗口大小是在 [1, window] 之间随机取的,而不是固定值。原因是固定窗口会让近处和远处的上下文权重完全一样,而实际上距离越远关联越弱。动态窗口相当于给不同距离的上下文一个隐式的权重衰减,实测对语义相似度任务有小幅提升。我一开始觉得这是玄学,后来对比过固定窗口和动态窗口在同一份语料上的结果,相似词列表里动态窗口的噪声确实少一些。
第二,窗口内不做去重。同一句话里“的”可能出现好几次,每次出现都该形成一个独立样本对。有人为了“减少重复”做去重,这是错的,词频信息就是这么丢掉的。
第三,跨句不滑窗。窗口必须在句子边界内截断,不能从上一句末尾滑到下一句开头。否则你会造出大量毫无语义关联的伪样本对,模型学到的是噪声。这个错误我在第一次实现时犯了,因为当时把整个文档拼成一个大列表,跑出来的向量近义词质量奇差,查了两天才发现是边界问题。
2.3 负采样表的构造与采样分布
负采样的采样分布是词频的 0.75 次方再归一化:
import numpy as np def build_noise_table(counts, vocab_size, table_size=10_000_000, power=0.75): freq = np.array(counts, dtype=np.float64) ** power freq /= freq.sum() # 预先采样成大表,训练时随机取下标,避免每次调 np.random.choice table = np.random.choice(vocab_size, size=table_size, p=freq) return table为什么要 0.75 次方?直观解释是:如果按原始词频采,高频词(“的”“是”)几乎垄断负样本,模型花大量精力去区分这些虚词,低频实词的区分度反而学不到。开 0.75 次方相当于把分布“压平”一些,给低频词更多出场机会,同时又不至于完全均匀。这个指数不是拍脑袋来的,论文里做过消融,0.75 附近效果最好。
性能上的关键技巧:不要在每个 batch 里现场调np.random.choice(vocab_size, size=K, p=freq),那个函数的内部实现每次都要做累积分布和二分查找,样本一多开销非常可观。正确做法是预先采一张一千万量级的整数表,训练时直接np.random.randint取表里的值。我换过这一处,训练速度提升了接近一倍,效果完全不变。
另外负采样有一个容易忽略的问题:采到的负样本可能恰好是真实上下文词。严格来说这是标签噪声,但实践中影响很小,一般不做处理。如果非要处理,可以在采样时检查是否等于当前上下文词,相等就重采一次,代价很小但会让代码多一层循环,我通常选择忽略。
3. 用 NumPy 裸写一版,把梯度走通
这一节的意义不在于“NumPy 版本能用于生产”,而在于你必须亲手写一遍前向反向,后面用框架时才知道loss.backward()到底在算什么。
3.1 参数矩阵的形状约定
先把形状定死,避免后面维度对不上:
| 变量 | 形状 | 说明 |
|---|---|---|
| W_in | (V, D) | 输入词向量矩阵,V 是词表大小 |
| W_out | (V, D) | 输出词向量矩阵 |
| v | (D,) | 某中心词的输入向量 |
| u_pos | (D,) | 正样本上下文词的输出向量 |
| u_neg | (K, D) | K 个负样本的输出向量 |
初始化用均匀分布,范围是 [-0.5/D, 0.5/D]。这个范围是有讲究的:如果初始化太大,sigmoid 立刻饱和,梯度接近 0;如果全零初始化,所有词向量对称,永远学不开。有人喜欢用正态分布 N(0, 0.01),也能跑,但在 D 很小(比如 50)时容易出现梯度消失。W_out 我习惯初始化为全零,因为它的梯度来自 sigmoid 的差值,全零不会导致对称问题,反而起步更稳,这一点和 W_in 不同。
3.2 前向、损失、反向的完整对照
直接上代码,我把每一步的梯度来源都注释清楚:
def sigmoid(x): return 1.0 / (1.0 + np.exp(-np.clip(x, -30, 30))) def train_step(center, context, negatives, W_in, W_out, lr): v = W_in[center] # 取行,注意这是视图不是拷贝 u_pos = W_out[context] # 正样本部分 s_pos = sigmoid(v @ u_pos) loss = -np.log(s_pos + 1e-8) grad_v = (s_pos - 1.0) * u_pos grad_u_pos = (s_pos - 1.0) * v # 负样本部分 for neg in negatives: u_neg = W_out[neg] s_neg = sigmoid(v @ u_neg) loss += -np.log(1.0 - s_neg + 1e-8) grad_v += s_neg * u_neg W_out[neg] -= lr * s_neg * v W_in[center] -= lr * grad_v W_out[context] -= lr * grad_u_pos return loss逐项解释几个梯度:
对正样本,s = σ(v·u),损失 -log s,对 v 求导得到 (s - 1)·u。因为 σ'(z) = σ(z)(1-σ(z)),链式下来正好化简成这个干净形式。
对负样本,损失 -log(1 - s),对 v 求导得到 s·u。符号和正样本相反,符合直觉:正样本希望得分升,负样本希望得分降。
这里有一个致命的实现坑:v = W_in[center]取到的是 NumPy 数组的视图,不是副本。如果你在计算 grad_v 之后立刻执行W_in[center] -= lr * grad_v,而 grad_v 又依赖于 v,那么在某些写法下会污染中间结果。上面的代码里我先算完所有梯度再更新,所以没出问题,但如果你把更新顺序打乱,比如先更新 W_in 再算负样本梯度,v 的值就变了,梯度全错。我建议保险起见直接写v = W_in[center].copy(),多一次拷贝,换来的是不会出现那种极难排查的数值错误。
3.3 训练循环与 loss 曲线怎么读
训练循环最简单的写法就是遍历样本对:
import random def train(pairs, V, D, noise_table, epochs=5, lr=0.025, K=5): rng = np.random.default_rng(42) W_in = (rng.random((V, D)) - 0.5) / D W_out = np.zeros((V, D)) for ep in range(epochs): random.shuffle(pairs) total = 0.0 for i, (c, o) in enumerate(pairs): negs = noise_table[rng.integers(0, len(noise_table), size=K)] loss = train_step(c, o, negs, W_in, W_out, lr) total += loss if i % 10000 == 0: lr *= 0.9999 # 简易衰减 print(f"epoch {ep}, avg loss {total / len(pairs):.4f}") return W_inloss 曲线怎么读?我总结了几种典型形态:
| loss 表现 | 大概率原因 |
|---|---|
| 前几百步快速下降,之后长期在 0.3 到 0.6 徘徊 | 正常,负采样 loss 本来就降不到很低 |
| 一开始就贴着 0.0 | 梯度写反了,或者正负样本标签搞混 |
| 完全不降,一直在 0.69 附近 | 通常对应随机猜测(-log 0.5 ≈ 0.693),检查点积是否为 0 |
| 剧烈震荡后发散 | 学习率太大,或者梯度符号错了 |
| 缓慢下降但相似词全是高频词 | 负采样分布没做 0.75 平滑 |
0.69 这个数字是个很好的自检信号,因为初始时 W_out 全零,v·u = 0,sigmoid 输出 0.5,正负样本损失都是 0.693。如果你的 loss 一直卡在这个值,说明梯度根本没传播出去。
4. 换成 PyTorch 搭一套能复用的骨架
NumPy 版本跑通之后,就该上框架了。这一节我给的是我自己常用的模板,拆成三块:数据、模型、训练循环,任何语料换进来只需要改数据加载那一层。
4.1 Dataset 与批次构造
import torch from torch.utils.data import Dataset, DataLoader class SkipGramDataset(Dataset): def __init__(self, pairs, noise_table, K=5): self.pairs = pairs self.noise_table = noise_table self.K = K def __len__(self): return len(self.pairs) def __getitem__(self, idx): c, o = self.pairs[idx] negs = self.noise_table[torch.randint(0, len(self.noise_table), (self.K,))] return (torch.tensor(c, dtype=torch.long), torch.tensor(o, dtype=torch.long), negs.to(torch.long))这里__getitem__里用torch.randint而不是 NumPy,是为了避免多进程 DataLoader 里 NumPy 随机数状态共享导致的重复采样。num_workers设成 4 到 8 比较合适,再往上收益递减,因为负采样本身很轻,瓶颈在 DataLoader 的进程通信。
有一个性能上很实际的建议:如果语料不大(样本对在千万级以下),干脆把所有 pairs 预先生成好放在内存里,别在__getitem__里现算窗口。窗口计算涉及大量 Python 循环,放在 worker 里反而更慢。只有当语料大到内存放不下时,才值得用流式生成。
4.2 模型定义与负采样损失
import torch.nn as nn import torch.nn.functional as F class SkipGramNS(nn.Module): def __init__(self, vocab_size, dim): super().__init__() self.in_emb = nn.Embedding(vocab_size, dim, sparse=True) self.out_emb = nn.Embedding(vocab_size, dim, sparse=True) bound = 0.5 / dim nn.init.uniform_(self.in_emb.weight, -bound, bound) nn.init.zeros_(self.out_emb.weight) def forward(self, center, context, negatives): v = self.in_emb(center) # (B, D) u_pos = self.out_emb(context) # (B, D) u_neg = self.out_emb(negatives) # (B, K, D) pos_score = (v * u_pos).sum(dim=1) # (B,) neg_score = torch.bmm(u_neg, v.unsqueeze(2)).squeeze(2) # (B, K) loss = -F.logsigmoid(pos_score).mean() \ - F.logsigmoid(-neg_score).mean() return loss几个说明:
sparse=True是这里最容易被忽略但收益最大的一行。它告诉优化器这个 Embedding 的梯度是稀疏的,更新时只更新被索引到的行,而不是整个矩阵。当词表有几十万行、batch 只有几百时,稠密梯度矩阵绝大部分是零,白算白写。改用sparse=True后必须搭配支持稀疏梯度的优化器,通常是torch.optim.SparseAdam,注意它不接受weight_decay。
F.logsigmoid(-neg_score)这个写法等价于log(1 - sigmoid(neg_score)),但数值稳定性好得多。直接写torch.log(1 - torch.sigmoid(x))在 x 很大时会下溢成 log(0),得到 -inf,训练直接崩。
bmm是批量矩阵乘法,负样本打分就是 (B, K, D) 与 (B, D, 1) 相乘。也可以写成(u_neg * v.unsqueeze(1)).sum(-1),可读性更好但稍慢,两者我都用过,差别不大。
4.3 训练循环与稀疏优化器
from torch.optim import SparseAdam def run_training(pairs, V, D=128, K=5, batch_size=1024, epochs=5, lr=2.5e-3): ds = SkipGramDataset(pairs, noise_table, K) dl = DataLoader(ds, batch_size=batch_size, shuffle=True, num_workers=4, drop_last=True) model = SkipGramNS(V, D) opt = SparseAdam(model.parameters(), lr=lr) for ep in range(epochs): model.train() total, steps = 0.0, 0 for c, o, n in dl: opt.zero_grad() loss = model(c, o, n) loss.backward() opt.step() total += loss.item() steps += 1 print(f"epoch {ep} loss {total / steps:.4f}") return model.in_emb.weight.data关于学习率,Skip-Gram 的比较合适的范围是 0.001 到 0.005,我用 0.0025 最多。不要用 Adam 的默认 0.001 直接套,也别用 SGD,稀疏梯度配 SGD 在 Embedding 上收敛非常慢。另外zero_grad()一定要在backward()之前,稀疏梯度模式下set_to_none=True的效果和全局默认行为不完全一致,稳妥起见用默认。
还有一个提示:训练完记得把model.in_emb.weight.data拷出来。如果你在训练结束前就del model,而data属性共享存储,会遇到部分内存被释放导致后续读取出错的问题,虽然不常见但确实踩过。
5. 训完之后怎么判断这份向量能不能用
训练跑完不看结果就存盘,这是最浪费时间的做法。向量质量不合格,你再训五个 epoch 也是白费。
5.1 相似词与类比任务的验证
最直接的检查是最近邻:
import torch def nearest(model_w, w2i, i2w, word, topn=8): idx = w2i[word] vec = model_w[idx] vec = vec / (vec.norm() + 1e-8) norms = model_w / (model_w.norm(dim=1, keepdim=True) + 1e-8) scores = norms @ vec top = torch.topk(scores, topn + 1) return [(i2w[i.item()], round(s.item(), 4)) for i, s in zip(top.indices, top.values) if i.item() != idx][:topn]判断标准不是“看起来像”,而是要看结构。合格的向量应该满足:近义词或同类词稳定排在前列;距离分数有明显梯度,第一名显著高于第五名;低频词也有合理的邻居,而不是被噪声填满。
类比任务更严格:v(国王) - v(男人) + v(女人) 应该最接近 v(王后)。这个检查对向量维度和语料规模都很敏感。我在一份 300 万词的中文语料上试过,D=100 时类比准确率很低,提到 D=200、语料扩到 2000 万词之后才有明显改善。所以如果你的类比结果很差,先别怀疑代码,先看看语料量。
5.2 向量全塌缩的排查链路
最有代表性的故障是“所有词向量都差不多”。我把当时的排查顺序列一下,你遇到类似情况可以直接照跑:
第一步,检查 loss。如果 loss 一直贴近 0 或者一直是 0.693,先回头去看梯度代码,不用往下走。
第二步,随机抽 20 个词,算它们两两余弦相似度,看均值。正常应该分布在 0 到 0.4 之间,如果普遍在 0.9 以上,说明塌缩。
第三步,打印 W_in 的前几行方差。如果方差极小(比如 1e-6 量级),说明更新根本没发生。常见原因是优化器没对sparse=True的 Embedding 生效,或者学习率小到可以忽略。
第四步,检查负采样表。如果负采样表意外全零,或者采样出来的全是同一类词,正负样本区分不开,梯度方向会互相抵消。用np.bincount(noise_table, minlength=V)看分布,高频词占比不应超过 20%。
第五步,检查学习率是否过大导致震荡抵消。把 lr 调小十倍再看几十步 loss。
我实际遇到的那次,问题出在第三步:换了优化器之后忘了 Embedding 是 sparse 模式,稠密优化器对稀疏梯度直接报错或者静默跳过,loss 看似在降,其实是虚假的(只更新了输出层部分)。这类问题不看中间变量根本发现不了。
5.3 几个超参数的经验区间
下面这张表是我自己反复调过之后沉淀下来的,可以直接当起点,再按语料微调:
| 参数 | 推荐区间 | 调整方向 |
|---|---|---|
| 向量维度 D | 100~300 | 语料小取小,语料大、任务细取大 |
| 窗口大小 | 5~10 | 语义任务取 5,主题/共现任务取 10 |
| 负样本数 K | 5~15 | 小语料取大,大语料取小 |
| batch size | 512~2048 | 越大越稳,但收敛起步慢 |
| 学习率 | 0.001~0.005 | 稀疏优化器下不要低于 1e-4 |
| min_count | 3~5 | 小语料降到 1 或 2 |
再补一句维度选择的直觉:D 太小时不同语义挤在一起,相似词列表会显得“什么都沾一点”;D 太大而语料不足时,大量维度学不到有效信号,训练慢且容易过拟合到高频共现上。语料词数除以词表大小得到的平均词频,如果低于 20,我一般不会把 D 设过 200。
6. 存盘、加训与后续能走的几条路
6.1 保存格式与增量训练
保存不要直接torch.save(model),把整个模型结构序列化进去,将来换代码就加载不了。我习惯只存三样东西:
torch.save({ "vectors": model.in_emb.weight.data.cpu(), "w2i": w2i, "config": {"dim": D, "window": 5, "K": 5, "min_count": 3}, }, "skipgram.pt")增量训练的场景很常见:新来一批语料,想在已有向量上继续训。这里要注意词表必须完全对齐,新语料里出现的新词要么丢弃,要么另建扩展词表。我一般选择保留旧词表并且冻结已有词的一部分更新——具体做法是给新词设更高的学习率,旧词设低学习率。用SparseAdam做不到逐行学习率,得换成手动更新或者给优化器传分组参数。如果没有特别强的增量需求,重新训一遍往往比增量更省事,也更不容易出向量漂移的问题。
6.2 从 Skip-Gram 往哪里延伸
Skip-Gram 本身是个很扎实的起点,往下有几个自然的方向:
一是子词扩展。把词拆成字符 n-gram,用 n-gram 向量之和表示词向量,好处是能处理未登录词和形态丰富的语言。代价是词表膨胀和计算量上升,实现上要额外维护 n-gram 到 id 的映射。
二是层次 softmax 替代负采样。用霍夫曼树把 V 分类降到 log(V) 次二分类。它在低频词上表现更稳定,但实现复杂度高,工程上负采样更划算,除非你对低频词质量有极致要求。
三是换目标函数。Skip-Gram 的窗口共现本质是在学 PMI 矩阵的低秩分解,理解了这一层之后,你可以直接构造共现矩阵再做矩阵分解,或者上 GloVe 那种加权最小二乘的形式。我在语料中等规模(几千万词)时对比过,Skip-Gram 训练快、调参简单,矩阵分解类方法在内存充足时结果更平滑。两条路都值得走一遍,走完对“词向量到底在编码什么”会有完全不同的理解。
最后分享一个我在实际项目里用得最多的技巧:先在小样本上把整条链路跑通。取语料的前 1% 训 2 个 epoch,十分钟内就能看到相似词结果,如果这一步的邻居已经合理,再上全量;如果不合理,问题一定在数据或梯度上,跟语料规模无关。这个习惯帮我省掉的调试时间,比任何调参技巧都多。