NLP Course documentation

提取文本摘要

Hugging Face's logo
Join the Hugging Face community

and get access to the augmented documentation experience

to get started

提取文本摘要

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 条评论, validationtest 部分有 5,000 条评论。我们感兴趣的评论正文和标题保存在 review_bodyreview_title 列中。让我们通过创建一个简单的函数来从训练集中随机抽取一些样本,该函数使用我们在 第五章 学到过:

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”标题的示例的信息量不大,但其他标题看起来像是对评论本身的不错的总结。在单个 GPU 上训练所有 400,000 条评论的摘要模型将花费太长时间,因此我们将专注于为单个产品领域生成摘要。为了了解我们可以选择哪些领域,让我们将 english_dataset 转换为 pandas.DataFrame ,并计算每个产品类别的评论数量:

english_dataset.set_format("pandas")
english_df = english_dataset["train"][:]
# 显示前 20 个产品的数量
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

在英语数据集中,最受欢迎的产品是家居用品、服装和无线电子产品。不过,为了带有亚马逊的特色,让我们专注于总结书籍的评论——毕竟,这是亚马逊这家公司成立的基础!我们可以看到两个符合要求的产品类别( bookdigital_ebook_purchase ),所以让我们用这两个产品类别过滤两种语言的数据集。正如我们在 第五章 学到的, Dataset.filter() 函数可以让我们非常有效地对数据集进行切片,所以我们可以定义一个简单的函数来进行此操作:

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

当我们使用这个函数对 english_datasetspanish_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)

# 挑选一些样例
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 模型采用了我们在 第一章 遇到的编码器-解码器架构。尽管有一些例外,例如 GPT 系列模型,它们在 few-shot(少量微调)之后也可以提取摘要。下表列出了一些可以进行摘要微调的流行预训练模型。

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

从此表中可以看出,大多数用于摘要的 Transformer 模型(以及大多数 NLP 任务)都是单一语言的。如果你的任务所使用的语言是“有大量语料库”(如英语或德语)的语言,这很好。但对于世界各地正在使用的数千种其他语言,则不然。幸运的是,有一类多语言 Transformer 模型,如 mT5 和 mBART,可以解决问题。这些模型也是使用因果语言建模进行预训练的,但有一点不同:它们不是在一种语言的语料库上训练,而是同时在 50 多种语言的文本上进行联合训练!

我们将使用 mT5,这是一种基于 T5 的有趣架构,在文本到文本任务中进行了预训练。在 T5 中,每个 NLP 任务都是以任务前缀(如 summarize: )的形式定义的,模型根据不同的任务生成不同的文本。如下图所示,这让 T5 变得非常通用,因为你可以用一个模型解决很多任务!

Different tasks performed by the T5 architecture.

mT5 不使用前缀,但具有 T5 的大部分功能,并且具有多语言的优势。现在我们已经选择了一个模型,接下来让我们来看看如何准备我们的训练数据。

✏️ 试试看! 完成本节后,可以尝试比较一下 mT5 和用相同技术微调过的 mBART 的性能。附加的挑战:只在英文评论上微调 T5。因为 T5 有一个特殊的前缀提示,你需要在下面的预处理步骤中将 summarize: 添加到输入例子前。

预处理数据

我们接下来的任务是对我们的评论及其标题进行 tokenize 和 encode 。通常,我们需要首先加载与预训练模型 checkpoint 相关的 tokenizer,这次我们将使用较小的 mt5-small 作为我们的 checkpoint 这样我们就可以在合理的时间消耗内对模型进行微调:

from transformers import AutoTokenizer

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

💡在 NLP 项目的早期阶段,一个好的做法是在小样本数据上训练一类“小”模型。这使你可以更快地调试和迭代端到端工作流。当你对结果有信心之后,你只需要通过简单地更改模型 checkpoint 就可以在较大规模数据上训练模型!

让我们在一个小样本上测试 mT5 tokenizer

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]}

在这里我们可以看到熟悉的 input_idsattention_mask ,我们在 第3章 的第一次微调实验中遇到过。让我们使用 tokenizer 的 convert_ids_to_tokens() 函数解码这些输入 ID,看看我们正在处理的是什么类型的 tokenizer:

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

从特殊的 Unicode 字符 和表示序列结束 </s> token 可以看出来,我们正在使用基于 第6章 中讨论的 Unigram 子词分词算法的 SentencePiece tokenizer 。 Unigram 对于多语言语料库特别有用,因为它让 SentencePiece 不必受口音、标点符号以及很多语言(如日语)没有空白字符的影响,只专注于找出最优的分词方式。

为了对我们的语料库 tokenize ,我们需要处理与摘要任务会遇到的一个细微问题:因为我们的输出目标也是文本,所以输入和输出加起来可能超过模型的最大上下文大小。这意味着我们需要对评论及其标题进行截断,以确保我们不会将过长的输入传递给我们的模型。🤗 Transformers 中的 tokenizer 提供了一个绝妙的 text_target 参数,允许你将目标文本与输入并行 tokenize。以下是如何为 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_lengthmax_target_length 的值,这些值设定了我们的评论和标题的最大长度。由于评论主体通常比标题大得多,我们相应地调整了这些值。

通过 preprocess_function() 函数,我们可以使用我们在这门课程中广泛使用的方便的 Dataset.map() 函数,轻松地对整个语料库 tokenize 。

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

既然语料库已经预处理完毕,我们来看看一些常用的摘要指标。正如我们在下面即将看到的,在衡量机器生成的文本的质量方面没有灵丹妙药。

💡 你可能已经注意到我们在上面的 Dataset.map() 函数中使用了 batched=True 。这将以 1000(默认值)的 batch size 对示例继续编码,并让你可以利用 🤗 Transformers 中快速 tokenizer 的多线程功能。在可能的情况下,尝试使用 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 是基于计算计算重叠部分的 精确度(Precision)召回率(Recall) 分数来计算的。

🙋 如果这是你第一次听说精确度(Precision)和召回率(Recall),请不要担心——我们将一起通过一些清晰的示例来理解它们。这些指标通常在分类任务中遇到,所以如果你想了解在分类任务中精确度(Precision)和召回率(Recall)是如何定义的,我们建议你查看 scikit-learn指南

对于 ROUGE,召回率衡量的是参考摘要中被生成摘要捕获的内容量。如果我们只是比较单词,召回率可以按照以下公式计算: 召回率=重叠词的数量参考摘要中的总词数 \mathrm{召回率} = \frac{\mathrm{重叠词的数量}}{\mathrm{参考摘要中的总词数}}

对于上面的那个例子,这个公式给出了 6/6 = 1 的完美召回率;即,参考摘要中的所有单词模型都生成出来了。这听起来可能很棒,但想象一下,如果我们生成的摘要是“我真的很喜欢整晚阅读饥饿游戏”。这也会有完美的 recall,但可以说这是一个更糟糕的总结,因为它很冗长。为了适应于这些场景,我们还计算了精确度,它在 ROUGE 上下文中衡量了生成的摘要中有多少是相关的: 精确度=重叠词的数量生成摘要中的总词数 \mathrm{精确度} = \frac{\mathrm{重叠词的数量}}{\mathrm{生成摘要中的总词数}}

详细摘要使用这种计算方法会得到 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 分数的置信区间;也些就是你在这里看到的 lowmidhigh 属性。此外,🤗 Datasets 还计算了基于在比较生成摘要和参考摘要时的采用不同文本粒度的各种 ROUGE 得分。 rouge1 测量的是生成摘要和参考摘要中单个单词的重叠程度。 为了验证这一点,让我们提取出我们得分的 mid 值:

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

太好了,精确度和召回率的数字都对上了!那么其他的 ROUGE 得分表示什么含义呢? rouge2 度量了二元词组(考虑单词对的重叠)之间的重叠,而 rougeLrougeLsum 通过寻找生成的摘要和参考摘要中最长的公共子串来度量单词的最长匹配序列。 rougeLsum 中的“sum”指的是该指标是在整个摘要上计算的,而 rougeL 是指在各个句子上计算的平均值。

✏️ 试试看! 自己手动创建一个生成摘要和参考摘要,看看使用 evaluate 得出的 ROUGE 分数是否与基于精确度和召回率公式的手动计算一致。附加的挑战:将文本切分为长度为2的词组,并手动计算精度和召回率与 rouge2 指标的精确度和召回率进行对比。

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

创建强大的 baseline

对于文本摘要,一个常见的参考 baseline 是简单地取文章的前三句话作为摘要,通常称为 lead-3 baseline。我们可以使用句号(英文使用.)来跟踪句子边界,但这在“U.S.” or “U.N.”之类的首字母缩略词上会计算错误。所以我们将使用 nltk 库,它包含一个更好的算法来处理这些情况。你可以使用以下方式安装该包:

!pip install nltk

然后下载标点规则:

import nltk

nltk.download("punkt")

接下来,我们从 nltk 导入句子的 tokenizer 并创建一个简单的函数用来提取评论中的前三个句子。文本摘要的默认情况下使用换行符分隔每个摘要,因此我们也按照这样的规则处理,并在训练集的示例上对其进行测试:

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.'

这似乎有效,接下来让我们现在实现一个函数,从数据集中提取这些“摘要”并计算 baseline 的 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 的分数明显低于其他的rouge;这可能反映了这样一个事实,即评论标题通常很简洁,因此 lead-3 baseline 过于冗长导致得分不高。现在我们有了一个很好的参考基准,让我们将注意力转向微调 mT5!

使用 Trainer API 微调 mT5

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

from transformers import AutoModelForSeq2SeqLM

model = AutoModelForSeq2SeqLM.from_pretrained(model_checkpoint)

💡 如果你想知道为什么在实例化的过程中没有看到任何关于微调模型的警告,那是因为对于序列到序列的任务,我们保留了网络的所有权重。与此相比,在 第三章 中的文本分类模型中,我们用一个随机初始化的网络替换了预训练模型的头部。

我们需要做的下一件事是登录 Hugging Face Hub。如果你在 notebook 中运行此代码,则可以使用以下实用程序函数进行此操作:

from huggingface_hub import notebook_login

notebook_login()

这将显示一个小工具,你可以在其中输入你的凭据。或者,你可以在你的终端运行这条命令来登陆:

huggingface-cli login

为了在训练期间计算 ROUGE 分数,我们需要在训练期间生成文本形式的摘要。幸运的是,🤗 Transformers 提供了专用的 Seq2SeqTrainingArgumentsSeq2SeqTrainer 类,可以自动为我们完成这项工作!为了了解它是如何工作的,让我们首先为我们的实验定义超参数和其他参数,在后面的的训练过程会讲到如何实现的。

from transformers import Seq2SeqTrainingArguments

batch_size = 8
num_train_epochs = 8
# 每个训练周期都输出训练损失
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 参数设置为 True ,这样可以在评估期间生成摘要来计算每个 epoch 的 ROUGE 分数。正如在 第一章 中学到的,模型的 generate() 函数实现了使用解码器逐个预测单词来推理生成的文本。设置 predict_with_generate=True 后,Seq2SeqTrainer 会在评估时使用 generate() 函数。除此之外我们还调整默认的超参数,如学习率、epochs 数和权重衰减,并且设置 save_total_limit 选项, 使训练期间最多只能保存 3 个 checkpoint 的选项。——这是因为即使是 mT5 的“small”版本也使用大约 1 GB 的硬盘空间,我们可以通过限制保存的副本数量来节省一点空间。

设置 push_to_hub=True 选项后 Trainer 会在训练后自动将模型推送到 Hub 中;你可以在 output_dir 指定的位置下的用户配置文件中找到对应的仓库。请注意,你可以使用 hub_model_id 参数指定要推送到的仓库的名称(特别是当你想要推送到组织时,就必须使用此参数)。例如,当我们将模型推送到 huggingface-course 组织 时,我们在 Seq2SeqTrainingArguments 中添加了 hub_model_id="huggingface-course/mt5-finetuned-amazon-en-es"

为了在训练期间评估模型,我们还需要为 Trainer 提供一个 compute_metrics() 函数。对于摘要模型来说,不能直接调用 rouge_score.compute() 进行评估,因为我们需要将输出和参考摘要解码为文本,然后才能计算 ROUGE 分数。下面的函数就完成了解码和计算分数,除此之外还使用了 nltk 中的 sent_tokenize() 函数将摘要句子用换行符分隔开:

import numpy as np


def compute_metrics(eval_pred):
    predictions, labels = eval_pred
    # 将生成的摘要解码为文本
    decoded_preds = tokenizer.batch_decode(predictions, skip_special_tokens=True)
    # 替换标签中的-100,因为我们无法解码它们
    labels = np.where(labels != -100, labels, tokenizer.pad_token_id)
    # 将参考摘要解码为文本
    decoded_labels = tokenizer.batch_decode(labels, skip_special_tokens=True)
    # ROUGE期望每个句子后都有一个换行符
    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]
    # 计算ROUGE分数
    result = rouge_score.compute(
        predictions=decoded_preds, references=decoded_labels, use_stemmer=True
    )
    # 计算ROUGE分数
    result = {key: value.mid.fmeasure * 100 for key, value in result.items()}
    return {k: round(v, 4) for k, v in result.items()}

接下来,我们需要为我们的序列到序列任务定义一个数据整理器(data collator)。由于 mT5 是一个编码器-解码器的 Transformer 模型,因此在将数据整理成 batch 时有一点需要注意,那就是在解码期间,我们需要将标签向右移动一个单位。这是为了确保解码器只看到之前的参考序列,而不是当前要预测的 token 或之后的参考序列,这样模型就能避免容易记住标签。这类似与在 因果语言模型 这样的任务中使用掩码自注意力的机制类似。

幸运的是,🤗 Transformers 提供了一个 DataCollatorForSeq2Seq 整理器,它会动态地填充我们的输入和标签。我们只需要提供 tokenizermodel 既可实例化这个整理器:

from transformers import DataCollatorForSeq2Seq

data_collator = DataCollatorForSeq2Seq(tokenizer, model=model)

让我们看看当给这个整理器提供一个小批次的样本时,它的处理过程是怎么样的。首先,我们需要删除带有字符串的列,因为整理器不知道如何对这些元素进行填充(padding):

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

由于 collator 需要一个 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_idsattention_mask 在右边用 [PAD] token (ID 为 0 )进行了填充。同样,我们可以看到标签也使用 -100 进行了填充,以确保填充的 tokens 被损失函数忽略。最后,我们可以看到多了一个新的 decoder_input_ids 字段,它是通过在第一个条目中插入 [PAD] tokens 来将标签向右移动一个 token 形成的。

我们终于拥有了训练所需的所有的前期准备!我们现在只需要使用标准参数实例化 Trainer

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()

在训练期间,应该可以看到训练损失逐渐减小,并且 ROUGE 分数随着 epoch 的增加而增加。训练完成之后,你可以通过运行 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 baseline——很好!最后要做的是将模型权重推送到 Hub,如下所示:

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

上面的代码会把 checkpoint 和配置文件保存到 output_dir ,然后将所有文件上传到 Hub。我们还可以通过 tags 参数指定模型的类型,这样就可以确保在 Hub 上的小工具会是一个摘要生成的小工具,而不是与 mT5 架构的默认文本生成小工具(关于模型标签的更多信息,请参见 🤗Hub文档 )。 trainer.push_to_hub() 的输出是带有 Git 提交哈希的 URL,所以你可以打开 URL 轻松查看模型库的修改记录!

在结束本节之前,让我们看一下如何使用 🤗 Accelerate 提供的底层 API 对 mT5 进行微调。

使用 🤗 Accelerate 微调 mT5

使用 🤗 Accelerate 微调我们的模型与我们在 第三章 中遇到的文本分类示例非常相似。与文本分类的主要区别在于摘要模型需要在训练期间显式生成摘要并实现 ROUGE 分数的计算(请记住, Seq2SeqTrainer 已经为我们实现了生成摘要的部分)。让我们看看我们如何在 🤗 Accelerate 中实现这两个要求!

为训练做好准备

首先,我们需要为每个数据分组创建一个 DataLoader 。由于 PyTorch 的 dataloaders 的输入是由张量组成的 batch,所以我们需要将数据集的格式设定为 "torch"

tokenized_datasets.set_format("torch")

然后我们可以实例化数据整理器,并使用它来定义我们的 DataLoader:

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)

为了重新开始微调,而不是从上面微调过的模型继续微调,我们需要重新实例化 model。

model = AutoModelForSeq2SeqLM.from_pretrained(model_checkpoint)

最后,我们将模型、优化器和 dataloaders 输入到 accelerator.prepare() 方法中:

from accelerate import Accelerator

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

🚨如果你在 TPU 上进行训练,则需要将上述所有代码移动到专门的训练函数中。有关 TPU 的详细信息,请回顾 第三章

现在我们已经准备好了我们的对象,还有三个事情需要做

  • 定义学习率调度计划。
  • 实现一个功能来对模型输出的摘要进行后续处理以进行评估。
  • 在 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 需要每个句子后有一个换行符
    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

如果你还记得我们是如何定义 Seq2SeqTrainercompute_metrics() 函数,那么你应该对上述的代码来感到很熟悉。

最后,我们需要在 Hugging Face Hub 上创建一个模型仓库。为此,我们可以使用名为🤗 Hub 的 python 库。我们只需要为我们的仓库取一个 ID,Hub 库中有一个实用的函数可以将仓库 ID 与用户 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. 通过在每个 epoch 迭代 train_dataloader 中的所有示例来训练模型。
  2. 在每个 epoch 结束时生成摘要,首先生成 tokens 然后将它们(和参考摘要)解码为文本。
  3. 使用我们之前的方法计算 ROUGE 分数。
  4. 保存 checkpoint 并将所有内容推送到 Hub。在这里,我们依赖 Repository 对象的巧妙的 blocking=False 参数,以便我们可以在每个 epoch 异步地上传 checkpoint,这使我们能够继续训练,而不必等待与 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):
    # 训练
    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)

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

            # 如果我们没有填充到最大长度,我们需要填充标签
            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()

            # 替换标签中的 -100,因为我们无法解码它们
            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)

    # 计算评估的 loss
    result = rouge_score.compute()
    # 提取中位 ROUGE 分数
    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)

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

在这个例子中生成的摘要翻译成中文的意思是“非常容易阅读”,我们可以看到它是直接从评论中提取的。同时,这个例子还展现了mT5 模型的多种功能特性,并支持处理多语言的语料库!

接下来,我们将尝试一个稍微复杂一点的任务:从头开始训练一个语言模型。

< > Update on GitHub