资讯动态

从零实现CBOW词向量:中英文混合语料训练与PyTorch实战

发布时间:2026/10/13 13:16:22 来源:尧图企业网站定制
简介这份资源面向自然语言处理初学者与课程实践者围绕CBOW词向量训练任务提供中英文双语语料与PyTorch实现脚本帮助理解词嵌入的基本原理与工程落地流程。压缩包共12个文件以txt语料与词向量输出、xml工程配置、py训练脚本为主整体约13.58MB结构上区分了数据、脚本与输出目录便于按模块查阅与复现。脚本可同时处理中文与英文语料运行前只需在代码中指定训练语言即可直接执行输出对应语言的词向量文件适合作为课程作业参考或词向量入门练手项目。目前已有2693人学习下载读者可借此掌握CBOW的输入构造、负采样或层次softmax思路、停用词处理以及中英文语料预处理差异并对照输出结果验证训练效果为后续文本分类、相似度计算等任务打下基础。1. 中英文语料训练 CBOW 模型从零拿到可用的词向量如果你手头有一批中英文混合的语料想快速拿到能算相似度、能做聚类、能喂给下游分类器的词向量CBOW 是最省事的一条路。它不像 Skip-gram 那样对低频词友好但训练快、向量稳尤其适合语料规模中等、又不想上大模型的场景。我这次拿一份中英混排的文本做实验目标很明确用 PyTorch 手写 CBOW把「上下文预测中心词」这件事跑通最后导出word2vec格式的向量文件能直接被gensim加载验证。整条链路不依赖任何预训练权重纯靠自己的语料训出来。适合谁适合想搞懂词向量到底怎么来的、又不想被框架黑盒糊弄的工程师。下面从原理到代码到踩坑一步步拆。2. CBOW 到底在预测什么窗口、负采样与损失函数2.1 从一句话看懂 CBOW 的训练目标CBOW 全称 Continuous Bag-of-Words核心思想一句话用周围词预测中间词。比如句子「我 爱 自然 语言 处理」窗口设为 2当中心词是「语言」时上下文就是「我 爱 自然 处理」。模型要做的是把这四个词的向量取平均或求和去预测「语言」这个词。注意这里没有顺序信息上下文词是被「袋」在一起的所以叫 Bag-of-Words。这跟 Skip-gram 正好反过来Skip-gram 是用中心词预测周围词。CBOW 的好处是训练时每个窗口只做一次预测而 Skip-gram 要做多次所以 CBOW 快。代价是低频词被平均掉了向量质量不如 Skip-gram。中英文混合语料里英文低频词多中文分词后低频词也不少所以如果你特别在意低频词得权衡。我一般会先统计词频把出现次数少于 3 的词统一换成unk不然词表太大训练慢还容易过拟合。词表大小控制在 3 万到 5 万之间比较舒服再大就得考虑降采样。2.2 负采样为什么是必备的从 softmax 到二分类原始 CBOW 用 softmax 计算下一个词的概率词表几万维每次更新都要算全量慢得离谱。负采样Negative Sampling把多分类问题拆成二分类对每个正样本真实中心词随机抽 K 个负样本词表里其他词让模型学会区分「真中心词」和「假中心词」。这样每次只更新 K1 个输出向量计算量骤降。K 一般取 5 到 20。小语料取 5 到 10大语料取 10 到 20。我这次语料大概 200 万词K 取 10。负采样概率按词频的 0.75 次方算这是原论文的经验值能压低高频词被抽中的概率。损失函数用BCEWithLogitsLoss它把 sigmoid 和交叉熵合在一起数值稳定。正样本标签为 1负样本为 0。注意负采样是每个样本独立抽的不是每个 batch 抽一次所以代码里要在循环内抽。2.3 用 PyTorch 搭一个最小 CBOW代码逐段拆先定义模型。输入是上下文词的索引输出是中心词的预测分数。嵌入层用nn.Embedding输出层用另一个nn.Embedding当权重矩阵最后做点积。import torch import torch.nn as nn import torch.nn.functional as F 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) # 初始化均匀分布范围 0.5/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_words, center_words, neg_words): # context_words: [batch, window*2] # center_words: [batch] # neg_words: [batch, K] ctx_emb self.in_embed(context_words) # [batch, window*2, dim] ctx_vec ctx_emb.mean(dim1) # [batch, dim] 平均池化 center_emb self.out_embed(center_words) # [batch, dim] neg_emb self.out_embed(neg_words) # [batch, K, dim] # 正样本得分点积 pos_score torch.sum(ctx_vec * center_emb, dim1) # [batch] pos_loss F.logsigmoid(pos_score) # 负样本得分ctx_vec 与每个负样本点积 neg_score torch.bmm(neg_emb, ctx_vec.unsqueeze(2)).squeeze(2) # [batch, K] neg_loss F.logsigmoid(-neg_score).sum(dim1) # [batch] # 总损失负的 log 似然取平均 loss -(pos_loss neg_loss).mean() return loss逻辑说明in_embed和out_embed是两套独立的向量训练完后通常取in_embed作为词向量也有人把两者相加或拼接。ctx_vec用平均而不是求和是为了让窗口大小不影响向量尺度。pos_score是正样本的点积越大越好所以logsigmoid后取负。负样本点积越小越好所以logsigmoid(-neg_score)。最后把正负损失加起来取负均值。参数说明vocab_size是词表大小embed_dim一般取 100 到 300。我这次取 200。window*2是上下文词数量窗口为 2 时就是 4。K是负采样个数代码里在外部传入。2.4 数据管道中英文分词、词表构建与窗口采样中英文混合语料不能直接用空格切。中文要分词英文要转小写并保留标点。我一般用jieba做中文分词英文用正则切。分词后统一转小写去掉纯数字和长度小于 2 的词。import re import jieba from collections import Counter def tokenize(text): # 先按空格和标点粗切再对中文片段分词 text text.lower() # 提取中文片段和英文片段 tokens [] for segment in re.split(r([\u4e00-\u9fa5]), text): if re.match(r[\u4e00-\u9fa5], segment): tokens.extend(jieba.lcut(segment)) else: tokens.extend(re.findall(r[a-z], segment)) # 过滤长度2非纯数字 tokens [t for t in tokens if len(t) 2 and not t.isdigit()] return tokens # 构建词表 counter Counter() with open(corpus.txt, r, encodingutf-8) as f: for line in f: counter.update(tokenize(line)) # 保留频率3的词其余归为 unk vocab [w for w, c in counter.items() if c 3] word2idx {w: i for i, w in enumerate(vocab)} word2idx[unk] len(word2idx) idx2word {i: w for w, i in word2idx.items()}逻辑说明re.split用捕获组保留中文片段这样能分别处理中英文。jieba.lcut是精确模式适合词向量训练。过滤掉长度小于 2 的词是因为单字词在中文里歧义太大英文单字母也没意义。词表构建时频率低于 3 的词统一映射到unk但unk本身不参与训练只在采样时替换。窗口采样对每个句子滑动窗口中心词从第window个词到倒数第window个词。上下文词是中心词前后各window个词去掉中心词本身。如果上下文词不在词表替换为unk的索引。def make_windows(tokens, word2idx, window2): idxs [word2idx.get(t, word2idx[unk]) for t in tokens] for i in range(window, len(idxs) - window): context idxs[i-window:i] idxs[i1:iwindow1] center idxs[i] yield context, center参数说明window是单侧窗口大小总上下文长度是2*window。我这次用 2总长 4。如果句子短于2*window1直接跳过。2.5 训练循环batch 组装、负采样与学习率训练时每个 batch 包含若干(context, center)对。负采样在 batch 内做对每个中心词从词表按 0.75 次方概率抽 K 个词排除中心词本身。import numpy as np from torch.utils.data import DataLoader, Dataset class CBOWDataset(Dataset): def __init__(self, windows): self.windows list(windows) def __len__(self): return len(self.windows) def __getitem__(self, idx): return self.windows[idx] # 负采样概率 freqs np.array([counter[w] for w in vocab], dtypenp.float32) probs freqs ** 0.75 probs / probs.sum() def collate_fn(batch): contexts, centers zip(*batch) contexts torch.tensor(contexts, dtypetorch.long) centers torch.tensor(centers, dtypetorch.long) # 负采样每个中心词抽 K 个 K 10 negs np.random.choice(len(vocab), size(len(centers), K), pprobs) negs torch.tensor(negs, dtypetorch.long) return contexts, centers, negs dataset CBOWDataset(make_windows(tokens, word2idx, window2)) loader DataLoader(dataset, batch_size512, shuffleTrue, collate_fncollate_fn) model CBOWModel(len(word2idx), embed_dim200) optimizer torch.optim.Adam(model.parameters(), lr0.001) for epoch in range(5): total_loss 0 for contexts, centers, negs in loader: optimizer.zero_grad() loss model(contexts, centers, negs) loss.backward() optimizer.step() total_loss loss.item() print(fepoch {epoch}, loss {total_loss/len(loader):.4f})逻辑说明collate_fn里做负采样是因为 DataLoader 的多进程不能直接共享 numpy 随机状态放在这里每个 batch 抽一次简单可靠。probs是 0.75 次方归一化后的概率np.random.choice按这个概率抽。注意negs可能抽到中心词本身但概率很低可以忽略严格做法是抽到后重抽。参数说明batch_size取 512显存不够就降到 256。lr用 0.001Adam 默认值。epoch取 5 到 10看损失是否还在降。损失降到 2 左右就差不多收敛了。3. 中英文混合语料的预处理分词、词表与窗口的工程细节3.1 中文分词器怎么选jieba 的三种模式对比jieba有三种分词模式精确模式、全模式、搜索引擎模式。精确模式适合词向量因为它切分最合理不会产生大量冗余短词。全模式会把所有可能的词都切出来比如「自然语言」会切成「自然」「语言」「自然语言」导致词表爆炸。搜索引擎模式在精确模式基础上对长词再切适合搜索场景但对词向量训练没必要。我一般用jieba.lcut精确模式然后手动过滤掉长度小于 2 的词。如果语料里有大量专业术语可以加载自定义词典但这次是通用语料没加。英文部分用正则[a-z]提取转小写。注意英文缩写如「dont」会被切成「don」和「t」所以最好先做一次替换把常见缩写展开。我这次语料里缩写不多直接切了。3.2 词表大小与unk的处理频率阈值怎么定词表大小直接影响训练速度和内存。词表越大输出层的out_embed越大负采样计算量也越大。我一般把频率阈值设在 3 到 5 之间。低于阈值的词归为unk但unk不参与负采样也不作为中心词。这样词表能控制在 3 万左右。有个坑如果unk的索引在词表末尾负采样时可能抽到它但它的向量没意义。所以负采样时要排除unk。我这次直接在probs里把unk的概率设为 0重新归一化。unk_idx word2idx[unk] probs[unk_idx] 0 probs / probs.sum()3.3 窗口大小对中英文的影响2 还是 5窗口大小决定上下文范围。英文词向量常用窗口 5 到 10因为英文句子结构松散大窗口能捕捉更多语义。中文词向量常用窗口 2 到 5因为中文词序更紧凑大窗口反而引入噪声。中英文混合时我取折中值 2 到 3。这次用 2总上下文长度 4。如果你发现相似词结果里语义相关的词排不到前面可以试着把窗口调到 3 或 4。但窗口越大训练越慢而且高频词会主导上下文平均低频词向量质量下降。3.4 批处理与显存batch_size 和 embed_dim 的取舍batch_size和embed_dim是显存大户。embed_dim取 200 时in_embed和out_embed各占vocab_size * 200 * 4字节。词表 3 万时每个嵌入约 24 MB两个共 48 MB很小。真正吃显存的是负采样后的点积计算batch_size * K * embed_dim。batch 512、K 10、dim 200 时中间张量约 51210200*4 4 MB也不大。所以显存不是瓶颈CPU 也能跑。如果语料上千万词词表几十万那out_embed会很大这时候要么减小embed_dim要么用稀疏更新。我这次语料 200 万词词表 3.2 万单卡随便跑。4. 训练完怎么验证相似度、类比与可视化4.1 用余弦相似度查最近邻代码与阈值训练完后取in_embed.weight.data作为词向量矩阵。归一化后算余弦相似度查每个词的 topN 最近邻。import torch.nn.functional as F # 提取词向量并归一化 embeddings model.in_embed.weight.data.cpu() embeddings F.normalize(embeddings, dim1) def nearest_words(word, topn5): if word not in word2idx: return [] idx word2idx[word] vec embeddings[idx] sims torch.matmul(embeddings, vec) top torch.topk(sims, topn 1) results [] for score, i in zip(top.values[1:], top.indices[1:]): results.append((idx2word[i.item()], score.item())) return results print(nearest_words(语言)) print(nearest_words(language))逻辑说明F.normalize把向量长度变为 1点积就是余弦相似度。topk取 topn1 是因为第一个是词本身要跳过。结果里能看到「语言」的最近邻可能是「中文」「英文」「处理」等说明向量学到了语义。阈值方面余弦相似度大于 0.5 算比较相关大于 0.7 算非常相关。但这不是绝对的取决于语料和训练程度。4.2 中英文跨语言类比king - man woman 能成立吗词向量的经典玩法是类比king - man woman ≈ queen。中英文混合语料里可以试国王 - 男人 女人看最近邻是不是「女王」。但跨语言类比很难因为中英文词向量在同一个空间里但语义对齐不一定好。我试了一下国王 - 男人 女人的最近邻是「女王」但相似度只有 0.4 左右不算强。如果你要做跨语言对齐得用双语词典做监督或者用对抗训练。CBOW 本身不保证跨语言语义对齐它只是把中英文词放在同一个空间里靠共现关系拉近。4.3 降维可视化t-SNE 看聚类效果用sklearn.manifold.TSNE把词向量降到二维画散点图。注意t-SNE 计算量大只取高频词或指定词。from sklearn.manifold import TSNE import matplotlib.pyplot as plt # 取前 200 个高频词 words vocab[:200] idxs [word2idx[w] for w in words] vecs embeddings[idxs].numpy() tsne TSNE(n_components2, random_state42, perplexity30) vecs_2d tsne.fit_transform(vecs) plt.figure(figsize(12, 8)) plt.scatter(vecs_2d[:, 0], vecs_2d[:, 1], s10) for i, w in enumerate(words): plt.annotate(w, (vecs_2d[i, 0], vecs_2d[i, 1]), fontsize8) plt.show()逻辑说明perplexity取 30是 t-SNE 的常用值表示每个点的有效邻居数。高频词会聚在一起中英文词可能混在一起也可能分开取决于语料里中英文的共现模式。4.4 保存与加载导出 word2vec 格式给 gensim 用训练完后把词向量存成word2vec文本格式第一行是词表和维度后面每行是词和向量。def save_word2vec(path, embeddings, idx2word): with open(path, w, encodingutf-8) as f: f.write(f{len(idx2word)} {embeddings.shape[1]}\n) for i in range(len(idx2word)): word idx2word[i] vec .join(f{v:.6f} for v in embeddings[i].tolist()) f.write(f{word} {vec}\n) save_word2vec(cbow_vectors.txt, embeddings, idx2word)加载验证from gensim.models import KeyedVectors kv KeyedVectors.load_word2vec_format(cbow_vectors.txt, binaryFalse) print(kv.most_similar(语言, topn5))注意gensim加载时默认会检查词表大小和维度是否匹配所以保存时第一行必须写对。如果词表里有空格会解析错所以分词时要去掉带空格的词。5. 避坑与排查训练 CBOW 时最容易翻车的 5 个点5.1 损失不降反升负采样概率没归一化现象训练几个 epoch 后损失从 2.5 涨到 3.0 以上模型完全没学到东西。原因负采样概率probs没有归一化或者归一化后某些词概率为负。np.random.choice要求概率和为 1且非负。如果probs里有 NaN 或负数采样会报错或抽到无效词。解决在probs / probs.sum()之后检查np.isnan(probs).any()和(probs 0).any()。如果有问题检查词频统计是否包含 0 或负数。另外unk的概率要设为 0 后重新归一化。5.2 相似词全是unk词表构建时把unk加错了位置现象查「语言」的最近邻返回的全是unk。原因unk的索引被加到了词表末尾但训练时unk也参与了嵌入更新导致它的向量和很多词都相似。或者分词时把所有词都映射到了unk因为词表里没有这些词。解决unk不参与训练在make_windows里如果词不在词表直接跳过这个窗口而不是替换成unk。或者把unk的嵌入固定为零向量不更新。5.3 中文词向量质量差分词粒度太细或太粗现象中文词的最近邻是单字或无关词英文词正常。原因jieba分词粒度太细比如「自然语言」被切成「自然」「语言」导致「自然语言」这个词不存在。或者粒度太粗把「自然语言处理」当成一个词导致词表里长词太多低频词被过滤。解决调整jieba词典或者用jieba.lcut后手动合并一些短语。我一般会统计词频把高频的二字词和三字词保留低频长词拆开。5.4 训练速度慢DataLoader 的 num_workers 设成了 0现象一个 epoch 要跑半小时CPU 利用率只有 20%。原因DataLoader默认num_workers0数据加载在主进程负采样也在主进程GPU 或 CPU 都在等数据。解决设num_workers4或 8但注意collate_fn里的负采样要用 numpy 的全局随机状态多进程下每个 worker 会复制状态导致采样重复。解决办法是在collate_fn里用np.random.default_rng()独立随机生成器或者把负采样放到模型 forward 里。5.5 显存溢出embed_dim 和 batch_size 同时设大现象RuntimeError: CUDA out of memory。原因embed_dim设了 500batch_size设了 2048负采样 K20中间张量batch_size * K * embed_dim达到 204820500*4 80 MB加上梯度显存爆了。解决先降batch_size到 256再降embed_dim到 200。如果还不够把负采样 K 降到 5。或者用梯度累积小 batch 多步更新。6. 进阶技巧让 CBOW 词向量在中英文混合场景更稳训练完基础版后我一般会做三件事来提升向量质量。第一用更小的学习率再训 2 个 epoch让模型微调。第二把in_embed和out_embed的向量相加后归一化作为最终词向量这样能融合两套表示。第三对高频词做降采样随机丢弃一些高频词让低频词有更多更新机会。降采样的公式是 ( P(w) 1 - \sqrt{t/f(w)} )其中 ( t ) 取 1e-5( f(w) ) 是词频。如果 ( P(w) ) 小于随机数就丢弃这个词。这样能压缩高频词的影响。import random def subsample(tokens, t1e-5): result [] for w in tokens: freq counter[w] / total_words prob 1 - (t / freq) ** 0.5 if random.random() prob: result.append(w) return result参数说明t越小丢弃越多。一般取 1e-5 到 1e-3。我这次取 1e-5高频词「的」被丢弃的概率约 0.9效果明显。另一个技巧是加入字符级 n-gram。中文里很多词是复合词比如「语言」和「处理」组合成「语言处理」。如果词表里没有「语言处理」可以加入字符 bigram 作为补充。但这样词表会变大得权衡。最后验证词向量质量不能只看几个例子。我一般会跑一个下游任务比如文本分类用词向量平均作为句子表示看准确率。如果准确率比随机猜高 20% 以上说明词向量可用。如果不行回头检查分词和窗口。我自己的习惯是每次训完词向量先存一份原始文件再存一份归一化后的文件。归一化后的用于相似度计算原始文件用于下游任务微调。这样不会因为归一化丢失模长信息。希望帮到你。本文还有配套的精品资源点击获取

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑