引言

本文主要分析CCKS 2022 通用信息抽取 – 基于UIE的基线系统代码实现,代码为DuUIE。本文和目前官方在不停推广的UIE稍微不同的是,本文是采用生成式的方式来做信息提取,也是百度最早论文所实现的方式。目前官方推广的是基于片段抽取的提取方式。对于这两者的不同,可参考DuUIE和UIE的对比

对于官方宣传的UIE的实现,之前代码也有做过分析UIE-事件提取,感兴趣的可以去看看。

通用信息提取

目前对于信息提取没有一个非常严格的定义,但是特点是针对不同的事件定义不同schema。针对给定的句子甚者是文章,来完成对应schema信息的提取。

信息提取形式上可分为三类:

1、flat(即每个schema成分都是平铺的,不存在交叉之类的现象)
2、nest(一个schema成分也可作为另外一个schema组合)
3、overlapping(比如张三、李四分别打算19号和20号回家。假设schema为:[人名、触发词、日期、动作],那打算就作为一个论元被其他论元重复使用)

目前信息提取比如有oneIE、casIE,各自的特点也有所不同。

生成式模型-T5

T5是google提出来的,其目的为Exploring the Limits of Transfer Learning with a Unified Text-to-Text Transformer。人如其名,其最大的特点为使用一个模型,通过对不同任务进行prompt建模,实现大一统(比如分类、摘要、翻译、文本生成等任务)。

这个是非常喜欢的,原因:
1、站在今天看GPT3、chatGPT等模型的成功,证明了这类模型通过增大参数量,使其具备更高的能力。
2、通过对不同任务进行prompt建模,能够在不同的监督任务上完成学习,不需要在下游不同任务(比如multi-task共用同一个encoder)进行分别建模(decoder部分)来各自适配。

相应的生成式模型也有比如bart、unilm等,此处bart应该也适用,所以跳过。后续有机会分享下unilm。更多介绍也可参考huggingface T5

DuUIE介绍

基线说明

本例采用面向信息抽取的统一序列到结构生成模型作为任务基线。

该模型将多种不同的信息抽取目标结构表示为统一的结构化抽取语言(Structured Extraction Language,SEL),并且通过端到端生成的方式实现复杂结构的抽取。

同时,该模型使用结构化框架前缀(Structural Schema Instructor,SSI)作为抽取目标来帮助模型区分不同的抽取任务。

结构化抽取语言

结构化抽取语言将不同的目标结构进行统一结构表示。
典型的结构化抽取语言的形式如下:

1
2
3
4
5
6
(
(Spot Name: Info Span
(Assocation Name: Info Span)
(Assocation Name: Info Span)
)
)

其中,

  • Spot Name: 信息点类别,如实体类型;
  • Assocation Name (asoc/asso): 信息点关联类别,如关系类型、事件论元类型;
  • Info Span: 信息点所对应的文本片段。

2月8日上午北京冬奥会自由式滑雪女子大跳台决赛中中国选手谷爱凌以188.25分获得金牌!中的信息结构为例:

  • 该句中的国籍关系 SEL 表达式为:

    1
    2
    3
    4
    5
    (
    (人物: 谷爱凌
    (国籍: 中国)
    )
    )
  • 该句中的夺冠事件 SEL 表达式为:

    1
    2
    3
    4
    5
    6
    7
    (
    (夺冠: 金牌
    (夺冠时间: 2月8号上午)
    (冠军: 谷爱凌)
    (夺冠赛事: 北京冬奥会自由式滑雪女子大跳台决赛)
    )
    )

生成SEL表达式后,我们通过解析器将表达式解析成对应的结构化抽取记录。

1
2
3
4
5
6
records = sel2record.sel2record(
pred=predicted_sel, # 生成的 SEL 表达式,例如 ((夺冠: 金牌 (冠军: 谷爱凌)))
text=raw_text,
...
)
records 为解析后的抽取结果。例如 {类别: 夺冠, 触发词: 金牌, 冠军: 谷爱凌}

结构化模式前缀

结构化模式前缀与带抽取的文本一同输入序列到结构生成模型,用于区分不同的抽取任务。
基线模型使用特殊字符 [spot][asoc] 来组织结构化模式前缀,[spot] 对应 SEL 中的 SpotName 类别,[asoc] 对应
不同任务的形式是:

  • 实体抽取:[spot] 实体类别 [text]
  • 关系抽取:[spot] 实体类别 [asoc] 关系类别 [text]
  • 事件抽取:[spot] 事件类别 [asoc] 论元类别 [text]
  • 情感抽取:[spot] 评价维度 [asoc] 观点类别 [text]

以夺冠事件为例,其对应的SSI为 [spot] 夺冠 [asoc] 夺冠事件 [asoc] 冠军 [asoc] 夺冠赛事 [text] 2月8日上午北京冬奥会自由...

阶段小结

别被这俩名词(SEL、SSI)给唬住了,SEL可以理解成是百度自创的一个language,将其这种树结构的转成平铺结构,如果你有看过constituency parser的话,就很类似了。之前出seq-to-seq模型结构时,就有人使用这种方式来训练constituency parser任务。SSI可以将其理解成是一种prompt构造方式,具体可参考上面,后续解码也有提及。

DuUIE预训练模型

官方示例使用的预训练模型是uie-char-small,和T5有明显不同的地方一是引入了<spot><asoc>两个special tokens,还有一个uie-char-small采用字粒度,而google的T5有的是基于词的,当然导致的就是词表增大(干了我想干的哇),可以自行尝试(默认T5是不支持中文的哦,可使用mt5来进行尝试),另外要添加这俩special tokens。

不过目前这种词粒度的tokenizer,除非分类,其他还是会是char粒度的。。

哎,百度是做了不少东西哇,啥都要自己搞一套。这里是pytorch版本的实现

DuUIE数据处理

这里不涉及数据预处理,schema的处理等操作,只涉及dataloader这些相关操作哦。

步骤一

这一步目的在于数据到dataloader时长这个样子。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
instance={
'id': '087d4f27c9e100ab27eca2d01f426edc',
'text': '无畏的约翰,弗兰德斯公爵菲利普二世的儿子,勃艮第菲利普三世的老爹因为领导勃艮第派与阿玛尼克-奥尔良派争夺法王可爱查理的摄政权而大打出手,葬送了查理五世亲手建立的大好局势无畏的约翰先是刺杀了奥尔良公爵路易并夺取摄政权,后又里通外国和英王亨利五世眉来眼去',
'tokens': ['无', '畏', '的', '约', '翰', ',', '弗', '兰', '德', '斯', '公', '爵', '菲', '利', '普', '二', '世', '的', '儿', '子', ',', '勃', '艮', '第', '菲', '利', '普', '三', '世', '的', '老', '爹', '因', '为', '领', '导', '勃', '艮', '第', '派', '与', '阿', '玛', '尼', '克', '-', '奥', '尔', '良', '派', '争', '夺', '法', '王', '可', '爱', '查', '理', '的', '摄', '政', '权', '而', '大', '打', '出', '手', ',', '葬', '送', '了', '查', '理', '五', '世', '亲', '手', '建', '立', '的', '大', '好', '局', '势', '无', '畏', '的', '约', '翰', '先', '是', '刺', '杀', '了', '奥', '尔', '良', '公', '爵', '路', '易', '并', '夺', '取', '摄', '政', '权', ',', '后', '又', '里', '通', '外', '国', '和', '英', '王', '亨', '利', '五', '世', '眉', '来', '眼', '去'],
'entity': [{
'type': '人物',
'offset': [12, 13, 14, 15, 16],
'text': '菲利普二世'
}, {
'type': '人物',
'offset': [0, 1, 2, 3, 4],
'text': '无畏的约翰'
}],
'relation': [{
'type': '父亲',
'args': [{
'type': '人物',
'offset': [0, 1, 2, 3, 4],
'text': '无畏的约翰'
}, {
'type': '人物',
'offset': [12, 13, 14, 15, 16],
'text': '菲利普二世'
}]
}],
'event': [],
'spot_asoc': [{
'span': '无畏的约翰',
'label': '人物',
'asoc': [
['父亲', '菲利普二世']
]
}, {
'span': '菲利普二世',
'label': '人物',
'asoc': []
}],
'spot': ['人物'],
'asoc': ['父亲']
}

预处理那里新增了spot、asoc和spot_asoc这些,spot可以理解成为entity的类别,asoc代表relation的类别,这俩对应的值都可以理解成来自spot_asoc。spot_asoc代表entity1和entity2的relation。比如上面spot_asoc,一共有两个:

  1. 无畏的约翰表示一个entity,其类别是人物,其对应的父亲菲利普二世
  2. 菲利普二世同样是一个entity,其类别是人物,但是没有与之对应的relation。

接着对instance[“text”]进行tokenizer,然后把这批数据对应的spots和asocs更新进来(这个在这批数据里都是一样的,都来自record.schema。后面涉及到schema的,都可理解成是record.schema,并包含了spots和asocs)

步骤二

这一步主要目的在于如何将其转换成训练格式。也包含如何构造结构化模式前缀

1. 构造input_id

接着走到了collate_fn这里,data是List[Dict],这个Dict就是上图的inputs,具体如下图。

每个data包含几部分:

  1. input_ids、attention_mask是对上图中的instance[“text”] tokenizer的结果
  2. spots、asocs是这批数据对应的record.schema
  3. spot_asoc就是将这条数据处理转换后的结果
  4. sample_ssi在train阶段都是true,eval阶段为false

可以看到,最终输入给模型的source_text_id(input_id) = prefix_id + text_start_id + ins["input_ids"]

其中prefix_id这里可以看到,前面self.ssi_generator.sample_spotself.ssi_generator.sample_asoc即打乱了schema中spotsasocs的顺序。

1
2
' '.join(self.tokenizer.convert_ids_to_tokens(prefix_id))
Out[3]: '<spot> 人 物 <spot> 地 理 位 置 <spot> 组 织 机 构 <asoc> 国 籍 <asoc> 妻 子 <asoc> 父 亲 <asoc> 祖 籍 <asoc> 丈 夫 <asoc> 毕 业 院 校 <asoc> 母 亲'

text_start_id为固定值,为<extra_id_2>

2. 构造label

代码入口convert_spot_asoc,看到了么,spot_asoc传了半天,最终在这里将其转成训练的label。最终转换成比如:

1
'<extra_id_0> <extra_id_0> 组织机构 <extra_id_5> 哈尔滨市第五中学 <extra_id_1> <extra_id_0> 组织机构 <extra_id_5> 哈五中 <extra_id_1> <extra_id_1>'

3. 阶段小结

这里有两点需要注意的:

  1. 在构造input_ids时的spots和asocs,schema数量是固定的,只是打乱了顺序。至于为什么需要将这个schema输入给模型来训练,想来想去莫非是要告诉模型这条数据的schema只有这些,抽取的话即要找到这些spots和asocs的值。但是如果record.schema中spots和asocs数量比较多的话,那就会导致需要修改喂给T5的max_source_length。
  2. label的构造太过复杂或者说不容易理解,同时也喂给T5的max_target_length过长,但是后续看了解码实现,又明白这么做的意义。

所以整体流程为:
给定句子s,若record.schema为spots=[“人物”, “地理位置”, “组织机构”],asocs={“人物”: [“母亲”, “妻子”, “毕业院校”, “丈夫”, “父亲”, “国籍”, “祖籍”], “地理位置”: [], “组织机构”: []},那么这条schema构造后喂给模型,我要抽取人物、地理位置、组织机构,如果有人物的话,那么还要抽取对应的母亲、妻子…祖籍。

如果有多个schema类别,数量为n,那么就构造n次来对句子s进行信息抽取。

DuUIE训练

这部分反而没什么过多可以说的,可以参考T5 training建议。包括数据构造,如何使用T5,学习率、generate等等。

DuUIE解码

数据处理在,接着来到generate这里,这里流程如下图。

看到了么,核心在sel2record,感兴趣的要仔细看这里,这里重点突出了结构化抽取语言的思想。。。

最终形式举例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
(
(<extra_id_7>
<extra_id_5>
天城公主
(祖籍 <extra_id_5> 通之)
(丈夫 <extra_id_5> 陈国峻)
(配偶 <extra_id_5> 陈国峻)
(国籍 <extra_id_5> 中国))
(<extra_id_7>
<extra_id_5>
陈国峻
(妻子 <extra_id_5> 天城公主)
(国籍 <extra_id_5> 中国)
(毕业院校 <extra_id_5> 北京大学)
(地理位置 <extra_id_5> 通之)
(祖籍 <extra_id_5> 通之)
(配偶 <extra_id_5> 天城公主))
(<extra_id_7> <extra_id_5> 天城 (人物 <extra_id_5> 陈国峻))
(<extra_id_7> <extra_id_5> 陈国 (国籍 <extra_id_5> 中国)))

这里可以花费更多经历在sel2record上,但是整体流程看下来,相比于UIE来讲,这个实现方式太偏学术,并且过于复杂,如果有不同的方法完成同一件事情,那么简单的做法其实会是更容易接受的做法。

总结

此篇文章算是开了一个新的思路,采用生成式方式来做信息提取。