欢迎光临
我们一直在努力

深度学习自然语言处理: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的完整神经网络结构:

  • 输入层:4 个上下文词,每个词是 4960 维的 one-hot 向量
  • 隐藏层:300 维(这就是我们最终词向量的维度)
  • 输出层: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 完整的训练流程:

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

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

    导入库 + 超参数设置

    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个词

    文本预处理 + 词表构建

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

    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() 同时拿到 索引 + 元素

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

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

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

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

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

    构造 CBOW 训练样本

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

    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)

    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 神经网络

    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

    模型遵循这个固定结构:

    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 求和法

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

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

    为什么推荐求和法?

  • 维度固定,改窗口大小不用改网络
  • 参数少,训练快,不易过拟合
  • 符合 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]

  • torch.sum(embeds, dim=0):4 个向量逐元素相加
    • [4, 10] → [10](4 个 10 维向量加起来变成 1 个 10 维)
  • .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]

    训练循环

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

    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}")

    模型测试提取词向量

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

    # 测试样本上下文
    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"],非常方便


    保存和加载词向量

    # 将训练好的词向量保存为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?

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

  • 加载 npz 文件

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

    使用:

    W_loaded = data['file_1'] # 取出词向量矩阵

    赞(0)
    未经允许不得转载:171主机测评 » 深度学习自然语言处理:CBOW 模型原理与代码精讲
    分享到: 更多 (0)

    评论 抢沙发

    • 昵称 (必填)
    • 邮箱 (必填)
    • 网址