词嵌入与word2vec从原理到实战:文本表示核心指南
发布时间:2026/9/15 2:33:06 作者:尧图编辑部 阅读量:1,286

在自然语言处理里待久了你会发现几乎所有任务的起点都是同一个问题怎么把文字变成模型能算的东西。词嵌入和word2vec就是这套思路里最有代表性的方案。这篇内容我想把它讲透——它不是“调个库、训个向量”的操作记录而是从为什么需要词向量到两种核心架构的取舍逻辑再到手写一个最小实现最后聊一聊我实际训练时踩过的那些坑。适合正在学深度学习、想把NLP基本功打牢的同学也适合那些调过word2vec但没搞懂内部原理的人。1. 词嵌入到底解决了什么问题1.1 one-hot编码的困境维度爆炸与语义孤岛先回到最原始的做法。假设你有一个包含五万个词的词典要给每个词一个表示最简单粗暴的方式就是one-hot给每个词分配一个五万维的向量当前词的位置是1其余全是0。这个方案有两个致命的毛病。第一个是维度爆炸。五万个词就要五万维向量这还只是一个很小的语料。如果是百万级词表那表示的维度就是百万级不管是存储还是计算都很难看。第二个毛病更致命one-hot向量之间没有任何语义关系。“猫”和“狗”这两个词的向量不管从哪个角度看相似度都是0。你没法通过这种表示告诉模型“猫和狗都是宠物”。这两个问题实际上是所有传统表示方法的通病。比如TF-IDF和共现矩阵虽然能在一定程度上体现词频和共现关系但本质还是基于稀疏的高维统计维度高、数据稀疏而且很难捕捉深层的语义关联。做机器翻译、情感分析这类任务时这种表示真的很难用。1.2 分布假说从统计规律到向量空间词嵌入的核心思想来源于语言学里的分布假说一个词的含义可以由它经常出现的上下文来刻画。这句话说白了就是——看一个词跟哪些词经常一起出现你就能猜出它大概是什么意思。word2vec就是把这个假说变成了一个可以训练的神经网络模型。我第一次接触这个概念时有个特别直观的类比你去参加一个聚会不认识某个人但你观察他一直在跟程序员聊代码、聊框架、聊部署那你能猜出这个人大概率也是做技术的。词嵌入就是通过“观察词的朋友圈”来给词画像。与one-hot不同词嵌入的核心是把每个词映射到一个低维稠密向量上比如100维或300维。这样一来“猫”和“狗”的向量就会因为经常出现在相似的上下文里而变得接近模型也能从这些向量中学到词之间的语义关系。而word2vec之所以名声大噪是因为它在2013年提出时就提供了两套非常优雅且高效的训练方案接下来会细讲。2. word2vec的核心设计拆解从整体到局部2.1 CBOW和Skip-gram一对互补的视角word2vec提供了两个不同的训练思路CBOW和Skip-gram。CBOWContinuous Bag-of-Words是“用上下文预测中心词”。给定一句话“我 喜欢 深度学习”如果窗口大小设为2那么CBOW会尝试用“我”、“喜欢”、“深度”、“学习”这四个词来预测中间的“深度学习”。它的特点是训练速度快因为它一次利用整个上下文的信息对于较小的语料库或者词频较高的词汇效果往往比较稳。Skip-gram则正好相反它是“用中心词预测上下文”。给定中心词“深度学习”模型要去预测它周围的词“我”、“喜欢”、“深度”、“学习”。Skip-gram每个词对只利用一个输入一个输出训练速度相对较慢但对低频词的表示效果通常更好。实际场景里语料越大Skip-gram的优势越明显语料较小或者想快速训练一个基线模型CBOW是更划算的选择。这两个架构的选择本质上是训练效率和表示质量之间的权衡。我自己的经验是在做中文语料的词向量预训练时如果语料规模在几千万字级别以上我倾向用Skip-gram如果只是一个领域内的几百万字小语料CBOW更容易得到一个看起来“合理”的结果。2.2 训练加速的关键负采样与层次Softmax理论上word2vec最直接的实现方式是用Softmax计算所有词的概率然后在五万甚至一百万词上做归一化。但这在计算上完全不可行每更新一个样本就要算一遍全词表的Softmax成本高得离谱。于是作者提出了两个优化方案层次Softmax和负采样。层次Softmax利用霍夫曼树把全词表的概率计算转化为一系列二分类任务计算复杂度从O(V)降到O(logV)V是词表大小。这是一种无损或近似无损的加速方式。负采样的思路更直白不计算全词表的概率而是只让模型区分“真实的目标词”和“随机抽取的几个噪音词”。这相当于把多分类问题变成了二分类问题每个训练样本只需要计算k1个词的得分k通常取5到20。实际使用中负采样更常用因为它实现简单、调参直观而且对词向量的质量往往有帮助。我最初自己写word2vec时天真地实现了完整Softmax结果在一个十万词表的语料上跑了一个小时还没完一个epoch。后来换成负采样同一个语料十几分钟就训完了一轮。这个对比让我深刻理解到负采样不是一个可有可无的trick而是word2vec能够在海量语料上跑起来的关键。3. 用Python从零实现一个word2vec3.1 数据准备构建词表与生成训练样本直接调gensim的Word2Vec很方便但要把原理吃透我还是建议自己写一遍。这里我选一个最简单的Skip-gram 负采样方案来实现用PyTorch做自动求导把主要精力放在数据流和损失设计上。第一步是准备语料。为了演示方便我用一个简短的英文文本但代码本身不挑语言你换成中文分词后的列表也一样跑。先把所有词变成索引from collections import Counter import random import torch import torch.nn as nn import torch.optim as optim corpus [ the quick brown fox jumps over the lazy dog.split(), the quick blue cat runs under the brown table.split(), the dog and the cat are friends.split(), the student learns deep learning in the classroom.split(), the teacher teaches neural networks with great patience.split(), ] # 构建词表过滤低频词 min_count 1 word_counts Counter() for sentence in corpus: word_counts.update(sentence) vocab [w for w, c in word_counts.items() if c min_count] word2idx {w: i for i, w in enumerate(vocab)} idx2word {i: w for w, i in word2idx.items()} vocab_size len(vocab) print(词表大小:, vocab_size)第二步是生成训练样本。Skip-gram的样本格式是(中心词, 上下文词)这样的词对。我定义一个窗口大小遍历每个句子中的每个词把它周围的词放进窗口内def generate_training_data(corpus, word2idx, window_size2): pairs [] for sentence in corpus: indices [word2idx[w] for w in sentence if w in word2idx] for center_pos, center_idx in enumerate(indices): start max(0, center_pos - window_size) end min(len(indices), center_pos window_size 1) for ctx_pos in range(start, end): if ctx_pos center_pos: continue context_idx indices[ctx_pos] pairs.append((center_idx, context_idx)) return pairs training_pairs generate_training_data(corpus, word2idx, window_size2) print(训练样本数量:, len(training_pairs))然后做负采样。负采样需要抽样出“噪音词”实践中会用一个经过平滑处理的分布来采样。原论文里用的unigram distribution的3/4次幂主要是为了压制高频词的采样概率让低频词有更多被抽中的机会。我先算好这个分布word_freq torch.tensor([word_counts[w] for w in vocab], dtypetorch.float32) word_freq word_freq ** 0.75 word_freq word_freq / word_freq.sum()3.2 构建网络结构与损失函数Skip-gram 负采样的网络结构其实很简单一个Embedding层作为输入向量表另一个Embedding层作为输出向量表。每个词有两套向量这是word2vec的一个细节也是很多人初学时容易忽略的地方。我这样理解这两套向量输入向量是词作为“主语”时的表示输出向量是词作为“上下文”时的表示。训练结束后我们通常只保留输入向量作为最终的词嵌入但输出向量在训练过程中也起着至关重要的作用。class SkipGramNegSampling(nn.Module): def __init__(self, vocab_size, embedding_dim): super().__init__() self.in_embed nn.Embedding(vocab_size, embedding_dim) self.out_embed nn.Embedding(vocab_size, embedding_dim) # 初始化向量 nn.init.uniform_(self.in_embed.weight, -0.5 / embedding_dim, 0.5 / embedding_dim) nn.init.uniform_(self.out_embed.weight, -0.5 / embedding_dim, 0.5 / embedding_dim) def forward(self, center, context, negative): # center: (batch,) # context: (batch,) # negative: (batch, neg_count) center_vec self.in_embed(center) # (batch, emb) context_vec self.out_embed(context) # (batch, emb) neg_vec self.out_embed(negative) # (batch, neg_count, emb) positive_score torch.sum(center_vec * context_vec, dim1) # (batch,) positive_loss -torch.log(torch.sigmoid(positive_score) 1e-8) neg_score torch.bmm(neg_vec, center_vec.unsqueeze(2)).squeeze(2) # (batch, neg_count) neg_loss -torch.sum(torch.log(torch.sigmoid(-neg_score) 1e-8), dim1) return (positive_loss neg_loss).mean()训练时每个batch里除了真实上下文词我还随机采样neg_count个噪音词。这里用torch.multinomial按前面算好的分布采样def train(model, pairs, epochs50, lr0.01, batch_size64, neg_count5): optimizer optim.Adam(model.parameters(), lrlr) dataset pairs n_batches (len(dataset) batch_size - 1) // batch_size model.train() for epoch in range(epochs): total_loss 0 random.shuffle(dataset) for i in range(n_batches): batch dataset[i * batch_size: (i 1) * batch_size] center torch.tensor([p[0] for p in batch], dtypetorch.long) context torch.tensor([p[1] for p in batch], dtypetorch.long) negative torch.multinomial(word_freq, len(batch) * neg_count, replacementTrue).view(len(batch), neg_count) optimizer.zero_grad() loss model(center, context, negative) loss.backward() optimizer.step() total_loss loss.item() if (epoch 1) % 10 0: print(fepoch {epoch 1}, loss: {total_loss / n_batches:.4f})3.3 训练效果验证从词向量到语义相似度训练结束后我习惯用余弦相似度来检查词向量的质量。比如查一下“cat”最相似的词是哪些def get_similar_words(model, word, topk5): idx word2idx[word] vec model.in_embed.weight.data target vec[idx] sims torch.cosine_similarity(target.unsqueeze(0), vec, dim1) top_indices sims.argsort(descendingTrue)[1: topk 1] return [(idx2word[i.item()], sims[i].item()) for i in top_indices] for w in [cat, dog, deep]: print(w, get_similar_words(model, w))在极小的演示语料上结果可能不会特别惊艳——毕竟只有几行文本——但你会发现cat和dog的相似度明显高于cat和learningdeep和learning会靠得很近。这就是词嵌入最基本的语义捕捉能力的体现。真正投入生产时需要把语料换成几GB的维基百科或行业数据维度调到100到300训练几十个epoch。基本原理和这段演示代码完全一致只是规模大了很多。4. 实战中的常见问题与排查技巧4.1 训练不收敛学习率和初始化怎么调我自己第一次手写word2vec时出现了loss飙到NaN的情况。排查了半天发现是embedding初始化范围太大导致sigmoid输入极端log里面出现0。后来我固定用一个经验范围[-0.5/embedding_dim, 0.5/embedding_dim]。这个范围的逻辑是让初始的向量内积保持在较小的量级sigmoid的输入不会过于极端数值稳定性就好很多。学习率方面word2vec的损失函数相对简单Adam一般用0.001到0.005就能稳住。如果你用的是SGD建议初始学习率设在0.01到0.025之间并且随训练进度衰减。注意观察loss曲线如果前几个epoch下降明显、后面变得平缓这是正常的如果一直在高位震荡不下降大概率是学习率偏大。负采样的数量也会影响训练稳定性。我建议先从neg_count5开始试验如果语料词表很大超过十万或者任务偏向低频词效果可以提高到10到20。负采样的数量过多会让模型过度区分噪音词导致向量质量下降这个是实测中很容易踩的坑。4.2 词向量效果不佳从语料到窗口的排查思路很多初学者训练完词向量后发现“相似词”看起来毫无关联。问题往往不在模型而在数据预处理和超参设置。第一个要排查的是语料质量。词向量的学习完全依赖共现信息如果你的语料里“苹果”和“香蕉”从不出现在相似的位置那模型再强也学不到它们的相似性。语料需要足够大、主题多样、并且做过合理的清洗和分词。中文语料尤其要注意分词质量分词错误会在根本上扭曲共现关系。第二个是窗口大小。窗口决定了“上下文”的范围。窗口太小比如1学到的向量更偏向语法位置信息窗口太大比如10以上向量会更偏向主题或领域信息。我做文本分类任务时通常用5左右的窗口做词语相似度任务时窗口小一点效果更明显。你可以把一个词的相邻词打印出来人工看一眼窗口范围内是否真的是你想要捕捉的语义单元。第三个是迭代次数。word2vec不是训练越久越好。训练过头低频词的向量会变得比较怪高频词的向量则会趋于稳定。建议每次保存模型后跑一批词语相似度测试集选效果最好的那个epoch来用。这种“早停”策略比盲目训练到底部更合理。4.3 一份参数速查表与扩展建议这里把我实践中用过的参数整理一下方便你参考参数推荐范围说明embedding_dim100~300词表越大维度越高window_size3~10语义相似度任务用偏小窗口min_count1~5过滤低频噪音词neg_count5~20词表大或低频词多时取高值lrAdam0.001~0.005过大会震荡过小收敛慢epoch按语料而定建议配合验证集早停subsample率1e-3~1e-5用于压制“的”“了”等高频词另外word2vec本身是一个非常好的起点但如果你做的是预训练语言模型方向后续可以接触GloVe、FastText、ELMo、BERT等方法。FastText在word2vec基础上增加了子词信息对中文分词错误和OOV问题更友好GloVe则从共现矩阵的全局统计角度出发跟word2vec的局部窗口思路形成互补。理解word2vec之后再去看这些模型会快很多。结尾从我自己的经验来看词嵌入最迷人的地方在于它把离散的语言符号变成了连续空间里的几何关系而word2vec以一种极其优雅的方式实现了这一点。写这个最小实现的过程中我收获最大的不是看懂了几行代码而是真正理解了负采样为什么重要、两套embedding为什么必要、窗口和采样分布到底在调节什么。如果你也想深入NLP手写一个word2vec是个绕不过去的功课。之后再回到gensim或者fastText你就能很清楚地知道调参时遇到的问题出在哪里了。