实体抽取基础概念
NER
实体识别:
获取实体边界
实体分类
实体抽取=命名实体识别=NER
-
实体是文本之中承载信息的语义单元,是文本语义理解的基础,像一句话之中的人名地名就是一种实体。
-
实体抽取,又称为命名实体识别(named entity recognition,NER),指的是从文本之中抽取出命名性实体,并把这些实体划分到指定的类别。
-
常见的实体包括七种类别:人名、地名、机构名、时间、日期、货币、百分比。
命名实体识别方法
命名实体识别一直是自然语言处理中非常重要的一个任务。目前,命名实体识别的方法大致可以分为基于规则的方法、基于传统机器学习的方法、基于深度学习的方法三大类。
基于规则
基于规则:针对有特殊上下文的实体,或实体本身有很多特征的文本,使用规则的方法简单且有效。比如抽取文本中物品价格,如果文本中所有商品价格都是"数字+元"的形式,则可以通过正则表达式"\d*.?\d+元"进行抽取。但如果待抽取文本中价格的表达方式多种多样,例如"一千八百万","伍佰贰拾圆","2000万元",遇到这些情况就要修改规则来满足所有可能的情况。随着语料数量的增加,面对的情况也越来越复杂,规则之间也可能发生冲突,整个系统也可能变得不可维护。因此基于规则的方式比较适合半结构化或比较规范的文本中的进行抽取任务,结合业务需求能够达到一定的效果。
-
优点:简单,快速。
-
缺点:适用性差,维护成本高后期甚至不能维护。
基于传统机器学习
早期使用于命名实体识别任务之中的机器学习方法,主要是一些基于统计的方法。使用统计方法,包括三个步骤:
①选择特征。传统统计方法需要人工选择特征。这些特征包括词语上下文信息、词缀、词性上下文特征等等。
②选择模型并训练模型。
③预测实体。这一步是使用已经训练好的模型来预测实体。
一般使用统计模型是把实体抽取任务转化为序列标注问题,使用IO、BIO、BIOES等标注方法对实体进行标注。对于文本之中的每个词,或者汉语之中的每个字,都有若干候选的标签。如表1所示,对"瓦特出生于苏格兰"这个序列进行标注,PER表示人名,LOC表示地名。分别使用IO、BIO、BIOES方法进行标注,可以得到表中的结果。
B代表Begin,表示实体名称的开头,I代表Inside,表示实体名称的中间或者末尾字符,E代表End,表示实体名称的结束字符,O代表Outside,表示不是实体名称的字符,S代表Single,表示单字为一个命名实体
文本序列 | IO标注体系 | BIO标注体系 | BIOES标注模式 |
---|---|---|---|
瓦 | I-PER | B-PER | B-PER |
特 | I-PER | I-PER | E-PER |
出 | O | O | O |
生 | O | O | O |
于 | O | O | O |
苏 | I-LOC | B-LOC | B-LOC |
格 | I-LOC | I-LOC | I-LOC |
兰 | I-LOC | I-LOC | E-LOC |
基于序列标注方法的统计模型,常见的包括:支持向量机(SVM)、隐马尔科夫模型(HMM)、**条件随机场(CRF)
**等。在实际研究之中,研究人员往往把这些模型和其他方法结合在一起。
在传统的统计方法之中,特征的选择是至关重要的,选择更好的特征能够提高实体抽取的效果。常见的特征可以分为形态、词汇、句法、全局特征、外部信息等。统计学习方法较之基于规则的方法,更加灵活和健壮,可以移植到其他领域。但是这些模型依赖人工设计的特征和现有的自然语言处理工具(如分词工具)。人工设计的特征和自然语言处理工具直接影响到统计模型的性能。
基于深度学习
在近年来深度学习模型逐渐火爆的背景下,大量的深度学习模型被使用到实体抽取任务之中。基于深度学习的方法 ,不需要设计复杂的特征 ,对自然语义处理工具的依赖也较少。基于深度学习的方法主要使用神经网络模型,结合条件随机场模型。常用的神经网络模型包括卷积神经网络(CNN)、循环神经网络(RNN)、长短期记忆网络(LSTM)等,其中**BiLSTM-CRF
**是目前最为常用的命名实体识别模型.
优点:
基于深度学习的方法,不需要人工来设计特征,同时能够取得较高的准确率和召回率。
缺点:
但是这些模型十分依赖人工标注数据,标注语料的缺乏为模型的训练带来了极大的困难。
条件随机场: 避免非法实体
评测标准
原理
NER的评测标准通常包括:准确率 ( Precision ) 、召回率 ( Recall ) 和 F1 值三个方面
-
准确率:模型识别出来的实体中,被所有预测为正的样本中实际为正样本的概率
-
召回率:模型识别出来的实体中,实际为正的样本中被预测为正样本的概率
-
而 F1 值则是准确率和召回率的调和平均值,可以对系统的性能进行综合性的评价
图示例
常见问题
-
现阶段的实体抽取模型很少考虑标签重叠问题。所谓标签重叠,指的是每个词或字具有两种以上的标签。而现阶段的模型,每个词或字只能属于某一类标签。对于多标签的实体抽取,仍然是实体抽取未来的主要研究方向之一。
-
目前的实体抽取方法大都局限于抽取人名、地名、机构名等传统实体类别,而实际应用中,所需要抽取的类别会更多,并且缺乏标注数据。
基于规则实现NER
规则原理
-
基于规则的方法多采用语言学专家手工构造规则模板, 选用特征包括统计信息, 标点符号, 关键字, 指示词和方向词, 位置词(如尾字), 中心词等方法, 以模式和字符串相匹配为主要手段, 这类系统大多依赖于知识库和词典的建立.
-
基于规则和词典的方法是命名实体识别中最早使用的方法.
-
一般而言, 当提取的规则能比较精确地反映语言现象时, 基于规则的方法性能要优于基于统计的方法.
-
但是这些规则往往依赖于具体语言, 领域和文本风格, 编制过程耗时且难以涵盖所有的语言现象, 特别容易产生错误, 系统可移植性不好, 对于不同的系统需要语言学专家重新书写规则. 基于规则的方法的另外一个缺点是代价太大, 存在系统建设周期长, 移植性差而且需要建立不同领域知识库作为辅助以提高系统识别能力等问题.
-
规则处理方法
工业界常用的规则处理方法:
-
领域字典匹配
-
定义正则表达式
案例分析
流程:
-
定义词典(用于存储实体的结尾信息)
-
定义抽取实体函数
-
根据词性分词
-
遍历词性列表
-
判断实体开始和结尾, 以及非实体内容或实体中间信息
-
将标注好的实体信息转为字符串
-
定义正则匹配表达式('S+O*E+')
-
获取句子中匹配到对应表达式开始和结尾的索引位置对象
-
遍历位置对象, 获取词表中的对应位置(开始和结尾)的切片结果
-
代码
# 导包
import jieba.posseg as psg
import re
# 定义词典(匹配结尾)
org_tag = ['总局', '局', '管理总局', '公司', '有限公司', '基金', '基金会', '政府', '人民政府']
# 定义函数处理匹配过程
def extract_ner(text):
# 词性分词
words_flag = psg.lcut(text)
# print(words_flag)
words = []
feature = []
# 遍历词性列表
for word, flag in words_flag:
words.append(word)
# 判断结尾
if word in org_tag:
feature.append('E')
else:
# 判断开头(以地名ns)
if flag == 'ns' or flag == 'nr':
feature.append('S')
# 判断非实体或中间词
else:
feature.append('O')
feature = ''.join(feature)
# print(feature)
# 正则匹配开头和结尾, 中间不限制
pattern = re.compile('S+O*S*E+')
# 获取匹配到的开始和结尾对应的对象, 返回句子中匹配到的对应开始和结尾的索引位置对象
ner_label = re.finditer(pattern, feature)
res = []
# 遍历开始和结尾对应索引
for ner in ner_label:
res.append(''.join(words[ner.start():ner.end()]))
return res
if __name__ == '__main__':
sent1 = '可在接到本决定书之日起六十日内向中国国家市场监督管理总局申请行政复议,杭州海康威视数字技术股份有限公司.'
sent2 = '北京强盛科技股份有限公司向四川雅安人民政府捐助人民币两亿元整,为了救助地震中受伤的孩子。'
sent3 = '李连杰壹基金和韩红爱心慈善基金会向甘肃积石山县人民政府捐助人民币共计两千万,用于灾后重建。'
print(extract_ner(sent1)) # ['中国国家市场监督管理总局', '杭州海康威视数字技术股份有限公司']
print(extract_ner(sent2)) # ['北京强盛科技股份有限公司', '四川雅安人民政府']
print(extract_ner(sent3)) # ['李连杰壹基金', '韩红慈善基金会', '甘肃积石山县人民政府']
基于深度学习实现NER
杜绝非法实体
BiLSTM+CRF介绍
作用
BiLSTM+CRF: 解决NER问题 实现方式: 从一段自然语言文本中找出相关实体,并标注出其位置以及类型。 命名实体识别问题实际上是序列标注问题
: 以中文分词任务进行举例, 例如输入序列是一串文字: "我是中国人", 输出序列是一串标签: "OOBII", 其中"BIO"组成了一种中文分词的标签体系: B表示这个字是词的开始, I表示词的中间到结尾, O表示其他类型词. 因此我们可以根据输出序列"OOBII"进行解码, 得到分词结果"我\是\中国人"。 序列标注问题涵盖了自然语言处理中的很多任务:包括语音识别, 中文分词, 机器翻译, 命名实体识别等, 而常见的序列标注模型包括HMM, CRF, RNN, LSTM, GRU等模型。 其中在命名实体识别技术上, 目前主流的技术: 通过BiLSTM+CRF模型进行序列标注。
标签
B-Person (人名的开始部分) I-Person (人名的中间部分) B-Organization (组织机构的开始部分) I-Organization (组织机构的中间部分) O (非实体信息)
BiLSTM网络结构:
-
所谓的BiLSTM,就是(Bidirectional LSTM)双向LSTM. 单向的LSTM模型只能捕捉到从前向后传递的信息,而双向的网络可以同时捕捉正向信息和反向信息,使得对文本信息的利用更全面,效果也更好。
-
在BiLSTM网络最终的输出层后面增加了一个线性层,用来将BiLSTM产生的隐藏层输出结果投射到具有某种表达标签特征意义的区间
CRF网络结构:
- 从图中可以看到,在BiLSTM上方我们添加了一个CRF层。具体地,在基于BiLSTM获得各个位置的标签向量之后,这些标签向量将被作为【发射分数】传入CRF中,发射这个概念是从CRF里面带出来的,后边在介绍CRF部分会更多地提及,这里先不用纠结这一点。
这些发射分数(标签向量)传入CRF之后,CRF会据此解码出一串标签序列。那么问题来了,从上图最上边的解码过程可以看出,这里可能对应着很多条不同的路径,例如:
B-Person, I-Person, O, ..., I-Organization
B-Organization, I-Person, O, ..., I-Person
B-Organization, I-Organization, O, ..., O
CRF的作用就是在所有可能的路径中,找出得出概率最大,效果最优的一条路径,那这个标签序列就是模型的输出。
CRF原理
线性CRF定义
输入序列𝑋和输出序列𝑌是线性序列
每个标签𝑦𝑖的产生,只与这些因素有关系:当前位置的输入𝑥𝑖,𝑦𝑖直接相连的两个邻居𝑦𝑖−1和𝑦𝑖+1,与其他的标签和输入没有关系。
两个分数
发射分数
(发射矩阵)
转移分数
在crf的api内部自动生成并学习
从列到行地来看下这个转移矩阵𝑇,B-Person向I-Person转移的分数为0.93,B-Person向I-Organization转移的分数为0.02,前者的分数远远大于后者。I-Person向I-Person转移的概率是0.71,I-Organization向I-Organization转移的分数是0.95,因为一个人或者组织的名字往往包含多个字,所以这个概率相对是比较高的,这其实也是很符合我们直观认识的。
这个转移分数矩阵是CRF中的一个可学习的参数矩阵,它的存在能够帮助显示地去建模标签之间的转移关系,提高命名实体识别的准确率。
损失函数
CRF的解码策略在所有可能的路径中,找出得出概率最大,效果最优的一条路径,那这个标签序列就是模型的输出,假设标签数量是𝑘,文本长度是𝑛,显然会有𝑁=𝑘^𝑛条路径,若用𝑆𝑖代表第𝑖条路径的分数,那我们可以这样去算一个标签序列出现的概率:
期待CRF解码出来的序列就是这一条。那它的分数可以表示为𝑠𝑟𝑒𝑎𝑙,它出现的概率就是:
建模学习的目的就是为了不断的提高𝑃(𝑆𝑟𝑒𝑎𝑙)的概率值,这就是目标函数,当目标函数越大时,它对应的损失就应该越小,所以我们可以这样去建模它的损失函数:(直接加-不严谨, 正常应该进行凸优化)
一般将这样的损失放到log空间去求解,因为log函数本身是单调递增的,所以它并不影响我们去迭代优化损失函数。
路径分数计算
单一路径
全部路径
Viterbi解码
在推理过程中只需要计算路径分数即可, 不需要解码
BiLSTM+CRF项目案例
数据处理
构造序列标注数据
用原始文本信息和标注信息构建序列标注数据
import os
import json
class TransferData(object):
"""
转换样本格式, 满足模型训练, 序列标注任务, token级别分类
"""
def __init__(self):
# 标签汉字转换英文字典
self.label_dict = json.load(open(r'D:\CodeProject\10KD_Graph\my_project\LSTM_CRF\data\labels.json', encoding='utf-8'))
# 标签英文转换id索引
self.tag2id_dict = json.load(open(r'D:\CodeProject\10KD_Graph\my_project\LSTM_CRF\data\tag2id.json', encoding='utf-8'))
# 未处理的源文件夹
self.origin_path = r'D:\CodeProject\10KD_Graph\my_project\LSTM_CRF\data_origin'
# 保存处理后的文件
self.train_file_path = r'D:\CodeProject\10KD_Graph\my_project\LSTM_CRF\data\train.txt'
def transfer(self):
# 打开文件保存处理后的数据
with open(self.train_file_path, 'w', encoding='utf-8') as fr:
# 遍历未处理的文件夹, root:未处理的文件夹, dirs:内部(root内)文件夹, 内部文件
for root, dirs, files in os.walk(self.origin_path):
# 遍历每层文件夹内的文件
for file in files:
# 拼接文件名称和当前根路径
file_path = os.path.join(root, file)
# 判断拼接后的文件是否是未标注的源文件, 确保后续操作的文件路径是基于未标注的路径
if 'original' not in file_path:
continue
# 将源文件路径改为标注后的路径, 并重新接收(标注后的数据)
label_path = file_path.replace('.txtoriginal', '')
# 调用函数, 将标签汉字转换为英文, 并以字典形式返回标注好实体和其对应索引
label_dict = self.read_label_text(label_path)
# 打开未标注的数据
with open(file_path, 'r', encoding='utf-8') as f:
# 一次读取所有内容
text = f.read()
text = text.strip()
# 遍历文件内的每个字符
for ids, word in enumerate(text):
# 根据返回的标签字典, 在字典内查找对应索引的值, 未在字典内的不是实体, 返回O
write_label = label_dict.get(ids, 'O')
# 逐字符写入到处理后的文件内
fr.write(word + '\t' + write_label + '\n')
def read_label_text(self, file_path):
# 定义空字典, 用于接收索引和实体{实体对应原文中的位置索引: 实体的英文表示(B-XXX/I-XXX)}
label_dict = {}
# 遍历标注好的实体文件
for text in open(file_path, 'r', encoding='utf-8'):
text = text.strip()
# 按制表符分割文件内容
res = text.split('\t')
# 具体实体内容 实体开始索引位置 实体结束索引位置 实体标签名称
start = int(res[1])
end = int(res[2])
# 将实体标签转换为英文
label = self.label_dict.get(res[3])
# 遍历每个实体, 从实体起始索引开始到实体结束索引位置 + 1: 因为标注好的数据中的索引是包左包右的
for i in range(start, end + 1):
# 判断是否是实体的第一个索引
if i == start:
tag = 'B-' + label
else:
tag = 'I-' + label
label_dict[i] = tag
return label_dict
if __name__ == '__main__':
trans = TransferData()
trans.transfer()
项目配置文件
import json
import torch
import os
class Config(object):
def __init__(self):
# 设备
self.device = torch.device('cuda' if torch.cuda.is_available() else 'cpu')
# 文件路径
self.train_path = r'D:\CodeProject\10KD_Graph\my_project\LSTM_CRF\data\train.txt'
self.vocab_path = r'D:\CodeProject\10KD_Graph\my_project\LSTM_CRF\data\vocab.txt'
self.text2id = json.load(open(r'D:\CodeProject\10KD_Graph\my_project\LSTM_CRF\data\tag2id.json', encoding='utf-8'))
# 模型相关参数
self.embedding_dim = 768
self.hidden_dim = 256 # lstm输出维度
self.dropout = 0.2
# 模型训练相关参数
# 选择训练时使用哪个模型
self.model = 'BiLSTM_CRF'
self.epochs = 10
self.batch_size = 16 # 除了大模型以外, 不一定越大越好
self.lr = 3e-4 # 0.0003
if __name__ == '__main__':
config = Config()
print(config.device)
构建(x, y)样本对和vocab
将一行一个token转换为按符号划分的的句子, 将每句话对应的tag标签组合成样本对
from LSTM_CRF.Config import Config
conf = Config()
# 获取数据集和词表
def build_data():
# 初始化容器
datas = []
sample_x = []
sample_y = []
vocab_list = ["PAD", "UNK"]
# 遍历train数据集, 将句子分段, 构建pair(x, y), 将结果保存到datas
for line in open(conf.train_path, 'r', encoding='utf-8'):
line = line.strip().split('\t')
if len(line) != 2:
continue
word, label = line[0], line[1]
sample_x.append(word) # 一句样本
sample_y.append(label) # 一句样本对应的标注序列
# 判断是否是一个完整的句子
if word in ['。', '?', '!', '!', '?']:
datas.append([sample_x, sample_y])
sample_x = []
sample_y = []
if word not in vocab_list:
vocab_list.append(word)
# 构建word2id
word2id = {word: idx for idx, word in enumerate(vocab_list)}
# 将词表写入到文件, 只写入词, 不写入索引
write_word2id(vocab_list, conf.vocab_path)
return datas, word2id
def write_word2id(word_list, save_path):
with open(save_path, 'w', encoding='utf-8') as f:
for word in word_list:
f.write(word + '\n')
if __name__ == '__main__':
datas, word2id = build_data()
print(len(datas)) # 7836
print('*' * 100)
print(len(word2id)) # 1758
构建数据迭代器
数据集对象
获取数据中的一条样本
from common import build_data
from torch.utils.data import Dataset, DataLoader
import json
import torch
from torch.nn.utils.rnn import pad_sequence
datas, word2id = build_data()
class NerDataset(Dataset):
def __init__(self, data):
super().__init__()
self.data = data
def __len__(self):
return len(self.data)
def __getitem__(self, item):
x = self.data[item][0]
y = self.data[item][1]
return x, y
collate_fn
对batch数据进行批处理
def collate_fn(batch):
# pad_sequence 需要传入的格式 [tensor,,,]
x_train = [torch.tensor([word2id[char] for char in data[0]]) for data in batch]
y_train = [torch.tensor([conf.tag2id[label] for label in data[1]]) for data in batch]
# 长度补齐
# pad_sequence: 根据批次数据进行补齐
input_ids_pad = pad_sequence(x_train, batch_first=True, padding_value=0)
label_pad = pad_sequence(y_train, batch_first=True, padding_value=11)
attention_mask = (input_ids_pad != 0).long()
return input_ids_pad, label_pad, attention_mask
构建数据迭代器
def get_data():
# 训练集
train_data = NerDataset(datas[:6200])
train_loader = DataLoader(
dataset=train_data,
collate_fn=collate_fn,
batch_size=conf.batch_size,
drop_last=True
)
# 验证集
dev_data = NerDataset(datas[:6200])
dev_loader = DataLoader(
dataset=dev_data,
collate_fn=collate_fn,
batch_size=conf.batch_size,
drop_last=True
)
return train_loader, dev_loader
构建模型
BiLSTM
import torch.nn as nn
class NERBiRLSTM(nn.Module):
def __init__(self, embedding_dim, hidden_dim, dropout, word2id, tag2id):
super().__init__()
# 变量赋值
self.name = 'BiLSTM'
self.embedding_dim = embedding_dim
self.hidden_dim = hidden_dim
self.vocab_size = len(word2id)
self.tag_size = len(tag2id)
self.tag2id = tag2id
# 实例化网络层
self.word_embed = nn.Embedding(self.vocab_size, self.embedding_dim)
# 双向lstm单向输出维度为hidden_dim // 2, 两个方向拼接后为hidden_dim
self.lstm = nn.LSTM(self.embedding_dim, self.hidden_dim // 2, bidirectional=True, batch_first=True)
self.dropout = nn.Dropout(p=dropout)
self.hidden2tag = nn.Linear(self.hidden_dim, self.tag_size)
def forward(self, x, mask):
embed = self.word_embed(x)
output, hidden = self.lstm(embed)
# print(output.shape, '1')
# 哈达玛积, 按位相乘 => 保留有效信息, 此处的unsqueeze是将mask内的每个具体元素升一维, 广播时复制到指定个数
output = output * mask.unsqueeze(-1)
# print(mask.unsqueeze(-1).shape, '2')
# print(output.shape, '3')
# 正则化
output = self.dropout(output)
output = self.hidden2tag(output)
return output
if __name__ == '__main__':
from LSTM_CRF.utils.data_loader import *
train_loader, dev_loader = get_data()
inputs, labels, mask = next(iter(train_loader))
print('inputs:', inputs.shape)
print('labels:', labels.shape)
print('mask:', mask.shape)
conf = Config()
datas, word2id = build_data()
model = NERBiRLSTM(embedding_dim=conf.embedding_dim, hidden_dim=conf.hidden_dim, dropout=conf.dropout, word2id=word2id, tag2id=conf.tag2id)
out = model(inputs, mask)
print(out.shape)
BiLSTM+CRF
import torch.nn as nn
from torchcrf import CRF
class NERBiRLSTMCrf(nn.Module):
def __init__(self, embedding_dim, hidden_dim, dropout, word2id, tag2id):
super().__init__()
# 变量赋值
self.name = 'BiLSTM_CRF'
self.embedding_dim = embedding_dim
self.hidden_dim = hidden_dim
self.vocab_size = len(word2id)
self.tag_size = len(tag2id)
self.tag2id = tag2id
# 实例化网络层
self.word_embed = nn.Embedding(self.vocab_size, self.embedding_dim)
# 双向lstm单向输出维度为hidden_dim // 2, 两个方向拼接后为hidden_dim
self.lstm = nn.LSTM(self.embedding_dim, self.hidden_dim // 2, bidirectional=True, batch_first=True)
self.dropout = nn.Dropout(p=dropout)
self.hidden2tag = nn.Linear(self.hidden_dim, self.tag_size)
self.crf = CRF(self.tag_size, batch_first=True)
def forward(self, x, mask):
"""
前向传播, 在推理时获取viterbi解码的最优路径, 并不计算损失
:param x: input_ids
:param mask: pad
:return: 最优解码路径列表
"""
out = self.get_lstm2linear(x) # 获取发射矩阵
out = out * mask.unsqueeze(-1)
out = self.crf.decode(out, mask.bool()) # 返回viterbi解码列表
return out
def log_likelihood(self, x, tags, mask):
"""
计算损失, 执行crf的前向传播方法
:param x: input_ids
:param tags: 样本标签labels
:param mask: 遮盖pad
:return: 返回损失
"""
out = self.get_lstm2linear(x) # 获取发射概率矩阵
out = out * mask.unsqueeze(-1)
out = self.crf(out, tags, mask.bool(), reduction='mean') # mean: 批次内样本损失的均值
return - out
def get_lstm2linear(self, x):
"""
获取发射概率矩阵
:param x: (batch_size, seq_len)
:return: 发射矩阵
"""
embed = self.word_embed(x)
outputs, hidden = self.lstm(embed)
outputs = self.dropout(outputs)
outputs = self.hidden2tag(outputs)
return outputs
if __name__ == '__main__':
from LSTM_CRF.utils.data_loader import *
train, dev = get_data()
inputs, labels, mask = next(iter(train))
print(labels)
print(mask)
model = NERBiRLSTMCrf(conf.embedding_dim, conf.hidden_dim, conf.dropout, word2id, conf.tag2id)
paths = model(inputs, mask)[0]
print(paths)
# 将config中的tag2id转换维id2tag
id2tag = {v: k for k, v in conf.tag2id.items()}
paths = [id2tag[i] for i in paths]
print(paths)
loss = model.log_likelihood(inputs, labels, mask)
print(loss.item())
模型训练
import time
import torch.nn as nn
import torch.optim as optim
from tqdm import tqdm
from LSTM_CRF.utils.data_loader import *
from model.BiLSTM import NERBiRLSTM
from model.BiLSTM_CRF import NERBiRLSTMCrf
from sklearn.metrics import accuracy_score, f1_score, precision_score, recall_score, classification_report
conf = Config()
def model2train():
# 准备物料
train_dataloader, dev_dataloader = get_data()
models = {'BiLSTM': NERBiRLSTM, 'BiLSTM_CRF': NERBiRLSTMCrf}
model = models[conf.model](conf.embedding_dim, conf.hidden_dim, conf.dropout, word2id, conf.tag2id)
model = model.to(conf.device)
loss_fn = nn.CrossEntropyLoss(reduction='mean') # 用于BiLSTM模型
optimizer = optim.AdamW(model.parameters(), lr=conf.lr)
# 开始训练
start_time = time.time()
f1_score_ = -10000
if conf.model == 'BiLSTM':
for epo in range(conf.epochs):
model.train()
for idx, (inputs, labels, mask) in enumerate(tqdm(train_dataloader, desc='BiLSTM Training')):
x = inputs.to(conf.device)
tags = labels.to(conf.device)
mask = mask.to(conf.device)
# 一个step训练
pred = model(x, mask)
pred = pred.view(-1, len(conf.tag2id))
loss = loss_fn(pred, tags.view(-1))
loss.backward()
optimizer.step()
optimizer.zero_grad()
if idx % 100 == 0 and idx != 0:
# print(epo + 1, loss.item(), time.time() - start_time)
print(f'训练轮次: {epo + 1}, 损失: {loss.item()}, 用时: {time.time() - start_time}')
# 每个epo验证一次
precision, recall, f1, report = model2eval(dev_dataloader, model, loss_fn)
if f1 > f1_score_:
f1_score_ = f1
torch.save(model.state_dict(), './save_model/bi_lstm_best.pth')
print('\n\n\n')
print(f'{model.name} 已保存')
print(report)
print('训练总用时: ', time.time() - start_time)
elif conf.model == 'BiLSTM_CRF':
for epo in range(conf.epochs):
model.train()
for idx, (inputs, labels, mask) in enumerate(tqdm(train_dataloader, desc='BiLSTMCrf Training')):
x = inputs.to(conf.device)
y = labels.to(conf.device)
mask = mask.to(conf.device)
loss = model.log_likelihood(x, y, mask)
loss.backward()
# 梯度裁剪, 防止梯度爆炸, 大于10的梯度用10代替
nn.utils.clip_grad_norm_(model.parameters(), max_norm=10)
optimizer.step()
optimizer.zero_grad()
if idx % 100 == 0 and idx != 0:
# print(epo + 1, loss.item(), time.time() - start_time)
print(f'训练轮次: {epo + 1}, 损失: {loss.item()}, 用时: {time.time() - start_time}')
precision, recall, f1, report = model2eval(dev_dataloader, model)
if f1 > f1_score_:
f1_score_ = f1
torch.save(model.state_dict(), './save_model/bi_lstm_crf_best.pth')
print('\n\n\n')
print(f'{model.name} 已保存')
print(report)
print('训练总用时: ', time.time() - start_time)
def model2eval(dev_iter, model, loss_fn=None):
aver_loss = 0
preds, golds = [], []
model.eval()
for idx, (inputs, labels, mask) in enumerate(tqdm(dev_iter, desc=f'使用{model.name}验证验证集数据')):
x = inputs.to(conf.device)
y = labels.to(conf.device)
mask = mask.to(conf.device)
predict = []
if conf.model == "BiLSTM":
pred = model(x, mask)
predict = torch.argmax(pred, dim=-1).tolist()
pred = pred.view(-1, len(conf.tag2id))
val_loss = loss_fn(pred, y.view(-1))
aver_loss += val_loss.item()
elif conf.model == "BiLSTM_CRF":
mask = mask.to(torch.bool)
predict = model(x, mask)
loss = model.log_likelihood(x, y, mask)
aver_loss += loss.mean().item()
# leng = []
# for i in y.cpu():
# tmp = []
#
#
# for j in i:
# if j.item() > 0:
# tmp.append(j.item())
# leng.append(tmp)
# for idx, i in enumerate(predict):
# preds.extend(i[:len(leng[idx])])
#
# for idx, i in enumerate(y.tolist()):
# golds.extend(i[:len(leng[idx])])
# for i, m in zip(predict, mask): # 遍历一个批次内的每条数据的预测值(张量形式)和掩码(张量形式)
# preds.extend([p for p, mm in zip(i, m) if mm]) # 遍历每条数据的每个token, 获取掩码不为零的预测结果
#
# for i, m in zip(y.tolist(), mask):
# golds.extend([p for p, mm in zip(i, m) if mm])
for one_pred, one_true in zip(predict, y.tolist()):
pad_len = one_true.count(11) # 标签中pad的索引为11, 统计11的个数即可统计pad长度
no_pad_len = len(one_true) - pad_len
preds.extend(one_pred[:no_pad_len])
golds.extend(one_true[:no_pad_len])
aver_loss /= len(dev_iter)
precision = precision_score(golds, preds, average='macro') # macro: 平等看待每个类别
recall = recall_score(golds, preds, average='macro')
f1 = f1_score(golds, preds, average='macro')
report = classification_report(golds, preds)
return precision, recall, f1, report
if __name__ == '__main__':
model2train()
模型预测
from model.BiLSTM import NERBiRLSTM
from model.BiLSTM_CRF import NERBiRLSTMCrf
from Config import Config
from LSTM_CRF.utils.data_loader import word2id
import torch
conf = Config()
models = {'BiLSTM': NERBiRLSTM, 'BiLSTM_CRF': NERBiRLSTMCrf}
model = models[conf.model](conf.embedding_dim, conf.hidden_dim, conf.dropout, word2id, conf.tag2id)
model.load_state_dict(torch.load('save_model/bi_lstm_crf_best.pth', weights_only=True))
id2tag = {value: key for key, value in conf.tag2id.items()}
def model2test(sample):
x = [word2id.get(char, word2id["UNK"]) for char in sample]
x_test = torch.tensor([x]) # 2维
mask = (x_test != 0).long()
model.eval()
with torch.no_grad():
if conf.model == "BiLSTM":
out = model(x_test, mask)
pred = torch.argmax(out, dim=-1)
tags = [id2tag[i.item()] for i in pred[0]]
else:
pred = model(x_test, mask)
tags = [id2tag[i] for i in pred[0]]
chars = list(sample)
assert len(chars) == len(tags)
res = extract_entities(chars, tags)
return res
def extract_entities(chars, tags):
# 初始化返回容器
entities = []
entity = []
entity_type = None
# 循环遍历, 组装结果
for char, tag in zip(chars, tags):
if tag.startswith('B-'):
if entity:
# 保存上一个非空实体
entities.append((entity_type, ''.join(entity)))
entity = []
entity_type = tag.split('-')[1]
entity.append(char)
elif tag.startswith('I-') and entity: # 中间实体, 切单一实体内不为空
entity.append(char)
else:
if entity:
entities.append((entity_type, ''.join(entity)))
entity = []
entity_type = None
# 保存最后一个实体, 并返回
if entity:
entities.append((entity_type, ''.join(entity)))
return {entity: entity_type for entity_type, entity in entities}
if __name__ == '__main__':
res = model2test("小明的父亲患有冠心病及糖尿病,无手术外伤史及药物过敏史, 服用药物, 白细胞坏死, 查右髋部X光片示")
print(res)