基于 Doc2vec 训练 sentence 向量

如何将一个句子、段落、或者文档用一个向量表示?词袋模型,该模型将每个文档转换为固定长度的整数向量。例如,给定以下句子:

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"]

词袋模型非常有效,但是存在以下两个缺点:

  1. 它们丢失了所有关于词序的信息:”John likes Mary” 和 “Mary likes John” 对应于相同的向量。有一个解决方案:n-gram 模型袋考虑长度为 n 的词短语将文档表示为固定长度的向量,以捕获局部词序,但会受到数据稀疏和高维的影响。
  2. 该模型不会尝试学习潜在单词的含义,词袋模型表示的向量之间的距离并不能够反映单词含义的差异。

Word2Vec 使用浅层神经网络将单词嵌入到低维向量空间中。其中,向量距离较近表示其在原文上下文中具有相似的含义。例如,strong 和 powerful 会很近,strong 和 Paris 会相对较远。

使用该Word2Vec模型,我们可以计算文档中每个单词的向量。但是如果我们想为整个文档计算一个向量呢?我们可以平均文档中每个单词的向量——虽然这既快速又粗略,但通常很有用。不过,还有更好的办法……

doc2vec paper :https://arxiv.org/pdf/1405.4053.pdf

下面主要根据论文描述了 doc2vector 的两种用于产生 doc vector 的模型,它们分别是:

  1. PV-DM(Distributed Memory Model of Paragraph Vector)
  2. 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()
未经允许不得转载:一亩三分地 » 基于 Doc2vec 训练 sentence 向量
评论 (0)

7 + 5 =