指针生成网络(Pointer-Generator-Network)原理与实战

0 前言

      本文主要内容:介绍Pointer-Generator-Network在文本摘要任务中的背景模型架构与原理在中英文数据集上实战效果与评估,最后得出结论。参考的《Get To The Point: Summarization with Pointer-Generator Networks》以及多篇博客均在文末给出连接,文中使用数据集已上传百度网盘代码已传至GitHub,读者可以在文中找到相应连接,实际操作过程中确实遇到很多坑,并未在文中一一指明,有兴趣的读者可以留言一起交流。由于水平有限,请读者多多指正。

  随着互联网飞速发展,产生了越来越多的文本数据,文本信息过载问题日益严重,对各类文本进行一个“降 维”处理显得非常必要,文本摘要便是其中一个重要的手段。文本摘要旨在将文本或文本集合转换为包含关键信息的简短摘要。按照输出类型可分为抽取式摘要和生成式摘要。抽取式摘要从源文档中抽取关键句和关键词组成摘要,摘要全部来源于原文生成式摘要根据原文,允许生成新的词语、原文本中没有的短语来组成摘要。

  指针生成网络属于生成式模型。

  仅用Neural sequence-to-sequence模型可以实现生成式摘要,但存在两个问题:

    1. 可能不准确地再现细节, 无法处理词汇不足(OOV)单词;

    2. 倾向于重复自己

    原文是(they are liable to reproducefactual details inaccurately, and they tendto repeat themselves.)

  指针生成网络(Pointer-Generator-Network)从两个方面进行了改进

    1. 该网络通过指向(pointer)从源文本中复制单词,有助于准确地复制信息,同时保留通过生成器产生新单词的能力;

    2. 使用coverage机制来跟踪已总结的内容,防止重复。 

  接下来从下面几个部分介绍Pointer-Generator-Network原理:

    1. Baseline sequence-to-sequence;

    2. Pointer-Generator-Network;

    3. Coverage Mechanism。

1 Baseline sequence-to-sequence

  Pointer-Generator Networks是在Baseline sequence-to-sequence模型的基础上构建的,我们首先Baseline seq2seq+attention。其架构图如下:

  该模型可以关注原文本中的相关单词以生成新单词进行概括。比如:模型可能注意到原文中的"victorious"" win"这个两个单词,在摘要"Germany beat Argentina 2-0"中生成了新的单词beat 。

  Seq2Seq的模型结构是经典的Encoder-Decoder模型,即先用Encoder将原文本编码成一个中间层的隐藏状态,然后用Decoder来将该隐藏状态解码成为另一个文本。Baseline Seq2Seq在Encoder端是一个双向的LSTM,这个双向的LSTM可以捕捉原文本的长距离依赖关系以及位置信息,编码时词嵌入经过双向LSTM后得到编码状态 $h_i$ 。在Decoder端解码器是一个单向的LSTM,训练阶段时参考摘要词依次输入(测试阶段时是上一步的生成词),在时间步 $t$得到解码状态 $s_t$ 。使用$h_i$和$s_t$得到该时间步原文第 $i$个词注意力权重。

$$e_i^t = v^T tanh(W_{h}h_i + W_{s}s_t + b_{attn})$$
$$a^t = softmax(e^t)$$

  得到的注意力权重和 $h_i$加权求和得到重要的上下文向量 $h_t^*(context vector)$:

$$h_{t}^{*} = \sum_{i}{a_i^t h_i}$$

  $h_t^*$可以看成是该时间步通读了原文的固定尺寸的表征。然后将 $s_t$和 $h_t^*$ 经过两层线性层得到单词表分布 $P_{vocab}$:

$$P_{vocab} = softmax(V'(V[s_t, h_t^*] + b) + b')$$

  其中 $[s_t, h_t^*]$是拼接。这样再通过$sofmax$得到了一个概率分布,就可以预测需要生成的词:

$$P(w) = P_{vocab}(w)$$

  在训练阶段,时间步 $t$ 时的损失为: 

$$loss_{t} = -logP(w_t^*)$$

  那么原输入序列的整体损失为: 

$$loss = \frac{1}{T} \sum_{t=0}^{T}loss_t$$

 

2 Pointer-Generator-Network

  原文中的Pointer-Generator Networks是一个混合了 Baseline seq2seq和PointerNetwork的网络,它具有Baseline seq2seq的生成能力和PointerNetwork的Copy能力。该网络的结构如下:

如何权衡一个词应该是生成的还是复制的?

  原文中引入了一个权重 $p_{gen}$ 。

  从Baseline seq2seq的模型结构中得到了$s_t$ 和$h_t^*$,和解码器输入 $x_t$ 一起来计算 $p_{gen}$ :

$$p_{gen} = \sigma(w_{h^*}^T h_t^* + w_s^Ts_t + w_x^Tx_t + b_{ptr})$$

  这时,会扩充单词表形成一个更大的单词表--扩充单词表(将原文当中的单词也加入到其中),该时间步的预测词概率为:

$$P(w) = p_{gen}P_{vocab}(w) + (1 - p_{gen}) \sum_{i:w_i=w} a_i^t$$

  其中 $a_i^t$ 表示的是原文档中的词。我们可以看到解码器一个词的输出概率有其是否拷贝是否生成的概率和决定。当一个词不出现在常规的单词表上时 $P_{vocab}(w)$ 为0,当该词不出现在文档中$ \sum_{i:w_i=w} a_i^t$为0。

3  Coverage mechanism

  原文的特色是运用了Coverage Mechanism来解决重复生成文本的问题,下图反映了前两个模型与添加了Coverage Mechanism生成摘要的结果:

  蓝色的字体表示的是参考摘要,三个模型的生成摘要的结果差别挺大;

  红色字体表明了不准确的摘要细节生成(UNK未登录词,无法解决OOV问题);

  绿色的字体表明了模型生成了重复文本。

  为了解决此问题--Repitition,原文使用了在机器翻译中解决“过翻译”和“漏翻译”的机制--Coverage Mechanism

  具体实现上,就是将先前时间步的注意力权重加到一起得到所谓的覆盖向量 $c^t (coverage vector)$,用先前的注意力权重决策来影响当前注意力权重的决策,这样就避免在同一位置重复,从而避免重复生成文本。计算上,先计算coverage vector $c^t$:

$$c^t = \sum_{t'=0}^{t-1}a^{t'}$$

  然后添加到注意力权重的计算过程中,$c^t$用来计算 $e_i^t$:

$$e_i^t = v^T tanh(W_{h}h_i + W_{s}s_t + w_{c}c_i^t + b_{attn})$$

  同时,为coverage vector添加损失是必要的,coverage loss计算方式为:

$$covloss_{t} = \sum_{i}min(a_i^t, c_i^t)$$

  这样coverage loss是一个有界的量  $covloss_t \leq \sum_{i}a_i^t = 1$ 。因此最终的LOSS为:

$$loss_t = -logP(w_t^*)  + \lambda \sum_{i}min(a_i^t, c_i^t)$$ 

4 实战部分

4.1 DataSet

英文数据集: cnn dailymail数据集,地址:https://github.com/becxer/cnn-dailymail/

中文数据集:新浪微博摘要数据集,这是中文数据集,有679898条文本及摘要。

中英文数据集均可从这里下载,链接:链接: https://pan.baidu.com/s/1AKjjxmZMmNa1SMATjtykHg  密码: w6vj 。

4.2 Experiments

  原论文代码参考:python3 tensorflow版本。调试时候不同平台可能会有不同的报错,需要debug,CentOS7下debug后的代码上传至GitHub:https://github.com/zingp/NLP/tree/master/P007PytorchPointerGeneratorNetwork

      本次我们复现结果时所用的代码是自己实现的pytorch版本,代码:https://github.com/zingp/pointer-generator-pytorch。具体环境是:Centos7.4/python3.6/pytorch1.0.1  GPU:Tesla V100-SXM2-16GB  

4.2.1 cnn/dailymail数据集实验

      使用上面所说的环境与代码, 优化器Adagrad,初始学习率0.15。hidden_dim为 256 ,词向量维度emb_dim为126,词汇表数目vocab_size为50K,batch_size设为16。模型有处理OOV能力,因此词汇表不用设置过大;在batch_size的选择上,显存小的同学建议设为8,否则会出现内存不够,难以训练。LOSS曲线如下:

  一开始我们未开启coverage模式,迭代500K后终止,可以看出模型已收敛。然后选择一个loss较好的模型即pointer-gen模型,在这个模型的基础之上,开启coverage模式之后继续训练40K后终止,得到收敛的模型即是pointer-gen+coverage了。具体rouge分数可以看下面一章分析。

4.2.2 微博中文数据集

  新浪微博的中文数据集没有现成工具处理成二进制,因此需要先写一段预处理代码。

  第一部分是对原始数据进行分词,划分训练集测试集,并保存文件。

import os
import sys
import time
import jieba

ARTICLE_FILE = "./data/weibo_news/train_text.txt"
SUMMARRY_FILE = "./data/weibo_news/train_label.txt"

TRAIN_FILE = "./data/weibo_news/train_art_summ_prep.txt"
VAL_FILE = "./data/weibo_news/val_art_summ_prep.txt"

def timer(func):
    def wrapper(*args, **kwargs):
        start = time.time()
        r = func(*args, **kwargs)
        end = time.time()
        cost = end - start
        print(f"Cost time: {cost} s")
        return r
    return wrapper

@timer
def load_data(filename):
    """加载数据文件,对文本进行分词"""
    data_list = []
    with open(filename, 'r', encoding= 'utf-8') as f:
        for line in f:
            # jieba.enable_parallel()
            words = jieba.cut(line.strip())
            word_list = list(words)
            # jieba.disable_parallel()
            data_list.append(' '.join(word_list).strip())
    return data_list

def build_train_val(article_data, summary_data, train_num=600_000):
    """划分训练和验证数据"""
    train_list = []
    val_list = []
    n = 0
    for text, summ in zip(article_data, summary_data):
        n += 1
        if n <= train_num:
            train_list.append(text)
            train_list.append(summ)
        else:
            val_list.append(text)
            val_list.append(summ)
    return train_list, val_list

def save_file(filename, li):
    """预处理后的数据保存到文件"""
    with open(filename, 'w+', encoding='utf-8') as f:
        for item in li:
            f.write(item + '\n')
    print(f"Save {filename} ok.")

if __name__ == '__main__':
    article_data = load_data(ARTICLE_FILE)     # 大概耗时10分钟
    summary_data = load_data(SUMMARRY_FILE)
    TRAIN_SPLIT = 600_000
    train_list, val_list = build_train_val(article_data, summary_data, train_num=TRAIN_SPLIT)
    save_file(TRAIN_FILE, train_list)
    save_file(VAL_FILE, val_list) 

第二部分是将文件打包,生成模型能够加载的二进制文件。

import os
import struct
import collections
from tensorflow.core.example import example_pb2

# 经过分词处理后的训练数据与测试数据文件
TRAIN_FILE = "./data/weibo_news/train_art_summ_prep.txt"
VAL_FILE = "./data/weibo_news/val_art_summ_prep.txt"

# 文本起始与结束标志
SENTENCE_START = '<s>'
SENTENCE_END = '</s>'

VOCAB_SIZE = 50_000  # 词汇表大小
CHUNK_SIZE = 1000    # 每个分块example的数量,用于分块的数据

# tf模型数据文件存放目录
FINISHED_FILE_DIR = './data/weibo_news/finished_files'
CHUNKS_DIR = os.path.join(FINISHED_FILE_DIR, 'chunked')

def chunk_file(finished_files_dir, chunks_dir, name, chunk_size):
    """构建二进制文件"""
    in_file = os.path.join(finished_files_dir, '%s.bin' % name)
    print(in_file)
    reader = open(in_file, "rb")
    chunk = 0
    finished = False
    while not finished:
        chunk_fname = os.path.join(chunks_dir, '%s_%03d.bin' % (name, chunk))  # 新的分块
        with open(chunk_fname, 'wb') as writer:
            for _ in range(chunk_size):
                len_bytes = reader.read(8)
                if not len_bytes:
                    finished = True
                    break
                str_len = struct.unpack('q', len_bytes)[0]
                example_str = struct.unpack('%ds' % str_len, reader.read(str_len))[0]
                writer.write(struct.pack('q', str_len))
                writer.write(struct.pack('%ds' % str_len, example_str))
            chunk += 1


def chunk_all():
    # 创建一个文件夹来保存分块
    if not os.path.isdir(CHUNKS_DIR):
        os.mkdir(CHUNKS_DIR)
    # 将数据分块
    for name in ['train', 'val']:
        print("Splitting %s data into chunks..." % name)
        chunk_file(FINISHED_FILE_DIR, CHUNKS_DIR, name, CHUNK_SIZE)
    print("Saved chunked data in %s" % CHUNKS_DIR)


def read_text_file(text_file):
    """从预处理好的文件中加载数据"""
    lines = []
    with open(text_file, "r", encoding='utf-8') as f:
        for line in f:
            lines.append(line.strip())
    return lines


def write_to_bin(input_file, out_file, makevocab=False):
    """生成模型需要的文件"""
    if makevocab:
        vocab_counter = collections.Counter()

    with open(out_file, 'wb') as writer:
        # 读取输入的文本文件,使偶数行成为article,奇数行成为abstract(行号从0开始)
        lines = read_text_file(input_file)
        for i, new_line in enumerate(lines):
            if i % 2 == 0:
                article = lines[i]
            if i % 2 != 0:
                abstract = "%s %s %s" % (SENTENCE_START, lines[i], SENTENCE_END)

                # 写入tf.Example
                tf_example = example_pb2.Example()
                tf_example.features.feature['article'].bytes_list.value.extend([bytes(article, encoding='utf-8')])
                tf_example.features.feature['abstract'].bytes_list.value.extend([bytes(abstract, encoding='utf-8')])
                tf_example_str = tf_example.SerializeToString()
                str_len = len(tf_example_str)
                writer.write(struct.pack('q', str_len))
                writer.write(struct.pack('%ds' % str_len, tf_example_str))

                # 如果可以,将词典写入文件
                if makevocab:
                    art_tokens = article.split(' ')
                    abs_tokens = abstract.split(' ')
                    abs_tokens = [t for t in abs_tokens if
                                  t not in [SENTENCE_START, SENTENCE_END]]  # 从词典中删除这些符号
                    tokens = art_tokens + abs_tokens
                    tokens = [t.strip() for t in tokens]     # 去掉句子开头结尾的空字符
                    tokens = [t for t in tokens if t != ""]  # 删除空行
                    vocab_counter.update(tokens)

    print("Finished writing file %s\n" % out_file)

    # 将词典写入文件
    if makevocab:
        print("Writing vocab file...")
        with open(os.path.join(FINISHED_FILE_DIR, "vocab"), 'w', encoding='utf-8') as writer:
            for word, count in vocab_counter.most_common(VOCAB_SIZE):
                writer.write(word + ' ' + str(count) + '\n')
        print("Finished writing vocab file")

if __name__ == '__main__':
    if not os.path.exists(FINISHED_FILE_DIR):
    os.makedirs(FINISHED_FILE_DIR)
    write_to_bin(VAL_FILE, os.path.join(FINISHED_FILE_DIR, "val.bin"))
    write_to_bin(TRAIN_FILE, os.path.join(FINISHED_FILE_DIR, "train.bin"), makevocab=True)
    chunk_all() 

  在训练中文数据集的时候我们采用了TensorFlow版本的代码,设置的hidden_dim为 256 ,词向量维度emb_dim为126,词汇表数目vocab_size为50K,batch_size设为16。在batch_size的选择上,显存小的同学建议设为8,否则会出现内存不够,难以训练。

  在batch_size=16时,训练了27k step, 出现loss震荡很难收敛的情况,train阶段loss如下:

val阶段loss如下:

  可以看到当step在10k之后,loss在3.0-5.0之间来回剧烈震荡,并没有下降趋势。前面我们为了省显存,将batch_size设置成16,可能有点小了,梯度下降方向不太明确,显得有点盲目,因此将batch_size设成了32后重新开始训练。注意:在一定范围内,batchsize越大,计算得到的梯度下降方向就越准,引起训练震荡越小。增大batch_size后训练的loss曲线如下:

val loss曲线如下:

  看起来loss还是比较震荡的,但是相比bathc_size=16时有所改善。一开始的前10K steps里loss下降还是很明显的基本上能从6降到4左右的区间,10k steps之后开始震荡,但还是能看到在缓慢下降:从4左右,开始在2-4之间震荡下降。这可能是目前的steps还比较少,只要val loss没有一直升高,可以继续观擦,如果500K steps都还是如此,可以考虑在一个合适的时机early stop。

    附一个pytorch版本的训练情况:

    优化器选择的Adam,初始学习率为0.001,batchsize设为32.其他参数可参见源代码中的config。loss曲线如下:

    同样也是先关闭coverage模式,收敛后再开启。可以看出Adma在训练初期loss还是下降挺快的。

4.3 Evaluation

  摘要质量评价需要考虑一下三点:

    (1) 决定原始文本最重要的、需要保留的部分;

    (2) 在自动文本摘要中识别出1中的部分;

    (3) 基于语法和连贯性(coherence)评价摘要的可读性(readability)。

  从这三点出发有人工评价和自动评价,本文只讨论一下更值得关注的自动评价。自动文档摘要评价方法分为两类:

    内部评价方法(Intrinsic Methods):提供参考摘要,以参考摘要为基准评价系统摘要的质量。系统摘要与参考摘要越吻合, 质量越高。

    外部评价方法(Extrinsic Methods):不提供参考摘要,利用文档摘要代替原文档执行某个文档相关的应用。

  内部评价方法是最常使用的文摘评价方法,将系统生成的自动摘要与参考摘要采用一定的方法进行比较是目前最为常见的文摘评价模式。下面介绍内部评价方法是ROUGE(Recall-Oriented Understudy for Gisting Evaluation)。

  ROUGE是2004年由ISI的Chin-Yew Lin提出的一种自动摘要评价方法,现被广泛应用于DUC(Document Understanding Conference)的摘要评测任务中。ROUGE基于摘要中n元词(n-gram)的共现信息来评价摘要,是一种面向n元词召回率的评价方法。基本思想为由多个专家分别生成人工摘要,构成标准摘要集,将系统生成的自动摘要与人工生成的标准摘要相对比,通过统计二者之间重叠的基本单元(n元语法、词序列和词对)的数目,来评价摘要的质量。通过与专家人工摘要的对比,提高评价系统的稳定性和健壮性。该方法现已成为摘要评价技术的通用标注之一。 ROUGE准则由一系列的评价方法组成,包括ROUGE-N(N=1、2、3、4,分别代表基于1元词到4元词的模型),ROUGE-L,ROUGE-S, ROUGE-W,ROUGE-SU等。在自动文摘相关研究中,一般根据自己的具体研究内容选择合适的ROUGE方法。公式如下: 

$$ROUGE-N = \frac{\sum_{S\in \left\{ ReferenceSummaries\right\} } \sum_{gram_n \in S}Count_{match}(gram_n)} {\sum_{S\in \left\{ ReferenceSummaries\right\} } \sum_{gram_n \in S}Count(gram_n)}$$

  其中,$n-gram$表示n元词${Ref Summaries}$表示参考摘要(标准摘要)$Count_{match}(n-gram)$表示生成摘要和参考摘要中同时出现$n-gram$的个数$Count(n-gram)$则表示参考摘要中出现的$n- gram$个数ROUGE公式是由召回率的计算公式演变而来的,分子可以看作“检出的相关文档数目”,即系统生成摘要与标准摘要相匹配的$n-gram$个数,分母可以看作“相关文档数目”,即参考摘要中所有的$n-gram$个数。

  举例说明一下:

      自动摘要Y(模型自动生成的):the cat was found under the bed

      参考摘要X1(人工生成的):the cat was under the bed

      摘要的1-gram、2-gram如下(N-gram以此类推):

     

   $ROUGE-1(X1,Y)= 6/6=1.0$ -->分子是待评测摘要和参考摘要都出现的1-gram的个数分母是参考摘要的1-gram个数。(其实分母也可以是待评测摘要的,但是在精确率和召回率之间,我们更关心的是召回率Recall,同时这也和上面ROUGN-N的公式相同)。
  同样,$ROUGE-2(X1,Y)=4/5=0.8$。

4.4 Results

  原论文中的cnn/dailymail数据集上的ROUGE分数如下:

  在上表中,上半部分是模型生成的的摘要评估,而下半部分的是提取摘要评估。可以看出抽象生成的效果接近了抽取效果。

  本实验复现ROUGE分数如下

  仅用pointer-Gen的情况下,我们的的分数是高于原文中的分数的使用Pointer-Gen+Coverage模型之后我们的分数与原文相当。但都可以反应使用Pointer-Gen+Coverage后的分数相较仅使用pointer-Gen要高。

再来看ngram重复情况:

   

  可以看出我们的no coverage的模型生成的摘要在n-gram上是要比reference摘要要多的,而使用了coverage之后,重复数目和reference相当。
中文实验结果:
  由于中文的rogue暂时不能用ROUGE库计算,所以暂时还没有计算分数,不过可以看一下效果。
  例子一:

  例子二:

  直观上效果还是不错的。可以看出,预测的摘要中已经基本没有不断重复自身的现象;像“[话筒] [思考] [吃惊] ”这种文本,应该是原文本中的表情,在对文本的处理中我们并没有将这些清洗掉,因此依然出现在预测摘要中。不过例子二还是出现了句子不是很通顺的情况,在输出句子的语序连贯上还有待改进。

5 总结与展望

5.1 回顾本文做的一些工作

  1. 本文用pytorch实现了指针生成网络,复现了原论文的结果,在pointer-gen模型上的ROUGE分数略高于原文。

  2 将模型方法应用在中文数据集上,取得了一定效果。

5.2 一些感想

  1. 可以看出指针生成网络通过指针复制原文中的单词,可以生成新的单词,解决oov问题;其次使用了coverage机制,能够避免生成的词语不断重复。

  2. 有时候会出现语句不通顺的情况,在语句的通顺和连贯上还有待加强,尤其是在中文数据集上比较明显。

5.3 所以我们实现了生成式(抽象)摘要吗?

  远没到实现的程度!尽管我们展示这些进步可以帮助我们克服循环神经网络的一些野蛮行为,但是仍然有很多未解决的问题。

  • 尽管指针生成网络生成了(生成式)抽象式摘要,但是生成的单词通常都是和原文相当接近的;更高水平的抽象——例如更加强大的压缩语义,仍然未被解决
  • 有时候,网络仍然没有去聚焦源文本的核心内容,反而概括一些不太重要的信息
  • 有时候网络错误地组合了原文的片段,例如,作出的摘要是 work incorrectly composes fragments of the source text – for example reporting that。这让人很费解。
  • 多句式摘要有时候并没有构成一个有意义的整体,例如,在没有事先介绍的情况下,就用一个代词(例如 she)去代替一个实体 (例如德国总理 Angela Merkel)。

   我认为未来最重要的研究方向是可解释性(interpretability)。通过揭示网络正在关注什么,注意力机制在神经网络的黑盒中点亮了珍贵的光芒,以帮助我们调试类似重复和复制的问题。为了取得进一步的进展,我们需要深入了解递归神经网络从文本中学习到的内容以及知识的表征方式。可以尝试利用预训练模型,效果可能会更好;也可以先采用抽取式生成摘要,然后利用抽取式摘要进行训练。

5 References

  1. https://arxiv.org/pdf/1704.04368.pdf
  2. https://www.jiqizhixin.com/articles/2019-03-25-7
  3. https://zhuanlan.zhihu.com/p/53821581
  4. https://www.aclweb.org/anthology/W04-1013
  5. https://blog.csdn.net/mr2zhang/article/details/90754134
  6. https://zhuanlan.zhihu.com/p/68253473
 
posted @ 2019-09-26 11:36  ZingpLiu  阅读(7972)  评论(26编辑  收藏
/* 登录到博客园之后,打开博客园的后台管理,切换到“设置”选项卡,将上面的代码,粘贴到 “页脚HTML代码” 区保存即可。 */