首页 > 技术文章 > 【代码精读】DocRED: A Large-Scale Document-Level Relation Extraction Dataset(1)

Harukaze 2020-12-28 15:06 原文

先介绍原作者的实验细节

为了比较不同模型的结果,使用Adam对所有基线进行了优化,学习率为0.001,$\beta_1=0.9$,$\beta_1=0.999$。实验中使用的其他实验超参数如下图所示。另外,由于实体之间的文档级距离,距离首先被划分为几个小单元{1,2,…,$2^k$},其中每个单元与可训练的距离嵌入(trainable distance embedding)相关联。

Types of Named Entities

在本文中,我们修改了Tjong Kim Sang and De Meulder(2003)中使用的现有命名实体类型,以更好地为DocRED服务。这些类型包括“Person(PER)”、“Organization(ORG)”、“Location(LOC)”、“Time(TIME)”、“Number(NUM)”和“other types(MISC)”。DocRED中命名实体的类型及其涵盖的内容如下图所示。

List of Relations

我们在DocRED中提供了关系列表,包括Wikidata IDs、relation names和Wikidata中的描述,如表10和表11所示

 

首先我们从官网下载得到数据集与代码原始的data包含五个文件

训练集:train_annotated.json(Supervised)  train_distant.json(Weakly Supervised)

验证集:dev.json

测试集:test.json

关系字典:rel_info.json 

Data Format:
{
  'title',
  'sents':     [
                  [word in sent 0],
                  [word in sent 1]
               ]
  'vertexSet': [
                  [
                    { 'name': mention_name, 
                      'sent_id': mention in which sentence, 
                      'pos': postion of mention in a sentence, 
                      'type': NER_type}
                    {anthor mention}
                  ], 
                  [anthoer entity]
                ]
  'labels':   [
                {
                  'h': idx of head entity in vertexSet,
                  't': idx of tail entity in vertexSet,
                  'r': relation,
                  'evidence': evidence sentences' id
                }
              ]
}

接下来打开json文件看下不同文件之间数据格式的差别

首先是人工标注的训练数据集train_annotated.json

 

可以看到每一篇文章被标注为这样的四部分,顶点集(entity set),标签集,主题title,句子

1) 先介绍vertexSet顶点集,可以看到这篇文章有17个entity

 

还可以看到第一个entity节点的4个mentions在文章中哪句话以及在对应的句子中的相对位置在哪,如下图所示

 

2)标签集,首先我们可以知道这篇文章拥有13个标签(13个关系)

 

Evidence:是论文提到的人工标注的Supporting Evidence

3) title项

4) sent项,可知这篇文章有8个句子,每个句子长度如下图所示

 

接下来介绍弱监督训练集,总体和有监督一样,区别在于label项,没有evidence

 

验证集与人工标注的训练数据集train_annotated.json格式相同,论文中也介绍过。

测试集没有label项需要我们拿来作预测,其他项同人工标注训练数据集:

 

Download metadata from TsinghuaCloud or GoogleDrive for baseline method and put them into prepro_data folder

分别有6个文件:

char_vec.npy Glove预训练好的字符向量文件

vec.npy     Glove预训好的单词向量文件

char2id.json   自定义BLANK(空),UNK(未识别),常见字母,生僻希腊字母等字符对应id

ner2id.json     {"BLANK": 0, "ORG": 1, "LOC": 2, "TIME": 3, "PER": 4, "MISC": 5, "NUM": 6}

rel2id.json       一个关系转换为id的字典"P1412": 38, "P206": 33, "P205": 77

word2id.json   文章中所有可能出现的所有字符单词标点符号对应的id

数据准备完毕,开始preprocessing data ,运行gen_data.py

创建解析器,添加路径参数

1 parser = argparse.ArgumentParser()
2 parser.add_argument('--in_path', type = str, default =  "data")
3 parser.add_argument('--out_path', type = str, default = "prepro_data")
4 args = parser.parse_args()
5 in_path = args.in_path
6 out_path = args.out_path

data文件夹中获取对应预处理数据的文件名

1 train_distant_file_name = os.path.join(in_path, 'train_distant.json')
2 train_annotated_file_name = os.path.join(in_path, 'train_annotated.json')
3 dev_file_name = os.path.join(in_path, 'dev.json')
4 test_file_name = os.path.join(in_path, 'test.json')

通过rel2id文件获得id2rel文件

1 rel2id = json.load(open(os.path.join(out_path, 'rel2id.json'), "r"))
2 id2rel = {v:u for u,v in rel2id.items()}
3 json.dump(id2rel, open(os.path.join(out_path, 'id2rel.json'), "w"))

调用init()函数生成处理后的文件

1 init(train_distant_file_name, rel2id, max_length = 512, is_training = True, suffix='')
2 init(train_annotated_file_name, rel2id, max_length = 512, is_training = False, suffix='_train')
3 init(dev_file_name, rel2id, max_length = 512, is_training = False, suffix='_dev')
4 init(test_file_name, rel2id, max_length = 512, is_training = False, suffix='_test')

可以看到弱监督训练集(也叫做远程监督数据集)设置is_training=True,suffix=’’

下面来介绍init()函数

加载要处理的文件

1 ori_data = json.load(open(data_file_name))

len(ori_data)是文档数目,这个for循环以文档为单位进行循环

1 for i in range(len(ori_data)):(1)

以下为循环(1)的内部

1 Ls = [0]
2 L = 0
3 for x in ori_data[i]['sents']:
4    L += len(x)
5    Ls.append(L)

Ls记录第i篇文档每句话的起始绝对位置

获取第i篇文档的顶点集

1 vertexSet =  ori_data[i]['vertexSet']
 1 # point position added with sent start position
 2 for j in range(len(vertexSet)):
 3    for k in range(len(vertexSet[j])):
 4       vertexSet[j][k]['sent_id'] = int(vertexSet[j][k]['sent_id'])
 5 
 6       sent_id = vertexSet[j][k]['sent_id']
 7       dl = Ls[sent_id]
 8       pos1 = vertexSet[j][k]['pos'][0]
 9       pos2 = vertexSet[j][k]['pos'][1]
10       vertexSet[j][k]['pos'] = (pos1+dl, pos2+dl)

因为一个entity(实体节点)可能有很多mentions,所以要用双重循环遍历到每一个mentions,获得每一个mention他在文档中属于第几句话,通过Ls记录的每句话的起始绝对位置与相对位置相加,得到该mention在文档中的绝对位置并返回给pos标签处

处理过的新vertexSet覆盖掉原来的老顶点集vertexSet

1 ori_data[i]['vertexSet'] = vertexSet

获取要处理文档的labels项,有就返回json字典labels项对应的内容,否则返回一个[]。(因为我们处理的test.json需要进行预测,无labels项)

1 labels = ori_data[i].get('labels', [])
 1 fact_in_train = set([])
 2 fact_in_dev_train = set([])
 3 train_triple = set([])
 4 new_labels = []
 5 for label in labels:
 6    rel = label['r']
 7    assert(rel in rel2id)
 8    label['r'] = rel2id[label['r']]
 9 
10    train_triple.add((label['h'], label['t']))
11 
12 
13    if suffix=='_train':
14       for n1 in vertexSet[label['h']]:
15          for n2 in vertexSet[label['t']]:
16             fact_in_dev_train.add((n1['name'], n2['name'], rel))
17 
18 
19    if is_training:
20       for n1 in vertexSet[label['h']]:
21          for n2 in vertexSet[label['t']]:
22             fact_in_train.add((n1['name'], n2['name'], rel))
23 
24    else:
25       # fix a bug here
26       label['intrain'] = False
27       label['indev_train'] = False
28 
29       for n1 in vertexSet[label['h']]:
30          for n2 in vertexSet[label['t']]:
31             if (n1['name'], n2['name'], rel) in fact_in_train:
32                label['intrain'] = True
33 
34             if suffix == '_dev' or suffix == '_test':
35                if (n1['name'], n2['name'], rel) in fact_in_dev_train:
36                   label['indev_train'] = True
37 
38 
39    new_labels.append(label)

我们遍历labels集,我们首先通过rel保存获得的关系代码(例如P17),然后我们将P17对应的id=1,重新存入label[‘r’]。train_triple将此关系对应的头实体与尾实体存入(因为是个set所以重复的就略去了)

第一个if判断,如果该文件是有监督训练数据集,关系头实体对应的所有mentions的name,关系尾实体对应的所有menitons的name,以及保存的关系代码rel,全部双重遍历以关系三元组的形式保存在了fact_in_dev_train中。

第二个if判断,如果该文件是弱监督训练数据集关系头实体对应的所有mentions的name,关系尾实体对应的所有menitons的name,以及保存的关系代码rel,全部双重遍历以关系三元组的形式保存在了fact_in_train中。

这个else与第二个if是搭配关系,也就是按照作者的想法如果,只要不是远程监督训练集都会有这两个新标签 :label['intrain'] = False    label['indev_train'] = False

首先打上两个新标签,赋值为False

之后执行双重循环,判断该关系的头实体对应的所有mentions的name,尾实体对应的所有mentions的name,形成的三元组是否在有监督训练数据集&弱监督训练数据集中出现过,出现过标签改为True

最后把修改过的label保存到list中new_labels.append(label)

1 na_triple = []
2 for j in range(len(vertexSet)):
3    for k in range(len(vertexSet)):
4       if (j != k):
5          if (j, k) not in train_triple:
6             na_triple.append((j, k))

遍历每篇文章中所有的entity(注意是entity不是mentions),将不存在关系的两个entity存入na_triple中

data = [ ] 每篇文章处理完,调用append(itme)保存信息(该list在循环(1)之外)

1 item = {}
2 item['vertexSet'] = vertexSet
3 item['labels'] = new_labels
4 item['title'] = ori_data[i]['title']
5 item['na_triple'] = na_triple
6 item['Ls'] = Ls
7 item['sents'] = ori_data[i]['sents']
8 data.append(item)

到此为止循环(1)结束

文件保存

1 # saving
2 print("Saving files")
3 if is_training:
4    name_prefix = "train"
5 else:
6    name_prefix = "dev"
7 json.dump(data , open(os.path.join(out_path, name_prefix + suffix + '.json'), "w"))  #生成新的json数据

如果是远程监督训练数据集则文件名前缀定为train

如果是人工标注训练数据集,验证集以及测试集,则前缀名为dev

1 char2id = json.load(open(os.path.join(out_path, "char2id.json")))
2 word2id = json.load(open(os.path.join(out_path, "word2id.json")))
3 ner2id = json.load(open(os.path.join(out_path, "ner2id.json")))

根据预处理文件文章篇数目初始化numpy矩阵,char_limit限制了每个单词字符最大长度不超过16

1 sen_tot = len(ori_data)
2 sen_word = np.zeros((sen_tot, max_length), dtype = np.int64)
3 sen_pos = np.zeros((sen_tot, max_length), dtype = np.int64)
4 sen_ner = np.zeros((sen_tot, max_length), dtype = np.int64)
5 sen_char = np.zeros((sen_tot, max_length, char_limit), dtype = np.int64)

接下来重点阅读分析encoding部分

 1 for i in range(len(ori_data)):
 2    item = ori_data[i]
 3    words = []
 4    for sent in item['sents']:
 5       words += sent
 6 
 7    for j, word in enumerate(words):
 8       word = word.lower()
 9 
10       if j < max_length:
11          if word in word2id:
12             sen_word[i][j] = word2id[word]
13          else:
14             sen_word[i][j] = word2id['UNK']
15 
16          for c_idx, k in enumerate(list(word)):  #list('you') = ['y','o','u']
17             if c_idx>=char_limit:
18                break  #break语句只能跳出当前层次的循环
19             sen_char[i,j,c_idx] = char2id.get(k, char2id['UNK'])
20 
21    for j in range(j + 1, max_length):
22       sen_word[i][j] = word2id['BLANK']
23 
24    vertexSet = item['vertexSet']
25 
26    for idx, vertex in enumerate(vertexSet, 1):   #idx从下标1开始
27       for v in vertex:
28          sen_pos[i][v['pos'][0]:v['pos'][1]] = idx
29          sen_ner[i][v['pos'][0]:v['pos'][1]] = ner2id[v['type']]

words列表存储第i篇文章的所有单词

接下来将单词全部转换为小写(因为word2id.json文件中全部都是小写单词),如果该篇文章所有单词小于max_length,在word2id.json中能找到的单词对应id的存入到sen_word的numpy矩阵中,找不到对应id的存入['UNK']对应的id

第16-19行的循环体,将每个单词的每个字符保存在sen_char的numpy矩阵中(长度大于16直接截断),

第21-22行循环体是文章长度不足512,用word2id['BLANK']进行padding补足

第26-29行循环体是将第i篇文章中的mentions使用idx在sen_pos矩阵中进行填充(idx表明是文中第几个实体),同一个entity的不同mentions的idx值都是一样的,以同一entity的第一个mention的idx为准。而且还将节点命名实体类型的id在sen_ner中进行填充

最后是文件保存部分

1 print("Finishing processing")
2 np.save(os.path.join(out_path, name_prefix + suffix + '_word.npy'), sen_word)
3 np.save(os.path.join(out_path, name_prefix + suffix + '_pos.npy'), sen_pos)
4 np.save(os.path.join(out_path, name_prefix + suffix + '_ner.npy'), sen_ner)
5 np.save(os.path.join(out_path, name_prefix + suffix + '_char.npy'), sen_char)
6 print("Finish saving")

train_distant.json 通过gen_data.py

init(train_distant_file_name, rel2id, max_length = 512, is_training = True, suffix='')

生成了train.json ,train_char.npy       train_ner.npy        train_pos.npy       train_word.npy(101873,512)        train_char.npy (101873,512,16)

可以看到处理过后的弱监督训练数据集,多了na_triple(记录无关系实体对)和Ls(记录句子开头绝对位置)项,vertexSet实体顶点集中的pos标签对应的相对位置转换为绝对位置,

train_annotated.json通过gen_data.py

init(train_annotated_file_name, rel2id, max_length = 512, is_training = False, suffix='_train')

dev_train.json       dev_train_char.npy       dev_train_ner.npy  dev_train_pos.npy       dev_train_word.npy (3053,512)  dev_train_char.npy (3053,512,16)

 

其余几项和上文描述相同,由人工注释训练数据集产生的dev_train.json在label项中intrain很多都为True,我感觉作者在这里写了一个Bug,逻辑混乱了没改过来

因为是远程监督数据集先执行的init( )函数所以,fact_in_train中会先存储该数据集中存在关系的mentions对,然后人工标注训练数据集再执行init( ),人工标注训练数据集中存在关系的mentions对添加到了fact_in_dev_train中,此时继续调用else,之前在远程监督数据集中出现的关系三元组被标记为True,就能区分开来,哪些是两个训练集都有的三元组,哪些不是两个训练集都有的三元组。至于label['indev_train']=False,就没有判断的必要了(因为本身这个数据集的所有关系都在人工监督数据集中,我判断我自己:))

dev.json通过gen_data.py

init(dev_file_name, rel2id, max_length = 512, is_training = False, suffix='_dev')

dev_dev.json         dev_dev_char.npy        dev_dev_ner.npy   dev_dev_pos.npy       dev_train_word.npy(1000,512)  dev_dev_char.npy (1000,512,16)

验证集生成的数据与dev_train.json格式相同。

test.json通过gen_data.py

init(test_file_name, rel2id, max_length = 512, is_training = False, suffix='_test')

dev_test.json        dev_test_char.npy  dev_test_ner.npy  dev_test_pos.npy       dev_test_word.npy(1000,512)  dev_test_char.npy (1000,512,16)

 

标签项等着我们分类判断,其余几项做了同上文相同的处理。

 

推荐阅读