简介:基于PyTorch实现的词向量模型,适合自然语言处理初学者和希望快速上手词向量训练的开发者。资源提供完整的小型英文语料(含1803个单词),配合DataSet、Main、Model等代码文件,覆盖数据预处理、模型构建、训练与评估流程,可帮助理解CBOW、Skip-gram等经典词向量原理,并可直接在Python环境中运行调试。压缩包共6个文件,包含3个py源码、2个pyc编译文件及1个txt数据文件,整体仅10KB,体量精简,便于阅读和二次修改。目前已有3100余人学习下载。通过源码中的Practical示例,读者能够掌握词向量的One-Hot输入、MLP结构、交叉熵损失及自动梯度更新等关键环节,还能将训练好的词向量应用于文本分类、情感分析等后续NLP任务。这份资源以代码为核心,附有数据与运行痕迹,适合动手实践和教学演示。
1. 基于PyTorch实现词向量模型,听着老古董,却是下游效果的底限
基于PyTorch实现词向量模型,听起来像是NLP入门课的老古董,但它在很多真实项目里决定了下游效果的底限。某开发者接过一个评论情感分析项目:某公司给了一批日化产品评论,要判断正负向。他图省事,直接加载现成的开源词向量,把句子词逐个映射再求平均,丢给分类器。第一轮回测准确率还行,第二批数据进来,全是新品名、用户自造词,准确率掉了近10个百分点。原因很简单:预训练向量根本不认识这批语料的“方言”,所有不在词表里的词全变成OOV。那次之后,凡是遇到私有语料,都用PyTorch从零训练一套词向量,先把OOV问题掐死在源头。下面这条路径从结构选型、数据管线、模型代码、负采样损失到参数调优和排错完整拆开,新手照着能跑通,熟手可以直接对比边界。
2. 结构选型先于代码:Skip-gram与CBOW怎么选才不返工
2.1 两种结构的输入输出和梯度流动差异
Word2Vec家族里最常见的两个结构是Skip-gram和CBOW。CBOW拿上下文词向量的平均去预测中心词,Skip-gram反过来,拿中心词去预测它窗口内的每一个上下文词。给定一句“冰箱里放着牛奶”,窗口取2时,CBOW看到“冰箱、里、放着、牛奶”里的周围词,要猜中间那个词是谁;Skip-gram看到“冰箱”,要让模型学会预测“里、放着、牛奶”这些词。
放到PyTorch里,二者的数据形状完全不同。CBOW的输入是(batch, 2 * window)的上下文id矩阵,查完Embedding后要做一个mean,得到(batch, emb_dim),再线性映射到(batch, vocab_size)。Skip-gram的输入是(batch,)的中心词id,中心词向量要和每个上下文词向量做内积,所以训练样本是按“中心词-上下文词”一对一对组织的。
这个差异直接决定了数据规模。同一个语料、同一个窗口,Skip-gram生成的样本对数量接近CBOW的2倍窗口大小。窗口为5时,Skip-gram的数据量大约是CBOW的10倍。在训练时间上,Skip-gram明显更贵,但对低频词的监督信号更密集。某开发者一开始为了省时间选了CBOW,结果发现命名实体和生僻词学得一团糟,最后换回Skip-gram才把实体相似度做出来。
2.2 语料规模、任务类型与结构选择对照表
选型不是看哪个结构更新,而是看语料和任务。下面这张对照表是我实际排错时经常参考的:
| 场景 | 推荐结构 | 理由 |
|---|---|---|
| 语料百万词以内,追求质量 | Skip-gram | 每个样本的监督更强,低频词和命名实体学得更细致 |
| 语料千万词以上,训练时间紧张 | CBOW | 样本量小很多,训练快,高频词效果接近 |
| 下游任务是语义相似度、检索召回 | Skip-gram | 中心词预测上下文,语义聚合能力更好 |
| 下游任务更关注词序、词性等句法特征 | CBOW | 平均操作天然平滑了上下文顺序,词序敏感度低 |
| 生僻词、产品词、专有名词多 | Skip-gram | 这类词作为中心词时被反复训练,向量更可靠 |
注意这个选型不是绝对的。GloVe这类基于全局共现统计的方案,在词类比任务上有自己的优势,但它需要构建共现矩阵,和PyTorch手写Word2Vec是两条路。如果任务只是给下游分类器提供静态词向量,Skip-gram和CBOW足够;如果要扛专门做词关系推理,再考虑GloVe方向。我一般会先看语料里低频词占比,占比高就Skip-gram,占比低又赶时间就CBOW。
2.3 负采样为何成为训练词向量的默认配置
结构定下来后,还要面对输出端的softmax问题。词表一上十万,完整softmax每个样本都要算一遍全部词的概率,分母的归一化项是巨大的计算瓶颈。Word2Vec原文给出了两个近似方案:层次Softmax和负采样。
层次Softmax会按词频建一棵哈夫曼树,每个内部节点配一个向量,训练时沿着树路径做二分类。这个方案不需要采样,但要在PyTorch里手写树结构、节点索引和路径编码,调试成本不低。负采样则把“预测哪个词”改成了“判断这对词是不是真正共现”:正样本是中心词加真实上下文词,负样本是中心词加随机抽的词。这样把一个多分类任务拆成若干个二分类任务,只用看1个正样本和k个噪声样本,计算量大幅下降。
PyTorch手写词向量时负采样是默认选择,代码短、和Embedding配合自然。看两种写法的对比:
# 完整softmax:需要过整个词表 scores = model(center_ids) # (batch, vocab_size) loss = nn.CrossEntropyLoss()(scores, ctx_ids) # 负采样:只算正样本和k个噪声样本 noise_ids = torch.randint(0, vocab_size, (batch_size, neg_num)) loss = negative_sampling_loss(center_vec, ctx_ids, noise_ids, embedding, neg_num)负采样里k的取值直接决定训练速度和质量。k=5是经典默认值,语料里生僻词多可以提到10到15,k再大收益就开始递减。噪声采样分布也可以改,后面参数章会专门讲。
3. 用PyTorch跑通最小训练管线:从词表到负采样损失
3.1 语料清洗与词表构建:第一步就把OOV和噪音挡在门外
很多人上来就建词表,结果把标点、数字、停用词全部收进来,训练出来的词向量全是噪音。我习惯先把文本统一做规范化:小写、去标点、去数字、合并空白。中文语料还要决定是否分词,这里用一个通用的清洗函数演示:
import re from collections import Counter def clean_and_tokenize(text): text = text.lower() text = re.sub(r'[^\w\s\u4e00-\u9fa5]', ' ', text) return text.split() def build_vocab(corpus, min_count=5, add_unk=True): counter = Counter() for tokens in corpus: counter.update(tokens) vocab = {} if add_unk: vocab['<unk>'] = 0 for word, cnt in counter.most_common(): if cnt >= min_count: vocab[word] = len(vocab) return vocab, counterclean_and_tokenize里的正则保留了中英文、数字和下划线,其余字符全部替换成空格。中文语料里如果不想分词,可以把split()换成按字切分,效果是另一个路线。build_vocab里min_count=5表示出现少于5次的词直接丢弃,这个参数把词表从几十万压到几万,训练时间和内存都大幅下降。用counter.most_common()排序是为了保证词表id在不同运行环境下保持一致,否则词表顺序漂移,复现实验会非常痛苦。<unk>固定在0号位,所有不在词表里的词在后续处理中统一映射到它。
3.2 窗口采样与数据集封装:把语料转成训练样本对
词表建好后进入样本生成环节。Skip-gram的数据集核心逻辑是:对每个中心词,取其窗口内的所有上下文词,每个上下文词和中心词组成一条训练样本。边界情况直接截断,句子不足窗口长度就取句子内的全部词。
from torch.utils.data import Dataset class SkipGramDataset(Dataset): def __init__(self, corpus, vocab, window=2): self.pairs = [] for tokens in corpus: ids = [vocab[w] for w in tokens if w in vocab] for center_pos in range(len(ids)): center = ids[center_pos] left = max(0, center_pos - window) right = min(len(ids), center_pos + window + 1) for ctx_pos in range(left, right): if ctx_pos == center_pos: continue self.pairs.append((center, ids[ctx_pos])) def __len__(self): return len(self.pairs) def __getitem__(self, idx): return self.pairs[idx]窗口参数window决定每个中心词生成多少个上下文对。窗口为2时,每个位置最多生成4个样本;窗口为5时,最多生成10个,样本量接近语料token数的10倍。样本量变大,训练时间线性增长,但模型能学到更宽的共现关系。这里还有个经常被忽略的操作:高频停用词贡献了大量冗余样本,比如“的”“了”“is”“the”几乎和所有词共现,它们会挤占低频词的学习机会。Word2Vec论文里用subsampling解决,即按词频给每个词一个丢弃概率,高频词更容易被丢弃。
import math import random class SkipGramDatasetWithSubsampling(SkipGramDataset): def __init__(self, corpus, vocab, counter, window=2, subsample_threshold=1e-4): super().__init__(corpus, vocab, window) total_tokens = sum(counter.values()) self.drop_prob = {} for word, cnt in counter.items(): freq = cnt / total_tokens if freq > subsample_threshold: self.drop_prob[word] = 1 - math.sqrt(subsample_threshold / freq) def _keep(self, word): prob = self.drop_prob.get(word, 0.0) return random.random() > probsubsample_threshold常见取1e-4或1e-5。阈值越小保留的词越多,丢弃越温和。这个概率公式是经验公式,直接照用即可。如果觉得引入随机性影响复现,可以把清洗、min_count和停用词表都做好,subsampling作为后续质量优化项再加。
3.3 Skip-gram模型与负采样损失:20行代码完成核心训练
模型本身很简单,一个Embedding层加一个输出层。Embedding层查表得到的向量就是我们要的词向量。先看完整结构:
import torch import torch.nn as nn class SkipGramModel(nn.Module): def __init__(self, vocab_size, emb_dim): super().__init__() self.embedding = nn.Embedding(vocab_size, emb_dim) self.out_layer = nn.Linear(emb_dim, vocab_size, bias=False) def forward(self, center_ids): center_vec = self.embedding(center_ids) scores = self.out_layer(center_vec) return scoresout_layer把中心词向量映射到词表大小,对应完整softmax的得分。实际训练时如果用负采样,这个输出层根本不会被调用,因为计算量太大。真正被保留下来的只有embedding.weight。训练结束后,常见做法是只保存embedding.weight,也有人把out_layer.weight也存下来,把中心词向量和上下文词向量相加或求平均,作为最终表示。两种做法都能用,平均版在语义相似度上通常更平顺。
负采样损失单独写成一个函数更清晰。它不依赖模型里的out_layer,只依赖Embedding层:
def negative_sampling_loss(center_vec, ctx_ids, noise_ids, embedding, neg_num=5): batch_size = center_vec.size(0) ctx_vec = embedding(ctx_ids) # (batch, emb_dim) pos_score = torch.sum(center_vec * ctx_vec, dim=1) # (batch,) pos_loss = -torch.log(torch.sigmoid(pos_score) + 1e-8).mean() noise_vec = embedding(noise_ids) # (batch, neg_num, emb_dim) noise_score = torch.bmm(noise_vec, center_vec.unsqueeze(2)).squeeze(2) noise_loss = -torch.log(1 - torch.sigmoid(noise_score) + 1e-8).sum(dim=1).mean() return pos_loss + noise_losscenter_vec是(batch, emb_dim),ctx_ids是真实上下文词id,查embedding得到(batch, emb_dim),两者逐元素相乘再求和得到正样本得分。噪声词noise_ids是(batch, neg_num),经embedding变成(batch, neg_num, emb_dim),用bmm和中心词向量做批量矩阵乘法,算出每个噪声词的得分。正样本希望得分接近1,噪声样本希望接近0。1e-8防除零扰动。注意传入的embedding就是模型里的model.embedding,两者是同一个对象,梯度由loss.backward()输送给词向量。
3.4 训练主循环与断点保存:跑一版能用的参数组合
训练主循环里有一个关键设计:每次迭代重新采样噪声id,而不是用固定噪声表。如果噪声样本固定,模型很容易记住那几个固定负样本,收敛效果差。每轮结束后打印平均loss,用于粗略判断训练是否在推进。
def train_skipgram(model, dataloader, vocab_size, emb_dim, epochs=5, lr=0.001, neg_num=5): optimizer = torch.optim.Adam(model.parameters(), lr=lr) model.train() for epoch in range(epochs): total_loss = 0.0 for center, ctx in dataloader: noise = torch.randint(0, vocab_size, (center.size(0), neg_num)) center_vec = model.embedding(center) loss = negative_sampling_loss(center_vec, ctx, noise, model.embedding, neg_num) optimizer.zero_grad() loss.backward() optimizer.step() total_loss += loss.item() print(f"epoch {epoch+1} loss {total_loss / len(dataloader):.4f}")torch.randint生成均匀分布的噪声id,实现最简单。如果要复现原论文的噪声分布,用词频的0.75次方做加权采样,后面参数章会补上。训练时间以小时计时,checkpoint一定要存,而且要把词表一起存下来:
def save_checkpoint(epoch, model, vocab, config, path="ckpt.pt"): torch.save({ "epoch": epoch, "model_state": model.state_dict(), "vocab": vocab, "config": config, }, path)config里至少包含emb_dim、window、neg_num、min_count。恢复训练时从ckpt里读出这些配置,重新建模型再加载model_state。不保存词表是常见失误:模型权重和词表id是一一对应的,词表丢了,训练好的向量等于白训。
4. 训练词向量的关键参数:从维度到采样窗口的取舍
4.1 Embedding维度:64还是300,跟着语料规模走
Embedding维度是词向量的长度,也是下游模型最容易出现对接冲突的参数。维度过低,语义区分度不够,相近词容易挤在一起;维度过高,在小语料上反而容易过拟合,训练出的向量稀疏且相似度表现飘忽。经验范围大致是:语料token数在10万级用64到128,百万级用128到200,千万级用300。如果你只是为了给下游分类器提供静态表示,128通常是个不出错的起点。
还要考虑一个现实约束:下游分类器的Embedding层已经定好了维度。比如某开发者之前的下游模型写死了300维,词向量训成128维就直接加载报错。正确的顺序是先确认下游接口要多少维,再回头定词向量的emb_dim。维度这个参数宁可一开始对齐,也不要训练完再返工。
4.2 学习率与迭代轮数:用相似度任务代替loss判断收敛
Adam优化器下,lr=1e-3是稳定的起点。loss如果震荡得厉害,降到3e-4或5e-4。学习率不是越大越好,词向量训练的目标是把共现信息平滑地揉进向量,学习率过大会让低频词在几次更新里被大幅拉偏。迭代轮数方面,常见范围在3到15轮,超过后质量提升趋缓。关键是:loss降到平台并不代表词向量质量好。
更可靠的判断方式是每轮训练后在固定验证集上算词相似度。准备20组手标近义词,比如“高兴-开心”“手机-电话”,每轮结束后计算它们的平均cosine相似度,看这个指标是否还在上升。loss下降、相似度停滞,说明模型在过拟合语料的共现频率;相似度上升,说明语义结构还在成形。这个方法比盯loss曲线实用得多。
4.3 采样窗口、负采样个数与最小词频:质量与速度的平衡点
窗口大小决定模型能看到多宽的共现范围。窗口取2到3,近邻词偏向搭配关系,适合词性和句法任务;窗口取5到10,近邻词偏向主题和语义,适合文本分类、情感分析。窗口翻倍,训练样本量也接近翻倍,所以调窗口前先算好时间预算。
负采样个数neg_num默认5。语料中生僻词和专有名词多时,可以提到10到15;再多收益就开始递减,训练时间线性增加。噪声词的采样分布也要留意,均匀采样最简单,但原论文用的是词频的0.75次方分布,低频词被抽到的概率相对更高,训练效果更细:
freq_table = torch.tensor([counter[w] ** 0.75 for w in vocab_order]) noise_ids = torch.multinomial(freq_table, batch_size * neg_num, replacement=True) noise_ids = noise_ids.view(batch_size, neg_num)min_count的作用是去掉绝对低频词。5到10是常见值,语料很小可以降到2,低于2等于不过滤。min_count和subsampling可以配合:先用min_count去掉绝对低频,再用subsampling压掉高频停用词。低频词出现次数太少,向量更新不了几次,学出来基本是随机点,留着只会增加训练时间。
4.4 参数速查表与一组可复用的起步配置
把前几节的结论汇成一张表,方便训练前快速对齐:
| 参数 | 常见范围 | 起步默认值 | 主要观测量 | 调整方向 |
|---|---|---|---|---|
| emb_dim | 64-300 | 128 | 近邻相似度 | 小语料取小值 |
| lr | 1e-4 到 3e-3 | 1e-3 | loss、验证相似度 | 震荡就降 |
| epochs | 3-15 | 5 | 验证相似度趋势 | 平台期就停 |
| window | 2-10 | 5 | 近邻语义 | 主题任务放大 |
| neg_num | 5-20 | 5 | 训练速度和质量 | 生僻词多就增大 |
| min_count | 2-10 | 5 | 词表大小 | 词表过载就升 |
| batch_size | 64-1024 | 256 | 显存、收敛速度 | 显存不足就降 |
这套配置在百万词级语料上通常能跑出可用的baseline。跑完先看近邻效果好再谈优化,不要一上来就叠参数。
5. 词向量训练避坑指南:四个最容易翻车的现场
5.1 标点和停用词没清理,词表里全是噪声样本
现象:训练完查“高兴”的近邻,结果返回的前几名全是逗号、句号、“的”“了”“is”“the”。所有词的近邻都指向这些高频功能词,语义结构完全不可用。
原因:标点和停用词出现频率最高,它们几乎和所有词共现,训练出的向量落在语义空间中央,和任意词都有不低的相似度。min_count即便设为5也拦不住这类高频词,因为它们远远超过阈值。
解决:在clean_and_tokenize阶段用正则去掉标点,再维护一个停用词表统一过滤。中文语料重点处理“的、了、在、是、和”,英文语料处理“the、of、is、and”。如果语料里网址、邮箱、数字串成片,先做归一化替换再分词。清洗这步决定词向量质量的下限,值得多花时间。
5.2 低频词由着性子学,训练慢还学不准
现象:把min_count设为1,词表一下冲到30万,训练两天loss还在降,但查看一个只出现3次的词,近邻毫无语义关联,基本是随机向量。
原因:低频词在整个语料里只有几次曝光,随机初始化后更新不了几步,学不出稳定表示。它们还占用了词表的大部分id,拉长输出层规模,训练时间被白白消耗。
解决:把min_count调到5到10,低于阈值的词统一并入<unk>。如果业务上必须保留某些稀有实体,可以考虑在上游用规则把它们替换成占位符,比如“产品A”“品牌B”,而不是放任原始词形在语料里零星出现。不要指望词向量把只出现几次的词学好,这是数据量决定的。
5.3 训练完嵌入维度与下游模型对不上,结构返工
现象:训练好的词向量是128维,下游分类器里写死了nn.Embedding(vocab_size, 300)或者从旧的预训练权重里load 300维的向量,加载时报shape不匹配,只能回头重训。
原因:训练词向量前没有确认下游接口的Embedding维度,也没有把维度当成全局配置统一管理。很多项目是先有下游模型,后有词向量训练,两者各写各的,最后对接时就炸。
解决:把emb_dim提到公共配置里,下游模型和词向量训练共用同一个值。加载预训练权重时如果shape不一致,新建一个nn.Embedding再copy数据,不要指望load_state_dict自动适配。保存checkpoint时把config里的emb_dim存下来,恢复训练也能对得上。
5.4 随机种子不固定,实验对不了比
现象:同一份语料、同一套参数,连续跑两次训练,近邻词结果差别很大。调参时不知道提升是参数带来的还是随机性带来的。
原因:Embedding的随机初始化、DataLoader的shuffle顺序、负采样的噪声id都是随机源。没有固定种子,模型每次走向不同的局部最优。
解决:训练脚本开头统一设置随机种子,DataLoader传入固定generator:
import random import numpy as np import torch def seed_all(seed=42): random.seed(seed) np.random.seed(seed) torch.manual_seed(seed) if torch.cuda.is_available(): torch.cuda.manual_seed_all(seed) seed_all(42) g = torch.Generator() g.manual_seed(42) dataloader = DataLoader(dataset, batch_size=256, shuffle=True, generator=g)注意固定随机种子不会让所有平台上的结果完全一致,GPU并行和浮点累加顺序仍可能带来微小差异。固定种子的作用是让同一环境下的实验可复现,这是调参对比的前提。
6. 把训练好的词向量用起来:质量验证与Embedding对接技巧
6.1 三个快速验证方法:近邻、类比、可视化
训练完不要急着接下游,先用三个小方法确认词向量真的学到了语义。近邻查找是最直接的:
def nearest_neighbors(word, word_vectors, vocab_list, k=10): if word not in vocab: return [] idx = vocab[word] vec = word_vectors[idx] scores = torch.cosine_similarity(word_vectors, vec.unsqueeze(0), dim=1) top = scores.topk(k + 1).indices.tolist() return [(vocab_list[i], scores[i].item()) for i in top[1:]]vocab_list是词表的有序列表,直接传进来避免每次都做list(vocab.keys())。近邻结果里如果全是同主题词,说明语义结构不错。接下来做类比推理,经典例子是“man - woman + king ≈ queen”,在中文语料里可以换成“北京 - 中国 + 法国 ≈ 巴黎”这类关系对,验证词向量的方向性。最后用TSNE或PCA把高频词向量降到2维做散点图,肉眼看聚类。能看出清晰簇,基本可以放心接下游。
6.2 冻结再微调:把词向量接进下游分类器
接入下游模型时,最保险的流程是先冻结词向量,只训练分类头,等分类任务收敛后再解冻Embedding做微调。一上来就把Embedding放开,分类头还没学会,词向量空间已经被带偏,两个任务一起翻车。
def build_embedding_from_pretrained(pretrained, freeze=True): emb = nn.Embedding(*pretrained.shape) emb.weight.data.copy_(pretrained) emb.weight.requires_grad = not freeze return emb冻结阶段学习率用常规值,解冻后降到1e-4到1e-5。某开发者的教训是:解冻阶段学习率没降,一个epoch就把之前训好的词向量空间冲散,近邻验证全部失效,只能回滚checkpoint。所以解冻前一定先存一版冻结状态的模型。文本分类场景里,句子长度不同时,词向量序列经过池化或最后时刻表示再进分类头,这个接口设计也要在训练前定好。
我现在的习惯是,训练词向量之前先花10分钟看语料:统计词频分布、跑几个近邻验证,再决定min_count、窗口和维度。固定随机种子、定期存checkpoint、先冻结再微调,这些习惯都是从翻车里学来的。词向量模型看着简单,真正决定它有没有用的,往往不是模型结构,而是数据管线和参数的克制。希望帮到你。
本文还有配套的精品资源,点击获取