每天给你送来NLP技术干货!
来自:ChallengeHub
作者:致Great
文本分类是 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(擎天柱,《变形金刚》) 的精神,让我们深入其中,“改造并启动!”
为了构建我们的推文情感分类器,我们将使用一篇论文中的一个非常高质量的数据集,该论文探讨了情绪如何在英语 Twitter 消息中表示。
PS:[E. Saravia 等人,“CARER: Contextualized Affect Representations for Emotion Recognition”,2018 年自然语言处理经验方法会议论文集(2018 年 10 月至 11 月):3687-3697,http://dx.doi.org/10.18653 /v1/D18-1404.] 。
与大多数仅涉及“正面”和“负面”极性的情绪分析数据集不同,该数据集包含六种基本情绪:愤怒、厌恶、恐惧、喜悦、悲伤和惊讶(anger, disgust, fear, joy, sadness, and surprise)。给定一条推文,我们的任务将是训练一个可以将其分类为其中一种情绪的模型。
我们将使用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")
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 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 提供了许多基础功能来对我们的数据进行切片和切块,但将 Dataset 对象转换为 Pandas DataFrame 通常很方便,因此我们可以访问高级 用于数据可视化的级别 API。为了启用转换 Datasets 提供了一个 set_format() 方法,允许我们更改 Dataset 的输出格式。请注意,这不会更改底层数据格式(这是一个箭头表),如果需要,您可以稍后切换到另一种格式:
import pandas as pd
emotions.set_format(type="pandas")
df = emotions["train"][:]
df.head()
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()
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()
在这种情况下,我们可以看到数据集严重不平衡;joy
和sadness
类经常出现,而love
和surprise
则少 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 模型不能接收原始字符串作为输入;相反,他们假设文本已被 tokenized 和 encoded 作为数字向量。分词(tokenizer)是将字符串分解为模型中使用的最基础单元的步骤。可以采用多种标记化策略,通常从语料库中学习将单词优化为子单元的方法。在查看用于 DistilBERT 的分词器之前,让我们考虑两种常见情况:_character_ 和 word 分词。
最简单的标记化方案是将每个字符单独提供给模型。在 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
Name | Label ID | |
---|---|---|
0 | Bumblebee | 0 |
1 | Optimus Prime | 1 |
2 | Megatron | 2 |
这种方法的问题在于它在名称之间创建了一个虚构的顺序,而神经网络非常擅长学习这些类型的关系。因此,我们可以为每个类别创建一个新列,并在类别为真时分配 1,否则分配 0。在 Pandas 中,这可以通过 get_dummies() 函数实现,如下所示:
pd.get_dummies(categorical_df["Name"])
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])
从我们的简单例子中,我们可以看到字符级标记化忽略了文本中的语义结构,并将整个字符串视为字符流。尽管这有助于处理拼写错误和稀有单词,但主要缺点是需要从数据中学习单词等语言结构。这需要大量的计算、内存和数据。出于这个原因,字符标记化在实践中很少使用。相反,在标记化步骤期间会保留文本的某些结构。词标记化是实现这一目标的一种直接方法,所以让我们来看看它是如何工作的。
我们可以将文本拆分为单词并将每个单词映射为一个整数,而不是将文本拆分为字符。从一开始就使用单词可以使模型跳过从字符中学习单词的步骤,从而降低训练过程的复杂性。
一种简单的分词方法就是使用空格来标记文本。我们可以通过直接在原始文本上应用 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分词背后的基本思想是结合字符和词标记化的最佳应用。一方面,我们希望将稀有词拆分成更小的单元,以使模型能够处理复杂的词和拼写错误。另一方面,我们希望将常用词保留为唯一实体,以便我们可以将输入的长度保持在可管理的大小。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, https://doi.org/10.1109/ICASSP.2012.6289079.] 由 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
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_ids
和 attention_mask
列:
print(emotions_encoded["train"].column_names)
['attention_mask', 'input_ids', 'label', 'text']
默认情况下,
map()
方法对语料库中的每个示例单独运行,因此设置batched=True
将对推文进行批量编码。因为我们设置了batch_size=None
,所以我们的tokenize()
函数将作为单个批次应用于整个数据集。这确保了输入张量和注意力掩码在全局范围内具有相同的形状,我们可以看到这个操作在数据集中添加了新的input_ids
和attention_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作为特征提取工具作为特征提取器相当简单。如下图所示,我们在训练期间冻结身体的权重,并使用隐藏状态作为分类器的特征。这种方法的优点是我们可以快速训练一个小的或浅的模型。这样的模型可以是神经分类层或不依赖梯度的方法,例如随机森林。如果 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 编写的,但 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_ids
和 attention_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_hidden_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()
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 中的 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”属性的命名元组),并需要返回一个字典,将每个指标的名称映射到它的值。对于我们的应用程序,我们将计算 -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”类之前,我们只需要处理最后两件事:
在 Hugging Face Hub 上登录我们的帐户。这将使我们能够将微调后的模型推送到我们在 Hub 上的帐户并与社区共享。
定义训练运行的所有超参数。
我们将在下一节中处理这些步骤。
如果您在 Jupyter 笔记本中运行此代码,则可以使用以下帮助函数登录到 Hub:
from huggingface_hub import notebook_login
notebook_login()
VBox(children=(HTML(value='\n
这将显示一个小部件,您可以在其中输入您的用户名和密码,或具有写入权限的访问令牌。您可以在 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.
[34m[1mwandb[0m: Currently logged in as: [33mquincyqiang[0m (use `wandb login --relogin` to force relogin)
[34m[1mwandb[0m: wandb version 0.12.11 is available! To upgrade, please run:
[34m[1mwandb[0m: $ pip install wandb --upgrade
Syncing run distilbert-base-uncased-finetuned-emotion to Weights & Biases (docs).
[500/500 01:11, Epoch 2/2]