解读的是苏剑林大神在百度关系抽取比赛中的代码,源代码看这里
数据转换
苏神把原始数据(数据下载地址)进行了转换,只提取出其中的(1)句子文本(2)spo列表,存成json,如下图:
这个json加载进来是一个列表,列表中每一个样本是一个dict,存储了上述的两个字段:text和spo_list,这两个字段是模型训练所需要的。
数据修复
传入的是一个dict,也就是上述json list中的一个样本,例如下图的例子:
首先,用正则抽取出text句子所有出现的书名号中的内容,比如这里something=["男人有泪", "好好先生"]。
然后,依次判断spo_list中的每一个三元组。如果predicate是”所属专辑“则记录此时的主语(肯定是歌曲)到歌曲list中,此时的宾语(肯定是专辑名)到专辑list中。【这里认为”所属专辑“的spo肯定是标注正确的】
这样遍历一遍之后,就得到当前text的歌曲list和专辑list。
再来第二次遍历spo_list,如果有”歌手“”作词“”作曲“任何一个关系,检查它的主语(这三种关系的主语肯定是歌曲名),如果主语是专辑而不是歌曲,则是错误的标注结果,丢弃这个spo。【这里认为这三种关系的主语有可能存在标注错误因此需要修复】
这样一来则做完了repair。
这里predicates是一个dict,它是train_data,保存的是:每种predicate的spo有哪些,predicate是key,value是在train_data中该predicate出现过的所有spo。
比如:"作曲": [["七里香", "作曲", "周杰伦"], ["还在你身边", "作曲", "刘力豪"], ...]
数据生成器
t1: 当前句子(也就是text)它的token_ids
t2: 当前句子的segment_ids(不太明白bert为什么要返回这个,为了标志是不同的句子?)
s1: 用来指示当前句子中(中文字符list),哪些位置id是“主语起始位”,只有0/1的1d array,是则为1,不是则为0。这里是针对所有的subjects。
s2: 同s1,指示哪些位置是“主语终止位”,它也是0/1的
k1: 当前句很可能存在多种关系,因此k1初始存储的是所有“主语起始ids”,后来从中随机选择了一个id
k2: 同上,初始存储的是“主语终止ids”,后来也是随机选择一个id,这和上面s1/s2不同,s1和s2标志的句中所有subject的位置,而k1/k2是标志了随机选择出的1个subject位置。
这里需要注意下,实际上k1及其对应的k2是成对出现的,也就是说,在随机选择一个k1的时候,其正确对应的k2就应该固定了,但是这里作者还是随机选择了k2的位置(有可能选到正确的主语终止id,也可能选到错误的主语终止id)。
如果很幸运,随机选择的k2正好和k1是配对的,也就是:
for j in items.get((k1, k2), []):
o1[j[0]][j[2]] = 1
o2[j[1]-1][j[2]] = 1
items.get可以取到,则会更新该主语的o1,o2和predicate标志位
o1/o2: 只有0/1的2d array,shape=(len(tokens), num_classes),指示的是上面随机选择出的那个subject的所有objects的“宾语起始id”和“宾语终止id”,且其对应的关系predicate
在(七里香,歌手,周杰伦)这个spo中,宾语是周杰伦,宾语起始位标志为红色1,宾语终止位标志位蓝色1,其余位置均为0
T1/T2: [batch_size, sent_max_len_in_batch],存储token_ids和segment_ids
S1/S2: [batchsize, sent_max_len_in_batch],存储每一句的全部subjects的0/1标志位
K1/K2: [batch_size, 1],存储每一句的“天选”subject起始和终止id
O1/O2: [batch_size, sent_max_len_in_batch, num_classes],存储每一句那一个“天选”subject所对应的全部objects和predicates
NOTE: 用0作为padding
模型构建
作者一上来定义了很多Input,这八个输入数据在上面已经讲过了,但是模型中怎么用,看到这里还是不清楚的。
实际上模型部分,需要分成 两段来看。
第一段是真正的定义模型,这部分只用到t1/t2和k1/k2。意思是说,该模型只需要接收一个句子(t1/t2表征)和句中某一个主语的起始idx和终止idx,比如(k1=20,k2=24)表示句中第20~24的字符构成的主语),给定这两个输入,就能输出该主语在句中所有的关系predicates及其宾语。多么完美的设计呀!
看代码比较懵逼,又是Dense又是Average的。画个图比较清楚:
绿色圈出来的部分是输入,也就是t1/t2/k1/k2。蓝色圈出来的是输出,也就是ps1/ps2和po1/po2,分别指示句子中哪些部分是主语(ps1/ps2),哪些部分是带有关系类型的宾语(po1/po2)。
如何得到ps1/ps2呢?这里是将t1/t2也就是token_ids和segment_ids传入bert,bert会对整句的每个字做embedding,用t表示,返回t=[B, MLB, 768],对应每个字都有一个768维的向量表示。然后分别传入两个Dense层(sigmoid激活值在0~1之间表示概率),将[B,MLB,768]->[B,MLB,1]表示每个字是主语起始位(或主语终止位)的概率。这部分和k1/k2没有关系(不存在choice one),因此提取的是整个句子上所有主语。
如何得到po1/po2呢?我们想一想,宾语及关系与什么有关?当然是整句和主语词啦!没错,那么显然为了得到po1/po2我们需要传入t和“天选”主语词k1/k2。作者在这里做了一个embedding提取操作,t是整句所有字符的embeddings,从t中抽取出k1和k2位置字符的embeddings那么我们就知道了主语的表示,代码中主语的表示是kv。得到主语的表示后,结合整句表示t,得到加入主语信息后的整句表示t。同上t作为输入,加入两个Dense层,就得到每个字是宾语起始位(或宾语终止位)的概率,而且给定主语的情况下,一旦宾语确定则spo的关系肯定是确定的,因此在object和predicate是一起指示的,也就是为什么po1/po2的shape=[B,MLB,num_class],表示句中每个字符作为每个关系的宾语起始位(或宾语终止位)的概率有多大(0~1之间)。
第二段是计算损失。这部分才会用到s1/s2和o1/o2。由于在第一部分得到了模型预测的ps1/ps2,它俩对应的ground truth是s1/s2,和预测的po1/po2,它俩对应的ground truth是o1/o2,接下来就可以算loss了。因为激活是sigmoid,所以是二分类binary_crossentropy。还需要注意的是binary_crossentropy(o1, po1)输出结果是[B,MLB,num_class],表示每个字在每一类关系上的loss,之后在第2维度num_class做的是sum而不是取max。为什么?因为给定主语的时候,它在句中可能有多个关系predicate,也就有可能对应多个宾语,如果取max,就只选择一个最有可能的1个宾语关系,这显然和ground truth o1/o2的设置不一样(前面有特别讲过o1/o2是标志了所有宾语而不是只有一个宾语被标为1),因此这里要看模型是不是在所有num_class上都能做出正确决定(如果都能,那么sum的loss也会很小)
Keras Callbacks
keras模型都有fit()或fit_generator(),就是模型定义好后,开始训练的。这俩函数比较重要的地方是callbacks列表,需要传入回调类的实体list。
回调类可以帮助我们在training阶段做任何想做的事情,比如保存最优模型啦,到一定阶段修改learning rate啦,观察统计dev指标啦等等。
我们可以继承keras.callbacks.Callback
来定义自己的callback类,只需重写其中的6个方法即可
on_train_begin
on_train_end
on_epoch_begin
on_epoch_end
on_batch_begin
on_batch_end
你看,这样我们就可以在train的最开始阶段,每个batch的阶段,每个epoch的阶段,分别做一些想做的事情。
自定义回调类Evaluate
作者重写了两个方法: on_batch_begin()和on_epoch_end()
在每个epoch结束后的固定操作就是evaluation:返回F1,precision和recall,然后将F1与best F1进行比较,存储best model。
遍历dev_data的每一个数据(每个都有text和spo_list两个字段),R是模型预测出的spolist,T是真实的ground truth spo_list。因此,A是它们两者的交集,就是TP。B是预测出的spo数量,那么A/B就是预测对的占全部预测的比例,即precision。C表示ground truth spo的个数,因此A/C就是预测对的占ground truth的比例,即recall。
还记录了原始句子text,ground truth spo,预测出的spo,预测相比gt多出来的spo,没有预测出来的spo这五类。
抽取模型预测结果
这是extract_items()函数的功能。在dev和test时都会用到。
首先会输入一个句子(注意只有一句,所以这里batch_size=1),tokenize后得到_t1/_t2=[1, sent_len],再传入subject_model输出主语的起始、终止位置概率_k1/_k2
得到概率后,我想知道哪些位置的概率值比较大,因此用np.where筛选。这个函数的返回值有两个,取第一个值,所以索引0,这样我们就得到当前句子,哪些位置是主语起始位,哪些位置是主语终止位。
_subjects存储当前句子所有符合条件的subject和他们的起始终止位。
如果在当前句子抽取到了合适的主语subjects,则进行后续的抽取objects和predicate,如果连主语都找不到,则该句子就直接返回空[]。
首先,刚才得到的_t1=[1, sent_len],在第0维重复,则_t1=[len(_subjects), sent_len],t2同理。意思是每个主语都要提取它的object和predicate,因此,整句的表示_t1要复制len(_subjects)次,类比于此时batch_size=len(_subjects)
接下来,就是k1和k2,在上面我们知道k1和k2=[batch_size, 1],因此在这里应该是[len(_subjects), 1]。得到的_k1和_k2的shape=[len(_subjects), 1]
这样输入到object_model输出的_o1和_o2=[len(_subjects), sent_len, num_class],表示每个主语它所对应的所有objects的起始终止位置概率。
_oo1, _oo2 = np.where(_o1[i] > 0.5), np.where(_o2[i] > 0.4)
_o1[i]=[sent_len, num_class],数组的每行有num_class个元素 。返回的_oo1是个tuple
In [136]: a = np.random.rand(4,6)
In [137]: a
Out[137]:
array([[0.26501788, 0.42240895, 0.62156192, 0.03454611, 0.80249999,
0.34420373],
[0.50437012, 0.9199876 , 0.44104429, 0.0844013 , 0.04764323,
0.48283732],
[0.45646247, 0.70486308, 0.96938077, 0.81548676, 0.63082475,
0.21882434],
[0.40202736, 0.81626156, 0.24711037, 0.13700467, 0.6880018 ,
0.22962576]])
In [138]: aa = np.where(a>0.5)
In [139]: aa
Out[139]: (array([0, 0, 1, 1, 2, 2, 2, 2, 3, 3]), array([2, 4, 0, 1, 1, 2, 3, 4, 1, 4]))
第一个表示符合条件的元素的所在行,第二个表示符合条件元素的所在列,这两个tuple对应起来就能定位到元素。
那么再看作者的实现,用zip把这两部分打包起来,每次_ooo1和_c1都是(宾语起始位, 关系),_ooo2和_c2都是(宾语终止位, 关系),如果这两者_c1=_c2且_ooo1<=_ooo2,则找到关系为c1,宾语也自然提取出来。每一个_ooo1只要找到终止位就break。
这样,就可以提取出当前句子text_in模型能抽取出的spo_list,返回即可。