ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

PyTorch复现CBOW词向量:从数据管道到训练避坑全记录

PyTorch复现CBOW词向量:从数据管道到训练避坑全记录

简介:面向自然语言处理入门者与PyTorch实践者的词向量模型资源包,聚焦词嵌入基础原理与实际应用。通过练习小型英文语料(1803词)训练CBOW或Skip-gram模型,帮助理解将词汇映射为高维向量、捕捉语义关系的完整流程。资源共6个文件,其中3个py文件分别负责数据集读取、模型定义与主训练流程,2个pyc为预编译缓存,1个txt为原始文本数据,整体压缩包仅10KB,轻量易上手。包内代码覆盖文本预处理、词汇表构建、One-Hot编码输入、MLP结构及交叉熵损失优化等关键环节,并附有PyTorch自动梯度训练示例,便于读者直接运行和修改参数。已有3110人学习,适合用于课程实验、自学实践或作为NLP项目的基础工具脚本,读者可从中掌握数据预处理、模型搭建和词向量评估的完整思路。

1. 词向量模型:从稀疏编码到语义嵌入,PyTorch 复现的价值

做自然语言处理时,最烦的第一件事就是文本表示。拿中文分词后的一万个词去 one-hot,每条样本就有一万维,而且大部分是零,计算量扛不住,词之间也没有任何语义关系。真正让模型能理解"国王"和"王后"接近的,是分布式词向量。基于 PyTorch 实现词向量模型,不只是调一个接口,而是要亲手把训练数据、网络结构、损失函数和采样策略串起来,这样后面做文本分类、序列标注或对话系统时,你才清楚 embedding 层到底在学什么。这篇笔记从原理到代码,再到训练完怎么验证效果,把我在一个模拟项目里复现 CBOW 模型的完整过程写出来,新手能照着跑,熟手能避开几个常见的坑。

2. 模型选型和数据准备:为什么用 CBOW 而不是 Skip-gram

2.1 从 one-hot 到分布式表示:词向量要解决的核心问题

传统的 one-hot 编码把每个词表示成词典长度的向量,某个位置是 1,其余是 0。这在人工智能和机器学习入门时很直观,但实际用起来有三个要命的问题:一是维度灾难,十万词就要十万维的稀疏向量,内存和算力都浪费;二是词汇鸿沟,任何两个词的 one-hot 内积都是 0,模型完全学不到"猫"和"狗"都是宠物这种关系;三是参数爆炸,如果把这个表示直接接到神经网络里,第一层权重矩阵就是词典大小乘隐藏层大小,直接会让训练失控。

词向量(word embedding)的思路完全不同,它把每个词映射成一个低维稠密向量,比如 100 或 300 维,让模型在训练过程中自动把语义相近的词放到向量空间相近的位置。这种分布式表示的核心假设是"语义相近的词,其上下文也相似",也就是著名的分布假说。你用 PyTorch 的nn.Embedding就能实现最简单的可训练词向量,但真正有价值的词向量模型必须让这个词表参与上下文预测任务,让损失函数把语义逼出来。

2.2 CBOW 和 Skip-gram 的本质区别与选型依据

词向量模型里最常见的是 Word2vec 的两种结构。CBOW 是"根据上下文词预测中心词",Skip-gram 是"根据中心词预测上下文词"。两者看起来对称,但实际行为不一样。CBOW 训练速度快,对高频词效果好,适合语料比较大但你只想快速得到词向量表示的项目;Skip-gram 对低频词更友好,能捕捉更丰富的语义,但训练时间明显更长。

我这次在模拟项目 X 里用的是 CBOW,因为语料是几万条电商评论,高频词多,而且我需要快速迭代看效果。如果你要处理的是专业领域的小语料,比如法律文书或医疗病历,低频术语占比高,那 Skip-gram 更合适。选型时还要考虑负采样:这两种结构都能配合负采样来加速训练,本质上是把多分类 softmax 变成二分类,避免每次都要计算整个词典的概率分布。

2.3 数据预处理:分词、构建词表和上下文窗口

确定了模型结构,下一步是准备数据。我用的是 Python 加 jieba 做中文分词,但这里有个容易翻车的点:分词粒度直接影响词表质量。之前我在一个项目里直接用默认词典切,结果"不"和"好"被切开,导致"不好"的语义被拆散。我一般会先加载一个领域自定义词典,把常见品牌名、专业术语先固定。

import jieba from collections import Counter corpus = [ "这款手机屏幕清晰,电池耐用", "电池续航一般,屏幕偏暗", "物流很快,包装严实" ] def tokenize(text): # 加载自定义词典后,用精确模式分词 return [w for w in jieba.cut(text) if w.strip()] sentences = [tokenize(sent) for sent in corpus] word_counts = Counter(w for sent in sentences for w in sent) vocab = {word: i for i, (word, _) in enumerate(word_counts.items())} print(vocab) # 输出类似 {'这款': 0, '手机': 1, '屏幕': 2, '清晰': 3, ...}

这段代码做了三件事:分词、统计词频、构建词到索引的映射。注意vocab的构建顺序是按词频降序,这样高频词获得小索引,后面做负采样时方便按词频做权重。这里有个参数细节:要不要过滤超低频词?我一般把出现次数少于 5 的词直接丢弃,否则词表太大会让负采样和输出层计算都变慢。如果你语料很小,可以把阈值降到 3,这需要根据实际数据量调节。

2.4 生成训练样本:滑窗采样与正负样本

CBOW 的训练样本是"上下文词序列 -> 中心词"。用一个窗口大小为window_size的滑动窗口遍历每个句子,窗内的词作为上下文,中间位置词作为标签。这里有个隐藏的参数:如果窗口大小为 5,是上下文各取 5 个词,还是总共 5 个词?我踩过坑,这里用代码说明。

def generate_cbow_samples(sentences, vocab, window_size=2): samples = [] for sent in sentences: token_ids = [vocab[w] for w in sent if w in vocab] for i, center in enumerate(token_ids): context = [] # 从左边取 window_size 个,右边取 window_size 个 for j in range(max(0, i-window_size), i): context.append(token_ids[j]) for j in range(i+1, min(len(token_ids), i+window_size+1)): context.append(token_ids[j]) if len(context) > 0: samples.append((context, center)) return samples samples = generate_cbow_samples(sentences, vocab, window_size=2) print(samples[:2]) # [([1, 2, 0], 3), ...] 上下文索引列表和中心词索引

注意这里窗口是左右各window_size个词,但实际代码里左边界用了max(0, i-window_size),右边界用了min(len(token_ids), i+window_size+1),这是为了防止句子开头和结尾越界。很多人喜欢用固定步长采样,但标准做法是每个中心词都生成一个样本,这样训练更充分。另外,如果上下文长度不足窗口大小,直接跳过,不要补零,因为补零会让模型学到"填充词"的虚假特征。

3. 构建 PyTorch 数据管道:从 Python 列表到可训练的 Tensor

3.1 数据集的必要封装:Dataset 与 DataLoader 的配合

直接用 Python 列表打循环训练当然可以,但 PyTorch 的DataLoader能自动做 batch、乱序和内存管理。我习惯把样本封装成Dataset子类,这样代码结构清晰,而且后期要换成负采样或动态生成样本也能无缝衔接。

import torch from torch.utils.data import Dataset, DataLoader class CbowDataset(Dataset): def __init__(self, samples): # samples 是 (context_ids, center_id) 的列表 self.contexts = torch.LongTensor([s[0] for s in samples]) self.centers = torch.LongTensor([s[1] for s in samples]) def __len__(self): return len(self.contexts) def __getitem__(self, idx): return self.contexts[idx], self.centers[idx]

这里我把contexts直接构造成LongTensor,因为nn.Embedding要求索引是整数类型。新手常犯的错误是把索引转成FloatTensor,然后模型直接报类型不匹配。另一个细节是:上下文长度可能不一致,所以如果不用 PyTorch 的自动 padding,就必须保存可变长度列表,在collate_fn里手动处理。我这里生产样本时已经固定了左右窗口,但窗口边缘的样本长度会小于2*window_size,所以需要一个collate_fn。

3.2 用 collate_fn 处理变长上下文

标准的DataLoader要求每个 batch 内张量形状一致,但 CBOW 的上下文长度天然是变长的。有两种解法:一是把所有上下文都截取到固定长度,比如只取前后各window_size个词,如果不够就随机重复或忽略;二是用collate_fn动态堆叠。我倾向第二种,因为不引入无效填充。

def collate_batch(batch): context_list, center_list = [], [] max_len = max(len(c) for c, _ in batch) for context, center in batch: # 简单方式:把短序列左边补 0,右边也补 0(这里用 -1 标记) padded = context + [-1] * (max_len - len(context)) context_list.append(padded) center_list.append(center) ctx = torch.LongTensor(context_list) ctr = torch.LongTensor(center_list) return ctx, ctr dataloader = DataLoader(CbowDataset(samples), batch_size=32, shuffle=True, collate_fn=collate_batch)

如果你用 -1 做 padding,模型里的nn.Embedding就会去查索引 -1,直接报错。所以要么用 0 且词典从 1 开始索引,把 0 留给填充符,要么在 embedding 之前把 -1 替换成 0。我一般让 vocabulary 里保留一个pad_token=0,所有正常词的索引从 1 开始。这个细节后面避坑章会再提。

3.3 负采样样本生成:不能只在数据预处理时做

CBOW 最大的加速点在于负采样,也就是正样本是"上下文+中心词"组合,负样本是"上下文+随机词"组合。模型要做的不是预测整个词典概率,而是判断一个词是不是真实中心词。负采样一般在训练时动态生成,而不是预先生成所有负样本,因为负样本数量巨大,预生成会撑爆内存。

import random def generate_negative_samples(vocab_size, num_neg, unigram_dist): # 从 unigram 分布中采样 num_neg 个负样本词索引 return random.choices(range(vocab_size), weights=unigram_dist, k=num_neg)

之所以用 unigram 分布,而不是均匀分布,是因为高频词作为负样本出现概率更高,可以让模型更快学会"这些高频词不是当前上下文的目标"。实际代码里我通常用unigram_dist = word_counts的 0.75 次方作为权重,这是 Word2vec 论文里的技巧,能平衡高采样频词但不过度抑制低频词。这里的random.choices会在每次迭代时生成,速度也够快。

4. 模型定义与训练循环:从 Embedding 到二元交叉熵损失

4.1 CBOW 网络结构:两个 Embedding 矩阵与向量平均

PyTorch 里实现 CBOW 最直接的方式是让上下文中的每个词查一个共享的nn.Embedding,然后把得到的向量求和或取平均,再和中心词向量做匹配。注意这里有两个 embedding:一个是中心词 embedding,一个是上下文词 embedding,权重不共享。原因是预测任务需要区分"作为上下文"和"作为目标"两种角色。

import torch.nn as nn import torch.nn.functional as F class CBOW(nn.Module): def __init__(self, vocab_size, embed_dim): super().__init__() self.embed_v = nn.Embedding(vocab_size, embed_dim) # 中心词 self.embed_u = nn.Embedding(vocab_size, embed_dim) # 上下文词 self.out_proj = nn.Linear(embed_dim, 1, bias=False) # 用于打分 def forward(self, context, center): # context: (batch, seq_len), center: (batch, 1) 或 (batch,) context_emb = self.embed_u(context) # (batch, seq_len, embed_dim) # 把所有上下文向量相加,再除以实际长度(平均) context_vec = context_emb.mean(dim=1) # (batch, embed_dim) center_emb = self.embed_v(center) # (batch, embed_dim) # 内积+全连接输出分数 score = self.out_proj(context_vec).squeeze(1) + torch.sum(context_vec * center_emb, dim=1) return score

这里我用了两种打分方式:一个线性层加内积,其实一般情况下直接用内积就行。上面的写法是为了演示,但实际项目中你只需要score = torch.sum(context_vec * center_emb, dim=1),更干净。注意mean代替sum,因为上下文长度不同,平均可以避免长上下文对输出贡献过大。

4.2 用二元交叉熵实现负采样训练

有了正样本和负样本,我们不再是多分类 softmax,而是训练一个二分类器。正样本的标签是 1,负样本的标签是 0。PyTorch 里可以用F.binary_cross_entropy_with_logits,它内部封装了 sigmoid 和交叉熵,数值上更稳定。

def train_step(model, optimizer, batch, num_neg, vocab_size, unigram_dist): context, center = batch batch_size = context.size(0) # 构造正负样本标签:前半是正样本,后半是负样本 pos_score = model(context, center).view(-1) # (batch,) neg_context = context.repeat_interleave(num_neg, dim=0) # 每个上下文复制 num_neg 次 neg_center = torch.LongTensor(generate_negative_samples(vocab_size, batch_size * num_neg, unigram_dist)) neg_score = model(neg_context, neg_center).view(batch_size, -1) # (batch, num_neg) # 正样本 loss 和负样本 loss 结合 pos_loss = F.binary_cross_entropy_with_logits(pos_score, torch.ones_like(pos_score)) neg_loss = F.binary_cross_entropy_with_logits(neg_score, torch.zeros_like(neg_score)) loss = pos_loss + neg_loss optimizer.zero_grad() loss.backward() optimizer.step() return loss.item()

这段代码有几个关键点。repeat_interleave让负采样能用 batch 向量运算,避免写循环。generate_negative_samples每次生成batch_size * num_neg个负中心词。负样本的上下文和正样本一样,只是中心词被换成了随机词,这样模型才能学到"这个上下文下面出现某个词的概率"。如果你的num_neg太小,模型区分能力差;太大,负样本主导 loss。我通常用 5 到 15,具体根据词典大小调整。

4.3 训练循环与模型保存:警惕 loss 不下降的问题

训练词向量时,loss 曲线下降很慢是常态,但不会完全不动。我一般跑 20 到 50 个 epoch,每轮记录平均 loss,并每隔几轮打印一次。如果 loss 从第一轮开始就一直是同一个量级,检查学习率和数据顺序。

model = CBOW(vocab_size=len(vocab), embed_dim=100) optimizer = torch.optim.Adam(model.parameters(), lr=0.01) epochs = 20 for epoch in range(epochs): total_loss = 0 for batch in dataloader: loss = train_step(model, optimizer, batch, num_neg=5, vocab_size=len(vocab), unigram_dist=unigram_dist) total_loss += loss print(f"Epoch {epoch+1}, Loss: {total_loss / len(dataloader):.4f}") # 每个 epoch 结束后保存一次 torch.save(model.state_dict(), f"cbow_epoch_{epoch+1}.pth")

这里unigram_dist是提前算好的列表,我在前面 3.3 节中用它来生成负样本。注意 Adam 的学习率我一般设 0.01 起步,如果数据量小可以到 0.001。保存模型时别只保存 embedding 权重,最好把整个 state_dict 保存,之后加载时还要把 vocab 和 id 映射也一并存个 json,不然你拿到向量也不知道对应哪个词。

4.4 关于训练时间与资源:什么时候该用多进程数据加载

如果你的语料不是几万条而是几千万条,数据管道会变成瓶颈。DataLoader的num_workers=4或8可以并行加载和生成样本,但要注意在 Windows 下多进程会出现EOFError,这时可以在if __name__ == '__main__'里包住训练脚本。另一个优化是预先把上下文词索引存成numpy数组,再转换为 tensor,减少每个 epoch 的重复 tokenize 开销。我在模拟项目里就是这么做的,训练速度提升了三倍。

5. 避坑指南:词向量训练里最常见的五个翻车现场

5.1 现象:loss 快速下降但某些词向量始终是随机值

这是未登录词问题。如果你的中心词索引在nn.Embedding里没有被训练,大概率是负采样时生成的负样本索引超出了词典范围,或者你把 padding 索引也算进了负采样。解决方法是负采样前过滤掉pad_token和低频词索引。另外,检查词汇表里有没有保留单字词,单字词很难被上下文预测准确,因为能提供的信息太少。我一般是把出现次数低于 2 的词直接删掉。

5.2 现象:训练结果里相似词的余弦相似度全部接近 1

这是向量空间坍缩的典型症状。原因是模型把所有词都推到了一个小范围,通常是学习率太大或者初始化范围不对。nn.Embedding默认初始化是均匀分布,范围很小,但如果你手动初始化成正态分布方差过大,训练初期就容易崩。我试过把 embedding 的init_range设为0.5 / embed_dim的一半,效果更稳定。另外,检查一下 loss 是否降到非常低,低到过拟合也可能让向量趋同。

5.3 现象:loss 变成 NaN

NaN 几乎都来自梯度爆炸。原因可能是分数计算中使用了大数相加,或者负采样个数太多导致 loss 值过大。常见做法是给分数乘一个缩放系数,比如score * 0.1,或者加大学习率衰减。我用 PyTorch 时喜欢在每次优化器更新前加一个torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm=5),这一行就能避免大多数 NaN。

5.4 现象:同一句话的不同切分导致词向量完全不同

这是中文分词不一致造成的。比如"人工智能"在一条样本里被切成一个词,在另一条里被切成"人工"和"智能"。你最后拿到的向量是"人工"而不是"人工智能",语义自然不对。我为此专门维护一个自定义词典,并且把语料里所有分词后的词再统计一遍,强制让共现频次高的词合并成词组。这个方法虽然简单,但效果非常明显。

5.5 现象:加载保存的 embedding 时维度 mismatch

保存的是state_dict,但加载时模型结构变了,比如没传入embed_dim。这个坑发生在我把训练和加载分成两个项目时。解药是把你训练时的超参数和词汇表一起保存下来,加载时重建模型再load_state_dict。我通常在项目里建一个配置字典,包括embed_dim、window_size、num_neg、vocab_size,然后序列化成 json,这样任何机器都能复现。

6. 进阶验证:用类比推理和可视化确认模型真的学到了语义

6.1 类比推理测试:A-B=C-D

训练完词向量,最直观的验证是词类比。例如"国王 - 男人 + 女人 ≈ 王后"。在中文语料里,我试过"北京 - 中国 + 日本 ≈ 东京"这样的地理类。测试代码很简单,先归一化所有 embedding,然后对目标词向量做加减,再从余弦相似度最高的词里排除查询词本身。

def analogy(model, vocab, word_a, word_b, word_c, top_k=10): ids = [vocab[w] for w in [word_a, word_b, word_c]] emb = model.embed_v.weight.data # (vocab_size, embed_dim) # 归一化 emb_norm = F.normalize(emb, dim=1) vec = emb_norm[ids[0]] - emb_norm[ids[1]] + emb_norm[ids[2]] scores = emb_norm @ vec _, ids_sorted = torch.topk(scores, top_k + 1) # 过滤掉输入词自身 result = [vocab_rev[i.item()] for i in ids_sorted if i.item() not in ids][:top_k] return result

这个测试能直接告诉你模型学到了多少语义关系。如果结果完全不相关,先别急着调模型,回头看你训练时是否 用了正确的上下文窗口。注意这里要vocab_rev,也就是索引到词的反向映射,保存模型时一定要存这个。

6.2 可视化:用 TSNE 把 100 维向量压到 2 维

另一个验证手段是降维可视化。把训练后的 embedding 拿去跑sklearn.manifold.TSNE,然后画散点图,观察语义相近的词是否聚在一起。我用这个方法发现高频数字和量词挤在一堆,而情感极性词分布在两端的边界上。可视化不是必须的,但当你怀疑模型没有学好时,它比 loss 曲线直观得多。

从那以后我每次训练完词向量,都会强制走一遍"词类比 + TSNE"的验证流程,不再只盯着 loss 数字。这个习惯帮我省去了很多调参时间。如果你也在复现这类模型,希望这篇笔记能让你少踩几个我曾经翻过的坑。

本文还有配套的精品资源,点击获取

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进