深度学习 2:第 2 部分第 10 课
原文:
medium.com/@hiromi_suenaga/deep-learning-2-part-2-lesson-10-422d87c3340c
译者:飞龙
协议:CC BY-NC-SA 4.0
来自 fast.ai 课程的个人笔记。随着我继续复习课程以“真正”理解它,这些笔记将继续更新和改进。非常感谢 Jeremy 和Rachel 给了我这个学习的机会。
视频 / 论坛
上周回顾[0:16]
-
许多学生在上周的内容上遇到了困难,所以如果你觉得困难,没关系。Jeremy 之所以提前放上这些内容,是为了让我们有东西可以思考、考虑,并逐渐努力,所以到第 14 课时,你将有第二次机会。
-
要理解这些部分,您需要了解卷积层输出的形状、感受野和损失函数——这些都是您在深度学习研究中需要理解的东西。
-
一个关键的事情是,我们从简单的开始——一个单一对象分类器,一个没有分类器的单一对象边界框,然后是一个单一对象分类器和边界框。我们转向多个对象的部分实际上几乎与此相同,只是我们首先必须解决匹配问题。我们最终创建了比我们需要的地面真实边界框更多的激活,因此我们将每个地面真实对象与这些激活的子集进行匹配。一旦我们做到了这一点,我们对每个匹配对执行的损失函数几乎与这个损失函数相同(即单一对象分类器和边界框的损失函数)。
-
如果您感到困惑,请回到第 8 课,并确保您理解数据集、数据加载器,尤其是损失函数。
-
因此,一旦我们有了可以预测一个对象的类别和边界框的东西,我们通过创建更多的激活来转向多个对象[2:40]。然后我们必须处理匹配问题,处理完匹配问题后,我们将每个锚框稍微移动一下,围绕一下,使其尽量与特定的地面真实对象对齐。
-
我们讨论了如何利用网络的卷积特性,尝试使激活具有类似于我们正在预测的地面真实对象的感受野。Chloe 提供了以下出色的图片,逐行讨论了 SSD_MultiHead.forward 的功能:
由Chloe Sultan提供
Chloe 在这里所做的是特别关注每个路径中张量的维度,逐渐使用步幅 2 的卷积进行下采样,确保她理解为什么会出现这些网格大小,然后理解输出是如何从中产生的。
-
这就是你必须记住这个
pbd.set_trace()
的地方。我在课前刚进入SSD_MultiHead.forward
,输入了pdb.set_trace()
,然后运行了一个批次。然后我可以打印出所有这些的大小。我们会犯错误,这就是为什么我们有调试器并知道如何检查事物并逐步进行小的操作的原因。 -
我们接着讨论了增加每个卷积网格单元的锚框数量k[5:49],我们可以通过不同的缩放比例、长宽比来实现,这给我们带来了大量的激活,从而预测边界框。
-
然后我们使用非极大值抑制将数量减少到一个较小的值。
-
非极大值抑制有点糟糕、丑陋、完全启发式,我们甚至没有讨论代码,因为它看起来很丑陋。最近有人提出了一篇论文,试图使用端到端的卷积网络来替换 NMS 部分(
arxiv.org/abs/1705.02950
)。 -
不够多的人在阅读论文!我们现在在课堂上所做的是实现论文,论文是真正的真相。我认为你从与人们交谈中知道,很多人不阅读论文的原因是因为很多人认为他们没有能力阅读论文。他们认为他们不是那种阅读论文的人,但你是。你在这里。我们上周开始看一篇论文,我们读到了用英语写的文字,我们大部分都理解了。如果你仔细看上面的图片,你会意识到
SSD_MultiHead.forward
并不是在做同样的事情。你可能会想知道这样是否更好。我的答案可能是。因为 SSD_MultiHead.forward 是我尝试的第一件事,只是为了让一些东西出现。在这个和 YOLO3 论文之间,可能有更好的方法。 -
特别要注意的一点是,他们使用了更小的 k,但他们有更多的网格集合 1x1、3x3、5x5、10x10、19x19、38x38——每类 8732 个。比我们拥有的要多得多,所以这将是一个有趣的实验。
-
我注意到的另一件事是,我们有 4x4、2x2、1x1,这意味着有很多重叠——每组都适合其他组。在这种情况下,你有 1、3、5,你没有那种重叠。所以这可能会使学习变得更容易。有很多有趣的东西可以玩。
-
我最推荐的可能是将代码和方程式放在一起。你可能是数学人或者代码人。将它们并排放置,你将学到一些另一方面的知识。
-
学习数学很难,因为符号可能看起来很难查找,但有一些好的资源,比如wikipedia。
-
另一件你应该尝试做的事情是重新创建你在论文中看到的东西。这里是来自焦点损失论文的最重要的图 1。
-
我上周发现了我的代码中一个小错误——我展平卷积激活的方式与我在损失函数中使用它们的方式不一致,修复这个问题使它变得更好了。
通常,当我们降采样时,我们会增加滤波器的数量,或者深度。当我们从 7x7 采样到 4x4 等时,为什么要将数量从 512 减少到 256 呢?为什么不在 SSD 头部减少维度?(与性能相关?)我们有许多输出路径,我们希望每个输出路径都是相同的,所以我们不希望每个输出路径具有不同数量的滤波器,这也是论文中所做的,所以我试图与之匹配。拥有这 256 个——这是一个不同的概念,因为我们不仅利用了最后一层,还利用了之前的层。如果我们使它们更一致,生活会更容易。
自然语言处理
我们的目标是:
我们在每节课中都看到了这个想法,即采用预训练模型,去掉一些顶部的东西,用新的东西替换它,并让它做一些类似的事情。我们深入研究了这一点,ConvLearner.pretrained
有一种标准的方法,可以在顶部添加一些东西,做一些特定的事情(即分类)。然后我们发现实际上我们可以在末尾添加任何我们喜欢的 PyTorch 模块,并使用custom_head
让它做任何我们喜欢的事情,所以突然间你会发现我们可以做一些非常有趣的事情。
事实上,杨露说“如果我们做一个不同类型的自定义头部会怎样?”不同的自定义头部是让我们拍摄原始图片,旋转它们,然后将我们的因变量设为该旋转的相反方向,看看它是否能学会将其旋转回来。事实上,我认为现在 Google 相册有这个选项,它会自动为您旋转照片。但酷的是,正如他在这里展示的,你可以通过完全按照我们之前的课程来构建这个网络。但是你的自定义头部会输出一个单一数字,即旋转的角度,你的数据集有一个因变量,即旋转的角度。
forums.fast.ai/t/fun-with-lesson8-rotation-adjustment-things-you-can-do-without-annotated-dataset/14261/1
所以你突然意识到,有了这个骨干加自定义头部的想法,你几乎可以做任何你想做的事情 [16:30]。
-
今天,我们将探讨相同的想法,并看看它如何应用于自然语言处理。
-
在下一课中,我们将进一步探讨,如果自然语言处理和计算机视觉让你可以做相同的基本想法,我们如何将两者结合起来。我们将学习一个模型,实际上可以从图像中学习找到单词结构,从单词结构中找到图像,或从图像中找到图像。如果你想进一步做像从图像到句子(即图像字幕)或从句子到图像这样的事情,那将是基础。
-
从那里开始,我们必须更深入地思考计算机视觉,看看我们可以用这个预训练网络加自定义头部的想法做些什么其他事情。因此,我们将研究各种图像增强技术,如增加低分辨率照片的分辨率以猜测缺失的部分,或在照片上添加艺术滤镜,或将马的照片变成斑马的照片等等。
-
最后,这将使我们回到边界框。为了达到这个目标,我们首先要学习分割,这不仅仅是找出边界框在哪里,还要找出图像中每个像素所属的部分 - 所以这个像素是人的一部分,这个像素是汽车的一部分。然后我们将使用这个想法,特别是一个叫做 UNet 的想法,事实证明 UNet 的这个想法,我们可以应用到边界框上 - 这被称为特征金字塔。我们将使用这个方法来获得边界框的非常好的结果。这就是我们从这里开始的路径。这一切都将相互建立,但会带领我们进入许多不同的领域。
torchtext 转 fastai.text [18:56]:
对于自然语言处理,我们过去依赖于一个叫做 torchtext 的库,但尽管它很好用,我后来发现它的限制太令人困扰,无法继续使用。正如你们很多人在论坛上抱怨的那样,它非常慢,部分原因是它不进行并行处理,部分原因是它不记得你上次做了什么,而是从头开始重新做。然后很难做一些相当简单的事情,比如很多人试图参加 Kaggle 上的有毒评论竞赛,这是一个多标签问题,试图用 torchtext 做到这一点,我最终搞定了,但花了我大约一周的时间,这有点荒谬。为了解决所有这些问题,我们创建了一个名为 fastai.text 的新库。Fastai.text 是 torchtext 和 fastai.nlp 的组合的替代品。所以不要再使用 fastai.nlp 了 - 那已经过时了。它更慢,更令人困惑,各方面都不如意,但有很多重叠。故意地,很多类和函数的名称都是相同的,但这是非 torchtext 版本。
IMDb [20:32]
笔记本
我们将再次使用 IMDb。对于那些忘记了的人,请返回查看 lesson 4。这是一个电影评论数据集,我们用它来找出我们是否会喜欢“Zombiegeddon”,我们认为可能是我喜欢的类型。
from fastai.text import *
import html
我们需要从这个网站下载 IMDB 大型电影评论:
ai.stanford.edu/~amaas/data/sentiment/
直接链接:链接
BOS = 'xbos' *# beginning-of-sentence tag*
FLD = 'xfld' *# data field tag*PATH=Path('data/aclImdb/')
标准化格式[21:27]
NLP 的基本路径是我们必须将句子转换为数字,有几种方法可以实现这一点。目前,有点故意地,fastai.text 并没有提供太多的辅助函数。它真的更多地设计为让你以一种相当灵活的方式处理事情。
CLAS_PATH=Path('data/imdb_clas/')
CLAS_PATH.mkdir(exist_ok=True)
LM_PATH=Path('data/imdb_lm/')
LM_PATH.mkdir(exist_ok=True)
正如你在这里看到的[21:59],我写了一个名为 get_texts 的东西,它遍历了CLASSES
中的每一个东西。IMDb 中有三个类别:负面、正面,然后还有另一个文件夹“unsupervised”,其中包含他们尚未标记的样本,所以我们暂时将其称为一个类别。所以我们只是遍历每一个类别,然后找到该文件夹中的每个文件,打开它,读取它,并将其放入数组的末尾。正如你所看到的,使用 pathlib,很容易获取并导入东西,然后标签就是到目前为止的任何类别。我们将为训练集和测试集都这样做。
CLASSES = ['neg', 'pos', 'unsup']
def get_texts(path):texts,labels = [],[]for idx,label in enumerate(CLASSES):for fname in (path/label).glob('*.*'):texts.append(fname.open('r').read())labels.append(idx)return np.array(texts),np.array(labels)
trn_texts,trn_labels = get_texts(PATH/'train')
val_texts,val_labels = get_texts(PATH/'test')
len(trn_texts),len(val_texts)
'''
(75000, 25000)
'''
训练集中有 75,000 个样本,测试集中有 25,000 个样本。训练集中的 50,000 个样本是无监督的,当我们进行分类时,实际上我们将无法使用它们。Jeremy 发现这比 torch.text 方法更容易,后者需要很多层和包装器,因为最终,读取文本文件并不那么困难。
col_names = ['labels','text']
一个总是好的想法是随机排序[23:19]。特别是当你有多个需要以相同方式排序的东西时,了解这个简单的随机排序技巧是很有用的。在这种情况下,你有标签和texts
。np.random.permutation,如果你给它一个整数,它会给你一个从 0 到该数字之间的随机列表,但不包括该数字,顺序是随机的。
np.random.seed(42)
trn_idx = np.random.permutation(len(trn_texts))
val_idx = np.random.permutation(len(val_texts))
你可以将其作为索引器传递,以便得到一个按照那种随机顺序排序的列表。所以在这种情况下,它将以相同的随机方式对trn_texts
和trn_labels
进行排序。这是一个有用的小技巧。
trn_texts = trn_texts[trn_idx]
val_texts = val_texts[val_idx]
trn_labels = trn_labels[trn_idx]
val_labels = val_labels[val_idx]
现在我们有了排序好的文本和标签,我们可以从中创建一个数据框[24:07]。我们为什么要这样做呢?原因是因为在文本分类数据集中,开始出现了一种有点标准的方法,即将训练集作为一个 CSV 文件,其中标签在前,NLP 文档的文本在后。所以它基本上看起来像这样:
df_trn = pd.DataFrame({'text':trn_texts, 'labels':trn_labels
}, columns=col_names)
df_val = pd.DataFrame({'text':val_texts, 'labels':val_labels
}, columns=col_names)
df_trn[df_trn['labels']!=2].to_csv(CLAS_PATH/'train.csv',header=False, index=False
)
df_val.to_csv(CLAS_PATH/'test.csv', header=False, index=False
)
(CLAS_PATH/'classes.txt').open('w') \.writelines(f'**{o}\n**' for o in CLASSES)
(CLAS_PATH/'classes.txt').open().readlines()
'''
['neg\n', 'pos\n', 'unsup\n']
'''
所以你有你的标签和文本,然后有一个名为 classes.txt 的文件,其中只列出了类别。我说“有点标准”,因为在最近的一篇学术论文中,Yann LeCun 和一组研究人员查看了相当多的数据集,并且他们对所有数据集都使用了这种格式。所以这就是我最近一篇论文开始使用的格式。你会发现,如果你将你的数据放入这种格式的笔记本中,整个笔记本每次都会运行[25:17]。所以,与其有一千种不同的格式,我只是说让我们选择一个标准格式,你的工作就是将你的数据放入那个格式,即 CSV 文件。CSV 文件默认没有标题。
你会注意到在开始时,我们有两条不同的路径[25:51]。一条是分类路径,另一条是语言模型路径。在自然语言处理中,你会一直看到 LM。LM 代表语言模型。分类路径将包含我们将用来创建情感分析模型的信息。语言模型路径将包含我们需要创建语言模型的信息。所以它们有一点不同。一个不同之处是,在分类路径中创建 train.csv 时,我们会删除所有标签为 2 的内容,因为标签为 2 是“无监督”的,我们不能使用它。
trn_texts,val_texts = sklearn.model_selection.train_test_split(np.concatenate([trn_texts,val_texts]), test_size=0.1
)
len(trn_texts), len(val_texts)
'''
(90000, 10000)
'''
第二个不同之处是标签[26:51]。对于分类路径,标签是实际标签,但对于语言模型,没有标签,所以我们只使用一堆零,这样做会更容易一些,因为我们可以使用一致的数据框/CSV 格式。
现在语言模型,我们可以创建自己的验证集,所以你可能已经遇到了 sklearn.model_selection.train_test_split
,这是一个非常简单的函数,根据你指定的比例随机将数据集分割成训练集和验证集。在这种情况下,我们将我们的分类训练和验证合并在一起,按 10%进行分割,现在我们有 90,000 个训练数据,10,000 个验证数据用于我们的语言模型。这样就为我们的语言模型和分类器以标准格式获取了数据。
df_trn = pd.DataFrame({'text':trn_texts, 'labels': [0]*len(trn_texts)
}, columns=col_names)
df_val = pd.DataFrame({'text':val_texts, 'labels': [0]*len(val_texts)
}, columns=col_names)
df_trn.to_csv(LM_PATH/'train.csv', header=False, index=False)
df_val.to_csv(LM_PATH/'test.csv', header=False, index=False)
语言模型标记[28:03]
接下来我们需要做的是标记化。标记化意味着在这个阶段,对于一个文档(比如一部电影评论),我们有一个很长的字符串,我们想将其转换为一个标记列表,类似于一个单词列表但不完全相同。例如,don’t
我们希望它变成do
和n’t
,我们可能希望句号成为一个标记,等等。标记化是我们交给一个名为 spaCy 的绝妙库的事情 — 部分原因是因为它是由澳大利亚人编写的,部分原因是因为它擅长它所做的事情。我们在 spaCy 之上加了一些东西,但绝大部分工作都是由 spaCy 完成的。
chunksize=24000
在将其传递给 spaCy 之前,Jeremy 编写了这个简单的fixup
函数,每次他查看不同的数据集(在构建过程中大约有十几个),每个数据集都有不同的奇怪之处需要替换。所以这是他迄今为止想出的所有内容,希望这也能帮助到你。所有实体都是 html 未转义的,还有更多我们替换的内容。看看在你输入的文本上运行这个函数的结果,并确保里面没有更多奇怪的标记。
re1 = re.compile(r' +')
def fixup(x):x = x.replace('#39;', "'").replace('amp;', '&').replace('#146;', "'").replace('nbsp;', ' ').replace('#36;', '$').replace('**\\**n', "**\n**").replace('quot;', "'").replace('<br />', "**\n**").replace('**\\**"', '"').replace('<unk>','u_n').replace(' @.@ ','.').replace(' @-@ ','-').replace('**\\**', ' **\\** ')return re1.sub(' ', html.unescape(x))
def get_texts(df, n_lbls=1):labels = df.iloc[:,range(n_lbls)].values.astype(np.int64)texts = f'**\n{BOS}** **{FLD}** 1 ' + df[n_lbls].astype(str)for i in range(n_lbls+1, len(df.columns)): texts += f' **{FLD}** {i-n_lbls} ' + df[i].astype(str)texts = texts.apply(fixup).values.astype(str) tok = Tokenizer().proc_all_mp(partition_by_cores(texts))return tok, list(labels)
get_all
函数调用get_texts
,而get_texts
将做一些事情[29:40]。其中之一是应用我们刚提到的fixup
。
def get_all(df, n_lbls):tok, labels = [], []for i, r in enumerate(df):print(i)tok_, labels_ = get_texts(r, n_lbls)tok += tok_;labels += labels_return tok, labels
让我们仔细看一下,因为有一些有趣的事情要指出。我们将使用 pandas 打开我们的 train.csv 文件,但是我们传入了一个你可能以前没有见过的额外参数,叫做chunksize
。当涉及存储和使用文本数据时,Python 和 pandas 都可能非常低效。所以你会发现,在 NLP 领域很少有人在处理大型语料库。Jeremy 认为部分原因是传统工具使得这一过程非常困难——你总是会耗尽内存。所以他今天向我们展示的这个过程,他已经成功地在超过十亿字的语料库上使用了这段代码。其中一个简单的技巧就是 pandas 中的chunksize
。这意味着 pandas 不会返回一个数据框,而是返回一个我们可以迭代遍历数据框块的迭代器。这就是为什么我们不说tok_trn = get_text(df_trn)
,而是调用get_all
,它会遍历数据框,但实际上它正在遍历数据框的块,因此每个块基本上是代表数据子集的数据框。
问题:当我处理 NLP 数据时,很多时候我会遇到包含外文文本/字符的数据。是更好地丢弃它们还是保留它们?不,绝对要保留它们。整个过程都是 unicode 的,我实际上已经在中文文本上使用过这个过程。这个过程设计用于几乎任何东西。一般来说,大多数情况下,删除任何东西都不是一个好主意。老式的 NLP 方法倾向于执行所有这些词形还原和所有这些规范化步骤来摆脱东西,将所有东西转换为小写等等。但这是在丢弃你事先不知道是否有用的信息,所以不要丢弃信息。
因此,我们遍历每个块,每个块都是一个数据框,然后我们调用get_texts
。get_texts
将获取标签并将它们转换为整数,并且它将获取文本。有几点需要指出:
-
在包含文本之前,我们有一个“流的开始”(
BOS
)标记,我们在开始时定义了。这些特定的字母字符串并没有什么特别之处——它们只是我发现在正常文本中很少出现的。因此,每个文本都将以‘xbos’
开头——为什么呢?因为对于你的模型来说,知道何时开始一个新文本通常是有用的。例如,如果是一个语言模型,我们将把所有文本连接在一起。因此,让它知道所有这些文章何时结束以及新文章何时开始是非常有帮助的,这样我可能应该忘记它们的一些上下文了。 -
Ditto 经常出现的情况是文本有多个字段,比如标题和摘要,然后是主要文档。因此,同样地,我们在 CSV 中可以有多个字段。这个过程设计得非常灵活。在每个字段的开始,我们放置一个特殊的“字段开始于此”标记,后面跟着这个字段开始的编号,对于我们有多少个字段就有多少个。然后我们对其应用
fixup
。 -
然后最重要的是[33:54], 我们对其进行标记化 - 通过进行“process all multiprocessing” (
proc_all_mp
) 进行标记化。标记化往往会很慢,但现在我们的机器都有多个核心,AWS 上一些更好的机器可以有几十个核心。spaCy 不太适合多处理,但 Jeremy 最终找到了让它工作的方法。好消息是现在所有这些都包含在这一个函数中。所以你只需要传递给该函数一个要标记化的事物列表,该列表的每个部分将在不同的核心上进行标记化。还有一个名为partition_by_cores
的函数,它接受一个列表并将其拆分为子列表。子列表的数量就是您计算机上的核心数量。在 Jeremy 的机器上,没有多处理,这需要大约一个半小时,而使用多处理,大约需要 2 分钟。所以这是一个非常方便的东西。随时查看并利用它来处理您自己的东西。记住,我们的笔记本电脑中都有多个核心,而且很少有 Python 中的东西能够利用它,除非您稍微努力使其工作。
df_trn = pd.read_csv(LM_PATH/'train.csv', header=None, chunksize=chunksize
)
df_val = pd.read_csv(LM_PATH/'test.csv', header=None, chunksize=chunksize
)
tok_trn, trn_labels = get_all(df_trn, 1)
tok_val, val_labels = get_all(df_val, 1)
'''
0
1
2
3
0
'''
(LM_PATH/'tmp').mkdir(exist_ok=True)
这是最终结果[35:42]。流的开始标记(xbos
),第 1 个字段的开始标记(xfld 1
),以及标记化的文本。您会看到标点现在是一个单独的标记。
**t_up**
: t_up mgm
- MGM 最初是大写的。但有趣的是,通常人们要么全部小写,要么保持大小写不变。现在如果保持大小写不变,那么“SCREW YOU”和“screw you”是两组完全不同的标记,必须从头开始学习。或者如果全部小写,那么根本没有区别。那么如何解决这个问题,以便既获得“我现在在大声喊叫”的语义影响,又不必学习大声喊叫版本与正常版本。所以想法是想出一个唯一的标记,表示下一个事物全是大写。然后我们将其小写,所以现在以前大写的部分被小写,然后我们可以学习全部大写的语义含义。
**tk_rep**
: 同样,如果您连续有 29 个!
,我们不会为 29 个感叹号学习一个单独的标记 - 而是为“下一个事物重复很多次”放入一个特殊的标记,然后放入数字 29 和一个感叹号(即tk_rep 29 !
)。所以有一些类似的技巧。如果您对 NLP 感兴趣,请查看 Jeremy 添加的这些小技巧的标记器代码,因为其中一些很有趣。
' '.join(tok_trn[0])
用这种方式做事情的好处是我们现在可以只需np.save
一下,稍后再加载回来[37:44]。我们不必像我们通常需要在 torchtext 或许多其他库中那样每次都重新计算所有这些东西。现在我们已经将其标记化,下一步需要做的是将其转换为数字,我们称之为数字化。我们数字化的方式非常简单。
-
我们制作一个按某种顺序出现的所有单词的列表
-
然后我们用该列表中的索引替换每个单词。
-
所有标记的列表,我们称之为词汇表。
np.save(LM_PATH/'tmp'/'tok_trn.npy', tok_trn)
np.save(LM_PATH/'tmp'/'tok_val.npy', tok_val)
tok_trn = np.load(LM_PATH/'tmp'/'tok_trn.npy')
tok_val = np.load(LM_PATH/'tmp'/'tok_val.npy')
这里是一些词汇的例子。Python 中的 Counter 类对此非常有用。它基本上为我们提供了一个独特项目和它们的计数的列表。这里是词汇中最常见的 25 个东西。一般来说,我们不希望在我们的词汇表中有每个独特的标记。如果它不至少出现两次,那可能只是一个拼写错误或者一个我们无法学到任何东西的词,如果它不经常出现的话。此外,在这一部分我们将要学习的东西一旦词汇量超过 60,000 就会变得有些笨重。如果时间允许,我们可能会看一下 Jeremy 最近在处理更大词汇量方面所做的一些工作,否则这可能会在未来的课程中出现。但实际上,对于分类来说,超过 60,000 个词并没有什么帮助。
freq = Counter(p for o in tok_trn for p in o)
freq.most_common(25)
'''
[('the', 1207984),('.', 991762),(',', 985975),('and', 587317),('a', 583569),('of', 524362),('to', 484813),('is', 393574),('it', 341627),('in', 337461),('i', 308563),('this', 270705),('that', 261447),('"', 236753),("'s", 221112),('-', 188249),('was', 180235),('\n\n', 178679),('as', 165610),('with', 159164),('for', 158981),('movie', 157676),('but', 150203),('film', 144108),('you', 124114)]
'''
所以我们将把我们的词汇表限制在 60,000 个词,至少出现两次的东西。这里有一个简单的方法。使用.most_common
,传入最大词汇大小。这将按频率排序,如果出现的频率低于最小频率,则根本不要理会。这给我们了itos
- 这是 torchtext 使用的相同名称,意思是整数到字符串。这只是词汇表中独特标记的列表。我们将插入两个额外的标记 - 一个未知的词汇项(_unk_
)和一个填充的词汇项(_pad_
)。
max_vocab = 60000
min_freq = 2
itos = [o for o,c in freq.most_common(max_vocab) if c>min_freq]
itos.insert(0, '_pad_')
itos.insert(0, '_unk_')
然后我们可以创建一个字典,它是相反的(从字符串到整数)。这不会覆盖所有内容,因为我们故意将它截断到 60,000 个词。如果我们遇到字典中没有的东西,我们希望用零替换它,表示未知,所以我们可以使用带有 lambda 函数的 defaultdict,它总是返回零。
stoi = collections.defaultdict(lambda:0, {v:k for k,v in enumerate(itos)}
)
len(itos)
'''
60002
'''
现在我们定义了我们的stoi
字典,我们可以为每个句子的每个单词调用它。
trn_lm = np.array([[stoi[o] for o in p] for p in tok_trn])
val_lm = np.array([[stoi[o] for o in p] for p in tok_val])
这是我们的数字化版本:
当然,好处是我们也可以保存这一步。每次我们到达另一个步骤时,我们都可以保存它。与您用于图像的文件相比,这些文件并不是很大。文本通常很小。
非常重要的是也保存那个词汇表(itos
)。数字列表没有意义,除非你知道每个数字指的是什么,这就是itos
告诉你的。
np.save(LM_PATH/'tmp'/'trn_ids.npy', trn_lm)
np.save(LM_PATH/'tmp'/'val_ids.npy', val_lm)
pickle.dump(itos, open(LM_PATH/'tmp'/'itos.pkl', 'wb'))
所以你保存这三样东西,以后你可以重新加载它们。
trn_lm = np.load(LM_PATH/'tmp'/'trn_ids.npy')
val_lm = np.load(LM_PATH/'tmp'/'val_ids.npy')
itos = pickle.load(open(LM_PATH/'tmp'/'itos.pkl', 'rb'))
现在我们的词汇量是 60,002,我们的训练语言模型中有 90,000 个文档。
vs=len(itos)
vs,len(trn_lm)
'''
(60002, 90000)
'''
这就是你要做的预处理。如果我们想的话,我们可以将更多的内容包装在实用函数中,但这一切都非常简单明了,一旦你将数据集转换为 CSV 格式,这段代码就可以适用于任何数据集。
预训练
这是一种新的见解,实际上并不新,我们想要预先训练一些东西。我们从第 4 课中知道,如果我们通过首先创建一个语言模型,然后将其微调为分类器来预训练我们的分类器,那是有帮助的。实际上,这给我们带来了一个新的最先进的结果 - 我们得到了最好的 IMDb 分类器结果,这个结果比之前发布的要好得多。然而,我们还没有走得那么远,因为 IMDb 电影评论与任何其他英文文档并没有太大不同;与随机字符串或甚至中文文档相比,它们之间的差异并不大。所以就像 ImageNet 让我们能够训练识别看起来像图片的东西的模型一样,我们可以将其用于与 ImageNet 无关的东西,比如卫星图像。为什么我们不训练一个擅长英语的语言模型,然后微调它以擅长电影评论呢。
这个基本的想法让 Jeremy 尝试在维基百科上构建一个语言模型。Stephen Merity 已经处理了维基百科,找到了几乎大部分内容的子集,但是丢弃了一些无关紧要的小文章,只留下了较大的文章。他称之为 wikitext103。Jeremy 拿到了 wikitext103 并在上面训练了一个语言模型。他使用了与他即将向你展示的训练 IMDb 语言模型完全相同的方法,但是他训练了一个 wikitext103 语言模型。他保存了这个模型,并且让任何想要使用它的人都可以在这个 URL 上找到。现在的想法是让我们训练一个 IMDb 语言模型,它以这些权重为起点。希望对你们来说,这是一个非常明显、非常不具争议的想法,因为这基本上是我们迄今为止在几乎每一堂课上所做的。但是当 Jeremy 去年六月或七月首次向 NLP 社区的人们提到这一点时,他们对此毫无兴趣,并被告知这是愚蠢的。因为 Jeremy 很固执,他忽略了他们,尽管他们对 NLP 了解更多,但还是尝试了。让我们看看发生了什么。
wikitext103 转换 [46:11]
这是我们如何做的。获取 wikitext 模型。如果你使用wget -r
,它将递归地抓取整个目录,其中有一些东西。
# ! wget -nH -r -np -P {PATH} http://files.fast.ai/models/wt103/
我们需要确保我们的语言模型具有与 Jeremy 的 wikitext 相同的嵌入大小、隐藏数量和层数,否则你无法加载这些权重。
em_sz,nh,nl = 400,1150,3
这是我们的预训练路径和我们的预训练语言模型路径。
PRE_PATH = PATH/'models'/'wt103'
PRE_LM_PATH = PRE_PATH/'fwd_wt103.h5'
让我们继续从前向 wikitext103 模型中torch.load
这些权重。我们通常不使用 torch.load,但这是 PyTorch 抓取文件的方式。它基本上给你一个包含层名称和这些权重的张量/数组的字典。
现在的问题是,wikitext 语言模型是建立在一个特定词汇表上的,这个词汇表与我们的不同。我们的#40 不同于 wikitext103 模型的#40。所以我们需要将一个映射到另一个。这非常简单,因为幸运的是 Jeremy 保存了 wikitext 词汇表的itos
。
wgts = torch.load(PRE_LM_PATH, map_location=lambda storage, loc: storage
)
enc_wgts = to_np(wgts['0.encoder.weight'])
row_m = enc_wgts.mean(0)
这是 wikitext103 模型中每个单词的列表,我们可以使用相同的defaultdict
技巧来反向映射。我们将使用-1 来表示它不在 wikitext 词典中。
itos2 = pickle.load((PRE_PATH/'itos_wt103.pkl').open('rb'))
stoi2 = collections.defaultdict(lambda:-1, {v:k for k,v in enumerate(itos2)}
)
现在我们可以说我们的新权重集只是一个由词汇大小乘以嵌入大小(即我们将创建一个嵌入矩阵)的一大堆零。然后我们遍历我们 IMDb 词汇表中的每一个单词。我们将在 wikitext103 词汇表的stoi2
(字符串到整数)中查找它,并查看它是否是一个单词。如果那是一个单词,那么我们就不会得到-1
。所以r
将大于或等于零,那么在这种情况下,我们将把嵌入矩阵的那一行设置为存储在名为‘0.encoder.weight’
的元素内的权重。你可以查看这个字典wgts
,很明显每个名称对应什么。它看起来非常类似于你在设置模块时给它的名称,所以这里是编码器权重。
如果我们找不到它 [49:02], 我们将使用行均值——换句话说,这是 wikitext103 中所有嵌入权重的平均值。因此,我们将得到一个嵌入矩阵,其中包含我们的 IMDb 词汇表和 wikitext103 词汇表中的每个单词;我们将使用 wikitext103 嵌入矩阵权重;对于其他任何单词,我们将只使用 wikitext103 嵌入矩阵中的平均权重。
new_w = np.zeros((vs, em_sz), dtype=np.float32)
for i,w in enumerate(itos):r = stoi2[w]new_w[i] = enc_wgts[r] if r >= 0 else row_m
然后我们将用new_w
替换编码器权重,变成一个张量[49:35]。我们没有谈论过权重绑定,但基本上解码器(将最终预测转换回单词的部分)使用完全相同的权重,所以我们也将它放在那里。然后有一个关于我们如何进行嵌入丢弃的奇怪事情,最终导致它们有一个完全独立的副本,原因并不重要。所以我们把权重放回它们需要去的地方。所以现在这是一组 torch 状态,我们可以加载进去。
wgts['0.encoder.weight'] = T(new_w)
wgts['0.encoder_with_dropout.embed.weight'] = T(np.copy(new_w))
wgts['1.decoder.weight'] = T(np.copy(new_w))
语言模型[50:18]
让我们创建我们的语言模型。我们将使用的基本方法是将所有文档连接在一起,形成一个长度为 24,998,320 的单词标记列表。这将是我们作为训练集传入的内容。所以对于语言模型:
-
我们将所有文档连接在一起。
-
我们将不断尝试预测这些单词之后的下一个单词。
-
我们将设置一系列的丢弃。
-
一旦我们有了一个模型数据对象,我们就可以从中获取模型,这样就会给我们一个学习者。
-
然后像往常一样,我们可以调用
learner.fit
。我们在最后一层上进行一个周期,只是为了确认一下。它的设置是最后一层是嵌入单词,因为显然这是最可能出错的地方,因为很多这些嵌入权重甚至在词汇表中都不存在。所以我们将训练一个周期,只是针对嵌入权重。 -
然后我们将开始对完整模型进行几个周期的训练。看起来怎么样?在第 4 课中,我们在 14 个周期后的损失为 4.23。在这种情况下,我们在 1 个周期后的损失为 4.12。因此,通过在 wikitext103 上进行预训练,我们在 1 个周期后的损失比其他情况下语言模型的最佳损失更好。
问题:wikitext103 模型是什么?它再次是 AWD LSTM 吗[52:41]?是的,我们即将深入研究。我训练它的方式实际上与您在上面看到的代码行完全相同,但没有在 wikitext103 上进行预训练。
关于 fastai 文档项目的简要讨论[53:07]
fastai 文档项目的目标是创建让读者说“哇,这是我读过的最棒的文档”并且我们有一些关于如何做到这一点的具体想法。这是一种自上而下、深思熟虑、充分利用媒体的方法,交互式实验代码优先,我们都很熟悉。如果您有兴趣参与,您可以在docs 目录中看到基本方法。在那里,除其他内容外,还有transforms-tmpl.adoc。adoc
是AsciiDoc。AsciiDoc 类似于 markdown,但它更像是 markdown 需要成为创建实际书籍的工具。许多实际的书籍都是用 AsciiDoc 编写的,它和 markdown 一样易于使用,但你可以用它做更多很酷的事情。这里是更标准的 AsciiDoc 示例。您可以做一些像插入目录(:toc:
)这样的事情。::
表示在这里放一个定义列表。+
表示这是前一个列表项的延续。所以有许多非常方便的功能,它就像是增强版的 markdown。因此,这个 asciidoc 会创建这个 HTML,没有添加自定义 CSS 或其他内容:
我们刚刚开始这个项目 4 个小时。所以你有一个带有超链接到特定部分的目录。我们有交叉引用,我们可以点击跳转到交叉引用。每种方法都附带其详细信息等等。为了使事情更加简单,他们创建了一个专门的模板用于参数、交叉引用、方法等。这个想法是,它几乎会像一本书一样。将会有表格、图片、视频片段和整个超链接。
你可能会想到文档字符串怎么办。但实际上,如果你查看 Python 标准库并查看re.compile()
的文档字符串,例如,它只有一行。几乎每个 Python 的文档字符串都是一行。然后 Python 确实这样做——他们有一个包含文档的网站,上面写着“这就是正则表达式是什么,这就是你需要知道的关于它们的东西,如果你想要快速执行它们,你需要编译,这里有一些关于编译的信息”等等。这些信息不在文档字符串中,这也是我们将要做的——我们的文档字符串将是一行,除非有时候你需要两行。欢迎每个人帮助贡献文档。
这与 word2vec 有什么比较?这实际上是一个很好的事情,你可以在这一周花时间思考。我现在会给你总结,但这是一个非常重要的概念区别。主要的概念区别是“word2vec 是什么?”Word2vec 是一个单一的嵌入矩阵——每个单词都有一个向量,就是这样。换句话说,它是一个来自预训练模型的单一层——具体来说,该层是输入层。而且具体来说,那个预训练模型是一个线性模型,它是在一个叫做共现矩阵的东西上进行预训练的。所以我们没有特别的理由相信这个模型已经学到了关于英语语言的很多东西,或者它有任何特殊的能力,因为它只是一个单一的线性层,就是这样。那么这个 wikitext103 模型呢?它是一个语言模型,有一个 400 维的嵌入矩阵,3 个隐藏层,每层有 1,150 个激活,还有正则化和所有那些与输入输出矩阵相关的东西——基本上是一个最先进的 AWD LSTM。一个单一线性模型的单一层与一个三层循环神经网络之间的区别是什么?一切!它们具有非常不同的能力水平。所以当你尝试使用一个预训练语言模型与 word2vec 层时,你会发现在绝大多数任务中得到非常不同的结果。
如果 numpy 数组不适合内存怎么办?是否可以直接从大型 CSV 文件中编写 PyTorch 数据加载器?几乎肯定不会出现这种情况,所以我不会花时间在这上面。这些东西很小——它们只是整数。想想你需要多少整数才会耗尽内存?那是不会发生的。它们不必适合 GPU 内存,只需适合你的内存。我实际上做过另一个维基百科模型,我称之为 giga wiki,它包含了整个维基百科,甚至那个也很容易适合内存。我之所以不使用它,是因为事实证明它与 wikitext103 相比并没有真正帮助太多。我建立了一个比我在学术文献中找到的任何其他人都要大的模型,并且它适合单台机器的内存。
问题:对于嵌入权重进行平均化的背后思想是什么[1:01:24]?它们必须被设置为某个值。这些是之前没有出现过的单词,所以另一个选择是我们可以将它们设置为零。但这似乎是一个非常极端的做法。零是一个非常极端的数字。为什么要设置为零?我们可以将它设置为一些随机数,但如果是这样,那么这些随机数的均值和标准差是多少?它们应该是均匀的吗?如果我们只是平均化其他嵌入,那么我们就得到了一个合理缩放的东西。只是澄清一下,这就是我们如何初始化在训练语料库中没有出现过的单词。
回到语言模型[1:02:20]
这是我们之前见过的大量内容,但有些地方有所改变。实际上,与第 1 部分相比,这部分要容易得多,但我想深入一点了解语言模型加载器。
wd=1e-7
bptt=70
bs=52
opt_fn = partial(optim.Adam, betas=(0.8, 0.99))
t = len(np.concatenate(trn_lm))
t, t//64
'''
(24998320, 390598)
'''
这是LanguageModelLoader
,我真的希望到现在为止,你已经学会了在你的编辑器或 IDE 中如何跳转到符号[1:02:37]。我不希望你为了找出LanguageModelLoader
的源代码而感到困扰。如果你的编辑器没有做到这一点,就不要再使用那个编辑器了。有很多好用的免费编辑器可以轻松实现这一点。
这就是LanguageModelLoader
的源代码,有趣的是它并没有做任何特别复杂的事情。它并没有从任何地方派生。能够作为数据加载器的关键是它可以被迭代。
这是 fastai.model 中的fit
函数[1:03:41]。这是最终所有东西都会经过的地方,它会遍历每个时代,从数据加载器创建一个迭代器,然后通过一个 for 循环进行遍历。所以任何你可以通过 for 循环遍历的东西都可以作为数据加载器。具体来说,它需要返回独立和依赖变量的元组,用于小批量。
所以任何具有__iter__
方法的东西都可以作为迭代器[1:04:09]。yield
是一个很棒的 Python 关键字,如果你还不了解的话,你可能应该学习一下。它基本上会输出一个东西,然后等待你请求另一个东西——通常在一个 for 循环中。在这种情况下,我们通过传入数字nums
来初始化语言模型,这是我们所有文档连接在一起的数字化长列表。我们首先要做的是“批量化”它。这是上次很多人感到困惑的地方。如果我们的批量大小是 64,我们的列表中有 2500 万个数字。我们不是创建长度为 64 的项目——我们总共创建了 64 个项目。因此,每个项目的大小是t
除以 64,即 390k。这就是我们在这里做的事情:
data = data.view(self.bs, -1).t().contiguous()
我们对它进行重塑,使得这个轴的长度为 64,-1
表示其他所有内容(390k blob),然后我们对它进行转置。这意味着我们现在有 64 列,390k 行。然后每次迭代时,我们抓取一批序列长度为bptt
(通过时间反向传播)大约等于 70 的数据。我们只抓取那么多行。所以从第i
行到第i+70
行,我们尝试预测下一个。请记住,我们试图预测我们当前位置的下一个位置。
所以我们有 64 列,每列是我们 2500 万个标记的 1/64,数以百万计的长,我们每次只抓取 70 个[1:06:29]。所以每次我们抓取每列时,它都会与前一列连接起来。这就是为什么我们会得到这种一致性。这个语言模型是有状态的,这一点非常重要。
语言模型中的几乎所有很酷的东西都是从 Stephen Merity 的 AWD-LSTM 中偷来的[1:06:59],包括这里的这个小技巧:
如果我们每次都拿 70 个,然后回去做一个新的时代,我们每次都会拿到完全相同的批次 — 没有随机性。通常,我们每次做一个时代或每次拿一些数据时,我们都会随机洗牌我们的数据。但是对于语言模型来说,我们不能这样做,因为这个集合必须与之前的集合连接起来,因为它试图学习句子。如果你突然跳到别的地方,那作为一个句子就没有意义了。所以 Stephen 的想法是说“好吧,既然我们不能洗牌顺序,那么我们就随机改变序列长度”。基本上,95%的时间,我们会使用bptt
(即 70),但 5%的时间,我们会使用一半。然后他说“你知道吗,我甚至不会把那作为序列长度,我会创建一个平均值为那个的正态分布随机数,标准差为 5,然后我会把那作为序列长度。” 所以序列长度大约是 70,这意味着每次我们经过时,我们得到的批次会稍微不同。所以我们有了那一点额外的随机性。Jeremy 问 Stephen Merity 他是从哪里得到这个想法的,他是自己想出来的吗?他说“我想我是自己想出来的,但似乎这么明显,以至于我觉得我可能没有想到” — 这对于 Jeremy 在深度学习中想出一个想法来说是真的。每次 Jeremy 想出一个想法时,它总是看起来如此明显,以至于你会假设别人已经想到了。但 Jeremy 认为 Stephen 是自己想出来的。
LanguageModelLoader
是一个很好的东西,如果你想用数据加载器做一些不太常见的事情的话,可以看一下[1:08:55]。这是一个简单的角色模型,你可以用它来从头开始创建一个数据加载器 — 一个可以输出数据批次的东西。
我们的语言模型加载器接收了所有文档连接在一起以及批次大小和 bptt[1:09:14]。
trn_dl = LanguageModelLoader(np.concatenate(trn_lm), bs, bptt)
val_dl = LanguageModelLoader(np.concatenate(val_lm), bs, bptt)
md = LanguageModelData(PATH, 1, vs, trn_dl, val_dl, bs=bs, bptt=bptt
)
一般来说,我们想要创建一个学习器,通常我们这样做是通过获取一个模型数据对象并调用某种方法,这个方法有各种各样的名字,但通常我们称这个方法为get_model
。这个想法是模型数据对象有足够的信息来知道给你什么样的模型。所以我们必须创建那个模型数据对象,这意味着我们需要一个非常容易做的 LanguageModelData 类[1:09:51]。
这里有所有的部分。我们将创建一个自定义的学习器,一个自定义的模型数据类,和一个自定义的模型类。所以一个模型数据类,同样它不继承任何东西,所以你真的可以看到几乎没有什么要做的。你需要告诉它最重要的是你的训练集是什么(给它一个数据加载器),你的验证集是什么(给它一个数据加载器),还可以选择地,给它一个测试集(数据加载器),再加上其他需要知道的任何东西。它可能需要知道 bptt,它需要知道标记的数量(即词汇表大小),它需要知道填充索引是什么。这样它就可以保存临时文件和模型,模型数据一直需要知道路径。所以我们只是获取所有这些东西然后把它们倒出来。就是这样。这就是整个初始化器。那里根本没有逻辑。
然后所有的工作都发生在get_model
内部。get_model 调用我们稍后将看到的东西,它只是获取一个普通的 PyTorch nn.Module 架构,并将其放在 GPU 上。注意:在 PyTorch 中,我们会说.cuda()
,在 fastai 中最好说to_gpu()
,原因是如果你没有 GPU,它会留在 CPU 上。它还提供了一个全局变量,你可以设置选择是否将其放在 GPU 上,所以这是一个更好的方法。我们将模型包装在LanguageModel
中,而LanguageModel
是BasicModel
的子类,除了定义层组之外几乎什么都不做。记住当我们进行区分性学习率时,不同的层有不同的学习率或者我们冻结不同的数量时,我们不会为每一层提供不同的学习率,因为可能有一千层。我们为每个层组提供不同的学习率。所以当你创建一个自定义模型时,你只需要覆盖这一点,它返回所有层组的列表。在这种情况下,最后一个层组包含模型的最后部分和一个 dropout 位。其余部分(*
这里表示拆分)所以这将是每个 RNN 层一个层。这就是全部。
最后将其转换为一个 learner。所以一个 learner,你只需传入模型,它就会变成一个 learner。在这种情况下,我们已经重写了 learner,唯一做的事情就是说我希望默认的损失函数是交叉熵。这整套自定义模型、自定义模型数据、自定义 learner 都适合在一个屏幕上。它们基本上看起来都是这样。
这段代码库中有趣的部分是get_language_model
。因为这给了我们我们的 AWD LSTM。实际上它包含了一个重要的想法。一个大而极其简单的想法,其他人都认为这是非常明显的,Jeremy 与 NLP 社区中的每个人都认为这是疯狂的。也就是说,每个模型都可以被看作是一个骨干加一个头部,如果你预训练骨干并添加一个随机头部,你可以进行微调,这是一个好主意。
这两段代码,字面上紧挨在一起,这就是fastai.lm_rnn
中的全部内容。
get_language_model
:创建一个 RNN 编码器,然后创建一个顺序模型,将其放在顶部 - 一个线性解码器。
get_rnn_classifier
:创建一个 RNN 编码器,然后创建一个顺序模型,将其放在顶部 - 一个池化线性分类器。
我们马上会看到这些差异是什么,但你已经得到了基本的想法。它们基本上在做同样的事情。它们有这个头部,然后在顶部添加一个简单的线性层。
问题:之前有一个问题,关于这是否适用于其他语言。是的,这整个过程适用于任何语言。你需要重新训练语言模型以适应那种语言的语料库吗?绝对需要!所以 wikitext103 预训练语言模型了解英语。你可以将其用作法语或德语模型的预训练起点,重新训练嵌入层可能会有所帮助。对于中文,可能效果不太好。但是鉴于语言模型可以从任何未标记的文档中训练,你永远不必这样做。因为世界上几乎每种语言都有大量文档 - 你可以获取报纸、网页、议会记录等。只要你有几千份展示该语言正常使用的文档,你就可以创建一个语言模型。我们的一位学生尝试了这种方法来处理泰语,他说他建立的第一个模型轻松击败了以前最先进的泰语分类器。对于那些国际同学,这是一个简单的方法,让你能够撰写一篇论文,要么创建你所在语言的第一个分类器,要么击败其他人的分类器。然后你可以告诉他们,你已经学习深度学习六个月了,惹恼你所在国家的所有学者。
这是我们的 RNN 编码器。它是一个标准的 nn.Module。看起来似乎有更多的东西在里面,但实际上我们只是创建一个嵌入层,为每个被要求的层创建一个 LSTM,就是这样。它里面的其他所有东西都是 dropout。基本上,AWS LSTM 论文中所有有趣的东西(几乎都是)都是你可以放置 dropout 的地方。然后前向传播基本上是相同的。调用嵌入层,添加一些 dropout,通过每一层,调用那个 RNN 层,将其附加到我们的输出列表中,添加 dropout,就这样。所以这很简单。
你应该阅读的论文是 AWD LSTM 论文,标题是Regularizing and Optimizing LSTM Language Models。它写得很好,非常易懂,并且完全在 fastai 中实现 - 所以你可以看到那篇论文的所有代码。实际上,很多代码都是在得到 Stephen 的许可后无耻地剽窃自他优秀的 GitHub 仓库 AWD LSTM。
这篇论文提到了其他论文。比如为什么编码器权重和解码器权重是相同的。这是因为有一种叫做“绑定权重”的东西。在get_language_model
中,有一个叫做tie_weights
的东西,默认为 true。如果为 true,那么我们实际上使用相同的权重矩阵用于编码器和解码器。它们指向同一块内存。为什么会这样?结果是什么?这是 Stephen 的论文中的一个引用,也是一篇写得很好的论文,你可以查阅并了解权重绑定。
我们基本上有一个标准的 RNN。唯一不标准的地方是它有更多类型的 dropout。在 RNN 的顶部顺序模型中,我们放置一个线性解码器,这实际上是代码屏幕的一半。它有一个单一的线性层,我们将权重初始化为某个范围,添加一些 dropout,就这样。所以它是一个带有 dropout 的线性层。
所以语言模型是:
- RNN → 一个带有 dropout 的线性层
选择 dropout
你选择的 dropout 很重要。通过大量实验,Jeremy 发现了一些对语言模型非常有效的 dropout。但如果你的语言模型数据较少,你需要更多的 dropout。如果你有更多的数据,你可以从更少的 dropout 中受益。你不想过度正则化。Jeremy 的观点是,这些比例已经相当不错,所以只需调整这个数字(下面的0.7
),我们只需将其乘以某个值。如果你过拟合,那么你需要增加这个数字,如果你欠拟合,你需要减少这个数字。因为除此之外,这些比例似乎相当不错。
drops = np.array([0.25, 0.1, 0.2, 0.02, 0.15]) * 0.7
learner= md.get_model(opt_fn, em_sz, nh, nl, dropouti=drops[0], dropout=drops[1], wdrop=drops[2],dropoute=drops[3], dropouth=drops[4]
)
learner.metrics = [accuracy]
learner.freeze_to(-1)
测量准确性
有一个看似不起眼但实际上非常有争议的重要观点是,当我们看语言模型时,我们应该衡量准确性。通常对于语言模型,我们看的是一个损失值,即交叉熵损失,但具体来说,我们几乎总是取 e 的幂次方,NLP 社区称之为“困惑度”。所以困惑度就是e^(交叉熵)
。基于交叉熵损失进行比较存在很多问题。不确定现在是否有时间详细讨论,但基本问题就像我们学到的关于焦点损失的那个东西。交叉熵损失 - 如果你是对的,它希望你非常确信自己是对的。因此,它会严厉惩罚那些不说“我非常确定这是错误”的模型,而实际上是错误的。而准确性完全不关心你有多自信 - 它关心的是你是否正确。这在现实生活中更常见。准确性是我们猜测下一个词正确的频率,这是一个更稳定的数字来跟踪。这是 Jeremy 做的一个简单小事。
learner.model.load_state_dict(wgts)
lr=1e-3
lrs = lrlearner.fit(lrs/2, 1, wds=wd, use_clr=(32,2), cycle_len=1)
'''
epoch trn_loss val_loss accuracy 0 4.398856 4.175343 0.28551
[4.175343, 0.2855095456305303]
'''
learner.save('lm_last_ft')
learner.load('lm_last_ft')
learner.unfreeze()
learner.lr_find(start_lr=lrs/10, end_lr=lrs*10, linear=True)
learner.sched.plot()
我们训练一段时间,将交叉熵损失降至 3.9,相当于约 49.40 的困惑度(e³.9
)。要让你了解语言模型的情况,如果你看一下大约 18 个月前的学术论文,你会看到他们谈论的最先进的困惑度超过一百。我们理解语言的能力以及衡量语言模型准确性或困惑度的速度并不是理解语言的一个可怕的代理。如果我能猜到你接下来要说什么,我需要很好地理解语言以及你可能会谈论的事情。困惑度数字已经下降了很多,这是令人惊讶的,而且它还会下降很多。在过去的 12-18 个月里,NLP 真的感觉像是 2011-2012 年的计算机视觉。我们开始理解迁移学习和微调,基本模型变得更好了很多。你对 NLP 能做什么和不能做什么的想法正在迅速过时。当然,NLP 仍然有很多不擅长的地方。就像在 2012 年,计算机视觉有很多不擅长的地方一样。但它的变化速度非常快,现在是非常好的时机,要么变得非常擅长 NLP,要么基于 NLP 创办初创公司,因为两年前计算机绝对擅长的一堆事情,现在还不如人类,而明年,它们将比人类好得多。
learner.fit(lrs, 1, wds=wd, use_clr=(20,10), cycle_len=15)
'''
epoch trn_loss val_loss accuracy 0 4.332359 4.120674 0.289563 1 4.247177 4.067932 0.294281 2 4.175848 4.027153 0.298062 3 4.140306 4.001291 0.300798 4 4.112395 3.98392 0.302663 5 4.078948 3.971053 0.304059 6 4.06956 3.958152 0.305356 7 4.025542 3.951509 0.306309 8 4.019778 3.94065 0.30756 9 4.027846 3.931385 0.308232 10 3.98106 3.928427 0.309011 11 3.97106 3.920667 0.30989 12 3.941096 3.917029 0.310515 13 3.924818 3.91302 0.311015 14 3.923296 3.908476 0.311586
[3.9084756, 0.3115861900150776]
'''
问题:您一周内阅读论文与编码的比例是多少?天哪,你觉得呢,Rachel?你看到我。我的意思是,更多的是编码,对吧?“编码要多得多。我觉得每周也会有很大的变化”(Rachel)。有了那些边界框的东西,有很多论文,但没有明确的指引,所以我甚至不知道该先读哪一篇,然后我读了引用,但一个也不懂。所以有几周时间只是读论文,然后才知道从哪里开始编码。不过这种情况很少见。每次我开始读一篇论文,我总是确信自己不够聪明,无法理解,无论是哪篇论文。但最终我总能理解。但我尽量花尽可能多的时间编码。
几乎总是在我读完一篇论文后,即使我读完了说这是我要解决的问题的部分,我会停下来,尝试实现我认为可能解决这个问题的东西。然后我会回去读论文,读一点关于如何解决这些问题的部分,然后我会说“哦,这是个好主意”,然后尝试实现这些。这就是为什么例如,我实际上没有实现 SSD。我的自定义头部与他们的头部不同。因为我大致了解了它,然后尝试尽力创建一些东西,然后回到论文中看原因。所以当我看到焦点损失论文时,Rachel 会告诉你,我因为为什么找不到小物体而把自己逼疯了?为什么总是预测背景?我读了焦点损失论文,然后我说“原来如此!”当你深刻理解他们试图解决的问题时,情况会好得多。我发现绝大多数时间,当我读到解决问题的部分时,我会说“是的,但是我想出的这三个想法他们没有尝试。”然后你突然意识到你有了新的想法。否则,如果你只是机械地实现论文,你往往不会有关于更好方法的这些见解。
问题:您的辍学率在培训过程中是否保持不变,或者您是否相应地调整它和权重?变化的辍学率真的很有趣,最近有一些论文建议逐渐改变辍学率。逐渐减小或逐渐增加它可能是个好主意,我不确定哪个更好。也许我们中的某个人可以在这一周尝试找到它。我还没有看到它被广泛使用。我在最近写的论文中尝试了一点,取得了一些好结果。我想我是逐渐减小它的,但我记不清了。
问题:我是否正确地认为这个语言模型是建立在词嵌入上的?尝试使用短语或句子嵌入是否有价值?我问这个问题是因为我前几天从谷歌那里看到了通用句子编码器。这比那个好多了。这不仅仅是一个句子的嵌入,这是一个完整的模型。嵌入的定义就像一个固定的东西。一个句子或短语嵌入总是创建一个模型。我们有一个试图理解语言的模型。这不仅仅是一个短语或句子——最终是一个文档,而且我们不仅仅是通过整个过程训练嵌入。多年来,这一直是自然语言处理的一个巨大问题,就是他们对嵌入的依赖。即使是最近最令人兴奋的来自 AI2(艾伦人工智能研究所)的论文,他们发现在许多模型中取得了更好的结果,但再次,这是一个嵌入。他们采用了一个固定的模型,并创建了一组固定的数字,然后将其输入到模型中。但是在计算机视觉中,多年来我们已经知道,拥有固定的特征集的方法,称为计算机视觉中的超列,人们在 3 或 4 年前就停止使用了,因为对整个模型进行微调效果更好。对于那些在自然语言处理方面花费了很多时间而在计算机视觉方面花费不多时间的人,你们将不得不开始重新学习。关于这个想法,你们被告知的所有关于这些叫做嵌入的东西以及你提前学习它们然后应用这些固定的东西,无论是单词级别还是短语级别或其他级别的所有东西——不要这样做。你实际上想要创建一个预训练模型并对其进行端到端的微调,然后你会看到一些具体的结果。
问题:对于使用准确度而不是困惑度作为模型的度量标准,我们是否可以将其纳入损失函数而不仅仅将其用作度量标准?不,无论是计算机视觉还是自然语言处理或其他任何领域,你都不想这样做。这太颠簸了。所以交叉熵作为损失函数是可以的。我并不是说取代,我是同时使用。我认为查看准确度和查看交叉熵是好的。但对于你的损失函数,你需要一些平滑的东西。准确度效果不是很好。
learner.save('lm1')
learner.save_encoder('lm1_enc')
save_encoder
你会看到有两个不同版本的save
。save
像往常一样保存整个模型。save_encoder
只保存那一部分。
换句话说,在顺序模型中,它只保存rnn_enc
而不保存LinearDecoder(n_tok, emb_sz, dropout, tie_encoder=enc)
(这实际上是将其转换为语言模型的部分)。在分类器中,我们不关心那部分,我们只关心rnn_end
。这就是为什么我们在这里保存两个不同的模型。
learner.sched.plot_loss()
分类器标记
现在让我们创建分类器。我们将快速浏览一下,因为它是相同的。但当你在这一周回顾代码时,确信它是相同的。
df_trn = pd.read_csv(CLAS_PATH/'train.csv', header=None, chunksize=chunksize
)
df_val = pd.read_csv(CLAS_PATH/'test.csv', header=None, chunksize=chunksize)
tok_trn, trn_labels = get_all(df_trn, 1)
tok_val, val_labels = get_all(df_val, 1)
'''
0
1
0
1
'''
(CLAS_PATH/'tmp').mkdir(exist_ok=True)
np.save(CLAS_PATH/'tmp'/'tok_trn.npy', tok_trn)
np.save(CLAS_PATH/'tmp'/'tok_val.npy', tok_val)
np.save(CLAS_PATH/'tmp'/'trn_labels.npy', trn_labels)
np.save(CLAS_PATH/'tmp'/'val_labels.npy', val_labels)
tok_trn = np.load(CLAS_PATH/'tmp'/'tok_trn.npy')
tok_val = np.load(CLAS_PATH/'tmp'/'tok_val.npy')
我们不创建一个新的itos
词汇表,显然我们想要使用语言模型中已有的相同词汇表,因为我们即将重新加载相同的编码器。
itos = pickle.load((LM_PATH/'tmp'/'itos.pkl').open('rb'))
stoi = collections.defaultdict(lambda:0, {v:k for k,v in enumerate(itos)}
)
len(itos)
'''
60002
'''
trn_clas = np.array([[stoi[o] for o in p] for p in tok_trn])
val_clas = np.array([[stoi[o] for o in p] for p in tok_val])
np.save(CLAS_PATH/'tmp'/'trn_ids.npy', trn_clas)
np.save(CLAS_PATH/'tmp'/'val_ids.npy', val_clas)
分类器
trn_clas = np.load(CLAS_PATH/'tmp'/'trn_ids.npy')
val_clas = np.load(CLAS_PATH/'tmp'/'val_ids.npy')
trn_labels = np.squeeze(np.load(CLAS_PATH/'tmp'/'trn_labels.npy'))
val_labels = np.squeeze(np.load(CLAS_PATH/'tmp'/'val_labels.npy'))
模型超参数的构建是相同的。我们可以更改丢失率。选择一个尽可能大的批量大小,以防内存不足。
bptt,em_sz,nh,nl = 70,400,1150,3
vs = len(itos)
opt_fn = partial(optim.Adam, betas=(0.8, 0.99))
bs = 48
min_lbl = trn_labels.min()
trn_labels -= min_lbl
val_labels -= min_lbl
c=int(trn_labels.max())+1
TextDataset
这一部分很有趣。这里有一些有趣的东西。
trn_ds = TextDataset(trn_clas, trn_labels)
val_ds = TextDataset(val_clas, val_labels)
这里的基本思想是,对于分类器,我们确实希望查看一个文档。这个文档是积极的还是消极的?所以我们确实希望打乱文档。但是这些文档的长度不同,所以如果我们把它们全部放入一个批次中(这是 fastai 为您做的一个方便的事情)- 您可以将不同长度的东西放入一个批次中,它会自动填充它们,所以您不必担心这个问题。但是如果它们的长度差异很大,那么您将浪费大量的计算时间。如果有一件事是 2000 个字长,而其他所有东西都是 50 个字长,那意味着您最终会得到一个 2000 宽的张量。这相当恼人。所以詹姆斯·布拉德伯里是斯蒂芬·梅里蒂的同事之一,也是提出 torchtext 的人,他提出了一个聪明的想法,即“让我们按长度对数据集进行排序”。因此,使得列表中的前几个东西总体上比最后的东西短,但也有一点随机性。
这是 Jeremy 如何实现的。我们需要的第一件事是一个数据集。因此,我们有一个传递文档及其标签的数据集。这里有一个继承自 Dataset 的 TextDataSet,下面还显示了 PyTorch 中的 Dataset:
实际上,Dataset 什么也不做。它说如果您没有__getitem__
,您将会收到一个错误。对于__len__
也是如此。因此,这是一个抽象类。对于 TextDataset,我们将传入我们的 x 和 y,__getitem__
将获取 x 和 y,并将它们返回-这不能更简单。可选地,1.他们可以颠倒它,2.在末尾添加一个流的结束,3.在开头添加一个流的开始。但我们没有做这些事情,所以我们实际上所做的就是将 x 和 y 放在一起,__getitem__
将它们作为元组返回。长度是 x 的长度。这就是 Dataset 的全部内容-一个具有长度的东西,您可以对其进行索引。
将其转换为 DataLoader
trn_samp = SortishSampler(trn_clas, key=lambda x: len(trn_clas[x]), bs=bs//2
)
val_samp = SortSampler(val_clas, key=lambda x: len(val_clas[x]))
trn_dl = DataLoader(trn_ds, bs//2, transpose=True, num_workers=1,pad_idx=1, sampler=trn_samp
)
val_dl = DataLoader(val_ds, bs, transpose=True, num_workers=1, pad_idx=1, sampler=val_samp
)
md = ModelData(PATH, trn_dl, val_dl)
要将其转换为 DataLoader,您只需将数据集传递给 DataLoader 构造函数,现在它将每次给您一个批次。通常您可以说 shuffle 等于 true 或 shuffle 等于 false,它会决定是否为您随机化。但在这种情况下,我们实际上将传递一个 sampler 参数,sampler 是一个我们将定义的类,告诉数据加载器如何进行洗牌。
-
对于验证集,我们将定义一个实际上只是排序的东西。它只是确定性地对其进行排序,以便所有最短的文档将在开头,所有最长的文档将在末尾,这将最小化填充的数量。
-
对于训练采样器,我们将创建一个称为 sort-ish 采样器的东西,它也进行排序(ish!)
PyTorch 的一个伟大之处在于,他们为数据加载器提出了一个 API 的想法,我们可以通过其中的新类来使其以不同的方式运行。SortSampler 是一个具有数据源长度的长度和一个迭代器的东西,迭代器只是一个按长度排序的数据源的迭代器(作为 key 传入)。对于 SortishSampler,它基本上做了同样的事情,稍微有些随机性。这只是 PyTorch 中 Jeremy 发现的另一个美丽设计。他可以采用詹姆斯·布拉德伯里的想法,他围绕这个想法写了一整套新的类,并且可以在 PyTorch 内部使用内置的钩子。您会注意到数据加载器实际上不是 PyTorch 的数据加载器-它实际上是 fastai 的数据加载器。但它基本上几乎完全抄袭了 PyTorch,但在某些方面进行了定制,以使其更快,主要是使用多线程而不是多处理。
问题:预训练的 LSTM 深度和bptt
需要与我们正在训练的新模型匹配吗[1:39:00]?不,bptt
根本不需要匹配。这就像我们一次看多少东西。这与架构无关。
现在我们可以调用我们之前看到的get_rnn_classifier
函数[1:39:16]。它将创建几乎完全相同的编码器,我们将传入与之前相同的架构细节。但这次,我们添加的头部有一些额外的功能。其中一个是你可以添加多个隐藏层。在layers=[em_sz*3, 50, c]
中:
-
em_sz * 3
:这是我头部(即分类器部分)的输入。 -
50
:这是第一层的输出 -
c
:这是第二层的输出
你可以添加任意数量的层。所以你基本上可以在最后创建一个小型多层神经网络分类器。同样,对于drops=[dps[4], 0.1]
,这些是在每个层之后要进行的丢弃。
# part 1
dps = np.array([0.4, 0.5, 0.05, 0.3, 0.1])
dps = np.array([0.4,0.5,0.05,0.3,0.4])*0.5
m = get_rnn_classifer(bptt, 20*70, c, vs, emb_sz=em_sz, n_hid=nh, n_layers=nl, pad_token=1,layers=[em_sz*3, 50, c], drops=[dps[4], 0.1],dropouti=dps[0], wdrop=dps[1], dropoute=dps[2], dropouth=dps[3]
)
opt_fn = partial(optim.Adam, betas=(0.7, 0.99))
我们将像以前一样使用 RNN_Learner。
learn = RNN_Learner(md, TextModel(to_gpu(m)), opt_fn=opt_fn)
learn.reg_fn = partial(seq2seq_reg, alpha=2, beta=1)
learn.clip=25.
learn.metrics = [accuracy]
我们将为不同层使用判别学习率[1:40:20]。
lr=3e-3
lrm = 2.6
lrs = np.array([lr/(lrm**4), lr/(lrm**3), lr/(lrm**2), lr/lrm, lr])
lrs=np.array([1e-4,1e-4,1e-4,1e-3,1e-2])
你可以尝试使用权重衰减或不使用。Jeremy 已经在尝试一些东西。
wd = 1e-7
wd = 0
learn.load_encoder('lm2_enc')
我们开始只训练最后一层,得到 92.9%的准确率:
learn.freeze_to(-1)
learn.lr_find(lrs/1000)
learn.sched.plot()
learn.fit(lrs, 1, wds=wd, cycle_len=1, use_clr=(8,3))
'''
epoch trn_loss val_loss accuracy 0 0.365457 0.185553 0.928719
[0.18555279, 0.9287188090884525]
'''
learn.save('clas_0')
learn.load('clas_0')
然后我们再解冻一层,得到 93.3%的准确率:
learn.freeze_to(-2)
learn.fit(lrs, 1, wds=wd, cycle_len=1, use_clr=(8,3))
'''
epoch trn_loss val_loss accuracy 0 0.340473 0.17319 0.933125
[0.17319041, 0.9331253991245995]
'''
learn.save('clas_1')
learn.load('clas_1')
learn.unfreeze()
learn.fit(lrs, 1, wds=wd, cycle_len=14, use_clr=(32,10))
'''
epoch trn_loss val_loss accuracy 0 0.337347 0.186812 0.930782 1 0.284065 0.318038 0.932062 2 0.246721 0.156018 0.941747 3 0.252745 0.157223 0.944106 4 0.24023 0.159444 0.945393 5 0.210046 0.202856 0.942858 6 0.212139 0.149009 0.943746 7 0.21163 0.186739 0.946553 8 0.186233 0.1508 0.945218 9 0.176225 0.150472 0.947985 10 0.198024 0.146215 0.948345 11 0.20324 0.189206 0.948145 12 0.165159 0.151402 0.947745 13 0.165997 0.146615 0.947905
[0.14661488, 0.9479046703071374]
'''
learn.sched.plot_loss()
learn.save('clas_2')
然后我们微调整个模型[1:40:47]。这是在我们的论文出现之前使用预训练模型的主要尝试:
在翻译中学到:上下文化的词向量
他们所做的是他们使用了一个预训练的翻译模型,但他们没有微调整个模型。他们只是取出了翻译模型的激活,当他们尝试 IMDb 时,他们得到了 91.8% —— 而我们只是微调了一个层就轻松超过了这个结果。他们不是最先进的,最先进的是 94.1%,而我们在微调整个模型 3 个 epochs 后达到了 94.8%,这显然是一个巨大的差异,因为从错误率来看,这已经从 5.9%下降到了 4.6%。一个简单的小技巧是回到这个笔记本的开头,颠倒所有文档的顺序,然后重新运行整个过程。当你到达fwd_wt_103
这部分时,用bwd
替换fwd
,即将 forward 替换为 backward。这是一个向后的英语语言模型,学习如何向后阅读英语。因此,如果你重新做这整个过程,将所有文档倒置,并将其更改为向后,你现在有了第二个分类器,根据反向文档的情感将事物分类为正面或负面。然后,你可以取这两个预测的平均值,基本上你就有了一个双向模型(你分别训练了每个部分),这将使你达到 95.4%的准确率。所以我们基本上将它从 5.9%降低到了 4.6%。这种最先进技术的 20%变化几乎是闻所未闻的。这种情况并不经常发生。所以你可以看到使用迁移学习的这个想法,它是非常强大的,每个新领域都认为自己的领域太特殊,无法做到。所以这对我们所有人来说都是一个巨大的机会。
文本分类的通用语言模型微调[1:44:02]
所以我们把这个变成了一篇论文,当我说“我们”时,是和这个家伙 Sebastian Ruder 一起做的。现在你可能记得他的名字,因为在第 5 课时,我告诉过你我实际上和 Sebastian 分享了第 4 课,因为我认为他是一个很棒的研究者,我认为他可能会喜欢。我根本不认识他。令我惊讶的是,他实际上看了这个视频。他看了整个视频并说:
Sebastian:“这实际上相当棒!我们应该把这变成一篇论文。”
Jeremy:“我不写论文。我不关心论文,对论文不感兴趣——那听起来真的很无聊”
Sebastian:“好的,我替你写论文。”
Jeremy:“你现在真的不能写关于这个的论文,因为你必须做研究来将其与其他事物进行比较(称为消融研究),看看哪部分实际起作用。这里没有严谨性,我只是把我脑子里想到的一切都放进去,然后把它们全部放在一起,结果竟然奏效了”
Sebastian:“好的,如果我写所有的论文并做所有你的消融研究,那我们可以写论文吗?”
Jeremy:“嗯,这就像一个我还没有记录的整个库,我现在还不会记录,你也不知道它是如何工作的”
Sebastian:“好的,如果我写了论文,做了消融研究,从头开始弄清楚代码的工作原理而不打扰你,那我们可以写论文吗?”
Jeremy:“嗯……是的,如果你做了所有这些事情,那我们可以写论文。好吧!”
然后两天后,他回来说“好的,我已经起草了论文。” 所以,我分享这个故事是想说,如果你是爱尔兰的某个学生,想做好工作,不要让任何人阻止你。我至少没有鼓励他。但最后,他说“我想做这项工作,我认为会很好,我会弄清楚的”,他写了一篇很棒的论文。他做了消融研究,弄清楚了 fastai 的工作原理,现在我们计划一起写另一篇论文。你必须小心,因为有时我会收到陌生人的消息,说“我有很多好主意,我们可以喝咖啡吗?”——“我不想……我随时可以在办公室喝咖啡,谢谢”。但是,说“嘿,我采纳了你的想法,写了一篇论文,做了一堆实验,弄清楚了你的代码如何工作,并为其添加了文档——我们应该提交到会议上吗?”就很不一样了。你明白我的意思吗?没有什么能阻止你做出惊人的工作,如果你做出了有助于他人的惊人工作,比如这种情况,我很高兴我们有了一篇论文。我并不特别关心论文,但我认为这些想法现在有了这样严谨的研究很酷。
让我展示一下他做了什么。
他拿走了我所有的代码,所以我已经做了所有的 fastai.text,正如你所看到的,它让我们能够处理大型语料库。Sebastian 读书很多,他说“这里有一篇 Yann LeCun 和一些人刚刚发表的论文,他们尝试了很多分类数据集,所以我打算在所有这些数据集上运行你的代码。” 所以这些数据集是:
其中一些文档有成千上万的文件,比我尝试的要大得多——但我认为它应该可以工作。
在我们进行下去的过程中,他有一些好主意,所以你应该确保阅读这篇论文。他说“嗯,你在课程中称之为差异学习率的东西,差异有点意思。也许我们应该重新命名它”,所以我们重新命名了。现在它被称为区分性学习率。所以我们在第一部分学到的这个想法,即对不同层使用不同的学习率,经过一些文献研究,似乎以前没有做过,所以现在正式成为一个事情——区分性学习率。这是我们在第一课中学到的东西,但现在它有了一个带有希腊字母和一切的方程式:
当你看到一个带有希腊字母的方程式时,并不一定意味着它比我们在第一课中做的任何事情更复杂,因为这个并不复杂。
再次,像逐层解冻一样的想法,似乎以前从未做过,所以现在是一个事情,它有一个非常聪明的名字“逐步解冻”。
倾斜三角形学习率
然后,如约,我们将看一下倾斜三角形学习率。这实际上不是我的想法。Leslie Smith,你们现在都知道的我最喜欢的研究人员之一,前段时间给我发了封邮件说:“我对循环学习率已经厌倦了。我不再这样做了。我现在做一个稍微不同的版本,其中有一个快速上升的周期,然后慢慢下降。我经常发现这样效果更好。”我试着回顾我所有的旧数据集,对所有数据集都效果更好——我尝试过的每一个。这就是学习率的样子。你可以通过在fit
中添加use_clr=
来在 fastai 中使用它。第一个数字是最高学习率和最低学习率之间的比率,因此初始学习率是峰值的 1/32。第二个数字是第一个峰值和最后一个峰值之间的比率。基本思想是,如果你正在进行一个长度为 10 的周期,你希望第一个时期是上升的部分,其他 9 个时期是下降的部分,那么你会使用 10。我发现这样效果非常好,这也是 Leslie 的建议,大约两天前,他写了这篇令人惊叹的论文:神经网络超参数的纪律方法。在这篇论文中,他描述了与此略有不同的内容,但基本思想相同。这是一篇必读的论文。它包含了 fastai 经常深入讨论的各种想法,其他人都没有谈论过。不幸的是,Leslie 在真正有时间编辑它之前不得不离开度假,所以阅读起来有点慢,但不要让这阻止你。它很棒。
右边的方程是我和 Sebastian 的论文中的。Sebastian 问:“Jeremy,你能把你写的代码背后的数学方程发给我吗?”我说:“不行,我只是写了代码。我无法把它转化为数学”,所以他为此找到了数学解。
连接池[1:51:36]
所以你可能已经注意到,我们分类器的第一层等于嵌入大小的 3 倍。为什么是 3 倍?因为,再次强调,这似乎是以前没有人做过的事情,所以一个新的想法“连接池”。我们对激活序列进行平均池化,对激活序列进行最大池化,以及最终的一组激活,并将它们全部连接在一起。这是我们在第一部分讨论过的内容,但在文献中似乎没有出现过,所以现在称为“连接池”,现在有一个方程,但这就是全部的实现。所以你可以阅读这篇论文,看看 fastai 代码如何实现每个部分。
BPT3C[1:52:46]
一个有趣的地方是RNN_Encoder
和 MultiBatchRNN 编码器之间的区别。那里有什么不同?关键区别在于,语言模型的普通 RNN 编码器,我们可以一次只做一个bptt
块。但是对于分类器,我们需要处理整个文档。在我们决定它是积极的还是消极的之前,我们需要处理整个电影评论。整个电影评论可能很容易就有 2000 个字,而且我无法将 2000 个字的梯度适应我的 GPU 内存中的每一个权重。那我们该怎么办?所以这个想法非常简单,就是我一次处理整个序列长度的一个bptt
批次。然后我调用super().forward
(换句话说,RNN_Encoder
)来获取它的输出,然后我有这个最大序列长度参数,它说“好的,只要你不超过那个序列长度,就开始将其附加到我的输出列表中。”换句话说,它发送回这个池的东西只有我们要求它保留的那么多激活。这样,你可以弄清楚你的特定 GPU 可以处理多少max_seq
。所以它仍然使用整个文档,但是假设max_seq
是 1000 个字,你最长的文档长度是 2000 个字。它仍然会通过 RNN 为这前 1000 个字创建状态,但实际上不会存储前 1000 个字的激活以进行反向传播。它只会保留最后 1000 个字。这意味着它无法将损失反向传播到在前 1000 个字中创建的任何状态,基本上那已经消失了。所以这是一个非常简单的代码片段,老实说,当我写它时,我没有花太多时间考虑,因为它似乎显而易见这是唯一可能起作用的方式。但是再次,这似乎是一个新事物,所以我们现在有了文本分类的时间反向传播。你可以看到这篇论文中有很多小片段。
结果
结果是什么?在我们尝试的每个数据集上,我们得到的结果都比以往任何一篇学术论文在文本分类方面都要好。各种不同类型。老实说,IMDb 是我花时间尝试优化模型的唯一一个,所以大多数情况下,我们只是用第一个出来的结果。所以如果我们真的花时间在上面,我认为结果会更好。这些比较的对象大多数在每个表格上都不同,因为它们是整体上定制的算法。所以这就是说一个简单的微调算法可以击败这些真正定制的算法。
消融研究
这是 Sebastian 做的消融研究。我非常希望如果要发表一篇论文,我们必须说明它为什么有效。所以 Sebastian 去尝试移除我提到的所有不同贡献。如果我们不使用逐渐冻结会怎样?如果我们不使用区分性学习率会怎样?如果我们使用余弦退火而不是区分性学习率会怎样?如果我们不使用维基百科进行任何预训练会怎样?如果我们不进行任何微调会怎样?对我来说真正有趣的是,如果我们只使用一百个训练样本(与 200、500 等相比),在 IMDb 上的验证错误率是多少。你可以看到,非常有趣的是,这种方法的完整版本在只有一百个训练样本时几乎与完整的 20000 个训练样本一样准确。而如果你从一百开始训练,几乎是随机的。这是我预料到的。我告诉 Sebastian 我真的认为这在没有太多数据时最有益。这就是 fastai 最感兴趣的地方——小数据范围,小计算范围等等。所以他进行了这些研究来检查。
运行消融研究的技巧
技巧#1:VNC
第一个技巧是我知道你们都会觉得非常方便的。我知道当你在 Jupyter 笔记本中运行某些东西时,当你失去互联网连接的时间足够长,它会认为你已经离开,然后你的会话消失了,你必须从头开始。那么你该怎么办?有一个非常简单而酷炫的东西叫做 VNC,你可以在您的 AWS 实例或 PaperSpace 上安装它,或者其他地方:
-
X Windows(
xorg
) -
轻量级窗口管理器(
lxde-core
) -
VNC 服务器(
tightvncserver
) -
Firefox(
firefox
) -
终端(
lxterminal
) -
一些字体(
xfonts-100dpi
)
将这些行添加到您的./vnc/xstartup
配置文件的末尾,然后运行这个命令(tightvncserver :13 -geometry 1200x900
):
现在正在运行一个服务器,您可以在您的计算机上运行 TightVNC Viewer 或任何 VNC 查看器,然后将其指向您的服务器。但具体来说,您要做的是使用 SSH 端口转发将:5913 转发到 localhost:5913:
然后连接到本地主机的端口 5013。它会将其发送到服务器上的端口 5913,这是 VNC 端口(因为你说了:13
),它会显示一个 X Windows 桌面。然后你可以点击 Linux 的开始按钮,点击 Firefox,现在你有了 Firefox。你在 Firefox 中看到这里写着 localhost,因为这个 Firefox 是在我的 AWS 服务器上运行的。所以你现在运行 Firefox,启动你的东西,然后关闭你的 VNC 查看器,记住 Firefox 显示在这个虚拟 VNC 显示上,而不是在一个真实的显示器上,所以稍后那天,你再次登录 VNC 查看器,它会再次弹出。所以这就像一个持久的桌面,而且速度惊人快。它运行得非常好。有很多不同的 VNC 服务器和客户端,但这个对我来说效果很好。
技巧#2:Google Fire[2:01:27]
技巧#2 是创建 Python 脚本,这就是我们最终做的事情。所以我最终为 Sebastian 创建了一个小 Python 脚本,告诉他这是你需要做的基本步骤,现在你需要为其他所有事情创建不同的版本。我建议他尝试使用一个叫做 Google Fire 的东西。Google Fire 的作用是,你创建一个带有大量参数的函数,这些都是 Sebastian 想要尝试的不同事情 - 不同的 dropout 数量,不同的学习率,我是否使用预训练,我是否使用 CLR,我是否使用区分性学习率等等。所以你创建一个函数,然后添加一些内容说:
if __name__ == '__main__': fire.Fire(train_clas)
你什么都不做 - 你不必添加任何元数据,任何文档字符串,什么都不用添加,然后你调用那个脚本,你现在自动拥有了一个命令行界面。这是在终端中运行许多不同变体的超级简单方法。如果你想要做很多变体,这种方法比使用笔记本更容易,因为你可以编写一个 bash 脚本来尝试所有这些变体并将它们全部输出。
技巧#3:IMDb 脚本[2:02:47]
你会在courses/dl2
中找到一个名为imdb_scripts
的文件夹,里面有 Sebastian 和我使用的所有脚本。因为我们需要对每个数据集进行标记化和数值化,然后为每个数据集训练一个语言模型和一个分类器。我们必须以各种不同的方式做所有这些事情来进行比较,所以我们为所有这些事情都准备了脚本。你可以查看并看到我们使用的所有脚本。
技巧#4:pip install -e[2:03:32]
当你在做很多脚本时,你会在各个地方得到不同的代码。最终,你可能会感到沮丧,不想一遍又一遍地创建 fastai 库的符号链接。但你可能也不想 pip 安装它,因为那个版本往往有点旧,我们进展如此之快,你想使用 Git 中的当前版本。如果你从 fastai 仓库基础目录运行 pip install -e .
,它会做一些相当巧妙的事情,基本上是在 site-packages 目录内创建一个到 fastai 库的符号链接(即你本地克隆的 Git 仓库)。你的 site-packages 目录是你的主 Python 库。所以如果你这样做,你就可以从任何地方访问 fastai,但每次你执行 git pull
时,你都会得到最新版本。这样做的一个缺点是它会安装 pip 中的任何更新版本的包,这可能会让 Conda 有点困惑,所以另一个选择是只需将 fastai 库符号链接到你的 site-packages 库。这同样有效。你可以从任何地方使用 fastai,当你想要在系统的不同目录中运行使用 fastai 的脚本时,这是非常方便的。
技巧 #5: SentencePiece
如果你愿意,这是你可以尝试的东西。你不必进行标记化。你可以标记化所谓的子词单元,而不是标记化单词。例如,“unsupervised”可以被标记化为“un”和“supervised”。“Tokenizer”可以被标记化为[“token”, “izer”]。然后你可以做同样的事情。使用子词单元的语言模型,使用子词单元的分类器等。这样做效果如何?我开始尝试并且没有花太多时间,我得到的分类结果几乎和使用单词级标记化一样好 —— 不完全一样,但几乎一样好。我怀疑通过更仔细的思考和尝试,也许我可以得到同样好甚至更好的结果。但即使我不能,如果你创建一个子词单元维基文本模型,然后 IMDb 语言模型,然后分类器正向和反向,然后将其与正向和反向的单词级模型合并,你应该能够超越我们。所以这是一个你可能能够超越我们最先进结果的方法。
Sebastian 告诉我这个特定项目 —— 谷歌有一个名为 sentence peace 的项目,实际上使用神经网络来找出单词的最佳拆分方式,因此你最终会得到一个子词单元的词汇表。在我的尝试中,我发现创建大约 30,000 个子词单元的词汇表似乎是最佳的。如果你感兴趣,这是你可以尝试的东西。安装起来有点麻烦 —— 它是 C++,没有创建错误消息,但它会工作。有一个 Python 库可以用于此。如果有人尝试这个,我很乐意帮助他们让它工作。对于子词和单词级别分类的集成,几乎没有什么实验,我认为这应该是最佳的方法。
祝你有一个美好的一周!
深度学习 2:第 2 部分第 11 课
原文:
medium.com/@hiromi_suenaga/deep-learning-2-part-2-lesson-11-61477d24dc34
译者:飞龙
协议:CC BY-NC-SA 4.0
来自 fast.ai 课程的个人笔记。随着我继续复习课程以“真正”理解它,这些笔记将继续更新和改进。非常感谢 Jeremy 和Rachel 给了我这个学习的机会。
链接
论坛 / 视频
开始之前:
-
1cycle 策略 由 Sylvain Gugger 提出。基于 Leslie Smith 的新论文,该论文结合了之前的两篇关键论文(循环学习率和超级收敛),并通过一系列实验来展示如何实现超级收敛。超级收敛让您训练模型比之前的分阶段方法快五倍(比 CLR 更快,尽管不到五倍)。超级收敛让您可以通过 1 到 3 之间的极高学习率进行训练。超级收敛的有趣之处在于,您在相当大比例的 epochs 中以非常高的学习率进行训练,而在此期间,损失并没有真正得到很大的改善。但诀窍在于它在空间中进行了大量搜索,以找到真正通用的区域。Sylvain 在 fastai 中实现了这一点,通过补充缺失的部分,然后确认他确实在 CIFAR10 上实现了超级收敛。目前称为
use_clr_beta
,但将来会更名。他还在 fastai 库中添加了循环动量。 -
如何使用序列到序列模型创建神奇的数据产品 由 Hamel Husain 撰写。他在博客中介绍了训练一个模型来总结 GitHub 问题。这是基于他的博客创建的 Kubeflow 团队的演示。
神经机器翻译 [5:36]
让我们构建一个序列到序列模型!我们将致力于机器翻译。机器翻译已经存在很长时间了,但我们将看一种称为神经翻译的方法,它使用神经网络进行翻译。神经机器翻译几年前出现,当时并不像使用经典特征工程和标准 NLP 方法(如词干处理、调整词频、n-gram 等)的统计机器翻译方法那么好。一年后,它比其他所有方法都要好。它基于一个叫做 BLEU 的指标——我们不会讨论这个指标,因为它不是一个很好的指标,也不是很有趣,但每个人都在使用它。
我们看到机器翻译开始沿着我们在 2012 年看到的计算机视觉对象分类的道路前进,后者刚刚超越了最先进技术,现在正在以很快的速度超越它。看这个视频的人不太可能会构建一个机器翻译模型,因为translate.google.com/
效果相当不错。那么我们为什么要学习机器翻译呢?我们学习机器翻译的原因是,将一些输入(比如法语句子)转换为任意长度的其他输出(比如英语句子)的一般想法是一件非常有用的事情。例如,正如我们刚才看到的,Hamel 将 GitHub 问题转换为摘要。另一个例子是将视频转换为描述,或者基本上任何你需要输出任意长度输出的地方,通常是一个句子。也许是将 CT 扫描转换为放射学报告——这就是你可以使用序列到序列学习的地方。
神经机器翻译的四个重大优势[8:36]
-
端到端训练:不需要围绕启发式和繁琐的特征工程纠缠。
-
我们能够构建这些分布式表示,这些表示被单个网络中的许多概念共享。
-
我们能够在 RNN 中使用长期状态,因此它比 n-gram 类型的方法使用了更多的上下文。
-
最终,我们生成的文本也使用了 RNN,因此我们可以构建更加流畅的东西。
BiLSTMs(+Attn)不仅适用于神经机器翻译
我们将使用带有注意力的双向 GRU(基本上与 LSTM 相同)-正如您在上面看到的,这些一般想法也可以用于许多其他事情。
让我们来看代码[9:47]
笔记本
我们将尝试按照标准的神经网络方法将法语翻译成英语:
-
数据
-
架构
-
损失函数
1. 数据
像往常一样,我们需要(x, y)
对。在这种情况下,x:法语句子,y:英语句子,您将与之比较您的预测。我们需要许多这些法语句子及其相应的英语句子的元组-这被称为“平行语料库”,比语言模型的语料库更难找到。对于语言模型,我们只需要某种语言的文本。对于任何生活语言,互联网上至少会有几千兆字节的文本供您获取。对于翻译,有一些非常好的欧洲语言的平行语料库可用。欧洲议会有每种欧洲语言的每个句子。任何提交给联合国的东西都会被翻译成许多语言。对于法语到英语,我们有一个特别好的东西,那就是几乎任何半官方的加拿大网站都会有法语版本和英语版本[12:13]。
翻译文件
from fastai.text import *
从www.statmt.org/wmt15/translation-task.html
获取的法语/英语平行文本。这是由克里斯·卡利森-伯奇(Chris Callison-Burch)创建的,他爬取了数百万个网页,然后使用一组简单的启发式规则将法语 URL 转换为英语 URL(即用“fr”替换为“en”和其他大约 40 个手写规则),并假设这些文档是彼此的翻译。
PATH = Path('data/translate')
TMP_PATH = PATH/'tmp'
TMP_PATH.mkdir(exist_ok=True)
fname='giga-fren.release2.fixed'
en_fname = PATH/f'{fname}.en'
fr_fname = PATH/f'{fname}.fr'
对于边界框,所有有趣的东西都在损失函数中,但对于神经翻译,所有有趣的东西都将在架构中[13:01]。让我们快速浏览一下,杰里米希望你特别考虑的一件事是我们在语言建模与神经翻译之间所做任务及如何做任务的关系或相似之处。
第一步是做与语言模型中相同的事情,即通过 RNN 传递一个句子[13:35]。
现在在分类模型中,我们有一个解码器,它接收 RNN 输出并提取三个内容:在所有时间步上的maxpool
和meanpool
,以及在最后一个时间步上的 RNN 的值,将所有这些堆叠在一起并通过一个线性层[14:24]。大多数人不这样做,只使用最后一个时间步,所以我们今天将要讨论的所有内容都使用最后一个时间步。
我们首先通过 RNN 将输入句子传递,然后得到一些“隐藏状态”(即代表编码了句子的 RNN 的输出的向量)。
编码器≈骨干[15:18]
Stephen 使用了“编码器”这个词,但我们倾向于使用“骨干”。就像当我们谈论向现有模型添加自定义头部时,例如现有的预训练 ImageNet 模型,我们说这是我们的骨干,然后我们在其上添加一些执行我们想要的任务的头部。在序列到序列学习中,他们使用“编码器”这个词,但基本上是一样的——它是神经网络架构的一部分,它接受输入并将其转换为我们可以在其上添加几层以获取某些内容的表示,就像我们为分类器所做的那样,我们在其上堆叠一个线性层以将其转换为情感。不过,这次我们要做的事情比创建情感要困难一点。我们不是将隐藏状态转换为积极或消极情感,而是要将其转换为一系列标记,这些标记是 Stephen 示例中的德语句子。
这听起来更像是语言模型而不是分类器,因为语言有多个标记(对于每个输入单词,都有一个输出单词)。但语言模型也更容易,因为语言模型输出中的标记数量与语言模型输入中的标记数量相同。不仅它们的长度相同,而且它们完全匹配(例如,单词一后面是单词二,单词二后面是单词三,依此类推)。对于翻译语言,你不一定知道单词“he”会被翻译为输出的第一个单词(不幸的是,在这种特殊情况下是这样的)。很多时候,主语宾语顺序会有所不同,或者会插入一些额外的单词,或者我们需要添加一些代词,性别化的文章等。我们将要处理的关键问题是,我们有一个任意长度的输出,其中输出中的标记与输入中的特定标记的顺序不对应。但总体思路是一样的。这里有一个 RNN 来编码输入,将其转换为一些隐藏状态,然后我们要学习的新内容是生成一个序列输出。
序列输出[17:47]
我们已经知道:
-
从序列到类别(IMDB 分类器)
-
从序列到等长序列(语言模型)
但我们还不知道如何做一个通用的序列到序列,所以这是今天的新内容。除非你真正理解第 6 课中 RNN 的工作原理,否则很少有人能理解这一点。
快速回顾第 6 课 [18:20]
我们学到,RNN 在其核心是一个标准的全连接网络。下面是一个有 4 层的网络——接受一个输入并通过四层,但在第二层,它将第二个输入连接起来,第三层将第三个输入连接起来,但实际上我们在 Python 中只写了一个四层的神经网络。除了线性层和 ReLU 之外,我们没有使用其他东西。每次输入时我们使用相同的权重矩阵,每次从一个隐藏状态到下一个时我们也使用相同的矩阵——这就是为什么这些箭头是相同颜色的原因。
我们可以将上面的图重新绘制成下面的样子[19:29]。
我们不仅重新绘制了它,还将 PyTorch 中的四行线性代码替换为一个 for 循环。记住,我们有一个与下面完全相同的东西,但只有四行代码说self.l_in(input)
,我们用一个 for 循环替换了它,因为这样重构很好。不改变任何数学、任何想法或任何输出的重构是一个 RNN。它将代码中的一堆单独的行转换为 Python 的 for 循环。
我们可以将输出放在循环内部而不是在循环外部[20:25]。如果这样做,我们现在将为每个输入生成一个单独的输出。上面的代码,隐藏状态每次都被替换,最终我们只输出最终的隐藏状态。但是如果我们有一个说hs.append(h)
并在最后返回hs
的东西,那就是下面的图片。
要记住的主要事情是当我们说隐藏状态时,我们指的是一个向量——技术上是每个小批量中的每个东西的向量,所以它是一个矩阵,但通常当 Jeremy 谈到这些事情时,他忽略了小批量部分,将其视为单个项目。
我们还学到可以将这些层堆叠在一起[21:41]。所以与其上面图中的左侧 RNN 输出,它们可以将输入传递到第二个 RNN 中。如果你此时在想“我想我理解了,但我不太确定”,那意味着你并没有理解。你真正理解的唯一方法是从头开始用 PyTorch 或 Numpy 编写这个。如果你做不到,那么你知道你并没有理解,你可以回去重新观看第 6 课,并查看笔记本,复制一些想法,直到你能够。重要的是你能够从头开始编写它——不到一屏的代码。所以你要确保你可以创建一个 2 层的 RNN。下面是展开它的样子。
为了得到(x,y)句子对,我们将从下载数据集开始[22:39]。训练一个翻译模型需要很长时间。谷歌的翻译模型有八层 RNN 堆叠在一起。八层和两层之间没有概念上的区别。如果你是谷歌,有更多的 GPU 或 TPU,那么你可以这样做。否则,在我们的情况下,我们构建的序列到序列模型很可能不需要那种计算水平。所以为了保持简单[23:22],让我们做一个简化的事情,而不是学习如何翻译法语到英语的任何句子,让我们学习如何将法语问题翻译成英语问题——具体是以 what/where/which/when 开头的问题。这里有一个正则表达式,寻找以“wh”开头并以问号结尾的内容。
re_eq = re.compile('^(Wh[^?.!]+\?)')
re_fq = re.compile('^([^?.!]+\?)')
lines = ((re_eq.search(eq), re_fq.search(fq)) for eq, fq in zip(open(en_fname, encoding='utf-8'), open(fr_fname, encoding='utf-8'))
)
qs = [(e.group(), f.group()) for e,f in lines if e and f]
我们遍历语料库,打开两个文件中的每一个,每一行是一个平行文本,将它们压缩在一起,获取英语问题和法语问题,并检查它们是否匹配正则表达式。
pickle.dump(qs, (PATH/'fr-en-qs.pkl').open('wb'))
qs = pickle.load((PATH/'fr-en-qs.pkl').open('rb'))
将其转储为一个 pickle,这样我们就不必再次执行它,现在我们有 52,000 个句子对,这里有一些示例:
qs[:5], len(qs)
'''
([('What is light ?', 'Qu’est-ce que la lumière?'),('Who are we?', 'Où sommes-nous?'),('Where did we come from?', "D'où venons-nous?"),('What would we do without it?', 'Que ferions-nous sans elle ?'),('What is the absolute location (latitude and longitude) of Badger, Newfoundland and Labrador?','Quelle sont les coordonnées (latitude et longitude) de Badger, à Terre-Neuve-etLabrador?')],52331)
'''
这样做的一个好处是,关于什么/谁/在哪里类型的问题往往相当简短。但是,我们可以从零开始学习,没有对语言概念的先前理解,更不用说英语或法语,我们可以创建一个可以将一个语言翻译成另一种语言的东西,对于任何任意问题,只需要 50k 个句子,听起来像是一个难以置信的困难任务。因此,如果我们能取得任何进展,那将是令人印象深刻的。这是一个非常少的数据来进行一个非常复杂的练习。
qs
包含法语和英语的元组。你可以使用这个方便的习语将它们分开成一个英语问题列表和一个法语问题列表。
en_qs,fr_qs = zip(*qs)
然后我们对英语问题进行标记化,对法语问题进行标记化。所以记住,这只是将它们分剒成单独的单词或类似单词的东西。默认情况下,我们这里有的标记器(记住这是一个包装在 spaCy 标记器周围的标记器,它是一个很棒的标记器)假设是英语。所以要求法语,你只需添加一个额外的参数’fr’。第一次这样做时,你会收到一个错误,说你没有安装 spaCy 法语模型,所以你可以运行python -m spacy download fr
来获取法语模型。
en_tok = Tokenizer.proc_all_mp(partition_by_cores(en_qs))
fr_tok = Tokenizer.proc_all_mp(partition_by_cores(fr_qs), 'fr')
在这里,你们中没有人会遇到 RAM 问题,因为这不是特别大的语料库,但是有些学生在这一周尝试训练新的语言模型时遇到了 RAM 问题。如果你遇到了,了解这些函数(proc_all_mp
)实际在做什么是值得的。proc_all_mp
正在跨多个进程处理每个句子:
上面的函数找出你有多少个 CPU,将其除以二(因为通常情况下,由于超线程,它们实际上并不都是并行工作的),然后并行运行这个proc_all
函数。这将为你的每个 CPU 生成一个完全独立的 Python 进程。如果你有很多核心,那就是很多 Python 进程——每个人都将加载所有这些数据,这可能会使用完所有你的 RAM。所以你可以用proc_all
替换它,而不是用proc_all_mp
来使用更少的 RAM。或者你可以只使用更少的核心。目前,我们正在调用partition_by_cores
,它在列表上调用partition
,并要求根据你有多少个 CPU 将其分剒成一些等长的部分。所以你可以将其替换为将列表分割成更小的部分,并在更少的部分上运行它。
在对英语和法语进行标记化后,你可以看到它是如何分割的。
en_tok[0], fr_tok0
你可以看到法语的标记化看起来非常不同,因为法语喜欢他们的撇号和连字符。因此,如果您尝试为法语句子使用英语标记器,您将得到一个相当糟糕的结果。您不需要了解大量的自然语言处理(NLP)理念来使用深度学习进行自然语言处理,但只需要一些基本的东西,比如使用正确的标记器对于您的语言是重要的。本周我们研究小组中的一些学生一直在尝试为中文实例构建语言模型,当然中文并没有真正的标记化概念,所以我们开始研究sentence piece,它将事物分割成任意的子词单元,所以当 Jeremy 说标记化时,如果您使用的是没有空格的语言,您应该考虑使用 sentence piece 或其他类似的子词单元。希望在接下来的一两周内,我们将能够报告这些中文实验的一些早期结果。
np.percentile([len(o) for o in en_tok], 90),
np.percentile([len(o) for o in fr_tok], 90)
'''
(23.0, 28.0)
'''
keep = np.array([len(o)<30 for o in en_tok])
en_tok = np.array(en_tok)[keep]
fr_tok = np.array(fr_tok)[keep]
pickle.dump(en_tok, (PATH/'en_tok.pkl').open('wb'))
pickle.dump(fr_tok, (PATH/'fr_tok.pkl').open('wb'))
en_tok = pickle.load((PATH/'en_tok.pkl').open('rb'))
fr_tok = pickle.load((PATH/'fr_tok.pkl').open('rb'))
所以在标记化之后,我们将其保存到磁盘。然后记住,在我们创建标记之后的下一步是将它们转换为数字。为此,我们有两个步骤——第一步是获取所有出现的单词的列表,然后我们将每个单词转换为索引。如果出现的单词超过 40,000 个,那么让我们在那里截断,以免变得太疯狂。我们插入一些额外的标记,用于流的开始(_bos_
)、填充(_pad_
)、流的结束(_eos_
)和未知(_unk
)。因此,如果我们尝试查找不在最常见的 40,000 个单词中的东西,那么我们使用deraultdict
返回 3,即未知。
def toks2ids(tok,pre):freq = Counter(p for o in tok for p in o)itos = [o for o,c in freq.most_common(40000)]itos.insert(0, '_bos_')itos.insert(1, '_pad_')itos.insert(2, '_eos_')itos.insert(3, '_unk')stoi = collections.defaultdict(lambda: 3, {v:k for k,v in enumerate(itos)})ids = np.array([([stoi[o] for o in p] + [2]) for p in tok])np.save(TMP_PATH/f'{pre}_ids.npy', ids)pickle.dump(itos, open(TMP_PATH/f'{pre}_itos.pkl', 'wb'))return ids,itos,stoi
现在我们可以继续,通过将每个标记放入我们刚刚创建的字符串到整数字典(stoi
)中,将每个标记转换为 ID,然后在最后添加数字 2,即流的结束。你在这里看到的代码是 Jeremy 在迭代和实验时编写的代码。因为他在迭代和实验时编写的代码中,99%都是完全错误的、愚蠢的或令人尴尬的,你看不到。但是在他编写代码时,没有必要重构它并使其变得美观,所以他希望你看到他所有的小技巧。与其为_eos_
标记使用某个常量并使用它,当他在原型设计时,他只做简单的事情。并不是说他最终会得到错误的代码,但他试图在美丽的代码和可行的代码之间找到一些折中。
问题:刚听他提到我们将 CPU 数量除以 2,因为使用超线程时,我们不会通过使用所有超线程核心来加速。这是基于实际经验还是有一些潜在原因导致我们无法获得额外的加速?是的,这只是实际经验,并不是所有事情都像这样,但我确实注意到在标记化时,超线程似乎会使事情变慢一点。此外,如果我使用所有核心,通常我想同时做一些其他事情(比如运行一些交互式笔记本),我没有多余的空间来做那些事情。
现在对于我们的英语和法语,我们可以获取一个 ID 列表en_ids
。当我们这样做时,当然,我们需要确保我们也存储了词汇。如果我们不知道数字 5 代表什么,那么拥有 ID 就没有意义,拥有数字 5 也没有意义。所以这就是我们的词汇en_itos
和反向映射en_stoi
,我们可以用它们来在将来转换更多的语料库。
en_ids,en_itos,en_stoi = toks2ids(en_tok,'en')
fr_ids,fr_itos,fr_stoi = toks2ids(fr_tok,'fr')
为了确认它是否有效,我们可以通过每个 ID,将 int 转换为字符串,并将其输出 - 现在我们的句子已经回来了,末尾有一个流标记。我们的英语词汇量为 17,000,法语词汇量为 25,000,所以我们处理的词汇量既不太大也不太复杂。
def load_ids(pre):ids = np.load(TMP_PATH/f'{pre}_ids.npy')itos = pickle.load(open(TMP_PATH/f'{pre}_itos.pkl', 'rb'))stoi = collections.defaultdict(lambda: 3, {v:k for k,v in enumerate(itos)})return ids,itos,stoien_ids,en_itos,en_stoi = load_ids('en')
fr_ids,fr_itos,fr_stoi = (load_ids('fr')[fr_itos[o] for o in fr_ids[0]], len(en_itos), len(fr_itos)
)
'''
(['qu’', 'est', '-ce', 'que', 'la', 'lumière', '?', '_eos_'], 17573, 24793)
'''
词向量[32:53]
在这一周的论坛上,我们花了很多时间讨论词向量是多么无聊,以及你应该停止对它们感到兴奋 - 现在我们要使用它们。为什么?我们一直在学习如何使用语言模型和预训练的正确模型,而不是预训练的线性单层,这就是词向量的内容,同样适用于序列到序列。但 Jeremy 和 Sebastian 正在开始研究这个问题。对于任何有兴趣创造一些真正新颖且高度可发表的结果的人来说,序列到序列与预训练语言模型的整个领域尚未被触及。Jeremy 相信这将和分类一样好。如果您在这方面有所作为,并且您已经有了一些看起来令人兴奋的东西,并且您希望得到帮助发表它,Jeremy 非常乐意帮助共同撰写论文。因此,当您有一些有趣的结果时,请随时联系。
在这个阶段,我们没有任何东西,所以我们将使用非常少的 fastai[34:14]。我们只有词向量 - 所以让我们至少使用体面的词向量。Word2vec 是非常古老的词向量。现在有更好的词向量,而 fast.text 是一个相当不错的词向量来源。有数百种语言可用,您的语言可能会被代表。
fasttext 词向量可从fasttext.cc/docs/en/english-vectors.html
获取
fasttext Python 库在 PyPI 中不可用,但这里有一个方便的技巧[35:03]。如果有一个 GitHub 存储库,其中包含 setup.py 和 reqirements.txt,您只需在开头加上git+
,然后将其放入pip install
中,它就会起作用。几乎没有人似乎知道这一点,如果您去 fasttext 存储库,他们不会告诉您这一点 - 他们会告诉您必须下载它并cd
进入它,等等,但您不必这样做。您只需运行以下命令:
# !pip install git+https://github.com/facebookresearch/fastText.git
import fastText as ft
要使用 fastText 库,您需要下载fasttext 词向量(下载“bin plus text”)。
en_vecs = ft.load_model(str((PATH/'wiki.en.bin')))
fr_vecs = ft.load_model(str((PATH/'wiki.fr.bin')))
以上是我们的英语和法语模型。有文本版本和二进制版本。二进制版本更快,所以我们将使用它。文本版本也有点 buggy。我们将把它转换为标准的 Python 字典,以使其更容易使用[35:55]。这只是通过字典理解遍历每个单词,并将其保存为 pickle 字典:
def get_vecs(lang, ft_vecs):vecd = {w:ft_vecs.get_word_vector(w) for w in ft_vecs.get_words()}pickle.dump(vecd, open(PATH/f'wiki.{lang}.pkl','wb'))return vecden_vecd = get_vecs('en', en_vecs)
fr_vecd = get_vecs('fr', fr_vecs)
en_vecd = pickle.load(open(PATH/'wiki.en.pkl','rb'))
fr_vecd = pickle.load(open(PATH/'wiki.fr.pkl','rb'))
ft_words = ft_vecs.get_words(include_freq=True)
ft_word_dict = {k:v for k,v in zip(*ft_words)}
ft_words = sorted(ft_word_dict.keys(), key=lambda x: ft_word_dict[x]
)
现在我们有了我们的 pickle 字典,我们可以继续查找一个单词,例如逗号[36:07]。这将返回一个向量。向量的长度是这组词向量的维度。在这种情况下,我们有 300 维的英语和法语词向量。
dim_en_vec = len(en_vecd[','])
dim_fr_vec = len(fr_vecd[','])
dim_en_vec,dim_fr_vec
'''
(300, 300)
'''
出于即将看到的原因,我们还想找出我们的向量的平均值和标准差。所以平均值约为零,标准差约为 0.3。
en_vecs = np.stack(list(en_vecd.values()))
en_vecs.mean(),en_vecs.std()
'''
(0.0075652334, 0.29283327)
'''
模型数据[36:48]
通常,语料库的序列长度具有相当长尾的分布,而最长的序列往往会压倒性地影响时间、内存使用等。因此,在这种情况下,我们将获取英语和法语的第 99 到 97 百分位数,并将它们截断到该数量。最初 Jeremy 使用的是 90 百分位数(因此变量名):
enlen_90 = int(np.percentile([len(o) for o in en_ids], 99))
frlen_90 = int(np.percentile([len(o) for o in fr_ids], 97))
enlen_90,frlen_90
'''
(29, 33)
'''
我们快要完成了[37:24]。我们已经有了我们的标记化、数字化的英语和法语数据集。我们有一些词向量。现在我们需要为 PyTorch 准备好它。PyTorch 需要一个Dataset
对象,希望到现在为止你可以说一个 Dataset 对象需要两个东西——一个长度(__len__
)和一个索引器(__getitem__
)。Jeremy 开始编写Seq2SeqDataset
,结果只是一个通用的Dataset
[37:52]。
en_ids_tr = np.array([o[:enlen_90] for o in en_ids])
fr_ids_tr = np.array([o[:frlen_90] for o in fr_ids])
class Seq2SeqDataset(Dataset):def __init__(self, x, y): self.x,self.y = x,ydef __getitem__(self, idx): return A(self.x[idx], self.y[idx])def __len__(self): return len(self.x)
-
A
:数组。它将遍历您传递的每个对象,如果它还不是一个 numpy 数组,它会将其转换为一个 numpy 数组,并返回一个元组,其中包含您传递的所有现在保证为 numpy 数组的对象[38:32]。 -
V
:变量 -
T
:张量
训练集和验证集[39:03]
现在我们需要获取我们的英语和法语 ID,并获得一个训练集和一个验证集。互联网上许多代码令人失望的一点是它们没有遵循一些简单的最佳实践。例如,如果你去 PyTorch 网站,他们有一个关于序列到序列翻译的示例部分。他们的示例没有单独的验证集。Jeremy 尝试根据他们的设置进行训练,并使用验证集进行测试,结果发现它严重过拟合。因此,这不仅仅是一个理论问题——实际的 PyTorch 存储库有实际的官方序列到序列翻译示例,它没有检查过拟合,严重过拟合[39:41]。此外,它没有使用小批量,因此实际上没有充分利用 PyTorch 的任何效率。即使你在官方 PyTorch 存储库中找到代码,也不要认为它是好的。你会注意到的另一件事是,Jeremy 在互联网上找到的几乎每个 PyTorch 序列到序列模型都明显是从那个糟糕的 PyTorch 存储库中复制的,因为它们都有相同的变量名,有相同的问题,有相同的错误。
另一个例子是,Jeremy 找到的几乎每个 PyTorch 卷积神经网络都没有使用自适应池化层[40:27]。换句话说,最终层总是平均池化(7,7)。他们假设前一层是 7 乘 7,如果你使用任何其他大小的输入,你会得到一个异常,因此几乎每个使用 PyTorch 的人都认为 CNNs 有一个基本限制,即它们与输入大小相关联,这自从 VGG 以来就不再成立。因此,每当 Jeremy 拿到一个新模型并将其放入 fastai 存储库时,他都必须搜索“pool”并在开头添加“adaptive”,将 7 替换为 1,现在它适用于任何大小的对象。所以要小心。现在仍然是早期阶段,信不信由你,即使你们大多数人只在过去一年开始了深度学习之旅,你们对许多更重要的实际方面了解的要比大多数在官方存储库中发布和编写东西的人多得多。因此,当阅读其他人的代码时,你需要比你期望的更有一些自信。如果你发现自己在想“那看起来很奇怪”,那不一定是你。
如果你正在查看的存储库没有一个部分说这里是我们做的测试,我们得到了与应该实现的论文相同的结果,那几乎肯定意味着他们没有得到他们正在实现的论文相同的结果,甚至可能根本没有检查[42:13]。如果你运行它,肯定不会得到那些结果,因为第一次做对事情很难——Jeremy 需要尝试 12 次。如果他们没有测试过一次,几乎肯定不会起作用。
这是一个获取训练和验证集的简单方法[42:45]。获取一堆随机数 - 每行数据一个,然后看它们是否大于 0.1。这会给你一个布尔值列表。使用该布尔值列表索引到你的数组中以获取一个训练集,使用该布尔值列表的相反值索引到该数组中以获取你的验证集。
np.random.seed(42)
trn_keep = np.random.rand(len(en_ids_tr))>0.1
en_trn,fr_trn = en_ids_tr[trn_keep],fr_ids_tr[trn_keep]
en_val,fr_val = en_ids_tr[~trn_keep],fr_ids_tr[~trn_keep]
len(en_trn),len(en_val)
'''
(45219, 5041)
'''
现在我们可以用我们的 X 和 Y(即法语和英语)创建我们的数据集[43:12]。如果你想将英语翻译成法语,只需交换这两个,就完成了。
trn_ds = Seq2SeqDataset(fr_trn,en_trn)
val_ds = Seq2SeqDataset(fr_val,en_val)
现在我们需要创建 DataLoaders[43:22]。我们只需获取我们的数据加载器并传入我们的数据集和批量大小。我们实际上必须转置数组 - 我们不会详细讨论为什么,但如果你感兴趣,我们可以在这一周讨论,但想一想为什么我们可能需要转置它们的方向。由于我们已经完成了所有的预处理,没有必要启动多个工作人员来进行增强等工作,因为没有工作要做。因此,使 num_workers=1
会节省一些时间。我们必须告诉它我们的填充索引是什么 - 这非常重要,因为将会发生的是,我们有不同长度的句子,fastai 将自动将它们粘在一起并填充较短的句子,使它们长度相等。记住张量必须是矩形的。
bs=125
trn_samp = SortishSampler(en_trn, key=lambda x: len(en_trn[x]), bs=bs
)
val_samp = SortSampler(en_val, key=lambda x: len(en_val[x]))
trn_dl = DataLoader(trn_ds, bs, transpose=True, transpose_y=True, num_workers=1, pad_idx=1, pre_pad=False, sampler=trn_samp
)
val_dl = DataLoader(val_ds, int(bs*1.6), transpose=True, transpose_y=True, num_workers=1, pad_idx=1,pre_pad=False, sampler=val_samp
)
md = ModelData(PATH, trn_dl, val_dl)
特别是在解码器中,我们希望我们的填充在末尾,而不是在开头[44:29]:
-
分类器 → 在开头填充。因为我们希望最终的标记代表电影评论的最后一个单词。
-
解码器 → 在末尾填充。正如你将看到的,将填充放在末尾实际上会更好一些。
采样器 [44:54] 最后,由于我们输入的句子长度不同,它们都必须通过填充放在一个小批次中以使它们具有相同的大小,我们更希望小批次中的句子已经具有相似的大小。否则,它将与最长的句子一样长,这将浪费时间和内存。因此,我们将使用上次学到的采样器技巧,即对验证集,我们将要求它首先按长度排序。然后对于训练集,我们将随机排列事物的顺序,但大致使得长度相似的事物大致在同一位置。
模型数据 [45:40] 在这一点上,我们可以创建一个模型数据对象 - 记住,模型数据对象实际上只做一件事,那就是它说“我有一个训练集和一个验证集,还有一个可选的测试集”,然后把它们放入一个单一对象中。我们还有一个路径,这样它就有地方存储临时文件、模型等等。
在这个例子中,我们几乎没有使用 fastai。我们使用了与 PyTorch 兼容的数据集和数据加载器 - 在幕后实际上使用的是 fastai 版本,因为我们需要它来方便地进行自动填充,因此在 fastai 版本中有一些稍微快速和更方便的调整。我们还使用了 fastai 的采样器,但这里没有太多的事情发生。
架构[46:59]
-
这个架构将接受我们的标记序列。
-
它将把它们传递给一个编码器(又名骨干)。
-
这将输出最终的隐藏状态,对于每个句子,它只是一个单一的向量。
这一切都不会是新的[47:41]。这一切都将使用我们已经学过的非常直接简单的技术。
- 然后我们将把它传递到另一个 RNN 中,这是一个解码器。这将有一些新的东西,因为我们需要一个可以逐个单词地进行处理的东西。它会一直进行下去,直到它认为已经完成了句子。它不知道句子将有多长。它会一直进行下去,直到它认为已经完成了句子,然后停止并返回一个句子。
def create_emb(vecs, itos, em_sz):emb = nn.Embedding(len(itos), em_sz, padding_idx=1)wgts = emb.weight.datamiss = []for i,w in enumerate(itos):try: wgts[i] = torch.from_numpy(vecs[w]*3)except: miss.append(w)print(len(miss),miss[5:10])return emb
nh,nl = 256,2
让我们从编码器开始[48:15]。在这里的变量命名方面,编码器和解码器具有相同的属性。编码器版本有enc
,解码器版本有dec
。
-
emb_enc
:编码器的嵌入 -
gru
:RNN。GRU 和 LSTM 几乎是相同的东西。
我们需要创建一个嵌入层,因为要记住 - 我们传递的是单词在词汇表中的索引。我们想要获取它们的 fast.text 嵌入。随着时间的推移,我们可能还想微调以端到端地训练该嵌入。
create_emb
[49:37]:现在重要的是您知道如何设置嵌入的行和列,因此行数必须等于您的词汇量大小 - 因此每个词汇都有一个词向量。嵌入的大小由 fast.text 确定,fast.text 嵌入的大小为 300。因此我们也必须使用大小 300,否则我们无法使用它们的嵌入开始。
nn.Embedding
最初会给我们一组随机的嵌入[50:12]。所以我们将遍历每一个,如果在 fast.text 中找到它,我们将用 fast.text 嵌入替换它。再次提醒您应该已经知道的是(emb.weight.data
):
-
一个可学习的 PyTorch 模块具有
weight
属性。 -
weight
属性是一个具有data
属性的Variable
。 -
data
属性是一个张量
现在我们有了权重张量,我们可以遍历我们的词汇表,查找我们预训练向量中的单词,如果找到,我们将用该预训练向量替换随机权重[52:35]。随机权重的标准差为 1。我们的预训练向量的标准差约为 0.3。所以,这是 Jeremy 在原型设计时做的一种巧妙的事情,他只是将其乘以 3。当您看到这个视频时,我们可能已经能够将所有这些序列到序列的内容放入 fastai 库中,您在那里不会找到这样的可怕的黑客行为(希望如此)。但在原型设计时可以尝试各种方法。有些东西可能不在 fast.text 中,这种情况下,我们将继续跟踪[53:22]。打印语句是为了让我们看到发生了什么(即为什么我们会丢失东西?)。记住我们大约有 30,000 个,所以我们不会丢失太多。
3097 ['l’', "d'", 't_up', 'd’', "qu'"]
1285 ["'s", '’s', "n't", 'n’t', ':']
Jeremy 已经开始做一些关于将大词汇量处理整合到 fastai 中的工作 - 还没有完成,但希望到达这里时,这种工作将是可能的[56:50]。
class Seq2SeqRNN(nn.Module):def __init__(self, vecs_enc, itos_enc, em_sz_enc, vecs_dec, itos_dec, em_sz_dec, nh, out_sl, nl=2):super().__init__()self.nl,self.nh,self.out_sl = nl,nh,out_slself.emb_enc = create_emb(vecs_enc, itos_enc, em_sz_enc)self.emb_enc_drop = nn.Dropout(0.15)self.gru_enc = nn.GRU(em_sz_enc, nh, num_layers=nl, dropout=0.25)self.out_enc = nn.Linear(nh, em_sz_dec, bias=False)self.emb_dec = create_emb(vecs_dec, itos_dec, em_sz_dec)self.gru_dec = nn.GRU(em_sz_dec, em_sz_dec, num_layers=nl, dropout=0.1)self.out_drop = nn.Dropout(0.35)self.out = nn.Linear(em_sz_dec, len(itos_dec))self.out.weight.data = self.emb_dec.weight.datadef forward(self, inp):sl,bs = inp.size()h = self.initHidden(bs)emb = self.emb_enc_drop(self.emb_enc(inp))enc_out, h = self.gru_enc(emb, h)h = self.out_enc(h)dec_inp = V(torch.zeros(bs).long())res = []for i in range(self.out_sl):emb = self.emb_dec(dec_inp).unsqueeze(0)outp, h = self.gru_dec(emb, h)outp = self.out(self.out_drop(outp[0]))res.append(outp)dec_inp = V(outp.data.max(1)[1])if (dec_inp==1).all(): breakreturn torch.stack(res)def initHidden(self, bs): return V(torch.zeros(self.nl, bs, self.nh))
要知道的关键是编码器接收我们的输入并输出一个隐藏向量,希望它能学会包含关于句子内容以及如何设置的所有信息[58:49]。如果它做不到,我们就不能将其输入解码器,并希望它将句子翻译成另一种语言。这就是我们希望它学会的。我们不会采取任何特殊措施来让它学会这样做 - 我们只会做三件事(数据、架构、损失函数),然后抱着幸运的心态。
解码器[59:58]:我们现在如何处理新的部分?新部分的基本思想是相同的。我们将做完全相同的事情,但我们将编写自己的 for 循环。这个 for 循环将完全执行 PyTorch 中编码器内部的 for 循环,但我们将手动执行。for 循环有多大?它是一个输出序列长度(out_sl
),这是传递给构造函数的一个参数,它等于最长英语句子的长度。因为我们正在翻译成英语,所以在这个语料库中至少不可能比这更长。如果我们将其用于某个更长的不同语料库,这将失败 —— 当然你可以传入不同的参数。因此,基本思想是相同的[1:01:06]。
-
我们将通过嵌入层。
-
我们将通过 RNN、dropout 和线性层。
-
然后,我们将输出附加到一个列表中,该列表将堆叠成一个单个张量并返回。
通常,递归神经网络一次处理整个序列,但我们有一个 for 循环来分别处理序列的每个部分[1:01:37]。因此,我们必须在开头添加一个主要单位轴(.unsqueeze(0)
)来表示这是一个长度为一的序列。我们实际上并没有充分利用递归网络 —— 我们可以很容易地用线性层重写这个。
要注意的一件事是dec_inp
[1:02:34]:嵌入的输入是什么?答案是前一个我们翻译的单词。基本思想是,如果你试图翻译新句子的第四个单词,但你不知道刚说的第三个单词是什么,那将非常困难。因此,我们将在每个时间步骤中提供这个信息。在开始时,前一个单词是什么?没有。具体来说,我们将从一个流的开始标记(_bos_
)开始,该标记为零。
outp
[1:05:24]:它是一个张量,其长度等于我们英语词汇中的单词数,其中包含每个单词是该单词的概率。
outp.data.max
:它在其张量中查找具有最高概率的单词。PyTorch 中的max
返回两个值:第一个是最大概率,第二个是该最大概率在数组中的索引。因此,我们想要第二个项目,即具有最大值的单词索引。
dec_inp
:它包含单词在词汇表中的索引。如果是 1(即填充),那么表示我们已经完成了 —— 我们已经以一堆填充结束了。如果不是 1,让我们回去继续。
每次,我们将输出(不是单词,而是概率)附加到列表[1:06:48]中,然后将其堆叠成一个张量,然后我们可以继续将其馈送到损失函数中。
损失函数[1:07:13]
损失函数是分类交叉熵损失。我们有一个概率列表,对应每个类别,其中类别是我们英语词汇中的所有单词,我们有一个目标,即正确的类别(即在此位置的正确单词)。有两个调整,这就是为什么我们需要编写自己的损失函数,但基本上可以看到它将是交叉熵损失。
def seq2seq_loss(input, target):sl,bs = target.size()sl_in,bs_in,nc = input.size()if sl>sl_in: input = F.pad(input, (0,0,0,0,0,sl-sl_in))input = input[:sl]return F.cross_entropy(input.view(-1,nc), target.view(-1))
调整[1:07:40]:
- 如果生成的序列长度短于目标序列长度,我们需要添加一些填充。PyTorch 填充函数需要一个 6 元组来填充一个秩为 3 的张量(序列长度、批量大小、词汇表中的单词数)。每对表示在该维度之前和之后的填充。
F.cross_entropy
期望一个秩为 2 的张量,但我们有序列长度乘以批量大小,所以让我们展平它。这就是 view(-1, ...)
做的事情。
opt_fn = partial(optim.Adam, betas=(0.8, 0.99))
.cuda()
和 to_gpu()
之间的区别:如果没有 GPU,to_gpu
不会将其放入 GPU。您还可以将 fastai.core.USE_GPU
设置为 false
,以强制它不使用 GPU,这对调试很方便。
rnn = Seq2SeqRNN(fr_vecd, fr_itos, dim_fr_vec, en_vecd, en_itos, dim_en_vec, nh, enlen_90
)
learn = RNN_Learner(md, SingleModel(to_gpu(rnn)), opt_fn=opt_fn)
learn.crit = seq2seq_loss
'''
3097 ['l’', "d'", 't_up', 'd’', "qu'"]
1285 ["'s", '’s', "n't", 'n’t', ':']
'''
然后我们需要一些东西告诉它如何处理学习率组,所以有一个叫做 SingleModel
的东西,你可以传递给它,它将整个东西视为一个单一的学习率组。这是将 PyTorch 模块转换为 fastai 模型的最简单方法。
我们可以直接调用 Learner 将其转换为一个学习器,但如果我们调用 RNN_Learner,它会添加 save_encoder
和 load_encoder
,有时会很方便。在这种情况下,我们确实可以说 Leaner
,但 RNN_Learner
也可以。
learn.lr_find()
learn.sched.plot()
lr=3e-3
learn.fit(lr, 1, cycle_len=12, use_clr=(20,10))
'''
epoch trn_loss val_loss 0 5.48978 5.462648 1 4.616437 4.770539 2 4.345884 4.37726 3 3.857125 4.136014 4 3.612306 3.941867 5 3.375064 3.839872 6 3.383987 3.708972 7 3.224772 3.664173 8 3.238523 3.604765 9 2.962041 3.587814 10 2.96163 3.574888 11 2.866477 3.581224
[3.5812237]
'''
learn.save('initial')
learn.load('initial')
测试
记住,学习器的模型属性是一个标准的 PyTorch 模型,所以我们可以传递一些 x
,我们可以从验证集中获取,或者您可以使用 learn.predict_array
或其他方法来获取一些预测。然后我们通过 .max()[1]
将这些预测转换为单词,以获取概率最高的单词的索引。然后我们可以通过一些示例,打印出法语、正确的英语和预测的英语,对于那些不是填充的内容。
x,y = next(iter(val_dl))
probs = learn.model(V(x))
preds = to_np(probs.max(2)[1])for i in range(180,190):print(' '.join([fr_itos[o] for o in x[:,i] if o != 1]))print(' '.join([en_itos[o] for o in y[:,i] if o != 1]))print(' '.join([en_itos[o] for o in preds[:,i] if o!=1]))print()
'''
quels facteurs pourraient influer sur le choix de leur emplacement ? _eos_
what factors influencetheir location ? _eos_
what factors might might influence on the their ? ? _eos_qu’ est -ce qui ne peut pas changer ? _eos_
what can not change ? _eos_
what not change change ? _eos_que faites - vous ? _eos_
what do you do ? _eos_
what do you do ? _eos_qui réglemente les pylônes d' antennes ? _eos_
who regulates antenna towers ? _eos_
who regulates the doors doors ? _eos_où sont - ils situés ? _eos_
where are they located ? _eos_
where are the located ? _eos_quelles sont leurs compétences ? _eos_
what are their qualifications ? _eos_
what are their skills ? _eos_qui est victime de harcèlement sexuel ? _eos_
who experiences sexual harassment ? _eos_
who is victim sexual sexual ? ? _eos_quelles sont les personnes qui visitent les communautés autochtones ? _eos_
who visits indigenous communities ? _eos_
who are people people aboriginal aboriginal ? _eos_pourquoi ces trois points en particulier ? _eos_
why these specific three ? _eos_
why are these two different ? ? _eos_pourquoi ou pourquoi pas ? _eos_
why or why not ? _eos_
why or why not _eos_
'''
令人惊讶的是,这种可能是最简单的从头开始编写的 PyTorch 模块,仅有五万个句子,有时在验证集上能够给出完全正确的答案。有时正确答案略有不同措辞,有时句子真的不通顺,甚至有太多的问号。所以我们在正确的轨道上。我们认为您会同意,即使是可能是最简单的 seq-to-seq 模型,经过很少的迭代训练,除了使用词嵌入之外没有任何预训练,效果也出奇的好。我们以后会改进这一点,但这里的信息是,即使您认为序列到序列模型比您认为的更简单,即使使用比您认为的更少的数据进行学习,也可能会出奇地有效,在某些情况下,这可能已经足够满足您的需求。
问题:规范标点符号(例如 ’
vs. '
)会有帮助吗?这种特定情况的答案可能是肯定的——弯引号和直引号之间的区别实际上是语义上的。但是你必须非常小心,因为可能会发现使用漂亮的弯引号的人更喜欢使用更正式的语言,他们的写作方式也不同。因此,如果你要进行某种类似标点符号规范化的预处理,你应该绝对检查带有和不带有这种预处理的结果,因为几乎总是这种预处理会使事情变得更糟,即使你确信它不会。
问题:除了 dropout 和权重衰减,有哪些正则化这些 seq2seq 模型的方法?让我在这一周内考虑一下。我们一直依赖的 AWD-LSTM 有许多不同种类的 dropout,还有一种基于激活和变化的正则化。Jeremy 还没有看到有人将这么多工作投入到正则化序列到序列模型中,有一个巨大的机会让某人像 AWD-LSTM 一样对 seq-to-seq 进行正则化,这可能就像从 AWD-LSTM 中窃取所有想法并直接在 seq-to-seq 中使用它们一样容易尝试。最近几周 Stephen Merity 添加了一篇有趣的论文,他使用了一个想法,即获取所有这些不同的 AWD-LSTM 超参数并训练一堆不同的模型,然后使用随机森林找出最重要的特征,然后找出如何设置它们。你完全可以使用这种方法来找出对序列到序列正则化方法哪种是最好的,并优化它们,这将是令人惊讶的。但目前,我们不知道除了那篇关于常规语言模型的论文之外,是否还有其他关于序列到序列正则化的想法。
技巧
技巧#1:使用双向
对于分类,Jeremy 建议使用的双向方法是获取所有的标记序列,旋转它们,训练一个新的语言模型,然后训练一个新的分类器。他还提到,如果你在名称中用bwd
替换fwd
,你将得到他为你创建的预训练的后向模型。获取一组预测,然后像普通集成一样对预测进行平均。这就是我们在这种分类中进行双向的方式。可能有一些方法可以端到端地完成,但 Jeremy 还没有完全弄清楚,而且它们还没有在 fastai 中。所以如果你弄清楚了,那是一个有趣的研究方向。但因为我们不是在处理大量文档,需要将其分成单独的部分,然后对它们进行汇总,所以在这种情况下我们可以很容易地进行双向。只需将bidirectional=True
添加到我们的编码器中就可以了。人们倾向于不对解码器进行双向处理,部分原因是因为这被认为是作弊,但也许在某些情况下它可能有效,尽管在解码器中可能需要更多的集成方法,因为这不太明显。但对于编码器来说很简单——bidirectional=True
,现在我们有了一个沿着相反方向的第二个 RNN。第二个 RNN 按相反顺序访问每个标记,因此当我们到达最终隐藏状态时,它是第一个(即最左边)标记。但隐藏状态的大小是相同的,因此最终的结果是我们得到了一个长度为 2 的额外轴的张量。根据你使用的库,通常这将与层数相结合,所以如果你有 2 层和双向——那个张量维度现在是长度 4。对于 PyTorch,取决于你查看的过程的哪一部分,你是否会得到每一层和/或每个双向位的单独结果。你必须查阅文档,它会告诉你适用于层数的输入输出张量大小以及是否有bidirectional=True
。
在这种特殊情况下,你将看到必须进行的所有更改。例如,当我们添加了bidirectional=True
时,Linear
层现在需要隐藏数量乘以 2(即nh*2
)来反映我们隐藏状态中有第二个方向的事实。在initHidden
中现在是self.nl*2
。
class Seq2SeqRNN_Bidir(nn.Module):def __init__(self, vecs_enc, itos_enc, em_sz_enc, vecs_dec, itos_dec, em_sz_dec, nh, out_sl, nl=2):super().__init__()self.emb_enc = create_emb(vecs_enc, itos_enc, em_sz_enc)self.nl,self.nh,self.out_sl = nl,nh,out_slself.gru_enc = nn.GRU(em_sz_enc, nh, num_layers=nl,dropout=0.25, bidirectional=True)self.out_enc = nn.Linear(nh*2, em_sz_dec, bias=False)self.drop_enc = nn.Dropout(0.05)self.emb_dec = create_emb(vecs_dec, itos_dec, em_sz_dec)self.gru_dec = nn.GRU(em_sz_dec, em_sz_dec, num_layers=nl,dropout=0.1)self.emb_enc_drop = nn.Dropout(0.15)self.out_drop = nn.Dropout(0.35)self.out = nn.Linear(em_sz_dec, len(itos_dec))self.out.weight.data = self.emb_dec.weight.datadef forward(self, inp):sl,bs = inp.size()h = self.initHidden(bs)emb = self.emb_enc_drop(self.emb_enc(inp))enc_out, h = self.gru_enc(emb, h)h = h.view(2,2,bs,-1) \ .permute(0,2,1,3).contiguous() \.view(2,bs,-1)h = self.out_enc(self.drop_enc(h)) dec_inp = V(torch.zeros(bs).long())res = []for i in range(self.out_sl):emb = self.emb_dec(dec_inp).unsqueeze(0)outp, h = self.gru_dec(emb, h)outp = self.out(self.out_drop(outp[0]))res.append(outp)dec_inp = V(outp.data.max(1)[1])if (dec_inp==1).all(): breakreturn torch.stack(res)def initHidden(self, bs): return V(torch.zeros(self.nl*2, bs, self.nh))
为什么将解码器设置为双向被认为是作弊?这不仅仅是作弊,而且我们有这种循环进行,所以不仅仅是有两个张量那么简单。那么如何将这两个单独的循环转换为最终结果呢?在休息期间讨论过后,Jeremy 已经从“每个人都知道这不起作用”变成“也许它可能起作用”,但需要更多的思考。在这一周期间,他可能会意识到这是一个愚蠢的想法,但我们会考虑一下。
为什么需要为循环设置一个范围?因为当我们开始训练时,一切都是随机的,所以if (dec_inp==1).all(): break
可能永远不会成立。后来,它最终几乎总是会中断,但基本上我们会永远进行下去。在设计架构时,非常重要的一点是要记住,当你开始时,模型对任何事情一无所知。所以你要确保如果它要做一些事情,至少它是模糊合理的。
我们使用单向获得了 3.58 的交叉熵损失。使用双向后,我们降到了 3.51,所以稍微有所改善。这不会真正减慢速度太多。双向意味着需要进行更多的顺序处理,但通常是一个很好的胜利。在 Google 翻译模型中,8 层中只有第一层是双向的,因为它允许它更多地并行进行,所以如果你创建了非常深的模型,你可能需要考虑哪些是双向的,否则我们会有性能问题。
rnn = Seq2SeqRNN_Bidir(fr_vecd, fr_itos,dim_fr_vec, en_vecd, en_itos, dim_en_vec, nh, enlen_90
)
learn = RNN_Learner(md, SingleModel(to_gpu(rnn)), opt_fn=opt_fn)
learn.crit = seq2seq_losslearn.fit(lr, 1, cycle_len=12, use_clr=(20,10))
'''
epoch trn_loss val_loss 0 4.896942 4.761351 1 4.323335 4.260878 2 3.962747 4.06161 3 3.596254 3.940087 4 3.432788 3.944787 5 3.310895 3.686629 6 3.454976 3.638168 7 3.093827 3.588456 8 3.257495 3.610536 9 3.033345 3.540344 10 2.967694 3.516766 11 2.718945 3.513977
[3.5139771]
'''
技巧#2 教师强制
现在让我们谈谈教师强制。当模型开始学习时,它对一无所知。所以当模型开始学习时,它不会在第一步就吐出“Er”,它会吐出一些随机无意义的单词,因为它对德语、英语或语言的概念一无所知。它会将其作为输入馈送到下一个过程中,并且完全没有帮助。这意味着早期学习会非常困难,因为它将一个愚蠢的输入馈送到一个一无所知的模型中,但不知何故它会变得更好。所以最终它会到达那里,但肯定不像我们可以做的那样有帮助。那么,如果我们不是输入我刚才预测的东西,而是输入实际正确的单词,会怎样呢?我们在推理时无法这样做,因为根据定义,我们不知道正确的单词 - 必须将其翻译。我们不能要求正确的翻译来进行翻译。
设置的方式是我们有一个叫做pr_force
的东西,它是强制的概率。如果某个随机数小于该概率,那么我们将用实际正确的东西替换我们的解码器输入。如果我们已经走得太远,如果它已经比目标序列长,我们就会停止,因为显然我们无法给出正确的东西。你可以看到 PyTorch 在这方面是多么美妙。去年课程的这个确切时刻,我们切换到 PyTorch 的关键原因是因为 Jeremy 尝试在 Keras 和 TensorFlow 中实现教师强制,结果比之前更疯狂。几周都没有进展,然后他在 Twitter 上看到 Andrej Karpathy 提到了一个叫做 PyTorch 的东西,很酷。他当天尝试了一下,第二天就有了教师强制。所有这些尝试调试的事情突然变得容易得多,这种动态的东西也变得容易得多。所以这是一个很好的例子,“嘿,我可以使用随机数和 if 语句”。
class Seq2SeqStepper(Stepper):def step(self, xs, y, epoch):self.m.pr_force = (10-epoch)*0.1 if epoch<10 else 0xtra = []output = self.m(*xs, y)if isinstance(output,tuple): output,*xtra = outputself.opt.zero_grad()loss = raw_loss = self.crit(output, y)if self.reg_fn: loss = self.reg_fn(output, xtra, raw_loss)loss.backward()if self.clip: # Gradient clippingnn.utils.clip_grad_norm(trainable_params_(self.m), self.clip)self.opt.step()return raw_loss.data[0]
这里是基本思想[1:25:29]。在训练开始时,让pr_force
非常高,以便几乎总是得到实际正确的前一个单词,因此它有一个有用的输入。然后随着我们训练的进一步,让pr_force
逐渐减少,直到最后pr_force
为零,它必须正确学习,这是可以的,因为现在它几乎总是输入合理的输入。
class Seq2SeqRNN_TeacherForcing(nn.Module):def __init__(self, vecs_enc, itos_enc, em_sz_enc, vecs_dec,itos_dec, em_sz_dec, nh, out_sl, nl=2):super().__init__()self.emb_enc = create_emb(vecs_enc, itos_enc, em_sz_enc)self.nl,self.nh,self.out_sl = nl,nh,out_slself.gru_enc = nn.GRU(em_sz_enc, nh, num_layers=nl, dropout=0.25)self.out_enc = nn.Linear(nh, em_sz_dec, bias=False)self.emb_dec = create_emb(vecs_dec, itos_dec, em_sz_dec)self.gru_dec = nn.GRU(em_sz_dec, em_sz_dec, num_layers=nl, dropout=0.1)self.emb_enc_drop = nn.Dropout(0.15)self.out_drop = nn.Dropout(0.35)self.out = nn.Linear(em_sz_dec, len(itos_dec))self.out.weight.data = self.emb_dec.weight.dataself.pr_force = 1.def forward(self, inp, y=None):sl,bs = inp.size()h = self.initHidden(bs)emb = self.emb_enc_drop(self.emb_enc(inp))enc_out, h = self.gru_enc(emb, h)h = self.out_enc(h) dec_inp = V(torch.zeros(bs).long())res = []for i in range(self.out_sl):emb = self.emb_dec(dec_inp).unsqueeze(0)outp, h = self.gru_dec(emb, h)outp = self.out(self.out_drop(outp[0]))res.append(outp)dec_inp = V(outp.data.max(1)[1])if (dec_inp==1).all(): breakif (y is not None) and (random.random()<self.pr_force):if i>=len(y): breakdec_inp = y[i]return torch.stack(res)def initHidden(self, bs): return V(torch.zeros(self.nl, bs, self.nh))
pr_force
: “probability of forcing”. High in the beginning zero by the end.
现在让我们写一些东西,使得在训练循环中逐渐减少pr_force
[1:26:01]。我们如何做到这一点?一种方法是编写我们自己的训练循环,但我们不要这样做,因为我们已经有一个训练循环,它有进度条,使用指数加权平均值来平滑损失,跟踪指标,并做了一堆事情。它们还跟踪在 epoch 开始时调用 RNN 的重置,以确保隐藏状态设置为零。我们发现的趋势是,当我们开始编写一些新东西并需要替换代码的某些部分时,我们会添加一些小钩子,以便我们可以使用该钩子使事情变得更容易。在这种特殊情况下,Jeremy 一直在使用的一个钩子是称为 stepper 的钩子。如果你查看源代码,model.py 是我们的 fit 函数所在的地方,这是最低级的东西,不需要学习者或任何其他东西,只需要一个标准的 PyTorch 模型和一个模型数据对象。你只需要知道多少个 epochs,一个标准的 PyTorch 优化器和一个标准的 PyTorch 损失函数。我们在课堂上几乎从未使用过,我们通常调用learn.fit
,但learn.fit
调用这个。
我们有时查看源代码[1:27:49]。我们看到它如何通过每个 epoch 循环,然后循环遍历批处理中的每个内容并调用stepper.step
。stepper.step
是负责的事情:
-
调用模型
-
获取损失
-
找到损失函数
-
调用优化器
所以默认情况下,stepper.step
使用一个称为Stepper
的特定类,基本上调用模型,将梯度置零,调用损失函数,调用backward
,如果需要进行梯度裁剪,然后调用优化器。这些是我们在“从头开始的 PyTorch”中看到的基本步骤。好处是,我们可以用其他东西替换它,而不是替换训练循环。如果你继承自Stepper
,然后编写你自己版本的step
,你可以只需复制并粘贴 step 的内容并添加任何你喜欢的内容。或者如果这是你要在之前或之后做的事情,你甚至可以调用super.step
。在这种情况下,Jeremy 相当怀疑他不必要地复杂[1:29:12] - 他可能本来可以做一些像这样的事情:
class Seq2SeqStepper(Stepper):def step(self, xs, y, epoch):self.m.pr_force = (10-epoch)*0.1 if epoch<10 else 0return super.step(xs, y, epoch)
但正如他所说的,当他在原型设计时,他并没有仔细考虑如何最小化他的代码 - 他复制并粘贴了step
的内容,并在顶部添加了一行,用于将模块中的pr_force
逐渐线性减少前 10 个 epochs,10 个 epochs 后,它为零。所以总体上是一个 hack,但足够好用来尝试一下。好处是除了添加这三行之外,其他一切都是一样的:
if (y is not None) and (random.random()<self.pr_force):if i>=len(y): breakdec_inp = y[i]
唯一需要做的不同之处是当我们调用fit
时,我们传入我们定制的 stepper 类。
rnn = Seq2SeqRNN_TeacherForcing(fr_vecd, fr_itos, dim_fr_vec, en_vecd, en_itos, dim_en_vec, nh, enlen_90
)
learn = RNN_Learner(md, SingleModel(to_gpu(rnn)), opt_fn=opt_fn)
learn.crit = seq2seq_losslearn.fit(lr, 1, cycle_len=12, use_clr=(20,10), stepper=Seq2SeqStepper
)
'''
epoch trn_loss val_loss 0 4.460622 12.661013 1 3.468132 7.138729 2 3.235244 6.202878 3 3.101616 5.454283 4 3.135989 4.823736 5 2.980696 4.933402 6 2.91562 4.287475 7 3.032661 3.975346 8 3.103834 3.790773 9 3.121457 3.578682 10 2.917534 3.532427 11 3.326946 3.490643
[3.490643]
'''
现在我们的损失降至 3.49。我们需要确保至少进行 10 个 epochs,因为在那之前,通过使用强制教师,这是作弊的。
Trick #3 注意力模型[1:31:00]
下一个技巧是一个更大、更酷的技巧。它被称为“注意力”。注意力的基本思想是这样的——期望将整个句子总结为这个单一的隐藏向量是要求太多了。它必须知道说了什么,怎么说的,以及创建德语句子所需的一切。注意力的想法基本上是我们可能要求太多了。特别是因为我们可以使用这种形式的模型(下面),在这种模型中我们不仅输出循环的每一步,而不仅仅是在最后有一个隐藏状态,而是在每个单词之后都有一个隐藏状态。为什么不尝试利用这些信息呢?它已经存在,但到目前为止我们只是把它丢掉了。不仅如此,而且双向的,我们在每一步都有两个状态向量,我们可以利用。我们怎么做呢?
让我们假设我们现在正在翻译一个词“liebte”[1:32:34]。我们想要之前的 5 个隐藏状态中的哪一个?显然我们想要“love”,因为这是这个词。那么“zu”呢?我们可能需要“eat”、“to”和“loved”来确保我们已经得到了正确的时态,并知道我实际上需要动词的这部分等等。因此,根据我们正在翻译的部分,我们可能需要这些不同隐藏状态的一个或多个部分。实际上,我们可能需要对它们进行加权。换句话说,对于这五个隐藏状态,我们想要一个加权平均值[1:33:47]。我们希望它根据某种可以确定哪些句子部分现在最重要的东西进行加权。我们如何找出哪些句子部分现在很重要?我们创建一个神经网络,训练神经网络来找出。我们什么时候训练这个神经网络?端到端。所以现在让我们训练两个神经网络[1:34:18]。嗯,我们已经有了一堆——RNN 编码器,RNN 解码器,几个线性层,那就再加一个神经网络吧。这个神经网络将为每一个这些状态输出一个权重,我们将在每一步进行加权平均,这只是我们同时学习的另一组参数。这就是所谓的“注意力”。
这个想法是一旦学会了注意力,每个单词都会进行加权平均,你可以在 Chris Olah 和 Shan Carter 的这个精彩演示中看到[1:34:50]。查看这篇distill.pub 文章——这些都是交互式图表,向你展示了注意力是如何工作的,以及在训练翻译模型中实际的注意力是什么样子。
让我们尝试实现注意力[1:35:47]:
def rand_t(*sz): return torch.randn(sz)/math.sqrt(sz[0])
def rand_p(*sz): return nn.Parameter(rand_t(*sz))
class Seq2SeqAttnRNN(nn.Module):def __init__(self, vecs_enc, itos_enc, em_sz_enc, vecs_dec, itos_dec, em_sz_dec, nh, out_sl, nl=2):super().__init__()self.emb_enc = create_emb(vecs_enc, itos_enc, em_sz_enc)self.nl,self.nh,self.out_sl = nl,nh,out_slself.gru_enc = nn.GRU(em_sz_enc, nh, num_layers=nl, dropout=0.25)self.out_enc = nn.Linear(nh, em_sz_dec, bias=False)self.emb_dec = create_emb(vecs_dec, itos_dec, em_sz_dec)self.gru_dec = nn.GRU(em_sz_dec, em_sz_dec, num_layers=nl, dropout=0.1)self.emb_enc_drop = nn.Dropout(0.15)self.out_drop = nn.Dropout(0.35)self.out = nn.Linear(em_sz_dec*2, len(itos_dec))self.out.weight.data = self.emb_dec.weight.data self.W1 = rand_p(nh, em_sz_dec)self.l2 = nn.Linear(em_sz_dec, em_sz_dec)self.l3 = nn.Linear(em_sz_dec+nh, em_sz_dec)self.V = rand_p(em_sz_dec) def forward(self, inp, y=None, ret_attn=False):sl,bs = inp.size()h = self.initHidden(bs)emb = self.emb_enc_drop(self.emb_enc(inp))enc_out, h = self.gru_enc(emb, h)h = self.out_enc(h) dec_inp = V(torch.zeros(bs).long())res,attns = [],[]w1e = enc_out @ self.W1for i in range(self.out_sl):w2h = self.l2(h[-1])u = F.tanh(w1e + w2h)a = F.softmax(u @ self.V, 0)attns.append(a)Xa = (a.unsqueeze(2) * enc_out).sum(0)emb = self.emb_dec(dec_inp)wgt_enc = self.l3(torch.cat([emb, Xa], 1))outp, h = self.gru_dec(wgt_enc.unsqueeze(0), h)outp = self.out(self.out_drop(outp[0]))res.append(outp)dec_inp = V(outp.data.max(1)[1])if (dec_inp==1).all(): breakif (y is not None) and (random.random()<self.pr_force):if i>=len(y): breakdec_inp = y[i] res = torch.stack(res)if ret_attn: res = res,torch.stack(attns)return res def initHidden(self, bs): return V(torch.zeros(self.nl, bs, self.nh))
有了注意力,大部分代码都是相同的。唯一的主要区别是这一行:Xa = (a.unsqueeze(2) * enc_out).sum(0)
。我们将进行加权平均,我们将如何进行加权平均是我们创建一个小型神经网络,我们将在这里看到:
w2h = self.l2(h[-1])
u = F.tanh(w1e + w2h)
a = F.softmax(u @ self.V, 0)
我们使用 softmax,因为 softmax 的好处是我们希望确保我们使用的所有权重加起来等于 1,而且我们也希望其中一个权重可能比其他权重更高[1:36:38]。Softmax 给了我们这样的保证,它们加起来等于 1,因为它里面有e^
,它倾向于鼓励其中一个权重比其他权重更高。
让我们看看这是如何工作的[1:37:09]。我们将取最后一层的隐藏状态,然后将其放入一个线性层中。然后我们将其放入一个非线性激活函数,然后进行矩阵相乘。所以如果你考虑一下——一个线性层,非线性激活函数,矩阵相乘——这就是一个神经网络。这是一个具有一个隐藏层的神经网络。将其放入 softmax,然后我们可以使用它来加权我们的编码器输出。现在,我们不再只是取最后一个编码器输出,而是有了所有编码器输出的张量,我们只需用我们创建的这个神经网络来加权。
在 Python 中,A @ B
是矩阵乘积,A * B
是逐元素乘积
论文[1:38:18]
-
通过联合学习对齐和翻译进行神经机器翻译——这是一篇令人惊叹的论文,最初介绍了注意力的概念,以及一些真正改变了人们在这一领域工作方式的关键事项。他们说,注意力领域不仅用于文本,还用于从图片中读取文本或在计算机视觉中执行各种任务。
-
作为外语的语法——Geoffrey Hinton 参与的第二篇论文,使用了 RNN 与注意力的想法,试图用自动标记每个单词的 RNN 替换基于规则的语法。结果表明,它比任何基于规则的系统做得更好,这在今天看来是显而易见的,但在当时被认为是非常令人惊讶的。它们是关于注意力如何工作的摘要,非常清晰简洁。
问题:你能再解释一下注意力吗?[1:39:46] 当然!让我们回头看看我们最初的编码器。
RNN 输出两个东西:它在每个时间步骤之后输出一个状态列表(enc_out
),并且还告诉您在最后一个时间步骤的状态(h
),我们使用最后一个时间步骤的状态来创建我们的解码器的输入状态,这是下面的一个向量s
:
但我们知道它在每个时间步骤都创建一个向量(橙色箭头),那么使用它们所有不是更好吗?但使用哪一个或哪些对于翻译我们正在翻译的单词最相关呢?所以能否按照当前适当的权重取每个时间步骤的隐藏状态的加权平均值会更好。例如,“liebte”肯定是时间步骤#2,因为那是我正在翻译的单词。那么我们如何得到一个适合当前训练的单词的权重列表呢?答案是通过训练一个神经网络来找出权重列表。所以每当我们想要弄清楚如何训练一个小型神经网络来执行任何任务时,通常最简单的方法就是将其包含在你的模块中,并与其他所有内容一起训练。最简单的神经网络是包含两层和一个非线性激活函数的东西,所以self.l2
是一个线性层。
实际上,我们甚至可以只是随机选择一个矩阵,如果我们不关心偏差[1:42:18]。self.W1
是一个随机张量,包装在一个Parameter
中。
Parameter
:记住,Parameter
与 PyTorch 的Variable
是相同的,但它只是告诉 PyTorch“请学习这些权重”。[1:42:35]
因此,当我们开始我们的解码器时,让我们取解码器当前的隐藏状态,将其放入一个线性层(self.l2
),因为我们用来决定接下来应该关注哪些单词的信息——我们唯一可以依赖的信息就是解码器当前的隐藏状态。所以让我们抓住它:
-
将其放入线性层(
self.l2
) -
将其通过非线性激活函数(
F.tanh
)处理 -
通过一个非线性层(
u @ self.V
中没有偏差,所以只是矩阵相乘) -
通过 softmax
就是这样——一个小型神经网络。它什么也不做。它只是一个神经网络,没有神经网络做任何事情,它们只是具有随机权重的线性层和非线性激活。但是如果我们给它一个任务,它就开始做一些事情。在这种情况下,我们给它的任务是不要只取最终状态,而是现在让我们使用所有编码器状态,并且让我们取出所有这些状态,并将它们乘以那个小型神经网络的输出。因此,考虑到这个小型神经网络中的东西是可学习的权重,希望它学会对这些编码器隐藏状态进行有用的加权。神经网络所做的一切就是我们给它一些随机权重作为起点和一个任务,并希望它学会完成这个任务。结果表明,它确实做到了。
这里的其他一切与以前完全相同。我们有教师强制,它不是双向的,所以我们可以看看情况如何。
rnn = Seq2SeqAttnRNN(fr_vecd, fr_itos, dim_fr_vec, en_vecd, en_itos, dim_en_vec, nh, enlen_90)
learn = RNN_Learner(md, SingleModel(to_gpu(rnn)), opt_fn=opt_fn)
learn.crit = seq2seq_loss
lr=2e-3
learn.fit(lr, 1, cycle_len=15, use_clr=(20,10), stepper=Seq2SeqStepper
)
'''
epoch trn_loss val_loss 0 3.882168 11.125291 1 3.599992 6.667136 2 3.236066 5.552943 3 3.050283 4.919096 4 2.99024 4.500383 5 3.07999 4.000295 6 2.891087 4.024115 7 2.854725 3.673913 8 2.979285 3.590668 9 3.109851 3.459867 10 2.92878 3.517598 11 2.778292 3.390253 12 2.795427 3.388423 13 2.809757 3.353334 14 2.6723 3.368584
[3.3685837]
'''
教师强制为 3.49,现在几乎完全相同的东西,但我们有这个小型神经网络来找出给我们输入的权重,我们降到了 3.37。记住,这些损失是对数,所以e³.37
是一个相当显著的变化。
learn.save('attn')
测试[1:45:37]
x,y = next(iter(val_dl))
probs,attns = learn.model(V(x),ret_attn=True)
preds = to_np(probs.max(2)[1])
for i in range(180,190):print(' '.join([fr_itos[o] for o in x[:,i] if o != 1]))print(' '.join([en_itos[o] for o in y[:,i] if o != 1]))print(' '.join([en_itos[o] for o in preds[:,i] if o!=1]))print()
'''
quels facteurs pourraient influer sur le choix de leur emplacement ? _eos_
what factors influencetheir location ? _eos_
what factors might influence the their their their ? _eos_**qu’ est -ce qui ne peut pas changer ? _eos_
what can not change ? _eos_
what can not change change ? _eos_**que faites - vous ? _eos_
what do you do ? _eos_
what do you do ? _eos_**qui réglemente les pylônes d' antennes ? _eos_
who regulates antenna towers ? _eos_
who regulates the lights ? ? _eos_**où sont - ils situés ? _eos_
where are they located ? _eos_
where are they located ? _eos_**quelles sont leurs compétences ? _eos_
what are their qualifications ? _eos_
what are their skills ? _eos_**qui est victime de harcèlement sexuel ? _eos_
who experiences sexual harassment ? _eos_
who is victim sexual sexual ? _eos_**quelles sont les personnes qui visitent les communautés autochtones ? _eos_
who visits indigenous communities ? _eos_
who is people people aboriginal people ? _eos_**pourquoi ces trois points en particulier ? _eos_
why these specific three ? _eos_
why are these three three ? ? _eos_**pourquoi ou pourquoi pas ? _eos_
why or why not ? _eos_
why or why not ? _eos_
'''
还不错。仍然不完美,但相当多的结果是正确的,考虑到我们要求它学习两种不同语言之间的语言概念,以及如何在两种语言之间进行翻译,以及语法和词汇,我们只有 50,000 个句子,很多词只出现一次,我会说这实际上是非常惊人的。
**问题:**为什么我们在注意力小网络中使用 tanh 而不是 ReLU?[1:46:23]我不太记得——我很久没有看过了。你完全可以尝试使用值并看看效果如何。显然,tanh 的关键区别在于它可以在每个方向上移动,并且在顶部和底部都受限。我知道在 RNNs、LSTMs 和 GRUs 内部的门中,tanh 通常效果更好,但是我已经大约一年没有看过这个具体问题了,所以我会在这周看一下。简短的答案是你应该尝试不同的激活函数,看看是否可以得到更好的结果。
来自第 7 课[44:06]:正如我们上周所看到的,tanh 强制值在-1 和 1 之间。由于我们一遍又一遍地乘以这个权重矩阵,我们担心 relu(因为它是无界的)可能会有更多的梯度爆炸问题。话虽如此,你可以指定 RNNCell 使用不同的非线性函数,其默认值为 tanh,并要求它使用 relu。
可视化[1:47:12]
我们还可以通过将forward
函数添加返回注意力参数来从模型中提取注意力。你可以在forward
函数参数中放任何你想要的东西。所以我们添加了一个返回注意力参数,默认为 false,因为显然训练循环不知道这一点,但然后我们在这里添加了一些东西,如果返回注意力,那么也将注意力添加进去(if ret_attn: res = res,torch.stack(attns)
)。注意力就是值a
,只需将其放入列表中(attns.append(a)
)。现在我们可以调用带有返回注意力等于 true 的模型,并获得概率和注意力[1:47:53]:
probs,attns = learn.model(V(x),ret_attn=True)
现在我们可以在每个时间步绘制注意力的图片。
attn = to_np(attns[...,180])
fig, axes = plt.subplots(3, 3, figsize=(15, 10))
for i,ax in enumerate(axes.flat):ax.plot(attn[i])
当你是 Chris Olah 和 Shan Carter 时,你做出的东西看起来像☟,当你是 Jeremy Howard 时,完全相同的信息看起来像☝︎。你可以看到在每个不同的时间步,我们有不同的注意力。
当你尝试构建这样的东西时,非常重要的一点是,你不知道它是否工作正常,因为如果它不工作(通常情况下,Jeremy 的前 12 次尝试都失败了),它们失败的意义在于它并没有真正学到任何有用的东西。因此,它对每件事都给予了同等的关注,它并没有变得更糟——只是并没有变得更好。直到你真正找到一种方法来以一种你事先知道它应该是什么样子的方式来可视化这个东西,你才真正知道它是否有效。因此,非常重要的一点是,你要尝试找到一种方法来检查你的中间步骤和输出。
问题:注意力神经网络的损失函数是什么?没有,注意力神经网络没有损失函数。它是端到端训练的。它只是坐在我们的解码器循环中。解码器循环的损失函数是相同的损失函数,因为结果包含的东西完全相同——单词的概率。为什么这个小型神经网络在学习?因为为了使输出变得更好,如果它使加权平均的权重变得更好,那将是很好的。因此,创建我们的输出的一部分是请尽量找到一组好的权重,如果它不能找到一组好的权重,那么损失函数就不会从那一部分改善。因此,端到端学习意味着你将一切都放入一个损失函数中,所有不同参数的梯度都指向一个方向,即“嘿,你知道如果你在那里放更多的权重,那会更好。”多亏了链式法则的魔力,它知道要在那里放更多的权重,稍微改变矩阵乘法中的参数等。这就是端到端学习的魔力。这是一个非常容易理解的问题,但你必须意识到这段代码中没有任何特定的东西表明这些特定的部分是单独的小型神经网络,就像 GRU 不是一个单独的小型神经网络,或者线性层不是一个单独的小型函数一样。所有这些最终都被推送到一个输出中,这个输出是一堆概率,最终进入一个返回单个数字的损失函数,这个数字表示这是一个好的翻译还是不是一个好的翻译。多亏了链式法则的魔力,我们然后向所有参数反向传播一点更新,使它们变得更好一点。这是一个很大、很奇怪、很反直觉的想法,如果它有点令人费解,那完全没关系。这是一个让我们回到第一课“我们是如何让它找到狗和猫的?”的地方——我们没有。我们所做的只是说“这是我们的数据,这是我们的架构,这是我们的损失函数。请反向传播到权重,使它们变得更好,当你让它们变得更好一段时间后,它将开始从狗中找到猫。”在这种情况下(即翻译),我们没有使用别人的卷积网络架构。我们说“这是一个我们希望在这个问题上特别擅长的自定义架构。”即使没有这个自定义架构,也还可以。但我们制作的方式更有意义,或者我们认为它应该做得更好。但在任何时候,我们都没有做任何不同的事情,只是说“这是数据,这是架构,这是损失函数——请找到参数”它做到了,因为这就是神经网络所做的事情。
所以这就是序列到序列学习。
-
如果你想将图像编码到某种 CNN 骨干中,然后将其传递到一个类似带有注意力的 RNN 的解码器中,然后将你的 y 值设为每个图像的实际正确字幕,你最终会得到一个图像字幕生成器。
-
如果你用视频和字幕做同样的事情,你最终会得到一个视频字幕生成器。
-
如果你用 3D CT 扫描和放射学报告做同样的事情,你最终会得到一个放射学报告生成器。
-
如果你用 Github 问题和人们选择的摘要做同样的事情,你会得到一个 Github 问题摘要生成器。
Seq-to-seq 是神奇的,但它们起作用。我觉得人们还没有开始深入研究如何在自己的领域中使用 seq-to-seq 模型。作为一个不太用 Github 的人,我从来没有想到“从某个问题开始并自动生成摘要会很酷”。但现在,当然,下次我进入 Github 时,我想看到一个为我写的摘要。我不想写自己的提交消息。当我完成对很多行添加注释后,为什么我要自己写代码审查的摘要呢 — 它也应该为我做这件事。现在我在想 Github 太落后了,它本来可以做这些事情。那么在你的行业中有什么事情呢?你可以从一个序列开始并生成一些东西。我无法想象。再次强调,这是一个相当新的领域,用于它的工具并不容易使用 — 它们甚至还没有内置到 fastai 中。希望很快会有。我认为没有人知道机会在哪里。
Devise [1:55:23]
笔记本 / 论文
我们将要做一些事情,第一次将我们专注的两个小世界——文本和图像[1:55:49]结合起来。这个想法是由一位名叫 Andrea Frome 的杰出深度学习从业者和研究人员提出的。当时 Andrea 在谷歌工作,她疯狂的想法是单词可以有一个分布式表示,一个空间,特别是在那个时候只是单词向量。图像也可以在一个空间中表示。最后,如果我们有一个全连接层,它们最终会成为一个向量表示。我们能够合并这两者吗?我们能否以某种方式鼓励图像最终得到的向量空间与单词所在的向量空间相同?如果我们能做到这一点,那意味着什么?我们可以用它做什么?那么我们可以用它做什么,涵盖了诸如“如果我错了怎么办,如果我预测这张图片是一只猎犬,而我预测是大型喷气机,Yannet 的模型预测是柯基。正常的损失函数表示 Yannet 和 Jeremy 的模型一样好(即它们都是错误的)。但如果我们能以某种方式说,你知道柯基更接近猎犬而不是大型喷气机。所以 Yannet 的模型比 Jeremy 的更好。我们应该能够做到这一点,因为在单词向量空间中,猎犬和柯基是非常接近的,但大型喷气机不是那么接近。所以这将给我们一个很好的情况,希望我们的推理如果错误的话会以更合理的方式出错。这也将使我们能够搜索不在 ImageNet Synset ID(即 ImageNet 中的一个类别)中的事物。为什么我们必须训练一个全新的模型来找到狗和猫,当我们已经有找到柯基和虎斑猫的东西。为什么我们不能只是说找到狗?如果我们在单词向量空间中训练过它,我们完全可以,因为它们是单词向量,我们可以找到具有正确图像向量的东西等等。我们将在一会儿看一些我们可以用它做的很酷的事情,但首先让我们训练一个模型,这个模型不是学习一个类别(独热编码 ID),其中每个类别与其他每个类别的距离都是相等的,而是训练一个模型,我们正在寻找一个依赖变量,这是一个单词向量。那么什么单词向量?显然是你想要的单词的单词向量。所以如果是柯基,让我们训练它创建一个柯基单词向量,如果是大型喷气机,让我们训练它与一个依赖变量说这是大型喷气机的单词向量。
from fastai.conv_learner import *
torch.backends.cudnn.benchmark=Trueimport fastText as ft
PATH = Path('data/imagenet/')
TMP_PATH = PATH/'tmp'
TRANS_PATH = Path('data/translate/')
PATH_TRN = PATH/'train'
这实在太容易了。让我们再次获取 fast text 单词向量,加载它们进来(这次我们只需要英语)。
ft_vecs = ft.load_model(str((TRANS_PATH/'wiki.en.bin')))
np.corrcoef(ft_vecs.get_word_vector('jeremy'), ft_vecs.get_word_vector('Jeremy'))
'''
array([[1\. , 0.60866],[0.60866, 1\. ]])
'''
例如,“jeremy”和“Jeremy”的相关系数为 0.6。
np.corrcoef(ft_vecs.get_word_vector('banana'), ft_vecs.get_word_vector('Jeremy'))
'''
array([[1\. , 0.14482],[0.14482, 1\. ]])
'''
Jeremy 一点也不喜欢香蕉,“香蕉”和“Jeremy”相关系数为 0.14。所以你期望相关的词是相关的,而应该尽可能远离彼此的词,不幸的是,它们仍然略微相关,但不那么明显。
将 ImageNet 类别映射到单词向量
现在让我们获取所有 ImageNet 类别,因为我们实际上想知道哪一个是柯基,哪一个是大型喷气机。
ft_words = ft_vecs.get_words(include_freq=True)
ft_word_dict = {k:v for k,v in zip(*ft_words)}
ft_words = sorted(ft_word_dict.keys(), key=lambda x: ft_word_dict[x])
len(ft_words)
'''
2519370
'''
from fastai.io import get_data
我们在 files.fast.ai 上有一个所有这些的列表,我们可以获取它们。
CLASSES_FN = 'imagenet_class_index.json'
get_data(f'http://files.fast.ai/models/{CLASSES_FN}', TMP_PATH/CLASSES_FN
)
让我们还获取 Jeremy 提供的所有英语名词的列表:
WORDS_FN = 'classids.txt'
get_data(f'http://files.fast.ai/data/{WORDS_FN}', PATH/WORDS_FN)
所以我们有每个千个 ImageNet 类别的名称,以及根据 WordNet 列出的所有英语名词,这是一个用于表示哪些词是什么的流行工具。我们现在可以加载 ImageNet 类别列表,将其转换为字典,因此classids_1k
包含了比赛数据集中的 1000 个图像的类别 ID。
class_dict = json.load((TMP_PATH/CLASSES_FN).open())
classids_1k = dict(class_dict.values())
nclass = len(class_dict); nclass*1000*
这里有一个例子。一个“tench”显然是一种鱼。
class_dict['0']
'''
['n01440764', 'tench']
'''
让我们为所有这些 WordNet 名词做同样的事情。结果发现 ImageNet 正在使用 WordNet 类名,这样在两者之间进行映射就变得简单了。
classid_lines = (PATH/WORDS_FN).open().readlines()
classid_lines[:5]
'''
['n00001740 entity\n','n00001930 physical_entity\n','n00002137 abstraction\n','n00002452 thing\n','n00002684 object\n']
'''
classids = dict(l.strip().split() for l in classid_lines)
len(classids),len(classids_1k)
'''
(82115, 1000)
'''
这是我们的两个世界 — 我们有 ImageNet 的一千个和 WordNet 中的 82,000 个。
lc_vec_d = {w.lower(): ft_vecs.get_word_vector(w) for w in ft_words[-1000000:]
}
我们想要将这两者联系起来,这只是简单地创建一些字典来基于 Synset ID 或 WordNet ID 进行映射。
syn_wv = [(k, lc_vec_d[v.lower()]) for k,v in classids.items()if v.lower() in lc_vec_d
]
syn_wv_1k = [(k, lc_vec_d[v.lower()]) for k,v in classids_1k.items()if v.lower() in lc_vec_d
]
syn2wv = dict(syn_wv)
len(syn2wv)
'''
49469
'''
现在我们需要做的是获取 WordNet 中的 82,000 个名词,并尝试在快速文本中查找它们。我们已经在快速文本中查找到了 49,469 个名词。我们现在有一个字典,从 synset ID(即 WordNet 称之为的 ID)到单词向量。我们还为 1k 个 ImageNet 类别做了同样的事情。
pickle.dump(syn2wv, (TMP_PATH/'syn2wv.pkl').open('wb'))
pickle.dump(syn_wv_1k, (TMP_PATH/'syn_wv_1k.pkl').open('wb'))
syn2wv = pickle.load((TMP_PATH/'syn2wv.pkl').open('rb'))
syn_wv_1k = pickle.load((TMP_PATH/'syn_wv_1k.pkl').open('rb'))
现在我们获取了所有的 ImageNet,你现在可以从 Kaggle 下载。如果你看一下 Kaggle 的 ImageNet 本地化比赛,其中包含了所有的 ImageNet 分类。
images = []
img_vecs = []
for d in (PATH/'train').iterdir():if d.name not in syn2wv: continuevec = syn2wv[d.name]for f in d.iterdir():images.append(str(f.relative_to(PATH)))img_vecs.append(vec)
n_val=0
for d in (PATH/'valid').iterdir():if d.name not in syn2wv: continuevec = syn2wv[d.name]for f in d.iterdir():images.append(str(f.relative_to(PATH)))img_vecs.append(vec)n_val += 1
n_val
'''
28650
'''
其中有 28,650 个项目的验证集。对于 ImageNet 中的每个图像,我们可以使用 synset 到单词向量(syn2wv
)获取其快速文本词向量,并将其放入图像向量数组(img_vecs
),将所有这些堆叠到一个矩阵中并保存下来。
img_vecs = np.stack(img_vecs)
img_vecs.shape
现在我们为每个 ImageNet 图像都有一个与之相关联的快速文本词向量。通过查找 synset ID → WordNet → 快速文本 → 单词向量。
pickle.dump(images, (TMP_PATH/'images.pkl').open('wb'))
pickle.dump(img_vecs, (TMP_PATH/'img_vecs.pkl').open('wb'))
images = pickle.load((TMP_PATH/'images.pkl').open('rb'))
img_vecs = pickle.load((TMP_PATH/'img_vecs.pkl').open('rb'))
arch = resnet50n = len(images); n
'''
766876
'''
val_idxs = list(range(n-28650, n))
这里有一个很酷的技巧。我们现在可以创建一个模型数据对象,它专门是一个图像分类器数据对象,我们有一个叫做from_names_and_array
的东西,我不确定我们以前是否使用过,但我们可以传递一个文件名列表(ImageNet 中的所有文件名)和一个我们的因变量数组(所有快速文本词向量)。然后我们传入验证索引,这种情况下只是所有最后的 ID — 我们需要确保它们与 ImageNet 使用的相同,否则我们会作弊。然后我们传入continuous=True
,这意味着这个图像分类器数据现在是一个图像回归数据,连续等于 True 意味着不要对我的输出进行独热编码,而是将它们视为连续值。现在我们有一个模型数据对象,其中包含所有文件名,对于每个文件名,都有一个表示该单词向量的连续数组。所以我们有了数据,现在我们需要一个架构和损失函数。
tfms = tfms_from_model(arch, 224, transforms_side_on, max_zoom=1.1)
md = ImageClassifierData.from_names_and_array(PATH, images, img_vecs, val_idxs=val_idxs, classes=None, tfms=tfms,continuous=True, bs=256
)
x,y = next(iter(md.val_dl))
让我们创建一个架构。我们下周会对此进行修订,但我们可以使用到目前为止学到的技巧,实际上非常简单。Fastai 有一个ConvnetBuilder
,当你说ConvLerner.pretrained
时就会调用它,并指定:
-
f
: 架构(我们将使用 ResNet50) -
c
: 你想要多少类(在这种情况下,它实际上不是类别,而是你想要的输出数量,即快速文本词向量的长度,即 300)。 -
is_multi
: 这不是多分类,因为根本不是分类。 -
is_reg
: 是的,这是一个回归。 -
xtra_fc
: 你想要什么全连接层。我们将添加一个长度为 1024 的全连接隐藏层。为什么是 1024?我认为 ResNet50 的最后一层是 1024,我们需要的最终输出是 300。显然,我们需要倒数第二层比 300 长。否则信息不足,所以我们选择了稍大一点的值。也许不同的数字会更好,但这对 Jeremy 有效。 -
ps
: 你想要多少 dropout。Jeremy 发现默认的 dropout,他一直欠拟合,所以他将 dropout 从 0.5 降低到 0.2。
所以这是一个卷积神经网络,没有任何 softmax 之类的东西,因为它是回归,最后只是一个线性层,这就是我们的模型。我们可以从该模型创建一个 ConvLearner,并为其提供一个优化函数。现在我们只需要一个损失函数。
models = ConvnetBuilder(arch, md.c, is_multi=False, is_reg=True, xtra_fc=[1024], ps=[0.2,0.2]
)
learn = ConvLearner(md, models, precompute=True)
learn.opt_fn = partial(optim.Adam, betas=(0.9,0.99))
损失函数:回归的默认损失函数是 L1 损失(绝对差异)- 这并不坏。但不幸的是,在真正高维空间中(任何稍微了解一点机器学习的人可能都知道),一切都在外面(在这种情况下,是 300 维)。当一切都在外面时,距离并不毫无意义,但有点尴尬。事物往往要么靠在一起,要么远离,在这些真正高维空间中,一切都在边缘,这并不意味着太多。然而,有意义的是,如果一件事在这边的边缘,另一件事在那边的边缘,我们可以形成这些向量之间的角度,这个角度是有意义的。这就是为什么在寻找高维空间中事物之间的接近或远离时,我们使用余弦相似度。如果你以前没有见过余弦相似度,它基本上与欧几里德距离相同,但被归一化为单位范数(即除以长度)。因此,我们不关心向量的长度,我们只关心它的角度。有很多东西你可以在几个小时内轻松学会,但如果你以前没有见过,它可能有点神秘。现在,只需知道损失函数和高维空间中,你在尝试找到相似性时,你关心角度,而不关心距离。如果你没有使用以下自定义损失函数,它仍然可以工作,但效果会差一点。现在我们有了数据、架构和损失函数,因此,我们完成了。我们可以继续拟合。
def cos_loss(inp,targ):return 1 - F.cosine_similarity(inp,targ).mean()
learn.crit = cos_losslearn.lr_find(start_lr=1e-4, end_lr=1e15)
learn.sched.plot()
lr = 1e-2
wd = 1e-7
我们正在训练所有的 ImageNet,这将需要很长时间。所以precompute=True
是你的朋友。还记得precompute=True
吗?那是我们很久以前学到的东西,它会缓存最终卷积层的输出,然后只训练完全连接的部分。即使使用precompute=True
,在所有的 ImageNet 上训练一个时代大约需要 3 分钟。所以这大约是一个小时的训练时间,但很酷的是,使用 fastai,我们可以在一个小时左右的时间内在所有的 ImageNet 上训练一个新的自定义头部 40 个时代。
learn.precompute=True
learn.fit(lr, 1, cycle_len=20, wds=wd, use_clr=(20,10))
'''
epoch trn_loss val_loss 0 0.104692 0.125685 1 0.112455 0.129307 2 0.110631 0.126568 3 0.108629 0.127338 4 0.110791 0.125033 5 0.108859 0.125186 6 0.106582 0.123875 7 0.103227 0.123945 8 0.10396 0.12304 9 0.105898 0.124894 10 0.10498 0.122582 11 0.104983 0.122906 12 0.102317 0.121171 13 0.10017 0.121816 14 0.099454 0.119647 15 0.100425 0.120914 16 0.097226 0.119724 17 0.094666 0.118746 18 0.094137 0.118744 19 0.090076 0.117908
[0.11790786389489033]
'''
learn.bn_freeze(True)
learn.fit(lr, 1, cycle_len=20, wds=wd, use_clr=(20,10))
'''
epoch trn_loss val_loss 0 0.104692 0.125685 1 0.112455 0.129307 2 0.110631 0.126568 3 0.108629 0.127338 4 0.110791 0.125033 5 0.108859 0.125186 6 0.106582 0.123875 7 0.103227 0.123945 8 0.10396 0.12304 9 0.105898 0.124894 10 0.10498 0.122582 11 0.104983 0.122906 12 0.102317 0.121171 13 0.10017 0.121816 14 0.099454 0.119647 15 0.100425 0.120914 16 0.097226 0.119724 17 0.094666 0.118746 18 0.094137 0.118744 19 0.090076 0.117908
[0.11790786389489033]
'''
lrs = np.array([lr/1000,lr/100,lr])
learn.precompute=False
learn.freeze_to(1)
learn.save('pre0')
learn.load('pre0')
图像搜索
搜索 imagenet 类
在所有这些之后,我们现在可以说让我们获取 1000 个 ImageNet 类,让我们在整个验证集上进行预测,并查看一些图片。
syns, wvs = list(zip(*syn_wv_1k))
wvs = np.array(wvs)
%time pred_wv = learn.predict()
'''
CPU times: user 18.4 s, sys: 7.91 s, total: 26.3 s
Wall time: 7.17 s
'''
start=300
denorm = md.val_ds.denorm
def show_img(im, figsize=None, ax=None):if not ax: ig,ax = plt.subplots(figsize=figsize)ax.imshow(im)ax.axis('off')return ax
def show_imgs(ims, cols, figsize=None):fig,axes = plt.subplots(len(ims)//cols, cols, figsize=figsize)for i,ax in enumerate(axes.flat): show_img(ims[i], ax=ax)plt.tight_layout()
因为验证集是有序的,所有相同类型的东西都在同一个地方。
show_imgs(denorm(md.val_ds[start:start+25][0]), 5, (10,10))
最近邻搜索[2:10:56]:现在我们可以使用最近邻搜索。所谓最近邻搜索意味着这里有一个 300 维向量,这里有很多其他 300 维向量,它最接近哪个?通常这需要很长时间,因为你必须查看每个 300 维向量,计算其距离,找出它有多远。但是有一个几乎不为人知的神奇库叫做NMSLib,它可以做得非常快。你们中有些人可能尝试过其他最近邻库,我保证这比你们使用的更快 —— 我可以告诉你这一点,因为这是由专业人士进行基准测试的。在每个可能的维度上,这是迄今为止最快的。我们想要在角距离上创建一个索引,并且需要在我们所有的 ImageNet 词向量上执行。添加一个完整的批次,创建索引,现在我们可以一次查询一堆向量,获取最近的 10 个邻居。该库使用多线程,绝对棒。你可以从 pip 安装(pip install nmslib
),它就能用了。
import nmslibdef create_index(a):index = nmslib.init(space='angulardist')index.addDataPointBatch(a)index.createIndex()return index
def get_knns(index, vecs):return zip(*index.knnQueryBatch(vecs, k=10, num_threads=4))
def get_knn(index, vec): return index.knnQuery(vec, k=10)
nn_wvs = create_index(wvs)
它告诉你它们有多远以及它们的索引[2:12:13].
idxs,dists = get_knns(nn_wvs, pred_wv)
所以现在我们可以浏览并打印出前 3 个,结果是鸟实际上是一只鹭鸟。有趣的是第四个并没有说它是一只鹭鸟,Jeremy 查了一下。他对鸟类了解不多,但其他一切都是棕色带白色斑点,但第四个不是。所以我们不知道那是否真的是一只鹭鸟,或者是否被错误标记,但它看起来绝对不像其他鸟类。
[[classids[syns[id]] for id in ids[:3]] for ids in idxs[start:start+10]
]
'''
[['limpkin', 'oystercatcher', 'spoonbill'],['limpkin', 'oystercatcher', 'spoonbill'],['limpkin', 'oystercatcher', 'spoonbill'],['spoonbill', 'bustard', 'oystercatcher'],['limpkin', 'oystercatcher', 'spoonbill'],['limpkin', 'oystercatcher', 'spoonbill'],['limpkin', 'oystercatcher', 'spoonbill'],['limpkin', 'oystercatcher', 'spoonbill'],['limpkin', 'oystercatcher', 'spoonbill'],['limpkin', 'oystercatcher', 'spoonbill']]
'''
这并不是一件特别困难的事情,因为 ImageNet 只有一千个类别,而且并没有做任何新的事情。但是如果我们现在引入整个 WordNet,然后说它最接近那 45,000 个东西中的哪一个呢?
搜索所有 WordMet 名词类别
all_syns, all_wvs = list(zip(*syn2wv.items()))
all_wvs = np.array(all_wvs)
nn_allwvs = create_index(all_wvs)
idxs,dists = get_knns(nn_allwvs, pred_wv)
[[classids[all_syns[id]] for id in ids[:3]] for ids in idxs[start:start+10]
]
'''
[['limpkin', 'oystercatcher', 'spoonbill'],['limpkin', 'oystercatcher', 'spoonbill'],['limpkin', 'oystercatcher', 'spoonbill'],['spoonbill', 'bustard', 'oystercatcher'],['limpkin', 'oystercatcher', 'spoonbill'],['limpkin', 'oystercatcher', 'spoonbill'],['limpkin', 'oystercatcher', 'spoonbill'],['limpkin', 'oystercatcher', 'spoonbill'],['limpkin', 'oystercatcher', 'spoonbill'],['limpkin', 'oystercatcher', 'spoonbill']]
'''
结果完全相同。现在正在搜索所有的 WordNet。
文本->图像搜索[2:13:16]
现在让我们做一些有点不同的事情 —— 就是取出我们所有的预测(pred_wv
),基本上取出我们整个验证图像集的预测,并创建一个图像表示的 KNN 索引,因为记住,它正在预测那些应该是词向量的东西。现在让我们获取“船”的快速文本向量,船不是 ImageNet 的概念 —— 然而我们现在可以找到所有在我们预测的词向量(即我们的验证集)中最接近“船”这个词的图像,即使它并没有被训练过。
nn_predwv = create_index(pred_wv)
en_vecd = pickle.load(open(TRANS_PATH/'wiki.en.pkl','rb'))
vec = en_vecd['boat']
idxs,dists = get_knn(nn_predwv, vec)
show_imgs([open_image(PATH/md.val_ds.fnames[i]) for i in idxs[:3]
], 3, figsize=(9,3));
如果我们现在取引擎的向量和船的向量并取它们的平均值,如果我们现在在最近的邻居中寻找那个[2:14:04]呢?
vec = (en_vecd['engine'] + en_vecd['boat'])/2
idxs,dists = get_knn(nn_predwv, vec)
show_imgs([open_image(PATH/md.val_ds.fnames[i]) for i in idxs[:3]
], 3, figsize=(9,3));
这些是带引擎的船。我的意思是,是的,中间那个实际上是一艘带引擎的船 —— 它碰巧也有翅膀。顺便说一句,帆不是 ImageNet 的东西,船也不是。这是两个不是 ImageNet 的东西的平均值,然而除了一个例外,它给我们找到了两艘帆船。
vec = (en_vecd['sail'] + en_vecd['boat'])/2
idxs,dists = get_knn(nn_predwv, vec)
show_imgs([open_image(PATH/md.val_ds.fnames[i]) for i in idxs[:3]
], 3, figsize=(9,3));
图像->图像[2:14:35]
好的,让我们做一些疯狂的事情。让我们在验证集中打开一张图像。让我们对该图像调用predict_array
以获取其类似词向量的东西,并让我们在所有其他图像上进行最近邻搜索。
fname = 'valid/n01440764/ILSVRC2012_val_00007197.JPEG'
img = open_image(PATH/fname)
show_img(img);
t_img = md.val_ds.transform(img)
pred = learn.predict_array(t_img[None])idxs,dists = get_knn(nn_predwv, pred)
show_imgs([open_image(PATH/md.val_ds.fnames[i]) for i in idxs[1:4]
], 3, figsize=(9,3));
这里是所有其他任何东西的图像。所以你可以看到,这很疯狂 —— 我们在一个小时内对所有 ImageNet 进行了训练,使用了一个基本上只需要两行代码的自定义头部,这些搜索运行在 300 毫秒内。
Jeremy 去年也教过这个基本概念,但是当时是在 Keras 中,代码很长,一切都很复杂。当时 Jeremy 说他无法想象你可以用这个做什么。他认为没有人真正深入思考过这个问题,但他觉得这很迷人。所以回去读 DeVICE 论文,因为 Andrea 有很多其他想法,现在做起来很容易,希望人们现在会深入研究这个问题。Jeremy 觉得这太疯狂和令人惊讶了。
好的,下周见!