【深度学习入门】手把手实现 Word2Vec(CBOW)——从词向量到 Numpy 参数存取
一、前言
很多刚入门深度学习的同学,面对的第一个疑问往往是:
“单词怎么能变成数字喂给模型?”
“训练好的词向量怎么保存下来,下次直接用?”
本文就用两个非常经典的入门素材,把这条链路完整走一遍:
- Numpy 数组的保存与读取(
.npy/.npz):深度学习里几乎所有中间产物(词向量、特征矩阵、预处理结果)都要落盘,这是基本功; - 用 PyTorch 从零实现 Word2Vec 中的 CBOW 模型:理解"词向量是怎么被训练出来的"这一核心概念。
读完本文,你将能:
- 熟练使用
np.save/np.savez/np.load存取数组; - 说清楚 One-hot 编码和分布式词向量的区别;
- 看懂 CBOW 模型的网络结构和训练流程;
- 训练完把词向量保存成
.npz文件,供下游任务复用。
二、热身:Numpy 数组的保存与读取(.npy / .npz)
2.1 为什么需要保存数组?
- 训练好的词向量、模型参数需要落盘持久化,不能每次重新训练;
- 大型预处理结果(例如清洗后的语料矩阵)存下来,避免重复计算;
- 别人开源的预训练资源(如腾讯 AI Lab 中文词向量)通常就以
.npz格式发布。
2.2 .npy:保存单个数组
.npy 是 Numpy 专用的二进制格式,一个文件保存一个数组。写法非常简单:
import numpy as np
a = np.random.randint(5, size=(2, 4)) # 生成一个 2x4 的随机整数数组
np.save('test.npy', a) # 保存为 .npy(未写后缀时 np.save 会自动补上)
b = np.load('test.npy') # 读回
print(a)
print(b)
要点:
np.save(file, arr):保存单个数组,会自动补.npy后缀;np.load(file):读回数组,返回的就是原来的 ndarray;- 因为是二进制格式,读取速度快、体积小,但不是人类可读的文本。
2.3 .npz:打包保存多个数组
当我们需要把多个数组放在一个文件里(比如"词表 + 词向量矩阵 + 词频"),就用 np.savez:
a = np.random.randint(0, 10, (3,), dtype='int')
b = np.random.randint(0, 10, (3,), dtype='int')
c = np.random.randint(0, 10, (3,), dtype='int')
np.savez('test.npz', file1=a, file2=b, file3=c) # 用关键字参数给每个数组命名
data = np.load('test.npz') # 返回的是一个 NpzFile 对象,类似字典
print(data.files) # 按保存顺序返回所有键:['file1', 'file2', 'file3']
aa = data[data.files[0]] # 通过键名取数组
bb = data[data.files[1]]
cc = data[data.files[2]]
print('read: ', aa, bb, cc)
要点:
np.savez把多个数组打包成一个.npz文件,本质是一个 ZIP 容器,里面的每个条目就是一个.npy文件;- 每个数组都可以用关键字参数命名,读取时用
data['名字']直接取; data.files返回键名列表,按保存顺序排列,也可以直接for key in data.files: ...遍历;- 如果不想起名字,
np.savez('x.npz', a, b, c)会自动命名为arr_0, arr_1, arr_2; - 想进一步压缩体积,用
np.savez_compressed,读取方式完全相同。
| 格式 | 保存的数组数 | 读取方式 | 典型用途 |
|---|---|---|---|
.npy | 单个 | np.load | 保存一个矩阵 / 向量 |
.npz | 多个(可命名) | np.load + data['键'] | 打包"词表 + 向量 + 元信息"等多组数据 |
补充:如果需要人类可读的文本格式,可以用
np.savetxt/np.loadtxt(适合小规模数据,如 CSV);大规模二进制数据首选np.save/np.savez。
2.4 加载预训练词向量(实战场景)
data = np.load('embedding_Tencent.npz') # 腾讯 AI Lab 开源的中文词向量
embeddings = data['embeddings'] # 取出词向量矩阵
这就是加载预训练词向量的标准姿势:别人把词向量矩阵保存为 .npz,你一行 np.load 就能拿到。后面我们自己训练的 CBOW 词向量,也会用同样的方式存下来。
三、为什么要"词向量"?
计算机只认数字,不认单词。要让神经网络处理文本,第一步就是把单词变成数字。
3.1 One-hot 编码(最朴素,但不实用)
假设词表里有 1 万个词,每个词用一个 1 万维的向量表示,只有自己对应位置是 1,其余全是 0:
(猪,狗,猫,鸡,...,鸭)
"猫" -> [0, 0, 1, 0, ..., 0]
"狗" -> [0, 1, 0, 0, ..., 0]
它的两个致命缺点:
- 维度爆炸:词表 10 万词就要 10 万维向量,非常稀疏、非常占内存;
- 没有任何语义信息:任意两个词的向量都正交,“猫"和"狗”、"猫"和"冰箱"在 One-hot 眼里毫无区别。
3.2 分布式表示(Distributed Representation)
词向量(Word Embedding)的思路是:把每个词映射成一个低维稠密的实数向量(如 100 维、300 维),让语义相近的词在向量空间中距离更近。经典例子:
vec(国王) - vec(男人) + vec(女人) ≈ vec(女王)
这类向量不是人手工设计的,而是从大量文本里自动学出来的——这正是 Word2Vec 做的事。
3.3 Word2Vec 的两种模型
Word2Vec 是 2013 年 Mikolov 等人在 Google 提出的经典词向量训练方法,包含两种模型:
| 模型 | 输入 → 输出 | 特点 |
|---|---|---|
| CBOW(连续词袋) | 上下文词 → 预测中心词 | 训练速度快,对高频词效果好 |
| Skip-gram(跳字模型) | 中心词 → 预测上下文词 | 对低频词更友好,通常需要更多数据 |
本文实现的是 CBOW。
四、CBOW 模型原理
CBOW 的目标非常直观:
给定中心词前后各
N个上下文单词,预测中心词是什么。
例如在句子 “People create programs to direct processes” 中,以 programs 为中心词(窗口大小 2),上下文就是 [People, create, to, direct]。模型要做的就是:根据这 4 个词,猜出中间被"挖掉"的词是 programs。
整体流程:
- 把上下文词转成索引,送进
nn.Embedding查表,得到各自的词向量; - 对上下文词向量取平均,得到一个"上下文表示";
- 经过隐藏层
Linear(10, 128) + ReLU做非线性变换(这是本实现相对经典 CBOW 的一个变体:经典 CBOW 平均后直接接输出层); - 输出层
Linear(128, vocab_size)得到词表大小的分数,再过log_softmax变成每个词的对数概率; - 用
NLLLoss计算预测分布与真实中心词之间的损失,反向传播更新所有参数——Embedding 层的权重就是我们要的词向量。
五、代码解析
5.1 数据准备:词表与映射
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)} # 索引 -> 词
raw_text.split()按空白把整段英文切分成单词列表;set()自动去重得到词表,vocab_size就是词表大小;- 神经网络只吃数字,所以要建立"单词 ↔ 索引"的双向映射。
注意:
set是无序的,而且 Python 字符串的哈希值默认带随机种子,所以每次运行词表顺序可能不一样,word_to_idx里的编号也会变。这会影响"预测出的词索引"但不会影响模型本身的学习。
5.2 构造训练样本:滑动窗口
CONTEXT_SIZE = 2 # 左右各取 2 个词作为上下文
data = []
for i in range(CONTEXT_SIZE, len(raw_text) - CONTEXT_SIZE):
context = (
[raw_text[i - (2 - j)] for j in range(2)] # 左侧 2 个词:i-2, i-1
+ [raw_text[i + j + 1] for j in range(CONTEXT_SIZE)] # 右侧 2 个词:i+1, i+2
)
target = raw_text[i] # 中心目标词
data.append((context, target))
- 对文本中每一个位置
i,取它左侧 2 个 + 右侧 2 个共 4 个词作为上下文,raw_text[i]作为目标中心词; - 开头和结尾不足一个完整窗口的位置直接跳过(
range的边界就是干这个的); - 最终
data里是(上下文词列表, 目标词)的样本列表。
以 “People create programs to direct processes” 为例:如果中心词是 programs,则训练样本就是 (['People', 'create', 'to', 'direct'], 'programs')。
5.3 定义模型
class CBOW(nn.Module):
def __init__(self, vocab_size, embedding_dim):
super(CBOW, self).__init__()
self.embeddings = nn.Embedding(vocab_size, embedding_dim) # 词嵌入层(查表)
self.proj = nn.Linear(embedding_dim, 128) # 隐藏层
self.output = nn.Linear(128, vocab_size) # 输出层
def forward(self, inputs):
embeds = sum(self.embeddings(inputs)).view(1, -1) / len(inputs) # 上下文向量取平均
out = F.relu(self.proj(embeds)) # 隐藏层 + ReLU
out = self.output(out) # 输出层
return F.log_softmax(out, dim=-1) # 对数概率
几个关键点:
nn.Embedding(vocab_size, embedding_dim):一个可学习的查表层,权重形状为(vocab_size, embedding_dim)。输入是词的索引,输出是对应行的词向量。训练结束后,这个权重矩阵就是词向量表;sum(self.embeddings(inputs)).view(1, -1) / len(inputs):把 4 个上下文词向量相加再除以数量,得到平均向量;- 隐藏层
Linear(10, 128)加ReLU做非线性变换,是本实现相对经典 CBOW 的增强; - 输出层把 128 维映射回词表大小,
log_softmax输出"每个词作为中心词的对数概率"。
5.4 训练
device = "cuda" if torch.cuda.is_available() else "mps" if torch.backends.mps.is_available() else "cpu"
model = CBOW(vocab_size, 10).to(device) # 词向量维度设为 10
optimizer = optim.Adam(model.parameters(), lr=0.001)
loss_function = nn.NLLLoss()
model.train()
for epoch in tqdm(range(200)): # 训练 200 轮,tqdm 显示进度条
total_loss = 0
for context, target in data:
context_vector = make_context_vector(context, word_to_idx).to(device)
target = torch.tensor([word_to_idx[target]]).to(device)
train_predict = model(context_vector)
loss = loss_function(train_predict, target)
optimizer.zero_grad() # 梯度清零
loss.backward() # 反向传播
optimizer.step() # 更新参数
total_loss += loss.item()
losses.append(total_loss)
- 设备自动选择:
cuda > mps > cpu,有 GPU 用 GPU(Mac 上支持 MPS),否则回退 CPU; - 损失函数:
log_softmax+NLLLoss(负对数似然)的组合,等价于直接使用CrossEntropyLoss; - 优化器:Adam,学习率
0.001,是深度学习最常用的默认配置之一; - 每个 epoch 遍历全部样本,累加损失并记录到
losses列表,用来观察训练是否收敛(整体应呈下降趋势,小语料上会有波动)。
5.5 用训练好的模型预测
model.eval() # 切换到评估模式
context = ['People', 'create', 'to', 'direct'] # 构造一个上下文
context_vector = make_context_vector(context, word_to_idx).to(device)
predict = model(context_vector) # 输出对数概率分布
max_idx = predict.argmax(1) # 取概率最大的词索引
print('预测结果:', idx_to_word[max_idx.item()]) # 索引还原成单词
model.eval()切换到评估模式(对含 Dropout / BatchNorm 的模型很重要,本模型无影响);- 模型输出的是词表大小的对数概率向量,
argmax找到概率最大的索引,再用idx_to_word还原成单词; - 上面这个上下文来自 “People create programs to direct processes”,训练充分的模型应当倾向于预测
programs。由于词表顺序随机、训练数据量很小(只有一段话),实际结果会有波动,这是正常的。
5.6 提取词向量并保存为 .npz
W = model.embeddings.weight.cpu().detach().numpy() # (词表大小, 10)
word_2_vec = {}
for word in word_to_idx.keys():
word_2_vec[word] = W[word_to_idx[word], :] # 词 -> 向量 的字典
np.savez('word2vec实现.npz', file_1=W) # 落盘保存
data = np.load('word2vec实现.npz') # 之后任何地方都能读回
print(data[data.files[0]])
三步走,非常经典:
model.embeddings.weight拿到 Embedding 权重矩阵;.cpu()移到 CPU、.detach()从计算图分离、.numpy()转成 ndarray(在 GPU 上的张量不能直接转 numpy);- 按
word_to_idx把每一行向量对应回单词,构建word_2_vec字典——这就是最终的"词 → 词向量"映射; np.savez把词向量矩阵打包成.npz,以后再要用,np.load一行读回即可,和第二部分加载腾讯词向量的方式一模一样。
六、完整代码
1. Numpy 数组:保存与读取 npy、npz 文件
import numpy as np
# 单数组保存读取 npy
a = np.random.randint(5, size=(2, 4)) # 生成2行4列数组
np.save('test.npy',a) # 保存数组到npy
print(a)
b = np.load('test.npy') # 读取npy
print(b)
# 多数组压缩保存读取 npz
a = np.random.randint(0, 10, (3,), dtype='int')
b = np.random.randint(0, 10, (3,), dtype='int')
c = np.random.randint(0, 10, (3,), dtype='int')
np.savez('test.npz', file1 = a, file2 = b, file3 = c) # 保存多个数组到npz
data = np.load('test.npz')
print(data)
print(data.files) # 获取npz内所有数组名
aa = data[data.files[0]]
bb = data[data.files[1]]
cc = data[data.files[2]]
print('read: ', aa, bb, cc)
# 读取腾讯词嵌入npz文件
a = np.load('embedding_Tencent.npz')
b = a['embeddings'] # 取出embeddings词嵌入矩阵
print(b)
2. word2vec 实现(CBOW)
import torch
import torch.nn as nn
import torch.nn.functional as F
import torch.optim as optim
from tqdm import tqdm
import numpy as np
CONTEXT_SIZE = 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()
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)} # 索引->词
# 构造CBOW训练样本 (上下文词,中心词)
data = []
for i in range(CONTEXT_SIZE, len(raw_text) - CONTEXT_SIZE):
context = [raw_text[i - (2 - j)] for j in range(2)] + [raw_text[i + j + 1] for j in range(CONTEXT_SIZE)]
target = raw_text[i]
data.append((context, target))
# 将上下文词转为索引tensor
def make_context_vector(context, word_to_ix):
idxs = [word_to_ix[w] for w in context]
return torch.tensor(idxs, dtype=torch.long)
print(make_context_vector(data[0][0], word_to_idx))
# 自动选择设备
device = "cuda" if torch.cuda.is_available() else "mps" if torch.backends.mps.is_available() else "cpu"
print(device)
# 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.proj = nn.Linear(embedding_dim, 128)
self.output = nn.Linear(128, vocab_size)
def forward(self, inputs):
embeds = sum(self.embeddings(inputs)).view(1, -1) / len(inputs) # 上下文向量取平均
out = F.relu(self.proj(embeds))
out = self.output(out)
nll_proj = F.log_softmax(out, dim=-1)
return nll_proj
model = CBOW(vocab_size, 10).to(device)
optimizer = optim.Adam(model.parameters(), lr=0.001)
losses = []
loss_function = nn.NLLLoss()
model.train()
# 训练循环
for epoch in tqdm(range(200)):
total_loss = 0
for context, target in data:
context_vector = make_context_vector(context, word_to_idx).to(device)
target = torch.tensor([word_to_idx[target]]).to(device)
train_predict = model(context_vector)
loss = loss_function(train_predict, target)
optimizer.zero_grad()
loss.backward()
optimizer.step()
total_loss += loss.item()
losses.append(total_loss)
print(losses)
# 测试预测
context = ['People', 'create', 'to', 'direct']
context_vector = make_context_vector(context, word_to_idx).to(device)
model.eval()
predict = model(context_vector)
print(predict)
max_idx = predict.argmax(1)
print('预测结果:', idx_to_word[max_idx.item()])
print('word_to_idx', word_to_idx)
print('CBOW embedding weight = ', model.embeddings.weight)
# 提取词向量,保存为npz
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], :]
print('word_2_vec: ', word_2_vec)
np.savez('word2vec实现.npz', file_1=W)
# 读取npz文件
data = np.load('word2vec实现.npz')
print('word2vec实现.npz: ', data[data.files[0]])
七、运行结果与观察
- 程序会先打印设备名(
cuda/mps/cpu); - 每轮训练结束会打印
losses列表,正常情况整体随 epoch 下降; - 预测阶段会输出类似
预测结果: programs的单词; - 最后打印词向量矩阵
W(形状为词表大小 × 10)并保存到word2vec实现.npz。
你可以试着:把 embedding_dim 从 10 改成 50 / 100,观察训练时间和向量表达能力的变化;或把 CONTEXT_SIZE 从 2 改成 3,感受窗口大小对预测难度的影响。
八、总结
本文核心收获:
| 知识点 | 一句话总结 |
|---|---|
.npy | Numpy 二进制格式,np.save / np.load 保存单个数组 |
.npz | ZIP 容器,np.savez 打包多个命名数组,data['键'] 读取 |
| One-hot | 词表大小维度、稀疏、无语义,不实用 |
| 词向量 | 低维稠密实数向量,语义相近的词距离更近 |
| CBOW | 用上下文词预测中心词,Embedding 权重即词向量 |
| 训练闭环 | 数据 → 模型 → 损失 → 反向传播 → 提取词向量 → .npz 落盘 |
更多推荐



所有评论(0)