简介面向自然语言处理课程学习与词向量入门实践这份压缩包提供了一套可直接运行的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按频次过滤低频词再按频次降序、词面升序排序保证每次构建结果可复现。低频词统一映射到unkpad只用于 batch 对齐不参与上下文平均。from collections import Counter import json from pathlib import Path PAD, UNK pad, unk def build_vocab(sentences, min_count3, max_sizeNone): counter Counter() for sent in sentences: counter.update(sent) # 先过滤低频再按频次和词面排序保证可复现 items [(w, c) for w, c in counter.items() if c min_count] items.sort(keylambda 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_asciiFalse, indent2), encodingutf-8 ) def load_vocab(path): return json.loads(Path(path).read_text(encodingutf-8))逻辑说明Counter统计 token 频次min_count过滤低频排序保证同一份语料每次构建的词表顺序一致。pad固定为 0unk固定为 1后面模型里padding_idx0才能对上。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太小表达不足太大易过拟合特殊 tokenpad0,unk1索引固定加载时不能变注意词表 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_size2, unkunk): 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, dtypetorch.long), torch.tensor(target, dtypetorch.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, dtypetorch.long) mask torch.zeros((len(ctxs), max_len), dtypetorch.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_size2适合中文短文本和关键词密集的语料英文或长句多时可以用 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_dim128, tie_weightsFalse): super().__init__() self.embed nn.Embedding(vocab_size, embed_dim, padding_idx0) self.out nn.Linear(embed_dim, vocab_size, biasFalse) 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(dim1) # [B, D] denom mask_f.sum(dim1).clamp(min1.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_idx0让 pad 的 embedding 不更新但它不会自动把 pad 从平均里排除所以mask必须做。tie_weightsTrue时输入输出共享权重参数更少但要求embed_dim与输出层输入维度一致且词表很大时共享不一定更好。参数上embed_dim128是常见起步值语料超过百万级可以试 256vocab_size由词表决定不要手写。平均池化后直接接线性层没有隐藏层这是 CBOW 的原始结构。如果加隐藏层模型表达更强但训练更慢也更容易把词向量学成任务特征。词表超过几十万时全 softmax 每步都要算整个词表建议改成负采样把多分类转成二分类采样 5 到 15 个负例。注意padding_idx0只阻止 pad 梯度更新不阻止 pad 参与平均。看到emb.mean(dim1)就要警惕。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(), lr1e-3) scheduler optim.lr_scheduler.ReduceLROnPlateau( optimizer, modemin, factor0.5, patience1 )逻辑说明CrossEntropyLoss直接把 logits 和类别 id 做交叉熵Adam更新所有参数ReduceLROnPlateau在验证 loss 不降时把学习率减半。参数上lr1e-3适合小到中等词表如果 loss 震荡或最近邻全是高频词先降到 3e-4。patience1表示验证 loss 一轮不降就降学习率训练轮次少时可以设 2。factor0.5是减半不要一次降太多。参数起步值调整方向batch_size256 或 512显存不够就降词表大时别盲目加lr1e-3loss 震荡降到 3e-4epochs5 到 10看验证 loss 和最近邻不追低 lossgrad_clip5.0embedding 梯度爆炸时下调window_size2 或 3中文短文本 2英文长句 3 到 5负采样是词表很大时的常见做法中心词和真实上下文词算正例按词频采样若干负例输出层从V维降到k维二分类。它训练快但实现比全 softmax 多一层采样逻辑第一次落地建议先跑全 softmax 小词表基线确认数据管线没问题再换。3.3 训练循环与验证训练循环负责搬 batch、前向、算 loss、反向、梯度裁剪和更新。验证循环只算 loss 和抽样最近邻不更新参数。每轮结束保存 checkpointcheckpoint 里必须带词表和配置不能只存state_dict。训练日志至少记录 loss、学习率、OOV 率和几个查询词的最近邻方便回看。from torch.utils.data import DataLoader from tqdm import tqdm def train_one_epoch(model, loader, optimizer, criterion, device, grad_clip5.0): model.train() total_loss 0.0 for ctx, mask, target in tqdm(loader, desctrain, leaveFalse): 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_clip5.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, k10): inv {i: w for w, i in vocab.items()} if word not in vocab: return [] vec emb[vocab[word]] vec vec / vec.norm().clamp(min1e-8) mat emb / emb.norm(dim1, keepdimTrue).clamp(min1e-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 并过滤自身。参数上k10足够观察邻居结构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 共享一个向量语义检索会退化成“未知词都相似”。如果业务里专名多本文还有配套的精品资源点击获取