
做自然语言处理绕不开“怎么把文字变成数字”这个基础问题。深度学习模型读不了字符串只能处理数值张量。早先大家用 one-hot 把每个词编成唯一的 0/1 向量但这样得到的向量维度巨大、完全表达不了语义关系“苹果”和“香蕉”之间的距离跟“苹果”和“汽车”之间的距离几乎一样。CBOW 是 word2vec 里最经典的神经网络结构之一中文叫“连续词袋模型”任务很直白给你一段上下文让你预测中间那个词。听起来像完形填空但把训练好的权重抽出来每一行就是一个词向量。这篇博客我不打算只讲概念而是把 CBOW 的训练和测试完整走一遍包括数据怎么处理、神经网络怎么搭、loss 怎么看、结果怎么评估最后把实操作业里常见的坑也一起列出来。适合刚入门词向量的读者也适合调参已经调到一头雾水的朋友。1. 项目概述与设计思路1.1 CBOW 到底想解决什么痛点传统统计语言模型比如 N-gram核心是计算一串词的条件概率但它对数据稀疏非常敏感。一个句子稍微换个说法组合可能就没在语料里出现过概率直接归零更谈不上把“猫”和“狗”的语义关联起来。CBOW 的思路是把词的共现关系压缩到固定维度的向量里。训练过程本质上是“基于上下文的完形填空”让模型用周围词预测中心词为了把这件事做对模型被迫把词的语义、语法角色、共现模式都压进向量。于是经常出现在相似语境里的词最后会获得相近的向量。所以 CBOW 的主要用途不是生成句子而是学习词向量。它可以在大规模无标注语料上做预训练再把向量作为特征或初始化权重交给下游任务比如文本分类、情感分析、命名实体识别、机器翻译的前置 embedding。我遇到这类需求时通常先问三个问题语料有多大、后续任务对可解释性要求高不高、是否需要增量更新。这三个问题基本决定了我是用现成工具快速出向量还是用深度学习框架手写一个可以嵌进业务系统的组件。1.2 为什么要把训练和测试放在一起讲很多资料只讲训练loss 降到很低就宣布结束。但 CBOW 真正考验人的地方恰恰在测试环节。训练只告诉你模型有没有收敛测试才告诉你词向量有没有语义。我见过不少 loss 下降很漂亮的模型一查“苹果”的最近邻居全是无关词甚至全是“了”“的”“是”这类功能词。这种情况非常常见原因往往不是网络结构写错了而是数据预处理、采样策略、超参数之间有隐藏 bug。所以我在实际项目里会把训练和测试当成一个闭环来设计先准备数据再训练模型然后用相似词、类比推理、降维可视化和下游任务做多角度验证。这比单看 loss 可靠得多。下面给两条路线一条是调用现成库快速出结果一条是从零手写理解每个细节。两条我都跑过关键差异会在后面明确说清楚。1.3 两条技术路线怎么选如果你的目标是快速得到一个可用的中文词向量表做实验优先用 gensim。它底层是 C 实现的训练速度比我们自己用 Python 手搓快一大截而且负采样、层次 Softmax 这些加速策略都封装好了几行代码就能训练。如果你想把词嵌入模块嵌入到自定义的深度学习模型里或者你想彻底搞清楚 CBOW 的前向计算和反向传播那就用 PyTorch 从零实现。手写最大的价值不是效率而是你能看清每个 tensor 的 shape 变化遇到问题能自己定位。还有一种情况是数据量超大、训练周期按天算这时候建议直接上 gensim 的多线程版本别自己折腾。但不管选哪条路我都建议先拿一份小语料跑通流程确认相似词结果符合直觉再放大规模。2. CBOW 模型原理拆解2.1 核心结构输入、投影、输出CBOW 的网络结构非常简单可以分成三层。输入层是上下文词的向量表示。假设窗口大小为 m中心词是 w_t那么输入就是它前后各 m 个词总共 2m 个词。每个词先查到一个稠密向量通常通过 embedding 层实现。投影层做的事更简单把这 2m 个向量做平均得到一个隐藏向量 h。输出层是一个全连接层把 h 映射回词表大小再过 Softmax得到每个词作为中心词的概率。用生活里的话说这就像你在一个小组里旁边几个人都告诉你一些特征但中间那个人的名字你忘了你只能根据身边人的平均特征去猜。模型训练的目标就是让“猜中正确答案”的概率最大。注意这里用的是“平均”而不是“拼接”所以上下文词之间的顺序对 CBOW 来说并不重要这也是“词袋”这个名字的由来。用公式表示就是h (v_{w_{t-m}} ... v_{w_{tm}}) / (2m)输出概率 p(w_t | context) 就是 h 经过输出矩阵 W 后的 Softmax 结果。训练过程中输入矩阵和输出矩阵都会被更新。它们都可以作为词向量实践中常见做法是只取输入矩阵或者把两个矩阵按列相加。我一般会先试输入矩阵效果不好再尝试两者组合差异在一些语料上会比较明显。2.2 为什么 CBOW 比 Skip-gram 训练快word2vec 家族里还有一个对称的结构叫 Skip-gram它是用中心词预测上下文词。Skip-gram 对每个上下文词都要做一次预测和梯度更新一个中心词周围有 2m 个上下文词就会有 2m 次预测开销。CBOW 是把 2m 个上下文词先平均成 h再做一次预测梯度更新次数小得多。所以 CBOW 的优势是训练速度快对高频词、常用词的向量质量通常也更好。缺点是它对低频词和生僻词没那么友好因为低频词很少作为中心词出现。Skip-gram 正好相反每个词都要充当中心词去预测周围词对低频词的训练机会更多。实际使用没有绝对优劣。如果语料不大、训练时间紧张我的选择是 CBOW如果语料足够大、低频词很重要我会转向 Skip-gram 或它的变体。很多库默认参数是 sg0也就是 CBOW也印证了它在大规模场景下的性价比。2.3 输出层加速负采样与层次 Softmax原理上输出层每次都要对全词表做 Softmax词表如果是 10 万一次前向就要算 10 万个指数运算训练会慢到不可接受。所以真正的工业实现都会做加速常见两种手段。第一种是负采样。它把“预测中心词”这个多分类问题转化成一系列二分类问题给定一组上下文词判断一个词是不是真正的中心词。正样本就是语料里真实共现的 (context, target)负样本则从词表里随机抽几个词常用 5 到 20 个。目标函数变成让正样本的 Sigmoid 输出逼近 1负样本的逼近 0。负采样相当于“抽查”而不是“全考”计算量大幅下降。第二种是层次 Softmax。它把输出层改成一颗哈夫曼树叶子节点是词内部节点也有参数向量。预测时不用比较所有词而是从根节点沿着路径走每走一步算一次二分类复杂度从 O(V) 降到 O(log V)。现代实现里负采样更常用因为实现简单、效果也不错。gensim 的 negative 参数就是控制负样本数量设为 5 是比较稳妥的起点。2.4 超参数设计的底层逻辑CBOW 的超参数不算多但每个都直接影响训练结果值得逐一说清楚。window 控制上下文窗口大小。窗口太小模型只能看到局部搭配学到的向量偏向语法位置窗口太大远距离的弱关联也混进来向量容易变得主题相似但语义模糊。常见值是 5短文本任务可以缩到 3。vector_size 是词向量维度。维度太低信息装不下维度太高小语料会稀疏训练也慢。常见范围是 100 到 300。如果语料不到一亿词100 维基本够用。min_count 是低频词过滤阈值。低频词没有足够多的上下文样本学出来的向量充满噪声。而且低频词数量庞大留着会让词表爆炸。一般设 2 到 5但有些只需要高频词的任务可以设更高。sample 是高频词降采样阈值。像“的”“了”“是”这类词出现太频繁提供的语义信息很少如果不处理它们会占据大量训练样本稀释其他词的更新。word2vec 会按概率随机丢弃部分高频词我常用 sample 值 1e-5 到 1e-4 之间。epochs 是训练轮数。CBOW 收敛很快轮次太多会过拟合到训练语料导致相似词结果固化。小语料 5 轮足够大语料 3 轮左右就可以看效果。3. 数据准备与预处理细节3.1 语料清洗和分词怎么处理CBOW 是数据驱动模型语料质量决定词向量上限。我习惯先做几件事统一字符串编码中文语料尤其要小心编码混乱去除 URL、邮箱、多余符号、不可见字符按句子切分保证窗口不会跨句。中文需要分词。分词质量直接决定上下文窗口是否正确。比如“自然语言处理”如果被切成“自然”“语言”“处理”窗口里的语义关系是合理的如果整个长词被拆散模型就学不到完整的语义单元。我常用通用分词工具但对领域语料比如医疗文本、代码文本最好再补一个领域词典。分词之后再决定是否去停用词。去不去要看下游任务。如果后面要做情感分析否定词“不”“没”就不应该去掉去掉会丢掉关键信息如果只是学通用词向量可以过滤掉部分超高频虚词减轻噪声。我见过有人为了省事把所有停用词删掉结果相似词结果一塌糊涂因为停用词没了窗口里的平均向量就会偏向内容词句子的整体结构被破坏。这个问题没有标准答案建议分别测。3.2 构建上下文窗口训练样本假设有句子“我 喜欢 学习 自然语言 处理”window 设为 2。以“学习”为中心词上下文就是“我”“喜欢”“自然语言”“处理”。模型输入是这 4 个词的 ID目标词是“学习”的 ID。窗口在句子里滑动每移动一个位置就生成一个训练样本。句子开头和结尾的词因为一侧不够 window可以选择跳过也可以只在有效范围内取平均。经典的 word2vec 实现会在句子内部动态截取不额外补空白。如果你手写 DataLoader我建议直接只保留那些上下文完整的中心词简单省事也避免 padding 带来的 mask 计算。真正训练时输入样本形状是 [batch_size, 2 * window]因为 CBOW 对上下文顺序不敏感所以不需要考虑输入顺序。值得注意的是样本的 target 必须保证在词表内任何未登录词都要提前替换成 UNK 或直接丢弃否则训练时 index 越界查都查不到。3.3 词表构建与低频词过滤词表是整个模型的字典。先统计每个词的频次然后按 min_count 过滤低频词。我见过一个中文语料原始词数几千万过滤前词表上百万过滤后只剩二十万模型参数量和训练时间都会大幅下降。低频词通常只出现几次上下文信息太少硬学只会得到随机向量保留反而有害。过滤后要给每个词分配整数 ID同时保存 word2id 和 id2word 两个映射。还要留一个 UNK 槽位给训练时没见过的词。手写代码时一个常见 bug 是句子里的词被 filter 掉之后上下文样本里出现了空洞。比如 window 内有些词因为 min_count 被删了你直接把剩下词平均但窗口长度已经不对。最稳妥的做法是先构建出过滤后的句子词序列再在过滤后的序列上重新切窗口不要在原句子上边切边过滤。还有一点保存词向量时一定要把 word2id 和 id2word 一起序列化保存否则加载模型时会发现 index 和词对不上这种事情我在多个项目里都吃过亏。4. 从零手写 CBOW 训练PyTorch 实现4.1 模型定义核心代码与张量形状用 PyTorch 写一个最朴素的 CBOW不加入负采样直接用全 Softmax适合小语料跑原理。先定义模型类import torch import torch.nn as nn import torch.nn.functional as F class CBOW(nn.Module): def __init__(self, vocab_size, embed_dim): super().__init__() # 输入权重矩阵每一行是一个词的向量 self.embeddings nn.Embedding(vocab_size, embed_dim) # 输出权重矩阵最后映射回词表大小 self.linear nn.Linear(embed_dim, vocab_size) def forward(self, context_ids): # context_ids: [batch_size, 2 * window] embeds self.embeddings(context_ids) # [batch, 窗口, embed_dim] # 对窗口维度做平均得到投影层 h hidden embeds.mean(dim1) # [batch, embed_dim] out self.linear(hidden) # [batch, vocab_size] log_probs F.log_softmax(out, dim1) # [batch, vocab_size] return log_probs这里 nn.Embedding 的 weight 就是输入词向量矩阵nn.Linear 的 weight 就是输出权重矩阵。隐藏层没有任何激活函数因为 CBOW 真正关心的是 embedding 层学到的向量非线性在这里不是必须的。如果你想验证自己对模型的理解可以打印一下每个中间张量的 shape整个链路非常清晰。4.2 训练循环DataLoader 与损失函数有了模型之后需要把 (context, target) 样本组织成 batch。我用自定义 Dataset 包装样本列表再用 DataLoader 做 batch。训练循环代码如下from torch.utils.data import Dataset, DataLoader import torch.optim as optim class CBOWDataset(Dataset): def __init__(self, contexts, targets): self.contexts contexts self.targets targets def __len__(self): return len(self.targets) def __getitem__(self, idx): return self.contexts[idx], self.targets[idx] model CBOW(vocab_sizelen(word2id), embed_dim100) loss_fn nn.NLLLoss() optimizer optim.Adam(model.parameters(), lr0.001) for epoch in range(5): total_loss 0.0 for ctx, target in DataLoader(CBOWDataset(contexts, targets), batch_size256, shuffleTrue): log_probs model(ctx) loss loss_fn(log_probs, target) optimizer.zero_grad() loss.backward() optimizer.step() total_loss loss.item() print(fepoch {epoch}, loss: {total_loss / len(contexts):.4f})这里损失函数用 NLLLoss 配合 log_softmax也可以直接用 nn.CrossEntropyLoss 配合原始 out。需要注意 target 的类型必须是 torch.long否则会报错。训练时如果词表特别大全 Softmax 会慢更接近实际的做法是手动实现负采样。但我觉得第一版代码先跑通不加速版本反而更容易理解。4.3 训练过程中的 Loss 曲线怎么看初学的人最容易犯的错是只盯着 loss 数字。CBOW 的初始 loss 是有参考值的如果词表大小是 V随机初始化模型时每个词被选中的概率接近 1/V交叉熵损失就接近 log(V)。比如词表是 10000初始 loss 大概在 9.21 左右。如果第一次打印的 loss 距离这个值差很远说明数据 pipeline 或模型初始化有问题不是“模型聪明”。训练开始后loss 会在第一个 epoch 快速下降然后进入缓慢下降阶段。如果 loss 在后期反复震荡可能学习率太高或者 batch_size 太小。如果 loss 降不下去可能学习率太低、embed_dim 太小、语料噪声太大。我这里说一句经验在小语料上CBOW 训练 3 到 5 轮就足够再往后 loss 可能还会继续降但词向量的质量和泛化能力反而可能变差。所以不要以“loss 越低越好”为标准最终标准是测试阶段的表现。5. 模型测试与词向量评估5.1 相似词查询最直接的输出验证训练结束后先做最简单的事输入一个词找出向量距离最近的 10 个词。核心代码是计算余弦相似度import numpy as np def get_vector(word): idx word2id.get(word, word2id.get(UNK)) return model.embeddings.weight[idx].detach().numpy() def most_similar(word, topk10): vec get_vector(word) sims [] for w, idx in word2id.items(): if w word: continue other model.embeddings.weight[idx].detach().numpy() sims.append((w, np.dot(vec, other) / (np.linalg.norm(vec) * np.linalg.norm(other)))) return sorted(sims, keylambda x: x[1], reverseTrue)[:topk]如果语料偏向科技领域“苹果”的邻居可能是“梨”“水果”“香蕉”也可能是“手机”“公司”“发布会”这取决于上下文窗口里的词分布。这不是 bug而是语料领域的体现。所以测试时一定要结合自己的语料领域来看结果。5.2 类比推理测试词向量经典试金石词向量最经典的评估是类比推理比如“国王 - 男人 女人 ≈ 女王”。对中文来说可以设计“北京 - 中国 日本 ≈ 东京”这类任务。虽然这个测试有一定争议但它能很直观地看出向量空间是否编码了语义方向。实现方式是把两个词向量的差加到第三个词上然后找余弦相似度最高的词def analogy(a, b, c, topk5): va get_vector(a) vb get_vector(b) vc get_vector(c) target vb - va vc sims [] for w, idx in word2id.items(): if w in (a, b, c): continue vw model.embeddings.weight[idx].detach().numpy() sims.append((w, np.dot(target, vw) / (np.linalg.norm(target) * np.linalg.norm(vw)))) return sorted(sims, keylambda x: x[1], reverseTrue)[:topk]常见的坑是分词不一致导致类比失效。比如你训练语料里“自然语言处理”被切成一个词但你测试时写的是“自然语言”和“处理”它们根本不在同一粒度上结果自然不对。类比测试一定要先确认测试词都出现在词表里。5.3 降维可视化看聚类但不能当唯一标准把 100 维向量直接观察是不可能的所以降维可视化很常用。我把一些语义类别的词挑选出来用 PCA 或 t-SNE 降到二维然后画散点图。代码不复杂from sklearn.manifold import TSNE words [苹果, 香蕉, 梨, 汽车, 火车, 飞机, 跑步, 游泳, 读书] vectors np.array([get_vector(w) for w in words]) result TSNE(n_components2, random_state42, perplexity5).fit_transform(vectors)t-SNE 本身有随机性每次跑出来的图都会不一样所以要用固定 random_state。可视化只能帮你快速发现“语义是不是成簇”不能做严格量化。我通常只看三件事同类词是否聚在一起、不同类词是否分开、有没有明显的异常点。如果有异常我会回到数据里查这个词的上下文问题往往出在分词或窗口切分上。5.4 下游任务验证词向量的最终验收相似词和类比推理都是内部指标最后还要放到真实任务里看收益。我常用的做法是拿一个文本分类任务比如情感二分类用两种方案对比。方案 A 用随机初始化 embedding 训练方案 B 用 CBOW 训练好的 embedding 初始化并按需求决定冻结还是微调。如果 CBOW 向量带来明显提升说明预训练有价值。要注意的是如果下游模型过于复杂比如深层 Transformer预训练词向量的增益可能被模型自身消化掉看不出差异。所以我建议用一个相对简单的 CNN 或浅层分类器来对比或者干脆冻结向量只训练分类层。这个对比实验不算复杂但比任何可视化都更有说服力。6. 常见问题与排查实录6.1 训练 Loss 为 NaN 或完全不收敛遇到 NaN先查三件事。第一学习率是不是太高Adam 的默认 0.001 在大部分 NLP 任务里还行但小批量数据上偶尔也需要降到 0.0005。第二训练数据里有没有空样本比如某条上下文全是过滤词后就变成一个空列表送入模型后 mean 会得到 NaN。第三词表索引有没有越界target 里混进了 OOV 词的旧 ID 会导致 embedding 查不到。最快的定位办法是在训练循环里打印每个 batch 的 loss一旦出现 NaN 就看是哪个样本造成的。不收敛但不是 NaN 的情况我遇到最多的是“初始 loss 远高于理论值”这通常说明标签和输入错位了。比如 DataLoader 返回的 context 和 target 不是同一批或者上下文窗口切错位置模型等于在乱猜训练半天也学不到规律。6.2 词向量结果“感觉不对”怎么办这是新手最常见的困惑。分几种情况处理。如果相似词全是“的”“了”“是”这类高频虚词说明高频词稀释了低频词的更新。解决方案是在预处理时对高频词做降采样或者在训练时用 gensim 的 sample 参数过滤一部分超高频词。如果“苹果”的相似词全是完全不相关词先确认是不是语料太小。小语料上词向量的统计意义不足几千个句子训出来的向量基本不可靠至少应该准备几十万行文本再看效果。如果你已经用了较大语料但结果还是差可以调大 window 或 vector_size多试几组超参。如果相似词在领域内合理但下游任务效果不好那可能是任务本身需要更丰富的语境信息。静态词向量对一词多义无能为力比如“苹果”可以是水果也可以是公司。这时候不要硬调 CBOW该换用上下文相关的动态词向量模型了。6.3 训练速度太慢内存占用太大手写 PyTorch CBOW 遇到十万级词表时全 Softmax 会非常慢。代码层面可以先做两件事一是把字符串到 ID 的映射全部放到训练前完成不要在训练循环里做任何字典查询二是把 batch_size 调大充分利用 GPU 并行。如果仍然慢就该上负采样输出层不计全词表概率只计算正样本和若干负样本的损失。如果只是想工程化地得到词向量真的建议直接用 gensim。它在 C 层面做了大量优化同样的语料可能比手写的 PyTorch 版本快一个数量级。手写是为了理解原理不是为了替代成熟工具这个边界要分清。6.4 gensim 与 PyTorch 手写实现怎么选我把这两个方案放在一起做一个对照方便你做决定对比项gensim 快速实现PyTorch 手写实现训练速度快C 实现慢纯 Python 端训练灵活性固定模型结构可随意改结构和损失函数负采样封装完善参数调节简单需要自己实现采样逻辑适合场景实验、工程化快速产出词表理解原理、嵌入自定义模型调试成本较低较高但可控我在实际项目中通常这样分工需要快速验证想法时用 gensim一行代码完成训练需要把词向量作为某个深度学习系统的一部分时用 PyTorch 手写再接后续网络。两者不冲突甚至可以在同一个项目里先用 gensim 做原型再用 PyTorch 复现。7. 一点实操心得与扩展思路7.1 先跑小规模语料再上全量这个习惯救了我很多次。正式训练前我会先从全量语料里随机抽 10 万行文本用同样的管道跑一遍 CBOW。看三个指标loss 是否正常下降相似词是否符合直觉训练时间是否可接受。小语料跑完没问题再上全量。全量训练一旦发现错误返工成本是很高的尤其是等你跑了十几个小时之后才发现数据管道有 bug那体验相当糟糕。小规模验证还有一个好处可以快速做超参对比。我会在 10 万行语料上试几种 window、vector_size 组合挑出最稳定的那组再放到全量上。虽然理论上最优超参在不同规模下不完全一致但至少能排除掉明显不合适的区间。7.2 从 CBOW 往后还能走多远CBOW 学到的词向量是静态的一个词永远只有一个向量这解决不了一词多义。后续很多工作都在这个方向上演进比如用语言模型给每个词按上下文生成动态表示也就是现在大家常用的预训练模型路线。但我觉得 CBOW 依然值得掌握因为它是理解词嵌入思想最干净的起点。理解了它的训练目标、负采样、窗口建模再看更复杂的模型会轻松很多。我最后再分享一个小技巧保存词向量的时候除了向量本身一定要把 word2id、id2word、训练参数一起存下来。很多时候不是模型不好而是下次加载时 index 对不上导致测试结果全部跑偏。把这些元数据存成一个 JSON 文件只占几 KB却能省掉后面一整天的排查时间。