1. 信息抽取任务是什么?
在NLP任务中,通常当我们拿到一段文本时,我们希望机器去理解这段文本描述的是什么内容,进而完成一些特定的任务。
例如,现在有这么一句话:
- 今日,在玩家们的期待中,王者荣耀终于上架了李白的新皮肤——凤求凰。
这句话中具体描述了什么事件呢?人类一看就知道,哦,王者出了一款李白的新皮肤。
没错,这句话的核心就是「王者」「出了」「李白」「新皮肤」。
至于「…在玩家们的…」这些文字大概率直接被读者自动给过滤掉了,这就是人类天然存在的超强信息提取的能力。
扩展:人类除了在阅读文字上有着超强的信息抽取能力,在听觉上同样如此,大部分人都能在多人同时说话的场景下清楚的听取想听的那个人说话的内容。同样的,学者们也尝试让机器具备同样的能力,Speech Separation的其中一个研究方向便是如此。
为此,我们希望机器也能具备人类一样的信息抽取能力,能够在一段文字中自动抽出一组一组成对的关键信息。
我们再来回顾一下刚才那句话,句子中包含两个主要信息:
- 王者出了一款新皮肤。
- 这款新皮肤是李白的。
这两个主要信息都可以用基本的三元组的结构来表示:
- 王者(主语) - 出了(谓语) - 新皮肤(宾语)
- 新皮肤(主语) - 属于(谓语) - 李白(宾语)
不难看出,不管多么复杂的句子和多么复杂的关系,最后都是可以被细分为若干个三元组,即:
(
E
n
t
i
t
y
1
,
R
e
l
a
t
i
o
n
,
E
n
t
i
t
y
2
)
(Entity_1, Relation, Entity_2)
(Entity1,Relation,Entity2)
**实体(Entity)**是NLP领域中的常用语,用于描述某一个「东西」,这个「东西」通常用于描述一种词语类别.
比如「游戏」就可以被定义为一种实体,举例来说,有如下句子:王者荣耀和和平精英都是当下比较流行的游戏。
该句子中就包含了2个「游戏」实体:王者荣耀(GameEntity1)和和平精英(GameEntity2)都是当下比较流行的游戏。
此外,在信息提取(InfoExtraction)领域中,我们需要将关系(Relation)前后的两个实体稍稍区分一下:
关系(Relation)前面的实体(Entity1)我们称之为头实体(Subject);
关系(Relation)后面的实体(Entity2)我们称之为尾实体(Object);
这就是我们常说的SPO表示法。S-头实体,O-尾实体,P-Predicate,即「关系(Relation)」更专业的叫法。
王
者
荣
耀
(
S
)
−
出
了
(
P
r
e
d
i
c
a
t
e
)
−
凤
求
凰
(
O
)
凤
求
凰
(
S
)
−
属
于
(
P
r
e
d
i
c
a
t
e
)
−
李
白
(
O
)
王者荣耀(S)- 出了(Predicate)- 凤求凰(O)\\ 凤求凰(S)- 属于(Predicate)- 李白(O)
王者荣耀(S)−出了(Predicate)−凤求凰(O)凤求凰(S)−属于(Predicate)−李白(O)
至此,我们已经明白了什么是信息抽取(抽取句子中的SPO结构体),以及什么是SPO结构体(描述实体关系的最小单位),那么接下来就开始进行实战吧。
2. 基于PaddleNLP的信息抽取任务
这篇文章中将选择使用PaddleNLP作为辅助来完成信息抽取任务,所用的数据集/示例代码均能在PaddleNLP官网上找到,并且很方便的为我们提供了运行环境,不用自己手搭环境、下载数据集。接下来的内容只是在官方资料的基础上加上一些自己的理解,有兴趣小伙伴可以参考下面的官方资料:
- 官方视频资料:https://aistudio.baidu.com/aistudio/education/lessonvideo/2016982
- 官方示例代码:https://aistudio.baidu.com/aistudio/projectdetail/3190877?forkThirdPart=1
2.1 训练任务概览
本次实战的数据集来自于千言数据集,是一个很全面的中文开源数据集合,这次我们将选取其中「信息抽取」任务相关的数据集。
我们先从训练数据集中随机挑出一条样本来看看:
{
"text": "2013年,哈密地区户籍人口约58万人",
"spo_list": [
{
"predicate": "人口数量",
"object_type": {"@value": "Number"},
"subject_type": "行政区",
"object": {"@value": "58万"},
"subject": "哈密"
}
]
}
分析一下这一条数据,输入的是一句话:「2013年,哈密地区户籍人口约58万人」。
我们需要提取出该句子中全部的SPO关系(1个,spo_list中表示):
哈
密
(
S
)
−
人
口
数
量
(
P
)
−
58
万
(
O
)
哈密(S)- 人口数量(P)- 58万(O)
哈密(S)−人口数量(P)−58万(O)
其中,“subject”、“predicate”、"object"分别代表SPO三个值;
“subject_type” 和 "object_type"代表头/尾实体的类型,这个我们将放在后面讲。
至此我们已经明白了数据集中样本长什么样,我们的任务目标就是将一个句子(text)当中所有的SPO结构体(spo_list)给提取出来。
2.2 Predicate列表
在明确了提取SPO的任务目标后,我们首先要做的事情就是对所有可能的Predicate进行枚举。
Predicate的本质是定义了两个实体之间的「关系」,因此,我们需要告诉模型,模型需要提取哪些「关系」。
例如,有如下句子:
- 张裕妃,顺天府涿州人,父张世登,母段氏
这个句子中就存在两种实体关系:
- 张裕妃(S)- 父亲(P1)- 张世登(O1)
- 张裕妃(S)- 母亲(P2)- 段氏(O2)
其中,父亲、母亲就是两种不同的实体关系(即不同的Predicate)。
因此,我们首先需要先对数据集进行分析,枚举出所有实体之间可能存在的实体关系,并记录下来。
这个「实体关系列表」就是项目文件下的 “predicate2id.json” 文件所表达的意思,我们将该文件打印出来看看:
{
"O": 0,
"I": 1,
"注册资本": 2,
"作者": 3,
"所属专辑": 4,
"歌手": 5,
...
"上映时间_@value": 8,
"上映时间_inArea": 9,
...
}
这个json文件中定义了所有可能的实体关系(Predicate),其中O和I和BIO(Begin,Intermediate,Other)标记法中的O、I完全一致,这里不再赘述,除了O、I以外,剩下的就是数据集中所有可能的实体关系了。
例如,
「作者」就代表了一种实体关系:
[+]《道学的病理》是2007年商务印书馆出版的图书,作者是韩东育 → \rightarrow →《道学的病理》的作者是韩东育
「歌手」也代表了一种实体关系:
[+] 写历史作业时,林俊杰的《曹操》和周杰伦的《爱在西元前》混搭,绝配 → \rightarrow →《曹操》的歌手是林俊杰
注意,实体关系(Predicate)不一定必须出现在原文本中。如「作者」出现在了第一句中;但「歌手」没有出现在第二句文本中。
这里可能有同学注意到了8号&9号实体关系有些奇怪,同样都是「上映时间」为什么后缀不同,一个是_@value,一个是_inArea,这个我们放到下一节讲。
2.3 SPO列表
在我们了解完实体关系(Predicate)列表后,我们已经明白数据集当中有哪些实体关系了,现在我们需要关注数据集当中有哪些实体了。
还记得实体是什么吗?
我们之前提到过,实体关系是用来连接首、尾实体的,因此,这里的实体应当枚举数据集中所有存在的实体类型。
那什么是实体类型呢?实体类型的实质就是对所有的实体按类别进行归类后,得到的类别标签。
举例来讲,
[+] 杨幂出演了《绣春刀》里的北斋。
[+] 胡歌出演了《琅琊榜》中的梅长苏。
这两句话中,「杨幂」和「胡歌」是两个实体,但这两个实体都对应同一种实体类型——演员。
实体类型是可以按照自己的需求来自己定义的,这取决于要做什么任务。
这很好理解:
如果我们要做一个针对影视作品的信息提取模型,那我们的实体类型就可能是:影视作品、演员、导演…等等。
但如果我们要做一个游戏内容的提取模型,那我们的实体类型就有可能是:英雄、技能、皮肤…等等。
在SPO列表中,对于头实体(S)和尾实体(O)的类型进行了分开表示,即,分为头实体类型(subject_type)和尾实体类型(object_type)。
类型列表在项目目录下 “id2spo.json” 文件中存储,我们打印该文件看看:
{
"predicate": ["empty", "empty", "注册资本", "作者", "所属专辑", ...],
"subject_type": ["empty", "empty", "企业", "图书作品", "歌曲", ...],
"object_type": ["empty", "empty", "Number", "人物", "音乐专辑", ...]
}
可以看到,该json文件下存放了三个list,分别代表了SPO的对应type类型。
其中,每一个列表中对应的索引是可以构成一个合法三元组的,我们将相同索引的各列表中的列表组合打印出来,结果如下:
----------------------
S | P | O
----------------------
empty | empty | empty
empty | empty | empty
企业 | 注册资本 | Number
图书作品 | 作者 | 人物
歌曲 | 所属专辑 | 音乐专辑
歌曲 | 歌手 | 人物
行政区 | 邮政编码 | Text
影视作品 | 主演 | 人物
影视作品 | 上映时间 | Date_@value
影视作品 | 上映时间 | 地点_inArea
娱乐人物 | 饰演 | 人物_@value
娱乐人物 | 饰演 | 影视作品_inWork
...
其中,前2个empty是为了O和I标签留的,因为之前定义的predicate列表中的前2个标签分别为O、I,这两个标签不会起到连接首尾实体的作用,因此需要置为empty。
注意看这两行:
...
影视作品 | 上映时间 | Date_@value
影视作品 | 上映时间 | 地点_inArea
...
还记得我们在1.2.2节中提到的奇怪的问题吗,为什么同一个实体关系「上映时间」会有两个不同的后缀——上映时间_@value和上映时间_inArea?
想必你已经在这个表中找到答案了吧?
原因就是,同一个「头实体(S) - 实体关系(P)- ?」可能会对应多个不同的尾实体(O)。
举例来讲,
[+]《大耳朵图图之美食狂想曲》的动画电影将于2017年7月28日在中国上映
这句话当中,《大耳朵图图之美食狂想曲》(S)- 上映(P)- ?,同一个SP可以对应两个不同的O:
- 《大耳朵图图之美食狂想曲》(S)- 上映(P)- 2017年7月28日(O1)
- 《大耳朵图图之美食狂想曲》(S)- 上映(P)- 中国(O2)
第一个SPO代表电影-上映-上映时间;
第二个SPO代表电影-上映-上映地点;
由此我们可以看出,对于同一个S-P,句子中是可能存在多个不同的合法O的,那我们就需要使用两个或多个不同的S-P来对应这些不同的O。
一种最常见的方法就是对P再进行细分,尾实体O不是既有可能是上映时间,也有可能是上映地点吗。那我就直接分别设定两个不同的P(上映时间)标签就好了,即:
- 上映时间_@value:用于连接实体类型为「时间」的尾实体。
- 上映时间_inArea:用于连接实体类型为「地点」的尾实体。
这就是我们对应在尾实体列表(object_type)中看到存在 […, Date_@value, 地点_inArea, …] ,在predicate2id文件中看到 […, “上映时间_@value”, “上映时间_inArea”, …] 的原因。
这里顺带再提一句,我们看看在训练数据集中这种关系是如何表示的:
{
...
"spo_list": [
{
"predicate": "上映时间",
"object_type": {"inArea": "地点", "@value": "Date"},
"subject_type": "影视作品",
"object": {"inArea": "中国", "@value": "4月14日"},
"subject": "垫底辣妹"
}
...
}
可以看到,训练数据集中,“object_type” 和 “object” 字段对应的都是一个字典(可以包含多个值),而SP对应的都是一个唯一的值。
这就印证了我们刚才的说法,确定一组S-P,可以对应多个不同的尾实体O。
但是,这里和prediacte2id.json中不同,训练集中的predicate的值并不包含标签后缀,不是「上映时间_@value」,而直接就是「上映时间」。
这样将原来的predicate后缀变化到 "object"字段中的key中,其实更符合人们的认知,毕竟关系不应该存在区别,区别的应该是实体类型。
2.4 代码解析
在该任务中,我们将实体关系抽取问题建模为Token Classification的问题。
我们知道,在命名实体识别(Named Entity Recognition,NER)问题中,通常会用Token Classification的方式来进行任务求解,即判断每个字符(token)属于哪个类别(某个实体?非实体?)。
在NER任务中使用字符分类的方式我们非常容易就理解,但是,我们怎么通过对字符分类的方式来提取出不同实体之间的关系呢?
答案就是:在字符类别中添加入「关系标签」,即该字符是否能和这句话当中的其他字符产生关联关系。
这个想法相当简单暴力,也相当符合神经网络「硬train一发」的核心原则。
模型结构图如下所示:
![](https://i-blog.csdnimg.cn/blog_migrate/e2ab12473d73eeaf8f24850910362777.png)
可以看到,对每个token,label一共有(2N + 2)维,其中 N 为我们之前所讲的 predicate 的类别个数。
这还是比较好理解,每个词既可能作为一个实体关系(Predicate)的头实体(S),也有可能作为尾实体(O),所以实体关系类别有N个,那么一个字符总共可能的 label 数就为 2N。
那么为什么还要 +2呢?因为某些字符可能只是无效信息(O),或是词中字符(I)(注:这个标注体系中只对B-进行实体类型分类,I就不用做分类了),因此整体维度还要再 +2。
还有一点非常重要,在传统的多分类任务中,我们是在N个label中选择1个类别作为最后的结果,使用的是softmax + corss_entropy;但在这个任务中,我们不使用softmax + CE,而是使用Binary Cross Entropy Loss(BCE Loss)。
仔细想想,这个任务和传统的多分类任务有何不同。对于每个字符来讲,其只能属于某一种类别吗?
举例来讲:
[+] 李白的皮肤叫凤求凰,技能为青莲剑歌。
这句话中李白(S)即可以是皮肤凤求凰(O)的头实体,也可以是技能青莲剑歌(O)的头实体。
即,在这2个类别的对应label都应该为1。
这种可能属于多个不同标签的任务叫做多标签分类任务(不同于多分类任务),其对于每个标签都建立一个sigmoid函数,判断其属于这个标签结果为0还是1(不同于softmax只选择概率最大的一个标签)。
- 模型搭建
该任务是基于ERNIE 1.0(基于Transformer Encoder的模型,大体和BERT一样,预训练方式不同,对中文支持更友好)作为pre-trained model实现的,paddle提供了很方便的高阶API,能够让我们快速导入ERNIE模型。
from paddlenlp.transformers import ErnieForTokenClassification, ErnieTokenizer
label_map_path = os.path.join('data', "predicate2id.json")
num_classes = (len(label_map.keys()) - 2) * 2 + 2 # 每个token可能的类别总数
model = ErnieForTokenClassification.from_pretrained("ernie-1.0", num_classes=num_classes)
tokenizer = ErnieTokenizer.from_pretrained("ernie-1.0")
inputs = tokenizer(text="这是一条测试数据", max_seq_len=20)
print(inputs)
model 和 tokenizer可以直接导入预训练模型(model为神经网络模型,tokenizer为文本预处理模型)。
我们输入一条测试文本「这是一条测试数据」,并打印出被tokenizer encode后的结果:
{
'input_ids': [1, 47, 10, 7, 304, 558, 525, 179, 342, 2],
'token_type_ids': [0, 0, 0, 0, 0, 0, 0, 0, 0, 0]
}
encode后结果是一个json,包含 ‘input ids’ 和 ‘token_type_ids’。
> input ids
文字对应的索引index,文字与index的对应关系在模型预训练的时候就已经规定好了,我们可以看看ERNIE模型中的文字-索引对应关系(只展示前10个数据):
[PAD]
[CLS]
[SEP]
[MASK]
,
的
、
一
人
有
...
...
...
可以看到,ERNIE 1.0的前4个token id都对应的是功能token,从第5个token id起为文字、标点对应的token id。
> token_type_ids
这个列表用于表示当前token属于第几个句子。
在某些任务中,例如QA(问答系统)任务,我们的输入会是两条(问题 & 文章)甚至两条以上的句子,token_type_ids用于表征当前字符token属于第几个句子(从0开始表示第一个句子)。
注意,开始符[CLS]和句子分割符[SEP]都会归类为第一条/上一条句子。
- 损失函数构建 & 模型训练
搭建完模型后,我们现在对损失函数进行设计,进而进行模型训练。
在小节开头我们说明了,该任务为一个多标签分类任务,应使用的BCE作为Loss Function,实现如下:
import paddle.nn as nn
class BCELossForDuIE(nn.Layer):
def __init__(self, ):
super(BCELossForDuIE, self).__init__()
self.criterion = nn.BCEWithLogitsLoss(reduction='none')
def forward(self, logits, labels, mask):
loss = self.criterion(logits, labels)
mask = paddle.cast(mask, 'float32')
loss = loss * mask.unsqueeze(-1)
loss = paddle.sum(loss.mean(axis=2), axis=1) / paddle.sum(mask, axis=1)
loss = loss.mean()
return loss
其中mask(0/1列表)的作用为去掉一些特殊符号(如[SEP],[CLS]等功能符号)的分类结果Loss,因为这些功能符号的token分类结果无关紧要,因此不计算它们的token loss用作反向传播。
接下来实例化优化器Optimizer:
from paddlenlp.transformers import LinearDecayWithWarmup
learning_rate = 2e-5
num_train_epochs = 5
warmup_ratio = 0.06
criterion = BCELossForDuIE()
# Defines learning rate strategy.
steps_by_epoch = len(train_data_loader)
num_training_steps = steps_by_epoch * num_train_epochs
lr_scheduler = LinearDecayWithWarmup(learning_rate, num_training_steps, warmup_ratio)
optimizer = paddle.optimizer.AdamW(
learning_rate=lr_scheduler,
parameters=model.parameters(),
apply_decay_param_fun=lambda x: x in [
p.name for n, p in model.named_parameters()
if not any(nd in n for nd in ["bias", "norm"])]) # 只对weights做退火策略
通常,在进行大模型的Fine-Tune过程中我们需要使用DecayWithWarmup 策略来使得模型具备更好的训练效果。
这是因为,pre-trained model通常比较大,且在较为复杂的数据集上完成了训练。当我们将模型用在自己较小的数据集上进行训练时,如果一开始就设置比较大的学习率,可能会导致过拟合,或者模型直接训坏。
为此,我们需要一开始时用比较柔和的更新策略,即设置较小的学习率,慢慢地增大学习率;等到了一段时间后,在将学习率慢慢降低,先WarmUp,再Decay。
常见的DecayWithWarmup方式有线性(Linear)和余弦(Cosine)两种方式,该项目中我们使用线性衰减的方式,如下图所示:
![](https://i-blog.csdnimg.cn/blog_migrate/834713c41f19e3a16ed5afc3a7b0ae9c.png)
注意:在warmup和decay过程中,我们都只对weights进行修改,而对bias和layer norm的参数都不做升温和退火处理。
最后,编写模型训练函数即可:
for epoch in range(num_train_epochs):
for step, batch in enumerate(train_data_loader):
input_ids, seq_lens, tok_to_orig_start_index, tok_to_orig_end_index, labels = batch
logits = model(input_ids=input_ids)
mask = (input_ids != 0).logical_and((input_ids != 1)).logical_and(
(input_ids != 2))
loss = criterion(logits, labels, mask)
loss.backward()
optimizer.step()
lr_scheduler.step()
optimizer.clear_gradients()
loss_item = loss.numpy().item()
if global_step % save_steps == 0 and global_step != 0:
precision, recall, f1 = evaluate(model, criterion, test_data_loader,
eval_file_path, "eval")
paddle.save(model.state_dict(), os.path.join(output_dir,
"model_%d.pdparams" % global_step))
global_step += 1
(完整代码在这里)
以上便是信息抽取(Info Extraction)的全部内容。