其它章节内容请见机器学习之PyTorch、Scikit-Learn和Transformers
文本分类是自然语言处理中最常见的任务之一,它可用于各种应用,例如将客户反馈标记为不同的类别,或者根据语言分发工单。电子邮件程序的垃圾邮件过滤器很有可能正在使用文本分类来保护收件箱免受大量不需要的垃圾邮件的侵扰!
另一种常见的文本分类是情感分析,正如我们在第1章中所看到的,它用于确定给定文本的极性。例如,特斯拉这样的公司可以分析类似于图2-1中的Twitter发帖,以确定人们是否喜欢其新车顶。
图2-1:分析Twitter内容可以从客户那里获得有用的反馈(图源Aditya Veluri)
设想你是一位数据科学家,需要构建一个系统,来自动识别人们在Twitter上对你公司产品表达的情绪状态,如"愤怒"或"喜悦"。本章中,我们将使用一种名为DistilBERT[1]的BERT变种来处理这个任务。该模型的主要优势在于,它在性能上与BERT相当,但体积更小、效率更高。这使得我们能够在几分钟内训练一个分类器,如果你想训练一个更大的BERT模型,只需简单地更换预训练模型的检查点。检查点对应于加载到给定Transformer架构中的一组权重。
这也将是我们与Hugging Face生态的三个核心库——🤗Datasets、🤗Tokenizers和🤗Transformers——的首次邂逅。如图2-2所示,这些库将使我们能够快速从原始文本转换为经过调优的模型,用于对新推文进行推断。因此,让我们以擎天柱之名,"变形,出发!"[2]
图2-2. 使用🤗Datasets、🤗Tokenizers和🤗Transformers库进行训练transformer模型的经典流程
数据集
为构建情绪检测器,我们将使用来自一篇探讨英文Twitter消息中情感表现的文章中的优秀数据集[3]。与大多数情感分析数据集只涉及“正面”和“负面”极性不同,该数据集包含六种基本情绪:愤怒、厌恶、恐惧、喜悦、悲伤和惊讶。给定一条推文,我们的任务是训练一个模型,将其归类为其中一种情绪。
初探Hugging Face数据集
我们使用🤗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 1753 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']
注: 国内用户使用时有可能会因网络原因报
Couldn't reach huggingface.co/datasets...
的错误,可设置本地代理解决该问题,如:import os os.environ['HTTPS_PROXY'] = 'http://127.0.0.1:7890'
我们看到每个数据集都有名称,下面使用load_dataset()
函数加载emotion
数据集:
from datasets import load_dataset
emotions = load_dataset("emotion")
深入查看emotions
对象的内部:
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
对象是🤗数据集中的核心数据结构之一,在本书中会探讨其很多的特性。初学者可以类比普通的Python数组或列表,因此可以获取其长度:
len(train_ds)
16000
或通过索引访问某个样本:
train_ds[0]
{'label': 0, 'text': 'i didnt feel humiliated'}
这里可以看到单行数据被表示成一个字典,其中键对应列名:
train_ds.column_names
['text', 'label']
值则是推文和情绪。这反映了🤗数据集基于Apache 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
列的数据类型是string
,而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加载和查看数据,让我们对推文的内容进行一些检测。
如果我的数据集不在Hub上怎么办?
在本书的大多数示例中,我们将使用Hugging Face Hub来下载数据集。但在许多情况下,可能需要使用存储在笔记本电脑或公司远程服务器上的数据。🤗Datasets提供了几个加载脚本来处理本地和远程数据集。表2-1展示了最常见数据格式的示例。
表2-1. 如何加载不同格式的数据集
数据格式 加载脚本 示例 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()
)函数,同时使用data_files
参数指定一个或多个文件的路径或URL。例如,emotion
数据集的源文件实际上托管在Dropbox上,因此加载数据集的另一种方法是先下载其中一个分割数据集:dataset_url = "https://www.dropbox.com/s/1pzkadrvffbqw6o/train.txt" !wget {dataset_url}
如果您想知道为什么在以上的shell命令中有一个
!
字符,那是因为我们在Jupyter笔记本中运行这些命令。如果是希望在终端中下载和解压缩数据集,请删除该前缀。下面我们来查看train.txt文件的第一行:!head -n 1 train.txt
i didnt feel humiliated;sadness
我们可以看到这里没有列标题,每个推文和情绪之间用分号分隔。不过这与CSV文件非常相似,因此我们可以使用
csv
脚本并将data_files
参数指向train.txt文件来在本地加载数据集。emotions_local = load_dataset("csv", data_files="train.txt", sep=";", names=["text", "label"])
这里我们还指定了分隔符的类型和列的名称。更简单的方法是只需将
data_files
参数指向URL本身即可: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"])
这会自动下载并缓存数据集。如你所见,
load_dataset()
函数非常灵活。建议查看🤗数据集文档,获取完整的综述。
从数据集到DataFrame
尽管🤗数据集提供了许多n底层功能来切分和处理数据,但将Dataset
对象转换为Pandas的DataFrame
是非常方便的,这样我们就可以使用高级API进行数据可视化。为了实现转换,🤗数据集提供了set_format()
方法,允许我们改变Dataset
的输出格式。注意这并不会改变底层数据的格式(即Arrow表),如果需要,之后还可以切换为另一种格式:
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 |
在上手构建分类器之前,让我们更深入地了解一下数据集。正如Andrej Karpathy在他著名的博文神经网络训练秘籍中所指出的那样,与数据“合为一体”是训练出优秀模型的关键步骤!
查看类别分布
无论何时在处理文本分类问题时,检查不同类别之间的样本分布是个好做法。一个类别分布不均衡的数据集可能需要在训练损失和评估指标方面与一个平衡的数据集有不同的处理方式。
使用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库。只需确保在创建训练/测试集之前不要应用采样方法,否则它们之间会存在很多信息泄露!
现在我们已经看过类别了,下面看一下推文本身。
我们的推文有多长?
Transformer模型有一个被称为最大上下文大小的最大输入序列长度。对于使用DistilBERT的应用程序,最大上下文大小为512个token,相当于几段文字。正如我们将在下一节中看到的,一个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()
将文本转为token
DistilBERT这样的Transformer模型无法接收原始字符串作为输入;而它们假设文本已经被标记化并编码为数值向量。标记化是将字符串分解为模型中使用的原子单元的步骤。有几种标记化策略,通常从语料库中学习最优的单词分割方式。在看DistilBERT使用的标记器之前,让我们考虑两种极端情况:字符标记化和单词标记化。
字符标记化
最简单的标记化方案是将每个字符单独喂给模型。在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
转换为一个包含独热向量的2D张量。在机器学习中,经常使用独热向量来编码分类数据,可以是有序的或标称的。例如,假设我们希望编码变形金刚剧集中的角色名称。一种方法是将每个名称映射到一个唯一的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
各行是独热向量,其中只有一个条目为1,其余都是0。现在,来看input_ids
,有一个类似的问题:元素创建了一个顺序量表。这意味着添加或去除两个ID是无意义的操作,因为结果是表示另一个随机标记的新ID。
另一方面,添加两个独热编码的结果很容易解释:两个“热”条目表示相应的标记共同出现。我们可以通过将input_ids
转换为张量在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维的独热向量,因为我们的词汇由20个唯一字符组成。
警告: 保持在
one_hot()
函数中设置num_classes
非常重要,否则独热向量可能会比词汇的长度短(需要手动用零填充)。在TensorFlow中,对应的函数是tf.one_hot()
,其中depth
参数作用同num_classes
。
通过检查第一个向量,我们可以通过input_ids[0]
指示的位置是否出现了1进行验证:
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.
被看成一个token。考虑到单词可能包括词形变化、动词变位及拼写错误,词汇量很容易增长到数百万个!
注:一些分词器对标点符号有额外的规则。也可以应用词干提取(stemming)或词形归并(lemmatization),将单词归一化为其词干(例如,“great”、“greater”和“greatest”都变为“great”),但会损失文本中的一些信息。
庞大的词汇表是一个问题,因为它需要神经网络具有海量的参数。为了说明这一点,假设我们有100万个独立单词,并希望在神经网络的第一层将这100万维的输入向量压缩为1千维的向量。这是大多数NLP架构中的标准步骤,而这一层的权重矩阵将包含1百万×1千 = 10亿个权重。这已经与最大的GPT-2模型[4](大约有15亿个参数)相当了!
显然我们希望避免在模型参数上这样铺张,因为模型的训练成本很高,而且模型越大越难维护。一种常见的方法是通过考虑比如语料库中最常见的10万个单词,限制词汇表的大小并丢弃不常见的单词。不属于词汇表的单词被归类为“未知”并映射到共享的UNK
标记。这意味着在单词分词过程中,可能会失去一些重要信息,因为模型没有与UNK
相关的单词的信息。
如果有一种既能保留所有输入信息又能保留部分输入结构的处于字符、单词分词之间的折衷方案,那不是很好?那就是子词分词法。
子词分词法
子词分词法的基本思想是将字符分词和单词分词的优点结合起来。一方面,我们希望将不常见单词拆分为更小的单元,以便模型处理复杂的单词和错误拼写。另一方面,我们希望将常用字保持为唯一实体,以便我们可以将输入的长度保持在可控范围内。子词分词(以及单词分词)的主要特点是它是通过使用统计规则和算法从预训练语料库中学习的。
在自然语言处理中有几种常用的子词分词算法,但我们从聊WordPiece[5],BERT和DistilBERT分词器都用到了它。了解WordPiece的原理最简单的方法是上手操作。🤗Transformers提供了一个便捷的AutoTokenizer
类,可以快速加载带预训练模型的分词器-我们只需调用其from_pretrained()
方法,提供Hub上的模型ID或本地文件路径。先来加载DistilBERT的分词器:
from transformers import AutoTokenizer
model_ckpt = "distilbert-base-uncased"
tokenizer = AutoTokenizer.from_pretrained(model_ckpt)
AutoTokenizer
类属于大型分类集合auto,其任务是根据检查点的名称自动检索模型的配置、预训练权重或词汇表。这样可以在不同模型之间快速切换,但如果读者希望手动加载某个类,也可以这样做。例如,我们可以按以下方式加载DistilBERT分词器:
from transformers import DistilBertTokenizer
distilbert_tokenizer = DistilBertTokenizer.from_pretrained(model_ckpt)
注:在第一次运行
AutoTokenizer.from_pretrained()
方法时,会看到一个进度条,显示从Hugging Face Hub加载预训练分词器的哪些参数。第二次运行代码时,将从缓存中加载分词器,通常位于~/.cache/huggingface目录下。
我闪将简单示例文本“Tokenizing text is a core task of NLP.”喂给这个分词器,看其如何运作:
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()
方法将它们转换回token:
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]
标记。这些标记因模型而异,但它们的主要作用是指示序列的开始和结束。其次,每个标记都被转换为小写,这是这个特定检查点的一个特征。最后,我们可以看到“tokenizing”和“NLP”被拆分为两个标记,这是有道理的,因为它们不是常用词。##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']
现在我们对单个字符串的分词过程有了基本的理解,让我们看看如何对整个数据集进行分词!
警告:在使用预训练模型时,保障使用与模型训练时相同的分词器非常重要。从模型的角度来看,切换分词器就像重排词汇表一样。如果你周围的每个人都开始将随机的词语如“house”替换为“cat”,你也很难理解是怎么回事!