文本摘要

  • 0.Abstract
  • 1.任务介绍
  • 数据集
  • 评估方法
  • 测试集
  • 2. 词表构建
  • 3. Baseline模型
  • 基线模型结果
  • 4. 解码部分
  • 5. 最终模型
  • 双层结构
  • Multi-Head Attention机制
  • Mask机制
  • 先验知识
  • 最终效果
  • 6. 另外一些还没有实现的想法/可能的方向


0.Abstract

本文实现了一个简单的中文文本摘要, 摘要方法使用生成式. 评估方法使用Rouge-N.
项目代码github地址:
PyTorch实现 [New!!! 2021.04.03新增]
https://github.com/neesetifa/summarization_pytorch Keras实现
https://github.com/neesetifa/summarization_keras

1.任务介绍

文本摘要通常有两种, 它们分别是抽取式摘要(Extractive summarization)和生成式摘要(Abstractive summarization). 压缩式摘要暂不讨论.

抽取式摘要主要从源文档中提取出现成的词/句.

生成式摘要是基于NLG(Natural Language Generation).由模型自己生成句子. 生成式摘要较抽取式摘要具有更高的灵活性, 允许模型有一定概率生成新的词语/短语. 通常基于sequence to sequence结构实现. 此类型是本文实现的摘要生成方式.

数据集

来自NLPCC2017 一共约50K条数据, 分为摘要(summarization)和原文(text)两部分.
使用正则匹配清洗数据集(清洗后有5条为空数据)

部分数据的清洗效果, 上面是原文, 下面是清洗后的数据:

seq2seq生成式文本摘要流程 生成常用摘要_Rouge

seq2seq生成式文本摘要流程 生成常用摘要_点乘_02

评估方法

本项目使用的评估方法是ROUGE-N和ROUGE-L ROUGE-N的最大值是1. 即"参考摘要"里的所有字都在"模型自动生成的摘要"里出现过.

测试集

同样来自NLPCC2017, 测试集约为2000条.

2. 词表构建

词表构建比较简单, 没有使用分词工具, 直接分字.
PS: 可以使用分词工具进行分词. 此处使用分字没有特殊含义.

chars = {}
for summarization,text in df.values:
    for w in summarization:  
        chars[w] = chars.get(w, 0) + 1
    for w in text:  #
        chars[w] = chars.get(w, 0) + 1

词表里额外加入了4个特殊词, 分别是:
<pad> 即padding,补全用
<unk> unknown,未知词/不在词表里的词
<start> 开始符
<end> 结束符

4个特殊词放在词表的 0-3这4个index里, 正式词从编号4开始. 整个词表保存在一个json文件里.

json.dump([chars, id2char, char2id], open('vocab.json', 'w'))

3. Baseline模型

Baseline模型使用了sequence to sequence结构+attention机制.
encoder部分用了双向LSTM结构(即BiLSTM), decoder里面用了单向LSTM.
由于单向结构只考虑了上下文中的"上文"信息, 并没有考虑后面的内容. 双向结构同时拥有从后往前的信息, 即考虑了"下文"的信息.

decoder部分没有加入双向是因为显然解码只能从左往右. 如果decoder部分也能做双向还请留言指教.

双向LSTM的结构可以参考下图, hi是LSTM单元. (把LSTM换成最基本的RNN或者GRU, 从结构上来说都是可以的)

seq2seq生成式文本摘要流程 生成常用摘要_seq2seq生成式文本摘要流程_03


h1是正向结构:

seq2seq生成式文本摘要流程 生成常用摘要_点乘_04

h2是逆向结构:

seq2seq生成式文本摘要流程 生成常用摘要_Rouge_05

最终输出:

seq2seq生成式文本摘要流程 生成常用摘要_点乘_06

由于BiLSTM里使用两套参数, 最后的输出是两套的结果进行拼接(concatenate)操作.

下面是实际操作过程中BiLSTM的实现方式. 通常是初始化两个LSTM单元, 然后分别送入原始的x以及逆序的x. 模型结构图可以参考最终模型里的图, 只是没有双层结构.

x_forward = LSTM(embedding_size, return_sequences=True)(x)  #把原始数据送入LSTM. return_sequences需要置为True,因为需要每个时间片(time step)的输出, 为attention做准备.
x_backward = reverse_sequence(x)     #把原始数据反转过来
x_backward = LSTM(embedding_size, return_sequences=True)(x_backward) #反转的数据送入另一个LSTM中
x_backward = reverse_sequence(x_backward)  #把结果再反转
x = concatenate([x_forward, x_backward], 2) #拼接

损失函数(loss function)使用cross entropy.

基线模型结果

在Tesla K80的GPU上训练, 每一个epoch的时间约为60s.

seq2seq生成式文本摘要流程 生成常用摘要_seq2seq生成式文本摘要流程_07


seq2seq生成式文本摘要流程 生成常用摘要_点乘_08


基线模型分数:

Rouge-1分数: 0.298

Rouge-2分数: 0.137

Rouge-L分数: 0.633实际效果:

seq2seq生成式文本摘要流程 生成常用摘要_点乘_09


我随机挑选了一句话, 可以看到摘要部分质量并不是很好, 并且有相当多的重复词语.

4. 解码部分

解码部分采用beam search算法, 我的代码里k值设定为3. 循环停止条件是碰到<end>符号或者到达max_len.

算法:

  1. 在开始运算前, y初始化为<start>标识符.
  2. 如果是第一次运算, 则直接保存top k个值.
    如果不是,则遍历top k * top k种组合找出新的top k. 比如当前有三个候选字 [A,B,C], A,B,C分别送入模型预测, 可以各获得三个结果: A->[A1,A2,A3], B->[B1,B2,B3], C->[C1,C2,C3], 那么一共有 3*3 = 9个组合, 即[A,A1],[A,A2],[A,A3], [B,B1], [B,B2],[B,B3], [C,C1],[C,C2],[C,C3], 从这9个里选取top 3作为下一轮y的输入.
    假设分数最高的组合是[A,A3],[C,C1],[C,C3], 那么这个组合就送入下一次循环, 他们又可以各产生3个候选字, [A,A3]->[S1,S2,S3], [C,C1]->[T1,T2,T3],[C,C3]->[R1,R2,R3], 如此往复.
  3. 如果碰到至少有一个y里已经含有<end>, 则在含有<end>里的y里选择分数最高的一个返回. 如果没有一个句子有<end>, 并且循环次数已经达到了max_len, 则返回分数最高的一个句子.
def beam_search(s, model, topk=3, max_len=64):
    x = str2id(s)   # 别忘了输入的句子是字符, 要转为id
    x = x * topk    # 复制三份, 因为我们的top k是3
    y = [[2]]       # 我设置的<start>的index是2, 所以y初始化为2
    y = y * topk    # 同样复制三份
    
    y_, score = [],[]
    #两种停止情况, 碰到<end>或者最长maxlen
    for i in range(maxlen):
        probability = model.predict([x, y])
        arg_topk = probability.argsort()[:, -topk:]

        if i == 0:
            # 如果是第一次, 直接保存结果
			y_.append(top_k_index)
			score.append(top_k_score)
        else:
            # 不然, 两个for遍历 top k * top k 次
            for j in range(topk):
                for k in range(topk):
                    y_.append(top_k_index) 
                    score.append(top_k_score)
            ... # 此时y_里应该有9个候选值,从中选出新的top k个
        y = y_  # y赋予新值
        
        if found <end>:   
            ... # 如果发现y里有<end>, 则在含有\<end>里的y里选择分数最高的一个返回
            return id2str(y_)
    # 如果maxlen后依旧没有<end>,直接返回
    return id2str(y_)

5. 最终模型

最终模型结构:

seq2seq生成式文本摘要流程 生成常用摘要_点乘_10

先谈一下这个最终模型结构. 这个结构算是半原创, 结合了一部分ELMo和Transformer的思想. 结构上并没有太多道理, 只是尝试把所学的知识结合在了一起.

在最终模型里,一共加入了以下几个优化点:

  1. 使用了双层结构
  2. 加入了Multi-Head Attention机制
  3. 加入了Mask机制
  4. 加入了先验知识
双层结构

encoder部分和decoder部分都加入双层结构, 即encoder部分为两层的BiLSTM, decoder部分为两层LSTM.
双层结构即为图里repeat部分, 第一层输出之后的向量会再经过同样的一个结构.
多层的好处很简单, 将模型特征进行多次重组, 可以学习到更高级的特征, 增强模型表现力.

Multi-Head Attention机制

这个做法来源于Transformer里的Multi-Head Self-Attention机制. 对于这个机制的详解可以参考我的另一篇博客–> 传送门 首先,加入attention的好处是把decoder的每个部分都和encoder的每个部分联系了起来, 使decoder每个部分都能获得encoder所有部分的信息.
其次, Multi-Head机制使模型能够学到不同的表达和关注的点. 增强了模型的表示能力. Multi-Head做法其实就相当于把输入的Q,K,V再过多个线性层.
在我的模型里, x的最终输出和y的最终输出会做一个Multi-Head Attention操作. 之所以不是self-attention是因为本模型里是y查询x的键值(即Q=y, K和V=x). 我这一步就相当于Transformer的Decoder结构里的第二个Multi-head Attention.

class Attention(Layer):
    """multi-head+attention 多头注意力机制
    	下面代码为伪代码, 不能运行
    """
    def __init__(self,num_of_heads,size_per_head):
        self.num_of_heads = num_of_heads  #头的数量
        self.size_per_head = size_per_head  #每个头的size
        
    def build(self):       
        self.wq = Dense()  #定义 wq,wk,wv 三个线性层/线性矩阵
        self.wk = Dense()
        self.wv = Dense()

    def call(self, inputs):
    	q, k, v= inputs  # 下图三个输入都为x,因为是self-attention. 在本模型里, q是y, k和v是x
        q = self.wq(q)  # Multi-head机制, 输入经过线性层
        k = self.wk(k)
        v = self.wv(v)

seq2seq生成式文本摘要流程 生成常用摘要_seq2seq生成式文本摘要流程_11

a = batch_dot(q, k)  # Q和K先做点乘
        a = a / self.size_per_head ** 0.5 # 缩放,即根号dk
        a = softmax(a)   # 做softmax
        z = batch_dot(a, v)  # 再和V做点乘
        return z

seq2seq生成式文本摘要流程 生成常用摘要_Rouge_12

Mask机制

Mask机制是什么?
简单讲, 如果句子是这样[2,4,3,6,0,0,0], 后面三个位置没有值, 那么这个句子的mask就是[1,1,1,1,0,0,0]. 即有值的地方设置为1,没值的设置为0.
为什么要Mask?
由于实际运算都是矩阵相乘. 我们做这类模型, 都会设置一个max_len, 太短的句子补足max_len的部分, 即padding. padding部分初始化的时候都是0, 但实际经过计算后, 它们或多或少都会产生值. 然而我们并不希望 padding部分也有值, 于是加入Mask机制. 每次点乘运算过后都要过一次mask. 让没有值得地方始终保持0.
mask机制分成两种:
1.普通状态下把padding部分设置成0.
2.需要经过softmax时设置成-inf. 因为如果直接把0送入softmax,这一部分在经过softmax计算后,也会享有一部分权重,然而我们不想0值的部分拥有权重,所以设置成-inf,这样softmax在计算时他们的权重都会变成0.
Mask机制主要在上节的Attention里应用.

def mask(x, mask, mode='mul'):
	if mode == 'mul':  #这里x在做点乘操作,把不应该有值的位置变成0
    	return x * mask
    else:              #这里把0的位置设置成一个非常大的负数
        return x - (1 - mask) * 1e10
先验知识

工业级落地场景中通常都会加入很多规则与先验.
先验知识的好处: 让模型有目的的往某些地方偏. 比如白发,老年斑. 先验知识是老人. 让模型更倾向于判断是老人.
而在文本摘要里, 摘要的词通常都会在原文中出现过. 所以这里加入的先验知识就是文章中已经出现过的词, 把他们作为最后分类时的一个先验知识. 使得模型倾向于选择文章中已经出现过的词.
做法就是在x输入时, 统计每句句子里, 某个字是否出现过. 出现过标注1, 没有出现过标注0. 该向量的长度应为单词表的长度. 最后和attention部分的输出做平均. 然后再送入cross entropy.

def get_prior(x)
	...
	return x_prior

x_prior = get_prior(x)  # 获得先验知识
xy = Attention()([y, x, x])  # 这是attention这步
xy = concate(xy, y)   # 图里最后一部分的concat
xy = (xy + x_prior) / 2  # 将这个结果和先验知识做平均
cross_entropy = crossentropy(y_in, xy)
最终效果

在Tesla K80的GPU上训练, 每一个epoch的时间约为231s.

seq2seq生成式文本摘要流程 生成常用摘要_seq2seq生成式文本摘要流程_13


seq2seq生成式文本摘要流程 生成常用摘要_点乘_14


最终模型分数:

Rouge-1分数: 0.309

Rouge-2分数: 0.219

Rouge-L分数: 0.523实例效果:

seq2seq生成式文本摘要流程 生成常用摘要_seq2seq生成式文本摘要流程_15


可以看到, Rouge-1分数和基线模型比起来有所提升, Rouge-2分数相差较大, 而Rouge-L分数则比基线模型低了很多.

我认为主要原因就是训练目标(即loss function)和评测指标并不相关. 模型在减少loss时并不会增加Rouge分数. 这也是目前生成式任务(包括机器翻译)的一些问题: 训练目的和评测不一致.

其次, 由于Rouge-1是一个字一个字比对, 并没有考虑顺序. 而在Rouge-2里组成了词之后, 会附带一些顺序信息, 能够看出baseline模型生成的句子里一些不通顺的问题.

Rouge-L比基线模型低这么多的原因我目前尚未分析出原因, 可能只是当前模型下的巧合. 在训练时只是保证了loss最低, 实际训练时Rouge分数上下浮动非常大.

但是可以看到实际效果中, 句子质量比起基线模型已经有了显著提升.

6. 另外一些还没有实现的想法/可能的方向

1.对模型结构进行调整, encoder部分舍弃sequence to sequence结构, 直接使用self-attention机制. 由于self-attention可以并行计算, 理论上可以提高训练速度. 当然也需要加上positional encoding. 这个做法其实就是一个超级简化版的Transformer的encoder部分的结构.

2.至于生成句子里有单词不断重复的问题, 虽然在最终模型里这个问题发生的现象有减少,但只是由于模型变复杂了, 文中提出的几个优化点并没有实际解决这个问题.
这篇论文<Get To The Point: Summarization with Pointer-Generator Networks>提出了一个解决方法. 该论文里的模型结构和我稍有不同,主要是在attention部分.
我的模型采用了Multi-head attention, x的所有hidden layer的输出和y的所有hidden layer的输出过attention. 论文里的基线模型部分采用如下机制:
我们知道在生成句子时,decoder部分是从左往右一个个词生成的,对于每个时间片t(或者说每一步t) , 论文里的decoder的hidden state进行了如下计算
seq2seq生成式文本摘要流程 生成常用摘要_seq2seq生成式文本摘要流程_16

其中v, Wh, Ws, battn 都是训练参数, 没什么特别. st是当前时刻decoder输出的hidden state, hi则是encoder部分每次输出的hidden state,即 [h1, h2, h3, …]其实就是我上面基线模型代码里的这个x.

x = concatenate([x_forward, x_backward], 2)

然后再过softmax层输出每个单词的概率.
seq2seq生成式文本摘要流程 生成常用摘要_点乘_17

论文的优化点是在此基础上的. 作者加入了一个覆盖机制(Coverage mechanism). 具体实现如下:
设定一个新的覆盖向量 ct. ct怎么获得呢? 公式如下:
seq2seq生成式文本摘要流程 生成常用摘要_seq2seq生成式文本摘要流程_18

说白了就是: 每个ct是前面(t-1)个at的和. 于是et的计算变成如下:
seq2seq生成式文本摘要流程 生成常用摘要_seq2seq生成式文本摘要流程_19

Wc和其他一样, 都是训练参数. 这么做其实就等于加了一个先验知识, 即当前的attention的分布结果都受前一次attention结果的影响. 或者说, 前一次attention的结果都会告知当前attention:“上一步的单词概率分布是长这样的, 这步计算时你尽量避免产生相同的分布, aka, 避免产生重复单词”
当然我们需要在损失函数后面加入一个惩罚项:
seq2seq生成式文本摘要流程 生成常用摘要_seq2seq生成式文本摘要流程_20

这样在训练时, 模型可以知道我每次产生了相同的at时是会有惩罚的.
把它加入原有loss function里, 就变成了:
seq2seq生成式文本摘要流程 生成常用摘要_点乘_21

=======
目前由于这个结构和我的multi-head attention结构稍有冲突, 暂时没有很好地想好该怎么将两者结合. 未来将持续进行尝试.