手写一遍 Skip-Gram,是我一直推荐给做文本方向的同行的"必做功课"。不是为了造轮子,而是因为你在调现成词向量工具时踩过的那些玄学问题——窗口调大调小效果忽好忽坏、训完发现相似词全是"的""了""是"、向量拿去聚类一塌糊涂、语料翻倍但指标就是不涨——九成都能在一份两百行的裸代码里找到答案。Skip-Gram 是 Word2vec 的两种训练方式之一,核心任务就一句话:给定一个中心词,预测它周围窗口里可能出现哪些词,训完之后把中间那个隐层矩阵拎出来,就是词向量。这篇东西我打算把代码从头到尾拆一遍,先讲清楚它在干什么,再讲代码怎么搭,然后是参数怎么调、坑怎么排,最后给出可以直接套用的模板。适合两类人看:一类是刚接触词向量、想搞清楚里面到底发生了什么的新手;另一类是已经会用工具、但遇到问题只能靠猜的老手。整篇的代码都是可以复制跑通的,语料用中文分好词的列表,不依赖任何分词库,你把自己的数据按同样格式塞进去就行。
1. 先把 Skip-Gram 这件事想明白
1.1 一句话版本:拿中心词去猜它的邻居
假设语料里有一句话:"模型 用 中心词 预测 上下文"。窗口取 2,中心词取"中心词"的时候,它的上下文就是左边两个"模型""用"和右边两个"预测""上下文"。Skip-Gram 干的事情就是训练一个模型,输入"中心词",输出要在这四个位置上分别猜对"模型""用""预测""上下文"。
这里有个反直觉的地方:语言模型通常是顺着往前预测下一个词,而 Skip-Gram 是同时往两边看,而且把每个上下文词当成一个独立的分类任务。这个设计看起来浪费——同一句话被拆成了四份训练信号,但它带来的好处是每个词在一个句子里能贡献多次梯度更新,小语料下收敛更快,低频词也能被多训几次。这也是为什么 Skip-Gram 在语料规模不大的场景下,通常比 CBOW 表现更稳。
再往下拆一层,所谓"猜",本质是一个V 分类问题,V 是词表大小。词表一万,那就是一万分类;词表十万,十万分类。直接做 softmax 计算量爆炸,所以真正的工程实现里几乎都用负采样(Negative Sampling)或者层次 softmax(Hierarchical Softmax)来绕开。我下面给的代码走的是负采样路线,因为它的实现最直白,改起来也最灵活。
1.2 one-hot 的死穴到底在哪
理解 Skip-Gram 之前,得先理解它要解决什么。词的 one-hot 表示是一个长度等于词表大小的向量,只有一位是 1,其余全 0。"苹果"和"梨"这两个语义相近的词,在 one-hot 空间里的夹角是 90 度,余弦相似度是 0,跟"苹果"和"宇宙飞船"的关系一模一样——也就是说,one-hot 里没有任何语义信息。
还有个更实际的问题:维度灾难。词表十万,每个词就是十万维的稀疏向量,存下来是一回事,送进下游模型是另一回事,特征维度直接爆掉。而 Skip-Gram 训出来的稠密向量,通常是 50 到 300 维,每个位置都是实数,语义相近的词在这个空间里距离也近,"苹果"和"梨"的余弦相似度会明显高于"苹果"和"飞船"。这就是所谓的分布式表示:一个词的含义由它经常出现的上下文决定,这是整个词向量流派的理论底座。
从信息论角度看,Skip-Gram 做的事情等价于对"词-上下文"共现矩阵做一次低秩分解,只是它用随机梯度下降在线地做,不需要先把整个共现矩阵算出来存到内存里。这也是它在超大语料上能跑得动的原因。理解了这一层,你就知道为什么窗口大小、下采样这些参数会显著影响结果——它们直接决定了共现矩阵里哪些格子被强化、哪些被抹掉。
1.3 Skip-Gram 和 CBOW 的取舍
这两个是 Word2vec 的两条腿,网上对比文章很多,但多数停留在"Skip-Gram 慢、CBOW 快"这种层面。实际做选择的时候,我会看三件事。
| 维度 | Skip-Gram | CBOW |
|---|---|---|
| 训练目标 | 中心词预测上下文 | 上下文预测中心词 |
| 单句训练样本量 | 大(每对词一个样本) | 小(每个位置一个样本) |
| 小语料表现 | 相对更好 | 容易欠拟合 |
| 低频词效果 | 更好,被训多次 | 较差,容易被平均掉 |
| 语义类比任务 | 通常更优 | 通常稍弱 |
| 训练耗时 | 高 | 低 |
我自己的经验是:语料低于千万词级别、或者数据里有很多长尾专业术语,闭眼选 Skip-Gram;语料上亿、追求训练速度、下游只是做个粗粒度的文本分类,CBOW 够用。还有一个折中做法是先跑 CBOW 拿个基线,再用 Skip-Gram 提升,比较两者的验证集指标,这个对比花不了多少时间,但能帮你确认哪条路线更适合你的数据分布。
2. 代码骨架:四个模块,谁先谁后
2.1 模块划分与数据流
一份能跑的 Skip-Gram,我认为至少拆成四块:语料预处理、样本生成、模型定义、训练循环。这四块的分界线要划清楚,因为后面调参、排查问题的时候,你需要能快速定位是"数据造错了"还是"模型写错了"。
数据流是这样走的:原始文本先过一遍分词,得到词列表;统计词频建词表,同时算出高频词下采样的保留概率;然后按滑动窗口遍历每句话,生成(中心词,上下文词)的配对;配对再和负采样出来的噪声词一起打包成一个训练样本;最后送进模型算损失、反向传播、更新两个嵌入矩阵。
新手最容易犯的错是把这几步揉在一起写,一个函数从上干到下,中间出了 bug 完全没法查。我的建议是每一步都留一个"中间产物"打印出来看看,比如生成完配对之后,随机抽十条看看(中心词,上下文)是不是真的在同一句话的窗口范围内。这个检查只要三行代码,但能省下你两小时的调试。
2.2 为什么必须先建词表再谈模型
词表是词向量模型的"坐标系",一旦定下来,向量的行号就固定了。这里有两个决策点:要不要限制词表大小,低频词怎么处理。
限制词表大小的动机是内存和速度。词表从十万降到三万,嵌入矩阵直接小三分之二,训练速度提升明显。代价是低频词全变成<UNK>,模型学不到它们。如果你的下游任务对长尾词敏感(比如医疗、法律领域),那我建议词表放大一点,或者干脆不设上限,只在内存吃紧的时候才砍。
低频词的处理,最粗暴的办法是设min_count,出现次数低于阈值的词直接丢出词表。这个阈值在百万级语料上一般设 5 到 10,在十万级以下的语料上设 1 到 2,因为语料本来就小,再砍就没什么词了。删词的时候要注意,删掉的词在句子里会留下"空洞",你可以选择跳过它,也可以选择用它前后相邻的词做拼接,两种做法都行,我倾向于直接跳过,因为拼接会造出一批只出现一次的新词,反而污染词表。
2.3 训练样本是"造"出来的,不是读出来的
这一点很多人一开始不理解。原始语料里句子就那么几句,但训练样本可以有几万条,因为每滑动一次窗口就产生一批配对。一句十个词的句子,窗口取 2,大概能产出十几个(中心,上下文)对。
这里的实现细节是:每个中心词,上下文的采样顺序要打乱,而且窗口大小在每一轮可以随机取 1 到 max_window 之间的值。后者是 Word2vec 原论文里的一个小技巧,叫动态窗口(dynamic window),目的是让近的上下文词被采到的概率更高,同时让远处偶尔也能被采到,相当于对不同距离的共现关系做了加权。实测下来,这个小改动对效果的提升比很多人想象的要明显,而且实现成本几乎为零,就是一个random.randint(1, max_window)。
3. 逐段实现与关键参数
3.1 词表构建与索引映射
这段没什么花活,但要写得干净。我给你一份可以直接用的:
import numpy as np import random from collections import Counter from itertools import chain random.seed(42) np.random.seed(42) # 已经分好词的语料,每条是一个词列表 sentences = [ ["自然语言", "处理", "是", "人工智能", "的", "一个", "重要", "方向"], ["词向量", "把", "词", "映射", "到", "低维", "稠密", "向量", "空间"], ["这个", "模型", "用", "中心词", "去", "预测", "上下文", "词"], ["滑动", "窗口", "决定", "了", "上下文", "的", "范围", "大小"], ["负采样", "降低", "了", "每次", "更新", "的", "计算量"], ["训练", "完成", "之后", "取出", "输入", "矩阵", "就是", "词向量"], ] MIN_COUNT = 1 counter = Counter(chain(*sentences)) vocab = [w for w, c in counter.items() if c >= MIN_COUNT] word2idx = {w: i for i, w in enumerate(vocab)} idx2word = {i: w for w, i in word2idx.items()} V = len(vocab) print(f"词表大小: {V}")有个细节值得提:vocab的顺序我用的是 Counter 的插入顺序,也就是词第一次出现的顺序。你可能见过有人按词频降序排,两者对最终效果没有实质影响,但如果后面要做向量切片对齐(比如和预训练向量合并),顺序必须固定且可复现,建议要么写死排序规则,要么把词表存成文件,下次直接读。
3.2 高频词下采样:把"的""是"的权重压下去
中文语料里"的""了""是"这类词频率极高,但它们携带的语义信息很少。如果不处理,训练出来"的"的最近邻会是一堆功能词,语义空间被这些词拖偏。Word2vec 论文里给了一个下采样公式,保留概率是:
P_keep(w) = sqrt(t / f(w))其中f(w)是词频占比,t是阈值,通常取1e-3到1e-5。当f(w)小于t时算出来的值大于 1,直接截断到 1,意思是低频词全部保留。
total_tokens = sum(counter[w] for w in vocab) freq = {w: counter[w] / total_tokens for w in vocab} t = 1e-4 keep_prob = {} for w in vocab: p = np.sqrt(t / freq[w]) keep_prob[w] = min(p, 1.0) # 举个例子看看 for w in ["的", "词向量"]: if w in keep_prob: print(f"{w}: 频率={freq[w]:.5f}, 保留概率={keep_prob[w]:.3f}")假设"的"占全部 token 的 5%,t=1e-4,那保留概率是sqrt(1e-4 / 0.05) = sqrt(0.002) ≈ 0.045,也就是下采样后只剩 4.5% 的"的"被保留。这个力度听起来很猛,但实际训练中效果是正面的——模型能在有限的更新次数里更多地看到有信息量的词。
注意:这个阈值不要照抄。语料小的时候(比如几十万字),
t=1e-4会把中频词也砍掉一大截,训出来的向量会偏。我的习惯是小语料用1e-3,大语料用1e-4或更小,而且下采样之后要重新训练,前后对比一下下游指标,别凭感觉。
3.3 滑动窗口与配对生成
def build_pairs(sentences, word2idx, keep_prob, max_window=2): pairs = [] for sent in sentences: idxs = [word2idx[w] for w in sent if w in word2idx] for i, center in enumerate(idxs): # 动态窗口:每次随机一个有效窗口 win = random.randint(1, max_window) left = max(0, i - win) right = min(len(idxs), i + win + 1) for j in range(left, right): if j == i: continue ctx = idxs[j] # 用中心词的保留概率做下采样 cw = sent[i] if random.random() > keep_prob.get(cw, 1.0): continue pairs.append((center, ctx)) return pairs pairs = build_pairs(sentences, word2idx, keep_prob, max_window=2) print(f"生成样本数: {len(pairs)}") print(pairs[:10])这段代码里有个容易忽略的点:下采样判断我用的是中心词,而不是上下文词。理论上应该对每个被保留的 token 分别判断,但工程实现上通常会简化成只看中心词,或者中心词和上下文词都判断一次。后者更严格,会让样本量进一步减少。我建议先把两种都试一遍,看看样本量差多少,语料本来就小的话,用宽松的那版。
3.4 负采样表:0.75 次方到底在调什么
负采样的思路是:每来一个正样本(中心词,真实上下文),我随机抽 K 个"假上下文"(从词表里按某种分布抽),让模型学着把正样本打高分、负样本打低分。K 一般取 5 到 20,小语料取 5 到 10 就够,大语料取 15 以上。
关键在于抽样的分布。如果按词频均匀抽,高频词会被当成负样本抽到很多次,模型学到的是"高频词不是上下文",这个信号没什么用。Word2vec 的做法是把词频取0.75次方再归一化:
power = 0.75 weights = np.array([counter[w] for w in vocab], dtype=np.float64) ** power weights = weights / weights.sum() print("负采样概率前 5 名:") order = np.argsort(-weights)[:5] for i in order: print(f" {idx2word[i]}: {weights[i]:.4f}")为什么是 0.75?直观解释是:如果按原始频率抽,"的"这种词会被抽中太多次,负样本里全是它,模型学了等于没学;如果按均匀分布抽,低频词会被过度采样,也不合理。0.75 次方是一个经验折中,它的效果是提高低频词被抽中的相对概率,同时不给高频词完全消失的机会。
举个具体数字:假设词 A 频率是 0.01,词 B 频率是 0.0001,两者频率差 100 倍。取 0.75 次方之后,A 变成 0.0316,B 变成 0.001,差距缩小到 31.6 倍。你看,高频词的优势被削弱了,但没被抹平。这个指数我自己试过 0.5 和 1.0,0.5 对高频词压制太狠,1.0 又回到原来的问题,0.75 确实是个不错的默认值。
3.5 模型本体:两层矩阵就够了
负采样版本的 Skip-Gram,参数就两个矩阵:W_in是中心词向量(V × D),W_out是上下文词向量(V × D)。注意,这是两个不同的矩阵,最后拿来用的是W_in,W_out训完就扔了。有些实现会把两个矩阵共享,效果通常会差一些,因为中心词和上下文词在语言学上的角色本来就不对称。
单样本的反向传播手推一遍:
def sigmoid(x): return 1.0 / (1.0 + np.exp(-x)) dim = 50 init_range = 0.5 / dim W_in = np.random.uniform(-init_range, init_range, (V, dim)) W_out = np.zeros((V, dim)) def train_one(center, context, negatives, lr): v = W_in[center] # (D,) u_pos = W_out[context] # (D,) u_neg = W_out[negatives] # (K, D) # 正样本:标签为 1 score_pos = v @ u_pos g_pos = sigmoid(score_pos) - 1.0 # dL/dscore grad_v = g_pos * u_pos grad_u_pos = g_pos * v # 负样本:标签为 0 scores_neg = u_neg @ v # (K,) g_neg = sigmoid(scores_neg) # dL/dscore,标签 0 所以是 p - 0 grad_v += g_neg @ u_neg # (D,) grad_u_neg = np.outer(g_neg, v) # (K, D) # 更新 W_in[center] -= lr * grad_v W_out[context] -= lr * grad_u_pos W_out[negatives] -= lr * grad_u_neg loss = -np.log(sigmoid(score_pos) + 1e-9) \ - np.sum(np.log(sigmoid(-scores_neg) + 1e-9)) return loss我要强调一个新手常犯的错误:W_out初始化成 0 是可以的,但W_in绝对不能用 0 初始化。全 0 的话,所有中心词的梯度都一样,训完之后所有词向量完全重合,相似度全是 1,看起来"训练成功"了,其实啥也没学到。初始化范围0.5/dim是 Word2vec 原论文的设定,作用是让初始的点积不要太大,避免 sigmoid 一开始就饱和。
3.6 把训练循环跑起来
K = 5 epochs = 5 lr = 0.025 neg_sampler = lambda n: np.random.choice(V, size=n, p=weights) for epoch in range(epochs): random.shuffle(pairs) total_loss = 0.0 for step, (c, o) in enumerate(pairs): negs = neg_sampler(K) loss = train_one(c, o, negs, lr) total_loss += loss print(f"epoch {epoch}, avg loss = {total_loss / len(pairs):.4f}")学习率这里我用0.025起步,这是原论文的量级。语料小的时候这个值也偏高,容易震荡,你可以降到0.01甚至0.005。更讲究的做法是线性衰减,从0.025一路降到0.0001,公式是lr = lr0 * (1 - progress),其中progress是已经处理的样本比例。这个改动对小语料提升明显,值得加上。
4. PyTorch 版本:把上面的逻辑翻译成框架语言
4.1 双嵌入表的标准写法
手写 numpy 版的价值在于理解原理,实际项目里我肯定用框架,因为框架自带自动求导、GPU 加速和优化器,改起来也快。同样的模型翻译成 PyTorch 长这样:
import torch import torch.nn as nn import torch.nn.functional as F class SkipGramNeg(nn.Module): def __init__(self, vocab_size, dim): super().__init__() self.in_emb = nn.Embedding(vocab_size, dim) self.out_emb = nn.Embedding(vocab_size, dim) init_range = 0.5 / dim self.in_emb.weight.data.uniform_(-init_range, init_range) self.out_emb.weight.data.zero_() def forward(self, center, context, negatives): v = self.in_emb(center) # (B, D) u = self.out_emb(context) # (B, D) un = self.out_emb(negatives) # (B, K, D) pos_score = torch.sum(v * u, dim=1) # (B,) neg_score = torch.bmm(un, v.unsqueeze(2)).squeeze(2) # (B, K) loss = -F.logsigmoid(pos_score).mean() \ - F.logsigmoid(-neg_score).mean() return losslogsigmoid这个函数比手动写log(sigmoid(x))稳,因为它在 x 很负的时候不会直接下溢到log(0)。另外注意负样本那项写的是-logsigmoid(-neg_score),等价于-log(1 - sigmoid(neg_score)),这就是负样本标签为 0 的交叉熵。这两项加起来才是完整的负采样损失。
4.2 Dataset 和 DataLoader 的两个坑
Dataset 本身很简单,就是把配对和负采样包起来:
from torch.utils.data import Dataset, DataLoader class SGPairs(Dataset): def __init__(self, pairs, weights, K): self.pairs = pairs self.weights = weights self.K = K def __len__(self): return len(self.pairs) def __getitem__(self, i): c, o = self.pairs[i] neg = np.random.choice(len(self.weights), size=self.K, p=self.weights) return c, o, neg def collate(batch): c, o, n = zip(*batch) return (torch.tensor(c, dtype=torch.long), torch.tensor(o, dtype=torch.long), torch.tensor(np.stack(n), dtype=torch.long))第一个坑:num_workers大于 0 的时候,每个 worker 是独立的进程,np.random的种子会被复制,导致不同 worker 抽到的负样本序列高度重复。解决方式是在worker_init_fn里给每个 worker 重新设种子:
def worker_init(worker_id): np.random.seed(42 + worker_id) random.seed(42 + worker_id) loader = DataLoader(ds, batch_size=1024, shuffle=True, num_workers=4, collate_fn=collate, worker_init_fn=worker_init)第二个坑:np.random.choice在每次__getitem__里都带一个概率向量,是个相当重的操作,如果你每轮抽 5 个负样本,十万样本就是五十万次调用,CPU 会先成为瓶颈。我的做法是预先生成一张大的负采样池,比如一次性按分布抽一百万个数存成数组,取样的时候直接从池里切片,速度能快一个数量级。
4.3 训练循环与日志观察
device = torch.device("cuda" if torch.cuda.is_available() else "cpu") model = SkipGramNeg(V, 100).to(device) opt = torch.optim.Adam(model.parameters(), lr=1e-3) for epoch in range(10): model.train() running = 0.0 for step, (c, o, n) in enumerate(loader): c, o, n = c.to(device), o.to(device), n.to(device) opt.zero_grad() loss = model(c, o, n) loss.backward() opt.step() running += loss.item() print(f"epoch {epoch} loss={running / len(loader):.4f}")日志这块我要提醒一句:只看 loss 数值没有意义,因为负采样损失和采样数量强相关,K 从 5 改到 15,loss 的绝对值就会变,不能跨配置比较。真正该盯的是 loss 的变化趋势,以及每隔几个 epoch 抽一组词算一下最近邻,看看语义上有没有变合理。我记得有一次 loss 从 2.1 平稳降到 1.3,看着特别健康,结果抽出来看"苹果"的最近邻是"的""以及""因此",一查才发现下采样阈值写错了,功能词压根没被压下去。
5. 参数调优:默认值不是万能钥匙
5.1 窗口大小决定学到的是"语法"还是"主题"
窗口小(2 到 3),模型看到的是紧邻的词,学到的是句法层面的相似性,比如"跑"和"跳"都是动词,它们周围的词性模式相似,向量会靠近。窗口大(5 到 10),模型看到的是段落级别的共现,学到的是主题层面的相似性,"苹果"和"香蕉"因为经常出现在同一类文本里而靠近,即使它们在句子里从不挨着。
这个区分在实战中很有用。做句法分析、命名实体识别,窗口取 2 到 3;做文本分类、推荐、主题聚类,窗口取 5 到 10。我见过有人做商品标题的相似度,窗口取 2,结果"手机壳"和"手机膜"分得很开,因为标题里这俩词的位置模式不一样,这就是窗口选小了的典型表现。
| 窗口大小 | 偏重的相似性 | 典型适用场景 |
|---|---|---|
| 1 到 2 | 严格句法搭配 | 词性标注、依存分析 |
| 3 到 5 | 句法与语义混合 | 通用词向量、NER |
| 5 到 10 | 主题与领域相关 | 文本分类、聚类、推荐 |
| 10 以上 | 篇章级主题 | 长文档相似度、话题发现 |
5.2 维度不是越大越好
维度决定了向量能容纳多少信息。经验值是dim ≈ 词表大小 ^ 0.25再乘个系数,但这个公式参考价值有限。我的实际感受是:十万词以下的语料,100 维就够,200 维开始收益递减;百万词级别的语料,200 到 300 维是常见选择;再往上,除非下游任务确实吃这个信息量,否则没必要。
维度调大的代价是显存和训练时间线性增长,而且小语料上高维模型特别容易过拟合——表现出来就是训练集上的相似度很好看,换一批新词就崩。判断方法很简单:把语料切一半训两个模型,看同一个词在两份向量里的最近邻重合度,重合度低于 30% 基本可以判定维度过高或者语料不足。
5.3 学习率的衰减策略
Word2vec 原论文用的是线性衰减,初始0.025,训练过程中逐步降到0.0001。这个策略的核心逻辑是:前期大步走,快速把随机初始化的向量拉到合理区域;后期小步走,精调细节。
在 PyTorch 里实现很简单:
total_steps = len(loader) * epochs lr0, lr_min = 0.025, 1e-4 def get_lr(step): ratio = min(step / total_steps, 1.0) return lr0 * (1 - ratio) + lr_min * ratio scheduler = torch.optim.lr_scheduler.LambdaLR(opt, get_lr)注意这里的 scheduler 是按 step 更新的,要在每个 batch 之后调用scheduler.step()。我踩过一次坑:忘了调,学习率一直停在初始值,训到后面 loss 开始上下横跳,怎么调 batch size 都没用,最后打印学习率才发现。现在我养成的习惯是每个 epoch 结束打一行当前学习率出来。
5.4 负采样数量和批大小的联动
K 越大,每个正样本的梯度信号越强,模型区分能力越好,但计算量也线性增长。我的经验区间是 5 到 15,小语料 5 到 8,大语料 10 到 15。K 大于 20 之后收益非常有限,因为负样本之间开始高度冗余。
批大小和 K 是联动的。批大小 1024、K=5,一个 batch 实际要算 1024 × 6 个词对的点积;批大小 256、K=5,计算量降到四分之一,但梯度噪声变大。我一般先用 512 起步,显存够就往上加。这里有个反直觉的点:批大小不是越大越好,负采样本身就是一种噪声估计,太小的批会让梯度方向抖得厉害,太大的批又会让每个 epoch 的更新次数变少,小语料上尤其明显。
6. 踩坑记录与排查清单
6.1 loss 不降或者降得诡异
这是最高频的问题。我按可能性从高到低排一下:
第一种,数据配对写错了。最常见的写法错误是窗口边界算错,导致j范围包含了中心词自己,或者越界。检查方法很朴素:抽十条样本打印出来,人工看一眼中心词和上下文词是不是真的在同一条句子里、距离是不是在窗口内。
第二种,词表索引错位。建词表的时候用的是word2idx,生成配对的时候又用了一次Counter的新顺序,两边对不上,训练出来的向量全是乱的。避免方式是词表只建一次,全程传同一个字典对象。
第三种,学习率太大。loss 在前几十步剧烈震荡,甚至出现nan,基本就是这个原因。nan还有一个来源是log(0),检查一下损失函数里有没有加eps。
第四种,梯度没清零。PyTorch 里忘了opt.zero_grad(),梯度会累积,训几步就爆。这个问题在新手代码里出现的频率高得离谱。
6.2 相似词全是功能词
训完之后查"人工智能"的最近邻,返回"的""了""是""一个",这种情况我遇到过至少三次,原因各不相同。
一次是下采样没生效,检查发现keep_prob里的键是字符串,但取值的时候用的是索引,get一直返回默认值 1.0,等于没做下采样。这个 bug 特别隐蔽,因为不报错,只是效果差。
一次是负采样分布写成了均匀分布,功能词被大量当作负样本,反而在W_out里形成了特殊的模式。改回 0.75 次方就正常了。
还有一次是语料本身的问题,用的是某类很短的标题文本,每句话就五六个词,窗口取 5,等于每句话里所有词两两配对,语义结构完全丢失。这种语料只能把窗口压到 2,或者换更长的文本。
6.3 语料小的时候怎么救
语料只有几十万字的时候,训出来的向量确实不太稳。我试过几个办法,按有效性排序:
增加迭代轮数。小语料的配对数量少,多跑几轮能补一些,但超过 10 轮之后基本就过拟合了,表现为相似度越来越"死板"。
减小维度。从 200 降到 50,参数少了很多,小语料反而训得更充分。这一点和直觉相反,但实测有效。
降低 min_count,同时缩小词表上限。把长尾词保留下来,但控制总量,让每个词都能被训练到足够多次。
用字符级或者子词信息辅助。中文可以考虑按字训练,再把字向量拼成词向量,这是一个独立的话题,但确实是低资源场景下的常规手段。
6.4 排查速查表
| 现象 | 最可能的原因 | 快速验证方式 |
|---|---|---|
| loss 出现 nan | 学习率过大 / log(0) | 降 lr 到 1e-3,损失里加 eps |
| loss 完全不降 | 学习率过小 / 数据配对错误 | 抽 10 条样本肉眼检查 |
| 相似词全是功能词 | 下采样失效 / 负采样分布错 | 打印 keep_prob 实际取值 |
| 所有词向量几乎相同 | W_in 用 0 初始化 | 检查初始化代码 |
| 训练速度极慢 | 负采样实时计算 / CPU 瓶颈 | 预生成负采样池 |
| 换批数据效果骤降 | 维度过高 / 过拟合 | 降维到 50 重训对比 |
7. 训完之后:怎么验证和用起来
7.1 三个验证角度,缺一不可
很多人训完模型就直接看"最近邻",看到几个词比较像就以为成了,这个验证太单薄。我一般会从三个角度看。
最近邻,就是给定一个词,找余弦相似度最高的十个词。这个最直观,但主观性也最强,容易自欺欺人。多挑几个不同类型的词看,专有名词、动词、形容词各来两个,如果全都合理,那基本没问题。
类比任务,经典的"国王 - 男人 + 女人 ≈ 女王"。中文上这个任务不太好做,因为词形变化少,但可以用"北京 - 中国 + 日本 ≈ 东京"这类地理类比。跑一组类比题,算准确率,这个指标比最近邻客观得多。
下游任务,这是最终裁判。把词向量冻结,接到一个文本分类模型上,看验证集 F1。这个最有说服力,但成本也最高。我的习惯是前两个先跑,如果最近邻明显不对劲,就别浪费时间去跑下游了。
def most_similar(word, topn=10): if word not in word2idx: return [] idx = word2idx[word] vec = W_in[idx] norms = np.linalg.norm(W_in, axis=1) + 1e-9 sims = (W_in @ vec) / (norms * (np.linalg.norm(vec) + 1e-9)) order = np.argsort(-sims)[1:topn + 1] return [(idx2word[i], round(float(sims[i]), 4)) for i in order]注意算余弦相似度的时候要加eps,不然零向量会直接除出nan。这种情况在低频词上真的会出现,因为它们的梯度更新次数太少,向量模长可能接近 0。
7.2 向量的几种用法
训好的W_in是个 V × D 的矩阵,用法无非三类。
一是查表,给定词拿向量,做相似度计算、去重、聚类。这类用法最直接,注意做相似度之前先做 L2 归一化,不然模长大的词会占便宜。
二是当特征送进下游模型。做法是把句子里所有词的向量取平均或者做加权平均,得到一个句向量。这个做法简单粗暴,在短文本分类上效果还行,长文本就明显不如专门的句向量模型了。如果下游是神经网络,更好的做法是把词向量矩阵当成 embedding 层的初始权重,训练时允许微调,这样模型能针对任务调整词表示。
三是做冷启动的语义先验。比如新来了一个词,词表里没有,可以用它的字向量或者字符 n-gram 的组合来近似它的向量,这是一种常见的兜底方案。中文场景下按字拆解特别自然,"词向量"如果没在词表里,可以拆成"词""向量"两部分,各自查表再平均。
7.3 模板化:把这份代码改成你自己的
最后说一下怎么套用。整份代码的可变部分集中在三个地方:语料输入、超参配置、模型规模。我一般会抽一个config出来:
config = { "min_count": 3, "max_window": 5, "dim": 100, "neg_k": 8, "subsample_t": 1e-4, "lr0": 0.025, "lr_min": 1e-4, "batch_size": 512, "epochs": 8, }这样做的好处是,做参数对比实验的时候不用改代码,跑脚本的时候传不同的 config 就行。我习惯把每次实验的 config、词表大小、最终 loss、几个抽查词的最近邻一起存到一个 json 里,时间长了就能看出规律,比如"这个数据集上窗口超过 5 就明显变差",这种结论比任何教程都可靠。
还有一个实操建议:第一次跑别追求效果,先确认流程通。把 epoch 设成 1,语料截前一万句,十分钟内跑完,看看 loss 是不是在降、样本数是不是合理、最近邻有没有一点点结构。流程通了再去调参,不然你会在一个本身就有 bug 的代码上反复调参,越调越迷茫。
我个人在实际操作中的体会是,Skip-Gram 这套东西代码量不大,但每个环节都有一两个细节能决定成败,尤其是下采样和负采样这两处,写法上的小差别会让最终效果差出一大截。所以我建议你别急着上大语料,先用几万词的小数据集把整条链路跑透,把每个中间产物都打印出来看一遍,把参数一个个单独改一遍看效果怎么变。这个"慢"的过程走完,后面换任何语料、任何领域,你都能凭感觉判断出问题大概出在哪一层。