基于 Bert 实现 NER 任务

命名实体识别(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': ['洛杉矶']}
未经允许不得转载:一亩三分地 » 基于 Bert 实现 NER 任务