doc2vec实战:从word2vec到文档向量与文本相似度计算

📅 发布时间:2026/10/15 16:04:12
doc2vec实战:从word2vec到文档向量与文本相似度计算
学习 NLP 到这个阶段word2vec 已经满足不了我了——确切地说是满足不了文本级别的任务。词向量能把“苹果”和“香蕉”拉近但当你需要比较两段话、两篇文章、两份用户反馈的整体语义时word2vec 就使不上劲了。这篇笔记是 NLP 学习系列的第九篇专门记录我从 word2vec 过渡到 doc2vec 的过程包含模型原理、基于 Python3 的 gensim 实现以及一个完整的文本相似度计算案例。如果你也正好卡在这个环节或者做完词向量之后不知道怎么把“整篇文章”变成向量这篇内容可以直接当参考。我会把重点放在“怎么把 doc2vec 用起来”上先讲清楚它相对于 word2vec 到底多做了什么再给一套能直接跑的代码和调参思路。文章默认你懂基本的 Python会一点 jieba 分词但对 doc2vec 只有模糊概念。放心所有代码都是 Python3 实测可运行的。1. 从 Word2Vec 到 Doc2Vec核心思路拆解1.1 Word2Vec 的局限性词是表达了句子呢先说一个很实际的场景。我当时手里的任务是把一批用户评论按内容相似度归档比如“快递太慢了等了三天”和“物流拖了一个星期才到”应该归到一类。用 word2vec 的话我可以先把每个词的向量取出来然后把一句话里所有词的向量做平均得到一个句子的粗略向量。但这么做有个明显的毛病平均词向量会丢掉语序和关键信息。哪怕句子结构差别很大、主体完全不同只要用到的词差不多平均出来的向量就可能很接近反过来同样意思但用词完全不同的两句话平均向量又可能离得很远。这个矛盾在短文本上尤其明显。另一个问题是word2vec 训练出来后每个词只有一个固定的向量同一个词在不同语境下的语义变化完全没办法区分。“苹果”可以是水果也可以是手机品牌但 word2vec 只会给你一个向量。这两种问题叠加在一起导致我非常需要一个能把“整段文本”作为一个整体去建模的模型。很多朋友在这里会去试一些别的方案比如 BERT 系列模型。但当时我的场景是文本量中等、硬件条件有限、对实时性有要求而且需要快速迭代。直接上预训练大模型推理速度跟不上部署也麻烦。所以我把注意力放到了一个更轻量但专门为“文档向量”设计的模型上——doc2vec。1.2 Doc2Vec 的两个模型PV-DM 和 PV-DBOWDoc2vec 的原理其实是在 word2vec 的基础上加了一个“段落向量”。它最早是 Mikolov 等人在 2014 年提出的论文里的名字叫 Paragraph Vector。核心思想是训练时不光给每个词分配一个向量也给每一篇文档分配一个向量然后让这个文档向量和滑动窗口内的词向量一起参与预测。Doc2vec 有两条训练路线对应两个不同的模型我在选型的时候也纠结过一阵子PV-DM可以粗略理解成带段落向量的 CBOW。训练时用段落向量加窗口内前几个词向量一起去预测下一个词。这个模式把段落向量看成整个文本的“全局主题”窗口里的词是“局部上下文”两者拼在一起决定下一个词是什么。PV-DBOW可以粗略理解成带段落向量的 Skip-gram。训练时模型只需要拿到段落向量然后去预测窗口里随机抽取的某个词。这个模式更单纯它强制把整个文本的信息压缩进一个向量里。如果你用 gensim 里的 Doc2Vec参数设置就有讲究dm1表示用 PV-DMdm0表示用 PV-DBOW。实验跑下来我觉得 PV-DM 在大部分语义相似度任务上效果更好因为它把词序也考虑进去了但我最终用的是两个模型的向量拼接这招在很多竞赛方案里很常见。1.3 为什么它比平均词向量更靠谱Doc2vec 相比平均词向量最大的变化是文档向量在训练过程中就被作为一个“独立角色”参与更新。你可以这样理解平均词向量好比是把一堆乐高零件倒在地上拍一张照片零件全在但结构关系丢了doc2vec 则是一个一个零件拼完之后再给整个模型拍一张照片结构信息被完整保留。另外doc2vec 的训练目标是跟着当前语料走的。如果你的语料偏向某个领域它的段落向量会自然地学习到这个领域的表达方式。这就带来了一个实打实的好处在一个垂直领域里比如电商评论、医疗问答、法律文书doc2vec 的效果往往比通用词向量平均出来的句子表示更贴合你的业务。当然它也不是没有代价。doc2vec 不能像 word2vec 那样从网上直接下载训练好的通用向量来用你需要用自己的语料训练。所以数据量和训练语料的质量对最终效果的影响非常大。这一点我放到后面“常见问题”里专门展开因为它真的是最容易踩的坑。2. 环境准备与数据预处理2.1 依赖库与 Python 环境配置在开始写代码之前先把环境准备好。我用的是 Python 3.8 以上版本主要用到下面几个库pip install gensim jieba numpy scikit-learn matplotlibgensim 是核心负责 Doc2Vec 模型的训练和推断jieba 做中文分词numpy 处理向量运算scikit-learn 用来做分类或者评估的辅助验证matplotlib 用来画向量可视化图。安装的时候有一个小坑gensim 和 numpy 的版本如果过旧某些新写法的接口会缺失。建议尽量保持相对较新的版本至少确保 gensim 的版本不低于 4.0因为 4.x 内部改动了不少接口很多老教程里的写法跑不通就是因为版本问题。装完之后我习惯先用一段极小的测试文本确认模型能跑通再上完整数据这样可以避免在正式数据上排错排半天。2.2 理解 TaggedDocument数据格式是第一步gensim 的 Doc2Vec 不能直接吃纯文本它要求把每篇文档包装成一个 TaggedDocument 对象。这个对象的格式很简单第一项是分词后的词列表第二项是文档的唯一标签。from gensim.models.doc2vec import TaggedDocument # 注意words 必须是已经分词、去停用词之后的列表tags 必须是唯一的编号 doc TaggedDocument(words[快递, 太慢, 等了, 三天], tags[comment_001])标签的类型可以是字符串也可以是整数。如果语料量特别大用整数标签在内存上会更友好一些字符串标签则方便调试。我最开始用的是字符串后来数据量上来之后改成了整数。有一点需要注意同一个 TaggedDocument 对应一个标签一条文本就是一个样本。如果你的文档特别长超过了几个段落建议先把长文档切成段落或者短句再分别做向量否则最终得到的向量会过于“平均”把每个子主题的信息都互相抹掉。2.3 分词与停用词过滤的细节中文 NLP 绕不开分词。比较常见的做法是用 jieba 的精准模式然后去掉停用词、数字和标点。我一般会准备一个停用词表注意把标点符号、单个无意义字、语气词都加进去。import jieba import re stopwords set() with open(stopwords.txt, r, encodingutf-8) as f: for line in f: stopwords.add(line.strip()) def preprocess_text(text): text re.sub(r[^\u4e00-\u9fa5a-zA-Z0-9], , text) # 去掉标点 words jieba.lcut(text) words [w.lower() for w in words if w.strip() and w not in stopwords] return words这里有个经验不要把所有词都过滤得干干净净。比如“不错”“太慢”这类词虽然拆开来是副词加形容词但如果停用词表里包含了“不”“太”这类词反而会丢情感信息。我的做法是停用词表只过滤虚词、代词、无语义的语气词不碰可能参与情感表达的否定词和程度词。如果你用的是现成的停用词表一定要检查表里有没有“不”“没”“很”这种词。另外分词的时候不要贪快用 jieba 的搜索引擎模式或全模式精准模式就够了。全模式会把“北京大学”切成“北京”“大学”“北京大学”等多个词对文档向量的训练没有什么帮助只会增加噪声。3. 模型训练与参数调优3.1 常用参数解析vector_size、window、min_count、epochs这一步是整个 doc2vec 实践里最需要耐心的地方。先把关键参数用一张表说清楚后面的经验都围绕着它们展开。参数名作用我的经验取值vector_size文档向量的维度100 到 300 之间window上下文窗口大小5 到 10min_count词频低于这个值就忽略1 或 2dm模型选择1 是 PV-DM0 是 PV-DBOW1epochs训练轮数20 到 100negative负采样数量5 到 10sample高频词随机下采样阈值1e-5 左右workers并行线程数根据机器核心数设定vector_size 不是越大越好。维度太高训练时间和内存开销都会显著增加而且如果你的语料不够大高维度反而会学不到足够多特征向量会很稀疏。我一般先用 100 试跑等确定思路可行再对比 200 和 300 的效果。min_count 也要看语料规模。语料几万条的时候设成 2 可以过滤掉一大批只出现一次的噪声词但如果语料本身很小比如只有几千条设成 1 反而更好因为保留下更多词汇信息。总体上我建议先按语料的规模量级来定而不是使用某一个固定值。epochs 是很多人容易忽略的参数。Doc2Vec 训练一个 epoch 之后向量往往还远没有收敛。尤其在小数据集上50 到 100 个 epoch 是很常见的选择。但这也意味着训练时间是成倍增加的需要平衡。3.2 训练流程与模型保存数据准备好之后训练代码其实非常短gensim 已经把复杂度基本封装掉了。from gensim.models.doc2vec import Doc2Vec # documents 是经过分词包装的 TaggedDocument 列表 model Doc2Vec( vector_size100, window8, min_count2, dm1, epochs30, negative5, sample1e-5, workers4, seed42 ) model.build_vocab(documents) model.train(documents, total_examplesmodel.corpus_count, epochsmodel.epochs) model.save(doc2vec.model)关键词是build_vocab之后一定要重新传入total_examples和epochs。如果你直接写model.train(documents)gensim 4.x 会报错提醒你必须显式传入这两个参数。3.x 版本可以直接传4.x 开始强制这也算是一个版本迁移的常见坑。训练完成后用model.save(doc2vec.model)保存。保存完的模型是一个完整文件包括所有词向量、段落向量和训练配置之后重启程序不需要重新训练。3.3 参数到底怎么选从业务效果反推调参不能只看训练损失更要从最终任务反推。我通常会把调参分成两层第一层是确认模型能不能学出基本的语义结构。训练完之后随手找两条已知相近的文本用余弦相似度算一下如果结果明显不对优先怀疑数据预处理和参数设置。第二层才是跑下游任务比如分类、聚类、相似度检索用准确率、召回率或者业务指标来定最终参数。如果你要做文本分类vector_size 就关系不大epochs 和 dm 的影响更明显。我实际测下来PV-DM 模式dm1配合 50 到 100 个 epochs通常比 PV-DBOW 稳定。做相似度检索时模型的选择影响更大PV-DBOW 的向量更偏全局语义但丢掉一部分词序信息而拼接两个模型的向量往往综合效果最好虽然内存开销会翻倍。再补充一个关于窗口大小的具体经验。中文的词间距并不像英文那样天然有空格分隔加上分词粒度的影响window 设得太小模型只能捕捉局部的固定搭配设得太大又会把不相关的词也拉进上下文。我处理电商评论时用 8 左右比较顺手如果是短文本任务句子才十几个词window 设到 5 就已经覆盖了整个句子的上半部分。4. 案例实现文本相似度计算与可视化4.1 一个小型新闻语料的准备工作为了不涉及任何真实数据我拿一个完全虚构的“某小型新闻数据集”来做演示。数据格式很简单文件夹里每篇文章一个 txt 文件文件名作为文章的唯一标识。假设有大约 5000 篇新闻稿覆盖体育、科技、财经、娱乐等不同主题每篇大概 100 到 300 字。读取数据的代码如下import os from gensim.models.doc2vec import TaggedDocument docs [] all_files os.listdir(news_data) for i, filename in enumerate(all_files): filepath os.path.join(news_data, filename) with open(filepath, r, encodingutf-8) as f: text f.read() words preprocess_text(text) # 上面定义的分词函数 docs.append(TaggedDocument(wordswords, tags[i]))这里的 tags 使用递增整数好处是训练速度更快后续取文档向量时会方便很多当然你也完全可以用文件名做标签。4.2 训练结束后如何获取文档向量Doc2Vec 训练完之后获取文档向量有两种方式。第一种是直接从模型里取model.dv[tag]也就是 dictionary vector在 gensim 3.x 叫 model.docvecs[tag]。这种方式只对训练语料里出现过的文档有效因为段落向量在训练过程中被记录下来了。第二种是推断新文档的向量model.infer_vector(words)。这是 doc2vec 最有价值的地方之一它能给训练时没见过的全新文本计算出向量。# 获取训练过的文档向量 vec_train model.dv[0] # 推断一个全新的文档向量 new_words preprocess_text(这是一篇全新的新闻稿内容完全不在训练集里。) vec_new model.infer_vector(new_words, epochs50)infer_vector后面这个 epochs 参数我建议显式指定不要用默认值尤其是将模型 PV-DM 训练出来后。默认值在 gensim 4.x 里可能比较小推断出来的向量不够稳定。可以在 20 到 100 之间调整如果文档较短50 是一个比较稳的起点。注意infer_vector要求输入文本里的词必须是训练语料里见过的如果是完全没见过的 OOV 词模型会直接忽略。所以如果你要处理线上新文章尽量保证新文章里的用词和训练语料分布一致否则向量质量会大打折扣。4.3 余弦相似度计算找相似文本拿到文档向量之后最直接的任务就是算句子或文章之间的相似度。这里最常用的是余弦相似度。import numpy as np from numpy.linalg import norm def cosine_similarity(vec_a, vec_b): return np.dot(vec_a, vec_b) / (norm(vec_a) * norm(vec_b) 1e-8) vec_1 model.dv[100] vec_2 model.infer_vector(preprocess_text(某条同主题的新文档)) similarity cosine_similarity(vec_1, vec_2) print(fsimilarity: {similarity:.4f})如果你想把所有文档做两两相似度比较几千篇是能承受的但上万篇之后 O(n^2) 的计算量会越来越大。这时建议往两个方向扩展一是用模型自带的model.dv.most_similar(tag)方法它内部会做高效的向量检索二是把文档向量建好索引以后用向量检索库去查效果会快很多。我做的这个新闻案例里用model.dv.most_similar(0)拿到和第 0 篇新闻最相近的前 5 篇文章人工抽查下来同主题的新闻确实会被聚合到一起。4.4 用 PCA 降维画出文档向量分布光看数字总是不够直观。我习惯把文档向量降维到二维平面上看分布这一步能很快判断模型有没有学到语料的结构。直接用 PCA 就行数据点多了之后换 t-SNE 会更漂亮。import matplotlib.pyplot as plt from sklearn.decomposition import PCA # 收集所有文档向量 doc_vectors np.array([model.dv[i] for i in range(len(docs))]) pca PCA(n_components2) result pca.fit_transform(doc_vectors) plt.figure(figsize(10, 8)) plt.scatter(result[:, 0], result[:, 1], s8, alpha0.6) plt.title(Doc2Vec Document Vectors (PCA)) plt.show()如果向量分布是一大团完全没有规律的点多半是训练不充分或者数据预处理有问题如果看到明显的分簇趋势那意味着模型学到了主题之间的区分。你可以在散点图上把每个点的标签文本也标出来或者按已知类别标色能看到更细的聚类情况。用 PCA 时记住一个小技巧不要只看全局分布还要看看相似文档之间的距离到底有多近。如果同一主题的文档点没有聚成一堆说明向量质量还没有达到预期需要回到参数调整的步骤。5. 常见问题与踩坑实录5.1 训练损失怎么看它到底能不能作为好坏的评判标准Doc2Vec 训练过程中gensim 会输出每个 epoch 的 loss数字看起来一直在变化。很多新手朋友会盯着这个值判断模型好坏我这里要专门泼一盆冷水这个 loss 值只能说明模型的收敛程度不能说明下游任务效果。有时候 loss 很低但文档向量的区分度还是很差。我在实验中就遇到过一次某个参数组合下训练 loss 一路下降但拿去跑分类任务准确率反而下降了。原因很简单模型把训练样本的细节都记住了文档向量却失去了泛化能力。所以 loss 只作为是否收敛的参考最终还是要用下游任务来验收。如果你实在想控制 loss 的稳定性可以用较小的 learning_rate 配合多 epochs 来训练但这属于手动干预一般只在调参问题的最后阶段才做。5.2 新文档推断出来的向量不稳定怎么办新文档推断向量不稳定这是 doc2vec 使用中最高频的问题。同一句话infer_vector 跑两次结果有时候相差还挺大。原因是推断过程有随机性epochs 又太短的话结果波动会更明显。解决分三步第一显式传入足够大的 epochs比如 50第二训练前固定模型种子也就是 seed 参数第三把随机数的开关关掉在 gensim 里可以通过在训练时传epochs并且在build_vocab前设置random.seed来尽量保证可复现。即便这样完全消除波动也不现实。如果你要上线推荐或检索系统建议对同一个文本做多次 infer_vector取平均向量这样能有效减少噪声。实测下来5 次以上取平均稳定性会明显改善。5.3 语料太小练出来的模型像是随机向量这是我见过最多翻车的地方。很多人手里只有几百条文本却指望 doc2vec 能无中生有学出高质量语义。文档向量的训练依赖足够多的上下文窗口样本语料太少词语共现信息不足窗口采样的噪声太大练出来的向量基本只能当随机的用。我的建议是如果你语料不足 5000 条先把项目目标降低一些考虑用预训练的 word2vec 先做词向量再做平均或加权平均来凑合表达文本语义或者干脆用未监督的词频类方法比如 TF-IDF先把业务跑起来。硬上 doc2vec反而是给自己找麻烦。如果一定要用 doc2vec可以尝试迁移学习找一个和你领域相近的更大的公开语料先把 doc2vec 模型预训练出来再在你这几百条数据上继续训练这样能缓解不少数据不足的问题。5.4 分布不均衡带来的偏置问题这里的分布不均衡不仅是数据量上的还包括文本长度和主题分布。如果你的语料里大部分文档都是两三千字的长文少量是几十字的短标题那长文的向量权重会过高短文本的向量几乎被淹没。我建议训练前先统计一下文档长度分布把过长和过短的文本做归一化处理。过长的文章可以切片过短的文本可以适当合并尽量让所有文档的 token 数量落在某个合理区间内。en另外主题分布也很关键。比如语料里 90% 都是体育新闻模型学到的主要是体育相关的上下文剩下的财经、科技新闻的向量质量会在训练中被稀释。如果你想做跨类别的相似度计算最好先对语料做主题层面的平衡或者干脆分主题训练多个模型。5.5 与分类器结合时的坑最后一公里很多人训练好向量之后直接拿去跑 svm 或者逻辑回归发现效果还不如 TF-IDF。原因是 TF-IDF 是高维稀疏表示分类器能直接从维度区分特征doc2vec 得到的是低维稠密向量信息被高度压缩如果向量维度设置太小或者下游分类器学习不足效果反而更差。这种时候我会优先检查两个地方一是 vector_size 是否太小尝试 200 到 300二是下游分类器是否足够复杂小样本少量数据时线性分类器在稠密向量上未必比简单规则强多少可以考虑用一些非线性模型或者加一个简单的神经网络。要把 doc2vec 作为文本的深层次语义特征我常用的方式是把它和 TF-IDF 特征拼接起来然后过一层特征筛选再进分类器。这样既保留显式的关键词特征又补充了语义层面的信息实验里往往比只用其中一种要好。5.6 训练集和测试集的向量空间不一致问题这个坑比较隐蔽。如果你把全部数据一起训练 doc2vec再把文档向量拿去做分类的交叉验证其实已经引入了信息泄漏。因为测试集里的文本也参与了训练阶段模型已经见过它们的上下文文档向量里隐含着测试集的信息。正确做法是先给文本切分训练集和测试集然后用训练集的文本训练 doc2vec再用训练好的模型给测试集文本推断向量。这样才能模拟线上真实场景里新文档到来的情况。代码上只需要注意一点不要用model.dv[test_tag]去拿测试集的向量就算你当初把测试集的 TaggedDocument 也传进去了也应该用model.infer_vector来做推断。这个细节很多人没注意到。最后分享几个小经验这篇笔记写到这里核心内容已经说完了。如果你只是想要一个能跑通的 doc2vec 流程直接照着前几节的代码改改数据路径就行。但如果你想要模型真正在自己业务里生效我的经验是不要一开始就追求复杂的参数组合和模型拼接先跑通再逐步加入对方业务语义有帮助的细节。从我个人操作来看doc2vec 最适合的阶段是“文本量不大不小、语义相似度要求又足够高”的时候。它比 TF-IDF 的稠密向量更有表达力比预训练的大模型更轻量在垂直领域语料下只要调参得当性价比非常高。如果语料太少就先把数据攒一攒如果任务简单到词频就能解决也别为了技术而技术。最后再提醒一句训练 doc2vec 之前先花点时间把数据清洗做扎实把文档长度和停用词表处理好。这个模型本质上就是一个数据驱动的采集器它的效果上限很大程度取决于你喂给它的文本有多干净。把这些基础打好后面无论是做相似搜索还是文本分类都会稳很多。