深度学习——PyTorch 实现 CBOW 词向量模型:从数据预处理到训练与保存

一、任务概述与整体流程

本篇文章基于 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 层的权重就是最终产物------每个单词的稠密向量。
  • 为什么有效:因为语义相近的单词经常出现在相似的上下文中,模型为了准确预测,会让这些单词的向量逐渐趋近,从而捕捉到语义和语法特征。

整个实现流程分为以下六个阶段:

  1. 数据预处理:对原始语料进行分词、去重,构建词表与单词编号映射。
  2. 构造训练数据集:按照上下文窗口滑动,生成(上下文单词列表,目标中心词)样本对。
  3. 定义 CBOW 神经网络:包含词嵌入层、全连接层和输出层。
  4. 实例化模型与配置优化器:设置 Adam 优化器和 NLLLoss 损失函数。
  5. 训练循环:前向传播、反向传播、参数更新,并记录损失。
  6. 测试与词向量保存:用新上下文预测中心词,导出词向量矩阵并保存为 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 损失函数使用,二者组合等价于交叉熵损失。
八、实例化模型与配置优化器
模型类定义完成后,需要创建模型实例,并配置优化器和损失函数。这一步的原理是:模型定义只是搭建了网络结构,实例化后才真正分配内存和初始化参数;
相关推荐
hans汉斯1 小时前
数据挖掘|基于BP神经网络的少数民族村寨文化型旅游体验产品潜在游客挖掘
深度学习·神经网络·算法·yolo·软件工程·bp·汉斯出版社
ai小陈11 小时前
CUDA Stream实战:让数据传输与GPU计算真正重叠
人工智能·深度学习·ai·pdf·云计算·gpu算力
renhongxia115 小时前
数字孪生不止在工厂:能源、医疗与农业
人工智能·深度学习·算法·机器学习·数字孪生
ysu_031416 小时前
边界条件的硬约束与软约束——从试函数构造到谱分析诊断
人工智能·pytorch·深度学习·物理信息神经网络·pinns·边界条件·ntk
会飞锦鲤16 小时前
基于 Mask R-CNN 的药片缺陷检测系统
人工智能·pytorch·python·神经网络·resnet-50
月疯17 小时前
ONNX Runtime
深度学习
马剑威(威哥爱编程)18 小时前
【共创稿事节】HarmonyOS 7 Agent A2A 实战:让日程智能体和打车智能体自己谈成一单
pytorch·深度学习·harmonyos
桃西西呀18 小时前
机器眼里没有脸,怎么识别人脸?手写 CNN 拆开卷积层与 BatchNorm
人工智能·深度学习·llm
Rocky Ding*18 小时前
一文读懂Hallo数字人核心基础知识
论文阅读·人工智能·深度学习·机器学习·aigc·数字人·ai-native