huggingface NLP工具包教程2:使用Transformers

huggingface NLP工具包教程2:使用Transformers

引言

Transformer 模型通常非常大,由于有数百万到数百亿个参数,训练和部署这些模型是一项复杂的任务。此外,由于几乎每天都有新模型发布,而且每个模型都有自己的实现,所以使用所有这些模型比较麻烦。

transformers 库就是为了解决这个问题而创建的。目标是提供一个 API,通过它可以加载、训练和保存任何 Transformer 模型。主要特点是:

  • 易用性:下载、加载和使用最新的 NLP 模型进行推理只需两行代码即可完成。
  • 灵活性:所有模型的核心都是简单的 PyTorch 中的 nn.Module 或 TensorFlow 中的 tf.keras.Model 类或其他框架中的模型一样处理。
  • 简单性:库中几乎没有任何抽象。核心概念是 “All-in-one file”:模型的前向传递完全在一个文件中定义,这样代码本身就很容易理解和魔改。

Transformers 库还有一点与其他机器学习框架不同。用于构建模型的各个模块并不是在不同文件之间共享的,每个模型有自己的层。这使得模型更容易理解,并且可以方便地在一个模型上进行实验,而不会影响到其他模型。

本章将会介绍一个端到端的例子,使用 model、tokenizer 来实现第一章中 pipeline() 方法中的流程。对于 model API,我们将深入 model 类和 configuration 类,并展示如何加载一个模型、它如何处理数值输入并输出结果。然后介绍 tokenizer API,它负责第一个和最后一个处理步骤,处理从文本到神经网络数值输入的转换,并在需要时转换回文本。最后,将介绍如何通过高层 tokenizer() 方法在一个批次内处理多个句子。

pipeline背后

我们从一个完整的例子开始,下面的例子在第一章中已经介绍过,现在来看一下在它执行的背后发生了什么:

from transformers import pipeline

classifier = pipeline("sentiment-analysis")
classifier(
    [
        "I've been waiting for a HuggingFace course my whole life.",
        "I hate this so much!",
    ]
)

# 输出:
[{
    
    'label': 'POSITIVE', 'score': 0.9598047137260437},
 {
    
    'label': 'NEGATIVE', 'score': 0.9994558095932007}]

下面这张图介绍了 pipeline 方法中具体做的事情,下面将逐步看一下。

在这里插入图片描述

tokenizer预处理

Transformer 模型肯定不能直接处理原始文本数据,所以 pipeline 的第一步就是将原始文本数据转换为数值数据。tokenizer 负责完成这件事情:

  • 将输入分割成单词、子词(subword)或者符号(比如根据标点),这些分割结果成为 tokens
  • 将每个 token 映射为一个整数
  • 添加一些额外的输入

所有这些预训练步骤,在模型预训练、微调、使用(推理)时都应该保持完全一致,因此我们在 Model Hub 中下载模型时也需要下载这些信息。为了加载给定的预处理步骤,我们需要使用 AutoTokenizer 类和它的 from_pretrained() 方法。使用指定模型权重的名称时,将会自动获取与该模型对应的 tokenizer 并将其缓存(所以仅在第一次使用时需要下载)。

from transformers import AutoTokenizer

checkpoint = "distilbert-base-uncased-finetuned-sst-2-english"
tokenizer = AutoTokenizer.from_pretrained(checkpoint)

当我们得到 tokenizer 之后,就可以直接将原始文本送入其中就可以得到可以直接输入给模型的整型值。剩下要做的事情就是将这些整型值转换为 tensor。

transformers 库支持多数机器学习框架,如 PyTorch、TensorFlow、Flax(部分模型支持)。我们知道,在机器学习框架中,模型需要接收张量(tensor)类型作为输入。我们可以通过 return_tensors 参数来指定返回张量类型所属的框架(如 Pytorch、TensorFlow、NumPy)。

raw_inputs = [
    "I've been waiting for a HuggingFace course my whole life.",
    "I hate this so much!",
]
inputs = tokenizer(raw_inputs, padding=True, truncation=True, return_tensors="pt")
print(inputs)

paddingtruncation 参数先不用管,后面会介绍。现在要知道的是,我们需要传入一个或一组句子,并指定返回张量所属的框架,如果未指定,则返回列表。以下是以 PyTorch 为例的返回结果:

{
    
    
    'input_ids': tensor([
        [  101,  1045,  1005,  2310,  2042,  3403,  2005,  1037, 17662, 12172, 2607,  2026,  2878,  2166,  1012,   102],
        [  101,  1045,  5223,  2023,  2061,  2172,   999,   102,     0,     0,     0,     0,     0,     0,     0,     0]
    ]), 
    '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, 0, 0, 0, 0, 0, 0, 0, 0, 0]
    ])
}

可以看到,返回结果是一个字典,其中有两个键:input_idsattention_mask 。其中 input_ids 由两行整型值组成(每一行对应一个输入句子),每行中的元素由独特的整型值表示一个 token。attention_mask 后面会介绍。

model处理过程

我们同样可以下载预训练模型(model)。transformers 库中有 AutoModel 类,与 AutoTokenizer 类似,它同样有 from_pretrained() 方法。

from transformers import AutoModel

checkpoint = "distilbert-base-uncased-finetuned-sst-2-english"
model = AutoModel.from_pretrained(checkpoint)

在这段代码中,我们下载了一个与之前 pipeline 中同样的模型权重(如果运行过 pipeline 方法,这里不会重新下载,而是使用缓存的权重),并使用该权重实例化了一个模型。这个架构只包含基本的 Transformer 模块:给定一些输入,输出隐藏状态,也称为特征。对于每个模型输入,也将得到一个高维向量,表示Transformer 模型对该输入的整体表示。虽然这些隐藏状态本身也可以直接用,但它们通常是模型另一部分(head)的输入。在第 1 章中,不同的任务可以用相同的体系结构来执行,但是这些任务中的每一个都有不同的 head 部分。

高维向量表示

Transformer 模型的输出通常非常巨大,一般来说它包含三个维度:

  • 批尺寸(batch size):同时处理的序列个数(上例中为 2);
  • 序列长度(sequence length):用于表示一个序列数值表示的长度(上例中为 16);
  • 隐藏状态尺寸(hidden size):高维向量和隐藏状态的尺寸

隐藏状态之所以被称为高维向量表示是因为它的尺寸通常非常大,在较小的 Transformer 模型中通常为 768,在大模型中可以达到 3072 甚至更大。可以通过以下方法查看它的尺寸:

outputs = model(**inputs)
print(outputs.last_hidden_state.shape)

# 输出:
torch.Size([2, 16, 768])

Transformer 模型的输出像一个命名元组(namedtuple)或字典。可以通过属性(outputs.last_hidden_state)、键(outputs['last_hidden_state'])、索引(outputs[0])三种形式访问 。

模型输出头

模型头(head)通常由几层全连接层组成,接收 Transformer 模型输出的隐藏状态高维向量作为输入,并将其映射到另外一个维度。在模型中的例程如下图所示。Transformer 模型由其嵌入层(embeddings)和后续层表示。嵌入层将 token 输入中的每个输入 ID 转换为表示相关 token 的向量。随后的层使用注意力机制处理这些向量,以产生句子的最终表示。然后送到模型头中进行处理,输出最终的预测结果。

在这里插入图片描述

Transformers 中有许多不同的架构,每一种架构都是围绕解决特定任务而设计的。以下列表展示了其中一部分:

  • *Model (获取模型的隐藏状态)
  • *ForCausalLM
  • *ForMaskedLM
  • *ForMultipleChoice
  • *ForQuestionAnswering
  • *ForSequenceClassification
  • *ForTokenClassification
  • 其他

在我们的例子中,我们的模型需要有一个序列分类头,来对句子是正向还是负向进行分类。所以实际上我们这里使用的不是 AutoModel 类,而是 AutoModelForSequenceClassification 类。

from transformers import AutoModelForSequenceClassification

checkpoint = "distilbert-base-uncased-finetuned-sst-2-english"
model = AutoModelForSequenceClassification.from_pretrained(checkpoint)
outputs = model(**inputs)

现在再来看模型的输出,维度数明显低了很多,因为在序列分类模型中,输出是模型头将 Transformer 网络输出的高维向量处理之后的结果。最终的输出向量只有两维,分别对应两个类别正向、负向。

print(outputs.logits.shape)
# 输出:
torch.Size([2, 2])

后处理

经过 Transformer 和模型头之后得到的输出本身没有实际意义。

print(outputs.logits)
# 输出:
tensor([[-1.5607,  1.6123],
        [ 4.1692, -3.3464]], grad_fn=<AddmmBackward>)

模型对于两个输入句子的输出结果分别是 [-1.5607, 1.6123][ 4.1692, -3.3464] ,现在的输出不是概率而是 logits 。我们需要使用 softmax 进行一步后处理,得到每个类别对应的概率。(所有的 transformer 模型输出的都是 logits,因为在计算损失函数(如交叉熵)时,会包含计算 softmax 的步骤。)

import torch

predictions = torch.nn.functional.softmax(outputs.logits, dim=-1)
print(predictions)

# 输出:
tensor([[4.0195e-02, 9.5980e-01],
        [9.9946e-01, 5.4418e-04]], grad_fn=<SoftmaxBackward>)

现在看到最终的输出 [0.0402, 0.9598][0.9995, 0.0005] 就已经是对应标签的概率值了。可以通过 id2label 属性来确定该模型配置中输出的每一维度所对应的标签。

print(model.config.id2label)

# 输出:
{
    
    0: 'NEGATIVE', 1: 'POSITIVE'}

最终我们得到模型的预测如下:

  • 句子一: 负向: 0.0402, 正向: 0.9598
  • 句子而: 负向: 0.9995, 正向: 0.0005

至此,我们已经成功复现了 pipeline 方法中的三个步骤:使用 tokenizer 进行前处理、将输入送入模型进行计算和后处理。下面我们会深入这些步骤的具体细节。

Models

本节将详细地讨论如何创建并使用一个模型。我们会用 AutoModel 类,它可以方便地通过权重文件来实例化一个模型。AutoModel 类及其所有相关类实际上是库中各种可用模型的简单封装。它可以自动猜测适合给定权重文件的模型架构,然后使用该架构实例化模型。但是,如果您知道要使用的模型架构,可以直接使用定义该架构的类。接下来以 BERT 为例进行介绍。

创建模型

首先需要初始化一个 BERT 模型并加载一个配置对象。

from transformers import BertConfig, BertModel

# Building the config
config = BertConfig()

# Building the model from the config
model = BertModel(config)

配置对象包含了模型的许多属性:

print(config)

# 输出:
BertConfig {
    
    
  [...]
  "hidden_size": 768,
  "intermediate_size": 3072,
  "max_position_embeddings": 512,
  "num_attention_heads": 12,
  "num_hidden_layers": 12,
  [...]
}

不同的加载方法

这种从默认配置中进行加载的方法会对模型权重进行随机初始化:

from transformers import BertConfig, BertModel

config = BertConfig()
model = BertModel(config)

# Model is randomly initialized!

此时模型可以正常使用,但它的输出是完全混乱的,它需要先训练。我们可以根据手头的任务从头开始训练模型,但正如在第 1 章中提到的,这将需要很长时间和大量数据,并且会对环境产生影响。为了避免不必要的重复工作,需要共享和复用已经预训练过的模型。

加载预训练的模型十分简单,使用 from_pretrained() 方法即可:

from transformers import BertModel

model = BertModel.from_pretrained("bert-base-cased")

在前面一节的介绍中,我们用的是 AutoModel 类,而非 BertModel 类。后面我们也将这样做,这会称为权重无关代码;即如果代码适用于一个权重,那么它也适用于加载其他权重文件。即使架构不同,只要权重针对类似任务(例如,情感分析任务)训练的,就适用。在上面的代码示例中,我们没有使用 BertConfig,而是通过 'bert-base-cased' 标识符加载了一个预训练模型。该权重由 BERT 原作者自己训练;可以在它的 model card 中查看更多细节。

现在该模型由指定权重文件初始化,它可以直接用于在本身的预训练任务上进行推理,也可以用于在新任务上进行微调。相比于从头训练,在预训练权重上进行微调能够更快地得到更好的结果。

模型权重默认缓存在 ~/.cache/huggingface/transformers 目录下,可以通过修改 HF_HOME 环境变量来更改缓存目录。

标识符(如上面的 'bert-base-cased' )可以用来指定加载 Model Hub 中的任何兼容 BERT 架构的模型。目前可用的 BERT 权重在这里查看。

保存方法

保存一个模型与加载模型同样简单,使用 save_pretrained() 方法:

model.save_pretrained("directory_on_my_computer")

这将在本地磁盘上保存两个文件:

ls directory_on_my_computer

config.json pytorch_model.bin

其中 config.json 文件中保存了构建模型必要的一些属性配置,还有一些元数据(metadata),比如保存该权重文件时的 transformers 库版本等。而 pytorch_model.bin 保存了模型的权重参数 。两份文件配合起来完整地保存了一个 Transformer 模型结构、权重的相关信息。

使用transformer模型进行推理

现在我们已经介绍过如何加载和保存模型。接下来将使用模型进行推理。前面提到过,Transformer 模型只能处理经 tokenizer 得到的数值输入,在详细介绍 tokenizer 之前,我们先来看一下模型接收的输入。

tokenizer 负责将输入文本转换为对应框架的张量,关于 tokenizer 后面会详细介绍,这里先快速看一下从原始文本到模型输入需要哪些步骤。

我们有一组原始文本序列:

sequences = ["Hello!", "Cool.", "Nice!"]

tokenizer 将这些序列转换为词表索引(通常称为 input IDs),每个序列是一组数字,输出如下:

encoded_sequences = [
    [101, 7592, 999, 102],
    [101, 4658, 1012, 102],
    [101, 3835, 999, 102],
]

这是一组序列(列表的列表),要将多维数组数据转换为 tensor 格式,该多维数组必须是矩形的形状(即每个维度的长度需要一致),这里刚好满足(不满足的话一般需要填充),可以直接转换为 tensor:

import torch

model_inputs = torch.tensor(encoded_sequences)

接下来就可以直接将 tensor 格式的数值数据输入给模型:

output = model(model_inputs)

模型接收许多参数,只有 input IDs 是必须得,其他参数将会再后面介绍。现在我们先来看一下 tokenizer 是如何将原始文本输入转换为数值输入的。

tokenizers

分词方式简介

tokenizers 是 NLP pipeline 中的一个关键组件,它的作用是:将文本转换为模型能够处理的数值型输入。本节将介绍 tokenization 的过程。

在 NLP 任务中,输入的是原始文本数据,如:

Jim Henson was a puppeteer

然而,模型只能处理数值型数据,所以我们需要 tokenizer 来将原始文本转换为数值型数据,有许多种分词方法能够实现这样的转换,核心的目标是找到最有意义的表示,即最方便模型理解的表示方法,然后,还要尽可能得小。以下将介绍几种常用的分词方法。

word-based

首先能够想到的就是基于词(word-based)的分词方法。它通常易于实现,并且效果也不错。如下图所示,按照 word-based 方法进行分词,并得到它们的数值表示:

在这里插入图片描述

word-based 分割原始文本也有不同的具体方法。比如最直接地按照空格来分词,是用 Python 中字符串的 split() 方法即可实现:

tokenized_text = "Jim Henson was a puppeteer".split()
print(tokenized_text)

# 输出:
['Jim', 'Henson', 'was', 'a', 'puppeteer']

也有一些加入标点的 word-based 分词方法的变体,通过这一类的 tokenizer,最终会得到一个相当大的词表,其中包含所有在语料库中出现过的 token。每个单词会被分配到一个 ID,ID 的值从 0 开始,一直到词表的总大小结束。模型使用这些 ID 来表示每一个单词。

如果想用 word-based 的 tokenizer 来完全覆盖一种语言,需要为语言中的每个单词都有一个 token,这将会有大量的 token。例如,英语中有超过 500000 个单词,要建立从每个单词到输入 ID 的映射,需要跟踪这么多ID。此外,像 “dog” 这样的词与 “dogs” 一样的词的表达方式不同,模型最初无法知道 “dog” 和 “dogs” 是相似的:它会将这两个词识别为不相关。这同样适用于其他类似的词,如 “run” 和 “running”,模型在学习开始前也不会将其视为相似。

另外,我们还要需要一个自定义标记来表示词汇表中没有的单词。这被称为 “unknown” token,通常表示为 “[UNK] ”或 “”。如果我们的 tokenizer 生成了大量的 unknown token,这通常是一个不好的迹象,因为这表明它无法检索一个单词的合理表示,并且在这一过程中丢失了信息。制定词表需要 tokenizer 产生尽可能少的 unknown token。

解决过多 unknown token 的方法是按照更深一层进行分词,即 character-based tokenizer。

character-based

character-based tokenizer 将文本划分为字符,这样做有两个显然的好处:

  • 词表会小得多
  • unknown token 会少得多,缓解 oov(out of vocabulary)问题,因为每个单词都是由英文字母组成

在这里插入图片描述

这种方法同样不完美,由于现在的表示是基于字符而不是单词,很明显每个字母是没有什么意义的。但这也因语言而异;例如,在汉语中,每个字符比拉丁语中的一个字符包含更多的信息。

另一个问题是,这种方法会需要大量的 token:一个单词使用 word-based 的 tokenizer 只需要一个 token,但当使用 character-based 方法时,则需要 10 个甚至更多 token,

为了两全其美,我们一般使用结合了这两种方法的技术:subword tokenization

subword tokenization

子单词标记化算法依赖于这样一个原则:频繁使用的单词不应该被拆分成更小的子单词,而少见的单词可以被分解成有意义的子词。

例如,“annoyingly” 可能被认为是一个少见的词,可以分解为 “annoying” 和“ly”。这两个词都可能更频繁地作为独立的子词出现,同时 “annoyingly” 的含义可以被 “annoying” 和 “ly” 的复合所表示。

下面是一个使用子词表示法进行分词的例子:

在这里插入图片描述

subword 分词法最终可以得到一个具有丰富语义的词表,比如上例中的 “tokenization” 被分为了 “token” 和 “ization”,这两个 token 都是有明确含义的。而且在空间上也比较高效,只需要两个 token 就可以组成一个长单词。

这种方法在土耳其语等语言中尤其有用,在这些语言中,通过将子词串在一起,可以形成(几乎)任意长的复杂单词。

这里再介绍几种具体的目前常用分词方法:

  • Byte-level BPE, GPT-2 的分词方式
  • WordPiece, BERT 的分词方式
  • SentencePiece和Unigram, 支持多语言模型

现在大家应该对常见分词方式有了一定的了解,接下来将介绍相关的 API。

保存和加载

保存和加载 tokenizers 与保存/加载 models 一样简单。还是使用 from_pretrained()save_pretrained() 两个方法。这两个方法会加载或保存 tokenizer 使用的算法(类似于保存模型的架构)和它生成的词表(类似于保存模型的权重参数)。

加载 BERT 的 tokenizer 与加载 BERT 权重的方式类似,只需要替换为 BertTokenizer 类就可以了:

from transformers import BertTokenizer

tokenizer = BertTokenizer.from_pretrained("bert-base-cased")

AutoModel 类似,AutoTokenizer 可以自动地选择库中合适的 tokenizer 类:

from transformers import AutoTokenizer

tokenizer = AutoTokenizer.from_pretrained("bert-base-cased")

现在可以像上一小节中一样使用 tokenizer 了:

tokenizer("Using a Transformer network is simple")

# 输出:
{
    
    'input_ids': [101, 7993, 170, 11303, 1200, 2443, 1110, 3014, 102],
 'token_type_ids': [0, 0, 0, 0, 0, 0, 0, 0, 0],
 'attention_mask': [1, 1, 1, 1, 1, 1, 1, 1, 1]}

保存 tokenizer 与保存 model 类似:

tokenizer.save_pretrained("directory_on_my_computer")

我们将在之后讨论 token_type_ids 和 attention_mask。现在,我们先来看看 input_ids 是如何生成的。为此,我们需要查看 tokenizer 的中间方法。

Encoding

将文本转换为数字的过程称为 encoding,它由两步组成:分词、将分词结果转换为输入 ID。

第一步就是将文本分割为单词(或者子词、标点等),即前面介绍的分词,得到 tokens。有多种规则可以实现该过程,这就是为什么需要使用模型的名称来实例化 tokenizer,是为了确保使用的规则与预训练模型时使用的规则相同。

第二步是将这些 tokens 转换为数字,然后可以将它们转换为张量并输入到模型中。tokenizer 有一张词表来记录 token 到数值的映射,在推理或微调时,需要保证与预训练时的词表是一致的。

接了更高的理解这两个步骤,我们将分别看一下两个步骤的过程。注意在实际使用中并不需要这样做,直接调用 tokenizer 并将你的输入传入即可。

分词

分词的过程由 tokenizer 的 tokenize() 方法完成:

from transformers import AutoTokenizer

tokenizer = AutoTokenizer.from_pretrained("bert-base-cased")

sequence = "Using a Transformer network is simple"
tokens = tokenizer.tokenize(sequence)

print(tokens)

# 输出:
['Using', 'a', 'transform', '##er', 'network', 'is', 'simple']

上例中的 tokenizer 是一个 subword tokenizer,它不断地将单词进行拆分,直至可以完全被词表中的 tokens 表示。例子中的 transformer 被拆分为 transform 和 ##er。

转换为数值

将分词结果转换为 input IDs 的步骤由 convert_tokens_to_ids() 方法来实现:

ids = tokenizer.convert_tokens_to_ids(tokens)

print(ids)
# 输出:
[7993, 170, 11303, 1200, 2443, 1110, 3014]

这些输出,在转换为对应框架的张量后,可以直接作为模型的输入。

Decoding

decoding 与 encoding 相反:根据词表索引,得到一个字符串。可以由 decode() 方法实现:

decoded_string = tokenizer.decode([7993, 170, 11303, 1200, 2443, 1110, 3014])
print(decoded_string)

# 输出:
'Using a Transformer network is simple'

decode() 方法不仅将索引转换回 tokens,还将属于同一单词的 token 合并在一起,生成可读的句子。

现在,已经介绍了 tokenizer 的各种原子操作:分词、转换为 ID,ID 转回字符串。然而,这只是冰山一角,接下来将介绍它的限制以及如何克服。

处理多序列

在上一节中,我们探讨了最简单的用例:对单个长度较小的序列进行推理。然而,实际中有很多新问题:

  • 如何处理多个序列?
  • 如何处理不同长度的多个序列?
  • 模型只能接收词表索引作为输入吗?
  • 是否存在序列过长的问题?

让我们看一下怎么使用 Transformer API 来处理这些问题。

批量输入模型

在之前已经介绍过如何将序列转换为数字列表。现在把这个数字列表转换为张量,并将其送入到模型:

import torch
from transformers import AutoTokenizer, AutoModelForSequenceClassification

checkpoint = "distilbert-base-uncased-finetuned-sst-2-english"
tokenizer = AutoTokenizer.from_pretrained(checkpoint)
model = AutoModelForSequenceClassification.from_pretrained(checkpoint)

sequence = "I've been waiting for a HuggingFace course my whole life."

tokens = tokenizer.tokenize(sequence)
ids = tokenizer.convert_tokens_to_ids(tokens)
input_ids = torch.tensor(ids)
# 这一行会报错
model(input_ids)

# 报错:
IndexError: Dimension out of range (expected to be in range of [-1, 0], but got 1)

为什么会报错呢?是因为我们这里将单个序列送入了模型,而 Transformer 模型默认接收多个序列。这里我们试图按照上节的介绍手动实现 tokenizer 的内部步骤,但发现行不通。实际上 tokenizer 不止是将文本转换为序列张量,而且还在张量上添加了一个维度。

tokenized_inputs = tokenizer(sequence, return_tensors="pt")
print(tokenized_inputs["input_ids"])
# 输出:
tensor([[  101,  1045,  1005,  2310,  2042,  3403,  2005,  1037, 17662, 12172,
          2607,  2026,  2878,  2166,  1012,   102]])

我们手动加一个维度再重新试一下:

import torch
from transformers import AutoTokenizer, AutoModelForSequenceClassification

checkpoint = "distilbert-base-uncased-finetuned-sst-2-english"
tokenizer = AutoTokenizer.from_pretrained(checkpoint)
model = AutoModelForSequenceClassification.from_pretrained(checkpoint)

sequence = "I've been waiting for a HuggingFace course my whole life."

tokens = tokenizer.tokenize(sequence)
ids = tokenizer.convert_tokens_to_ids(tokens)

input_ids = torch.tensor([ids])
print("Input IDs:", input_ids)

output = model(input_ids)
print("Logits:", output.logits)

# 输出:
Input IDs: [[ 1045,  1005,  2310,  2042,  3403,  2005,  1037, 17662, 12172,  2607, 2026,  2878,  2166,  1012]]
Logits: [[-2.7276,  2.8789]]

batching ,是指同时向模型输入多个句子。如果只有一个句子,也可以将它们打包起来:

batched_ids = [ids, ids]

这个 batch 包含两个相同的句子。

batching 使得模型在接收多个输入序列时能够正常工作。同时处理多个序列还有一个问题:当将多个序列打包在一起时,它们的长度可能会不同。而要转换为张量,必须是矩形的数组。我们通常采用填充来解决这个问题。

填充输入

下面这种非矩形的数组无法直接转换为张量

batched_ids = [
    [200, 200, 200],
    [200, 200]
]

为了解决这个问题,我们对输入进行填充,来使得它们有相同的长度,即得到矩形的数组。具体来说,我们在每个长度较短的序列后面添加一种特殊的 token:padding token。如果我们有 10 个 10 个 tokens 的句子和 1 个 20 个 tokens 的句子,就要通过填充将它们全部填充到 20 个 token。在上面的例子中,应该填充成下面这样:

padding_id = 100

batched_ids = [
    [200, 200, 200],
    [200, 200, padding_id],
]

padding token id 可以通过 tokenizer.pad_token_id 查看。

现在,我们将单个句子和两个组成 batch 的句子分别送入模型看一下结果是否相同:

model = AutoModelForSequenceClassification.from_pretrained(checkpoint)

sequence1_ids = [[200, 200, 200]]
sequence2_ids = [[200, 200]]
batched_ids = [
    [200, 200, 200],
    [200, 200, tokenizer.pad_token_id],
]

print(model(torch.tensor(sequence1_ids)).logits)
print(model(torch.tensor(sequence2_ids)).logits)
print(model(torch.tensor(batched_ids)).logits)

# 输出:
tensor([[ 1.5694, -1.3895]], grad_fn=<AddmmBackward>)
tensor([[ 0.5803, -0.4125]], grad_fn=<AddmmBackward>)
tensor([[ 1.5694, -1.3895],
        [ 1.3373, -1.2163]], grad_fn=<AddmmBackward>)

可以看到,出现了一些问题。我们期望的结果是将两个序列一同送入时每个句子的输出结果与单个句子分别送入时的输出结果一致,但是看第二个句子,输出结果明显是不一样的。

这是因为 Transformer 模型的自注意力机制会关注到序列中的每一个 token,这当然会将我们填充的 token 也计算在内。当输入不同长度的句子经过填充统一长度时,我们需要告诉模型每个句子的真实长度,即哪些是实际的数据 token,哪些是为了保持长度一致填充的 token。实际中我们会通过传入 attention masks 来实现这一点。

attention masks

attention masks 是与输入张量同样形状的一个张量,由 0 和 1 组成。1 表示对应的 token 是实际的数据 token 需要计算注意力,而 0 表示对应的 token 是填充的 token 不需要计算注意力。

上面的例子应该修改为这样:

batched_ids = [
    [200, 200, 200],
    [200, 200, tokenizer.pad_token_id],
]

attention_mask = [
    [1, 1, 1],
    [1, 1, 0],
]

outputs = model(torch.tensor(batched_ids), attention_mask=torch.tensor(attention_mask))
print(outputs.logits)

# 输出:
tensor([[ 1.5694, -1.3895],
        [ 0.5803, -0.4125]], grad_fn=<AddmmBackward>)

现在我们的第二个句子就得到了预期中的结果,

更长的序列

在 Transformer 模型中,我们可以传递给模型的序列长度是有限制的。大部分模型可以处理 最多512 或 1024 个 token 的序列,再长就无能为力了。如果需要处理更长的序列,有两种方案:

  • 使用一个支持更长序列的模型
  • 对输入序列进行截断

不同的模型能够处理的最长序列长度不同,有些模型擅长处理长序列。比如 LongformerLED 等。如果你的任务中都是非常长的序列,那么推荐使用这些模型,否则,推荐使用 max_sequence_length 对个别超出长度上限的序列进行截断。

sequence = sequence[:max_sequence_length]

整合到一起

在之前几节,我们已经分别介绍了 tokenizer 的几个步骤,包括分词、转换到 ID、填充、截断、注意力掩码。然而 Transformer API 可以通过一个高层的函数处理所有这些过程。当我们调用 tokenizer 并传入句子时,得到的返回值就是可以直接传入模型的输入。

from transformers import AutoTokenizer

checkpoint = "distilbert-base-uncased-finetuned-sst-2-english"
tokenizer = AutoTokenizer.from_pretrained(checkpoint)

sequence = "I've been waiting for a HuggingFace course my whole life."

model_inputs = tokenizer(sequence)

这里的 model_inputs 包含了所有模型所需要的输入。对于 distilBERT,这包括 input IDsattention mask 。对于其他模型,可能会有其他所需要的输入,同样会由 tokenizer 对象返回出来。

如下面例子所示,这个方法十分强大。首先,它可以处理单个序列:

sequence = "I've been waiting for a HuggingFace course my whole life."

model_inputs = tokenizer(sequence)

也可以同时处理多个序列,并且不需要改动调用方式:

sequences = ["I've been waiting for a HuggingFace course my whole life.", "So have I!"]

model_inputs = tokenizer(sequences)

可以支持多种 padding 方式:

# 将各序列填充到输入序列中的最大长度
model_inputs = tokenizer(sequences, padding="longest")

# 将序列填充到模型支持的最大长度(对于BERT或distilBERT,为512)
model_inputs = tokenizer(sequences, padding="max_length")

# 将序列填充到指定的最大长度
model_inputs = tokenizer(sequences, padding="max_length", max_length=8)

也可以截断序列:

sequences = ["I've been waiting for a HuggingFace course my whole life.", "So have I!"]

# 将序列截断到模型支持的最大长度(对于BERT或distilBERT,为512)
model_inputs = tokenizer(sequences, truncation=True)

# 将大于最大长度的序列进行截断
model_inputs = tokenizer(sequences, max_length=8, truncation=True)

tokenizer 还可以将数值转换到特定框架的张量类型,可以直接输入到模型中:

sequences = ["I've been waiting for a HuggingFace course my whole life.", "So have I!"]

# PyTorch
model_inputs = tokenizer(sequences, padding=True, return_tensors="pt")

# TensorFlow
model_inputs = tokenizer(sequences, padding=True, return_tensors="tf")

# NumPy
model_inputs = tokenizer(sequences, padding=True, return_tensors="np")

特殊token

查看 tokenizer 返回的 input IDs,可以看到与之前有些不同:

sequence = "I've been waiting for a HuggingFace course my whole life."

model_inputs = tokenizer(sequence)
print(model_inputs["input_ids"])

tokens = tokenizer.tokenize(sequence)
ids = tokenizer.convert_tokens_to_ids(tokens)
print(ids)

# 输出:
[101, 1045, 1005, 2310, 2042, 3403, 2005, 1037, 17662, 12172, 2607, 2026, 2878, 2166, 1012, 102]
[1045, 1005, 2310, 2042, 3403, 2005, 1037, 17662, 12172, 2607, 2026, 2878, 2166, 1012]

在序列的开始前和结束后分别添加了一个 token,我们可以对上述序列进行 decode:

print(tokenizer.decode(model_inputs["input_ids"]))
print(tokenizer.decode(ids))

# 输出:
"[CLS] i've been waiting for a huggingface course my whole life. [SEP]"
"i've been waiting for a huggingface course my whole life."

tokenizer 在开始前添加了一个特殊 token [CLS] ,在结束后添加了一个特殊 token [SEP] 。这是因为 BERT 模型在预训练时任务中需要这两个额外的特殊 token,所以为了在推理时保持结果一致,也需要添加。不同的模型可能会需要添加不同的特殊 token,有的模型只在后面加,有的只在前面加,有的不需要加。总之,tokenizer 都会知道需要怎么加,并帮你做好。

总结:从tokenizer到model

现在我们已经介绍完了 tokenizer 处理文本时的全部步骤,让我们最后看一下它是如何处理多序列(填充)、如何处理长序列(截断)以及如何返回不同框架的张量类型:

import torch
from transformers import AutoTokenizer, AutoModelForSequenceClassification

checkpoint = "distilbert-base-uncased-finetuned-sst-2-english"
tokenizer = AutoTokenizer.from_pretrained(checkpoint)
model = AutoModelForSequenceClassification.from_pretrained(checkpoint)
sequences = ["I've been waiting for a HuggingFace course my whole life.", "So have I!"]

tokens = tokenizer(sequences, padding=True, truncation=True, return_tensors="pt")
output = model(**tokens)

概括一下,在本章中,我们:

  • 学习了 Transformer 模型的基本构建块。
  • 了解了 tokenizer pipeline 的组成。
  • 了解如何在实践中使用 Transformer 模型。
  • 学习了如何利用标 tokenizer 将文本转换为模型可以理解的张量。
  • 同时设置 tokenizer 和 model,从文本到预测。
  • 学习了输入 ID 的限制,并学习了注意力掩码。
  • 使用多功能和可配置的 tokenizer 方法。

从现在起,你应该能够自由地浏览 Transformer 的文档:其中术语会听起来很熟悉,你已经看到了大多数时候会用到的方法。

猜你喜欢

转载自blog.csdn.net/weixin_44966641/article/details/127641585
今日推荐