作者:张贵发
研究方向:自然语言处理
在这信息过载的时代,自动摘要变得尤为重要。自动文本摘要就是自动地把一段文字压缩为它对应的较短的版本的任务。
文本摘要的主要方式分为两种,一种是抽取式(extractive),另一种是生成式(abstractive)。
抽取式是根据词语重要性、句子重要性排序,抽取出重要度高的句子,从而形成摘要。主要是对文本的选择,过程相对更容易,但是对于复杂的文本时,很难仅仅通过选择文本来形成摘要,如小说。
生成式则是通过自然语言处理,输出简洁、流畅、保留关键信息,更像人进行摘要的过程,涉及到生成文本,过程更为复杂。但生成能力更强,可认为有一定的概括能力。
sequence-to-sequence为生成式摘要提供了一种可行的新方法。然而,这些模型有两个缺点:它们容易复制事实上的细节不准确,他们倾向于重复自己。而指针生成网络从两方面做了改进。第一,使用指针生成器网络可以通过指向从源文本中复制单词,这有助于准确复制信息,同时保留generater的生成能力。第二,使用coverage跟踪摘要的内容,不断更新注意力,从而阻止文本不断重复。
想要详细了解seq2seq attention请参考:
https://zhuanlan.zhihu.com/p/40920384
论文地址:https://arxiv.org/pdf/1704.04368.pdf
(RNN)既可读取也可自由生成文本,具有使抽象总结可行。但不准确地复制事实细节,无法处理词汇不足(OOV)单词,并重复它们自己,是RNN存在的缺陷。在本文中,我们介绍一种架构解决这三个问题。
虽然最近的抽象性工作集中在标题生成任务上(将一句或两句话简化为单一标题),我们认为,更长的文本摘要更具挑战性(需要更高的抽象级别,同时避免重复),最终更有用。因此我们将我们的模型应用于最近推出的CNN / Daily Mail文本。混合指针生成器网络有助于通过指针从源文本复制单词,这提高了OOV单词的准确性和处理能力,同时保留了产生新词的能力。此结构这可以看作是提取方法和抽象方法之间的平衡。中提出一种新的coverage vector,利用注意力分布追踪目前应被覆盖的单词,并且当网络再次注意同一部分的时候,予以惩罚。可以理解为用来跟踪更新注意力。事实证明coverage vector对于消除重复非常有效。
baseline的seq2seq attention模型
pointer-generator 模型
coverage 机制模型
encoder端每一步还是先做emdebing,做完embeding之后输入到lstm中,lstm中每一步输出都被使用到。对每一步的输出做一个加权,每一个encoder存在一个系数α(α可以是一个标量也可以是一个向量,是向量就与lstm输出的向量点乘,是标量就与向量直接相乘,α需要做归一化处理)。encoder的lstm每一步的输出与α相乘,将所有相乘最终相加,这就是加权平均的过程。
加权平均过程之后,会输入到decoder第二个单元中去。为什么输入到第二步中去?这里用到了第一步的信息,decoder第一步输出参与到α的计算过程去,会与encoder中每一个lstm输出操作,计算出α的值,α的值又去和5个lstm的向量做加权平均,得到一个向量,将得到的向量输入到decoder第二步中去。同理decoder第二步输出参与到α计算,得到α值,与encoder五个lstm中的向量点乘,加权平均得到一个向量,输入到decoder的第三步中去。。。
具体的公式过程如下:
有了Attention-based Encoder-Decoder的讲解,我们再来理解paper中的模型。
baseline的seq2seq attention模型。该模型可以关注源文本中的相关单词生成新单词,例如,在抽象摘要中生成新单词beat德国队2-0阿根廷队该模型可以集中注意力在源文本中的victorious 和win单词。如上图文章逐个输送到encoder(单层双向的lstm)中,产生一系列的encoder隐藏状态hi,hi参与到注意力系数的计算。在每轮训练中,decoder端的输出状态st也将参与注意力系数的计算,计算得出α。decoder(单层双向的lstm)解码器decoder的状态st与encoder隐藏状态hi经过变换计算得出系数α
即at,可以理解为注意力的分布。
在理解了Sequence-to-sequence attentional model的基础上,我们模型的基础上进行一些改进:
指针生成器模型。对于每个译码器时间步,计算一个生成概率pgen∈[0,1],该概率决定从词汇表生成单词的概率,而不是从源文本复制单词的概率。对词汇分布和注意分布进行加权和求和得到最终分布,并据此进行预测。上图与Sequence-to-sequence attentional model并没有太大变化,这就是指针生成器模型。对于每个译码器时间步,计算一个生成概率pgen∈[0,1],该概率决定从词汇表生成单词的概率,而不是从源文本复制单词的概率。对词汇分布和注意分布进行加权和求和得到最终分布,并据此进行预测。
我们的指针生成器网络是baseline和指针网络之间的混合体,因为它既允许通过指针复制单词,也允许通过固定词汇生成单词。在指针生成器模型中,注意力分布和上下文向量h*t,另外,根据上下文向量h*t计算了时间步长t的生成概率pgen∈[0,1],解码器状态st和解码器输入xt,
这里值得注意的是,如果w是词汇表外(oov)单词,则Pvocab(w)为零;同样,如果w未出现在源文档中,则∑i:wi=w ati是零。产生OOV单词的能力是指针生成器模型的主要优势之一;通过对比模型,如baseline仅限于其预设词汇,而指针网络则有复制能力,不再局限于预设词汇表Pvocab
与基于 attention 的端到端系统相比,指针生成网络具有以下优点:
指针生成网络让从源文本生成单词变得更加容易。这个网络仅需要将足够多的 attention 集中在相关的单词上,并且让pgen 足够的大。
指针生成网络甚至可以复制原文本中的非正式单词。这是此方法带给我我们的主要福利,让我们能够处理那些没出现过的单词,同时也允许我们使用更小规模的词汇集(需要较少的计算资源和存储空间)。
指针生成网络能够被更快地训练,尤其是训练的前几个阶段。
重复是sequenceto-sequence模型的常见问题,本文中采用coverage 机制来解决,在我们的coverage model中,主要维持coverage vector,是之前所有解码器时间步的注意力分配总和也就是某个特定的源单词的收敛就是到此刻它所受到 attention 的和:
其中wc是和v长度一样的学习参数。这确保注意力机制当前的决定(选择下一个注意点)通过提醒前一个决定而得到通知。即在下一次选择之前,应用了以前注意力的信息,这应该使注意力机制更容易避免重复关注同一地点,从而避免生成重复文本。
定义一个coverage loss 来对同一地点的重复惩罚
下载数据
原始链接下载速度比较慢,在此提供了已下载好的数据,请自行下载
百度网盘:https://pan.baidu.com/s/1yUC6yp0-VTh3aWwCgnT7TQ
提取码:f8de
数据处理程序
数据的处理程序python2版本:
数据的处理程序python3版本
程序中设置词汇表200k,chunksize设置的1000。
程序内容修改以及准备工作
源程序中read_text_file中可能报gbk的错误,看本地要求,可以加入encoding='utf-8'的参数,统一编码格式。
在hashhex的h.update(s)更改为h.update(s)
安装Stanford corenlp来进行分词,我本地环境是python3的环境,可自己下载安装,在此提供我的下载版本
百度网盘:https://pan.baidu.com/s/1yUC6yp0-VTh3aWwCgnT7TQ
提取码:f8de
下载数据
百度网盘:https://pan.baidu.com/s/1NWe6K33GMTp4Wk7CwaGotA
密码:4k12
共 679898 条数据,分为两个文件,label和text数据,数据详情
数据处理程序代码:
加载数据,进行分词处理,此处还可以进行更多的数据去重、去停用词等更多操作。
#读取内容 进行分词
# 分词存储
import jieba
import os
import time
import sys
# 文章、摘要 、最终生成文件的路径
ARTICL_PAHT = 'G:/data/新闻标题数据集/新闻标题数据集/train_text.txt'
SUMMARY_PATH = 'G:/data/新闻标题数据集/新闻标题数据集/train_label.txt'
TRAIN_PAHT = '../data/train.txt'
VAL_PAHT = '../data/val.txt'
# 读入文本并进行分词
def read_text_file(text_file):
lines = []
with open(text_file, "r", encoding='utf-8') as f:
for line in f:
line = ' '.join(jieba.cut(line))
lines.append(line.strip())
return lines
# 对原文内容和摘要内容进行拼接
def mergeText(articlText,summaryText):
trainList=[]
valList = []
linum = 0
for seq1, seq2 in zip(articlText, summaryText):
linum = linum + 1
if linum < 600000:
trainList.append(seq1)
trainList.append(seq2)
else:
valList.append(seq1)
valList.append(seq2)
return trainList,valList
#文本写入文件
def data_writer( finishList, path) :
with open(path, 'w', encoding='utf-8') as writer:
for item in finishList:
writer.write(item+ '\n')
# 运行程序入口
if __name__ == '__main__':
articls = read_text_file(ARTICL_PAHT)
summays = read_text_file(SUMMARY_PATH)
trainlist,vallist = mergeText(articls, summays)
data_writer(trainlist,TRAIN_PAHT)
data_writer(vallist,VAL_PAHT)
数据处理为模型需要的数据:
import os
import struct
import collections
from tensorflow.core.example import example_pb2
# 我们用这两个符号切分在.bin数据文件中的摘要句子
SENTENCE_START = '<s>'
SENTENCE_END = '</s>'
train_file = '../data/train.txt'
val_file = '../data/val.txt'
# test_file = './test/test.txt'
finished_files_dir = '../data/finished_files'
chunks_dir = os.path.join(finished_files_dir, "chunked")
VOCAB_SIZE = 200000
CHUNK_SIZE = 1000 # 每个分块example的数量,用于分块的数据
def chunk_file(set_name):
in_file = os.path.join(finished_files_dir, '%s.bin' % set_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' % (set_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 set_name in ['train', 'val']:
print("Splitting %s data into chunks..." % set_name)
chunk_file(set_name)
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_files_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_files_dir): os.makedirs(finished_files_dir)
# 读取文本文件,做一些后处理然后写入到.bin文件
# write_to_bin(test_file, os.path.join(finished_files_dir, "test.bin"))
write_to_bin(val_file, os.path.join(finished_files_dir, "val.bin"))
write_to_bin(train_file, os.path.join(finished_files_dir, "train.bin"), makevocab=True)
chunk_all()
paper的项目实现代码:python2版本、python3版本、pytorch版本
上述三个版本中,我直接运行存在编码格式问题,引用问题、版本支持问题等,需要进行稍微修改。具体细节比较多不一一列举了,如有问题请私信本人,可以提供已经修改好跑通的代码。我的中文版本对paper项目代码没进行修改,只是调节了batch_size。
对于所有的实验,我们的模型有256维隐藏状态和128维嵌入字。对于指针生成器模型,我们对源和目标都使用50k字的词汇表——注意,由于指针网络能够处理OOV字,我们可以使用比150k源和60k目标词汇表更小的词汇表。
batch_size设置为8,显存低于4g的,建议调为4,不然会存在内存溢出。最好在gpu上运行程序,每个1k或更多查看loss下降趋势,开始loss为6+,经过10k,loss开始到4+,中文版和英文版都设置的迭代500k轮次。
我们使用Adagrad进行模型优化,学习率为0.15,初始累计值为0.1。我尝试了adam优化,效果不佳。我们使用最大值的梯度剪辑梯度范数为2,但不使用任何形式的正则化。我们在验证集上使用loss来实现早期停止。在train和test期间,我们将文章截断为400个tokens,并将摘要的长度限制为100个tokens用于train,120个tokens用于test。这样做是为了加快train和test,但我们还发现截断文章可以提高模型的性能。
设置500k轮次,在gpu中大概需要训练50小时,由于中文数据60多万相对数据少一些,训练时间相对较少,训练了30多小时如下图:自动文档摘要评价方法大致分为两类:
(1)内部评价方法(Intrinsic Methods):提供参考摘要,以参考摘要为基准评价系统摘要的质量。系统摘要与参考摘要越吻合, 质量越高。
(2)外部评价方法(Extrinsic Methods):不提供参考摘要,利用文档摘要代替原文档执行某个文档相关的应用。例如:文档检索、文档聚类、文档分类等, 能够提高应用性能的摘要被认为是质量好的摘要。
其中内部评价方法,是比较直接比较纯粹的,被学术界最常使用的文摘评价方法,将系统生成的自动摘要与专家摘要采用一定的方法进行比较也是目前最为常见的文摘评价模式。
ROUGE就属于内部评价的一种方法。
ROUGE是由ISI的Lin和Hovy提出的一种自动摘要评价方法,现被广泛应用于DUC1(Document Understanding Conference)的摘要评测任务中。
ROUGE基于摘要中n元词(n-gram)的共现信息来评价摘要,是一种面向n元词召回率的评价方法。ROUGE准则由一系列的评价方法组成,包括ROUGE-1,ROUGE-2,ROUGE-3,ROUGE-4,以及ROUGE-Skipped-N-gram等,1、2、3、4分别代表基于1元词到4元词以有跳跃的N-gram模型。在自动文摘相关研究中,一般根据自己的具体研究内容选择合适的N元语法ROUGE方法。
其中,n-gram表示n元词,{Ref Summaries}表示参考摘要,即事先获得的标准摘要,Countmatch(n-gram)表示系统摘要和参考摘要中同时出现n-gram的个数,Count(n-gram)则表示参考摘要中出现的n- gram个数。
不难看出,ROUGE公式是由召回率的计算公式演变而来的,分子可以看作“检出的相关文档数目”,即系统生成摘要与标准摘要相匹配的N-gram个数,分母可以看作“相关文档数目”,即标准摘要中所有的N-gram个数。
关于更多的评价指标解释参考:https://www.zhihu.com/question/304798594/answer/567383628?edition=yidianzixun&utm_source=yidianzixun&yidian_docid=K_008KcG1d
尽管coverage(汇聚)训练阶段很短,但重复问题几乎完全消除,从定性和定量两方面都可以看出。然而,我们的最佳模型并没有完全超过Lead-3基线的rouge评分。
下图显示了我们最终模型的摘要包含的新n-gram(即那些没有出现在文章中的)的比率要比参考摘要,表示抽象程度较低。注意,基线模型生成新的n-gram的频率更高,这统计数据包括所有错误复制的单词、UNK标记和捏造,以及良好的抽象实例。
首先我们看一下英文输出对比,
中文摘要输出:
模型虽然取得了一定效果,但是还有许多问题未能解决:
网络没有去聚焦源文本的核心内容,反而概括一些不太重要的信息。如中文中的第二个例子
有时候,网络错误地组合了原文的片段,造成错误的结果,中文第二个例子中同样展示了片段组合带来的缺陷
生成的摘要都是相近的词或片段概括,没有更高层次的压缩概括。
在生成中同义词可能造成语句通畅性降低。
本文由作者投稿并且原创授权AINLP首发于公众号平台,点击'阅读原文'直达原文链接,欢迎投稿,AI、NLP均可。