知识图谱本体层构建好之后,就需要向本体层内输入相应的数据层。知识图谱数据层一般包含实体、实体属性、实体之间的关系。
上一篇介绍了如何对数据集中的实体进行识别,主要使用的是CRF模型。
关系抽取是在实体抽取的基础上所进行的,也属于知识图谱构建中知识抽取的一部分。
这一篇介绍对数据集中的人物实体关系进行抽取,主要使用的是BiLSTM+Attention模型。文章目录如下:
unknown 0
父母 1
夫妻 2
师生 3
兄弟姐妹 4
合作 5
情侣 6
祖孙 7
好友 8
亲戚 9
同门 10
上下级 11
名称:自建的人物关系数据集,txt格式,30.8M,共7933行文本。截图如下:
关于训练数据集的三点说明:
数据集文档经过模型抽取后,输出被模型所标记过的文档,也就是用模型标记12个关系后的文档。
F1值,一般选取F1值作为评价标准
在这里我们用BiLSTM+Attention模型来完成这次任务,具体如下:
1.BiLSTM概念
(1)RNN的全称是Recurrent Neural Network,循环神经网络RNN。是一种用于处理序列数据的神经网络。相比一般的神经网络来说,他能够处理序列变化的数据。比如某个单词的意思会因为上文提到的内容不同而有不同的含义,RNN就能够很好地解决这类问题。
RNN的方法是,为了预测最后的结果,我先用第一个词预测,当然,只用第一个预测的预测结果肯定不精确,我把这个结果作为特征,跟第二词一起,来预测结果;接着,我用这个新的预测结果结合第三词,来作新的预测;然后重复这个过程;直到最后一个词。这样,如果输入有n个词,那么我们事实上对结果作了n次预测,给出了n个预测序列。整个过程中,模型共享一组参数。因此,RNN降低了模型的参数数目,防止了过拟合,同时,它生来就是为处理序列问题而设计的,因此,特别适合处理序列问题。
(2)LSTM的全称是Long Short-Term Memory,长短期记忆网络。长短期记忆(Long short-term memory, LSTM)是一种特殊的RNN,主要是为了解决长序列训练过程中的梯度消失和梯度爆炸问题。简单来说,就是相比普通的RNN,LSTM能够在更长的序列中有更好的表现。
LSTM对RNN做了改进,使得其能够捕捉更长距离的信息。但是不管是LSTM还是RNN,都有一个问题,它是从左往右推进的,因此后面的词会比前面的词更重要。因此出现了双向LSTM,它从左到右做一次LSTM,然后从右到左做一次LSTM,然后把两次结果组合起来。
(3)BiLSTM全称是Bi-directional Long Short-Term Memory,双向长短期记忆网络。是由前向LSTM与后向LSTM组合而成。两者在自然语言处理任务中都常被用来建模上下文信息。
2.优势
将词的表示组合成句子的表示,可以采用相加的方法,即将所有词的表示进行加和,或者取平均等方法,但是这些方法没有考虑到词语在句子中前后顺序。如句子“我不觉得他好”。“不”字是对后面“好”的否定,即该句子的情感极性是贬义。使用LSTM模型可以更好的捕捉到较长距离的依赖关系。因为LSTM通过训练过程可以学到记忆哪些信息和遗忘哪些信息。
但是利用LSTM对句子进行建模还存在一个问题:无法编码从后到前的信息。在更细粒度的分类时,如对于强程度的褒义、弱程度的褒义、中性、弱程度的贬义、强程度的贬义的五分类任务需要注意情感词、程度词、否定词之间的交互。举一个例子,“这个餐厅脏得不行,没有隔壁好”,这里的“不行”是对“脏”的程度的一种修饰,通过BiLSTM可以更好的捕捉双向的语义依赖。
为什么使用LSTM与BiLSTM?
如果我们想要句子的表示,可以在词的表示基础上组合成句子的表示,那么我们可以采用相加的方法,即将所有词的表示进行加和,或者取平均等方法。但是这些方法很大的问题是没有考虑到词语在句子中前后顺序。而使用LSTM模型可以更好的捕捉到较长距离的依赖关系。因为LSTM通过训练过程可以学到记忆哪些信息和遗忘哪些信息。但是利用LSTM对句子进行建模也存在一个问题:无法编码从后到前的信息。而通过BiLSTM可以更好的捕捉双向的语义依赖。
Attention层:其实就是对双向LSTM的结果使用Attention加权。不同于传统的最后只要hr,而是加权生成结果,模型整体如下:
以上是对BiLSTM+Attention模型的简单会回顾,如果你一点也不了解这个模型,或者都不了解深度学习,也没关系,先看懂下面的实例,再看另三篇文章:《一文读懂神经网络》、《一文读懂循环神经网络RNN》、《一文读懂循环神经网络中的BILSTM》即可。
(1)对文本按行进行处理,处理前XX行
(2)每行得到3个向量(该行所有字的字向量、该行所有字的位置向量1、该行所有字的位置向量2)和1个关系值:
(3)将所有行的3个向量集合和1个关系集合,4样东西,作为模型训练集输入
(4)按上述(1)(2)(3)步,处理文本后XX行,得出模型测试集
第1步,打开relation2id.txt文,将预定义的12种关系,放入字典变量relation2id中,并给每个关系key一个对应的数字value,关键代码如下:
with codecs.open('relation2id.txt','r','utf-8') as input_data:
#循环读取input_data变量,放入字典relation2id{}中
for line in input_data.readlines():
#line.split() ,表示以空格为分隔符进行字符串分割,包含 \n
relation2id[line.split()[0]] = int(line.split()[1])
input_data.close()
查询,得到字典变量relation2id如下:
relation2id[]={'unknown': 0, '父母': 1, '夫妻': 2, '师生': 3, '兄弟姐妹': 4, '合作': 5, '情侣': 6, '祖孙': 7, '好友': 8}
第2步,打开训练文本train.txt,对文本按行全部循环读取,得到每一行文本。这里的处理设置了一个阈值,处理每种关系的次数限制1500次,当这个关系出现1500次以后就不处理了(这部分文本行为了留给后续的测试集用,后面会说到),关键代码如下:
# 将train.txt中的数据以行为单位输入到变量tfc中,每一行是一个列表
with codecs.open('train.txt','r','utf-8') as tfc:
# 对tfc进行按行循环处理,
for lines in tfc: #lines ='朱时茂 陈佩斯 合作 《水与火的缠绵》《低头不见抬头见》《天剑群侠》小品陈佩斯与朱时茂1984年《吃面条》合作者:陈佩斯聽1985年《拍电影》
#对每一行,按空格进行字符串分割
line = lines.split()
以第一行为例,查询,结果如下:
line=<class 'list'>: ['朱时茂', '陈佩斯', '合作', '《水与火的缠绵》《低头不见抬头见》《天剑群侠》小品陈佩斯与朱时茂1984年《吃面条》合作者:陈佩斯聽1985年《拍电影》合']
第3步,对每一行文本处理,得到两个人物名字在该行语句中的位置,代码如下:
#index()方法检测字符串中是否包含子字符串,line[3]是那句话,line[0]是朱时茂
index1 = line[3].index(line[0])
index2 = line[3].index(line[1])
以第一行为例,查询,得到结果如下
index1=29,表示朱时茂的朱是这句话里的29个字
index2=25,表示陈佩斯的陈是这句话里的第25个字
第4步,对每一行文本进行处理,得到该行中每一个字的列表,以及每一个字和两个人物名字之间的距离(向量),共三个列表。
关键代码如下:
sentence = []
position1 = [] # position1=[-29]
position2 = [] #position2=[-25]
#enumerate() 函数用于将一个可遍历的数据对象(如列表、元组或字符串)组合为一个索引序列,同时列出数据和数据下标,一般用在 for 循环当中
#当i=1 word=《 当i=2 word=水 依次循环
for i,word in enumerate(line[3]): #i=1 word=《
#.append()在列表末尾添加新的对象
sentence.append(word) #
position1.append(i-index1) #循环一次后
position2.append(i-index2) #循环一次后
i+=1
以第一行为例,查询,得到的三个列表结果如下:
<class 'list'>: ['《', '水', '与', '火', '的', '缠', '绵', '》', '《', '低', '头', '不', '见', '抬', '头', '见', '》', '《', '天', '剑', '群', '侠', '》', '小', '品', '陈', '佩', '斯', '与', '朱', '时', '茂', '1', '9', '8', '4', '年', '《', '吃', '面', '条', '》', '合', '作', '者', ':', '陈', '佩', '斯', '聽', '1', '9', '8', '5', '年', '《', '拍', '电', '影', '》', '合']
<class 'list'>: [-29, -28, -27, -26, -25, -24, -23, -22, -21, -20, -19, -18, -17, -16, -15, -14, -13, -12, -11, -10, -9, -8, -7, -6, -5, -4, -3, -2, -1, 0, 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]
<class 'list'>: [-25, -24, -23, -22, -21, -20, -19, -18, -17, -16, -15, -14, -13, -12, -11, -10, -9, -8, -7, -6, -5, -4, -3, -2, -1, 0, 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]
这三个列表其实就是一个文字向量,一个距人物1的位置向量,一个距人物2的距离向量
第5步,每行循环后,将该行得到的三个列表(向量)、标注的关系,分别放入一个双向队列中,所有行循环完后,最终得到四个双向队列:1个汉字向量集合,2个位置向量集合,1个关系向量集合
datas.append(sentence)#放所有的文字向量
positionE1.append(position1) #放所有的位置向量1
positionE2.append(position2) # 放所有的位置向量2
labels.append(relation2id[line[2]])#
查询datas,部分结果如下:
#data双向队列deque里每个元素就是一行文本的后半部分句子sentence ,总共18000行,1500*12种关系,循环后如下
[['《', '水', '与', '火', '的', '缠', '绵', '》', '《', '低', '头', '不', '见', '抬', '头', '见', '》', '《', '天', '剑', '群',# '侠', '》', '小', '品', '陈', '佩', '斯', '与', '朱', '时', '茂', '1', '9', '8', '4', '年', '《', '吃', '面', '条', '》', '合', '作', '者', ':', '陈', '佩', '斯', '聽', '1', '9', '8', '5', '年', '《', '拍', '电', '影', '》', '合'],
#下面第二行的
['卢', '恬', ..此处省略N行.]...]
第6步,将得到的四个双向队列:1个汉字向量集合,2个位置向量集合,1个关系向量集合,放入一个二维表结构的DataFrame中,索引由0开始。得到二维表DataFrame:第一列列名为positionE1,内容为位置向量集合positionE1,第二列列名为positionE2,内容为位置向量集合positionE2,第三列列名为tags,内容为关系向量集合,第四列列名为words,内容为汉字向量集合。
df_data = pd.DataFrame({'words': datas, 'tags': labels,'positionE1':positionE1,'positionE2':positionE2}, index=range(len(datas)))
查询,部分结果如下:
positionE1 \
0 [-29, -28, -27, -26, -25, -24, -23, -22, -21, ...
1 [-29, -28, -27, -26, -25, -24, -23, -22, -21, ...
2 [-28, -27, -26, -25, -24, -23, -22, -21, -20, ...
3 [0, 1, 2,
tags
0 5
1 0
2 2
3 2
...
17997 7
17998 7
17999 7
words
0 [《, 水, 与, 火, 的, 缠, 绵, 》, 《, 低, 头, 不, 见, 抬, 头, ...
1 [卢, 恬, 儿, 是, 现, 任, 香, 港, 南, 华, 体, 育, 会, 主, 席, ...
2 [场, 照, 片, 事, 后, 将, 发, 给, 媒, 体, ,, 避, 免, 采, 访, ...
3 [李, 敖, 后, 来, 也, 认, 为, ,, “, 任, 何, 当, 过, 王, 尚, ...
4 [改, 写, 2, 3, 年, 历, 史, 2, 0, 1, 0, 年, 1, 0, 月, ...
5 [-, 简, 介, 梁, 左, 与, 丈, 夫, 英, 达, 梁, 欢, ,, 女, ,,
...
这时里面的words列,里面是汉字的向量集合,BiLSTM+Attention模型无法处理,需要转换为数字
第7步,处理datas,得到word2id字典,说白了就是文本里所有字和其对应的使用频率名次,具体如下:
(1)将上述汉字向量集合datas,处理成一个整体的大向量,也就是去除里面的中括号,例如:例如:Given [[‘你’,‘好’],[‘太’,‘阳’]], return [‘你’,‘好’,‘太’,‘阳’],关键代码如下:
def flatten(x):
result = []
for el in x:
if isinstance(x, collections.Iterable) and not isinstance(el, str):
result.extend(flatten(el))
else:
result.append(el)
return result
(2)将上述汉字整体的大向量(在双向队列中),放入pandas的Series数组中,这样则有了索引
sr_allwords = pd.Series(all_words)
查询sr_allwords ,结果如下:
0 《
1 水
2 与
3 火
...
(3)对上述Series数组里面的存的每个汉字进行统计计数并且排序,放入pandas的Series数组中(汉字作为索引,该字统计结果作为值)
sr_allwords = sr_allwords.value_counts()
查询sr_allwords ,结果如下
, 32968
、 21748
的 15069
。 8837
...
(4)将上述Series数组中的索引列,也就是按统计总数排序后的汉字,放入set_words对象中
set_words = sr_allwords.index
查询set_words,结果如下
([',', '、', '的', '。', '1', '年', '人', '是', '0', '一',...
(5)得到一个数组,索引是按按统计总数排序后的汉字,对应的值是从1开始递增,相当于排名
set_ids = range(1, len(set_words)+1)#range(1, 4407)
#生成一个1到4407的列表
word2id = pd.Series(set_ids, index=set_words)
查询,结果如下:
, 1
、 2
的 3
。 4
1 5
(6)把上述数组的序列和值互换,排名变为索引,值变为统计总数排序后的汉字
id2word = pd.Series(set_words, index=set_ids)
查询,结果如下
1 ,
2 、
3 的
4 。
5 1
6 年
7 人
(7)给上述两个数组最后加入两个末尾行 blank、unknow,用于查询不到的情况,因为建立字典最初的数据源datas并不是全部循环,刚才说了有阈值,所以字典里也不是所有的字,避有查不到的情况,或补全的情况等,末端加unknow,作为备用。
word2id["BLANK"]=len(word2id)+1 #len(word2id)+1=4407 在数组最后一行加入 blank 4407,倒数第二行为 摹 4406
word2id["UNKNOW"]=len(word2id)+1 #word2id['UNKNOW']: 4408 在数组最后一行加入 UNKNOW 4408
id2word[len(id2word)+1]="BLANK" #在数组最后一行加入 4407 blank,倒数第二行为 4406 摹
id2word[len(id2word)+1]="UNKNOW" #在数组最后一行加入 4408 unknown
到这里,word2id字典就建立好了。
第8步,将二维表结构的DataFrame中的words列,通过查询上述建立的word2id字典,将每个汉字向量里的每个字转换为出现频率的排序名次数字。 并且,设定阈值50,若这句话里的字多于50个则截断,少于50个则补全为50。(由于神经网络训练中一旦设定参数则必须输入固定长度数据,所以此处进行统一)
关键代码如下:
for i in words:
if i in word2id:
ids.append(word2id[i])
else:
ids.append(word2id["UNKNOW"])
max_len=50
if len(ids) >= max_len:#循环后,若ids长度大于等于50,返回ids的前50个元素
return ids[:max_len] #则返回ids[:50],也就是ids的前50个元素
#循环后,若ids长度小于50
ids.extend([word2id["BLANK"]]*(max_len-len(ids)))
转换前
0 [《, 水, 与, 火, 的, 缠, 绵, 》, 《, 低, 头, 不, 见, 抬, 头, ...
1 [卢, 恬, 儿, 是, 现, 任, 香, 港, 南, 华, 体, 育, 会, 主, 席, ...
2 [场, 照, 片, 事, 后, 将, 发, 给, 媒, 体, ,, 避, 免, 采, 访, ...
3 [李, 敖, 后, 来, 也, 认, 为, ,, “, 任, 何, 当, 过, 王, 尚, ...
4 [改, 写, 2, 3, 年, 历, 史, 2, 0, 1, 0, 年, 1, 0, 月, ...
5 [-, 简, 介, 梁, 左, 与, 丈, 夫, 英, 达, 梁, 欢, ,, 女, ,, ...
6 [演, 义, 中, 诸, 葛, 亮, 先, 用, 反, 间, 计, 使, 魏, 免, 了, ...
7 [张, 竹, 君, 立, 即, 出, 面, 组, 成, 开, 往, 武, 汉, 战, 地, ...
转换后
0 [20, 335, 19, 858, 3, 2590, 2771, 22, 20, 1203...
1 [909, 2104, 63, 8, 265, 79, 155, 339, 256, 76,...
2 [259, 295, 350, 133, 35, 163, 236, 332, 607, 3...
3 [28, 1394, 35, 96, 128, 366, 18, 1, 42, 79, 15...
4 [775, 618, 15, 81, 6, 200, 330, 15, 9, 5, 9, 6...
5 [17, 409, 278, 172, 478, 19, 728, 101, 86, 359...
6 [34, 297, 24, 419, 395, 376, 179, 382, 605, 31...
7 [31, 972, 178, 212, 515, 40, 390, 516, 44, 147...
第9步,将positionE1、positionE2两列中的向量元素里的数字,映射到0-81之间,并且控制每个向量的长度为50,与字向量长度一致。
关键代码如下:
#若num小于-40返回0,大于40返回80,之间加40
def pos(num):
if num < -40:
return 0
if num >= -40 and num <= 40:
return num + 40
if num > 40:
return 80
if len(words) >= max_len: # 如果words长度大于等于50
return words[:max_len] # 返回words的前50个元素
# 如果不大于50
words.extend([81] * (max_len - len(words))) # [81]]*(50-48)=2
查询,结果如下:转换前
positionE1 \
0 [-29, -28, -27, -26, -25, -24, -23, -22, -21, ...
1 [-29, -28, -27, -26, -25, -24, -23, -22, -21, ...
2 [-28, -27, -26, -25, -24, -23, -22, -21, -20, ...
3 [0, 1, 2,
查询,结果如下,转换后
positionE1 \
0 [11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 2...
1 [11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 2...
2 [12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 2...
3 [40, 41, 42, 43, 44, 45, 46, 47, 48, 49, 50, 5...
...
可以看出,只有tag这一列没有转换,也就是标注的关系这一列。
第10步,将处理后得到的这4个向量集(字向量、两个位置向量、关系向量),和word2id字典、id2word字典、relation2id字典,7个变量写入训练PKL文本中
代码如下
with open('../people_relation_train.pkl', 'wb') as outp:
pickle.dump(datas, outp)
pickle.dump(labels, outp)
pickle.dump(positionE1, outp)
pickle.dump(positionE2, outp)
pickle.dump(word2id, outp)#序列化对象,将对象word2id保存到文件outp中去
pickle.dump(id2word, outp)
pickle.dump(relation2id, outp)
print ('** Finished saving the traindata.')
到此为止,我们已经获取了处理好的模型的训练集向量数据了
第11步,按上述方法获取模型的测试集向量数据,保障数据源不同。
具体做法是,将刚才的2、3、4、5、6、8、9、10重新写一遍(第7步建立word2id字典不需要写了直接用上面建好的),只不过调整第2步的阈值,使得处理数据源文本中不同的行。
变化如下:
之前的第2步
行循环开始:
count = [0,0,0,0,0,0,0,0,0,0,0,0]
if count[relation2id[line[2]]] <=1500:
开始处理
每循环一行,将该行关系对应的count[relation2id[line[2]]]加1
这里的第2步
行循环开始:
count = [0,0,0,0,0,0,0,0,0,0,0,0]
if count[relation2id[line[2]]] >1500 and count[relation2id[line[2]]]<=1800:
开始处理
每循环一行,将该行关系对应的count[relation2id[line[2]]]加1
可以看出,每拿到一行提取出关系
这样处理到的行就肯定和之前的不同。也保证了测试集和训练集的不同。
按照这种方法,将处理后得到的这4个向量集(少了word2id字典、id2word字典、relation2id字典),写入测试PKL文本中
with open('../people_relation_test.pkl', 'wb') as outp:
pickle.dump(datas, outp)
pickle.dump(labels, outp)
pickle.dump(positionE1, outp)
pickle.dump(positionE2, outp)
print ('** Finished saving the testdata.')
至此,完成了数据的预处理。得到了输入模型的向量。
第1步,打开刚才生成的训练集文件people_relation_train.pkl、测试文件people_relation_train.pkl,加载内容到对应的数组变量里
with open('./data/people_relation_train.pkl', 'rb') as inp:
word2id = pickle.load(inp)
id2word = pickle.load(inp)
relation2id = pickle.load(inp)
train = pickle.load(inp)
labels = pickle.load(inp)
position1 = pickle.load(inp)
position2 = pickle.load(inp)
with open('./data/people_relation_test.pkl', 'rb') as inp:
test = pickle.load(inp)
labels_t = pickle.load(inp)
position1_t = pickle.load(inp)
position2_t = pickle.load(inp)
查询test,结果如下
[[ 126 2 293 ... 4407 4407 4407]
[ 805 2748 1 ... 232 76 68]
[ 40 385 20 ... 155 339 440]
...
[ 254 48 80 ... 4407 4407 4407]
[ 5 16 77 ... 168 4 4407]
[ 680 980 1 ... 4407 4407 4407]]
第2步,创建一个字典变量config,放入配置信息:word2id的长度4409,以及其他直接给定的参数值。(其实这个才是模型配置的关键)
查询,结果如下
config:
{
'EMBEDDING_SIZE': 4409, #嵌入字典(字向量)的大小(也就是字典中有多少个字)
'EMBEDDING_DIM': 100, #嵌入向量的维度(也就是每个字用多少维度向量表示)
'POS_SIZE': 82, #嵌入字典(位置向量)的大小(也就是字典中有多少个字)
'POS_DIM': 25,#嵌入向量的维度(也就是每个字用多少维度向量表示)‘
'HIDDEN_DIM': 200, #隐藏层特征维度
'TAG_SIZE': 12, #标签的大小
'BATCH': 128, #批量大小,说白了在这里也是一次训练几个句子
'pretrained': False #是否之前训练过
}
上述只是初步准备工作,下面介绍如果用pytorch实现模型训练,用的也是基于pytorch中的基本流程(主要是数据输入模型的流程):
第3步,基于第1步中获取的训练向量数组,创建训练张量和测试张量
基于张量,创建了训练和测试的TensorDataset、DataLoader(DataLoader是PyTorch中数据读取的一个重要接口,只要是用PyTorch来训练模型基本都会用到该接口),核心代码如下:
创建4个训练用的长张量LongTensor、TensorDataset和DataLoader
# 以下是将处理过的数据写回到变量中,变量太大读不出来
train = torch.LongTensor(train[:len(train) - len(train) % BATCH])
position1 = torch.LongTensor(position1[:len(train) - len(train) % BATCH])
position2 = torch.LongTensor(position2[:len(train) - len(train) % BATCH])
labels = torch.LongTensor(labels[:len(train) - len(train) % BATCH])
#创建一个 Dataset 对象
train_datasets = D.TensorDataset(train, position1, position2, labels)
# 创建一个 DataLoader 对象
train_dataloader = D.DataLoader(train_datasets, BATCH, True, num_workers=0)
其中DataLoader是数据进模型前的最终形式,非常重要,详细可以参考这篇文章。
里面参数含义如下:
train_datasets:表示传入的数据集
BATCH:批量的意思,上述已经设置batch=128,指每个批量有多少样本,也是后续在模型中一次处理的样本数
num_workers=0:决定有几个进程来处理data loading。0意味着只有一个主进程,所有的数据都会被load进主进程。(默认为0)
ture:这里个人不完全确定,猜测表示shuffle(bool, optional): 在每个epoch开始的时候,对数据进行重新排序。
创建4个测试用的长张量、TensorDataset和DataLoader
test = torch.LongTensor(test[:len(test) - len(test) % BATCH])
position1_t = torch.LongTensor(position1_t[:len(test) - len(test) % BATCH])
position2_t = torch.LongTensor(position2_t[:len(test) - len(test) % BATCH])
labels_t = torch.LongTensor(labels_t[:len(test) - len(test) % BATCH])
#创建一个 Dataset 对象
test_datasets = D.TensorDataset(test, position1_t, position2_t, labels_t)
# 创建一个 DataLoader 对象
test_dataloader = D.DataLoader(test_datasets, BATCH, True, num_workers=0)
第4步,将配置信息字典变量config和embedding_pre传入BiLSTM_ATT.py中,返回BiLSTM_ATT的model,代码如下:
model = BiLSTM_ATT(config, embedding_pre)
这一步其实初始化了模型,具体调用BiLSTM_ATT.py中的类的构造函数创建对象,其实主要是对成员变量赋初始值
#输入:三个参数如下
#self:Unable to get repr for
#config:{'EMBEDDING_SIZE': 4409, 'EMBEDDING_DIM': 100, 'POS_SIZE': 82, 'POS_DIM': 25, 'HIDDEN_DIM': 200, 'TAG_SIZE': 12, 'BATCH': 128, 'pretrained': False}
#embedding_pre:[]
#输出:无,主要给公共变量赋值。
def __init__(self,config,embedding_pre):# embedding_pre参数没用到
super(BiLSTM_ATT,self).__init__()#执行后,self:BiLSTM_ATT()
# 这是对继承自父类的属性进行初始化。而且是用父类的初始化方法来初始化继承的属性。
# 子类继承了父类的所有属性和方法,父类属性自然会用父类方法来进行初始化。
# 如果初始化的逻辑与父类的不同,不使用父类的方法,自己重新初始化也是可以的。
#父类是:nn.Module 也就是nn.LSTM继承自nn.RNNBase
self.batch = config['BATCH']#执行后,batch=128
self.embedding_size = config['EMBEDDING_SIZE']#执行后,embedding_size=4409
self.embedding_dim = config['EMBEDDING_DIM']#执行后,
self.hidden_dim = config['HIDDEN_DIM']#执行后,hidden_dim=200
self.tag_size = config['TAG_SIZE']#执行后,tag_size=12
self.pos_size = config['POS_SIZE']#执行后,pos_size=82
self.pos_dim = config['POS_DIM']#执行后,pos_dim=25
self.pretrained = config['pretrained']#执行后,pretrained=False
if self.pretrained:#如果之前训练过,也就是pretrained=true
#self.word_embeds.weight.data.copy_(torch.from_numpy(embedding_pre))
self.word_embeds = nn.Embedding.from_pretrained(torch.FloatTensor(embedding_pre),freeze=False)
else:#否则
#执行下面语句前,nn=
self.word_embeds = nn.Embedding(self.embedding_size,self.embedding_dim)
#word_embeds:Embedding(4409, 100)
self.pos1_embeds = nn.Embedding(self.pos_size,self.pos_dim)
#pos1_embeds:Embedding(82, 25)
self.pos2_embeds = nn.Embedding(self.pos_size,self.pos_dim)
#pos2_embeds:Embedding(82, 25)
self.relation_embeds = nn.Embedding(self.tag_size,self.hidden_dim)
#relation_embeds:Embedding(12, 200)
#关键步骤:对lstm进行了初始化
self.lstm = nn.LSTM(input_size=self.embedding_dim+self.pos_dim*2,hidden_size=self.hidden_dim//2,num_layers=1, bidirectional=True)
#debug结果:lstm=LSTM(150, 100, bidirectional=True)
# 1.input_size :输入数据的大小,也就是前面例子中每个单词向量的长度?
input_sizeTest = self.embedding_dim + self.pos_dim * 2
#debug结果:input_sizeTest=150 为int整形变量,其中embedding_dim=100,pos_dim=25
#2.hidden_size :隐藏层的大小(即隐藏层节点数量),输出向量的维度等于隐藏节点数?
hidden_sizeTest=self.hidden_dim//2
# debug结果:hidden_sizeTest=100 其中,HIDDEN_DIM=200
#3.num_layers – 堆叠LSTM的层数,默认等于1。具体意义见后面的图。
#4.bidirectional 如果是true则变成bilstm,默认为false
self.hidden2tag = nn.Linear(self.hidden_dim,self.tag_size)
#hidden2tag:Linear(in_features=200, out_features=12, bias=True)
self.dropout_emb=nn.Dropout(p=0.5)
#dropout_emb:Dropout(p=0.5)
self.dropout_lstm=nn.Dropout(p=0.5)
#dropout_lstm:Dropout(p=0.5)
self.dropout_att=nn.Dropout(p=0.5)
#dropout_att:Dropout(p=0.5)
self.hidden = self.init_hidden()
# self.hidden运行后值如下
# tensor([[[1.8814e+00, -7.8799e-01, 3.1843e-01, ..., 1.5938e-01,
# -1.8232e+00, -9.2752e-01],
# ...,
# [9.5565e-01, -7.9343e-01, -2.2698e-01, ..., -1.7630e+00,
# -1.0395e+00, 3.5405e-01]],
#
# ...,
# [-1.8917e+00, -1.3558e+00, -2.2992e-01, ..., -2.5419e+00,
# -1.6056e+00, -3.8730e-01]]])
self.att_weight = nn.Parameter(torch.randn(self.batch,1,self.hidden_dim))
# att_weight运行后值如下
# Parameter
# containing:
# tensor([[[-1.3792e+00, 1.5979e+00, 3.1023e-01, ..., -1.6458e+00,
# -1.8570e-01, -7.5595e-01]],
#
# [[-4.9305e-01, 9.1801e-01, 4.7714e-02, ..., -3.5667e-01,
# -1.4952e-02, 9.3234e-01]],
#
# [[7.8945e-01, 2.8244e-01, 2.3381e+00, ..., 5.2980e-01,
# 1.8889e+00, -1.9045e-01]],
#
# ...,
self.relation_bias = nn.Parameter(torch.randn(self.batch,self.tag_size,1))
可以看出,output保存了最后一层,每个time step的输出h,如果是双向LSTM,每个time step的输出h = [h正向, h逆向] (同一个time step的正向和逆向的h连接起来)。
第5步,指定一个优化器,这里用的是Adam优化器(还有很多别的)
调用pytorch中的optim.Adam接口,对model进行优化,返回一个optmizer,
learning_rate = 0.0005 #学习率
optimizer = optim.Adam(model.parameters(), lr=learning_rate, weight_decay=1e-5)
#参数含义:要优化什么、学习率是多少
第6步,定义一个损失函数,一般分类任务就是交叉熵,回归任务一般就是mse损失函数,就是算一下预测值和真实值之间的均方误差
调用nn.CrossEntropyLoss()损失函数,返回一个criterion
criterion = nn.CrossEntropyLoss(size_average=True)
开始10次大循环(模型训练、模型测试、结果评测)
EPOCHS = 10 # 循环次数,这个参数可以自己设置
for epoch in range(EPOCHS): # 第一次epoch=0
每次大循环,在训练集上模型训练一次,再在测试集上模型测试一次,得出评测结果,方法如下:
(1)在训练集上模型训练一次
对train_dataloader所有行进行循环,对每一行:
所行循环完后,model进化了很多。代码及注释如下:
for sentence, pos1, pos2, tag in train_dataloader:
# Variable像一个装鸡蛋的篮子,里面的鸡蛋就是torch的Tensor了,鸡蛋数会不断发生变化
sentence = Variable(sentence)
pos1 = Variable(pos1)
pos2 = Variable(pos2)
tags = Variable(tag)
#1.前向传播:将train_dataloader中sentence、pos1、pos2转换为Variable类型后输入model中,得到预测值y
y = model(sentence, pos1, pos2)
#2.将y和标签tags(转换为Variable的),输入到损失函数中,得到损失值loss
loss = criterion(y, tags)#criterion,这是损失函数用于多分类问题
#上述两步为神经网络的前向传播
#3.梯度清零
optimizer.zero_grad()
#由于pytorch的动态计算图,当我们使用后续loss.backward()和opimizer.step()进行梯度下降更新参数的时候,
# 梯度并不会自动清零。并且这两个操作是独立操作。
#如若不清理,下面反向传播backward()的时候就会累加梯度
#4.反向传播
loss.backward()
#反向传播求解梯度
#当通过前向传播得到由任意一组随机参数W和b计算出的网络预测结果后,
# 我们可以利用损失函数相对于每个参数的梯度来对他们进行修正,使得损失越来越小,这就是反向传播
# 事实上神经网络的训练就是这样一个不停的前向 - 反向传播的过程,
# 直到网络的预测能力达到我们的预期。
#5.更新权重参数
optimizer.step()
关于批次:每次循环并不是只取了一行文本中的四个向量和一个关系,而是一次128行文本对应的四个向量和一个关系,首次循环debug时,部分结果展示如下,可以看出y值非常不靠谱
sentence = Variable(sentence)
#tensor
# ([[ 531, 29, 316, ..., 3, 444, 908],
# [ 110, 1053, 460, ..., 4407, 4407, 4407],
# [ 19, 31, 1232, ..., 4407, 4407, 4407],
# ...,
# [ 1579, 2622, 1100, ..., 4407, 4407, 4407],
# [ 113, 48, 87, ..., 4407, 4407, 4407],
# [ 478, 264, 920, ..., 4407, 4407, 4407]])
#torch.Size([128, 50]) 有128个向量,每个长度50
tags = Variable(tag)
#tensor
# ([ 8, 3, 11, 5, 9, 8, 10, 6, 4, 5, 6, 0,
# 7, 8, 4, 7, 10, 6, 10, 8, 2, 7, 9, 10,
# 9, 6, 1, 5, 5, 5, 4, 2, 6, 9, 7, 5,
# 10, 0, 5, 0, 11, 6, 2, 5, 7, 7, 10, 3,
# 10, 11, 1, 3, 0, 1, 3, 10, 11, 2, 11, 4,
# 6, 4, 8, 3, 0, 0, 10, 5, 4, 2, 3, 9,
# 8, 7, 0, 11, 5, 4, 5, 4, 1, 8, 2, 3,
# 3, 2, 1, 0, 10, 6, 3, 8, 6, 0, 3, 11,
# 8, 2, 1, 11, 0, 0, 10, 11, 9, 10, 1, 7,
# 10, 9, 4, 2, 3, 8, 0, 7, 10, 1, 5, 4,
# 7, 7, 3, 3, 9, 8, 4, 11])
#torch.Size([128])
y = model(sentence, pos1, pos2)
#tensor
# ([[ 1.6851e-02, 2.2230e-03, 2.5432e-03, ..., 1.1103e-01,
# 2.5689e-03, 8.3816e-02],
# [ 4.0907e-03, 3.9208e-01, 1.5956e-01, ..., 1.1556e-02,
# 2.2479e-03, 3.0899e-04],
# [ 6.0303e-04, 1.3746e-03, 5.3099e-07, ..., 1.7214e-05,
# 1.2239e-02, 3.5070e-03],
# ...,
# [ 2.5958e-03, 5.1988e-01, 4.6925e-03, ..., 1.2565e-04,
# 6.4943e-02, 3.9626e-01],
# [ 1.0320e-01, 1.7339e-01, 5.6059e-05, ..., 2.5072e-04,
# 7.2548e-04, 1.1548e-02],
# [ 3.9980e-01, 6.8521e-02, 2.0005e-07, ..., 1.7625e-02,
# 4.2092e-02, 5.8418e-03]])
#torch.Size([128, 12])
关于前向传播:
模型前向传播时的调用函数如下( 也就是进行 y = model(sentence, pos1, pos2)时,调用这个函数)
def forward(self,sentence,pos1,pos2):
#计算隐层
self.hidden = torch.randn(2, self.batch, self.hidden_dim // 2),torch.randn(2, self.batch, self.hidden_dim // 2)
#对输入的三个向量进行拼接,拼接成最终输入的矩阵
# 拼接数据,可以根据dim进行调整:
# dim = 0: 代表基于行拼接
# dim = 1: 代表基于列拼接
# dim = 2: 代表基于通道拼接,此处采取2,也就是拼接成:很多行,1列,150通道的输入
embeds = torch.cat((self.word_embeds(sentence),self.pos1_embeds(pos1),self.pos2_embeds(pos2)),2)
#交换embeds中的维度
embeds = torch.transpose(embeds,0,1)
lstm_out, self.hidden = self.lstm(embeds, self.hidden)
lstm_out = torch.transpose(lstm_out,0,1)
lstm_out = torch.transpose(lstm_out,1,2)
lstm_out = self.dropout_lstm(lstm_out)
att_out = F.tanh(self.attention(lstm_out))
#att_out = self.dropout_att(att_out)
relation = torch.tensor([i for i in range(self.tag_size)],dtype = torch.long).repeat(self.batch, 1)
relation = self.relation_embeds(relation)
res = torch.add(torch.bmm(relation,att_out),self.relation_bias)
res = F.softmax(res,1)
return res.view(self.batch,-1)
上述的数据拼接,具体查看此文章
关于梯度清零:第三步中的梯度简单来说是多元函数在某点的偏导,pytorch中不会自动梯度清零,所以要手工清,具体什么是梯度?可以查看这篇文章
关于反向传播:
举个例子如下图,左侧为正向传播,那么右侧求导的过程为反向传播
(2)在测试集上模型测试一次(这时for sentence, pos1, pos2, tag in train_dataloader已经结束了,也就是在训练集上已经小循环一次了)
对test_dataloader所有行进行循环,对每一行:
for sentence, pos1, pos2, tag in test_dataloader:
sentence = Variable(sentence)
pos1 = Variable(pos1)
pos2 = Variable(pos2)
y = model(sentence, pos1, pos2)#测试集三个向量Variable输入model得到y,不在进行反向传播等步骤
y = np.argmax(y.data.numpy(), axis=1)#得到标注值y
for y1, y2 in zip(y, tag):#开始评测
主要计算该关系预测了多少次,实际多数次,预测对了多少次。
count_predict[y1] += 1#预测列表中,该关系数量加一
count_total[y2] += 1 #实际列表中,该关系数量加一
if y1 == y2: #如果判断对了,正确次数列表中,该关系数量加一
count_right[y1] += 1
这时的小循环中,每次也是处理128行,debug该小循环第一次时的部分结果如下,可以看出,经过上述训练集训练,y值已经比较靠谱了
sentence = Variable(sentence)
#tensor
# ([[ 2057, 661, 1, ..., 4407, 4407, 4407],
# [ 755, 805, 2282, ..., 4407, 4407, 4407],
# [ 131, 632, 17, ..., 4407, 4407, 4407],
# ...,
# [ 51, 60, 381, ..., 4407, 4407, 4407],
# [ 12, 1261, 635, ..., 4407, 4407, 4407],
# [ 2, 28, 155, ..., 30, 5, 16]])
#torch.Size([128, 50])
y = model(sentence, pos1, pos2)#测试集三个向量Variable输入model得到y,不在进行反向传播等步骤
#[ 0 9 2 1 10 0 5 8 4 0 6 10 2 4 2 11 7 3 2 10 10 1 0 10
# 10 1 9 0 10 5 10 7 11 0 0 0 11 10 7 9 1 1 1 11 4 3 3 8
# 8 2 3 0 3 3 10 11 3 3 11 10 7 11 10 9 11 7 11 11 7 1 11 4
# 0 3 1 5 9 7 11 6 6 1 5 7
# size=128
(3)计算评测指标
评测指标计算原理如下:
单个关系精确率Precision=该关系预测正确的次数/该关系预测总次数
单个关系召回率recall=该关系预测正确的次数/该关系实际的总数量
平均精确率precision = sum(precision) / len(relation2id)
平均召回率recall = sum(recall) / len(relation2id)
平均F1=(2 * precision * recall) / (precision + recall)
F1是一个综合指标,是Precision和Recall的调和平均数,一般情况下,Precision和Recall是两个互斥指标,即Recall越大,Precision往往越小,所以需要通过F1测度来综合进行评估,F1越大,分类器效果越好。
代码如下
for i in range(len(count_predict)):#预测关系列表长度循环,其实就是十二次
if count_predict[i] != 0:#如果第i种关系的预测的次数不为零
precision[i] = float(count_right[i]) / count_predict[i]
#精确率Precision=该关系预测正确的次数/该关系预测总次数
if count_total[i] != 0:#如果第i种关系的实际标注次数不为零
recall[i] = float(count_right[i]) / count_total[i]
#召回率=该关系预测正确的次数/该关系该关系实际的数量
precision = sum(precision) / len(relation2id)
recall = sum(recall) / len(relation2id)
print("准确率:", precision)
print("召回率:", recall)
print("f:", (2 * precision * recall) / (precision + recall))
运行结果如下:
可以看到,每一次循环f1就会提高,10次后达到0.33170570738290694,若是继续再来10次肯定还会提高。
这是第1次运行
准确率: 0.09732348095086467
召回率: 0.10572769953051642
f1: 0.10135166638127953
./model/model_epoch0.pkl has been saved
epoch: 1
这是第2次运行
准确率: 0.12754717366473256
召回率: 0.1339397496087637
f1: 0.13066532192198033
epoch: 2
这是第3次运行
准确率: 0.14299586874126904
召回率: 0.15960485133020344
f1: 0.1508445476659336
epoch: 3
这是第4次运行
准确率: 0.1781159290898783
召回率: 0.19576291079812205
f1: 0.18652295352468587
epoch: 4
这是第5次运行
准确率: 0.19483159165835137
召回率: 0.20969483568075117
f1: 0.20199015855134486
epoch: 5
这是第6次运行
准确率: 0.2279694419920687
召回率: 0.25456181533646327
f1: 0.24053287372938081
epoch: 6
这是第7次运行
准确率: 0.2648314485387588
召回率: 0.28221048513302033
f1: 0.27324490855374156
epoch: 7
这是第8次运行
准确率: 0.29647680625393114
召回率: 0.3174687010954616
f1: 0.30661387846202454
epoch: 8
这是第9次运行
准确率: 0.32590673571822565
召回率: 0.3437284820031299
f1: 0.3345804538901519
epoch: 9
这是第10次运行
准确率: 0.3193007855587718
召回率: 0.3451134585289515
f1: 0.33170570738290694
model has been saved
上述的模型数学原理和代码中模型内部原理,你也许还不太理解,但你只要知道这几步,也可以在关系抽取时中先使用它。
但后续有时间,还是要搞懂原理。
数据预处理时:
1.第2步阈值的控制是为了防止数据量太大,训练集设置处理1500行,测试集设置处理了300行,这两个数值肯定是越大越好。
2.第8、9步处理字向量和两个位置向量时,向量长度都设置为了50,这个数值到底是不是最佳的,还有大家一起待研究。
3.第9步处理两个位置向量时,将每个向量里的每个数字映射到了0-80,这个选择不知道是不是最佳的,还有待大家一起研究。
4.第11步生成测试集的过程中,直接用的生成训练集时的word2id字典,这个字典是通过处理训练集的那1500行得到的,测试集拿来用效果怎么样,还有待研究。
1.模型的输入是不是有这三个向量刚好是最佳的
2.为什么这条语句,三个向量就可以得出y值
y = model(sentence, pos1, pos2)
3.从预处理到模型训练时,很多时候代码中向量名字都变了,比如输入时是datas,拿出来用的train,是一样的吗?要debug看一下
关于pki文件的概念还有待研究