一、前言

很多刚入门深度学习的同学,面对的第一个疑问往往是:

“单词怎么能变成数字喂给模型?”
“训练好的词向量怎么保存下来,下次直接用?”

本文就用两个非常经典的入门素材,把这条链路完整走一遍:

  1. Numpy 数组的保存与读取.npy / .npz):深度学习里几乎所有中间产物(词向量、特征矩阵、预处理结果)都要落盘,这是基本功;
  2. 用 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]

它的两个致命缺点:

  1. 维度爆炸:词表 10 万词就要 10 万维向量,非常稀疏、非常占内存;
  2. 没有任何语义信息:任意两个词的向量都正交,“猫"和"狗”、"猫"和"冰箱"在 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

整体流程:

  1. 把上下文词转成索引,送进 nn.Embedding 查表,得到各自的词向量;
  2. 对上下文词向量取平均,得到一个"上下文表示";
  3. 经过隐藏层 Linear(10, 128) + ReLU 做非线性变换(这是本实现相对经典 CBOW 的一个变体:经典 CBOW 平均后直接接输出层);
  4. 输出层 Linear(128, vocab_size) 得到词表大小的分数,再过 log_softmax 变成每个词的对数概率;
  5. 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]])

三步走,非常经典:

  1. model.embeddings.weight 拿到 Embedding 权重矩阵;.cpu() 移到 CPU、.detach() 从计算图分离、.numpy() 转成 ndarray(在 GPU 上的张量不能直接转 numpy);
  2. word_to_idx 把每一行向量对应回单词,构建 word_2_vec 字典——这就是最终的"词 → 词向量"映射;
  3. 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,感受窗口大小对预测难度的影响。


八、总结

本文核心收获:

知识点一句话总结
.npyNumpy 二进制格式,np.save / np.load 保存单个数组
.npzZIP 容器,np.savez 打包多个命名数组,data['键'] 读取
One-hot词表大小维度、稀疏、无语义,不实用
词向量低维稠密实数向量,语义相近的词距离更近
CBOW用上下文词预测中心词,Embedding 权重即词向量
训练闭环数据 → 模型 → 损失 → 反向传播 → 提取词向量 → .npz 落盘

更多推荐