1. 从独热到稠密Word2Vec出现前词向量到底卡在哪我刚接触CS224n的时候第一反应是词向量不就是把词变成一串数字吗有什么好学的后来真正把Word2Vec从论文到代码走了一遍才意识到这个看似简单的概念背后是整个NLP从符号主义走向分布式表示的分水岭。在Word2Vec出现之前学界和工业界主流的词表示方式有两种。一种是符号化的比如WordNet这种人工构建的知识库把词义组织成树状结构。问题很明显——人工维护成本极高覆盖不了新词而且同义词、上下位关系的颗粒度完全依赖语言学家的主观判断。另一种是统计式的比如独热编码one-hot。把每个词表示成一个词表长度的向量当前词的位置是1其余全是0。这种方式确实简单但两个致命问题一是词表一大向量维度就爆炸十万词表就是十万维二是任意两个词的向量内积永远是0猫和狗、猫和汽车在向量空间里没有任何区别。后来又有一类基于共现矩阵的方法比如把词和上下文窗口内的词统计共现次数建一个词-词矩阵再做SVD降维。这类方法在Word2Vec之前其实已经能产出一批还不错的向量但CS224n课程里反复强调的痛点就在这里共现矩阵的规模随词表平方级增长一个一百万词的语料矩阵动辄上万亿个元素SVD的复杂度根本扛不住。而且这类方法对高频词有天然偏向the和a这种词的共现关系会主导整体分解方向低频词的信息被稀释得非常严重。所以要理解Word2Vec的贡献不能只盯着它给出了一个能算相似度的向量。真正重要的是它提供了另一条完全不同的思路不再统计全语料的共现矩阵而是让模型在预测任务中不断调整词向量参数让语义信息在训练过程的梯度传播里被蒸馏出来。这就是CS224n课程反复强调的distributional semantics分布语义——一个词的意义由它经常出现的上下文决定而Word2Vec是第一套把这个思想用神经网络落地、并且能在海量语料上高效训练的词向量框架。当时课堂上有一句话我记得特别清楚语义不是被定义出来的而是被使用出来的。理解了这一层再看Word2Vec的两个架构和代码实现思路就顺多了。2. CBOW与Skip-gram的前世今生为什么要搞出两套架构2.1 两套架构在做什么Word2Vec论文里给出两种训练架构CBOWContinuous Bag-of-Words和Skip-gram。它们的任务刚好是对称的。CBOW的任务是用上下文预测中心词。比如句子我 喜欢 喝 咖啡窗口半径设为2那么给定我喜欢喝这些上下文词去预测中间的咖啡是什么。Skip-gram反过来给定中心词咖啡去预测它周围的上下文词我喜欢喝。一开始我有个困惑两种架构看起来只是把输入输出对调效果差别会很大吗CS224n的课后作业和论文实验都给了明确结论——差别不小。在词相似度任务上Skip-gram对低频词的表示明显更好CBOW训练速度更快但对高频词的语义刻画更平滑。原因是这样的Skip-gram每个训练样本只涉及一个中心词和它周围的一个上下文词同一个低频词只要出现几次就能领取几次梯度更新而CBOW是把多个上下文词向量先平均再做预测低频词在平均过程里被高频词稀释了梯度更新的强度被摊薄。如果你去读原始论文的Table部分会发现作者在训练时间和准确率之间做了一个帕累托式的权衡想要快选CBOW想要低频词效果好选Skip-gram。实际工程里多数场景选Skip-gram尤其是语料规模中等、词表覆盖不全的时候它的鲁棒性明显更好。2.2 训练瓶颈与两大战术改良但如果你真的去读CS224n讲义里关于Softmax的部分会发现一个现实问题标准的Softmax在做预测时要对整个词表的每个词算一遍分数再归一化词表十万那每个训练样本就要算十万次向量点积。这个计算量在当时2013年的硬件条件下是无法接受的。所以原论文提出了两个优化方向。一个是层级SoftmaxHierarchical Softmax本质上是把词表组织成一棵哈夫曼树预测时从根节点一路走到叶子节点每走一步只需要做一次二分类复杂度从词表大小降到树高也就是对数级别。工程上实现起来其实不复杂但树结构一旦固定词的表示能力受限于树的拓扑而且写起来不如另一个方案简单。另一个是负采样Negative Sampling这也是我们在代码里最常碰到的方案。负采样的思路是既然完整的Softmax太贵那我干脆不计算所有词的归一化概率只把正样本真实的上下文词的分数拉高同时随机抽几个负样本与当前词不相关的词把它们的分数压低。这样一来每次更新只涉及正样本加负样本总共十几个词计算量大幅下降。这里要特别注意负采样的采样分布原文用的是经验分布的三分之四次方[ P(w) \frac{f(w)^{3/4}}{\sum_{u \in V} f(u)^{3/4}} ]其中 (f(w)) 是词 w 在语料中的频率。为什么要加这个0.75次方直接按频率采样的话高频词比如的了是会被反复抽成负样本模型会一直往这些词不出现的方向优化低频词几乎被忽略。加上0.75次方的平滑效应后高频词的采样概率被压低低频词的采样机会被拉高负样本的分布更均匀实验也证明这能稳定提升词向量质量。这个细节是很多开源代码直接copy频次数组然后用torch.multinomial采样看起来很简单但如果你自己从零实现很容易漏掉这个3/4幂次而漏掉之后训练出来的向量在低频词上会有肉眼可见的退化。2.3 高频词降采样容易被忽略的预处理原论文里还有个预处理细节——subsampling即对高频词做随机丢弃。比如一句话里每个词以一定概率被丢弃概率公式大致是[ P_{\text{keep}}(w) \left(\sqrt{\frac{t}{f(w)}} 1\right) \cdot \frac{t}{f(w)} ]其中 t 是人工设定的阈值论文里常用1e-3到1e-5。为什么要做这一步因为的是在这类高频词在语料里出现得太频繁它们提供的语义信息有限却占据了大量的训练时间。丢弃一部分高频词不仅训练速度更快还能让上下文窗口更多聚焦在低频但有意义的词上。我在实现的时候一开始没做这步想着语料不大无所谓。结果训练出来的向量里哈哈啊这类语气词的近义词全是它们自己明显是高频词被重复训练得太多了。后来加上subsampling效果立刻改善。所以如果你想复现一个看起来正常的Word2Vec结果subsampling不是可选项几乎是必选项。3. 从零手写Skip-gram数据流与负采样的完整实现3.1 为什么建议手写一遍而不是直接调库我知道现在有Gensim这种封装得很好的库一行Word2Vec(sentences)就能跑出词向量。但CS224n的作业之所以设计成手写核心目的是让你理解每个模块存在的理由。如果你只会调库你永远不知道损失函数里那个负采样项到底在优化什么也不知道学习率设置不合理时loss曲线会表现出什么样的病态形态。下面我给出一个完整的Skip-gram 负采样的PyTorch实现从数据准备到训练再到相似度评测每段都会解释关键点。代码目标是运行在CPU上语料选用小型英文语料词表控制在几千词级别方便快速迭代。3.2 数据预处理从原始文本到词频表和训练样本import re from collections import Counter, defaultdict import random import math import torch import torch.nn as nn import torch.nn.functional as F corpus_raw [ the quick brown fox jumps over the lazy dog, the dog barks at the cat, cats and dogs play in the yard, a quick brown mouse runs away, ] def tokenize(text): text text.lower().strip() tokens re.findall(r\w, text) return tokens sentences [tokenize(s) for s in corpus_raw] # 统计词频建立词到索引的映射 word_count Counter() for sent in sentences: word_count.update(sent) vocab_size len(word_count) word2idx {word: i for i, (word, _) in enumerate(word_count.items())} idx2word {i: word for word, i in word2idx.items()}这里有个小决策词表直接用了全部去重后的词没有设最小词频阈值。实际项目里对出现次数小于5的词通常直接替换成UNK。原因有二一是这些词学不出稳定的表示五六个样本根本不可能撑起一个可靠的向量方向二是它们在负采样环节还会造成样本扰动。不过我这个小语料就无所谓了。接下来构建训练样本。Skip-gram里每个中心词和窗口内的每个上下文词构成一对训练样本。窗口大小可以设为非对称即左侧看2个右侧看2个也可以设成对称比如window2表示左右各2个。这里我做一下随机窗口处理对每个中心词实际采样的窗口半径在1到window之间随机目的有些类似subsampling——给离中心词更近的词更大权重同时减少重复样本的过度拟合。window 2 train_pairs [] for sent in sentences: for center_pos, center_word in enumerate(sent): w random.randint(1, window) start max(0, center_pos - w) end min(len(sent), center_pos w 1) for context_pos in range(start, end): if context_pos center_pos: continue context_word sent[context_pos] train_pairs.append((word2idx[center_word], word2idx[context_word]))3.3 负采样概率表0.75幂次的实际写法训练之前要生成负采样概率表。严谨的写法是维护一个weights数组然后每次负采样时用torch.multinomial从全词表里抽。考虑到词表不大我直接构建一个概率张量freq torch.tensor([word_count[word] for word in idx2word], dtypetorch.float32) freq_powered freq ** 0.75 neg_sample_probs freq_powered / freq_powered.sum() def sample_negatives(batch_size, num_neg5): # 每个样本抽 num_neg 个负样本返回形状 (batch_size, num_neg) return torch.multinomial(neg_sample_probs, num_neg * batch_size, replacementTrue)注意一个容易出错的地方正样本里中心词和上下文词不能成为自身的负样本。torch.multinomial不会感知当前上下文词是谁所以如果抽中了同一个词通常有两种处理方式一是直接跳过并重新抽二是把概率置零。实现中为了方便可以在损失计算时把负样本中与被预测上下文词相同的索引mask掉。但说实话在小语料里这个碰撞概率很低一般直接忽略也不会影响最终质量。3.4 模型前向嵌入层做查表Skip-gram模型本身极其简单一张词嵌入表查中心词向量再查上下文词向量算点积。这里不用写什么forward里的复杂结构核心就是两个nn.Embeddingnn.Embedding本质就是一张可学习的查表一个做中心词一个做上下文词。class SkipGramNegSampling(nn.Module): def __init__(self, vocab_size, embed_dim): super().__init__() self.center_embed nn.Embedding(vocab_size, embed_dim) self.context_embed nn.Embedding(vocab_size, embed_dim) def forward(self, center_idx, context_idx, negative_idx): # center_idx: (batch_size,) # context_idx: (batch_size,) # negative_idx: (batch_size, n_neg) center_vec self.center_embed(center_idx) # (B, D) context_vec self.context_embed(context_idx) # (B, D) pos_score torch.sum(center_vec * context_vec, dim1) # (B,) pos_loss -torch.log(torch.sigmoid(pos_score) 1e-8) neg_vec self.context_embed(negative_idx) # (B, n_neg, D) neg_score torch.bmm(neg_vec, center_vec.unsqueeze(2)).squeeze(2) # (B, n_neg) neg_loss -torch.log(torch.sigmoid(-neg_score) 1e-8).sum(dim1) return (pos_loss neg_loss).mean()解释一下为什么负样本的分数要取负再进sigmoid。我们期望正样本的点积尽量大负样本的点积尽量小。用sigmoid(-score)把分数越小、损失越小这件事转化成了标准的二分类损失形式——把正样本当作1负样本当作0。所以整个损失本质上就是把中心词-上下文词对当成正类把中心词-随机词对当成负类做一个逻辑回归。这个视角非常关键理解了它你就不会再好奇为什么这里用的不是MSE或者别的损失了。3.5 训练循环学习率、梯度更新与子图释放训练循环非常简单但有两个细节需要经验。第一个是学习率。Adam优化器默认学习率0.001在这个任务上通常可以但如果loss出现剧烈震荡多半是学习率偏大。我会先用torch.optim.SGD配0.05学习率跑两轮看趋势然后切Adam配0.001。真实项目里我通常对嵌入矩阵用更大的学习率因为嵌入参数更新相对稀疏但这属于后话。第二个是torch.no_grad()。在评测相似度、或者用训练好的中心词向量做下游任务时一定要包上torch.no_grad()否则梯度图会累积内存被一点点吃光。我见过不少人训练时正常一跑评测就爆内存十有八九是没关梯度追踪。epochs 200 lr 1e-3 model SkipGramNegSampling(vocab_size, embed_dim64) optimizer torch.optim.Adam(model.parameters(), lrlr) batch_size 128 n_neg 5 for epoch in range(epochs): random.shuffle(train_pairs) total_loss 0.0 for i in range(0, len(train_pairs), batch_size): batch train_pairs[i: i batch_size] center_idx torch.tensor([p[0] for p in batch], dtypetorch.long) context_idx torch.tensor([p[1] for p in batch], dtypetorch.long) negative_idx sample_negatives(len(batch), n_neg) optimizer.zero_grad() loss model(center_idx, context_idx, negative_idx) loss.backward() optimizer.step() total_loss loss.item() if epoch % 20 0: print(fEpoch {epoch}, Loss {total_loss / max(len(train_pairs) // batch_size, 1):.4f})如果你训练完之后发现loss在震荡但下降趋势不明确不要急着调网络结构先查看两个东西一是batch_size和数据顺序带来的梯度噪声二是学习率是否过冲。Skip-gram这种浅模型绝大多数训练异常都是超参问题而不是模型问题。3.6 相似度评测注意方向性训练结束后最常用的评测是词相似度。把中心词向量拿出来和另一个词的向量做余弦相似度。def cos_sim(a, b): return torch.cosine_similarity(a.unsqueeze(0), b.unsqueeze(0)) def query(target, topk5): target_vec model.center_embed.weight[word2idx[target]] sims torch.cosine_similarity(target_vec.unsqueeze(0), model.center_embed.weight) top_indices sims.argsort(descendingTrue)[1: topk1] return [(idx2word[i.item()], sims[i].item()) for i in top_indices] with torch.no_grad(): print(query(dog))这里有个很值得留意的现象对同一个词用中心词向量查上下文词表和用上下文词向量查中心词表得到的相似度排序常常不一样。原因在于Skip-gram训练时中心词和上下文词的参数是两套独立的表它们只是通过梯度被拉到相近位置但并没有完全对齐。工程上最后取词向量时很多人直接取center_embed就完事了如果你的下游任务表现不佳可以试试把两套向量拼接或者取平均。原始论文里的最终词向量通常取中心词向量但不同任务的最优选择并不绝对。4. 训练完才知道的坑一个实验五连坑的排查记录4.1 训练集过小相似度结果迷惑一开始我用上面那个只有四句话的语料跑实验结果query(dog)出的top1不是cat也不是bark而是dog自己虽然已经排除了目标词本身但语义上毫无区分度。原因很简单语料太小每个词出现的次数少得可怜向量之间基本没有稳定的共现关系。要让Word2Vec有效语料规模至少得上万句。CS224n作业里用的text8语料大概170MB训练出来的效果才谈得上语义。但这里有个实际矛盾小语料上跑模型的整个训练过程很快方便调试代码逻辑等逻辑通了再换大语料就能避免在调试期浪费大量算力。4.2 window_size设置的歧义不同实现里window_size定义不同。有的实现把window2解释成左右各2个词有的是窗口总长度不超过2。我在最初写数据生成代码时窗口边界计算用了center_pos - w到center_pos w结果w2时上下文窗口内包含了中心词自己虽然我把相等的位置跳过了但窗口边界如果取错甚至会把中心词左边的词漏掉。最终的规范写法是左边2个右边2个共4个上下文去重中心句本身。这点写进代码前最好先想清楚否则训练出的样本比例会偏移。4.3 负采样数量不是越多越好负样本数量n_neg设成5是论文里的默认值但很多人随手改成50或100以为负样本越丰富越好。实测下来n_neg越大每个正样本对受到的信号压制次数越多模型会把更多精力用于压低随机词分数而不是区分正样本和相近词。对语义区分度反而有伤害。CS224n的练习里给出的取值范围是2到10超过15以后收益基本为负。如果你的语料噪声特别大可以适度提升到10但别盲目加。4.4 词向量方向的不可解释性训练完成后整个词向量空间是各向异性的不同方向并不等价。中文里好和坏在某些维度上距离很近在另一些维度上又几乎重合。这不是bug而是分布式表示的固有特性——向量的方向只代表帮助模型完成任务的方向并不存在一个人类的意义坐标系。所以做下游任务之前通常要做向量均值中心化或对齐操作否则直接用原始向量做聚类结果会被整体偏移主导。我发现一个很实用的经验把训练好的词向量全体减去均值后再做归一化相似度匹配的稳定性会明显提升。原因在于训练过程中高频词向量通常占据了一个公共方向中心化能去除这个方向上的偏置。4.5 复现论文效果为什么那么难最后踩的一个大坑是想复现论文里的语义类比效果比如king - man woman ≈ queen。这个小语料里几乎不可能出现这种结果。原因很简单类比推理本质上是向量的线性运算能捕捉到可叠加的语义维度而这种维度需要大规模语料才能稳定涌现。CS224n课上展示的经典案例背后是数十亿词的语料和精心调过的超参。如果你在小语料上跑得到的更多是垃圾进垃圾出。我把这个踩坑过程整理成表格方便后面自己翻看现象根因处理办法相似度top1全是目标词自己语料过小停用词或目标词频率过高加大语料规模加入subsamplingloss下降后又反弹学习率过大调小学习率或换SGD热身低频词向量和随机向量没区别样本数太少负采样占比过大降低n_neg提升迭代轮数word2idx里没UNK遇到新词报错词表覆盖不全加UNK token并设最小词频可视化时不同类词混在一起没有中心化/归一化预处理去均值单位化5. 从Word2Vec到ELMo、BERT一条被继承的表示学习路线很多教程写到词向量训练完就结束了但CS224n这门课真正有价值的部分其实在后面——它能帮你建立一个清晰的演进坐标系。Word2Vec证明了词义可以从预测任务中涌现但它有一个绕不开的硬伤每个词只对应一个固定向量多义词没有分开表示。bank在river bank和bank account里用的是同一个向量这在很多下游任务里是致命的。后来ELMo的思路是对每个词学习一组向量再根据当前上下文赋予不同权重动态组合BERT更进一步用Transformer把整句话的上下文信息编码进每个位置的向量里。但你需要理解的是这些模型的底层逻辑和Word2Vec是一致的仍然是通过预测任务BERT里是掩码语言模型来学表示仍然靠梯度传播把上下文信息蒸馏到表示向量里。Word2Vec种下的预测式表示学习这颗种子并没有被推翻只是长成了更大的树。所以在实现层面你在手写Word2Vec时积累的这些经验——欠拟合与过拟合的判断、嵌入表的参数更新、负样本的采样分布、多义词的表示冗余——在转向预训练模型时依然全部适用。区别只是把两层的浅网络换成了十二层的Transformer把负采样换成了更复杂的训练目标但调参的直觉和排查问题的思路是一脉相承的。从我个人的实际感受来说Word2Vec是整个NLP领域里最适合手推一遍手写一遍的模型。它的数学相对简单代码量适中但是涉及了词表处理、采样分布、梯度训练、向量评价等一整套流程。把这套流程走通之后无论是去看Gensim源码还是去读BERT的代码都会觉得顺畅很多。如果你的学习路径也是从CS224n入门我的建议很直白不要跳过作业不要只跑通就直接下一章多问自己几个为什么多去改一改超参观察效果变化这个模型的全部价值都会在这种折腾里体现出来。