fast-ai-深度学习笔记-全-

fast.ai 深度学习笔记(全)

译者:飞龙

协议:CC BY-NC-SA 4.0

深度学习 2:第 1 部分第 1 课

原文:medium.com/@hiromi_suenaga/deep-learning-2-part-1-lesson-1-602f73869197

译者:飞龙

协议:CC BY-NC-SA 4.0

来自 fast.ai 课程的个人笔记。随着我继续复习课程以“真正”理解它,这些笔记将继续更新和改进。非常感谢 JeremyRachel 给了我这个学习的机会。

第一课

开始 [0:00]:

  • 为了训练神经网络,您几乎肯定需要图形处理单元(GPU) —— 具体来说是 NVIDIA GPU,因为它是唯一支持 CUDA(几乎所有深度学习库和从业者使用的语言和框架)的 GPU。

  • 有几种租用 GPU 的方法:Crestle [04:06], Paperspace [06:10]

Jupyter Notebook 和 猫狗分类简介 [12:39]

  • 您可以通过选择单元格并按shift+enter来运行单元格(您可以按住shift并多次按enter以继续向下移动单元格),或者您可以点击顶部的运行按钮。一个单元格可以包含代码、文本、图片、视频等。

  • Fast.ai 需要 Python 3

%reload_ext autoreload
%autoreload 2
%matplotlib inline*
# This file contains all the main external libs we'll use
from fastai.imports import *
from fastai.transforms import *
from fastai.conv_learner import *
from fastai.model import *
from fastai.dataset import *
from fastai.sgdr import *
from fastai.plots import *
PATH = "data/dogscats/"
sz=224

首先看图片 [15:39]

!ls {PATH}
'''
models	sample	test1  tmp  train  valid
'''
  • ! 告诉使用 bash(shell)而不是 python

  • 如果您不熟悉训练集和验证集,请查看实用机器学习课程(或阅读Rachel 的博客

!ls {PATH}valid
'''
cats  dogs
'''
files = !ls {PATH}valid/cats | head
files
'''
['cat.10016.jpg',
 'cat.1001.jpg',
 'cat.10026.jpg',
 'cat.10048.jpg',
 'cat.10050.jpg',
 'cat.10064.jpg',
 'cat.10071.jpg',
 'cat.10091.jpg',
 'cat.10103.jpg',
 'cat.10104.jpg']
'''
  • 这个文件夹结构是共享和提供图像分类数据集的最常见方法。每个文件夹告诉您标签(例如dogscats)。
img = plt.imread(f{PATH}valid/cats/{files[0]}')
plt.imshow(img);

  • f’{PATH}valid/cats/{files[0]}’ — 这是 Python 3.6. 格式化字符串,是一种方便的格式化字符串的方法。
img.shape
'''
(198, 179, 3)
'''
img[:4,:4]
'''
array([[[ 29,  20,  23],
        [ 31,  22,  25],
        [ 34,  25,  28],
        [ 37,  28,  31]],**[[ 60,  51,  54],
        [ 58,  49,  52],
        [ 56,  47,  50],
        [ 55,  46,  49]],**[[ 93,  84,  87],
        [ 89,  80,  83],
        [ 85,  76,  79],
        [ 81,  72,  75]],**[[104,  95,  98],
        [103,  94,  97],
        [102,  93,  96],
        [102,  93,  96]]], dtype=uint8)*
  • img 是一个三维数组(也称为秩为 3 的张量)。

  • 这三个项目(例如[29, 20, 23])代表介于 0 和 255 之间的红绿蓝像素值。

  • 这个想法是拿这些数字并使用它们来预测这些数字是否代表一只猫还是一只狗,基于查看大量猫和狗的图片。

  • 这个数据集来自Kaggle 竞赛,当它发布时(2013 年),最先进的技术准确率为 80%。

让我们训练一个模型 [20:21]

这是训练模型所需的三行代码:

data = ImageClassifierData.from_paths(PATH, tfms=tfms_from_model(resnet34, sz))
learn = ConvLearner.pretrained(resnet34, data, precompute=True)
learn.fit(0.01, 3)
'''
[ 0\.       0.04955  0.02605  0.98975]                         
[ 1\.       0.03977  0.02916  0.99219]                         
[ 2\.       0.03372  0.02929  0.98975]
'''

  • 这将进行 3 ,这意味着它将三次查看整个图像集。

  • 输出中的三个数字中的最后一个是验证集上的准确率。

  • 前两个是训练集和验证集的损失函数值(在本例中是交叉熵损失)。

  • 起始(例如0.1.)是轮数。

  • 我们在 17 秒内用 3 行代码实现了约 99% 的准确率(这在 2013 年将赢得 Kaggle 竞赛)![21:49]

  • 很多人认为深度学习需要大量时间、资源和数据 —— 总的来说,这并不是真的!

Fast.ai 库 [22:24]

  • 该库采用了他们能找到的所有最佳实践和方法 —— 每次有一篇看起来有趣的论文出来时,他们会测试它,如果它在各种数据集上表现良好并且他们能够找出如何调整它,那么它就会被实现在库中。

  • Fast.ai 为您整理了所有这些最佳实践并打包起来,大多数情况下,会自动找出最佳处理方式。

  • Fast.ai 建立在一个名为 PyTorch 的库之上,这是一个由 Facebook 编写的非常灵活的深度学习、机器学习和 GPU 计算库。

  • 大多数人对 TensorFlow 比 PyTorch 更熟悉,但 Jeremy 现在认识的大多数顶尖研究人员已经转向 PyTorch。

  • Fast.ai 非常灵活,您可以根据需要使用所有这些精心策划的最佳实践。您可以轻松地在任何时候连接并编写自己的数据增强、损失函数、网络架构等,我们将在本课程中学习所有这些。

分析结果[24:21]

这是验证数据集标签(将其视为正确答案)的样子:

data.val_y
'''
array([0, 0, 0, ..., 1, 1, 1])
'''

这些 0 和 1 代表什么?

data.classes
'''
['cats', 'dogs']
'''
  • data包含验证和训练数据

  • learn包含模型

让我们对验证集进行预测(预测以对数刻度表示):

log_preds = learn.predict()
log_preds.shape
'''
(2000, 2)
'''
log_preds[:10]
'''
array([[ -0.00002, -11.07446],
       [ -0.00138,  -6.58385],
       [ -0.00083,  -7.09025],
       [ -0.00029,  -8.13645],
       [ -0.00035,  -7.9663 ],
       [ -0.00029,  -8.15125],
       [ -0.00002, -10.82139],
       [ -0.00003, -10.33846],
       [ -0.00323,  -5.73731],
       [ -0.0001 ,  -9.21326]], dtype=float32)
'''
  • 输出表示对猫的预测和对狗的预测
preds = np.argmax(log_preds, axis=1)  # from log probabilities to 0 or 1
probs = np.exp(log_preds[:,1])        # pr(dog)
  • 在 PyTorch 和 Fast.ai 中,大多数模型返回预测的对数而不是概率本身(我们将在课程中稍后学习原因)。现在,只需知道要获得概率,您必须执行np.exp()

  • 确保您熟悉 numpy(np

# 1\. A few correct labels at random
plot_val_with_title(rand_by_correct(True), "Correctly classified")
  • 图像上方的数字是狗的概率
# 2\. A few incorrect labels at random
plot_val_with_title(rand_by_correct(False), "Incorrectly classified")

plot_val_with_title(most_by_correct(0, True), "Most correct cats")

plot_val_with_title(most_by_correct(1, True), "Most correct dogs")

更有趣的是,以下是模型认为肯定是狗的东西,但结果是猫,反之亦然:

plot_val_with_title(most_by_correct(0, False), "Most incorrect cats")

plot_val_with_title(most_by_correct(1, False), "Most incorrect dogs")

most_uncertain = np.argsort(np.abs(probs -0.5))[:4]
plot_val_with_title(most_uncertain, "Most uncertain predictions")

  • 为什么查看这些图像很重要?Jeremy 在构建模型后的第一件事是找到一种可视化其构建内容的方法。因为如果他想让模型更好,那么他需要利用做得好的事情并修复做得不好的事情。

  • 在这种情况下,我们已经了解了数据集本身的一些信息,即这里有一些可能不应该存在的图像。但很明显,这个模型还有改进的空间(例如数据增强 - 我们将在以后学习)。

  • 现在,您已经准备好构建自己的图像分类器(用于常规照片 - 也许不是 CT 扫描)!例如,这里是一个学生的示例。

  • 查看此论坛帖子以了解不同的可视化结果方式(例如,当存在超过 2 个类别时等)

自上而下 vs 自下而上[30:52]

自下而上:学习您需要的每个构建块,最终将它们组合在一起

  • 难以保持动力

  • 难以了解“全局图景”

  • 难以知道您实际需要哪些部分

fast.ai:让学生立即使用神经网络,尽快获得结果

  • 逐渐剥开层,修改,查看内部

课程结构[33:53]

  1. 使用深度学习的图像分类器(代码行数最少)

  2. 多标签分类和不同类型的图像(例如卫星图像)

  3. 结构化数据(例如销售预测)- 结构化数据来自数据库或电子表格

  4. 语言:NLP 分类器(例如电影评论分类)

  5. 协同过滤(例如推荐引擎)

  6. 生成语言模型:如何逐个字符从头开始编写您自己的尼采哲学

  7. 回到计算机视觉 - 不仅识别猫照片,还要找到照片中的猫所在位置(热图),并学习如何从头开始编写我们自己的架构(ResNet)

图像分类器示例:

图像分类算法对许多事物非常有用。

  • 例如,AlphaGo[42:20]查看了成千上万个围棋棋盘,每个棋盘上都有一个标签,说明这个棋盘最终是赢家还是输家。因此,它学会了一种能够查看围棋棋盘并判断它是好还是坏的图像分类——这是打好围棋最重要的一步:知道哪一步走得更好。

  • 另一个例子是一个早期的学生创建了一个鼠标移动图像分类器并检测到欺诈交易。

深度学习≠机器学习[44:26]

  • 深度学习是一种机器学习

  • 机器学习是由 Arthur Samuel 发明的。在 50 年代末,他让 IBM 大型机比他更擅长下棋,发明了机器学习。他让大型机反复对弈,并找出导致胜利的种种因素,然后利用这些因素,以某种方式编写自己的程序。1962 年,Arthur Samuel 说,未来绝大多数计算机软件将使用这种机器学习方法编写,而不是手工编写。

  • C-Path(计算病理学家)[45:42]是传统机器学习方法的一个例子。他拍摄了乳腺癌活检的病理学切片,咨询了许多病理学家关于与长期生存相关的模式或特征可能是什么。然后,他们编写了专家算法来计算这些特征,通过逻辑回归进行运算,并预测了生存率。它胜过了病理学家,但需要领域专家和计算机专家多年的工作才能构建。

更好的方法[47:35]

  • 具有这三个特性的算法类别是深度学习。

无限灵活的函数:神经网络[48:43]

深度学习使用的基础函数称为神经网络:

  • 现在你需要知道的是,它由许多简单的线性层和许多简单的非线性层组成。当你交错这些层时,你会得到一个称为通用逼近定理的东西。通用逼近定理所说的是,只要添加足够的参数,这种函数可以解决任何给定的问题,达到任意接近的精度。

全能参数拟合:梯度下降[49:39]

快速且可扩展:GPU[51:05]

上面显示的神经网络示例有一个隐藏层。我们在过去几年学到的一些东西是,这种神经网络如果不添加多个隐藏层,就不会快速或可扩展,因此被称为“深度”学习。

将所有内容放在一起[53:40]

以下是一些例子:

诊断肺癌[56:55]

其他当前应用:

卷积神经网络[59:13]

线性层

setosa.io/ev/image-kernels/

非线性层[01:02:12]

神经网络和深度学习

在这一章中,我给出了普适性定理的简单且大部分是可视化的解释。我们将一步一步地进行...

Sigmoid 和 ReLU

  • 线性层和逐元素非线性函数的组合使我们能够创建任意复杂的形状 — 这是普适性定理的本质。

如何设置这些参数来解决问题[01:04:25]

  • 随机梯度下降 — 我们沿着山坡小步前进。步长被称为学习率

  • 如果学习率太大,它会发散而不是收敛

  • 如果学习率太小,将需要很长时间

可视化和理解卷积网络[01:08:27]

我们从一些非常简单的东西开始,但如果我们将其用作足够大的规模,由于普适性定理和深度学习中多个隐藏层的使用,我们实际上获得了非常丰富的能力。这实际上是我们在训练狗和猫识别器时使用的方法。

狗 vs. 猫再访——选择学习率[01:11:41]

learn.fit(0.01, 3)
  • 第一个数字0.01是学习率。

  • 学习率决定了你想要多快或多慢地更新权重(或参数)。学习率是最难设置的参数之一,因为它会显著影响模型性能。

  • 方法learn.lr_find()可以帮助你找到一个最佳的学习率。它使用了 2015 年的论文Cyclical Learning Rates for Training Neural Networks中开发的技术,我们简单地从一个非常小的值开始不断增加学习率,直到损失停止减少。我们可以绘制跨批次的学习率,看看这是什么样子。

learn = ConvLearner.pretrained(arch, data, precompute=True)
learn.lr_find()

我们的learn对象包含一个包含我们学习率调度器的属性sched,并具有一些方便的绘图功能,包括这个:

learn.sched.plot_lr()

  • Jeremy 目前正在尝试指数增加学习率与线性增加学习率。

我们可以看到损失与学习率的图表,以查看我们的损失何时停止减少:

learn.sched.plot()

  • 然后我们选择损失仍然明显改善的学习率 — 在这种情况下是1e-2(0.01)

选择迭代次数[1:18:49]


'''
[ 0\.       0.04955  0.02605  0.98975]                         
[ 1\.       0.03977  0.02916  0.99219]                         
[ 2\.       0.03372  0.02929  0.98975]
'''

  • 你想要多少都可以,但如果运行时间太长,准确性可能会开始变差。这被称为“过拟合”,我们稍后会更多地了解它。

  • 另一个考虑因素是你可用的时间。

技巧和窍门[1:21:40]

1.Tab — 当你记不住函数名时,它会自动完成

2. Shift + Tab — 它会显示函数的参数

3. Shift + Tab + Tab — 它会显示文档(即 docstring)

4. Shift + Tab + Tab + Tab — 它会打开一个带有相同信息的单独窗口。

在单元格中键入?后跟一个函数名并运行它将与shift + tab(3 次)相同

5. 输入两个问号将显示源代码

6. 在 Jupyter Notebook 中键入H将打开一个带有键盘快捷键的窗口。尝试每天学习 4 或 5 个快捷键

7. 停止 Paperspace、Crestle、AWS — 否则你将被收费$$

8. 请记住关于论坛course.fast.ai/(每节课)的最新信息。

深度学习 2:第 1 部分第 2 课

原文:medium.com/@hiromi_suenaga/deep-learning-2-part-1-lesson-2-eeae2edd2be4

译者:飞龙

协议:CC BY-NC-SA 4.0

来自 fast.ai 课程的个人笔记。随着我继续复习课程以“真正”理解它,这些笔记将继续更新和改进。非常感谢 JeremyRachel 给了我这个学习的机会。

第 2 课

笔记本

上一课的回顾[01:02]

  • 我们用 3 行代码构建了一个图像分类器。

  • 为了训练模型,数据需要以一定方式组织在PATH(在本例中为data/dogscats/)下:

  • 应该有一个train文件夹和一个valid文件夹,每个文件夹下面都有带有分类标签的文件夹(例如本例中的catsdogs),其中包含相应的图像。

  • 训练输出:[*epoch #*,* *training loss*, *validation loss*, *accuracy*]


'''
[ 0\.       0.04955  0.02605  0.98975]
'''

学习率[4:54]

  • 学习率的基本思想是它将决定我们快速地聚焦在解决方案上。

  • 如果学习率太小,将需要很长时间才能到达底部

  • 如果学习率太大,它可能会从底部摆动。

  • 学习率查找器(learn.lr_find)会在每个小批次后增加学习率。最终,学习率会变得太高,损失会变得更糟。然后,我们查看学习率与损失的图表,并确定最低点,然后后退一个数量级,并选择该学习率(在下面的示例中为1e-2)。

  • 小批量是我们每次查看的几个图像,以便有效地利用 GPU 的并行处理能力(通常每次 64 或 128 个图像)。

  • 在 Python 中:

  • 通过调整这个数字,您应该能够获得相当不错的结果。fast.ai 库会为您选择其余的超参数。但随着课程的进行,我们将学习到一些更多的可以调整以获得稍微更好结果的东西。但学习率对我们来说是关键数字。

  • 学习率查找器位于其他优化器(例如动量、Adam 等)之上,并帮助您选择最佳学习率,考虑您正在使用的其他调整(例如高级优化器但不限于优化器)。

  • 问题:在 epoch 期间改变学习率的优化器会发生什么?这个查找器是否选择了初始学习率?[14:05] 我们稍后会详细了解优化器,但基本答案是否定的。即使是 Adam 也有一个学习率,该学习率会被平均先前梯度和最近平方梯度的总和除以。即使那些所谓的“动态学习率”方法也有学习率。

  • 使模型更好的最重要的事情是提供更多数据。由于这些模型有数百万个参数,如果您训练它们一段时间,它们开始做所谓的“过拟合”。

  • 过拟合 - 模型开始看到训练集中图像的具体细节,而不是学习一些可以转移到验证集的通用内容。

  • 我们可以收集更多数据,但另一种简单的方法是数据增强。

数据增强[15:50]

  • 每个 epoch,我们会随机微调图像。换句话说,模型每个 epoch 都会看到图像的略微不同版本。

  • 您希望为不同类型的图像使用不同类型的数据增强(水平翻转、垂直翻转、放大、缩小、变化对比度和亮度等)。

学习率查找问题:

  • 为什么不选择最低点?损失最低的点是红色圆圈所在的位置。但是在那一点学习率实际上太大了,不太可能收敛。因此,前一个点可能是更好的选择(总是选择比太大的学习率更小的学习率更好)

  • 何时学习lr_find?在开始时运行一次,也许在解冻层之后再运行(我们稍后会学习)。还有当我改变我正在训练的东西或改变我训练的方式时。运行它永远不会有害。

回到数据增强:

tfms = tfms_from_model(resnet34, sz, aug_tfms=transforms_side_on, max_zoom=1.1)
  • transform_side_on - 用于侧面照片的预定义转换集(还有transform_top_down)。稍后我们将学习如何创建自定义转换列表。

  • 这并不是在创建新数据,而是让卷积神经网络学习如何从略有不同的角度识别猫或狗。

data = ImageClassifierData.from_paths(PATH, tfms=tfms)
learn = ConvLearner.pretrained(arch, data, precompute=True)learn.fit(1e-2, 1)
  • 现在我们创建了一个包含增强的新data对象。最初,由于precompute=True,增强实际上什么也没做。

  • 卷积神经网络有这些称为“激活”的东西。激活是一个数字,表示“这个特征在这个位置以这个置信度(概率)”。我们正在使用一个已经学会识别特征的预训练网络(即我们不想改变它学到的超参数),所以我们可以预先计算隐藏层的激活,然后只训练最终的线性部分。

  • 这就是为什么当你第一次训练模型时,需要更长时间 - 它正在预计算这些激活。

  • 尽管我们每次都试图展示猫的不同版本,但我们已经为特定版本的猫预先计算了激活(即我们没有使用改变后的版本重新计算激活)。

  • 要使用数据增强,我们必须执行learn.precompute=False

learn.precompute=Falselearn.fit(1e-2, 3, cycle_len=1)
'''
[ 0\.       0.03597  0.01879  0.99365]                         
[ 1\.       0.02605  0.01836  0.99365]                         
[ 2\.       0.02189  0.0196   0.99316]
'''

  • 坏消息是准确性没有提高。训练损失在减少,但验证损失没有,但我们没有过拟合。过拟合是指训练损失远低于验证损失。换句话说,当你的模型在训练集上表现比在验证集上好得多时,这意味着你的模型没有泛化。

  • cycle_len=1:这使得随机梯度下降重启(SGDR)成为可能。基本思想是,当你越来越接近具有最小损失的位置时,你可能希望开始减小学习率(采取更小的步骤)以确切地到达正确的位置。

  • 在训练过程中降低学习率的想法被称为学习率退火,这是非常常见的。最常见和“hacky”方法是使用某个学习率训练模型一段时间,当它停止改进时,手动降低学习率(分阶段退火)。

  • 更好的方法是简单地选择某种功能形式 - 结果表明,真正好的功能形式是余弦曲线的一半,它在开始时保持高学习率,然后在接近时迅速下降。

  • 然而,我们可能发现自己处于一个不太有弹性的权重空间中 - 也就是说,对权重进行微小的更改可能导致损失的巨大变化。我们希望鼓励我们的模型找到既准确又稳定的权重空间的部分。因此,我们不时增加学习率(这是“SGDR”中的“重启”),这将迫使模型跳到权重空间的不同部分,如果当前区域“尖锐”。如果我们三次重置学习率,它可能看起来像这样(在这篇论文中,他们称之为“循环 LR 计划”):

  • 重置学习率之间的周期数由cycle_len设置,这种情况下发生的次数被称为周期数,实际上是我们作为fit()的第二个参数传递的内容。这是我们实际学习率的样子:

  • 问题:我们可以通过使用随机起始点获得相同的效果吗?在创建 SGDR 之前,人们通常会创建“集成”,他们会重新学习一个全新的模型十次,希望其中一个会变得更好。在 SGDR 中,一旦我们接近最佳和稳定区域,重置实际上不会“重置”,而是权重保持更好。因此,SGDR 将比随机尝试几个不同的起始点给出更好的结果。

  • 选择一个学习率(这是 SGDR 使用的最高学习率)很重要,它足够大,可以使重置跳转到函数的不同部分。

  • SGDR 会在每个小批次中降低学习率,并且重置每个cycle_len周期(在这种情况下设置为 1)。

  • 问题:我们的主要目标是泛化,而不是陷入狭窄的最优解。在这种方法中,我们是否跟踪最小值并对其进行平均处理并集成它们?这是另一种复杂程度,您可以在图表中看到“快照集成”。我们目前没有这样做,但如果您希望泛化得更好,可以在重置之前保存权重并取平均值。但目前,我们只会选择最后一个。

  • 如果您想要跳过,还有一个名为cycle_save_name的参数,您可以添加它以及cycle_len,它将在每个学习率周期结束时保存一组权重,然后您可以将它们集成。

保存模型

learn.save('224_lastlayer')
learn.load('224_lastlayer')
  • 当您预计算激活或创建调整大小的图像(我们将很快学习到),会创建各种临时文件,您可以在data/dogcats/tmp文件夹下看到。如果出现奇怪的错误,可能是因为预计算的激活只完成了一半,或者以某种方式与您正在进行的操作不兼容。因此,您可以随时继续并删除此/tmp文件夹,看看是否可以消除错误(相当于将其关闭然后重新打开)。

  • 您还会看到一个名为/models的目录,这是当您说learn.save时保存模型的位置。

微调和差分学习率

  • 到目前为止,我们还没有重新训练任何预训练的特征 - 具体来说,卷积核中的任何权重。我们所做的只是在顶部添加了一些新层,并学会了如何混合和匹配预训练的特征。

  • 像卫星图像、CT 扫描等图像具有完全不同类型的特征(与 ImageNet 图像相比),因此您需要重新训练许多层。

  • 对于狗和猫,图像与模型预先训练的图像相似,但我们仍然可能发现微调一些后续层会有所帮助。

  • 这是如何告诉学习者我们要开始实际更改卷积滤波器本身的方法:

learn.unfreeze()
  • “冻结”层是一个未被训练/更新的层。unfreeze会解冻所有层。

  • 像第一层(检测对角边缘或梯度)或第二层(识别角落或曲线)这样的早期层可能根本不需要或只需要很少的更改。

  • 后续层更有可能需要更多的学习。因此,我们创建了一个学习率数组(差分学习率):

lr=np.array([1e-4,1e-3,1e-2])
  • 1e-4:用于前几层(基本几何特征)

  • 1e-3:用于中间层(复杂的卷积特征)

  • 1e-2:用于我们添加的顶部层

  • 为什么是 3?实际上它们是 3 个 ResNet 块,但现在,可以将其视为一组层。

问题:如果我的图片比模型训练的图片大怎么办?简短的答案是,使用这个库和我们正在使用的现代架构,我们可以使用任何大小的图片。

问题:我们可以只解冻特定的层吗?我们还没有这样做,但如果你想的话,你可以使用learn.unfreeze_to(n)(这将从第n层开始解冻层)。Jeremy 几乎从来没有发现这有帮助,他认为这是因为我们使用了不同的学习率,优化器可以学习到它需要的一样多。他发现有帮助的一个地方是,如果他使用一个真正大的内存密集型模型,而且他的 GPU 快要用完了,你解冻的层数越少,占用的内存和时间就越少。

使用不同的学习率,我们的准确率达到了 99.5%!

learn.fit(lr, 3, cycle_len=1, cycle_mult=2)
'''
[ 0\.       0.04538  0.01965  0.99268]                          
[ 1\.       0.03385  0.01807  0.99268]                          
[ 2\.       0.03194  0.01714  0.99316]                          
[ 3\.       0.0358   0.0166   0.99463]                          
[ 4\.       0.02157  0.01504  0.99463]                          
[ 5\.       0.0196   0.0151   0.99512]                          
[ 6\.       0.01356  0.01518  0.9956 ]
'''

  • 之前我们说3是周期的数量,但实际上是周期。所以如果cycle_len=2,它将执行 3 个周期,每个周期为 2 个周期(即 6 个周期)。那为什么是 7 个?这是因为cycle_mult

  • cycle_mult=2:这会在每个周期后乘以周期的长度(1 个周期+2 个周期+4 个周期=7 个周期)。

直观地说,如果周期长度太短,它开始下降寻找一个好的位置,然后弹出,再次下降寻找一个好的位置,然后弹出,永远无法找到一个好的位置。在早期,你希望它这样做,因为它试图找到一个更平滑的位置,但后来,你希望它做更多的探索。这就是为什么cycle_mult=2似乎是一个好方法。

我们正在引入越来越多的超参数,告诉你没有很多。你可以只选择一个好的学习率,但添加这些额外的调整可以在不费力的情况下获得额外的提升。一般来说,好的起点是:

  • n_cycle=3,cycle_len=1,cycle_mult=2

  • n_cycle=3,cycle_len=2(没有cycle_mult

问题:为什么更平滑的表面与更广义的网络相关?

假设你有一个尖锐的东西(蓝线)。X 轴显示了当你改变这个特定参数时,它在识别狗和猫方面的表现如何。可泛化意味着当我们给它一个略微不同的数据集时,我们希望它能够工作。略微不同的数据集可能在这个参数和猫狗之间的关系上有略微不同。它可能看起来像红线。换句话说,如果我们最终到达蓝色尖锐部分,那么它在这个略微不同的数据集上不会表现良好。或者,如果我们最终到达较宽的蓝色部分,它仍然会在红色数据集上表现良好。

  • 这里有一些关于峰值最小值的有趣讨论。

测试时间增强(TTA)

我们的模型已经达到了 99.5%。但我们还能让它变得更好吗?让我们看看我们错误预测的图片:

在这里,Jeremy 打印出了所有这些图片。当我们进行验证集时,我们模型的所有输入必须是正方形的。原因有点小的技术细节,但如果不同的图片有不同的尺寸,GPU 不会很快。它需要保持一致,以便 GPU 的每个部分都可以做同样的事情。这可能是可以解决的,但目前这是我们拥有的技术状态。

为了使它成为正方形,我们只需挑选中间的正方形——正如你所看到的,可以理解为什么这张图片被错误分类:

我们将进行所谓的“测试时间增强”。这意味着我们将随机进行 4 次数据增强,以及未增强的原始图像(中心裁剪)。然后我们将为所有这些图像计算预测,取平均值,并将其作为我们的最终预测。请注意,这仅适用于验证集和/或测试集。

要做到这一点,您只需learn.TTA()——这将将准确性提高到 99.65%!

log_preds,y = learn.TTA()
probs = np.mean(np.exp(log_preds),0)
accuracy(probs, y)
'''
0.99650000000000005
'''

关于增强方法的问题[01:01:36]:为什么不使用边框或填充使其变成正方形?通常 Jeremy 不会做太多填充,而是会做一点缩放。有一种叫做反射填充的东西在卫星图像中效果很好。一般来说,使用 TTA 加数据增强,最好的做法是尽可能使用尽可能大的图像。此外,固定裁剪位置加上随机对比度、亮度、旋转变化可能对 TTA 更好。

问题:非图像数据集的数据增强?[01:03:35] 没有人似乎知道。看起来会有帮助,但例子很少。在自然语言处理中,人们尝试替换同义词,但总体来说,这个领域研究不足,发展不足。

问题:fast.ai 库是开源的吗?[01:05:34] 是的。然后他讲解了Fast.ai 从 Keras + TensorFlow 切换到 PyTorch 的原因

随机笔记:PyTorch 不仅仅是一个深度学习库。它实际上让我们可以从头开始编写任意 GPU 加速的算法——Pyro 是人们现在在 PyTorch 之外进行的一个很好的例子。

分析结果[01:11:50]

混淆矩阵

分类结果的简单查看方式称为混淆矩阵——不仅用于深度学习,而且用于任何类型的机器学习分类器。如果你试图预测四五类,特别有帮助,可以看出你在哪个组别遇到了最大的困难。

preds = np.argmax(probs, axis=1)
probs = probs[:,1]
from sklearn.metrics import confusion_matrix
cm = confusion_matrix(y, preds)
plot_confusion_matrix(cm, data.classes)

让我们再次看看图片[01:13:00]

大多数错误的猫(只有左边两个是错误的——默认显示 4 个):

大多数错误的点:

回顾:训练世界一流的图像分类器的简单步骤[01:14:09]

  1. 启用数据增强,precompute=True

  2. 使用lr_find()找到损失仍然明显改善的最高学习率

  3. 从预计算的激活中训练最后一层 1-2 个时期

  4. 使用数据增强训练最后一层(即precompute=False)2-3 个时期,cycle_len=1

  5. 解冻所有层

  6. 将前面的层设置为比下一层低 3 倍至 10 倍的学习率。经验法则:ImageNet 类似的图像为 10 倍,卫星或医学成像为 3 倍

  7. 再次使用lr_find()(注意:如果您设置了不同的学习率并调用lr_find,它打印出的是最后几层的学习率。)

  8. 使用cycle_mult=2训练完整网络直到过拟合

让我们再做一次:狗品种挑战 [01:16:37]

  • 您可以使用Kaggle CLI下载 Kaggle 竞赛的数据

  • 笔记本没有公开,因为它是一个活跃的竞赛

%reload_ext autoreload
%autoreload 2
%matplotlib inlinefrom fastai.imports import *
from fastai.transforms import *
from fastai.conv_learner import *
from fastai.model import *
from fastai.dataset import *
from fastai.sgdr import *
from fastai.plots import *
PATH = 'data/dogbreed/'
sz = 224
arch = resnext101_64
bs=16
label_csv = f'{PATH}labels.csv'
n = len(list(open(label_csv)))-1
val_idxs = get_cv_idxs(n)
!ls {PATH}

这与我们以前的数据集有点不同。它没有一个包含每个狗品种的单独文件夹的train文件夹,而是有一个带有正确标签的 CSV 文件。我们将使用 Pandas 读取 CSV 文件。Pandas 是我们在 Python 中用来进行结构化数据分析的工具,比如 CSV,通常被导入为pd

label_df = pd.read_csv(label_csv)
label_df.head()

label_df.pivot_table(index='breed', aggfunc=len).sort_values('id', ascending=False)

每个品种有多少狗图像

tfms = tfms_from_model(arch, sz, aug_tfms=transforms_side_on, 
                max_zoom=1.1)
data = ImageClassifierData.from_csv(PATH, 'train', 
                f'{PATH}labels.csv', test_name='test', 
                val_idxs=val_idxs, suffix='.jpg', tfms=tfms, bs=bs)
  • max_zoom——我们将放大 1.1 倍

  • ImageClassifierData.from_csv — 上次我们使用了from_paths,但由于标签在 CSV 文件中,我们将使用from_csv

  • test_name — 如果要提交到 Kaggle 比赛,我们需要指定测试集的位置

  • val_idx — 没有validation文件夹,但我们仍然想要跟踪我们的本地表现有多好。因此你会看到上面的:

n = len(list(open(label_csv)))-1:打开 CSV 文件,创建一个行列表,然后取长度。 -1是因为第一行是标题。因此n是我们拥有的图像数量。

val_idxs = **get_cv_idxs**(n): “获取交叉验证索引” — 默认情况下,这将返回随机 20%的行(确切的索引)作为验证集。你也可以发送val_pct以获得不同的数量。

  • suffix='.jpg' — 文件名以.jpg结尾,但 CSV 文件没有。因此我们将设置suffix以便它知道完整的文件名。
fn = PATH + data.trn_ds.fnames[0]; fn
'''
'data/dogbreed/train/001513dfcb2ffafc82cccf4d8bbaba97.jpg'
'''
  • 你可以通过说data.trn_ds来访问训练数据集,trn_ds包含很多东西,包括文件名(fnames
img = PIL.Image.open(fn); img

img.size
'''
(500, 375)
'''
  • 现在我们检查图像大小。如果它们很大,那么你必须非常仔细地考虑如何处理它们。如果它们很小,也是具有挑战性的。大多数 ImageNet 模型都是在 224x224 或 299x299 的图像上训练的
size_d = {k: PIL.Image.open(PATH+k).size for k in data.trn_ds.fnames}
  • 字典推导 — 键: 文件名值: 文件大小
row_sz, col_sz = list(zip(*size_d.values()))
  • *size_d.values()将解压缩一个列表。zip将元组的元素配对以创建一个元组列表。
plt.hist(row_sz);

行的直方图

  • 如果你在 Python 中进行任何数据科学或机器学习,Matplotlib 是你想要非常熟悉的东西。Matplotlib 总是被称为plt

问题:我们应该使用多少图像作为验证集?[01:26:28] 使用 20%是可以的,除非数据集很小 — 那么 20%就不够了。如果你多次训练相同的模型并且得到非常不同的验证集结果,那么你的验证集太小了。如果验证集小于一千,很难解释你的表现如何。如果你关心准确度的第三位小数,并且验证集中只有一千个数据,一个图像的变化就会改变准确度。如果你关心 0.01 和 0.02 之间的差异,你希望这代表 10 或 20 行。通常 20%似乎效果不错。

def get_data(sz, bs):
    tfms = tfms_from_model(arch, sz, aug_tfms=transforms_side_on,
                           max_zoom=1.1)
    data = ImageClassifierData.from_csv(PATH, 'train', 
               f'{PATH}labels.csv', test_name='test', num_workers=4,
               val_idxs=val_idxs, suffix='.jpg', tfms=tfms, bs=bs) return data if sz>300 else data.resize(340, 'tmp') 
  • 这是常规的两行代码。当我们开始使用新数据集时,我们希望一切都能快速进行。因此,我们可以指定大小并从 64 开始,这样会运行得更快。稍后,我们将使用更大的图像和更大的架构,到那时,你可能会耗尽 GPU 内存。如果你看到 CUDA 内存不足错误,你需要做的第一件事是重新启动内核(你无法从中恢复),然后减小批量大小。
data = get_data(224, bs)
learn = ConvLearner.pretrained(arch, data, precompute=True)
learn.fit(1e-2, 5)
'''
[0\.      1.99245 1.0733  0.76178]                             
[1\.      1.09107 0.7014  0.8181 ]                             
[2\.      0.80813 0.60066 0.82148]                             
[3\.      0.66967 0.55302 0.83125]                             
[4\.      0.57405 0.52974 0.83564]
'''

  • 对于 120 个类别来说,83%是相当不错的。
learn.precompute = False
learn.fit(1e-2, 5, cycle_len=1)
  • 提醒:一个epoch是对数据的一次遍历,一个cycle是你说一个周期中有多少个 epoch
learn.save('224_pre')
learn.load('224_pre')

增加图像大小 [1:32:55]

learn.set_data(get_data(299, bs))
  • 如果你在较小尺寸的图像上训练了一个模型,然后可以调用learn.set_data并传入一个更大尺寸的数据集。这将采用到目前为止已经训练过的模型,并让你继续在更大的图像上训练。

从小图像开始训练几个时期,然后切换到更大的图像,并继续训练是一个非常有效的避免过拟合的方法。

learn.fit(1e-2, 3, cycle_len=1)
'''
[0\.      0.35614 0.22239 0.93018]                            
[1\.      0.28341 0.2274  0.92627]
[2\.* *0.28341**0.2274* *0.92627]
'''

  • 如你所见,验证集损失(0.2274)远低于训练集损失(0.28341) — 这意味着它是欠拟合。当你欠拟合时,意味着cycle_len=1太短了(学习率在适当缩小之前被重置)。所以我们将添加cycle_mult=2(即第一个周期是 1 个时期,第二个周期是 2 个时期,第三个周期是 4 个时期)
learn.fit(1e-2, 3, cycle_len=1, cycle_mult=2)
'''
[0\.      0.27171 0.2118  0.93192]                            
[1\.      0.28743 0.21008 0.9324 ]
[2\.      0.25328 0.20953 0.93288]                            
[3\.      0.23716 0.20868 0.93001]
[4\.      0.23306 0.20557 0.93384]                            
[5\.      0.22175 0.205   0.9324 ]
[6\.      0.2067  0.20275 0.9348 ]
  • 现在验证损失和训练损失大致相同 — 这是正确的轨道。然后我们尝试TTA
log_preds, y = learn.TTA()
probs = np.exp(log_preds)
accuracy(log_preds,y), metrics.log_loss(y, probs)
'''
(0.9393346379647749, 0.20101565705592733)
'''

其他尝试:

  • 尝试再运行一个 2 个时期的周期

  • 解冻(在这种情况下,训练卷积层根本没有帮助,因为图像实际上来自 ImageNet)

  • 删除验证集,只需重新运行相同的步骤,并提交 - 这样我们可以使用 100%的数据。

问题:我们如何处理不平衡的数据集?[01:38:46]这个数据集不是完全平衡的(在 60 和 100 之间),但不够不平衡,以至于 Jeremy 不会再考虑。最近的一篇论文说,处理非常不平衡的数据集的最佳方法是复制罕见情况。

问题precompute=Trueunfreeze之间的区别?

  • 我们从预训练网络开始

  • 我们在其末尾添加了几层,这些层最初是随机的。当所有内容都被冻结且precompute=True时,我们学到的只是我们添加的层。

  • 使用precompute=True,数据增强不起作用,因为每次显示的激活完全相同。

  • 然后我们将precompute=False设置为假,这意味着我们仍然只训练我们添加的层,因为它被冻结,但数据增强现在正在工作,因为它实际上正在重新计算所有激活。

  • 最后,我们解冻,这意味着“好的,现在您可以继续更改所有这些早期卷积滤波器”。

问题:为什么不从一开始就将precompute=False设置为假?将precompute=True的唯一原因是它速度更快(快 10 倍或更多)。如果您正在处理相当大的数据集,它可以节省相当多的时间。从来没有理由使用precompute=True来提高准确性。

获得良好结果的最小步骤:

  1. 使用lr_find()找到损失仍然明显改善的最高学习率

  2. 使用数据增强(即precompute=False)训练最后一层 2-3 个周期,cycle_len=1

  3. 解冻所有层

  4. 将较早的层设置为比下一层更高的层次低 3 倍至 10 倍的学习率

  5. 使用cycle_mult=2训练完整网络直到过拟合

问题:减少批量大小只影响训练速度吗?[1:43:34]是的,基本上是这样。如果每次显示的图像较少,则使用较少的图像计算梯度 - 因此准确性较低。换句话说,知道要走哪个方向以及在该方向上走多远的准确性较低。因此,随着批量大小变小,它变得更加不稳定。它会影响您需要使用的最佳学习率,但实际上,将批量大小除以 2 与除以 4 似乎并没有太大变化。如果更改批量大小很大,可以重新运行学习率查找器进行检查。

问题:灰色图像与右侧图像之间有什么区别?

可视化和理解卷积网络

第一层,它们确实是滤波器的样子。很容易可视化,因为输入是像素。后来,变得更难,因为输入本身是激活,是激活的组合。Zeiler 和 Fergus 提出了一种聪明的技术,展示滤波器平均倾向于什么样子 - 称为反卷积(我们将在第 2 部分学习)。右侧是激活该滤波器的图像块的示例。

问题:如果狗在角落或很小,你会怎么做(关于狗品种识别)?[01:47:16]我们将在第 2 部分学习,但有一种技术可以让您大致确定图像的哪些部分最有趣。然后您可以裁剪出该区域。

进一步改进[01:48:16]

立即可以做两件事来使其更好:

  1. 假设您使用的图像大小小于您所获得的图像的平均大小,您可以增加大小。正如我们之前所看到的,您可以在训练期间增加它。

  2. 使用更好的架构。有不同的方法来组合卷积滤波器的大小以及它们如何连接在一起,不同的架构具有不同数量的层,内核大小,滤波器等。

我们一直在使用 ResNet34 — 一个很好的起点,通常也是一个很好的终点,因为它没有太多参数,并且在小数据集上表现良好。还有另一种架构叫做 ResNext,它是去年 ImageNet 比赛的第二名。ResNext50 的训练时间是 ResNet34 的两倍,内存使用量是其 2-4 倍。

这里是几乎与原始狗和猫相同的笔记本。使用了 ResNext50,实现了 99.75%的准确率。

卫星图像 [01:53:01]

笔记本

代码基本与之前看到的相同。以下是一些不同之处:

  • transforms_top_down — 由于它们是卫星图像,所以在垂直翻转时仍然有意义。

  • 学习率更高 — 与这个特定数据集有关

  • lrs = np.array([lr/9,lr/3,lr]) — 差异学习率现在变为 3 倍,因为图像与 ImageNet 图像非常不同

  • sz=64 — 这有助于避免卫星图像的过拟合,但对于狗和猫或狗品种(与 ImageNet 相似的图像)他不会这样做,因为 64x64 相当小,可能会破坏预训练权重。

如何设置您的 AWS [01:58:54]

您可以跟着视频或这里是一位学生写的很好的文章。

深度学习 2:第 1 部分第 3 课

原文:medium.com/@hiromi_suenaga/deep-learning-2-part-1-lesson-3-74b0ef79e56

译者:飞龙

协议:CC BY-NC-SA 4.0

来自 fast.ai 课程的个人笔记。随着我继续复习课程以“真正”理解它,这些笔记将继续更新和改进。非常感谢 JeremyRachel 给了我这个学习的机会。

第 3 课

学生们制作的有用材料:

我们接下来要做什么:

回顾[08:24]:

Kaggle CLI:如何下载数据 1:

Kaggle CLI是从 Kaggle 下载时使用的好工具。因为它是通过屏幕抓取从 Kaggle 网站下载数据,当网站更改时会中断。当发生这种情况时,运行pip install kaggle-cli --upgrade

然后您可以运行:

$ kg download -u <username> -p <password> -c <competition>

用您的凭据替换<username><password><competition>是 URL 中/c/后面的内容。例如,如果您想从https://www.kaggle.com**/c/**dog-breed-identification下载狗品种数据,命令将如下所示:

$ kg download -u john.doe -p mypassword -c dog-breed-identification

确保您已经从计算机上点击了下载按钮并接受了规则:

CurWget(Chrome 扩展程序):如何下载数据 2:

快速狗与猫[13:39]

from fastai.conv_learner import * 
PATH = 'data/dogscats/'
sz=224; bs=64

通常笔记本假设您的数据在data文件夹中。但也许您想把它们放在其他地方。在这种情况下,您可以使用符号链接(简称 symlink):

以下是一个端到端的过程,用于获得狗与猫的最新结果:

快速狗与猫

稍微进一步的分析:

data = ImageClassifierData.from_paths(PATH, tfms= tfms, bs=bs, test_name='test')
  • from_paths:表示子文件夹名称是标签。如果您的train文件夹或valid文件夹有不同的名称,您可以发送trn_nameval_name参数。

  • test_name:如果您想提交到 Kaggle 竞赛,您需要填写测试集所在文件夹的名称。

learn = ConvLearner.pretrained(resnet50, data)
  • 请注意,我们没有设置pre_compue=True。这只是一个快捷方式,可以缓存一些中间步骤,这些步骤不必每次重新计算。如果您对此感到困惑,可以将其留空。

  • 请记住,当pre_compute=True时,数据增强不起作用。

learn.unfreeze() 
learn.bn_freeze(True) 
%time learn.fit([1e-5, 1e-4,1e-2], 1, cycle_len=1)
  • bn_freeze:如果您正在使用更大更深的模型,如 ResNet50 或 ResNext101(任何数字大于 34 的模型),在一个与 ImageNet 非常相似的数据集上(即侧面拍摄的标准物体的照片,其大小与 ImageNet 在 200-500 像素之间),您应该添加这一行。我们将在课程的后半部分学到更多,但这会导致批量归一化移动平均值不会被更新。

如何使用其他库 — Keras [20:02]

了解如何使用 Fast.ai 以外的库是很重要的。Keras 是一个很好的例子,因为就像 Fast.ai 建立在 PyTorch 之上一样,它也建立在各种库之上,如 TensorFlow、MXNet、CNTK 等。

如果您想运行笔记本,运行pip install tensorflow-gpu keras

  1. 定义数据生成器
train_data_dir = f'{PATH}train' 
validation_data_dir = f'{PATH}valid'
train_datagen = ImageDataGenerator(
    rescale=1. / 255,
    shear_range=0.2, 
    zoom_range=0.2, 
    horizontal_flip=True
)
test_datagen = ImageDataGenerator(rescale=1. / 255)
train_generator = train_datagen.flow_from_directory(
    train_data_dir,
    target_size=(sz, sz),
    batch_size=batch_size, 
    class_mode='binary'
)
validation_generator = test_datagen.flow_from_directory(
    validation_data_dir,
    shuffle=False,
    target_size=(sz, sz),
    batch_size=batch_size, 
    class_mode='binary'
)
  • 训练文件夹和验证文件夹的子文件夹与标签名称的想法是常见的,Keras 也这样做。

  • Keras 需要更多的代码和更多的参数来设置。

  • 与创建单个数据对象不同,在 Keras 中,您定义DataGenerator并指定要进行的数据增强类型,还要指定要进行的规范化类型。换句话说,在 Fast.ai 中,我们可以说“ResNet50 需要什么,就请为我做”,但在 Keras 中,您需要知道期望的是什么。没有标准的增强集。

  • 然后您必须创建一个验证数据生成器,您负责创建一个没有数据增强的生成器。您还必须告诉它不要对验证数据集进行洗牌,否则您无法跟踪您的表现如何。

2. 创建模型

base_model = ResNet50(weights='imagenet', include_top=False)
x = base_model.output
x = GlobalAveragePooling2D()(x)
x = Dense(1024, activation='relu')(x)
predictions = Dense(1, activation='sigmoid')(x)
  • Jeremy 在 Quick Dogs and Cats 中使用 ResNet50 的原因是因为 Keras 没有 ResNet34。我们想要进行苹果对苹果的比较。

  • 您不能要求它构建适合特定数据集的模型,因此您必须手动完成。

  • 首先创建一个基本模型,然后构建您想要添加到其顶部的层。

3. 冻结层并编译

model = Model(inputs=base_model.input, outputs=predictions)
for layer in base_model.layers: 
    layer.trainable = Falsemodel.compile(
        optimizer='rmsprop', 
        loss='binary_crossentropy', 
        metrics=['accuracy']
    )
  • 通过循环层并手动调用layer.trainable=False来冻结它们

  • 您需要编译一个模型

  • 传递优化器、损失和指标的类型

4. 拟合

model.fit_generator(
    train_generator, 
    train_generator.n//batch_size,
    epochs=3, 
    workers=4, 
    validation_data=validation_generator,
    validation_steps=validation_generator.n // batch_size
)
  • Keras 希望知道每个 epoch 有多少批次。

  • workers:要使用的处理器数量

5. 微调:解冻一些层,编译,然后再次拟合

split_at = 140
for layer in model.layers[:split_at]: 
    layer.trainable = False
for layer in model.layers[split_at:]: 
    layer.trainable = True
model.compile(
    optimizer='rmsprop', 
    loss='binary_crossentropy',
    metrics=['accuracy']
)
%%time model.fit_generator(
    train_generator, 
    train_generator.n // batch_size, 
    epochs=1, 
    workers=3,
    validation_data=validation_generator,
    validation_steps=validation_generator.n // batch_size
)

Pytorch — 如果您想要部署到移动设备,PyTorch 仍处于早期阶段。

Tensorflow — 如果您想将在本课程中学到的内容转换为更多的 Keras 工作,但这需要更多的工作,很难获得相同水平的结果。也许将来会有 TensorFlow 兼容版本的 Fast.ai。我们将看到。

为 Kaggle 创建提交文件[32:45]

要创建提交文件,我们需要两个信息:

  • data.classes:包含所有不同的类

  • data.test_ds.fnames:测试文件名

log_preds, y = learn.TTA(is_test=True)
probs = np.exp(log_preds)

始终使用TTA是一个好主意:

  • is_test=True:它将为您提供测试集的预测,而不是验证集

  • 默认情况下,PyTorch 模型将返回预测的对数,因此您需要执行np.exp(log_preds)以获得概率。

ds = pd.DataFrame(probs)
ds.columns = data.classes
  • 创建 Pandas DataFrame

  • 将列名设置为data.classes

ds.insert(0, 'id', [o[5:-4] for o in data.test_ds.fnames])
  • 在位置零插入一个名为id的新列。删除前 5 个和最后 4 个字母,因为我们只需要 ID(文件名看起来像test/0042d6bf3e5f3700865886db32689436.jpg
ds.head()

SUBM = f'{PATH}sub/' 
os.makedirs(SUBM, exist_ok=True) 
ds.to_csv(f'{SUBM}subm.gz', compression='gzip', index=False)
  • 现在您可以调用ds.to_csv创建一个 CSV 文件,compression='gzip'将在服务器上对其进行压缩。
FileLink(f'{SUBM}subm.gz')
  • 您可以使用 Kaggle CLI 直接从服务器提交,或者您可以使用FileLink,它将为您提供一个链接,从服务器下载文件到您的计算机。

单个预测[39:32]

如果我们想通过模型运行单个图像以获得预测,会怎样?

fn = data.val_ds.fnames[0]; fn
'''
'train/001513dfcb2ffafc82cccf4d8bbaba97.jpg'
'''
Image.open(PATH + fn)

  • 我们将从验证集中选择第一个文件。

这是获得预测的最简单方法:

trn_tfms, val_tfms = tfms_from_model(arch, sz)
im = val_tfms(Image.open(PATH+fn))

preds = learn.predict_array(im[None])
np.argmax(preds)
  • 图像必须被转换。tfms_from_model返回训练转换和验证转换。在这种情况下,我们将使用验证转换。

  • 传递给模型或从模型返回的所有内容通常被假定为在一个小批次中。这里我们只有一张图片,但我们必须将其转换为一批包含一张图片的小批次。换句话说,我们需要创建一个张量,不仅是[行,列,通道],而是[图片数量,行,列,通道]

  • im[None]:Numpy 技巧,将额外的单位轴添加到开头。

理论:卷积神经网络背后实际发生了什么[42:17]

  • 我们在第 1 课中看到了一点理论 — setosa.io/ev/image-kernels/

  • 卷积是一种操作,其中我们有一个小矩阵(在深度学习中几乎总是 3x3),将该矩阵的每个元素与图像的 3x3 部分的每个元素相乘,然后将它们全部加在一起,以获得在一个点上的卷积结果。

Otavio 的出色可视化(他创建了 Word Lens):

youtu.be/Oqm9vsf_hvU

Jeremy 的可视化: 电子表格 [49:51]

我使用office.live.com/start/Excel.aspx

  • 这些数据来自 MNIST

  • 激活: 通过对输入中的一些数字应用某种线性操作来计算的数字。

  • 修正线性单元(ReLU):丢弃负数 — 即 MAX(0, x)

  • 滤波器/卷积核: 用于卷积的 3D 张量的 3x3 切片

  • 张量: 多维数组或矩阵 隐藏层 既不是输入也不是输出的层

  • 最大池化: (2,2)最大池化将在高度和宽度上减半 — 将其视为一个摘要

  • 全连接层: 为每个激活赋予权重并计算总乘积。权重矩阵与整个输入一样大。

  • 注意:在最大池化层之后可以做许多事情。其中之一是在整个大小上再做一次最大池化。在旧的架构或结构化数据中,我们会做全连接层。大量使用全连接层的架构容易过拟合且速度较慢。ResNet 和 ResNext 不使用非常大的全连接层。

问题:如果输入有 3 个通道会发生什么?[1:05:30] 它将看起来类似于具有 2 个通道的 Conv1 层 — 因此,滤波器每个滤波器有 2 个通道。预训练的 ImageNet 模型使用 3 个通道。当你的通道少于 3 个时,你可以使用一些技术,例如复制一个通道使其变为 3 个,或者如果你有 2 个通道,那么取平均值并将其视为第三个通道。如果你有 4 个通道,你可以向卷积核添加额外的级别,所有值都为零。

接下来会发生什么?[1:08:47]

我们已经走到了全连接层(它执行经典的矩阵乘积)。在 Excel 表中,有一个激活。如果我们想要查看输入是哪一个十位数,我们实际上想要计算 10 个数字。

让我们看一个例子,我们试图预测一张图片是猫、狗、飞机、鱼还是建筑物。我们的目标是:

  1. 从全连接层获取输出(没有 ReLU,因此可能有负数)

  2. 计算 5 个数字,每个数字都在 0 和 1 之间,它们加起来等于 1。

为此,我们需要一种不同类型的激活函数(应用于激活的函数)。

为什么我们需要非线性?如果堆叠多个线性层,它仍然只是一个线性层。通过添加非线性层,我们可以拟合任意复杂的形状。我们使用的非线性激活函数是 ReLU。

Softmax [01:14:08]

Softmax 只会出现在最后一层。它输出介于 0 和 1 之间的数字,它们加起来为 1。理论上,这并不是绝对必要的 - 我们可以要求我们的神经网络学习一组核,这些核给出的概率尽可能接近我们想要的。一般来说,在深度学习中,如果你可以构建你的架构,使得所需的特征尽可能容易表达,你将得到更好的模型(学习更快,参数更少)。

  1. 通过e^x去除负数,因为我们不能有负概率。它也突出了值的差异(2.85:4.08 → 17.25:59.03)

所有你需要熟悉的数学来进行深度学习:

  1. 然后我们将exp列(182.75)相加,然后将e^x除以总和。结果总是正的,因为我们将正数除以正数。每个数字将在 0 和 1 之间,总和为 1。

问题:如果我们想要将图片分类为猫和狗,我们应该使用什么样的激活函数?这正好是我们现在要做的事情。我们可能想这样做的一个原因是进行多标签分类。

星球竞赛[01:20:54]

笔记本 / Kaggle 页面

我绝对建议你拟人化你的激活函数。它们有个性。[1:22:21]

Softmax 不喜欢预测多个事物。它想要选择一个事物。

Fast.ai 库会在有多个标签时自动切换到多标签模式。所以你不需要做任何事情。但是这是幕后发生的事情:

from planet import f2

metrics=[f2]
f_model = resnet34label_csv = f'**{PATH}**train_v2.csv'
n = len(list(open(label_csv)))-1
val_idxs = get_cv_idxs(n)
def get_data(sz):
    tfms = tfms_from_model(
        f_model, sz,
        aug_tfms=transforms_top_down, 
        max_zoom=1.05
    ) 
    return ImageClassifierData.from_csv(
        PATH, 
        'train-jpg',
        label_csv, 
        tfms=tfms, 
        suffix='.jpg',
        val_idxs=val_idxs, 
        test_name='test-jpg'
    )
data = get_data(256)
  • 使用 Keras 风格的方法无法进行多标签分类,其中子文件夹是标签的名称。所以我们使用from_csv

  • transform_top_down:它不仅仅是垂直翻转。对于一个正方形,有 8 种可能的对称性 - 它可以通过 0、90、180、270 度旋转,对于每一个,它可以被翻转(八面体群)。

x,y = next(iter(data.val_dl))
  • 我们已经看到了data.val_dstest_dstrain_dsds:数据集),你可以通过data.train_ds[0]来获取单个图像,例如。

  • dl是一个数据加载器,它会给你一个小批量,特别是转换后的小批量。使用数据加载器,你不能要求一个特定的小批量;你只能得到next小批量。在 Python 中,它被称为“生成器”或“迭代器”。PyTorch 真正利用了现代 Python 方法。

如果你很了解 Python,PyTorch 会非常自然。如果你不太了解 Python,PyTorch 是学习 Python 的一个很好的理由。

  • x:一批图像,y:一批标签。

如果你不确定一个函数需要什么参数,按下shift+tab

list(zip(data.classes, y[0]))

'''
[('agriculture', 1.0),
 ('artisinal_mine', 0.0),
 ('bare_ground', 0.0),
 ('blooming', 0.0),
 ('blow_down', 0.0),
 ('clear', 1.0),
 ('cloudy', 0.0),
 ('conventional_mine', 0.0),
 ('cultivation', 0.0),
 ('habitation', 0.0),
 ('haze', 0.0),
 ('partly_cloudy', 0.0),
 ('primary', 1.0),
 ('road', 0.0),
 ('selective_logging', 0.0),
 ('slash_burn', 1.0),
 ('water', 1.0)]
'''

在幕后,PyTorch 和 fast.ai 将我们的标签转换为独热编码标签。如果实际标签是狗,它看起来像:

我们取actualssoftmax之间的差异,将它们相加以表示有多少错误(即损失函数)[1:31:02]。

独热编码对于存储来说非常低效,所以我们将存储一个索引值(单个整数)而不是目标值(y)的 0 和 1。如果您查看狗品种竞赛的y值,您实际上不会看到一个大的 1 和 0 的列表,而是会看到一个单个整数。在内部,PyTorch 将索引转换为独热编码向量(即使您永远不会看到它)。PyTorch 有不同的损失函数,适用于独热编码和其他不是独热编码的情况,但这些细节被 fast.ai 库隐藏,因此您不必担心。但要意识到的很酷的事情是,我们对单标签分类和多标签分类都做了完全相同的事情。

问题:改变 softmax 的对数基数有意义吗?[01:32:55] 不,改变基数只是一个线性缩放,神经网络可以轻松学习:

plt.imshow(data.val_ds.denorm(to_np(x))[0]*1.4);

  • *1.4:图像被冲洗了,所以让它更明显(“稍微提亮”)。图像只是数字矩阵,所以我们可以做这样的事情。

  • 尝试这样的图像是很好的,因为这些图像根本不像 ImageNet。你所做的绝大多数涉及卷积神经网络的事情实际上都不像 ImageNet(医学成像,分类不同种类的钢管,卫星图像等)

sz=64
data = get_data(sz)
data = data.resize(int(sz*1.3), 'tmp')
  • 我们不会在猫狗竞赛中使用sz=64,因为我们从预训练的 ImageNet 网络开始,它几乎完美。如果我们用 64x64 的图像重新训练整个集合,我们会破坏已经非常好的权重。请记住,大多数 ImageNet 模型是用 224x224 或 299x299 的图像训练的。

  • ImageNet 中没有像上面那样的图像。而且只有前几层对我们有用。所以从较小的图像开始在这种情况下效果很好。

learn = ConvLearner.pretrained(f_model, data, metrics=metrics)
lrf=learn.lr_find() 
learn.sched.plot()

lr = 0.2
learn.fit(lr, 3, cycle_len=1, cycle_mult=2)
'''
[ 0\.       0.14882  0.13552  0.87878]                        
[ 1\.       0.14237  0.13048  0.88251]                        
[ 2\.       0.13675  0.12779  0.88796]                        
[ 3\.       0.13528  0.12834  0.88419]                        
[ 4\.       0.13428  0.12581  0.88879]                        
[ 5\.       0.13237  0.12361  0.89141]                        
[ 6\.       0.13179  0.12472  0.8896 ]
'''
lrs = np.array(**[lr/9, lr/3, lr]**)learn.unfreeze()
learn.fit(lrs, 3, cycle_len=1, cycle_mult=2)
'''
[ 0\.       0.12534  0.10926  0.90892]                        
[ 1\.       0.12035  0.10086  0.91635]                        
[ 2\.       0.11001  0.09792  0.91894]                        
[ 3\.       0.1144   0.09972  0.91748]                        
[ 4\.       0.11055  0.09617  0.92016]                        
[ 5\.       0.10348  0.0935   0.92267]                        
[ 6\.       0.10502  0.09345  0.92281]
'''

  • [lr/9, lr/3, lr] — 这是因为这些图像不像 ImageNet 图像,而且较早的层可能与它们需要的不太接近。
learn.sched.plot_loss()

sz = 128
learn.set_data(get_data(sz))
learn.freeze()
learn.fit(lr, 3, cycle_len=1, cycle_mult=2)
'''
[ 0\.       0.09729  0.09375  0.91885]                         
[ 1\.       0.10118  0.09243  0.92075]                         
[ 2\.       0.09805  0.09143  0.92235]                         
[ 3\.       0.09834  0.09134  0.92263]                         
[ 4\.       0.096    0.09046  0.9231 ]                         
[ 5\.       0.09584  0.09035  0.92403]                         
[ 6\.       0.09262  0.09059  0.92358]
'''
learn.unfreeze()
learn.fit(lrs, 3, cycle_len=1, cycle_mult=2)
learn.save(f'{sz}')
'''
[ 0\.       0.09623  0.08693  0.92696]                         
[ 1\.       0.09371  0.08621  0.92887]                         
[ 2\.       0.08919  0.08296  0.93113]                         
[ 3\.       0.09221  0.08579  0.92709]                         
[ 4\.       0.08994  0.08575  0.92862]                         
[ 5\.       0.08729  0.08248  0.93108]                         
[ 6\.       0.08218  0.08315  0.92971]
'''
sz = 256
learn.set_data(get_data(sz))
learn.freeze()
learn.fit(lr, 3, cycle_len=1, cycle_mult=2)
'''
[ 0\.       0.09161  0.08651  0.92712]                         
[ 1\.       0.08933  0.08665  0.92677]                         
[ 2\.       0.09125  0.08584  0.92719]                         
[ 3\.       0.08732  0.08532  0.92812]                         
[ 4\.       0.08736  0.08479  0.92854]                         
[ 5\.       0.08807  0.08471  0.92835]                         
[ 6\.       0.08942  0.08448  0.9289 ]
'''
learn.unfreeze()
learn.fit(lrs, 3, cycle_len=1, cycle_mult=2)
learn.save(f'{sz}')
'''
[ 0\.       0.08932  0.08218  0.9324 ]                         
[ 1\.       0.08654  0.08195  0.93313]                         
[ 2\.       0.08468  0.08024  0.93391]                         
[ 3\.       0.08596  0.08141  0.93287]                         
[ 4\.       0.08211  0.08152  0.93401]                         
[ 5\.       0.07971  0.08001  0.93377]                         
[ 6\.       0.07928  0.0792   0.93554]
'''
log_preds,y = learn.TTA()
preds = np.mean(np.exp(log_preds),0)
f2(preds,y)
'''
0.93626519738612801
'''

有几个人问了这个问题[01:38:46]:

data = data.resize(int(sz*1.3), 'tmp')

当我们指定要应用的转换时,我们发送一个大小:

tfms = tfms_from_model(
    f_model, sz,
    aug_tfms=transforms_top_down, 
    max_zoom=1.05
)

数据加载器的一项工作是按需调整图像的大小。这与data.resize无关。如果初始图像是 1000x1000,读取该 JPEG 并将其调整为 64x64 比训练卷积网络需要更多时间。data.resize告诉它我们不会使用大于sz*1.3的图像,因此请通过一次并创建新的这个大小的 JPEG。由于图像是矩形的,因此最小边为sz*1.3的新 JPEG(中心裁剪)。这将节省您大量时间。

metrics=[f2]

我们在这个笔记本中使用F-beta而不是accuacy,这是一种权衡假阴性和假阳性的方法。我们使用它的原因是因为这个特定的 Kaggle 竞赛想要使用它。查看planet.py看看如何创建自己的指标函数。这是最后打印出来的内容[ 0\. 0.08932 0.08218 **0.9324** ]

多标签分类的激活函数[01:44:25]

多标签分类的激活函数称为sigmoid

问题:为什么我们不从不同的学习率开始训练,而是只训练最后的层?[01:50:30]

您可以跳过训练最后一层,直接进行不同的学习率,但您可能不想这样做。卷积层都包含预训练权重,因此它们不是随机的 — 对于接近 ImageNet 的东西,它们非常好;对于不接近 ImageNet 的东西,它们比没有好。然而,我们所有的全连接层都是完全随机的。因此,您始终希望通过先训练它们使全连接权重比随机更好一些。否则,如果直接解冻,那么您实际上将在后续层仍然是随机的情况下摆弄那些早期层的权重 — 这可能不是您想要的。

问题:当您使用不同的学习率时,这三个学习率是否均匀分布在各层之间?[01:55:35]我们将在课程后面更多地讨论这个问题,但是在 fast.ai 库中,有一个“层组”的概念。在像 ResNet50 这样的模型中,有数百个层,您可能不想编写数百个学习率,因此库为您决定如何分割它们,最后一个始终指的是我们随机初始化并添加的全连接层。

可视化层[01:56:42]

learn.summary()
'''
[('Conv2d-1',
  OrderedDict([('input_shape', [-1, 3, 64, 64]),
               ('output_shape', [-1, 64, 32, 32]),
               ('trainable', False),
               ('nb_params', 9408)])),
 ('BatchNorm2d-2',
  OrderedDict([('input_shape', [-1, 64, 32, 32]),
               ('output_shape', [-1, 64, 32, 32]),
               ('trainable', False),
               ('nb_params', 128)])),
 ('ReLU-3',
  OrderedDict([('input_shape', [-1, 64, 32, 32]),
               ('output_shape', [-1, 64, 32, 32]),
               ('nb_params', 0)])),
 ('MaxPool2d-4',
  OrderedDict([('input_shape', [-1, 64, 32, 32]),
               ('output_shape', [-1, 64, 16, 16]),
               ('nb_params', 0)])),
 ('Conv2d-5',
  OrderedDict([('input_shape', [-1, 64, 16, 16]),
               ('output_shape', [-1, 64, 16, 16]),
               ('trainable', False),
               ('nb_params', 36864)]))
 ...
'''
  • ‘input_shape’, [-1, **3, 64, 64**] — PyTorch 在图像尺寸之前列出通道。当按照这个顺序进行 GPU 计算时,一些计算会更快。这是通过转换步骤在幕后完成的。

  • -1:表示批量大小有多大。Keras 使用None

  • ‘output_shape’, [-1, 64, 32, 32] — 64 是卷积核的数量

问题:对于一个非常小的数据集,学习率查找器返回了奇怪的数字,绘图为空[01:58:57] — 学习率查找器将逐个小批量进行。如果您有一个微小的数据集,那么就没有足够的小批量。因此,诀窍是将批量大小设置得非常小,如 4 或 8。

结构化数据[01:59:48]

在机器学习中我们使用两种类型的数据集:

  • 非结构化 — 音频、图像、自然语言文本,其中对象内的所有内容都是同一种类型的东西 — 像素、波形振幅或单词。

  • 结构化 — 损益表,关于 Facebook 用户的信息,其中每列在结构上都非常不同。 “结构化”指的是列式数据,就像您在数据库或电子表格中找到的那样,不同的列代表不同类型的事物,每行代表一个观察。

结构化数据在学术界经常被忽视,因为如果您有更好的物流模型,很难在高端会议论文中发表。但这是让世界运转的东西,让每个人都赚钱和提高效率。我们不会忽视它,因为我们正在进行实际的深度学习,Kaggle 也不会,因为人们在 Kaggle 上提供奖金来解决现实世界的问题:

Rossmann Store Sale [02:02:42]

笔记本

from fastai.structured import *
from fastai.column_data import *
np.set_printoptions(threshold=50, edgeitems=20)

PATH='data/rossmann/'
  • fastai.structured — 不是特定于 PyTorch 的,也在机器学习课程中使用,使用随机森林而没有 PyTorch。它可以独立使用,而无需使用 Fast.ai 库的其他部分。

  • fastai.column_data — 允许我们使用列式结构化数据进行 Fast.ai 和 PyTorch 操作。

  • 对于结构化数据,需要大量使用 Pandas。Pandas 是在 Python 中尝试复制 R 的数据框架(如果您对 Pandas 不熟悉,这里有一本好书 — Python 数据分析,第二版

有很多数据预处理。这个笔记本包含了第三名获奖者的整个流程(分类变量的实体嵌入)。数据处理在本课程中没有涉及,但在一些机器学习课程中有详细介绍,因为特征工程非常重要。

查看 CSV 文件

table_names = [
    'train', 'store', 
    'store_states', 
    'state_names', 
    'googletrend', 
    'weather', 'test'
]
tables = [
    pd.read_csv(f'{PATH}{fname}.csv', low_memory=False) 
    for fname in table_names
]
for t in tables: display(t.head())

  • StoreType — 您经常会得到一些列包含“代码”的数据集。实际上,这个代码的含义并不重要。不要过多地了解它,先看看数据说了什么。

连接表

这是一个关系型数据集,您需要将许多表连接在一起 — 这在 Pandas 的 merge 中很容易实现:

def join_df(left, right, left_on, right_on=None, suffix='_y'):
    if right_on is None: 
        right_on = left_on

    return left.merge(
        right, 
        how='left', 
        left_on=left_on,
        right_on=right_on, 
        suffixes=("", suffix)
    )

来自 Fast.ai 库:

add_datepart(train, "Date", drop=False)
  • 取一个日期并提取出一堆列,比如“星期几”,“季度开始”,“年份的月份”等等,并将它们全部添加到数据集中。

  • 持续时间部分将计算诸如距下一个假期还有多长时间,距上一个假期已经过去多长时间等等。

joined.to_feather(f'{PATH}joined')
  • to_feather:将 Pandas 的数据框保存为“feather”格式,该格式将数据框原封不动地转储到磁盘上。因此速度非常快。厄瓜多尔杂货店竞赛有 3.5 亿条记录,因此您会关心保存需要多长时间。

下周

  • 将列分为两种类型:分类和连续。分类列将被表示为独热编码,而连续列将被直接输入到全连接层中。

  • 分类:商店 #1 和商店 #2 之间没有数值关联。同样,星期几的星期一(第 0 天)和星期二(第 1 天)也没有数值关联。

  • 连续:像到最近竞争对手的公里数这样的距离是我们以数字方式处理的一个数字。

  • ColumnarModelData

深度学习 2:第 1 部分第 4 课

原文:medium.com/@hiromi_suenaga/deep-learning-2-part-1-lesson-4-2048a26d58aa

译者:飞龙

协议:CC BY-NC-SA 4.0

来自 fast.ai 课程的个人笔记。随着我继续复习课程以“真正”理解它,这些笔记将继续更新和改进。非常感谢 JeremyRachel 给了我这个学习的机会。

第 4 课

学生的文章:

Dropout [04:59]

learn = ConvLearner.pretrained(arch, data, ps=0.5, precompute=True)
  • precompute=True:预先计算出最后一个卷积层的激活。请记住,激活是根据一些权重/参数计算出来的数字,这些权重/参数构成了卷积核/滤波器,并且它们被应用于前一层的激活或输入。
learn 
'''
Sequential(
  (0): BatchNorm1d(1024, eps=1e-05, momentum=0.1, affine=True)
  (1): Dropout(p=0.5)
  (2): Linear(in_features=1024, out_features=512)
  (3): ReLU()
  (4): BatchNorm1d(512, eps=1e-05, momentum=0.1, affine=True)
  (5): Dropout(p=0.5)
  (6): Linear(in_features=512, out_features=120)
  (7): LogSoftmax()
)
'''

learn — 这将显示我们在末尾添加的层。这些是我们在precompute=True时训练的层

(0), (4): BatchNorm将在最后一课中讨论

(1), (5): Dropout

(2):Linear层简单地意味着矩阵相乘。这是一个具有 1024 行和 512 列的矩阵,因此它将接收 1024 个激活并输出 512 个激活。

(3):ReLU — 只是用零替换负数

(6): Linear — 第二个线性层,将前一个线性层的 512 个激活通过一个新的矩阵相乘 512 乘以 120,并输出 120 个激活

(7): Softmax — 返回总和为 1 的数字的激活函数,每个数字都在 0 到 1 之间:

出于微小的数值精度原因,事实证明取 softmax 的对数比直接取 softmax 要好[15:03]。这就是为什么当我们从模型中获取预测时,我们必须执行np.exp(log_preds)

什么是Dropout和什么是p?[08:17]

Dropout(p=0.5)

如果我们对Conv2层应用了p=0.5的 dropout,它看起来像上面那样。我们遍历,选择一个激活,并以 50%的概率删除它。因此,p=0.5是删除该单元的概率。输出实际上并没有太大变化,只是有点。

在一个层中随机丢弃一半的激活具有有趣的效果。需要注意的一点是,对于每个小批量,我们在该层中随机丢弃不同的一半激活。这迫使它不会过拟合。换句话说,当一个特定的激活被删除时,模型必须尝试找到一个表示,即使在每次随机丢弃一半激活时,它仍然有效。

这对于使现代深度学习工作并几乎解决泛化问题至关重要。Geoffrey Hinton 和他的同事们提出了这个想法,受到了大脑工作方式的启发。

  • p=0.01将丢弃 1%的激活。这几乎不会改变事情,也不会防止过拟合(不具有泛化性)。

  • p=0.99将丢弃 99%的激活。不会过拟合,对泛化很好,但会降低准确性。

  • 默认情况下,第一层为0.25,第二层为0.5。如果发现过拟合,开始逐步增加-尝试将所有设置为0.5,仍然过拟合,尝试0.7等。如果欠拟合,可以尝试降低,但不太可能需要降低太多。

  • ResNet34 的参数较少,因此不会过度拟合,但对于更大的架构如 ResNet50,通常需要增加辍学率。

您是否想知道为什么验证损失在训练早期特别好?这是因为我们在对验证集进行推断(即进行预测)时关闭了辍学。我们希望使用我们能够使用的最佳模型。

问题:您是否需要做任何事情来适应丢弃激活的事实?我们不需要,但当您说p=0.5时,PyTorch 会执行两件事。它会丢弃一半的激活,并将所有已经存在的激活加倍,以使平均激活不变。

在 Fast.ai 中,您可以传递ps,这是所有添加层的p值。它不会改变预训练网络中的辍学,因为它应该已经使用了适当水平的辍学进行训练:

learn = ConvLearner.pretrained(arch, data, ps=0.5, precompute=True)

您可以通过设置ps=0.来删除辍学,但即使经过几个时期,我们开始严重过拟合(训练损失≪验证损失):

[2\.      0.3521   0.55247  0.84189]

ps=0.时,辍学层甚至不会添加到模型中:

Sequential(
  (0): BatchNorm1d(4096, eps=1e-05, momentum=0.1, affine=True)
  (1): Linear(in_features=4096, out_features=512)
  (2): ReLU()
  (3): BatchNorm1d(512, eps=1e-05, momentum=0.1, affine=True)
  (4): Linear(in_features=512, out_features=120)
  (5): LogSoftmax()
)

您可能已经注意到,它已经添加了两个Linear层。我们不必这样做。您可以设置xtra_fc参数。注意:您至少需要一个,它接受卷积层的输出(在此示例中为 4096)并将其转换为类别数(120 种狗品种):

learn = ConvLearner.pretrained(
    arch, data, 
    ps=0., 
    precompute=True, 
    xtra_fc=[]
); learn 
'''
Sequential(
  (0): BatchNorm1d(1024, eps=1e-05, momentum=0.1, affine=True)
  (1): Linear(in_features=1024, out_features=120)
  (2): LogSoftmax()
)
'''
learn = ConvLearner.pretrained(
    arch, data, 
    ps=0., 
    precompute=True, 
    xtra_fc=[700, 300]
); learn
'''
Sequential(
  (0): BatchNorm1d(1024, eps=1e-05, momentum=0.1, affine=True)
  (1): Linear(in_features=1024, out_features=**700**)
  (2): ReLU()
  (3): BatchNorm1d(700, eps=1e-05, momentum=0.1, affine=True)
  (4): Linear(in_features=700, out_features=**300**)
  (5): ReLU()
  (6): BatchNorm1d(300, eps=1e-05, momentum=0.1, affine=True)
  (7): Linear(in_features=300, out_features=120)
  (8): LogSoftmax()
)
'''

问题:有没有一种特定的方法可以确定是否过拟合?是的,您可以看到训练损失远低于验证损失。您无法确定是否过度拟合。零过拟合通常不是最佳选择。您唯一要做的就是使验证损失降低,因此您需要尝试一些方法,看看什么可以使验证损失降低。随着时间的推移,您会对您的特定问题过度拟合的程度有所了解。

问题:为什么平均激活很重要?如果我们只删除了一半的激活,那么接下来以它们作为输入的激活也会减半,以及之后的所有激活。例如,如果这大于 0.6,则毛茸茸的耳朵是毛茸茸的,现在只有大于 0.3 才是毛茸茸的-这改变了含义。这里的目标是删除激活而不改变含义。

问题:我们可以通过层设置不同的辍学率吗?是的,这就是为什么称为ps

learn = ConvLearner.pretrained(
    arch, data, 
    ps=[0., 0.2],
    precompute=True, 
    xtra_fc=[512]
); learn
'''
Sequential(
  (0): BatchNorm1d(4096, eps=1e-05, momentum=0.1, affine=True)
  (1): Linear(in_features=4096, out_features=512)
  (2): ReLU()
  (3): BatchNorm1d(512, eps=1e-05, momentum=0.1, affine=True)
  (4): Dropout(p=0.2)
  (5): Linear(in_features=512, out_features=120)
  (6): LogSoftmax()
)
'''
  • 目前还没有关于早期或后期层应该具有不同辍学率的经验法则。

  • 如果有疑问,对每个全连接层使用相同的辍学率。

  • 通常人们只在最后一个线性层上放置辍学。

问题:为什么监控损失而不是准确率?损失是我们可以看到验证集和训练集的唯一东西。正如我们后来了解的那样,损失是我们实际优化的东西,因此更容易监控和理解其含义。

问题:在添加辍学率后,我们需要调整学习率吗?似乎不会对学习率产生足够的影响以引起注意。理论上可能会,但不足以影响我们。

结构化和时间序列数据

笔记本 / Kaggle

有两种类型的列:

  • 分类——它有一定数量的“级别”,例如 StoreType、Assortment

  • 连续型——它有一个数字,该数字的差异或比率具有某种含义,例如 CompetitionDistance

cat_vars = ['Store', 'DayOfWeek', 'Year', 'Month', 'Day',
            'StateHoliday', 'CompetitionMonthsOpen', 'Promo2Weeks',
            'StoreType', 'Assortment', 'PromoInterval', 
            'CompetitionOpenSinceYear', 'Promo2SinceYear', 'State',
            'Week', 'Events', 'Promo_fw', 'Promo_bw', 
            'StateHoliday_fw', 'StateHoliday_bw', 
            'SchoolHoliday_fw', 'SchoolHoliday_bw']
contin_vars = ['CompetitionDistance', 'Max_TemperatureC', 
               'Mean_TemperatureC', 'Min_TemperatureC', 
               'Max_Humidity', 'Mean_Humidity', 'Min_Humidity', 
               'Max_Wind_SpeedKm_h', 'Mean_Wind_SpeedKm_h', 
               'CloudCover', 'trend', 'trend_DE', 
               'AfterStateHoliday', 'BeforeStateHoliday', 'Promo', 
               'SchoolHoliday']
n = len(joined); n
  • YearMonth这样的数字,尽管我们可以将它们视为连续的,但我们不必这样做。如果我们决定将Year作为分类变量,我们告诉我们的神经网络,对于每个不同的Year“级别”(2000、2001、2002),你可以完全不同地对待它;而如果我们说它是连续的,它必须提出某种平滑函数来拟合它们。因此,通常实际上是连续的但没有许多不同级别的事物(例如YearDayOfWeek),通常最好将它们视为分类变量。

  • 选择分类变量还是连续变量是您要做的建模决策。总之,如果数据中是分类的,那么它必须是分类的。如果数据中是连续的,您可以选择在模型中将其视为连续或分类。

  • 通常,浮点数很难转换为分类变量,因为有许多级别(我们称级别数为“基数”——例如,星期几变量的基数为 7)。

问题:您是否对连续变量进行分箱?Jeremy 不对变量进行分箱,但我们可以对最高温度等进行分组,例如 0-10,10-20,20-30,并将其视为分类变量。有趣的是,上周刚刚发表了一篇论文,其中一组研究人员发现有时分箱可能有所帮助。

问题:如果将年份用作类别,当模型遇到以前从未见过的年份时会发生什么?我们会解决这个问题,但简短的答案是它将被视为未知类别。Pandas 有一个特殊的未知类别,如果它看到以前未见过的类别,它将被视为未知。

for v in cat_vars: 
    joined[v] = joined[v].astype('category').cat.as_ordered()
for v in contin_vars:
    joined[v] = joined[v].astype('float32')
dep = 'Sales'
joined = joined[cat_vars+contin_vars+[dep, 'Date']].copy()
  • 循环遍历cat_vars并将适用的数据框列转换为分类列。

  • 循环遍历contin_vars并将它们设置为float32(32 位浮点数),因为这是 PyTorch 所期望的。

从一个小样本开始

idxs = get_cv_idxs(n, val_pct=150000/n) 
joined_samp = joined.iloc[idxs].set_index("Date") 
samp_size = len(joined_samp); samp_size

这是我们的数据样子。尽管我们将一些列设置为“category”(例如‘StoreType’,‘Year’),但 Pandas 在笔记本中仍然显示为字符串。

df, y, nas, mapper = proc_df(joined_samp, 'Sales', do_scale=True)
yl = np.log(y)

proc_df(处理数据框)——Fast.ai 中的一个函数,执行以下几项操作:

  1. 将因变量提取出来,放入一个单独的变量中,并从原始数据框中删除它。换句话说,df没有Sales列,而y只包含Sales列。

  2. do_scale:神经网络非常喜欢输入数据的均值大约为零,标准差大约为 1。因此,我们取出数据,减去均值,除以标准差以实现这一点。它返回一个特殊对象,用于跟踪用于归一化的均值和标准差,以便稍后对测试集执行相同操作(mapper)。

  3. 它还处理缺失值——对于分类变量,它变为 ID:0,其他类别变为 1、2、3 等。对于连续变量,它用中位数替换缺失值,并创建一个新的布尔列,指示是否缺失。

处理后,例如 2014 年变成 2,因为分类变量已被替换为从零开始的连续整数。原因是,我们稍后要将它们放入矩阵中,我们不希望矩阵在可以只有两行时却有 2014 行。

现在我们有一个不包含因变量且所有内容都是数字的数据框。这就是我们需要进行深度学习的地方。查看机器学习课程以获取更多详细信息。机器学习课程中涵盖的另一件事是验证集。在这种情况下,我们需要预测接下来两周的销售额,因此我们应该创建一个验证集,即我们训练集的最后两周:

val_idx = np.flatnonzero((df.index<=datetime.datetime(2014,9,17)) &
              (df.index>=datetime.datetime(2014,8,1)))

让我们直接进入深度学习行动[39:48]

对于任何 Kaggle 竞赛,重要的是您要对您的指标有一个很好的理解 - 您将如何被评判。在这个比赛中,我们将根据均方根百分比误差(RMSPE)进行评判。

def inv_y(a): 
    return np.exp(a)
def exp_rmspe(y_pred, targ):
    targ = inv_y(targ)
    pct_var = (targ - inv_y(y_pred))/targ
    return math.sqrt((pct_var**2).mean())
max_log_y = np.max(yl)
y_range = (0, max_log_y*1.2)
  • 当您对数据取对数时,得到均方根误差将实际上得到均方根百分比误差。
md = ColumnarModelData.from_data_frame(
    PATH, val_idx, df, 
    yl.astype(np.float32), 
    cat_flds=cat_vars, 
    bs=128, 
    test_df=df_test
)
  • 像往常一样,我们将首先创建一个模型数据对象,其中包含验证集、训练集和可选的测试集。从中,我们将得到一个学习器,然后我们可以选择调用lr_find,然后调用learn.fit等等。

  • 这里的区别是我们不是使用ImageClassifierData.from_csv.from_paths,我们需要一种称为ColumnarModelData的不同类型的模型数据,并调用from_data_frame

  • PATH:指定存储模型文件等的位置

  • val_idx:我们要放入验证集的行的索引列表

  • df:包含自变量的数据框

  • yl:我们取proc_df返回的因变量y,并取其对数(即np.log(y)

  • cat_flds:要作为分类变量处理的列。请记住,到这个时候,一切都是数字,所以除非我们指定,否则它会将它们全部视为连续的。

现在我们有一个标准模型数据对象,我们熟悉并包含train_dlval_dltrain_dsval_ds等。

m = md.get_learner(
    emb_szs, 
    len(df.columns)-len(cat_vars),
    0.04, 1, 
    [1000,500], 
    [0.001,0.01], 
    y_range=y_range
)
  • 在这里,我们要求它创建一个适合我们模型数据的学习器。

  • 0.04:要使用多少 dropout

  • [1000,500]:每个层中要有多少激活

  • [0.001,0.01]:在后续层中要使用多少 dropout

关键新概念:嵌入[45:39]

让我们暂时忘记分类变量:

请记住,您永远不要在最后一层中放置 ReLU,因为 softmax 需要负数来创建低概率。

全连接神经网络的简单视图[49:13]

对于回归问题(而非分类),您甚至可以跳过 softmax 层。

分类变量[50:49]

我们创建一个新的矩阵,有 7 行,以及我们选择的列数(例如 4),并用浮点数填充它。要将“Sunday”添加到我们的连续变量的秩 1 张量中,我们查找这个矩阵,它将返回 4 个浮点数,我们将它们用作“Sunday”。

最初,这些数字是随机的。但是我们可以将它们通过神经网络,并以减少损失的方式更新它们。换句话说,这个矩阵只是我们神经网络中的另一组权重。这种类型的矩阵被称为“嵌入矩阵”。嵌入矩阵是一种我们从该类别的零到最大级别数之间开始的整数。我们索引到矩阵中找到特定行,并将其附加到所有连续变量,之后的一切就和以前一样(线性→ReLU→等等)。

问题:这四个数字代表什么?我们将在查看协同过滤时了解更多,但目前,它们只是我们正在学习的参数,最终给我们带来了良好的损失。我们将在后面发现,这些特定的参数通常是人类可解释的,而且相当有趣,但这是一个副作用。

问题:对于嵌入矩阵的维度有好的启发吗?我有!让我们看一看。

cat_sz = [
    (c, len(joined_samp[c].cat.categories)+1) 
    for c in cat_vars
]
cat_sz
'''
[('Store', 1116),
 ('DayOfWeek', 8),
 ('Year', 4),
 ('Month', 13),
 ('Day', 32),
 ('StateHoliday', 3),
 ('CompetitionMonthsOpen', 26),
 ('Promo2Weeks', 27),
 ('StoreType', 5),
 ('Assortment', 4),
 ('PromoInterval', 4),
 ('CompetitionOpenSinceYear', 24),
 ('Promo2SinceYear', 9),
 ('State', 13),
 ('Week', 53),
 ('Events', 22),
 ('Promo_fw', 7),
 ('Promo_bw', 7),
 ('StateHoliday_fw', 4),
 ('StateHoliday_bw', 4),
 ('SchoolHoliday_fw', 9),
 ('SchoolHoliday_bw', 9)]
'''
  • 这里是每个分类变量及其基数的列表。

  • 即使原始数据中没有缺失值,你仍然应该留出一个未知值。

  • 确定嵌入大小的经验法则是基数大小除以 2,但不超过 50。

emb_szs = [(c, min(50, (c+1)//2)) for _,c in cat_sz]
emb_szs
'''
[(1116, 50),
 (8, 4),
 (4, 2),
 (13, 7),
 (32, 16),
 (3, 2),
 (26, 13),
 (27, 14),
 (5, 3),
 (4, 2),
 (4, 2),
 (24, 12),
 (9, 5),
 (13, 7),
 (53, 27),
 (22, 11),
 (7, 4),
 (7, 4),
 (4, 2),
 (4, 2),
 (9, 5),
 (9, 5)]
'''

然后将嵌入大小传递给学习器:

m = md.get_learner(
    emb_szs, 
    len(df.columns)-len(cat_vars), 
    0.04, 1,
    [1000,500], 
    [0.001,0.01], 
    y_range=y_range
)

问题:除了随机初始化,是否有初始化嵌入矩阵的方法?我们可能会在课程后面谈论更多关于预训练的内容,但基本思想是,如果 Rossmann 的其他人已经训练了一个神经网络来预测奶酪销售,你可能会从他们的店铺嵌入矩阵开始,以预测酒类销售。例如,Pinterest 和 Instacart 就是这样做的。Instacart 使用这种技术来为他们的购物者规划路线,Pinterest 使用它来决定在网页上显示什么。他们有产品/店铺的嵌入矩阵在组织中共享,这样人们就不必训练新的了。

问题:使用嵌入矩阵相比独热编码有什么优势?对于上面的星期几示例,我们可以轻松地传递 7 个数字(例如,星期日为[0, 1, 0, 0, 0, 0, 0])。那也是一组浮点数,完全可行——这通常是统计学中多年来使用分类变量的方式(称为“虚拟变量”)。问题是,星期日这个概念只能与一个单一的浮点数相关联。因此它具有这种线性行为——它说星期日更多或更少是一个单一的事物。通过嵌入,星期日是一个四维空间中的概念。我们通常发现的情况是,这些嵌入向量往往具有丰富的语义概念。例如,如果周末有不同的行为,你会发现周六和周日的某个特定数字更高。

通过使用高维向量而不仅仅是一个单一数字,深度学习网络有机会学习这些丰富的表示。

嵌入的概念被称为“分布式表示”——神经网络的最基本概念。这是神经网络中的一个概念,它具有一个高维表示,很难解释。这个向量中的数字甚至不必只有一个含义。如果这个数字低,那个数字高,它可能表示一件事,如果那个数字高,那个数字低,它可能表示另一件事,因为它经过了这个丰富的非线性函数。正是这种丰富的表示使得它能够学习这样有趣的关系。

问题:嵌入适用于某些类型的变量吗?[01:02:45] 嵌入适用于任何分类变量。唯一不能很好工作的是具有太高基数的变量。如果您有 60 万行数据,一个变量有 60 万个水平,那就不是一个有用的分类变量。但总的来说,在这个比赛中的第三名真的决定将所有不太高基数的变量都作为分类变量。一个很好的经验法则是,如果您可以将一个变量变成分类变量,最好这样做,因为这样它可以学习到丰富的分布式表示;否则,如果您将其保留为连续变量,它最多只能尝试找到一个适合它的单一函数形式。

幕后的矩阵代数[01:04:47]

通过索引查找嵌入与对一个独热编码向量和嵌入矩阵进行矩阵乘积是相同的。但这样做效率非常低,所以现代库将其实现为取一个整数并查找数组中的值。

问题:您能谈谈如何将日期和时间作为分类变量以及这如何影响季节性吗?[01:06:59] 有一个 Fast.ai 函数叫做add_datepart,它接受一个数据框和一个列名。它可以选择从数据框中删除该列,并用许多列代替,表示有关该日期的所有有用信息,如星期几、月份、年份等(基本上是 Pandas 给我们的所有信息)。

add_datepart(weather, "Date", drop=False)
add_datepart(googletrend, "Date", drop=False)
add_datepart(train, "Date", drop=False)
add_datepart(test, "Date", drop=False)

例如,现在星期几变成了一个八行四列的嵌入矩阵。从概念上讲,这使我们的模型能够创建一些有趣的时间序列模型。如果有一些东西有一个七天周期循环,星期一上升,星期三下降,但仅限于每天且仅在柏林,它完全可以做到 - 它拥有所有需要的信息。这是处理时间序列的一种奇妙方式。您只需要确保时间序列中的周期指示器存在为一列。如果没有一个名为星期几的列,神经网络学习进行模七和查找嵌入矩阵将非常困难。这并非不可能,但确实很难。如果您正在预测旧金山饮料的销售情况,您可能想要一个 AT&T 球场的球赛时间表,因为这将影响到 SoMa 地区喝啤酒的人数。因此,您需要确保基本指标或周期性存在于您的数据中,只要它们存在,神经网络就会学会使用它们。

学习者[01:10:13]

m = md.get_learner(
    emb_szs, 
    len(df.columns)-len(cat_vars), 
    0.04, 1,
    [1000,500], 
    [0.001,0.01], 
    y_range=y_range
)
lr = 1e-3
  • emb_szs:嵌入大小

  • len(df.columns)-len(cat_vars):数据框中连续变量的数量

  • 0.04:嵌入矩阵有自己的丢失率,这是丢失率

  • 1:我们想要创建多少输出(最后一个线性层的输出)

  • [1000, 500]:第一个线性层和第二个线性层中的激活数量

  • [0.001, 0.01]:第一个线性层和第二个线性层中的丢失率

  • y_range:现在我们不会担心这个

m.fit(lr, 3, metrics=[exp_rmspe])
'''
A Jupyter Widget
[ 0\.       0.02479  0.02205* *0.19309**]                          
[ 1\.       0.02044  0.01751* *0.18301**]                          
[ 2\.       0.01598  0.01571* *0.17248**]*
  • metrics:这是一个自定义指标,指定在每个时期结束时调用的函数并打印结果
m.fit(lr, 1, metrics=[exp_rmspe], cycle_len=1)
'''
[ 0\.       0.00676  0.01041  0.09711]* 

通过使用所有的训练数据,我们实现了大约 0.09711 的 RMSPE。公共排行榜和私人排行榜之间存在很大差异,但我们肯定处于比赛的前端。

因此,这是一种处理时间序列和结构化数据的技术。有趣的是,与使用这种技术的组相比(分类变量的实体嵌入),第二名的获胜者进行了更多的特征工程。这个比赛的获胜者实际上是物流销售预测方面的专家,因此他们有自己的代码来创建大量的特征。Pinterest 的人们为推荐构建了一个非常相似的模型,他们也说当他们从梯度提升机转向深度学习时,他们做了更少的特征工程,这是一个更简单的模型,需要更少的维护。因此,使用这种深度学习方法的一个重要好处是,您可以获得最先进的结果,但工作量要少得多。

问题:我们在这些中使用任何时间序列吗?间接地,是的。正如我们刚才看到的,我们的列中有一周的天数,一年的月份等,大多数被视为类别,因此我们正在构建一种分布式表示,例如一月,星期日等。我们没有使用任何经典的时间序列技术,我们所做的只是在神经网络中进行真正的全连接层。嵌入矩阵能够以比任何标准时间序列技术更丰富的方式处理一周中的周期性。

关于图像模型和这个模型之间的区别的问题:在调用get_learner的方式上有所不同。在图像处理中,我们只是做了Learner.trained并传递数据:

learn = ConvLearner.pretrained(arch, data, ps=0., precompute=True)

对于这些类型的模型,实际上对于很多模型,我们构建的模型取决于数据。在这种情况下,我们需要知道我们有哪些嵌入矩阵。因此,在这种情况下,数据对象创建了学习者(与我们之前看到的相反):

m = md.get_learner(
    emb_szs, 
    len(df.columns)-len(cat_vars), 
    0.04, 1,
    [1000,500], 
    [0.001,0.01], 
    y_range=y_range
)

步骤总结(如果你想为自己的数据集使用这个):

步骤 1。列出分类变量名称和连续变量名称,并将它们放入 Pandas 数据框中

步骤 2。创建一个列表,其中包含您想要在验证集中的行索引

步骤 3。调用这行代码:

md = ColumnarModelData.from_data_frame(
    PATH, val_idx, df, 
    yl.astype(np.float32), 
    cat_flds=cat_vars, 
    bs=128, 
    test_df=df_test
)

步骤 4。创建一个您想要每个嵌入矩阵有多大的列表

步骤 5。调用get_learner — 您可以使用这些确切的参数开始:

m = md.get_learner(
    emb_szs, 
    len(df.columns)-len(cat_vars), 
    0.04, 1,
    [1000,500], 
    [0.001,0.01], 
    y_range=y_range
)

步骤 6。调用m.fit

问题:如何为这种类型的数据使用数据增强,以及辍学是如何工作的?没有头绪。Jeremy 认为这必须是特定于领域的,但他从未见过任何论文或任何行业人士在结构化数据和深度学习中使用数据增强。他认为这是可以做到的,但还没有看到有人这样做。辍学所做的事情与以前完全相同。

问题:有什么缺点?几乎没有人在使用这个。为什么?基本上答案就像我们之前讨论的那样,几乎没有人在学术界从事这方面的工作,因为这不是人们发表论文的内容。因此,人们没有真正出色的例子可以参考,说“哦,这是一个很好的技术,让我们让我们的公司实施它”。但也许同样重要的是,直到现在有了这个 Fast.ai 库,没有任何方便的方法来做到这一点。如果你想要实现其中一个模型,你必须自己编写所有的自定义代码。有很多商业和科学机会可以利用这一点,并解决以前没有很好解决的问题。

自然语言处理

深度学习中最具潜力的领域是自然语言处理,它比计算机视觉落后了两三年。软件状态和一些概念的成熟程度远不及计算机视觉。在自然语言处理中,你会发现有一些特定的问题可以解决,它们有特定的名称。在自然语言处理中有一种特定的问题叫做“语言建模”,它有一个非常具体的定义——构建一个模型,在给定一个句子的几个单词后,你能否预测下一个单词是什么。

语言建模

笔记本

这里我们有来自 arXiv(arXiv.org)的 18 个月的论文,这是一个例子:

' '.join(md.trn_ds[0].text[:150])
'''
'<cat> csni <summ> the exploitation of mm - wave bands is one of the key - enabler for 5 g mobile \n radio networks . however , the introduction of mm - wave technologies in cellular \n networks is not straightforward due to harsh propagation conditions that limit \n the mm - wave access availability . mm - wave technologies require high - gain antenna \n systems to compensate for high path loss and limited power . as a consequence , \n directional transmissions must be used for cell discovery and synchronization \n processes : this can lead to a non - negligible access delay caused by the \n exploration of the cell area with multiple transmissions along different \n directions . \n    the integration of mm - wave technologies and conventional wireless access \n networks with the objective of speeding up the cell search process requires new \n'
'''
  • <cat> — 论文的类别。CSNI 是计算机科学和网络

  • <summ> — 论文的摘要

这是训练语言模型的输出看起来像什么。我们进行了一些简单的测试,输入一些初始文本,看模型认为接下来应该是什么:

sample_model(m, "<CAT> csni <SUMM> algorithms that")
'''
...use the same network as a single node are not able to achieve the same performance as the traditional network - based routing algorithms . in this paper , we propose a novel routing scheme for routing protocols in wireless networks . the proposed scheme is based ...
'''

它通过阅读 arXiv 论文学到,写关于计算机网络的人会这样说话。记住,它最初完全不懂英语。它最初为英语中的每个单词都有一个随机的嵌入矩阵。通过阅读大量的 arXiv 论文,它学会了哪些单词跟随其他单词。

这里我们尝试指定一个类别为计算机视觉:

sample_model(m, "<CAT> cscv <SUMM> algorithms that")
'''
...use the same data to perform image classification are increasingly being used to improve the performance of image classification algorithms . in this paper , we propose a novel method for image classification using a deep convolutional neural network ( cnn ) . the proposed method is ...
'''

它不仅学会了写英语,而且在你说完“卷积神经网络”之后,你应该使用括号来指定一个缩写“(CNN)”。

sample_model(m,"<CAT> cscv <SUMM> algorithms. <TITLE> on ")
'''
...the performance of deep learning for image classification <eos>*sample_model(m,"<CAT> csni <SUMM> algorithms. <TITLE> on ")*...the performance of wireless networks <eos>*sample_model(m,"<CAT> cscv <SUMM> algorithms. <TITLE> towards ")*...a new approach to image classification <eos>*sample_model(m,"<CAT> csni <SUMM> algorithms. <TITLE> towards ")*...a new approach to the analysis of wireless networks <eos>
'''

语言模型可以非常深奥,因此我们将尝试构建它——不是因为我们真的在乎这个,而是因为我们试图创建一个用于执行其他任务的预训练模型。例如,给定一个 IMDB 电影评论,我们将确定它们是积极的还是消极的。这很像猫和狗——一个分类问题。因此,我们真的希望使用一个至少知道如何阅读英语的预训练网络。因此,我们将训练一个模型来预测句子的下一个单词(即语言模型),就像在计算机视觉中一样,在最后添加一些新层,并要求它预测某物是积极的还是消极的。

IMDB

笔记本

我们要做的是训练一个语言模型,使其成为分类模型的预训练模型。换句话说,我们试图利用我们在计算机视觉中学到的微调技巧来创建强大的分类模型。

问题:为什么直接做你想做的事情不起作用?事实证明它并不起作用。有几个原因。首先,我们知道微调预训练网络非常强大。因此,如果我们可以让它先学习一些相关任务,然后我们可以利用所有这些信息来尝试帮助它完成第二个任务。另一个原因是 IMDB 电影评论长达数千字。因此,在阅读数千字后,不了解英语结构或单词或标点符号的情况下,你只会得到一个 1 或 0(积极或消极)。试图学习整个英语结构,然后从一个数字中了解它如何表达积极和消极情绪,这是期望太高了。

问题:这与 Karpathy 的 Char-RNN 相似吗?这与 Char-RNN 有些相似,它可以根据前几个字母预测下一个字母。语言模型通常在单词级别上工作(但不一定),我们将在本课程中专注于单词级别的建模。

问题:这些生成的单词/句子在多大程度上是实际复制了在训练集中找到的内容?单词肯定是之前见过的单词,因为它不是字符级别的,所以它只能给我们之前见过的单词。句子,有严格的方法来做,但最简单的方法是看上面的例子,你会对此有所了解。最重要的是,当我们训练语言模型时,我们将有一个验证集,以便我们尝试预测以前从未见过的东西的下一个单词。有一些技巧可以使用语言模型来生成文本,比如 beam search。

文本分类的用例:

  • 对于对冲基金,识别过去导致市场大幅下跌的文章或 Twitter 中的内容。

  • 识别客户服务查询,这些查询往往与下个月取消合同的人相关联

  • 将文档组织成是否属于法律发现的一部分。

from fastai.learner import *
import torchtext
from torchtext import vocab, data
from torchtext.datasets import language_modeling
from fastai.rnn_reg import *
from fastai.rnn_train import *
from fastai.nlp import *
from fastai.lm_rnn import *
import dill as pickle
  • torchtext — PyTorch 的 NLP 库

数据

IMDB 大型电影评论数据集

PATH = 'data/aclImdb/'
TRN_PATH = 'train/all/'
VAL_PATH = 'test/all/'
TRN = f'{PATH}{TRN_PATH}'
VAL = f'{PATH}{VAL_PATH}'
%ls {PATH}
'''
imdbEr.txt  imdb.vocab  models/  README  test/  tmp/  train/
'''

在这种情况下,我们没有单独的测试和验证。就像在视觉中一样,训练目录中有一堆文件:

trn_files = !ls {TRN}
trn_files[:10]

'''
['0_0.txt',
 '0_3.txt',
 '0_9.txt',
 '10000_0.txt',
 '10000_4.txt',
 '10000_8.txt',
 '1000_0.txt',
 '10001_0.txt',
 '10001_10.txt',
 '10001_4.txt']
'''
review = !cat {TRN}{trn_files[6]}
review[0]
'''
"I have to say when a name like Zombiegeddon and an atom bomb on the front cover I was expecting a flat out chop-socky fung-ku, but what I got instead was a comedy. So, it wasn't quite was I was expecting, but I really liked it anyway! The best scene ever was the main cop dude pulling those kids over and pulling a Bad Lieutenant on them!! I was laughing my ass off. I mean, the cops were just so bad! And when I say bad, I mean The Shield Vic Macky bad. But unlike that show I was laughing when they shot people and smoked dope.<br /><br />Felissa Rose...man, oh man. What can you say about that hottie. She was great and put those other actresses to shame. She should work more often!!!!! I also really liked the fight scene outside of the building. That was done really well. Lots of fighting and people getting their heads banged up. FUN! Last, but not least Joe Estevez and William Smith were great as the...well, I wasn't sure what they were, but they seemed to be having fun and throwing out lines. I mean, some of it didn't make sense with the rest of the flick, but who cares when you're laughing so hard! All in all the film wasn't the greatest thing since sliced bread, but I wasn't expecting that. It was a Troma flick so I figured it would totally suck. It's nice when something surprises you but not totally sucking.<br /><br />Rent it if you want to get stoned on a Friday night and laugh with your buddies. Don't rent it if you are an uptight weenie or want a zombie movie with lots of flesh eating.<br /><br />P.S. Uwe Boil was a nice touch."
'''

现在我们将检查数据集中有多少单词:

!find {TRN} -name '*.txt' | xargs cat | wc -w
'''
17486581
'''
!find {VAL} -name '*.txt' | xargs cat | wc -w
'''
5686719
'''

在我们可以对文本进行任何操作之前,我们必须将其转换为标记列表。标记基本上就像一个单词。最终我们将把它们转换成一系列数字,但第一步是将其转换成一系列单词——这在 NLP 中称为“标记化”。一个好的标记器将很好地识别句子中的片段。每个分隔的标点符号将被分开,每个多部分单词的部分将被适当地分开。Spacy 做了很多 NLP 工作,Jeremy 知道它有最好的标记器。因此,Fast.ai 库被设计为与 Spacey 标记器以及 torchtext 一起很好地工作。

创建一个字段

字段是如何预处理一些文本的定义。

TEXT = data.Field(lower=True, tokenize=spacy_tok)
  • lower=True — 将文本转换为小写

  • tokenize=spacy_tok — 使用spacy_tok进行标记化

现在我们创建通常的 Fast.ai 模型数据对象:

bs=64; bptt=70
FILES = dict(train=TRN_PATH, validation=VAL_PATH, test=VAL_PATH)
md = LanguageModelData.from_text_files(
    PATH, TEXT, **FILES, bs=bs, 
    bptt=bptt, min_freq=10
)
  • PATH:通常是数据所在的位置,保存模型等

  • TEXT:torchtext 的 Field 定义

  • **FILES:我们拥有的所有文件的列表:训练、验证和测试(为了保持简单,我们没有单独的验证和测试集,所以两者都指向验证文件夹)

  • bs:批量大小

  • bptt:通过时间反向传播。这意味着我们一次将多长的句子放在 GPU 上

  • min_freq=10:一会儿,我们将用整数(每个单词的唯一索引)替换单词。如果有任何出现次数少于 10 次的单词,就称之为未知。

构建了我们的ModelData对象之后,它会自动填充TEXT对象的一个非常重要的属性:TEXT.vocab。这是一个词汇表,它存储了文本中看到的哪些唯一单词(或标记),以及每个单词将被映射到一个唯一的整数 ID。

# 'itos': 'int-to-string' 
TEXT.vocab.itos[:12]
'''
['<unk>', '<pad>', 'the', ',', '.', 'and', 'a', 'of', 'to', 'is', 'it', 'in']
'''
# 'stoi': 'string to int'
TEXT.vocab.stoi['the']
'''
2
'''

itos按频率排序,除了前两个特殊的。使用vocab,torchtext 将为我们将单词转换为整数 ID:

md.trn_ds[0].text[:12]
'''
['i',
 'have',
 'always',
 'loved',
 'this',
 'story',
 '-',
 'the',
 'hopeful',
 'theme',
 ',',
 'the']
'''
TEXT.numericalize([md.trn_ds[0].text[:12]])
'''
Variable containing:
   12
   35
  227
  480
   13
   76
   17
    2
 7319
  769
    3
    2
[torch.cuda.LongTensor of size 12x1 (GPU 0)]
'''

问题:通常会进行任何词干处理或词形还原吗?不是很常见。一般来说,我们只需要进行分词。为了尽可能通用,我们想知道接下来会发生什么,所以无论是将来时还是过去时,还是复数还是单数,我们并不真的知道哪些事情会有趣,哪些不会,所以似乎最好尽可能保持不变。

问题:处理自然语言时,上下文不重要吗?为什么我们要对单个词进行标记化和查看?[01:46:38] 不,我们不是在查看单个词 - 它们仍然是有序的。只是因为我们用数字 12 替换了 I,它们仍然是按照那个顺序的。处理自然语言的另一种方法叫做“词袋”,它们会丢弃顺序和上下文。在机器学习课程中,我们将学习如何使用词袋表示,但我认为它们已经不再有用或即将不再有用。我们开始学习如何正确使用深度学习来使用上下文。

批次大小和 BPTT [01:47:40]

在语言模型中发生的情况是,尽管我们有很多电影评论,它们都被连接在一起成为一个大文本块。因此,我们预测这个巨大的长文本中的下一个单词,其中包含所有 IMDB 电影评论的连接。

  • 我们将连接的评论分成批次。在这种情况下,我们将其分成 64 个部分。

  • 然后我们将每个部分移动到前一个部分的下方,并对其进行转置。

  • 我们最终得到一个大小为 100 万乘以 64 的矩阵。

  • 然后我们每次抓取一小块,这些块的长度大致等于 BPTT。在这里,我们抓取一个大约 70 个字符长的部分,这是我们放入 GPU(即批次)的第一件事。

next(iter(md.trn_dl))
'''
(Variable containing:
     12    567      3  ...    2118      4   2399
     35      7     33  ...       6    148     55
    227    103    533  ...    4892     31     10
         ...            ⋱           ...         
     19   8879     33  ...      41     24    733
    552   8250     57  ...     219     57   1777
      5     19      2  ...    3099      8     48
 [torch.cuda.LongTensor of size 75x64 (GPU 0)], 
 Variable containing:
     35
      7
     33
      ⋮   
     22
   3885
  21587
 [torch.cuda.LongTensor of size 4800 (GPU 0)])
'''
  • 我们通过将数据加载器包装在iter中,然后调用next来获取我们的第一个训练批次。

  • 我们得到了一个 75 乘以 64 的张量(大约 70 行,但不完全)

  • Torchtext 做的一个巧妙的技巧是每次随机更改bptt数字,因此每个时期它都会获取略有不同的文本片段 - 类似于在计算机视觉中对图像进行洗牌。我们不能随机洗牌单词,因为它们需要按正确的顺序排列,所以我们随机移动它们的断点一点点。

  • 目标值也是 75 乘以 64,但出于一些技术原因,它被展平为一个单一向量。

问题:为什么不按句子分割?[01:53:40] 不完全是。请记住,我们使用的是列。因此,我们的每一列长度约为 100 万,因此尽管这些列并不总是完全以句号结束,但它们非常长,我们不在乎。每列包含多个句子。

关于这个问题,Jeremy 发现了在这个语言模型矩阵中的内容有一段时间让人有点费解,所以如果需要一段时间并且需要问一千个问题,不要担心。

创建一个模型 [01:55:46]

现在我们有一个可以提供批次的模型数据对象,我们可以创建一个模型。首先,我们将创建一个嵌入矩阵。

这里是:#批次;词汇表中的唯一标记数;数据集的长度;单词数

len(md.trn_dl), md.nt, len(md.trn_ds), len(md.trn_ds[0].text)
'''
(4602, 34945, 1, 20621966)
'''

这是我们的嵌入矩阵的样子:

  • 这是一个高基数分类变量,而且,这是唯一的变量 - 这在自然语言处理中很典型

  • 嵌入大小为 200,比我们以前的嵌入向量要大得多。这并不奇怪,因为一个词比“星期天”的概念要复杂得多。一般来说,一个词的嵌入大小会在 50 到 600 之间。

em_sz = 200  # size of each embedding vector
nh = 500     # number of hidden activations per layer
nl = 3       # number of layers

研究人员发现大量的动量(我们稍后会了解)与这些循环神经网络模型不太兼容,因此我们创建了一个Adam优化器的版本,其动量小于默认值0.9。每当你在做自然语言处理时,你应该包括这一行:

opt_fn = partial(optim.Adam, betas=(0.7, 0.99))

Fast.ai 使用了由 Stephen Merity 开发的最先进的AWD LSTM 语言模型的变体。这个模型的一个关键特征是通过Dropout提供了出色的正则化。目前还没有简单的方法来找到下面的 dropout 参数的最佳值 - 您只需要进行实验...

然而,其他参数(alphabetaclip)通常不需要调整。

learner = md.get_model(
    opt_fn, em_sz, nh, nl, 
    dropouti=0.05,
    dropout=0.05, 
    wdrop=0.1, 
    dropoute=0.02, 
    dropouth=0.05
)
learner.reg_fn = partial(seq2seq_reg, alpha=2, beta=1)
learner.clip=0.3
  • 在最后一堂课中,我们将学习架构是什么以及所有这些 dropout 是什么。现在,只需知道它与通常情况下相同,如果您尝试构建一个 NLP 模型并且欠拟合,那么减少所有这些 dropout,如果过拟合,那么以大致这个比例增加所有这些 dropout。由于这是一篇最近的论文,所以没有太多的指导,但这些比例效果很好 - 这也是 Stephen 一直在使用的。

  • 还有另一种我们可以避免过拟合的方法,我们将在最后一堂课上讨论。目前,learner.reg_fn = partial(seq2seq_reg, alpha=2, beta=1)可以可靠地工作,因此您所有的 NLP 模型可能都需要这一行。

  • learner.clip=0.3:当您查看梯度并将其乘以学习率以决定更新权重的量时,这将不允许它们超过 0.3。这是一个很酷的小技巧,可以防止我们迈出太大的一步。

  • 细节现在并不太重要,所以您可以按原样使用它们。

问题:有一些词嵌入,如 Word2vec 或 GloVe。它们与这个有什么不同?为什么不最初使用它们来初始化权重?人们以前已经对这些嵌入矩阵进行了预训练,以执行各种其他任务。它们不被称为预训练模型;它们只是一个预训练的嵌入矩阵,您可以下载它们。我们完全可以下载它们。我发现以这种方式构建一个完整的预训练模型似乎并没有从使用预训练词向量中受益,而使用一个完整的预训练语言模型则产生了更大的差异。也许我们可以将两者结合起来使它们变得更好。

问题:模型的架构是什么?我们将在最后一课中学习有关模型架构的知识,但现在,它是使用一种称为 LSTM(长短期记忆)的递归神经网络。

拟合

learner.fit(3e-3, 4, wds=1e-6, cycle_len=1, cycle_mult=2)
learner.save_encoder('adam1_enc')
learner.fit(
    3e-3, 4, 
    wds=1e-6, 
    cycle_len=10, 
    cycle_save_name='adam3_10'
)
learner.save_encoder('adam3_10_enc')
learner.fit(
    3e-3, 1, 
    wds=1e-6, 
    cycle_len=20, 
    cycle_save_name='adam3_20'
)
learner.load_cycle('adam3_20',0)

在情感分析部分,我们只需要语言模型的一半 - 编码器,所以我们保存了那部分。

learner.save_encoder('adam3_20_enc')
learner.load_encoder('adam3_20_enc')

语言建模的准确性通常使用指标困惑度来衡量,这只是我们使用的损失函数的exp()

math.exp(4.165)
'''
64.3926824434624
'''
pickle.dump(TEXT, open(f'{PATH}models/TEXT.pkl','wb'))

测试

我们可以稍微玩弄一下我们的语言模型,以确保它运行正常。首先,让我们创建一小段文本来“引导”一组预测。我们将使用我们的 torchtext 字段对其进行数值化,以便将其馈送给我们的语言模型。

m=learner.model
ss=""". So, it wasn't quite was I was expecting, but I really liked it anyway! The best"""
s = [spacy_tok(ss)]
t=TEXT.numericalize(s)
' '.join(s[0])
'''
". So , it was n't quite was I was expecting , but I really liked it anyway ! The best"
'''

我们还没有添加使测试语言模型变得容易的方法,因此我们需要手动执行这些步骤。

# Set batch size to 1
m[0].bs=1
# Turn off dropout
m.eval()
# Reset hidden state
m.reset()
# Get predictions from model
res,*_ = m(t)
# Put the batch size back to what it was
m[0].bs=bs

让我们看看在我们短文本之后的下一个单词的前 10 个预测是什么:

nexts = torch.topk(res[-1], 10)[1]
[TEXT.vocab.itos[o] for o in to_np(nexts)]
'''
['film',
 'movie',
 'of',
 'thing',
 'part',
 '<unk>',
 'performance',
 'scene',
 ',',
 'actor']
'''

...让我们看看我们的模型是否可以自己生成更多文本!

print(ss,"\n")
for i in range(50):
    n=res[-1].topk(2)[1]
    n = n[1] if n.data[0]==0 else n[0]
    print(TEXT.vocab.itos[n.data[0]], end=' ')
    res,*_ = m(n[0].unsqueeze(0))
print('...')
'''
. So, it wasn't quite was I was expecting, but I really liked it anyway! The best* *film ever ! <eos> i saw this movie at the toronto international film festival . i was very impressed . i was very impressed with the acting . i was very impressed with the acting . i was surprised to see that the actors were not in the movie . ...
'''

情感

所以我们之前已经预训练了一个语言模型,现在我们想要微调它以进行情感分类。

要使用预训练模型,我们将需要语言模型的保存的词汇表,因为我们需要确保相同的单词映射到相同的 ID。

TEXT = pickle.load(open(f'{PATH}models/TEXT.pkl','rb'))

sequential=False告诉 torchtext 文本字段应该被标记化(在这种情况下,我们只想存储“正面”或“负面”单个标签)。

IMDB_LABEL = data.Field(sequential=False)

这一次,我们需要将每个评论视为单独的而不是作为一个大段的文本,因为每个评论都有不同的情感附着。

splits是 torchtext 的一个方法,用于创建训练、测试和验证集。IMDB 数据集内置在 torchtext 中,因此我们可以利用它。查看lang_model-arxiv.ipynb,了解如何定义自己的 fastai/torchtext 数据集。

splits = torchtext.datasets.IMDB.splits(TEXT, IMDB_LABEL, 'data/')
t = splits[0].examples[0]
t.label, ' '.join(t.text[:16])
'''
('pos', 'ashanti is a very 70s sort of film ( 1979 , to be precise ) .')
'''

fastai 可以直接从 torchtext 的splits创建一个ModelData对象。

md2 = TextData.from_splits(PATH, splits, bs)

现在你可以继续调用get_model来获取我们的学习者。然后我们可以加载预训练的语言模型(load_encoder)。

m3 = md2.get_model(
    opt_fn, 1500, bptt, 
    emb_sz=em_sz, 
    n_hid=nh, 
    n_layers=nl, 
    dropout=0.1, 
    dropouti=0.4,
    wdrop=0.5, 
    dropoute=0.05, 
    dropouth=0.3
)
m3.reg_fn = partial(seq2seq_reg, alpha=2, beta=1)
m3.load_encoder(f'adam3_20_enc')

因为我们正在微调一个预训练模型,我们将使用不同的学习率,并增加用于剪切的最大梯度,以使 SGDR 更好地工作。

m3.clip=25.
lrs=np.array([1e-4,1e-3,1e-2])
m3.freeze_to(-1)
m3.fit(lrs/2, 1, metrics=[accuracy])
m3.unfreeze()
m3.fit(lrs, 1, metrics=[accuracy], cycle_len=1)
'''
[ 0\.       0.45074  0.28424  0.88458]
[ 0\.       0.29202  0.19023  0.92768]
'''

我们确保除了最后一层外,所有层都被冻结。然后我们进行一些训练,解冻它,再进行一些训练。好处是一旦你有了一个预训练的语言模型,它实际上训练速度非常快。

m3.fit(
    lrs, 7, 
    metrics=[accuracy], 
    cycle_len=2, 
    cycle_save_name='imdb2'
)
'''
[ 0\.       0.29053  0.18292  0.93241]                        
[ 1\.       0.24058  0.18233  0.93313]                        
[ 2\.       0.24244  0.17261  0.93714]                        
[ 3\.       0.21166  0.17143  0.93866]                        
[ 4\.       0.2062   0.17143  0.94042]                        
[ 5\.       0.18951  0.16591  0.94083]                        
[ 6\.       0.20527  0.16631  0.9393 ]                        
[ 7\.       0.17372  0.16162  0.94159]                        
[ 8\.       0.17434  0.17213  0.94063]                        
[ 9\.       0.16285  0.16073  0.94311]                        
[ 10\.        0.16327   0.17851   0.93998]                    
[ 11\.        0.15795   0.16042   0.94267]                    
[ 12\.        0.1602    0.16015   0.94199]                    
[ 13\.        0.15503   0.1624    0.94171]
'''
m3.load_cycle('imdb2', 4)
accuracy(*m3.predict_with_targs())
'''
0.94310897435897434
'''

Bradbury 等人最近发表的一篇论文,学习中的翻译:上下文化的词向量,对解决 IMDB 情感分析问题的最新学术研究进行了方便的总结。许多最新的算法都是针对这个特定问题进行调整的。

正如你所看到的,我们在情感分析方面取得了最新的技术成果,将错误率从 5.9%降低到 5.5%!你应该能够使用相同的基本步骤在其他 NLP 分类问题上获得同样世界级的结果。

有许多机会进一步改进这一点,尽管我们在本课程的第二部分之前不会能够做到这一点。

  • 例如,我们可以开始训练查看许多医学期刊的语言模型,然后我们可以制作一个可下载的医学语言模型,然后任何人都可以用它来在医学文献的前列腺癌子集上进行微调。

  • 我们还可以将其与预训练的词向量结合使用

  • 我们本可以预先训练一个维基百科语料库语言模型,然后将其微调为一个 IMDB 语言模型,然后再将其微调为一个 IMDB 情感分析模型,我们会得到比这个更好的东西。

有一个名为 Sebastian Ruder 的非常出色的研究人员,他是唯一一个真正大量撰写关于 NLP 中预训练、微调和迁移学习的研究人员。Jeremy 问他为什么这种情况没有更多发生,他的观点是因为没有软件使其变得容易。希望 Fast.ai 会改变这一点。

协同过滤介绍 [02:11:38]

笔记本

数据可从files.grouplens.org/datasets/movielens/ml-latest-small.zip获取

path='data/ml-latest-small/'
ratings = pd.read_csv(path+'ratings.csv')
ratings.head()

数据集看起来像这样:

它包含用户的评分。我们的目标是对于我们以前没有见过的某个用户-电影组合,我们必须预测一个评分。

movies = pd.read_csv(path+'movies.csv')
movies.head()

为了使其更有趣,我们还将实际下载一份电影列表,以便我们可以解释这些嵌入矩阵中实际包含的内容。

g=ratings.groupby('userId')['rating'].count()
topUsers=g.sort_values(ascending=False)[:15]
g=ratings.groupby('movieId')['rating'].count()
topMovies=g.sort_values(ascending=False)[:15]
top_r = ratings.join(
    topUsers, 
    rsuffix='_r', 
    how='inner', 
    on='userId'
)
top_r = top_r.join(
    topMovies, 
    rsuffix='_r', 
    how='inner', 
    on='movieId'
)
pd.crosstab(
    top_r.userId, 
    top_r.movieId, 
    top_r.rating, 
    aggfunc=np.sum
)

这就是我们正在创建的——用户和电影的这种交叉表。

随意提前查看,你会发现大部分步骤对你来说已经很熟悉了。

深度学习 2:第 1 部分第 5 课

原文:medium.com/@hiromi_suenaga/deep-learning-2-part-1-lesson-5-dd904506bee8

译者:飞龙

协议:CC BY-NC-SA 4.0

来自 fast.ai 课程的个人笔记。随着我继续复习课程以“真正”理解它,这些笔记将继续更新和改进。非常感谢 JeremyRachel 给了我这个学习的机会。

Lesson 5

I. 介绍

在结构化深度学习方面的出版物还不够,但在行业中肯定正在发生:

结构化深度学习 by Kerem Turgutlu

您可以使用这个工具从 Google 下载图片并解决自己的问题:

小图像数据集的乐趣(第 2 部分)by Nikhil B

关于如何训练神经网络的介绍(一篇很棒的技术文章):

我们如何‘训练’神经网络? by Vitaly Bushaev

学生们正在与 Jeremy 一起参加Kaggle 幼苗分类竞赛

II. 协同过滤 — 使用 MovieLens 数据集

讨论的笔记本可以在这里(lesson5-movielens.ipynb)找到。

让我们看看数据。我们将使用userId(分类)、movieId(分类)和rating(依赖)进行建模。

ratings = pd.read_csv(path+'ratings.csv')
ratings.head()

为 Excel 创建子集

我们创建了最受欢迎的电影和最痴迷于电影的用户的交叉表,我们将把它复制到 Excel 中进行可视化。

g=ratings.groupby('userId')['rating'].count()
topUsers=g.sort_values(ascending=False)[:15]
g=ratings.groupby('movieId')['rating'].count()
topMovies=g.sort_values(ascending=False)[:15]
top_r = ratings.join(topUsers, rsuffix='_r', how='inner', on='userId')
top_r = top_r.join(topMovies, rsuffix='_r', how='inner', on='movieId')
pd.crosstab(top_r.userId, top_r.movieId, top_r.rating, aggfunc=np.sum)

是包含上述信息的 Excel 文件。首先,我们将使用矩阵分解而不是构建神经网络。

  • 蓝色单元格 — 实际评分

  • 紫色单元格 — 我们的预测

  • 红色单元格 — 我们的损失函数即均方根误差(RMSE)

  • 绿色单元格 — 电影嵌入(随机初始化)

  • 橙色单元格 — 用户嵌入(随机初始化)

每个预测是电影嵌入向量和用户嵌入向量的点积。在线性代数术语中,这相当于矩阵乘积,因为一个是行,一个是列。如果没有实际评分,我们将预测设为零(将其视为测试数据 — 而不是训练数据)。

然后我们使用梯度下降来最小化我们的损失。Microsoft Excel 中有一个“求解器”插件,可以通过更改选定的单元格来最小化一个变量(GRG Nonlinear是您要使用的方法)。

这可以称为“浅层学习”(与深度学习相对),因为没有非线性层或第二个线性层。那么我们刚刚直观地做了什么?每部电影的五个数字称为“嵌入”(潜在因素) - 第一个数字可能表示它有多少科幻和奇幻,第二个可能表示为电影使用了多少特效,第三个可能表示它有多少对话驱动,等等。同样,每个用户也有 5 个数字,例如,表示用户有多喜欢科幻奇幻、特效和对话驱动的电影。我们的预测是这些向量的叉积。由于我们没有每个用户对每部电影的评论,我们试图找出哪些电影与这部电影相似,以及其他用户如何评价这部电影(因此称为“协同”)。

对于新用户或新电影,我们该怎么办 - 我们需要重新训练模型吗?我们现在没有时间来讨论这个问题,但基本上您需要有一个新的用户模型或新的电影模型,最初会使用它,随着时间的推移,您将需要重新训练模型。

简单的 Python 版本[26:03]

这应该现在看起来很熟悉。我们通过选择随机 ID 集创建一个验证集。wd是 L2 正则化的权重衰减,n_factors是我们想要的嵌入矩阵有多大。

val_idxs = get_cv_idxs(len(ratings)) 
wd = 2e-4 
n_factors = 50

我们从 CSV 文件创建一个模型数据对象:

cf = CollabFilterDataset.from_csv(path, 'ratings.csv', 'userId', 'movieId', 'rating')

然后我们得到一个适合模型数据的学习器,并拟合模型:

learn = cf.get_learner(n_factors, val_idxs, 64, opt_fn=optim.Adam)
learn.fit(1e-2, 2, wds=wd, cycle_len=1, cycle_mult=2)

输出 MSE

由于输出是均方误差,您可以通过以下方式获得 RMSE:

math.sqrt(0.765)

输出约为 0.88,优于 0.91 的基准。

您可以以通常的方式获得预测:

preds = learn.predict()

您还可以使用 seaborn sns进行绘图(建立在 matplotlib 之上):

y = learn.data.val_y
sns.jointplot(preds, y, kind='hex', stat_func=None)

使用 Python 进行点积

T是 Torch 中的张量

a = T([[1., 2], [3, 4]])
b = T([[2., 2], [10, 10]])

当我们在 numpy 或 PyTorch 中的张量之间有数学运算符时,它将假定它们具有相同的维度进行逐元素操作。下面是如何计算两个向量的点积的方法(例如(1, 2)⋅(2, 2) = 6 - 矩阵 a 和 b 的第一行):

(a*b).sum(1)
'''
6
70
[torch.FloatTensor of size 2]
'''

构建我们的第一个自定义层(即 PyTorch 模块)[33:55]

我们通过创建一个扩展nn.Module并覆盖forward函数的 Python 类来实现这一点。

class DotProduct (nn.Module):
   def forward(self, u, m): 
        return (u*m).sum(1)

现在我们可以调用它并获得预期结果(请注意,我们不需要说model.forward(a, b)来调用forward函数 - 这是 PyTorch 的魔法。)[40:14]:

model = DotProduct()
model(a,b)
'''
6
70
[torch.FloatTensor of size 2]
'''

构建更复杂的模块[41:31]

这个实现对DotProduct类有两个添加:

  • 两个nn.Embedding矩阵

  • 在上面的嵌入矩阵中查找我们的用户和电影

用户 ID 可能不是连续的,这使得难以用作嵌入矩阵的索引。因此,我们将从零开始创建连续的索引,并用 Panda 的apply函数和匿名函数lambda替换ratings.userId列,并对ratings.movieId执行相同操作。

u_uniq = ratings.userId.unique() 
user2idx = {o:i for i,o in enumerate(u_uniq)} 
ratings.userId = ratings.userId.apply(lambda x: user2idx[x]) 
m_uniq = ratings.movieId.unique() 
movie2idx = {o:i for i,o in enumerate(m_uniq)} 
ratings.movieId = ratings.movieId.apply(lambda x: movie2idx[x]) 
n_users=int(ratings.userId.nunique()) 
n_movies=int(ratings.movieId.nunique())

提示:{o:i for i,o in enumerate(u_uniq)}是一行方便的代码,可以保存在您的工具包中!

class EmbeddingDot(nn.Module):
    def __init__(self, n_users, n_movies):
        super().__init__()
        self.u = nn.Embedding(n_users, n_factors)
        self.m = nn.Embedding(n_movies, n_factors)
        self.u.weight.data.uniform_(0,0.05)
        self.m.weight.data.uniform_(0,0.05)

    def forward(self, cats, conts):
        users,movies = cats[:,0],cats[:,1]
        u,m = self.u(users),self.m(movies)
        return (u*m).sum(1)

请注意,__init__是一个构造函数,现在需要它,因为我们的类需要跟踪“状态”(有多少电影,有多少用户,有多少因素等)。我们将权重初始化为 0 到 0.05 之间的随机数,您可以在这里找到有关权重初始化的标准算法“Kaiming Initialization”的更多信息(PyTorch 具有 He 初始化实用程序函数,但我们正在尝试从头开始做事)[46:58]。

Embedding不是张量,而是变量。变量执行与张量完全相同的操作,但它还执行自动微分。要从变量中提取张量,请调用data属性。所有张量函数都有一个带有下划线的变体(例如uniform_),将在原地执行操作。

x = ratings.drop(['rating', 'timestamp'],axis=1)
y = ratings['rating'].astype(np.float32)
data = ColumnarModelData.from_data_frame(path, val_idxs, x, y, ['userId', 'movieId'], 64)

我们正在重用来自 Rossmann 笔记本的ColumnarModelData(来自 fast.ai 库),这就是为什么在EmbeddingDot类的def forward(self, cats, conts)函数中有分类和连续变量的原因[50:20]。由于在这种情况下我们没有连续变量,我们将忽略conts,并使用cats的第一列和第二列作为usersmovies。请注意,它们是用户和电影的小批量。重要的是不要手动循环遍历小批量,因为这样不会获得 GPU 加速,而是一次处理整个小批量,就像您在上面forward函数的第 3 和第 4 行中看到的那样[51:00–52:05]。

wd=1e-5
model = EmbeddingDot(n_users, n_movies).cuda()
opt = optim.SGD(model.parameters(), 1e-1, weight_decay=wd, momentum=0.9)

optim是 PyTorch 中提供优化器的东西。model.parameters()是从nn.Modules继承的一个函数,它给出所有需要更新/学习的权重。

fit(model, data, 3, opt, F.mse_loss)

这个函数来自 fast.ai 库[54:40],与我们一直在使用的learner.fit()相比,更接近常规的 PyTorch 方法。它不会为您提供“随机梯度下降重启”或“不同学习率”等功能。

让我们改进我们的模型

偏差 - 调整到普遍受欢迎的电影或普遍热情的用户。

min_rating,max_rating = ratings.rating.min(),ratings.rating.max()
min_rating,max_rating
def get_emb(ni,nf):
    e = nn.Embedding(ni, nf)
    e.weight.data.uniform_(-0.01,0.01)
    return e
class EmbeddingDotBias(nn.Module):
    def __init__(self, n_users, n_movies):
        super().__init__()
        (self.u, self.m, self.ub, self.mb) = [
            get_emb(*o) 
            for o in [
                (n_users, n_factors), 
                (n_movies, n_factors), 
                (n_users,1), 
                (n_movies,1)
            ]
        ]

    def forward(self, cats, conts):
        users,movies = cats[:,0],cats[:,1]
        um = (self.u(users)* self.m(movies)).sum(1)
        res = um + self.ub(users).squeeze() + self.mb(movies).squeeze()
        res = F.sigmoid(res) * (max_rating-min_rating) + min_rating
        return res

squeeze是 PyTorch 版本的广播[1:04:11],有关更多信息,请参阅机器学习课程或numpy 文档

我们可以压缩评分,使其在 1 和 5 之间吗?可以!通过将预测通过 sigmoid 函数,将得到 1 和 0 之间的数字。因此,在我们的情况下,我们可以将其乘以 4 并加 1 - 这将得到 1 和 5 之间的数字。

F是 PyTorch 功能(torch.nn.functional),包含所有张量的函数,并在大多数情况下导入为F

wd=2e-4
model = EmbeddingDotBias(cf.n_users, cf.n_items).cuda()
opt = optim.SGD(model.parameters(), 1e-1, weight_decay=wd, momentum=0.9)
fit(model, data, 3, opt, F.mse_loss)
'''
[ 0\.       0.85056  0.83742]                                     
[ 1\.       0.79628  0.81775]                                     
[ 2\.       0.8012   0.80994]
'''

让我们看看我们在简单的 Python 版本中使用的 fast.ai 代码[1:13:44]。在column_data.py文件中,CollabFilterDataSet.get_leaner调用get_model函数,该函数创建了EmbeddingDotBias类,与我们创建的内容相同。

神经网络版本[1:17:21]

我们回到 Excel 表格来理解直觉。请注意,我们创建了user_idx来查找嵌入,就像我们之前在 Python 代码中所做的那样。如果我们要对user_idx进行独热编码并将其乘以用户嵌入,我们将得到用户的适用行。如果只是矩阵乘法,为什么我们需要嵌入?这是为了计算性能优化的目的。

与计算用户嵌入向量和电影嵌入向量的点积以获得预测不同,我们将连接这两者并将其馈送到神经网络中。

class EmbeddingNet(nn.Module):
    def __init__(self, n_users, n_movies, nh=10, p1=0.5, p2=0.5):
        super().__init__()
        (self.u, self.m) = [
            get_emb(*o) 
            for o in [
                (n_users, n_factors), 
                (n_movies, n_factors)
            ]
        ]
        self.lin1 = nn.Linear(n_factors*2, nh)
        self.lin2 = nn.Linear(nh, 1)
        self.drop1 = nn.Dropout(p1)
        self.drop2 = nn.Dropout(p2)

    def forward(self, cats, conts):
        users,movies = cats[:,0],cats[:,1]
        x = self.drop1(torch.cat([self.u(users),self.m(movies)], dim=1))
        x = self.drop2(F.relu(self.lin1(x)))
        return F.sigmoid(self.lin2(x)) * (max_rating-min_rating+1) + min_rating-0.5

注意到我们不再有偏差项,因为 PyTorch 中的Linear层已经内置了偏差。nh是线性层创建的激活数量(Jeremy 称之为“num hidden”)。

它只有一个隐藏层,所以可能不是“深度”,但这绝对是一个神经网络。

wd=1e-5
model = EmbeddingNet(n_users, n_movies).cuda()
opt = optim.Adam(model.parameters(), 1e-3, weight_decay=wd)
fit(model, data, 3, opt, F.mse_loss)
'''
A Jupyter Widget
[ 0\.       0.88043  0.82363]                                    
[ 1\.       0.8941   0.81264]                                    
[ 2\.       0.86179  0.80706]
'''

请注意,损失函数也在F中(这里是均方损失)。

现在我们有了神经网络,我们可以尝试很多事情:

  • 添加丢弃

  • 为用户嵌入和电影嵌入使用不同的嵌入大小

  • 不仅用户和电影嵌入,还可以附加电影类型嵌入和/或原始数据中的时间戳。

  • 增加/减少隐藏层和激活数量

  • 增加/减少正则化

训练循环中发生了什么?[1:33:21]

目前,我们将权重的更新交给 PyTorch 的优化器。优化器做什么?动量是什么?

opt = optim.SGD(model.parameters(), 1e-1, weight_decay=wd, momentum=0.9)

我们将在 Excel 表格中实现梯度下降(graddesc.xlsm)- 从右到左查看工作表。首先我们创建一组随机的xy,它们与x线性相关(例如y= ax* + b)。通过使用一组xy,我们将尝试学习ab

要计算误差,我们首先需要一个预测,并计算差的平方:

为了减少误差,我们稍微增加/减少ab,并找出什么会使误差减少。这被称为通过有限差分找到导数。

在高维空间中,有限差分变得复杂[1:41:46],并且变得非常占用内存且需要很长时间。因此,我们希望找到一种更快地完成这项工作的方法。值得查阅雅可比和黑塞(深度学习书籍:第 4.3.1 节第 84 页)。

链式规则和反向传播

更快的方法是通过分析进行[1:45:27]。为此,我们需要一个链式规则:

链式规则概述

这是 Chris Olah 关于反向传播作为链式规则的一篇很棒的文章。

现在我们用实际导数替换有限差分WolframAlpha给我们提供了(请注意,有限差分输出与实际导数非常接近,是计算自己的导数的快速检查的好方法):

  • “在线”训练 - 小批量大小为 1

这就是你在 Excel 表格中使用 SGD 的方法。如果你将预测值更改为 CNN 电子表格的输出,我们可以使用 SGD 训练 CNN。

动量[1:53:47]

来吧,给个提示 - 那是一个好方向。请继续这样做,但更多。

通过这种方法,我们将使用当前小批量导数和上一个小批量之后我们采取的步骤(以及方向)之间的线性插值(单元格 K9):

与随机符号(+/-)的de/db相比,具有动量的方向会保持相同的方向,直到某个点为止。这将减少训练所需的周期数。

Adam[1:59:04]

Adam 速度更快,但问题在于最终预测不如使用动量的 SGD 那么好。似乎是由于 Adam 和权重衰减的结合使用。修复此问题的新版本称为AdamW

  • 单元格 J8:导数和上一个方向的线性插值(与动量中的相同)

  • 单元格 L8:导数平方和上一步的导数平方的线性插值(单元格 L7

  • 这个想法被称为“指数加权移动平均”(换句话说,平均值随着先前值的乘法递减)

学习率比以前高得多,因为我们将其除以L8的平方根。

如果你看一下 fast.ai 库(model.py),你会注意到在fit函数中,它不仅计算平均损失,而且计算损失的指数加权移动平均

avg_loss = avg_loss * avg_mom + loss * (1-avg_mom)

另一个有用的概念是每当你看到α(…) + (1-α)(…)时,立即想到线性插值

一些直觉

  • 我们计算了梯度平方的指数加权移动平均值,对其取平方根,并将学习率除以它。

  • 梯度的平方始终为正。

  • 当梯度变化很大时,梯度的平方会很大。

  • 当梯度恒定时,梯度的平方会很小。

  • 如果梯度变化很大,我们希望小心谨慎,并通过一个大数来除以学习率(减慢速度)

  • 如果梯度变化不大,我们将通过一个小数来除以学习率,从而迈出更大的一步

  • 自适应学习率 ——跟踪梯度平方的平均值,并使用它来调整学习率。因此只有一个学习率,但如果梯度恒定,则每个参数在每个时期都会跳得更远;否则跳得更小。

  • 有两种动量 —— 一个用于梯度,另一个用于梯度的平方(在 PyTorch 中,它被称为 beta,是两个数字的元组)

AdamW[2:11:18]

当参数比数据点多得多时,正则化变得重要。我们之前见过 dropout,权重衰减是另一种正则化方法。权重衰减(L2 正则化)通过将平方权重(乘以权重衰减系数)添加到损失中来惩罚大权重。现在损失函数希望保持权重较小,因为增加权重会增加损失;因此只有在损失提高超过惩罚时才会这样做。

问题在于,由于我们将平方权重添加到损失函数中,这会影响 Adam 的梯度移动平均和梯度平方移动平均。这会导致在梯度变化很大时减少权重衰减的量,在变化很小时增加权重衰减的量。换句话说,“惩罚大权重,除非梯度变化很大”,这不是我们的初衷。AdamW 将权重衰减从损失函数中移除,并在更新权重时直接添加。

深度学习 2:第 1 部分第 6 课

原文:medium.com/@hiromi_suenaga/deep-learning-2-part-1-lesson-6-de70d626976c

译者:飞龙

协议:CC BY-NC-SA 4.0

来自 fast.ai 课程的个人笔记。随着我继续复习课程以“真正”理解它,这些笔记将继续更新和改进。非常感谢 JeremyRachel 给了我这个学习的机会。

第 6 课

[## 2017 年深度学习优化的亮点

目录:深度学习最终是关于找到一个很好泛化的最小值--附加分为...

ruder.io](http://ruder.io/deep-learning-optimization-2017/index.html?source=post_page-----de70d626976c--------------------------------)

上周的回顾[2:15]

上周我们深入研究了协同过滤,最终在 fast.ai 库中重新创建了EmbeddingDotBias类(column_data.py)。让我们看看嵌入是什么样子的[笔记本]。

在一个学习者learn内部,通过调用learn.model可以获得一个 PyTorch 模型本身。@property看起来像一个普通的函数,但当你调用它时不需要括号。

@property
def model(self): 
    return self.models.model

learn.modelsCollabFilterModel的一个实例,它是 PyTorch 模型的一个薄包装,允许我们使用“层组”,这是 PyTorch 中没有的概念,fast.ai 使用它来对不同的层组应用不同的学习率。

PyTorch 模型很好地打印出层,包括层名称,这就是我们在代码中称呼它们的方式。

m=learn.model; m
'''
EmbeddingDotBias (
  (u): Embedding(671, 50)
  (i): Embedding(9066, 50)
  (ub): Embedding(671, 1)
  (ib): Embedding(9066, 1)
)
'''

m.ib指的是一个项目偏差的嵌入层--在我们的例子中是电影偏差。PyTorch 模型和层的好处是我们可以像调用函数一样调用它们。所以如果你想得到一个预测,你调用m(...)并传入变量。

层需要变量而不是张量,因为它需要跟踪导数--这就是V(...)将张量转换为变量的原因。PyTorch 0.4 将摆脱变量,我们将能够直接使用张量。

movie_bias = to_np(m.ib(V(topMovieIdx)))

to_np函数将获取一个变量或张量(无论是在 CPU 还是 GPU 上)并返回一个 numpy 数组。Jeremy 的方法[12:03]是除了在他明确需要在 GPU 上运行的时候或者需要它的导数时使用 PyTorch 外,其他情况下都使用 numpy。Numpy 比 PyTorch 存在的时间更长,与其他库如 OpenCV、Pandas 等很好地配合。

关于生产中的 CPU vs. GPU 的问题。建议的方法是在 CPU 上进行推断,因为它更具可扩展性,而且你不需要将事物放入批处理中。你可以通过键入m.cpu()将模型移动到 CPU 上,类似地,通过键入V(topMovieIndex).cpu()将变量移动到 CPU 上(从 CPU 到 GPU 的操作是m.cuda())。如果你的服务器没有 GPU,它将自动在 CPU 上运行推断。要加载在 GPU 上训练的保存模型,请查看torch_imports.py中的这行代码:

def load_model(m, p): 
    m.load_state_dict(torch.load(p, map_location=lambda storage, loc: storage))

现在我们有了前 3000 部电影的电影偏差,让我们来看一下评分:

movie_ratings = [(b[0], movie_names[i]) for i,b in zip(topMovies,movie_bias)]

zip将允许你同时迭代多个列表。

最差的电影

关于排序键--Python 有itemgetter函数,但普通的lambda只是多了一个字符。

sorted(movie_ratings, key=lambda o: o[0])[:15]
'''
[(-0.96070349, 'Battlefield Earth (2000)'),
 (-0.76858485, 'Speed 2: Cruise Control (1997)'),
 (-0.73675376, 'Wild Wild West (1999)'),
 (-0.73655486, 'Anaconda (1997)'),
 ...]
'''
sorted(movie_ratings, key=itemgetter(0))[:15]

最佳电影

sorted(movie_ratings, key=lambda o: o[0], reverse=True)[:15]
'''
[(1.3070084, 'Shawshank Redemption, The (1994)'),
 (1.1196285, 'Godfather, The (1972)'),
 (1.0844109, 'Usual Suspects, The (1995)'),
 (0.96578616, "Schindler's List (1993)"),
 ...]
'''

嵌入解释[18:42]

每部电影有 50 个嵌入,很难可视化 50 维空间,所以我们将其转换为三维空间。我们可以使用几种技术来压缩维度:主成分分析(PCA)(Rachel 的计算线性代数课程详细介绍了这一点——几乎与奇异值分解(SVD)相同)

movie_emb = to_np(m.i(V(topMovieIdx)))
movie_emb.shape*(3000, 50)
from sklearn.decomposition import PCA
pca = PCA(n_components=3)
movie_pca = pca.fit(movie_emb.T).components_
movie_pca.shape
'''
(3, 3000)
'''

我们将看一下第一个维度“轻松观看 vs. 严肃”(我们不知道它代表什么,但可以通过观察来推测):

fac0 = movie_pca[0] 
movie_comp = [(f, movie_names[i]) for f,i in zip(fac0, topMovies)]
sorted(movie_comp, key=itemgetter(0), reverse=True)[:10]
sorted(movie_comp, key=itemgetter(0), reverse=True)[:10]
'''
[(0.06748189, 'Independence Day (a.k.a. ID4) (1996)'),
 (0.061572548, 'Police Academy 4: Citizens on Patrol (1987)'),
 (0.061050549, 'Waterworld (1995)'),
 (0.057877172, 'Rocky V (1990)'),
 ...
]
'''
sorted(movie_comp, key=itemgetter(0))[:10]
'''
[(-0.078433245, 'Godfather: Part II, The (1974)'),
 (-0.072180331, 'Fargo (1996)'),
 (-0.071351372, 'Pulp Fiction (1994)'),
 (-0.068537779, 'Goodfellas (1990)'),
 ...
]
'''

第二个维度“对话驱动 vs. CGI”

fac1 = movie_pca[1]
movie_comp = [(f, movie_names[i]) for f,i in zip(fac1, topMovies)]
sorted(movie_comp, key=itemgetter(0), reverse=True)[:10]
'''
[(0.058975246, 'Bonfire of the Vanities (1990)'),
 (0.055992026, '2001: A Space Odyssey (1968)'),
 (0.054682467, 'Tank Girl (1995)'),
 (0.054429606, 'Purple Rose of Cairo, The (1985)'),
 ...]*sorted(movie_comp, key=itemgetter(0))[:10]*[(-0.1064609, 'Lord of the Rings: The Return of the King, The (2003)'),
 (-0.090635143, 'Aladdin (1992)'),
 (-0.089208141, 'Star Wars: Episode V - The Empire Strikes Back (1980)'),
 (-0.088854566, 'Star Wars: Episode IV - A New Hope (1977)'),
 ...]
'''

绘图

idxs = np.random.choice(len(topMovies), 50, replace=False)
X = fac0[idxs]
Y = fac1[idxs]
plt.figure(figsize=(15,15))
plt.scatter(X, Y)
for i, x, y in zip(topMovies[idxs], X, Y):
    plt.text(x,y,movie_names[i], color=np.random.rand(3)*0.7, fontsize=11)
plt.show()

当你说learn.fit时实际发生了什么?

分类变量的实体嵌入 [24:42]

第二篇论文讨论了分类嵌入。图 1 的标题应该听起来很熟悉,因为它们讨论了实体嵌入层等效于一个独热编码后跟着一个矩阵乘法。

他们做的有趣的事情是,他们用神经网络训练的实体嵌入替换了每个分类变量,然后将其输入到梯度提升机(GBM)、随机森林(RF)和 KNN 中——这将误差降低到几乎与神经网络(NN)一样好。这是一个很好的方法,可以在组织内提供神经网络的能力,而不需要强迫其他人学习深度学习,因为他们可以继续使用他们目前使用的东西,并将嵌入作为输入。GBM 和 RF 的训练速度比 NN 快得多。

他们还绘制了德国各州的嵌入,有趣的是(正如 Jeremy 所说的那样)它们与实际地图相似。

他们还绘制了实体在物理空间和嵌入空间中的距离——显示了一个美丽而清晰的相关性。

星期几或一年中的月份之间似乎也存在相关性。可视化嵌入可能很有趣,因为它向你展示了你期望看到的或者你没有预料到的内容。

关于 Skip-Gram 生成嵌入的问题 [31:31]

Skip-Gram 是特定于 NLP 的。将一个无标签的问题转化为有标签的问题的一个好方法是“发明”标签。Word2Vec 的方法是取一个包含 11 个单词的句子,删除中间的单词,然后用一个随机单词替换它。然后他们给原始句子一个标签 1;虚假句子一个标签 0,并构建一个机器学习模型来找出虚假句子。结果,他们现在有了可以用于其他目的的嵌入。如果你将这个作为一个单一的矩阵乘法器(浅层模型)而不是深度神经网络来训练,你可以训练得非常快速——缺点是这是一个预测性较差的模型,但优点是你可以在一个非常大的数据集上训练,更重要的是,所得到的嵌入具有线性特征,这使我们可以很好地进行加减或绘制。在 NLP 中,我们应该超越 Word2Vec 和 Glove(即基于线性的方法),因为这些嵌入的预测性较差。最先进的语言模型使用深度 RNN。

要学习任何类型的特征空间,你要么需要有标记的数据,要么需要发明一个虚假任务 [35:45]

  • 一个虚假任务比另一个更好吗?尚未研究清楚。

  • 直觉上,我们希望有一个任务可以帮助机器学习你关心的关系类型。

  • 在计算机视觉中,人们使用一种虚假任务的类型是应用不真实和不合理的数据增强。

  • 如果你想不出很好的虚假任务,只需使用糟糕的任务——令人惊讶的是你需要的很少。

  • 自动编码器 - 它最近赢得了一场保险索赔竞赛。拿一个单一的政策,通过神经网络运行它,并让它重建自己(确保中间层的激活少于输入变量)。基本上,这是一个输入=输出的任务,作为一个虚假任务效果惊人。

在计算机视觉中,您可以训练猫和狗,并将其用于 CT 扫描。也许它对语言/NLP 也有效!(未来研究)

Rossmann

  • 笔记本中添加了正确使用测试集的方法。

  • 有关更详细的解释,请参见机器学习课程。

  • apply_cats(joined_test, joined) 用于确保测试集和训练集具有相同的分类代码。

  • 跟踪包含每个连续列的均值和标准差的mapper,并将相同的mapper应用于测试集。

  • 不要依赖 Kaggle 公共板块 - 依赖您自己精心创建的验证集。

查看 Rossmann 的一个好的Kernel

  • 周日对销售的影响

在店铺关闭前后销售有所增长。第三名获奖者在开始任何分析之前删除了关闭的店铺行。

除非您首先分析以确保您所做的是正确的 - 不要触碰您的数据。

Vim 技巧

  • :tag ColumnarModelData将带您到类定义处

  • ctrl + ]将带您到光标下的定义

  • ctrl + t返回

  • *查找光标下的内容的用法

  • 您可以使用:tabn:tabp在选项卡之间切换,使用:tabe <filepath>可以添加一个新选项卡;使用常规的:q:wq关闭一个选项卡。如果将:tabn:tabp映射到 F7/F8 键,您可以轻松地在文件之间切换。

在 ColumnarModelData 内部

慢慢地,曾经只是“魔术”的东西开始变得熟悉起来。正如您所看到的,get_learner返回Learner,这是 fast.ai 概念,它包装了数据和 PyTorch 模型:

MixedInputModel内部,您可以看到它是如何创建我们现在更多了解的Embedding的。nn.ModuleList用于注册一系列层。我们将在下周讨论BatchNorm,但是其他部分,我们之前已经见过。

同样,我们现在了解了forward函数中发生的事情。

  • 使用第i个分类变量调用嵌入层,并将它们全部连接在一起

  • 通过 dropout 处理

  • 逐个遍历我们的线性层,称之为,应用 relu 和 dropout

  • 然后最终的线性层大小为 1

  • 如果传入y_range,则应用 sigmoid 并将输出拟合在一个范围内(我们上周学到的)

随机梯度下降 - SGD

为了确保我们完全熟悉 SGD,我们将使用它来学习*y = ax + b*。如果我们可以用 2 个参数解决问题,我们可以使用相同的技术来解决 1 亿个参数。

# Here we generate some fake data
def lin(a,b,x): 
    return a*x+b

def gen_fake_data(n, a, b):
    x = s = np.random.uniform(0,1,n) 
    y = lin(a,b,x) + 0.1 * np.random.normal(0,3,n)
    return x, y

x, y = gen_fake_data(50, 3., 8.)

plt.scatter(x,y, s=8); plt.xlabel("x"); plt.ylabel("y");

要开始,我们需要一个损失函数。这是一个回归问题,因为输出是连续输出,最常见的损失函数是均方误差(MSE)。

回归 - 目标输出是一个实数或一整个实数向量

分类 - 目标输出是一个类标签

def mse(y_hat, y): 
    return ((y_hat - y) ** 2).mean()
def mse_loss(a, b, x, y): 
    return mse(lin(a,b,x), y)
  • y_hat - 预测

我们将创建 10,000 个更多的虚假数据,并将它们转换为 PyTorch 变量,因为 Jeremy 不喜欢求导,PyTorch 可以为他做到这一点:

x, y = gen_fake_data(10000, 3., 8.) 
x,y = V(x),V(y)

然后为ab创建随机权重,它们是我们想要学习的变量,因此设置requires_grad=True

a = V(np.random.randn(1), requires_grad=True) 
b = V(np.random.randn(1), requires_grad=True)

然后设置学习率,并进行 10000 个完全梯度下降的周期(不是 SGD,因为每个周期将查看所有数据):

learning_rate = 1e-3
for t in range(10000):
    # Forward pass: compute predicted y using operations on Variables
    loss = mse_loss(a,b,x,y)
    if t % 1000 == 0: print(loss.data[0])

    # Computes the gradient of loss with respect to all Variables with requires_grad=True.
    # After this call a.grad and b.grad will be Variables holding the gradient
    # of the loss with respect to a and b respectively
    loss.backward()

    # Update a and b using gradient descent; a.data and b.data are Tensors,
    # a.grad and b.grad are Variables and a.grad.data and b.grad.data are Tensors
    a.data -= learning_rate * a.grad.data
    b.data -= learning_rate * b.grad.data

    # Zero the gradients
    a.grad.data.zero_()
    b.grad.data.zero_()

  • 计算损失(记住,ab最初是随机设置的)

  • 偶尔(每 1000 个周期)打印出损失

  • loss.backward()将计算所有requires_grad=True的变量的梯度,并填充.grad属性

  • a更新为原来的值减去 LR * grad.data访问变量内的张量)

  • 当有多个损失函数或许多输出层对梯度有贡献时,PyTorch 会将它们相加。所以你需要告诉何时将梯度设置回零(zero_()中的_表示变量是原地更改的)。

  • 代码的最后 4 行是包含在optim.SGD.step函数中的内容

让我们只用 Numpy(不用 PyTorch)来做这个[1:07:01]

我们实际上需要做微积分,但其他方面应该看起来类似:

x, y = gen_fake_data(50, 3., 8.)
a_guess,b_guess = -1., 1.
mse_loss(y, a_guess, b_guess, x)
lr=0.01 
def upd():
     global a_guess, b_guess
     y_pred = lin(a_guess, b_guess, x)
     dydb = 2 * (y_pred - y)
     dyda = x*dydb
     a_guess -= lr*dyda.mean()
     b_guess -= lr*dydb.mean()

只是为了好玩,你可以使用matplotlib.animation.FuncAnimation来制作动画:

提示:Fast.ai AMI 没有附带ffmpeg。所以如果你看到KeyError: 'ffmpeg'

  • 运行print(animation.writers.list())并打印出可用的 MovieWriters 列表

  • 如果ffmpeg在其中。否则安装它

循环神经网络 - RNN [1:09:16]

让我们学习如何像尼采一样写哲学。这类似于我们在第 4 课学到的语言模型,但这次,我们将一次一个字符地做。RNN 与我们已经学过的内容没有区别。

一些例子:

具有单隐藏层的基本 NN

所有形状都是激活(激活是通过 relu、矩阵乘积等计算得到的数字)。箭头是层操作(可能不止一个)。查看机器学习课程第 9-11 课,从头开始创建这个。

具有单个密集隐藏层的图像 CNN

我们将在下周更详细地介绍如何展平一个层,但主要方法被称为“自适应最大池化”——在高度和宽度上取平均值,将其转换为向量。

这里没有显示batch_size维度和激活函数(例如 relu,softmax)

使用字符 1 和 2 预测字符 3[1:18:04]

我们将为 NLP 实现这个。

  • 输入可以是一个独热编码字符(向量长度=唯一字符数)或一个整数,并通过使用嵌入层假装它是独热编码。

  • 与 CNN 的区别在于然后 char 2 输入被添加。

未显示层操作;记住箭头代表层操作

让我们实现这个,没有 torchtext 或 fast.ai 库,这样我们就可以看到。

  • set将返回所有唯一字符。
text = open(f'{PATH}nietzsche.txt').read()
print(text[:400])
'''
'PREFACE\n\n\nSUPPOSING that Truth is a woman--what then? Is there not ground\nfor suspecting that all philosophers, in so far as they have been\ndogmatists, have failed to understand women--that the terrible\nseriousness and clumsy importunity with which they have usually paid\ntheir addresses to Truth, have been unskilled and unseemly methods for\nwinning a woman? Certainly she has never allowed herself '
'''
chars = sorted(list(set(text))) 
vocab_size = len(chars)+1 
print('total chars:', vocab_size)
'''
total chars: 85
'''
  • 总是好的为填充放置一个空字符或空字符。
chars.insert(0, "\0")

将每个字符映射到唯一 ID,并将唯一 ID 映射到字符

char_indices = dict((c, i) for i, c in enumerate(chars))
indices_char = dict((i, c) for i, c in enumerate(chars))

现在我们可以用它的 ID 来表示文本:

idx = [char_indices[c] for c in text]
idx[:10]
'''
[40, 42, 29, 30, 25, 27, 29, 1, 1, 1]
'''

问题:基于字符的模型与基于单词的模型[1:22:30]

  • 通常,你希望结合字符级模型和单词级模型(例如用于翻译)。

  • 当词汇表包含不寻常的单词时,字符级模型很有用——而单词级模型将其视为“未知”。当你看到一个以前没有见过的单词时,你可以使用字符级模型。

  • 还有一种叫做字节对编码(BPE)的东西,它查看字符的 n-gram。

创建输入[1:23:48]

cs = 3 
c1_dat = [idx[i]   for i in range(0, len(idx)-cs, cs)]
c2_dat = [idx[i+1] for i in range(0, len(idx)-cs, cs)]
c3_dat = [idx[i+2] for i in range(0, len(idx)-cs, cs)]
c4_dat = [idx[i+3] for i in range(0, len(idx)-cs, cs)]

注意c1_dat[n+1] == c4_dat[n],因为我们是按 3 跳过的(range的第三个参数)

x1 = np.stack(c1_dat) 
x2 = np.stack(c2_dat) 
x3 = np.stack(c3_dat) 
y = np.stack(c4_dat)

x是我们的输入,y是我们的目标值。

构建一个模型[1:26:08]

n_hidden = 256 
n_fac = 42
  • n_hiddein-图表中的“#激活”。

  • n_fac-嵌入矩阵的大小。

这是上一个图表的更新版本。请注意,现在箭头是彩色的。所有具有相同颜色的箭头将使用相同的权重矩阵。这里的想法是,一个字符不会根据它在序列中是第一个、第二个还是第三个项目而具有不同的含义(语义上或概念上),因此将它们视为相同。

class Char3Model(nn.Module):
     def __init__(self, vocab_size, n_fac):
         super().__init__()
         self.e = nn.Embedding(vocab_size, n_fac)
         self.l_in = nn.Linear(n_fac, n_hidden)
         self.l_hidden = nn.Linear(n_hidden, n_hidden)
         self.l_out = nn.Linear(n_hidden, vocab_size) 
     
     def forward(self, c1, c2, c3):
         
         in1 = F.relu(self.l_in(self.e(c1)))
         in2 = F.relu(self.l_in(self.e(c2)))
         in3 = F.relu(self.l_in(self.e(c3)))

         h = V(torch.zeros(in1.size()).cuda())
         h = F.tanh(self.l_hidden(h+in1))
         h = F.tanh(self.l_hidden(h+in2))
         h = F.tanh(self.l_hidden(h+in3))

         return F.log_softmax(self.l_out(h))

视频[1:27:57]

  • [1:29:58]重要的是,这个l_hidden使用一个大小与l_in的输出匹配的方形权重矩阵。然后hin2将具有相同的形状,允许我们像在self.l_hidden(h+in2)中看到的那样将它们相加。

  • V(torch.zeros(in1.size()).cuda())只是为了使这三行相同,以便稍后更容易放入循环中。

md = ColumnarModelData.from_arrays('.', [-1], np.stack([x1,x2,x3], axis=1), y, bs=512)

我们将重用ColumnarModelData[1:32:20]。如果我们堆叠x1x2x3,我们将在forward方法中得到c1c2c3。当您想以原始方式训练模型时,ColumnarModelData.from_arrays会派上用场,您在[x1, x2, x3]中放入的内容,将在**def** **forward**(self, c1, c2, c3)中返回。

m = Char3Model(vocab_size, n_fac).cuda()
  • 我们创建一个标准的 PyTorch 模型(不是Learner

  • 因为它是一个标准的 PyTorch 模型,不要忘记.cuda

it = iter(md.trn_dl)
*xs,yt = next(it)
t = m(*V(xs))
  • iter来获取一个迭代器

  • next返回一个小批量

  • “变量化”xs张量,并将其通过模型-这将给我们一个包含预测的 512x85 张量(批量大小*独特字符)

opt = optim.Adam(m.parameters(), 1e-2)
  • 创建一个标准的 PyTorch 优化器-需要传入一个要优化的列表,该列表由m.parameters()返回
fit(m, md, 1, opt, F.nll_loss)
set_lrs(opt, 0.001)
fit(m, md, 1, opt, F.nll_loss)
  • 我们找不到学习率查找器和 SGDR,因为我们没有使用Learner,所以我们需要手动进行学习率退火(将 LR 设置得稍低一些)

测试一个模型[1:35:58]

def get_next(inp):
     idxs = T(np.array([char_indices[c] for c in inp]))
     p = m(*VV(idxs))
     i = np.argmax(to_np(p))
     return chars[i]

这个函数接受三个字符,并返回模型预测的第四个。注意:np.argmax返回最大值的索引。

get_next('y. ')
'''
'T'
'''
get_next('ppl')
'''
'e'
'''
get_next(' th')
'''
'e'
'''
get_next('and')
'''
' '
'''

让我们创建我们的第一个 RNN[1:37:45]

我们可以简化上一个图表如下:

使用 1 到 n-1 个字符预测第 n 个字符

让我们实现这个。这次,我们将使用前 8 个字符来预测第 9 个。这是如何创建输入和输出的,就像上次一样:

cs = 8
c_in_dat = [[idx[i+j] for i in range(cs)] for j in range(len(idx)-cs)]
c_out_dat = [idx[j+cs] for j in range(len(idx)-cs)]
xs = np.stack(c_in_dat, axis=0)
y = np.stack(c_out_dat)
xs[:cs,:cs]
'''
array([[40, 42, 29, 30, 25, 27, 29,  1],
       [42, 29, 30, 25, 27, 29,  1,  1],
       [29, 30, 25, 27, 29,  1,  1,  1],
       [30, 25, 27, 29,  1,  1,  1, 43],
       [25, 27, 29,  1,  1,  1, 43, 45],
       [27, 29,  1,  1,  1, 43, 45, 40],
       [29,  1,  1,  1, 43, 45, 40, 40],
       [ 1,  1,  1, 43, 45, 40, 40, 39]])
'''
y[:cs]
'''
array([ 1,  1, 43, 45, 40, 40, 39, 43])
'''

请注意,它们是重叠的(即 0-7 预测 8,1-8 预测 9)。

val_idx = get_cv_idxs(len(idx)-cs-1)
md = ColumnarModelData.from_arrays('.', val_idx, xs, y, bs=512)

创建模型[1:43:03]

class CharLoopModel(nn.Module):
    # This is an RNN!
    def __init__(self, vocab_size, n_fac):
        super().__init__()
        self.e = nn.Embedding(vocab_size, n_fac)
        self.l_in = nn.Linear(n_fac, n_hidden)
        self.l_hidden = nn.Linear(n_hidden, n_hidden)
        self.l_out = nn.Linear(n_hidden, vocab_size)

    def forward(self, *cs):
        bs = cs[0].size(0)
        h = V(torch.zeros(bs, n_hidden).cuda())
        for c in cs:
            inp = F.relu(self.l_in(self.e(c)))
            h = F.tanh(self.l_hidden(h+inp))

        return F.log_softmax(self.l_out(h), dim=-1)

大部分代码与以前相同。您会注意到forward函数中有一个for循环。

双曲正切(Tanh)[1:43:43]

这是一个偏移的 sigmoid 函数。在隐藏状态到隐藏状态的转换中使用双曲正切是常见的,因为它可以阻止其飞得太高或太低。对于其他目的,relu 更常见。

现在这是一个相当深的网络,因为它使用 8 个字符而不是 2 个。随着网络变得更深,它们变得更难训练。

m = CharLoopModel(vocab_size, n_fac).cuda() 
opt = optim.Adam(m.parameters(), 1e-2)
fit(m, md, 1, opt, F.nll_loss)
set_lrs(opt, 0.001)
fit(m, md, 1, opt, F.nll_loss)

添加 vs.连接

现在我们将尝试为self.l_hidden(**h+inp**)[1:46:04]尝试其他方法。原因是输入状态和隐藏状态在质上是不同的。输入是字符的编码,h 是一系列字符的编码。因此,将它们相加,我们可能会丢失信息。让我们改为连接它们。不要忘记更改输入以匹配形状(n_fac+n_hidden而不是n_fac)。

class CharLoopConcatModel(nn.Module):
    def __init__(self, vocab_size, n_fac):
        super().__init__()
        self.e = nn.Embedding(vocab_size, n_fac)
        self.l_in = nn.Linear(n_fac+n_hidden, n_hidden)
        self.l_hidden = nn.Linear(n_hidden, n_hidden)
        self.l_out = nn.Linear(n_hidden, vocab_size)

    def forward(self, *cs):
        bs = cs[0].size(0)
        h = V(torch.zeros(bs, n_hidden).cuda())
        for c in cs:
            inp = torch.cat((h, self.e(c)), 1)
            inp = F.relu(self.l_in(inp))
            h = F.tanh(self.l_hidden(inp))

        return F.log_softmax(self.l_out(h), dim=-1)

这带来了一些改进。

使用 PyTorch 的 RNN[1:48:47]

PyTorch 将自动为我们编写for循环,还会编写线性输入层。

class CharRnn(nn.Module):
    def __init__(self, vocab_size, n_fac):
        super().__init__()
        self.e = nn.Embedding(vocab_size, n_fac)
        **self.rnn = nn.RNN(n_fac, n_hidden)**
        self.l_out = nn.Linear(n_hidden, vocab_size)

    def forward(self, *cs):
        bs = cs[0].size(0)
        h = V(torch.zeros(1, bs, n_hidden))
        inp = self.e(torch.stack(cs))
        **outp,h = self.rnn(inp, h)**

        return F.log_softmax(self.l_out(**outp[-1]**), dim=-1)
  • 出于以后会变得明显的原因,self.rnn将返回不仅输出,还有隐藏状态。

  • PyTorch 中的一个微小差异是self.rnn会将一个新的隐藏状态附加到张量上,而不是替换(换句话说,它会在图表中返回所有省略号)。我们只想要最后一个,所以我们做outp[-1]

m = CharRnn(vocab_size, n_fac).cuda() 
opt = optim.Adam(m.parameters(), 1e-3)
ht = V(torch.zeros(1, 512,n_hidden)) 
outp, hn = m.rnn(t, ht) 
outp.size(), hn.size()
'''
(torch.Size([8, 512, 256]), torch.Size([1, 512, 256]))
'''

在 PyTorch 版本中,隐藏状态是一个秩为 3 的张量h = V(torch.zeros(1, bs, n_hidden)(在我们的版本中,它是秩为 2 的张量)[1:51:58]。我们以后会学到更多关于这个,但事实证明你可以有第二个向后运行的 RNN。这个想法是它会更好地找到向后的关系——它被称为“双向 RNN”。你也可以有一个 RNN 馈送到一个 RNN,这被称为“多层 RNN”。对于这些 RNN,你将需要张量中的额外轴来跟踪额外层的隐藏状态。现在,我们只有 1 个,然后返回 1 个。

测试模型

def get_next(inp):
    idxs = T(np.array([char_indices[c] for c in inp]))
    p = m(*VV(idxs))
    i = np.argmax(to_np(p))
    return chars[i]
def get_next_n(inp, n):
    res = inp
    for i in range(n):
        c = get_next(inp)
        res += c
        inp = inp[1:]+c
    return res
get_next_n('for thos', 40) 
'''
'for those the same the same the same the same th'
'''

这次,我们循环n次,每次调用get_next,每次我们将我们的输入替换为删除第一个字符并添加我们刚预测的字符。

对于有趣的作业,尝试编写自己的nn.RNNJeremysRNN”,而不查看 PyTorch 源代码。

多输出[1:55:31]

从最后一个图表中,我们可以进一步简化,将字符 1 视为字符 2 到 n-1 相同。你会注意到三角形(输出)也移动到循环内部,换句话说,我们在每个字符之后创建一个预测。

使用字符 1 到 n-1 预测字符 2 到 n

我们可能想要这样做的原因之一是我们之前看到的冗余:

array([[40, 42, 29, 30, 25, 27, 29,  1],
       [42, 29, 30, 25, 27, 29,  1,  1],
       [29, 30, 25, 27, 29,  1,  1,  1],
       [30, 25, 27, 29,  1,  1,  1, 43],
       [25, 27, 29,  1,  1,  1, 43, 45],
       [27, 29,  1,  1,  1, 43, 45, 40],
       [29,  1,  1,  1, 43, 45, 40, 40],
       [ 1,  1,  1, 43, 45, 40, 40, 39]])

这次我们可以通过采用不重叠的字符集来使其更有效。因为我们正在进行多输出,对于输入字符 0 到 7,输出将是字符 1 到 8 的预测。

xs[:cs,:cs]
'''
array([[40, 42, 29, 30, 25, 27, 29,  1],
       [ 1,  1, 43, 45, 40, 40, 39, 43],
       [33, 38, 31,  2, 73, 61, 54, 73],
       [ 2, 44, 71, 74, 73, 61,  2, 62],
       [72,  2, 54,  2, 76, 68, 66, 54],
       [67,  9,  9, 76, 61, 54, 73,  2],
       [73, 61, 58, 67, 24,  2, 33, 72],
       [ 2, 73, 61, 58, 71, 58,  2, 67]])
'''
ys[:cs,:cs]
'''
array([[42, 29, 30, 25, 27, 29,  1,  1],
       [ 1, 43, 45, 40, 40, 39, 43, 33],
       [38, 31,  2, 73, 61, 54, 73,  2],
       [44, 71, 74, 73, 61,  2, 62, 72],
       [ 2, 54,  2, 76, 68, 66, 54, 67],
       [ 9,  9, 76, 61, 54, 73,  2, 73],
       [61, 58, 67, 24,  2, 33, 72,  2],
       [73, 61, 58, 71, 58,  2, 67, 68]])
'''

这不会使我们的模型更准确,但我们可以更有效地训练它。

class CharSeqRnn(nn.Module):
    def __init__(self, vocab_size, n_fac):
        super().__init__()
        self.e = nn.Embedding(vocab_size, n_fac)
        self.rnn = nn.RNN(n_fac, n_hidden)
        self.l_out = nn.Linear(n_hidden, vocab_size)

    def forward(self, *cs):
        bs = cs[0].size(0)
        h = V(torch.zeros(1, bs, n_hidden))
        inp = self.e(torch.stack(cs))
        outp,h = self.rnn(inp, h)
        return F.log_softmax(self.l_out(outp), dim=-1)

请注意,我们不再做outp[-1],因为我们想保留所有这些。但其他一切都是相同的。一个复杂性[2:00:37]是我们想要像以前一样使用负对数似然损失函数,但它期望两个秩为 2 的张量(两个矢量的小批量)。但在这里,我们有秩为 3 的张量:

  • 8 个字符(时间步)

  • 84 个概率

  • 对于 512 个小批量

让我们编写一个自定义损失函数[2:02:10]:

def nll_loss_seq(inp, targ):
    sl,bs,nh = inp.size()
    targ = targ.transpose(0,1).contiguous().view(-1)
    return F.nll_loss(inp.view(-1,nh), targ)
  • F.nll_loss是 PyTorch 的损失函数。

  • 展平我们的输入和目标。

  • 转置前两个轴,因为 PyTorch 期望 1.序列长度(多少个时间步),2.批量大小,3.隐藏状态本身。yt.size()是 512 乘以 8,而sl, bs是 8 乘以 512。

  • 当你做像“transpose”这样的事情时,PyTorch 通常不会实际洗牌内存顺序,而是保留一些内部元数据来处理它,就好像它被转置了。当你转置一个矩阵时,PyTorch 只是更新元数据。如果你看到一个错误说“这个张量不连续”,在它后面加上.contiguous(),错误就会消失。

  • .viewnp.reshape相同。-1表示它需要多长。

fit(m, md, 4, opt, null_loss_seq)

记住fit(...)是 fast.ai 实现训练循环的最低级抽象。因此,除了md是包装测试集、训练集和验证集的模型数据对象之外,所有参数都是标准的 PyTorch 东西。

问题[2:06:04]: 现在我们在循环内部放了一个三角形,我们需要更大的序列大小吗?

  • 如果我们有一个短序列像 8 这样,第一个字符没有任何依据。它从零开始的空隐藏状态。

  • 我们将学习如何避免这个问题下周。

  • 基本思想是“为什么我们每次都要将隐藏状态重置为零?”(见下面的代码)。如果我们可以以某种方式排列这些小批量,使得下一个小批量正确连接起来,代表尼采作品中的下一个字母,那么我们可以将h = V(torch.zeros(1, bs, n_hidden))移到构造函数中。

class CharSeqRnn(nn.Module):
    def __init__(self, vocab_size, n_fac):
        super().__init__()
        self.e = nn.Embedding(vocab_size, n_fac)
        self.rnn = nn.RNN(n_fac, n_hidden)
        self.l_out = nn.Linear(n_hidden, vocab_size)

    def forward(self, *cs):
        bs = cs[0].size(0)
        **h = V(torch.zeros(1, bs, n_hidden))**
        inp = self.e(torch.stack(cs))
        outp,h = self.rnn(inp, h)
        return F.log_softmax(self.l_out(outp), dim=-1)

梯度爆炸 [2:08:21]

self.rnn(inp, h) 是一个循环,一遍又一遍地应用相同的矩阵乘法。如果那个矩阵乘法倾向于每次增加激活,我们实际上是将其乘以 8 次 — 我们称之为梯度爆炸。我们希望确保初始的l_hidden不会导致我们的激活平均增加或减少。

一个很好的能做到这一点的矩阵被称为单位矩阵:

我们可以用单位矩阵覆盖随机初始化的隐藏-隐藏权重:

m.rnn.weight_hh_l0.data.copy_(torch.eye(n_hidden))

这是由 Geoffrey Hinton 等人在 2015 年介绍的(一种初始化修正线性单元循环网络的简单方法) — 在 RNN 存在几十年后。它效果非常好,你可以使用更高的学习率,因为它表现良好。

深度学习 2:第 1 部分第 7 课

原文:medium.com/@hiromi_suenaga/deep-learning-2-part-1-lesson-7-1b9503aff0c

译者:飞龙

协议:CC BY-NC-SA 4.0

来自 fast.ai 课程的个人笔记。随着我继续复习课程以“真正”理解它,这些笔记将继续更新和改进。非常感谢 JeremyRachel 给了我这个学习的机会。

第 7 课

第 1 部分的主题是:

  • 使用深度学习进行分类和回归

  • 识别和学习最佳和已建立的实践

  • 重点是分类和回归,即预测“一件事”(例如一个数字,少量标签)

课程的第 2 部分:

  • 重点是生成建模,这意味着预测“很多事情” — 例如,在神经翻译中创建句子,图像字幕或问题回答,同时创建图像,例如风格转移,超分辨率,分割等等。

  • 不是那么多的最佳实践,而是从最近的可能尚未完全测试的论文中更多的推测。

Char3Model 的回顾

提醒:RNN 在任何方面都不是不同或不寻常或神奇的 — 只是一个标准的全连接网络。

标准全连接网络

  • 箭头代表一个或多个层操作 —— 一般来说是线性后跟一个非线性函数,本例中是矩阵乘法后跟 relutanh

  • 相同颜色的箭头表示使用完全相同的权重矩阵。

  • 与以前的一个细微差别是第二层和第三层有输入进来。我们尝试了两种方法 —— 将这些输入连接或添加到当前激活中。

class Char3Model(nn.Module):
    def __init__(self, vocab_size, n_fac):
        super().__init__()
        self.e = nn.Embedding(vocab_size, n_fac)

        # The 'green arrow' from our diagram
        self.l_in = nn.Linear(n_fac, n_hidden)

        # The 'orange arrow' from our diagram
        self.l_hidden = nn.Linear(n_hidden, n_hidden)

        # The 'blue arrow' from our diagram
        self.l_out = nn.Linear(n_hidden, vocab_size)

    def forward(self, c1, c2, c3):
        in1 = F.relu(self.l_in(self.e(c1)))
        in2 = F.relu(self.l_in(self.e(c2)))
        in3 = F.relu(self.l_in(self.e(c3)))

        h = V(torch.zeros(in1.size()).cuda())
        h = F.tanh(self.l_hidden(h+in1))
        h = F.tanh(self.l_hidden(h+in2))
        h = F.tanh(self.l_hidden(h+in3))

        return F.log_softmax(self.l_out(h))
  • 通过使用 nn.Linear,我们免费获得了权重矩阵和偏置向量。

  • 为了解决第一个椭圆中没有橙色箭头的问题,我们发明了一个空矩阵

class CharLoopModel(nn.Module):
    # This is an RNN!
    def __init__(self, vocab_size, n_fac):
        super().__init__()
        self.e = nn.Embedding(vocab_size, n_fac)
        self.l_in = nn.Linear(n_fac, n_hidden)
        self.l_hidden = nn.Linear(n_hidden, n_hidden)
        self.l_out = nn.Linear(n_hidden, vocab_size)

    def forward(self, *cs):
        bs = cs[0].size(0)
        h = V(torch.zeros(bs, n_hidden).cuda())
        for c in cs:
            inp = F.relu(self.l_in(self.e(c)))
            h = F.tanh(self.l_hidden(h+inp))

        return F.log_softmax(self.l_out(h), dim=-1)
  • 几乎相同,除了 for 循环
class CharRnn(nn.Module):
    def __init__(self, vocab_size, n_fac):
        super().__init__()
        self.e = nn.Embedding(vocab_size, n_fac)
        self.rnn = nn.RNN(n_fac, n_hidden)
        self.l_out = nn.Linear(n_hidden, vocab_size)

    def forward(self, *cs):
        bs = cs[0].size(0)
        h = V(torch.zeros(1, bs, n_hidden))
        inp = self.e(torch.stack(cs))
        outp,h = self.rnn(inp, h)

        return F.log_softmax(self.l_out(outp[-1]), dim=-1)
  • PyTorch 版本 — nn.RNN 将创建循环并跟踪 h

  • 我们使用白色部分来预测绿色字符 —— 这似乎是浪费的,因为下一部分与当前部分大部分重叠。

  • 然后我们尝试在多输出模型中将其分割为不重叠的部分:

  • 在这种方法中,我们在处理每个部分后丢弃了我们的 h 激活,并开始了一个新的激活。为了在下一部分中使用第一个字符来预测第二个字符,它除了默认激活外没有其他信息。让我们不要丢弃 h

有状态的 RNN

class CharSeqStatefulRnn(nn.Module):
    def __init__(self, vocab_size, n_fac, bs):
        self.vocab_size = vocab_size
        super().__init__()
        self.e = nn.Embedding(vocab_size, n_fac)
        self.rnn = nn.RNN(n_fac, n_hidden)
        self.l_out = nn.Linear(n_hidden, vocab_size)
        self.init_hidden(bs)

    def forward(self, cs):
        bs = cs[0].size(0)
        if self.h.size(1) != bs: self.init_hidden(bs)
        outp,h = self.rnn(self.e(cs), self.h)
        self.h = repackage_var(h)
        return F.log_softmax(self.l_out(outp), dim=-1).view(-1, self.vocab_size)

    def init_hidden(self, bs): 
        self.h = V(torch.zeros(1, bs, n_hidden))
  • 构造函数中的一个额外行。self.init_hidden(bs)self.h 设置为一堆零。

  • 问题 #1 — 如果我们简单地执行 self.h = h,并在一个包含一百万个字符的文档上进行训练,那么 RNN 的展开版本的大小将有一百万层(椭圆)。一百万层全连接网络将非常占用内存,因为为了进行链式规则,我们必须在每个批次中乘以一百万层,同时记住所有一百万个梯度。

  • 为了避免这种情况,我们告诉它不时忘记它的历史。我们仍然可以记住状态(隐藏矩阵中的值)而不必记住如何到达那里的一切。

def repackage_var(h):
    return (
        Variable(h.data) 
        if type(h) == Variable 
        else tuple(repackage_var(v) for v in h)
    )
  • Variable h 中取出张量(记住,张量本身没有任何历史概念),并从中创建一个新的 Variable。新变量具有相同的值,但没有操作历史,因此当它尝试反向传播时,它将在那里停止。

  • forward将处理 8 个字符,然后通过 8 个层进行反向传播,跟踪隐藏状态中的值,但会丢弃其操作历史。这被称为时间反向传播(bptt)

  • 换句话说,在for循环之后,只需丢弃操作历史并重新开始。因此,我们保留了我们的隐藏状态,但没有保留我们的隐藏状态历史。

  • 不要通过太多层进行反向传播的另一个很好的理由是,如果您有任何梯度不稳定性(例如,梯度爆炸或梯度消失),您拥有的层数越多,网络训练就越困难(速度更慢,弹性更差)。

  • 另一方面,更长的bptt意味着您能够明确捕获更长的记忆和更多状态。

  • 皱纹#2[16:00] - 如何创建小批量。我们不想一次处理一个部分,而是一次并行处理一堆。

  • 当我们第一次开始研究 TorchText 时,我们谈到了它如何创建这些小批量。

  • Jeremy 说我们拿一整个由尼采的全部作品或所有 IMDB 评论连接在一起的长文档,将其分成 64 个相等大小的块(不是大小为 64 的块)。

  • 对于一个长度为 6400 万字符的文档,每个“块”将是 100 万个字符。我们将它们堆叠在一起,现在按bptt拆分它们 - 1 个小批次由 64 个bptt矩阵组成。

  • 第二块(第 100 万个字符)的第一个字符可能在一个句子的中间。但没关系,因为这只会在每一百万个字符中发生一次。

问题:这种数据集的数据增强?[20:34]

没有已知的好方法。最近有人通过进行数据增强赢得了一个 Kaggle 竞赛,随机插入不同行的部分 - 这样的方法可能在这里有用。但最近没有任何最先进的 NLP 论文在进行这种数据增强。

问题:我们如何选择 bptt 的大小?[21:36]

有几件事需要考虑:

  • 第一点是小批量矩阵的大小为bs(块数)乘以bptt,因此您的 GPU RAM 必须能够容纳嵌入矩阵。因此,如果您遇到 CUDA 内存不足错误,您需要减少其中一个。

  • 如果您的训练不稳定(例如,您的损失突然飙升到 NaN),那么您可以尝试减少您的bptt,因为您的层较少,梯度不会爆炸。

  • 如果速度太慢[22:44],尝试减少你的bptt,因为它会一次执行一个步骤。for循环不能并行化(对于当前版本)。最近有一种叫做 QRNN(准循环神经网络)的东西,它可以并行化,我们希望在第二部分中介绍。

  • 所以选择满足所有这些条件的最高数字。

有状态的 RNN 和 TorchText[23:23]

在使用期望数据符合特定格式的现有 API 时,您可以将数据更改为符合该格式,也可以编写自己的数据集子类来处理您的数据已经存在的格式。两者都可以,但在这种情况下,我们将把我们的数据放在 TorchText 已经支持的格式中。Fast.ai 对 TorchText 的包装器已经有了一些东西,您可以在每个路径中有一个训练路径和验证路径,并且每个路径中有一个或多个文本文件,其中包含一堆文本,这些文本被连接在一起用于您的语言模型。

from torchtext import vocab, data 
from fastai.nlp import * 
from fastai.lm_rnn import * 
PATH='data/nietzsche/' 
TRN_PATH = 'trn/' 
VAL_PATH = 'val/' 
TRN = f'{PATH}{TRN_PATH}' 
VAL = f'{PATH}{VAL_PATH}'
%ls {PATH}
'''
models/  nietzsche.txt  trn/  val/
'''
%ls {PATH}trn
'''
trn.txt
'''
  • 复制了尼采文件,粘贴到训练和验证目录中。然后从训练集中删除最后 20%的行,并删除验证集中除最后 20%之外的所有内容[25:15]。

  • 这样做的另一个好处是,似乎更现实地拥有一个验证集,它不是文本行的随机洗牌集,而是完全独立于语料库的一部分。

  • 当您进行语言模型时,您实际上不需要单独的文件。您可以有多个文件,但它们最终会被连接在一起。

TEXT = data.Field(lower=True, tokenize=list)
bs=64; bptt=8; n_fac=42; n_hidden=256

FILES = dict(train=TRN_PATH, validation=VAL_PATH, test=VAL_PATH)
md = LanguageModelData.from_text_files(PATH, TEXT, **FILES, bs=bs, bptt=bptt, min_freq=3)

len(md.trn_dl), md.nt, len(md.trn_ds), len(md.trn_ds[0].text)
'''
(963, 56, 1, 493747)
'''
  • 在 TorchText 中,我们创建了一个叫做Field的东西,最初Field只是关于如何进行文本预处理的描述。

  • lower - 我们告诉它将文本转换为小写

  • tokenize - 上次,我们使用了一个在空格上分割的函数,给我们一个单词模型。这次,我们想要一个字符模型,所以使用list函数来对字符串进行标记化。记住,在 Python 中,list('abc')将返回['a','b','c']

  • bs:批次大小,bptt:我们将其重命名为csn_fac:嵌入的大小,n_hidden:我们隐藏状态的大小

  • 我们没有单独的测试集,所以我们将只使用验证集进行测试

  • TorchText 每次都会稍微随机化bptt的长度。它并不总是给我们确切的 8 个字符;有 5%的概率,它会将其减半并添加一个小的标准偏差,使其略大或略小于 8。我们不能对数据进行洗牌,因为它需要是连续的,所以这是引入一些随机性的一种方式。

  • 问题:每个小批次的大小是否保持恒定?是的,我们需要用h权重矩阵进行矩阵乘法,因此小批次的大小必须保持恒定。但是序列长度可以改变,没有问题。

  • len(md.trn_dl):数据加载器的长度(即有多少个小批次),md.nt:标记的数量(即词汇表中有多少个唯一的东西)

  • 一旦运行LanguageModelData.from_text_filesTEXT将包含一个名为vocab的额外属性。TEXT.vocab.itos是词汇表中唯一项目的列表,TEXT.vocab.stoi是从每个项目到数字的反向映射。

class CharSeqStatefulRnn(nn.Module):
    def __init__(self, vocab_size, n_fac, bs):
        self.vocab_size = vocab_size
        super().__init__()
        self.e = nn.Embedding(vocab_size, n_fac)
        self.rnn = nn.RNN(n_fac, n_hidden)
        self.l_out = nn.Linear(n_hidden, vocab_size)
        self.init_hidden(bs)

    def forward(self, cs):
        bs = cs[0].size(0)
        if self.h.size(1) != bs: 
            self.init_hidden(bs)
        outp,h = self.rnn(self.e(cs), self.h)
        self.h = repackage_var(h)
        return F.log_softmax(self.l_out(outp), dim=-1).view(-1, self.vocab_size)

    def init_hidden(self, bs): 
        self.h = V(torch.zeros(1, bs, n_hidden))
  • 问题 #3:Jeremy 在说小批次大小保持恒定时对我们撒谎了。最后一个小批次很可能比其他小批次短,除非数据集恰好可以被bptt乘以bs整除。这就是为什么我们要检查self.h的第二维是否与输入的bs相同。如果不相同,将其设置回零,并使用输入的bs。这发生在周期结束和周期开始时(将其设置回完整的批次大小)。

  • 问题 #4:最后一个问题是关于 PyTorch 的一个小问题,也许有人可以友好地尝试通过 PR 来修复它。损失函数不喜欢接收一个三维张量(即三维数组)。它们不应该不喜欢接收一个三维张量(按序列长度、批次大小和结果计算损失 - 因此您可以为两个初始轴的每个计算损失)。对于二维或四维张量可以工作,但对于三维张量不行。

  • .view将三维张量重塑为二维的-1(必要时尽可能大)乘以vocab_size。TorchText 自动将目标展平,因此我们不需要为实际值这样做(当我们在第 4 课看到一个小批次时,我们注意到它被展平了。Jeremy 说我们以后会了解原因,现在就是时候了)。

  • PyTorch(截至 0.3 版),log_softmax要求我们指定我们要对 softmax 进行的轴(即我们要将其求和为 1 的轴)。在这种情况下,我们希望在最后一个轴dim = -1上进行。

m = CharSeqStatefulRnn(md.nt, n_fac, 512).cuda() 
opt = optim.Adam(m.parameters(), 1e-3)
fit(m, md, 4, opt, F.nll_loss)

让我们通过拆解 RNN 来获得更多见解

我们移除了nn.RNN的使用,并用nn.RNNCell替换。PyTorch 源代码如下。您应该能够阅读和理解(注意:它们不会连接输入和隐藏状态,而是将它们相加 - 这是我们的第一种方法):

def RNNCell(input, hidden, w_ih, w_hh, b_ih, b_hh):
    return F.tanh(F.linear(input, w_ih, b_ih) + F.linear(hidden, w_hh, b_hh))

关于tanh的问题[44:06]:正如我们上周所看到的,tanh强制值在-1 和 1 之间。由于我们一遍又一遍地乘以这个权重矩阵,我们担心relu(因为它是无界的)可能会有更多的梯度爆炸问题。话虽如此,您可以指定RNNCell使用不同的nonlineality,其默认值为tanh,并要求其使用relu

class CharSeqStatefulRnn2(nn.Module):
    def __init__(self, vocab_size, n_fac, bs):
        super().__init__()
        self.vocab_size = vocab_size
        self.e = nn.Embedding(vocab_size, n_fac)
        self.rnn = nn.RNNCell(n_fac, n_hidden)
        self.l_out = nn.Linear(n_hidden, vocab_size)
        self.init_hidden(bs)

    def forward(self, cs):
        bs = cs[0].size(0)
        if self.h.size(1) != bs: 
            self.init_hidden(bs)
        outp = []
        o = self.h
        for c in cs: 
            o = self.rnn(self.e(c), o)
            outp.append(o)
        outp = self.l_out(torch.stack(outp))
        self.h = repackage_var(o)
        return F.log_softmax(outp, dim=-1).view(-1, self.vocab_size)

    def init_hidden(self, bs): 
        self.h = V(torch.zeros(1, bs, n_hidden))
  • for循环回来并将线性函数的结果附加到列表中 - 最终将它们堆叠在一起。

  • 实际上,fast.ai 库确实正是为了使用 PyTorch 不支持的正则化方法而这样做的。

门控循环单元(GRU)[46:44]

在实践中,没有人真正使用RNNCell,因为即使使用tanh,梯度爆炸仍然是一个问题,我们需要使用较低的学习率和较小的bptt来训练它们。因此,我们所做的是用类似GRUCell替换RNNCell

www.wildml.com/2015/10/recurrent-neural-network-tutorial-part-4-implementing-a-grulstm-rnn-with-python-and-theano/

  • 通常,输入会乘以一个权重矩阵以创建新的激活h,并立即添加到现有的激活中。这里不是这样发生的。

  • 输入进入,它不仅仅被添加到先前的激活中,而是先前的激活被r(重置门)乘以,r的值为 0 或 1。

  • r的计算如下 - 一些权重矩阵的矩阵乘法和我们先前隐藏状态和新输入的连接。换句话说,这是一个小型的单隐藏层神经网络。它也通过 sigmoid 函数传递。这个小型神经网络学会了确定要记住隐藏状态的多少(也许在看到句号字符时全部忘记 - 新句子的开始)。

  • z门(更新门)确定要使用(隐藏状态的新输入版本)的程度,以及要保持隐藏状态与之前相同的程度。

colah.github.io/posts/2015-08-Understanding-LSTMs/

  • 线性插值
def GRUCell(input, hidden, w_ih, w_hh, b_ih, b_hh):
    gi = F.linear(input, w_ih, b_ih)
    gh = F.linear(hidden, w_hh, b_hh)
    i_r, i_i, i_n = gi.chunk(3, 1)
    h_r, h_i, h_n = gh.chunk(3, 1)

    resetgate = F.sigmoid(i_r + h_r)
    inputgate = F.sigmoid(i_i + h_i)
    newgate = F.tanh(i_n + resetgate * h_n)
    return newgate + inputgate * (hidden - newgate)

上面是GRUCell代码的样子,我们利用这个新模型如下:

class CharSeqStatefulGRU(nn.Module):
    def __init__(self, vocab_size, n_fac, bs):
        super().__init__()
        self.vocab_size = vocab_size
        self.e = nn.Embedding(vocab_size, n_fac)
        self.rnn = nn.GRU(n_fac, n_hidden)
        self.l_out = nn.Linear(n_hidden, vocab_size)
        self.init_hidden(bs)

    def forward(self, cs):
        bs = cs[0].size(0)
        if self.h.size(1) != bs: 
            self.init_hidden(bs)
        outp,h = self.rnn(self.e(cs), self.h)
        self.h = repackage_var(h)
        return F.log_softmax(self.l_out(outp), dim=-1).view(-1, self.vocab_size)

    def init_hidden(self, bs): 
        self.h = V(torch.zeros(1, bs, n_hidden))

结果,我们可以将损失降低到 1.36(RNNCell为 1.54)。在实践中,GRU 和 LSTM 是人们使用的。

将所有内容放在一起:长短期记忆[54:09]

LSTM 中还有一个称为“单元状态”的状态(不仅仅是隐藏状态),因此如果使用 LSTM,必须在init_hidden中返回一个矩阵元组(与隐藏状态完全相同的大小):

from fastai import sgdr

n_hidden=512
class CharSeqStatefulLSTM(nn.Module):
    def __init__(self, vocab_size, n_fac, bs, nl):
        super().__init__()
        self.vocab_size,self.nl = vocab_size,nl
        self.e = nn.Embedding(vocab_size, n_fac)
        self.rnn = nn.LSTM(n_fac, n_hidden, nl, dropout=0.5)
        self.l_out = nn.Linear(n_hidden, vocab_size)
        self.init_hidden(bs)

    def forward(self, cs):
        bs = cs[0].size(0)
        if self.h[0].size(1) != bs: 
            self.init_hidden(bs)
        outp,h = self.rnn(self.e(cs), self.h)
        self.h = repackage_var(h)
        return F.log_softmax(self.l_out(outp), dim=-1).view(-1, self.vocab_size)

    def init_hidden(self, bs):
        self.h = (V(torch.zeros(self.nl, bs, n_hidden)),
                  V(torch.zeros(self.nl, bs, n_hidden)))

代码与 GRU 相同。添加的一件事是dropout,它在每个时间步之后进行 dropout 并将隐藏层加倍 - 希望它能够学到更多并且在这样做时更具弹性。

回调(特别是 SGDR)没有 Learner 类[55:23]

m = CharSeqStatefulLSTM(md.nt, n_fac, 512, 2).cuda()
lo = LayerOptimizer(optim.Adam, m, 1e-2, 1e-5)
  • 创建标准的 PyTorch 模型后,我们通常会做类似opt = optim.Adam(m.parameters(), 1e-3)的事情。相反,我们将使用 fast.ai 的LayerOptimizer,它接受一个优化器optim.Adam,我们的模型m,学习率1e-2,以及可选的权重衰减1e-5

  • LayerOptimizer存在的一个关键原因是进行差分学习率和差分权重衰减。我们需要使用它的原因是 fast.ai 内部的所有机制都假定您有其中之一。如果要在不使用 Learner 类的代码中使用回调或 SGDR,您需要使用这个。

  • lo.opt返回优化器。

on_end = lambda sched, cycle: 
save_model(m, f'{PATH}models/cyc_{cycle}')
cb = [CosAnneal(lo, len(md.trn_dl), cycle_mult=2, on_cycle_end=on_end)]
fit(m, md, 2**4-1, lo.opt, F.nll_loss, callbacks=cb)
  • 当我们调用fit时,现在可以传递LayerOptimizercallbacks

  • 在这里,我们使用余弦退火回调 —— 需要一个LayerOptimizer对象。它通过更改lo对象内的学习率来进行余弦退火。

  • 概念:创建一个余弦退火回调,它将更新层优化器lo中的学习率。一个周期的长度等于len(md.trn_dl) —— 一个周期中有多少个小批次就是数据加载器的长度。由于它正在进行余弦退火,它需要知道多久重置一次。您可以以通常的方式传递cycle_mult。我们甚至可以自动保存我们的模型,就像我们在Learner.fit中使用cycle_save_name一样。

  • 我们可以在训练、周期或批处理的开始时进行回调,也可以在训练、周期或批处理的结束时进行回调。

  • 它已用于CosAnneal(SGDR),和解耦权重衰减(AdamW),随时间变化的损失图等。

测试[59:55]

def get_next(inp):
    idxs = TEXT.numericalize(inp)
    p = m(VV(idxs.transpose(0,1)))
    r = torch.multinomial(p[-1].exp(), 1)
    return TEXT.vocab.itos[to_np(r)[0]]
def get_next_n(inp, n):
    res = inp
    for i in range(n):
        c = get_next(inp)
        res += c
        inp = inp[1:]+c
    return resprint(get_next_n('for thos', 400))
'''
for those the skemps), or imaginates, though they deceives. it should so each ourselvess and new present, step absolutely for the science." the contradity and measuring,  the whole!* *293\. perhaps, that every life a values of blood of intercourse when it senses there is unscrupulus, his very rights, and still impulse, love? just after that thereby how made with the way anything, and set for harmless philos
'''
  • 在第 6 课中,当我们测试CharRnn模型时,我们注意到它一遍又一遍地重复。在这个新版本中使用的torch.multinomial处理了这个问题。p[-1]用于获取最终输出(三角形),exp用于将对数概率转换为概率。然后我们使用torch.multinomial函数,根据给定的概率给出一个样本。如果概率是[0, 1, 0, 0],并要求它给我们一个样本,它将始终返回第二个项目。如果是[0.5, 0, 0.5],它将 50%的时间给出第一个项目,50%的时间给出第二个项目(多项分布的评论

  • 要尝试训练基于字符的语言模型,可以尝试在不同损失水平上运行get_next_n,以了解其外观。上面的示例是 1.25,但在 1.3 时,它看起来像一团垃圾。

  • 当您在玩弄 NLP 时,特别是像这样的生成模型,并且结果还可以但不是很好时,请不要灰心,因为这意味着您实际上非常非常接近成功!

返回计算机视觉:CIFAR 10 [1:01:58]

CIFAR 10 是学术界中一个古老而著名的数据集 —— 在 ImageNet 之前,有 CIFAR 10。它在图像数量和大小方面都很小,这使得它既有趣又具有挑战性。您可能会处理成千上万张图像,而不是一百五十万张图像。此外,我们正在研究的许多内容,比如在医学成像中,我们正在查看一个肺结节的特定区域,您可能最多查看 32x32 像素。

它也运行得很快,因此最好测试一下您的算法。正如 Ali Rahini 在 NIPS 2017 中提到的,Jeremy 担心许多人在深度学习中没有进行精心调整和深思熟虑的实验,而是他们投入大量的 GPU 和 TPU 或大量的数据,然后认为一天就够了。在像 CIFAR 10 这样的数据集上测试您的算法的许多版本是很重要的,而不是像 ImageNet 那样需要几周的时间。尽管人们倾向于抱怨 MNIST,但它也适用于研究和实验。

CIFAR 10 数据以图像格式可在此处获取

from fastai.conv_learner import *
PATH = "data/cifar10/"
os.makedirs(PATH,exist_ok=True)
classes = ('plane', 'car', 'bird', 'cat', 'deer', 'dog', 'frog', 'horse', 'ship', 'truck')
stats = (np.array([ 0.4914 ,  0.48216,  0.44653]), np.array([ 0.24703,  0.24349,  0.26159]))
def get_data(sz,bs):
     tfms = tfms_from_stats(stats, sz, aug_tfms=[RandomFlipXY()], pad=sz//8)
     return ImageClassifierData.from_paths(PATH, val_name='test', tfms=tfms, bs=bs)
bs=256
  • classes — 图像标签

  • stats — 当我们使用预训练模型时,可以调用tfms_from_model,它会创建必要的转换,将我们的数据集转换为基于原始模型中每个通道的均值和标准差的归一化数据集。由于我们正在从头开始训练模型,因此需要告诉它我们数据的均值和标准差以进行归一化。确保您可以计算每个通道的均值和标准差。

  • tfms — 对于 CIFAR 10 数据增强,人们通常会进行水平翻转和在边缘周围添加黑色填充,并在填充图像内随机选择 32x32 区域。

data = get_data(32,bs)

lr=1e-2

来自我们的学生 Kerem Turgutlu 的这个笔记本

class SimpleNet(nn.Module):
    def __init__(self, layers):
        super().__init__()
        self.layers = nn.ModuleList([
            nn.Linear(layers[i], layers[i + 1]) 
            for i in range(len(layers) - 1)
        ])

    def forward(self, x):
        x = x.view(x.size(0), -1)
        for l in self.layers:
            l_x = l(x)
            x = F.relu(l_x)
        return F.log_softmax(l_x, dim=-1)
  • nn.ModuleList - 每当您在 PyTorch 中创建一组层时,您必须将其包装在 ModuleList 中以将这些注册为属性。
learn = ConvLearner.from_model_data(SimpleNet([32*32*3, 40,10]), data)
  • 现在我们提高一个 API 级别 - 而不是调用 fit 函数,我们从一个自定义模型创建一个 learn 对象。ConfLearner.from_model_data 接受标准的 PyTorch 模型和模型数据对象。
learn, [o.numel() for o in learn.model.parameters()]
'''
(SimpleNet(
   (layers): ModuleList(
     (0): Linear(in_features=3072, out_features=40)
     (1): Linear(in_features=40, out_features=10)
   )
 ), [122880, 40, 400, 10])
 '''
 learn.summary()
 '''
 OrderedDict([('Linear-1',
              OrderedDict([('input_shape', [-1, 3072]),
                           ('output_shape', [-1, 40]),
                           ('trainable', True),
                           ('nb_params', 122920)])),
             ('Linear-2',
              OrderedDict([('input_shape', [-1, 40]),
                           ('output_shape', [-1, 10]),
                           ('trainable', True),
                           ('nb_params', 410)]))])
'''
learn.lr_find()
learn.sched.plot()

%time learn.fit(lr, 2)
'''
A Jupyter Widget
[ 0\.       1.7658   1.64148  0.42129]                       
[ 1\.       1.68074  1.57897  0.44131]                       

CPU times: user 1min 11s, sys: 32.3 s, total: 1min 44s
Wall time: 55.1 s
'''
%time learn.fit(lr, 2, cycle_len=1)
'''
A Jupyter Widget
[ 0\.       1.60857  1.51711  0.46631]                       
[ 1\.       1.59361  1.50341  0.46924]                       

CPU times: user 1min 12s, sys: 31.8 s, total: 1min 44s
Wall time: 55.3 s
'''

通过一个具有 122,880 个参数的简单单隐藏层模型,我们实现了 46.9%的准确率。让我们改进这一点,并逐渐构建一个基本的 ResNet 架构。

CNN [01:12:30]

  • 让我们用一个卷积模型替换一个全连接模型。全连接层只是做一个点积。这就是为什么权重矩阵很大(3072 个输入 * 40 = 122880)。我们没有有效地使用参数,因为输入中的每个像素都有不同的权重。我们想要做的是一组具有特定模式的 3x3 像素(即卷积)。

  • 我们将使用一个 3x3 核的滤波器。当有多个滤波器时,输出将具有额外的维度。

class ConvNet(nn.Module):
    def __init__(self, layers, c):
        super().__init__()
        self.layers = nn.ModuleList([
            nn.Conv2d(layers[i], layers[i + 1], kernel_size=3, stride=2)
            for i in range(len(layers) - 1)
        ])
        self.pool = nn.AdaptiveMaxPool2d(1)
        self.out = nn.Linear(layers[-1], c)

    def forward(self, x):
        for l in self.layers: x = F.relu(l(x))
        x = self.pool(x)
        x = x.view(x.size(0), -1)
        return F.log_softmax(self.out(x), dim=-1)
  • nn.Conv2d 替换 nn.Linear

  • 前两个参数与 nn.Linear 完全相同 - 输入特征的数量和输出特征的数量

  • kernel_size=3,滤波器的大小

  • stride=2 将使用每隔一个 3x3 区域,这将使每个维度的输出分辨率减半(即具有与 2x2 最大池化相同的效果)

learn = ConvLearner.from_model_data(ConvNet([3, 20, 40, 80], 10), data)
learn.summary()
'''
OrderedDict([('Conv2d-1',
              OrderedDict([('input_shape', [-1, 3, 32, 32]),
                           ('output_shape', [-1, 20, 15, 15]),
                           ('trainable', True),
                           ('nb_params', 560)])),
             ('Conv2d-2',
              OrderedDict([('input_shape', [-1, 20, 15, 15]),
                           ('output_shape', [-1, 40, 7, 7]),
                           ('trainable', True),
                           ('nb_params', 7240)])),
             ('Conv2d-3',
              OrderedDict([('input_shape', [-1, 40, 7, 7]),
                           ('output_shape', [-1, 80, 3, 3]),
                           ('trainable', True),
                           ('nb_params', 28880)])),
             ('AdaptiveMaxPool2d-4',
              OrderedDict([('input_shape', [-1, 80, 3, 3]),
                           ('output_shape', [-1, 80, 1, 1]),
                           ('nb_params', 0)])),
             ('Linear-5',
              OrderedDict([('input_shape', [-1, 80]),
                           ('output_shape', [-1, 10]),
                           ('trainable', True),
                           ('nb_params', 810)]))])
'''
  • ConvNet([3, 20, 40, 80], 10) - 从 3 个 RGB 通道开始,20、40、80 个特征,然后预测 10 个类别。

  • AdaptiveMaxPool2d - 这是一个线性层后面的内容,通过这种方式,你可以从 3x3 降到 10 个类别中的一个预测,并且现在已经成为最先进算法的标准。在最后一层,我们进行一种特殊类型的最大池化,您需要指定输出激活分辨率,而不是要池化的区域有多大。换句话说,在这里我们进行 3x3 最大池化,相当于 1x1 的自适应最大池化。

  • x = x.view(x.size(0), -1) - x 的形状是特征的数量乘以 1 乘以 1,因此它将删除最后两层。

  • 这个模型被称为“完全卷积网络” - 每一层都是卷积的,除了最后一层。

learn.lr_find(end_lr=100)
learn.sched.plot()

  • lr_find 尝试的默认最终学习率是 10。如果在那一点上损失仍在变好,您可以通过指定 end_lr 来覆盖。
%time learn.fit(1e-1, 2)
'''
A Jupyter Widget

[ 0\.       1.72594  1.63399  0.41338]                       
[ 1\.       1.51599  1.49687  0.45723]                       

CPU times: user 1min 14s, sys: 32.3 s, total: 1min 46s
Wall time: 56.5 s
'''
%time learn.fit(1e-1, 4, cycle_len=1)
'''
A Jupyter Widget
[ 0\.       1.36734  1.28901  0.53418]                       
[ 1\.       1.28854  1.21991  0.56143]                       
[ 2\.       1.22854  1.15514  0.58398]                       
[ 3\.       1.17904  1.12523  0.59922]                       

CPU times: user 2min 21s, sys: 1min 3s, total: 3min 24s
Wall time: 1min 46s
'''
  • 准确率在 60%左右稳定下来。考虑到它使用约 30,000 个参数(与 122k 参数的 47%相比)

  • 每个时期的时间大约相同,因为它们的架构都很简单,大部分时间都花在内存传输上。

重构 [01:21:57]

通过创建 ConvLayer(我们的第一个自定义层)简化 forward 函数。在 PyTorch 中,层定义和神经网络定义是相同的。每当您有一个层时,您可以将其用作神经网络,当您有一个神经网络时,您可以将其用作层。

class ConvLayer(nn.Module):
    def __init__(self, ni, nf):
        super().__init__()
        self.conv = nn.Conv2d(ni, nf, kernel_size=3, stride=2, padding=1)

    def forward(self, x): 
        return F.relu(self.conv(x))
  • padding=1 - 当进行卷积时,图像的每一侧都会缩小 1 个像素。因此,它不是从 32x32 到 16x16,而实际上是 15x15。padding 将添加一个边框,以便我们可以保留边缘像素信息。对于大图像来说,这不是一个大问题,但当缩小到 4x4 时,您真的不想丢弃整个部分。
class ConvNet2(nn.Module):
    def __init__(self, layers, c):
        super().__init__()
        self.layers = nn.ModuleList([
            ConvLayer(layers[i], layers[i + 1])
            for i in range(len(layers) - 1)
        ])
        self.out = nn.Linear(layers[-1], c)

    def forward(self, x):
        for l in self.layers: x = l(x)
        x = F.adaptive_max_pool2d(x, 1)
        x = x.view(x.size(0), -1)
        return F.log_softmax(self.out(x), dim=-1)
  • 与上一个模型的另一个不同之处是 nn.AdaptiveMaxPool2d 没有任何状态(即没有权重)。因此,我们可以将其作为一个函数 F.adaptive_max_pool2d 调用。

BatchNorm [1:25:10]

  • 最后一个模型,当我们尝试添加更多层时,我们遇到了训练困难。我们遇到训练困难的原因是,如果使用更大的学习率,它会变成 NaN,如果使用更小的学习率,它将花费很长时间,无法正确探索 - 因此它不具有弹性。

  • 为了使其具有弹性,我们将使用一种称为批量归一化的东西。 BatchNorm 大约两年前出现,自那时以来,它已经发生了很大变化,因为它突然使训练更深的网络变得非常容易。

  • 我们可以简单地使用nn.BatchNorm,但为了了解它,我们将从头开始编写。

  • 平均来看,权重矩阵不太可能导致激活不断变小或不断变大。保持它们在合理的范围内很重要。因此,我们从零均值标准差为 1 开始通过对输入进行归一化。我们真正想要做的是对所有层进行这样的操作,而不仅仅是对输入。

class BnLayer(nn.Module):
    def __init__(self, ni, nf, stride=2, kernel_size=3):
        super().__init__()
        self.conv = nn.Conv2d(
            ni, nf, 
            kernel_size=kernel_size, 
            stride=stride, 
            bias=False, 
            padding=1
        )
        self.a = nn.Parameter(torch.zeros(nf,1,1))
        self.m = nn.Parameter(torch.ones(nf,1,1))

    def forward(self, x):
        x = F.relu(self.conv(x))
        x_chan = x.transpose(0,1).contiguous().view(x.size(1), -1)
        if self.training:
           self.means = x_chan.mean(1)[:,None,None]
           self.stds  = x_chan.std (1)[:,None,None]
        return (x-self.means) / self.stds *self.m + self.a
  • 计算每个通道或每个滤波器的均值和每个通道或每个滤波器的标准差。然后减去均值并除以标准差。

  • 我们不再需要归一化我们的输入,因为它是按通道归一化的,或者对于后续层,它是按滤波器归一化的。

  • 事实证明这还不够,因为 SGD 是固执的。如果 SGD 决定要使矩阵整体变大/变小,那么做(x=self.means) / self.stds是不够的,因为 SGD 会撤消它,并尝试在下一个小批次中再次执行。因此,我们将添加两个参数:a - 加法器(初始值为零)和m - 乘法器(初始值为 1)用于每个通道。

  • Parameter告诉 PyTorch 可以将这些作为权重进行学习。

  • 为什么这样做?如果要扩展该层,它不必扩展矩阵中的每个值。如果要将其全部上移或下移一点,它不必移动整个权重矩阵,它们只需移动这三个数字self.m。直觉:我们正在对数据进行归一化,然后我们说您可以使用远少于实际需要的参数来移动和缩放它,而不是移动和缩放整套卷积滤波器。在实践中,它允许我们增加学习速率,增加训练的弹性,并且允许我们添加更多层并仍然有效地进行训练。

  • 批量归一化的另一件事是正则化,换句话说,您通常可以减少或删除辍学或权重衰减。原因是每个小批次将具有不同的均值和不同的标准差与上一个小批次不同。因此它们不断变化,以微妙的方式改变滤波器的含义,起到噪声(即正则化)的作用。

  • 在真实版本中,它不使用这个批次的均值和标准差,而是采用指数加权移动平均标准差和均值。

  • **if** self.training - 这很重要,因为当您通过验证集时,您不希望更改模型的含义。有一些类型的层实际上对网络的模式敏感,无论它是处于训练模式还是评估/测试模式。当我们为 MovieLens 实现迷你网络时,存在一个错误,即在验证期间应用了辍学 - 这已经得到修复。在 PyTorch 中,有两种这样的层:辍学和批量归一化。nn.Dropout已经进行了检查。

  • 在 fast.ai 中的关键区别是,这些均值和标准差在训练模式下会得到更新,而在其他库中,只要您说“我在训练”,无论该层是否可训练,这些均值和标准差就会立即得到更新。对于预训练网络来说,这是一个糟糕的主意。如果您有一个针对批量归一化中这些均值和标准差的特定值进行预训练的网络,如果更改它们,就会改变这些预训练层的含义。在 fast.ai 中,默认情况下,如果您的层被冻结,它将不会触及这些均值和标准差。一旦您解冻它,它将开始更新它们,除非您设置learn.bn_freeze=True。实际上,这在处理与预训练模型非常相似的数据时似乎经常效果更好。

  • 您应该在哪里放置批量归一化层?我们稍后会详细讨论,但现在,在relu之后

消融研究

这是一个尝试打开和关闭模型不同部分以查看哪些部分产生哪些影响的过程,原始批量归一化论文中没有进行任何有效的消融。因此,缺失的一点是刚刚提出的这个问题——批量归一化放在哪里。这个疏忽导致了很多问题,因为原始论文实际上没有将其放在最佳位置。自那时以来,其他人已经弄清楚了这一点,当 Jeremy 向人们展示代码时,实际上放在更好位置的人们会说他的批量归一化放错了位置。

  • 尽量在每一层上都使用批量归一化。

  • 不要停止对数据进行归一化,这样使用您的数据的人就会知道您是如何对数据进行归一化的。其他库可能无法正确处理预训练模型的批量归一化,因此当人们开始重新训练时可能会出现问题。

class ConvBnNet(nn.Module):
    def __init__(self, layers, c):
        super().__init__()
        self.conv1 = nn.Conv2d(3, 10, kernel_size=5, stride=1, padding=2)
        self.layers = nn.ModuleList([
            BnLayer(layers[i], layers[i + 1])
            for i in range(len(layers) - 1)
        ])
        self.out = nn.Linear(layers[-1], c)

    def forward(self, x):
        x = self.conv1(x)
        for l in self.layers: x = l(x)
        x = F.adaptive_max_pool2d(x, 1)
        x = x.view(x.size(0), -1)
        return F.log_softmax(self.out(x), dim=-1)
  • 代码的其余部分类似——使用BnLayer而不是ConvLayer

  • 在开始时添加了一个单个卷积层,试图接近现代方法。它具有更大的内核大小和步幅为 1。基本思想是我们希望第一层具有更丰富的输入。它使用 5x5 区域进行卷积,这使它可以尝试在该 5x5 区域中找到更有趣更丰富的特征,然后输出更大的输出(在这种情况下,是 10x5x5 个滤波器)。通常是 5x5 或 7x7,甚至是 11x11 卷积,输出相当多的滤波器(例如 32 个滤波器)。

  • 由于padding = kernel_size — 1 / 2stride=1,输入大小与输出大小相同——只是有更多的滤波器。

  • 这是尝试创建更丰富的起点的好方法。

深度批量归一化

让我们增加模型的深度。我们不能只添加更多的步幅为 2 的层,因为每次都会将图像的大小减半。相反,在每个步幅为 2 的层之后,我们插入一个步幅为 1 的层。

class ConvBnNet2(nn.Module):
    def __init__(self, layers, c):
        super().__init__()
        self.conv1 = nn.Conv2d(3, 10, kernel_size=5, stride=1, padding=2)
        self.layers = nn.ModuleList([
            BnLayer(layers[i], layers[i+1])
            for i in range(len(layers) - 1)
        ])
        self.layers2 = nn.ModuleList([
            BnLayer(layers[i+1], layers[i + 1], 1)
            for i in range(len(layers) - 1)
        ])
        self.out = nn.Linear(layers[-1], c)

    def forward(self, x):
        x = self.conv1(x)
        for l,l2 in zip(self.layers, self.layers2):
            x = l(x)
            x = l2(x)
        x = F.adaptive_max_pool2d(x, 1)
        x = x.view(x.size(0), -1)
        return F.log_softmax(self.out(x), dim=-1)

learn = ConvLearner.from_model_data((ConvBnNet2([10, 20, 40, 80, 160], 10), data)
%time learn.fit(1e-2, 2)

'''
A Jupyter Widget
[ 0\.       1.53499  1.43782  0.47588]                       
[ 1\.       1.28867  1.22616  0.55537]                       

CPU times: user 1min 22s, sys: 34.5 s, total: 1min 56s
Wall time: 58.2 s
'''
%time learn.fit(1e-2, 2, cycle_len=1)
'''
A Jupyter Widget
[ 0\.       1.10933  1.06439  0.61582]                       
[ 1\.       1.04663  0.98608  0.64609]                       

CPU times: user 1min 21s, sys: 32.9 s, total: 1min 54s
Wall time: 57.6 s
'''

准确率与之前相同。现在深度为 12 层,即使对于批量归一化来说也太深了。可以训练 12 层深的卷积网络,但开始变得困难。而且似乎并没有太多帮助。

ResNet

class ResnetLayer(BnLayer):
    def forward(self, x): 
        return x + super().forward(x)
class Resnet(nn.Module):
    def __init__(self, layers, c):
        super().__init__()
        self.conv1 = nn.Conv2d(3, 10, kernel_size=5, stride=1, padding=2)
        self.layers = nn.ModuleList([
            BnLayer(layers[i], layers[i+1])
            for i in range(len(layers) - 1)
        ])
        self.layers2 = nn.ModuleList([
            ResnetLayer(layers[i+1], layers[i + 1], 1)
            for i in range(len(layers) - 1)
        ])
        self.layers3 = nn.ModuleList([
            ResnetLayer(layers[i+1], layers[i + 1], 1)
            for i in range(len(layers) - 1)
        ])
        self.out = nn.Linear(layers[-1], c)

    def forward(self, x):
        x = self.conv1(x)
        for l,l2,l3 in zip(self.layers, self.layers2, self.layers3):
            x = l3(l2(l(x)))
        x = F.adaptive_max_pool2d(x, 1)
        x = x.view(x.size(0), -1)
        return F.log_softmax(self.out(x), dim=-1)
  • ResnetLayer继承自BnLayer并覆盖forward

  • 然后添加一堆层,使其深度增加 3 倍,仍然可以很好地训练,只是因为x + super().forward(x)

learn = ConvLearner.from_model_data(Resnet([10, 20, 40, 80, 160], 10), data)wd=1e-5%time learn.fit(1e-2, 2, wds=wd)
'''
A Jupyter Widget
[ 0\.       1.58191  1.40258  0.49131]                       
[ 1\.       1.33134  1.21739  0.55625]                       

CPU times: user 1min 27s, sys: 34.3 s, total: 2min 1s
Wall time: 1min 3s
'''
%time learn.fit(1e-2, 3, cycle_len=1, cycle_mult=2, wds=wd)
'''
A Jupyter Widget
[ 0\.       1.11534  1.05117  0.62549]                       
[ 1\.       1.06272  0.97874  0.65185]                       
[ 2\.       0.92913  0.90472  0.68154]                        
[ 3\.       0.97932  0.94404  0.67227]                        
[ 4\.       0.88057  0.84372  0.70654]                        
[ 5\.       0.77817  0.77815  0.73018]                        
[ 6\.       0.73235  0.76302  0.73633]                        

CPU times: user 5min 2s, sys: 1min 59s, total: 7min 1s
Wall time: 3min 39s
'''
%time learn.fit(1e-2, 8, cycle_len=4, wds=wd)
'''
A Jupyter Widget
[ 0\.       0.8307   0.83635  0.7126 ]                        
[ 1\.       0.74295  0.73682  0.74189]                        
[ 2\.       0.66492  0.69554  0.75996]                        
[ 3\.       0.62392  0.67166  0.7625 ]                        
[ 4\.       0.73479  0.80425  0.72861]                        
[ 5\.       0.65423  0.68876  0.76318]                        
[ 6\.       0.58608  0.64105  0.77783]                        
[ 7\.       0.55738  0.62641  0.78721]                        
[ 8\.       0.66163  0.74154  0.7501 ]                        
[ 9\.       0.59444  0.64253  0.78106]                        
[ 10\.        0.53      0.61772   0.79385]                    
[ 11\.        0.49747   0.65968   0.77832]                    
[ 12\.        0.59463   0.67915   0.77422]                    
[ 13\.        0.55023   0.65815   0.78106]                    
[ 14\.        0.48959   0.59035   0.80273]                    
[ 15\.        0.4459    0.61823   0.79336]                    
[ 16\.        0.55848   0.64115   0.78018]                    
[ 17\.        0.50268   0.61795   0.79541]                    
[ 18\.        0.45084   0.57577   0.80654]                    
[ 19\.        0.40726   0.5708    0.80947]                    
[ 20\.        0.51177   0.66771   0.78232]                    
[ 21\.        0.46516   0.6116    0.79932]                    
[ 22\.        0.40966   0.56865   0.81172]                    
[ 23\.        0.3852    0.58161   0.80967]                    
[ 24\.        0.48268   0.59944   0.79551]                    
[ 25\.        0.43282   0.56429   0.81182]                    
[ 26\.        0.37634   0.54724   0.81797]                    
[ 27\.        0.34953   0.54169   0.82129]                    
[ 28\.        0.46053   0.58128   0.80342]                    
[ 29\.        0.4041    0.55185   0.82295]                    
[ 30\.        0.3599    0.53953   0.82861]                    
[ 31\.        0.32937   0.55605   0.82227]                    

CPU times: user 22min 52s, sys: 8min 58s, total: 31min 51s
Wall time: 16min 38s
'''

ResNet 块

return x + super().forward(x)

y = x + f(x)

其中x是来自上一层的预测,y是来自当前层的预测。重新排列公式,我们得到:公式重新排列

f(x) = y − x

差异y − x残差。残差是迄今为止我们计算的错误。这意味着尝试找到一组卷积权重,试图填补我们偏离的量。换句话说,我们有一个输入,我们有一个函数试图预测错误(即我们偏离的量)。然后我们将输入的错误预测量相加,然后再添加另一个错误预测量,然后重复这个过程,逐层放大到正确答案。这基于一种称为boosting的理论。

  • 完整的 ResNet 在将其添加回原始输入之前进行了两次卷积(我们这里只做了一次)。

  • 在每个块x = l3(l2(l(x)))中,其中一层不是ResnetLayer而是一个带有stride=2的标准卷积——这被称为“瓶颈层”。ResNet 不是卷积层,而是我们将在第 2 部分中介绍的不同形式的瓶颈块。

ResNet 2 [01:59:33]

在这里,我们增加了特征的大小并添加了 dropout。

class Resnet2(nn.Module):
    def __init__(self, layers, c, p=0.5):
        super().__init__()
        self.conv1 = BnLayer(3, 16, stride=1, kernel_size=7)
        self.layers = nn.ModuleList([
            BnLayer(layers[i], layers[i+1])
            for i in range(len(layers) - 1)
        ])
        self.layers2 = nn.ModuleList([
            ResnetLayer(layers[i+1], layers[i + 1], 1)
            for i in range(len(layers) - 1)
        ])
        self.layers3 = nn.ModuleList([
            ResnetLayer(layers[i+1], layers[i + 1], 1)
            for i in range(len(layers) - 1)
        ])
        self.out = nn.Linear(layers[-1], c)
        self.drop = nn.Dropout(p)

    def forward(self, x):
        x = self.conv1(x)
        for l,l2,l3 in zip(self.layers, self.layers2, self.layers3):
            x = l3(l2(l(x)))
        x = F.adaptive_max_pool2d(x, 1)
        x = x.view(x.size(0), -1)
        x = self.drop(x)
        return F.log_softmax(self.out(x), dim=-1)
        
learn = ConvLearner.from_model_data(Resnet2([**16, 32, 64, 128, 256**], 10, 0.2), data)
wd=1e-6
%time learn.fit(1e-2, 2, wds=wd)
%time learn.fit(1e-2, 3, cycle_len=1, cycle_mult=2, wds=wd)
%time learn.fit(1e-2, 8, cycle_len=4, wds=wd)
log_preds,y = learn.TTA()
preds = np.mean(np.exp(log_preds),0)
metrics.log_loss(y,preds), accuracy(preds,y)
'''
(0.44507397166057938, 0.84909999999999997)
'''

85%是 2012 年或 2013 年 CIFAR 10 的最新技术。如今,它已经达到了 97%,因此还有改进的空间,但所有都基于这些技术:

  • 更好的数据增强方法

  • 更好的正则化方法

  • 对 ResNet 进行一些调整

问题[02:01:07]:我们可以将“训练残差”方法应用于非图像问题吗?是的!但是它已经被其他地方忽略了。在 NLP 中,“transformer 架构”最近出现,并被证明是翻译的最新技术,并且其中有一个简单的 ResNet 结构。这种一般方法称为“跳过连接”(即跳过一层的想法),在计算机视觉中经常出现,但似乎没有其他人多使用,尽管它与计算机视觉无关。好机会!

狗与猫 [02:02:03]

回到狗和猫。我们将创建 resnet34(如果您对尾随数字的含义感兴趣,请参阅这里——只是不同的参数)。

PATH = "data/dogscats/"
sz = 224
arch = resnet34  # <-- Name of the function 
bs = 64
m = arch(pretrained=True) # Get a model w/ pre-trained weight loaded
m
'''
ResNet(
  (conv1): Conv2d (3, 64, kernel_size=(7, 7), stride=(2, 2), padding=(3, 3), bias=False)
  (bn1): BatchNorm2d(64, eps=1e-05, momentum=0.1, affine=True)
  (relu): ReLU(inplace)
  (maxpool): MaxPool2d(kernel_size=(3, 3), stride=(2, 2), padding=(1, 1), dilation=(1, 1))
  (**layer1**): Sequential(
    (0): BasicBlock(
      (conv1): Conv2d (64, 64, kernel_size=(3, 3), stride=(1, 1), padding=(1, 1), bias=False)
      (bn1): BatchNorm2d(64, eps=1e-05, momentum=0.1, affine=True)
      (relu): ReLU(inplace)
      (conv2): Conv2d (64, 64, kernel_size=(3, 3), stride=(1, 1), padding=(1, 1), bias=False)
      (bn2): BatchNorm2d(64, eps=1e-05, momentum=0.1, affine=True)
    )
    (1): BasicBlock(
      (conv1): Conv2d (64, 64, kernel_size=(3, 3), stride=(1, 1), padding=(1, 1), bias=False)
      (bn1): BatchNorm2d(64, eps=1e-05, momentum=0.1, affine=True)
      (relu): ReLU(inplace)
      (conv2): Conv2d (64, 64, kernel_size=(3, 3), stride=(1, 1), padding=(1, 1), bias=False)
      (bn2): BatchNorm2d(64, eps=1e-05, momentum=0.1, affine=True)
    )
    (2): BasicBlock(
      (conv1): Conv2d (64, 64, kernel_size=(3, 3), stride=(1, 1), padding=(1, 1), bias=False)
      (bn1): BatchNorm2d(64, eps=1e-05, momentum=0.1, affine=True)
      (relu): ReLU(inplace)
      (conv2): Conv2d (64, 64, kernel_size=(3, 3), stride=(1, 1), padding=(1, 1), bias=False)
      (bn2): BatchNorm2d(64, eps=1e-05, momentum=0.1, affine=True)
    )
  )
  (layer2): Sequential(
    (0): BasicBlock(
      (conv1): Conv2d (64, 128, kernel_size=(3, 3), stride=(2, 2), padding=(1, 1), bias=False)
      (bn1): BatchNorm2d(128, eps=1e-05, momentum=0.1, affine=True)
      (relu): ReLU(inplace)
      (conv2): Conv2d (128, 128, kernel_size=(3, 3), stride=(1, 1), padding=(1, 1), bias=False)
      (bn2): BatchNorm2d(128, eps=1e-05, momentum=0.1, affine=True)
      (downsample): Sequential(
        (0): Conv2d (64, 128, kernel_size=(1, 1), stride=(2, 2), bias=False)
        (1): BatchNorm2d(128, eps=1e-05, momentum=0.1, affine=True)
      )
    )
    (1): BasicBlock(
      (conv1): Conv2d (128, 128, kernel_size=(3, 3), stride=(1, 1), padding=(1, 1), bias=False)
      (bn1): BatchNorm2d(128, eps=1e-05, momentum=0.1, affine=True)
      (relu): ReLU(inplace)
      (conv2): Conv2d (128, 128, kernel_size=(3, 3), stride=(1, 1), padding=(1, 1), bias=False)
      (bn2): BatchNorm2d(128, eps=1e-05, momentum=0.1, affine=True)
    )
    (2): BasicBlock(
      (conv1): Conv2d (128, 128, kernel_size=(3, 3), stride=(1, 1), padding=(1, 1), bias=False)
      (bn1): BatchNorm2d(128, eps=1e-05, momentum=0.1, affine=True)
      (relu): ReLU(inplace)
      (conv2): Conv2d (128, 128, kernel_size=(3, 3), stride=(1, 1), padding=(1, 1), bias=False)
      (bn2): BatchNorm2d(128, eps=1e-05, momentum=0.1, affine=True)
    )
    (3): BasicBlock(
      (conv1): Conv2d (128, 128, kernel_size=(3, 3), stride=(1, 1), padding=(1, 1), bias=False)
      (bn1): BatchNorm2d(128, eps=1e-05, momentum=0.1, affine=True)
      (relu): ReLU(inplace)
      (conv2): Conv2d (128, 128, kernel_size=(3, 3), stride=(1, 1), padding=(1, 1), bias=False)
      (bn2): BatchNorm2d(128, eps=1e-05, momentum=0.1, affine=True)
    )
  )
  ...
  (avgpool): AvgPool2d(kernel_size=7, stride=7, padding=0, ceil_mode=False, count_include_pad=True)
  (fc): Linear(in_features=512, out_features=1000)
)
'''

我们的 ResNet 模型具有 Relu → BatchNorm。TorchVision 使用 BatchNorm → Relu。有三个不同版本的 ResNet 在流传,最好的是 PreAct (arxiv.org/pdf/1603.05027.pdf)。

  • 目前,最后一层有数千个特征,因为 ImageNet 有 1000 个特征,所以我们需要摆脱它。

  • 当您使用 fast.ai 的ConvLearner时,它会为您删除最后两层。fast.ai 用自适应平均池化和自适应最大池化替换AvgPool2d,并将两者连接在一起。

  • 对于这个练习,我们将做一个简单版本。

m = nn.Sequential(*children(m)[:-2], 
    nn.Conv2d(512, 2, 3, padding=1), 
    nn.AdaptiveAvgPool2d(1), 
    Flatten(), 
    nn.LogSoftmax()
)
  • 删除最后两层

  • 添加一个只有 2 个输出的卷积。

  • 进行平均池化然后进行 softmax

  • 最后没有线性层。这是产生两个数字的不同方式——这使我们能够进行 CAM!

tfms = tfms_from_model(arch, sz, aug_tfms=transforms_side_on, max_zoom=1.1)
data = ImageClassifierData.from_paths(PATH, tfms=tfms, bs=bs)
learn = ConvLearner.from_model_data(m, data)
learn.freeze_to(-4)
learn.fit(0.01, 1)
learn.fit(0.01, 1, cycle_len=1)
  • ConvLearner.from_model是我们之前学到的——允许我们使用自定义模型创建 Learner 对象。

  • 然后冻结除了我们刚刚添加的层之外的所有层。

类激活图(CAM)[02:08:55]

我们选择一个特定的图像,并使用一种称为 CAM 的技术,询问模型哪些部分的图像被证明是重要的。

它是如何做到的?让我们逆向工作。它是通过生成这个矩阵来做到的:

大数字对应于猫。那么这个矩阵是什么?这个矩阵简单地等于特征矩阵feat乘以py向量的值:

f2=np.dot(np.rollaxis(feat,0,3), py)
f2-=f2.min()
f2/=f2.max()
f2

py 向量是预测,表示“我对这是一只猫有 100%的信心”。feat 是最终卷积层(我们添加的Conv2d层)输出的值(2×7×7)。如果我们将feat乘以py,我们会得到所有第一个通道的值,而第二个通道的值为零。因此,它将返回与猫对应的部分的最后一个卷积层的值。换句话说,如果我们将feat乘以[0, 1],它将与狗对应。

sf = SaveFeatures(m[-4])
py = m(Variable(x.cuda()))
sf.remove()

py = np.exp(to_np(py)[0]); py
'''
array([ 1.,  0.], dtype=float32)
'''
feat = np.maximum(0, sf.features[0])
feat.shape

换句话说,在模型中,卷积层之后唯一发生的事情是平均池化层。平均池化层将 7×7 的网格平均化,计算出每个部分有多少“像猫”。然后,我们将“猫样”矩阵调整大小为与原始猫图像相同的大小,并叠加在顶部,然后你就得到了热图。

您可以在家中使用这种技术的方法是:

  1. 当您有一幅大图像时,您可以在一个快速小的卷积网络上计算这个矩阵。

  2. 放大具有最高值的区域

  3. 仅在该部分重新运行

由于时间不够,我们很快跳过了这部分,但我们将在第 2 部分中学习更多关于这种方法的内容。

“Hook”是让我们要求模型返回矩阵的机制。register_forward_hook要求 PyTorch 每次计算一个层时运行给定的函数 - 类似于每次计算一个层时发生的回调。在以下情况下,它保存了我们感兴趣的特定层的值:

class SaveFeatures():
    features=None
    def __init__(self, m): 
        self.hook = m.register_forward_hook(self.hook_fn)
    def hook_fn(self, module, input, output): 
        self.features = to_np(output)
    def remove(self): 
        self.hook.remove()

Jeremy 的问题[02:14:27]:“您对深度学习的探索”和“如何跟上从业者的重要研究”

“如果您打算参加第 2 部分,您应该掌握我们在第 1 部分学到的所有技术”。以下是您可以做的一些事情:

  1. 至少观看每个视频 3 次。

  2. 确保您可以重新创建笔记本而无需观看视频 - 可能使用不同的数据集来使其更有趣。

  3. 密切关注论坛上的最新论文和最新进展。

  4. 坚持不懈,继续努力!

深度学习 2:第 2 部分第 8 课

原文:medium.com/@hiromi_suenaga/deep-learning-2-part-2-lesson-8-5ae195c49493

译者:飞龙

协议:CC BY-NC-SA 4.0

来自 fast.ai 课程的个人笔记。随着我继续复习课程以“真正”理解它,这些笔记将继续更新和改进。非常感谢 JeremyRachel 给了我这个学习的机会。

目标检测

论坛 / 视频 / 笔记本 / 幻灯片

我们在第一部分中涵盖的内容 [02:00]

可微分层 [02:11]

Yann LeCun 一直在推广这样一个观点,即我们不称之为“深度学习”,而是“可微分编程”。我们在第一部分所做的一切实际上都是关于建立一个可微分函数和一个描述参数好坏的损失函数,然后按下开始按钮,它就开始工作了。如果你能配置一个评分函数来评估某个任务的表现,并且有一个相当灵活的神经网络架构,那么你就完成了。

是的,可微分编程只不过是现代深度学习技术的一个重新包装,就像深度学习是神经网络的现代化版本,具有超过两层的层次一样。

重要的一点是,人们现在通过组装参数化的功能块网络,并通过使用某种形式的基于梯度的优化从示例中训练它们来构建一种新型软件……这实际上非常类似于常规程序,只是它是参数化的,自动区分的,可训练/可优化的。

2. 迁移学习 [03:23]

迁移学习是有效使用深度学习的最重要的单一事项。你几乎永远不会想要或需要从随机权重开始,除非没有人曾经在一个大致相似的数据集上训练过一个与你正在解决的问题有一定联系的模型 — 这几乎不会发生。Fastai 库专注于迁移学习,这使它与其他库不同。迁移学习的基本思想是:

  • 给定一个执行 A 任务的网络,移除最后一层。

  • 在最后随机添加几个层

  • 微调这些层以执行 B 任务,同时利用原始网络学到的特征

  • 然后选择性地对整个模型进行微调,现在你有了一个可能使用数量级更少的数据,更准确,训练速度更快的东西。

3. 架构设计 [05:17]

通常有一小范围的架构通常在很多时候都表现得相当不错。我们一直专注于使用 CNN 处理通常大小固定的有序数据,RNN 处理具有某种状态的序列。我们还稍微调整了一下激活函数 — 如果有单一分类结果,则使用 softmax,如果有多个结果,则使用 sigmoid。我们将在第 2 部分研究的一些架构设计更有趣。特别是关于目标检测的这个第一个会话。但总的来说,我们可能花更少的时间讨论架构设计,因为这通常不是难点。

4. 处理过拟合 [06:26]

Jeremy 喜欢构建模型的方式:

  • 创建一些明显过度参数化的东西,肯定会过度拟合,训练它并确保它确实过拟合。在那一点上,你已经有了一个能够反映训练集的模型。然后只需做这些事情来减少过拟合。

如果你不从一个过拟合的地方开始,你就会迷失。所以你从一个过拟合的地方开始,为了让它过拟合得更少,你可以:

  • 添加更多数据

  • 添加更多数据增强

  • 做一些像更多的批量归一化层、稠密网络,或者各种可以处理更少数据的东西。

  • 添加正则化,如权重衰减和丢失

  • 最后(这通常是人们首先做的事情,但这应该是你最后做的事情)减少你的架构复杂性。减少层数或激活数量。

  1. 嵌入[07:46]

我们已经谈了很多关于嵌入 - 无论是用于自然语言处理还是任何种类的分类数据,现在你可以用神经网络来建模。就在今年初,几乎没有关于在深度学习中使用表格数据的例子,但现在越来越多的人开始使用神经网络来进行时间序列和表格数据分析。

第一部分到第二部分[08:54]

第一部分真的是关于引入深度学习的最佳实践。我们看到的技术已经足够成熟,可以相对可靠地应用于实际的现实世界问题。Jeremy 经过相当长一段时间的研究和调整,提出了一系列步骤、架构等,并将它们快速、轻松地放入 fastai 库中。

第二部分是面向程序员的前沿深度学习,这意味着 Jeremy 通常不知道确切的最佳参数、架构细节等来解决特定问题。我们不一定知道它是否能够解决问题到足够实用的程度。它几乎肯定不会被很好地整合到 fastai 或任何其他库中,你不能只按几个按钮就开始工作。Jeremy 不会教授它,除非他非常有信心,要么现在就是,要么很快就会成为非常实用的技术。但通常需要大量的调整和实验才能使其在你的特定问题上工作,因为我们不知道足够的细节来知道如何使其适用于每个数据集或每个示例。

这意味着与 Fastai 和 PyTorch 成为你只知道这些配方的晦涩黑匣子不同,你将学会足够了解它们的细节,以便可以按照自己的意愿定制它们,可以调试它们,可以阅读它们的源代码以了解发生了什么。如果你对面向对象的 Python 不自信,那么这是你在本课程中要专注学习的内容,因为我们不会在课堂上涵盖它。但 Jeremy 会介绍一些他认为特别有帮助的工具,比如 Python 调试器,如何使用你的编辑器跳转到代码中。总的来说,将会有更多详细和具体的代码演示,编码技术讨论,以及更详细的论文演示。

注意示例代码[13:20]!学术界提供的代码与论文配套或其他人在 github 上编写的示例代码,Jeremy 几乎总是发现有一些重大的关键缺陷,所以小心从在线资源中获取代码,并准备做一些调试。

如何使用笔记本[14:17]

构建你自己的盒子[16:50]

阅读论文[21:37]

每周,我们将实现一两篇论文。左边是一篇实现 adam 的论文摘录(你也在电子表格上看到过 adam 作为一个单独的 excel 公式)。在学术论文中,人们喜欢使用希腊字母。他们也不喜欢重构,所以你经常会看到一页长的公式,仔细看时你会意识到相同的子方程出现了 8 次。学术论文有点奇怪,但最终,这是研究界传达他们发现的方式,所以我们需要学会阅读它们。一个很好的做法是拿一篇论文,努力理解它,然后写一篇博客,在博客中用代码和普通英语解释它。许多这样做的人最终会得到相当多的关注,得到一些非常好的工作机会等,因为能够展示你能理解这些论文、在代码中实现它们并用英语解释它们是一种非常有用的技能。很难阅读或理解你无法口头表达的东西。所以学习希腊字母吧!

更多机会

第二部分的主题

生成模型

在第一部分,我们的神经网络的输出通常是一个数字或一个类别,而在第二部分中,很多东西的输出将是很多东西,比如:

  • 图像中每个对象的左上角和右下角位置以及该对象是什么

  • 一幅完整的图片,显示该图片中每个像素的类别

  • 输入图像的增强超分辨率版本

  • 整个原始输入段落翻译成法语

我们将要查看的绝大多数数据要么是文本数据,要么是图像数据。

我们将查看一些更大的数据集,无论是数据集中的对象数量还是每个对象的大小。对于那些使用有限计算资源的人,请不要因此而退缩。随时可以用更小更简单的东西替代。Jeremy 实际上在没有互联网的情况下(在 Point Leo)用 15 英寸的 surface book 写了大部分课程。几乎所有这门课程在 Windows 笔记本电脑上都能很好地运行。你可以始终使用更小的批量大小、精简版本的数据集。但如果你有资源,当可用时,使用更大的数据集会获得更好的结果。

目标检测

与我们习惯的两个主要区别:

1.我们正在对多个事物进行分类。

这并不罕见——我们在第一部分的星球卫星数据中做过这个。

2.我们正在对我们分类的事物周围加上边界框。

边界框有一个非常具体的定义,即它是一个矩形,矩形内的对象完全适合其中,但它不会比必须的更大。

我们的工作将是采用这种方式标记的数据,并在未标记的数据上生成对象的类别和每个对象的边界框。需要注意的一点是,标记这种数据通常更昂贵。对于目标检测数据集,标注者会得到一个对象类别列表,并被要求在图片中找到每一个类型的对象以及它们的位置。在这种情况下,为什么没有一个树或跳跃被标记呢?因为对于这个特定的数据集,标注者没有被要求找到它们,因此不是这个特定问题的一部分。

阶段:

  1. 对每个图像中最大的对象进行分类。

  2. 找到每个图像中最大对象的位置。

  3. 最后,我们将尝试同时做两件事(即标记它是什么以及在图片中的位置)。

帕斯卡笔记本

%matplotlib inline
%reload_ext autoreload
%autoreload 2
from fastai.conv_learner import *
from fastai.dataset import *
from pathlib import Path
import json
from PIL import ImageDraw, ImageFont
from matplotlib import patches, patheffects
# torch.cuda.set_device(1) 

您可能会发现一行torch.cuda.set_device(1)被遗留下来,如果您只有一个 GPU,这会导致错误。这是在您有多个 GPU 时选择 GPU 的方法,所以只需将其设置为零或完全删除该行。

就像 ImageNet 是一个标准的对象分类数据集一样,还有许多标准的目标检测数据集[41:12]。经典的 ImageNet 等价物是 Pascal VOC。

Pascal VOC

我们将查看Pascal VOC数据集。这个数据集相当慢,所以您可能更喜欢从这个镜像下载。有两个不同的竞赛/研究数据集,分别来自 2007 年和 2012 年。我们将使用 2007 年的版本。您可以使用更大的 2012 年版本获得更好的结果,甚至可以将它们结合起来[42:25](但如果这样做,请注意避免验证集之间的数据泄漏)。

与以前的课程不同,我们在路径和文件访问中使用了 Python 3 标准库pathlib。请注意,它返回一个特定于操作系统的类(在 Linux 上是PosixPath),因此您的输出可能会有些不同[44:50]。大多数以路径作为输入的库可以接受pathlib对象 - 尽管有些(如cv2)不能,这种情况下可以使用str()将其转换为字符串。

Pathlib Cheat Sheet

PATH = Path('data/pascal')
list(PATH.iterdir())
'''
[PosixPath('data/pascal/PASCAL_VOC.zip'),
 PosixPath('data/pascal/VOCdevkit'),
 PosixPath('data/pascal/VOCtrainval_06-Nov-2007.tar'),
 PosixPath('data/pascal/pascal_train2012.json'),
 PosixPath('data/pascal/pascal_val2012.json'),
 PosixPath('data/pascal/pascal_val2007.json'),
 PosixPath('data/pascal/pascal_train2007.json'),
 PosixPath('data/pascal/pascal_test2007.json')]
'''

关于生成器的一点说明[43:23]:

生成器是 Python 3 中的一种可以迭代的东西。

  • for i in PATH.iterdir(): print(i)

  • [i for i in PATH.iterdir()](列表推导)

  • list(PATH.iterdir())(将生成器转换为列表)

通常返回生成器的原因是,如果目录中有 1000 万个项目,您不一定希望有 1000 万个长列表。生成器让您可以“懒惰”地执行操作。

加载注释

除了图像外,还有注释 - 显示每个对象位置的边界框。这些是手工标记的。原始版本是 XML 格式[47:59],这在现在有点难以处理,所以我们使用了更近期的 JSON 版本,您可以从此链接下载。

您可以在这里看到pathlib包含打开文件的能力(以及许多其他功能)。

trn_j = json.load((PATH/'pascal_train2007.json').open())
trn_j.keys()
'''
dict_keys(['images', 'type', 'annotations', 'categories'])
'''

这里的/不是除法符号,而是路径斜杠[45:55]。PATH/可以让您获取该路径中的子项。PATH/’pascal_train2007.json’返回一个pathlib对象,该对象具有一个open方法。这个 JSON 文件不包含图像,而是包含对象的边界框和类别。

IMAGES,ANNOTATIONS,CATEGORIES = [
    'images', 'annotations', 'categories'
] 
trn_j[IMAGES][:5]
'''
[{'file_name': '000012.jpg', 'height': 333, 'id': 12, 'width': 500},  {'file_name': '000017.jpg', 'height': 364, 'id': 17, 'width': 480},  {'file_name': '000023.jpg', 'height': 500, 'id': 23, 'width': 334},  {'file_name': '000026.jpg', 'height': 333, 'id': 26, 'width': 500},  {'file_name': '000032.jpg', 'height': 281, 'id': 32, 'width': 500}]
'''

注释 [49:16]

  • bbox:列,行(左上角),高度,宽度

  • image_id:您需要将其与trn_j[IMAGES](上面)连接起来,以查找file_name等。

  • category_id:查看trn_j[CATEGORIES](下面)

  • segmentation:多边形分割(我们将使用它们)

  • ignore:我们将忽略忽略标志

  • iscrowd:指定这是该对象的一群,而不仅仅是其中一个

trn_j[ANNOTATIONS][:2]
'''
[{'area': 34104,
  'bbox': [155, 96, 196, 174],
  'category_id': 7,
  'id': 1,
  'ignore': 0,
  'image_id': 12,
  'iscrowd': 0,
  'segmentation': [[155, 96, 155, 270, 351, 270, 351, 96]]},
 {'area': 13110,
  'bbox': [184, 61, 95, 138],
  'category_id': 15,
  'id': 2,
  'ignore': 0,
  'image_id': 17,
  'iscrowd': 0,
  'segmentation': [[184, 61, 184, 199, 279, 199, 279, 61]]}]
'''

类别 [50:15]

trn_j[CATEGORIES][:4]
'''
[{'id': 1, 'name': 'aeroplane', 'supercategory': 'none'},
 {'id': 2, 'name': 'bicycle', 'supercategory': 'none'},
 {'id': 3, 'name': 'bird', 'supercategory': 'none'},
 {'id': 4, 'name': 'boat', 'supercategory': 'none'}]
'''

使用常量而不是字符串很有帮助,因为我们可以获得制表符补全,不会输错。

FILE_NAME,ID,IMG_ID,CAT_ID,BBOX = 'file_name','id','image_id','category_id','bbox'
cats = dict((o[ID], o['name']) for o in trn_j[CATEGORIES])
trn_fns = dict((o[ID], o[FILE_NAME]) for o in trn_j[IMAGES])
trn_ids = [o[ID] for o in trn_j[IMAGES]]

侧记:当人们实时看到 Jeremy 在工作时,看到他的课程后最常评论的是[51:21]:

“哇,你实际上不知道自己在做什么,是吧”。他做的 99%的事情都不起作用,而那些确实起作用的事情只占很小的比例。他提到这一点是因为机器学习,特别是深度学习,非常令人沮丧。理论上,您只需定义正确的损失函数和足够灵活的架构,然后按下训练按钮,就完成了。但如果那确实是所有需要的,那么什么都不会花费任何时间。问题在于一直到它起作用的所有步骤,它都不起作用。就像它直接进入无限大,崩溃并显示不正确的张量大小等。他将努力向您展示一些调试技术,但这是最难教授的事情之一。它需要的主要是坚韧不拔。那些非常有效的人和那些似乎走得不远的人之间的区别从来不是智力问题。它总是关于坚持下去 - 基本上是永不放弃。这在这种深度学习中尤为重要,因为您不会得到持续的奖励循环。它是一种持续的不起作用,不起作用,不起作用,直到最终起作用的过程,所以有点烦人。

让我们看看这些图像。

list((PATH/'VOCdevkit'/'VOC2007').iterdir())
'''
[PosixPath('data/pascal/VOCdevkit/VOC2007/JPEGImages'),
 PosixPath('data/pascal/VOCdevkit/VOC2007/SegmentationObject'),
 PosixPath('data/pascal/VOCdevkit/VOC2007/ImageSets'),
 PosixPath('data/pascal/VOCdevkit/VOC2007/SegmentationClass'),
 PosixPath('data/pascal/VOCdevkit/VOC2007/Annotations')]*JPEGS = 'VOCdevkit/VOC2007/JPEGImages'IMG_PATH = PATH/JPEGS
list(IMG_PATH.iterdir())[:5]*[PosixPath('data/pascal/VOCdevkit/VOC2007/JPEGImages/007594.jpg'),
 PosixPath('data/pascal/VOCdevkit/VOC2007/JPEGImages/005682.jpg'),
 PosixPath('data/pascal/VOCdevkit/VOC2007/JPEGImages/005016.jpg'),
 PosixPath('data/pascal/VOCdevkit/VOC2007/JPEGImages/001930.jpg'),
 PosixPath('data/pascal/VOCdevkit/VOC2007/JPEGImages/007666.jpg')]
'''

创建字典(键:图像 ID,值:注释)

每个图像都有一个唯一的 ID。

im0_d = trn_j[IMAGES][0]
im0_d[FILE_NAME],im0_dID

defaultdict在任何时候都很有用,当您想要为新键设置默认字典条目时。如果尝试访问不存在的键,则它会自动使其存在,并将其设置为您指定的函数的返回值(在本例中为lambda:[])。

在这里,我们创建了一个从图像 ID 到注释列表(边界框和类别 ID 的元组)的字典。

我们将 VOC 的高度/宽度转换为左上角/右下角,并切换 x/y 坐标以与 numpy 保持一致。如果给定的数据集格式不佳,请花一点时间使事情保持一致,并使它们成为您想要的方式。

trn_anno = collections.defaultdict(lambda:[])
for o in trn_j[ANNOTATIONS]:
    if not o['ignore']:
        bb = o[BBOX]
        bb = np.array([bb[1], bb[0], bb[3]+bb[1]-1, bb[2]+bb[0]-1])
        trn_anno[o[IMG_ID]].append((bb,o[CAT_ID]))

len(trn_anno)
'''
2501
'''

变量命名,编码风格哲学等

示例 1

  • [ 96, 155, 269, 350]:一个边界框。正如您在上面看到的,当我们创建边界框时,我们做了几件事。首先是我们交换了 x 和 y 坐标。这样做的原因是,在计算机视觉世界中,当您说“我的屏幕是 640 乘以 480”时,宽度是高度。或者,在数学世界中,当您说“我的数组是 640 乘以 480”时,是行乘以列。因此,pillow 图像库倾向于按宽度和高度或列和行进行操作,而 numpy 则相反。其次,我们将通过描述左上角 xy 坐标和右下角 xy 坐标来进行操作,而不是 x、y、高度、宽度。

  • 7:类标签/类别

im0_a = im_a[0]; im0_a
'''
[(array([96, 155, 269, 350]), 7)]
'''
im0_a = im_a[0]; im0_a
'''
(array([ 96, 155, 269, 350]), 7)
'''
cats[7]
'''
'car'
'''

示例 2

trn_anno[17]
'''
[(array([61, 184, 198, 278]), 15), (array([77, 89, 335, 402]), 13)]
'''
cats[15],cats[13]
'''
('person', 'horse')
'''

有些库采用 VOC 格式的边界框,因此当需要时,我们可以将其转换回来:

def bb_hw(a): 
    return np.array([a[1],a[0],a[3]-a[1],a[2]-a[0]])

我们将使用 fast.ai 的open_image来显示它:

im = open_image(IMG_PATH/im0_d[FILE_NAME])

集成开发环境(IDE)简介

您可以使用Visual Studio Code(vscode - 附带最新版本的 Anaconda 的开源编辑器,或者可以单独安装),或者大多数编辑器和 IDE,了解有关open_image函数的所有信息。vscode 需要知道的事项:

  • 命令面板(Ctrl-shift-p

  • 选择解释器(用于 fastai 环境)

  • 选择终端 shell

  • 转到符号(Ctrl-t

  • 查找引用(Shift-F12

  • 转到定义(F12

  • 返回(alt-left

  • 查看文档

  • 隐藏侧边栏(Ctrl-b

  • 禅模式(Ctrl-k,z

如果您像我一样在 Mac 上使用 PyCharm 专业版:

  • 命令面板(Shift-command-a

  • 选择解释器(用于 fastai 环境)(Shift-command-a然后搜索“解释器”)

  • 选择终端外壳(Option-F12

  • 转到符号(Option-command-shift-n并输入类名、函数名等。如果是驼峰式或下划线分隔的,您可以输入每个部分的前几个字母)

  • 查找引用(Option-F7),下一个出现(Option-command-⬇︎),上一个出现(Option-command-⬆︎

  • 转到定义(Command-b

  • 返回(Option-command-⬅︎

  • 查看文档

  • 禅模式(Control--4-2`或搜索“无干扰模式”)

让我们谈谈 open_image [1:10:52]

Fastai 使用 OpenCV。TorchVision 使用 PyTorch 张量进行数据增强等。很多人使用 Pillow PIL。Jeremy 对所有这些进行了大量测试,他发现 OpenCV 比 TorchVision 快 5 到 10 倍。对于星球卫星图像竞赛 [1:11:55],TorchVision 非常慢,因为他们进行了大量的数据增强,只能利用 25%的 GPU 利用率。分析器显示这一切都在 TorchVision 中。

Pillow 速度相当快,但不及 OpenCV 快,也远不及线程安全[1:12:19]。Python 有一个叫做全局解释器锁(GIL)的东西,这意味着两个线程不能同时执行 Pythonic 的事情 —— 这使得 Python 成为现代编程的糟糕语言,但我们却被困在其中。OpenCV 释放了 GIL。fast.ai 库之所以如此快,是因为它不像其他库那样为数据增强使用多个处理器 —— 它实际上使用多个线程。它能够使用多个线程的原因是因为它使用了 OpenCV。不幸的是,OpenCV 有一个晦涩的 API,文档有些晦涩。这就是为什么 Jeremy 试图让使用 fast.ai 的人不需要知道它正在使用 OpenCV。您不需要知道要传递哪些标志来打开一个图像。您不需要知道如果读取失败,它不会显示异常 —— 它会静默地返回None

不要开始使用 PyTorch 进行数据增强或引入 Pillow —— 您会发现事情突然变得非常缓慢,或者多线程将不再起作用。您应该坚持使用 OpenCV 进行处理[1:14:10]

更好地使用 Matplotlib [1:14:45]

Matplotlib 之所以被命名为 Matplotlib,是因为它最初是 Matlab 绘图库的一个克隆。不幸的是,Matlab 的绘图库并不好,但那时候,这是每个人都知道的。在某个时候,Matplotlib 的开发人员意识到了这一点,并添加了第二个 API,即面向对象的 API。不幸的是,因为最初学习 Matplotlib 的人没有学习过 OO API,他们随后教导下一代人使用旧的 Matlab 风格 API。现在几乎没有例子或教程使用更好、更容易理解和更简单的 OO API。由于绘图在深度学习中非常重要,我们在这门课程中要学习的一件事就是如何使用这个 API。

技巧 1:plt.subplots [1:16:00]

Matplotlib 的plt.subplots是一个非常有用的包装器,用于创建图表,无论您是否有多个子图。请注意,Matplotlib 有一个可选的面向对象的 API,我认为这个 API 更容易理解和使用(尽管在线上很少有例子使用它!)

def show_img(im, figsize=None, ax=None):
    if not ax: 
        fig,ax = plt.subplots(figsize=figsize)
    ax.imshow(im)
    ax.get_xaxis().set_visible(False)
    ax.get_yaxis().set_visible(False)
    return ax

它返回两个东西 —— 你可能不会关心第一个(图形对象),第二个是 Axes 对象(或其数组)。基本上,你以前在哪里说 plt. 什么,现在你说 ax. 什么,它将绘制到特定的子图。当你想绘制多个图以便进行比较时,这很有帮助。

技巧 2:无论背景颜色如何都可见的文本 [1:17:59]

使文本在任何背景下都可见的一个简单但很少使用的技巧是使用白色文本和黑色轮廓,或者反之。这是如何在 matplotlib 中做到的。

def draw_outline(o, lw):
    o.set_path_effects([
        patheffects.Stroke(linewidth=lw, foreground='black'), 
        patheffects.Normal()
    ])

请注意参数列表中的 *splat 操作符。在这种情况下,与写出 b[-2],b[-1] 相比,这是一个小快捷方式。

def draw_rect(ax, b):
    patch = ax.add_patch(patches.Rectangle(
        b[:2], *b[-2:], 
        fill=False, 
        edgecolor='white', 
        lw=2
    ))
    draw_outline(patch, 4)
def draw_text(ax, xy, txt, sz=14):
    text = ax.text(
        *xy, txt, 
        verticalalignment='top', 
        color='white',
        fontsize=sz, 
        weight='bold'
    )
    draw_outline(text, 1)
    ax = show_img(im)
b = bb_hw(im0_a[0])
draw_rect(ax, b)
draw_text(ax, b[:2], cats[im0_a[1]])

将所有内容打包起来 [1:21:20]

def draw_im(im, ann):
    ax = show_img(im, figsize=(16,8))
    for b,c in ann:
        b = bb_hw(b)
        draw_rect(ax, b)
        draw_text(ax, b[:2], cats[c], sz=16)
def draw_idx(i):
    im_a = trn_anno[i]
    im = open_image(IMG_PATH/trn_fns[i])
    print(im.shape)
    draw_im(im, im_a)
    draw_idx(17)

当你使用新数据集时,快速探索它的能力是值得的。

最大项目分类器 [1:22:57]

与其一次性解决所有问题,不如持续取得进展。我们知道如何找到每个图像中最大的对象并对其进行分类,所以让我们从那里开始。Jeremy 在 Kaggle 竞赛中的方法是每天半小时 [1:24:00]。在那半小时结束时,提交一些东西,并尝试比昨天稍微好一点。

我们需要做的第一件事是遍历图像中的每个边界框并获取最大的边界框。lambda 函数 只是一种内联定义匿名函数的方式。在这里,我们用它来描述如何对每个图像的注释进行排序 —— 按边界框大小(降序)。

我们从左上角减去右下角并乘以(np.product)值以获得一个面积 lambda x: np.product(x[0][-2:]-x[0][:2])

def get_lrg(b):
    if not b: 
        raise Exception()
    b = sorted(
        b, 
        key=lambda x: np.product(x[0][-2:]-x[0][:2]), 
        reverse=True
    )
    return b[0]

字典推导式 [1:27:04]

trn_lrg_anno = {a: get_lrg(b) for a,b in trn_anno.items()}

现在我们有一个从图像 ID 到单个边界框的字典 —— 该图像的最大边界框。

b,c = trn_lrg_anno[23]
b = bb_hw(b)
ax = show_img(open_image(IMG_PATH/trn_fns[23]), figsize=(5,10))
draw_rect(ax, b)
draw_text(ax, b[:2], cats[c], sz=16)

当你有任何类型的处理管道时,你需要查看每个阶段 [1:28:01]。假设你做的每件事第一次都会出错。

(PATH/'tmp').mkdir(exist_ok=True)
CSV = PATH/'tmp/lrg.csv'

通常,最简单的方法是简单地创建要建模的数据的 CSV,而不是尝试创建自定义数据集 [1:29:06]。在这里,我们使用 Pandas 帮助我们创建一个图像文件名和类别的 CSV。columns=[‘fn’,’cat’] 是因为字典没有顺序,列的顺序很重要。

df = pd.DataFrame({
    'fn': [trn_fns[o] for o in trn_ids],
    'cat': [cats[trn_lrg_anno[o][1]] for o in trn_ids]
}, columns=['fn','cat'])
df.to_csv(CSV, index=False)
f_model = resnet34
sz=224
bs=64

从这里开始就像狗与猫!

tfms = tfms_from_model(
    f_model, sz, 
    aug_tfms=transforms_side_on, 
    crop_type=CropType.NO
)
md = ImageClassifierData.from_csv(PATH, JPEGS, CSV, tfms=tfms)

让我们来看看这个 [1:30:48]

一个不同的地方是 crop_type。在 fast.ai 中创建 224x224 图像的默认策略是首先调整大小,使最小边为 224。然后在训练期间随机取一个正方形裁剪。在验证期间,我们取中心裁剪,除非我们使用数据增强。

对于边界框,我们不想这样做,因为与图像网不同,我们关心的东西基本上在中间且相当大,而在目标检测中,很多东西相当小且靠近边缘。通过将 crop_type 设置为 CropType.NO,它将不会裁剪,因此,为了使其成为正方形,它会压缩它 [1:32:09]。一般来说,许多计算机视觉模型在裁剪而不是压缩时效果稍好一些,但如果你压缩,它们仍然效果很好。在这种情况下,我们绝对不想裁剪,所以这是完全可以的。

x,y=next(iter(md.val_dl))
show_img(md.val_ds.denorm(to_np(x))[0]);

数据加载器 [1:33:04]

您已经知道,在模型数据对象内部,我们有一堆东西,包括训练数据加载器和训练数据集。关于数据加载器的主要知识点是,它是一个迭代器,每次从中获取下一个迭代的内容时,您会得到一个小批量。您获得的小批量是您请求的任何大小,默认情况下批量大小为 64。在 Python 中,从迭代器中获取下一个内容的方法是使用next(md.trn_dl),但您不能直接这样做。您不能这样说的原因是您需要说“现在开始一个新的时期”。通常情况下,不仅仅是在 PyTorch 中,对于任何 Python 迭代器,您需要说“请从序列的开头开始”。您这样做的方式是使用iter(md.trn_dl),它将从md.trn_dl中获取一个迭代器 —— 具体来说,正如我们稍后将学到的那样,这意味着这个类必须定义一个__iter__方法,该方法返回一些不同的对象,然后该对象具有一个__next__方法。

如果您只想获取一个批次,这是您的操作方法(x:自变量,y:因变量):

x,y=next(iter(md.val_dl))

我们不能直接将其发送到show_image[1:35:30]。例如,x不是一个 numpy 数组,不在 CPU 上,并且形状完全错误(3x224x224)。此外,它们不是介于 0 和 1 之间的数字,因为所有标准 ImageNet 预训练模型都期望我们的数据已经被标准化为具有零均值和 1 标准差。

正如您所看到的,对输入进行了大量处理,以便准备传递给预训练模型。因此我们有一个名为denorm的函数用于反标准化,还可以修复维度顺序等。由于反标准化取决于转换[1:37:52],并且数据集知道用于创建它的转换,这就是为什么您需要执行md.val_ds.denorm并将小批量转换为 numpy 数组后传递:

show_img(md.val_ds.denorm(to_np(x))[0]);

使用 ResNet34 进行训练[1:38:36]

learn = ConvLearner.pretrained(f_model, md, metrics=[accuracy])
learn.opt_fn = optim.Adamlrf=learn.lr_find(1e-5,100)
learn.sched.plot()

我们故意删除了前几个点和最后几个点[1:38:54],因为通常最后几个点会向无穷大飙升,以至于您无法看到任何东西,所以这通常是个好主意。但是当您只有很少的小批量时,这并不是一个好主意。当您的 LR 查找器图像看起来像上面时,您可以要求在每一端获取更多点(您还可以将批量大小设置得非常小):

learn.sched.plot(n_skip=5, n_skip_end=1)

lr = 2e-2
learn.fit(lr, 1, cycle_len=1)
'''
epoch      trn_loss   val_loss   accuracy                      
    0      1.280753   0.604127   0.806941
'''

解冻几层:

lrs = np.array([lr/1000,lr/100,lr])
learn.freeze_to(-2)
learn.fit(lrs/5, 1, cycle_len=1)
'''
epoch      trn_loss   val_loss   accuracy                      
    0      0.780925   0.575539   0.821064
'''

解冻整个模型:

learn.unfreeze()
learn.fit(lrs/5, 1, cycle_len=2)
'''
epoch      trn_loss   val_loss   accuracy                       
    0      0.676254   0.546998   0.834285       
    1      0.460609   0.533741   0.833233
'''

准确率没有太大改善 —— 由于许多图像具有多个不同的对象,要达到那么高的准确率几乎是不可能的。

让我们看看结果[1:40:48]

fig, axes = plt.subplots(3, 4, figsize=(12, 8))
for i,ax in enumerate(axes.flat):
    ima=md.val_ds.denorm(x)[i]
    b = md.classes[preds[i]]
    ax = show_img(ima, ax=ax)
    draw_text(ax, (0,0), b)
plt.tight_layout()

如何理解陌生的代码:

  • 逐行运行代码,打印输入和输出。

方法 1[1:42:28]:您可以获取循环的内容,复制它,创建一个在其上方的单元格,粘贴它,取消缩进,设置i=0并将它们放在单独的单元格中。

方法 2[1:43:04]:使用 Python 调试器

您可以使用 Python 调试器pdb逐步执行代码。

  • pdb.set_trace()设置断点

  • %debug魔术以跟踪错误(在异常发生后)

您需要了解的命令:

  • h(帮助)

  • s(步入)

  • n(下一行/跳过 —— 您也可以按回车键)

  • c(继续到下一个断点)

  • u(向上调用堆栈)

  • d(向下调用堆栈)

  • p(打印) —— 当有一个单字母变量也是一个命令时,强制打印。

  • l(列出) —— 显示上面和下面的行

  • q(退出) —— 非常重要

注释 [1:49:10][IPython.core.debugger](http://ipython.readthedocs.io/en/stable/api/generated/IPython.core.debugger.html)(右侧)使其看起来很漂亮:

创建边界框[1:52:51]

围绕最大对象创建边界框可能看起来像是您以前没有做过的事情,但实际上它完全是您以前做过的事情。我们可以创建一个回归而不是分类神经网络。分类神经网络是具有 sigmoid 或 softmax 输出的神经网络,我们使用交叉熵、二元交叉熵或负对数似然损失函数。这基本上是使其成为分类器的原因。如果我们在最后没有 softmax 或 sigmoid,并且我们使用均方误差作为损失函数,那么现在它是一个预测连续数字而不是类别的回归模型。我们还知道我们可以有多个输出,就像在 planet 竞赛中一样(多分类)。如果我们将这两个想法结合起来并进行多列回归呢?

这是您考虑它像可微编程的地方。不是“我如何创建一个边界框模型?”而是更像:

  • 我们需要四个数字,因此需要一个具有 4 个激活的神经网络

  • 对于损失函数,什么样的函数在较低时意味着这四个数字更好?均方损失函数!

就是这样。让我们试试看。

Bbox only [1:55:27]

现在我们将尝试找到最大对象的边界框。这只是一个具有 4 个输出的回归。因此,我们可以使用具有多个“标签”的 CSV。如果您还记得第 1 部分如何进行多标签分类,您的多个标签必须以空格分隔,并且文件名以逗号分隔。

BB_CSV = PATH/'tmp/bb.csv'
bb = np.array([trn_lrg_anno[o][0] for o in trn_ids])
bbs = [' '.join(str(p) for p in o) for o in bb]
df = pd.DataFrame({
    'fn': [trn_fns[o] for o in trn_ids], 
    'bbox': bbs
}, columns=['fn','bbox'])
df.to_csv(BB_CSV, index=False)
BB_CSV.open().readlines()[:5]
'''
['fn,bbox\n',
 '000012.jpg,96 155 269 350\n',
 '000017.jpg,77 89 335 402\n',
 '000023.jpg,1 2 461 242\n',
 '000026.jpg,124 89 211 336\n']
'''

Training [1:56:11]

f_model=resnet34
sz=224
bs=64

continuous=True设置为告诉 fastai 这是一个回归问题,这意味着它不会对标签进行独热编码,并且将使用 MSE 作为默认的 crit。

请注意,我们必须告诉 transforms 构造函数我们的标签是坐标,以便它可以正确处理 transforms。

此外,我们使用 CropType.NO,因为我们希望将矩形图像“压缩”成正方形,而不是中心裁剪,以免意外裁剪掉一些对象。(在像 imagenet 这样的情况下,这不是太大的问题,因为有一个要分类的单个对象,通常很大且位于中心位置)。

tfms = tfms_from_model(
    f_model, sz, 
    crop_type=CropType.NO, 
    tfm_y=TfmType.COORD
)
md = ImageClassifierData.from_csv(
    PATH, JPEGS, BB_CSV, 
    tfms=tfms, 
    continuous=True
)

下周我们将看一下TfmType.COORD,但现在,只需意识到当我们进行缩放和数据增强时,需要对边界框进行操作,而不仅仅是图像。

x,y=next(iter(md.val_dl))ima=md.val_ds.denorm(to_np(x))[0]
b = bb_hw(to_np(y[0])); b
'''
array([  49.,    0.,  131.,  205.], dtype=float32)
'''
ax = show_img(ima)
draw_rect(ax, b)
draw_text(ax, b[:2], 'label')

让我们基于 ResNet34 创建一个卷积网络[1:56:57]:

fastai 允许您使用custom_head在卷积网络的顶部添加自己的模块,而不是默认添加的自适应池化和全连接网络。在这种情况下,我们不想进行任何池化,因为我们需要知道每个网格单元的激活。

最终层有 4 个激活,每个激活对应一个边界框坐标。我们的目标是连续的,而不是分类的,因此使用的 MSE 损失函数不会对模块输出进行任何 sigmoid 或 softmax 处理。

head_reg4 = nn.Sequential(Flatten(), nn.Linear(25088,4))
learn = ConvLearner.pretrained(f_model, md, custom_head=head_reg4)
learn.opt_fn = optim.Adam
learn.crit = nn.L1Loss()
  • Flatten():通常在 ResNet34 中,前一层具有7x7x512,因此将其展平为长度为 2508 的单个向量。

  • L1Loss[1:58:22]:不是将平方误差相加,而是将误差的绝对值相加。这通常是您想要的,因为将平方误差相加会过度惩罚错误。因此,L1Loss 通常更好地处理。

learn.lr_find(1e-5,100)
learn.sched.plot(5)78%|███████▊  | 25/32 [00:04<00:01,  6.16it/s, loss=395]

lr = 2e-3
learn.fit(lr, 2, cycle_len=1, cycle_mult=2)
'''
epoch      trn_loss   val_loss                            
    0      49.523444  34.764141 
    1      36.864003  28.007317                           
    2      30.925234  27.230705
'''
lrs = np.array([lr/100,lr/10,lr])
learn.freeze_to(-2)
lrf=learn.lr_find(lrs/1000)
learn.sched.plot(1)

learn.fit(lrs, 2, cycle_len=1, cycle_mult=2)
'''
epoch      trn_loss   val_loss                            
    0      25.616161  22.83597  
    1      21.812624  21.387115                           
    2      17.867176  20.335539
'''
learn.freeze_to(-3)
learn.fit(lrs, 1, cycle_len=2)
'''
epoch      trn_loss   val_loss                            
    0      16.571885  20.948696 
    1      15.072718  19.925312
'''

验证损失是绝对值的平均值,像素偏离了。

learn.save('reg4')

看一下结果[1:59:18]

x,y = next(iter(md.val_dl))
learn.model.eval()
preds = to_np(learn.model(VV(x)))
fig, axes = plt.subplots(3, 4, figsize=(12, 8))
for i,ax in enumerate(axes.flat):
    ima=md.val_ds.denorm(to_np(x))[i]
    b = bb_hw(preds[i])
    ax = show_img(ima, ax=ax)
    draw_rect(ax, b)
plt.tight_layout()

我们将在下周进一步修改这个。在这堂课之前,如果有人问你“你知道如何创建一个边界框模型吗?”,你可能会说“不,没有人教过我”。但实际上问题是:

  • 您可以创建一个具有 4 个连续输出的模型吗?可以。

  • 您能否创建一个损失函数,如果这 4 个输出接近另外 4 个数字,则较低?可以

然后你就完成了。

当你继续往下看时,它开始看起来有点糟糕 - 每当我们有多个对象时。这并不奇怪。总的来说,它做得相当不错。

深度学习 2:第 2 部分第 9 课

原文:medium.com/@hiromi_suenaga/deep-learning-2-part-2-lesson-9-5f0cf9e4bb5b

译者:飞龙

协议:CC BY-NC-SA 4.0

来自 fast.ai 课程的个人笔记。随着我继续复习课程以“真正”理解它,这些笔记将继续更新和改进。非常感谢 JeremyRachel 给了我这个学习的机会。

链接

论坛 / 视频

回顾

上周的内容:

  • Pathlib;JSON

  • 字典推导

  • Defaultdict

  • 如何在 fastai 源代码中跳转

  • matplotlib OO API

  • Lambda 函数

  • 边界框坐标

  • 自定义头部;边界框回归

来自第 1 部分:

  • 如何查看 DataLoader 中的模型输入

  • 如何查看模型输出

数据增强和边界框[2:58]

笔记本

fastai 的尴尬问题:

分类器是任何具有分类或二元因变量的东西。与回归相对,回归是任何具有连续因变量的东西。命名有点混乱,但将在未来得到解决。在这里,continuousTrue,因为我们的因变量是边界框的坐标 — 因此这实际上是一个回归器数据。

tfms = tfms_from_model(f_model, sz, crop_type=CropType.NO, 
                       aug_tfms=augs)
md = ImageClassifierData.from_csv(PATH, JPEGS, BB_CSV, tfms=tfms,
                                  **continuous=True**, bs=4)

让我们创建一些数据增强[4:40]

augs = [RandomFlip(), 
        RandomRotate(30),
        RandomLighting(0.1,0.1)]

通常,我们使用 Jeremy 为我们创建的这些快捷方式,但它们只是随机增强的列表。但您可以轻松创建自己的(大多数,如果不是全部,都以“Random”开头)。

tfms = tfms_from_model(
    f_model, sz, 
    crop_type=CropType.NO,
    aug_tfms=augs
)
md = ImageClassifierData.from_csv(
    PATH, JPEGS, BB_CSV, 
    tfms=tfms,
    continuous=True, 
    bs=4
)
idx=3
fig,axes = plt.subplots(3,3, figsize=(9,9))
for i,ax in enumerate(axes.flat):
    x,y=next(iter(md.aug_dl))
    ima=md.val_ds.denorm(to_np(x))[idx]
    b = bb_hw(to_np(y[idx]))
    print(b)
    show_img(ima, ax=ax)
    draw_rect(ax, b)
'''
[115\.   63\.  240\.  311.]
[ 115\.   63\.  240\.  311.]
[ 115\.   63\.  240\.  311.]
[ 115\.   63\.  240\.  311.]
[ 115\.   63\.  240\.  311.]
[ 115\.   63\.  240\.  311.]
[ 115\.   63\.  240\.  311.]
[ 115\.   63\.  240\.  311.]
[ 115\.   63\.  240\.  311.]*

如您所见,图像会旋转并且光照会变化,但边界框不会移动,而且位置不正确[6:17]。这是数据增强的问题,当您的因变量是像素值或以某种方式与自变量相关联时,它们需要一起增强。如您在边界框坐标[ 115. 63. 240. 311.]中所看到的,我们的图像是 224 乘以 224 — 因此它既没有缩放也没有裁剪。因变量需要经历所有几何变换,就像自变量一样。

要执行此操作[7:10],每个转换都有一个可选的tfm_y参数:

augs = [
    RandomFlip(tfm_y=TfmType.COORD),
    RandomRotate(30, tfm_y=TfmType.COORD),
    RandomLighting(0.1,0.1, tfm_y=TfmType.COORD)
]
tfms = tfms_from_model(
    f_model, sz, 
    crop_type=CropType.NO,
    tfm_y=TfmType.COORD, 
    aug_tfms=augs
)
md = ImageClassifierData.from_csv(
    PATH, JPEGS, BB_CSV, 
    tfms=tfms, 
    continuous=True, 
    bs=4
)

TrmType.COORD表示y值表示坐标。这需要添加到所有增强以及tfms_from_model中,后者负责裁剪、缩放、调整大小、填充等。

idx=3
fig,axes = plt.subplots(3,3, figsize=(9,9))
for i,ax in enumerate(axes.flat):
    x,y=next(iter(md.aug_dl))
    ima=md.val_ds.denorm(to_np(x))[idx]
    b = bb_hw(to_np(y[idx]))
    print(b)
    show_img(ima, ax=ax)
    draw_rect(ax, b)
'''
[ 48\.   34\.  112\.  188.]
[  65\.   36\.  107\.  185.]
[  49\.   27\.  131\.  195.]
[  24\.   18\.  147\.  204.]
[  61\.   34\.  113\.  188.]
[  55\.   31\.  121\.  191.]
[  52\.   19\.  144\.  203.]
[   7\.    0\.  193\.  222.]
[  52\.   38\.  105\.  182.]*

现在,边界框随图像移动并位于正确位置。您可能会注意到有时看起来像底部行中间的那个奇怪。这是我们拥有的信息的限制。如果对象占据原始边界框的角落,那么在图像旋转后,您的新边界框需要更大。因此,您必须小心不要对边界框进行过高的旋转,因为没有足够的信息使它们保持准确。如果我们正在进行多边形或分割,我们将不会遇到这个问题。

这就是为什么框变大了

tfm_y = TfmType.COORD
augs = [
    RandomFlip(tfm_y=tfm_y),
    RandomRotate(3, **p=0.5**, tfm_y=tfm_y),
    RandomLighting(0.05,0.05, tfm_y=tfm_y)
]
tfms = tfms_from_model(
    f_model, sz, 
    crop_type=CropType.NO, 
    tfm_y=tfm_y, 
    aug_tfms=augs
)
md = ImageClassifierData.from_csv(
    PATH, JPEGS, BB_CSV, 
    tfms=tfms, 
    continuous=True
)

因此,在这里,我们最多进行 3 度旋转,以避免这个问题[9:14]。它也只有一半的时间旋转(p=0.5)。

custom_head[9:34]

learn.summary()将通过模型运行一小批数据,并打印出每一层张量的大小。正如您所看到的,在Flatten层之前,张量的形状为 512 乘以 7 乘以 7。因此,如果它是一个秩为 1 的张量(即一个单一向量),其长度将为 25088(512 * 7 * 7),这就是为什么我们自定义标题的输入大小为 25088。输出大小为 4,因为它是边界框坐标。

head_reg4 = nn.Sequential(Flatten(), nn.Linear(25088,4))
learn = ConvLearner.pretrained(f_model, md, custom_head=head_reg4)
learn.opt_fn = optim.Adam
learn.crit = nn.L1Loss()

单个对象检测[10:35]

让我们将这两者结合起来,创建一个可以对每个图像中最大的对象进行分类和定位的东西。

训练神经网络有 3 件事情我们需要做:

  1. 数据

  2. 架构

  3. 损失函数

1. 提供数据

我们需要一个ModelData对象,其独立变量是图像,依赖变量是一个包含边界框坐标和类别标签的元组。有几种方法可以做到这一点,但这里是 Jeremy 想出的一个特别懒惰和方便的方法,即创建两个代表我们想要的两个不同依赖变量的ModelData对象(一个带有边界框坐标,一个带有类别)。

f_model=resnet34
sz=224
bs=64
val_idxs = get_cv_idxs(len(trn_fns))
tfms = tfms_from_model(
    f_model, sz, 
    crop_type=CropType.NO, 
    tfm_y=TfmType.COORD, 
    aug_tfms=augs
)
md = ImageClassifierData.from_csv(
    PATH, JPEGS, BB_CSV, 
    tfms=tfms, 
    continuous=True, 
    val_idxs=val_idxs
)
md2 = ImageClassifierData.from_csv(
    PATH, JPEGS, CSV,
    tfms=tfms_from_model(f_model, sz)
)

数据集可以是任何具有__len____getitem__的东西。这里有一个数据集,它向现有数据集添加了第二个标签:

class ConcatLblDataset(Dataset):
    def __init__(self, ds, y2): 
        self.ds,self.y2 = ds,y2
    def __len__(self): 
        return len(self.ds)

    def __getitem__(self, i):
        x,y = self.ds[i]
        return (x, (y,self.y2[i]))
  • ds:包含独立和依赖变量

  • y2:包含额外的依赖变量

  • (x, (y,self.y2[i]))__getitem___返回一个独立变量和两个依赖变量的组合。

我们将用它来将类别添加到边界框标签中。

trn_ds2 = ConcatLblDataset(md.trn_ds, md2.trn_y)
val_ds2 = ConcatLblDataset(md.val_ds, md2.val_y)

这是一个例子的依赖变量:

val_ds2[0][1]*(array([   0.,   49.,  205.,  180.], dtype=float32), 14)*

我们可以用这些新的数据集替换数据加载器的数据集。

md.trn_dl.dataset = trn_ds2
md.val_dl.dataset = val_ds2

我们必须在绘图之前从数据加载器中对图像进行denormalize。

x,y = next(iter(md.val_dl))
idx = 3
ima = md.val_ds.ds.denorm(to_np(x))[idx]
b = bb_hw(to_np(y[0][idx])); b
'''
array([  52.,   38.,  106.,  184.], dtype=float32)
'''
ax = show_img(ima)
draw_rect(ax, b)
draw_text(ax, b[:2], md2.classes[y[1][idx]])

2. 选择架构[13:54]

架构将与我们用于分类器和边界框回归的相同,但我们将它们结合起来。换句话说,如果我们有c个类别,那么最终层中所需的激活数量是 4 加上c。4 用于边界框坐标和c个概率(每个类别一个)。

这次我们将使用额外的线性层,再加上一些 dropout,来帮助我们训练一个更灵活的模型。一般来说,如果预训练的主干适合,我们希望我们的自定义头部能够独立解决问题。因此,在这种情况下,我们尝试做了很多事情——分类器和边界框回归,所以单个线性层似乎不够。如果你想知道为什么第一个ReLU后面没有BatchNorm1d,那是因为 ResNet 主干已经有BatchNorm1d作为最后一层。

head_reg4 = nn.Sequential(
    Flatten(),
    nn.ReLU(),
    nn.Dropout(0.5),
    nn.Linear(25088,256),
    nn.ReLU(),
    nn.BatchNorm1d(256),
    nn.Dropout(0.5),
    nn.Linear(256, 4+len(cats)),
)
models = ConvnetBuilder(f_model, 0, 0, 0, custom_head=head_reg4)

learn = ConvLearner(md, models)
learn.opt_fn = optim.Adam

3. 损失函数[15:46]

损失函数需要查看这些4 + len(cats)激活,并决定它们是否良好——这些数字是否准确反映了图像中最大对象的位置和类别。我们知道如何做到这一点。对于前 4 个激活,我们将像以前一样使用 L1Loss(L1Loss 类似于均方误差——它使用绝对值的和,而不是平方误差的和)。对于其余的激活,我们可以使用交叉熵损失。

def detn_loss(input, target):
    bb_t,c_t = target
    bb_i,c_i = input[:, :4], input[:, 4:]
    bb_i = F.sigmoid(bb_i)*224
    # I looked at these quantities separately first then picked a 
    # multiplier to make them approximately equal
    return F.l1_loss(bb_i, bb_t) + F.cross_entropy(c_i, c_t)*20
def detn_l1(input, target):
    bb_t,_ = target
    bb_i = input[:, :4]
    bb_i = F.sigmoid(bb_i)*224
    return F.l1_loss(V(bb_i),V(bb_t)).data
def detn_acc(input, target):
    _,c_t = target
    c_i = input[:, 4:]
    return accuracy(c_i, c_t)
learn.crit = detn_loss
learn.metrics = [detn_acc, detn_l1]
  • input:激活

  • target:真实值

  • bb_t,c_t = target:我们的自定义数据集返回一个包含边界框坐标和类别的元组。这个赋值将对它们进行解构。

  • bb_i,c_i = input[:, :4], input[:, 4:]:第一个:是用于批处理维度。

  • b_i = F.sigmoid(bb_i)*224:我们知道我们的图像是 224x224。Sigmoid将强制它在 0 和 1 之间,并将其乘以 224,以帮助我们的神经网络处于必须的范围内。

问题:一般规则是,在 ReLU 之前还是之后放置 BatchNorm 更好[18:02]?Jeremy 建议在 ReLU 之后放置 BatchNorm,因为 BatchNorm 旨在朝着零均值一标准差移动。因此,如果你在它之后放置 ReLU,你就在零处截断它,所以没有办法创建负数。但如果你先放 ReLU 再放 BatchNorm,它确实有这个能力,并且会给出稍微更好的结果。话虽如此,无论哪种方式都不是太大的问题。你会在这门课程的这部分看到,大多数时候 Jeremy 会先 ReLU 再 BatchNorm,但有时会相反,当他想要与论文保持一致时。

问题:在 BatchNorm 之后使用 dropout 的直觉是什么?BatchNorm 不是已经很好地进行了正则化吗[19:12]?BatchNorm 做正则化的效果还可以,但是如果回想第 1 部分,我们讨论过避免过拟合的一系列方法,添加 BatchNorm 是其中之一,数据增强也是其中之一。但是仍然有可能过拟合。关于 dropout 的一个好处是它有一个参数来指定要丢弃多少。参数非常好,特别是决定要进行多少正则化,因为它让你可以构建一个很大的超参数化模型,然后决定要进行多少正则化。Jeremy 倾向于总是从p=0开始添加 dropout,然后随着添加正则化,他可以只需更改 dropout 参数,而不必担心是否保存了一个模型,他希望能够重新加载它,但如果一个中有 dropout 层而另一个中没有,它将无法加载。这样,保持一致性。

现在我们有了输入和目标,我们可以计算 L1 损失并添加交叉熵[20:39]:

F.l1_loss(bb_i, bb_t) + F.cross_entropy(c_i, c_t)*20

这是我们的损失函数。交叉熵和 L1 损失可能处于非常不同的尺度——在这种情况下,较大的那个将占主导地位。在这种情况下,Jeremy 打印出值并发现如果我们将交叉熵乘以 20,它们就会大致处于相同的尺度。

lr=1e-2
learn.fit(lr, 1, cycle_len=3, use_clr=(32,5))
'''
epoch      trn_loss   val_loss   detn_acc   detn_l1       
    0      72.036466  45.186367  0.802133   32.647586 
    1      51.037587  36.34964   0.828425   25.389733     
    2      41.4235    35.292709  0.835637   24.343577
[35.292709, 0.83563701808452606, 24.343576669692993]
'''

在训练时打印信息是很好的,所以我们抓取了 L1 损失并将其添加为指标。

learn.save('reg1_0')
learn.freeze_to(-2)
lrs = np.array([lr/100, lr/10, lr])
learn.fit(lrs/5, 1, cycle_len=5, use_clr=(32,10))
'''
epoch      trn_loss   val_loss   detn_acc   detn_l1       
    0      34.448113  35.972973  0.801683   22.918499 
    1      28.889909  33.010857  0.830379   21.689888     
    2      24.237017  30.977512  0.81881    20.817996     
    3      21.132993  30.60677   0.83143    20.138552     
    4      18.622983  30.54178   0.825571   19.832196
[30.54178, 0.82557091116905212, 19.832195997238159]
'''
learn.unfreeze()
learn.fit(lrs/10, 1, cycle_len=10, use_clr=(32,10))
'''
epoch      trn_loss   val_loss   detn_acc   detn_l1       
    0      15.957164  31.111507  0.811448   19.970753 
    1      15.955259  32.597153  0.81235    20.111022     
    2      15.648723  32.231941  0.804087   19.522853     
    3      14.876172  30.93821   0.815805   19.226574     
    4      14.113872  31.03952   0.808594   19.155093     
    5      13.293885  29.736671  0.826022   18.761728     
    6      12.562566  30.000023  0.827524   18.82006      
    7      11.885125  30.28841   0.82512    18.904158     
    8      11.498326  30.070133  0.819712   18.635296     
    9      11.015841  30.213772  0.815805   18.551489
[30.213772, 0.81580528616905212, 18.551488876342773]
'''

检测准确率在 80%左右,与之前相同。这并不令人惊讶,因为 ResNet 是设计用于分类的,所以我们不会指望能够以这种简单的方式改进事情。它确实不是设计用于边界框回归的。实际上,它是明确设计成不关心几何形状的——它取最后的 7x7 激活网格并将它们全部平均在一起,丢弃了所有关于每个位置的信息。

有趣的是,当我们同时进行准确性(分类)和边界框时,L1 似乎比我们只进行边界框回归时要好一点[22:46]。如果这对你来说是违反直觉的,那么这将是本课后需要考虑的主要问题之一,因为这是一个非常重要的想法。这个想法是——找出图像中的主要对象是比较困难的部分。然后确定边界框的确切位置和类别是一种简单的方式。因此,当你有一个同时指出对象是什么和对象在哪里的单个网络时,它将共享所有关于找到对象的计算。所有这些共享的计算非常高效。当我们反向传播类别和位置的错误时,所有这些信息都将帮助计算找到最大对象的周围。因此,每当你有多个任务共享某些概念,这些任务需要完成它们的工作,它们很可能应该至少共享网络的一些层。今天晚些时候,我们将看一个模型,其中大部分层都是共享的,除了最后一层。

以下是结果[24:34]。与以前一样,在图像中有单个主要对象时表现良好。

多标签分类[25:29]

笔记本

我们希望继续构建比上一个模型稍微复杂的模型,这样如果某些东西停止工作,我们就知道出了什么问题。以下是上一个笔记本中的函数:

%matplotlib inline
%reload_ext autoreload
%autoreload 2
from fastai.conv_learner import *
from fastai.dataset import *

import json, pdb
from PIL import ImageDraw, ImageFont
from matplotlib import patches, patheffects
torch.backends.cudnn.benchmark=True

设置

PATH = Path('data/pascal')
trn_j = json.load((PATH / 'pascal_train2007.json').open())
IMAGES,ANNOTATIONS,CATEGORIES = [
    'images', 'annotations', 'categories'
]
FILE_NAME,ID,IMG_ID,CAT_ID,BBOX = \
    'file_name','id','image_id', 'category_id','bbox'

cats = dict((o[ID], o['name']) for o in trn_j[CATEGORIES])
trn_fns = dict((o[ID], o[FILE_NAME]) for o in trn_j[IMAGES])
trn_ids = [o[ID] for o in trn_j[IMAGES]]

JPEGS = 'VOCdevkit/VOC2007/JPEGImages'
IMG_PATH = PATH/JPEGSdef get_trn_anno():
    trn_anno = collections.defaultdict(lambda:[])
    for o in trn_j[ANNOTATIONS]:
        if not o['ignore']:
            bb = o[BBOX]
            bb = np.array([
                bb[1], bb[0], 
                bb[3]+bb[1]-1, 
                bb[2]+bb[0]-1
            ])
            trn_anno[o[IMG_ID]].append((bb,o[CAT_ID]))
    return trn_anno

trn_anno = get_trn_anno()
def show_img(im, figsize=None, ax=None):
    if not ax: fig,ax = plt.subplots(figsize=figsize)
    ax.imshow(im)
    ax.set_xticks(np.linspace(0, 224, 8))
    ax.set_yticks(np.linspace(0, 224, 8))
    ax.grid()
    ax.set_yticklabels([])
    ax.set_xticklabels([])
    return ax

def draw_outline(o, lw):
    o.set_path_effects([
        patheffects.Stroke(linewidth=lw, foreground='black'), 
        patheffects.Normal()
    ])

def draw_rect(ax, b, color='white'):
    patch = ax.add_patch(patches.Rectangle(
        b[:2], *b[-2:], 
        fill=False, 
        edgecolor=color, 
        lw=2
    ))
    draw_outline(patch, 4)

def draw_text(ax, xy, txt, sz=14, color='white'):
    text = ax.text(
        *xy, txt,
        verticalalignment='top', 
        color=color, 
        fontsize=sz, 
        weight='bold'
    )
    draw_outline(text, 1)
def bb_hw(a): 
    return np.array([a[1],a[0],a[3]-a[1],a[2]-a[0]])

def draw_im(im, ann):
    ax = show_img(im, figsize=(16,8))
    for b,c in ann:
        b = bb_hw(b)
        draw_rect(ax, b)
        draw_text(ax, b[:2], cats[c], sz=16)

def draw_idx(i):
    im_a = trn_anno[i]
    im = open_image(IMG_PATH/trn_fns[i])
    draw_im(im, im_a)

多类别[26:12]

MC_CSV = PATH/'tmp/mc.csv'
trn_anno[12]
'''
[(array([ 96, 155, 269, 350]), 7)]
'''
mc = [set([cats[p[1]] for p in trn_anno[o]]) for o in trn_ids]
mcs = [' '.join(str(p) for p in o) for o in mc]
df = pd.DataFrame({
    'fn': [trn_fns[o] for o in trn_ids], 
    'clas': mcs
}, columns=['fn','clas'])
df.to_csv(MC_CSV, index=False)

有一个学生指出,通过使用 Pandas,我们可以比使用collections.defaultdict更简单地完成一些事情,并分享了这个gist。您越了解 Pandas,就越会意识到它是解决许多不同问题的好方法。

问题:当您在较小的模型基础上逐步构建时,您是否重复使用它们作为预训练权重?还是将其丢弃然后从头开始重新训练?当 Jeremy 像这样逐步弄清楚事情时,他通常倾向于丢弃,因为重用预训练权重会引入不必要的复杂性。但是,如果他试图达到一个可以在非常大的图像上训练的点,他通常会从更小的模型开始,并经常重用这些权重。

f_model=resnet34
sz=224
bs=64
tfms = tfms_from_model(f_model, sz, crop_type=CropType.NO)
md = ImageClassifierData.from_csv(PATH, JPEGS, MC_CSV, tfms=tfms)
learn = ConvLearner.pretrained(f_model, md)
learn.opt_fn = optim.Adamlr = 2e-2
learn.fit(lr, 1, cycle_len=3, use_clr=(32,5))
'''
epoch      trn_loss   val_loss   <lambda>                  
    0      0.104836   0.085015   0.972356  
    1      0.088193   0.079739   0.972461                   
    2      0.072346   0.077259   0.974114
[0.077258907, 0.9741135761141777]
'''
lrs = np.array([lr/100, lr/10, lr])
learn.freeze_to(-2)learn.fit(lrs/10, 1, cycle_len=5, use_clr=(32,5))
'''
epoch      trn_loss   val_loss   <lambda>                   
    0      0.063236   0.088847   0.970681  
    1      0.049675   0.079885   0.973723                   
    2      0.03693    0.076906   0.975601                   
    3      0.026645   0.075304   0.976187                   
    4      0.018805   0.074934   0.975165
[0.074934497, 0.97516526281833649]
'''
learn.save('mclas')
learn.load('mclas')
y = learn.predict()
x,_ = next(iter(md.val_dl))
x = to_np(x)
fig, axes = plt.subplots(3, 4, figsize=(12, 8))
for i,ax in enumerate(axes.flat):
    ima=md.val_ds.denorm(x)[i]
    ya = np.nonzero(y[i]>0.4)[0]
    b = '\n'.join(md.classes[o] for o in ya)
    ax = show_img(ima, ax=ax)
    draw_text(ax, (0,0), b)
plt.tight_layout()

多类别分类非常直接。在这一行中使用set的一个小调整,以便每种对象类型只出现一次。

mc = [set([cats[p[1]] for p in trn_anno[o]]) for o in trn_ids]

SSD 和 YOLO

我们有一个输入图像通过卷积网络,输出大小为4+c的向量,其中c=len(cats)。这为我们提供了一个用于单个最大对象的对象检测器。现在让我们创建一个可以找到 16 个对象的检测器。显而易见的方法是取最后一个线性层,而不是有4+c个输出,我们可以有16x(4+c)个输出。这为我们提供了 16 组类别概率和 16 组边界框坐标。然后我们只需要一个损失函数,检查这 16 组边界框是否正确表示了图像中的最多 16 个对象(我们将在后面讨论损失函数)。

第二种方法是,与其使用nn.linear,不如从我们的 ResNet 卷积主干中取出并添加一个带有步幅 2 的nn.Conv2d?这将给我们一个4x4x[# of filters]张量 - 这里让我们将其设为4x4x(4+c),以便得到一个元素数量与我们想要的元素数量完全相等的张量。现在,如果我们创建一个损失函数,接受一个4x4x(4+c)张量,并将其映射到图像中的 16 个对象,并检查每个对象是否由这些4+c激活正确表示,这也可以起作用。事实证明,这两种方法实际上都被使用。从一个完全连接的线性层输出一个很长的向量的方法被一类模型使用,这类模型被称为YOLO(You Only Look Once),而卷积激活的方法被一些从SSD(Single Shot Detector)开始的模型使用。由于这些东西在 2015 年末几乎同时出现,事情在很大程度上朝着 SSD 发展。所以今天早上,YOLO 版本 3发布了,现在正在使用 SSD,这就是我们要做的。我们还将了解为什么这样做更有意义。

锚框

假设我们有另一个Conv2d(stride=2),那么我们将有一个2x2x(4+c)张量。基本上,它创建了一个看起来像这样的网格:

这是第二个额外的卷积步幅 2 层激活的几何形状。请记住,步幅 2 卷积对激活的几何形状做的事情与步幅 1 卷积后跟着最大池化假设填充正常的激活几何形状是一样的。

让我们谈谈我们可能在这里做什么。我们希望每个网格单元负责查找图像该部分中最大的对象。

感受野

为什么我们关心每个卷积网格单元负责找到图像相应部分中的事物的想法?原因是因为有一个叫做卷积网格单元的感受野。基本思想是,在您的卷积层中,这些张量的每一部分都有一个感受野,这意味着负责计算该单元的输入图像的哪个部分。就像生活中的所有事物一样,最容易通过 Excel 来看到这一点[38:01]。

取一个激活(在这种情况下是在最大池层)并看看它来自哪里[38:45]。在 Excel 中,您可以执行公式 → 跟踪前导。一直追溯到输入层,您可以看到它来自图像的这个 6 x 6 部分(以及滤波器)。更重要的是,中间部分有很多权重从外部的细胞中出来,而外部的细胞只有一个权重出来。所以我们称这 6 x 6 个单元格为我们选择的一个激活的感受野。

3x3 卷积,不透明度为 15% —— 明显地,盒子的中心有更多的依赖关系

请注意,感受野不仅仅是说这是一个盒子,而且盒子的中心有更多的依赖关系[40:27],当涉及到理解架构以及理解为什么卷积网络工作方式时,这是一个至关重要的概念。

架构 [41:18]

架构是,我们将有一个 ResNet 主干,后面跟着一个或多个 2D 卷积(现在只有一个),这将给我们一个4x4的网格。

class StdConv(nn.Module):
    def __init__(self, nin, nout, stride=2, drop=0.1):
        super().__init__()
        self.conv = nn.Conv2d(
            nin, nout, 3, 
            stride=stride, 
            padding=1
        )
        self.bn = nn.BatchNorm2d(nout)
        self.drop = nn.Dropout(drop)

    def forward(self, x): 
        return self.drop(self.bn(F.relu(self.conv(x))))

def flatten_conv(x,k):
    bs,nf,gx,gy = x.size()
    x = x.permute(0,2,3,1).contiguous()
    return x.view(bs,-1,nf//k)
class OutConv(nn.Module):
    def __init__(self, k, nin, bias):
        super().__init__()
        self.k = k
        self.oconv1 = nn.Conv2d(
            nin, (len(id2cat)+1)*k, 3, 
            padding=1
        )
        self.oconv2 = nn.Conv2d(nin, 4*k, 3, padding=1)
        self.oconv1.bias.data.zero_().add_(bias)

    def forward(self, x):
        return [
            flatten_conv(self.oconv1(x), self.k),
            flatten_conv(self.oconv2(x), self.k)
        ]
class SSD_Head(nn.Module):
    def __init__(self, k, bias):
        super().__init__()
        self.drop = nn.Dropout(0.25)
        self.sconv0 = StdConv(512,256, stride=1)
        self.sconv2 = StdConv(256,256)
        self.out = OutConv(k, 256, bias)

    def forward(self, x):
        x = self.drop(F.relu(x))
        x = self.sconv0(x)
        x = self.sconv2(x)
        return self.out(x)

head_reg4 = SSD_Head(k, -3.)
models = ConvnetBuilder(f_model, 0, 0, 0, custom_head=head_reg4)
learn = ConvLearner(md, models)
learn.opt_fn = optim.Adam

SSD_Head

  1. 我们从 ReLU 和 dropout 开始

  2. 然后是步幅为 1 的卷积。我们从步幅为 1 的卷积开始的原因是因为这不会改变几何形状 —— 它只让我们增加一层额外的计算。它让我们不仅可以创建一个线性层,而且现在我们的自定义头部中有一个小型神经网络。StdConv在上面定义了 —— 它执行卷积、ReLU、BatchNorm 和 dropout。您看到的大多数研究代码不会像这样定义一个类,而是一遍又一遍地写整个代码。不要这样做。重复的代码会导致错误和理解不足。

  3. 步幅为 2 的卷积 [44:56]

  4. 最后,步骤 3 的输出是4x4,传递给OutConvOutConv有两个单独的卷积层,每个都是步幅为 1,因此不会改变输入的几何形状。其中一个的长度是类别数(现在忽略k+1是为了“背景” —— 即没有检测到对象),另一个的长度是 4。与其有一个输出4+c的单个卷积层,不如有两个卷积层并将它们的输出返回到列表中。这使得这些层可以稍微专门化。我们谈到了这样一个想法,当您有多个任务时,它们可以共享层,但它们不必共享所有层。在这种情况下,我们的两个任务是创建一个分类器和创建和创建边界框回归,除了最后一个层外,它们共享每一个层。

  5. 最后,我们展平卷积,因为 Jeremy 编写的损失函数期望展平的张量,但我们完全可以重写它以不这样做。

Fastai 编码风格 [42:58]

第一版本本周发布。它非常重视阐述性编程的概念,即编程代码应该是您可以用来解释一个想法的东西,理想情况下,可以像数学符号一样容易地向理解您编码方法的人解释。这个想法已经存在很长时间了,但最好的描述是杰里米最崇拜的计算机科学英雄肯·艾弗森在 1979 年的图灵奖演讲中描述的。他在 1964 年之前就一直在研究这个问题,但 1964 年是他发布这种编程方法的第一个例子,称为 APL,25 年后,他获得了图灵奖。然后他把接力棒传给了他的儿子埃里克·艾弗森。Fastai 风格指南是对这些想法的一种尝试。

损失函数[47:44]

损失函数需要查看这 16 组激活中的每一组,每组都有四个边界框坐标和c+1类概率,并决定这些激活是否接近或远离图像中与该网格单元最接近的对象。如果没有任何东西,那么它是否正确地预测了背景。这是非常难做到的。

匹配问题[48:43]

损失函数需要将图像中的每个对象与这些卷积网格单元中的一个进行匹配,以便说“这个网格单元负责这个特定对象”,然后它可以继续说“好的,这 4 个坐标有多接近,类概率有多接近”。

这是我们的目标[49:56]:

我们的因变量看起来像左边的那个,我们最终的卷积层将是4x4x(c+1),在这种情况下c=20。然后我们将其展平为一个向量。我们的目标是设计一个函数,该函数接受一个因变量和模型输出的一些特定激活,并在这些激活不是地面真实边界框的良好反映时返回更高的数字;或者如果是一个好的反映,则返回更低的数字。

测试[51:58]

x,y = next(iter(md.val_dl))
x,y = V(x),V(y)
learn.model.eval()
batch = learn.model(x)
b_clas,b_bb = batch
b_clas.size(),b_bb.size()
'''
(torch.Size([64, 16, 21]), torch.Size([64, 16, 4]))
'''

确保这些形状是合理的。现在让我们看看地面真实y[53:24]:

idx=7
b_clasi = b_clas[idx]
b_bboxi = b_bb[idx]
ima=md.val_ds.ds.denorm(to_np(x))[idx]
bbox,clas = get_y(y[0][idx], y[1][idx])
bbox,clas
'''
(Variable containing:
  0.6786  0.4866  0.9911  0.6250
  0.7098  0.0848  0.9911  0.5491
  0.5134  0.8304  0.6696  0.9063
 [torch.cuda.FloatTensor of size 3x4 (GPU 0)], 
 Variable containing:
   8
  10
  17
 [torch.cuda.LongTensor of size 3 (GPU 0)])
'''

请注意,边界框坐标已缩放到 0 和 1 之间 - 基本上我们将图像视为 1x1,因此它们是相对于图像大小的。

我们已经有了show_ground_truth函数。这个torch_gt(gt:地面真相)函数简单地将张量转换为 numpy 数组。

def torch_gt(ax, ima, bbox, clas, prs=None, thresh=0.4):
    return show_ground_truth(
        ax, ima, 
        to_np((bbox*224).long()),
        to_np(clas), 
        to_np(prs) 
        if prs is not None 
        else None, thresh
    )
fig, ax = plt.subplots(figsize=(7,7))
torch_gt(ax, ima, bbox, clas)

以上是一个地面真相。这是我们最终卷积层的4x4网格单元[54:44]:

fig, ax = plt.subplots(figsize=(7,7))
torch_gt(ax, ima, anchor_cnr, b_clasi.max(1)[1])

每个正方形框,不同的论文称其为不同的东西。您将听到的三个术语是:锚框、先验框或默认框。我们将坚持使用术语锚框。

对于这个损失函数,我们将通过一个匹配问题,看看这 16 个框中的每一个与给定正方形中的这三个地面真实对象哪一个有最高的重叠量。为了做到这一点,我们必须有一种衡量重叠量的方法,这种标准函数称为 Jaccard 指数(IoU)。

我们将逐个查看这三个对象与每个 16 个锚框的 Jaccard 重叠[57:11]。这将给我们一个3x16矩阵。

这是我们所有锚框(中心、高度、宽度)的坐标

anchors
'''
Variable containing:
 0.1250  0.1250  0.2500  0.2500
 0.1250  0.3750  0.2500  0.2500
 0.1250  0.6250  0.2500  0.2500
 0.1250  0.8750  0.2500  0.2500
 0.3750  0.1250  0.2500  0.2500
 0.3750  0.3750  0.2500  0.2500
 0.3750  0.6250  0.2500  0.2500
 0.3750  0.8750  0.2500  0.2500
 0.6250  0.1250  0.2500  0.2500
 0.6250  0.3750  0.2500  0.2500
 0.6250  0.6250  0.2500  0.2500
 0.6250  0.8750  0.2500  0.2500
 0.8750  0.1250  0.2500  0.2500
 0.8750  0.3750  0.2500  0.2500
 0.8750  0.6250  0.2500  0.2500
 0.8750  0.8750  0.2500  0.2500
[torch.cuda.FloatTensor of size 16x4 (GPU 0)]
'''

这是 3 个地面真实对象和 16 个锚框之间的重叠量:

overlaps = jaccard(bbox.data, anchor_cnr.data)
overlaps
'''
Columns 0 to 7   
0.0000  0.0000  0.0000  0.0000  0.0000  0.0000  0.0000  0.0000    0.0000  0.0000  0.0000  0.0000  0.0000  0.0000  0.0000  0.0000    0.0000  0.0000  0.0000  0.0000  0.0000  0.0000  0.0000  0.0000 Columns 8 to 15   
0.0000  0.0091 0.0922  0.0000  0.0000  0.0315  0.3985  0.0000  0.0356  0.0549 0.0103  0.0000  0.2598  0.4538  0.0653  0.0000  0.0000  0.0000 0.0000  0.1897  0.0000  0.0000  0.0000  0.0000 [torch.cuda.FloatTensor of size 3x16 (GPU 0)]
'''

现在我们可以取维度 1(按行)的最大值,这将告诉我们每个地面真实对象的最大重叠量以及索引:

overlaps.max(1)
'''
(
  0.3985
  0.4538
  0.1897
 [torch.cuda.FloatTensor of size 3 (GPU 0)], 
  14
  13
  11
 [torch.cuda.LongTensor of size 3 (GPU 0)])
'''

我们还将查看维度 0(按列)的最大值,这将告诉我们每个网格单元与所有地面真实对象之间的最大重叠量是多少:

overlaps.max(0)
'''
(
  0.0000
  0.0000
  0.0000
  0.0000
  0.0000
  0.0000
  0.0000
  0.0000
  0.0356
  0.0549
  0.0922
  0.1897
  0.2598
  0.4538
  0.3985
  0.0000
 [torch.cuda.FloatTensor of size 16 (GPU 0)], 
  0
  0
  0
  0
  0
  0
  0
  0
  1
  1
  0
  2
  1
  1
  0
  0
 [torch.cuda.LongTensor of size 16 (GPU 0)])
'''

这里特别有趣的是,它告诉我们每个网格单元与之重叠最多的地面真实对象的索引是什么。零在这里有点过载 - 零可能意味着重叠量为零,也可能意味着它与对象索引零的重叠最大。这将被证明并不重要,但只是供参考。

有一个名为map_to_ground_truth的函数,我们现在不用担心。这是非常简单的代码,但稍微难以理解。基本上它的作用是以 SSD 论文中描述的方式将这两组重叠组合起来,将每个锚框分配给一个地面真实对象。它的分配方式是每个三个(按行最大)都被分配为是。对于其余的锚框,它们被分配给它们与至少 0.5 重叠的任何东西(按列)。如果两者都不适用,则被视为包含背景的单元格。

gt_overlap,gt_idx = map_to_ground_truth(overlaps)
gt_overlap,gt_idx
'''
(
  0.0000
  0.0000
  0.0000
  0.0000
  0.0000
  0.0000
  0.0000
  0.0000
  0.0356
  0.0549
  0.0922
  1.9900
  0.2598
  1.9900
  1.9900
  0.0000
 [torch.cuda.FloatTensor of size 16 (GPU 0)], 
  0
  0
  0
  0
  0
  0
  0
  0
  1
  1
  0
  2
  1
  1
  0
  0
 [torch.cuda.LongTensor of size 16 (GPU 0)])
'''

现在您可以看到所有分配的列表。任何gt_overlap < 0.5的地方都被分配为背景。三行最大锚框具有较高的数字以强制分配。现在我们可以将这些值组合到类别中:

gt_clas = clas[gt_idx]; gt_clas
'''
Variable containing:
  8
  8
  8
  8
  8
  8
  8
  8
 10
 10
  8
 17
 10
 10
  8
  8
[torch.cuda.LongTensor of size 16 (GPU 0)]
'''

然后添加一个阈值,最后得出正在预测的三个类:

thresh = 0.5
pos = gt_overlap > thresh
pos_idx = torch.nonzero(pos)[:,0]
neg_idx = torch.nonzero(1-pos)[:,0]
pos_idx 
'''
 11
 13
 14
[torch.cuda.LongTensor of size 3 (GPU 0)]
'''

这里是每个锚框预测的含义:

gt_clas[1-pos] = len(id2cat)
[id2cat[o] if o<len(id2cat) else 'bg' for o in gt_clas.data]
'''
['bg',
 'bg',
 'bg',
 'bg',
 'bg',
 'bg',
 'bg',
 'bg',
 'bg',
 'bg',
 'bg',
 'sofa',
 'bg',
 'diningtable',
 'chair',
 'bg']
'''

那就是匹配阶段。对于 L1 损失,我们可以:

  1. 取匹配的激活(pos_idx = [11, 13, 14]

  2. 从中减去地面真实边界框

  3. 取差的绝对值

  4. 取平均值。

对于分类,我们可以做一个交叉熵

gt_bbox = bbox[gt_idx]
loc_loss = ((a_ic[pos_idx] - gt_bbox[pos_idx]).abs()).mean()
clas_loss  = F.cross_entropy(b_clasi, gt_clas)
loc_loss,clas_loss
'''
(Variable containing:
 1.00000e-02 
   6.5887
 [torch.cuda.FloatTensor of size 1 (GPU 0)], 
 Variable containing:
  1.0331
 [torch.cuda.FloatTensor of size 1 (GPU 0)])
'''

最终我们将得到 16 个预测的边界框,其中大多数将是背景。如果您想知道它在背景边界框方面的预测是什么,答案是它完全忽略了它。

fig, axes = plt.subplots(3, 4, figsize=(16, 12))
for idx,ax in enumerate(axes.flat):
    ima=md.val_ds.ds.denorm(to_np(x))[idx]
    bbox,clas = get_y(y[0][idx], y[1][idx])
    ima=md.val_ds.ds.denorm(to_np(x))[idx]
    bbox,clas = get_y(bbox,clas); bbox,clas
    a_ic = actn_to_bb(b_bb[idx], anchors)
    torch_gt(
        ax, ima, a_ic, 
        b_clas[idx].max(1)[1], 
        b_clas[idx].max(1)[0].sigmoid(), 
        0.01
    )
plt.tight_layout()

微调 1.我们如何解释激活?

我们解释激活的方式在这里定义:

def actn_to_bb(actn, anchors):
    actn_bbs = torch.tanh(actn)
    actn_centers = (actn_bbs[:,:2]/2 * grid_sizes) + anchors[:,:2]
    actn_hw = (actn_bbs[:,2:]/2+1) * anchors[:,2:]
    return hw2corners(actn_centers, actn_hw)

我们抓取激活,将它们通过tanh(记住tanh与 sigmoid 形状相同,只是缩放到-1 和 1 之间)强制使其在该范围内。然后我们抓取锚框的实际位置,并根据激活值除以二(actn_bbs[:,:2]/2)将它们移动。换句话说,每个预测的边界框可以从其默认位置最多移动一个网格大小的 50%。高度和宽度也是如此 - 它可以是默认大小的两倍大或一半小。

微调 2.我们实际上使用二元交叉熵损失而不是交叉熵

class BCE_Loss(nn.Module):
    def __init__(self, num_classes):
        super().__init__()
        self.num_classes = num_classes

    def forward(self, pred, targ):
        t = one_hot_embedding(targ, self.num_classes+1)
        t = V(t[:,:-1].contiguous())*#.cpu()*
        x = pred[:,:-1]
        w = self.get_weight(x,t)
        return F.binary_cross_entropy_with_logits(
            x, t, w, 
            size_average=False
        ) / self.num_classes

    def get_weight(self,x,t): 
        return None

二元交叉熵是我们通常用于多标签分类的。就像在行星卫星竞赛中,每个卫星图像可能有多个物体。如果它有多个物体,你不能使用 softmax,因为 softmax 真的鼓励只有一个物体有高的数字。在我们的情况下,每个锚框只能与一个物体相关联,所以我们避免使用 softmax 并不是因为这个原因。还有其他原因——即一个锚框可能没有任何与之相关联的物体。处理这种“背景”的想法有两种方法;一种是说背景只是一个类,所以让我们使用 softmax,将背景视为 softmax 可以预测的类之一。很多人都是这样做的。但这是一个非常困难的事情要求神经网络做[1:06:52] — 基本上是在问这个网格单元是否没有我感兴趣的 20 个物体中的任何一个,Jaccard 重叠大于 0.5。这是一个非常难以放入单个计算中的事情。另一方面,如果我们只问每个类;“这是摩托车吗?”“这是公共汽车吗?”“这是一个人吗?”等等,如果所有的答案都是否定的,那就认为是背景。这就是我们在这里做的方式。并不是我们可以有多个真实标签,而是我们可以有零个。

forward中:

  1. 首先我们获取目标的 one hot 编码(在这个阶段,我们已经有了背景的概念)

  2. 然后我们移除背景列(最后一列),结果是一个全为零或全为一的向量。

  3. 使用二元交叉熵预测。

这是一个小的调整,但这是 Jeremy 希望你考虑和理解的小调整,因为它对你的训练有很大的影响,当有一些对以前论文的增量时,会是这样的[1:08:25]。重要的是要理解这是在做什么,更重要的是为什么。

现在我们有[1:09:39]:

  • 一个自定义损失函数

  • 计算 Jaccard 指数的方法

  • 将激活转换为边界框的方法

  • 将锚框映射到地面真实的方法

现在剩下的就是 SSD 损失函数。

SSD 损失函数[1:09:55]

def ssd_1_loss(b_c,b_bb,bbox,clas,print_it=False):
    bbox,clas = get_y(bbox,clas)
    a_ic = actn_to_bb(b_bb, anchors)
    overlaps = jaccard(bbox.data, anchor_cnr.data)
    gt_overlap,gt_idx = map_to_ground_truth(overlaps,print_it)
    gt_clas = clas[gt_idx]
    pos = gt_overlap > 0.4
    pos_idx = torch.nonzero(pos)[:,0]
    gt_clas[1-pos] = len(id2cat)
    gt_bbox = bbox[gt_idx]
    loc_loss = ((a_ic[pos_idx] - gt_bbox[pos_idx]).abs()).mean()
    clas_loss  = loss_f(b_c, gt_clas)
    return loc_loss, clas_loss

def ssd_loss(pred,targ,print_it=False):
    lcs,lls = 0.,0.
    for b_c,b_bb,bbox,clas in zip(*pred,*targ):
        loc_loss,clas_loss = ssd_1_loss(b_c,b_bb,bbox,clas,print_it)
        lls += loc_loss
        lcs += clas_loss
    if print_it: 
        print(f'loc: {lls.data[0]}, clas: {lcs.data[0]}')
    return lls+lcs

ssd_loss函数是我们设置的标准,它循环遍历每个小批量中的图像,并调用ssd_1_loss函数(即一个图像的 SSD 损失)。

ssd_1_loss是所有操作发生的地方。它从bboxclas开始解构。让我们更仔细地看一下get_y[1:10:38]:

def get_y(bbox,clas):
    bbox = bbox.view(-1,4)/sz
    bb_keep = ((bbox[:,2]-bbox[:,0])>0).nonzero()[:,0]
    return bbox[bb_keep],clas[bb_keep]

你在互联网上找到的很多代码都不能用于小批量。它一次只能做一件事,而我们不想要这样。在这种情况下,所有这些函数(get_yactn_to_bbmap_to_ground_truth)都是在一次处理,不完全是一个小批量,而是一次处理一堆地面真实对象。数据加载器每次被馈送一个小批量以执行卷积层。因为我们可以在每个图像中有不同数量的地面真实对象,但张量必须是严格的矩形形状,fastai 会自动用零填充它(任何较短的目标值)[1:11:08]。这是最近添加的一个功能,非常方便,但这意味着你必须确保去掉这些零。因此,get_y会去掉任何只是填充的边界框。

  1. 去掉填充

  2. 将激活转换为边界框

  3. 计算 Jaccard 指数

  4. 进行地面真实的映射

  5. 检查是否有大约 0.4~0.5 的重叠(不同的论文使用不同的值)

  6. 找到匹配的索引

  7. 为那些不匹配的分配背景类

  8. 然后最终得到定位的 L1 损失,分类的二元交叉熵损失,并将它们返回,加入ssd_loss

训练

learn.crit = ssd_loss
lr = 3e-3
lrs = np.array([lr/100,lr/10,lr])
learn.lr_find(lrs/1000,1.)
learn.sched.plot(1)
'''
epoch      trn_loss   val_loss                            
    0      44.232681  21476.816406
'''

learn.lr_find(lrs/1000,1.)
learn.sched.plot(1)
'''
epoch      trn_loss   val_loss                            
    0      86.852668  32587.789062
'''

learn.fit(lr, 1, cycle_len=5, use_clr=(20,10))
'''
epoch      trn_loss   val_loss                            
    0      45.570843  37.099854 
    1      37.165911  32.165031                           
    2      33.27844   30.990122                           
    3      31.12054   29.804482                           
    4      29.305789  28.943184
[28.943184]
'''
learn.fit(lr, 1, cycle_len=5, use_clr=(20,10))
'''
epoch      trn_loss   val_loss                            
    0      43.726979  33.803085 
    1      34.771754  29.012939                           
    2      30.591864  27.132868                           
    3      27.896905  26.151638                           
    4      25.907382  25.739273
[25.739273]
'''
learn.save('0')
learn.load('0')

结果

在实践中,我们希望去除背景,并为概率添加一些阈值,但这是正确的方向。盆栽植物图像,结果并不令人惊讶,因为我们所有的锚盒都很小(4x4 网格)。要从这里走向更准确的东西,我们要做的就是创建更多的锚盒。

问题:对于多标签分类,为什么我们不像以前那样将分类损失乘以一个常数?很好的问题。因为后来会发现我们不需要这样做。

更多的锚点!

有 3 种方法可以做到这一点:

  1. 创建不同尺寸的锚盒(缩放):

从左边(1x1、2x2、4x4 的锚盒网格)。注意一些锚盒比原始图像大。

  1. 创建不同长宽比的锚盒:

  1. 使用更多卷积层作为锚盒的来源(盒子被随机抖动,以便我们可以看到重叠的盒子):

结合这些方法,你可以创建很多锚盒(Jeremy 说他不会打印出来,但这里有):

anc_grids = [4, 2, 1]
anc_zooms = [0.75, 1., 1.3]
anc_ratios = [(1., 1.), (1., 0.5), (0.5, 1.)]

anchor_scales = [
    (anz*i,anz*j) 
    for anz in anc_zooms 
    for (i,j) in anc_ratios
]
k = len(anchor_scales)
anc_offsets = [1/(o*2) for o in anc_grids]
anc_x = np.concatenate([
    np.repeat(np.linspace(ao, 1-ao, ag), ag)
    for ao,ag in zip(anc_offsets,anc_grids)
])
anc_y = np.concatenate([
    np.tile(np.linspace(ao, 1-ao, ag), ag)
    for ao,ag in zip(anc_offsets,anc_grids)
])
anc_ctrs = np.repeat(np.stack([anc_x,anc_y], axis=1), k, axis=0)
anc_sizes = np.concatenate([
    np.array([
        [o/ag,p/ag] 
        for i in range(ag*ag) 
        for o,p in anchor_scales
    ])
    for ag in anc_grids
])
grid_sizes = V(np.concatenate([
    np.array([ 
        1/ag 
        for i in range(ag*ag) 
        for o,p in anchor_scales
    ])
    for ag in anc_grids
]), requires_grad=False).unsqueeze(1)
anchors = V(
    np.concatenate([anc_ctrs, anc_sizes], axis=1), 
    requires_grad=False
).float()
anchor_cnr = hw2corners(anchors[:,:2], anchors[:,2:])

锚点:中间和高度,宽度

anchor_cnr:左上角和右下角

关键概念回顾

  • 我们有一个地面真相的向量(一组 4 个边界框坐标和一个类)

  • 我们有一个神经网络,它接受一些输入并输出一些输出激活

  • 比较激活和地面真相,计算损失,找到该导数的导数,并根据导数乘以学习率调整权重。

  • 我们需要一个损失函数,可以接受地面真相和激活,并输出一个数字,表示这些激活有多好。为了做到这一点,我们需要考虑每一个m个地面真相对象,并决定哪组(4+c)激活负责该对象 — 我们应该比较哪一个来决定类是否正确,边界框是否接近(匹配问题)。

  • 由于我们使用 SSD 方法,所以我们匹配的对象并不是任意的。我们希望匹配的是接收域密度最大的激活集,从真实对象所在的地方。

  • 损失函数需要是一些一致的任务。如果在第一幅图像中,左上角的对象对应于前 4+c 个激活,并且在第二幅图像中,我们把事物扔来扔去,突然它现在与最后的 4+c 个激活一起,神经网络就不知道要学习什么。

  • 一旦匹配问题解决了,其余的就和单个对象检测一样。

架构:

  • YOLO — 最后一层是全连接的(没有几何概念)

  • SSD — 最后一层是卷积

k(缩放 x 比率)

对于每个可能具有不同大小的网格单元,我们可以有不同的方向和缩放,代表不同的锚框,这些锚框就像是每个锚框都与我们模型中的一个4+c激活集相关联的概念性想法。因此,无论我们有多少个锚框,我们都需要有那么多次(4+c)激活。这并不意味着每个卷积层都需要那么多激活。因为 4x4 卷积层已经有 16 组激活,2x2 层有 4 组激活,最后 1x1 层有一组激活。所以我们基本上可以免费获得 1 + 4 + 16。因此,我们只需要知道k,其中k是缩放数乘以宽高比数。而网格,我们将通过我们的架构免费获得。

模型架构

drop=0.4

class SSD_MultiHead(nn.Module):
    def __init__(self, k, bias):
        super().__init__()
        self.drop = nn.Dropout(drop)
        self.sconv0 = StdConv(512,256, stride=1, drop=drop)
        self.sconv1 = StdConv(256,256, drop=drop)
        self.sconv2 = StdConv(256,256, drop=drop)
        self.sconv3 = StdConv(256,256, drop=drop)
        self.out1 = OutConv(k, 256, bias)
        self.out2 = OutConv(k, 256, bias)
        self.out3 = OutConv(k, 256, bias)

    def forward(self, x):
        x = self.drop(F.relu(x))
        x = self.sconv0(x)
        x = self.sconv1(x)
        o1c,o1l = self.out1(x)
        x = self.sconv2(x)
        o2c,o2l = self.out2(x)
        x = self.sconv3(x)
        o3c,o3l = self.out3(x)
        return [
            torch.cat([o1c,o2c,o3c], dim=1),
            torch.cat([o1l,o2l,o3l], dim=1)
        ]

head_reg4 = SSD_MultiHead(k, -4.)
models = ConvnetBuilder(f_model, 0, 0, 0, custom_head=head_reg4)
learn = ConvLearner(md, models)
learn.opt_fn = optim.Adam

模型几乎与之前的模型相同。但我们有许多步长为 2 的卷积,这将带我们到 4x4、2x2 和 1x1(每个步长为 2 的卷积都会将我们的网格大小在两个方向上减半)。

  • 在我们进行第一次卷积以达到 4x4 后,我们将从中获取一组输出,因为我们想要保存 4x4 的锚点。

  • 一旦我们到达 2x2,我们再抓取一组 2x2 的锚点

  • 最后我们到达 1x1

  • 然后我们将它们全部连接在一起,这给我们正确数量的激活(每个锚框一个激活)。

训练

learn.crit = ssd_loss
lr = 1e-2
lrs = np.array([lr/100,lr/10,lr])
learn.lr_find(lrs/1000,1.)
learn.sched.plot(n_skip_end=2)

learn.fit(lrs, 1, cycle_len=4, use_clr=(20,8))
'''
epoch      trn_loss   val_loss                            
    0      15.124349  15.015433 
    1      13.091956  10.39855                            
    2      11.643629  9.4289                              
    3      10.532467  8.822998
[8.822998]
'''
learn.save('tmp')
learn.freeze_to(-2)
learn.fit(lrs/2, 1, cycle_len=4, use_clr=(20,8))
'''
epoch      trn_loss   val_loss                            
    0      9.821056   10.335152 
    1      9.419633   11.834093                           
    2      8.78818    7.907762                            
    3      8.219976   7.456364
[7.4563637]
'''
x,y = next(iter(md.val_dl))
y = V(y)
batch = learn.model(V(x))
b_clas,b_bb = batch
x = to_np(x)

fig, axes = plt.subplots(3, 4, figsize=(16, 12))
for idx,ax in enumerate(axes.flat):
    ima=md.val_ds.ds.denorm(x)[idx]
    bbox,clas = get_y(y[0][idx], y[1][idx])
    a_ic = actn_to_bb(b_bb[idx], anchors)
    torch_gt(
        ax, ima, a_ic, 
        b_clas[idx].max(1)[1], 
        b_clas[idx].max(1)[0].sigmoid(), 
        0.2
    )
plt.tight_layout()

在这里,我们打印出那些至少概率为0.2的检测结果。有些看起来很有希望,但有些则不太好。

目标检测的历史

使用深度神经网络的可扩展目标检测

  • 当人们提到多框法时,他们指的是这篇论文。

  • 这篇论文提出了一个损失函数的想法,该函数具有匹配过程,然后可以用来进行目标检测。因此,自那时以来,一切都在尝试找出如何使其更好。

实时目标检测与区域提议网络

  • 同时,Ross Girshick 正在走一条完全不同的方向。他有这两个阶段的过程,第一阶段使用经典的计算机视觉方法来找到边缘和梯度变化,猜测图像的哪些部分可能代表不同的对象。然后将每个对象放入一个卷积神经网络中,这个网络基本上是设计用来确定我们感兴趣的对象的类型。

  • R-CNN 和 Fast R-CNN 是传统计算机视觉和深度学习的混合体。

  • Ross 和他的团队接着做的是,他们采用了多框法的思想,用卷积网络替换了他们两阶段过程中传统的非深度学习计算机视觉部分。现在他们有两个卷积网络:一个用于区域提议(可能是对象的所有东西),第二部分与他之前的工作相同。

统一、实时目标检测

单次多框检测器(SSD)

  • 在同一时间,这些论文出现了。这两篇论文做了一些非常酷的事情,就是他们实现了与 Faster R-CNN 相似的性能,但只用了 1 个阶段。

  • 他们采用了多框法,并试图找出如何处理混乱的输出。基本思想是使用,例如,硬负样本挖掘,他们会遍历所有看起来不太好的匹配项并将其丢弃,使用非常棘手和复杂的数据增强方法,以及各种技巧。但他们让它们运行得相当不错。

密集目标检测的焦点损失(RetinaNet)

  • 然后去年年底发生了一件非常酷的事情,那就是焦点损失。

  • 他们实际上意识到为什么这个混乱的东西不起作用。当我们查看图像时,有 3 种不同的卷积网格粒度(4x4、2x2、1x1)。1x1 很可能与某个对象有合理的重叠,因为大多数照片都有某种主题。另一方面,在 4x4 网格单元中,大多数 16 个锚框不会与任何东西有太多重叠。因此,如果有人对你说“20 美元赌注,你认为这个小片段是什么?”而你不确定,你会说“背景”,因为大多数时候,它是背景。

问题:我理解为什么我们在图像中有一个 4x4 网格的感受野,每个都有一个锚框来粗略定位对象。但我觉得我不明白的是为什么我们需要不同尺寸的多个感受野。第一个版本已经包括了 16 个感受野,每个都有一个关联的单个锚框。通过添加,现在有更多的锚框要考虑。这是因为您限制了感受野可以从其原始大小移动或缩放的程度吗?还是有其他原因?这有点反向。Jeremy 做约束的原因是因为他知道他以后会添加更多的框。但实际上,原因是 4x4 网格单元之一与占据图像大部分的单个对象的图像之间的 Jaccard 重叠永远不会达到 0.5。交集远小于并集,因为对象太大。因此,为了使这个一般想法起作用,我们说你负责的东西与之有 50%以上的重叠,我们需要锚框,这些锚框将定期具有 50%或更高的重叠,这意味着我们需要具有各种大小、形状和比例的锚框。所有这些都发生在损失函数中。所有目标检测中大部分有趣的东西都在损失函数中。

焦点损失

关键是这张第一张图片。蓝线是二元交叉熵损失。如果答案不是摩托车,我说“我认为这不是摩托车,我有 60%的把握”用蓝线,损失仍然约为 0.5,这相当糟糕。所以,如果我们想降低损失,那么对于所有这些实际上是背景的东西,我们必须说“我确定那是背景”,“我确定这不是摩托车,公共汽车或人” — 因为如果我不说我们确定它不是这些东西中的任何一个,那么我们仍然会有损失。

这就是为什么摩托车的例子不起作用。因为即使它到达右下角并想说“我认为这是一辆摩托车”,也没有回报。如果错了,就会被淘汰。而且大多数时候,它是背景。即使不是背景,仅仅说“这不是背景”是不够的 — 你必须说它是 20 件事物中的哪一个。

所以诀窍是尝试找到一个更像紫线的不同损失函数。焦点损失实际上只是一个缩放的交叉熵损失。现在如果我们说“我有 60%的把握这不是摩托车”,那么损失函数会说“干得好!没问题”。

这篇论文的实际贡献是在方程的开头添加(1 − pt)^γ,听起来像无关紧要的事情,但实际上人们多年来一直在努力解决这个问题。当你遇到这样一个改变游戏规则的论文时,不要假设你将不得不编写成千上万行的代码。很多时候只是一行代码,或者改变一个常数,或者在一个地方添加对数。

关于这篇论文的一些了不起的事情[1:46:08]:

  • 方程式以简单的方式编写

  • 他们“重构”

实现焦点损失[1:49:27]:

记住,-log(pt)是交叉熵损失,焦点损失只是一个缩放版本。当我们定义二项式交叉熵损失时,您可能已经注意到默认情况下没有权重:

class BCE_Loss(nn.Module):
    def __init__(self, num_classes):
        super().__init__()
        self.num_classes = num_classes

    def forward(self, pred, targ):
        t = one_hot_embedding(targ, self.num_classes+1)
        t = V(t[:,:-1].contiguous()) #.cpu()
        x = pred[:,:-1]
        w = self.get_weight(x,t)
        return F.binary_cross_entropy_with_logits(
            x, t, w, 
            size_average=False
        ) / self.num_classes

    def get_weight(self,x,t): return None

当您调用F.binary_cross_entropy_with_logits时,可以传入权重。由于我们只想将交叉熵乘以某个值,我们可以定义get_weight。这是焦点损失的全部内容[1:50:23]:

class FocalLoss(BCE_Loss):
    def get_weight(self,x,t):
        alpha,gamma = 0.25,2.
        p = x.sigmoid()
        pt = p*t + (1-p)*(1-t)
        w = alpha*t + (1-alpha)*(1-t)
        return w * (1-pt).pow(gamma)

如果您想知道为什么 alpha 和 gamma 是 0.25 和 2,这篇论文的另一个优点是,因为他们尝试了许多不同的值,并发现这些值效果很好:

训练[1:51:25]

learn.lr_find(lrs/1000,1.)
learn.sched.plot(n_skip_end=2)

learn.fit(lrs, 1, cycle_len=10, use_clr=(20,10))
'''
epoch      trn_loss   val_loss                            
    0      24.263046  28.975235 
    1      20.459562  16.362392                           
    2      17.880827  14.884829                           
    3      15.956896  13.676485                           
    4      14.521345  13.134197                           
    5      13.460941  12.594139                           
    6      12.651842  12.069849                           
    7      11.944972  11.956457                           
    8      11.385798  11.561226                           
    9      10.988802  11.362164
[11.362164]
'''
learn.save('fl0')
learn.load('fl0')
learn.freeze_to(-2)
learn.fit(lrs/4, 1, cycle_len=10, use_clr=(20,10))
'''
epoch      trn_loss   val_loss                            
    0      10.871668  11.615532 
    1      10.908461  11.604334                           
    2      10.549796  11.486127                           
    3      10.130961  11.088478                           
    4      9.70691    10.72144                            
    5      9.319202   10.600481                           
    6      8.916653   10.358334                           
    7      8.579452   10.624706                           
    8      8.274838   10.163422                           
    9      7.994316   10.108068
[10.108068]
'''
learn.save('drop4')
learn.load('drop4')
plot_results(0.75)

这次情况看起来好多了。因此,我们现在的最后一步是基本上弄清楚如何只提取感兴趣的部分。

非极大值抑制[1:52:15]

我们要做的就是遍历每对这些边界框,如果它们重叠超过一定数量,比如 0.5,使用 Jaccard 并且它们都预测相同的类别,我们将假设它们是相同的东西,并且我们将选择具有更高p值的那个。

这是非常无聊的代码,Jeremy 自己没有写,而是复制了别人的。没有特别的原因要去研究它。

def nms(boxes, scores, overlap=0.5, top_k=100):
    keep = scores.new(scores.size(0)).zero_().long()
    if boxes.numel() == 0: 
        return keep
    x1 = boxes[:, 0]
    y1 = boxes[:, 1]
    x2 = boxes[:, 2]
    y2 = boxes[:, 3]
    area = torch.mul(x2 - x1, y2 - y1)
    v, idx = scores.sort(0)  # sort in ascending order
    idx = idx[-top_k:]  # indices of the top-k largest vals
    xx1 = boxes.new()
    yy1 = boxes.new()
    xx2 = boxes.new()
    yy2 = boxes.new()
    w = boxes.new()
    h = boxes.new()

    count = 0
    while idx.numel() > 0:
        i = idx[-1]  # index of current largest val
        keep[count] = i
        count += 1
        if idx.size(0) == 1: break
        idx = idx[:-1]  # remove kept element from view
        # load bboxes of next highest vals
        torch.index_select(x1, 0, idx, out=xx1)
        torch.index_select(y1, 0, idx, out=yy1)
        torch.index_select(x2, 0, idx, out=xx2)
        torch.index_select(y2, 0, idx, out=yy2)
        # store element-wise max with next highest score
        xx1 = torch.clamp(xx1, min=x1[i])
        yy1 = torch.clamp(yy1, min=y1[i])
        xx2 = torch.clamp(xx2, max=x2[i])
        yy2 = torch.clamp(yy2, max=y2[i])
        w.resize_as_(xx2)
        h.resize_as_(yy2)
        w = xx2 - xx1
        h = yy2 - yy1
        # check sizes of xx1 and xx2.. after each iteration
        w = torch.clamp(w, min=0.0)
        h = torch.clamp(h, min=0.0)
        inter = w*h
        # IoU = i / (area(a) + area(b) - i)
        rem_areas = torch.index_select(area, 0, idx)  
        # load remaining areas)
        union = (rem_areas - inter) + area[i]
        IoU = inter/union  # store result in iou
        # keep only elements with an IoU <= overlap
        idx = idx[IoU.le(overlap)]
    return keep, count
def show_nmf(idx):
    ima=md.val_ds.ds.denorm(x)[idx]
    bbox,clas = get_y(y[0][idx], y[1][idx])
    a_ic = actn_to_bb(b_bb[idx], anchors)
    clas_pr, clas_ids = b_clas[idx].max(1)
    clas_pr = clas_pr.sigmoid()

    conf_scores = b_clas[idx].sigmoid().t().data

    out1,out2,cc = [],[],[]
    for cl in range(0, len(conf_scores)-1):
        c_mask = conf_scores[cl] > 0.25
        if c_mask.sum() == 0: 
            continue
        scores = conf_scores[cl][c_mask]
        l_mask = c_mask.unsqueeze(1).expand_as(a_ic)
        boxes = a_ic[l_mask].view(-1, 4)
        ids, count = nms(boxes.data, scores, 0.4, 50)
        ids = ids[:count]
        out1.append(scores[ids])
        out2.append(boxes.data[ids])
        cc.append([cl]*count)
    cc = T(np.concatenate(cc))
    out1 = torch.cat(out1)
    out2 = torch.cat(out2)

    fig, ax = plt.subplots(figsize=(8,8))
    torch_gt(ax, ima, out2, cc, out1, 0.1)
    for i in range(12): 
        show_nmf(i)

这里还有一些需要修复的地方[1:53:43]。技巧将是使用称为特征金字塔的东西。这就是我们将在第 14 课中做的事情。

更多关于 SSD 论文的讨论[1:54:03]

当这篇论文出来时,Jeremy 很兴奋,因为这和 YOLO 是第一种单次通过的高质量目标检测方法。在深度学习世界中存在这种连续的历史重复,即涉及多次通过多个不同部分的事物,特别是当它们涉及一些非深度学习部分(如 R-CNN)时,随着时间的推移,它们总是被转化为单一的端到端深度学习模型。因此,我倾向于忽略它们,直到发生这种情况,因为那是人们已经找到如何将其展示为深度学习模型的时候,一旦他们这样做,它们通常会变得更快更准确。因此,SSD 和 YOLO 非常重要。

这个模型有 4 段。论文非常简洁,这意味着您需要非常仔细地阅读它们。但部分原因是,您需要知道哪些部分需要仔细阅读。当他们说“在这里我们将证明该模型的误差界限”时,您可以忽略,因为您不关心证明误差界限。但是当他们说这就是模型时,您需要仔细阅读。

Jeremy 阅读了一个部分2.1 模型[1:56:37]

如果您直接阅读这样的论文,这 4 段可能毫无意义。但是现在我们已经阅读过了,您阅读这些内容时,希望会想到“哦,这就是 Jeremy 说的,只是他们比 Jeremy 说得更好,用词更少[2:00:37]。如果您开始阅读一篇论文并说“到底是什么”,那么技巧就是开始回顾引文。

Jeremy 阅读匹配策略训练目标(也称为损失函数)[2:01:44]

一些论文提示[2:02:34]

使用深度神经网络的可扩展目标检测

  • “训练目标”是损失函数

  • 双条和两个 2,像这样表示均方误差

  • log(c)和 log(1-c),以及 x 和(1-x)它们都是二元交叉熵的组成部分。

这周,浏览代码和论文,看看发生了什么。记住 Jeremy 为了让你更容易理解,他将损失函数复制到一个单元格中,并将其拆分,使每个部分都在单独的单元格中。然后在每次出售后,他打印或绘制该值。希望这是一个好的起点。

深度学习 2:第 2 部分第 10 课

原文:medium.com/@hiromi_suenaga/deep-learning-2-part-2-lesson-10-422d87c3340c

译者:飞龙

协议:CC BY-NC-SA 4.0

来自 fast.ai 课程的个人笔记。随着我继续复习课程以“真正”理解它,这些笔记将继续更新和改进。非常感谢 JeremyRachel 给了我这个学习的机会。

视频 / 论坛

上周回顾[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_textstrn_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我们希望它变成don’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_textsget_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.adocadocAsciiDoc。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中,而LanguageModelBasicModel的子类,除了定义层组之外几乎什么都不做。记住当我们进行区分性学习率时,不同的层有不同的学习率或者我们冻结不同的数量时,我们不会为每一层提供不同的学习率,因为可能有一千层。我们为每个层组提供不同的学习率。所以当你创建一个自定义模型时,你只需要覆盖这一点,它返回所有层组的列表。在这种情况下,最后一个层组包含模型的最后部分和一个 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

你会看到有两个不同版本的savesave像往常一样保存整个模型。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 课程的个人笔记。随着我继续复习课程以“真正”理解它,这些笔记将继续更新和改进。非常感谢 JeremyRachel 给了我这个学习的机会。

链接

论坛 / 视频

开始之前:

  • 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. 数据

  2. 架构

  3. 损失函数

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 输出并提取三个内容:在所有时间步上的maxpoolmeanpool,以及在最后一个时间步上的 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,y
    def __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.data
    miss = []
    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_sl
        self.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.data

    def 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(): 
                break
        return 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]:

  1. 如果生成的序列长度短于目标序列长度,我们需要添加一些填充。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_encoderload_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_sl
        self.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.data

    def 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(): 
                break
        return 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 0
        xtra = []
        output = self.m(*xs, y)
        if isinstance(output,tuple): 
            output,*xtra = output
        self.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 clipping
            nn.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_sl
        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.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.data
        self.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(): break
            if (y is not None) and (random.random()<self.pr_force):
                if i>=len(y): 
                    break
                dec_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.stepstepper.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 0
        return 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): 
        break
    dec_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_sl
        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.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.W1
        for 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(): 
                break
            if (y is not None) and (random.random()<self.pr_force):
                if i>=len(y): 
                    break
                dec_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=True

import 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: 
        continue
    vec = 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: 
        continue
    vec = 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 觉得这太疯狂和令人惊讶了。

好的,下周见!

深度学习 2:第 2 部分第 12 课

原文:medium.com/@hiromi_suenaga/deep-learning-2-part-2-lesson-12-215dfbf04a94

译者:飞龙

协议:CC BY-NC-SA 4.0

来自 fast.ai 课程的个人笔记。随着我继续复习课程以“真正”理解它,这些笔记将继续更新和改进。非常感谢 JeremyRachel 给了我这个学习的机会。

生成对抗网络(GANs)

视频 / 论坛

非常炙手可热的技术,但绝对值得成为课程中前沿深度学习部分的一部分,因为它们并不完全被证明对任何事情都有用,但它们几乎到了那个地步,肯定会成功。我们将专注于那些在实践中肯定会有用的事情,有许多领域可能会被证明有用,但我们还不知道。所以我认为它们在实践中肯定会有用的领域是你在幻灯片左侧看到的那种东西 —— 例如将绘画转化为渲染图片。这来自于两天前刚刚发布的一篇论文,所以目前正在进行非常活跃的研究。

从上一堂课 [1:04]: 我们的多样性研究员 Christine Payne 拥有斯坦福大学的医学硕士学位,因此她对构建医学语言模型感兴趣。我们在第四课中简要提到过的一件事,但上次并没有详细讨论的是,你实际上可以种子一个生成式语言模型,这意味着你已经在某个语料库上训练了一个语言模型,然后你将从该语言模型生成一些文本。你可以通过输入一些词来开始,告诉它“这是用来创建语言模型中隐藏状态的前几个词,请从这里生成”。Christine 做了一些聪明的事情,她用一个问题作为种子,重复这个问题三次,然后让它从那里生成。她向语言模型输入了许多不同的医学文本,并输入了下面看到的问题:

Jeremy 发现这个有趣的地方是,对于没有医学硕士学位的人来说,这几乎是一个可信的答案。但它与现实完全没有关系。他认为这是一种有趣的伦理和用户体验困境。Jeremy 参与了一个名为 doc.ai 的公司,该公司试图做很多事情,但最终提供一个应用程序供医生和患者使用,可以帮助他们解决医疗问题。他一直在对团队中的软件工程师说,请不要尝试使用 LSTM 或其他东西创建生成模型,因为它们会擅长创造听起来令人印象深刻但实际上是错误建议的东西 —— 就像政治评论员或终身教授可以以极大的权威说废话一样。所以他认为这是一个非常有趣的实验。如果你做了一些有趣的实验,请在论坛、博客、Twitter 上分享。让人们知道并受到了了不起的人的关注。

CIFAR10 [5:26]

让我们谈谈 CIFAR10,原因是今天我们将看一些更基础的 PyTorch 内容,以构建这些生成对抗模型。目前没有关于 GAN 的 fastai 支持 - 很快就会有,但目前还没有,所以我们将从头开始构建许多模型。我们已经有一段时间没有进行严肃的模型构建了。在课程的第一部分中,我们看了 CIFAR10,并构建了一个准确率约为 85%的模型,训练时间约为几个小时。有趣的是,现在正在进行一项竞赛,看看谁能最快地训练 CIFAR10(DAWN),目标是将准确率提高到 94%。看看我们是否能构建一个能够达到 94%准确率的架构,因为这比我们之前的尝试要好得多。希望通过这样做,我们将学到一些关于创建良好架构的东西,这对于今天研究 GANs 将会很有用。此外,这也很有用,因为 Jeremy 在过去几年深入研究了关于不同类型 CNN 架构的论文,并意识到这些论文中的许多见解并没有被广泛利用,显然也没有被广泛理解。因此,他想向您展示如果我们能利用其中一些理解会发生什么。

cifar10-darknet.ipynb [7:17]

这个笔记本被称为darknet,因为我们将要查看的特定架构与 darknet 架构非常接近。但在这个过程中,您会发现 darknet 架构并不是整个 YOLO v3 端到端的东西,而只是他们在 ImageNet 上预训练用于分类的部分。这几乎就像您可以想到的最通用的简单架构,因此它是实验的一个很好的起点。因此,我们将其称为“darknet”,但它并不完全是那样,您可以对其进行调整以创建绝对不是 darknet 的东西。它实际上只是几乎任何现代基于 ResNet 的架构的基础。

CIFAR10 是一个相当小的数据集[8:06]。图像大小仅为 32x32,这是一个很好的数据集,因为:

  • 与 ImageNet 不同,您可以相对快速地对其进行训练

  • 相对较少的数据

  • 实际上很难识别这些图像,因为 32x32 太小了,很难看清楚发生了什么。

这是一个被低估的数据集,因为它很老。谁愿意使用小而古老的数据集,当他们可以利用整个服务器房间来处理更大的数据时。但这是一个非常好的数据集,值得关注。

继续导入我们通常使用的东西,我们将尝试从头开始构建一个网络来训练这个[8:58]。

%matplotlib inline
%reload_ext autoreload
%autoreload 2
from fastai.conv_learner import *
PATH = Path("data/cifar10/")
os.makedirs(PATH,exist_ok=True)

对于那些对广播和 PyTorch 基本技能不是 100%自信的人来说,一个非常好的练习是弄清楚 Jeremy 是如何得出这些stats数字的。这些数字是 CIFAR10 中每个通道的平均值和标准差。尝试确保您可以重新创建这些数字,并查看是否可以在不超过几行代码的情况下完成(不使用循环!)。

由于这些数据相当小,我们可以使用比通常更大的批量大小,并且这些图像的大小为 32[9:46]。

classes = (
    'plane', 'car', 'bird', 
    'cat', 'deer', 'dog', 'frog', 
    'horse', 'ship', 'truck'
)
stats = (
    np.array([ 0.4914 ,  0.48216,  0.44653]), 
    np.array([ 0.24703,  0.24349,  0.26159])
)

num_workers = num_cpus()//2
bs=256
sz=32

变换[9:57],通常我们有这一套标准的 side_on 变换,用于普通物体的照片。我们不会在这里使用这个,因为这些图像太小了,尝试将一个 32x32 的图像稍微旋转会引入很多块状失真。人们倾向于使用的标准变换是随机水平翻转,然后我们在每一侧添加 4 个像素(尺寸除以 8)的填充。一个非常有效的方法是,默认情况下 fastai 不会添加黑色填充,而许多其他库会这样做。Fastai 会取现有照片的最后 4 个像素,翻转并反射它,我们发现使用反射填充会得到更好的结果。现在我们有了 40x40 的图像,在训练中,这组变换将随机选择 32x32 的裁剪,所以我们会有一点变化但不会太多。因此我们可以使用正常的from_paths来获取我们的数据。

tfms = tfms_from_stats(
    stats, sz, 
    aug_tfms=[RandomFlip()], 
    pad=sz//8
)
data = ImageClassifierData.from_paths(
    PATH, 
    val_name='test', 
    tfms=tfms, 
    bs=bs
)

现在我们需要一个架构,我们将创建一个适合在一个屏幕上显示的架构[11:07]。这是从头开始的。我们正在使用预定义的Conv2dBatchNorm2dLeakyReLU模块,但我们没有使用任何块或其他东西。整个东西都在一个屏幕上,所以如果你曾经想知道我是否能理解一个现代的高质量架构,绝对可以!让我们来学习这个。

def conv_layer(ni, nf, ks=3, stride=1):
    return nn.Sequential(
        nn.Conv2d(
            ni, nf, 
            kernel_size=ks, 
            bias=False, 
            stride=stride,
            padding=ks//2
        ),
        nn.BatchNorm2d(nf, momentum=0.01),
        nn.LeakyReLU(negative_slope=0.1, inplace=True)
    )
class ResLayer(nn.Module):
    def __init__(self, ni):
        super().__init__()
        self.conv1=conv_layer(ni, ni//2, ks=1)
        self.conv2=conv_layer(ni//2, ni, ks=3)

    def forward(self, x): 
        return x.add_(self.conv2(self.conv1(x)))
class Darknet(nn.Module):
    def make_group_layer(self, ch_in, num_blocks, stride=1):
        return [conv_layer(ch_in, ch_in*2,stride=stride)] + \
               [(ResLayer(ch_in*2)) for i in range(num_blocks)]

    def __init__(self, num_blocks, num_classes, nf=32):
        super().__init__()
        layers = [conv_layer(3, nf, ks=3, stride=1)]
        for i,nb in enumerate(num_blocks):
            layers += self.make_group_layer(nf, nb, stride=2-(i==1))
            nf *= 2
        layers += [
            nn.AdaptiveAvgPool2d(1), 
            Flatten(), 
            nn.Linear(nf, num_classes)
        ]
        self.layers = nn.Sequential(*layers)

    def forward(self, x): 
        return self.layers(x)

架构的基本起点是说它是一堆堆叠的层,一般来说会有一种层次结构[11:51]。在最底层,有像卷积层和批量归一化层这样的东西,但任何时候你有一个卷积,你可能会有一些标准的顺序。通常会是:

  1. 卷积

  2. 批量归一化

  3. 一个非线性激活(例如 ReLU)

我们将从确定我们的基本单元是什么开始,并在一个函数(conv_layer)中定义它,这样我们就不必担心保持一致性,这将使一切变得更简单。

Leaky Relu [12:43]:

Leaky ReLU 的梯度(其中x < 0)会有所变化,但通常是 0.1 或 0.01 左右。其背后的想法是,当你处于负区域时,你不会得到一个零梯度,这会使更新变得非常困难。实践中,人们发现 Leaky ReLU 在较小的数据集上更有用,在大数据集上不太有用。但有趣的是,在YOLO v3论文中,他们使用了 Leaky ReLU,并从中获得了很好的性能。它很少会使事情变得更糟,通常会使事情变得更好。所以如果你需要创建自己的架构,Leaky ReLU 可能不错作为默认选择。

你会注意到我们在conv_layer中没有定义 PyTorch 模块,我们只是使用nn.Sequential[14:07]。如果你阅读其他人的 PyTorch 代码,你会发现这是一个被低估的东西。人们倾向于将一切都写成 PyTorch 模块,带有__init__forward,但如果你想要的只是一系列按顺序排列的东西,将其作为Sequential会更简洁易懂。

残差块 [14:40]:如前所述,大多数现代网络中通常有多个层次的单元,我们现在知道 ResNet 中这个单元层次结构的下一个级别是 ResBlock 或残差块(参见ResLayer)。回顾我们上次做 CIFAR10 时,我们过于简化了(有点作弊)。我们将x输入,经过一个conv,然后将其加回到x中输出。在真正的 ResBlock 中,有两个这样的块。当我们说conv时,我们将其作为conv_layer的快捷方式(卷积,批量归一化,ReLU)。

这里有一个有趣的观点是这些卷积中的通道数量。我们有一些 ni 进来(一些输入通道/滤波器的数量)。Darknet 团队设置的方式是,他们让每一个这些 Res 层输出与进来的相同数量的通道,Jeremy 喜欢这样做,这就是为什么他在ResLayer中使用它,因为这样会让生活更简单。第一个卷积将通道数量减半,然后第二个卷积再将其加倍。所以你有一个漏斗效应,64 个通道进来,通过第一个卷积压缩到 32 个通道,然后再次提升到 64 个通道输出。

问题:为什么LeakyReLU中要使用inplace=True?谢谢你的提问!很多人忘记了这一点或者不知道这一点,但这是一个非常重要的内存技巧。如果你想一下,这个conv_layer,它是最底层的东西,所以基本上我们的 ResNet 一旦全部组装起来,就会有很多conv_layer。如果你没有inplace=True,它会为 ReLU 的输出创建一个完全独立的内存块,这样就会分配一大堆完全不必要的内存。另一个例子是ResLayer中的原始forward看起来像这样:

def forward(self, x): 
    return x + self.conv2(self.conv1(x))

希望你们中的一些人记得在 PyTorch 中几乎每个函数都有一个下划线后缀版本,告诉它在原地执行。+等同于addadd的原地版本是add_,这样可以减少内存使用量:

def forward(self, x): 
    return x.add_(self.conv2(self.conv1(x)))

这些都是非常方便的小技巧。Jeremy 一开始忘记了inplace=True,但他不得不将批量大小降低到非常低的数量,这让他发疯了——然后他意识到那个部分缺失了。如果你使用了 dropout,你也可以这样做。以下是需要注意的事项:

  • Dropout

  • 所有激活函数

  • 任何算术操作

问题:在 ResNet 中,为什么conv_layer中的偏置通常设置为 False?在Conv之后,紧接着是BatchNorm。记住,BatchNorm对于每个激活有 2 个可学习参数——你要乘以的东西和你要添加的东西。如果我们在Conv中有偏置,然后在BatchNorm中再添加另一件事,那就是在添加两件事,这完全没有意义——这是两个权重,一个就够了。所以如果在Conv之后有一个BatchNorm,你可以告诉BatchNorm不要包括添加部分,或者更简单的方法是告诉Conv不要包括偏置。这没有特别的危害,但是会占用更多内存,因为它需要跟踪更多的梯度,所以最好避免。

另一个小技巧是,大多数人的conv_layer都有填充作为参数。但一般来说,你应该能够很容易地计算填充。如果卷积核大小为 3,那么显然每边会有一个单位的重叠,所以我们需要填充 1。或者,如果卷积核大小为 1,那么我们就不需要任何填充。所以一般来说,卷积核大小“整数除以 2”就是你需要的填充。有时会有一些调整,但在这种情况下,这个方法非常有效。再次尝试简化我的代码,让计算机为我计算东西,而不是我自己去做。

另一个关于这两个conv_layer的事情:我们有这个瓶颈的想法(减少通道然后再增加),还有要使用的卷积核大小。第一个有 1 乘 1 的Conv。1 乘 1 卷积实际上发生了什么?如果我们有一个 4 乘 4 的网格,有 32 个滤波器/通道,我们将进行 1 乘 1 卷积,卷积的核看起来像中间的那个。当我们谈论卷积核大小时,我们从来没有提到最后一部分——但假设它是 1 乘 1 乘 32,因为这是输入和输出的滤波器的一部分。卷积核被放在黄色的第一个单元上,我们得到这 32 个深度位的点积,这给了我们第一个输出。然后我们将其移动到第二个单元并得到第二个输出。所以对于网格中的每个点,都会有一堆点积。这使我们能够以任何我们想要的方式改变通道维度。我们创建了ni//2个滤波器,我们将有ni//2个点积,基本上是输入通道的不同加权平均值。通过非常少的计算,它让我们添加了这个额外的计算和非线性步骤。这是一个很酷的技巧,利用这些 1 乘 1 卷积,创建这个瓶颈,然后再用 3 乘 3 卷积拉出来——这将充分利用输入的 2D 特性。否则,1 乘 1 卷积根本不利用这一点。

这两行代码,里面没有太多内容,但这是一个对你对正在发生的事情的理解和直觉的很好的测试——为什么它有效?为什么张量秩是对齐的?为什么维度都很好地对齐?为什么这是一个好主意?它到底在做什么?这是一个很好的东西来调整。也许在 Jupyter Notebook 中创建一些小的实例,自己运行一下,看看输入和输出是什么。真正感受一下。一旦你这样做了,你就可以尝试不同的东西。

这篇真正被低估的论文是这篇Wide Residual Networks。这篇论文非常简单,但他们做的是围绕这两行代码进行调整:

  • 如果我们用ni*2代替ni//2会怎样?

  • 如果我们添加conv3呢?

他们提出了一种简单的符号表示来定义这两行代码可能的样子,并展示了许多实验。他们展示的是,在 ResNet 中普遍采用的减少通道数量的瓶颈方法可能不是一个好主意。实际上,根据实验结果,绝对不是一个好主意。因为这样可以创建非常深的网络。创建 ResNet 的人因为创建了 1001 层网络而变得特别有名。但是 1001 层的问题在于,你无法在完成第 1 层之前计算第 2 层。你无法在完成计算第 2 层之前计算第 3 层。所以是顺序的。GPU 不喜欢顺序。所以他们展示的是,如果层数较少但每层计算量更大——一个简单的方法是去掉//2,没有其他改变:

在家里试试吧。尝试运行 CIFAR 看看会发生什么。甚至乘以 2 或者摆弄一下。这样可以让你的 GPU 做更多的工作,这非常有趣,因为绝大多数关于不同架构性能的论文实际上从来没有计算运行一个批次需要多长时间。他们说“这个需要每批次 X 个浮点运算”,但他们从来没有真正费心像一个合格的实验者那样运行它,找出它是快还是慢。现在很有名的很多架构结果都很慢,占用大量内存,完全没用,因为研究人员从来没有费心看看它们是否快,实际上看看它们是否适合正常批次大小的内存。所以 Wide ResNet 论文之所以不同在于它实际上计算了运行所需的时间,YOLO v3 论文也做了同样的发现。他们可能错过了 Wide ResNet 论文,因为 YOLO v3 论文得出了很多相同的结论,但 Jeremy 不确定他们是否引用了 Wide ResNet 论文,所以他们可能不知道所有这些工作已经完成。看到人们实际上在计时并注意到什么是有意义的是很好的。

问题:你对 SELU(缩放指数线性单元)有什么看法?[29:44] SELU 主要用于全连接层,它允许你摆脱批量归一化,基本思想是,如果你使用这种不同的激活函数,它是自归一化的。自归一化意味着它将始终保持单位标准差和零均值,因此你不需要批量归一化。它实际上并没有取得什么进展,原因是因为它非常挑剔 — 你必须使用非常特定的初始化,否则它就不会以完全正确的标准差和均值开始。很难将其用于诸如嵌入之类的东西,如果你这样做,那么你必须使用一种特定类型的嵌入初始化,这对嵌入来说是没有意义的。你做了所有这些工作,很难搞对,最终如果你搞对了,有什么意义呢?好吧,你成功摆脱了一些并没有真正伤害你的批量归一化层。有趣的是 SELU 论文 — 人们注意到它的主要原因是因为它是由 LSTM 的发明者创建的,而且它有一个巨大的数学附录。所以人们认为“一个名人的大量数学 — 必定很棒!”但实际上,Jeremy 没有看到任何人使用它来获得任何最先进的结果或赢得任何比赛。

Darknet.make_group_layer包含一堆ResLayer[31:28]。group_layer将会有一些通道/滤波器进入。我们将通过使用标准的conv_layer来使进入的通道数量加倍。可选地,我们将通过使用步幅为 2 来减半网格大小。然后我们将做一系列的 ResLayers — 我们可以选择多少个(2、3、8 等),因为记住 ResLayers 不会改变网格大小,也不会改变通道数量,所以你可以添加任意数量而不会造成任何问题。这将使用更多的计算和内存,但除此之外你可以添加任意数量。因此,group_layer最终将使通道数量加倍,因为初始卷积使通道数量加倍,取决于我们传入的stride,如果我们设置stride=2,它也可能减半网格大小。然后我们可以做一系列 Res 块的计算,任意数量。

定义我们的Darknet,我们将传入类似这样的东西[33:13]:

m = Darknet([1, 2, 4, 6, 3], num_classes=10, nf=32)
m = nn.DataParallel(m, [1,2,3])

这意味着创建五个组层:第一个将包含 1 个额外的 ResLayer,第二个将包含 2 个,然后是 4 个,6 个,3 个,我们希望从 32 个滤波器开始。第一个 ResLayers 将包含 32 个滤波器,只会有一个额外的 ResLayer。第二个将会使滤波器数量翻倍,因为每次有一个新的组层时我们都会这样做。所以第二个将有 64 个,然后 128 个,256 个,512 个,就这样。几乎整个网络将由这些层组成,记住,每个组层在开始时也有一个卷积。所以在这之前,我们将在一开始有一个卷积层,在最后我们将执行标准的自适应平均池化,展平,并在最后创建一个线性层来生成最终的类别数量。总结一下,一个端有一个卷积,自适应池化和另一个端有一个线性层,中间是这些组层,每个组层由一个卷积层和n个 ResLayers 组成。

自适应平均池化:Jeremy 多次提到过这个,但他还没有看到任何代码,任何示例,任何地方使用自适应平均池化。他看到的每一个都像nn.AvgPool2d(n)这样写,其中n是一个特定的数字-这意味着它现在与特定的图像大小绑定在一起,这绝对不是您想要的。所以大多数人仍然认为特定的架构与特定的大小绑定在一起。当人们认为这样时,这是一个巨大的问题,因为这会严重限制他们使用更小的尺寸来启动建模或使用更小的尺寸进行实验的能力。

Sequential:创建架构的一个好方法是首先创建一个列表,在这种情况下,这是一个只有一个conv_layer的列表,然后make_group_layer返回另一个列表。然后我们可以用+=将该列表附加到前一个列表中,并对包含AdaptiveAvnPool2d的另一个列表执行相同操作。最后,我们将调用所有这些层的nn.Sequential。现在forward只是self.layers(x)

这是如何使您的架构尽可能简单的好方法。有很多可以摆弄的地方。您可以将ni的除数参数化,使其成为您传入的数字,以传入不同的数字-也许是乘以 2。您还可以传入一些可以改变内核大小或改变卷积层数量的参数。Jeremy 有一个版本,他将为您运行,其中实现了 Wide ResNet 论文中的所有不同参数,因此他可以摆弄看看哪些效果好。

lr = 1.3
learn = ConvLearner.from_model_data(m, data)
learn.crit = nn.CrossEntropyLoss()
learn.metrics = [accuracy]
wd=1e-4
%time learn.fit(
    lr, 1, 
    wds=wd, 
    cycle_len=30, 
    use_clr_beta=(20, 20, 0.95, 0.85)
)

一旦我们有了这个,我们可以使用ConvLearner.from_model_data来获取我们的 PyTorch 模块和模型数据对象,并将它们转换为一个学习器。给它一个标准,如果我们喜欢,可以添加一个指标,然后我们可以拟合并开始。

问题:您能解释一下自适应平均池化吗?将其设置为 1 是如何工作的?当我们进行平均池化时,通常情况下,假设我们有 4x4,然后进行avgpool((2, 2))。这将创建一个 2x2 的区域(下方的蓝色),并取这四个的平均值。如果我们传入stride=1,下一个是 2x2(绿色),然后取平均值。这就是正常的 2x2 平均池化。如果我们没有填充,那么输出将是 3x3。如果我们想要 4x4,我们可以添加填充。

如果我们想要 1x1 呢?那么我们可以说avgpool((4,4), stride=1),这将在黄色中进行 4x4 并对整体进行平均,结果为 1x1。但这只是一种方法。与其说池化滤波器的大小,为什么不说“我不在乎输入网格的大小。我总是想要一个一个”。这就是你说adap_avgpool(1)的地方。在这种情况下,你不说池化滤波器的大小,而是说我们想要的输出大小。我们想要的是一个一个。如果你放一个单独的整数n,它会假设你的意思是n乘以n。在这种情况下,一个 4x4 网格的自适应平均池化与平均池化(4,4)相同。如果是一个 7x7 的网格进来,它将与平均池化(7,7)相同。这是相同的操作,只是以一种方式表达,无论输入是什么,我们都希望得到那个大小的输出。

DAWNBench:让我们看看我们的简单网络与这些最新技术结果相比如何。Jeremy 已经准备好命令了。我们已经将所有这些东西放入一个简单的 Python 脚本中,他修改了一些他提到的参数,创建了一个他称之为wrn_22网络,它并不存在,但根据 Jeremy 的实验,它对我们讨论的参数进行了一些改变。它有一堆很酷的东西,比如:

  • 莱斯利·史密斯的一个周期

  • 半精度浮点实现

这将在 AWS p3 上运行,它有 8 个 GPU 和 Volta 架构的 GPU,这些 GPU 对半精度浮点有特殊支持。Fastai 是第一个实际将 Volta 优化的半精度浮点集成到库中的库,所以你只需learn.half()就可以自动获得支持。它也是第一个集成一个周期的库。

实际上,这是使用 PyTorch 的多 GPU 支持。由于有八个 GPU,它实际上会启动八个单独的 Python 处理器,每个处理器都会训练一点,然后最后将梯度更新传回主进程,主进程将把它们全部整合在一起。所以你会看到很多进度条一起弹出。

你可以看到这种方式训练三到四秒。而在之前,当 Jeremy 早些时候训练时,他每个时代要花 30 秒。所以用这种方式,我们可以训练东西大约快 10 倍,这很酷。

检查状态

完成了!我们达到了 94%,用时 3 分 11 秒。之前的最新技术是 1 小时 7 分钟。折腾这些参数,学习这些架构实际上是如何工作的,而不仅仅是使用开箱即用的东西,值得吗?哇哦。我们刚刚使用了一个公开可用的实例(我们使用了一个 spot 实例,所以花费了我们每小时 8 美元——3 分钟 40 美分)来从头开始训练,比以往任何人都要快 20 倍。所以这是最疯狂的最新技术结果之一。我们看到了很多,但这个结果真的让人大吃一惊。这在很大程度上要归功于调整这些架构参数,主要是关于使用莱斯利·史密斯的一个周期。提醒一下它在做什么,对于学习率,它创建了一个向上的路径,与向下的路径一样长,所以它是真正的三角形循环学习率(CLR)。像往常一样,你可以选择 x 和 y 的比例(即起始 LR/峰值 LR)。在

在这种情况下,我们选择了 50 作为比率。所以我们从更小的学习率开始。然后它有一个很酷的想法,你可以说你的 epochs 的百分之几是从三角形底部一直下降到几乎为零 - 这是第二个数字。所以 15%的批次花在从我们的三角形底部进一步下降。

这不是一个周期所做的唯一事情,我们还有动量。动量从 0.95 到 0.85。换句话说,当学习率很低时,我们使用很大的动量,当学习率很高时,我们使用很少的动量,这很有道理,但在 Leslie Smith 在论文中展示之前,Jeremy 从未见过有人这样做。这是一个非常酷的技巧。你现在可以通过在 fastai 中使用use-clr-beta参数来使用它(Sylvain 的论坛帖子),你应该能够复制最先进的结果。你可以在自己的计算机上或者 paper space 上使用它,唯一得不到的是多 GPU 部分,但这样训练会更容易一些。

问题make_group_layer包含步幅等于 2,这意味着第一层的步幅为 1,其他所有层的步幅为 2。背后的逻辑是什么?通常我见过的步幅是奇数。步幅要么是 1,要么是 2。我认为你在考虑卷积核大小。所以步幅=2 意味着我跨越两个,这意味着你的网格大小减半。所以我认为你可能在步幅和卷积核大小之间混淆了。如果步幅为 1,网格大小不会改变。如果步幅为 2,那么会改变。在这种情况下,因为这是 CIFAR10,32x32 很小,我们不会经常减半网格大小,因为很快我们就会用完单元格。这就是为什么第一层的步幅为 1,这样我们不会立即减小网格大小。这是一种很好的做法,因为这就是为什么我们一开始在大网格上没有太多计算Darknet([1, 2, 4, 6, 3], …)。我们可以从大网格上开始,然后随着网格变小,逐渐增加更多的计算,因为网格越小,计算所需的时间就越少。

生成对抗网络(GAN)[48:49]

我们将讨论生成对抗网络,也称为 GAN,具体来说,我们将专注于沃瑟斯坦 GAN 论文,其中包括后来创建 PyTorch 的 Soumith Chintala。沃瑟斯坦 GAN(WGAN)受到了深度卷积生成对抗网络论文的重大影响,Soumith 也参与其中。这是一篇非常有趣的论文。很多内容看起来像这样:

好消息是你可以跳过那些部分,因为还有一个看起来像这样的部分:

很多论文都有一个理论部分,似乎完全是为了满足审稿人对理论的需求。但 WGAN 论文并非如此。理论部分实际上很有趣 - 你不需要了解它就能使用它,但如果你想了解一些很酷的想法,并看到为什么选择这种特定算法的思考过程,那绝对是迷人的。在这篇论文出来之前,Jeremy 不认识任何研究其基础数学的人,所以每个人都必须学习这些数学知识。这篇论文做了很好的工作,列出了所有的要点(你需要自己阅读一些内容)。所以如果你对深入研究某篇论文背后更深层次的数学感兴趣,想看看学习它是什么感觉,我会选择这篇,因为在那个理论部分结束时,你会说“我现在明白他们为什么要设计这种算法了。”

GAN 的基本思想是它是一个生成模型[51:23]。它将创建句子、创建图像或生成一些东西。它将尝试创建一些很难区分生成的东西和真实的东西的东西。因此,生成模型可以用于换脸视频——目前发生的深度伪造和虚假色情非常有争议。它可以用来伪造某人的声音。它可以用来伪造对医学问题的回答——但在这种情况下,它并不是真正的伪造,它可以是对医学问题的生成回答,实际上是一个好的回答,因此你在生成语言。例如,你可以为图像生成标题。因此,生成模型有许多有趣的应用。但一般来说,它们需要足够好,例如,如果你要用它自动为凯丽·费舍尔在下一部星球大战电影中的新场景而她已经不在了,你想尝试生成一个看起来一样的图像,那么它必须欺骗星球大战的观众,让他们认为“好吧,那看起来不像奇怪的凯丽·费舍尔——那看起来像真正的凯丽·费舍尔。或者如果你试图生成对医学问题的回答,你希望生成的英语读起来流畅清晰,并且听起来有权威和意义。生成对抗网络的思想是我们不仅要创建一个生成模型来创建生成的图像,还要创建一个第二个模型,它将尝试挑选哪些是真实的,哪些是生成的(我们将称之为“假的”)。因此,我们有一个生成器,它将创建我们的虚假内容,还有一个鉴别器,它将努力变得擅长识别哪些是真实的,哪些是假的。因此,将有两个模型,它们将是对抗性的,意味着生成器将努力不断提高欺骗鉴别器认为假的是真实的能力,而鉴别器将努力不断提高区分真实和虚假的能力。因此,它们将正面交锋。这基本上就像 Jeremy 刚刚描述的那样[54:14]:

  • 我们将在 PyTorch 中构建两个模型

  • 我们将创建一个训练循环,首先说鉴别器的损失函数是“你能分辨真实和虚假吗,然后更新那个的权重。

  • 我们将为生成器创建一个损失函数,即“你能生成一些能欺骗鉴别器的东西并从中更新权重。

  • 然后我们将循环几次并看看会发生什么。

查看代码[54:52]

笔记本

GAN 有很多不同的用途。我们将做一些有点无聊但易于理解的事情,而且甚至可能的是我们将从无中生成一些图片。我们只是让它画一些图片。具体来说,我们将让它画卧室的图片。希望你有机会在这一周内使用自己的数据集玩耍。如果你选择一个非常多样化的数据集,比如 ImageNet,然后让 GAN 尝试创建 ImageNet 的图片,它往往做得不太好,因为你想要的图片不够清晰。所以最好给它,例如,有一个名为CelebA的数据集,其中包含名人的脸部图片,这对 GAN 非常有效。你可以生成真实但实际上不存在的名人脸。卧室数据集也是一个不错的选择——同一种类型的图片。

有一个叫做 LSUN 场景分类数据集的东西。

from fastai.conv_learner import *
from fastai.dataset import *
import gzip

下载 LSUN 场景分类数据集卧室类别,解压缩它,并将其转换为 jpg 文件(脚本文件夹在dl2文件夹中):

curl 'http://lsun.cs.princeton.edu/htbin/download.cgi?tag=latest&category=bedroom&set=train' -o bedroom.zip
unzip bedroom.zip
pip install lmdb
python lsun-data.py {PATH}/bedroom_train_lmdb --out_dir {PATH}/bedroom

这在 Windows 上没有经过测试 - 如果不起作用,您可以使用 Linux 框来转换文件,然后复制它们。或者,您可以从 Kaggle 数据集中下载这个 20%的样本。

PATH = Path('data/lsun/')
IMG_PATH = PATH/'bedroom'
CSV_PATH = PATH/'files.csv'
TMP_PATH = PATH/'tmp'
TMP_PATH.mkdir(exist_ok=True)

在处理我们的数据时,通过 CSV 路线会更容易。因此,我们生成一个包含我们想要的文件列表和一个虚假标签“0”的 CSV,因为我们实际上根本没有这些标签。一个 CSV 文件包含卧室数据集中的所有内容,另一个包含随机的 10%。这样做很好,因为这样我们在实验时大多数时间可以使用样本,因为即使只是读取列表也需要很长时间,因为有超过一百万个文件。

files = PATH.glob('bedroom/**/*.jpg')

with CSV_PATH.open('w') as fo:
    for f in files: 
        fo.write(f'{f.relative_to(IMG_PATH)},0\n')
        # Optional - sampling a subset of files
CSV_PATH = PATH/'files_sample.csv'
files = PATH.glob('bedroom/**/*.jpg')

with CSV_PATH.open('w') as fo:
    for f in files:
        if random.random()<0.1: 
            fo.write(f'{f.relative_to(IMG_PATH)},0\n')

这看起来非常熟悉。这是在 Jeremy 意识到顺序模型更好之前。因此,如果将这与以前的顺序模型的卷积块进行比较,这里有更多的代码行数——但它做的事情是一样的,卷积,ReLU,批量归一化。

class ConvBlock(nn.Module):
    def __init__(self, ni, no, ks, stride, bn=True, pad=None):
        super().__init__()
        if pad is None: 
            pad = ks//2//stride
        self.conv = nn.Conv2d(
            ni, no, 
            ks, stride, 
            padding=pad, 
            bias=False
        )
        self.bn = nn.BatchNorm2d(no) if bn else None
        self.relu = nn.LeakyReLU(0.2, inplace=True)

    def forward(self, x):
        x = self.relu(self.conv(x))
        return self.bn(x) if self.bn else x

我们要做的第一件事是构建一个鉴别器。鉴别器将接收一幅图像作为输入,并输出一个数字。如果它认为这幅图像是真实的,那么这个数字应该更低。当然,“它为什么输出一个更低的数字”这个问题不会出现在架构中,这将在损失函数中。所以我们所要做的就是创建一个接收图像并输出数字的东西。这些代码的很多部分都是从这篇论文的原始作者那里借来的,所以一些命名方案与我们习惯的不同。但它看起来与我们之前的很相似。我们从卷积(conv,ReLU,批量归一化)开始。然后我们有一堆额外的卷积层——这不会使用残差,所以它看起来与之前非常相似,有一堆额外的层,但这些将是卷积层而不是残差层。最后,我们需要添加足够的步幅为 2 的卷积层,使网格大小减小到不大于 4x4。所以它将继续使用步幅 2,将大小除以 2,并重复直到我们的网格大小不大于 4。这是一个非常好的方法,可以创建网络中所需的任意数量的层,以处理任意大小的图像并将它们转换为固定的已知网格大小。

问题:GAN 是否需要比狗和猫或 NLP 等更多的数据?还是可以相提并论?老实说,我有点尴尬地说我不是 GAN 的专家从业者。我在第一部分教授的东西是我很高兴地说我知道如何做这些事情的最佳方式,所以我可以展示像我们刚刚在 CIFAR10 中所做的那样的最新结果,有一些学生的帮助。我在 GAN 方面一点也不行,所以我不太确定你需要多少。总的来说,似乎需要相当多,但请记住我们在狗和猫方面不需要太多的原因是因为我们有一个预训练模型,我们可以利用预训练的 GAN 模型并微调它们,可能。据我所知,我认为没有人这样做过。这可能是人们考虑和实验的一个非常有趣的事情。也许人们已经这样做了,有一些文献我们还没有接触到。我对 GAN 的主要文献有一些了解,但并不是全部,所以也许我错过了关于 GAN 中迁移学习的一些内容。但这可能是不需要太多数据的诀窍。

问题:是单周期学习率和动量退火加上八个 GPU 并行训练在半精度下的巨大加速?只有消费级 GPU 才能进行半精度计算吗?另一个问题,为什么从单精度到半精度的计算速度提高了 8 倍,而从双精度到单精度只提高了 2 倍?好的,所以 CIFAR10 的结果,从单精度到半精度并不是提高了 8 倍。从单精度到半精度大约快了 2 到 3 倍。NVIDIA 声称张量核心的 flops 性能,在学术上是正确的,但在实践中是没有意义的,因为这真的取决于你需要什么调用来做什么事情——所以半精度大约提高了 2 到 3 倍。所以半精度有所帮助,额外的 GPU 有所帮助,单周期有很大帮助,然后另一个关键部分是我告诉你的参数调整。所以仔细阅读 Wide ResNet 论文,识别他们在那里发现的东西的类型,然后编写一个你刚刚看到的架构的版本,使我们可以轻松地调整参数,整夜不眠地尝试每种可能的不同核大小、核数、层组数、层组大小的组合。记住,我们做了一个瓶颈,但实际上我们更倾向于扩大,所以我们增加了大小,然后减小了,因为这更好地利用了 GPU。所以所有这些结合在一起,我会说单周期也许是最关键的,但每一个都导致了巨大的加速。这就是为什么我们能够在 CIFAR10 的最新技术上取得 30 倍的改进。我们对其他事情有一些想法——在这个 DAWN 基准完成之后,也许我们会尝试更进一步,看看是否可以在某一天打破一分钟。那将很有趣。

class DCGAN_D(nn.Module):
    def __init__(self, isize, nc, ndf, n_extra_layers=0):
        super().__init__()
        assert isize % 16 == 0, "isize has to be a multiple of 16"

        self.initial = ConvBlock(nc, ndf, 4, 2, bn=False)
        csize,cndf = isize/2,ndf
        self.extra = nn.Sequential(*[
            ConvBlock(cndf, cndf, 3, 1)
            for t in range(n_extra_layers)
        ])

        pyr_layers = []
        while csize > 4:
            pyr_layers.append(ConvBlock(cndf, cndf*2, 4, 2))
            cndf *= 2; csize /= 2
        self.pyramid = nn.Sequential(*pyr_layers)

        self.final = nn.Conv2d(cndf, 1, 4, padding=0, bias=False)

    def forward(self, input):
        x = self.initial(input)
        x = self.extra(x)
        x = self.pyramid(x)
        return self.final(x).mean(0).view(1)

所以这是我们的鉴别器。关于架构需要记住的重要事情是它除了有一些输入张量大小和秩,以及一些输出张量大小和秩之外,什么也不做。正如你所看到的,最后一个卷积层只有一个通道。这与我们通常的做法不同,因为通常我们的最后一层是一个线性块。但我们这里的最后一层是一个卷积块。它只有一个通道,但它的网格大小大约是 4x4(不超过 4x4)。所以我们将输出(假设是 4x4),4x4x1 张量。然后我们计算平均值。所以它从 4x4x1 变成一个标量。这有点像最终的自适应平均池化,因为我们有一个通道,我们取平均值。这有点不同——通常我们首先进行平均池化,然后通过一个全连接层来得到我们的输出。但这里是得到一个通道,然后取平均值。Jeremy 怀疑如果我们按照正常方式做会更好,但他还没有尝试过,他也没有足够好的直觉来知道是否漏掉了什么——但如果有人想要尝试在自适应平均池化层和一个具有单个输出的全连接层之后添加一个,那将是一个有趣的实验。

这就是一个鉴别器。假设我们已经有了一个生成器——有人说“好的,这里有一个生成卧室的生成器。我希望你建立一个模型,可以找出哪些是真实的,哪些是假的”。我们将拿取数据集,并标记一堆来自生成器的假卧室图像,以及 LSUN 数据集中真实卧室的一堆图像,然后在每个图像上贴上 1 或 0。然后我们将尝试让鉴别器区分出差异。所以这将是足够简单的。但我们还没有得到一个生成器。我们需要建立一个。我们还没有讨论损失函数——我们将假设有一个损失函数可以做到这一点。

生成器

生成器也是一种架构,本身不会做任何事情,直到我们有损失函数和数据。但张量的秩和大小是什么?生成器的输入将是一个随机数向量。在论文中,他们称之为“先验”。有多大?我们不知道。这个想法是不同的一堆随机数将生成一个不同的卧室。因此,我们的生成器必须将一个向量作为输入,通过顺序模型,将其转换为一个秩为 4 的张量(没有批量维度的秩为 3)-高度乘以宽度乘以 3。因此,在最后一步,nc(通道数)最终将变为 3,因为它将创建一个大小为 3 的通道图像。

class DeconvBlock(nn.Module):
    def __init__(self, ni, no, ks, stride, pad, bn=True):
        super().__init__()
        self.conv = nn.ConvTranspose2d(
            ni, no, 
            ks, stride, 
            padding=pad, bias=False
        )
        self.bn = nn.BatchNorm2d(no)
        self.relu = nn.ReLU(inplace=True)

    def forward(self, x):
        x = self.relu(self.conv(x))
        return self.bn(x) if self.bn else x
class DCGAN_G(nn.Module):
    def __init__(self, isize, nz, nc, ngf, n_extra_layers=0):
        super().__init__()
        assert isize % 16 == 0, "isize has to be a multiple of 16"

        cngf, tisize = ngf//2, 4
        while tisize!=isize: 
            cngf*=2; tisize*=2
        layers = [DeconvBlock(nz, cngf, 4, 1, 0)]

        csize, cndf = 4, cngf
        while csize < isize//2:
            layers.append(DeconvBlock(cngf, cngf//2, 4, 2, 1))
            cngf //= 2; csize *= 2

        layers += [
            DeconvBlock(cngf, cngf, 3, 1, 1) 
            for t in range(n_extra_layers)
        ]
        layers.append(nn.ConvTranspose2d(cngf, nc, 4, 2, 1, bias=False))
        self.features = nn.Sequential(*layers)

    def forward(self, input): 
        return F.tanh(self.features(input))

问题:在 ConvBlock 中,为什么批量归一化在 ReLU 之后(即self.bn(self.relu(...)))?我通常期望先进行 ReLU,然后批量归一化,这实际上是 Jeremy 认为有意义的顺序。我们在 darknet 中使用的顺序是 darknet 论文中使用的顺序,所以每个人似乎对这些事情有不同的顺序。事实上,大多数人对 CIFAR10 有一个不同的顺序,即批量归一化→ReLU→卷积,这是一种奇特的思考方式,但事实证明,对于残差块来说,这通常效果更好。这被称为“预激活 ResNet”。有一些博客文章中,人们已经尝试了不同顺序的事物,似乎这很大程度上取决于特定数据集以及您正在处理的内容,尽管性能差异很小,除非是为了比赛,否则您不会在意。

反卷积

因此,生成器需要从一个向量开始,最终得到一个秩为 3 的张量。我们还不知道如何做到这一点。我们需要使用一种称为“反卷积”的东西,PyTorch 称之为转置卷积-相同的东西,不同的名称。反卷积是一种增加网格大小而不是减小网格大小的东西。因此,像所有事物一样,在 Excel 电子表格中最容易看到。

这是一个卷积。我们开始,假设有一个单通道的 4x4 网格单元。让我们通过一个单输出滤波器的 3x3 核心。所以我们有一个输入通道,一个滤波器核心,如果我们不添加任何填充,最终会得到 2x2。记住,卷积只是核心和适当网格单元的乘积的总和。所以这是我们标准的 3x3 卷积一个通道一个滤波器。

现在的想法是我们想要朝相反的方向发展。我们想要从我们的 2x2 开始,我们想要创建一个 4x4。具体来说,我们想要创建与我们开始的相同的 4x4。我们想通过使用卷积来实现这一点。我们如何做到这一点?

如果我们有一个 3x3 卷积,那么如果我们想要创建一个 4x4 输出,我们将需要创建这么多填充:

因为有这么多填充,我们最终会得到 4x4。所以假设我们的卷积滤波器只是一堆零,那么我们可以通过进行这个减法来计算每个单元格的错误:

然后我们可以通过对这些错误的绝对值求和来获得绝对值之和(L1 损失):

现在我们可以使用优化,在 Excel 中称为“求解器”来进行梯度下降。所以我们将设置总单元格等于最小值,然后尝试通过改变我们的滤波器来减少我们的损失。你可以看到它提出了一个滤波器,使得结果几乎像数据一样。它并不完美,一般来说,你不能假设反卷积可以完全创建出你想要的完全相同的东西,因为这里没有足够的。因为滤波器中有 9 个东西,结果中有 16 个东西。但它做出了一个相当不错的尝试。所以这就是反卷积的样子 - 在一个 2x2 的网格单元上进行步长为 1 的 3x3 反卷积。

问题: 创建一个鉴别器来识别假新闻和真实新闻有多难?你不需要任何特殊的东西 - 那只是一个分类器。所以你可以使用之前课程和第 4 课的 NLP 分类器。在这种情况下,没有生成部分,所以你只需要一个数据集,其中说这些是我们认为是假新闻的东西,这些是我们认为是真实新闻的东西,它应该工作得非常好。据我们所知,如果你尝试,你应该得到和其他人一样好的结果 - 它是否足够实用,Jeremy 不知道。在这个阶段,你能做的最好的事情可能是生成一种分类,说这些东西看起来相当可疑,基于它们的写作方式,然后一些人可以去核实它们。NLP 分类器和 RNN 不能核实事实,但它可以识别这些是以那种高度通俗的风格写成的,通常假新闻就是这样写的,所以也许这些值得关注。这可能是你在不依赖某种外部数据源的情况下所能希望的最好的结果。但重要的是要记住,鉴别器基本上只是一个分类器,你不需要任何特殊的技术,超出我们已经学会的 NLP 分类的范围。

ConvTranspose2d

在 PyTorch 中进行反卷积,只需说:

nn.ConvTranspose2d(ni, no, ks, stride, padding=pad, bias=False)

  • ni: 输入通道的数量

  • no: 输出通道的数量

  • ks: 卷积核大小

它被称为 ConvTranspose 的原因是因为事实证明这与卷积的梯度计算是相同的。这就是为什么他们这样称呼它。

可视化

deeplearning.net/software/theano/tutorial/conv_arithmetic.html

左边的是我们刚刚看到的进行 2x2 反卷积。如果有一个步长为 2,那么你不仅在外面周围有填充,而且你实际上还需要在中间放填充。它们实际上并不是这样实现的,因为这样做很慢。在实践中,你会以不同的方式实现它们,但所有这些都是在幕后发生的,所以你不必担心。我们之前已经讨论过这个卷积算术教程,如果你对卷积仍然不熟悉,并且想要熟悉反卷积,这是一个很好的网站。如果你想看这篇论文,它是A guide to convolution arithmetic for deep learning

DeconvBlock 看起来与 ConvBlock 几乎相同,只是多了一个 Transpose。我们像以前一样进行卷积 → relu → 批量归一化,它有输入滤波器和输出滤波器。唯一的区别是步长为 2 意味着网格大小会加倍而不是减半。

问题:nn.ConvTranspose2dnn.Upsample 似乎做着相同的事情,即从上一层扩展网格大小(高度和宽度)。我们可以说 nn.ConvTranspose2d 总是优于 nn.Upsample 吗,因为 nn.Upsample 仅仅是调整大小并用零或插值填充未知部分吗?不,不能。在 distill.pub 上有一篇名为 反卷积和棋盘伪影 的出色互动论文指出,我们现在正在做的事情极其不理想,但好消息是其他人都在这样做。

看一下这里,你能看到这些棋盘伪影吗?这些都来自实际论文,基本上他们注意到每一篇关于生成模型的论文都有这些棋盘伪影,他们意识到这是因为当您使用大小为三的内核的步幅 2 卷积时,它们会重叠。因此,一些网格单元会获得两倍的激活。

因此,即使您从随机权重开始,最终也会得到一个棋盘状的伪影。所以你越深入,情况就越糟。他们的建议没有那么直接,Jeremy 发现对于大多数生成模型,上采样更好。如果你使用 nn.Upsample,基本上是在做池化的相反操作 —— 它说让我们用四个(2x2)网格单元替换这一个。有许多方法可以进行上采样 —— 一种方法是将所有内容复制到这四个单元格中,另一种方法是使用双线性或双三次插值。有各种技术可以尝试创建平滑的上采样版本,您可以在 PyTorch 中选择任何一种。如果您进行了 2x2 的上采样,然后正常的 3x3 卷积,这是另一种与 ConvTranspose 相同的操作方式 —— 它将网格大小加倍,并对其进行一些卷积运算。对于生成模型,这几乎总是效果更好。在 distil.pub 的出版物中,他们指出也许这是一个好方法,但他们没有直接说出来,而 Jeremy 会直接说出来。话虽如此,对于 GANS,他还没有取得太大的成功,他认为可能需要一些调整才能使其正常工作。问题在于在早期阶段,它没有产生足够的噪音。他尝试过使用上采样的版本,您可以看到噪音看起来并不是很嘈杂。下周当我们研究风格转移和超分辨率时,您将看到 nn.Upsample 真正发挥作用。

生成器,我们现在可以从向量开始。我们可以决定并说好,让我们不把它看作一个向量,而实际上是一个 1x1 的网格单元,然后我们可以将其转换为 4x4,然后是 8x8 等等。这就是为什么我们必须确保它是一个合适的倍数,以便我们可以创建出正确大小的东西。正如您所看到的,它正在做与之前完全相反的事情。它每次使单元格大小增加 2,直到达到我们想要的一半大小,然后最后我们再添加 n 个,步幅为 1。然后我们再添加一个 ConvTranspose 最终得到我们想要的大小,然后我们完成了。最后我们通过一个 tanh,这将强制我们处于零到一的范围内,因为当然我们不希望输出任意大小的像素值。因此,我们有一个生成器架构,它输出一个给定大小的图像,具有正确数量的通道,值在零到一之间。

在这一点上,我们现在可以创建我们的模型数据对象。这些东西需要一段时间来训练,所以我们将其设置为 128x128(只是一个更快的便利方式)。因此,这将是输入的大小,但然后我们将使用转换将其转换为 64x64。

最近有更多的进展,试图将其提高到高分辨率大小,但它们仍然倾向于要求批量大小为 1 或大量的 GPU。所以我们试图做一些可以用单个消费者 GPU 完成的事情。这是一个 64x64 卧室的例子。

bs,sz,nz = 64,64,100
tfms = tfms_from_stats(inception_stats, sz)
md = ImageClassifierData.from_csv(
    PATH, 'bedroom', 
    CSV_PATH, 
    tfms=tfms, 
    bs=128, 
    skip_header=False, 
    continuous=True
)
md = md.resize(128)
x,_ = next(iter(md.val_dl))
plt.imshow(md.trn_ds.denorm(x)[0]);

将它们全部放在一起

我们将几乎所有事情都手动完成,所以让我们继续创建我们的两个模型 - 我们的生成器和鉴别器,正如你所看到的它们是 DCGAN,换句话说,它们是出现在这篇论文中的相同模块。值得回头看一下 DCGAN 论文,看看这些架构是什么,因为假定当你阅读 Wasserstein GAN 论文时,你已经知道这一点。

netG = DCGAN_G(sz, nz, 3, 64, 1).cuda()
netD = DCGAN_D(sz, 3, 64, 1).cuda()

问题:如果我们想要在 0 到 1 之间的值,我们不应该使用 sigmoid 吗?像往常一样,我们的图像已经被归一化为范围从-1 到 1,因此它们的像素值不再在 0 到 1 之间。这就是为什么我们希望值从-1 到 1,否则我们将无法为鉴别器提供正确的输入。

所以我们有一个生成器和一个鉴别器,我们需要一个返回“先验”向量(即一堆噪音)的函数。我们通过创建一堆零来实现这一点。nzz的大小 - 在我们的代码中经常看到一个神秘的字母,那是因为那是他们在论文中使用的字母。这里,z是我们噪音向量的大小。然后我们使用正态分布生成 0 到 1 之间的随机数。这需要是一个变量,因为它将参与梯度更新。

def create_noise(b): 
   return V(torch.zeros(b, nz, 1, 1).normal_(0, 1))
preds = netG(create_noise(4))
pred_ims = md.trn_ds.denorm(preds)

fig, axes = plt.subplots(2, 2, figsize=(6, 6))
for i,ax in enumerate(axes.flat): 
    ax.imshow(pred_ims[i])

这里是创建一些噪音并生成四个不同噪音片段的示例。

def gallery(x, nc=3):
    n,h,w,c = x.shape
    nr = n//nc
    assert n == nr*nc
    return (
        x.reshape(nr, nc, h, w, c)
         .swapaxes(1,2)
         .reshape(h*nr, w*nc, c)
    )

我们需要一个优化器来更新我们的梯度。在 Wasserstein GAN 论文中,他们告诉我们使用 RMSProp:

我们可以很容易地在 PyTorch 中做到这一点:

optimizerD = optim.RMSprop(netD.parameters(), lr = 1e-4)
optimizerG = optim.RMSprop(netG.parameters(), lr = 1e-4)

在论文中,他们建议使用学习率为 0.00005(5e-5),我们发现1e-4似乎有效,所以我们将其增加了一点。

现在我们需要一个训练循环:

为了更容易阅读

训练循环将经过我们选择的一些时代(这将是一个参数)。记住,当你手动完成所有事情时,你必须记住所有手动步骤:

  1. 当你训练模型时,你必须将模块设置为训练模式,并在评估时将其设置为评估模式,因为在训练模式下,批量归一化更新会发生,丢失会发生,在评估模式下,这两个事情会被关闭。

  2. 我们将从我们的训练数据加载器中获取一个迭代器

  3. 我们将看看我们需要经过多少步,然后我们将使用tqdm给我们提供一个进度条,然后我们将经过那么多步。

论文中算法的第一步是更新鉴别器(在论文中,他们称鉴别器为“评论家”,w是评论家的权重)。所以第一步是训练我们的评论家一点点,然后我们将训练我们的生成器一点点,然后我们将回到循环的顶部。论文中的内部for循环对应于我们代码中的第二个while循环。

现在我们要做的是我们现在有一个随机的生成器。所以我们的生成器将生成看起来像噪音的东西。首先,我们需要教我们的鉴别器区分噪音和卧室之间的区别 - 你希望这不会太难。所以我们只是按照通常的方式做,但有一些小调整:

  1. 我们将获取一小批真实卧室照片,这样我们就可以从迭代器中获取下一批,将其转换为变量。

  2. 然后我们将计算损失——这将是鉴别器认为这看起来假的程度(“真实的看起来假吗?”)。

  3. 然后我们将创建一些假图像,为此我们将创建一些随机噪音,并将其通过我们的生成器,这个阶段它只是一堆随机权重。这将创建一个小批量的假图像。

  4. 然后我们将通过与之前相同的鉴别器模块来获取该损失(“假的看起来有多假?”)。记住,当你手动做所有事情时,你必须在循环中将梯度归零(netD.zero_grad())。如果你忘记了这一点,请回到第 1 部分课程,我们从头开始做所有事情。

  5. 最后,总鉴别器损失等于真实损失减去假损失。

所以你可以在这里看到:

他们没有谈论损失,实际上他们只谈论了一个梯度更新。

在 PyTorch 中,我们不必担心获取梯度,我们只需指定损失并调用loss.backward(),然后鉴别器的optimizer.step()。有一个关键步骤,即我们必须将 PyTorch 模块中的所有权重(参数)保持在-0.01 和 0.01 的小范围内。为什么?因为使该算法工作的数学假设仅适用于一个小球。了解为什么这样是有趣的数学是有趣的,但这与这篇论文非常相关,了解它不会帮助你理解其他论文,所以只有在你感兴趣的情况下才去学习。Jeremy 认为这很有趣,但除非你对 GANs 非常感兴趣,否则这不会是你在其他地方会重复使用的信息。他还提到,在改进的 Wasserstein GAN 出现后,有更好的方法来确保你的权重空间在这个紧密球内,即惩罚梯度过高,所以现在有稍微不同的方法来做这个。但这行代码是关键贡献,它是使 Wasserstein GAN 成功的关键:

for p in netD.parameters(): p.data.clamp_(-0.01, 0.01)

在这之后,我们有一个可以识别真实卧室和完全随机糟糕生成的图像的鉴别器。现在让我们尝试创建一些更好的图像。所以现在将可训练的鉴别器设置为 false,将可训练的生成器设置为 true,将生成器的梯度归零。我们的损失再次是生成器的fw(鉴别器)应用于一些更多的随机噪音。所以这与之前完全相同,我们对噪音进行生成,然后将其传递给鉴别器,但这次,可训练的是生成器,而不是鉴别器。换句话说,在伪代码中,更新的是θ,即生成器的参数。它接受噪音,生成一些图像,尝试弄清楚它们是假的还是真实的,并使用这些梯度来更新生成器的权重,而不是之前我们是根据鉴别器来获取梯度,并使用 RMSProp 和 alpha 学习率来更新我们的权重。

def train(niter, first=True):
    gen_iterations = 0
    for epoch in trange(niter):
        netD.train(); netG.train()
        data_iter = iter(md.trn_dl)
        i,n = 0,len(md.trn_dl)
        with tqdm(total=n) as pbar:
            while i < n:
                set_trainable(netD, True)
                set_trainable(netG, False)
                d_iters = (
                    100 
                    if (first and (gen_iterations < 25) 
                              or  (gen_iterations % 500 == 0)) 
                    else 5
                )
                j = 0
                while (j < d_iters) and (i < n):
                    j += 1; i += 1
                    for p in netD.parameters(): 
                        p.data.clamp_(-0.01, 0.01)
                    real = V(next(data_iter)[0])
                    real_loss = netD(real)
                    fake = netG(create_noise(real.size(0)))
                    fake_loss = netD(V(fake.data))
                    netD.zero_grad()
                    lossD = real_loss-fake_loss
                    lossD.backward()
                    optimizerD.step()
                    pbar.update()

                set_trainable(netD, False)
                set_trainable(netG, True)
                netG.zero_grad()
                lossG = netD(netG(create_noise(bs))).mean(0).view(1)
                lossG.backward()
                optimizerG.step()
                gen_iterations += 1

        print(
            f'Loss_D {to_np(lossD)}; Loss_G {to_np(lossG)}; ' + 
            f'D_real {to_np(real_loss)}; Loss_D_fake {to_np(fake_loss)}'
        )

你会发现鉴别器被训练ncritic次(上面代码中的 d_iters),他们将其设置为 5,每次我们训练生成器一次。论文中谈到了这一点,但基本思想是如果鉴别器还不知道如何区分,那么让生成器变得更好是没有意义的。这就是为什么我们有第二个 while 循环。这里是 5:

d_iters = (
    100 
    if (first and (gen_iterations < 25) 
              or  (gen_iterations % 500 == 0)) 
    else 5
)

实际上,稍后的论文中添加的内容或者可能是补充材料是,不时地在开始时,您应该在鉴别器上执行更多步骤,以确保鉴别器是有能力的。

torch.backends.cudnn.benchmark=True

让我们为一个时代进行训练:

train(1, False)0%|          | 0/1 [00:00<?, ?it/s]
100%|██████████| 18957/18957 [19:48<00:00, 10.74it/s]
Loss_D [-0.67574]; Loss_G [0.08612]; D_real [-0.1782]; Loss_D_fake [0.49754]
100%|██████████| 1/1 [19:49<00:00, 1189.02s/it]

然后让我们创建一些噪音,这样我们就可以生成一些示例。

fixed_noise = create_noise(bs)

但在此之前,将学习率降低 10 倍,并再进行一次训练:

set_trainable(netD, True)
set_trainable(netG, True)
optimizerD = optim.RMSprop(netD.parameters(), lr = 1e-5)
optimizerG = optim.RMSprop(netG.parameters(), lr = 1e-5)
train(1, False)
'''
0%|          | 0/1 [00:00<?, ?it/s]
100%|██████████| 18957/18957 [23:31<00:00, 13.43it/s]
Loss_D [-1.01657]; Loss_G [0.51333]; D_real [-0.50913]; Loss_D_fake [0.50744]
100%|██████████| 1/1 [23:31<00:00, 1411.84s/it]
'''

然后让我们使用噪音传递给我们的生成器,然后通过我们的反标准化将其转换回我们可以看到的东西,然后绘制它:

netD.eval(); netG.eval();
fake = netG(fixed_noise).data.cpu()
faked = np.clip(md.trn_ds.denorm(fake),0,1)

plt.figure(figsize=(9,9))
plt.imshow(gallery(faked, 8));

我们有一些卧室。这些不是真实的卧室,有些看起来并不像卧室,但有些看起来很像卧室,这就是想法。这就是 GAN。最好的方法是将 GAN 视为一种基础技术,你可能永远不会像这样使用它,但你会以许多有趣的方式使用它。例如,我们将使用它来创建一个循环 GAN。

问题:为什么要特别使用 RMSProp 作为优化器,而不是 Adam 等等?我不记得论文中有明确讨论过这个问题。我不知道这是实验性的还是理论上的原因。看看论文中是怎么说的。

来自论坛

通过实验,我发现 Adam 和 WGAN 不仅效果更差 - 它导致生成器训练失败。

来自 WGAN 论文:

最后,作为一个负面结果,我们报告说当使用基于动量的优化器(如 Adam [8](具有β1>0))对评论者进行训练时,WGAN 训练有时会变得不稳定,或者当使用高学习率时。由于评论者的损失是非平稳的,基于动量的方法似乎表现更差。我们确定动量可能是一个潜在原因,因为随着损失的增加和样本变得更糟,Adam 步骤和梯度之间的余弦通常变为负值。这种余弦为负值的唯一情况是在这些不稳定的情况下。因此,我们转而使用 RMSProp [21],它被认为在非平稳问题上表现良好

问题: 在训练过程中,检测过拟合的一个合理方法是什么?或者在训练结束后评估这些 GAN 模型的性能的一个方法是什么?换句话说,训练/验证/测试集的概念如何转化为 GANs [1:41:57]?这是一个很棒的问题,很多人开玩笑说 GANs 是唯一不需要测试集的领域,人们利用这一点编造东西并说看起来很棒。GANs 存在一些著名的问题,其中之一被称为模式崩溃。模式崩溃发生在你查看卧室时,结果发现只有三种卧室,每个可能的噪声向量都映射到这三种卧室中的一种。你查看画廊,结果发现它们都是相同的东西或者只有三种不同的东西。模式崩溃很容易看到,如果崩溃到一个很小的模式数量,比如 3 或 4。但如果模式崩溃到 10,000 种模式怎么办?因此,只有 10,000 种可能的卧室,所有的噪声向量都崩溃到这些卧室。你不太可能在我们刚刚看到的画廊视图中看到,因为在 10,000 种卧室中很少会有两个相同的卧室。或者如果每个卧室基本上是输入的直接副本 —— 它基本上记住了一些输入。这可能正在发生吗?事实是,大多数论文在检查这些问题方面做得不好,有时甚至根本不检查。因此,我们如何评估 GANs 甚至也许我们应该真正正确地评估 GANs 是一个现在还不够广泛理解的问题。一些人正在努力推动。Ian Goodfellow 是最著名的深度学习书籍的第一作者,也是 GANs 的发明者,他一直在发送持续的推文提醒人们测试 GANs 的重要性。如果你看到一篇声称有异常 GAN 结果的论文,那么这绝对值得关注。他们是否谈到了模式崩溃?他们是否谈到了记忆化?等等。

问题: GANs 可以用于数据增强吗 [1:45:33]?是的,绝对可以使用 GAN 进行数据增强。你应该吗?我不知道。有一些论文尝试使用 GANs 进行半监督学习。我还没有找到任何特别引人注目的论文,在广泛研究的真正有趣的数据集上展示出最先进的结果。我有点怀疑,原因是在我的经验中,如果用合成数据训练模型,神经网络将变得极其擅长识别你合成数据的具体问题,并且最终学到的将是这些问题。还有很多其他方法可以做半监督模型,效果很好。有一些地方可以工作。例如,你可能还记得 Otavio Good 在第一部分的缩放卷积网络中创建的那个奇妙的可视化,其中显示了字母通过 MNIST,他,至少在那个时候,是自动遥控汽车比赛中的第一名,他使用合成增强数据训练了他的模型,基本上是拿真实的汽车绕着赛道行驶的视频,然后添加了虚假的人和虚假的其他汽车。我认为这样做效果很好,因为 A. 他有点天才,B. 因为我认为他有一个明确定义的小子集需要处理。但总的来说,使用合成数据真的很难。我尝试过几十年使用合成数据和模型(显然不包括 GANs,因为它们是相当新的),但总的来说,这很难做到。非常有趣的研究问题。

Cycle GAN [1:41:08]

Paper / Notebook

我们将使用 cycle GAN 将马变成斑马。您也可以使用它将莫奈的印刷品转变为照片,或将优胜美地夏季的照片转变为冬季。

这将非常简单,因为它只是一个神经网络。我们要做的就是创建一个包含大量斑马照片的输入,并将每个照片与等价的马照片配对,然后训练一个从一个到另一个的神经网络。或者您可以对每幅莫奈的画做同样的事情——创建一个包含该地点照片的数据集……哦等等,这不可能,因为莫奈绘制的地方已经不存在了,也没有确切的斑马版本的马……这将如何运作?这似乎违背了我们对神经网络能做什么以及它们如何做的一切认知。

所以某种方式,这些伯克利的人创造了一个模型,可以将马变成斑马,尽管没有任何照片。除非他们出去画马并拍摄前后照片,但我相信他们没有。那么他们是如何做到的呢?这有点天才。

我知道目前正在进行最有趣的 cycle GAN 实践的人是我们的学生 Helena Sarin。她是我所知道的唯一一位 cycle GAN 艺术家。

以下是她更多令人惊叹的作品,我觉得非常有趣。我在这堂课开始时提到,GANs 属于尚未出现的东西,但它们几乎已经到位了。在这种情况下,世界上至少有一个人正在使用 GANs(具体来说是 cycle GANs)创作美丽而非凡的艺术作品。至少我知道有十几个人正在用神经网络进行有趣的创意工作。创意人工智能领域将会大幅扩展。

这是基本的技巧。这是来自 cycle GAN 论文。我们将有两幅图像(假设我们正在处理图像)。关键是它们不是配对的图像,所以我们没有一组马和等价斑马的数据集。我们有一堆马,一堆斑马。拿一匹马X,拿一匹斑马Y。我们将训练一个生成器(他们在这里称之为“映射函数”),将马变成斑马。我们将称之为映射函数G,并创建一个将斑马变成马的映射函数(也称为生成器),我们将称之为F。我们将创建一个鉴别器,就像以前一样,它将尽可能地识别真假马,我们将称之为Dx。另一个鉴别器,它将尽可能地识别真假斑马,我们将称之为Dy。这是我们的起点。

使这个工作的关键[1:51:27] - 所以我们在这里生成一个损失函数(DxDy)。我们将创建一个叫做循环一致性损失的东西,它说当你用生成器将你的马变成斑马后,检查我是否能识别它是真实的。我们将我们的马变成斑马,然后尝试将那只斑马再变回我们开始的同一匹马。然后我们将有另一个函数,它将检查这匹马是否与原始马相似,这匹马是完全由这只斑马Y生成的,不知道x的任何信息。所以想法是,如果你生成的斑马看起来一点也不像原始马,你就没有机会将其变回原始马。因此,将x-hatx进行比较的损失会非常糟糕,除非你能进入Y再出来,如果你能够创建一个看起来像原始马的斑马,那么你可能能够做到这一点。反之亦然 - 将你的斑马变成一个假马,检查你是否能识别它,然后尝试将其变回原始斑马并检查它是否看起来像原始的。

注意F(斑马到马)和G(马到斑马)正在做两件事。它们都将原始马变成斑马,然后将斑马再变回原始马。所以只有两个生成器。没有一个单独的生成器用于反向映射。你必须使用用于原始映射的相同生成器。这就是循环一致性损失。我认为这是天才。这种事情甚至可能存在的想法。老实说,当这一点出现时,我从未想过我甚至可以尝试解决这个问题。它似乎如此明显地不可能,然后你可以像这样解决它的想法 - 我只是觉得这太聪明了。

看这篇论文中的方程式是很好的,因为它们是很好的例子 - 它们写得相当简单,不像一些瓦瑟斯坦 GAN 论文那样,那些是很多理论证明和其他东西。在这种情况下,它们只是列出了正在发生的事情的方程式。你真的想要达到一个可以阅读并理解它们的程度。

所以我们有一匹马X和一只斑马Y。对于一些映射函数G,这是我们的马到斑马映射函数,然后有一个 GAN 损失,这是我们已经熟悉的一部分,它说我们有一匹马,一只斑马,一个假斑马识别器和一个马斑马生成器。损失就是我们之前看到的 - 我们能够从我们的斑马中画出一只斑马并识别它是真实的还是假的。然后拿一匹马变成一只斑马并识别它是真实的还是假的。然后做一减另一个(在这种情况下,它们里面有一个对数,但对数并不是非常重要)。这就是我们刚刚看到的东西。这就是为什么我们先做了瓦瑟斯坦 GAN。这只是一个标准的数学形式的 GAN 损失。

问题:所有这些听起来很像将一种语言翻译成另一种语言,然后再翻译回原来的语言。GANs 或任何等效物已经尝试过翻译吗?来自论坛的论文。回到我所知道的 — 通常在翻译中,你需要这种配对的输入(即平行文本 — “这是这个英语句子的法语翻译”)。最近有几篇论文显示了在没有配对数据的情况下创建高质量翻译模型的能力。我还没有实施它们,我不理解我没有实施的任何东西,但它们很可能在做同样的基本想法。我们将在本周内研究一下,并回复您。

循环一致性损失:所以我们有一个 GAN 损失,接下来是循环一致性损失。基本思想是我们从我们的马开始,使用我们的斑马生成器创建一匹斑马,然后使用我们的马生成器创建一匹马,并将其与原始马进行比较。这个双线与 1 是 L1 损失 — 差异的绝对值的和。否则,如果这是 2,那么它将是 L2 损失,即平方差的和。

我们现在知道这个波浪线的想法是从我们的马抓取一匹马。这就是我们所说的从分布中取样。有各种各样的分布,但在这些论文中,我们最常用的是经验分布,换句话说,我们有一些数据行,抓取一行。所以这里,它是说从数据中抓取一些东西,我们将称那个东西为x。为了重新概括:

  1. 从我们的马图片中,抓取一匹马

  2. 将其变成斑马

  3. 将其转换回马

  4. 将其与原始图像进行比较并求绝对值的和

  5. 也对斑马进行同样的操作

  6. 然后将两者相加

这就是我们的循环一致性损失。

完整目标

现在我们得到了我们的损失函数,整个损失函数取决于:

  • 我们的马生成器

  • 一个斑马生成器

  • 我们的马识别器

  • 我们的斑马识别器(又名鉴别器)

我们将加起来:

  • 用于识别马的 GAN 损失

  • 用于识别斑马的 GAN 损失

  • 我们两个生成器的循环一致性损失

我们这里有一个 lambda,希望我们现在对这个想法有点习惯了,当你有两种不同的损失时,你可以加入一个参数,这样你可以将它们乘以一个相同的比例。我们在定位时也对我们的边界框损失与分类器损失做了类似的事情。

然后对于这个损失函数,我们将尝试最大化鉴别器的辨别能力,同时最小化生成器的辨别能力。因此,生成器和鉴别器将面对面地对抗。当你在论文中看到这个 min max 时,基本上意味着在你的训练循环中,一个东西试图让某事变得更好,另一个东西试图让某事变得更糟,有很多方法可以做到,但最常见的是你会在两者之间交替。你经常会在数学论文中看到这个被简称为 min-max。所以当你看到 min-max 时,你应该立即想到对抗训练。

实施循环 GAN

让我们看看代码。我们将要做一些几乎闻所未闻的事情,那就是我开始查看别人的代码,但并没有对整个东西感到恶心,然后自己重新做。我实际上说我相当喜欢这个,我喜欢它到足以向我的学生展示。这是代码的来源,这是一个为循环 GAN 创建原始代码的人之一,他们创建了一个 PyTorch 版本。我不得不稍微整理一下,但实际上它还是相当不错的。这个酷的地方是,你现在将看到几乎所有 fast.ai 的部分,或者其他相关的 fast.ai 部分,是由其他人以不同的方式编写的。所以你将看到他们如何处理数据集、数据加载器、模型、训练循环等等。

你会发现有一个cgan目录,这基本上几乎是原始的,只是做了一些清理,我希望有一天能提交为 PR。它是以一种不幸地使它与他们作为脚本使用的方式过于连接的方式编写的,所以我稍微整理了一下,以便我可以将其用作模块。但除此之外,它还是相当相似的。

from fastai.conv_learner import *
from fastai.dataset import *
from cgan.options.train_options import *

所以cgan是他们从 github 仓库复制的代码,做了一些小的改动。cgan迷你库的设置方式是,它假设配置选项是被传递到像脚本一样。所以他们有TrainOptions().parse方法,我基本上传入一个脚本选项的数组(我的数据在哪里,有多少线程,我想要丢弃吗,我要迭代多少次,我要怎么称呼这个模型,我要在哪个 GPU 上运行)。这给我们一个opt对象,你可以看到它包含了什么。你会看到它包含了一些我们没有提到的东西,那是因为它对我们没有提到的其他所有东西都有默认值。

opt = TrainOptions().parse(['--dataroot',    
   '/data0/datasets/cyclegan/horse2zebra', '--nThreads', '8', 
   '--no_dropout', '--niter', '100', '--niter_decay', '100', 
   '--name', 'nodrop', '--gpu_ids', '2'])

所以我们不再使用 fast.ai 的东西,我们将主要使用 cgan 的东西。

from cgan.data.data_loader import CreateDataLoader
from cgan.models.models import create_model

我们首先需要的是一个数据加载器。这也是一个很好的机会,让你再次练习使用你选择的编辑器或 IDE 浏览代码的能力。我们将从CreateDataLoader开始。你应该能够找到符号或在 vim 标签中直接跳转到CreateDataLoader,我们可以看到它创建了一个CustomDatasetDataLoader。然后我们可以看到CustomDatasetDataLoader是一个BaseDataLoader。我们可以看到它将使用标准的 PyTorch DataLoader,这很好。我们知道如果要使用标准的 PyTorch DataLoader,你需要传递一个数据集,我们知道数据集是包含长度和索引器的东西,所以当我们查看CreateDataset时,它应该会这样做。

这里是CreateDataset,这个库不仅仅是循环 GAN - 它处理对齐和不对齐的图像对。我们知道我们的图像对是不对齐的,所以我们要使用UnalignedDataset

正如预期的那样,它有__getitem____len__。对于长度,A 和 B 是我们的马和斑马,我们有两组,所以较长的那个将是DataLoader的长度。__getitem__将会:

  • 随机从我们的两匹马和斑马中抓取一些东西

  • 用 Pillow(PIL)打开它们

  • 通过一些转换运行它们

  • 然后我们可以把马变成斑马,或者把斑马变成马,所以有一些方向

  • 返回我们的马、斑马、马的路径和斑马的路径

希望你能看到这看起来与 fast.ai 所做的事情非常相似。当涉及到转换和性能时,fast.ai 显然做了更多,但请记住,这是为这个特定事情的研究代码,他们做了这么多工作,这是相当酷的。

data_loader = CreateDataLoader(opt)
dataset = data_loader.load_data()
dataset_size = len(data_loader)
dataset_size
'''
1334
'''

我们有一个数据加载器,所以我们可以将我们的数据加载到其中[2:06:17]。这将告诉我们其中有多少个小批次(这是 PyTorch 数据加载器的长度)。

下一步是创建一个模型。同样的想法,我们有不同类型的模型,我们将要做一个循环 GAN。

这是我们的CycleGANModelCycleGANModel中有相当多的内容,所以让我们逐步找出将要使用的内容。在这个阶段,我们只是调用了初始化器,所以当我们初始化它时,它将会定义两个生成器,一个用于我们的马,一个用于斑马。它有一种方法来生成一组假数据,然后我们将获取我们的 GAN 损失,正如我们所讨论的,我们的循环一致性损失是一个 L1 损失。他们将使用 Adam,显然对于循环 GAN,他们发现 Adam 效果很好。然后我们将为我们的马判别器、斑马判别器和生成器各自创建一个优化器。生成器的优化器将包含马生成器和斑马生成器的参数,所有这些都在一个地方。

因此,初始化器将设置我们需要的所有不同网络和损失函数,并将它们存储在这个model中[2:08:14]。

model = create_model(opt)

然后打印出并向我们展示我们拥有的 PyTorch 模型。看到他们正在使用 ResNets,你会发现 ResNets 看起来非常熟悉,所以我们有卷积、批量归一化、Relu。InstanceNorm基本上与批量归一化相同,但它是针对一幅图像应用的,区别并不特别重要。你可以看到他们正在做反射填充,就像我们一样。当你尝试像这样从头开始构建所有东西时,这是很多工作,你可能会忘记 fast.ai 自动为你做的一些好事。你必须手动完成所有这些工作,最终只能得到其中的一部分。所以随着时间的推移,希望很快,我们将把所有这些 GAN 内容整合到 fast.ai 中,这将变得简单而容易。

我们有我们的模型,记住模型包含损失函数、生成器、判别器,所有这些都在一个方便的地方[2:09:32]。我已经复制、粘贴并稍微重构了他们代码中的训练循环,这样我们就可以在笔记本中运行它。所以这个应该看起来很熟悉。一个循环用于遍历每个 epoch,一个循环用于遍历数据。在这之前,我们设置了dataset。实际上这不是一个 PyTorch 数据集,我认为这是他们稍微令人困惑地用来谈论他们的组合数据,我们称之为模型数据对象——他们需要的所有数据。用tqdm循环遍历它,以获得进度条,这样我们就可以看看模型中发生了什么。

total_steps = 0

for epoch in range(opt.epoch_count, opt.niter + opt.niter_decay+1):
    epoch_start_time = time.time()
    iter_data_time = time.time()
    epoch_iter = 0

    for i, data in tqdm(enumerate(dataset)):
        iter_start_time = time.time()
        if total_steps % opt.print_freq == 0: 
             t_data = iter_start_time - iter_data_time
        total_steps += opt.batchSize
        epoch_iter += opt.batchSize
        model.set_input(data)
        model.optimize_parameters()

        if total_steps % opt.display_freq == 0:
            save_result = total_steps % opt.update_html_freq == 0

        if total_steps % opt.print_freq == 0:
            errors = model.get_current_errors()
            t = (time.time() - iter_start_time) / opt.batchSize

        if total_steps % opt.save_latest_freq == 0:
            print(
                'saving the latest model(epoch %d,total_steps %d)'
                % (epoch, total_steps)
            )
            model.save('latest')

        iter_data_time = time.time()
    if epoch % opt.save_epoch_freq == 0:
        print(
            'saving the model at the end of epoch %d, iters %d' 
            % (epoch, total_steps)
        )
        model.save('latest')
        model.save(epoch)

    print(
        'End of epoch %d / %d \t Time Taken: %d sec' %
        (
            epoch, opt.niter + opt.niter_decay, 
            time.time() - epoch_start_time
        )
    )
    model.update_learning_rate()

set_input[2:10:32]:这是一种与 fast.ai 中所做的不同方法。这很巧妙,它相当特定于循环 GAN,但基本上在这个模型内部的想法是,我们将进入我们的数据并获取适当的数据。我们要么将马转换为斑马,要么将斑马转换为马,取决于我们选择的方式,A要么是马要么是斑马,反之亦然。如果需要,将其放在适当的 GPU 上,然后获取适当的路径。因此,模型现在有一批马和一批斑马。

现在我们优化参数[2:11:19]。这样看起来很好。你可以看到每一步。首先,尝试优化生成器,然后尝试优化马判别器,然后尝试优化斑马判别器。zero_grad()是 PyTorch 的一部分,以及step()。因此,有趣的部分是实际执行生成器反向传播的部分。

这里是[2:12:04]。让我们跳到关键部分。这里有我们刚刚在论文中看到的所有公式。让我们拿一匹马生成一只斑马。现在让我们使用鉴别器来看看我们是否能够判断它是假的还是真的(pred_fake)。然后让我们将其放入我们之前设置的损失函数中,以基于该预测获得 GAN 损失。然后让我们以相反的方向做同样的事情,使用相反的鉴别器,然后再次通过损失函数。然后让我们做循环一致性损失。再次,我们拿我们创建的假的东西,尝试将其转回原始状态。让我们使用之前创建的循环一致性损失函数将其与真实原始状态进行比较。这里是那个 lambda - 所以有一些权重我们使用了,实际上我们只是使用了他们在选项中建议的默认值。然后对相反的方向做同样的事情,然后将它们全部加在一起。然后进行反向步骤。就是这样。

所以我们可以为第一个鉴别器做同样的事情[2:13:50]。因为基本上所有的工作现在都已经完成了,这里要做的事情就少得多了。就是这样。我们不会一步步走过来,但基本上是我们已经看到的相同的基本东西。

所以optimize_parameters()正在计算损失并执行优化器步骤。不时保存并打印一些结果。然后不时更新学习率,所以他们在这里也有一些学习率退火的机制。有点像 fast.ai,他们有这个调度器的概念,你可以用它来更新你的学习率。

对于那些对更好地理解深度学习 API、更多地为 fast.ai 做贡献,或者在一些不同的后端中创建自己版本的一些东西感兴趣的人,看看第二个 API 是很酷的,它涵盖了一些类似的东西的一些子集,以便了解他们是如何解决这些问题的,以及相似之处/不同之处是什么。

def show_img(im, ax=None, figsize=None):
    if not ax: 
        fig,ax = plt.subplots(figsize=figsize)
    ax.imshow(im)
    ax.get_xaxis().set_visible(False)
    ax.get_yaxis().set_visible(False)
    return ax
def get_one(data):
    model.set_input(data)
    model.test()
    return list(model.get_current_visuals().values())
model.save(201)
test_ims = []
for i,o in enumerate(dataset):
    if i>10: 
        break
    test_ims.append(get_one(o))
def show_grid(ims):
    fig,axes = plt.subplots(2,3,figsize=(9,6))
    for i,ax in enumerate(axes.flat): 
        show_img(ims[i], ax);
    fig.tight_layout()
for i in range(8): 
    show_grid(test_ims[i])

我们训练了一段时间,然后我们可以随便拿几个例子,这里有它们[2:15:29]。这里有马、斑马,然后再变回马。

我花了大约 24 小时来训练它,所以它有点慢[2:16:39]。我知道 Helena 经常在 Twitter 上抱怨这些事情花费的时间有多长。我不知道她是如何在这些事情上如此高效的。

# !wget https://people.eecs.berkeley.edu/~taesung_park/CycleGAN/datasets/horse2zebra.zip

我还要提到昨天刚出来的另一件事[2:16:54]:

多模态无监督图像到图像翻译

现在有一种多模态的无监督图像到图像的翻译。所以你现在基本上可以从这只狗创建不同的猫。

youtu.be/ab64TWzWn40

这不仅仅是创建你想要的输出的一个例子,而是创建多个例子。这是昨天或前天才出来的。我觉得这很惊人。所以你可以看到这项技术是如何发展的,我认为在音乐、语音、写作方面,或者为艺术家创造工具方面,可能有很多机会。

深度学习 2:第 2 部分第 13 课

原文:medium.com/@hiromi_suenaga/deep-learning-2-part-2-lesson-13-43454b21a5d0

译者:飞龙

协议:CC BY-NC-SA 4.0

来自 fast.ai 课程的个人笔记。随着我继续复习课程以“真正”理解它,这些笔记将继续更新和改进。非常感谢 JeremyRachel 给了我这个学习的机会。

论坛 / 视频

图像增强 - 我们将涵盖您可能熟悉的这幅画。然而,您可能之前没有注意到这幅画中有一只鹰。您之前可能没有注意到的原因是这幅画以前没有鹰。同样地,第一张幻灯片上的画以前也没有美国队长的盾牌。

这是一篇很酷的新论文,几天前刚发表,名为Deep Painterly Harmonization,它几乎完全使用了我们将在本课程中学习的技术,只是进行了一些微小的调整。但您可以看到基本思想是将一张图片粘贴在另一张图片上,然后使用某种方法将两者结合起来。这种方法被称为“风格转移”。

在我们讨论之前,我想提一下 William Horton 的这个非常酷的贡献,他将这种随机权重平均技术添加到了 fastai 库中,现在已经全部合并并准备就绪。他写了一整篇关于这个的文章,我强烈建议您查看,不仅因为随机权重平均让您可以从现有的神经网络中获得更高的性能,而且基本上不需要额外的工作(只需向您的 fit 函数添加两个参数:use_swaswa_start),而且他描述了他构建这个过程以及他如何测试它以及他如何为库做出贡献。所以如果您有兴趣做类似的事情,我认为这很有趣。我认为 William 以前没有建立过这种类型的库,所以他描述了他是如何做到的。

medium.com/@hortonhearsafoo/adding-a-cutting-edge-deep-learning-training-technique-to-the-fast-ai-library-2cd1dba90a49

TrainPhase [2:01]

笔记本

fastai 库的另一个非常酷的贡献是一个新的训练阶段 API。我将做一件我以前从未做过的事情,那就是我将展示别人的笔记本。之前我没有这样做的原因是因为我没有喜欢到足够好的笔记本,认为值得展示,但 Sylvain 在这里做得非常出色,不仅创建了这个新 API,还创建了一个描述它是什么以及如何工作等等的精美笔记本。背景是,正如大家所知,我们一直在努力更快地训练网络,部分原因是作为这个 Dawn bench 竞赛的一部分,还有一个下周您将了解的原因。我上周在论坛上提到,如果我们有一个更容易尝试不同的学习率调度等的方法,那对我们的实验将非常方便,我提出了我心目中的 API,如果有人能写出来那将非常酷,因为我现在要睡觉了,明天我有点需要它。Sylvain 在论坛上回复说,听起来是一个不错的挑战,24 小时后,它就完成了,而且效果非常酷。我想带您了解一下,因为它将使您能够研究以前没有人尝试过的东西。

这被称为 TrainPhase API,最简单的方法是展示它的示例。这是一个迭代学习率图表,你应该很熟悉。我们在学习率为 0.01 的情况下训练一段时间,然后在学习率为 0.001 的情况下训练一段时间。我实际上想创建一个非常类似于学习率图表的东西,因为大多数训练 ImageNet 的人都使用这种分阶段的方法,而这实际上并不是 fastai 内置的,因为我们通常不建议这样做。但为了复制现有的论文,我想以同样的方式做。因此,与其写一系列不同学习率的 fit、fit、fit 调用,不如能够说在这个学习率下训练 n 个周期,然后在那个学习率下训练 m 个周期。

这就是你如何做到的:

phases = [
    TrainingPhase(epochs=1, opt_fn=optim.SGD, lr = 1e-2),   
    TrainingPhase(epochs=2, opt_fn=optim.SGD, lr = 1e-3)
]

一个阶段是一个具有特定优化器参数的训练期,phases由许多训练阶段对象组成。一个训练阶段对象说明要训练多少个周期,要使用什么优化函数,以及其他我们将看到的东西。在这里,你会看到你刚刚在那张图上看到的两个训练阶段。所以现在,不再调用learn.fit,而是说:

learn.fit_opt_sched(phases)

换句话说,learn.fit与一个具有这些阶段的优化器调度器。大多数传递的参数都可以像往常一样传递给 fit 函数,所以大多数通常的参数都可以正常工作。一般来说,我们只需使用这些训练阶段,你会看到它以一种通常的方式适应。然后当你说plot_lr时,你会看到上面的图表。它不仅绘制学习率,还绘制动量,并且对于每个阶段,它告诉你使用了什么优化器。你可以关闭优化器的打印(show_text=False),你可以关闭动量的打印(show_moms=False),你还可以做其他一些小事情,比如一个训练阶段可以有一个lr_decay参数:

phases = [
    TrainingPhase(epochs=1, opt_fn=optim.SGD, lr = 1e-2), 
    TrainingPhase(
        epochs=1, 
        opt_fn=optim.SGD, 
        lr = (1e-2,1e-3), 
        lr_decay=DecayType.LINEAR
    ),
    TrainingPhase(epochs=1, opt_fn=optim.SGD, lr = 1e-3)
]

这里有一个固定的学习率,然后是线性衰减的学习率,然后是放弃这个图像的固定学习率:

lr_i = start_lr + (end_lr - start_lr) * i/n

这可能是一个很好的训练方式,因为我们知道在高学习率下,你可以更好地探索,在低学习率下,你可以更好地微调。逐渐在两者之间滑动可能更好。所以我认为这实际上不是一个坏方法。

你可以使用其他衰减类型,比如余弦:

phases = [
    TrainingPhase(epochs=1, opt_fn=optim.SGD, lr = 1e-2),    
    TrainingPhase(
        epochs=1, 
        opt_fn=optim.SGD, 
        lr =(1e-2,1e-3),
        lr_decay=DecayType.COSINE
    ),           
    TrainingPhase(epochs=1, opt_fn=optim.SGD, lr = 1e-3)
]

这可能更有意义,作为一个真正有用的学习率退火形状。

lr_i = end_lr + (start_lr - end_lr)/2 * (1 + np.cos(i * np.pi)/n)

指数,这是一个非常流行的方法:

lr_i = start_lr * (end_lr/start_lr)**(i/n)

多项式并不是非常流行,但实际上在文献中比其他任何方法都要好,但似乎已经被大多数人忽视了。所以多项式是值得注意的。Sylvain 已经为每个曲线给出了公式。因此,使用多项式,你可以选择使用哪个多项式。我相信 p 为 0.9 的多项式是我看到的效果非常好的一个 - FYI。

lr_i = end_lr + (start_lr - end_lr) * (1 - i/n) ** p

如果在 LR 衰减时不提供学习率的元组,那么它将一直衰减到零。如你所见,你可以愉快地从不同的点开始下一个周期。

phases = [
    TrainingPhase(epochs=1, opt_fn=optim.SGD, lr = 1e-2), 
    TrainingPhase(
        epochs=1, 
        opt_fn=optim.SGD, 
        lr = 1e-2, 
        lr_decay=DecayType.COSINE
    ),
    TrainingPhase(epochs=1, opt_fn=optim.SGD, lr = 1e-3)
]

SGDR

所以酷的是,现在我们可以仅仅使用这些训练阶段来复制所有我们现有的计划。这里有一个名为phases_sgdr的函数,它使用新的训练阶段 API 来进行 SGDR。

def phases_sgdr(lr, opt_fn, num_cycle,cycle_len,cycle_mult):
    phases = [TrainingPhase(epochs = cycle_len/ 20, opt_fn=opt_fn, 
                       lr=lr/100),
              TrainingPhase(epochs = cycle_len * 19/20, 
                   opt_fn=opt_fn, lr=lr, lr_decay=DecayType.COSINE)]
    for i in range(1,num_cycle):
        phases.append(TrainingPhase(epochs=cycle_len*
                      (cycle_mult**i), opt_fn=opt_fn, lr=lr, 
                      lr_decay=DecayType.COSINE))
    return phases

所以你可以看到,如果他按照这个计划运行,这就是它的样子:

他甚至做了我训练时使用非常低的学习率一小段时间然后突然增加并进行几个周期的小技巧,而且这些周期的长度在增加[8:05]。而且这一切都在一个函数中完成。

1cycle

现在我们可以用一个小函数来实现新的 1cycle。

def phases_1cycle(cycle_len,lr,div,pct,max_mom,min_mom):
    tri_cyc = (1-pct/100) * cycle_len
    return [TrainingPhase(epochs=tri_cyc/2, opt_fn=optim.SGD, 
                          lr=(lr/div,lr), lr_decay=DecayType.LINEAR,
                          momentum=(max_mom,min_mom),  
                          momentum_decay=DecayType.LINEAR),
           TrainingPhase(epochs=tri_cyc/2, opt_fn=optim.SGD, 
                         lr=(lr,lr/div), lr_decay=DecayType.LINEAR, 
                          momentum=(min_mom,max_mom), 
                          momentum_decay=DecayType.LINEAR),
           TrainingPhase(epochs=cycle_len-tri_cyc, opt_fn=optim.SGD,
                         lr=(lr/div,lr/(100*div)), 
                         lr_decay=DecayType.LINEAR, 
                         momentum=max_mom)]

所以如果我们符合这个,我们会得到这个三角形,然后是一个稍微平坦的部分,动量是一个很酷的东西 - 动量有一个动量衰减。在第三个训练阶段,我们有一个固定的动量。所以它同时处理动量和学习率。

区分学习率+ 1cycle

我还没有尝试过的一件事,但我认为会非常有趣的是使用区分学习率和 1cycle 的组合。还没有人尝试过。这将非常有趣。我遇到的唯一一篇使用区分学习率的论文使用了一种称为 LARS 的东西。它被用来通过查看每层的梯度和均值之间的比率并使用该比率自动更改每层的学习率来训练 ImageNet,从而使用非常大的批量大小。他们发现他们可以使用更大的批量大小。这是我看到这种方法使用的唯一其他地方,但是您可以尝试结合区分学习率和不同有趣的调度尝试很多有趣的事情。

您自己的 LR 查找器

现在您可以编写不同类型的 LR finder,特别是因为现在有这个stop_div参数,基本上意味着当损失变得太糟糕时,它将停止训练。

添加的一个有用功能是plot函数中的linear参数。如果您在学习率查找器中使用线性调度而不是指数调度,这是一个好主意,如果您调整到大致正确的区域,那么您可以使用线性来找到确切的区域。然后您可能希望使用线性比例来绘制它。因此,您现在也可以将 linear 传递给 plot。

您可以在每个阶段更改优化器。这比您想象的更重要,因为实际上针对 ImageNet 在非常大的批量大小上快速训练的当前最先进技术实际上是从 RMSProp 开始的,然后他们在第二部分切换到 SGD。因此,这可能是一个有趣的实验,因为至少有一篇论文现在已经表明这样可以很好地工作。再次强调,这是一个尚未被充分认识的问题。

更改数据

然后我发现最有趣的部分是您可以更改您的数据。为什么我们要更改我们的数据?因为您还记得第 1 和第 2 课,您可以在开始时使用小图像,然后稍后使用更大的图像。理论上,您可以使用这种方法更快地训练第一部分,然后记住,如果您将高度减半并将宽度减半,则每层的激活数量就会减少四分之一,因此速度可能会更快。它甚至可能泛化得更好。因此,您现在可以创建几种不同大小,例如,他有 28 和 32 大小的图像。这是 CIFAR10,所以您可以做的事情有限。然后,如果您在调用fit_opt_sched时在data_list参数中传入数据数组,它将在每个阶段使用不同的数据集。

data1 = get_data(28,batch_size)
data2 = get_data(32,batch_size)learn = ConvLearner.from_model_data(ShallowConvNet(), data1)phases = [TrainingPhase(epochs=1, opt_fn=optim.Adam, lr=1e-2, 
                        lr_decay=DecayType.COSINE),
          TrainingPhase(epochs=2, opt_fn=optim.Adam, lr=1e-2, 
                        lr_decay=DecayType.COSINE)]learn.fit_opt_sched(phases, data_list=[data1,data2])

这真的很酷,因为我们现在可以像在 DAWN bench 条目中那样使用它,并查看当我们实际上用很少的代码增加大小时会发生什么。那么当我们这样做时会发生什么?答案在 DAWN bench 上对 ImageNet 的训练中。

你可以看到,谷歌用半小时在一组 TPU 上赢得了比赛。最好的非 TPU 集群结果是 fast.ai + 学生在不到 3 小时内击败了拥有 128 台计算机的英特尔,而我们只用了一台计算机。我们还击败了在 TPU 上运行的谷歌,所以使用这种方法,我们已经证明了:

  • 最快的 GPU 结果

  • 最快的单机结果

  • 最快的公开可用基础设施结果

这些 TPU 机架,除非你是谷歌,否则无法使用。而且成本很低(72.54 美元),这个英特尔的成本是 1200 美元的计算成本——他们甚至没有写在这里,但如果你同时使用 128 台计算机,每台有 36 个核心,每台有 140G,那就是你得到的结果,与我们的单个 AWS 实例相比。所以这在我们可以做的事情方面是一种突破。我们可以在一个公开可用的机器上训练 ImageNet,这个成本是 72 美元,顺便说一句,实际上是 25 美元,因为我们使用了一个 spot 实例。我们的学生 Andrew Shaw 建立了整个系统,让我们可以同时运行一堆 spot 实例实验,并且几乎自动化,但 DAWN bench 没有引用我们使用的实际数字。所以实际上是 25 美元,而不是 72 美元。所以这个 data_list 的想法非常重要和有帮助。

CIFAR10 结果

我们的 CIFAR10 结果现在也正式发布了,你可能还记得之前最好的结果是一个多小时。这里的诀窍是使用 1cycle,所以 Sylvain 的训练阶段 API 中的所有东西实际上都是我们用来获得这些顶级结果的东西。另一位 fast.ai 学生 bkj 采用了这个方法,并做了自己的版本,他采用了一个 Resnet18,并在顶部添加了我们学到的 concat pooling,并使用了 Leslie Smith 的 1cycle,所以他上了排行榜。所以前三名都是 fast.ai 的学生,这太棒了。

CIFAR10 成本结果

成本也是一样的——前三名,你可以看到,Paperspace。Brett 在 Paperspace 上运行,得到了最便宜的结果,略胜于 bkj。

所以我认为你可以看到,目前训练更快、更便宜的有趣机会很多都是关于学习率退火、尺寸退火,以及在不同时间使用不同参数进行训练,我仍然认为大家只是触及了表面。我认为我们可以做得更快、更便宜。这对于资源受限的环境中的人们非常有帮助,基本上除了谷歌,也许还有 Facebook。

架构也很有趣,上周我们看了一下简化版本的 darknet 架构。但有一个架构我们还没有谈到,那就是理解 Inception 网络所必需的。Inception 网络实际上非常有趣,因为他们使用了一些技巧使得事情更加高效。我们目前没有使用这些技巧,我觉得也许我们应该尝试一下。最有趣、最成功的 Inception 网络是他们的 Inception-ResNet-v2 网络,其中大部分块看起来像这样:

它看起来很像标准的 ResNet 块,因为有一个恒等连接,还有一个卷积路径,我们把它们加在一起。但实际上并不完全是这样。首先,中间的卷积路径是一个 1x1 卷积,值得思考一下 1x1 卷积实际上是什么。

1x1 卷积

1x1 卷积简单地说,对于输入中的每个网格单元,您基本上有一个向量。1 乘 1 乘滤波器数量的张量基本上是一个向量。对于输入中的每个网格单元,您只需与该张量进行点积。然后,当然,对于我们正在创建的 192 个激活之一,它将是这些向量之一。因此,基本上对网格单元(1,1)进行 192 个点积,然后对网格单元(1,2)或(1,3)等进行 192 个点积。因此,您将得到与输入具有相同网格大小和输出中的 192 个通道的内容。因此,这是一种非常好的方法,可以减少或增加输入的维度,而不改变网格大小。这通常是我们使用 1x1 卷积的方式。在这里,我们有一个 1x1 卷积和另一个 1x1 卷积,然后将它们相加。然后有第三个路径,这第三个路径没有被添加。虽然没有明确提到,但这第三个路径是被连接的。有一种形式的 ResNet 基本上与 ResNet 相同,但我们不使用加号,而是使用连接。这被称为 DenseNet。这只是一个使用连接而不是加法的 ResNet。这是一个有趣的方法,因为这样,身份路径实际上被复制。因此,您可以一直保持这种流动,因此正如我们将在下周看到的那样,这对于分割等需要保留原始像素、第一层像素和第二层像素不变的情况非常有用。

连接而不是添加分支是一件非常有用的事情,我们正在连接中间分支和右侧分支。最右侧的分支正在做一些有趣的事情,首先是 1x1 卷积,然后是 1x7,然后是 7x1。那里发生了什么?所以,那里发生的事情基本上是我们真正想要做的是 7x7 卷积。我们想要做 7x7 卷积的原因是,如果有多个路径(每个路径具有不同的内核大小),那么它可以查看图像的不同部分。最初的 Inception 网络将 1x1、3x3、5x5、7x7 连接在一起或类似的东西。因此,如果我们可以有一个 7x7 滤波器,那么我们可以一次查看图像的很多部分并创建一个非常丰富的表示。因此,Inception 网络的干部,即 Inception 网络的前几层实际上也使用了这种 7x7 卷积,因为您从这个 224x224x3 开始,希望将其转换为 112x112x64。通过使用 7x7 卷积,您可以在每个输出中获得大量信息以获得这些 64 个滤波器。但问题是 7x7 卷积是很费力的。您需要将 49 个内核值乘以每个通道的每个输入像素的 49 个输入。因此,计算量很大。您可能可以在第一层中使用它(也许可以),实际上,ResNet 的第一个卷积就是 7x7 卷积。

但对于《盗梦空间》来说并非如此。它们不使用 7x7 卷积,而是使用 1x7 接着 7x1。因此,基本思想是 Inception 网络或其所有不同版本的基本思想是有许多不同的卷积宽度的独立路径。在这种情况下,概念上的想法是中间路径是 1x1 卷积宽度,右侧路径将是 7 卷积宽度,因此它们正在查看不同数量的数据,然后将它们组合在一起。但我们不希望在整个网络中都使用 7x7 卷积,因为这太耗费计算资源了。

但是如果你考虑一下[23:18],如果我们有一些输入进来,我们有一些我们想要的大滤波器,但它太大了无法处理。我们能做什么?让我们做 5x5。我们可以创建两个滤波器 —— 一个是 1x5,一个是 5x1。我们将前一层的激活传递给 1x5。我们从中取出激活,然后通过 5x1 传递,最后得到一些结果。现在另一端出来了什么?与其将其视为首先我们取激活,然后通过 1x5,然后通过 5x1,不如一起考虑这两个操作,看看一个 5x1 点积和一个 1x5 点积一起做会发生什么?实际上,你可以取一个 1x5 和 5x1,它们的外积将给你一个 5x5。现在你不能通过取这个积来创建任何可能的 5x5 矩阵,但是你可以创建很多 5x5 矩阵。所以这里的基本思想是当你考虑操作的顺序时(如果你对这里的理论更感兴趣,你应该查看 Rachel 的数值线性代数课程,这基本上是关于这个的整个课程)。但从概念上来说,很多时候你想要做的计算实际上比整个 5x5 卷积更简单。在线性代数中我们经常使用的术语是有一些低秩近似。换句话说,1x5 和 5x1 结合在一起 —— 那个 5x5 矩阵几乎和你理想情况下应该计算的 5x5 矩阵一样好。所以在实践中这往往是情况 —— 因为现实世界的本质是现实世界往往比随机性更具结构性。

酷的地方是[26:16],如果我们用 1x7 和 7x1 替换我们的 7x7 卷积,对于每个单元格,它有 14 个输入通道乘以输出通道的点积要做,而 7x7 卷积则有 49 个要做。所以速度会快得多,我们希望它的效果几乎一样好。从定义上来说,它肯定捕捉到了尽可能多的信息宽度。

如果你对这方面的知识感兴趣,特别是在深度学习领域,你可以搜索分解卷积。这个想法是 3 年或 4 年前提出的。它可能已经存在更长时间了,但那是我第一次看到它的时候。结果表明它的效果非常好,Inception 网络广泛使用它。他们实际上在他们的干部中使用它。我们之前谈过,我们倾向于添加-on —— 我们倾向于说这是主干,例如我们有 ResNet34。这是主干,其中包含所有的卷积,然后我们可以添加一个自定义头部,通常是最大池化或全连接层。更好的做法是谈论主干包含两个部分:一个是干部,另一个是主干。原因是进来的东西只有 3 个通道,所以我们希望有一系列操作将其扩展为更丰富的东西 —— 通常是 64 个通道之类的东西。

在 ResNet 中,干部非常简单。它是一个 7x7 步幅 2 卷积,后面跟着一个步幅 2 最大池(如果我记得正确的话)。Inception 有一个更复杂的干部,其中包括多个路径的组合和连接,包括因子化卷积(1x7 和 7x1)。我很感兴趣的是,如果你在 Inception 干部上堆叠一个标准的 ResNet 会发生什么。我认为这将是一个非常有趣的尝试,因为 Inception 干部是一个非常精心设计的东西,以及如何将 3 通道输入转换为更丰富的东西似乎非常重要。而所有这些工作似乎都被抛弃了。我们喜欢 ResNet,它的效果非常好。但是如果我们在 Inception 干部上放置一个密集的网络骨干呢?或者如果我们用标准 ResNet 中的 1x7 和 7x1 因子化卷积替换 7x7 卷积呢?有很多事情我们可以尝试,我认为这将是非常有趣的。所以这是关于潜在研究方向的一些想法。

这就是我小小一堆随机东西部分的内容[29:51]。稍微接近这个实际主题的是图像增强。我将简要谈一下一篇新论文,因为它与我刚刚讨论的内容和我们接下来要讨论的内容有很大联系。这是一篇关于渐进式 GAN 的论文,来自 Nvidia:渐进增长的 GANs 用于提高质量、稳定性和变化。渐进式 GANs 采用了逐渐增加图像大小的想法。这是我所知道的唯一另一个人们实际上逐渐增加图像大小的方向。令我惊讶的是,这篇论文实际上非常受欢迎,知名度很高,而且受欢迎,但是人们还没有将逐渐增加图像大小的基本思想应用到其他地方,这显示了你可以在深度学习研究社区中期望找到的创造力水平。

他们真的回到了 4x4 GAN 开始[31:47]。实际上,他们试图复制 4x4 像素,然后是 8x8(上面左上角的那些)。这是 CelebA 数据集,所以我们试图重新创建名人的图片。然后他们去 16x16,32,64,128,然后 256。他们做的一个非常聪明的事情是,随着尺寸的增加,他们还向网络添加更多层。这有点说得通,因为如果你在做更多的 ResNet 类型的事情,那么你应该能够在每个网格单元大小输出一些有意义的东西,所以你应该能够在其上叠加东西。当他们这样做时,他们做了另一个聪明的事情,他们添加了一个跳过连接,并逐渐改变线性插值参数,使其越来越远离旧的 4x4 网络,朝向新的 8x8 网络。一旦完全移动到新网络,他们就会丢弃那个额外的连接。细节并不太重要,但它使用了我们谈论过的基本思想,逐渐增加图像大小和跳过连接。这是一篇很棒的论文,因为这是一种罕见的情况,好的工程师实际上构建了一些以非常明智的方式工作的东西。现在这并不奇怪,这实际上来自 Nvidia 自己。Nvidia 并不发表很多论文,有趣的是,当他们这样做时,他们构建了一些非常实用和明智的东西。所以我认为这是一篇很棒的论文,如果你想整合我们学到的许多不同的东西,而且没有太多的重新实现,所以这是一个有趣的项目,也许你可以继续研究并找到其他东西。

接下来会发生什么[33:45]。我们最终会升级到 1024x1024,你会看到图像不仅分辨率更高,而且质量更好。所以我要看看你能否猜出以下哪一个是假的:

它们全都是假的。这是下一个阶段。你一直往上走,然后突然爆炸。所以 GANs 和其他东西变得疯狂,你们中的一些人可能在这周看到了这个[34:16]。这个视频刚刚发布,是巴拉克·奥巴马的演讲,让我们来看一下:

youtu.be/cQ54GDm1eL0

正如你所看到的,他们使用这种技术来实际移动奥巴马的脸,就像乔丹·皮尔的脸在移动一样。你现在基本上拥有了所有需要的技术。这是一个好主意吗?

人工智能伦理[35:31]

这是我们谈论最重要的部分,现在我们可以做所有这些事情,我们应该做什么,我们如何考虑?简而言之,我其实不知道。最近,你们中的许多人看到了 spaCy prodigy 公司的创始人在 Explosion AI 做了一个演讲,Matthew 和 Ines,之后我和他们一起吃饭,我们基本上整个晚上都在讨论,辩论,争论我们这样的公司正在构建可以以有害方式使用的工具,这意味着什么。他们是非常深思熟虑的人,我们,我不会说我们没有达成一致意见,我们只是无法得出结论。所以我只是列出一些问题,并指出一些研究,当我说研究时,实际上大部分文献综述和整理工作都是由 Rachel 完成的,所以谢谢 Rachel。

让我先说一下,我们构建的模型通常在某些方面相当糟糕,这些问题并不立即显现[36:52]。除非与你一起构建它们的人是各种各样的人,与你一起使用它们的人也是各种各样的人,否则你不会知道它们有多糟糕。例如,一对出色的研究人员,Timnit Gebru在微软工作,Joy Buolamwini刚从麻省理工学院获得博士学位,他们进行了一项非常有趣的研究,他们查看了一些现成的人脸识别器,其中包括来自 FACE++的一个,这是一家庞大的中国公司,IBM 的,以及微软的,他们寻找了一系列不同类型的人脸。

一般来说,微软的一个特别准确,除非人脸类型恰好是深色皮肤,突然间糟糕了 25 倍。IBM 几乎一半的时间都搞错了。对于这样一个大公司来说,发布一个对世界上大部分人来说都不起作用的产品,不仅仅是技术上的失败。这是对理解需要使用什么样的团队来创建这样的技术以及测试这样的技术,甚至对你的客户是谁的一种深刻失败。你的一些客户有深色皮肤。“我还要补充说,分类器在女性身上的表现都比在男性身上差”(Rachel)。令人震惊。有趣的是,Rachel 前几天在推特上发表了类似的言论,有人说“这是怎么回事?你在说什么?难道你不知道人们很长时间以来一直在制造汽车吗——你是在说你需要女性来制造汽车吗?”Rachel 指出——实际上是的。在汽车安全的大部分历史中,女性在汽车中的死亡风险远远高于男性,因为男性创造了看起来像男性、感觉像男性、尺寸像男性的碰撞测试假人,所以汽车安全实际上没有在女性身材上进行测试。产品管理糟糕,缺乏多样性和理解的失败在我们领域并不新鲜。

“我只是想说,这是在比较男性和女性的影响力”(Rachel)。我不知道为什么每当你在 Twitter 上说这样的话时,Rachel 都要这样说,因为每当你在 Twitter 上说这样的话时,大约有 10 个人会说“哦,你必须比较所有这些其他事情”,好像我们不知道一样。

像微软的人脸识别器或谷歌的语言翻译器这样的我们最好最著名的系统做的其他事情,你把“她是医生。他是护士。”翻译成土耳其语,非常正确——两个代词都变成了 O,因为土耳其语中没有性别代词。反过来,它会变成什么?“他是医生。她是护士。”所以我们在每天使用的工具中内置了这种偏见。而且,人们会说“哦,它只是展示了世界上的东西”,好吧,这个基本断言有很多问题,但正如你所知,机器学习算法喜欢概括。

因为他们喜欢概括,这是你们现在了解技术细节的一个很酷的事情,因为当你看到像 60%的照片中烹饪的人是女性,而他们用来构建这个模型的照片,然后你在另一组照片上运行模型时,84%被选择为烹饪的人是女性,而不是正确的 67%。这对于算法来说是一个非常可以理解的事情,因为它接受了有偏见的输入,并创造了一个更有偏见的输出,因为对于这个特定的损失函数来说,这就是它的结果。这是一种非常常见的模型放大。

这些事情很重要。它的重要性不仅仅体现在尴尬的翻译或黑人照片未被正确分类的方式上。也许也有一些胜利,比如到处可怕的监视,也许对黑人不起作用。“或者会更糟,因为这是可怕的监视,而且是彻头彻尾的种族主义和错误”(Rachel)。但让我们深入一点。尽管我们谈论人类的缺陷,但文明和社会有着长期的历史,创造了层层人类判断,希望避免最可怕的事情发生。有时候,热爱技术的公司会认为“让我们抛弃人类,用技术取代他们”,就像 Facebook 所做的那样。几年前,Facebook 真的摆脱了他们的人类编辑,当时这成为了新闻。他们被算法取代了。现在,当算法将所有内容放在你的新闻源上,而人类编辑却被排除在外时,接下来会发生什么?

接下来发生了很多事情。其中之一是缅甸发生了大规模的可怕种族灭绝。婴儿被从母亲怀里夺走,扔进火里。大规模的强奸、谋杀,整个民族被流放出境。

好吧,我不会说那是因为 Facebook 这样做的,但我要说的是,当这个可怕项目的领导接受采访时,他们经常谈论他们从 Facebook 学到的关于罗辛亚人恶劣动物行为的一切,这些行为需要被扫除。因为算法只是想要给你更多让你点击的东西。如果你被告知这些人不像你,你不认识这些坏人,这里有很多关于坏人的故事,然后你开始点击它们,然后他们会给你更多这些东西。接下来你会发现,你陷入了这个不寻常的循环。人们一直在研究这个问题,比如,我们被告知有几次人们点击我们的 fast.ai 视频,然后推荐给他们的下一个东西是来自 Alex Jones 的阴谋论视频,然后继续下去。因为人类点击那些让我们震惊、惊讶和恐惧的东西。在很多层面上,这个决定产生了不同寻常的后果,我们只是开始理解。再次强调,这并不是说这个特定的后果是因为这一个原因,但说它与此毫无关联显然是在忽视我们所拥有的所有证据和信息。

有意外的后果

关键是要考虑你正在构建什么,以及它可能如何被使用。现在有很多努力投入到人脸识别中,包括我们的课程。我们一直在花费大量时间思考如何识别东西以及它在哪里。有很多很好的理由希望在这方面做得更好,比如改善农业产量、改善医学诊断和治疗规划、改善你的乐高分类机器人系统等等。但它也被广泛用于监视、宣传和虚假信息。再次,问题是我该怎么办?我不完全知道。但至少重要的是要考虑这个问题,谈论这个问题。

失控的反馈循环

有时候你可以做一些非常好的事情。例如,meetup.com 做了一件我认为是非常好的事情的事情,他们早就意识到一个潜在的问题,即更多的男性倾向于参加他们的聚会。这导致他们的协同过滤系统,你现在熟悉正在构建的系统,向男性推荐更多技术内容。这导致更多的男性参加更多的技术内容,从而导致推荐系统向男性推荐更多技术内容。当我们将算法和人类结合在一起时,这种失控的反馈循环是非常常见的。那么 Meetup 做了什么?他们故意做出了向女性推荐更多技术内容的决定,不是因为对世界应该如何的高尚想法,而只是因为这是有道理的。失控的反馈循环是一个 bug——有女性想要参加技术聚会,但当你去参加一个技术聚会,里面全是男性,你就不去了,然后它就会向男性推荐更多,依此类推。因此,Meetup 在这里做出了一个非常强有力的产品管理决策,即不按照算法建议的做。不幸的是,这种情况很少见。大多数这种失控的反馈循环,例如在预测性警务中,算法告诉警察去哪里,很多时候是更多的黑人社区,这些社区最终会涌入更多的警察,导致更多的逮捕,这有助于告诉更多的警察去更多的黑人社区等等。

AI 中的偏见

算法偏见的问题现在非常普遍,随着算法在特定政策决策、司法决策以及日常决策中的广泛使用,这个问题变得越来越严重。其中一些问题实际上是产品管理决策中的人员在最初就应该看到的,这些问题在任何定义下都是没有意义和不合理的。例如,阿贝·龚指出的这些问题——这些问题既用于预审,即谁需要支付保释金,这些人甚至还没有被定罪,也用于判决以及谁获得假释。尽管存在所有的缺陷,这在去年被威斯康星州最高法院维持了。所以你是否必须因为支付不起保释金而留在监狱,你的刑期有多长,你在监狱里待多久取决于你父亲做了什么,你的父母是否离婚,你的朋友是谁,以及你住在哪里。现在事实证明这些算法实际上非常糟糕,最近的一些分析显示它们基本上比随机还要糟糕。但即使公司在这些统计上的相关性上很有信心,有人能想象出一个世界,在那里根据你父亲的行为来决定发生什么吗?

在基本层面上,很多事情显然是不合理的,很多事情只是以这种你可以从经验上看到的方式失败了,这种失控的反馈循环一定发生过,这种过度概括一定发生过。例如,任何领域工作的人都应该准备这些交叉表,使用这些算法。因此,对黑人和白人被告重新犯罪的可能性的预测,我们可以很简单地计算出来。那些被标记为高风险但没有再犯的人中,23.5%是白人,而非洲裔美国人大约是白人的两倍。而那些被标记为低风险但再犯的人中,白人只有非洲裔美国人的一半,而非洲裔美国人只有 28%。这就是这种情况,至少如果你正在使用我们谈论过的技术,并以任何方式将其投入生产,为其他人构建 API,为人们提供培训,或者其他什么——那么至少确保你所做的事情可以被追踪,以便人们知道发生了什么,至少他们是知情的。我认为假设人们是邪恶的,试图破坏社会是错误的。我认为我更愿意从一个假设开始,即如果人们做了愚蠢的事情,那是因为他们不知道更好的方法。所以至少确保他们有这些信息。我发现很少有机器学习从业者考虑他们的界面中应该呈现什么信息。然后我经常会和数据科学家交谈,他们会说“哦,我正在研究的东西对社会没有影响。”真的吗?有很多人认为他们正在做的事情完全毫无意义吗?来吧。人们付钱让你做这件事是有原因的。它会以某种方式影响人们。所以考虑一下这是什么。

在招聘中的责任

我知道的另一件事是很多参与其中的人都在招聘人才,如果你在招聘人才,我想你现在都非常熟悉 fast.ai 的理念,这基本上是这样一个前提,我认为人们总体上并不邪恶,我认为他们需要被告知并拥有工具。因此,我们正在尽可能地为尽可能多的人提供他们需要的工具,特别是我们正在尝试将这些工具交到更广泛人群的手中。因此,如果你参与招聘决策,也许你可以记住这种理念。如果你不仅仅是招聘更广泛的人才,而且还提拔更广泛的人才,并为更广泛的人提供适当的职业管理,除了其他任何事情,你的公司会做得更好。事实证明,更多样化的团队更有创造力,往往比不那么多样化的团队更快更好地解决问题,而且你也可能避免这些糟糕的失误,这在某种程度上对世界是有害的,而在另一层面,如果你被发现,它们可能毁掉你的公司。

IBM 和“死亡计算器”

他们也可以摧毁你,或者至少让你在历史上看起来很糟糕。举几个例子,一个是回到第二次世界大战。IBM 提供了跟踪大屠杀所需的所有基础设施。这些是他们使用的表格,它们有不同的代码 - 犹太人是 8,吉普赛人是 12,毒气室中的死亡是 6,所有这些都记录在这些打孔卡上。现在你可以去博物馆看这些打孔卡,这实际上已经被一位瑞士法官审查过,他说 IBM 的技术支持促进了纳粹分子的任务并促使他们犯下反人类罪行。回顾这些时期的历史,看看当时 IBM 的人们在想什么是很有趣的。当时人们明显在想的是展示技术优势的机会,测试他们的新系统的机会,当然还有他们赚取的巨额利润。当你做了一些事情,即使在某个时候会变成问题,即使你被告知要这样做,这对你个人来说也可能成为问题。例如,大家都记得大众柴油排放丑闻。谁是唯一一个入狱的人?那就是只是在做他的工作的工程师。如果所有这些关于实际上不要搞砸世界的东西还不足以说服你,那么它也可能毁掉你的生活。如果你做了一些事情,结果导致问题,即使有人告诉你要这样做,你绝对可能被追究刑事责任。亚历山大·科根就是那个交出剑桥分析数据的人。他是一位剑桥学者。现在是一位全球著名的剑桥学者,因为他为摧毁民主的基础做出了自己的贡献。这不是我们想要留在历史上的方式。

问题: 在你的一条推特中,你说 dropout 被专利化了[56:50]。我认为这是关于 Google 的 WaveNet 专利。这是什么意思?你能分享更多关于这个主题的见解吗?这意味着我们将来要付费使用 dropout 吗?专利持有人之一是 Geoffrey Hinton。那又怎样?这不是很棒吗?发明就是关于专利的,啦啦啦。我的答案是否定的。专利已经变得疯狂。我们每周讨论的可以被专利化的东西数量会有几十个。很容易想出一个小调整,然后如果你把它变成专利来阻止每个人在接下来的 14 年内使用那个小调整,最终我们就会面临现在的情况,所有东西都以 50 种不同的方式被专利化。然后你会遇到这些专利流氓,他们通过购买大量垃圾专利然后起诉任何无意中做了那件事的人,比如给按钮加上圆角。那么对于我们来说,深度学习中有很多东西被专利化意味着什么?我不知道。

做这个工作的主要人员之一是 Google,而且来自 Google 的人回应这个专利时倾向于认为 Google 这样做是因为他们想要防御性地拥有它,所以如果有人起诉他们,他们可以说不要起诉我们,我们会反诉你,因为我们有所有这些专利。问题是据我所知,他们还没有签署所谓的防御性专利承诺,所以基本上你可以签署一个法律约束文件,说我们的专利组合只会用于防御而不是进攻。即使你相信 Google 的所有管理层永远不会变成专利流氓,你必须记住管理层会变化。给你一个具体的例子,我知道,Google 的最近的 CFO 对 PNL 有更积极的态度,我不知道,也许她会决定他们应该开始变现他们的专利,或者也许做出那个专利的团队可能会被分拆然后卖给另一家公司,最终可能会落入私募股权手中并决定变现专利或其他。所以我认为这是一个问题。最近在法律上有一个从软件专利转向实际上没有任何法律地位的大变化,所以这些可能最终都会被驳回,但现实是,任何不是大公司的人都不太可能有财务能力来抵抗这些庞大的专利流氓。

如果你写代码,就无法避免使用专利的东西。我不会感到惊讶,如果你写的大部分代码都有专利。实际上,有趣的是,最好的做法不是研究专利,因为如果你故意侵犯,惩罚会更严重。所以最好的做法是把手放在耳朵上,唱首歌,然后继续工作。所以关于 dropout 被专利化的事情,忘记我说过的。你不知道那个。你跳过那部分。

风格迁移[1:01:28]

笔记本

arxiv.org/abs/1508.06576

这非常有趣——艺术风格。我们在这里有点复古,因为这实际上是最初的艺术风格论文,后来有很多更新和很多不同的方法,我实际上认为在很多方面最初的方法是最好的。我们也会看一些更新的方法,但我实际上认为最初的方法是一个很棒的方式,即使在之后的一切发展之后。让我们来看看代码。

%matplotlib inline
%reload_ext autoreload
%autoreload 2from fastai.conv_learner import *
from pathlib import Path
from scipy import ndimage
torch.cuda.set_device(3)

torch.backends.cudnn.benchmark=TruePATH = Path('data/imagenet')
PATH_TRN = PATH/'train'm_vgg = to_gpu(vgg16(True)).eval()
set_trainable(m_vgg, False)

这里的想法是我们想要拍摄一只鸟的照片,并且我们想要创作一幅看起来像梵高画了这只鸟的画。顺便说一句,我正在做的很多事情都使用了 ImageNet。你不必为我所做的任何事情下载整个 ImageNet。在files.fast.ai/data中有一个 ImageNet 样本,它有几个 G 的数据,对我们正在做的一切来说应该足够了。如果你想要得到真正出色的结果,你可以获取 ImageNet。你可以从Kaggle下载。定位竞赛实际上包含了所有的分类数据。如果你有空间,最好拥有一份 ImageNet 的副本,因为它随时都会派上用场。

img_fn = PATH_TRN/'n01558993'/'n01558993_9684.JPEG'
img = open_image(img_fn)
plt.imshow(img);

所以我刚从我的 ImageNet 文件夹中拿出了这只鸟,这就是我的鸟:

sz=288trn_tfms,val_tfms = tfms_from_model(vgg16, sz)
img_tfm = val_tfms(img)
img_tfm.shape*(3, 288, 288)*opt_img = np.random.uniform(0, 1, size=img.shape).astype(np.float32)
plt.imshow(opt_img);

我要做的是从这张图片开始:

我将尝试让它越来越像梵高画的鸟的图片。我做的方法实际上非常简单。你们都很熟悉它。我们将创建一个损失函数,我们将称之为f。损失函数将以一张图片作为输入,并输出一个值。如果图像看起来更像梵高画的鸟照片,那么这个值将更低。编写了这个损失函数之后,我们将使用 PyTorch 的梯度和优化器。梯度乘以学习率,我们不会更新任何权重,而是会更新输入图像的像素,使其更像梵高画的鸟的图片。然后我们再次通过损失函数来获取更多的梯度,一遍又一遍地进行。就是这样。所以这与我们解决每个问题的方式是相同的。你们知道我是一个只会一招的人,对吧?这是我的唯一招数。创建一个损失函数,用它来获取一些梯度,乘以学习率来更新某些东西,以前我们总是更新模型中的权重,但今天我们不会这样做。我们将更新输入图像中的像素。但实际上并没有什么不同。我们只是针对输入而不是针对权重来获取梯度。就是这样。我们快要完成了。

让我们做更多的事情。让我们在这里提到,我们的损失函数将有两个额外的输入。一个是鸟的图片。第二个是梵高的一幅艺术作品。通过将它们作为输入,这意味着我们以后可以重新运行这个函数,使其看起来像梵高画的鸟,或者像莫奈画的鸟,或者像梵高画的大型喷气式飞机等。这些将是三个输入。最初,正如我们讨论过的,我们的输入是一些随机噪音。我们从一些随机噪音开始,使用损失函数,获取梯度,使其更像梵高画的鸟,依此类推。

所以我猜我们可以简要讨论的唯一未解决的问题是我们如何计算我们的图像看起来有多像梵高画的这只鸟。让我们将其分为两部分:

内容损失:返回一个值,如果看起来更像这只鸟(不只是任何鸟,而是我们要处理的特定鸟)。

风格损失:如果图像更像 V.G.的风格,则返回一个较低的数字。

有一种非常简单的计算内容损失的方法——我们可以查看输出的像素,将它们与鸟的像素进行比较,计算均方误差,然后相加。所以如果我们这样做,我运行了一段时间。最终我们的图像会变成一只鸟的图像。你应该尝试一下。你应该尝试这个作为练习。尝试使用 PyTorch 中的优化器,从一个随机图像开始,通过使用均方误差像素损失将其转变为另一幅图像。这并不是非常令人兴奋,但这将是第一步。

问题是,即使我们已经有了我们的风格损失函数运行得很好,然后假设我们要做的是将这两者相加,然后其中一个,我们将乘以一些λ来调整风格与内容的比例。假设我们有一个风格损失并选择了一些合理的λ,如果我们使用像素级的内容损失,那么任何使其看起来更像梵高而不是完全像照片、背景、对比度、光照等的东西都会增加内容损失——这不是我们想要的。我们希望它看起来像鸟,但不是以相同的方式。它仍然会有相同位置的两只眼睛,相同的形状等等,但不是相同的表示。所以我们要做的是,这可能会让您震惊,我们要使用一个神经网络!我们将使用 VGG 神经网络,因为那是我去年使用的,我没有时间看其他东西是否有效,所以您可以在这一周自己尝试。

VGG 网络是一个接受输入并将其通过多个层的网络,我将把这些层视为卷积层,显然还有 ReLU,如果是带有批量归一化的 VGG,那么它也有批量归一化。还有一些最大池化等等,但没关系。我们可以做的是,我们可以取其中一个卷积激活,而不是比较这只鸟的像素,我们可以比较这个(由 V.G.绘制的)鸟的 VGG 层 5 激活与我们原始鸟的 VGG 层 5 激活(或第 6 层,第 7 层等)。那么为什么这样更有趣呢?首先,它不会是同一只鸟。它不会完全相同,因为我们不是在检查像素。我们在检查一些后续的激活。那么这些后续的激活包含什么?假设它经过一些最大池化后,它包含一个较小的网格——所以它对事物的位置不那么具体。而不是包含像素颜色值,它们更像是语义的东西,比如这是一种眼球,这是一种毛茸茸的,这是一种明亮的,或者这是一种反射的,或者平放的,或者其他什么。因此,我们希望通过这些层有一定程度的语义特征,如果我们得到一个与这些激活匹配的图片,那么任何匹配这些激活的图片看起来像鸟,但不是相同的鸟的表示。这就是我们要做的。这就是我们的内容损失将是什么。人们通常称之为感知损失,因为在深度学习中,您总是为您做的每件明显的事情创造一个新名称。如果您将两个激活进行比较,您正在进行感知损失。就是这样。我们的内容损失将是感知损失。然后我们将稍后进行风格损失。

让我们从尝试创建一只最初是随机噪音的鸟开始,我们将使用感知损失来创建类似鸟的东西,但不是特定的鸟。我们将从 288x288 开始。因为我们只做一只鸟,所以不会出现 GPU 内存问题。我实际上很失望地意识到我选择了一个相当小的输入图像。尝试使用更大的图像创建一个真正宏伟的作品会很有趣。另一件事要记住的是,如果您要将其投入生产,可以一次处理整个批次。有时人们会抱怨这种方法(Gatys 是主要作者)——Gatys 的风格迁移方法很慢,但我不同意它很慢。只需要几秒钟,您就可以在几秒钟内处理整个批次。

sz=288

所以我们将按照通常的做法将其通过 VGG16 模型的一些转换。记住,转换类有 dunder call 方法(__call__),所以我们可以将其视为一个函数。如果你将一个图像传递给它,那么我们将得到转换后的图像。尽量不要将 fast.ai 和 PyTorch 基础设施视为黑盒,因为它们都设计成非常易于以解耦的方式使用。所以这个转换只是“可调用”的想法(即用括号括起来的东西)来自于 PyTorch,我们完全抄袭了这个想法。所以在 torch.vision 或 fast.ai 中,你的转换只是可调用的。整个转换流水线只是一个可调用的。

trn_tfms,val_tfms = tfms_from_model(vgg16, sz)
img_tfm = val_tfms(img)
img_tfm.shape
'''
(3, 288, 288)
'''

现在我们有了一个 3x288x288 的东西,因为 PyTorch 喜欢通道在前面。正如你所看到的,它已经被转化为一个方形,被归一化为(0,1),所有这些正常的东西。

现在我们正在创建一个随机图像。

opt_img = np.random.uniform(0, 1, size=img.shape).astype(np.float32)
plt.imshow(opt_img);

这是我发现的一件事。试图将这个转化为任何东西的图片实际上非常困难。我发现实际上很难让优化器获得合理的梯度,使其有所作为。就在我以为我要在这门课上耗尽时间并真正让自己尴尬的时候,我意识到关键问题是图片不是这样的。它们更加平滑,所以我稍微模糊了一下,将其转化为以下内容:

opt_img = scipy.ndimage.filters.median_filter(opt_img, [8,8,1])
plt.imshow(opt_img);

我使用了一个中值滤波器——基本上就像一个中值池化。一旦我将其改为这样,它立即开始训练得非常好。你必须做一些微小的调整才能让这些东西工作起来,这有点疯狂,但这里有一个小调整。

所以我们从一个随机图像开始,这个图像至少有一定的平滑度。我发现我的鸟类图像的像素均值大约是这个值的一半,所以我将其除以 2,只是试图让匹配变得更容易一些(我不知道这是否重要)。将其转化为一个变量,因为这个图像,记住,我们将使用优化算法修改这些像素,所以任何涉及损失函数的东西都需要是一个变量。并且,它需要梯度,因为我们实际上是在更新图像。

opt_img = val_tfms(opt_img)/2
opt_img_v = V(opt_img[None], requires_grad=True)
opt_img_v.shape*torch.Size([1, 3, 288, 288])*

所以现在我们有了一个大小为 1 的小批量,3 个通道,288x288 的随机噪声。

m_vgg = nn.Sequential(*children(m_vgg)[:37])

我们将使用,没有特定原因,VGG 的第 37 层。如果你打印出 VGG 网络(你只需输入m_vgg并打印出来),你会看到这是中后期的层。所以我们可以只获取前 37 层并将其转化为一个顺序模型。现在我们有了一个 VGG 的子集,它将输出一些中间层的激活,这就是模型将要做的事情。所以我们可以拿到我们实际的鸟类图像,我们想创建一个大小为一的小批量。记住,如果你在 Numpy 中使用None进行切片,也就是np.newaxis,它会在那个点引入一个新的单位轴。这里,我想创建一个大小为 1 的轴,表示这是一个大小为一的小批量。所以就像我在这里做的一样(opt_img_v = V(opt_img[**None**], requires_grad=**True**))使用None进行切片,在前面得到一个单位轴。然后我们将其转化为一个变量,这个变量不需要更新,所以我们使用VV来表示你不需要为这个变量计算梯度。这将给我们我们的目标激活。

  • 我们已经拿到了我们的鸟类图像。

  • 将其转化为一个变量

  • 将其通过我们的模型传递,以获取第 37 层的激活,这是我们的目标。我们希望我们的内容损失是这组激活。

  • 我们将创建一个优化器(我们稍后会回到这个细节)

  • 我们将进行多次迭代

  • 梯度清零

  • 调用一些损失函数

  • 损失反向传播()

这就是高层次的版本。我一会儿会回到细节,但关键是我们传入那个随机生成的图像的损失函数——优化图像的变量。因此,我们将该图像传递给我们的损失函数,它将使用损失函数进行更新,而损失函数是通过将我们当前的优化图像通过我们的 VGG 获取中间激活,并将其与目标激活进行比较来计算均方误差损失。我们运行一堆次数,然后将其打印出来。我们有我们的鸟,但没有它的表示形式。

targ_t = m_vgg(VV(img_tfm[None]))
targ_v = V(targ_t)
targ_t.shape
'''
torch.Size([1, 512, 18, 18])
'''
max_iter = 1000
show_iter = 100
optimizer = optim.LBFGS([opt_img_v], lr=0.5)

Broyden–Fletcher–Goldfarb–Shanno(BFGS)

这里有一些新的细节。其中一个是一个奇怪的优化器(optim.LBFGS)。任何完成过某些数学和计算机科学课程的人进入深度学习领域都会发现我们使用像 Adam 和 SGD 这样的东西,并且总是假设该领域的人对计算机科学一无所知,立即说“你们有人尝试过使用 BFGS 吗?”实际上,我们并没有使用来训练神经网络的完全不同类型的优化算法的长期历史。当然,事实上,那些花了几十年研究神经网络的人确实对计算机科学有所了解,结果表明这些技术整体上并不工作得很好。但实际上,这对我们来说会很有效,并且这是一个很好的机会,让那些在学校没有学习过这种类型的优化算法的人了解一个有趣的算法。BFGS(四个不同人的首字母缩写),L 代表有限内存。它是一个优化器,也就是说,有一些损失函数,它将使用一些梯度(并非所有优化器都使用梯度,但我们使用的所有优化器都会)来找到一个方向,并尝试通过调整一些参数使损失函数降低。它只是一个优化器。但它是一种有趣的优化器,因为它在每一步上做的工作比我们习惯的要多一点。具体来说,它的工作方式与我们习惯的方式相同,即我们只是选择一个起点,而在这种情况下,我们选择了一个随机图像,正如你所看到的。像往常一样,我们计算梯度。但我们不仅仅是采取一步,而是实际上在找到梯度的同时,我们还尝试找到二阶导数。二阶导数表示梯度变化的速度。

梯度:函数变化的速度

二阶导数:梯度变化的速度

换句话说,它有多曲折?基本思想是,如果你知道它不太曲折,那么你可能可以跳得更远。但如果它非常曲折,那么你可能不想跳得太远。因此,在更高维度中,梯度被称为雅可比矩阵,而二阶导数被称为海森矩阵。你会经常看到这些词,但它们的意思就是这样。再次强调,数学家们也必须为每件事发明新词。他们就像深度学习研究人员一样——也许有点傲慢。使用 BFGS,我们将尝试计算二阶导数,然后我们将使用它来确定前进的方向和距离——因此,这不是对未知领域的一次疯狂跳跃。

现在的问题是,实际计算 Hessian(二阶导数)几乎肯定不是一个好主意。因为在你要前进的每个可能方向上,对于你测量梯度的每个方向,你还必须在每个方向上计算 Hessian。这变得非常庞大。所以我们不是真的计算它,我们走几步,基本上看一下梯度在每一步变化了多少,然后用那个小函数来近似 Hessian。再次强调,这似乎是一个非常明显的事情,但直到后来有人想到了,这花了相当长的时间。跟踪每一步都需要大量内存,所以别跟踪每一步,只保留最后的十步或二十步。第二部分,就是 L 到 LBFGS。有限内存的 BFGS 意味着保留最后的 10 或 20 个梯度,用它来近似曲率的量,然后用曲率和梯度来估计前进的方向和距离。在深度学习中通常不是一个好主意,有很多原因。这比 Adam 或 SGD 更新更费力,也使用更多内存,当你有一个 GPU 来存储和数亿个权重时,内存就成了一个更大的问题。但更重要的是,小批量是非常颠簸的,所以弄清楚曲率以决定到底要前进多远,有点像我们说的磨亮了粪便(是的,澳大利亚和英国的表达方式,你懂的)。有趣的是,实际上使用二阶导数信息,结果就像是一个吸引鞍点的磁铁。因此,有一些有趣的理论结果基本上说,如果使用二阶导数信息,它实际上会把你引向函数的恶劣平坦区域。所以通常不是一个好主意。

def actn_loss(x): 
    return F.mse_loss(m_vgg(x), targ_v)*1000
def step(loss_fn):
    global n_iter
    optimizer.zero_grad()
    loss = loss_fn(opt_img_v)
    loss.backward()
    n_iter+=1
    if n_iter%show_iter==0: 
        print(f'Iteration: n_iter, loss: **{loss.data[0]}**')
    return loss

但在这种情况下,我们不是在优化权重,而是在优化像素,所以所有规则都改变了,实际上 BFGS 是有意义的。因为每次它做更多的工作,它是一种不同类型的优化器,PyTorch 中的 API 也有点不同。正如你在这里看到的,当你说optimizer.step时,你实际上传入了损失函数。所以我们的损失函数是调用step,传入一个特定的损失函数,即我们的激活损失(actn_loss)。在循环内部,你不会说 step,step,step。而是看起来像这样。所以有点不同,你可以尝试重写这个来使用 SGD,它仍然会工作。只是会花更长的时间,我还没有尝试过用 SGD,我很想知道它需要多长时间。

n_iter=0
while n_iter <= max_iter: 
    optimizer.step(partial(step,actn_loss))
'''
Iteration: n_iter, loss: 0.8466196656227112
Iteration: n_iter, loss: 0.34066855907440186
Iteration: n_iter, loss: 0.21001280844211578
Iteration: n_iter, loss: 0.15562333166599274
Iteration: n_iter, loss: 0.12673595547676086
Iteration: n_iter, loss: 0.10863320529460907
Iteration: n_iter, loss: 0.0966048613190651
Iteration: n_iter, loss: 0.08812198787927628
Iteration: n_iter, loss: 0.08170554041862488
Iteration: n_iter, loss: 0.07657770067453384
'''

所以你可以看到损失函数在下降。我们的 VGG 模型第 37 层的激活与目标激活之间的均方误差,记住目标激活是应用于我们的鸟的 VGG。明白了吗?所以现在我们有了一个内容损失。现在,关于这个内容损失,我要说的一件事是我们不知道哪一层会起到最好的作用。所以如果我们能多做一些实验就好了。现在的情况很烦人:

也许我们甚至想使用多个层。所以,与其截断我们想要的层之后的所有层,不如我们能够以某种方式抓取几个层的激活值。现在,我们已经知道一种方法可以在我们做 SSD 时做到这一点,我们实际上编写了一个具有多个输出的网络。记得吗?不同的卷积层,我们吐出了一个不同的oconv东西?但我真的不想去添加到 torch.vision ResNet 模型中,特别是如果以后我想尝试 torch.vision VGG 模型,然后我想尝试 NASNet-A 模型,我不想去修改它们的输出。此外,我希望能够轻松地按需打开和关闭某些激活。所以我们之前简要提到过这个想法,PyTorch 有这些名为 hooks 的奇妙东西。您可以有前向钩子,让您将任何您喜欢的东西插入到计算的前向传递中,或者有后向钩子,让您将任何您喜欢的东西插入到后向传递中。所以我们将创建世界上最简单的前向钩子。

x = val_tfms.denorm(np.rollaxis(to_np(opt_img_v.data),1,4))[0]
plt.figure(figsize=(7,7))
plt.imshow(x);

前向钩子[1:29:42]

这是几乎没有人知道的事情之一,因此几乎在互联网上找到的任何实现风格转移的代码都会有各种可怕的黑客,而不是使用前向钩子。但前向钩子真的很容易。

要创建一个前向钩子,只需创建一个类。该类必须有一个名为hook_fn的东西。您的钩子函数将接收您挂钩的module,前向传递的inputoutput,然后您可以做任何您喜欢的事情。所以我要做的就是将这个模块的输出存储在某个属性中。就是这样。所以hook_fn实际上可以被称为您喜欢的任何东西,但“hook function”似乎是标准,因为您可以看到,在构造函数中发生的是我在某个属性中存储了m.register_forward_hook的结果(m将是我要挂钩的层),并传入您希望在调用模块的前向方法时调用的函数。当调用其前向方法时,它将调用self.hook_fn,该函数将在名为features的属性中存储输出。

class SaveFeatures():
    features=None
    def __init__(self, m): 
        self.hook = m.register_forward_hook(self.hook_fn)
    def hook_fn(self, module, input, output): 
        self.features = output
    def close(self): 
        self.hook.remove()

现在我们可以像以前一样创建一个 VGG。让我们将其设置为不可训练,这样我们就不会浪费时间和内存来计算梯度。让我们遍历并找到所有的最大池层。让我们遍历这个模块的所有子层,如果是一个最大池层,让我们输出索引减 1——这样就会给我最大池之前的层。通常,最大池或步长 2 卷积之前的层是一个非常完整的表示,因为下一层正在改变网格。所以这对我来说是一个很好的地方来获取内容损失。我们在该网格大小上拥有的最语义化、最有趣的内容。这就是为什么我要选择这些索引。

m_vgg = to_gpu(vgg16(True)).eval()
set_trainable(m_vgg, False)

这些是 VGG 中每个最大池之前的最后一层的索引[1:32:30]。

block_ends = [
    i-1 for i,o in enumerate(children(m_vgg))
    if isinstance(o,nn.MaxPool2d)
]
block_ends
'''
[5, 12, 22, 32, 42]
'''

我要获取32——没有特定的原因,只是尝试其他东西。所以我要说block_ends[3](即 32)。children(m_vgg)[block_ends[3]]会给我 VGG 的第 32 层作为一个模块。

sf = SaveFeatures(children(m_vgg)[block_ends[3]])

然后,如果我调用SaveFeatures构造函数,它会执行:

self.hook = {VGG 的第 32 层}.register_forward_hook(self.hook_fn)

现在,每当我对这个 VGG 模型进行前向传递时,它都会将第 32 层的输出存储在sf.features中。

def get_opt():
    opt_img = np.random.uniform(
        0, 1, 
        size=img.shape
    ).astype(np.float32)
    opt_img = scipy.ndimage.filters.median_filter(opt_img, [8,8,1])
    opt_img_v = V(val_tfms(opt_img/2)[None], requires_grad=True)
    return opt_img_v, optim.LBFGS([opt_img_v])
opt_img_v, optimizer = get_opt()

在这里[1:33:33],我调用了我的 VGG 网络,但我没有将其存储在任何地方。我没有说activations = m_vgg(VV(img_tfm[**None**]))。我调用它,丢弃答案,然后抓取我们在SaveFeatures对象中存储的特征。

m_vgg() — 这是在 PyTorch 中进行前向路径的方法。你不会说 m_vgg.forward(),你只是将其用作可调用。在 nn.module 上使用可调用会自动调用 forward。这就是 PyTorch 模块的工作方式。

所以我们称之为可调用的,最终调用我们的前向钩子,前向钩子将激活存储在 sf.features 中,所以现在我们有了我们的目标变量 — 就像以前一样,但以一种更加灵活的方式。

get_opt 包含了我们之前的相同的 4 行代码[1:34:34]。它只是给我一个要优化的随机图像和一个优化器来优化该图像。

m_vgg(VV(img_tfm[None]))
targ_v = V(sf.features.clone())
targ_v.shape
'''
torch.Size([1, 512, 36, 36])
'''
def actn_loss2(x):
    m_vgg(x)
    out = V(sf.features)
    return F.mse_loss(out, targ_v)*1000

现在我可以继续做完全相同的事情。但现在我将使用不同的损失函数 actn_loss2(激活损失 #2),它不会说 out=m_vgg,再次,它调用 m_vgg 进行前向传递,丢弃结果,并获取 sf.features。所以现在这是我的第 32 层激活,然后我可以在其上执行均方误差损失。你可能已经注意到,最后一个损失函数和这个都乘以了一千。为什么它们乘以一千?这就像所有试图使这个课程不正确的事情。我以前没有使用一千,它就无法训练。今天午餐时间,什么都不起作用。经过几天的尝试让这个东西工作,最终偶然注意到“天哪,损失函数的数字真的很低(如 10E-7)”,我想如果它们不那么低会怎样。所以我将它们乘以一千,然后它开始工作了。那为什么它不起作用呢?因为我们正在使用单精度浮点数,而单精度浮点数并不那么精确。特别是当你得到的梯度有点小,然后你乘以学习率可能也很小,最终得到一个很小的数字。如果它太小,它们可能会被四舍五入为零,这就是发生的事情,我的模型还没有准备好。我相信有比乘以一千更好的方法,但无论如何。它运行得很好。无论你将损失函数乘以多少,因为你关心的只是它的方向和相对大小。有趣的是,这与我们在训练 ImageNet 时所做的事情类似。我们使用了半精度浮点数,因为 Volta 张量核要求如此。如果你想要训练半精度浮点数,实际上你必须将损失函数乘以一个缩放因子。我们使用了 1024 或 512。我认为 fast.ai 现在是第一个具有所有必要技巧以在半精度浮点数中进行训练的库,因此如果你有幸拥有 Volta 或者你可以支付 AWS P3,如果你有一个学习对象,你只需说 learn.half,它现在就会神奇地正确地训练半精度浮点数。它也内置在模型数据对象中,一切都是自动的。我相信没有其他库能做到这一点。

n_iter=0
while n_iter <= max_iter: 
    optimizer.step(partial(step,actn_loss2))
'''
Iteration: n_iter, loss: 0.2112911492586136
Iteration: n_iter, loss: 0.0902421623468399
Iteration: n_iter, loss: 0.05904778465628624
Iteration: n_iter, loss: 0.04517251253128052
Iteration: n_iter, loss: 0.03721420466899872
Iteration: n_iter, loss: 0.03215853497385979
Iteration: n_iter, loss: 0.028526008129119873
Iteration: n_iter, loss: 0.025799645110964775
Iteration: n_iter, loss: 0.02361033484339714
Iteration: n_iter, loss: 0.021835438907146454
'''

这只是在稍早的层上做同样的事情[1:37:35]。这只是让鸟看起来更像鸟。希望你能理解,较早的层越接近像素。有更多的网格单元,每个单元更小,更小的感受野,更简单的语义特征。所以我们越早得到,它看起来就越像一只鸟。

x = val_tfms.denorm(np.rollaxis(to_np(opt_img_v.data),1,4))[0]
plt.figure(figsize=(7,7))
plt.imshow(x);

sf.close()

事实上,这篇论文有一张很好的图片展示了各种不同的层,并放大到这座房子[1:38:17]。他们试图让这座房子看起来像《星夜》的图片。你可以看到后来,它变得非常混乱,而之前看起来像这座房子。所以这只是在做我们刚刚做的事情。我在我们的学习小组中注意到的一件事是,每当我告诉某人回答一个问题,每当我说去读这篇论文中有一些东西告诉你问题的答案时,总会有一种震惊的表情“读这篇论文?我?”但是说真的,论文已经做了这些实验并绘制了这些图片。论文中有很多东西。这并不意味着你必须读完论文的每一部分。但至少看看图片。所以看看 Gatys 的论文,里面有很好的图片。所以他们已经为我们做了实验,但看起来他们没有深入研究 — 他们只是得到了一些早期的结果。

风格匹配[1:39:29]

我们接下来需要做的是创建风格损失。我们已经有了损失,即它有多像鸟。现在我们需要知道它有多像这幅绘画的风格。我们将做几乎相同的事情。我们将获取某一层的激活。现在问题是,某一层的激活,假设它是一个 5x5 的层(当然没有 5x5 的层,它是 224x224,但我们假装)。这里是一些激活,我们可以获取这些激活,无论是针对我们正在优化的图像还是我们的梵高绘画。让我们看看我们的梵高绘画。这就是它 —《星夜》

style_fn = PATH/'style'/'starry_night.jpg'
style_img = open_image(style_fn)
style_img.shape, img.shape
'''
((1198, 1513, 3), (291, 483, 3))
'''
plt.imshow(style_img);

我从维基百科下载了这幅图像,我想知道为什么加载如此缓慢[1:40:39] — 结果,我下载的维基百科版本是 30,000 x 30,000 像素。他们有这种严肃的画廊品质存档真的很酷。我不知道这个存在。不要试图在上面运行神经网络。完全毁了我的 Jupyter 笔记本。

所以我们可以为我们的梵高图像做到这一点,也可以为我们的优化图像做到这一点。然后我们可以比较这两者,最终我们会创建一幅内容类似于绘画但并非绘画的图像 — 这不是我们想要的。我们想要的是具有相同风格但不是绘画且没有内容的东西。所以我们想要丢弃所有的空间信息。我们不想要创造出一个这里有月亮,这里有星星,这里有教堂的东西。我们不想要任何这些。那么我们如何丢弃所有的特殊信息呢?

在这种情况下,这里有 19 个面 - 19 个切片。所以让我们拿到这个顶部切片,这将是一个 5x5 矩阵。现在,让我们展平它,我们得到一个 25 个元素的长向量。一下子,我们通过展平抛弃了大部分空间信息。现在让我们拿到第二个切片(即另一个通道)并做同样的事情。所以我们有通道 1 展平和通道 2 展平,它们都有 25 个元素。现在,让我们进行点积,我们可以在 Numpy 中用 @ 来做(注:这里是 Jeremy 对我的点积与矩阵乘法问题的回答)。点积将给我们一个数字。那个数字是什么?它告诉我们什么?假设激活在 VGG 网络的中间层附近,我们可能期望其中一些激活是笔触纹理有多强,一些是这个区域有多明亮,一些是这部分是房子的一部分还是圆形的一部分,或者其他部分是这幅画的哪部分有多暗。所以点积基本上是一个相关性。如果这个元素和这个元素都非常正或都非常负,它会给我们一个大结果。另外,如果它们相反,它会给一个小结果。如果它们都接近零,它不会给结果。所以基本上点积是衡量这两个东西有多相似的一个指标。所以如果通道 1 和通道 2 的激活相似,那么它基本上是在说 - 让我们举个例子[1:44:28]。比如第一个是笔触纹理有多强(C1),而另一个是笔触有多倾斜(C2)。

如果细胞(1,1)的 C1 和 C2 同时高,细胞(4,2)也是如此,那么它表明具有纹理的网格单元也倾向于具有对角线。因此,当具有纹理的网格单元也具有对角线时,点积会很高,当它们没有时,点积也不高。所以这就是 C1 @ C2。另外,C1 @ C1 实际上是 2-范数(即 C1 的平方和)。这基本上是在说纹理通道中有多少网格单元是活跃的,以及它们有多活跃。换句话说,C1 @ C1 告诉我们纹理绘画进行了多少。而 C2 @ C2 告诉我们对角线绘画进行了多少。也许 C3 是“颜色是否明亮?”,所以 C3 @ C3 将告诉我们明亮颜色单元有多频繁。

那么我们可以创建一个包含每个点积的 19x19 矩阵[1:47:17]。就像我们讨论过的,数学家们必须给每样东西起个名字,所以这个特定的矩阵,将其展平然后进行所有点积的操作,被称为 Gram 矩阵。

我告诉你一个秘密[1:48:29]。大多数深度学习从业者要么不知道,要么不记得所有这些东西,比如如果他们曾经在大学学过 Gram 矩阵。他们可能忘记了,因为之后他们可能熬夜了。实际上的工作方式是你意识到“哦,我可以创建一个非空间表示,展示通道之间的相关性”,然后当我写论文时,我不得不去问周围的人,“这个东西有个名字吗?” 然后有人会说“这不就是 Gram 矩阵吗?” 你去查一下,确实是。所以不要认为你必须先学习所有的数学。先运用你的直觉和常识,然后再担心数学叫什么,通常是这样。有时候也会反过来,不过不是对我,因为我不擅长数学。

所以这被称为 Gram 矩阵。当然,如果你是一个真正的数学家,非常重要的是你要说得好像你一直知道这是一个 Gram 矩阵,然后你就会说,哦是的,我们只是计算 Gram 矩阵。所以 Gram 矩阵就是这种映射——对角线可能是最有趣的部分。对角线显示哪些通道最活跃,然后非对角线显示哪些通道倾向于一起出现。总的来说,如果两幅图片有相同的风格,那么我们期望某些激活层会有相似的 Gram 矩阵。因为如果我们找到了捕捉很多关于画笔笔触和颜色的东西的激活层,那么仅仅对角线(在 Gram 矩阵中)可能就足够了。这是另一个有趣的作业,如果有人想尝试的话,可以尝试使用 Gatys 的风格迁移,而不是使用 Gram 矩阵,而是只使用 Gram 矩阵的对角线。这只需要改变一行代码。但我还没有看到有人尝试过,也不知道它是否会起作用,但它可能会很好。

“好的,是的,克里斯汀,你已经尝试过了。”“我已经尝试过了,大多数时候都有效,除非你有需要两种风格出现在同一个地方的有趣图片。所以看起来像是一半是草,一半是人群,你需要这两种风格。”(克里斯汀)。很酷,你仍然会做你的作业,但克里斯汀说她会替你做。

def scale_match(src, targ):
    h,w,_ = img.shape
    sh,sw,_ = style_img.shape
    rat = max(h/sh,w/sw); rat
    res = cv2.resize(style_img, (int(sw*rat), int(sh*rat)))
    return res[:h,:w]
style = scale_match(img, style_img)
plt.imshow(style)
style.shape, img.shape
'''
((291, 483, 3), (291, 483, 3))
'''

这是我们的绘画。我尝试调整绘画的大小,使其与我的鸟类图片大小相同。所以这就是所有这些在做的事情。不管我使用哪一部分,只要它有很多漂亮的风格就可以了。

我像以前一样获取了我的优化器和随机图像:

opt_img_v, optimizer = get_opt()

这一次,我为所有的block_ends调用SaveFeatures,这将给我一个 SaveFeatures 对象的数组——每个模块都会出现在最大池化之前的层中。因为这一次,我想玩弄不同的激活层风格,更具体地说,我想让你来玩。所以现在我有了一个完整的数组。

sfs = [SaveFeatures(children(m_vgg)[idx]) for idx in block_ends]

style_img是我的梵高的绘画。所以我拿我的style_img,通过我的转换来创建我的转换风格图像(style_tfm)。

style_tfm = val_tfms(style_img)

将其转换为一个变量,通过我的 VGG 模块的前向传播,现在我可以遍历所有的 SaveFeatures 对象并获取每组特征。请注意,我调用clone,因为以后,如果我再次调用我的 VGG 对象,它将替换这些内容。我还没有想过这是否有必要。如果你把它拿走了,那没关系。但我只是小心翼翼。现在这是每个block_end层的激活的数组。在这里,你可以看到所有这些形状:

m_vgg(VV(style_tfm[None]))
targ_styles = [V(o.features.clone()) for o in sfs]
[o.shape for o in targ_styles]
'''
[torch.Size([1, 64, 288, 288]),
 torch.Size([1, 128, 144, 144]),
 torch.Size([1, 256, 72, 72]),
 torch.Size([1, 512, 36, 36]),
 torch.Size([1, 512, 18, 18])]
'''

你可以看到,能够快速地编写一个列表推导式在你的 Jupyter 玩耍中非常重要。因为你真的希望能够立即看到这是我的通道(64、128、256,...),以及我们期望的网格大小减半(288、144、72...),因为所有这些都出现在最大池化之前。

因此,要进行 Gram MSE 损失,它将是输入的 Gram 矩阵与目标的 Gram 矩阵的 MSE 损失。Gram 矩阵只是xx转置(x.t())的矩阵乘积,其中 x 简单地等于我已经将批处理和通道轴全部展平的输入。我只有一个图像,所以可以忽略批处理部分——基本上是通道。然后其他所有部分(-1),在这种情况下是高度和宽度,是另一个维度,因为现在将是通道乘以高度和宽度,然后正如我们讨论过的,我们可以将其与其转置进行矩阵乘积。为了归一化,我们将其除以元素的数量(b*c*h*w)——如果我说input.numel(元素的数量)会更优雅,这将是相同的事情。再次,这给我了很小的数字,所以我乘以一个大数字使其变得更合理。所以这基本上就是我的损失。

def gram(input):
        b,c,h,w = input.size()
        x = input.view(b*c, -1)
        return torch.mm(x, x.t())/input.numel()*1e6

def gram_mse_loss(input, target): 
        return F.mse_loss(gram(input), gram(target))

现在我的风格损失是将我的图像优化,通过 VGG 前向传递,获取所有 SaveFeatures 对象中特征的数组,然后在每一层上调用我的 Gram MSE 损失。这将给我一个数组,然后我只需将它们相加。现在你可以用不同的权重将它们相加,你可以添加子集,或者其他。在这种情况下,我只是获取了所有的。

def style_loss(x):
    m_vgg(opt_img_v)
    outs = [V(o.features) for o in sfs]
    losses = [gram_mse_loss(o, s) for o,s in zip(outs, targ_styles)]
    return sum(losses) 

像以前一样将其传递给我的优化器:

n_iter=0
while n_iter <= max_iter: 
    optimizer.step(partial(step,style_loss))
'''
Iteration: n_iter, loss: 230718.453125
Iteration: n_iter, loss: 219493.21875
Iteration: n_iter, loss: 202618.109375
Iteration: n_iter, loss: 481.5616760253906
Iteration: n_iter, loss: 147.41177368164062
Iteration: n_iter, loss: 80.62625122070312
Iteration: n_iter, loss: 49.52326965332031
Iteration: n_iter, loss: 32.36254119873047
Iteration: n_iter, loss: 21.831811904907227
Iteration: n_iter, loss: 15.61091423034668
'''

这里有一张随机图像,风格类似于梵高,我觉得挺酷的。

x = val_tfms.denorm(np.rollaxis(to_np(opt_img_v.data),1,4))[0]
plt.figure(figsize=(7,7))
plt.imshow(x);

再次,Gatys 已经为我们做好了。这里是不同层次的随机图像,风格类似于梵高。所以第一个,你可以看到,激活是简单的几何图形——一点也不有趣。后面的层次更有趣。所以我们有一种怀疑,我们可能想要主要使用后面的层次来进行风格损失,如果我们想要看起来好的话。

我添加了这个SaveFeatures.close,它只是调用self.hook.remove()。记住,我将 hook 存储为self.hook,所以hook.remove()会将其删除。最好将其删除,否则可能会一直使用内存。因此,在最后,我只需遍历每个 SaveFeatures 对象并关闭它:

for sf in sfs: sf.close()

风格转移

风格转移是将内容损失和风格损失加在一起,并加上一些权重。所以没有太多可以展示的。

获取我的优化器,获取我的图像:

opt_img_v, optimizer = get_opt()

我的综合损失是一个特定层次的 MSE 损失,我所有层次的风格损失,将风格损失相加,加到内容损失上,我正在缩放内容损失。实际上,我已经将风格损失缩放为 1E6。所以它们都被精确地缩放了。将它们加在一起。再次,你可以尝试对不同的风格损失进行加权,或者你可以删除其中一些,所以这是最简单的版本。

def comb_loss(x):
    m_vgg(opt_img_v)
    outs = [V(o.features) for o in sfs]
    losses = [gram_mse_loss(o, s) for o,s in zip(outs, targ_styles)]
    cnt_loss   = F.mse_loss(outs[3], targ_vs[3])*1000000
    style_loss = sum(losses)
    return cnt_loss + style_loss

训练它:

n_iter=0
while n_iter <= max_iter: 
    optimizer.step(partial(step,comb_loss))
'''
Iteration: n_iter, loss: 1802.36767578125
Iteration: n_iter, loss: 1163.05908203125
Iteration: n_iter, loss: 961.6024169921875
Iteration: n_iter, loss: 853.079833984375
Iteration: n_iter, loss: 784.970458984375
Iteration: n_iter, loss: 739.18994140625
Iteration: n_iter, loss: 706.310791015625
Iteration: n_iter, loss: 681.6689453125
Iteration: n_iter, loss: 662.4088134765625
Iteration: n_iter, loss: 646.329833984375
'''
x = val_tfms.denorm(np.rollaxis(to_np(opt_img_v.data),1,4))[0]
plt.figure(figsize=(9,9))
plt.imshow(x, interpolation='lanczos')
plt.axis('off');

for sf in sfs: sf.close()

天啊,它看起来真的很好。所以我觉得这很棒。这里的主要要点是,如果你想用神经网络解决问题,你所要做的就是设置一个损失函数,然后优化某些东西。而损失函数是一个较低的数字是你更满意的东西。因为当你优化它时,它会使那个数字尽可能低,它会做你想要它做的事情。所以在这里,Gatys 提出了一个损失函数,当它看起来像我们想要的东西时,它会是一个较小的数字,看起来像我们想要的风格。这就是我们所要做的。

实际上,除了实现了 Gram MSE 损失,这只是 6 行代码,这就是我们的损失函数:

将其传递给我们的优化器,等大约 5 秒钟,我们就完成了。记住,我们可以一次处理一批,所以我们可以等待 5 秒钟,64 个就完成了。所以我认为这真的很有趣,自从这篇论文发表以来,它确实激发了很多有趣的工作。不过对我来说,大部分有趣的工作还没有发生,因为对我来说,有趣的工作是将人类创造力与这些工具结合起来的工作。我还没有看到可以下载或使用的工具,艺术家可以控制并可以以交互方式进行操作。与Google Magenta项目的人交谈很有趣,这是他们的创意人工智能项目,他们在音乐方面所做的一切都是关于这个的。它正在构建音乐家可以实时使用的工具。由于 Magenta 的存在,您将在音乐领域看到更多这样的东西。如果您访问他们的网站,您会看到各种按键,可以实际更改鼓点、旋律、音调等。您肯定会看到 Adobe 或 Nvidia 开始发布一些小型原型并开始这样做,但这种创意人工智能的爆发尚未发生。我认为我们已经拥有了我们所需的所有技术,但没有人将其整合到一起并说“看看我建造的东西,看看人们用我的东西建造的东西”。所以这只是一个巨大的机会领域。

所以我在课堂开始时提到的那篇论文[2:01:16] ——基本上是将美国队长的盾牌添加到任意绘画中使用了这种技术。不过,诀窍是通过一些微小的调整使粘贴的美国队长盾牌能够很好地融入其中。但那篇论文只有几天的历史,所以尝试这个项目将是一个非常有趣的项目,因为您可以使用所有这些代码。它确实利用了这种方法。然后,您可以从使内容图像类似于带有盾牌的绘画开始,然后样式图像可以是不带盾牌的绘画。这将是一个很好的开始,然后您可以看看他们在这篇论文中尝试解决的具体问题,以使其更好。但您现在可以开始。

问题:之前有很多人表达了对 Pyro 和概率编程的兴趣。所以 TensorFlow 现在有了这个 TensorFlow 概率或其他东西。有很多概率编程框架。我认为它们很有趣,但至今未经证明,因为我还没有看到任何使用概率编程系统完成的事情,而没有使用它们更好。基本前提是它允许你创建更多关于你认为世界是如何运作的模型,然后插入参数。所以 20 年前当我还在管理咨询行业工作时,我们经常使用电子表格,然后我们会使用这些蒙特卡洛模拟插件——有一个叫做 At Risk(?),一个叫做 Crystal Ball。我不知道几十年后它们是否还存在。基本上它们让你可以更改电子表格中的一个单元格,说这不是一个具体的值,而实际上代表一个具有这个均值和标准差的值分布,或者它有这个分布,然后你会点击一个按钮,电子表格会从这些分布中随机抽取一千次数字重新计算,并显示你的结果的分布,可能是利润或市场份额或其他什么。那时我们经常使用它们。显然,人们认为电子表格是做这种工作的更明显的地方,因为你可以更自然地看到所有这些,但我不知道。我们将看到。在这个阶段,我希望它能够被证明有用,因为我觉得它非常吸引人,符合我过去经常做的工作。实际上,围绕这种东西有整个实践,他们过去称之为系统动力学,这实际上是建立在这种东西之上的,但它并没有走得太远。

问题:然后有一个关于通用风格转移预训练的问题。我不认为你可以为通用风格进行预训练,但你可以为特定风格的通用照片进行预训练,这就是我们要达到的目标。尽管可能最终会成为一项作业。我还没有决定。但我会做所有的部分。

问题:请让他谈谈多 GPU。哦,是的,我还没有关于那个的幻灯片。我们马上就要谈到了。

在我们开始之前,再分享一张来自 Gatys 论文的有趣图片。他们有更多图片,只是没有适合我的幻灯片,但是有不同的卷积层用于风格。不同的风格和内容比例,这里是不同的图片。显然这不再是梵高的风格,这是一个不同的组合。所以你可以看到,如果你只做风格,你看不到任何图片。如果你做很多内容,但是使用足够低的卷积层,看起来还可以,但背景有点愚蠢。所以你可能想要在中间某个地方。所以你可以尝试一下,做一些实验,但也可以使用论文来帮助指导你。

数学

实际上,我想现在开始研究数学,下周我们将讨论多 GPU 和超分辨率,因为这是来自论文的内容,我真的希望在我们讨论完论文后,你们能阅读论文,并在论坛上提出任何不清楚的问题。但这篇论文中有一部分我想谈谈,讨论如何解释它。所以论文说,我们将得到一个输入图像x,这个小东西通常表示它是一个向量,Rachel,但这个是一个矩阵。我猜它可能是两者之一。我不知道。通常小写粗体字母表示向量,或者带有上箭头的小写字母表示向量。通常大写字母表示矩阵,或者带有两个箭头的小写字母表示矩阵。在这种情况下,我们的图像是一个矩阵。我们基本上将其视为向量,所以也许我们只是在超前一步。

所以我们有一个输入图像x,它可以通过 CNN 的特定层中的滤波器响应(即激活)进行编码。滤波器响应就是激活。希望你们都能理解。CNN 的基本功能就是生成激活层。一个层有一堆滤波器,产生一定数量的通道。今年表示第 L 层有大写 Nl个滤波器。再次强调,这里的大写字母不代表矩阵。所以我不知道,数学符号是如此不一致。所以在第 L 层有 Nl 个不同的滤波器,这意味着也有同样数量的特征图。所以确保你能看到这个字母 Nl 和这个字母是一样的。所以你必须非常小心地阅读字母并认识到它就像啪一下,这个字母和那个字母是一样的。显然,Nl 个滤波器创建了 Nl 个特征图或通道,每个尺寸为 Ml(好吧,我看到这里正在发生展开)。这就像 numpy 符号中的 M[l]。这是第l层。所以 M 是第l层。尺寸是高度乘以宽度——所以我们将其展平。所以第 l 层的响应可以存储在矩阵 F 中(现在l在顶部,出于某种原因)。这不是 f^l,这只是另一个索引。我们只是为了好玩而移动它。这里我们说它是 R 的元素——这是一个特殊的 R,表示实数 N 乘以 M(这表示它的维度是 N 乘以 M)。这非常重要,不要继续。就像 PyTorch 一样,确保你首先理解维度的秩和大小,数学也是一样。这些是你停下来思考为什么是 N 乘以 M 的地方。N 是滤波器的数量,M 是高度乘以宽度。所以你还记得我们做.view(b*c, -1)的时候吗?这就是。所以尝试将代码映射到数学上。所以 F 是x

如果我对你更友好,我会使用与论文相同的字母。但我太忙于让这个该死的东西运行起来,无法仔细做到这一点。所以你可以回去将其重命名为大写 F。

所以我们将 L 移到顶部是因为我们现在要有更多的索引。在 Numpy 或 PyTorch 中,我们通过方括号索引事物,然后用逗号分隔很多东西。在数学中的方法是用小写字母围绕你的字母——到处都扔上去。所以这里,Fl是 F 的第l层,然后ij是第l层中第i个滤波器在第j位置的激活。所以位置j的大小是 M,即高度乘以宽度的大小。这是容易混淆的事情。通常你会看到一个ij,然后假设它是在图像的高度乘以宽度的位置进行索引,但实际上不是,对吧?它是在通道中对展平图像的第i个滤波器/通道的第j个位置进行索引。它甚至告诉你——它是第l层中展平图像中第j个位置的第i个滤波器/通道。所以除非你理解 F 是什么,否则你将无法进一步阅读论文。这就是为什么这些是你停下来确保你感到舒适的地方。

所以现在,内容损失,我不会花太多时间,但基本上我们只是要检查激活值与预测值的平方[2:12:03]。所以这就是我们的内容损失。风格损失将是类似的,但使用格拉姆矩阵 G:

我真的很想向你展示这个。我觉得这很棒。有时我真的喜欢数学符号中可以做的事情,它们也是你通常可以在 J 和 APL 中做的事情,这种隐式循环正在这里进行。这是在说什么呢?嗯,它在说我的层l中的格拉姆矩阵,对于一个轴上的第i个位置和另一个轴上的第j个位置等于我的 F 矩阵(所以我的展平矩阵)对于该层中的第i个通道与同一层中的第j个通道,然后我将进行求和。我们将取第k个位置并将它们相乘然后将它们全部加起来。所以这正是我们之前计算格拉姆矩阵时所做的事情。所以这里发生了很多事情,因为对我来说,这是非常巧妙的符号 —— 有三个隐式循环同时进行,加上求和中的一个显式循环,然后它们一起工作来为每一层创建这个格拉姆矩阵。所以让我们回去看看你是否能匹配这个。所以所有这一切都同时发生,这非常棒。

就是这样。所以下周,我们将看到一个非常类似的方法,基本上再次进行风格转移,但这次我们实际上会训练一个神经网络来为我们做这件事,而不是进行优化。我们还将看到你可以做同样的事情来进行超分辨率。我们还将回顾一些 SSD 的内容,以及进行一些分割。所以如果你忘记了 SSD,这周可能值得进行一点复习。好的,谢谢大家。下周见。

深度学习 2:第 2 部分第 14 课

原文:medium.com/@hiromi_suenaga/deep-learning-2-part-2-lesson-14-e0d23c7a0add

译者:飞龙

协议:CC BY-NC-SA 4.0

来自 fast.ai 课程的个人笔记。随着我继续复习课程以“真正”理解它,这些笔记将继续更新和改进。非常感谢 JeremyRachel 给了我这个学习的机会。

论坛 / 视频

上周的展示

Alena Harley 做了一些非常有趣的事情,她尝试找出如果只对三四百张图片进行循环 GAN 会发生什么,我真的很喜欢这些项目,人们只需使用 API 或其中一个库去谷歌图片搜索。我们的一些学生已经创建了一些非常好的库,用于与谷歌图片 API 进行交互,下载一些他们感兴趣的东西,比如一些照片和一些彩色玻璃窗。有了 300~400 张照片,她训练了几个不同的模型——这是我特别喜欢的。正如你所看到的,用相当少量的图片,她得到了非常漂亮的彩色玻璃效果。所以我认为这是一个有趣的例子,使用相当少量的数据,她能够很快地下载到的数据。如果你感兴趣,论坛上有更多信息。

人们会用这种生成模型想出什么样的东西是很有趣的。这显然是一个很好的艺术媒介。显然也是一个很好的伪造和欺骗媒介。我想知道人们会意识到他们可以用这种生成模型做什么其他类型的事情。我认为音频将成为下一个重要领域。还有非常互动的类型。英伟达刚刚发布了一篇论文,展示了一种互动的照片修复工具,你只需刷过一个物体,它就会用深度学习生成的替代品替换得很好。我认为这种互动工具也会很有趣。

超分辨率[2:06]

实时风格转移和超分辨率的感知损失

上次,我们看了通过直接优化像素来进行风格转移。就像第二部分的大部分内容一样,我并不是想让你理解风格转移本身,而是直接优化输入并使用激活作为损失函数的一种想法,这才是真正的关键点。

因此,有趣的是看到接下来的论文,不是来自同一组人,而是在这些视觉生成模型序列中接下来的一篇来自斯坦福大学的 Justin Johnson 和他的同事。它实际上做了同样的事情——风格转移,但是用了不同的方法。与其优化像素,我们将回到更熟悉的东西,优化一些权重。具体来说,我们将训练一个模型,学习将一张照片转换成某种艺术作品风格的照片。因此,每个卷积网络将学习产生一种风格。

现在,事实证明,要达到那一点,有一个中间点(我认为更有用,可以让我们走一半的路)叫做超分辨率。所以我们实际上要从超分辨率开始[3:55]。因为然后我们将在超分辨率的基础上构建卷积神经网络风格转移的最后部分。

超分辨率是指我们将一个低分辨率图像(我们将采用 72x72)放大到一个更大的图像(在我们的情况下是 288x288),试图创建一个看起来尽可能真实的高分辨率图像。这是一件具有挑战性的事情,因为在 72x72 的情况下,关于很多细节的信息并不多。很酷的是,我们将以一种与视觉模型相似的方式来做,这种方式不受输入大小的限制,因此您完全可以将这个模型应用于 288x288 的图像,得到每边都大四倍的东西,比原始图像大 16 倍。通常在那个级别甚至效果更好,因为您真的在更细节的地方引入了很多细节,您可以真正打印出一个高分辨率的打印品,而之前它看起来相当像素化。

笔记本

这很像 CSI 风格的增强,我们将拿出一些看起来信息不在那里的东西,我们会发明它——但是卷积网络将学会以与已有信息一致的方式发明它,所以希望它发明正确的信息。这种问题的一个非常好的地方是,我们可以创建自己的数据集,而不需要任何标签要求,因为我们可以通过对图像进行降采样轻松地从高分辨率图像创建低分辨率图像。所以我希望你们中的一些人这周尝试做其他类型的图像到图像的转换,你可以发明“标签”(你的因变量)。例如:

  • 去斜:识别已经旋转了 90 度或更好的是旋转了 5 度并将其拉直的东西。

  • 着色:将一堆图像变成黑白,然后学会重新加上颜色。

  • 降噪:也许做一个质量很低的 JPEG 保存,然后学会将其恢复到应该有的样子。

  • 也许将一个 16 色调色板的东西放回到更高的色调色板。

我认为这些东西都很有趣,因为它们可以用来处理您以前用糟糕的旧数码相机拍摄的照片,或者您可能已经扫描了一些现在已经褪色的旧照片等。我认为这是一件非常有用的事情,也是一个很好的项目,因为它与我们在这里所做的非常相似,但又有足够的不同,让您在途中遇到一些有趣的挑战,我相信。

我将再次使用 ImageNet。您根本不需要使用所有的 ImageNet,我只是碰巧有它。您可以从 files.fast.ai 下载 ImageNet 的百分之一样本。您可以使用您手头上任何一组图片。

matplotlib inline
%reload_ext autoreload
%autoreload 2

超分辨率数据

from fastai.conv_learner import *
from pathlib import Path

torch.backends.cudnn.benchmark=True
PATH = Path('data/imagenet')
PATH_TRN = PATH/'train'

在这种情况下,正如我所说,我们实际上没有标签,所以我只是给每样东西都标上零,这样我们就可以更容易地与我们现有的基础设施一起使用。

fnames_full,label_arr_full,all_labels = folder_source(PATH, 'train')
fnames_full = ['/'.join(Path(fn).parts[-2:]) for fn in fnames_full]
list(zip(fnames_full[:5],label_arr_full[:5]))
'''
[('n01440764/n01440764_9627.JPEG', 0),
 ('n01440764/n01440764_9609.JPEG', 0),
 ('n01440764/n01440764_5176.JPEG', 0),
 ('n01440764/n01440764_6936.JPEG', 0),
 ('n01440764/n01440764_4005.JPEG', 0)]
'''
all_labels[:5]
'''
['n01440764', 'n01443537', 'n01484850', 'n01491361', 'n01494475']
'''

现在,因为我指向一个包含所有 ImageNet 的文件夹,我当然不想等待所有 ImageNet 完成一个周期才运行。所以在这里,我通常会将“保留百分比”(keep_pct)设置为 1 或 2%。然后我只生成一堆随机数,然后只保留那些小于 0.02 的数,这样让我快速地对行进行子采样。

np.random.seed(42)
# keep_pct = 1.
keep_pct = 0.02
keeps = np.random.rand(len(fnames_full)) < keep_pct
fnames = np.array(fnames_full, copy=False)[keeps]
label_arr = np.array(label_arr_full, copy=False)[keeps]

所以我们将使用 VGG16,VGG16 是我们在这门课程中还没有真正研究过的东西,但它是一个非常简单的模型,我们将采用我们通常的预计是 3 通道输入,然后基本上通过一系列 3x3 的卷积运行它,然后不时地,我们将它通过一个 2x2 的最大池化,然后我们再做一些 3x3 的卷积,最大池化,依此类推。这就是我们的骨干。

然后我们不再使用自适应平均池化层。经过几次操作后,我们像往常一样得到了一个 7x7x512 的网格(或类似的东西)。所以我们不再进行平均池化,而是做一些不同的事情,即将整个东西展平 - 这样就会输出一个大小为 7x7x512 的非常长的激活向量。然后将其馈送到两个全连接层,每个全连接层有 4096 个激活,并且还有一个具有多少类别的全连接层。所以如果你考虑一下,这里的权重矩阵是巨大的 7x7x512x4096。正是因为这个权重矩阵,VGG 很快就不受欢迎了 - 因为它占用了大量内存,需要大量计算,速度非常慢。这里有很多冗余的东西,因为实际上这 512 个激活并不特定于它们在哪个 7x7 网格单元中。但是当你有这里的整个权重矩阵,包含了每种可能的组合,它会将它们都视为独特的。这也可能导致泛化问题,因为有很多权重等等。

我认为现代网络中使用的方法是进行自适应平均池化(在 Keras 中被称为全局平均池化,在 fast.ai 中我们使用自适应连接池),这将直接输出一个 512 维的激活。我认为这样做丢失了太多的几何信息。所以对我来说,可能正确的答案在两者之间,并且可能涉及某种因子卷积或张量分解,也许我们中的一些人可以在未来几个月考虑一下。所以目前,我们已经从自适应平均池化这个极端转向了另一个极端,即这个巨大的扁平化全连接层。

关于 VGG 有一些有趣的事情,使它至今仍然有用[11:59]。第一件事是这里有更多有趣的层,大多数现代网络包括 ResNet 系列,第一层通常是一个 7x7 的卷积,步幅为 2 或类似的。这意味着我们立即丢弃了一半的网格大小,因此几乎没有机会使用细节,因为我们从不对其进行任何计算。这对于分割或超分辨率模型等需要细节的问题是一个问题。我们实际上想要恢复它。然后第二个问题是自适应池化层完全丢弃了最后几个部分的几何信息,这意味着模型的其余部分实际上没有太多有趣的几何学习。因此,对于依赖位置的事物,任何需要生成模型的定位方法都会不太有效。所以我希望你在我描述这些内容时能听到的一件事是,也许现有的架构都不是理想的。我们可以发明一个新的。实际上,我在这一周尝试了发明一个新的,就是将 VGG 头部连接到 ResNet 骨干上。有趣的是,我发现我实际上得到了一个稍微更好的分类器,比普通的 ResNet 好一点,但它也包含了一些更有用的信息。训练时间长了 5 到 10%,但没有什么值得担心的。也许我们可以在 ResNet 中,用我们之前简要讨论过的方式,将这个(7x7 卷积步幅 2)替换为更像 Inception stem 的东西,这样有更多的计算。我认为这些架构肯定有一些小的调整空间,这样我们可以构建一些可能更多功能的模型。目前,人们倾向于构建只能做一件事的架构。他们并没有真正考虑到机会的丢失,因为这就是出版的工作方式。你发表“我在这一件事上达到了最新水平”而不是你创造了一些在很多方面都很擅长的东西。

出于这些原因,今天我们将使用 VGG,尽管它已经过时并且缺少很多很棒的东西[14:42]。不过,我们要做的一件事是使用一个稍微更现代的版本,这是一个在所有卷积层之后添加了批量归一化的 VGG 版本。在 fast.ai 中,当你请求一个 VGG 网络时,你总是得到批量归一化的版本,因为那基本上总是你想要的。所以这是带有批量归一化的 VGG。有 16 和 19,19 更大更重,但实际上并没有做得更好,所以没有人真的使用它。

arch = vgg16
sz_lr = 72

我们将从 72x72 的 LR(sz_lr:低分辨率大小)输入开始。我们将首先通过 64 的批次大小将其放大 2 倍,以获得 2 * 72,即 144x144 的输出。这将是我们的第一阶段。

scale,bs = 2,64
# scale,bs = 4,32
sz_hr = sz_lr*scale

我们将为此创建自己的数据集,值得查看 fastai.dataset 模块的内部并看看那里有什么[15:45]。因为几乎任何你想要的东西,我们可能都有几乎符合你要求的东西。所以在这种情况下,我想要一个数据集,其中我的x是图像,我的y也是图像。已经有一个文件数据集,我们可以继承其中的x是图像,然后我只需继承自那个,并且我只是复制并粘贴了get_x并将其转换为get_y,这样它就打开了一个图像。现在我有了一个x是图像,y也是图像的东西,在这两种情况下,我们传入的都是文件名数组。

class MatchedFilesDataset(FilesDataset):
    def __init__(self, fnames, y, transform, path):
        self.y=y
        assert(len(fnames)==len(y))
        super().__init__(fnames, transform, path)
    def get_y(self, i): 
        return open_image(os.path.join(self.path, self.y[i]))
    def get_c(self): 
        return 0

我将进行一些数据增强。显然,对于所有的 ImageNet,我们并不真正需要它,但这主要是为了任何使用较小数据集的人能够充分利用它。RandomDihedral指的是每个可能的 90 度旋转加上可选的左/右翻转,因此它们是八个对称的二面角群。通常我们不会对 ImageNet 图片使用这种转换,因为你通常不会把狗颠倒过来,但在这种情况下,我们并不是试图分类它是狗还是猫,我们只是试图保持它的一般结构。因此,实际上对于这个问题来说,每个可能的翻转都是一个相当明智的事情。

aug_tfms = [RandomDihedral(tfm_y=TfmType.PIXEL)]

以通常的方式创建一个验证集。你可以看到我使用了一些更低级别的函数——一般来说,我只是从 fastai 源代码中复制和粘贴它们,找到我想要的部分。这里有一个部分,它接受一个验证集索引数组和一个或多个变量数组,然后简单地分割。在这种情况下,这个(np.array(fnames))分成一个训练和验证集,这个(第二个np.array(fnames))分成一个训练和验证集,给我们我们的xy。在这种情况下,xy是相同的。我们的输入图像和输出图像是相同的。我们将使用转换使它们中的一个分辨率较低。这就是为什么它们是相同的东西。

val_idxs = get_cv_idxs(len(fnames), val_pct=min(0.01/keep_pct, 0.1))
((val_x,trn_x),(val_y,trn_y)) = split_by_idx(
    val_idxs, 
    np.array(fnames), 
    np.array(fnames)
)
len(val_x),len(trn_x)
'''
(12811, 1268356)
'''
img_fn = PATH/'train'/'n01558993'/'n01558993_9684.JPEG'

接下来我们需要像往常一样创建我们的转换。我们将使用tfm_y参数,就像我们为边界框所做的那样,但我们不是使用TfmType.COORD,而是使用TfmType.PIXEL。这告诉我们的转换框架,你的y值是带有正常像素的图像,所以任何你对x做的事情,你也需要对y做同样的事情。你需要确保你使用的任何数据增强转换也具有相同的参数。

tfms = tfms_from_model(
    arch, sz_lr, 
    tfm_y=TfmType.PIXEL, 
    aug_tfms=aug_tfms, 
    sz_y=sz_hr
)
datasets = ImageData.get_ds(
    MatchedFilesDataset, 
    (trn_x,trn_y), 
    (val_x,val_y), 
    tfms, 
    path=PATH_TRN
)
md = ImageData(PATH, datasets, bs, num_workers=16, classes=None)

你可以看到你得到的可能的转换类型:

  • 分类:我们将在今天的下半部分使用分割

  • 坐标:坐标——没有任何转换

  • 像素

一旦我们有了Dataset类和一些xy的训练和验证集。有一个方便的小方法叫做获取数据集(get_ds),它基本上运行构造函数,返回你需要的所有数据集,以恰好正确的格式传递给 ModelData 构造函数(在这种情况下是ImageData构造函数)。所以我们有点回到了 fastai 的内部,从头开始构建。在接下来的几周里,这一切都将被整合和重构成你可以在 fastai 中一步完成的东西。但这个类的目的是为了学习一些关于内部的知识。

我们之前简要看到的是,当我们输入图像时,我们不仅要进行数据增强,还要将通道维度移到开头,我们要减去平均值除以标准差等。所以如果我们想要显示那些从我们的数据集或数据加载器中出来的图片,我们需要对它们进行反归一化。所以模型数据对象(md)的数据集(val_ds)有一个 denorm 函数,知道如何做到这一点。我只是为了方便给它一个简短的名字:

denorm = md.val_ds.denorm

现在我要创建一个函数,可以显示数据集中的图像,如果你传入一个说这是一个归一化图像的东西,那么我们将对它进行反归一化。

def show_img(ims, idx, figsize=(5,5), normed=True, ax=None):
    if ax is None: 
        fig,ax = plt.subplots(figsize=figsize)
    if normed: 
        ims = denorm(ims)
    else:      
        ims = np.rollaxis(to_np(ims),1,4)
    ax.imshow(np.clip(ims,0,1)[idx])
    ax.axis('off')
x,y = next(iter(md.val_dl))
x.size(),y.size()
'''
(torch.Size([32, 3, 72, 72]), torch.Size([32, 3, 288, 288]))
'''

你会看到我们传入了低分辨率大小(sz_lr)作为我们的转换大小,高分辨率大小(sz_hr)作为,这是新的东西,大小 y 参数(sz_y)。所以这两部分将得到不同的大小。

在这里,您可以看到我们的xy的两种不同分辨率,用于一大堆鱼。

idx=1
fig,axes = plt.subplots(1, 2, figsize=(9,5))
show_img(x,idx, ax=axes[0])
show_img(y,idx, ax=axes[1])

像往常一样,使用plt.subplots创建我们的两个图,然后我们可以使用返回的不同轴将东西放在一起。

batches = [next(iter(md.aug_dl)) for i in range(9)]

然后我们可以看一下数据转换的几个不同版本[21:37]。在那里,您可以看到它们被以各种不同方向翻转。

fig, axes = plt.subplots(3, 6, figsize=(18, 9))
for i,(x,y) in enumerate(batches):
    show_img(x,idx, ax=axes.flat[i*2])
    show_img(y,idx, ax=axes.flat[i*2+1])

模型[21:48]

让我们创建我们的模型。我们将有一个小图像输入,并且我们希望有一个大图像输出。因此,我们需要在这两者之间进行一些计算,以计算大图像会是什么样子。基本上有两种方法来进行这种计算:

  • 我们首先可以进行一些上采样,然后进行一些步幅为 1 的层来进行大量计算。

  • 我们可以首先进行大量步幅为 1 的层来进行所有计算,然后最后进行一些上采样。

我们将选择第二种方法,因为我们想在较小的东西上进行大量计算,因为这样做速度更快。此外,在上采样过程中,我们可以利用所有这些计算。上采样,我们知道有几种可能的方法可以做到这一点。我们可以使用:

  • 转置或分数步幅卷积

  • 最近邻上采样,然后是 1x1 卷积

在“进行大量计算”部分,我们可以只进行大量的 3x3 卷积。但在这种特殊情况下,ResNet 块似乎更好,因为输出和输入非常相似。因此,我们真的希望有一个流经路径,允许尽可能少地烦扰,除了必要的最小量来进行我们的超分辨率。如果我们使用 ResNet 块,那么它们已经有一个身份路径。因此,您可以想象那些简单版本,它采用双线性采样方法或其他方法,它可以直接通过身份块,然后在上采样块中,只需学习获取输入的平均值,并得到一些不太糟糕的东西。

这就是我们要做的。我们将创建一个具有五个 ResNet 块的模型,然后对于每个 2 倍的缩放,我们将有一个上采样块。

它们都将由通常的卷积层组成,可能在其中的许多之后带有激活函数[24:37]。我喜欢将我的标准卷积块放入一个函数中,这样我可以更容易地重构它。我不会担心传递填充,并直接计算它作为内核大小的一半。

def conv(ni, nf, kernel_size=3, actn=False):
    layers = [
        nn.Conv2d(ni, nf, kernel_size, padding=kernel_size//2)
    ]
    if actn: 
        layers.append(nn.ReLU(True))
    return nn.Sequential(*layers)

我们小卷积块的一个有趣之处在于没有批量归一化,这对于 ResNet 类型的模型来说是非常不寻常的。

arxiv.org/abs/1707.02921

没有批量归一化的原因是因为我从这篇最近的出色论文中窃取了一些想法,这篇论文实际上赢得了最近的超分辨率性能比赛。要看看这篇论文有多好,SRResNet 是之前的最先进技术,他们在这里所做的是他们已经放大到了一个上采样的网格/围栏。HR 是原始的。您可以看到在以前的最佳方法中,存在大量的失真和模糊。或者,在他们的方法中,几乎完美。因此,这篇论文是一个真正的重大进步。他们称其模型为 EDSR(增强深度超分辨率网络),并且他们与以前的标准方法有两点不同:

  1. 拿起 ResNet 块并丢弃批量归一化。为什么要丢弃批量归一化?原因是因为批量归一化会改变东西,而我们希望有一个不改变东西的良好直通路径。因此,这里的想法是,如果您不想对输入进行更多操作,那么就不要强迫它计算诸如批量归一化参数之类的东西-所以丢弃批量归一化。

  2. 缩放因子(我们很快会看到)。

class ResSequential(nn.Module):
    def __init__(self, layers, res_scale=1.0):
        super().__init__()
        self.res_scale = res_scale
        self.m = nn.Sequential(*layers)

    def forward(self, x): 
        return x + self.m(x) * self.res_scale

所以我们将创建一个包含两个卷积的残差块。正如你在他们的方法中看到的那样,他们甚至在第二个卷积后没有 ReLU。这就是为什么我只在第一个上有激活。

def res_block(nf):
    return ResSequential(
        [conv(nf, nf, actn=True), conv(nf, nf)], 0.1
    )

这里有几个有趣的地方[27:10]。一个是这个想法,即有一种主要的 ResNet 路径(卷积,ReLU,卷积),然后通过将其添加回到身份来将其转换为 ReLU 块——我们经常这样做,以至于我将其提取出来成为一个名为 ResSequential 的小模块。它简单地将您想要放入残差路径的一堆层转换为顺序模型,运行它,然后将其添加回输入。有了这个小模块,我们现在可以通过将其包装在 ResSequential 中,将任何东西,比如卷积激活卷积,转换为一个 ResNet 块。

但这并不是我正在做的全部,因为通常一个 Res 块在它的forward中只有x + self.m(x)。但我还加上了* self.res_scale。什么是res_scaleres_scale是数字 0.1。为什么要有它?我不确定有人完全知道。但简短的答案是,发明批量归一化的那个人最近还发表了一篇论文,他在其中首次展示了在不到一个小时内训练 ImageNet 的能力。他是如何做到的呢?他启动了大量的机器,并让它们并行工作,以创建非常大的批量大小。通常情况下,当你将批量大小增加N倍时,你也会相应地增加N倍的学习率。所以通常情况下,非常大的批量大小训练也意味着非常高的学习率训练。他发现,当使用这些非常大的批量大小,如 8000+甚至高达 32000 时,在训练开始时,他的激活基本上会直接变为无穷大。很多其他人也发现了这一点。我们在 DAWN bench 上参加 CIFAR 和 ImageNet 比赛时也发现了这一点,我们很难充分利用我们试图利用的八个 GPU,因为这些更大批量大小和利用它们的挑战。Christian 发现的一件事是,在 ResNet 块中,如果他将它们乘以小于 1 的某个数字,比如 0.1 或 0.2,这确实有助于在开始时稳定训练。这有点奇怪,因为从数学上讲,它是相同的。因为显然,无论我在这里乘以什么,我只需按相反的数量缩放权重,就可以得到相同的数字。但我们不是在处理抽象的数学——我们在处理真实的优化问题,不同的初始化、学习率和其他因素。所以权重消失到无穷大的问题,我想通常主要是关于计算机在实践中的离散和有限性质的一部分。因此,通常这种小技巧可以起到关键作用。

在这种情况下,我们只是根据我们的初始初始化来调整事物。所以可能还有其他方法可以做到这一点。例如,Nvidia 的一些人提出的一种叫做 LARS 的方法,我上周简要提到过,这是一种实时计算的判别学习率方法。基本上是通过查看梯度和激活之间的比率来按层缩放学习率。因此,他们发现他们不需要这个技巧来大幅增加批量大小。也许只需要不同的初始化就足够了。我提到这一点的原因并不是因为我认为你们中很多人可能想要在大型计算机集群上进行训练,而是因为我认为你们中很多人想要快速训练模型,这意味着使用高学习率,并且理想情况下实现超级收敛。我认为这些技巧是我们需要能够在更多不同的架构等方面实现超级收敛的技巧。除了 Leslie Smith 之外,没有其他人真正致力于超级收敛,现在只有一些 fastai 学生在做这些事情。因此,关于如何以非常非常高的学习率进行训练的问题,我们将不得不自己去解决,因为据我所知,其他人还没有关心这个问题。因此,查看围绕在一个小时内训练 ImageNet 的文献,或者最近有现在在 15 分钟内训练 ImageNet 的文献,我认为,这些论文实际上有一些技巧可以让我们以高学习率训练事物。这就是其中之一。

有趣的是,除了在一个小时内训练 ImageNet 的论文中提到过之外,我唯一看到这个提到的地方是在这篇 EDSR 论文中。这真的很酷,因为赢得比赛的人,我发现他们非常务实和博学。他们实际上必须让事情运转起来。因此,这篇论文描述了一种方法,实际上比任何其他方法都要好,他们做了这些务实的事情,比如放弃批量归一化,使用几乎没有人知道的这个小缩放因子。所以这就是 0.1 的来源。

def upsample(ni, nf, scale):
    layers = []
    for i in range(int(math.log(scale,2))):
        layers += [conv(ni, nf*4), nn.PixelShuffle(2)]
    return nn.Sequential(*layers)

因此,我们的超分辨率 ResNet(SrResnet)将进行卷积,从我们的三个通道到 64 个通道,只是为了稍微丰富一下空间。然后我们实际上有 8 个而不是 5 个 Res 块。请记住,每个 Res 块的步幅都是 1,因此网格大小不会改变,滤波器的数量也不会改变。一直都是 64。我们将再做一次卷积,然后根据我们要求的比例进行上采样。然后我添加了一个批量归一化,因为感觉可能有帮助,只是为了缩放最后一层。最后再进行卷积,回到我们想要的三个通道。因此,你可以看到这里有大量的计算,然后稍微进行一些上采样,就像我们描述的那样。

class SrResnet(nn.Module):
    def __init__(self, nf, scale):
        super().__init__()
        features = [conv(3, 64)]
        for i in range(8): 
            features.append(res_block(64))
        features += [
            conv(64,64), 
            upsample(64, 64, scale),
            nn.BatchNorm2d(64),
            conv(64, 3)
        ]
        self.features = nn.Sequential(*features)

    def forward(self, x): 
        return self.features(x)

只是提一下,就像我现在倾向于做的那样,整个过程是通过创建一个带有层的列表,然后在最后将其转换为一个顺序模型,因此我的前向函数尽可能简单。

这是我们的上采样,上采样有点有趣,因为它既不是转置卷积也不是分数步长卷积,也不是最近邻上采样后跟着 1x1 卷积。所以让我们稍微谈谈上采样。

这是来自论文《用于实时风格转移和超分辨率的感知损失》的图片。所以他们说“嘿,我们的方法好得多”,但看看他们的方法。里面有一些瑕疵。这些瑕疵到处都是,不是吗。其中一个原因是他们使用了转置卷积,我们都知道不要使用转置卷积。

这里是转置卷积[35:39]。这是来自这篇出色的卷积算术论文,也在 Theano 文档中展示过。如果我们从(蓝色是原始图像)3x3 图像升级到 5x5 图像(如果我们添加了一层填充则为 6x6),那么转置卷积所做的就是使用常规的 3x3 卷积,但它在每对像素之间插入白色零像素。这使得输入图像变大,当我们在其上运行这个卷积时,因此会给我们一个更大的输出。但这显然很愚蠢,因为当我们到达这里时,例如,从九个像素中进入的八个是零。所以我们只是浪费了大量的计算。另一方面,如果我们稍微偏离,那么我们九个中有四个是非零的。但是,我们只有一个滤波器/核来使用,所以它不能根据进入的零的数量而改变。所以它必须适用于两者,这是不可能的,所以我们最终得到这些伪像。

deeplearning.net/software/theano/tutorial/conv_arithmetic.html

我们学到的一种方法是不要在这里放白色的东西,而是将像素的值复制到这三个位置中的每一个[36:53]。所以这是最近邻上采样。这当然好一点,但仍然相当糟糕,因为现在当我们到达这九个(如上所示)时,其中有 4 个是完全相同的数字。当我们移动一个时,现在我们有了完全不同的情况。所以取决于我们在哪里,特别是,如果我们在这里,重复会少得多:

所以再次,我们有这样一个问题,即存在浪费的计算和数据中的太多结构,这将再次导致伪像。因此,上采样比转置卷积更好——最好复制它们而不是用零替换它们。但这仍然不够好。

因此,我们将进行像素洗牌[37:56]。像素洗牌是这个次像素卷积神经网络中的一个操作,有点令人费解,但却很迷人。

使用高效的次像素卷积神经网络进行实时单图像和视频超分辨率

我们从输入开始,经过一些卷积一段时间,直到最终到达第n[i-1]层,其中有 n[i-1]个特征图。我们将进行另一个 3x3 卷积,我们的目标是从一个 7x7 的网格单元(我们将进行一个 3x3 的放大),所以我们将扩展到一个 21x21 的网格单元。那么我们还有另一种方法可以做到这一点吗?为了简化,让我们只选择一个面/层-所以让我们取最顶部的滤波器,只对其进行卷积,看看会发生什么。我们要做的是使用一个卷积,其中卷积核大小(滤波器数量)比我们需要的大九倍(严格来说)。所以如果我们需要 64 个滤波器,实际上我们要做的是 64 乘以 9 个滤波器。为什么?这里,r 是比例因子,所以 3²是 9,这里有九个滤波器来覆盖这些输入层/切片中的一个。但我们可以做的是,我们从 7x7 开始,然后将其转换为 7x7x9。我们想要的输出等于 7 乘以 3 乘以 7 乘以 3。换句话说,这里的像素/激活数量与上一步的激活数量相同。所以我们可以重新洗牌这些 7x7x9 的激活,以创建这个 7x3 乘以 7x3 的地图。所以我们要做的是,我们要取这里的一个小管道(所有网格的左上角),我们要把紫色的放在左上角,然后把蓝色的放在右边,淡蓝色的放在右边,稍微深一点的放在最左边的中间,绿色的放在中间,依此类推。所以这些九个单元中的每一个在左上角,它们最终会出现在我们网格的小 3x3 部分中。然后我们要取(2,1)并将所有这 9 个移动到网格的这个 3x3 部分,依此类推。所以我们最终会在 7x3 乘以 7x3 的图像中有每一个这些 7x7x9 的激活。

所以首先要意识到的是,当然这在某种定义下是有效的,因为我们这里有一个可学习的卷积,它将得到一些梯度,这些梯度将尽力填充正确的激活,使得输出是我们想要的东西。所以第一步是意识到这里没有什么特别神奇的地方。我们可以创建任何我们喜欢的架构。我们可以随意移动事物,我们想要的方式,我们的卷积中的权重将尽力做到我们要求的一切。真正的问题是——这是一个好主意吗?这是一个更容易做的事情,也是一个更灵活的事情,比转置卷积或上采样后再进行一对一卷积更好吗?简短的答案是是的,原因很简单,因为这里的卷积发生在低分辨率的 7x7 空间中,这是相当高效的。否则,如果我们首先进行上采样,然后再进行卷积,那么我们的卷积将发生在 21x21 的空间中,这是很多计算。此外,正如我们讨论过的,最近邻上采样版本中存在很多复制和冗余。实际上,他们在这篇论文中展示了这一点,事实上,我认为他们有一个后续的技术说明,其中提供了更多关于正在进行的工作的数学细节,并展示了这种方式确实更有效。所以这就是我们要做的。对于我们的上采样,我们有两个步骤:

  1. 3x3 卷积,比我们最初想要的通道数多r²倍

  2. 然后是一个像素洗牌操作,将每个网格单元中的所有内容移动到遍布其中的小r乘以r的网格中。

所以这就是:

这只是一行代码。这是一个卷积,输入数量到输出数量乘以四,因为我们正在进行一个比例为 2 的上采样(2²=4)。这是我们的卷积,然后这里是我们的像素洗牌,它内置在 PyTorch 中。像素洗牌是将每个东西移动到正确位置的东西。因此,这将通过一个比例因子为 2 进行上采样。所以我们需要做对数以 2 为底的比例次数。如果比例是四,那么我们将做两次,以便两次两次。这就是这里的上采样所做的事情。

棋盘格模式[44:19]

太好了。猜猜看。这并没有消除棋盘格模式。我们仍然有棋盘格模式。所以我相信在极度愤怒和沮丧的情况下,来自 Twitter 团队的同一团队,我认为这是在他们被 Twitter 收购之前的一个创业公司叫做魔术小马,他们再次回来,发表了另一篇论文,说好吧,这次我们消除了棋盘格。

arxiv.org/abs/1707.02937

为什么我们仍然有棋盘格?即使在这样做之后,我们仍然有棋盘格的原因是,当我们在开始时随机初始化这个卷积核时,这意味着这里这个小的 3x3 网格中的每个 9 个像素将会完全随机不同。但接下来的 3 个像素集将彼此随机不同,但将与前一个 3x3 部分中的相应像素非常相似。所以我们将一直有重复的 3x3 东西。然后当我们尝试学习更好的东西时,它是从这个重复的 3x3 起点开始的,这不是我们想要的。实际上,我们想要的是这些 3x3 像素最初是相同的。为了使这些 3x3 像素相同,我们需要使每个滤波器的这 9 个通道在这里相同。因此,这篇论文中的解决方案非常简单。就是当我们在开始时初始化这个卷积时,我们不是完全随机初始化它。我们随机初始化r²组通道中的一个,然后将其复制到其他r²中,使它们都相同。这样,最初,这些 3x3 将是相同的。这就是所谓的 ICNR,这就是我们马上要使用的。

像素损失[46:41]

在我们开始之前,让我们快速看一下。所以我们有这个超分辨率的 ResNet,它只是用很多 ResNet 块进行大量计算,然后进行一些上采样,得到我们最终的三个通道输出。

然后为了让生活更快,我们将并行运行这些东西。我们想要并行运行的一个原因是因为 Gerardo 告诉我们他有 6 个 GPU,这就是他的电脑现在的样子。

所以我相信任何拥有多个 GPU 的人以前都有过这种经历。那么我们如何让这些设备一起工作呢?你所需要做的就是将你的 PyTorch 模块包装在nn.DataParallel中。一旦你这样做了,它会将它复制到每个 GPU,并自动并行运行。它在两个 GPU 上表现得相当好,三个 GPU 还可以,四个 GPU 及以上,性能就会下降。默认情况下,它会将其复制到所有 GPU 上 - 你可以添加一个 GPU 数组,否则如果你想避免麻烦,例如,我必须与 Yannet 共享我们的盒子,如果我没有把这个放在这里,那么她现在会对我大喊大叫或抵制我的课程。这就是你如何避免与 Yannet 发生麻烦。

m = to_gpu(SrResnet(64, scale))
m = nn.DataParallel(m, [0,2])
learn = Learner(md, SingleModel(m), opt_fn=optim.Adam)
learn.crit = F.mse_loss

这里需要注意的一件事是,一旦你这样做了,它实际上会修改你的模块[48:21]。所以如果你现在打印出你的模块,比如以前它只是一个无限的顺序,现在你会发现它是一个嵌入在一个名为Module的模块内部的nn.Sequential。换句话说,如果你保存了一个nn.DataParallel的东西,然后尝试将其加载到一个没有nn.DataParallel的东西中,它会说它不匹配,因为其中一个嵌入在这个 Module 属性内部,而另一个没有。甚至可能取决于你将其复制到的 GPU ID。两种可能的解决方案:

  1. 不要保存模块m,而是保存模块属性m.module,因为那实际上是非数据并行位。

  2. 始终将其放在相同的 GPU ID 上,然后使用数据并行,并每次加载和保存。这就是我使用的方法。

这对我来说很容易在 fast.ai 中自动修复,我很快就会做到,这样它就会自动查找那个模块属性并自动处理。但是现在,我们必须手动操作。了解背后发生的事情可能很有用。

所以我们有了我们的模块[49:46]。我发现如果你在 1080Ti 上运行,它会比较快 50%或 60%,如果你在 volta 上运行,它实际上会并行化得更好。有更快的并行化方式,但这是一个超级简单的方式。

我们以通常的方式创建我们的学习器。我们可以在这里使用 MSE 损失,这样就可以比较输出的像素与我们期望的像素。我们可以运行我们的学习率查找器,然后训练一段时间。

learn.lr_find(start_lr=1e-5, end_lr=10000)
learn.sched.plot()
'''
31%|███▏      | 225/720 [00:24<00:53,  9.19it/s, loss=0.0482]
'''

lr=2e-3
learn.fit(lr, 1, cycle_len=1, use_clr_beta=(40,10))
'''
2%|▏         | 15/720 [00:02<01:52,  6.25it/s, loss=0.042]  
epoch      trn_loss   val_loss                                 
    0      0.007431   0.008192
[array([0.00819])]
'''
x,y = next(iter(md.val_dl))
preds = learn.model(VV(x))

这是我们的输入:

idx=4
show_img(y,idx,normed=False)

这是我们的输出。

show_img(preds,idx,normed=False);

你可以看到我们已经成功训练了一个非常先进的残差卷积网络,学会了将事物变蓝。为什么呢?因为这是我们要求的。我们说要最小化 MSE 损失。像素之间的 MSE 损失真的最好的方法就是对像素求平均,即模糊化。所以像素损失不好。所以我们要使用我们的感知损失。

show_img(x,idx,normed=True);

x,y = next(iter(md.val_dl))
preds = learn.model(VV(x))
show_img(y,idx,normed=False)

show_img(preds,idx,normed=False);

show_img(x,idx);

感知损失[50:57]

使用感知损失,我们基本上要拿出我们的 VGG 网络,就像我们上周做的那样,找到在我们得到最大池之前的块索引。

def icnr(x, scale=2, init=nn.init.kaiming_normal):
    new_shape = [int(x.shape[0] / (scale ** 2))] + list(x.shape[1:])
    subkernel = torch.zeros(new_shape)
    subkernel = init(subkernel)
    subkernel = subkernel.transpose(0, 1)
    subkernel = subkernel.contiguous().view(
        subkernel.shape[0],
        subkernel.shape[1], 
        -1
    )
    kernel = subkernel.repeat(1, 1, scale ** 2)
    transposed_shape = [x.shape[1]] + \
                       [x.shape[0]] + 
                       list(x.shape[2:])
    kernel = kernel.contiguous().view(transposed_shape)
    kernel = kernel.transpose(0, 1)
    return kernelm_vgg = vgg16(True)

blocks = [
    i-1 for i,o in enumerate(children(m_vgg))
    if isinstance(o,nn.MaxPool2d)
]
blocks, [m_vgg[i] for i in blocks]
'''
([5, 12, 22, 32, 42],
 [ReLU(inplace), ReLU(inplace), ReLU(inplace), ReLU(inplace), ReLU(inplace)])
'''

这是每个相同网格大小块的末尾。如果我们将它们打印出来,正如我们所期望的那样,每一个都是一个 ReLU 模块,所以在这种情况下,这最后两个块对我们来说不太有趣。那里的网格大小足够小,当然足够小,对于超分辨率来说并不那么有用。所以我们只会使用前三个。为了节省不必要的计算,我们只会使用 VGG 的前 23 层,然后丢弃其余的。我们会把它放在 GPU 上。我们不会训练这个 VGG 模型——我们只是用它来比较激活。所以我们会将其设置为评估模式,并设置为不可训练。

vgg_layers = children(m_vgg)[:23]
m_vgg = nn.Sequential(*vgg_layers).cuda().eval()
set_trainable(m_vgg, False)
def flatten(x): 
    return x.view(x.size(0), -1)

就像上周一样,我们将使用SaveFeatures类来做一个前向钩子,保存每个层的输出激活[52:07]。

class SaveFeatures():
    features=None
    def __init__(self, m): 
        self.hook = m.register_forward_hook(self.hook_fn)
    def hook_fn(self, module, input, output): 
        self.features = output
    def remove(self): 
        self.hook.remove()

现在我们已经有了创建我们的感知损失或者我在这里称之为FeatureLoss类所需的一切。我们将传入一个层 ID 列表,我们希望计算内容损失的层,以及每个层的权重列表。我们可以遍历每个层 ID 并创建一个具有前向钩子函数来存储激活的对象。所以在我们的前向传播中,我们可以直接调用模型的前向传播,使用目标(我们试图创建的高分辨率图像)。我们这样做的原因是因为这将调用那个钩子函数并将我们想要的激活存储在self.sfs(self 点保存特征)中。现在我们还需要对我们的卷积网络输出进行相同的操作。所以我们需要克隆这些,否则卷积网络输出将继续覆盖我已经有的内容。所以现在我们可以对卷积网络输出执行相同的操作,这是损失函数的输入。所以现在我们有了这两个东西,我们可以将它们与权重一起压缩在一起,所以我们有了输入、目标和权重。然后我们可以计算输入和目标之间的 L1 损失,并乘以层权重。我还做的另一件事是我也获取了像素损失,但我将其权重降低了很多。大多数人不这样做。我没有看到有论文这样做,但在我看来,这可能更好一点,因为你有感知内容损失激活的东西,但在最细微的层面上,它也关心个别像素。所以这就是我们的损失函数。

class FeatureLoss(nn.Module):
    def __init__(self, m, layer_ids, layer_wgts):
        super().__init__()
        self.m,self.wgts = m,layer_wgts
        self.sfs = [SaveFeatures(m[i]) for i in layer_ids]

    def forward(self, input, target, sum_layers=True):
        self.m(VV(target.data))
        res = [F.l1_loss(input,target)/100]
        targ_feat = [V(o.features.data.clone()) for o in self.sfs]
        self.m(input)
        res += [
            F.l1_loss(flatten(inp.features),flatten(targ))*wgt
            for inp,targ,wgt in zip(self.sfs, targ_feat, self.wgts)
        ]
        if sum_layers: res = sum(res)
        return res

    def close(self):
        for o in self.sfs: o.remove()

我们创建我们的超分辨率 ResNet,告诉它要放大多少倍。

m = SrResnet(64, scale)

然后我们将对像素混洗卷积进行icnr初始化[54:27]。这是非常无聊的代码,实际上我是从别人那里抄的。它实际上只是说,好吧,你有一些权重张量x,你想要初始化,所以我们将把它视为具有形状(即特征数量)除以比例平方特征的实际特征。所以这可能是 2² = 4,因为我们实际上只想保留一组然后将它们复制四次,所以我们除以四并创建一个相同大小的东西,我们用默认的kaiming_normal初始化它。然后我们只需复制它的 scale²份。其余部分只是稍微移动一下轴。所以这将返回一个新的权重矩阵,其中每个初始化的子核被重复 r²或scale²次。所以细节并不重要。这里重要的是我只是查找了一下,在像素混洗之前实际的卷积层,并将其存储起来,然后我调用icnr来获得我的新权重矩阵。然后我将这个新的权重矩阵复制回那一层。

conv_shuffle = m.features[10][0][0]
kernel = icnr(conv_shuffle.weight, scale=scale)
conv_shuffle.weight.data.copy_(kernel);

正如你所看到的,我在这个练习中费了很大的劲,真的尽力去实现所有最佳实践[56:13]。我倾向于做事情有点极端。我向你展示了一个只能勉强工作的非常粗糙的版本,或者我会尽最大努力让它真正运行良好。所以这个版本是我声称这几乎是一个最先进的实现。这是一个获奖的竞赛,或者至少是我重新实现的一个获奖方法。我这样做的原因是因为我认为这是那些实际上把很多细节做对的罕见论文之一,我希望你能感受到把所有细节做对的感觉。记住,把细节做对是区分丑陋模糊混乱和漂亮精致结果之间的区别。

m = to_gpu(m)
learn = Learner(md, SingleModel(m), opt_fn=optim.Adam)
t = torch.load(
    learn.get_model_path('sr-samp0'), 
    map_location=lambda storage, loc: storage
)
learn.model.load_state_dict(t, strict=False)
learn.freeze_to(999)
for i in range(10,13): 
    set_trainable(m.features[i], True)
conv_shuffle = m.features[10][2][0]
kernel = icnr(conv_shuffle.weight, scale=scale)
conv_shuffle.weight.data.copy_(kernel);

所以我们再次对其进行 DataParallel[57:14]。

m = nn.DataParallel(m, [0,2])
learn = Learner(md, SingleModel(m), opt_fn=optim.Adam)
learn.set_data(md)

我们将把我们的标准设置为使用我们的 VGG 模型的 FeatureLoss,获取前几个块,这些是我发现效果非常好的一组层权重。

learn.crit = FeatureLoss(m_vgg, blocks[:3], [0.2,0.7,0.1])
lr=6e-3
wd=1e-7

进行学习率查找。

learn.lr_find(1e-4, 0.1, wds=wd, linear=True) 
'''
1%|          | 15/1801 [00:06<12:55,  2.30it/s, loss=0.0965]
12%|█▏        | 220/1801 [01:16<09:08,  2.88it/s, loss=0.42]
'''
learn.sched.plot(n_skip_end=1)

适应一段时间

learn.fit(lr, 1, cycle_len=2, wds=wd, use_clr=(20,10))
'''
epoch      trn_loss   val_loss                                  
    0      0.04523    0.042932  
    1      0.043574   0.041242
[array([0.04124])]
'''
learn.save('sr-samp0')
learn.save('sr-samp1')

我花了一段时间来尝试弄清楚一些细节。但这里是我最喜欢的论文部分,接下来会发生什么。现在我们已经为尺度等于 2 做好了准备——渐进式调整大小。渐进式调整大小是让我们在 DAWN 基准上对 ImageNet 训练获得最佳单台计算机结果的技巧。这个想法是从小开始逐渐变大。我只知道有两篇论文使用了这个想法。一篇是 GANs 渐进式调整大小的论文,允许训练非常高分辨率的 GANs,另一篇是 EDSR 论文。渐进式调整大小的酷之处不仅在于,假设你的前几个时期是 2x2 更小,速度快了四倍。你也可以让批量大小可能增加 3 或 4 倍。但更重要的是,它们将更好地泛化,因为在训练过程中你会向模型输入不同尺寸的图像。因此,我们能够为 ImageNet 训练使用一半的时代,比大多数人快。我们的时代更快,而且数量更少。因此,渐进式调整大小是一种特别适合从头开始训练的东西(我不确定它是否对微调迁移学习有用,但如果你是从头开始训练),你可能几乎想一直这样做。

渐进式调整大小

接下来的步骤是回到顶部,将尺度改为 4,批量大小为 32,重新启动。在这样做之前,我保存了模型。

回去,这就是为什么在这里重新加载时会有一点混乱,因为现在我需要做的是重新加载我的保存模型。

但有一个小问题,就是现在我有一个比以前多的上采样层,从 2x2 到 4x4。我的循环现在循环两次,而不是一次。因此,它添加了一个额外的卷积网络和一个额外的像素混洗。那么我要如何为不同的网络加载权重呢?

答案是我在 PyTorch 中使用一个非常方便的东西load_state_dict。这就是lean.load在幕后调用的内容。如果我传递这个参数strict=False,那么它会说“好吧,如果你不能填充所有的层,就填充你能填充的层。”因此,在这种方式下加载模型后,我们将得到一个加载了所有可能层的模型,而那个新的卷积层将被随机初始化。

然后我冻结所有的层,然后解冻那个上采样部分。然后在我新添加的额外层上使用icnr。然后我可以继续学习。所以接下来的步骤是一样的。

如果你试图复制这个过程,不要只是从头到尾运行。要意识到这需要有一些跳跃。

learn.load('sr-samp1')
lr=3e-3
learn.fit(lr, 1, cycle_len=1, wds=wd, use_clr=(20,10))
'''
epoch      trn_loss   val_loss                                
    0      0.069054   0.06638
[array([0.06638])]
'''
learn.save('sr-samp2')
learn.unfreeze()
learn.load('sr-samp2')
learn.fit(lr/3, 1, cycle_len=1, wds=wd, use_clr=(20,10))
'''
epoch      trn_loss   val_loss           
    0      0.06042    0.057613
[array([0.05761])]
'''
learn.save('sr1')
learn.sched.plot_loss()

def plot_ds_img(idx, ax=None, figsize=(7,7), normed=True):
    if ax is None: 
        fig,ax = plt.subplots(figsize=figsize)
    im = md.val_ds[idx][0]
    if normed: 
        im = denorm(im)[0]
    else:      
        im = np.rollaxis(to_np(im),0,3)
    ax.imshow(im)
    ax.axis('off')
fig,axes=plt.subplots(6,6,figsize=(20,20))
for i,ax in enumerate(axes.flat): 
    plot_ds_img(i+200,ax=ax, normed=True)

x,y=md.val_ds[215]
y=y[None]
learn.model.eval()
preds = learn.model(VV(x[None]))
x.shape,y.shape,preds.shape
'''
((3, 72, 72), (1, 3, 288, 288), torch.Size([1, 3, 288, 288]))
'''
learn.crit(preds, V(y), sum_layers=False)
'''
[Variable containing:
 1.00000e-03 
   1.1935
 [torch.cuda.FloatTensor of size 1 (GPU 0)], 
 Variable containing:
 1.00000e-03 
   8.5054
 [torch.cuda.FloatTensor of size 1 (GPU 0)], 
 Variable containing:
 1.00000e-02 
   3.4656
 [torch.cuda.FloatTensor of size 1 (GPU 0)], 
 Variable containing:
 1.00000e-03 
   3.8243
 [torch.cuda.FloatTensor of size 1 (GPU 0)]]
'''
learn.crit.close()

训练时间越长,效果就越好。我最终训练了大约 10 个小时,但如果你不那么耐心,仍然可以更快地获得非常好的结果。所以我们可以试一试,这里是结果。左边是我的像素化鸟,右边是放大版本。它实际上发明了着色。但它弄清楚了这是什么鸟,知道这些羽毛应该是什么样子的。因此,它想象出了一组与这些确切像素兼容的羽毛,这是天才。同样适用于头部后面。你无法告诉这些蓝点代表什么。但如果你知道这种鸟在这里有一排羽毛,你就知道它们必须是这样的。然后你可以推断出羽毛必须是这样的,以至于当它们被像素化时它们会出现在这些位置。因此,它根据对这种确切鸟类的了解,逆向工程出了它必须看起来像这样才能创建这个输出。这太神奇了。它还知道周围所有的迹象表明这里(背景)几乎肯定被模糊处理了。因此,它实际上重建了模糊的植被。如果它没有做所有这些事情,它就不会得到如此好的损失函数。因为最终,它必须匹配激活,说“哦,这里有一根羽毛,看起来有点蓬松,朝这个方向”,等等。

_,axes=plt.subplots(1,2,figsize=(14,7))
show_img(x[None], 0, ax=axes[0])
show_img(preds,0, normed=True, ax=axes[1])

好了,这就是超分辨率的结束。别忘了查看向 Jeremy 提问任何问题的帖子。

向 Jeremy 提问

问题:fast.ai 和这门课程的未来计划是什么?会有第 3 部分吗?如果有第 3 部分,我真的很想参加。

Jeremy:我不太确定。猜测总是很困难的。我希望会有某种后续。去年,在第 2 部分之后,有一名学生发起了一个每周读书俱乐部,通过 Ian Goodfellow 的深度学习书籍,Ian 实际上进来并介绍了很多章节,还有一个专家,每章节都有人介绍。那是一个非常酷的第 3 部分。在很大程度上,这将取决于你们社区,提出想法并帮助实现它们,我肯定愿意帮助。我有很多想法,但我对说出来感到紧张,因为我不确定哪些会发生,哪些不会。但如果你们支持我,让你们想要发生的事情发生,那么它们发生的可能性就更大。

问题:你创业的经历是怎样的?你一直是创业者吗,还是从大公司开始,然后转向创业公司?你是从学术界转向创业公司,还是从创业公司转向学术界的?

Jeremy:不,我绝对不是学术界的。我完全是一个假学者。我 18 岁时在麦肯锡公司开始工作,那是一家战略公司,这意味着我不能真正去大学,所以我也没有去。然后在商界度过了 8 年,帮助一些大公司解决战略问题。我一直想成为一名企业家,计划只在麦肯锡待两年,我生命中唯一后悔的事情就是没有坚持那个计划,而是浪费了八年。所以两年本来就够了。然后我进入了创业领域,在澳大利亚创办了两家公司。最好的部分是我没有得到任何资金支持,所以我赚的钱都是我的,决策也是我和我的合作伙伴的。我完全专注于利润、产品、客户和服务。而我发现在旧金山,我很高兴来到这里,我和安东尼一起来到这里为 Kaggle 工作,为这家全新的公司筹集了 1100 万美元的资金。这真的很有趣,但也很分散注意力,要担心扩张和风险投资者想看到你的业务发展计划,而且根本没有真正需要实现利润。所以在 Enlitic,我又遇到了同样的问题,我很快又筹集了 1500 万美元,分散了很多注意力。我认为尝试自己创业,专注于通过销售盈利并将利润再投入公司,效果非常好。因为在五年内,我们从第三个月开始盈利,五年内,我们的利润足够不仅支付我们所有人的工资,还能看到我的银行账户在增长,十年后以一大笔钱出售,虽然不足以让风险投资者兴奋,但足以让我不再为钱担心。所以我认为自己创业是一个好主意,至少在旧金山的人似乎不太欣赏这个主意。

问题:如果你今天 25 岁,仍然知道你所知道的,你会在哪里寻找使用人工智能的机会?你现在正在做什么,或者在接下来的两年里打算做什么?

Jeremy:你应该忽略那个问题的最后部分。我甚至不会回答它。我在哪里寻找并不重要。你应该利用你对领域的知识。我们这样做的主要原因之一是为了让那些在招聘、油田调查、新闻业、活动主义等领域有背景的人解决问题。对你来说,真正的问题会很明显,你拥有的数据在哪里找也会很明显。对其他人来说,这些都是非常困难的部分。所以那些开始时说“哦,我现在懂深度学习了,我会找一些东西来应用它”的人基本上从来没有成功,而那些像“哦,我已经花了 25 年专门为法律公司招聘,我知道关键问题是什么,我知道这个数据完全解决了它,所以我现在就去做,我已经知道该打电话给谁或者开始销售了”的人往往会成功。如果你除了学术研究什么都没做过,那可能更多是关于你的爱好和兴趣。每个人都有爱好。我想说的主要是,请不要专注于为数据科学家或软件工程师构建工具,因为每个数据科学家都了解数据科学家的市场,而只有你了解分析油田调查世界或理解听力学研究等你所做的市场。

问题:鉴于您向我们展示了如何将迁移学习从图像识别应用到 NLP,看起来值得关注整个机器学习领域发生的所有发展,如果您专注于某一领域,可能会错过其他领域的一些重大进展。在深入研究您特定领域的同时,如何保持对整个领域的所有进展的了解?

Jeremy:是的,这太棒了。我是说这门课程的关键信息之一。在不同地方做了很多好工作,人们都很专业,大多数人都不知道。如果我在开始研究 NLP 六个月后就能获得最先进的结果,我认为这更多地反映了 NLP 而不是我。这有点像创业的事情。你选择你了解的领域,然后转移类似“哦,我们可以使用深度学习来解决这个问题”或者在这种情况下,我们可以使用计算机视觉的这个想法来解决那个问题。所以像迁移学习这样的东西,我敢肯定在其他领域有成千上万的机会让你像 Sebastian 和我在 NLP 中做 NLP 分类那样做。所以回答你的问题的简短答案是保持对正在发生的事情的了解的方法是关注我的 Twitter 收藏夹,我的方法是在 Twitter 上关注很多人,然后将他们放入你的 Twitter 收藏夹。每当我遇到有趣的东西时,我都会点击收藏。我这样做的原因有两个。第一个是当下一门课程出现时,我会浏览我的收藏夹,找出我想学习的东西。第二个是为了让你也可以做同样的事情。然后你深入研究的东西几乎无关紧要。我发现每次我看某件事情时,它都会变得非常有趣和重要。所以选择一些你觉得解决那个问题会真正有用的东西,而且似乎并不很受欢迎,这与其他人的做法恰恰相反。其他人都在解决已经受欢迎的问题,因为它们似乎很受欢迎。我无法完全理解这种思维方式,但它似乎非常普遍。

问题:在表格数据上使用深度学习是否过度?什么时候最好在表格数据上使用 DL 而不是 ML?

Jeremy:这是一个真正的问题,还是你只是放在那里让我指出 Rachel Thomas 刚写了一篇文章?www.fast.ai/2018/04/29/categorical-embeddings/

所以 Rachel 刚刚写了这篇文章,Rachel 和我花了很长时间讨论这个问题,简短的答案是我们认为在表格数据上使用深度学习是很棒的。实际上,在 Rachel 的 Twitter 流中出现的所有丰富复杂重要和有趣的事情中,从罗兴亚种族灭绝到 AI 公司最新的伦理违规行为,迄今为止引起社区最多关注和参与的是有关表格数据或结构化数据的问题。所以是的,问计算机人如何命名事物,你会得到很多兴趣。这里有一些来自 Instacart 和 Pinterest 以及其他一些在这一领域做出了一些出色工作的人的链接。如果你们中有人参加了数据研究所的会议,就会看到 Jeremy Stanley 在 Instacart 做的非常酷的工作的演示。

Rachel:我在撰写这篇文章时主要依赖于第 1 部分的第 3 和第 4 课,因此其中的许多内容可能对您来说很熟悉。

Jeremy: Rachel 在后面问我如何判断是否应该使用决策树集成,如 GBM 或随机森林,还是神经网络,我的答案是我仍然不知道。据我所知,没有人以任何特别有意义的方式进行过这方面的研究。所以这里有一个需要回答的问题,我想。我的方法是尽可能通过 fast.ai 库使这两种方法都尽可能易于使用,这样你就可以尝试它们并看看哪种方法有效。这就是我做的。

问题: 强化学习在最近逐渐受到关注。你对强化学习有什么看法?fast.ai 是否考虑在未来涵盖一些流行的强化学习技术?

Jeremy: 我仍然不相信强化学习。我认为解决这个问题是一个有趣的问题,但我们并没有一个很好的解决这个问题的方法。问题实际上是延迟奖励问题。所以我想学会玩乒乓球,我向上或向下移动,三分钟后我才知道我是否赢得了乒乓球比赛——我采取的哪些行动实际上是有用的?对我来说,计算输出相对于这些输入的梯度,奖励是如此延迟,以至于这些导数似乎并不那么有趣。到目前为止,在我所教授的四门课程中,我经常被问到这个问题。我总是说同样的话。我很高兴最近终于有一些结果表明,实际上基本上随机搜索往往比强化学习做得更好,所以基本上发生的情况是,拥有大量计算能力的资金充裕的公司将所有资源投入到强化学习问题中,并取得了良好的结果,然后人们就会说“这是因为强化学习”,而不是因为大量的计算资源。或者他们使用非常周到和聪明的算法,比如卷积神经网络和蒙特卡洛树搜索的组合,就像他们在 Alpha Go 项目中所做的那样取得了很好的结果,人们错误地说“这是因为强化学习”,而实际上根本不是强化学习。所以我对解决这些更通用的优化问题非常感兴趣,而不仅仅是预测问题,这些延迟奖励问题看起来就是这样。但我认为我们还没有得到足够好的最佳实践,我没有任何准备好教授的东西,也没有说我必须教你这个东西,因为我认为明年它仍然会有用。所以我们将继续观察并看看会发生什么。

超分辨率网络转换为风格转移网络[1:17:57]

我们现在要把超分辨率网络转换为风格转移网络。我们会很快地完成这个过程。我们基本上已经有了一些东西。x是我的输入图像,我将有一些损失函数和一些神经网络。这次我们的输入和输出大小是一样的,所以我们要先做一些下采样。然后是计算,最后是上采样。这是我们要做的第一个改变——我们要在网络的前面添加一些下采样,也就是一些步幅为 2 的卷积层。第二个改变是,不再只是比较ycx是否相同。我们基本上要说我们的输入图像应该在最后看起来像它自己。具体来说,我们将通过 VGG 将其传递并在其中一个激活层进行比较。然后它的风格应该看起来像一幅画,我们将像我们用 Gatys 的方法那样通过查看多个层的 Gram 矩阵对应来实现。基本上就是这样。这应该非常简单明了。这实际上是将我们已经做过的两件事结合在一起。

风格转移网络

笔记本

所以所有这些代码都是相同的,除了我们没有高分辨率和低分辨率,我们只有一个尺寸为 256。

%matplotlib inline
%reload_ext autoreload
%autoreload 2
from fastai.conv_learner import *
from pathlib import Path
torch.cuda.set_device(0)
torch.backends.cudnn.benchmark=True
PATH = Path('data/imagenet')
PATH_TRN = PATH/'train'
fnames_full,label_arr_full,all_labels = folder_source(PATH, 'train')
fnames_full = ['/'.join(Path(fn).parts[-2:]) for fn in fnames_full]
list(zip(fnames_full[:5],label_arr_full[:5]))
'''
[('n01440764/n01440764_9627.JPEG', 0),
 ('n01440764/n01440764_9609.JPEG', 0),
 ('n01440764/n01440764_5176.JPEG', 0),
 ('n01440764/n01440764_6936.JPEG', 0),
 ('n01440764/n01440764_4005.JPEG', 0)]
'''
all_labels[:5]
'''
['n01440764', 'n01443537', 'n01484850', 'n01491361', 'n01494475']
'''
np.random.seed(42)
# keep_pct = 1.
# keep_pct = 0.01
keep_pct = 0.1
keeps = np.random.rand(len(fnames_full)) < keep_pct
fnames = np.array(fnames_full, copy=False)[keeps]
label_arr = np.array(label_arr_full, copy=False)[keeps]
arch = vgg16
# sz,bs = 96,32
sz,bs = 256,24
# sz,bs = 128,32
class MatchedFilesDataset(FilesDataset):
    def __init__(self, fnames, y, transform, path):
        self.y=y
        assert(len(fnames)==len(y))
        super().__init__(fnames, transform, path)
    def get_y(self, i): 
        return open_image(os.path.join(self.path, self.y[i]))
    def get_c(self): 
        return 0
val_idxs = get_cv_idxs(len(fnames), val_pct=min(0.01/keep_pct, 0.1))
((val_x,trn_x),(val_y,trn_y)) = split_by_idx(
    val_idxs, 
    np.array(fnames), 
    np.array(fnames)
)
len(val_x),len(trn_x)
'''
(12800, 115206)
'''
img_fn = PATH/'train'/'n01558993'/'n01558993_9684.JPEG'
tfms = tfms_from_model(arch, sz, tfm_y=TfmType.PIXEL)
datasets = ImageData.get_ds(
    MatchedFilesDataset, 
    (trn_x,trn_y), 
    (val_x,val_y), 
    tfms, 
    path=PATH_TRN
)
md = ImageData(PATH, datasets, bs, num_workers=16, classes=None)
denorm = md.val_ds.denorm
def show_img(ims, idx, figsize=(5,5), normed=True, ax=None):
    if ax is None: 
        fig,ax = plt.subplots(figsize=figsize)
    if normed: 
        ims = denorm(ims)
    else:      
        ims = np.rollaxis(to_np(ims),1,4)
    ax.imshow(np.clip(ims,0,1)[idx])
    ax.axis('off')

模型

我的模型是一样的。这里我做的一件事是我没有使用任何花哨的最佳实践。部分原因是因为似乎没有。与超分辨率的研究相比,对这种方法的跟进非常少。我们稍后会讨论原因。所以你会看到,这看起来更加正常。

def conv(
    ni, nf, kernel_size=3, stride=1, 
    actn=True, pad=None, bn=True
):
    if pad is None: 
        pad = kernel_size//2
    layers = [nn.Conv2d(
        ni, nf, 
        kernel_size, 
        stride=stride,
        padding=pad, 
        bias=not bn
    )]
    if actn: 
        layers.append(nn.ReLU(inplace=True))
    if bn: 
        layers.append(nn.BatchNorm2d(nf))
    return nn.Sequential(*layers)

我有批量归一化层。这里没有缩放因子。

class ResSequentialCenter(nn.Module):
    def __init__(self, layers):
        super().__init__()
        self.m = nn.Sequential(*layers) 
    def forward(self, x): 
        return x[:, :, 2:-2, 2:-2] + self.m(x)
    def res_block(nf):
        return ResSequentialCenter([
            conv(nf, nf, actn=True, pad=0), 
            conv(nf, nf, pad=0)
        ])

我没有像素混洗 —— 只是使用正常的上采样,然后是 1x1 的卷积。所以这只是更正常的。

def upsample(ni, nf):
    return nn.Sequential(nn.Upsample(scale_factor=2), conv(ni, nf))

他们在论文中提到的一件事是他们在零填充中遇到了很多问题,他们解决这个问题的方法是在开始时添加 40 像素的反射填充。所以我也做了同样的事情,然后他们在他们的 Res 块中的卷积中使用了零填充。现在如果你的 Res 块中的卷积中有零填充,那么你的 ResNet 的两部分将不再相加,因为你在每个卷积的每一侧都失去了一个像素。所以我的ResSequential变成了ResSequentialCenter,我去掉了那些好细胞的每一侧的最后 2 个像素。除此之外,这基本上和以前一样。

class StyleResnet(nn.Module):
    def __init__(self):
        super().__init__()
        features = [
            nn.ReflectionPad2d(40),
            conv(3, 32, 9),
            conv(32, 64, stride=2), 
            conv(64, 128, stride=2)
        ]
        for i in range(5): 
            features.append(res_block(128))
        features += [
            upsample(128, 64), 
            upsample(64, 32),
            conv(32, 3, 9, actn=False)
        ]
        self.features = nn.Sequential(*features)

    def forward(self, x): 
        return self.features(x)

风格图像

然后我们可以引入我们的星夜图片。

style_fn = PATH/'style'/'starry_night.jpg'
style_img = open_image(style_fn)
style_img.shape
'''
(1198, 1513, 3)
'''
plt.imshow(style_img);

h,w,_ = style_img.shape
rat = max(sz/h,sz/h)
res = cv2.resize(style_img, (int(w*rat), int(h*rat)), interpolation=cv2.INTER_AREA)
resz_style = res[:sz,-sz:]

我们可以调整大小。

plt.imshow(resz_style);

我们可以通过我们的变换

style_tfm,_ = tfms1style_tfm = np.broadcast_to(style_tfm[None], (bs,)+style_tfm.shape)

为了让我的大脑更容易处理这种方法,我拿出了我们的变换风格图像,经过 3 x 256 x 256 的变换后,我制作了一个小批量。我的批量大小是 24 — 有 24 个副本。这样做可以更容易地进行批量算术,而不用担心一些广播问题。它们实际上并不是 24 个副本。我使用np.broadcast基本上伪造了 24 个部分。

style_tfm.shape(24, 3, 256, 256)

感知损失

所以就像以前一样,我们创建了一个 VGG,抓住了最后一个块。这一次我们要使用所有这些层,所以我们保留了所有直到第 43 层的内容。

m_vgg = vgg16(True)
blocks = [
    i-1 for i,o in enumerate(children(m_vgg))
    if isinstance(o,nn.MaxPool2d)
]
blocks, [m_vgg[i] for i in blocks[1:]]
'''
([5, 12, 22, 32, 42],
 [ReLU(inplace), ReLU(inplace), ReLU(inplace), ReLU(inplace)])
'''
vgg_layers = children(m_vgg)[:43]
m_vgg = nn.Sequential(*vgg_layers).cuda().eval()
set_trainable(m_vgg, False)
def flatten(x): 
    return x.view(x.size(0), -1)
class SaveFeatures():
    features=None
    def __init__(self, m): 
        self.hook = m.register_forward_hook(self.hook_fn)
    def hook_fn(self, module, input, output): 
        self.features = output
    def remove(self): 
        self.hook.remove()
    def ct_loss(input, target): 
        return F.mse_loss(input,target)
    def gram(input):
        b,c,h,w = input.size()
        x = input.view(b, c, -1)
        return torch.bmm(x, x.transpose(1,2))/(c*h*w)*1e6
    def gram_loss(input, target):
        return F.mse_loss(gram(input), gram(target[:input.size(0)]))

所以现在我们的组合损失将加上第三个块的内容损失,再加上所有块的 Gram 损失,使用不同的权重。再次回到尽可能正常的一切,我又回到了使用均方误差。基本上发生的事情是我在训练这个模型时遇到了很多困难。所以我逐渐去掉了一个又一个技巧,最终只是说“好吧,我只会让它尽可能平淡”。

上周的 Gram 矩阵是错误的。它只适用于批量大小为 1,而我们只有一个批量大小,所以没问题。我使用的是矩阵乘法,这意味着每个批次都与其他每个批次进行比较。实际上,你需要使用批量矩阵乘法(torch.bmm),它对每个批次执行矩阵乘法。所以这是需要注意的一点。

class CombinedLoss(nn.Module):
    def __init__(self, m, layer_ids, style_im, ct_wgt, style_wgts):
        super().__init__()
        self.m,self.ct_wgt,self.style_wgts = m,ct_wgt,style_wgts
        self.sfs = [SaveFeatures(m[i]) for i in layer_ids]
        m(VV(style_im))
        self.style_feat = [
            V(o.features.data.clone()) 
            for o in self.sfs
        ] 
    def forward(self, input, target, sum_layers=True):
        self.m(VV(target.data))
        targ_feat = self.sfs[2].features.data.clone()
        self.m(input)
        inp_feat = [o.features for o in self.sfs]

        res = [ct_loss(inp_feat[2],V(targ_feat)) * self.ct_wgt]
        res += [
            gram_loss(inp,targ)*wgt 
            for inp,targ,wgt in zip(inp_feat, self.style_feat, self.style_wgts)
        ]

        if sum_layers: res = sum(res)
        return res

    def close(self):
        for o in self.sfs: 
            o.remove()

所以我有 Gram 矩阵,我在 Gram 矩阵之间进行均方误差损失,我用风格权重对它们进行加权,所以我创建了那个 ResNet。

m = StyleResnet()
m = to_gpu(m)learn = Learner(md, SingleModel(m), opt_fn=optim.Adam)

我创建了我的组合损失,传入 VGG 网络,传入块 ID,传入变换后的星夜图像,你会看到这里的开始,我通过我的 VGG 模型进行了前向传递,以保存其特征。请注意,现在非常重要的是我不做任何数据增强,因为我保存了特定未增强版本的风格特征。所以如果我增强它,可能会出现一些小问题。但没关系,因为我有所有的 ImageNet 要处理。我实际上不需要做数据增强。

learn.crit = CombinedLoss(
    m_vgg, blocks[1:], 
    style_tfm, 1e4,
    [0.025,0.275,5.,0.2]
)
wd=1e-7
learn.lr_find(wds=wd)
learn.sched.plot(n_skip_end=1) 
'''
  1%|▏         | 7/482 [00:04<05:32,  1.43it/s, loss=2.48e+04] 
 53%|█████▎    | 254/482 [02:27<02:12,  1.73it/s, loss=1.13e+12]
'''

lr=5e-3

所以我有我的损失函数,我可以继续拟合[1:24:06]。这里一点聪明的地方都没有。

learn.fit(lr, 1, cycle_len=1, wds=wd, use_clr=(20,10))
'''
epoch      trn_loss   val_loss                               
    0      105.351372 105.833994
[array([105.83399])]
'''
learn.save('style-2')
x,y=md.val_ds[201]
learn.model.eval()
preds = learn.model(VV(x[None]))
x.shape,y.shape,preds.shape
'''
((3, 256, 256), (3, 256, 256), torch.Size([1, 3, 256, 256]))
'''

最后,我有我的sum_layers=False,这样我就可以看到每个部分的样子,看到它们是平衡的。然后我终于可以弹出它

learn.crit(preds, VV(y[None]), sum_layers=False)
'''
[Variable containing:
  53.2221
 [torch.cuda.FloatTensor of size 1 (GPU 0)], 
 Variable containing:
  3.8336
 [torch.cuda.FloatTensor of size 1 (GPU 0)], 
 Variable containing:
  4.0612
 [torch.cuda.FloatTensor of size 1 (GPU 0)], 
 Variable containing:
  5.0639
 [torch.cuda.FloatTensor of size 1 (GPU 0)], 
 Variable containing:
  53.0019
 [torch.cuda.FloatTensor of size 1 (GPU 0)]]
'''
learn.crit.close()
_,axes=plt.subplots(1,2,figsize=(14,7))
show_img(x[None], 0, ax=axes[0])
show_img(preds, 0, ax=axes[1])

所以我提到这应该很容易,但实际上花了我大约 4 天,因为我发现这个真的很麻烦,才让它正常工作[1:24:26]。所以当我终于早上起床时,我对 Rachel 说“猜猜,它训练正确了。” Rachel 说“我从来没想过会发生这种事。” 它看起来一直很糟糕,真的是关于得到精确的内容损失和风格损失的混合以及风格损失的层次的混合。最糟糕的部分是训练这个该死的 CNN 需要很长时间,我真的不知道应该训练多久才能确定它表现不佳。我应该只是继续训练吗?我不知道所有这些细节似乎都没有稍微改变它,但它总是会完全崩溃。所以我提到这部分是为了提醒大家,最终你在这里看到的答案是在我整整一周把自己逼疯几乎总是不起作用,直到最后一刻它终于起作用。即使对于那些看起来不可能困难的事情,因为那是将两个我们已经有的工作结合在一起。另一个是要小心解释作者声称的内容。

让这个风格转移起作用真的很麻烦[1:26:10]。做完之后,我想为什么我要费这个劲,因为现在我有了一个需要花几个小时来创建一个可以将任何类型的照片转换为一个特定风格的网络。我觉得我很少会想要这样做。我能想到这有用的唯一原因是在视频上做一些艺术性的东西,我想把每一帧都转换成某种风格。这是一个极其狭隘的想法。但当我看了论文后,表格上写着“哦,我们比 Gatys 的方法快一千倍”,这种说法显然毫无意义。这是一个极其误导人的说法,因为它忽略了为每种风格进行的所有训练时间,我发现这很令人沮丧,因为像斯坦福这样的团体显然更清楚或应该更清楚,但仍然我猜学术界鼓励人们提出这些荒谬的夸大宣称。它也完全忽视了这个极其敏感的繁琐的训练过程,所以这篇论文一出来就被如此广泛接受。我记得每个人都在推特上说“哇,你知道这些斯坦福的人找到了这种方式可以让风格转移快一千倍。” 显然说这话的人是该领域的顶尖研究人员,显然他们中没有人真正理解,因为没有人说“我不明白为什么这有任何用处,而且我尝试过,让它正常工作真的很麻烦。” 直到 18 个月后,我最终回头看,有点想“等一下,这有点愚蠢。” 所以我认为这就是为什么人们没有对此进行后续研究,以创造真正令人惊叹的最佳实践和更好的方法,就像论文中的超分辨率部分一样。我认为答案是因为这很愚蠢。所以我认为论文中的超分辨率部分显然不愚蠢。它已经得到改进,现在我们有了很棒的超分辨率。我认为我们可以从中得到很棒的降噪、很棒的着色、很棒的倾斜去除、很棒的交互式伪影去除等等。所以我认为这里有很多很酷的技术。它还利用了我们一直在学习和不断进步的许多东西。

分割[1:29:13]

最后,让我们谈谈分割。这来自著名的 CamVid 数据集,这是一个学术分割数据集的经典示例。基本上你可以看到我们的做法是从一幅图片开始(实际上在这个数据集中是视频帧),我们有一些标签,它们实际上不是颜色 - 每个标签都有一个 ID,这些 ID 映射到颜色。所以红色可能是 1,紫色可能是 2,浅粉色可能是 3,等等。所以所有建筑物属于一类,所有汽车属于另一类,所有人属于另一类,所有道路属于另一类,依此类推。所以我们实际上在这里为每个像素进行多类分类。你可以看到,有时多类分类确实非常棘手 - 就像这些分支。尽管有时标签实际上并不是那么好。正如你所看到的,这非常粗糙。这就是我们要做的。

我们将进行分割,所以这很像边界框。但与其只是找到每个物体周围的框,我们实际上要为每个像素标记其类别。实际上,这实际上要容易得多,因为它非常适合我们的 CNN 风格,我们可以创建任何输出为 N 乘以 M 网格的 CNN,其中包含从 0 到 C 的整数,其中 C 是类别数。然后我们可以使用 softmax 激活的交叉熵损失,然后就完成了。我实际上可以在这里停止课程,你可以使用在第 1 和第 2 课中学到的完全相同的方法,你会得到一个完全可以接受的结果。所以首先要说的是,这实际上并不是一件非常困难的事情。但我们将尽力做得更好。

以简单的方式进行[1:31:26]

笔记本

让我们从最简单的方式开始。我们将使用 Kaggle Carvana比赛,你可以像往常一样使用 Kaggle API 下载它。

%matplotlib inline
%reload_ext autoreload
%autoreload 2
from fastai.conv_learner import *
from fastai.dataset import *

from pathlib import Path
import json

设置

有一个包含一堆图像的训练文件夹,这是自变量,还有一个 train_masks 文件夹,这是因变量,它们看起来像下面这样。

在这种情况下,就像猫和狗一样,我们选择简单的方式,而不是进行多类分类,我们将进行二元分类。但当然,多类分类只是更一般的版本 - 分类交叉熵或二元分类熵。在概念上没有区别,因此因变量只是零和一,而自变量是常规图像。

为了做到这一点,真的很有帮助知道汽车是什么样子的。因为我们真正想做的是弄清楚这是一辆车,以及它的方向,并根据图片和他们对汽车外观的理解,在我们期望汽车出现的地方放置白色像素。

PATH = Path('data/carvana')
list(PATH.iterdir())
'''
[PosixPath('data/carvana/train_masks.csv'),
 PosixPath('data/carvana/train_masks-128'),
 PosixPath('data/carvana/sample_submission.csv'),
 PosixPath('data/carvana/train_masks_png'),
 PosixPath('data/carvana/train.csv'),
 PosixPath('data/carvana/train-128'),
 PosixPath('data/carvana/train'),
 PosixPath('data/carvana/metadata.csv'),
 PosixPath('data/carvana/tmp'),
 PosixPath('data/carvana/models'),
 PosixPath('data/carvana/train_masks')]
'''
MASKS_FN = 'train_masks.csv'
META_FN = 'metadata.csv'
TRAIN_DN = 'train'
MASKS_DN = 'train_masks'
masks_csv = pd.read_csv(PATH/MASKS_FN)
masks_csv.head()

原始数据集还附带了这些 CSV 文件[1:32:44]。我实际上并没有用它们做很多其他事情,只是从中获取图像列表。

meta_csv = pd.read_csv(PATH/META_FN)
meta_csv.head()

def show_img(im, figsize=None, ax=None, alpha=None):
    if not ax: 
        fig,ax = plt.subplots(figsize=figsize)
    ax.imshow(im, alpha=alpha)
    ax.set_axis_off()
    return axCAR_ID = '00087a6bd4dc'
list((PATH/TRAIN_DN).iterdir())[:5]
'''
[PosixPath('data/carvana/train/5ab34f0e3ea5_15.jpg'),
 PosixPath('data/carvana/train/de3ca5ec1e59_07.jpg'),
 PosixPath('data/carvana/train/28d9a149cb02_13.jpg'),
 PosixPath('data/carvana/train/36a3f7f77e85_12.jpg'),
 PosixPath('data/carvana/train/843763f47895_08.jpg')]
'''
Image.open(PATH/TRAIN_DN/f'{CAR_ID}_01.jpg').resize((300,200))

list((PATH/MASKS_DN).iterdir())[:5]
'''
[PosixPath('data/carvana/train_masks/6c0cd487abcd_03_mask.gif'),
 PosixPath('data/carvana/train_masks/351c583eabd6_01_mask.gif'),
 PosixPath('data/carvana/train_masks/90fdd8932877_02_mask.gif'),
 PosixPath('data/carvana/train_masks/28d9a149cb02_10_mask.gif'),
 PosixPath('data/carvana/train_masks/88bc32b9e1d9_14_mask.gif')]
'''
Image.open(PATH/MASKS_DN/f'{CAR_ID}_01_mask.gif').resize((300,200))

每张图片在车辆 ID 之后都有一个 01、02 等,我已经打印出其中一个车辆的所有 16 个方向,正如你所看到的,基本上这些数字是一个车辆的 16 个方向[1:32:58]。我认为在这个比赛中没有人实际上使用这些方向信息。我相信他们都保留了车辆的图像,只是单独处理它们。

ims = [
    open_image(PATH/TRAIN_DN/f'{CAR_ID}_{i+1:02d}.jpg') 
    for i in range(16)
]
fig, axes = plt.subplots(4, 4, figsize=(9, 6))
for i,ax in enumerate(axes.flat): 
    show_img(ims[i], ax=ax)
plt.tight_layout(pad=0.1)

调整大小和转换[1:33:27]

这些图像非常大 - 大小超过 1000 乘以 1000,只是打开 JPEG 并调整它们的大小很慢。所以我对它们进行了处理。此外,OpenCV 无法处理 GIF 文件,因此我对它们进行了转换。

问题:有人最初如何获得这些用于训练的蒙版?Mechanical turk或其他什么[1:33:48]?是的,只是很多无聊的工作。可能有一些工具可以帮助你进行一些边缘捕捉,这样人类可以粗略地完成,然后只需微调它错误的部分。这种标签是昂贵的。所以我真正想要做的事情之一是增强深度学习交互式标注工具,因为这显然是可以帮助很多人的事情。

我这里有一个小节,如果你想的话可以运行。你可能想要。它将 GIF 转换为 PNG,所以只需用 PIL 打开它,然后保存为 PNG,因为 OpenCV 不支持 GIF。像往常一样,对于这种类型的东西,我使用线程池,这样我就可以利用并行处理。然后创建一个单独的目录train-128train_masks-128,其中包含它们的 128x128 调整大小版本。

这是在过程早期进行的工作,可以让你保持理智的工作。所以每当你获得新的数据集时,认真考虑创建一个较小的版本以加快速度。每当你发现自己在电脑上等待时,尝试想出一种创建较小版本的方法。

(PATH/'train_masks_png').mkdir(exist_ok=True)
def convert_img(fn):
    fn = fn.name
    Image.open(PATH/'train_masks'/fn).save(
        PATH/'train_masks_png'/f'{fn[:-4]}.png'
    )
    files = list((PATH/'train_masks').iterdir())
with ThreadPoolExecutor(8) as e: 
    e.map(convert_img, files)
    (PATH/'train_masks-128').mkdir(exist_ok=True)
def resize_mask(fn):
    Image.open(fn).resize((128,128)).save(
        (fn.parent.parent)/'train_masks-128'/fn.name
    )

files = list((PATH/'train_masks_png').iterdir())
with ThreadPoolExecutor(8) as e: 
    e.map(resize_img, files)
    (PATH/'train-128').mkdir(exist_ok=True)
def resize_img(fn):
    Image.open(fn).resize((128,128)).save(
        (fn.parent.parent)/'train-128'/fn.name
    )

files = list((PATH/'train').iterdir())
with ThreadPoolExecutor(8) as e: 
    e.map(resize_img, files)

所以在你从 Kaggle 获取它之后,你可能想要运行这些东西,离开,吃午餐,回来时,当你完成时,你将拥有这些较小的目录,我们将从 128x128 开始使用。

数据集[1:35:33]

TRAIN_DN = 'train-128'
MASKS_DN = 'train_masks-128'
sz = 128
bs = 64
ims = [
    open_image(PATH/TRAIN_DN/f'{CAR_ID}_{i+1:02d}.jpg') 
    for i in range(16)
]
im_masks = [
    open_image(PATH/MASKS_DN/f'{CAR_ID}_{i+1:02d}_mask.png') 
    for i in range(16)
]

这里有一个很酷的技巧。如果你使用相同的轴对象(ax)两次绘制图像,第二次使用 alpha,你可能知道在计算机视觉世界中意味着透明度,那么你实际上可以在照片的顶部绘制蒙版。这是一个很好的方法,可以看到所有车辆组中所有照片顶部的所有蒙版。

fig, axes = plt.subplots(4, 4, figsize=(9, 6))
for i,ax in enumerate(axes.flat):
    ax = show_img(ims[i], ax=ax)
    show_img(im_masks[i][...,0], ax=ax, alpha=0.5)
plt.tight_layout(pad=0.1)

这是我们已经看过两次的相同的 MatchedFilesDataset。这是相同的代码。这里有一些重要的东西。如果我们在训练集中有左边的图像,然后验证集中有右边的图像,那将是一种作弊,因为它是相同的车辆。

class MatchedFilesDataset(FilesDataset):
    def __init__(self, fnames, y, transform, path):
        self.y=y
        assert(len(fnames)==len(y))
        super().__init__(fnames, transform, path)
    def get_y(self, i): 
        return open_image(os.path.join(self.path, self.y[i]))
    def get_c(self): 
        return 0
x_names = np.array([Path(TRAIN_DN)/o for o in masks_csv['img']])
y_names = np.array([
    Path(MASKS_DN)/f'{o[:-4]}_mask.png' 
     o in masks_csv['img']
])
len(x_names)//16//5*16
'''
1008
'''

所以我们使用一系列连续的汽车 ID,由于每个集合是一组 16 个,我们确保可以被 16 整除。因此,我们确保我们的验证集包含与训练集不同的汽车 ID。这是你必须小心的事情。在 Kaggle 上,情况并不那么糟糕 - 你会知道,因为你会提交你的结果,你的排行榜上的结果会与你的验证集有很大不同。但在现实世界中,你不会知道,直到你投入生产并让公司破产并失去工作。所以在这种情况下,你可能需要仔细考虑你的验证集。

val_idxs = list(range(1008))
((val_x,trn_x),(val_y,trn_y)) = split_by_idx(val_idxs, x_names, y_names)
len(val_x),len(trn_x)
'''
(1008, 4080)
'''

在这里,我们将使用转换类型分类(TfmType.CLASS)[1:37:03]。这基本上与转换类型像素(TfmType.PIXEL)相同,但是如果你考虑一下,对于像素版本,如果我们旋转一点,那么我们可能希望在两者之间平均像素,但是分类,显然我们不需要。我们使用最近邻。所以这里有一点不同。此外,对于分类,光照不起作用,归一化不起作用于因变量。

aug_tfms = [
    RandomRotate(4, tfm_y=TfmType.CLASS),
    RandomFlip(tfm_y=TfmType.CLASS),
    RandomLighting(0.05, 0.05)
]
# aug_tfms = []

它们已经是方形图像,所以我们不必进行任何裁剪。

tfms = tfms_from_model(
    resnet34, sz, 
    crop_type=CropType.NO, 
    tfm_y=TfmType.CLASS, 
    aug_tfms=aug_tfms
)
datasets = ImageData.get_ds(
    MatchedFilesDataset, 
    (trn_x,trn_y), 
    (val_x,val_y), 
    tfms, 
    path=PATH
)
md = ImageData(
    PATH, datasets, bs, 
    num_workers=8, 
    classes=None
)
denorm = md.trn_ds.denorm
x,y = next(iter(md.aug_dl))
x = denorm(x)

所以在这里你可以看到增强图像的不同版本 - 它们在移动一点,旋转一点,等等。

fig, axes = plt.subplots(5, 6, figsize=(12, 10))
for i,ax in enumerate(axes.flat):
    ax=show_img(x[i], ax=ax)
    show_img(y[i], ax=ax, alpha=0.5)
plt.tight_layout(pad=0.1)

在我们的学习小组中,我经常被问到如何调试和修复不起作用的东西。我从来没有一个很好的答案,除了每次我解决问题都是因为我经常做这样的事情。我总是在进行过程中打印出所有内容,然后我搞砸的那一件事总是最后发现是我忘记检查的那一件事。你能做这种事情的越多越好。如果你不看所有的中间结果,你会遇到麻烦。

模型[1:38:30]

class Empty(nn.Module): 
    def forward(self,x): 
        return x

models = ConvnetBuilder(resnet34, 0, 0, 0, custom_head=Empty())
learn = ConvLearner(md, models)
learn.summary()
class StdUpsample(nn.Module):
    def __init__(self, nin, nout):
        super().__init__()
        self.conv = nn.ConvTranspose2d(nin, nout, 2, stride=2)
        self.bn = nn.BatchNorm2d(nout)

    def forward(self, x): 
        return self.bn(F.relu(self.conv(x)))
flatten_channel = Lambda(lambda x: x[:,0])
simple_up = nn.Sequential(
    nn.ReLU(),
    StdUpsample(512,256),
    StdUpsample(256,256),
    StdUpsample(256,256),
    StdUpsample(256,256),
    nn.ConvTranspose2d(256, 1, 2, stride=2),
    flatten_channel
)

考虑到我们想要一个知道汽车长什么样的东西,我们可能想要从一个预训练的 ImageNet 网络开始。所以我们将从 ResNet34 开始。使用ConvnetBuilder,我们可以获取我们的 ResNet34 并添加一个自定义头部。自定义头部将是一些上采样的东西,现在我们将做一些非常愚蠢的事情,就是我们只是做一个 ConvTranspose2d,批量规范化,ReLU。

这就是我说的 - 任何人都可以在不看任何笔记本的情况下构建这个,或者至少你有来自以前课程的信息。这里没有任何新东西。所以最后,我们有一个单一的过滤器。现在这将给我们一个批量大小为 1 乘以 128 乘以 128。但我们想要的是批量大小为 128 乘以 128。所以我们必须去掉那个单元轴,所以我在这里有一个 lambda 层。Lambda 层非常有帮助,因为没有这个 lambda 层,它只是通过索引 0 来删除那个单元轴,没有 lambda 层,我将不得不创建一个自定义类,具有自定义的前向方法等等。但通过创建一个 lambda 层来执行一个自定义操作,我现在可以将其放入 Sequential 中,这样就更容易了。

PyTorch 的人们对这种方法有点傲慢。Lambda 层实际上是 fastai 库的一部分,而不是 PyTorch 库的一部分。而且 PyTorch 讨论板上的人们说“是的,我们可以给人们这个”,“是的,这只是一行代码”,但他们从不鼓励他们过于频繁地使用 Sequential。所以你看。

这是我们的自定义头部[1:40:36]。所以我们将有一个 ResNet 34 进行下采样,然后一个非常简单的自定义头部,非常快速地上采样,希望这样做一些事情。我们将使用阈值为 0.5 的准确度并打印出指标。

models = ConvnetBuilder(resnet34, 0, 0, 0, custom_head=simple_up)
learn = ConvLearner(md, models)
learn.opt_fn=optim.Adam
learn.crit=nn.BCEWithLogitsLoss()
learn.metrics=[accuracy_thresh(0.5)
learn.lr_find()
learn.sched.plot()
'''
94%|█████████▍| 30/32 [00:05<00:00,  5.48it/s, loss=10.6]
'''

lr=4e-2
learn.fit(lr,1,cycle_len=5,use_clr=(20,5))
'''
epoch      trn_loss   val_loss   <lambda>                  
    0      0.124078   0.133566   0.945951  
    1      0.111241   0.112318   0.954912                  
    2      0.099743   0.09817    0.957507                   
    3      0.090651   0.092375   0.958117                   
    4      0.084031   0.086026   0.963243
[0.086025625, 0.96324310824275017]
'''

经过几个时代,我们得到了 96%的准确率。这好吗[1:40:56]?96%的准确率好吗?希望对这个问题的答案是取决于。这是为了什么?答案是 Carvana 想要这个,因为他们想要能够拍摄他们的汽车图像并将它们剪切并粘贴到异国情调的蒙特卡洛背景或其他地方(这是蒙特卡洛的地方,而不是模拟)。为了做到这一点,你需要一个非常好的蒙版。你不想留下后视镜,缺少一个车轮,或者包括一点背景之类的东西。那看起来很愚蠢。所以你需要一些非常好的东西。所以只有 96%的像素正确并不听起来很好。但我们真的不知道直到我们看到它。所以让我们看看。

learn.save('tmp')
learn.load('tmp')
py,ay = learn.predict_with_targs()
ay.shape
'''
(1008, 128, 128)
'''

所以这是我们想要剪切的正确版本[1:41:54]

show_img(ay[0]);

这是 96%准确的版本。所以当你看到它时,你会意识到“哦,是的,准确地获取 96%的像素实际上很容易,因为所有外部部分都不是汽车,所有内部部分都是汽车,而真正有趣的部分是边缘。所以我们需要做得更好。

show_img(py[0]>0);

让我们解冻,因为到目前为止我们只训练了自定义头部。让我们做更多。

learn.unfreeze()
learn.bn_freeze(True)
lrs = np.array([lr/100,lr/10,lr])/4
learn.fit(lrs,1,cycle_len=20,use_clr=(20,10))
'''
epoch      trn_loss   val_loss   <lambda>                   
    0      0.06577    0.053292   0.972977  
    1      0.049475   0.043025   0.982559                   
    2      0.039146   0.035927   0.98337                    
    3      0.03405    0.031903   0.986982                   
    4      0.029788   0.029065   0.987944                   
    5      0.027374   0.027752   0.988029                   
    6      0.026041   0.026718   0.988226                   
    7      0.024302   0.025927   0.989512                   
    8      0.022921   0.026102   0.988276                   
    9      0.021944   0.024714   0.989537                   
    10     0.021135   0.0241     0.990628                   
    11     0.020494   0.023367   0.990652                   
    12     0.01988    0.022961   0.990989                   
    13     0.019241   0.022498   0.991014                   
    14     0.018697   0.022492   0.990571                   
    15     0.01812    0.021771   0.99105                    
    16     0.017597   0.02183    0.991365                   
    17     0.017192   0.021434   0.991364                   
    18     0.016768   0.021383   0.991643                   
    19     0.016418   0.021114   0.99173
[0.021113895, 0.99172959849238396]
'''

再经过一段时间,我们得到了 99.1%。这好吗?我不知道。让我们看看。

learn.save('0')
x,y = next(iter(md.val_dl))
py = to_np(learn.model(V(x)))

实际上不是。它完全错过了左侧的后视镜,右侧也错过了很多。底部的边缘明显错了。当我们尝试剪裁时,这些事情完全会影响到,所以还不够好。

ax = show_img(denorm(x)[0])
show_img(py[0]>0, ax=ax, alpha=0.5);

ax = show_img(denorm(x)[0])
show_img(y[0], ax=ax, alpha=0.5);

512x512

让我们尝试放大。很好的一点是,当我们将其放大到 512x512 时(确保减少批量大小,因为你会耗尽内存),有更多的信息供其使用,因此我们的准确性提高到 99.4%,事情一直在变得更好。

TRAIN_DN = 'train'
MASKS_DN = 'train_masks_png'
sz = 512
bs = 16
x_names = np.array([Path(TRAIN_DN)/o for o in masks_csv['img']])
y_names = np.array([
    Path(MASKS_DN)/f'**{o[:-4]}**_mask.png' 
    for o in masks_csv['img']
])
((val_x,trn_x),(val_y,trn_y)) = split_by_idx(val_idxs, x_names, y_names)
len(val_x),len(trn_x)
'''
(1008, 4080)
'''
tfms = tfms_from_model(
    resnet34, sz, 
    crop_type=CropType.NO,
    tfm_y=TfmType.CLASS, 
    aug_tfms=aug_tfms
)
datasets = ImageData.get_ds(
    MatchedFilesDataset, 
    (trn_x,trn_y),
    (val_x,val_y), 
    tfms, 
    path=PATH
)
md = ImageData(
    PATH, datasets, bs, 
    num_workers=8, 
    classes=None
)
denorm = md.trn_ds.denorm
x,y = next(iter(md.aug_dl))
x = denorm(x)

这是真实的。

fig, axes = plt.subplots(4, 4, figsize=(10, 10))
for i,ax in enumerate(axes.flat):
    ax=show_img(x[i], ax=ax)
    show_img(y[i], ax=ax, alpha=0.5)
plt.tight_layout(pad=0.1)

simple_up = nn.Sequential(
    nn.ReLU(),
    StdUpsample(512,256),
    StdUpsample(256,256),
    StdUpsample(256,256),
    StdUpsample(256,256),
    nn.ConvTranspose2d(256, 1, 2, stride=2),
    flatten_channel
)
models = ConvnetBuilder(resnet34, 0, 0, 0, custom_head=simple_up)
learn = ConvLearner(md, models)
learn.opt_fn=optim.Adam
learn.crit=nn.BCEWithLogitsLoss()
learn.metrics=[accuracy_thresh(0.5)]
learn.load('0')
learn.lr_find()
learn.sched.plot()
'''
85%|████████▌ | 218/255 [02:12<00:22,  1.64it/s, loss=8.91]
'''

lr=4e-2
learn.fit(lr,1,cycle_len=5,use_clr=(20,5))
'''
epoch      trn_loss   val_loss   <lambda>                     
    0      0.02178    0.020653   0.991708  
    1      0.017927   0.020653   0.990241                     
    2      0.015958   0.016115   0.993394                     
    3      0.015172   0.015143   0.993696                     
    4      0.014315   0.014679   0.99388
[0.014679321, 0.99388032489352751]
'''
learn.save('tmp')
learn.load('tmp')
learn.unfreeze()
learn.bn_freeze(True)
lrs = np.array([lr/100,lr/10,lr])/4
learn.fit(lrs,1,cycle_len=8,use_clr=(20,8))
'''
epoch      trn_loss   val_loss   mask_acc                     
    0      0.038687   0.018685   0.992782  
    1      0.024906   0.014355   0.994933                     
    2      0.025055   0.014737   0.995526                     
    3      0.024155   0.014083   0.995708                     
    4      0.013446   0.010564   0.996166                     
    5      0.01607    0.010555   0.996096                     
    6      0.019197   0.010883   0.99621                      
    7      0.016157   0.00998    0.996393
[0.0099797687, 0.99639255659920833]
'''
learn.save('512')
x,y = next(iter(md.val_dl))
py = to_np(learn.model(V(x)))
ax = show_img(denorm(x)[0])
show_img(py[0]>0, ax=ax, alpha=0.5);

ax = show_img(denorm(x)[0])
show_img(y[0], ax=ax, alpha=0.5);

事情一直在变得更好,但我们仍然有一些小黑色块状物。所以让我们调整到 1024x1024。

1024x1024

所以让我们调整到 1024x1024,批量大小减少到 4。现在这是相当高分辨率的了,再训练一段时间,99.6%,99.8%!

sz = 1024
bs = 4
tfms = tfms_from_model(
    resnet34, sz, 
    crop_type=CropType.NO,
    tfm_y=TfmType.CLASS, 
    aug_tfms=aug_tfms
)
datasets = ImageData.get_ds(
    MatchedFilesDataset, 
    (trn_x,trn_y), 
    (val_x,val_y), 
    tfms, 
    path=PATH
)
md = ImageData(
    PATH, datasets, bs, 
    num_workers=8, 
    classes=None
)
denorm = md.trn_ds.denorm
x,y = next(iter(md.aug_dl))
x = denorm(x)
y = to_np(y)
fig, axes = plt.subplots(2, 2, figsize=(8, 8))
for i,ax in enumerate(axes.flat):
    show_img(x[i], ax=ax)
    show_img(y[i], ax=ax, alpha=0.5)
plt.tight_layout(pad=0.1)

simple_up = nn.Sequential(
    nn.ReLU(),
    StdUpsample(512,256),
    StdUpsample(256,256),
    StdUpsample(256,256),
    StdUpsample(256,256),
    nn.ConvTranspose2d(256, 1, 2, stride=2),
    flatten_channel,
)
models = ConvnetBuilder(resnet34, 0, 0, 0, custom_head=simple_up)
learn = ConvLearner(md, models)
learn.opt_fn=optim.Adam
learn.crit=nn.BCEWithLogitsLoss()
learn.metrics=[accuracy_thresh(0.5)]
learn.load('512')learn.lr_find()
learn.sched.plot()
'''
85%|████████▌ | 218/255 [02:12<00:22,  1.64it/s, loss=8.91]
'''

lr=4e-2
learn.fit(lr,1,cycle_len=2,use_clr=(20,4))
'''
epoch      trn_loss   val_loss   <lambda>                       
    0      0.01066    0.011119   0.996227  
    1      0.009357   0.009696   0.996553
[0.0096957013, 0.99655332546385511]
'''
learn.save('tmp')
learn.load('tmp')
learn.unfreeze()
learn.bn_freeze(True)
lrs = np.array([lr/100,lr/10,lr])/8
learn.fit(lrs,1,cycle_len=40,use_clr=(20,10))
'''
epoch      trn_loss   val_loss   mask_acc                       
    0      0.015565   0.007449   0.997661  
    1      0.01979    0.008376   0.997542                       
    2      0.014874   0.007826   0.997736                       
    3      0.016104   0.007854   0.997347                       
    4      0.023386   0.009745   0.997218                       
    5      0.018972   0.008453   0.997588                       
    6      0.013184   0.007612   0.997588                       
    7      0.010686   0.006775   0.997688                       
    8      0.0293     0.015299   0.995782                       
    9      0.018713   0.00763    0.997638                       
    10     0.015432   0.006575   0.9978                         
    11     0.110205   0.060062   0.979043                      
    12     0.014374   0.007753   0.997451                       
    13     0.022286   0.010282   0.997587                       
    14     0.015645   0.00739    0.997776                       
    15     0.013821   0.00692    0.997869                       
    16     0.022389   0.008632   0.997696                       
    17     0.014607   0.00677    0.997837                       
    18     0.018748   0.008194   0.997657                       
    19     0.016447   0.007237   0.997899                       
    20     0.023596   0.008211   0.997918                       
    21     0.015721   0.00674    0.997848                       
    22     0.01572    0.006415   0.998006                       
    23     0.019519   0.007591   0.997876                       
    24     0.011159   0.005998   0.998053                       
    25     0.010291   0.005806   0.998012                       
    26     0.010893   0.005755   0.998046                       
    27     0.014534   0.006313   0.997901                       
    28     0.020971   0.006855   0.998018                       
    29     0.014074   0.006107   0.998053                       
    30     0.01782    0.006561   0.998114                       
    31     0.01742    0.006414   0.997942                       
    32     0.016829   0.006514   0.9981                         
    33     0.013148   0.005819   0.998033                       
    34     0.023495   0.006261   0.997856                       
    35     0.010931   0.005516   0.99812                        
    36     0.015798   0.006176   0.998126                       
    37     0.021636   0.005931   0.998067                       
    38     0.012133   0.005496   0.998158                       
    39     0.012562   0.005678   0.998172
[0.0056782686, 0.99817223208291195]
'''
learn.save('1024')
x,y = next(iter(md.val_dl))
py = to_np(learn.model(V(x)))
ax = show_img(denorm(x)[0])
show_img(py[0][0]>0, ax=ax, alpha=0.5);

ax = show_img(denorm(x)[0])
show_img(y[0,...,-1], ax=ax, alpha=0.5);

show_img(py[0][0]>0);

show_img(y[0,...,-1]);

现在如果我们看一下掩模,它们实际上看起来不错。这看起来相当不错。那么我们能做得更好吗?答案是肯定的。

U-Net

笔记本 / 论文

U-Net 网络非常了不起。使用之前的方法,我们的预训练 ImageNet 网络被压缩到 7x7,然后再扩展到 224x224(1024 被压缩到比 7x7 大得多)。然后再次扩展出来,这意味着它必须以某种方式在小版本中存储关于更大版本的所有信息。实际上,关于更大版本的大部分信息实际上已经在原始图片中。因此,这种压缩和解压似乎不是一个很好的方法。

因此,U-Net 的想法来自于这篇出色的论文,在这篇论文中,它实际上是在生物医学图像分割这个非常特定的领域中发明的。但事实上,基本上每一个与分割有关的 Kaggle 获胜者最终都使用了 U-Net。这是每个 Kaggle 参与者都知道的最佳实践之一,但在更多的学术圈中,这已经存在至少几年了,很多人仍然没有意识到这是迄今为止最好的方法。

这里是基本的想法。在左侧是向下路径,我们从 572x572 开始,然后将网格大小减半 4 次,然后在右侧是向上路径,我们将网格大小扩大 4 次。但我们还做的一件事是,在每个减半网格大小的点,我们实际上将这些激活复制到向上路径,并将它们连接在一起。

在右下角可以看到,这些红色箭头是最大池化操作,这些绿色箭头是向上采样,然后这些灰色箭头是复制。所以我们复制并连接。换句话说,经过几次卷积后的输入图像被复制到输出中,连接在一起,现在我们可以使用所有经过所有向下和向上的信息,还有输入像素的略微修改版本。以及输入像素的略微修改版本,因为它们是通过这里上来的。所以我们拥有所有向下和向上的丰富性,但也有一个略微不那么粗糙的版本,然后是一个略微不那么粗糙的版本,然后是一个真正简单的版本,它们都可以组合在一起。这就是 U-Net。这是一个很酷的想法。

我们在 carvana-unet 笔记本中。所有这些与之前的代码相同。

%matplotlib inline
%reload_ext autoreload
%autoreload 2
from fastai.conv_learner import *
from fastai.dataset import *
from fastai.models.resnet import vgg_resnet50

import jsontorch.backends.cudnn.benchmark=True

数据

PATH = Path('data/carvana')
MASKS_FN = 'train_masks.csv'
META_FN = 'metadata.csv'
masks_csv = pd.read_csv(PATH/MASKS_FN)
meta_csv = pd.read_csv(PATH/META_FN)
def show_img(im, figsize=None, ax=None, alpha=None):
    if not ax: 
        fig,ax = plt.subplots(figsize=figsize)
    ax.imshow(im, alpha=alpha)
    ax.set_axis_off()
    return axTRAIN_DN = 'train-128'
MASKS_DN = 'train_masks-128'
sz = 128
bs = 64
nw = 16
TRAIN_DN = 'train'
MASKS_DN = 'train_masks_png'
sz = 128
bs = 64
nw = 16
class MatchedFilesDataset(FilesDataset):
    def __init__(self, fnames, y, transform, path):
        self.y=y
        assert(len(fnames)==len(y))
        super().__init__(fnames, transform, path)
    def get_y(self, i): 
        return open_image(os.path.join(self.path, self.y[i]))
    def get_c(self): 
        return 0
x_names = np.array([Path(TRAIN_DN)/o for o in masks_csv['img']])
y_names = np.array([
    Path(MASKS_DN)/f'{o[:-4]}_mask.png' 
    for o in masks_csv['img']
])
val_idxs = list(range(1008))
((val_x,trn_x),(val_y,trn_y)) = split_by_idx(val_idxs, x_names, y_names)
aug_tfms = [
    RandomRotate(4, tfm_y=TfmType.CLASS),
    RandomFlip(tfm_y=TfmType.CLASS),
    RandomLighting(0.05, 0.05, tfm_y=TfmType.CLASS)
]
tfms = tfms_from_model(
    esnet34, sz, 
    crop_type=CropType.NO, 
    tfm_y=TfmType.CLASS, 
    aug_tfms=aug_tfms
)
datasets = ImageData.get_ds(
    MatchedFilesDataset, 
    (trn_x,trn_y), 
    (val_x,val_y), 
    tfms, 
    ath=PATH
)
md = ImageData(PATH, datasets, bs, num_workers=16, classes=None)
denorm = md.trn_ds.denorm
x,y = next(iter(md.trn_dl))
x.shape,y.shape
'''
(torch.Size([64, 3, 128, 128]), torch.Size([64, 128, 128]))
'''

简单的上采样

一开始,我有一个简单的上采样版本,只是为了再次向你展示非 U-net 版本。这次,我将加入一个称为 dice 指标的东西。Dice 非常类似,如你所见,与 Jaccard 或 I over U 非常相似。只是有一点小差别。基本上是交集除以并集,稍微调整了一下。我们要使用 dice 的原因是 Kaggle 竞赛使用了这个指标,而且要获得高 dice 分数比获得高准确度要困难一些,因为它真的在看正确像素与你的像素的重叠部分。但它非常相似。

在 Kaggle 竞赛中,表现良好的人得到了大约 99.6 点,而获胜者得到了大约 99.7 点。

f = resnet34
cut,lr_cut = model_meta[f]def get_base():
    layers = cut_model(f(True), cut)
    return nn.Sequential(*layers)
def dice(pred, targs):
    pred = (pred>0).float()
    return 2. * (pred*targs).sum() / (pred+targs).sum()

这是我们的标准上采样。

class StdUpsample(nn.Module):
    def __init__(self, nin, nout):
        super().__init__()
        self.conv = nn.ConvTranspose2d(nin, nout, 2, stride=2)
        self.bn = nn.BatchNorm2d(nout)

    def forward(self, x): 
        return self.bn(F.relu(self.conv(x)))

这一切和以前一样。

class Upsample34(nn.Module):
    def __init__(self, rn):
        super().__init__()
        self.rn = rn
        self.features = nn.Sequential(
            rn, nn.ReLU(),
            StdUpsample(512,256),
            StdUpsample(256,256),
            StdUpsample(256,256),
            StdUpsample(256,256),
            nn.ConvTranspose2d(256, 1, 2, stride=2)
        )

    def forward(self,x): 
        return self.features(x)[:,0]
class UpsampleModel():
    def __init__(self,model,name='upsample'):
        self.model,self.name = model,name

    def get_layer_groups(self, precompute):
        lgs = list(split_by_idxs(children(self.model.rn), [lr_cut]))
        return lgs + [children(self.model.features)[1:]]
m_base = get_base() 
m = to_gpu(Upsample34(m_base))
models = UpsampleModel(m)
learn = ConvLearner(md, models)
learn.opt_fn=optim.Adam
learn.crit=nn.BCEWithLogitsLoss()
learn.metrics=[accuracy_thresh(0.5),dice]
learn.freeze_to(1)
learn.lr_find()
learn.sched.plot()
'''
86%|█████████████████████████████████████████████████████████████          | 55/64 [00:22<00:03,  2.46it/s, loss=3.21]
'''

lr=4e-2
wd=1e-7
lrs = np.array([lr/100,lr/10,lr])/2
learn.fit(lr,1, wds=wd, cycle_len=4,use_clr=(20,8))
'''
0%|          | 0/64 [00:00<?, ?it/s]
epoch      trn_loss   val_loss   <lambda>   dice           
    0      0.216882   0.133512   0.938017   0.855221  
    1      0.169544   0.115158   0.946518   0.878381       
    2      0.153114   0.099104   0.957748   0.903353       
    3      0.144105   0.093337   0.964404   0.915084
[0.09333742126112893, 0.9644036065964472, 0.9150839788573129]
'''
learn.save('tmp')
learn.load('tmp')
learn.unfreeze()
learn.bn_freeze(True)
learn.fit(lrs,1,cycle_len=4,use_clr=(20,8))
'''
epoch      trn_loss   val_loss   <lambda>   dice           
    0      0.174897   0.061603   0.976321   0.94382   
    1      0.122911   0.053625   0.982206   0.957624       
    2      0.106837   0.046653   0.985577   0.965792       
    3      0.099075   0.042291   0.986519   0.968925
[0.042291240323157536, 0.986519161670927, 0.9689251193924556]
'''

现在我们可以检查我们的 dice 指标[1:48:00]。所以你可以看到在 dice 指标上,我们在 128x128 处得到了大约 96.8。所以这不太好。

learn.save('128')
x,y = next(iter(md.val_dl))
py = to_np(learn.model(V(x)))
show_img(py[0]>0);

show_img(y[0]);

U-net(ish)[1:48:16]

所以让我们尝试 U-Net。我称之为 U-net(ish),因为通常我正在创建自己的有点 hacky 版本——尽量保持与你习惯的东西尽可能相似,并做我认为有意义的事情。所以至少有很多机会让你至少通过查看确切的网格大小来使其更加真实地成为 U-net,看看这里(左上角的卷积)大小有点下降。所以显然他们没有添加任何填充,然后有一些裁剪——有一些差异。但其中一件事是因为我想利用迁移学习——这意味着我不能完全使用 U-Net。

所以另一个重要的机会是,如果你创建了 U-Net 的下行路径,然后在末尾添加一个分类器,然后在 ImageNet 上训练它。现在你有了一个在 ImageNet 上训练过的分类器,专门设计为 U-Net 的良好骨干。然后你应该能够回来并接近赢得这个旧竞赛(实际上并不是很旧——是一个相当新的竞赛)。因为以前不存在这种预训练网络。但是如果你想一下 YOLO v3 是如何做的,基本上就是这样。他们创建了一个 DarkNet,他们在 ImageNet 上预训练了它,然后他们将其用作边界框的基础。所以,再次强调这种不仅为分类而设计而且为其他事物而设计的预训练的想法——这是迄今为止没有人做过的事情。但正如我们所展示的,你现在可以用 25 美元在三小时内训练 ImageNet。如果社区中的人们对此感兴趣,希望我也能提供帮助,如果你愿意,我可以帮助你设置并给我一个脚本,我可能可以为你运行它。但目前我们还没有。所以我们将使用 ResNet。

class SaveFeatures():
    features=None
    def __init__(self, m):
        self.hook = m.register_forward_hook(self.hook_fn)
    def hook_fn(self, module, input, output): 
        self.features = output
    def remove(self): 
        self.hook.remove()

所以我们基本上要从get_base开始[1:50:37]。Base 是我们的基础网络,这在第一部分中已经定义过了。

所以get_base将调用f是什么,fresnet34。所以我们将获取我们的 ResNet34 并且cut_model是我们的卷积网络构建器做的第一件事。它基本上删除了自适应池化之后的所有内容,这样我们就得到了 ResNet34 的骨干。所以get_base将给我们返回 ResNet34 的骨干。

class UnetBlock(nn.Module):
    def __init__(self, up_in, x_in, n_out):
        super().__init__()
        up_out = x_out = n_out//2
        self.x_conv  = nn.Conv2d(x_in,  x_out,  1)
        self.tr_conv = nn.ConvTranspose2d(up_in, up_out, 2, stride=2)
        self.bn = nn.BatchNorm2d(n_out)

    def forward(self, up_p, x_p):
        up_p = self.tr_conv(up_p)
        x_p = self.x_conv(x_p)
        cat_p = torch.cat([up_p,x_p], dim=1)
        return self.bn(F.relu(cat_p))class Unet34(nn.Module):
    def __init__(self, rn):
        super().__init__()
        self.rn = rn
        self.sfs = [SaveFeatures(rn[i]) for i in [2,4,5,6]]
        self.up1 = UnetBlock(512,256,256)
        self.up2 = UnetBlock(256,128,256)
        self.up3 = UnetBlock(256,64,256)
        self.up4 = UnetBlock(256,64,256)
        self.up5 = nn.ConvTranspose2d(256, 1, 2, stride=2)

    def forward(self,x):
        x = F.relu(self.rn(x))
        x = self.up1(x, self.sfs[3].features)
        x = self.up2(x, self.sfs[2].features)
        x = self.up3(x, self.sfs[1].features)
        x = self.up4(x, self.sfs[0].features)
        x = self.up5(x)
        return x[:,0]

    def close(self):
        for sf in self.sfs: 
            sf.remove()
class UnetModel():
    def __init__(self,model,name='unet'):
        self.model,self.name = model,name

    def get_layer_groups(self, precompute):
        lgs = list(split_by_idxs(children(self.model.rn), [lr_cut]))
        return lgs + [children(self.model)[1:]]

然后我们将把那个 ResNet34 主干转换成一个,我称之为 Unet34。因此,它将保存我们传入的 ResNet,然后我们将使用一个前向钩子,就像以前一样,在第 2、4、5 和 6 个块处保存结果,这些块是每个步幅 2 卷积之前的层。然后我们将创建一堆我们称之为UnetBlock的东西。我们需要告诉UnetBlock有多少东西来自我们要上采样的上一层,有多少来自交叉路径,然后我们想要输出多少。来自上一层的数量完全由基础网络定义——无论下行路径是什么,我们都需要那么多层。这有点尴尬。实际上我们这里的一个硕士学生,Kerem,实际上创建了一个叫做 DynamicUnet 的东西,你可以在fastai.model.DynamicUnet中找到,它实际上为你计算这一切,并自动从你的基础模型创建整个 Unet。它仍然有一些小问题,我想要修复。视频发布时,它肯定会正常工作,我至少会有一个展示如何使用它的笔记本,可能还有一个额外的视频。但现在你只能自己去做。一旦你有了一个 ResNet,你可以输入它的名称,它会打印出层。你可以看到每个块中有多少激活。或者你可以让它自动为每个块打印出来。无论如何,我只是手动做了这个。

所以 UnetBlock 的工作原理是这样的:

  • up_in:从上一层传入的数量

  • x_in:从下行路径传入的数量(因此x

  • n_out:我们想要输出的数量

现在我要做的是,然后我说,好的,我们将从上行路径创建一定数量的卷积,从交叉路径创建一定数量的卷积,所以我将它们连接在一起,所以让我们将我们想要的数量除以 2。因此,我们将让我们的交叉卷积从交叉路径中取出并除以 2(n_out//2)。然后上行路径将是ConvTranspose2d,因为我们想要增加/上采样。同样在这里,我们将我们想要的数量除以 2(up_out),然后最后,我只是将它们连接在一起。

所以我有一个上升样本,我有一个交叉卷积,我可以将这两者连接在一起。这就是 UnetBlock 的全部内容。所以这实际上是一个相当容易创建的模块。

然后在我的前向路径中,我需要将上升路径和交叉路径传递给 UnetBlock 的前向方法。上升路径只是到目前为止的任何事情。但是交叉路径是在下降过程中存储的激活。因此,当我上升时,我首先需要的是最后一组保存的特征。随着我逐渐向上走得更远,最终是第一组特征。

有一些更多的技巧可以让这个变得更好一点,但这已经是一个很好的东西了。所以简单的上采样方法看起来很糟糕,dice 值为 0.968。一个 Unet,除了现在我们有了这些 UnetBlocks 之外,其他一切都相同,dice 值为…

m_base = get_base()
m = to_gpu(Unet34(m_base))
models = UnetModel(m)
learn = ConvLearner(md, models)
learn.opt_fn=optim.Adam
learn.crit=nn.BCEWithLogitsLoss()
learn.metrics=[accuracy_thresh(0.5),dice]
learn.summary()
'''
OrderedDict([('Conv2d-1',
              OrderedDict([('input_shape', [-1, 3, 128, 128]),
                           ('output_shape', [-1, 64, 64, 64]),
                           ('trainable', False),
                           ('nb_params', 9408)])),
             ('BatchNorm2d-2',
              OrderedDict([('input_shape', [-1, 64, 64, 64]),
                           ('output_shape', [-1, 64, 64, 64]),
                           ('trainable', False),
                           ('nb_params', 128)])),
             ('ReLU-3',
              OrderedDict([('input_shape', [-1, 64, 64, 64]),
                           ('output_shape', [-1, 64, 64, 64]),
                           ('nb_params', 0)])),
             ('MaxPool2d-4',
              OrderedDict([('input_shape', [-1, 64, 64, 64]),
                           ('output_shape', [-1, 64, 32, 32]),
                           ('nb_params', 0)])),
             ('Conv2d-5',
              OrderedDict([('input_shape', [-1, 64, 32, 32]),
                           ('output_shape', [-1, 64, 32, 32]),
                           ('trainable', False),
                           ('nb_params', 36864)])),
             ('BatchNorm2d-6',
              OrderedDict([('input_shape', [-1, 64, 32, 32]),
                           ('output_shape', [-1, 64, 32, 32]),
                           ('trainable', False),
                           ('nb_params', 128)])),
             ('ReLU-7',
              OrderedDict([('input_shape', [-1, 64, 32, 32]),
                           ('output_shape', [-1, 64, 32, 32]),
                           ('nb_params', 0)])),
             ('Conv2d-8',
              OrderedDict([('input_shape', [-1, 64, 32, 32]),
                           ('output_shape', [-1, 64, 32, 32]),
                           ('trainable', False),
                           ('nb_params', 36864)])),
             ('BatchNorm2d-9',
              OrderedDict([('input_shape', [-1, 64, 32, 32]),
                           ('output_shape', [-1, 64, 32, 32]),
                           ('trainable', False),
                           ('nb_params', 128)])),
             ('ReLU-10',
              OrderedDict([('input_shape', [-1, 64, 32, 32]),
                           ('output_shape', [-1, 64, 32, 32]),
                           ('nb_params', 0)])),
             ('BasicBlock-11',
              OrderedDict([('input_shape', [-1, 64, 32, 32]),
                           ('output_shape', [-1, 64, 32, 32]),
                           ('nb_params', 0)])),
             ('Conv2d-12',
              OrderedDict([('input_shape', [-1, 64, 32, 32]),
                           ('output_shape', [-1, 64, 32, 32]),
                           ('trainable', False),
                           ('nb_params', 36864)])),
             ('BatchNorm2d-13',
              OrderedDict([('input_shape', [-1, 64, 32, 32]),
                           ('output_shape', [-1, 64, 32, 32]),
                           ('trainable', False),
                           ('nb_params', 128)])),
             ('ReLU-14',
              OrderedDict([('input_shape', [-1, 64, 32, 32]),
                           ('output_shape', [-1, 64, 32, 32]),
                           ('nb_params', 0)])),
             ('Conv2d-15',
              OrderedDict([('input_shape', [-1, 64, 32, 32]),
                           ('output_shape', [-1, 64, 32, 32]),
                           ('trainable', False),
                           ('nb_params', 36864)])),
             ('BatchNorm2d-16',
              OrderedDict([('input_shape', [-1, 64, 32, 32]),
                           ('output_shape', [-1, 64, 32, 32]),
                           ('trainable', False),
                           ('nb_params', 128)])),
             ('ReLU-17',
              OrderedDict([('input_shape', [-1, 64, 32, 32]),
                           ('output_shape', [-1, 64, 32, 32]),
                           ('nb_params', 0)])),
             ('BasicBlock-18',
              OrderedDict([('input_shape', [-1, 64, 32, 32]),
                           ('output_shape', [-1, 64, 32, 32]),
                           ('nb_params', 0)])),
             ('Conv2d-19',
              OrderedDict([('input_shape', [-1, 64, 32, 32]),
                           ('output_shape', [-1, 64, 32, 32]),
                           ('trainable', False),
                           ('nb_params', 36864)])),
             ('BatchNorm2d-20',
              OrderedDict([('input_shape', [-1, 64, 32, 32]),
                           ('output_shape', [-1, 64, 32, 32]),
                           ('trainable', False),
                           ('nb_params', 128)])),
             ('ReLU-21',
              OrderedDict([('input_shape', [-1, 64, 32, 32]),
                           ('output_shape', [-1, 64, 32, 32]),
                           ('nb_params', 0)])),
             ('Conv2d-22',
              OrderedDict([('input_shape', [-1, 64, 32, 32]),
                           ('output_shape', [-1, 64, 32, 32]),
                           ('trainable', False),
                           ('nb_params', 36864)])),
             ('BatchNorm2d-23',
              OrderedDict([('input_shape', [-1, 64, 32, 32]),
                           ('output_shape', [-1, 64, 32, 32]),
                           ('trainable', False),
                           ('nb_params', 128)])),
             ('ReLU-24',
              OrderedDict([('input_shape', [-1, 64, 32, 32]),
                           ('output_shape', [-1, 64, 32, 32]),
                           ('nb_params', 0)])),
             ('BasicBlock-25',
              OrderedDict([('input_shape', [-1, 64, 32, 32]),
                           ('output_shape', [-1, 64, 32, 32]),
                           ('nb_params', 0)])),
             ('Conv2d-26',
              OrderedDict([('input_shape', [-1, 64, 32, 32]),
                           ('output_shape', [-1, 128, 16, 16]),
                           ('trainable', False),
                           ('nb_params', 73728)])),
             ('BatchNorm2d-27',
              OrderedDict([('input_shape', [-1, 128, 16, 16]),
                           ('output_shape', [-1, 128, 16, 16]),
                           ('trainable', False),
                           ('nb_params', 256)])),
             ('ReLU-28',
              OrderedDict([('input_shape', [-1, 128, 16, 16]),
                           ('output_shape', [-1, 128, 16, 16]),
                           ('nb_params', 0)])),
             ('Conv2d-29',
              OrderedDict([('input_shape', [-1, 128, 16, 16]),
                           ('output_shape', [-1, 128, 16, 16]),
                           ('trainable', False),
                           ('nb_params', 147456)])),
             ('BatchNorm2d-30',
              OrderedDict([('input_shape', [-1, 128, 16, 16]),
                           ('output_shape', [-1, 128, 16, 16]),
                           ('trainable', False),
                           ('nb_params', 256)])),
             ('Conv2d-31',
              OrderedDict([('input_shape', [-1, 64, 32, 32]),
                           ('output_shape', [-1, 128, 16, 16]),
                           ('trainable', False),
                           ('nb_params', 8192)])),
             ('BatchNorm2d-32',
              OrderedDict([('input_shape', [-1, 128, 16, 16]),
                           ('output_shape', [-1, 128, 16, 16]),
                           ('trainable', False),
                           ('nb_params', 256)])),
             ('ReLU-33',
              OrderedDict([('input_shape', [-1, 128, 16, 16]),
                           ('output_shape', [-1, 128, 16, 16]),
                           ('nb_params', 0)])),
             ('BasicBlock-34',
              OrderedDict([('input_shape', [-1, 64, 32, 32]),
                           ('output_shape', [-1, 128, 16, 16]),
                           ('nb_params', 0)])),
             ('Conv2d-35',
              OrderedDict([('input_shape', [-1, 128, 16, 16]),
                           ('output_shape', [-1, 128, 16, 16]),
                           ('trainable', False),
                           ('nb_params', 147456)])),
             ('BatchNorm2d-36',
              OrderedDict([('input_shape', [-1, 128, 16, 16]),
                           ('output_shape', [-1, 128, 16, 16]),
                           ('trainable', False),
                           ('nb_params', 256)])),
             ('ReLU-37',
              OrderedDict([('input_shape', [-1, 128, 16, 16]),
                           ('output_shape', [-1, 128, 16, 16]),
                           ('nb_params', 0)])),
             ('Conv2d-38',
              OrderedDict([('input_shape', [-1, 128, 16, 16]),
                           ('output_shape', [-1, 128, 16, 16]),
                           ('trainable', False),
                           ('nb_params', 147456)])),
             ('BatchNorm2d-39',
              OrderedDict([('input_shape', [-1, 128, 16, 16]),
                           ('output_shape', [-1, 128, 16, 16]),
                           ('trainable', False),
                           ('nb_params', 256)])),
             ('ReLU-40',
              OrderedDict([('input_shape', [-1, 128, 16, 16]),
                           ('output_shape', [-1, 128, 16, 16]),
                           ('nb_params', 0)])),
             ('BasicBlock-41',
              OrderedDict([('input_shape', [-1, 128, 16, 16]),
                           ('output_shape', [-1, 128, 16, 16]),
                           ('nb_params', 0)])),
             ('Conv2d-42',
              OrderedDict([('input_shape', [-1, 128, 16, 16]),
                           ('output_shape', [-1, 128, 16, 16]),
                           ('trainable', False),
                           ('nb_params', 147456)])),
             ('BatchNorm2d-43',
              OrderedDict([('input_shape', [-1, 128, 16, 16]),
                           ('output_shape', [-1, 128, 16, 16]),
                           ('trainable', False),
                           ('nb_params', 256)])),
             ('ReLU-44',
              OrderedDict([('input_shape', [-1, 128, 16, 16]),
                           ('output_shape', [-1, 128, 16, 16]),
                           ('nb_params', 0)])),
             ('Conv2d-45',
              OrderedDict([('input_shape', [-1, 128, 16, 16]),
                           ('output_shape', [-1, 128, 16, 16]),
                           ('trainable', False),
                           ('nb_params', 147456)])),
             ('BatchNorm2d-46',
              OrderedDict([('input_shape', [-1, 128, 16, 16]),
                           ('output_shape', [-1, 128, 16, 16]),
                           ('trainable', False),
                           ('nb_params', 256)])),
             ('ReLU-47',
              OrderedDict([('input_shape', [-1, 128, 16, 16]),
                           ('output_shape', [-1, 128, 16, 16]),
                           ('nb_params', 0)])),
             ('BasicBlock-48',
              OrderedDict([('input_shape', [-1, 128, 16, 16]),
                           ('output_shape', [-1, 128, 16, 16]),
                           ('nb_params', 0)])),
             ('Conv2d-49',
              OrderedDict([('input_shape', [-1, 128, 16, 16]),
                           ('output_shape', [-1, 128, 16, 16]),
                           ('trainable', False),
                           ('nb_params', 147456)])),
             ('BatchNorm2d-50',
              OrderedDict([('input_shape', [-1, 128, 16, 16]),
                           ('output_shape', [-1, 128, 16, 16]),
                           ('trainable', False),
                           ('nb_params', 256)])),
             ('ReLU-51',
              OrderedDict([('input_shape', [-1, 128, 16, 16]),
                           ('output_shape', [-1, 128, 16, 16]),
                           ('nb_params', 0)])),
             ('Conv2d-52',
              OrderedDict([('input_shape', [-1, 128, 16, 16]),
                           ('output_shape', [-1, 128, 16, 16]),
                           ('trainable', False),
                           ('nb_params', 147456)])),
             ('BatchNorm2d-53',
              OrderedDict([('input_shape', [-1, 128, 16, 16]),
                           ('output_shape', [-1, 128, 16, 16]),
                           ('trainable', False),
                           ('nb_params', 256)])),
             ('ReLU-54',
              OrderedDict([('input_shape', [-1, 128, 16, 16]),
                           ('output_shape', [-1, 128, 16, 16]),
                           ('nb_params', 0)])),
             ('BasicBlock-55',
              OrderedDict([('input_shape', [-1, 128, 16, 16]),
                           ('output_shape', [-1, 128, 16, 16]),
                           ('nb_params', 0)])),
             ('Conv2d-56',
              OrderedDict([('input_shape', [-1, 128, 16, 16]),
                           ('output_shape', [-1, 256, 8, 8]),
                           ('trainable', False),
                           ('nb_params', 294912)])),
             ('BatchNorm2d-57',
              OrderedDict([('input_shape', [-1, 256, 8, 8]),
                           ('output_shape', [-1, 256, 8, 8]),
                           ('trainable', False),
                           ('nb_params', 512)])),
             ('ReLU-58',
              OrderedDict([('input_shape', [-1, 256, 8, 8]),
                           ('output_shape', [-1, 256, 8, 8]),
                           ('nb_params', 0)])),
             ('Conv2d-59',
              OrderedDict([('input_shape', [-1, 256, 8, 8]),
                           ('output_shape', [-1, 256, 8, 8]),
                           ('trainable', False),
                           ('nb_params', 589824)])),
             ('BatchNorm2d-60',
              OrderedDict([('input_shape', [-1, 256, 8, 8]),
                           ('output_shape', [-1, 256, 8, 8]),
                           ('trainable', False),
                           ('nb_params', 512)])),
             ('Conv2d-61',
              OrderedDict([('input_shape', [-1, 128, 16, 16]),
                           ('output_shape', [-1, 256, 8, 8]),
                           ('trainable', False),
                           ('nb_params', 32768)])),
             ('BatchNorm2d-62',
              OrderedDict([('input_shape', [-1, 256, 8, 8]),
                           ('output_shape', [-1, 256, 8, 8]),
                           ('trainable', False),
                           ('nb_params', 512)])),
             ('ReLU-63',
              OrderedDict([('input_shape', [-1, 256, 8, 8]),
                           ('output_shape', [-1, 256, 8, 8]),
                           ('nb_params', 0)])),
             ('BasicBlock-64',
              OrderedDict([('input_shape', [-1, 128, 16, 16]),
                           ('output_shape', [-1, 256, 8, 8]),
                           ('nb_params', 0)])),
             ('Conv2d-65',
              OrderedDict([('input_shape', [-1, 256, 8, 8]),
                           ('output_shape', [-1, 256, 8, 8]),
                           ('trainable', False),
                           ('nb_params', 589824)])),
             ('BatchNorm2d-66',
              OrderedDict([('input_shape', [-1, 256, 8, 8]),
                           ('output_shape', [-1, 256, 8, 8]),
                           ('trainable', False),
                           ('nb_params', 512)])),
             ('ReLU-67',
              OrderedDict([('input_shape', [-1, 256, 8, 8]),
                           ('output_shape', [-1, 256, 8, 8]),
                           ('nb_params', 0)])),
             ('Conv2d-68',
              OrderedDict([('input_shape', [-1, 256, 8, 8]),
                           ('output_shape', [-1, 256, 8, 8]),
                           ('trainable', False),
                           ('nb_params', 589824)])),
             ('BatchNorm2d-69',
              OrderedDict([('input_shape', [-1, 256, 8, 8]),
                           ('output_shape', [-1, 256, 8, 8]),
                           ('trainable', False),
                           ('nb_params', 512)])),
             ('ReLU-70',
              OrderedDict([('input_shape', [-1, 256, 8, 8]),
                           ('output_shape', [-1, 256, 8, 8]),
                           ('nb_params', 0)])),
             ('BasicBlock-71',
              OrderedDict([('input_shape', [-1, 256, 8, 8]),
                           ('output_shape', [-1, 256, 8, 8]),
                           ('nb_params', 0)])),
             ('Conv2d-72',
              OrderedDict([('input_shape', [-1, 256, 8, 8]),
                           ('output_shape', [-1, 256, 8, 8]),
                           ('trainable', False),
                           ('nb_params', 589824)])),
             ('BatchNorm2d-73',
              OrderedDict([('input_shape', [-1, 256, 8, 8]),
                           ('output_shape', [-1, 256, 8, 8]),
                           ('trainable', False),
                           ('nb_params', 512)])),
             ('ReLU-74',
              OrderedDict([('input_shape', [-1, 256, 8, 8]),
                           ('output_shape', [-1, 256, 8, 8]),
                           ('nb_params', 0)])),
             ('Conv2d-75',
              OrderedDict([('input_shape', [-1, 256, 8, 8]),
                           ('output_shape', [-1, 256, 8, 8]),
                           ('trainable', False),
                           ('nb_params', 589824)])),
             ('BatchNorm2d-76',
              OrderedDict([('input_shape', [-1, 256, 8, 8]),
                           ('output_shape', [-1, 256, 8, 8]),
                           ('trainable', False),
                           ('nb_params', 512)])),
             ('ReLU-77',
              OrderedDict([('input_shape', [-1, 256, 8, 8]),
                           ('output_shape', [-1, 256, 8, 8]),
                           ('nb_params', 0)])),
             ('BasicBlock-78',
              OrderedDict([('input_shape', [-1, 256, 8, 8]),
                           ('output_shape', [-1, 256, 8, 8]),
                           ('nb_params', 0)])),
             ('Conv2d-79',
              OrderedDict([('input_shape', [-1, 256, 8, 8]),
                           ('output_shape', [-1, 256, 8, 8]),
                           ('trainable', False),
                           ('nb_params', 589824)])),
             ('BatchNorm2d-80',
              OrderedDict([('input_shape', [-1, 256, 8, 8]),
                           ('output_shape', [-1, 256, 8, 8]),
                           ('trainable', False),
                           ('nb_params', 512)])),
             ('ReLU-81',
              OrderedDict([('input_shape', [-1, 256, 8, 8]),
                           ('output_shape', [-1, 256, 8, 8]),
                           ('nb_params', 0)])),
             ('Conv2d-82',
              OrderedDict([('input_shape', [-1, 256, 8, 8]),
                           ('output_shape', [-1, 256, 8, 8]),
                           ('trainable', False),
                           ('nb_params', 589824)])),
             ('BatchNorm2d-83',
              OrderedDict([('input_shape', [-1, 256, 8, 8]),
                           ('output_shape', [-1, 256, 8, 8]),
                           ('trainable', False),
                           ('nb_params', 512)])),
             ('ReLU-84',
              OrderedDict([('input_shape', [-1, 256, 8, 8]),
                           ('output_shape', [-1, 256, 8, 8]),
                           ('nb_params', 0)])),
             ('BasicBlock-85',
              OrderedDict([('input_shape', [-1, 256, 8, 8]),
                           ('output_shape', [-1, 256, 8, 8]),
                           ('nb_params', 0)])),
             ('Conv2d-86',
              OrderedDict([('input_shape', [-1, 256, 8, 8]),
                           ('output_shape', [-1, 256, 8, 8]),
                           ('trainable', False),
                           ('nb_params', 589824)])),
             ('BatchNorm2d-87',
              OrderedDict([('input_shape', [-1, 256, 8, 8]),
                           ('output_shape', [-1, 256, 8, 8]),
                           ('trainable', False),
                           ('nb_params', 512)])),
             ('ReLU-88',
              OrderedDict([('input_shape', [-1, 256, 8, 8]),
                           ('output_shape', [-1, 256, 8, 8]),
                           ('nb_params', 0)])),
             ('Conv2d-89',
              OrderedDict([('input_shape', [-1, 256, 8, 8]),
                           ('output_shape', [-1, 256, 8, 8]),
                           ('trainable', False),
                           ('nb_params', 589824)])),
             ('BatchNorm2d-90',
              OrderedDict([('input_shape', [-1, 256, 8, 8]),
                           ('output_shape', [-1, 256, 8, 8]),
                           ('trainable', False),
                           ('nb_params', 512)])),
             ('ReLU-91',
              OrderedDict([('input_shape', [-1, 256, 8, 8]),
                           ('output_shape', [-1, 256, 8, 8]),
                           ('nb_params', 0)])),
             ('BasicBlock-92',
              OrderedDict([('input_shape', [-1, 256, 8, 8]),
                           ('output_shape', [-1, 256, 8, 8]),
                           ('nb_params', 0)])),
             ('Conv2d-93',
              OrderedDict([('input_shape', [-1, 256, 8, 8]),
                           ('output_shape', [-1, 256, 8, 8]),
                           ('trainable', False),
                           ('nb_params', 589824)])),
             ('BatchNorm2d-94',
              OrderedDict([('input_shape', [-1, 256, 8, 8]),
                           ('output_shape', [-1, 256, 8, 8]),
                           ('trainable', False),
                           ('nb_params', 512)])),
             ('ReLU-95',
              OrderedDict([('input_shape', [-1, 256, 8, 8]),
                           ('output_shape', [-1, 256, 8, 8]),
                           ('nb_params', 0)])),
             ('Conv2d-96',
              OrderedDict([('input_shape', [-1, 256, 8, 8]),
                           ('output_shape', [-1, 256, 8, 8]),
                           ('trainable', False),
                           ('nb_params', 589824)])),
             ('BatchNorm2d-97',
              OrderedDict([('input_shape', [-1, 256, 8, 8]),
                           ('output_shape', [-1, 256, 8, 8]),
                           ('trainable', False),
                           ('nb_params', 512)])),
             ('ReLU-98',
              OrderedDict([('input_shape', [-1, 256, 8, 8]),
                           ('output_shape', [-1, 256, 8, 8]),
                           ('nb_params', 0)])),
             ('BasicBlock-99',
              OrderedDict([('input_shape', [-1, 256, 8, 8]),
                           ('output_shape', [-1, 256, 8, 8]),
                           ('nb_params', 0)])),
             ('Conv2d-100',
              OrderedDict([('input_shape', [-1, 256, 8, 8]),
                           ('output_shape', [-1, 512, 4, 4]),
                           ('trainable', False),
                           ('nb_params', 1179648)])),
             ('BatchNorm2d-101',
              OrderedDict([('input_shape', [-1, 512, 4, 4]),
                           ('output_shape', [-1, 512, 4, 4]),
                           ('trainable', False),
                           ('nb_params', 1024)])),
             ('ReLU-102',
              OrderedDict([('input_shape', [-1, 512, 4, 4]),
                           ('output_shape', [-1, 512, 4, 4]),
                           ('nb_params', 0)])),
             ('Conv2d-103',
              OrderedDict([('input_shape', [-1, 512, 4, 4]),
                           ('output_shape', [-1, 512, 4, 4]),
                           ('trainable', False),
                           ('nb_params', 2359296)])),
             ('BatchNorm2d-104',
              OrderedDict([('input_shape', [-1, 512, 4, 4]),
                           ('output_shape', [-1, 512, 4, 4]),
                           ('trainable', False),
                           ('nb_params', 1024)])),
             ('Conv2d-105',
              OrderedDict([('input_shape', [-1, 256, 8, 8]),
                           ('output_shape', [-1, 512, 4, 4]),
                           ('trainable', False),
                           ('nb_params', 131072)])),
             ('BatchNorm2d-106',
              OrderedDict([('input_shape', [-1, 512, 4, 4]),
                           ('output_shape', [-1, 512, 4, 4]),
                           ('trainable', False),
                           ('nb_params', 1024)])),
             ('ReLU-107',
              OrderedDict([('input_shape', [-1, 512, 4, 4]),
                           ('output_shape', [-1, 512, 4, 4]),
                           ('nb_params', 0)])),
             ('BasicBlock-108',
              OrderedDict([('input_shape', [-1, 256, 8, 8]),
                           ('output_shape', [-1, 512, 4, 4]),
                           ('nb_params', 0)])),
             ('Conv2d-109',
              OrderedDict([('input_shape', [-1, 512, 4, 4]),
                           ('output_shape', [-1, 512, 4, 4]),
                           ('trainable', False),
                           ('nb_params', 2359296)])),
             ('BatchNorm2d-110',
              OrderedDict([('input_shape', [-1, 512, 4, 4]),
                           ('output_shape', [-1, 512, 4, 4]),
                           ('trainable', False),
                           ('nb_params', 1024)])),
             ('ReLU-111',
              OrderedDict([('input_shape', [-1, 512, 4, 4]),
                           ('output_shape', [-1, 512, 4, 4]),
                           ('nb_params', 0)])),
             ('Conv2d-112',
              OrderedDict([('input_shape', [-1, 512, 4, 4]),
                           ('output_shape', [-1, 512, 4, 4]),
                           ('trainable', False),
                           ('nb_params', 2359296)])),
             ('BatchNorm2d-113',
              OrderedDict([('input_shape', [-1, 512, 4, 4]),
                           ('output_shape', [-1, 512, 4, 4]),
                           ('trainable', False),
                           ('nb_params', 1024)])),
             ('ReLU-114',
              OrderedDict([('input_shape', [-1, 512, 4, 4]),
                           ('output_shape', [-1, 512, 4, 4]),
                           ('nb_params', 0)])),
             ('BasicBlock-115',
              OrderedDict([('input_shape', [-1, 512, 4, 4]),
                           ('output_shape', [-1, 512, 4, 4]),
                           ('nb_params', 0)])),
             ('Conv2d-116',
              OrderedDict([('input_shape', [-1, 512, 4, 4]),
                           ('output_shape', [-1, 512, 4, 4]),
                           ('trainable', False),
                           ('nb_params', 2359296)])),
             ('BatchNorm2d-117',
              OrderedDict([('input_shape', [-1, 512, 4, 4]),
                           ('output_shape', [-1, 512, 4, 4]),
                           ('trainable', False),
                           ('nb_params', 1024)])),
             ('ReLU-118',
              OrderedDict([('input_shape', [-1, 512, 4, 4]),
                           ('output_shape', [-1, 512, 4, 4]),
                           ('nb_params', 0)])),
             ('Conv2d-119',
              OrderedDict([('input_shape', [-1, 512, 4, 4]),
                           ('output_shape', [-1, 512, 4, 4]),
                           ('trainable', False),
                           ('nb_params', 2359296)])),
             ('BatchNorm2d-120',
              OrderedDict([('input_shape', [-1, 512, 4, 4]),
                           ('output_shape', [-1, 512, 4, 4]),
                           ('trainable', False),
                           ('nb_params', 1024)])),
             ('ReLU-121',
              OrderedDict([('input_shape', [-1, 512, 4, 4]),
                           ('output_shape', [-1, 512, 4, 4]),
                           ('nb_params', 0)])),
             ('BasicBlock-122',
              OrderedDict([('input_shape', [-1, 512, 4, 4]),
                           ('output_shape', [-1, 512, 4, 4]),
                           ('nb_params', 0)])),
             ('ConvTranspose2d-123',
              OrderedDict([('input_shape', [-1, 512, 4, 4]),
                           ('output_shape', [-1, 128, 8, 8]),
                           ('trainable', True),
                           ('nb_params', 262272)])),
             ('Conv2d-124',
              OrderedDict([('input_shape', [-1, 256, 8, 8]),
                           ('output_shape', [-1, 128, 8, 8]),
                           ('trainable', True),
                           ('nb_params', 32896)])),
             ('BatchNorm2d-125',
              OrderedDict([('input_shape', [-1, 256, 8, 8]),
                           ('output_shape', [-1, 256, 8, 8]),
                           ('trainable', True),
                           ('nb_params', 512)])),
             ('UnetBlock-126',
              OrderedDict([('input_shape', [-1, 512, 4, 4]),
                           ('output_shape', [-1, 256, 8, 8]),
                           ('nb_params', 0)])),
             ('ConvTranspose2d-127',
              OrderedDict([('input_shape', [-1, 256, 8, 8]),
                           ('output_shape', [-1, 128, 16, 16]),
                           ('trainable', True),
                           ('nb_params', 131200)])),
             ('Conv2d-128',
              OrderedDict([('input_shape', [-1, 128, 16, 16]),
                           ('output_shape', [-1, 128, 16, 16]),
                           ('trainable', True),
                           ('nb_params', 16512)])),
             ('BatchNorm2d-129',
              OrderedDict([('input_shape', [-1, 256, 16, 16]),
                           ('output_shape', [-1, 256, 16, 16]),
                           ('trainable', True),
                           ('nb_params', 512)])),
             ('UnetBlock-130',
              OrderedDict([('input_shape', [-1, 256, 8, 8]),
                           ('output_shape', [-1, 256, 16, 16]),
                           ('nb_params', 0)])),
             ('ConvTranspose2d-131',
              OrderedDict([('input_shape', [-1, 256, 16, 16]),
                           ('output_shape', [-1, 128, 32, 32]),
                           ('trainable', True),
                           ('nb_params', 131200)])),
             ('Conv2d-132',
              OrderedDict([('input_shape', [-1, 64, 32, 32]),
                           ('output_shape', [-1, 128, 32, 32]),
                           ('trainable', True),
                           ('nb_params', 8320)])),
             ('BatchNorm2d-133',
              OrderedDict([('input_shape', [-1, 256, 32, 32]),
                           ('output_shape', [-1, 256, 32, 32]),
                           ('trainable', True),
                           ('nb_params', 512)])),
             ('UnetBlock-134',
              OrderedDict([('input_shape', [-1, 256, 16, 16]),
                           ('output_shape', [-1, 256, 32, 32]),
                           ('nb_params', 0)])),
             ('ConvTranspose2d-135',
              OrderedDict([('input_shape', [-1, 256, 32, 32]),
                           ('output_shape', [-1, 128, 64, 64]),
                           ('trainable', True),
                           ('nb_params', 131200)])),
             ('Conv2d-136',
              OrderedDict([('input_shape', [-1, 64, 64, 64]),
                           ('output_shape', [-1, 128, 64, 64]),
                           ('trainable', True),
                           ('nb_params', 8320)])),
             ('BatchNorm2d-137',
              OrderedDict([('input_shape', [-1, 256, 64, 64]),
                           ('output_shape', [-1, 256, 64, 64]),
                           ('trainable', True),
                           ('nb_params', 512)])),
             ('UnetBlock-138',
              OrderedDict([('input_shape', [-1, 256, 32, 32]),
                           ('output_shape', [-1, 256, 64, 64]),
                           ('nb_params', 0)])),
             ('ConvTranspose2d-139',
              OrderedDict([('input_shape', [-1, 256, 64, 64]),
                           ('output_shape', [-1, 1, 128, 128]),
                           ('trainable', True),
                           ('nb_params', 1025)]))])
'''
[o.features.size() for o in m.sfs]
'''
[torch.Size([3, 64, 64, 64]),
 torch.Size([3, 64, 32, 32]),
 torch.Size([3, 128, 16, 16]),
 torch.Size([3, 256, 8, 8])]
'''
learn.freeze_to(1)learn.lr_find()
learn.sched.plot()
''' 0%|                                                                                           | 0/64 [00:00<?, ?it/s]92%|█████████████████████████████████████████████████████████████████▍     | 59/64 [00:22<00:01,  2.68it/s, loss=2.45]
'''

lr=4e-2
wd=1e-7

lrs = np.array([lr/100,lr/10,lr])
learn.fit(lr,1,wds=wd,cycle_len=8,use_clr=(5,8))
'''
epoch      trn_loss   val_loss   <lambda>   dice           
    0      0.12936    0.03934    0.988571   0.971385  
    1      0.098401   0.039252   0.990438   0.974921        
    2      0.087789   0.02539    0.990961   0.978927        
    3      0.082625   0.027984   0.988483   0.975948        
    4      0.079509   0.025003   0.99171    0.981221        
    5      0.076984   0.022514   0.992462   0.981881        
    6      0.076822   0.023203   0.992484   0.982321        
    7      0.075488   0.021956   0.992327   0.982704
[0.021955982234979434, 0.9923273126284281, 0.9827044502137199]
'''
learn.save('128urn-tmp')
learn.load('128urn-tmp')
learn.unfreeze()
learn.bn_freeze(True)
learn.fit(lrs/4, 1, wds=wd, cycle_len=20,use_clr=(20,10))
'''
0%|          | 0/64 [00:00<?, ?it/s]
epoch      trn_loss   val_loss   <lambda>   dice            
    0      0.073786   0.023418   0.99297    0.98283   
    1      0.073561   0.020853   0.992142   0.982725        
    2      0.075227   0.023357   0.991076   0.980879        
    3      0.074245   0.02352    0.993108   0.983659        
    4      0.073434   0.021508   0.993024   0.983609        
    5      0.073092   0.020956   0.993188   0.983333        
    6      0.073617   0.019666   0.993035   0.984102        
    7      0.072786   0.019844   0.993196   0.98435         
    8      0.072256   0.018479   0.993282   0.984277        
    9      0.072052   0.019479   0.993164   0.984147        
    10     0.071361   0.019402   0.993344   0.984541        
    11     0.070969   0.018904   0.993139   0.984499        
    12     0.071588   0.018027   0.9935     0.984543        
    13     0.070709   0.018345   0.993491   0.98489         
    14     0.072238   0.019096   0.993594   0.984825        
    15     0.071407   0.018967   0.993446   0.984919        
    16     0.071047   0.01966    0.993366   0.984952        
    17     0.072024   0.018133   0.993505   0.98497         
    18     0.071517   0.018464   0.993602   0.985192        
    19     0.070109   0.018337   0.993614   0.9852
[0.018336569653853538, 0.9936137114252362, 0.9852004420189631]
'''

0.985!这就像我们将错误减半,其他一切完全相同。而且更重要的是,你可以看一下。

learn.save('128urn-0')
learn.load('128urn-0')
x,y = next(iter(md.val_dl))
py = to_np(learn.model(V(x)))

与我们的非 Unet 等效物相比,这实际上看起来有点像汽车,后者只是一个斑点。因为试图通过下行和上行路径来做这个——这只是要求太多了。而当我们实际上在每个点提供下行路径像素时,它实际上可以开始创建一些类似汽车的东西。

show_img(py[0]>0);

show_img(y[0]);

最后,我们将执行 m.close 以删除占用 GPU 内存的sfs.features

m.close()

512x512 [1:56:26]

转到较小的批量大小,更高的大小

sz=512
bs=16
tfms = tfms_from_model(
    resnet34, sz, 
    crop_type=CropType.NO, 
    tfm_y=TfmType.CLASS, 
    aug_tfms=aug_tfms
)
datasets = ImageData.get_ds(
    MatchedFilesDataset, 
    (trn_x,trn_y), 
    (val_x,val_y), 
    tfms, 
    path=PATH
)
md = ImageData(PATH, datasets, bs, num_workers=4, classes=None)
denorm = md.trn_ds.denormm_base = get_base()
m = to_gpu(Unet34(m_base))
models = UnetModel(m)
learn = ConvLearner(md, models)
learn.opt_fn=optim.Adam
learn.crit=nn.BCEWithLogitsLoss()
learn.metrics=[accuracy_thresh(0.5),dice]
learn.freeze_to(1)
learn.load('128urn-0')
learn.fit(lr,1,wds=wd, cycle_len=5,use_clr=(5,5))
'''
epoch      trn_loss   val_loss   <lambda>   dice              
    0      0.071421   0.02362    0.996459   0.991772  
    1      0.070373   0.014013   0.996558   0.992602          
    2      0.067895   0.011482   0.996705   0.992883          
    3      0.070653   0.014256   0.996695   0.992771          
    4      0.068621   0.013195   0.996993   0.993359
[0.013194938530288046, 0.996993034604996, 0.993358936574724]
'''

你可以看到 Dice 系数真的在上升[1:56:30]。所以请注意,我正在加载网络的 128x128 版本。我们再次使用渐进式调整大小的技巧,这样我们得到了 0.993。

learn.save('512urn-tmp')
learn.unfreeze()
learn.bn_freeze(True)
learn.load('512urn-tmp')
learn.fit(lrs/4,1,wds=wd, cycle_len=8,use_clr=(20,8))
'''
epoch      trn_loss   val_loss   <lambda>   dice              
    0      0.06605    0.013602   0.997      0.993014  
    1      0.066885   0.011252   0.997248   0.993563          
    2      0.065796   0.009802   0.997223   0.993817          
    3      0.065089   0.009668   0.997296   0.993744          
    4      0.064552   0.011683   0.997269   0.993835          
    5      0.065089   0.010553   0.997415   0.993827          
    6      0.064303   0.009472   0.997431   0.994046          
    7      0.062506   0.009623   0.997441   0.994118
[0.009623114736602894, 0.9974409020136273, 0.9941179137381296]
'''

然后解冻以达到 0.994。

learn.save('512urn')
learn.load('512urn')
x,y = next(iter(md.val_dl))
py = to_np(learn.model(V(x)))

你可以看到,现在看起来很不错。

show_img(py[0]>0);

show_img(y[0]);

m.close()

1024x1024 [1:56:53]

将批量大小降至 4,大小为 1024。

sz=1024
bs=4
tfms = tfms_from_model(
    resnet34, sz, 
    crop_type=CropType.NO, 
    tfm_y=TfmType.CLASS
)
datasets = ImageData.get_ds(
    MatchedFilesDataset, 
    (trn_x,trn_y), 
    (val_x,val_y), 
    tfms, 
    path=PATH
)
md = ImageData(PATH, datasets, bs, num_workers=16, classes=None)
denorm = md.trn_ds.denormm_base = get_base()
m = to_gpu(Unet34(m_base))
models = UnetModel(m)
learn = ConvLearner(md, models)
learn.opt_fn=optim.Adam
learn.crit=nn.BCEWithLogitsLoss()
learn.metrics=[accuracy_thresh(0.5),dice]

加载我们刚刚保存的 512。

learn.load('512urn')
learn.freeze_to(1)
learn.fit(lr,1, wds=wd, cycle_len=2,use_clr=(5,4))
'''
epoch      trn_loss   val_loss   <lambda>   dice                 
    0      0.007656   0.008155   0.997247   0.99353   
    1      0.004706   0.00509    0.998039   0.995437
[0.005090427414942828, 0.9980387706605215, 0.995437301104031]
'''

这让我们达到了 0.995。

learn.save('1024urn-tmp')
learn.load('1024urn-tmp')
learn.unfreeze()
learn.bn_freeze(True)
lrs = np.array([lr/200,lr/30,lr])
learn.fit(lrs/10,1, wds=wd,cycle_len=4,use_clr=(20,8))
'''
epoch      trn_loss   val_loss   <lambda>   dice                 
    0      0.005688   0.006135   0.997616   0.994616  
    1      0.004412   0.005223   0.997983   0.995349             
    2      0.004186   0.004975   0.99806    0.99554              
    3      0.004016   0.004899   0.99812    0.995627
[0.004898778487196458, 0.9981196409180051, 0.9956271404784823]
'''
learn.fit(lrs/10,1, wds=wd,cycle_len=4,use_clr=(20,8))
'''
epoch      trn_loss   val_loss   <lambda>   dice                 
    0      0.004169   0.004962   0.998049   0.995517  
    1      0.004022   0.004595   0.99823    0.995818             
    2      0.003772   0.004497   0.998215   0.995916             
    3      0.003618   0.004435   0.998291   0.995991
[0.004434524739663753, 0.9982911745707194, 0.9959913929776539]
'''

解冻将我们带到...我们将称之为 0.996。

learn.sched.plot_loss()

learn.save('1024urn')
learn.load('1024urn')
x,y = next(iter(md.val_dl))
py = to_np(learn.model(V(x)))

正如你所看到的,实际上看起来很不错[1:57:17]。在准确性方面,99.82%。你可以看到这看起来像是你可以用来裁剪的东西。我认为,在这一点上,我们可以做一些微小的调整来达到 0.997,但真正的关键是,我认为,也许只需要做一些平滑处理或一点后处理。你可以去看看 Carvana 获奖者的博客,看看其中的一些技巧,但正如我所说,我们目前的 0.996 和获奖者得到的 0.997 之间的差距并不大。所以实际上,U-Net 基本上解决了这个问题。

show_img(py[0]>0);

show_img(y[0]);

回到边界框[1:58:15]

好的,就是这样。我想要提到的最后一件事是现在回到边界框,因为你可能还记得,我说我们的边界框模型在小物体上仍然表现不佳。所以希望你能猜到我接下来要做什么,那就是对于边界框模型,记得我们在不同的网格单元中输出了模型的输出。那些较早的具有较小网格大小的输出并不好。我们该如何修复呢?用 U-Net!让我们有一个带有交叉连接的向上路径。然后我们将使用 U-Net,然后从中输出。因为现在那些更精细的网格单元具有该路径的所有信息,以及该路径、该路径和该路径的信息。当然,这是深度学习,这意味着你不能写一篇论文说我们只是用 U-Net 来处理边界框。你必须发明一个新词,所以这被称为特征金字塔网络或 FPNs。这在 RetinaNet 论文中使用过,它是在早期关于 FPNs 的论文中创建的。如果我记得正确的话,他们确实简要引用了 U-Net 论文,但他们似乎让它听起来像是这个模糊地稍微相关的东西,也许有些人可能认为稍微有用。但实际上,FPNs 就是 U-Nets。

我没有实现它来展示给你,但这将是一件有趣的事情,也许对于我们中的一些人来尝试,我知道一些学生一直在尝试在论坛上使其良好运行。所以是的,尝试一下是有趣的事情。所以我认为在这堂课之后要看的一些事情,以及我提到的其他事情,可能是玩玩 FPNs,也可能尝试一下 Kerem 的 DynamicUnet。它们都是值得一看的有趣的东西。

所以你们现在已经经历了我对你们讲解的 14 堂课。对此我感到抱歉。谢谢你们忍受我。我认为你们会发现很难找到其他人对神经网络训练和实践了解得像你们这样多。你们很容易高估其他人的能力,低估自己的能力。所以我想说的是,请继续练习。因为现在没有每个星期一晚上都有我在这里让你们回来了。很容易失去动力。所以找到方法保持下去。组织一个学习小组,一个读书小组,或者和朋友们一起做项目,或者做一些不仅仅是决定我要继续做 X 的事情。除非你是那种超级有动力的人,每当你决定做某事,它就会发生。那不是我。我知道,要让事情发生,我必须说“是的,大卫。十月份,我绝对会教那门课程”,然后我就得开始写一些材料。这是我让事情发生的唯一方法。所以我们在论坛上有一个很棒的社区。如果有人有想法让它变得更好,请告诉我。如果你认为你可以帮忙,如果你想创建一些新的论坛或以某种不同的方式进行管理,或者其他什么的,只要告诉我。你可以随时私信我,GitHub 上也有很多项目正在进行中——很多东西。所以我希望能在其他地方再见到你们,非常感谢你们加入我的旅程。

posted @ 2026-03-26 08:48  布客飞龙II  阅读(0)  评论(0)    收藏  举报