命名实体识别(Named Entity Recognition,简称 NER)是自然语言处理(NLP)中的一项重要任务,旨在从非结构化文本中识别并分类具有特定意义的实体,如人名、地名、机构名、时间表达、数量、货币和百分比等。
1. 数据处理
我们使用的是 MSRA 中文 NER 数据集,该数据集共包含三个目录:test、valid、train,分别对应了测试集、验证集、训练集。训练集有:42000 条数据,验证集有 3000 条数据,测试集有 3442 条数据。 另外也包括了一个定义了预测标签的 tags.txt 文件,数据集使用的是 BIO 的标签体系,即:O、B-PER、I-PER、B-ORG、I-ORG、B-LOC、I-LOC,目录结构为:
msra ├── tags.txt ├── test │ ├── sentences.txt │ └── tags.txt ├── train │ ├── sentences.txt │ └── tags.txt └── valid ├── sentences.txt └── tags.txt
数据集链接:https://www.aliyundrive.com/s/HkNk51zog6gi 提取码: 60oq
import pandas as pd import torch import pickle import os from datasets import Dataset, DatasetDict from transformers import BertTokenizer import datasets import transformers # 显示进度条 datasets.disable_progress_bar() # 设置日志级别 datasets.logging.set_verbosity(datasets.logging.CRITICAL) transformers.logging.set_verbosity(transformers.logging.CRITICAL) # 修改工作目录 os.chdir(os.path.dirname(os.path.abspath(__file__)))
我们将 valid、train 数据集合并为 train 数据集。由于我们使用的是 Bert 模型来完成 NER 任务,而 Bert 模型对输入有 max_len 限制。所以,我们得把长度大于 505 的数据过滤掉。当然这里的 505 可以修改,只要总长度不超过 512 就可以。最终,我们会生成两个 csv 文件,01-训练集.csv 和 02-测试集.csv 分别存储训练集和测试集数据。
# 加载训练集 def load_train_corpus(): train_path = ['msra/train/sentences.txt', 'msra/train/tags.txt'] valid_path = ['msra/valid/sentences.txt', 'msra/valid/tags.txt'] data_path = [train_path, valid_path] data_inputs, data_labels = [], [] max_len = 0 for x_path, y_path in data_path: for data_input, data_label in zip(open(x_path), open(y_path)): data_input = data_input.split() data_label = data_label.split() if len(data_input) > max_len: max_len = len(data_input) if len(data_input) > 505: continue if len(data_input) != len(data_label): continue data_labels.append(' '.join(data_label)) data_inputs.append(' '.join(data_input)) # 数据存储 train_data = pd.DataFrame() train_data['data_inputs'] = data_inputs train_data['data_labels'] = data_labels train_data.to_csv('data/01-训练集.csv') print('训练集数据量:', len(train_data), '最大句子长度:', max_len) # 加载测试集 def load_valid_corpus(): x_path = 'msra/test/sentences.txt' y_path = 'msra/test/tags.txt' data_inputs, data_labels = [], [] max_len = 0 for data_input, data_label in zip(open(x_path), open(y_path)): data_input = data_input.split() data_label = data_label.split() if len(data_input) > max_len: max_len = len(data_input) if len(data_input) > 505: continue if len(data_input) != len(data_label): continue data_labels.append(' '.join(data_label)) data_inputs.append(' '.join(data_input)) # 数据存储 valid_data = pd.DataFrame() valid_data['data_inputs'] = data_inputs valid_data['data_labels'] = data_labels valid_data.to_csv('data/02-测试集.csv') print('测试集数据量:', len(valid_data), '最大句子长度:', max_len)
程序的输出结果:
训练集数据量: 44968 最大句子长度: 746 测试集数据量: 3438 最大句子长度: 2427
这里要做的事情就是将 O、B-LOC、I-PER…等标签映射为数字索引表示。转换完成之后,存储到 data/03-train,具体实现代码如下:
def data_handler(data_labels_batch, data_inputs): labels = [] for data_labels in data_labels_batch: label = [] for data_label in data_labels.split(): label.append(label_to_index[data_label]) labels.append(label) return {'data_label_ids': labels, 'data_inputs': data_inputs} if __name__ == '__main__': load_train_corpus() load_valid_corpus() train_data = pd.read_csv('data/01-训练集.csv') valid_data = pd.read_csv('data/02-测试集.csv') train_data = Dataset.from_pandas(train_data) valid_data = Dataset.from_pandas(valid_data) # 由于在 centos 里 load_dataset 总是报错,所以按照以下步骤加载 # 1. 先使用 pd.read_csv 从 csv 文件加载数据 # 2. 再使用 Dataset.from_pandas 从 DataFram 加载数据 # 3. 最后使用 DatasetDict 构建包含训练集和测试集的数据对象 data = DatasetDict({'train': train_data, 'valid': valid_data}) # input_columns: 指定传递到 data_handler 中的列,以位置参数的形式传递 # fn_kwargs: 指定传递到 data_handler 中的自定义关键字参数 train_data = data.map(data_handler, input_columns=['data_labels', 'data_inputs'], batched=True, batch_size=1000) train_data.save_to_disk('data/03-train')
2. 模型训练
我们使用 Bert 的 bert-base-chinese 为基础模型,在我们自己的数据集上进行微调来实现 NER 任务。我们这里使用的是 transformers 库中提供的 BertForTokenClassification 类来进行微调,该 head 非常适合字词粒度的分类任务。
import datasets from transformers import BertModel from transformers import BertConfig from transformers import BertTokenizer from datasets import load_from_disk from transformers import BertForMaskedLM from transformers import BertForTokenClassification import torch from torch.nn.utils.rnn import pad_sequence import torch.optim as optim import math import numpy as np
由于数据集有 4 万+,训练时间较长,我们这里我们使用累计多个 step 的损失之后,再更新参数,训练效率得到明显提升。训练的轮数为 40,也可以改成其他值。存储模型时,从 11 epoch 开始,每一个 epoch 结束就存储一次。
另外,由于我们希望模型对象帮我们去自动计算损失,所以,在调用 model.forward 函数时,可以传递 labels 参数,但要注意的是,我们传递的 labels 参数必须为 tensor 类型,它要求每个数据长度必须一样。所以,可以使用 pad_sequence 对 labels 填充下,模型内部会使用 attention_mask 屏蔽填充这部分标签值的。
受限于显存大小,我这里最高只能设置 batch_size 为 4,没迭代 8 次,即: 累计 8 次梯度进行一次参数更新。
device = torch.device('cuda' if torch.cuda.is_available() else 'cpu') def train(): # 读取训练数据 train_data = load_from_disk('data/03-train')['train'] # 初始化分词器 tokenizer = BertTokenizer.from_pretrained('bert-base-chinese') # 初始化模型 model = BertForTokenClassification.from_pretrained('bert-base-chinese', num_labels=7) model.to(device) # 优化器 optimizer = optim.AdamW(model.parameters(), lr=3e-5) # 训练批次 batch_size = 4 # 训练轮数 epoch_num = 40 # 累计梯度 accumulate_step = 0 total_steps = int(math.ceil(len(train_data) / batch_size)) def start_train(data_inputs, data_label_ids): data_inputs = tokenizer.batch_encode_plus(data_inputs, add_special_tokens=False, padding='longest', return_tensors='pt') data_inputs['input_ids'] = data_inputs['input_ids'].to(device) data_inputs['attention_mask'] = data_inputs['attention_mask'].to(device) data_inputs['token_type_ids'] = data_inputs['token_type_ids'].to(device) # 标签转换张量 data_labels = [] for labels in data_label_ids: data_labels.append(torch.tensor(labels, dtype=torch.int64, device=device)) labels = pad_sequence(data_labels, batch_first=True, padding_value=-1) # 模型计算 outputs = model(**data_inputs, labels=labels) # 反向传播 loss = outputs.loss loss.backward() nonlocal accumulate_step accumulate_step += 1 if accumulate_step % 8 == 0 or accumulate_step == total_steps: # 参数更新 optimizer.step() # 梯度清零 optimizer.zero_grad() nonlocal epoch_loss epoch_loss += loss.item() for epoch_idx in range(epoch_num): epoch_loss = 0.0 train_data.map(start_train, batched=True, batch_size=batch_size, input_columns=['data_inputs', 'data_label_ids']) print('loss: %.5f' % epoch_loss) if epoch_idx > 10: model.save_pretrained('data/ner-model-%d' % epoch_idx)
训练结束后得到的模型文件如下:
ner-model-12 ner-model-17 ner-model-22 ner-model-27 ner-model-32 ner-model-37 ner-model-13 ner-model-18 ner-model-23 ner-model-28 ner-model-33 ner-model-38 ner-model-14 ner-model-19 ner-model-24 ner-model-29 ner-model-34 ner-model-39 ner-model-15 ner-model-20 ner-model-25 ner-model-30 ner-model-35 ner-model-11 ner-model-16 ner-model-21 ner-model-26 ner-model-31 ner-model-36
每个模型目录下的内容为:
ner-model-11 ├── config.json └── pytorch_model.bin
3. 模型评估
模型评估主要做的事情,提取测试集所有的实体名称,并划分为 ORG、PER、LOC 类别。分别统计每个类别的精度、召回率,以及准确率。
我编写了 extract_decode 函数,该函数接收一个句子,以及该句子中每个字对应的实体标签。从而,提取出该剧中标识的所有实体,并分类存储。
def extract_decode(label_list, text): """ :param label_list: 模型输出的包含标签序列的一维列表 :param text: 模型输入的句子 :return: 提取到的实体名字 """ labels = ['O', 'B-ORG', 'I-ORG', 'B-PER', 'I-PER', 'B-LOC', 'I-LOC'] label_to_index = {label: index for index, label in enumerate(labels)} B_ORG, I_ORG = label_to_index['B-ORG'], label_to_index['I-ORG'] B_PER, I_PER = label_to_index['B-PER'], label_to_index['I-PER'] B_LOC, I_LOC = label_to_index['B-LOC'], label_to_index['I-LOC'] # 提取连续的标签代表的实体 def extract_word(start_index, next_label): # index 表示最后索引的位置 index, entity = start_index + 1, [text[start_index]] for index in range(start_index + 1, len(label_list)): if label_list[index] != next_label: break entity.append(text[index]) return index, ''.join(entity) # 存储提取的命名实体 extract_entites, index = {'ORG': [], 'PER': [], 'LOC': []}, 0 # 映射下一个持续的标签 next_label = {B_ORG: I_ORG, B_PER: I_PER, B_LOC: I_LOC} # 映射词的所属类别 word_class = {B_ORG: 'ORG', B_PER: 'PER', B_LOC: 'LOC'} while index < len(label_list): # 获得当前位置的标签 label = label_list[index] if label in next_label.keys(): # 将当前位置和对应的下一个持续标签传递到 extract_word 函数 index, word = extract_word(index, next_label[label]) extract_entites[word_class[label]].append(word) continue index += 1 return extract_entites
评估函数 evaluate 主要做三件事。首先,计算提取测试集中所有的实体名称;然后,由模型对测试集数据进行预测,从而得到模型预测出的实体标签。最后,根据模型预测的结果和真实结果计算不同类别的精度、召回率,例如:计算模型对 LOC 位置实体的预测的精度、召回率。
前面,我们训练出了多个模型,我们这里分别对每个模型都进行评估。部分评估的结果如下:
# ner-model-12 这个模型在测试集上的评估结果 ORG 查全率: 0.929 ORG 查准率: 0.844 -------------------------------------------------- PER 查全率: 0.973 PER 查准率: 0.936 -------------------------------------------------- LOC 查全率: 0.933 LOC 查准率: 0.902 -------------------------------------------------- 准确率: 0.942 # ner-model-37 这个模型在测试集上的评估结果 ORG 查全率: 0.924 ORG 查准率: 0.814 -------------------------------------------------- PER 查全率: 0.961 PER 查准率: 0.926 -------------------------------------------------- LOC 查全率: 0.924 LOC 查准率: 0.894 -------------------------------------------------- 准确率: 0.933 ...等等
从评估结果可以看到,不同的模型在 ORG、PER、LOC 上的精度、召回率都是不同的。这也可以简单理解,不同的模型在对具体的类别的命名实体识别时,性能是不同的。下面是完整的评估代码:
def evaluate(): # 读取测试数据 valid_data = load_from_disk('data/03-train')['valid_data'] # 1. 计算各个不同类别总实体数量 # 计算测试集实体数量 total_entities = {'ORG': [], 'PER': [], 'LOC': []} def calculate_handler(data_inputs, data_label_ids): # 将 data_inputs 转换为没有空格隔开的句子 text = ''.join(data_inputs.split()) label_list = data_label_ids # 提取句子中的实体 extract_entities = extract_decode(data_label_ids, text) # 统计每种实体的数量 nonlocal total_entities for key, value in extract_entities.items(): total_entities[key].extend(value) # 统计不同实体的数量 valid_data.map(calculate_handler, input_columns=['data_inputs', 'data_label_ids']) print(total_entities) # 2. 计算模型预测的各个类别实体数量 # 初始化分词器 tokenizer = BertTokenizer.from_pretrained('bert-base-chinese') for index in range(11, 38): # 初始化模型 model = BertForTokenClassification.from_pretrained('data/ner-model-%d' % index, num_labels=7) model.train(mode=False) model_entities = {'ORG': [], 'PER': [], 'LOC': []} def start_evaluate(data_inputs): # 对输入文本进行分词 model_inputs = tokenizer(data_inputs, add_special_tokens=False, return_tensors='pt') # 文本送入模型进行计算 with torch.no_grad(): outputs = model(**model_inputs) # 统计预测的实体数量 label_list = torch.argmax(outputs.logits.squeeze(), dim=-1).tolist() text = ''.join(data_inputs.split()) # 从预测结果提取实体名字 extract_entities = extract_decode(label_list, text) nonlocal model_entities for key, value in extract_entities.items(): model_entities[key].extend(value) # 统计预测不同实体的数量 valid_data.map(start_evaluate, input_columns=['data_inputs'], batched=False) print(model_entities) # 3. 统计每个类别的召回率 print('#%d\n' % index) total_pred_correct = 0 total_true_correct = 0 for key in total_entities.keys(): # 获得当前 key 类别真实和模型预测实体列表 true_entities = total_entities[key] true_entities_num = len(true_entities) pred_entities = model_entities[key] # 分解预测实体中,pred_correct 表示预测正确,pred_incorrect 表示预测错误 pred_correct, pred_incorrect = 0, 0 for pred_entity in pred_entities: if pred_entity in true_entities: pred_correct += 1 continue pred_incorrect += 1 # 模型预测的 key 类别的实体数量 model_pred_key_num = true_entities_num + pred_incorrect # 计算共预测正确多少个实体 total_pred_correct += pred_correct # 计算共有多少个真实的实体 total_true_correct += true_entities_num # 计算精度 print(key, '查全率: %.3f' % (pred_correct / true_entities_num)) print(key, '查准率: %.3f' % (pred_correct / model_pred_key_num)) print('-' * 50) print('准确率: %.3f' % (total_pred_correct / total_true_correct))
4. 预测函数
预测函数就比较简单了,就是传递一个句子,抽取句子中的实体。实现代码如下:
def entity_extract(text): # 初始化分词器 tokenizer = BertTokenizer.from_pretrained('bert-base-chinese') # 初始化模型 model = BertForTokenClassification.from_pretrained('data/ner-model-39', num_labels=7) model.train(False) # 我们先按字将其分开,并在字之间添加空格,便于 Bert 分词器能够准确按字分割 input_text = ' '.join(list(text)) print(text) inputs = tokenizer.encode_plus(input_text, add_special_tokens=False, return_tensors='pt') with torch.no_grad(): outputs = model(**inputs) y_pred = torch.argmax(outputs.logits, dim=-1)[0].tolist() return extract_decode(y_pred, text) if __name__ == '__main__': text = '今年7月1日我国政府恢复对香港行使主权,标志着“一国两制”构想的巨大成功,标志着中国人民在祖国统一大业的道路上迈出了重要的一步。' result = entity_extract(text) print(result) text = '同时,三毛集团自身也快速扩张,企业新创造了3000多个就业岗位,安置了一大批下岗职工。' result = entity_extract(text) print(result) text = '我要感谢洛杉矶市民议政论坛、亚洲协会南加中心、美中关系全国委员会、美中友协美西分会等友好团体的盛情款待。' result = entity_extract(text) print(result)
程序运行结果如下:
今年7月1日我国政府恢复对香港行使主权,标志着“一国两制”构想的巨大成功,标志着中国人民在祖国统一大业的道路上迈出了重要的一步。 {'ORG': [], 'PER': [], 'LOC': ['香港', '中国']} 同时,三毛集团自身也快速扩张,企业新创造了3000多个就业岗位,安置了一大批下岗职工。 {'ORG': ['三毛集团'], 'PER': [], 'LOC': []} 我要感谢洛杉矶市民议政论坛、亚洲协会南加中心、美中关系全国委员会、美中友协美西分会等友好团体的盛情款待。 {'ORG': ['亚洲协会南加中心', '美中关系全国委员会', '美中友协美西分会'], 'PER': [], 'LOC': ['洛杉矶']}