面向程序员的 FastAI 和 PyTorch 深度学习(七)
原文:Deep Learning for Coders With Fastai and Pytorch
译者:飞龙
第十章:NLP 深入探讨:RNNs
在第一章中,我们看到深度学习可以用于处理自然语言数据集并取得出色的结果。我们的示例依赖于使用预训练的语言模型,并对其进行微调以对评论进行分类。该示例突出了 NLP 和计算机视觉中迁移学习的区别:通常情况下,在 NLP 中,预训练模型是在不同任务上训练的。
我们所谓的语言模型是一个经过训练以猜测文本中下一个单词的模型(在读取之前的单词后)。这种任务称为自监督学习:我们不需要为我们的模型提供标签,只需向其提供大量文本。它有一个过程可以从数据中自动获取标签,这个任务并不是微不足道的:为了正确猜测句子中的下一个单词,模型将必须发展对英语(或其他语言)的理解。自监督学习也可以用于其他领域;例如,参见“自监督学习和计算机视觉”以了解视觉应用。自监督学习通常不用于直接训练的模型,而是用于预训练用于迁移学习的模型。
术语:自监督学习
使用嵌入在自变量中的标签来训练模型,而不是需要外部标签。例如,训练一个模型来预测文本中的下一个单词。
我们在第一章中用于分类 IMDb 评论的语言模型是在维基百科上预训练的。通过直接微调这个语言模型到电影评论分类器,我们取得了出色的结果,但通过一个额外的步骤,我们甚至可以做得更好。维基百科的英语与 IMDb 的英语略有不同,因此,我们可以将我们的预训练语言模型微调到 IMDb 语料库,然后将那个作为我们分类器的基础。
即使我们的语言模型了解我们在任务中使用的语言的基础知识(例如,我们的预训练模型是英语),熟悉我们的目标语料库的风格也是有帮助的。它可能是更非正式的语言,或者更技术性的,有新词要学习或者不同的句子构成方式。在 IMDb 数据集的情况下,将会有很多电影导演和演员的名字,通常比维基百科中看到的语言更不正式。
我们已经看到,使用 fastai,我们可以下载一个预训练的英语语言模型,并用它来获得 NLP 分类的最新结果。(我们预计很快将提供更多语言的预训练模型;实际上,当您阅读本书时,它们可能已经可用。)那么,为什么我们要详细学习如何训练语言模型呢?
当然,一个原因是了解您正在使用的模型的基础知识是有帮助的。但还有另一个非常实际的原因,那就是如果在微调分类模型之前微调(基于序列的)语言模型,您将获得更好的结果。例如,对于 IMDb 情感分析任务,数据集包括额外的 50,000 条电影评论,这些评论没有任何积极或消极的标签。由于训练集中有 25,000 条带标签的评论,验证集中有 25,000 条,总共有 100,000 条电影评论。我们可以使用所有这些评论来微调仅在维基百科文章上训练的预训练语言模型,这将导致一个特别擅长预测电影评论下一个单词的语言模型。
这被称为通用语言模型微调(ULMFiT)方法。介绍它的论文表明,在将语言模型微调到传递学习到分类任务之前,这个额外的微调阶段会导致预测显著更好。使用这种方法,我们在 NLP 中有三个传递学习阶段,如图 10-1 所总结。
图 10-1。ULMFiT 过程
我们现在将探讨如何将神经网络应用于这个语言建模问题,使用前两章介绍的概念。但在继续阅读之前,请暂停一下,思考一下您将如何处理这个问题。
文本预处理
到目前为止,我们学到的如何构建语言模型并不明显。句子的长度可能不同,文档可能很长。那么我们如何使用神经网络来预测句子的下一个单词呢?让我们找出答案!
我们已经看到分类变量可以作为神经网络的独立变量使用。以下是我们为单个分类变量采取的方法:
-
制作该分类变量的所有可能级别的列表(我们将称此列表为词汇)。
-
用词汇表中的索引替换每个级别。
-
为此创建一个包含每个级别的行的嵌入矩阵(即,词汇表中的每个项目)。
-
将此嵌入矩阵用作神经网络的第一层。(专用嵌入矩阵可以将步骤 2 中创建的原始词汇索引作为输入;这相当于但比使用表示索引的独热编码向量作为输入更快速和更有效。)
我们几乎可以用文本做同样的事情!新的是序列的概念。首先,我们将数据集中的所有文档连接成一个大字符串,然后将其拆分为单词(或标记),从而给我们一个非常长的单词列表。我们的独立变量将是从我们非常长的列表中的第一个单词开始并以倒数第二个单词结束的单词序列,我们的因变量将是从第二个单词开始并以最后一个单词结束的单词序列。
我们的词汇将由一些常见词汇和我们语料库中特定的新词汇(例如电影术语或演员的名字)混合组成。我们的嵌入矩阵将相应构建:对于预训练模型词汇中的词,我们将使用预训练模型的嵌入矩阵中的相应行;但对于新词,我们将没有任何内容,因此我们将只是用随机向量初始化相应的行。
创建语言模型所需的每个步骤都与自然语言处理领域的术语相关联,并且有 fastai 和 PyTorch 类可用于帮助。步骤如下:
标记化
将文本转换为单词列表(或字符,或子字符串,取决于您模型的粒度)。
数值化
列出所有出现的唯一单词(词汇表),并通过查找其在词汇表中的索引将每个单词转换为一个数字。
语言模型数据加载器创建
fastai 提供了一个LMDataLoader
类,它会自动处理创建一个依赖变量,该变量与独立变量相差一个标记。它还处理一些重要的细节,例如如何以保持所需结构的方式对训练数据进行洗牌。
语言模型创建
我们需要一种特殊类型的模型,可以处理我们以前没有见过的输入列表,这些列表可能非常大或非常小。有许多方法可以做到这一点;在本章中,我们将使用循环神经网络(RNN)。我们将在第十二章中详细介绍 RNN 的细节,但现在,您可以将其视为另一个深度神经网络。
让我们详细看看每个步骤是如何工作的。
分词
当我们说“将文本转换为单词列表”时,我们忽略了很多细节。例如,我们如何处理标点符号?我们如何处理像“don’t”这样的单词?它是一个单词还是两个?长的医学或化学术语怎么办?它们应该被分割成各自的含义部分吗?连字符词怎么处理?像德语和波兰语这样的语言如何处理,它们可以从许多部分组成一个非常长的单词?像日语和中文这样的语言如何处理,它们根本不使用基础,也没有一个明确定义的单词的概念?
由于这些问题没有一个正确答案,所以也没有一个分词的方法。有三种主要方法:
基于单词的
将一个句子按空格分割,同时应用特定于语言的规则,尝试在没有空格的情况下分隔含义部分(例如将“don’t”转换为“do n’t”)。通常,标点符号也会被分割成单独的标记。
基于子词的
根据最常出现的子字符串将单词分割成较小的部分。例如,“occasion”可能被分词为“o c ca sion”。
基于字符的
将一个句子分割成其各个字符。
我们将在这里看一下单词和子词的分词,将字符为基础的分词留给你在本章末尾的问卷中实现。
行话:Token
由分词过程创建的列表的一个元素。它可以是一个单词,一个单词的一部分(一个子词),或一个单个字符。
使用 fastai 进行单词分词
fastai 并没有提供自己的分词器,而是提供了一个一致的接口来使用外部库中的一系列分词器。分词是一个活跃的研究领域,新的和改进的分词器不断涌现,因此 fastai 使用的默认值也会发生变化。然而,API 和选项不应该发生太大变化,因为 fastai 试图在底层技术发生变化时保持一致的 API。
让我们尝试一下我们在第一章中使用的 IMDb 数据集:
from fastai.text.all import *
path = untar_data(URLs.IMDB)
我们需要获取文本文件以尝试一个分词器。就像get_image_files
(我们已经使用了很多次)获取路径中的所有图像文件一样,get_text_files
获取路径中的所有文本文件。我们还可以选择性地传递folders
来限制搜索到特定的子文件夹列表:
files = get_text_files(path, folders = ['train', 'test', 'unsup'])
这是一个我们将要分词的评论(我们这里只打印开头部分以节省空间):
txt = files[0].open().read(); txt[:75]
'This movie, which I just discovered at the video store, has apparently sit '
在撰写本书时,fastai 的默认英语单词分词器使用了一个名为spaCy的库。它有一个复杂的规则引擎,具有针对 URL、特殊英语单词等的特殊规则,以及更多。然而,我们不会直接使用SpacyTokenizer
,而是使用WordTokenizer
,因为它将始终指向 fastai 当前默认的单词分词器(取决于你阅读本书的时间,可能不一定是 spaCy)。
让我们试一试。我们将使用 fastai 的coll_repr(*collection*,*n*)
函数来显示结果。这会显示collection
的前n
个项目,以及完整的大小——这是L
默认使用的。请注意,fastai 的分词器接受一个要分词的文档集合,因此我们必须将txt
包装在一个列表中:
spacy = WordTokenizer()
toks = first(spacy([txt]))
print(coll_repr(toks, 30))
(#201) ['This','movie',',','which','I','just','discovered','at','the','video','s
> tore',',','has','apparently','sit','around','for','a','couple','of','years','
> without','a','distributor','.','It',"'s",'easy','to','see'...]
正如你所看到的,spaCy 主要只是将单词和标点符号分开。但它在这里也做了其他事情:它将“it's”分割成“it”和“’s”。这是直观的;这些实际上是分开的单词。分词是一个令人惊讶的微妙任务,当你考虑到所有必须处理的细节时。幸运的是,spaCy 为我们处理得相当好——例如,在这里我们看到“.”在终止句子时被分开,但在首字母缩写或数字中不会被分开:
first(spacy(['The U.S. dollar $1 is $1.00.']))
(#9) ['The','U.S.','dollar','$','1','is','$','1.00','.']
然后 fastai 通过Tokenizer
类为分词过程添加了一些额外功能:
tkn = Tokenizer(spacy)
print(coll_repr(tkn(txt), 31))
(#228) ['xxbos','xxmaj','this','movie',',','which','i','just','discovered','at',
> 'the','video','store',',','has','apparently','sit','around','for','a','couple
> ','of','years','without','a','distributor','.','xxmaj','it',"'s",'easy'...]
请注意,现在有一些以“xx”开头的标记,这不是英语中常见的单词前缀。这些是特殊标记。
例如,列表中的第一项xxbos
是一个特殊标记,表示新文本的开始(“BOS”是一个标准的 NLP 缩写,意思是“流的开始”)。通过识别这个开始标记,模型将能够学习需要“忘记”先前说过的内容,专注于即将出现的单词。
这些特殊标记并不是直接来自 spaCy。它们存在是因为 fastai 默认添加它们,通过在处理文本时应用一系列规则。这些规则旨在使模型更容易识别句子中的重要部分。在某种意义上,我们正在将原始的英语语言序列翻译成一个简化的标记化语言——这种语言被设计成易于模型学习。
例如,规则将用一个感叹号替换四个感叹号,后面跟着一个特殊的重复字符标记,然后是数字四。通过这种方式,模型的嵌入矩阵可以编码关于重复标点等一般概念的信息,而不需要为每个标点符号的重复次数添加单独的标记。同样,一个大写的单词将被替换为一个特殊的大写标记,后面跟着单词的小写版本。这样,嵌入矩阵只需要单词的小写版本,节省了计算和内存资源,但仍然可以学习大写的概念。
以下是一些你会看到的主要特殊标记:
xxbos
指示文本的开始(这里是一篇评论)
xxmaj
指示下一个单词以大写字母开头(因为我们将所有字母转换为小写)
xxunk
指示下一个单词是未知的
要查看使用的规则,可以查看默认规则:
defaults.text_proc_rules
[<function fastai.text.core.fix_html(x)>,
<function fastai.text.core.replace_rep(t)>,
<function fastai.text.core.replace_wrep(t)>,
<function fastai.text.core.spec_add_spaces(t)>,
<function fastai.text.core.rm_useless_spaces(t)>,
<function fastai.text.core.replace_all_caps(t)>,
<function fastai.text.core.replace_maj(t)>,
<function fastai.text.core.lowercase(t, add_bos=True, add_eos=False)>]
如常,你可以通过在笔记本中键入以下内容查看每个规则的源代码:
??replace_rep
以下是每个标记的简要摘要:
fix_html
用可读版本替换特殊的 HTML 字符(IMDb 评论中有很多这样的字符)
replace_rep
用一个特殊标记替换任何重复三次或更多次的字符(xxrep
),重复的次数,然后是字符
replace_wrep
用一个特殊标记替换任何重复三次或更多次的单词(xxwrep
),重复的次数,然后是单词
spec_add_spaces
在/和#周围添加空格
rm_useless_spaces
删除所有空格的重复
replace_all_caps
将所有大写字母单词转换为小写,并在其前面添加一个特殊标记(xxcap
)
replace_maj
将大写的单词转换为小写,并在其前面添加一个特殊标记(xxmaj
)
lowercase
将所有文本转换为小写,并在开头(xxbos
)和/或结尾(xxeos
)添加一个特殊标记
让我们看看其中一些的操作:
coll_repr(tkn('© Fast.ai www.fast.ai/INDEX'), 31)
"(#11) ['xxbos','©','xxmaj','fast.ai','xxrep','3','w','.fast.ai','/','xxup','ind
> ex'...]"
现在让我们看看子词标记化是如何工作的。
子词标记化
除了在前一节中看到的单词标记化方法之外,另一种流行的标记化方法是子词标记化。单词标记化依赖于一个假设,即空格在句子中提供了有意义的组件的有用分隔。然而,这个假设并不总是适用。例如,考虑这个句子:我的名字是郝杰瑞(中文中的“My name is Jeremy Howard”)。这对于单词标记器来说不会很好,因为其中没有空格!像中文和日文这样的语言不使用空格,事实上它们甚至没有一个明确定义的“单词”概念。其他语言,如土耳其语和匈牙利语,可以将许多子词组合在一起而不使用空格,创建包含许多独立信息片段的非常长的单词。
为了处理这些情况,通常最好使用子词标记化。这个过程分为两步:
-
分析一组文档以找到最常出现的字母组。这些将成为词汇表。
-
使用这个子词单元的词汇对语料库进行标记化。
让我们看一个例子。对于我们的语料库,我们将使用前 2,000 条电影评论:
txts = L(o.open().read() for o in files[:2000])
我们实例化我们的标记器,传入我们想要创建的词汇表的大小,然后我们需要“训练”它。也就是说,我们需要让它阅读我们的文档并找到常见的字符序列以创建词汇表。这是通过setup
完成的。正如我们将很快看到的,setup
是一个特殊的 fastai 方法,在我们通常的数据处理流程中会自动调用。然而,由于目前我们正在手动执行所有操作,因此我们必须自己调用它。这是一个为给定词汇表大小执行这些步骤并显示示例输出的函数:
def subword(sz):
sp = SubwordTokenizer(vocab_sz=sz)
sp.setup(txts)
return ' '.join(first(sp([txt]))[:40])
让我们试一试:
subword(1000)
'▁This ▁movie , ▁which ▁I ▁just ▁dis c over ed ▁at ▁the ▁video ▁st or e , ▁has
> ▁a p par ent ly ▁s it ▁around ▁for ▁a ▁couple ▁of ▁years ▁without ▁a ▁dis t
> ri but or . ▁It'
使用 fastai 的子词标记器时,特殊字符▁
代表原始文本中的空格字符。
如果我们使用较小的词汇表,每个标记将代表更少的字符,并且需要更多的标记来表示一个句子:
subword(200)
'▁ T h i s ▁movie , ▁w h i ch ▁I ▁ j us t ▁ d i s c o ver ed ▁a t ▁the ▁ v id e
> o ▁ st or e , ▁h a s'
另一方面,如果我们使用较大的词汇表,大多数常见的英语单词将最终出现在词汇表中,我们将不需要那么多来表示一个句子:
subword(10000)
"▁This ▁movie , ▁which ▁I ▁just ▁discover ed ▁at ▁the ▁video ▁store , ▁has
> ▁apparently ▁sit ▁around ▁for ▁a ▁couple ▁of ▁years ▁without ▁a ▁distributor
> . ▁It ' s ▁easy ▁to ▁see ▁why . ▁The ▁story ▁of ▁two ▁friends ▁living"
选择子词词汇表大小代表一种折衷:较大的词汇表意味着每个句子的标记较少,这意味着训练速度更快,内存更少,并且模型需要记住的状态更少;但是,缺点是,这意味着更大的嵌入矩阵,这需要更多的数据来学习。
总的来说,子词标记化提供了一种在字符标记化(即使用较小的子词词汇表)和单词标记化(即使用较大的子词词汇表)之间轻松切换的方法,并且处理每种人类语言而无需开发特定于语言的算法。它甚至可以处理其他“语言”,如基因组序列或 MIDI 音乐符号!因此,过去一年中,它的流行度飙升,似乎很可能成为最常见的标记化方法(当您阅读本文时,它可能已经是了!)。
一旦我们的文本被分割成标记,我们需要将它们转换为数字。我们将在下一步中看到这一点。
使用 fastai 进行数字化
数字化是将标记映射到整数的过程。这些步骤基本上与创建Category
变量所需的步骤相同,例如 MNIST 中数字的因变量:
-
制作该分类变量的所有可能级别的列表(词汇表)。
-
用词汇表中的索引替换每个级别。
让我们看看在之前看到的单词标记化文本上的实际操作:
toks = tkn(txt)
print(coll_repr(tkn(txt), 31))
(#228) ['xxbos','xxmaj','this','movie',',','which','i','just','discovered','at',
> 'the','video','store',',','has','apparently','sit','around','for','a','couple
> ','of','years','without','a','distributor','.','xxmaj','it',"'s",'easy'...]
就像SubwordTokenizer
一样,我们需要在Numericalize
上调用setup
;这是我们创建词汇表的方法。这意味着我们首先需要我们的标记化语料库。由于标记化需要一段时间,fastai 会并行进行;但是对于这个手动演示,我们将使用一个小的子集:
toks200 = txts[:200].map(tkn)
toks200[0]
(#228)
> ['xxbos','xxmaj','this','movie',',','which','i','just','discovered','at'...]
我们可以将这个传递给setup
来创建我们的词汇表:
num = Numericalize()
num.setup(toks200)
coll_repr(num.vocab,20)
"(#2000) ['xxunk','xxpad','xxbos','xxeos','xxfld','xxrep','xxwrep','xxup','xxmaj
> ','the','.',',','a','and','of','to','is','in','i','it'...]"
我们的特殊规则标记首先出现,然后每个单词按频率顺序出现一次。Numericalize
的默认值为min_freq=3
和max_vocab=60000
。max_vocab=60000
导致 fastai 用特殊的未知单词标记xxunk
替换除最常见的 60,000 个单词之外的所有单词。这有助于避免过大的嵌入矩阵,因为这可能会减慢训练速度并占用太多内存,并且还可能意味着没有足够的数据来训练稀有单词的有用表示。然而,通过设置min_freq
来处理最后一个问题更好;默认值min_freq=3
意味着出现少于三次的任何单词都将被替换为xxunk
。
fastai 还可以使用您提供的词汇表对数据集进行数字化,方法是将单词列表作为vocab
参数传递。
一旦我们创建了我们的Numericalize
对象,我们可以像使用函数一样使用它:
nums = num(toks)[:20]; nums
tensor([ 2, 8, 21, 28, 11, 90, 18, 59, 0, 45, 9, 351, 499, 11,
> 72, 533, 584, 146, 29, 12])
这一次,我们的标记已经转换为模型可以接收的整数张量。我们可以检查它们是否映射回原始文本:
' '.join(num.vocab[o] for o in nums)
'xxbos xxmaj this movie , which i just xxunk at the video store , has apparently
> sit around for a'
| xxbos | xxmaj | 在 | 这个 | 章节 | , | 我们 | 将 | 回顾 | 一下 | 分类 | 的 | 例子 |
回到我们之前的例子,有 6 个长度为 15 的批次,如果我们选择序列长度为 5,那意味着我们首先输入以下数组:
处理图像时,我们需要将它们全部调整为相同的高度和宽度,然后将它们组合在一起形成一个小批次,以便它们可以有效地堆叠在一个张量中。这里会有一点不同,因为不能简单地将文本调整为所需的长度。此外,我们希望我们的语言模型按顺序阅读文本,以便它可以有效地预测下一个单词是什么。这意味着每个新批次应该从上一个批次结束的地方开始。
假设我们有以下文本:
在这一章中,我们将回顾我们在第一章中学习的分类电影评论的例子,并深入挖掘。首先,我们将看一下将文本转换为数字所需的处理步骤以及如何自定义它。通过这样做,我们将有另一个使用数据块 API 中的预处理器的例子。
然后我们将学习如何构建一个语言模型并训练它一段时间。
标记化过程将添加特殊标记并处理标点以返回这个文本:
xxbos 在这一章中,我们将回顾我们在第一章中学习的分类电影评论的例子,并深入挖掘。首先,我们将看一下将文本转换为数字所需的处理步骤以及如何自定义它。通过这样做,我们将有另一个使用数据块 API 中的预处理器的例子。
现在我们有 90 个标记,用空格分隔。假设我们想要一个批次大小为 6。我们需要将这个文本分成 6 个长度为 15 的连续部分:
转换 | 文本 | 为 | 数字 | 和 |
---|---|---|---|---|
电影 | 评论 | 我们 | 研究 | 在 |
首先 | 我们 | 将 | 看 | 处理 |
如何 | 自定义 | 它 | 。 | 通过 |
预处理器 | 在 | 数据 | 块 | xxup |
将 | 学习 | 我们 | 如何 | 构建 |
在理想的情况下,我们可以将这一个批次提供给我们的模型。但这种方法不具有可扩展性,因为在这个玩具示例之外,一个包含所有标记的单个批次不太可能适合我们的 GPU 内存(这里有 90 个标记,但所有 IMDb 评论一起给出了数百万个)。
因此,我们需要将这个数组更细地划分为固定序列长度的子数组。在这些子数组内部和之间保持顺序非常重要,因为我们将使用一个保持状态的模型,以便在预测接下来的内容时记住之前读到的内容。
| 语言 | 模型 | 和 | 训练 |
xxbos | xxmaj | 在 | 这个 | 章节 |
---|---|---|---|---|
分类 | 的 | 例子 | 在 | 电影 |
首先 | 我们 | 将 | 看 | 到 |
如何 | 自定义 | 它 | 。 | |
预处理器 | 使用 | 在 | ||
将 | 学习 | 我们 | 如何 | 构建 |
然后,这一个:
, | 我们 | 将 | 回顾 |
---|---|---|---|
章节 | 1 | 和 | 深入 |
处理 | 步骤 | 必要 | 将 |
通过 | 这样做 | , | |
数据 | 块 | xxup | api |
现在我们有了数字,我们需要将它们分批放入模型中。 |
最后:
将我们的文本放入语言模型的批次中 |
---|
更深 |
将文本转换为数字,并按行翻译成中文: |
我们 |
。 |
它 |
回到我们的电影评论数据集,第一步是通过将各个文本串联在一起将其转换为流。与图像一样,最好随机化输入的顺序,因此在每个时期的开始,我们将对条目进行洗牌以生成新的流(我们对文档的顺序进行洗牌,而不是其中的单词顺序,否则文本将不再有意义!)。
然后将此流切成一定数量的批次(这是我们的批量大小)。例如,如果流有 50,000 个标记,我们设置批量大小为 10,这将给我们 5,000 个标记的 10 个小流。重要的是我们保留标记的顺序(因此从 1 到 5,000 为第一个小流,然后从 5,001 到 10,000…),因为我们希望模型读取连续的文本行(如前面的示例)。在预处理期间,在每个文本的开头添加一个xxbos
标记,以便模型知道当读取流时新条目何时开始。
因此,总结一下,每个时期我们都会对文档集合进行洗牌,并将它们连接成一个标记流。然后将该流切成一批固定大小的连续小流。我们的模型将按顺序读取小流,并由于内部状态,无论我们选择的序列长度如何,它都将产生相同的激活。
当我们创建LMDataLoader
时,所有这些都是由 fastai 库在幕后完成的。我们首先将我们的Numericalize
对象应用于标记化的文本
nums200 = toks200.map(num)
然后将其传递给LMDataLoader
:
dl = LMDataLoader(nums200)
让我们通过获取第一批来确认这是否给出了预期的结果
x,y = first(dl)
x.shape,y.shape
(torch.Size([64, 72]), torch.Size([64, 72]))
然后查看独立变量的第一行,这应该是第一个文本的开头:
' '.join(num.vocab[o] for o in x[0][:20])
'xxbos xxmaj this movie , which i just xxunk at the video store , has apparently
> sit around for a'
依赖变量是相同的,只是偏移了一个标记:
' '.join(num.vocab[o] for o in y[0][:20])
'xxmaj this movie , which i just xxunk at the video store , has apparently sit
> around for a couple'
这就完成了我们需要对数据应用的所有预处理步骤。我们现在准备训练我们的文本分类器。
训练文本分类器
正如我们在本章开头看到的那样,使用迁移学习训练最先进的文本分类器有两个步骤:首先,我们需要微调在 Wikipedia 上预训练的语言模型以适应 IMDb 评论的语料库,然后我们可以使用该模型来训练分类器。
像往常一样,让我们从组装数据开始。
使用 DataBlock 的语言模型
当TextBlock
传递给DataBlock
时,fastai 会自动处理标记化和数值化。所有可以传递给Tokenizer
和Numericalize
的参数也可以传递给TextBlock
。在下一章中,我们将讨论分别运行每个步骤的最简单方法,以便进行调试,但您也可以通过在数据的子集上手动运行它们来进行调试,如前几节所示。不要忘记DataBlock
的方便的summary
方法,用于调试数据问题非常有用。
这是我们如何使用TextBlock
使用 fastai 的默认值创建语言模型的方式:
get_imdb = partial(get_text_files, folders=['train', 'test', 'unsup'])
dls_lm = DataBlock(
blocks=TextBlock.from_folder(path, is_lm=True),
get_items=get_imdb, splitter=RandomSplitter(0.1)
).dataloaders(path, path=path, bs=128, seq_len=80)
与我们在DataBlock
中使用的以前类型不同的一件事是,我们不仅仅直接使用类(即TextBlock(...)
,而是调用类方法。类方法是 Python 方法,如其名称所示,属于类而不是对象。(如果您对类方法不熟悉,请务必在网上搜索更多信息,因为它们在许多 Python 库和应用程序中常用;我们在本书中以前使用过几次,但没有特别提到。)TextBlock
之所以特殊是因为设置数值化器的词汇表可能需要很长时间(我们必须读取和标记化每个文档以获取词汇表)。
为了尽可能高效,fastai 执行了一些优化:
-
它将标记化的文档保存在临时文件夹中,因此不必多次对其进行标记化。
-
它并行运行多个标记化过程,以利用计算机的 CPU。
我们需要告诉TextBlock
如何访问文本,以便它可以进行这种初始预处理——这就是from_folder
的作用。
show_batch
然后以通常的方式工作:
dls_lm.show_batch(max_n=2)
text | text_ | |
---|---|---|
0 | xxbos xxmaj it ’s awesome ! xxmaj in xxmaj story xxmaj mode , your going from punk to pro . xxmaj you have to complete goals that involve skating , driving , and walking . xxmaj you create your own skater and give it a name , and you can make it look stupid or realistic . xxmaj you are with your friend xxmaj eric throughout the game until he betrays you and gets you kicked off of the skateboard | xxmaj it ’s awesome ! xxmaj in xxmaj story xxmaj mode , your going from punk to pro . xxmaj you have to complete goals that involve skating , driving , and walking . xxmaj you create your own skater and give it a name , and you can make it look stupid or realistic . xxmaj you are with your friend xxmaj eric throughout the game until he betrays you and gets you kicked off of the skateboard xxunk |
1 | what xxmaj i ‘ve read , xxmaj death xxmaj bed is based on an actual dream , xxmaj george xxmaj barry , the director , successfully transferred dream to film , only a genius could accomplish such a task . \n\n xxmaj old mansions make for good quality horror , as do portraits , not sure what to make of the killer bed with its killer yellow liquid , quite a bizarre dream , indeed . xxmaj also , this | xxmaj i ‘ve read , xxmaj death xxmaj bed is based on an actual dream , xxmaj george xxmaj barry , the director , successfully transferred dream to film , only a genius could accomplish such a task . \n\n xxmaj old mansions make for good quality horror , as do portraits , not sure what to make of the killer bed with its killer yellow liquid , quite a bizarre dream , indeed . xxmaj also , this is |
现在我们的数据准备好了,我们可以对预训练语言模型进行微调。
微调语言模型
将整数单词索引转换为我们可以用于神经网络的激活时,我们将使用嵌入,就像我们在协同过滤和表格建模中所做的那样。然后,我们将把这些嵌入馈送到递归神经网络(RNN)中,使用一种称为AWD-LSTM的架构(我们将在第十二章中向您展示如何从头开始编写这样一个模型)。正如我们之前讨论的,预训练模型中的嵌入与为不在预训练词汇表中的单词添加的随机嵌入合并。这在language_model_learner
内部自动处理:
learn = language_model_learner(
dls_lm, AWD_LSTM, drop_mult=0.3,
metrics=[accuracy, Perplexity()]).to_fp16()
默认使用的损失函数是交叉熵损失,因为我们基本上有一个分类问题(不同类别是我们词汇表中的单词)。这里使用的困惑度指标通常用于 NLP 的语言模型:它是损失的指数(即torch.exp(cross_entropy)
)。我们还包括准确性指标,以查看我们的模型在尝试预测下一个单词时有多少次是正确的,因为交叉熵(正如我们所见)很难解释,并且更多地告诉我们有关模型信心而不是准确性。
让我们回到本章开头的流程图。第一个箭头已经为我们完成,并作为 fastai 中的预训练模型提供,我们刚刚构建了第二阶段的DataLoaders
和Learner
。现在我们准备好对我们的语言模型进行微调!
每个时代的训练需要相当长的时间,因此我们将在训练过程中保存中间模型结果。由于fine_tune
不会为我们执行此操作,因此我们将使用fit_one_cycle
。就像cnn_learner
一样,当使用预训练模型(这是默认设置)时,language_model_learner
在使用时会自动调用freeze
,因此这将仅训练嵌入(模型中唯一包含随机初始化权重的部分——即我们 IMDb 词汇表中存在但不在预训练模型词汇表中的单词的嵌入):
learn.fit_one_cycle(1, 2e-2)
epoch | train_loss | valid_loss | accuracy | perplexity | time |
---|---|---|---|---|---|
0 | 4.120048 | 3.912788 | 0.299565 | 50.038246 | 11:39 |
这个模型训练时间较长,所以现在是谈论保存中间结果的好机会。
保存和加载模型
您可以轻松保存模型的状态如下:
learn.save('1epoch')
这将在 learn.path/models/ 中创建一个名为 1epoch.pth 的文件。如果您想在另一台机器上加载模型,或者稍后恢复训练,可以按照以下方式加载此文件的内容:
learn = learn.load('1epoch')
一旦初始训练完成,我们可以在解冻后继续微调模型:
learn.unfreeze()
learn.fit_one_cycle(10, 2e-3)
epoch | train_loss | valid_loss | accuracy | perplexity | time |
---|---|---|---|---|---|
0 | 3.893486 | 3.772820 | 0.317104 | 43.502548 | 12:37 |
1 | 3.820479 | 3.717197 | 0.323790 | 41.148880 | 12:30 |
2 | 3.735622 | 3.659760 | 0.330321 | 38.851997 | 12:09 |
3 | 3.677086 | 3.624794 | 0.333960 | 37.516987 | 12:12 |
4 | 3.636646 | 3.601300 | 0.337017 | 36.645859 | 12:05 |
5 | 3.553636 | 3.584241 | 0.339355 | 36.026001 | 12:04 |
6 | 3.507634 | 3.571892 | 0.341353 | 35.583862 | 12:08 |
7 | 3.444101 | 3.565988 | 0.342194 | 35.374371 | 12:08 |
8 | 3.398597 | 3.566283 | 0.342647 | 35.384815 | 12:11 |
9 | 3.375563 | 3.568166 | 0.342528 | 35.451500 | 12:05 |
完成后,我们保存所有模型,除了将激活转换为在我们的词汇表中选择每个标记的概率的最终层。不包括最终层的模型称为编码器。我们可以使用 save_encoder
来保存它:
learn.save_encoder('finetuned')
术语:编码器
不包括任务特定的最终层。当应用于视觉 CNN 时,这个术语与“主体”几乎意思相同,但在 NLP 和生成模型中更常用“编码器”。
这完成了文本分类过程的第二阶段:微调语言模型。我们现在可以使用它来微调一个分类器,使用 IMDb 的情感标签。然而,在继续微调分类器之前,让我们快速尝试一些不同的东西:使用我们的模型生成随机评论。
文本生成
因为我们的模型经过训练可以猜测句子的下一个单词,所以我们可以用它来写新评论:
TEXT = "I liked this movie because"
N_WORDS = 40
N_SENTENCES = 2
preds = [learn.predict(TEXT, N_WORDS, temperature=0.75)
for _ in range(N_SENTENCES)]
print("\n".join(preds))
i liked this movie because of its story and characters . The story line was very
> strong , very good for a sci - fi film . The main character , Alucard , was
> very well developed and brought the whole story
i liked this movie because i like the idea of the premise of the movie , the (
> very ) convenient virus ( which , when you have to kill a few people , the "
> evil " machine has to be used to protect
正如您所看到的,我们添加了一些随机性(我们根据模型返回的概率选择一个随机单词),这样我们就不会得到完全相同的评论两次。我们的模型没有任何关于句子结构或语法规则的编程知识,但它显然已经学会了很多关于英语句子:我们可以看到它正确地大写了(I 被转换为 i,因为我们的规则要求两个字符或更多才能认为一个单词是大写的,所以看到它小写是正常的)并且使用一致的时态。一般的评论乍一看是有意义的,只有仔细阅读时才能注意到有些地方有点不对。对于在几个小时内训练的模型来说,这还不错!
但我们的最终目标不是训练一个生成评论的模型,而是对其进行分类...所以让我们使用这个模型来做到这一点。
创建分类器数据加载器
我们现在从语言模型微调转向分类器微调。简而言之,语言模型预测文档的下一个单词,因此不需要任何外部标签。然而,分类器预测外部标签——在 IMDb 的情况下,是文档的情感。
这意味着我们用于 NLP 分类的 DataBlock
结构看起来非常熟悉。它几乎与我们为许多图像分类数据集看到的相同:
dls_clas = DataBlock(
blocks=(TextBlock.from_folder(path, vocab=dls_lm.vocab),CategoryBlock),
get_y = parent_label,
get_items=partial(get_text_files, folders=['train', 'test']),
splitter=GrandparentSplitter(valid_name='test')
).dataloaders(path, path=path, bs=128, seq_len=72)
就像图像分类一样,show_batch
显示了依赖变量(情感,在这种情况下)与每个独立变量(电影评论文本):
dls_clas.show_batch(max_n=3)
文本 | 类别 | |
---|---|---|
0 | xxbos 我给这部电影打了 3 颗头骨的评分,只是因为女孩们知道如何尖叫,这部电影本可以更好,如果演员更好的话,双胞胎还行,我相信他们是邪恶的,但是最大和最小的兄弟,他们表现得真的很糟糕,看起来他们在读剧本而不是表演……。剧透:如果他们是吸血鬼,为什么他们会冻结血液?吸血鬼不能喝冻结的血液,电影中的姐姐说让我们在她活着的时候喝她……。但是当他们搬到另一栋房子时,他们带了一个冷藏盒装着他们的冻结血液。剧透结束\n\n 这是浪费时间,这让我很生气,因为我读了所有关于它的评论 | neg |
1 | xxbos 我已经阅读了所有的《爱来的方式》系列书籍。我充分了解电影无法使用书中的所有方面,但通常它们至少会有书中的主要内容。我对这部电影感到非常失望。这部电影中唯一与书中相同的是,书中有 xxmaj missy 的父亲来到 xxunk (在书中父母都来了)。就是这样。故事情节扭曲且牵强,是的,悲伤,与书中完全不同,我无法享受。即使我没有读过这本书,它也太悲伤了。我知道拓荒生活很艰难,但整部电影都是一个沮丧的故事。评分 | neg |
2 | xxbos 这部电影,用一个更好的词来说,很糟糕。我从哪里开始呢……\n\n 电影摄影 - 这或许是我今年看过的最糟糕的。看起来就像摄影师之间在互相抛接相机。也许他们只有一台相机。这让你感觉像是一个排球。\n\n 有一堆场景,零零散散地扔进去,完全没有连贯性。当他们做 '分屏' 时,那是荒谬的。一切都被压扁了,看起来荒谬。颜色调整完全错了。这些人需要学会如何平衡相机。这部 '电影' 制作很差, | neg |
从 DataBlock
的定义来看,每个部分都与我们构建的先前数据块相似,但有两个重要的例外:
-
TextBlock.from_folder
不再具有is_lm=True
参数。 -
我们传递了为语言模型微调创建的
vocab
。
我们传递语言模型的 vocab
是为了确保我们使用相同的标记到索引的对应关系。否则,我们在微调语言模型中学到的嵌入对这个模型没有任何意义,微调步骤也没有任何用处。
通过传递 is_lm=False
(或者根本不传递 is_lm
,因为它默认为 False
),我们告诉 TextBlock
我们有常规标记的数据,而不是将下一个标记作为标签。然而,我们必须处理一个挑战,这与将多个文档合并成一个小批次有关。让我们通过一个示例来看,尝试创建一个包含前 10 个文档的小批次。首先我们将它们数值化:
nums_samp = toks200[:10].map(num)
现在让我们看看这 10 条电影评论中每条有多少个标记:
nums_samp.map(len)
(#10) [228,238,121,290,196,194,533,124,581,155]
记住,PyTorch 的 DataLoader
需要将批次中的所有项目整合到一个张量中,而一个张量具有固定的形状(即,每个轴上都有特定的长度,并且所有项目必须一致)。这应该听起来很熟悉:我们在图像中也遇到了同样的问题。在那种情况下,我们使用裁剪、填充和/或压缩来使所有输入大小相同。对于文档来说,裁剪可能不是一个好主意,因为我们可能会删除一些关键信息(话虽如此,对于图像也是同样的问题,我们在那里使用裁剪;数据增强在自然语言处理领域尚未得到很好的探索,因此也许在自然语言处理中也有使用裁剪的机会!)。你不能真正“压缩”一个文档。所以只剩下填充了!
我们将扩展最短的文本以使它们都具有相同的大小。为此,我们使用一个特殊的填充标记,该标记将被我们的模型忽略。此外,为了避免内存问题并提高性能,我们将大致相同长度的文本批量处理在一起(对于训练集进行一些洗牌)。我们通过在每个时期之前(对于训练集)按长度对文档进行排序来实现这一点。结果是,整理成单个批次的文档往往具有相似的长度。我们不会将每个批次填充到相同的大小,而是使用每个批次中最大文档的大小作为目标大小。
动态调整图像大小
可以对图像执行类似的操作,这对于不规则大小的矩形图像特别有用,但在撰写本文时,尚无库提供良好的支持,也没有任何涵盖此内容的论文。然而,我们计划很快将其添加到 fastai 中,因此请关注本书的网站;一旦我们成功运行,我们将添加有关此内容的信息。
当使用TextBlock
和is_lm=False
时,数据块 API 会自动为我们进行排序和填充。(对于语言模型数据,我们不会遇到这个问题,因为我们首先将所有文档连接在一起,然后将它们分成相同大小的部分。)
我们现在可以创建一个用于分类文本的模型:
learn = text_classifier_learner(dls_clas, AWD_LSTM, drop_mult=0.5,
metrics=accuracy).to_fp16()
在训练分类器之前的最后一步是从我们微调的语言模型中加载编码器。我们使用load_encoder
而不是load
,因为我们只有编码器的预训练权重可用;load
默认情况下会在加载不完整的模型时引发异常:
learn = learn.load_encoder('finetuned')
微调分类器
最后一步是使用有区分性的学习率和逐步解冻进行训练。在计算机视觉中,我们经常一次性解冻整个模型,但对于 NLP 分类器,我们发现逐层解冻会产生真正的差异:
learn.fit_one_cycle(1, 2e-2)
epoch | train_loss | valid_loss | accuracy | time |
---|---|---|---|---|
0 | 0.347427 | 0.184480 | 0.929320 | 00:33 |
仅仅一个时期,我们就获得了与第一章中的训练相同的结果——还不错!我们可以将freeze_to
设置为-2
,以冻结除最后两个参数组之外的所有参数组:
learn.freeze_to(-2)
learn.fit_one_cycle(1, slice(1e-2/(2.6**4),1e-2))
epoch | train_loss | valid_loss | accuracy | time |
---|---|---|---|---|
0 | 0.247763 | 0.171683 | 0.934640 | 00:37 |
然后我们可以解冻更多层并继续训练:
learn.freeze_to(-3)
learn.fit_one_cycle(1, slice(5e-3/(2.6**4),5e-3))
epoch | train_loss | valid_loss | accuracy | time |
---|---|---|---|---|
0 | 0.193377 | 0.156696 | 0.941200 | 00:45 |
最后,整个模型!
learn.unfreeze()
learn.fit_one_cycle(2, slice(1e-3/(2.6**4),1e-3))
epoch | train_loss | valid_loss | accuracy | time |
---|---|---|---|---|
0 | 0.172888 | 0.153770 | 0.943120 | 01:01 |
1 | 0.161492 | 0.155567 | 0.942640 | 00:57 |
我们达到了 94.3%的准确率,这在仅仅三年前是最先进的性能。通过在所有文本上训练另一个模型,并对这两个模型的预测进行平均,我们甚至可以达到 95.1%的准确率,这是由 ULMFiT 论文引入的最先进技术。仅仅几个月前,通过微调一个更大的模型并使用昂贵的数据增强技术(将句子翻译成另一种语言,然后再翻译回来,使用另一个模型进行翻译)来打破了这一记录。
使用预训练模型让我们构建了一个非常强大的微调语言模型,可以用来生成假评论或帮助对其进行分类。这是令人兴奋的事情,但要记住这项技术也可以被用于恶意目的。
虚假信息和语言模型
即使是基于规则的简单算法,在广泛使用深度学习语言模型之前,也可以用来创建欺诈账户并试图影响决策者。ProPublica 的计算记者 Jeff Kao 分析了发送给美国联邦通信委员会(FCC)有关 2017 年废除网络中立提案的评论。在他的文章“可能伪造了一百多万条支持废除网络中立的评论”中,他报告了他是如何发现一大批反对网络中立的评论,这些评论似乎是通过某种 Mad Libs 风格的邮件合并生成的。在图 10-2 中,Kao 已经帮助着色编码了这些虚假评论,以突出它们的公式化特性。
图 10-2. FCC 在网络中立辩论期间收到的评论
Kao 估计“2200 多万条评论中不到 80 万条...可以被认为是真正独特的”,“超过 99%的真正独特评论支持保持网络中立”。
鉴于自 2017 年以来语言建模的进展,这种欺诈性活动现在几乎不可能被发现。您现在拥有所有必要的工具来创建一个引人注目的语言模型 - 可以生成与上下文相关、可信的文本。它不一定会完全准确或正确,但它会是可信的。想象一下,当这种技术与我们近年来了解到的各种虚假信息活动结合在一起时会意味着什么。看看 Reddit 对话中显示的图 10-3,其中基于 OpenAI 的 GPT-2 算法的语言模型正在讨论美国政府是否应该削减国防开支。
图 10-3. Reddit 上的算法自言自语
在这种情况下,解释了正在使用算法生成对话。但想象一下,如果一个坏演员决定在社交网络上发布这样的算法会发生什么 - 他们可以慢慢而谨慎地这样做,让算法随着时间逐渐发展出追随者和信任。要做到这一点并不需要太多资源,就可以让成千上万的账户这样做。在这种情况下,我们很容易想象到在线讨论的绝大部分都是来自机器人,而没有人会意识到这种情况正在发生。
我们已经开始看到机器学习被用来生成身份的例子。例如,图 10-4 显示了 Katie Jones 的 LinkedIn 个人资料。
图 10-4. Katie Jones 的 LinkedIn 个人资料
Katie Jones 在 LinkedIn 上与几位主流华盛顿智库成员有联系。但她并不存在。你看到的那张图片是由生成对抗网络自动生成的,而某人名为 Katie Jones 的确没有毕业于战略与国际研究中心。
许多人假设或希望算法将在这里为我们辩护 - 我们将开发能够自动识别自动生成内容的分类算法。然而,问题在于这将永远是一场军备竞赛,更好的分类(或鉴别器)算法可以用来创建更好的生成算法。
结论
在本章中,我们探讨了 fastai 库中提供的最后一个开箱即用的应用:文本。我们看到了两种类型的模型:可以生成文本的语言模型,以及可以确定评论是积极还是消极的分类器。为了构建一个最先进的分类器,我们使用了一个预训练的语言模型,对其进行微调以适应我们任务的语料库,然后使用其主体(编码器)与一个新的头部进行分类。
在结束本书的这一部分之前,我们将看看 fastai 库如何帮助您为您的特定问题组装数据。
问卷
-
什么是自监督学习?
-
什么是语言模型?
-
为什么语言模型被认为是自监督的?
-
自监督模型通常用于什么?
-
为什么我们要微调语言模型?
-
创建一流文本分类器的三个步骤是什么?
-
50,000 个未标记的电影评论如何帮助为 IMDb 数据集创建更好的文本分类器?
-
为语言模型准备数据的三个步骤是什么?
-
什么是标记化?为什么我们需要它?
-
列出三种标记化方法。
-
什么是
xxbos
? -
列出 fastai 在标记化期间应用的四条规则。
-
为什么重复字符被替换为一个显示重复次数和被重复的字符的标记?
-
什么是数值化?
-
为什么会有单词被替换为“未知单词”标记?
-
使用批量大小为 64,表示第一批次的张量的第一行包含数据集的前 64 个标记。那个张量的第二行包含什么?第二批次的第一行包含什么?(小心 - 学生经常答错这个问题!一定要在书的网站上检查你的答案。)
-
为什么文本分类需要填充?为什么语言建模不需要填充?
-
NLP 的嵌入矩阵包含什么?它的形状是什么?
-
什么是困惑度?
-
为什么我们必须将语言模型的词汇传递给分类器数据块?
-
什是逐步解冻?
-
为什么文本生成总是可能领先于自动识别机器生成的文本?
进一步研究
-
看看你能学到关于语言模型和虚假信息的什么。今天最好的语言模型是什么?看看它们的一些输出。你觉得它们令人信服吗?坏人如何最好地利用这样的模型来制造冲突和不确定性?
-
考虑到模型不太可能能够一致地识别机器生成的文本,可能需要哪些其他方法来处理利用深度学习的大规模虚假信息活动?
第十一章:使用 fastai 的中级 API 进行数据整理
我们已经看到了Tokenizer
和Numericalize
对文本集合的处理方式,以及它们如何在数据块 API 中使用,该 API 直接使用TextBlock
处理这些转换。但是,如果我们只想应用这些转换中的一个,要么是为了查看中间结果,要么是因为我们已经对文本进行了标记化,我们该怎么办?更一般地说,当数据块 API 不足以满足我们特定用例的需求时,我们需要使用 fastai 的中级 API来处理数据。数据块 API 是建立在该层之上的,因此它将允许您执行数据块 API 所做的一切,以及更多更多。
深入了解 fastai 的分层 API
fastai 库是建立在分层 API上的。在最顶层是应用程序,允许我们在五行代码中训练模型,正如我们在第一章中看到的。例如,对于为文本分类器创建DataLoaders
,我们使用了这一行:
from fastai.text.all import *
dls = TextDataLoaders.from_folder(untar_data(URLs.IMDB), valid='test')
当您的数据排列方式与 IMDb 数据集完全相同时,工厂方法TextDataLoaders.from_folder
非常方便,但实际上,情况通常不会如此。数据块 API 提供了更多的灵活性。正如我们在前一章中看到的,我们可以通过以下方式获得相同的结果:
path = untar_data(URLs.IMDB)
dls = DataBlock(
blocks=(TextBlock.from_folder(path),CategoryBlock),
get_y = parent_label,
get_items=partial(get_text_files, folders=['train', 'test']),
splitter=GrandparentSplitter(valid_name='test')
).dataloaders(path)
但有时它并不够灵活。例如,出于调试目的,我们可能需要仅应用与此数据块一起的部分转换。或者我们可能希望为 fastai 不直接支持的应用程序创建一个DataLoaders
。在本节中,我们将深入探讨 fastai 内部用于实现数据块 API 的组件。了解这些将使您能够利用这个中间层 API 的强大和灵活性。
中级 API
中级 API 不仅包含用于创建DataLoaders
的功能。它还具有回调系统,允许我们以任何我们喜欢的方式自定义训练循环,以及通用优化器。这两者将在第十六章中介绍。
转换
在前一章中研究标记化和数值化时,我们首先获取了一堆文本:
files = get_text_files(path, folders = ['train', 'test'])
txts = L(o.open().read() for o in files[:2000])
然后我们展示了如何使用Tokenizer
对它们进行标记化
tok = Tokenizer.from_folder(path)
tok.setup(txts)
toks = txts.map(tok)
toks[0]
(#374) ['xxbos','xxmaj','well',',','"','cube','"','(','1997',')'...]
以及如何进行数值化,包括自动为我们的语料库创建词汇表:
num = Numericalize()
num.setup(toks)
nums = toks.map(num)
nums[0][:10]
tensor([ 2, 8, 76, 10, 23, 3112, 23, 34, 3113, 33])
这些类还有一个decode
方法。例如,Numericalize.decode
会将字符串标记返回给我们:
nums_dec = num.decode(nums[0][:10]); nums_dec
(#10) ['xxbos','xxmaj','well',',','"','cube','"','(','1997',')']
Tokenizer.decode
将其转换回一个字符串(但可能不完全与原始字符串相同;这取决于标记器是否是可逆的,在我们撰写本书时,默认的单词标记器不是):
tok.decode(nums_dec)
'xxbos xxmaj well , " cube " ( 1997 )'
decode
被 fastai 的show_batch
和show_results
以及其他一些推断方法使用,将预测和小批量转换为人类可理解的表示。
在前面的示例中,对于tok
或num
,我们创建了一个名为setup
的对象(如果需要为tok
训练标记器并为num
创建词汇表),将其应用于我们的原始文本(通过将对象作为函数调用),然后最终将结果解码回可理解的表示。大多数数据预处理任务都需要这些步骤,因此 fastai 提供了一个封装它们的类。这就是Transform
类。Tokenize
和Numericalize
都是Transform
。
一般来说,Transform
是一个行为类似于函数的对象,它具有一个可选的setup
方法,用于初始化内部状态(例如num
内部的词汇表),以及一个可选的decode
方法,用于反转函数(正如我们在tok
中看到的那样,这种反转可能不完美)。
decode
的一个很好的例子可以在我们在 第七章 中看到的 Normalize
转换中找到:为了能够绘制图像,它的 decode
方法会撤消归一化(即,乘以标准差并加回均值)。另一方面,数据增强转换没有 decode
方法,因为我们希望展示图像上的效果,以确保数据增强按我们的意愿进行工作。
Transform
的一个特殊行为是它们总是应用于元组。一般来说,我们的数据总是一个元组 (input, target)
(有时有多个输入或多个目标)。当对这样的项目应用转换时,例如 Resize
,我们不希望整个元组被调整大小;相反,我们希望分别调整输入(如果适用)和目标(如果适用)。对于进行数据增强的批处理转换也是一样的:当输入是图像且目标是分割掩模时,需要将转换(以相同的方式)应用于输入和目标。
如果我们将一个文本元组传递给 tok
,我们可以看到这种行为:
tok((txts[0], txts[1]))
((#374) ['xxbos','xxmaj','well',',','"','cube','"','(','1997',')'...],
(#207)
> ['xxbos','xxmaj','conrad','xxmaj','hall','went','out','with','a','bang'...])
编写您自己的转换
如果您想编写一个自定义的转换来应用于您的数据,最简单的方法是编写一个函数。正如您在这个例子中看到的,Transform
只会应用于匹配的类型,如果提供了类型(否则,它将始终被应用)。在下面的代码中,函数签名中的 :int
表示 f
仅应用于 ints
。这就是为什么 tfm(2.0)
返回 2.0
,但 tfm(2)
在这里返回 3
:
def f(x:int): return x+1
tfm = Transform(f)
tfm(2),tfm(2.0)
(3, 2.0)
在这里,f
被转换为一个没有 setup
和没有 decode
方法的 Transform
。
Python 有一种特殊的语法,用于将一个函数(如 f
)传递给另一个函数(或类似函数的东西,在 Python 中称为 callable),称为 decorator。通过在可调用对象前加上 @
并将其放在函数定义之前来使用装饰器(关于 Python 装饰器有很多很好的在线教程,如果这对您来说是一个新概念,请查看其中一个)。以下代码与前面的代码相同:
@Transform
def f(x:int): return x+1
f(2),f(2.0)
(3, 2.0)
如果您需要 setup
或 decode
,您需要对 Transform
进行子类化,以在 encodes
中实现实际的编码行为,然后(可选)在 setups
中实现设置行为和在 decodes
中实现解码行为:
class NormalizeMean(Transform):
def setups(self, items): self.mean = sum(items)/len(items)
def encodes(self, x): return x-self.mean
def decodes(self, x): return x+self.mean
在这里,NormalizeMean
将在设置期间初始化某个状态(传递的所有元素的平均值);然后转换是减去该平均值。为了解码目的,我们通过添加平均值来实现该转换的反向。这里是 NormalizeMean
的一个示例:
tfm = NormalizeMean()
tfm.setup([1,2,3,4,5])
start = 2
y = tfm(start)
z = tfm.decode(y)
tfm.mean,y,z
(3.0, -1.0, 2.0)
请注意,每个方法的调用和实现是不同的:
类 | 调用 | 实现 |
---|---|---|
nn.Module (PyTorch) |
() (即,作为函数调用) |
forward |
Transform |
() |
encodes |
Transform |
decode() |
decodes |
Transform |
setup() |
setups |
因此,例如,您永远不会直接调用 setups
,而是会调用 setup
。原因是 setup
在为您调用 setups
之前和之后做了一些工作。要了解有关 Transform
及如何使用它们根据输入类型实现不同行为的更多信息,请务必查看 fastai 文档中的教程。
Pipeline
要将几个转换组合在一起,fastai 提供了 Pipeline
类。我们通过向 Pipeline
传递一个 Transform
列表来定义一个 Pipeline
;然后它将组合其中的转换。当您在对象上调用 Pipeline
时,它将自动按顺序调用其中的转换:
tfms = Pipeline([tok, num])
t = tfms(txts[0]); t[:20]
tensor([ 2, 8, 76, 10, 23, 3112, 23, 34, 3113, 33, 10, 8,
> 4477, 22, 88, 32, 10, 27, 42, 14])
您可以对编码结果调用 decode
,以获取可以显示和分析的内容:
tfms.decode(t)[:100]
'xxbos xxmaj well , " cube " ( 1997 ) , xxmaj vincenzo \'s first movie , was one
> of the most interesti'
Transform
中与 Transform
不同的部分是设置。要在一些数据上正确设置 Transform
的 Pipeline
,您需要使用 TfmdLists
。
TfmdLists 和 Datasets:转换的集合
您的数据通常是一组原始项目(如文件名或 DataFrame 中的行),您希望对其应用一系列转换。我们刚刚看到,一系列转换在 fastai 中由Pipeline
表示。将这个Pipeline
与您的原始项目组合在一起的类称为TfmdLists
。
TfmdLists
以下是在前一节中看到的转换的简短方式:
tls = TfmdLists(files, [Tokenizer.from_folder(path), Numericalize])
在初始化时,TfmdLists
将自动调用每个Transform
的setup
方法,依次提供每个原始项目而不是由所有先前的Transform
转换的项目。我们可以通过索引到TfmdLists
中的任何原始元素来获得我们的Pipeline
的结果:
t = tls[0]; t[:20]
tensor([ 2, 8, 91, 11, 22, 5793, 22, 37, 4910, 34,
> 11, 8, 13042, 23, 107, 30, 11, 25, 44, 14])
而TfmdLists
知道如何解码以进行显示:
tls.decode(t)[:100]
'xxbos xxmaj well , " cube " ( 1997 ) , xxmaj vincenzo \'s first movie , was one
> of the most interesti'
实际上,它甚至有一个show
方法:
tls.show(t)
xxbos xxmaj well , " cube " ( 1997 ) , xxmaj vincenzo 's first movie , was one
> of the most interesting and tricky ideas that xxmaj i 've ever seen when
> talking about movies . xxmaj they had just one scenery , a bunch of actors
> and a plot . xxmaj so , what made it so special were all the effective
> direction , great dialogs and a bizarre condition that characters had to deal
> like rats in a labyrinth . xxmaj his second movie , " cypher " ( 2002 ) , was
> all about its story , but it was n't so good as " cube " but here are the
> characters being tested like rats again .
" nothing " is something very interesting and gets xxmaj vincenzo coming back
> to his ' cube days ' , locking the characters once again in a very different
> space with no time once more playing with the characters like playing with
> rats in an experience room . xxmaj but instead of a thriller sci - fi ( even
> some of the promotional teasers and trailers erroneous seemed like that ) , "
> nothing " is a loose and light comedy that for sure can be called a modern
> satire about our society and also about the intolerant world we 're living .
> xxmaj once again xxmaj xxunk amaze us with a great idea into a so small kind
> of thing . 2 actors and a blinding white scenario , that 's all you got most
> part of time and you do n't need more than that . xxmaj while " cube " is a
> claustrophobic experience and " cypher " confusing , " nothing " is
> completely the opposite but at the same time also desperate .
xxmaj this movie proves once again that a smart idea means much more than just
> a millionaire budget . xxmaj of course that the movie fails sometimes , but
> its prime idea means a lot and offsets any flaws . xxmaj there 's nothing
> more to be said about this movie because everything is a brilliant surprise
> and a totally different experience that i had in movies since " cube " .
TfmdLists
以“s”命名,因为它可以使用splits
参数处理训练集和验证集。您只需要传递在训练集中的元素的索引和在验证集中的元素的索引:
cut = int(len(files)*0.8)
splits = [list(range(cut)), list(range(cut,len(files)))]
tls = TfmdLists(files, [Tokenizer.from_folder(path), Numericalize],
splits=splits)
然后可以通过train
和valid
属性访问它们:
tls.valid[0][:20]
tensor([ 2, 8, 20, 30, 87, 510, 1570, 12, 408, 379,
> 4196, 10, 8, 20, 30, 16, 13, 12216, 202, 509])
如果您手动编写了一个Transform
,一次执行所有预处理,将原始项目转换为具有输入和目标的元组,那么TfmdLists
是您需要的类。您可以使用dataloaders
方法直接将其转换为DataLoaders
对象。这是我们稍后在本章中将要做的事情。
一般来说,您将有两个(或更多)并行的转换流水线:一个用于将原始项目处理为输入,另一个用于将原始项目处理为目标。例如,在这里,我们定义的流水线仅将原始文本处理为输入。如果我们要进行文本分类,还必须将标签处理为目标。
为此,我们需要做两件事。首先,我们从父文件夹中获取标签名称。有一个名为parent_label
的函数:
lbls = files.map(parent_label)
lbls
(#50000) ['pos','pos','pos','pos','pos','pos','pos','pos','pos','pos'...]
然后我们需要一个Transform
,在设置期间将抓取的唯一项目构建为词汇表,然后在调用时将字符串标签转换为整数。fastai 为我们提供了这个;它被称为Categorize
:
cat = Categorize()
cat.setup(lbls)
cat.vocab, cat(lbls[0])
((#2) ['neg','pos'], TensorCategory(1))
要在我们的文件列表上自动执行整个设置,我们可以像以前一样创建一个TfmdLists
:
tls_y = TfmdLists(files, [parent_label, Categorize()])
tls_y[0]
TensorCategory(1)
但是然后我们得到了两个分开的对象用于我们的输入和目标,这不是我们想要的。这就是Datasets
发挥作用的地方。
Datasets
Datasets
将并行应用两个(或更多)流水线到相同的原始对象,并构建一个包含结果的元组。与TfmdLists
一样,它将自动为我们进行设置,当我们索引到Datasets
时,它将返回一个包含每个流水线结果的元组:
x_tfms = [Tokenizer.from_folder(path), Numericalize]
y_tfms = [parent_label, Categorize()]
dsets = Datasets(files, [x_tfms, y_tfms])
x,y = dsets[0]
x[:20],y
像TfmdLists
一样,我们可以将splits
传递给Datasets
以在训练和验证集之间拆分我们的数据:
x_tfms = [Tokenizer.from_folder(path), Numericalize]
y_tfms = [parent_label, Categorize()]
dsets = Datasets(files, [x_tfms, y_tfms], splits=splits)
x,y = dsets.valid[0]
x[:20],y
(tensor([ 2, 8, 20, 30, 87, 510, 1570, 12, 408, 379,
> 4196, 10, 8, 20, 30, 16, 13, 12216, 202, 509]),
TensorCategory(0))
它还可以解码任何处理过的元组或直接显示它:
t = dsets.valid[0]
dsets.decode(t)
('xxbos xxmaj this movie had horrible lighting and terrible camera movements .
> xxmaj this movie is a jumpy horror flick with no meaning at all . xxmaj the
> slashes are totally fake looking . xxmaj it looks like some 17 year - old
> idiot wrote this movie and a 10 year old kid shot it . xxmaj with the worst
> acting you can ever find . xxmaj people are tired of knives . xxmaj at least
> move on to guns or fire . xxmaj it has almost exact lines from " when a xxmaj
> stranger xxmaj calls " . xxmaj with gruesome killings , only crazy people
> would enjoy this movie . xxmaj it is obvious the writer does n\'t have kids
> or even care for them . i mean at show some mercy . xxmaj just to sum it up ,
> this movie is a " b " movie and it sucked . xxmaj just for your own sake , do
> n\'t even think about wasting your time watching this crappy movie .',
'neg')
最后一步是将我们的Datasets
对象转换为DataLoaders
,可以使用dataloaders
方法完成。在这里,我们需要传递一个特殊参数来解决填充问题(正如我们在前一章中看到的)。这需要在我们批处理元素之前发生,所以我们将其传递给before_batch
:
dls = dsets.dataloaders(bs=64, before_batch=pad_input)
dataloaders
直接在我们的Datasets
的每个子集上调用DataLoader
。fastai 的DataLoader
扩展了 PyTorch 中同名类,并负责将我们的数据集中的项目整理成批次。它有很多自定义点,但您应该知道的最重要的是:
after_item
在数据集中抓取项目后应用于每个项目。这相当于DataBlock
中的item_tfms
。
before_batch
在整理之前应用于项目列表上。这是将项目填充到相同大小的理想位置。
after_batch
在构建后对整个批次应用。这相当于DataBlock
中的batch_tfms
。
最后,这是为了准备文本分类数据所需的完整代码:
tfms = [[Tokenizer.from_folder(path), Numericalize], [parent_label, Categorize]]
files = get_text_files(path, folders = ['train', 'test'])
splits = GrandparentSplitter(valid_name='test')(files)
dsets = Datasets(files, tfms, splits=splits)
dls = dsets.dataloaders(dl_type=SortedDL, before_batch=pad_input)
与之前的代码的两个不同之处是使用GrandparentSplitter
来分割我们的训练和验证数据,以及dl_type
参数。这是告诉dataloaders
使用DataLoader
的SortedDL
类,而不是通常的类。SortedDL
通过将大致相同长度的样本放入批次来构建批次。
这与我们之前的DataBlock
完全相同:
path = untar_data(URLs.IMDB)
dls = DataBlock(
blocks=(TextBlock.from_folder(path),CategoryBlock),
get_y = parent_label,
get_items=partial(get_text_files, folders=['train', 'test']),
splitter=GrandparentSplitter(valid_name='test')
).dataloaders(path)
但现在你知道如何定制每一个部分了!
让我们现在通过一个计算机视觉示例练习刚学到的关于使用这个中级 API 进行数据预处理。
应用中级数据 API:SiamesePair
一个暹罗模型需要两张图片,并且必须确定它们是否属于同一类。在这个例子中,我们将再次使用宠物数据集,并准备数据用于一个模型,该模型将预测两张宠物图片是否属于同一品种。我们将在这里解释如何为这样的模型准备数据,然后我们将在第十五章中训练该模型。
首先要做的是-让我们获取数据集中的图片:
from fastai.vision.all import *
path = untar_data(URLs.PETS)
files = get_image_files(path/"images")
如果我们根本不关心显示我们的对象,我们可以直接创建一个转换来完全预处理那个文件列表。但是我们想要查看这些图片,因此我们需要创建一个自定义类型。当您在TfmdLists
或Datasets
对象上调用show
方法时,它将解码项目,直到达到包含show
方法的类型,并使用它来显示对象。该show
方法会传递一个ctx
,它可以是图像的matplotlib
轴,也可以是文本的 DataFrame 行。
在这里,我们创建了一个SiameseImage
对象,它是Tuple
的子类,旨在包含三个东西:两张图片和一个布尔值,如果图片是同一品种则为True
。我们还实现了特殊的show
方法,使其将两张图片与中间的黑线连接起来。不要太担心if
测试中的部分(这是在 Python 图片而不是张量时显示SiameseImage
的部分);重要的部分在最后三行:
class SiameseImage(Tuple):
def show(self, ctx=None, **kwargs):
img1,img2,same_breed = self
if not isinstance(img1, Tensor):
if img2.size != img1.size: img2 = img2.resize(img1.size)
t1,t2 = tensor(img1),tensor(img2)
t1,t2 = t1.permute(2,0,1),t2.permute(2,0,1)
else: t1,t2 = img1,img2
line = t1.new_zeros(t1.shape[0], t1.shape[1], 10)
return show_image(torch.cat([t1,line,t2], dim=2),
title=same_breed, ctx=ctx)
让我们创建一个第一个SiameseImage
并检查我们的show
方法是否有效:
img = PILImage.create(files[0])
s = SiameseImage(img, img, True)
s.show();
我们也可以尝试一个不属于同一类的第二张图片:
img1 = PILImage.create(files[1])
s1 = SiameseImage(img, img1, False)
s1.show();
我们之前看到的转换的重要之处是它们会分派到元组或其子类。这正是为什么在这种情况下我们选择子类化Tuple
的原因-这样,我们可以将适用于图像的任何转换应用于我们的SiameseImage
,并且它将应用于元组中的每个图像:
s2 = Resize(224)(s1)
s2.show();
这里Resize
转换应用于两个图片中的每一个,但不应用于布尔标志。即使我们有一个自定义类型,我们也可以从库中的所有数据增强转换中受益。
现在我们准备构建Transform
,以便为暹罗模型准备数据。首先,我们需要一个函数来确定所有图片的类别:
def label_func(fname):
return re.match(r'^(.*)_\d+.jpg$', fname.name).groups()[0]
对于每张图片,我们的转换将以 0.5 的概率从同一类中绘制一张图片,并返回一个带有真标签的SiameseImage
,或者从另一类中绘制一张图片并返回一个带有假标签的SiameseImage
。这一切都在私有的_draw
函数中完成。训练集和验证集之间有一个区别,这就是为什么转换需要用拆分初始化:在训练集上,我们将每次读取一张图片时进行随机选择,而在验证集上,我们将在初始化时进行一次性随机选择。这样,在训练期间我们会得到更多不同的样本,但始终是相同的验证集:
class SiameseTransform(Transform):
def __init__(self, files, label_func, splits):
self.labels = files.map(label_func).unique()
self.lbl2files = {l: L(f for f in files if label_func(f) == l)
for l in self.labels}
self.label_func = label_func
self.valid = {f: self._draw(f) for f in files[splits[1]]}
def encodes(self, f):
f2,t = self.valid.get(f, self._draw(f))
img1,img2 = PILImage.create(f),PILImage.create(f2)
return SiameseImage(img1, img2, t)
def _draw(self, f):
same = random.random() < 0.5
cls = self.label_func(f)
if not same:
cls = random.choice(L(l for l in self.labels if l != cls))
return random.choice(self.lbl2files[cls]),same
然后我们可以创建我们的主要转换:
splits = RandomSplitter()(files)
tfm = SiameseTransform(files, label_func, splits)
tfm(files[0]).show();
在数据收集的中级 API 中,我们有两个对象可以帮助我们在一组项目上应用转换:TfmdLists
和Datasets
。如果您记得刚才看到的内容,一个应用一系列转换的Pipeline
,另一个并行应用多个Pipeline
,以构建元组。在这里,我们的主要转换已经构建了元组,因此我们使用TfmdLists
:
tls = TfmdLists(files, tfm, splits=splits)
show_at(tls.valid, 0);
最后,我们可以通过调用dataloaders
方法在DataLoaders
中获取我们的数据。这里需要注意的一点是,这个方法不像DataBlock
那样接受item_tfms
和batch_tfms
。fastai 的DataLoader
有几个钩子,这些钩子以事件命名;在我们抓取项目后应用的内容称为after_item
,在构建批次后应用的内容称为after_batch
:
dls = tls.dataloaders(after_item=[Resize(224), ToTensor],
after_batch=[IntToFloatTensor, Normalize.from_stats(*imagenet_stats)])
请注意,我们需要传递比通常更多的转换,这是因为数据块 API 通常会自动添加它们:
-
ToTensor
是将图像转换为张量的函数(再次,它应用于元组的每个部分)。 -
IntToFloatTensor
将包含 0 到 255 之间整数的图像张量转换为浮点数张量,并除以 255,使值在 0 到 1 之间。
现在我们可以使用这个DataLoaders
来训练模型。与cnn_learner
提供的通常模型相比,它需要更多的定制,因为它必须接受两个图像而不是一个,但我们将看到如何创建这样的模型并在第十五章中进行训练。
结论
fastai 提供了分层 API。当数据处于通常设置之一时,只需一行代码即可获取数据,这使得初学者可以专注于训练模型,而无需花费太多时间组装数据。然后,高级数据块 API 通过允许您混合和匹配构建块来提供更多灵活性。在其下面,中级 API 为您提供更大的灵活性,以在项目上应用转换。在您的实际问题中,这可能是您需要使用的内容,我们希望它使数据处理步骤尽可能简单。
问卷调查
-
为什么我们说 fastai 具有“分层”API?这是什么意思?
-
Transform
为什么有一个decode
方法?它是做什么的? -
Transform
为什么有一个setup
方法?它是做什么的? -
当在元组上调用
Transform
时,它是如何工作的? -
编写自己的
Transform
时需要实现哪些方法? -
编写一个完全规范化项目的
Normalize
转换(减去数据集的平均值并除以标准差),并且可以解码该行为。尽量不要偷看! -
编写一个
Transform
,用于对标记化文本进行数字化(它应该从已见数据集自动设置其词汇,并具有decode
方法)。如果需要帮助,请查看 fastai 的源代码。 -
什么是
Pipeline
? -
什么是
TfmdLists
? -
什么是
Datasets
?它与TfmdLists
有什么不同? -
为什么
TfmdLists
和Datasets
带有“s”这个名字? -
如何从
TfmdLists
或Datasets
构建DataLoaders
? -
在从
TfmdLists
或Datasets
构建DataLoaders
时,如何传递item_tfms
和batch_tfms
? -
当您希望自定义项目与
show_batch
或show_results
等方法一起使用时,您需要做什么? -
为什么我们可以轻松地将 fastai 数据增强转换应用于我们构建的
SiamesePair
?
进一步研究
-
使用中级 API 在自己的数据集上准备
DataLoaders
中的数据。尝试在 Pet 数据集和 Adult 数据集上进行此操作,这两个数据集来自第一章。 -
查看fastai 文档中的 Siamese 教程,了解如何为新类型的项目自定义
show_batch
和show_results
的行为。在您自己的项目中实现它。
理解 fastai 的应用:总结
恭喜你——你已经完成了本书中涵盖训练模型和使用深度学习的关键实用部分的所有章节!你知道如何使用所有 fastai 内置的应用程序,以及如何使用数据块 API 和损失函数进行定制。你甚至知道如何从头开始创建神经网络并训练它!(希望你现在也知道一些问题要问,以确保你的创作有助于改善社会。)
你已经掌握的知识足以创建许多类型的神经网络应用的完整工作原型。更重要的是,它将帮助你了解深度学习模型的能力和局限性,以及如何设计一个适应它们的系统。
在本书的其余部分,我们将逐个拆解这些应用程序,以了解它们构建在哪些基础之上。这对于深度学习从业者来说是重要的知识,因为它使您能够检查和调试您构建的模型,并创建定制的新应用程序,以适应您特定的项目。
标签:dl,06,pt,标记,模型,xxmaj,单词,fastai,我们 From: https://www.cnblogs.com/apachecn/p/18011854