Distributed Representations of Sentences and Documents(Doc2vec)精读分享

转载 2019-11-12 22:55  阅读 31 次 评论 0 条

论文原文链接

Distributed Representations of Sentences and Documents

论文导读

1. 句子分布式表示

什么叫做句子的分布式表示?顾名思义,和词的分布式表示一样,句子分布式表示指的就是将一个句子,使用向量的形式代表它。有什么好处呢?好处就是:我们知道,词的分布式表示,也就是词向量,可以将词的语义信息和语法信息囊括在向量中,怎么解释呢?从语义的角度来看,经过训练后的词向量,“经济适用房”和“经适房”这两个词的距离(欧式距离或者其他距离等)会比较相近。从语法的角度来看,名词们会聚集在一起,动词们会聚集在一起。从语义特征的应用上来分析,我们可以做文本分类,信息检索,机器翻译。从语法特征的应用上来分析,可以将其应用于词性标注,实体识别。那么,使用句向量代表一个句子,也同样可以将其应用于文本分类,信息检索,机器翻译等等的领域。

2.句子分布式表示的历史模型

在这篇文章之前的历史里,有很多方法来做到使用句向量代表一个句子。主要分成两大类:

  • 基于统计的句子分布式表示
  • 基于深度学习的句子分布式表示

2.1 基于统计的句子分布式表示

在这里,基于统计的句子分布式表示我主要将两种方法,一种是Bag-of-words,另一种是Bag-of-n-grams。我们先将下Bag-of-words的方法。Bag-of-words方法很简单,假设我们的语料只有“我/看/了/一篇/博客/,/这篇/博客/让/我/学习到/了/很多”这句话,句子中我直接使用了斜杠代表分好词的结果。那么经过,词频数的统计,我们可以得到如下信息,

一篇 博客 这篇 学习到 很多
频数 2 1 2 1 2 1 1 1 1 1

于是,我们便使用[2, 1, 2, 1, 2, 1, 1, 1, 1, 1]这个向量代表这个句子。

其算法流程是这样的:

  • 根据语料构建一个词表,词表中,每个词都会有个索引。
  • 对于一句话sentence,统计其出现过的词及其词频数。
  • 将这个统计的词频数写成一个向量,这个向量就代表这个句子。

以上便是Bag-of-words的主要思想。那Bag-of-n-gram又是怎么样的呢?大家可以先去了解下n-gram。在这里,我们拿Bag-of-2-gram来举例子,对于同样的语料,“我/看/了/一篇/博客/,/这篇/博客/让/我/学习到/了/很多”这句话,Bag-of-2-gram去统计“我/看”,“看/了”,“了/一篇”,“一篇/博客”,“博客/,”,“,/这篇”,“这篇/博客”,“博客/让”,“让/我”,“我/学习到”,“学习到/了”,“了/很多”这些组合出现的频数,然后再将这个频数组成的向量作为这个句子的向量。

经过上面的讲解,大家可以想想,Bag-of-words和Bag-of-n-gram有什么缺点的呢?那就是句向量的维度会很大,并且句向量中很多部分都是0,也就是向量会非常稀疏。因此后续也有学者使用PCA,SVD等方法将其降维,降到一个维度较小的向量,方便计算。但是大家仔细想想也可以知道,这种方法肯定是存在缺陷的。比如,有以下缺点:

  • 由于是词袋模型,因此就丢失了一些位置信息。
  • 句向量只是单纯的利用了统计的信息,并没有将一些语法囊括进去。

对于Bag-of-n-grams模型来说,也有上述的缺点。

2.2 基于深度学习的句子分布式表示

在这里,基于深度学习的句子分布式表示的方法我主要讲两种方法,一种是加权平均法,另一种是深度学习模型。

我们首先讲一下加权平均法的算法思路,其流程是这样的:

  • 根据语料构建词表
  • 使用词向量的模型(如Skip-gram或者语言模型)训练词向量,得到每个词的词向量
  • 将某个句子中的每个词的词向量,相加然后取平均,得到该句子的句向量。

以上便是加权平均法的算法思路,思路非常简单,效果也不错。只不过这个方法没有将词的顺序考虑进去。

接下来我们讲一下深度学习模型的方法,如下图

算法的流程是这样的:

  • 根据语料构建词表
  • 使用词向量的模型(如Skip-gram或者语言模型)训练词向量,得到每个词的词向量。或者不训练也没关系。
  • 如果上一步骤已经训练出了词向量,那就将句子“我/看/了/这篇/博客”先找到词向量,然后按上面的流程放入到模型中,最终得到一个句向量。如果上一步骤中没有提前训练好词向量,那也没关系,随机初始化词向量也可以的。

从上述的讲解可以知道,这个模型是需要标注数据的,而现实情况是,基于某个业务,标注数据是很难获得的。或者说标注数据是很昂贵的。因此这个模型并不具有通用性。

3. 精读论文前必要的知识储备

  • 熟悉词向量相关的知识,如word2vec。
  • 了解使用语言模型训练词向量的方法,如NNLM,ELMOs,BERT。有些人认为word2vec不算是一种语言模型,这个我觉得见仁见智。总之,word2vec可以训练出词向量,语言模型也可以训练出词向量。

4. 论文abstract和introduction

论文的“摘要”主要介绍了句向量表示的概念和意义,以往句向量表示模型以及它的缺点。本文提出的模型以及它的优点,以及新模型的效果。

论文的“介绍”部分主要提了一些过往的模型,比如基于统计的句子分布式表示和基于深度学习的句子分布式表示。其实也就是本博客前面所讲的内容。

5. 论文提出的模型及其我个人的理解

PV-DM(Distributed Memory version of Paragraph Vector)

PV-DM(Distributed Memory version of Paragraph Vector)模型如上图,大家可以看到,其实模型和word2vec(戳此进入我的word2vec讲解博客)中的CBOW很像,不过仔细观察可以发现,word2vec的CBOW模型,输入是窗口中中心词的周边的词,而该篇论文中,PVDM模型的输入是窗口中除最后一个词外的所有的词,外加该文档的向量,然后去预测窗口中的最后一个词。其实,这就是语言模型。

总结一下,其算法是这样的:类似于NNLM(戳此进入我的NNLM讲解博客)的语言模型,拿前面几个词去预测下一个词。与NNLM不同的是,模型还将一个句子映射成一个向量,一起放入输入,去联合预测下一个词。

在训练阶段:

首先,通过训练集构建词典,并且随机初始化词向量矩阵,以及句向量矩阵,然后设置n-gram窗口大小(也就是滑动窗口大小)。然后通过不断的迭代,训练词向量矩阵和句向量矩阵。

在测试阶段:

我们固定词向量以及模型中的其他参数,然后随机初始化要预测的句子的向量矩阵,然后利用梯度下降法去训练要预测的句子的向量矩阵,模型收敛时,测试集中的句向量就生成了。

PV-DBOW(Distributed Bag of Words version of Paragraph Vector)

论文中还讲了另一种模型,就是PV-DBOW,其实它的原理和word2vec(戳此进入我的word2vec讲解博客)里面的skip-gram有点像,只不过输入变成了句向量,然后label变成了这个句子中的某个词。

6. 论文复现代码

完整代码地址:https://github.com/haitaifantuan/nlp_paper_understand

代码下载:nlp_paper_understand-master

数据下载:data

7. 语料预处理

首先,我们需要对语料做预处理操作。

代码如下,预处理的操作包括,读取原始语料、建立词表、将原始语料转换成id形式等操作。)

#coding=utf-8
'''
Author:Haitaifantuan
'''
import os
import nltk
import numpy as np
import collections
import pickle
import random
 
 
class Data_preprocessing(object):
    '''
    这个类是用来对数据进行预处理,根据aclImdb数据里面,有5万是带情感标签的数据,有5万是不带情感标签的数据。
    带标签的数据中,有2.5万是训练集的标签,另外2.5万是测试集的标签。
    2.5万训练集的数据中,有1.25万是负面情绪的标签,1.25万正面情绪的标签。
    2.5万测试集的数据中,有1.25万是负面情绪的标签,1.25万正面情绪的标签。
    '''
    def __init__(self):
        # 以下参数需要配置=======================================
        self.window_size = 10
        # 以上参数需要配置=======================================
 
        # 判断文件夹存不存在,不存在就创建一个
        if not os.path.exists('./saved_things/'):
            os.mkdir('./saved_things/')
        if not os.path.exists('./saved_things/model'):
            os.mkdir('./saved_things/model')
        if not os.path.exists('./saved_things/data_pickle'):
            os.mkdir('./saved_things/data_pickle') 
        if not os.path.exists('./saved_things/model/training_set_doesnt_finished_model'):
            os.mkdir('./saved_things/model/training_set_doesnt_finished_model')
        if not os.path.exists('./saved_things/model/training_set_classification_finished_model'):
            os.mkdir('./saved_things/model/training_set_classification_finished_model')
 
        if not os.path.exists('./saved_things/data_pickle/test-input-data-可删除.pickle'):  # 判断某个文件存不存在,不存在就重新读取并且创建这些文件
            # 从本地读取原始语料,并且将其分词后,返回回来。
            self.train_neg_raw_data, self.train_pos_raw_data, self.test_neg_raw_data, self.test_pos_raw_data, self.upsup_raw_data = self.read_all_raw_data_from_local_file()
            # 统计词频,构造常用词词表。
            self.word2id_dictionary = self.construct_word_dictionary()
            # 保存成pickle,下次继续训练可以加快代码运行速度
            with open('./saved_things/data_pickle/all_data_not_combined-可删除.pickle', 'wb') as file:
                pickle.dump([self.train_neg_raw_data, self.train_pos_raw_data, self.test_neg_raw_data, self.test_pos_raw_data, self.upsup_raw_data, self.word2id_dictionary], file)
 
            # 根据常用词词表,将raw_data转换为词所对应的id。
            self.train_zipped, self.test_zipped, self.upsup_raw_data_converted_to_id = self.convert_data_to_word_id_and_shuffle()
            # 保存成pickle,下次继续训练可以加快代码运行速度
            with open('./saved_things/data_pickle/shuffled_data_combined-可删除.pickle', 'wb') as file:
                pickle.dump([self.train_zipped, self.test_zipped, self.upsup_raw_data_converted_to_id], file)
 
            # 基于转换为id后的训练数据,构建input样本。
            self.train_embedding_word_input_data, self.train_embedding_sentence_input, self.train_embedding_labels, self.train_sentiment_input, self.train_sentiment_labels = self.construct_input_data_and_label(self.train_zipped)
            # 基于转换为id后的测试数据,构建input样本。
            self.test_embedding_word_input_data, self.test_embedding_sentence_input, self.test_embedding_labels, self.test_sentiment_input, self.test_sentiment_labels = self.construct_input_data_and_label(self.test_zipped)
            # 保存成pickle,下次继续训练可以加快代码运行速度
            with open('./saved_things/data_pickle/train-input-data-可删除.pickle', 'wb') as file:
                pickle.dump([self.train_embedding_word_input_data, self.train_embedding_sentence_input, self.train_embedding_labels, self.train_sentiment_input, self.train_sentiment_labels], file)
            with open('./saved_things/data_pickle/test-input-data-可删除.pickle', 'wb') as file:
                pickle.dump([self.test_embedding_word_input_data, self.test_embedding_sentence_input, self.test_embedding_labels, self.test_sentiment_input, self.test_sentiment_labels], file)
 
        else:
            with open('./saved_things/data_pickle/all_data_not_combined-可删除.pickle', 'rb') as file:
                self.train_neg_raw_data, self.train_pos_raw_data, self.test_neg_raw_data, self.test_pos_raw_data, self.upsup_raw_data, self.word2id_dictionary = pickle.load(file)
            with open('./saved_things/data_pickle/shuffled_data_combined-可删除.pickle', 'rb') as file:
                self.train_zipped, self.test_zipped, self.upsup_raw_data_converted_to_id = pickle.load(file)
            with open('./saved_things/data_pickle/train-input-data-可删除.pickle', 'rb') as file:
                self.train_embedding_word_input_data, self.train_embedding_sentence_input, self.train_embedding_labels, self.train_sentiment_input, self.train_sentiment_labels = pickle.load(file)
            with open('./saved_things/data_pickle/test-input-data-可删除.pickle', 'rb') as file:
                self.test_embedding_word_input_data, self.test_embedding_sentence_input, self.test_embedding_labels, self.test_sentiment_input, self.test_sentiment_labels = pickle.load(file)
            print('从保存下来的pickle中读取了数据')
 
    def read_raw_data(self, file_path):
        '''
        这个函数的作用是:
        根据传进来的file_path,读取file_path下面所有的txt文件里面的数据(每个文件是一条语料)
        然后将其分词,将分词后的结果,append到一个列表里
        最后返回这个列表。
        '''
        data_list = []
        file_list = os.listdir(file_path)
        for each_file in file_list:
            with open(os.path.join(file_path, each_file), 'r', encoding='utf-8') as file:
                cnt = file.read().strip()
                cnt = cnt.lower()  # 将语料全部转为小写。因为下游任务不是POS之类的,所以变成小写没关系。
                cnt_tokenized = nltk.word_tokenize(cnt)  # 将语料分词
                data_list.append(cnt_tokenized)
        return data_list
 
    def convert_to_id(self, raw_data):
        data_converted_to_id = []
        for sentence in raw_data:
            this_sentence_converted_to_id = []
            for word in sentence:
                # 将词转换为id,如果碰到词表中没有的词,就转换为''的id。
                this_sentence_converted_to_id.append(self.word2id_dictionary.get(word, self.word2id_dictionary['']))
            data_converted_to_id.append(this_sentence_converted_to_id)
        return data_converted_to_id
 
 
    def read_all_raw_data_from_local_file(self):
        '''
        这个函数目的是为了读取数据,并提前将它们分好词。
        '''
        # 读取训练集中,1.25万的负面情绪的原始语料,并且将其分词。
        train_neg_raw_data = self.read_raw_data('./aclImdb/train/neg')
        print('训练集中负面情绪的语料读取完毕-----共{}条'.format(len(train_neg_raw_data)))
        # 读取训练集中,1.25万的正面情绪的原始语料,并且将其分词。
        train_pos_raw_data = self.read_raw_data('./aclImdb/train/pos')
        print('训练集中正面情绪的语料读取完毕-----共{}条'.format(len(train_pos_raw_data)))
 
        # 读取测试集中,1.25万的负面情绪的原始语料,并且将其分词。
        test_neg_raw_data = self.read_raw_data('./aclImdb/test/neg')
        print('测试集中负面情绪的语料读取完毕-----共{}条'.format(len(test_neg_raw_data)))
        # 读取测试集中,1.25万的正面情绪的原始语料,并且将其分词。
        test_pos_raw_data = self.read_raw_data('./aclImdb/test/pos')
        print('测试集中正面情绪的语料读取完毕-----共{}条'.format(len(test_pos_raw_data)))
 
        # 读取unsup数据集中,5万的原始语料,并且将其分词。
        upsup_raw_data = self.read_raw_data('./aclImdb/train/unsup')
        print('无人工情绪标签的数据语料读取完毕-----共{}条'.format(len(upsup_raw_data)))
 
        return train_neg_raw_data, train_pos_raw_data, test_neg_raw_data, test_pos_raw_data, upsup_raw_data
 
 
    def construct_word_dictionary(self):
        '''
        这个函数的作用是,
        我们拿训练集以及无人工情感标签的语料,来构建常用词的单词词表。
        在这个代码里,我们不会拿无人工情感标签的语料是拿来训练词向量和句向量的,因此仅仅拿它们来构建词表。
        读者可以自己把无人工情感标签的语料放进去训练试试,看看效果有没有提高。
        '''
        # 先将所有的词,放入到一个列表里,统计出每个词的词频数。然后找出最常用的30000个词构建词表。
        all_words = []
        for eachCorpus in self.train_neg_raw_data:
            all_words.extend(eachCorpus)
        for eachCorpus in self.train_pos_raw_data:
            all_words.extend(eachCorpus)
        for eachCorpus in self.upsup_raw_data:
            all_words.extend(eachCorpus)
 
        # 统计词频数。
        counter = collections.Counter(all_words)
        common_words = dict(counter.most_common(29998))
        word2id_dictionary = {'':0, '':1}  # 除了29998个常用词外,其他所有的词都转为''
        for eachWord in common_words:
            word2id_dictionary[eachWord] = len(word2id_dictionary)
 
        return word2id_dictionary
 
 
    def convert_data_to_word_id_and_shuffle(self):
        '''
        该函数的作用:
        根据常用词词表,将raw_data转换为词所对应的id。
        并且将pos数据和neg数据合并掉,
        '''
        train_neg_data_converted_to_id = self.convert_to_id(self.train_neg_raw_data)  # 将训练集中,带有标签的负样本的词都转换为id。
        train_pos_data_converted_to_id = self.convert_to_id(self.train_pos_raw_data)  # 将训练集中,带有标签的正样本的词都转换为id。
        test_neg_data_converted_to_id = self.convert_to_id(self.test_neg_raw_data)  # 将测试集中,带有标签的负样本的词都转换为id。
        test_pos_data_converted_to_id = self.convert_to_id(self.test_pos_raw_data)  # 将训练集中,带有标签的正样本的词都转换为id。
        upsup_raw_data_converted_to_id = self.convert_to_id(self.upsup_raw_data)  # 将训练集中,无情感标签的样本的词都转换为id。
 
        # 将训练集的正样本和负样本进行合并,同时将训练数据和情感标签的labelzip在一起,并且shuffle。
        train_data = train_neg_data_converted_to_id + train_pos_data_converted_to_id
        train_sentiment_label = [0] * len(train_neg_data_converted_to_id) + [1] * len(train_pos_data_converted_to_id)
        train_zipped = list(zip(train_data, train_sentiment_label))
        random.shuffle(train_zipped)
 
        # 将测试集的正样本和负样本进行合并,并且shuffle。情感类别的label也和样本的顺序保持一致。
        test_data = test_neg_data_converted_to_id + test_pos_data_converted_to_id
        test_sentiment_label = [0] * len(test_neg_data_converted_to_id) + [1] * len(test_pos_data_converted_to_id)
        test_zipped = list(zip(test_data, test_sentiment_label))
        random.shuffle(test_zipped)
 
        return train_zipped, test_zipped, upsup_raw_data_converted_to_id
 
 
    def construct_input_data_and_label(self, data_zipped):
        '''
        该函数的作用:
        构建句向量训练阶段的input数据。
        构建情感分类训练阶段的input数据。
        '''
        #以下是构造train的输入数据==================================================================================================================
        embedding_word_input_data = []  # 窗口中,最后一个词为embedding_train_labels,其他的词为input
        embedding_sentence_input = []  # 放的是对应句子的id
        embedding_labels = []
        sentiment_input = []
        sentiment_labels = []
 
        sentence_id = 0
        for eachSentence_tuple in data_zipped:  # eachSentence_tuple[0]是句子转换成id后的样子,eachSentence_tuple[1]是句子的情感分类的类别。
            # 首先判断句长是否大于self.window_size,如果小于它,那就将句子的进行pad,补在句子最前面。
            if len(eachSentence_tuple[0]) < 10:
                if set(eachSentence_tuple[0][0:-1]) == self.word2id_dictionary['']:
                    # 如果所有的词都是'',那这个样本就抛弃了
                    pass
                else:
                    # append''的id。句子的最后一个词为训练句向量时的目标词。
                    temp_embedding_word_input_data = []
                    temp_embedding_word_input_data.extend([self.word2id_dictionary['']]*(self.window_size-len(eachSentence_tuple[0])))
                    temp_embedding_word_input_data.extend(eachSentence_tuple[0][0:-1])
                    embedding_word_input_data.append(temp_embedding_word_input_data)
                    embedding_sentence_input.append([sentence_id])
                    embedding_labels.append([eachSentence_tuple[0][-1]])  # 把最后一个词作为label
                    # 构造sentiment_train_input和sentiment_train_labels
                    sentiment_input.append([sentence_id])
                    sentiment_labels.append([eachSentence_tuple[1]])
                    sentence_id += 1
            else:
                for input_begin_index in range(len(eachSentence_tuple[0])):
                    # 如果窗口已经到达最后一个了,那就break
                    if input_begin_index == (len(eachSentence_tuple[0])-self.window_size+1):
                        sentiment_input.append([sentence_id])
                        sentiment_labels.append([eachSentence_tuple[1]])
                        sentence_id += 1
                        break
                    else:
                        embedding_word_input_data.append(eachSentence_tuple[0][input_begin_index:input_begin_index+9])
                        embedding_sentence_input.append([sentence_id])
                        embedding_labels.append([eachSentence_tuple[0][input_begin_index+9]])
 
        return embedding_word_input_data, embedding_sentence_input, embedding_labels, sentiment_input, sentiment_labels
本文地址:http://51blog.com/?p=5941
关注我们:请关注一下我们的微信公众号:扫描二维码广东高校数据家园_51博客的公众号,公众号:数博联盟
温馨提示:文章内容系作者个人观点,不代表广东高校数据家园_51博客对观点赞同或支持。
版权声明:本文为转载文章,来源于 张小李 ,版权归原作者所有,欢迎分享本文,转载请保留出处!

发表评论


表情