使用Bert进行中文NER命名实体识别feat.fastNLP(上:模型篇)

本文最后更新于:2023年3月25日 晚上

前言

项目工程地址:https://github.com/Ash-one/ChineseBert-finetuned-NER

chatGPT的大火让很多NLP工作者的研究都陷入僵局,NER这种传统任务对于这种LLM已经可以说是小菜一碟。

NewBing进行ner

虽然没能力搞个GPT出来,搞个简单的Bert微调还是可以做到的。

本文对于NER命名实体识别任务,使用复旦大学的fastNLP工具包快速完成Bert微调和预测任务,还实现了Bert+BiLSTM+CRF的模型提高预测准确率,最终部署在服务器上可视化呈现。

结果展示

fastNLP文档gitee仓库最近更新在五个月前,还比较活跃。fastNLP在手册上有序列标注的源码实现,本文基于此进行改写。

数据预处理

使用内置的dataloader加载Weibo数据集

fastNLP自带库中有许多内置数据集,这里选择微博数据集展示,其实体类别分为人物,机构组织,地址和地缘政治实体四个类别,且每个类别可细分为特指(NAM,如“张三”标签为“PER.NAM”)和泛指(NOM,如“男人”标签为“PER.NOM”)。总数据量1890条。

含义 标签
地区名特指,如深圳 B-GPE.NAM I-GPE.NAM
地名特指,如华克山庄 B-LOC.NAM I-LOC.NAM
地名泛指,如寺庙 B-LOC.NOM I-LOC.NOM
组织名特指 B-ORG.NAM I-ORG.NAM
组织名泛指 B-ORG.NOM I-ORG.NOM
人名特指,如方进玉 B-PER.NAM I-PER.NAM
人名泛指,如男人 B-PER.NOM I-PER.NOM
其他 O

17个标签的分布如图所示:

数据集标签总览

接下来开始使用fastNLP库的loader下载并加载数据到data_bundle中。

1
2
3
4
from fastNLP.io import WeiboNERLoader
data_bundle = WeiboNERLoader().load()
print(data_bundle)
print(data_bundle.get_dataset('train')[:4])

data_bundle和它的名字一样,是训练集、验证集、测试集的打包,需要分别提取。

1
2
3
4
5
6
7
8
9
10
11
12
13
In total 3 datasets:
dev has 270 instances.
test has 270 instances.
train has 1350 instances.

+------------------------------------------+------------------------------------------+
| raw_chars | target |
+------------------------------------------+------------------------------------------+
| ['科', '技', '全', '方', '位', '资', ... | ['O', 'O', 'O', 'O', 'O', 'O', 'O', '... |
| ['', '', '', '', '', '', ... | ['O', 'O', 'O', 'O', 'O', 'O', 'B-PER... |
| ['今', '天', '下', '午', '起', '来', ... | ['O', 'O', 'O', 'O', 'O', 'O', 'O', '... |
| ['', '', '', '', '', '', ... | ['O', 'O', 'O', 'O', 'O', 'O', 'O', '... |
+------------------------------------------+------------------------------------------+

计算数据集中的属性

使用BertTokenizer和BPE算法处理文本,得到input_ids|input_len | first| seq_len | new_target这几列,其中first表示bpe算法的结果,在后面的模型中会使用,这里使用new_target作为最终label的编码表示。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
from fastNLP.transformers.torch import BertTokenizer
from fastNLP import cache_results, Vocabulary

def process_data(data_bundle, model_name):
tokenizer = BertTokenizer.from_pretrained(model_name)
def bpe(raw_words):
bpes = [tokenizer.cls_token_id]
first = [0]
first_index = 1 # 记录第一个bpe的位置
for word in raw_words:
bpe = tokenizer.encode(word, add_special_tokens=False)
bpes.extend(bpe)
first.append(first_index)
first_index += len(bpe)
bpes.append(tokenizer.sep_token_id)
first.append(first_index)
return {'input_ids': bpes, 'input_len': len(bpes), 'first': first, 'seq_len': len(raw_words)}
# 对data_bundle中每个dataset的每一条数据中的raw_chars使用bpe函数,并且将返回的结果加入到每条数据中。
data_bundle.apply_field_more(bpe, field_name='raw_chars', num_proc=4)
# --------------------------------------------这里需要注意field_name
# tag的词表,由于这是词表,所以不需要有padding和unk
tag_vocab = Vocabulary(padding=None, unknown=None)
# 从 train 数据的 raw_target 中获取建立词表
tag_vocab.from_dataset(data_bundle.get_dataset('train'), field_name='target')
# 使用词表将每个 dataset 中的target转为数字,并且将写入到new_target这个field中
tag_vocab.index_dataset(data_bundle.datasets.values(), field_name='target', new_field_name='new_target')

# 可以将 vocabulary 绑定到 data_bundle 上,方便之后使用。
data_bundle.set_vocab(tag_vocab, field_name='new_target')

return data_bundle, tokenizer

data_bundle, tokenizer = process_data(data_bundle, 'hfl/rbt3')
print(data_bundle)
print(data_bundle.get_dataset("train")[:4])

可以看出input_lenseq_len多两个,分别是一头一尾两个token。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
[08:23:40 AM] INFO     In total 3 datasets:                                  1356482314.py:35
dev has 270 instances.
test has 270 instances.
train has 1350 instances.
In total 1 vocabs:
new_target has 17 entries.

INFO +----------------+----------------+----------------+- 1356482314.py:36
----------+----------------+---------+---------------
-+
| raw_chars | target | input_ids |
input_len | first | seq_len | new_target
|
+----------------+----------------+----------------+-
----------+----------------+---------+---------------
-+
| ['科', '技'... | ['O', 'O', ... | [101, 4906,... |
28 | [0, 1, 2, 3... | 26 | [0, 0, 0, 0...
|
| ['对', ','... | ['O', 'O', ... | [101, 2190,... |
17 | [0, 1, 2, 3... | 15 | [0, 0, 0, 0...
|
| ['今', '天'... | ['O', 'O', ... | [101, 791, ... |
81 | [0, 1, 2, 3... | 79 | [0, 0, 0, 0...
|
| ['今', '年'... | ['O', 'O', ... | [101, 791, ... |
20 | [0, 1, 2, 3... | 18 | [0, 0, 0, 0...
|
+----------------+----------------+----------------+-
----------+----------------+---------+---------------
-+

将计算后的数据放入dataloader

制作dataloader方便遍历,bs大小256大约占用20G显存。

对输入和输出分别进行padding

1
2
3
4
5
6
7
8
from fastNLP import prepare_torch_dataloader

dataloaders = prepare_torch_dataloader(data_bundle, batch_size=256)

for dl in dataloaders.values():
# 可以通过 set_pad 修改 padding 的行为。
dl.set_pad('input_ids', pad_val=tokenizer.pad_token_id)
dl.set_pad('new_target', pad_val=-100)

Bert模型微调

Bert模型的提供者们往往提供的是预训练模型,我们可以在预训练模型之后添加其他模型或层来改变输出,而将Bert的部分当作对文字编码的部分使用。这里后接MLP作为NER任务的baseline做参考,然后在Bert后添加BiLSTM和CRF进行对比。

设计Bert+MLP模型

fastNLP的模型基于torch,所以写法是一样的;同时内置的transformer库也是huggingface的直接迁移,用法上完全一样,只是默认的模型加载路径和huggingface的transformer库不一样,如果同时使用可能会发现缓存位置不同而重复下载。

  • 这里使用hfl/rbt3这个中文Bert模型,大小约500M,BertModel.from_pretrained()函数会从远程拉取该模型保存在缓存后加载(复旦源,国内很快)。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
import torch
from torch import nn
from fastNLP.transformers.torch import BertModel
from fastNLP import seq_len_to_mask
import torch.nn.functional as F

class BertNER(nn.Module):
def __init__(self, model_name, num_class):
super().__init__()
self.bert = BertModel.from_pretrained(model_name)
self.mlp = nn.Sequential(nn.Linear(self.bert.config.hidden_size, self.bert.config.hidden_size),
nn.Dropout(0.3),
nn.Linear(self.bert.config.hidden_size, num_class))

def forward(self, input_ids, input_len, first):
attention_mask = seq_len_to_mask(input_len)
outputs = self.bert(input_ids=input_ids, attention_mask=attention_mask)
last_hidden_state = outputs.last_hidden_state
first = first.unsqueeze(-1).repeat(1, 1, last_hidden_state.size(-1))
first_bpe_state = last_hidden_state.gather(dim=1, index=first)
first_bpe_state = first_bpe_state[:, 1:-1] # 删除 cls 和 sep

pred = self.mlp(first_bpe_state)
return {'pred': pred}

def train_step(self, input_ids, input_len, first, target):
pred = self(input_ids, input_len, first)['pred']
loss = F.cross_entropy(pred.transpose(1, 2), target)
return {'loss': loss}

def evaluate_step(self, input_ids, input_len, first):
pred = self(input_ids, input_len, first)['pred'].argmax(dim=-1)
return {'pred': pred}

model = BertNER('hfl/rbt3', len(data_bundle.get_vocab('new_target')))

forward函数和torch写法相同,train_step会被后面声明的trainer对象调用,计算训练的每一步loss,evaluate_step会被后面声明的evaluator对象调用,相当于预测函数,返回的是预测标签对应的数字index。

设计Bert+BiLSTM+CRF模型

这里需要考虑的是三个模型之间的输入输出要匹配,去掉了bpe算法部分方便将bert结果输入LSTM,另外在CRF模型的输入中mask大小不是Bert输入的带有前后标记的attention mask,而是最简单的长度mask,所以重新制作了mask输入crf中。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
import torch
from torch import nn
from fastNLP.transformers.torch import BertModel
from fastNLP import seq_len_to_mask
import torch.nn.functional as F
from fastNLP.modules.torch import ConditionalRandomField

class BertBilstmCrfNER(nn.Module):
def __init__(self, model_name,num_class, embedding_dim = 768,hidden_size=512,dropout=0.5):
super().__init__()
self.bert = BertModel.from_pretrained(model_name)

self.lstm = nn.LSTM(
input_size=embedding_dim,
num_layers=2,
hidden_size=hidden_size,
bidirectional=True,
batch_first=True)
self.dropout = nn.Dropout(dropout)
self.fc = nn.Linear(hidden_size * 2, num_class)
self.crf = ConditionalRandomField(num_class)


def forward(self, input_ids, input_len,target=None):
attention_mask = seq_len_to_mask(input_len)
with torch.no_grad():
outputs = self.bert(input_ids=input_ids, attention_mask=attention_mask)
last_hidden_state = outputs.last_hidden_state

first_bpe_state = last_hidden_state[:, 1:-1]
feats, _ = self.lstm(first_bpe_state) # 输入lstm
feats = self.fc(feats)
feats = self.dropout(feats)
logits = F.log_softmax(feats, dim=-1)

mask = seq_len_to_mask(input_len-2)

if target is None:
pred, _ = self.crf.viterbi_decode(logits, mask)
return {'pred': pred}
else:
loss = self.crf(logits, target, mask).mean()
return {'loss': loss}

def train_step(self, input_ids, input_len, target):
# {'loss':loss}
return self(input_ids, input_len,target)

def evaluate_step(self, input_ids, input_len):
# {'pred': pred}
return self(input_ids, input_len)

model = BertBilstmCrfNER('hfl/rbt3', len(data_bundle.get_vocab('new_target')))

开始训练

这里我们准备Trainer对象的各个参数,具体细节不做深入解释,看名字很好理解。

其中Trainer对象默认调用数据的标签的表头是target,所以这里需要这个函数将我们自己设计的new_target列调整。如果你和我一样是按照官方文档的写法,直接调用官方loader加载,在这里就需要这个函数,因为在文档里的ipython结果是从文件读取的,列名称和loader加载的不同。

实际训练中CRF模型训练需要调大学习率,否则转移矩阵的学习结果会很差,实际使用的lr为2e-2,是mlp模型的一千倍。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
from torch import optim
from fastNLP import Trainer, LoadBestModelCallback, TorchWarmupCallback
from fastNLP import SpanFPreRecMetric

optimizer = optim.Adam(model.parameters(), lr=2e-5)
callbacks = [
LoadBestModelCallback(),
TorchWarmupCallback(),
]
metrics = {
"f": SpanFPreRecMetric(tag_vocab=data_bundle.get_vocab('new_target')),
}

# ————重要函数----Trainer对象默认调用数据的标签的表头是target,所以这里需要这个函数将我们自己设计的new_target列调整
def input_mapping(data):
data['target'] = data['new_target']
return data

trainer = Trainer(model=model, train_dataloader=dataloaders['train'], optimizers=optimizer,
evaluate_dataloaders=dataloaders['dev'],
metrics=metrics, n_epochs=50, callbacks=callbacks,
monitor='f#f',device='cuda',driver="torch",input_mapping=input_mapping)
trainer.run()
  • Bert+MLP模型:50轮跑完结果如下,自动将最佳模型加载到model对象,这里在验证集上的F1值有0.573477,不算很高,因为Bert直接微调效果有限,但是预测结果有一定的参考价值了。
1
2
3
4
5
6
7
8
[11:36:55 AM] INFO     The best performance for monitor f#f:0.573477  progress_callback.py:37
was achieved in Epoch:47, Global Batch:282.
The evaluation result:
{'f#f': 0.573477, 'pre#f': 0.535714, 'rec#f':
0.616967}
INFO Loading best model from buffer with load_best_model_callback.py:120
f#f: 0.573477 (achieved in Epoch: 47,
Global Batch: 282) ...
  • Bert+BiLSTM+CRF模型:F1值有0.705416,提升明显。

    Bert+BiLSTM+CRF模型结果

模型保存和加载

训练好模型之后需要进行保存,这里使用torch的保存,fastNLP也有自己的保存方法,是相同的。

1
2
3
import os
torch.save(model,'rbt3-mlp-ner.pth')
ner_model = torch.load('rbt3-mlp-ner.pth')

使用模型预测

由于后续需要部署到服务器上进行使用,这里需要构建好整个预测流程,fastNLP工具包本身没有这个功能,只是用来快速构建模型验证,但是预测流程本身与训练流程相同,并不复杂。

  • 将预测文本放入dataset,再放入databundle,经过bpe计算,
  • 放入dataloader,交给evaluator调用预测函数得到预测结果。

构建dataset

其实是构建一个只有一条数据的的dataset再放入databundle对象中。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
from fastNLP.io import DataBundle
from fastNLP import DataSet, Instance

def text2dataset(text:str):
ds = DataSet()
if text != '':
ds.append(Instance(raw_words = list(text)))
return ds

text = '我今天就要在中国传媒大学吃上崔永元真面!'

predict_data_bundle = DataBundle(datasets={
"predict": text2dataset(text),
})
print(predict_data_bundle)
print(predict_data_bundle.get_dataset("predict"))

结果如下,这里Instance对象赋给输入的列名是raw_words,和前面默认loader加载是不同的。

1
2
3
--------------------------+| raw_words|+---------------------------------------+                                           
| ['我', '今', '天', '就', '要', '在', '中', '国', '传', '媒', '大', '学', ... |
+------------------------------------------------------------------------------+

构建dataloader

其实就是把前面的函数改一改,甚至可以将前面的函数加参数重构,重复使用。需要注意的是loader的列名不一样

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
from fastNLP.transformers.torch import BertTokenizer
from fastNLP import cache_results, Vocabulary

def process_predict_data(data_bundle, model_name):

tokenizer = BertTokenizer.from_pretrained(model_name)
def bpe(raw_words):
bpes = [tokenizer.cls_token_id]
first = [0]
first_index = 1 # 记录第一个bpe的位置
for word in raw_words:
bpe = tokenizer.encode(word, add_special_tokens=False)
bpes.extend(bpe)
first.append(first_index)
first_index += len(bpe)
bpes.append(tokenizer.sep_token_id)
first.append(first_index)
return {'input_ids': bpes, 'input_len': len(bpes), 'first': first, 'seq_len': len(raw_words)}
# 对data_bundle中每个dataset的每一条数据中的raw_words使用bpe函数,并且将返回的结果加入到每条数据中。
data_bundle.apply_field_more(bpe, field_name='raw_words', num_proc=1)

return data_bundle, tokenizer

predict_data_bundle, predict_tokenizer = process_predict_data(predict_data_bundle, 'hfl/rbt3')

print(predict_data_bundle)
print(predict_data_bundle.get_dataset("predict"))

from fastNLP import prepare_torch_dataloader

predict_dataloaders = prepare_torch_dataloader(predict_data_bundle, batch_size=1)

结果如下,和前面的loader相比只是少了target一列,不过因为是做预测本身也用不到。

1
2
3
4
5
+---------------------+--------------------+-----------+--------------------+---------+
| raw_words | input_ids | input_len | first | seq_len |
+---------------------+--------------------+-----------+--------------------+---------+
| ['我', '今', '天... | [101, 2769, 791... | 22 | [0, 1, 2, 3, 4,... | 20 |
+---------------------+--------------------+-----------+--------------------+---------+

进行预测

第一种方法:Evaluator对象进行预测

通常的预测方法是构建一个evaluator对象用于调用模型的预测函数,这里需要加载数据集中的vocab用于进行idx2word

1
2
3
4
5
6
7
8
9
10
11
def predict_output_labeling(evaluator, batch):
outputs = evaluator.evaluate_step(batch)["pred"]
raw_words = batch["raw_words"]
for words, output in zip(raw_words, outputs):
print("sentence:", words)
labels = [data_bundle.get_vocab("new_target").idx2word[idx] for idx in output[:len(words)].tolist() ]
print("labels:", labels)
print('outputs:',outputs)
predictor = Evaluator(model=ner_model, dataloaders=predict_dataloaders["predict"],
device=0, evaluate_batch_step_fn=predict_output_labeling)
predictor.run(1)

预测结果如下:

预测结果

这种方法查看预测结果是没有问题的,但是evaluator没有设置返回的参数,我们无法保存预测的结果用于后面部署可视化展示。

第二种方法:手动调用模型中的evaluate_step方法

考虑到我们部署的需求,需要提前将label和idx的对应关系提取出来,在预测后得到outputs列表后翻译成label。下面是一些工具函数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
label_idx_list = list(data_bundle.get_vocab("new_target"))

def write_list_into_text(path,label_idx_list):
with open(path,'w') as f:
for pair in label_idx_list:
f.writelines(str(pair[0])+','+str(pair[1]))
f.write('\n')
print('write over!')

def read_list_from_text(path):
final_list = []
with open(path,'r') as f:
lines = f.readlines()
for line in lines:
line = line.strip().split(',')
final_list.append((line[0],int(line[1])))
return final_list

write_list_into_text('label_idx_list.txt',label_idx_list)
label_idx_list = read_list_from_text('label_idx_list.txt')

def idx2label(label_idx_list:list,idx:int):
# [('O',0),(label,idx)...]
# 用于idx和label转换
# return label
label = None
for pair in label_idx_list:
if pair[1] == idx:
label = pair[0]
return label

接下来很简单,将制作好的输入数据整理成tensor,调用模型的evaluate_step方法进行预测。

⚠️这里由于手动调用模型,一定记得使用eval函数将模型设置为评估模式,关闭dropout的影响。

1
2
3
4
5
6
7
8
9
dev = next(ner_model.parameters()).device
ner_model.eval()

for data in predict_dataloaders['predict']:
input_ids = torch.LongTensor(data['input_ids']).to(dev)
input_len = torch.LongTensor(data['input_len']).to(dev)
first = torch.LongTensor(data['first']).to(dev)

result = ner_model.evaluate_step(input_ids,input_len,first)['pred']

预测结果如下:

手动预测的结果

准备部署

将模型和txt文件保存好,到此为止模型的方面搞定了,接下来就是用flask搭建网页服务器的内容,内容太长,下一篇继续~


使用Bert进行中文NER命名实体识别feat.fastNLP(上:模型篇)
https://ash-one.github.io/2023/03/18/shi-yong-bert-jin-xing-ner-ming-ming-shi-ti-shi-bie-feat-fastnlp/
作者
灰一
发布于
2023年3月18日
许可协议