SPACES:“抽取-生成”式长文本摘要(法研杯总结)
By 苏剑林 | 2021-01-01 | 241116位读者 |“法研杯”算是近年来比较知名的NLP赛事之一,今年是第三届,包含四个赛道,其中有一个“司法摘要”赛道引起了我们的兴趣。经过了解,这是面向法律领域裁判文书的长文本摘要生成,这应该是国内第一个公开的长文本生成任务和数据集。过去一年多以来,我们在文本生成方面都有持续的投入和探索,所以决定选择该赛道作为检验我们研究成果的“试金石”。很幸运,我们最终以微弱的优势获得了该赛道的第一名。在此,我们对我们的比赛模型做一个总结和分享。
在该比赛中,我们跳出了纯粹炼丹的过程,通过新型的Copy机制、Sparse Softmax等颇具通用性的新方法提升了模型的性能。整体而言,我们的模型比较简洁有效,而且可以做到端到端运行。窃以为我们的结果对工程和研究都有一定的参考价值。
赛题分析 #
观察、分析任务数据是NLP的第一步,也是相当重要的一步,它关系到我们后面的模型选择,也关系到后面的提升方向。
统计信息 #
这次比赛官方共提供了9484个标注样本,以“(原文, 摘要)”这样的数据对形式出现,原训练数据还附带了其他的一些辅助标注信息,但为了模型的通用性,我们没有用这些辅助信息,所以我们的模型原则上适用于所有单条样本格式为“(原文, 摘要)”的监督式摘要任务。
下面是训练数据的一些统计信息:
1、总量:9484;
2、输入:平均字数 2568,字数标准差 1122,最大字数 13064,最小数字 866;
3、输出:平均字数 283,字数标准差 36,最大字数 474,最小数字 66;
4、指标:以词为单位的加权Rouge。
因此,简单来说这大概就是一个“输入3000字、输出300字”的文本生成任务,其难度在于两千多的平均长度远远超出了我们平时处理的文本长度。9484是全部发布的数据集,网上目前可以下载到的第一阶段的数据是4047条,其实也可以直接跑出还不错的效果,整个模型对数据量的依赖不是特别严重,读者不用太纠结数据量的问题。
样本预览 #
上图演示的是训练集的某个样本,其中上面是输入(裁判文书原文),下面是输出(人工标注的摘要),其中绿色部分标注的是两者的“最长公共子序列”。可以看到,输出跟输入是高度重合的。
建模思路 #
综合上述数据特性,我们不难想到应该采取“抽取+生成”相结合的方式进行摘要,并配合一些新方法来保证摘要的忠实程度与提升最终的效果。最终的模型笔者我们命名为SPACES:
S:Sparse Softmax(新设计的Softmax替代品);
P:Pretrained Language Model(预训练模型);
A:Abstractive(抽象式,即生成式);
C:Copy Mechanism(新设计的Copy机制);
E:Extractive(抽取式);
S:Special Words(将特殊词添加到预训练模型)。
很显然,这是笔者“煞费苦心”强行拼凑的(捂脸),对应于本博客的域名之一“spaces.ac.cn”。不过,上述缩写确实已经把我们的模型的主要技术点都罗列出来了。下面我们将仔细介绍SPACES为何物。
抽取模型 #
这一节我们将对抽取模型部分做一个简要介绍。抽取模型的思路是先通过规则将原始的生成式语料转化为序列标注式语料,然后用笔者常用的DGCNN模型来建模。
语料转换 #
首先,我们需要记住的是,抽取模型只是过程而不是结果,我们还要把抽取的结果送入到Seq2Seq模型优化。因此,抽取模型的原则是“求全”,即尽量把最终摘要所需要的信息覆盖到。为此,我们按照如下规则将原始训练语料转换为抽取式语料:
1、自行构建分句函数,使得句子的颗粒度更细;
2、人工摘要的每个句子,都在原文中匹配与之相似度最高的那个句子(可以重复匹配);
3、将所有匹配到的原文句子作为抽取句子标签;
4、删掉部分匹配出来的句子,使得与人工摘要的Rouge得分最高。
注意,我们在最终模型中删掉了第4点,而它本来是我们最初版模型的默认选择。事实上,加上第4点有利于提高抽取模型的指标,但是综合生成模型后最终得分反而下降了。这不难理解,生成模型本来有删改功能,而且比抽取模型做得更好;如果抽取模型意外地把本应该抽取的关键句子删掉了的话,那么生成模型就很难把它恢复出来了,从而导致性能下降。也就是说,第4点不满足抽取模型的“求全”原则,我们应该把删改工作教程生成模型来做,不应该放到抽取模型中。
指标问题 #
上述转换流程涉及到一个“相似度”的选择,根据前面的介绍,本次比赛选择“以词为单位的加权Rouge”作为评测指标,因此我们可以直接选择这个加权Rouge作为相似度指标。事实上,我们一开始确实是这样做的,但是后来在调试的时候发现,这样并不是一个好的选择,我们最终选择的是“以字为单位的加权Rouge”。
这两者有什么区别呢?对于官方以词为单位来算评测指标的做法,我们也不难理解其目的,就是为了使得专有名词能够完全匹配上,比如本来是“中国人民共和国未成年人保护法”,你预测成了“中国人民共和国文物保护法”,如果以字为单位的话,最长公共子序列为“中国人民共和国...保护法”,至少还是算对了大部分,但是如果以词为单位的话,两者就是不同的词,因此算全错。因此,以词为单位有利于专有名词匹配得更精准。
然而,以词为单位会带来一个严重的副作用,那就是降低了长词的权重。比如“根据 《 中国人民共和国未成年人保护法 》 的 有关 规定”中,核心词“中国人民共和国未成年人保护法”的权重仅为1,剩下的“根据”、“《”、“》”、“的”等我们认为无关紧要的词权重分别都为1,占了大部分,这样一来,模型宁愿去匹配“根据”、“《”、“》”、“的”等词,也不愿意去拟合核心词“中国人民共和国未成年人保护法”了。说白了,以词为单位的话,得分高的摘要未必是有什么关键信息的摘要。
那怎么调和两者呢?事实上,最好的方案应该还是以词为单位,但是算指标的时候,按照字数跟每个词加权,比如“中国人民共和国未成年人保护法”,匹配不上就给0分,匹配对了就给14分(因为有14个字)而不是1分才好。不过,这需要自己来实现Rouge计算函数,有点麻烦,我们最终是直接选择以字为单位来算加权Rouge,这也勉强够用,因为在转换语料的时候,我们知道摘要和原文都是在描述同一件案子,因此基本不会出现“中国人民共和国未成年人保护法”预测成“中国人民共和国文物保护法”的情况。
模型结构 #
回到模型方面,我们使用的是以句为单位的序列标注模型作为抽取模型,句向量部分用“BERT+平均池化”来生成,并固定不变,标注模型主体方面则用DGCNN模型构建。关于DGCNN模型,请参考《基于CNN的阅读理解式问答模型:DGCNN》、《开源一版DGCNN阅读理解问答模型(Keras版)》、《基于DGCNN和概率图的轻量级信息抽取模型》等。
值得指出的一个细节是,在训练抽取模型的时候,我们是以0.3为阈值做EarlyStop的,但最终以0.2为阈值构建生成模型的数据,依据还是前面说的抽取模型的原则是要“求全”。
输出数据 #
我们需要将原文作为输入,通过抽取模型输出抽取摘要,然后把抽取摘要作为生成模型的输入,来输出最终摘要。但是,这有一个问题,训练的数据我们都是见过的,但我们真正预测的是未见过的数据,如果直接训练一个抽取模型,然后用该模型抽取训练集的摘要,那么很明显由于都被训练过了,抽取出来的摘要分数肯定会偏高,而新样本的效果则会偏低,造成训练预测的不一致性。
这时候的解决方案就是交叉验证了。具体来说,我们将标注数据分为$n$份,其中$n-1$份训练抽取模型,然后用这个抽取模型预测剩下的那份数据的抽取摘要,如此重复$n$遍,就得到全部数据的抽取摘要,并且尽可能地减少了训练和预测阶段的不一致性。
生成模型 #
生成模型是我们投入主要时间的部分,也是我们的主要贡献点。生成模型就是一个Seq2Seq模型,以抽取模型的输出结果作为输入、人工标注的摘要作为输出进行训练,我们可以理解为是对抽取结果做进一步的“润色”。
模型总览 #
如果用一张图概括我们的生成模型,那么大概如下:
接下来我们会介绍模型的各个模块。
基础架构 #
Seq2Seq模型依然选择了经典的UniLM(参考《从语言模型到Seq2Seq:Transformer如戏,全靠Mask》),并且考虑到“输入+输出”的总长度基本上都超过512了,所以选择华为的NEZHA模型作为基础模型架构,因为NEZHA使用了相对位置编码,不限长度。
当然,这是当时的选择,现在的话我们至少还有如下两个选择:
1、参考《层次分解位置编码,让BERT可以处理超长文本》中的直接延拓绝对位置编码的做法,使得BERT有能力直接处理更长序列(理论上可达26万),自然也可以用于“BERT+UniLM”中;
2、使用《那个屠榜的T5模型,现在可以在中文上玩玩了》介绍的多国语言版T5模型(mT5),它用的也是相对位置编码,不限长度,但要注意T5用的tokenizer会将全角逗号转为半角逗号,这会导致评测分数下降。
此外,在使用预训练模型方面,我们首创地将部分词语加入到了NEZHA模型中,改变了中文预训练模型以字为单位的通用选择,这使得模型的效果和速度都有一定的提升。这部分结果已经发布在之前的文章《提速不掉点:基于词颗粒度的中文WoBERT》之中,读者可以移步参考。
BIO Copy #
Copy机制在摘要生成模型中并不新鲜,甚至可以说已经成为了生成式摘要的标配了。常规的Copy机制一般就是《Pointer Networks》的做法,但这种做法有两个不足之处:1、每次只能Copy一个token,不能保证Copy一个连续片段(n-gram)出来;2、实现起来比较复杂,不够即插即用。为此,我们构思了一种新型的Copy机制,暂时称为BIO Copy,它实现起来非常简单,而且具有Copy连续片段的能力。
其实前面的图示已经展示了这种Copy机制,它其实就是在Decoder部分多加一个序列预测任务,即原来Decoder建模的是每个Token的分布$p(y_t|y_{< t}, x)$,现在多预测一个标签分布,变为
\begin{equation}p(y_t, z_t|y_{< t}, x) = p(y_t|y_{< t}, x) p(z_t|y_{< t}, x)\end{equation}
其中$z_t\in\{\text{B},\text{I},\text{O}\}$,含义如下:
B:表示该token复制而来;
I:表示该token复制而来且跟前面Token组成连续片段;
O:表示该token不是复制而来的。
那么,训练时$z$的标签哪里来呢?这里直接采用一种比较简单的方法:算摘要与原文的“最长公共子序列”,只要是出现在最长公共子序列的token,都算是Copy过来的,根据BIO的具体含义设置不同的标签。比如前面图片中的例子,“我 真的 非常 热爱 我 的 祖国”与“我 爱 我 的 祖国”的最长公共子序列“我 我 的 祖国”,其中第一个“我”是单字,标签为B,后面“我 的 祖国”是一个连续片段,标签为“B I I”,其他标签为O,所以总的标签为“B O B I I”。
所以,在训练阶段,其实就是多了一个序列预测任务,并且标签都是已知的,实现起来很容易,也不增加什么计算成本。至于预测阶段,对于每一步,我们先预测标签$z_t$,如果$z_t$是O,那么不用改变,如果$z_t$是B,那么在token的分布中mask掉所有不在原文中的token,如果$z_t$是I,那么在token的分布中mask掉所有不能组成原文中对应的n-gram的token。也就是说,解码的时候还是一步步解码,并不是一次性生成一个片段,但可以通过mask的方式,保证BI部分位置对应的token是原文中的一个片段。
需要指出的是,Copy机制的引入未必能明显提高分数,印象中好像只提升了0.5%左右,但是Copy机制可以保证摘要与原始文本的忠实程度,避免出现专业性错误,这在实际使用中是相当必要的。
稀疏Softmax #
在这次比赛中,我们还发现了一个Softmax及交叉熵代替品,我们称之为Sparse Softmax,我们发现Sparse Softmax可以在相当多的分类问题(包括常规分类问题和文本生成等)中替换掉Softmax,并且效果能得到一定的提升。
Sparse Softmax的思想源于《From Softmax to Sparsemax: A Sparse Model of Attention and Multi-Label Classification》、《Sparse Sequence-to-Sequence Models》等文章,里边作者提出了将Softmax稀疏化的做法来增强其解释性乃至提升效果。但笔者嫌里边的设计太麻烦,于是自己想了一个更简单的版本:
\begin{array}{c|c|c}
\hline
& \text{原版} & \text{稀疏版} \\
\hline
softmax & p_i = \frac{e^{s_i}}{\sum\limits_{j=1}^{n} e^{s_j}} & p_i=\left\{\begin{aligned}&\frac{e^{s_i}}{\sum\limits_{j\in\Omega_k} e^{s_j}},\,i\in\Omega_k\\ &\quad 0,\,i\not\in\Omega_k\end{aligned}\right.\\
\hline
交叉熵 & \log\left(\sum\limits_{i=1}^n e^{s_i}\right) - s_t & \log\left(\sum\limits_{i\in\Omega_k} e^{s_i}\right) - s_t\\
\hline
\end{array}
其中$\Omega_k$是将$s_1, s_2, \dots, s_n$从大到小排列后前$k$个元素的下标集合。说白了,我们提出的Sparse Softmax就是在计算概率的时候,只保留前$k$个,后面的直接置零,$k$是人为选择的超参数,这次比赛中我们选择了$k=10$。在算交叉熵的时候,则将原来的对全体类别$\text{logsumexp}$操作,改为只对最大的$k$个类别进行,其中$t$代表目标类别。
为什么稀疏化之后会有效呢?我们认为这是因为避免了Softmax的过度学习问题。假设已经成功分类,那么我们有$s_{\max}=s_t$(目标类别的分数最大),此时我们可以推导原始交叉熵的一个不等式:
\begin{equation}\begin{aligned}
\log\left(\sum\limits_{i=1}^n e^{s_i}\right)-s_{\max} &= \log\left(1+\sum\limits_{i\neq t} e^{s_i-s_{\max}}\right)\\
&\geq \log\left(1+(n-1) e^{s_{\min}-s_{\max}}\right)
\end{aligned}\end{equation}
假设当前交叉熵值为$\varepsilon$,那么解得
\begin{equation}s_{\max} - s_{\min}\geq \log (n-1) - \log \left(e^{\varepsilon} - 1\right)
\end{equation}
我们以$\varepsilon=\ln 2=0.69...$为例,这时候$\log \left(e^{\varepsilon} - 1\right)=0$,那么$s_{\max} - s_{\min}\geq \log (n-1)$。也就是说,为了要loss降到0.69,那么最大的logit和最小的logit的差就必须大于$\log (n-1)$,当$n$比较大的时候,对于分类问题来说这是一个没有必要的过大的间隔,因为我们只希望目标类的logit比所有非目标类都要大一点就行,但是并不一定需要大$\log (n-1)$那么多,因此常规的交叉熵容易造成过度学习而导致过拟合,而截断之后就不会有这个问题。
在这次比赛中,Sparse Softmax带来的提升可能(没有细测)有2%左右!同时,我们私下还补充做了很多实验,包括NLP和CV的,发现它在大多数任务上都有1%的提升,所以非常欢迎大家尝试!不过,我们也发现,Sparse Softmax只适用于有预训练的场景,因为预训练模型已经训练得很充分了,因此finetune阶段要防止过拟合;但是如果你是从零训练一个模型,那么Sparse Softmax会造成性能下降,因为每次只有$k$个类别被学习到,反而会存在学习不充分的情况(欠拟合)。
其他细节 #
在训练生成模型的时候,我们加入了EMA(权重滑动平均),这能使得训练过程更加稳定,甚至可能提升模型效果。事实上,EMA基本是笔者打比赛的标配,它能让我们省一些调试训练策略的心。
此外,在谈到BIO Copy机制时,我们说到理论上只需要在Decoder处新增一个BIO预测,不过在实际训练的时候,我们同时在Encoder和Decoder处都加了,我们发现这样能提升模型的最终效果。直观来想的话,起作用的原因应该是同时加的话增强了Encoder和Decoder之间的同步性,能够引导Decoder更精准地Attention到Encoder的合理的位置。
至于其他要补充的,还在想,想到了再补充吧。
代码开源 #
SPACES模型的源码已经发布在Github上:
SPACECS:https://github.com/bojone/SPACES
使用说明在Github上也有介绍,这里就不重复了,有问题可以提issue或者留言。开源是技术进步的动力,在非利益相关的情况下,笔者会尽量做到开源,也鼓励大家开源。
可能有读者想看看当前的自动摘要能生成到什么程度了,这里演示一个例子吧(验证集的样本,无人工修改,第一行是原文,第二行是标准摘要,第三行是模型摘要,绿色部分是标准摘要与模型摘要的最长公共子序列):
文章小结 #
本文总结了我们做法研杯司法摘要任务的经验,提出了一个名为SPACES的长文本摘要模型,它通过“先抽取后生成”的方式,结合了我们自研的BIO Copy机制、Sparse Softmax等方法,最终可以得到比较靠谱的摘要结果,欢迎大家交流使用。
转载到请包括本文地址:https://kexue.fm/archives/8046
更详细的转载事宜请参考:《科学空间FAQ》
如果您还有什么疑惑或建议,欢迎在下方评论区继续讨论。
如果您觉得本文还不错,欢迎分享/打赏本文。打赏并非要从中获得收益,而是希望知道科学空间获得了多少读者的真心关注。当然,如果你无视它,也不会影响你的阅读。再次表示欢迎和感谢!
如果您需要引用本文,请参考:
苏剑林. (Jan. 01, 2021). 《SPACES:“抽取-生成”式长文本摘要(法研杯总结) 》[Blog post]. Retrieved from https://kexue.fm/archives/8046
@online{kexuefm-8046,
title={SPACES:“抽取-生成”式长文本摘要(法研杯总结)},
author={苏剑林},
year={2021},
month={Jan},
url={\url{https://kexue.fm/archives/8046}},
}
October 1st, 2021
苏神你好 请问抽取式的效果怎么进行判断呢 也是用rouge吗 这样的话抽取式的rouge与生成式的rouge有没有什么关系 比如正比反比之类或者其他呢?
是,都是rouge。在一定范围内,抽取式rouge跟生成式rouge没有什么必然关系,因为文章已经说了,抽取求的是“全”,“全”的抽取模型的rouge不一定高
October 2nd, 2021
苏神,方便请教一下,您使用的简化版sparse softmax与top-k采样的区别吗?
交流一下? qq:944706925
这两者不是互为替代品,所以说不上什么区别。至于联系,我们可以认为,如果decoder的输出使用sparse softmax,那么随机采样就是top-k采样。
谢谢苏神,搞明白了
October 7th, 2021
苏神,想请问为什么不直接用原文和参考摘要做抽取训练呢?为什么要做一步语料转化?
November 17th, 2021
您好,请问采用原来的模型以及法研杯官网上那个9000多的数据运行时,到python seq2seq_model.py这一步OOM了怎么办?是采用较小的bert-base吗?还是去修改自己生成的seq-2-seq模型参数?
December 9th, 2021
苏神你好,想请问既然BERT也可以处理超长文本、nezha也是相对位置编码 都不限制字数 那为什么不直接用原数据集直接做生成呢 而是分两步来做呢?
是因为直接生成的效果没有分两步做效果好吗还是有其他的原因
三个原因:
1、我没那么多显存跑得起几千甚至上万长度的文本;
2、就算跑得起,也相当慢;
3、BERT和nezha的预训练长度是512的,其实严重偏离这个长度的话,就算能跑起来也不见得好。
当然,最根本是第一个原因。
February 1st, 2022
[...][SPACES: extract-generated] long text summary (French cup summary) SPACES_-[...]
March 25th, 2022
您好,我在最终的摘要预测时,到了生成阶段运行非常非常慢,几乎是卡在了beam search这里,请问这是正常情况吗
而且最后生成的摘要内容几乎都是重复的
重复是什么意思?
就是把一个词重复生成,我在beam search为3的时候生成的一句话都是同一个词,而且用了两个小时,不知道我是不是哪里做的有问题
生成一句话用了两小时?那就是不正常。同一个词的问题,可能是没训练好。
正常啊。抽取式摘要相当于只是把能作为摘要的句子圈出来,生成式摘要是要你自己从零写一篇摘要,你想想要是人来做,得有多少倍的速度差距?
March 29th, 2022
苏神,请教一下拷贝机制的这段代码。它是处理预测label为I的token的生成的吗,ngram的长度是只到2?
def predict(self, inputs, output_ids, states):
。。。
for i, token_ids in enumerate(inputs[0]):
。。。
if states[i] > 0:
ngrams = self.get_ngram_set(token_ids, states[i])
prefix = tuple(output_ids[i, 1 - states[i]:])
if prefix in ngrams: # 如果确实是适合的ngram
candidates = ngrams[prefix]
states[i]可以大于2的
April 6th, 2022
苏神你好,我在训练抽取模型的时候出现了过拟合问题,修改了dropout参数以后问题依然存在,我使用的是4047大小的数据集,请问应该怎么解决呢
这个没有什么绝对有效的方案,需要自行分析起因来针对性地解决。
谢谢!我正在自行调整参数了!
May 9th, 2022
苏神您好,想请问一下关于sparsesoftmax。
我在另外的摘要任务上尝试使用torch版本的sparsesoftmax,发现效果并不理想。
我的模型是采用的huggingface BART模型(vocab_size约50000),k值尝试10,100,10000,以及k=vocab_size(也就是正常的log_softmax+nnl_loss),发现效果仍然是k值越大效果越好。我想可能是我有忽略了一些细节导致效果变差,还望您指导一下,谢谢。
def compute_seq2seq_loss(predictions, input_ids, vocab_size, k_sparse=10):
predictions = predictions.view(-1, vocab_size)
labels = labels.view(-1)
target_mask = (labels != -100) # -100 表示被Padding
# 正loss
pos_loss = predictions[list(range(predictions.shape[0])), labels]
# 负loss
y_pred = torch.topk(predictions, k=k_sparse)[0]
neg_loss = torch.logsumexp(y_pred, dim=-1)
loss = neg_loss - pos_loss
return (loss * target_mask).sum() / target_mask.sum()
我不懂,看上去实现没错。但要注意的是,sparse softmax不仅仅是训练的loss要改,预测时做beam search或者random sample时,模型的预测结果也要改。