ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

用PyTorch从零实现Word2Vec的CBOW模型:原理、代码与调参实战

用PyTorch从零实现Word2Vec的CBOW模型:原理、代码与调参实战 很多刚接触NLP的朋友第一次听到“Word2Vec”这个名字都会以为它是一个开箱即用的工具包。确实用gensim训练一个词向量模型也就是几行代码的事调用Word2Vec(sentences, vector_size100)然后保存模型看起来就完事了。但如果你只是调包大概率会错过NLP中最关键的一块地基词是怎么变成向量的CBOW就是回答这个问题最简单的入口。这篇文章记录的是我用PyTorch从零实现CBOW模型的完整过程从原理、数据预处理、模型构建到训练与调参一次讲清楚。适合已经会一点Python、想入门NLP和词向量、但不想只停留在“调库”层面的读者。我会把代码和细节尽量都放出来你照着敲一遍基本上就能把这个模型吃透。1. 项目意义与整体设计思路1.1 为什么入门词向量要先写CBOWCBOW全称是Continuous Bag-of-Words连续词袋模型。这个“连续”强调的是它把词表示成连续的低维稠密向量而不是传统的one-hot离散表示“词袋”则意味着它只关心上下文里有哪些词不关心这些词的顺序。我见过不少新手一上来就啃BERT、GPT结果被注意力机制和Transformer搞得晕头转向最后连“词向量从哪来”都没搞明白。其实在深度学习NLP的路径里从CBOW开始是最平滑的它只有一层隐层没有循环、没有注意力但分布式表示、嵌入矩阵、负采样这些核心概念全都涉及了学完它再去看LSTM、Transformer会轻松很多。从教学角度看CBOW的代码量控制在两百行左右整个模型放在CPU上也能跑出效果这对初学阶段的硬件条件非常友好。你不需要先搞懂什么是多头注意力也不需要背整张注意力公式的推导只需要理解“上下文平均向量 - 预测中心词”这一条主线。等这条主线通了词嵌入的本质就抓到了后面再学nn.Embedding在其他场景里的用法也只是一层窗户纸的事。1.2 CBOW与Skip-gram这对兄弟该怎么选Word2Vec原文里其实有两个模型CBOW和Skip-gram。CBOW用上下文词去预测中心词Skip-gram正好反过来用中心词去预测上下文词。从效果上讲CBOW训练速度更快对高频词更友好Skip-gram虽然慢一点但在低频词上的表现通常更好。所以早期很多工业应用里低频词多的语料会优先选Skip-gram而CBOW更适合快速训练、语料规模大的场景。作为入门项目我推荐先写CBOW因为它的前向计算是一次“池化 预测”逻辑特别直接调Bug也容易。等CBOW跑通了改动几行代码就能变成Skip-gram顺带也能体会到两个模型的效果差异。我在自己项目里就吃过盲目选型的亏曾经在商品标题这种短文本场景里直接套用了Skip-gram训练慢不说低频词多但词表小优势完全发挥不出来。后来换成CBOW训练时间缩短了一大截下游分类效果反而更稳定。建议新手两个模型都亲手实现一遍选型的感觉就是这样攒出来的。1.3 从零实现而不是调库到底图什么有朋友问我gensim里一行代码就训好了自己写不就是在重复造轮子吗我的看法是调库和复现是两回事。调库你能看到词向量结果但你不一定知道这个结果是怎么被训练出来的自己用PyTorch从零实现你被迫去处理每一个细节语料怎么清洗、词表怎么建、正负样本怎么构造、梯度从哪来。这些细节恰恰是面试和实际项目中经常被问到的。另外脱离gensim之后你能把Word2Vec“迁移”到推荐系统的物品向量、图节点嵌入等场景。比如给用户的行为序列做CBOW把“商品”当词把“用户点击序列里的上下文商品”当上下文就能得到商品的向量表示。这个思路在很多推荐项目里是Core的一部分。如果你只会调word2vec包的API换个场景可能就不知道怎么下手了但如果你亲手实现过CBOW就能很快反应过来这本质上就是一个嵌入学习问题换一下样本构造方式就行。2. 核心原理CBOW到底在学习什么2.1 一句话说清CBOW的任务CBOW的任务非常朴素给你一个词的上下文让你猜中间这个词是谁。比如“我爱[ ]编程”如果窗口大小为1上下文就是“爱”和“编程”目标词是“我”。模型要输出的不是真的猜到“我”而是给词典里每一个词打一个概率分概率最大的那个词越接近真实目标词模型就算学得好。这里的关键是为了完成这个任务模型必须把具有相似语境的词映射到相近的向量这就是分布式表示的基础。比如“苹果”和“香蕉”经常出现在“吃水果”之类的语境里它们的输入向量就会慢慢靠拢。词向量就是这个“猜词游戏”里被训练出来的副产品。理解这一层你就能明白为什么CBOW学到的向量能反映语义相似度两个词的上下文越像它们的向量就会被拉得越近。2.2 模型结构其实只有三层CBOW的网络结构极其简单就三层输入层、隐层、输出层。输入层把上下文里每个词的one-hot向量长度是词典大小V通过一个嵌入矩阵W1查表得到对应的稠密向量然后取平均得到隐层向量隐层向量再接一个权重矩阵W2输出层是一个V维的logits经过softmax变成概率分布再用交叉熵去逼近真实目标词的分布。训练完成后我们真正要的是W1这个矩阵——它每一行就是一个词的向量表示。这里有一个容易混淆的点为什么查表就能替代矩阵乘法因为one-hot向量和矩阵相乘本质就是把矩阵的某一行取出来。PyTorch里的nn.Embedding做的就是这件事它不真的去算一个巨大的one-hot向量乘法而是直接用索引查权重矩阵的行省内存也省时间。这个设计在工程上非常重要是我个人觉得CBOW最值得体会的细节之一。很多人第一次看到这里会问输出层那个V维softmax是不是太贵了确实如果词典有10万词每训练一个样本都要算10万个类别的softmax慢到怀疑人生。这也是Word2Vec论文要提出层次Softmax和负采样的原因。负采样的思路是不预测全量词典而是把任务改造成“给定上下文判断某个词是不是真正的目标词”训练时对正样本和随机采样的几个负样本做二分类复杂度从O(V)降到了O(neg_num)而neg_num通常只有5到20。2.3 负采样为什么几乎成了标配负采样能大幅降低计算量但它不只是加速这么简单。它把多分类问题变成了多个二分类任务正样本是真实目标词负样本是从词典里按一定概率随机挑的几个词。模型要学的是让“上下文真实目标词”的得分尽量高让“上下文随机负样本”的得分尽量低。负样本的采样概率不是均匀的而是按词频的3/4次方归一化后采样。这个3/4次方是一个经验值它会把一些中频词的概率稍微抬高让采样结果更均衡避免全是“的”“了”这种高频词当负样本。我在刚开始实践时犯过一个错直接用均匀分布采样负样本结果模型对高频词产生了很强的偏见相似度结果乱七八糟。换成词频的3/4次方分布后效果立刻正常了。这个细节在gensim里被封装好了自己实现时很容易漏掉建议特别注意。3. 数据准备与处理训练样本从哪来3.1 语料选择与清洗这次实现我用的是一份中文新闻语料大概几百万字。对入门来说语料不需要太大几十万到几百万字足够跑出能看的词向量语料太小的话词向量会稀疏很多词学不到好表示。语料领域要和你的使用场景匹配你想做电商评论分析就用商品评论语料你想做新闻分类就用新闻语料。语料清洗这一步经常被新手忽略但词向量的质量很大程度上取决于它。中文语料一般要做这几步去重、去HTML标签、去掉无意义的符号和数字、按标点分句。我通常会把所有英文和数字转成小写或直接过滤避免“Apple”和“apple”被当成两个词。还有一个容易踩的坑如果语料来自爬虫里面经常混着大量URL和乱码这些噪声会让词表变得很脏。我的经验是清洗阶段宁可多过滤也不要留着奇怪符号进词表否则训练出来的向量会有明显“噪声聚集”现象。3.2 分词与词表构建中文没有天然空格分隔必须先分词。最简单直接的办法是jieba分词虽然它在某些专业领域表现一般但对入门项目足够了。分词之后遍历所有词统计词频过滤掉出现次数太少的词我常用min_count5再给剩下的词编号。构建词表时要留一个UNK槽位防止评估时遇到没见过的词。这里有个容易纠结的问题要不要删掉“你”“我”“的”这类停用词我的做法是高频词不删因为CBOW的负采样和子采样本身就能处理高频词偏置删掉反而会破坏上下文信息。比如“的”虽然本身没什么语义但它能帮助模型区分“我的书”和“书”的语法结构。这一步输出两个关键结构word2idx字典和idx2word列表后面所有代码都依赖它们。词表大小直接影响显存和训练速度入门语料通常会有3万到8万词这个规模完全可控。3.3 滑动窗口与训练样本生成有了词表之后接下来要构造样本对。对语料里每一句话以每个词为中心取前后window_size个词作为上下文。比如窗口大小设为2句子“我 爱 自然 语言 处理”里中心词“自然”的上下文就是“我”“爱”“语言”“处理”。每次生成一条记录包含上下文词的索引列表和目标词的索引。这里有个小细节靠近句子开头的词的上下文不足窗口大小通常会做padding或者直接跳过样本。我实际用的是“不足就跳过”因为padding出来的0号词往往是UNK会引入噪声。窗口大小一般取5左右窗口越大学到的词向量越偏向主题语义窗口越小越偏向句法和近义关系。入门项目可以先用2到5训练完对比效果。生成样本的代码不算复杂我习惯把每个样本写成(context_indices, target_index)的元组最后统一转成tensor。def generate_samples(token_ids, window_size2): samples [] for i in range(window_size, len(token_ids) - window_size): context token_ids[i - window_size:i] token_ids[i 1:i window_size 1] target token_ids[i] samples.append((context, target)) return samples3.4 子采样与负样本采样训练之前还有两个预处理步骤不能省。一个是子采样就是按概率丢弃一些高频词。道理很直观像“的”“了”“在”这种词在几乎每个窗口里都出现贡献的信息量很低却占了大量训练时间而且它们会让“共现窗口”失去区分度。Word2Vec原文给了一个丢弃概率公式主要和词频相关高频词概率大低频词基本保留。另一个是负采样上面说过用词频的3/4次方分布来采样。实现时我会预计算一个概率数组之后每次训练直接用torch.multinomial采样速度很快。我通常一次性把所有负样本索引生成好而不是每个batch实时采样这样能省不少时间。这两步虽然写起来只有几行但直接影响词向量质量建议不要跳过。如果你觉得子采样公式拗口可以先用一个简化版词频超过某个阈值时按固定概率丢弃效果也不差。4. PyTorch从零实现模型与训练4.1 模型定义双嵌入表是关键我选的方案是负采样版CBOW模型里维护两张嵌入表in_embed用于查上下文词的向量out_embed用于计算目标词和负样本的得分。为什么要两张表这是Word2Vec原始论文的设计。你可以理解为一个词作为“被预测目标”时的向量和它作为“上下文”时的向量允许是不同的。这样做能让训练更稳定最终使用词向量时可以把两张表相加或拼接得到最终向量效果通常比只用一张表好一点点。在PyTorch里实现如下import torch import torch.nn as nn class CBOW(nn.Module): def __init__(self, vocab_size, embedding_dim): super().__init__() self.vocab_size vocab_size self.embedding_dim embedding_dim self.in_embed nn.Embedding(vocab_size, embedding_dim) self.out_embed nn.Embedding(vocab_size, embedding_dim) init_range 0.5 / embedding_dim self.in_embed.weight.data.uniform_(-init_range, init_range) self.out_embed.weight.data.uniform_(-init_range, init_range) def forward(self, context_ids, target_ids, neg_ids): # context_ids: (batch, 2*window) ctx_vec self.in_embed(context_ids).mean(dim1) # (batch, emb) pos_vec self.out_embed(target_ids).squeeze(1) # (batch, emb) pos_score torch.sum(ctx_vec * pos_vec, dim1) # (batch,) # neg_ids: (batch, neg_num) neg_vec self.out_embed(neg_ids) # (batch, neg_num, emb) neg_score torch.bmm(neg_vec, ctx_vec.unsqueeze(2)).squeeze(2) return pos_score, neg_score那个torch.bmm是在做批量矩阵乘法把每个负样本向量和上下文向量做点积得到一批负样本得分。unsqueeze(2)是把(batch, emb)变成(batch, emb, 1)配合bmm就能一次算出所有负样本得分。初始化时把权重范围设在[-0.5/embedding_dim, 0.5/embedding_dim]附近是为了让初始logits不至于太大从而避免梯度爆炸。这个细节我实测过特别是使用SGD时初始化范围过大会导致loss直接飞掉。4.2 损失函数与训练循环损失函数用二分类交叉熵的思路。正样本得分要尽量大负样本得分要尽量小。我在代码里用F.logsigmoid来构造训练时对正样本计算-log(sigmoid(pos_score))对负样本计算-log(sigmoid(-neg_score))然后加起来取平均。等价于BCEWithLogitsLoss但这样写更直观也方便调试。import torch.nn.functional as F def compute_loss(pos_score, neg_score): pos_loss -F.logsigmoid(pos_score).mean() neg_loss -F.logsigmoid(-neg_score).mean() return pos_loss neg_loss训练循环并不复杂先对训练样本分batch前向拿到pos_score和neg_score计算loss反向传播然后更新参数。这里有一个经验之谈不要每次迭代都从原始语料重新生成样本。我是先把所有样本生成好存成整数索引的tensor再封装成PyTorch的Dataset和DataLoader。否则每次epoch都要重新滑动窗口CPU会被拖慢好几倍。from torch.utils.data import DataLoader, TensorDataset # context_ids_tensor: (num_samples, 2*window) # target_ids_tensor: (num_samples, 1) # neg_ids_tensor: (num_samples, neg_num) dataset TensorDataset(context_ids_tensor, target_ids_tensor, neg_ids_tensor) loader DataLoader(dataset, batch_size512, shuffleTrue) model CBOW(vocab_size, embedding_dim128) optimizer torch.optim.Adam(model.parameters(), lr0.001) for epoch in range(epochs): total_loss 0.0 for ctx, tgt, neg in loader: pos_score, neg_score model(ctx, tgt, neg) loss compute_loss(pos_score, neg_score) optimizer.zero_grad() loss.backward() optimizer.step() total_loss loss.item() print(fepoch {epoch 1}, loss {total_loss / len(loader):.4f})这里有一个微小但重要的点target_ids一定要保持(batch, 1)的形状否则squeeze(1)会把不该去掉的维度去掉。我在调试时遇到过pos_vec维度对不上导致矩阵乘法报错后来检查发现是创建tensor时忘了保持二维。这种小问题在自实现模型里特别常见遇到维度报错先打印每个张量的shape再逐行检查。4.3 超参数怎么选更省心下面这组参数是我在几百万字中文语料上实测比较稳的起点embedding_dim128window_size2到5负样本数neg_num5batch_size512学习率0.001Adamepochs5到10。embedding_dim不是越大越好太小表示能力不够太大在语料不足时容易过拟合入门阶段128够用了。负样本数一般是5到20太小训练不稳定太大反而引入噪声。batch_size主要看内存和显存CBOW这个模型很轻量CPU上也能跑只是速度慢一些。超参数推荐值说明embedding_dim128语料小用64语料大可以加到256window_size2-5窗口小偏句法窗口大偏主题neg_num5常用5到20越大训练越慢batch_size512CPU也能跑显存够可调大optimizerAdam入门阶段最省心lr0.001震荡时降到0.0003学习率是我最想提醒的点。用Adam时0.001是常用起点但如果loss出现震荡可以降到0.0003甚至0.0001用SGD时0.01到0.1也可能不够通常还需要配合学习率衰减。我在CPU上训练时发现Adam收敛比SGD稳定得多所以入门阶段直接用Adam就好别在优化器上给自己找麻烦。5. 训练效果分析与词向量评估5.1 训练过程中看什么指标训练时我主要盯两个东西一个是loss曲线是否平滑下降另一个是是否出现过拟合或梯度爆炸。CBOW的loss不会降到0因为负采样训练目标是“拉开正负样本得分”一个正常的loss大概会从几降到0.1到1之间具体量纲和语料、负样本数有关。如果loss在某个epoch后开始上升很可能是学习率太大如果loss下降特别慢可能是样本量不够或者学习率太小。这里我想特别提一句loss“必须为零”是误解尤其是负采样版本它不是一个传统的多分类交叉熵不需要追求极低loss。我见过有人因为loss在0.5左右徘徊就一直调参结果反而破坏了效果。正确的做法是在训练结束后直接看词向量的质量用相似度任务来验证而不是死磕loss数值。毕竟CBOW只是手段词向量才是我们要的产物。5.2 用相似度检查词向量质量训练完怎么知道词向量好坏最快的办法是找近义词。把模型切到eval模式取in_embed的权重也可以把in_embed和out_embed相加再做余弦相似度。我实测在中文新闻语料上“北京”的近义词能出现“上海”“广州”“深圳”这类城市名“篮球”的近义词能出现“足球”“排球”这类运动名。如果你发现“北京”的近义词全是“的”“了”说明高频词偏置没处理好多半是子采样或负采样没做对。计算余弦相似度的代码很简单先把向量L2归一化然后做矩阵乘法得到的就是所有词与目标词的余弦相似度。这里我习惯把最终词向量取成in_embed.weight out_embed.weight然后在归一化后求相似度。要注意训练时模型处于train模式nn.Embedding虽然没有dropout之类的东西但为了保险评估前还是要调用model.eval()并且用torch.no_grad()包住计算过程。5.3 向量可视化TSNE降维评价词向量还有一个直观方式用TSNE把128维向量降到二维把感兴趣的词标出来。这一步能让你一眼看出“语义相近的词是否聚在一起”。我用的是sklearn里的TSNE把“体育”“音乐”“科技”等领域的词各选几十个降维后画散点图效果好的话不同领域的词会明显分簇。有一点要注意TSNE每次运行结果会有随机性而且对超参数perplexity比较敏感不要因为一次效果不好就怀疑模型。另外为了可视化效果好选词时尽量选高频词低频词在TSNE里往往被挤到边缘看不出聚类效果。我自己的经验是perplexity设在20到40之间迭代次数不要少于1000出图才比较稳。可视化主要是给人看的真正上线还是看下游任务效果。6. 常见问题与排查技巧实录6.1 Loss不下降或震荡怎么办这是我在带人跑CBOW时遇到最多的问题。原因通常集中在四个地方学习率不合适、数据没打乱、样本构造错误、负采样比例不合理。排查顺序建议是先看学习率太高会震荡太低会半天不动再看DataLoader有没有开shuffle然后打印几个batch的样本确认上下文索引和目标索引不是错位的最后检查负样本采样分布如果全部采到同一个高频词loss也会不稳。一个我常用的小手段是把loss打印保留每个step而不是只打印每个epoch的平均值这样能看到loss下降的细节比单看epoch波动容易定位问题。还有一次我遇到的问题非常隐蔽负样本和正样本的索引完全一样也就是目标词同时出现在负样本里导致loss一直降不下来。后来我在构造负样本时加了排除逻辑保证负样本里不包含当前batch的目标词问题就解决了。6.2 词向量结果像随机噪声明明loss下降了近义词却乱七八糟这是另一个高频问题。我总结下来常见原因有语料太少或领域太杂分词分得不好很多有意义的词被切开min_count太小低频噪声太多或者没有做子采样高频词主导了训练信号。还有一个隐蔽问题最后取向量时用错了表。负采样版本里如果只取out_embed去算相似度效果通常不如in_embed和out_embed相加甚至有明显劣化。如果这些都没问题那可能需要增加epoch或扩大语料。我最早在只有几万字的玩具语料上跑近义词基本没法看这不是代码错了而是数据量不够。词向量属于数据饥渴型方法数据量上去了效果自然会有明显提升。建议入门阶段至少准备几十万字的语料再小的话别指望有惊艳结果。6.3 训练太慢怎么优化CBOW本身并不重慢主要慢在样本生成和全量softmax。如果你用的是softmax版词典10万词时会非常痛苦建议换成负采样版。另外负采样样本如果每个step都用torch.multinomial实时采样也会增加不少时间我一般提前一次性生成所有负样本索引训练时直接查表读取。再一个容易被忽略的点是数据和模型是否都在CPU上PyTorch在CPU上跑小模型时线程开销反而可能拖慢可以试一下调低torch.get_num_threads()或者干脆用GPU。实测几百万字、词表5万左右用负采样版在CPU上训练十几个epoch也就几十分钟是可以接受的。如果你在Windows上发现训练特别慢先检查PyTorch是不是装了CPU版本以及Python环境里有没有开其他占CPU的程序。还有一个经常被问到的PyTorch安装很慢怎么办这个跟CBOW本身关系不大但有环境问题确实会卡住进度。我的一贯做法是使用国内镜像源安装下载速度能快很多版本选择也建议直接按PyTorch官网推荐的稳定版来装别追最新版容易遇到CUDA不匹配的问题。6.4 关于环境配置的一点建议虽然这篇的重点是实现但环境坑也得提一嘴。很多新手在安装PyTorch时就卡住了特别是GPU版光是CUDA版本匹配就能折腾一晚上。我的建议是入门阶段完全可以用CPU版先跑通CBOW因为模型很小CPU训练完全能接受。等以后做大规模训练或跑CNN、Transformer时再考虑GPU版也不迟。如果你一定要装GPU版记住一个原则PyTorch、CUDA、显卡驱动三者要匹配。最容易翻车的地方是装了最新版PyTorch但CUDA版本太老或者反过来。安装时可以用官方命令生成器选择适合自己环境的命令再用国内镜像加速下载。这个过程容易消磨信心所以我的经验是“先跑通再优化” —— 先用CPU版把代码跑起来确保项目逻辑没问题再回头折腾GPU环境会从容很多。跑通CBOW之后我强烈建议你立刻做一件事找一个和自己业务相关的语料重新训练一遍然后写一个小的相似词搜索工具感受一下词向量在真实数据上的表现。这一步能帮你把“模型跑通”和“模型能用”连起来。我个人在最早实践时就是靠反复拿不同语料训练、观察近义词列表才真正建立起对Word2Vec的直觉。接下来你可以试试把代码改成Skip-gram或者加入层次Softmax这些都是很好的扩展练习。祝训练顺利跑出有意思的词向量。
返回列表