Efficient GlobalPointer:少点参数,多点效果
By 苏剑林 | 2022-01-25 | 122962位读者 |在《GlobalPointer:用统一的方式处理嵌套和非嵌套NER》中,我们提出了名为“GlobalPointer”的token-pair识别模块,当它用于NER时,能统一处理嵌套和非嵌套任务,并在非嵌套场景有着比CRF更快的速度和不逊色于CRF的效果。换言之,就目前的实验结果来看,至少在NER场景,我们可以放心地将CRF替换为GlobalPointer,而不用担心效果和速度上的损失。
在这篇文章中,我们提出GlobalPointer的一个改进版——Efficient GlobalPointer,它主要针对原GlobalPointer参数利用率不高的问题进行改进,明显降低了GlobalPointer的参数量。更有趣的是,多个任务的实验结果显示,参数量更少的Efficient GlobalPointer反而还取得更好的效果。
大量的参数 #
这里简单回顾一下GlobalPointer,详细介绍则请读者阅读《GlobalPointer:用统一的方式处理嵌套和非嵌套NER》。简单来说,GlobalPointer是基于内积的token-pair识别模块,它可以用于NER场景,因为对于NER来说我们只需要把每一类实体的“(首, 尾)”这样的token-pair识别出来就行了。
设长度为$n$的输入$t$经过编码后得到向量序列$[\boldsymbol{h}_1,\boldsymbol{h}_2,\cdots,\boldsymbol{h}_n]$,原始GlobalPointer通过变换$\boldsymbol{q}_{i,\alpha}=\boldsymbol{W}_{q,\alpha}\boldsymbol{h}_i$和$\boldsymbol{k}_{i,\alpha}=\boldsymbol{W}_{k,\alpha}\boldsymbol{h}_i$我们得到序列向量序列$[\boldsymbol{q}_{1,\alpha},\boldsymbol{q}_{2,\alpha},\cdots,\boldsymbol{q}_{n,\alpha}]$和$[\boldsymbol{k}_{1,\alpha},\boldsymbol{k}_{2,\alpha},\cdots,\boldsymbol{k}_{n,\alpha}]$,然后定义
\begin{equation}s_{\alpha}(i,j) = \boldsymbol{q}_{i,\alpha}^{\top}\boldsymbol{k}_{j,\alpha}\end{equation}
作为从$i$到$j$的连续片段是一个类型为$\alpha$的实体的打分。这里我们暂时省略了偏置项,如果觉得有必要,自行加上就好。
这样一来,有多少种类型的实体,就有多少个$\boldsymbol{W}_{q,\alpha}$和$\boldsymbol{W}_{k,\alpha}$。不妨设$\boldsymbol{W}_{q,\alpha},\boldsymbol{W}_{k,\alpha}\in\mathbb{R}^{d\times D}$,那么每新增一种实体类型,我们就要新增$2Dd$个参数;而如果用CRF+BIO标注的话,每新增一种实体类型,我们只需要增加$2D$的参数(转移矩阵参数较少,忽略不计)。对于BERT base来说,常见的选择是$D=768,d=64$,可见GlobalPointer的参数量远远大于CRF。
识别与分类 #
事实上,不难想象对于任意类型$\alpha$,其打分矩阵$s_{\alpha}(i,j)$必然有很多相似之处,因为对于大多数token-pair而言,它们代表的都是“非实体”,这些非实体的正确打分都是负的。这也就意味着,我们没必要为每种实体类型都设计独立的$s_{\alpha}(i,j)$,它们应当包含更多的共性。
怎么突出$s_{\alpha}(i,j)$的共性呢?以NER为例,我们知道NER实际上可以分解为“抽取”和“分类”两个步骤,“抽取”就是抽取出为实体的片段,“分类”则是确定每个实体的类型。这样一来,“抽取”这一步相当于只有一种实体类型的NER,我们可以用一个打分矩阵就可以完成,即$(\boldsymbol{W}_q\boldsymbol{h}_i)^{\top}(\boldsymbol{W}_k\boldsymbol{h}_j)$,而“分类”这一步,我们则可以用“特征拼接+Dense层”来完成,即$\boldsymbol{w}_{\alpha}^{\top}[\boldsymbol{h}_i;\boldsymbol{h}_j]$。于是我们可以将两项组合起来,作为新的打分函数:
\begin{equation}s_{\alpha}(i,j) = (\boldsymbol{W}_q\boldsymbol{h}_i)^{\top}(\boldsymbol{W}_k\boldsymbol{h}_j) + \boldsymbol{w}_{\alpha}^{\top}[\boldsymbol{h}_i;\boldsymbol{h}_j]\label{eq:EGP-1}\end{equation}
这样一来,“抽取”这部分的参数对所有实体类型都是共享的,因此每新增一种实体类型,我们只需要新增对应的$\boldsymbol{w}_{\alpha}\in\mathbb{R}^{2D}$就行了,即新增一种实体类型增加的参数量也只是$2D$。进一步地,我们记$\boldsymbol{q}_i=\boldsymbol{W}_q\boldsymbol{h}_i, \boldsymbol{k}_i=\boldsymbol{W}_k\boldsymbol{h}_i$,然后为了进一步地减少参数量,我们可以用$[\boldsymbol{q}_i;\boldsymbol{k}_i]$来代替$\boldsymbol{h}_i$,此时
\begin{equation}s_{\alpha}(i,j) = \boldsymbol{q}_i^{\top}\boldsymbol{k}_j + \boldsymbol{w}_{\alpha}^{\top}[\boldsymbol{q}_i;\boldsymbol{k}_i;\boldsymbol{q}_j;\boldsymbol{k}_j]\label{eq:EGP}\end{equation}
此时$\boldsymbol{w}_{\alpha}\in\mathbb{R}^{4d}$,因此每新增一种实体类型所增加的参数量为$4d$,由于通常$d \ll D$,所以式$\eqref{eq:EGP}$的参数量往往少于式$\eqref{eq:EGP-1}$,它就是Efficient GlobalPointer最终所用的打分函数。
惊喜的实验 #
Efficient GlobalPointer已经内置在bert4keras>=0.10.9
中,读者只需要更改一行代码,就可以切换Efficient GlobalPointer了。
# from bert4keras.layers import GlobalPointer
from bert4keras.layers import EfficientGlobalPointer as GlobalPointer
下面我们来对比一下GlobalPointer和Efficient GlobalPointer的结果:
\begin{array}{c}
\text{人民日报NER实验结果} \\
{\begin{array}{c|cc}
\hline
& \text{验证集F1} & \text{测试集F1}\\
\hline
\text{CRF} & 96.39\% & 95.46\% \\
\text{GlobalPointer} & \textbf{96.25%} & \textbf{95.51%} \\
\text{Efficient GlobalPointer} & 96.10\% & 95.36\%\\
\hline
\end{array}} \\ \\
\text{CLUENER实验结果} \\
{\begin{array}{c|cc}
\hline
& \text{验证集F1} & \text{测试集F1} \\
\hline
\text{CRF} & 79.51\% & 78.70\% \\
\text{GlobalPointer} & 80.03\% & 79.44\%\\
\text{Efficient GlobalPointer} & \textbf{80.66%} & \textbf{80.04%} \\
\hline
\end{array}} \\ \\
\text{CMeEE实验结果} \\
{\begin{array}{c|cc}
\hline
& \text{验证集F1} & \text{测试集F1} \\
\hline
\text{CRF} & 63.81\% & 64.39\% \\
\text{GlobalPointer} & 64.84\% & 65.98\%\\
\text{Efficient GlobalPointer} & \textbf{65.16%} & \textbf{66.54%} \\
\hline
\end{array}}
\end{array}
可以看到,Efficient GlobalPointer的实验结果还是很不错的,除了在人民日报任务上有轻微下降外,其他两个任务都获得了一定提升,并且整体而言提升的幅度大于下降的幅度,所以Efficient GlobalPointer不单单是节省了参数量,还提升了效果。而在速度上,Efficient GlobalPointer与原始的GlobalPointer几乎没有差别。
分析与评述 #
考虑到人民日报NER只有3种实体类型,CLUENER和CMeEE分别有10种和9种实体类型,从分数来看也是人民日报比其他两种要高,这说明CLUENER和CMeEE的难度更大。另一方面,在CLUENER和CMeEE上Efficient GlobalPointer都取得了提升,所以我们可以初步推断:实体类别越多、任务越难时,Efficient GlobalPointer越有效。
这也不难理解,原版GlobalPointer参数过大,那么平均起来每个参数更新越稀疏,相对来说也越容易过拟合;而Efficient GlobalPointer共享了“抽取”这一部分参数,仅通过“分类”参数区分不同的实体类型,那么实体抽取这一步的学习就会比较充分,而实体分类这一步由于参数比较少,学起来也比较容易。反过来,Efficient GlobalPointer的实验效果好也间接证明了式$\eqref{eq:EGP}$的分解是合理的。
当然,不排除在训练数据足够多的时候,原版GlobalPointer会取得更好的效果。但即便如此,在类别数目较多时,原版GlobalPointer可能会占用较多显存以至于难以使用,还是以base版$D=768,d=64$为例,如果类别数有100个,那么原版GlobalPointer的参数量为$2\times 768\times 64\times 100$,接近千万,不得不说确实是不够友好了。
最后的总结 #
本文指出了原版GlobalPointer的参数利用率不高问题,并提出了相应的改进版Efficient GlobalPointer。实验结果显示,Efficient GlobalPointer在降低参数量的同时,基本不会损失性能,甚至还可能获得提升。
转载到请包括本文地址:https://kexue.fm/archives/8877
更详细的转载事宜请参考:《科学空间FAQ》
如果您还有什么疑惑或建议,欢迎在下方评论区继续讨论。
如果您觉得本文还不错,欢迎分享/打赏本文。打赏并非要从中获得收益,而是希望知道科学空间获得了多少读者的真心关注。当然,如果你无视它,也不会影响你的阅读。再次表示欢迎和感谢!
如果您需要引用本文,请参考:
苏剑林. (Jan. 25, 2022). 《Efficient GlobalPointer:少点参数,多点效果 》[Blog post]. Retrieved from https://kexue.fm/archives/8877
@online{kexuefm-8877,
title={Efficient GlobalPointer:少点参数,多点效果},
author={苏剑林},
year={2022},
month={Jan},
url={\url{https://kexue.fm/archives/8877}},
}
January 25th, 2022
之前我也遇到过这个问题,我的解决思路是直接给每个类别训练一个embedding,然后通过condition layer norm或者gate的方式把$h_i$变成$h_{i,\alpha}$,这样实现了头部参数的完全共享,在我的任务上效果还不错,但没有在这几个数据集上尝试,苏神如果有兴趣可以尝试一下。
你这个方案直接在原始$\boldsymbol{h}_i$上操作,感觉显存会偏大,效率会偏低;有效倒应该是有效的,
其实类似方案倒是有很多,我自己都构思了几个版本。但是我需要保持两点:一是不能增加计算复杂度,最好还能降低复杂度;二是不单单NER效果不变,做复杂的token-pair分类时效果也要保持不变(比如用GlobalPointer做关系抽取时依然有效)。最后只筛选出当前版本了~
确实在类别比较多的时候会很耗显存,所以我是每个batch采样一部分类别进行训练的。但是感觉你当前的版本对实体的分类是头尾分开进行的,而且好像没有利用到距离信息,这点有些遗憾。不得不说模型设计时充满了妥协,能选出一个各方面都work的模型真是不容易。
其实我觉得刚刚相反,你说的“分类是头尾分开进行的,而且好像没有利用到距离信息”我认为是合理的,因为我将GlobalPointer视为一个token-pair的分类模块,既然是token-pair的分类,那么自然认为分类所需要的信息(理论上包括距离信息)都已经包含在token-piar的特征之中(即$[\boldsymbol{h}_i,\boldsymbol{h}_j]$)。
如果结合具体的任务来看GlobalPointer,反而有些局限了,比如任务为NER时,我们又会认为span的识别不应该只是看首尾,还应该包括中间的token,这样无限细分下去,不断针对性处理,反而失去了普适性。
January 25th, 2022
(这样式(2)和biaffine是等价了,不过多了一个W shared weight的条件
是的,殊途同归。不过参数共享是关键~
January 26th, 2022
一直用我基于torch的写的GP,
更新 Efficient GlobalPointer 后,我照着改动了下,在CMeEE数据集上效果反而降低了0.5%,而且训练过程中验证集精度上升没有之前快了,不知为何
对齐一下初始化?还有实现细节(比如先scale然后再加bias)。本文的结果都是直接用 https://github.com/bojone/GlobalPointer 中的代码替换Efficient GlobalPointer跑出来的结果,并且重复跑了多次求平均,可复现性很强。
谢谢回答,已经复现。使用chinese-bert-base在CMeEE测试集是66.95%±0.3,
$[q_i;k_i]代替$$h_i$这个操作,我试着反而$h_i$直接输入效果好一丢丢,可能多试几次平均效果差不多。
很有意义的一份工作!
在CMeEE和CLUENER上两者都差不多,但我发现在人民日报上用$\boldsymbol{h}_i$会差一点。
借苏神楼,torch 代码开源放在了 https://github.com/xhw205/Efficient-GlobalPointer-torch,CMeEE数据集未做任何trick,仅用 bert-large 就为 67.94±0.2%。bio-crf那一套,我将彻底摒弃。
January 26th, 2022
确实很强,自己做了torch版本的复现,还有用Efficient GlobalPointer来进行关系抽取的torch版本,大家看写的对不对,地址:https://github.com/powerycy/Efficient-GlobalPointer
手动点赞。你复现的成绩比我的还高,你用哪个预训练模型?
感谢苏神的回复,我用的chinese-roberta-wwm-ext,不过在做关系抽取的时候遇到了一些问题,就是在处理下三角的时候,没法像tplinker那样只算上半三角,本想着是否能有一些方法修改Circle Loss的时候,看到您发的cosnet那点,发现这个loss只能解决一些排序的分类,我最近也在想是否有什么更好的办法解决此问题= =
鄙人粗略的实现,https://github.com/powerycy/Efficient-GlobalPointer-rel,好多地方不太优雅,期待苏神的实现= =
请问我在看您复现的实体抽取的时候配置的模型应该是roberta-base而不是chinese-roberta-wwm-ext对吗
chinese-roberta-wwm-ext不就是roberta-base?
抱歉问这样的问题,因为入门不久,我自己在自己的训练集上训练之后效果远远不如BERT-BILSTM-CRF,甚至相差了十几个百分点,因为训练数据的语义是比较混乱的,GP模型是在混乱的语义下抽取出来的句子,想问一下是否可能因为这样混乱的语义导致训练效果很差,得到的效果还是比较不理想的,CRF模型的数据是单个标记的单行然后整理成句子,按理说句子语法也是一样的问题,想不明白。
建议检查数据处理代码和训练代码,99%是使用不当问题。
是否可能因为CEF的标注数据是BIO有头尾的标记导致,感觉也不应该产生这么大的差距才对
想了一晚上,整理了一下代码,刚刚看到CRF计算标签的时候把O标签也算进去了,可能是这方面导致CRF模型的效果虚高。
January 26th, 2022
刷到一系列的论文,都是基于start & end组合,识别span后再识别类型的。
Named Entity Recognition as Dependency Parsing
Boundary Enhanced Neural Span Classification for Nested Named Entity Recognition
Global Pointer中最独特的应该是RoPE和multilabel category loss了。
当前很多任务的主流做法都是基于token-pair的分类,所以从这个角度来看确实没什么新东西了。RoPE和multilabel_categorical_crossentropy确实是GlobalPointer成功的关键,尤其是multilabel_categorical_crossentropy,后来的Tplinker-plus也用到了它。
不过除了这两点,GlobalPointer一致坚持的追求是不能为了所谓的效果而牺牲效率,所以坚持要使用这种scaled-dot形式的打分,因为它算是目前最快速和最省显存的pair打分方式了。而有些改进采用Additive Attention甚至Conditional Layer Norm等方式,在我看来就是完全不实用的了。
January 30th, 2022
之前用多任务的方式做NER,就自然的把实体抽取用gp做,分类用线性层做,但是效果没有单独GP好,看了楼上的经验,看来是我尝试不够深。最后不得不感谢苏神和各位大佬们!
本文的思路是“分开设计,整体训练”。
February 23rd, 2022
苏神,我之前稍微看了下源码,有个问题想问一下哈,Efficient版本是不是就不再是multi-head了,这样理论上会不会对不同类别之间的识别带来影响?(虽然实验效果好像没什么影响或者更好了)
Efficient GlobalPointer在原理和使用上,跟标准版并无任何不同,只是打分函数的计算方式不一样,哪里不再是multi-head?
February 23rd, 2022
博主,我看了下近几年的ner工作,将词信息融入到模型大都有进一步的提升,博主有没有考虑把词信息融入到globalpointer中去?
这跟globalpointer完全无关。本质上globalpointer就是一个token-pair分类模块,用来干嘛、怎么用是使用者的事,至于加不加词信息,更加只是使用者的兴趣问题。
February 24th, 2022
苏神,《识别与分类》这一节里好像有一处小错误,原文里写的是“进一步地,我们记qi=Wqhi,ki=Wqhi”,是不是应该改为“进一步地,我们记qi=Wqhi,ki=Wkhi”。
谢谢反馈,已经修正。
March 29th, 2022
苏神,请问有试过不区分q,k,直接用一个W变换得到的q_i,k_j进行计算,这样计算的效果?
如果是这样,那$\boldsymbol{q}_i^{\top}\boldsymbol{k}_i$不是一定大于等于0?感觉不科学。