LLM 课程文档

摘要

Hugging Face's logo
加入 Hugging Face 社区

并获得增强的文档体验

开始使用

摘要

Ask a Question Open In Colab Open In Studio Lab

在本节中,我们将探讨如何使用 Transformer 模型将长文档浓缩成摘要,这项任务称为_文本摘要_。这是最具挑战性的 NLP 任务之一,因为它需要一系列能力,例如理解长段落和生成能够捕捉文档主要主题的连贯文本。然而,如果做得好,文本摘要是一个强大的工具,可以通过减轻领域专家详细阅读长文档的负担来加快各种业务流程。

尽管 Hugging Face Hub 上已经存在各种针对摘要任务进行微调的模型,但几乎所有这些模型都只适用于英文文档。因此,为了在本节中增加一些趣味性,我们将训练一个英西双语模型。在本节结束时,你将拥有一个可以总结客户评论的模型,如下所示:

正如我们将看到的,这些摘要之所以简洁,是因为它们是从客户在产品评论中提供的标题中学到的。让我们首先为这项任务准备一个合适的双语语料库。

准备多语言语料库

我们将使用多语言亚马逊评论语料库来创建我们的双语摘要器。这个语料库包含六种语言的亚马逊产品评论,通常用于基准测试多语言分类器。然而,由于每条评论都附带一个简短的标题,我们可以使用这些标题作为我们模型学习的目标摘要!为了开始,让我们从 Hugging Face Hub 下载英语和西班牙语子集。

from datasets import load_dataset

spanish_dataset = load_dataset("amazon_reviews_multi", "es")
english_dataset = load_dataset("amazon_reviews_multi", "en")
english_dataset
DatasetDict({
    train: Dataset({
        features: ['review_id', 'product_id', 'reviewer_id', 'stars', 'review_body', 'review_title', 'language', 'product_category'],
        num_rows: 200000
    })
    validation: Dataset({
        features: ['review_id', 'product_id', 'reviewer_id', 'stars', 'review_body', 'review_title', 'language', 'product_category'],
        num_rows: 5000
    })
    test: Dataset({
        features: ['review_id', 'product_id', 'reviewer_id', 'stars', 'review_body', 'review_title', 'language', 'product_category'],
        num_rows: 5000
    })
})

如你所见,每种语言的 `train` 分割都有 200,000 条评论,`validation` 和 `test` 分割各有 5,000 条评论。我们感兴趣的评论信息包含在 `review_body` 和 `review_title` 列中。让我们通过创建一个简单的函数来查看一些示例,该函数使用我们在第 5 章中学到的技术从训练集中随机抽取样本。

def show_samples(dataset, num_samples=3, seed=42):
    sample = dataset["train"].shuffle(seed=seed).select(range(num_samples))
    for example in sample:
        print(f"\n'>> Title: {example['review_title']}'")
        print(f"'>> Review: {example['review_body']}'")


show_samples(english_dataset)
'>> Title: Worked in front position, not rear'
'>> Review: 3 stars because these are not rear brakes as stated in the item description. At least the mount adapter only worked on the front fork of the bike that I got it for.'

'>> Title: meh'
'>> Review: Does it’s job and it’s gorgeous but mine is falling apart, I had to basically put it together again with hot glue'

'>> Title: Can\'t beat these for the money'
'>> Review: Bought this for handling miscellaneous aircraft parts and hanger "stuff" that I needed to organize; it really fit the bill. The unit arrived quickly, was well packaged and arrived intact (always a good sign). There are five wall mounts-- three on the top and two on the bottom. I wanted to mount it on the wall, so all I had to do was to remove the top two layers of plastic drawers, as well as the bottom corner drawers, place it when I wanted and mark it; I then used some of the new plastic screw in wall anchors (the 50 pound variety) and it easily mounted to the wall. Some have remarked that they wanted dividers for the drawers, and that they made those. Good idea. My application was that I needed something that I can see the contents at about eye level, so I wanted the fuller-sized drawers. I also like that these are the new plastic that doesn\'t get brittle and split like my older plastic drawers did. I like the all-plastic construction. It\'s heavy duty enough to hold metal parts, but being made of plastic it\'s not as heavy as a metal frame, so you can easily mount it to the wall and still load it up with heavy stuff, or light stuff. No problem there. For the money, you can\'t beat it. Best one of these I\'ve bought to date-- and I\'ve been using some version of these for over forty years.'

✏️ 试试看! 更改 `Dataset.shuffle()` 命令中的随机种子,以探索语料库中的其他评论。如果你是西班牙语使用者,请查看 `spanish_dataset` 中的一些评论,看看标题是否也像是合理的摘要。

这个样本展示了通常在网上发现的评论的多样性,从积极到消极(以及介于两者之间的所有内容!)。尽管带有“meh”标题的示例信息量不大,但其他标题看起来像是评论本身的不错摘要。在所有 40 万条评论上训练摘要模型在单个 GPU 上需要太长时间,所以我们将重点放在为单个产品领域生成摘要。为了了解我们可以选择哪些领域,让我们将 `english_dataset` 转换为 `pandas.DataFrame` 并计算每个产品类别的评论数量。

english_dataset.set_format("pandas")
english_df = english_dataset["train"][:]
# Show counts for top 20 products
english_df["product_category"].value_counts()[:20]
home                      17679
apparel                   15951
wireless                  15717
other                     13418
beauty                    12091
drugstore                 11730
kitchen                   10382
toy                        8745
sports                     8277
automotive                 7506
lawn_and_garden            7327
home_improvement           7136
pet_products               7082
digital_ebook_purchase     6749
pc                         6401
electronics                6186
office_product             5521
shoes                      5197
grocery                    4730
book                       3756
Name: product_category, dtype: int64

英语数据集中最受欢迎的产品是家居用品、服装和无线电子产品。然而,为了保持亚马逊的主题,我们将专注于总结书评——毕竟,这是公司赖以建立的基础!我们可以看到两个符合条件的产商品类别(`book` 和 `digital_ebook_purchase`),所以让我们只过滤这两种语言数据集中的这些产品。正如我们在第 5 章中看到的,`Dataset.filter()` 函数允许我们非常高效地切分数据集,因此我们可以定义一个简单的函数来完成此操作。

def filter_books(example):
    return (
        example["product_category"] == "book"
        or example["product_category"] == "digital_ebook_purchase"
    )

现在,当我们对 `english_dataset` 和 `spanish_dataset` 应用此函数时,结果将只包含涉及图书类别的那些行。在应用过滤器之前,让我们将 `english_dataset` 的格式从 `"pandas"` 切换回 `"arrow"`。

english_dataset.reset_format()

然后我们可以应用过滤函数,并作为健全性检查,让我们检查一些评论样本,看看它们是否确实是关于书籍的。

spanish_books = spanish_dataset.filter(filter_books)
english_books = english_dataset.filter(filter_books)
show_samples(english_books)
'>> Title: I\'m dissapointed.'
'>> Review: I guess I had higher expectations for this book from the reviews. I really thought I\'d at least like it. The plot idea was great. I loved Ash but, it just didnt go anywhere. Most of the book was about their radio show and talking to callers. I wanted the author to dig deeper so we could really get to know the characters. All we know about Grace is that she is attractive looking, Latino and is kind of a brat. I\'m dissapointed.'

'>> Title: Good art, good price, poor design'
'>> Review: I had gotten the DC Vintage calendar the past two years, but it was on backorder forever this year and I saw they had shrunk the dimensions for no good reason. This one has good art choices but the design has the fold going through the picture, so it\'s less aesthetically pleasing, especially if you want to keep a picture to hang. For the price, a good calendar'

'>> Title: Helpful'
'>> Review: Nearly all the tips useful and. I consider myself an intermediate to advanced user of OneNote. I would highly recommend.'

好的,我们可以看到评论不完全是关于书籍的,也可能涉及日历和电子应用程序(如 OneNote)。尽管如此,这个领域似乎很适合训练摘要模型。在我们研究适用于此任务的各种模型之前,我们还有最后一项数据准备工作要做:将英语和西班牙语评论合并为一个 `DatasetDict` 对象。🤗 Datasets 提供了一个便捷的 `concatenate_datasets()` 函数,顾名思义,它将两个 `Dataset` 对象堆叠在一起。因此,为了创建我们的双语数据集,我们将遍历每个拆分,连接该拆分的数据集,并打乱结果以确保我们的模型不会过度拟合单一语言。

from datasets import concatenate_datasets, DatasetDict

books_dataset = DatasetDict()

for split in english_books.keys():
    books_dataset[split] = concatenate_datasets(
        [english_books[split], spanish_books[split]]
    )
    books_dataset[split] = books_dataset[split].shuffle(seed=42)

# Peek at a few examples
show_samples(books_dataset)
'>> Title: Easy to follow!!!!'
'>> Review: I loved The dash diet weight loss Solution. Never hungry. I would recommend this diet. Also the menus are well rounded. Try it. Has lots of the information need thanks.'

'>> Title: PARCIALMENTE DAÑADO'
'>> Review: Me llegó el día que tocaba, junto a otros libros que pedí, pero la caja llegó en mal estado lo cual dañó las esquinas de los libros porque venían sin protección (forro).'

'>> Title: no lo he podido descargar'
'>> Review: igual que el anterior'

这看起来确实是英语和西班牙语评论的混合!现在我们有了训练语料库,最后一件要检查的事情是评论及其标题中的词语分布。这对于摘要任务尤其重要,因为数据中较短的参考摘要可能会使模型偏向于在生成的摘要中只输出一两个词。下面的图表显示了词语分布,我们可以看到标题严重偏向于只有 1-2 个词。

Word count distributions for the review titles and texts.

为了解决这个问题,我们将过滤掉标题过短的示例,以便我们的模型可以生成更有趣的摘要。由于我们处理的是英语和西班牙语文本,我们可以使用一个粗略的启发式方法来根据空格分割标题,然后使用我们值得信赖的 `Dataset.filter()` 方法,如下所示:

books_dataset = books_dataset.filter(lambda x: len(x["review_title"].split()) > 2)

现在我们已经准备好语料库,接下来看看我们可以对它进行微调的几种 Transformer 模型!

文本摘要模型

如果你仔细想想,文本摘要与机器翻译是相似的任务:我们有一段文本(比如评论),我们希望将其“翻译”成一个较短的版本,捕捉输入的显著特征。因此,大多数用于摘要的 Transformer 模型都采用我们在第 1 章中首次遇到的编码器-解码器架构,尽管也有一些例外,例如 GPT 模型系列,它们也可以用于少量样本设置下的摘要任务。下表列出了一些流行的预训练模型,可以对它们进行微调以用于摘要。

Transformer 模型 描述 多语言?
GPT-2 尽管 GPT-2 被训练为自回归语言模型,但你可以通过在输入文本末尾附加“TL;DR”来使其生成摘要。
PEGASUS 使用预训练目标来预测多句文本中的掩码句子。这个预训练目标比普通语言建模更接近摘要,并且在流行的基准测试中得分很高。
T5 一种通用的 Transformer 架构,将所有任务都 формулирует 为文本到文本框架;例如,模型用于摘要文档的输入格式是 `summarize: ARTICLE`。
mT5 T5 的多语言版本,在多语言 Common Crawl 语料库 (mC4) 上预训练,涵盖 101 种语言。
BART 一种新颖的 Transformer 架构,具有编码器和解码器堆栈,经过训练以重建损坏的输入,结合了 BERT 和 GPT-2 的预训练方案。
mBART-50 BART 的多语言版本,在 50 种语言上预训练。

正如你从这张表中看到的,大多数用于摘要(以及大多数 NLP 任务)的 Transformer 模型都是单语言的。这对于像英语或德语这样的“高资源”语言来说很好,但对于世界上使用的数千种其他语言来说就不那么理想了。幸运的是,有一类多语言 Transformer 模型,如 mT5 和 mBART,可以解决这个问题。这些模型使用语言建模进行预训练,但有一个特点:它们不是在一个语言语料库上进行训练,而是同时在 50 多种语言的文本上进行联合训练!

我们将重点关注 mT5,这是一种基于 T5 的有趣架构,它在文本到文本框架中进行了预训练。在 T5 中,每个 NLP 任务都通过一个提示前缀(如 `summarize:`)来 формулируется,该前缀会使模型根据提示调整生成的文本。如下图所示,这使得 T5 具有极强的通用性,因为您可以使用一个模型解决许多任务!

Different tasks performed by the T5 architecture.

mT5 不使用前缀,但与 T5 具有许多通用性,并且具有多语言优势。现在我们已经选择了模型,接下来看看如何准备数据以进行训练。

✏️ 试试看! 完成本节后,通过使用相同的技术对 mT5 和 mBART 进行微调,比较它们的效果。作为加分项,你还可以尝试仅对英语评论进行 T5 微调。由于 T5 有一个特殊的前缀提示,你需要在下面的预处理步骤中将 `summarize:` 添加到输入示例的前面。

数据预处理

我们的下一个任务是对评论及其标题进行标记化和编码。像往常一样,我们首先加载与预训练模型检查点关联的标记器。我们将使用 `mt5-small` 作为检查点,以便我们可以在合理的时间内微调模型。

from transformers import AutoTokenizer

model_checkpoint = "google/mt5-small"
tokenizer = AutoTokenizer.from_pretrained(model_checkpoint)

💡 在 NLP 项目的早期阶段,一个好的做法是,在少量数据样本上训练一类“小型”模型。这使得你能够更快地调试和迭代,以实现端到端的工作流程。一旦你对结果有信心,你总是可以通过简单地更改模型检查点来扩大模型规模!

让我们在一个小例子上测试 mT5 分词器。

inputs = tokenizer("I loved reading the Hunger Games!")
inputs
{'input_ids': [336, 259, 28387, 11807, 287, 62893, 295, 12507, 1], 'attention_mask': [1, 1, 1, 1, 1, 1, 1, 1, 1]}

这里我们可以看到在第 3 章的第一次微调实验中遇到的熟悉的 `input_ids` 和 `attention_mask`。让我们使用分词器的 `convert_ids_to_tokens()` 函数解码这些输入 ID,看看我们正在处理哪种分词器。

tokenizer.convert_ids_to_tokens(inputs.input_ids)
['▁I', '▁', 'loved', '▁reading', '▁the', '▁Hung', 'er', '▁Games', '</s>']

特殊的 Unicode 字符 ` ` 和序列结束符 ` ` 表示我们正在处理 SentencePiece 分词器,它基于第 6 章中讨论的 Unigram 分段算法。Unigram 对于多语言语料库特别有用,因为它允许 SentencePiece 不受重音、标点符号以及许多语言(如日语)没有空格字符的事实的影响。

为了标记化我们的语料库,我们必须处理与摘要相关的细微差别:由于我们的标签也是文本,它们有可能超过模型的最大上下文大小。这意味着我们需要对评论及其标题应用截断,以确保我们不会向模型传递过长的输入。🤗 Transformers 中的标记器提供了一个巧妙的 `text_target` 参数,允许您与输入并行地标记化标签。以下是 mT5 如何处理输入和目标的示例:

max_input_length = 512
max_target_length = 30


def preprocess_function(examples):
    model_inputs = tokenizer(
        examples["review_body"],
        max_length=max_input_length,
        truncation=True,
    )
    labels = tokenizer(
        examples["review_title"], max_length=max_target_length, truncation=True
    )
    model_inputs["labels"] = labels["input_ids"]
    return model_inputs

让我们来看看这段代码,了解发生了什么。我们做的第一件事是定义 `max_input_length` 和 `max_target_length` 的值,它们设置了评论和标题长度的上限。由于评论主体通常比标题长得多,我们相应地调整了这些值。

有了 `preprocess_function()`,使用我们在本课程中广泛使用的便捷 `Dataset.map()` 函数来标记化整个语料库就变得很简单了。

tokenized_datasets = books_dataset.map(preprocess_function, batched=True)

现在语料库已经预处理完毕,让我们看看一些常用于摘要的指标。正如我们将看到的,在衡量机器生成文本的质量时,没有万能的解决方案。

💡 你可能已经注意到,我们在上面的 `Dataset.map()` 函数中使用了 `batched=True`。这会以 1,000(默认值)的批次编码示例,并允许你利用 🤗 Transformers 中快速标记器的多线程功能。在可能的情况下,尝试使用 `batched=True` 以充分利用你的预处理!

文本摘要的指标

与本课程中涵盖的大多数其他任务相比,衡量摘要或翻译等文本生成任务的性能并不那么简单。例如,对于像“我喜欢读《饥饿游戏》”这样的评论,有多种有效的摘要,例如“我喜欢《饥饿游戏》”或“《饥饿游戏》是一部很棒的作品”。显然,在生成的摘要和标签之间应用某种精确匹配不是一个好的解决方案——即使是人类在这种指标下也会表现不佳,因为我们都有自己的写作风格。

对于摘要,最常用的指标之一是 ROUGE 分数(Recall-Oriented Understudy for Gisting Evaluation 的缩写)。这个指标的基本思想是将生成的摘要与一组通常由人类创建的参考摘要进行比较。为了更精确,假设我们要比较以下两个摘要:

generated_summary = "I absolutely loved reading the Hunger Games"
reference_summary = "I loved reading the Hunger Games"

比较它们的一种方法是计算重叠词的数量,在本例中为 6。但这有点粗糙,因此 ROUGE 基于计算重叠的_精确度_和_召回率_分数。

🙋 如果这是你第一次听说精确度和召回率,请不用担心——我们将一起通过一些明确的例子来使其清晰。这些指标通常在分类任务中遇到,因此如果你想了解精确度和召回率在该上下文中是如何定义的,我们建议查阅 `scikit-learn` 的指南

对于 ROUGE,召回率衡量生成的摘要捕获了参考摘要多少内容。如果我们只是比较词语,召回率可以根据以下公式计算:Recall=NumberofoverlappingwordsTotalnumberofwordsinreferencesummary \mathrm{Recall} = \frac{\mathrm{Number\,of\,overlapping\, words}}{\mathrm{Total\, number\, of\, words\, in\, reference\, summary}}

对于我们上面的简单例子,这个公式给出了 6/6 = 1 的完美召回率;也就是说,参考摘要中的所有词语都已被模型生成。这听起来可能很棒,但想象一下,如果我们生成的摘要是“我真的非常喜欢通宵阅读《饥饿游戏》”。这也会有完美的召回率,但可以说这是一个更糟糕的摘要,因为它过于冗长。为了处理这些情况,我们还计算了精确度,在 ROUGE 的上下文中,它衡量了生成的摘要有多少是相关的:Precision=NumberofoverlappingwordsTotalnumberofwordsingeneratedsummary \mathrm{Precision} = \frac{\mathrm{Number\,of\,overlapping\, words}}{\mathrm{Total\, number\, of\, words\, in\, generated\, summary}}

将其应用于我们冗长的摘要,精确度为 6/10 = 0.6,这比我们较短的摘要获得的 6/7 = 0.86 的精确度要差得多。在实践中,通常会计算精确度和召回率,然后报告 F1 分数(精确度和召回率的调和平均值)。我们可以在 🤗 Datasets 中轻松完成此操作,首先安装 `rouge_score` 包:

!pip install rouge_score

然后按照以下方式加载 ROUGE 指标:

import evaluate

rouge_score = evaluate.load("rouge")

然后我们可以使用 `rouge_score.compute()` 函数一次性计算所有指标。

scores = rouge_score.compute(
    predictions=[generated_summary], references=[reference_summary]
)
scores
{'rouge1': AggregateScore(low=Score(precision=0.86, recall=1.0, fmeasure=0.92), mid=Score(precision=0.86, recall=1.0, fmeasure=0.92), high=Score(precision=0.86, recall=1.0, fmeasure=0.92)),
 'rouge2': AggregateScore(low=Score(precision=0.67, recall=0.8, fmeasure=0.73), mid=Score(precision=0.67, recall=0.8, fmeasure=0.73), high=Score(precision=0.67, recall=0.8, fmeasure=0.73)),
 'rougeL': AggregateScore(low=Score(precision=0.86, recall=1.0, fmeasure=0.92), mid=Score(precision=0.86, recall=1.0, fmeasure=0.92), high=Score(precision=0.86, recall=1.0, fmeasure=0.92)),
 'rougeLsum': AggregateScore(low=Score(precision=0.86, recall=1.0, fmeasure=0.92), mid=Score(precision=0.86, recall=1.0, fmeasure=0.92), high=Score(precision=0.86, recall=1.0, fmeasure=0.92))}

哇,输出中有大量信息——这一切意味着什么?首先,🤗 Datasets 实际上计算了精确度、召回率和 F1 分数的置信区间;这些就是你在这里看到的 `low`、`mid` 和 `high` 属性。此外,🤗 Datasets 计算了各种 ROUGE 分数,这些分数基于不同类型的文本粒度来比较生成的摘要和参考摘要。`rouge1` 变体是 unigram 的重叠——这只是一个花哨的说法,表示词语的重叠,正是我们上面讨论的指标。为了验证这一点,让我们提取分数的 `mid` 值:

scores["rouge1"].mid
Score(precision=0.86, recall=1.0, fmeasure=0.92)

太棒了,精确度和召回率数字匹配!那么其他 ROUGE 分数呢?`rouge2` 衡量的是二元组(即词对)之间的重叠,而 `rougeL` 和 `rougeLsum` 则通过查找生成摘要和参考摘要中最长的共同子字符串来衡量最长匹配序列的词语。`rougeLsum` 中的“sum”指的是这个指标是针对整个摘要计算的,而 `rougeL` 则是按单个句子平均计算的。

✏️ 试试看! 创建你自己的生成摘要和参考摘要示例,看看生成的 ROUGE 分数是否与基于精确度和召回率公式的手动计算结果一致。作为加分项,将文本分割成二元组,并比较 `rouge2` 指标的精确度和召回率。

我们将使用这些 ROUGE 分数来跟踪我们模型的性能,但在此之前,让我们做每个优秀的 NLP 从业者都应该做的事情:创建一个强大而简单的基线!

建立强大的基线

文本摘要的一个常见基线是简单地提取文章的前三句话,通常称为_lead-3_基线。我们可以使用句号来跟踪句子边界,但这在像“U.S.”或“U.N.”这样的缩写词上会失败——所以我们将使用`nltk`库,它包含一个更好的算法来处理这些情况。你可以使用`pip`安装该软件包,如下所示:

!pip install nltk

然后下载标点符号规则:

import nltk

nltk.download("punkt")

接下来,我们从 `nltk` 导入句子分词器,并创建一个简单的函数来提取评论中的前三句话。文本摘要的约定是用换行符分隔每个摘要,所以我们也这样做,并在训练示例上进行测试。

from nltk.tokenize import sent_tokenize


def three_sentence_summary(text):
    return "\n".join(sent_tokenize(text)[:3])


print(three_sentence_summary(books_dataset["train"][1]["review_body"]))
'I grew up reading Koontz, and years ago, I stopped,convinced i had "outgrown" him.'
'Still,when a friend was looking for something suspenseful too read, I suggested Koontz.'
'She found Strangers.'

这似乎可行,所以现在让我们实现一个函数,从数据集中提取这些“摘要”并计算基线的 ROUGE 分数。

def evaluate_baseline(dataset, metric):
    summaries = [three_sentence_summary(text) for text in dataset["review_body"]]
    return metric.compute(predictions=summaries, references=dataset["review_title"])

然后我们可以使用此函数来计算验证集上的 ROUGE 分数,并使用 Pandas 对它们进行一些美化。

import pandas as pd

score = evaluate_baseline(books_dataset["validation"], rouge_score)
rouge_names = ["rouge1", "rouge2", "rougeL", "rougeLsum"]
rouge_dict = dict((rn, round(score[rn].mid.fmeasure * 100, 2)) for rn in rouge_names)
rouge_dict
{'rouge1': 16.74, 'rouge2': 8.83, 'rougeL': 15.6, 'rougeLsum': 15.96}

我们可以看到 `rouge2` 分数明显低于其他分数;这可能反映了评论标题通常简洁,因此 lead-3 基线过于冗长的事实。现在我们有了一个很好的基线可以开始工作,让我们将注意力转向微调 mT5!

使用 Trainer API 微调 mT5

微调用于摘要的模型与我们在本章中介绍的其他任务非常相似。我们需要做的第一件事是从 `mt5-small` 检查点加载预训练模型。由于摘要是一个序列到序列的任务,我们可以使用 `AutoModelForSeq2SeqLM` 类加载模型,该类将自动下载并缓存权重。

from transformers import AutoModelForSeq2SeqLM

model = AutoModelForSeq2SeqLM.from_pretrained(model_checkpoint)

💡 如果你好奇为什么在下游任务上微调模型时没有看到任何警告,那是因为对于序列到序列的任务,我们保留了网络的所有权重。与我们在第 3 章中的文本分类模型进行比较,在那里预训练模型的头部被随机初始化的网络替换了。

我们接下来需要做的就是登录 Hugging Face Hub。如果你在 notebook 中运行此代码,可以使用以下实用函数完成此操作:

from huggingface_hub import notebook_login

notebook_login()

这将显示一个您可以在其中输入凭据的窗口小部件。或者,您可以在终端中运行此命令并在此处登录。

huggingface-cli login

我们将需要在训练期间生成摘要以计算 ROUGE 分数。幸运的是,🤗 Transformers 提供了专门的 `Seq2SeqTrainingArguments` 和 `Seq2SeqTrainer` 类,可以为我们自动完成此操作!为了了解其工作原理,我们首先定义实验的超参数和其他参数:

from transformers import Seq2SeqTrainingArguments

batch_size = 8
num_train_epochs = 8
# Show the training loss with every epoch
logging_steps = len(tokenized_datasets["train"]) // batch_size
model_name = model_checkpoint.split("/")[-1]

args = Seq2SeqTrainingArguments(
    output_dir=f"{model_name}-finetuned-amazon-en-es",
    evaluation_strategy="epoch",
    learning_rate=5.6e-5,
    per_device_train_batch_size=batch_size,
    per_device_eval_batch_size=batch_size,
    weight_decay=0.01,
    save_total_limit=3,
    num_train_epochs=num_train_epochs,
    predict_with_generate=True,
    logging_steps=logging_steps,
    push_to_hub=True,
)

这里,`predict_with_generate` 参数已设置为指示我们应在评估期间生成摘要,以便我们可以计算每个 epoch 的 ROUGE 分数。如第 1 章所述,解码器通过逐个预测 token 来执行推理,这由模型的 `generate()` 方法实现。将 `predict_with_generate=True` 设置为告诉 `Seq2SeqTrainer` 使用该方法进行评估。我们还调整了一些默认超参数,如学习率、epoch 数量和权重衰减,并且我们设置了 `save_total_limit` 选项以在训练期间仅保存最多 3 个检查点——这是因为即使是 mT5 的“小型”版本也使用大约 1 GB 的硬盘空间,我们可以通过限制保存的副本数量来节省一些空间。

参数 `push_to_hub=True` 将允许我们在训练后将模型推送到 Hub;您可以在 `output_dir` 定义的位置下的用户配置文件中找到该仓库。请注意,您可以使用 `hub_model_id` 参数指定要推送到的仓库名称(特别是,您必须使用此参数才能推送到组织)。例如,当我们推送到 `huggingface-course` 组织时,我们将 `hub_model_id="huggingface-course/mt5-finetuned-amazon-en-es"` 添加到 `Seq2SeqTrainingArguments`。

接下来我们需要做的是为训练器提供一个 `compute_metrics()` 函数,以便我们可以在训练期间评估模型。对于摘要任务,这比简单地在模型预测上调用 `rouge_score.compute()` 要复杂一些,因为我们需要先将输出和标签_解码_成文本,然后才能计算 ROUGE 分数。以下函数正是这样做的,并且还使用 `nltk` 的 `sent_tokenize()` 函数用换行符分隔摘要句子:

import numpy as np


def compute_metrics(eval_pred):
    predictions, labels = eval_pred
    # Decode generated summaries into text
    decoded_preds = tokenizer.batch_decode(predictions, skip_special_tokens=True)
    # Replace -100 in the labels as we can't decode them
    labels = np.where(labels != -100, labels, tokenizer.pad_token_id)
    # Decode reference summaries into text
    decoded_labels = tokenizer.batch_decode(labels, skip_special_tokens=True)
    # ROUGE expects a newline after each sentence
    decoded_preds = ["\n".join(sent_tokenize(pred.strip())) for pred in decoded_preds]
    decoded_labels = ["\n".join(sent_tokenize(label.strip())) for label in decoded_labels]
    # Compute ROUGE scores
    result = rouge_score.compute(
        predictions=decoded_preds, references=decoded_labels, use_stemmer=True
    )
    # Extract the median scores
    result = {key: value.mid.fmeasure * 100 for key, value in result.items()}
    return {k: round(v, 4) for k, v in result.items()}

接下来,我们需要为我们的序列到序列任务定义一个数据整理器。由于 mT5 是一个编码器-解码器 Transformer 模型,准备批次时的一个微妙之处是,在解码过程中我们需要将标签向右移动一位。这是为了确保解码器只看到先前的真实标签,而不是当前或未来的标签,否则模型很容易记住。这与在因果语言建模等任务中如何对输入应用掩码自注意力类似。

幸运的是,🤗 Transformers 提供了一个 `DataCollatorForSeq2Seq` 整理器,它将为我们动态填充输入和标签。要实例化这个整理器,我们只需提供 `tokenizer` 和 `model`:

from transformers import DataCollatorForSeq2Seq

data_collator = DataCollatorForSeq2Seq(tokenizer, model=model)

让我们看看这个整理器在输入一小批示例时会产生什么。首先,我们需要删除包含字符串的列,因为整理器不知道如何填充这些元素。

tokenized_datasets = tokenized_datasets.remove_columns(
    books_dataset["train"].column_names
)

由于整理器期望一个 `dict` 列表,其中每个 `dict` 代表数据集中的一个示例,我们还需要在将其传递给数据整理器之前将数据整理成预期格式。

features = [tokenized_datasets["train"][i] for i in range(2)]
data_collator(features)
{'attention_mask': tensor([[1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1,
         1, 1, 1, 1, 1, 0, 0, 0, 0, 0, 0],
        [1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1,
         1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1]]), 'input_ids': tensor([[  1494,    259,   8622,    390,    259,    262,   2316,   3435,    955,
            772,    281,    772,   1617,    263,    305,  14701,    260,   1385,
           3031,    259,  24146,    332,   1037,    259,  43906,    305,    336,
            260,      1,      0,      0,      0,      0,      0,      0],
        [   259,  27531,  13483,    259,   7505,    260, 112240,  15192,    305,
          53198,    276,    259,  74060,    263,    260,    459,  25640,    776,
           2119,    336,    259,   2220,    259,  18896,    288,   4906,    288,
           1037,   3931,    260,   7083, 101476,   1143,    260,      1]]), 'labels': tensor([[ 7483,   259,  2364, 15695,     1,  -100],
        [  259, 27531, 13483,   259,  7505,     1]]), 'decoder_input_ids': tensor([[    0,  7483,   259,  2364, 15695,     1],
        [    0,   259, 27531, 13483,   259,  7505]])}

这里要注意的主要一点是,第一个示例比第二个长,因此第二个示例的 `input_ids` 和 `attention_mask` 在右侧填充了 `[PAD]` 令牌(其 ID 为 `0`)。同样,我们可以看到 `labels` 已用 `-100` 填充,以确保损失函数忽略填充令牌。最后,我们可以看到一个新的 `decoder_input_ids`,它通过在第一个条目中插入一个 `[PAD]` 令牌将标签向右移动了一位。

我们终于有了训练所需的所有要素!现在我们只需使用标准参数实例化训练器:

from transformers import Seq2SeqTrainer

trainer = Seq2SeqTrainer(
    model,
    args,
    train_dataset=tokenized_datasets["train"],
    eval_dataset=tokenized_datasets["validation"],
    data_collator=data_collator,
    tokenizer=tokenizer,
    compute_metrics=compute_metrics,
)

并启动我们的训练运行。

trainer.train()

在训练期间,您应该会看到训练损失随着每个 epoch 的增加而减少,ROUGE 分数也会增加。训练完成后,您可以通过运行 `Trainer.evaluate()` 来查看最终的 ROUGE 分数:

trainer.evaluate()
{'eval_loss': 3.028524398803711,
 'eval_rouge1': 16.9728,
 'eval_rouge2': 8.2969,
 'eval_rougeL': 16.8366,
 'eval_rougeLsum': 16.851,
 'eval_gen_len': 10.1597,
 'eval_runtime': 6.1054,
 'eval_samples_per_second': 38.982,
 'eval_steps_per_second': 4.914}

从分数中我们可以看出,我们的模型轻松超越了我们的 lead-3 基线——棒极了!最后要做的是将模型权重推送到 Hub,如下所示:

trainer.push_to_hub(commit_message="Training complete", tags="summarization")
'https://huggingface.co/huggingface-course/mt5-finetuned-amazon-en-es/commit/aa0536b829b28e73e1e4b94b8a5aacec420d40e0'

这会将检查点和配置文件保存到 `output_dir`,然后将所有文件上传到 Hub。通过指定 `tags` 参数,我们还确保 Hub 上的小部件将是摘要管道的小部件,而不是与 mT5 架构关联的默认文本生成小部件(有关模型标签的更多信息,请参阅 🤗 Hub 文档)。`trainer.push_to_hub()` 的输出是 Git 提交哈希的 URL,因此您可以轻松查看对模型存储库所做的更改!

总结本节,让我们看看如何使用 🤗 Accelerate 提供的低级功能来微调 mT5。

使用 🤗 Accelerate 微调 mT5

使用 🤗 Accelerate 微调我们的模型与我们在第 3 章中遇到的文本分类示例非常相似。主要区别在于需要显式地在训练期间生成摘要并定义如何计算 ROUGE 分数(回想一下 `Seq2SeqTrainer` 为我们处理了生成)。让我们看看如何在 🤗 Accelerate 中实现这两个要求!

为训练做准备

我们需要做的第一件事是为每个分割创建一个 `DataLoader`。由于 PyTorch 数据加载器期望的是张量批次,我们需要在数据集中将格式设置为 `"torch"`。

tokenized_datasets.set_format("torch")

现在我们有了只包含张量的数据集,接下来要做的是再次实例化 `DataCollatorForSeq2Seq`。为此,我们需要提供一个全新的模型版本,所以让我们从缓存中再次加载它。

model = AutoModelForSeq2SeqLM.from_pretrained(model_checkpoint)

然后我们可以实例化数据整理器并用它来定义我们的数据加载器。

from torch.utils.data import DataLoader

batch_size = 8
train_dataloader = DataLoader(
    tokenized_datasets["train"],
    shuffle=True,
    collate_fn=data_collator,
    batch_size=batch_size,
)
eval_dataloader = DataLoader(
    tokenized_datasets["validation"], collate_fn=data_collator, batch_size=batch_size
)

接下来要做的就是定义我们想要使用的优化器。与我们的其他示例一样,我们将使用 `AdamW`,它适用于大多数问题。

from torch.optim import AdamW

optimizer = AdamW(model.parameters(), lr=2e-5)

最后,我们将模型、优化器和数据加载器提供给 `accelerator.prepare()` 方法。

from accelerate import Accelerator

accelerator = Accelerator()
model, optimizer, train_dataloader, eval_dataloader = accelerator.prepare(
    model, optimizer, train_dataloader, eval_dataloader
)

🚨 如果您正在 TPU 上训练,您需要将以上所有代码移到一个专用的训练函数中。有关更多详细信息,请参阅第 3 章

现在我们已经准备好了对象,还有三件事要做:

  • 定义学习率调度。
  • 实现一个函数,用于对摘要进行后处理以进行评估。
  • 在 Hub 上创建一个仓库,我们可以将我们的模型推送上去。

对于学习率调度,我们将使用之前章节的标准线性调度。

from transformers import get_scheduler

num_train_epochs = 10
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,
)

对于后处理,我们需要一个函数,将生成的摘要分割成用换行符分隔的句子。这是 ROUGE 指标期望的格式,我们可以通过以下代码片段来实现:

def postprocess_text(preds, labels):
    preds = [pred.strip() for pred in preds]
    labels = [label.strip() for label in labels]

    # ROUGE expects a newline after each sentence
    preds = ["\n".join(nltk.sent_tokenize(pred)) for pred in preds]
    labels = ["\n".join(nltk.sent_tokenize(label)) for label in labels]

    return preds, labels

如果你还记得我们是如何定义 `Seq2SeqTrainer` 的 `compute_metrics()` 函数的,这应该看起来很熟悉。

最后,我们需要在 Hugging Face Hub 上创建一个模型仓库。为此,我们可以使用标题恰当的 🤗 Hub 库。我们只需要为我们的仓库定义一个名称,并且该库有一个实用函数可以将仓库 ID 与用户个人资料结合起来。

from huggingface_hub import get_full_repo_name

model_name = "test-bert-finetuned-squad-accelerate"
repo_name = get_full_repo_name(model_name)
repo_name
'lewtun/mt5-finetuned-amazon-en-es-accelerate'

现在我们可以使用这个仓库名称将本地版本克隆到我们的结果目录,该目录将存储训练工件。

from huggingface_hub import Repository

output_dir = "results-mt5-finetuned-squad-accelerate"
repo = Repository(output_dir, clone_from=repo_name)

这将允许我们在训练期间调用 `repo.push_to_hub()` 方法将工件推回 Hub!现在让我们通过编写训练循环来结束我们的分析。

训练循环

摘要的训练循环与我们遇到的其他 🤗 Accelerate 示例非常相似,大致分为四个主要步骤:

  1. 通过遍历 `train_dataloader` 中每个 epoch 的所有示例来训练模型。
  2. 在每个 epoch 结束时生成模型摘要,首先生成 token,然后将它们(和参考摘要)解码为文本。
  3. 使用我们之前看到的相同技术计算 ROUGE 分数。
  4. 保存检查点并将所有内容推送到 Hub。这里我们依赖 `Repository` 对象的巧妙的 `blocking=False` 参数,以便我们可以_异步_地推送每个 epoch 的检查点。这使得我们可以继续训练,而无需等待与 GB 大小模型相关的有些缓慢的上传!

这些步骤可以在以下代码块中看到:

from tqdm.auto import tqdm
import torch
import numpy as np

progress_bar = tqdm(range(num_training_steps))

for epoch in range(num_train_epochs):
    # Training
    model.train()
    for step, batch in enumerate(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()
    for step, batch in enumerate(eval_dataloader):
        with torch.no_grad():
            generated_tokens = accelerator.unwrap_model(model).generate(
                batch["input_ids"],
                attention_mask=batch["attention_mask"],
            )

            generated_tokens = accelerator.pad_across_processes(
                generated_tokens, dim=1, pad_index=tokenizer.pad_token_id
            )
            labels = batch["labels"]

            # If we did not pad to max length, we need to pad the labels too
            labels = accelerator.pad_across_processes(
                batch["labels"], dim=1, pad_index=tokenizer.pad_token_id
            )

            generated_tokens = accelerator.gather(generated_tokens).cpu().numpy()
            labels = accelerator.gather(labels).cpu().numpy()

            # Replace -100 in the labels as we can't decode them
            labels = np.where(labels != -100, labels, tokenizer.pad_token_id)
            if isinstance(generated_tokens, tuple):
                generated_tokens = generated_tokens[0]
            decoded_preds = tokenizer.batch_decode(
                generated_tokens, skip_special_tokens=True
            )
            decoded_labels = tokenizer.batch_decode(labels, skip_special_tokens=True)

            decoded_preds, decoded_labels = postprocess_text(
                decoded_preds, decoded_labels
            )

            rouge_score.add_batch(predictions=decoded_preds, references=decoded_labels)

    # Compute metrics
    result = rouge_score.compute()
    # Extract the median ROUGE scores
    result = {key: value.mid.fmeasure * 100 for key, value in result.items()}
    result = {k: round(v, 4) for k, v in result.items()}
    print(f"Epoch {epoch}:", result)

    # 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: {'rouge1': 5.6351, 'rouge2': 1.1625, 'rougeL': 5.4866, 'rougeLsum': 5.5005}
Epoch 1: {'rouge1': 9.8646, 'rouge2': 3.4106, 'rougeL': 9.9439, 'rougeLsum': 9.9306}
Epoch 2: {'rouge1': 11.0872, 'rouge2': 3.3273, 'rougeL': 11.0508, 'rougeLsum': 10.9468}
Epoch 3: {'rouge1': 11.8587, 'rouge2': 4.8167, 'rougeL': 11.7986, 'rougeLsum': 11.7518}
Epoch 4: {'rouge1': 12.9842, 'rouge2': 5.5887, 'rougeL': 12.7546, 'rougeLsum': 12.7029}
Epoch 5: {'rouge1': 13.4628, 'rouge2': 6.4598, 'rougeL': 13.312, 'rougeLsum': 13.2913}
Epoch 6: {'rouge1': 12.9131, 'rouge2': 5.8914, 'rougeL': 12.6896, 'rougeLsum': 12.5701}
Epoch 7: {'rouge1': 13.3079, 'rouge2': 6.2994, 'rougeL': 13.1536, 'rougeLsum': 13.1194}
Epoch 8: {'rouge1': 13.96, 'rouge2': 6.5998, 'rougeL': 13.9123, 'rougeLsum': 13.7744}
Epoch 9: {'rouge1': 14.1192, 'rouge2': 7.0059, 'rougeL': 14.1172, 'rougeLsum': 13.9509}

就是这样!一旦你运行这个,你将拥有一个模型和结果,它们与我们使用 `Trainer` 获得的结果非常相似。

使用微调模型

将模型推送到 Hub 后,您可以通过推理小部件或使用 `pipeline` 对象来使用它,如下所示:

from transformers import pipeline

hub_model_id = "huggingface-course/mt5-small-finetuned-amazon-en-es"
summarizer = pipeline("summarization", model=hub_model_id)

我们可以从测试集中(模型未见过)输入一些示例到我们的管道中,以了解摘要的质量。首先让我们实现一个简单的函数来同时显示评论、标题和生成的摘要:

def print_summary(idx):
    review = books_dataset["test"][idx]["review_body"]
    title = books_dataset["test"][idx]["review_title"]
    summary = summarizer(books_dataset["test"][idx]["review_body"])[0]["summary_text"]
    print(f"'>>> Review: {review}'")
    print(f"\n'>>> Title: {title}'")
    print(f"\n'>>> Summary: {summary}'")

我们来看看其中一个英文例子:

print_summary(100)
'>>> Review: Nothing special at all about this product... the book is too small and stiff and hard to write in. The huge sticker on the back doesn’t come off and looks super tacky. I would not purchase this again. I could have just bought a journal from the dollar store and it would be basically the same thing. It’s also really expensive for what it is.'

'>>> Title: Not impressed at all... buy something else'

'>>> Summary: Nothing special at all about this product'

这还不错!我们可以看到我们的模型实际上已经能够通过用新词语补充评论的一部分来执行_抽象_摘要。我们模型最酷的方面也许是它是双语的,所以我们也可以生成西班牙语评论的摘要:

print_summary(0)
'>>> Review: Es una trilogia que se hace muy facil de leer. Me ha gustado, no me esperaba el final para nada'

'>>> Title: Buena literatura para adolescentes'

'>>> Summary: Muy facil de leer'

摘要翻译成英文是“Very easy to read”(非常容易阅读),在本例中我们可以看到这是直接从评论中提取的。尽管如此,这展示了 mT5 模型的通用性,并让您体验了处理多语言语料库的感觉!

接下来,我们将把注意力转向一个稍微复杂的任务:从头开始训练一个语言模型。

< > 在 GitHub 上更新