深度学习自然语言处理:CBOW 模型原理与代码精讲

NLP 领域最经典的模型 ------Word2Vec,通过 "猜词游戏",让计算机自己学会每个词的语义,把文字变成有用的数字向量。

计算机天生只会算数字,不会认字。我们说 "苹果"、"香蕉",计算机根本不懂这是什么意思。那怎么让计算机 "看懂" 人类的语言呢?

从最原始的词袋法,到 One-hot 编码,再到 2013 年革命性的 Word2Vec,NLP 领域花了很多年才找到一个好方案。

在 Word2Vec 出现之前,人们用两种办法把文字转成数字:

方法 1:统计词袋模型

最简单的想法:统计每个词在句子里出现几次。

比如有 3 句话:

句子1:我 爱 吃 苹果

句子2:我 爱 吃 香蕉

句子3:苹果 香蕉 都 好吃

就会得到这样的数字表格

句子 1 句子 2 句子 3
1 1 0
1 1 0
1 1 0
苹果 1 0 1
香蕉 0 1 1
  • "苹果" 和 "香蕉" 都是水果,语义非常接近,但在这个表格里完全是两个无关的东西
  • 词越多,列数越多,10000 个词就要 10000 列,计算机跑不动

方法 2:One-hot 独热编码

稍微进步一点:每个词对应一个向量,只有自己的位置是 1,其他都是 0。

我 → 1, 0, 0, 0, 0

爱 → 0, 1, 0, 0, 0

吃 → 0, 0, 1, 0, 0

苹果 → 0, 0, 0, 1, 0

香蕉 → 0, 0, 0, 0, 1

  • 5 个词 = 5 维,4960 个词 = 4960 维!维度爆炸
  • "苹果" 和 "香蕉" 的向量距离还是很远,完全没有语义关联

词嵌入(Word Embedding)

2013 年 Google 的 Tomas Mikolov 提出了 Word2Vec,彻底改变了这个局面!

把高维的稀疏向量,压缩成低维的稠密向量。

原来(4960维):苹果 → 0,0,0,...,1,...,0

现在(300维):苹果 → 0.12, -0.34, 0.78, 0.21, ...

训练完之后你会发现:向量距离近 = 语义相近!

苹果 向量:0.5, 0.8, 0.1

香蕉 向量:0.48, 0.79, 0.12 ← 和苹果很近!都是水果

我 向量:-0.2, 0.1, -0.5 ← 和水果很远

Word2Vec 的两大训练模型

Word2Vec 有如图两种训练思路

1. CBOW(连续词袋模型)

用上下文的词,来预测中间的词

举个例子,句子:我 爱 吃 苹果 香蕉

  • 给你:我、爱、苹果、香蕉(前后各 2 个词)
  • 猜:中间那个词是什么?
  • 正确答案:

2. Skip-gram(跳字模型)

用中间的词,来预测上下文的词

还是这个句子:我 爱 吃 苹果 香蕉

  • 给你:(中间那个词)
  • 猜:它前后应该出现什么词?
  • 正确答案:我、爱、苹果、香蕉

CBOW 神经网络结构详解

CBOW的完整神经网络结构:

  1. 输入层:4 个上下文词,每个词是 4960 维的 one-hot 向量
  2. 隐藏层:300 维(这就是我们最终词向量的维度)
  3. 输出层:4960 维,对应每个词的概率

假设:词汇量 4960 个,词向量维度 300,上下文 4 个词

**输入:**4个词 × 每个词4960维 = 4×4960 的矩阵 (4 个 one-hot 向量)

乘第一个矩阵 W(4960×300):4×4960 × 4960×300 = 4×300 (每个 one-hot 向量,经过矩阵乘法,变成了 300 维的稠密向量)

**求和取平均:**4个300维向量,加起来除以4 = 1×300 (CBOW 关键的一步:把 4 个上下文词的信息融合成一个向量)

**乘第二个矩阵 W'(300×4960):**1×300 × 300×4960 = 1×4960 (再映射回词汇表大小,每个位置对应一个词的得分。)

第一个矩阵 Wv*N就是我们最终要的词向量。做语义相似度、文本分类、命名实体识别,用的都是这个矩阵里的向量。

W 矩阵是 4960 行 × 300 列

每一行 = 对应那个词的 300 维词向量

第 1 行 = 第 1 个词的向量

第 2 行 = 第 2 个词的向量

...

第 4960 行 = 第 4960 个词的向量

训练过程中,模型需要一个 "老师" 告诉它:这次猜得准不准,差了多少。这就是损失函数,CBOW 用的是 softmax + 交叉熵损失

模型最后输出 4960 个数字,每个数字代表一个词的 "得分"。

softmax 把所有得分转成正数(取 e 的 x 次方)并做归一化,让所有得分加起来等于 1,输出的就是每个词的概率。

预测概率 = 0.99(很准) → -log(0.99) ≈ 0.01(损失很小)

预测概率 = 0.01(很烂) → -log(0.01) ≈ 4.6(损失很大)

  • 预测越准,损失越小,奖励模型
  • 预测越错,损失越大,惩罚模型

模型就是根据这个损失,反过来调整矩阵参数,越猜越准。

CBOW 完整的训练流程:

  1. 准备输入:把 4 个上下文词转成 one-hot 编码
  2. 词嵌入:每个词乘矩阵 W,得到各自的 300 维向量
  3. 信息融合:4 个向量加起来,变成 1 个 300 维向量
  4. 映射输出:再乘矩阵 W',得到 4960 维的得分
  5. 概率化:softmax 把得分转成每个词的概率
  6. 取最大值:概率最大的那个词,就是模型的预测
  7. 算损失:和真实答案对比,算出这次猜得差了多少
  8. 反向传播:根据损失调整 W 和 W' 两个矩阵,下次更准

循环往复,训练几百轮,W 矩阵里的词向量就越来越准确。

下面用一段demo来讲解他的实际应用流程

导入库 + 超参数设置

python 复制代码
import torch                      # 导入PyTorch核心库
import torch.nn as nn             # 神经网络模块
import torch.nn.functional as F   # 激活函数等功能
import torch.optim as optim       # 优化器
from tqdm import tqdm             # 进度条显示
import numpy as np                # 数值计算

# 注意:上下文窗口的一半,例如:(前2, 后2)
# 1:就是只取前后各1个词,所以总共有(1+1)个上下文词
CONTEXT_SIZE = 2  # 窗口大小:表示前后各取2个词作为上下文,共4个词

文本预处理 + 词表构建

把文字变成计算机能处理的数字。

python 复制代码
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()

vocab = set(raw_text)       # 去重得到所有不重复的词
vocab_size = len(vocab)     # 词汇表大小

word_to_idx = {word: i for i, word in enumerate(vocab)}  # 给每个词分配编号,建立词到索引的映射
idx_to_word = {i: word for i, word in enumerate(vocab)}  # 索引到词的映射

split() 分词:按空格把长文本切成单词列表,raw_text 变成一个 Python 列表 'We', 'are', 'about', 'to', 'study', ...

set() 去重:自动剔除重复单词,得到词表,len(vocab):统计一共有多少个不同的单词

原文本:"我 爱 吃 苹果 苹果 香蕉"

去重后:{"我", "爱", "吃", "苹果", "香蕉"}

词汇量:5

注:大小写问题,"Process" 和 "process" 会被当成两个不同的词,真实项目中一般会先 .lower() 全部转小写,再去重,set() 是无序的每次运行,单词的顺序可能不一样,真实项目中要固定随机种子,保证每次运行编号一致

字典推导式构建双向映射

enumerate() 同时拿到 索引 + 元素

python 复制代码
for i, word in enumerate(["苹果", "香蕉", "我"]):
    print(i, word)
# 输出:
# 0 苹果
# 1 香蕉
# 2 我

神经网络只能处理数字,必须把词转成编号,词表映射的代码中,我们用到了 字典推导式,遍历所有单词,自动生成 {单词: 编号} 字典

enumerate(vocab):遍历词表,每次取出一组 (索引i, 单词word)

word: i:指定字典的键是单词,值是对应的数字索引

  1. 训练时用 word_to_idx:把输入的单词转成编号,喂给神经网络
  2. 预测后用 idx_to_word:把模型输出的编号,转回人类能看懂的单词

进网络用 word_to_idx,出网络用 idx_to_word

构造 CBOW 训练样本

数据准备步骤,我们要用一个滑动窗口,在长句子上滑过去,每个位置都生成一个「用前后 4 个词猜中间 1 个词」的训练样本。

python 复制代码
data = []
for i in range(CONTEXT_SIZE, len(raw_text) - CONTEXT_SIZE):
    context = (
        [raw_text[i - j - 1] for j in range(CONTEXT_SIZE)] +  # 前面的词
        [raw_text[i + j + 1] for j in range(CONTEXT_SIZE)]    # 后面的词
    )
    target = raw_text[i]  # 中间的目标词
    data.append((context, target))

初始化空列表,用来存放所有训练样本,每个样本的格式是:

复制代码
(上下文词列表, 中心目标词)

滑动窗口的起始和结束,为什么不从 0 开始,不从最后结束?

复制代码
单词位置:0  1  2  3  4  5  6  ...  N-3  N-2  N-1
          ↑              ↑
      从这里开始      到这里结束
      CONTEXT_SIZE    len - CONTEXT_SIZE

中心词在位置 0:前面没有 2 个词,取不到

中心词在位置 N-1:后面没有 2 个词,取不

举个例子:

总词数 = 50,CONTEXT_SIZE = 2

遍历范围:range(2, 48) → i = 2, 3, 4, ..., 47

一共 46 个中心词位置

提取上下文词

取前面的 2 个词,当 CONTEXT_SIZE = 2,j = 0, 1:

j=0 → i - 0 - 1 = i-1 → 前 1 个词

j=1 → i - 1 - 1 = i-2 → 前 2 个词

结果:[raw_text[i-2], raw_text[i-1]]

取后面的 2 个词,当 CONTEXT_SIZE = 2,j = 0, 1:

j=0 → i + 0 + 1 = i+1 → 后 1 个词

j=1 → i + 1 + 1 = i+2 → 后 2 个词

结果:[raw_text[i+1], raw_text[i+2]]

当前位置 i 的单词,就是我们要预测的中心目标词 。把 (上下文, 中心词) 这个训练样本,加入数据集。

工具函数 + 设备选择

把单词列表转成 PyTorch 张量(神经网络的输入格式),自动选择最快的计算设备(GPU/CPU)

python 复制代码
def make_context_vector(context, word_to_idx):
    idxs = [word_to_idx[w] for w in context]
    return torch.tensor(idxs, dtype=torch.long)

print(make_context_vector(data[0][0], word_to_idx))  # 示例

device = torch.device('cuda' if torch.cuda.is_available() else 'mps' if torch.backends.mps.is_available() else 'cpu')
print(device)

神经网络只认数字张量,必须将单词列表先转成数字,再转成 PyTorch 张量

输入 :context = 上下文单词列表,如 ["We", "are", "to", "study"] ,word_to_idx = 词→编号的映射字典

把每个单词转成编号

"We"0

"are"1

"to"3

"study"4

结果:idxs = [0, 1, 3, 4]

dtype=torch.long:PyTorch 的nn.Embedding层,只接受torch.long类型的索引作为输入。

data[0] = 第一个训练样本

data[0][0] = 第一个样本的上下文单词列表

输出示例:

复制代码
tensor([26, 46, 20, 22])

这就是 4 个上下文词对应的编号张量。

CBOW 模型定义

用 PyTorch 搭建 CBOW 神经网络

python 复制代码
class CBOW(nn.Module):
    def __init__(self, vocab_size, embedding_dim):
        super(CBOW, self).__init__()
        self.embeddings = nn.Embedding(vocab_size, embedding_dim)  # 词嵌入层
        self.linear1 = nn.Linear(embedding_dim, 128)               # 第一个全连接层
        # self.linear1 = nn.Linear((CONTEXT_SIZE * 2 *embedding_dim, 128)   # 把所有上下文4个词向量首尾连成长向量
        # 如果你需要区分上下文词的位置(比如:左边第一个词、右边第一个词语义权重不同),比如改进型模型、需要学习位置特征时才用拼接;纯基础词向量训练完全没必要。
        self.linear2 = nn.Linear(128, vocab_size)                  # 输出层

    def forward(self, inputs):
        embeds = self.embeddings(inputs)  # [4, 10]
        embeds = torch.sum(embeds, dim=0).unsqueeze(0)  # [1, 10],标准CBOW
        out = F.relu(self.linear1(embeds))
        out = self.linear2(out)
        log_probs = F.log_softmax(out, dim=1)
        return log_probs

模型遵循这个固定结构:

python 复制代码
class 模型名(nn.Module):          # 1. 继承nn.Module
    def __init__(self, 参数):      # 2. __init__:定义网络层
        super().__init__()         #    必须调用父类初始化
        self.层名 = 层定义
        
    def forward(self, 输入):       # 3. forward:写前向传播逻辑
        数据流动过程
        return 输出

__init__ 方法:定义网络层

继承和初始化

复制代码
def __init__(self, vocab_size, embedding_dim):
    super(CBOW, self).__init__()
  • vocab_size:词汇表大小(49)
  • embedding_dim:词向量维度(10)
  • super():初始化父类nn.Module

词嵌入层

复制代码
self.embeddings = nn.Embedding(vocab_size, embedding_dim)

这就是训练的词向量矩阵

形状:vocab_size × embedding_dim = 49 × 10

  • 每一行 = 一个单词的词向量
  • 输入:单词编号(如 26)
  • 输出:对应的 10 维词向量

第一个全连接层

复制代码
self.linear1 = nn.Linear(embedding_dim, 128)

形状:10 → 128

  • 输入:10 维(求和后的上下文向量)
  • 输出:128 维特征
  • 做非线性特征提取

拼接法 vs 求和法

python 复制代码
# 拼接法(维度随窗口变大)
# self.linear1 = nn.Linear(CONTEXT_SIZE * 2 * embedding_dim, 128)
# 4个词 × 10维 = 40维输入

# 求和法(标准CBOW)
self.linear1 = nn.Linear(embedding_dim, 128)
# 4个词求和 = 10维输入,维度固定

为什么推荐求和法?

  1. 维度固定,改窗口大小不用改网络
  2. 参数少,训练快,不易过拟合
  3. 符合 CBOW"词袋" 思想,不关心顺序

输出层

复制代码
self.linear2 = nn.Linear(128, vocab_size)

形状:128 → 49

  • 输入:128 维特征
  • 输出:49 维(每个词的得分)
  • 后面接 softmax 转成概率

forward 方法:前向传播

我们一步步看维度变化(参数:4 个上下文词,10 维词向量)

词嵌入

复制代码
embeds = self.embeddings(inputs)  # shape: [4, 10]
  • 输入:4 个单词的编号 [26, 46, 20, 22]
  • 输出:4 个 10 维词向量
  • 形状:4 × 10

求和融合

复制代码
embeds = torch.sum(embeds, dim=0).unsqueeze(0)  # shape: [1, 10]
  1. torch.sum(embeds, dim=0):4 个向量逐元素相加
    • [4, 10] → [10](4 个 10 维向量加起来变成 1 个 10 维)
  2. .unsqueeze(0):增加 batch 维度
    • [10] → [1, 10](PyTorch 要求必须有 batch 维度)

这就是 CBOW:把 4 个上下文词的信息,融合成 1 个向量


第一层全连接 + 激活

复制代码
out = F.relu(self.linear1(embeds))

[1, 10] → [1, 128],ReLU 激活:把负数变成 0,引入非线性


第二层全连接

复制代码
out = self.linear2(out)

[1, 128] → [1, 49],输出 49 个得分,对应 49 个词


log_softmax

复制代码
log_probs = F.log_softmax(out, dim=1)

log_softmax 对比 softmax ,数值更稳定,避免溢出,和后面的NLLLoss是黄金搭档,数学上等价效果一样,输出每个词的对数概率


维度变化完整流程图

复制代码
输入: [4] (4个单词编号)
    ↓
embedding: [4, 10] (4个词向量)
    ↓
sum + unsqueeze: [1, 10] (融合成1个向量)
    ↓
linear1 + relu: [1, 128] (特征提取)
    ↓
linear2: [1, 49] (映射到词汇表)
    ↓
log_softmax: [1, 49] (每个词的概率)
输出: [1, 49]

训练循环

模型真正 "学习" 的过程。通过几百轮的反复训练,让词向量越来越准确。

python 复制代码
model = CBOW(vocab_size, 10).to(device)          # 创建模型,词向量维度=10

optimizer = optim.Adam(model.parameters(), lr=0.001)  # Adam优化器
loss_function = nn.NLLLoss()                     # 负对数似然损失

losses = []
for epoch in tqdm(range(200)):  # 训练200轮
    total_loss = 0
    for context, target in data:  # 遍历每个训练样本
        # 1. 准备数据
        context_vector = make_context_vector(context, word_to_idx).to(device)
        target = torch.tensor([word_to_idx[target]]).to(device)

        # 2. 前向传播:模型预测
        train_predict = model(context_vector)

        # 3. 计算损失
        loss = loss_function(train_predict, target)

        # 4. 反向传播 + 参数更新
        optimizer.zero_grad()  # 清空梯度
        loss.backward()  # 反向传播计算梯度
        optimizer.step()  # 更新权重

        total_loss += loss.item()

    losses.append(total_loss)
    # print(losses)
    print(f"Epoch {epoch + 1}, Loss: {total_loss:.4f}")
    # 拿到预测概率最大的词索引

创建模型并移到设备

复制代码
model = CBOW(vocab_size, 10).to(device)

创建 CBOW 模型,词向量维度 = 10,.to(device):把模型参数移到 GPU/CPU,模型和数据必须在同一个设备!


优化器:Adam

复制代码
optimizer = optim.Adam(model.parameters(), lr=0.001)

优化器根据计算出来的梯度,更新模型的参数(就是词向量矩阵)

  • model.parameters():要更新的所有参数(embedding 矩阵、两个 Linear 层的权重)
  • lr=0.001:学习率,每次更新的步长大小
  • Adam 是目前最常用、最稳定的优化器

损失函数:NLLLoss

复制代码
loss_function = nn.NLLLoss()

损失函数计算 "模型预测" 和 "真实答案" 之间的差距有多大

NLLLoss = Negative Log Likelihood Loss(负对数似然损失),和前面的log_softmax是黄金搭档,专门用于多分类任务


训练循环外层:轮次(Epoch)

复制代码
for epoch in tqdm(range(200)):

把所有训练样本完整过一遍,叫做 1 轮(Epoch)

训练 200 轮 = 所有样本反复学习 200 次,tqdm():显示进度条,直观看到训练进度,一般训练 100-500 轮,损失下降到平稳就可以停了


训练循环内层:遍历每个样本,每个样本就是:(上下文4个词, 中心词)

准备数据

复制代码
context_vector = make_context_vector(context, word_to_idx).to(device)
target = torch.tensor([word_to_idx[target]]).to(device)

把单词转成张量,并移到设备上:

  • 上下文:[26, 46, 20, 22]
  • 中心词:[5](真实答案的编号)

前向传播(模型猜答案)

复制代码
train_predict = model(context_vector)

输入上下文,模型输出每个词的概率:

复制代码
[0.01, 0.02, ..., 0.56, ..., 0.01]
                ↑
          模型猜是第56个词

计算损失,loss 越大 = 猜得越差;loss 越小 = 猜得越准


反向传播

复制代码
# 1. 清空上一轮的梯度
optimizer.zero_grad()

# 2. 反向传播:计算每个参数的梯度
loss.backward()

# 3. 更新参数:根据梯度调整词向量
optimizer.step()

zero_grad():把上次算的梯度清零,不然会累加,backward():从 loss 往回算,每个参数该调大调小,step():真正调整参数(词向量矩阵更新)


累计损失并打印

复制代码
total_loss += loss.item()

loss是张量,.item()取出纯 Python 数字,把这一轮所有样本的损失加起来

复制代码
losses.append(total_loss)
print(f"Epoch {epoch + 1}, Loss: {total_loss:.4f}")

模型测试提取词向量

验证模型效果,并提取最终的词向量

python 复制代码
# 测试样本上下文
context = ['People', 'create', 'to', 'direct']  # People create programs to direct
context_vector = make_context_vector(context, word_to_idx).to(device)

# 预测部分
model.eval()  # 进入测试模式
predict = model(context_vector)
max_idx = predict.argmax(1)  # dim=1表示每一行中的最大值对应的索引号,dim=0表示每一列中的最大值对应的索引号

# 获取词向量权重矩阵
print("CBOW embedding weight=", model.embeddings.weight)  # GPU
# .detach():断开梯度计算,不参与反向传播;.cpu()移到CPU;转numpy数组
W = model.embeddings.weight.cpu().detach().numpy()
print(W)

# 生成 单词-词向量 字典
word_2_vec = {}
for word in word_to_idx.keys():
    # 权重矩阵一行对应一个单词的词向量
    word_2_vec[word] = W[word_to_idx[word], :]

准备测试数据

复制代码
context = ['People', 'create', 'to', 'direct']
context_vector = make_context_vector(context, word_to_idx).to(device)

我们手动构造一个测试样本:

  • 上下文:People, create, to, direct
  • 正确的中心词应该是:programs

原句:People create programs to direct processes

给模型前后 4 个词,看它能不能猜出中间是programs


切换到测试模式

复制代码
model.eval()

训练模式:Dropout、BatchNorm 等层会生效,测试模式:关闭所有训练专属层,固定参数,推理 / 测试前必须加,否则结果不稳定


模型预测并取最大值

复制代码
predict = model(context_vector)
max_idx = predict.argmax(1)
  • predict:输出 49 个词的概率分布
  • .argmax(1):取概率最大的那个词的索引
  • dim=1:在 "词" 这个维度取最大值

然后我们可以打印看看:

复制代码
print("预测的中心词:", idx_to_word[max_idx.item()])

如果训练得好,应该输出:programs


提取词向量

复制代码
W = model.embeddings.weight.cpu().detach().numpy()
步骤 作用 少了会怎么样
.cpu() GPU 张量移到 CPU GPU 张量不能直接转 numpy,报错!
.detach() 断开计算图,去掉梯度 报错:Can't call numpy () on Tensor that requires grad
.numpy() PyTorch 张量转 numpy 数组 还是张量格式,无法保存

W 的形状:49 × 10(词汇量 × 词向量维度)


构建词向量字典(方便使用)

复制代码
word_2_vec = {}
for word in word_to_idx.keys():
    word_2_vec[word] = W[word_to_idx[word], :]

效果:

复制代码
word_2_vec["People"] = [0.12, -0.34, ...]  # 10维向量
word_2_vec["create"] = [0.56, 0.78, ...]

以后用的时候直接:vec = word_2_vec["computer"],非常方便


保存和加载词向量

python 复制代码
# 将训练好的词向量保存为npz格式(numpy专用存储格式)
np.savez('word2vec实现.npz', file_1=W)
# 读取npz文件
data = np.load('word2vec实现.npz')
# 打印文件内存储的数组名
print(data.files)

保存为 npz 格式

复制代码
np.savez('word2vec实现.npz', file_1=W)

为什么用 npz?

  1. 压缩存储:体积比 txt 小很多
  2. 可存多个数组:可以同时存词向量、词表等
  3. 加载快:二进制格式,比读 txt 快几十倍
  4. 跨平台:脱离 PyTorch 也能用

加载 npz 文件

复制代码
data = np.load('word2vec实现.npz')
print(data.files)  # 输出:['file_1']

使用:

复制代码
W_loaded = data['file_1']  # 取出词向量矩阵