1.背景介绍
知识图谱是目前比较流行的技术和话题,现阶段各大AI公司都创建自己的知识图谱,其整体流程可以如下图所示:
包括:知识抽取、知识融合、知识构建、知识加工、知识存储、知识推理几大模块,在这几个模块中:实体识别和关系抽取是最核心的内容
而关系抽取也隶属于三元组抽取的一部分,所以研究三元组的抽取对知识图谱的构建有比较重要的意义。
2.Bert 原理
模型介绍
代码实践
bert 原理
Bert的全名是Bidirectional Encoder Representations from Transformers,其主要结构是Transformer的encoder层,其包括两个训练阶段,预训练与fine-tuning
BERT_BASE (L=12, H=768, A=12, Total Parameters=110M) BERT_LARGE (L=24, H=1024, A=16, Total Parameters=340M).
https://arxiv.org/pdf/1810.04805.pdf
预训练阶段
BERT的预训练包括两个任务,Masked Language Model与Next Sentence Prediction。
Masked Language Model
Masked Language Model可以理解为完形填空,随机mask每一个句子中15%的词,用其上下文来做预测,
例如:my dog is hairy → my dog is [MASK]
此处将hairy进行了mask处理,然后采用非监督学习的方法预测mask位置的词是什么,但是该方法有一个问题,因为是mask15%的词,其数量已经很高了,这样就会导致某些词在fine-tuning阶段从未见过,
80%的是采用[mask],my dog is hairy → my dog is [MASK]
10%的是随机取一个词来代替mask的词,my dog is hairy -> my dog is apple
10%的保持不变,my dog is hairy -> my dog is hairy
这是因为transformer要保持对每个输入token分布式的表征,否则Transformer很可能会记住这个[MASK]就是“hairy”。至于使用随机词带来的负面影响,论文中认为所有其他的token(即非"hairy"的token)共享15%*10% = 1.5%的概率,其影响是可以忽略不计的。
Next Sentence Prediction
选择一些句子对A与B,其中50%的数据B是A的下一条句子,剩余50%的数据B是语料库中随机选择的,学习其中的相关性,添加这样的预训练的目的是目前很多NLP的任务比如QA和NLI都需要理解两个句子之间的关系,从而能让预训练的模型更好的适应这样的任务。
序列的头部会填充一个[CLS]标识符,该符号对应的bert输出值通常用来直接表示句向量,不同的序列之间以[SEP]标识符进行填充表示,序列尾部也以[SEP]进行填充
输入值包括了三个部分,分别是token embedding词向量,segment embedding段落向量,position embedding位置向量,这三个部分相加形成了最终的bert输入向量。
3.模型介绍
基于Bert的半指针半结构化网络抽取使用基于Bert的半指针半结构化网络作为三元组抽取的技术,其目标是从数据中抽取三元组数据,三元组数据包含了:主语对象subject、关系relation、 目标对象Object。譬如在医疗场景中: “银翘解毒片可以治疗感冒”, 根据半指针半结构化网络进行训练后可以抽取出主语对象: (银翘解毒片,治疗,感冒)。该算法模型由三部分组成:第一部分是用于抽取Subject的模型、第二部分是用于 抽取Relation模型、第三部分是用于抽取Object的模型,其中Subject模型使用Bert作为基础模型作为输入,第一部分的输出作为第二部分和第三部分的输入,三部分模型进行联合训练,并且对最终的损失进行前向回归,最终关系得以在线上得到一定规模地使用。
代码如下所示:
## train 代码
import json
from tqdm import tqdm # 进度条
import os
import numpy as np
from transformers import BertTokenizer, AdamW
import torch
from model import ObjectModel, SubjectModel
GPU_NUM = 0
device = torch.device(f'cuda:{GPU_NUM}') if torch.cuda.is_available() else torch.device('cpu')
vocab = {}
with open('bert/vocab.txt', encoding='utf_8')as file: # 使用with open 的方法读取词典
for l in file.readlines():
vocab[len(vocab)] = l.strip() # 根据key读取词典
def load_data(filename): # 中文解码加载数据
"""加载数据
单条格式:{'text': text, 'spo_list': [[s, p, o]]}
"""
with open(filename, encoding='utf-8') as f:
json_list = json.load(f)
return json_list
# 加载数据集
train_data = load_data('data/train.json')
valid_data = load_data('data/dev.json')
tokenizer = BertTokenizer.from_pretrained('bert') # 调用分词器
with open('data/schemas.json', encoding='utf-8') as f: # 读取predicate
json_list = json.load(f)
id2predicate = json_list[0]
predicate2id = json_list[1]
def search(pattern, sequence):
"""从sequence中寻找子串pattern
如果找到,返回第一个下标;否则返回-1。
"""
n = len(pattern)
for i in range(len(sequence)):
if sequence[i:i + n] == pattern:
return i
return -1
def sequence_padding(inputs, length=None, padding=0, mode='post'):
"""Numpy函数,将序列padding到同一长度
"""
if length is None:
length = max([len(x) for x in inputs])
pad_width = [(0, 0) for _ in np.shape(inputs[0])]
outputs = []
for x in inputs:
x = x[:length]
if mode == 'post':
pad_width[0] = (0, length - len(x))
elif mode == 'pre':
pad_width[0] = (length - len(x), 0)
else:
raise ValueError('"mode" argument must be "post" or "pre".')
x = np.pad(x, pad_width, 'constant', constant_values=padding)
outputs.append(x)
return np.array(outputs)
def data_generator(data, batch_size=3): # 数据迭代器/数据生成器
batch_input_ids, batch_attention_mask = [], [] # 输出给模型(object)的变量,通过调用bert分词器得到
batch_subject_labels, batch_subject_ids, batch_object_labels = [], [], []
texts = []
for i, d in enumerate(data): # 数据来自dataloader i = 数据索引 d = text
text = d['text'] # 从train 中取出text
texts.append(text) # text 贴入元组
encoding = tokenizer(text=text) # 使用bert 分词
input_ids, attention_mask = encoding.input_ids, encoding.attention_mask # 分词后对应“bert词典下标”和mask
# 整理三元组 {s: [(o, p)]}
spoes = {}
for s, p, o in d['spo_list']: # 遍历三元组
# [cls] XXX [sep]
s_encoding = tokenizer(text=s).input_ids[1:-1] # 将s,o编码成对应的下标
o_encoding = tokenizer(text=o).input_ids[1:-1] # [1:-1] 去除cls sep
s_idx = search(s_encoding, input_ids) # 从text的input_ids 寻找s的下标
o_idx = search(o_encoding, input_ids) # 从text的input_ids 寻找o的下标
p = predicate2id[p] # 的到predicate的下标
if s_idx != -1 and o_idx != -1: # 做判断没有反应的返回-1
s = (s_idx, s_idx + len(s_encoding) - 1) # s保存subject的起始位置,起始位置加上长度 -1
o = (o_idx, o_idx + len(o_encoding) - 1, p)# 同上 s,o 是一个元组保存着起始位置和终止位置的下标 以及 p
if s not in spoes:
spoes[s] = []
spoes[s].append(o) # 将 下标加入 spoes 字典当中去
if spoes:
# subject标签
subject_labels = np.zeros((len(input_ids), 2)) # 生成一个input长度的二维向量/ s头s尾
for s in spoes:
# 注意要+1,因为有cls符号
subject_labels[s[0], 0] = 1 # 第一行 = ‘0’ 的起始 = s[0] 等于1
subject_labels[s[1], 1] = 1 # 第二行 = ‘1’ 的终止 =s[1] 等于1
# 一个s对应多个o时,随机选一个subject
start, end = np.array(list(spoes.keys())).T
start = np.random.choice(start)
end = np.random.choice(end[end >= start])
subject_ids = (start, end)
# 对应的object标签
object_labels = np.zeros((len(input_ids), len(predicate2id), 2)) # 序列长度 x predicate长度 x 2
for o in spoes.get(subject_ids, []): # 通过subject 拿出对应的 o
object_labels[o[0], o[2], 0] = 1 # 对应 起始位置,predicate , 第一维度/头(取字o元组)
object_labels[o[1], o[2], 1] = 1 # 同上
# 构建batch
batch_input_ids.append(input_ids) # 将上述值加入batch
batch_attention_mask.append(attention_mask)
batch_subject_labels.append(subject_labels)
batch_subject_ids.append(subject_ids)
batch_object_labels.append(object_labels)
if len(batch_subject_labels) == batch_size or i == len(data) - 1: # 没有补偿
batch_input_ids = sequence_padding(batch_input_ids)
batch_attention_mask = sequence_padding(batch_attention_mask)
batch_subject_labels = sequence_padding(batch_subject_labels)
batch_subject_ids = np.array(batch_subject_ids)
batch_object_labels = sequence_padding(batch_object_labels)
yield [
torch.from_numpy(batch_input_ids).long(), torch.from_numpy(batch_attention_mask).long(),
torch.from_numpy(batch_subject_labels), torch.from_numpy(batch_subject_ids),
torch.from_numpy(batch_object_labels)
], None
batch_input_ids, batch_attention_mask = [], [] # 清空进入下个batch
batch_subject_labels, batch_subject_ids, batch_object_labels = [], [], []
if os.path.exists('graph_model.bin'): # 加载模型 保存档将graph model 加载过来
print('load model')
model = torch.load('graph_model.bin').to(device)
subject_model = model.encoder
else:
subject_model = SubjectModel.from_pretrained('./bert') # 没有使用bert train
subject_model.to(device)
model = ObjectModel(subject_model)
model.to(device)
train_loader = data_generator(train_data, batch_size=8) # dataloader = 8
optim = AdamW(model.parameters(), lr=5e-5) # 加速器 adamw 学习率 5e-5
loss_func = torch.nn.BCELoss() # cross binary loss
model.train()
class SPO(tuple):
def __init__(self, spo):
self.spox = (
spo[0],
spo[1],
spo[2],
)
def __hash__(self):
return self.spox.__hash__()
def __eq__(self, spo):
return self.spox == spo.spox
def train_func():
train_loss = 0
pbar = tqdm(train_loader) # 开启进度条并遍历 train_loader
for step, batch in enumerate(pbar): # 遍历每个step 和 batch
optim.zero_grad() # 将每个梯度清零
batch = batch[0] # 将batch 数据取出来第一个维度
input_ids = batch[0].to(device) # text对应bert词典的下标
attention_mask = batch[1].to(device) # mask
subject_labels = batch[2].to(device) # subject对应bert词典的下标
subject_ids = batch[3].to(device) # subject 在句子中id
object_labels = batch[4].to(device) # object对应bert词典的下标
subject_out, object_out = model(input_ids, subject_ids.float(), attention_mask) # 拿到subject和object输出
subject_out = subject_out * attention_mask.unsqueeze(-1) # 将输入中补长的位置变成 0 / input当中的padding
object_out = object_out * attention_mask.unsqueeze(-1).unsqueeze(-1) # 同上
subject_loss = loss_func(subject_out, subject_labels.float()) # 识别subject的损失函数
object_loss = loss_func(object_out, object_labels.float()) # 识别object的损失函数
# subject_loss = torch.mean(subject_loss, dim=2)
# subject_loss = torch.sum(subject_loss * attention_mask) / torch.sum(attention_mask)
loss = subject_loss + object_loss # 将loss进行相加 根据实际情况添加超参数
train_loss += loss.item() # 累加到train loss
loss.backward() # 反向传播
optim.step() # 更新参数
pbar.update()
pbar.set_description(f'train loss:{loss.item()}') # 显示更新参数
if step % 1000 == 0: # 每跑1000个step 保存模型
torch.save(model, 'graph_model.bin')
if step % 100 == 0 and step != 0: # 每跑100步在验证集当中检验效果
with torch.no_grad():
# texts = ['如何演好自己的角色,请读《演员自我修养》《喜剧之王》周星驰崛起于穷困潦倒之中的独门秘笈',
# '茶树茶网蝽,Stephanitis chinensis Drake,属半翅目网蝽科冠网椿属的一种昆虫',
# '爱德华·尼科·埃尔南迪斯(1986-),是一位身高只有70公分哥伦比亚男子,体重10公斤,只比随身行李高一些,2010年获吉尼斯世界纪录正式认证,成为全球当今最矮的成年男人']
X, Y, Z = 1e-10, 1e-10, 1e-10
pbar = tqdm()
for data in valid_data[0:100]: # 遍历验证集
spo = []
# for text in texts:
text = data['text'] # 取出text
spo_ori = data['spo_list'] # 去除三元组
en = tokenizer(text=text, return_tensors='pt') # 将text分词
_, subject_preds = subject_model(en.input_ids.to(device), en.attention_mask.to(device)) # 检验阶段需要预测subject的下标
subject_preds = subject_preds.cpu().data.numpy() # 将下标转换成numpy数组
start = np.where(subject_preds[0, :, 0] > 0.5)[0] # 阈值,大于0.5判断为start
end = np.where(subject_preds[0, :, 1] > 0.4)[0] # 阈值 大于0.4判断为end # 阈值自己设定
subjects = []
for i in start: # 遍历start 用来应对多个start的情况
j = end[end >= i] # 只取大于start的end 否则会出现逻辑错误
if len(j) > 0: # 如果 end 大于0将 start end 成对加入subject
j = j[0]
subjects.append((i, j))
# print(subjects)
if subjects:
for s in subjects: # 遍历每个s
index = en.input_ids.cpu().data.numpy().squeeze(0)[s[0]:s[1] + 1] # 根据输入的下标
subject = ''.join([vocab[i] for i in index]) # 将bert的vcab里的汉字映射出来
# print(subject)
_, object_preds = model(en.input_ids.to(device), # 将input分词的结果添加进去
torch.from_numpy(np.array([s])).float().to(device), # s的下标添加进去
en.attention_mask.to(device)) # 将mask添加进去
object_preds = object_preds.cpu().data.numpy() # 转换成numpy数组
for object_pred in object_preds: # 遍历所有的object
start = np.where(object_pred[:, :, 0] > 0.2) # object的阈值大于0.2取start
end = np.where(object_pred[:, :, 1] > 0.2) # 同上
for _start, predicate1 in zip(*start): # 星号zip代表把两个值解开 两行对应的元组 # 遍历start取 s 和 p
for _end, predicate2 in zip(*end): # 遍历end 取 e 和 p
if _start <= _end and predicate1 == predicate2: # 判断是否复合逻辑 spo
index = en.input_ids.cpu().data.numpy().squeeze(0)[_start:_end + 1] # 从输入中找到对应下标
object = ''.join([vocab[i] for i in index]) # 从bert词典中映射成中文
predicate = id2predicate[str(predicate1)] # 找predicate下标返回predicate
# print(object, '\t', predicate)
spo.append([subject, predicate, object]) # 三元组放到数组当中
# 预测结果
R = set([SPO(_spo) for _spo in spo]) # 预测去重
# 真实结果
T = set([SPO(_spo) for _spo in spo_ori]) # 真是去重
# R = set(spo_ori)
# T = set(spo)
# 交集
X += len(R & T) # R & T 交集长度
Y += len(R) # R 长度
Z += len(T) # T 长度
f1, precision, recall = 2 * X / (Y + Z), X / Y, X / Z # f1 精准度 召回率
pbar.update() # 把代码更新到pbar
pbar.set_description(
'f1: %.5f, precision: %.5f, recall: %.5f' % (f1, precision, recall)
)
pbar.close()
print('f1:', f1, 'precision:', precision, 'recall:', recall)
for epoch in range(100):
print('************start train************')
train_func()
4 总结与后续优化
4.1 总结:
该项目提供了一种新的标注方法(即半指针-半标注的方式)对spo三元组数据进行标注,而没有采用传统的BIO标注方式。
在构建模型的时候,使用一种同时抽取实体和关系的模型,提升了模型效率。先根据text的embedding信息预测subject,然后再将subject的预测结果结合text的embedding,共同作为预测object的数据数据。
4.2 后续优化
数据处理部分:训练阶段,若数据集存在多个subject,则随机选取其中一个subject;而验证集上需要预测所有的subejct,因此训练集与验证集的数据分布不一致。后续需要对数据标注部分进行优化,使数据更加合理。
数据采样部分:三元组类别分布差异非常大,从而导致小类别的预测效果非常差,应该选择一种合适的抽样方法,在训练阶段缩小不同类别样本的分布差异。
模型部分:在预测subject和object的时候,将subject和object的开始和结束位置分别设置了label,人为增加了模型的复杂度;该方法是否有助于模型的学习,暂时还不太确定,后续需要对模型进行更详细的分析。
超参数设置:模型中存在多个超参数,如subject_loss和object_loss各自权重应该如何设置;subject预测概率和object的预测概率的阈值设置等。这些参数是否可以通过学习的方式,让模型自动学习。
这里transformers模型只使用了最基础的bert模型,后续可以尝试bert的优化模型,如roberta等。
后续增加在自己的数据集(医疗数据集提取三元组)上的验证。