当前位置: 首页 > news >正文

基于BiLSTM+CRF实现NER

基于BiLSTM+CRF实现NER

学习目标

  • 理解BiLSTM实现NER的原理
  • 理解什么是CRF算法
  • 掌握CRF的损失函数原理


1 BiLSTM+CRF模型介绍

  • BiLSTM+CRF模型的作用

    BiLSTM+CRF: 解决NER问题
    实现方式: 从一段自然语言文本中找出相关实体,并标注出其位置以及类型。
    命名实体识别问题实际上是序列标注问题: 以中文分词任务进行举例, 例如输入序列是一串文字: “我是中国人”, 输出序列是一串标签: “OOBII”, 其中"BIO"组成了一种中文分词的标签体系: B表示这个字是词的开始, I表示词的中间到结尾, O表示其他类型词. 因此我们可以根据输出序列"OOBII"进行解码, 得到分词结果"我\是\中国人"。
    序列标注问题涵盖了自然语言处理中的很多任务:包括语音识别, 中文分词, 机器翻译, 命名实体识别等, 而常见的序列标注模型包括HMM, CRF, RNN, LSTM, GRU等模型。
    其中在命名实体识别技术上, 目前主流的技术: 通过BiLSTM+CRF模型进行序列标注。
    
  • 模型架构(图1)

在这里插入图片描述

  • 使用BiLSTM+CRF模型架构实现NER任务,大致分为两个阶段:
    • 使用BiLSTM生成发射分数(标签向量)。
    • 基于发射分数使用CRF解码最优的标签路径。

2 CRF原理

  • CRF在NER任务中的作用:

    • 可以对标签序列之间起到约束左右
    • 核心特征:转移概率矩阵和发射概率矩阵(来自于BiLSTM模型)
  • CRF建模的损失函数

    • 简单概括:真实路径分数/所有路径分数之和
    • 具体公式:

在这里插入图片描述

  • 这个损失函数包含两部分:

    • 单条真实路径的分数𝑆𝑟𝑒𝑎𝑙和归一化项𝑙𝑜𝑔(𝑒𝑆1+𝑒𝑆2+…+𝑒𝑆𝑁),
    • 即将全部的路径分数进行𝑙𝑜𝑔_𝑠𝑢𝑚_𝑒𝑥𝑝操作,即先将每条路径分数𝑆𝑖进行𝑒𝑥𝑝(𝑆𝑖),然后再将所有的项加起来,最后取𝑙𝑜𝑔值。
  • 注意:

    • 使用前向算法计算所有路径分数。
    • 前向算法和穷举所有路径的算法在时间复杂度上的差异显著:假设序列长度为 n,每个位置可能有 k 个标签:
      • 穷举法:时间复杂度为 O(k^n)
      • 前向算法:时间复杂度为 O(n* k^2),
    • 使用维特比算法预测最优路径
      • 其复杂度和前向算法一样

3 BiLSTM+CRF项目完整实现

基本步骤:

  • 数据预处理
  • 构建模型
  • 模型训练和验证
  • 模型预测

数据预处理

构造序列标注数据
  • 需要对上述的origin数据进行处理,进行序列标注,选择标注方式:BIO,目的:对句子中的每个token都要标记上对应的标签,并统计所有tokens(去重后)并保存

    举例说明:

    • 未标注前:
    ['以', '咳', '嗽', ',', '咳', '痰', ',', '发', '热', '为', '主', '症', '。']
    
    • 标注后:
    ['O', 'B-SIGNS', 'I-SIGNS', 'O', 'B-SIGNS', 'I-SIGNS', 'O', 'B-SIGNS', 'I-SIGNS', 'O', 'O', 'O', 'O']
    
  • 构造序列标注数据

    • 代码路径:/MedicalKB/Ner/LSTM_CRF/utils/data_process.py
    • 在data_process.py脚本中,定义一个TransferData类,实现数据格式的转换
    • 具体代码:
    import json
    import os
    from collections import Counter
    os.chdir('..')
    cur = os.getcwd()
    print('当前数据处理默认工作目录:', cur)class TransferData():def __init__(self):self.label_dict = json.load(open(os.path.join(cur, 'data/labels.json')))self.seq_tag_dict = json.load(open(os.path.join(cur,'data/tag2id.json')))self.origin_path = os.path.join(cur, 'data_origin')self.train_filepath = os.path.join(cur, 'data/train.txt')def transfer(self):with open(self.train_filepath, 'w', encoding='utf-8') as fr:for root, dirs, files in os.walk(self.origin_path):for file in files:filepath = os.path.join(root, file)if 'original' not in filepath:continuelabel_filepath = filepath.replace('.txtoriginal','')print(filepath, '\t\t', label_filepath)res_dict = self.read_label_text(label_filepath)with open(filepath, 'r', encoding='utf-8')as f:content = f.read().strip()for indx, char in enumerate(content):char_label = res_dict.get(indx, 'O')fr.write(char + '\t' + char_label + '\n')def read_label_text(self, label_filepath):res_dict = {}for line in open(label_filepath, 'r', encoding='utf-8'):# line--》[右髋部\t21\t23\t身体部位]res = line.strip().split('\t')# res-->['右髋部', '21', '23', '身体部位']start = int(res[1])end = int(res[2])label = res[3]label_tag = self.label_dict.get(label)for i in range(start, end + 1):if i == start:tag = "B-" + label_tagelse:tag = "I-" + label_tagres_dict[i] = tagreturn res_dictif __name__ == '__main__':handler = TransferData()handler.transfer()

    经过数据转换后,我们会得到一个train.txt文档,此时,对于所有origin中的数据,都实现了BIO标注

    咳	B-SIGNS
    痰	I-SIGNS
    ,	O
    发	B-SIGNS
    热	I-SIGNS
    ,	O
    饮	O
    食	O
    、	O
    睡	O
    眠	O
    尚	O
    可	O
    

编写Config类项目文件配置代码
  • Config类文件路径为:/MedicalKB/Ner/LSTM_CRF/config.py

  • config文件目的: 配置项目常用变量,一般这些变量属于不经常改变的,比如: 训练文件路径、模型训练次数、模型超参数等等
import os
import torch
import jsonclass Config(object):def __init__(self):# 如果是windows或者linux电脑(使用GPU)# self.device = "cuda:0" if torch.cuda.is_available() else "cpu:0"# M1芯片及其以上的电脑(使用GPU)self.device = 'mps'self.train_path = '/MedicalKB/Ner/LSTM_CRF/data/train.txt'self.vocab_path = '/MedicalKB/Ner/LSTM_CRF/vocab/vocab.txt'self.embedding_dim = 300self.epochs = 5self.batch_size = 8self.hidden_dim = 256self.lr = 2e-3 # crf的时候,lr可以小点,比如1e-3self.dropout = 0.2self.model = "BiLSTM_CRF" # 可以只用"BiLSTM"self.tag2id = json.load(open('/MedicalKB/Ner/LSTM_CRF/data/tag2id.json'))if __name__ == '__main__':conf = Config()print(conf.train_path)print(conf.tag2id)

编写数据处理相关函数
  • 构造数据预处理函数,分为两步骤:
    • 1.构造样本x以及标签y数据对,以及获取vocabs
    • 2.构造数据迭代器

构造(x,y)样本对,以及获取vocabs
  • 因为在第二步构造序列标注数据时,没有对样本进行明确的分割,这里我们采用标点符号为分隔符,构造不同的(x, y)样本对
  • 代码实现:
    • 路径:/MedicalKB/Ner/LSTM_CRF/utils/common.py
from LSTM_CRF.config import *
conf = Config()'''构造数据集'''def build_data():datas = []sample_x = []sample_y = []vocab_list = ["PAD", 'UNK']for line in open(conf.train_path, 'r', encoding='utf-8'):line = line.rstrip().split('\t')if not line:continuechar = line[0]if not char:continuecate = line[-1]sample_x.append(char)sample_y.append(cate)if char not in vocab_list:vocab_list.append(char)if char in ['。', '?', '!', '!', '?']:datas.append([sample_x, sample_y])sample_x = []sample_y = []word2id = {wd: index for index, wd in enumerate(vocab_list)}write_file(vocab_list, conf.vocab_path)return datas, word2id'''保存字典文件'''def write_file(wordlist, filepath):with open(filepath, 'w', encoding='utf-8') as f:f.write('\n'.join(wordlist))if __name__ == '__main__':datas, word2id = build_data()print(len(datas))print(datas[:4])print(word2id)print(len(word2id))

构造数据迭代器
  • 代码路径:/MedicalKB/Ner/LSTM_CRF/utils/data_loader.py

  • 第一步:导入必备的工具包

    import json
    import torch
    from common import *
    from torch.utils.data import DataLoader, Dataset
    from torch.nn.utils.rnn import pad_sequencedatas, word2id = build_data()
    
  • 第二步:构建Dataset类

class NerDataset(Dataset):def __init__(self, datas):super().__init__()self.datas = datasdef __len__(self):return len(self.datas)def __getitem__(self, item):x = self.datas[item][0]y = self.datas[item][1]return x, y

  • 第三步:构建自定义函数collate_fn()
def collate_fn(batch):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]# 补齐input_ids, 使用0作为填充值input_ids_padded = pad_sequence(x_train, batch_first=True, padding_value=0)# # 补齐labels,注意如果使用了CRF,用0补齐,如果不用CRF,用-100补齐labels_padded = pad_sequence(y_train, batch_first=True, padding_value=-100)# 创建attention maskattention_mask = (input_ids_padded != 0).long()return input_ids_padded, labels_padded, attention_mask

  • 第四步:构建get_data函数,获得数据迭代器
def get_data():train_dataset = NerDataset(datas[:6200])train_dataloader = DataLoader(dataset=train_dataset,batch_size=conf.batch_size,collate_fn=collate_fn,drop_last=True,)dev_dataset = NerDataset(datas[6200:])dev_dataloader = DataLoader(dataset=dev_dataset,batch_size=conf.batch_size,collate_fn=collate_fn,drop_last=True,)return train_dataloader, dev_dataloaderif __name__ == '__main__':train_dataloader, dev_dataloader = get_data()for input_ids_padded, labels_padded, attention_mask in train_dataloader:print(input_ids_padded.shape)print(labels_padded.shape)print(attention_mask.shape)break

BiLSTM+CRF模型搭建

  • 构建BiLSTM模型
  • 代码路径: /MedicalKB/Ner/LSTM_CRF/model/BiLSTM.py
import torch
import torch.nn as nnclass NERLSTM(nn.Module):def __init__(self, embedding_dim, hidden_dim, dropout, word2id, tag2id):super(NERLSTM, self).__init__()self.name = "BiLSTM"self.embedding_dim = embedding_dimself.hidden_dim = hidden_dimself.vocab_size = len(word2id) + 1self.tag_to_ix = tag2idself.tag_size = len(tag2id)self.word_embeds = nn.Embedding(self.vocab_size, self.embedding_dim)self.dropout = nn.Dropout(dropout)self.lstm = nn.LSTM(self.embedding_dim, self.hidden_dim // 2,bidirectional=True, batch_first=True)self.hidden2tag = nn.Linear(self.hidden_dim, self.tag_size)def forward(self, x, mask):embedding = self.word_embeds(x)outputs, hidden = self.lstm(embedding)outputs = outputs * mask.unsqueeze(-1)  # 仅保留有效位置的输出outputs = self.dropout(outputs)outputs = self.hidden2tag(outputs)return outputs

  • 构建BiLSTM_CRF模型类
  • 代码路径: /MedicalKB/Ner/LSTM_CRF/model/BiLSTM_CRF.py
import torch
import torch.nn as nn
from TorchCRF import CRFclass NERLSTM_CRF(nn.Module):def __init__(self, embedding_dim, hidden_dim, dropout, word2id, tag2id):super(NERLSTM_CRF, self).__init__()self.name = "BiLSTM_CRF"self.embedding_dim = embedding_dimself.hidden_dim = hidden_dimself.vocab_size = len(word2id) + 1self.tag_to_ix = tag2idself.tag_size = len(tag2id)self.word_embeds = nn.Embedding(self.vocab_size, self.embedding_dim)self.dropout = nn.Dropout(dropout)#CRFself.lstm = nn.LSTM(self.embedding_dim, self.hidden_dim // 2,bidirectional=True, batch_first=True)self.hidden2tag = nn.Linear(self.hidden_dim, self.tag_size)self.crf = CRF(self.tag_size)def forward(self, x, mask):#lstm模型得到的结果outputs = self.get_lstm2linear(x)outputs = outputs * mask.unsqueeze(-1)outputs = self.crf.viterbi_decode(outputs, mask)return outputsdef log_likelihood(self, x, tags, mask):# lstm模型得到的结果outputs = self.get_lstm2linear(x)outputs = outputs * mask.unsqueeze(-1)# 计算损失return - self.crf(outputs, tags, mask)def get_lstm2linear(self, x):embedding = self.word_embeds(x)outputs, hidden = self.lstm(embedding)outputs = self.dropout(outputs)outputs = self.hidden2tag(outputs)return outputs

编写训练函数

  • 实现训练函数train.py
  • 代码位置:/MedicalKB/Ner/LSTM_CRF/trian.py
  • 第一步:导入必备工具包
import torch
import torch.nn as nn
import torch.optim as optim
from model.BiLSTM import *
from model.BiLSTM_CRF import *
from utils.data_loader import *
from  tqdm import tqdm
# classification_report可以导出字典格式,修改参数:output_dict=True,可以将字典在保存为csv格式输出
from sklearn.metrics import precision_score, recall_score, f1_score, classification_report
from config import *
conf = Config()

  • 第二步:实现模型训练函数的搭建:mode2trian()
def model2train():# 获取数据train_dataloader, dev_dataloader = get_data()# 实例化模型models = {'BiLSTM': NERLSTM,'BiLSTM_CRF': NERLSTM_CRF}model = models[conf.model](conf.embedding_dim, conf.hidden_dim, conf.dropout, word2id, conf.tag2id)model = model.to(conf.device)# 实例化损失函数criterion = nn.CrossEntropyLoss()# 实例化优化器optimizer = optim.Adam(model.parameters(), lr=conf.lr)# 选择模型进行训练start_time = time.time()if conf.model == 'BiLSTM':f1_score = -1000for epoch in range(conf.epochs):model.train()for index, (inputs, labels, mask) in enumerate(tqdm(train_dataloader, desc='BiLSTM训练')):x = inputs.to(conf.device)mask = mask.to(conf.device)y = labels.to(conf.device)pred = model(x, mask)pred = pred.view(-1, len(conf.tag2id))my_loss = criterion(pred, y.view(-1))optimizer.zero_grad()my_loss.backward()optimizer.step()if index % 200 == 0:print('epoch:%04d,------------loss:%f' % (epoch, my_loss.item()))precision, recall, f1, report = model2dev(dev_dataloader, model, criterion)if f1 > f1_score:f1_score = f1torch.save(model.state_dict(), 'save_model/bilstm_best.pth')print(report)end_time = time.time()print(f'训练总耗时:{end_time - start_time}')elif conf.model == 'BiLSTM_CRF':f1_score = -1000for epoch in range(conf.epochs):model.train()for index, (inputs, labels, mask)in enumerate(tqdm(train_dataloader, desc='bilstm+crf训练')):x = inputs.to(conf.device)mask = mask.to(torch.bool).to(conf.device)tags = labels.to(conf.device)# CRFloss = model.log_likelihood(x, tags, mask).mean()optimizer.zero_grad()loss.backward()# CRFtorch.nn.utils.clip_grad_norm_(parameters=model.parameters(), max_norm=10)optimizer.step()if index % 200 == 0:print('epoch:%04d,------------loss:%f' % (epoch, loss.item()))precision, recall, f1, report = model2dev(dev_dataloader, model)if f1 > f1_score:f1_score = f1torch.save(model.state_dict(), 'save_model/bilstm_crf_best.pth')print(report)end_time = time.time()print(f'训练总耗时:{end_time-start_time}')
  • 第三步:实现模型验证函数的搭建:model2dev()
def model2dev(dev_iter, model, criterion=None):aver_loss = 0preds, golds = [], []model.eval()for index, (inputs, labels, mask) in enumerate(tqdm(dev_iter, desc="测试集验证")):val_x = inputs.to(conf.device)mask = mask.to(conf.device)val_y = labels.to(conf.device)predict = []if model.name == "BiLSTM":pred = model(val_x, mask)predict = torch.argmax(pred, dim=-1).tolist()pred = pred.view(-1, len(conf.tag2id))val_loss = criterion(pred, val_y.view(-1))aver_loss += val_loss.item()elif model.name == "BiLSTM_CRF":mask = mask.to(torch.bool)predict = model(val_x, mask)loss = model.log_likelihood(val_x, val_y, mask)aver_loss += loss.mean().item()# 统计非0的,也就是真实标签的长度leng = []for i in val_y.cpu():tmp = []for j in i:if j.item() > 0:tmp.append(j.item())leng.append(tmp)# 提取真实长度的预测标签for index, i in enumerate(predict):preds.extend(i[:len(leng[index])])# 提取真实长度的真实标签for index, i in enumerate(val_y.tolist()):golds.extend(i[:len(leng[index])])aver_loss /= (len(dev_iter) * 64)precision = precision_score(golds, preds, average='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
  • 模型训练效果:

  • BiLSTM:训练5轮,总共耗时:105s


  • BiLSTM+CRF:训练5轮,总共耗时:831s

  • 整体对比:BiLSTM+CRF虽然比BiLSTM速度慢,但是精度有所提升,这个效果随着数据量的增加还会进一步提升。

编写模型预测函数

  • 使用训练好的模型,随机抽取文本进行NER
  • 代码位置: /MedicalKB/Ner/LSTM_CRF/ner_predict.py
  • 第一步:导入必备的工具包
import torch.nn as nn
import torch.optim as optim
from model.BiLSTM import *
from model.BiLSTM_CRF import *
from utils.data_loader import *
from tqdm import tqdm# 实例化模型
models = {'BiLSTM': NERLSTM,'BiLSTM_CRF': NERLSTM_CRF}
model = models["BiLSTM_CRF"](conf.embedding_dim, conf.hidden_dim, conf.dropout, word2id, conf.tag2id)
model.load_state_dict(torch.load('save_model/bilstm_crf_best.pth'))id2tag = {value: key for key, value in conf.tag2id.items()}

  • 第二步:实现模型预测函数:model2test
def model2test(sample):x = []for char in sample:if char not in word2id:char = "UNK"x.append(word2id[char])x_train = torch.tensor([x])mask = (x_train != 0).long()model.eval()with torch.no_grad():if model.name =="BiLSTM":outputs = model(x_train, mask)preds_ids = torch.argmax(outputs,dim=-1)[0]tags = [id2tag[i.item()] for i in preds_ids]else:preds_ids = model(x_train, mask)tags = [id2tag[i] for i in preds_ids[0]]chars = [i for i in sample]assert len(chars) == len(tags)result = extract_entities(chars, tags)return resultif __name__ == '__main__':result = model2test(sample='小明的父亲患有冠心病及糖尿病,无手术外伤史及药物过敏史')print(result)

  • 第三步:实现实体解析函数:extract_entities
def extract_entities(tokens, labels):entities = []entity = []entity_type = Nonefor token, label in zip(tokens, labels):if label.startswith("B-"):  # 实体的开始if entity:  # 如果已经有实体,先保存entities.append((entity_type, ''.join(entity)))entity = []entity_type = label.split('-')[1]entity.append(token)elif label.startswith("I-") and entity:  # 实体的中间或结尾entity.append(token)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}

http://www.lryc.cn/news/609238.html

相关文章:

  • JavaWeb学习------SpringCloud入门
  • 最小半径覆盖问题【C++解法+二分+扫描线】
  • 研报复现|史蒂夫·路佛价值选股法则
  • [硬件电路-138]:模拟电路 - 什么是正电源?什么是负电源?集成运放为什么有VCC+和VCC-
  • 【RH124 问答题】第 8 章 监控和管理 Linux 进程
  • MySQL学习之MVCC多版本并发控制
  • 浅谈Python中的os.environ:环境变量交互机制
  • [硬件电路-141]:模拟电路 - 源电路,信号源与电源,能自己产生确定性波形的电路。
  • IO流-数据流
  • LLM的训练:RLHF及其替代方案
  • 2025年6月电子学会青少年软件编程(C语言)等级考试试卷(七级)
  • 当Windows远程桌面出现“身份验证错误。要求的函数不受支持”的问题
  • 电机结构设计与特性曲线分析:基于MATLAB和FEMM的仿真研究
  • 【软考中级网络工程师】知识点之 IS-IS 协议
  • AI Agent 重塑产业发展新格局
  • SpringAI的使用
  • 图像张量中的通道维度
  • 【C 学习】04.1-数字化基础
  • Spring Boot 整合 Minio 实现高效文件存储解决方案(本地和线上)
  • Monaco Editor 开发流程详解
  • Flutter Dart类的使用
  • Redisson高并发实战:守护Netty IO线程的关键指南
  • 一加Ace5无法连接ColorOS助手解决(安卓设备ADB模式无法连接)
  • 【MySQL】MySQL 中的数据排序是怎么实现的?
  • FreeRTOS源码分析三:列表数据结构
  • 深度学习-读写模型网络文件
  • 03.一键编译安装Redis脚本
  • 07.config 命令实现动态修改配置和慢查询
  • ThinkPHP8.x控制器和模型的使用方法
  • VUE-第二季-01