ARTICLE · INTELLIGENCE

战地情报 · 详情页

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

PyTorch中英文混合CBOW词向量训练与避坑指南

PyTorch中英文混合CBOW词向量训练与避坑指南

简介:面向自然语言处理课程学习与词向量入门实践,这份压缩包提供了一套可直接运行的CBOW模型训练材料,重点解决中英文语料共用脚本、训练流程与词向量输出之间的衔接问题。包内共12个文件,以txt语料与输出文本、xml项目配置、一个PyTorch训练脚本及少量工程配置文件为主,整体约13.58MB,目录区分数据、脚本与输出,便于按实验环节查找和复用。已有2695人学习下载,适合需要完成课程作业或复现词向量实验的读者参考。训练脚本可在中文与英文语料之间切换,并配合停用词文件完成预处理,最终生成中英文词向量结果;读者可据此理解CBOW的输入构造、训练参数设置和结果保存方式,也能将现成语料与脚本迁移到自己的NLP实验中,减少环境搭建与数据整理成本。

1. 从“词向量”三个字开始:CBOW 在中英文混合语料上的最小可用路径

做 nlp 的人第一次碰到词向量,常被 Transformer 和大模型晃花眼;但如果目标只是给中英文混合短文本做一版可解释的 pytorch 基线,cbow 仍然是性价比很高的入口。这套实现用中英文语料训练 CBOW 模型,输出可查询、可导出、可复现的词向量,重点不在刷榜,而在把分词、词表、滑动窗口、损失函数和保存加载这条链路跑通。

它适合想理解 embedding 训练细节的开发者、需要轻量语义检索原型的团队,以及拿它做实验对照的人。语料不大、机器一般、又想把“词向量怎么来的”彻底看清,这条路比直接调预训练模型更透明。下面按数据管线、模型训练、避坑排查、验证导出四段推进,每一步都给可抄的代码和参数。

2. 语料预处理:中英文分词、词表截断与 CBOW 样本构造

中英文混合语料不是把两种文本倒进同一个列表就完事。中文分词粒度、英文大小写、标点、低频词、窗口是否跨句,都会直接改变词向量的邻居结构。CBOW 本身很简单,真正决定结果的是预处理和样本构造;这两步做粗了,后面调学习率基本是玄学。

2.1 中英文混合语料的分词策略

中文常用 jieba 做词级切分,英文用正则保留单词和数字,统一小写并压缩空白。不要直接把整句丢给 jieba 处理英文,它对英文片段的切分规则和中文词典混在一起,容易出现奇怪 token。更稳的做法是按中文块、英文词、数字分别匹配,中文块交给 jieba,英文和数字保留原形。

import re import jieba from typing import List CJK = re.compile(r'[\u4e00-\u9fff]+') TOKEN_PATTERN = re.compile(r"[\u4e00-\u9fff]+|[a-zA-Z]+(?:['’][a-zA-Z]+)?|\d+(?:\.\d+)?") def normalize(text: str) -> str: # 全角空格转半角,统一小写,压缩连续空白 text = text.replace('\u3000', ' ').lower() text = re.sub(r'\s+', ' ', text).strip() return text def tokenize_mixed(text: str) -> List[str]: text = normalize(text) tokens = [] for m in TOKEN_PATTERN.finditer(text): piece = m.group(0) if CJK.fullmatch(piece): # 中文块走 jieba,英文和数字保留原形 tokens.extend(jieba.lcut(piece)) else: tokens.append(piece) return [t for t in tokens if t.strip()]

逻辑说明:normalize负责全半角、空白和小写,保证训练与推理走同一套清洗;tokenize_mixed用正则把中文块、英文词、数字分开,中文块才交给 jieba。这样英文缩写、带撇号的词和数字不会被中文词典误切。参数上,TOKEN_PATTERN可以按语料补充连字符、下划线或版本号格式;jieba 可以加载自定义词典,但训练和推理必须使用同一份词典配置,否则词表会漂移。

常见做法是把标点直接丢掉,不进入词表。如果标点对任务有意义,比如问号能区分疑问句,可以保留少量高频标点,但要在词表构建时单独统计。中英文混合语料里,英文词形变化多,可以加简单词干化或复数归一,但词表会变小,评估时要说明处理规则。我一般会先跑一版不做词干化的基线,确认 OOV 率和最近邻后再决定是否加规则。

提示:训练和推理必须共用同一个tokenize_mixed函数,不能训练时用一套规则、推理时临时写另一套。词表漂移大多不是模型问题,而是这里分叉了。

2.2 词表构建、低频截断与特殊 token

词表是 CBOW 的索引表,顺序就是 embedding 的行号。构建时先统计所有句子里的 token,按频次过滤低频词,再按频次降序、词面升序排序,保证每次构建结果可复现。低频词统一映射到<unk>,<pad>只用于 batch 对齐,不参与上下文平均。

from collections import Counter import json from pathlib import Path PAD, UNK = '<pad>', '<unk>' def build_vocab(sentences, min_count=3, max_size=None): counter = Counter() for sent in sentences: counter.update(sent) # 先过滤低频,再按频次和词面排序,保证可复现 items = [(w, c) for w, c in counter.items() if c >= min_count] items.sort(key=lambda x: (-x[1], x[0])) if max_size: items = items[:max_size] vocab = {PAD: 0, UNK: 1} for w, _ in items: if w not in vocab: vocab[w] = len(vocab) return vocab def save_vocab(vocab, path): Path(path).write_text( json.dumps(vocab, ensure_ascii=False, indent=2), encoding='utf-8' ) def load_vocab(path): return json.loads(Path(path).read_text(encoding='utf-8'))

逻辑说明:Counter统计 token 频次,min_count过滤低频,排序保证同一份语料每次构建的词表顺序一致。<pad>固定为 0,<unk>固定为 1,后面模型里padding_idx=0才能对上。max_size用来控制词表上限,因为 CBOW 的输出层是embed_dim × vocab_size,词表越大,最后全连接层越占显存和计算。

参数上,中英文混合语料建议min_count从 3 起步;中文词粒度大,低频专名多,min_count太高会把有效词打成<unk>;英文词形变化多,可以适当提高到 4 或 5。max_size常见设 5 万到 20 万,具体看语料规模和显存。<unk>一定要参与训练,否则推理遇到 OOV 时没有可用向量;<pad>不需要语义,只做占位。

参数建议起步值影响
min_count3太低词表膨胀,太高 OOV 增加
max_size5 万到 20 万控制输出层参数量和训练速度
embed_dim128 或 256太小表达不足,太大易过拟合
特殊 token<pad>=0,<unk>=1索引固定,加载时不能变

注意:词表 JSON 要跟模型一起保存。重新构建词表即使词面相同,排序或过滤参数变了,索引也会变,加载后查询结果会整体错位。

2.3 滑动窗口与 CBOW 样本生成

CBOW 的样本是“上下文词 id 列表 → 中心词 id”。实现时按句子生成,窗口只在句子内部滑动,不能先把整篇文档拼成一条长序列再开窗,否则句尾和下一句句首会被强行关联,词向量里会出现莫名其妙的邻居。句子短于2 * window_size + 1时直接跳过,或者边界补 pad 并用 mask 排除。

import torch from torch.utils.data import Dataset class CBOWDataset(Dataset): def __init__(self, sentences, vocab, window_size=2, unk='<unk>'): self.samples = [] self.window_size = window_size self.unk_id = vocab[unk] for sent in sentences: ids = [vocab.get(t, self.unk_id) for t in sent] if len(ids) < 2 * window_size + 1: continue for i in range(window_size, len(ids) - window_size): # 左右各取 window_size 个词,拼成上下文 ctx = ids[i - window_size:i] + ids[i + 1:i + 1 + window_size] self.samples.append((ctx, ids[i])) def __len__(self): return len(self.samples) def __getitem__(self, idx): ctx, target = self.samples[idx] return torch.tensor(ctx, dtype=torch.long), torch.tensor(target, dtype=torch.long) def collate_cbow(batch): ctxs, targets = zip(*batch) max_len = max(len(c) for c in ctxs) padded = torch.full((len(ctxs), max_len), 0, dtype=torch.long) mask = torch.zeros((len(ctxs), max_len), dtype=torch.float) for i, c in enumerate(ctxs): padded[i, :len(c)] = c mask[i, :len(c)] = 1.0 return padded, mask, torch.stack(targets)

逻辑说明:CBOWDataset对每个句子先做 token 到 id 的映射,OOV 走<unk>,然后以每个中心词为锚点,左右各取window_size个词拼上下文。collate_cbow把同一 batch 的上下文补齐到等长,同时生成mask,后面模型做加权平均时用它排除 pad。这里的样本上下文长度固定为2 * window_size,因为循环只取完整窗口;如果希望覆盖句首句尾,可以改成边界补 pad,但 mask 必须同步做好。

参数上,window_size=2适合中文短文本和关键词密集的语料;英文或长句多时可以用 3 到 5。窗口越大,上下文越宽,训练样本的语义越模糊;窗口太小,词向量容易只学到局部搭配。中英文混合语料可以先统一用 2 或 3,训练完看最近邻,再决定是否分语种调窗口。max_len在固定窗口下就是2 * window_size,写成动态最大值是为了兼容边界补 pad 的变体。

提示:句子边界是硬边界。短句样本少时,宁可对边界样本补 pad 并用 mask,也不要把相邻句子拼起来开窗。

3. 模型与训练:PyTorch 实现里的形状、损失和优化器

CBOW 的模型图不复杂,但形状和 mask 很容易写错。上下文 id 是[B, L],embedding 后是[B, L, D],平均后是[B, D],输出 logits 是[B, V],交叉熵接收 logits 和类别 id。任何一步维度对不上,或者 pad 参与了平均,训练 loss 可能照样下降,但词向量会偏。下面把模型、损失和训练循环拆开写。

3.1 模型结构:Embedding、平均池化与输出层

CBOW 用上下文词向量的平均去预测中心词。平均而不是拼接,是因为 CBOW 把上下文当词袋,不建模顺序,参数少、训练快;拼接会保留位置信息,但维度变成2 * window_size * D,小语料上更容易过拟合。输出层用全连接映射到词表大小,后面接交叉熵。词表很大时,全 softmax 会慢,常见优化是负采样或层次 softmax,这里先给全 softmax 基线,便于验证管线。

import torch import torch.nn as nn class CBOWModel(nn.Module): def __init__(self, vocab_size, embed_dim=128, tie_weights=False): super().__init__() self.embed = nn.Embedding(vocab_size, embed_dim, padding_idx=0) self.out = nn.Linear(embed_dim, vocab_size, bias=False) if tie_weights: # 输入输出共享权重,减少参数,但要求维度一致 self.out.weight = self.embed.weight def forward(self, context_ids, mask): # context_ids: [B, L], mask: [B, L] emb = self.embed(context_ids) # [B, L, D] mask_f = mask.unsqueeze(-1) # [B, L, 1] summed = (emb * mask_f).sum(dim=1) # [B, D] denom = mask_f.sum(dim=1).clamp(min=1.0) avg = summed / denom # [B, D] logits = self.out(avg) # [B, V] return logits

逻辑说明:nn.Embedding把上下文 id 映射到embed_dim维向量;mask加权求和再除以有效长度,得到不受 pad 影响的上下文平均向量;nn.Linear输出每个词作为中心词的 logit。padding_idx=0让 pad 的 embedding 不更新,但它不会自动把 pad 从平均里排除,所以mask必须做。tie_weights=True时输入输出共享权重,参数更少,但要求embed_dim与输出层输入维度一致,且词表很大时共享不一定更好。

参数上,embed_dim=128是常见起步值,语料超过百万级可以试 256;vocab_size由词表决定,不要手写。平均池化后直接接线性层,没有隐藏层,这是 CBOW 的原始结构。如果加隐藏层,模型表达更强,但训练更慢,也更容易把词向量学成任务特征。词表超过几十万时,全 softmax 每步都要算整个词表,建议改成负采样,把多分类转成二分类,采样 5 到 15 个负例。

注意:padding_idx=0只阻止 pad 梯度更新,不阻止 pad 参与平均。看到emb.mean(dim=1)就要警惕。

3.2 损失函数与优化器选择

CBOW 的输出是词表上的多分类,损失用CrossEntropyLoss,它接收 logits 和 target id,内部做 log-softmax 和 NLL。不要先手动 softmax 再传进去,也不要对 target 做 one-hot 后用 MSE,除非你明确在做另一种目标。优化器常用 Adam,学习率 1e-3 起步,词表稀疏时它对低频词更友好;SGD 需要更细的调参,但泛化有时更稳。

import torch.nn as nn import torch.optim as optim criterion = nn.CrossEntropyLoss() optimizer = optim.Adam(model.parameters(), lr=1e-3) scheduler = optim.lr_scheduler.ReduceLROnPlateau( optimizer, mode='min', factor=0.5, patience=1 )

逻辑说明:CrossEntropyLoss直接把 logits 和类别 id 做交叉熵;Adam更新所有参数,ReduceLROnPlateau在验证 loss 不降时把学习率减半。参数上,lr=1e-3适合小到中等词表;如果 loss 震荡或最近邻全是高频词,先降到 3e-4。patience=1表示验证 loss 一轮不降就降学习率,训练轮次少时可以设 2。factor=0.5是减半,不要一次降太多。

参数起步值调整方向
batch_size256 或 512显存不够就降,词表大时别盲目加
lr1e-3loss 震荡降到 3e-4
epochs5 到 10看验证 loss 和最近邻,不追低 loss
grad_clip5.0embedding 梯度爆炸时下调
window_size2 或 3中文短文本 2,英文长句 3 到 5

负采样是词表很大时的常见做法:中心词和真实上下文词算正例,按词频采样若干负例,输出层从V维降到k维二分类。它训练快,但实现比全 softmax 多一层采样逻辑,第一次落地建议先跑全 softmax 小词表基线,确认数据管线没问题再换。

3.3 训练循环与验证

训练循环负责搬 batch、前向、算 loss、反向、梯度裁剪和更新。验证循环只算 loss 和抽样最近邻,不更新参数。每轮结束保存 checkpoint,checkpoint 里必须带词表和配置,不能只存state_dict。训练日志至少记录 loss、学习率、OOV 率和几个查询词的最近邻,方便回看。

from torch.utils.data import DataLoader from tqdm import tqdm def train_one_epoch(model, loader, optimizer, criterion, device, grad_clip=5.0): model.train() total_loss = 0.0 for ctx, mask, target in tqdm(loader, desc='train', leave=False): ctx, mask, target = ctx.to(device), mask.to(device), target.to(device) optimizer.zero_grad() logits = model(ctx, mask) loss = criterion(logits, target) loss.backward() torch.nn.utils.clip_grad_norm_(model.parameters(), grad_clip) optimizer.step() total_loss += loss.item() * target.size(0) return total_loss / len(loader.dataset) def validate_loss(model, loader, criterion, device): model.eval() total_loss = 0.0 with torch.no_grad(): for ctx, mask, target in loader: ctx, mask, target = ctx.to(device), mask.to(device), target.to(device) logits = model(ctx, mask) total_loss += criterion(logits, target).item() * target.size(0) return total_loss / len(loader.dataset) def save_checkpoint(path, model, vocab, config): torch.save({ 'model_state': model.state_dict(), 'vocab': vocab, 'config': config, }, path)

逻辑说明:train_one_epoch把 batch 搬到设备,前向得到 logits,交叉熵算 loss,反向传播后用clip_grad_norm_限制梯度范数,防止 embedding 梯度爆炸。validate_loss只做前向,不更新参数。save_checkpoint把模型参数、词表、配置打包,加载时先恢复词表和配置,再实例化模型,索引才不会错位。

参数上,grad_clip=5.0是保守值,loss 出现 NaN 时先降到 1.0 并降学习率;tqdm只是进度条,可以换成普通循环。验证不要只看 loss,每轮抽 20 个词查最近邻,看语义是否合理。如果 loss 降但最近邻全是“的”“是”“the”,先查词频和学习率,不要继续加轮次。训练轮次不是越多越好,CBOW 在 5 到 10 轮后收益递减,过拟合表现为最近邻过于集中在高频搭配。

4. 避坑与排查:中英文 CBOW 训练里最容易翻车的五件事

中英文混合训练的问题往往不在模型,而在数据管线和索引一致性。下面五条是实际落地时最容易翻车的地方,每条按“现象 → 原因 → 解决”写,并给出可直接跑的诊断代码。遇到问题先按顺序排查,不要一上来就改模型结构。

4.1 loss 快速下降但最近邻全是高频词

现象:训练 loss 从 8 降到 3,查询“退货”最近邻却是“的”“是”“the”,词向量看起来没有语义。原因通常是全 softmax 对高频词有偏置,学习率偏大,窗口太小,或者语料里高频模板句太多。解决顺序是:先统计词频和最近邻,再降学习率到 3e-4,把min_count提到 3 或 4,窗口从 2 提到 3,必要时对高频词做频率降采样或改用负采样。不要只盯着 loss 曲线。

@torch.no_grad() def topk_neighbors(word, vocab, emb, k=10): inv = {i: w for w, i in vocab.items()} if word not in vocab: return [] vec = emb[vocab[word]] vec = vec / vec.norm().clamp(min=1e-8) mat = emb / emb.norm(dim=1, keepdim=True).clamp(min=1e-8) sims = mat @ vec vals, ids = sims.topk(min(k + 1, len(vocab))) out = [] for v, i in zip(vals.tolist(), ids.tolist()): if i == vocab[word]: continue out.append((inv[i], round(v, 4))) if len(out) == k: break return out

逻辑说明:先把查询词向量和整个词表矩阵归一化,点积就是余弦相似度,再取 topk 并过滤自身。参数上,k=10足够观察邻居结构;emb要在 CPU 上执行,GPU tensor 先detach().cpu()。如果 topk 里全是停用词,先看词频表,不要急着改模型;如果邻居里出现<unk>,回看 4.2 的 OOV 问题。

血泪经验:学习率设到 5e-3 时 loss 曲线很漂亮,但词向量会挤成一团,最近邻全是高频虚词。CBOW 的 loss 低不等于语义好。

4.2 中文查询全变<unk>

现象:推理时输入“退款”“物流”,分词结果在词表里找不到,全部映射成<unk>,相似度查询失效。原因通常是训练和推理分词不一致:训练时加载了自定义词典,推理时没加载;英文大小写没统一;或者词表重新构建后索引变了。解决是把normalize和tokenize_mixed封装成唯一入口,词表随模型保存,启动时先算 OOV 率。

def oov_rate(sentences, vocab): total, oov = 0, 0 for sent in sentences: for t in tokenize_mixed(sent): total += 1 if t not in vocab: oov += 1 return oov / max(total, 1)

逻辑说明:逐句分词后统计不在词表里的 token 比例,分母用总 token 数。参数上,OOV 率超过 5% 就要查分词和词表,超过 20% 基本不可用。<unk>向量可以训练,但所有 OOV 共享一个向量,语义检索会退化成“未知词都相似”。如果业务里专名多,

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

RELATED READING

延伸阅读

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