如何将一个句子、段落、或者文档用一个向量表示?词袋模型,该模型将每个文档转换为固定长度的整数向量。例如,给定以下句子:
John likes to watch movies. Mary likes movies too. John also likes to watch football games. Mary hates football.
模型输出向量:
[1, 2, 1, 1, 2, 1, 1, 0, 0, 0, 0] [1, 1, 1, 1, 0, 1, 0, 1, 2, 1, 1]
每个向量有 10 个元素,其中每个元素计算特定单词在文档中出现的次数。元素的顺序是任意的。在上面的示例中,元素的顺序对应于单词:
["John", "likes", "to", "watch", "movies", "Mary", "too", "also", "football", "games", "hates"]
词袋模型非常有效,但是存在以下两个缺点:
- 它们丢失了所有关于词序的信息:”John likes Mary” 和 “Mary likes John” 对应于相同的向量。有一个解决方案:n-gram 模型袋考虑长度为 n 的词短语将文档表示为固定长度的向量,以捕获局部词序,但会受到数据稀疏和高维的影响。
- 该模型不会尝试学习潜在单词的含义,词袋模型表示的向量之间的距离并不能够反映单词含义的差异。
Word2Vec 使用浅层神经网络将单词嵌入到低维向量空间中。其中,向量距离较近表示其在原文上下文中具有相似的含义。例如,strong 和 powerful 会很近,strong 和 Paris 会相对较远。
使用该Word2Vec模型,我们可以计算文档中每个单词的向量。但是如果我们想为整个文档计算一个向量呢?我们可以平均文档中每个单词的向量——虽然这既快速又粗略,但通常很有用。不过,还有更好的办法……
doc2vec paper :https://arxiv.org/pdf/1405.4053.pdf
下面主要根据论文描述了 doc2vector 的两种用于产生 doc vector 的模型,它们分别是:
- PV-DM(Distributed Memory Model of Paragraph Vector)
- PV-DBOW(Distributed Bag Of Words Version of Paragraph Vector)
1. PV-DM
在 CBOW 中,我们输入上下文词,来预测中心词。简单的过程是输入 the、cat、sat 向量,并将向量拼接或者取平均,从而去预测 on 单词。
将上面的模型改动一下,在输入的地方增加一个额外的向量 D, 用来表示句子向量。即:输入除了 the、cat、sat 之外,还多了一个向量 D,将这 4 个向量平均或者拼接,去预测 on 单词。
上图中多出的 D 向量表示段落标记(paragraph token),可以被当做一个特殊的词,它扮演了记忆的角色。我想到了 RNN,它在每个时间步不停在向一个向量上去累积句子的信息,把最后一个时间步得到的向量作为整个句子的向量表示。只不过在这里这个用于累积信息的向量就是 D 而已。
模型的输入和 word2vec 是一样的,通过滑动窗口,将上下文词作为输入,其中一个词作为预测。而 D 向量则是在输入每个窗口词时,都要添加进来的,即:这个段落在滑动过程中,都需要输入 D 向量。但是 D 向量不在不同段落之间共享,段落不同就需要重新初始化 D 向量。
模型训练完成后,我们如何得到一个输入句子的向量呢?
RNN 训练完成之后,参数就会固定,我们依次累积计算每个时间步,最终就会得到句子的向量表示。PV-DM 模型训练完成之后,其参数是固定的,我们只需要将句子按照滑动窗口,扔到模型里进行计算,并计算损失,更新 D 向量的参数(其他学习完的参数不更新),当损失近乎稳定时,我们就可以拿到 D 向量做为句子向量表示了。
2. PV-DBOW
这种方式输入:段落向量 D,预测:随机从段落中抽取一个窗口,从窗口中随机抽取一个词。
每个句子向量可以由 PV-DM、PV-DBOW 产生,大多数任务使用 PV-DM 就可以胜任。但是作者建议结合两者来使用。
3. 训练词向量
Gensim 的 doc2vec 实现默认使用的是 pv-dm 模型,也可以通过设置 dm=0 来强制使用 DBOW 模型。
from gensim.models.doc2vec import TaggedDocument from gensim.models.doc2vec import Doc2Vec import pandas as pd from gensim.models.callbacks import CallbackAny2Vec from tqdm import tqdm import jieba jieba.setLogLevel(0) from data_select import select_questions class Callback(CallbackAny2Vec): def __init__(self, epoch): super(Callback, self).__init__() self.progress = tqdm(range(epoch)) def on_train_begin(self, model): self.progress.set_description('开始句向量训练') def on_epoch_begin(self, model): pass def on_epoch_end(self, model): self.progress.set_description('正在句向量训练') self.progress.update() def on_train_end(self, model): self.progress.set_description('结束句向量训练') self.progress.close() # 语料由所有的问题和答案组成 def build_train_corpus(): # 读取原问题、增强问题、问题答案 questions = select_questions() # 对文档进行标记 model_train_corpus = [] for qid, question in questions: tokens = jieba.lcut(question) # TaggedDocument 用于构建 doc2vec 的训练语料,第二个参数用于标记样本唯一性 document = TaggedDocument(tokens, [qid]) model_train_corpus.append(document) return model_train_corpus epochs = 100 def train_doc2vec_pvdm(): # 构建训练语料 documents = build_train_corpus() # 训练向量模型 model = Doc2Vec(vector_size=256, negative=128, alpha=1e-4, dm=1, min_count=2, epochs=epochs, window=5, seed=42) # 构建训练词表 model.build_vocab(documents) model.train(documents, total_examples=model.corpus_count, epochs=model.epochs, # start_alpha=1e-2, # end_alpha=1e-4, callbacks=[Callback(epochs)]) # 存储词向量模型 model.save('finish/semantics/doc2vec/pvdm.bin') def train_doc2vec_pvbbow(): # 构建训练语料 documents = build_train_corpus() # 训练向量模型 model = Doc2Vec(vector_size=128, negative=5, alpha=1e-4, dm=0, min_count=2, epochs=epochs, window=5, seed=42) # 构建训练词表 model.build_vocab(documents) model.train(documents, total_examples=model.corpus_count, epochs=model.epochs, # start_alpha=1e-2, # end_alpha=1e-4, callbacks=[Callback(epochs)]) # 存储词向量模型 model.save('finish/semantics/doc2vec/pvdbow.bin') if __name__ == '__main__': train_doc2vec_pvdm() # train_doc2vec_pvbbow()
3. 相似度比较
generate_question_embedding_doc2vec_single 函数使用 PV-DM 或者 PV-DBOW 来计算句子向量。
generate_question_embedding_doc2vec_combine 函数则是将 PV-DM 或者 PV-DBOW 计算结果拼接起来作为句子向量表示。最终将得到的句子向量存储到 faiss 数据库中。
示例代码:
import torch from gensim.models import Doc2Vec import numpy as np import pandas as pd import faiss from tqdm import tqdm import torch.nn.functional as F import jieba jieba.setLogLevel(0) from data_select import select_questions def generate_question_embedding_doc2vec_single(): estimator = Doc2Vec.load('finish/semantics/doc2vec/pvdm.bin') # estimator = Doc2Vec.load('finish/semantics/doc2vec/pvdbow.bin') questions = select_questions() database = faiss.IndexIDMap(faiss.IndexFlatIP(256)) progress = tqdm(range(len(questions)), desc='开始计算问题向量') ids, embeddings = [], [] for index, question in questions: embedding = estimator.infer_vector(jieba.lcut(question)) # 向量转换成单位向量 ids.append(index) embeddings.append(embedding.tolist()) # 存储问题向量及其编号 progress.update() progress.set_description('结束计算问题向量') progress.close() # 向量转换为单位向量 embeddings = F.normalize(torch.tensor(embeddings), dim=-1) # 存储向量索引对象 database.add_with_ids(embeddings, ids) faiss.write_index(database, 'finish/semantics/doc2vec/doc2vec.faiss') # 拼接 pvdm + pvdbow 模型的向量 def generate_question_embedding_doc2vec_combine(): estimator1 = Doc2Vec.load('finish/semantics/doc2vec/pvdm.bin') estimator2 = Doc2Vec.load('finish/semantics/doc2vec/pvdbow.bin') questions = select_questions() database = faiss.IndexIDMap(faiss.IndexFlatIP(256)) progress = tqdm(range(len(questions)), desc='开始计算问题向量') qids, embeddings = [], [] for qid, question in questions: # 拼接两个向量 question = jieba.lcut(question) embedding1 = estimator1.infer_vector(question) embedding2 = estimator2.infer_vector(question) embedding = torch.concat([torch.tensor(embedding1), torch.tensor(embedding2)], dim=-1) qids.append(qid) embeddings.append(embedding) progress.update() progress.set_description('结束计算问题向量') progress.close() # 将张量列表转换为张量类型 embeddings = torch.stack(embeddings, dim=0) # 向量转换为单位向量 embeddings = F.normalize(embeddings, dim=-1) # 存储向量索引对象 database.add_with_ids(embeddings, qids) faiss.write_index(database, 'finish/semantics/doc2vec/doc2vec.faiss') if __name__ == '__main__': generate_question_embedding_doc2vec_single() # generate_question_embedding_doc2vec_combine()