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

引言

我们将展示如何使用 🤗 Transformers代码库中的模型来解决文本分类任务,任务来源于GLUE Benchmark.

任务介绍

本质就是分类问题,比如对一句话的情感极性分类(正向1或负向-1或中性0):

输入:这部电影真不错!
输出:1

主要分为以下几个部分:

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

前期准备

安装以下两个库:

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

数据加载

数据集介绍

我们使用的是GLUE榜单的数据集:

GLUE_TASKS = ["cola", "mnli", "mnli-mm", "mrpc", "qnli", "qqp", "rte", "sst2", "stsb", "wnli"]

GLUE榜单包含了9个句子级别的分类任务,分别是:

分类任务 任务目标
CoLA (Corpus of Linguistic Acceptability) 鉴别一个句子是否语法正确.
MNLI (Multi-Genre Natural Language Inference) 给定一个假设,判断另一个句子与该假设的关系:entails, contradicts 或者 unrelated。
MRPC (Microsoft Research Paraphrase Corpus) 判断两个句子是否互为paraphrases改写.
QNLI (Question-answering Natural Language Inference) 判断第2句是否包含第1句问题的答案。
QQP (Quora Question Pairs2) 判断两个问句是否语义相同。
RTE (Recognizing Textual Entailment) 判断一个句子是否与假设成entail关系。
SST-2 (Stanford Sentiment Treebank) 判断一个句子的情感正负向.
STS-B (Semantic Textual Similarity Benchmark) 判断两个句子的相似性(分数为1-5分)。
WNLI (Winograd Natural Language Inference) 判断一个有匿名代词的句子和一个有该代词被替换的句子是否包含。Determine if a sentence with an anonymous pronoun and a sentence with this pronoun replaced are entailed or not.

加载数据

下面介绍两种使用🤗 Datasets库来加载数据load_dataset的方法,主要参考官方文档

  1. 加载官方库的数据;
  2. 加载自己的数据或来自网络的数据:
    1. csv格式;
    2. json格式;
    3. txt格式
    4. pandas.DataFrame格式。

加载官方库的数据

除了mnli-mm以外,其他任务都可以直接通过任务名字进行加载。数据加载之后会自动缓存。

from datasets import load_dataset
actual_task = "mnli" if task == "mnli-mm" else task
dataset = load_dataset("glue", actual_task)
metric = load_metric('glue', actual_task)

这个datasets对象本身是一种DatasetDict数据结构. 对于训练集、验证集和测试集,只需要使用对应的key(train,validation,test)即可得到相应的数据

给定一个数据切分的key(train、validation或者test)和下标即可查看数据:dataset["train"][0]

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

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

def show_random_elements(dataset, num_examples=10):
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, datasets.ClassLabel):
df[column] = df[column].transform(lambda i: typ.names[i])
display(HTML(df.to_html()))

show_random_elements(dataset["train"])

加载自己的数据或来自网络的数据

csv格式

data_files为本地文件名或网络数据链接,如果没有用字典指定训练集、验证集、测试集,默认都为训练集。

from datasets import load_dataset
dataset = load_dataset('csv', data_files='my_file.csv')
dataset = load_dataset('csv', data_files=['my_file_1.csv', 'my_file_2.csv', 'my_file_3.csv'])
dataset = load_dataset('csv', data_files={'train': ['my_train_file_1.csv', 'my_train_file_2.csv'],
base_url = 'https://huggingface.co/datasets/lhoestq/demo1/resolve/main/data/'
dataset = load_dataset('csv', data_files={'train': base_url + 'train.csv', 'test': base_url + 'test.csv'})
json格式

情况1:json数据不包括嵌套的json,比如:

{"a": 1, "b": 2.0, "c": "foo", "d": false}
{"a": 4, "b": -5.5, "c": null, "d": true}

此时可以直接加载数据:

from datasets import load_dataset
dataset = load_dataset('json', data_files={'train': ['my_text_1.json', 'my_text_2.json'], 'test': 'my_test_file.json'})

dataset = load_dataset('text', data_files={'train': 'https://huggingface.co/datasets/lhoestq/test/resolve/main/some_text.json'})

情况2:json数据包括嵌套的json,比如:

{"version": "0.1.0",
"data": [{"a": 1, "b": 2.0, "c": "foo", "d": false},
{"a": 4, "b": -5.5, "c": null, "d": true}]
}

此时需要使用 field 参数指定哪个字段包含数据集:

from datasets import load_dataset
dataset = load_dataset('json', data_files='my_file.json', field='data')
txt格式
from datasets import load_dataset
dataset = load_dataset('text', data_files={'train': ['my_text_1.txt', 'my_text_2.txt'], 'test': 'my_test_file.txt'})

dataset = load_dataset('text', data_files={'train': 'https://huggingface.co/datasets/lhoestq/test/resolve/main/some_text.txt'})
dict格式
my_dict = {'id': [0, 1, 2],
'name': ['mary', 'bob', 'eve'],
'age': [24, 53, 19]}
from datasets import Dataset
dataset = Dataset.from_dict(my_dict)
pandas.DataFrame格式
from datasets import Dataset
import pandas as pd
df = pd.DataFrame({"a": [1, 2, 3]})
dataset = Dataset.from_pandas(df)

数据预处理

在将数据喂入模型之前,我们需要对数据进行预处理。之前我们已经知道了数据预处理的基本流程:

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

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

初始化Tokenizer

使用AutoTokenizer.from_pretrained方法根据模型文件实例化tokenizer,这样可以确保:

  • 得到一个与预训练模型一一对应的tokenizer
  • 使用指定的模型checkpoint对应的tokenizer时,同时下载了模型需要的词表库vocabulary,准确来说是tokens vocabulary。
from transformers import AutoTokenizer
tokenizer = AutoTokenizer.from_pretrained(model_checkpoint, use_fast=True)

注意:use_fast=True要求tokenizer必须是transformers.PreTrainedTokenizerFast类型,以便在预处理的时候需要用到fast tokenizer的一些特殊特性(比如多线程快速tokenizer)。如果对应的模型没有fast tokenizer,去掉这个选项即可。

几乎所有模型对应的tokenizer都有对应的fast tokenizer,可以在模型tokenizer对应表里查看所有预训练模型对应的tokenizer所拥有的特点。

Tokenizer分词示例

预训练的Tokenizer通常包含了分单句分一对句子的函数。如:

#分单句(一个batch)
batch_sentences = ["Hello I'm a single sentence",
"And another sentence",
"And the very very last one"]
encoded_inputs = tokenizer(batch_sentences)
print(encoded_inputs)
#{'input_ids': [[101, 8667, 146, 112, 182, 170, 1423, 5650, 102],
# [101, 1262, 1330, 5650, 102],
# [101, 1262, 1103, 1304, 1304, 1314, 1141, 102]],
# 'token_type_ids': [[0, 0, 0, 0, 0, 0, 0, 0, 0],
# [0, 0, 0, 0, 0],
# [0, 0, 0, 0, 0, 0, 0, 0]],
# 'attention_mask': [[1, 1, 1, 1, 1, 1, 1, 1, 1],
# [1, 1, 1, 1, 1],
# [1, 1, 1, 1, 1, 1, 1, 1]]}
#分一对句子
encoded_input = tokenizer("How old are you?", "I'm 6 years old")
print(encoded_input)
#{'input_ids': [101, 1731, 1385, 1132, 1128, 136, 102, 146, 112, 182, 127, #1201, 1385, 102],
# 'token_type_ids': [0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 1, 1, 1],
# 'attention_mask': [1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1]}

我们之前也提到如果是自己预训练的tokenizers可以通过以下方式为tokenizers增加处理一对句子的方法:

from tokenizers.processors import TemplateProcessing

tokenizer.post_processor = TemplateProcessing(
single="[CLS] $A [SEP]",
pair="[CLS] $A [SEP] $B:1 [SEP]:1",
special_tokens=[
("[CLS]", tokenizer.token_to_id("[CLS]")),
("[SEP]", tokenizer.token_to_id("[SEP]")),
],
)
#设置句子最大长度
tokenizer.enable_truncation(max_length=512)
#使用tokenizer.save()保存模型
tokenizer.save("data/tokenizer-wiki.json")

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

tokenizer有不同的返回取决于选择的预训练模型,tokenizer和预训练模型是一一对应的,更多信息可以在这里进行学习。

不同数据和对应的数据格式,为了预处理我们的数据,定义下面这个dict,以便分别用tokenizer处理输入是单句或句子对的情况。

task_to_keys = {
"cola": ("sentence", None),
"mnli": ("premise", "hypothesis"),
"mnli-mm": ("premise", "hypothesis"),
"mrpc": ("sentence1", "sentence2"),
"qnli": ("question", "sentence"),
"qqp": ("question1", "question2"),
"rte": ("sentence1", "sentence2"),
"sst2": ("sentence", None),
"stsb": ("sentence1", "sentence2"),
"wnli": ("sentence1", "sentence2"),
}

将预处理的代码放到一个函数中:

def preprocess_function(examples):
if sentence2_key is None:
return tokenizer(examples[sentence1_key], truncation=True)
return tokenizer(examples[sentence1_key], examples[sentence2_key], truncation=True)

前面我们已经展示了tokenizer处理一个小batch的案例。dataset类直接用索引就可以取对应下标的句子1和句子2,因此上面的预处理函数既可以处理单个样本,也可以对多个样本进行处理。如果输入是多个样本,那么返回的是一个list:

preprocess_function(dataset['train'][:5])
#{'input_ids': [[101, 2256, 2814, 2180, 1005, 1056, 4965, 2023, 4106, 1010, 2292, 2894, 1996, 2279, 2028, 2057, 16599, 1012, 102], [101, 2028, 2062, 18404, 2236, 3989, 1998, 1045, 1005, 1049, 3228, 2039, 1012, 102], [101, 2028, 2062, 18404, 2236, 3989, 2030, 1045, 1005, 1049, 3228, 2039, 1012, 102], [101, 1996, 2062, 2057, 2817, 16025, 1010, 1996, 13675, 16103, 2121, 2027, 2131, 1012, 102], [101, 2154, 2011, 2154, 1996, 8866, 2024, 2893, 14163, 8024, 3771, 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]]}

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

encoded_dataset = dataset.map(preprocess_function, batched=True)

返回的结果会自动被缓存,避免下次处理的时候重新计算(但是也要注意,如果输入有改动,可能会被缓存影响!)

datasets库函数会对输入的参数进行检测,判断是否有变化,如果没有变化就使用缓存数据,如果有变化就重新处理。但如果输入参数不变,想改变输入的时候,最好清理调这个缓存(使用load_from_cache_file=False参数)。另外,上面使用到的batched=True这个参数是tokenizer的特点,这会使用多线程同时并行对输入进行处理。

微调预训练模型

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

加载预训练模型

既然是做seq2seq任务,那么需要一个能解决这个任务的模型类。我们使用AutoModelForSequenceClassification 这个类

和tokenizer相似,from_pretrained方法同样可以帮助下载并加载模型,同时也会对模型进行缓存,也可以填入一个包括模型相关文件的文件夹(比如自己预训练的模型),这样会从本地直接加载。理论上可以使用各种各样的transformer模型(模型面板),解决任何文本分类分类任务。

需要注意的是:STS-B是一个回归问题,MNLI是一个3分类问题

from transformers import AutoModelForSequenceClassification, TrainingArguments, Trainer

task = "cola"
model_checkpoint = "distilbert-base-uncased" #所选择的预训练模型

num_labels = 3 if task.startswith("mnli") else 1 if task=="stsb" else 2
model = AutoModelForSequenceClassification.from_pretrained(model_checkpoint, num_labels=num_labels)

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

设定训练参数

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

batch_size = 16
metric_name = "pearson" if task == "stsb" else "matthews_correlation" if task == "cola" else "accuracy"

args = TrainingArguments(
"test-glue",
evaluation_strategy = "epoch", #每个epcoh会做一次验证评估;
save_strategy = "epoch",
learning_rate=2e-5,
per_device_train_batch_size=batch_size,
per_device_eval_batch_size=batch_size,
num_train_epochs=5,
weight_decay=0.01,
load_best_model_at_end=True,
metric_for_best_model=metric_name, #根据哪个评价指标选最优模型
)

定义评估方法

还有一件重要的事,我们需要选择一个合适的评价指标引导模型进行微调

我们使用🤗 Datasets库来加载评价指标计算库load_metric。metic是datasets.Metric的一个实例:

from datasets import load_metric

直接调用metric的compute方法,传入labelspredictions即可得到metric的值:

import numpy as np

fake_preds = np.random.randint(0, 2, size=(64,))
fake_labels = np.random.randint(0, 2, size=(64,))
metric.compute(predictions=fake_preds, references=fake_labels)
#{'matthews_correlation': 0.1513518081969605}

每一个文本分类任务所对应的metic有所不同,一定要将metric和任务对齐,具体如下:

GLUE benchmark分类任务 评价指标
CoLA Matthews Correlation Coefficient
MNLI (matched or mismatched) Accuracy
MRPC Accuracy and F1 score
QNLI Accuracy
QQP Accuracy and F1 score
RTE Accuracy
SST-2 Accuracy
STS-B Pearson Correlation Coefficient and Spearman's_Rank_Correlation_Coefficient
WNLI Accuracy

Trainer定义各个任务的评估方法compute_metrics

from datasets import load_metric
def compute_metrics(eval_pred):
predictions, labels = eval_pred
if task != "stsb":
predictions = np.argmax(predictions, axis=1)
else:
predictions = predictions[:, 0]
return metric.compute(predictions=predictions, references=labels)

开始训练

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

validation_key = "validation_mismatched" if task == "mnli-mm" else "validation_matched" if task == "mnli" else "validation"
trainer = Trainer(
model,
args,
train_dataset=encoded_dataset["train"],
eval_dataset=encoded_dataset[validation_key],
tokenizer=tokenizer,
compute_metrics=compute_metrics
)

开始训练:

trainer.train()

模型评估

训练完成后对模型进行评估:

trainer.evaluate()

超参数搜索

Trainer还支持超参搜索,使用optuna or Ray Tune代码库。

需要安装以下两个依赖:

pip install optuna
pip install ray[tune]

超参搜索时,Trainer将会返回多个训练好的模型,所以需要传入一个定义好的模型从而让Trainer可以不断重新初始化该传入的模型:

def model_init():
return AutoModelForSequenceClassification.from_pretrained(model_checkpoint, num_labels=num_labels)

和之前调用 Trainer类似:

trainer = Trainer(
model_init=model_init,
args=args,
train_dataset=encoded_dataset["train"],
eval_dataset=encoded_dataset[validation_key],
tokenizer=tokenizer,
compute_metrics=compute_metrics
)

调用方法hyperparameter_search进行超参数搜索

注意,这个过程可能很久,可以先用部分数据集进行超参搜索,再进行全量训练。 比如使用1/10的数据进行搜索(利用n_trials设置):

best_run = trainer.hyperparameter_search(n_trials=10, direction="maximize")

hyperparameter_search会返回效果最好的模型相关的参数best_run

Trainner设置为搜索到的最好参数best_run,再对全部数据进行训练:

for n, v in best_run.hyperparameters.items():
setattr(trainer.args, n, v)

trainer.train()

上传模型到huggingface

学习如何上传模型🤗 Model Hub。别人也可以用你上传的模型,通过网络直接用模型名字就能直接下载上传的模型。

参考文献

4.1-文本分类.ipynb

官方文档:transformers/training

dataset官方文档:加载本地数据