
NLP 领域最经典的模型 ——Word2Vec通过 猜词游戏让计算机自己学会每个词的语义把文字变成有用的数字向量。计算机天生只会算数字不会认字。我们说 苹果、香蕉计算机根本不懂这是什么意思。那怎么让计算机 看懂 人类的语言呢从最原始的词袋法到 One-hot 编码再到 2013 年革命性的 Word2VecNLP 领域花了很多年才找到一个好方案。在 Word2Vec 出现之前人们用两种办法把文字转成数字方法 1统计词袋模型最简单的想法统计每个词在句子里出现几次。比如有 3 句话句子1我 爱 吃 苹果句子2我 爱 吃 香蕉句子3苹果 香蕉 都 好吃就会得到这样的数字表格词句子 1句子 2句子 3我110爱110吃110苹果101香蕉011苹果 和 香蕉 都是水果语义非常接近但在这个表格里完全是两个无关的东西词越多列数越多10000 个词就要 10000 列计算机跑不动方法 2One-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 Embedding2013 年 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 向量)乘第一个矩阵 W4960×300:[4×4960] × [4960×300] [4×300] (每个 one-hot 向量经过矩阵乘法变成了 300 维的稠密向量)求和取平均:4个300维向量加起来除以4 1×300 (CBOW 关键的一步把 4 个上下文词的信息融合成一个向量)乘第二个矩阵 W300×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个词所以总共有(11)个上下文词 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 个词取不举个例子总词数 50CONTEXT_SIZE 2遍历范围range(2, 48)→ i 2, 3, 4, ..., 47一共 46 个中心词位置提取上下文词取前面的 2 个词当CONTEXT_SIZE 2j 0, 1j0 →i - 0 - 1 i-1→ 前 1 个词j1 →i - 1 - 1 i-2→ 前 2 个词结果[raw_text[i-2], raw_text[i-1]]取后面的 2 个词当CONTEXT_SIZE 2j 0, 1j0 →i 0 1 i1→ 后 1 个词j1 →i 1 1 i2→ 后 2 个词结果[raw_text[i1], raw_text[i2]]当前位置i的单词就是我们要预测的中心目标词。把(上下文, 中心词)这个训练样本加入数据集。工具函数 设备选择把单词列表转成 PyTorch 张量神经网络的输入格式自动选择最快的计算设备GPU/CPUdef make_context_vector(context, word_to_idx): idxs [word_to_idx[w] for w in context] return torch.tensor(idxs, dtypetorch.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→0are→1to→3study→4结果idxs [0, 1, 3, 4]dtypetorch.longPyTorch 的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, dim0).unsqueeze(0) # [1, 10]标准CBOW out F.relu(self.linear1(embeds)) out self.linear2(out) log_probs F.log_softmax(out, dim1) 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词汇表大小49embedding_dim词向量维度10super()初始化父类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, dim0).unsqueeze(0) # shape: [1, 10]torch.sum(embeds, dim0)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_softmaxlog_probs F.log_softmax(out, dim1)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(), lr0.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(fEpoch {epoch 1}, Loss: {total_loss:.4f}) # 拿到预测概率最大的词索引创建模型并移到设备model CBOW(vocab_size, 10).to(device)创建 CBOW 模型词向量维度 10.to(device)把模型参数移到 GPU/CPU模型和数据必须在同一个设备优化器Adamoptimizer optim.Adam(model.parameters(), lr0.001)优化器根据计算出来的梯度更新模型的参数就是词向量矩阵model.parameters()要更新的所有参数embedding 矩阵、两个 Linear 层的权重lr0.001学习率每次更新的步长大小Adam 是目前最常用、最稳定的优化器损失函数NLLLossloss_function nn.NLLLoss()损失函数计算 模型预测 和 真实答案 之间的差距有多大NLLLoss Negative Log Likelihood Loss负对数似然损失和前面的log_softmax是黄金搭档专门用于多分类任务训练循环外层轮次Epochfor 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(fEpoch {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) # dim1表示每一行中的最大值对应的索引号dim0表示每一列中的最大值对应的索引号 # 获取词向量权重矩阵 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)取概率最大的那个词的索引dim1在 词 这个维度取最大值然后我们可以打印看看print(预测的中心词, idx_to_word[max_idx.item()])如果训练得好应该输出programs提取词向量W model.embeddings.weight.cpu().detach().numpy()步骤作用少了会怎么样.cpu()GPU 张量移到 CPUGPU 张量不能直接转 numpy报错.detach()断开计算图去掉梯度报错Cant 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_1W) # 读取npz文件 data np.load(word2vec实现.npz) # 打印文件内存储的数组名 print(data.files)保存为 npz 格式np.savez(word2vec实现.npz, file_1W)为什么用 npz压缩存储体积比 txt 小很多可存多个数组可以同时存词向量、词表等加载快二进制格式比读 txt 快几十倍跨平台脱离 PyTorch 也能用加载 npz 文件data np.load(word2vec实现.npz) print(data.files) # 输出[file_1]使用W_loaded data[file_1] # 取出词向量矩阵