LLM 课程文档
微调掩码语言模型
并获得增强的文档体验
开始使用
微调掩码语言模型
对于涉及 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 从业者在开始新项目时都应该执行的一个有用的健全性检查!
现在我们已经快速浏览了数据,接下来我们深入探讨如何为掩码语言建模准备数据。正如我们将看到的,与第三章中的序列分类任务相比,还需要一些额外的步骤。让我们开始吧!
数据预处理
对于自回归和掩码语言建模,一个常见的预处理步骤是连接所有示例,然后将整个语料库分成大小相等的块。这与我们通常只对单个示例进行分词的方法大相径庭。为什么要将所有内容连接起来?原因在于,如果单个示例太长,它们可能会被截断,从而导致丢失对语言建模任务可能有用的信息!
所以,首先,我们将像往常一样对语料库进行分词,但**不**在分词器中设置 truncation=True
选项。如果可用,我们还会获取词 ID(如果使用快速分词器,如第 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 等平台上运行我们的实验,我们将选择一个稍微小一点的尺寸,以便能够适应内存。
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
})
})
您可以看到,对文本进行分组然后分块后,生成的示例比我们原始的 25,000 个 train
和 test
分割示例多得多。这是因为我们现在拥有涉及*连续词元*的示例,这些示例跨越了原始语料库中的多个示例。您可以通过查看其中一个块中的特殊 [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
微调掩码语言模型与微调序列分类模型几乎相同,就像我们在第三章中做的那样。唯一的区别是我们需要一个特殊的数据整理器,它可以随机掩码每批文本中的一些标记。幸运的是,🤗 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()
,以查看给定单词的标记始终一起被掩码。
现在我们有了两个数据校对器,其余的微调步骤都是标准操作。在 Google Colab 上训练可能需要一段时间,如果你没有幸运地获得一个传说中的 P100 GPU 😭,所以我们首先将训练集的大小下采样到几千个示例。别担心,我们仍然会得到一个相当不错的语言模型!在 🤗 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
,但您可以尝试使用全词掩码 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
中所看到的,微调掩码语言模型与第三章中的文本分类示例非常相似。事实上,唯一的细微之处在于使用了特殊的数据校对器,而我们已经在前面讨论过了!
然而,我们发现 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
太棒了,我们已经能够评估每个 epoch 的困惑度,并确保多次训练运行是可重现的!
使用我们微调后的模型
您可以通过 Hugging Face Hub 上的微调模型小部件或在本地使用 🤗 Transformers 的 pipeline
与您的微调模型进行交互。让我们使用后者通过 fill-mask
pipeline 下载我们的模型。
from transformers import pipeline
mask_filler = pipeline(
"fill-mask", model="huggingface-course/distilbert-base-uncased-finetuned-imdb"
)
然后,我们可以将示例文本“This is a great [MASK]”输入到 pipeline 中,看看前 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.'
棒极了——我们的模型显然已经调整了其权重,可以预测出与电影更强烈相关的词语!
至此,我们完成了语言模型训练的第一次实验。在第六节中,您将学习如何从头开始训练一个自回归模型,如 GPT-2;如果您想了解如何预训练自己的 Transformer 模型,请前往该章节!
✏️ **试一试!** 为了量化领域适应的好处,在 IMDb 标签上为预训练和微调的 DistilBERT 检查点微调一个分类器。如果你需要文本分类的复习,请查看第 3 章。