ARTICLE · INTELLIGENCE

战地情报 · 详情页

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

PyTorch实战:中英文混合语料CBOW词向量训练指南

PyTorch实战:中英文混合语料CBOW词向量训练指南

简介:这份资源面向自然语言处理初学者与课程实践者,围绕中英文语料训练CBOW模型获取词向量这一任务展开,适合正在学习word2vec原理、需要动手复现词向量训练流程的同学参考。压缩包共12个文件,约13.58MB,以txt语料与词向量输出文件为主,辅以xml工程配置、py训练脚本及gitignore等辅助文件,其中脚本可同时处理中文与英文语料,data目录存放中英文语料及中文停用词,output目录给出训练后的中英文词向量结果。使用时只需在脚本中确定训练中文或英文,直接运行即可完成训练。目前已有2693人学习下载,读者可借此理解CBOW的输入输出构造、停用词处理与词向量文件生成方式,并对照输出结果验证训练效果,为后续文本分类、相似度计算等任务打下基础。

1. 中英文语料训练 CBOW 模型:从零拿到可用的词向量

如果你手头有一批中英文混合的文本——比如产品评论、客服对话、双语工单——想快速拿到能算相似度、能做聚类、能喂给下游分类器的词向量,CBOW 往往是最先值得试的模型。它不像 BERT 那样吃显存,也不像 Word2Vec 的 skip-gram 那样在低频词上磨蹭,训练快、代码短、效果对多数业务场景够用。但真正动手时,坑集中在三处:中文要不要分词、窗口怎么滑、负采样怎么配。这篇笔记按“先跑通再调优”的顺序,把 PyTorch 实现 CBOW 的完整路径拆开,每一步都给可复制的代码和参数解释,目标是让你在自己的中英文语料上当天拿到第一版词向量。

2. CBOW 到底在学什么:输入输出与选型理由

2.1 从一句话看懂 CBOW 的训练目标

CBOW(Continuous Bag-of-Words)的核心假设很朴素:一个词的语义由它周围的词决定。给定窗口内的上下文词,预测中心词。比如句子“模型 训练 需要 大量 语料”,窗口为 2 时,用“模型 训练 大量 语料”去预测“需要”。模型学到的不是这个预测任务本身,而是训练过程中产生的词嵌入矩阵——那才是我们要的词向量。

和 skip-gram 的区别值得说清楚:skip-gram 是用中心词预测上下文,低频词表现更好但训练慢;CBOW 是把上下文词向量求平均后预测中心词,训练快、高频词更准。中英文混合语料里,中文分词后的词频分布往往比英文更集中,CBOW 的平滑效果反而占优。如果你的语料里长尾专有名词特别多,再考虑换 skip-gram。

2.2 为什么选 PyTorch 而不是 gensim

gensim 的 Word2Vec 确实三行代码就能跑,但它的封装太厚,窗口滑动、负采样、动态学习率这些细节你改不动。用 PyTorch 手写 CBOW,你能控制:

  • 中文分词后如何拼接英文 token
  • 负采样表怎么建、采样几个负例
  • 嵌入维度、窗口大小、最小词频的联动
  • 训练中途如何保存检查点、如何做增量训练

代价是代码量从 3 行变成 150 行左右,但换来的是可调试、可复现、可迁移到自定义损失函数。对于要落地到生产环境的场景,这个交换是值得的。

2.3 中英文混合语料的预处理边界

中英文混在一起时,最容易被忽略的是 token 化策略。常见做法是:

  • 中文用 jieba 分词,英文用空格和标点切分
  • 数字统一替换为<NUM>,避免“2024”和“2025”被当成无关词
  • 英文统一转小写,中文不做大小写处理
  • 标点保留为独立 token 还是丢弃,取决于下游任务;做相似度计算时建议丢弃

这里有一个血泪经验:如果中文分词后不把英文单词单独切出来,jieba 会把“PyTorch实现”切成一个整体,导致“PyTorch”和“实现”的向量都学不好。正确做法是先用正则把英文片段抽出来,再对中文片段做 jieba 分词,最后按原顺序合并。

3. 从原始文本到训练样本:预处理与词表构建

3.1 中英文混合分词脚本

import re import jieba from collections import Counter def tokenize_mixed(text): """ 中英文混合分词: 1. 用正则把英文单词、数字、中文片段分开 2. 中文片段走 jieba,英文片段直接保留 3. 统一转小写,数字替换为 <NUM> """ # 先按英文单词/数字/中文块切分 pattern = re.compile(r'([a-zA-Z]+|\d+|[^\x00-\x7F]+)') tokens = [] for match in pattern.finditer(text): seg = match.group() if re.match(r'[a-zA-Z]+', seg): tokens.append(seg.lower()) elif re.match(r'\d+', seg): tokens.append('<NUM>') else: # 中文片段走 jieba tokens.extend(jieba.lcut(seg)) # 过滤空白和单字符标点 tokens = [t.strip() for t in tokens if t.strip() and not re.match(r'^[^\w<>]+$', t)] return tokens # 示例 text = "PyTorch实现CBOW模型,训练2024年的语料" print(tokenize_mixed(text)) # 输出类似:['pytorch', '实现', 'cbow', '模型', '训练', '<NUM>', '年', '的', '语料']

这段代码的关键在正则的[^\x00-\x7F]+,它匹配所有非 ASCII 字符,也就是中文块。英文和数字被单独捕获后分别处理。注意<NUM>这个特殊 token 要加入词表,否则会被当成未知词。

参数说明:jieba.lcut返回列表,比jieba.cut生成器更方便后续统计。如果你有自定义词典,在jieba.lcut之前调用jieba.load_userdict加载。

3.2 构建词表与最小词频过滤

def build_vocab(tokenized_corpus, min_freq=5): """ 统计词频,过滤低频词,建立词到索引的映射 """ counter = Counter() for tokens in tokenized_corpus: counter.update(tokens) # 保留频率 >= min_freq 的词 vocab = [word for word, freq in counter.items() if freq >= min_freq] # 特殊 token vocab = ['<PAD>', '<UNK>'] + sorted(vocab) word2idx = {word: idx for idx, word in enumerate(vocab)} idx2word = {idx: word for word, idx in word2idx.items()} # 计算负采样用的词频分布(取 0.75 次方) freqs = [counter[word] ** 0.75 for word in vocab[2:]] total = sum(freqs) neg_probs = [f / total for f in freqs] return word2idx, idx2word, neg_probs # 假设 tokenized_corpus 是分词后的列表的列表 # word2idx, idx2word, neg_probs = build_vocab(tokenized_corpus, min_freq=5)

min_freq=5是经验值:语料小于 10 万词时调到 2 或 3,大于 100 万词时调到 10。负采样概率用 0.75 次方是 Word2Vec 原论文的做法,能压低高频词被采样的概率,让低频词有机会被更新。

3.3 生成 CBOW 训练对

def generate_cbow_pairs(tokenized_corpus, word2idx, window_size=5): """ 生成 (上下文索引列表, 中心词索引) 对 上下文取中心词左右各 window_size 个词,不足则截断 """ pairs = [] unk_idx = word2idx['<UNK>'] for tokens in tokenized_corpus: indices = [word2idx.get(t, unk_idx) for t in tokens] for center in range(len(indices)): left = max(0, center - window_size) right = min(len(indices), center + window_size + 1) context = indices[left:center] + indices[center+1:right] if len(context) == 0: continue pairs.append((context, indices[center])) return pairs # pairs = generate_cbow_pairs(tokenized_corpus, word2idx, window_size=5)

窗口大小 5 意味着左右各 5 个词,总共 10 个上下文词。中英文混合语料里,中文句子通常比英文短,窗口 5 可能覆盖整句;英文长句则只覆盖局部。如果语料以短文本为主,窗口调到 3 更合适。

4. PyTorch 实现 CBOW:模型、负采样与训练循环

4.1 模型定义与负采样损失

import torch import torch.nn as nn import torch.optim as optim import numpy as np class CBOWModel(nn.Module): def __init__(self, vocab_size, embed_dim): super().__init__() # 输入嵌入:上下文词 -> 向量 self.in_embed = nn.Embedding(vocab_size, embed_dim) # 输出嵌入:用于计算得分 self.out_embed = nn.Embedding(vocab_size, embed_dim) # 初始化 init_range = 0.5 / embed_dim self.in_embed.weight.data.uniform_(-init_range, init_range) self.out_embed.weight.data.uniform_(-init_range, init_range) def forward(self, context, target, neg_samples): """ context: (batch, context_len) 上下文词索引 target: (batch,) 中心词索引 neg_samples: (batch, n_neg) 负采样词索引 """ # 上下文词向量求平均 ctx_vec = self.in_embed(context).mean(dim=1) # (batch, embed_dim) # 正样本得分 target_vec = self.out_embed(target) # (batch, embed_dim) pos_score = torch.sum(ctx_vec * target_vec, dim=1) # (batch,) pos_loss = -torch.log(torch.sigmoid(pos_score) + 1e-10) # 负样本得分 neg_vec = self.out_embed(neg_samples) # (batch, n_neg, embed_dim) neg_score = torch.bmm(neg_vec, ctx_vec.unsqueeze(2)).squeeze(2) # (batch, n_neg) neg_loss = -torch.sum(torch.log(torch.sigmoid(-neg_score) + 1e-10), dim=1) return (pos_loss + neg_loss).mean()

负采样损失的本质是把多分类问题转成二分类:正样本对打高分,负样本对打低分。1e-10是防止 log(0) 的数值稳定项。in_embed和out_embed分开是 Word2Vec 的标准做法,训练完后通常取in_embed作为词向量,也有把两者相加或拼接的变体。

4.2 批处理与负采样表

def collate_batch(pairs, neg_probs, n_neg=5): """ 把一个 batch 的 (context, target) 转成张量,并采样负例 """ contexts, targets = zip(*pairs) # 上下文 padding 到同一长度 max_len = max(len(c) for c in contexts) context_tensor = torch.zeros(len(contexts), max_len, dtype=torch.long) for i, c in enumerate(contexts): context_tensor[i, :len(c)] = torch.tensor(c, dtype=torch.long) target_tensor = torch.tensor(targets, dtype=torch.long) # 负采样:按 neg_probs 分布采样 neg_tensor = torch.multinomial( torch.tensor(neg_probs, dtype=torch.float), num_samples=len(targets) * n_neg, replacement=True ).view(len(targets), n_neg) return context_tensor, target_tensor, neg_tensor # 训练循环 def train_cbow(pairs, vocab_size, embed_dim=128, batch_size=512, epochs=5, lr=0.001): model = CBOWModel(vocab_size, embed_dim) optimizer = optim.Adam(model.parameters(), lr=lr) for epoch in range(epochs): np.random.shuffle(pairs) total_loss = 0 for i in range(0, len(pairs), batch_size): batch = pairs[i:i+batch_size] ctx, tgt, neg = collate_batch(batch, neg_probs, n_neg=5) optimizer.zero_grad() loss = model(ctx, tgt, neg) loss.backward() optimizer.step() total_loss += loss.item() print(f"Epoch {epoch+1}, Loss: {total_loss / (len(pairs)//batch_size):.4f}") return model

n_neg=5是负采样个数,语料越大可以调到 10 到 15。batch_size=512在 16GB 显存上跑 128 维嵌入没问题,如果显存小就降到 128。Adam 的学习率 0.001 是安全起点,训练 loss 不降时先检查负采样表是否归一化。

4.3 训练参数怎么调:一张表说清

参数推荐范围影响调整信号
embed_dim64~256维度越高表达力越强,但过拟合风险大相似度任务差就升到 200
window_size3~10窗口大捕捉主题相似,窗口小捕捉功能相似短文本用 3,长文档用 8
min_freq2~10过滤低频词,减少噪声词表超过 10 万就调高
n_neg5~15负例越多训练越慢但更准loss 震荡就降到 5
batch_size128~1024影响梯度稳定性显存不够就减半
lr0.0005~0.005太大不收敛,太小收敛慢loss 不降就乘 0.5

这张表建议打印出来贴在显示器边上,调参时逐项改,不要一次动三个以上。

5. 避坑与排查:中英文 CBOW 训练中的五个翻车现场

5.1 中文分词把英文单词切碎

现象:训练出的词向量里,“pytorch”和“torch”相似度极低,甚至“pytorch”本身找不到。

原因:jieba 默认词典不含英文技术词,会把“PyTorch”切成“Py”和“Torch”,或者直接按字符切。

解决:在分词前用正则把英文片段整体抽出,如 3.1 节代码所示。另外把领域英文词加入 jieba 自定义词典,调用jieba.add_word('pytorch')。

5.2 负采样表未归一化导致采样崩溃

现象:训练几个 batch 后 loss 变成 nan。

原因:torch.multinomial要求输入是概率分布,如果neg_probs里有负数或总和不为 1,会采样出非法索引。

解决:构建neg_probs后加一行neg_probs = [max(p, 1e-10) for p in neg_probs],再除以总和。另外确保<PAD>和<UNK>不参与负采样。

5.3 窗口滑动时上下文为空

现象:某些短句(如只有一个词)生成训练对时 context 为空列表,导致mean(dim=1)报错。

原因:中心词左右都没有词时,上下文长度为 0。

解决:在generate_cbow_pairs里加判断if len(context) == 0: continue,如 3.3 节代码所示。更稳妥的做法是给空上下文填<PAD>,但会引入噪声,直接跳过更干净。

5.4 学习率过大导致嵌入向量发散

现象:loss 先降后升,最终稳定在一个很高的值。

原因:Adam 的默认学习率 0.001 对某些语料偏大,尤其是词表超过 20 万时。

解决:把学习率降到 0.0005,或者加梯度裁剪torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm=1.0)。如果还不行,换 SGD 加动量 0.9,学习率 0.01。

5.5 中英文词频差异导致英文词向量质量差

现象:中文词的相似度结果合理,英文词的最近邻全是无关词。

原因:中文分词后词频普遍高于英文单词,负采样时英文词被采到的概率低,更新次数少。

解决:对英文词单独做上采样,或者在负采样概率里给英文词一个最低权重。简单做法是在build_vocab时对英文词频乘以 1.5 再算 0.75 次方。

6. 验证词向量质量与增量训练技巧

训练完不是终点,怎么判断这版词向量能不能用,以及新语料来了怎么接着训,才是落地时最常被问的。

先说验证。最直接的方法是找几组已知相似的词,看余弦相似度。比如“模型”和“算法”、“训练”和“学习”、“pytorch”和“tensorflow”。如果相似度低于 0.3,说明嵌入维度或窗口大小需要调。另一个方法是做类比任务:国王 - 男人 + 女人 ≈ 女王。中英文混合语料里,可以试“北京 - 中国 + 美国 ≈ 华盛顿”,但中文词和英文词混在一起时类比效果会打折,建议中英文分开验证。

def cosine_sim(word1, word2, model, word2idx, idx2word): if word1 not in word2idx or word2 not in word2idx: return None vec1 = model.in_embed.weight[word2idx[word1]].detach() vec2 = model.in_embed.weight[word2idx[word2]].detach() return torch.cosine_similarity(vec1.unsqueeze(0), vec2.unsqueeze(0)).item() # 示例 # print(cosine_sim('模型', '算法', model, word2idx, idx2word)) # print(cosine_sim('pytorch', 'tensorflow', model, word2idx, idx2word))

如果相似度普遍偏低,优先升embed_dim到 200,再把window_size从 5 降到 3。如果相似度虚高(所有词都在 0.8 以上),说明嵌入空间坍缩了,检查负采样个数是否太少,或者学习率是否太小。

再说增量训练。新语料来了,不要从头训。正确做法是:

  1. 用旧词表把新语料转成索引,未知词标<UNK>
  2. 加载旧模型权重
  3. 用新语料继续训练 1 到 2 个 epoch,学习率降到原来的十分之一
  4. 如果新语料里有大量新词,先扩充词表,再冻结旧词的嵌入,只训练新词的嵌入
# 增量训练示例 model.load_state_dict(torch.load('cbow_old.pt')) optimizer = optim.Adam(model.parameters(), lr=0.0001) # 学习率降 10 倍 # 然后用新语料的 pairs 跑 1-2 个 epoch

最后说一个我自己的习惯:每次训练完,把词向量导出成文本格式,用gensim.models.KeyedVectors.load_word2vec_format加载后做一次最近邻检查。这一步能快速发现“某个词向量全是 0”或“所有词都挤在一起”这类低级错误。导出的代码很简单:

def save_vectors(model, idx2word, path='cbow_vectors.txt'): vectors = model.in_embed.weight.detach().cpu().numpy() with open(path, 'w', encoding='utf-8') as f: f.write(f"{len(idx2word)} {vectors.shape[1]}\n") for idx, word in idx2word.items(): vec_str = ' '.join(f'{v:.6f}' for v in vectors[idx]) f.write(f"{word} {vec_str}\n") # save_vectors(model, idx2word, 'cbow_vectors.txt')

这个格式是 Word2Vec 的标准文本格式,第一行是词数和维度,后面每行是词加向量。导出后可以用任何支持该格式的工具加载,方便对比不同版本的效果。

希望帮到你。

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

RELATED READING

延伸阅读

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