命名实体识别作为序列标注类的典型任务,其使用场景特别广泛。本项目基于PyTorch搭建HMM、CRF、BiLSTM、BiLSTM+CRF及BERT模型,实现中文命名识别任务,全部代码链接上可找。
数据集来源于ACL 2018Chinese NER using Lattice LSTM论文中从新浪财经收集的简历数据,数据的格式如下,它的每一行由一个字及其对应的标注组成,标注集采用BIOES(B表示实体开头,E表示实体结尾,I表示在实体内部,LOC、PER等表示具体的实体,O表示非实体),句子与句子之间用一个空行隔开。
美 B-LOC
国 E-LOC
的 O
华 B-PER
莱 I-PER
士 E-PER
我 O
跟 O
他 O
谈 O
笑 O
风 O
生 O
共实现了5个模型,HMM、CRF、BiLSTM、BiLSTM+CRF以及BERT。传统的基于统计学习方法HMM和CRF,基于深度学习方法BiLSTM以及nlp中的战斗机BERT。
隐马尔科夫模型(HMM)
HMM是关于时序的概率模型,描述由一个隐藏的马尔科夫链随机生成不可观测的状态随机序列,再由各个状态生成一个观测而产生观测随机序列的过程(李航 统计学习方法)。HMM基于2个假设—齐次马尔科夫假设与观测独立性假设,这是HMM相较于其它几个模型性能差的主要原因。HMM由三要素—初始状态向量、观测概率矩阵及状态转移概率矩阵所确定。模型涉及HMM的2大问题,参数即三要素的学习算法和解码预测算法。
class HMM(object):
def __init__(self,N,M):
"""
HMM模型
:param N: 状态数,这里对应存在的标注的种类
:param M: 观测数 这里对应有多少个不同的字
"""
self.N = N
self.M = M
# 初始状态概率 Pi[i]表示初始时刻状态为i的概率
self.Pi = torch.zeros(N)
# 状态转移概率矩阵 A[i][j]表示状态从i转移到j的概率
self.A = torch.zeros(N,N)
# 观测概率矩阵 B[i][j]表示i状态下生成j观测的概率
self.B = torch.zeros(N,M)
def train(self,word_lists,tag_lists,word2id,tag2id):
"""HMM的训练,即根据训练语料对模型参数进行估计,
因为我们有观测序列以及其对应的状态序列,所以我们
可以使用极大似然估计的方法来估计隐马尔可夫模型的参数
"""
assert len(word_lists) == len(tag_lists)
# 估计状态转移概率矩阵A
# HMM的一个假设:齐次马尔科夫假设即任意时刻的隐藏状态只依赖以前一个隐藏状态
for tag_list in tag_lists:
seq_len = len(tag_list)
for i in range(seq_len-1):
current_tagid = tag2id[tag_list[i]]
next_tagid = tag2id[tag_list[i+1]]
self.A[current_tagid][next_tagid] += 1
# smoth
self.A[self.A == 0.] = 1e-10
# 计算概率
self.A = self.A / torch.sum(self.A,dim=1,keepdim=True)
# 估计观测概率矩阵
# 观测独立假设,即当前的观测值只依赖以当前的隐藏状态
for tag_list,word_list in zip(tag_lists,word_lists):
assert len(tag_list)==len(word_list)
for tag,word in zip(tag_list,word_list):
tag_id = tag2id[tag]
word_id = word2id[word]
self.B[tag_id][word_id] += 1
self.B[self.B==0.] = 1e-10
self.B = self.B / torch.sum(self.B,dim=1,keepdim=True)
# 估计初始概率,即每个句子开头标注状态
for tag_list in tag_lists:
init_tagId = tag2id[tag_list[0]]
self.Pi[init_tagId] += 1
self.Pi[self.Pi==0] = 1e-10
self.Pi = self.Pi/self.Pi.sum()
HMM的三要素确定好后,对于给定的句子求解每个字对应的命名实体标注,使用维特比(viterbi)算法。维特比算法实际是用动态规划解隐马尔可夫模型预测问题,即用动态规划求概率最大路径(最优路径),这里一条路径对应着一个状态序列。递推公式为:
具体代码如下:
def decoding(self,word_list,word2id,tag2id):
# 概率取对数
A = torch.log(self.A)
B = torch.log(self.B)
Pi = torch.log(self.Pi)
seq_len = len(word_list)
viterbi = torch.zeros(self.N, seq_len)
# backpointer[i, j]存储的是 标注序列的第j个标注为i时,第j-1个标注的id,用于解码
backpointer = torch.zeros(self.N, seq_len).long()
# 初始第一步
start_wordid = word2id.get(word_list[0],None)
Bt = B.t() # 转置,[M,N]
if start_wordid is None:
# 如果word不在字典里,则假设其状态转移概率分布为均匀分布
bt1 = (torch.ones(self.N)/self.N).long()
else:
bt1 = Bt[start_wordid]
viterbi[:,0] = self.Pi + bt1
backpointer[:,0] = -1
# 递推公式
# viterbi[tag_id,step] = max(viterbi[:,step-1] * self.A[:,tag_id]) * Bt[word]
# word是step时刻对应的观测值
for step in range(1,seq_len):
wordid = word2id.get(word_list[step],None)
# bt为时刻t字为wordid时,状态转移的概率
if wordid is None:
bt = (torch.ones(self.N)/self.N).long()
else:
# 从观测矩阵B中取值
bt = Bt[wordid]
for tag_id in range(len(tag2id)):
# 求前一个step中即维特比矩阵中的前一列每个元素和对应的状态转移矩阵的概率乘积的最大值
max_prob,max_id = torch.max(viterbi[:,step-1] + A[:,tag_id],dim=0)
viterbi[tag_id,step] = max_prob + bt[tag_id]
backpointer[tag_id, step] = max_id
# 终止, t=seq_len 即 viterbi[:, seq_len]中的最大概率,就是最优路径的概率
best_path_prob, best_path_pointer = torch.max(viterbi[:, seq_len - 1], dim=0)
# 回溯,求最优路径
best_path_pointer = best_path_pointer.item()
best_path = [best_path_pointer]
for back_step in range(seq_len-1, 0, -1):
best_path_pointer = backpointer[best_path_pointer, back_step]
best_path_pointer = best_path_pointer.item()
best_path.append(best_path_pointer)
# 将tag_id组成的序列转化为tag
assert len(best_path) == len(word_list)
id2tag = dict((id_, tag) for tag, id_ in tag2id.items())
tag_list = [id2tag[id_] for id_ in reversed(best_path)]
return tag_list
模型在test集上结果为:
precision | recall | f1-score |
---|---|---|
0.9129 | 0.9097 | 0.9107 |
条件随机场(CRF)
HMM是典型的生成模式,对联合概率建模且受限于2个假设,其性能天花板相对较低。CRF(我们用的是特殊CRF,即线性链条件随机场)是典型的判别模式。CRF通过引入自定义的特征函数,不仅可以表达观测之间的依赖,还可表示当前观测与前后多个状态之间的复杂依赖,可以有效克服HMM模型面临的问题。
首先定义一个特征函数集,该函数集内的每个特征函数都以标注序列作为输入,提取的特征作为输出。假设该函数集为:
其中 x = ( x 1 , . . . , x m ) x=(x_1, ..., x_m) x=(x1,...,xm)表示观测序列, s = ( s 1 , . . . . , s m ) s = (s_1, ...., s_m) s=(s1,....,sm) 表示状态序列。然后,CRF使用对数线性模型来计算给定观测序列下状态序列的条件概率:
w w w是条件随机场模型的参数,可以把它看成是每个特征函数的权重。CRF模型的训练其实就是对参数 w w w的估计。解码的时候与HMM类似,也可以采用维特比算法。CRF的实现实在太过复杂、繁琐,笔者借用了第三方库实现。
class CRFModel(object):
def __init__(self,algorithm='lbfgs',c1=0.1,c2=0.1,max_iterations=100,all_possible_transitions=False):
self.model = CRF(algorithm=algorithm,
c1=c1,
c2=c2,
max_iterations=max_iterations,
all_possible_transitions=all_possible_transitions)
def train(self,sentences,tag_lists):
# list of lists of dicts
features = [sent2features(sent) for sent in sentences]
self.model.fit(features,tag_lists)
def test(self,sentences):
features = [sent2features(s) for s in sentences]
pred_tag_lists = self.model.predict(features)
return pred_tag_lists
def word2features(sent,i):
"""抽取单个字的特征"""
word = sent[i]
prev_word = "" if i == 0 else sent[i-1]
next_word = "" if i == (len(sent)-1) else sent[i+1]
# 使用的特征:
# 前一个词,当前词,后一个词,
# 前一个词+当前词, 当前词+后一个词
feature = {
'w':word,
'w-1':prev_word,
'w+1':next_word,
'w-1:w':prev_word+word,
'w:w+1':word+next_word,
'bias':1
}
return feature
def sent2features(sent):
"""抽取序列特征"""
return [word2features(sent,i) for i in range(len(sent))]
CRF模型结果:
precision | recall | f1-score |
---|---|---|
0.9543 | 0.9543 | 0.9542 |
Bi-LSTM
循环神经网络RNN的改进型LSTM,特别适用于时序文本数据的处理。LSTM依靠神经网络超强的非线性拟合能力以及隐状态对信息的传递,在训练时将样本通过高维空间中的复杂非线性变换,学习到从样本到标注的函数。双向LSTM对前后向的序列依赖的捕获能力更强:
Bi-LSTM比起CRF模型最大的好处就是简单直接,不需要做繁杂的特征工程,建立模型直接训练即可,同时准确率也相当高。模型相当简单,一个embedding层+双向LSTM+全连接层:
class BiLSTM(nn.Module):
def __init__(self,vocab_size,emb_size,hidden_size,out_size,dropout=0.1):
super(BiLSTM,self).__init__()
self.embedding = nn.Embedding(vocab_size,emb_size)
self.bilstm = nn.LSTM(emb_size,hidden_size,batch_first=True,bidirectional=True)
self.fc = nn.Linear(2*hidden_size,out_size)
self.dropout = nn.Dropout(dropout)
def forward(self,x,lengths):
# [b,l,emb_size ]
emb = self.dropout(self.embedding(x))
# 这里要求输入按长度递减排好序,否则enforce_sorted设置为false,低版本方法有不同之处
emb = nn.utils.rnn.pack_padded_sequence(emb,lengths,batch_first=True)
emb,_ = self.bilstm(emb)
emb,_ = nn.utils.rnn.pad_packed_sequence(emb,batch_first=True,padding_value=0.,total_length=x.shape[1])
scores = self.fc(emb)
return scores
Bi-LSTM的模型结果:
precision | recall | f1-score |
---|---|---|
0.9553 | 0.9552 | 0.9551 |
Bi-LSTM + CRF
LSTM的优点是能够通过hidden state的传递学习到观测序列(输入的字)之间的依赖,在训练过程中,LSTM能够根据目标(比如识别实体)自动提取观测序列的特征,但缺无法学习到状态序列(输出的标注)之间的关系。在命名实体识别等序列标注任务中,标注之间是有一定的关系的,比如B类标注(表示某实体的开头)后面不会再接一个B类标注,E类标注(表示某实体的结尾)后面不会再接一个E类标注。
所以LSTM在解决NER这类序列标注任务时,虽然可以省去很繁杂的特征工程,但是无法有效学习到标注之间的上下文关系。相反,CRF的优点就是能对隐含状态建模,学习状态序列的特征,但它的缺点是需要手动提取序列特征。
所以一般的做法是,将两者结合起来,在LSTM后面再加一层CRF,以获得两者的优点。
损失函数为:
其中S的计算包括两部分:
P为emission score,是Bi-LSTM的输出(观测值到标注的非归一化概率),A为transition score,是CRF层的状态转移输出。训练过程中求解所有路径和的score时使用logsumexp,解码时略有不同,求argmax。具体的模型解释可以参考这边博文,写的很详细https://createmomo.github.io/2017/11/11/CRF-Layer-on-the-Top-of-BiLSTM-5/
class BiLSTM_CRF(nn.Module):
def __init__(self,vocab_size,emb_size,hidden_size,out_size):
"""
:param vocab_size:
:param emb_size:
:param hidden_size:
:param out_size:
"""
super(BiLSTM_CRF,self).__init__()
self.bilstm = BiLSTM(vocab_size,emb_size,hidden_size,out_size)
# CRF实际上就是多学习一个转移矩阵 [out_size, out_size] 初始化为均匀分布
self.transition = nn.Parameter(torch.ones(out_size,out_size) * 1 / out_size)
def forward(self,sents_tensor,lengths):
emission = self.bilstm(sents_tensor,lengths)
# 计算CRF scores, 这个scores大小为[B, L, out_size, out_size]
# 也就是每个字对应对应一个 [out_size, out_size]的矩阵
# 这个矩阵第i行第j列的元素的含义是:上一时刻tag为i,这一时刻tag为j的分数
batch_size,max_len,out_size = emission.size()
crf_scores = emission.unsqueeze(2).expand(-1,-1,out_size,-1) + self.transition.unsqueeze(0)
return crf_scores
def test(self, test_sents_tensor, lengths, tag2id):
"""使用维特比算法进行解码"""
start_id = tag2id['']
end_id = tag2id['']
pad = tag2id['']
tagset_size = len(tag2id)
crf_scores = self.forward(test_sents_tensor, lengths)
device = crf_scores.device
# B:batch_size, L:max_len, T:target set size
B, L, T, _ = crf_scores.size()
# viterbi[i, j, k]表示第i个句子,第j个字对应第k个标记的最大分数
viterbi = torch.zeros(B, L, T).to(device)
# backpointer[i, j, k]表示第i个句子,第j个字对应第k个标记时前一个标记的id,用于回溯
backpointer = (torch.zeros(B, L, T).long() * end_id).to(device)
lengths = torch.LongTensor(lengths).to(device)
# 向前递推
for step in range(L):
batch_size_t = (lengths > step).sum().item()
if step == 0:
# 第一个字它的前一个标记只能是start_id
viterbi[:batch_size_t, step,
:] = crf_scores[: batch_size_t, step, start_id, :]
backpointer[: batch_size_t, step, :] = start_id
else:
max_scores, prev_tags = torch.max(
viterbi[:batch_size_t, step-1, :].unsqueeze(2) +
crf_scores[:batch_size_t, step, :, :], # [B, T, T]
dim=1
)
viterbi[:batch_size_t, step, :] = max_scores
backpointer[:batch_size_t, step, :] = prev_tags
# 在回溯的时候我们只需要用到backpointer矩阵
backpointer = backpointer.view(B, -1) # [B, L * T]
tagids = [] # 存放结果
tags_t = None
for step in range(L-1, 0, -1):
batch_size_t = (lengths > step).sum().item()
if step == L-1:
index = torch.ones(batch_size_t).long() * (step * tagset_size)
index = index.to(device)
index += end_id
else:
prev_batch_size_t = len(tags_t)
new_in_batch = torch.LongTensor(
[end_id] * (batch_size_t - prev_batch_size_t)).to(device)
offset = torch.cat(
[tags_t, new_in_batch],
dim=0
) # 这个offset实际上就是前一时刻的
index = torch.ones(batch_size_t).long() * (step * tagset_size)
index = index.to(device)
index += offset.long()
try:
tags_t = backpointer[:batch_size_t].gather(
dim=1, index=index.unsqueeze(1).long())
except RuntimeError:
import pdb
pdb.set_trace()
tags_t = tags_t.squeeze(1)
tagids.append(tags_t.tolist())
# tagids:[L-1](L-1是因为扣去了end_token),大小的liebiao
# 其中列表内的元素是该batch在该时刻的标记
# 下面修正其顺序,并将维度转换为 [B, L]
tagids = list(zip_longest(*reversed(tagids), fillvalue=pad))
tagids = torch.Tensor(tagids).long()
# 返回解码的结果
return tagids
def cal_lstm_crf_loss(crf_scores, targets, tag2id):
"""计算双向LSTM-CRF模型的损失
该损失函数的计算可以参考:https://arxiv.org/pdf/1603.01360.pdf
"""
pad_id = tag2id.get('')
start_id = tag2id.get('')
end_id = tag2id.get('')
device = crf_scores.device
# targets:[B, L] crf_scores:[B, L, T, T]
batch_size, max_len = targets.size()
target_size = len(tag2id)
# mask = 1 - ((targets == pad_id) + (targets == end_id)) # [B, L]
mask = (targets != pad_id)
lengths = mask.sum(dim=1)
targets = indexed(targets, target_size, start_id)
# # 计算Golden scores方法1
# import pdb
# pdb.set_trace()
targets = targets.masked_select(mask) # [real_L]
flatten_scores = crf_scores.masked_select(
mask.view(batch_size, max_len, 1, 1).expand_as(crf_scores)
).view(-1, target_size*target_size).contiguous()
# 【batch*seq-masklen, target*target】
golden_scores = flatten_scores.gather(
dim=1, index=targets.unsqueeze(1)).sum()
# 计算golden_scores方法2:利用pack_padded_sequence函数
# targets[targets == end_id] = pad_id
# scores_at_targets = torch.gather(
# crf_scores.view(batch_size, max_len, -1), 2, targets.unsqueeze(2)).squeeze(2)
# scores_at_targets, _ = pack_padded_sequence(
# scores_at_targets, lengths-1, batch_first=True
# )
# golden_scores = scores_at_targets.sum()
# 计算all path scores
# scores_upto_t[i, j]表示第i个句子的第t个词被标注为j标记的所有t时刻事前的所有子路径的分数之和
scores_upto_t = torch.zeros(batch_size, target_size).to(device)
for t in range(max_len):
# 当前时刻 有效的batch_size(因为有些序列比较短)
batch_size_t = (lengths > t).sum().item()
if t == 0:
scores_upto_t[:batch_size_t] = crf_scores[:batch_size_t,
t, start_id, :]
else:
# We add scores at current timestep to scores accumulated up to previous
# timestep, and log-sum-exp Remember, the cur_tag of the previous
# timestep is the prev_tag of this timestep
# So, broadcast prev. timestep's cur_tag scores
# along cur. timestep's cur_tag dimension
scores_upto_t[:batch_size_t] = torch.logsumexp(
crf_scores[:batch_size_t, t, :, :] +
scores_upto_t[:batch_size_t].unsqueeze(2),
dim=1
)
all_path_scores = scores_upto_t[:, end_id].sum()
# 训练大约两个epoch loss变成负数,从数学的角度上来说,loss = -logP
loss = (all_path_scores - golden_scores) / batch_size
return loss
Bi-LSTM的模型结果:
precision | recall | f1-score |
---|---|---|
0.9578 | 0.9578 | 0.9576 |
模型ensembel结果(上面四个模型ensembel后多数表决)
precision | recall | f1-score |
---|---|---|
0.9558 | 0.9558 | 0.9557 |
BERT
BERT模型是最近两年来nlp最火最强大的基础模型。BERT基于Transformers Encoder,使用MLM获取双向融合信息,在海量连续语料上进行预训练,得到的预训练模型,基于下游任务只需简单的fine-tuning就能获得特别好的结果。本项目使用huggingface的transformers,预训练包为https://github.com/google-research/bert上release的base-chinese,训练代码如下:
import argparse
import glob
import logging
import os
import random
import numpy as np
import torch
from seqeval.metrics import f1_score, precision_score, recall_score
from torch.nn import CrossEntropyLoss
from torch.utils.data import DataLoader, RandomSampler, SequentialSampler, TensorDataset
from torch.utils.data.distributed import DistributedSampler
from tqdm import tqdm, trange
from transformers import (
WEIGHTS_NAME,
AdamW,
BertConfig,
BertForTokenClassification,
BertTokenizer,
CamembertConfig,
CamembertForTokenClassification,
CamembertTokenizer,
DistilBertConfig,
DistilBertForTokenClassification,
DistilBertTokenizer,
RobertaConfig,
RobertaForTokenClassification,
RobertaTokenizer,
XLMRobertaConfig,
XLMRobertaForTokenClassification,
XLMRobertaTokenizer,
get_linear_schedule_with_warmup,
)
from bert_utils import convert_examples_to_features, get_labels, read_examples_from_file
try:
from torch.utils.tensorboard import SummaryWriter
except ImportError:
from tensorboardX import SummaryWriter
logger = logging.getLogger(__name__)
ALL_MODELS = sum(
(
tuple(conf.pretrained_config_archive_map.keys())
for conf in (BertConfig, RobertaConfig, DistilBertConfig, CamembertConfig, XLMRobertaConfig)
),
(),
)
MODEL_CLASSES = {
"bert": (BertConfig, BertForTokenClassification, BertTokenizer),
"roberta": (RobertaConfig, RobertaForTokenClassification, RobertaTokenizer),
"distilbert": (DistilBertConfig, DistilBertForTokenClassification, DistilBertTokenizer),
"camembert": (CamembertConfig, CamembertForTokenClassification, CamembertTokenizer),
"xlmroberta": (XLMRobertaConfig, XLMRobertaForTokenClassification, XLMRobertaTokenizer),
}
def set_seed(args):
random.seed(args.seed)
np.random.seed(args.seed)
torch.manual_seed(args.seed)
if args.n_gpu > 0:
torch.cuda.manual_seed_all(args.seed)
def train(args, train_dataset, model, tokenizer, labels, pad_token_label_id):
""" Train the model """
if args.local_rank in [-1, 0]:
tb_writer = SummaryWriter()
args.train_batch_size = args.per_gpu_train_batch_size * max(1, args.n_gpu)
train_sampler = RandomSampler(train_dataset) if args.local_rank == -1 else DistributedSampler(train_dataset)
train_dataloader = DataLoader(train_dataset, sampler=train_sampler, batch_size=args.train_batch_size)
if args.max_steps > 0:
t_total = args.max_steps
args.num_train_epochs = args.max_steps // (len(train_dataloader) // args.gradient_accumulation_steps) + 1
else:
t_total = len(train_dataloader) // args.gradient_accumulation_steps * args.num_train_epochs
# Prepare optimizer and schedule (linear warmup and decay)
no_decay = ["bias", "LayerNorm.weight"]
optimizer_grouped_parameters = [
{
"params": [p for n, p in model.named_parameters() if not any(nd in n for nd in no_decay)],
"weight_decay": args.weight_decay,
},
{"params": [p for n, p in model.named_parameters() if any(nd in n for nd in no_decay)], "weight_decay": 0.0},
]
optimizer = AdamW(optimizer_grouped_parameters, lr=args.learning_rate, eps=args.adam_epsilon)
scheduler = get_linear_schedule_with_warmup(
optimizer, num_warmup_steps=args.warmup_steps, num_training_steps=t_total
)
# Check if saved optimizer or scheduler states exist
if os.path.isfile(os.path.join(args.model_name_or_path, "optimizer.pt")) and os.path.isfile(
os.path.join(args.model_name_or_path, "scheduler.pt")
):
# Load in optimizer and scheduler states
optimizer.load_state_dict(torch.load(os.path.join(args.model_name_or_path, "optimizer.pt")))
scheduler.load_state_dict(torch.load(os.path.join(args.model_name_or_path, "scheduler.pt")))
if args.fp16:
try:
from apex import amp
except ImportError:
raise ImportError("Please install apex from https://www.github.com/nvidia/apex to use fp16 training.")
model, optimizer = amp.initialize(model, optimizer, opt_level=args.fp16_opt_level)
# multi-gpu training (should be after apex fp16 initialization)
if args.n_gpu > 1:
model = torch.nn.DataParallel(model)
# Distributed training (should be after apex fp16 initialization)
if args.local_rank != -1:
model = torch.nn.parallel.DistributedDataParallel(
model, device_ids=[args.local_rank], output_device=args.local_rank, find_unused_parameters=True
)
# Train!
logger.info("***** Running training *****")
logger.info(" Num examples = %d", len(train_dataset))
logger.info(" Num Epochs = %d", args.num_train_epochs)
logger.info(" Instantaneous batch size per GPU = %d", args.per_gpu_train_batch_size)
logger.info(
" Total train batch size (w. parallel, distributed & accumulation) = %d",
args.train_batch_size
* args.gradient_accumulation_steps
* (torch.distributed.get_world_size() if args.local_rank != -1 else 1),
)
logger.info(" Gradient Accumulation steps = %d", args.gradient_accumulation_steps)
logger.info(" Total optimization steps = %d", t_total)
global_step = 0
epochs_trained = 0
steps_trained_in_current_epoch = 0
tr_loss, logging_loss = 0.0, 0.0
model.zero_grad()
train_iterator = trange(
epochs_trained, int(args.num_train_epochs), desc="Epoch", disable=args.local_rank not in [-1, 0]
)
set_seed(args) # Added here for reproductibility
for _ in train_iterator:
epoch_iterator = tqdm(train_dataloader, desc="Iteration", disable=args.local_rank not in [-1, 0])
for step, batch in enumerate(epoch_iterator):
# Skip past any already trained steps if resuming training
if steps_trained_in_current_epoch > 0:
steps_trained_in_current_epoch -= 1
continue
model.train()
batch = tuple(t.to(args.device) for t in batch)
inputs = {"input_ids": batch[0], "attention_mask": batch[1], "labels": batch[3]}
if args.model_type != "distilbert":
inputs["token_type_ids"] = (
batch[2] if args.model_type in ["bert", "xlnet"] else None
) # XLM and RoBERTa don"t use segment_ids
outputs = model(**inputs)
loss = outputs[0]
if args.n_gpu > 1:
loss = loss.mean() # mean() to average on multi-gpu parallel training
if args.gradient_accumulation_steps > 1:
loss = loss / args.gradient_accumulation_steps
if args.fp16:
with amp.scale_loss(loss, optimizer) as scaled_loss:
scaled_loss.backward()
else:
loss.backward()
tr_loss += loss.item()
if (step + 1) % args.gradient_accumulation_steps == 0:
if args.fp16:
torch.nn.utils.clip_grad_norm_(amp.master_params(optimizer), args.max_grad_norm)
else:
torch.nn.utils.clip_grad_norm_(model.parameters(), args.max_grad_norm)
scheduler.step() # Update learning rate schedule
optimizer.step()
model.zero_grad()
global_step += 1
if args.local_rank in [-1, 0] and args.logging_steps > 0 and global_step % args.logging_steps == 0:
# Log metrics
if (
args.local_rank == -1 and args.evaluate_during_training
): # Only evaluate when single GPU otherwise metrics may not average well
results, _ = evaluate(args, model, tokenizer, labels, pad_token_label_id, mode="dev")
for key, value in results.items():
tb_writer.add_scalar("eval_{}".format(key), value, global_step)
tb_writer.add_scalar("lr", scheduler.get_lr()[0], global_step)
tb_writer.add_scalar("loss", (tr_loss - logging_loss) / args.logging_steps, global_step)
logging_loss = tr_loss
if args.local_rank in [-1, 0] and args.save_steps > 0 and global_step % args.save_steps == 0:
# Save model checkpoint
output_dir = os.path.join(args.output_dir, "checkpoint-{}".format(global_step))
if not os.path.exists(output_dir):
os.makedirs(output_dir)
model_to_save = (
model.module if hasattr(model, "module") else model
) # Take care of distributed/parallel training
model_to_save.save_pretrained(output_dir)
tokenizer.save_pretrained(output_dir)
torch.save(args, os.path.join(output_dir, "training_args.bin"))
logger.info("Saving model checkpoint to %s", output_dir)
torch.save(optimizer.state_dict(), os.path.join(output_dir, "optimizer.pt"))
torch.save(scheduler.state_dict(), os.path.join(output_dir, "scheduler.pt"))
logger.info("Saving optimizer and scheduler states to %s", output_dir)
if args.max_steps > 0 and global_step > args.max_steps:
epoch_iterator.close()
break
if args.max_steps > 0 and global_step > args.max_steps:
train_iterator.close()
break
if args.local_rank in [-1, 0]:
tb_writer.close()
return global_step, tr_loss / global_step
def evaluate(args, model, tokenizer, labels, pad_token_label_id, mode, prefix=""):
eval_dataset = load_and_cache_examples(args, tokenizer, labels, pad_token_label_id, mode=mode)
args.eval_batch_size = args.per_gpu_eval_batch_size * max(1, args.n_gpu)
# Note that DistributedSampler samples randomly
eval_sampler = SequentialSampler(eval_dataset) if args.local_rank == -1 else DistributedSampler(eval_dataset)
eval_dataloader = DataLoader(eval_dataset, sampler=eval_sampler, batch_size=args.eval_batch_size)
# multi-gpu evaluate
if args.n_gpu > 1:
model = torch.nn.DataParallel(model)
# Eval!
logger.info("***** Running evaluation %s *****", prefix)
logger.info(" Num examples = %d", len(eval_dataset))
logger.info(" Batch size = %d", args.eval_batch_size)
eval_loss = 0.0
nb_eval_steps = 0
preds = None
out_label_ids = None
model.eval()
for batch in tqdm(eval_dataloader, desc="Evaluating"):
batch = tuple(t.to(args.device) for t in batch)
with torch.no_grad():
inputs = {"input_ids": batch[0], "attention_mask": batch[1], "labels": batch[3]}
if args.model_type != "distilbert":
inputs["token_type_ids"] = (
batch[2] if args.model_type in ["bert", "xlnet"] else None
) # XLM and RoBERTa don"t use segment_ids
outputs = model(**inputs)
tmp_eval_loss, logits = outputs[:2]
if args.n_gpu > 1:
tmp_eval_loss = tmp_eval_loss.mean() # mean() to average on multi-gpu parallel evaluating
eval_loss += tmp_eval_loss.item()
nb_eval_steps += 1
if preds is None:
preds = logits.detach().cpu().numpy()
out_label_ids = inputs["labels"].detach().cpu().numpy()
else:
preds = np.append(preds, logits.detach().cpu().numpy(), axis=0)
out_label_ids = np.append(out_label_ids, inputs["labels"].detach().cpu().numpy(), axis=0)
eval_loss = eval_loss / nb_eval_steps
# 【total_size,seq_len】
preds = np.argmax(preds, axis=2)
label_map = {i: label for i, label in enumerate(labels)}
out_label_list = [[] for _ in range(out_label_ids.shape[0])]
preds_list = [[] for _ in range(out_label_ids.shape[0])]
# 对于out_label_ids中pad及word做tokenize后多余位值为pad_token_label_id,进行删除过滤操作
for i in range(out_label_ids.shape[0]):
for j in range(out_label_ids.shape[1]):
if out_label_ids[i, j] != pad_token_label_id:
out_label_list[i].append(label_map[out_label_ids[i][j]])
preds_list[i].append(label_map[preds[i][j]])
results = {
"loss": eval_loss,
"precision": precision_score(out_label_list, preds_list),
"recall": recall_score(out_label_list, preds_list),
"f1": f1_score(out_label_list, preds_list),
}
logger.info("***** Eval results %s *****", prefix)
for key in sorted(results.keys()):
logger.info(" %s = %s", key, str(results[key]))
return results, preds_list
def load_and_cache_examples(args, tokenizer, labels, pad_token_label_id, mode):
if args.local_rank not in [-1, 0] and not evaluate:
torch.distributed.barrier() # Make sure only the first process in distributed training process the dataset, and the others will use the cache
# Load data features from cache or dataset file
cached_features_file = os.path.join(
args.data_dir,
"cached_{}_{}_{}".format(
mode, 'bert', str(args.max_seq_length)
),
)
if os.path.exists(cached_features_file) and not args.overwrite_cache:
logger.info("Loading features from cached file %s", cached_features_file)
features = torch.load(cached_features_file)
else:
logger.info("Creating features from dataset file at %s", args.data_dir)
examples = read_examples_from_file(args.data_dir, mode)
features = convert_examples_to_features(
examples,
labels,
args.max_seq_length,
tokenizer,
cls_token_at_end=bool(args.model_type in ["xlnet"]),
cls_token=tokenizer.cls_token,
cls_token_segment_id=2 if args.model_type in ["xlnet"] else 0,
sep_token=tokenizer.sep_token,
sep_token_extra=bool(args.model_type in ["roberta"]),
pad_on_left=bool(args.model_type in ["xlnet"]),
pad_token=tokenizer.convert_tokens_to_ids([tokenizer.pad_token])[0],
pad_token_segment_id=4 if args.model_type in ["xlnet"] else 0,
pad_token_label_id=pad_token_label_id,
)
if args.local_rank in [-1, 0]:
logger.info("Saving features into cached file %s", cached_features_file)
torch.save(features, cached_features_file)
if args.local_rank == 0 and not evaluate:
torch.distributed.barrier() # Make sure only the first process in distributed training process the dataset, and the others will use the cache
# Convert to Tensors and build dataset
# features中的label_ids 统一将word做tokenize后多余的以及pad的位置全部置为了pad_token_label_id = -100
# 后续验证的时候需要筛掉
all_input_ids = torch.tensor([f.input_ids for f in features], dtype=torch.long)
all_input_mask = torch.tensor([f.input_mask for f in features], dtype=torch.long)
all_segment_ids = torch.tensor([f.segment_ids for f in features], dtype=torch.long)
all_label_ids = torch.tensor([f.label_ids for f in features], dtype=torch.long)
dataset = TensorDataset(all_input_ids, all_input_mask, all_segment_ids, all_label_ids)
return dataset
def main():
parser = argparse.ArgumentParser()
# Required parameters
parser.add_argument(
"--data_dir",
default='./data',
type=str,
required=False,
help="The input data dir. Should contain the training files for the CoNLL-2003 NER task.",
)
parser.add_argument(
"--model_type",
default='bert',
type=str,
required=False,
help="Model type selected in the list: " + ", ".join(MODEL_CLASSES.keys()),
)
parser.add_argument(
"--model_name_or_path",
default='D:\\NLP\\my-wholes-models\\chinese_wwm_pytorch',
type=str,
required=False,
help="Path to pre-trained model or shortcut name selected in the list: " + ", ".join(ALL_MODELS),
)
parser.add_argument(
"--output_dir",
default='./ckpts/bert',
type=str,
required=False,
help="The output directory where the model predictions and checkpoints will be written.",
)
# Other parameters
parser.add_argument(
"--labels",
default="./data/lables.char",
type=str,
help="Path to a file containing all labels. If not specified, CoNLL-2003 labels are used.",
)
parser.add_argument(
"--config_name", default="", type=str, help="Pretrained config name or path if not the same as model_name"
)
parser.add_argument(
"--tokenizer_name",
default="",
type=str,
help="Pretrained tokenizer name or path if not the same as model_name",
)
parser.add_argument(
"--cache_dir",
default="./ckpts/bert/cache",
type=str,
help="Where do you want to store the pre-trained models downloaded from s3",
)
parser.add_argument(
"--max_seq_length",
default=256,
type=int,
help="The maximum total input sequence length after tokenization. Sequences longer "
"than this will be truncated, sequences shorter will be padded.",
)
parser.add_argument("--do_train",default=True, action="store_true", help="Whether to run training.")
parser.add_argument("--do_eval",default=True, action="store_true", help="Whether to run eval on the dev set.")
parser.add_argument("--do_predict",default=True , action="store_true", help="Whether to run predictions on the test set.")
parser.add_argument(
"--evaluate_during_training",
action="store_true",
help="Whether to run evaluation during training at each logging step.",
)
parser.add_argument(
"--do_lower_case",default=True, action="store_true", help="Set this flag if you are using an uncased model."
)
parser.add_argument("--per_gpu_train_batch_size", default=8, type=int, help="Batch size per GPU/CPU for training.")
parser.add_argument(
"--per_gpu_eval_batch_size", default=16, type=int, help="Batch size per GPU/CPU for evaluation."
)
parser.add_argument(
"--gradient_accumulation_steps",
type=int,
default=1,
help="Number of updates steps to accumulate before performing a backward/update pass.",
)
parser.add_argument("--learning_rate", default=5e-5, type=float, help="The initial learning rate for Adam.")
parser.add_argument("--weight_decay", default=0.0, type=float, help="Weight decay if we apply some.")
parser.add_argument("--adam_epsilon", default=1e-8, type=float, help="Epsilon for Adam optimizer.")
parser.add_argument("--max_grad_norm", default=1.0, type=float, help="Max gradient norm.")
parser.add_argument(
"--num_train_epochs", default=5.0, type=float, help="Total number of training epochs to perform."
)
parser.add_argument(
"--max_steps",
default=-1,
type=int,
help="If > 0: set total number of training steps to perform. Override num_train_epochs.",
)
parser.add_argument("--warmup_steps", default=0, type=int, help="Linear warmup over warmup_steps.")
parser.add_argument("--logging_steps", type=int, default=100, help="Log every X updates steps.")
parser.add_argument("--save_steps", type=int,default=600, help="Save checkpoint every X updates steps.")
parser.add_argument(
"--eval_all_checkpoints",
default=True,
action="store_true",
help="Evaluate all checkpoints starting with the same prefix as model_name ending and ending with step number",
)
parser.add_argument("--no_cuda", action="store_true", help="Avoid using CUDA when available")
parser.add_argument(
"--overwrite_output_dir", action="store_true", help="Overwrite the content of the output directory"
)
parser.add_argument(
"--overwrite_cache", action="store_true", help="Overwrite the cached training and evaluation sets"
)
parser.add_argument("--seed", type=int, default=42, help="random seed for initialization")
parser.add_argument(
"--fp16",
action="store_true",
help="Whether to use 16-bit (mixed) precision (through NVIDIA apex) instead of 32-bit",
)
parser.add_argument(
"--fp16_opt_level",
type=str,
default="O1",
help="For fp16: Apex AMP optimization level selected in ['O0', 'O1', 'O2', and 'O3']."
"See details at https://nvidia.github.io/apex/amp.html",
)
parser.add_argument("--local_rank", type=int, default=-1, help="For distributed training: local_rank")
parser.add_argument("--server_ip", type=str, default="", help="For distant debugging.")
parser.add_argument("--server_port", type=str, default="", help="For distant debugging.")
args = parser.parse_args()
if (
os.path.exists(args.output_dir)
and os.listdir(args.output_dir)
and args.do_train
and not args.overwrite_output_dir
):
raise ValueError(
"Output directory ({}) already exists and is not empty. Use --overwrite_output_dir to overcome.".format(
args.output_dir
)
)
# Setup distant debugging if needed
if args.server_ip and args.server_port:
# Distant debugging - see https://code.visualstudio.com/docs/python/debugging#_attach-to-a-local-script
import ptvsd
print("Waiting for debugger attach")
ptvsd.enable_attach(address=(args.server_ip, args.server_port), redirect_output=True)
ptvsd.wait_for_attach()
# Setup CUDA, GPU & distributed training
if args.local_rank == -1 or args.no_cuda:
device = torch.device("cuda" if torch.cuda.is_available() and not args.no_cuda else "cpu")
args.n_gpu = torch.cuda.device_count()
else: # Initializes the distributed backend which will take care of sychronizing nodes/GPUs
torch.cuda.set_device(args.local_rank)
device = torch.device("cuda", args.local_rank)
torch.distributed.init_process_group(backend="nccl")
args.n_gpu = 1
args.device = device
# Setup logging
logging.basicConfig(
format="%(asctime)s - %(levelname)s - %(name)s - %(message)s",
datefmt="%m/%d/%Y %H:%M:%S",
level=logging.INFO if args.local_rank in [-1, 0] else logging.WARN,
)
logger.warning(
"Process rank: %s, device: %s, n_gpu: %s, distributed training: %s, 16-bits training: %s",
args.local_rank,
device,
args.n_gpu,
bool(args.local_rank != -1),
args.fp16,
)
# Set seed
set_seed(args)
labels = get_labels(args.labels)
num_labels = len(labels)
# 交叉熵损失函数忽略计算的-100做为label id的pad token,计算损失时不计入
pad_token_label_id = CrossEntropyLoss().ignore_index
# Load pretrained model and tokenizer
if args.local_rank not in [-1, 0]:
torch.distributed.barrier() # Make sure only the first process in distributed training will download model & vocab
args.model_type = args.model_type.lower()
config_class, model_class, tokenizer_class = MODEL_CLASSES[args.model_type]
config = config_class.from_pretrained(
args.config_name if args.config_name else args.model_name_or_path,
num_labels=num_labels,
cache_dir=args.cache_dir if args.cache_dir else None,
)
tokenizer = tokenizer_class.from_pretrained(
args.tokenizer_name if args.tokenizer_name else args.model_name_or_path,
do_lower_case=args.do_lower_case,
cache_dir=args.cache_dir if args.cache_dir else None,
)
model = model_class.from_pretrained(
args.model_name_or_path,
from_tf=bool(".ckpt" in args.model_name_or_path),
config=config,
cache_dir=args.cache_dir if args.cache_dir else None,
)
if args.local_rank == 0:
torch.distributed.barrier() # Make sure only the first process in distributed training will download model & vocab
model.to(args.device)
logger.info("Training/evaluation parameters %s", args)
# Training
if args.do_train:
train_dataset = load_and_cache_examples(args, tokenizer, labels, pad_token_label_id, mode="train")
global_step, tr_loss = train(args, train_dataset, model, tokenizer, labels, pad_token_label_id)
logger.info(" global_step = %s, average loss = %s", global_step, tr_loss)
# Saving best-practices: if you use defaults names for the model, you can reload it using from_pretrained()
if args.do_train and (args.local_rank == -1 or torch.distributed.get_rank() == 0):
# Create output directory if needed
if not os.path.exists(args.output_dir) and args.local_rank in [-1, 0]:
os.makedirs(args.output_dir)
logger.info("Saving model checkpoint to %s", args.output_dir)
# Save a trained model, configuration and tokenizer using `save_pretrained()`.
# They can then be reloaded using `from_pretrained()`
model_to_save = (
model.module if hasattr(model, "module") else model
) # Take care of distributed/parallel training
model_to_save.save_pretrained(args.output_dir)
tokenizer.save_pretrained(args.output_dir)
# Good practice: save your training arguments together with the trained model
torch.save(args, os.path.join(args.output_dir, "training_args.bin"))
# Evaluation
results = {}
if args.do_eval and args.local_rank in [-1, 0]:
tokenizer = tokenizer_class.from_pretrained(args.output_dir, do_lower_case=args.do_lower_case)
checkpoints = [args.output_dir]
if args.eval_all_checkpoints:
checkpoints = list(
os.path.dirname(c) for c in sorted(glob.glob(args.output_dir + "/**/" + WEIGHTS_NAME, recursive=True))
)
logging.getLogger("pytorch_transformers.modeling_utils").setLevel(logging.WARN) # Reduce logging
logger.info("Evaluate the following checkpoints: %s", checkpoints)
for checkpoint in checkpoints:
global_step = checkpoint.split("-")[-1] if len(checkpoints) > 1 else ""
model = model_class.from_pretrained(checkpoint)
model.to(args.device)
result, _ = evaluate(args, model, tokenizer, labels, pad_token_label_id, mode="dev", prefix=global_step)
if global_step:
result = {"{}_{}".format(global_step, k): v for k, v in result.items()}
results.update(result)
output_eval_file = os.path.join(args.output_dir, "eval_results.txt")
with open(output_eval_file, "w") as writer:
for key in sorted(results.keys()):
writer.write("{} = {}\n".format(key, str(results[key])))
if args.do_predict and args.local_rank in [-1, 0]:
tokenizer = tokenizer_class.from_pretrained(args.output_dir, do_lower_case=args.do_lower_case)
model = model_class.from_pretrained(args.output_dir)
model.to(args.device)
result, predictions = evaluate(args, model, tokenizer, labels, pad_token_label_id, mode="test")
# Save results
output_test_results_file = os.path.join(args.output_dir, "test_results.txt")
with open(output_test_results_file, "w") as writer:
for key in sorted(result.keys()):
writer.write("{} = {}\n".format(key, str(result[key])))
# Save predictions
output_test_predictions_file = os.path.join(args.output_dir, "test_predictions.txt")
with open(output_test_predictions_file, "w") as writer:
with open(os.path.join(args.data_dir, "test.char"),encoding="utf-8") as f:
example_id = 0
for line in f:
if line.startswith("-DOCSTART-") or line == "" or line == "\n":
writer.write(line)
if not predictions[example_id]:
example_id += 1
elif predictions[example_id]:
output_line = line.split()[0] + " " + predictions[example_id].pop(0) + "\n"
writer.write(output_line)
else:
logger.warning("Maximum sequence length exceeded: No prediction for '%s'.", line.split()[0])
return results
if __name__ == "__main__":
main()
BERT模型的结果:
precision | recall | f1-score |
---|---|---|
0.9494 | 0.9646 | 0.9569 |
所有的测试结果如下表:
模型 | precision | recall | f1-score |
---|---|---|---|
HMM | 0.9129 | 0.9097 | 0.9107 |
CRF | 0.9543 | 0.9543 | 0.9542 |
BiLSTM | 0.9553 | 0.9552 | 0.9551 |
BiLSTM+CRF | 0.9578 | 0.9578 | 0.9576 |
ensembel | 0.9558 | 0.9558 | 0.9557 |
BERT | 0.9494 | 0.9646 | 0.9569 |
对照模型结果表,可以看出:
寻找更大的中文命名实体识别数据集,并训练比较BiLSTM与BiLSTM+CRF。尝试用BERT替代LSTM,训练BERT+CRF的模型