nlp-with-transformers系列-02-从头构建文本分类器

1,104 阅读39分钟

文本分类

文本分类是 NLP 中最常见的任务之一, 它可用于广泛的应用或者开发成程序,例如将用户反馈文本标记为某种类别,或者根据客户文本语言自动归类。另外向我们平时见到的邮件垃圾过滤器也是文本分类最熟悉的应用场景之一。

另一种常见的文本分类类型是情感分析,它旨在识别给定文本的极性。 例如,像特斯拉这样的公司可能会分析类似下图中的 Twitter 帖子,以确定人们是否喜欢它的新车天窗。

假设你是一名数据科学算法工程师,现在需要构建一个系统来自动识别人们在 Twitter 上表达的关于你公司产品的情绪极性,例如“愤怒”或“快乐”。在本章中,我们将使用 DistilBERT 模型(BERT 变体)来解决这个任务。PS:[V. Sanh et al., "DistilBERT, a Distilled Version of BERT: Smaller, Faster, Cheaper and Lighter", (2019).]

这个模型的主要优点是它实现了与 BERT 相当的性能,同时占用内存更小、训练与推理更高效。这使我们能够在几分钟内训练一个分类器,如果你想训练一个更大的 BERT 模型,你可以简单地更改预训练模型的权重, checkpoint 对应于加载到给定Transformer模型中的权重集合。

这也将是我们第一次接触 Hugging Face 生态系统中的三个核心库:Datasets,Tokenizers和Transformers。如下图中所示,这些库将使我们能够快速从原始文本转换为可用于推断新推文的微调模型。因此,本着 Optimus Prime(擎天柱,《变形金刚》) 的精神,让我们深入其中,“改造并启动!”

Dataset

为了构建我们的推文情感分类器,我们将使用一篇论文中的一个非常高质量的数据集,该论文探讨了情绪如何在英语 Twitter 消息中表示。

PS:[E. Saravia 等人,“CARER: Contextualized Affect Representations for Emotion Recognition”,2018 年自然语言处理经验方法会议论文集(2018 年 10 月至 11 月):3687-3697,dx.doi.org/10.18653 /v1/D18-1404.] 。

与大多数仅涉及“正面”和“负面”极性的情绪分析数据集不同,该数据集包含六种基本情绪:愤怒、厌恶、恐惧、喜悦、悲伤和惊讶(anger, disgust, fear, joy, sadness, and surprise)。 给定一条推文,我们的任务将是训练一个可以将其分类为其中一种情绪的模型。

初识 Hugging Face Datasets

我们将使用datasets从 Hugging Face Hub 下载数据。 我们可以使用 list_datasets() 函数来查看 Hub 上有哪些数据集:

from datasets import list_datasets

all_datasets = list_datasets()
print(f"There are {len(all_datasets)} datasets currently available on the Hub")
print(f"The first 10 are: {all_datasets[:10]}")
There are 3480 datasets currently available on the Hub
The first 10 are: ['acronym_identification', 'ade_corpus_v2', 'adversarial_qa',
'aeslc', 'afrikaans_ner_corpus', 'ag_news', 'ai2_arc', 'air_dialogue',
'ajgt_twitter_ar', 'allegro_reviews']

我们看到每个数据集都有一个名称,所以让我们使用 load_dataset() 函数加载情绪emotion数据集:

# 查看我们本机电脑是否可以连接数据集地址,出于考虑,你能想到办法的
import requests                                                                                                                                                                                                         
requests.head("https://www.dropbox.com/s/1pzkadrvffbqw6o/train.txt?dl=1")
<Response [301]>
from datasets import load_dataset

emotions = load_dataset("emotion")
  0%|          | 0/3 [00:00<?, ?it/s]

If we look inside our emotions object:

emotions
DatasetDict({
    train: Dataset({
        features: ['text', 'label'],
        num_rows: 16000
    })
    validation: Dataset({
        features: ['text', 'label'],
        num_rows: 2000
    })
    test: Dataset({
        features: ['text', 'label'],
        num_rows: 2000
    })
})

我们看到它类似于 Python 字典,每个键值对应一个不同的数据集划分,我们可以按照字典查询的方式访问具体数据集,比如下面是获取训练集:

train_ds = emotions["train"]
train_ds
Dataset({
    features: ['text', 'label'],
    num_rows: 16000
})

它返回 Dataset 类的实例。 Dataset 对象是 Datasets 中的核心数据结构之一,我们将在本书的整个过程中都会使用它并且你会逐步掌握它许多特性。 对于初学者来说,它的作用就像一个普通的 Python 数组或列表,所以我们可以查看它的长度:

len(train_ds)
16000

或通过索引访问某个样本:

train_ds[0] # 训练集的的第一条数据
{'text': 'i didnt feel humiliated', 'label': 0}

在这里,我们看到一条数据格式为字典,其中键对应于列名:

train_ds.column_names
['text', 'label']
type(train_ds['label'])
list

我们可以看到数据是推文文本和情感标签,这表明了数据集基于 Apache Arrow 构建(Arrow定义了一种比原生 Python 内存效率更高的类型化列格式)。 我们可以通过访问 Dataset 对象的 features 属性来查看背后使用的数据类型:

print(train_ds.features)
{'text': Value(dtype='string', id=None), 'label': ClassLabel(num_classes=6,
names=['sadness', 'joy', 'love', 'anger', 'fear', 'surprise'], names_file=None,
id=None)}

在这种情况下,text列的数据类型是字符串,而label列是一个特殊的 ClassLabel 对象,其中包含有关类名及其到整数的映射的信息。 我们还可以使用切片访问多行:

print(train_ds[:5])
{'text': ['i didnt feel humiliated', 'i can go from feeling so hopeless to so
damned hopeful just from being around someone who cares and is awake', 'im
grabbing a minute to post i feel greedy wrong', 'i am ever feeling nostalgic
about the fireplace i will know that it is still on the property', 'i am feeling
grouchy'], 'label': [0, 0, 3, 2, 3]}

请注意,在这种情况下,字典值现在是列表而不是单个元素。我们还可以按名称获取完整列:

print(train_ds["text"][:5])
['i didnt feel humiliated', 'i can go from feeling so hopeless to so damned
hopeful just from being around someone who cares and is awake', 'im grabbing a
minute to post i feel greedy wrong', 'i am ever feeling nostalgic about the
fireplace i will know that it is still on the property', 'i am feeling grouchy']

现在我们已经了解了如何使用 数Datasets加载和检查数据,让我们对推文的内容进行一些完整性检查。

自定义Huggingface Datatsets

假如我们的数据集没有在Huggingface Datasets怎么办?

我们将使用 Hugging Face Hub 下载本书中大多数示例的数据集。 但在许多情况下,我们会经常遇到自己在处理存储在笔记本电脑或公司远程服务器上的数据的情况。 Datasets 提供了几个加载脚本来处理本地和远程数据集。 dataset-loading 中显示了最常见数据格式的示例。

[[dataset-loading]]
.How to load datasets in various formats
[options="header"]
|======
| Data format | Loading script | Example
| CSV | `csv` | `load_dataset("csv", data_files="my_file.csv")` 
| Text | `text` | `load_dataset("text", data_files="my_file.txt")` 
| JSON | `json` | `load_dataset("json", data_files="my_file.jsonl")`
|======

如我们所看见的,对于每种数据格式,我们只需将相关的加载脚本传递给 load_dataset() 函数,以及指定一个或多个文件的路径或 URL 的 data_files 参数。 例如,情感数据集的源文件实际上托管在 Dropbox 上,因此加载数据集的另一种方法是首先下载其中一个子集,比如我们可以把训练集下载到本地:

dataset_url = "https://www.dropbox.com/s/1pzkadrvffbqw6o/train.txt" # 没有wget命令,这里可以手动下载
!wget {dataset_url}
--2022-03-14 11:46:13--  https://www.dropbox.com/s/1pzkadrvffbqw6o/train.txt
Connecting to 127.0.0.1:7890... connected.
ERROR: cannot verify www.dropbox.com's certificate, issued by `/C=US/O=DigiCert Inc/OU=www.digicert.com/CN=DigiCert SHA2 High Assurance Server CA':
  Unable to locally verify the issuer's authority.
To connect to www.dropbox.com insecurely, use `--no-check-certificate'.
Unable to establish SSL connection.
!head -n 1 train.txt
'head' 不是内部或外部命令,也不是可运行的程序
或批处理文件。

我们可以看到这里没有列标题,每条推文和情感都用分号分隔。 尽管如此,这与 CSV 文件非常相似,因此我们可以通过使用 csv 脚本并将 data_files 参数指向 train.txt 文件来在本地加载数据集:

#hide_output
emotions_local = load_dataset("csv", data_files="data/train.txt", sep=";", 
                              names=["text", "label"])
  0%|          | 0/1 [00:00<?, ?it/s]

在这里,我们还指定了分隔符的类型和列的名称。更简单的方法是将 data_files 参数指向 URL 本身

#hide_output
dataset_url = "https://www.dropbox.com/s/1pzkadrvffbqw6o/train.txt?dl=1"
emotions_remote = load_dataset("csv", data_files=dataset_url, sep=";", 
                               names=["text", "label"])
  0%|          | 0/1 [00:00<?, ?it/s]

它将自动为您下载和缓存数据集。 如您所见,load_dataset() 函数用途广泛。 我们建议查看 Datastesdocumentation以获得完整的概述

将Datasets对象转为DataFrame

尽管Datasets 提供了许多基础功能来对我们的数据进行切片和切块,但将 Dataset 对象转换为 Pandas DataFrame 通常很方便,因此我们可以访问高级 用于数据可视化的级别 API。 为了启用转换 Datasets 提供了一个 set_format() 方法,允许我们更改 Dataset 的输出格式。 请注意,这不会更改底层数据格式(这是一个箭头表),如果需要,您可以稍后切换到另一种格式:

import pandas as pd

emotions.set_format(type="pandas")
df = emotions["train"][:]
df.head()
.dataframe tbody tr th:only-of-type { vertical-align: middle; } .dataframe tbody tr th { vertical-align: top; } .dataframe thead th { text-align: right; }
text label
0 i didnt feel humiliated 0
1 i can go from feeling so hopeless to so damned... 0
2 im grabbing a minute to post i feel greedy wrong 3
3 i am ever feeling nostalgic about the fireplac... 2
4 i am feeling grouchy 3

如上面所见,列名已被保留,前几行与我们之前的数据视图是一样的。 但是,label表示为整数,因此让我们使用标签功能的 int2str() 方法在 DataFrame 中创建一个具有相应标签名称的新列:

def label_int2str(row):
    return emotions["train"].features["label"].int2str(row)

df["label_name"] = df["label"].apply(label_int2str)
df.head()
.dataframe tbody tr th:only-of-type { vertical-align: middle; } .dataframe tbody tr th { vertical-align: top; } .dataframe thead th { text-align: right; }
text label label_name
0 i didnt feel humiliated 0 sadness
1 i can go from feeling so hopeless to so damned... 0 sadness
2 im grabbing a minute to post i feel greedy wrong 3 anger
3 i am ever feeling nostalgic about the fireplac... 2 love
4 i am feeling grouchy 3 anger

在深入构建分类器之前,让我们仔细看看数据集。正如 Andrej Karpathy 在他著名的博客文章"A Recipe for Training Neural Networks"中指出的那样,成为“与数据合一”是训练优秀模型的重要一步!

查看类别分布

每当您处理文本分类问题时,检查数据集中类别分布是第一个步骤,也是最好的习惯。 具有倾斜类分布的数据集在训练损失和评估指标方面可能需要与平衡数据集不同的处理。

使用 Pandas 和 Matplotlib,我们可以快速可视化类分布,如下所示:

import matplotlib.pyplot as plt

df["label_name"].value_counts(ascending=True).plot.barh()
plt.title("Frequency of Classes")
plt.show()

在这种情况下,我们可以看到数据集严重不平衡; joysadness类经常出现,而lovesurprise则少 5 到 10 倍。 有几种方法可以处理不平衡的数据,包括:

  • 随机过采样少数类。
  • 随机对多数类进行欠采样。
  • 从代表性不足的类别中收集更多标记数据。

为了在本章中保持简单,我们将使用原始的、不平衡的类频率。 如果你想了解有关这些采样技术的更多信息,我们建议你查看 Imbalanced-learn library。 只需确保在创建训练/测试拆分之前不应用采样方法,否则它们之间会出现大量泄漏!

既然我们已经看过类,让我们来看看推文本身。

我们的推文有多长?

Transformer 模型具有最大输入序列长度,称为最大上下文大小。 对于使用 DistilBERT 的预训练模型,最大上下文大小为 512 个标记,相当于几段文本。 正如我们将在下一节中看到的,token是一串基础文本; 现在,我们将一个token视为一个单词。 通过查看每条推文的单词分布,我们可以粗略估计每种情绪的推文长度:

df["Words Per Tweet"] = df["text"].str.split().apply(len) # 按空格切分,获取雷彪长度
df.boxplot("Words Per Tweet", by="label_name", grid=False, showfliers=False,
           color="black")
plt.suptitle("")
plt.xlabel("")
plt.show()

从图中我们看到,对于每种情绪,大多数推文的长度约为 15 个单词,最长的推文远低于 DistilBERT 的最大长度。 长于模型上下文大小的文本需要被截断,如果截断的文本包含关键信息,可能会导致性能下降;对于这个数据集下,看起来这不是问题。

现在让我们弄清楚如何将这些原始文本转换为适合Transformers的格式! 当我们这样做时,让我们也重置数据集的输出格式,因为我们不再需要 DataFrame 格式:

emotions.reset_format()

从文本到分词

像 DistilBERT 这样的 Transformer 模型不能接收原始字符串作为输入; 相反,他们假设文本已被 tokenizedencoded 作为数字向量。 分词(tokenizer)是将字符串分解为模型中使用的最基础单元的步骤。 可以采用多种标记化策略,通常从语料库中学习将单词优化为子单元的方法。 在查看用于 DistilBERT 的分词器之前,让我们考虑两种常见情况:characterword 分词。

Character Tokenization

最简单的标记化方案是将每个字符单独提供给模型。 在 Python 中,str 对象实际上是引擎盖下的数组,这使我们只需一行代码就可以快速实现字符级标记化:

text = "Tokenizing text is a core task of NLP."
tokenized_text = list(text)
print(tokenized_text)
['T', 'o', 'k', 'e', 'n', 'i', 'z', 'i', 'n', 'g', ' ', 't', 'e', 'x', 't', ' ','i', 's', ' ', 'a', ' ', 'c', 'o', 'r', 'e', ' ', 't', 'a', 's', 'k', ' ', 'o','f', ' ', 'N', 'L', 'P', '.']

这是一个好的开始,但我们还没有完成。 我们的模型期望将每个字符转换为整数,这个过程有时称为数值化。 一种简单的方法是使用唯一整数对每个唯一标记(在本例中为字符)进行编码:

token2idx = {ch: idx for idx, ch in enumerate(sorted(set(tokenized_text)))}
print(token2idx)
{' ': 0, '.': 1, 'L': 2, 'N': 3, 'P': 4, 'T': 5, 'a': 6, 'c': 7, 'e': 8, 'f': 9,
'g': 10, 'i': 11, 'k': 12, 'n': 13, 'o': 14, 'r': 15, 's': 16, 't': 17, 'x': 18,
'z': 19}

这为我们提供了从词汇表中的每个字符到唯一整数的映射。 我们现在可以使用 token2idx 将标记化的文本转换为整数列表:

input_ids = [token2idx[token] for token in tokenized_text]
print(input_ids)
[5, 14, 12, 8, 13, 11, 19, 11, 13, 10, 0, 17, 8, 18, 17, 0, 11, 16, 0, 6, 0, 7,
14, 15, 8, 0, 17, 6, 16, 12, 0, 14, 9, 0, 3, 2, 4, 1]

现在每个标记都被映射到一个唯一的数字标识符(因此名称为 input_ids)。 最后一步是将 input_ids 转换为 one-hot 向量的 2D 张量。 One-hot 向量在机器学习中经常用于对分类数据进行编码,这些数据可以是有序的也可以是名义的。 例如,假设我们想要对<变形金刚>电视剧中的角色名称进行编码。 一种方法是将每个名称映射到一个唯一的 ID,如下所示:

categorical_df = pd.DataFrame(
    {"Name": ["Bumblebee", "Optimus Prime", "Megatron"], "Label ID": [0,1,2]})
categorical_df
.dataframe tbody tr th:only-of-type { vertical-align: middle; } .dataframe tbody tr th { vertical-align: top; } .dataframe thead th { text-align: right; }
Name Label ID
0 Bumblebee 0
1 Optimus Prime 1
2 Megatron 2

这种方法的问题在于它在名称之间创建了一个虚构的顺序,而神经网络非常擅长学习这些类型的关系。 因此,我们可以为每个类别创建一个新列,并在类别为真时分配 1,否则分配 0。 在 Pandas 中,这可以通过 get_dummies() 函数实现,如下所示:

pd.get_dummies(categorical_df["Name"])
.dataframe tbody tr th:only-of-type { vertical-align: middle; } .dataframe tbody tr th { vertical-align: top; } .dataframe thead th { text-align: right; }
Bumblebee Megatron Optimus Prime
0 1 0 0
1 0 0 1
2 0 1 0

这个 DataFrame 的行是 one-hot 向量,它有一个“hot”数据,在其他任何地方都有 1 和 0。 现在,看看我们的 input_ids,我们有一个类似的问题:元素创建了一个序数比例。 这意味着添加或减去两个 ID 是无意义的操作,因为结果是代表另一个随机令牌的新 ID。

另一方面,添加两个单热编码的结果可以很容易地解释:两个“热”条目表示相应的标记同时出现。 我们可以通过将 input_ids 转换为张量并应用 one_hot() 函数在 PyTorch 中创建 one-hot 编码,如下所示:

import torch
import torch.nn.functional as F

input_ids = torch.tensor(input_ids)
one_hot_encodings = F.one_hot(input_ids, num_classes=len(token2idx))
one_hot_encodings.shape
torch.Size([38, 20])

对于 38 个输入标记中的每一个,我们现在都有一个 20 维的 one-hot 向量,因为我们的词汇表由 20 个唯一字符组成。

警告:始终在 one_hot() 函数中设置 num_classes 很重要,否则 one-hot 向量最终可能会比词汇表的长度短(并且需要手动填充零)。 在 TensorFlow 中,等效函数是 tf.one_hot(),其中 depth 参数扮演 num_classes 的角色。

通过检查第一个向量,我们可以验证 1 出现在 input_ids[0] 指示的位置:

print(f"Token: {tokenized_text[0]}")
print(f"Tensor index: {input_ids[0]}")
print(f"One-hot: {one_hot_encodings[0]}")
Token: T
Tensor index: 5
One-hot: tensor([0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0])

从我们的简单例子中,我们可以看到字符级标记化忽略了文本中的语义结构,并将整个字符串视为字符流。 尽管这有助于处理拼写错误和稀有单词,但主要缺点是需要从数据中学习单词等语言结构。 这需要大量的计算、内存和数据。 出于这个原因,字符标记化在实践中很少使用。 相反,在标记化步骤期间会保留文本的某些结构。 词标记化是实现这一目标的一种直接方法,所以让我们来看看它是如何工作的。

Word Tokenization

我们可以将文本拆分为单词并将每个单词映射为一个整数,而不是将文本拆分为字符。 从一开始就使用单词可以使模型跳过从字符中学习单词的步骤,从而降低训练过程的复杂性。

一种简单的分词方法就是使用空格来标记文本。 我们可以通过直接在原始文本上应用 Python 的split() 函数来做到这一点(就像我们测量推文长度一样):

tokenized_text = text.split()
print(tokenized_text)
['Tokenizing', 'text', 'is', 'a', 'core', 'task', 'of', 'NLP.']

从这里我们可以采取与字符标分词相同的步骤将每个单词映射到一个 ID。 但是,我们已经可以看到这种标记化方案的一个潜在问题:没有考虑标点符号,因此 NLP. 被视为单个标记。 鉴于单词可能包括偏角、变位或拼写错误,词汇量很容易增长到数百万!

注意:一些词标记器对标点符号有额外的规则。 也可以应用词干化或词形还原,将词标准化为词干(例如,“great”、“greater”和“greatest”都变成“great”),但会丢失文本中的一些信息。

拥有大量词汇是一个问题,因为它需要神经网络具有大量参数。 为了说明这一点,假设我们有 100 万个唯一的词,并且想要在我们的神经网络的第一层中将 100 万维输入向量压缩为 1000 维向量。 这是大多数 NLP 架构中的标准步骤,第一层的最终权重矩阵将包含 100 万乘以1000 = 10 亿个权重参数。 这已经可以与最大的 GPT-2 模型媲美了,ps:[GPT-2 是 GPT 的继承者,它以令人印象深刻的生成逼真文本的能力吸引了公众的注意力,我们将在文本生成章节中 中详细探讨 GPT-2,它总共有大约 15 亿个参数!]。

所以情理之中,我们希望避免在模型参数上如此浪费,因为模型的训练成本很高,而且更大的模型更难维护。 一种常见的方法是通过考虑语料库中最常见的 100,000 个词来限制词汇并丢弃稀有词。 不属于词汇表的单词被归类为“未知”并映射到共享的 UNK 标记。 这意味着我们在词标记化过程中丢失了一些潜在的重要信息,因为该模型没有关于与 UNK 相关的词的信息。

Subword Tokenization

Subword分词背后的基本思想是结合字符和词标记化的最佳应用。一方面,我们希望将稀有词拆分成更小的单元,以使模型能够处理复杂的词和拼写错误。另一方面,我们希望将常用词保留为唯一实体,以便我们可以将输入的长度保持在可管理的大小。Subword分词(以及词标记化)的主要区别特征是它是使用统计规则和算法的组合从预训练语料库中学习而来的。

NLP中常用的子词分词算法有几种,但我们先从WordPiece开始,[M. Schuster and K. Nakajima, "Japanese and Korean Voice Search," 2012 IEEE International Conference on Acoustics, Speech and Signal Processing (2012): 5149–5152, doi.org/10.1109/ICA….] 由 BERT 和 DistilBERT 分词器使用。了解 WordPiece 工作原理的最简单方法是查看它的实际运行情况。 Transformers 提供了一个方便的 AutoTokenizer 类,允许我们快速加载与预训练模型关联的标记器——我们只需调用它的 from_pretrained() 方法,提供 分词器的模型或本地文件路径。让我们从为 DistilBERT 加载分词器开始:

# 加载distilbert模型
from transformers import AutoTokenizer

model_ckpt = "distilbert-base-uncased"
tokenizer = AutoTokenizer.from_pretrained(model_ckpt)

AutoTokenizer 类属于一组更大的“自动”类,其工作是从checkpoint的名称中自动检索模型的配置、预训练的权重或词汇表。 这允许我们在模型之间灵活切换,但如果您希望手动加载特定类,您也可以这样做。 例如,我们可以按如下方式加载 DistilBERT 分词器:

from transformers import DistilBertTokenizer

distilbert_tokenizer = DistilBertTokenizer.from_pretrained(model_ckpt)
​```-->


> 注意:当我们第一次运行 `AutoTokenizer.from_pretrained()` 方法时,我们将看到一个进度条,显示从 Hugging Face Hub 加载的预训练标记器的哪些参数。 当你第二次运行代码时,它会从缓存中加载分词器,通常位于_~/.cache/huggingface/_,windows系统在我们用户目录下

让我们通过简单的“文本分词是 NLP 的核心任务”来检查这个分词模块是如何工作的。 示例文本:


​```python
encoded_text = tokenizer(text)
print(encoded_text)
{'input_ids': [101, 19204, 6026, 3793, 2003, 1037, 4563, 4708, 1997, 17953,
2361, 1012, 102], 'attention_mask': [1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1]}

就像我们在字符分词中看到的那样,我们可以看到单词已经映射到 input_ids 字段中的唯一整数。 我们将在下一节讨论 attention_mask 字段的作用。 现在我们有了 input_ids,我们可以使用分词器的 convert_ids_to_tokens() 方法将它们转换回原始字符:

tokens = tokenizer.convert_ids_to_tokens(encoded_text.input_ids)
print(tokens)
['[CLS]', 'token', '##izing', 'text', 'is', 'a', 'core', 'task', 'of', 'nl','##p', '.', '[SEP]']

我们可以在这里观察到三件事。 首先,在序列的开头和结尾添加了一些特殊的 [CLS] 和 [SEP] 标记。 这些toeken因模型而异,但它们的主要作用是指示序列的开始和结束。 其次,每个标记都被小写,这个是xxxxx-uncased模型权重。 最后,我们可以看到“tokenizing”和“NLP”被分成了两个token,这是有道理的,因为它们不是常用词。 ##izing 和##p 中的## 前缀表示前面的字符串不是空格; 当您将标记转换回字符串时,任何具有此前缀的标记都应与前一个标记合并。 AutoTokenizer 类有一个 convert_tokens_to_string() 方法可以做到这一点,所以让我们将它应用于我们的令牌:

print(tokenizer.convert_tokens_to_string(tokens))
[CLS] tokenizing text is a core task of nlp. [SEP]

AutoTokenizer 类还具有几个提供有关分词方法信息的属性。 例如,我们可以检查词汇量:

tokenizer.vocab_size
30522

以及相应模型的最大上下文最大长度:

tokenizer.model_max_length
512

另一个比较有趣的属性是模型在其前向传递中需要输入的字段名称:

tokenizer.model_input_names
['input_ids', 'attention_mask']

现在我们对单个字符串的分词过程有了基本的了解,让我们看看如何标记整个数据集!

警告:使用预训练模型时,确保使用与训练模型相同的分词器(tokenizer)非常重要。 从模型的角度来看,切换分词器就像打乱词汇表一样。 如果您周围的每个人都开始将“房子”之类的随机词替换为“猫”,那么你也很难理解发生了什么!

对整个数据集进行分词

为了标记整个语料库,我们将使用 DatasetDict 对象的map()方法。 我们将在本书中多次遇到这种方法,因为它提供了一种将处理函数应用于数据集中每个元素的便捷方法。 我们很快就会看到map() 方法也可以用来创建新的行和列。

首先,我们需要一个处理函数来分词我们的文本:

def tokenize(batch):
    return tokenizer(batch["text"], padding=True, truncation=True)

该函数将分词器应用于一批文本数据; padding=True 会将示例用零填充到批次中最长的大小,而 truncation=True 会将示例截断为模型的最大上下文大小。 要查看 tokenize() 的实际效果,让我们从训练集中传递含有两条数据的batch:

print(tokenize(emotions["train"][:2]))
{'input_ids': [[101, 1045, 2134, 2102, 2514, 26608, 102, 0, 0, 0, 0, 0, 0, 0, 0,
0, 0, 0, 0, 0, 0, 0, 0], [101, 1045, 2064, 2175, 2013, 3110, 2061, 20625, 2000,
2061, 9636, 17772, 2074, 2013, 2108, 2105, 2619, 2040, 14977, 1998, 2003, 8300,
102]], 'attention_mask': [[1, 1, 1, 1, 1, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 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]]}

在这里我们可以看到填充的结果:input_ids 的第一个元素比第二个短,所以在那个元素上添加了零以使它们的长度相同。 这些零在词汇表中有一个对应的 [PAD] 标记,而特殊标记的集合还包括我们之前遇到的 [CLS][SEP] 标记:

tokens2ids = list(zip(tokenizer.all_special_tokens, tokenizer.all_special_ids))
data = sorted(tokens2ids, key=lambda x : x[-1])
df = pd.DataFrame(data, columns=["Special Token", "Special Token ID"])
df.T
.dataframe tbody tr th:only-of-type { vertical-align: middle; } .dataframe tbody tr th { vertical-align: top; } .dataframe thead th { text-align: right; }
0 1 2 3 4
Special Token [PAD] [UNK] [CLS] [SEP] [MASK]
Special Token ID 0 100 101 102 103

另请注意,除了将编码的推文作为“input_ids”返回之外,标记器还返回一个“attention_mask”数组的列表。 这是因为我们不希望模型被额外的填充标记混淆:注意掩码允许模型忽略输入的填充部分。 下图a提供了如何填充输入 ID 和attention-mask的可视化解释。

一旦我们定义了一个处理函数,我们就可以在一行代码中将它应用于语料库中的所有拆分:

# hide_output
emotions_encoded = emotions.map(tokenize, batched=True, batch_size=None)

默认情况下,map() 方法对语料库中的每个示例单独运行,因此设置 batched=True 将对推文进行批量编码。 因为我们设置了 batch_size=None,所以我们的 tokenize() 函数将作为单个批次应用于整个数据集。 这确保了输入张量和注意力掩码在全局范围内具有相同的形状,我们可以看到这个操作在数据集中添加了新的 input_idsattention_mask 列:

print(emotions_encoded["train"].column_names)
['attention_mask', 'input_ids', 'label', 'text']

默认情况下,map() 方法对语料库中的每个示例单独运行,因此设置 batched=True 将对推文进行批量编码。 因为我们设置了 batch_size=None,所以我们的 tokenize() 函数将作为单个批次应用于整个数据集。 这确保了输入张量和注意力掩码在全局范围内具有相同的形状,我们可以看到这个操作在数据集中添加了新的 input_idsattention_mask 列:

训练一个分类器

如 第一章transformers简介中所讨论的,像 DistilBERT 这样的模型经过预训练来预测文本序列中的掩码单词。 但是,我们不能直接使用这些语言模型进行文本分类; 我们需要稍微修改它们。 为了理解哪些修改是必要的,让我们看一下像 DistilBERT 这样的基于编码器的模型的架构,它在 encoder-classifier中有所描述。

首先,文本被标记化并表示为称为_token encodings_的单热向量。 标记器词汇的大小决定了标记编码的维度,它通常由 20k-200k 个唯一标记组成。 接下来,这些令牌编码被转换为 token embeddings,它们是生活在低维空间中的向量。 然后,令牌嵌入通过编码器块层传递,为每个输入令牌生成一个“隐藏状态”。 对于语言建模的预训练目标,⁠脚注:[在 DistilBERT 的情况下,它是在猜测被屏蔽的标记。]每个隐藏状态都被馈送到一个预测被屏蔽输入标记的层。 对于分类任务,我们将语言建模层替换为分类层。

注意:在实践中,PyTorch 跳过了为令牌编码创建 one-hot 向量的步骤,因为将矩阵与 one-hot 向量相乘与从矩阵中选择一列相同。 这可以通过从矩阵中获取具有标记 ID 的列来直接完成。 当我们使用 nn.Embedding 类时,我们将在 transformers解剖中看到这一点。

我们有两种选择可以在 Twitter 数据集上训练这样的模型:

  • 特征提取:: 我们使用隐藏状态作为特征,只在它们上训练一个分类器,而不修改预训练模型。
  • Fine-tuning:: 我们端到端训练整个模型,这也更新了预训练模型的参数。

在以下部分中,我们将探讨 DistilBERT 的两个选项并检查它们的权衡。

Transformers作为特征提取工具

使用Transformers作为特征提取工具作为特征提取器相当简单。 如下图所示,我们在训练期间冻结身体的权重,并使用隐藏状态作为分类器的特征。 这种方法的优点是我们可以快速训练一个小的或浅的模型。 这样的模型可以是神经分类层或不依赖梯度的方法,例如随机森林。 如果 GPU 不可用,这种方法特别方便,因为隐藏状态只需要预先计算一次。

使用预训练模型

我们将使用 Transformers 中另一个方便的自动类,称为“AutoModel”。 类似于 AutoTokenizer 类,AutoModel 有一个 from_pretrained() 方法来加载预训练模型的权重。 让我们使用这个方法来加载 DistilBERT 检查点:

# hide_output
from transformers import AutoModel

model_ckpt = "distilbert-base-uncased"
device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
model = AutoModel.from_pretrained(model_ckpt).to(device)

在这里,我们使用 PyTorch 检查 GPU 是否可用,然后将 PyTorch nn.Module.to() 方法链接到模型加载器。 如果我们有一个,这可以确保模型将在 GPU 上运行。 如果没有,模型将在 CPU 上运行,这可能会慢很多。

Pytorch与TensorFlow框架切换

尽管本书中的代码大部分是用 PyTorch 编写的,但 Transformers 提供了与 TensorFlow 和 JAX 的紧密互操作性。 这意味着您只需更改几行代码即可在您最喜欢的深度学习框架中加载预训练模型! 例如,我们可以使用 TFAutoModel 类在 TensorFlow 中加载 DistilBERT,如下所示:

# from transformers import TFAutoModel

# tf_model = TFAutoModel.from_pretrained(model_ckpt)

当模型仅在一个框架中发布但您想在另一个框架中使用它时,这种互操作性特别有用。 例如,我们将在 ner章节中遇到的 XLM-RoBERTa 模型 只有 PyTorch 权重,所以如果你尝试在 TensorFlow 中加载它 就像我们之前做的那样:

tf_xlmr = TFAutoModel.from_pretrained("xlm-roberta-base")

你会得到一个错误。 在这些情况下,您可以为 TfAutoModel.from_pretrained() 函数指定一个 from_pt=True 参数,该库将自动为您下载并转换 PyTorch 权重:

# tf_xlmr = TFAutoModel.from_pretrained("xlm-roberta-base", from_pt=True)

如您所见,在Transformers 中的框架之间切换非常简单! 在大多数情况下,您只需在类中添加“TF”前缀,您将获得等效的 TensorFlow 2.0 类。 当我们使用 PyTorch 的缩写 "pt" 字符串(例如,在下一节中)时,只需将其替换为 TensorFlow 的缩写 "tf"

提取最后的隐藏状态

为了热身,让我们检索单个字符串的最后隐藏状态。 我们需要做的第一件事是对字符串进行编码并将标记转换为 PyTorch 张量。 这可以通过向标记器提供 return_tensors="pt" 参数来完成,如下所示:

text = "this is a test"
inputs = tokenizer(text, return_tensors="pt")
print(f"Input tensor shape: {inputs['input_ids'].size()}")
Input tensor shape: torch.Size([1, 6])

正如我们所见,生成的张量具有“[batch_size, n_tokens]”的形状。 现在我们将编码作为张量,最后一步是将它们放在与模型相同的设备上,并按如下方式传递输入:

inputs = {k:v.to(device) for k,v in inputs.items()}
with torch.no_grad():
    outputs = model(**inputs)
print(outputs)
BaseModelOutput(last_hidden_state=tensor([[[-0.1565, -0.1863,  0.0527,  ...,-0.1187,  0.0663,  0.5469],
         [-0.3571, -0.6483, -0.0621,  ..., -0.3037,  0.3509,  0.5223],
         [-0.2769, -0.4459,  0.1816,  ..., -0.0949, -0.0077,  0.9957],
         [-0.2835, -0.3918,  0.3750,  ..., -0.2150, -0.1170,  1.0523],
         [ 0.2663, -0.5093, -0.3182,  ..., -0.4205,  0.0144, -0.2148],
         [ 0.9442,  0.0113, -0.4717,  ...,  0.1439, -0.7286, -0.1616]]],
       device='cuda:0'), hidden_states=None, attentions=None)

在这里,我们使用了 torch.no_grad() 上下文管理器来禁用梯度的自动计算。 这对于推理很有用,因为它减少了计算的内存占用。 根据模型配置,输出可以包含多个对象,例如隐藏状态、损失或注意,它们排列在类似于 Python 中的“命名元组”的类中。 在我们的示例中,模型输出是“BaseModelOutput”的一个实例,我们可以简单地通过名称访问它的属性。 当前模型只返回一个属性,也就是最后一个隐藏状态,所以让我们检查一下它的形状:

outputs.last_hidden_state.size()
torch.Size([1, 6, 768])

查看隐藏状态张量,我们看到它的形状为“[batch_size, n_tokens, hidden_dim]”。 换句话说,为 6 个输入标记中的每一个返回一个 768 维向量。 对于分类任务,通常的做法是仅使用与“[CLS]”标记关联的隐藏状态作为输入特征。 由于这个标记出现在每个序列的开头,我们可以通过简单地索引到 outputs.last_hidden_state 来提取它,如下所示:

outputs.last_hidden_state[:,0].size()
torch.Size([1, 768])

查看隐藏状态张量,我们看到它的形状为“[batch_size, n_tokens, hidden_dim]”。 换句话说,为 6 个输入标记中的每一个返回一个 768 维向量。 对于分类任务,通常的做法是仅使用与“[CLS]”标记关联的隐藏状态作为输入特征。 由于这个标记出现在每个序列的开头,我们可以通过简单地索引到 outputs.last_hidden_state 来提取它,如下所示:

def extract_hidden_states(batch):
    # Place model inputs on the GPU
    inputs = {k:v.to(device) for k,v in batch.items() 
              if k in tokenizer.model_input_names}
    # Extract last hidden states
    with torch.no_grad():
        last_hidden_state = model(**inputs).last_hidden_state
    # Return vector for [CLS] token
    return {"hidden_state": last_hidden_state[:,0].cpu().numpy()}

这个函数和我们之前的逻辑的唯一区别是最后一步,我们将最终的隐藏状态作为 NumPy 数组放回 CPU。 当我们使用批处理输入时,map() 方法需要处理函数返回 Python 或 NumPy 对象。

由于我们的模型需要张量作为输入,接下来要做的是将 input_idsattention_mask 列转换为 "torch" 格式,如下所示:

emotions_encoded.set_format("torch", 
                            columns=["input_ids", "attention_mask", "label"])

然后我们可以继续并一次性提取所有拆分中的隐藏状态:

#hide_output
emotions_hidden = emotions_encoded.map(extract_hidden_states, batched=True)
  0%|          | 0/16 [00:00<?, ?ba/s]



  0%|          | 0/2 [00:00<?, ?ba/s]



  0%|          | 0/2 [00:00<?, ?ba/s]

请注意,在这种情况下,我们没有设置 batch_size=None,因此使用默认的 batch_size=1000。 正如预期的那样,应用 +extract_​hid⁠den_​states()+ 函数已向我们的数据集添加了一个新的 hidden_state 列:

emotions_hidden["train"].column_names
['attention_mask', 'hidden_state', 'input_ids', 'label', 'text']

现在我们已经有了与每条推文相关的隐藏状态向量,下一步是在它们上训练一个分类器。 为此,我们需要一个特征矩阵——让我们来看看。

创建特征矩阵

预处理的数据集现在包含我们训练分类器所需的所有信息。 我们将使用隐藏状态作为输入特征,使用标签作为目标。 我们可以很容易地以众所周知的 Scikit-Learn 格式创建相应的数组,如下所示:

import numpy as np

X_train = np.array(emotions_hidden["train"]["hidden_state"])
X_valid = np.array(emotions_hidden["validation"]["hidden_state"])
y_train = np.array(emotions_hidden["train"]["label"])
y_valid = np.array(emotions_hidden["validation"]["label"])
X_train.shape, X_valid.shape
((16000, 768), (2000, 768))

在我们在隐藏状态上训练模型之前,最好进行一次健全性检查,以确保它们为我们想要分类的情绪提供有用的表示。 在下一节中,我们将看到可视化功能如何提供实现此目的的快速方法。

可视化训练集

由于在 768 维中可视化隐藏状态至少可以说是很棘手,我们将使用强大的 UMAPfootnote:[L. McInnes、J. Healy 和 J. Melville,“UMAP:统一流形逼近和降维投影”,(2018)。] 向下投影向量的算法 为 2D。 由于 UMAP 在将特征缩放到 [0,1] 区间时效果最佳,因此我们将首先应用“MinMaxScaler”,然后使用“umap-learn”库中的 UMAP 实现来减少隐藏状态:

from umap import UMAP
from sklearn.preprocessing import MinMaxScaler

# Scale features to [0,1] range
X_scaled = MinMaxScaler().fit_transform(X_train)
# Initialize and fit UMAP
mapper = UMAP(n_components=2, metric="cosine").fit(X_scaled)
# Create a DataFrame of 2D embeddings
df_emb = pd.DataFrame(mapper.embedding_, columns=["X", "Y"])
df_emb["label"] = y_train
df_emb.head()
.dataframe tbody tr th:only-of-type { vertical-align: middle; } .dataframe tbody tr th { vertical-align: top; } .dataframe thead th { text-align: right; }
X Y label
0 4.340796 6.294675 0
1 -2.886896 5.534088 0
2 5.104525 2.727540 3
3 -2.622140 2.926228 2
4 -3.205107 3.488102 3

结果是一个具有相同数量训练样本的数组,但只有 2 个特征,而不是我们开始时的 768 个! 让我们进一步研究压缩数据并分别绘制每个类别的点密度:

fig, axes = plt.subplots(2, 3, figsize=(7,5))
axes = axes.flatten()
cmaps = ["Greys", "Blues", "Oranges", "Reds", "Purples", "Greens"]
labels = emotions["train"].features["label"].names

for i, (label, cmap) in enumerate(zip(labels, cmaps)):
    df_emb_sub = df_emb.query(f"label == {i}")
    axes[i].hexbin(df_emb_sub["X"], df_emb_sub["Y"], cmap=cmap,
                   gridsize=20, linewidths=(0,))
    axes[i].set_title(label)
    axes[i].set_xticks([]), axes[i].set_yticks([])

plt.tight_layout()
plt.show()


注意:这些只是对低维空间的投影。 仅仅因为某些类别重叠并不意味着它们在原始空间中不可分离。 相反,如果它们在投影空间中是可分离的,那么它们在原始空间中也是可分离的。

从这个图中我们可以看到一些清晰的模式:“sadness”、“anger”和“fear”等负面情绪都占据相似的区域,分布略有不同。 另一方面,“joy”和“love”与负面情绪很好地分开,也共享一个相似的空间。 最后,“surprise”散落一地。 尽管我们可能希望有一些分离,但这并不能保证,因为模型没有经过训练来了解这些情绪之间的差异。 它只是通过猜测文本中的蒙面词来隐式地学习它们。

现在我们已经对数据集的特征有了一些了解,让我们最终训练一个模型吧!

训练一个简单的分类器

我们已经看到情绪之间的隐藏状态有些不同,尽管其中一些情绪没有明显的界限。 让我们使用这些隐藏状态来使用 Scikit-Learn 训练逻辑回归模型。 训练这样一个简单的模型很快并且不需要 GPU:

#hide_output
# We increase `max_iter` to guarantee convergence 
from sklearn.linear_model import LogisticRegression

lr_clf = LogisticRegression(max_iter=3000)
lr_clf.fit(X_train, y_train)
LogisticRegression(max_iter=3000)
lr_clf.score(X_valid, y_valid)
0.6335

从准确性来看,我们的模型似乎比随机模型好一点——但由于我们正在处理一个不平衡的多类数据集,它实际上要好得多。 我们可以通过将它与一个简单的基线进行比较来检查我们的模型是否有任何好处。 在 Scikit-Learn 中有一个 DummyClassifier 可用于构建具有简单启发式的分类器,例如始终选择多数类或始终绘制随机类。 在这种情况下,表现最好的启发式方法是始终选择最频繁的类,这会产生大约 35% 的准确度:

from sklearn.dummy import DummyClassifier

dummy_clf = DummyClassifier(strategy="most_frequent")
dummy_clf.fit(X_train, y_train)
dummy_clf.score(X_valid, y_valid)
0.352

因此,我们的带有 DistilBERT 嵌入的简单分类器明显优于我们的基线。 我们可以通过查看分类器的混淆矩阵来进一步研究模型的性能,它告诉我们真实标签和预测标签之间的关系:

from sklearn.metrics import ConfusionMatrixDisplay, confusion_matrix

def plot_confusion_matrix(y_preds, y_true, labels):
    cm = confusion_matrix(y_true, y_preds, normalize="true")
    fig, ax = plt.subplots(figsize=(6, 6))
    disp = ConfusionMatrixDisplay(confusion_matrix=cm, display_labels=labels)
    disp.plot(cmap="Blues", values_format=".2f", ax=ax, colorbar=False)
    plt.title("Normalized confusion matrix")
    plt.show()
    
y_preds = lr_clf.predict(X_valid)
plot_confusion_matrix(y_preds, y_valid, labels)

我们可以看到,“愤怒”和“恐惧”最常与“悲伤”混淆,这与我们在可视化嵌入时所做的观察一致。 此外,“love”和“surprise”经常被误认为“joy”。

在下一节中,我们将探讨微调方法,它可以带来出色的分类性能。 但是,重要的是要注意,这样做需要更多的计算资源,例如 GPU,而这些资源在您的组织中可能不可用。 在这种情况下,基于特征的方法可以是传统机器学习和深度学习之间的一个很好的折衷方案。

Transformers微调

现在让我们探讨一下如何对变压器进行端到端微调。 通过微调方法,我们不使用隐藏状态作为固定特征,而是按照 <> 中所示的方式训练它们。 这就要求分类头是可微的,这就是为什么这种方法通常使用神经网络进行分类的原因。

训练作为分类模型输入的隐藏状态将帮助我们避免处理可能不太适合分类任务的数据的问题。 相反,初始隐藏状态会在训练期间进行调整以减少模型损失,从而提高其性能。

我们将使用 Transformers 中的 Trainer API 来简化训练循环。 让我们看看我们需要设置的成分!

加载预训练模型

我们需要的第一件事是一个预训练的 DistilBERT 模型,就像我们在基于特征的方法中使用的模型一样。 唯一的细微修改是我们使用 AutoModelForSequenceClassification 模型而不是 AutoModel。 不同之处在于,“AutoModelForSequenceClassification”模型在预训练模型输出之上有一个分类头,可以使用基本模型轻松训练。 我们只需要指定模型必须预测多少个标签(在我们的例子中是六个),因为这决定了分类头的输出数量:

# hide_output
from transformers import AutoModelForSequenceClassification

num_labels = 6
model = (AutoModelForSequenceClassification
         .from_pretrained(model_ckpt, num_labels=num_labels)
         .to(device))

您将看到模型的某些部分被随机初始化的警告。 这是正常的,因为尚未训练分类头。 下一步是定义我们将在微调期间用于评估模型性能的指标。

定义性能指标

为了在训练期间监控指标,我们需要为“Trainer”定义一个“compute_metrics()”函数。 该函数接收一个“EvalPrediction”对象(它是一个具有“predictions”和“label_ids”属性的命名元组),并需要返回一个字典,将每个指标的名称映射到它的值。 对于我们的应用程序,我们将计算 F1F_1-score 和模型的准确性,如下所示:

from sklearn.metrics import accuracy_score, f1_score

def compute_metrics(pred):
    labels = pred.label_ids
    preds = pred.predictions.argmax(-1)
    f1 = f1_score(labels, preds, average="weighted")
    acc = accuracy_score(labels, preds)
    return {"accuracy": acc, "f1": f1}

准备好数据集和指标后,在定义“Trainer”类之前,我们只需要处理最后两件事:

  1. 在 Hugging Face Hub 上登录我们的帐户。 这将使我们能够将微调后的模型推送到我们在 Hub 上的帐户并与社区共享。
  2. 定义训练运行的所有超参数。

我们将在下一节中处理这些步骤。

训练模型

如果您在 Jupyter 笔记本中运行此代码,则可以使用以下帮助函数登录到 Hub:

from huggingface_hub import notebook_login

notebook_login()
VBox(children=(HTML(value='<center>\n<img src=https://huggingface.co/front/assets/huggingface_logo-noborder.sv…

这将显示一个小部件,您可以在其中输入您的用户名和密码,或具有写入权限的访问令牌。 您可以在 Hub 文档 中找到有关如何创建访问令牌的详细信息。 如果您在终端中工作,则可以通过运行以下命令登录:

$ huggingface-cli login

要定义训练参数,我们使用“TrainingArguments”类。 这个类存储了很多信息,让你可以对训练和评估进行细粒度的控制。 要指定的最重要的参数是 output_dir,它是存储所有训练工件的地方。 下面是一个完整的 TrainingArguments 示例:

from transformers import Trainer, TrainingArguments

batch_size = 64
logging_steps = len(emotions_encoded["train"]) // batch_size
model_name = f"{model_ckpt}-finetuned-emotion"
training_args = TrainingArguments(output_dir=model_name,
                                  num_train_epochs=2,
                                  learning_rate=2e-5,
                                  per_device_train_batch_size=batch_size,
                                  per_device_eval_batch_size=batch_size,
                                  weight_decay=0.01,
                                  evaluation_strategy="epoch",
                                  disable_tqdm=False,
                                  logging_steps=logging_steps,
                                  push_to_hub=True, 
                                  log_level="error")

在这里,我们还设置了批量大小、学习率和 epoch 数,并指定在训练运行结束时加载最佳模型。 有了这个最终成分,我们可以使用“Trainer”实例化和微调我们的模型:

from transformers import Trainer

trainer = Trainer(model=model, args=training_args, 
                  compute_metrics=compute_metrics,
                  train_dataset=emotions_encoded["train"],
                  eval_dataset=emotions_encoded["validation"],
                  tokenizer=tokenizer)
trainer.train();
F:\Projects\nlp-with-transformers\notebooks\distilbert-base-uncased-finetuned-emotion is already a clone of https://huggingface.co/quincyqiang/distilbert-base-uncased-finetuned-emotion. Make sure you pull the latest changes with `repo.git_pull()`.
Failed to detect the name of this notebook, you can set it manually with the WANDB_NOTEBOOK_NAME environment variable to enable code saving.
wandb: Currently logged in as: quincyqiang (use `wandb login --relogin` to force relogin)
wandb: wandb version 0.12.11 is available!  To upgrade, please run:
wandb:  $ pip install wandb --upgrade

Syncing run distilbert-base-uncased-finetuned-emotion to Weights & Biases (docs).

<div>

  <progress value='500' max='500' style='width:300px; height:20px; vertical-align: middle;'></progress>
  [500/500 01:11, Epoch 2/2]
</div>
<table border="1" class="dataframe">
Epoch Training Loss Validation Loss Accuracy F1 1 0.800700 0.295522 0.914000 0.911742 2 0.241700 0.210593 0.927000 0.927274

查看日志,我们可以看到我们的模型在验证集上的 F1F_1-score 约为 92% - 这是对基于特征的方法的显着改进!

我们可以通过计算混淆矩阵来更详细地查看训练指标。 为了可视化混淆矩阵,我们首先需要获得对验证集的预测。 Trainer 类的 predict() 方法返回几个我们可以用于评估的有用对象:

# hide_output
preds_output = trainer.predict(emotions_encoded["validation"])

[32/32 00:01]

predict() 方法的输出是一个 PredictionOutput 对象,其中包含 predictionslabel_ids 数组,以及我们传递给训练器的指标。 例如,验证集上的指标可以按如下方式访问:

preds_output.metrics
{'test_loss': 0.21059347689151764,
 'test_accuracy': 0.927,
 'test_f1': 0.927273630943427,
 'test_runtime': 1.2807,
 'test_samples_per_second': 1561.68,
 'test_steps_per_second': 24.987}

它还包含每个类的原始预测。 我们可以使用 np.argmax() 贪婪地解码预测。 这会产生预测的标签,并且与 Scikit-Learn 模型在基于特征的方法中返回的标签具有相同的格式:

y_preds = np.argmax(preds_output.predictions, axis=1)

通过预测,我们可以再次绘制混淆矩阵:

plot_confusion_matrix(y_preds, y_valid, labels)

这更接近于理想的对角混淆矩阵。 love 类别仍然经常与 joy 混淆,这似乎很自然。 surprise 也经常被误认为 joy,或与 fear 混淆。 总体而言,该模型的性能似乎相当不错,但在我们结束之前,让我们更深入地了解一下我们的模型可能会犯的错误类型。

使用Keras进行微调

如果您使用 TensorFlow,也可以使用 Keras API 微调您的模型。 与 PyTorch API 的主要区别在于没有 Trainer 类,因为 Keras 模型已经提供了内置的 fit() 方法。 要了解它是如何工作的,我们首先将 DistilBERT 作为 TensorFlow 模型加载:

# #hide_output
# from transformers import TFAutoModelForSequenceClassification

# tf_model = (TFAutoModelForSequenceClassification
#             .from_pretrained(model_ckpt, num_labels=num_labels))

接下来,我们将数据集转换为 tf.data.Dataset 格式。 由于我们已经填充了标记化的输入,我们可以通过将 to_tf_dataset() 方法应用于 emotions_encoded 来轻松完成此操作:

# # The column names to convert to TensorFlow tensors
# tokenizer_columns = tokenizer.model_input_names

# tf_train_dataset = emotions_encoded["train"].to_tf_dataset(
#     columns=tokenizer_columns, label_cols=["label"], shuffle=True,
#     batch_size=batch_size)
# tf_eval_dataset = emotions_encoded["validation"].to_tf_dataset(
#     columns=tokenizer_columns, label_cols=["label"], shuffle=False,
#     batch_size=batch_size)

在这里,我们还对训练集进行了洗牌,并为其定义了批量大小和验证集。 最后要做的是编译和训练模型:

# #hide_output
# import tensorflow as tf

# tf_model.compile(
#     optimizer=tf.keras.optimizers.Adam(learning_rate=5e-5),
#     loss=tf.keras.losses.SparseCategoricalCrossentropy(from_logits=True),
#     metrics=tf.metrics.SparseCategoricalAccuracy())

# tf_model.fit(tf_train_dataset, validation_data=tf_eval_dataset, epochs=2)

误差分析

如果您使用的是 TensorFlow 阿在继续之前,我们应该进一步研究我们模型的预测。 一种简单而强大的技术是按模型损失对验证样本进行排序。 当我们在前向传递过程中传递标签时,会自动计算并返回损失。 这是一个返回损失和预测标签的函数:也可以使用 Keras API 微调您的模型。 与 PyTorch API 的主要区别在于没有 Trainer 类,因为 Keras 模型已经提供了内置的 fit() 方法。 要了解它是如何工作的,我们首先将 DistilBERT 作为 TensorFlow 模型加载:

from torch.nn.functional import cross_entropy

def forward_pass_with_label(batch):
    # Place all input tensors on the same device as the model
    inputs = {k:v.to(device) for k,v in batch.items() 
              if k in tokenizer.model_input_names}

    with torch.no_grad():
        output = model(**inputs)
        pred_label = torch.argmax(output.logits, axis=-1)
        loss = cross_entropy(output.logits, batch["label"].to(device), 
                             reduction="none")

    # Place outputs on CPU for compatibility with other dataset columns   
    return {"loss": loss.cpu().numpy(), 
            "predicted_label": pred_label.cpu().numpy()}

再次使用 map() 方法,我们可以应用此函数来获取所有样本的损失:

#hide_output
# Convert our dataset back to PyTorch tensors
emotions_encoded.set_format("torch", 
                            columns=["input_ids", "attention_mask", "label"])
# Compute loss values
emotions_encoded["validation"] = emotions_encoded["validation"].map(
    forward_pass_with_label, batched=True, batch_size=16)
  0%|          | 0/125 [00:00<?, ?ba/s]

最后,我们创建一个带有文本、损失和预测/真实标签的“DataFrame”:

emotions_encoded.set_format("pandas")
cols = ["text", "label", "predicted_label", "loss"]
df_test = emotions_encoded["validation"][:][cols]
df_test["label"] = df_test["label"].apply(label_int2str)
df_test["predicted_label"] = (df_test["predicted_label"]
                              .apply(label_int2str))

我们现在可以轻松地按损失按升序或降序对“emotions_encoded”进行排序。 本练习的目标是检测以下情况之一:

  • 错误标签:: 每个向数据添加标签的过程都可能存在缺陷。 注释者可能会犯错误或不同意,而从其他特征推断出的标签可能是错误的。 如果自动注释数据很容易,那么我们就不需要模型来做到这一点。 因此,有一些错误标记的示例是正常的。 通过这种方法,我们可以快速找到并纠正它们。

  • 数据集的怪癖:: 现实世界中的数据集总是有点混乱。 处理文本时,输入中的特殊字符或字符串会对模型的预测产生重大影响。 检查模型最弱的预测可以帮助识别这些特征,清理数据或注入类似的例子可以使模型更加健壮。

我们先来看看损失最高的数据样本:

#hide_output
df_test.sort_values("loss", ascending=False).head(10)
.dataframe tbody tr th:only-of-type { vertical-align: middle; } .dataframe tbody tr th { vertical-align: top; } .dataframe thead th { text-align: right; }
text label predicted_label loss
882 i feel badly about reneging on my commitment t... love sadness 5.349359
1500 i guess we would naturally feel a sense of lon... anger sadness 5.317410
1950 i as representative of everything thats wrong ... surprise sadness 5.078965
1963 i called myself pro life and voted for perry w... joy sadness 4.999394
1111 im lazy my characters fall into categories of ... joy fear 4.933125
1801 i feel that he was being overshadowed by the s... love sadness 4.914969
1870 i guess i feel betrayed because i admired him ... joy sadness 4.697159
318 i felt ashamed of these feelings and was scare... fear sadness 4.511000
1840 id let you kill it now but as a matter of fact... joy fear 4.437477
1581 i feel stronger clearer but a little annoyed n... anger joy 4.385489

我们可以清楚地看到该模型错误地预测了一些标签。 另一方面,似乎有相当多的例子没有明确的类别,它们可能被错误标记或完全需要一个新的类别。 特别是,“joy”似乎多次被贴错标签。 有了这些信息,我们可以优化数据集,这通常可以带来与拥有更多数据或更大模型一样大的性能提升(或更多)!

在查看损失最低的样本时,我们观察到该模型在预测“悲伤”类别时似乎最有信心。 深度学习模型非常擅长发现和利用捷径来进行预测。 出于这个原因,还值得花时间查看模型最有信心的示例,这样我们就可以确信模型不会不恰当地利用文本的某些特征。 所以,让我们也看看损失最小的预测:

#hide_output
df_test.sort_values("loss", ascending=True).head(10)
.dataframe tbody tr th:only-of-type { vertical-align: middle; } .dataframe tbody tr th { vertical-align: top; } .dataframe thead th { text-align: right; }
text label predicted_label loss
578 i got to christmas feeling positive about the ... joy joy 0.015832
1147 i type i feel bouncy and excited to get out my... joy joy 0.016036
620 i shall move right along to the post interview... joy joy 0.016114
1101 im feeling good i increase joy joy 0.016384
1636 i feel so lucky that my mom is alive and i get... joy joy 0.016387
1873 i feel practically virtuous this month i have ... joy joy 0.016417
1513 i have also been getting back into my gym rout... joy joy 0.016536
611 i woke up this morning feeling hopeful and ene... joy joy 0.016557
1028 im now winded at the end of a tough rally but ... joy joy 0.016606
260 i am feeling so invigorated and so ready to ke... joy joy 0.016628

我们现在知道“快乐”有时会被错误标记,并且该模型最有信心预测“悲伤”标签。 有了这些信息,我们可以对我们的数据集进行有针对性的改进,并密切关注模型似乎非常有信心的类。

为训练好的模型提供服务之前的最后一步是将其保存以供以后使用。 image:images/logo.png[hf,13,13] Transformers 允许我们通过几个步骤完成此操作,我们将在下一节中向您展示。

保存模型

NLP 社区从共享预训练和微调模型中受益匪浅,每个人都可以通过 Hugging Face Hub 与他人共享他们的模型。 任何社区生成的模型都可以从 Hub 下载,就像我们下载 DistilBERT 模型一样。 使用 Trainer API,保存和共享模型很简单:

#hide_output
trainer.push_to_hub(commit_message="Training completed!")
Upload file runs/Mar14_11-48-37_DESKTOP-G5E8965/events.out.tfevents.1647229730.DESKTOP-G5E8965.3200.0: 100%|##…


To https://huggingface.co/quincyqiang/distilbert-base-uncased-finetuned-emotion
   cf4a9a7..b97a429  main -> main

To https://huggingface.co/quincyqiang/distilbert-base-uncased-finetuned-emotion
   b97a429..6dca4d8  main -> main

'https://huggingface.co/quincyqiang/distilbert-base-uncased-finetuned-emotion/commit/b97a42913c1bb6c5cead68a64313f30a3c77e8a3'

我们还可以使用微调模型对新推文进行预测。 由于我们已经将模型推送到 Hub,我们现在可以将它与 pipeline() 函数一起使用,就像我们在 <<chapter_introduction>> 中所做的那样。 首先,让我们加载管道:

#hide_output
from transformers import pipeline

# Change `transformersbook` to your Hub username
model_id = "transformersbook/distilbert-base-uncased-finetuned-emotion"
classifier = pipeline("text-classification", model=model_id)

然后让我们使用示例推文测试管道:

custom_tweet = "I saw a movie today and it was really good."
preds = classifier(custom_tweet, return_all_scores=True)

最后,我们可以在条形图中绘制每个类别的概率。 显然,该模型估计最有可能的类别是“快乐”,考虑到推文,这似乎是合理的:

preds_df = pd.DataFrame(preds[0])
plt.bar(labels, 100 * preds_df["score"], color='C0')
plt.title(f'"{custom_tweet}"')
plt.ylabel("Class probability (%)")
plt.show()

结论

恭喜大家把这个notebook看完了,代码量非常大,但是文本分类也是是入门HuggingFace最佳实践,希望大家可以回头多多看看这个notebook,或多或少有你意想不到的用法。我们现在知道如何训练 Transformer 模型对推文中的情绪进行分类!我们已经看到了两种基于特征和微调的互补方法,并分析了它们的优缺点。

然而,这只是使用 Transformer 模型构建实际应用程序的第一步,我们还有更多内容需要介绍。下面列举了我们在 NLP 之旅中可能遇到的挑战问题集合:

我的老板昨天想让我的模型投入生产!:

在大多数应用程序中,您的模型不只是坐在尘土飞扬的地方——您要确保它能够提供预测!将模型推送到 Hub 时,会自动创建一个推理端点,可以使用 HTTP 请求调用该端点。如果您想了解更多信息,我们建议您查看推理 API 的 文档

我的用户想要更快的预测!::

我们已经看到了解决这个问题的一种方法:使用 DistilBERT。在 《模型压缩》章节 中,我们将深入探讨知识蒸馏(DistilBERT 的创建过程),以及加速转换器模型的其他技巧。

你的模型也可以做 XXXXX 吗?::

正如我们在本章中所提到的,变压器的用途非常广泛。在本书的其余部分,我们将探索一系列任务,例如问答和命名实体识别,所有这些任务都使用相同的基本架构。

我的文字都不是英文的!:: 事实证明,transformer 也有多种语言,我们将在 《命名实体识别》章节 中使用它们来同时处理多种语言。

我没有任何标签!::

如果可用的标记数据非常少,则可能无法进行微调。在《少样本学习》章节 中,我们将探索一些技术来处理这种情况。

现在我们已经了解了训练和共享转换器所涉及的内容,在下一章中,我们将探索从头开始实现我们自己的转换器模型。

大家好,我是致Great,微信TO-Great,欢迎加入大家公众号ChallengeHub的NLP技术交流群。