news 2026/10/16 14:31:04

从零实现CBOW与Skip-gram:词嵌入实验全流程解析

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
从零实现CBOW与Skip-gram:词嵌入实验全流程解析

深度学习实验做到“词嵌入模型”这一站,如果你正卡在“词向量到底是什么”“CBOW 和 Skip-gram 怎么调才不翻车”这个位置,那这篇文章值得你从头到尾看完。我最近完整跑了一套课程实验项目“深度学习 实验六 词嵌入模型”,内容包括中文语料预处理、从零实现 CBOW 与 Skip-gram 两套模型、负采样训练、词向量相似度验证与可视化。做完之后最大的感受是:这个实验不只是在教你“调用一个 Word2Vec”,而是在强迫你把向量空间、上下文窗口、采样策略这些东西用代码亲手过一遍,很多理论课上含糊的地方,一写代码就全清楚了。

这套实验能拿下的核心技能,我总结下来有三块:第一,搞清楚词嵌入到底在优化什么问题;第二,会用 PyTorch 从零搭出 CBOW 和 Skip-gram,并理解负采样为什么能大幅降低计算量;第三,知道怎么用相似度计算、类比推理和可视化去评估训练出来的词向量质量,而不是看一眼 loss 下降就觉得“完事了”。

下面我按照自己实际的操作顺序展开,从实验拆解、工具选型,到数据准备、模型实现、效果评估和问题排查,全部记录下来。如果你也在做同类的实验课或者自学项目,可以直接照着这套流程走。

1. 实验目标与整体思路

1.1 这个实验到底在解决什么问题

在自然语言处理早期,文本进模型之前要先变成数字。最朴素的做法是 one-hot 编码:建立一个词表,每个词用一个只有一位是 1、其余全是 0 的向量表示。这种表示有两个致命缺点,一是维度随词表膨胀,十万个词就是十万维,计算和存储都扛不住;二是任意两个 one-hot 向量的内积都是 0,也就是说“苹果”和“香蕉”之间没有任何相似度,模型完全学不到词语之间的语义关联。

词嵌入要解决的就是这两个问题。它把每个词映射到一个低维稠密向量,比如 128 维、256 维,通过训练让语义相近的词在向量空间里距离更近。这个“训练”不是凭空来的,而是基于一个非常朴素的假设:一个词的含义由其上下文决定,也就是语言学里常说的“You shall know a word by the company it keeps”。Word2Vec 的两个经典模型 CBOW 和 Skip-gram 都是在这个假设下设计的,只是方向不同:CBOW 用上下文预测中心词,Skip-gram 用中心词预测上下文。

理解这一步是整个实验的地基。很多同学直接抄代码训练,最后虽然拿到了词向量,但遇到“为什么窗口要取 5”“为什么负采样要设 15”这类问题就答不上来,这就是没把优化目标吃透。所以我会在后面的章节里反复把“这个参数改的是哪个环节”说清楚。

1.2 实验拆解成哪几个阶段

我在实际操作中把这个实验分成了四个阶段,每个阶段都有明确的输入输出,这样不容易乱:

  • 数据准备:拿到一段规模适中的语料,做清洗、分词、去噪,构建词表并完成索引映射,最后生成训练样本对。
  • 模型搭建:分别实现 CBOW 和 Skip-gram,先做带负采样的版本,后续可以对比朴素 softmax 版本的效果差异。
  • 训练与调参:设定词向量维度、窗口大小、采样数量、学习率等关键超参,观察 loss 曲线和收敛情况。
  • 评估与应用:用词相似度、类比关系、PCA/t-SNE 可视化,以及下游分类任务来检验词向量的质量。

这个划分方式对新手尤其友好。我见过有人在数据还没清洗干净时就直接开训,结果 loss 怎么调都不降,最后发现是语料里存在大量空格和乱码,分词都分碎了。分阶段推进,至少能让你在出问题时快速定位到具体环节,而不是一头扎进模型代码里找 bug。

2. 技术方案与工具选型

2.1 常见词嵌入方案怎么选

做词嵌入实验,可选的方案其实不少。我先把常见的几个放在一起对比,方便你根据实验要求做取舍:

方案核心思路优点缺点适用场景
CBOW上下文预测中心词训练速度快,低频词也有较好表示对每个词只做一次平均,语义细节略粗糙语料较大、需要快速迭代
Skip-gram中心词预测上下文对低频词和语义细节更敏感训练速度慢,样本量大语料较小或对词义精细度有要求
GloVe基于共现矩阵分解能较好捕捉全局统计信息需要构建并处理大矩阵希望结合全局共现信息
FastText子词粒度训练可以处理未登录词模型更复杂,训练时间更长语料含较多形态变化或新词

我们实验要求从零实现,所以重点放在 CBOW 和 Skip-gram 上。其他方案可以作为扩展对比,但不要一开始就铺开,否则很容易陷入“调参地狱”。我当时是先用 CBOW 跑通全流程,再切到 Skip-gram 做效果对比,这样既控制变量,又能直观感受到两个模型在训练时间和词向量质量上的差异。

2.2 框架与辅助库的选择

深度学习框架这块,我选了 PyTorch。理由很直接:它对初学者更友好,张量操作和自动求导的写法非常接近数学推导,调试时可以一行一行打印中间张量,不需要像部分框架那样先构图再执行。如果你已经选了 TensorFlow,也能做完实验,只是实现负采样的时候写法会绕一点。

辅助库方面,我用到了这些:

  • jieba:中文分词。中文不像英文天然按空格分词,必须先用分词工具把句子切成词序列。
  • pandas / numpy:数据读取和批量处理。
  • matplotlib:训练完成后画 PCA/t-SNE 词向量分布图。
  • collections.Counter:统计词频、构建词表。

还有一个容易被忽略的点:随机数种子。PyTorch 默认的乱序和初始化是随机的,如果不设置种子,两次训练出来的词向量可能差异较大,不利于你对比不同超参的效果。我在代码里固定了三处随机种子:Python 内置 random、numpy 以及 torch 的 CPU/GPU 相关随机源。

2.3 数据集与超参数设定

数据集我选择了一份中等规模的中文百科类语料,大概有几十万条文本,清洗后总字数超过一千万。这个规模对课程实验来说足够用了,训练时间也控制在十几分钟到半小时内。

实验关键超参的初始设定如下:

  • 词向量维度 embedding_dim:128
  • 上下文窗口 window:5(前后各 2 个词)
  • 最小词频 min_count:5
  • 负采样数量 negative:15
  • 训练轮数 epochs:5
  • 学习率 learning_rate:0.025
  • 批量大小 batch_size:512

这里要解释一下为什么这样设。embedding_dim 取 128 是因为在千万级语料上,128 维已经足以表达大部分语义关系,继续增大到 300 收益有限,但训练时间会涨不少。window=5 兼顾了短距离语法关系和中等距离的语义关系。min_count=5 用来过滤掉只出现一两次的噪声词,这些词学不出可靠向量还会拖慢训练。负采样数量默认 15,是论文里验证过效果较好的取值区间。

3. 数据处理与语料准备

3.1 文本清洗与分词

数据处理是整个实验里最“脏活累活”的部分,但做不好后面全部白搭。我按这几个步骤清洗文本:

  1. 去除非中文字符。中文语料里经常混杂英文字母、数字、标点和 emoji,这些对训练静默词向量没有帮助,我会统一用正则表达式去掉,只保留中文、常用标点中的句号逗号等。
  2. 去除过短文本。比如空行和只有一两个字的碎片文本,直接丢弃。
  3. 统一标点。中英文逗号、括号尽量归一化,避免同一个句子因为标点差异被拆成不同的词序列。
  4. 分词。我调用了 jieba 的分词接口,同时加入自定义词典,比如一些专业术语如果不加词典会被切碎。

分词这一步的细节直接决定样本质量。举个例子,“机器学习”如果被切成“机器”“学习”,虽然也能用,但“机器学习”作为整体概念的语义就丢了。遇到这类词时,我会先把领域术语添加进自定义词典,再重新分词。

3.2 词表构建与索引映射

分词完成后,用 Counter 统计每个词的出现次数。构建词表的规则是:只保留频率不低于 min_count 的词,然后给每个词分配一个从 0 开始的唯一 id,同时记录 id 到词的映射关系。

这一步有三个细节容易出错:

  • 边界 id 是否需要预留 0 作为 padding。我的做法是给 padding 单独留一个 id,例如 0 表示 ,正常词从 1 开始编号,这样后面处理变长序列时方便对齐。
  • 未登录词的兜底处理。语料里总会出现词表中没有的词,我会用 统一替代。需要注意 本身也占一个 id,加入词表。
  • 词表大小要打印出来确认。我见过有人训练半天,最后发现词表只有几百个词,原因是分词环节正则写错,大部分文本被过滤掉了。

3.3 CBOW 与 Skip-gram 训练样本生成

不同模型需要不同的训练样本格式,我分开说明。

CBOW 的样本是“上下文词序列”作为输入,“中心词”作为标签。假设“我 爱 深度 学习”这句话,中心词“深度”且窗口为 2,则上下文是“爱”和“学习”,样本就是 ([爱, 学习], 深度)。实际构建时窗口大小会根据句子边界自动截断,句首和句尾的词样本会少一些。

Skip-gram 的样本是“中心词”作为输入,“上下文词”作为标签。同样一句话,中心词“深度”会生成两条样本:(深度, 爱) 和 (深度, 学习)。

这里有一个很实用的技巧:打乱样本顺序。如果不打乱,相邻样本在语义上高度相关,会导致梯度更新方向单一,收敛变慢。PyTorch 的 DataLoader 本身会做 shuffle,但我建议在生成样本时先随机打乱一轮再交付给 DataLoader,效果更稳。

4. 核心代码与实现细节

4.1 CBOW 模型的 PyTorch 实现

CBOW 的结构非常清晰:一个 Embedding 层负责查表得到上下文各词的向量,一个线性层负责将平均池化后的上下文向量映射到词表大小的得分向量。核心代码如下:

import torch import torch.nn as nn class CBOW(nn.Module): def __init__(self, vocab_size, embedding_dim): super(CBOW, self).__init__() self.embedding = nn.Embedding(vocab_size, embedding_dim) self.output = nn.Linear(embedding_dim, vocab_size) def forward(self, context_ids): # context_ids shape: (batch_size, context_size) embeds = self.embedding(context_ids) # 对上下文维度求平均 context_vec = torch.mean(embeds, dim=1) logits = self.output(context_vec) return logits

注意这里我用的是线性层输出后再接损失函数,而不是直接在模型里做 softmax。原因是训练时用交叉熵损失,损失函数内部会做 log_softmax,如果把 softmax 写在模型里反而会数值不稳定且重复计算。

如果词表很大,这个输出线性层的参数量是 embedding_dim 乘 vocab_size,会占很多内存。以 128 维、5 万词为例,光这个输出层就有 640 万参数。所以理论课讲 Word2Vec 时一定会引入负采样,目的就是避免对整个词表做 softmax。

4.2 带负采样的 Skip-gram 实现

Skip-gram 带负采样可以拆成三组嵌入向量:中心词嵌入、上下文正向词嵌入、上下文负向词嵌入。在实际训练中,中心词和上下文词可以共用同一个 Embedding 权重,也可以各自独立。共享权重能减少参数量,独立权重在部分任务上效果稍好。我实验里采用独立权重,方便观察对比。

下面是一个可以直接跑的简化实现:

class SkipGramNeg(nn.Module): def __init__(self, vocab_size, embedding_dim): super(SkipGramNeg, self).__init__() self.center_embedding = nn.Embedding(vocab_size, embedding_dim) self.context_embedding = nn.Embedding(vocab_size, embedding_dim) def forward(self, center, positive, negative): # center: (batch_size,) # positive: (batch_size,) # negative: (batch_size, num_neg) center_vec = self.center_embedding(center) # (batch_size, embed_dim) pos_vec = self.context_embedding(positive) # (batch_size, embed_dim) neg_vec = self.context_embedding(negative) # (batch_size, num_neg, embed_dim) pos_score = torch.sum(center_vec * pos_vec, dim=1) # (batch_size,) neg_score = torch.bmm(neg_vec, center_vec.unsqueeze(2)).squeeze(2) # (batch_size, num_neg) loss = -torch.mean( torch.log(torch.sigmoid(pos_score) + 1e-8) + torch.sum(torch.log(torch.sigmoid(-neg_score) + 1e-8), dim=1) ) return loss

代码里几个关键点我解释一下:

  • center_vec 和 pos_vec 做逐元素乘再加和,得到正向样本的匹配分数,越大越好。
  • BMM 计算的是每个负样本和中心词的匹配分数,我们希望这个分数越小越好,所以损失里用 sigmoid(-neg_score)。
  • 1e-8 是防止 log 0 时出现的 NaN,尤其是在训练后期 sigmoid 结果可能非常接近 0。

负采样的候选词怎么选?按词频的 3/4 次方加权采样。这是因为纯按词频采样会让“的”“了”这类高频词被采成负样本的次数过多,削弱训练信号。取 3/4 次方是一种常见做法,可以有效平衡高频词和低频词被采样的概率。

除了吞吐量,我还注意到一个容易出错的地方:负采样的样本不能和正样本重复。如果同一个词既出现在 positive 里又出现在 negative 里,损失计算会互相矛盾。我在生成负样本时做了一个集合过滤,确保当前 batch 内不碰撞。

4.3 训练循环与优化器选择

训练循环本身并不复杂,常规结构是:加载 batch -> 前向计算 -> 计算损失 -> 反向传播 -> 更新参数。我额外做了三件对训练有帮助的事:

  1. 学习率预热。前 200 步将学习率从 0 线性升到 0.025,后面再按需衰减。这样做能避免训练初期梯度方向剧烈跳变。
  2. 梯度裁剪。把全局梯度范数裁剪到 1.0,防止个别异常样本把参数冲出最佳区域。
  3. 周期性打印 loss。我每隔 100 个 batch 打印一次平均损失,用来观察收敛趋势。
optimizer = torch.optim.SGD(model.parameters(), lr=0.025) scheduler = torch.optim.lr_scheduler.StepLR(optimizer, step_size=2, gamma=0.5) for epoch in range(epochs): for center, positive, negative in train_dataloader: optimizer.zero_grad() loss = model(center, positive, negative) loss.backward() nn.utils.clip_grad_norm_(model.parameters(), max_norm=1.0) optimizer.step() scheduler.step()

这里我坚持用 SGD 而不是 Adam,因为 Word2Vec 这类模型本身对优化器不敏感,SGD 配合适当学习率已经完全够用,而且逻辑上更贴近原始论文。如果你觉得收敛偏慢,也可以换 Adam,但初始学习率要相应调低到 0.001 左右。

4.4 词向量导出与使用

训练完成后,需要明确“该拿哪一层作为词向量”。如果是共享嵌入权重,直接取 center_embedding.weight 即可。如果是独立权重,常见做法是取 center_embedding 和 context_embedding 的平均,或者只取 center_embedding。我通常先测试两种取法的相似度结果,再决定用哪个。

word_vectors = model.center_embedding.weight.detach().numpy()

要注意把权重从计算图中分离出来,不要在这个阶段还挂着 require_grad。导出为 numpy 数组后,可以存成文件,也可以直接交给下游任务使用。

5. 实操过程与结果分析

5.1 训练损失曲线怎么看

我以 Skip-gram 为例记录了训练过程中的 loss 变化。刚开始时 loss 在 6 左右,随后快速下降,到第 2 轮降到 1.8 左右,之后的下降速度开始放缓。这是非常典型的收敛模式:前期语义结构快速成型,后期进入微调阶段。

训练时画损失曲线,我只看几个点:

  • 是不是前期下降太慢。如果前 500 个 batch 损失纹丝不动,大概率是学习率太小或者数据有问题,需要停一下检查样本。
  • 是不是训练到一半突然出现 loss 冲高。这种情况一般是某个 batch 噪声词过多,或者学习率偏大导致梯度爆炸。
  • 最后两轮是否继续平稳下降。如果第 4 轮和第 5 轮完全一样甚至反弹,说明已经收敛,继续训练意义不大。

5.2 相似度与语义类比验证

训练好用词向量做验证是很有成就感的环节。我用的验证方法是,随机抽几组词,计算它们的余弦相似度。

余弦相似度的计算方式:

def cosine_similarity(vec1, vec2): return np.dot(vec1, vec2) / (np.linalg.norm(vec1) * np.linalg.norm(vec2))

我抽到的几个结果如下:

  • “中国” 与 “北京” 的相似度比较高,说明模型学到了地理关联。
  • “数据” 和 “算法” 的相似度也明显高于 “数据” 和 “香蕉”,说明语义相关性被成功编码。
  • 还有一些相对抽象的同义词,比如“美丽”“漂亮”,也能得到不错的相似度。

类比推理是另一个经典测试。我用“国王 - 男人 + 女人 ≈ 女王”这类关系做验证。做法是先取中心词向量做减法再找最近邻,可以看到模型对语义维度有了一定的分解能力,但不是所有关系都能测得完美,一些类比会因为语料偏颇而失败,这很正常。

5.3 词向量可视化观察

直接看 128 维向量太抽象,我用 PCA 降到二维,之后又尝试了 t-SNE 可视化。把语义相近的词放到图上观察,能看到明显的聚类效应:水果类的词聚在一起,运动类的词聚在另一块,科技类的词又单独一团。

做可视化时有几个注意点:

  • 选取高频词但不要只取最高频的那几十个。最高频的词大多是“的”“了”“是”这类功能词,聚类图上看不到有意义的语义结构。
  • 采样的话要覆盖多个主题,最好人工混合一部分动物词、水果词、职业词等,便于观察聚类是否合理。
  • t-SNE 里 perplexity 一般取 5 到 30,太小容易被噪声主导,太大会牺牲局部结构。我实验里取 15,效果比较均匀。

5.4 下游任务效果检验

词向量最终是要给下游任务用的。我加了一个简单的文本分类实验:把句子中的词向量平均,拼接到一起后输入一个逻辑回归分类器,看能否区分几个预设类别。实验结果显示,用训练好的词向量做分类的准确率明显高于随机初始化的词向量,这证明实验训练出的词嵌入确实学到了有效信息。

这个下游任务不是必须的,但强烈建议做一下。原因是它把“词向量好不好”从一个模糊的主观评价变成了可量化的指标,也方便你后面扩展实验时写对比实验。

6. 常见问题与故障排查

6.1 损失值不下降或下降极慢

这个现象我在第一次跑 CBOW 时也遇到过,后来定位到两个主要原因。

第一个原因是数据问题。语料里如果存在大量完全没有清洗掉的空行、半角字符,分词后就会出现大量单字碎片,样本里上下文信息几乎为空,模型自然学不到东西。排查办法是检查词表前 20 个词的分布,如果出现大量“无意义”内容,回数据处理环节重新清洗。

第二个原因是学习率设置不当。SGD 学习率如果低于 0.01,在小语料上会收敛得很慢;而如果学习率高于 0.1,loss 可能会震荡。建议先按 0.025 起跑,观察前几百个 batch 的走势再调整。

6.2 词向量效果差,相似度结果莫名其妙

词向量效果差最常见的原因是窗口设置不合理。窗口太小时,模型只能学到相邻词的共现,关系局限于语法层面;窗口太大时,距离很远的无关词也会被拉近,语义被稀释。另一个高频坑是中停用词没有处理,“的”“了”“在”这类词频繁出现在各种上下文里,会把向量往一堆语义的“中心”挤。

解决办法有两步。第一步,在生成训练样本时过滤掉停用词;第二步,训练完成后把高频功能词的向量单独抽出来看,如果它们和所有词相似度都偏高,说明语料中它们信息量过大,也可以通过增加子采样概率来解决。

6.3 内存溢出或训练速度过慢

这个问题通常发生在负采样实现不当或 batch 过大时。我先说是实现上的问题:如果直接用 softmax 对整个词表做计算,几十万词的表会让输出层和损失计算占据大量内存。很多同学第一次写完代码没注意这个,跑起来才发现显存爆掉。

具体解法是:确认损失函数是否走了负采样分支,而不是全词表 softmax;适当调小 batch size;生成训练样本时使用 PyTorch 的 Dataset 加 DataLoader,不要一次性把上亿对样本全部 load 进内存。

还有一个提升训练速度的技巧:负采样数量不用太多。从 5 增加到 15 会带来明显提升,但继续增加到 30 提升不大,速度却会明显变慢。训练时可以把 negative 设小跑一轮实验,后续再调大,避免首次运行就等半天。

6.4 复现结果时向量不一致

如果两次实验用相同超参,得到的词向量和相似度结果却不完全一致,先检查随机种子是否固定。我自己的经验是,即使固定了 Python 和 numpy 的种子,PyTorch 在 CUDA 上的某些操作仍然会有不确定性,所以跨硬件平台无法保证完全一致的复现结果。但至少在同一个环境里,固定随机种子后主要指标应该保持一致。

还有一个容易被忽略的坑:词表构建顺序不固定。如果构建词表时用集合迭代,而集合顺序是随机的,会导致 id 不固定。解决方法是构建词表时对词按频率和字典序排序,保证每次生成的 id 映射相同。

7. 我把这次实验踩过的坑做个补充

最后再分享几个除了“报错”之外的隐性坑,这些不太容易在文档里看到。

第一个是关于中心词和上下文词的权重使用。很多初学者训练完只拿 center_embedding 去做下游任务,但实验里如果用独立权重,最后只用一半参数其实是有点浪费的。我后来分别测了 center 权重、context 权重、两者平均三种取法,平均取法的相似度结果通常更稳一点。你可以自己在验证集上跑一跑,选出最合理的导出方式。

第二个是样本数量估算。以千万级语料加窗口 5 为例,Skip-gram 生成的训练样本数量会达到亿级,一开始我也觉得是不是生成错了。其实这是正常现象,因为一个词在窗口内可以产生多个正样本。面对这个数据量,不要硬拼内存,老老实实用数据流分批处理,训练效率会高很多。

第三个是负采样权重。很多人直接照抄“按词频的 3/4 次方”这个结论,但没意识到这个权重是建立在原始词频分布上的。如果你用的是已经过滤了低频词的子集,需要从过滤后的词频重新计算采样概率,否则那些恰好过线的低频词采样概率会变得不平衡。

第四个是中文和英文的差异。英文按空格分词,中文需要额外做分词,分词器的好坏直接决定了词表质量和训练效果。我试过不同的分词策略,发现保持术语完整性和过滤低质量分词结果这两件事,比调模型的 embedding_dim 还影响最终效果。所以建议你在预处理上多花点时间,别把精力全放在模型本身。

我个人在做完这套实验后的体会是:词嵌入模型看起来简单,但把它真正跑通、观察训练细节、再解释结果背后的原理,远比“调包完成”要费功夫。如果后续你还有余力,可以把模型从 Word2Vec 扩展到 FastText,或者把训练好的词向量接进文本分类、情感分析这类任务,这些扩展会让整条学习链路更完整。词嵌入作为深度学习和自然语言处理之间的桥梁,值得你花一个下午把代码里的每一行都弄明白。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/16 14:27:55

华为华三思科锐捷四厂商网络设备命令对照速查手册

简介:一份面向网络管理员和初学者的多厂商命令对照速查手册,聚焦华为、华三、思科、锐捷四大主流设备的常用配置场景。资源提供单个PDF文件,大小约107KB,共1个文件,内容按功能模块编排,轻量便携&#xff0c…

作者头像 李华
网站建设 2026/10/16 14:23:53

智诺方AI|期刊论文研究方法,降AIGC正确思路

同学最常问的「研究方法」问题 我坚持一点:智诺方AI是定点手术刀,不是整篇代写按钮。 研究方法章节最怕「教科书式复述」。方法定义写得很全,却看不出为何适配本课题,模板感强,AIGC 与导师质疑会一起出现。 常见问题速…

作者头像 李华
网站建设 2026/10/16 14:16:26

Apache Atlas 2.3.0实战:元数据治理与血缘关系构建

简介:面向 Hadoop 生态元数据治理场景,Apache Atlas 2.3.0 二进制发行包用于构建企业级数据资产分类、血缘追踪与合规审计体系。该版本整合 Hive、HDFS、Kafka 等组件插件,支持基于 RBAC/ABAC 的安全管控,适合大数据平台运维与数据…

作者头像 李华