打开导航 打开导航 Open menu
量化交易系统开发(C++)/ 期权策略与风控 / 行业研究分析
量化交易系统開發(C++)/ 期权策略与风控 / 行业研究分析
C++ trading systems / options strategy & risk controls / equity research
adrian@adrianxv.cn

撰写简单的 Word2vec 的 CBOW 和 Skip-gram 实现 撰写简单的 Word2vec 的 CBOW 和 Skip-gram 实现 撰写简单的 Word2vec 的 CBOW 和 Skip-gram 实现

这篇文章整理自自然语言处理课程实验报告,目标是用一个很小的英文语料实现 Word2vec 的两种经典架构:CBOW(Continuous Bag-of-Words)和 Skip-gram。实现刻意保持简单,只使用 PyTorch 的 EmbeddingLinear、交叉熵损失和 Adam 优化器,让每一步都能和模型原理对应起来。

一、实验目的

  1. 理解词向量表示的基本概念,掌握把离散词语映射为连续向量的基本思想。
  2. 理解 Word2vec 的分布式表示假设:上下文相似的词,通常具有相似的语义或语法功能。
  3. 掌握 CBOW 根据上下文词预测中心词的建模流程。
  4. 掌握 Skip-gram 根据中心词预测上下文词的建模流程。
  5. 能够使用 Python 完成语料预处理、词表构建、训练样本生成、模型训练和词向量输出。
  6. 能够比较 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 后,要在词表中预测它的上下文词 ideep。如果两个中心词长期预测出相似的上下文,它们的嵌入就会被优化到相近的位置;反之,出现在完全不同语境中的词,其向量不会靠近。因此,预测上下文的能力也就编码了词的使用环境。

本实验的脚本路径为 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 则为一个中心词构造多个目标上下文,往往更擅长学习低频词的表示。这里的结论是经验性的,实际表现仍取决于语料规模、窗口大小、采样策略和训练轮数。

六、两种实现共享的训练流程

虽然训练目标不同,但两个脚本的工程流程基本一致:

  1. 将句子分词,得到 token 序列。
  2. 对所有 token 去重并排序,构建 vocab
  3. word2index 把词转换为整数,用 index2word 在需要时还原。
  4. 根据窗口大小构造训练样本。
  5. 用 embedding 层查表,用线性层输出词表分数。
  6. 用交叉熵计算损失,反向传播后由 Adam 更新参数。
  7. 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 对比

对比维度CBOWSkip-gram
输入一个窗口内的多个上下文词一个中心词
预测目标中心词上下文词
样本数量每个中心位置通常一个样本一个中心位置可产生多个样本
上下文处理对上下文 embedding 求平均(本实验实现)分别预测每个上下文词
训练特点计算集中、训练速度通常较快目标更多,低频词表示通常更充分
适合场景高频词较多、希望快速训练语料较大、关注细粒度词语关系

从概率角度看,CBOW 学习的是 P(center | context),Skip-gram 学习的是 P(context | center)。两者最终都把词映射到同一个向量空间,但优化方向不同,因此即使使用同一份语料,得到的向量数值和几何结构也不会完全相同。

总结

这个最小实验把 Word2vec 的核心链路完整串了起来:从分词和词表开始,把词转换成索引;再根据滑动窗口生成 CBOW 或 Skip-gram 样本;最后通过 embedding、线性分类器和交叉熵损失学习稠密词表示。

CBOW 用上下文预测中心词,信息汇总得更快;Skip-gram 用中心词预测多个上下文,更直接地刻画一个词的使用环境。理解这两个方向之后,再学习负采样、子采样、批量训练以及 gensim 等成熟实现,会更容易看清优化技巧究竟是在改进哪一个环节。