数据预处理
在投入模型进行训练之前,需要对数据进行适当的预处理。同时,也需要调整或“微调”一个预训练好的模型来适应你的特定任务。由于正在进行文本分类,所以当加载预训练模型时,可能需要忽略或舍弃某些不适用的网络参数。
使用Datasets库来下载数据,并且得到我们需要的评测指标(和benchmark基准进行比较)
使用函数完成
from datasets import load_dataset, load_metric
数据包括context,这代表一个段落,而对于这个段落会有几个问题和对应的答案,所以还需要question和text以及answer start,text就是question的答案。这个数据集一个question只有一个答案。result中的字段除了id外其余的就是我们训练需要的字段。
下载SQUAD数据集
datasets = load_dataset("squad_v2" if squad_v2 else "squad")
这个datasets对象是DataaetDict结构,训练、验证、测试分别对应这dict的一个key。
无论是训练集、验证集还是测试集,对于每一个问答数据样本都会有context,question和answers
预处理训练数据
导入库
import numpy as np
from datasets import load_dataset, load_metric
from transformers import AutoTokenizer
from transformers import AutoModelForQuestionAnswering, TrainingArguments, Trainer
from transformers import default_data_collator
import torch
import collections
from tqdm.auto import tqdm
数据预处理及转换
from transformers import AutoTokenizer
tokenizer = AutoTokenizer.from_pretrained(model_checkpoint)
在将数据喂入模型之前,需要对数据进行预处理。预处理的工具叫tokenizer。tokenizer首先对输入进行tokenize,然后将tokens转化为预模型中需要对应的token ID,再转化为模型需要的输入格式。
以下代码要求tokenizer必须是transformers.PreTrainedTokenizerFast类型
import transformers
assert isinstance(tokenizer, transformers.PreTrainedTokenizerFast)
如果想要看到tokenizer预处理之后的文本格式,仅使用tokenizer的tokenize方法,
add special tokens意思是增加预训练模型所要求的特殊token。
预训练模型输入格式要求的输入为token IDs,还需要attetnion mask。可以使用下面的方法得到预训练模型格式所要求的输入。
tokenizer既可以对单个文本进行预处理,也可以对一对文本进行预处理,tokenizer预处理后得到的数据满足预训练模型输入格式
# 对单个文本进行预处理
# 对2个文本进行预处理,可以看到tokenizer在开始添加了101 token ID,中间用102token ID区分两段文本,末尾用102结尾。这些规则都是预训练模型是所设计的。
长文本
把超长的输入切片为多个较短的输入,每个输入都要满足模型最大长度输入要求。由于答案可能存在与切片的地方,因此我们需要允许相邻切片之间有交集,代码中通过doc_stride参数控制。机器问答预训练模型通常将question和context拼接之后作为输入,然后让模型从context里寻找答案。
for循环遍历数据集,寻找一个超长样本,本notebook例子模型所要求的最大输入是38
max_length = 384 # 输入feature的最大长度,question和context拼接之后
doc_stride = 128 # 2个切片之间的重合token数量。
# 使用range(len())代替直接迭代,避免潜在的不兼容问题
for i in range(len(datasets["train"])):
example = datasets["train"][i]
tokenized_example = tokenizer(example["question"], example["context"])
if len(tokenized_example["input_ids"]) > max_length:
break
只对context进行切片,不会对问题进行切片,由于context是拼接在question后面的,对应着第2个文本,所以使用only_second控制.tokenizer使用doc_stride控制切片之间的重合长度。
# 准备训练数据并转换为feature
tokenized_example = tokenizer(
example["question"], # 问题文本
example["context"], # 篇章文本
max_length=max_length,
truncation="only_second", # 截断只发生在第二部分,即篇章
return_overflowing_tokens=True, # 超出最大长度的标记,将篇章切成多片
stride=doc_stride # 设定篇章切片步长
)
由于我们对超长文本进行了切片,我们需要重新寻找答案所在位置。机器问答模型将使用答案的位置作为训练标签。所以切片需要和原始输入有一个对应关系,每个token在切片后context的位置和原始超长context里位置的对应关系。在tokenizer里可以使用return_offsets_mapping参数得到这个对应关系的map:
tokenized_example = tokenizer(
example["question"],
example["context"],
max_length=max_length,
truncation="only_second",
return_overflowing_tokens=True,
return_offsets_mapping=True,
stride=doc_stride
)
# 打印切片前后位置下标的对应关系
print(tokenized_example["offset_mapping"][0][:100])
然后使用offset_mapping参数映射回切片前的token位置,找到原始位置的tokens。由于question拼接在context前面,直接从question里根据下标找。
first_token_id = tokenized_example["input_ids"][0][1]
offsets = tokenized_example["offset_mapping"][0][1]
print(tokenizer.convert_ids_to_tokens([first_token_id])[0], example["question"][offsets[0]:offsets[1]])
得到了切片前后的位置对应关系。我们还需要使用sequence_ids参数来区分question和context。
None对应了special tokens,然后0或者1分表代表第1个文本和第2个文本,由于我们qeustin第1个传入,context第2个传入,所以分别对应question和context。最终我们可以找到标注的答案在预处理之后的features里的位置:
tokenized_example = tokenizer(example["question"], example["context"], return_attention_mask=True, return_token_type_ids=True)
sequence_ids = tokenized_example['token_type_ids']
answers = example["answers"]
start_char = answers["answer_start"][0]
end_char = start_char + len(answers["text"][0])
# 找到当前文本的Start token index.
token_start_index = 0
while sequence_ids[token_start_index] != 1:
token_start_index += 1
# 找到当前文本的End token idnex.
token_end_index = len(tokenized_example["input_ids"]) - 1
while sequence_ids[token_end_index] != 1:
token_end_index -= 1
end_position = token_end_index + 1
print("start_position: {}, end_position: {}".format(start_position, end_position))
else:
print("The answer is not in this feature.")
有时question拼接context,而有时候是context拼接question,不同的模型有不同的要求,因此我们需要使用padding_side参数来指定。
pad_on_right = tokenizer.padding_side == "right" #context在右边
加载预训练模型
已经预处理好了训练/微调需要的数据,现在下载预训练的模型。由于要做的是机器问答任务,于是我们使用这个类AutoModelForQuestionAnswering。和tokenizer相似,model也是使用from_pretrained方法进行加载。
from transformers import AutoModelForQuestionAnswering, TrainingArguments, Trainer
model = AutoModelForQuestionAnswering.from_pretrained(model_checkpoint)
设定训练参数
# 训练设定参数
args = TrainingArguments(
f"test-squad",
evaluation_strategy = "epoch",
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,
)
训练模型
训练的时候,只计算loss。根据评测指标评估模型将会放在下一节。需要把模型,训练参数,数据,之前使用的tokenizer,和数据投递工具default_data_collator传入Tranier。
trainer = Trainer(
model,
args,
train_dataset=tokenized_datasets["train"],
eval_dataset=tokenized_datasets["validation"],
data_collator=data_collator,
tokenizer=tokenizer,
)
调用train方法开始进行训练
trainer.train()
得到模型预测输出结果
在调用 `trainer.train()` 后,模型开始训练过程。完成训练后,可以使用模型进行预测。
模型预测的主要输出是关于答案开始(start)和结束(end)位置的 logits(即,模型认为每个词可能是答案开始或结束位置的概率得分)。
如果您在评估时向模型输入一个批量(batch)的数据,输出结构大致如下:
在这个例子中,我们首先从评估数据加载器 `trainer.get_eval_dataloader()` 中获取一个批量的数据。然后,我们将这些数据移动到模型所在的设备
使用 `torch.no_grad()` 确保在进行前向传播时不会计算梯度,这样可以节省内存并加速计算。
运行模型后,`output` 将包含多个键值对,其中包括 loss、答案的开始和结束位置的 logits 等。在预测阶段,我们通常只关心开始和结束位置的 logits。
模型为输入中的每个 "feature"(在这里通常是文本中的每个单词或 sub-word)生成一个 logit 值,表示该词作为答案开始或结束位置的可能性。
最后,可以通过在 logits 上应用 `argmax` 函数来找出模型预测的答案的开始和结束位置。这将分别给出开始和结束位置的最大概率得分的索引。
模型本身预测的是answer所在start/end位置的logits。如果评估时喂入模型的是一个batch,输出如下:
import torch
for batch in trainer.get_eval_dataloader():
break
batch = {k: v.to(trainer.args.device) for k, v in batch.items()}
with torch.no_grad():
output = trainer.model(**batch)
output.keys()
模型的输出是一个像dict的数据结构,包含了loss,answer start和end的logits。在输出预测结果的时候不需要看loss,直接看logits。
output.start_logits.shape, output.end_logits.shape
(torch.Size([16, 384]), torch.Size([16, 384]))
每个feature里的每个token都会有一个logit。
预测answer
选择start的logits里最大的下标最为answer其实位置,end的logits里最大下标作为answer的结束位置。
output.start_logits.argmax(dim=-1), output.end_logits.argmax(dim=-1)