add cn translations (#643)

This commit is contained in:
布客飞龙 2024-07-08 06:59:03 +08:00 committed by GitHub
parent 9cabecb9a6
commit 10ae0cff67
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
248 changed files with 16232 additions and 0 deletions

1063
translations/cn/01_intro.md Normal file

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,835 @@
# 第二章:从模型到生产
我们在第一章中看到的六行代码只是在实践中使用深度学习过程的一小部分。在本章中,我们将使用一个计算机视觉示例来查看创建深度学习应用的端到端过程。更具体地说,我们将构建一个熊分类器!在这个过程中,我们将讨论深度学习的能力和限制,探讨如何创建数据集,在实践中使用深度学习时可能遇到的问题等等。许多关键点同样适用于其他深度学习问题,例如第一章中的问题。如果您解决的问题在关键方面类似于我们的示例问题,我们期望您可以快速获得极好的结果,而只需很少的代码。
让我们从如何构建您的问题开始。
# 深度学习的实践
我们已经看到深度学习可以快速解决许多具有挑战性的问题,并且只需很少的代码。作为初学者,有一些问题与我们的示例问题足够相似,以便您可以非常快速地获得极其有用的结果。然而,深度学习并不是魔法!同样的六行代码不会适用于今天任何人可以想到的每个问题。
低估深度学习的限制并高估其能力可能导致令人沮丧的糟糕结果,至少在您获得一些经验并能解决出现的问题之前。相反,高估深度学习的限制并低估其能力可能意味着您不会尝试可解决的问题,因为您自己否定了它。
我们经常与低估深度学习的限制和能力的人交谈。这两者都可能是问题:低估能力意味着您可能甚至不会尝试可能非常有益的事情,而低估限制可能意味着您未能考虑和应对重要问题。
最好的做法是保持开放的心态。如果您对深度学习可能以比您预期的更少的数据或复杂性解决部分问题持开放态度,您可以设计一个过程,通过该过程您可以找到与您特定问题相关的特定能力和限制。这并不意味着进行任何冒险的赌注-我们将向您展示如何逐渐推出模型,以便它们不会带来重大风险,并且甚至可以在投入生产之前对其进行回测。
## 开始您的项目
那么您应该从哪里开始深度学习之旅呢?最重要的是确保您有一个要处理的项目-只有通过处理自己的项目,您才能获得构建和使用模型的真实经验。在选择项目时,最重要的考虑因素是数据的可用性。
无论您是为了自己的学习还是为了在组织中的实际应用而进行项目,您都希望能够快速开始。我们看到许多学生、研究人员和行业从业者在试图找到他们完美的数据集时浪费了几个月甚至几年的时间。目标不是找到“完美”的数据集或项目,而只是开始并从那里迭代。如果您采取这种方法,您将在完美主义者仍处于规划阶段时进行第三次迭代学习和改进!
我们还建议您在项目中端到端迭代;不要花几个月来微调您的模型,或打磨完美的 GUI或标记完美的数据集……相反尽可能在合理的时间内完成每一步一直到最后。例如如果您的最终目标是一个在手机上运行的应用程序那么每次迭代后您都应该拥有这个。但也许在早期迭代中您会采取捷径例如在远程服务器上进行所有处理并使用简单的响应式 Web 应用程序。通过完成项目的端到端,您将看到最棘手的部分在哪里,以及哪些部分对最终结果产生最大影响。
当您阅读本书时,我们建议您完成许多小实验,通过运行和调整我们提供的笔记本,同时逐渐开发自己的项目。这样,您将获得所有我们解释的工具和技术的经验,同时我们讨论它们。
# Sylvain 说
为了充分利用这本书,花时间在每一章之间进行实验,无论是在您自己的项目上还是通过探索我们提供的笔记本。然后尝试在新数据集上从头开始重写这些笔记本。只有通过大量练习(和失败),您才能培养出如何训练模型的直觉。
通过使用端到端迭代方法,您还将更好地了解您实际需要多少数据。例如,您可能会发现您只能轻松获得 200 个标记数据项,而在尝试之前,您无法真正知道这是否足以使您的应用在实践中良好运行。
在组织环境中,您可以通过展示一个真实的工作原型来向同事展示您的想法是可行的。我们反复观察到,这是获得项目良好组织支持的秘诀。
由于最容易开始的项目是您已经有数据可用的项目,这意味着最容易开始的项目可能与您已经在做的事情相关,因为您已经有关于您正在做的事情的数据。例如,如果您在音乐行业工作,您可能可以访问许多录音。如果您是放射科医生,您可能可以访问大量医学图像。如果您对野生动物保护感兴趣,您可能可以访问大量野生动物图像。
有时您必须有点创造性。也许您可以找到一个先前的机器学习项目,比如一个与您感兴趣的领域相关的 Kaggle 竞赛。有时您必须做出妥协。也许您找不到您所需的确切数据来完成您心中的项目;但您可能会找到一些来自类似领域的数据,或者以不同方式测量的数据,解决一个略有不同的问题。在这些类似项目上工作仍然会让您对整个过程有很好的理解,并可能帮助您识别其他捷径、数据来源等。
特别是当您刚开始学习深度学习时,最好不要涉足非常不同的领域,不要涉足深度学习之前未应用的领域。因为如果您的模型一开始就不起作用,您将不知道是因为您犯了错误,还是您试图解决的问题根本无法用深度学习解决。您也不知道从哪里寻求帮助。因此,最好首先找到在线的一个例子,该例子已经取得了良好的结果,并且至少与您尝试实现的目标有些相似,通过将您的数据转换为其他人以前使用过的格式(例如从您的数据创建图像)。让我们看看深度学习的现状,这样您就知道深度学习目前擅长的领域。
## 深度学习的现状
让我们首先考虑深度学习是否能够解决您要解决的问题。本节概述了 2020 年初深度学习的现状。然而事情发展得非常快当您阅读本文时其中一些限制可能已经不存在。我们将尽力保持本书网站的最新信息此外搜索“AI 现在能做什么”可能会提供当前信息。
### 计算机视觉
深度学习尚未用于分析图像的许多领域,但在已经尝试过的领域中,几乎普遍表明计算机可以至少与人类一样好地识别图像中的物品,甚至是经过专门训练的人,如放射科医生。这被称为*物体识别*。深度学习还擅长识别图像中物体的位置,并可以突出它们的位置并命名每个找到的物体。这被称为*物体检测*(在我们在第一章中看到的变体中,每个像素根据其所属的对象类型进行分类—这被称为*分割*)。
深度学习算法通常不擅长识别结构或风格与用于训练模型的图像明显不同的图像。例如,如果训练数据中没有黑白图像,模型可能在黑白图像上表现不佳。同样,如果训练数据不包含手绘图像,模型可能在手绘图像上表现不佳。没有一般方法可以检查训练集中缺少哪些类型的图像,但我们将在本章中展示一些方法,以尝试识别当模型在生产中使用时数据中出现意外图像类型的情况(这被称为检查*域外*数据)。
物体检测系统面临的一个主要挑战是图像标记可能会很慢且昂贵。目前有很多工作正在进行中,旨在开发工具以尝试使这种标记更快速、更容易,并且需要更少的手工标签来训练准确的物体检测模型。一个特别有帮助的方法是合成生成输入图像的变化,例如通过旋转它们或改变它们的亮度和对比度;这被称为*数据增强*,并且对文本和其他类型的模型也很有效。我们将在本章中详细讨论这一点。
另一个要考虑的问题是,尽管您的问题可能看起来不像是一个计算机视觉问题,但通过一点想象力可能可以将其转变为一个。例如,如果您要分类的是声音,您可以尝试将声音转换为其声学波形的图像,然后在这些图像上训练模型。
### 自然语言处理
计算机擅长基于类别对短文档和长文档进行分类,例如垃圾邮件或非垃圾邮件、情感(例如,评论是积极的还是消极的)、作者、来源网站等。我们不知道在这个领域是否有任何严格的工作来比较计算机和人类,但从经验上看,我们认为深度学习的性能在这些任务上与人类的性能相似。
深度学习还擅长生成与上下文相关的文本,例如回复社交媒体帖子,并模仿特定作者的风格。它还擅长使这些内容对人类具有吸引力—事实上,甚至比人类生成的文本更具吸引力。然而,深度学习不擅长生成*正确*的回应!例如,我们没有可靠的方法来将医学信息知识库与深度学习模型结合起来,以生成医学上正确的自然语言回应。这是危险的,因为很容易创建对外行人看来具有吸引力但实际上完全不正确的内容。
另一个问题是,社交媒体上的上下文适当、高度引人入胜的回应可能被大规模使用——比以前见过的任何喷子农场规模大几千倍——来传播虚假信息,制造动荡,鼓励冲突。一般来说,文本生成模型总是在技术上略领先于识别自动生成文本的模型。例如,可以使用一个能够识别人工生成内容的模型来实际改进创建该内容的生成器,直到分类模型无法完成其任务为止。
尽管存在这些问题,深度学习在自然语言处理中有许多应用:可以用来将文本从一种语言翻译成另一种语言,将长篇文档总结为更快消化的内容,找到感兴趣概念的所有提及等。不幸的是,翻译或总结可能包含完全错误的信息!然而,性能已经足够好,许多人正在使用这些系统——例如,谷歌的在线翻译系统(以及我们所知道的每个其他在线服务)都是基于深度学习的。
### 结合文本和图像
深度学习将文本和图像结合成一个单一模型的能力通常比大多数人直觉期望的要好得多。例如,一个深度学习模型可以在输入图像上进行训练,输出用英语编写的标题,并且可以学会为新图像自动生成令人惊讶地适当的标题!但是,我们再次提出与前一节讨论的相同警告:不能保证这些标题是正确的。
由于这个严重问题,我们通常建议深度学习不要作为完全自动化的过程,而是作为模型和人类用户密切互动的过程的一部分。这可能使人类的生产力比完全手动方法高出几个数量级,并且比仅使用人类更准确。
例如,自动系统可以直接从 CT 扫描中识别潜在的中风患者,并发送高优先级警报,以便快速查看这些扫描。治疗中风只有三个小时的时间窗口,因此这种快速的反馈循环可以挽救生命。同时,所有扫描仍然可以按照通常的方式发送给放射科医生,因此不会减少人类的参与。其他深度学习模型可以自动测量扫描中看到的物品,并将这些测量结果插入报告中,警告放射科医生可能错过的发现,并告诉他们可能相关的其他病例。
### 表格数据
对于分析时间序列和表格数据,深度学习最近取得了巨大进展。然而,深度学习通常作为多种模型集成的一部分使用。如果您已经有一个正在使用随机森林或梯度提升机(流行的表格建模工具,您很快将了解)的系统,那么切换到或添加深度学习可能不会带来任何显著的改进。
深度学习确实大大增加了您可以包含的列的种类——例如,包含自然语言(书名、评论等)和高基数分类列(即包含大量离散选择的内容,如邮政编码或产品 ID。不过与随机森林或梯度提升机相比深度学习模型通常需要更长的训练时间尽管由于提供 GPU 加速的库(如[RAPIDS](https://rapids.ai)),情况正在改变。我们在第九章中详细介绍了所有这些方法的优缺点。
### 推荐系统
推荐系统实际上只是一种特殊类型的表格数据。特别是,它们通常具有代表用户的高基数分类变量,以及代表产品(或类似物品)的另一个变量。像亚马逊这样的公司将客户所做的每一次购买都表示为一个巨大的稀疏矩阵,其中客户是行,产品是列。一旦他们以这种格式拥有数据,数据科学家们会应用某种形式的协同过滤来*填充矩阵*。例如,如果客户 A 购买产品 1 和 10客户 B 购买产品 1、2、4 和 10引擎将推荐 A 购买 2 和 4。
由于深度学习模型擅长处理高基数分类变量,它们非常擅长处理推荐系统。尤其是当将这些变量与其他类型的数据(如自然语言或图像)结合时,它们就像处理表格数据一样发挥作用。它们还可以很好地将所有这些类型的信息与其他元数据(如用户信息、先前交易等)表示为表格进行组合。
然而几乎所有的机器学习方法都有一个缺点那就是它们只告诉你一个特定用户可能喜欢哪些产品而不是对用户有用的推荐。用户可能喜欢的产品的许多种推荐可能根本不会有任何帮助——例如如果用户已经熟悉这些产品或者如果它们只是用户已经购买过的产品的不同包装例如当他们已经拥有该套装中的每一件物品时推荐一个小说的套装。Jeremy 喜欢读特里·普拉切特的书,有一段时间亚马逊一直在向他推荐特里·普拉切特的书(见图 2-1这实际上并不是有用的因为他已经知道这些书了
![特里·普拉切特的书推荐](img/dlcf_0201.png)
###### 图 2-1. 一个不太有用的推荐
### 其他数据类型
通常,您会发现特定领域的数据类型非常适合现有的类别。例如,蛋白质链看起来很像自然语言文档,因为它们是由复杂关系和意义贯穿整个序列的离散令牌组成的长序列。事实上,使用 NLP 深度学习方法是许多类型蛋白质分析的最先进方法。另一个例子,声音可以表示为频谱图,可以被视为图像;标准的图像深度学习方法在频谱图上表现得非常好。
## 驱动系统方法
许多准确的模型对任何人都没有用而许多不准确的模型却非常有用。为了确保您的建模工作在实践中有用您需要考虑您的工作将如何使用。2012 年Jeremy 与 Margit Zwemer 和 Mike Loukides 一起提出了一种称为*驱动系统方法*的思考这个问题的方法。
驱动系统方法,如图 2-2 所示,详细介绍在[“设计出色的数据产品”](https://oreil.ly/KJIIa)中。基本思想是从考虑您的目标开始,然后考虑您可以采取哪些行动来实现该目标以及您拥有的(或可以获取的)可以帮助的数据,然后构建一个模型,您可以使用该模型确定为实现目标而采取的最佳行动。
![](img/dlcf_0202.png)
###### 图 2-2. 驱动系统方法
考虑自动驾驶汽车中的模型:您希望帮助汽车安全地从 A 点驾驶到 B 点,而无需人为干预。出色的预测建模是解决方案的重要组成部分,但它并不是独立存在的;随着产品变得更加复杂,它会消失在管道中。使用自动驾驶汽车的人完全不知道使其运行的数百(甚至数千)个模型和海量数据。但随着数据科学家构建越来越复杂的产品,他们需要一种系统化的设计方法。
我们使用数据不仅仅是为了生成更多数据(以预测的形式),而是为了产生可操作的结果。这是 Drivetrain 方法的目标。首先要明确定义一个明确的目标。例如,当谷歌创建其第一个搜索引擎时,考虑了“用户在输入搜索查询时的主要目标是什么?”这导致了谷歌的目标,即“显示最相关的搜索结果”。下一步是考虑您可以拉动的杠杆(即您可以采取的行动)以更好地实现该目标。在谷歌的情况下,这是搜索结果的排名。第三步是考虑他们需要什么新数据来生成这样的排名;他们意识到关于哪些页面链接到哪些其他页面的隐含信息可以用于此目的。
只有在完成了这前三个步骤之后,我们才开始考虑构建预测模型。我们的目标和可用的杠杆,我们已经拥有的数据以及我们需要收集的额外数据,决定了我们可以构建的模型。这些模型将以杠杆和任何不可控变量作为输入;模型的输出可以结合起来预测我们的目标的最终状态。
让我们考虑另一个例子:推荐系统。推荐引擎的目标是通过推荐客户不会在没有推荐的情况下购买的物品来推动额外的销售。杠杆是推荐的排名。必须收集新数据以生成将导致新销售的推荐。这将需要进行许多随机实验,以收集关于各种客户的各种推荐的数据。这是很少有组织采取的一步;但是没有它,您就没有所需的信息来根据您的真正目标(更多销售!)优化推荐。
最后,您可以为购买概率构建两个模型,条件是看到或没有看到推荐。这两个概率之间的差异是给定推荐给客户的效用函数。在算法推荐客户已经拒绝的熟悉书籍(两个组成部分都很小)或者他们本来就会购买的书籍(两个组成部分都很大并互相抵消)的情况下,效用函数会很低。
正如您所看到的,在实践中,您的模型的实际实施通常需要比仅仅训练一个模型更多!您通常需要运行实验来收集更多数据,并考虑如何将您的模型整合到您正在开发的整个系统中。说到数据,现在让我们专注于如何为您的项目找到数据。
# 收集数据
对于许多类型的项目,您可能能够在线找到所需的所有数据。本章中我们将完成的项目是一个“熊探测器”。它将区分三种类型的熊:灰熊、黑熊和泰迪熊。互联网上有许多每种类型熊的图片可供我们使用。我们只需要找到它们并下载它们。
我们提供了一个工具供您使用,这样您就可以跟随本章并为您感兴趣的任何对象创建自己的图像识别应用程序。在 fast.ai 课程中,成千上万的学生在课程论坛上展示了他们的作品,展示了从特立尼达的蜂鸟品种到巴拿马的公交车类型的一切——甚至有一名学生创建了一个应用程序,可以帮助他的未婚妻在圣诞假期期间认出他的 16 个表兄弟!
在撰写本文时Bing 图像搜索是我们知道的用于查找和下载图像的最佳选择。每月免费提供最多 1,000 次查询,每次查询可下载最多 150 张图片。然而,在我们撰写本书时和您阅读本书时之间可能会出现更好的选择,因此请务必查看本[书籍网站](https://book.fast.ai)以获取我们当前的推荐。
# 与最新服务保持联系
用于创建数据集的服务时常变化,它们的功能、接口和定价也经常变化。在本节中,我们将展示如何在撰写本书时作为 Azure 认知服务一部分提供的[Bing 图像搜索 API](https://oreil.ly/P8VtT)。
要使用 Bing 图像搜索下载图像,请在 Microsoft 注册一个免费帐户。您将获得一个密钥,您可以将其复制并输入到一个单元格中(用您的密钥替换*`XXX`*并执行):
```py
key = 'XXX'
```
或者,如果您在命令行上感到自在,您可以在终端中设置它
```py
export AZURE_SEARCH_KEY=*your_key_here*
```
然后重新启动 Jupyter 服务器,在一个单元格中键入以下内容,并执行:
```py
key = os.environ['AZURE_SEARCH_KEY']
```
设置了`key`之后,您可以使用`search_images_bing`。这个函数是在线笔记本中包含的小`utils`类提供的(如果您不确定一个函数是在哪里定义的,您可以在笔记本中输入它来找出,如下所示):
```py
search_images_bing
```
```py
<function utils.search_images_bing(key, term, min_sz=128)>
```
让我们尝试一下这个函数:
```py
results = search_images_bing(key, 'grizzly bear')
ims = results.attrgot('content_url')
len(ims)
```
```py
150
```
我们已成功下载了 150 只灰熊的 URL或者至少是 Bing 图像搜索为该搜索词找到的图像)。让我们看一个:
```py
dest = 'images/grizzly.jpg'
download_url(ims[0], dest)
```
```py
im = Image.open(dest)
im.to_thumb(128,128)
```
![](img/dlcf_02in01.png)
这似乎运行得很好,所以让我们使用 fastai 的`download_images`来下载每个搜索词的所有 URL。我们将每个放在一个单独的文件夹中
```py
bear_types = 'grizzly','black','teddy'
path = Path('bears')
```
```py
if not path.exists():
path.mkdir()
for o in bear_types:
dest = (path/o)
dest.mkdir(exist_ok=True)
results = search_images_bing(key, f'{o} bear')
download_images(dest, urls=results.attrgot('content_url'))
```
我们的文件夹中有图像文件,正如我们所期望的那样:
```py
fns = get_image_files(path)
fns
```
```py
(#421) [Path('bears/black/00000095.jpg'),Path('bears/black/00000133.jpg'),Path('
> bears/black/00000062.jpg'),Path('bears/black/00000023.jpg'),Path('bears/black
> /00000029.jpg'),Path('bears/black/00000094.jpg'),Path('bears/black/00000124.j
> pg'),Path('bears/black/00000056.jpeg'),Path('bears/black/00000046.jpg'),Path(
> 'bears/black/00000045.jpg')...]
```
# Jeremy 说
我就是喜欢在 Jupyter 笔记本中工作的这一点!逐步构建我想要的东西并在每一步检查我的工作是如此容易。我犯了*很多*错误,所以这对我真的很有帮助。
通常当我们从互联网下载文件时,会有一些文件损坏。让我们检查一下:
```py
failed = verify_images(fns)
failed
```
```py
(#0) []
```
要删除所有失败的图像,您可以使用`unlink`。像大多数返回集合的 fastai 函数一样,`verify_images`返回一个类型为`L`的对象,其中包括`map`方法。这会在集合的每个元素上调用传递的函数:
```py
failed.map(Path.unlink);
```
在这个过程中要注意的一件事是正如我们在第一章中讨论的模型只能反映用于训练它们的数据。而世界充满了有偏见的数据这最终会反映在例如Bing 图像搜索(我们用来创建数据集的)。例如,假设您有兴趣创建一个应用程序,可以帮助用户确定他们是否拥有健康的皮肤,因此您训练了一个模型,该模型基于搜索结果(比如)“健康皮肤”。图 2-3 展示了您将获得的结果类型。
![](img/dlcf_0203.png)
###### 图 2-3\. 用于健康皮肤检测器的数据?
使用此作为训练数据,您最终不会得到一个健康皮肤检测器,而是一个*年轻白人女性触摸她的脸*检测器!一定要仔细考虑您可能在应用程序中实际看到的数据类型,并仔细检查以确保所有这些类型都反映在您模型的源数据中。(感谢 Deb Raji 提出了健康皮肤的例子。请查看她的论文[“可操作的审计:调查公开命名商业 AI 产品偏见性能结果的影响”](https://oreil.ly/POS_C)以获取更多有关模型偏见的迷人见解。)
现在我们已经下载了一些数据,我们需要将其组装成适合模型训练的格式。在 fastai 中,这意味着创建一个名为`DataLoaders`的对象。
# 从数据到数据加载器
`DataLoaders`是一个简单的类,只是存储您传递给它的`DataLoader`对象,并将它们作为`train`和`valid`可用。尽管它是一个简单的类,但在 fastai 中非常重要:它为您的模型提供数据。`DataLoaders`中的关键功能仅用这四行代码提供(它还有一些其他次要功能我们暂时跳过):
```py
class DataLoaders(GetAttr):
def __init__(self, *loaders): self.loaders = loaders
def __getitem__(self, i): return self.loaders[i]
train,valid = add_props(lambda i,self: self[i])
```
# 术语DataLoaders
一个 fastai 类,存储您传递给它的多个`DataLoader`对象——通常是一个`train`和一个`valid`,尽管可以有任意数量。前两个作为属性提供。
在本书的后面,您还将了解`Dataset`和`Datasets`类,它们具有相同的关系。要将我们下载的数据转换为`DataLoaders`对象,我们至少需要告诉 fastai 四件事:
+ 我们正在处理什么类型的数据
+ 如何获取项目列表
+ 如何为这些项目打标签
+ 如何创建验证集
到目前为止,我们已经看到了一些特定组合的*工厂方法*当您有一个应用程序和数据结构恰好适合这些预定义方法时这些方法非常方便。当您不适用时fastai 有一个名为*数据块 API*的极其灵活的系统。使用此 API您可以完全自定义创建`DataLoaders`的每个阶段。这是我们需要为刚刚下载的数据集创建`DataLoaders`的步骤:
```py
bears = DataBlock(
blocks=(ImageBlock, CategoryBlock),
get_items=get_image_files,
splitter=RandomSplitter(valid_pct=0.2, seed=42),
get_y=parent_label,
item_tfms=Resize(128))
```
让我们依次查看每个参数。首先,我们提供一个元组,指定我们希望独立变量和因变量的类型:
```py
blocks=(ImageBlock, CategoryBlock)
```
*独立变量*是我们用来进行预测的东西,*因变量*是我们的目标。在这种情况下,我们的独立变量是一组图像,我们的因变量是每个图像的类别(熊的类型)。在本书的其余部分中,我们将看到许多其他类型的块。
对于这个`DataLoaders`,我们的基础项目将是文件路径。我们必须告诉 fastai 如何获取这些文件的列表。`get_image_files`函数接受一个路径,并返回该路径中所有图像的列表(默认情况下递归):
```py
get_items=get_image_files
```
通常,您下载的数据集已经定义了验证集。有时,这是通过将用于训练和验证集的图像放入不同的文件夹中来完成的。有时,这是通过提供一个 CSV 文件在该文件中每个文件名都与应该在其中的数据集一起列出。有许多可以完成此操作的方法fastai 提供了一种通用方法,允许您使用其预定义类之一或编写自己的类。
在这种情况下,我们希望随机拆分我们的训练和验证集。但是,我们希望每次运行此笔记本时都具有相同的训练/验证拆分,因此我们固定随机种子(计算机实际上不知道如何创建随机数,而只是创建看起来随机的数字列表;如果您每次都为该列表提供相同的起始点——称为*种子*,那么您将每次都获得完全相同的列表)。
```py
splitter=RandomSplitter(valid_pct=0.2, seed=42)
```
自变量通常被称为`x`,因变量通常被称为`y`。在这里,我们告诉 fastai 要调用哪个函数来创建数据集中的标签:
```py
get_y=parent_label
```
`parent_label`是 fastai 提供的一个函数,它简单地获取文件所在文件夹的名称。因为我们将每个熊图像放入基于熊类型的文件夹中,这将为我们提供所需的标签。
我们的图像大小各不相同,这对深度学习是一个问题:我们不是一次向模型提供一个图像,而是多个图像(我们称之为*mini-batch*)。为了将它们分组到一个大数组(通常称为*张量*)中,以便通过我们的模型,它们都需要是相同的大小。因此,我们需要添加一个转换,将这些图像调整为相同的大小。*Item transforms*是在每个单独项目上运行的代码片段无论是图像、类别还是其他。fastai 包含许多预定义的转换;我们在这里使用`Resize`转换,并指定大小为 128 像素:
```py
item_tfms=Resize(128)
```
这个命令给了我们一个`DataBlock`对象。这就像创建`DataLoaders`的*模板*。我们仍然需要告诉 fastai 我们数据的实际来源——在这种情况下,图像所在的路径:
```py
dls = bears.dataloaders(path)
```
`DataLoaders`包括验证和训练`DataLoader`。`DataLoader`是一个类,它一次向 GPU 提供几个项目的批次。我们将在下一章中更多地了解这个类。当您循环遍历`DataLoader`时fastai 会一次给您 64 个(默认值)项目,全部堆叠到一个单一张量中。我们可以通过在`DataLoader`上调用`show_batch`方法来查看其中一些项目:
```py
dls.valid.show_batch(max_n=4, nrows=1)
```
![](img/dlcf_02in02.png)
默认情况下,`Resize`会将图像*裁剪*成适合请求大小的正方形形状,使用完整的宽度或高度。这可能会导致丢失一些重要细节。或者,您可以要求 fastai 用零(黑色)填充图像,或者压缩/拉伸它们:
```py
bears = bears.new(item_tfms=Resize(128, ResizeMethod.Squish))
dls = bears.dataloaders(path)
dls.valid.show_batch(max_n=4, nrows=1)
```
![](img/dlcf_02in03.png)
```py
bears = bears.new(item_tfms=Resize(128, ResizeMethod.Pad, pad_mode='zeros'))
dls = bears.dataloaders(path)
dls.valid.show_batch(max_n=4, nrows=1)
```
![](img/dlcf_02in04.png)
所有这些方法似乎都有些浪费或问题。如果我们压缩或拉伸图像,它们最终会变成不现实的形状,导致模型学习到事物看起来与实际情况不同,这会导致更低的准确性。如果我们裁剪图像,我们会移除一些允许我们进行识别的特征。例如,如果我们试图识别狗或猫的品种,我们可能会裁剪掉区分相似品种所需的身体或面部的关键部分。如果我们填充图像,就会有很多空白空间,这对我们的模型来说只是浪费计算,并导致我们实际使用的图像部分具有较低的有效分辨率。
相反,我们在实践中通常做的是随机选择图像的一部分,然后裁剪到该部分。在每个纪元(即数据集中所有图像的完整遍历),我们随机选择每个图像的不同部分。这意味着我们的模型可以学习关注和识别图像中的不同特征。这也反映了图像在现实世界中的工作方式:同一物体的不同照片可能以略有不同的方式构图。
事实上,一个完全未经训练的神经网络对图像的行为一无所知。它甚至不认识当一个物体旋转一度时,它仍然是同一物体的图片!因此,通过训练神经网络使用物体在略有不同位置并且大小略有不同的图像的示例,有助于它理解物体的基本概念,以及如何在图像中表示它。
这里是另一个示例,我们将`Resize`替换为`RandomResizedCrop`,这是提供刚才描述行为的转换。传递的最重要参数是`min_scale`,它确定每次选择图像的最小部分:
```py
bears = bears.new(item_tfms=RandomResizedCrop(128, min_scale=0.3))
dls = bears.dataloaders(path)
dls.train.show_batch(max_n=4, nrows=1, unique=True)
```
![](img/dlcf_02in05.png)
在这里,我们使用了`unique=True`,以便将相同图像重复使用不同版本的`RandomResizedCrop`变换。
`RandomResizedCrop`是更一般的数据增强技术的一个具体示例。
## 数据增强
*数据增强*指的是创建输入数据的随机变化,使它们看起来不同但不改变数据的含义。对于图像的常见数据增强技术包括旋转、翻转、透视变形、亮度变化和对比度变化。对于我们在这里使用的自然照片图像,我们发现一组标准的增强技术与`aug_transforms`函数一起提供,效果非常好。
因为我们的图像现在都是相同大小,我们可以使用 GPU 将这些增强应用于整个批次的图像,这将节省大量时间。要告诉 fastai 我们要在批次上使用这些变换,我们使用`batch_tfms`参数(请注意,在此示例中我们没有使用`RandomResizedCrop`,这样您可以更清楚地看到差异;出于同样的原因,我们使用了默认值的两倍的增强量):
```py
bears = bears.new(item_tfms=Resize(128), batch_tfms=aug_transforms(mult=2))
dls = bears.dataloaders(path)
dls.train.show_batch(max_n=8, nrows=2, unique=True)
```
![](img/dlcf_02in06.png)
现在我们已经将数据组装成适合模型训练的格式,让我们使用它来训练一个图像分类器。
# 训练您的模型,并使用它来清理您的数据
现在是时候使用与第一章中相同的代码行来训练我们的熊分类器了。对于我们的问题,我们没有太多的数据(每种熊最多 150 张图片),因此为了训练我们的模型,我们将使用`RandomResizedCrop`,图像大小为 224 像素,这对于图像分类来说是相当标准的,并且使用默认的`aug_transforms`
```py
bears = bears.new(
item_tfms=RandomResizedCrop(224, min_scale=0.5),
batch_tfms=aug_transforms())
dls = bears.dataloaders(path)
```
现在我们可以按照通常的方式创建我们的`Learner`并进行微调:
```py
learn = cnn_learner(dls, resnet18, metrics=error_rate)
learn.fine_tune(4)
```
| epoch | train_loss | valid_loss | error_rate | time |
| --- | --- | --- | --- | --- |
| 0 | 1.235733 | 0.212541 | 0.087302 | 00:05 |
| epoch | train_loss | valid_loss | error_rate | time |
| --- | --- | --- | --- | --- |
| 0 | 0.213371 | 0.112450 | 0.023810 | 00:05 |
| 1 | 0.173855 | 0.072306 | 0.023810 | 00:06 |
| 2 | 0.147096 | 0.039068 | 0.015873 | 00:06 |
| 3 | 0.123984 | 0.026801 | 0.015873 | 00:06 |
现在让我们看看模型犯的错误主要是认为灰熊是泰迪熊(这对安全性来说是不好的!),还是认为灰熊是黑熊,或者其他情况。为了可视化这一点,我们可以创建一个*混淆矩阵*
```py
interp = ClassificationInterpretation.from_learner(learn)
interp.plot_confusion_matrix()
```
![](img/dlcf_02in07.png)
行代表数据集中所有黑色、灰熊和泰迪熊,列分别代表模型预测为黑色、灰熊和泰迪熊的图像。因此,矩阵的对角线显示了被正确分类的图像,而非对角线的单元格代表被错误分类的图像。这是 fastai 允许您查看模型结果的许多方式之一。当然,这是使用验证集计算的。通过颜色编码,目标是在对角线以外的地方都是白色,而在对角线上我们希望是深蓝色。我们的熊分类器几乎没有犯错!
看到我们的错误发生在哪里是有帮助的,以便确定它们是由数据集问题(例如,根本不是熊的图像,或者标记错误)还是模型问题(也许它无法处理使用不同光照或从不同角度拍摄的图像等)。为了做到这一点,我们可以根据损失对图像进行排序。
*损失*是一个数字,如果模型不正确(尤其是如果它对其不正确的答案也很自信),或者如果它是正确的但对其正确答案不自信,那么损失就会更高。在第二部分的开头,我们将深入学习损失是如何计算和在训练过程中使用的。现在,`plot_top_losses`向我们展示了数据集中损失最高的图像。正如输出的标题所说,每个图像都标有四个内容:预测、实际(目标标签)、损失和概率。这里的*概率*是模型对其预测分配的置信水平,从零到一:
```py
interp.plot_top_losses(5, nrows=1)
```
![](img/dlcf_02in08.png)
这个输出显示,损失最高的图像是一个被预测为“灰熊”的图像,且置信度很高。然而,根据我们的必应图像搜索,它被标记为“黑熊”。我们不是熊专家,但在我们看来,这个标签显然是错误的!我们可能应该将其标签更改为“灰熊”。
进行数据清洗的直观方法是在*训练*模型之前进行。但正如您在本例中所看到的,模型可以帮助您更快速、更轻松地找到数据问题。因此,我们通常更喜欢先训练一个快速简单的模型,然后使用它来帮助我们进行数据清洗。
fastai 包括一个方便的用于数据清洗的 GUI名为`ImageClassifierCleaner`,它允许您选择一个类别和训练与验证集,并查看损失最高的图像(按顺序),以及菜单允许选择要删除或重新标记的图像:
```py
cleaner = ImageClassifierCleaner(learn)
cleaner
```
![清洁工具小部件](img/dlcf_02in09.png)
我们可以看到在我们的“黑熊”中有一张包含两只熊的图片:一只灰熊,一只黑熊。因此,我们应该在此图片下的菜单中选择`<Delete>`。`ImageClassifierCleaner`不会为您删除或更改标签;它只会返回要更改的项目的索引。因此,例如,要删除(取消链接)所有选定要删除的图像,我们将运行以下命令:
```py
for idx in cleaner.delete(): cleaner.fns[idx].unlink()
```
要移动我们选择了不同类别的图像,我们将运行以下命令:
```py
for idx,cat in cleaner.change(): shutil.move(str(cleaner.fns[idx]), path/cat)
```
# Sylvain 说
清理数据并为您的模型做好准备是数据科学家面临的两个最大挑战;他们说这需要他们 90%的时间。fastai 库旨在提供尽可能简单的工具。
在本书中,我们将看到更多基于模型驱动的数据清洗示例。一旦我们清理了数据,我们就可以重新训练我们的模型。自己尝试一下,看看你的准确性是否有所提高!
# 不需要大数据
通过这些步骤清理数据集后,我们通常在这个任务上看到 100%的准确性。即使我们下载的图像比我们在这里使用的每类 150 张要少得多,我们也能看到这个结果。正如您所看到的,*您需要大量数据才能进行深度学习*的常见抱怨可能与事实相去甚远!
现在我们已经训练了我们的模型,让我们看看如何部署它以便在实践中使用。
# 将您的模型转化为在线应用程序
现在我们将看看将这个模型转化为一个可工作的在线应用程序需要什么。我们将只创建一个基本的工作原型;在本书中,我们没有范围来教授您有关 Web 应用程序开发的所有细节。
## 使用模型进行推断
一旦您拥有一个满意的模型,您需要保存它,以便随后将其复制到一个服务器上,在那里您将在生产中使用它。请记住,模型由两部分组成:*架构*和训练的*参数*。保存模型的最简单方法是保存这两部分,因为这样,当您加载模型时,您可以确保具有匹配的架构和参数。要保存这两部分,请使用`export`方法。
这种方法甚至保存了如何创建您的`DataLoaders`的定义。这很重要因为否则您将不得不重新定义如何转换您的数据以便在生产中使用您的模型。fastai 默认使用验证集`DataLoader`进行推理,因此不会应用数据增强,这通常是您想要的。
当您调用`export`时fastai 将保存一个名为*export.pkl*的文件:
```py
learn.export()
```
让我们通过使用 fastai 添加到 Python 的`Path`类的`ls`方法来检查文件是否存在:
```py
path = Path()
path.ls(file_exts='.pkl')
```
```py
(#1) [Path('export.pkl')]
```
您需要这个文件在您部署应用程序的任何地方。现在,让我们尝试在我们的笔记本中创建一个简单的应用程序。
当我们使用模型进行预测而不是训练时,我们称之为*推理*。要从导出的文件创建我们的推理学习者,我们使用`load_learner`(在这种情况下,这并不是真正必要的,因为我们已经在笔记本中有一个工作的`Learner`;我们在这里这样做是为了让您看到整个过程的始终):
```py
learn_inf = load_learner(path/'export.pkl')
```
在进行推理时,通常一次只为一个图像获取预测。要做到这一点,将文件名传递给`predict`
```py
learn_inf.predict('images/grizzly.jpg')
```
```py
('grizzly', tensor(1), tensor([9.0767e-06, 9.9999e-01, 1.5748e-07]))
```
这返回了三个东西:以与您最初提供的格式相同的预测类别(在本例中,这是一个字符串),预测类别的索引以及每个类别的概率。最后两个是基于`DataLoaders`的*vocab*中类别的顺序;也就是说,所有可能类别的存储列表。在推理时,您可以将`DataLoaders`作为`Learner`的属性访问:
```py
learn_inf.dls.vocab
```
```py
(#3) ['black','grizzly','teddy']
```
我们可以看到,如果我们使用`predict`返回的整数索引到 vocab 中,我们会得到“灰熊”,这是预期的。另外,请注意,如果我们在概率列表中进行索引,我们会看到几乎有 1.00 的概率这是一只灰熊。
我们知道如何从保存的模型中进行预测,因此我们拥有开始构建我们的应用程序所需的一切。我们可以直接在 Jupyter 笔记本中完成。
## 从模型创建一个笔记本应用
要在应用程序中使用我们的模型,我们可以简单地将`predict`方法视为常规函数。因此,使用任何应用程序开发人员可用的各种框架和技术都可以创建一个从模型创建的应用程序。
然而,大多数数据科学家并不熟悉 Web 应用程序开发领域。因此,让我们尝试使用您目前已经了解的东西:事实证明,我们可以仅使用 Jupyter 笔记本创建一个完整的工作 Web 应用程序!使这一切成为可能的两个因素如下:
+ IPython 小部件(ipywidgets)
+ Voilà
*IPython 小部件*是 GUI 组件,它在 Web 浏览器中将 JavaScript 和 Python 功能结合在一起,并可以在 Jupyter 笔记本中创建和使用。例如,我们在本章前面看到的图像清理器完全是用 IPython 小部件编写的。但是,我们不希望要求我们的应用程序用户自己运行 Jupyter。
这就是*Voilà*存在的原因。它是一个使 IPython 小部件应用程序可供最终用户使用的系统,而无需他们使用 Jupyter。Voilà利用了一个事实即笔记本*已经是*一种 Web 应用程序,只是另一个复杂的依赖于另一个 Web 应用程序Jupyter 本身的 Web 应用程序。基本上,它帮助我们自动将我们已经隐式创建的复杂 Web 应用程序(笔记本)转换为一个更简单、更易部署的 Web 应用程序,它的功能类似于普通的 Web 应用程序,而不是笔记本。
但是我们仍然可以在笔记本中开发的优势,因此使用 ipywidgets我们可以逐步构建我们的 GUI。我们将使用这种方法创建一个简单的图像分类器。首先我们需要一个文件上传小部件
```py
btn_upload = widgets.FileUpload()
btn_upload
```
![上传按钮](img/dlcf_01in02.png)
现在我们可以获取图像:
```py
img = PILImage.create(btn_upload.data[-1])
```
![表示图像的输出小部件](img/dlcf_02in11.png)
我们可以使用`Output`小部件来显示它:
```py
out_pl = widgets.Output()
out_pl.clear_output()
with out_pl: display(img.to_thumb(128,128))
out_pl
```
![表示图像的输出小部件](img/dlcf_02in11.png)
然后我们可以得到我们的预测:
```py
pred,pred_idx,probs = learn_inf.predict(img)
```
并使用`Label`来显示它们:
```py
lbl_pred = widgets.Label()
lbl_pred.value = f'Prediction: {pred}; Probability: {probs[pred_idx]:.04f}'
lbl_pred
```
`预测灰熊概率1.0000`
我们需要一个按钮来进行分类。它看起来与上传按钮完全相同:
```py
btn_run = widgets.Button(description='Classify')
btn_run
```
我们还需要一个*点击事件处理程序*;也就是说,当按下按钮时将调用的函数。我们可以简单地复制之前的代码行:
```py
def on_click_classify(change):
img = PILImage.create(btn_upload.data[-1])
out_pl.clear_output()
with out_pl: display(img.to_thumb(128,128))
pred,pred_idx,probs = learn_inf.predict(img)
lbl_pred.value = f'Prediction: {pred}; Probability: {probs[pred_idx]:.04f}'
btn_run.on_click(on_click_classify)
```
您现在可以通过单击按钮来测试按钮,您应该会看到图像和预测会自动更新!
现在,我们可以将它们全部放在一个垂直框(`VBox`)中,以完成我们的 GUI
```py
VBox([widgets.Label('Select your bear!'),
btn_upload, btn_run, out_pl, lbl_pred])
```
![整个小部件](img/dlcf_02in13.png)
我们已经编写了所有必要的应用程序代码。下一步是将其转换为我们可以部署的内容。
## 将您的笔记本变成一个真正的应用程序
现在我们在这个 Jupyter 笔记本中已经让一切运转起来了,我们可以创建我们的应用程序。为此,请启动一个新的笔记本,并仅添加创建和显示所需小部件的代码,以及任何要显示的文本的 Markdown。查看书中存储库中的*bear_classifier*笔记本,看看我们创建的简单笔记本应用程序。
接下来,如果您尚未安装 Voilà请将这些行复制到笔记本单元格中并执行
```py
!pip install voila
!jupyter serverextension enable voila --sys-prefix
```
以`!`开头的单元格不包含 Python 代码,而是包含传递给您的 shellbashWindows PowerShell 等)的代码。如果您习惯使用命令行,我们将在本书中更详细地讨论这一点,您当然可以直接在终端中键入这两行(不带`!`前缀)。在这种情况下,第一行安装`voila`库和应用程序,第二行将其连接到您现有的 Jupyter 笔记本。
Voilà运行 Jupyter 笔记本,就像您现在使用的 Jupyter 笔记本服务器一样,但它还做了一件非常重要的事情:它删除了所有单元格输入,仅显示输出(包括 ipywidgets以及您的 Markdown 单元格。因此,剩下的是一个 Web 应用程序!要将您的笔记本视为 Voilà Web 应用程序,请将浏览器 URL 中的“notebooks”一词替换为“voila/render”。您将看到与您的笔记本相同的内容但没有任何代码单元格。
当然,您不需要使用 Voilà或 ipywidgets。您的模型只是一个可以调用的函数`predpred_idxprobs = learn.predictimg`),因此您可以将其与任何框架一起使用,托管在任何平台上。您可以将在 ipywidgets 和 Voilà中原型设计的内容稍后转换为常规 Web 应用程序。我们在本书中展示这种方法,因为我们认为这是数据科学家和其他不是 Web 开发专家的人从其模型创建应用程序的绝佳方式。
我们有了我们的应用程序;现在让我们部署它!
## 部署您的应用程序
正如您现在所知,几乎任何有用的深度学习模型都需要 GPU 来训练。那么,在生产中使用该模型需要 GPU 吗?不需要!您几乎可以肯定*在生产中不需要 GPU 来提供您的模型*。这样做有几个原因:
+ 正如我们所见GPU 仅在并行执行大量相同工作时才有用。如果您正在进行(比如)图像分类,通常一次只会对一个用户的图像进行分类,而且通常在一张图像中没有足够的工作量可以让 GPU 忙碌足够长的时间以使其非常有效。因此CPU 通常更具成本效益。
+ 另一种选择可能是等待一些用户提交他们的图像,然后将它们批量处理并一次性在 GPU 上处理。但是这样会让用户等待,而不是立即得到答案!而且您需要一个高流量的网站才能实现这一点。如果您确实需要这种功能,您可以使用诸如 Microsoft 的[ONNX Runtime](https://oreil.ly/nj-6f)或[AWS SageMaker](https://oreil.ly/ajcaP)之类的工具。
+ 处理 GPU 推理的复杂性很大。特别是GPU 的内存需要仔细手动管理,您需要一个仔细的排队系统,以确保一次只处理一个批次。
+ CPU 服务器的市场竞争要比 GPU 服务器更激烈,因此 CPU 服务器有更便宜的选项可供选择。
由于 GPU 服务的复杂性,许多系统已经出现尝试自动化此过程。然而,管理和运行这些系统也很复杂,通常需要将您的模型编译成专门针对该系统的不同形式。通常最好避免处理这种复杂性,直到/除非您的应用程序变得足够受欢迎,以至于您有明显的财务理由这样做。
至少对于您的应用程序的初始原型以及您想展示的任何爱好项目,您可以轻松免费托管它们。最佳位置和最佳方式随时间而变化,因此请查看本书网站以获取最新的建议。由于我们在 2020 年初撰写本书,最简单(且免费!)的方法是使用[Binder](https://mybinder.org)。要在 Binder 上发布您的 Web 应用程序,请按照以下步骤操作:
1. 将您的笔记本添加到[GitHub 存储库](http://github.com)。
1. 将该存储库的 URL 粘贴到 Binder 的 URL 字段中,如图 2-4 所示。
1. 将文件下拉菜单更改为选择 URL。
1. 在“要打开的 URL”字段中输入`/voila/render/*name*.ipynb`(将*`name`*替换为您笔记本的名称)。
1. 单击右下角的剪贴板按钮以复制 URL并将其粘贴到安全位置。
1. 单击“启动”。
![部署到 Binder](img/dlcf_0204.png)
###### 图 2-4\. 部署到 Binder
第一次执行此操作时Binder 将花费大约 5 分钟来构建您的站点。在幕后,它正在查找一个可以运行您的应用程序的虚拟机,分配存储空间,并收集所需的文件以用于 Jupyter、您的笔记本以及将您的笔记本呈现为 Web 应用程序。
最后,一旦启动应用程序运行,它将导航您的浏览器到您的新 Web 应用程序。您可以分享您复制的 URL 以允许其他人访问您的应用程序。
要了解部署 Web 应用程序的其他(免费和付费)选项,请务必查看[书籍网站](https://book.fast.ai)。
您可能希望将应用程序部署到移动设备或边缘设备,如树莓派。有许多库和框架允许您将模型直接集成到移动应用程序中。但是,这些方法往往需要许多额外的步骤和样板文件,并且并不总是支持您的模型可能使用的所有 PyTorch 和 fastai 层。此外,您所做的工作将取决于您针对部署的移动设备的类型 - 您可能需要做一些工作以在 iOS 设备上运行,不同的工作以在较新的 Android 设备上运行,不同的工作以在较旧的 Android 设备上运行,等等。相反,我们建议在可能的情况下,将模型本身部署到服务器,并让您的移动或边缘应用程序连接到它作为 Web 服务。
这种方法有很多优点。初始安装更容易,因为您只需部署一个小型 GUI 应用程序,该应用程序连接到服务器执行所有繁重的工作。更重要的是,核心逻辑的升级可以在您的服务器上进行,而不需要分发给所有用户。您的服务器将拥有比大多数边缘设备更多的内存和处理能力,并且如果您的模型变得更加苛刻,那么扩展这些资源将更容易。您在服务器上拥有的硬件也将更加标准化,并且更容易受到 fastai 和 PyTorch 的支持,因此您不必将模型编译成不同的形式。
当然也有缺点。你的应用程序将需要网络连接,每次调用模型时都会有一些延迟。(神经网络模型本来就需要一段时间来运行,所以这种额外的网络延迟在实践中可能对用户没有太大影响。事实上,由于你可以在服务器上使用更好的硬件,总体延迟甚至可能比在本地运行时更少!)此外,如果你的应用程序使用敏感数据,你的用户可能会担心采用将数据发送到远程服务器的方法,因此有时隐私考虑将意味着你需要在边缘设备上运行模型(通过在公司防火墙内部设置*本地*服务器可能可以避免这种情况)。管理复杂性和扩展服务器也可能会带来额外的开销,而如果你的模型在边缘设备上运行,每个用户都会带来自己的计算资源,这将导致随着用户数量的增加更容易扩展(也称为*水平扩展*)。
# Alexis 说
我有机会近距离看到移动机器学习领域在我的工作中是如何变化的。我们提供一个依赖于计算机视觉的 iPhone 应用程序多年来我们在云中运行我们自己的计算机视觉模型。那时这是唯一的方法因为那些模型需要大量的内存和计算资源并且需要几分钟来处理输入。这种方法不仅需要构建模型有趣还需要构建基础设施来确保一定数量的“计算工作机器”始终在运行可怕如果流量增加更多的机器会自动上线有稳定的存储用于大型输入和输出iOS 应用程序可以知道并告诉用户他们的工作进展如何等等。如今,苹果提供了 API可以将模型转换为在设备上高效运行大多数 iOS 设备都有专用的 ML 硬件,所以这是我们用于新模型的策略。这仍然不容易,但在我们的情况下,为了更快的用户体验和更少地担心服务器,这是值得的。对你来说有效的方法将取决于你试图创建的用户体验以及你个人认为容易做的事情。如果你真的知道如何运行服务器,那就去做。如果你真的知道如何构建本地移动应用程序,那就去做。有很多条路通往山顶。
总的来说,我们建议在可能的情况下尽可能使用简单的基于 CPU 的服务器方法,只要你能够做到。如果你足够幸运拥有一个非常成功的应用程序,那么你将能够在那个时候为更复杂的部署方法进行投资。
恭喜你——你已经成功构建了一个深度学习模型并部署了它!现在是一个很好的时机停下来思考可能出现的问题。
# 如何避免灾难
在实践中一个深度学习模型只是一个更大系统中的一部分。正如我们在本章开头讨论的那样构建数据产品需要考虑整个端到端的过程从概念到在生产中使用。在这本书中我们无法希望涵盖所有管理部署数据产品的复杂性比如管理多个模型版本A/B 测试,金丝雀发布,刷新数据(我们应该一直增加和增加我们的数据集,还是应该定期删除一些旧数据?),处理数据标记,监控所有这些,检测模型腐烂等等。
在本节中,我们将概述一些需要考虑的最重要问题;关于部署问题的更详细讨论,我们建议您参考 Emmanuel AmeisinO'Reilly的优秀著作《构建机器学习驱动的应用程序》。
需要考虑的最大问题之一是,理解和测试深度学习模型的行为比大多数其他代码更困难。在正常软件开发中,您可以分析软件所采取的确切步骤,并仔细研究这些步骤中哪些与您试图创建的期望行为相匹配。但是,对于神经网络,行为是从模型尝试匹配训练数据中产生的,而不是精确定义的。
这可能导致灾难!例如,假设我们真的正在推出一个熊检测系统,将连接到国家公园露营地周围的视频摄像头,并警告露营者有熊靠近。如果我们使用下载的数据集训练的模型,实际上会出现各种问题,比如:
+ 处理视频数据而不是图像
+ 处理可能不在数据集中出现的夜间图像
+ 处理低分辨率摄像头图像
+ 确保结果返回得足够快以在实践中有用
+ 在照片中很少见到的位置识别熊(例如从背后,部分被灌木覆盖,或者离摄像机很远)
问题的一个重要部分是,人们最有可能上传到互联网的照片是那些能够清晰艺术地展示主题的照片,而这并不是该系统将获得的输入类型。因此,我们可能需要进行大量自己的数据收集和标记以创建一个有用的系统。
这只是更一般的“域外”数据问题的一个例子。也就是说,在生产中,我们的模型可能看到与训练时非常不同的数据。这个问题没有完全的技术解决方案;相反,我们必须谨慎地推出技术。
我们还需要小心的其他原因。一个非常常见的问题是*域漂移*,即我们的模型看到的数据类型随着时间的推移而发生变化。例如,一个保险公司可能将深度学习模型用作其定价和风险算法的一部分,但随着时间的推移,公司吸引的客户类型和代表的风险类型可能发生如此大的变化,以至于原始训练数据不再相关。
域外数据和域漂移是更大问题的例子:您永远无法完全理解神经网络的所有可能行为,因为它们有太多参数。这是它们最好特性的自然缺点——它们的灵活性,使它们能够解决我们甚至可能无法完全指定首选解决方案的复杂问题。然而,好消息是,有办法通过一个经过深思熟虑的过程来减轻这些风险。这些细节将根据您正在解决的问题的细节而变化,但我们将尝试提出一个高层次的方法,总结在图 2-5 中,我们希望这将提供有用的指导。
![部署过程](img/dlcf_0205.png)
###### 图 2-5\. 部署过程
在可能的情况下,第一步是使用完全手动的过程,您的深度学习模型方法并行运行,但不直接用于驱动任何操作。参与手动过程的人员应查看深度学习输出,并检查其是否合理。例如,对于我们的熊分类器,公园管理员可以在屏幕上显示所有摄像头的视频源,任何可能的熊目击都会被简单地用红色突出显示。在部署模型之前,公园管理员仍然应该像以前一样警惕;模型只是在这一点上帮助检查问题。
第二步是尝试限制模型的范围,并由人仔细监督。例如,对模型驱动方法进行小范围地理和时间限制的试验。与其在全国各地的每个国家公园推出我们的熊分类器,我们可以选择一个单一的观测站,在一个星期的时间内,让一名公园管理员在每次警报发出之前检查。
然后,逐渐扩大您的推出范围。在这样做时,请确保您有非常好的报告系统,以确保您了解与您的手动流程相比所采取的行动是否发生了重大变化。例如,如果在某个地点推出新系统后,熊警报数量翻倍或减半,您应该非常关注。尝试考虑系统可能出错的所有方式,然后考虑什么措施、报告或图片可以反映出这个问题,并确保您的定期报告包含这些信息。
# 杰里米说
20 年前,我创办了一家名为 Optimal Decisions 的公司,利用机器学习和优化帮助巨大的保险公司设定价格,影响数千亿美元的风险。我们使用这里描述的方法来管理可能出错的潜在风险。此外,在与客户合作将任何东西投入生产之前,我们尝试通过在他们去年的数据上测试端到端系统的影响来模拟影响。将这些新算法投入生产总是一个非常紧张的过程,但每次推出都取得了成功。
## 意想不到的后果和反馈循环
推出模型的最大挑战之一是您的模型可能会改变其所属系统的行为。例如考虑一个“预测执法”算法它预测某些社区的犯罪率更高导致更多警察被派往这些社区这可能导致这些社区记录更多犯罪依此类推。在皇家统计学会的论文“预测和服务”中Kristian Lum 和 William Isaac 观察到“预测性执法的命名恰如其分:它预测未来的执法,而不是未来的犯罪。”
在这种情况下的部分问题是,在存在偏见的情况下(我们将在下一章中深入讨论),*反馈循环*可能导致该偏见的负面影响变得越来越严重。例如,在美国已经存在着在种族基础上逮捕率存在显著偏见的担忧。根据美国公民自由联盟的说法,“尽管使用率大致相等,黑人因大麻被逮捕的可能性是白人的 3.73 倍。”这种偏见的影响,以及在美国许多地区推出预测性执法算法,导致 Bärí Williams 在*纽约时报*中写道:“在我的职业生涯中引起如此多兴奋的技术正在以可能意味着在未来几年,我的 7 岁儿子更有可能因为他的种族和我们居住的地方而被无故定性或逮捕,甚至更糟。”
在推出重要的机器学习系统之前,一个有用的练习是考虑这个问题:“如果它真的很成功会发生什么?”换句话说,如果预测能力非常高,对行为的影响非常显著,那么会发生什么?谁会受到最大影响?最极端的结果可能是什么样的?你怎么知道到底发生了什么?
这样的思考练习可能会帮助你制定一个更加谨慎的推出计划,配备持续监控系统和人类监督。当然,如果人类监督没有被听取,那么它就没有用,因此确保可靠和有弹性的沟通渠道存在,以便正确的人会意识到问题并有权力解决它们。
# 开始写作吧!
我们的学生发现最有帮助巩固对这一材料的理解的事情之一是把它写下来。尝试教给别人是对你对一个主题的理解的最好测试。即使你从不向任何人展示你的写作,这也是有帮助的,但如果你分享了,那就更好了!因此,我们建议,如果你还没有开始写博客,那么现在就开始吧。现在你已经完成了这一章并学会了如何训练和部署模型,你已经可以写下你的第一篇关于深度学习之旅的博客文章了。你有什么惊讶?你在你的领域看到了深度学习的机会?你看到了什么障碍?
fast.ai 的联合创始人 Rachel Thomas 在文章[“为什么你(是的,你)应该写博客”](https://oreil.ly/X9-3L)中写道:
> 我会给年轻的自己的最重要建议是尽早开始写博客。以下是一些写博客的理由:
>
> + 这就像一份简历,只不过更好。我知道有几个人因为写博客文章而得到了工作机会!
> +
> + 帮助你学习。组织知识总是帮助我整合自己的想法。是否理解某事的一个测试是你是否能够向别人解释它。博客文章是一个很好的方式。
> +
> + 我通过我的博客文章收到了参加会议的邀请和演讲邀请。我因为写了一篇关于我不喜欢 TensorFlow 的博客文章而被邀请参加 TensorFlow Dev Summit太棒了
> +
> + 结识新朋友。我认识了几个回复我写的博客文章的人。
> +
> + 节省时间。每当你通过电子邮件多次回答同一个问题时,你应该把它变成一篇博客文章,这样下次有人问起时你就更容易分享了。
也许她最重要的建议是:
> 你最适合帮助比你落后一步的人。这些材料仍然新鲜在你的脑海中。许多专家已经忘记了作为初学者(或中级学习者)时的感受,忘记了当你第一次听到这个话题时为什么难以理解。你特定背景、风格和知识水平的背景将为你所写的内容带来不同的视角。
我们已经提供了如何在附录 A 中设置博客的详细信息。如果你还没有博客,现在就看看吧,因为我们有一个非常好的方法让你免费开始写博客,没有广告,甚至可以使用 Jupyter Notebook
# 问卷调查
1. 文本模型目前存在哪些主要不足之处?
1. 文本生成模型可能存在哪些负面社会影响?
1. 在模型可能犯错且这些错误可能有害的情况下,自动化流程的一个好的替代方案是什么?
1. 深度学习在哪种表格数据上特别擅长?
1. 直接使用深度学习模型进行推荐系统的一个主要缺点是什么?
1. 驱动器方法的步骤是什么?
1. 驱动器方法的步骤如何映射到推荐系统?
1. 使用你策划的数据创建一个图像识别模型,并将其部署在网络上。
1. `DataLoaders`是什么?
1. 我们需要告诉 fastai 创建`DataLoaders`的四件事是什么?
1. `DataBlock`中的`splitter`参数是做什么的?
1. 我们如何确保随机分割总是给出相同的验证集?
1. 哪些字母通常用来表示自变量和因变量?
1. 裁剪、填充和压缩调整方法之间有什么区别?在什么情况下你会选择其中之一?
1. 什么是数据增强?为什么需要它?
1. 提供一个例子,说明熊分类模型在生产中可能因训练数据的结构或风格差异而效果不佳。
1. `item_tfms`和`batch_tfms`之间有什么区别?
1. 混淆矩阵是什么?
1. `export`保存了什么?
1. 当我们使用模型进行预测而不是训练时,这被称为什么?
1. IPython 小部件是什么?
1. 什么时候会使用 CPU 进行部署?什么时候 GPU 可能更好?
1. 将应用部署到服务器而不是客户端(或边缘)设备(如手机或 PC的缺点是什么
1. 在实践中推出熊警告系统时可能出现的三个问题的例子是什么?
1. 什么是域外数据?
1. 什么是领域转移?
1. 部署过程中的三个步骤是什么?
## 进一步研究
1. 考虑一下驱动器方法如何映射到你感兴趣的项目或问题。
1. 在什么情况下最好避免某些类型的数据增强?
1. 对于你有兴趣应用深度学习的项目,考虑一下这个思维实验,“如果它进展得非常顺利会发生什么?”
1. 开始写博客,撰写你的第一篇博客文章。例如,写一下你认为深度学习在你感兴趣的领域可能有用的地方。

View File

@ -0,0 +1,619 @@
# 第三章:数据伦理
正如我们在第一章和第二章中讨论的,有时机器学习模型可能出错。它们可能有错误。它们可能被呈现出以前没有见过的数据,并以我们意料之外的方式行事。或者它们可能完全按设计工作,但被用于我们非常希望它们永远不要被用于的事情。
因为深度学习是如此强大的工具,可以用于很多事情,所以我们特别需要考虑我们选择的后果。哲学上对*伦理*的研究是对对错的研究,包括我们如何定义这些术语,识别对错行为,以及理解行为和后果之间的联系。*数据伦理*领域已经存在很长时间,许多学者都专注于这个领域。它被用来帮助定义许多司法管辖区的政策;它被用在大大小小的公司中,考虑如何最好地确保产品开发对社会的良好结果;它被研究人员用来确保他们正在做的工作被用于好的目的,而不是坏的目的。
因此,作为一个深度学习从业者,你很可能在某个时候会面临需要考虑数据伦理的情况。那么数据伦理是什么?它是伦理学的一个子领域,所以让我们从那里开始。
# 杰里米说
在大学里,伦理哲学是我的主要研究领域(如果我完成了论文,而不是辍学加入现实世界,它本来会是我的论文题目)。根据我花在研究伦理学上的年份,我可以告诉你这个:没有人真正同意什么是对什么是错,它们是否存在,如何识别它们,哪些人是好人哪些人是坏人,或者几乎任何其他事情。所以不要对理论抱太大期望!我们将在这里专注于例子和思考的起点,而不是理论。
在回答问题[“什么是伦理?”](https://oreil.ly/nyVh4) 应用伦理马库拉中心说,这个术语指的是以下内容:
+ 有根据的对人类应该做什么的正确和错误的标准
+ 研究和发展自己的伦理标准
没有正确答案的清单。没有应该和不应该做的清单。伦理是复杂的,依赖于背景。它涉及许多利益相关者的观点。伦理是一个你必须发展和实践的能力。在本章中,我们的目标是提供一些路标,帮助你在这个旅程中前进。
发现伦理问题最好是作为一个协作团队的一部分来做。这是你真正可以融入不同观点的唯一方式。不同人的背景将帮助他们看到你可能没有注意到的事情。与团队合作对于许多“锻炼肌肉”的活动都是有帮助的,包括这个。
这一章当然不是本书中唯一讨论数据伦理的部分,但是有一个地方专注于它一段时间是很好的。为了定位,也许最容易看一些例子。所以,我们挑选了三个我们认为有效地说明了一些关键主题的例子。
# 数据伦理的关键例子
我们将从三个具体的例子开始,这些例子说明了技术中三个常见的伦理问题(我们将在本章后面更深入地研究这些问题):
救济程序
阿肯色州有缺陷的医疗保健算法让患者陷入困境。
反馈循环
YouTube 的推荐系统帮助引发了阴谋论繁荣。
偏见
当在谷歌上搜索传统的非裔美国人名字时,会显示犯罪背景调查的广告。
事实上,在本章中我们介绍的每个概念,我们都会提供至少一个具体的例子。对于每一个例子,想想在这种情况下你可以做什么,以及可能会有什么样的障碍阻止你完成。你会如何处理它们?你会注意什么?
## 错误和救济:用于医疗福利的错误算法
《The Verge》调查了在美国半数以上州使用的软件以确定人们接受多少医疗保健并在文章《当算法削减您的医疗保健时会发生什么》中记录了其发现。在阿肯色州实施算法后数百人许多患有严重残疾的人的医疗保健被大幅削减。
例如Tammy Dobbs 是一名患有脑瘫的女性,需要助手帮助她起床、上厕所、拿食物等,她的帮助时间突然减少了 20 小时每周。她无法得到任何解释为什么她的医疗保健被削减。最终一场法庭案件揭示了算法的软件实施中存在错误对患有糖尿病或脑瘫的人造成了负面影响。然而Dobbs 和许多其他依赖这些医疗福利的人生活在恐惧中,担心他们的福利可能再次突然而莫名其妙地被削减。
## 反馈循环YouTube 的推荐系统
当您的模型控制您获得的下一轮数据时,反馈循环可能会发生。返回的数据很快就会被软件本身破坏。
例如YouTube 有 19 亿用户,他们每天观看超过 10 亿小时的 YouTube 视频。其推荐算法(由谷歌构建)旨在优化观看时间,负责约 70%的观看内容。但出现了问题:它导致了失控的反馈循环,导致《纽约时报》在 2019 年 2 月发表了标题为《YouTube 引发了阴谋论繁荣。能够控制吗?》的文章。表面上,推荐系统正在预测人们会喜欢什么内容,但它们也在很大程度上决定了人们甚至看到什么内容。
## 偏见:拉塔尼亚·斯威尼“被捕”
拉塔尼亚·斯威尼博士是哈佛大学的教授也是该大学数据隐私实验室的主任。在论文《在线广告投放中的歧视》中她描述了她发现谷歌搜索她的名字会出现“拉塔尼亚·斯威尼被捕了”的广告尽管她是唯一已知的拉塔尼亚·斯威尼从未被捕。然而当她搜索其他名字如“Kirsten Lindquist”时她得到了更中立的广告尽管 Kirsten Lindquist 已经被捕了三次。
![谷歌搜索显示关于拉塔尼亚·斯威尼(不存在的)被捕记录的广告](img/dlcf_0301.png)
###### 图 3-1。谷歌搜索显示关于拉塔尼亚·斯威尼不存在的被捕记录的广告
作为一名计算机科学家,她系统地研究了这个问题,并查看了 2000 多个名字。她发现了一个明显的模式:历史上黑人的名字会收到暗示这个人有犯罪记录的广告,而传统上的白人名字则会有更中立的广告。
这是偏见的一个例子。它可能对人们的生活产生重大影响,例如,如果一个求职者被谷歌搜索,可能会出现他们有犯罪记录的情况,而实际上并非如此。
## 这为什么重要?
考虑这些问题的一个非常自然的反应是:“那又怎样?这和我有什么关系?我是一名数据科学家,不是政治家。我不是公司的高级执行官之一,他们决定我们要做什么。我只是尽力构建我能构建的最具预测性的模型。”
这些是非常合理的问题。但我们将试图说服您,答案是每个训练模型的人都绝对需要考虑他们的模型将如何被使用,并考虑如何最好地确保它们被尽可能积极地使用。有一些你可以做的事情。如果你不这样做,事情可能会变得相当糟糕。
当技术人员以任何代价专注于技术时,发生的一个特别可怕的例子是 IBM 与纳粹德国的故事。2001 年,一名瑞士法官裁定认为“推断 IBM 的技术援助促进了纳粹在犯下反人类罪行时的任务,这些行为还涉及 IBM 机器进行的会计和分类,并在集中营中使用。”
你看IBM 向纳粹提供了数据制表产品,以追踪大规模灭绝犹太人和其他群体。这是公司高层的决定,向希特勒及其领导团队推销。公司总裁托马斯·沃森亲自批准了 1939 年发布特殊的 IBM 字母排序机,以帮助组织波兰犹太人的驱逐。在图 3-2 中,阿道夫·希特勒(最左)与 IBM 首席执行官汤姆·沃森(左二)会面,希特勒在 1937 年授予沃森特别的“对帝国的服务”奖章。
![IBM 首席执行官汤姆·沃森与阿道夫·希特勒会面的图片](img/dlcf_0302.png)
###### 图 3-2. IBM 首席执行官汤姆·沃森与阿道夫·希特勒会面
但这并不是个案 - 该组织的涉入是广泛的。IBM 及其子公司在集中营现场提供定期培训和维护打印卡片配置机器并在它们经常出现故障时进行维修。IBM 在其打孔卡系统上设置了每个人被杀害的方式他们被分配到的组别以及跟踪他们通过庞大的大屠杀系统所需的后勤信息的分类。IBM 在集中营中对犹太人的代码是 8约有 600 万人被杀害。对于罗姆人的代码是 12纳粹将他们标记为“不合群者”在“吉普赛营”中有超过 30 万人被杀害)。一般处决被编码为 4毒气室中的死亡被编码为 6。
![IBM 在集中营中使用的打孔卡的图片](img/dlcf_0303.png)
###### 图 3-3. IBM 在集中营中使用的打孔卡
当然参与其中的项目经理、工程师和技术人员只是过着普通的生活。照顾家人周日去教堂尽力做好自己的工作。服从命令。市场营销人员只是尽力实现他们的业务发展目标。正如《IBM 与大屠杀》Dialog Press的作者埃德温·布莱克所观察到的“对于盲目的技术官僚来说手段比目的更重要。犹太人民的毁灭变得更不重要因为 IBM 技术成就的振奋性只会因在面包排长队的时候赚取的奇幻利润而更加突出。”
退一步思考一下:如果你发现自己是一个最终伤害社会的系统的一部分,你会有什么感受?你会愿意了解吗?你如何帮助确保这种情况不会发生?我们在这里描述了最极端的情况,但今天观察到与人工智能和机器学习相关的许多负面社会后果,其中一些我们将在本章中描述。
这也不仅仅是道德负担。有时,技术人员会直接为他们的行为付出代价。例如,作为大众汽车丑闻的结果而被监禁的第一个人并不是监督该项目的经理,也不是公司的执行主管。而是其中一名工程师詹姆斯·梁,他只是听从命令。
当然,情况并非全是坏的 - 如果你参与的项目最终对一个人产生了巨大的积极影响,这会让你感到非常棒!
好的,希望我们已经说服您应该关心这个问题。但是您应该怎么做呢?作为数据科学家,我们自然倾向于通过优化某些指标来改进我们的模型。但是优化这个指标可能不会导致更好的结果。即使它确实有助于创造更好的结果,几乎肯定不会是唯一重要的事情。考虑一下从研究人员或从业者开发模型或算法到使用这项工作做出决策之间发生的步骤流程。如果我们希望获得我们想要的结果,整个流程必须被视为一个整体。
通常,从一端到另一端有一条非常长的链。如果您是一名研究人员,甚至可能不知道您的研究是否会被用于任何事情,或者如果您参与数据收集,那就更早了。但是没有人比您更适合告知所有参与这一链的人您的工作的能力、约束和细节。虽然没有“灵丹妙药”可以确保您的工作被正确使用,但通过参与这个过程,并提出正确的问题,您至少可以确保正确的问题正在被考虑。
有时,对于被要求做一项工作的正确回应就是说“不”。然而,我们经常听到的回应是:“如果我不做,别人会做。”但请考虑:如果您被选中做这项工作,那么您是他们找到的最合适的人——所以如果您不做,最合适的人就不会参与该项目。如果他们询问的前五个人也都说不,那就更好了!
# 将机器学习与产品设计整合
假设您做这项工作的原因是希望它被用于某些目的。否则,您只是在浪费时间。因此,让我们假设您的工作最终会有所作为。现在,当您收集数据并开发模型时,您会做出许多决定。您将以什么级别的聚合存储数据?应该使用什么损失函数?应该使用什么验证和训练集?您应该专注于实现的简单性、推理的速度还是模型的准确性?您的模型如何处理域外数据项?它可以进行微调,还是必须随时间从头开始重新训练?
这些不仅仅是算法问题。它们是数据产品设计问题。但是产品经理、高管、法官、记者、医生——最终会开发和使用您的模型的系统的人——将无法理解您所做的决定,更不用说改变它们了。
例如,两项研究发现亚马逊的面部识别软件产生了[不准确](https://oreil.ly/bL5D9)和[种族偏见](https://oreil.ly/cDYqz)的结果。亚马逊声称研究人员应该更改默认参数,但没有解释这将如何改变有偏见的结果。此外,事实证明,[亚马逊并没有指导使用其软件的警察部门](https://oreil.ly/I5OAj)这样做。可以想象,开发这些算法的研究人员和为警察提供指导的亚马逊文档人员之间存在很大的距离。
缺乏紧密整合导致社会、警察和亚马逊出现严重问题。结果表明,其系统错误地将 28 名国会议员与犯罪照片匹配!(而与犯罪照片错误匹配的国会议员是有色人种,如图 3-4 所示。)
![国会议员与亚马逊软件匹配的犯罪照片,他们是有色人种](img/dlcf_0304.png)
###### 图 3-4. 亚马逊软件将国会议员与犯罪照片匹配
数据科学家需要成为跨学科团队的一部分。研究人员需要与最终使用他们研究成果的人密切合作。更好的是,领域专家们自己可以学到足够的知识,以便能够自己训练和调试一些模型——希望你们中的一些人正在阅读这本书!
现代职场是一个非常专业化的地方。每个人都倾向于有明确定义的工作要做。特别是在大公司,很难知道所有的细节。有时公司甚至会故意模糊正在进行的整体项目目标,如果他们知道员工不会喜欢答案的话。有时通过尽可能地将部分隔离来实现这一点。
换句话说,我们并不是说这些都很容易。这很难。真的很难。我们都必须尽力而为。我们经常看到那些参与这些项目更高层次背景的人,试图发展跨学科能力和团队的人,成为他们组织中最重要和最受奖励的成员之一。这是一种工作,往往受到高级主管的高度赞赏,即使有时被中层管理人员认为相当不舒服。
# 数据伦理学主题
数据伦理学是一个广阔的领域,我们无法涵盖所有内容。相反,我们将选择一些我们认为特别相关的主题:
+ 追索和问责制的需求
+ 反馈循环
+ 偏见
+ 虚假信息
让我们依次看看每一个。
## 追索和问责制
在一个复杂的系统中,很容易没有任何一个人感到对结果负责。虽然这是可以理解的,但这并不会带来好的结果。在早期的阿肯色州医疗保健系统的例子中,一个错误导致患有脑瘫的人失去了所需护理的访问权限,算法的创建者责怪政府官员,政府官员责怪那些实施软件的人。纽约大学教授[丹娜·博伊德](https://oreil.ly/KK5Hf)描述了这种现象:“官僚主义经常被用来转移或逃避责任……今天的算法系统正在扩展官僚主义。”
追索如此必要的另一个原因是数据经常包含错误。审计和纠错机制至关重要。加利福尼亚执法官员维护的一个涉嫌帮派成员的数据库发现充满了错误,包括 42 名不到 1 岁的婴儿被添加到数据库中(其中 28 名被标记为“承认是帮派成员”。在这种情况下没有流程来纠正错误或在添加后删除人员。另一个例子是美国信用报告系统2012 年联邦贸易委员会FTC对信用报告进行的大规模研究发现26%的消费者的档案中至少有一个错误5%的错误可能是灾难性的。
然而,纠正这类错误的过程非常缓慢和不透明。当公共广播记者[鲍比·艾伦](https://oreil.ly/BUD6h)发现自己被错误列为有枪支罪时,他花了“十几个电话,一个县法院书记的手工操作和六周的时间来解决问题。而且这还是在我作为一名记者联系了公司的传播部门之后。”
作为机器学习从业者,我们并不总是认为理解我们的算法最终如何在实践中实施是我们的责任。但我们需要。
## 反馈循环
我们在第一章中解释了算法如何与环境互动以创建反馈循环,做出预测以加强在现实世界中采取的行动,从而导致更加明显朝着同一方向的预测。举个例子,让我们再次考虑 YouTube 的推荐系统。几年前,谷歌团队谈到他们如何引入了强化学习(与深度学习密切相关,但你的损失函数代表了潜在长时间后行动发生的结果)来改进 YouTube 的推荐系统。他们描述了如何使用一个算法,使推荐以优化观看时间为目标。
然而,人类往往被争议性内容所吸引。这意味着关于阴谋论之类的视频开始越来越多地被推荐给用户。此外,事实证明,对阴谋论感兴趣的人也是那些经常观看在线视频的人!因此,他们开始越来越多地被吸引到 YouTube。越来越多的阴谋论者在 YouTube 上观看视频导致算法推荐越来越多的阴谋论和其他极端内容,这导致更多的极端分子在 YouTube 上观看视频,更多的人在 YouTube 上形成极端观点,进而导致算法推荐更多的极端内容。系统失控了。
这种现象并不局限于这种特定类型的内容。2019 年 6 月,《纽约时报》发表了一篇关于 YouTube 推荐系统的文章,标题为[“在 YouTube 的数字游乐场,对恋童癖者敞开大门”](https://oreil.ly/81BEy)。文章以这个令人不安的故事开头:
> 当 Christiane C.的 10 岁女儿和一个朋友上传了一个在后院游泳池玩耍的视频时,她并没有在意……几天后……视频的观看次数已经达到了数千次。不久之后,观看次数已经增加到 40 万……“我再次看到视频看到观看次数我感到害怕”Christiane 说。她有理由感到害怕。研究人员发现YouTube 的自动推荐系统……开始向观看其他预备期、部分穿着少儿视频的用户展示这个视频。
>
> 单独看,每个视频可能是完全无辜的,比如一个孩子制作的家庭影片。任何暴露的画面都是短暂的,看起来是偶然的。但是,当它们被组合在一起时,它们共享的特征变得明显。
YouTube 的推荐算法开始为恋童癖者策划播放列表,挑选出偶然包含预备期、部分穿着少儿的无辜家庭视频。
谷歌没有计划创建一个将家庭视频变成儿童色情片的系统。那么发生了什么?
这里的问题之一是指标在推动一个财政重要系统中的核心性。当一个算法有一个要优化的指标时,正如你所看到的,它会尽其所能来优化这个数字。这往往会导致各种边缘情况,与系统互动的人类会寻找、发现并利用这些边缘情况和反馈循环以谋取利益。
有迹象表明,这正是发生在 YouTube 的推荐系统中的情况。*卫报*发表了一篇题为[“一位前 YouTube 内部人员是如何调查其秘密算法的”](https://oreil.ly/yjnPT)的文章,讲述了前 YouTube 工程师 Guillaume Chaslot 创建了一个[网站](https://algotransparency.org)来跟踪这些问题。Chaslot 在罗伯特·穆勒“关于 2016 年总统选举中俄罗斯干预调查”的发布后发布了图表,如图 3-5 所示。
![穆勒报告的报道](img/dlcf_0305.png)
###### 图 3-5. 穆勒报告的报道
俄罗斯今日电视台对穆勒报告的报道在推荐频道中是一个极端的离群值。这表明俄罗斯今日电视台,一个俄罗斯国有媒体机构,成功地操纵了 YouTube 的推荐算法。不幸的是,这种系统缺乏透明度,使我们很难揭示我们正在讨论的问题。
本书的一位审阅者 Aurélien Géron曾在 2013 年至 2016 年间领导 YouTube 的视频分类团队(远在这里讨论的事件之前)。他指出,涉及人类的反馈循环不仅是一个问题。也可能存在没有人类参与的反馈循环!他向我们讲述了 YouTube 的一个例子:
> 对视频的主题进行分类的一个重要信号是视频的来源频道。例如,上传到烹饪频道的视频很可能是烹饪视频。但我们如何知道一个频道的主题是什么?嗯...部分是通过查看它包含的视频的主题!你看到循环了吗?例如,许多视频有描述,指示拍摄视频所使用的相机。因此,一些视频可能被分类为“摄影”视频。如果一个频道有这样一个错误分类的视频,它可能被分类为“摄影”频道,使得未来在该频道上的视频更有可能被错误分类为“摄影”。这甚至可能导致失控的病毒般的分类!打破这种反馈循环的一种方法是对有和没有频道信号的视频进行分类。然后在对频道进行分类时,只能使用没有频道信号获得的类别。这样,反馈循环就被打破了。
有人和组织试图解决这些问题的积极例子。Meetup 的首席机器学习工程师 Evan Estola 讨论了男性对科技见面会表现出比女性更感兴趣的例子。因此,考虑性别可能会导致 Meetup 的算法向女性推荐更少的科技见面会结果导致更少的女性了解并参加科技见面会这可能导致算法向女性推荐更少的科技见面会如此循环反馈。因此Evan 和他的团队做出了道德决定,让他们的推荐算法不会创建这样的反馈循环,明确不在模型的那部分使用性别。看到一家公司不仅仅是盲目地优化指标,而是考虑其影响是令人鼓舞的。根据 Evan 的说法,“你需要决定在算法中不使用哪个特征...最优算法也许不是最适合投入生产的算法。”
尽管 Meetup 选择避免这种结果,但 Facebook 提供了一个允许失控的反馈循环肆虐的例子。与 YouTube 类似,它倾向于通过向用户介绍更多阴谋论来激化用户。正如虚构信息传播研究员 Renee DiResta 所写的那样:
> 一旦人们加入一个阴谋论倾向的[Facebook]群组,他们就会被算法路由到其他大量群组。加入反疫苗群组,你的建议将包括反转基因、化学尾迹观察、地平论者(是的,真的)和“自然治愈癌症”群组。推荐引擎不是将用户拉出兔子洞,而是将他们推得更深。
非常重要的是要记住这种行为可能会发生,并在看到自己项目中出现第一个迹象时,要么预见到一个反馈循环,要么采取积极行动来打破它。另一件要记住的事情是*偏见*,正如我们在上一章中简要讨论的那样,它可能与反馈循环以非常麻烦的方式相互作用。
## 偏见
在线讨论偏见往往会变得非常混乱。 “偏见”一词有很多不同的含义。统计学家经常认为,当数据伦理学家谈论偏见时,他们在谈论统计学术语“偏见”,但他们并没有。他们当然也没有在谈论出现在模型参数中的权重和偏见中的偏见!
他们所谈论的是社会科学概念中的偏见。在[“理解机器学习意外后果的框架”](https://oreil.ly/aF33V)中,麻省理工学院的 Harini Suresh 和 John Guttag 描述了机器学习中的六种偏见类型,总结在图 3-6 中。
![显示机器学习中偏见可能出现的所有来源的图表](img/dlcf_0306.png)
###### 图 3-6。机器学习中的偏见可能来自多个来源由 Harini Suresh 和 John V. Guttag 提供)
我们将讨论其中四种偏见类型,这些是我们在自己的工作中发现最有帮助的(有关其他类型的详细信息,请参阅论文)。
### 历史偏见
*历史偏见*源于人们的偏见,过程的偏见,以及社会的偏见。苏雷什和古塔格说:“历史偏见是数据生成过程的第一步存在的基本结构性问题,即使进行了完美的抽样和特征选择,它也可能存在。”
例如,以下是美国历史上*种族偏见*的几个例子,来自芝加哥大学 Sendhil Mullainathan 的《纽约时报》文章[“种族偏见,即使我们有良好意图”](https://oreil.ly/cBQop)
+ 当医生看到相同的档案时,他们更不可能向黑人患者推荐心脏导管化(一种有益的程序)。
+ 在讨价还价购买二手车时,黑人被要求支付的初始价格高出 700 美元,并获得了远低于预期的让步。
+ 在 Craigslist 上回应带有黑人姓名的公寓出租广告比带有白人姓名的回应要少。
+ 一个全白人陪审团比一个黑人被告有 16 个百分点更有可能定罪,但当陪审团有一个黑人成员时,他们以相同的比率定罪。
在美国用于判决和保释决定的 COMPAS 算法是一个重要算法的例子,当[ProPublica](https://oreil.ly/1XocO)进行测试时,实际上显示出明显的种族偏见(图 3-7
![表格显示即使重新犯罪COMPAS 算法更有可能给白人保释](img/dlcf_0307.png)
###### 图 3-7。COMPAS 算法的结果
任何涉及人类的数据集都可能存在这种偏见:医疗数据、销售数据、住房数据、政治数据等等。由于潜在偏见是如此普遍,数据集中的偏见也非常普遍。甚至在计算机视觉中也会出现种族偏见,正如 Twitter 上一位 Google 照片用户分享的自动分类照片的例子所示,见图 3-8。
![Google 照片使用黑人用户和她朋友的照片标记为大猩猩的屏幕截图](img/dlcf_0308.png)
###### 图 3-8。其中一个标签是非常错误的...
是的这正是你认为的Google 照片将一位黑人用户的照片与她的朋友一起分类为“大猩猩”!这种算法错误引起了媒体的广泛关注。一位公司女发言人表示:“我们对此感到震惊和真诚地抱歉。自动图像标记仍然存在许多问题,我们正在研究如何防止将来发生这类错误。”
不幸的是,当输入数据存在问题时,修复机器学习系统中的问题是困难的。谷歌的第一次尝试并没有激发信心,正如*卫报*的报道所建议的那样(图 3-9
![来自卫报的标题图片,当谷歌从其算法的可能标签中删除大猩猩和其他猴子时](img/dlcf_0309.png)
###### 图 3-9。谷歌对问题的第一次回应
这些问题当然不仅限于谷歌。麻省理工学院的研究人员研究了最受欢迎的在线计算机视觉 API以了解它们的准确性。但他们并不只是计算一个准确性数字而是查看了四个组的准确性如图 3-10 所示。
![表格显示各种面部识别系统在较深肤色和女性上表现更差](img/dlcf_0310.png)
###### 图 3-10。各种面部识别系统的性别和种族错误率
例如IBM 的系统对较深肤色的女性有 34.7%的错误率,而对较浅肤色的男性只有 0.3%的错误率——错误率高出 100 多倍!一些人对这些实验的反应是错误的,他们声称差异仅仅是因为较深的皮肤更难被计算机识别。然而,事实是,由于这一结果带来的负面宣传,所有相关公司都大幅改进了他们对较深肤色的模型,以至于一年后,它们几乎和对较浅肤色的一样好。因此,这表明开发人员未能利用包含足够多较深肤色面孔的数据集,或者未能用较深肤色的面孔测试他们的产品。
麻省理工学院的一位研究人员 Joy Buolamwini 警告说:“我们已经进入了自信过度但准备不足的自动化时代。如果我们未能制定道德和包容性的人工智能,我们将冒着在机器中立的幌子下失去民权和性别平等所取得的成就的风险。”
问题的一部分似乎是流行数据集的构成存在系统性不平衡用于训练模型。Shreya Shankar 等人的论文“没有代表性就没有分类:评估发展中国家开放数据集中的地理多样性问题”的摘要中指出,“我们分析了两个大型公开可用的图像数据集,以评估地理多样性,并发现这些数据集似乎存在明显的美洲中心和欧洲中心的代表性偏见。此外,我们分析了在这些数据集上训练的分类器,以评估这些训练分布的影响,并发现在不同地区的图像上表现出强烈的相对性能差异。”图 3-11 展示了论文中的一个图表,展示了当时(以及本书撰写时仍然如此)两个最重要的图像数据集的地理构成。
![图表显示流行训练数据集中绝大多数图像来自美国或西欧](img/dlcf_0311.png)
###### 图 3-11。流行训练集中的图像来源
绝大多数图像来自美国和其他西方国家,导致在 ImageNet 上训练的模型在其他国家和文化的场景中表现更差。例如,研究发现这样的模型在识别低收入国家的家庭物品(如肥皂、香料、沙发或床)时表现更差。图 3-12 展示了 Facebook AI Research 的 Terrance DeVries 等人的论文“目标识别对每个人都有效吗?”中的一幅图像,说明了这一点。
![图表显示一个目标检测算法在西方产品上表现更好](img/dlcf_0312.png)
###### 图 3-12。目标检测的实际应用
在这个例子中,我们可以看到低收入肥皂的例子离准确还有很长的路要走,每个商业图像识别服务都预测“食物”是最可能的答案!
接下来我们将讨论,绝大多数人工智能研究人员和开发人员都是年轻的白人男性。我们看到的大多数项目都是使用产品开发团队的朋友和家人进行用户测试。鉴于此,我们刚刚讨论的问题不应该令人惊讶。
类似的历史偏见也存在于用作自然语言处理模型数据的文本中。这会在许多下游机器学习任务中出现。例如,据[广泛报道](https://oreil.ly/Vt_vT)直到去年Google 翻译在将土耳其中性代词“o”翻译成英语时显示了系统性偏见当应用于通常与男性相关联的工作时它使用“he”而当应用于通常与女性相关联的工作时它使用“she”图 3-13
![显示语言模型训练中数据集中性别偏见在翻译中的体现的图表](img/dlcf_0313.png)
###### 图 3-13。文本数据集中的性别偏见
我们也在在线广告中看到这种偏见。例如2019 年穆罕默德·阿里等人的一项[研究](https://oreil.ly/UGxuh)发现即使放置广告的人没有故意歧视Facebook 也会根据种族和性别向非常不同的受众展示广告。展示了同样文本但图片分别是白人家庭或黑人家庭的房屋广告被展示给了种族不同的受众。
### 测量偏见
在《“机器学习是否自动化了道德风险和错误”》一文中Sendhil Mullainathan 和 Ziad Obermeyer 研究了一个模型试图回答这个问题使用历史电子健康记录EHR数据哪些因素最能预测中风这是该模型的前几个预测因素
+ 先前的中风
+ 心血管疾病
+ 意外伤害
+ 良性乳腺肿块
+ 结肠镜检查
+ 鼻窦炎
然而,只有前两个与中风有关!根据我们迄今所学,你可能已经猜到原因。我们实际上并没有测量*中风*,中风是由于脑部某个区域由于血液供应中断而被剥夺氧气而发生的。我们测量的是谁有症状,去看医生,接受了适当的检查,*并且*被诊断出中风。实际上患中风不仅与这个完整列表相关联,还与那些会去看医生的人相关联(这受到谁能获得医疗保健、能否负担得起自付款、是否经历种族或性别歧视等影响)!如果你在发生*意外伤害*时可能会去看医生,那么在中风时你也可能会去看医生。
这是*测量偏见*的一个例子。当我们的模型因为测量错误、以错误方式测量或不恰当地将该测量纳入模型时,就会发生这种偏见。
### 聚合偏见
*聚合偏见*发生在模型未以包含所有适当因素的方式聚合数据,或者模型未包含必要的交互项、非线性等情况下。这在医疗环境中尤其常见。例如,糖尿病的治疗通常基于简单的单变量统计和涉及小组异质人群的研究。结果分析通常未考虑不同种族或性别。然而,事实证明糖尿病患者在[不同种族之间有不同的并发症](https://oreil.ly/gNS39)HbA1c 水平(用于诊断和监测糖尿病的广泛指标)[在不同种族和性别之间以复杂方式不同](https://oreil.ly/nR4fx)。这可能导致人们被误诊或错误治疗,因为医疗决策基于不包含这些重要变量和交互作用的模型。
### 表征偏见
Maria De-Arteaga 等人的论文[“Bias in Bios: A Case Study of Semantic Representation Bias in a High-Stakes Setting”](https://oreil.ly/0iowq)的摘要指出,职业中存在性别不平衡(例如,女性更有可能成为护士,男性更有可能成为牧师),并表示“性别之间的真正阳性率差异与职业中现有的性别不平衡相关,这可能会加剧这些不平衡。”
换句话说,研究人员注意到,预测职业的模型不仅*反映*了潜在人口中的实际性别不平衡,而且*放大*了它!这种*表征偏差*是相当常见的,特别是对于简单模型。当存在明显、容易看到的基本关系时,简单模型通常会假定这种关系始终存在。正如论文中的图 3-14 所示,对于女性比例较高的职业,模型往往会高估该职业的普遍性。
![显示模型预测如何过度放大现有偏见的图表](img/dlcf_0314.png)
###### 图 3-14。预测职业中的模型误差与该职业中女性比例的关系
例如在训练数据集中14.6%的外科医生是女性,然而在模型预测中,真正阳性中只有 11.6%是女性。因此,模型放大了训练集中存在的偏见。
既然我们已经看到这些偏见存在,我们可以采取什么措施来减轻它们呢?
### 解决不同类型的偏见
不同类型的偏见需要不同的缓解方法。虽然收集更多样化的数据集可以解决表征偏见,但这对历史偏见或测量偏见无济于事。所有数据集都包含偏见。没有完全无偏的数据集。该领域的许多研究人员一直在提出一系列建议,以便更好地记录决策、背景和有关特定数据集创建方式的细节,以及为什么在什么情况下使用它,以及其局限性。这样,使用特定数据集的人不会被其偏见和局限性所困扰。
我们经常听到这样的问题,“人类有偏见,那么算法偏见真的重要吗?”这个问题经常被提出,肯定有一些让提问者认为有道理的理由,但对我们来说似乎并不太合乎逻辑!独立于这是否合乎逻辑,重要的是要意识到算法(特别是机器学习算法!)和人类是不同的。考虑一下关于机器学习算法的这些观点:
机器学习可以创建反馈循环
少量偏见可能会因为反馈循环而迅速呈指数增长。
机器学习可能会放大偏见
人类偏见可能导致更多的机器学习偏见。
算法和人类的使用方式不同
在实践中,人类决策者和算法决策者并不是以插拔方式互换使用的。这些例子列在下一页的清单中。
技术就是力量
随之而来的是责任。
正如阿肯色州医疗保健的例子所示机器学习通常在实践中实施并不是因为它能带来更好的结果而是因为它更便宜和更高效。凯西·奥尼尔在她的书《数学毁灭的武器》Crown中描述了一个模式即特权人士由人处理而穷人由算法处理。这只是算法与人类决策者使用方式的许多方式之一。其他方式包括以下内容
+ 人们更有可能认为算法是客观或无误差的(即使他们有人类覆盖的选项)。
+ 算法更有可能在没有上诉程序的情况下实施。
+ 算法通常以规模使用。
+ 算法系统成本低廉。
即使在没有偏见的情况下,算法(尤其是深度学习,因为它是一种如此有效和可扩展的算法)也可能导致负面社会问题,比如当用于*虚假信息*时。
## 虚假信息
*虚假信息*的历史可以追溯到数百甚至数千年前。它不一定是让某人相信错误的事情,而是经常用来播撒不和谐和不确定性,并让人们放弃寻求真相。收到矛盾的说法可能会导致人们认为他们永远无法知道该信任谁或什么。
有些人认为虚假信息主要是关于错误信息或*假新闻*,但实际上,虚假信息经常包含真相的种子,或者是脱离上下文的半真相。拉迪斯拉夫·比特曼是苏联的一名情报官员,后来叛逃到美国,并在 20 世纪 70 年代和 80 年代写了一些关于苏联宣传行动中虚假信息角色的书籍。在《克格勃和苏联虚假信息》Pergamon他写道“大多数活动都是精心设计的事实、半真相、夸大和故意谎言的混合物。”
在美国近年来FBI 详细描述了与 2016 年选举中的俄罗斯有关的大规模虚假信息活动。了解在这次活动中使用的虚假信息非常有教育意义。例如FBI 发现俄罗斯的虚假信息活动经常组织两个独立的假“草根”抗议活动,一个支持某一方面,另一个支持另一方面,并让他们同时抗议![休斯顿纪事报](https://oreil.ly/VyCkL)报道了其中一个奇怪事件(图 3-15
> 一个自称为“德克萨斯之心”的团体在社交媒体上组织了一场抗议活动,他们声称这是反对“德克萨斯伊斯兰化”的。在特拉维斯街的一边,我发现大约有 10 名抗议者。在另一边,我发现大约有 50 名反对抗议者。但我找不到集会的组织者。没有“德克萨斯之心”。我觉得这很奇怪,并在文章中提到:一个团体在自己的活动中缺席是什么样的团体?现在我知道为什么了。显然,集会的组织者当时在俄罗斯的圣彼得堡。“德克萨斯之心”是特别检察官罗伯特·穆勒最近指控试图干预美国总统选举的俄罗斯人中引用的一个互联网喷子团体。
![德克萨斯之心组织的活动截图](img/dlcf_0315.png)
###### 图 3-15。由德克萨斯之心组织的活动
虚假信息通常涉及协调的不真实行为活动。例如,欺诈账户可能试图让人们认为许多人持有特定观点。虽然大多数人喜欢认为自己是独立思考的,但实际上我们进化为受到内部群体的影响,并与外部群体对立。在线讨论可能会影响我们的观点,或改变我们认为可接受观点的范围。人类是社会动物,作为社会动物,我们受周围人的影响极大。越来越多的极端化发生在在线环境中;因此影响来自虚拟空间中的在线论坛和社交网络中的人们。
通过自动生成的文本进行虚假信息传播是一个特别重要的问题,这是由于深度学习提供的大大增强的能力。当我们深入研究创建语言模型时,我们会深入讨论这个问题第十章。
一种提出的方法是开发某种形式的数字签名,以无缝方式实施它,并创建我们应该信任仅经过验证的内容的规范。艾伦人工智能研究所的负责人奥伦·艾齐奥尼在一篇题为[“我们将如何防止基于人工智能的伪造?”](https://oreil.ly/8z7wm)的文章中写道:“人工智能正准备使高保真伪造变得廉价和自动化,可能会对民主、安全和社会造成灾难性后果。人工智能伪造的幽灵意味着我们需要采取行动,使数字签名成为验证数字内容的手段。”
虽然我们无法讨论深度学习和算法带来的所有伦理问题,但希望这个简短的介绍可以成为您的有用起点。现在我们将继续讨论如何识别伦理问题以及如何处理它们。
# 识别和解决伦理问题
错误是难免的。了解并处理错误需要成为包括机器学习在内的任何系统设计的一部分(还有许多其他系统)。数据伦理中提出的问题通常是复杂且跨学科的,但至关重要的是我们努力解决这些问题。
那么我们能做什么?这是一个重要的话题,但以下是一些解决伦理问题的步骤:
+ 分析你正在进行的项目。
+ 在您的公司实施流程以发现和解决伦理风险。
+ 支持良好的政策。
+ 增加多样性。
让我们逐步进行,从分析你正在进行的项目开始。
## 分析你正在进行的项目
在考虑工作的伦理影响时很容易忽略重要问题。一个极大的帮助是简单地提出正确的问题。Rachel Thomas 建议在数据项目的开发过程中考虑以下问题:
+ 我们甚至应该这样做吗?
+ 数据中存在什么偏见?
+ 代码和数据可以进行审计吗?
+ 不同子群体的错误率是多少?
+ 基于简单规则的替代方案的准确性如何?
+ 有哪些处理申诉或错误的流程?
+ 构建它的团队有多少多样性?
这些问题可能有助于您识别未解决的问题,以及更容易理解和控制的可能替代方案。除了提出正确的问题外,考虑实施的实践和流程也很重要。
在这个阶段需要考虑的一件事是你正在收集和存储的数据。数据往往最终被用于不同于最初意图的目的。例如IBM 在大屠杀之前就开始向纳粹德国出售产品,包括帮助纳粹德国进行的 1933 年人口普查,这次普查有效地识别出了比之前在德国被认可的犹太人更多。同样,美国人口普查数据被用来拘留二战期间的日裔美国人(他们是美国公民)。重要的是要认识到收集的数据和图像如何在以后被武器化。哥伦比亚大学教授[蒂姆·吴写道](https://oreil.ly/6L0QM):“你必须假设 Facebook 或 Android 保存的任何个人数据都是世界各国政府将试图获取或盗贼将试图窃取的数据。”
## 实施流程
马库拉中心发布了[工程/设计实践的伦理工具包](https://oreil.ly/vDGGC),其中包括在您的公司实施的具体实践,包括定期安排的扫描,以主动搜索伦理风险(类似于网络安全渗透测试),扩大伦理圈,包括各种利益相关者的观点,并考虑可怕的人(坏人如何滥用、窃取、误解、黑客、破坏或武器化您正在构建的东西?)。
即使您没有多样化的团队,您仍然可以尝试主动包括更广泛群体的观点,考虑这些问题(由马库拉中心提供):
+ 我们是否只是假设了谁/哪些团体和个人的利益、愿望、技能、经验和价值观,而没有实际咨询?
+ 谁将直接受到我们产品影响的所有利益相关者?他们的利益是如何得到保护的?我们如何知道他们的真正利益是什么——我们有没有询问过?
+ 哪些团体和个人将受到重大影响而间接受到影响?
+ 谁可能会使用这个产品,而我们没有预料到会使用它,或者出于我们最初没有打算的目的?
### 伦理镜头
马库拉中心的另一个有用资源是其[技术和工程实践中的概念框架](https://oreil.ly/QnRTt)。这考虑了不同基础伦理镜头如何帮助识别具体问题,并列出以下方法和关键问题:
权利的观点
哪个选项最尊重所有利益相关者的权利?
正义的观点
哪个选项平等或成比例地对待人们?
功利主义的观点
哪个选项将产生最多的好处并造成最少的伤害?
共同利益的观点
哪个选项最好地服务于整个社区,而不仅仅是一些成员?
美德的观点
哪个选项会让我表现得像我想成为的那种人?
马库拉的建议包括更深入地探讨这些观点,包括通过*后果*的视角来审视一个项目:
+ 谁将直接受到这个项目的影响?谁将间接受到影响?
+ 总体上,这些影响可能会产生更多的好处还是伤害,以及什么*类型*的好处和伤害?
+ 我们是否考虑了*所有*相关类型的伤害/好处(心理、政治、环境、道德、认知、情感、制度、文化)?
+ 未来的后代可能会受到这个项目的影响吗?
+ 这个项目可能会对社会中最弱势的人造成的伤害风险是否不成比例?好处是否会不成比例地给予富裕者?
+ 我们是否充分考虑了“双重使用”和意外的下游影响?
另一种视角是*义务论*的视角,它侧重于*对*和*错*的基本概念:
+ 我们必须尊重他人的哪些*权利*和对他人的*义务*
+ 这个项目可能会如何影响每个利益相关者的尊严和自主权?
+ 信任和正义的考虑对这个设计/项目有何影响?
+ 这个项目是否涉及与他人的冲突道德责任,或者与利益相关者的冲突权利?我们如何能够优先考虑这些?
帮助提出完整和周到的答案的最佳方法之一是确保提出问题的人是*多样化*的。
## 多样性的力量
根据[Element AI 的一项研究](https://oreil.ly/sO09p),目前不到 12%的人工智能研究人员是女性。在种族和年龄方面的统计数据同样令人堪忧。当团队中的每个人背景相似时,他们很可能在道德风险方面有相似的盲点。*哈佛商业评论*HBR发表了许多研究显示了多样化团队的许多好处包括以下内容
+ [“多样性如何推动创新”](https://oreil.ly/WRFSm)
+ [“当团队的认知多样性更高时,他们解决问题更快”](https://oreil.ly/vKy5b)
+ [“为什么多样化的团队更聪明”](https://oreil.ly/SFVBF)
+ [“捍卫您的研究:什么使一个团队更聪明?更多的女性”](https://oreil.ly/A1A5n)
多样性可以导致问题更早地被识别并考虑更广泛的解决方案。例如Tracy Chou 是 Quora 的一名早期工程师。她[描述了自己的经历](https://oreil.ly/n7WSn)描述了她在内部为添加一个功能而进行倡导该功能可以允许封锁恶意用户和其他不良行为者。Chou 回忆道,“我渴望参与这个功能的开发,因为我在网站上感到被挑衅和虐待(性别可能是一个原因)...但如果我没有那种个人视角Quora 团队可能不会那么早地将构建封锁按钮作为优先事项。”骚扰经常会导致边缘群体的人离开在线平台,因此这种功能对于维护 Quora 社区的健康至关重要。
一个关键的方面要理解的是,女性离开科技行业的速度是男性的两倍以上。根据[哈佛商业评论](https://oreil.ly/ZIC7t)的数据41%的从事科技行业的女性离开,而男性只有 17%。对 200 多本书籍、白皮书和文章的分析发现,她们离开的原因是“她们受到不公平对待;薪酬较低,不如男同事那样容易获得快速晋升,无法晋升。”
研究已经证实了一些使女性在职场中更难晋升的因素。女性在绩效评估中收到更多模糊的反馈和个性批评,而男性收到与业务结果相关的可操作建议(更有用)。女性经常被排除在更具创造性和创新性的角色之外,并且没有获得有助于晋升的高能见度的“拓展”任务。一项研究发现,即使阅读相同的脚本,男性的声音被认为比女性的声音更具有说服力、基于事实和逻辑。
统计数据显示,接受指导有助于男性晋升,但对女性没有帮助。背后的原因是,当女性接受指导时,这是关于她们应该如何改变和获得更多自我认识的建议。当男性接受指导时,这是对他们权威的公开认可。猜猜哪个对于晋升更有用?
只要合格的女性继续退出科技行业,教更多女孩编程并不能解决困扰该领域的多样性问题。多样性倡议往往主要关注白人女性,尽管有色人种女性面临许多额外障碍。在对从事 STEM 研究的 60 名有色人种女性进行的采访中100%的人表示曾经遭受过歧视。
技术领域的招聘过程特别混乱。一项表明这种功能障碍的研究来自 Triplebyte这是一家帮助将软件工程师安置到公司的公司作为这一过程的一部分进行了标准化的技术面试。该公司拥有一个引人入胜的数据集300 多名工程师在考试中的表现结果,以及这些工程师在各种公司的面试过程中的表现结果。[Triplebyte 的研究](https://oreil.ly/2Wtw4)中的第一个发现是,“每家公司寻找的程序员类型往往与公司的需求或业务无关。相反,它们反映了公司文化和创始人的背景。”
这对于试图进入深度学习领域的人来说是一个挑战,因为大多数公司的深度学习团队今天都是由学者创立的。这些团队往往寻找“像他们一样”的人——也就是说,能够解决复杂数学问题并理解密集行话的人。他们并不总是知道如何发现那些真正擅长使用深度学习解决实际问题的人。
这为那些愿意超越地位和门第,专注于结果的公司提供了一个巨大的机会!
## 公平、问责和透明度
计算机科学家的专业协会 ACM 举办了一个名为“公平性、问责制和透明度会议”的数据伦理会议ACM FAccT以前使用的缩写是 FAT现在使用不那么有争议的 FAccT。微软也有一个专注于 AI 中的公平性、问责制、透明度和伦理的团队FATE。在本节中我们将使用缩写 FAccT 来指代公平性、问责制和透明度的概念。
FAccT 是一些人用来考虑伦理问题的一种视角。一个有用的资源是 Solon Barocas 等人的免费在线书籍《公平性与机器学习:限制与机会》,该书“提供了一个将公平性视为中心问题而不是事后想法的机器学习视角”。然而,它也警告说,“它故意范围狭窄……机器学习伦理的狭窄框架可能会诱使技术人员和企业专注于技术干预,而回避有关权力和问责制的更深层次问题。我们警告不要陷入这种诱惑。”与提供 FAccT 伦理方法概述的重点不同(最好在像那样的书籍中完成),我们的重点将放在这种狭窄框架的局限性上。
考虑伦理视角是否完整的一个好方法是尝试提出一个例子其中视角和我们自己的伦理直觉给出不同的结果。Os Keyes 等人在他们的论文中以图形方式探讨了这一点。该论文的摘要如下:
> 算法系统的伦理含义在人机交互和对技术设计、开发和政策感兴趣的更广泛社区中已经被广泛讨论。在本文中,我们探讨了一个著名的伦理框架——公平性、问责制和透明度——在一个旨在解决食品安全和人口老龄化等各种社会问题的算法中的应用。通过使用各种标准化的算法审计和评估形式,我们大大增加了算法对 FAT 框架的遵从,从而实现了更具伦理和善意的系统。我们讨论了这如何可以作为其他研究人员或从业者的指南,帮助他们确保在工作中的算法系统产生更好的伦理结果。
在本文中,相当有争议的提议(“将老年人变成高营养浆料”)和结果(“大大增加算法对 FAT 框架的遵从,从而实现更具伦理和善意的系统”)是相互矛盾的……至少可以这么说!
在哲学中,尤其是伦理哲学中,这是最有效的工具之一:首先,提出一个过程、定义、一组问题等,旨在解决问题。然后尝试提出一个例子,其中明显的解决方案导致一个没有人会认为可接受的提议。这可以进一步完善解决方案。
到目前为止,我们关注的是您和您的组织可以做的事情。但有时个人或组织的行动是不够的。有时政府也需要考虑政策影响。
# 政策的作用
我们经常与那些渴望技术或设计修复成为解决我们所讨论问题的全部解决方案的人交谈;例如,对数据进行去偏见的技术方法,或者制定技术不那么容易上瘾的设计指南。虽然这些措施可能有用,但它们不足以解决导致我们目前状态的根本问题。例如,只要创造上瘾的技术是有利可图的,公司将继续这样做,无论这是否会导致推广阴谋论并污染我们的信息生态系统。虽然个别设计师可能会尝试调整产品设计,但在基础利润激励措施改变之前,我们不会看到实质性的变化。
## 监管的有效性
要看看是什么导致公司采取具体行动,考虑 Facebook 的以下两个行为示例。2018 年,联合国调查发现 Facebook 在缅甸罗兴亚人持续种族灭绝中发挥了“决定性作用”,联合国秘书长安东尼奥·古特雷斯将罗兴亚人描述为“世界上最受歧视的人之一,如果不是最受歧视的人”。自 2013 年以来,当地活动人士一直在警告 Facebook 高管称他们的平台被用来传播仇恨言论和煽动暴力。2015 年他们被警告说Facebook 可能在缅甸扮演与卢旺达种族灭绝期间广播电台扮演的相同角色(那里有一百万人被杀)。然而,到 2015 年底Facebook 只雇用了四名会说缅甸语的承包商。正如一位知情人士所说“这不是事后诸葛亮。这个问题的规模很大而且已经显而易见。”扎克伯格在国会听证会上承诺雇佣“几十人”来解决缅甸的种族灭绝问题2018 年,数年后种族灭绝已经开始,包括 2017 年 8 月之后至少摧毁了北拉钦邦至少 288 个村庄)。
这与 Facebook 迅速[在德国雇佣了 1,200 人](https://oreil.ly/q_8Dz)以避免根据德国新法律反对仇恨言论面临高达 5000 万欧元的昂贵罚款形成鲜明对比。显然在这种情况下Facebook 更多地是对财务处罚的威胁做出反应,而不是对一个种族少数群体的系统性破坏。
在[一篇关于隐私问题的文章](https://oreil.ly/K5YKf)中,马切伊·塞格洛夫斯基与环境运动进行了类比:
> 这一监管项目在第一世界取得了如此成功,以至于我们可能忘记了之前的生活是什么样子。今天在雅加达和德里杀死成千上万人的浓烟曾经是[伦敦的象征](https://oreil.ly/pLzU7)。俄亥俄州的奎哈霍加河曾经[经常起火](https://oreil.ly/qrU5v)。在一个特别可怕的意外后果的例子中,添加到汽油中的四乙基铅[导致全球暴力犯罪率上升](https://oreil.ly/4ngvr)了五十年。这些伤害都不能通过告诉人们用钱包投票,或者仔细审查他们给予业务的每家公司的环境政策,或者停止使用相关技术来解决。这需要跨越司法辖区的协调和有时高度技术化的监管来解决。在一些情况下,比如[禁止商用制冷剂](https://oreil.ly/o839J)导致臭氧层消耗,这种监管需要全球共识。我们已经到了需要在隐私法中进行类似转变的时候。
## 权利和政策
清洁空气和清洁饮用水是几乎不可能通过个人市场决策来保护的公共物品,而是需要协调的监管行动。同样,许多技术误用的意外后果造成的伤害涉及公共物品,比如污染的信息环境或恶化的环境隐私。隐私往往被框定为个人权利,然而广泛监视会产生社会影响(即使有一些个人可以选择退出也是如此)。
我们在科技领域看到的许多问题都是人权问题,比如一个带有偏见的算法建议黑人被告应该获得更长的监禁,特定的工作广告只显示给年轻人,或者警察使用面部识别来识别抗议者。解决人权问题的适当场所通常是法律。
我们需要监管和法律变革,以及个人的道德行为。个人行为的改变无法解决不一致的利润激励、外部性(即企业在向更广泛社会转嫁成本和危害的同时获得巨额利润)或系统性失败。然而,法律永远不可能涵盖所有边缘案例,重要的是个人软件开发人员和数据科学家能够在实践中做出道德决策。
## 汽车:历史先例
我们面临的问题是复杂的,没有简单的解决方案。这可能令人沮丧,但我们在考虑历史上人们已经解决的其他重大挑战时找到了希望。一个例子是增加汽车安全的运动,被提及为[“数据集数据表”](https://oreil.ly/nqG_r)一书中的案例研究,作者是 Timnit Gebru 等人,以及设计播客[99% Invisible](https://oreil.ly/2HGPd)。早期汽车没有安全带,仪表盘上有金属旋钮,在事故中可能刺入人们的头颅,常规平板玻璃窗以危险的方式破碎,非可折叠转向柱刺穿驾驶员。然而,汽车公司甚至不愿讨论安全作为他们可以帮助解决的问题,普遍的看法是汽车就是它们的样子,是使用它们的人造成了问题。
消费者安全活动家和倡导者经过几十年的努力,改变了国家对汽车公司可能需要通过监管来解决一些责任的讨论。可折叠转向柱发明后,由于没有财务激励,几年内并未实施。主要汽车公司通用汽车公司雇佣了私家侦探,试图挖掘消费者安全倡导者拉尔夫·纳德的黑材料。安全带、碰撞测试假人和可折叠转向柱的要求是重大胜利。直到 2011 年,汽车公司才被要求开始使用代表普通女性的碰撞测试假人,而不仅仅是代表普通男性的身体;在此之前,女性在相同冲击下的车祸中受伤的可能性比男性高 40%。这是偏见、政策和技术产生重要后果的生动例证。
# 结论
从二进制逻辑的背景出发,伦理学中缺乏明确答案可能一开始会令人沮丧。然而,我们的工作如何影响世界,包括意外后果和工作被不良行为者武器化的影响,是我们可以(也应该!)考虑的最重要问题之一。尽管没有简单的答案,但有明确的陷阱要避免和实践要遵循,以朝着更具道德行为迈进。
许多人包括我们正在寻找更令人满意、扎实的答案以解决技术带来的有害影响。然而考虑到我们面临的问题的复杂性、广泛性和跨学科性质没有简单的解决方案。Julia AngwinProPublica 前资深记者,专注于算法偏见和监视问题(也是 2016 年调查 COMPAS 累犯算法的调查人员之一,该算法帮助引发了 FAccT 领域),在 2019 年的一次采访中表示:
> 我坚信要解决问题必须先诊断问题而我们仍处于诊断阶段。如果您考虑到世纪之交和工业化我们经历了我不知道30 年的童工、无限工作时间、糟糕的工作条件,需要大量记者揭发和倡导来诊断问题并对其有所了解,然后通过积极行动来改变法律。我觉得我们正处于数据信息的第二次工业化...我认为我的角色是尽可能清楚地表明问题的不利方面,并准确诊断问题,以便能够解决。这是艰苦的工作,需要更多的人来做。
令人欣慰的是Angwin 认为我们在很大程度上仍处于诊断阶段:如果您对这些问题的理解感到不完整,那是正常和自然的。目前还没有“治疗”方法,但我们继续努力更好地理解和解决我们面临的问题是至关重要的。
我们这本书的一位审阅者 Fred Monroe 曾在对冲基金交易领域工作。他在阅读本章后告诉我们,这里讨论的许多问题(数据分布与模型训练不同、部署和扩展后反馈循环对模型的影响等)也是构建盈利交易模型的关键问题。考虑到社会后果所需做的事情将与考虑组织、市场和客户后果所需做的事情有很多重叠,因此认真思考伦理问题也可以帮助您认真思考如何使您的数据产品更普遍地成功!
# 问卷
1. 伦理是否提供了“正确答案”清单?
1. 在考虑伦理问题时,与不同背景的人合作如何有助于解决问题?
1. IBM 在纳粹德国的角色是什么?为什么公司会参与其中?为什么工人会参与其中?
1. 第一个在大众柴油丑闻中被监禁的人的角色是什么?
1. 加利福尼亚执法官员维护的涉嫌黑帮成员数据库存在什么问题?
1. 为什么 YouTube 的推荐算法会向恋童癖者推荐部分裸露儿童的视频,尽管谷歌的员工没有编程这个功能?
1. 指标的中心性存在哪些问题?
1. 为什么 Meetup.com 在其技术见面会的推荐系统中没有包括性别?
1. 根据 Suresh 和 Guttag机器学习中有哪六种偏见类型
1. 在美国历史上,有哪两个种族偏见的例子?
1. ImageNet 中的大多数图像来自哪里?
1. 在论文“机器学习是否自动化道德风险和错误?”中,为什么鼻窦炎被发现与中风有关?
1. 代表性偏见是什么?
1. 在决策方面,机器和人有何不同?
1. 虚假信息和“假新闻”是一回事吗?
1. 通过自动生成的文本传播虚假信息为什么是一个特别重要的问题?
1. 马库拉中心描述的五种伦理视角是什么?
1. 政策在解决数据伦理问题方面是否是一个合适的工具?
## 进一步研究
1. 阅读文章“当算法削减您的医疗保健”(链接)。未来如何避免类似问题?
1. 研究更多关于 YouTube 推荐系统及其社会影响的信息。你认为推荐系统是否必须始终具有带有负面结果的反馈循环?谷歌可以采取什么方法来避免这种情况?政府呢?
1. 阅读论文[“在线广告投放中的歧视”](https://oreil.ly/jgKpM)。你认为谷歌应该对 Sweeney 博士发生的事情负责吗?什么是一个合适的回应?
1. 跨学科团队如何帮助避免负面后果?
1. 阅读论文[“机器学习是否自动化了道德风险和错误?”](https://oreil.ly/tLLOf) 你认为应该采取什么行动来处理这篇论文中指出的问题?
1. 阅读文章[“我们将如何防止基于 AI 的伪造?”](https://oreil.ly/6MQe4) 你认为 Etzioni 提出的方法能行得通吗?为什么?
1. 完成部分“分析你正在进行的项目”。
1. 考虑一下你的团队是否可以更多元化。如果可以,有哪些方法可能会有所帮助?
# 实践中的深度学习:总结!
恭喜!你已经完成了书的第一部分。在这一部分中,我们试图向你展示深度学习可以做什么,以及你如何使用它来创建真实的应用和产品。在这一点上,如果你花一些时间尝试你所学到的东西,你将从这本书中获得更多。也许你一直在学习的过程中已经在做这些事情了,如果是这样,太棒了!如果没有,也没关系——现在是开始自己尝试实验的好时机。
如果你还没有去过[书的网站](https://book.fast.ai),现在就去吧。非常重要的是你要设置好自己来运行这些笔记本。成为一个有效的深度学习从业者就是要不断练习,所以你需要训练模型。所以,如果你还没有开始运行这些笔记本,请现在就去运行!并查看网站上的任何重要更新或通知;深度学习变化迅速,我们无法改变这本书中印刷的文字,所以你需要查看网站以确保你拥有最新的信息。
确保你已经完成了以下步骤:
1. 连接到书网站上推荐的 GPU Jupyter 服务器之一。
1. 自己运行第一个笔记本。
1. 上传你在第一个笔记本中找到的图像;然后尝试一些不同类型的图像,看看会发生什么。
1. 运行第二个笔记本,根据你提出的图像搜索查询收集你自己的数据集。
1. 思考一下如何利用深度学习来帮助你自己的项目,包括你可以使用什么类型的数据,可能会遇到什么问题,以及你如何在实践中可能会减轻这些问题。
在书的下一部分,你将了解深度学习是如何以及为什么起作用的,而不仅仅是看到你如何在实践中使用它。了解如何以及为什么对从业者和研究人员都很重要,因为在这个相当新的领域中,几乎每个项目都需要一定程度的定制和调试。你对深度学习的基础理解越深入,你的模型就会越好。这些基础对于高管、产品经理等人来说不那么重要(尽管仍然有用,所以请继续阅读!),但对于任何正在训练和部署模型的人来说都是至关重要的。

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,914 @@
# 第五章:图像分类
现在您了解了深度学习是什么、它的用途以及如何创建和部署模型,现在是时候深入了!在理想的世界中,深度学习从业者不必了解每个细节是如何在底层工作的。但事实上,我们还没有生活在理想的世界中。事实是,要使您的模型真正起作用并可靠地工作,您必须正确处理很多细节,并检查很多细节。这个过程需要能够在训练神经网络时查看内部情况,找到可能的问题,并知道如何解决它们。
因此,从本书开始,我们将深入研究深度学习的机制。计算机视觉模型的架构是什么,自然语言处理模型的架构是什么,表格模型的架构是什么等等?如何创建一个与您特定领域需求匹配的架构?如何从训练过程中获得最佳结果?如何加快速度?随着数据集的变化,您必须做出哪些改变?
我们将从重复第一章中查看的相同基本应用程序开始,但我们将做两件事:
+ 让它们变得更好。
+ 将它们应用于更多类型的数据。
为了做这两件事,我们将不得不学习深度学习难题的所有部分。这包括不同类型的层、正则化方法、优化器、如何将层组合成架构、标记技术等等。但我们不会一次性把所有这些东西都扔给你;我们将根据需要逐步引入它们,以解决与我们正在处理的项目相关的实际问题。
# 从狗和猫到宠物品种
在我们的第一个模型中,我们学会了如何区分狗和猫。就在几年前,这被认为是一个非常具有挑战性的任务——但今天,这太容易了!我们将无法向您展示训练模型时的细微差别,因为我们在不担心任何细节的情况下获得了几乎完美的结果。但事实证明,同一数据集还允许我们解决一个更具挑战性的问题:找出每张图像中显示的宠物品种是什么。
在第一章中,我们将应用程序呈现为已解决的问题。但这不是实际情况下的工作方式。我们从一个我们一无所知的数据集开始。然后我们必须弄清楚它是如何组合的,如何从中提取我们需要的数据,以及这些数据是什么样子的。在本书的其余部分,我们将向您展示如何在实践中解决这些问题,包括理解我们正在处理的数据以及在进行建模时测试的所有必要中间步骤。
我们已经下载了宠物数据集,并且可以使用与第一章相同的代码获取到该数据集的路径:
```py
from fastai2.vision.all import *
path = untar_data(URLs.PETS)
```
现在,如果我们要理解如何从每个图像中提取每只宠物的品种,我们需要了解数据是如何布局的。数据布局的细节是深度学习难题的重要组成部分。数据通常以以下两种方式之一提供:
+ 表示数据项的个别文件,例如文本文档或图像,可能组织成文件夹或具有表示有关这些项信息的文件名
+ 数据表(例如,以 CSV 格式)中的数据,其中每行是一个项目,可能包括文件名,提供表中数据与其他格式(如文本文档和图像)中数据之间的连接
有一些例外情况——特别是在基因组学等领域,可能存在二进制数据库格式或甚至网络流——但总体而言,您将处理的绝大多数数据集将使用这两种格式的某种组合。
要查看数据集中的内容,我们可以使用`ls`方法:
```py
path.ls()
```
```py
(#3) [Path('annotations'),Path('images'),Path('models')]
```
我们可以看到这个数据集为我们提供了*images*和*annotations*目录。数据集的[网站](https://oreil.ly/xveoN)告诉我们*annotations*目录包含有关宠物所在位置而不是它们是什么的信息。在本章中,我们将进行分类,而不是定位,也就是说我们关心的是宠物是什么,而不是它们在哪里。因此,我们暂时会忽略*annotations*目录。那么,让我们来看看*images*目录里面的内容:
```py
(path/"images").ls()
```
```py
(#7394) [Path('images/great_pyrenees_173.jpg'),Path('images/wheaten_terrier_46.j
> pg'),Path('images/Ragdoll_262.jpg'),Path('images/german_shorthaired_3.jpg'),P
> ath('images/american_bulldog_196.jpg'),Path('images/boxer_188.jpg'),Path('ima
> ges/staffordshire_bull_terrier_173.jpg'),Path('images/basset_hound_71.jpg'),P
> ath('images/staffordshire_bull_terrier_37.jpg'),Path('images/yorkshire_terrie
> r_18.jpg')...]
```
在 fastai 中,大多数返回集合的函数和方法使用一个名为`L`的类。这个类可以被认为是普通 Python `list`类型的增强版本,具有用于常见操作的附加便利。例如,当我们在笔记本中显示这个类的对象时,它会以这里显示的格式显示。首先显示的是集合中的项目数,前面带有`#`。在前面的输出中,你还会看到列表后面有省略号。这意味着只显示了前几个项目,这是件好事,因为我们不希望屏幕上出现超过 7000 个文件名!
通过检查这些文件名,我们可以看到它们似乎是如何结构化的。每个文件名包含宠物品种,然后是一个下划线(`_`),一个数字,最后是文件扩展名。我们需要创建一段代码,从单个`Path`中提取品种。Jupyter 笔记本使这变得容易,因为我们可以逐渐构建出可用的东西,然后用于整个数据集。在这一点上,我们必须小心不要做太多假设。例如,如果你仔细观察,你可能会注意到一些宠物品种包含多个单词,因此我们不能简单地在找到的第一个`_`字符处中断。为了让我们能够测试我们的代码,让我们挑选出一个这样的文件名:
```py
fname = (path/"images").ls()[0]
```
从这样的字符串中提取信息的最强大和灵活的方法是使用*regular expression*,也称为*regex*。正则表达式是一种特殊的字符串,用正则表达式语言编写,它指定了一个一般规则,用于决定另一个字符串是否通过测试(即“匹配”正则表达式),并且可能用于从另一个字符串中提取特定部分。在这种情况下,我们需要一个正则表达式从文件名中提取宠物品种。
我们没有空间在这里为您提供完整的正则表达式教程,但有许多优秀的在线教程,我们知道你们中的许多人已经熟悉这个神奇的工具。如果你不熟悉,那完全没问题——这是一个让你纠正的绝佳机会!我们发现正则表达式是我们编程工具包中最有用的工具之一,我们的许多学生告诉我们,这是他们最兴奋学习的事情之一。所以赶紧去谷歌搜索“正则表达式教程”吧,然后在你看得很开心之后回到这里。[书籍网站](https://book.fast.ai)也提供了我们喜欢的教程列表。
# 亚历克西斯说
正则表达式不仅非常方便而且还有有趣的起源。它们之所以被称为“regular”是因为它们最初是“regular”语言的示例这是乔姆斯基层次结构中最低的一级。这是语言学家诺姆·乔姆斯基开发的一种语法分类他还写了《句法结构》这是一项寻找人类语言基础形式语法的开创性工作。这是计算的魅力之一你每天使用的工具可能实际上来自太空船。
当你编写正则表达式时,最好的方法是首先针对一个示例尝试。让我们使用`findall`方法来对`fname`对象的文件名尝试一个正则表达式:
```py
re.findall(r'(.+)_\d+.jpg$', fname.name)
```
```py
['great_pyrenees']
```
这个正则表达式提取出所有字符,直到最后一个下划线字符,只要后续字符是数字,然后是 JPEG 文件扩展名。
现在我们确认了正则表达式对示例的有效性让我们用它来标记整个数据集。fastai 提供了许多类来帮助标记。对于使用正则表达式进行标记,我们可以使用`RegexLabeller`类。在这个例子中,我们使用了数据块 API我们在第二章中看到过实际上我们几乎总是使用数据块 API——它比我们在第一章中看到的简单工厂方法更灵活
```py
pets = DataBlock(blocks = (ImageBlock, CategoryBlock),
get_items=get_image_files,
splitter=RandomSplitter(seed=42),
get_y=using_attr(RegexLabeller(r'(.+)_\d+.jpg$'), 'name'),
item_tfms=Resize(460),
batch_tfms=aug_transforms(size=224, min_scale=0.75))
dls = pets.dataloaders(path/"images")
```
这个`DataBlock`调用中一个重要的部分是我们以前没有见过的这两行:
```py
item_tfms=Resize(460),
batch_tfms=aug_transforms(size=224, min_scale=0.75)
```
这些行实现了一个我们称之为*预调整*的 fastai 数据增强策略。预调整是一种特殊的图像增强方法,旨在最大限度地减少数据破坏,同时保持良好的性能。
# 预调整
我们需要我们的图像具有相同的尺寸,这样它们可以整合成张量传递给 GPU。我们还希望最小化我们执行的不同增强计算的数量。性能要求表明我们应该尽可能将我们的增强变换组合成更少的变换以减少计算数量和损失操作的数量并将图像转换为统一尺寸以便在 GPU 上更有效地处理)。
挑战在于,如果在调整大小到增强尺寸之后执行各种常见的数据增强变换,可能会引入虚假的空白区域,降低数据质量,或两者兼而有之。例如,将图像旋转 45 度会在新边界的角落区域填充空白,这不会教会模型任何东西。许多旋转和缩放操作将需要插值来创建像素。这些插值像素是从原始图像数据派生的,但质量较低。
为了解决这些挑战,预调整采用了图 5-1 中显示的两种策略:
1. 将图像调整为相对“大”的尺寸,即明显大于目标训练尺寸。
1. 将所有常见的增强操作(包括调整大小到最终目标大小)组合成一个,并在 GPU 上一次性执行组合操作,而不是单独执行操作并多次插值。
第一步是调整大小创建足够大的图像使其内部区域有多余的边距以允许进一步的增强变换而不会产生空白区域。这个转换通过调整大小为一个正方形使用一个大的裁剪尺寸来实现。在训练集上裁剪区域是随机选择的裁剪的大小被选择为覆盖图像宽度或高度中较小的那个。在第二步中GPU 用于所有数据增强,并且所有潜在破坏性操作都一起完成,最后进行单次插值。
![训练集上的预调整](img/dlcf_0501.png)
###### 图 5-1。训练集上的预调整
这张图片展示了两个步骤:
1. *裁剪全宽或全高*:这在`item_tfms`中,因此它应用于每个单独的图像,然后再复制到 GPU。它用于确保所有图像具有相同的尺寸。在训练集上裁剪区域是随机选择的。在验证集上总是选择图像的中心正方形。
1. *随机裁剪和增强*:这在`batch_tfms`中,因此它一次在 GPU 上应用于整个批次,这意味着速度快。在验证集上,只有调整大小到模型所需的最终大小。在训练集上,首先进行随机裁剪和任何其他增强。
要在 fastai 中实现此过程,您可以使用`Resize`作为具有大尺寸的项目转换,以及`RandomResizedCrop`作为具有较小尺寸的批处理转换。如果在`aug_transforms`函数中包含`min_scale`参数,`RandomResizedCrop`将为您添加,就像在上一节中的`DataBlock`调用中所做的那样。或者,您可以在初始`Resize`中使用`pad`或`squish`而不是`crop`(默认值)。
图 5-2 显示了一个图像经过缩放、插值、旋转然后再次插值这是所有其他深度学习库使用的方法显示在右侧以及一个图像经过缩放和旋转作为一个操作然后插值一次fastai 方法),显示在左侧。
![](img/dlcf_0502.png)
###### 图 5-2。fastai 数据增强策略(左)与传统方法(右)的比较
您可以看到右侧的图像定义不够清晰,在左下角有反射填充伪影;此外,左上角的草完全消失了。我们发现,在实践中,使用预调整显著提高了模型的准确性,通常也会加快速度。
fastai 库还提供了简单的方法来检查您的数据在训练模型之前的外观,这是一个非常重要的步骤。我们将在下一步中看到这些。
## 检查和调试 DataBlock
我们永远不能假设我们的代码完美运行。编写`DataBlock`就像编写蓝图一样。如果您的代码中有语法错误,您将收到错误消息,但是您无法保证您的模板会按照您的意图在数据源上运行。因此,在训练模型之前,您应该始终检查您的数据。
您可以使用`show_batch`方法来执行此操作:
```py
dls.show_batch(nrows=1, ncols=3)
```
![](img/dlcf_05in01.png)
查看每个图像,并检查每个图像是否具有正确的宠物品种标签。通常,数据科学家使用的数据可能不如领域专家熟悉:例如,我实际上不知道这些宠物品种中的许多是什么。由于我不是宠物品种的专家,我会在这一点上使用谷歌图像搜索一些这些品种,并确保图像看起来与我在输出中看到的相似。
如果在构建`DataBlock`时出现错误,您可能在此步骤之前不会看到它。为了调试这个问题,我们鼓励您使用`summary`方法。它将尝试从您提供的源创建一个批次,并提供大量细节。此外,如果失败,您将准确地看到错误发生的位置,并且库将尝试为您提供一些帮助。例如,一个常见的错误是忘记使用`Resize`转换,因此最终得到不同大小的图片并且无法将它们整理成批次。在这种情况下,摘要将如下所示(请注意,自撰写时可能已更改确切文本,但它将给您一个概念):
```py
pets1 = DataBlock(blocks = (ImageBlock, CategoryBlock),
get_items=get_image_files,
splitter=RandomSplitter(seed=42),
get_y=using_attr(RegexLabeller(r'(.+)_\d+.jpg$'), 'name'))
pets1.summary(path/"images")
```
```py
Setting-up type transforms pipelines
Collecting items from /home/sgugger/.fastai/data/oxford-iiit-pet/images
Found 7390 items
2 datasets of sizes 5912,1478
Setting up Pipeline: PILBase.create
Setting up Pipeline: partial -> Categorize
Building one sample
Pipeline: PILBase.create
starting from
/home/sgugger/.fastai/data/oxford-iiit-pet/images/american_bulldog_83.jpg
applying PILBase.create gives
PILImage mode=RGB size=375x500
Pipeline: partial -> Categorize
starting from
/home/sgugger/.fastai/data/oxford-iiit-pet/images/american_bulldog_83.jpg
applying partial gives
american_bulldog
applying Categorize gives
TensorCategory(12)
Final sample: (PILImage mode=RGB size=375x500, TensorCategory(12))
Setting up after_item: Pipeline: ToTensor
Setting up before_batch: Pipeline:
Setting up after_batch: Pipeline: IntToFloatTensor
Building one batch
Applying item_tfms to the first sample:
Pipeline: ToTensor
starting from
(PILImage mode=RGB size=375x500, TensorCategory(12))
applying ToTensor gives
(TensorImage of size 3x500x375, TensorCategory(12))
Adding the next 3 samples
No before_batch transform to apply
Collating items in a batch
Error! It's not possible to collate your items in a batch
Could not collate the 0-th members of your tuples because got the following
shapes:
torch.Size([3, 500, 375]),torch.Size([3, 375, 500]),torch.Size([3, 333, 500]),
torch.Size([3, 375, 500])
```
您可以看到我们如何收集数据并拆分数据,如何从文件名转换为*样本*(元组(图像,类别)),然后应用了哪些项目转换以及如何在批处理中无法整理这些样本(因为形状不同)。
一旦您认为数据看起来正确,我们通常建议下一步应该使用它来训练一个简单的模型。我们经常看到人们将实际模型的训练推迟得太久。结果,他们不知道他们的基准结果是什么样的。也许您的问题不需要大量花哨的领域特定工程。或者数据似乎根本无法训练模型。这些都是您希望尽快了解的事情。
对于这个初始测试,我们将使用与第一章中使用的相同简单模型:
```py
learn = cnn_learner(dls, resnet34, metrics=error_rate)
learn.fine_tune(2)
```
| epoch | train_loss | valid_loss | error_rate | time |
| --- | --- | --- | --- | --- |
| 0 | 1.491732 | 0.337355 | 0.108254 | 00:18 |
| epoch | train_loss | valid_loss | error_rate | time |
| --- | --- | --- | --- | --- |
| 0 | 0.503154 | 0.293404 | 0.096076 | 00:23 |
| 1 | 0.314759 | 0.225316 | 0.066306 | 00:23 |
正如我们之前简要讨论过的,当我们拟合模型时显示的表格展示了每个训练周期后的结果。记住,一个周期是对数据中所有图像的完整遍历。显示的列是训练集中项目的平均损失、验证集上的损失,以及我们请求的任何指标——在这种情况下是错误率。
请记住*损失*是我们决定用来优化模型参数的任何函数。但是我们实际上并没有告诉 fastai 我们想要使用什么损失函数。那么它在做什么呢fastai 通常会根据您使用的数据和模型类型尝试选择适当的损失函数。在这种情况下,我们有图像数据和分类结果,所以 fastai 会默认使用*交叉熵损失*。
# 交叉熵损失
*交叉熵损失*是一个类似于我们在上一章中使用的损失函数,但是(正如我们将看到的)有两个好处:
+ 即使我们的因变量有两个以上的类别,它也能正常工作。
+ 这将导致更快速、更可靠的训练。
要理解交叉熵损失如何处理具有两个以上类别的因变量,我们首先必须了解损失函数看到的实际数据和激活是什么样子的。
## 查看激活和标签
让我们看看我们模型的激活。要从我们的`DataLoaders`中获取一批真实数据,我们可以使用`one_batch`方法:
```py
x,y = dls.one_batch()
```
正如您所见,这返回了因变量和自变量,作为一个小批量。让我们看看我们的因变量中包含什么:
```py
y
```
```py
TensorCategory([11, 0, 0, 5, 20, 4, 22, 31, 23, 10, 20, 2, 3, 27, 18, 23,
> 33, 5, 24, 7, 6, 12, 9, 11, 35, 14, 10, 15, 3, 3, 21, 5, 19, 14, 12,
> 15, 27, 1, 17, 10, 7, 6, 15, 23, 36, 1, 35, 6,
4, 29, 24, 32, 2, 14, 26, 25, 21, 0, 29, 31, 18, 7, 7, 17],
> device='cuda:5')
```
我们的批量大小是 64因此在这个张量中有 64 行。每行是一个介于 0 和 36 之间的整数,代表我们 37 种可能的宠物品种。我们可以通过使用`Learner.get_preds`来查看预测(我们神经网络最后一层的激活)。这个函数默认返回预测和目标,但由于我们已经有了目标,我们可以通过将其赋值给特殊变量`_`来有效地忽略它们:
```py
preds,_ = learn.get_preds(dl=[(x,y)])
preds[0]
```
```py
tensor([7.9069e-04, 6.2350e-05, 3.7607e-05, 2.9260e-06, 1.3032e-05, 2.5760e-05,
> 6.2341e-08, 3.6400e-07, 4.1311e-06, 1.3310e-04, 2.3090e-03, 9.9281e-01,
> 4.6494e-05, 6.4266e-07, 1.9780e-06, 5.7005e-07,
3.3448e-06, 3.5691e-03, 3.4385e-06, 1.1578e-05, 1.5916e-06, 8.5567e-08,
> 5.0773e-08, 2.2978e-06, 1.4150e-06, 3.5459e-07, 1.4599e-04, 5.6198e-08,
> 3.4108e-07, 2.0813e-06, 8.0568e-07, 4.3381e-07,
1.0069e-05, 9.1020e-07, 4.8714e-06, 1.2734e-06, 2.4735e-06])
```
实际预测是 37 个介于 0 和 1 之间的概率,总和为 1
```py
len(preds[0]),preds[0].sum()
```
```py
(37, tensor(1.0000))
```
为了将我们模型的激活转换为这样的预测,我们使用了一个叫做*softmax*的激活函数。
## Softmax
在我们的分类模型中,我们在最后一层使用 softmax 激活函数,以确保激活值都在 0 到 1 之间,并且它们总和为 1。
Softmax 类似于我们之前看到的 sigmoid 函数。作为提醒sigmoid 看起来像这样:
```py
plot_function(torch.sigmoid, min=-4,max=4)
```
![](img/dlcf_05in02.png)
我们可以将这个函数应用于神经网络的一个激活列,并得到一个介于 0 和 1 之间的数字列,因此对于我们的最后一层来说,这是一个非常有用的激活函数。
现在想象一下,如果我们希望目标中有更多类别(比如我们的 37 种宠物品种)。这意味着我们需要比单个列更多的激活:我们需要一个激活*每个类别*。例如,我们可以创建一个预测 3 和 7 的神经网络,返回两个激活,每个类别一个——这将是创建更一般方法的一个很好的第一步。让我们只是使用一些标准差为 2 的随机数(因此我们将`randn`乘以 2作为示例假设我们有六个图像和两个可能的类别其中第一列代表 3第二列代表 7
```py
acts = torch.randn((6,2))*2
acts
```
```py
tensor([[ 0.6734, 0.2576],
[ 0.4689, 0.4607],
[-2.2457, -0.3727],
[ 4.4164, -1.2760],
[ 0.9233, 0.5347],
[ 1.0698, 1.6187]])
```
我们不能直接对这个进行 sigmoid 运算,因为我们得不到行相加为 1 的结果(我们希望 3 的概率加上 7 的概率等于 1
```py
acts.sigmoid()
```
```py
tensor([[0.6623, 0.5641],
[0.6151, 0.6132],
[0.0957, 0.4079],
[0.9881, 0.2182],
[0.7157, 0.6306],
[0.7446, 0.8346]])
```
在第四章中,我们的神经网络为每个图像创建了一个单一激活,然后通过`sigmoid`函数传递。这个单一激活代表了模型对输入是 3 的置信度。二进制问题是分类问题的一种特殊情况,因为目标可以被视为单个布尔值,就像我们在`mnist_loss`中所做的那样。但是二进制问题也可以在任意数量的类别的分类器的更一般上下文中考虑:在这种情况下,我们碰巧有两个类别。正如我们在熊分类器中看到的,我们的神经网络将为每个类别返回一个激活。
那么在二进制情况下,这些激活实际上表示什么?一对激活仅仅表示输入是 3 还是 7 的*相对*置信度。总体值,无论它们是高还是低,都不重要,重要的是哪个更高,以及高多少。
我们期望,由于这只是表示相同问题的另一种方式,我们应该能够直接在我们的神经网络的两个激活版本上使用`sigmoid`。事实上我们可以!我们只需取神经网络激活之间的*差异*,因为这反映了我们对输入是 3 还是 7 更有把握的程度,然后取其 sigmoid
```py
(acts[:,0]-acts[:,1]).sigmoid()
```
```py
tensor([0.6025, 0.5021, 0.1332, 0.9966, 0.5959, 0.3661])
```
第二列(它是 7 的概率)将是该值从 1 中减去的值。现在,我们需要一种适用于多于两列的方法。事实证明,这个名为`softmax`的函数正是这样的:
```py
def softmax(x): return exp(x) / exp(x).sum(dim=1, keepdim=True)
```
# 术语指数函数exp
定义为`e**x`,其中`e`是一个特殊的数字,约等于 2.718。它是自然对数函数的倒数。请注意,`exp`始终为正,并且增长*非常*迅速!
让我们检查`softmax`是否为第一列返回与`sigmoid`相同的值,以及这些值从 1 中减去的值为第二列:
```py
sm_acts = torch.softmax(acts, dim=1)
sm_acts
```
```py
tensor([[0.6025, 0.3975],
[0.5021, 0.4979],
[0.1332, 0.8668],
[0.9966, 0.0034],
[0.5959, 0.4041],
[0.3661, 0.6339]])
```
`softmax`是`sigmoid`的多类别等价物——每当我们有超过两个类别且类别的概率必须加起来为 1 时,我们必须使用它,即使只有两个类别,我们通常也会使用它,只是为了使事情更加一致。我们可以创建其他具有所有激活在 0 和 1 之间且总和为 1 的属性的函数;然而,没有其他函数与我们已经看到是平滑且对称的 sigmoid 函数具有相同的关系。此外,我们很快将看到 softmax 函数与我们将在下一节中看到的损失函数密切配合。
如果我们有三个输出激活,就像在我们的熊分类器中一样,为单个熊图像计算 softmax 看起来会像图 5-3 那样。
![熊 softmax 示例](img/dlcf_0503.png)
###### 图 5-3. 熊分类器上 softmax 的示例
实际上,这个函数是做什么的呢?取指数确保我们所有的数字都是正数,然后除以总和确保我们将得到一堆加起来等于 1 的数字。指数还有一个很好的特性:如果我们激活中的某个数字略大于其他数字,指数将放大这个差异(因为它呈指数增长),这意味着在 softmax 中,该数字将更接近 1。
直观地softmax 函数*真的*想要在其他类别中选择一个类别,因此在我们知道每张图片都有一个明确标签时,训练分类器时是理想的选择。(请注意,在推断过程中可能不太理想,因为有时您可能希望模型告诉您它在训练过程中看到的类别中没有识别出任何一个,并且不选择一个类别,因为它的激活分数略高。在这种情况下,最好使用多个二进制输出列来训练模型,每个列使用 sigmoid 激活。)
Softmax 是交叉熵损失的第一部分,第二部分是对数似然。
## 对数似然
在上一章中为我们的 MNIST 示例计算损失时,我们使用了这个:
```py
def mnist_loss(inputs, targets):
inputs = inputs.sigmoid()
return torch.where(targets==1, 1-inputs, inputs).mean()
```
就像我们从 sigmoid 到 softmax 的转变一样,我们需要扩展损失函数,使其能够处理不仅仅是二元分类,还需要能够对任意数量的类别进行分类(在本例中,我们有 37 个类别)。我们的激活,在 softmax 之后,介于 0 和 1 之间,并且对于预测批次中的每一行,总和为 1。我们的目标是介于 0 和 36 之间的整数。
在二元情况下,我们使用`torch.where`在`inputs`和`1-inputs`之间进行选择。当我们将二元分类作为具有两个类别的一般分类问题处理时,它变得更容易,因为(正如我们在前一节中看到的)现在有两列包含等同于`inputs`和`1-inputs`的内容。因此,我们只需要从适当的列中进行选择。让我们尝试在 PyTorch 中实现这一点。对于我们合成的 3 和 7 的示例,假设这些是我们的标签:
```py
targ = tensor([0,1,0,1,1,0])
```
这些是 softmax 激活:
```py
sm_acts
```
```py
tensor([[0.6025, 0.3975],
[0.5021, 0.4979],
[0.1332, 0.8668],
[0.9966, 0.0034],
[0.5959, 0.4041],
[0.3661, 0.6339]])
```
然后对于每个`targ`项,我们可以使用它来使用张量索引选择`sm_acts`的适当列,如下所示:
```py
idx = range(6)
sm_acts[idx, targ]
```
```py
tensor([0.6025, 0.4979, 0.1332, 0.0034, 0.4041, 0.3661])
```
为了准确了解这里发生了什么,让我们将所有列放在一起放在一个表中。这里,前两列是我们的激活,然后是目标,行索引,最后是前面代码中显示的结果:
| 3 | 7 | targ | idx | loss |
| --- | --- | --- | --- | --- |
| 0.602469 | 0.397531 | 0 | 0 | 0.602469 |
| 0.502065 | 0.497935 | 1 | 1 | 0.497935 |
| 0.133188 | 0.866811 | 0 | 2 | 0.133188 |
| 0.99664 | 0.00336017 | 1 | 3 | 0.00336017 |
| 0.595949 | 0.404051 | 1 | 4 | 0.404051 |
| 0.366118 | 0.633882 | 0 | 5 | 0.366118 |
从这个表中可以看出,最后一列可以通过将`targ`和`idx`列作为索引,指向包含`3`和`7`列的两列矩阵来计算。这就是`sm_acts[idx, targ]`的作用。
这里真正有趣的是这种方法同样适用于超过两列的情况。想象一下如果我们为每个数字0 到 9添加一个激活列然后`targ`包含从 0 到 9 的数字。只要激活列总和为 1如果我们使用 softmax它们将是这样我们将有一个损失函数显示我们预测每个数字的准确程度。
我们只从包含正确标签的列中选择损失。我们不需要考虑其他列,因为根据 softmax 的定义,它们加起来等于 1 减去与正确标签对应的激活。因此,使正确标签的激活尽可能高必须意味着我们也在降低其余列的激活。
PyTorch 提供了一个与`sm_acts[range(n), targ]`完全相同的函数(除了它取负数,因为之后应用对数时,我们将得到负数),称为`nll_loss`*NLL*代表*负对数似然*
```py
-sm_acts[idx, targ]
```
```py
tensor([-0.6025, -0.4979, -0.1332, -0.0034, -0.4041, -0.3661])
```
```py
F.nll_loss(sm_acts, targ, reduction='none')
```
```py
tensor([-0.6025, -0.4979, -0.1332, -0.0034, -0.4041, -0.3661])
```
尽管它的名字是这样的,但这个 PyTorch 函数并不取对数。我们将在下一节看到原因,但首先,让我们看看为什么取对数会有用。
## 取对数
在前一节中我们看到的函数作为损失函数效果很好,但我们可以让它更好一些。问题在于我们使用的是概率,概率不能小于 0 或大于 1。这意味着我们的模型不会在乎它是预测 0.99 还是 0.999。确实这些数字非常接近但从另一个角度来看0.999 比 0.99 自信程度高 10 倍。因此,我们希望将我们的数字从 0 到 1 转换为从负无穷到无穷。有一个数学函数可以做到这一点:*对数*(可用`torch.log`)。它对小于 0 的数字没有定义,并且如下所示:
```py
plot_function(torch.log, min=0,max=4)
```
![](img/dlcf_05in03.png)
“对数”这个词让你想起了什么吗?对数函数有这个恒等式:
```py
y = b**a
a = log(y,b)
```
在这种情况下,我们假设`log(y,b)`返回*log y 以 b 为底*。然而PyTorch 并没有这样定义`log`Python 中的`log`使用特殊数字`e`2.718…)作为底。
也许对数是您在过去 20 年中没有考虑过的东西。但对于深度学习中的许多事情来说,对数是一个非常关键的数学概念,所以现在是一个很好的时机来刷新您的记忆。关于对数的关键事情是这样的关系:
```py
log(a*b) = log(a)+log(b)
```
当我们以这种格式看到它时,它看起来有点无聊;但想想这实际上意味着什么。这意味着当基础信号呈指数或乘法增长时,对数会线性增加。例如,在地震严重程度的里氏震级和噪音级别的分贝尺中使用。它也经常用于金融图表中,我们希望更清楚地显示复合增长率。计算机科学家喜欢使用对数,因为这意味着可以用加法代替修改,这样可以避免产生计算机难以处理的难以处理的规模。
# Sylvain 说
不仅是计算机科学家喜欢对数!在计算机出现之前,工程师和科学家使用一种称为*滑尺*的特殊尺子,通过添加对数来进行乘法运算。对数在物理学中被广泛用于乘法非常大或非常小的数字,以及许多其他领域。
对我们的概率取正对数或负对数的平均值(取决于是否是正确或不正确的类)给出了*负对数似然*损失。在 PyTorch 中,`nll_loss`假设您已经对 softmax 取了对数,因此不会为您执行对数运算。
# 令人困惑的名称,注意
`nll_loss`中的“nll”代表“负对数似然”但实际上它根本不进行对数运算它假设您已经*已经*进行了对数运算。PyTorch 有一个名为`log_softmax`的函数,以快速准确的方式结合了`log`和`softmax`。`nll_loss`设计用于在`log_softmax`之后使用。
当我们首先进行 softmax然后对其进行对数似然这种组合被称为*交叉熵损失*。在 PyTorch 中,这可以通过`nn.CrossEntropyLoss`来实现(实际上执行`log_softmax`然后`nll_loss`
```py
loss_func = nn.CrossEntropyLoss()
```
正如您所看到的,这是一个类。实例化它会给您一个像函数一样行为的对象:
```py
loss_func(acts, targ)
```
```py
tensor(1.8045)
```
所有 PyTorch 损失函数都以两种形式提供,刚刚显示的类形式以及在`F`命名空间中提供的普通函数形式:
```py
F.cross_entropy(acts, targ)
```
```py
tensor(1.8045)
```
两者都可以正常工作,并且可以在任何情况下使用。我们注意到大多数人倾向于使用类版本,并且在 PyTorch 的官方文档和示例中更常见,因此我们也会倾向于使用它。
默认情况下PyTorch 损失函数取所有项目的损失的平均值。您可以使用`reduction='none'`来禁用这一点:
```py
nn.CrossEntropyLoss(reduction='none')(acts, targ)
```
```py
tensor([0.5067, 0.6973, 2.0160, 5.6958, 0.9062, 1.0048])
```
# Sylvain 说
当我们考虑交叉熵损失的梯度时,一个有趣的特性就出现了。`cross_entropy(a,b)`的梯度是`softmax(a)-b`。由于`softmax(a)`是模型的最终激活,这意味着梯度与预测和目标之间的差异成比例。这与回归中的均方误差相同(假设没有像`y_range`添加的最终激活函数),因为`(a-b)**2`的梯度是`2*(a-b)`。由于梯度是线性的,我们不会看到梯度的突然跳跃或指数增加,这应该导致模型的平滑训练。
我们现在已经看到了隐藏在我们损失函数背后的所有部分。但是,虽然这可以对我们的模型表现如何(好或坏)进行评估,但它对于帮助我们知道它是否好并没有任何帮助。现在让我们看看一些解释我们模型预测的方法。
# 模型解释
直接解释损失函数非常困难,因为它们被设计为计算机可以区分和优化的东西,而不是人类可以理解的东西。这就是为什么我们有指标。这些指标不用于优化过程,而只是帮助我们这些可怜的人类理解发生了什么。在这种情况下,我们的准确率已经看起来相当不错!那么我们在哪里犯了错误呢?
我们在第一章中看到,我们可以使用混淆矩阵来查看模型表现好和表现不佳的地方:
```py
interp = ClassificationInterpretation.from_learner(learn)
interp.plot_confusion_matrix(figsize=(12,12), dpi=60)
```
![](img/dlcf_05in04.png)
哦,亲爱的——在这种情况下,混淆矩阵很难阅读。我们有 37 种宠物品种,这意味着在这个巨大矩阵中有 37×37 个条目!相反,我们可以使用`most_confused`方法,它只显示混淆矩阵中预测错误最多的单元格(这里至少有 5 个或更多):
```py
interp.most_confused(min_val=5)
```
```py
[('american_pit_bull_terrier', 'staffordshire_bull_terrier', 10),
('Ragdoll', 'Birman', 6)]
```
由于我们不是宠物品种专家,很难知道这些类别错误是否反映了识别品种时的实际困难。因此,我们再次求助于谷歌。一点点搜索告诉我们,这里显示的最常见的类别错误是即使是专家育种者有时也会对其存在分歧的品种差异。因此,这让我们有些安慰,我们正在走在正确的道路上。
我们似乎有一个良好的基线。现在我们可以做些什么来使它变得更好呢?
# 改进我们的模型
我们现在将探讨一系列技术,以改进我们模型的训练并使其更好。在此过程中,我们将更详细地解释迁移学习以及如何尽可能最好地微调我们的预训练模型,而不破坏预训练权重。
在训练模型时我们需要设置的第一件事是学习率。我们在上一章中看到它需要恰到好处才能尽可能高效地训练那么我们如何选择一个好的学习率呢fastai 提供了一个工具来帮助。
## 学习率查找器
在训练模型时,我们可以做的最重要的事情之一是确保我们有正确的学习率。如果我们的学习率太低,训练模型可能需要很多个 epoch。这不仅浪费时间还意味着我们可能会出现过拟合的问题因为每次完整地遍历数据时我们都给了模型记住数据的机会。
那么我们就把学习率调得很高,对吗?当然,让我们试试看会发生什么:
```py
learn = cnn_learner(dls, resnet34, metrics=error_rate)
learn.fine_tune(1, base_lr=0.1)
```
| epoch | train_loss | valid_loss | error_rate | time |
| --- | --- | --- | --- | --- |
| 0 | 8.946717 | 47.954632 | 0.893775 | 00:20 |
| epoch | train_loss | valid_loss | error_rate | time |
| --- | --- | --- | --- | --- |
| 0 | 7.231843 | 4.119265 | 0.954668 | 00:24 |
这看起来不太好。发生了什么呢。优化器朝着正确的方向迈出了一步,但它迈得太远,完全超过了最小损失。多次重复这样的过程会使其越来越远,而不是越来越接近!
我们该如何找到完美的学习率——既不太高也不太低?在 2015 年,研究员 Leslie Smith 提出了一个绝妙的想法,称为*学习率查找器*。他的想法是从一个非常非常小的学习率开始,一个我们永远不会认为它太大而无法处理的学习率。我们用这个学习率进行一个 mini-batch找到之后的损失然后按一定百分比增加学习率例如每次加倍。然后我们再做另一个 mini-batch跟踪损失并再次加倍学习率。我们一直这样做直到损失变得更糟而不是更好。这是我们知道我们走得太远的时候。然后我们选择一个比这个点稍低的学习率。我们建议选择以下任一
+ 比最小损失达到的地方少一个数量级(即最小值除以 10
+ 最后一次损失明显减少的点
学习率查找器计算曲线上的这些点来帮助您。这两个规则通常给出大致相同的值。在第一章中,我们没有指定学习率,而是使用了 fastai 库的默认值(即 1e-3
```py
learn = cnn_learner(dls, resnet34, metrics=error_rate)
lr_min,lr_steep = learn.lr_find()
```
![](img/dlcf_05in05.png)
```py
print(f"Minimum/10: {lr_min:.2e}, steepest point: {lr_steep:.2e}")
```
```py
Minimum/10: 8.32e-03, steepest point: 6.31e-03
```
我们可以看到在 1e-6 到 1e-3 的范围内,没有什么特别的事情发生,模型不会训练。然后损失开始减少,直到达到最小值,然后再次增加。我们不希望学习率大于 1e-1因为这会导致训练发散您可以自行尝试但 1e-1 已经太高了:在这个阶段,我们已经离开了损失稳定下降的阶段。
在这个学习率图中,看起来学习率约为 3e-3 可能是合适的,所以让我们选择这个:
```py
learn = cnn_learner(dls, resnet34, metrics=error_rate)
learn.fine_tune(2, base_lr=3e-3)
```
| epoch | train_loss | valid_loss | error_rate | time |
| --- | --- | --- | --- | --- |
| 0 | 1.071820 | 0.427476 | 0.133965 | 00:19 |
| epoch | train_loss | valid_loss | error_rate | time |
| --- | --- | --- | --- | --- |
| 0 | 0.738273 | 0.541828 | 0.150880 | 00:24 |
| 1 | 0.401544 | 0.266623 | 0.081867 | 00:24 |
# 对数刻度
学习率查找器图表采用对数刻度,这就是为什么在 1e-3 和 1e-2 之间的中间点在 3e-3 和 4e-3 之间。这是因为我们主要关心学习率的数量级。
有趣的是,学习率查找器是在 2015 年才被发现的,而神经网络自上世纪 50 年代以来一直在发展。在那段时间里找到一个好的学习率可能是从业者面临的最重要和最具挑战性的问题。解决方案不需要任何高级数学、巨大的计算资源、庞大的数据集或其他任何使其对任何好奇的研究人员不可及的东西。此外Smith 并不是某个独家的硅谷实验室的一部分,而是作为一名海军研究员工作。所有这些都是为了说:在深度学习中的突破性工作绝对不需要访问大量资源、精英团队或先进的数学思想。还有很多工作需要做,只需要一点常识、创造力和坚韧不拔。
现在我们有了一个好的学习率来训练我们的模型,让我们看看如何微调预训练模型的权重。
## 解冻和迁移学习
我们在第一章中简要讨论了迁移学习的工作原理。我们看到基本思想是,一个预训练模型,可能在数百万数据点(如 ImageNet上训练被为另一个任务进行微调。但这到底意味着什么
我们现在知道,卷积神经网络由许多线性层组成,每对之间有一个非线性激活函数,然后是一个或多个最终的线性层,最后是一个诸如 softmax 之类的激活函数。最终的线性层使用一个具有足够列数的矩阵,使得输出大小与我们模型中的类数相同(假设我们正在进行分类)。
当我们在迁移学习设置中进行微调时,这个最终的线性层对我们来说可能没有任何用处,因为它专门设计用于对原始预训练数据集中的类别进行分类。因此,在进行迁移学习时,我们会将其移除、丢弃,并用一个新的线性层替换,该线性层具有我们所需任务的正确输出数量(在这种情况下,将有 37 个激活)。
这个新添加的线性层将完全随机的权重。因此,在微调之前,我们的模型具有完全随机的输出。但这并不意味着它是一个完全随机的模型!最后一个层之前的所有层都经过精心训练,以便在一般的图像分类任务中表现良好。正如我们在[Zeiler 和 Fergus 论文](https://oreil.ly/aTRwE)中看到的那样,在第一章中(参见图 1-10 到 1-13前几层编码了一般概念比如找到梯度和边缘后面的层编码了对我们仍然有用的概念比如找到眼球和毛发。
我们希望以这样的方式训练模型,使其能够记住预训练模型中的所有这些通常有用的想法,用它们来解决我们的特定任务(分类宠物品种),并仅根据我们特定任务的具体要求进行调整。
在微调时,我们的挑战是用能够正确实现我们所需任务(分类宠物品种)的权重替换我们添加的线性层中的随机权重,而不破坏精心预训练的权重和其他层。一个简单的技巧可以实现这一点:告诉优化器仅更新那些随机添加的最终层中的权重。根本不要改变神经网络的其他部分的权重。这被称为*冻结*那些预训练的层。
当我们从预训练网络创建模型时fastai 会自动为我们冻结所有预训练层。当我们调用`fine_tune`方法时fastai 会做两件事:
+ 训练随机添加的层一个周期,同时冻结所有其他层
+ 解冻所有层,并根据请求的周期数进行训练
尽管这是一个合理的默认方法,但对于您的特定数据集,您可能通过稍微不同的方式做事情来获得更好的结果。`fine_tune`方法有一些参数可以用来改变其行为,但如果您想获得自定义行为,直接调用底层方法可能更容易。请记住,您可以使用以下语法查看该方法的源代码:
```py
learn.fine_tune??
```
所以让我们尝试手动操作。首先,我们将使用`fit_one_cycle`训练随机添加的层三个周期。正如在第一章中提到的,`fit_one_cycle`是在不使用`fine_tune`的情况下训练模型的建议方法。我们将在本书后面看到原因;简而言之,`fit_one_cycle`的作用是以低学习率开始训练,逐渐增加学习率进行第一部分的训练,然后在最后一部分的训练中逐渐降低学习率:
```py
learn = cnn_learner(dls, resnet34, metrics=error_rate)
learn.fit_one_cycle(3, 3e-3)
```
| epoch | train_loss | valid_loss | error_rate | time |
| --- | --- | --- | --- | --- |
| 0 | 1.188042 | 0.355024 | 0.102842 | 00:20 |
| 1 | 0.534234 | 0.302453 | 0.094723 | 00:20 |
| 2 | 0.325031 | 0.222268 | 0.074425 | 00:20 |
然后我们将解冻模型:
```py
learn.unfreeze()
```
并再次运行`lr_find`,因为有更多层要训练,而且已经训练了三个周期的权重,意味着我们之前找到的学习率不再合适:
```py
learn.lr_find()
```
```py
(1.0964782268274575e-05, 1.5848931980144698e-06)
```
![](img/dlcf_05in06.png)
请注意,图表与随机权重时有所不同:我们没有那种表明模型正在训练的陡峭下降。这是因为我们的模型已经训练过了。在这里,我们有一个相对平坦的区域,然后是一个急剧增加的区域,我们应该选择在那个急剧增加之前的一个点,例如 1e-5。具有最大梯度的点不是我们在这里寻找的应该被忽略。
让我们以适当的学习率进行训练:
```py
learn.fit_one_cycle(6, lr_max=1e-5)
```
| epoch | train_loss | valid_loss | error_rate | time |
| --- | --- | --- | --- | --- |
| 0 | 0.263579 | 0.217419 | 0.069012 | 00:24 |
| 1 | 0.253060 | 0.210346 | 0.062923 | 00:24 |
| 2 | 0.224340 | 0.207357 | 0.060217 | 00:24 |
| 3 | 0.200195 | 0.207244 | 0.061570 | 00:24 |
| 4 | 0.194269 | 0.200149 | 0.059540 | 00:25 |
| 5 | 0.173164 | 0.202301 | 0.059540 | 00:25 |
这稍微改进了我们的模型,但我们还可以做更多。预训练模型的最深层可能不需要像最后一层那样高的学习率,因此我们可能应该为这些层使用不同的学习率——这被称为使用*区分性*学习率。
## 区分性学习率
即使我们解冻后,我们仍然非常关心那些预训练权重的质量。我们不会期望那些预训练参数的最佳学习率与随机添加参数的学习率一样高,即使在我们为随机添加参数调整了几个轮数之后。请记住,预训练权重已经在数百个轮数中,在数百万张图像上进行了训练。
此外,您还记得我们在第一章中看到的图像吗?显示每个层学习的内容?第一层学习非常简单的基础知识,如边缘和梯度检测器;这些对于几乎任何任务都可能非常有用。后面的层学习更复杂的概念,如“眼睛”和“日落”,这些对您的任务可能完全没有用(也许您正在对汽车型号进行分类)。因此,让后面的层比前面的层更快地微调是有道理的。
因此fastai 的默认方法是使用区分性学习率。这种技术最初是在我们将在第十章中介绍的 NLP 迁移学习的 ULMFiT 方法中开发的。就像深度学习中的许多好主意一样,这个方法非常简单:对神经网络的早期层使用较低的学习率,对后期层(尤其是随机添加的层)使用较高的学习率。这个想法基于[Jason Yosinski 等人](https://oreil.ly/j3640)在 2014 年展示的见解,即在迁移学习中,神经网络的不同层应该以不同的速度训练,如图 5-4 所示。
![不同层和训练方法对迁移学习的影响Yosinski](img/dlcf_0504.png)
###### 图 5-4。不同层和训练方法对迁移学习的影响由 Jason Yosinski 等人提供)
fastai 允许您在任何需要学习率的地方传递 Python `slice`对象。传递的第一个值将是神经网络最早层的学习率,第二个值将是最后一层的学习率。中间的层将在该范围内等距地乘法地具有学习率。让我们使用这种方法复制先前的训练,但这次我们只将我们网络的*最低*层的学习率设置为 1e-6其他层将增加到 1e-4。让我们训练一段时间看看会发生什么
```py
learn = cnn_learner(dls, resnet34, metrics=error_rate)
learn.fit_one_cycle(3, 3e-3)
learn.unfreeze()
learn.fit_one_cycle(12, lr_max=slice(1e-6,1e-4))
```
| 轮数 | 训练损失 | 验证损失 | 错误率 | 时间 |
| --- | --- | --- | --- | --- |
| 0 | 1.145300 | 0.345568 | 0.119756 | 00:20 |
| 1 | 0.533986 | 0.251944 | 0.077131 | 00:20 |
| 2 | 0.317696 | 0.208371 | 0.069012 | 00:20 |
| 轮数 | 训练损失 | 验证损失 | 错误率 | 时间 |
| --- | --- | --- | --- | --- |
| 0 | 0.257977 | 0.205400 | 0.067659 | 00:25 |
| 1 | 0.246763 | 0.205107 | 0.066306 | 00:25 |
| 2 | 0.240595 | 0.193848 | 0.062246 | 00:25 |
| 3 | 0.209988 | 0.198061 | 0.062923 | 00:25 |
| 4 | 0.194756 | 0.193130 | 0.064276 | 00:25 |
| 5 | 0.169985 | 0.187885 | 0.056157 | 00:25 |
| 6 | 0.153205 | 0.186145 | 0.058863 | 00:25 |
| 7 | 0.141480 | 0.185316 | 0.053451 | 00:25 |
| 8 | 0.128564 | 0.180999 | 0.051421 | 00:25 |
| 9 | 0.126941 | 0.186288 | 0.054127 | 00:25 |
| 10 | 0.130064 | 0.181764 | 0.054127 | 00:25 |
| 11 | 0.124281 | 0.181855 | 0.054127 | 00:25 |
现在微调效果很好!
fastai 可以展示训练和验证损失的图表:
```py
learn.recorder.plot_loss()
```
![](img/dlcf_05in07.png)
正如你所看到的,训练损失一直在变得越来越好。但请注意,最终验证损失的改善会减缓,有时甚至会变得更糟!这是模型开始过拟合的时候。特别是,模型开始对其预测变得过于自信。但这并不意味着它一定变得不准确。看一下每个 epoch 的训练结果表,你会经常看到准确率持续提高,即使验证损失变得更糟。最终,重要的是你的准确率,或者更一般地说是你选择的指标,而不是损失。损失只是我们给计算机的函数,帮助我们优化。
在训练模型时,你还需要做出的另一个决定是训练多长时间。我们将在下面考虑这个问题。
## 选择 epochs 的数量
通常情况下,你会发现在选择训练多少个 epochs 时,你受到的限制更多是时间,而不是泛化和准确性。因此,你训练的第一步应该是简单地选择一个你愿意等待的时间内可以完成的 epochs 数量。然后查看训练和验证损失图,特别是你的指标。如果你看到它们甚至在最后几个 epochs 中仍在变得更好,那么你就知道你没有训练得太久。
另一方面,你可能会发现你选择的指标在训练结束时确实变得更糟。记住,我们不仅仅是在寻找验证损失变得更糟,而是实际的指标。你的验证损失在训练过程中会先变得更糟,因为模型变得过于自信,只有后来才会因为错误地记忆数据而变得更糟。在实践中,我们只关心后一种情况。记住,我们的损失函数是我们用来让优化器有东西可以区分和优化的,实际上我们关心的不是这个。
在 1cycle 训练出现之前,通常会在每个 epoch 结束时保存模型,然后从所有保存的模型中选择准确率最高的模型。这被称为*早停*。然而,这不太可能给出最好的答案,因为那些中间的 epochs 出现在学习率还没有机会达到小值的情况下,这时它才能真正找到最佳结果。因此,如果你发现你过拟合了,你应该重新从头开始训练模型,并根据之前找到最佳结果的地方选择一个总的 epochs 数量。
如果你有时间训练更多的 epochs你可能会选择用这段时间来训练更多的参数也就是使用更深的架构。
## 更深的架构
一般来说,具有更多参数的模型可以更准确地对数据进行建模。(对于这个泛化有很多很多的例外情况,这取决于你使用的架构的具体情况,但现在这是一个合理的经验法则。)对于我们将在本书中看到的大多数架构,你可以通过简单地添加更多层来创建更大的版本。然而,由于我们想使用预训练模型,我们需要确保选择已经为我们预训练的层数。
这就是为什么在实践中,架构往往只有少数几种变体。例如,在本章中使用的 ResNet 架构有 18、34、50、101 和 152 层的变体,都是在 ImageNet 上预训练的。一个更大的(更多层和参数;有时被描述为模型的*容量*ResNet 版本总是能够给我们更好的训练损失,但它可能更容易过拟合,因为它有更多参数可以过拟合。
总的来说,一个更大的模型能够更好地捕捉数据的真实基本关系,以及捕捉和记忆你个别图像的具体细节。
然而,使用更深的模型将需要更多的 GPU 内存,因此你可能需要降低批量大小以避免*内存不足错误*。当你尝试将太多内容装入 GPU 时,就会发生这种情况,看起来像这样:
```py
Cuda runtime error: out of memory
```
当发生这种情况时,你可能需要重新启动你的笔记本。解决方法是使用较小的批量大小,这意味着在任何给定时间通过你的模型传递较小的图像组。你可以通过使用`bs=`创建你想要的批量大小来调用。
更深层次架构的另一个缺点是训练时间要长得多。一个可以大大加快速度的技术是*混合精度训练*。这指的是在训练过程中尽可能使用不那么精确的数字(半精度浮点数,也称为 fp16。截至 2020 年初,几乎所有当前的 NVIDIA GPU 都支持一种特殊功能,称为*张量核心*,可以将神经网络训练速度提高 2-3 倍。它们还需要更少的 GPU 内存。要在 fastai 中启用此功能,只需在创建`Learner`后添加`to_fp16()`(你还需要导入模块)。
你实际上无法提前知道适合你特定问题的最佳架构——你需要尝试一些训练。所以现在让我们尝试使用混合精度的 ResNet-50
```py
from fastai2.callback.fp16 import *
learn = cnn_learner(dls, resnet50, metrics=error_rate).to_fp16()
learn.fine_tune(6, freeze_epochs=3)
```
| epoch | train_loss | valid_loss | error_rate | time |
| --- | --- | --- | --- | --- |
| 0 | 1.427505 | 0.310554 | 0.098782 | 00:21 |
| 1 | 0.606785 | 0.302325 | 0.094723 | 00:22 |
| 2 | 0.409267 | 0.294803 | 0.091340 | 00:21 |
| epoch | train_loss | valid_loss | error_rate | time |
| --- | --- | --- | --- | --- |
| 0 | 0.261121 | 0.274507 | 0.083897 | 00:26 |
| 1 | 0.296653 | 0.318649 | 0.084574 | 00:26 |
| 2 | 0.242356 | 0.253677 | 0.069012 | 00:26 |
| 3 | 0.150684 | 0.251438 | 0.065629 | 00:26 |
| 4 | 0.094997 | 0.239772 | 0.064276 | 00:26 |
| 5 | 0.061144 | 0.228082 | 0.054804 | 00:26 |
你会看到我们又回到使用`fine_tune`,因为它非常方便!我们可以传递`freeze_epochs`告诉 fastai 在冻结时训练多少个周期。它将自动为大多数数据集更改学习率。
在这种情况下,我们没有从更深的模型中看到明显的优势。这是值得记住的——对于你的特定情况,更大的模型不一定是更好的模型!确保在扩大规模之前尝试小模型。
# 结论
在本章中,你学到了一些重要的实用技巧,既可以为建模准备图像数据(预调整大小,数据块摘要),也可以为拟合模型(学习率查找器,解冻,区分性学习率,设置周期数,使用更深的架构)。使用这些工具将帮助你更快地构建更准确的图像模型。
我们还讨论了交叉熵损失。这本书的这部分值得花费大量时间。在实践中,你可能不太可能需要自己从头开始实现交叉熵损失,但你需要理解该函数的输入和输出,因为它(或它的变体,正如我们将在下一章中看到的)几乎在每个分类模型中使用。因此,当你想要调试一个模型,或将一个模型投入生产,或提高一个模型的准确性时,你需要能够查看其激活和损失,并理解发生了什么以及为什么。如果你不理解你的损失函数,你就无法正确地做到这一点。
如果交叉熵损失函数还没有“点亮”你的灯泡,不要担心——你会理解的!首先,回到前一章,确保你真正理解了`mnist_loss`。然后逐渐地通过本章的笔记本单元格,逐步了解交叉熵损失的每个部分。确保你理解每个计算在做什么以及为什么。尝试自己创建一些小张量,并将它们传递给函数,看看它们返回什么。
记住在实现交叉熵损失时所做的选择并不是唯一可能的选择。就像我们在回归中可以在均方误差和平均绝对差L1之间进行选择一样这里也可以改变细节。如果您对可能有效的其他函数有其他想法请随时在本章的笔记本中尝试但要注意您可能会发现模型训练速度较慢准确性较低。这是因为交叉熵损失的梯度与激活和目标之间的差异成比例因此 SGD 始终会为权重提供一个很好的缩放步长。)
# 问卷调查
1. 为什么我们首先在 CPU 上调整大小到较大尺寸,然后在 GPU 上调整到较小尺寸?
1. 如果您不熟悉正则表达式,请查找正则表达式教程和一些问题集,并完成它们。查看书籍网站以获取建议。
1. 对于大多数深度学习数据集,数据通常以哪两种方式提供?
1. 查阅`L`的文档,并尝试使用它添加的一些新方法。
1. 查阅 Python `pathlib`模块的文档,并尝试使用`Path`类的几种方法。
1. 给出两个图像转换可能降低数据质量的示例。
1. fastai 提供了哪种方法来查看`DataLoaders`中的数据?
1. fastai 提供了哪种方法来帮助您调试`DataBlock`
1. 在彻底清理数据之前,是否应该暂停训练模型?
1. 在 PyTorch 中,交叉熵损失是由哪两个部分组合而成的?
1. softmax 确保的激活函数的两个属性是什么?为什么这很重要?
1. 何时可能希望激活函数不具有这两个属性?
1. 自己计算图 5-3 中的`exp`和`softmax`列(即在电子表格、计算器或笔记本中)。
1. 为什么我们不能使用`torch.where`为标签可能有多于两个类别的数据集创建损失函数?
1. log-2的值是多少为什么
1. 选择学习率时有哪两个好的经验法则来自学习率查找器?
1. `fine_tune`方法执行了哪两个步骤?
1. 在 Jupyter Notebook 中,如何获取方法或函数的源代码?
1. 什么是区分性学习率?
1. 当将 Python `slice`对象作为学习率传递给 fastai 时,它是如何解释的?
1. 为什么在使用 1cycle 训练时,提前停止是一个不好的选择?
1. `resnet50`和`resnet101`之间有什么区别?
1. `to_fp16`是做什么的?
## 进一步研究
1. 找到 Leslie Smith 撰写的介绍学习率查找器的论文,并阅读。
1. 看看是否可以提高本章分类器的准确性。您能达到的最佳准确性是多少?查看论坛和书籍网站,看看其他学生在这个数据集上取得了什么成就以及他们是如何做到的。

View File

@ -0,0 +1,704 @@
# 第六章:其他计算机视觉问题
在上一章中,你学习了一些在实践中训练模型的重要技术。选择学习率和周期数等考虑因素对于获得良好结果非常重要。
在本章中,我们将看到另外两种计算机视觉问题:多标签分类和回归。第一种情况发生在你想要预测每个图像的多个标签(有时甚至没有标签),第二种情况发生在你的标签是一个或多个数字——数量而不是类别。
在这个过程中,我们将更深入地研究深度学习模型中的输出激活、目标和损失函数。
# 多标签分类
*多标签分类*指的是识别图像中可能不只包含一种对象类别的问题。可能有多种对象,或者在你寻找的类别中根本没有对象。
例如,这对我们的熊分类器来说是一个很好的方法。我们在第二章中推出的熊分类器的一个问题是,如果用户上传了任何不是熊的东西,模型仍然会说它是灰熊、黑熊或泰迪熊之一——它无法预测“根本不是熊”。事实上,在我们完成本章后,你可以回到你的图像分类器应用程序,尝试使用多标签技术重新训练它,然后通过传入一个不属于你识别类别的图像来测试它。
实际上,我们并没有看到很多人为这个目的训练多标签分类器的例子——但我们经常看到用户和开发人员抱怨这个问题。看起来这个简单的解决方案并不被广泛理解或赞赏!因为在实践中,很可能有一些图像没有匹配项或有多个匹配项,所以我们应该预期在实践中,多标签分类器比单标签分类器更具普适性。
首先让我们看看多标签数据集是什么样的;然后我们将解释如何准备好供我们的模型使用。你会发现模型的架构与前一章并没有改变;只有损失函数改变了。让我们从数据开始。
## 数据
对于我们的示例,我们将使用 PASCAL 数据集,该数据集中的每个图像可以有多种分类对象。
我们首先按照通常的方式下载和提取数据集:
```py
from fastai.vision.all import *
path = untar_data(URLs.PASCAL_2007)
```
这个数据集与我们之前看到的不同,它不是按文件名或文件夹结构化的,而是附带一个 CSV 文件,告诉我们每个图像要使用的标签。我们可以通过将其读入 Pandas DataFrame 来检查 CSV 文件:
```py
df = pd.read_csv(path/'train.csv')
df.head()
```
| | 文件名 | 标签 | 是否有效 |
| --- | --- | --- | --- |
| 0 | 000005.jpg | 椅子 | True |
| 1 | 000007.jpg | 汽车 | True |
| 2 | 000009.jpg | 马 人 | True |
| 3 | 000012.jpg | 汽车 | False |
| 4 | 000016.jpg | 自行车 | True |
正如你所看到的,每个图像中的类别列表显示为一个以空格分隔的字符串。
既然我们已经看到了数据的样子,让我们准备好进行模型训练。
## 构建数据块
我们如何将`DataFrame`对象转换为`DataLoaders`对象?我们通常建议在可能的情况下使用数据块 API 来创建`DataLoaders`对象,因为它提供了灵活性和简单性的良好组合。在这里,我们将展示使用数据块 API 构建`DataLoaders`对象的实践步骤,以这个数据集为例。
正如我们所看到的PyTorch 和 fastai 有两个主要类用于表示和访问训练集或验证集:
`数据集`
返回单个项目的独立变量和依赖变量的元组的集合
`数据加载器`
提供一系列小批量的迭代器,其中每个小批量是一批独立变量和一批因变量的组合
除此之外fastai 还提供了两个类来将您的训练和验证集合在一起:
`Datasets`
包含一个训练`Dataset`和一个验证`Dataset`的迭代器
`DataLoaders`
包含一个训练`DataLoader`和一个验证`DataLoader`的对象
由于`DataLoader`是建立在`Dataset`之上并为其添加附加功能(将多个项目整合成一个小批量),通常最容易的方法是首先创建和测试`Datasets`,然后再查看`DataLoaders`。
当我们创建`DataBlock`时,我们逐步逐步构建,并使用笔记本检查我们的数据。这是一个很好的方式,可以确保您在编码时保持动力,并留意任何问题。易于调试,因为您知道如果出现问题,它就在您刚刚输入的代码行中!
让我们从没有参数创建的数据块开始,这是最简单的情况:
```py
dblock = DataBlock()
```
我们可以从中创建一个`Datasets`对象。唯一需要的是一个源——在这种情况下是我们的 DataFrame
```py
dsets = dblock.datasets(df)
```
这包含一个`train`和一个`valid`数据集,我们可以对其进行索引:
```py
dsets.train[0]
```
```py
(fname 008663.jpg
labels car person
is_valid False
Name: 4346, dtype: object,
fname 008663.jpg
labels car person
is_valid False
Name: 4346, dtype: object)
```
正如您所看到的,这只是简单地两次返回 DataFrame 的一行。这是因为默认情况下,数据块假定我们有两个东西:输入和目标。我们需要从 DataFrame 中获取适当的字段,可以通过传递`get_x`和`get_y`函数来实现:
```py
dblock = DataBlock(get_x = lambda r: r['fname'], get_y = lambda r: r['labels'])
dsets = dblock.datasets(df)
dsets.train[0]
```
```py
('005620.jpg', 'aeroplane')
```
正如您所看到的,我们并没有以通常的方式定义函数,而是使用了 Python 的`lambda`关键字。这只是定义并引用函数的一种快捷方式。以下更冗长的方法是相同的:
```py
def get_x(r): return r['fname']
def get_y(r): return r['labels']
dblock = DataBlock(get_x = get_x, get_y = get_y)
dsets = dblock.datasets(df)
dsets.train[0]
```
```py
('002549.jpg', 'tvmonitor')
```
Lambda 函数非常适合快速迭代,但不兼容序列化,因此我们建议您在训练后要导出您的`Learner`时使用更冗长的方法如果您只是在尝试实验lambda 是可以的)。
我们可以看到独立变量需要转换为完整路径,以便我们可以将其作为图像打开,而因变量需要根据空格字符(这是 Python 的`split`函数的默认值)进行拆分,以便它变成一个列表:
```py
def get_x(r): return path/'train'/r['fname']
def get_y(r): return r['labels'].split(' ')
dblock = DataBlock(get_x = get_x, get_y = get_y)
dsets = dblock.datasets(df)
dsets.train[0]
```
```py
(Path('/home/sgugger/.fastai/data/pascal_2007/train/008663.jpg'),
['car', 'person'])
```
要实际打开图像并将其转换为张量,我们需要使用一组转换;块类型将为我们提供这些。我们可以使用先前使用过的相同块类型,只有一个例外:`ImageBlock`将再次正常工作,因为我们有一个指向有效图像的路径,但`CategoryBlock`不会起作用。问题在于该块返回一个单个整数,但我们需要为每个项目有多个标签。为了解决这个问题,我们使用`MultiCategoryBlock`。这种类型的块期望接收一个字符串列表,就像我们在这种情况下所做的那样,所以让我们来测试一下:
```py
dblock = DataBlock(blocks=(ImageBlock, MultiCategoryBlock),
get_x = get_x, get_y = get_y)
dsets = dblock.datasets(df)
dsets.train[0]
```
```py
(PILImage mode=RGB size=500x375,
TensorMultiCategory([0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 1., 0., 0.,
> 0., 0., 0., 0., 0., 0.]))
```
正如您所看到的,我们的类别列表的编码方式与常规的`CategoryBlock`不同。在那种情况下,我们有一个整数表示哪个类别存在,基于它在我们的词汇表中的位置。然而,在这种情况下,我们有一系列 0其中任何位置上有一个 1 表示该类别存在。例如,如果第二和第四位置上有一个 1那意味着词汇项二和四在这个图像中存在。这被称为*独热编码*。我们不能简单地使用类别索引列表的原因是每个列表的长度都不同,而 PyTorch 需要张量,其中所有内容必须是相同长度。
# 行话:独热编码
使用一个 0 向量,其中每个位置都表示数据中表示的位置,以编码一个整数列表。
让我们来看看这个例子中类别代表什么(我们使用方便的`torch.where`函数,告诉我们条件为真或假的所有索引):
```py
idxs = torch.where(dsets.train[0][1]==1.)[0]
dsets.train.vocab[idxs]
```
```py
(#1) ['dog']
```
使用 NumPy 数组、PyTorch 张量和 fastai 的`L`类,我们可以直接使用列表或向量进行索引,这使得很多代码(比如这个例子)更清晰、更简洁。
到目前为止,我们忽略了列`is_valid`,这意味着`DataBlock`一直在使用默认的随机拆分。要明确选择我们验证集的元素,我们需要编写一个函数并将其传递给`splitter`(或使用 fastai 的预定义函数或类之一)。它将获取项目(这里是我们整个 DataFrame并必须返回两个或更多整数列表
```py
def splitter(df):
train = df.index[~df['is_valid']].tolist()
valid = df.index[df['is_valid']].tolist()
return train,valid
dblock = DataBlock(blocks=(ImageBlock, MultiCategoryBlock),
splitter=splitter,
get_x=get_x,
get_y=get_y)
dsets = dblock.datasets(df)
dsets.train[0]
```
```py
(PILImage mode=RGB size=500x333,
TensorMultiCategory([0., 0., 0., 0., 0., 0., 1., 0., 0., 0., 0., 0., 0., 0.,
> 0., 0., 0., 0., 0., 0.]))
```
正如我们讨论过的,`DataLoader`将`Dataset`中的项目整理成一个小批量。这是一个张量的元组,其中每个张量简单地堆叠了`Dataset`项目中该位置的项目。
现在我们已经确认了单个项目看起来没问题,还有一步,我们需要确保我们可以创建我们的`DataLoaders`,即确保每个项目的大小相同。为了做到这一点,我们可以使用`RandomResizedCrop`
```py
dblock = DataBlock(blocks=(ImageBlock, MultiCategoryBlock),
splitter=splitter,
get_x=get_x,
get_y=get_y,
item_tfms = RandomResizedCrop(128, min_scale=0.35))
dls = dblock.dataloaders(df)
```
现在我们可以显示我们数据的一个样本:
```py
dls.show_batch(nrows=1, ncols=3)
```
![](img/dlcf_06in01.png)
请记住,如果在从`DataBlock`创建`DataLoaders`时出现任何问题,或者如果您想查看`DataBlock`的确切情况,您可以使用我们在上一章中介绍的`summary`方法。
我们的数据现在已经准备好用于训练模型。正如我们将看到的,当我们创建我们的`Learner`时没有任何变化但在幕后fastai 库将为我们选择一个新的损失函数:二元交叉熵。
## 二元交叉熵
现在我们将创建我们的`Learner`。我们在第四章中看到,`Learner`对象包含四个主要内容:模型、`DataLoaders`对象、优化器和要使用的损失函数。我们已经有了我们的`DataLoaders`,我们可以利用 fastai 的`resnet`模型(稍后我们将学习如何从头开始创建),并且我们知道如何创建一个`SGD`优化器。因此,让我们专注于确保我们有一个合适的损失函数。为此,让我们使用`cnn_learner`创建一个`Learner`,这样我们就可以查看它的激活:
```py
learn = cnn_learner(dls, resnet18)
```
我们还看到,`Learner`中的模型通常是从`nn.Module`继承的类的对象,并且我们可以使用括号调用它,它将返回模型的激活。你应该将独立变量作为一个小批量传递给它。我们可以尝试从我们的`DataLoader`中获取一个小批量,然后将其传递给模型:
```py
x,y = dls.train.one_batch()
activs = learn.model(x)
activs.shape
```
```py
torch.Size([64, 20])
```
想想为什么`activs`有这种形状——我们的批量大小为 64我们需要计算 20 个类别中的每一个的概率。这是其中一个激活的样子:
```py
activs[0]
```
```py
tensor([ 2.0258, -1.3543, 1.4640, 1.7754, -1.2820, -5.8053, 3.6130, 0.7193,
> -4.3683, -2.5001, -2.8373, -1.8037, 2.0122, 0.6189, 1.9729, 0.8999,
> -2.6769, -0.3829, 1.2212, 1.6073],
device='cuda:0', grad_fn=<SelectBackward>)
```
# 获取模型激活
知道如何手动获取一个小批量并将其传递到模型中,并查看激活和损失,对于调试模型非常重要。这对学习也非常有帮助,这样你就可以清楚地看到发生了什么。
它们还没有缩放到 0 到 1 之间,但我们学会了如何在第四章中使用`sigmoid`函数来做到这一点。我们还看到了如何基于此计算损失——这是我们在第四章中的损失函数,加上了在前一章中讨论的`log`
```py
def binary_cross_entropy(inputs, targets):
inputs = inputs.sigmoid()
return -torch.where(targets==1, inputs, 1-inputs).log().mean()
```
请注意,由于我们有一个独热编码的因变量,我们不能直接使用`nll_loss`或`softmax`(因此我们不能使用`cross_entropy`
+ 正如我们所看到的,`softmax`要求所有预测总和为 1并且倾向于使一个激活远远大于其他激活因为使用了`exp`);然而,我们可能有多个我们确信出现在图像中的对象,因此限制激活的最大总和为 1 并不是一个好主意。出于同样的原因,如果我们认为*任何*类别都不出现在图像中,我们可能希望总和*小于*1。
+ 正如我们所看到的,`nll_loss`返回的是一个激活值:与项目的单个标签对应的单个激活值。当我们有多个标签时,这是没有意义的。
另一方面,`binary_cross_entropy`函数,即`mnist_loss`加上`log`,正是我们所需要的,这要归功于 PyTorch 的逐元素操作的魔力。每个激活将与每个列的每个目标进行比较,因此我们不必做任何事情使此函数适用于多个列。
# Jeremy Says
我真的很喜欢使用像 PyTorch 这样的库,具有广播和逐元素操作,因为我经常发现我可以编写的代码同样适用于单个项目或一批项目,而无需更改。`binary_cross_entropy`就是一个很好的例子。通过使用这些操作,我们不必自己编写循环,可以依赖 PyTorch 根据我们正在处理的张量的秩适当地执行我们需要的循环。
PyTorch 已经为我们提供了这个函数。实际上,它提供了许多版本,名称相当令人困惑!
`F.binary_cross_entropy`及其模块等效`nn.BCELoss`计算一个独热编码目标的交叉熵,但不包括初始的`sigmoid`。通常,对于独热编码目标,您将希望使用`F.binary_cross_entropy_with_logits`(或`nn.BCEWithLogitsLoss`),它们在一个函数中同时执行 sigmoid 和二元交叉熵,就像前面的例子一样。
对于单标签数据集(如 MNIST 或 Pet 数据集),其中目标被编码为单个整数,相应的是`F.nll_loss`或`nn.NLLLoss`(没有初始 softmax 的版本),以及`F.cross_entropy`或`nn.CrossEntropyLoss`(具有初始 softmax 的版本)。
由于我们有一个独热编码的目标,我们将使用`BCEWithLogitsLoss`
```py
loss_func = nn.BCEWithLogitsLoss()
loss = loss_func(activs, y)
loss
```
```py
tensor(1.0082, device='cuda:5', grad_fn=<BinaryCrossEntropyWithLogitsBackward>)
```
我们不需要告诉 fastai 使用这个损失函数尽管如果我们想要的话可以这样做因为它将自动为我们选择。fastai 知道`DataLoaders`具有多个类别标签,因此默认情况下将使用`nn.BCEWithLogitsLoss`。
与前一章相比的一个变化是我们使用的指标:因为这是一个多标签问题,我们不能使用准确度函数。为什么呢?嗯,准确度是这样比较我们的输出和我们的目标的:
```py
def accuracy(inp, targ, axis=-1):
"Compute accuracy with `targ` when `pred` is bs * n_classes"
pred = inp.argmax(dim=axis)
return (pred == targ).float().mean()
```
预测的类是具有最高激活的类(这就是`argmax`的作用)。这里不起作用,因为我们可能在单个图像上有多个预测。在对我们的激活应用 sigmoid使它们在 0 和 1 之间)之后,我们需要通过选择*阈值*来决定哪些是 0哪些是 1。高于阈值的每个值将被视为 1低于阈值的每个值将被视为 0
```py
def accuracy_multi(inp, targ, thresh=0.5, sigmoid=True):
"Compute accuracy when `inp` and `targ` are the same size."
if sigmoid: inp = inp.sigmoid()
return ((inp>thresh)==targ.bool()).float().mean()
```
如果我们直接将`accuracy_multi`作为指标传递,它将使用`threshold`的默认值,即 0.5。我们可能希望调整该默认值并创建一个具有不同默认值的新版本的`accuracy_multi`。为了帮助解决这个问题Python 中有一个名为`partial`的函数。它允许我们*绑定*一个带有一些参数或关键字参数的函数,从而创建该函数的新版本,每当调用它时,总是包含这些参数。例如,这里是一个接受两个参数的简单函数:
```py
def say_hello(name, say_what="Hello"): return f"{say_what} {name}."
say_hello('Jeremy'),say_hello('Jeremy', 'Ahoy!')
```
```py
('Hello Jeremy.', 'Ahoy! Jeremy.')
```
我们可以通过使用`partial`切换到该函数的法语版本:
```py
f = partial(say_hello, say_what="Bonjour")
f("Jeremy"),f("Sylvain")
```
```py
('Bonjour Jeremy.', 'Bonjour Sylvain.')
```
现在我们可以训练我们的模型。让我们尝试将准确度阈值设置为 0.2 作为我们的指标:
```py
learn = cnn_learner(dls, resnet50, metrics=partial(accuracy_multi, thresh=0.2))
learn.fine_tune(3, base_lr=3e-3, freeze_epochs=4)
```
| epoch | train_loss | valid_loss | accuracy_multi | time |
| --- | --- | --- | --- | --- |
| 0 | 0.903610 | 0.659728 | 0.263068 | 00:07 |
| 1 | 0.724266 | 0.346332 | 0.525458 | 00:07 |
| 2 | 0.415597 | 0.125662 | 0.937590 | 00:07 |
| 3 | 0.254987 | 0.116880 | 0.945418 | 00:07 |
| epoch | train_loss | valid_loss | accuracy_multi | time |
| --- | --- | --- | --- | --- |
| 0 | 0.123872 | 0.132634 | 0.940179 | 00:08 |
| 1 | 0.112387 | 0.113758 | 0.949343 | 00:08 |
| 2 | 0.092151 | 0.104368 | 0.951195 | 00:08 |
选择阈值很重要。如果选择的阈值太低,通常会选择错误标记的对象。我们可以通过改变我们的度量标准然后调用`validate`来看到这一点,它会返回验证损失和度量标准:
```py
learn.metrics = partial(accuracy_multi, thresh=0.1)
learn.validate()
```
```py
(#2) [0.10436797887086868,0.93057781457901]
```
如果选择的阈值太高,将只选择模型非常有信心的对象:
```py
learn.metrics = partial(accuracy_multi, thresh=0.99)
learn.validate()
```
```py
(#2) [0.10436797887086868,0.9416930675506592]
```
我们可以通过尝试几个级别并查看哪个效果最好来找到最佳阈值。如果我们只抓取一次预测,这将快得多:
```py
preds,targs = learn.get_preds()
```
然后我们可以直接调用度量标准。请注意,默认情况下,`get_preds`会为我们应用输出激活函数(在本例中为 sigmoid因此我们需要告诉`accuracy_multi`不要应用它:
```py
accuracy_multi(preds, targs, thresh=0.9, sigmoid=False)
```
```py
TensorMultiCategory(0.9554)
```
现在我们可以使用这种方法找到最佳阈值水平:
```py
xs = torch.linspace(0.05,0.95,29)
accs = [accuracy_multi(preds, targs, thresh=i, sigmoid=False) for i in xs]
plt.plot(xs,accs);
```
![](img/dlcf_06in02.png)
在这种情况下,我们使用验证集来选择一个超参数(阈值),这就是验证集的目的。有时学生们表达了他们的担忧,即我们可能会对验证集*过拟合*,因为我们正在尝试很多值来找出哪个是最好的。然而,正如你在图中看到的,改变阈值在这种情况下会产生一个平滑的曲线,因此我们显然没有选择不合适的异常值。这是一个很好的例子,说明你必须小心理论(不要尝试很多超参数值,否则可能会过拟合验证集)与实践(如果关系是平滑的,这样做是可以的)之间的区别。
这结束了本章专门讨论多标签分类的部分。接下来,我们将看一下回归问题。
# 回归
很容易将深度学习模型视为被分类到领域中,如*计算机视觉*、*NLP*等等。事实上,这就是 fastai 对其应用程序进行分类的方式——主要是因为大多数人习惯于这样思考事物。
但实际上,这隐藏了一个更有趣和更深入的视角。一个模型由其独立和依赖变量以及其损失函数定义。这意味着实际上有比简单的基于领域的分割更广泛的模型数组。也许我们有一个独立变量是图像,一个依赖变量是文本(例如,从图像生成标题);或者我们有一个独立变量是文本,一个依赖变量是图像(例如,从标题生成图像——这实际上是深度学习可以做到的!);或者我们有图像、文本和表格数据作为独立变量,我们试图预测产品购买……可能性真的是无穷无尽的。
要能够超越固定应用程序,为新问题制定自己的新颖解决方案,真正理解数据块 API也许还有我们将在本书后面看到的中间层 API是有帮助的。举个例子让我们考虑*图像回归*的问题。这指的是从一个独立变量是图像,依赖变量是一个或多个浮点数的数据集中学习。通常我们看到人们将图像回归视为一个完全独立的应用程序——但正如你在这里看到的,我们可以将其视为数据块 API 上的另一个 CNN。
我们将直接跳到图像回归的一个有点棘手的变体,因为我们知道你已经准备好了!我们将做一个关键点模型。*关键点*指的是图像中表示的特定位置——在这种情况下,我们将使用人物的图像,并且我们将寻找每个图像中人脸的中心。这意味着我们实际上将为每个图像预测*两个*值:人脸中心的行和列。
## 数据组装
我们将在这一部分使用[Biwi Kinect Head Pose 数据集](https://oreil.ly/-4cO-)。我们将像往常一样开始下载数据集:
```py
path = untar_data(URLs.BIWI_HEAD_POSE)
```
让我们看看我们有什么!
```py
path.ls()
```
```py
(#50) [Path('13.obj'),Path('07.obj'),Path('06.obj'),Path('13'),Path('10'),Path('
> 02'),Path('11'),Path('01'),Path('20.obj'),Path('17')...]
```
有 24 个从 01 到 24 编号的目录(它们对应不同的被摄人物),以及每个目录对应的*.obj*文件(我们这里不需要)。让我们看看其中一个目录的内容:
```py
(path/'01').ls()
```
```py
(#1000) [Path('01/frame_00281_pose.txt'),Path('01/frame_00078_pose.txt'),Path('0
> 1/frame_00349_rgb.jpg'),Path('01/frame_00304_pose.txt'),Path('01/frame_00207_
> pose.txt'),Path('01/frame_00116_rgb.jpg'),Path('01/frame_00084_rgb.jpg'),Path
> ('01/frame_00070_rgb.jpg'),Path('01/frame_00125_pose.txt'),Path('01/frame_003
> 24_rgb.jpg')...]
```
在子目录中,我们有不同的帧。每个帧都带有一个图像(*_rgb.jpg*)和一个姿势文件(*_pose.txt*)。我们可以使用`get_image_files`轻松递归获取所有图像文件,然后编写一个函数,将图像文件名转换为其关联的姿势文件:
```py
img_files = get_image_files(path)
def img2pose(x): return Path(f'{str(x)[:-7]}pose.txt')
img2pose(img_files[0])
```
```py
Path('13/frame_00349_pose.txt')
```
让我们来看看我们的第一张图片:
```py
im = PILImage.create(img_files[0])
im.shape
```
```py
(480, 640)
```
```py
im.to_thumb(160)
```
![](img/dlcf_06in03.png)
[Biwi 数据集网站](https://oreil.ly/wHL28)用于解释与每个图像关联的姿势文本文件的格式,显示头部中心的位置。这些细节对我们来说并不重要,所以我们只会展示我们用来提取头部中心点的函数:
```py
cal = np.genfromtxt(path/'01'/'rgb.cal', skip_footer=6)
def get_ctr(f):
ctr = np.genfromtxt(img2pose(f), skip_header=3)
c1 = ctr[0] * cal[0][0]/ctr[2] + cal[0][2]
c2 = ctr[1] * cal[1][1]/ctr[2] + cal[1][2]
return tensor([c1,c2])
```
这个函数将坐标作为两个项目的张量返回:
```py
get_ctr(img_files[0])
```
```py
tensor([384.6370, 259.4787])
```
我们可以将此函数传递给`DataBlock`作为`get_y`,因为它负责为每个项目标记。我们将将图像调整为其输入大小的一半,以加快训练速度。
一个重要的要点是我们不应该只使用随机分割器。在这个数据集中,同一个人出现在多个图像中,但我们希望确保我们的模型可以泛化到它尚未见过的人。数据集中的每个文件夹包含一个人的图像。因此,我们可以创建一个分割器函数,仅为一个人返回`True`,从而使验证集仅包含该人的图像。
与以前的数据块示例的唯一区别是第二个块是`PointBlock`。这是必要的,以便 fastai 知道标签代表坐标;这样,它就知道在进行数据增强时,应该对这些坐标执行与图像相同的增强:
```py
biwi = DataBlock(
blocks=(ImageBlock, PointBlock),
get_items=get_image_files,
get_y=get_ctr,
splitter=FuncSplitter(lambda o: o.parent.name=='13'),
batch_tfms=[*aug_transforms(size=(240,320)),
Normalize.from_stats(*imagenet_stats)]
)
```
# 点和数据增强
我们不知道其他库(除了 fastai会自动且正确地将数据增强应用于坐标。因此如果您使用另一个库可能需要禁用这些问题的数据增强。
在进行任何建模之前,我们应该查看我们的数据以确认它看起来没问题:
```py
dls = biwi.dataloaders(path)
dls.show_batch(max_n=9, figsize=(8,6))
```
![](img/dlcf_06in04.png)
看起来不错!除了通过视觉查看批次外,还可以查看底层张量(尤其是作为学生;这将有助于澄清您对模型实际看到的内容的理解):
```py
xb,yb = dls.one_batch()
xb.shape,yb.shape
```
```py
(torch.Size([64, 3, 240, 320]), torch.Size([64, 1, 2]))
```
确保您了解为什么这些是我们小批量的形状。
这是依赖变量的一个示例行:
```py
yb[0]
```
```py
tensor([[0.0111, 0.1810]], device='cuda:5')
```
正如您所看到的,我们不必使用单独的*图像回归*应用程序;我们所要做的就是标记数据并告诉 fastai 独立变量和因变量代表什么类型的数据。
创建我们的`Learner`也是一样的。我们将使用与之前相同的函数,只有一个新参数,然后我们就可以准备训练我们的模型了。
## 训练模型
像往常一样,我们可以使用`cnn_learner`来创建我们的`Learner`。还记得在第一章中我们如何使用`y_range`告诉 fastai 我们目标的范围吗我们将在这里做同样的事情fastai 和 PyTorch 中的坐标始终在-1 和+1 之间重新缩放):
```py
learn = cnn_learner(dls, resnet18, y_range=(-1,1))
```
`y_range`在 fastai 中使用`sigmoid_range`实现,其定义如下:
```py
def sigmoid_range(x, lo, hi): return torch.sigmoid(x) * (hi-lo) + lo
```
如果定义了`y_range`,则将其设置为模型的最终层。花点时间思考一下这个函数的作用,以及为什么它强制模型在范围`(lo,hi)`内输出激活。
这是它的样子:
```py
plot_function(partial(sigmoid_range,lo=-1,hi=1), min=-4, max=4)
```
![](img/dlcf_06in05.png)
我们没有指定损失函数,这意味着我们得到了 fastai 选择的默认值。让我们看看它为我们选择了什么:
```py
dls.loss_func
```
```py
FlattenedLoss of MSELoss()
```
这是有道理的,因为当坐标被用作因变量时,大多数情况下我们可能会尽可能地预测接近某个值;这基本上就是 `MSELoss`(均方误差损失)所做的。如果你想使用不同的损失函数,你可以通过使用 `loss_func` 参数将其传递给 `cnn_learner`
还要注意,我们没有指定任何指标。这是因为均方误差已经是这个任务的一个有用指标(尽管在我们取平方根之后可能更易解释)。
我们可以使用学习率查找器选择一个好的学习率:
```py
learn.lr_find()
```
![](img/dlcf_06in06.png)
我们将尝试一个学习率为 2e-2
```py
lr = 2e-2
learn.fit_one_cycle(5, lr)
```
| epoch | train_loss | valid_loss | time |
| --- | --- | --- | --- |
| 0 | 0.045840 | 0.012957 | 00:36 |
| 1 | 0.006369 | 0.001853 | 00:36 |
| 2 | 0.003000 | 0.000496 | 00:37 |
| 3 | 0.001963 | 0.000360 | 00:37 |
| 4 | 0.001584 | 0.000116 | 00:36 |
通常情况下,当我们运行这个时,我们得到的损失大约是 0.0001,这对应于这个平均坐标预测误差:
```py
math.sqrt(0.0001)
```
```py
0.01
```
这听起来非常准确!但是重要的是要用 `Learner.show_results` 查看我们的结果。左侧是实际(*真实*)坐标,右侧是我们模型的预测:
```py
learn.show_results(ds_idx=1, max_n=3, figsize=(6,8))
```
![](img/dlcf_06in07.png)
令人惊讶的是,仅仅几分钟的计算,我们就创建了一个如此准确的关键点模型,而且没有任何特定领域的应用。这就是在灵活的 API 上构建并使用迁移学习的力量!特别引人注目的是,我们能够如此有效地使用迁移学习,即使在完全不同的任务之间;我们的预训练模型是用来进行图像分类的,而我们对图像回归进行了微调。
# 结论
在乍一看完全不同的问题(单标签分类、多标签分类和回归)中,我们最终使用相同的模型,只是输出的数量不同。唯一改变的是损失函数,这就是为什么重要的是要仔细检查你是否为你的问题使用了正确的损失函数。
fastai 将自动尝试从您构建的数据中选择正确的损失函数,但如果您使用纯 PyTorch 构建您的 `DataLoader`,请确保您认真考虑您选择的损失函数,并记住您很可能想要以下内容:
+ `nn.CrossEntropyLoss` 用于单标签分类
+ `nn.BCEWithLogitsLoss` 用于多标签分类
+ `nn.MSELoss` 用于回归
# 问卷
1. 多标签分类如何提高熊分类器的可用性?
1. 在多标签分类问题中,我们如何对因变量进行编码?
1. 如何访问 DataFrame 的行和列,就像它是一个矩阵一样?
1. 如何从 DataFrame 中按名称获取列?
1. `Dataset``DataLoader` 之间有什么区别?
1. `Datasets` 对象通常包含什么?
1. `DataLoaders` 对象通常包含什么?
1. `lambda` 在 Python 中是做什么的?
1. 如何使用数据块 API 自定义独立变量和因变量的创建方法?
1. 当使用一个独热编码的目标时,为什么 softmax 不是一个合适的输出激活函数?
1. 当使用一个独热编码的目标时,为什么 `nll_loss` 不是一个合适的损失函数?
1. `nn.BCELoss``nn.BCEWithLogitsLoss` 之间有什么区别?
1. 为什么在多标签问题中不能使用常规准确率?
1. 何时可以在验证集上调整超参数?
1. `y_range` 在 fastai 中是如何实现的?(看看你是否可以自己实现并在不偷看的情况下测试!)
1. 回归问题是什么?对于这样的问题应该使用什么损失函数?
1. 为了确保 fastai 库将相同的数据增强应用于您的输入图像和目标点坐标,您需要做什么?
## 进一步研究
1. 阅读关于 Pandas DataFrames 的教程,并尝试一些看起来有趣的方法。查看书籍网站上推荐的教程。
1. 使用多标签分类重新训练熊分类器。看看你是否可以使其有效地处理不包含任何熊的图像,包括在 Web 应用程序中显示该信息。尝试一张包含两种熊的图像。检查在单标签数据集上使用多标签分类是否会影响准确性。

View File

@ -0,0 +1,374 @@
# 第七章:训练一个最先进的模型
本章介绍了更高级的技术,用于训练图像分类模型并获得最先进的结果。如果您想了解更多关于深度学习的其他应用,并稍后回来,您可以跳过它——后续章节不会假设您已掌握这些材料。
我们将看一下什么是归一化,一种强大的数据增强技术叫做 Mixup渐进式调整大小方法以及测试时间增强。为了展示所有这些我们将从头开始训练一个模型不使用迁移学习使用一个名为 Imagenette 的 ImageNet 子集。它包含了原始 ImageNet 数据集中 10 个非常不同的类别的子集,使得在我们想要进行实验时训练更快。
这将比我们之前的数据集更难做得好,因为我们使用全尺寸、全彩色的图像,这些图像是不同大小、不同方向、不同光照等对象的照片。因此,在本章中,我们将介绍一些重要的技术,以便充分利用您的数据集,特别是当您从头开始训练,或者使用迁移学习在一个与预训练模型使用的非常不同类型的数据集上训练模型时。
# Imagenette
当 fast.ai 刚开始时,人们主要使用三个主要数据集来构建和测试计算机视觉模型:
ImageNet
1.3 百万张各种尺寸的图像,大约 500 像素宽,分为 1,000 个类别,需要几天时间来训练
MNIST
50,000 个 28×28 像素的灰度手写数字
CIFAR10
60,000 个 32×32 像素的彩色图像,分为 10 类
问题在于较小的数据集无法有效地泛化到大型 ImageNet 数据集。在 ImageNet 上表现良好的方法通常必须在 ImageNet 上开发和训练。这导致许多人认为,只有拥有巨大计算资源的研究人员才能有效地为发展图像分类算法做出贡献。
我们认为这似乎是不太可能成立的。我们从未见过一项研究表明 ImageNet 恰好是正确的大小,其他数据集无法提供有用的见解。因此,我们希望创建一个新的数据集,研究人员可以快速、廉价地测试他们的算法,但也能提供可能在完整的 ImageNet 数据集上起作用的见解。
大约三个小时后,我们创建了 Imagenette。我们从完整的 ImageNet 中选择了 10 个看起来非常不同的类别。正如我们所希望的那样,我们能够快速、廉价地创建一个能够识别这些类别的分类器。然后我们尝试了一些算法调整,看它们如何影响 Imagenette。我们发现一些效果不错的并在 ImageNet 上进行了测试,我们很高兴地发现我们的调整在 ImageNet 上也效果很好!
这里有一个重要的信息:您得到的数据集不一定是您想要的数据集。特别是不太可能是您想要进行开发和原型设计的数据集。您应该力求迭代速度不超过几分钟——也就是说,当您想尝试一个新想法时,您应该能够在几分钟内训练一个模型并查看其效果。如果做一个实验花费的时间更长,考虑如何减少数据集的规模,或简化模型,以提高实验速度。您做的实验越多,效果就越好!
让我们从这个数据集开始:
```py
from fastai.vision.all import *
path = untar_data(URLs.IMAGENETTE)
```
首先,我们将使用在第五章中介绍的*预调整*技巧将我们的数据集放入`DataLoaders`对象中:
```py
dblock = DataBlock(blocks=(ImageBlock(), CategoryBlock()),
get_items=get_image_files,
get_y=parent_label,
item_tfms=Resize(460),
batch_tfms=aug_transforms(size=224, min_scale=0.75))
dls = dblock.dataloaders(path, bs=64)
```
然后我们将进行一个作为基线的训练运行:
```py
model = xresnet50()
learn = Learner(dls, model, loss_func=CrossEntropyLossFlat(), metrics=accuracy)
learn.fit_one_cycle(5, 3e-3)
```
| epoch | train_loss | valid_loss | accuracy | time |
| --- | --- | --- | --- | --- |
| 0 | 1.583403 | 2.064317 | 0.401792 | 01:03 |
| 1 | 1.208877 | 1.260106 | 0.601568 | 01:02 |
| 2 | 0.925265 | 1.036154 | 0.664302 | 01:03 |
| 3 | 0.730190 | 0.700906 | 0.777819 | 01:03 |
| 4 | 0.585707 | 0.541810 | 0.825243 | 01:03 |
这是一个很好的基准,因为我们没有使用预训练模型,但我们可以做得更好。当使用从头开始训练的模型,或者对与预训练使用的数据集非常不同的数据集进行微调时,一些额外的技术就变得非常重要。在本章的其余部分,我们将考虑一些您需要熟悉的关键方法。第一个方法是*归一化*您的数据。
# 归一化
在训练模型时,如果您的输入数据是*归一化*的,那将会有所帮助——也就是说,具有平均值为 0 和标准差为 1。但大多数图像和计算机视觉库使用像素值在 0 到 255 之间,或者在 0 到 1 之间;在任何一种情况下,您的数据都不会具有平均值为 0 和标准差为 1。
让我们获取一批数据并查看这些值,通过对除了通道轴之外的所有轴进行平均,通道轴是轴 1
```py
x,y = dls.one_batch()
x.mean(dim=[0,2,3]),x.std(dim=[0,2,3])
```
```py
(TensorImage([0.4842, 0.4711, 0.4511], device='cuda:5'),
TensorImage([0.2873, 0.2893, 0.3110], device='cuda:5'))
```
正如我们预期的那样,平均值和标准差与期望值不太接近。幸运的是,在 fastai 中对数据进行归一化很容易,只需添加`Normalize`转换。这会一次作用于整个小批量数据,因此您可以将其添加到数据块的`batch_tfms`部分。您需要传递给此转换您想要使用的平均值和标准差fastai 已经定义了标准的 ImageNet 平均值和标准差。(如果您没有向`Normalize`转换传递任何统计数据fastai 将自动从您的数据的一个批次中计算出它们。)
让我们添加这个转换(使用`imagenet_stats`,因为 Imagenette 是 ImageNet 的一个子集),现在看一下一个批次:
```py
def get_dls(bs, size):
dblock = DataBlock(blocks=(ImageBlock, CategoryBlock),
get_items=get_image_files,
get_y=parent_label,
item_tfms=Resize(460),
batch_tfms=[*aug_transforms(size=size, min_scale=0.75),
Normalize.from_stats(*imagenet_stats)])
return dblock.dataloaders(path, bs=bs)
```
```py
dls = get_dls(64, 224)
```
```py
x,y = dls.one_batch()
x.mean(dim=[0,2,3]),x.std(dim=[0,2,3])
```
```py
(TensorImage([-0.0787, 0.0525, 0.2136], device='cuda:5'),
TensorImage([1.2330, 1.2112, 1.3031], device='cuda:5'))
```
让我们来看看这对训练我们的模型有什么影响:
```py
model = xresnet50()
learn = Learner(dls, model, loss_func=CrossEntropyLossFlat(), metrics=accuracy)
learn.fit_one_cycle(5, 3e-3)
```
| epoch | train_loss | valid_loss | accuracy | time |
| --- | --- | --- | --- | --- |
| 0 | 1.632865 | 2.250024 | 0.391337 | 01:02 |
| 1 | 1.294041 | 1.579932 | 0.517177 | 01:02 |
| 2 | 0.960535 | 1.069164 | 0.657207 | 01:04 |
| 3 | 0.730220 | 0.767433 | 0.771845 | 01:05 |
| 4 | 0.577889 | 0.550673 | 0.824496 | 01:06 |
尽管在这里只有一点帮助,但在使用预训练模型时,归一化变得尤为重要。预训练模型只知道如何处理之前见过的数据类型。如果训练数据的平均像素值为 0但您的数据的像素最小可能值为 0那么模型将看到与预期完全不同的东西
这意味着当您分发模型时,您需要同时分发用于归一化的统计数据,因为任何使用它进行推断或迁移学习的人都需要使用相同的统计数据。同样,如果您使用别人训练过的模型,请确保您了解他们使用的归一化统计数据,并进行匹配。
在之前的章节中,我们不必处理归一化,因为通过`cnn_learner`使用预训练模型时fastai 库会自动添加适当的`Normalize`转换;模型已经使用`Normalize`中的某些统计数据进行了预训练(通常来自 ImageNet 数据集),因此库可以为您填充这些数据。请注意,这仅适用于预训练模型,这就是为什么在从头开始训练时需要在这里手动添加这些信息的原因。
到目前为止,我们所有的训练都是在尺寸为 224 的情况下进行的。我们本可以在那之前从较小的尺寸开始训练。这被称为*渐进调整*。
# 渐进调整
当 fast.ai 及其学生团队在 2018 年赢得 DAWNBench 比赛时,其中最重要的创新之一是非常简单的事情:使用小图像开始训练,然后使用大图像结束训练。在大部分时期使用小图像进行训练有助于训练完成得更快。使用大图像完成训练使最终准确率更高。我们称这种方法为*渐进式调整大小*。
# 术语:渐进式调整大小
在训练过程中逐渐使用越来越大的图像。
正如我们所看到的,卷积神经网络学习的特征类型与图像的大小无关——早期层发现边缘和梯度等内容,而后期层可能发现鼻子和日落等内容。因此,当我们在训练中途更改图像大小时,并不意味着我们必须为我们的模型找到完全不同的参数。
但显然小图像和大图像之间存在一些差异,因此我们不应该期望我们的模型继续完全不变地工作得很好。这让你想起了什么吗?当我们开发这个想法时,它让我们想起了迁移学习!我们试图让我们的模型学会做一些与以前学会的有点不同的事情。因此,在调整图像大小后,我们应该能够使用`fine_tune`方法。
渐进式调整大小还有一个额外的好处:它是另一种数据增强形式。因此,您应该期望看到使用渐进式调整大小训练的模型具有更好的泛化能力。
要实现渐进式调整大小,最方便的方法是首先创建一个`get_dls`函数,该函数接受图像大小和批量大小,就像我们在前一节中所做的那样,并返回您的`DataLoaders`。
现在,您可以使用小尺寸创建您的`DataLoaders`,并以通常的方式使用`fit_one_cycle`,训练的时期比您可能以其他方式做的要少:
```py
dls = get_dls(128, 128)
learn = Learner(dls, xresnet50(), loss_func=CrossEntropyLossFlat(),
metrics=accuracy)
learn.fit_one_cycle(4, 3e-3)
```
| epoch | train_loss | valid_loss | accuracy | time |
| --- | --- | --- | --- | --- |
| 0 | 1.902943 | 2.447006 | 0.401419 | 00:30 |
| 1 | 1.315203 | 1.572992 | 0.525765 | 00:30 |
| 2 | 1.001199 | 0.767886 | 0.759149 | 00:30 |
| 3 | 0.765864 | 0.665562 | 0.797984 | 00:30 |
然后,您可以在`Learner`内部替换`DataLoaders`,并进行微调:
```py
learn.dls = get_dls(64, 224)
learn.fine_tune(5, 1e-3)
```
| epoch | train_loss | valid_loss | accuracy | time |
| --- | --- | --- | --- | --- |
| 0 | 0.985213 | 1.654063 | 0.565721 | 01:06 |
| epoch | train_loss | valid_loss | accuracy | time |
| --- | --- | --- | --- | --- |
| 0 | 0.706869 | 0.689622 | 0.784541 | 01:07 |
| 1 | 0.739217 | 0.928541 | 0.712472 | 01:07 |
| 2 | 0.629462 | 0.788906 | 0.764003 | 01:07 |
| 3 | 0.491912 | 0.502622 | 0.836445 | 01:06 |
| 4 | 0.414880 | 0.431332 | 0.863331 | 01:06 |
正如您所看到的,我们的性能要好得多,而在每个时期的小图像上的初始训练速度要快得多。
您可以根据需要重复增加大小并训练更多时期的过程,为您希望的图像大小——但当然,如果使用大于磁盘上图像大小的图像大小,您将不会获得任何好处。
请注意,对于迁移学习,渐进式调整大小实际上可能会损害性能。如果您的预训练模型与您的迁移学习任务和数据集非常相似,并且是在类似大小的图像上训练的,那么权重不需要进行太多更改。在这种情况下,使用较小的图像进行训练可能会损坏预训练权重。
另一方面,如果迁移学习任务将使用与预训练任务中使用的图像大小、形状或风格不同的图像,渐进式调整大小可能会有所帮助。像往常一样,“它会有帮助吗?”的答案是“试试看!”
我们还可以尝试将数据增强应用于验证集。到目前为止,我们只在训练集上应用了数据增强;验证集始终获得相同的图像。但也许我们可以尝试为验证集的几个增强版本进行预测并取平均值。我们将在下一步考虑这种方法。
# 测试时间增强
我们一直在使用随机裁剪作为一种获取一些有用数据增强的方法这导致更好的泛化并且需要更少的训练数据。当我们使用随机裁剪时fastai 将自动为验证集使用中心裁剪——也就是说,它将选择图像中心的最大正方形区域,而不会超出图像的边缘。
这通常会带来问题。例如,在多标签数据集中,有时图像边缘会有小物体;这些物体可能会被中心裁剪完全裁剪掉。即使对于像我们的宠物品种分类示例这样的问题,也有可能关键特征,例如鼻子的颜色,可能会被裁剪掉。
解决这个问题的一个方法是完全避免随机裁剪。相反,我们可以简单地压缩或拉伸矩形图像以适应正方形空间。但是这样我们会错过一个非常有用的数据增强,并且还会使图像识别对我们的模型更加困难,因为它必须学会识别被压缩和拉伸的图像,而不仅仅是正确比例的图像。
另一个解决方案是在验证时不进行中心裁剪,而是从原始矩形图像中选择若干区域进行裁剪,将每个区域通过我们的模型,然后取预测的最大值或平均值。事实上,我们不仅可以对不同裁剪进行此操作,还可以对所有测试时间增强参数的不同值进行操作。这被称为*测试时间增强*TTA
# 术语测试时间增强TTA
在推断或验证期间,使用数据增强创建每个图像的多个版本,然后取每个增强版本的预测的平均值或最大值。
根据数据集的不同测试时间增强可以显著提高准确性。它不会改变训练所需的时间但会增加验证或推断所需的时间数量取决于请求的测试时间增强图像数量。默认情况下fastai 将使用未增强的中心裁剪图像加上四个随机增强的图像。
您可以将任何`DataLoader`传递给 fastai 的`tta`方法;默认情况下,它将使用您的验证集:
```py
preds,targs = learn.tta()
accuracy(preds, targs).item()
```
```py
0.8737863898277283
```
正如我们所看到的,使用 TTA 可以显著提高性能,而无需额外的训练。但是,它会使推断变慢——如果你对 TTA 平均了五张图像,推断将变慢五倍。
我们已经看到了一些数据增强如何帮助训练更好的模型。现在让我们专注于一种名为*混合*的新数据增强技术。
# 混合
混合Mixup是在 2017 年张宏毅等人的论文《*混合:超越经验风险最小化*》中引入的一种强大的数据增强技术,可以提供极高的准确性,特别是当你没有太多数据,也没有经过预训练的模型,该模型是在与你的数据集相似的数据上训练的。该论文解释道:“虽然数据增强始终会导致改进的泛化,但该过程取决于数据集,并因此需要专业知识的使用。”例如,将图像翻转作为数据增强的一部分是很常见的,但是你应该只水平翻转还是同时垂直翻转呢?答案是取决于你的数据集。此外,如果(例如)翻转对你来说提供的数据增强不够,你不能“多翻转”。有助于拥有数据增强技术,可以“调高”或“调低”变化的程度,以找到最适合你的方法。
对于每个图像Mixup 的工作方式如下:
1. 随机从数据集中选择另一个图像。
1. 随机选择一个权重。
1. 使用步骤 2 中的权重对所选图像和您的图像进行加权平均;这将是您的自变量。
1. 将这个图像的标签与您的图像的标签进行加权平均(使用相同的权重);这将是您的因变量。
在伪代码中,我们这样做(其中`t`是我们加权平均值的权重):
```py
image2,target2 = dataset[randint(0,len(dataset)]
t = random_float(0.5,1.0)
new_image = t * image1 + (1-t) * image2
new_target = t * target1 + (1-t) * target2
```
为了使其正常工作,我们的目标需要进行独热编码。该论文使用图 7-1 中的方程式描述了这一点(其中<math alttext="lamda"><mi>λ</mi></math>与我们伪代码中的`t`相同)。
![Mixup 论文摘录](img/dlcf_0701.png)
###### 图 7-1。Mixup 论文摘录
图 7-2 展示了在 Mixup 中进行图像*线性组合*的样子。
![一座教堂、一个加油站和两者混合的图像。](img/dlcf_0702.png)
###### 图 7-2。混合教堂和加油站
第三个图像是通过将第一个图像的 0.3 倍和第二个图像的 0.7 倍相加而构建的。在这个例子中,模型应该预测“教堂”还是“加油站”?正确答案是 30%的教堂和 70%的加油站,因为如果我们采用独热编码目标的线性组合,那就是我们将得到的结果。例如,假设我们有 10 个类别,“教堂”由索引 2 表示,“加油站”由索引 7 表示。独热编码表示如下:
```py
[0, 0, 1, 0, 0, 0, 0, 0, 0, 0] and [0, 0, 0, 0, 0, 0, 0, 1, 0, 0]
```
这是我们的最终目标:
```py
[0, 0, 0.3, 0, 0, 0, 0, 0.7, 0, 0]
```
fastai 通过向我们的`Learner`添加一个*callback*来完成所有这些操作。`Callback`是 fastai 中用于在训练循环中注入自定义行为的内容(如学习率调度或混合精度训练)。您将在第十六章中学习有关回调的所有内容,包括如何制作自己的回调。目前,您只需要知道使用`cbs`参数将回调传递给`Learner`。
这是我们如何使用 Mixup 训练模型的方式:
```py
model = xresnet50()
learn = Learner(dls, model, loss_func=CrossEntropyLossFlat(),
metrics=accuracy, cbs=Mixup)
learn.fit_one_cycle(5, 3e-3)
```
当我们用这种方式“混合”的数据训练模型时会发生什么?显然,训练会更加困难,因为很难看清每个图像中的内容。模型必须为每个图像预测两个标签,而不仅仅是一个,并且还必须弄清楚每个标签的权重。然而,过拟合似乎不太可能成为问题,因为我们不会在每个时代中显示相同的图像,而是显示两个图像的随机组合。
与我们看到的其他增强方法相比Mixup 需要更多的时代来训练以获得更好的准确性。您可以尝试使用[fastai repo](https://oreil.ly/lrGXE)中的*examples/train_imagenette.py*脚本来训练 Imagenette使用 Mixup 和不使用 Mixup。在撰写本文时[Imagenette repo](https://oreil.ly/3Gt56)中的排行榜显示Mixup 用于训练超过 80 个时代的所有领先结果,而对于更少的时代,不使用 Mixup。这与我们使用 Mixup 的经验一致。
Mixup 如此令人兴奋的原因之一是它可以应用于除照片之外的数据类型。事实上,有些人甚至已经展示了通过在模型内部的激活上使用 Mixup 而获得良好结果,而不仅仅是在输入上使用 Mixup——这使得 Mixup 也可以用于 NLP 和其他数据类型。
Mixup 为我们处理的另一个微妙问题是,我们之前看到的模型实际上永远无法完美。问题在于我们的标签是 1 和 0但 softmax 和 sigmoid 的输出永远无法等于 1 或 0。这意味着训练我们的模型会使我们的激活值越来越接近这些值这样我们做的时代越多我们的激活值就会变得越极端。
使用 Mixup我们不再有这个问题因为我们的标签只有在我们碰巧与同一类别的另一幅图像“混合”时才会完全是 1 或 0。其余时间我们的标签将是一个线性组合比如我们在之前的教堂和加油站示例中得到的 0.7 和 0.3。
然而,这种方法的一个问题是 Mixup“意外地”使标签大于 0 或小于 1。也就是说我们并没有*明确*告诉我们的模型我们想以这种方式改变标签。因此,如果我们想要使标签更接近或远离 0 和 1我们必须改变 Mixup 的数量,这也会改变数据增强的数量,这可能不是我们想要的。然而,有一种更直接处理的方法,那就是使用*标签平滑*。
# 标签平滑
在损失的理论表达中,在分类问题中,我们的目标是独热编码的(在实践中,我们倾向于避免这样做以节省内存,但我们计算的损失与使用独热编码时相同)。这意味着模型被训练为对所有类别返回 0只有一个类别返回 1。即使是 0.999 也不是“足够好”;模型将获得梯度并学会以更高的信心预测激活。这会鼓励过拟合,并在推理时给出一个不会给出有意义概率的模型:即使不太确定,它总是会为预测的类别说 1只是因为它是这样训练的。
如果您的数据不完全标记,这可能会变得非常有害。在我们在第二章中研究的熊分类器中,我们看到一些图像被错误标记,或包含两种不同种类的熊。一般来说,您的数据永远不会是完美的。即使标签是人工制作的,也可能出现错误,或者在难以标记的图像上存在不同意见。
相反,我们可以用一个比 1 稍微小一点的数字替换所有的 1用一个比 0 稍微大一点的数字替换所有的 0然后进行训练。这就是*标签平滑*。通过鼓励模型变得不那么自信,标签平滑将使您的训练更加健壮,即使存在错误标记的数据。结果将是一个在推理时更好泛化的模型。
这就是标签平滑在实践中的工作方式:我们从独热编码的标签开始,然后用<math alttext="StartFraction epsilon Over upper N EndFraction"><mfrac><mi>ϵ</mi> <mi>N</mi></mfrac></math>(这是希腊字母*epsilon*,在介绍标签平滑的[论文](https://oreil.ly/L3ypf)和 fastai 代码中使用)替换所有的 0其中<math alttext="upper N"><mi>N</mi></math>是类别数,<math alttext="epsilon"><mi>ϵ</mi></math>是一个参数(通常为 0.1,这意味着我们对标签有 10%的不确定性)。由于我们希望标签总和为 1我们还用<math alttext="1 minus epsilon plus StartFraction epsilon Over upper N EndFraction"><mrow><mn>1</mn> <mo>-</mo> <mi>ϵ</mi> <mo>+</mo> <mfrac><mi>ϵ</mi> <mi>N</mi></mfrac></mrow></math>替换 1。这样我们不会鼓励模型过于自信地预测。在我们的 Imagenette 示例中有 10 个类别,目标变成了这样(这里是对应于索引 3 的目标):
```py
[0.01, 0.01, 0.01, 0.91, 0.01, 0.01, 0.01, 0.01, 0.01, 0.01]
```
在实践中,我们不想对标签进行独热编码,幸运的是我们也不需要(独热编码只是用来解释标签平滑和可视化的)。
在实践中使用这个方法,我们只需要在调用`Learner`时改变损失函数:
```py
model = xresnet50()
learn = Learner(dls, model, loss_func=LabelSmoothingCrossEntropy(),
metrics=accuracy)
learn.fit_one_cycle(5, 3e-3)
```
与 Mixup 一样,您通常在训练更多时期后才会看到标签平滑带来的显著改进。自己尝试一下:在标签平滑显示改进之前,您需要训练多少个时期?
# 结论
您现在已经看到了训练计算机视觉中最先进模型所需的一切,无论是从头开始还是使用迁移学习。现在您只需要在自己的问题上进行实验!看看使用 Mixup 和/或标签平滑进行更长时间的训练是否可以避免过拟合并给出更好的结果。尝试渐进式调整大小和测试时间增强。
最重要的是,记住,如果您的数据集很大,那么在整个数据集上进行原型设计是没有意义的。找到一个代表整体的小子集,就像我们在 Imagenette 上所做的那样,并在其上进行实验。
在接下来的三章中,我们将看到 fastai 直接支持的其他应用程序:协同过滤、表格建模和处理文本。在本书的下一部分中,我们将回到计算机视觉,深入研究卷积神经网络在第十三章中。
# 问卷
1. ImageNet 和 Imagenette 之间有什么区别?在什么情况下最好在其中一个上进行实验而不是另一个?
1. 什么是归一化?
1. 为什么在使用预训练模型时我们不需要关心归一化?
1. 什么是渐进式调整大小?
1. 在自己的项目中实现渐进式调整大小。有帮助吗?
1. 什么是测试时间增强?如何在 fastai 中使用它?
1. 在推理中使用 TTA 比常规推理更慢还是更快?为什么?
1. 什么是 Mixup如何在 fastai 中使用它?
1. 为什么 Mixup 可以防止模型过于自信?
1. 为什么使用 Mixup 进行五个时期的训练最终比不使用 Mixup 训练更糟糕?
1. 标签平滑背后的理念是什么?
1. 您的数据中有哪些问题可以通过标签平滑来解决?
1. 在使用五个类别的标签平滑时,与索引 1 相关联的目标是什么?
1. 当您想在新数据集上快速进行原型实验时,应该采取的第一步是什么?
## 进一步研究
1. 使用 fastai 文档构建一个函数,将图像裁剪为每个角落的正方形;然后实现一种 TTA 方法,该方法对中心裁剪和这四个裁剪的预测进行平均。有帮助吗?比 fastai 的 TTA 方法更好吗?
1. 在 arXiv 上找到 Mixup 论文并阅读。选择一两篇介绍 Mixup 变体的较新文章并阅读它们;然后尝试在您的问题上实现它们。
1. 找到使用 Mixup 训练 Imagenette 的脚本,并将其用作在自己项目上进行长时间训练的示例。执行它并查看是否有帮助。
1. 阅读侧边栏["标签平滑,论文"](label_smoothing然后查看原始论文的相关部分看看您是否能够理解。不要害怕寻求帮助

View File

@ -0,0 +1,800 @@
# 第八章:协同过滤深入探讨
解决的一个常见问题是有一定数量的用户和产品,您想推荐哪些产品最有可能对哪些用户有用。存在许多变体:例如,推荐电影(如 Netflix 上),确定在主页上为用户突出显示什么,决定在社交媒体动态中显示什么故事等。解决这个问题的一般方法称为*协同过滤*,工作原理如下:查看当前用户使用或喜欢的产品,找到其他使用或喜欢类似产品的用户,然后推荐那些用户使用或喜欢的其他产品。
例如,在 Netflix 上,您可能观看了很多科幻、充满动作并且是上世纪 70 年代制作的电影。Netflix 可能不知道您观看的这些电影的特定属性,但它将能够看到观看了与您观看相同电影的其他人也倾向于观看其他科幻、充满动作并且是上世纪 70 年代制作的电影。换句话说,要使用这种方法,我们不一定需要了解电影的任何信息,只需要知道谁喜欢观看它们。
这种方法可以解决更一般的一类问题,不一定涉及用户和产品。实际上,在协同过滤中,我们更常用*项目*这个术语,而不是*产品*。项目可以是人们点击的链接、为患者选择的诊断等。
关键的基础概念是*潜在因素*。在 Netflix 的例子中,我们假设您喜欢老式、充满动作的科幻电影。但您从未告诉 Netflix 您喜欢这类电影。Netflix 也不需要在其电影表中添加列,说明哪些电影属于这些类型。尽管如此,必须存在一些关于科幻、动作和电影年龄的潜在概念,这些概念对于至少一些人的电影观看决策是相关的。
在本章中,我们将解决这个电影推荐问题。我们将从获取适合协同过滤模型的一些数据开始。
# 数据初探
我们无法访问 Netflix 的完整电影观看历史数据集,但有一个很好的数据集可供我们使用,称为[MovieLens](https://oreil.ly/gP3Q5)。该数据集包含数千万部电影排名(电影 ID、用户 ID 和数字评分的组合),尽管我们只会使用其中的 10 万部作为示例。如果您感兴趣,可以尝试在完整的 2500 万推荐数据集上复制这种方法,您可以从他们的网站上获取。
该数据集可通过通常的 fastai 函数获得:
```py
from fastai.collab import *
from fastai.tabular.all import *
path = untar_data(URLs.ML_100k)
```
根据*README*,主表位于文件*u.data*中。它是以制表符分隔的,列分别是用户、电影、评分和时间戳。由于这些名称没有编码,我们需要在使用 Pandas 读取文件时指定它们。以下是打开此表并查看的方法:
```py
ratings = pd.read_csv(path/'u.data', delimiter='\t', header=None,
names=['user','movie','rating','timestamp'])
ratings.head()
```
| | 用户 | 电影 | 评分 | 时间戳 |
| --- | --- | --- | --- | --- |
| 0 | 196 | 242 | 3 | 881250949 |
| 1 | 186 | 302 | 3 | 891717742 |
| 2 | 22 | 377 | 1 | 878887116 |
| 3 | 244 | 51 | 2 | 880606923 |
| 4 | 166 | 346 | 1 | 886397596 |
尽管这包含了我们需要的所有信息,但这并不是人类查看这些数据的特别有用的方式。图 8-1 将相同数据交叉制表成了一个人类友好的表格。
![电影和用户的交叉表](img/dlcf_0801.png)
###### 图 8-1. 电影和用户的交叉表
我们只选择了一些最受欢迎的电影和观看电影最多的用户,作为这个交叉表示例。这个表格中的空单元格是我们希望我们的模型学会填充的内容。这些是用户尚未评论电影的地方,可能是因为他们还没有观看。对于每个用户,我们希望找出他们最有可能喜欢哪些电影。
如果我们知道每个用户对电影可能属于的每个重要类别的喜好程度,比如流派、年龄、喜欢的导演和演员等,以及我们对每部电影的相同信息,那么填写这个表格的一个简单方法是将这些信息相乘,然后使用组合。例如,假设这些因子的范围在-1 到+1 之间,正数表示更强的匹配,负数表示更弱的匹配,类别是科幻、动作和老电影,那么我们可以表示电影《最后的绝地武士》如下:
```py
last_skywalker = np.array([0.98,0.9,-0.9])
```
在这里,例如,我们将*非常科幻*评分为 0.98*非常不老*评分为-0.9。我们可以表示喜欢现代科幻动作电影的用户如下:
```py
user1 = np.array([0.9,0.8,-0.6])
```
现在我们可以计算这种组合之间的匹配:
```py
(user1*last_skywalker).sum()
```
```py
2.1420000000000003
```
当我们将两个向量相乘并将结果相加时,这被称为*点积*。它在机器学习中被广泛使用,并构成了矩阵乘法的基础。我们将在第十七章中更多地研究矩阵乘法和点积。
# 术语:点积
将两个向量的元素相乘,然后将结果相加的数学运算。
另一方面,我们可以表示电影《卡萨布兰卡》如下:
```py
casablanca = np.array([-0.99,-0.3,0.8])
```
这种组合之间的匹配如下所示:
```py
(user1*casablanca).sum()
```
```py
-1.611
```
由于我们不知道潜在因子是什么,也不知道如何为每个用户和电影评分,我们应该学习它们。
# 学习潜在因子
在指定模型的结构和学习模型之间,实际上几乎没有什么区别,因为我们可以使用我们的一般梯度下降方法。
这种方法的第一步是随机初始化一些参数。这些参数将是每个用户和电影的一组潜在因子。我们将不得不决定要使用多少个。我们将很快讨论如何选择这些,但为了说明,让我们现在使用 5 个。因为每个用户将有一组这些因子,每部电影也将有一组这些因子,我们可以在交叉表中的用户和电影旁边显示这些随机初始化的值,然后我们可以填写这些组合的点积。例如,图 8-2 显示了在 Microsoft Excel 中的样子,顶部左侧的单元格公式显示为示例。
这种方法的第二步是计算我们的预测。正如我们讨论过的,我们可以通过简单地将每部电影与每个用户进行点积来实现这一点。例如,如果第一个潜在用户因子代表用户喜欢动作电影的程度,第一个潜在电影因子代表电影是否有很多动作,那么如果用户喜欢动作电影并且电影中有很多动作,或者用户不喜欢动作电影并且电影中没有任何动作,这两者的乘积将特别高。另一方面,如果存在不匹配(用户喜欢动作电影但电影不是动作片,或者用户不喜欢动作电影但电影是动作片),乘积将非常低。
![交叉表中的潜在因子](img/dlcf_0802.png)
###### 图 8-2. 交叉表中的潜在因子
第三步是计算我们的损失。我们可以使用任何损失函数,让我们现在选择均方误差,因为这是一种合理的表示预测准确性的方法。
这就是我们需要的全部内容。有了这个,我们可以使用随机梯度下降来优化我们的参数(潜在因素),以最小化损失。在每一步中,随机梯度下降优化器将使用点积计算每部电影与每个用户之间的匹配,并将其与每个用户给出的每部电影的实际评分进行比较。然后它将计算这个值的导数,并通过学习率乘以这个值来调整权重。经过多次这样的操作,损失会变得越来越好,推荐也会变得越来越好。
要使用通常的`Learner.fit`函数,我们需要将我们的数据放入`DataLoaders`中,所以让我们现在专注于这一点。
# 创建 DataLoaders
在展示数据时,我们宁愿看到电影标题而不是它们的 ID。表`u.item`包含 ID 与标题的对应关系:
```py
movies = pd.read_csv(path/'u.item', delimiter='|', encoding='latin-1',
usecols=(0,1), names=('movie','title'), header=None)
movies.head()
```
| | 电影 | 标题 |
| --- | --- | --- |
| 0 | 1 | 玩具总动员1995 |
| 1 | 2 | 黄金眼1995 |
| 2 | 3 | 四个房间1995 |
| 3 | 4 | 短小1995 |
| 4 | 5 | 复制猫1995 |
我们可以将这个表与我们的`ratings`表合并,以获得按标题分类的用户评分:
```py
ratings = ratings.merge(movies)
ratings.head()
```
| | 用户 | 电影 | 评分 | 时间戳 | 标题 |
| --- | --- | --- | --- | --- | --- |
| 0 | 196 | 242 | 3 | 881250949 | 科洛亚1996 |
| 1 | 63 | 242 | 3 | 875747190 | 科洛亚1996 |
| 2 | 226 | 242 | 5 | 883888671 | 科洛亚1996 |
| 3 | 154 | 242 | 3 | 879138235 | 科洛亚1996 |
| 4 | 306 | 242 | 5 | 876503793 | 科洛亚1996 |
然后我们可以从这个表构建一个`DataLoaders`对象。默认情况下,它将使用第一列作为用户,第二列作为项目(这里是我们的电影),第三列作为评分。在我们的情况下,我们需要更改`item_name`的值,以使用标题而不是 ID
```py
dls = CollabDataLoaders.from_df(ratings, item_name='title', bs=64)
dls.show_batch()
```
| | 用户 | 标题 | 评分 |
| --- | --- | --- | --- |
| 0 | 207 | 四个婚礼和一个葬礼1994 | 3 |
| 1 | 565 | 日残余1993 | 5 |
| 2 | 506 | 小孩1995 | 1 |
| 3 | 845 | 追求艾米1997 | 3 |
| 4 | 798 | 人类1993 | 2 |
| 5 | 500 | 低俗法则1986 | 4 |
| 6 | 409 | 无事生非1993 | 3 |
| 7 | 721 | 勇敢的心1995 | 5 |
| 8 | 316 | 精神病患者1960 | 2 |
| 9 | 883 | 判决之夜1993 | 5 |
为了在 PyTorch 中表示协同过滤,我们不能直接使用交叉表表示,特别是如果我们希望它适应我们的深度学习框架。我们可以将我们的电影和用户潜在因素表表示为简单的矩阵:
```py
n_users = len(dls.classes['user'])
n_movies = len(dls.classes['title'])
n_factors = 5
user_factors = torch.randn(n_users, n_factors)
movie_factors = torch.randn(n_movies, n_factors)
```
要计算特定电影和用户组合的结果,我们必须查找电影在我们的电影潜在因素矩阵中的索引,以及用户在我们的用户潜在因素矩阵中的索引;然后我们可以在两个潜在因素向量之间进行点积。但*查找索引*不是我们的深度学习模型知道如何执行的操作。它们知道如何执行矩阵乘积和激活函数。
幸运的是,我们可以将*查找索引*表示为矩阵乘积。技巧是用单热编码向量替换我们的索引。这是一个例子,展示了如果我们将一个向量乘以一个表示索引 3 的单热编码向量会发生什么:
```py
one_hot_3 = one_hot(3, n_users).float()
user_factors.t() @ one_hot_3
```
```py
tensor([-0.4586, -0.9915, -0.4052, -0.3621, -0.5908])
```
它给我们的结果与矩阵中索引 3 处的向量相同:
```py
user_factors[3]
```
```py
tensor([-0.4586, -0.9915, -0.4052, -0.3621, -0.5908])
```
如果我们一次为几个索引这样做,我们将得到一个独热编码向量的矩阵,这个操作将是一个矩阵乘法!这将是使用这种架构构建模型的一种完全可接受的方式,只是它会比必要的使用更多的内存和时间。我们知道没有真正的基础原因来存储独热编码向量,或者通过搜索找到数字 1 的出现 - 我们应该能够直接使用整数索引到数组中。因此,大多数深度学习库,包括 PyTorch都包括一个特殊的层它就是这样做的它使用整数索引到一个向量中但其导数的计算方式使其与使用独热编码向量进行矩阵乘法时完全相同。这被称为*嵌入*。
# 术语:嵌入
通过一个独热编码矩阵相乘,使用计算快捷方式,可以通过直接索引来实现。这是一个非常简单概念的相当花哨的词。您将独热编码矩阵相乘的东西(或者使用计算快捷方式,直接索引)称为*嵌入矩阵*。
在计算机视觉中,我们有一种非常简单的方法通过其 RGB 值获取像素的所有信息:彩色图像中的每个像素由三个数字表示。这三个数字给我们红色、绿色和蓝色,这足以让我们的模型在之后工作。
对于手头的问题,我们没有同样简单的方法来描述用户或电影。可能与流派有关:如果给定用户喜欢爱情片,他们可能会给爱情片更高的评分。其他因素可能是电影是更注重动作还是对话,或者是否有一个特定的演员,用户可能特别喜欢。
我们如何确定用来描述这些数字的数字?答案是,我们不确定。我们将让我们的模型*学习*它们。通过分析用户和电影之间的现有关系,我们的模型可以自己找出看起来重要或不重要的特征。
这就是嵌入。我们将为我们的每个用户和每个电影分配一个特定长度的随机向量(这里,`n_factors=5`),并将使它们成为可学习的参数。这意味着在每一步,当我们通过比较我们的预测和目标来计算损失时,我们将计算损失相对于这些嵌入向量的梯度,并根据 SGD或其他优化器的规则更新它们。
一开始,这些数字没有任何意义,因为我们是随机选择的,但在训练结束时,它们将有意义。通过学习关于用户和电影之间关系的现有数据,没有任何其他信息,我们将看到它们仍然获得一些重要特征,并且可以将大片与独立电影、动作片与爱情片等区分开来。
我们现在有能力从头开始创建我们的整个模型。
# 从头开始协同过滤
在我们可以用 PyTorch 编写模型之前,我们首先需要学习面向对象编程和 Python 的基础知识。如果您以前没有进行过面向对象编程,我们将在这里为您进行快速介绍,但我们建议您在继续之前查阅教程并进行一些练习。
面向对象编程中的关键思想是*类*。我们在本书中一直在使用类,比如`DataLoader`、`String`和`Learner`。Python 还让我们很容易地创建新类。这是一个简单类的示例:
```py
class Example:
def __init__(self, a): self.a = a
def say(self,x): return f'Hello {self.a}, {x}.'
```
这其中最重要的部分是一个特殊的方法叫做`__init__`(发音为*dunder init*)。在 Python 中,任何像这样用双下划线包围的方法都被认为是特殊的。它表示与这个方法名称相关联一些额外的行为。对于`__init__`,这是 Python 在创建新对象时将调用的方法。因此,这是你可以在对象创建时设置任何需要初始化的状态的地方。当用户构造类的实例时包含的任何参数都将作为参数传递给`__init__`方法。请注意,在类内定义的任何方法的第一个参数是`self`,因此你可以使用它来设置和获取任何你需要的属性:
```py
ex = Example('Sylvain')
ex.say('nice to meet you')
```
```py
'Hello Sylvain, nice to meet you.'
```
还要注意,创建一个新的 PyTorch 模块需要继承自`Module`。*继承*是一个重要的面向对象的概念在这里我们不会详细讨论——简而言之它意味着我们可以向现有类添加额外的行为。PyTorch 已经提供了一个`Module`类,它提供了一些我们想要构建的基本基础。因此,我们在定义类的名称后面添加这个*超类*的名称,如下面的示例所示。
你需要知道创建一个新的 PyTorch 模块的最后一件事是当调用你的模块时PyTorch 将调用你的类中的一个名为`forward`的方法,并将包含在调用中的任何参数传递给它。这是定义我们的点积模型的类:
```py
class DotProduct(Module):
def __init__(self, n_users, n_movies, n_factors):
self.user_factors = Embedding(n_users, n_factors)
self.movie_factors = Embedding(n_movies, n_factors)
def forward(self, x):
users = self.user_factors(x[:,0])
movies = self.movie_factors(x[:,1])
return (users * movies).sum(dim=1)
```
如果你以前没有见过面向对象的编程,不用担心;在这本书中你不需要经常使用它。我们在这里提到这种方法只是因为大多数在线教程和文档将使用面向对象的语法。
请注意,模型的输入是一个形状为`batch_size x 2`的张量,其中第一列(`x[:, 0]`)包含用户 ID第二列`x[:, 1]`)包含电影 ID。如前所述我们使用*嵌入*层来表示我们的用户和电影潜在因子的矩阵:
```py
x,y = dls.one_batch()
x.shape
```
```py
torch.Size([64, 2])
```
现在我们已经定义了我们的架构并创建了参数矩阵,我们需要创建一个`Learner`来优化我们的模型。在过去,我们使用了特殊函数,比如`cnn_learner`,为特定应用程序为我们设置了一切。由于我们在这里从头开始做事情,我们将使用普通的`Learner`类:
```py
model = DotProduct(n_users, n_movies, 50)
learn = Learner(dls, model, loss_func=MSELossFlat())
```
现在我们准备拟合我们的模型:
```py
learn.fit_one_cycle(5, 5e-3)
```
| epoch | train_loss | valid_loss | time |
| --- | --- | --- | --- |
| 0 | 1.326261 | 1.295701 | 00:12 |
| 1 | 1.091352 | 1.091475 | 00:11 |
| 2 | 0.961574 | 0.977690 | 00:11 |
| 3 | 0.829995 | 0.893122 | 00:11 |
| 4 | 0.781661 | 0.876511 | 00:12 |
我们可以做的第一件事是让这个模型更好一点,强制这些预测值在 0 到 5 之间。为此,我们只需要使用`sigmoid_range`,就像第六章中那样。我们经验性地发现,最好让范围略微超过 5所以我们使用`(0, 5.5)`
```py
class DotProduct(Module):
def __init__(self, n_users, n_movies, n_factors, y_range=(0,5.5)):
self.user_factors = Embedding(n_users, n_factors)
self.movie_factors = Embedding(n_movies, n_factors)
self.y_range = y_range
def forward(self, x):
users = self.user_factors(x[:,0])
movies = self.movie_factors(x[:,1])
return sigmoid_range((users * movies).sum(dim=1), *self.y_range)
```
```py
model = DotProduct(n_users, n_movies, 50)
learn = Learner(dls, model, loss_func=MSELossFlat())
learn.fit_one_cycle(5, 5e-3)
```
| epoch | train_loss | valid_loss | time |
| --- | --- | --- | --- |
| 0 | 0.976380 | 1.001455 | 00:12 |
| 1 | 0.875964 | 0.919960 | 00:12 |
| 2 | 0.685377 | 0.870664 | 00:12 |
| 3 | 0.483701 | 0.874071 | 00:12 |
| 4 | 0.385249 | 0.878055 | 00:12 |
这是一个合理的开始,但我们可以做得更好。一个明显缺失的部分是,有些用户在推荐中只是更积极或更消极,有些电影只是比其他电影更好或更差。但在我们的点积表示中,我们没有任何方法来编码这两件事。如果你只能说一部电影,例如,它非常科幻,非常动作导向,非常不老旧,那么你实际上没有办法说大多数人是否喜欢它。
这是因为在这一点上我们只有权重;我们没有偏差。如果我们为每个用户有一个可以添加到我们的分数中的单个数字,对于每部电影也是如此,那么这将非常好地处理这个缺失的部分。因此,首先让我们调整我们的模型架构:
```py
class DotProductBias(Module):
def __init__(self, n_users, n_movies, n_factors, y_range=(0,5.5)):
self.user_factors = Embedding(n_users, n_factors)
self.user_bias = Embedding(n_users, 1)
self.movie_factors = Embedding(n_movies, n_factors)
self.movie_bias = Embedding(n_movies, 1)
self.y_range = y_range
def forward(self, x):
users = self.user_factors(x[:,0])
movies = self.movie_factors(x[:,1])
res = (users * movies).sum(dim=1, keepdim=True)
res += self.user_bias(x[:,0]) + self.movie_bias(x[:,1])
return sigmoid_range(res, *self.y_range)
```
让我们尝试训练这个模型,看看效果如何:
```py
model = DotProductBias(n_users, n_movies, 50)
learn = Learner(dls, model, loss_func=MSELossFlat())
learn.fit_one_cycle(5, 5e-3)
```
| epoch | train_loss | valid_loss | time |
| --- | --- | --- | --- |
| 0 | 0.929161 | 0.936303 | 00:13 |
| 1 | 0.820444 | 0.861306 | 00:13 |
| 2 | 0.621612 | 0.865306 | 00:14 |
| 3 | 0.404648 | 0.886448 | 00:13 |
| 4 | 0.292948 | 0.892580 | 00:13 |
但是,结果并不比之前更好(至少在训练结束时)。为什么呢?如果我们仔细观察这两次训练,我们会发现验证损失在中间停止改善并开始变差。正如我们所见,这是过拟合的明显迹象。在这种情况下,没有办法使用数据增强,所以我们将不得不使用另一种正则化技术。一个有帮助的方法是*权重衰减*。
## Weight Decay
权重衰减,或*L2 正则化*,包括将所有权重的平方和添加到损失函数中。为什么这样做?因为当我们计算梯度时,它会为梯度增加一个贡献,鼓励权重尽可能小。
为什么它可以防止过拟合?这个想法是,系数越大,损失函数中的峡谷就会越尖锐。如果我们以抛物线的基本例子`y = a * (x**2)`为例,`a`越大,抛物线就越*狭窄*
![不同 a 值的抛物线](img/dlcf_0803.png)
因此,让我们的模型学习高参数可能导致它用一个过于复杂、具有非常尖锐变化的函数拟合训练集中的所有数据点,这将导致过拟合。
限制我们的权重过大会阻碍模型的训练,但会产生一个更好泛化的状态。回顾一下理论,权重衰减(或`wd`)是一个控制我们在损失中添加的平方和的参数(假设`parameters`是所有参数的张量):
```py
loss_with_wd = loss + wd * (parameters**2).sum()
```
然而,在实践中,计算那个大和并将其添加到损失中将非常低效(也许在数值上不稳定)。如果你还记得一点高中数学,你可能会记得`p**2`关于`p`的导数是`2*p`,所以将那个大和添加到我们的损失中,实际上等同于这样做:
```py
parameters.grad += wd * 2 * parameters
```
实际上,由于`wd`是我们选择的一个参数,我们可以使它变为两倍大,所以在这个方程中我们甚至不需要`*2`。要在 fastai 中使用权重衰减,在调用`fit`或`fit_one_cycle`时传递`wd`即可(可以同时传递):
```py
model = DotProductBias(n_users, n_movies, 50)
learn = Learner(dls, model, loss_func=MSELossFlat())
learn.fit_one_cycle(5, 5e-3, wd=0.1)
```
| epoch | train_loss | valid_loss | time |
| --- | --- | --- | --- |
| 0 | 0.972090 | 0.962366 | 00:13 |
| 1 | 0.875591 | 0.885106 | 00:13 |
| 2 | 0.723798 | 0.839880 | 00:13 |
| 3 | 0.586002 | 0.823225 | 00:13 |
| 4 | 0.490980 | 0.823060 | 00:13 |
好多了!
## 创建我们自己的嵌入模块
到目前为止,我们使用`Embedding`而没有考虑它是如何工作的。让我们重新创建`DotProductBias`*不*使用这个类。我们需要为每个嵌入初始化一个随机权重矩阵。然而,我们必须小心。回想一下第四章中提到的,优化器要求能够从模块的`parameters`方法中获取模块的所有参数。然而,这并不是完全自动发生的。如果我们只是将一个张量作为`Module`的属性添加,它不会包含在`parameters`中:
```py
class T(Module):
def __init__(self): self.a = torch.ones(3)
L(T().parameters())
```
```py
(#0) []
```
要告诉`Module`我们希望将一个张量视为参数,我们必须将其包装在`nn.Parameter`类中。这个类不添加任何功能(除了自动为我们调用`requires_grad_`)。它只用作一个“标记”,以显示要包含在`parameters`中的内容:
```py
class T(Module):
def __init__(self): self.a = nn.Parameter(torch.ones(3))
L(T().parameters())
```
```py
(#1) [Parameter containing:
tensor([1., 1., 1.], requires_grad=True)]
```
所有 PyTorch 模块都使用`nn.Parameter`来表示任何可训练参数,这就是为什么我们直到现在都不需要显式使用这个包装器:
```py
class T(Module):
def __init__(self): self.a = nn.Linear(1, 3, bias=False)
t = T()
L(t.parameters())
```
```py
(#1) [Parameter containing:
tensor([[-0.9595],
[-0.8490],
[ 0.8159]], requires_grad=True)]
```
```py
type(t.a.weight)
```
```py
torch.nn.parameter.Parameter
```
我们可以创建一个张量作为参数,进行随机初始化,如下所示:
```py
def create_params(size):
return nn.Parameter(torch.zeros(*size).normal_(0, 0.01))
```
让我们再次使用这个来创建`DotProductBias`,但不使用`Embedding`
```py
class DotProductBias(Module):
def __init__(self, n_users, n_movies, n_factors, y_range=(0,5.5)):
self.user_factors = create_params([n_users, n_factors])
self.user_bias = create_params([n_users])
self.movie_factors = create_params([n_movies, n_factors])
self.movie_bias = create_params([n_movies])
self.y_range = y_range
def forward(self, x):
users = self.user_factors[x[:,0]]
movies = self.movie_factors[x[:,1]]
res = (users*movies).sum(dim=1)
res += self.user_bias[x[:,0]] + self.movie_bias[x[:,1]]
return sigmoid_range(res, *self.y_range)
```
然后让我们再次训练它,以检查我们是否得到了与前一节中看到的大致相同的结果:
```py
model = DotProductBias(n_users, n_movies, 50)
learn = Learner(dls, model, loss_func=MSELossFlat())
learn.fit_one_cycle(5, 5e-3, wd=0.1)
```
| epoch | train_loss | valid_loss | time |
| --- | --- | --- | --- |
| 0 | 0.962146 | 0.936952 | 00:14 |
| 1 | 0.858084 | 0.884951 | 00:14 |
| 2 | 0.740883 | 0.838549 | 00:14 |
| 3 | 0.592497 | 0.823599 | 00:14 |
| 4 | 0.473570 | 0.824263 | 00:14 |
现在,让我们看看我们的模型学到了什么。
# 解释嵌入和偏差
我们的模型已经很有用,因为它可以为我们的用户提供电影推荐,但看到它发现了什么参数也很有趣。最容易解释的是偏差。以下是偏差向量中值最低的电影:
```py
movie_bias = learn.model.movie_bias.squeeze()
idxs = movie_bias.argsort()[:5]
[dls.classes['title'][i] for i in idxs]
```
```py
['Children of the Corn: The Gathering (1996)',
'Lawnmower Man 2: Beyond Cyberspace (1996)',
'Beautician and the Beast, The (1997)',
'Crow: City of Angels, The (1996)',
'Home Alone 3 (1997)']
```
想想这意味着什么。它表明对于这些电影中的每一部,即使用户与其潜在因素非常匹配(稍后我们将看到,这些因素往往代表动作水平、电影年龄等等),他们通常仍然不喜欢它。我们本可以简单地按照电影的平均评分对其进行排序,但查看学到的偏差告诉我们更有趣的事情。它告诉我们不仅仅是电影是人们不喜欢观看的类型,而且即使是他们本来会喜欢的类型,人们也倾向于不喜欢观看!同样地,以下是偏差最高的电影:
```py
idxs = movie_bias.argsort(descending=True)[:5]
[dls.classes['title'][i] for i in idxs]
```
```py
['L.A. Confidential (1997)',
'Titanic (1997)',
'Silence of the Lambs, The (1991)',
'Shawshank Redemption, The (1994)',
'Star Wars (1977)']
```
因此,例如,即使您通常不喜欢侦探电影,您可能会喜欢*LA 机密*
直接解释嵌入矩阵并不那么容易。对于人类来说,因素太多了。但有一种技术可以提取出这种矩阵中最重要的基础*方向*,称为*主成分分析*PCA。我们不会在本书中详细讨论这个因为您要成为深度学习从业者并不特别重要但如果您感兴趣我们建议您查看 fast.ai 课程[面向程序员的计算线性代数](https://oreil.ly/NLj2R)。图 8-3 显示了基于两个最强的 PCA 组件的电影的外观。
![基于两个最强的 PCA 组件的电影表示](img/dlcf_0804.png)
###### 图 8-3. 基于两个最强的 PCA 组件的电影表示
我们可以看到模型似乎已经发现了*经典*与*流行文化*电影的概念,或者这里代表的是*广受好评*。
# 杰里米说
无论我训练多少模型,我永远不会停止被这些随机初始化的数字组合所感动和惊讶,这些数字通过如此简单的机制训练,竟然能够自己发现关于我的数据的东西。我几乎觉得可以欺骗,我可以创建一个能够做有用事情的代码,而从未真正告诉它如何做这些事情!
我们从头开始定义了我们的模型,以教给您内部情况,但您可以直接使用 fastai 库来构建它。我们将在下一节看看如何做到这一点。
## 使用 fastai.collab
我们可以使用 fastai 的`collab_learner`使用先前显示的确切结构创建和训练协同过滤模型:
```py
learn = collab_learner(dls, n_factors=50, y_range=(0, 5.5))
```
```py
learn.fit_one_cycle(5, 5e-3, wd=0.1)
```
| epoch | train_loss | valid_loss | time |
| --- | --- | --- | --- |
| 0 | 0.931751 | 0.953806 | 00:13 |
| 1 | 0.851826 | 0.878119 | 00:13 |
| 2 | 0.715254 | 0.834711 | 00:13 |
| 3 | 0.583173 | 0.821470 | 00:13 |
| 4 | 0.496625 | 0.821688 | 00:13 |
通过打印模型可以看到层的名称:
```py
learn.model
```
```py
EmbeddingDotBias(
(u_weight): Embedding(944, 50)
(i_weight): Embedding(1635, 50)
(u_bias): Embedding(944, 1)
(i_bias): Embedding(1635, 1)
)
```
我们可以使用这些来复制我们在上一节中所做的任何分析,例如:
```py
movie_bias = learn.model.i_bias.weight.squeeze()
idxs = movie_bias.argsort(descending=True)[:5]
[dls.classes['title'][i] for i in idxs]
```
```py
['Titanic (1997)',
"Schindler's List (1993)",
'Shawshank Redemption, The (1994)',
'L.A. Confidential (1997)',
'Silence of the Lambs, The (1991)']
```
我们可以使用这些学到的嵌入来查看*距离*。
## 嵌入距离
在二维地图上,我们可以通过使用毕达哥拉斯定理的公式来计算两个坐标之间的距离:<math alttext="StartRoot x squared plus y squared EndRoot"><msqrt><mrow><msup><mi>x</mi> <mn>2</mn></msup> <mo>+</mo> <msup><mi>y</mi> <mn>2</mn></msup></mrow></msqrt></math>(假设*x*和*y*是每个轴上坐标之间的距离)。对于一个 50 维的嵌入,我们可以做完全相同的事情,只是将所有 50 个坐标距离的平方相加。
如果有两部几乎相同的电影,它们的嵌入向量也必须几乎相同,因为喜欢它们的用户几乎完全相同。这里有一个更一般的想法:电影的相似性可以由喜欢这些电影的用户的相似性来定义。这直接意味着两部电影的嵌入向量之间的距离可以定义这种相似性。我们可以利用这一点找到与“沉默的羔羊”最相似的电影:
```py
movie_factors = learn.model.i_weight.weight
idx = dls.classes['title'].o2i['Silence of the Lambs, The (1991)']
distances = nn.CosineSimilarity(dim=1)(movie_factors, movie_factors[idx][None])
idx = distances.argsort(descending=True)[1]
dls.classes['title'][idx]
```
```py
'Dial M for Murder (1954)'
```
现在我们已经成功训练了一个模型,让我们看看如何处理没有用户数据的情况。我们如何向新用户推荐?
# 引导协同过滤模型
在实践中使用协同过滤模型的最大挑战是“引导问题”。这个问题的最极端版本是没有用户,因此没有历史可供学习。您向您的第一个用户推荐什么产品?
但即使您是一家历史悠久的公司,拥有长期的用户交易记录,您仍然会面临一个问题:当新用户注册时,您该怎么办?实际上,当您向您的产品组合添加新产品时,您该怎么办?这个问题没有魔法解决方案,而我们建议的解决方案实际上只是“运用常识”的变体。您可以将新用户分配为其他用户所有嵌入向量的平均值,但这会带来一个问题,即该潜在因素的特定组合可能并不常见(例如,科幻因素的平均值可能很高,而动作因素的平均值可能很低,但很少有人喜欢科幻而不喜欢动作)。最好选择一个特定用户来代表“平均品味”。
更好的方法是使用基于用户元数据的表格模型来构建您的初始嵌入向量。当用户注册时,考虑一下您可以询问哪些问题来帮助您了解他们的口味。然后,您可以创建一个模型,其中因变量是用户的嵌入向量,而自变量是您问他们的问题的结果,以及他们的注册元数据。我们将在下一节中看到如何创建这些类型的表格模型。(您可能已经注意到,当您注册 Pandora 和 Netflix 等服务时,它们往往会问您一些关于您喜欢的电影或音乐类型的问题;这就是它们如何提出您的初始协同过滤推荐的方式。)
需要注意的一点是,一小部分非常热情的用户可能最终会有效地为整个用户群设置推荐。这是一个非常常见的问题,例如,在电影推荐系统中。看动漫的人往往会看很多动漫,而且不怎么看其他东西,花很多时间在网站上评分。因此,动漫往往在许多“有史以来最佳电影”列表中被过度代表。在这种特殊情况下,很明显您有一个代表性偏见的问题,但如果偏见发生在潜在因素中,可能一点也不明显。
这样的问题可能会改变您的用户群体的整体构成,以及您系统的行为。这特别是由于正反馈循环。如果您的一小部分用户倾向于设定您的推荐系统的方向,他们自然会吸引更多类似他们的人来到您的系统。这当然会放大原始的表征偏见。这种偏见是一种被指数级放大的自然倾向。您可能已经看到一些公司高管对他们的在线平台如何迅速恶化表示惊讶,以至于表达了与创始人价值观不符的价值观。在存在这种类型的反馈循环的情况下,很容易看到这种分歧如何迅速发生,以及以一种隐藏的方式,直到为时已晚。
在这样一个自我强化的系统中,我们可能应该预期这些反馈循环是常态,而不是例外。因此,您应该假设您会看到它们,为此做好计划,并提前确定如何处理这些问题。尝试考虑反馈循环可能在您的系统中表示的所有方式,以及您如何能够在数据中识别它们。最终,这又回到了我们关于如何在推出任何类型的机器学习系统时避免灾难的最初建议。这一切都是为了确保有人参与其中;有仔细的监控,以及一个渐进和周到的推出。
我们的点积模型效果相当不错,并且是许多成功的现实世界推荐系统的基础。这种协同过滤方法被称为*概率矩阵分解*PMF。另一种方法通常在给定相同数据时效果类似是深度学习。
# 协同过滤的深度学习
将我们的架构转换为深度学习模型的第一步是获取嵌入查找的结果并将这些激活连接在一起。这给我们一个矩阵,然后我们可以按照通常的方式通过线性层和非线性传递它们。
由于我们将连接嵌入矩阵而不是取它们的点积所以两个嵌入矩阵可以具有不同的大小不同数量的潜在因素。fastai 有一个函数`get_emb_sz`,根据 fast.ai 发现在实践中往往效果良好的启发式方法,返回推荐的嵌入矩阵大小:
```py
embs = get_emb_sz(dls)
embs
```
```py
[(944, 74), (1635, 101)]
```
让我们实现这个类:
```py
class CollabNN(Module):
def __init__(self, user_sz, item_sz, y_range=(0,5.5), n_act=100):
self.user_factors = Embedding(*user_sz)
self.item_factors = Embedding(*item_sz)
self.layers = nn.Sequential(
nn.Linear(user_sz[1]+item_sz[1], n_act),
nn.ReLU(),
nn.Linear(n_act, 1))
self.y_range = y_range
def forward(self, x):
embs = self.user_factors(x[:,0]),self.item_factors(x[:,1])
x = self.layers(torch.cat(embs, dim=1))
return sigmoid_range(x, *self.y_range)
```
并使用它创建一个模型:
```py
model = CollabNN(*embs)
```
`CollabNN`以与本章中先前类似的方式创建我们的`Embedding`层,只是现在我们使用`embs`大小。`self.layers`与我们在第四章为 MNIST 创建的迷你神经网络是相同的。然后,在`forward`中,我们应用嵌入,连接结果,并通过迷你神经网络传递。最后,我们像以前的模型一样应用`sigmoid_range`。
让我们看看它是否训练:
```py
learn = Learner(dls, model, loss_func=MSELossFlat())
learn.fit_one_cycle(5, 5e-3, wd=0.01)
```
| epoch | train_loss | valid_loss | time |
| --- | --- | --- | --- |
| 0 | 0.940104 | 0.959786 | 00:15 |
| 1 | 0.893943 | 0.905222 | 00:14 |
| 2 | 0.865591 | 0.875238 | 00:14 |
| 3 | 0.800177 | 0.867468 | 00:14 |
| 4 | 0.760255 | 0.867455 | 00:14 |
如果您在调用`collab_learner`时传递`use_nn=True`(包括为您调用`get_emb_sz`fastai 在`fastai.collab`中提供了这个模型,并且让您轻松创建更多层。例如,在这里我们创建了两个隐藏层,分别为大小 100 和 50
```py
learn = collab_learner(dls, use_nn=True, y_range=(0, 5.5), layers=[100,50])
learn.fit_one_cycle(5, 5e-3, wd=0.1)
```
| epoch | train_loss | valid_loss | time |
| --- | --- | --- | --- |
| 0 | 1.002747 | 0.972392 | 00:16 |
| 1 | 0.926903 | 0.922348 | 00:16 |
| 2 | 0.877160 | 0.893401 | 00:16 |
| 3 | 0.838334 | 0.865040 | 00:16 |
| 4 | 0.781666 | 0.864936 | 00:16 |
`learn.model`是`EmbeddingNN`类型的对象。让我们看一下 fastai 对这个类的代码:
```py
@delegates(TabularModel)
class EmbeddingNN(TabularModel):
def __init__(self, emb_szs, layers, **kwargs):
super().__init__(emb_szs, layers=layers, n_cont=0, out_sz=1, **kwargs)
```
哇,这不是很多代码!这个类*继承*自`TabularModel`,这是它获取所有功能的地方。在`__init__`中,它调用`TabularModel`中的相同方法,传递`n_cont=0`和`out_sz=1`;除此之外,它只传递它收到的任何参数。
尽管`EmbeddingNN`的结果比点积方法稍差一些(这显示了为领域精心构建架构的力量),但它确实允许我们做一件非常重要的事情:我们现在可以直接将其他用户和电影信息、日期和时间信息或任何可能与推荐相关的信息纳入考虑。这正是`TabularModel`所做的。事实上,我们现在已经看到,`EmbeddingNN`只是一个`TabularModel`,其中`n_cont=0`和`out_sz=1`。因此,我们最好花一些时间了解`TabularModel`,以及如何使用它获得出色的结果!我们将在下一章中做到这一点。
# 结论
对于我们的第一个非计算机视觉应用,我们研究了推荐系统,并看到梯度下降如何从评分历史中学习有关项目的内在因素或偏差。然后,这些因素可以为我们提供有关数据的信息。
我们还在 PyTorch 中构建了我们的第一个模型。在书的下一部分中,我们将做更多这样的工作,但首先,让我们完成对深度学习的其他一般应用的探讨,继续处理表格数据。
# 问卷
1. 协同过滤解决了什么问题?
1. 它是如何解决的?
1. 为什么协同过滤预测模型可能无法成为非常有用的推荐系统?
1. 协同过滤数据的交叉表表示是什么样的?
1. 编写代码创建 MovieLens 数据的交叉表表示(您可能需要进行一些网络搜索!)。
1. 什么是潜在因素?为什么它是“潜在”的?
1. 什么是点积?使用纯 Python 和列表手动计算点积。
1. `pandas.DataFrame.merge`是做什么的?
1. 什么是嵌入矩阵?
1. 嵌入和一个独热编码向量矩阵之间的关系是什么?
1. 如果我们可以使用独热编码向量来做同样的事情,为什么我们需要`Embedding`
1. 在我们开始训练之前,嵌入包含什么内容(假设我们没有使用预训练模型)?
1. 创建一个类(尽量不要偷看!)并使用它。
1. `x[:,0]`返回什么?
1. 重写`DotProduct`类(尽量不要偷看!)并用它训练模型。
1. 在 MovieLens 中使用什么样的损失函数是好的?为什么?
1. 如果我们在 MovieLens 中使用交叉熵损失会发生什么?我们需要如何更改模型?
1. 点积模型中偏差的用途是什么?
1. 权重衰减的另一个名称是什么?
1. 写出权重衰减的方程(不要偷看!)。
1. 写出权重衰减的梯度方程。为什么它有助于减少权重?
1. 为什么减少权重会导致更好的泛化?
1. PyTorch 中的`argsort`是做什么的?
1. 对电影偏差进行排序是否会得到与按电影平均评分相同的结果?为什么/为什么不?
1. 如何打印模型中层的名称和详细信息?
1. 协同过滤中的“自举问题”是什么?
1. 如何处理新用户的自举问题?对于新电影呢?
1. 反馈循环如何影响协同过滤系统?
1. 在协同过滤中使用神经网络时,为什么我们可以为电影和用户使用不同数量的因素?
1. 为什么在`CollabNN`模型中有一个`nn.Sequential`
1. 如果我们想要向协同过滤模型添加有关用户和项目的元数据,或者有关日期和时间等信息,应该使用什么样的模型?
## 进一步研究
1. 看看`Embedding`版本的`DotProductBias`和`create_params`版本之间的所有差异,并尝试理解为什么需要进行每一项更改。如果不确定,尝试撤销每个更改以查看发生了什么。(注意:甚至在`forward`中使用的括号类型也已更改!)
1. 找到另外三个协同过滤正在使用的领域,并在这些领域中确定这种方法的优缺点。
1. 使用完整的 MovieLens 数据集完成这个笔记本,并将结果与在线基准进行比较。看看你能否提高准确性。在书的网站和 fast.ai 论坛上寻找想法。请注意,完整数据集中有更多列,看看你是否也可以使用这些列(下一章可能会给你一些想法)。
1. 为 MovieLens 创建一个使用交叉熵损失的模型,并将其与本章中的模型进行比较。

File diff suppressed because it is too large Load Diff

857
translations/cn/10_nlp.md Normal file
View File

@ -0,0 +1,857 @@
# 第十章NLP 深入探讨RNNs
在第一章中,我们看到深度学习可以用于处理自然语言数据集并取得出色的结果。我们的示例依赖于使用预训练的语言模型,并对其进行微调以对评论进行分类。该示例突出了 NLP 和计算机视觉中迁移学习的区别:通常情况下,在 NLP 中,预训练模型是在不同任务上训练的。
我们所谓的*语言模型*是一个经过训练以猜测文本中下一个单词的模型(在读取之前的单词后)。这种任务称为*自监督学习*:我们不需要为我们的模型提供标签,只需向其提供大量文本。它有一个过程可以从数据中自动获取标签,这个任务并不是微不足道的:为了正确猜测句子中的下一个单词,模型将必须发展对英语(或其他语言)的理解。自监督学习也可以用于其他领域;例如,参见[“自监督学习和计算机视觉”](https://oreil.ly/ECjfJ)以了解视觉应用。自监督学习通常不用于直接训练的模型,而是用于预训练用于迁移学习的模型。
# 术语:自监督学习
使用嵌入在自变量中的标签来训练模型,而不是需要外部标签。例如,训练一个模型来预测文本中的下一个单词。
我们在第一章中用于分类 IMDb 评论的语言模型是在维基百科上预训练的。通过直接微调这个语言模型到电影评论分类器,我们取得了出色的结果,但通过一个额外的步骤,我们甚至可以做得更好。维基百科的英语与 IMDb 的英语略有不同,因此,我们可以将我们的预训练语言模型微调到 IMDb 语料库,然后将*那个*作为我们分类器的基础。
即使我们的语言模型了解我们在任务中使用的语言的基础知识(例如,我们的预训练模型是英语),熟悉我们的目标语料库的风格也是有帮助的。它可能是更非正式的语言,或者更技术性的,有新词要学习或者不同的句子构成方式。在 IMDb 数据集的情况下,将会有很多电影导演和演员的名字,通常比维基百科中看到的语言更不正式。
我们已经看到,使用 fastai我们可以下载一个预训练的英语语言模型并用它来获得 NLP 分类的最新结果。(我们预计很快将提供更多语言的预训练模型;实际上,当您阅读本书时,它们可能已经可用。)那么,为什么我们要详细学习如何训练语言模型呢?
当然,一个原因是了解您正在使用的模型的基础知识是有帮助的。但还有另一个非常实际的原因,那就是如果在微调分类模型之前微调(基于序列的)语言模型,您将获得更好的结果。例如,对于 IMDb 情感分析任务,数据集包括额外的 50,000 条电影评论,这些评论没有任何积极或消极的标签。由于训练集中有 25,000 条带标签的评论,验证集中有 25,000 条,总共有 100,000 条电影评论。我们可以使用所有这些评论来微调仅在维基百科文章上训练的预训练语言模型,这将导致一个特别擅长预测电影评论下一个单词的语言模型。
这被称为通用语言模型微调ULMFiT方法。[介绍它的论文](https://oreil.ly/rET-C)表明,在将语言模型微调到传递学习到分类任务之前,这个额外的微调阶段会导致预测显著更好。使用这种方法,我们在 NLP 中有三个传递学习阶段,如图 10-1 所总结。
![ULMFiT 过程的图表](img/dlcf_1001.png)
###### 图 10-1。ULMFiT 过程
我们现在将探讨如何将神经网络应用于这个语言建模问题,使用前两章介绍的概念。但在继续阅读之前,请暂停一下,思考一下*您*将如何处理这个问题。
# 文本预处理
到目前为止,我们学到的如何构建语言模型并不明显。句子的长度可能不同,文档可能很长。那么我们如何使用神经网络来预测句子的下一个单词呢?让我们找出答案!
我们已经看到分类变量可以作为神经网络的独立变量使用。以下是我们为单个分类变量采取的方法:
1. 制作该分类变量的所有可能级别的列表(我们将称此列表为*词汇*)。
1. 用词汇表中的索引替换每个级别。
1. 为此创建一个包含每个级别的行的嵌入矩阵(即,词汇表中的每个项目)。
1. 将此嵌入矩阵用作神经网络的第一层。(专用嵌入矩阵可以将步骤 2 中创建的原始词汇索引作为输入;这相当于但比使用表示索引的独热编码向量作为输入更快速和更有效。)
我们几乎可以用文本做同样的事情!新的是序列的概念。首先,我们将数据集中的所有文档连接成一个大字符串,然后将其拆分为单词(或*标记*),从而给我们一个非常长的单词列表。我们的独立变量将是从我们非常长的列表中的第一个单词开始并以倒数第二个单词结束的单词序列,我们的因变量将是从第二个单词开始并以最后一个单词结束的单词序列。
我们的词汇将由一些常见词汇和我们语料库中特定的新词汇(例如电影术语或演员的名字)混合组成。我们的嵌入矩阵将相应构建:对于预训练模型词汇中的词,我们将使用预训练模型的嵌入矩阵中的相应行;但对于新词,我们将没有任何内容,因此我们将只是用随机向量初始化相应的行。
创建语言模型所需的每个步骤都与自然语言处理领域的术语相关联,并且有 fastai 和 PyTorch 类可用于帮助。步骤如下:
标记化
将文本转换为单词列表(或字符,或子字符串,取决于您模型的粒度)。
数值化
列出所有出现的唯一单词(词汇表),并通过查找其在词汇表中的索引将每个单词转换为一个数字。
语言模型数据加载器创建
fastai 提供了一个`LMDataLoader`类,它会自动处理创建一个依赖变量,该变量与独立变量相差一个标记。它还处理一些重要的细节,例如如何以保持所需结构的方式对训练数据进行洗牌。
语言模型创建
我们需要一种特殊类型的模型,可以处理我们以前没有见过的输入列表,这些列表可能非常大或非常小。有许多方法可以做到这一点;在本章中,我们将使用*循环神经网络*RNN。我们将在第十二章中详细介绍 RNN 的细节,但现在,您可以将其视为另一个深度神经网络。
让我们详细看看每个步骤是如何工作的。
## 分词
当我们说“将文本转换为单词列表”时我们忽略了很多细节。例如我们如何处理标点符号我们如何处理像“dont”这样的单词它是一个单词还是两个长的医学或化学术语怎么办它们应该被分割成各自的含义部分吗连字符词怎么处理像德语和波兰语这样的语言如何处理它们可以从许多部分组成一个非常长的单词像日语和中文这样的语言如何处理它们根本不使用基础也没有一个明确定义的*单词*的概念?
由于这些问题没有一个正确答案,所以也没有一个分词的方法。有三种主要方法:
基于单词的
将一个句子按空格分割同时应用特定于语言的规则尝试在没有空格的情况下分隔含义部分例如将“dont”转换为“do nt”。通常标点符号也会被分割成单独的标记。
基于子词的
根据最常出现的子字符串将单词分割成较小的部分。例如“occasion”可能被分词为“o c ca sion”。
基于字符的
将一个句子分割成其各个字符。
我们将在这里看一下单词和子词的分词,将字符为基础的分词留给你在本章末尾的问卷中实现。
# 行话Token
由分词过程创建的列表的一个元素。它可以是一个单词,一个单词的一部分(一个*子词*),或一个单个字符。
## 使用 fastai 进行单词分词
fastai 并没有提供自己的分词器,而是提供了一个一致的接口来使用外部库中的一系列分词器。分词是一个活跃的研究领域,新的和改进的分词器不断涌现,因此 fastai 使用的默认值也会发生变化。然而API 和选项不应该发生太大变化,因为 fastai 试图在底层技术发生变化时保持一致的 API。
让我们尝试一下我们在第一章中使用的 IMDb 数据集:
```py
from fastai.text.all import *
path = untar_data(URLs.IMDB)
```
我们需要获取文本文件以尝试一个分词器。就像`get_image_files`(我们已经使用了很多次)获取路径中的所有图像文件一样,`get_text_files`获取路径中的所有文本文件。我们还可以选择性地传递`folders`来限制搜索到特定的子文件夹列表:
```py
files = get_text_files(path, folders = ['train', 'test', 'unsup'])
```
这是一个我们将要分词的评论(我们这里只打印开头部分以节省空间):
```py
txt = files[0].open().read(); txt[:75]
```
```py
'This movie, which I just discovered at the video store, has apparently sit '
```
在撰写本书时fastai 的默认英语单词分词器使用了一个名为*spaCy*的库。它有一个复杂的规则引擎,具有针对 URL、特殊英语单词等的特殊规则以及更多。然而我们不会直接使用`SpacyTokenizer`,而是使用`WordTokenizer`,因为它将始终指向 fastai 当前默认的单词分词器(取决于你阅读本书的时间,可能不一定是 spaCy
让我们试一试。我们将使用 fastai 的`coll_repr(*collection*,*n*)`函数来显示结果。这会显示*`collection`*的前*`n`*个项目,以及完整的大小——这是`L`默认使用的。请注意fastai 的分词器接受一个要分词的文档集合,因此我们必须将`txt`包装在一个列表中:
```py
spacy = WordTokenizer()
toks = first(spacy([txt]))
print(coll_repr(toks, 30))
```
```py
(#201) ['This','movie',',','which','I','just','discovered','at','the','video','s
> tore',',','has','apparently','sit','around','for','a','couple','of','years','
> without','a','distributor','.','It',"'s",'easy','to','see'...]
```
正如你所看到的spaCy 主要只是将单词和标点符号分开。但它在这里也做了其他事情它将“it's”分割成“it”和“s”。这是直观的这些实际上是分开的单词。分词是一个令人惊讶的微妙任务当你考虑到所有必须处理的细节时。幸运的是spaCy 为我们处理得相当好——例如,在这里我们看到“.”在终止句子时被分开,但在首字母缩写或数字中不会被分开:
```py
first(spacy(['The U.S. dollar $1 is $1.00.']))
```
```py
(#9) ['The','U.S.','dollar','$','1','is','$','1.00','.']
```
然后 fastai 通过`Tokenizer`类为分词过程添加了一些额外功能:
```py
tkn = Tokenizer(spacy)
print(coll_repr(tkn(txt), 31))
```
```py
(#228) ['xxbos','xxmaj','this','movie',',','which','i','just','discovered','at',
> 'the','video','store',',','has','apparently','sit','around','for','a','couple
> ','of','years','without','a','distributor','.','xxmaj','it',"'s",'easy'...]
```
请注意现在有一些以“xx”开头的标记这不是英语中常见的单词前缀。这些是*特殊标记*。
例如,列表中的第一项`xxbos`是一个特殊标记表示新文本的开始“BOS”是一个标准的 NLP 缩写,意思是“流的开始”)。通过识别这个开始标记,模型将能够学习需要“忘记”先前说过的内容,专注于即将出现的单词。
这些特殊标记并不是直接来自 spaCy。它们存在是因为 fastai 默认添加它们,通过在处理文本时应用一系列规则。这些规则旨在使模型更容易识别句子中的重要部分。在某种意义上,我们正在将原始的英语语言序列翻译成一个简化的标记化语言——这种语言被设计成易于模型学习。
例如,规则将用一个感叹号替换四个感叹号,后面跟着一个特殊的*重复字符*标记,然后是数字四。通过这种方式,模型的嵌入矩阵可以编码关于重复标点等一般概念的信息,而不需要为每个标点符号的重复次数添加单独的标记。同样,一个大写的单词将被替换为一个特殊的大写标记,后面跟着单词的小写版本。这样,嵌入矩阵只需要单词的小写版本,节省了计算和内存资源,但仍然可以学习大写的概念。
以下是一些你会看到的主要特殊标记:
`xxbos`
指示文本的开始(这里是一篇评论)
`xxmaj`
指示下一个单词以大写字母开头(因为我们将所有字母转换为小写)
`xxunk`
指示下一个单词是未知的
要查看使用的规则,可以查看默认规则:
```py
defaults.text_proc_rules
```
```py
[<function fastai.text.core.fix_html(x)>,
<function fastai.text.core.replace_rep(t)>,
<function fastai.text.core.replace_wrep(t)>,
<function fastai.text.core.spec_add_spaces(t)>,
<function fastai.text.core.rm_useless_spaces(t)>,
<function fastai.text.core.replace_all_caps(t)>,
<function fastai.text.core.replace_maj(t)>,
<function fastai.text.core.lowercase(t, add_bos=True, add_eos=False)>]
```
如常,你可以通过在笔记本中键入以下内容查看每个规则的源代码:
```py
??replace_rep
```
以下是每个标记的简要摘要:
`fix_html`
用可读版本替换特殊的 HTML 字符IMDb 评论中有很多这样的字符)
`replace_rep`
用一个特殊标记替换任何重复三次或更多次的字符(`xxrep`),重复的次数,然后是字符
`replace_wrep`
用一个特殊标记替换任何重复三次或更多次的单词(`xxwrep`),重复的次数,然后是单词
`spec_add_spaces`
在/和#周围添加空格
`rm_useless_spaces`
删除所有空格的重复
`replace_all_caps`
将所有大写字母单词转换为小写,并在其前面添加一个特殊标记(`xxcap`
`replace_maj`
将大写的单词转换为小写,并在其前面添加一个特殊标记(`xxmaj`
`lowercase`
将所有文本转换为小写,并在开头(`xxbos`)和/或结尾(`xxeos`)添加一个特殊标记
让我们看看其中一些的操作:
```py
coll_repr(tkn('&copy; Fast.ai www.fast.ai/INDEX'), 31)
```
```py
"(#11) ['xxbos','©','xxmaj','fast.ai','xxrep','3','w','.fast.ai','/','xxup','ind
> ex'...]"
```
现在让我们看看子词标记化是如何工作的。
## 子词标记化
除了在前一节中看到的*单词标记化*方法之外,另一种流行的标记化方法是*子词标记化*。单词标记化依赖于一个假设即空格在句子中提供了有意义的组件的有用分隔。然而这个假设并不总是适用。例如考虑这个句子我的名字是郝杰瑞中文中的“My name is Jeremy Howard”。这对于单词标记器来说不会很好因为其中没有空格像中文和日文这样的语言不使用空格事实上它们甚至没有一个明确定义的“单词”概念。其他语言如土耳其语和匈牙利语可以将许多子词组合在一起而不使用空格创建包含许多独立信息片段的非常长的单词。
为了处理这些情况,通常最好使用子词标记化。这个过程分为两步:
1. 分析一组文档以找到最常出现的字母组。这些将成为词汇表。
1. 使用这个*子词单元*的词汇对语料库进行标记化。
让我们看一个例子。对于我们的语料库,我们将使用前 2,000 条电影评论:
```py
txts = L(o.open().read() for o in files[:2000])
```
我们实例化我们的标记器,传入我们想要创建的词汇表的大小,然后我们需要“训练”它。也就是说,我们需要让它阅读我们的文档并找到常见的字符序列以创建词汇表。这是通过`setup`完成的。正如我们将很快看到的,`setup`是一个特殊的 fastai 方法,在我们通常的数据处理流程中会自动调用。然而,由于目前我们正在手动执行所有操作,因此我们必须自己调用它。这是一个为给定词汇表大小执行这些步骤并显示示例输出的函数:
```py
def subword(sz):
sp = SubwordTokenizer(vocab_sz=sz)
sp.setup(txts)
return ' '.join(first(sp([txt]))[:40])
```
让我们试一试:
```py
subword(1000)
```
```py
'▁This ▁movie , ▁which ▁I ▁just ▁dis c over ed ▁at ▁the ▁video ▁st or e , ▁has
> ▁a p par ent ly ▁s it ▁around ▁for ▁a ▁couple ▁of ▁years ▁without ▁a ▁dis t
> ri but or . ▁It'
```
使用 fastai 的子词标记器时,特殊字符`▁`代表原始文本中的空格字符。
如果我们使用较小的词汇表,每个标记将代表更少的字符,并且需要更多的标记来表示一个句子:
```py
subword(200)
```
```py
'▁ T h i s ▁movie , ▁w h i ch ▁I ▁ j us t ▁ d i s c o ver ed ▁a t ▁the ▁ v id e
> o ▁ st or e , ▁h a s'
```
另一方面,如果我们使用较大的词汇表,大多数常见的英语单词将最终出现在词汇表中,我们将不需要那么多来表示一个句子:
```py
subword(10000)
```
```py
"▁This ▁movie , ▁which ▁I ▁just ▁discover ed ▁at ▁the ▁video ▁store , ▁has
> ▁apparently ▁sit ▁around ▁for ▁a ▁couple ▁of ▁years ▁without ▁a ▁distributor
> . ▁It ' s ▁easy ▁to ▁see ▁why . ▁The ▁story ▁of ▁two ▁friends ▁living"
```
选择子词词汇表大小代表一种折衷:较大的词汇表意味着每个句子的标记较少,这意味着训练速度更快,内存更少,并且模型需要记住的状态更少;但是,缺点是,这意味着更大的嵌入矩阵,这需要更多的数据来学习。
总的来说,子词标记化提供了一种在字符标记化(即使用较小的子词词汇表)和单词标记化(即使用较大的子词词汇表)之间轻松切换的方法,并且处理每种人类语言而无需开发特定于语言的算法。它甚至可以处理其他“语言”,如基因组序列或 MIDI 音乐符号!因此,过去一年中,它的流行度飙升,似乎很可能成为最常见的标记化方法(当您阅读本文时,它可能已经是了!)。
一旦我们的文本被分割成标记,我们需要将它们转换为数字。我们将在下一步中看到这一点。
## 使用 fastai 进行数字化
*数字化*是将标记映射到整数的过程。这些步骤基本上与创建`Category`变量所需的步骤相同,例如 MNIST 中数字的因变量:
1. 制作该分类变量的所有可能级别的列表(词汇表)。
1. 用词汇表中的索引替换每个级别。
让我们看看在之前看到的单词标记化文本上的实际操作:
```py
toks = tkn(txt)
print(coll_repr(tkn(txt), 31))
```
```py
(#228) ['xxbos','xxmaj','this','movie',',','which','i','just','discovered','at',
> 'the','video','store',',','has','apparently','sit','around','for','a','couple
> ','of','years','without','a','distributor','.','xxmaj','it',"'s",'easy'...]
```
就像`SubwordTokenizer`一样,我们需要在`Numericalize`上调用`setup`这是我们创建词汇表的方法。这意味着我们首先需要我们的标记化语料库。由于标记化需要一段时间fastai 会并行进行;但是对于这个手动演示,我们将使用一个小的子集:
```py
toks200 = txts[:200].map(tkn)
toks200[0]
```
```py
(#228)
> ['xxbos','xxmaj','this','movie',',','which','i','just','discovered','at'...]
```
我们可以将这个传递给`setup`来创建我们的词汇表:
```py
num = Numericalize()
num.setup(toks200)
coll_repr(num.vocab,20)
```
```py
"(#2000) ['xxunk','xxpad','xxbos','xxeos','xxfld','xxrep','xxwrep','xxup','xxmaj
> ','the','.',',','a','and','of','to','is','in','i','it'...]"
```
我们的特殊规则标记首先出现,然后每个单词按频率顺序出现一次。`Numericalize`的默认值为`min_freq=3`和`max_vocab=60000`。`max_vocab=60000`导致 fastai 用特殊的*未知单词*标记`xxunk`替换除最常见的 60,000 个单词之外的所有单词。这有助于避免过大的嵌入矩阵,因为这可能会减慢训练速度并占用太多内存,并且还可能意味着没有足够的数据来训练稀有单词的有用表示。然而,通过设置`min_freq`来处理最后一个问题更好;默认值`min_freq=3`意味着出现少于三次的任何单词都将被替换为`xxunk`。
fastai 还可以使用您提供的词汇表对数据集进行数字化,方法是将单词列表作为`vocab`参数传递。
一旦我们创建了我们的`Numericalize`对象,我们可以像使用函数一样使用它:
```py
nums = num(toks)[:20]; nums
```
```py
tensor([ 2, 8, 21, 28, 11, 90, 18, 59, 0, 45, 9, 351, 499, 11,
> 72, 533, 584, 146, 29, 12])
```
这一次,我们的标记已经转换为模型可以接收的整数张量。我们可以检查它们是否映射回原始文本:
```py
' '.join(num.vocab[o] for o in nums)
```
```py
'xxbos xxmaj this movie , which i just xxunk at the video store , has apparently
> sit around for a'
```
| xxbos | xxmaj | 在 | 这个 | 章节 | | 我们 | 将 | 回顾 | 一下 | 分类 | 的 | 例子 |
## 回到我们之前的例子,有 6 个长度为 15 的批次,如果我们选择序列长度为 5那意味着我们首先输入以下数组
处理图像时,我们需要将它们全部调整为相同的高度和宽度,然后将它们组合在一起形成一个小批次,以便它们可以有效地堆叠在一个张量中。这里会有一点不同,因为不能简单地将文本调整为所需的长度。此外,我们希望我们的语言模型按顺序阅读文本,以便它可以有效地预测下一个单词是什么。这意味着每个新批次应该从上一个批次结束的地方开始。
假设我们有以下文本:
> 在这一章中,我们将回顾我们在第一章中学习的分类电影评论的例子,并深入挖掘。首先,我们将看一下将文本转换为数字所需的处理步骤以及如何自定义它。通过这样做,我们将有另一个使用数据块 API 中的预处理器的例子。
>
> 然后我们将学习如何构建一个语言模型并训练它一段时间。
标记化过程将添加特殊标记并处理标点以返回这个文本:
> xxbos 在这一章中,我们将回顾我们在第一章中学习的分类电影评论的例子,并深入挖掘。首先,我们将看一下将文本转换为数字所需的处理步骤以及如何自定义它。通过这样做,我们将有另一个使用数据块 API 中的预处理器的例子。
现在我们有 90 个标记,用空格分隔。假设我们想要一个批次大小为 6。我们需要将这个文本分成 6 个长度为 15 的连续部分:
| 转换 | 文本 | 为 | 数字 | 和 |
| --- | --- | --- | --- | --- |
| 电影 | 评论 | 我们 | 研究 | 在 | 章节 | 1 | 和 | 深入 | 更深 | 在 | 表面 | 。 | xxmaj |
| 首先 | 我们 | 将 | 看 | 处理 | 步骤 | 必要 | 将 | 文本 | 转换 | 为 | 数字 | 和 |
| 如何 | 自定义 | 它 | 。 | 通过 | 这样做 | | 我们 | 将 | 有 | 另一个 | 例子 |
| 预处理器 | 在 | 数据 | 块 | xxup | api | 中 | 使用 | 的 | 例子 | \n | 然后 | 我们 |
| 将 | 学习 | 我们 | 如何 | 构建 | 一个 | 语言 | 模型 | 并 | 训练 | 它 | 一段时间 | 为 | 一个 | 。 |
在理想的情况下,我们可以将这一个批次提供给我们的模型。但这种方法不具有可扩展性,因为在这个玩具示例之外,一个包含所有标记的单个批次不太可能适合我们的 GPU 内存(这里有 90 个标记,但所有 IMDb 评论一起给出了数百万个)。
因此,我们需要将这个数组更细地划分为固定序列长度的子数组。在这些子数组内部和之间保持顺序非常重要,因为我们将使用一个保持状态的模型,以便在预测接下来的内容时记住之前读到的内容。
| 语言 | 模型 | 和 | 训练 |
| xxbos | xxmaj | 在 | 这个 | 章节 |
| --- | --- | --- | --- | --- |
| 分类 | 的 | 例子 | 在 | 电影 | 评论 | 中 | 我们 | 研究 |
| 首先 | 我们 | 将 | 看 | 到 |
| 如何 | 自定义 | 它 | 。 |
| 预处理器 | 使用 | 在 |
| 将 | 学习 | 我们 | 如何 | 构建 |
然后,这一个:
| | 我们 | 将 | 回顾 |
| --- | --- | --- | --- |
| 章节 | 1 | 和 | 深入 | 更深 |
| 处理 | 步骤 | 必要 | 将 |
| 通过 | 这样做 | |
| 数据 | 块 | xxup | api |
| 现在我们有了数字,我们需要将它们分批放入模型中。 |
最后:
| 将我们的文本放入语言模型的批次中 |
| --- |
| 更深 | 在 | 表面 | 。 xxmaj |
| 将文本转换为数字,并按行翻译成中文: |
| 我们 | 有 | 另一个 | 例子 |
| 。 | \n | xxmaj | 然后 | 我们 |
| 它 | 一段时间 | 为 | 一个 | 。 |
回到我们的电影评论数据集,第一步是通过将各个文本串联在一起将其转换为流。与图像一样,最好随机化输入的顺序,因此在每个时期的开始,我们将对条目进行洗牌以生成新的流(我们对文档的顺序进行洗牌,而不是其中的单词顺序,否则文本将不再有意义!)。
然后将此流切成一定数量的批次(这是我们的*批量大小*)。例如,如果流有 50,000 个标记,我们设置批量大小为 10这将给我们 5,000 个标记的 10 个小流。重要的是我们保留标记的顺序(因此从 1 到 5,000 为第一个小流,然后从 5,001 到 10,000…因为我们希望模型读取连续的文本行如前面的示例。在预处理期间在每个文本的开头添加一个`xxbos`标记,以便模型知道当读取流时新条目何时开始。
因此,总结一下,每个时期我们都会对文档集合进行洗牌,并将它们连接成一个标记流。然后将该流切成一批固定大小的连续小流。我们的模型将按顺序读取小流,并由于内部状态,无论我们选择的序列长度如何,它都将产生相同的激活。
当我们创建`LMDataLoader`时,所有这些都是由 fastai 库在幕后完成的。我们首先将我们的`Numericalize`对象应用于标记化的文本
```py
nums200 = toks200.map(num)
```
然后将其传递给`LMDataLoader`
```py
dl = LMDataLoader(nums200)
```
让我们通过获取第一批来确认这是否给出了预期的结果
```py
x,y = first(dl)
x.shape,y.shape
```
```py
(torch.Size([64, 72]), torch.Size([64, 72]))
```
然后查看独立变量的第一行,这应该是第一个文本的开头:
```py
' '.join(num.vocab[o] for o in x[0][:20])
```
```py
'xxbos xxmaj this movie , which i just xxunk at the video store , has apparently
> sit around for a'
```
依赖变量是相同的,只是偏移了一个标记:
```py
' '.join(num.vocab[o] for o in y[0][:20])
```
```py
'xxmaj this movie , which i just xxunk at the video store , has apparently sit
> around for a couple'
```
这就完成了我们需要对数据应用的所有预处理步骤。我们现在准备训练我们的文本分类器。
# 训练文本分类器
正如我们在本章开头看到的那样,使用迁移学习训练最先进的文本分类器有两个步骤:首先,我们需要微调在 Wikipedia 上预训练的语言模型以适应 IMDb 评论的语料库,然后我们可以使用该模型来训练分类器。
像往常一样,让我们从组装数据开始。
## 使用 DataBlock 的语言模型
当`TextBlock`传递给`DataBlock`时fastai 会自动处理标记化和数值化。所有可以传递给`Tokenizer`和`Numericalize`的参数也可以传递给`TextBlock`。在下一章中,我们将讨论分别运行每个步骤的最简单方法,以便进行调试,但您也可以通过在数据的子集上手动运行它们来进行调试,如前几节所示。不要忘记`DataBlock`的方便的`summary`方法,用于调试数据问题非常有用。
这是我们如何使用`TextBlock`使用 fastai 的默认值创建语言模型的方式:
```py
get_imdb = partial(get_text_files, folders=['train', 'test', 'unsup'])
dls_lm = DataBlock(
blocks=TextBlock.from_folder(path, is_lm=True),
get_items=get_imdb, splitter=RandomSplitter(0.1)
).dataloaders(path, path=path, bs=128, seq_len=80)
```
与我们在`DataBlock`中使用的以前类型不同的一件事是,我们不仅仅直接使用类(即`TextBlock...`,而是调用*类方法*。类方法是 Python 方法,如其名称所示,属于*类*而不是*对象*。(如果您对类方法不熟悉,请务必在网上搜索更多信息,因为它们在许多 Python 库和应用程序中常用;我们在本书中以前使用过几次,但没有特别提到。)`TextBlock`之所以特殊是因为设置数值化器的词汇表可能需要很长时间(我们必须读取和标记化每个文档以获取词汇表)。
为了尽可能高效fastai 执行了一些优化:
+ 它将标记化的文档保存在临时文件夹中,因此不必多次对其进行标记化。
+ 它并行运行多个标记化过程,以利用计算机的 CPU。
我们需要告诉`TextBlock`如何访问文本,以便它可以进行这种初始预处理——这就是`from_folder`的作用。
`show_batch`然后以通常的方式工作:
```py
dls_lm.show_batch(max_n=2)
```
| | text | text_ |
| --- | --- | --- |
| 0 | xxbos xxmaj it s awesome ! xxmaj in xxmaj story xxmaj mode , your going from punk to pro . xxmaj you have to complete goals that involve skating , driving , and walking . xxmaj you create your own skater and give it a name , and you can make it look stupid or realistic . xxmaj you are with your friend xxmaj eric throughout the game until he betrays you and gets you kicked off of the skateboard | xxmaj it s awesome ! xxmaj in xxmaj story xxmaj mode , your going from punk to pro . xxmaj you have to complete goals that involve skating , driving , and walking . xxmaj you create your own skater and give it a name , and you can make it look stupid or realistic . xxmaj you are with your friend xxmaj eric throughout the game until he betrays you and gets you kicked off of the skateboard xxunk |
| 1 | what xxmaj i ve read , xxmaj death xxmaj bed is based on an actual dream , xxmaj george xxmaj barry , the director , successfully transferred dream to film , only a genius could accomplish such a task . \n\n xxmaj old mansions make for good quality horror , as do portraits , not sure what to make of the killer bed with its killer yellow liquid , quite a bizarre dream , indeed . xxmaj also , this | xxmaj i ve read , xxmaj death xxmaj bed is based on an actual dream , xxmaj george xxmaj barry , the director , successfully transferred dream to film , only a genius could accomplish such a task . \n\n xxmaj old mansions make for good quality horror , as do portraits , not sure what to make of the killer bed with its killer yellow liquid , quite a bizarre dream , indeed . xxmaj also , this is |
现在我们的数据准备好了,我们可以对预训练语言模型进行微调。
## 微调语言模型
将整数单词索引转换为我们可以用于神经网络的激活时,我们将使用嵌入,就像我们在协同过滤和表格建模中所做的那样。然后,我们将把这些嵌入馈送到*递归神经网络*RNN使用一种称为*AWD-LSTM*的架构(我们将在第十二章中向您展示如何从头开始编写这样一个模型)。正如我们之前讨论的,预训练模型中的嵌入与为不在预训练词汇表中的单词添加的随机嵌入合并。这在`language_model_learner`内部自动处理:
```py
learn = language_model_learner(
dls_lm, AWD_LSTM, drop_mult=0.3,
metrics=[accuracy, Perplexity()]).to_fp16()
```
默认使用的损失函数是交叉熵损失,因为我们基本上有一个分类问题(不同类别是我们词汇表中的单词)。这里使用的*困惑度*指标通常用于 NLP 的语言模型:它是损失的指数(即`torch.exp(cross_entropy)`)。我们还包括准确性指标,以查看我们的模型在尝试预测下一个单词时有多少次是正确的,因为交叉熵(正如我们所见)很难解释,并且更多地告诉我们有关模型信心而不是准确性。
让我们回到本章开头的流程图。第一个箭头已经为我们完成,并作为 fastai 中的预训练模型提供,我们刚刚构建了第二阶段的`DataLoaders`和`Learner`。现在我们准备好对我们的语言模型进行微调!
![ULMFiT 过程的图表](img/dlcf_1001.png)
每个时代的训练需要相当长的时间,因此我们将在训练过程中保存中间模型结果。由于`fine_tune`不会为我们执行此操作,因此我们将使用`fit_one_cycle`。就像`cnn_learner`一样,当使用预训练模型(这是默认设置)时,`language_model_learner`在使用时会自动调用`freeze`,因此这将仅训练嵌入(模型中唯一包含随机初始化权重的部分——即我们 IMDb 词汇表中存在但不在预训练模型词汇表中的单词的嵌入):
```py
learn.fit_one_cycle(1, 2e-2)
```
| epoch | train_loss | valid_loss | accuracy | perplexity | time |
| --- | --- | --- | --- | --- | --- |
| 0 | 4.120048 | 3.912788 | 0.299565 | 50.038246 | 11:39 |
这个模型训练时间较长,所以现在是谈论保存中间结果的好机会。
## 保存和加载模型
您可以轻松保存模型的状态如下:
```py
learn.save('1epoch')
```
这将在 *learn.path/models/* 中创建一个名为 *1epoch.pth* 的文件。如果您想在另一台机器上加载模型,或者稍后恢复训练,可以按照以下方式加载此文件的内容:
```py
learn = learn.load('1epoch')
```
一旦初始训练完成,我们可以在解冻后继续微调模型:
```py
learn.unfreeze()
learn.fit_one_cycle(10, 2e-3)
```
| epoch | train_loss | valid_loss | accuracy | perplexity | time |
| --- | --- | --- | --- | --- | --- |
| 0 | 3.893486 | 3.772820 | 0.317104 | 43.502548 | 12:37 |
| 1 | 3.820479 | 3.717197 | 0.323790 | 41.148880 | 12:30 |
| 2 | 3.735622 | 3.659760 | 0.330321 | 38.851997 | 12:09 |
| 3 | 3.677086 | 3.624794 | 0.333960 | 37.516987 | 12:12 |
| 4 | 3.636646 | 3.601300 | 0.337017 | 36.645859 | 12:05 |
| 5 | 3.553636 | 3.584241 | 0.339355 | 36.026001 | 12:04 |
| 6 | 3.507634 | 3.571892 | 0.341353 | 35.583862 | 12:08 |
| 7 | 3.444101 | 3.565988 | 0.342194 | 35.374371 | 12:08 |
| 8 | 3.398597 | 3.566283 | 0.342647 | 35.384815 | 12:11 |
| 9 | 3.375563 | 3.568166 | 0.342528 | 35.451500 | 12:05 |
完成后,我们保存所有模型,除了将激活转换为在我们的词汇表中选择每个标记的概率的最终层。不包括最终层的模型称为*编码器*。我们可以使用 `save_encoder` 来保存它:
```py
learn.save_encoder('finetuned')
```
# 术语:编码器
不包括任务特定的最终层。当应用于视觉 CNN 时,这个术语与“主体”几乎意思相同,但在 NLP 和生成模型中更常用“编码器”。
这完成了文本分类过程的第二阶段:微调语言模型。我们现在可以使用它来微调一个分类器,使用 IMDb 的情感标签。然而,在继续微调分类器之前,让我们快速尝试一些不同的东西:使用我们的模型生成随机评论。
## 文本生成
因为我们的模型经过训练可以猜测句子的下一个单词,所以我们可以用它来写新评论:
```py
TEXT = "I liked this movie because"
N_WORDS = 40
N_SENTENCES = 2
preds = [learn.predict(TEXT, N_WORDS, temperature=0.75)
for _ in range(N_SENTENCES)]
```
```py
print("\n".join(preds))
```
```py
i liked this movie because of its story and characters . The story line was very
> strong , very good for a sci - fi film . The main character , Alucard , was
> very well developed and brought the whole story
i liked this movie because i like the idea of the premise of the movie , the (
> very ) convenient virus ( which , when you have to kill a few people , the "
> evil " machine has to be used to protect
```
正如您所看到的,我们添加了一些随机性(我们根据模型返回的概率选择一个随机单词),这样我们就不会得到完全相同的评论两次。我们的模型没有任何关于句子结构或语法规则的编程知识,但它显然已经学会了很多关于英语句子:我们可以看到它正确地大写了(*I* 被转换为 *i*,因为我们的规则要求两个字符或更多才能认为一个单词是大写的,所以看到它小写是正常的)并且使用一致的时态。一般的评论乍一看是有意义的,只有仔细阅读时才能注意到有些地方有点不对。对于在几个小时内训练的模型来说,这还不错!
但我们的最终目标不是训练一个生成评论的模型,而是对其进行分类...所以让我们使用这个模型来做到这一点。
## 创建分类器数据加载器
我们现在从语言模型微调转向分类器微调。简而言之,语言模型预测文档的下一个单词,因此不需要任何外部标签。然而,分类器预测外部标签——在 IMDb 的情况下,是文档的情感。
这意味着我们用于 NLP 分类的 `DataBlock` 结构看起来非常熟悉。它几乎与我们为许多图像分类数据集看到的相同:
```py
dls_clas = DataBlock(
blocks=(TextBlock.from_folder(path, vocab=dls_lm.vocab),CategoryBlock),
get_y = parent_label,
get_items=partial(get_text_files, folders=['train', 'test']),
splitter=GrandparentSplitter(valid_name='test')
).dataloaders(path, path=path, bs=128, seq_len=72)
```
就像图像分类一样,`show_batch` 显示了依赖变量(情感,在这种情况下)与每个独立变量(电影评论文本):
```py
dls_clas.show_batch(max_n=3)
```
| | 文本 | 类别 |
| --- | --- | --- |
| 0 | xxbos 我给这部电影打了 3 颗头骨的评分,只是因为女孩们知道如何尖叫,这部电影本可以更好,如果演员更好的话,双胞胎还行,我相信他们是邪恶的,但是最大和最小的兄弟,他们表现得真的很糟糕,看起来他们在读剧本而不是表演……。剧透:如果他们是吸血鬼,为什么他们会冻结血液?吸血鬼不能喝冻结的血液,电影中的姐姐说让我们在她活着的时候喝她……。但是当他们搬到另一栋房子时,他们带了一个冷藏盒装着他们的冻结血液。剧透结束\n\n 这是浪费时间,这让我很生气,因为我读了所有关于它的评论 | neg |
| 1 | xxbos 我已经阅读了所有的《爱来的方式》系列书籍。我充分了解电影无法使用书中的所有方面,但通常它们至少会有书中的主要内容。我对这部电影感到非常失望。这部电影中唯一与书中相同的是,书中有 xxmaj missy 的父亲来到 xxunk (在书中父母都来了)。就是这样。故事情节扭曲且牵强,是的,悲伤,与书中完全不同,我无法享受。即使我没有读过这本书,它也太悲伤了。我知道拓荒生活很艰难,但整部电影都是一个沮丧的故事。评分 | neg |
| 2 | xxbos 这部电影,用一个更好的词来说,很糟糕。我从哪里开始呢……\n\n 电影摄影 - 这或许是我今年看过的最糟糕的。看起来就像摄影师之间在互相抛接相机。也许他们只有一台相机。这让你感觉像是一个排球。\n\n 有一堆场景,零零散散地扔进去,完全没有连贯性。当他们做 '分屏' 时,那是荒谬的。一切都被压扁了,看起来荒谬。颜色调整完全错了。这些人需要学会如何平衡相机。这部 '电影' 制作很差, | neg |
`DataBlock` 的定义来看,每个部分都与我们构建的先前数据块相似,但有两个重要的例外:
+ `TextBlock.from_folder` 不再具有 `is_lm=True` 参数。
+ 我们传递了为语言模型微调创建的 `vocab`
我们传递语言模型的 `vocab` 是为了确保我们使用相同的标记到索引的对应关系。否则,我们在微调语言模型中学到的嵌入对这个模型没有任何意义,微调步骤也没有任何用处。
通过传递 `is_lm=False`(或者根本不传递 `is_lm`,因为它默认为 `False`),我们告诉 `TextBlock` 我们有常规标记的数据,而不是将下一个标记作为标签。然而,我们必须处理一个挑战,这与将多个文档合并成一个小批次有关。让我们通过一个示例来看,尝试创建一个包含前 10 个文档的小批次。首先我们将它们数值化:
```py
nums_samp = toks200[:10].map(num)
```
现在让我们看看这 10 条电影评论中每条有多少个标记:
```py
nums_samp.map(len)
```
```py
(#10) [228,238,121,290,196,194,533,124,581,155]
```
记住PyTorch 的 `DataLoader` 需要将批次中的所有项目整合到一个张量中,而一个张量具有固定的形状(即,每个轴上都有特定的长度,并且所有项目必须一致)。这应该听起来很熟悉:我们在图像中也遇到了同样的问题。在那种情况下,我们使用裁剪、填充和/或压缩来使所有输入大小相同。对于文档来说,裁剪可能不是一个好主意,因为我们可能会删除一些关键信息(话虽如此,对于图像也是同样的问题,我们在那里使用裁剪;数据增强在自然语言处理领域尚未得到很好的探索,因此也许在自然语言处理中也有使用裁剪的机会!)。你不能真正“压缩”一个文档。所以只剩下填充了!
我们将扩展最短的文本以使它们都具有相同的大小。为此,我们使用一个特殊的填充标记,该标记将被我们的模型忽略。此外,为了避免内存问题并提高性能,我们将大致相同长度的文本批量处理在一起(对于训练集进行一些洗牌)。我们通过在每个时期之前(对于训练集)按长度对文档进行排序来实现这一点。结果是,整理成单个批次的文档往往具有相似的长度。我们不会将每个批次填充到相同的大小,而是使用每个批次中最大文档的大小作为目标大小。
# 动态调整图像大小
可以对图像执行类似的操作,这对于不规则大小的矩形图像特别有用,但在撰写本文时,尚无库提供良好的支持,也没有任何涵盖此内容的论文。然而,我们计划很快将其添加到 fastai 中,因此请关注本书的网站;一旦我们成功运行,我们将添加有关此内容的信息。
当使用`TextBlock`和`is_lm=False`时,数据块 API 会自动为我们进行排序和填充。(对于语言模型数据,我们不会遇到这个问题,因为我们首先将所有文档连接在一起,然后将它们分成相同大小的部分。)
我们现在可以创建一个用于分类文本的模型:
```py
learn = text_classifier_learner(dls_clas, AWD_LSTM, drop_mult=0.5,
metrics=accuracy).to_fp16()
```
在训练分类器之前的最后一步是从我们微调的语言模型中加载编码器。我们使用`load_encoder`而不是`load`,因为我们只有编码器的预训练权重可用;`load`默认情况下会在加载不完整的模型时引发异常:
```py
learn = learn.load_encoder('finetuned')
```
## 微调分类器
最后一步是使用有区分性的学习率和*逐步解冻*进行训练。在计算机视觉中,我们经常一次性解冻整个模型,但对于 NLP 分类器,我们发现逐层解冻会产生真正的差异:
```py
learn.fit_one_cycle(1, 2e-2)
```
| epoch | train_loss | valid_loss | accuracy | time |
| --- | --- | --- | --- | --- |
| 0 | 0.347427 | 0.184480 | 0.929320 | 00:33 |
仅仅一个时期,我们就获得了与第一章中的训练相同的结果——还不错!我们可以将`freeze_to`设置为`-2`,以冻结除最后两个参数组之外的所有参数组:
```py
learn.freeze_to(-2)
learn.fit_one_cycle(1, slice(1e-2/(2.6**4),1e-2))
```
| epoch | train_loss | valid_loss | accuracy | time |
| --- | --- | --- | --- | --- |
| 0 | 0.247763 | 0.171683 | 0.934640 | 00:37 |
然后我们可以解冻更多层并继续训练:
```py
learn.freeze_to(-3)
learn.fit_one_cycle(1, slice(5e-3/(2.6**4),5e-3))
```
| epoch | train_loss | valid_loss | accuracy | time |
| --- | --- | --- | --- | --- |
| 0 | 0.193377 | 0.156696 | 0.941200 | 00:45 |
最后,整个模型!
```py
learn.unfreeze()
learn.fit_one_cycle(2, slice(1e-3/(2.6**4),1e-3))
```
| epoch | train_loss | valid_loss | accuracy | time |
| --- | --- | --- | --- | --- |
| 0 | 0.172888 | 0.153770 | 0.943120 | 01:01 |
| 1 | 0.161492 | 0.155567 | 0.942640 | 00:57 |
我们达到了 94.3%的准确率,这在仅仅三年前是最先进的性能。通过在所有文本上训练另一个模型,并对这两个模型的预测进行平均,我们甚至可以达到 95.1%的准确率,这是由 ULMFiT 论文引入的最先进技术。仅仅几个月前,通过微调一个更大的模型并使用昂贵的数据增强技术(将句子翻译成另一种语言,然后再翻译回来,使用另一个模型进行翻译)来打破了这一记录。
使用预训练模型让我们构建了一个非常强大的微调语言模型,可以用来生成假评论或帮助对其进行分类。这是令人兴奋的事情,但要记住这项技术也可以被用于恶意目的。
# 虚假信息和语言模型
即使是基于规则的简单算法在广泛使用深度学习语言模型之前也可以用来创建欺诈账户并试图影响决策者。ProPublica 的计算记者 Jeff Kao 分析了发送给美国联邦通信委员会FCC有关 2017 年废除网络中立提案的评论。在他的文章“可能伪造了一百多万条支持废除网络中立的评论”中,他报告了他是如何发现一大批反对网络中立的评论,这些评论似乎是通过某种 Mad Libs 风格的邮件合并生成的。在图 10-2 中Kao 已经帮助着色编码了这些虚假评论,以突出它们的公式化特性。
![](img/dlcf_1002.png)
###### 图 10-2. FCC 在网络中立辩论期间收到的评论
Kao 估计“2200 多万条评论中不到 80 万条...可以被认为是真正独特的”,“超过 99%的真正独特评论支持保持网络中立”。
鉴于自 2017 年以来语言建模的进展,这种欺诈性活动现在几乎不可能被发现。您现在拥有所有必要的工具来创建一个引人注目的语言模型 - 可以生成与上下文相关、可信的文本。它不一定会完全准确或正确,但它会是可信的。想象一下,当这种技术与我们近年来了解到的各种虚假信息活动结合在一起时会意味着什么。看看 Reddit 对话中显示的图 10-3其中基于 OpenAI 的 GPT-2 算法的语言模型正在讨论美国政府是否应该削减国防开支。
![Reddit 上的算法自言自语](img/dlcf_1003.png)
###### 图 10-3. Reddit 上的算法自言自语
在这种情况下,解释了正在使用算法生成对话。但想象一下,如果一个坏演员决定在社交网络上发布这样的算法会发生什么 - 他们可以慢慢而谨慎地这样做,让算法随着时间逐渐发展出追随者和信任。要做到这一点并不需要太多资源,就可以让成千上万的账户这样做。在这种情况下,我们很容易想象到在线讨论的绝大部分都是来自机器人,而没有人会意识到这种情况正在发生。
我们已经开始看到机器学习被用来生成身份的例子。例如,图 10-4 显示了 Katie Jones 的 LinkedIn 个人资料。
![](img/dlcf_1004.png)
###### 图 10-4. Katie Jones 的 LinkedIn 个人资料
Katie Jones 在 LinkedIn 上与几位主流华盛顿智库成员有联系。但她并不存在。你看到的那张图片是由生成对抗网络自动生成的,而某人名为 Katie Jones 的确没有毕业于战略与国际研究中心。
许多人假设或希望算法将在这里为我们辩护 - 我们将开发能够自动识别自动生成内容的分类算法。然而,问题在于这将永远是一场军备竞赛,更好的分类(或鉴别器)算法可以用来创建更好的生成算法。
# 结论
在本章中,我们探讨了 fastai 库中提供的最后一个开箱即用的应用:文本。我们看到了两种类型的模型:可以生成文本的语言模型,以及可以确定评论是积极还是消极的分类器。为了构建一个最先进的分类器,我们使用了一个预训练的语言模型,对其进行微调以适应我们任务的语料库,然后使用其主体(编码器)与一个新的头部进行分类。
在结束本书的这一部分之前,我们将看看 fastai 库如何帮助您为您的特定问题组装数据。
# 问卷
1. 什么是自监督学习?
1. 什么是语言模型?
1. 为什么语言模型被认为是自监督的?
1. 自监督模型通常用于什么?
1. 为什么我们要微调语言模型?
1. 创建一流文本分类器的三个步骤是什么?
1. 50,000 个未标记的电影评论如何帮助为 IMDb 数据集创建更好的文本分类器?
1. 为语言模型准备数据的三个步骤是什么?
1. 什么是标记化?为什么我们需要它?
1. 列出三种标记化方法。
1. 什么是 `xxbos`
1. 列出 fastai 在标记化期间应用的四条规则。
1. 为什么重复字符被替换为一个显示重复次数和被重复的字符的标记?
1. 什么是数值化?
1. 为什么会有单词被替换为“未知单词”标记?
1. 使用批量大小为 64表示第一批次的张量的第一行包含数据集的前 64 个标记。那个张量的第二行包含什么?第二批次的第一行包含什么?(小心 - 学生经常答错这个问题!一定要在书的网站上检查你的答案。)
1. 为什么文本分类需要填充?为什么语言建模不需要填充?
1. NLP 的嵌入矩阵包含什么?它的形状是什么?
1. 什么是困惑度?
1. 为什么我们必须将语言模型的词汇传递给分类器数据块?
1. 什是逐步解冻?
1. 为什么文本生成总是可能领先于自动识别机器生成的文本?
## 进一步研究
1. 看看你能学到关于语言模型和虚假信息的什么。今天最好的语言模型是什么?看看它们的一些输出。你觉得它们令人信服吗?坏人如何最好地利用这样的模型来制造冲突和不确定性?
1. 考虑到模型不太可能能够一致地识别机器生成的文本,可能需要哪些其他方法来处理利用深度学习的大规模虚假信息活动?

View File

@ -0,0 +1,604 @@
# 第十一章:使用 fastai 的中级 API 进行数据整理
我们已经看到了`Tokenizer`和`Numericalize`对文本集合的处理方式,以及它们如何在数据块 API 中使用,该 API 直接使用`TextBlock`处理这些转换。但是,如果我们只想应用这些转换中的一个,要么是为了查看中间结果,要么是因为我们已经对文本进行了标记化,我们该怎么办?更一般地说,当数据块 API 不足以满足我们特定用例的需求时,我们需要使用 fastai 的*中级 API*来处理数据。数据块 API 是建立在该层之上的,因此它将允许您执行数据块 API 所做的一切,以及更多更多。
# 深入了解 fastai 的分层 API
fastai 库是建立在*分层 API*上的。在最顶层是*应用程序*,允许我们在五行代码中训练模型,正如我们在第一章中看到的。例如,对于为文本分类器创建`DataLoaders`,我们使用了这一行:
```py
from fastai.text.all import *
dls = TextDataLoaders.from_folder(untar_data(URLs.IMDB), valid='test')
```
当您的数据排列方式与 IMDb 数据集完全相同时,工厂方法`TextDataLoaders.from_folder`非常方便,但实际上,情况通常不会如此。数据块 API 提供了更多的灵活性。正如我们在前一章中看到的,我们可以通过以下方式获得相同的结果:
```py
path = untar_data(URLs.IMDB)
dls = DataBlock(
blocks=(TextBlock.from_folder(path),CategoryBlock),
get_y = parent_label,
get_items=partial(get_text_files, folders=['train', 'test']),
splitter=GrandparentSplitter(valid_name='test')
).dataloaders(path)
```
但有时它并不够灵活。例如,出于调试目的,我们可能需要仅应用与此数据块一起的部分转换。或者我们可能希望为 fastai 不直接支持的应用程序创建一个`DataLoaders`。在本节中,我们将深入探讨 fastai 内部用于实现数据块 API 的组件。了解这些将使您能够利用这个中间层 API 的强大和灵活性。
# 中级 API
中级 API 不仅包含用于创建`DataLoaders`的功能。它还具有*回调*系统,允许我们以任何我们喜欢的方式自定义训练循环,以及*通用优化器*。这两者将在第十六章中介绍。
## 转换
在前一章中研究标记化和数值化时,我们首先获取了一堆文本:
```py
files = get_text_files(path, folders = ['train', 'test'])
txts = L(o.open().read() for o in files[:2000])
```
然后我们展示了如何使用`Tokenizer`对它们进行标记化
```py
tok = Tokenizer.from_folder(path)
tok.setup(txts)
toks = txts.map(tok)
toks[0]
```
```py
(#374) ['xxbos','xxmaj','well',',','"','cube','"','(','1997',')'...]
```
以及如何进行数值化,包括自动为我们的语料库创建词汇表:
```py
num = Numericalize()
num.setup(toks)
nums = toks.map(num)
nums[0][:10]
```
```py
tensor([ 2, 8, 76, 10, 23, 3112, 23, 34, 3113, 33])
```
这些类还有一个`decode`方法。例如,`Numericalize.decode`会将字符串标记返回给我们:
```py
nums_dec = num.decode(nums[0][:10]); nums_dec
```
```py
(#10) ['xxbos','xxmaj','well',',','"','cube','"','(','1997',')']
```
`Tokenizer.decode`将其转换回一个字符串(但可能不完全与原始字符串相同;这取决于标记器是否是*可逆的*,在我们撰写本书时,默认的单词标记器不是):
```py
tok.decode(nums_dec)
```
```py
'xxbos xxmaj well , " cube " ( 1997 )'
```
`decode`被 fastai 的`show_batch`和`show_results`以及其他一些推断方法使用,将预测和小批量转换为人类可理解的表示。
在前面的示例中,对于`tok`或`num`,我们创建了一个名为`setup`的对象(如果需要为`tok`训练标记器并为`num`创建词汇表),将其应用于我们的原始文本(通过将对象作为函数调用),然后最终将结果解码回可理解的表示。大多数数据预处理任务都需要这些步骤,因此 fastai 提供了一个封装它们的类。这就是`Transform`类。`Tokenize`和`Numericalize`都是`Transform`。
一般来说,`Transform`是一个行为类似于函数的对象,它具有一个可选的`setup`方法,用于初始化内部状态(例如`num`内部的词汇表),以及一个可选的`decode`方法,用于反转函数(正如我们在`tok`中看到的那样,这种反转可能不完美)。
`decode` 的一个很好的例子可以在我们在 第七章 中看到的 `Normalize` 转换中找到:为了能够绘制图像,它的 `decode` 方法会撤消归一化(即,乘以标准差并加回均值)。另一方面,数据增强转换没有 `decode` 方法,因为我们希望展示图像上的效果,以确保数据增强按我们的意愿进行工作。
`Transform` 的一个特殊行为是它们总是应用于元组。一般来说,我们的数据总是一个元组 `(input, target)`(有时有多个输入或多个目标)。当对这样的项目应用转换时,例如 `Resize`,我们不希望整个元组被调整大小;相反,我们希望分别调整输入(如果适用)和目标(如果适用)。对于进行数据增强的批处理转换也是一样的:当输入是图像且目标是分割掩模时,需要将转换(以相同的方式)应用于输入和目标。
如果我们将一个文本元组传递给 `tok`,我们可以看到这种行为:
```py
tok((txts[0], txts[1]))
```
```py
((#374) ['xxbos','xxmaj','well',',','"','cube','"','(','1997',')'...],
(#207)
> ['xxbos','xxmaj','conrad','xxmaj','hall','went','out','with','a','bang'...])
```
## 编写您自己的转换
如果您想编写一个自定义的转换来应用于您的数据,最简单的方法是编写一个函数。正如您在这个例子中看到的,`Transform` 只会应用于匹配的类型,如果提供了类型(否则,它将始终被应用)。在下面的代码中,函数签名中的 `:int` 表示 `f` 仅应用于 `ints`。这就是为什么 `tfm(2.0)` 返回 `2.0`,但 `tfm(2)` 在这里返回 `3`
```py
def f(x:int): return x+1
tfm = Transform(f)
tfm(2),tfm(2.0)
```
```py
(3, 2.0)
```
在这里,`f` 被转换为一个没有 `setup` 和没有 `decode` 方法的 `Transform`
Python 有一种特殊的语法,用于将一个函数(如 `f`)传递给另一个函数(或类似函数的东西,在 Python 中称为 *callable*),称为 *decorator*。通过在可调用对象前加上 `@` 并将其放在函数定义之前来使用装饰器(关于 Python 装饰器有很多很好的在线教程,如果这对您来说是一个新概念,请查看其中一个)。以下代码与前面的代码相同:
```py
@Transform
def f(x:int): return x+1
f(2),f(2.0)
```
```py
(3, 2.0)
```
如果您需要 `setup``decode`,您需要对 `Transform` 进行子类化,以在 `encodes` 中实现实际的编码行为,然后(可选)在 `setups` 中实现设置行为和在 `decodes` 中实现解码行为:
```py
class NormalizeMean(Transform):
def setups(self, items): self.mean = sum(items)/len(items)
def encodes(self, x): return x-self.mean
def decodes(self, x): return x+self.mean
```
在这里,`NormalizeMean` 将在设置期间初始化某个状态(传递的所有元素的平均值);然后转换是减去该平均值。为了解码目的,我们通过添加平均值来实现该转换的反向。这里是 `NormalizeMean` 的一个示例:
```py
tfm = NormalizeMean()
tfm.setup([1,2,3,4,5])
start = 2
y = tfm(start)
z = tfm.decode(y)
tfm.mean,y,z
```
```py
(3.0, -1.0, 2.0)
```
请注意,每个方法的调用和实现是不同的:
| 类 | 调用 | 实现 |
| --- | --- | --- |
| `nn.Module`PyTorch | `()`(即,作为函数调用) | `forward` |
| `Transform` | `()` | `encodes` |
| `Transform` | `decode()` | `decodes` |
| `Transform` | `setup()` | `setups` |
因此,例如,您永远不会直接调用 `setups`,而是会调用 `setup`。原因是 `setup` 在为您调用 `setups` 之前和之后做了一些工作。要了解有关 `Transform` 及如何使用它们根据输入类型实现不同行为的更多信息,请务必查看 fastai 文档中的教程。
## Pipeline
要将几个转换组合在一起fastai 提供了 `Pipeline` 类。我们通过向 `Pipeline` 传递一个 `Transform` 列表来定义一个 `Pipeline`;然后它将组合其中的转换。当您在对象上调用 `Pipeline` 时,它将自动按顺序调用其中的转换:
```py
tfms = Pipeline([tok, num])
t = tfms(txts[0]); t[:20]
```
```py
tensor([ 2, 8, 76, 10, 23, 3112, 23, 34, 3113, 33, 10, 8,
> 4477, 22, 88, 32, 10, 27, 42, 14])
```
您可以对编码结果调用 `decode`,以获取可以显示和分析的内容:
```py
tfms.decode(t)[:100]
```
```py
'xxbos xxmaj well , " cube " ( 1997 ) , xxmaj vincenzo \'s first movie , was one
> of the most interesti'
```
`Transform` 中与 `Transform` 不同的部分是设置。要在一些数据上正确设置 `Transform``Pipeline`,您需要使用 `TfmdLists`
# TfmdLists 和 Datasets转换的集合
您的数据通常是一组原始项目(如文件名或 DataFrame 中的行),您希望对其应用一系列转换。我们刚刚看到,一系列转换在 fastai 中由`Pipeline`表示。将这个`Pipeline`与您的原始项目组合在一起的类称为`TfmdLists`。
## TfmdLists
以下是在前一节中看到的转换的简短方式:
```py
tls = TfmdLists(files, [Tokenizer.from_folder(path), Numericalize])
```
在初始化时,`TfmdLists`将自动调用每个`Transform`的`setup`方法,依次提供每个原始项目而不是由所有先前的`Transform`转换的项目。我们可以通过索引到`TfmdLists`中的任何原始元素来获得我们的`Pipeline`的结果:
```py
t = tls[0]; t[:20]
```
```py
tensor([ 2, 8, 91, 11, 22, 5793, 22, 37, 4910, 34,
> 11, 8, 13042, 23, 107, 30, 11, 25, 44, 14])
```
而`TfmdLists`知道如何解码以进行显示:
```py
tls.decode(t)[:100]
```
```py
'xxbos xxmaj well , " cube " ( 1997 ) , xxmaj vincenzo \'s first movie , was one
> of the most interesti'
```
实际上,它甚至有一个`show`方法:
```py
tls.show(t)
```
```py
xxbos xxmaj well , " cube " ( 1997 ) , xxmaj vincenzo 's first movie , was one
> of the most interesting and tricky ideas that xxmaj i 've ever seen when
> talking about movies . xxmaj they had just one scenery , a bunch of actors
> and a plot . xxmaj so , what made it so special were all the effective
> direction , great dialogs and a bizarre condition that characters had to deal
> like rats in a labyrinth . xxmaj his second movie , " cypher " ( 2002 ) , was
> all about its story , but it was n't so good as " cube " but here are the
> characters being tested like rats again .
" nothing " is something very interesting and gets xxmaj vincenzo coming back
> to his ' cube days ' , locking the characters once again in a very different
> space with no time once more playing with the characters like playing with
> rats in an experience room . xxmaj but instead of a thriller sci - fi ( even
> some of the promotional teasers and trailers erroneous seemed like that ) , "
> nothing " is a loose and light comedy that for sure can be called a modern
> satire about our society and also about the intolerant world we 're living .
> xxmaj once again xxmaj xxunk amaze us with a great idea into a so small kind
> of thing . 2 actors and a blinding white scenario , that 's all you got most
> part of time and you do n't need more than that . xxmaj while " cube " is a
> claustrophobic experience and " cypher " confusing , " nothing " is
> completely the opposite but at the same time also desperate .
xxmaj this movie proves once again that a smart idea means much more than just
> a millionaire budget . xxmaj of course that the movie fails sometimes , but
> its prime idea means a lot and offsets any flaws . xxmaj there 's nothing
> more to be said about this movie because everything is a brilliant surprise
> and a totally different experience that i had in movies since " cube " .
```
`TfmdLists`以“s”命名因为它可以使用`splits`参数处理训练集和验证集。您只需要传递在训练集中的元素的索引和在验证集中的元素的索引:
```py
cut = int(len(files)*0.8)
splits = [list(range(cut)), list(range(cut,len(files)))]
tls = TfmdLists(files, [Tokenizer.from_folder(path), Numericalize],
splits=splits)
```
然后可以通过`train`和`valid`属性访问它们:
```py
tls.valid[0][:20]
```
```py
tensor([ 2, 8, 20, 30, 87, 510, 1570, 12, 408, 379,
> 4196, 10, 8, 20, 30, 16, 13, 12216, 202, 509])
```
如果您手动编写了一个`Transform`,一次执行所有预处理,将原始项目转换为具有输入和目标的元组,那么`TfmdLists`是您需要的类。您可以使用`dataloaders`方法直接将其转换为`DataLoaders`对象。这是我们稍后在本章中将要做的事情。
一般来说,您将有两个(或更多)并行的转换流水线:一个用于将原始项目处理为输入,另一个用于将原始项目处理为目标。例如,在这里,我们定义的流水线仅将原始文本处理为输入。如果我们要进行文本分类,还必须将标签处理为目标。
为此,我们需要做两件事。首先,我们从父文件夹中获取标签名称。有一个名为`parent_label`的函数:
```py
lbls = files.map(parent_label)
lbls
```
```py
(#50000) ['pos','pos','pos','pos','pos','pos','pos','pos','pos','pos'...]
```
然后我们需要一个`Transform`在设置期间将抓取的唯一项目构建为词汇表然后在调用时将字符串标签转换为整数。fastai 为我们提供了这个;它被称为`Categorize`
```py
cat = Categorize()
cat.setup(lbls)
cat.vocab, cat(lbls[0])
```
```py
((#2) ['neg','pos'], TensorCategory(1))
```
要在我们的文件列表上自动执行整个设置,我们可以像以前一样创建一个`TfmdLists`
```py
tls_y = TfmdLists(files, [parent_label, Categorize()])
tls_y[0]
```
```py
TensorCategory(1)
```
但是然后我们得到了两个分开的对象用于我们的输入和目标,这不是我们想要的。这就是`Datasets`发挥作用的地方。
## Datasets
`Datasets`将并行应用两个(或更多)流水线到相同的原始对象,并构建一个包含结果的元组。与`TfmdLists`一样,它将自动为我们进行设置,当我们索引到`Datasets`时,它将返回一个包含每个流水线结果的元组:
```py
x_tfms = [Tokenizer.from_folder(path), Numericalize]
y_tfms = [parent_label, Categorize()]
dsets = Datasets(files, [x_tfms, y_tfms])
x,y = dsets[0]
x[:20],y
```
像`TfmdLists`一样,我们可以将`splits`传递给`Datasets`以在训练和验证集之间拆分我们的数据:
```py
x_tfms = [Tokenizer.from_folder(path), Numericalize]
y_tfms = [parent_label, Categorize()]
dsets = Datasets(files, [x_tfms, y_tfms], splits=splits)
x,y = dsets.valid[0]
x[:20],y
```
```py
(tensor([ 2, 8, 20, 30, 87, 510, 1570, 12, 408, 379,
> 4196, 10, 8, 20, 30, 16, 13, 12216, 202, 509]),
TensorCategory(0))
```
它还可以解码任何处理过的元组或直接显示它:
```py
t = dsets.valid[0]
dsets.decode(t)
```
```py
('xxbos xxmaj this movie had horrible lighting and terrible camera movements .
> xxmaj this movie is a jumpy horror flick with no meaning at all . xxmaj the
> slashes are totally fake looking . xxmaj it looks like some 17 year - old
> idiot wrote this movie and a 10 year old kid shot it . xxmaj with the worst
> acting you can ever find . xxmaj people are tired of knives . xxmaj at least
> move on to guns or fire . xxmaj it has almost exact lines from " when a xxmaj
> stranger xxmaj calls " . xxmaj with gruesome killings , only crazy people
> would enjoy this movie . xxmaj it is obvious the writer does n\'t have kids
> or even care for them . i mean at show some mercy . xxmaj just to sum it up ,
> this movie is a " b " movie and it sucked . xxmaj just for your own sake , do
> n\'t even think about wasting your time watching this crappy movie .',
'neg')
```
最后一步是将我们的`Datasets`对象转换为`DataLoaders`,可以使用`dataloaders`方法完成。在这里,我们需要传递一个特殊参数来解决填充问题(正如我们在前一章中看到的)。这需要在我们批处理元素之前发生,所以我们将其传递给`before_batch`
```py
dls = dsets.dataloaders(bs=64, before_batch=pad_input)
```
`dataloaders`直接在我们的`Datasets`的每个子集上调用`DataLoader`。fastai 的`DataLoader`扩展了 PyTorch 中同名类,并负责将我们的数据集中的项目整理成批次。它有很多自定义点,但您应该知道的最重要的是:
`after_item`
在数据集中抓取项目后应用于每个项目。这相当于`DataBlock`中的`item_tfms`。
`before_batch`
在整理之前应用于项目列表上。这是将项目填充到相同大小的理想位置。
`after_batch`
在构建后对整个批次应用。这相当于`DataBlock`中的`batch_tfms`。
最后,这是为了准备文本分类数据所需的完整代码:
```py
tfms = [[Tokenizer.from_folder(path), Numericalize], [parent_label, Categorize]]
files = get_text_files(path, folders = ['train', 'test'])
splits = GrandparentSplitter(valid_name='test')(files)
dsets = Datasets(files, tfms, splits=splits)
dls = dsets.dataloaders(dl_type=SortedDL, before_batch=pad_input)
```
与之前的代码的两个不同之处是使用`GrandparentSplitter`来分割我们的训练和验证数据,以及`dl_type`参数。这是告诉`dataloaders`使用`DataLoader`的`SortedDL`类,而不是通常的类。`SortedDL`通过将大致相同长度的样本放入批次来构建批次。
这与我们之前的`DataBlock`完全相同:
```py
path = untar_data(URLs.IMDB)
dls = DataBlock(
blocks=(TextBlock.from_folder(path),CategoryBlock),
get_y = parent_label,
get_items=partial(get_text_files, folders=['train', 'test']),
splitter=GrandparentSplitter(valid_name='test')
).dataloaders(path)
```
但现在你知道如何定制每一个部分了!
让我们现在通过一个计算机视觉示例练习刚学到的关于使用这个中级 API 进行数据预处理。
# 应用中级数据 APISiamesePair
一个*暹罗模型*需要两张图片,并且必须确定它们是否属于同一类。在这个例子中,我们将再次使用宠物数据集,并准备数据用于一个模型,该模型将预测两张宠物图片是否属于同一品种。我们将在这里解释如何为这样的模型准备数据,然后我们将在第十五章中训练该模型。
首先要做的是-让我们获取数据集中的图片:
```py
from fastai.vision.all import *
path = untar_data(URLs.PETS)
files = get_image_files(path/"images")
```
如果我们根本不关心显示我们的对象,我们可以直接创建一个转换来完全预处理那个文件列表。但是我们想要查看这些图片,因此我们需要创建一个自定义类型。当您在`TfmdLists`或`Datasets`对象上调用`show`方法时,它将解码项目,直到达到包含`show`方法的类型,并使用它来显示对象。该`show`方法会传递一个`ctx`,它可以是图像的`matplotlib`轴,也可以是文本的 DataFrame 行。
在这里,我们创建了一个`SiameseImage`对象,它是`Tuple`的子类,旨在包含三个东西:两张图片和一个布尔值,如果图片是同一品种则为`True`。我们还实现了特殊的`show`方法,使其将两张图片与中间的黑线连接起来。不要太担心`if`测试中的部分(这是在 Python 图片而不是张量时显示`SiameseImage`的部分);重要的部分在最后三行:
```py
class SiameseImage(Tuple):
def show(self, ctx=None, **kwargs):
img1,img2,same_breed = self
if not isinstance(img1, Tensor):
if img2.size != img1.size: img2 = img2.resize(img1.size)
t1,t2 = tensor(img1),tensor(img2)
t1,t2 = t1.permute(2,0,1),t2.permute(2,0,1)
else: t1,t2 = img1,img2
line = t1.new_zeros(t1.shape[0], t1.shape[1], 10)
return show_image(torch.cat([t1,line,t2], dim=2),
title=same_breed, ctx=ctx)
```
让我们创建一个第一个`SiameseImage`并检查我们的`show`方法是否有效:
```py
img = PILImage.create(files[0])
s = SiameseImage(img, img, True)
s.show();
```
![](img/dlcf_11in01.png)
我们也可以尝试一个不属于同一类的第二张图片:
```py
img1 = PILImage.create(files[1])
s1 = SiameseImage(img, img1, False)
s1.show();
```
![](img/dlcf_11in02.png)
我们之前看到的转换的重要之处是它们会分派到元组或其子类。这正是为什么在这种情况下我们选择子类化`Tuple`的原因-这样,我们可以将适用于图像的任何转换应用于我们的`SiameseImage`,并且它将应用于元组中的每个图像:
```py
s2 = Resize(224)(s1)
s2.show();
```
![](img/dlcf_11in03.png)
这里`Resize`转换应用于两个图片中的每一个,但不应用于布尔标志。即使我们有一个自定义类型,我们也可以从库中的所有数据增强转换中受益。
现在我们准备构建`Transform`,以便为暹罗模型准备数据。首先,我们需要一个函数来确定所有图片的类别:
```py
def label_func(fname):
return re.match(r'^(.*)_\d+.jpg$', fname.name).groups()[0]
```
对于每张图片,我们的转换将以 0.5 的概率从同一类中绘制一张图片,并返回一个带有真标签的`SiameseImage`,或者从另一类中绘制一张图片并返回一个带有假标签的`SiameseImage`。这一切都在私有的`_draw`函数中完成。训练集和验证集之间有一个区别,这就是为什么转换需要用拆分初始化:在训练集上,我们将每次读取一张图片时进行随机选择,而在验证集上,我们将在初始化时进行一次性随机选择。这样,在训练期间我们会得到更多不同的样本,但始终是相同的验证集:
```py
class SiameseTransform(Transform):
def __init__(self, files, label_func, splits):
self.labels = files.map(label_func).unique()
self.lbl2files = {l: L(f for f in files if label_func(f) == l)
for l in self.labels}
self.label_func = label_func
self.valid = {f: self._draw(f) for f in files[splits[1]]}
def encodes(self, f):
f2,t = self.valid.get(f, self._draw(f))
img1,img2 = PILImage.create(f),PILImage.create(f2)
return SiameseImage(img1, img2, t)
def _draw(self, f):
same = random.random() < 0.5
cls = self.label_func(f)
if not same:
cls = random.choice(L(l for l in self.labels if l != cls))
return random.choice(self.lbl2files[cls]),same
```
然后我们可以创建我们的主要转换:
```py
splits = RandomSplitter()(files)
tfm = SiameseTransform(files, label_func, splits)
tfm(files[0]).show();
```
![](img/dlcf_11in04.png)
在数据收集的中级 API 中,我们有两个对象可以帮助我们在一组项目上应用转换:`TfmdLists`和`Datasets`。如果您记得刚才看到的内容,一个应用一系列转换的`Pipeline`,另一个并行应用多个`Pipeline`,以构建元组。在这里,我们的主要转换已经构建了元组,因此我们使用`TfmdLists`
```py
tls = TfmdLists(files, tfm, splits=splits)
show_at(tls.valid, 0);
```
![](img/dlcf_11in05.png)
最后,我们可以通过调用`dataloaders`方法在`DataLoaders`中获取我们的数据。这里需要注意的一点是,这个方法不像`DataBlock`那样接受`item_tfms`和`batch_tfms`。fastai 的`DataLoader`有几个钩子,这些钩子以事件命名;在我们抓取项目后应用的内容称为`after_item`,在构建批次后应用的内容称为`after_batch`
```py
dls = tls.dataloaders(after_item=[Resize(224), ToTensor],
after_batch=[IntToFloatTensor, Normalize.from_stats(*imagenet_stats)])
```
请注意,我们需要传递比通常更多的转换,这是因为数据块 API 通常会自动添加它们:
+ `ToTensor`是将图像转换为张量的函数(再次,它应用于元组的每个部分)。
+ `IntToFloatTensor`将包含 0 到 255 之间整数的图像张量转换为浮点数张量,并除以 255使值在 0 到 1 之间。
现在我们可以使用这个`DataLoaders`来训练模型。与`cnn_learner`提供的通常模型相比,它需要更多的定制,因为它必须接受两个图像而不是一个,但我们将看到如何创建这样的模型并在第十五章中进行训练。
# 结论
fastai 提供了分层 API。当数据处于通常设置之一时只需一行代码即可获取数据这使得初学者可以专注于训练模型而无需花费太多时间组装数据。然后高级数据块 API 通过允许您混合和匹配构建块来提供更多灵活性。在其下面,中级 API 为您提供更大的灵活性,以在项目上应用转换。在您的实际问题中,这可能是您需要使用的内容,我们希望它使数据处理步骤尽可能简单。
# 问卷调查
1. 为什么我们说 fastai 具有“分层”API这是什么意思
1. `Transform`为什么有一个`decode`方法?它是做什么的?
1. `Transform`为什么有一个`setup`方法?它是做什么的?
1. 当在元组上调用`Transform`时,它是如何工作的?
1. 编写自己的`Transform`时需要实现哪些方法?
1. 编写一个完全规范化项目的`Normalize`转换(减去数据集的平均值并除以标准差),并且可以解码该行为。尽量不要偷看!
1. 编写一个`Transform`,用于对标记化文本进行数字化(它应该从已见数据集自动设置其词汇,并具有`decode`方法)。如果需要帮助,请查看 fastai 的源代码。
1. 什么是`Pipeline`
1. 什么是`TfmdLists`
1. 什么是`Datasets`?它与`TfmdLists`有什么不同?
1. 为什么`TfmdLists`和`Datasets`带有“s”这个名字
1. 如何从`TfmdLists`或`Datasets`构建`DataLoaders`
1. 在从`TfmdLists`或`Datasets`构建`DataLoaders`时,如何传递`item_tfms`和`batch_tfms`
1. 当您希望自定义项目与`show_batch`或`show_results`等方法一起使用时,您需要做什么?
1. 为什么我们可以轻松地将 fastai 数据增强转换应用于我们构建的`SiamesePair`
## 进一步研究
1. 使用中级 API 在自己的数据集上准备`DataLoaders`中的数据。尝试在 Pet 数据集和 Adult 数据集上进行此操作,这两个数据集来自第一章。
1. 查看[fastai 文档](https://docs.fast.ai)中的 Siamese 教程,了解如何为新类型的项目自定义`show_batch`和`show_results`的行为。在您自己的项目中实现它。
# 理解 fastai 的应用:总结
恭喜你——你已经完成了本书中涵盖训练模型和使用深度学习的关键实用部分的所有章节!你知道如何使用所有 fastai 内置的应用程序,以及如何使用数据块 API 和损失函数进行定制。你甚至知道如何从头开始创建神经网络并训练它!(希望你现在也知道一些问题要问,以确保你的创作有助于改善社会。)
你已经掌握的知识足以创建许多类型的神经网络应用的完整工作原型。更重要的是,它将帮助你了解深度学习模型的能力和局限性,以及如何设计一个适应它们的系统。
在本书的其余部分,我们将逐个拆解这些应用程序,以了解它们构建在哪些基础之上。这对于深度学习从业者来说是重要的知识,因为它使您能够检查和调试您构建的模型,并创建定制的新应用程序,以适应您特定的项目。

View File

@ -0,0 +1,971 @@
# 第十二章:从头开始的语言模型
我们现在准备深入…深入深度学习!您已经学会了如何训练基本的神经网络,但是如何从那里创建最先进的模型呢?在本书的这一部分,我们将揭开所有的神秘,从语言模型开始。
您在第十章中看到了如何微调预训练的语言模型以构建文本分类器。在本章中,我们将解释该模型的内部结构以及 RNN 是什么。首先,让我们收集一些数据,这些数据将允许我们快速原型化各种模型。
# 数据
每当我们开始处理一个新问题时,我们总是首先尝试想出一个最简单的数据集,这样可以让我们快速轻松地尝试方法并解释结果。几年前我们开始进行语言建模时,我们没有找到任何可以快速原型的数据集,所以我们自己制作了一个。我们称之为*Human Numbers*,它简单地包含了用英语写出的前 10000 个数字。
# Jeremy 说
我在高度经验丰富的从业者中经常看到的一个常见实际错误是在分析过程中未能在适当的时间使用适当的数据集。特别是,大多数人倾向于从太大、太复杂的数据集开始。
我们可以按照通常的方式下载、提取并查看我们的数据集:
```py
from fastai.text.all import *
path = untar_data(URLs.HUMAN_NUMBERS)
```
```py
path.ls()
```
```py
(#2) [Path('train.txt'),Path('valid.txt')]
```
让我们打开这两个文件,看看里面有什么。首先,我们将把所有文本连接在一起,忽略数据集给出的训练/验证拆分(我们稍后会回到这一点):
```py
lines = L()
with open(path/'train.txt') as f: lines += L(*f.readlines())
with open(path/'valid.txt') as f: lines += L(*f.readlines())
lines
```
```py
(#9998) ['one \n','two \n','three \n','four \n','five \n','six \n','seven
> \n','eight \n','nine \n','ten \n'...]
```
我们将所有这些行连接在一个大流中。为了标记我们从一个数字到下一个数字的转变,我们使用`.`作为分隔符:
```py
text = ' . '.join([l.strip() for l in lines])
text[:100]
```
```py
'one . two . three . four . five . six . seven . eight . nine . ten . eleven .
> twelve . thirteen . fo'
```
我们可以通过在空格上拆分来对这个数据集进行标记化:
```py
tokens = text.split(' ')
tokens[:10]
```
```py
['one', '.', 'two', '.', 'three', '.', 'four', '.', 'five', '.']
```
为了数值化,我们必须创建一个包含所有唯一标记(我们的*词汇表*)的列表:
```py
vocab = L(*tokens).unique()
vocab
```
```py
(#30) ['one','.','two','three','four','five','six','seven','eight','nine'...]
```
然后,我们可以通过查找每个词在词汇表中的索引,将我们的标记转换为数字:
```py
word2idx = {w:i for i,w in enumerate(vocab)}
nums = L(word2idx[i] for i in tokens)
nums
```
```py
(#63095) [0,1,2,1,3,1,4,1,5,1...]
```
现在我们有了一个小数据集,语言建模应该是一个简单的任务,我们可以构建我们的第一个模型。
# 我们的第一个从头开始的语言模型
将这转换为神经网络的一个简单方法是指定我们将基于前三个单词预测每个单词。我们可以创建一个包含每个三个单词序列的列表作为我们的自变量,以及每个序列后面的下一个单词作为因变量。
我们可以用普通的 Python 来做到这一点。首先让我们用标记来确认它是什么样子的:
```py
L((tokens[i:i+3], tokens[i+3]) for i in range(0,len(tokens)-4,3))
```
```py
(#21031) [(['one', '.', 'two'], '.'),(['.', 'three', '.'], 'four'),(['four',
> '.', 'five'], '.'),(['.', 'six', '.'], 'seven'),(['seven', '.', 'eight'],
> '.'),(['.', 'nine', '.'], 'ten'),(['ten', '.', 'eleven'], '.'),(['.',
> 'twelve', '.'], 'thirteen'),(['thirteen', '.', 'fourteen'], '.'),(['.',
> 'fifteen', '.'], 'sixteen')...]
```
现在我们将使用数值化值的张量来做到这一点,这正是模型实际使用的:
```py
seqs = L((tensor(nums[i:i+3]), nums[i+3]) for i in range(0,len(nums)-4,3))
seqs
```
```py
(#21031) [(tensor([0, 1, 2]), 1),(tensor([1, 3, 1]), 4),(tensor([4, 1, 5]),
> 1),(tensor([1, 6, 1]), 7),(tensor([7, 1, 8]), 1),(tensor([1, 9, 1]),
> 10),(tensor([10, 1, 11]), 1),(tensor([ 1, 12, 1]), 13),(tensor([13, 1,
> 14]), 1),(tensor([ 1, 15, 1]), 16)...]
```
我们可以使用`DataLoader`类轻松地对这些进行批处理。现在,我们将随机拆分序列:
```py
bs = 64
cut = int(len(seqs) * 0.8)
dls = DataLoaders.from_dsets(seqs[:cut], seqs[cut:], bs=64, shuffle=False)
```
现在我们可以创建一个神经网络架构,它以三个单词作为输入,并返回词汇表中每个可能的下一个单词的概率预测。我们将使用三个标准线性层,但有两个调整。
第一个调整是,第一个线性层将仅使用第一个词的嵌入作为激活,第二层将使用第二个词的嵌入加上第一层的输出激活,第三层将使用第三个词的嵌入加上第二层的输出激活。关键效果是每个词都在其前面的任何单词的信息上下文中被解释。
第二个调整是,这三个层中的每一个将使用相同的权重矩阵。一个词对来自前面单词的激活的影响方式不应该取决于单词的位置。换句话说,激活值会随着数据通过层移动而改变,但是层权重本身不会从一层到另一层改变。因此,一个层不会学习一个序列位置;它必须学会处理所有位置。
由于层权重不会改变您可能会认为顺序层是“重复的相同层”。事实上PyTorch 使这一点具体化;我们可以创建一个层并多次使用它。
## 我们的 PyTorch 语言模型
我们现在可以创建我们之前描述的语言模型模块:
```py
class LMModel1(Module):
def __init__(self, vocab_sz, n_hidden):
self.i_h = nn.Embedding(vocab_sz, n_hidden)
self.h_h = nn.Linear(n_hidden, n_hidden)
self.h_o = nn.Linear(n_hidden,vocab_sz)
def forward(self, x):
h = F.relu(self.h_h(self.i_h(x[:,0])))
h = h + self.i_h(x[:,1])
h = F.relu(self.h_h(h))
h = h + self.i_h(x[:,2])
h = F.relu(self.h_h(h))
return self.h_o(h)
```
正如您所看到的,我们已经创建了三个层:
+ 嵌入层(`i_h`,表示 *输入**隐藏*
+ 线性层用于创建下一个单词的激活(`h_h`,表示 *隐藏**隐藏*
+ 一个最终的线性层来预测第四个单词(`h_o`,表示 *隐藏**输出*
这可能更容易以图示形式表示因此让我们定义一个基本神经网络的简单图示表示。图 12-1 显示了我们将如何用一个隐藏层表示神经网络。
![简单神经网络的图示表示](img/dlcf_1201.png)
###### 图 12-1。简单神经网络的图示表示
每个形状代表激活:矩形代表输入,圆圈代表隐藏(内部)层激活,三角形代表输出激活。我们将在本章中的所有图表中使用这些形状(在 图 12-2 中总结)。
![我们图示表示中使用的形状](img/dlcf_1202.png)
###### 图 12-2。我们图示表示中使用的形状
箭头代表实际的层计算——即线性层后跟激活函数。使用这种符号 12-3 显示了我们简单语言模型的外观。
![我们基本语言模型的表示](img/dlcf_1203.png)
###### 图 12-3。我们基本语言模型的表示
为了简化事情,我们已经从每个箭头中删除了层计算的细节。我们还对箭头进行了颜色编码,使所有具有相同颜色的箭头具有相同的权重矩阵。例如,所有输入层使用相同的嵌入矩阵,因此它们都具有相同的颜色(绿色)。
让我们尝试训练这个模型,看看效果如何:
```py
learn = Learner(dls, LMModel1(len(vocab), 64), loss_func=F.cross_entropy,
metrics=accuracy)
learn.fit_one_cycle(4, 1e-3)
```
| epoch | train_loss | valid_loss | accuracy | time |
| --- | --- | --- | --- | --- |
| 0 | 1.824297 | 1.970941 | 0.467554 | 00:02 |
| 1 | 1.386973 | 1.823242 | 0.467554 | 00:02 |
| 2 | 1.417556 | 1.654497 | 0.494414 | 00:02 |
| 3 | 1.376440 | 1.650849 | 0.494414 | 00:02 |
要查看这是否有效,请查看一个非常简单的模型会给我们什么结果。在这种情况下,我们总是可以预测最常见的标记,因此让我们找出在我们的验证集中最常见的目标是哪个标记:
```py
n,counts = 0,torch.zeros(len(vocab))
for x,y in dls.valid:
n += y.shape[0]
for i in range_of(vocab): counts[i] += (y==i).long().sum()
idx = torch.argmax(counts)
idx, vocab[idx.item()], counts[idx].item()/n
```
```py
(tensor(29), 'thousand', 0.15165200855716662)
```
最常见的标记的索引是 29对应于标记 `thousand`。总是预测这个标记将给我们大约 15% 的准确率,所以我们表现得更好!
# Alexis 说
我的第一个猜测是分隔符会是最常见的标记,因为每个数字都有一个分隔符。但查看 `tokens` 提醒我,大数字用许多单词写成,所以在通往 10,000 的路上你会经常写“thousand”five thousand, five thousand and one, five thousand and two 等等。糟糕!查看数据对于注意到微妙特征以及尴尬明显的特征都很有帮助。
这是一个不错的第一个基线。让我们看看如何用循环重构它。
## 我们的第一个循环神经网络
查看我们模块的代码,我们可以通过用 `for` 循环替换调用层的重复代码来简化它。除了使我们的代码更简单外,这样做的好处是我们将能够同样适用于不同长度的标记序列——我们不会被限制在长度为三的标记列表上:
```py
class LMModel2(Module):
def __init__(self, vocab_sz, n_hidden):
self.i_h = nn.Embedding(vocab_sz, n_hidden)
self.h_h = nn.Linear(n_hidden, n_hidden)
self.h_o = nn.Linear(n_hidden,vocab_sz)
def forward(self, x):
h = 0
for i in range(3):
h = h + self.i_h(x[:,i])
h = F.relu(self.h_h(h))
return self.h_o(h)
```
让我们检查一下,看看我们使用这种重构是否得到相同的结果:
```py
learn = Learner(dls, LMModel2(len(vocab), 64), loss_func=F.cross_entropy,
metrics=accuracy)
learn.fit_one_cycle(4, 1e-3)
```
| epoch | train_loss | valid_loss | accuracy | time |
| --- | --- | --- | --- | --- |
| 0 | 1.816274 | 1.964143 | 0.460185 | 00:02 |
| 1 | 1.423805 | 1.739964 | 0.473259 | 00:02 |
| 2 | 1.430327 | 1.685172 | 0.485382 | 00:02 |
| 3 | 1.388390 | 1.657033 | 0.470406 | 00:02 |
我们还可以以完全相同的方式重构我们的图示表示,如图 12-4 所示(这里我们也删除了激活大小的细节,并使用与图 12-3 相同的箭头颜色)。
![基本循环神经网络](img/dlcf_1204.png)
###### 图 12-4\. 基本循环神经网络
您将看到一组激活在每次循环中被更新,存储在变量`h`中—这被称为*隐藏状态*。
# 术语:隐藏状态
在循环神经网络的每一步中更新的激活。
使用这样的循环定义的神经网络称为*循环神经网络*RNN。重要的是要意识到 RNN 并不是一个复杂的新架构,而只是使用`for`循环对多层神经网络进行重构。
# Alexis 说
我的真实看法:如果它们被称为“循环神经网络”或 LNNs它们看起来会少恐怖 50%
现在我们知道了什么是 RNN让我们试着让它变得更好一点。
# 改进 RNN
观察我们的 RNN 代码,有一个看起来有问题的地方是,我们为每个新的输入序列将隐藏状态初始化为零。为什么这是个问题呢?我们将样本序列设置得很短,以便它们可以轻松地适应批处理。但是,如果我们正确地对这些样本进行排序,模型将按顺序读取样本序列,使模型暴露于原始序列的长时间段。
我们还可以考虑增加更多信号:为什么只预测第四个单词,而不使用中间预测来预测第二和第三个单词呢?让我们看看如何实现这些变化,首先从添加一些状态开始。
## 维护 RNN 的状态
因为我们为每个新样本将模型的隐藏状态初始化为零,这样我们就丢失了关于迄今为止看到的句子的所有信息,这意味着我们的模型实际上不知道我们在整体计数序列中的进度。这很容易修复;我们只需将隐藏状态的初始化移动到`__init__`中。
但是,这种修复方法将产生自己微妙但重要的问题。它实际上使我们的神经网络变得和文档中的令牌数量一样多。例如,如果我们的数据集中有 10,000 个令牌,我们将创建一个有 10,000 层的神经网络。
要了解为什么会出现这种情况,请考虑我们循环神经网络的原始图示表示,即在图 12-3 中,在使用`for`循环重构之前。您可以看到每个层对应一个令牌输入。当我们谈论使用`for`循环重构之前的循环神经网络的表示时,我们称之为*展开表示*。在尝试理解 RNN 时,考虑展开表示通常是有帮助的。
10,000 层神经网络的问题在于,当您到达数据集的第 10,000 个单词时,您仍然需要计算直到第一层的所有导数。这将非常缓慢,且占用内存。您可能无法在 GPU 上存储一个小批量。
解决这个问题的方法是告诉 PyTorch 我们不希望通过整个隐式神经网络反向传播导数。相反,我们将保留梯度的最后三层。为了在 PyTorch 中删除所有梯度历史,我们使用`detach`方法。
这是我们 RNN 的新版本。现在它是有状态的,因为它在不同调用`forward`时记住了其激活,这代表了它在批处理中用于不同样本的情况:
```py
class LMModel3(Module):
def __init__(self, vocab_sz, n_hidden):
self.i_h = nn.Embedding(vocab_sz, n_hidden)
self.h_h = nn.Linear(n_hidden, n_hidden)
self.h_o = nn.Linear(n_hidden,vocab_sz)
self.h = 0
def forward(self, x):
for i in range(3):
self.h = self.h + self.i_h(x[:,i])
self.h = F.relu(self.h_h(self.h))
out = self.h_o(self.h)
self.h = self.h.detach()
return out
def reset(self): self.h = 0
```
无论我们选择什么序列长度,这个模型将具有相同的激活,因为隐藏状态将记住上一批次的最后激活。唯一不同的是在每一步计算的梯度:它们将仅在过去的序列长度标记上计算,而不是整个流。这种方法称为*时间穿梭反向传播*BPTT
# 术语:时间穿梭反向传播
将一个神经网络有效地视为每个时间步长一个层(通常使用循环重构),并以通常的方式在其上计算梯度。为了避免内存和时间不足,我们通常使用*截断* BPTT每隔几个时间步“分离”隐藏状态的计算历史。
要使用`LMModel3`,我们需要确保样本按照一定顺序进行查看。正如我们在第十章中看到的,如果第一批的第一行是我们的`dset[0]`,那么第二批应该将`dset[1]`作为第一行,以便模型看到文本流动。
`LMDataLoader`在第十章中为我们做到了这一点。这次我们要自己做。
为此,我们将重新排列我们的数据集。首先,我们将样本分成`m = len(dset) // bs`组这相当于将整个连接数据集分成例如64 个大小相等的部分,因为我们在这里使用`bs=64`)。`m`是每个这些部分的长度。例如,如果我们使用整个数据集(尽管我们实际上将在一会儿将其分成训练和验证),我们有:
```py
m = len(seqs)//bs
m,bs,len(seqs)
```
```py
(328, 64, 21031)
```
第一批将由样本组成
```py
(0, m, 2*m, ..., (bs-1)*m)
```
样本的第二批
```py
(1, m+1, 2*m+1, ..., (bs-1)*m+1)
```
等等。这样,每个时期,模型将在每批次的每行上看到大小为`3*m`的连续文本块(因为每个文本的大小为 3
以下函数执行重新索引:
```py
def group_chunks(ds, bs):
m = len(ds) // bs
new_ds = L()
for i in range(m): new_ds += L(ds[i + m*j] for j in range(bs))
return new_ds
```
然后,我们在构建`DataLoaders`时只需传递`drop_last=True`来删除最后一个形状不为`bs`的批次。我们还传递`shuffle=False`以确保文本按顺序阅读:
```py
cut = int(len(seqs) * 0.8)
dls = DataLoaders.from_dsets(
group_chunks(seqs[:cut], bs),
group_chunks(seqs[cut:], bs),
bs=bs, drop_last=True, shuffle=False)
```
我们添加的最后一件事是通过`Callback`对训练循环进行微调。我们将在第十六章中更多地讨论回调;这个回调将在每个时期的开始和每个验证阶段之前调用我们模型的`reset`方法。由于我们实现了该方法来将模型的隐藏状态设置为零,这将确保我们在阅读这些连续文本块之前以干净的状态开始。我们也可以开始训练更长一点:
```py
learn = Learner(dls, LMModel3(len(vocab), 64), loss_func=F.cross_entropy,
metrics=accuracy, cbs=ModelResetter)
learn.fit_one_cycle(10, 3e-3)
```
| epoch | train_loss | valid_loss | accuracy | time |
| --- | --- | --- | --- | --- |
| 0 | 1.677074 | 1.827367 | 0.467548 | 00:02 |
| 1 | 1.282722 | 1.870913 | 0.388942 | 00:02 |
| 2 | 1.090705 | 1.651793 | 0.462500 | 00:02 |
| 3 | 1.005092 | 1.613794 | 0.516587 | 00:02 |
| 4 | 0.965975 | 1.560775 | 0.551202 | 00:02 |
| 5 | 0.916182 | 1.595857 | 0.560577 | 00:02 |
| 6 | 0.897657 | 1.539733 | 0.574279 | 00:02 |
| 7 | 0.836274 | 1.585141 | 0.583173 | 00:02 |
| 8 | 0.805877 | 1.629808 | 0.586779 | 00:02 |
| 9 | 0.795096 | 1.651267 | 0.588942 | 00:02 |
这已经更好了!下一步是使用更多目标并将它们与中间预测进行比较。
## 创建更多信号
我们当前方法的另一个问题是,我们仅为每三个输入单词预测一个输出单词。因此,我们反馈以更新权重的信号量不如可能的那么大。如果我们在每个单词后预测下一个单词,而不是每三个单词,将会更好,如图 12-5 所示。
![RNN 在每个标记后进行预测](img/dlcf_1205.png)
###### 图 12-5。RNN 在每个标记后进行预测
这很容易添加。我们需要首先改变我们的数据,使得因变量在每个三个输入词后的每个三个词中都有。我们使用一个属性`sl`(用于序列长度),并使其稍微变大:
```py
sl = 16
seqs = L((tensor(nums[i:i+sl]), tensor(nums[i+1:i+sl+1]))
for i in range(0,len(nums)-sl-1,sl))
cut = int(len(seqs) * 0.8)
dls = DataLoaders.from_dsets(group_chunks(seqs[:cut], bs),
group_chunks(seqs[cut:], bs),
bs=bs, drop_last=True, shuffle=False)
```
查看`seqs`的第一个元素,我们可以看到它包含两个相同大小的列表。第二个列表与第一个相同,但偏移了一个元素:
```py
[L(vocab[o] for o in s) for s in seqs[0]]
```
```py
[(#16) ['one','.','two','.','three','.','four','.','five','.'...],
(#16) ['.','two','.','three','.','four','.','five','.','six'...]]
```
现在我们需要修改我们的模型,使其在每个单词之后输出一个预测,而不仅仅是在一个三个词序列的末尾:
```py
class LMModel4(Module):
def __init__(self, vocab_sz, n_hidden):
self.i_h = nn.Embedding(vocab_sz, n_hidden)
self.h_h = nn.Linear(n_hidden, n_hidden)
self.h_o = nn.Linear(n_hidden,vocab_sz)
self.h = 0
def forward(self, x):
outs = []
for i in range(sl):
self.h = self.h + self.i_h(x[:,i])
self.h = F.relu(self.h_h(self.h))
outs.append(self.h_o(self.h))
self.h = self.h.detach()
return torch.stack(outs, dim=1)
def reset(self): self.h = 0
```
这个模型将返回形状为`bs x sl x vocab_sz`的输出(因为我们在`dim=1`上堆叠)。我们的目标的形状是`bs x sl`,所以在使用`F.cross_entropy`之前,我们需要将它们展平:
```py
def loss_func(inp, targ):
return F.cross_entropy(inp.view(-1, len(vocab)), targ.view(-1))
```
我们现在可以使用这个损失函数来训练模型:
```py
learn = Learner(dls, LMModel4(len(vocab), 64), loss_func=loss_func,
metrics=accuracy, cbs=ModelResetter)
learn.fit_one_cycle(15, 3e-3)
```
| epoch | train_loss | valid_loss | accuracy | time |
| --- | --- | --- | --- | --- |
| 0 | 3.103298 | 2.874341 | 0.212565 | 00:01 |
| 1 | 2.231964 | 1.971280 | 0.462158 | 00:01 |
| 2 | 1.711358 | 1.813547 | 0.461182 | 00:01 |
| 3 | 1.448516 | 1.828176 | 0.483236 | 00:01 |
| 4 | 1.288630 | 1.659564 | 0.520671 | 00:01 |
| 5 | 1.161470 | 1.714023 | 0.554932 | 00:01 |
| 6 | 1.055568 | 1.660916 | 0.575033 | 00:01 |
| 7 | 0.960765 | 1.719624 | 0.591064 | 00:01 |
| 8 | 0.870153 | 1.839560 | 0.614665 | 00:01 |
| 9 | 0.808545 | 1.770278 | 0.624349 | 00:01 |
| 10 | 0.758084 | 1.842931 | 0.610758 | 00:01 |
| 11 | 0.719320 | 1.799527 | 0.646566 | 00:01 |
| 12 | 0.683439 | 1.917928 | 0.649821 | 00:01 |
| 13 | 0.660283 | 1.874712 | 0.628581 | 00:01 |
| 14 | 0.646154 | 1.877519 | 0.640055 | 00:01 |
我们需要训练更长时间,因为任务有点变化,现在更加复杂。但我们最终得到了一个好结果...至少有时候是这样。如果你多次运行它,你会发现在不同的运行中可以得到非常不同的结果。这是因为实际上我们在这里有一个非常深的网络,这可能导致非常大或非常小的梯度。我们将在本章的下一部分看到如何处理这个问题。
现在,获得更好模型的明显方法是加深:在我们基本的 RNN 中,隐藏状态和输出激活之间只有一个线性层,所以也许我们用更多的线性层会得到更好的结果。
# 多层 RNNs
在多层 RNN 中,我们将来自我们递归神经网络的激活传递到第二个递归神经网络中,就像图 12-6 中所示。
![2 层 RNN](img/dlcf_1206.png)
###### 图 12-6\. 2 层 RNN
展开的表示在图 12-7 中显示(类似于图 12-3
![2 层展开的 RNN](img/dlcf_1207.png)
###### 图 12-7\. 2 层展开的 RNN
让我们看看如何在实践中实现这一点。
## 模型
我们可以通过使用 PyTorch 的`RNN`类来节省一些时间,该类实现了我们之前创建的内容,但也给了我们堆叠多个 RNN 的选项,正如我们之前讨论的那样:
```py
class LMModel5(Module):
def __init__(self, vocab_sz, n_hidden, n_layers):
self.i_h = nn.Embedding(vocab_sz, n_hidden)
self.rnn = nn.RNN(n_hidden, n_hidden, n_layers, batch_first=True)
self.h_o = nn.Linear(n_hidden, vocab_sz)
self.h = torch.zeros(n_layers, bs, n_hidden)
def forward(self, x):
res,h = self.rnn(self.i_h(x), self.h)
self.h = h.detach()
return self.h_o(res)
def reset(self): self.h.zero_()
```
```py
learn = Learner(dls, LMModel5(len(vocab), 64, 2),
loss_func=CrossEntropyLossFlat(),
metrics=accuracy, cbs=ModelResetter)
learn.fit_one_cycle(15, 3e-3)
```
| epoch | train_loss | valid_loss | accuracy | time |
| --- | --- | --- | --- | --- |
| 0 | 3.055853 | 2.591640 | 0.437907 | 00:01 |
| 1 | 2.162359 | 1.787310 | 0.471598 | 00:01 |
| 2 | 1.710663 | 1.941807 | 0.321777 | 00:01 |
| 3 | 1.520783 | 1.999726 | 0.312012 | 00:01 |
| 4 | 1.330846 | 2.012902 | 0.413249 | 00:01 |
| 5 | 1.163297 | 1.896192 | 0.450684 | 00:01 |
| 6 | 1.033813 | 2.005209 | 0.434814 | 00:01 |
| 7 | 0.919090 | 2.047083 | 0.456706 | 00:01 |
| 8 | 0.822939 | 2.068031 | 0.468831 | 00:01 |
| 9 | 0.750180 | 2.136064 | 0.475098 | 00:01 |
| 10 | 0.695120 | 2.139140 | 0.485433 | 00:01 |
| 11 | 0.655752 | 2.155081 | 0.493652 | 00:01 |
| 12 | 0.629650 | 2.162583 | 0.498535 | 00:01 |
| 13 | 0.613583 | 2.171649 | 0.491048 | 00:01 |
| 14 | 0.604309 | 2.180355 | 0.487874 | 00:01 |
现在这令人失望...我们之前的单层 RNN 表现更好。为什么?原因是我们有一个更深的模型,导致激活爆炸或消失。
## 激活爆炸或消失
在实践中,从这种类型的 RNN 创建准确的模型是困难的。如果我们调用`detach`的频率较少,并且有更多的层,我们将获得更好的结果 - 这使得我们的 RNN 有更长的时间跨度来学习和创建更丰富的特征。但这也意味着我们有一个更深的模型要训练。深度学习发展中的关键挑战是如何训练这种类型的模型。
这是具有挑战性的,因为当您多次乘以一个矩阵时会发生什么。想想当您多次乘以一个数字时会发生什么。例如,如果您从 1 开始乘以 2您会得到序列 1、2、4、8...在 32 步之后,您已经达到 4,294,967,296。如果您乘以 0.5,类似的问题会发生:您会得到 0.5、0.25、0.125...在 32 步之后,它是 0.00000000023。正如您所看到的,即使是比 1 稍高或稍低的数字,经过几次重复乘法后,我们的起始数字就会爆炸或消失。
因为矩阵乘法只是将数字相乘并将它们相加,重复矩阵乘法会发生完全相同的事情。这就是深度神经网络的全部内容 - 每一层都是另一个矩阵乘法。这意味着深度神经网络很容易最终得到极大或极小的数字。
这是一个问题,因为计算机存储数字的方式(称为*浮点数*)意味着随着数字远离零点,它们变得越来越不准确。来自优秀文章“关于浮点数你从未想知道但却被迫了解”的图 12-8 中的图表显示了浮点数的精度如何随着数字线变化。
![浮点数的精度](img/dlcf_1208.png)
###### 图 12-8。浮点数的精度
这种不准确性意味着通常为更新权重计算的梯度最终会变为零或无穷大。这通常被称为*消失梯度*或*爆炸梯度*问题。这意味着在 SGD 中,权重要么根本不更新,要么跳到无穷大。无论哪种方式,它们都不会随着训练而改善。
研究人员已经开发出了解决这个问题的方法,我们将在本书后面讨论。一种选择是改变层的定义方式,使其不太可能出现激活爆炸。当我们讨论批量归一化时,我们将在第十三章中看到这是如何完成的,当我们讨论 ResNets 时,我们将在第十四章中看到,尽管这些细节通常在实践中并不重要(除非您是一个研究人员,正在创造解决这个问题的新方法)。另一种处理这个问题的策略是谨慎初始化,这是我们将在第十七章中调查的一个主题。
为了避免激活爆炸RNN 经常使用两种类型的层:*门控循环单元*GRUs和*长短期记忆*LSTM层。这两种都在 PyTorch 中可用,并且可以直接替换 RNN 层。在本书中,我们只会涵盖 LSTMs在线上有很多好的教程解释 GRUs它们是 LSTM 设计的一个小变体。
# LSTM
LSTM 是由 Jürgen Schmidhuber 和 Sepp Hochreiter 于 1997 年引入的一种架构。在这种架构中,不是一个,而是两个隐藏状态。在我们的基本 RNN 中,隐藏状态是 RNN 在上一个时间步的输出。那个隐藏状态负责两件事:
+ 拥有正确的信息来预测正确的下一个标记的输出层
+ 保留句子中发生的一切记忆
例如考虑句子“Henry has a dog and he likes his dog very much”和“Sophie has a dog and she likes her dog very much。”很明显RNN 需要记住句子开头的名字才能预测*he/she*或*his/her*。
在实践中RNN 在保留句子中较早发生的记忆方面表现非常糟糕,这就是在 LSTM 中有另一个隐藏状态(称为*cell state*的动机。cell state 将负责保持*长期短期记忆*,而隐藏状态将专注于预测下一个标记。让我们更仔细地看看如何实现这一点,并从头开始构建一个 LSTM。
## 从头开始构建一个 LSTM
为了构建一个 LSTM我们首先必须了解其架构。图 12-9 显示了其内部结构。
![显示 LSTM 内部架构的图表](img/dlcf_1209.png)
###### 图 12-9\. LSTM 的架构
在这张图片中,我们的输入<math alttext="x Subscript t"><msub><mi>x</mi> <mi>t</mi></msub></math>从左侧进入,带有先前的隐藏状态(<math alttext="h Subscript t minus 1"><msub><mi>h</mi> <mrow><mi>t</mi><mo>-</mo><mn>1</mn></mrow></msub></math>)和 cell state<math alttext="c Subscript t minus 1"><msub><mi>c</mi> <mrow><mi>t</mi><mo>-</mo><mn>1</mn></mrow></msub></math>)。四个橙色框代表四个层(我们的神经网络),激活函数可以是 sigmoid<math alttext="sigma"><mi>σ</mi></math>)或 tanh。tanh 只是一个重新缩放到范围-1 到 1 的 sigmoid 函数。它的数学表达式可以写成这样:
<math alttext="双曲正切左括号 x 右括号等于开始分数 e 上标 x 基线加 e 上标 负 x 基线 除以 e 上标 x 基线减 e 上标 负 x 基线 等于 2 sigma 左括号 2 x 右括号 减 1" display="block"><mrow><mo form="prefix">tanh</mo> <mrow><mo>(</mo> <mi>x</mi> <mo>)</mo></mrow> <mo>=</mo> <mfrac><mrow><msup><mi>e</mi> <mi>x</mi></msup> <mo>+</mo><msup><mi>e</mi> <mrow><mo>-</mo><mi>x</mi></mrow></msup></mrow> <mrow><msup><mi>e</mi> <mi>x</mi></msup> <mo>-</mo><msup><mi>e</mi> <mrow><mo>-</mo><mi>x</mi></mrow></msup></mrow></mfrac> <mo>=</mo> <mn>2</mn> <mi>σ</mi> <mrow><mo>(</mo> <mn>2</mn> <mi>x</mi> <mo>)</mo></mrow> <mo>-</mo> <mn>1</mn></mrow></math>
其中<math alttext="sigma"><mi>σ</mi></math>是 sigmoid 函数。图中的绿色圆圈是逐元素操作。右侧输出的是新的隐藏状态(<math alttext="h Subscript t"><msub><mi>h</mi> <mi>t</mi></msub></math>)和新的 cell state<math alttext="c Subscript t"><msub><mi>c</mi> <mi>t</mi></msub></math>),准备接受我们的下一个输入。新的隐藏状态也被用作输出,这就是为什么箭头分开向上移动。
让我们逐一查看四个神经网络(称为*门*)并解释图表——但在此之前,请注意 cell state顶部几乎没有改变。它甚至没有直接通过神经网络这正是为什么它将继续保持较长期的状态。
首先,将输入和旧隐藏状态的箭头连接在一起。在本章前面编写的 RNN 中,我们将它们相加。在 LSTM 中,我们将它们堆叠在一个大张量中。这意味着我们的嵌入的维度(即<math alttext="x Subscript t"><msub><mi>x</mi> <mi>t</mi></msub></math>的维度)可以与隐藏状态的维度不同。如果我们将它们称为`n_in`和`n_hid`,底部的箭头大小为`n_in + n_hid`;因此所有的神经网络(橙色框)都是具有`n_in + n_hid`输入和`n_hid`输出的线性层。
第一个门(从左到右看)称为*遗忘门*。由于它是一个线性层后面跟着一个 sigmoid它的输出将由 0 到 1 之间的标量组成。我们将这个结果乘以细胞状态,以确定要保留哪些信息,要丢弃哪些信息:接近 0 的值被丢弃,接近 1 的值被保留。这使得 LSTM 有能力忘记关于其长期状态的事情。例如,当穿过一个句号或一个`xxbos`标记时,我们期望它(已经学会)重置其细胞状态。
第二个门称为*输入门*。它与第三个门(没有真正的名称,但有时被称为*细胞门*)一起更新细胞状态。例如,我们可能看到一个新的性别代词,这时我们需要替换遗忘门删除的关于性别的信息。与遗忘门类似,输入门决定要更新的细胞状态元素(接近 1 的值)或不更新(接近 0 的值)。第三个门确定这些更新值是什么,范围在-1 到 1 之间(由于 tanh 函数)。结果被添加到细胞状态中。
最后一个门是*输出门*。它确定从细胞状态中使用哪些信息来生成输出。细胞状态经过 tanh 后与输出门的 sigmoid 输出结合,结果就是新的隐藏状态。在代码方面,我们可以这样写相同的步骤:
```py
class LSTMCell(Module):
def __init__(self, ni, nh):
self.forget_gate = nn.Linear(ni + nh, nh)
self.input_gate = nn.Linear(ni + nh, nh)
self.cell_gate = nn.Linear(ni + nh, nh)
self.output_gate = nn.Linear(ni + nh, nh)
def forward(self, input, state):
h,c = state
h = torch.stack([h, input], dim=1)
forget = torch.sigmoid(self.forget_gate(h))
c = c * forget
inp = torch.sigmoid(self.input_gate(h))
cell = torch.tanh(self.cell_gate(h))
c = c + inp * cell
out = torch.sigmoid(self.output_gate(h))
h = outgate * torch.tanh(c)
return h, (h,c)
```
实际上,我们可以重构代码。此外,就性能而言,做一次大矩阵乘法比做四次小矩阵乘法更好(因为我们只在 GPU 上启动一次特殊的快速内核,这样可以让 GPU 并行处理更多工作)。堆叠需要一点时间(因为我们必须在 GPU 上移动一个张量,使其全部在一个连续的数组中),所以我们为输入和隐藏状态使用两个单独的层。优化和重构后的代码如下:
```py
class LSTMCell(Module):
def __init__(self, ni, nh):
self.ih = nn.Linear(ni,4*nh)
self.hh = nn.Linear(nh,4*nh)
def forward(self, input, state):
h,c = state
# One big multiplication for all the gates is better than 4 smaller ones
gates = (self.ih(input) + self.hh(h)).chunk(4, 1)
ingate,forgetgate,outgate = map(torch.sigmoid, gates[:3])
cellgate = gates[3].tanh()
c = (forgetgate*c) + (ingate*cellgate)
h = outgate * c.tanh()
return h, (h,c)
```
在这里,我们使用 PyTorch 的`chunk`方法将张量分成四部分。它的工作原理如下:
```py
t = torch.arange(0,10); t
```
```py
tensor([0, 1, 2, 3, 4, 5, 6, 7, 8, 9])
```
```py
t.chunk(2)
```
```py
(tensor([0, 1, 2, 3, 4]), tensor([5, 6, 7, 8, 9]))
```
现在让我们使用这个架构来训练一个语言模型!
## 使用 LSTMs 训练语言模型
这是与`LMModel5`相同的网络,使用了两层 LSTM。我们可以以更高的学习率进行训练时间更短获得更好的准确性
```py
class LMModel6(Module):
def __init__(self, vocab_sz, n_hidden, n_layers):
self.i_h = nn.Embedding(vocab_sz, n_hidden)
self.rnn = nn.LSTM(n_hidden, n_hidden, n_layers, batch_first=True)
self.h_o = nn.Linear(n_hidden, vocab_sz)
self.h = [torch.zeros(n_layers, bs, n_hidden) for _ in range(2)]
def forward(self, x):
res,h = self.rnn(self.i_h(x), self.h)
self.h = [h_.detach() for h_ in h]
return self.h_o(res)
def reset(self):
for h in self.h: h.zero_()
```
```py
learn = Learner(dls, LMModel6(len(vocab), 64, 2),
loss_func=CrossEntropyLossFlat(),
metrics=accuracy, cbs=ModelResetter)
learn.fit_one_cycle(15, 1e-2)
```
| epoch | train_loss | valid_loss | accuracy | time |
| --- | --- | --- | --- | --- |
| 0 | 3.000821 | 2.663942 | 0.438314 | 00:02 |
| 1 | 2.139642 | 2.184780 | 0.240479 | 00:02 |
| 2 | 1.607275 | 1.812682 | 0.439779 | 00:02 |
| 3 | 1.347711 | 1.830982 | 0.497477 | 00:02 |
| 4 | 1.123113 | 1.937766 | 0.594401 | 00:02 |
| 5 | 0.852042 | 2.012127 | 0.631592 | 00:02 |
| 6 | 0.565494 | 1.312742 | 0.725749 | 00:02 |
| 7 | 0.347445 | 1.297934 | 0.711263 | 00:02 |
| 8 | 0.208191 | 1.441269 | 0.731201 | 00:02 |
| 9 | 0.126335 | 1.569952 | 0.737305 | 00:02 |
| 10 | 0.079761 | 1.427187 | 0.754150 | 00:02 |
| 11 | 0.052990 | 1.494990 | 0.745117 | 00:02 |
| 12 | 0.039008 | 1.393731 | 0.757894 | 00:02 |
| 13 | 0.031502 | 1.373210 | 0.758464 | 00:02 |
| 14 | 0.028068 | 1.368083 | 0.758464 | 00:02 |
现在这比多层 RNN 好多了!然而,我们仍然可以看到有一点过拟合,这表明一点正则化可能会有所帮助。
# 正则化 LSTM
循环神经网络总体上很难训练,因为我们之前看到的激活和梯度消失问题。使用 LSTM或 GRU单元比使用普通 RNN 更容易训练,但它们仍然很容易过拟合。数据增强虽然是一种可能性,但在文本数据中使用得比图像数据少,因为在大多数情况下,它需要另一个模型来生成随机增强(例如,将文本翻译成另一种语言,然后再翻译回原始语言)。总的来说,目前文本数据的数据增强并不是一个被充分探索的领域。
然而,我们可以使用其他正则化技术来减少过拟合,这些技术在与 LSTMs 一起使用时进行了深入研究,如 Stephen Merity 等人的论文[“正则化和优化 LSTM 语言模型”](https://oreil.ly/Rf-OG)。这篇论文展示了如何有效地使用 dropout、激活正则化和时间激活正则化可以使一个 LSTM 击败以前需要更复杂模型的最新结果。作者将使用这些技术的 LSTM 称为*AWD-LSTM*。我们将依次看看这些技术。
## Dropout
*Dropout*是由 Geoffrey Hinton 等人在[“通过防止特征探测器的共适应来改进神经网络”](https://oreil.ly/-_xie)中引入的一种正则化技术。基本思想是在训练时随机将一些激活变为零。这确保所有神经元都积极地朝着输出工作,如图 12-10 所示(来自 Nitish Srivastava 等人的[“Dropout防止神经网络过拟合的简单方法”](https://oreil.ly/pYNxF))。
![文章中显示 dropout 如何关闭神经元的图](img/dlcf_1210.png)
###### 图 12-10。在神经网络中应用 dropout由 Nitish Srivastava 等人提供)
Hinton 在一次采访中解释了 dropout 的灵感时使用了一个很好的比喻:
> 我去了我的银行。出纳员不断变换,我问其中一个原因。他说他不知道,但他们经常被调动。我想这一定是因为需要员工之间的合作才能成功欺诈银行。这让我意识到,随机在每个示例中删除不同的神经元子集将防止阴谋,从而减少过拟合。
在同一次采访中,他还解释了神经科学提供了额外的灵感:
> 我们并不真正知道为什么神经元会突触。有一种理论是它们想要变得嘈杂以进行正则化因为我们的参数比数据点多得多。dropout 的想法是,如果你有嘈杂的激活,你可以承担使用一个更大的模型。
这解释了为什么 dropout 有助于泛化的想法:首先它帮助神经元更好地合作;然后它使激活更嘈杂,从而使模型更健壮。
然而,我们可以看到,如果我们只是将这些激活置零而不做其他任何操作,我们的模型将会训练出问题:如果我们从五个激活的总和(由于我们应用了 ReLU它们都是正数变为只有两个这不会有相同的规模。因此如果我们以概率`p`应用 dropout我们通过将所有激活除以`1-p`来重新缩放它们(平均`p`将被置零,所以剩下`1-p`),如图 12-11 所示。
![介绍 dropout 的文章中的一个图,显示神经元是开启/关闭状态](img/dlcf_1211.png)
###### 图 12-11。应用 dropout 时为什么要缩放激活(由 Nitish Srivastava 等人提供)
这是 PyTorch 中 dropout 层的完整实现(尽管 PyTorch 的原生层实际上是用 C 而不是 Python 编写的):
```py
class Dropout(Module):
def __init__(self, p): self.p = p
def forward(self, x):
if not self.training: return x
mask = x.new(*x.shape).bernoulli_(1-p)
return x * mask.div_(1-p)
```
`bernoulli_`方法创建一个随机零(概率为`p`)和一(概率为`1-p`)的张量,然后将其乘以我们的输入,再除以`1-p`。注意`training`属性的使用,它在任何 PyTorch `nn.Module`中都可用,并告诉我们是否在训练或推理。
# 做你自己的实验
在本书的前几章中,我们会在这里添加一个`bernoulli_`的代码示例,这样您就可以看到它的确切工作原理。但是现在您已经了解足够多,可以自己做这个,我们将为您提供越来越少的示例,而是期望您自己进行实验以了解事物是如何工作的。在这种情况下,您将在章节末尾的问卷中看到,我们要求您尝试使用`bernoulli_`,但不要等到我们要求您进行实验才开发您对我们正在研究的代码的理解;无论如何都可以开始做。
在将我们的 LSTM 的输出传递到最终层之前使用 dropout 将有助于减少过拟合。在许多其他模型中也使用了 dropout包括`fastai.vision`中使用的默认 CNN 头部,并且通过传递`ps`参数其中每个“p”都传递给每个添加的`Dropout`层)在`fastai.tabular`中也可用,正如我们将在第十五章中看到的。
在训练和验证模式下dropout 的行为不同,我们使用`Dropout`中的`training`属性进行指定。在`Module`上调用`train`方法会将`training`设置为`True`(对于您调用该方法的模块以及递归包含的每个模块),而`eval`将其设置为`False`。在调用`Learner`的方法时会自动执行此操作,但如果您没有使用该类,请记住根据需要在两者之间切换。
## 激活正则化和时间激活正则化
激活正则化AR和时间激活正则化TAR是两种与权重衰减非常相似的正则化方法在第八章中讨论过。在应用权重衰减时我们会对损失添加一个小的惩罚旨在使权重尽可能小。对于激活正则化我们将尝试使 LSTM 生成的最终激活尽可能小,而不是权重。
为了对最终激活进行正则化,我们必须将它们存储在某个地方,然后将它们的平方的平均值添加到损失中(以及一个乘数`alpha`,就像权重衰减的`wd`一样):
```py
loss += alpha * activations.pow(2).mean()
```
时间激活正则化与我们在句子中预测标记有关。这意味着当我们按顺序阅读它们时,我们的 LSTM 的输出应该在某种程度上是有意义的。TAR 通过向损失添加惩罚来鼓励这种行为,使两个连续激活之间的差异尽可能小:我们的激活张量的形状为`bs x sl x n_hid`我们在序列长度轴上中间维度读取连续激活。有了这个TAR 可以表示如下:
```py
loss += beta * (activations[:,1:] - activations[:,:-1]).pow(2).mean()
```
然后,`alpha`和`beta`是要调整的两个超参数。为了使这项工作成功,我们需要让我们的带有 dropout 的模型返回三个东西正确的输出LSTM 在 dropout 之前的激活以及 LSTM 在 dropout 之后的激活。通常在 dropout 后的激活上应用 AR以免惩罚我们之后转换为零的激活而 TAR 应用在未经 dropout 的激活上(因为这些零会在两个连续时间步之间产生很大的差异)。然后,一个名为`RNNRegularizer`的回调将为我们应用这种正则化。
## 训练带有权重绑定的正则化 LSTM
我们可以将 dropout应用在我们进入输出层之前与 AR 和 TAR 相结合,以训练我们之前的 LSTM。我们只需要返回三个东西而不是一个我们的 LSTM 的正常输出dropout 后的激活以及我们的 LSTM 的激活。最后两个将由回调`RNNRegularization`捕获,以便为其对损失的贡献做出贡献。
我们可以从[AWD-LSTM 论文](https://oreil.ly/ETQ5X)中添加另一个有用的技巧是*权重绑定*。在语言模型中,输入嵌入表示从英语单词到激活的映射,输出隐藏层表示从激活到英语单词的映射。直觉上,我们可能会期望这些映射是相同的。我们可以通过将相同的权重矩阵分配给这些层来在 PyTorch 中表示这一点:
```py
self.h_o.weight = self.i_h.weight
```
在`LMMModel7`中,我们包括了这些最终的调整:
```py
class LMModel7(Module):
def __init__(self, vocab_sz, n_hidden, n_layers, p):
self.i_h = nn.Embedding(vocab_sz, n_hidden)
self.rnn = nn.LSTM(n_hidden, n_hidden, n_layers, batch_first=True)
self.drop = nn.Dropout(p)
self.h_o = nn.Linear(n_hidden, vocab_sz)
self.h_o.weight = self.i_h.weight
self.h = [torch.zeros(n_layers, bs, n_hidden) for _ in range(2)]
def forward(self, x):
raw,h = self.rnn(self.i_h(x), self.h)
out = self.drop(raw)
self.h = [h_.detach() for h_ in h]
return self.h_o(out),raw,out
def reset(self):
for h in self.h: h.zero_()
```
我们可以使用`RNNRegularizer`回调函数创建一个正则化的`Learner`
```py
learn = Learner(dls, LMModel7(len(vocab), 64, 2, 0.5),
loss_func=CrossEntropyLossFlat(), metrics=accuracy,
cbs=[ModelResetter, RNNRegularizer(alpha=2, beta=1)])
```
`TextLearner`会自动为我们添加这两个回调函数(使用`alpha`和`beta`的默认值),因此我们可以简化前面的行:
```py
learn = TextLearner(dls, LMModel7(len(vocab), 64, 2, 0.4),
loss_func=CrossEntropyLossFlat(), metrics=accuracy)
```
然后我们可以训练模型,并通过增加权重衰减到`0.1`来添加额外的正则化:
```py
learn.fit_one_cycle(15, 1e-2, wd=0.1)
```
| epoch | train_loss | valid_loss | accuracy | time |
| --- | --- | --- | --- | --- |
| 0 | 2.693885 | 2.013484 | 0.466634 | 00:02 |
| 1 | 1.685549 | 1.187310 | 0.629313 | 00:02 |
| 2 | 0.973307 | 0.791398 | 0.745605 | 00:02 |
| 3 | 0.555823 | 0.640412 | 0.794108 | 00:02 |
| 4 | 0.351802 | 0.557247 | 0.836100 | 00:02 |
| 5 | 0.244986 | 0.594977 | 0.807292 | 00:02 |
| 6 | 0.192231 | 0.511690 | 0.846761 | 00:02 |
| 7 | 0.162456 | 0.520370 | 0.858073 | 00:02 |
| 8 | 0.142664 | 0.525918 | 0.842285 | 00:02 |
| 9 | 0.128493 | 0.495029 | 0.858073 | 00:02 |
| 10 | 0.117589 | 0.464236 | 0.867188 | 00:02 |
| 11 | 0.109808 | 0.466550 | 0.869303 | 00:02 |
| 12 | 0.104216 | 0.455151 | 0.871826 | 00:02 |
| 13 | 0.100271 | 0.452659 | 0.873617 | 00:02 |
| 14 | 0.098121 | 0.458372 | 0.869385 | 00:02 |
现在这比我们之前的模型好多了!
# 结论
您现在已经看到了我们在第十章中用于文本分类的 AWD-LSTM 架构内部的所有内容。它在更多地方使用了丢失:
+ 嵌入丢失(就在嵌入层之后)
+ 输入丢失(在嵌入层之后)
+ 权重丢失(应用于每个训练步骤中 LSTM 的权重)
+ 隐藏丢失(应用于两个层之间的隐藏状态)
这使得它更加规范化。由于微调这五个丢失值(包括输出层之前的丢失)很复杂,我们已经确定了良好的默认值,并允许通过您在该章节中看到的`drop_mult`参数来整体调整丢失的大小。
另一个非常强大的架构,特别适用于“序列到序列”问题(依赖变量本身是一个变长序列的问题,例如语言翻译),是 Transformer 架构。您可以在[书籍网站](https://book.fast.ai)的额外章节中找到它。
# 问卷
1. 如果您的项目数据集非常庞大且复杂,处理它需要大量时间,您应该怎么做?
1. 为什么在创建语言模型之前我们要将数据集中的文档连接起来?
1. 要使用标准的全连接网络来预测前三个单词给出的第四个单词,我们需要对模型进行哪两个调整?
1. 我们如何在 PyTorch 中跨多个层共享权重矩阵?
1. 编写一个模块,预测句子前两个单词给出的第三个单词,而不偷看。
1. 什么是循环神经网络?
1. 隐藏状态是什么?
1. `LMModel1`中隐藏状态的等价物是什么?
1. 为了在 RNN 中保持状态,为什么按顺序将文本传递给模型很重要?
1. 什么是 RNN 的“展开”表示?
1. 为什么在 RNN 中保持隐藏状态会导致内存和性能问题?我们如何解决这个问题?
1. 什么是 BPTT
1. 编写代码打印出验证集的前几个批次,包括将标记 ID 转换回英文字符串,就像我们在第十章中展示的 IMDb 数据批次一样。
1. `ModelResetter`回调函数的作用是什么?我们为什么需要它?
1. 为每三个输入词预测一个输出词的缺点是什么?
1. 为什么我们需要为`LMModel4`设计一个自定义损失函数?
1. 为什么`LMModel4`的训练不稳定?
1. 在展开表示中,我们可以看到递归神经网络有许多层。那么为什么我们需要堆叠 RNN 以获得更好的结果?
1. 绘制一个堆叠多层RNN 的表示。
1. 如果我们不经常调用`detach`,为什么在 RNN 中应该获得更好的结果?为什么在实践中可能不会发生这种情况?
1. 为什么深度网络可能导致非常大或非常小的激活?这为什么重要?
1. 在计算机的浮点数表示中,哪些数字是最精确的?
1. 为什么消失的梯度会阻止训练?
1. 在 LSTM 架构中有两个隐藏状态为什么有帮助?每个的目的是什么?
1. 在 LSTM 中这两个状态被称为什么?
1. tanh 是什么,它与 sigmoid 有什么关系?
1. `LSTMCell`中这段代码的目的是什么:
```py
h = torch.stack([h, input], dim=1)
```
1. 在 PyTorch 中`chunk`是做什么的?
1. 仔细研究`LSTMCell`的重构版本,确保你理解它如何以及为什么与未重构版本执行相同的操作。
1. 为什么我们可以为`LMModel6`使用更高的学习率?
1. AWD-LSTM 模型中使用的三种正则化技术是什么?
1. 什么是 dropout
1. 为什么我们要用 dropout 来缩放权重?这是在训练期间、推理期间还是两者都应用?
1. `Dropout`中这行代码的目的是什么:
```py
if not self.training: return x
```
1. 尝试使用`bernoulli_`来了解它的工作原理。
1. 如何在 PyTorch 中将模型设置为训练模式?在评估模式下呢?
1. 写出激活正则化的方程(数学或代码,任你选择)。它与权重衰减有什么不同?
1. 写出时间激活正则化的方程(数学或代码,任你选择)。为什么我们不会在计算机视觉问题中使用这个?
1. 语言模型中的权重绑定是什么?
## 进一步研究
1. 在`LMModel2`中,为什么`forward`可以从`h=0`开始?为什么我们不需要写`h=torch.zeros(...)`
1. 从头开始编写一个 LSTM 的代码(你可以参考图 12-9
1. 搜索互联网了解 GRU 架构并从头开始实现它,尝试训练一个模型。看看能否获得类似于本章中看到的结果。将你的结果与 PyTorch 内置的`GRU`模块的结果进行比较。
1. 查看 fastai 中 AWD-LSTM 的源代码,并尝试将每行代码映射到本章中展示的概念。

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,491 @@
# 第十四章ResNets
在本章中,我们将在上一章介绍的 CNN 基础上构建,并向您解释 ResNet残差网络架构。它是由 Kaiming He 等人于 2015 年在文章[“Deep Residual Learning for Image Recognition”](https://oreil.ly/b68K8)中引入的,到目前为止是最常用的模型架构。最近在图像模型中的发展几乎总是使用残差连接的相同技巧,大多数时候,它们只是原始 ResNet 的调整。
我们将首先展示最初设计的基本 ResNet然后解释使其性能更好的现代调整。但首先我们需要一个比 MNIST 数据集更难一点的问题,因为我们已经在常规 CNN 上接近 100%的准确率了。
# 回到 Imagenette
当我们已经在上一章的 MNIST 中看到的准确率已经很高时,要评估我们对模型的任何改进将会很困难,因此我们将通过回到 Imagenette 来解决一个更困难的图像分类问题。我们将继续使用小图像以保持事情相对快速。
让我们获取数据——我们将使用已经调整大小为 160 像素的版本以使事情更快,然后将随机裁剪到 128 像素:
```py
def get_data(url, presize, resize):
path = untar_data(url)
return DataBlock(
blocks=(ImageBlock, CategoryBlock), get_items=get_image_files,
splitter=GrandparentSplitter(valid_name='val'),
get_y=parent_label, item_tfms=Resize(presize),
batch_tfms=[*aug_transforms(min_scale=0.5, size=resize),
Normalize.from_stats(*imagenet_stats)],
).dataloaders(path, bs=128)
```
```py
dls = get_data(URLs.IMAGENETTE_160, 160, 128)
```
```py
dls.show_batch(max_n=4)
```
![](img/dlcf_14in01.png)
当我们查看 MNIST 时,我们处理的是 28×28 像素的图像。对于 Imagenette我们将使用 128×128 像素的图像进行训练。稍后,我们希望能够使用更大的图像,至少与 224×224 像素的 ImageNet 标准一样大。您还记得我们如何从 MNIST 卷积神经网络中获得每个图像的单个激活向量吗?
我们采用的方法是确保有足够的步幅为 2 的卷积,以使最终层具有 1 的网格大小。然后我们展平我们最终得到的单位轴,为每个图像获得一个向量(因此,对于一个小批量的激活矩阵)。我们可以对 Imagenette 做同样的事情,但这会导致两个问题:
+ 我们需要很多步幅为 2 的层,才能使我们的网格在最后变成 1×1 的大小——可能比我们本来会选择的要多。
+ 该模型将无法处理除最初训练的大小之外的任何大小的图像。
处理第一个问题的一种方法是以一种处理 1×1 以外的网格大小的方式展平最终的卷积层。我们可以简单地将矩阵展平为向量,就像我们以前做过的那样,通过将每一行放在前一行之后。事实上,这是卷积神经网络直到 2013 年几乎总是采用的方法。最著名的例子是 2013 年 ImageNet 的获奖者 VGG有时今天仍在使用。但这种架构还有另一个问题它不仅不能处理与训练集中使用的相同大小的图像之外的图像而且需要大量内存因为展平卷积层导致许多激活被馈送到最终层。因此最终层的权重矩阵是巨大的。
这个问题通过创建*完全卷积网络*来解决。完全卷积网络的技巧是对卷积网格中的激活进行平均。换句话说,我们可以简单地使用这个函数:
```py
def avg_pool(x): return x.mean((2,3))
```
正如您所看到的,它正在计算 x 轴和 y 轴上的平均值。这个函数将始终将一组激活转换为每个图像的单个激活。PyTorch 提供了一个稍微更灵活的模块,称为`nn.AdaptiveAvgPool2d`,它将一组激活平均到您需要的任何大小的目标(尽管我们几乎总是使用大小为 1
因此,一个完全卷积网络具有多个卷积层,其中一些将是步幅为 2 的,在最后是一个自适应平均池化层,一个展平层来移除单位轴,最后是一个线性层。这是我们的第一个完全卷积网络:
```py
def block(ni, nf): return ConvLayer(ni, nf, stride=2)
def get_model():
return nn.Sequential(
block(3, 16),
block(16, 32),
block(32, 64),
block(64, 128),
block(128, 256),
nn.AdaptiveAvgPool2d(1),
Flatten(),
nn.Linear(256, dls.c))
```
我们将在网络中用其他变体替换`block`的实现,这就是为什么我们不再称其为`conv`。我们还通过利用 fastai 的`ConvLayer`节省了一些时间,它已经提供了前一章中`conv`的功能(还有更多!)。
# 停下来思考
考虑这个问题:这种方法对于像 MNIST 这样的光学字符识别OCR问题是否有意义绝大多数从事 OCR 和类似问题的从业者倾向于使用全卷积网络,因为这是现在几乎每个人都学习的。但这真的毫无意义!例如,你不能通过将数字切成小块、混在一起,然后决定每个块平均看起来像 3 还是 8 来判断一个数字是 3 还是 8。但这正是自适应平均池化有效地做的事情全卷积网络只对没有单一正确方向或大小的对象例如大多数自然照片是一个很好的选择。
一旦我们完成卷积层,我们将得到大小为`bs x ch x h x w`的激活(批量大小、一定数量的通道、高度和宽度)。我们想将其转换为大小为`bs x ch`的张量,因此我们取最后两个维度的平均值,并像在我们之前的模型中那样展平尾随的 1×1 维度。
这与常规池化不同,因为这些层通常会取给定大小窗口的平均值(对于平均池化)或最大值(对于最大池化)。例如,大小为 2 的最大池化层在旧的 CNN 中非常流行,通过在每个维度上取每个 2×2 窗口的最大值(步幅为 2将图像的尺寸减半。
与以前一样,我们可以使用我们自定义的模型定义一个`Learner`,然后在之前获取的数据上对其进行训练:
```py
def get_learner(m):
return Learner(dls, m, loss_func=nn.CrossEntropyLoss(), metrics=accuracy
).to_fp16()
learn = get_learner(get_model())
```
```py
learn.lr_find()
```
```py
(0.47863011360168456, 3.981071710586548)
```
![](img/dlcf_14in02.png)
对于 CNN 来说3e-3 通常是一个很好的学习率,这在这里也是如此,所以让我们试一试:
```py
learn.fit_one_cycle(5, 3e-3)
```
| epoch | train_loss | valid_loss | accuracy | time |
| --- | --- | --- | --- | --- |
| 0 | 1.901582 | 2.155090 | 0.325350 | 00:07 |
| 1 | 1.559855 | 1.586795 | 0.507771 | 00:07 |
| 2 | 1.296350 | 1.295499 | 0.571720 | 00:07 |
| 3 | 1.144139 | 1.139257 | 0.639236 | 00:07 |
| 4 | 1.049770 | 1.092619 | 0.659108 | 00:07 |
考虑到我们必须从头开始选择 10 个类别中的正确一个,而且我们只训练了 5 个时期这是一个相当不错的开始我们可以通过使用更深的模型做得更好但只是堆叠新层并不会真正改善我们的结果你可以尝试自己看看。为了解决这个问题ResNets 引入了*跳跃连接*的概念。我们将在下一节中探讨 ResNets 的这些方面。
# 构建现代 CNNResNet
我们现在已经拥有构建我们自从本书开始就一直在计算机视觉任务中使用的模型所需的所有要素ResNets。我们将介绍它们背后的主要思想并展示它如何在 Imagenette 上提高了准确性,然后构建一个带有所有最新调整的版本。
## 跳跃连接
2015 年ResNet 论文的作者们注意到了一件他们觉得奇怪的事情。即使使用了批量归一化,他们发现使用更多层的网络表现不如使用更少层的网络,并且模型之间没有其他差异。最有趣的是,这种差异不仅在验证集中观察到,而且在训练集中也观察到;因此这不仅仅是一个泛化问题,而是一个训练问题。正如论文所解释的:
> 出乎意料的是,这种退化并不是由过拟合引起的,向适当深度的模型添加更多层会导致更高的训练错误,正如我们的实验[先前报告]和彻底验证的那样。
这种现象在图 14-1 中的图表中有所说明,左侧是训练错误,右侧是测试错误。
![不同深度网络的训练](img/dlcf_1401.png)
###### 图 14-1。不同深度网络的训练由 Kaiming He 等人提供)。
正如作者在这里提到的,他们并不是第一个注意到这个奇怪事实的人。但他们是第一个迈出非常重要的一步:
> 让我们考虑一个更浅的架构及其更深的对应物,后者在其上添加更多层。存在一种通过构建解决更深模型的方法:添加的层是恒等映射,其他层是从学习的更浅模型中复制的。
由于这是一篇学术论文,这个过程以一种不太易懂的方式描述,但概念实际上非常简单:从一个训练良好的 20 层神经网络开始,然后添加另外 36 层什么都不做的层(例如,它们可以是具有单个权重等于 1 和偏置等于 0 的线性层)。结果将是一个 56 层的网络,它与 20 层网络完全相同,证明总是存在深度网络应该*至少和*任何浅层网络一样好。但由于某种原因,随机梯度下降似乎无法找到它们。
# 行话:恒等映射
将输入返回而不做任何改变。这个过程由一个*恒等函数*执行。
实际上,还有另一种更有趣的方法来创建这些额外的 36 层。如果我们用`x + conv(x)`替换每次出现的`conv(x)`,其中`conv`是上一章中添加第二个卷积,然后是 ReLU然后是批量归一化层的函数。此外回想一下批量归一化是`gamma*y + beta`。如果我们为这些最终批量归一化层中的每一个初始化`gamma`为零会怎样?那么我们这些额外的 36 层的`conv(x)`将始终等于零,这意味着`x+conv(x)`将始终等于`x`。
这给我们带来了什么好处?关键是,这 36 个额外的层,就目前而言,是一个*恒等映射*,但它们有*参数*,这意味着它们是*可训练的*。因此,我们可以从最好的 20 层模型开始,添加这 36 个最初什么都不做的额外层,然后*微调整个 56 层模型*。这些额外的 36 层可以学习使它们最有用的参数!
ResNet 论文提出了这样的一个变体,即“跳过”每第二个卷积,因此我们实际上得到了`x+conv2(conv1(x))`。这在图 14-2来自论文中的图表中显示。
![一个简单的 ResNet 块](img/dlcf_1402.png)
###### 图 14-2。一个简单的 ResNet 块(由 Kaiming He 等人提供)。
右侧的箭头只是`x+conv2(conv1(x))`中的`x`部分,被称为*恒等分支*或*跳跃连接*。左侧路径是`conv2(conv1(x))`部分。您可以将恒等路径视为提供从输入到输出的直接路径。
在 ResNet 中,我们不是先训练少量层,然后在末尾添加新层并进行微调。相反,我们在整个 CNN 中使用像图 14-2 中的 ResNet 块这样的块,以通常的方式从头开始初始化并以通常的方式使用 SGD 进行训练。我们依靠跳跃连接使网络更容易使用 SGD 进行训练。
还有另一种(在很大程度上等效的)思考这些 ResNet 块的方式。这就是论文描述的方式:
> 我们不是希望每几个堆叠的层直接适应所需的底层映射,而是明确让这些层适应一个残差映射。形式上,将所需的底层映射表示为*H*(*x*),我们让堆叠的非线性层适应另一个映射*F*(*x*) := H(*x*)*x*。原始映射被重新构造为*F*(*x*)+*x*。我们假设优化残差映射比优化原始未引用的映射更容易。在极端情况下,如果恒等映射是最佳的,将残差推向零将比通过一堆非线性层适应恒等映射更容易。
再次,这是相当晦涩的文字,让我们尝试用简单的英语重新表述一下!如果给定层的结果是`x`,我们使用一个返回`y = x + block(x)`的 ResNet 块,我们不是要求该块预测`y`;我们要求它预测`y`和`x`之间的差异。因此,这些块的任务不是预测特定的特征,而是最小化`x`和期望的`y`之间的误差。因此ResNet 擅长学习不做任何事情和通过两个卷积层块(具有可训练权重)之间的区别。这就是这些模型得名的原因:它们在预测残差(提醒:“残差”是预测减去目标)。
这两种关于 ResNet 的思考方式共享的一个关键概念是学习的便利性。这是一个重要的主题。回想一下普遍逼近定理,它指出一个足够大的网络可以学习任何东西。这仍然是真的,但事实证明,在原始数据和训练方案下,网络在原则上可以学习的东西与它实际上容易学习的东西之间存在非常重要的区别。过去十年中神经网络的许多进步都像 ResNet 块一样:意识到如何使一些一直可能的东西变得可行。
# 真实身份路径
原始论文实际上并没有在每个块的最后一个 batchnorm 层中使用零作为`gamma`的初始值的技巧这是几年后才出现的。因此ResNet 的原始版本并没有真正以真实的身份路径开始训练 ResNet 块,但是尽管如此,具有“穿越”跳过连接的能力确实使其训练效果更好。添加 batchnorm `gamma`初始化技巧使模型能够以更高的学习速率训练。
这是一个简单 ResNet 块的定义fastai 将最后一个 batchnorm 层的`gamma`权重初始化为零,因为`norm_type=NormType.BatchZero`
```py
class ResBlock(Module):
def __init__(self, ni, nf):
self.convs = nn.Sequential(
ConvLayer(ni,nf),
ConvLayer(nf,nf, norm_type=NormType.BatchZero))
def forward(self, x): return x + self.convs(x)
```
然而,这有两个问题:它无法处理除 1 以外的步幅,并且要求`ni==nf`。停下来仔细思考为什么会这样。
问题在于,如果在其中一个卷积层上使用步幅为 2输出激活的网格大小将是输入的每个轴的一半。因此我们无法将其添加回`forward`中的`x`,因为`x`和输出激活具有不同的维度。如果`ni!=nf`,则会出现相同的基本问题:输入和输出连接的形状不允许我们将它们相加。
为了解决这个问题,我们需要一种方法来改变`x`的形状,使其与`self.convs`的结果匹配。可以通过使用步幅为 2 的平均池化层来减半网格大小:也就是说,该层从输入中获取 2×2 的块,并用它们的平均值替换它们。
可以通过使用卷积来改变通道数。然而,我们希望这个跳过连接尽可能接近一个恒等映射,这意味着使这个卷积尽可能简单。最简单的卷积是一个卷积核大小为 1 的卷积。这意味着卷积核大小为`ni` × `nf` × `1` × `1`,因此它只是对每个输入像素的通道进行点积运算,根本不跨像素进行组合。这种*1x1 卷积*在现代 CNN 中被广泛使用,因此花一点时间思考它是如何工作的。
# 术语1x1 卷积
卷积核大小为 1 的卷积。
以下是使用这些技巧处理跳过连接中形状变化的 ResBlock
```py
def _conv_block(ni,nf,stride):
return nn.Sequential(
ConvLayer(ni, nf, stride=stride),
ConvLayer(nf, nf, act_cls=None, norm_type=NormType.BatchZero))
```
```py
class ResBlock(Module):
def __init__(self, ni, nf, stride=1):
self.convs = _conv_block(ni,nf,stride)
self.idconv = noop if ni==nf else ConvLayer(ni, nf, 1, act_cls=None)
self.pool = noop if stride==1 else nn.AvgPool2d(2, ceil_mode=True)
def forward(self, x):
return F.relu(self.convs(x) + self.idconv(self.pool(x)))
```
请注意,我们在这里使用`noop`函数,它只是返回其未更改的输入(*noop*是一个计算机科学术语,代表“无操作”)。在这种情况下,如果`nf==nf``idconv`什么也不做,如果`stride==1``pool`也不做任何操作,这正是我们在跳过连接中想要的。
此外,您会看到我们已经从`convs`的最后一个卷积层和`idconv`中删除了 ReLU`act_cls=None`),并将其移到*在*我们添加跳跃连接之后。这样做的想法是整个 ResNet 块就像一个层,您希望激活在层之后。
让我们用`ResBlock`替换我们的`block`并尝试一下:
```py
def block(ni,nf): return ResBlock(ni, nf, stride=2)
learn = get_learner(get_model())
```
```py
learn.fit_one_cycle(5, 3e-3)
```
| epoch | train_loss | valid_loss | accuracy | time |
| --- | --- | --- | --- | --- |
| 0 | 1.973174 | 1.845491 | 0.373248 | 00:08 |
| 1 | 1.678627 | 1.778713 | 0.439236 | 00:08 |
| 2 | 1.386163 | 1.596503 | 0.507261 | 00:08 |
| 3 | 1.177839 | 1.102993 | 0.644841 | 00:09 |
| 4 | 1.052435 | 1.038013 | 0.667771 | 00:09 |
这并没有好多少。但这一切的目的是让我们能够训练*更深*的模型,而我们实际上还没有充分利用这一点。要创建一个比如说深两倍的模型,我们只需要用两个`ResBlock`替换我们的`block`
```py
def block(ni, nf):
return nn.Sequential(ResBlock(ni, nf, stride=2), ResBlock(nf, nf))
```
```py
learn = get_learner(get_model())
learn.fit_one_cycle(5, 3e-3)
```
| epoch | train_loss | valid_loss | accuracy | time |
| --- | --- | --- | --- | --- |
| 0 | 1.964076 | 1.864578 | 0.355159 | 00:12 |
| 1 | 1.636880 | 1.596789 | 0.502675 | 00:12 |
| 2 | 1.335378 | 1.304472 | 0.588535 | 00:12 |
| 3 | 1.089160 | 1.065063 | 0.663185 | 00:12 |
| 4 | 0.942904 | 0.963589 | 0.692739 | 00:12 |
现在我们取得了良好的进展!
ResNet 论文的作者后来赢得了 2015 年 ImageNet 挑战赛。当时,这是计算机视觉领域迄今为止最重要的年度事件。我们已经看到另一个 ImageNet 的获奖者2013 年的获奖者 Zeiler 和 Fergus。值得注意的是在这两种情况下突破的起点都是实验观察Zeiler 和 Fergus 案例中关于层实际学习内容的观察,以及 ResNet 作者案例中关于可以训练哪种网络的观察。设计和分析周到的实验,甚至只是看到一个意想不到的结果,然后,最重要的是,开始弄清楚到底发生了什么,具有极大的坚韧性,这是许多科学发现的核心。深度学习不像纯数学。这是一个非常实验性的领域,因此成为一个强大的实践者,而不仅仅是一个理论家,是非常重要的。
自 ResNet 推出以来,它已经被广泛研究和应用于许多领域。其中最有趣的论文之一,发表于 2018 年,是由 Hao Li 等人撰写的[“可视化神经网络损失景观”](https://oreil.ly/C9cFi)。它表明使用跳跃连接有助于平滑损失函数,这使得训练更容易,因为它避免了陷入非常陡峭的区域。图 14-3 展示了该论文中的一幅惊人图片,说明了 SGD 需要导航以优化普通 CNN左侧与 ResNet右侧之间的不同之处。
![ResNet 对损失景观的影响](img/dlcf_1403.png)
###### 图 14-3\. ResNet 对损失景观的影响(由 Hao Li 等人提供)
我们的第一个模型已经很好了,但进一步的研究发现了更多可以应用的技巧,使其变得更好。我们接下来将看看这些技巧。
## 一个最先进的 ResNet
在[“用卷积神经网络进行图像分类的技巧”](https://oreil.ly/n-qhd)中Tong He 等人研究了 ResNet 架构的变体,这几乎没有额外的参数或计算成本。通过使用调整后的 ResNet-50 架构和 Mixup他们在 ImageNet 上实现了 94.6%的 Top-5 准确率,而普通的 ResNet-50 没有 Mixup 只有 92.2%。这个结果比普通 ResNet 模型取得的结果更好,后者深度是它的两倍(速度也是两倍,更容易过拟合)。
# 术语Top-5 准确率
一个度量,测试我们模型的前 5 个预测中我们想要的标签有多少次。在 ImageNet 竞赛中使用它,因为许多图像包含多个对象,或者包含可以轻松混淆甚至可能被错误标记为相似标签的对象。在这些情况下,查看前 1 的准确率可能不合适。然而,最近 CNN 的表现越来越好,以至于前 5 的准确率几乎达到 100%,因此一些研究人员现在也在 ImageNet 中使用前 1 的准确率。
当我们扩展到完整的 ResNet 时,我们将使用这个调整过的版本,因为它要好得多。它与我们之前的实现略有不同,它不是直接从 ResNet 块开始,而是从几个卷积层开始,然后是一个最大池化层。这就是网络的第一层,称为*干*的样子:
```py
def _resnet_stem(*sizes):
return [
ConvLayer(sizes[i], sizes[i+1], 3, stride = 2 if i==0 else 1)
for i in range(len(sizes)-1)
] + [nn.MaxPool2d(kernel_size=3, stride=2, padding=1)]
```
```py
_resnet_stem(3,32,32,64)
```
```py
[ConvLayer(
(0): Conv2d(3, 32, kernel_size=(3, 3), stride=(2, 2), padding=(1, 1))
(1): BatchNorm2d(32, eps=1e-05, momentum=0.1)
(2): ReLU()
), ConvLayer(
(0): Conv2d(32, 32, kernel_size=(3, 3), stride=(1, 1), padding=(1, 1))
(1): BatchNorm2d(32, eps=1e-05, momentum=0.1)
(2): ReLU()
), ConvLayer(
(0): Conv2d(32, 64, kernel_size=(3, 3), stride=(1, 1), padding=(1, 1))
(1): BatchNorm2d(64, eps=1e-05, momentum=0.1)
(2): ReLU()
), MaxPool2d(kernel_size=3, stride=2, padding=1, ceil_mode=False)]
```
# 术语:干
CNN 的前几层。通常,干的结构与 CNN 的主体不同。
我们之所以有一系列普通卷积层的起始,而不是 ResNet 块,是基于对所有深度卷积神经网络的一个重要洞察:绝大部分的计算发生在早期层。因此,我们应该尽可能保持早期层的速度和简单。
要了解为什么绝大部分的计算发生在早期层,考虑一下在 128 像素输入图像上的第一个卷积。如果是步幅为 1 的卷积,它将应用核到 128×128 个像素中的每一个。这是很多工作!然而,在后续层中,网格大小可能只有 4×4 甚至 2×2因此要做的核应用要少得多。
另一方面,第一层卷积只有 3 个输入特征和 32 个输出特征。由于它是一个 3×3 的核,这是权重中的 864 个参数。但最后一个卷积将有 256 个输入特征和 512 个输出特征,导致 1,179,648 个权重!因此,第一层包含了绝大部分的计算量,而最后几层包含了绝大部分的参数。
一个 ResNet 块比一个普通卷积块需要更多的计算,因为(在步幅为 2 的情况下)一个 ResNet 块有三个卷积和一个池化层。这就是为什么我们希望从普通卷积开始我们的 ResNet。
现在我们准备展示一个现代 ResNet 的实现,带有“技巧袋”。它使用了四组 ResNet 块,分别为 64、128、256 和 512 个滤波器。每组都以步幅为 2 的块开始,除了第一组,因为它紧接着一个`MaxPooling`层:
```py
class ResNet(nn.Sequential):
def __init__(self, n_out, layers, expansion=1):
stem = _resnet_stem(3,32,32,64)
self.block_szs = [64, 64, 128, 256, 512]
for i in range(1,5): self.block_szs[i] *= expansion
blocks = [self._make_layer(*o) for o in enumerate(layers)]
super().__init__(*stem, *blocks,
nn.AdaptiveAvgPool2d(1), Flatten(),
nn.Linear(self.block_szs[-1], n_out))
def _make_layer(self, idx, n_layers):
stride = 1 if idx==0 else 2
ch_in,ch_out = self.block_szs[idx:idx+2]
return nn.Sequential(*[
ResBlock(ch_in if i==0 else ch_out, ch_out, stride if i==0 else 1)
for i in range(n_layers)
])
```
`_make_layer`函数只是用来创建一系列`n_layers`块。第一个是从`ch_in`到`ch_out`,步幅为指定的`stride`,其余所有块都是步幅为 1 的块,从`ch_out`到`ch_out`张量。一旦块被定义,我们的模型就是纯顺序的,这就是为什么我们将其定义为`nn.Sequential`的子类。(暂时忽略`expansion`参数;我们将在下一节讨论它。暂时设为`1`,所以它不起作用。)
模型的各个版本ResNet-18、-34、-50 等)只是改变了每个组中块的数量。这是 ResNet-18 的定义:
```py
rn = ResNet(dls.c, [2,2,2,2])
```
让我们训练一下,看看它与之前的模型相比如何:
```py
learn = get_learner(rn)
learn.fit_one_cycle(5, 3e-3)
```
| epoch | train_loss | valid_loss | accuracy | time |
| --- | --- | --- | --- | --- |
| 0 | 1.673882 | 1.828394 | 0.413758 | 00:13 |
| 1 | 1.331675 | 1.572685 | 0.518217 | 00:13 |
| 2 | 1.087224 | 1.086102 | 0.650701 | 00:13 |
| 3 | 0.900428 | 0.968219 | 0.684331 | 00:12 |
| 4 | 0.760280 | 0.782558 | 0.757197 | 00:12 |
尽管我们有更多的通道(因此我们的模型更准确),但由于我们优化了干,我们的训练速度与以前一样快。
为了使我们的模型更深,而不占用太多计算或内存,我们可以使用 ResNet 论文引入的另一种层:瓶颈层。
## 瓶颈层
瓶颈层不是使用 3 个内核大小为 3 的卷积堆叠,而是使用三个卷积:两个 1×1在开头和结尾和一个 3×3如右侧在图 14-4 中所示。
![常规和瓶颈 ResNet 块的比较](img/dlcf_1404.png)
###### 图 14-4\. 常规和瓶颈 ResNet 块的比较(由 Kaiming He 等人提供)
为什么这很有用1×1 卷积速度更快,因此即使这似乎是一个更复杂的设计,这个块的执行速度比我们看到的第一个 ResNet 块更快。这样一来我们可以使用更多的滤波器正如我们在插图中看到的输入和输出的滤波器数量是四倍更高的256 而不是 64。1×1 卷积减少然后恢复通道数(因此称为*瓶颈*)。总体影响是我们可以在相同的时间内使用更多的滤波器。
让我们尝试用这种瓶颈设计替换我们的`ResBlock`
```py
def _conv_block(ni,nf,stride):
return nn.Sequential(
ConvLayer(ni, nf//4, 1),
ConvLayer(nf//4, nf//4, stride=stride),
ConvLayer(nf//4, nf, 1, act_cls=None, norm_type=NormType.BatchZero))
```
我们将使用这个来创建一个具有组大小`(3,4,6,3)`的 ResNet-50。现在我们需要将`4`传递给`ResNet`的`expansion`参数,因为我们需要从四倍少的通道开始,最终将以四倍多的通道结束。
像这样更深的网络通常在仅训练 5 个时期时不会显示出改进,所以这次我们将将其增加到 20 个时期,以充分利用我们更大的模型。为了获得更好的结果,让我们也使用更大的图像:
```py
dls = get_data(URLs.IMAGENETTE_320, presize=320, resize=224)
```
我们不必为更大的 224 像素图像做任何调整;由于我们的全卷积网络,它可以正常工作。这也是为什么我们能够在本书的早期进行*渐进调整*的原因——我们使用的模型是全卷积的,所以我们甚至能够微调使用不同尺寸训练的模型。现在我们可以训练我们的模型并查看效果:
```py
rn = ResNet(dls.c, [3,4,6,3], 4)
```
```py
learn = get_learner(rn)
learn.fit_one_cycle(20, 3e-3)
```
| epoch | train_loss | valid_loss | accuracy | time |
| --- | --- | --- | --- | --- |
| 0 | 1.613448 | 1.473355 | 0.514140 | 00:31 |
| 1 | 1.359604 | 2.050794 | 0.397452 | 00:31 |
| 2 | 1.253112 | 4.511735 | 0.387006 | 00:31 |
| 3 | 1.133450 | 2.575221 | 0.396178 | 00:31 |
| 4 | 1.054752 | 1.264525 | 0.613758 | 00:32 |
| 5 | 0.927930 | 2.670484 | 0.422675 | 00:32 |
| 6 | 0.838268 | 1.724588 | 0.528662 | 00:32 |
| 7 | 0.748289 | 1.180668 | 0.666497 | 00:31 |
| 8 | 0.688637 | 1.245039 | 0.650446 | 00:32 |
| 9 | 0.645530 | 1.053691 | 0.674904 | 00:31 |
| 10 | 0.593401 | 1.180786 | 0.676433 | 00:32 |
| 11 | 0.536634 | 0.879937 | 0.713885 | 00:32 |
| 12 | 0.479208 | 0.798356 | 0.741656 | 00:32 |
| 13 | 0.440071 | 0.600644 | 0.806879 | 00:32 |
| 14 | 0.402952 | 0.450296 | 0.858599 | 00:32 |
| 15 | 0.359117 | 0.486126 | 0.846369 | 00:32 |
| 16 | 0.313642 | 0.442215 | 0.861911 | 00:32 |
| 17 | 0.294050 | 0.485967 | 0.853503 | 00:32 |
| 18 | 0.270583 | 0.408566 | 0.875924 | 00:32 |
| 19 | 0.266003 | 0.411752 | 0.872611 | 00:33 |
现在我们得到了一个很好的结果!尝试添加 Mixup然后在吃午餐时将其训练一百个时期。你将拥有一个从头开始训练的非常准确的图像分类器。
这里展示的瓶颈设计通常仅用于 ResNet-50、-101 和-152 模型。ResNet-18 和-34 模型通常使用前一节中看到的非瓶颈设计。然而,我们注意到瓶颈层通常即使对于较浅的网络也效果更好。这只是表明,论文中的细节往往会持续多年,即使它们并不是最佳设计!质疑假设和“每个人都知道的东西”总是一个好主意,因为这仍然是一个新领域,很多细节并不总是做得很好。
# 结论
自第一章以来我们一直在使用的计算机视觉模型是如何构建的使用跳跃连接来训练更深的模型。尽管已经进行了大量研究以寻找更好的架构但它们都使用这个技巧的某个版本来建立从输入到网络末端的直接路径。在使用迁移学习时ResNet 是预训练模型。在下一章中,我们将看一下我们使用的模型是如何从中构建的最终细节。
# 问卷调查
1. 在以前的章节中,我们如何将用于 MNIST 的 CNN 转换为单个激活向量?为什么这对 Imagenette 不适用?
1. 我们在 Imagenette 上做了什么?
1. 什么是自适应池化?
1. 什么是平均池化?
1. 为什么在自适应平均池化层之后需要`Flatten`
1. 什么是跳跃连接?
1. 为什么跳跃连接使我们能够训练更深的模型?
1. 图 14-1 展示了什么?这是如何导致跳跃连接的想法的?
1. 什么是恒等映射?
1. ResNet 块的基本方程是什么(忽略批量归一化和 ReLU 层)?
1. ResNet 与残差有什么关系?
1. 当存在步幅为 2 的卷积时,我们如何处理跳跃连接?当滤波器数量发生变化时呢?
1. 我们如何用向量点积表示 1×1 卷积?
1. 使用`F.conv2d`或`nn.Conv2d`创建一个 1×1 卷积并将其应用于图像。图像的形状会发生什么变化?
1. `noop`函数返回什么?
1. 解释图 14-3 中显示的内容。
1. 何时使用前 5 准确度比前 1 准确度更好?
1. CNN 的“起始”是什么?
1. 为什么在 CNN 的起始部分使用普通卷积而不是 ResNet 块?
1. 瓶颈块与普通 ResNet 块有何不同?
1. 为什么瓶颈块更快?
1. 完全卷积网络(以及具有自适应池化的网络)如何实现渐进式调整大小?
## 进一步研究
1. 尝试为 MNIST 创建一个带有自适应平均池化的完全卷积网络(请注意,您将需要更少的步幅为 2 的层)。与没有这种池化层的网络相比如何?
1. 在第十七章中,我们介绍了*爱因斯坦求和符号*。快进去看看它是如何工作的,然后使用`torch.einsum`编写一个 1×1 卷积操作的实现。将其与使用`torch.conv2d`进行相同操作进行比较。
1. 使用纯 PyTorch 或纯 Python 编写一个前 5 准确度函数。
1. 在 Imagenette 上训练一个模型更多的 epochs使用和不使用标签平滑。查看 Imagenette 排行榜,看看你能达到最佳结果有多接近。阅读描述领先方法的链接页面。

View File

@ -0,0 +1,342 @@
# 第十五章:应用架构深入探讨
我们现在处于一个令人兴奋的位置,我们可以完全理解我们为计算机视觉、自然语言处理和表格分析使用的最先进模型的架构。在本章中,我们将填补有关 fastai 应用模型如何工作的所有缺失细节,并向您展示如何构建它们。
我们还将回到我们在第十一章中看到的用于 Siamese 网络的自定义数据预处理流程,并向您展示如何使用 fastai 库中的组件为新任务构建自定义预训练模型。
我们将从计算机视觉开始。
# 计算机视觉
对于计算机视觉应用,我们使用`cnn_learner`和`unet_learner`函数来构建我们的模型,具体取决于任务。在本节中,我们将探讨如何构建我们在本书的第 I 部分和 II 部分中使用的`Learner`对象。
## cnn_learner
让我们看看当我们使用`cnn_learner`函数时会发生什么。我们首先向这个函数传递一个用于网络*主体*的架构。大多数情况下,我们使用 ResNet您已经知道如何创建所以我们不需要深入研究。预训练权重将根据需要下载并加载到 ResNet 中。
然后,对于迁移学习,网络需要被*切割*。这指的是切掉最后一层,该层仅负责 ImageNet 特定的分类。实际上,我们不仅切掉这一层,还切掉自自适应平均池化层以及之后的所有内容。这样做的原因很快就会变得清楚。由于不同的架构可能使用不同类型的池化层,甚至完全不同类型的*头部*,我们不仅仅搜索自适应池化层来决定在哪里切割预训练模型。相反,我们有一个信息字典,用于确定每个模型的主体在哪里结束,头部从哪里开始。我们称之为`model_meta`—这是`resnet50`的信息:
```py
model_meta[resnet50]
```
```py
{'cut': -2,
'split': <function fastai.vision.learner._resnet_split(m)>,
'stats': ([0.485, 0.456, 0.406], [0.229, 0.224, 0.225])}
```
# 行话:主体和头部
神经网络的头部是专门针对特定任务的部分。对于 CNN通常是自适应平均池化层之后的部分。主体是其他所有部分包括干部我们在第十四章中学到的
如果我们取出在`-2`之前的所有层,我们就得到了 fastai 将保留用于迁移学习的模型部分。现在,我们放上我们的新头部。这是使用`create_head`函数创建的:
```py
create_head(20,2)
```
```py
Sequential(
(0): AdaptiveConcatPool2d(
(ap): AdaptiveAvgPool2d(output_size=1)
(mp): AdaptiveMaxPool2d(output_size=1)
)
(1): Flatten()
(2): BatchNorm1d(20, eps=1e-05, momentum=0.1, affine=True)
(3): Dropout(p=0.25, inplace=False)
(4): Linear(in_features=20, out_features=512, bias=False)
(5): ReLU(inplace=True)
(6): BatchNorm1d(512, eps=1e-05, momentum=0.1, affine=True)
(7): Dropout(p=0.5, inplace=False)
(8): Linear(in_features=512, out_features=2, bias=False)
)
```
使用这个函数,您可以选择在末尾添加多少个额外的线性层,每个线性层之后使用多少 dropout以及使用什么类型的池化。默认情况下fastai 将同时应用平均池化和最大池化,并将两者连接在一起(这是`AdaptiveConcatPool2d`层)。这不是一个特别常见的方法,但它在 fastai 和其他研究实验室近年来独立开发,并倾向于比仅使用平均池化提供小幅改进。
fastai 与大多数库有所不同,因为默认情况下它在 CNN 头部中添加两个线性层,而不是一个。原因是,正如我们所看到的,即使将预训练模型转移到非常不同的领域,迁移学习仍然可能是有用的。然而,在这些情况下,仅使用单个线性层可能不足够;我们发现使用两个线性层可以使迁移学习更快速、更容易地应用在更多情况下。
# 最后一个 Batchnorm
`create_head`的一个值得关注的参数是`bn_final`。将其设置为`True`将导致一个 batchnorm 层被添加为您的最终层。这有助于帮助您的模型适当地缩放输出激活。迄今为止,我们还没有看到这种方法在任何地方发表,但我们发现在实践中无论我们在哪里使用它,它都效果很好。
现在让我们看看`unet_learner`在我们在第一章展示的分割问题中做了什么。
## unet_learner
深度学习中最有趣的架构之一是我们在第一章中用于分割的架构。分割是一项具有挑战性的任务,因为所需的输出实际上是一幅图像,或者一个像素网格,包含了每个像素的预测标签。其他任务也有类似的基本设计,比如增加图像的分辨率(*超分辨率*)、给黑白图像添加颜色(*着色*)、或将照片转换为合成画作(*风格转移*)——这些任务在本书的[在线章节](https://book.fast.ai)中有介绍,所以在阅读完本章后一定要查看。在每种情况下,我们都是从一幅图像开始,将其转换为另一幅具有相同尺寸或纵横比的图像,但像素以某种方式被改变。我们将这些称为*生成式视觉模型*。
我们的做法是从与前一节中看到的开发 CNN 头部的确切方法开始。例如,我们从一个 ResNet 开始,然后截断自适应池化层和之后的所有层。然后我们用我们的自定义头部替换这些层,执行生成任务。
在上一句中有很多含糊之处!我们到底如何创建一个生成图像的 CNN 头部?如果我们从一个 224 像素的输入图像开始,那么在 ResNet 主体的末尾,我们将得到一个 7×7 的卷积激活网格。我们如何将其转换为一个 224 像素的分割掩模?
当然,我们使用神经网络来做这个!所以我们需要一种能够在 CNN 中增加网格大小的层。一个简单的方法是用一个 2×2 的方块替换 7×7 网格中的每个像素。这四个像素中的每一个将具有相同的值——这被称为*最近邻插值*。PyTorch 为我们提供了一个可以做到这一点的层,因此一个选项是创建一个包含步长为 1 的卷积层(以及通常的批归一化和 ReLU 层)和 2×2 最近邻插值层的头部。实际上,你现在可以尝试一下!看看你是否可以创建一个设计如此的自定义头部,并在 CamVid 分割任务上尝试一下。你应该会发现你得到了一些合理的结果,尽管它们不会像我们在第一章中的结果那样好。
另一种方法是用*转置卷积*替换最近邻和卷积的组合,也被称为*步长一半卷积*。这与常规卷积相同,但首先在输入的所有像素之间插入零填充。这在图片上最容易看到——图 15-1 显示了一张来自我们在第十三章讨论过的优秀的[卷积算术论文](https://oreil.ly/hu06c)中的图表,展示了一个应用于 3×3 图像的 3×3 转置卷积。
![一个转置卷积](img/dlcf_1501.png)
###### 图 15-1\. 一个转置卷积(由 Vincent Dumoulin 和 Francesco Visin 提供)
正如你所看到的,结果是增加输入的大小。你现在可以通过使用 fastai 的`ConvLayer`类来尝试一下;在你的自定义头部中传递参数`transpose=True`来创建一个转置卷积,而不是一个常规卷积。
然而,这两种方法都不是很好。问题在于我们的 7×7 网格根本没有足够的信息来创建一个 224×224 像素的输出。要求每个网格单元的激活具有足够的信息来完全重建输出中的每个像素是非常困难的。
解决方案是使用*跳跃连接*,就像 ResNet 中那样,但是从 ResNet 主体中的激活一直跳到架构对面的转置卷积的激活。这种方法在 2015 年 Olaf Ronneberger 等人的论文[“U-Net:用于生物医学图像分割的卷积网络”](https://oreil.ly/6ely4)中有所阐述。尽管该论文侧重于医学应用,但 U-Net 已经彻底改变了各种生成视觉模型。
![U-Net 架构](img/dlcf_1502.png)
###### 图 15-2。U-Net 架构(由 Olaf Ronneberger、Philipp Fischer 和 Thomas Brox 提供)
这幅图片展示了左侧的 CNN 主体(在这种情况下,它是一个常规的 CNN而不是 ResNet它们使用 2×2 最大池化而不是步幅为 2 的卷积,因为这篇论文是在 ResNets 出现之前写的),右侧是转置卷积(“上采样”)层。额外的跳跃连接显示为从左到右的灰色箭头(有时被称为*交叉连接*)。你可以看到为什么它被称为*U-Net*
有了这种架构,传递给转置卷积的输入不仅是前一层中较低分辨率的网格,还有 ResNet 头部中较高分辨率的网格。这使得 U-Net 可以根据需要使用原始图像的所有信息。U-Net 的一个挑战是确切的架构取决于图像大小。fastai 有一个独特的`DynamicUnet`类,根据提供的数据自动生成合适大小的架构。
现在让我们专注于一个示例,其中我们利用 fastai 库编写一个自定义模型。
## 孪生网络
让我们回到我们在第十一章中为孪生网络设置的输入管道。你可能还记得,它由一对图像组成,标签为`True`或`False`,取决于它们是否属于同一类。
利用我们刚刚看到的内容,让我们为这个任务构建一个自定义模型并对其进行训练。如何做?我们将使用一个预训练的架构并将我们的两个图像传递给它。然后我们可以连接结果并将它们发送到一个自定义头部,该头部将返回两个预测。在模块方面,看起来像这样:
```py
class SiameseModel(Module):
def __init__(self, encoder, head):
self.encoder,self.head = encoder,head
def forward(self, x1, x2):
ftrs = torch.cat([self.encoder(x1), self.encoder(x2)], dim=1)
return self.head(ftrs)
```
要创建我们的编码器,我们只需要取一个预训练模型并切割它,就像我们之前解释的那样。函数`create_body`为我们执行此操作我们只需传递我们想要切割的位置。正如我们之前看到的根据预训练模型的元数据字典ResNet 的切割值为`-2`
```py
encoder = create_body(resnet34, cut=-2)
```
然后我们可以创建我们的头部。查看编码器告诉我们最后一层有 512 个特征,所以这个头部将需要接收`512*4`。为什么是 4首先我们必须乘以 2因为我们有两个图像。然后我们需要第二次乘以 2因为我们的连接池技巧。因此我们创建头部如下
```py
head = create_head(512*4, 2, ps=0.5)
```
有了我们的编码器和头部,我们现在可以构建我们的模型:
```py
model = SiameseModel(encoder, head)
```
在使用`Learner`之前,我们还需要定义两件事。首先,我们必须定义要使用的损失函数。它是常规的交叉熵,但由于我们的目标是布尔值,我们需要将它们转换为整数,否则 PyTorch 会抛出错误:
```py
def loss_func(out, targ):
return nn.CrossEntropyLoss()(out, targ.long())
```
更重要的是,为了充分利用迁移学习,我们必须定义一个自定义的*splitter*。*splitter*是一个告诉 fastai 库如何将模型分成参数组的函数。这些在幕后用于在进行迁移学习时仅训练模型的头部。
这里我们想要两个参数组:一个用于编码器,一个用于头部。因此我们可以定义以下*splitter*`params`只是一个返回给定模块的所有参数的函数):
```py
def siamese_splitter(model):
return [params(model.encoder), params(model.head)]
```
然后,我们可以通过传递数据、模型、损失函数、分割器和任何我们想要的指标来定义我们的`Learner`。由于我们没有使用 fastai 的传输学习便利函数(如`cnn_learner`),我们必须手动调用`learn.freeze`。这将确保只有最后一个参数组(在本例中是头部)被训练:
```py
learn = Learner(dls, model, loss_func=loss_func,
splitter=siamese_splitter, metrics=accuracy)
learn.freeze()
```
然后我们可以直接使用通常的方法训练我们的模型:
```py
learn.fit_one_cycle(4, 3e-3)
```
| epoch | train_loss | valid_loss | accuracy | time |
| --- | --- | --- | --- | --- |
| 0 | 0.367015 | 0.281242 | 0.885656 | 00:26 |
| 1 | 0.307688 | 0.214721 | 0.915426 | 00:26 |
| 2 | 0.275221 | 0.170615 | 0.936401 | 00:26 |
| 3 | 0.223771 | 0.159633 | 0.943843 | 00:26 |
现在我们解冻并使用有区别的学习率微调整个模型一点(即,对于主体使用较低的学习率,对于头部使用较高的学习率):
```py
learn.unfreeze()
learn.fit_one_cycle(4, slice(1e-6,1e-4))
```
| epoch | train_loss | valid_loss | accuracy | time |
| --- | --- | --- | --- | --- |
| 0 | 0.212744 | 0.159033 | 0.944520 | 00:35 |
| 1 | 0.201893 | 0.159615 | 0.942490 | 00:35 |
| 2 | 0.204606 | 0.152338 | 0.945196 | 00:36 |
| 3 | 0.213203 | 0.148346 | 0.947903 | 00:36 |
94.8%是非常好的,当我们记得以相同方式训练的分类器(没有数据增强)的错误率为 7时。
现在我们已经看到如何创建完整的最先进的计算机视觉模型,让我们继续进行自然语言处理。
# 自然语言处理
将 AWD-LSTM 语言模型转换为迁移学习分类器,就像我们在第十章中所做的那样,遵循与本章第一节中的`cnn_learner`相似的过程。在这种情况下,我们不需要一个“元”字典,因为我们没有这么多种类的体系结构需要在主体中支持。我们只需要选择语言模型中的堆叠 RNN 作为编码器,这是一个单独的 PyTorch 模块。这个编码器将为输入的每个单词提供一个激活,因为语言模型需要为每个下一个单词输出一个预测。
要从中创建一个分类器,我们使用了[ULMFiT 论文](https://oreil.ly/3hdSj)中描述的一种方法,称为“用于文本分类的 BPTTBPT3C
> 我们将文档分成固定长度为*b*的批次。在每个批次的开始,模型使用前一个批次的最终状态进行初始化;我们跟踪用于平均值和最大池化的隐藏状态;梯度被反向传播到隐藏状态对最终预测有贡献的批次。在实践中,我们使用可变长度的反向传播序列。
换句话说,分类器包含一个`for`循环,循环遍历每个序列的批次。状态在批次之间保持不变,并且存储每个批次的激活。最后,我们使用相同的平均值和最大连接池技巧,这与我们用于计算机视觉模型的方法相同,但这一次,我们不是在 CNN 网格单元上进行池化,而是在 RNN 序列上进行池化。
对于这个`for`循环,我们需要将我们的数据分批处理,但每个文本需要单独处理,因为它们各自有自己的标签。然而,这些文本很可能不会都是相同的长度,这意味着我们无法将它们都放在同一个数组中,就像我们在语言模型中所做的那样。
这就是填充将会有所帮助的地方:当获取一堆文本时,我们确定最长的文本,然后用一个特殊的标记`xxpad`填充较短的文本。为了避免在同一批次中有一个包含 2,000 个标记的文本和一个包含 10 个标记的文本的极端情况(因此有很多填充和浪费的计算),我们通过确保相似大小的文本被放在一起来改变随机性。文本在训练集中仍然会以某种随机顺序排列(对于验证集,我们可以简单地按长度顺序排序),但不完全是这样。
这是由 fastai 库在创建我们的`DataLoaders`时在幕后自动完成的。
# 表格
最后,让我们看看`fastai.tabular`模型。(我们不需要单独查看协同过滤,因为我们已经看到这些模型只是表格模型或使用点积方法,我们之前从头开始实现。)
这是`TabularModel`的`forward`方法:
```py
if self.n_emb != 0:
x = [e(x_cat[:,i]) for i,e in enumerate(self.embeds)]
x = torch.cat(x, 1)
x = self.emb_drop(x)
if self.n_cont != 0:
x_cont = self.bn_cont(x_cont)
x = torch.cat([x, x_cont], 1) if self.n_emb != 0 else x_cont
return self.layers(x)
```
我们不会在这里显示`__init__`,因为这并不那么有趣,但会依次查看`forward`中的每行代码。第一行只是测试是否有任何嵌入需要处理-如果只有连续变量,我们可以跳过这一部分:
```py
if self.n_emb != 0:
```
`self.embeds`包含嵌入矩阵,因此这会获取每个激活
```py
x = [e(x_cat[:,i]) for i,e in enumerate(self.embeds)]
```
并将它们连接成一个单一张量:
```py
x = torch.cat(x, 1)
```
然后应用了辍学。您可以将`emb_drop`传递给`__init__`以更改此值:
```py
x = self.emb_drop(x)
```
现在我们测试是否有任何连续变量需要处理:
```py
if self.n_cont != 0:
```
它们通过一个批量归一化层
```py
x_cont = self.bn_cont(x_cont)
```
并与嵌入激活连接在一起,如果有的话:
```py
x = torch.cat([x, x_cont], 1) if self.n_emb != 0 else x_cont
```
最后,这些通过线性层传递(每个线性层包括批量归一化,如果`use_bn`为`True`,并且辍学,如果`ps`设置为某个值或值列表):
```py
return self.layers(x)
```
恭喜!现在您已经了解了 fastai 库中使用的每个架构的所有细节!
# 结论
正如您所看到的,深度学习架构的细节现在不应该让您感到恐惧。您可以查看 fastai 和 PyTorch 的代码,看看发生了什么。更重要的是,尝试理解*为什么*会发生这种情况。查看代码中引用的论文,并尝试看看代码如何与描述的算法相匹配。
现在我们已经调查了模型的所有部分以及传递给它的数据,我们可以考虑这对于实际深度学习意味着什么。如果您拥有无限的数据,无限的内存和无限的时间,那么建议很简单:在所有数据上训练一个巨大的模型很长时间。但深度学习不简单的原因是您的数据、内存和时间通常是有限的。如果内存或时间不足,解决方案是训练一个较小的模型。如果您无法训练足够长时间以过拟合,那么您没有充分利用模型的容量。
因此,第一步是达到过拟合的点。然后问题是如何减少过拟合。图 15-3 显示了我们建议从那里优先考虑的步骤。
![减少过拟合的步骤](img/dlcf_1503.png)
###### 图 15-3。减少过拟合的步骤
许多从业者在面对过拟合模型时,从这个图表的完全错误的一端开始。他们的起点是使用更小的模型或更多的正则化。除非训练模型占用太多时间或内存,否则使用更小的模型应该是您采取的最后一步。减小模型的大小会降低模型学习数据中微妙关系的能力。
相反,您的第一步应该是寻求*创建更多数据*。这可能涉及向您已经拥有的数据添加更多标签,找到模型可以被要求解决的其他任务(或者,换个角度思考,识别您可以建模的不同类型的标签),或者通过使用更多或不同的数据增强技术创建额外的合成数据。由于 Mixup 和类似方法的发展,现在几乎所有类型的数据都可以获得有效的数据增强。
一旦您获得了您认为可以合理获得的尽可能多的数据,并且通过利用您可以找到的所有标签并进行所有有意义的增强来尽可能有效地使用它,如果您仍然过拟合,您应该考虑使用更具一般化能力的架构。例如,添加批量归一化可能会提高泛化能力。
如果在尽力使用数据和调整架构后仍然过拟合,您可以考虑正则化。一般来说,在最后一层或两层添加 dropout 可以很好地正则化您的模型。然而,正如我们从 AWD-LSTM 开发故事中学到的那样,在整个模型中添加不同类型的 dropout 通常会更有帮助。一般来说,具有更多正则化的较大模型更灵活,因此比具有较少正则化的较小模型更准确。
只有在考虑了所有这些选项之后,我们才建议您尝试使用较小版本的架构。
# 问卷
1. 神经网络的头是什么?
1. 神经网络的主体是什么?
1. 什么是“剪切”神经网络?为什么我们需要在迁移学习中这样做?
1. `model_meta`是什么?尝试打印它以查看里面的内容。
1. 阅读`create_head`的源代码,并确保你理解每一行的作用。
1. 查看`create_head`的输出,并确保你理解每一层的存在原因,以及`create_head`源代码是如何创建它的。
1. 找出如何改变`create_cnn`创建的 dropout、层大小和层数并查看是否可以找到能够提高宠物识别准确性的值。
1. `AdaptiveConcatPool2d`是什么作用?
1. 什么是最近邻插值?如何用它来上采样卷积激活?
1. 什么是转置卷积?还有另一个名称是什么?
1. 创建一个带有`transpose=True`的卷积层,并将其应用于图像。检查输出形状。
1. 绘制 U-Net 架构。
1. 什么是用于文本分类的 BPTTBPT3C
1. 在 BPT3C 中如何处理不同长度的序列?
1. 尝试在笔记本中逐行运行`TabularModel.forward`的每一行,每个单元格一行,并查看每个步骤的输入和输出形状。
1. `TabularModel`中的`self.layers`是如何定义的?
1. 预防过拟合的五个步骤是什么?
1. 为什么在尝试其他方法预防过拟合之前不减少架构复杂性?
## 进一步研究
1. 编写自己的自定义头,并尝试使用它训练宠物识别器。看看是否可以获得比 fastai 默认更好的结果。
1. 尝试在 CNN 头部之间切换`AdaptiveConcatPool2d`和`AdaptiveAvgPool2d`,看看会有什么不同。
1. 编写自己的自定义分割器,为每个 ResNet 块创建一个单独的参数组,以及一个单独的参数组用于干扰。尝试使用它进行训练,看看是否可以改善宠物识别器。
1. 阅读关于生成图像模型的在线章节,并创建自己的着色器、超分辨率模型或风格转移模型。
1. 使用最近邻插值创建一个自定义头,并用它在 CamVid 上进行分割。

View File

@ -0,0 +1,683 @@
# 第十六章:训练过程
现在你知道如何为计算机视觉、自然图像处理、表格分析和协同过滤创建最先进的架构,也知道如何快速训练它们。所以我们完成了,对吧?还没有。我们仍然需要探索一下训练过程的更多内容。
我们在第四章中解释了随机梯度下降的基础:将一个小批量数据传递给模型,用损失函数将其与目标进行比较,然后计算这个损失函数对每个权重的梯度,然后使用公式更新权重:
```py
new_weight = weight - lr * weight.grad
```
我们在训练循环中从头开始实现了这个,看到 PyTorch 提供了一个简单的`nn.SGD`类,可以为我们的每个参数进行这个计算。在本章中,我们将构建一些更快的优化器,使用一个灵活的基础。但在训练过程中,我们可能还想要改变一些东西。对于训练循环的任何调整,我们都需要一种方法来向 SGD 的基础添加一些代码。fastai 库有一个回调系统来做到这一点,我们将教你所有相关知识。
让我们从标准的 SGD 开始建立一个基线;然后我们将介绍最常用的优化器。
# 建立基线
首先,我们将使用普通的 SGD 创建一个基线,并将其与 fastai 的默认优化器进行比较。我们将通过使用与第十四章中相同的`get_data`来获取 Imagenette
```py
dls = get_data(URLs.IMAGENETTE_160, 160, 128)
```
我们将创建一个没有预训练的 ResNet-34并传递任何接收到的参数
```py
def get_learner(**kwargs):
return cnn_learner(dls, resnet34, pretrained=False,
metrics=accuracy, **kwargs).to_fp16()
```
这是默认的 fastai 优化器,具有通常的 3e-3 学习率:
```py
learn = get_learner()
learn.fit_one_cycle(3, 0.003)
```
| epoch | train_loss | valid_loss | accuracy | time |
| --- | --- | --- | --- | --- |
| 0 | 2.571932 | 2.685040 | 0.322548 | 00:11 |
| 1 | 1.904674 | 1.852589 | 0.437452 | 00:11 |
| 2 | 1.586909 | 1.374908 | 0.594904 | 00:11 |
现在让我们尝试普通的 SGD。我们可以将`opt_func`(优化函数)传递给`cnn_learner`,以便让 fastai 使用任何优化器:
```py
learn = get_learner(opt_func=SGD)
```
首先要看的是`lr_find`
```py
learn.lr_find()
```
```py
(0.017378008365631102, 3.019951861915615e-07)
```
![](img/dlcf_16in01.png)
看起来我们需要使用比我们通常使用的更高的学习率:
```py
learn.fit_one_cycle(3, 0.03, moms=(0,0,0))
```
| epoch | train_loss | valid_loss | accuracy | time |
| --- | --- | --- | --- | --- |
| 0 | 2.969412 | 2.214596 | 0.242038 | 00:09 |
| 1 | 2.442730 | 1.845950 | 0.362548 | 00:09 |
| 2 | 2.157159 | 1.741143 | 0.408917 | 00:09 |
因为用动量加速 SGD 是一个很好的主意fastai 在`fit_one_cycle`中默认执行这个操作,所以我们用`moms=(0,0,0)`关闭它。我们很快会讨论动量。
显然,普通的 SGD 训练速度不如我们所希望的快。所以让我们学习一些技巧来加速训练!
# 通用优化器
为了构建我们加速的 SGD 技巧,我们需要从一个灵活的优化器基础开始。在 fastai 之前没有任何库提供这样的基础,但在 fastai 的开发过程中,我们意识到学术文献中看到的所有优化器改进都可以使用*优化器回调*来处理。这些是我们可以组合、混合和匹配在优化器中构建优化器步骤的小代码片段。它们由 fastai 的轻量级`Optimizer`类调用。这些是我们在本书中使用的两个关键方法在`Optimizer`中的定义:
```py
def zero_grad(self):
for p,*_ in self.all_params():
p.grad.detach_()
p.grad.zero_()
def step(self):
for p,pg,state,hyper in self.all_params():
for cb in self.cbs:
state = _update(state, cb(p, **{**state, **hyper}))
self.state[p] = state
```
正如我们在从头开始训练 MNIST 模型时看到的,`zero_grad`只是循环遍历模型的参数并将梯度设置为零。它还调用`detach_`,这会删除任何梯度计算的历史,因为在`zero_grad`之后不再需要它。
更有趣的方法是`step`,它循环遍历回调(`cbs`)并调用它们来更新参数(如果`cb`返回任何内容,`_update`函数只是调用`state.update`)。正如你所看到的,`Optimizer`本身不执行任何 SGD 步骤。让我们看看如何将 SGD 添加到`Optimizer`中。
这是一个优化器回调,通过将`-lr`乘以梯度并将其添加到参数(当在 PyTorch 中传递`Tensor.add_`两个参数时,它们在相加之前相乘)来执行单个 SGD 步骤:
```py
def sgd_cb(p, lr, **kwargs): p.data.add_(-lr, p.grad.data)
```
我们可以使用`cbs`参数将这个传递给`Optimizer`;我们需要使用`partial`,因为`Learner`将调用这个函数来创建我们的优化器:
```py
opt_func = partial(Optimizer, cbs=[sgd_cb])
```
让我们看看这是否有效:
```py
learn = get_learner(opt_func=opt_func)
learn.fit(3, 0.03)
```
| epoch | train_loss | valid_loss | accuracy | time |
| --- | --- | --- | --- | --- |
| 0 | 2.730918 | 2.009971 | 0.332739 | 00:09 |
| 1 | 2.204893 | 1.747202 | 0.441529 | 00:09 |
| 2 | 1.875621 | 1.684515 | 0.445350 | 00:09 |
它正在工作!这就是我们如何在 fastai 中从头开始创建 SGD。现在让我们看看这个“动量”是什么。
# 动量
正如在第四章中所描述的SGD 可以被看作站在山顶上,通过在每个时间点沿着最陡峭的斜坡方向迈出一步来往下走。但如果我们有一个球在山上滚动呢?在每个给定点,它不会完全按照梯度的方向前进,因为它会有*动量*。具有更多动量的球(例如,一个更重的球)会跳过小凸起和洞,更有可能到达崎岖山脉的底部。另一方面,乒乓球会卡在每一个小缝隙中。
那么我们如何将这个想法带到 SGD 中呢?我们可以使用移动平均值,而不仅仅是当前梯度,来进行我们的步骤:
```py
weight.avg = beta * weight.avg + (1-beta) * weight.grad
new_weight = weight - lr * weight.avg
```
这里`beta`是我们选择的一个数字,定义了要使用多少动量。如果`beta`为 0第一个方程变为`weight.avg = weight.grad`,因此我们最终得到普通的 SGD。但如果它接近 1所选择的主要方向是以前步骤的平均值。如果你对统计学有一点了解你可能会在第一个方程中认出*指数加权移动平均*,它经常用于去噪数据并获得潜在趋势。)
请注意,我们写`weight.avg`以突出显示我们需要为模型的每个参数存储移动平均值(它们都有自己独立的移动平均值)。
图 16-1 显示了一个单参数的噪声数据示例,其中动量曲线以红色绘制,参数的梯度以蓝色绘制。梯度增加,然后减少,动量很好地跟随总体趋势,而不会受到噪声的太大影响。
![显示动量示例的图表](img/dlcf_1601.png)
###### 图 16-1。动量的一个例子
如果损失函数有窄谷,我们需要导航:普通的 SGD 会使我们从一边反弹到另一边,而带有动量的 SGD 会将这些平均值平滑地滚动到一侧。参数`beta`确定我们使用的动量的强度:使用较小的`beta`,我们会保持接近实际梯度值,而使用较高的`beta`,我们将主要朝着梯度的平均值前进,直到梯度的任何变化使得该趋势移动。
使用较大的`beta`,我们可能会错过梯度改变方向并滚动到一个小的局部最小值。这是一个期望的副作用:直观地,当我们向模型展示一个新的输入时,它会看起来像训练集中的某个东西,但不会*完全*像它。它将对应于损失函数中接近我们在训练结束时得到的最小值的点,但不会*在*那个最小值。因此,我们宁愿在一个宽阔的最小值中进行训练,附近的点具有近似相同的损失(或者如果你喜欢的话,损失尽可能平坦的点)。图 16-2 显示了当我们改变`beta`时,图 16-1 中的图表如何变化。
![显示 beta 值如何影响动量的图表](img/dlcf_1602.png)
###### 图 16-2。不同 beta 值的动量
我们可以看到在这些示例中,`beta`太高会导致梯度的整体变化被忽略。在带动量的 SGD 中,通常使用的`beta`值为 0.9。
`fit_one_cycle`默认从 0.95 开始,逐渐调整到 0.85,然后在训练结束时逐渐移回到 0.95。让我们看看在普通 SGD 中添加动量后我们的训练情况如何。
要向我们的优化器添加动量,我们首先需要跟踪移动平均梯度,我们可以使用另一个回调来实现。当优化器回调返回一个`dict`时,它用于更新优化器的状态,并在下一步传回优化器。因此,这个回调将跟踪梯度平均值,存储在名为`grad_avg`的参数中:
```py
def average_grad(p, mom, grad_avg=None, **kwargs):
if grad_avg is None: grad_avg = torch.zeros_like(p.grad.data)
return {'grad_avg': grad_avg*mom + p.grad.data}
```
要使用它,我们只需在我们的步骤函数中用`grad_avg`替换`p.grad.data`
```py
def momentum_step(p, lr, grad_avg, **kwargs): p.data.add_(-lr, grad_avg)
```
```py
opt_func = partial(Optimizer, cbs=[average_grad,momentum_step], mom=0.9)
```
`Learner`将自动调度`mom`和`lr`,因此`fit_one_cycle`甚至可以与我们自定义的`Optimizer`一起使用:
```py
learn = get_learner(opt_func=opt_func)
learn.fit_one_cycle(3, 0.03)
```
| epoch | train_loss | valid_loss | accuracy | time |
| --- | --- | --- | --- | --- |
| 0 | 2.856000 | 2.493429 | 0.246115 | 00:10 |
| 1 | 2.504205 | 2.463813 | 0.348280 | 00:10 |
| 2 | 2.187387 | 1.755670 | 0.418853 | 00:10 |
```py
learn.recorder.plot_sched()
```
![](img/dlcf_16in02.png)
我们仍然没有得到很好的结果,所以让我们看看我们还能做什么。
# RMSProp
RMSProp 是由 Geoffrey Hinton 在[他的 Coursera 课程“神经网络机器学习”第 6 讲 e](https://oreil.ly/FVcIE)中介绍的 SGD 的另一种变体。与 SGD 的主要区别在于它使用自适应学习率:每个参数都有自己特定的学习率,由全局学习率控制。这样,我们可以通过为需要大幅度改变的权重提供更高的学习率来加速训练,而对于已经足够好的权重,则提供较低的学习率。
我们如何决定哪些参数应该具有较高的学习率,哪些不应该?我们可以查看梯度来获取一个想法。如果一个参数的梯度一直接近于零,那么该参数将需要更高的学习率,因为损失是平的。另一方面,如果梯度到处都是,我们可能应该小心并选择一个较低的学习率以避免发散。我们不能简单地平均梯度来查看它们是否变化很多,因为大正数和大负数的平均值接近于零。相反,我们可以使用通常的技巧,即取绝对值或平方值(然后在平均后取平方根)。
再次,为了确定噪声背后的一般趋势,我们将使用移动平均值,具体来说是梯度的平方的移动平均值。然后,我们将通过使用当前梯度(用于方向)除以这个移动平均值的平方根来更新相应的权重(这样,如果它很低,有效的学习率将更高,如果它很高,有效的学习率将更低):
```py
w.square_avg = alpha * w.square_avg + (1-alpha) * (w.grad ** 2)
new_w = w - lr * w.grad / math.sqrt(w.square_avg + eps)
```
`eps`*epsilon*)是为了数值稳定性而添加的(通常设置为 1e-8`alpha`的默认值通常为 0.99。
我们可以通过做与`avg_grad`类似的事情将其添加到`Optimizer`中,但是多了一个`**2`
```py
def average_sqr_grad(p, sqr_mom, sqr_avg=None, **kwargs):
if sqr_avg is None: sqr_avg = torch.zeros_like(p.grad.data)
return {'sqr_avg': sqr_avg*sqr_mom + p.grad.data**2}
```
我们可以像以前一样定义我们的步骤函数和优化器:
```py
def rms_prop_step(p, lr, sqr_avg, eps, grad_avg=None, **kwargs):
denom = sqr_avg.sqrt().add_(eps)
p.data.addcdiv_(-lr, p.grad, denom)
opt_func = partial(Optimizer, cbs=[average_sqr_grad,rms_prop_step],
sqr_mom=0.99, eps=1e-7)
```
让我们试一试:
```py
learn = get_learner(opt_func=opt_func)
learn.fit_one_cycle(3, 0.003)
```
| epoch | train_loss | valid_loss | accuracy | time |
| --- | --- | --- | --- | --- |
| 0 | 2.766912 | 1.845900 | 0.402548 | 00:11 |
| 1 | 2.194586 | 1.510269 | 0.504459 | 00:11 |
| 2 | 1.869099 | 1.447939 | 0.544968 | 00:11 |
好多了!现在我们只需将这些想法结合起来,我们就有了 Adamfastai 的默认优化器。
# Adam
Adam 将 SGD 与动量和 RMSProp 的思想结合在一起:它使用梯度的移动平均作为方向,并除以梯度平方的移动平均的平方根,为每个参数提供自适应学习率。
Adam 计算移动平均值的方式还有一个不同之处。它采用*无偏*移动平均值,即
```py
w.avg = beta * w.avg + (1-beta) * w.grad
unbias_avg = w.avg / (1 - (beta**(i+1)))
```
如果我们是第`i`次迭代(从 0 开始,就像 Python 一样)。这个除数`1 - (beta**(i+1))`确保无偏平均值看起来更像开始时的梯度(因为`beta < 1`分母很快接近 1
将所有内容放在一起,我们的更新步骤看起来像这样:
```py
w.avg = beta1 * w.avg + (1-beta1) * w.grad
unbias_avg = w.avg / (1 - (beta1**(i+1)))
w.sqr_avg = beta2 * w.sqr_avg + (1-beta2) * (w.grad ** 2)
new_w = w - lr * unbias_avg / sqrt(w.sqr_avg + eps)
```
至于 RMSProp`eps`通常设置为 1e-8文献建议的`(beta1beta2)`的默认值为`(0.90.999)`。
在 fastai 中Adam 是我们使用的默认优化器,因为它可以加快训练速度,但我们发现`beta2=0.99`更适合我们使用的调度类型。`beta1`是动量参数,我们在调用`fit_one_cycle`时用参数`moms`指定。至于`eps`fastai 使用默认值 1e-5。`eps`不仅仅对数值稳定性有用。更高的`eps`限制了调整学习率的最大值。举个极端的例子,如果`eps`为 1那么调整后的学习率永远不会高于基本学习率。
与其在书中展示所有这些代码,我们让你去看看 fastai 的优化器笔记本[*https://oreil.ly/*](https://oreil.ly/)*24_O[GitHub 存储库](浏览 _nbs*文件夹并搜索名为*optimizer*的笔记本)。你会看到到目前为止我们展示的所有代码,以及 Adam 和其他优化器,以及许多示例和测试。
当我们从 SGD 转换为 Adam 时,有一件事情会改变,那就是我们如何应用权重衰减,这可能会产生重要的后果。
# 解耦权重衰减
权重衰减,我们在第八章中讨论过,相当于(在普通 SGD 的情况下)用以下方式更新参数:
```py
new_weight = weight - lr*weight.grad - lr*wd*weight
```
这个公式的最后一部分解释了这种技术的名称:每个权重都会被`lr * wd`的因子衰减。
权重衰减的另一个名称是*L2 正则化*,它包括将所有平方权重的总和添加到损失中(乘以权重衰减)。正如我们在第八章中看到的,这可以直接表达在梯度上:
```py
weight.grad += wd*weight
```
对于 SGD这两个公式是等价的。然而这种等价性仅适用于标准 SGD因为正如我们在动量、RMSProp 或 Adam 中看到的,更新周围有一些额外的梯度公式。
大多数库使用第二种公式,但 Ilya Loshchilov 和 Frank Hutter 在[“解耦权重衰减正则化”](https://oreil.ly/w37Ac)中指出,第一种方法是 Adam 优化器或动量的唯一正确方法,这就是为什么 fastai 将其设为默认值。
现在你知道了`learn.fit_one_cycle`这行代码背后隐藏的一切!
然而,优化器只是训练过程的一部分。当你需要改变 fastai 的训练循环时,你不能直接改变库内的代码。相反,我们设计了一套回调系统,让你可以在独立的块中编写任何你喜欢的调整,然后进行混合和匹配。
# 回调
有时候你需要稍微改变事物的工作方式。事实上我们已经看到了这种情况的例子Mixupfp16 训练,每个时期重置模型以训练 RNN 等。我们如何进行这种类型的调整训练过程?
我们已经看到了基本训练循环,借助`Optimizer`类的帮助,对于单个时期,它看起来像这样:
```py
for xb,yb in dl:
loss = loss_func(model(xb), yb)
loss.backward()
opt.step()
opt.zero_grad()
```
图 16-3 展示了如何形象地描绘这一点。
![基本训练循环](img/dlcf_1603.png)
###### 图 16-3. 基本训练循环
深度学习从业者通常自定义训练循环的常规方式是复制现有训练循环,然后将特定更改所需的代码插入其中。这几乎是你在网上找到的所有代码的样子。但是它存在严重问题。
某个特定调整过的训练循环不太可能满足您的特定需求。可以对训练循环进行数百次更改,这意味着可能有数十亿种可能的排列组合。您不能只是从这里的一个训练循环中复制一个调整,从那里的另一个训练循环中复制另一个调整,然后期望它们都能一起工作。每个都将基于对其所在环境的不同假设,使用不同的命名约定,并期望数据以不同的格式存在。
我们需要一种方法,允许用户在训练循环的任何部分插入自己的代码,但以一种一致和明确定义的方式。计算机科学家已经提出了一个优雅的解决方案:回调。*回调*是您编写并注入到另一段代码中的代码片段,在预定义的点执行。事实上,回调已经多年用于深度学习训练循环。问题在于,在以前的库中,只能在可能需要的一小部分地方注入代码——更重要的是,回调无法执行它们需要执行的所有操作。
为了能够像手动复制和粘贴训练循环并直接插入代码一样灵活回调必须能够读取训练循环中的所有可能信息根据需要修改所有信息并完全控制批次、周期甚至整个训练循环何时应该终止。fastai 是第一个提供所有这些功能的库。它修改了训练循环,使其看起来像图 16-4。
![带有回调的训练循环](img/dlcf_1604.png)
###### 图 16-4. 带有回调的训练循环
这种方法的有效性在过去几年中得到了验证——通过使用 fastai 回调系统,我们能够实现我们尝试的每一篇新论文,并满足每一个修改训练循环的用户请求。训练循环本身并不需要修改。图 16-5 展示了添加的一些回调。
![一些 fastai 回调](img/dlcf_1605.png)
###### 图 16-5. 一些 fastai 回调
这很重要,因为这意味着我们头脑中的任何想法,我们都可以实现。我们永远不需要深入 PyTorch 或 fastai 的源代码,并临时拼凑一个系统来尝试我们的想法。当我们实现自己的回调来开发自己的想法时,我们知道它们将与 fastai 提供的所有其他功能一起工作——因此我们将获得进度条、混合精度训练、超参数退火等等。
另一个优点是,它使逐渐删除或添加功能以及执行消融研究变得容易。您只需要调整传递给 fit 函数的回调列表。
例如,这是每个训练循环批次运行的 fastai 源代码:
```py
try:
self._split(b); self('begin_batch')
self.pred = self.model(*self.xb); self('after_pred')
self.loss = self.loss_func(self.pred, *self.yb); self('after_loss')
if not self.training: return
self.loss.backward(); self('after_backward')
self.opt.step(); self('after_step')
self.opt.zero_grad()
except CancelBatchException: self('after_cancel_batch')
finally: self('after_batch')
```
形式为`self('...')`的调用是回调被调用的地方。正如您所看到的,这发生在每一步之后。回调将接收整个训练状态,并且还可以修改它。例如,输入数据和目标标签分别在`self.xb`和`self.yb`中;回调可以修改这些以修改训练循环看到的数据。它还可以修改`self.loss`甚至梯度。
让我们通过编写一个回调来看看这在实践中是如何工作的。
## 创建回调
当您想要编写自己的回调时,可用事件的完整列表如下:
`begin_fit`
在做任何事情之前调用;适用于初始设置。
`begin_epoch`
在每个周期开始时调用;对于需要在每个周期重置的任何行为都很有用。
`begin_train`
在周期的训练部分开始时调用。
`begin_batch`
在每个批次开始时调用,就在绘制该批次之后。可以用于对批次进行任何必要的设置(如超参数调度)或在输入/目标进入模型之前对其进行更改(例如,通过应用 Mixup
`after_pred`
在计算模型对批次的输出后调用。可以用于在将其馈送到损失函数之前更改该输出。
`after_loss`
在计算损失之后但在反向传播之前调用。可以用于向损失添加惩罚(例如在 RNN 训练中的 AR 或 TAR
`after_backward`
在反向传播之后调用,但在参数更新之前调用。可以在更新之前对梯度进行更改(例如通过梯度裁剪)。
`after_step`
在步骤之后和梯度归零之前调用。
`after_batch`
在批次结束时调用,以在下一个批次之前执行任何必要的清理。
`after_train`
在时代的训练阶段结束时调用。
`begin_validate`
在时代的验证阶段开始时调用;用于特定于验证所需的任何设置。
`after_validate`
在时代的验证部分结束时调用。
`after_epoch`
在时代结束时调用,进行下一个时代之前的任何清理。
`after_fit`
在训练结束时调用,进行最终清理。
此列表的元素可作为特殊变量`event`的属性使用,因此您只需在笔记本中键入`event.`并按 Tab 键即可查看所有选项的列表
让我们看一个例子。您是否还记得在第十二章中我们需要确保在每个时代的训练和验证开始时调用我们的特殊`reset`方法?我们使用 fastai 提供的`ModelResetter`回调来为我们执行此操作。但它究竟是如何工作的呢?这是该类的完整源代码:
```py
class ModelResetter(Callback):
def begin_train(self): self.model.reset()
def begin_validate(self): self.model.reset()
```
是的,实际上就是这样!它只是在完成时代的训练或验证后,调用一个名为`reset`的方法。
回调通常像这样“简短而甜美”。实际上,让我们再看一个。这是添加 RNN 正则化AR 和 TAR的 fastai 回调的源代码:
```py
class RNNRegularizer(Callback):
def __init__(self, alpha=0., beta=0.): self.alpha,self.beta = alpha,beta
def after_pred(self):
self.raw_out,self.out = self.pred[1],self.pred[2]
self.learn.pred = self.pred[0]
def after_loss(self):
if not self.training: return
if self.alpha != 0.:
self.learn.loss += self.alpha * self.out[-1].float().pow(2).mean()
if self.beta != 0.:
h = self.raw_out[-1]
if len(h)>1:
self.learn.loss += self.beta * (h[:,1:] - h[:,:-1]
).float().pow(2).mean()
```
# 自己编写代码。
回去重新阅读“激活正则化和时间激活正则化”,然后再看看这里的代码。确保您理解它在做什么以及为什么。
在这两个示例中,请注意我们如何可以通过直接检查`self.model`或`self.pred`来访问训练循环的属性。这是因为`Callback`将始终尝试获取其内部`Learner`中没有的属性。这些是`self.learn.model`或`self.learn.pred`的快捷方式。请注意,它们适用于读取属性,但不适用于编写属性,这就是为什么当`RNNRegularizer`更改损失或预测时,您会看到`self.learn.loss =`或`self.learn.pred =`。
在编写回调时,可以直接检查`Learner`的以下属性:
`model`
用于训练/验证的模型。
`data`
底层的`DataLoaders`。
`loss_func`
使用的损失函数。
`opt`
用于更新模型参数的优化器。
`opt_func`
用于创建优化器的函数。
`cbs`
包含所有`Callback`的列表。
`dl`
用于迭代的当前`DataLoader`。
`x`/`xb`
从`self.dl`中绘制的最后一个输入(可能由回调修改)。`xb`始终是一个元组(可能有一个元素),`x`是去元组化的。您只能分配给`xb`。
`y`/`yb`
从`self.dl`中绘制的最后一个目标(可能由回调修改)。`yb`始终是一个元组(可能有一个元素),`y`是去元组化的。您只能分配给`yb`。
`pred`
从`self.model`中绘制的最后预测(可能由回调修改)。
`loss`
最后计算的损失(可能由回调修改)。
`n_epoch`
此次训练的时代数。
`n_iter`
当前`self.dl`中的迭代次数。
`纪元`
当前纪元索引(从 0 到`n_epoch-1`)。
`iter`
`self.dl`中的当前迭代索引(从 0 到`n_iter-1`)。
以下属性由`TrainEvalCallback`添加,除非您刻意删除该回调,否则应该可用:
`train_iter`
自此次训练开始以来已完成的训练迭代次数
`pct_train`
已完成的训练迭代的百分比(从 0 到 1
`training`
一个指示我们是否处于训练模式的标志
以下属性由`Recorder`添加,除非您刻意删除该回调,否则应该可用:
`smooth_loss`
训练损失的指数平均版本
回调也可以通过使用异常系统中断训练循环的任何部分。
## 回调排序和异常
有时回调需要能够告诉 fastai 跳过一个批次或一个纪元,或者完全停止训练。例如,考虑`TerminateOnNaNCallback`。这个方便的回调将在损失变为无穷大或`NaN`*不是一个数字*)时自动停止训练。以下是此回调的 fastai 源代码:
```py
class TerminateOnNaNCallback(Callback):
run_before=Recorder
def after_batch(self):
if torch.isinf(self.loss) or torch.isnan(self.loss):
raise CancelFitException
```
`raise CancelFitException`这一行告诉训练循环在这一点中断训练。训练循环捕获此异常并不再运行任何进一步的训练或验证。可用的回调控制流异常如下:
`CancelFitException`
跳过本批次的其余部分并转到`after_batch`。
`CancelEpochException`
跳过本纪元的训练部分的其余部分并转到`after_train`。
`CancelTrainException`
跳过本纪元的验证部分的其余部分并转到`after_validate`。
`CancelValidException`
跳过本纪元的其余部分并转到`after_epoch`。
`CancelBatchException`
训练中断并转到`after_fit`。
您可以检测是否发生了其中一个异常,并添加代码,以在以下事件之后立即执行:
`after_cancel_batch`
在继续到`after_batch`之前立即到达`CancelBatchException`后
`after_cancel_train`
在继续到`after_epoch`之前立即到达`CancelTrainException`后
`after_cancel_valid`
在继续到`after_epoch`之前立即到达`CancelValidException`后
`after_cancel_epoch`
在继续到`after_epoch`之前立即到达`CancelEpochException`后
`after_cancel_fit`
在继续到`after_fit`之前立即到达`CancelFitException`后
有时需要按特定顺序调用回调。例如,在`TerminateOnNaNCallback`的情况下,很重要的是`Recorder`在此回调之后运行其`after_batch`,以避免注册`NaN`损失。您可以在回调中指定`run_before`(此回调必须在之前运行...)或`run_after`(此回调必须在之后运行...)以确保您需要的顺序。
# 结论
在本章中,我们仔细研究了训练循环,探讨了 SGD 的变体以及为什么它们可能更强大。在撰写本文时,开发新的优化器是一个活跃的研究领域,因此在阅读本章时,可能会在[书籍网站](https://book.fast.ai)上发布新变体的附录。请务必查看我们的通用优化器框架如何帮助您快速实现新的优化器。
我们还研究了强大的回调系统,该系统允许您通过允许您在每个步骤之间检查和修改任何参数来自定义训练循环的每一部分。
# 问卷调查
1. SGD 一步的方程是什么,以数学或代码形式(您喜欢的方式)?
1. 我们传递什么给`cnn_learner`以使用非默认优化器?
1. 什么是优化器回调?
1. 优化器中的`zero_grad`是做什么的?
1. 优化器中的`step`是做什么的?通常优化器中如何实现它?
1. 重写`sgd_cb`以使用`+=`运算符,而不是`add_`。
1. 什么是动量?写出方程式。
1. 动量的物理类比是什么?它如何应用在我们的模型训练设置中?
1. 动量值越大对梯度有什么影响?
1. 1cycle 训练的动量的默认值是多少?
1. RMSProp 是什么?写出方程。
1. 梯度的平方值表示什么?
1. Adam 与动量和 RMSProp 有何不同?
1. 写出 Adam 的方程。
1. 计算几批虚拟值的`unbias_avg`和`w.avg`的值。
1. 在 Adam 中,`eps`值较高会产生什么影响?
1. 阅读 fastai 存储库中的优化器笔记本并执行它。
1. 在哪些情况下,像 Adam 这样的动态学习率方法会改变权重衰减的行为?
1. 训练循环的四个步骤是什么?
1. 为什么使用回调比为每个想要添加的调整编写新的训练循环更好?
1. fastai 回调系统设计的哪些方面使其像复制和粘贴代码片段一样灵活?
1. 在编写回调时,如何获取可用事件的列表?
1. 编写`ModelResetter`回调(请不要偷看)。
1. 如何在回调内部访问训练循环的必要属性?何时可以使用或不使用与它们配套的快捷方式?
1. 回调如何影响训练循环的控制流?
1. 编写`TerminateOnNaN`回调(如果可能的话,请不要偷看)。
1. 如何确保你的回调在另一个回调之后或之前运行?
## 进一步研究
1. 查阅“修正的 Adam”论文使用通用优化器框架实现它并尝试一下。搜索其他最近在实践中表现良好的优化器并选择一个实现。
1. 查看[文档](https://docs.fast.ai)中的混合精度回调。尝试理解每个事件和代码行的作用。
1. 从头开始实现自己版本的学习率查找器。与 fastai 的版本进行比较。
1. 查看 fastai 附带的回调的源代码。看看能否找到一个与你要做的类似的回调,以获得一些灵感。
# 深度学习基础:总结
恭喜你——你已经完成了书中“深度学习基础”部分!现在你理解了 fastai 的所有应用程序和最重要的架构是如何构建的,以及训练它们的推荐方法——你拥有构建这些内容所需的所有信息。虽然你可能不需要创建自己的训练循环或批归一化层,但了解幕后发生的事情对于调试、性能分析和部署解决方案非常有帮助。
既然你现在理解了 fastai 应用的基础,一定要花时间深入研究源代码笔记本,并运行和实验它们的部分。这将让你更清楚地了解 fastai 中的所有内容是如何开发的。
在下一节中,我们将更深入地探讨:我们将探索神经网络的实际前向和后向传递是如何进行的,以及我们可以利用哪些工具来获得更好的性能。然后,我们将继续进行一个项目,将书中的所有材料汇集在一起,用它来构建一个用于解释卷积神经网络的工具。最后但并非最不重要的是,我们将从头开始构建 fastai 的`Learner`类。

File diff suppressed because it is too large Load Diff

263
translations/cn/18_CAM.md Normal file
View File

@ -0,0 +1,263 @@
# 第十八章:使用 CAM 解释 CNN
现在我们知道如何从头开始构建几乎任何东西,让我们利用这些知识来创建全新(并非常有用!)的功能:*类激活图*。它让我们对 CNN 为何做出预测有一些见解。
在这个过程中,我们将学习到 PyTorch 中一个我们之前没有见过的方便功能,*hook*,并且我们将应用本书中介绍的许多概念。如果你想真正测试你对本书材料的理解,完成本章后,尝试将其放在一边,从头开始重新创建这里的想法(不要偷看!)。
# CAM 和 Hooks
*类激活图*CAM是由周博磊等人在[“学习用于区分定位的深度特征”](https://oreil.ly/5hik3)中引入的。它使用最后一个卷积层的输出(就在平均池化层之前)以及预测结果,为我们提供一个热图可视化,解释模型为何做出决定。这是一个有用的解释工具。
更准确地说,在我们最终卷积层的每个位置,我们有与最后一个线性层中一样多的滤波器。因此,我们可以计算这些激活与最终权重的点积,以便为我们特征图上的每个位置得到用于做出决定的特征的分数。
在训练模型时,我们需要一种方法来访问模型内部的激活。在 PyTorch 中,可以通过 *hook* 来实现。Hook 是 PyTorch 的等价于 fastai 的回调。然而,与允许您像 fastai 的 `Learner` 回调一样将代码注入训练循环不同hook 允许您将代码注入前向和反向计算本身。我们可以将 hook 附加到模型的任何层,并且在计算输出(前向 hook或反向传播后向 hook时执行。前向 hook 是一个接受三个参数的函数——一个模块它的输入和输出——它可以执行任何您想要的行为。fastai 还提供了一个方便的 `HookCallback`,我们这里不涉及,但看看 fastai 文档;它使使用 hook 更容易一些。)
为了说明,我们将使用我们在第一章中训练的相同的猫狗模型:
```py
path = untar_data(URLs.PETS)/'images'
def is_cat(x): return x[0].isupper()
dls = ImageDataLoaders.from_name_func(
path, get_image_files(path), valid_pct=0.2, seed=21,
label_func=is_cat, item_tfms=Resize(224))
learn = cnn_learner(dls, resnet34, metrics=error_rate)
learn.fine_tune(1)
```
| 轮次 | 训练损失 | 验证损失 | 错误率 | 时间 |
| --- | --- | --- | --- | --- |
| 0 | 0.141987 | 0.018823 | 0.007442 | 00:16 |
| 轮次 | 训练损失 | 验证损失 | 错误率 | 时间 |
| --- | --- | --- | --- | --- |
| 0 | 0.050934 | 0.015366 | 0.006766 | 00:21 |
首先,我们将获取一张猫的图片和一批数据:
```py
img = PILImage.create('images/chapter1_cat_example.jpg')
x, = first(dls.test_dl([img]))
```
对于 CAM我们想要存储最后一个卷积层的激活。我们将我们的 hook 函数放在一个类中,这样它就有一个我们稍后可以访问的状态,并且只存储输出的副本:
```py
class Hook():
def hook_func(self, m, i, o): self.stored = o.detach().clone()
```
然后我们可以实例化一个 `Hook` 并将其附加到我们想要的层,即 CNN 主体的最后一层:
```py
hook_output = Hook()
hook = learn.model[0].register_forward_hook(hook_output.hook_func)
```
现在我们可以获取一个批次并将其通过我们的模型:
```py
with torch.no_grad(): output = learn.model.eval()(x)
```
我们可以访问我们存储的激活:
```py
act = hook_output.stored[0]
```
让我们再次双重检查我们的预测:
```py
F.softmax(output, dim=-1)
```
```py
tensor([[7.3566e-07, 1.0000e+00]], device='cuda:0')
```
我们知道 `0`(对于 `False`)是“狗”,因为在 fastai 中类别会自动排序,但我们仍然可以通过查看 `dls.vocab` 来进行双重检查:
```py
dls.vocab
```
```py
(#2) [False,True]
```
所以,我们的模型非常确信这是一张猫的图片。
为了对我们的权重矩阵2 乘以激活数量)与激活(批次大小乘以激活乘以行乘以列)进行点积,我们使用自定义的 `einsum`
```py
x.shape
```
```py
torch.Size([1, 3, 224, 224])
```
```py
cam_map = torch.einsum('ck,kij->cij', learn.model[1][-1].weight, act)
cam_map.shape
```
```py
torch.Size([2, 7, 7])
```
对于我们批次中的每个图像,对于每个类别,我们得到一个 7×7 的特征图,告诉我们激活较高和较低的位置。这将让我们看到哪些图片区域影响了模型的决策。
例如,我们可以找出哪些区域使模型决定这个动物是一只猫(请注意,由于`DataLoader`对输入`x`进行了归一化,我们需要`decode`并且由于在撰写本书时PyTorch 在索引时不保留类型,我们需要转换为`TensorImage`——这可能在您阅读本文时已经修复):
```py
x_dec = TensorImage(dls.train.decode((x,))[0][0])
_,ax = plt.subplots()
x_dec.show(ctx=ax)
ax.imshow(cam_map[1].detach().cpu(), alpha=0.6, extent=(0,224,224,0),
interpolation='bilinear', cmap='magma');
```
![](img/dlcf_18in01.png)
在这种情况下,明亮黄色的区域对应于高激活,紫色区域对应于低激活。在这种情况下,我们可以看到头部和前爪是使模型决定这是一张猫的图片的两个主要区域。
完成钩子后,应该将其删除,否则可能会泄漏一些内存:
```py
hook.remove()
```
这就是为什么将`Hook`类作为*上下文管理器*通常是一个好主意,当您进入时注册钩子,当您退出时删除它。上下文管理器是一个 Python 构造,在`with`子句中创建对象时调用`__enter__`,在`with`子句结束时调用`__exit__`。例如,这就是 Python 处理`with open(...) as f:`构造的方式,您经常会看到用于打开文件而不需要在最后显式调用`close(f)`。
如果我们将`Hook`定义如下
```py
class Hook():
def __init__(self, m):
self.hook = m.register_forward_hook(self.hook_func)
def hook_func(self, m, i, o): self.stored = o.detach().clone()
def __enter__(self, *args): return self
def __exit__(self, *args): self.hook.remove()
```
我们可以安全地这样使用它:
```py
with Hook(learn.model[0]) as hook:
with torch.no_grad(): output = learn.model.eval()(x.cuda())
act = hook.stored
```
fastai 为您提供了这个`Hook`类,以及一些其他方便的类,使使用钩子更容易。
这种方法很有用,但仅适用于最后一层。*梯度 CAM*是一个解决这个问题的变体。
# 梯度 CAM
我们刚刚看到的方法让我们只能计算最后激活的热图因为一旦我们有了我们的特征我们必须将它们乘以最后的权重矩阵。这对网络中的内部层不起作用。2016 年的一篇论文[“Grad-CAM: Why Did You Say That?”](https://oreil.ly/4krXE)由 Ramprasaath R. Selvaraju 等人介绍了一种变体,使用所需类的最终激活的梯度。如果您还记得一点关于反向传播的知识,最后一层输出的梯度与该层输入的梯度相对应,因为它是一个线性层。
对于更深的层我们仍然希望梯度但它们不再等于权重。我们必须计算它们。PyTorch 在反向传播期间为我们计算每一层的梯度,但它们不会被存储(除了`requires_grad`为`True`的张量。然而我们可以在反向传播上注册一个钩子PyTorch 将把梯度作为参数传递给它,因此我们可以在那里存储它们。为此,我们将使用一个`HookBwd`类,它的工作方式类似于`Hook`,但是拦截并存储梯度而不是激活:
```py
class HookBwd():
def __init__(self, m):
self.hook = m.register_backward_hook(self.hook_func)
def hook_func(self, m, gi, go): self.stored = go[0].detach().clone()
def __enter__(self, *args): return self
def __exit__(self, *args): self.hook.remove()
```
然后对于类索引`1`(对于`True`,即“猫”),我们拦截最后一个卷积层的特征,如前所述,计算我们类的输出激活的梯度。我们不能简单地调用`output.backward`,因为梯度只对标量有意义(通常是我们的损失),而`output`是一个秩为 2 的张量。但是,如果我们选择单个图像(我们将使用`0`)和单个类(我们将使用`1`),我们*可以*计算我们喜欢的任何权重或激活的梯度,与该单个值相关,使用`output[0,cls].backward`。我们的钩子拦截了我们将用作权重的梯度:
```py
cls = 1
with HookBwd(learn.model[0]) as hookg:
with Hook(learn.model[0]) as hook:
output = learn.model.eval()(x.cuda())
act = hook.stored
output[0,cls].backward()
grad = hookg.stored
```
Grad-CAM 的权重由特征图上的梯度平均值给出。然后就像以前一样:
```py
w = grad[0].mean(dim=[1,2], keepdim=True)
cam_map = (w * act[0]).sum(0)
```
```py
_,ax = plt.subplots()
x_dec.show(ctx=ax)
ax.imshow(cam_map.detach().cpu(), alpha=0.6, extent=(0,224,224,0),
interpolation='bilinear', cmap='magma');
```
![](img/dlcf_18in02.png)
Grad-CAM 的新颖之处在于我们可以在任何层上使用它。例如,在这里我们将其用于倒数第二个 ResNet 组的输出:
```py
with HookBwd(learn.model[0][-2]) as hookg:
with Hook(learn.model[0][-2]) as hook:
output = learn.model.eval()(x.cuda())
act = hook.stored
output[0,cls].backward()
grad = hookg.stored
```
```py
w = grad[0].mean(dim=[1,2], keepdim=True)
cam_map = (w * act[0]).sum(0)
```
现在我们可以查看此层的激活图:
```py
_,ax = plt.subplots()
x_dec.show(ctx=ax)
ax.imshow(cam_map.detach().cpu(), alpha=0.6, extent=(0,224,224,0),
interpolation='bilinear', cmap='magma');
```
![](img/dlcf_18in03.png)
# 结论
模型解释是一个活跃研究领域,我们只是在这一简短章节中探讨了可能性的一部分。类激活图让我们了解模型为什么预测了某个结果,它展示了图像中对于给定预测最负责的区域。这可以帮助我们分析假阳性,并找出在我们的训练中缺少了哪种数据以避免它们。
# 问卷调查
1. PyTorch 中的 hook 是什么?
1. CAM 使用哪个层的输出?
1. 为什么 CAM 需要一个 hook
1. 查看`ActivationStats`类的源代码,看看它如何使用 hooks。
1. 编写一个 hook用于存储模型中给定层的激活如果可能的话不要偷看
1. 为什么我们在获取激活之前要调用`eval`?为什么我们要使用`no_grad`
1. 使用`torch.einsum`来计算模型主体最后激活的每个位置的“狗”或“猫”得分。
1. 如何检查类别的顺序(即索引→类别的对应关系)?
1. 为什么我们在显示输入图像时使用`decode`
1. 什么是上下文管理器?需要定义哪些特殊方法来创建一个?
1. 为什么我们不能对网络的内部层使用普通的 CAM
1. 为了执行 Grad-CAM为什么我们需要在反向传播中注册一个 hook
1. 当`output`是每个图像每个类别的输出激活的秩为 2 的张量时,为什么我们不能调用`output.backward`
## 进一步研究
1. 尝试移除`keepdim`,看看会发生什么。查阅 PyTorch 文档中的这个参数。为什么我们在这个笔记本中需要它?
1. 创建一个类似这个的笔记本,但用于 NLP并用它来找出电影评论中哪些词对于评估特定电影评论的情感最重要。

View File

@ -0,0 +1,912 @@
# 第十九章:从头开始创建一个 fastai 学习器
这最后一章(除了结论和在线章节)将会有所不同。它包含的代码比以前的章节要多得多,而叙述要少得多。我们将介绍新的 Python 关键字和库,而不进行讨论。这一章的目的是为您开展一项重要的研究项目。您将看到,我们将从头开始实现 fastai 和 PyTorch API 的许多关键部分,仅建立在我们在第十七章中开发的组件上!这里的关键目标是最终拥有自己的`Learner`类和一些回调函数,足以训练一个模型在 Imagenette 上,包括我们学习的每个关键技术的示例。在构建`Learner`的过程中,我们将创建我们自己的`Module`、`Parameter`和并行`DataLoader`的版本,这样您就会对 PyTorch 类的功能有一个很好的了解。
本章末尾的问卷调查对于本章非常重要。这是我们将指导您探索许多有趣方向的地方,使用本章作为起点。我们建议您在计算机上跟着本章进行学习,并进行大量的实验、网络搜索和其他必要的工作,以了解发生了什么。在本书的其余部分,您已经积累了足够的技能和专业知识来做到这一点,所以我们相信您会做得很好!
让我们开始手动收集一些数据。
# 数据
查看`untar_data`的源代码,看看它是如何工作的。我们将在这里使用它来访问 Imagene 的 160 像素版本,以在本章中使用:
```py
path = untar_data(URLs.IMAGENETTE_160)
```
要访问图像文件,我们可以使用`get_image_files`
```py
t = get_image_files(path)
t[0]
```
```py
Path('/home/jhoward/.fastai/data/imagenette2-160/val/n03417042/n03417042_3752.JP
> EG')
```
或者我们可以使用 Python 的标准库`glob`来做同样的事情:
```py
from glob import glob
files = L(glob(f'{path}/**/*.JPEG', recursive=True)).map(Path)
files[0]
```
```py
Path('/home/jhoward/.fastai/data/imagenette2-160/val/n03417042/n03417042_3752.JP
> EG')
```
如果您查看`get_image_files`的源代码,您会发现它使用了 Python 的`os.walk`;这是一个比`glob`更快、更灵活的函数,所以一定要尝试一下。
我们可以使用 Python Imaging Library 的`Image`类打开一张图片:
```py
im = Image.open(files[0])
im
```
![](img/dlcf_19in01.png)
```py
im_t = tensor(im)
im_t.shape
```
```py
torch.Size([160, 213, 3])
```
这将成为我们独立变量的基础。对于我们的因变量,我们可以使用`pathlib`中的`Path.parent`。首先,我们需要我们的词汇表
```py
lbls = files.map(Self.parent.name()).unique(); lbls
```
```py
(#10) ['n03417042','n03445777','n03888257','n03394916','n02979186','n03000684','
> n03425413','n01440764','n03028079','n02102040']
```
以及反向映射,感谢`L.val2idx`
```py
v2i = lbls.val2idx(); v2i
```
```py
{'n03417042': 0,
'n03445777': 1,
'n03888257': 2,
'n03394916': 3,
'n02979186': 4,
'n03000684': 5,
'n03425413': 6,
'n01440764': 7,
'n03028079': 8,
'n02102040': 9}
```
这就是我们需要组合成`Dataset`的所有部分。
## 数据集
在 PyTorch 中,`Dataset`可以是任何支持索引(`__getitem__`)和`len`的东西:
```py
class Dataset:
def __init__(self, fns): self.fns=fns
def __len__(self): return len(self.fns)
def __getitem__(self, i):
im = Image.open(self.fns[i]).resize((64,64)).convert('RGB')
y = v2i[self.fns[i].parent.name]
return tensor(im).float()/255, tensor(y)
```
我们需要一个训练和验证文件名列表传递给`Dataset.__init__`
```py
train_filt = L(o.parent.parent.name=='train' for o in files)
train,valid = files[train_filt],files[~train_filt]
len(train),len(valid)
```
```py
(9469, 3925)
```
现在我们可以尝试一下:
```py
train_ds,valid_ds = Dataset(train),Dataset(valid)
x,y = train_ds[0]
x.shape,y
```
```py
(torch.Size([64, 64, 3]), tensor(0))
```
```py
show_image(x, title=lbls[y]);
```
![](img/dlcf_19in02.png)
正如您所看到的,我们的数据集返回独立变量和因变量作为元组,这正是我们需要的。我们需要将这些整合成一个小批量。通常,可以使用`torch.stack`来完成这个任务,这就是我们将在这里使用的方法:
```py
def collate(idxs, ds):
xb,yb = zip(*[ds[i] for i in idxs])
return torch.stack(xb),torch.stack(yb)
```
这是一个包含两个项目的小批量,用于测试我们的`collate`
```py
x,y = collate([1,2], train_ds)
x.shape,y
```
```py
(torch.Size([2, 64, 64, 3]), tensor([0, 0]))
```
现在我们有了数据集和一个整合函数,我们准备创建`DataLoader`。我们将在这里添加两个东西:一个可选的`shuffle`用于训练集,以及一个`ProcessPoolExecutor`来并行进行预处理。并行数据加载器非常重要,因为打开和解码 JPEG 图像是一个缓慢的过程。一个 CPU 核心不足以快速解码图像以使现代 GPU 保持繁忙。这是我们的`DataLoader`类:
```py
class DataLoader:
def __init__(self, ds, bs=128, shuffle=False, n_workers=1):
self.ds,self.bs,self.shuffle,self.n_workers = ds,bs,shuffle,n_workers
def __len__(self): return (len(self.ds)-1)//self.bs+1
def __iter__(self):
idxs = L.range(self.ds)
if self.shuffle: idxs = idxs.shuffle()
chunks = [idxs[n:n+self.bs] for n in range(0, len(self.ds), self.bs)]
with ProcessPoolExecutor(self.n_workers) as ex:
yield from ex.map(collate, chunks, ds=self.ds)
```
让我们尝试一下我们的训练和验证数据集:
```py
n_workers = min(16, defaults.cpus)
train_dl = DataLoader(train_ds, bs=128, shuffle=True, n_workers=n_workers)
valid_dl = DataLoader(valid_ds, bs=256, shuffle=False, n_workers=n_workers)
xb,yb = first(train_dl)
xb.shape,yb.shape,len(train_dl)
```
```py
(torch.Size([128, 64, 64, 3]), torch.Size([128]), 74)
```
这个数据加载器的速度不比 PyTorch 的慢,但它要简单得多。因此,如果您正在调试一个复杂的数据加载过程,不要害怕尝试手动操作,以帮助您准确地了解发生了什么。
对于归一化,我们需要图像统计数据。通常,可以在一个训练小批量上计算这些数据,因为这里不需要精度:
```py
stats = [xb.mean((0,1,2)),xb.std((0,1,2))]
stats
```
```py
[tensor([0.4544, 0.4453, 0.4141]), tensor([0.2812, 0.2766, 0.2981])]
```
我们的`Normalize`类只需要存储这些统计数据并应用它们(要查看为什么需要`to_device`,请尝试将其注释掉,然后查看后面的笔记本中会发生什么):
```py
class Normalize:
def __init__(self, stats): self.stats=stats
def __call__(self, x):
if x.device != self.stats[0].device:
self.stats = to_device(self.stats, x.device)
return (x-self.stats[0])/self.stats[1]
```
我们总是喜欢在笔记本中测试我们构建的一切,一旦我们构建它:
```py
norm = Normalize(stats)
def tfm_x(x): return norm(x).permute((0,3,1,2))
```
```py
t = tfm_x(x)
t.mean((0,2,3)),t.std((0,2,3))
```
```py
(tensor([0.3732, 0.4907, 0.5633]), tensor([1.0212, 1.0311, 1.0131]))
```
这里`tfm_x`不仅仅应用`Normalize`,还将轴顺序从`NHWC`排列为`NCHW`如果你需要提醒这些首字母缩写指的是什么请参阅第十三章。PIL 使用`HWC`轴顺序,我们不能在 PyTorch 中使用,因此需要这个`permute`。
这就是我们模型的数据所需的全部内容。现在我们需要模型本身!
# Module 和 Parameter
要创建一个模型,我们需要`Module`。要创建`Module`,我们需要`Parameter`,所以让我们从那里开始。回想一下,在第八章中我们说`Parameter`类“没有添加任何功能(除了自动调用`requires_grad_`)。它只用作一个‘标记’,以显示要包含在`parameters`中的内容。”这里有一个确切的定义:
```py
class Parameter(Tensor):
def __new__(self, x): return Tensor._make_subclass(Parameter, x, True)
def __init__(self, *args, **kwargs): self.requires_grad_()
```
这里的实现有点尴尬:我们必须定义特殊的`__new__` Python 方法,并使用内部的 PyTorch 方法`_make_subclass`因为在撰写本文时PyTorch 否则无法正确处理这种子类化或提供官方支持的 API 来执行此操作。也许在你阅读本文时,这个问题已经得到解决,所以请查看本书网站以获取更新的详细信息。
我们的`Parameter`现在表现得就像一个张量,正如我们所希望的:
```py
Parameter(tensor(3.))
```
```py
tensor(3., requires_grad=True)
```
现在我们有了这个,我们可以定义`Module`
```py
class Module:
def __init__(self):
self.hook,self.params,self.children,self._training = None,[],[],False
def register_parameters(self, *ps): self.params += ps
def register_modules (self, *ms): self.children += ms
@property
def training(self): return self._training
@training.setter
def training(self,v):
self._training = v
for m in self.children: m.training=v
def parameters(self):
return self.params + sum([m.parameters() for m in self.children], [])
def __setattr__(self,k,v):
super().__setattr__(k,v)
if isinstance(v,Parameter): self.register_parameters(v)
if isinstance(v,Module): self.register_modules(v)
def __call__(self, *args, **kwargs):
res = self.forward(*args, **kwargs)
if self.hook is not None: self.hook(res, args)
return res
def cuda(self):
for p in self.parameters(): p.data = p.data.cuda()
```
关键功能在`parameters`的定义中:
```py
self.params + sum([m.parameters() for m in self.children], [])
```
这意味着我们可以询问任何`Module`的参数,并且它将返回它们,包括所有子模块(递归地)。但是它是如何知道它的参数是什么的呢?这要归功于实现 Python 的特殊`__setattr__`方法,每当 Python 在类上设置属性时,它就会为我们调用。我们的实现包括这一行:
```py
if isinstance(v,Parameter): self.register_parameters(v)
```
正如你所看到的,这是我们将新的`Parameter`类用作“标记”的地方——任何属于这个类的东西都会被添加到我们的`params`中。
Python 的`__call__`允许我们定义当我们的对象被视为函数时会发生什么;我们只需调用`forward`(这里不存在,所以子类需要添加)。在我们这样做之前,如果定义了钩子,我们将调用一个钩子。现在你可以看到 PyTorch 的钩子并没有做任何花哨的事情——它们只是调用任何已注册的钩子。
除了这些功能之外,我们的`Module`还提供了`cuda`和`training`属性,我们很快会用到。
现在我们可以创建我们的第一个`Module`,即`ConvLayer`
```py
class ConvLayer(Module):
def __init__(self, ni, nf, stride=1, bias=True, act=True):
super().__init__()
self.w = Parameter(torch.zeros(nf,ni,3,3))
self.b = Parameter(torch.zeros(nf)) if bias else None
self.act,self.stride = act,stride
init = nn.init.kaiming_normal_ if act else nn.init.xavier_normal_
init(self.w)
def forward(self, x):
x = F.conv2d(x, self.w, self.b, stride=self.stride, padding=1)
if self.act: x = F.relu(x)
return x
```
我们不是从头开始实现`F.conv2d`,因为你应该已经在第十七章的问卷中使用`unfold`完成了这个任务。相反,我们只是创建了一个小类,将它与偏置和权重初始化一起包装起来。让我们检查它是否与`Module.parameters`正确工作:
```py
l = ConvLayer(3, 4)
len(l.parameters())
```
```py
2
```
并且我们可以调用它(这将导致`forward`被调用):
```py
xbt = tfm_x(xb)
r = l(xbt)
r.shape
```
```py
torch.Size([128, 4, 64, 64])
```
同样,我们可以实现`Linear`
```py
class Linear(Module):
def __init__(self, ni, nf):
super().__init__()
self.w = Parameter(torch.zeros(nf,ni))
self.b = Parameter(torch.zeros(nf))
nn.init.xavier_normal_(self.w)
def forward(self, x): return x@self.w.t() + self.b
```
测试一下是否有效:
```py
l = Linear(4,2)
r = l(torch.ones(3,4))
r.shape
```
```py
torch.Size([3, 2])
```
让我们也创建一个测试模块来检查,如果我们将多个参数作为属性包含,它们是否都被正确注册:
```py
class T(Module):
def __init__(self):
super().__init__()
self.c,self.l = ConvLayer(3,4),Linear(4,2)
```
由于我们有一个卷积层和一个线性层,每个层都有权重和偏置,我们期望总共有四个参数:
```py
t = T()
len(t.parameters())
```
```py
4
```
我们还应该发现,在这个类上调用`cuda`会将所有这些参数放在 GPU 上:
```py
t.cuda()
t.l.w.device
```
```py
device(type='cuda', index=5)
```
现在我们可以使用这些部分来创建一个 CNN。
## 简单的 CNN
正如我们所见,`Sequential`类使许多架构更容易实现,所以让我们创建一个:
```py
class Sequential(Module):
def __init__(self, *layers):
super().__init__()
self.layers = layers
self.register_modules(*layers)
def forward(self, x):
for l in self.layers: x = l(x)
return x
```
这里的`forward`方法只是依次调用每个层。请注意,我们必须使用我们在`Module`中定义的`register_modules`方法,否则`layers`的内容不会出现在`parameters`中。
# 所有的代码都在这里
请记住,我们在这里没有使用任何 PyTorch 模块的功能;我们正在自己定义一切。所以如果你不确定`register_modules`做什么,或者为什么需要它,再看看我们为`Module`编写的代码!
我们可以创建一个简化的`AdaptivePool`,它只处理到 1×1 输出的池化,并且也将其展平,只需使用`mean`
```py
class AdaptivePool(Module):
def forward(self, x): return x.mean((2,3))
```
这就足够我们创建一个 CNN 了!
```py
def simple_cnn():
return Sequential(
ConvLayer(3 ,16 ,stride=2), #32
ConvLayer(16,32 ,stride=2), #16
ConvLayer(32,64 ,stride=2), # 8
ConvLayer(64,128,stride=2), # 4
AdaptivePool(),
Linear(128, 10)
)
```
让我们看看我们的参数是否都被正确注册了:
```py
m = simple_cnn()
len(m.parameters())
```
```py
10
```
现在我们可以尝试添加一个钩子。请注意,我们在`Module`中只留了一个钩子的空间;您可以将其变成列表,或者使用类似`Pipeline`的东西将几个钩子作为单个函数运行:
```py
def print_stats(outp, inp): print (outp.mean().item(),outp.std().item())
for i in range(4): m.layers[i].hook = print_stats
r = m(xbt)
r.shape
```
```py
0.5239089727401733 0.8776043057441711
0.43470510840415955 0.8347987532615662
0.4357188045978546 0.7621666193008423
0.46562111377716064 0.7416611313819885
torch.Size([128, 10])
```
我们有数据和模型。现在我们需要一个损失函数。
# 损失
我们已经看到如何定义“负对数似然”:
```py
def nll(input, target): return -input[range(target.shape[0]), target].mean()
```
实际上,这里没有对数,因为我们使用与 PyTorch 相同的定义。这意味着我们需要将对数与 softmax 放在一起:
```py
def log_softmax(x): return (x.exp()/(x.exp().sum(-1,keepdim=True))).log()
sm = log_softmax(r); sm[0][0]
```
```py
tensor(-1.2790, grad_fn=<SelectBackward>)
```
将这些结合起来就得到了我们的交叉熵损失:
```py
loss = nll(sm, yb)
loss
```
```py
tensor(2.5666, grad_fn=<NegBackward>)
```
请注意公式
<math alttext="log 左括号分数 a 除以 b 右括号等于 log 左括号 a 右括号减去 log 左括号 b 右括号" display="block"><mrow><mo form="prefix">log</mo> <mfenced separators="" open="(" close=")"><mfrac><mi>a</mi> <mi>b</mi></mfrac></mfenced> <mo>=</mo> <mo form="prefix">log</mo> <mrow><mo>(</mo> <mi>a</mi> <mo>)</mo></mrow> <mo>-</mo> <mo form="prefix">log</mo> <mrow><mo>(</mo> <mi>b</mi> <mo>)</mo></mrow></mrow></math>
在计算对数 softmax 时,这给出了一个简化,之前定义为`(x.exp()/(x.exp().sum(-1))).log()`
```py
def log_softmax(x): return x - x.exp().sum(-1,keepdim=True).log()
sm = log_softmax(r); sm[0][0]
```
```py
tensor(-1.2790, grad_fn=<SelectBackward>)
```
然后,有一种更稳定的计算指数和的对数的方法,称为[*LogSumExp*技巧](https://oreil.ly/9UB0b)。这个想法是使用以下公式
<math alttext="log 左括号 sigma-求和下标 j 等于 1 上标 n 上标 e 上标 x 上标 j 下标基准右括号等于 log 左括号 e 上标 a 基准 sigma-求和下标 j 等于 1 上标 n 上标 e 上标 x 上标 j 下标减 a 基准右括号等于 a 加 log 左括号 sigma-求和下标 j 等于 1 上标 n 上标 e 上标 x 上标 j 下标减 a 基准右括号" display="block"><mrow><mo form="prefix">log</mo> <mfenced separators="" open="(" close=")"><munderover><mo></mo> <mrow><mi>j</mi><mo>=</mo><mn>1</mn></mrow> <mi>n</mi></munderover> <msup><mi>e</mi> <msub><mi>x</mi> <mi>j</mi></msub></msup></mfenced> <mo>=</mo> <mo form="prefix">log</mo> <mfenced separators="" open="(" close=")"><msup><mi>e</mi> <mi>a</mi></msup> <munderover><mo></mo> <mrow><mi>j</mi><mo>=</mo><mn>1</mn></mrow> <mi>n</mi></munderover> <msup><mi>e</mi> <mrow><msub><mi>x</mi> <mi>j</mi></msub> <mo>-</mo><mi>a</mi></mrow></msup></mfenced> <mo>=</mo> <mi>a</mi> <mo>+</mo> <mo form="prefix">log</mo> <mfenced separators="" open="(" close=")"><munderover><mo></mo> <mrow><mi>j</mi><mo>=</mo><mn>1</mn></mrow> <mi>n</mi></munderover> <msup><mi>e</mi> <mrow><msub><mi>x</mi> <mi>j</mi></msub> <mo>-</mo><mi>a</mi></mrow></msup></mfenced></mrow></math>
其中*a*是<math alttext="x 下标 j"><msub><mi>x</mi> <mi>j</mi></msub></math>的最大值。
以下是相同的代码:
```py
x = torch.rand(5)
a = x.max()
x.exp().sum().log() == a + (x-a).exp().sum().log()
```
```py
tensor(True)
```
我们将其放入一个函数中
```py
def logsumexp(x):
m = x.max(-1)[0]
return m + (x-m[:,None]).exp().sum(-1).log()
logsumexp(r)[0]
```
```py
tensor(3.9784, grad_fn=<SelectBackward>)
```
因此我们可以将其用于我们的`log_softmax`函数:
```py
def log_softmax(x): return x - x.logsumexp(-1,keepdim=True)
```
这与之前得到的结果相同:
```py
sm = log_softmax(r); sm[0][0]
```
```py
tensor(-1.2790, grad_fn=<SelectBackward>)
```
我们可以使用这些来创建`交叉熵`
```py
def cross_entropy(preds, yb): return nll(log_softmax(preds), yb).mean()
```
现在让我们将所有这些部分组合起来创建一个`学习者`。
# 学习者
我们有数据、模型和损失函数;在我们可以拟合模型之前,我们只需要另一件事,那就是优化器!这里是 SGD
```py
class SGD:
def __init__(self, params, lr, wd=0.): store_attr(self, 'params,lr,wd')
def step(self):
for p in self.params:
p.data -= (p.grad.data + p.data*self.wd) * self.lr
p.grad.data.zero_()
```
正如我们在本书中所看到的,有了`学习者`生活就变得更容易了。`学习者`需要知道我们的训练和验证集,这意味着我们需要`DataLoaders`来存储它们。我们不需要任何其他功能,只需要一个地方来存储它们并访问它们:
```py
class DataLoaders:
def __init__(self, *dls): self.train,self.valid = dls
dls = DataLoaders(train_dl,valid_dl)
```
现在我们准备创建我们的`学习者`类:
```py
class Learner:
def __init__(self, model, dls, loss_func, lr, cbs, opt_func=SGD):
store_attr(self, 'model,dls,loss_func,lr,cbs,opt_func')
for cb in cbs: cb.learner = self
```
```py
def one_batch(self):
self('before_batch')
xb,yb = self.batch
self.preds = self.model(xb)
self.loss = self.loss_func(self.preds, yb)
if self.model.training:
self.loss.backward()
self.opt.step()
self('after_batch')
def one_epoch(self, train):
self.model.training = train
self('before_epoch')
dl = self.dls.train if train else self.dls.valid
for self.num,self.batch in enumerate(progress_bar(dl, leave=False)):
self.one_batch()
self('after_epoch')
def fit(self, n_epochs):
self('before_fit')
self.opt = self.opt_func(self.model.parameters(), self.lr)
self.n_epochs = n_epochs
try:
for self.epoch in range(n_epochs):
self.one_epoch(True)
self.one_epoch(False)
except CancelFitException: pass
self('after_fit')
def __call__(self,name):
for cb in self.cbs: getattr(cb,name,noop)()
```
这是我们在本书中创建的最大的类,但每个方法都非常小,所以通过依次查看每个方法,您应该能够理解发生了什么。
我们将调用的主要方法是`fit`。这个循环
```py
for self.epoch in range(n_epochs)
```
并在每个 epoch 中分别调用`self.one_epoch`,然后`train=True`,然后`train=False`。然后`self.one_epoch`对`dls.train`或`dls.valid`中的每个批次调用`self.one_batch`,适当地(在将`DataLoader`包装在`fastprogress.progress_bar`之后)。最后,`self.one_batch`遵循我们在本书中看到的适合一个小批量的一系列步骤。
在每个步骤之前和之后,`Learner`调用`self``self`调用`__call__`(这是标准的 Python 功能)。`__call__`在`self.cbs`中的每个回调上使用`getattr(cb,name)`,这是 Python 的内置函数,返回具有请求名称的属性(在本例中是一个方法)。因此,例如,`self('before_fit')`将为每个定义了该方法的回调调用`cb.before_fit()`。
正如您所看到的,`Learner`实际上只是使用了我们的标准训练循环,只是在适当的时候还调用了回调。所以让我们定义一些回调!
## 回调
在`Learner.__init__`中,我们有
```py
for cb in cbs: cb.learner = self
```
换句话说,每个回调都知道它是在哪个学习器中使用的。这是至关重要的,否则回调无法从学习器中获取信息,或者更改学习器中的内容。因为从学习器中获取信息是如此常见,我们通过将`Callback`定义为`GetAttr`的子类,并将默认属性定义为`learner`,使其更容易:
```py
class Callback(GetAttr): _default='learner'
```
`GetAttr`是一个 fastai 类,为您实现了 Python 的标准`__getattr__`和`__dir__`方法,因此每当您尝试访问一个不存在的属性时,它会将请求传递给您定义为`_default`的内容。
例如,我们希望在`fit`开始时自动将所有模型参数移动到 GPU。我们可以通过将`before_fit`定义为`self.learner.model.cuda`来实现这一点;然而,由于`learner`是默认属性,并且我们让`SetupLearnerCB`继承自`Callback`(它继承自`GetAttr`),我们可以去掉`.learner`,只需调用`self.model.cuda`
```py
class SetupLearnerCB(Callback):
def before_batch(self):
xb,yb = to_device(self.batch)
self.learner.batch = tfm_x(xb),yb
def before_fit(self): self.model.cuda()
```
在`SetupLearnerCB`中,我们还通过调用`to_device(self.batch)`将每个小批量移动到 GPU我们也可以使用更长的`to_device(self.learner.batch)`)。然而,请注意,在`self.learner.batch = tfm_x(xb),yb`这一行中,我们不能去掉`.learner`,因为这里我们是*设置*属性,而不是获取它。
在尝试我们的`Learner`之前,让我们创建一个回调来跟踪和打印进度。否则,我们将无法真正知道它是否正常工作:
```py
class TrackResults(Callback):
def before_epoch(self): self.accs,self.losses,self.ns = [],[],[]
def after_epoch(self):
n = sum(self.ns)
print(self.epoch, self.model.training,
sum(self.losses).item()/n, sum(self.accs).item()/n)
def after_batch(self):
xb,yb = self.batch
acc = (self.preds.argmax(dim=1)==yb).float().sum()
self.accs.append(acc)
n = len(xb)
self.losses.append(self.loss*n)
self.ns.append(n)
```
现在我们准备好第一次使用我们的`Learner`了!
```py
cbs = [SetupLearnerCB(),TrackResults()]
learn = Learner(simple_cnn(), dls, cross_entropy, lr=0.1, cbs=cbs)
learn.fit(1)
```
```py
0 True 2.1275552130636814 0.2314922378287042
0 False 1.9942575636942674 0.2991082802547771
```
惊人的是,我们可以用如此少的代码实现 fastai 的`Learner`中的所有关键思想!现在让我们添加一些学习率调度。
## 调度学习率
如果我们想要获得良好的结果,我们将需要一个 LR finder 和 1cycle 训练。这两个都是*退火*回调,也就是说,它们在训练过程中逐渐改变超参数。这是`LRFinder`
```py
class LRFinder(Callback):
def before_fit(self):
self.losses,self.lrs = [],[]
self.learner.lr = 1e-6
def before_batch(self):
if not self.model.training: return
self.opt.lr *= 1.2
def after_batch(self):
if not self.model.training: return
if self.opt.lr>10 or torch.isnan(self.loss): raise CancelFitException
self.losses.append(self.loss.item())
self.lrs.append(self.opt.lr)
```
这展示了我们如何使用`CancelFitException`,它本身是一个空类,仅用于表示异常的类型。您可以在`Learner`中看到这个异常被捕获。(您应该自己添加和测试`CancelBatchException``CancelEpochException`等。)让我们尝试一下,将其添加到我们的回调列表中:
```py
lrfind = LRFinder()
learn = Learner(simple_cnn(), dls, cross_entropy, lr=0.1, cbs=cbs+[lrfind])
learn.fit(2)
```
```py
0 True 2.6336045582954903 0.11014890695955222
0 False 2.230653363853503 0.18318471337579617
```
并查看结果:
```py
plt.plot(lrfind.lrs[:-2],lrfind.losses[:-2])
plt.xscale('log')
```
![](img/dlcf_19in03.png)
现在我们可以定义我们的`OneCycle`训练回调:
```py
class OneCycle(Callback):
def __init__(self, base_lr): self.base_lr = base_lr
def before_fit(self): self.lrs = []
def before_batch(self):
if not self.model.training: return
n = len(self.dls.train)
bn = self.epoch*n + self.num
mn = self.n_epochs*n
pct = bn/mn
pct_start,div_start = 0.25,10
if pct<pct_start:
pct /= pct_start
lr = (1-pct)*self.base_lr/div_start + pct*self.base_lr
else:
pct = (pct-pct_start)/(1-pct_start)
lr = (1-pct)*self.base_lr
self.opt.lr = lr
self.lrs.append(lr)
```
我们将尝试一个 LR 为 0.1
```py
onecyc = OneCycle(0.1)
learn = Learner(simple_cnn(), dls, cross_entropy, lr=0.1, cbs=cbs+[onecyc])
```
让我们适应一段时间,看看它的样子(我们不会在书中展示所有输出——在笔记本中尝试以查看结果):
```py
learn.fit(8)
```
最后,我们将检查学习率是否遵循我们定义的调度(如您所见,我们这里没有使用余弦退火):
```py
plt.plot(onecyc.lrs);
```
![](img/dlcf_19in04.png)
# 结论
我们已经通过在本章中重新实现它们来探索 fastai 库的关键概念。由于这本书大部分内容都是代码,您应该尝试通过查看书籍网站上相应的笔记本来进行实验。现在您已经了解了它是如何构建的,作为下一步,请务必查看 fastai 文档中的中级和高级教程,以了解如何自定义库的每一个部分。
# 问卷调查
# 实验
对于这里要求您解释函数或类是什么的问题,您还应该完成自己的代码实验。
1. 什么是`glob`
1. 如何使用 Python 图像处理库打开图像?
1. `L.map`是做什么的?
1. `Self`是做什么的?
1. 什么是`L.val2idx`
1. 您需要实现哪些方法来创建自己的`Dataset`
1. 当我们从 Imagenette 打开图像时为什么要调用`convert`
1. `~`是做什么的?它如何用于拆分训练和验证集?
1. `~`是否适用于`L`或`Tensor`类NumPy 数组、Python 列表或 Pandas DataFrames 呢?
1. 什么是`ProcessPoolExecutor`
1. `L.range(self.ds)`是如何工作的?
1. `__iter__`是什么?
1. 什么是`first`
1. `permute`是什么?为什么需要它?
1. 什么是递归函数?它如何帮助我们定义`parameters`方法?
1. 编写一个递归函数,返回斐波那契数列的前 20 个项目。
1. 什么是`super`
1. 为什么`Module`的子类需要重写`forward`而不是定义`__call__`
1. 在`ConvLayer`中,为什么`init`取决于`act`
1. 为什么`Sequential`需要调用`register_modules`
1. 编写一个打印每个层激活形状的钩子。
1. 什么是 LogSumExp
1. 为什么`log_softmax`有用?
1. 什么是`GetAttr`?它如何帮助回调?
1. 重新实现本章中的一个回调,而不继承自`Callback`或`GetAttr`。
1. `Learner.__call__`是做什么的?
1. 什么是`getattr`?(注意与`GetAttr`的大小写区别!)
1. 在`fit`中为什么有一个`try`块?
1. 为什么在`one_batch`中检查`model.training`
1. 什么是`store_attr`
1. `TrackResults.before_epoch`的目的是什么?
1. `model.cuda`是做什么的?它是如何工作的?
1. 为什么我们需要在`LRFinder`和`OneCycle`中检查`model.training`
1. 在`OneCycle`中使用余弦退火。
## 进一步研究
1. 从头开始编写`resnet18`(如有需要,请参考第十四章),并在本章中使用`Learner`进行训练。
1. 从头开始实现一个批归一化层,并在您的`resnet18`中使用它。
1. 为本章编写一个 Mixup 回调。
1. 向 SGD 添加动量。
1. 从 fastai或任何其他库中挑选几个您感兴趣的特性并使用本章中创建的对象实现它们。
1. 选择一篇尚未在 fastai 或 PyTorch 中实现的研究论文,并使用本章中创建的对象进行实现。然后:
+ 将论文移植到 fastai。
+ 向 fastai 提交拉取请求,或创建自己的扩展模块并发布。
提示:您可能会发现使用[`nbdev`](https://nbdev.fast.ai)来创建和部署您的软件包很有帮助。

View File

@ -0,0 +1,29 @@
# 第二十章:总结思考
恭喜!你成功了!如果你已经完成了到这一点的所有笔记本,你已经加入了一个小但不断增长的人群,他们能够利用深度学习的力量解决实际问题。你可能不会觉得这样—事实上,你可能不会。我们一再看到完成 fast.ai 课程的学生明显低估了自己作为深度学习从业者的效力。我们也看到这些人经常被具有传统学术背景的其他人低估。所以如果你要超越自己的期望和他人的期望,那么在关闭这本书后,你接下来要做的事情比你到目前为止所做的更重要。
最重要的是保持动力。事实上,从你对优化器的研究中知道,动量是可以自我增强的!所以想想你现在可以做些什么来维持和加速你的深度学习之旅。图 20-1 可以给你一些想法。
![接下来要做什么](img/dlcf_2001.png)
###### 图 20-1。接下来要做什么
在这本书中,我们谈了很多关于写作的价值,无论是代码还是散文。但也许你到目前为止还没有写得像你希望的那样多。没关系!现在是一个扭转局面的好机会。此时你有很多话要说。也许你已经在一个数据集上尝试了一些实验,其他人似乎没有以同样的方式看待。告诉世界!或者你可能正在考虑尝试一些在阅读时想到的想法——现在是把这些想法转化为代码的好时机。
如果你想分享你的想法,一个相对低调的地方是[fast.ai 论坛](https://forums.fast.ai)。你会发现那里的社区非常支持和乐于助人,所以请过来告诉我们你在做什么。或者看看你是否可以回答一些问题,帮助那些在学习早期的人。
如果你在深度学习之旅中取得了一些成功,无论大小,请务必告诉我们!在论坛上发布这些信息尤其有帮助,因为了解其他学生的成功可以极大地激励人们。
对许多人来说,保持与学习之旅的联系最重要的方法之一是围绕它建立一个社区。例如,你可以尝试在你当地社区设立一个小型深度学习聚会,或者一个学习小组,甚至可以在当地聚会上做一个关于你到目前为止学到的内容或者你感兴趣的某个特定方面的演讲。你现在还不是世界领先的专家也没关系——重要的是要记住你现在知道很多其他人不知道的东西,所以他们很可能会欣赏你的观点。
另一个许多人发现有用的社区活动是定期的读书俱乐部或论文阅读俱乐部。你可能已经在你的社区找到了一些,如果没有,你可以尝试开始一个。即使只有另一个人和你一起做,也会帮助你获得支持和鼓励,让你开始行动起来。
如果你不在一个容易与志同道合的人聚在一起的地方,可以去论坛,因为人们总是在组建虚拟学习小组。这些通常涉及一群人每周一次通过视频聊天讨论一个深度学习主题。
希望到这一点,你已经有了一些小项目和实验。我们建议你的下一步是选择其中一个,并尽可能把它做得更好。真正把它打磨成你能做到的最好作品——一件让你真正自豪的作品。这将迫使你更深入地了解一个主题,测试你的理解,并让你看到当你全力以赴时你能做到什么。
此外,您可能想看一下[fast.ai 免费在线课程](https://course.fast.ai),它涵盖了与本书相同的内容。有时,以两种方式看同样的材料确实有助于澄清思路。事实上,人类学习研究人员发现,学习材料的最佳方式之一是从不同角度看同一件事,用不同的方式描述。
如果您选择接受最后的任务,那就是把这本书送给您认识的某人,并帮助另一个人开始他们自己的深度学习之旅!

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 147 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 120 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 57 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 55 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 106 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 85 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 350 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 159 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 107 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 69 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 169 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 219 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 168 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 75 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 215 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 328 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 556 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 520 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 328 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Some files were not shown because too many files have changed in this diff Show More