BERT实战——(2)序列标注

引言

我们将展示如何使用 🤗 Transformers代码库中的模型来解决序列标注任务

任务介绍

序列标注,通常也可以看作是token级别的分类问题:对每一个token进行分类。

token级别的分类任务通常指的是为文本中的每一个token预测一个标签结果。比如命名实体识别任务:

输入:我爱北京天安门
输出:O O B-LOC I-LOC B-POI B-POI I-POI

常见的token级别分类任务:

  • NER (Named-entity recognition 名词-实体识别) 分辨出文本中的名词和实体 (person人名, organization组织机构名, location地点名...).
  • POS (Part-of-speech tagging词性标注) 根据语法对token进行词性标注 (noun名词, verb动词, adjective形容词...)
  • Chunk (Chunking短语组块) 将同一个短语的tokens组块放在一起。

主要分为以下几个部分:

  1. 数据加载
  2. 数据预处理
  3. 微调预训练模型:使用transformer中的Trainer接口对预训练模型进行微调。

前期准备

安装以下库:

pip install datasets transformers seqeval
#transformers==4.9.2
#datasets==1.11.0
#seqeval==1.2.2

数据加载

数据集介绍

我们使用的是CONLL 2003 dataset数据集。

无论是在训练集、验证机还是测试集中,datasets都包含了一个名为tokens的列(一般来说是将文本切分成了多个token),还包含完成三个不同任务的label列(ner_tag,pos_tag和chunk_tag),对应了不同任务这tokens的标注。

加载数据

该数据的加载方式在transformers库中进行了封装,我们可以通过以下语句进行数据加载:

from datasets import load_dataset
datasets = load_dataset("conll2003")

如果你使用的是自己的数据,参考第一篇实战博客【定位词:加载数据】加载自己的数据。

给定一个数据切分的key(train、validation或者test)和下标即可查看数据。

datasets["train"][0]
#{'chunk_tags': [11, 21, 11, 12, 21, 22, 11, 12, 0],
# 'id': '0',
# 'ner_tags': [3, 0, 7, 0, 0, 0, 7, 0, 0],
# 'pos_tags': [22, 42, 16, 21, 35, 37, 16, 21, 7],
# 'tokens': ['EU',
# 'rejects',
# 'German',
# 'call',
# 'to',
# 'boycott',
# 'British',
# 'lamb',
# '.']}

所有的数据标签都已经被编码成了整数,可以直接被预训练transformer模型使用。这些整数的编码所对应的实际类别储存在features中。

datasets["train"].features[f"ner_tags"]
#Sequence(feature=ClassLabel(num_classes=9, names=['O', 'B-PER', 'I-PER', 'B-ORG', 'I-ORG', 'B-LOC', 'I-LOC', 'B-MISC', 'I-MISC'], names_file=None, id=None), length=-1, id=None)

以NER任务为例,0对应的标签类别是”O“, 1对应的是”B-PER“等等。

”O“表示没有特别实体(no special entity/other)。本例包含4种有价值实体类别分别是(PER、ORG、LOC,MISC),每一种实体类别又分别有B-(实体开始的token)前缀和I-(实体中间的token)前缀。

  1. 'PER' for person
  2. 'ORG' for organization
  3. 'LOC' for location
  4. 'MISC' for miscellaneous
label_list = datasets["train"].features[f"{task}_tags"].feature.names
label_list
#['O', 'B-PER', 'I-PER', 'B-ORG', 'I-ORG', 'B-LOC', 'I-LOC', 'B-MISC', 'I-MISC']

下面的函数将从数据集里随机选择几个例子进行展示:

from datasets import ClassLabel, Sequence
import random
import pandas as pd
from IPython.display import display, HTML

def show_random_elements(dataset, num_examples=4):
assert num_examples <= len(dataset), "Can't pick more elements than there are in the dataset."
picks = []
for _ in range(num_examples):
pick = random.randint(0, len(dataset)-1)
while pick in picks:
pick = random.randint(0, len(dataset)-1)
picks.append(pick)

df = pd.DataFrame(dataset[picks])
for column, typ in dataset.features.items():
if isinstance(typ, ClassLabel):
df[column] = df[column].transform(lambda i: typ.names[i])
elif isinstance(typ, Sequence) and isinstance(typ.feature, ClassLabel):
df[column] = df[column].transform(lambda x: [typ.feature.names[i] for i in x])
display(HTML(df.to_html()))
show_random_elements(datasets["train"])
id tokens pos_tags chunk_tags ner_tags
0 2227 [Result, of, a, French, first, division, match, on, Friday, .] [NN, IN, DT, JJ, JJ, NN, NN, IN, NNP, .] [B-NP, B-PP, B-NP, I-NP, I-NP, I-NP, I-NP, B-PP, B-NP, O] [O, O, O, B-MISC, O, O, O, O, O, O]
1 2615 [Mid-tier, golds, up, in, heavy, trading, .] [NN, NNS, IN, IN, JJ, NN, .] [B-NP, I-NP, B-PP, B-PP, B-NP, I-NP, O] [O, O, O, O, O, O, O]
2 10256 [Neagle, (, 14-6, ), beat, the, Braves, for, the, third, time, this, season, ,, allowing, two, runs, and, six, hits, in, eight, innings, .] [NNP, (, CD, ), VB, DT, NNPS, IN, DT, JJ, NN, DT, NN, ,, VBG, CD, NNS, CC, CD, NNS, IN, CD, NN, .] [B-NP, O, B-NP, O, B-VP, B-NP, I-NP, B-PP, B-NP, I-NP, I-NP, B-NP, I-NP, O, B-VP, B-NP, I-NP, O, B-NP, I-NP, B-PP, B-NP, I-NP, O] [B-PER, O, O, O, O, O, B-ORG, O, O, O, O, O, O, O, O, O, O, O, O, O, O, O, O, O]
3 10720 [Hansa, Rostock, 4, 1, 2, 1, 5, 4, 5] [NNP, NNP, CD, CD, CD, CD, CD, CD, CD] [B-NP, I-NP, I-NP, I-NP, I-NP, I-NP, I-NP, I-NP, I-NP] [B-ORG, I-ORG, O, O, O, O, O, O, O]
4 7125 [MONTREAL, 70, 59, .543, 11] [NNP, CD, CD, CD, CD] [B-NP, I-NP, I-NP, I-NP, I-NP] [B-ORG, O, O, O, O]

数据预处理

在将数据喂入模型之前,我们需要对数据进行预处理。

仍然是两个数据预处理的基本流程:

  1. 分词;
  2. 转化成对应任务输入模型的格式;

Tokenizer用于上面两步数据预处理工作:Tokenizer首先对输入进行tokenize,然后将tokens转化为预模型中需要对应的token ID,再转化为模型需要的输入格式。

初始化Tokenizer

上一篇博客已经介绍了一些Tokenizer的内容,并做了Tokenizer分词的示例,这里不再重复,下面补充一些在序列标注中需要注意的内容。

from transformers import AutoTokenizer
model_checkpoint = "bert-base-uncased"
tokenizer = AutoTokenizer.from_pretrained(model_checkpoint)

转化成对应任务输入模型的格式

transformer预训练模型在预训练的时候通常使用的是subword,如果我们的文本输入已经被切分成了word,这些word还会被tokenizer继续切分为subwords,同时,由于预训练模型输入格式的要求,往往还需要加上一些特殊符号比如: [CLS][SEP]比如:

example = datasets["train"][4]
tokenized_input = tokenizer(example["tokens"], is_split_into_words=True)
tokens = tokenizer.convert_ids_to_tokens(tokenized_input["input_ids"])
print(len(example[f"{task}_tags"]), len(tokenized_input["input_ids"]))
#(31, 39)
print(example["tokens"])
print(tokens)
#['Germany', "'s", 'representative', 'to', 'the', 'European', 'Union', "'s", 'veterinary', 'committee', 'Werner', 'Zwingmann', 'said', 'on', 'Wednesday', 'consumers', 'should', 'buy', 'sheepmeat', 'from', 'countries', 'other', 'than', 'Britain', 'until', 'the', 'scientific', 'advice', 'was', 'clearer', '.']
#['[CLS]', 'germany', "'", 's', 'representative', 'to', 'the', 'european', 'union', "'", 's', 'veterinary', 'committee', 'werner', 'z', '##wing', '##mann', 'said', 'on', 'wednesday', 'consumers', 'should', 'buy', 'sheep', '##me', '##at', 'from', 'countries', 'other', 'than', 'britain', 'until', 'the', 'scientific', 'advice', 'was', 'clearer', '.', '[SEP]']

可以看到单词"Zwingmann" 继续被切分成了3个subtokens: 'z', '##wing', '##mann'

由于标注数据通常是在word级别进行标注的,而word被切分成了subwords,那么意味需要对标注数据进行subtokens的对齐

tokenizer中word_ids方法可以帮助我们解决这个问题。

print(tokenized_input.word_ids())
#[None, 0, 1, 1, 2, 3, 4, 5, 6, 7, 7, 8, 9, 10, 11, 11, 11, 12, 13, 14, 15, 16, 17, 18, 18, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, None]

可以看到,word_ids将每一个subtokens位置都对应了一个word的下标。比如第1个位置对应第0个word,然后第2、3个位置对应第1个word。特殊字符对应了None

利用这个list,我们就能将subtokens和words还有标注的labels对齐。

word_ids = tokenized_input.word_ids()
aligned_labels = [-100 if i is None else example[f"{task}_tags"][i] for i in word_ids]
print(len(aligned_labels), len(tokenized_input["input_ids"]))
#39 39

通常将特殊字符的label设置为-100,在模型中-100通常会被忽略掉不计算loss

有两种对齐label的方式,通过label_all_tokens = True切换。

  • 多个subtokens对齐一个word,对齐一个label;
  • 多个subtokens的第一个subtoken对齐word,对齐一个label,其他subtokens直接赋予-100.

所有内容合起来变成我们的预处理函数。is_split_into_words=True因为输入的数据已经是按空格切分成word的格式了。

label_all_tokens = True

def tokenize_and_align_labels(examples):
tokenized_inputs = tokenizer(examples["tokens"], truncation=True, is_split_into_words=True)

labels = []
for i, label in enumerate(examples[f"{task}_tags"]):
word_ids = tokenized_inputs.word_ids(batch_index=i)
previous_word_idx = None
label_ids = []
for word_idx in word_ids:
# Special tokens have a word id that is None. We set the label to -100 so they are automatically
# ignored in the loss function.
if word_idx is None:
label_ids.append(-100)
# We set the label for the first token of each word.
elif word_idx != previous_word_idx:
label_ids.append(label[word_idx])
# For the other tokens in a word, we set the label to either the current label or -100, depending on
# the label_all_tokens flag.
else:
label_ids.append(label[word_idx] if label_all_tokens else -100)
previous_word_idx = word_idx

labels.append(label_ids)

tokenized_inputs["labels"] = labels
return tokenized_inputs

以上的预处理函数可以处理一个样本,也可以处理多个样本exapmles。如果是处理多个样本,则返回的是多个样本被预处理之后的结果list。

tokenize_and_align_labels(datasets['train'][:5])
#{'input_ids': [[101, 7327, 19164, 2446, 2655, 2000, 17757, 2329, 12559, 1012, 102], [101, 2848, 13934, 102], [101, 9371, 2727, 1011, 5511, 1011, 2570, 102], [101, 1996, 2647, 3222, 2056, 2006, 9432, 2009, 18335, 2007, 2446, 6040, 2000, 10390, 2000, 18454, 2078, 2329, 12559, 2127, 6529, 5646, 3251, 5506, 11190, 4295, 2064, 2022, 11860, 2000, 8351, 1012, 102], [101, 2762, 1005, 1055, 4387, 2000, 1996, 2647, 2586, 1005, 1055, 15651, 2837, 14121, 1062, 9328, 5804, 2056, 2006, 9317, 10390, 2323, 4965, 8351, 4168, 4017, 2013, 3032, 2060, 2084, 3725, 2127, 1996, 4045, 6040, 2001, 24509, 1012, 102]], 'attention_mask': [[1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1], [1, 1, 1, 1], [1, 1, 1, 1, 1, 1, 1, 1], [1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1], [1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1]], 'labels': [[-100, 3, 0, 7, 0, 0, 0, 7, 0, 0, -100], [-100, 1, 2, -100], [-100, 5, 0, 0, 0, 0, 0, -100], [-100, 0, 3, 4, 0, 0, 0, 0, 0, 0, 7, 0, 0, 0, 0, 0, 0, 7, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, -100], [-100, 5, 0, 0, 0, 0, 0, 3, 4, 0, 0, 0, 0, 1, 2, 2, 2, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 5, 0, 0, 0, 0, 0, 0, 0, -100]]}

接下来使用map函数对数据集datasets里面三个样本集合的所有样本进行预处理,将预处理函数prepare_train_features应用到(map)所有样本上。

tokenized_datasets = datasets.map(tokenize_and_align_labels, batched=True)

微调预训练模型

数据已经准备好了,我们需要下载并加载预训练模型,然后微调预训练模型。

加载预训练模型

序列标注token classification任务,那么需要一个能解决这个任务的模型类。我们使用AutoModelForTokenClassification 这个类

和tokenizer相似,from_pretrained方法同样可以帮助下载并加载模型,同时也会对模型进行缓存,也可以填入一个包括模型相关文件的文件夹(比如自己预训练的模型),这样会从本地直接加载。

from transformers import AutoModelForTokenClassification
model = AutoModelForTokenClassification.from_pretrained(model_checkpoint, num_labels=len(label_list))

同样会提示我们加载模型的时候扔掉了一些不匹配的神经网络参数,比如:预训练语言模型的神经网络head被扔掉了,同时随机初始化了文本分类的神经网络head。

设定训练参数

为了能够得到一个Trainer训练工具,我们还需要训练的设定/参数 TrainingArguments。这个训练设定包含了能够定义训练过程的所有属性

task='ner'
batch_size = 16

from transformers import TrainingArguments

args = TrainingArguments(
f"test-{task}",
evaluation_strategy = "epoch",#每个epcoh会做一次验证评估
learning_rate=2e-5,
per_device_train_batch_size=batch_size,
per_device_eval_batch_size=batch_size,
num_train_epochs=3,
weight_decay=0.01,
)

数据收集器data collator

接下来需要告诉Trainer如何从预处理的输入数据中构造batch。我们使用数据收集器data collator,将经预处理的输入分batch再次处理后喂给模型。

from transformers import DataCollatorForTokenClassification
data_collator = DataCollatorForTokenClassification(tokenizer)

定义评估方法

使用seqeval metric来完成评估。

from datasets import load_metric
metric = load_metric("seqeval")

评估的输入是预测和label的list:

labels = [label_list[i] for i in example[f"{task}_tags"]]
metric.compute(predictions=[labels], references=[labels])
#{'LOC': {'f1': 1.0, 'number': 2, 'precision': 1.0, 'recall': 1.0},
# 'ORG': {'f1': 1.0, 'number': 1, 'precision': 1.0, 'recall': 1.0},
# 'PER': {'f1': 1.0, 'number': 1, 'precision': 1.0, 'recall': 1.0},
# 'overall_accuracy': 1.0,
# 'overall_f1': 1.0,
# 'overall_precision': 1.0,
# 'overall_recall': 1.0}

将模型预测送入评估之前,还需要做一些数据后处理:

  • 选择预测分类最大概率的下标;
  • 将下标转化为label;
  • 忽略-100所在位置。

下面的函数将上面的步骤合并了起来:

import numpy as np

def compute_metrics(p):
predictions, labels = p
predictions = np.argmax(predictions, axis=2) #选择预测分类最大概率的下标

# Remove ignored index (special tokens)忽略-100所在位置
true_predictions = [
[label_list[p] for (p, l) in zip(prediction, label) if l != -100]
for prediction, label in zip(predictions, labels)
]
true_labels = [
[label_list[l] for (p, l) in zip(prediction, label) if l != -100]
for prediction, label in zip(predictions, labels)
]

results = metric.compute(predictions=true_predictions, references=true_labels)
#计算所有类别总的precision/recall/f1,所以会扔掉单个类别的precision/recall/f1
return {
"precision": results["overall_precision"],
"recall": results["overall_recall"],
"f1": results["overall_f1"],
"accuracy": results["overall_accuracy"],
}

开始训练

将数据/模型/参数传入Trainer即可:

from transformers import   Trainer
trainer = Trainer(
model,
args,
train_dataset=tokenized_datasets["train"],
eval_dataset=tokenized_datasets["validation"],
data_collator=data_collator,
tokenizer=tokenizer,
compute_metrics=compute_metrics
)

调用train方法开始训练:

trainer.train()

模型评估

使用evaluate方法进行评估:

trainer.evaluate()

如果想要得到单个类别的precision/recall/f1,直接将结果输入相同的评估函数即可:

predictions, labels, _ = trainer.predict(tokenized_datasets["validation"])
predictions = np.argmax(predictions, axis=2)

# Remove ignored index (special tokens)
true_predictions = [
[label_list[p] for (p, l) in zip(prediction, label) if l != -100]
for prediction, label in zip(predictions, labels)
]
true_labels = [
[label_list[l] for (p, l) in zip(prediction, label) if l != -100]
for prediction, label in zip(predictions, labels)
]

results = metric.compute(predictions=true_predictions, references=true_labels)
print(results)

参考文献

4.2-序列标注.md

BERT实战——(1)文本分类

transformers官方文档