一、任务概述与整体流程
本篇文章基于 PyTorch 从零实现 CBOW(Continuous Bag-of-Words,连续词袋)词向量模型。CBOW 的核心思想是:利用上下文单词来预测中心词。例如在句子 "People create programs to direct" 中,给定上下文单词 People、create、to、direct,模型需要预测中心词 programs。
在深入代码之前,先理解 CBOW 背后的核心原理:
- 分布式表示(Distributed Representation):与传统 One-Hot 编码不同,词向量将每个单词映射为低维稠密向量,语义相近的单词在向量空间中距离更近。例如 "king" 和 "queen" 的向量会落在相近区域。
- 词袋模型(Bag-of-Words):CBOW 不考虑上下文中单词的顺序,只把上下文单词的向量求和(或取平均)作为整体表示。这是"词袋"名称的由来------把单词装进一个袋子里,顺序无关紧要。
- 预测任务驱动学习:模型通过"用上下文预测中心词"这个任务,反向传播误差,不断调整词向量。训练完成后,Embedding 层的权重就是最终产物------每个单词的稠密向量。
- 为什么有效:因为语义相近的单词经常出现在相似的上下文中,模型为了准确预测,会让这些单词的向量逐渐趋近,从而捕捉到语义和语法特征。
整个实现流程分为以下六个阶段:
- 数据预处理:对原始语料进行分词、去重,构建词表与单词编号映射。
- 构造训练数据集:按照上下文窗口滑动,生成(上下文单词列表,目标中心词)样本对。
- 定义 CBOW 神经网络:包含词嵌入层、全连接层和输出层。
- 实例化模型与配置优化器:设置 Adam 优化器和 NLLLoss 损失函数。
- 训练循环:前向传播、反向传播、参数更新,并记录损失。
- 测试与词向量保存:用新上下文预测中心词,导出词向量矩阵并保存为 npz 文件。
下面按照代码顺序,逐段讲解每一部分的原理与实现细节。
二、导入依赖库
首先导入实现 CBOW 模型所需的全部依赖库:
python
import torch
import torch.nn as nn # 神经网络模块
import torch.nn.functional as F # 激活函数、softmax 等函数式接口
import torch.optim as optim # 优化器(Adam、SGD 等)
from tqdm import tqdm, trange # 训练进度条显示
import numpy as np # 数值计算与数组操作
各库的作用说明:
- torch:PyTorch 核心库,提供张量(Tensor)运算和自动求导机制。张量是 PyTorch 的基本数据结构,类似 NumPy 数组,但支持 GPU 加速和自动求导。
- torch.nn:神经网络模块,包含 Embedding、Linear、NLLLoss 等常用组件。所有网络层都继承自 nn.Module 基类。
- torch.nn.functional:函数式接口,提供 relu、log_softmax 等无需实例化的函数。与 nn 模块的区别在于,functional 里的函数不保存参数,直接调用。
- torch.optim:优化器模块,这里使用 Adam 优化器更新模型参数。优化器负责根据梯度调整模型权重,是训练的核心组件。
- tqdm:显示训练进度条,方便观察训练进度。在长训练循环中,进度条能直观反映当前迭代位置和剩余时间。
- numpy:用于数组操作,训练结束后将词向量张量转为 numpy 数组保存。NumPy 是 Python 科学计算的基础库,支持多维数组和高效矩阵运算。
三、数据预处理:构建词表与编号映射
数据预处理是整个 CBOW 流程的第一步,目标是把原始文本转换为模型可以处理的数字编号。这一步的核心原理是:神经网络无法直接处理字符串,必须把每个单词映射为一个唯一的整数编号,再通过 Embedding 层把编号转换为向量。
python
CONTEXT_SIZE = 2 # 上下文窗口大小:中心词左右各取 2 个单词
raw_text = """We are about to study the idea of a computational process.
Computational processes are abstract beings that inhabit computers.
As they evolve, processes manipulate other abstract things called data.
The evolution of a process is directed by a pattern of rules
called a program. People create programs to direct processes. In effect,
we conjure the spirits of the computer with our spells.""".split()
这里有两个关键点:
- CONTEXT_SIZE = 2:表示中心词左右各取 2 个单词作为上下文,因此每个训练样本的上下文共包含 4 个单词。窗口大小是 CBOW 的超参数,窗口越大,模型看到的上下文越广,但计算量也越大。
- raw_text :原始语料通过
.split()按空白字符分词,得到一个单词列表。注意这里保留了英文标点(如句号),实际项目中通常需要先做标点清洗。分词是 NLP 任务的第一步,中文通常使用 jieba 等分词工具,英文则直接按空格切分。
接下来构建词表:
python
vocab = set(raw_text) # 转集合:自动去重,得到所有不重复单词
vocab_size = len(vocab) # 词表大小,这里为 49
# 构建【单词 → 编号】映射字典
word_to_idx = {word: i for i, word in enumerate(vocab)}
# 构建【编号 → 单词】映射字典
idx_to_word = {i: word for i, word in enumerate(vocab)}
知识点详解:
- set() 去重:Python 集合(set)的元素具有唯一性,将单词列表转为集合后自动去除重复单词,得到词表。这是构建词表最简洁高效的方式。
- vocab_size:词表大小,即语料中不重复单词的总数,这里为 49。这个数值决定了 Embedding 层和输出层的维度。词表越大,模型参数越多,训练越慢。
- word_to_idx:单词到编号的映射字典,用于把文本单词转换为数字编号,是模型输入的"翻译官"。模型只能吃数字,所以所有输入单词都要先查这个字典。
- idx_to_word:编号到单词的反向映射,用于把模型预测的编号还原为单词,方便人工查看结果。训练结束后,我们想知道模型预测的是哪个单词,就需要这个字典。
- enumerate():Python 内置函数,同时取出序号和元素,这里用于为每个单词分配唯一编号。注意:由于 set 是无序的,每次运行编号可能不同,但不影响训练结果。
四、构造训练数据集:上下文预测中心词
CBOW 的训练样本格式为(上下文单词列表,目标中心词)。核心思路是:以每个单词为中心,取其左右各 CONTEXT_SIZE 个单词作为上下文,该中心词作为预测目标。这背后的原理是"分布假说"(Distributional Hypothesis):出现在相似上下文中的单词,其语义也相似。
python
data = [] # 存放训练样本,每个样本格式:(上下文单词列表, 目标中心词)
for i in range(CONTEXT_SIZE, len(raw_text) - CONTEXT_SIZE):
# 拼接左边上下文 + 右边上下文
context = (
[raw_text[i - (2 - j)] for j in range(2)] # 左侧 2 个单词
+ [raw_text[i + j + 1] for j in range(CONTEXT_SIZE)] # 右侧 2 个单词
)
target = raw_text[i] # 中心词
data.append((context, target))
知识点详解:
- 循环范围 :
range(CONTEXT_SIZE, len(raw_text) - CONTEXT_SIZE)从第 2 个单词开始,到倒数第 3 个单词结束。这样保证每个中心词左右都至少有 2 个单词可取,避免越界。这是滑动窗口的标准做法。 - 左侧上下文 :
[raw_text[i - (2 - j)] for j in range(2)]等价于取raw_text[i-2]和raw_text[i-1],即中心词左边紧邻的 2 个单词。列表推导式让代码更简洁。 - 右侧上下文 :
[raw_text[i + j + 1] for j in range(CONTEXT_SIZE)]取raw_text[i+1]和raw_text[i+2],即中心词右边紧邻的 2 个单词。 - 样本格式:每个样本是一个元组 (context, target),context 是包含 4 个单词的列表,target 是 1 个中心词字符串。整个语料会生成 len(raw_text) - 2*CONTEXT_SIZE 个样本。
例如,对于句子 "People create programs to direct",当中心词为 programs 时,上下文为 'People', 'create', 'to', 'direct'。
五、辅助函数:上下文单词列表转编号张量
模型无法直接处理字符串,需要把上下文单词列表转换为数字编号张量。这一步的原理是:通过 word_to_idx 字典查表,把每个单词映射为唯一整数,再包装成 PyTorch 张量,供 Embedding 层使用。
python
def make_context_vector(context, word_to_ix):
"""
参数:
context:上下文单词列表,例如 ['We', 'are', 'to', 'study']
word_to_ix:单词到编号的映射字典
返回:
torch.long 类型张量,存放每个单词对应的数字索引
"""
idxs = [word_to_ix[w] for w in context]
return torch.tensor(idxs, dtype=torch.long)
知识点详解:
- 查表转换:遍历上下文列表中的每个单词,通过 word_to_ix 字典查出对应的数字编号。列表推导式一行完成转换,简洁高效。
- dtype=torch.long:必须使用长整型。PyTorch 的 Embedding 层要求输入为 long 类型张量,否则会报类型错误。这是 PyTorch 的硬性要求,因为 Embedding 层内部用整数索引查表。
- 测试调用 :
print(make_context_vector(data[0][0], word_to_idx))取数据集第 0 个样本的上下文,转换为编号张量并打印,用于验证转换逻辑是否正确。这是调试的常用手段。
六、自动选择训练设备
PyTorch 支持在 GPU、苹果 MPS 和 CPU 上训练模型,这里通过条件判断自动选择最优设备。原理是:GPU 拥有大量并行计算核心,适合矩阵运算,能大幅加速神经网络训练;MPS 是苹果芯片的 GPU 加速接口;CPU 作为通用兜底。
python
device = "cuda" if torch.cuda.is_available() else "mps" if torch.backends.mps.is_available() else "cpu"
知识点详解:
- cuda:NVIDIA 显卡的 GPU 加速接口,训练速度最快,优先选择。CUDA 是 NVIDIA 推出的并行计算平台,PyTorch 通过它调用 GPU 算力。
- mps:苹果自研芯片(M1/M2/M3)的 GPU 加速接口,适用于 Mac 电脑。MPS(Metal Performance Shaders)是苹果的 GPU 加速框架。
- cpu:中央处理器,作为兜底方案,任何电脑都支持。CPU 训练小模型足够,但大模型会非常慢。
- 后续所有张量和模型都通过
.to(device)移动到所选设备上计算。注意:模型和输入张量必须在同一设备上,否则会报错。
七、定义 CBOW 神经网络类
CBOW 模型由三部分组成:词嵌入层、隐藏全连接层、输出全连接层。整体结构可以理解为:输入层(Embedding 查表求和)→ 隐藏层(Linear + ReLU)→ 输出层(Linear + log_softmax)。
python
class CBOW(nn.Module):
def __init__(self, vocab_size, embedding_dim):
super(CBOW, self).__init__()
# 词嵌入层:每个单词映射为 embedding_dim 维向量
self.embeddings = nn.Embedding(vocab_size, embedding_dim)
# 第一层全连接:embedding_dim → 128
self.proj = nn.Linear(embedding_dim, 128)
# 第二层全连接:128 → vocab_size
self.output = nn.Linear(128, vocab_size)
def forward(self, inputs):
# 1. 查表得到上下文词向量并求和
embeds = sum(self.embeddings(inputs)).view(1, -1) # (1, embedding_dim)
# 2. 全连接 + ReLU 激活
out = F.relu(self.proj(embeds)) # (1, 128)
# 3. 输出层,得到原始得分
out = self.output(out) # (1, vocab_size)
# 4. log_softmax 转为对数概率
nll_prob = F.log_softmax(out, dim=-1)
return nll_prob</code></pre>
知识点详解:
nn.Embedding(vocab_size, embedding_dim):词嵌入层,本质是一个可训练的查找表。输入单词编号,输出对应的词向量。这里 embedding_dim=10,即每个单词映射为 10 维向量。Embedding 层的权重矩阵形状为 (vocab_size, embedding_dim),训练过程中这个矩阵会被不断优化,最终就是我们要的词向量。
sum(self.embeddings(inputs)):CBOW 的核心操作。将上下文 4 个单词的词向量逐元素相加,合并为一个向量。这是"词袋"(Bag-of-Words)思想的体现------不考虑词序,只做向量求和。也可以改用取平均(mean),效果类似。
.view(1, -1):把求和后的向量形状调整为 (1, embedding_dim),即 1 行 embedding_dim 列,适配全连接层的输入要求。-1 表示自动推断该维度大小。
nn.Linear(embedding_dim, 128):第一层全连接,把 10 维向量映射到 128 维隐藏空间。全连接层的作用是对输入做线性变换(y = xW^T + b),128 是隐藏层神经元数量,是超参数。
F.relu:ReLU 激活函数,引入非线性,使模型能够学习更复杂的模式。如果没有激活函数,多层线性变换叠加仍然是线性变换,无法拟合复杂函数。ReLU 公式为 f(x) = max(0, x),计算简单且能缓解梯度消失。
nn.Linear(128, vocab_size):输出层,把 128 维映射到词表大小 49 维。每个位置代表对应单词的预测得分。得分越高,说明模型认为该单词是中心词的概率越大。
F.log_softmax(out, dim=-1):在最后一个维度(单词类别维度)上做 softmax 归一化并取对数,得到对数概率。softmax 把原始得分转换为概率分布(所有概率之和为 1),取对数后数值更稳定,且方便与 NLLLoss 配合。这样做的目的是配合 NLLLoss 损失函数使用,二者组合等价于交叉熵损失。
八、实例化模型与配置优化器
模型类定义完成后,需要创建模型实例,并配置优化器和损失函数。这一步的原理是:模型定义只是搭建了网络结构,实例化后才真正分配内存和初始化参数;