微调掩码语言模型
对于许多涉及 Transformer 模型的 NLP 应用,您可以直接从 Hugging Face Hub 获取预训练模型,并在您的数据上针对手头的任务对其进行微调。如果用于预训练的语料库与用于微调的语料库差别不大,则迁移学习通常会产生良好的结果。
但是,在某些情况下,您需要首先在您的数据上对语言模型进行微调,然后再训练特定于任务的头部。例如,如果您的数据集包含法律合同或科学文章,则像 BERT 这样的普通 Transformer 模型通常会将语料库中的特定于领域的词视为罕见词,并且产生的性能可能不尽如人意。通过在领域内数据上微调语言模型,您可以提高许多下游任务的性能,这意味着您通常只需要执行此步骤一次!
这种在领域内数据上微调预训练语言模型的过程通常称为领域自适应。它在 2018 年由ULMFiT推广,ULMFiT 是第一个使迁移学习真正适用于 NLP 的神经架构(基于 LSTM)之一。下图显示了使用 ULMFiT 进行领域自适应的示例;在本节中,我们将做类似的事情,但使用 Transformer 而不是 LSTM!
在本节结束时,您将拥有一个在 Hub 上的掩码语言模型,它可以像下面显示的那样自动补全句子
让我们开始吧!
🙋 如果“掩码语言建模”和“预训练模型”这两个术语对您来说很陌生,请查看第 1 章,我们将在那里解释所有这些核心概念,并提供视频讲解!
选择用于掩码语言建模的预训练模型
首先,让我们为掩码语言建模选择一个合适的预训练模型。如下面的屏幕截图所示,您可以通过在Hugging Face Hub上应用“填充掩码”过滤器来找到候选模型列表。
尽管 BERT 和 RoBERTa 系列模型下载次数最多,但我们将使用一个名为DistilBERT的模型,该模型可以更快地训练,并且在下游性能方面几乎没有损失。该模型使用一种称为知识蒸馏的特殊技术进行训练,其中像 BERT 这样的大型“教师模型”用于指导具有更少参数的“学生模型”的训练。对知识蒸馏细节的解释将使我们偏离本节的主题,但如果您有兴趣,可以在使用 Transformer 进行自然语言处理(俗称 Transformer 教材)中阅读所有相关内容。
让我们继续使用AutoModelForMaskedLM
类下载 DistilBERT
from transformers import AutoModelForMaskedLM
model_checkpoint = "distilbert-base-uncased"
model = AutoModelForMaskedLM.from_pretrained(model_checkpoint)
我们可以通过调用num_parameters()
方法查看该模型有多少个参数。
distilbert_num_parameters = model.num_parameters() / 1_000_000
print(f"'>>> DistilBERT number of parameters: {round(distilbert_num_parameters)}M'")
print(f"'>>> BERT number of parameters: 110M'")
'>>> DistilBERT number of parameters: 67M'
'>>> BERT number of parameters: 110M'
DistilBERT 拥有大约 6700 万个参数,大约是 BERT 基本模型的一半,这大致转化为训练速度提高两倍——不错!现在让我们看看这种模型预测哪些标记最可能是少量文本示例中最可能的补全。
text = "This is a great [MASK]."
作为人类,我们可以想象 [MASK]
标记的许多可能性,例如“day”、“ride”或“painting”。对于预训练模型,预测取决于模型训练所依据的语料库,因为它学习捕捉数据中存在的统计模式。与 BERT 一样,DistilBERT 也在英文维基百科和BookCorpus数据集上进行预训练,因此我们预计[MASK]
的预测会反映这些领域。为了预测掩码,我们需要 DistilBERT 的分词器生成模型的输入,因此让我们也从 Hub 下载它。
from transformers import AutoTokenizer
tokenizer = AutoTokenizer.from_pretrained(model_checkpoint)
有了分词器和模型,我们现在可以将我们的文本示例传递给模型,提取 logits,并打印出前 5 个候选者。
import torch
inputs = tokenizer(text, return_tensors="pt")
token_logits = model(**inputs).logits
# Find the location of [MASK] and extract its logits
mask_token_index = torch.where(inputs["input_ids"] == tokenizer.mask_token_id)[1]
mask_token_logits = token_logits[0, mask_token_index, :]
# Pick the [MASK] candidates with the highest logits
top_5_tokens = torch.topk(mask_token_logits, 5, dim=1).indices[0].tolist()
for token in top_5_tokens:
print(f"'>>> {text.replace(tokenizer.mask_token, tokenizer.decode([token]))}'")
'>>> This is a great deal.'
'>>> This is a great success.'
'>>> This is a great adventure.'
'>>> This is a great idea.'
'>>> This is a great feat.'
我们可以从输出中看到模型的预测指的是日常术语,鉴于英文维基百科的基础,这也许并不令人惊讶。让我们看看如何将此领域更改为更利基的领域——高度两极分化的电影评论!
数据集
为了展示领域自适应,我们将使用著名的大型电影评论数据集(简称 IMDb),这是一个经常用于对情感分析模型进行基准测试的电影评论语料库。通过在该语料库上微调 DistilBERT,我们预计语言模型将使其词汇从其预训练的维基百科事实数据适应到电影评论中更主观的元素。我们可以使用 🤗 Datasets 中的load_dataset()
函数从 Hugging Face Hub 获取数据。
from datasets import load_dataset
imdb_dataset = load_dataset("imdb")
imdb_dataset
DatasetDict({
train: Dataset({
features: ['text', 'label'],
num_rows: 25000
})
test: Dataset({
features: ['text', 'label'],
num_rows: 25000
})
unsupervised: Dataset({
features: ['text', 'label'],
num_rows: 50000
})
})
我们可以看到train
和test
拆分分别包含 25,000 条评论,而有一个名为unsupervised
的未标记拆分包含 50,000 条评论。让我们查看一些示例,以了解我们正在处理哪种类型的文本。就像我们在课程的前几章中所做的那样,我们将链接Dataset.shuffle()
和Dataset.select()
函数以创建随机样本。
sample = imdb_dataset["train"].shuffle(seed=42).select(range(3))
for row in sample:
print(f"\n'>>> Review: {row['text']}'")
print(f"'>>> Label: {row['label']}'")
'>>> Review: This is your typical Priyadarshan movie--a bunch of loony characters out on some silly mission. His signature climax has the entire cast of the film coming together and fighting each other in some crazy moshpit over hidden money. Whether it is a winning lottery ticket in Malamaal Weekly, black money in Hera Pheri, "kodokoo" in Phir Hera Pheri, etc., etc., the director is becoming ridiculously predictable. Don\'t get me wrong; as clichéd and preposterous his movies may be, I usually end up enjoying the comedy. However, in most his previous movies there has actually been some good humor, (Hungama and Hera Pheri being noteworthy ones). Now, the hilarity of his films is fading as he is using the same formula over and over again.<br /><br />Songs are good. Tanushree Datta looks awesome. Rajpal Yadav is irritating, and Tusshar is not a whole lot better. Kunal Khemu is OK, and Sharman Joshi is the best.'
'>>> Label: 0'
'>>> Review: Okay, the story makes no sense, the characters lack any dimensionally, the best dialogue is ad-libs about the low quality of movie, the cinematography is dismal, and only editing saves a bit of the muddle, but Sam" Peckinpah directed the film. Somehow, his direction is not enough. For those who appreciate Peckinpah and his great work, this movie is a disappointment. Even a great cast cannot redeem the time the viewer wastes with this minimal effort.<br /><br />The proper response to the movie is the contempt that the director San Peckinpah, James Caan, Robert Duvall, Burt Young, Bo Hopkins, Arthur Hill, and even Gig Young bring to their work. Watch the great Peckinpah films. Skip this mess.'
'>>> Label: 0'
'>>> Review: I saw this movie at the theaters when I was about 6 or 7 years old. I loved it then, and have recently come to own a VHS version. <br /><br />My 4 and 6 year old children love this movie and have been asking again and again to watch it. <br /><br />I have enjoyed watching it again too. Though I have to admit it is not as good on a little TV.<br /><br />I do not have older children so I do not know what they would think of it. <br /><br />The songs are very cute. My daughter keeps singing them over and over.<br /><br />Hope this helps.'
'>>> Label: 1'
是的,这些确实是电影评论,如果你年龄够大,甚至可能理解最后一条评论中关于拥有 VHS 版本的评论😜!虽然我们不需要用于语言建模的标签,但我们已经可以看到0
表示负面评论,而1
则对应正面评论。
✏️ 试试看! 创建一个unsupervised
分割的随机样本,并验证标签既不是0
也不是1
。趁此机会,你还可以检查train
和test
分割中的标签确实是0
或1
——这是一个有用的健全性检查,每个 NLP 从业者都应该在新项目的开始执行!
现在我们已经快速浏览了数据,让我们深入了解如何为掩码语言建模准备数据。正如我们将看到的,与我们在第 3 章中看到的序列分类任务相比,需要采取一些额外的步骤。让我们开始吧!
数据预处理
对于自回归和掩码语言建模,一个常见的预处理步骤是连接所有示例,然后将整个语料库分成大小相等的部分。这与我们通常的做法大不相同,我们通常只是对单个示例进行标记化。为什么要将所有内容连接在一起?原因是如果单个示例太长,可能会被截断,这会导致丢失可能对语言建模任务有用的信息!
因此,首先,我们将像往常一样对语料库进行标记化,但不在标记器中设置truncation=True
选项。如果可用(如果我们使用的是快速标记器,如第 6 章中所述,则会可用),我们也会获取单词 ID,因为稍后我们将需要它们来进行整词掩码。我们将将其封装在一个简单的函数中,并且在这样做的时候,我们将删除text
和label
列,因为我们不再需要它们了。
def tokenize_function(examples):
result = tokenizer(examples["text"])
if tokenizer.is_fast:
result["word_ids"] = [result.word_ids(i) for i in range(len(result["input_ids"]))]
return result
# Use batched=True to activate fast multithreading!
tokenized_datasets = imdb_dataset.map(
tokenize_function, batched=True, remove_columns=["text", "label"]
)
tokenized_datasets
DatasetDict({
train: Dataset({
features: ['attention_mask', 'input_ids', 'word_ids'],
num_rows: 25000
})
test: Dataset({
features: ['attention_mask', 'input_ids', 'word_ids'],
num_rows: 25000
})
unsupervised: Dataset({
features: ['attention_mask', 'input_ids', 'word_ids'],
num_rows: 50000
})
})
由于 DistilBERT 是一个类似 BERT 的模型,我们可以看到编码后的文本包含我们在其他章节中看到的input_ids
和attention_mask
,以及我们添加的word_ids
。
现在我们已经对电影评论进行了标记化,下一步是将它们全部组合在一起,并将结果分成块。但是这些块应该有多大?这最终将取决于你可用的 GPU 内存量,但一个好的起点是查看模型的最大上下文大小。可以通过检查标记器的model_max_length
属性来推断这一点。
tokenizer.model_max_length
512
此值派生自与检查点关联的tokenizer_config.json文件;在这种情况下,我们可以看到上下文大小为 512 个标记,就像 BERT 一样。
✏️ 试试看! 一些 Transformer 模型,如BigBird和Longformer,具有比 BERT 和其他早期 Transformer 模型长得多的上下文长度。为这些检查点之一实例化标记器,并验证model_max_length
是否与其模型卡上引用的内容一致。
因此,为了在 Google Colab 上的 GPU(如 Google Colab 上的 GPU)上运行我们的实验,我们将选择稍微小一点的东西,以便它可以放入内存中。
chunk_size = 128
请注意,在现实世界的场景中,使用较小的块大小可能会造成不利影响,因此你应该使用与你将模型应用到的用例相对应的块大小。
现在是激动人心的部分。为了展示连接是如何工作的,让我们从标记化的训练集中获取一些评论,并打印出每条评论的标记数量。
# Slicing produces a list of lists for each feature
tokenized_samples = tokenized_datasets["train"][:3]
for idx, sample in enumerate(tokenized_samples["input_ids"]):
print(f"'>>> Review {idx} length: {len(sample)}'")
'>>> Review 0 length: 200'
'>>> Review 1 length: 559'
'>>> Review 2 length: 192'
然后,我们可以使用简单的字典推导将所有这些示例连接起来,如下所示。
concatenated_examples = {
k: sum(tokenized_samples[k], []) for k in tokenized_samples.keys()
}
total_length = len(concatenated_examples["input_ids"])
print(f"'>>> Concatenated reviews length: {total_length}'")
'>>> Concatenated reviews length: 951'
太好了,总长度检查通过了——所以现在让我们将连接后的评论分成chunk_size
给定大小的块。为此,我们遍历concatenated_examples
中的特征,并使用列表推导来创建每个特征的切片。结果是每个特征的块字典。
chunks = {
k: [t[i : i + chunk_size] for i in range(0, total_length, chunk_size)]
for k, t in concatenated_examples.items()
}
for chunk in chunks["input_ids"]:
print(f"'>>> Chunk length: {len(chunk)}'")
'>>> Chunk length: 128'
'>>> Chunk length: 128'
'>>> Chunk length: 128'
'>>> Chunk length: 128'
'>>> Chunk length: 128'
'>>> Chunk length: 128'
'>>> Chunk length: 128'
'>>> Chunk length: 55'
正如你在此示例中看到的,最后一个块通常会小于最大块大小。处理此问题主要有两种策略。
- 如果最后一个块小于
chunk_size
,则将其删除。 - 填充最后一个块,直到其长度等于
chunk_size
。
我们将在此处采用第一种方法,因此让我们将所有上述逻辑封装在一个可以应用于标记化数据集的单个函数中。
def group_texts(examples):
# Concatenate all texts
concatenated_examples = {k: sum(examples[k], []) for k in examples.keys()}
# Compute length of concatenated texts
total_length = len(concatenated_examples[list(examples.keys())[0]])
# We drop the last chunk if it's smaller than chunk_size
total_length = (total_length // chunk_size) * chunk_size
# Split by chunks of max_len
result = {
k: [t[i : i + chunk_size] for i in range(0, total_length, chunk_size)]
for k, t in concatenated_examples.items()
}
# Create a new labels column
result["labels"] = result["input_ids"].copy()
return result
请注意,在group_texts()
的最后一步,我们创建了一个新的labels
列,它是input_ids
列的副本。正如我们很快将看到的,这是因为在掩码语言建模中,目标是预测输入批次中随机掩码的标记,并且通过创建labels
列,我们为我们的语言模型提供了学习的真实情况。
现在让我们使用我们可靠的Dataset.map()
函数将group_texts()
应用于我们标记化后的数据集。
lm_datasets = tokenized_datasets.map(group_texts, batched=True)
lm_datasets
DatasetDict({
train: Dataset({
features: ['attention_mask', 'input_ids', 'labels', 'word_ids'],
num_rows: 61289
})
test: Dataset({
features: ['attention_mask', 'input_ids', 'labels', 'word_ids'],
num_rows: 59905
})
unsupervised: Dataset({
features: ['attention_mask', 'input_ids', 'labels', 'word_ids'],
num_rows: 122963
})
})
你可以看到,对文本进行分组然后分块产生了比我们原始的train
和test
分割的 25,000 个示例多得多的示例。这是因为我们现在有包含连续标记的示例,这些标记跨越了原始语料库中的多个示例。你可以通过在其中一个块中查找特殊的[SEP]
和[CLS]
标记来明确地看到这一点。
tokenizer.decode(lm_datasets["train"][1]["input_ids"])
".... at.......... high. a classic line : inspector : i'm here to sack one of your teachers. student : welcome to bromwell high. i expect that many adults of my age think that bromwell high is far fetched. what a pity that it isn't! [SEP] [CLS] homelessness ( or houselessness as george carlin stated ) has been an issue for years but never a plan to help those on the street that were once considered human who did everything from going to school, work, or vote for the matter. most people think of the homeless"
在此示例中,你可以看到两篇重叠的电影评论,一篇关于高中电影,另一篇关于无家可归。让我们也看看掩码语言建模的标签是什么样子。
tokenizer.decode(lm_datasets["train"][1]["labels"])
".... at.......... high. a classic line : inspector : i'm here to sack one of your teachers. student : welcome to bromwell high. i expect that many adults of my age think that bromwell high is far fetched. what a pity that it isn't! [SEP] [CLS] homelessness ( or houselessness as george carlin stated ) has been an issue for years but never a plan to help those on the street that were once considered human who did everything from going to school, work, or vote for the matter. most people think of the homeless"
正如我们上面group_texts()
函数所预期的那样,这看起来与解码后的input_ids
相同——但是我们的模型怎么可能学习任何东西呢?我们缺少一个关键步骤:在输入中的随机位置插入[MASK]
标记!让我们看看如何在微调期间使用特殊的数据整理器动态地做到这一点。
使用 Trainer API 微调 DistilBERT
微调掩码语言模型几乎与微调序列分类模型相同,就像我们在第 3 章中所做的那样。唯一的区别是我们需要一个特殊的数据整理器,它可以随机掩码每个文本批次中的一些标记。幸运的是,🤗 Transformers 为此任务准备了一个专用的DataCollatorForLanguageModeling
。我们只需要将标记器和一个mlm_probability
参数传递给它,该参数指定要掩码的标记的比例。我们将选择 15%,这是 BERT 使用的量,也是文献中常见的选择。
from transformers import DataCollatorForLanguageModeling
data_collator = DataCollatorForLanguageModeling(tokenizer=tokenizer, mlm_probability=0.15)
为了了解随机掩码是如何工作的,让我们将一些示例馈送到数据整理器。由于它期望一个dict
列表,其中每个dict
表示一个连续文本的单个块,因此在将批次馈送到整理器之前,我们首先遍历数据集。我们为这个数据整理器删除了"word_ids"
键,因为它不需要它。
samples = [lm_datasets["train"][i] for i in range(2)]
for sample in samples:
_ = sample.pop("word_ids")
for chunk in data_collator(samples)["input_ids"]:
print(f"\n'>>> {tokenizer.decode(chunk)}'")
'>>> [CLS] bromwell [MASK] is a cartoon comedy. it ran at the same [MASK] as some other [MASK] about school life, [MASK] as " teachers ". [MASK] [MASK] [MASK] in the teaching [MASK] lead [MASK] to believe that bromwell high\'[MASK] satire is much closer to reality than is " teachers ". the scramble [MASK] [MASK] financially, the [MASK]ful students whogn [MASK] right through [MASK] pathetic teachers\'pomp, the pettiness of the whole situation, distinction remind me of the schools i knew and their students. when i saw [MASK] episode in [MASK] a student repeatedly tried to burn down the school, [MASK] immediately recalled. [MASK]...'
'>>> .... at.. [MASK]... [MASK]... high. a classic line plucked inspector : i\'[MASK] here to [MASK] one of your [MASK]. student : welcome to bromwell [MASK]. i expect that many adults of my age think that [MASK]mwell [MASK] is [MASK] fetched. what a pity that it isn\'t! [SEP] [CLS] [MASK]ness ( or [MASK]lessness as george 宇in stated )公 been an issue for years but never [MASK] plan to help those on the street that were once considered human [MASK] did everything from going to school, [MASK], [MASK] vote for the matter. most people think [MASK] the homeless'
不错,它起作用了!我们可以看到[MASK]
标记已在我们的文本中各种位置随机插入。这些将是我们模型在训练期间必须预测的标记——数据整理器的妙处在于它将在每个批次中随机化[MASK]
的插入!
✏️ 试试看! 运行上面的代码片段几次,亲眼见证随机掩码的发生!还可以将tokenizer.decode()
方法替换为tokenizer.convert_ids_to_tokens()
,以查看有时会掩码给定单词中的单个标记,而不是其他标记。
随机掩码的一个副作用是,当使用Trainer
时,我们的评估指标将不确定,因为我们对训练集和测试集使用相同的数据整理器。我们稍后将在查看使用 🤗 Accelerate 进行微调时看到,如何使用自定义评估循环的灵活性来冻结随机性。
在训练掩码语言建模模型时,可以使用的一种技术是将整个单词一起掩码,而不仅仅是单个标记。这种方法称为整词掩码。如果我们想使用整词掩码,我们将需要自己构建一个数据整理器。数据整理器只是一个函数,它接受一个样本列表并将其转换为批次,所以让我们现在就来做吧!我们将使用前面计算的单词 ID 来创建单词索引与其对应标记之间的映射,然后随机决定要掩码哪些单词并将其应用于输入。请注意,标签全部为-100
,除了与掩码单词对应的标签。
import collections
import numpy as np
from transformers import default_data_collator
wwm_probability = 0.2
def whole_word_masking_data_collator(features):
for feature in features:
word_ids = feature.pop("word_ids")
# Create a map between words and corresponding token indices
mapping = collections.defaultdict(list)
current_word_index = -1
current_word = None
for idx, word_id in enumerate(word_ids):
if word_id is not None:
if word_id != current_word:
current_word = word_id
current_word_index += 1
mapping[current_word_index].append(idx)
# Randomly mask words
mask = np.random.binomial(1, wwm_probability, (len(mapping),))
input_ids = feature["input_ids"]
labels = feature["labels"]
new_labels = [-100] * len(labels)
for word_id in np.where(mask)[0]:
word_id = word_id.item()
for idx in mapping[word_id]:
new_labels[idx] = labels[idx]
input_ids[idx] = tokenizer.mask_token_id
feature["labels"] = new_labels
return default_data_collator(features)
接下来,我们可以像以前一样尝试它。
samples = [lm_datasets["train"][i] for i in range(2)]
batch = whole_word_masking_data_collator(samples)
for chunk in batch["input_ids"]:
print(f"\n'>>> {tokenizer.decode(chunk)}'")
'>>> [CLS] bromwell high is a cartoon comedy [MASK] it ran at the same time as some other programs about school life, such as " teachers ". my 35 years in the teaching profession lead me to believe that bromwell high\'s satire is much closer to reality than is " teachers ". the scramble to survive financially, the insightful students who can see right through their pathetic teachers\'pomp, the pettiness of the whole situation, all remind me of the schools i knew and their students. when i saw the episode in which a student repeatedly tried to burn down the school, i immediately recalled.....'
'>>> .... [MASK] [MASK] [MASK] [MASK]....... high. a classic line : inspector : i\'m here to sack one of your teachers. student : welcome to bromwell high. i expect that many adults of my age think that bromwell high is far fetched. what a pity that it isn\'t! [SEP] [CLS] homelessness ( or houselessness as george carlin stated ) has been an issue for years but never a plan to help those on the street that were once considered human who did everything from going to school, work, or vote for the matter. most people think of the homeless'
✏️ 试试看! 运行上面的代码片段几次,亲眼见证随机掩码的发生!还可以将tokenizer.decode()
方法替换为tokenizer.convert_ids_to_tokens()
,以查看给定单词中的标记始终一起被掩码。
现在我们有两个数据整理器了,其余的微调步骤都是标准的。如果你没有足够幸运地获得一个神话般的 P100 GPU 😭,那么在 Google Colab 上的训练可能需要一段时间,因此我们首先将训练集的大小减少到几千个示例。别担心,我们仍然可以获得一个相当不错的语言模型!在 🤗 Datasets 中快速对数据集进行下采样的一种方法是通过我们在第 5 章中看到的Dataset.train_test_split()
函数。
train_size = 10_000
test_size = int(0.1 * train_size)
downsampled_dataset = lm_datasets["train"].train_test_split(
train_size=train_size, test_size=test_size, seed=42
)
downsampled_dataset
DatasetDict({
train: Dataset({
features: ['attention_mask', 'input_ids', 'labels', 'word_ids'],
num_rows: 10000
})
test: Dataset({
features: ['attention_mask', 'input_ids', 'labels', 'word_ids'],
num_rows: 1000
})
})
这会自动创建新的train
和test
分割,训练集大小设置为 10,000 个示例,验证集设置为训练集的 10%——如果你有强大的 GPU,可以随意增加它!接下来我们需要做的是登录到 Hugging Face Hub。如果你在笔记本中运行此代码,可以使用以下实用程序函数。
from huggingface_hub import notebook_login
notebook_login()
它将显示一个窗口小部件,你可以在其中输入你的凭据。或者,你可以在
huggingface-cli login
你喜欢的终端中运行,并在那里登录。
登录后,我们可以为Trainer
指定参数。
from transformers import TrainingArguments
batch_size = 64
# Show the training loss with every epoch
logging_steps = len(downsampled_dataset["train"]) // batch_size
model_name = model_checkpoint.split("/")[-1]
training_args = TrainingArguments(
output_dir=f"{model_name}-finetuned-imdb",
overwrite_output_dir=True,
evaluation_strategy="epoch",
learning_rate=2e-5,
weight_decay=0.01,
per_device_train_batch_size=batch_size,
per_device_eval_batch_size=batch_size,
push_to_hub=True,
fp16=True,
logging_steps=logging_steps,
)
在这里,我们调整了一些默认选项,包括logging_steps
以确保我们跟踪每个 epoch 的训练损失。我们还使用了fp16=True
来启用混合精度训练,这使我们能够进一步提高速度。默认情况下,Trainer
将删除任何不属于模型forward()
方法一部分的列。这意味着如果你使用的是整词掩码整理器,你还需要设置remove_unused_columns=False
以确保我们在训练期间不会丢失word_ids
列。
请注意,您可以使用hub_model_id
参数指定要推送到哪个仓库(特别是,您必须使用此参数才能推送到组织)。例如,当我们将模型推送到huggingface-course
组织时,我们在TrainingArguments
中添加了hub_model_id="huggingface-course/distilbert-finetuned-imdb"
。默认情况下,使用的仓库将在您的命名空间中,并以您设置的输出目录命名,因此在我们的例子中,它将是"lewtun/distilbert-finetuned-imdb"
。
现在我们已经具备了实例化Trainer
的所有要素。在这里,我们只使用标准的data_collator
,但您可以尝试使用整个单词掩码的data_collator
,并将结果进行比较作为练习。
from transformers import Trainer
trainer = Trainer(
model=model,
args=training_args,
train_dataset=downsampled_dataset["train"],
eval_dataset=downsampled_dataset["test"],
data_collator=data_collator,
tokenizer=tokenizer,
)
现在我们准备运行trainer.train()
了——但在运行之前,让我们简要了解一下困惑度,这是一个用于评估语言模型性能的常用指标。
语言模型的困惑度
与其他任务(如文本分类或问答)不同,在这些任务中,我们使用带标签的语料库进行训练,而在语言建模中,我们没有任何显式的标签。那么,我们如何确定什么是好的语言模型呢?就像您手机中的自动更正功能一样,一个好的语言模型是能够为语法正确的句子分配高概率,为无意义的句子分配低概率的模型。为了让您更好地了解这一点,您可以在网上找到很多“自动更正失败”的例子,其中手机中的模型产生了一些相当有趣(并且经常不恰当)的补全!
假设我们的测试集主要包含语法正确的句子,那么衡量语言模型质量的一种方法是计算它为测试集中所有句子中下一个单词分配的概率。高概率表明模型对未见过的示例“不感到惊讶”或“困惑”,并表明它已经学习了语言中基本的语法模式。困惑度有各种各样的数学定义,但我们将使用的一个定义是将其定义为交叉熵损失的指数。因此,我们可以通过使用Trainer.evaluate()
函数计算测试集上的交叉熵损失,然后取结果的指数来计算我们预训练模型的困惑度。
import math
eval_results = trainer.evaluate()
print(f">>> Perplexity: {math.exp(eval_results['eval_loss']):.2f}")
>>> Perplexity: 21.75
困惑度得分越低,表示语言模型越好,我们可以看到,我们的起始模型的困惑度值有点大。让我们看看是否可以通过微调来降低它!为此,我们首先运行训练循环。
trainer.train()
然后像以前一样计算测试集上得到的困惑度。
eval_results = trainer.evaluate()
print(f">>> Perplexity: {math.exp(eval_results['eval_loss']):.2f}")
>>> Perplexity: 11.32
不错——困惑度大幅降低,这告诉我们模型已经学习了一些关于电影评论领域的信息!
训练完成后,我们可以将包含训练信息的模型卡片推送到Hub(检查点在训练过程中本身就会保存)。
trainer.push_to_hub()
✏️ 轮到你了!在将数据整理器更改为整个单词掩码的整理器后,运行上面的训练。您是否获得了更好的结果?
在我们的用例中,我们不需要对训练循环做任何特殊处理,但在某些情况下,您可能需要实现一些自定义逻辑。对于这些应用,您可以使用 🤗 Accelerate——让我们来看一下!
使用 🤗 Accelerate 微调 DistilBERT
正如我们在Trainer
中看到的,微调掩码语言模型与第 3 章中的文本分类示例非常相似。事实上,唯一的细微差别是使用了特殊的数据整理器,我们已经在本节的前面介绍过!
但是,我们看到DataCollatorForLanguageModeling
在每次评估时也应用随机掩码,因此我们会在每次训练运行中看到困惑度分数的一些波动。消除此随机性来源的一种方法是在整个测试集上一次应用掩码,然后在 🤗 Transformers 中使用默认数据整理器在评估期间收集批次。要了解其工作原理,让我们实现一个简单的函数,该函数在批次上应用掩码,类似于我们第一次遇到DataCollatorForLanguageModeling
时。
def insert_random_mask(batch):
features = [dict(zip(batch, t)) for t in zip(*batch.values())]
masked_inputs = data_collator(features)
# Create a new "masked" column for each column in the dataset
return {"masked_" + k: v.numpy() for k, v in masked_inputs.items()}
接下来,我们将此函数应用于我们的测试集并删除未掩码的列,以便我们可以用掩码的列替换它们。您可以通过将上面的data_collator
替换为合适的整理器来使用整个单词掩码,在这种情况下,您应该删除此处的第一行。
downsampled_dataset = downsampled_dataset.remove_columns(["word_ids"])
eval_dataset = downsampled_dataset["test"].map(
insert_random_mask,
batched=True,
remove_columns=downsampled_dataset["test"].column_names,
)
eval_dataset = eval_dataset.rename_columns(
{
"masked_input_ids": "input_ids",
"masked_attention_mask": "attention_mask",
"masked_labels": "labels",
}
)
然后,我们可以像往常一样设置数据加载器,但我们将使用 🤗 Transformers 中的default_data_collator
作为评估集。
from torch.utils.data import DataLoader
from transformers import default_data_collator
batch_size = 64
train_dataloader = DataLoader(
downsampled_dataset["train"],
shuffle=True,
batch_size=batch_size,
collate_fn=data_collator,
)
eval_dataloader = DataLoader(
eval_dataset, batch_size=batch_size, collate_fn=default_data_collator
)
从这里开始,我们遵循 🤗 Accelerate 的标准步骤。首先要加载预训练模型的新版本。
model = AutoModelForMaskedLM.from_pretrained(model_checkpoint)
然后我们需要指定优化器;我们将使用标准的AdamW
。
from torch.optim import AdamW
optimizer = AdamW(model.parameters(), lr=5e-5)
使用这些对象,我们现在可以使用Accelerator
对象为训练做好一切准备。
from accelerate import Accelerator
accelerator = Accelerator()
model, optimizer, train_dataloader, eval_dataloader = accelerator.prepare(
model, optimizer, train_dataloader, eval_dataloader
)
现在我们的模型、优化器和数据加载器都已配置好,我们可以按如下方式指定学习率调度器。
from transformers import get_scheduler
num_train_epochs = 3
num_update_steps_per_epoch = len(train_dataloader)
num_training_steps = num_train_epochs * num_update_steps_per_epoch
lr_scheduler = get_scheduler(
"linear",
optimizer=optimizer,
num_warmup_steps=0,
num_training_steps=num_training_steps,
)
在训练之前还有一件事要做:在 Hugging Face Hub 上创建模型仓库!我们可以使用 🤗 Hub 库首先生成我们仓库的全名。
from huggingface_hub import get_full_repo_name
model_name = "distilbert-base-uncased-finetuned-imdb-accelerate"
repo_name = get_full_repo_name(model_name)
repo_name
'lewtun/distilbert-base-uncased-finetuned-imdb-accelerate'
然后使用 🤗 Hub 中的Repository
类创建并克隆仓库。
from huggingface_hub import Repository
output_dir = model_name
repo = Repository(output_dir, clone_from=repo_name)
完成此操作后,只需编写完整的训练和评估循环即可。
from tqdm.auto import tqdm
import torch
import math
progress_bar = tqdm(range(num_training_steps))
for epoch in range(num_train_epochs):
# Training
model.train()
for batch in train_dataloader:
outputs = model(**batch)
loss = outputs.loss
accelerator.backward(loss)
optimizer.step()
lr_scheduler.step()
optimizer.zero_grad()
progress_bar.update(1)
# Evaluation
model.eval()
losses = []
for step, batch in enumerate(eval_dataloader):
with torch.no_grad():
outputs = model(**batch)
loss = outputs.loss
losses.append(accelerator.gather(loss.repeat(batch_size)))
losses = torch.cat(losses)
losses = losses[: len(eval_dataset)]
try:
perplexity = math.exp(torch.mean(losses))
except OverflowError:
perplexity = float("inf")
print(f">>> Epoch {epoch}: Perplexity: {perplexity}")
# Save and upload
accelerator.wait_for_everyone()
unwrapped_model = accelerator.unwrap_model(model)
unwrapped_model.save_pretrained(output_dir, save_function=accelerator.save)
if accelerator.is_main_process:
tokenizer.save_pretrained(output_dir)
repo.push_to_hub(
commit_message=f"Training in progress epoch {epoch}", blocking=False
)
>>> Epoch 0: Perplexity: 11.397545307900472
>>> Epoch 1: Perplexity: 10.904909330983092
>>> Epoch 2: Perplexity: 10.729503505340409
太棒了,我们能够在每个时期评估困惑度,并确保多次训练运行的可重复性!
使用我们微调的模型
您可以通过使用 Hub 上的模型小部件或在本地使用 🤗 Transformers 中的pipeline
与微调后的模型进行交互。让我们使用后者使用fill-mask
管道下载我们的模型。
from transformers import pipeline
mask_filler = pipeline(
"fill-mask", model="huggingface-course/distilbert-base-uncased-finetuned-imdb"
)
然后,我们可以将示例文本“This is a great [MASK]”提供给管道,并查看前 5 个预测结果。
preds = mask_filler(text)
for pred in preds:
print(f">>> {pred['sequence']}")
'>>> this is a great movie.'
'>>> this is a great film.'
'>>> this is a great story.'
'>>> this is a great movies.'
'>>> this is a great character.'
不错——我们的模型显然已经调整了权重,以便预测与电影更相关的单词!
这结束了我们第一次训练语言模型的实验。在第 6 节中,您将学习如何从头开始训练像 GPT-2 这样的自回归模型;如果您想了解如何预训练您自己的 Transformer 模型,请前往那里!
✏️ 试试看!为了量化领域自适应的好处,请在预训练和微调的 DistilBERT 检查点上,针对 IMDb 标签微调分类器。如果您需要复习文本分类,请查看第 3 章。