撰写简单的 Word2vec 的 CBOW 和 Skip-gram 实现 撰写简单的 Word2vec 的 CBOW 和 Skip-gram 实现 撰写简单的 Word2vec 的 CBOW 和 Skip-gram 实现
这篇文章整理自自然语言处理课程实验报告,目标是用一个很小的英文语料实现 Word2vec 的两种经典架构:CBOW(Continuous Bag-of-Words)和 Skip-gram。实现刻意保持简单,只使用 PyTorch 的 Embedding、Linear、交叉熵损失和 Adam 优化器,让每一步都能和模型原理对应起来。
一、实验目的
- 理解词向量表示的基本概念,掌握把离散词语映射为连续向量的基本思想。
- 理解 Word2vec 的分布式表示假设:上下文相似的词,通常具有相似的语义或语法功能。
- 掌握 CBOW 根据上下文词预测中心词的建模流程。
- 掌握 Skip-gram 根据中心词预测上下文词的建模流程。
- 能够使用 Python 完成语料预处理、词表构建、训练样本生成、模型训练和词向量输出。
- 能够比较 CBOW 与 Skip-gram 在模型结构、训练目标、适用场景和表示效果上的差异。
二、实验内容与要求
实验使用一个小规模的英文语料,完成以下任务:
- 学习 Word2vec 的基本思想和词向量表示方式;
- 了解 CBOW 与 Skip-gram 的网络结构;
- 完成分词、词表构建和训练样本生成;
- 使用 PyTorch 分别实现两个模型;
- 训练模型并输出词向量,观察词语之间的表示结果。
实验要求是能够独立说明 Word2vec、CBOW 和 Skip-gram 的基本原理,完成文本预处理和训练数据生成,并记录训练过程、比较两种模型的结果。
三、Word2vec 的基本思想
传统的 one-hot 表示为每个词分配一个很长的稀疏向量。词表有多少个词,向量就有多少维,而且不同词的 one-hot 向量彼此正交,无法直接表达“猫”和“狗”比“猫”和“飞机”更相似这样的关系。
Word2vec 用一个可学习的稠密矩阵替代 one-hot 向量。矩阵的每一行对应一个词,训练时通过预测上下文不断调整这些行向量。它依赖的核心假设是分布式假设(distributional hypothesis):如果两个词经常出现在相似的上下文中,那么它们在向量空间中的位置也会接近。
本实验使用的语料是:
i like deep learning
i like nlp
nlp uses deep learning
设窗口大小为 1,每个词只看左右相邻的词。模型的输入是词的整数索引,Embedding 层把索引查表得到五维向量;Linear 层再把这个向量映射到整个词表,输出每个词成为目标词的分数。交叉熵损失会推动正确目标词的分数变高。
四、Skip-gram:由中心词预测上下文
Skip-gram 的训练样本形式是 (center, context)。例如句子 i like deep learning 在窗口为 1 时会产生:
(i, like)
(like, i)
(like, deep)
(deep, like)
(deep, learning)
(learning, deep)
模型看到中心词 like 后,要在词表中预测它的上下文词 i 或 deep。如果两个中心词长期预测出相似的上下文,它们的嵌入就会被优化到相近的位置;反之,出现在完全不同语境中的词,其向量不会靠近。因此,预测上下文的能力也就编码了词的使用环境。
本实验的脚本路径为 src/word2vec/01_skip_gram.py,完整实现如下:
import torch
import torch.nn as nn
import torch.optim as optim
# 准备训练数据
sentences = [
"i like deep learning",
"i like nlp",
"nlp uses deep learning"
]
# 分词
tokens = [sentence.split() for sentence in sentences]
# 构建词表和双向映射
all_words = []
for sentence in tokens:
for word in sentence:
all_words.append(word)
vocab = sorted(set(all_words))
word2index = {word: index for index, word in enumerate(vocab)}
index2word = {index: word for index, word in enumerate(vocab)}
# 构造 Skip-gram 训练数据
window_size = 1
pairs = []
for sentence in tokens:
ids = [word2index[word] for word in sentence]
for index, center in enumerate(ids):
for j in range(max(0, index - window_size),
min(len(ids), index + window_size + 1)):
if index != j:
pairs.append((center, ids[j]))
class Word2Vec(nn.Module):
def __init__(self, vocab_size, embedding_dimension):
super().__init__()
self.embedding = nn.Embedding(vocab_size, embedding_dimension)
self.linear = nn.Linear(embedding_dimension, vocab_size)
def forward(self, x):
embedding = self.embedding(x)
return self.linear(embedding)
model = Word2Vec(vocab_size=len(vocab), embedding_dimension=5)
loss_fn = nn.CrossEntropyLoss()
optimizer = optim.Adam(model.parameters(), lr=0.01)
for epoch in range(200):
total_loss = 0
for center, context in pairs:
x = torch.tensor([center])
y = torch.tensor([context])
pred = model(x)
loss = loss_fn(pred, y)
optimizer.zero_grad()
loss.backward()
optimizer.step()
total_loss += loss.item()
# 查看训练好的词向量
word = "nlp"
vec = model.embedding.weight[word2index[word]]
print(word, vec.detach())
这里的 embedding.weight 就是词向量表。训练完成后取出 nlp 对应的那一行,即可得到这个词的稠密向量。示例实现每次只处理一个 (center, context) 样本,便于理解;实际项目通常会使用批量训练、负采样或层次 softmax 来提高效率。
五、CBOW:由上下文预测中心词
CBOW 和 Skip-gram 的方向相反。它的训练样本形式是 (context, center):给定中心词两侧的上下文,预测被遮住的中心词。
例如窗口大小为 1 时,like 的上下文是 [i, deep],模型需要预测中心词 like。因为上下文可能包含多个词,代码先查表得到多个 embedding,再沿词的维度求平均,得到一个上下文向量,最后交给线性层预测中心词。
本实验的脚本路径为 src/word2vec/02_cbow.py,完整实现如下:
import torch
import torch.nn as nn
import torch.optim as optim
sentences = [
"i like deep learning",
"i like nlp",
"nlp uses deep learning"
]
# 分词并构建词表
tokens = [sentence.split() for sentence in sentences]
words = []
for sentence in tokens:
for word in sentence:
words.append(word)
vocab = sorted(set(words))
word2index = {word: index for index, word in enumerate(vocab)}
index2word = {index: word for word, index in word2index.items()}
window_size = 1
pairs = []
for sentence in tokens:
ids = [word2index[word] for word in sentence]
for index, center in enumerate(ids):
context = []
for j in range(max(0, index - window_size),
min(len(ids), index + window_size + 1)):
if index != j:
context.append(ids[j])
if context:
pairs.append((context, center))
class CBOW(nn.Module):
def __init__(self, vocab_size, embedding_dimension):
super().__init__()
self.embedding = nn.Embedding(
num_embeddings=vocab_size,
embedding_dim=embedding_dimension
)
self.linear = nn.Linear(
in_features=embedding_dimension,
out_features=vocab_size
)
def forward(self, x):
embedding = self.embedding(x)
# 多个上下文词的向量取平均,合成上下文表示
embedding = embedding.mean(dim=0)
score = self.linear(embedding)
# 增加 batch 维度,方便 CrossEntropyLoss 使用
return score.unsqueeze(0)
model = CBOW(len(vocab), embedding_dimension=5)
loss_fn = nn.CrossEntropyLoss()
optimizer = optim.Adam(model.parameters(), lr=0.01)
for epoch in range(200):
total_loss = 0
for context, center in pairs:
x = torch.tensor(context)
y = torch.tensor([center])
pred = model(x)
loss = loss_fn(pred, y)
optimizer.zero_grad()
loss.backward()
optimizer.step()
total_loss += loss.item()
word = "nlp"
vec = model.embedding.weight[word2index[word]]
print(word, vec.detach())
CBOW 先把上下文词压缩成一个平均向量,再做一次多分类预测。它的计算路径比逐个预测上下文更集中,通常在大规模、高频词语料上训练较快;Skip-gram 则为一个中心词构造多个目标上下文,往往更擅长学习低频词的表示。这里的结论是经验性的,实际表现仍取决于语料规模、窗口大小、采样策略和训练轮数。
六、两种实现共享的训练流程
虽然训练目标不同,但两个脚本的工程流程基本一致:
- 将句子分词,得到 token 序列。
- 对所有 token 去重并排序,构建
vocab。 - 用
word2index把词转换为整数,用index2word在需要时还原。 - 根据窗口大小构造训练样本。
- 用 embedding 层查表,用线性层输出词表分数。
- 用交叉熵计算损失,反向传播后由 Adam 更新参数。
- 从
embedding.weight中取出目标词的向量,观察训练结果。
需要注意的是,词向量本身不是模型直接输出的概率,而是 embedding 矩阵里的参数。预测任务只是训练它们的手段;当预测上下文或中心词的损失下降时,这些参数逐渐包含了语料中的统计关系。
七、运行结果
在本机环境上,两个脚本都能输出 nlp 的五维稠密向量。由于初始化和优化过程包含随机性,具体数值每次运行可能不同,报告中的一次结果是:
Skip-gram
nlp tensor([ 0.9330, 0.4051, -1.6318, 0.8830, 0.8898])
CBOW
nlp tensor([-3.1629, -1.4608, -1.9887, -2.6762, 1.7669])
两个模型都已经学习到词语的稠密表示,但语料非常小、训练样本有限,单个向量的绝对数值没有独立的语义。真正有意义的是向量之间的相对关系,例如用余弦相似度比较词语,或在更大语料上观察同义词、相似语法角色是否聚集。
八、CBOW 与 Skip-gram 对比
| 对比维度 | CBOW | Skip-gram |
|---|---|---|
| 输入 | 一个窗口内的多个上下文词 | 一个中心词 |
| 预测目标 | 中心词 | 上下文词 |
| 样本数量 | 每个中心位置通常一个样本 | 一个中心位置可产生多个样本 |
| 上下文处理 | 对上下文 embedding 求平均(本实验实现) | 分别预测每个上下文词 |
| 训练特点 | 计算集中、训练速度通常较快 | 目标更多,低频词表示通常更充分 |
| 适合场景 | 高频词较多、希望快速训练 | 语料较大、关注细粒度词语关系 |
从概率角度看,CBOW 学习的是 P(center | context),Skip-gram 学习的是 P(context | center)。两者最终都把词映射到同一个向量空间,但优化方向不同,因此即使使用同一份语料,得到的向量数值和几何结构也不会完全相同。
总结
这个最小实验把 Word2vec 的核心链路完整串了起来:从分词和词表开始,把词转换成索引;再根据滑动窗口生成 CBOW 或 Skip-gram 样本;最后通过 embedding、线性分类器和交叉熵损失学习稠密词表示。
CBOW 用上下文预测中心词,信息汇总得更快;Skip-gram 用中心词预测多个上下文,更直接地刻画一个词的使用环境。理解这两个方向之后,再学习负采样、子采样、批量训练以及 gensim 等成熟实现,会更容易看清优化技巧究竟是在改进哪一个环节。