NLP Course documentation

微调掩码语言模型(masked language model)

Hugging Face's logo
Join the Hugging Face community

and get access to the augmented documentation experience

to get started

微调掩码语言模型(masked language model)

Open In Colab Open In Studio Lab

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

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

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

ULMFiT.

在本节结束时,你将在 Hub 上拥有一个 掩码语言模型(masked language model) ,该模型可以自动补全句子,如下所示:

让我们开始吧!

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

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

首先,让我们为掩码语言建模选择一个合适的预训练模型。如以下屏幕截图所示,你可以通过在 Hugging Face Hub 上选择“Fill-Mask”过滤器:

Hub models.
尽管 BERT 和 RoBERTa 系列模型的下载量最大,但我们将使用名为 [DistilBERT](https://huggingface.co./distilbert-base-uncased) 的模型。它可以更快地训练,而且对下游性能几乎没有损失。它使用了一种称为 [`知识蒸馏(knowledge distillation)`](https://en.wikipedia.org/wiki/Knowledge_distillation) 的特殊技术进行训练,其中使用像 BERT 这样的大型“教师模型”来指导参数少得多的“学生模型”的训练。在本节中对知识蒸馏进行详细解释会使我们偏离本节主题太远,但如果你有兴趣,可以阅读 [`使用 Transformers 进行自然语言处理(Natural Language Processing with Transformers)`](https://www.oreilly.com/library/view/natural-language-processing/9781098136789/) (俗称 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 模型的二分之一,这大致意味着训练的速度可以提高两倍 —— 非常棒!现在让我们看看对于下面的一小部分文本,这个模型最有可能预测什么:

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

作为人类,我们可以想象 [MASK] token 有很多可能性,例如 “day”、“ride” 或者 “painting”。对于预训练模型,预测取决于模型所训练的语料库,因为它会学习获取数据中存在的语料统计分布。与 BERT 一样,DistilBERT 在 English WikipediaBookCorpus 数据集上进行预训练,所以我们猜想模型对 [MASK] 的预测能够反映这些领域。为了预测 [MASK] ,我们需要 DistilBERT 的 tokenizer 来处理输入,所以让我们也从 Hub 下载它:

from transformers import AutoTokenizer

tokenizer = AutoTokenizer.from_pretrained(model_checkpoint)

有了 tokenizer 和模型,我们现在可以将我们的示例文本传递给模型,提取 logits,并打印出前 5 个候选词:

import torch

inputs = tokenizer(text, return_tensors="pt")
token_logits = model(**inputs).logits
# 找到 [MASK] 的位置并提取其 logits
mask_token_index = torch.where(inputs["input_ids"] == tokenizer.mask_token_id)[1]
mask_token_logits = token_logits[0, mask_token_index, :]
# 选择具有最高 logits 的 [MASK] 候选词
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的 大型电影评论数据集(Large Movie Review Dataset),这是一个电影评论语料库,通常用于对情感分析模型进行基准测试。通过在这个语料库上对 DistilBERT 进行微调,我们期望语言模型会从其预训练的维基百科的事实性数据,适应到更主观的电影评论的领域。

首先,我们可以使用🤗 Datasets 中的 load_dataset() 函数从 Hugging Face 中获取数据:

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 实践者在开始新项目时都应该对数据标注进行的有用的、合理的检查!

现在我们已经快速浏览了一下数据,接下来我们要深入准备这些数据以供进行掩码语言建模。如我们所见,与我们在 第三章 看到的序列分类任务相比,这里需要采取一些额外的步骤。让我们开始吧!

预处理数据

对于自回归和掩码语言建模,常见的预处理步骤是将所有的文本拼接起来,然后再将整个语料库切割为相同大小的块。这与我们之前的做法有很大的不同,我们之前只是对单个的示例进行 tokenize。为什么要将所有的示例连接在一起呢?原因是如果单个示例太长,可能会被截断,这会导致我们失去可能对语言建模任务有用的信息!

因此,我们首先会像往常一样对语料库进行 tokenize 处理,但是不在 tokenizer 中设置 truncation=True 选项。如果我们有可以使用快速 tokenizer(如 第六章 中所述),除此之外,我们还需要获取单词的 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


# 使用 batched=True 来激活快速多线程!
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

现在我们已经对电影评论进行了 tokenize,下一步是将它们全部组合在一起并将结果分割成块。但是,这些块应该有多大呢?这最终将取决于你可以使用的显存大小,但一个好的起点是查看模型的最大上下文大小。这可以在 tokenizer 的 model_max_length 属性中找到:

tokenizer.model_max_length
512

该值来自于与 checkpoint 相关联的 tokenizer_config.json 文件;在我们的例子中,我们可以看到上下文大小是 512 个 tokens 与 BERT 模型一样。

✏️ 试试看! 一些 Transformer 模型,例如 BigBirdLongformer 具有比 BERT 和其他早期 Transformer 模型更长的上下文长度。选择一个 checkpoint 来实例化 tokenizer 并验证 model_max_length 是否与模型卡上标注的大小一致。

因此,为了可以在像 Google Colab 那样的 GPU 上运行我们的实验,我们会选择一个稍小一点、可以放入内存中的分块大小:

chunk_size = 128

注意,在实际应用场景中,使用小的块可能会有丢失长句子之间的语义信息从而对最终模型的性能产生不利的影响,所以如果显存条件允许的话,你应该选择一个与你将要使用模型的相匹配的大小。

现在来到了最有趣的部分。为了展示如何把这些示例连接在一,我们从分词后的训练集中取出几个评论,并打印出每个评论的 token 数量:

# 切片会为每个特征生成一个列表的列表
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):
    # 拼接所有的文本
    concatenated_examples = {k: sum(examples[k], []) for k in examples.keys()}
    # 计算拼接文本的长度
    total_length = len(concatenated_examples[list(examples.keys())[0]])
    # 如果最后一个块小于 chunk_size,我们将其丢弃
    total_length = (total_length // chunk_size) * chunk_size
    # 按最大长度分块
    result = {
        k: [t[i : i + chunk_size] for i in range(0, total_length, chunk_size)]
        for k, t in concatenated_examples.items()
    }
    # 创建一个新的 labels 列
    result["labels"] = result["input_ids"].copy()
    return result

注意,在 group_texts() 的最后一步,我们创建了一个新的 labels 列,它是通过复制 input_ids 列形成的。这是因为在掩码语言模型的目标是预测输入中随机遮住(Masked)的 token,我们保存了让我们的语言模型从中学习 [Mask] 的答案。

现在,让我们使用我们强大的 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
    })
})

通过对文本进行分块,我们得到了比原来的训练集和测试集的 25000 个例子多得多的评论数据。这是因为我们现在有了涉及跨越原始语料库中多个例子的连续标记的例子。你可以通过在其中一个块中查找特殊的 [SEP][CLS] tokens 来清晰地看到这一点:

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

微调掩码语言模型几乎与微调序列分类模型相同,就像我们在 第三章 所做的那样。唯一的区别是我们需要一个特殊的数据整理器,它可以随机屏蔽每批文本中的一些 tokens。幸运的是,🤗 Transformers 为这项任务准备了专用的 DataCollatorForLanguageModeling 。我们只需要将 tokenizer 和一个 mlm_probability 参数(掩盖 tokens 的比例)传递给它。在这里我们将 mlm_probability 参数设置为 15%,这是 BERT 默认的数量,也是文献中最常见的选择。

from transformers import DataCollatorForLanguageModeling

data_collator = DataCollatorForLanguageModeling(tokenizer=tokenizer, mlm_probability=0.15)

为了了解随机掩码数据整理器的工作原理,让我们把一些例子输入到数据整理器。由于数据整理器期望接收一个字典列表,其中每个字典中存储一段连续文本的块,所以我们首先遍历数据集取出来一些样本数据,然后将样本数据输入到整理器。在输入到数据整理器之间,我们删除了 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] tokens 已随机插入我们文本中的不同位置。这些将是我们的模型在训练期间必须预测的 tokens —— 数据整理器的美妙之处在于,它会在每个 batch 中随机插入 [MASK]

✏️ 试一试! 多运行上面的代码片段几次,亲眼看看随机遮蔽的效果!也可以用 tokenizer.convert_ids_to_tokens() 替换 tokenizer.decode() 方法,看看只把一个给定单词的单个 token 遮蔽,而保持这个单词其他 tokens 不变的效果。

随机掩码的一个缺点是,当使用 Trainer 时,每次计算出来的评估结果会有些许不同,即使我们会对训练集和测试集使用相同的数据整理器。稍后,我们在学习使用 🤗 Accelerate 进行微调时, 就会看到如何利用灵活的自定义评估循环的来冻结随机性。

在为掩码语言建模训练模型时,不仅仅可以遮蔽单个 token,还可以一次遮蔽整个单词的所有 token,这种方法被称为全词屏蔽(whole word masking)。如果我们想使用全词屏蔽(whole word masking),我们就需要自己构建一个数据整理器。数据整理器的核心是一个函数,它接受一个样本列表并将它们转换为一个 batch,所以现在让我们这样做吧!我们将使用先前计算的word 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")

        # 创建一个单词与对应 token 索引之间的映射
        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)

        # 随机遮蔽单词
        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() ,可以观察到给定单词的所有 tokens 总是被一起遮蔽。

现在我们有了两个数据整理器,剩下的微调步骤与其他任务类似都是标准的。如果你在 Google Colab 上运行并且没有幸运地分配到神秘的 P100 GPU😭,那么训练可能会需要一些时间,所以我们首先将训练集的大小减小到几千个例子。不用担心,我们仍然可以得到一个相当不错的语言模型!在 🤗 Datasets 中快速筛选数据集的方法是使用我们在 第五章 中看到的 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。如果你在 Notebook 中运行这段代码,你可以通过以下的工具函数进行登录:

from huggingface_hub import notebook_login

notebook_login()

它将显示一个小部件,在其中你可以输入你的账号和密码进行登陆。或者,你也可以在你最喜欢的终端中输入指令:

huggingface-cli login

然后在那里登录。

登陆后,我们可以指定 Trainer 的参数:

from transformers import TrainingArguments

batch_size = 64
# 在每个 epoch 输出训练的 loss
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() 方法中未使用的列。这意味着,如果你使用全词屏蔽(whole word masking)数据整理器,你还需要设置 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() —— 但在此之前让我们简要地看一下 困惑度(perplexity) ,这是评估语言模型性能常用的指标。

语言模型的困惑度(perplexity)

语言建模与文本分类或问答等其他任务有所不同,在其他任务中,我们会得到一个带标签的语料库进行训练,而语言建模则没有任何明确的标签。那么我们如何确定什么是好的语言模型呢?就像手机中的自动更正功能一样,一个好的语言模型会较高概率输出一个语法正确的句子,较低概率输出无意义的句子。为了给你一个更直观感受,你可以在网上找到一整套“自动更正失败”的例子。其中,人们手机中的模型产生了一些相当有趣(并且常常不妥当)的自动生成的结果!

如果测试集主要由语法正确的句子组成,那么衡量语言模型质量的一种方式就是计算它给测试集中所有句子的下一个词的概率。高概率表示模型对未见过的例子不感到“惊讶”或“困惑”,这表明它已经学习了语言的基本语法模式。困惑度有很多种数学定义,我们将使用的定义是交叉熵损失的指数。具体方法是使用 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(checkpoint 在训练过程中就已经保存了):

trainer.push_to_hub()

✏️ 轮到你了! 将数据整理器改为全词屏蔽的数据整理器后运行上面的训练。你能得到更好的结果吗?

在我们的使用案例中,我们不需要对训练循环做任何特殊的处理,但在某些情况下,你可能需要实现一些自定义逻辑。对于这些应用,你可以使用 🤗 Accelerate —— 让我们看一看!

使用 🤗 Accelerate 微调 DistilBERT

从上面的 Trainer 训练流程中我们就能发现,微调一个掩码的语言模型与 第三章 中的文本分类非常相似。事实上,唯一的不同之处是使用了一个特殊的数据整理器,我们已经在本节的前面讨论过这个问题了! 然而,我们注意到 DataCollatorForLanguageModeling 在每次评估时也会进行随机遮罩,因此我们在每次训练运行中都会看到困惑度得分有些波动。消除这种随机性的一种方法是在整个测试集上 仅进行一次 遮罩,然后在评估过程中使用🤗 Transformers 中的默认数据整理器来收集 batch。为实现这个过程,让我们实现一个简单的函数,类似于我们第一次使用 DataCollatorForLanguageModeling 时进行遮罩的方式:

def insert_random_mask(batch):
    features = [dict(zip(batch, t)) for t in zip(*batch.values())]
    masked_inputs = data_collator(features)
    # 为数据集中的每一列创建一个新的"masked"列
    return {"masked_" + k: v.numpy() for k, v in masked_inputs.items()}

接下来,我们将上述函数应用到测试集,并删除未进行遮罩的列,这样就实现了使用遮罩过的数据替换原始输入的数据。你可以通过将上述 data_collator 替换为支持全词遮罩的数据整理器并且删除下面的第一行(全词遮罩的数据整理器需要 word_ids 列来定位同一个单词中的不同 token)来实现全词遮罩

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",
    }
)

然后我们可以像往常一样设置 DataLoader,但我们将使用🤗 Transformers 中的 default_data_collator 来设置 DataLoader:

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
)

现在我们的模型、优化器和 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 库的 get_full_repo_name,生成我们仓库的全名:

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):
    # 训练
    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)

    # 评估
    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}")

    # 保存并上传
    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.'

Nice!—— 我们的模型显然已经调整了它的权重来预测与电影更密切相关的词!

这标志着我们第一次训练语言模型的实验到现在就结束了。在 第 6 节 中,你将学习如何从头开始训练一个自动回归模型,比如 GPT-2;如果你想看看如何预训练你自己的 Transformer 模型,就赶快去那里看看吧!

✏️ 试试看! 为了量化领域适应的好处,分别使用预训练和微调的 DistilBERT checkpoint 以及数据集自带的 IMDb 标签来微调一个分类器,并对比一下这个两个 checkpoint 的差异。如果你需要复习文本分类的知识,请查看 第三章

< > Update on GitHub