基于Transformer的三元组抽取和实践

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等。
后续增加在自己的数据集(医疗数据集提取三元组)上的验证。

评论 6
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值