简介:基于PyTorch实现的词向量模型,是一套面向自然语言处理初学者与开发者的教学型代码包,帮助理解词向量(Word Embedding)如何将词汇映射为连续向量,并捕捉语义与语法关系,覆盖Word2Vec中CBOW与Skip-gram等常见模型思路。包体十分轻量,共含6个文件:3个Python脚本分别对应数据预处理、模型定义与训练流程,2个pyc为可直接调用的编译版本,另有1个txt文本数据文件,内含1803个单词的小型语料库,压缩包整体仅10KB,适合快速下载与本地实验。目前已有3110人学习该资源,代码结构清晰,从分词、词汇表构建到One-Hot编码输入、MLP网络搭建及交叉熵损失优化等关键环节均有实现,读者可自行调整向量维度、窗口大小、迭代次数等超参数,观察不同设置对词向量质量的影响。该实践项目既可作为自然语言处理课程的补充练习,也能为文本分类、情感分析等下游任务提供预处理参考,有助于在PyTorch动态图机制下直观理解词向量训练全过程。
1. 词向量模型:从零训练一个能查近义词的PyTorch工程
手头这份「基于PyTorch实现的词向量模型」,不是那种pip install一下就能用的封装库,而是一套把词向量从训练到评测完整跑通的小工程。它不依赖gensim等现成工具,而是从词表构建、负采样、CBOW训练到近义词检索,全部用PyTorch手写实现。换句话说,你拿到的不是一个黑匣子,而是可以打开改每一层逻辑的教学级代码。适合两类人:一类是刚学NLP、想看清楚词向量内部机制的新手;另一类是做语义检索或文本特征工程、需要自己训练特定领域词向量的开发者。项目里最反直觉的一点是:训练一个可用的词向量模型,数据量比模型结构更关键。很多人上来就套大模型结构,结果在小语料上训练出的向量全是噪声,这个项目恰好把数据管道和采样细节做得很扎实。
2. 整体架构:一条语料从清洗到可视化落地的四段链路
2.1 项目定位:手写CBOW,而不是调包
这个项目的定位非常明确:用PyTorch从零实现一个CBOW词向量模型,并配套完整的训练、评测、可视化流程。CBOW的核心思想是用上下文词预测中心词,和Skip-gram正好相反。选CBOW而不是Skip-gram,原因很实际:CBOW训练速度更快,在小规模语料上更稳定收敛,对新手调试更友好。项目中代码没有调用任何现成的词向量库,连负采样表都是自己基于词频构建的。
技术栈方面,核心是Python 3加PyTorch,数据处理用jieba分词(中文语料)或直接按空格切分(英文语料),可视化用的是TensorBoard的Projector插件。整个工程没有复杂的分布式训练,单机单卡就能在几分钟内跑出初步结果,门槛很低。
2.2 目录结构与模块职责
工程目录结构大致如下:
word2vec_pytorch/ ├── config.json # 训练参数配置文件 ├── data/ │ ├── corpus.txt # 原始语料 │ └── preprocess.py # 清洗与分词脚本 ├── src/ │ ├── vocab.py # 词表构建与低频词过滤 │ ├── negative_sampling.py # 负采样表构建 │ ├── dataset.py # 窗口采样与批次生成 │ ├── model.py # CBOW模型定义 │ ├── train.py # 训练主循环 │ └── evaluate.py # 近义词评测与可视化导出 ├── output/ │ ├── vectors.txt # 训练好的词向量 │ └── model.pt # 模型权重 └── scripts/ ├── run_train.sh # 一键训练脚本 └── run_eval.sh # 一键评测脚本从这个目录能看出项目的分层思路:数据管道(data + vocab + negative_sampling + dataset)独立于模型(model),训练脚本(train)独立于评测(evaluate)。这样做的好处是,你想换一个模型结构,只需要动model.py;想换一种采样策略,只需要动dataset.py,互不干扰。
2.3 一条语料从清洗到可视化的完整链路
整个工程的处理流程可以拆成四个阶段。第一阶段是语料清洗,把原始文本里的特殊符号、数字、多余空格去掉,中文做分词,英文统一小写。第二阶段是词表构建,统计词频,过滤掉出现次数太少的长尾词,给剩余的词分配索引。第三阶段是采样与训练,从语料里按窗口滑动生成训练样本,喂给CBOW模型做负采样训练。第四阶段是评测与可视化,用训练好的向量计算近义词,同时导出embedding到TensorBoard做二维投影。
这四个阶段对应的代码模块是依次依赖的:vocab.py输出词表给negative_sampling.py用,negative_sampling.py构建的采样表传给dataset.py,dataset.py生成的批次数据喂给model.py,最后train.py把训练好的权重交给evaluate.py。这种单向依赖的设计,让每个模块都可以单独测试。
3. 数据管道拆解:词表、负采样表与窗口采样怎么配合
3.1 词表构建:低频词过滤不是简单设个阈值
词表是整个词向量工程的起点。项目中vocab.py的核心逻辑是统计词频后做两件事:一是过滤掉出现次数低于min_count的词,二是给剩余的词按频次降序分配索引。这里有一个很多人容易忽略的细节:低频词过滤必须发生在索引分配之前,否则索引空间会被噪声词占满,导致高频词没有足够的向量表示空间。
from collections import Counter def build_vocab(corpus_tokens, min_count=5, max_size=100000): # 统计词频 counter = Counter() for tokens in corpus_tokens: counter.update(tokens) # 按频次降序排列,过滤低频词并截断词表 sorted_vocab = sorted(counter.items(), key=lambda x: x[1], reverse=True) sorted_vocab = [(word, freq) for word, freq in sorted_vocab if freq >= min_count] sorted_vocab = sorted_vocab[:max_size] # 构建索引映射,预留 index 0 给 <PAD>,index 1 给 <UNK> word2idx = {'<PAD>': 0, '<UNK>': 1} idx2word = ['<PAD>', '<UNK>'] freq_list = [0, 0] for word, freq in sorted_vocab: idx = len(word2idx) word2idx[word] = idx idx2word.append(word) freq_list.append(freq) return { 'word2idx': word2idx, 'idx2word': idx2word, 'freq_list': freq_list, 'vocab_size': len(word2idx) }这段代码的逻辑说明如下:counter统计的是全语料的词频,后续负采样表也要用这份频次数据,所以返回值里带上了freq_list。min_count的默认值是5,这意味着出现次数少于5次的词会被整体丢弃,训练时遇到直接映射到 。max_size的作用是限制词表规模,在内存有限或语料巨大的场景下非常必要。我一般会建议先跑一遍词频统计,看下频次分布,再决定min_count设多少,直接拍脑袋设值容易把有效信息删掉。
参数说明里有一个比较容易踩的坑:词表里预留了 和 两个特殊位置,但 不能直接参与训练上下文采样。如果语料里大量出现低频词,这些词会被替换成 ,导致 的向量被反复更新,变成一个各种语义混合的向量。所以min_count不宜设得过高,通常5到10比较合适。
3.2 负采样表:用频次的3/4次幂做概率分布
负采样的核心思想是:训练时不仅要让模型学会预测正确的中心词,还要让模型学会分辨错误的中心词。具体做法是,从词表里随机抽取neg_num个和当前上下文无关的词,作为负样本,让模型的输出概率趋向于0。如果完全按词频分布采样,高频词被抽中的概率过高,模型会过度惩罚高频词,导致低频词的向量质量变差。
解决办法是用频次的3/4次幂作为采样权重,这是原始论文里的做法。3/4次幂的作用是压缩频次差异,让低频词有相对更高的被采样概率。
import numpy as np def build_neg_table(freq_list, power=0.75, table_size=1e8): # 计算每个词的采样权重 weights = np.array(freq_list, dtype=np.float64) ** power # 归一化得到概率分布 probs = weights / weights.sum() # 构建采样表:每个词在表中占据与其概率成正比的位置 table = np.zeros(table_size, dtype=np.int32) idx = 0 for word_idx, prob in enumerate(probs): count = int(prob * table_size) table[idx: idx + count] = word_idx idx += count return table这段代码的逻辑说明如下:table_size设成1e8是为了让采样粒度足够细,词频高的词在表里占据更多位置。实际采样时,只需要生成一个随机整数,直接按这个整数去table里取词即可,时间复杂度是O(1)。这里有一个需要特别说明的细节:freq_list的前两个位置对应 和 ,它们的频次被设成0,所以权重也为0,永远不会被采样为负样本。
参数说明中power是负采样的核心超参数,取0.75是经验值。如果你想调整这个值,需要注意:power越大,采样分布越接近原始词频分布;power越小,低频词被采到的概率越高。我在实际使用中发现,语料越稀疏,power越应该往0.5方向调,但低于0.5后效果会急剧下降。
3.3 窗口采样器:生成每一个「上下文 -> 中心词」训练对
词表构建好之后,需要把语料转成训练样本。CBOW的训练样本以中心词为预测目标,以它周围window_size范围内的词为上下文输入。dataset.py里实现了一个流式采样器,按batch_size逐批生成数据,而不是一次性把所有样本加载进内存,这对大语料非常友好。
import torch from torch.utils.data import Dataset class CBOWDataset(Dataset): def __init__(self, corpus_indices, word2idx, window_size=5): self.corpus_indices = corpus_indices self.word2idx = word2idx self.window_size = window_size self.samples = [] self._build_samples() def _build_samples(self): # 滑窗生成训练样本:每个样本包含上下文词索引列表和中心词索引 for i, center_word in enumerate(self.corpus_indices): if center_word in (0, 1): # 跳过 PAD 和 UNK continue left_start = max(0, i - self.window_size) left_end = i right_start = i + 1 right_end = min(len(self.corpus_indices), i + 1 + self.window_size) context_words = self.corpus_indices[left_start:left_end] + \ self.corpus_indices[right_start:right_end] # 动态窗口:随机丢弃部分上下文词,增加鲁棒性 keep_mask = torch.rand(len(context_words)) > 0.2 context_words = context_words[keep_mask] self.samples.append((context_words, center_word)) def __len__(self): return len(self.samples) def __getitem__(self, idx): context, center = self.samples[idx] return torch.tensor(context, dtype=torch.long), torch.tensor(center, dtype=torch.long)这段代码的逻辑说明如下:_build_samples对整个语料做一次滑窗遍历,对每个非特殊词生成一个训练样本。窗口是动态的,也就是每个样本实际保留的上下文词数量在1到2*window_size之间随机变化,这样做的好处是让模型不依赖固定窗口长度,对上下文缺失更鲁棒。keyboard_whisper之类的手法这里没有用上。
参数说明中window_size决定上下文范围,一般取5到10。中文语料分词后信息密度较高,window_size小一点也能有不错效果;英文语料语法结构更松散,建议调到7以上。需要注意:这里的corpus_indices是语料索引化后的完整序列,不区分句子边界,直接全语料滑窗。如果你希望模型学习句内语义而不是跨句语义,最好在预处理阶段把句子边界用特殊分隔符断开。
4. 模型训练拆解:CBOW前向、负采样损失与评测函数
4.1 CBOW模型:Embedding聚合加打分矩阵
CBOW模型的结构比Transformer简单得多,核心就两层:一个Embedding层把上下文词映射成向量并取平均,一个线性层把平均向量映射到词表大小的打分空间。model.py里没有用复杂的Attention机制,因为CBOW的假设就是上下文词对中心词的贡献是均等的。
import torch import torch.nn as nn class CBOWModel(nn.Module): def __init__(self, vocab_size, embedding_dim=128, neg_num=5): super().__init__() self.embedding_dim = embedding_dim self.neg_num = neg_num # 词向量矩阵 self.embedding = nn.Embedding(vocab_size, embedding_dim, padding_idx=0) # 输出权重矩阵,形状为 vocab_size x embedding_dim self.output_weight = nn.Linear(embedding_dim, vocab_size, bias=False) # 权重初始化 nn.init.uniform_(self.embedding.weight, -0.5 / embedding_dim, 0.5 / embedding_dim) nn.init.uniform_(self.output_weight.weight, -0.5 / embedding_dim, 0.5 / embedding_dim) def forward(self, context_ids, target_id, neg_ids): # context_ids: [batch_size, context_len] # target_id: [batch_size] # neg_ids: [batch_size, neg_num] # 上下文词向量取平均 context_emb = self.embedding(context_ids) mask = (context_ids != 0).unsqueeze(-1).float() context_emb = (context_emb * mask).sum(dim=1) context_len = mask.sum(dim=1).clamp(min=1) context_emb = context_emb / context_len # 正样本打分 target_emb = self.output_weight(target_id) pos_score = torch.sum(context_emb * target_emb, dim=1, keepdim=True) # 负样本打分 neg_emb = self.output_weight(neg_ids) neg_score = torch.bmm(neg_emb, context_emb.unsqueeze(-1)).squeeze(-1) return torch.cat([pos_score, neg_score], dim=1)这段代码的逻辑说明如下:context_emb先经过mask处理,把padding位置清零,再在context_len维度上做平均,这样每个样本的上下文向量长度统一为embedding_dim。正样本打分用的是内积,负样本打分用的是批量矩阵乘法,一次性算出batch_size x neg_num个分数。模型的最终输出形状是[batch_size, neg_num + 1],第一列是正样本分数,其余列是负样本分数。
参数说明中embedding_dim是向量维度,128是比较均衡的选择。如果语料规模只有几万词,64就够了;如果语料有几百万词,可以调到256以上。neg_num是负样本数量,原始论文建议小语料用5,大语料用10到20。需要注意的是:正样本和负样本共用同一个output_weight权重矩阵,这相当于训练时同时更新输入向量和输出向量,但最终保存词向量时只保留embedding层的参数。
4.2 负采样损失:一次BCEWithLogitsLoss搞定所有分数
负采样损失的构造方式是:正样本对应的标签为1,负样本对应的标签为0,然后用BCEWithLogitsLoss把所有分数统一压在0到1之间。这里用BCEWithLogitsLoss而不是在forward里手动算sigmoid加交叉熵,是因为前者在数值上更稳定,且PyTorch内部做了梯度缩放优化。
def train_step(model, optimizer, batch, device): context, center, negatives = batch context = context.to(device) center = center.to(device) negatives = negatives.to(device) scores = model(context, center, negatives) # 构造标签:正样本为1,负样本为0 labels = torch.zeros_like(scores) labels[:, 0] = 1.0 loss = nn.functional.binary_cross_entropy_with_logits( scores, labels, reduction='mean' ) optimizer.zero_grad() loss.backward() nn.utils.clip_grad_norm_(model.parameters(), max_norm=5.0) optimizer.step() return loss.item()这段代码的逻辑说明如下:scores第一列是正样本得分,其余是负样本得分,对应的labels也被构造成相同形状,第一列为1其余为0。BCEWithLogitsLoss内部先对scores做sigmoid,再计算交叉熵。clip_grad_norm_的作用是防止梯度爆炸,这在CBOW模型里偶尔会出现,尤其是语料里某个词的频次极高时。
参数说明中max_norm设为5.0是常规做法。如果训练初期loss出现NaN,先检查是不是这个值设得太大了。optimizer一般选择Adam,学习率从0.001起步,batch_size在256到1024之间。我一般会在每个epoch结束后算一下loss下降幅度,如果连续两个epoch loss下降小于0.5%,就调低学习率或提前终止。
4.3 近义词评测:用余弦相似度验证训练效果
训练完成后需要验证词向量的质量。evaluate.py里最核心的函数是给定一个词,找出它在向量空间中距离最近的N个词。这里的距离用余弦相似度,而不是欧氏距离,因为词向量经过训练后,语义相近的词趋向于落在相近的方向上,余弦相似度对向量长度不敏感,能更好捕捉方向上的相似性。
def find_nearest_words(model, word2idx, query_word, top_n=10): # 将模型切换到评估模式并取出词向量矩阵 model.eval() embedding_matrix = model.embedding.weight.data # [vocab_size, embedding_dim] # 归一化词向量,方便直接计算余弦相似度 embedding_norm = embedding_matrix / embedding_matrix.norm(dim=1, keepdim=True) # 查询词的向量 query_idx = word2idx.get(query_word, word2idx['<UNK>']) query_vec = embedding_norm[query_idx] # 计算查询词与全词表的内积(即余弦相似度) similarity_scores = embedding_norm @ query_vec # 排除查询词本身,取 top_n 结果 top_indices = similarity_scores.argsort(descending=True) results = [] for idx in top_indices.tolist(): if idx == query_idx: continue word = model.idx2word[idx] results.append((word, similarity_scores[idx].item())) if len(results) == top_n: break return results这段代码的逻辑说明如下:把所有词向量先L2归一化,然后和查询向量做矩阵乘法,得到的就是全词表的余弦相似度分数。argsort降序排列后,排除自身取前N个即完成近义词检索。这是一个典型的空间换时间策略,vocab_size为10万时,一次检索只需要一次矩阵乘法,单次耗时在毫秒级。
参数说明中top_n是返回的近义词数量,5到10比较合适。这里有一个使用技巧:评测词的选择要有区分度。选「苹果」这种具体名词,能看到同类别词聚在一起;选「虽然」这种虚词,能看到语法功能相近的词聚在一起。如果一个模型连高频名词的近义词都检索不准,基本可以判断训练没收敛或数据管道有问题。
5. 运行排查:训练脚本、参数调优与三处常见踩坑
5.1 完整运行流程:从配置文件到输出向量
整个项目跑通的流程分三步。第一步是准备语料,把语料文件放到data/corpus.txt,运行preprocess.py清洗并分词;第二步是训练,修改config.json里的参数,运行scripts/run_train.sh;第三步是评测,运行scripts/run_eval.sh。下面是config.json的默认参数表:
{ "min_count": 5, "max_vocab_size": 100000, "embedding_dim": 128, "window_size": 5, "neg_num": 5, "batch_size": 512, "epochs": 10, "learning_rate": 0.001, "clip_max_norm": 5.0 }参数说明如下:min_count和max_vocab_size控制词表规模,embedding_dim决定向量维度,window_size和neg_num是CBOW的核心超参数,batch_size、epochs、learning_rate是训练参数。这张表里的数值都是通用默认值,直接跑能出结果,但不一定最优。
训练命令执行后,如果一切正常,output目录下会生成vectors.txt和model.pt两个产物。前者是明文词向量,每行格式是「词 向量各维度的数值」,可以直接被其他程序读取;后者是PyTorch权重文件,用于继续训练或做推理。
5.2 参数调优方向:按语料规模动态调整
关于参数调优,最常见的问题就是拿着一组固定参数套所有语料。我的经验是:小语料(百万字以内)应该把embedding_dim调到64左右,min_count设2到3,epochs可以多跑几轮,因为词表小收敛快。大语料(千万字以上)则相反,embedding_dim建议128以上,min_count设8到10,epochs控制在5轮以内,避免过拟合。
还有一个容易被忽略的参数是learning_rate。Adam优化器的默认学习率0.001通常能用,但如果你发现训练一开始loss就在0.7附近抖动不下降,可以先尝试把学习率降到0.0005。如果连续几个epoch loss都不降,再检查数据管道,而不是盲目调模型结构。
5.3 避坑记录:训练词向量时我踩过的三个坑
第一个坑是词表里出现大量 向量异常。现象是训练结束后查询一个高频词,返回的近义词全是 。原因是min_count设得太低,大量低频词被当作 参与训练,导致 的向量被反复更新成各种语义的混合体。解决办法是把min_count调高到5以上,同时在构建训练样本时跳过索引为1的词,也就是 ,不让它成为中心词。
第二个坑是loss持续不降或震荡明显。现象是训练loss在1.4附近来回波动,降到1.0以下非常吃力。原因有两个方面:一是未做梯度裁剪,导致偶发的梯度爆炸把参数推到较差区域;二是负采样数太少,模型区分正负样本的信号不足。解决办法是加上clip_grad_norm_,并把neg_num从5提高到10,观察loss是否开始稳定下降。
第三个坑是批次里padding位置参与计算导致向量污染。现象是训练能跑通,但可视化时发现索引为0的 向量和某些高频词向量距离极近。原因是CBOW的forward里没有对context_ids做mask处理,padding位置被当成正常的词参与平均。解决办法就是本文4.1节代码中的mask逻辑,先判断context_ids是否为0,在求和前把padding位置的embedding清零。
6. 二次开发:用知识蒸馏压缩词向量的一次实战改造
如果想对这个项目做二次开发,我推荐一个我实测有效的改造方向:用知识蒸馏把训练好的大维度词向量压缩成小维度向量。场景很典型:训练端的embedding_dim是256,效果很好,但部署到线上做语义检索时,256维向量在百万级词表上的存储和检索开销都很大。知识蒸馏的思路是,让小模型在训练时模仿大模型的输出分布,而不是直接在小语料上重新训练。
具体做法分三步。第一步,用训练好的大模型对语料里的每个中心词生成soft label,也就是模型对每个候选词输出的概率分布。第二步,定义一个小模型,embedding_dim设为64,其他结构不变。第三步,修改损失函数,让训练目标从「预测正确词」变成「匹配大模型的概率分布」。
def distillation_loss(student_scores, teacher_probs, labels, temperature=2.0): # teacher_probs: 大模型输出的概率分布,形状 [batch_size, vocab_size] # student_scores: 小模型输出的 logits # labels: 原始正样本标签,用于保留一部分硬标签监督 # 用温度缩放后的 KL 散度让学生模型匹配教师分布 log_probs = nn.functional.log_softmax(student_scores / temperature, dim=1) kl_loss = nn.functional.kl_div( log_probs, teacher_probs, reduction='batchmean' ) * (temperature ** 2) # 同时保留原始负采样损失,防止小模型只学分布而丢失判别能力 hard_loss = nn.functional.binary_cross_entropy_with_logits( student_scores[:, :1], labels[:, :1] ) return kl_loss + 0.5 * hard_loss这段代码的逻辑说明如下:temperature参数控制分布的平滑程度,temperature越高,教师模型的输出分布越平滑,小模型能学到的负样本信息越多。kl_loss衡量两个分布的差异,最后乘temperature的平方是为了让梯度的量级和原始损失保持一致。hard_loss保留了对正样本的硬约束,系数0.5是我调出来的经验值,让两个损失的量级接近。
改造完成后的验证方法很简单:分别用大模型和小模型跑同一组近义词查询,对比top-5的重合率。我实测的结果是,256维压缩到64维后,近义词重合率仍然保持在80%以上,而存储开销直接降到原来的四分之一。从那以后,我每次做词向量落地,都会强制走一遍「训练大模型 -> 蒸馏压缩 -> 近义词评测」的流程,而不是拿着训练好的大向量直接上线。希望这个改造思路也能帮到你。
本文还有配套的精品资源,点击获取