Alan Hou的个人博客

Transformers自然语言处理第二章 文本分类

其它章节内容请见机器学习之PyTorch、Scikit-Learn和Transformers

代码参数GitHub仓库

文本分类是自然语言处理中最常见的任务之一,它可用于各种应用,例如将客户反馈标记为不同的类别,或者根据语言分发工单。电子邮件程序的垃圾邮件过滤器很有可能正在使用文本分类来保护收件箱免受大量不需要的垃圾邮件的侵扰!

另一种常见的文本分类是情感分析,正如我们在第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上可用的数据集:

注:国内用户使用时有可能会因网络原因报Couldn't reach huggingface.co/datasets...的错误,可设置本地代理解决该问题,如:

我们看到每个数据集都有名称,下面使用load_dataset()函数加载emotion数据集:

深入查看emotions对象的内部:

可以看到和Python字典很像,每个键对应不同的数据集。我们可以使用常规的字典语法来访问单独的数据集:

返回了一个Dataset类的实例。Dataset对象是🤗数据集中的核心数据结构之一,在本书中会探讨其很多的特性。初学者可以类比普通的Python数组或列表,因此可以获取其长度:

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

这里可以看到单行数据被表示成一个字典,其中键对应列名:

值则是推文和情绪。这反映了🤗数据集基于Apache Arrow,它定义了一种类型化的列格式,比原生Python更高效地使用内存。我们可以通过访问Dataset对象的features属性来查看底层使用的数据类型:

本例中,text列的数据类型是string,而label列是一个特殊的ClassLabel对象,它包含有关类别名称及其与整数映射关系的信息。我们还可以使用切片访问多行数据:

注意本例中字典的值是列表,而不是单个元素。我们还可以通过名称获取完整的列数据:

现在我们已经了解了如何使用🤗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上,因此加载数据集的另一种方法是先下载其中一个分割数据集:

如果您想知道为什么在以上的shell命令中有一个!字符,那是因为我们在Jupyter笔记本中运行这些命令。如果是希望在终端中下载和解压缩数据集,请删除该前缀。下面我们来查看train.txt文件的第一行:

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

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

这会自动下载并缓存数据集。如你所见,load_dataset()函数非常灵活。建议查看🤗数据集文档,获取完整的综述。

从数据集到DataFrame

尽管🤗数据集提供了许多n底层功能来切分和处理数据,但将Dataset对象转换为Pandas的DataFrame是非常方便的,这样我们就可以使用高级API进行数据可视化。为了实现转换,🤗数据集提供了set_format()方法,允许我们改变Dataset的输出格式。注意这并不会改变底层数据的格式(即Arrow表),如果需要,之后还可以切换为另一种格式:

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中新建一列:

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在他著名的博文神经网络训练秘籍中所指出的那样,与数据“合为一体”是训练出优秀模型的关键步骤!

查看类别分布

无论何时在处理文本分类问题时,检查不同类别之间的样本分布是个好做法。一个类别分布不均衡的数据集可能需要在训练损失和评估指标方面与一个平衡的数据集有不同的处理方式。

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

本例中,可以看到数据集存在严重的不均衡;joysadness类别频繁出现,而lovesurprise类别要少约为5-10倍。处理不均衡数据有几种方法:

  1. 随机过采样少数类别。
  2. 随机下采样多数类别。
  3. 获取更多占比较少类别的标记数据。

为简单起见,本章中我们将使用原始的、不均衡的类别频率。如果希望了解更多关于这些采样技术的知识,推荐查看Imbalanced-learn库。只需确保在创建训练/测试集之前不要应用采样方法,否则它们之间会存在很多信息泄露!

现在我们已经看过类别了,下面看一下推文本身。

我们的推文有多长?

Transformer模型有一个被称为最大上下文大小的最大输入序列长度。对于使用DistilBERT的应用程序,最大上下文大小为512个token,相当于几段文字。下一节中会学到token是文本的原子片段;现在,我们先把一个token看成一个单词。通过观察每种情绪的推文中词语的分布,可以对推文长度进行粗略估计:

从图中可以看出,对于每种情绪,大多数推文的长度约为15个词,而最长的推文远远低于DistilBERT的最大上下文大小。超过模型上下文大小的文本需要被截断,如果被截断的文本包含关键信息,可能会导致性能损失;但本例中不存在这个问题。

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

将文本转为token

DistilBERT这样的Transformer模型无法接收原始字符串作为输入;而它们假设文本已经被标记化并编码为数值向量。标记化是将字符串分解为模型中使用的原子单元的步骤。有几种标记化策略,通常从语料库中学习最优的单词分割方式。在看DistilBERT使用的标记器之前,让我们考虑两种极端情况:字符标记化和单词标记化。

字符标记化

最简单的标记化方案是将每个字符单独喂给模型。在Python中,str对象实际上在底层是数组,这样我们可以只用一行代码快速实现字符级标记化:

开局不错,但尚未完成。我们的模型希望将每个字符转换为一个整数,这个过程有时被称为数值化。一种简单的方法是通过使用唯一的整数对每个唯一标记(本例中是字符)进行编码:

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

现在,每个标记都已映射到一个唯一的数字标识符(因此称为input_ids)。最后一步是将input_ids转换为一个包含独热向量的2D张量。在机器学习中,经常使用独热向量来编码分类数据,可以是有序的或标称的。例如,假设我们希望编码变形金刚剧集中的角色名称。一种方法是将每个名称映射到一个唯一的ID,如下所示:

Name Label ID
0 Bumblebee 0
1 Optimus Prime 1
2 Megatron 2

这种方法的问题是它在名称之间创建了一种虚构的顺序,而神经网络极其擅于学习这种关系。对应地,我们可以为每个类别新建一列,在该类别为真时赋值1,其他情况下赋值0。在Pandas中,可以使用get_dummies()函数来实现,如下:

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()函数如下:

对于这38个输入标记中的每一个,都有一个20维的独热向量,因为我们的词汇由20个唯一字符组成。

警告:保持在one_hot()函数中设置num_classes非常重要,否则独热向量可能会比词汇的长度短(需要手动用零填充)。在TensorFlow中,对应的函数是tf.one_hot(),其中depth参数作用同num_classes

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

从我们简单的示例中,可以看到字符级的切分忽略了文本结构,将整个字符串视为字符流。虽然这有助于处理拼写错误和生僻字,但其主要缺点是语言结构(如单词)需要从数据中学习。这需要大量的计算、内存和数据。因此,实操中很少使用字符切分。而是在切分步骤中保留文本结构。单词切分是一种简洁的方法,我们来看如何操作。

单词分词法

与将文本拆分为字符不同,我们可以将其拆分为单词,并让单词与整数产生映射。一开始使用单词能让模型跳过从字符学习单词的步骤,从而降低了训练过程的复杂性。

一种简单的单词分词器使用空格来切分文本。我们可以通过直接在原始文本上应用Python的split()函数来实现这一点(和获取推文长度一样):

我们可以采取与字符分词器相同的步骤,将每个单词映射到一个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的分词器:

AutoTokenizer类属于大型分类集合auto,其任务是根据检查点的名称自动检索模型的配置、预训练权重或词汇表。这样可以在不同模型之间快速切换,但如果读者希望手动加载某个类,也可以这样做。例如,我们可以按以下方式加载DistilBERT分词器:

注:在第一次运行AutoTokenizer.from_pretrained()方法时,会看到一个进度条,显示从Hugging Face Hub加载预训练分词器的哪些参数。第二次运行代码时,将从缓存中加载分词器,通常位于~/.cache/huggingface目录下。

我闪将简单示例文本“Tokenizing text is a core task of NLP.”喂给这个分词器,看其如何运作:

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

这里我们可以观察到三点。首先,在序列的开头和结尾添加了一些特殊的[CLS][SEP]标记。这些标记因模型而异,但它们的主要作用是指示序列的开始和结束。其次,每个标记都被转换为小写,这是这个特定检查点的一个特征。最后,我们可以看到“tokenizing”和“NLP”被拆分为两个标记,这是有道理的,因为它们不是常用词。##izing##p中的##前缀表示前面的字符串不是空格;在将标记转换回字符串时,任何带有这个前缀的标记都应与前一个标记合并。AutoTokenizer类有一个convert_tokens_to_string()方法实现了这个功能,我们将它应用到标记上:

AutoTokenizer类还有多个属性,可以提供分词器相关信息。例如,我们可以查看词汇表的大小:

以及相关模型的最大上下文大小:

另一个有意思的属性是获取模型在前向传递中预期的字段的名称:

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

警告:在使用预训练模型时,保障使用与模型训练时相同的分词器非常重要。从模型的角度来看,切换分词器就像重排词汇表一样。如果你周围的每个人都开始将随机的词语如“house”替换为“cat”,你也很难理解是怎么回事!

对整个数据集分词

将整个语料库进行分词,我们使用DatasetDict对象的map()方法。在本书的许多地方我们都会遇到这个方法,因为它提供了一种方便的方式来将处理函数应用于数据集中的每个元素。我们很快会学到,map()方法还可以用于新建的行和列。

首先我们需要一个处理函数来对样本进行分词:

这个函数将分词器应用于一批样本;padding=True会用0将样本填充至最长样本,而truncation=True会将样本截断为模型的最大上下文大小。为了实际查看tokenize(),我们传入一批来自训练集的两个样本:

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

Special Token [PAD] [UNK] [CLS] [SEP] [MASK]
Special Token ID 0 100 101 102 103

还应注意,分词器除了返回编码后的推文作为input_ids之外,还返回了一组attention_mask数组。这是因为我们不希望模型因为额外的填充标记而混淆:注意力掩码允许模型忽略输入中的填充部分。图2-3有关于如何对输入ID和注意力掩码进行填充的可视化讲解。

图2-3:对于每个批次,输入序列会填充到批次中最大序列长度;模型中的注意力掩码用于忽略输入张量中的填充区域

定义好处理函数扣, 可以用一行代码将其应用到语料库的所有分段中:

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

注:在后面的章节中,我们将看到如何使用数据整理器(data collator)来动态填充每个批次中的张量。全局填充对下一节非常有用,我们将从整个语料库中提取一个特征矩阵。

训练文本分类器

第1章中讨论过,像DistilBERT这样的模型是预训练的,用于预测文本序列中的掩码单词。但是,我们不能直接将这些语言模型用于文本分类;需要做轻微的修改。为了解需要进行哪些修改,我们来看看基于编码器的模型(如DistilBERT)的架构,如图2-4所示。

图2-4:基于编码器transformer的用于序列分类的架构;由模型的预训练主体与自定义分类头部组合而成。

 

 

 

 

from umap.umap_ import UMAP

翻译整理中…

退出移动版