文章目录
1.前言
- 本文主要分析本次实验的代码,讲解主要流程和代码含义,并不关注参数的选择和模型的选择
- 后续可能还会更新
model
的原理 - 如果有问题,欢迎评论或私聊讨论
- 若分析过程出现错误,请及时指正,谢谢
2.数据预处理
- 原始标注:对句子中的每个字标注上一个标签,可以简单地看成是直接对每个字分类(需要融合上下文信息),因此可以使用一个多分类器,分类器输出类别就是该字的标签
- 联合标注:对一串连续的字标注相同的标签。在NER任务中,实体由一个或多个字组成,所以 它属于联合标注任务。
但是在联合标注中,相邻词语标签之间可能会存在依赖关系。 这一问题可以通过标签转化的方式,把联合标注转化成原始标注解决。
我们这里使用的是 BIOS标注
标签含义B-X该字是词片段 X 的起始字I-X该字是词片段 X 起始字之后的字S-X该字单独标记为 X 标签O该字不属于事先定义的任何词片段类型
在 process.py
中,我们将 .json
文件中的语句和标签,按照 BIOS
方式,处理转换成了 .npz
文件。主要代码如下。分析过程写在注释中,依据样例 .json
。
text = json_line['text']
words = list(text)
label_entities = json_line.get('label', None)
labels = ['O'] * len(words)
if label_entities is not None:
for key, value in label_entities.items():
for sub_name, sub_index in value.items():
for start_index, end_index in sub_index:
assert ''.join(words[start_index:end_index + 1]) == sub_name
if start_index == end_index:
labels[start_index] = 'S-' + key
else:
labels[start_index] = 'B-' + key
labels[start_index + 1:end_index + 1] = ['I-' + key] * (len(sub_name) - 1)
- 字符串转
list
验证 - 这里很重要的一点是,输入的字符串都转成单字符了,下面使用 tokenize 的时候会看到为什么
a = "你好,我是nsy,哈哈哈"
print(list(a))
>>['你', '好', ',', '我', '是', 'n', 's', 'y', ',', '哈', '哈', '哈']
.json
文件中,数据存储结构如下所示
{
"text": "浙商银行企业信贷部叶老桂博士则从另一个角度对五道门槛进行了解读。叶老桂认为,对目前国内商业银行而言,",
"label": {
"name": {
"叶老桂": [
[9, 11],
[32, 34]
]
},
"company": {
"浙商银行": [
[0, 3]
]
}
}
}
2.1本地查看数据转换后的结果
- code
import numpy as np
a = np.load(r'D:\2022 spring\nlp\exp4\code\BERT-LSTM-CRF\data\clue\test.npz', allow_pickle=True)
index = 0
words = a['words']
labels = a['labels']
print(words[0])
print(labels[0])
- 结果
['彭', '小', '军', '认', '为', ',', '国', '内', '银', '行', '现', '在', '走', '的', '是', '台', '湾', '的', '发', '卡', '模', '式', ',', '先', '通', '过', '跑', '马', '圈', '地', '再', '在', '圈', '的', '地', '里', '面', '选', '择', '客', '户', ',']
['B-name', 'I-name', 'I-name', 'O', 'O', 'O', 'O', 'O', 'O', 'O', 'O', 'O', 'O', 'O', 'O', 'B-address', 'I-address', 'O', 'O', 'O', 'O', 'O', 'O', 'O', 'O', 'O', 'O', 'O', 'O', 'O', 'O', 'O', 'O', 'O', 'O', 'O', 'O', 'O', 'O', 'O', 'O', 'O']
3.构建数据集
我们构建自己的数据集 Dataset
类。该类主要属性为
self.tokenizer = BertTokenizer.from_pretrained(config.bert_model, do_lower_case=True)
self.label2id = config.label2id
self.id2label = {_id: _label for _label, _id in list(config.label2id.items())}
self.dataset = self.preprocess(words, labels)
self.word_pad_idx = word_pad_idx
self.label_pad_idx = label_pad_idx
self.device = config.device
- 因为我们加载的数据是
.npz
文件中的,数据(不是label
)是存在列表中的单个字符,我们不进行分词工作了。所以这里的tokenizer属性
主要是将大写字母转化为小写字母 - 一个比较难理解的属性是
self.dataset
,我们来看看里面到底是什么内容。preprocess
函数如下。函数主要功能为 - 在每句话前面加一个开头
CLS
- 将原始字符/字都转换成
id
,并存储有label
的字的开始位置的索引 - 将
label
转成成id
- 注意:代码中
token
的长度都是1
,这是由.npz
中的数据作为输入决定的
def preprocess(self, origin_sentences, origin_labels):
"""
Maps tokens and tags to their indices and stores them in the dict data.
examples:
word:['[CLS]', '浙', '商', '银', '行', '企', '业', '信', '贷', '部']
sentence:([101, 3851, 1555, 7213, 6121, 821, 689, 928, 6587, 6956],
array([ 1, 2, 3, 4, 5, 6, 7, 8, 9, 10]))
label:[3, 13, 13, 13, 0, 0, 0, 0, 0]
"""
data = []
sentences = []
labels = []
for line in origin_sentences:
words = []
word_lens = []
for token in line:
words.append(self.tokenizer.tokenize(token))
word_lens.append(len(token))
words = ['[CLS]'] + [item for token in words for item in token]
token_start_idxs = 1 + np.cumsum([0] + word_lens[:-1])
sentences.append((self.tokenizer.convert_tokens_to_ids(words), token_start_idxs))
for tag in origin_labels:
label_id = [self.label2id.get(t) for t in tag]
labels.append(label_id)
for sentence, label in zip(sentences, labels):
data.append((sentence, label))
return data
- 比较难理解的部分
for token in line:
words.append(self.tokenizer.tokenize(token))
word_lens.append(len(token))
words = ['[CLS]'] + [item for token in words for item in token]
token_start_idxs = 1 + np.cumsum([0] + word_lens[:-1])
+ 对上面的例子来说, tokenize
效果就是 浙
-> ['浙']
* tokenize
其实有分词的作用,比如
import torch
import numpy as np
from transformers import BertTokenizer
tokenizer = BertTokenizer.from_pretrained('pretrained_bert_models/bert-base-chinese/', do_lower_case=True)
print(tokenizer.tokenize("unwanted"))
print(tokenizer.tokenize("===+"))
>>['u', '##n', '##wan', '##ted']
>>['=', '=', '=', '+']
* 如果有上述功能, len(token)
和 真实索引开始位置就对不上了,比如 unwanted
进行 tokenize
之后提供四个部分,但是索引却要 + 8
* 这里的字符全是单个的(上面解释过),因此只有大写 -> 小写的作用
+ for
循环之后,得到 words = [['浙'], ['商'], ['银'], ['行'], ['企'], ['业'], ['信'], ['贷'], ['部']]
,大写变小写在这里没有体现。 word_lens=[1,1,1,1,1,1,1,1,1]
+ 下一步 words->['[CLS]', '浙', '商', '银', '行', '企', '业', '信', '贷', '部']
+ word_lens
去掉最后一个,前面添加一个 0
,然后前向求和 + 1,得到 [ 1, 2, 3, 4, 5, 6, 7, 8, 9]
。我认为样例的数字错了(代码没问题,本人已经测试过)。我感觉直接对 word_lens
前向求和就行
- 该部分还有一个主要函数是
collate_fn(self, batch)
。主要功能为: - 将每个
batch
的data
扩充到同一长度(batch
中最长的data
的长度)- 先找到最大的长度
- 初始化一个矩阵(句子个数, 最大句子长度), 初始化值为
0
- 将相应的值放到对应的索引上
- 将每个
batch
的label
扩充到统一长度(batch
中最长的label
的长度)- 先找到最大的长度
- 初始化一个矩阵(句子个数, 最大
label
长度) - 将相应的值放到对应的索引上
- 将
batch_data, batch_label_starts, batch_labels
转换为tensor
并移动到GPU
上,然后返回
4.数据集分割
我们按照 9:1
的比例,将训练数据分割成训练集和验证集,代码在 run.py
中。
word_train, word_dev, label_train, label_dev = load_dev('train')
函数 load_dev()
代码如下
def dev_split(dataset_dir):
"""split dev set"""
data = np.load(dataset_dir, allow_pickle=True)
words = data["words"]
labels = data["labels"]
x_train, x_dev, y_train, y_dev = train_test_split(words, labels, test_size=config.dev_split_size, random_state=0)
return x_train, x_dev, y_train, y_dev
5.模型架构
5.1模型初始化
- 我们的模型继承了一个预训练模型
BertPreTrainedModel
- 主要属性:
- 一个
bert
模型(Transformer
的堆叠,bert
作为Encoding
来使用,对输入数据进行编码)Bert简介_长命百岁️的博客-CSDN博客 dropout
层- 一个两层的
bilstm(双向lstm)
:输出 - 一个线性分类器
- 一个
crf
模型 bilstm-CRF
模型结构如下所示,代码下面有各层的作用
class BertNER(BertPreTrainedModel):
def __init__(self, config):
super(BertNER, self).__init__(config)
self.num_labels = config.num_labels
self.bert = BertModel(config)
self.dropout = nn.Dropout(config.hidden_dropout_prob)
self.bilstm = nn.LSTM(
input_size=config.lstm_embedding_size,
hidden_size=config.hidden_size // 2,
batch_first=True,
num_layers=2,
dropout=config.lstm_dropout_prob,
bidirectional=True
)
self.classifier = nn.Linear(config.hidden_size, config.num_labels)
self.crf = CRF(config.num_labels, batch_first=True)
self.init_weights()
5.2前向传播过程
- 先利用
bert
处理输入数据。bert
简介可参考 - 输入是每个
token
对应的表征 - 输出是对输入
token
的编码
input_ids, input_token_starts = input_data
outputs = self.bert(input_ids,
attention_mask=attention_mask,
token_type_ids=token_type_ids,
position_ids=position_ids,
head_mask=head_mask,
inputs_embeds=inputs_embeds)
sequence_output = outputs[0]
- 将原来有
label
的位置对应的输出提取出来
origin_sequence_output = [layer[starts.nonzero().squeeze(1)]
for layer, starts in zip(sequence_output, input_token_starts)]
- 将
origin_sequence_output
填充到最大长度
padded_sequence_output = pad_sequence(origin_sequence_output, batch_first=True)
- 将
padded_sequence_output
输入bilstm
padded_sequence_output = self.dropout(padded_sequence_output)
lstm_output, _ = self.bilstm(padded_sequence_output)
- 进行结果的判别,返回结果
logits
是每个位置对有label的打分(对bilstm
的输出进行维度变换)大小是(batch_size, max_len, num_labels)
要注意,只有label
不是None
时,才算loss
,否则就会只返回得分。这一点在train.py
中,估计不传label
参数,从而只计算得分
logits = self.classifier(lstm_output)
outputs = (logits,)
if labels is not None:
loss_mask = labels.gt(-1)
loss = self.crf(logits, labels, loss_mask) * (-1)
outputs = (loss,) + outputs
return outputs
对 (loss,) + outputs
的解释。可以见到这里是把 loss
添加到前面,作为元组的第一项
a = (1, )
for i in range(10):
a = (2, ) + a
print(a)
>>(2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 1)
6.模型训练
6.1训练一个epoch
- 首先开启训练模式,本次实验中其实就是开启
dropout
。关于这样做的理由,请参考 Pytorch model.train()_长命百岁️的博客-CSDN博客
model.train()
- 利用
Dataloader
类的实例train_loader
进行分批训练(一次训练一个batch
),train_epoch
代码如下:
for idx, batch_samples in enumerate(tqdm(train_loader)):
batch_data, batch_token_starts, batch_labels = batch_samples
batch_masks = batch_data.gt(0)
loss = model((batch_data, batch_token_starts),
token_type_ids=None, attention_mask=batch_masks, labels=batch_labels)[0]
train_losses += loss.item()
model.zero_grad()
loss.backward()
nn.utils.clip_grad_norm_(parameters=model.parameters(), max_norm=config.clip_grad)
optimizer.step()
scheduler.step()
- 这里的
mask
是因为我们对一句话进行了padding
,self-attention
会关注所有位置,但是我们不想关注padding
的位置。因此我们就提取出来这些位置(为0
),然后进行mask
。 - 返回结果
train_loss = float(train_losses) / len(train_loader)
logging.info("Epoch: {}, train loss: {}".format(epoch, train_loss))
6.2训练所有epoch
- 遍历
epoch
,调用train_epoch
进行参数更新和loss
计算
for epoch in range(1, config.epoch_num + 1):
train_epoch(train_loader, model, optimizer, scheduler, epoch)
val_metrics = evaluate(dev_loader, model, mode='dev')
val_f1 = val_metrics['f1']
- 根据
f1_score
的变化考虑是否保存当前模型,并设置停止训练的条件,若满足条件,则停止训练。
6.3evaluate函数
在这里, mode = 'dev'
。利用当前 epoch
的模型对验证集进行预测,计算出 metrics['loss'] = float(dev_losses) / len(dev_loader)
。并利用预测 label
与真实 label
计算出 f1_score = metrics['f1']
。
- 要注意的是,我们调用
model
函数前向传播时,有的输入了label
,然后接收output[0]
,是loss
- 有的没输入
label
,返回的结果是每个位置对所有label
的得分
7.整体训练过程
- 数据预处理
utils.set_logger(config.log_dir)
logging.info("device: {}".format(config.device))
processor = Processor(config)
processor.process()
logging.info("--------Process Done!--------")
- 划分训练集和验证集,并使用上面构建的
Dataset
类,构建数据集(可用于Dataloader
)
word_train, word_dev, label_train, label_dev = load_dev('train')
train_dataset = NERDataset(word_train, label_train, config)
dev_dataset = NERDataset(word_dev, label_dev, config)
logging.info("--------Dataset Build!--------")
train_size = len(train_dataset)
- 将
Dataset
类放入DataLoader
中,以进行后续的分batch
训练
train_loader = DataLoader(train_dataset, batch_size=config.batch_size,
shuffle=True, collate_fn=train_dataset.collate_fn)
dev_loader = DataLoader(dev_dataset, batch_size=config.batch_size,
shuffle=True, collate_fn=dev_dataset.collate_fn)
logging.info("--------Get Dataloader!--------")
- 准备模型
device = config.device
model = BertNER.from_pretrained(config.bert_model, num_labels=len(config.label2id))
model.to(device)
- 下面就是模型的参数选择,优化器的选择,调优策略的配置
- 模型训练,保存最优模型
- 模型测试
- 上面三个内容可以参见 用BERT做NER?教你用PyTorch轻松入门Roberta! – 知乎 (zhihu.com)。本文只对代码内容进行讲解,不研究训练的参数选择。
- 因为只是讲解代码,代码并非本人编写,这里附上原作者代码地址 hemingkx/CLUENER2020: A PyTorch implementation of a BiLSTM\BERT\Roberta(+CRF) model for Named Entity Recognition. (github.com)
Original: https://blog.csdn.net/qq_52852138/article/details/123766574
Author: 长命百岁️
Title: 使用BERT+Bi-LSTM+CRF实现命名实体识别
原创文章受到原创版权保护。转载请注明出处:https://www.johngo689.com/711045/
转载文章受原作者版权保护。转载请注明原作者出处!