学习 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-DBOW
Doc2vec 的原理其实是在 word2vec 的基础上加了一个“段落向量”。它最早是 Mikolov 等人在 2014 年提出的,论文里的名字叫 Paragraph Vector。核心思想是:训练时不光给每个词分配一个向量,也给每一篇文档分配一个向量,然后让这个文档向量和滑动窗口内的词向量一起参与预测。
Doc2vec 有两条训练路线,对应两个不同的模型,我在选型的时候也纠结过一阵子:
- PV-DM,可以粗略理解成带段落向量的 CBOW。训练时,用段落向量加窗口内前几个词向量,一起去预测下一个词。这个模式把段落向量看成整个文本的“全局主题”,窗口里的词是“局部上下文”,两者拼在一起决定下一个词是什么。
- PV-DBOW,可以粗略理解成带段落向量的 Skip-gram。训练时,模型只需要拿到段落向量,然后去预测窗口里随机抽取的某个词。这个模式更单纯,它强制把整个文本的信息压缩进一个向量里。
如果你用 gensim 里的 Doc2Vec,参数设置就有讲究:dm=1表示用 PV-DM,dm=0表示用 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', encoding='utf-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 到 10 |
| min_count | 词频低于这个值就忽略 | 1 或 2 |
| dm | 模型选择,1 是 PV-DM,0 是 PV-DBOW | 1 |
| epochs | 训练轮数 | 20 到 100 |
| negative | 负采样数量 | 5 到 10 |
| sample | 高频词随机下采样阈值 | 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_size=100, window=8, min_count=2, dm=1, epochs=30, negative=5, sample=1e-5, workers=4, seed=42 ) model.build_vocab(documents) model.train(documents, total_examples=model.corpus_count, epochs=model.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 模式(dm=1)配合 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', encoding='utf-8') as f: text = f.read() words = preprocess_text(text) # 上面定义的分词函数 docs.append(TaggedDocument(words=words, 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, epochs=50)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(f'similarity: {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_components=2) result = pca.fit_transform(doc_vectors) plt.figure(figsize=(10, 8)) plt.scatter(result[:, 0], result[:, 1], s=8, alpha=0.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 之前,先花点时间把数据清洗做扎实,把文档长度和停用词表处理好。这个模型本质上就是一个数据驱动的采集器,它的效果上限,很大程度取决于你喂给它的文本有多干净。把这些基础打好,后面无论是做相似搜索还是文本分类,都会稳很多。