一、引言:自然语言处理的核心难题 ------ 语言到数值的转换
在自然语言处理(NLP)任务中,我们面临的第一个核心问题就是:如何将人类的自然语言,转换为计算机模型可以直接识别、处理的数值?
语言是人类的抽象表达,而模型只能处理结构化的数值数据。因此,语言转换方法是所有 NLP 任务的基础,它的优劣直接决定了后续模型的效果上限。
二、传统方案:统计语言模型的局限
-
在深度学习兴起之前,主流的语言转换方法是统计语言模型 ,其中最具代表性的就是基于
CountVectorizer的词频统计方法。2.1 统计语言模型的核心思路
统计语言模型的核心逻辑非常直观:
-
对语料进行分词,统计所有不重复的词汇,构建词表;
-
用
ngram(n 元语法)对词汇进行组合,捕捉局部上下文关系; -
统计每个词汇 / 词组在文本中的出现频率,将文本转换为词向量,输入模型。
python
from sklearn.feature_extraction.text import CountVectorizer
# 示例语料
texts = ["dog cat fish","dog cat cat","fish bird", 'bird']
# 实例化模型,ngram_range=(1,2)表示1元组和2元组组合
cv = CountVectorizer(ngram_range=(1,2))
# 训练模型并转换
cv_fit=cv.fit_transform(texts)
# 输出词库
print(cv.get_feature_names_out())
# 输出词频向量
print(cv_fit.toarray())

2.2 统计语言模型的致命缺陷
尽管统计方法简单易实现,但它存在两个无法解决的核心问题:
- 参数爆炸,无法处理长序列 :当
n>3时,词汇组合的数量会呈指数级增长,导致维度灾难,完全无法在实际场景中使用; - 完全丢失语义关联:统计模型只关注词的出现频率,完全不考虑词与词之间的内在语义联系。例如,它无法理解 "cat" 和 "dog" 都是动物,"walking" 和 "running" 都是动作,无法从相似词汇中泛化语义。
因此,统计语言模型逐渐被更先进的神经语言模型所取代。
三、神经语言模型:词嵌入(Word Embedding)的诞生
为了解决统计模型的缺陷,研究者提出了 ** 词嵌入(Word Embedding)** 技术,它的核心目标是:将高维稀疏的 one-hot 编码,映射为低维稠密的语义向量,同时保留词汇的语义信息。
3.1 从 one-hot 到词嵌入:解决维度灾难
在词嵌入出现之前,最基础的文本向量化方法是one-hot编码:
- 假设词表大小为
V(例如 4960 个词),每个词对应一个长度为V的向量,其中只有一个位置为 1,其余全为 0; - 例如句子 "我爱北京天安门",分词后每个字的 one-hot 编码长度都是 4960,最终输入模型的矩阵维度为
4*4960。
one-hot 编码的问题非常明显:矩阵极度稀疏,维度灾难,且完全无语义信息。
而词嵌入通过神经网络训练,将每个高维的 one-hot 向量,映射为一个低维的稠密向量(通常为 300 维):
- 同样的句子,转换后矩阵维度仅为
4*300,维度大幅降低; - 向量中的值不再是 0 和 1,而是可以表达语义的浮点数,语义相似的词,在向量空间中的距离也更近(例如 "西瓜" 和 "苹果" 在向量空间中距离很近,而 "篮球" 和 "乒乓球" 距离更近)。
这种高维稀疏→低维稠密的转换方法,就是词嵌入(Word Embedding)。
3.2 Word2Vec:词嵌入的经典实现
2013 年,Google 团队提出了Word2Vec模型,它是词嵌入技术的里程碑式成果,包含两个经典的训练模型:
- CBOW(Continuous Bag-of-Words,连续词袋模型) :以上下文词汇预测当前词 ,即通过
w_{t-2}, w_{t-1}, w_{t+1}, w_{t+2}预测w_t; - Skip-gram :以当前词预测其上下文词汇 ,即通过
w_t预测w_{t-2}, w_{t-1}, w_{t+1}, w_{t+2}。
两者的核心结构对比如下:
|-----------|-------|--------|----------------|
| 模型 | 输入 | 输出 | 适用场景 |
| CBOW | 上下文词汇 | 中心词 | 语料量较小,训练速度快 |
| Skip-gram | 中心词 | 上下文词汇语 | 语料量较大,对稀有词效果更好 |
四、CBOW 模型的原理与训练流程
我们以代码中实现的 CBOW 模型为例,详细拆解其原理与训练过程。
4.1 CBOW 的网络结构
CBOW 是一个简单的三层神经网络:
- 输入层 :上下文词汇的 one-hot 编码,维度为
C*V(C为上下文窗口大小,V为词表大小); - 隐藏层(投影层) :通过共享权重矩阵
W_{V×N},将每个 one-hot 向量映射为N维的词向量,然后对所有上下文词向量取平均,得到1×N的向量; - 输出层 :通过权重矩阵
W'_{N×V},将投影层向量转换为1×V的向量,再通过softmax归一化,得到每个词作为中心词的概率。
4.2 CBOW 的完整训练流程
- 输入:上下文词汇的 one-hot 编码;
- 投影 :所有上下文词分别乘以共享矩阵
W_{V×N},得到各自的1×N词向量,取平均得到1×N的投影向量; - 输出 :投影向量乘以矩阵
W'_{N×V},得到1×V的输出向量; - 归一化 :通过
softmax将输出转换为概率分布; - 误差计算:用预测概率与真实标签(中心词的 one-hot 编码)计算损失;
- 反向传播 :通过梯度下降,反向更新矩阵
W和W'; - 迭代收敛 :重复上述过程,直到模型收敛,最终矩阵
W就是我们需要的词嵌入矩阵。
五、PyTorch 从零实现 CBOW 与 Word2Vec
下面我们结合代码,一步步实现 CBOW 模型,完成词向量的训练与保存。
5.1 数据准备:构建上下文 - 目标词数据集
首先,我们需要将原始语料转换为模型可训练的格式:上下文词汇作为输入,中心词作为标签。
python
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
# 1. 超参数设置
CONTEXT_SIZE = 2 # 上下文窗口大小:左右各取2个词
EMBEDDING_DIM = 10 # 词向量维度
# 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()
# 3. 构建词表与映射
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)}
# 4. 构建训练数据集:(上下文, 目标词)
data = []
for i in range(CONTEXT_SIZE, len(raw_text) - CONTEXT_SIZE):
# 取当前词左边2个 + 右边2个 作为上下文
context = [raw_text[i - 2], raw_text[i - 1], raw_text[i + 1], raw_text[i + 2]]
target = raw_text[i] # 当前词作为目标
data.append((context, target))
# 5. 上下文转张量工具函数
def make_context_vector(context, word_to_ix):
idxs = [word_to_ix[w] for w in context]
return torch.tensor(idxs, dtype=torch.long)
5.2 定义 CBOW 神经网络模型
接下来,我们定义 CBOW 的网络结构,核心是nn.Embedding层(即权重矩阵W):
python
# 设备配置:优先使用GPU/MPS加速
device = "cuda" if torch.cuda.is_available() else "mps" if torch.backends.mps.is_available() else "cpu"
print(f"使用设备: {device}")
class CBOW(nn.Module):
def __init__(self, vocab_size, embedding_dim):
super(CBOW, self).__init__()
# 词嵌入层:对应权重矩阵W,将one-hot映射为低维词向量
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):
# 对所有上下文词的词向量求和取平均
embds = sum(self.embeddings(inputs)).view(1, -1)
# 激活层
out = F.relu(self.proj(embds))
# 输出层
out = self.output(out)
# log_softmax归一化,配合NLLLoss使用
nll_prob = F.log_softmax(out, dim=-1)
return nll_prob
# 实例化模型
model = CBOW(vocab_size, EMBEDDING_DIM).to(device)
5.3 模型训练:损失函数与优化器
我们使用NLLLoss(负对数似然损失)作为损失函数,配合log_softmax实现多分类,用 Adam 优化器更新参数:
python
# 优化器与损失函数
optimizer = optim.Adam(model.parameters(), lr=0.001)
loss_function = nn.NLLLoss()
# 训练循环
losses = []
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)
if (epoch + 1) % 20 == 0:
print(f"Epoch {epoch+1}/200, Loss: {total_loss:.4f}")
5.4 模型测试与词向量提取
训练完成后,我们可以测试模型的预测效果,并提取最终的词嵌入矩阵:
python
# 测试:用上下文预测中心词
context = ['People', 'create', 'to', 'direct']
context_vector = make_context_vector(context, word_to_idx).to(device)
model.eval()
with torch.no_grad():
predict = model(context_vector)
max_idx = predict.argmax(1).item()
print(f"上下文: {context}, 预测中心词: {idx_to_word[max_idx]}")
# 提取词嵌入矩阵
print("CBOW词嵌入权重:")
print(model.embeddings.weight)
# 转换为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], :]
# 保存词向量为npz文件,方便后续使用
np.savez('word2vec实现.npz', word_vectors=W, word_to_idx=word_to_idx)
# 加载验证
data = np.load('word2vec实现.npz', allow_pickle=True)
print("保存的文件内容:", data.files)
六、补充:损失函数与 softmax 的原理
在多分类任务中,我们使用softmax交叉熵损失来计算模型误差,核心公式如下:

softmax的作用是将模型的原始输出转换为 0-1 之间的概率分布,保证所有类别概率之和为 1;- 取
-log的原因是:当预测正确时,概率p接近 1,-log(p)接近 0,损失值小;当预测错误时,概率p接近 0,-log(p)趋近于无穷大,损失值大,完美符合损失函数的需求。
八、完整代码
python
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
EMBEDDING_DIM = 10
# 加载语料
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)}
# 构建数据集
data = []
for i in range(CONTEXT_SIZE, len(raw_text) - CONTEXT_SIZE):
context = [raw_text[i-2], raw_text[i-1], raw_text[i+1], raw_text[i+2]]
target = raw_text[i]
data.append((context, target))
# 上下文转张量
def make_context_vector(context, word_to_ix):
idxs = [word_to_ix[w] for w in context]
return torch.tensor(idxs, dtype=torch.long)
# 设备配置
device = "cuda" if torch.cuda.is_available() else "mps" if torch.backends.mps.is_available() else "cpu"
print(f"使用设备: {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):
embds = sum(self.embeddings(inputs)).view(1, -1)
out = F.relu(self.proj(embds))
out = self.output(out)
return F.log_softmax(out, dim=-1)
# 实例化模型
model = CBOW(vocab_size, EMBEDDING_DIM).to(device)
# 优化器与损失函数
optimizer = optim.Adam(model.parameters(), lr=0.001)
loss_function = nn.NLLLoss()
# 训练
losses = []
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)
# 前向传播
pred = model(context_vector)
loss = loss_function(pred, target)
# 反向传播
optimizer.zero_grad()
loss.backward()
optimizer.step()
total_loss += loss.item()
losses.append(total_loss)
if (epoch + 1) % 20 == 0:
print(f"Epoch {epoch+1}/200, Loss: {total_loss:.4f}")
# 测试
context = ['People', 'create', 'to', 'direct']
context_vector = make_context_vector(context, word_to_idx).to(device)
model.eval()
with torch.no_grad():
pred = model(context_vector)
max_idx = pred.argmax(1).item()
print(f"\n测试结果:上下文{context},预测中心词:{idx_to_word[max_idx]}")
# 提取词向量
W = model.embeddings.weight.cpu().detach().numpy()
word_2_vec = {word: W[word_to_idx[word], :] for word in word_to_idx.keys()}
# 保存词向量
np.savez('word2vec实现.npz', word_vectors=W, word_to_idx=word_to_idx)
data = np.load('word2vec实现.npz', allow_pickle=True)
print("\n词向量保存成功,文件内容:", data.files)
