LLM 课程文档

微调掩码语言模型

Hugging Face's logo
加入 Hugging Face 社区

并获得增强的文档体验

开始使用

微调掩码语言模型

Ask a Question Open In Colab Open In Studio Lab

对于许多涉及 Transformer 模型的 NLP 应用,您可以简单地从 Hugging Face Hub 中获取一个预训练模型,并直接在您的数据上针对手头的任务进行微调。只要用于预训练的语料库与用于微调的语料库没有太大差异,迁移学习通常会产生良好的结果。

但是,在某些情况下,您需要先在您的数据上微调语言模型,然后再训练特定于任务的头部。例如,如果您的数据集包含法律合同或科学文章,像 BERT 这样的普通 Transformer 模型通常会将语料库中特定领域的词语视为稀有 token,并且由此产生的性能可能不尽如人意。通过在领域内数据上微调语言模型,您可以提高许多下游任务的性能,这意味着您通常只需要执行一次此步骤!

这种在领域内数据上微调预训练语言模型的过程通常称为领域自适应。它在 2018 年由 ULMFiT 推广,ULMFiT 是首批使迁移学习真正适用于 NLP 的神经架构之一(基于 LSTM)。下图显示了使用 ULMFiT 进行领域自适应的示例;在本节中,我们将进行类似的操作,但使用 Transformer 而不是 LSTM!

ULMFiT.

在本节结束时,您将在 Hub 上获得一个 掩码语言模型,它可以像下面这样自动补全句子

让我们深入了解一下!

🙋 如果您对“掩码语言建模”和“预训练模型”这些术语感到陌生,请查看 第 1 章,我们在其中解释了所有这些核心概念,并附带视频!

选择用于掩码语言建模的预训练模型

首先,让我们选择一个适用于掩码语言建模的预训练模型。如下图所示,您可以通过在 Hugging Face Hub 上应用“Fill-Mask”过滤器来找到候选模型列表

Hub models.

虽然 BERT 和 RoBERTa 系列模型是最常下载的,但我们将使用一个名为 DistilBERT 的模型,该模型可以更快地训练,而下游性能几乎没有损失。此模型使用一种称为 知识蒸馏 的特殊技术进行训练,其中像 BERT 这样的大型“教师模型”用于指导参数少得多的“学生模型”的训练。对知识蒸馏细节的解释会使我们在本节中跑题太远,但如果您有兴趣,可以在 Natural Language Processing with Transformers(俗称 Transformers 教科书)中阅读所有相关内容。

让我们继续使用 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 base 模型的一半大小,这大致转化为训练速度提高两倍——太棒了!现在让我们看看这个模型预测哪些 token 最有可能完成一小段文本

text = "This is a great [MASK]."

作为人类,我们可以想象 [MASK] token 的许多可能性,例如“day”、“ride”或“painting”。对于预训练模型,预测取决于模型训练的语料库,因为它学习拾取数据中存在的统计模式。与 BERT 一样,DistilBERT 也在 英文维基百科BookCorpus 数据集上进行了预训练,因此我们期望对 [MASK] 的预测能够反映这些领域。为了预测 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
    })
})

我们可以看到 traintest 分割都包含 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。同时,您还可以检查 traintest 分割中的标签是否确实是 01 ——这是一个有用的健全性检查,每个 NLP 从业者都应该在新项目开始时执行!

现在我们已经快速浏览了数据,让我们深入研究如何准备数据以进行掩码语言建模。正如我们将看到的,与我们在 第 3 章 中看到的序列分类任务相比,需要采取一些额外的步骤。开始吧!

预处理数据

对于自回归和掩码语言建模,常见的预处理步骤是将所有示例连接起来,然后将整个语料库拆分为大小相等的块。这与我们通常的方法截然不同,在通常的方法中,我们只是对单个示例进行分词。为什么要将所有内容连接在一起?原因是单个示例如果太长可能会被截断,这将导致丢失可能对语言建模任务有用的信息!

因此,首先,我们将像往常一样对语料库进行分词,但在分词器中设置 truncation=True 选项。如果单词 ID 可用(如果我们像 第 6 章 中描述的那样使用快速分词器,则单词 ID 将可用),我们还将获取单词 ID,因为稍后我们将需要它们来进行全词掩码。我们将此包装在一个简单的函数中,同时我们将删除 textlabel 列,因为我们不再需要它们

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_idsattention_mask 以及我们添加的 word_ids 组成。

现在我们已经对电影评论进行了分词,下一步是将它们全部组合在一起,并将结果拆分为块。但是这些块应该有多大?这最终将取决于您可用的 GPU 内存量,但一个好的起点是查看模型的最大上下文大小是多少。这可以通过检查分词器的 model_max_length 属性来推断

tokenizer.model_max_length
512

此值源自与检查点关联的 tokenizer_config.json 文件;在本例中,我们可以看到上下文大小为 512 个 token,就像 BERT 一样。

✏️ 试试看! 一些 Transformer 模型,例如 BigBirdLongformer,比 BERT 和其他早期 Transformer 模型具有更长的上下文长度。实例化其中一个检查点的分词器,并验证 model_max_length 与其模型卡上引用的内容一致。

因此,为了在 Google Colab 等 GPU 上运行我们的实验,我们将选择更小的尺寸,以便可以容纳在内存中

chunk_size = 128

请注意,在实际场景中使用小块尺寸可能是有害的,因此您应该使用与您将应用模型的用例相对应的尺寸。

现在到了有趣的部分。为了展示连接是如何工作的,让我们从分词后的训练集中取出一些评论,并打印出每条评论的 token 数量

# 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 列的副本。正如我们稍后将看到的,这是因为在掩码语言建模中,目标是预测输入批次中随机掩码的 token,通过创建 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
    })
})

您可以看到,分组然后分块文本产生的示例比我们原始的 traintest 分割的 25,000 个示例多得多。这是因为我们现在有了涉及连续 token 的示例,这些 token 跨越了原始语料库中的多个示例。您可以通过在一个块中查找特殊的 [SEP][CLS] token 来显式地看到这一点

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] token!让我们看看如何使用特殊的数据整理器在微调期间动态地执行此操作。

使用 Trainer API 微调 DistilBERT

微调掩码语言模型几乎与微调序列分类模型相同,就像我们在 第 3 章 中所做的那样。唯一的区别是我们需要一个特殊的数据整理器,它可以随机掩码每批文本中的某些 token。幸运的是,🤗 Transformers 已经准备好了一个专用的 DataCollatorForLanguageModeling 来完成这项任务。我们只需要将分词器和一个 mlm_probability 参数传递给它,该参数指定要掩码的 token 的比例。我们将选择 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] token 已随机插入到我们文本的各个位置。这些将是我们的模型在训练期间必须预测的 token ——数据整理器的妙处在于,它将在每个批次中随机化 [MASK] 插入!

✏️ 试试看! 多次运行上面的代码片段,亲眼看看随机掩码的发生!还要将 tokenizer.decode() 方法替换为 tokenizer.convert_ids_to_tokens(),以查看有时会掩码给定单词的单个 token,而不是其他 token。

随机掩码的一个副作用是,当使用 Trainer 时,我们的评估指标将不是确定性的,因为我们对训练集和测试集使用相同的数据整理器。稍后,当我们查看使用 🤗 Accelerate 进行微调时,我们将看到如何使用自定义评估循环的灵活性来冻结随机性。

在训练掩码语言建模模型时,可以使用的一种技术是将整个单词一起掩码,而不仅仅是单个 token。这种方法称为全词掩码。如果我们想使用全词掩码,我们将需要自己构建一个数据整理器。数据整理器只是一个函数,它接受一个样本列表并将其转换为批次,所以让我们现在就这样做!我们将使用之前计算的单词 ID 来构建单词索引和相应 token 之间的映射,然后随机决定要掩码哪些单词,并将该掩码应用于输入。请注意,除了与掩码单词对应的标签外,所有标签均为 -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(),以查看给定单词的 token 始终一起掩码。

现在我们有了两个数据整理器,其余的微调步骤都是标准的。如果您没有幸运地获得神话般的 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
    })
})

这会自动创建新的 traintest 分割,训练集大小设置为 10,000 个示例,验证集设置为其 10% ——如果您有强大的 GPU,请随意增加此值!接下来我们需要做的是登录到 Hugging Face Hub。如果您在笔记本中运行此代码,可以使用以下实用程序函数执行此操作

from huggingface_hub import notebook_login

notebook_login()

这将显示一个 widget,您可以在其中输入您的凭据。或者,您可以运行

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,但您可以尝试全词掩码整理器并比较结果作为练习

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

太酷了,我们已经能够评估每个 epoch 的困惑度,并确保多次训练运行是可重现的!

使用我们微调的模型

您可以通过在 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.'

太棒了 — 我们的模型显然已经调整了权重,以预测与电影更相关的词语!

这结束了我们第一个训练语言模型的实验。在第 6 节中,您将学习如何从头开始训练像 GPT-2 这样的自回归模型;如果您想了解如何预训练您自己的 Transformer 模型,请前往那里!

✏️ 试试看! 为了量化领域自适应的好处,在预训练和微调的 DistilBERT 检查点上,在 IMDb 标签上微调分类器。如果您需要复习文本分类,请查看第 3 章

< > 在 GitHub 上更新