面向程序员的-FastAI-和-PyTorch-深度学习-全-

面向程序员的 FastAI 和 PyTorch 深度学习(全)

原文:www.bookstack.cn/read/th-fastai-book

译者:飞龙

协议:CC BY-NC-SA 4.0

前言

深度学习是一项强大的新技术,我们认为它应该应用于许多学科。领域专家最有可能发现它的新应用,我们需要更多来自各个背景的人参与并开始使用它。

这就是为什么 Jeremy 共同创立了 fast.ai,通过免费在线课程和软件使深度学习更容易使用。Sylvain 是 Hugging Face 的研究工程师。以前他是 fast.ai 的研究科学家,曾是一名数学和计算机科学教师,负责培养学生进入法国精英大学的计划。我们一起写这本书,希望尽可能多地将深度学习交到更多人手中。

这本书适合谁

如果你是深度学习和机器学习的完全初学者,你在这里是最受欢迎的。我们唯一的期望是你已经知道如何编码,最好是用 Python。

没有经验?没问题!

如果你没有编码经验,也没关系!前三章已经以一种明确的方式编写,以便让高管、产品经理等了解他们需要了解的关键内容。当你在文本中看到代码片段时,尝试看一下它们,以直观地了解它们在做什么。我们会逐行解释它们。语法的细节远不及对正在发生的事情有高层次的理解重要。

如果你已经是一个自信的深度学习从业者,你也会在这里找到很多内容。在这本书中,我们将向你展示如何取得世界一流的结果,包括来自最新研究的技术。正如我们将展示的那样,这并不需要高级数学训练或多年的学习。它只需要一点常识和坚韧不拔。

你需要知道什么

正如我们之前所说,唯一的先决条件是你知道如何编码(一年的经验足够了),最好是用 Python,并且你至少上过高中数学课程。现在记得多少并不重要;我们将根据需要进行复习。可汗学院有很多免费在线资源可以帮助你。

我们并不是说深度学习不需要高中水平以上的数学,但我们会教你(或指导你去学习)你在学习需要的主题时所需的基础知识。

这本书从宏观角度开始,逐渐深入细节,所以你可能需要不时将其放在一边,去学习一些额外的主题(编码方式或一点数学)。这完全没问题,这也是我们希望这本书被阅读的方式。开始浏览它,并根据需要查阅额外资源。

请注意,Kindle 或其他电子阅读器用户可能需要双击图像才能查看完整大小的版本。

在线资源

本书中显示的所有代码示例都以 Jupyter 笔记本的形式在线提供(不用担心;你将在第一章中了解 Jupyter 是什么)。这是本书的交互版本,你可以实际执行代码并进行实验。有关更多信息,请查看书籍网站。该网站还包含有关设置我们提供的各种工具以及一些额外奖励章节的最新信息。

你将学到什么

阅读完这本书后,你将了解以下内容:

  • 如何训练能够取得最先进结果的模型

    • 计算机视觉,包括图像分类(例如按品种分类宠物照片)和图像定位和检测(例如在图像中找到动物)

    • 自然语言处理(NLP),包括文档分类(例如电影评论情感分析)和语言建模

    • 表格数据(例如销售预测)包括分类数据、连续数据和混合数据,包括时间序列

    • 协同过滤(例如电影推荐)

  • 如何将您的模型转化为 Web 应用程序

  • 为什么以及深度学习模型如何工作,以及如何利用这些知识来提高模型的准确性、速度和可靠性

  • 实际上很重要的最新深度学习技术

  • 如何阅读深度学习研究论文

  • 如何从头开始实现深度学习算法

  • 如何思考您工作的伦理影响,以确保您正在使世界变得更美好,并且您的工作不会被滥用造成伤害

查看目录以获取完整列表,但为了让您有所了解,这里列出了一些涵盖的技术(如果这些词对您还没有意义,不用担心,您很快就会学会它们):

  • 仿射函数和非线性

  • 参数和激活

  • 随机初始化和迁移学习

  • SGD,Momentum,Adam 和其他优化器

  • 卷积

  • 批量归一化

  • 辍学

  • 数据增强

  • 权重衰减

  • ResNet 和 DenseNet 架构

  • 图像分类和回归

  • 嵌入

  • 循环神经网络(RNN)

  • 分割

  • U-Net

  • 还有更多!

章节问卷

如果您查看每章末尾,您会找到一个问卷调查。那是一个很好的地方,可以看到我们在每章中涵盖了什么,因为(我们希望!)到每章结束时,您将能够回答那里的所有问题。事实上,我们的一位审阅者(谢谢,Fred!)说他喜欢先阅读问卷调查,然后再阅读章节,这样他就知道要注意什么。

序言

原文:www.bookstack.cn/read/th-fastai-book/README.md

译者:飞龙

协议:CC BY-NC-SA 4.0

在很短的时间内,深度学习已经成为一种广泛应用的技术,解决和自动化计算机视觉、机器人技术、医疗保健、物理学、生物学等领域的问题。深度学习的一大乐趣在于其相对简单性。强大的深度学习软件已经构建起来,使得快速入门变得快速简单。在几周内,你就可以理解基础知识并熟悉技术。

这打开了一个创造性的世界。你开始将其应用于手头的数据问题,并且看到机器为你解决问题时感到很棒。然而,你慢慢感到自己越来越接近一个巨大的障碍。你构建了一个深度学习模型,但它的效果并不如你希望的那样好。这时你进入下一个阶段,寻找并阅读关于深度学习的最新研究。

然而,关于深度学习有大量的知识,有三十年的理论、技术和工具支持。当你阅读一些研究时,你会意识到人类可以用非常复杂的方式解释简单的事情。科学家在这些论文中使用的词语和数学符号看起来很陌生,没有任何教科书或博客文章似乎涵盖了你需要的背景知识。工程师和程序员假设你知道 GPU 如何工作,并且了解一些晦涩的工具。

这时候你会希望有一个导师或者一个可以交流的朋友。一个曾经身临其境、了解工具和数学的人——一个可以指导你掌握最佳研究、最先进技术和高级工程,并使其变得滑稽简单的人。十年前,我曾经身临其境,当时我正在进入机器学习领域。多年来,我努力理解那些含有一点数学的论文。我周围有很好的导师,这对我帮助很大,但我花了很多年才对机器学习和深度学习感到舒适。这激励我成为 PyTorch 的合著者,这是一个使深度学习变得易于接触的软件框架。

Jeremy Howard 和 Sylvain Gugger 也曾身临其境。他们想学习和应用深度学习,但没有任何以前的机器学习科学家或工程师的正式培训。像我一样,Jeremy 和 Sylvain 在多年的学习中逐渐成为专家和领导者。但与我不同的是,Jeremy 和 Sylvain 无私地投入了大量精力,确保其他人不必像他们走过的痛苦之路。他们建立了一个名为 fast.ai 的优秀课程,使了解前沿深度学习技术的人们能够掌握基本编程知识。这个课程已经毕业了成千上万渴望学习的学习者,他们已经成为了优秀的实践者。

在这本书中,Jeremy 和 Sylvain 构建了一个通过深度学习的神奇之旅。他们用简单的语言介绍每个概念。他们将前沿深度学习和最新研究带给你,同时使其变得非常易于理解。

你将通过最新的计算机视觉进展,深入自然语言处理,并在 500 页的愉快旅程中学习一些基础数学。而这个旅程并不仅仅停留在有趣,因为他们还会带你将你的想法投入生产。你可以将 fast.ai 社区视为你的扩展家庭,成千上万的从业者在线上,像你一样的个人可以交流和构思大小解决方案,无论问题是什么。

我很高兴你找到了这本书,我希望它能激励你将深度学习应用到实际问题中,无论问题的性质如何。

Soumith Chintala

PyTorch 的共同创作者

第一部分:实践中的深度学习

第一章:你的深度学习之旅

原文:www.bookstack.cn/read/th-fastai-book/85a70c895d8d75be.md

译者:飞龙

协议:CC BY-NC-SA 4.0

你好,感谢你让我们加入你的深度学习之旅,无论你已经走了多远!在本章中,我们将告诉你更多关于本书的内容,介绍深度学习背后的关键概念,并在不同任务上训练我们的第一个模型。无论你是否有技术或数学背景(尽管如果你有也没关系!),我们写这本书是为了让尽可能多的人能够接触到深度学习。

深度学习适合每个人

很多人认为你需要各种难以找到的东西才能在深度学习中取得出色的结果,但正如你在本书中所看到的,这些人是错误的。表 1-1 列出了一些你绝对不需要进行世界级深度学习的东西。

表 1-1。深度学习不需要的东西

迷思(不需要) 真相
大量的数学 高中数学就足够了。
大量的数据 我们已经看到少于 50 个数据项取得了创纪录的结果。
大量昂贵的计算机 你可以免费获得进行最先进工作所需的设备。

深度学习是一种计算机技术,通过使用多层神经网络从人类语音识别到动物图像分类等用例来提取和转换数据。每个层从前一层获取输入,并逐渐完善它们。这些层通过最小化错误并提高准确性的算法进行训练。这样,网络学会执行指定的任务。我们将在下一节详细讨论训练算法。

深度学习具有强大、灵活和简单的特点。这就是为什么我们认为它应该应用于许多学科。这些学科包括社会科学、自然科学、艺术、医学、金融、科学研究等等。举个个人例子,尽管没有医学背景,Jeremy 创办了 Enlitic 公司,该公司使用深度学习算法来诊断疾病。在公司成立几个月后,宣布其算法能够比放射科医生更准确地识别恶性肿瘤。

以下是一些不同领域中数千个任务的列表,其中深度学习或大量使用深度学习的方法现在是世界上最好的:

自然语言处理(NLP)

回答问题;语音识别;总结文件;分类文件;在文件中查找名称、日期等;搜索提及某一概念的文章

计算机视觉

卫星和无人机图像解释(例如用于灾害韧性),人脸识别,图像字幕,读取交通标志,定位自动驾驶车辆中的行人和车辆

医学

在放射学图像中找到异常,包括 CT、MRI 和 X 射线图像;在病理学幻灯片中计数特征;在超声波中测量特征;诊断糖尿病视网膜病变

生物学

折叠蛋白;分类蛋白质;许多基因组学任务,如肿瘤-正常测序和分类临床可操作的遗传突变;细胞分类;分析蛋白质/蛋白质相互作用

图像生成

给图像上色,增加图像分辨率,去除图像中的噪音,将图像转换为著名艺术家风格的艺术品

推荐系统

网络搜索,产品推荐,主页布局

玩游戏

国际象棋,围棋,大多数 Atari 视频游戏以及许多实时策略游戏

机器人技术

处理难以定位的物体(例如透明、闪亮、缺乏纹理)或难以拾取的物体

其他应用

金融和物流预测,文本转语音等等…

令人惊讶的是,深度学习有如此多样的应用,然而几乎所有的深度学习都基于一种创新的模型类型:神经网络。

但事实上,神经网络并不是完全新的。为了对该领域有更广泛的视角,值得从一点历史开始。

神经网络:简史

1943 年,神经生理学家沃伦·麦卡洛克和逻辑学家沃尔特·皮茨联手开发了人工神经元的数学模型。在他们的论文《神经活动中内在思想的逻辑演算》中,他们宣称:

由于神经活动的“全有或全无”特性,神经事件及其之间的关系可以通过命题逻辑来处理。发现每个网络的行为都可以用这些术语来描述。

麦卡洛克和皮茨意识到,可以使用简单的加法和阈值处理来表示真实神经元的简化模型,如图 1-1 所示。皮茨是自学成才的,12 岁时就收到了与伟大的伯特兰·罗素一起在剑桥大学学习的邀请。他没有接受这个邀请,事实上,他一生中没有接受任何高级学位或权威职位的邀请。他大部分著名的工作都是在无家可归时完成的。尽管他没有正式认可的职位,社交孤立日益加剧,但他与麦卡洛克的合作对心理学家弗兰克·罗森布拉特产生了影响。

自然和人工神经元

图 1-1. 自然和人工神经元

罗森布拉特进一步发展了人工神经元,使其具有学习能力。更重要的是,他致力于构建第一个使用这些原则的设备,即 Mark I 感知器。在《智能自动机的设计》中,罗森布拉特写道:“我们现在将见证这样一台机器的诞生——一台能够在没有任何人类训练或控制的情况下感知、识别和辨认其周围环境的机器。”感知器被建造出来,并成功地识别了简单的形状。

麻省理工学院的教授马文·明斯基(与罗森布拉特在同一所高中,但比他晚一年)与西摩·帕帕特合著了一本名为《感知器》(麻省理工学院出版社)的书,讲述了罗森布拉特的发明。他们表明,这些设备的单层无法学习一些简单但关键的数学函数(如异或)。在同一本书中,他们还表明,使用多层设备可以解决这些限制。不幸的是,这些洞见中只有第一个被广泛认可。因此,全球学术界在接下来的两十年几乎完全放弃了神经网络。

在过去 50 年中,神经网络领域最具影响力的工作可能是由大卫·鲁梅尔哈特、詹姆斯·麦克莱兰和 PDP 研究小组于 1986 年由麻省理工学院出版社出版的多卷本《并行分布式处理》(PDP)。第一章提出了与罗森布拉特所展示的类似希望:

人类比今天的计算机更聪明,因为大脑采用了一种更适合处理人类擅长的自然信息处理任务的基本计算架构。我们将介绍一个用于建模认知过程的计算框架,它似乎比其他框架更接近大脑可能进行的计算风格。

PDP 所使用的前提是,传统计算机程序与大脑的工作方式非常不同,这可能是为什么计算机程序在那时如此糟糕地执行大脑发现容易的任务(如识别图片中的物体)。作者声称 PDP 方法“比其他框架更接近”大脑的工作方式,因此可能更能够处理这些任务。

事实上,PDP 中提出的方法与今天的神经网络所使用的方法非常相似。该书将并行分布式处理定义为需要以下内容:

  • 一组处理单元

  • 激活状态

  • 每个单元的输出函数

  • 单位之间的连接模式

  • 通过网络连接传播活动模式的传播规则

  • 将输入与单位的当前状态相结合以产生单位输出的激活规则

  • 通过经验修改连接模式的学习规则

  • 系统必须运行的环境

我们将在本书中看到现代神经网络如何处理这些要求。

在 20 世纪 80 年代,大多数模型都建立了第二层神经元,从而避免了由明斯基和帕佩特(这是他们“单元之间的连接模式”,使用前面的框架)所确定的问题。事实上,神经网络在 80 年代和 90 年代被广泛用于真实的实际项目。然而,对理论问题的误解再次阻碍了该领域的发展。理论上,只需添加一个额外的神经元层就足以使这些神经网络能够近似任何数学函数,但实际上这样的网络通常太大且太慢,无法发挥作用。

尽管研究人员 30 年前就表明,要获得实际的良好性能,你需要使用更多层的神经元,但直到最近十年,这个原则才被更广泛地认可和应用。现在,由于计算机硬件的改进、数据可用性的增加以及允许神经网络更快更容易地训练的算法调整,神经网络终于实现了其潜力。我们现在拥有了 Rosenblatt 所承诺的:“一台能够感知、识别和辨认周围环境的机器,无需任何人类训练或控制。”

这就是你将在本书中学会如何构建的内容。但首先,因为我们将花很多时间在一起,让我们彼此稍微了解一下...

我们是谁

我们是 Sylvain 和 Jeremy,是你们在这个旅程中的向导。我们希望你们认为我们非常适合这个职位。

Jeremy 已经使用和教授机器学习约 30 年。他在 25 年前开始使用神经网络。在此期间,他领导了许多以机器学习为核心的公司和项目,包括成立专注于深度学习和医学的第一家公司 Enlitic,并担任全球最大的机器学习社区 Kaggle 的总裁兼首席科学家。他与 Rachel Thomas 博士共同创立了 fast.ai,这个组织建立了本书基于的课程。

不时地,你会直接从我们这里听到一些侧边栏的信息,比如 Jeremy 在这里说的:

Jeremy 说

大家好,我是 Jeremy!你可能会感兴趣知道,我没有接受过任何正式的技术教育。我获得了哲学专业的学士学位,成绩并不好。我对做实际项目比对理论研究更感兴趣,所以在大学期间我全职在一家名为麦肯锡公司的管理咨询公司工作。如果你更愿意亲自动手建造东西而不是花费数年学习抽象概念,你会理解我的想法!请留意我的侧边栏,以找到最适合没有数学或正式技术背景的人的信息,也就是像我这样的人...

另一方面,Sylvain 对正式的技术教育了解很多。他已经写了 10 本数学教科书,涵盖了整个法国高级数学课程!

Sylvain 说

与杰里米不同,我没有花很多年编写和应用机器学习算法。相反,我最近通过观看杰里米的 fast.ai 课程视频进入了机器学习世界。因此,如果你是一个从未打开终端并在命令行中编写命令的人,你会理解我在说什么!请留意我的旁注,以找到最适合具有更多数学或正式技术背景,但缺乏实际编码经验的人的信息,也就是像我这样的人...

fast.ai 课程已经被来自世界各地各行各业的数十万学生研究过。Sylvain 被认为是 Jeremy 见过的该课程中最令人印象深刻的学生,这导致他加入了 fast.ai,然后与 Jeremy 一起成为 fastai 软件库的合著者。

所有这些意味着,在我们之间,你拥有最好的两个世界:那些比任何人都更了解软件的人,因为他们编写了它;数学专家,编码和机器学习专家;以及那些了解在数学中作为相对外行者和在编码和机器学习中作为相对外行者的感受的人。

任何看过体育比赛的人都知道,如果有一个两人评论团队,你还需要第三个人来做“特别评论”。我们的特别评论员是亚历克西斯·加拉格尔(Alexis Gallagher)。亚历克西斯有着非常多样化的背景:他曾是数学生物学研究员,编剧,即兴表演者,麦肯锡顾问(就像 Jeremy 一样!),Swift 编码者和首席技术官。

亚历克西斯说

我决定是时候学习这个人工智能的东西了!毕竟,我几乎尝试了所有其他的东西...但我并没有建立机器学习模型的背景。不过...这有多难呢?我将在这本书中一直学习,就像你一样。请留意我的旁注,找到我在学习过程中发现有用的学习提示,希望你也会觉得有用。

如何学习深度学习

哈佛教授大卫·帕金斯(David Perkins)在《全面学习》(Jossey-Bass)一书中有很多关于教学的观点。基本思想是教授“整个游戏”。这意味着,如果你在教棒球,你首先带人们去看一场棒球比赛或让他们玩棒球。你不会教他们如何缠绕麻线从头开始制作棒球,也不会教他们抛物线的物理学,或者球在球拍上的摩擦系数。

哥伦比亚数学博士、前布朗大学教授和 K-12 数学教师保罗·洛克哈特(Paul Lockhart)在具有影响力的文章《数学家的悲歌》中设想了一个噩梦般的世界,在那里音乐和艺术的教学方式与数学的教学方式相同。孩子们在掌握音乐符号和理论十多年后才被允许听音乐或演奏音乐,花时间将乐谱转换到不同的音调。在艺术课上,学生学习颜色和应用工具,但直到大学才被允许真正绘画。听起来荒谬吗?这就是数学的教学方式——我们要求学生花费多年时间进行死记硬背和学习干燥、脱离实际的“基础知识”,我们声称这些知识在大多数学生放弃这门学科后才会有回报。

不幸的是,这正是许多关于深度学习的教学资源开始的地方——要求学习者跟随 Hessian 的定义和泰勒近似定理的步伐,而从未给出实际工作代码的示例。我们并不是在抨击微积分。我们喜欢微积分,Sylvain 甚至在大学教过微积分,但我们认为学习深度学习时不是最好的起点!

在深度学习中,如果你有动力修复模型使其表现更好,那真的会有帮助。那时你开始学习相关的理论。但你首先需要有模型。我们几乎所有的教学都是通过真实例子展示的。随着我们构建这些例子,我们会越来越深入,向您展示如何使您的项目变得更好。这意味着您将逐渐学习所有您需要的理论基础,以上下文方式,这样您就会明白为什么重要以及如何运作。

因此,这是我们对您的承诺。在整本书中,我们遵循以下原则:

教授整个游戏

我们将从向您展示如何使用完整、可用、最先进的深度学习网络来解决现实世界问题,使用简单、表达力强的工具开始。然后我们将逐渐深入了解这些工具是如何制作的,以及制作这些工具的工具是如何制作的,依此类推…

始终通过示例教学

我们将确保您能直观理解背景和目的,而不是从代数符号操作开始。

尽可能简化

我们花了多年时间建立工具和教学方法,使以前复杂的主题变得简单。

消除障碍

深度学习直到现在一直是一个独家游戏。我们正在打破这个局面,确保每个人都能参与。

深度学习中最困难的部分是手工制作的:你如何知道你是否有足够的数据,数据是否以正确的格式存在,你的模型是否正确训练,如果不正确,你应该怎么做?这就是为什么我们相信通过实践学习。与基本的数据科学技能一样,通过实践经验才能变得更好。花太多时间在理论上可能会适得其反。关键是只需编写代码并尝试解决问题:理论可以稍后再来,当你有了上下文和动力时。

在旅程中会有困难的时候。有时你会感到困惑。不要放弃!回顾一下书中你肯定没有困惑的部分,然后从那里开始慢慢阅读,找到第一个不清楚的地方。然后尝试一些代码实验,自己搜索更多关于你遇到问题的教程——通常你会找到一个不同的角度来理解材料,可能会帮助你理解。此外,第一次阅读时不理解一切(尤其是代码)是正常的。有时在继续之前按顺序理解材料有时会很困难。有时在你从后面的部分获得更多上下文后,事情就会豁然开朗,从整体上看到更多。所以如果你在某个部分卡住了,尝试继续前进,做个笔记以后再回来。

请记住,要在深度学习中取得成功,您不需要任何特定的学术背景。许多重要的突破是由没有博士学位的人在研究和工业领域取得的,比如 Alec Radford 在大学本科时写的一篇论文《使用深度卷积生成对抗网络进行无监督表示学习》,这是过去十年中最有影响力的论文之一,被引用超过 5000 次。甚至在特斯拉,他们正在努力解决制造自动驾驶汽车这个极具挑战性的问题,首席执行官埃隆·马斯克表示:

绝对不需要博士学位。重要的是对人工智能有深刻理解和能够实际应用神经网络(后者才是真正困难的)。甚至不在乎你是否高中毕业。

然而,要成功,您需要将本书中学到的知识应用到个人项目中,并始终坚持不懈。

你的项目和心态

无论您是因为兴奋地想要从植物叶片的图片中识别植物是否患病,自动生成编织图案,从 X 射线诊断结核病,还是确定浣熊何时使用您的猫门,我们将尽快让您使用深度学习解决自己的问题(通过他人预训练的模型),然后将逐步深入更多细节。在下一章的前 30 分钟内,您将学会如何使用深度学习以最先进的准确性解决自己的问题!(如果您迫不及待地想要立即开始编码,请随时跳转到那里。)有一个错误的观念认为,要进行深度学习,您需要像谷歌那样拥有计算资源和数据集的规模,但这是不正确的。

那么,什么样的任务适合作为良好的测试案例?您可以训练模型区分毕加索和莫奈的画作,或者挑选您女儿的照片而不是您儿子的照片。专注于您的爱好和激情有助于您设定四到五个小项目,而不是努力解决一个大问题,这在刚开始时效果更好。由于很容易陷入困境,过早野心勃勃往往会适得其反。然后,一旦掌握了基础知识,就努力完成一些让您真正自豪的事情!

杰里米说

深度学习几乎可以应用于任何问题。例如,我的第一家创业公司叫做 FastMail,它于 1999 年推出时提供了增强的电子邮件服务(至今仍在提供)。2002 年,我将其设置为使用一种原始形式的深度学习,即单层神经网络,以帮助分类电子邮件并阻止客户收到垃圾邮件。

在深度学习中表现良好的人的共同特征包括好玩和好奇。已故物理学家理查德·费曼就是我们期望在深度学习方面表现出色的人的一个例子:他对亚原子粒子运动的理解来自于他对盘子在空中旋转时摇晃的好奇。

现在让我们专注于您将学到的内容,从软件开始。

软件:PyTorch、fastai 和 Jupyter(以及为什么这不重要)

我们已经完成了数百个机器学习项目,使用了数十种软件包和多种编程语言。在 fast.ai,我们编写了大多数当今主要的深度学习和机器学习软件包的课程。在 2017 年 PyTorch 发布后,我们花费了一千多个小时进行测试,然后决定将其用于未来的课程、软件开发和研究。自那时以来,PyTorch 已成为全球增长最快的深度学习库,并且已经被用于顶级会议上的大多数研究论文。这通常是行业使用的领先指标,因为这些论文最终会被商业产品和服务使用。我们发现 PyTorch 是最灵活和表达力强的深度学习库。它不会以速度为代价而简化,而是提供了两者。

PyTorch 最适合作为低级基础库,提供高级功能的基本操作。fastai 库是在 PyTorch 之上添加高级功能的最流行的库。它也特别适合本书的目的,因为它在提供深度分层软件架构方面是独一无二的(甚至有一篇同行评审的学术论文介绍了这种分层 API)。在本书中,随着我们深入研究深度学习的基础,我们也将深入研究 fastai 的各个层次。本书涵盖了 fastai 库的第 2 版,这是一个从头开始重写的版本,提供了许多独特的功能。

然而,学习哪种软件并不重要,因为只需要几天就可以学会从一个库切换到另一个库。真正重要的是正确学习深度学习的基础和技术。我们的重点将是使用尽可能清晰地表达你需要学习的概念的代码。在教授高级概念时,我们将使用高级 fastai 代码。在教授低级概念时,我们将使用低级 PyTorch 或甚至纯 Python 代码。

尽管现在似乎新的深度学习库以快速的速度出现,但你需要为未来几个月和几年内更快的变化做好准备。随着更多人进入这个领域,他们将带来更多的技能和想法,并尝试更多的事情。你应该假设你今天学到的特定库和软件将在一两年内过时。想想在网络编程领域中一直发生的库和技术栈的变化数量——这是一个比深度学习更成熟和增长缓慢的领域。我们坚信学习的重点应该放在理解基础技术以及如何将其应用于实践中,以及如何在新工具和技术发布时快速建立专业知识。

到书的结尾,你将几乎理解 fastai 中的所有代码(以及大部分 PyTorch 代码),因为在每一章中,我们都会深入挖掘,向你展示我们构建和训练模型时究竟发生了什么。这意味着你将学到现代深度学习中使用的最重要的最佳实践,不仅是如何使用它们,还有它们是如何真正工作和实现的。如果你想在另一个框架中使用这些方法,你将有必要的知识来做到这一点。

由于学习深度学习最重要的是编写代码和实验,所以重要的是你有一个很好的代码实验平台。最流行的编程实验平台称为 Jupyter。这是我们将在整本书中使用的工具。我们将向你展示如何使用 Jupyter 训练和实验模型,并审查数据预处理和模型开发流程的每个阶段。Jupyter 是在 Python 中进行数据科学最流行的工具,理由充分。它功能强大、灵活且易于使用。我们相信你会喜欢它!

让我们实践一下,训练我们的第一个模型。

你的第一个模型

正如我们之前所说,我们将教你如何做事情,然后再解释为什么它们有效。遵循这种自上而下的方法,我们将首先实际训练一个图像分类器,几乎可以 100%准确地识别狗和猫。为了训练这个模型并运行我们的实验,你需要进行一些初始设置。不要担心,这并不像看起来那么难。

Sylvain 说

即使初始设置看起来令人生畏,也不要跳过设置部分,特别是如果你很少或没有使用终端或命令行的经验。大部分并不是必要的,你会发现最简单的服务器只需使用你平常的网络浏览器就可以设置好。在学习过程中,与本书并行运行你自己的实验是至关重要的。

获取 GPU 深度学习服务器

在本书中几乎所有的事情都需要使用一台带有 NVIDIA GPU 的计算机(不幸的是,其他品牌的 GPU 并没有得到主要深度学习库的全面支持)。然而,我们不建议你购买一台;事实上,即使你已经有一台,我们也不建议你立即使用!设置一台计算机需要时间和精力,而你现在想要把所有精力集中在深度学习上。因此,我们建议你租用一台已经预装并准备就绪的计算机。使用时的成本可能只需每小时 0.25 美元,甚至有些选项是免费的。

术语:图形处理单元(GPU)

也称为图形卡。计算机中一种特殊类型的处理器,可以同时处理成千上万个单个任务,专门设计用于在计算机上显示 3D 环境以进行游戏。这些相同的基本任务与神经网络所做的非常相似,因此 GPU 可以比常规 CPU 快数百倍运行神经网络。所有现代计算机都包含 GPU,但很少包含进行深度学习所需的正确类型的 GPU。

随着公司的兴衰和价格的变化,与本书一起使用的 GPU 服务器的最佳选择将随时间而变化。我们在书的网站上维护了我们推荐选项的列表,所以现在去那里,按照说明连接到 GPU 深度学习服务器。不用担心;在大多数平台上,设置只需要大约两分钟,许多甚至不需要任何付款或信用卡即可开始。

Alexis 说

我的建议:听取这些建议!如果您喜欢计算机,您可能会想要设置自己的计算机。小心!这是可行的,但令人惊讶地复杂和分散注意力。这本书没有标题为关于 Ubuntu 系统管理、NVIDIA 驱动程序安装、apt-get、conda、pip 和 Jupyter 笔记本配置的所有内容。那将是一本独立的书。在工作中设计和部署我们的生产机器学习基础设施后,我可以证明它有其满足感,但与建模无关,就像维护飞机与驾驶飞机无关。

网站上显示的每个选项都包括一个教程;完成教程后,您将看到一个屏幕,看起来像图 1-2。

Jupyter 笔记本的初始视图

图 1-2. Jupyter 笔记本的初始视图

您现在已经准备好运行您的第一个 Jupyter 笔记本!

行话:Jupyter 笔记本

一种软件,允许您在单个交互式文档中包含格式化文本、代码、图像、视频等。Jupyter 因其在许多学术领域和工业中的广泛使用和巨大影响而获得了软件的最高荣誉,ACM 软件系统奖。Jupyter 笔记本是数据科学家用于开发和与深度学习模型交互的最广泛使用的软件。

运行您的第一个笔记本

笔记本按章节编号,与本书中呈现的顺序相同。因此,您将看到列出的第一个笔记本是您现在需要使用的笔记本。您将使用此笔记本来训练一个可以识别狗和猫照片的模型。为此,您将下载一组狗和猫照片的数据集,并使用该数据集训练模型

数据集只是一堆数据——可以是图像、电子邮件、财务指标、声音或其他任何东西。有许多免费提供的数据集适合用于训练模型。许多这些数据集是由学者创建的,以帮助推动研究,许多是为竞赛提供的(有一些竞赛,数据科学家可以竞争,看看谁有最准确的模型!),有些是其他过程的副产品(如财务申报)。

完整和剥离的笔记本

有两个包含不同版本笔记本的文件夹。full文件夹包含用于创建您现在阅读的书的确切笔记本,包括所有散文和输出。stripped版本具有相同的标题和代码单元格,但所有输出和散文都已删除。阅读书的一部分后,我们建议关闭书,通过 stripped 笔记本进行练习,并尝试在执行之前弄清楚每个单元格将显示什么。还要尝试回想代码正在演示什么。

要打开一个笔记本,只需单击它。笔记本将打开,看起来类似于图 1-3(请注意,不同平台之间可能存在细节上的轻微差异;您可以忽略这些差异)。

笔记本的示例

图 1-3. 一个 Jupyter 笔记本

一个笔记本由单元格组成。有两种主要类型的单元格:

  • 包含格式化文本、图像等内容的单元格。这些使用一种称为Markdown的格式,您很快就会了解。

  • 包含可执行代码的单元格,输出将立即显示在其下方(可以是纯文本、表格、图像、动画、声音,甚至交互式应用程序)。

Jupyter 笔记本可以处于两种模式之一:编辑模式或命令模式。在编辑模式下,键盘上的输入以通常的方式输入到单元格中。但是,在命令模式下,您将看不到任何闪烁的光标,键盘上的每个键都将具有特殊功能。

在继续之前,请按下键盘上的 Escape 键切换到命令模式(如果您已经在命令模式下,则此操作无效,因此现在请按下它)。要查看所有可用功能的完整列表,请按 H;按 Escape 键以删除此帮助屏幕。请注意,在命令模式下,与大多数程序不同,命令不需要按住 Control、Alt 或类似键,您只需按下所需的字母键。

您可以通过按下 C 键(需要首先选择单元格,显示为周围有轮廓;如果尚未选择,请单击一次)来复制单元格。然后按 V 键粘贴副本。

单击以“# CLICK ME”开头的单元格以选择它。该行中的第一个字符表示后面的内容是 Python 中的注释,因此在执行单元格时会被忽略。单元格的其余部分是一个完整的系统,用于创建和训练一个用于识别猫和狗的最先进模型。所以,现在让我们开始训练吧!要这样做,只需在键盘上按 Shift-Enter,或单击工具栏上的播放按钮。然后等待几分钟,以下事情会发生:

  1. 一个名为牛津-IIIT 宠物数据集的数据集,其中包含来自 37 个品种的 7,349 张猫和狗的图像,将从 fast.ai 数据集合中下载到您正在使用的 GPU 服务器上,然后进行提取。

  2. 一个预训练模型,已经在 130 万张图像上训练过,使用了一个获奖模型,将从互联网上下载。

  3. 预训练模型将使用迁移学习的最新进展进行微调,以创建一个专门定制用于识别狗和猫的模型。

前两个步骤只需要在您的 GPU 服务器上运行一次。如果再次运行单元格,它将使用已经下载的数据集和模型,而不是重新下载它们。让我们看看单元格的内容和结果(表 1-2):

# CLICK ME
from fastai.vision.all import *
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=42,
    label_func=is_cat, item_tfms=Resize(224))

learn = cnn_learner(dls, resnet34, metrics=error_rate)
learn.fine_tune(1)

表 1-2. 第一次训练的结果

epoch train_loss valid_loss error_rate time
0 0.169390 0.021388 0.005413 00:14
epoch train_loss valid_loss error_rate time
--- --- --- --- ---
0 0.058748 0.009240 0.002706 00:19

您可能不会看到这里显示的完全相同的结果。训练模型涉及许多小随机变化的来源。在这个例子中,我们通常看到错误率远低于 0.02,然而。

训练时间

根据您的网络速度,下载预训练模型和数据集可能需要几分钟。运行fine_tune可能需要一两分钟。通常,本书中的模型需要几分钟来训练,您自己的模型也是如此,因此最好想出一些好的技巧来充分利用这段时间。例如,当您的模型训练时,继续阅读下一节,或者打开另一个笔记本并用它进行一些编码实验。

那么,我们如何知道这个模型是否好用?在表的最后一列中,你可以看到错误率,即被错误识别的图像的比例。错误率作为我们的度量标准——我们选择的模型质量的衡量标准,旨在直观和易于理解。正如你所看到的,即使训练时间只有几秒钟(不包括数据集和预训练模型的一次性下载),模型几乎是完美的。事实上,你已经取得的准确率比任何人在 10 年前取得的都要好得多!

最后,让我们检查一下这个模型是否真的有效。去找一张狗或猫的照片;如果你手头没有,只需搜索 Google 图片并下载你找到的一张图片。现在执行定义了uploader的单元格。它会输出一个按钮,你可以点击它,然后选择你想分类的图片:

uploader = widgets.FileUpload()
uploader

上传按钮

现在你可以将上传的文件传递给模型。确保它是一张清晰的狗或猫的照片,而不是线描、卡通或类似的照片。笔记本会告诉你它认为这是一只狗还是一只猫,以及它的自信程度。希望你会发现你的模型表现得很好:

img = PILImage.create(uploader.data[0])
is_cat,_,probs = learn.predict(img)
print(f"Is this a cat?: {is_cat}.")
print(f"Probability it's a cat: {probs[1].item():.6f}")
Is this a cat?: True.
Probability it's a cat: 0.999986

恭喜你的第一个分类器!

但是这意味着什么?你实际上做了什么?为了解释这一点,让我们再次放大,看看整体情况。

什么是机器学习?

你的分类器是一个深度学习模型。正如已经提到的,深度学习模型使用神经网络,这些神经网络最初可以追溯到上世纪 50 年代,并且最近由于最新的进展变得非常强大。

另一个重要的背景是,深度学习只是更一般的机器学习领域中的一个现代领域。要理解当你训练自己的分类模型时所做的事情的本质,你不需要理解深度学习。看到你的模型和训练过程是如何成为适用于机器学习的概念的例子就足够了。

因此,在本节中,我们将描述机器学习。我们将探讨关键概念,并看看它们如何可以追溯到最初介绍它们的原始文章。

机器学习就像常规编程一样,是让计算机完成特定任务的一种方式。但是如果要用常规编程来完成前面部分我们刚刚做的事情:在照片中识别狗和猫,我们将不得不为计算机写下完成任务所需的确切步骤。

通常,当我们编写程序时,很容易为我们写下完成任务的步骤。我们只需考虑如果我们必须手动完成任务时会采取的步骤,然后将它们转换为代码。例如,我们可以编写一个对列表进行排序的函数。一般来说,我们会编写一个类似于图 1-4 的函数(其中inputs可能是一个未排序的列表,results是一个排序后的列表)。

管道输入、程序、结果

图 1-4. 传统程序

但是要在照片中识别物体,这有点棘手;当我们在图片中识别物体时,我们采取了什么步骤?我们真的不知道,因为这一切都发生在我们的大脑中,而我们并没有意识到!

早在计算机诞生之初,1949 年,IBM 的一位研究员阿瑟·塞缪尔开始研究一种让计算机完成任务的不同方式,他称之为机器学习。在他经典的 1962 年文章“人工智能:自动化的前沿”中,他写道:

为这样的计算编程对于我们来说是相当困难的,主要不是因为计算机本身的任何固有复杂性,而是因为需要详细说明过程的每一个细微步骤。任何程序员都会告诉你,计算机是巨大的白痴,而不是巨大的大脑。

他的基本想法是这样的:不是告诉计算机解决问题所需的确切步骤,而是向其展示解决问题的示例,并让它自己找出如何解决。结果证明这非常有效:到 1961 年,他的跳棋程序学到了很多,以至于击败了康涅狄格州冠军!这是他描述自己想法的方式(与之前提到的同一篇文章):

假设我们安排一些自动手段来测试任何当前权重分配的有效性,以实际表现为准,并提供一种机制来改变权重分配以最大化性能。我们不需要详细了解这种程序的细节,就可以看到它可以完全自动化,并且可以看到一个这样编程的机器将从中学习。

这个简短陈述中嵌入了一些强大的概念:

  • “权重分配”的想法

  • 每个权重分配都有一些“实际表现”的事实

  • 要求有一种“自动手段”来测试该性能

  • 需要一个“机制”(即,另一个自动过程)来通过改变权重分配来提高性能

让我们逐一了解这些概念,以便了解它们在实践中如何结合。首先,我们需要了解塞缪尔所说的权重分配是什么意思。

权重只是变量,权重分配是这些变量的特定值选择。程序的输入是它处理以产生结果的值,例如,将图像像素作为输入,并返回分类“狗”作为结果。程序的权重分配是定义程序操作方式的其他值。

因为它们会影响程序,它们在某种意义上是另一种输入。我们将更新我们的基本图片图 1-4,并用图 1-5 替换,以便考虑到这一点。

图 1-5。使用权重分配的程序

我们已将方框的名称从程序更改为模型。这是为了遵循现代术语并反映模型是一种特殊类型的程序:它可以根据权重许多不同的事情。它可以以许多不同的方式实现。例如,在塞缪尔的跳棋程序中,不同的权重值会导致不同的跳棋策略。

(顺便说一句,塞缪尔所说的“权重”如今通常被称为模型参数,以防您遇到这个术语。术语权重保留给特定类型的模型参数。)

接下来,塞缪尔说我们需要一种自动测试任何当前权重分配的有效性的方法,以实际表现为准。在他的跳棋程序中,“实际表现”模型的表现有多好。您可以通过让两个模型相互对战并看哪个通常获胜来自动测试两个模型的表现。

最后,他说我们需要一种机制来改变权重分配,以最大化性能。例如,我们可以查看获胜模型和失败模型之间的权重差异,并将权重进一步调整到获胜方向。

我们现在可以看到他为什么说这样的程序可以完全自动化,并且...一个这样编程的机器将从中学习。当权重的调整也是自动的时,学习将变得完全自动——当我们不再通过手动调整权重来改进模型,而是依赖于根据性能产生调整的自动化机制时。

图 1-6 展示了塞缪尔关于训练机器学习模型的完整图景。

!基本训练循环

图 1-6。训练机器学习模型

注意模型的结果(例如,在跳棋游戏中的移动)和其性能(例如,是否赢得比赛,或者赢得比赛的速度)之间的区别。

还要注意,一旦模型训练好了,也就是说,一旦我们选择了最终的、最好的、最喜欢的权重分配,那么我们可以将权重视为模型的一部分,因为我们不再对它们进行变化。

因此,实际上在训练后使用模型看起来像图 1-7。

图 1-7。使用训练后的模型作为程序

这看起来与我们在图 1-4 中的原始图表相同,只是将程序一词替换为模型。这是一个重要的观点:训练后的模型可以像常规计算机程序一样对待

行话:机器学习

通过让计算机从经验中学习而不是通过手动编码个别步骤来开发程序的培训。

什么是神经网络?

不难想象跳棋程序的模型可能是什么样子。可能编码了一系列跳棋策略,以及某种搜索机制,然后权重可以变化以决定如何选择策略,在搜索期间关注棋盘的哪些部分等等。但是对于图像识别程序,或者理解文本,或者我们可能想象的许多其他有趣的问题,模型可能是什么样子却一点也不明显。

我们希望有一种函数,它如此灵活,以至于可以通过调整其权重来解决任何给定问题。令人惊讶的是,这种函数实际上存在!这就是我们已经讨论过的神经网络。也就是说,如果您将神经网络视为数学函数,那么它将是一种极其灵活的函数,取决于其权重。一种称为通用逼近定理的数学证明表明,这种函数在理论上可以解决任何问题,达到任何精度水平。神经网络如此灵活的事实意味着,在实践中,它们通常是一种合适的模型,您可以将精力集中在训练过程上,即找到良好的权重分配。

但是这个过程呢?人们可以想象,您可能需要为每个问题找到一种新的“机制”来自动更新权重。这将是费力的。我们在这里也希望有一种完全通用的方法来更新神经网络的权重,使其在任何给定任务上都能提高。方便的是,这也存在!

这被称为随机梯度下降(SGD)。我们将在第四章中详细了解神经网络和 SGD 的工作原理,以及解释通用逼近定理。然而,现在,我们将使用塞缪尔自己的话来说:我们不需要深入了解这样一个过程的细节,就可以看到它可以完全自动化,并且可以看到这样一个机器编程的机器可以从中学习经验。

杰里米说

不要担心;无论是 SGD 还是神经网络,在数学上都不复杂。它们几乎完全依赖于加法和乘法来完成工作(但它们进行了大量的加法和乘法!)。当学生们看到细节时,我们听到的主要反应是:“就是这样吗?”

换句话说,简而言之,神经网络是一种特殊类型的机器学习模型,它完全符合塞缪尔最初的构想。神经网络之所以特殊,是因为它们非常灵活,这意味着它们可以通过找到正确的权重来解决异常广泛的问题。这是强大的,因为随机梯度下降为我们提供了一种自动找到这些权重值的方法。

放大后,让我们现在缩小范围,重新审视使用塞缪尔框架解决我们的图像分类问题。

我们的输入是图像。我们的权重是神经网络中的权重。我们的模型是一个神经网络。我们的结果是由神经网络计算出的值,比如“狗”或“猫”。

下一个部分是什么,一个自动测试任何当前权重分配的有效性的手段?确定“实际表现”很容易:我们可以简单地将模型的表现定义为其在预测正确答案时的准确性。

将所有这些放在一起,假设 SGD 是我们更新权重分配的机制,我们可以看到我们的图像分类器是一个机器学习模型,就像 Samuel 所设想的那样。

一些深度学习术语

Samuel 在 1960 年代工作,自那时术语已经发生了变化。以下是我们讨论过的所有部分的现代深度学习术语:

  • 模型的功能形式被称为架构(但要小心—有时人们将模型用作架构的同义词,这可能会让人困惑)。

  • 权重被称为参数

  • 预测是从独立变量计算出来的,这是不包括标签数据

  • 模型的结果被称为预测

  • 性能的度量被称为损失

  • 损失不仅取决于预测,还取决于正确的标签(也称为目标因变量);例如,“狗”或“猫”。

在进行这些更改后,我们在图 1-6 中的图表看起来像图 1-8。

图 1-8. 详细训练循环

机器学习固有的限制

从这幅图片中,我们现在可以看到关于训练深度学习模型的一些基本事情:

  • 没有数据就无法创建模型。

  • 模型只能学习操作训练数据中看到的模式。

  • 这种学习方法只创建预测,而不是推荐的行动

  • 仅仅拥有输入数据的示例是不够的;我们还需要为这些数据提供标签(例如,仅有狗和猫的图片不足以训练模型;我们需要为每个图片提供一个标签,说明哪些是狗,哪些是猫)。

一般来说,我们已经看到大多数组织声称他们没有足够的数据实际上意味着他们没有足够的带标签数据。如果任何组织有兴趣在实践中使用模型做一些事情,那么他们可能有一些输入数据计划运行他们的模型。并且可能他们已经以其他方式做了一段时间(例如,手动或使用一些启发式程序),因此他们有来自这些过程的数据!例如,放射学实践几乎肯定会有医学扫描的存档(因为他们需要能够检查他们的患者随时间的进展),但这些扫描可能没有包含诊断或干预措施列表的结构化标签(因为放射科医生通常创建自由文本自然语言报告,而不是结构化数据)。在本书中,我们将大量讨论标记方法,因为这在实践中是一个非常重要的问题。

由于这类机器学习模型只能进行预测(即试图复制标签),这可能导致组织目标与模型能力之间存在显著差距。例如,在本书中,您将学习如何创建一个推荐系统,可以预测用户可能购买的产品。这通常用于电子商务,例如通过显示排名最高的商品来定制主页上显示的产品。但这样的模型通常是通过查看用户及其购买历史(输入)以及他们最终购买或查看的内容(标签)来创建的,这意味着该模型很可能会告诉您关于用户已经拥有或已经了解的产品,而不是他们最有可能对其感兴趣的新产品。这与您当地书店的专家所做的事情大不相同,他们会询问您的口味,然后告诉您您以前从未听说过的作者或系列。

另一个关键的洞察来自于考虑模型如何与其环境互动。这可能会产生反馈循环,如此处所述:

  1. 基于过去的逮捕地点创建了一个预测性执法模型。实际上,这并不是在预测犯罪,而是在预测逮捕,因此部分地只是反映了现有执法过程中的偏见。

  2. 然后执法人员可能会使用该模型来决定在哪里集中他们的执法活动,导致这些地区的逮捕增加。

  3. 这些额外逮捕的数据将被反馈回去重新训练未来版本的模型。

这是一个正反馈循环:模型被使用得越多,数据就变得越有偏见,使模型变得更加有偏见,依此类推。

反馈循环也可能在商业环境中造成问题。例如,视频推荐系统可能会偏向于推荐由视频最大观看者消费的内容(例如,阴谋论者和极端分子倾向于观看比平均水平更多的在线视频内容),导致这些用户增加他们的视频消费量,进而导致更多这类视频被推荐。我们将在第三章中更详细地讨论这个话题。

既然你已经看到了理论的基础,让我们回到我们的代码示例,详细看看代码如何与我们刚刚描述的过程相对应。

我们的图像识别器是如何工作的

让我们看看我们的图像识别器代码如何映射到这些想法。我们将把每一行放入一个单独的单元格,并查看每一行正在做什么(我们暂时不会解释每个参数的每个细节,但会给出重要部分的描述;完整细节将在本书后面提供)。第一行导入了整个 fastai.vision 库:

from fastai.vision.all import *

这为我们提供了创建各种计算机视觉模型所需的所有函数和类。

Jeremy 说

许多 Python 编程人员建议避免像这样导入整个库(使用import *语法),因为在大型软件项目中可能会引起问题。然而,在交互式工作中,比如在 Jupyter 笔记本中,它非常有效。fastai 库专门设计用于支持这种交互式使用,它只会将必要的部分导入到您的环境中。

第二行从fast.ai 数据集合下载一个标准数据集(如果之前没有下载),将其提取出来(如果之前没有提取),并返回一个提取位置的Path对象:

path = untar_data(URLs.PETS)/'images'

Sylvain 说

在 fast.ai 学习期间,甚至到今天,我学到了很多关于高效编码实践的知识。fastai 库和 fast.ai 笔记本中充满了许多有用的小贴士,这些贴士帮助我成为了一个更好的程序员。例如,请注意 fastai 库不仅返回包含数据集路径的字符串,而是一个Path对象。这是 Python 3 标准库中一个非常有用的类,使得访问文件和目录变得更加容易。如果你之前没有接触过它,请务必查看其文档或教程并尝试使用。请注意,书籍网站包含了每章推荐教程的链接。我会继续在我们遇到时告诉你我发现有用的小编码技巧。

在第三行,我们定义了一个函数is_cat,根据数据集创建者提供的文件名规则来标记猫:

def is_cat(x): return x[0].isupper()

我们在第四行使用了这个函数,告诉 fastai 我们拥有什么类型的数据集以及它的结构:

dls = ImageDataLoaders.from_name_func(
    path, get_image_files(path), valid_pct=0.2, seed=42,
    label_func=is_cat, item_tfms=Resize(224))

不同类型的深度学习数据集和问题有各种类别,这里我们使用ImageDataLoaders。类名的第一部分通常是你拥有的数据类型,比如图像或文本。

我们必须告诉 fastai 的另一个重要信息是如何从数据集中获取标签。计算机视觉数据集通常以标签作为文件名或路径的一部分进行结构化,最常见的是父文件夹名称。fastai 带有许多标准化的标记方法,以及编写自己的方法。在这里,我们告诉 fastai 使用我们刚刚定义的is_cat函数。

最后,我们定义了我们需要的TransformTransform包含在训练期间自动应用的代码;fastai 包含许多预定义的Transform,添加新的Transform就像创建一个 Python 函数一样简单。有两种类型:item_tfms应用于每个项目(在本例中,每个项目都被调整为 224 像素的正方形),而batch_tfms应用于一次处理一批项目的 GPU,因此它们特别快速(我们将在本书中看到许多这样的例子)。

为什么是 224 像素?出于历史原因(旧的预训练模型需要这个确切的尺寸),但你几乎可以传入任何尺寸。如果增加尺寸,通常会得到更好的模型结果(因为它可以关注更多细节),但代价是速度和内存消耗;如果减小尺寸,则相反。

术语:分类和回归

分类回归在机器学习中有非常具体的含义。这两种模型是我们在本书中将要研究的两种主要类型。分类模型试图预测一个类别。也就是说,它从许多离散的可能性中进行预测,比如“狗”或“猫”。回归模型试图预测一个或多个数值,比如温度或位置。有时人们使用回归一词来指代一种特定类型的模型,称为线性回归模型;这是一个不好的做法,在本书中我们不会使用这种术语!

Pet 数据集包含 7390 张狗和猫的图片,包括 37 种品种。每个图像都使用其文件名进行标记:例如,文件great_pyrenees_173.jpg是数据集中大白熊犬品种的第 173 个示例图像。如果图像是猫,则文件名以大写字母开头,否则以小写字母开头。我们必须告诉 fastai 如何从文件名中获取标签,我们通过调用from_name_func来实现(这意味着可以使用应用于文件名的函数来提取文件名),并传递x[0].isupper(),如果第一个字母是大写字母(即是猫),则评估为True

在这里提到的最重要的参数是valid_pct=0.2。这告诉 fastai 保留 20%的数据,完全不用于训练模型。这 20%的数据被称为验证集;剩下的 80%被称为训练集。验证集用于衡量模型的准确性。默认情况下,被保留的 20%是随机选择的。参数seed=42随机种子设置为每次运行此代码时相同的值,这意味着每次运行时我们都会得到相同的验证集,这样,如果我们更改模型并重新训练它,我们知道任何差异都是由于对模型的更改,而不是由于有不同的随机验证集。

fastai 将始终仅使用验证集显示模型的准确性,永远不会使用训练集。这是绝对关键的,因为如果您为足够长的时间训练足够大的模型,它最终会记住数据集中每个项目的标签!结果将不是一个有用的模型,因为我们关心的是我们的模型在以前未见过的图像上的工作效果。这总是我们创建模型时的目标:使其在模型仅在未来看到的数据上有用,经过训练后。

即使您的模型尚未完全记住所有数据,在训练的早期阶段可能已经记住了其中的某些部分。因此,您训练的时间越长,您在训练集上的准确性就会越好;验证集的准确性也会在一段时间内提高,但最终会开始变差,因为模型开始记住训练集而不是在数据中找到可泛化的潜在模式。当这种情况发生时,我们说模型过拟合

图 1-9 展示了过拟合时会发生什么,使用一个简化的例子,我们只有一个参数和一些基于函数x**2随机生成的数据。正如您所看到的,尽管过拟合模型在接近观察到的数据点的数据上的预测是准确的,但在该范围之外时则相差甚远。

过拟合示例

图 1-9. 过拟合示例

过拟合是训练所有机器学习从业者和所有算法时最重要和具有挑战性的问题。正如您将看到的,很容易创建一个在准确预测其训练数据上做得很好的模型,但要在模型从未见过的数据上做出准确预测要困难得多。当然,这些数据在实践中是重要的。例如,如果您创建一个手写数字分类器(我们很快就会!)并将其用于识别支票上写的数字,那么您永远不会看到模型训练过的任何数字——每张支票都会有稍微不同的书写变化。

您将在本书中学习许多避免过拟合的方法。但是,只有在确认发生过拟合时(即,如果您观察到训练过程中验证准确性变差)才应使用这些方法。我们经常看到从业者在有足够数据的情况下也使用过拟合避免技术,最终得到的模型可能比他们本可以实现的更不准确。

验证集

当您训练模型时,您必须始终同时拥有训练集和验证集,并且必须仅在验证集上测量模型的准确性。如果您训练时间过长,数据不足,您将看到模型的准确性开始变差;这被称为过拟合。fastai 将valid_pct默认设置为0.2,因此即使您忘记了,fastai 也会为您创建一个验证集!

我们的图像识别器训练代码的第五行告诉 fastai 创建一个卷积神经网络(CNN),并指定要使用的架构(即要创建的模型类型)、我们要对其进行训练的数据以及要使用的度量标准

learn = cnn_learner(dls, resnet34, metrics=error_rate)

为什么使用 CNN?这是创建计算机视觉模型的当前最先进方法。我们将在本书中学习有关 CNN 如何工作的所有知识。它们的结构受到人类视觉系统工作方式的启发。

在 fastai 中有许多架构,我们将在本书中介绍(以及讨论如何创建您自己的架构)。然而,大多数情况下,选择架构并不是深度学习过程中非常重要的部分。这是学术界喜欢谈论的内容,但实际上您不太可能需要花费太多时间。有一些标准架构在大多数情况下都有效,而在这种情况下,我们使用的是一种称为ResNet的架构,我们将在本书中大量讨论;它对许多数据集和问题都既快速又准确。resnet34中的34指的是该架构变体中的层数(其他选项是1850101152)。使用层数更多的架构模型训练时间更长,更容易过拟合(即在验证集上的准确率开始变差之前无法训练多少个时期)。另一方面,当使用更多数据时,它们可能会更准确。

什么是度量标准?度量标准是一个函数,使用验证集来衡量模型预测的质量,并将在每个时期结束时打印出来。在这种情况下,我们使用error_rate,这是 fastai 提供的一个函数,它正是它所说的:告诉您验证集中有多少百分比的图像被错误分类。分类的另一个常见度量标准是accuracy(即1.0 - error_rate)。fastai 提供了许多其他度量标准,这将在本书中讨论。

度量标准的概念可能会让您想起损失,但有一个重要区别。损失的整个目的是定义一个“性能度量”,训练系统可以使用它来自动更新权重。换句话说,损失的一个好选择是易于随机梯度下降使用的选择。但度量标准是为人类消费而定义的,因此一个好的度量标准是您易于理解的,并且尽可能接近您希望模型执行的任务。有时,您可能会决定损失函数是一个合适的度量标准,但这并不一定是情况。

cnn_learner还有一个名为pretrained的参数,默认值为True(因此在这种情况下使用,即使我们没有指定),它将您模型中的权重设置为已经由专家训练过的值,以识别 130 万张照片中的一千个不同类别(使用著名的ImageNet数据集)。具有已在另一个数据集上训练过的权重的模型称为预训练模型。您几乎总是应该使用预训练模型,因为这意味着您的模型在您甚至没有展示任何数据之前就已经非常有能力。正如您将看到的,在深度学习模型中,许多这些能力是您几乎无论项目细节如何都需要的。例如,预训练模型的部分将处理边缘、梯度和颜色检测,这些对许多任务都是必需的。

使用预训练模型时,cnn_learner将移除最后一层,因为该层始终是针对原始训练任务(即 ImageNet 数据集分类)专门定制的,并将其替换为一个或多个具有随机权重的新层,适合您正在处理的数据集的大小。模型的最后部分被称为

使用预训练模型是我们训练更准确、更快速、使用更少数据和更少时间和金钱的最重要方法。您可能会认为使用预训练模型将是学术深度学习中最研究的领域...但您会非常、非常错误!预训练模型的重要性通常在大多数课程、书籍或软件库功能中没有得到认可或讨论,并且在学术论文中很少被考虑。当我们在 2020 年初写这篇文章时,事情刚刚开始改变,但这可能需要一段时间。因此要小心:您与之交谈的大多数人可能会严重低估您可以在深度学习中使用少量资源做些什么,因为他们可能不会深入了解如何使用预训练模型。

使用一个预训练模型来执行一个与其最初训练目的不同的任务被称为迁移学习。不幸的是,由于迁移学习研究不足,很少有领域提供预训练模型。例如,目前在医学领域很少有预训练模型可用,这使得在该领域使用迁移学习具有挑战性。此外,目前还不清楚如何将迁移学习应用于诸如时间序列分析之类的任务。

术语:迁移学习

使用一个预训练模型来执行一个与其最初训练目的不同的任务。

我们代码的第六行告诉 fastai 如何适应模型:

learn.fine_tune(1)

正如我们所讨论的,架构只是描述数学函数的模板;直到我们为其包含的数百万参数提供值之前,它才会真正发挥作用。

这是深度学习的关键之处——确定如何适应模型的参数以使其解决您的问题。要适应一个模型,我们必须提供至少一条信息:每个图像查看多少次(称为时代数)。您选择的时代数将在很大程度上取决于您有多少时间可用,以及您发现在实践中适应模型需要多长时间。如果选择的数字太小,您可以随时稍后进行更多时代的训练。

但为什么这种方法被称为fine_tune,而不是fit?fastai 确实有一个名为fit的方法,它确实适合一个模型(即,多次查看训练集中的图像,每次更新参数使预测越来越接近目标标签)。但在这种情况下,我们已经从一个预训练模型开始,并且我们不想丢弃它已经具有的所有这些功能。正如您将在本书中了解到的,有一些重要的技巧可以使预训练模型适应新数据集,这个过程称为微调

术语:微调

一种迁移学习技术,通过使用与预训练不同的任务进行额外时代的训练来更新预训练模型的参数。

当您使用fine_tune方法时,fastai 将为您使用这些技巧。您可以设置一些参数(我们稍后会讨论),但在此处显示的默认形式中,它执行两个步骤:

  1. 使用一个时代来适应模型的那些部分,以使新的随机头部能够正确地与您的数据集配合工作。

  2. 在调用适合整个模型的方法时,请使用请求的时代数,更快地更新后面的层(特别是头部)的权重,而不是早期的层(正如我们将看到的,通常不需要对预训练权重进行太多更改)。

模型的头部是新添加的部分,专门针对新数据集。一个时代是对数据集的一次完整遍历。在调用fit之后,每个时代后的结果都会被打印出来,显示时代编号,训练和验证集的损失(用于训练模型的“性能度量”),以及您请求的任何指标(在这种情况下是错误率)。

因此,通过所有这些代码,我们的模型学会了仅仅通过标记的示例来识别猫和狗。但它是如何做到的呢?

我们的图像识别器学到了什么

在这个阶段,我们有一个工作良好的图像识别器,但我们不知道它在做什么!尽管许多人抱怨深度学习导致不可理解的“黑匣子”模型(即,可以提供预测但没有人能理解的东西),但事实并非如此。有大量研究表明如何深入检查深度学习模型并从中获得丰富的见解。话虽如此,各种机器学习模型(包括深度学习和传统统计模型)都可能难以完全理解,特别是考虑到它们在遇到与用于训练它们的数据非常不同的数据时的行为。我们将在本书中讨论这个问题。

2013 年,博士生 Matt Zeiler 和他的导师 Rob Fergus 发表了《可视化和理解卷积网络》,展示了如何可视化模型每一层学到的神经网络权重。他们仔细分析了赢得 2012 年 ImageNet 比赛的模型,并利用这一分析大大改进了模型,使他们能够赢得 2013 年的比赛!图 1-10 是他们发表的第一层权重的图片。

CNN 早期层的激活

图 1-10。CNN 第一层的激活(由 Matthew D. Zeiler 和 Rob Fergus 提供)

这张图片需要一些解释。对于每一层,具有浅灰色背景的图像部分显示了重建的权重,底部较大的部分显示了与每组权重最匹配的训练图像部分。对于第一层,我们可以看到模型发现了代表对角线、水平和垂直边缘以及各种梯度的权重。(请注意,对于每一层,只显示了部分特征;实际上,在所有层中有成千上万个特征。)

这些是模型为计算机视觉学习的基本构建块。它们已经被神经科学家和计算机视觉研究人员广泛分析,结果表明,这些学习的构建块与人眼的基本视觉机制以及在深度学习之前开发的手工计算机视觉特征非常相似。下一层在图 1-11 中表示。

CNN 早期层的激活

图 1-11。CNN 第二层的激活(由 Matthew D. Zeiler 和 Rob Fergus 提供)

对于第 2 层,模型找到的每个特征都有九个权重重建示例。我们可以看到模型已经学会创建寻找角、重复线条、圆圈和其他简单模式的特征检测器。这些是从第一层中开发的基本构建块构建的。对于每个特征,图片右侧显示了与这些特征最匹配的实际图像的小块。例如,第 2 行第 1 列中的特定模式与日落相关的梯度和纹理相匹配。

图 1-12 显示了一篇论文中展示第 3 层特征重建结果的图片。

CNN 中间层的激活

图 1-12。CNN 第三层的激活(由 Matthew D. Zeiler 和 Rob Fergus 提供)

通过观察图片右侧,您可以看到特征现在能够识别和匹配更高级的语义组件,如汽车车轮、文字和花瓣。利用这些组件,第 4 层和第 5 层可以识别更高级的概念,如图 1-13 所示。

CNN 末端层的激活

图 1-13。CNN 的第四和第五层的激活(由 Matthew D. Zeiler 和 Rob Fergus 提供)

本文研究了一个名为AlexNet的旧模型,该模型只包含五层。自那时以来开发的网络可以有数百层 - 所以你可以想象这些模型开发的特征有多丰富!

当我们早期微调我们的预训练模型时,我们调整了最后几层关注的内容(花朵、人类、动物),以专注于猫与狗问题。更一般地,我们可以将这样的预训练模型专门用于许多不同的任务。让我们看一些例子。

图像识别器可以处理非图像任务

图像识别器只能识别图像,顾名思义。但很多事物可以被表示为图像,这意味着图像识别器可以学会完成许多任务。

例如,声音可以转换为频谱图,这是一种图表,显示音频文件中每个时间的每个频率的数量。fast.ai 学生 Ethan Sutin 使用这种方法,轻松击败了一种最先进的环境声音检测模型的发布准确率,使用了 8732 个城市声音的数据集。fastai 的show_batch清楚地显示了每个声音具有相当独特的频谱图,如图 1-14 所示。

显示具有声音频谱图的 show_batch

图 1-14。显示具有声音频谱图的 show_batch

时间序列可以很容易地通过简单地在图表上绘制时间序列来转换为图像。然而,通常最好尝试以尽可能简单的方式表示数据,以便提取出最重要的组件。在时间序列中,季节性和异常很可能是感兴趣的。

时间序列数据有各种转换方法。例如,fast.ai 学生 Ignacio Oguiza 使用一种称为 Gramian Angular Difference Field(GADF)的技术,从一个时间序列数据集中为橄榄油分类创建图像,你可以在图 1-15 中看到结果。然后,他将这些图像输入到一个图像分类模型中,就像你在本章中看到的那样。尽管只有 30 个训练集图像,但他的结果准确率超过 90%,接近最先进水平。

将时间序列转换为图像

图 1-15。将时间序列转换为图像

另一个有趣的 fast.ai 学生项目示例来自 Gleb Esman。他在 Splunk 上进行欺诈检测,使用了用户鼠标移动和鼠标点击的数据集。他通过绘制显示鼠标指针位置、速度和加速度的图像,使用彩色线条,并使用小彩色圆圈显示点击,将这些转换为图片,如图 1-16 所示。他将这些输入到一个图像识别模型中,就像我们在本章中使用的那样,效果非常好,导致了这种方法在欺诈分析方面的专利!

将计算机鼠标行为转换为图像

图 1-16。将计算机鼠标行为转换为图像

另一个例子来自 Mahmoud Kalash 等人的论文“使用深度卷积神经网络进行恶意软件分类”,解释了“恶意软件二进制文件被分成 8 位序列,然后转换为等效的十进制值。这个十进制向量被重塑,生成了一个代表恶意软件样本的灰度图像”,如图 1-17 所示。

恶意软件分类过程

图 1-17。恶意软件分类过程

作者们随后展示了通过恶意软件分类生成的“图片”,如图 1-18 所示。

恶意软件示例

图 1-18。恶意软件示例

正如您所看到的,不同类型的恶意软件在人眼中看起来非常独特。研究人员基于这种图像表示训练的模型在恶意软件分类方面比学术文献中显示的任何先前方法都更准确。这表明将数据集转换为图像表示的一个很好的经验法则:如果人眼可以从图像中识别类别,那么深度学习模型也应该能够做到。

总的来说,您会发现在深度学习中,少数几种通用方法可以走得很远,只要您在如何表示数据方面有点创造性!您不应该将这里描述的方法视为“巧妙的变通方法”,因为它们通常(如此处)击败了以前的最先进结果。这确实是正确思考这些问题领域的方法。

术语回顾

我们刚刚涵盖了很多信息,让我们简要回顾一下。表 1-3 提供了一个方便的词汇表。

表 1-3. 深度学习词汇表

术语 意义
标签 我们试图预测的数据,比如“狗”或“猫”
架构 我们试图拟合的模型的 * 模板 *;即我们将输入数据和参数传递给的实际数学函数
模型 架构与特定一组参数的组合
参数 模型中改变任务的值,通过模型训练进行更新
拟合 更新模型的参数,使得使用输入数据的模型预测与目标标签匹配
训练 * 拟合 * 的同义词
预训练模型 已经训练过的模型,通常使用大型数据集,并将进行微调
微调 为不同任务更新预训练模型
纪元 一次完整通过输入数据
损失 衡量模型好坏的指标,选择以驱动通过 SGD 进行训练
指标 使用验证集衡量模型好坏的测量标准,选择供人类消费
验证集 从训练中保留的一组数据,仅用于衡量模型好坏
训练集 用于拟合模型的数据;不包括验证集中的任何数据
过拟合 以使模型 * 记住 * 输入数据的特定特征而不是很好地泛化到训练期间未见的数据的方式训练模型
CNN 卷积神经网络;一种特别适用于计算机视觉任务的神经网络

有了这个词汇表,我们现在可以将迄今介绍的所有关键概念汇集在一起。花点时间回顾这些定义,并阅读以下摘要。如果您能理解解释,那么您就有能力理解接下来的讨论。

  • 机器学习 *是一种学科,我们通过从数据中学习来定义程序,而不是完全自己编写它。 * 深度学习 *是机器学习中使用具有多个 * 层 * 的 * 神经网络 * 的专业领域。 * 图像分类 * 是一个代表性的例子(也称为 * 图像识别 *)。我们从 * 标记数据 * 开始 - 一组我们为每个图像分配了 * 标签 * 的图像,指示它代表什么。我们的目标是生成一个称为 * 模型 * 的程序,给定一个新图像,将对该新图像代表的内容进行准确的 * 预测 *。

每个模型都从选择 * 架构 * 开始,这是该类型模型内部工作方式的一般模板。 * 训练 *(或 * 拟合 *)模型的过程是找到一组 * 参数值 *(或 * 权重 *),这些参数值将该一般架构专门化为适用于我们特定数据类型的模型。为了定义模型在单个预测上的表现如何,我们需要定义一个 * 损失函数 *,它确定我们如何将预测评分为好或坏。

为了让训练过程更快,我们可以从一个预训练模型开始——一个已经在其他人的数据上训练过的模型。然后我们可以通过在我们的数据上进一步训练它来使其适应我们的数据,这个过程称为微调

当我们训练一个模型时,一个关键问题是确保我们的模型泛化:它从我们的数据中学到的一般性教训也适用于它将遇到的新项目,这样它就可以对这些项目做出良好的预测。风险在于,如果我们训练模型不当,它实际上会记住它已经看到的内容,而不是学习一般性教训,然后它将对新图像做出糟糕的预测。这样的失败被称为过拟合

为了避免这种情况,我们总是将数据分为两部分,训练集验证集。我们通过只向模型展示训练集来训练模型,然后通过查看模型在验证集中的表现来评估模型的表现如何。通过这种方式,我们检查模型从训练集中学到的教训是否适用于验证集。为了评估模型在验证集上的整体表现,我们定义一个度量。在训练过程中,当模型看到训练集中的每个项目时,我们称之为一个周期

所有这些概念都适用于机器学习。它们适用于各种通过训练数据定义模型的方案。深度学习的独特之处在于一类特定的架构:基于神经网络的架构。特别是,像图像分类这样的任务在卷积神经网络上严重依赖,我们将很快讨论。

深度学习不仅仅适用于图像分类

近年来,深度学习在分类图像方面的有效性已经被广泛讨论,甚至在识别 CT 扫描中的恶性肿瘤等复杂任务上显示出超人类的结果。但它可以做的远不止这些,正如我们将在这里展示的。

例如,让我们谈谈对于自动驾驶汽车至关重要的一点:在图片中定位物体。如果自动驾驶汽车不知道行人在哪里,那么它就不知道如何避开!创建一个能够识别图像中每个单独像素内容的模型被称为分割。以下是我们如何使用 fastai 训练一个分割模型,使用来自 Gabriel J. Brostow 等人的论文“视频中的语义对象类:高清实况数据库”中的CamVid数据集的子集:

path = untar_data(URLs.CAMVID_TINY)
dls = SegmentationDataLoaders.from_label_func(
    path, bs=8, fnames = get_image_files(path/"images"),
    label_func = lambda o: path/'labels'/f'{o.stem}_P{o.suffix}',
    codes = np.loadtxt(path/'codes.txt', dtype=str)
)

learn = unet_learner(dls, resnet34)
learn.fine_tune(8)
epoch train_loss valid_loss time
0 2.906601 2.347491 00:02
epoch train_loss valid_loss time
--- --- --- ---
0 1.988776 1.765969 00:02
1 1.703356 1.265247 00:02
2 1.591550 1.309860 00:02
3 1.459745 1.102660 00:02
4 1.324229 0.948472 00:02
5 1.205859 0.894631 00:02
6 1.102528 0.809563 00:02
7 1.020853 0.805135 00:02

我们甚至不会逐行走过这段代码,因为它几乎与我们之前的示例完全相同!(我们将在第十五章深入探讨分割模型,以及本章中我们简要介绍的所有其他模型以及更多更多。)

我们可以通过要求模型为图像的每个像素着色来可视化它的任务完成情况。正如您所看到的,它几乎完美地对每个对象中的每个像素进行分类。例如,请注意所有的汽车都被叠加着相同的颜色,所有的树都被叠加着相同的颜色(在每对图像中,左侧图像是地面实况标签,右侧是模型的预测):

learn.show_results(max_n=6, figsize=(7,8))

另一个深度学习在过去几年显著改进的领域是自然语言处理(NLP)。计算机现在可以生成文本,自动从一种语言翻译到另一种语言,分析评论,标记句子中的单词等等。以下是训练一个模型所需的所有代码,该模型可以比五年前世界上任何东西更好地分类电影评论的情感:

from fastai.text.all import *

dls = TextDataLoaders.from_folder(untar_data(URLs.IMDB), valid='test')
learn = text_classifier_learner(dls, AWD_LSTM, drop_mult=0.5, metrics=accuracy)
learn.fine_tune(4, 1e-2)
epoch train_loss valid_loss accuracy time
0 0.594912 0.407416 0.823640 01:35
epoch train_loss valid_loss accuracy time
--- --- --- --- ---
0 0.268259 0.316242 0.876000 03:03
1 0.184861 0.246242 0.898080 03:10
2 0.136392 0.220086 0.918200 03:16
3 0.106423 0.191092 0.931360 03:15

这个模型使用了 Andrew Maas 等人的论文“Learning Word Vectors for Sentiment Analysis”中的IMDb Large Movie Review dataset。它在许多千字长的电影评论中表现良好,但让我们在一个短评论上测试一下看看它的表现如何:

learn.predict("I really liked that movie!")
('pos', tensor(1), tensor([0.0041, 0.9959]))

在这里我们可以看到模型认为评论是积极的。结果的第二部分是我们数据词汇中“pos”的索引,最后一部分是分配给每个类的概率(“pos”为 99.6%,“neg”为 0.4%)。

现在轮到你了!写下你自己的迷你电影评论,或者从互联网上复制一个,看看这个模型对它的看法。

如果您对 fastai 的任何方法有任何疑问,您应该使用doc函数,将方法名称传递给它:

doc(learn.predict)

一个窗口弹出,包含一个简短的一行解释。 “在文档中显示”链接将带您到完整的文档,在那里您将找到所有细节和许多示例。此外,fastai 的大多数方法只是几行代码,因此您可以单击“源”链接查看幕后发生的情况。

让我们继续讨论一些不那么性感,但可能在商业上更有用的事情:从普通表格数据构建模型。

术语:表格

表格形式的数据,例如来自电子表格、数据库或逗号分隔值(CSV)文件。表格模型是一种试图根据表格中其他列的信息来预测表格中一列的模型。

事实证明,这看起来非常相似。以下是训练一个模型所需的代码,该模型将根据个人的社会经济背景预测一个人是否是高收入者:

from fastai.tabular.all import *
path = untar_data(URLs.ADULT_SAMPLE)

dls = TabularDataLoaders.from_csv(path/'adult.csv', path=path, y_names="salary",
    cat_names = ['workclass', 'education', 'marital-status', 'occupation',
                 'relationship', 'race'],
    cont_names = ['age', 'fnlwgt', 'education-num'],
    procs = [Categorify, FillMissing, Normalize])

learn = tabular_learner(dls, metrics=accuracy)

正如您所看到的,我们不得不告诉 fastai 哪些列是分类(包含一组离散选择之一的值,例如occupation)与连续(包含表示数量的数字,例如age)。

这个任务没有预训练模型可用(一般来说,预训练模型在任何表格建模任务中都不广泛可用,尽管一些组织已经为内部使用创建了这些模型),所以在这种情况下我们不使用fine_tune。相反,我们使用fit_one_cycle,这是训练 fastai 模型从头开始(即没有迁移学习)最常用的方法:

learn.fit_one_cycle(3)
epoch train_loss valid_loss accuracy time
0 0.359960 0.357917 0.831388 00:11
1 0.353458 0.349657 0.837991 00:10
2 0.338368 0.346997 0.843213 00:10

这个模型使用了 Ron Kohavi 的论文“Scaling Up the Accuracy of Naive-Bayes Classifiers: a Decision-Tree Hybrid”中的Adult数据集,其中包含一些关于个人的人口统计数据(如他们的教育、婚姻状况、种族、性别以及是否年收入超过 5 万美元)。该模型的准确率超过 80%,训练时间约为 30 秒。

让我们再看一个例子。推荐系统很重要,特别是在电子商务中。像亚马逊和 Netflix 这样的公司努力推荐用户可能喜欢的产品或电影。以下是如何训练一个模型,根据用户以前的观影习惯,预测用户可能喜欢的电影,使用MovieLens 数据集

from fastai.collab import *
path = untar_data(URLs.ML_SAMPLE)
dls = CollabDataLoaders.from_csv(path/'ratings.csv')
learn = collab_learner(dls, y_range=(0.5,5.5))
learn.fine_tune(10)
epoch train_loss valid_loss time
0 1.554056 1.428071 00:01
epoch train_loss valid_loss time
--- --- --- ---
0 1.393103 1.361342 00:01
1 1.297930 1.159169 00:00
2 1.052705 0.827934 00:01
3 0.810124 0.668735 00:01
4 0.711552 0.627836 00:01
5 0.657402 0.611715 00:01
6 0.633079 0.605733 00:01
7 0.622399 0.602674 00:01
8 0.629075 0.601671 00:00
9 0.619955 0.601550 00:01

这个模型在 0.5 到 5.0 的范围内预测电影评分,平均误差约为 0.6。由于我们预测的是一个连续数值,而不是一个类别,我们必须告诉 fastai 我们的目标范围是多少,使用y_range参数。

虽然我们实际上并没有使用预训练模型(和表格模型一样的原因),但这个例子显示了 fastai 在这种情况下仍然让我们使用fine_tune(您将在第五章中学习到这是如何以及为什么有效)。有时最好尝试fine_tunefit_one_cycle,看看哪个对您的数据集效果更好。

我们可以使用之前看到的相同的show_results调用来查看一些用户和电影 ID、实际评分和预测:

learn.show_results()
userId movieId rating rating_pred
0 157 1200 4.0 3.558502
1 23 344 2.0 2.700709
2 19 1221 5.0 4.390801
3 430 592 3.5 3.944848
4 547 858 4.0 4.076881
5 292 39 4.5 3.753513
6 529 1265 4.0 3.349463
7 19 231 3.0 2.881087
8 475 4963 4.0 4.023387
9 130 260 4.5 3.979703

我们训练的每个模型都显示了训练和验证损失。一个好的验证集是训练过程中最重要的部分之一。让我们看看为什么,并学习如何创建一个。

验证集和测试集

正如我们所讨论的,模型的目标是对数据进行预测。但模型训练过程基本上是愚蠢的。如果我们用所有的数据训练一个模型,然后使用同样的数据评估模型,我们将无法判断我们的模型在未见过的数据上表现如何。没有这个非常宝贵的信息来指导我们训练模型,很有可能模型会擅长对这些数据进行预测,但在新数据上表现不佳。

为了避免这种情况,我们的第一步是将数据集分成两组:训练集(模型在训练中看到的)和验证集,也称为开发集(仅用于评估)。这样我们可以测试模型是否从训练数据中学到的经验可以推广到新数据,即验证数据。

理解这种情况的一种方式是,在某种意义上,我们不希望我们的模型通过“作弊”来获得好的结果。如果它对一个数据项做出准确的预测,那应该是因为它已经学到了那种类型的特征,而不是因为模型已经被实际看到那个特定项所塑造。

将验证数据集分离出来意味着我们的模型在训练中从未见过它,因此完全没有被它污染,并且没有以任何方式作弊。对吧?

实际上,并非一定如此。情况更为微妙。这是因为在现实场景中,我们很少仅通过一次训练参数来构建模型。相反,我们可能通过各种建模选择来探索模型的许多版本,包括网络架构、学习率、数据增强策略等因素,我们将在接下来的章节中讨论。其中许多选择可以描述为超参数的选择。这个词反映了它们是关于参数的参数,因为它们是控制权重参数含义的高级选择。

问题在于,即使普通的训练过程只看训练数据的预测结果来学习权重参数的值,我们却不是这样。作为建模者,当我们决定探索新的超参数值时,我们通过查看验证数据的预测结果来评估模型!因此,模型的后续版本间接地受到我们看到验证数据的影响。就像自动训练过程有过拟合训练数据的危险一样,我们通过人为试错和探索有过拟合验证数据的危险。

解决这个难题的方法是引入另一个更高度保留数据的层级:测试集。就像我们在训练过程中保留验证数据一样,我们必须连自己都不使用测试集数据。它不能用来改进模型;它只能在我们努力的最后阶段用来评估模型。实际上,我们定义了一个基于我们希望如何完全隐藏数据的层次结构:训练数据完全暴露,验证数据较少暴露,测试数据完全隐藏。这种层次结构与不同种类的建模和评估过程本身相对应——自动训练过程与反向传播,尝试不同超参数之间的更手动过程,以及我们最终结果的评估。

测试集和验证集应该有足够的数据来确保您对准确性有一个良好的估计。例如,如果您正在创建一个猫检测器,通常您希望验证集中至少有 30 只猫。这意味着如果您有数千个项目的数据集,使用默认的 20%验证集大小可能超出您的需求。另一方面,如果您有大量数据,将其中一部分用于验证可能没有任何不利之处。

拥有两个级别的“保留数据”——验证集和测试集,其中一个级别代表您几乎隐藏自己的数据——可能看起来有点极端。但通常是必要的,因为模型往往倾向于朝着做出良好预测的最简单方式(记忆)发展,而我们作为易犯错误的人类往往倾向于欺骗自己关于我们的模型表现如何。测试集的纪律帮助我们保持思想上的诚实。这并不意味着我们总是需要一个单独的测试集——如果您的数据很少,您可能只需要一个验证集——但通常最好尽可能使用一个。

如果您打算雇用第三方代表您进行建模工作,这种纪律也可能至关重要。第三方可能无法准确理解您的要求,或者他们的激励甚至可能鼓励他们误解。一个好的测试集可以极大地减轻这些风险,并让您评估他们的工作是否解决了您实际的问题。

直截了当地说,如果你是组织中的高级决策者(或者你正在为高级决策者提供建议),最重要的要点是:如果你确保真正理解测试和验证集以及它们的重要性,你将避免我们看到的组织决定使用 AI 时最大的失败源。例如,如果你考虑引入外部供应商或服务,请确保保留一些测试数据,供供应商永远看不到。然后在你的测试数据上检查他们的模型,使用根据实际情况选择的度量标准,并决定什么水平的性能是足够的。(你自己尝试一个简单的基线也是个好主意,这样你就知道一个真正简单的模型能够实现什么。通常情况下,你的简单模型的表现会和外部“专家”制作的模型一样好!)

在定义测试集时要有判断力

要很好地定义验证集(以及可能的测试集),有时你需要做的不仅仅是随机抽取原始数据集的一部分。记住:验证和测试集的一个关键特性是它们必须代表你将来看到的新数据。这听起来可能像一个不可能的要求!根据定义,你还没有看到这些数据。但通常你仍然会知道一些事情。

看一些例子是很有启发性的。这些例子中的许多来自于Kaggle平台上的预测建模竞赛,这是你可能在实践中看到的问题和方法的很好代表。

一个情况可能是当你在查看时间序列数据时。对于时间序列,选择数据的随机子集既太容易(你可以查看你试图预测的日期之前和之后的数据),又不代表大多数业务用例(在这些用例中,你使用历史数据构建模型以供将来使用)。如果你的数据包含日期,并且你正在构建一个将来使用的模型,你将希望选择最新日期的连续部分作为验证集(例如,可用数据的最后两周或最后一个月)。

假设你想将图 1-19 中的时间序列数据分成训练集和验证集。

一系列数值

图 1-19. 一个时间序列

一个随机子集是一个糟糕的选择(填补缺失太容易,且不代表你在生产中所需的),正如我们在图 1-20 中所看到的。

随机训练子集

图 1-20. 一个糟糕的训练子集

相反,使用早期数据作为训练集(以及后期数据作为验证集),如图 1-21 所示。

使用直到某个时间戳的数据作为训练子集

图 1-21. 一个好的训练子集

例如,Kaggle 曾举办一场竞赛,要求预测厄瓜多尔杂货店连锁店的销售额。 Kaggle 的训练数据从 2013 年 1 月 1 日到 2017 年 8 月 15 日,测试数据跨越了 2017 年 8 月 16 日到 2017 年 8 月 31 日。这样,竞赛组织者确保参赛者在未来时间段进行预测,从他们模型的角度来看。这类似于量化对冲基金交易员进行回测,以检查他们的模型是否能够根据过去的数据预测未来的时间段。

第二种常见情况是,当你可以很容易地预见到你将用来训练模型的数据与你将在生产中进行预测的数据可能在质量上有所不同时。

在 Kaggle 分心司机比赛中,自变量是司机在车轮上的照片,因变量是文本、吃东西或安全地向前看等类别。很多照片是同一司机在不同位置的照片,正如我们在图 1-22 中所看到的。如果你是一家保险公司根据这些数据构建模型,注意你最感兴趣的是模型在未见过的司机身上的表现(因为你可能只有一小部分人的训练数据)。为此,比赛的测试数据包括那些在训练集中没有出现的人的图像。

来自训练数据的两张图片,展示同一个司机

图 1-22. 训练数据中的两张图片

如果你将图 1-22 中的一张图片放入训练集,另一张放入验证集,你的模型将更容易预测验证集中的那张图片,因此它看起来表现得比在新人身上更好。另一个角度是,如果你在训练模型时使用了所有人,你的模型可能会过度拟合这些特定人的特点,而不仅仅是学习状态(发短信、吃东西等)。

Kaggle 渔业比赛中,也存在类似的动态,目的是识别渔船捕捞的鱼类物种,以减少对濒临灭绝种群的非法捕捞。测试集包括来自训练数据中没有出现的船只的图像,因此在这种情况下,你希望你的验证集也包括训练集中没有的船只。

有时可能不清楚你的验证数据会有什么不同。例如,对于使用卫星图像的问题,你需要收集更多信息,了解训练集是否只包含某些地理位置或来自地理分散的数据。

现在你已经尝试了如何构建模型,你可以决定接下来想深入研究什么。

一个选择你自己的冒险时刻

如果你想了解如何在实践中使用深度学习模型,包括如何识别和修复错误、创建一个真正的工作网络应用程序,并避免你的模型对你的组织或社会造成意外伤害,那么请继续阅读接下来的两章。如果你想开始学习深度学习在幕后是如何工作的基础知识,请跳到第四章。(你小时候有读过选择你自己的冒险系列书吗?嗯,这有点像那个……只不过比那本书系列包含更多的深度学习。)

你需要阅读所有这些章节才能在书中进一步前进,但你阅读它们的顺序完全取决于你。它们不相互依赖。如果你跳到第四章,我们会在最后提醒你回来阅读你跳过的章节,然后再继续前进。

问卷调查

阅读了一页又一页的散文之后,很难知道你真正需要专注和记住的关键事项。因此,我们准备了一份问题列表和建议的步骤清单,供你在每章末完成。所有答案都在章节的文本中,所以如果你对这里的任何事情不确定,重新阅读文本的那部分,并确保你理解了它。所有这些问题的答案也可以在书的网站上找到。如果你遇到困难,也可以访问论坛寻求其他学习这些材料的人的帮助。

  1. 你需要这些来进行深度学习吗?

    • 很多数学 T/F

    • 很多数据 T/F

    • 很多昂贵的电脑 T/F

    • 一个博士学位 T/F

  2. 列出深度学习现在是世界上最好的工具的五个领域。

  3. 第一个基于人工神经元原理的设备的名称是什么?

  4. 根据同名书籍,分布式并行处理(PDP)的要求是什么?

  5. 是什么两个理论误解阻碍了神经网络领域的发展?

  6. 什么是 GPU?

  7. 打开一个笔记本并执行包含:1+1 的单元格。会发生什么?

  8. 跟随本章笔记本的精简版本中的每个单元格。在执行每个单元格之前,猜测会发生什么。

  9. 完成Jupyter Notebook 在线附录

  10. 为什么使用传统计算机程序来识别照片中的图像很困难?

  11. 塞缪尔所说的“权重分配”是什么意思?

  12. 在深度学习中,我们通常用什么术语来表示塞缪尔所说的“权重”?

  13. 画一幅总结塞缪尔对机器学习模型看法的图片。

  14. 为什么很难理解深度学习模型为什么会做出特定的预测?

  15. 展示了一个定理的名称,该定理表明神经网络可以解决任何数学问题并达到任何精度水平是什么?

  16. 为了训练模型,您需要什么?

  17. 反馈循环如何影响预测性警务模型的推出?

  18. 我们在猫识别模型中总是需要使用 224×224 像素的图像吗?

  19. 分类和回归之间有什么区别?

  20. 什么是验证集?什么是测试集?为什么我们需要它们?

  21. 如果不提供验证集,fastai 会怎么做?

  22. 我们总是可以使用随机样本作为验证集吗?为什么或为什么不?

  23. 什么是过拟合?举个例子。

  24. 什么是度量?它与损失有什么不同?

  25. 预训练模型如何帮助?

  26. 模型的“头”是什么?

  27. CNN 的早期层找到了什么样的特征?后期层呢?

  28. 图像模型仅对照片有用吗?

  29. 什么是架构?

  30. 什么是分割?

  31. y_range 用于什么?什么时候需要它?

  32. 什么是超参数?

  33. 在组织中使用 AI 时避免失败的最佳方法是什么?

进一步研究

每章还有一个“进一步研究”部分,提出了一些在文本中没有完全回答的问题,或者给出了更高级的任务。这些问题的答案不在书的网站上;您需要自己进行研究!

  1. 为什么 GPU 对深度学习有用?CPU 有什么不同之处,为什么对深度学习效果不佳?

  2. 试着想出三个反馈循环可能影响机器学习使用的领域。看看是否能找到实践中发生这种情况的文档示例。

第二章:从模型到生产

原文:www.bookstack.cn/read/th-fastai-book/fc07273d062b4173.md

译者:飞龙

协议:CC BY-NC-SA 4.0

我们在第一章中看到的六行代码只是在实践中使用深度学习过程的一小部分。在本章中,我们将使用一个计算机视觉示例来查看创建深度学习应用的端到端过程。更具体地说,我们将构建一个熊分类器!在这个过程中,我们将讨论深度学习的能力和限制,探讨如何创建数据集,在实践中使用深度学习时可能遇到的问题等等。许多关键点同样适用于其他深度学习问题,例如第一章中的问题。如果您解决的问题在关键方面类似于我们的示例问题,我们期望您可以快速获得极好的结果,而只需很少的代码。

让我们从如何构建您的问题开始。

深度学习的实践

我们已经看到深度学习可以快速解决许多具有挑战性的问题,并且只需很少的代码。作为初学者,有一些问题与我们的示例问题足够相似,以便您可以非常快速地获得极其有用的结果。然而,深度学习并不是魔法!同样的六行代码不会适用于今天任何人可以想到的每个问题。

低估深度学习的限制并高估其能力可能导致令人沮丧的糟糕结果,至少在您获得一些经验并能解决出现的问题之前。相反,高估深度学习的限制并低估其能力可能意味着您不会尝试可解决的问题,因为您自己否定了它。

我们经常与低估深度学习的限制和能力的人交谈。这两者都可能是问题:低估能力意味着您可能甚至不会尝试可能非常有益的事情,而低估限制可能意味着您未能考虑和应对重要问题。

最好的做法是保持开放的心态。如果您对深度学习可能以比您预期的更少的数据或复杂性解决部分问题持开放态度,您可以设计一个过程,通过该过程您可以找到与您特定问题相关的特定能力和限制。这并不意味着进行任何冒险的赌注-我们将向您展示如何逐渐推出模型,以便它们不会带来重大风险,并且甚至可以在投入生产之前对其进行回测。

开始您的项目

那么您应该从哪里开始深度学习之旅呢?最重要的是确保您有一个要处理的项目-只有通过处理自己的项目,您才能获得构建和使用模型的真实经验。在选择项目时,最重要的考虑因素是数据的可用性。

无论您是为了自己的学习还是为了在组织中的实际应用而进行项目,您都希望能够快速开始。我们看到许多学生、研究人员和行业从业者在试图找到他们完美的数据集时浪费了几个月甚至几年的时间。目标不是找到“完美”的数据集或项目,而只是开始并从那里迭代。如果您采取这种方法,您将在完美主义者仍处于规划阶段时进行第三次迭代学习和改进!

我们还建议您在项目中端到端迭代;不要花几个月来微调您的模型,或打磨完美的 GUI,或标记完美的数据集……相反,尽可能在合理的时间内完成每一步,一直到最后。例如,如果您的最终目标是一个在手机上运行的应用程序,那么每次迭代后您都应该拥有这个。但也许在早期迭代中您会采取捷径;例如,在远程服务器上进行所有处理,并使用简单的响应式 Web 应用程序。通过完成项目的端到端,您将看到最棘手的部分在哪里,以及哪些部分对最终结果产生最大影响。

当您阅读本书时,我们建议您完成许多小实验,通过运行和调整我们提供的笔记本,同时逐渐开发自己的项目。这样,您将获得所有我们解释的工具和技术的经验,同时我们讨论它们。

Sylvain 说

为了充分利用这本书,花时间在每一章之间进行实验,无论是在您自己的项目上还是通过探索我们提供的笔记本。然后尝试在新数据集上从头开始重写这些笔记本。只有通过大量练习(和失败),您才能培养出如何训练模型的直觉。

通过使用端到端迭代方法,您还将更好地了解您实际需要多少数据。例如,您可能会发现您只能轻松获得 200 个标记数据项,而在尝试之前,您无法真正知道这是否足以使您的应用在实践中良好运行。

在组织环境中,您可以通过展示一个真实的工作原型来向同事展示您的想法是可行的。我们反复观察到,这是获得项目良好组织支持的秘诀。

由于最容易开始的项目是您已经有数据可用的项目,这意味着最容易开始的项目可能与您已经在做的事情相关,因为您已经有关于您正在做的事情的数据。例如,如果您在音乐行业工作,您可能可以访问许多录音。如果您是放射科医生,您可能可以访问大量医学图像。如果您对野生动物保护感兴趣,您可能可以访问大量野生动物图像。

有时您必须有点创造性。也许您可以找到一个先前的机器学习项目,比如一个与您感兴趣的领域相关的 Kaggle 竞赛。有时您必须做出妥协。也许您找不到您所需的确切数据来完成您心中的项目;但您可能会找到一些来自类似领域的数据,或者以不同方式测量的数据,解决一个略有不同的问题。在这些类似项目上工作仍然会让您对整个过程有很好的理解,并可能帮助您识别其他捷径、数据来源等。

特别是当您刚开始学习深度学习时,最好不要涉足非常不同的领域,不要涉足深度学习之前未应用的领域。因为如果您的模型一开始就不起作用,您将不知道是因为您犯了错误,还是您试图解决的问题根本无法用深度学习解决。您也不知道从哪里寻求帮助。因此,最好首先找到在线的一个例子,该例子已经取得了良好的结果,并且至少与您尝试实现的目标有些相似,通过将您的数据转换为其他人以前使用过的格式(例如从您的数据创建图像)。让我们看看深度学习的现状,这样您就知道深度学习目前擅长的领域。

深度学习的现状

让我们首先考虑深度学习是否能够解决您要解决的问题。本节概述了 2020 年初深度学习的现状。然而,事情发展得非常快,当您阅读本文时,其中一些限制可能已经不存在。我们将尽力保持本书网站的最新信息;此外,搜索“AI 现在能做什么”可能会提供当前信息。

计算机视觉

深度学习尚未用于分析图像的许多领域,但在已经尝试过的领域中,几乎普遍表明计算机可以至少与人类一样好地识别图像中的物品,甚至是经过专门训练的人,如放射科医生。这被称为物体识别。深度学习还擅长识别图像中物体的位置,并可以突出它们的位置并命名每个找到的物体。这被称为物体检测(在我们在第一章中看到的变体中,每个像素根据其所属的对象类型进行分类—这被称为分割)。

深度学习算法通常不擅长识别结构或风格与用于训练模型的图像明显不同的图像。例如,如果训练数据中没有黑白图像,模型可能在黑白图像上表现不佳。同样,如果训练数据不包含手绘图像,模型可能在手绘图像上表现不佳。没有一般方法可以检查训练集中缺少哪些类型的图像,但我们将在本章中展示一些方法,以尝试识别当模型在生产中使用时数据中出现意外图像类型的情况(这被称为检查域外数据)。

物体检测系统面临的一个主要挑战是图像标记可能会很慢且昂贵。目前有很多工作正在进行中,旨在开发工具以尝试使这种标记更快速、更容易,并且需要更少的手工标签来训练准确的物体检测模型。一个特别有帮助的方法是合成生成输入图像的变化,例如通过旋转它们或改变它们的亮度和对比度;这被称为数据增强,并且对文本和其他类型的模型也很有效。我们将在本章中详细讨论这一点。

另一个要考虑的问题是,尽管您的问题可能看起来不像是一个计算机视觉问题,但通过一点想象力可能可以将其转变为一个。例如,如果您要分类的是声音,您可以尝试将声音转换为其声学波形的图像,然后在这些图像上训练模型。

自然语言处理

计算机擅长基于类别对短文档和长文档进行分类,例如垃圾邮件或非垃圾邮件、情感(例如,评论是积极的还是消极的)、作者、来源网站等。我们不知道在这个领域是否有任何严格的工作来比较计算机和人类,但从经验上看,我们认为深度学习的性能在这些任务上与人类的性能相似。

深度学习还擅长生成与上下文相关的文本,例如回复社交媒体帖子,并模仿特定作者的风格。它还擅长使这些内容对人类具有吸引力—事实上,甚至比人类生成的文本更具吸引力。然而,深度学习不擅长生成正确的回应!例如,我们没有可靠的方法来将医学信息知识库与深度学习模型结合起来,以生成医学上正确的自然语言回应。这是危险的,因为很容易创建对外行人看来具有吸引力但实际上完全不正确的内容。

另一个问题是,社交媒体上的上下文适当、高度引人入胜的回应可能被大规模使用——比以前见过的任何喷子农场规模大几千倍——来传播虚假信息,制造动荡,鼓励冲突。一般来说,文本生成模型总是在技术上略领先于识别自动生成文本的模型。例如,可以使用一个能够识别人工生成内容的模型来实际改进创建该内容的生成器,直到分类模型无法完成其任务为止。

尽管存在这些问题,深度学习在自然语言处理中有许多应用:可以用来将文本从一种语言翻译成另一种语言,将长篇文档总结为更快消化的内容,找到感兴趣概念的所有提及等。不幸的是,翻译或总结可能包含完全错误的信息!然而,性能已经足够好,许多人正在使用这些系统——例如,谷歌的在线翻译系统(以及我们所知道的每个其他在线服务)都是基于深度学习的。

结合文本和图像

深度学习将文本和图像结合成一个单一模型的能力通常比大多数人直觉期望的要好得多。例如,一个深度学习模型可以在输入图像上进行训练,输出用英语编写的标题,并且可以学会为新图像自动生成令人惊讶地适当的标题!但是,我们再次提出与前一节讨论的相同警告:不能保证这些标题是正确的。

由于这个严重问题,我们通常建议深度学习不要作为完全自动化的过程,而是作为模型和人类用户密切互动的过程的一部分。这可能使人类的生产力比完全手动方法高出几个数量级,并且比仅使用人类更准确。

例如,自动系统可以直接从 CT 扫描中识别潜在的中风患者,并发送高优先级警报,以便快速查看这些扫描。治疗中风只有三个小时的时间窗口,因此这种快速的反馈循环可以挽救生命。同时,所有扫描仍然可以按照通常的方式发送给放射科医生,因此不会减少人类的参与。其他深度学习模型可以自动测量扫描中看到的物品,并将这些测量结果插入报告中,警告放射科医生可能错过的发现,并告诉他们可能相关的其他病例。

表格数据

对于分析时间序列和表格数据,深度学习最近取得了巨大进展。然而,深度学习通常作为多种模型集成的一部分使用。如果您已经有一个正在使用随机森林或梯度提升机(流行的表格建模工具,您很快将了解)的系统,那么切换到或添加深度学习可能不会带来任何显著的改进。

深度学习确实大大增加了您可以包含的列的种类——例如,包含自然语言(书名、评论等)和高基数分类列(即包含大量离散选择的内容,如邮政编码或产品 ID)。不过,与随机森林或梯度提升机相比,深度学习模型通常需要更长的训练时间,尽管由于提供 GPU 加速的库(如RAPIDS),情况正在改变。我们在第九章中详细介绍了所有这些方法的优缺点。

推荐系统

推荐系统实际上只是一种特殊类型的表格数据。特别是,它们通常具有代表用户的高基数分类变量,以及代表产品(或类似物品)的另一个变量。像亚马逊这样的公司将客户所做的每一次购买都表示为一个巨大的稀疏矩阵,其中客户是行,产品是列。一旦他们以这种格式拥有数据,数据科学家们会应用某种形式的协同过滤来填充矩阵。例如,如果客户 A 购买产品 1 和 10,客户 B 购买产品 1、2、4 和 10,引擎将推荐 A 购买 2 和 4。

由于深度学习模型擅长处理高基数分类变量,它们非常擅长处理推荐系统。尤其是当将这些变量与其他类型的数据(如自然语言或图像)结合时,它们就像处理表格数据一样发挥作用。它们还可以很好地将所有这些类型的信息与其他元数据(如用户信息、先前交易等)表示为表格进行组合。

然而,几乎所有的机器学习方法都有一个缺点,那就是它们只告诉你一个特定用户可能喜欢哪些产品,而不是对用户有用的推荐。用户可能喜欢的产品的许多种推荐可能根本不会有任何帮助——例如,如果用户已经熟悉这些产品,或者如果它们只是用户已经购买过的产品的不同包装(例如,当他们已经拥有该套装中的每一件物品时,推荐一个小说的套装)。Jeremy 喜欢读特里·普拉切特的书,有一段时间亚马逊一直在向他推荐特里·普拉切特的书(见图 2-1),这实际上并不是有用的,因为他已经知道这些书了!

特里·普拉切特的书推荐

图 2-1. 一个不太有用的推荐

其他数据类型

通常,您会发现特定领域的数据类型非常适合现有的类别。例如,蛋白质链看起来很像自然语言文档,因为它们是由复杂关系和意义贯穿整个序列的离散令牌组成的长序列。事实上,使用 NLP 深度学习方法是许多类型蛋白质分析的最先进方法。另一个例子,声音可以表示为频谱图,可以被视为图像;标准的图像深度学习方法在频谱图上表现得非常好。

驱动系统方法

许多准确的模型对任何人都没有用,而许多不准确的模型却非常有用。为了确保您的建模工作在实践中有用,您需要考虑您的工作将如何使用。2012 年,Jeremy 与 Margit Zwemer 和 Mike Loukides 一起提出了一种称为驱动系统方法的思考这个问题的方法。

驱动系统方法,如图 2-2 所示,详细介绍在“设计出色的数据产品”中。基本思想是从考虑您的目标开始,然后考虑您可以采取哪些行动来实现该目标以及您拥有的(或可以获取的)可以帮助的数据,然后构建一个模型,您可以使用该模型确定为实现目标而采取的最佳行动。

图 2-2. 驱动系统方法

考虑自动驾驶汽车中的模型:您希望帮助汽车安全地从 A 点驾驶到 B 点,而无需人为干预。出色的预测建模是解决方案的重要组成部分,但它并不是独立存在的;随着产品变得更加复杂,它会消失在管道中。使用自动驾驶汽车的人完全不知道使其运行的数百(甚至数千)个模型和海量数据。但随着数据科学家构建越来越复杂的产品,他们需要一种系统化的设计方法。

我们使用数据不仅仅是为了生成更多数据(以预测的形式),而是为了产生可操作的结果。这是 Drivetrain 方法的目标。首先要明确定义一个明确的目标。例如,当谷歌创建其第一个搜索引擎时,考虑了“用户在输入搜索查询时的主要目标是什么?”这导致了谷歌的目标,即“显示最相关的搜索结果”。下一步是考虑您可以拉动的杠杆(即您可以采取的行动)以更好地实现该目标。在谷歌的情况下,这是搜索结果的排名。第三步是考虑他们需要什么新数据来生成这样的排名;他们意识到关于哪些页面链接到哪些其他页面的隐含信息可以用于此目的。

只有在完成了这前三个步骤之后,我们才开始考虑构建预测模型。我们的目标和可用的杠杆,我们已经拥有的数据以及我们需要收集的额外数据,决定了我们可以构建的模型。这些模型将以杠杆和任何不可控变量作为输入;模型的输出可以结合起来预测我们的目标的最终状态。

让我们考虑另一个例子:推荐系统。推荐引擎的目标是通过推荐客户不会在没有推荐的情况下购买的物品来推动额外的销售。杠杆是推荐的排名。必须收集新数据以生成将导致新销售的推荐。这将需要进行许多随机实验,以收集关于各种客户的各种推荐的数据。这是很少有组织采取的一步;但是没有它,您就没有所需的信息来根据您的真正目标(更多销售!)优化推荐。

最后,您可以为购买概率构建两个模型,条件是看到或没有看到推荐。这两个概率之间的差异是给定推荐给客户的效用函数。在算法推荐客户已经拒绝的熟悉书籍(两个组成部分都很小)或者他们本来就会购买的书籍(两个组成部分都很大并互相抵消)的情况下,效用函数会很低。

正如您所看到的,在实践中,您的模型的实际实施通常需要比仅仅训练一个模型更多!您通常需要运行实验来收集更多数据,并考虑如何将您的模型整合到您正在开发的整个系统中。说到数据,现在让我们专注于如何为您的项目找到数据。

收集数据

对于许多类型的项目,您可能能够在线找到所需的所有数据。本章中我们将完成的项目是一个“熊探测器”。它将区分三种类型的熊:灰熊、黑熊和泰迪熊。互联网上有许多每种类型熊的图片可供我们使用。我们只需要找到它们并下载它们。

我们提供了一个工具供您使用,这样您就可以跟随本章并为您感兴趣的任何对象创建自己的图像识别应用程序。在 fast.ai 课程中,成千上万的学生在课程论坛上展示了他们的作品,展示了从特立尼达的蜂鸟品种到巴拿马的公交车类型的一切——甚至有一名学生创建了一个应用程序,可以帮助他的未婚妻在圣诞假期期间认出他的 16 个表兄弟!

在撰写本文时,Bing 图像搜索是我们知道的用于查找和下载图像的最佳选择。每月免费提供最多 1,000 次查询,每次查询可下载最多 150 张图片。然而,在我们撰写本书时和您阅读本书时之间可能会出现更好的选择,因此请务必查看本书籍网站以获取我们当前的推荐。

与最新服务保持联系

用于创建数据集的服务时常变化,它们的功能、接口和定价也经常变化。在本节中,我们将展示如何在撰写本书时作为 Azure 认知服务一部分提供的Bing 图像搜索 API

要使用 Bing 图像搜索下载图像,请在 Microsoft 注册一个免费帐户。您将获得一个密钥,您可以将其复制并输入到一个单元格中(用您的密钥替换XXX并执行):

key = 'XXX'

或者,如果您在命令行上感到自在,您可以在终端中设置它

export AZURE_SEARCH_KEY=*your_key_here*

然后重新启动 Jupyter 服务器,在一个单元格中键入以下内容,并执行:

key = os.environ['AZURE_SEARCH_KEY']

设置了key之后,您可以使用search_images_bing。这个函数是在线笔记本中包含的小utils类提供的(如果您不确定一个函数是在哪里定义的,您可以在笔记本中输入它来找出,如下所示):

search_images_bing
<function utils.search_images_bing(key, term, min_sz=128)>

让我们尝试一下这个函数:

results = search_images_bing(key, 'grizzly bear')
ims = results.attrgot('content_url')
len(ims)
150

我们已成功下载了 150 只灰熊的 URL(或者至少是 Bing 图像搜索为该搜索词找到的图像)。让我们看一个:

dest = 'images/grizzly.jpg'
download_url(ims[0], dest)
im = Image.open(dest)
im.to_thumb(128,128)

这似乎运行得很好,所以让我们使用 fastai 的download_images来下载每个搜索词的所有 URL。我们将每个放在一个单独的文件夹中:

bear_types = 'grizzly','black','teddy'
path = Path('bears')
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'))

我们的文件夹中有图像文件,正如我们所期望的那样:

fns = get_image_files(path)
fns
(#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 笔记本中工作的这一点!逐步构建我想要的东西并在每一步检查我的工作是如此容易。我犯了很多错误,所以这对我真的很有帮助。

通常当我们从互联网下载文件时,会有一些文件损坏。让我们检查一下:

failed = verify_images(fns)
failed
(#0) []

要删除所有失败的图像,您可以使用unlink。像大多数返回集合的 fastai 函数一样,verify_images返回一个类型为L的对象,其中包括map方法。这会在集合的每个元素上调用传递的函数:

failed.map(Path.unlink);

在这个过程中要注意的一件事是:正如我们在第一章中讨论的,模型只能反映用于训练它们的数据。而世界充满了有偏见的数据,这最终会反映在,例如,Bing 图像搜索(我们用来创建数据集的)。例如,假设您有兴趣创建一个应用程序,可以帮助用户确定他们是否拥有健康的皮肤,因此您训练了一个模型,该模型基于搜索结果(比如)“健康皮肤”。图 2-3 展示了您将获得的结果类型。

图 2-3. 用于健康皮肤检测器的数据?

使用此作为训练数据,您最终不会得到一个健康皮肤检测器,而是一个年轻白人女性触摸她的脸检测器!一定要仔细考虑您可能在应用程序中实际看到的数据类型,并仔细检查以确保所有这些类型都反映在您模型的源数据中。(感谢 Deb Raji 提出了健康皮肤的例子。请查看她的论文“可操作的审计:调查公开命名商业 AI 产品偏见性能结果的影响”以获取更多有关模型偏见的迷人见解。)

现在我们已经下载了一些数据,我们需要将其组装成适合模型训练的格式。在 fastai 中,这意味着创建一个名为DataLoaders的对象。

从数据到数据加载器

DataLoaders是一个简单的类,只是存储您传递给它的DataLoader对象,并将它们作为trainvalid可用。尽管它是一个简单的类,但在 fastai 中非常重要:它为您的模型提供数据。DataLoaders中的关键功能仅用这四行代码提供(它还有一些其他次要功能我们暂时跳过):

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,尽管可以有任意数量。前两个作为属性提供。

在本书的后面,您还将了解DatasetDatasets类,它们具有相同的关系。要将我们下载的数据转换为DataLoaders对象,我们至少需要告诉 fastai 四件事:

  • 我们正在处理什么类型的数据

  • 如何获取项目列表

  • 如何为这些项目打标签

  • 如何创建验证集

到目前为止,我们已经看到了一些特定组合的工厂方法,当您有一个应用程序和数据结构恰好适合这些预定义方法时,这些方法非常方便。当您不适用时,fastai 有一个名为数据块 API的极其灵活的系统。使用此 API,您可以完全自定义创建DataLoaders的每个阶段。这是我们需要为刚刚下载的数据集创建DataLoaders的步骤:

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))

让我们依次查看每个参数。首先,我们提供一个元组,指定我们希望独立变量和因变量的类型:

blocks=(ImageBlock, CategoryBlock)

独立变量是我们用来进行预测的东西,因变量是我们的目标。在这种情况下,我们的独立变量是一组图像,我们的因变量是每个图像的类别(熊的类型)。在本书的其余部分中,我们将看到许多其他类型的块。

对于这个DataLoaders,我们的基础项目将是文件路径。我们必须告诉 fastai 如何获取这些文件的列表。get_image_files函数接受一个路径,并返回该路径中所有图像的列表(默认情况下递归):

get_items=get_image_files

通常,您下载的数据集已经定义了验证集。有时,这是通过将用于训练和验证集的图像放入不同的文件夹中来完成的。有时,这是通过提供一个 CSV 文件,在该文件中,每个文件名都与应该在其中的数据集一起列出。有许多可以完成此操作的方法,fastai 提供了一种通用方法,允许您使用其预定义类之一或编写自己的类。

在这种情况下,我们希望随机拆分我们的训练和验证集。但是,我们希望每次运行此笔记本时都具有相同的训练/验证拆分,因此我们固定随机种子(计算机实际上不知道如何创建随机数,而只是创建看起来随机的数字列表;如果您每次都为该列表提供相同的起始点——称为种子,那么您将每次都获得完全相同的列表)。

splitter=RandomSplitter(valid_pct=0.2, seed=42)

自变量通常被称为x,因变量通常被称为y。在这里,我们告诉 fastai 要调用哪个函数来创建数据集中的标签:

get_y=parent_label

parent_label是 fastai 提供的一个函数,它简单地获取文件所在文件夹的名称。因为我们将每个熊图像放入基于熊类型的文件夹中,这将为我们提供所需的标签。

我们的图像大小各不相同,这对深度学习是一个问题:我们不是一次向模型提供一个图像,而是多个图像(我们称之为mini-batch)。为了将它们分组到一个大数组(通常称为张量)中,以便通过我们的模型,它们都需要是相同的大小。因此,我们需要添加一个转换,将这些图像调整为相同的大小。Item transforms是在每个单独项目上运行的代码片段,无论是图像、类别还是其他。fastai 包含许多预定义的转换;我们在这里使用Resize转换,并指定大小为 128 像素:

item_tfms=Resize(128)

这个命令给了我们一个DataBlock对象。这就像创建DataLoaders模板。我们仍然需要告诉 fastai 我们数据的实际来源——在这种情况下,图像所在的路径:

dls = bears.dataloaders(path)

DataLoaders包括验证和训练DataLoaderDataLoader是一个类,它一次向 GPU 提供几个项目的批次。我们将在下一章中更多地了解这个类。当您循环遍历DataLoader时,fastai 会一次给您 64 个(默认值)项目,全部堆叠到一个单一张量中。我们可以通过在DataLoader上调用show_batch方法来查看其中一些项目:

dls.valid.show_batch(max_n=4, nrows=1)

默认情况下,Resize会将图像裁剪成适合请求大小的正方形形状,使用完整的宽度或高度。这可能会导致丢失一些重要细节。或者,您可以要求 fastai 用零(黑色)填充图像,或者压缩/拉伸它们:

bears = bears.new(item_tfms=Resize(128, ResizeMethod.Squish))
dls = bears.dataloaders(path)
dls.valid.show_batch(max_n=4, nrows=1)

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)

所有这些方法似乎都有些浪费或问题。如果我们压缩或拉伸图像,它们最终会变成不现实的形状,导致模型学习到事物看起来与实际情况不同,这会导致更低的准确性。如果我们裁剪图像,我们会移除一些允许我们进行识别的特征。例如,如果我们试图识别狗或猫的品种,我们可能会裁剪掉区分相似品种所需的身体或面部的关键部分。如果我们填充图像,就会有很多空白空间,这对我们的模型来说只是浪费计算,并导致我们实际使用的图像部分具有较低的有效分辨率。

相反,我们在实践中通常做的是随机选择图像的一部分,然后裁剪到该部分。在每个纪元(即数据集中所有图像的完整遍历),我们随机选择每个图像的不同部分。这意味着我们的模型可以学习关注和识别图像中的不同特征。这也反映了图像在现实世界中的工作方式:同一物体的不同照片可能以略有不同的方式构图。

事实上,一个完全未经训练的神经网络对图像的行为一无所知。它甚至不认识当一个物体旋转一度时,它仍然是同一物体的图片!因此,通过训练神经网络使用物体在略有不同位置并且大小略有不同的图像的示例,有助于它理解物体的基本概念,以及如何在图像中表示它。

这里是另一个示例,我们将Resize替换为RandomResizedCrop,这是提供刚才描述行为的转换。传递的最重要参数是min_scale,它确定每次选择图像的最小部分:

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)

在这里,我们使用了unique=True,以便将相同图像重复使用不同版本的RandomResizedCrop变换。

RandomResizedCrop是更一般的数据增强技术的一个具体示例。

数据增强

数据增强指的是创建输入数据的随机变化,使它们看起来不同但不改变数据的含义。对于图像的常见数据增强技术包括旋转、翻转、透视变形、亮度变化和对比度变化。对于我们在这里使用的自然照片图像,我们发现一组标准的增强技术与aug_transforms函数一起提供,效果非常好。

因为我们的图像现在都是相同大小,我们可以使用 GPU 将这些增强应用于整个批次的图像,这将节省大量时间。要告诉 fastai 我们要在批次上使用这些变换,我们使用batch_tfms参数(请注意,在此示例中我们没有使用RandomResizedCrop,这样您可以更清楚地看到差异;出于同样的原因,我们使用了默认值的两倍的增强量):

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)

现在我们已经将数据组装成适合模型训练的格式,让我们使用它来训练一个图像分类器。

训练您的模型,并使用它来清理您的数据

现在是时候使用与第一章中相同的代码行来训练我们的熊分类器了。对于我们的问题,我们没有太多的数据(每种熊最多 150 张图片),因此为了训练我们的模型,我们将使用RandomResizedCrop,图像大小为 224 像素,这对于图像分类来说是相当标准的,并且使用默认的aug_transforms

bears = bears.new(
    item_tfms=RandomResizedCrop(224, min_scale=0.5),
    batch_tfms=aug_transforms())
dls = bears.dataloaders(path)

现在我们可以按照通常的方式创建我们的Learner并进行微调:

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

现在让我们看看模型犯的错误主要是认为灰熊是泰迪熊(这对安全性来说是不好的!),还是认为灰熊是黑熊,或者其他情况。为了可视化这一点,我们可以创建一个混淆矩阵

interp = ClassificationInterpretation.from_learner(learn)
interp.plot_confusion_matrix()

行代表数据集中所有黑色、灰熊和泰迪熊,列分别代表模型预测为黑色、灰熊和泰迪熊的图像。因此,矩阵的对角线显示了被正确分类的图像,而非对角线的单元格代表被错误分类的图像。这是 fastai 允许您查看模型结果的许多方式之一。当然,这是使用验证集计算的。通过颜色编码,目标是在对角线以外的地方都是白色,而在对角线上我们希望是深蓝色。我们的熊分类器几乎没有犯错!

看到我们的错误发生在哪里是有帮助的,以便确定它们是由数据集问题(例如,根本不是熊的图像,或者标记错误)还是模型问题(也许它无法处理使用不同光照或从不同角度拍摄的图像等)。为了做到这一点,我们可以根据损失对图像进行排序。

损失是一个数字,如果模型不正确(尤其是如果它对其不正确的答案也很自信),或者如果它是正确的但对其正确答案不自信,那么损失就会更高。在第二部分的开头,我们将深入学习损失是如何计算和在训练过程中使用的。现在,plot_top_losses向我们展示了数据集中损失最高的图像。正如输出的标题所说,每个图像都标有四个内容:预测、实际(目标标签)、损失和概率。这里的概率是模型对其预测分配的置信水平,从零到一:

interp.plot_top_losses(5, nrows=1)

这个输出显示,损失最高的图像是一个被预测为“灰熊”的图像,且置信度很高。然而,根据我们的必应图像搜索,它被标记为“黑熊”。我们不是熊专家,但在我们看来,这个标签显然是错误的!我们可能应该将其标签更改为“灰熊”。

进行数据清洗的直观方法是在训练模型之前进行。但正如您在本例中所看到的,模型可以帮助您更快速、更轻松地找到数据问题。因此,我们通常更喜欢先训练一个快速简单的模型,然后使用它来帮助我们进行数据清洗。

fastai 包括一个方便的用于数据清洗的 GUI,名为ImageClassifierCleaner,它允许您选择一个类别和训练与验证集,并查看损失最高的图像(按顺序),以及菜单允许选择要删除或重新标记的图像:

cleaner = ImageClassifierCleaner(learn)
cleaner

清洁工具小部件

我们可以看到在我们的“黑熊”中有一张包含两只熊的图片:一只灰熊,一只黑熊。因此,我们应该在此图片下的菜单中选择<Delete>ImageClassifierCleaner不会为您删除或更改标签;它只会返回要更改的项目的索引。因此,例如,要删除(取消链接)所有选定要删除的图像,我们将运行以下命令:

for idx in cleaner.delete(): cleaner.fns[idx].unlink()

要移动我们选择了不同类别的图像,我们将运行以下命令:

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的文件:

learn.export()

让我们通过使用 fastai 添加到 Python 的Path类的ls方法来检查文件是否存在:

path = Path()
path.ls(file_exts='.pkl')
(#1) [Path('export.pkl')]

您需要这个文件在您部署应用程序的任何地方。现在,让我们尝试在我们的笔记本中创建一个简单的应用程序。

当我们使用模型进行预测而不是训练时,我们称之为推理。要从导出的文件创建我们的推理学习者,我们使用load_learner(在这种情况下,这并不是真正必要的,因为我们已经在笔记本中有一个工作的Learner;我们在这里这样做是为了让您看到整个过程的始终):

learn_inf = load_learner(path/'export.pkl')

在进行推理时,通常一次只为一个图像获取预测。要做到这一点,将文件名传递给predict

learn_inf.predict('images/grizzly.jpg')
('grizzly', tensor(1), tensor([9.0767e-06, 9.9999e-01, 1.5748e-07]))

这返回了三个东西:以与您最初提供的格式相同的预测类别(在本例中,这是一个字符串),预测类别的索引以及每个类别的概率。最后两个是基于DataLoadersvocab中类别的顺序;也就是说,所有可能类别的存储列表。在推理时,您可以将DataLoaders作为Learner的属性访问:

learn_inf.dls.vocab
(#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。我们将使用这种方法创建一个简单的图像分类器。首先,我们需要一个文件上传小部件:

btn_upload = widgets.FileUpload()
btn_upload

上传按钮

现在我们可以获取图像:

img = PILImage.create(btn_upload.data[-1])

表示图像的输出小部件

我们可以使用Output小部件来显示它:

out_pl = widgets.Output()
out_pl.clear_output()
with out_pl: display(img.to_thumb(128,128))
out_pl

表示图像的输出小部件

然后我们可以得到我们的预测:

pred,pred_idx,probs = learn_inf.predict(img)

并使用Label来显示它们:

lbl_pred = widgets.Label()
lbl_pred.value = f'Prediction: {pred}; Probability: {probs[pred_idx]:.04f}'
lbl_pred

预测:灰熊;概率:1.0000

我们需要一个按钮来进行分类。它看起来与上传按钮完全相同:

btn_run = widgets.Button(description='Classify')
btn_run

我们还需要一个点击事件处理程序;也就是说,当按下按钮时将调用的函数。我们可以简单地复制之前的代码行:

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:

VBox([widgets.Label('Select your bear!'),
      btn_upload, btn_run, out_pl, lbl_pred])

整个小部件

我们已经编写了所有必要的应用程序代码。下一步是将其转换为我们可以部署的内容。

将您的笔记本变成一个真正的应用程序

现在我们在这个 Jupyter 笔记本中已经让一切运转起来了,我们可以创建我们的应用程序。为此,请启动一个新的笔记本,并仅添加创建和显示所需小部件的代码,以及任何要显示的文本的 Markdown。查看书中存储库中的bear_classifier笔记本,看看我们创建的简单笔记本应用程序。

接下来,如果您尚未安装 Voilà,请将这些行复制到笔记本单元格中并执行:

!pip install voila
!jupyter serverextension enable voila --sys-prefix

!开头的单元格不包含 Python 代码,而是包含传递给您的 shell(bash,Windows PowerShell 等)的代码。如果您习惯使用命令行,我们将在本书中更详细地讨论这一点,您当然可以直接在终端中键入这两行(不带!前缀)。在这种情况下,第一行安装voila库和应用程序,第二行将其连接到您现有的 Jupyter 笔记本。

Voilà运行 Jupyter 笔记本,就像您现在使用的 Jupyter 笔记本服务器一样,但它还做了一件非常重要的事情:它删除了所有单元格输入,仅显示输出(包括 ipywidgets),以及您的 Markdown 单元格。因此,剩下的是一个 Web 应用程序!要将您的笔记本视为 Voilà Web 应用程序,请将浏览器 URL 中的“notebooks”一词替换为“voila/render”。您将看到与您的笔记本相同的内容,但没有任何代码单元格。

当然,您不需要使用 Voilà或 ipywidgets。您的模型只是一个可以调用的函数(pred,pred_idx,probs = learn.predict(img)),因此您可以将其与任何框架一起使用,托管在任何平台上。您可以将在 ipywidgets 和 Voilà中原型设计的内容稍后转换为常规 Web 应用程序。我们在本书中展示这种方法,因为我们认为这是数据科学家和其他不是 Web 开发专家的人从其模型创建应用程序的绝佳方式。

我们有了我们的应用程序;现在让我们部署它!

部署您的应用程序

正如您现在所知,几乎任何有用的深度学习模型都需要 GPU 来训练。那么,在生产中使用该模型需要 GPU 吗?不需要!您几乎可以肯定在生产中不需要 GPU 来提供您的模型。这样做有几个原因:

  • 正如我们所见,GPU 仅在并行执行大量相同工作时才有用。如果您正在进行(比如)图像分类,通常一次只会对一个用户的图像进行分类,而且通常在一张图像中没有足够的工作量可以让 GPU 忙碌足够长的时间以使其非常有效。因此,CPU 通常更具成本效益。

  • 另一种选择可能是等待一些用户提交他们的图像,然后将它们批量处理并一次性在 GPU 上处理。但是这样会让用户等待,而不是立即得到答案!而且您需要一个高流量的网站才能实现这一点。如果您确实需要这种功能,您可以使用诸如 Microsoft 的ONNX RuntimeAWS SageMaker之类的工具。

  • 处理 GPU 推理的复杂性很大。特别是,GPU 的内存需要仔细手动管理,您需要一个仔细的排队系统,以确保一次只处理一个批次。

  • CPU 服务器的市场竞争要比 GPU 服务器更激烈,因此 CPU 服务器有更便宜的选项可供选择。

由于 GPU 服务的复杂性,许多系统已经出现尝试自动化此过程。然而,管理和运行这些系统也很复杂,通常需要将您的模型编译成专门针对该系统的不同形式。通常最好避免处理这种复杂性,直到/除非您的应用程序变得足够受欢迎,以至于您有明显的财务理由这样做。

至少对于您的应用程序的初始原型以及您想展示的任何爱好项目,您可以轻松免费托管它们。最佳位置和最佳方式随时间而变化,因此请查看本书网站以获取最新的建议。由于我们在 2020 年初撰写本书,最简单(且免费!)的方法是使用Binder。要在 Binder 上发布您的 Web 应用程序,请按照以下步骤操作:

  1. 将您的笔记本添加到GitHub 存储库

  2. 将该存储库的 URL 粘贴到 Binder 的 URL 字段中,如图 2-4 所示。

  3. 将文件下拉菜单更改为选择 URL。

  4. 在“要打开的 URL”字段中,输入/voila/render/*name*.ipynb(将name替换为您笔记本的名称)。

  5. 单击右下角的剪贴板按钮以复制 URL,并将其粘贴到安全位置。

  6. 单击“启动”。

部署到 Binder

图 2-4. 部署到 Binder

第一次执行此操作时,Binder 将花费大约 5 分钟来构建您的站点。在幕后,它正在查找一个可以运行您的应用程序的虚拟机,分配存储空间,并收集所需的文件以用于 Jupyter、您的笔记本以及将您的笔记本呈现为 Web 应用程序。

最后,一旦启动应用程序运行,它将导航您的浏览器到您的新 Web 应用程序。您可以分享您复制的 URL 以允许其他人访问您的应用程序。

要了解部署 Web 应用程序的其他(免费和付费)选项,请务必查看书籍网站

您可能希望将应用程序部署到移动设备或边缘设备,如树莓派。有许多库和框架允许您将模型直接集成到移动应用程序中。但是,这些方法往往需要许多额外的步骤和样板文件,并且并不总是支持您的模型可能使用的所有 PyTorch 和 fastai 层。此外,您所做的工作将取决于您针对部署的移动设备的类型 - 您可能需要做一些工作以在 iOS 设备上运行,不同的工作以在较新的 Android 设备上运行,不同的工作以在较旧的 Android 设备上运行,等等。相反,我们建议在可能的情况下,将模型本身部署到服务器,并让您的移动或边缘应用程序连接到它作为 Web 服务。

这种方法有很多优点。初始安装更容易,因为您只需部署一个小型 GUI 应用程序,该应用程序连接到服务器执行所有繁重的工作。更重要的是,核心逻辑的升级可以在您的服务器上进行,而不需要分发给所有用户。您的服务器将拥有比大多数边缘设备更多的内存和处理能力,并且如果您的模型变得更加苛刻,那么扩展这些资源将更容易。您在服务器上拥有的硬件也将更加标准化,并且更容易受到 fastai 和 PyTorch 的支持,因此您不必将模型编译成不同的形式。

当然也有缺点。你的应用程序将需要网络连接,每次调用模型时都会有一些延迟。(神经网络模型本来就需要一段时间来运行,所以这种额外的网络延迟在实践中可能对用户没有太大影响。事实上,由于你可以在服务器上使用更好的硬件,总体延迟甚至可能比在本地运行时更少!)此外,如果你的应用程序使用敏感数据,你的用户可能会担心采用将数据发送到远程服务器的方法,因此有时隐私考虑将意味着你需要在边缘设备上运行模型(通过在公司防火墙内部设置本地服务器可能可以避免这种情况)。管理复杂性和扩展服务器也可能会带来额外的开销,而如果你的模型在边缘设备上运行,每个用户都会带来自己的计算资源,这将导致随着用户数量的增加更容易扩展(也称为水平扩展)。

Alexis 说

我有机会近距离看到移动机器学习领域在我的工作中是如何变化的。我们提供一个依赖于计算机视觉的 iPhone 应用程序,多年来我们在云中运行我们自己的计算机视觉模型。那时这是唯一的方法,因为那些模型需要大量的内存和计算资源,并且需要几分钟来处理输入。这种方法不仅需要构建模型(有趣!),还需要构建基础设施来确保一定数量的“计算工作机器”始终在运行(可怕),如果流量增加,更多的机器会自动上线,有稳定的存储用于大型输入和输出,iOS 应用程序可以知道并告诉用户他们的工作进展如何等等。如今,苹果提供了 API,可以将模型转换为在设备上高效运行,大多数 iOS 设备都有专用的 ML 硬件,所以这是我们用于新模型的策略。这仍然不容易,但在我们的情况下,为了更快的用户体验和更少地担心服务器,这是值得的。对你来说有效的方法将取决于你试图创建的用户体验以及你个人认为容易做的事情。如果你真的知道如何运行服务器,那就去做。如果你真的知道如何构建本地移动应用程序,那就去做。有很多条路通往山顶。

总的来说,我们建议在可能的情况下尽可能使用简单的基于 CPU 的服务器方法,只要你能够做到。如果你足够幸运拥有一个非常成功的应用程序,那么你将能够在那个时候为更复杂的部署方法进行投资。

恭喜你——你已经成功构建了一个深度学习模型并部署了它!现在是一个很好的时机停下来思考可能出现的问题。

如何避免灾难

在实践中,一个深度学习模型只是一个更大系统中的一部分。正如我们在本章开头讨论的那样,构建数据产品需要考虑整个端到端的过程,从概念到在生产中使用。在这本书中,我们无法希望涵盖所有管理部署数据产品的复杂性,比如管理多个模型版本,A/B 测试,金丝雀发布,刷新数据(我们应该一直增加和增加我们的数据集,还是应该定期删除一些旧数据?),处理数据标记,监控所有这些,检测模型腐烂等等。

在本节中,我们将概述一些需要考虑的最重要问题;关于部署问题的更详细讨论,我们建议您参考 Emmanuel Ameisin(O'Reilly)的优秀著作《构建机器学习驱动的应用程序》。

需要考虑的最大问题之一是,理解和测试深度学习模型的行为比大多数其他代码更困难。在正常软件开发中,您可以分析软件所采取的确切步骤,并仔细研究这些步骤中哪些与您试图创建的期望行为相匹配。但是,对于神经网络,行为是从模型尝试匹配训练数据中产生的,而不是精确定义的。

这可能导致灾难!例如,假设我们真的正在推出一个熊检测系统,将连接到国家公园露营地周围的视频摄像头,并警告露营者有熊靠近。如果我们使用下载的数据集训练的模型,实际上会出现各种问题,比如:

  • 处理视频数据而不是图像

  • 处理可能不在数据集中出现的夜间图像

  • 处理低分辨率摄像头图像

  • 确保结果返回得足够快以在实践中有用

  • 在照片中很少见到的位置识别熊(例如从背后,部分被灌木覆盖,或者离摄像机很远)

问题的一个重要部分是,人们最有可能上传到互联网的照片是那些能够清晰艺术地展示主题的照片,而这并不是该系统将获得的输入类型。因此,我们可能需要进行大量自己的数据收集和标记以创建一个有用的系统。

这只是更一般的“域外”数据问题的一个例子。也就是说,在生产中,我们的模型可能看到与训练时非常不同的数据。这个问题没有完全的技术解决方案;相反,我们必须谨慎地推出技术。

我们还需要小心的其他原因。一个非常常见的问题是域漂移,即我们的模型看到的数据类型随着时间的推移而发生变化。例如,一个保险公司可能将深度学习模型用作其定价和风险算法的一部分,但随着时间的推移,公司吸引的客户类型和代表的风险类型可能发生如此大的变化,以至于原始训练数据不再相关。

域外数据和域漂移是更大问题的例子:您永远无法完全理解神经网络的所有可能行为,因为它们有太多参数。这是它们最好特性的自然缺点——它们的灵活性,使它们能够解决我们甚至可能无法完全指定首选解决方案的复杂问题。然而,好消息是,有办法通过一个经过深思熟虑的过程来减轻这些风险。这些细节将根据您正在解决的问题的细节而变化,但我们将尝试提出一个高层次的方法,总结在图 2-5 中,我们希望这将提供有用的指导。

部署过程

图 2-5. 部署过程

在可能的情况下,第一步是使用完全手动的过程,您的深度学习模型方法并行运行,但不直接用于驱动任何操作。参与手动过程的人员应查看深度学习输出,并检查其是否合理。例如,对于我们的熊分类器,公园管理员可以在屏幕上显示所有摄像头的视频源,任何可能的熊目击都会被简单地用红色突出显示。在部署模型之前,公园管理员仍然应该像以前一样警惕;模型只是在这一点上帮助检查问题。

第二步是尝试限制模型的范围,并由人仔细监督。例如,对模型驱动方法进行小范围地理和时间限制的试验。与其在全国各地的每个国家公园推出我们的熊分类器,我们可以选择一个单一的观测站,在一个星期的时间内,让一名公园管理员在每次警报发出之前检查。

然后,逐渐扩大您的推出范围。在这样做时,请确保您有非常好的报告系统,以确保您了解与您的手动流程相比所采取的行动是否发生了重大变化。例如,如果在某个地点推出新系统后,熊警报数量翻倍或减半,您应该非常关注。尝试考虑系统可能出错的所有方式,然后考虑什么措施、报告或图片可以反映出这个问题,并确保您的定期报告包含这些信息。

杰里米说

20 年前,我创办了一家名为 Optimal Decisions 的公司,利用机器学习和优化帮助巨大的保险公司设定价格,影响数千亿美元的风险。我们使用这里描述的方法来管理可能出错的潜在风险。此外,在与客户合作将任何东西投入生产之前,我们尝试通过在他们去年的数据上测试端到端系统的影响来模拟影响。将这些新算法投入生产总是一个非常紧张的过程,但每次推出都取得了成功。

意想不到的后果和反馈循环

推出模型的最大挑战之一是,您的模型可能会改变其所属系统的行为。例如,考虑一个“预测执法”算法,它预测某些社区的犯罪率更高,导致更多警察被派往这些社区,这可能导致这些社区记录更多犯罪,依此类推。在皇家统计学会的论文“预测和服务?”中,Kristian Lum 和 William Isaac 观察到“预测性执法的命名恰如其分:它预测未来的执法,而不是未来的犯罪。”

在这种情况下的部分问题是,在存在偏见的情况下(我们将在下一章中深入讨论),反馈循环可能导致该偏见的负面影响变得越来越严重。例如,在美国已经存在着在种族基础上逮捕率存在显著偏见的担忧。根据美国公民自由联盟的说法,“尽管使用率大致相等,黑人因大麻被逮捕的可能性是白人的 3.73 倍。”这种偏见的影响,以及在美国许多地区推出预测性执法算法,导致 Bärí Williams 在纽约时报中写道:“在我的职业生涯中引起如此多兴奋的技术正在以可能意味着在未来几年,我的 7 岁儿子更有可能因为他的种族和我们居住的地方而被无故定性或逮捕,甚至更糟。”

在推出重要的机器学习系统之前,一个有用的练习是考虑这个问题:“如果它真的很成功会发生什么?”换句话说,如果预测能力非常高,对行为的影响非常显著,那么会发生什么?谁会受到最大影响?最极端的结果可能是什么样的?你怎么知道到底发生了什么?

这样的思考练习可能会帮助你制定一个更加谨慎的推出计划,配备持续监控系统和人类监督。当然,如果人类监督没有被听取,那么它就没有用,因此确保可靠和有弹性的沟通渠道存在,以便正确的人会意识到问题并有权力解决它们。

开始写作吧!

我们的学生发现最有帮助巩固对这一材料的理解的事情之一是把它写下来。尝试教给别人是对你对一个主题的理解的最好测试。即使你从不向任何人展示你的写作,这也是有帮助的,但如果你分享了,那就更好了!因此,我们建议,如果你还没有开始写博客,那么现在就开始吧。现在你已经完成了这一章并学会了如何训练和部署模型,你已经可以写下你的第一篇关于深度学习之旅的博客文章了。你有什么惊讶?你在你的领域看到了深度学习的机会?你看到了什么障碍?

fast.ai 的联合创始人 Rachel Thomas 在文章“为什么你(是的,你)应该写博客”中写道:

我会给年轻的自己的最重要建议是尽早开始写博客。以下是一些写博客的理由:

  • 这就像一份简历,只不过更好。我知道有几个人因为写博客文章而得到了工作机会!
  • 帮助你学习。组织知识总是帮助我整合自己的想法。是否理解某事的一个测试是你是否能够向别人解释它。博客文章是一个很好的方式。
  • 我通过我的博客文章收到了参加会议的邀请和演讲邀请。我因为写了一篇关于我不喜欢 TensorFlow 的博客文章而被邀请参加 TensorFlow Dev Summit(太棒了!)。
  • 结识新朋友。我认识了几个回复我写的博客文章的人。
  • 节省时间。每当你通过电子邮件多次回答同一个问题时,你应该把它变成一篇博客文章,这样下次有人问起时你就更容易分享了。

也许她最重要的建议是:

你最适合帮助比你落后一步的人。这些材料仍然新鲜在你的脑海中。许多专家已经忘记了作为初学者(或中级学习者)时的感受,忘记了当你第一次听到这个话题时为什么难以理解。你特定背景、风格和知识水平的背景将为你所写的内容带来不同的视角。

我们已经提供了如何在附录 A 中设置博客的详细信息。如果你还没有博客,现在就看看吧,因为我们有一个非常好的方法让你免费开始写博客,没有广告,甚至可以使用 Jupyter Notebook!

问卷调查

  1. 文本模型目前存在哪些主要不足之处?

  2. 文本生成模型可能存在哪些负面社会影响?

  3. 在模型可能犯错且这些错误可能有害的情况下,自动化流程的一个好的替代方案是什么?

  4. 深度学习在哪种表格数据上特别擅长?

  5. 直接使用深度学习模型进行推荐系统的一个主要缺点是什么?

  6. 驱动器方法的步骤是什么?

  7. 驱动器方法的步骤如何映射到推荐系统?

  8. 使用你策划的数据创建一个图像识别模型,并将其部署在网络上。

  9. DataLoaders是什么?

  10. 我们需要告诉 fastai 创建DataLoaders的四件事是什么?

  11. DataBlock中的splitter参数是做什么的?

  12. 我们如何确保随机分割总是给出相同的验证集?

  13. 哪些字母通常用来表示自变量和因变量?

  14. 裁剪、填充和压缩调整方法之间有什么区别?在什么情况下你会选择其中之一?

  15. 什么是数据增强?为什么需要它?

  16. 提供一个例子,说明熊分类模型在生产中可能因训练数据的结构或风格差异而效果不佳。

  17. item_tfmsbatch_tfms之间有什么区别?

  18. 混淆矩阵是什么?

  19. export保存了什么?

  20. 当我们使用模型进行预测而不是训练时,这被称为什么?

  21. IPython 小部件是什么?

  22. 什么时候会使用 CPU 进行部署?什么时候 GPU 可能更好?

  23. 将应用部署到服务器而不是客户端(或边缘)设备(如手机或 PC)的缺点是什么?

  24. 在实践中推出熊警告系统时可能出现的三个问题的例子是什么?

  25. 什么是域外数据?

  26. 什么是领域转移?

  27. 部署过程中的三个步骤是什么?

进一步研究

  1. 考虑一下驱动器方法如何映射到你感兴趣的项目或问题。

  2. 在什么情况下最好避免某些类型的数据增强?

  3. 对于你有兴趣应用深度学习的项目,考虑一下这个思维实验,“如果它进展得非常顺利会发生什么?”

  4. 开始写博客,撰写你的第一篇博客文章。例如,写一下你认为深度学习在你感兴趣的领域可能有用的地方。

第三章:数据伦理

原文:www.bookstack.cn/read/th-fastai-book/9bc6d15b4440b85d.md

译者:飞龙

协议:CC BY-NC-SA 4.0

正如我们在第一章和第二章中讨论的,有时机器学习模型可能出错。它们可能有错误。它们可能被呈现出以前没有见过的数据,并以我们意料之外的方式行事。或者它们可能完全按设计工作,但被用于我们非常希望它们永远不要被用于的事情。

因为深度学习是如此强大的工具,可以用于很多事情,所以我们特别需要考虑我们选择的后果。哲学上对伦理的研究是对对错的研究,包括我们如何定义这些术语,识别对错行为,以及理解行为和后果之间的联系。数据伦理领域已经存在很长时间,许多学者都专注于这个领域。它被用来帮助定义许多司法管辖区的政策;它被用在大大小小的公司中,考虑如何最好地确保产品开发对社会的良好结果;它被研究人员用来确保他们正在做的工作被用于好的目的,而不是坏的目的。

因此,作为一个深度学习从业者,你很可能在某个时候会面临需要考虑数据伦理的情况。那么数据伦理是什么?它是伦理学的一个子领域,所以让我们从那里开始。

杰里米说

在大学里,伦理哲学是我的主要研究领域(如果我完成了论文,而不是辍学加入现实世界,它本来会是我的论文题目)。根据我花在研究伦理学上的年份,我可以告诉你这个:没有人真正同意什么是对什么是错,它们是否存在,如何识别它们,哪些人是好人哪些人是坏人,或者几乎任何其他事情。所以不要对理论抱太大期望!我们将在这里专注于例子和思考的起点,而不是理论。

在回答问题“什么是伦理?” 应用伦理马库拉中心说,这个术语指的是以下内容:

  • 有根据的对人类应该做什么的正确和错误的标准

  • 研究和发展自己的伦理标准

没有正确答案的清单。没有应该和不应该做的清单。伦理是复杂的,依赖于背景。它涉及许多利益相关者的观点。伦理是一个你必须发展和实践的能力。在本章中,我们的目标是提供一些路标,帮助你在这个旅程中前进。

发现伦理问题最好是作为一个协作团队的一部分来做。这是你真正可以融入不同观点的唯一方式。不同人的背景将帮助他们看到你可能没有注意到的事情。与团队合作对于许多“锻炼肌肉”的活动都是有帮助的,包括这个。

这一章当然不是本书中唯一讨论数据伦理的部分,但是有一个地方专注于它一段时间是很好的。为了定位,也许最容易看一些例子。所以,我们挑选了三个我们认为有效地说明了一些关键主题的例子。

数据伦理的关键例子

我们将从三个具体的例子开始,这些例子说明了技术中三个常见的伦理问题(我们将在本章后面更深入地研究这些问题):

救济程序

阿肯色州有缺陷的医疗保健算法让患者陷入困境。

反馈循环

YouTube 的推荐系统帮助引发了阴谋论繁荣。

偏见

当在谷歌上搜索传统的非裔美国人名字时,会显示犯罪背景调查的广告。

事实上,在本章中我们介绍的每个概念,我们都会提供至少一个具体的例子。对于每一个例子,想想在这种情况下你可以做什么,以及可能会有什么样的障碍阻止你完成。你会如何处理它们?你会注意什么?

错误和救济:用于医疗福利的错误算法

《The Verge》调查了在美国半数以上州使用的软件,以确定人们接受多少医疗保健,并在文章《当算法削减您的医疗保健时会发生什么》中记录了其发现。在阿肯色州实施算法后,数百人(许多患有严重残疾的人)的医疗保健被大幅削减。

例如,Tammy Dobbs 是一名患有脑瘫的女性,需要助手帮助她起床、上厕所、拿食物等,她的帮助时间突然减少了 20 小时每周。她无法得到任何解释为什么她的医疗保健被削减。最终,一场法庭案件揭示了算法的软件实施中存在错误,对患有糖尿病或脑瘫的人造成了负面影响。然而,Dobbs 和许多其他依赖这些医疗福利的人生活在恐惧中,担心他们的福利可能再次突然而莫名其妙地被削减。

反馈循环:YouTube 的推荐系统

当您的模型控制您获得的下一轮数据时,反馈循环可能会发生。返回的数据很快就会被软件本身破坏。

例如,YouTube 有 19 亿用户,他们每天观看超过 10 亿小时的 YouTube 视频。其推荐算法(由谷歌构建)旨在优化观看时间,负责约 70%的观看内容。但出现了问题:它导致了失控的反馈循环,导致《纽约时报》在 2019 年 2 月发表了标题为《YouTube 引发了阴谋论繁荣。能够控制吗?》的文章。表面上,推荐系统正在预测人们会喜欢什么内容,但它们也在很大程度上决定了人们甚至看到什么内容。

偏见:拉塔尼亚·斯威尼“被捕”

拉塔尼亚·斯威尼博士是哈佛大学的教授,也是该大学数据隐私实验室的主任。在论文《在线广告投放中的歧视》中,她描述了她发现谷歌搜索她的名字会出现“拉塔尼亚·斯威尼,被捕了?”的广告,尽管她是唯一已知的拉塔尼亚·斯威尼,从未被捕。然而,当她搜索其他名字,如“Kirsten Lindquist”时,她得到了更中立的广告,尽管 Kirsten Lindquist 已经被捕了三次。

谷歌搜索显示关于拉塔尼亚·斯威尼(不存在的)被捕记录的广告

图 3-1。谷歌搜索显示关于拉塔尼亚·斯威尼(不存在的)被捕记录的广告

作为一名计算机科学家,她系统地研究了这个问题,并查看了 2000 多个名字。她发现了一个明显的模式:历史上黑人的名字会收到暗示这个人有犯罪记录的广告,而传统上的白人名字则会有更中立的广告。

这是偏见的一个例子。它可能对人们的生活产生重大影响,例如,如果一个求职者被谷歌搜索,可能会出现他们有犯罪记录的情况,而实际上并非如此。

这为什么重要?

考虑这些问题的一个非常自然的反应是:“那又怎样?这和我有什么关系?我是一名数据科学家,不是政治家。我不是公司的高级执行官之一,他们决定我们要做什么。我只是尽力构建我能构建的最具预测性的模型。”

这些是非常合理的问题。但我们将试图说服您,答案是每个训练模型的人都绝对需要考虑他们的模型将如何被使用,并考虑如何最好地确保它们被尽可能积极地使用。有一些你可以做的事情。如果你不这样做,事情可能会变得相当糟糕。

当技术人员以任何代价专注于技术时,发生的一个特别可怕的例子是 IBM 与纳粹德国的故事。2001 年,一名瑞士法官裁定认为“推断 IBM 的技术援助促进了纳粹在犯下反人类罪行时的任务,这些行为还涉及 IBM 机器进行的会计和分类,并在集中营中使用。”

你看,IBM 向纳粹提供了数据制表产品,以追踪大规模灭绝犹太人和其他群体。这是公司高层的决定,向希特勒及其领导团队推销。公司总裁托马斯·沃森亲自批准了 1939 年发布特殊的 IBM 字母排序机,以帮助组织波兰犹太人的驱逐。在图 3-2 中,阿道夫·希特勒(最左)与 IBM 首席执行官汤姆·沃森(左二)会面,希特勒在 1937 年授予沃森特别的“对帝国的服务”奖章。

IBM 首席执行官汤姆·沃森与阿道夫·希特勒会面的图片

图 3-2. IBM 首席执行官汤姆·沃森与阿道夫·希特勒会面

但这并不是个案 - 该组织的涉入是广泛的。IBM 及其子公司在集中营现场提供定期培训和维护:打印卡片,配置机器,并在它们经常出现故障时进行维修。IBM 在其打孔卡系统上设置了每个人被杀害的方式,他们被分配到的组别以及跟踪他们通过庞大的大屠杀系统所需的后勤信息的分类。IBM 在集中营中对犹太人的代码是 8:约有 600 万人被杀害。对于罗姆人的代码是 12(纳粹将他们标记为“不合群者”,在“吉普赛营”中有超过 30 万人被杀害)。一般处决被编码为 4,毒气室中的死亡被编码为 6。

IBM 在集中营中使用的打孔卡的图片

图 3-3. IBM 在集中营中使用的打孔卡

当然,参与其中的项目经理、工程师和技术人员只是过着普通的生活。照顾家人,周日去教堂,尽力做好自己的工作。服从命令。市场营销人员只是尽力实现他们的业务发展目标。正如《IBM 与大屠杀》(Dialog Press)的作者埃德温·布莱克所观察到的:“对于盲目的技术官僚来说,手段比目的更重要。犹太人民的毁灭变得更不重要,因为 IBM 技术成就的振奋性只会因在面包排长队的时候赚取的奇幻利润而更加突出。”

退一步思考一下:如果你发现自己是一个最终伤害社会的系统的一部分,你会有什么感受?你会愿意了解吗?你如何帮助确保这种情况不会发生?我们在这里描述了最极端的情况,但今天观察到与人工智能和机器学习相关的许多负面社会后果,其中一些我们将在本章中描述。

这也不仅仅是道德负担。有时,技术人员会直接为他们的行为付出代价。例如,作为大众汽车丑闻的结果而被监禁的第一个人并不是监督该项目的经理,也不是公司的执行主管。而是其中一名工程师詹姆斯·梁,他只是听从命令。

当然,情况并非全是坏的 - 如果你参与的项目最终对一个人产生了巨大的积极影响,这会让你感到非常棒!

好的,希望我们已经说服您应该关心这个问题。但是您应该怎么做呢?作为数据科学家,我们自然倾向于通过优化某些指标来改进我们的模型。但是优化这个指标可能不会导致更好的结果。即使它确实有助于创造更好的结果,几乎肯定不会是唯一重要的事情。考虑一下从研究人员或从业者开发模型或算法到使用这项工作做出决策之间发生的步骤流程。如果我们希望获得我们想要的结果,整个流程必须被视为一个整体。

通常,从一端到另一端有一条非常长的链。如果您是一名研究人员,甚至可能不知道您的研究是否会被用于任何事情,或者如果您参与数据收集,那就更早了。但是没有人比您更适合告知所有参与这一链的人您的工作的能力、约束和细节。虽然没有“灵丹妙药”可以确保您的工作被正确使用,但通过参与这个过程,并提出正确的问题,您至少可以确保正确的问题正在被考虑。

有时,对于被要求做一项工作的正确回应就是说“不”。然而,我们经常听到的回应是:“如果我不做,别人会做。”但请考虑:如果您被选中做这项工作,那么您是他们找到的最合适的人——所以如果您不做,最合适的人就不会参与该项目。如果他们询问的前五个人也都说不,那就更好了!

将机器学习与产品设计整合

假设您做这项工作的原因是希望它被用于某些目的。否则,您只是在浪费时间。因此,让我们假设您的工作最终会有所作为。现在,当您收集数据并开发模型时,您会做出许多决定。您将以什么级别的聚合存储数据?应该使用什么损失函数?应该使用什么验证和训练集?您应该专注于实现的简单性、推理的速度还是模型的准确性?您的模型如何处理域外数据项?它可以进行微调,还是必须随时间从头开始重新训练?

这些不仅仅是算法问题。它们是数据产品设计问题。但是产品经理、高管、法官、记者、医生——最终会开发和使用您的模型的系统的人——将无法理解您所做的决定,更不用说改变它们了。

例如,两项研究发现亚马逊的面部识别软件产生了不准确种族偏见的结果。亚马逊声称研究人员应该更改默认参数,但没有解释这将如何改变有偏见的结果。此外,事实证明,亚马逊并没有指导使用其软件的警察部门这样做。可以想象,开发这些算法的研究人员和为警察提供指导的亚马逊文档人员之间存在很大的距离。

缺乏紧密整合导致社会、警察和亚马逊出现严重问题。结果表明,其系统错误地将 28 名国会议员与犯罪照片匹配!(而与犯罪照片错误匹配的国会议员是有色人种,如图 3-4 所示。)

国会议员与亚马逊软件匹配的犯罪照片,他们是有色人种

图 3-4. 亚马逊软件将国会议员与犯罪照片匹配

数据科学家需要成为跨学科团队的一部分。研究人员需要与最终使用他们研究成果的人密切合作。更好的是,领域专家们自己可以学到足够的知识,以便能够自己训练和调试一些模型——希望你们中的一些人正在阅读这本书!

现代职场是一个非常专业化的地方。每个人都倾向于有明确定义的工作要做。特别是在大公司,很难知道所有的细节。有时公司甚至会故意模糊正在进行的整体项目目标,如果他们知道员工不会喜欢答案的话。有时通过尽可能地将部分隔离来实现这一点。

换句话说,我们并不是说这些都很容易。这很难。真的很难。我们都必须尽力而为。我们经常看到那些参与这些项目更高层次背景的人,试图发展跨学科能力和团队的人,成为他们组织中最重要和最受奖励的成员之一。这是一种工作,往往受到高级主管的高度赞赏,即使有时被中层管理人员认为相当不舒服。

数据伦理学主题

数据伦理学是一个广阔的领域,我们无法涵盖所有内容。相反,我们将选择一些我们认为特别相关的主题:

  • 追索和问责制的需求

  • 反馈循环

  • 偏见

  • 虚假信息

让我们依次看看每一个。

追索和问责制

在一个复杂的系统中,很容易没有任何一个人感到对结果负责。虽然这是可以理解的,但这并不会带来好的结果。在早期的阿肯色州医疗保健系统的例子中,一个错误导致患有脑瘫的人失去了所需护理的访问权限,算法的创建者责怪政府官员,政府官员责怪那些实施软件的人。纽约大学教授丹娜·博伊德描述了这种现象:“官僚主义经常被用来转移或逃避责任……今天的算法系统正在扩展官僚主义。”

追索如此必要的另一个原因是数据经常包含错误。审计和纠错机制至关重要。加利福尼亚执法官员维护的一个涉嫌帮派成员的数据库发现充满了错误,包括 42 名不到 1 岁的婴儿被添加到数据库中(其中 28 名被标记为“承认是帮派成员”)。在这种情况下,没有流程来纠正错误或在添加后删除人员。另一个例子是美国信用报告系统:2012 年联邦贸易委员会(FTC)对信用报告进行的大规模研究发现,26%的消费者的档案中至少有一个错误,5%的错误可能是灾难性的。

然而,纠正这类错误的过程非常缓慢和不透明。当公共广播记者鲍比·艾伦发现自己被错误列为有枪支罪时,他花了“十几个电话,一个县法院书记的手工操作和六周的时间来解决问题。而且这还是在我作为一名记者联系了公司的传播部门之后。”

作为机器学习从业者,我们并不总是认为理解我们的算法最终如何在实践中实施是我们的责任。但我们需要。

反馈循环

我们在第一章中解释了算法如何与环境互动以创建反馈循环,做出预测以加强在现实世界中采取的行动,从而导致更加明显朝着同一方向的预测。举个例子,让我们再次考虑 YouTube 的推荐系统。几年前,谷歌团队谈到他们如何引入了强化学习(与深度学习密切相关,但你的损失函数代表了潜在长时间后行动发生的结果)来改进 YouTube 的推荐系统。他们描述了如何使用一个算法,使推荐以优化观看时间为目标。

然而,人类往往被争议性内容所吸引。这意味着关于阴谋论之类的视频开始越来越多地被推荐给用户。此外,事实证明,对阴谋论感兴趣的人也是那些经常观看在线视频的人!因此,他们开始越来越多地被吸引到 YouTube。越来越多的阴谋论者在 YouTube 上观看视频导致算法推荐越来越多的阴谋论和其他极端内容,这导致更多的极端分子在 YouTube 上观看视频,更多的人在 YouTube 上形成极端观点,进而导致算法推荐更多的极端内容。系统失控了。

这种现象并不局限于这种特定类型的内容。2019 年 6 月,《纽约时报》发表了一篇关于 YouTube 推荐系统的文章,标题为“在 YouTube 的数字游乐场,对恋童癖者敞开大门”。文章以这个令人不安的故事开头:

当 Christiane C.的 10 岁女儿和一个朋友上传了一个在后院游泳池玩耍的视频时,她并没有在意……几天后……视频的观看次数已经达到了数千次。不久之后,观看次数已经增加到 40 万……“我再次看到视频,看到观看次数,我感到害怕,”Christiane 说。她有理由感到害怕。研究人员发现,YouTube 的自动推荐系统……开始向观看其他预备期、部分穿着少儿视频的用户展示这个视频。

单独看,每个视频可能是完全无辜的,比如一个孩子制作的家庭影片。任何暴露的画面都是短暂的,看起来是偶然的。但是,当它们被组合在一起时,它们共享的特征变得明显。

YouTube 的推荐算法开始为恋童癖者策划播放列表,挑选出偶然包含预备期、部分穿着少儿的无辜家庭视频。

谷歌没有计划创建一个将家庭视频变成儿童色情片的系统。那么发生了什么?

这里的问题之一是指标在推动一个财政重要系统中的核心性。当一个算法有一个要优化的指标时,正如你所看到的,它会尽其所能来优化这个数字。这往往会导致各种边缘情况,与系统互动的人类会寻找、发现并利用这些边缘情况和反馈循环以谋取利益。

有迹象表明,这正是发生在 YouTube 的推荐系统中的情况。卫报发表了一篇题为“一位前 YouTube 内部人员是如何调查其秘密算法的”的文章,讲述了前 YouTube 工程师 Guillaume Chaslot 创建了一个网站来跟踪这些问题。Chaslot 在罗伯特·穆勒“关于 2016 年总统选举中俄罗斯干预调查”的发布后发布了图表,如图 3-5 所示。

穆勒报告的报道

图 3-5. 穆勒报告的报道

俄罗斯今日电视台对穆勒报告的报道在推荐频道中是一个极端的离群值。这表明俄罗斯今日电视台,一个俄罗斯国有媒体机构,成功地操纵了 YouTube 的推荐算法。不幸的是,这种系统缺乏透明度,使我们很难揭示我们正在讨论的问题。

本书的一位审阅者 Aurélien Géron,曾在 2013 年至 2016 年间领导 YouTube 的视频分类团队(远在这里讨论的事件之前)。他指出,涉及人类的反馈循环不仅是一个问题。也可能存在没有人类参与的反馈循环!他向我们讲述了 YouTube 的一个例子:

对视频的主题进行分类的一个重要信号是视频的来源频道。例如,上传到烹饪频道的视频很可能是烹饪视频。但我们如何知道一个频道的主题是什么?嗯...部分是通过查看它包含的视频的主题!你看到循环了吗?例如,许多视频有描述,指示拍摄视频所使用的相机。因此,一些视频可能被分类为“摄影”视频。如果一个频道有这样一个错误分类的视频,它可能被分类为“摄影”频道,使得未来在该频道上的视频更有可能被错误分类为“摄影”。这甚至可能导致失控的病毒般的分类!打破这种反馈循环的一种方法是对有和没有频道信号的视频进行分类。然后在对频道进行分类时,只能使用没有频道信号获得的类别。这样,反馈循环就被打破了。

有人和组织试图解决这些问题的积极例子。Meetup 的首席机器学习工程师 Evan Estola 讨论了男性对科技见面会表现出比女性更感兴趣的例子。因此,考虑性别可能会导致 Meetup 的算法向女性推荐更少的科技见面会,结果导致更少的女性了解并参加科技见面会,这可能导致算法向女性推荐更少的科技见面会,如此循环反馈。因此,Evan 和他的团队做出了道德决定,让他们的推荐算法不会创建这样的反馈循环,明确不在模型的那部分使用性别。看到一家公司不仅仅是盲目地优化指标,而是考虑其影响是令人鼓舞的。根据 Evan 的说法,“你需要决定在算法中不使用哪个特征...最优算法也许不是最适合投入生产的算法。”

尽管 Meetup 选择避免这种结果,但 Facebook 提供了一个允许失控的反馈循环肆虐的例子。与 YouTube 类似,它倾向于通过向用户介绍更多阴谋论来激化用户。正如虚构信息传播研究员 Renee DiResta 所写的那样:

一旦人们加入一个阴谋论倾向的[Facebook]群组,他们就会被算法路由到其他大量群组。加入反疫苗群组,你的建议将包括反转基因、化学尾迹观察、地平论者(是的,真的)和“自然治愈癌症”群组。推荐引擎不是将用户拉出兔子洞,而是将他们推得更深。

非常重要的是要记住这种行为可能会发生,并在看到自己项目中出现第一个迹象时,要么预见到一个反馈循环,要么采取积极行动来打破它。另一件要记住的事情是偏见,正如我们在上一章中简要讨论的那样,它可能与反馈循环以非常麻烦的方式相互作用。

偏见

在线讨论偏见往往会变得非常混乱。 “偏见”一词有很多不同的含义。统计学家经常认为,当数据伦理学家谈论偏见时,他们在谈论统计学术语“偏见”,但他们并没有。他们当然也没有在谈论出现在模型参数中的权重和偏见中的偏见!

他们所谈论的是社会科学概念中的偏见。在“理解机器学习意外后果的框架”中,麻省理工学院的 Harini Suresh 和 John Guttag 描述了机器学习中的六种偏见类型,总结在图 3-6 中。

显示机器学习中偏见可能出现的所有来源的图表

图 3-6。机器学习中的偏见可能来自多个来源(由 Harini Suresh 和 John V. Guttag 提供)

我们将讨论其中四种偏见类型,这些是我们在自己的工作中发现最有帮助的(有关其他类型的详细信息,请参阅论文)。

历史偏见

历史偏见源于人们的偏见,过程的偏见,以及社会的偏见。苏雷什和古塔格说:“历史偏见是数据生成过程的第一步存在的基本结构性问题,即使进行了完美的抽样和特征选择,它也可能存在。”

例如,以下是美国历史上种族偏见的几个例子,来自芝加哥大学 Sendhil Mullainathan 的《纽约时报》文章“种族偏见,即使我们有良好意图”

  • 当医生看到相同的档案时,他们更不可能向黑人患者推荐心脏导管化(一种有益的程序)。

  • 在讨价还价购买二手车时,黑人被要求支付的初始价格高出 700 美元,并获得了远低于预期的让步。

  • 在 Craigslist 上回应带有黑人姓名的公寓出租广告比带有白人姓名的回应要少。

  • 一个全白人陪审团比一个黑人被告有 16 个百分点更有可能定罪,但当陪审团有一个黑人成员时,他们以相同的比率定罪。

在美国用于判决和保释决定的 COMPAS 算法是一个重要算法的例子,当ProPublica进行测试时,实际上显示出明显的种族偏见(图 3-7)。

表格显示,即使重新犯罪,COMPAS 算法更有可能给白人保释

图 3-7。COMPAS 算法的结果

任何涉及人类的数据集都可能存在这种偏见:医疗数据、销售数据、住房数据、政治数据等等。由于潜在偏见是如此普遍,数据集中的偏见也非常普遍。甚至在计算机视觉中也会出现种族偏见,正如 Twitter 上一位 Google 照片用户分享的自动分类照片的例子所示,见图 3-8。

Google 照片使用黑人用户和她朋友的照片标记为大猩猩的屏幕截图

图 3-8。其中一个标签是非常错误的...

是的,这正是你认为的:Google 照片将一位黑人用户的照片与她的朋友一起分类为“大猩猩”!这种算法错误引起了媒体的广泛关注。一位公司女发言人表示:“我们对此感到震惊和真诚地抱歉。自动图像标记仍然存在许多问题,我们正在研究如何防止将来发生这类错误。”

不幸的是,当输入数据存在问题时,修复机器学习系统中的问题是困难的。谷歌的第一次尝试并没有激发信心,正如卫报的报道所建议的那样(图 3-9)。

来自卫报的标题图片,当谷歌从其算法的可能标签中删除大猩猩和其他猴子时

图 3-9。谷歌对问题的第一次回应

这些问题当然不仅限于谷歌。麻省理工学院的研究人员研究了最受欢迎的在线计算机视觉 API,以了解它们的准确性。但他们并不只是计算一个准确性数字,而是查看了四个组的准确性,如图 3-10 所示。

表格显示各种面部识别系统在较深肤色和女性上表现更差

图 3-10。各种面部识别系统的性别和种族错误率

例如,IBM 的系统对较深肤色的女性有 34.7%的错误率,而对较浅肤色的男性只有 0.3%的错误率——错误率高出 100 多倍!一些人对这些实验的反应是错误的,他们声称差异仅仅是因为较深的皮肤更难被计算机识别。然而,事实是,由于这一结果带来的负面宣传,所有相关公司都大幅改进了他们对较深肤色的模型,以至于一年后,它们几乎和对较浅肤色的一样好。因此,这表明开发人员未能利用包含足够多较深肤色面孔的数据集,或者未能用较深肤色的面孔测试他们的产品。

麻省理工学院的一位研究人员 Joy Buolamwini 警告说:“我们已经进入了自信过度但准备不足的自动化时代。如果我们未能制定道德和包容性的人工智能,我们将冒着在机器中立的幌子下失去民权和性别平等所取得的成就的风险。”

问题的一部分似乎是流行数据集的构成存在系统性不平衡,用于训练模型。Shreya Shankar 等人的论文“没有代表性就没有分类:评估发展中国家开放数据集中的地理多样性问题”的摘要中指出,“我们分析了两个大型公开可用的图像数据集,以评估地理多样性,并发现这些数据集似乎存在明显的美洲中心和欧洲中心的代表性偏见。此外,我们分析了在这些数据集上训练的分类器,以评估这些训练分布的影响,并发现在不同地区的图像上表现出强烈的相对性能差异。”图 3-11 展示了论文中的一个图表,展示了当时(以及本书撰写时仍然如此)两个最重要的图像数据集的地理构成。

图表显示流行训练数据集中绝大多数图像来自美国或西欧

图 3-11。流行训练集中的图像来源

绝大多数图像来自美国和其他西方国家,导致在 ImageNet 上训练的模型在其他国家和文化的场景中表现更差。例如,研究发现这样的模型在识别低收入国家的家庭物品(如肥皂、香料、沙发或床)时表现更差。图 3-12 展示了 Facebook AI Research 的 Terrance DeVries 等人的论文“目标识别对每个人都有效吗?”中的一幅图像,说明了这一点。

图表显示一个目标检测算法在西方产品上表现更好

图 3-12。目标检测的实际应用

在这个例子中,我们可以看到低收入肥皂的例子离准确还有很长的路要走,每个商业图像识别服务都预测“食物”是最可能的答案!

接下来我们将讨论,绝大多数人工智能研究人员和开发人员都是年轻的白人男性。我们看到的大多数项目都是使用产品开发团队的朋友和家人进行用户测试。鉴于此,我们刚刚讨论的问题不应该令人惊讶。

类似的历史偏见也存在于用作自然语言处理模型数据的文本中。这会在许多下游机器学习任务中出现。例如,据广泛报道,直到去年,Google 翻译在将土耳其中性代词“o”翻译成英语时显示了系统性偏见:当应用于通常与男性相关联的工作时,它使用“he”,而当应用于通常与女性相关联的工作时,它使用“she”(图 3-13)。

显示语言模型训练中数据集中性别偏见在翻译中的体现的图表

图 3-13。文本数据集中的性别偏见

我们也在在线广告中看到这种偏见。例如,2019 年穆罕默德·阿里等人的一项研究发现,即使放置广告的人没有故意歧视,Facebook 也会根据种族和性别向非常不同的受众展示广告。展示了同样文本但图片分别是白人家庭或黑人家庭的房屋广告被展示给了种族不同的受众。

测量偏见

在《“机器学习是否自动化了道德风险和错误”》一文中,Sendhil Mullainathan 和 Ziad Obermeyer 研究了一个模型,试图回答这个问题:使用历史电子健康记录(EHR)数据,哪些因素最能预测中风?这是该模型的前几个预测因素:

  • 先前的中风

  • 心血管疾病

  • 意外伤害

  • 良性乳腺肿块

  • 结肠镜检查

  • 鼻窦炎

然而,只有前两个与中风有关!根据我们迄今所学,你可能已经猜到原因。我们实际上并没有测量中风,中风是由于脑部某个区域由于血液供应中断而被剥夺氧气而发生的。我们测量的是谁有症状,去看医生,接受了适当的检查,并且被诊断出中风。实际上患中风不仅与这个完整列表相关联,还与那些会去看医生的人相关联(这受到谁能获得医疗保健、能否负担得起自付款、是否经历种族或性别歧视等影响)!如果你在发生意外伤害时可能会去看医生,那么在中风时你也可能会去看医生。

这是测量偏见的一个例子。当我们的模型因为测量错误、以错误方式测量或不恰当地将该测量纳入模型时,就会发生这种偏见。

聚合偏见

聚合偏见发生在模型未以包含所有适当因素的方式聚合数据,或者模型未包含必要的交互项、非线性等情况下。这在医疗环境中尤其常见。例如,糖尿病的治疗通常基于简单的单变量统计和涉及小组异质人群的研究。结果分析通常未考虑不同种族或性别。然而,事实证明糖尿病患者在不同种族之间有不同的并发症,HbA1c 水平(用于诊断和监测糖尿病的广泛指标)在不同种族和性别之间以复杂方式不同。这可能导致人们被误诊或错误治疗,因为医疗决策基于不包含这些重要变量和交互作用的模型。

表征偏见

Maria De-Arteaga 等人的论文“Bias in Bios: A Case Study of Semantic Representation Bias in a High-Stakes Setting”的摘要指出,职业中存在性别不平衡(例如,女性更有可能成为护士,男性更有可能成为牧师),并表示“性别之间的真正阳性率差异与职业中现有的性别不平衡相关,这可能会加剧这些不平衡。”

换句话说,研究人员注意到,预测职业的模型不仅反映了潜在人口中的实际性别不平衡,而且放大了它!这种表征偏差是相当常见的,特别是对于简单模型。当存在明显、容易看到的基本关系时,简单模型通常会假定这种关系始终存在。正如论文中的图 3-14 所示,对于女性比例较高的职业,模型往往会高估该职业的普遍性。

显示模型预测如何过度放大现有偏见的图表

图 3-14。预测职业中的模型误差与该职业中女性比例的关系

例如,在训练数据集中,14.6%的外科医生是女性,然而在模型预测中,真正阳性中只有 11.6%是女性。因此,模型放大了训练集中存在的偏见。

既然我们已经看到这些偏见存在,我们可以采取什么措施来减轻它们呢?

解决不同类型的偏见

不同类型的偏见需要不同的缓解方法。虽然收集更多样化的数据集可以解决表征偏见,但这对历史偏见或测量偏见无济于事。所有数据集都包含偏见。没有完全无偏的数据集。该领域的许多研究人员一直在提出一系列建议,以便更好地记录决策、背景和有关特定数据集创建方式的细节,以及为什么在什么情况下使用它,以及其局限性。这样,使用特定数据集的人不会被其偏见和局限性所困扰。

我们经常听到这样的问题,“人类有偏见,那么算法偏见真的重要吗?”这个问题经常被提出,肯定有一些让提问者认为有道理的理由,但对我们来说似乎并不太合乎逻辑!独立于这是否合乎逻辑,重要的是要意识到算法(特别是机器学习算法!)和人类是不同的。考虑一下关于机器学习算法的这些观点:

机器学习可以创建反馈循环

少量偏见可能会因为反馈循环而迅速呈指数增长。

机器学习可能会放大偏见

人类偏见可能导致更多的机器学习偏见。

算法和人类的使用方式不同

在实践中,人类决策者和算法决策者并不是以插拔方式互换使用的。这些例子列在下一页的清单中。

技术就是力量

随之而来的是责任。

正如阿肯色州医疗保健的例子所示,机器学习通常在实践中实施并不是因为它能带来更好的结果,而是因为它更便宜和更高效。凯西·奥尼尔在她的书《数学毁灭的武器》(Crown)中描述了一个模式,即特权人士由人处理,而穷人由算法处理。这只是算法与人类决策者使用方式的许多方式之一。其他方式包括以下内容:

  • 人们更有可能认为算法是客观或无误差的(即使他们有人类覆盖的选项)。

  • 算法更有可能在没有上诉程序的情况下实施。

  • 算法通常以规模使用。

  • 算法系统成本低廉。

即使在没有偏见的情况下,算法(尤其是深度学习,因为它是一种如此有效和可扩展的算法)也可能导致负面社会问题,比如当用于虚假信息时。

虚假信息

虚假信息的历史可以追溯到数百甚至数千年前。它不一定是让某人相信错误的事情,而是经常用来播撒不和谐和不确定性,并让人们放弃寻求真相。收到矛盾的说法可能会导致人们认为他们永远无法知道该信任谁或什么。

有些人认为虚假信息主要是关于错误信息或假新闻,但实际上,虚假信息经常包含真相的种子,或者是脱离上下文的半真相。拉迪斯拉夫·比特曼是苏联的一名情报官员,后来叛逃到美国,并在 20 世纪 70 年代和 80 年代写了一些关于苏联宣传行动中虚假信息角色的书籍。在《克格勃和苏联虚假信息》(Pergamon)中,他写道“大多数活动都是精心设计的事实、半真相、夸大和故意谎言的混合物。”

在美国,近年来,FBI 详细描述了与 2016 年选举中的俄罗斯有关的大规模虚假信息活动。了解在这次活动中使用的虚假信息非常有教育意义。例如,FBI 发现俄罗斯的虚假信息活动经常组织两个独立的假“草根”抗议活动,一个支持某一方面,另一个支持另一方面,并让他们同时抗议!休斯顿纪事报报道了其中一个奇怪事件(图 3-15):

一个自称为“德克萨斯之心”的团体在社交媒体上组织了一场抗议活动,他们声称这是反对“德克萨斯伊斯兰化”的。在特拉维斯街的一边,我发现大约有 10 名抗议者。在另一边,我发现大约有 50 名反对抗议者。但我找不到集会的组织者。没有“德克萨斯之心”。我觉得这很奇怪,并在文章中提到:一个团体在自己的活动中缺席是什么样的团体?现在我知道为什么了。显然,集会的组织者当时在俄罗斯的圣彼得堡。“德克萨斯之心”是特别检察官罗伯特·穆勒最近指控试图干预美国总统选举的俄罗斯人中引用的一个互联网喷子团体。

德克萨斯之心组织的活动截图

图 3-15。由德克萨斯之心组织的活动

虚假信息通常涉及协调的不真实行为活动。例如,欺诈账户可能试图让人们认为许多人持有特定观点。虽然大多数人喜欢认为自己是独立思考的,但实际上我们进化为受到内部群体的影响,并与外部群体对立。在线讨论可能会影响我们的观点,或改变我们认为可接受观点的范围。人类是社会动物,作为社会动物,我们受周围人的影响极大。越来越多的极端化发生在在线环境中;因此影响来自虚拟空间中的在线论坛和社交网络中的人们。

通过自动生成的文本进行虚假信息传播是一个特别重要的问题,这是由于深度学习提供的大大增强的能力。当我们深入研究创建语言模型时,我们会深入讨论这个问题第十章。

一种提出的方法是开发某种形式的数字签名,以无缝方式实施它,并创建我们应该信任仅经过验证的内容的规范。艾伦人工智能研究所的负责人奥伦·艾齐奥尼在一篇题为“我们将如何防止基于人工智能的伪造?”的文章中写道:“人工智能正准备使高保真伪造变得廉价和自动化,可能会对民主、安全和社会造成灾难性后果。人工智能伪造的幽灵意味着我们需要采取行动,使数字签名成为验证数字内容的手段。”

虽然我们无法讨论深度学习和算法带来的所有伦理问题,但希望这个简短的介绍可以成为您的有用起点。现在我们将继续讨论如何识别伦理问题以及如何处理它们。

识别和解决伦理问题

错误是难免的。了解并处理错误需要成为包括机器学习在内的任何系统设计的一部分(还有许多其他系统)。数据伦理中提出的问题通常是复杂且跨学科的,但至关重要的是我们努力解决这些问题。

那么我们能做什么?这是一个重要的话题,但以下是一些解决伦理问题的步骤:

  • 分析你正在进行的项目。

  • 在您的公司实施流程以发现和解决伦理风险。

  • 支持良好的政策。

  • 增加多样性。

让我们逐步进行,从分析你正在进行的项目开始。

分析你正在进行的项目

在考虑工作的伦理影响时很容易忽略重要问题。一个极大的帮助是简单地提出正确的问题。Rachel Thomas 建议在数据项目的开发过程中考虑以下问题:

  • 我们甚至应该这样做吗?

  • 数据中存在什么偏见?

  • 代码和数据可以进行审计吗?

  • 不同子群体的错误率是多少?

  • 基于简单规则的替代方案的准确性如何?

  • 有哪些处理申诉或错误的流程?

  • 构建它的团队有多少多样性?

这些问题可能有助于您识别未解决的问题,以及更容易理解和控制的可能替代方案。除了提出正确的问题外,考虑实施的实践和流程也很重要。

在这个阶段需要考虑的一件事是你正在收集和存储的数据。数据往往最终被用于不同于最初意图的目的。例如,IBM 在大屠杀之前就开始向纳粹德国出售产品,包括帮助纳粹德国进行的 1933 年人口普查,这次普查有效地识别出了比之前在德国被认可的犹太人更多。同样,美国人口普查数据被用来拘留二战期间的日裔美国人(他们是美国公民)。重要的是要认识到收集的数据和图像如何在以后被武器化。哥伦比亚大学教授蒂姆·吴写道:“你必须假设 Facebook 或 Android 保存的任何个人数据都是世界各国政府将试图获取或盗贼将试图窃取的数据。”

实施流程

马库拉中心发布了工程/设计实践的伦理工具包,其中包括在您的公司实施的具体实践,包括定期安排的扫描,以主动搜索伦理风险(类似于网络安全渗透测试),扩大伦理圈,包括各种利益相关者的观点,并考虑可怕的人(坏人如何滥用、窃取、误解、黑客、破坏或武器化您正在构建的东西?)。

即使您没有多样化的团队,您仍然可以尝试主动包括更广泛群体的观点,考虑这些问题(由马库拉中心提供):

  • 我们是否只是假设了谁/哪些团体和个人的利益、愿望、技能、经验和价值观,而没有实际咨询?

  • 谁将直接受到我们产品影响的所有利益相关者?他们的利益是如何得到保护的?我们如何知道他们的真正利益是什么——我们有没有询问过?

  • 哪些团体和个人将受到重大影响而间接受到影响?

  • 谁可能会使用这个产品,而我们没有预料到会使用它,或者出于我们最初没有打算的目的?

伦理镜头

马库拉中心的另一个有用资源是其技术和工程实践中的概念框架。这考虑了不同基础伦理镜头如何帮助识别具体问题,并列出以下方法和关键问题:

权利的观点

哪个选项最尊重所有利益相关者的权利?

正义的观点

哪个选项平等或成比例地对待人们?

功利主义的观点

哪个选项将产生最多的好处并造成最少的伤害?

共同利益的观点

哪个选项最好地服务于整个社区,而不仅仅是一些成员?

美德的观点

哪个选项会让我表现得像我想成为的那种人?

马库拉的建议包括更深入地探讨这些观点,包括通过后果的视角来审视一个项目:

  • 谁将直接受到这个项目的影响?谁将间接受到影响?

  • 总体上,这些影响可能会产生更多的好处还是伤害,以及什么类型的好处和伤害?

  • 我们是否考虑了所有相关类型的伤害/好处(心理、政治、环境、道德、认知、情感、制度、文化)?

  • 未来的后代可能会受到这个项目的影响吗?

  • 这个项目可能会对社会中最弱势的人造成的伤害风险是否不成比例?好处是否会不成比例地给予富裕者?

  • 我们是否充分考虑了“双重使用”和意外的下游影响?

另一种视角是义务论的视角,它侧重于的基本概念:

  • 我们必须尊重他人的哪些权利和对他人的义务

  • 这个项目可能会如何影响每个利益相关者的尊严和自主权?

  • 信任和正义的考虑对这个设计/项目有何影响?

  • 这个项目是否涉及与他人的冲突道德责任,或者与利益相关者的冲突权利?我们如何能够优先考虑这些?

帮助提出完整和周到的答案的最佳方法之一是确保提出问题的人是多样化的。

多样性的力量

根据Element AI 的一项研究,目前不到 12%的人工智能研究人员是女性。在种族和年龄方面的统计数据同样令人堪忧。当团队中的每个人背景相似时,他们很可能在道德风险方面有相似的盲点。哈佛商业评论(HBR)发表了许多研究,显示了多样化团队的许多好处,包括以下内容:

多样性可以导致问题更早地被识别,并考虑更广泛的解决方案。例如,Tracy Chou 是 Quora 的一名早期工程师。她描述了自己的经历,描述了她在内部为添加一个功能而进行倡导,该功能可以允许封锁恶意用户和其他不良行为者。Chou 回忆道,“我渴望参与这个功能的开发,因为我在网站上感到被挑衅和虐待(性别可能是一个原因)...但如果我没有那种个人视角,Quora 团队可能不会那么早地将构建封锁按钮作为优先事项。”骚扰经常会导致边缘群体的人离开在线平台,因此这种功能对于维护 Quora 社区的健康至关重要。

一个关键的方面要理解的是,女性离开科技行业的速度是男性的两倍以上。根据哈佛商业评论的数据,41%的从事科技行业的女性离开,而男性只有 17%。对 200 多本书籍、白皮书和文章的分析发现,她们离开的原因是“她们受到不公平对待;薪酬较低,不如男同事那样容易获得快速晋升,无法晋升。”

研究已经证实了一些使女性在职场中更难晋升的因素。女性在绩效评估中收到更多模糊的反馈和个性批评,而男性收到与业务结果相关的可操作建议(更有用)。女性经常被排除在更具创造性和创新性的角色之外,并且没有获得有助于晋升的高能见度的“拓展”任务。一项研究发现,即使阅读相同的脚本,男性的声音被认为比女性的声音更具有说服力、基于事实和逻辑。

统计数据显示,接受指导有助于男性晋升,但对女性没有帮助。背后的原因是,当女性接受指导时,这是关于她们应该如何改变和获得更多自我认识的建议。当男性接受指导时,这是对他们权威的公开认可。猜猜哪个对于晋升更有用?

只要合格的女性继续退出科技行业,教更多女孩编程并不能解决困扰该领域的多样性问题。多样性倡议往往主要关注白人女性,尽管有色人种女性面临许多额外障碍。在对从事 STEM 研究的 60 名有色人种女性进行的采访中,100%的人表示曾经遭受过歧视。

技术领域的招聘过程特别混乱。一项表明这种功能障碍的研究来自 Triplebyte,这是一家帮助将软件工程师安置到公司的公司,作为这一过程的一部分进行了标准化的技术面试。该公司拥有一个引人入胜的数据集:300 多名工程师在考试中的表现结果,以及这些工程师在各种公司的面试过程中的表现结果。Triplebyte 的研究中的第一个发现是,“每家公司寻找的程序员类型往往与公司的需求或业务无关。相反,它们反映了公司文化和创始人的背景。”

这对于试图进入深度学习领域的人来说是一个挑战,因为大多数公司的深度学习团队今天都是由学者创立的。这些团队往往寻找“像他们一样”的人——也就是说,能够解决复杂数学问题并理解密集行话的人。他们并不总是知道如何发现那些真正擅长使用深度学习解决实际问题的人。

这为那些愿意超越地位和门第,专注于结果的公司提供了一个巨大的机会!

公平、问责和透明度

计算机科学家的专业协会 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 人以避免根据德国新法律反对仇恨言论面临高达 5000 万欧元的昂贵罚款形成鲜明对比。显然,在这种情况下,Facebook 更多地是对财务处罚的威胁做出反应,而不是对一个种族少数群体的系统性破坏。

一篇关于隐私问题的文章中,马切伊·塞格洛夫斯基与环境运动进行了类比:

这一监管项目在第一世界取得了如此成功,以至于我们可能忘记了之前的生活是什么样子。今天在雅加达和德里杀死成千上万人的浓烟曾经是伦敦的象征。俄亥俄州的奎哈霍加河曾经经常起火。在一个特别可怕的意外后果的例子中,添加到汽油中的四乙基铅导致全球暴力犯罪率上升了五十年。这些伤害都不能通过告诉人们用钱包投票,或者仔细审查他们给予业务的每家公司的环境政策,或者停止使用相关技术来解决。这需要跨越司法辖区的协调和有时高度技术化的监管来解决。在一些情况下,比如禁止商用制冷剂导致臭氧层消耗,这种监管需要全球共识。我们已经到了需要在隐私法中进行类似转变的时候。

权利和政策

清洁空气和清洁饮用水是几乎不可能通过个人市场决策来保护的公共物品,而是需要协调的监管行动。同样,许多技术误用的意外后果造成的伤害涉及公共物品,比如污染的信息环境或恶化的环境隐私。隐私往往被框定为个人权利,然而广泛监视会产生社会影响(即使有一些个人可以选择退出也是如此)。

我们在科技领域看到的许多问题都是人权问题,比如一个带有偏见的算法建议黑人被告应该获得更长的监禁,特定的工作广告只显示给年轻人,或者警察使用面部识别来识别抗议者。解决人权问题的适当场所通常是法律。

我们需要监管和法律变革,以及个人的道德行为。个人行为的改变无法解决不一致的利润激励、外部性(即企业在向更广泛社会转嫁成本和危害的同时获得巨额利润)或系统性失败。然而,法律永远不可能涵盖所有边缘案例,重要的是个人软件开发人员和数据科学家能够在实践中做出道德决策。

汽车:历史先例

我们面临的问题是复杂的,没有简单的解决方案。这可能令人沮丧,但我们在考虑历史上人们已经解决的其他重大挑战时找到了希望。一个例子是增加汽车安全的运动,被提及为“数据集数据表”一书中的案例研究,作者是 Timnit Gebru 等人,以及设计播客99% Invisible。早期汽车没有安全带,仪表盘上有金属旋钮,在事故中可能刺入人们的头颅,常规平板玻璃窗以危险的方式破碎,非可折叠转向柱刺穿驾驶员。然而,汽车公司甚至不愿讨论安全作为他们可以帮助解决的问题,普遍的看法是汽车就是它们的样子,是使用它们的人造成了问题。

消费者安全活动家和倡导者经过几十年的努力,改变了国家对汽车公司可能需要通过监管来解决一些责任的讨论。可折叠转向柱发明后,由于没有财务激励,几年内并未实施。主要汽车公司通用汽车公司雇佣了私家侦探,试图挖掘消费者安全倡导者拉尔夫·纳德的黑材料。安全带、碰撞测试假人和可折叠转向柱的要求是重大胜利。直到 2011 年,汽车公司才被要求开始使用代表普通女性的碰撞测试假人,而不仅仅是代表普通男性的身体;在此之前,女性在相同冲击下的车祸中受伤的可能性比男性高 40%。这是偏见、政策和技术产生重要后果的生动例证。

结论

从二进制逻辑的背景出发,伦理学中缺乏明确答案可能一开始会令人沮丧。然而,我们的工作如何影响世界,包括意外后果和工作被不良行为者武器化的影响,是我们可以(也应该!)考虑的最重要问题之一。尽管没有简单的答案,但有明确的陷阱要避免和实践要遵循,以朝着更具道德行为迈进。

许多人(包括我们!)正在寻找更令人满意、扎实的答案,以解决技术带来的有害影响。然而,考虑到我们面临的问题的复杂性、广泛性和跨学科性质,没有简单的解决方案。Julia Angwin,ProPublica 前资深记者,专注于算法偏见和监视问题(也是 2016 年调查 COMPAS 累犯算法的调查人员之一,该算法帮助引发了 FAccT 领域),在 2019 年的一次采访中表示:

我坚信,要解决问题,必须先诊断问题,而我们仍处于诊断阶段。如果您考虑到世纪之交和工业化,我们经历了,我不知道,30 年的童工、无限工作时间、糟糕的工作条件,需要大量记者揭发和倡导来诊断问题并对其有所了解,然后通过积极行动来改变法律。我觉得我们正处于数据信息的第二次工业化...我认为我的角色是尽可能清楚地表明问题的不利方面,并准确诊断问题,以便能够解决。这是艰苦的工作,需要更多的人来做。

令人欣慰的是,Angwin 认为我们在很大程度上仍处于诊断阶段:如果您对这些问题的理解感到不完整,那是正常和自然的。目前还没有“治疗”方法,但我们继续努力更好地理解和解决我们面临的问题是至关重要的。

我们这本书的一位审阅者 Fred Monroe 曾在对冲基金交易领域工作。他在阅读本章后告诉我们,这里讨论的许多问题(数据分布与模型训练不同、部署和扩展后反馈循环对模型的影响等)也是构建盈利交易模型的关键问题。考虑到社会后果所需做的事情将与考虑组织、市场和客户后果所需做的事情有很多重叠,因此认真思考伦理问题也可以帮助您认真思考如何使您的数据产品更普遍地成功!

问卷

  1. 伦理是否提供了“正确答案”清单?

  2. 在考虑伦理问题时,与不同背景的人合作如何有助于解决问题?

  3. IBM 在纳粹德国的角色是什么?为什么公司会参与其中?为什么工人会参与其中?

  4. 第一个在大众柴油丑闻中被监禁的人的角色是什么?

  5. 加利福尼亚执法官员维护的涉嫌黑帮成员数据库存在什么问题?

  6. 为什么 YouTube 的推荐算法会向恋童癖者推荐部分裸露儿童的视频,尽管谷歌的员工没有编程这个功能?

  7. 指标的中心性存在哪些问题?

  8. 为什么 Meetup.com 在其技术见面会的推荐系统中没有包括性别?

  9. 根据 Suresh 和 Guttag,机器学习中有哪六种偏见类型?

  10. 在美国历史上,有哪两个种族偏见的例子?

  11. ImageNet 中的大多数图像来自哪里?

  12. 在论文“机器学习是否自动化道德风险和错误?”中,为什么鼻窦炎被发现与中风有关?

  13. 代表性偏见是什么?

  14. 在决策方面,机器和人有何不同?

  15. 虚假信息和“假新闻”是一回事吗?

  16. 通过自动生成的文本传播虚假信息为什么是一个特别重要的问题?

  17. 马库拉中心描述的五种伦理视角是什么?

  18. 政策在解决数据伦理问题方面是否是一个合适的工具?

进一步研究

  1. 阅读文章“当算法削减您的医疗保健”(链接)。未来如何避免类似问题?

  2. 研究更多关于 YouTube 推荐系统及其社会影响的信息。你认为推荐系统是否必须始终具有带有负面结果的反馈循环?谷歌可以采取什么方法来避免这种情况?政府呢?

  3. 阅读论文“在线广告投放中的歧视”。你认为谷歌应该对 Sweeney 博士发生的事情负责吗?什么是一个合适的回应?

  4. 跨学科团队如何帮助避免负面后果?

  5. 阅读论文“机器学习是否自动化了道德风险和错误?” 你认为应该采取什么行动来处理这篇论文中指出的问题?

  6. 阅读文章“我们将如何防止基于 AI 的伪造?” 你认为 Etzioni 提出的方法能行得通吗?为什么?

  7. 完成部分“分析你正在进行的项目”。

  8. 考虑一下你的团队是否可以更多元化。如果可以,有哪些方法可能会有所帮助?

实践中的深度学习:总结!

恭喜!你已经完成了书的第一部分。在这一部分中,我们试图向你展示深度学习可以做什么,以及你如何使用它来创建真实的应用和产品。在这一点上,如果你花一些时间尝试你所学到的东西,你将从这本书中获得更多。也许你一直在学习的过程中已经在做这些事情了,如果是这样,太棒了!如果没有,也没关系——现在是开始自己尝试实验的好时机。

如果你还没有去过书的网站,现在就去吧。非常重要的是你要设置好自己来运行这些笔记本。成为一个有效的深度学习从业者就是要不断练习,所以你需要训练模型。所以,如果你还没有开始运行这些笔记本,请现在就去运行!并查看网站上的任何重要更新或通知;深度学习变化迅速,我们无法改变这本书中印刷的文字,所以你需要查看网站以确保你拥有最新的信息。

确保你已经完成了以下步骤:

  1. 连接到书网站上推荐的 GPU Jupyter 服务器之一。

  2. 自己运行第一个笔记本。

  3. 上传你在第一个笔记本中找到的图像;然后尝试一些不同类型的图像,看看会发生什么。

  4. 运行第二个笔记本,根据你提出的图像搜索查询收集你自己的数据集。

  5. 思考一下如何利用深度学习来帮助你自己的项目,包括你可以使用什么类型的数据,可能会遇到什么问题,以及你如何在实践中可能会减轻这些问题。

在书的下一部分,你将了解深度学习是如何以及为什么起作用的,而不仅仅是看到你如何在实践中使用它。了解如何以及为什么对从业者和研究人员都很重要,因为在这个相当新的领域中,几乎每个项目都需要一定程度的定制和调试。你对深度学习的基础理解越深入,你的模型就会越好。这些基础对于高管、产品经理等人来说不那么重要(尽管仍然有用,所以请继续阅读!),但对于任何正在训练和部署模型的人来说都是至关重要的。

第二部分:理解 fastai 的应用

第四章:底层:训练数字分类器

原文:www.bookstack.cn/read/th-fastai-book/026b6e039c998ba1.md

译者:飞龙

协议:CC BY-NC-SA 4.0

在第二章中看到训练各种模型的样子后,现在让我们深入了解并看看究竟发生了什么。我们将使用计算机视觉来介绍深度学习的基本工具和概念。

确切地说,我们将讨论数组和张量的作用以及广播的作用,这是一种使用它们表达性地的强大技术。我们将解释随机梯度下降(SGD),这是通过自动更新权重学习的机制。我们将讨论基本分类任务的损失函数的选择,以及小批量的作用。我们还将描述基本神经网络正在执行的数学。最后,我们将把所有这些部分组合起来。

在未来的章节中,我们还将深入研究其他应用,并看看这些概念和工具如何泛化。但本章是关于奠定基础的。坦率地说,这也使得这是最困难的章节之一,因为这些概念彼此相互依赖。就像一个拱门,所有的石头都需要放在正确的位置才能支撑结构。也像一个拱门,一旦发生这种情况,它就是一个强大的结构,可以支撑其他事物。但是需要一些耐心来组装。

让我们开始吧。第一步是考虑图像在计算机中是如何表示的。

像素:计算机视觉的基础

要理解计算机视觉模型中发生的事情,我们首先必须了解计算机如何处理图像。我们将使用计算机视觉中最著名的数据集之一 MNIST 进行实验。MNIST 包含由国家标准与技术研究所收集的手写数字图像,并由 Yann Lecun 及其同事整理成一个机器学习数据集。Lecun 在 1998 年使用 MNIST 在 LeNet-5 中,这是第一个演示实用手写数字序列识别的计算机系统。这是人工智能历史上最重要的突破之一。

对于这个初始教程,我们只是尝试创建一个模型,可以将任何图像分类为 3 或 7。所以让我们下载一个包含这些数字图像的 MNIST 样本:

path = untar_data(URLs.MNIST_SAMPLE)

我们可以使用ls来查看此目录中的内容,这是 fastai 添加的一个方法。这个方法返回一个特殊的 fastai 类L的对象,它具有 Python 内置list的所有功能,还有更多功能。其中一个方便的功能是,在打印时,它会显示项目的计数,然后列出项目本身(如果项目超过 10 个,它只显示前几个):

path.ls()
(#9) [Path('cleaned.csv'),Path('item_list.txt'),Path('trained_model.pkl'),Path('
 > models'),Path('valid'),Path('labels.csv'),Path('export.pkl'),Path('history.cs
 > v'),Path('train')]

MNIST 数据集遵循机器学习数据集的常见布局:训练集和验证(和/或测试)集分开存放。让我们看看训练集中的内容:

(path/'train').ls()
(#2) [Path('train/7'),Path('train/3')]

有一个包含 3 的文件夹,和一个包含 7 的文件夹。在机器学习术语中,我们说“3”和“7”是这个数据集中的标签(或目标)。让我们看看其中一个文件夹中的内容(使用sorted确保我们都得到相同的文件顺序):

threes = (path/'train'/'3').ls().sorted()
sevens = (path/'train'/'7').ls().sorted()
threes
(#6131) [Path('train/3/10.png'),Path('train/3/10000.png'),Path('train/3/10011.pn
 > g'),Path('train/3/10031.png'),Path('train/3/10034.png'),Path('train/3/10042.p
 > ng'),Path('train/3/10052.png'),Path('train/3/1007.png'),Path('train/3/10074.p
 > ng'),Path('train/3/10091.png')...]

正如我们所预期的那样,它充满了图像文件。让我们现在看一个。这是一个手写数字 3 的图像,来自著名的手写数字 MNIST 数据集:

im3_path = threes[1]
im3 = Image.open(im3_path)
im3

在这里,我们使用Python Imaging Library(PIL)中的Image类,这是最广泛使用的 Python 包,用于打开、操作和查看图像。Jupyter 知道 PIL 图像,所以它会自动为我们显示图像。

在计算机中,一切都以数字表示。要查看构成这幅图像的数字,我们必须将其转换为NumPy 数组PyTorch 张量。例如,这是转换为 NumPy 数组后图像的一部分的样子:

array(im3)[4:10,4:10]
array([[  0,   0,   0,   0,   0,   0],
       [  0,   0,   0,   0,   0,  29],
       [  0,   0,   0,  48, 166, 224],
       [  0,  93, 244, 249, 253, 187],
       [  0, 107, 253, 253, 230,  48],
       [  0,   3,  20,  20,  15,   0]], dtype=uint8)

4:10表示我们请求从索引 4(包括)到 10(不包括)的行,列也是一样。NumPy 从上到下,从左到右索引,因此此部分位于图像的左上角附近。这里是一个 PyTorch 张量:

tensor(im3)[4:10,4:10]
tensor([[  0,   0,   0,   0,   0,   0],
        [  0,   0,   0,   0,   0,  29],
        [  0,   0,   0,  48, 166, 224],
        [  0,  93, 244, 249, 253, 187],
        [  0, 107, 253, 253, 230,  48],
        [  0,   3,  20,  20,  15,   0]], dtype=torch.uint8)

我们可以切片数组,只选择包含数字顶部部分的部分,然后使用 Pandas DataFrame 使用渐变对值进行着色,这清楚地显示了图像是如何由像素值创建的:

im3_t = tensor(im3)
df = pd.DataFrame(im3_t[4:15,4:22])
df.style.set_properties(**{'font-size':'6pt'}).background_gradient('Greys')

你可以看到,背景白色像素存储为数字 0,黑色为数字 255,灰色在两者之间。整个图像横向包含 28 个像素,纵向包含 28 个像素,总共 768 个像素。(这比你从手机相机得到的图像要小得多,手机相机有数百万像素,但对于我们的初始学习和实验来说,这是一个方便的大小。我们将很快构建更大的全彩图像。)

所以,现在你已经看到了计算机对图像的看法,让我们回顾一下我们的目标:创建一个能够识别 3 和 7 的模型。你会如何让计算机做到这一点呢?

停下来思考!

在继续阅读之前,花点时间考虑一下计算机可能如何识别这两个数字。它可能能够看到什么样的特征?它可能如何识别这些特征?它如何将它们结合起来?学习最好的方式是尝试自己解决问题,而不仅仅是阅读别人的答案;所以离开这本书几分钟,拿一张纸和笔,写下一些想法。

第一次尝试:像素相似度

所以,这是一个第一个想法:我们可以找到每个 3 的像素的平均值,然后对 7 做同样的操作。这将给我们两组平均值,定义了我们可能称之为“理想”3 和 7。然后,为了将图像分类为一个数字或另一个数字,我们看看这两个理想数字中图像与哪个更相似。这肯定似乎比没有好,所以这将成为一个很好的基线。

术语:基线

一个简单的模型,你有信心应该表现得相当不错。它应该简单实现和易于测试,这样你就可以测试每个改进的想法,并确保它们始终优于基线。如果没有以合理的基线开始,很难知道你的超级花哨的模型是否好用。创建基线的一个好方法是做我们在这里做的事情:考虑一个简单、易于实现的模型。另一个好方法是四处寻找解决类似问题的其他人,并在你的数据集上下载并运行他们的代码。最好两者都尝试一下!

我们简单模型的第一步是获取我们两组像素值的平均值。在这个过程中,我们将学习很多有趣的 Python 数值编程技巧!

让我们创建一个包含所有 3 的张量堆叠在一起。我们已经知道如何创建包含单个图像的张量。要创建一个包含目录中所有图像的张量,我们将首先使用 Python 列表推导来创建一个单个图像张量的普通列表。

我们将使用 Jupyter 在途中做一些小的检查——在这种情况下,确保返回的项目数量看起来合理:

seven_tensors = [tensor(Image.open(o)) for o in sevens]
three_tensors = [tensor(Image.open(o)) for o in threes]
len(three_tensors),len(seven_tensors)
(6131, 6265)

列表推导

列表和字典推导是 Python 的一个很棒的特性。许多 Python 程序员每天都在使用它们,包括本书的作者们——它们是“Python 的成语”。但是来自其他语言的程序员可能以前从未见过它们。许多很棒的教程只需一次网络搜索,所以我们现在不会花很长时间讨论它们。这里有一个快速的解释和示例,让您开始。列表推导看起来像这样:new_list = [f(o) for o in a_list if o>0]。这将返回a_list中大于 0 的每个元素,在将其传递给函数f之后。这里有三个部分:您正在迭代的集合(a_list),一个可选的过滤器(if o>0),以及对每个元素执行的操作(f(o))。不仅写起来更短,而且比用循环创建相同列表的替代方法更快。

我们还将检查其中一张图像是否正常。由于我们现在有张量(Jupyter 默认会将其打印为值),而不是 PIL 图像(Jupyter 默认会显示图像),我们需要使用 fastai 的show_image函数来显示它:

show_image(three_tensors[1]);

对于每个像素位置,我们想要计算该像素的强度在所有图像上的平均值。为了做到这一点,我们首先将此列表中的所有图像组合成一个三维张量。描述这样的张量最常见的方式是称之为rank-3 张量。我们经常需要将集合中的单个张量堆叠成一个张量。不出所料,PyTorch 带有一个名为stack的函数,我们可以用它来实现这个目的。

PyTorch 中的一些操作,如取平均值,需要我们将整数类型转换为浮点类型。由于我们稍后会需要这个,我们现在也将我们的堆叠张量转换为float。在 PyTorch 中进行转换就像写下您希望转换为的类型名称,并将其视为方法一样简单。

通常,当图像是浮点数时,像素值应该在 0 到 1 之间,所以我们也会在这里除以 255:

stacked_sevens = torch.stack(seven_tensors).float()/255
stacked_threes = torch.stack(three_tensors).float()/255
stacked_threes.shape
torch.Size([6131, 28, 28])

张量最重要的属性也许是其形状。这告诉您每个轴的长度。在这种情况下,我们可以看到我们有 6,131 张图像,每张图像大小为 28×28 像素。关于这个张量没有特别的地方表明第一个轴是图像的数量,第二个是高度,第三个是宽度——张量的语义完全取决于我们以及我们如何构建它。就 PyTorch 而言,它只是内存中的一堆数字。

张量形状的长度是其秩:

len(stacked_threes.shape)
3

对于您来说,将张量术语的这些部分记忆并加以实践非常重要:是张量中轴或维度的数量;形状是张量每个轴的大小。

Alexis 说

要小心,因为术语“维度”有时以两种方式使用。考虑我们生活在“三维空间”中,其中物理位置可以用长度为 3 的向量v描述。但根据 PyTorch,属性v.ndim(看起来确实像v的“维度数量”)等于一,而不是三!为什么?因为v是一个向量,它是一个秩为一的张量,这意味着它只有一个(即使该轴的长度为三)。换句话说,有时维度用于描述轴的大小(“空间是三维的”),而其他时候用于描述秩或轴的数量(“矩阵有两个维度”)。当感到困惑时,我发现将所有陈述转换为秩、轴和长度这些明确的术语是有帮助的。

我们也可以直接使用ndim来获取张量的秩:

stacked_threes.ndim
3

最后,我们可以计算理想的 3 是什么样子的。我们通过沿着我们堆叠的 rank-3 张量的维度 0 取平均值来计算所有图像张量的平均值。这是索引所有图像的维度。

换句话说,对于每个像素位置,这将计算所有图像中该像素的平均值。结果将是每个像素位置的一个值,或者一个单独的图像。这就是它:

mean3 = stacked_threes.mean(0)
show_image(mean3);

根据这个数据集,这是理想的数字 3!(您可能不喜欢,但这就是顶级数字 3 表现的样子。)您可以看到在所有图像都认为应该是暗的地方非常暗,但在图像不一致的地方变得模糊。

让我们对 7 做同样的事情,但一次将所有步骤放在一起以节省时间:

mean7 = stacked_sevens.mean(0)
show_image(mean7);

现在让我们选择一个任意的 3,并测量它与我们的“理想数字”的距离

停下来思考一下!

您如何计算特定图像与我们的每个理想数字之间的相似程度?在继续前进之前,请记得远离这本书,记录一些想法!研究表明,通过解决问题、实验和尝试新想法,您参与学习过程时,召回和理解会显著提高。

这是一个示例 3:

a_3 = stacked_threes[1]
show_image(a_3);

我们如何确定它与我们理想的 3 之间的距离?我们不能简单地将此图像的像素之间的差异相加,并与理想数字进行比较。一些差异将是正的,而另一些将是负的,这些差异将相互抵消,导致一种情况,即在某些地方太暗而在其他地方太亮的图像可能被显示为与理想的总差异为零。那将是误导性的!

为了避免这种情况,数据科学家在这种情况下使用两种主要方法来测量距离:

  • 取差值的绝对值的平均值(绝对值是将负值替换为正值的函数)。这被称为平均绝对差L1 范数

  • 取差值的平方的平均值(使所有值变为正数),然后取平方根(撤销平方)。这被称为均方根误差(RMSE)或L2 范数

忘记数学是可以的

在这本书中,我们通常假设您已经完成了高中数学,并且至少记得一些内容 - 但每个人都会忘记一些东西!这完全取决于您在此期间有理由练习的内容。也许您已经忘记了平方根是什么,或者它们究竟是如何工作的。没问题!每当您遇到本书中没有完全解释的数学概念时,不要只是继续前进;相反,停下来查一下。确保您理解基本概念,它是如何工作的,以及为什么我们可能会使用它。刷新您理解的最佳地方之一是 Khan Academy。例如,Khan Academy 有一个很棒的平方根介绍

现在让我们尝试这两种方法:

dist_3_abs = (a_3 - mean3).abs().mean()
dist_3_sqr = ((a_3 - mean3)**2).mean().sqrt()
dist_3_abs,dist_3_sqr
(tensor(0.1114), tensor(0.2021))
dist_7_abs = (a_3 - mean7).abs().mean()
dist_7_sqr = ((a_3 - mean7)**2).mean().sqrt()
dist_7_abs,dist_7_sqr
(tensor(0.1586), tensor(0.3021))

在这两种情况下,我们的 3 与“理想”的 3 之间的距离小于与理想的 7 之间的距离,因此在这种情况下,我们简单的模型将给出正确的预测。

PyTorch 已经提供了这两种作为损失函数。您会在torch.nn.functional中找到这些,PyTorch 团队建议将其导入为F(并且默认情况下以这个名称在 fastai 中可用):

F.l1_loss(a_3.float(),mean7), F.mse_loss(a_3,mean7).sqrt()
(tensor(0.1586), tensor(0.3021))

在这里,MSE代表均方误差l1是标准数学术语平均绝对值的缩写(在数学中称为L1 范数)。

Sylvain 说

直观地,L1 范数和均方误差(MSE)之间的区别在于,后者会比前者更严厉地惩罚更大的错误(并对小错误更宽容)。

杰里米说

当我第一次遇到这个 L1 的东西时,我查了一下看它到底是什么意思。我在谷歌上发现它是使用“绝对值”作为“向量范数”,所以我查了“向量范数”并开始阅读:“给定一个实数或复数域 F 上的向量空间 V,V 上的范数是一个非负值的任意函数 p: V → [0,+∞),具有以下属性:对于所有的 a ∈ F 和所有的 u, v ∈ V,p(u + v) ≤ p(u) + p(v)…”然后我停止阅读。“唉,我永远也理解不了数学!”我想,这已经是第一千次了。从那时起,我学到了每当实践中出现这些复杂的数学术语时,我可以用一点点代码来替换它们!比如,L1 损失 只等于 (a-b).abs().mean(),其中 ab 是张量。我猜数学家们只是和我想法不同…我会确保在本书中,每当出现一些数学术语时,我会给你相应的代码片段,并用通俗的语言解释发生了什么。

我们刚刚在 PyTorch 张量上完成了各种数学运算。如果你之前在 PyTorch 中进行过数值编程,你可能会发现这些与 NumPy 数组相似。让我们来看看这两个重要的数据结构。

NumPy 数组和 PyTorch 张量

NumPy 是 Python 中用于科学和数值编程最广泛使用的库。它提供了类似的功能和类似的 API,与 PyTorch 提供的功能相似;然而,它不支持使用 GPU 或计算梯度,这两者对于深度学习都是至关重要的。因此,在本书中,我们通常会在可能的情况下使用 PyTorch 张量而不是 NumPy 数组。

(请注意,fastai 在 NumPy 和 PyTorch 中添加了一些功能,使它们更加相似。如果本书中的任何代码在您的计算机上无法运行,可能是因为您忘记在笔记本的开头包含类似这样的一行代码:from fastai.vision.all import *。)

但是数组和张量是什么,为什么你应该关心呢?

Python 相对于许多语言来说速度较慢。在 Python、NumPy 或 PyTorch 中快速的任何东西,很可能是另一种语言(特别是 C)编写(并优化)的编译对象的包装器。事实上,NumPy 数组和 PyTorch 张量可以比纯 Python 快几千倍完成计算

NumPy 数组是一个多维数据表,所有项都是相同类型的。由于可以是任何类型,它们甚至可以是数组的数组,内部数组可能是不同大小的 - 这被称为 不规则数组。通过“多维数据表”,我们指的是,例如,一个列表(一维)、一个表或矩阵(二维)、一个表的表或立方体(三维),等等。如果所有项都是简单类型,如整数或浮点数,NumPy 将它们存储为紧凑的 C 数据结构在内存中。这就是 NumPy 的优势所在。NumPy 有各种运算符和方法,可以在这些紧凑结构上以优化的 C 速度运行计算,因为它们是用优化的 C 编写的。

PyTorch 张量几乎与 NumPy 数组相同,但有一个额外的限制,可以解锁额外的功能。它与 NumPy 数组相同,也是一个多维数据表,所有项都是相同类型的。然而,限制是张量不能使用任何旧类型 - 它必须对所有组件使用单一基本数值类型。因此,张量不像真正的数组数组那样灵活。例如,PyTorch 张量不能是不规则的。它始终是一个形状规则的多维矩形结构。

NumPy 在这些结构上支持的绝大多数方法和运算符在 PyTorch 上也支持,但 PyTorch 张量具有额外的功能。一个主要功能是这些结构可以存在于 GPU 上,这样它们的计算将被优化为 GPU,并且可以运行得更快(给定大量值进行处理)。此外,PyTorch 可以自动计算这些操作的导数,包括操作的组合。正如你将看到的,没有这种能力,实际上是不可能进行深度学习的。

Sylvain 说

如果你不知道 C 是什么,不用担心:你根本不需要它。简而言之,它是一种低级语言(低级意味着更类似于计算机内部使用的语言),与 Python 相比非常快。为了在 Python 中利用其速度,尽量避免编写循环,用直接作用于数组或张量的命令替换它们。

也许对于 Python 程序员来说,学习如何有效地使用数组/张量 API 是最重要的新编码技能。我们将在本书的后面展示更多技巧,但现在这里是你需要知道的关键事项的摘要。

要创建一个数组或张量,将列表(或列表的列表,或列表的列表的列表等)传递给arraytensor

data = [[1,2,3],[4,5,6]]
arr = array (data)
tns = tensor(data)
arr  # numpy
array([[1, 2, 3],
       [4, 5, 6]])
tns  # pytorch
tensor([[1, 2, 3],
        [4, 5, 6]])

以下所有操作都是在张量上展示的,但 NumPy 数组的语法和结果是相同的。

你可以选择一行(请注意,与 Python 中的列表一样,张量是从 0 开始索引的,所以 1 指的是第二行/列):

tns[1]
tensor([4, 5, 6])

或者通过使用:来指示所有第一个轴(我们有时将张量/数组的维度称为)选择一列。

tns[:,1]
tensor([2, 5])

你可以结合 Python 切片语法([*start*:*end*],其中end被排除)来选择一行或一列的一部分:

tns[1,1:3]
tensor([5, 6])

你可以使用标准运算符,如+-*/

tns+1
tensor([[2, 3, 4],
        [5, 6, 7]])

张量有一个类型:

tns.type()
'torch.LongTensor'

并且会根据需要自动更改该类型;例如,从intfloat

tns*1.5
tensor([[1.5000, 3.0000, 4.5000],
        [6.0000, 7.5000, 9.0000]])

那么,我们的基准模型好吗?为了量化这一点,我们必须定义一个度量。

使用广播计算度量

回想一下度量是基于我们模型的预测和数据集中正确标签计算出来的一个数字,以告诉我们我们的模型有多好。例如,我们可以使用我们在上一节中看到的两个函数之一,均方误差或平均绝对误差,并计算整个数据集上它们的平均值。然而,这两个数字对大多数人来说并不是很容易理解;实际上,我们通常使用准确度作为分类模型的度量。

正如我们讨论过的,我们想要在验证集上计算我们的度量。这样我们就不会无意中过拟合——也就是说,训练一个模型只在我们的训练数据上表现良好。这对于我们在这里作为第一次尝试使用的像素相似度模型来说并不是真正的风险,因为它没有经过训练的组件,但我们仍然会使用一个验证集来遵循正常的实践,并为我们稍后的第二次尝试做好准备。

为了获得一个验证集,我们需要完全从训练数据中删除一些数据,这样模型根本就看不到它。事实证明,MNIST 数据集的创建者已经为我们做了这个。你还记得valid这个整个独立的目录吗?这个目录就是为此而设立的!

所以,让我们从那个目录中为我们的 3 和 7 创建张量。这些是我们将用来计算度量的张量,用来衡量我们第一次尝试模型的质量,这个度量衡量了与理想图像的距离:

valid_3_tens = torch.stack([tensor(Image.open(o))
                            for o in (path/'valid'/'3').ls()])
valid_3_tens = valid_3_tens.float()/255
valid_7_tens = torch.stack([tensor(Image.open(o))
                            for o in (path/'valid'/'7').ls()])
valid_7_tens = valid_7_tens.float()/255
valid_3_tens.shape,valid_7_tens.shape
(torch.Size([1010, 28, 28]), torch.Size([1028, 28, 28]))

在进行操作时检查形状是一个好习惯。在这里我们看到两个张量,一个代表了 1,010 张大小为 28×28 的 3 的验证集,另一个代表了 1,028 张大小为 28×28 的 7 的验证集。

我们最终想要编写一个函数is_3,它将决定任意图像是 3 还是 7。它将通过确定任意图像更接近我们的两个“理想数字”中的哪一个来实现这一点。为此,我们需要定义距离的概念——即,计算两个图像之间距离的函数。

我们可以编写一个简单的函数,使用与我们在上一节中编写的表达式非常相似的表达式来计算平均绝对误差:

def mnist_distance(a,b): return (a-b).abs().mean((-1,-2))
mnist_distance(a_3, mean3)
tensor(0.1114)

这是我们先前为这两个图像之间的距离计算的相同值,理想数字 3 mean_3和任意样本 3 a_3,它们都是形状为[28,28]的单个图像张量。

但是要计算整体准确度的指标,我们需要计算验证集中每张图像到理想数字 3 的距离。我们如何进行这种计算?我们可以编写一个循环,遍历验证集张量valid_3_tens中堆叠的所有单图像张量,其形状为[1010,28,28],表示 1,010 张图像。但是有一种更好的方法。

当我们使用相同的距离函数,设计用于比较两个单个图像,但将表示 3 的验证集张量valid_3_tens作为参数传入时,会发生一些有趣的事情:

valid_3_dist = mnist_distance(valid_3_tens, mean3)
valid_3_dist, valid_3_dist.shape
(tensor([0.1050, 0.1526, 0.1186,  ..., 0.1122, 0.1170, 0.1086]),
 torch.Size([1010]))

它没有抱怨形状不匹配,而是为每个单个图像返回了一个距离(即,长度为 1,010 的秩-1 张量)。这是如何发生的?

再看看我们的函数mnist_distance,您会看到我们在那里有减法(a-b)。魔术技巧在于 PyTorch 在尝试在不同秩的两个张量之间执行简单的减法操作时,将使用广播:它将自动扩展秩较小的张量,使其大小与秩较大的张量相同。广播是一种重要的功能,使张量代码更容易编写。

在广播后,使两个参数张量具有相同的秩后,PyTorch 对于秩相同的两个张量应用其通常的逻辑:它对两个张量的每个对应元素执行操作,并返回张量结果。例如:

tensor([1,2,3]) + tensor([1,1,1])
tensor([2, 3, 4])

因此,在这种情况下,PyTorch 将mean3视为一个表示单个图像的秩-2 张量,就好像它是 1,010 个相同图像的副本,然后从我们的验证集中的每个 3 中减去每个副本。您期望这个张量的形状是什么?在查看这里的答案之前,请尝试自己想出来:

(valid_3_tens-mean3).shape
torch.Size([1010, 28, 28])

我们正在计算我们的理想数字 3 与验证集中的每个 1,010 个 3 之间的差异,对于每个 28×28 图像,结果形状为[1010,28,28]

有关广播实现的一些重要要点,使其不仅对于表达性有价值,而且对于性能也有价值:

  • PyTorch 实际上并没有将mean3复制 1,010 次。它假装它是一个具有该形状的张量,但不分配任何额外内存。

  • 它在 C 中完成整个计算(或者,如果您使用 GPU,则在 CUDA 中,相当于 GPU 上的 C),比纯 Python 快数万倍(在 GPU 上甚至快数百万倍!)。

这适用于 PyTorch 中所有广播和逐元素操作和函数。这是您要了解的最重要的技术,以创建高效的 PyTorch 代码。

接下来在mnist_distance中我们看到abs。现在您可能能猜到将其应用于张量时会发生什么。它将方法应用于张量中的每个单独元素,并返回结果的张量(即,它逐元素应用方法)。因此,在这种情况下,我们将得到 1,010 个绝对值。

最后,我们的函数调用mean((-1,-2))。元组(-1,-2)表示一系列轴。在 Python 中,-1指的是最后一个元素,-2指的是倒数第二个元素。因此,在这种情况下,这告诉 PyTorch 我们要对张量的最后两个轴的值进行平均。最后两个轴是图像的水平和垂直维度。在对最后两个轴进行平均后,我们只剩下第一个张量轴,它索引我们的图像,这就是为什么我们的最终大小是(1010)。换句话说,对于每个图像,我们对该图像中所有像素的强度进行了平均。

在本书中,我们将学习更多关于广播的知识,特别是在第十七章中,并且也会经常进行实践。

我们可以使用mnist_distance来确定一幅图像是否为 3,方法是使用以下逻辑:如果问题中的数字与理想的 3 之间的距离小于到理想的 7 的距离,则它是一个 3。这个函数将自动进行广播,并逐个应用,就像所有 PyTorch 函数和运算符一样:

def is_3(x): return mnist_distance(x,mean3) < mnist_distance(x,mean7)

让我们在我们的示例案例上测试一下:

is_3(a_3), is_3(a_3).float()
(tensor(True), tensor(1.))

请注意,当我们将布尔响应转换为浮点数时,True会得到1.0False会得到0.0

由于广播,我们还可以在所有 3 的完整验证集上进行测试:

is_3(valid_3_tens)
tensor([True, True, True,  ..., True, True, True])

现在我们可以计算每个 3 和 7 的准确率,方法是对所有 3 的函数取平均值,对所有 7 的函数取其倒数的平均值:

accuracy_3s =      is_3(valid_3_tens).float() .mean()
accuracy_7s = (1 - is_3(valid_7_tens).float()).mean()

accuracy_3s,accuracy_7s,(accuracy_3s+accuracy_7s)/2
(tensor(0.9168), tensor(0.9854), tensor(0.9511))

这看起来是一个相当不错的开始!我们在 3 和 7 上都获得了超过 90%的准确率,我们已经看到了如何使用广播方便地定义度量。但让我们诚实一点:3 和 7 是非常不同的数字。到目前为止,我们只对 10 个可能的数字中的 2 个进行分类。所以我们需要做得更好!

为了做得更好,也许现在是时候尝试一个真正学习的系统了,一个可以自动修改自身以提高性能的系统。换句话说,现在是时候谈论训练过程和 SGD 了。

随机梯度下降

你还记得 Arthur Samuel 在第一章中描述机器学习的方式吗?

假设我们安排一些自动手段来测试任何当前权重分配的有效性,以实际性能为基础,并提供一种机制来改变权重分配以最大化性能。我们不需要详细了解这种程序的细节,就可以看到它可以完全自动化,并且可以看到一个这样编程的机器会从中学习。

正如我们讨论过的,这是让我们拥有一个可以变得越来越好的模型的关键,可以学习。但我们的像素相似性方法实际上并没有做到这一点。我们没有任何权重分配,也没有任何根据测试权重分配的有效性来改进的方法。换句话说,我们无法通过修改一组参数来改进我们的像素相似性方法。为了充分利用深度学习的力量,我们首先必须按照 Samuel 描述的方式来表示我们的任务。

与其尝试找到图像与“理想图像”之间的相似性,我们可以查看每个单独的像素,并为每个像素提出一组权重,使得最高的权重与最有可能为特定类别的黑色像素相关联。例如,向右下方的像素不太可能被激活为 7,因此它们对于 7 的权重应该很低,但它们很可能被激活为 8,因此它们对于 8 的权重应该很高。这可以表示为一个函数和每个可能类别的一组权重值,例如,成为数字 8 的概率:

def pr_eight(x,w) = (x*w).sum()

在这里,我们假设X是图像,表示为一个向量—换句话说,所有行都堆叠在一起形成一个长长的单行。我们假设权重是一个向量W。如果我们有了这个函数,我们只需要一种方法来更新权重,使它们变得更好一点。通过这种方法,我们可以重复这个步骤多次,使权重变得越来越好,直到我们能够使它们尽可能好。

我们希望找到导致我们的函数对于那些是 8 的图像结果高,对于那些不是的图像结果低的向量W的特定值。搜索最佳向量W是搜索最佳函数以识别 8 的一种方式。(因为我们还没有使用深度神经网络,我们受到我们的函数能力的限制,我们将在本章后面解决这个约束。)

更具体地说,以下是将这个函数转化为机器学习分类器所需的步骤:

  1. 初始化权重。

  2. 对于每个图像,使用这些权重来预测它是 3 还是 7。

  3. 基于这些预测,计算模型有多好(它的损失)。

  4. 计算梯度,它衡量了每个权重的变化如何改变损失。

  5. 根据这个计算,改变(即,改变)所有权重。

  6. 回到步骤 2 并重复这个过程。

  7. 迭代直到你决定停止训练过程(例如,因为模型已经足够好或者你不想再等待了)。

这七个步骤,如图 4-1 所示,是所有深度学习模型训练的关键。深度学习完全依赖于这些步骤,这是非常令人惊讶和反直觉的。令人惊奇的是,这个过程可以解决如此复杂的问题。但是,正如你将看到的,它确实可以!

显示梯度下降步骤的图表

图 4-1. 梯度下降过程

每个步骤都有许多方法,我们将在本书的其余部分学习它们。这些细节对于深度学习从业者来说非常重要,但事实证明,对于每个步骤的一般方法都遵循一些基本原则。以下是一些建议:

初始化

我们将参数初始化为随机值。这可能听起来令人惊讶。我们当然可以做其他选择,比如将它们初始化为该类别激活该像素的百分比—但由于我们已经知道我们有一种方法来改进这些权重,结果证明只是从随机权重开始就可以完全正常运行。

损失

这就是 Samuel 所说的根据实际表现测试任何当前权重分配的有效性。我们需要一个函数,如果模型的表现好,它将返回一个小的数字(标准方法是将小的损失视为好的,大的损失视为坏的,尽管这只是一种约定)。

步骤

一个简单的方法来判断一个权重是否应该增加一点或减少一点就是尝试一下:增加一点权重,看看损失是增加还是减少。一旦找到正确的方向,你可以再多改变一点或少改变一点,直到找到一个效果好的量。然而,这很慢!正如我们将看到的,微积分的魔力使我们能够直接找出每个权重应该朝哪个方向改变,大概改变多少,而不必尝试所有这些小的改变。这样做的方法是通过计算梯度。这只是一种性能优化;我们也可以通过使用更慢的手动过程得到完全相同的结果。

停止

一旦我们决定要为模型训练多少个周期(之前的列表中给出了一些建议),我们就会应用这个决定。对于我们的数字分类器,我们会继续训练,直到模型的准确率开始变差,或者我们用完时间为止。

在将这些步骤应用于我们的图像分类问题之前,让我们在一个更简单的情况下看看它们是什么样子。首先我们将定义一个非常简单的函数,二次函数—假设这是我们的损失函数,x是函数的权重参数:

def f(x): return x**2

这是该函数的图表:

plot_function(f, 'x', 'x**2')

我们之前描述的步骤序列从选择参数的随机值开始,并计算损失的值:

plot_function(f, 'x', 'x**2')
plt.scatter(-1.5, f(-1.5), color='red');

现在我们来看看如果我们稍微增加或减少参数会发生什么—调整。这只是特定点的斜率:

显示在某一点的斜率的平方函数的图表

我们可以稍微改变我们的权重朝着斜坡的方向,计算我们的损失和调整,然后再重复几次。最终,我们将到达曲线上的最低点:

梯度下降的示意图

这个基本思想最早可以追溯到艾萨克·牛顿,他指出我们可以以这种方式优化任意函数。无论我们的函数变得多么复杂,梯度下降的这种基本方法不会有太大变化。我们在本书后面看到的唯一微小变化是一些方便的方法,可以让我们更快地找到更好的步骤。

计算梯度

唯一的魔法步骤是计算梯度的部分。正如我们提到的,我们使用微积分作为性能优化;它让我们更快地计算当我们调整参数时我们的损失会上升还是下降。换句话说,梯度将告诉我们我们需要改变每个权重多少才能使我们的模型更好。

您可能还记得高中微积分课上的导数告诉您函数参数的变化会如何改变其结果。如果不记得,不用担心;我们很多人高中毕业后就忘了微积分!但在继续之前,您需要对导数有一些直观的理解,所以如果您对此一头雾水,可以前往 Khan Academy 完成基本导数课程。您不必自己计算导数;您只需要知道导数是什么。

导数的关键点在于:对于任何函数,比如我们在前一节中看到的二次函数,我们可以计算它的导数。导数是另一个函数。它计算的是变化,而不是值。例如,在值为 3 时,二次函数的导数告诉我们函数在值为 3 时的变化速度。更具体地说,您可能还记得梯度被定义为上升/水平移动;也就是说,函数值的变化除以参数值的变化。当我们知道我们的函数将如何变化时,我们就知道我们需要做什么来使它变小。这是机器学习的关键:有一种方法来改变函数的参数使其变小。微积分为我们提供了一个计算的捷径,即导数,它让我们直接计算我们函数的梯度。

一个重要的事情要注意的是我们的函数有很多需要调整的权重,所以当我们计算导数时,我们不会得到一个数字,而是很多个—每个权重都有一个梯度。但在这里没有数学上的技巧;您可以计算相对于一个权重的导数,将其他所有权重视为常数,然后对每个其他权重重复这个过程。这就是计算所有梯度的方法,对于每个权重。

刚才我们提到您不必自己计算任何梯度。这怎么可能?令人惊讶的是,PyTorch 能够自动计算几乎任何函数的导数!而且,它计算得非常快。大多数情况下,它至少与您手动创建的任何导数函数一样快。让我们看一个例子。

首先,让我们选择一个张量数值,我们想要梯度:

xt = tensor(3.).requires_grad_()

注意特殊方法requires_grad_?这是我们告诉 PyTorch 我们想要计算梯度的神奇咒语。这实质上是给变量打上标记,这样 PyTorch 就会记住如何计算您要求的其他直接计算的梯度。

Alexis 说

如果您来自数学或物理学,这个 API 可能会让您困惑。在这些背景下,函数的“梯度”只是另一个函数(即,它的导数),因此您可能期望与梯度相关的 API 提供给您一个新函数。但在深度学习中,“梯度”通常意味着函数的导数在特定参数值处的。PyTorch API 也将重点放在参数上,而不是您实际计算梯度的函数。起初可能感觉有些反常,但这只是一个不同的视角。

现在我们用这个值计算我们的函数。注意 PyTorch 打印的不仅是计算的值,还有一个提示,它有一个梯度函数将在需要时用来计算我们的梯度:

yt = f(xt)
yt
tensor(9., grad_fn=<PowBackward0>)

最后,我们告诉 PyTorch 为我们计算梯度:

yt.backward()

这里的backward指的是反向传播,这是计算每一层导数的过程的名称。我们将在第十七章中看到这是如何精确完成的,当我们从头开始计算深度神经网络的梯度时。这被称为网络的反向传播,与前向传播相对,前者是计算激活的地方。如果backward只是被称为calculate_grad,生活可能会更容易,但深度学习的人确实喜欢在任何地方添加行话!

我们现在可以通过检查我们张量的grad属性来查看梯度:

xt.grad
tensor(6.)

如果您记得高中微积分规则,x**2的导数是2*x,我们有x=3,所以梯度应该是2*3=6,这就是 PyTorch 为我们计算的结果!

现在我们将重复前面的步骤,但使用一个向量参数来计算我们的函数:

xt = tensor([3.,4.,10.]).requires_grad_()
xt
tensor([ 3.,  4., 10.], requires_grad=True)

并且我们将sum添加到我们的函数中,以便它可以接受一个向量(即,一个秩为 1 的张量)并返回一个标量(即,一个秩为 0 的张量):

def f(x): return (x**2).sum()

yt = f(xt)
yt
tensor(125., grad_fn=<SumBackward0>)

我们的梯度是2*xt,正如我们所期望的!

yt.backward()
xt.grad
tensor([ 6.,  8., 20.])

梯度告诉我们函数的斜率;它们并不告诉我们要调整参数多远。但它们确实给了我们一些想法:如果斜率非常大,那可能意味着我们需要更多的调整,而如果斜率非常小,那可能意味着我们接近最优值。

使用学习率进行步进

根据梯度值来决定如何改变我们的参数是深度学习过程中的一个重要部分。几乎所有方法都从一个基本思想开始,即将梯度乘以一些小数字,称为学习率(LR)。学习率通常是 0.001 到 0.1 之间的数字,尽管它可以是任何值。通常人们通过尝试几个学习率来选择一个,并找出哪个在训练后产生最佳模型的结果(我们将在本书后面展示一个更好的方法,称为学习率查找器)。一旦选择了学习率,您可以使用这个简单函数调整参数:

w -= w.grad * lr

这被称为调整您的参数,使用优化步骤

如果您选择的学习率太低,可能意味着需要执行很多步骤。图 4-2 说明了这一点。

梯度下降示例,学习率过低

图 4-2。学习率过低的梯度下降

但选择一个学习率太高的学习率更糟糕——它可能导致损失变得更糟,正如我们在图 4-3 中看到的!

学习率过高的梯度下降示例

图 4-3. 学习率过高的梯度下降

如果学习率太高,它也可能会“弹跳”而不是发散;图 4-4 显示了这样做需要许多步骤才能成功训练。

带有弹跳学习率的梯度下降示例

图 4-4. 带有弹跳学习率的梯度下降

现在让我们在一个端到端的示例中应用所有这些。

一个端到端的 SGD 示例

我们已经看到如何使用梯度来最小化我们的损失。现在是时候看一个 SGD 示例,并看看如何找到最小值来训练模型以更好地拟合数据。

让我们从一个简单的合成示例模型开始。想象一下,您正在测量过山车通过顶峰时的速度。它会开始快速,然后随着上坡而变慢;在顶部最慢,然后在下坡时再次加速。您想建立一个关于速度随时间变化的模型。如果您每秒手动测量速度 20 秒,它可能看起来像这样:

time = torch.arange(0,20).float(); time
tensor([ 0.,  1.,  2.,  3.,  4.,  5.,  6.,  7.,  8.,  9., 10., 11., 12., 13.,
 > 14., 15., 16., 17., 18., 19.])
speed = torch.randn(20)*3 + 0.75*(time-9.5)**2 + 1
plt.scatter(time,speed);

我们添加了一些随机噪声,因为手动测量不够精确。这意味着很难回答问题:过山车的速度是多少?使用 SGD,我们可以尝试找到一个与我们的观察相匹配的函数。我们无法考虑每种可能的函数,所以让我们猜测它将是二次的;即,一个形式为a*(time**2)+(b*time)+c的函数。

我们希望清楚地区分函数的输入(我们测量过山车速度的时间)和其参数(定义我们正在尝试的二次函数的值)。因此,让我们将参数收集在一个参数中,从而在函数的签名中分离输入t和参数params

def f(t, params):
    a,b,c = params
    return a*(t**2) + (b*t) + c

换句话说,我们已经将找到最佳拟合数据的最佳函数的问题限制为找到最佳二次函数。这极大地简化了问题,因为每个二次函数都由三个参数abc完全定义。因此,要找到最佳二次函数,我们只需要找到最佳的abc的值。

如果我们可以解决二次函数的三个参数的问题,我们就能够对其他具有更多参数的更复杂函数应用相同的方法——比如神经网络。让我们先找到f的参数,然后我们将回来对 MNIST 数据集使用神经网络做同样的事情。

首先,我们需要定义“最佳”是什么意思。我们通过选择一个损失函数来精确定义这一点,该函数将根据预测和目标返回一个值,其中函数的较低值对应于“更好”的预测。对于连续数据,通常使用均方误差

def mse(preds, targets): return ((preds-targets)**2).mean()

现在,让我们按照我们的七步流程进行工作。

第一步:初始化参数

首先,我们将参数初始化为随机值,并告诉 PyTorch 我们要使用requires_grad_跟踪它们的梯度:

params = torch.randn(3).requires_grad_()

第二步:计算预测

接下来,我们计算预测:

preds = f(time, params)

让我们创建一个小函数来查看我们的预测与目标的接近程度,并看一看:

def show_preds(preds, ax=None):
    if ax is None: ax=plt.subplots()[1]
    ax.scatter(time, speed)
    ax.scatter(time, to_np(preds), color='red')
    ax.set_ylim(-300,100)
show_preds(preds)

这看起来并不接近——我们的随机参数表明过山车最终会倒退,因为我们有负速度!

第三步:计算损失

我们计算损失如下:

loss = mse(preds, speed)
loss
tensor(25823.8086, grad_fn=<MeanBackward0>)

我们的目标现在是改进这一点。为了做到这一点,我们需要知道梯度。

第四步:计算梯度

下一步是计算梯度,或者近似参数需要如何改变:

loss.backward()
params.grad
tensor([-53195.8594,  -3419.7146,   -253.8908])
params.grad * 1e-5
tensor([-0.5320, -0.0342, -0.0025])

我们可以利用这些梯度来改进我们的参数。我们需要选择一个学习率(我们将在下一章中讨论如何在实践中做到这一点;现在,我们将使用 1e-5 或 0.00001):

params
tensor([-0.7658, -0.7506,  1.3525], requires_grad=True)

第 5 步:调整权重

现在我们需要根据刚刚计算的梯度更新参数:

lr = 1e-5
params.data -= lr * params.grad.data
params.grad = None

Alexis 说

理解这一点取决于记住最近的历史。为了计算梯度,我们在loss上调用backward。但是这个loss本身是通过mse计算的,而mse又以preds作为输入,preds是使用f计算的,fparams作为输入,params是我们最初调用required_grads_的对象,这是最初的调用,现在允许我们在loss上调用backward。这一系列函数调用代表了函数的数学组合,使得 PyTorch 能够在幕后使用微积分的链式法则来计算这些梯度。

让我们看看损失是否有所改善:

preds = f(time,params)
mse(preds, speed)
tensor(5435.5366, grad_fn=<MeanBackward0>)

再看一下图表:

show_preds(preds)

我们需要重复这个过程几次,所以我们将创建一个应用一步的函数:

def apply_step(params, prn=True):
    preds = f(time, params)
    loss = mse(preds, speed)
    loss.backward()
    params.data -= lr * params.grad.data
    params.grad = None
    if prn: print(loss.item())
    return preds

第 6 步:重复这个过程

现在我们进行迭代。通过循环和进行许多改进,我们希望达到一个好的结果:

for i in range(10): apply_step(params)
5435.53662109375
1577.4495849609375
847.3780517578125
709.22265625
683.0757446289062
678.12451171875
677.1839599609375
677.0025024414062
676.96435546875
676.9537353515625

损失正在下降,正如我们所希望的!但仅仅看这些损失数字掩盖了一个事实,即每次迭代代表尝试一个完全不同的二次函数,以找到最佳可能的二次函数。如果我们不打印出损失函数,而是在每一步绘制函数,我们可以看到形状是如何接近我们的数据的最佳可能的二次函数:

_,axs = plt.subplots(1,4,figsize=(12,3))
for ax in axs: show_preds(apply_step(params, False), ax)
plt.tight_layout()

第 7 步:停止

我们刚刚决定在任意选择的 10 个 epochs 后停止。在实践中,我们会观察训练和验证损失以及我们的指标,以决定何时停止,正如我们所讨论的那样。

总结梯度下降

现在您已经看到每个步骤中发生的事情,让我们再次看一下我们的梯度下降过程的图形表示(图 4-5)并进行一个快速回顾。

显示梯度下降步骤的图表

图 4-5. 梯度下降过程

在开始时,我们模型的权重可以是随机的(从头开始训练)或来自预训练模型(迁移学习)。在第一种情况下,我们从输入得到的输出与我们想要的完全无关,即使在第二种情况下,预训练模型也可能不太擅长我们所针对的特定任务。因此,模型需要学习更好的权重。

我们首先将模型给出的输出与我们的目标进行比较(我们有标记数据,所以我们知道模型应该给出什么结果),使用一个损失函数,它返回一个数字,我们希望通过改进我们的权重使其尽可能低。为了做到这一点,我们从训练集中取出一些数据项(如图像)并将它们馈送给我们的模型。我们使用我们的损失函数比较相应的目标,我们得到的分数告诉我们我们的预测有多么错误。然后我们稍微改变权重使其稍微更好。

为了找出如何改变权重使损失稍微变好,我们使用微积分来计算梯度。(实际上,我们让 PyTorch 为我们做这个!)让我们考虑一个类比。想象一下你在山上迷路了,你的车停在最低点。为了找到回去的路,你可能会朝着随机方向走,但那可能不会有太大帮助。由于你知道你的车在最低点,你最好是往下走。通过始终朝着最陡峭的下坡方向迈出一步,你最终应该到达目的地。我们使用梯度的大小(即坡度的陡峭程度)来告诉我们应该迈多大一步;具体来说,我们将梯度乘以我们选择的一个称为学习率的数字来决定步长。然后我们迭代直到达到最低点,那将是我们的停车场;然后我们可以停止

我们刚刚看到的所有内容都可以直接转换到 MNIST 数据集,除了损失函数。现在让我们看看如何定义一个好的训练目标。

MNIST 损失函数

我们已经有了我们的x—也就是我们的自变量,图像本身。我们将它们全部连接成一个单一的张量,并且还将它们从矩阵列表(一个秩为 3 的张量)转换为向量列表(一个秩为 2 的张量)。我们可以使用view来做到这一点,view是一个 PyTorch 方法,可以改变张量的形状而不改变其内容。-1view的一个特殊参数,意思是“使这个轴尽可能大以适应所有数据”:

train_x = torch.cat([stacked_threes, stacked_sevens]).view(-1, 28*28)

我们需要为每张图片标记。我们将使用1表示 3,0表示 7:

train_y = tensor([1]*len(threes) + [0]*len(sevens)).unsqueeze(1)
train_x.shape,train_y.shape
(torch.Size([12396, 784]), torch.Size([12396, 1]))

在 PyTorch 中,当索引时,Dataset需要返回一个(x,y)元组。Python 提供了一个zip函数,当与list结合使用时,可以简单地实现这个功能:

dset = list(zip(train_x,train_y))
x,y = dset[0]
x.shape,y
(torch.Size([784]), tensor([1]))
valid_x = torch.cat([valid_3_tens, valid_7_tens]).view(-1, 28*28)
valid_y = tensor([1]*len(valid_3_tens) + [0]*len(valid_7_tens)).unsqueeze(1)
valid_dset = list(zip(valid_x,valid_y))

现在我们需要为每个像素(最初是随机的)分配一个权重(这是我们七步过程中的初始化步骤):

def init_params(size, std=1.0): return (torch.randn(size)*std).requires_grad_()
weights = init_params((28*28,1))

函数weights*pixels不够灵活—当像素等于 0 时,它总是等于 0(即其截距为 0)。你可能还记得高中数学中线的公式是y=w*x+b;我们仍然需要b。我们也会将其初始化为一个随机数:

bias = init_params(1)

在神经网络中,方程y=w*x+b中的w被称为权重b被称为偏置。权重和偏置一起构成参数

术语:参数

模型的权重偏置。权重是方程w*x+b中的w,偏置是该方程中的b

现在我们可以为一张图片计算一个预测:

(train_x[0]*weights.T).sum() + bias
tensor([20.2336], grad_fn=<AddBackward0>)

虽然我们可以使用 Python 的for循环来计算每张图片的预测,但那将非常慢。因为 Python 循环不在 GPU 上运行,而且因为 Python 在一般情况下循环速度较慢,我们需要尽可能多地使用高级函数来表示模型中的计算。

在这种情况下,有一个非常方便的数学运算可以为矩阵的每一行计算w*x—它被称为矩阵乘法。图 4-6 展示了矩阵乘法的样子。

矩阵乘法

图 4-6. 矩阵乘法

这幅图展示了两个矩阵AB相乘。结果的每个项目,我们称之为AB,包含了A的对应行的每个项目与B的对应列的每个项目相乘后相加。例如,第 1 行第 2 列(带有红色边框的黄色点)计算为。如果您需要复习矩阵乘法,我们建议您查看 Khan Academy 的“矩阵乘法简介”,因为这是深度学习中最重要的数学运算。

在 Python 中,矩阵乘法用@运算符表示。让我们试一试:

def linear1(xb): return xb@weights + bias
preds = linear1(train_x)
preds
tensor([[20.2336],
        [17.0644],
        [15.2384],
        ...,
        [18.3804],
        [23.8567],
        [28.6816]], grad_fn=<AddBackward0>)

第一个元素与我们之前计算的相同,正如我们所期望的。这个方程batch @ weights + bias是任何神经网络的两个基本方程之一(另一个是激活函数,我们马上会看到)。

让我们检查我们的准确性。为了确定输出代表 3 还是 7,我们只需检查它是否大于 0,因此我们可以计算每个项目的准确性(使用广播,因此没有循环!)如下:

corrects = (preds>0.0).float() == train_y
corrects
tensor([[ True],
        [ True],
        [ True],
        ...,
        [False],
        [False],
        [False]])
corrects.float().mean().item()
0.4912068545818329

现在让我们看看一个权重的微小变化对准确性的影响是什么:

weights[0] *= 1.0001
preds = linear1(train_x)
((preds>0.0).float() == train_y).float().mean().item()
0.4912068545818329

正如我们所看到的,我们需要梯度来通过 SGD 改进我们的模型,为了计算梯度,我们需要一个损失函数,它代表了我们的模型有多好。这是因为梯度是损失函数如何随着对权重的微小调整而变化的度量。

因此,我们需要选择一个损失函数。显而易见的方法是使用准确性作为我们的度量标准,也作为我们的损失函数。在这种情况下,我们将为每个图像计算我们的预测,收集这些值以计算总体准确性,然后计算每个权重相对于总体准确性的梯度。

不幸的是,我们在这里有一个重要的技术问题。函数的梯度是其斜率,或者是其陡峭程度,可以定义为上升与下降——也就是说,函数值上升或下降的幅度,除以我们改变输入的幅度。我们可以用数学方式写成:

(y_new – y_old) / (x_new – x_old)

x_new非常类似于x_old时,这给出了梯度的良好近似,这意味着它们的差异非常小。但是,只有当预测从 3 变为 7,或者反之时,准确性才会发生变化。问题在于,从x_oldx_new的权重的微小变化不太可能导致任何预测发生变化,因此(y_new - y_old)几乎总是为 0。换句话说,梯度几乎在任何地方都为 0。

权重值的微小变化通常不会改变准确性。这意味着使用准确性作为损失函数是没有用的——如果我们这样做,大多数时候我们的梯度将为 0,模型将无法从该数字中学习。

Sylvain 说

在数学术语中,准确性是一个几乎在任何地方都是常数的函数(除了阈值 0.5),因此它的导数几乎在任何地方都是零(在阈值处为无穷大)。这将导致梯度为 0 或无穷大,这对于更新模型是没有用的。

相反,我们需要一个损失函数,当我们的权重导致稍微更好的预测时,给出稍微更好的损失。那么,“稍微更好的预测”具体是什么样呢?在这种情况下,这意味着如果正确答案是 3,则分数稍高,或者如果正确答案是 7,则分数稍低。

现在让我们编写这样一个函数。它是什么形式?

损失函数接收的不是图像本身,而是模型的预测。因此,让我们做一个参数prds,值在 0 和 1 之间,其中每个值是图像是 3 的预测。它是一个矢量(即,一个秩-1 张量),索引在图像上。

损失函数的目的是衡量预测值与真实值之间的差异,即目标(又称标签)。因此,让我们再做一个参数trgts,其值为 0 或 1,告诉图像实际上是 3 还是不是 3。它也是一个矢量(即,另一个秩-1 张量),索引在图像上。

例如,假设我们有三幅图像,我们知道其中一幅是 3,一幅是 7,一幅是 3。假设我们的模型以高置信度(0.9)预测第一幅是 3,以轻微置信度(0.4)预测第二幅是 7,以公平置信度(0.2),但是错误地预测最后一幅是 7。这意味着我们的损失函数将接收这些值作为其输入:

trgts  = tensor([1,0,1])
prds   = tensor([0.9, 0.4, 0.2])

这是一个测量predictionstargets之间距离的损失函数的第一次尝试:

def mnist_loss(predictions, targets):
    return torch.where(targets==1, 1-predictions, predictions).mean()

我们正在使用一个新函数,torch.where(a,b,c)。这与运行列表推导[b[i] if a[i] else c[i] for i in range(len(a))]相同,只是它在张量上运行,以 C/CUDA 速度运行。简单来说,这个函数将衡量每个预测离 1 有多远,如果应该是 1 的话,以及它离 0 有多远,如果应该是 0 的话,然后它将取所有这些距离的平均值。

阅读文档

学习 PyTorch 这样的函数很重要,因为在 Python 中循环张量的速度是 Python 速度,而不是 C/CUDA 速度!现在尝试运行help(torch.where)来阅读此函数的文档,或者更好的是,在 PyTorch 文档站点上查找。

让我们在我们的prdstrgts上尝试一下:

torch.where(trgts==1, 1-prds, prds)
tensor([0.1000, 0.4000, 0.8000])

您可以看到,当预测更准确时,当准确预测更自信时(绝对值更高),以及当不准确预测更不自信时,此函数返回较低的数字。在 PyTorch 中,我们始终假设损失函数的较低值更好。由于我们需要一个标量作为最终损失,mnist_loss取前一个张量的平均值:

mnist_loss(prds,trgts)
tensor(0.4333)

例如,如果我们将对一个“错误”目标的预测从0.2更改为0.8,损失将减少,表明这是一个更好的预测:

mnist_loss(tensor([0.9, 0.4, 0.8]),trgts)
tensor(0.2333)

mnist_loss当前定义的一个问题是它假设预测总是在 0 和 1 之间。因此,我们需要确保这实际上是这种情况!恰好有一个函数可以做到这一点,让我们来看看。

Sigmoid

sigmoid函数总是输出一个介于 0 和 1 之间的数字。它的定义如下:

def sigmoid(x): return 1/(1+torch.exp(-x))

PyTorch 为我们定义了一个加速版本,所以我们不需要自己的。这是深度学习中一个重要的函数,因为我们经常希望确保数值在 0 和 1 之间。它看起来是这样的:

plot_function(torch.sigmoid, title='Sigmoid', min=-4, max=4)

正如您所看到的,它接受任何输入值,正数或负数,并将其压缩为 0 和 1 之间的输出值。它还是一个只上升的平滑曲线,这使得 SGD 更容易找到有意义的梯度。

让我们更新mnist_loss,首先对输入应用sigmoid

def mnist_loss(predictions, targets):
    predictions = predictions.sigmoid()
    return torch.where(targets==1, 1-predictions, predictions).mean()

现在我们可以确信我们的损失函数将起作用,即使预测不在 0 和 1 之间。唯一需要的是更高的预测对应更高的置信度。

定义了一个损失函数,现在是一个好时机回顾为什么这样做。毕竟,我们已经有了一个度量标准,即整体准确率。那么为什么我们定义了一个损失?

关键区别在于指标用于驱动人类理解,而损失用于驱动自动学习。为了驱动自动学习,损失必须是一个具有有意义导数的函数。它不能有大的平坦部分和大的跳跃,而必须是相当平滑的。这就是为什么我们设计了一个损失函数,可以对置信水平的小变化做出响应。这个要求意味着有时它实际上并不完全反映我们试图实现的目标,而是我们真正目标和一个可以使用其梯度进行优化的函数之间的妥协。损失函数是针对数据集中的每个项目计算的,然后在时代结束时,所有损失值都被平均,整体均值被报告为时代。

另一方面,指标是我们关心的数字。这些是在每个时代结束时打印的值,告诉我们我们的模型表现如何。重要的是,我们学会关注这些指标,而不是损失,来评估模型的性能。

SGD 和小批次

现在我们有了一个适合驱动 SGD 的损失函数,我们可以考虑学习过程的下一阶段涉及的一些细节,即根据梯度改变或更新权重。这被称为优化步骤

要进行优化步骤,我们需要计算一个或多个数据项的损失。我们应该使用多少?我们可以为整个数据集计算并取平均值,或者可以为单个数据项计算。但这两种方法都不理想。为整个数据集计算将需要很长时间。为单个数据项计算将不会使用太多信息,因此会导致不精确和不稳定的梯度。您将费力更新权重,但只考虑这将如何改善模型在该单个数据项上的性能。

因此,我们做出妥协:我们一次计算几个数据项的平均损失。这被称为小批次。小批次中的数据项数量称为批次大小。较大的批次大小意味着您将从损失函数中获得更准确和稳定的数据集梯度估计,但这将需要更长时间,并且您将在每个时代处理较少的小批次。选择一个好的批次大小是您作为深度学习从业者需要做出的决定之一,以便快速准确地训练您的模型。我们将在本书中讨论如何做出这个选择。

使用小批次而不是在单个数据项上计算梯度的另一个很好的理由是,实际上,我们几乎总是在加速器上进行训练,例如 GPU。这些加速器只有在一次有很多工作要做时才能表现良好,因此如果我们可以给它们很多数据项来处理,这将是有帮助的。使用小批次是实现这一目标的最佳方法之一。但是,如果您一次给它们太多数据来处理,它们会耗尽内存——让 GPU 保持愉快也是棘手的!

正如您在第二章中关于数据增强的讨论中所看到的,如果我们在训练过程中可以改变一些东西,我们会获得更好的泛化能力。我们可以改变的一个简单而有效的事情是将哪些数据项放入每个小批次。我们通常不是简单地按顺序枚举我们的数据集,而是在每个时代之前随机洗牌,然后创建小批次。PyTorch 和 fastai 提供了一个类,可以为您执行洗牌和小批次整理,称为DataLoader

DataLoader可以将任何 Python 集合转换为一个迭代器,用于生成多个批次,就像这样:

coll = range(15)
dl = DataLoader(coll, batch_size=5, shuffle=True)
list(dl)
[tensor([ 3, 12,  8, 10,  2]),
 tensor([ 9,  4,  7, 14,  5]),
 tensor([ 1, 13,  0,  6, 11])]

对于训练模型,我们不只是想要任何 Python 集合,而是一个包含独立和相关变量(模型的输入和目标)的集合。包含独立和相关变量元组的集合在 PyTorch 中被称为Dataset。这是一个极其简单的Dataset的示例:

ds = L(enumerate(string.ascii_lowercase))
ds
(#26) [(0, 'a'),(1, 'b'),(2, 'c'),(3, 'd'),(4, 'e'),(5, 'f'),(6, 'g'),(7,
 > 'h'),(8, 'i'),(9, 'j')...]

当我们将Dataset传递给DataLoader时,我们将得到许多批次,它们本身是表示独立和相关变量批次的张量元组:

dl = DataLoader(ds, batch_size=6, shuffle=True)
list(dl)
[(tensor([17, 18, 10, 22,  8, 14]), ('r', 's', 'k', 'w', 'i', 'o')),
 (tensor([20, 15,  9, 13, 21, 12]), ('u', 'p', 'j', 'n', 'v', 'm')),
 (tensor([ 7, 25,  6,  5, 11, 23]), ('h', 'z', 'g', 'f', 'l', 'x')),
 (tensor([ 1,  3,  0, 24, 19, 16]), ('b', 'd', 'a', 'y', 't', 'q')),
 (tensor([2, 4]), ('c', 'e'))]

我们现在准备为使用 SGD 的模型编写我们的第一个训练循环!

把所有东西放在一起

是时候实现我们在图 4-1 中看到的过程了。在代码中,我们的过程将为每个时期实现类似于这样的东西:

for x,y in dl:
    pred = model(x)
    loss = loss_func(pred, y)
    loss.backward()
    parameters -= parameters.grad * lr

首先,让我们重新初始化我们的参数:

weights = init_params((28*28,1))
bias = init_params(1)

DataLoader可以从Dataset创建:

dl = DataLoader(dset, batch_size=256)
xb,yb = first(dl)
xb.shape,yb.shape
(torch.Size([256, 784]), torch.Size([256, 1]))

我们将对验证集执行相同的操作:

valid_dl = DataLoader(valid_dset, batch_size=256)

让我们创建一个大小为 4 的小批量进行测试:

batch = train_x[:4]
batch.shape
torch.Size([4, 784])
preds = linear1(batch)
preds
tensor([[-11.1002],
        [  5.9263],
        [  9.9627],
        [ -8.1484]], grad_fn=<AddBackward0>)
loss = mnist_loss(preds, train_y[:4])
loss
tensor(0.5006, grad_fn=<MeanBackward0>)

现在我们可以计算梯度了:

loss.backward()
weights.grad.shape,weights.grad.mean(),bias.grad
(torch.Size([784, 1]), tensor(-0.0001), tensor([-0.0008]))

让我们把所有这些放在一个函数中:

def calc_grad(xb, yb, model):
    preds = model(xb)
    loss = mnist_loss(preds, yb)
    loss.backward()

并测试它:

calc_grad(batch, train_y[:4], linear1)
weights.grad.mean(),bias.grad
(tensor(-0.0002), tensor([-0.0015]))

但是看看如果我们调用两次会发生什么:

calc_grad(batch, train_y[:4], linear1)
weights.grad.mean(),bias.grad
(tensor(-0.0003), tensor([-0.0023]))

梯度已经改变了!这是因为loss.backward 添加loss的梯度到当前存储的任何梯度中。因此,我们首先必须将当前梯度设置为 0:

weights.grad.zero_()
bias.grad.zero_();

原地操作

PyTorch 中以下划线结尾的方法会原地修改它们的对象。例如,bias.zero_会将张量bias的所有元素设置为 0。

我们唯一剩下的步骤是根据梯度和学习率更新权重和偏差。当我们这样做时,我们必须告诉 PyTorch 不要对这一步骤进行梯度计算,否则当我们尝试在下一个批次计算导数时会变得混乱!如果我们将张量的data属性赋值,PyTorch 将不会对该步骤进行梯度计算。这是我们用于一个时期的基本训练循环:

def train_epoch(model, lr, params):
    for xb,yb in dl:
        calc_grad(xb, yb, model)
        for p in params:
            p.data -= p.grad*lr
            p.grad.zero_()

我们还想通过查看验证集的准确性来检查我们的表现。要决定输出是否代表 3 或 7,我们只需检查它是否大于 0。因此,我们可以计算每个项目的准确性(使用广播,所以没有循环!)如下:

(preds>0.0).float() == train_y[:4]
tensor([[False],
        [ True],
        [ True],
        [False]])

这给了我们计算验证准确性的这个函数:

def batch_accuracy(xb, yb):
    preds = xb.sigmoid()
    correct = (preds>0.5) == yb
    return correct.float().mean()

我们可以检查它是否有效:

batch_accuracy(linear1(batch), train_y[:4])
tensor(0.5000)

然后把批次放在一起:

def validate_epoch(model):
    accs = [batch_accuracy(model(xb), yb) for xb,yb in valid_dl]
    return round(torch.stack(accs).mean().item(), 4)
validate_epoch(linear1)
0.5219

这是我们的起点。让我们训练一个时期,看看准确性是否提高:

lr = 1.
params = weights,bias
train_epoch(linear1, lr, params)
validate_epoch(linear1)
0.6883

然后再做几次:

for i in range(20):
    train_epoch(linear1, lr, params)
    print(validate_epoch(linear1), end=' ')
0.8314 0.9017 0.9227 0.9349 0.9438 0.9501 0.9535 0.9564 0.9594 0.9618 0.9613
 > 0.9638 0.9643 0.9652 0.9662 0.9677 0.9687 0.9691 0.9691 0.9696

看起来不错!我们的准确性已经接近“像素相似性”方法的准确性,我们已经创建了一个通用的基础可以构建。我们的下一步将是创建一个将处理 SGD 步骤的对象。在 PyTorch 中,它被称为优化器

创建一个优化器

因为这是一个如此通用的基础,PyTorch 提供了一些有用的类来使实现更容易。我们可以做的第一件事是用 PyTorch 的nn.Linear模块替换我们的linear函数。模块是从 PyTorch nn.Module类继承的类的对象。这个类的对象的行为与标准 Python 函数完全相同,您可以使用括号调用它们,它们将返回模型的激活。

nn.Linear做的事情与我们的init_paramslinear一样。它包含了权重偏差在一个单独的类中。这是我们如何复制上一节中的模型:

linear_model = nn.Linear(28*28,1)

每个 PyTorch 模块都知道它有哪些可以训练的参数;它们可以通过parameters方法获得:

w,b = linear_model.parameters()
w.shape,b.shape
(torch.Size([1, 784]), torch.Size([1]))

我们可以使用这些信息创建一个优化器:

class BasicOptim:
    def __init__(self,params,lr): self.params,self.lr = list(params),lr

    def step(self, *args, **kwargs):
        for p in self.params: p.data -= p.grad.data * self.lr

    def zero_grad(self, *args, **kwargs):
        for p in self.params: p.grad = None

我们可以通过传入模型的参数来创建优化器:

opt = BasicOptim(linear_model.parameters(), lr)

我们的训练循环现在可以简化:

def train_epoch(model):
    for xb,yb in dl:
        calc_grad(xb, yb, model)
        opt.step()
        opt.zero_grad()

我们的验证函数不需要任何更改:

validate_epoch(linear_model)
0.4157

让我们把我们的小训练循环放在一个函数中,让事情变得更简单:

def train_model(model, epochs):
    for i in range(epochs):
        train_epoch(model)
        print(validate_epoch(model), end=' ')

结果与上一节相同:

train_model(linear_model, 20)
0.4932 0.8618 0.8203 0.9102 0.9331 0.9468 0.9555 0.9629 0.9658 0.9673 0.9687
 > 0.9707 0.9726 0.9751 0.9761 0.9761 0.9775 0.978 0.9785 0.9785

fastai 提供了SGD类,默认情况下与我们的BasicOptim做相同的事情:

linear_model = nn.Linear(28*28,1)
opt = SGD(linear_model.parameters(), lr)
train_model(linear_model, 20)
0.4932 0.852 0.8335 0.9116 0.9326 0.9473 0.9555 0.9624 0.9648 0.9668 0.9692
 > 0.9712 0.9731 0.9746 0.9761 0.9765 0.9775 0.978 0.9785 0.9785

fastai 还提供了Learner.fit,我们可以使用它来代替train_model。要创建一个Learner,我们首先需要创建一个DataLoaders,通过传入我们的训练和验证DataLoader

dls = DataLoaders(dl, valid_dl)

要创建一个Learner而不使用应用程序(如cnn_learner),我们需要传入本章中创建的所有元素:DataLoaders,模型,优化函数(将传递参数),损失函数,以及可选的任何要打印的指标:

learn = Learner(dls, nn.Linear(28*28,1), opt_func=SGD,
                loss_func=mnist_loss, metrics=batch_accuracy)

现在我们可以调用fit

learn.fit(10, lr=lr)
epoch train_loss valid_loss batch_accuracy time
0 0.636857 0.503549 0.495584 00:00
1 0.545725 0.170281 0.866045 00:00
2 0.199223 0.184893 0.831207 00:00
3 0.086580 0.107836 0.911187 00:00
4 0.045185 0.078481 0.932777 00:00
5 0.029108 0.062792 0.946516 00:00
6 0.022560 0.053017 0.955348 00:00
7 0.019687 0.046500 0.962218 00:00
8 0.018252 0.041929 0.965162 00:00
9 0.017402 0.038573 0.967615 00:00

正如您所看到的,PyTorch 和 fastai 类并没有什么神奇之处。它们只是方便的预打包部件,使您的生活变得更轻松!(它们还提供了许多我们将在未来章节中使用的额外功能。)

有了这些类,我们现在可以用神经网络替换我们的线性模型。

添加非线性

到目前为止,我们已经有了一个优化函数的一般过程,并且我们已经在一个无聊的函数上尝试了它:一个简单的线性分类器。线性分类器在能做什么方面受到限制。为了使其更复杂一些(并且能够处理更多任务),我们需要在两个线性分类器之间添加一些非线性(即与 ax+b 不同的东西)——这就是给我们神经网络的东西。

这是一个基本神经网络的完整定义:

def simple_net(xb):
    res = xb@w1 + b1
    res = res.max(tensor(0.0))
    res = res@w2 + b2
    return res

就是这样!在simple_net中,我们只有两个线性分类器,它们之间有一个max函数。

在这里,w1w2是权重张量,b1b2是偏置张量;也就是说,这些参数最初是随机初始化的,就像我们在上一节中所做的一样:

w1 = init_params((28*28,30))
b1 = init_params(30)
w2 = init_params((30,1))
b2 = init_params(1)

关键点是w1有 30 个输出激活(这意味着w2必须有 30 个输入激活,以便匹配)。这意味着第一层可以构建 30 个不同的特征,每个特征代表不同的像素混合。您可以将30更改为任何您喜欢的数字,以使模型更复杂或更简单。

那个小函数res.max(tensor(0.0))被称为修正线性单元,也被称为ReLU。我们认为我们都可以同意修正线性单元听起来相当花哨和复杂...但实际上,它不过是res.max(tensor(0.0))——换句话说,用零替换每个负数。这个微小的函数在 PyTorch 中也可以作为F.relu使用:

plot_function(F.relu)

Jeremy 说

深度学习中有大量行话,包括修正线性单元等术语。绝大多数这些行话并不比我们在这个例子中看到的一行代码更复杂。事实是,学术界为了发表论文,他们需要让论文听起来尽可能令人印象深刻和复杂。他们通过引入行话来实现这一点。不幸的是,这导致该领域变得比应该更加令人生畏和难以进入。您确实需要学习这些行话,因为否则论文和教程对您来说将毫无意义。但这并不意味着您必须觉得这些行话令人生畏。只需记住,当您遇到以前未见过的单词或短语时,它几乎肯定是指一个非常简单的概念。

基本思想是通过使用更多的线性层,我们的模型可以进行更多的计算,从而模拟更复杂的函数。但是,直接将一个线性布局放在另一个线性布局之后是没有意义的,因为当我们将事物相乘然后多次相加时,可以用不同的事物相乘然后只相加一次来替代!也就是说,一系列任意数量的线性层可以被替换为具有不同参数集的单个线性层。

但是,如果我们在它们之间放置一个非线性函数,比如max,这就不再成立了。现在每个线性层都有点解耦,可以做自己有用的工作。max函数特别有趣,因为它作为一个简单的if语句运行。

Sylvain 说

数学上,我们说两个线性函数的组合是另一个线性函数。因此,我们可以堆叠任意多个线性分类器在一起,而它们之间没有非线性函数,这将与一个线性分类器相同。

令人惊讶的是,可以数学证明这个小函数可以解决任何可计算问题,只要你能找到w1w2的正确参数,并且使这些矩阵足够大。对于任何任意波动的函数,我们可以将其近似为一堆连接在一起的线条;为了使其更接近波动函数,我们只需使用更短的线条。这被称为通用逼近定理。我们这里的三行代码被称为。第一和第三行被称为线性层,第二行代码被称为非线性激活函数

就像在前一节中一样,我们可以利用 PyTorch 简化这段代码:

simple_net = nn.Sequential(
    nn.Linear(28*28,30),
    nn.ReLU(),
    nn.Linear(30,1)
)

nn.Sequential创建一个模块,依次调用列出的每个层或函数。

nn.ReLU是一个 PyTorch 模块,与F.relu函数完全相同。大多数可以出现在模型中的函数也有相同的模块形式。通常,只需将F替换为nn并更改大小写。在使用nn.Sequential时,PyTorch 要求我们使用模块版本。由于模块是类,我们必须实例化它们,这就是为什么在这个例子中看到nn.ReLU

因为nn.Sequential是一个模块,我们可以获取它的参数,它将返回它包含的所有模块的所有参数的列表。让我们试一试!由于这是一个更深层的模型,我们将使用更低的学习率和更多的周期:

learn = Learner(dls, simple_net, opt_func=SGD,
                loss_func=mnist_loss, metrics=batch_accuracy)
learn.fit(40, 0.1)

我们这里不展示 40 行输出,以节省空间;训练过程记录在learn.recorder中,输出表存储在values属性中,因此我们可以绘制训练过程中的准确性:

plt.plot(L(learn.recorder.values).itemgot(2));

我们可以查看最终的准确性:

learn.recorder.values[-1][2]
0.982826292514801

在这一点上,我们有一些非常神奇的东西:

  • 给定正确的参数集,可以解决任何问题到任何精度的函数(神经网络)

  • 找到任何函数的最佳参数集的方法(随机梯度下降)

这就是为什么深度学习可以做出如此奇妙的事情。相信这些简单技术的组合确实可以解决任何问题是我们发现许多学生必须迈出的最大步骤之一。这似乎太好了,以至于难以置信——事情肯定应该比这更困难和复杂吧?我们的建议是:试一试!我们刚刚在 MNIST 数据集上尝试了一下,你已经看到了结果。由于我们自己从头开始做所有事情(除了计算梯度),所以你知道背后没有隐藏任何特殊的魔法。

更深入地探讨

我们不必止步于只有两个线性层。我们可以添加任意数量的线性层,只要在每对线性层之间添加一个非线性。然而,正如您将了解的那样,模型变得越深,实际中优化参数就越困难。在本书的后面,您将学习一些简单但非常有效的训练更深层模型的技巧。

我们已经知道,一个带有两个线性层的单个非线性足以逼近任何函数。那么为什么要使用更深的模型呢?原因是性能。通过更深的模型(具有更多层),我们不需要使用太多参数;事实证明,我们可以使用更小的矩阵,更多的层,获得比使用更大的矩阵和少量层获得更好的结果。

这意味着我们可以更快地训练模型,并且它将占用更少的内存。在 1990 年代,研究人员如此专注于通用逼近定理,以至于很少有人尝试超过一个非线性。这种理论但不实际的基础阻碍了该领域多年。然而,一些研究人员确实尝试了深度模型,并最终能够证明这些模型在实践中表现得更好。最终,出现了理论结果,解释了为什么会发生这种情况。今天,几乎不可能找到任何人只使用一个非线性的神经网络。

当我们使用与我们在第一章中看到的相同方法训练一个 18 层模型时会发生什么:

dls = ImageDataLoaders.from_folder(path)
learn = cnn_learner(dls, resnet18, pretrained=False,
                    loss_func=F.cross_entropy, metrics=accuracy)
learn.fit_one_cycle(1, 0.1)
时代 训练损失 验证损失 准确性 时间
0 0.082089 0.009578 0.997056 00:11

近乎 100%的准确性!这与我们简单的神经网络相比有很大的差异。但是在本书的剩余部分中,您将学习到一些小技巧,可以让您自己从头开始获得如此出色的结果。您已经了解了关键的基础知识。 (当然,即使您知道所有技巧,您几乎总是希望使用 PyTorch 和 fastai 提供的预构建类,因为它们可以帮助您省去自己考虑所有细节的麻烦。)

术语回顾

恭喜:您现在知道如何从头开始创建和训练深度神经网络了!我们经历了很多步骤才达到这一点,但您可能会惊讶于它实际上是多么简单。

既然我们已经到了这一点,现在是一个很好的机会来定义和回顾一些术语和关键概念。

神经网络包含很多数字,但它们只有两种类型:计算的数字和这些数字计算出的参数。这给我们学习最重要的两个术语:

激活

计算的数字(线性和非线性层)

参数

随机初始化并优化的数字(即定义模型的数字)

在本书中,我们经常谈论激活和参数。请记住它们具有特定的含义。它们是数字。它们不是抽象概念,而是实际存在于您的模型中的具体数字。成为一名优秀的深度学习从业者的一部分是习惯于查看您的激活和参数,并绘制它们以及测试它们是否正确运行的想法。

我们的激活和参数都包含在 张量 中。这些只是正规形状的数组—例如,一个矩阵。矩阵有行和列;我们称这些为 维度。张量的维度数是它的 等级。有一些特殊的张量:

  • 等级-0:标量

  • 等级-1:向量

  • 等级-2:矩阵

神经网络包含多个层。每一层都是线性非线性的。我们通常在神经网络中交替使用这两种类型的层。有时人们将线性层及其后续的非线性一起称为一个单独的层。是的,这很令人困惑。有时非线性被称为激活函数

表 4-1 总结了与 SGD 相关的关键概念。

表 4-1. 深度学习词汇表

术语 意义
ReLU 对负数返回 0 且不改变正数的函数。
小批量 一小组输入和标签,聚集在两个数组中。在这个批次上更新梯度下降步骤(而不是整个 epoch)。
前向传播 将模型应用于某些输入并计算预测。
损失 代表我们的模型表现如何(好或坏)的值。
梯度 损失相对于模型某个参数的导数。
反向传播 计算损失相对于所有模型参数的梯度。
梯度下降 沿着梯度相反方向迈出一步,使模型参数稍微变得更好。
学习率 当应用 SGD 更新模型参数时我们所采取的步骤的大小。

选择你的冒险 提醒

在你兴奋地想要窥探内部机制时,你选择跳过第 2 和第三章节了吗?好吧,这里提醒你现在回到第二章,因为你很快就会需要了解那些内容!

问卷调查

  1. 灰度图像在计算机上是如何表示的?彩色图像呢?

  2. MNIST_SAMPLE数据集中的文件和文件夹是如何结构化的?为什么?

  3. 解释“像素相似性”方法如何工作以对数字进行分类。

  4. 什么是列表推导?现在创建一个从列表中选择奇数并将其加倍的列表推导。

  5. 什么是秩-3 张量?

  6. 张量秩和形状之间有什么区别?如何从形状中获取秩?

  7. RMSE 和 L1 范数是什么?

  8. 如何才能比 Python 循环快几千倍地一次性对数千个数字进行计算?

  9. 创建一个包含从 1 到 9 的数字的 3×3 张量或数组。将其加倍。选择右下角的四个数字。

  10. 广播是什么?

  11. 度量通常是使用训练集还是验证集计算的?为什么?

  12. SGD 是什么?

  13. 为什么 SGD 使用小批量?

  14. SGD 在机器学习中有哪七个步骤?

  15. 我们如何初始化模型中的权重?

  16. 什么是损失?

  17. 为什么我们不能总是使用高学习率?

  18. 什么是梯度?

  19. 你需要知道如何自己计算梯度吗?

  20. 为什么我们不能将准确率作为损失函数使用?

  21. 绘制 Sigmoid 函数。它的形状有什么特别之处?

  22. 损失函数和度量之间有什么区别?

  23. 使用学习率计算新权重的函数是什么?

  24. DataLoader类是做什么的?

  25. 编写伪代码,显示每个 epoch 中 SGD 所采取的基本步骤。

  26. 创建一个函数,如果传递两个参数[1,2,3,4]'abcd',则返回[(1, 'a'), (2, 'b'), (3, 'c'), (4, 'd')]。该输出数据结构有什么特别之处?

  27. PyTorch 中的view是做什么的?

  28. 神经网络中的偏差参数是什么?我们为什么需要它们?

  29. Python 中的@运算符是做什么的?

  30. backward方法是做什么的?

  31. 为什么我们必须将梯度清零?

  32. 我们需要向Learner传递什么信息?

  33. 展示训练循环的基本步骤的 Python 或伪代码。

  34. ReLU 是什么?为值从-2+2绘制一个图。

  35. 什么是激活函数?

  36. F.relunn.ReLU之间有什么区别?

  37. 通用逼近定理表明,任何函数都可以使用一个非线性逼近得到所需的精度。那么为什么我们通常使用更多的非线性函数?

进一步研究

  1. 从头开始创建自己的Learner实现,基于本章展示的训练循环。

  2. 使用完整的 MNIST 数据集完成本章的所有步骤(不仅仅是 3 和 7)。这是一个重要的项目,需要花费相当多的时间来完成!您需要进行一些研究,以找出如何克服在途中遇到的障碍。

第五章:图像分类

原文:www.bookstack.cn/read/th-fastai-book/0661b9d7375f45ab.md

译者:飞龙

协议:CC BY-NC-SA 4.0

现在您了解了深度学习是什么、它的用途以及如何创建和部署模型,现在是时候深入了!在理想的世界中,深度学习从业者不必了解每个细节是如何在底层工作的。但事实上,我们还没有生活在理想的世界中。事实是,要使您的模型真正起作用并可靠地工作,您必须正确处理很多细节,并检查很多细节。这个过程需要能够在训练神经网络时查看内部情况,找到可能的问题,并知道如何解决它们。

因此,从本书开始,我们将深入研究深度学习的机制。计算机视觉模型的架构是什么,自然语言处理模型的架构是什么,表格模型的架构是什么等等?如何创建一个与您特定领域需求匹配的架构?如何从训练过程中获得最佳结果?如何加快速度?随着数据集的变化,您必须做出哪些改变?

我们将从重复第一章中查看的相同基本应用程序开始,但我们将做两件事:

  • 让它们变得更好。

  • 将它们应用于更多类型的数据。

为了做这两件事,我们将不得不学习深度学习难题的所有部分。这包括不同类型的层、正则化方法、优化器、如何将层组合成架构、标记技术等等。但我们不会一次性把所有这些东西都扔给你;我们将根据需要逐步引入它们,以解决与我们正在处理的项目相关的实际问题。

从狗和猫到宠物品种

在我们的第一个模型中,我们学会了如何区分狗和猫。就在几年前,这被认为是一个非常具有挑战性的任务——但今天,这太容易了!我们将无法向您展示训练模型时的细微差别,因为我们在不担心任何细节的情况下获得了几乎完美的结果。但事实证明,同一数据集还允许我们解决一个更具挑战性的问题:找出每张图像中显示的宠物品种是什么。

在第一章中,我们将应用程序呈现为已解决的问题。但这不是实际情况下的工作方式。我们从一个我们一无所知的数据集开始。然后我们必须弄清楚它是如何组合的,如何从中提取我们需要的数据,以及这些数据是什么样子的。在本书的其余部分,我们将向您展示如何在实践中解决这些问题,包括理解我们正在处理的数据以及在进行建模时测试的所有必要中间步骤。

我们已经下载了宠物数据集,并且可以使用与第一章相同的代码获取到该数据集的路径:

from fastai2.vision.all import *
path = untar_data(URLs.PETS)

现在,如果我们要理解如何从每个图像中提取每只宠物的品种,我们需要了解数据是如何布局的。数据布局的细节是深度学习难题的重要组成部分。数据通常以以下两种方式之一提供:

  • 表示数据项的个别文件,例如文本文档或图像,可能组织成文件夹或具有表示有关这些项信息的文件名

  • 数据表(例如,以 CSV 格式)中的数据,其中每行是一个项目,可能包括文件名,提供表中数据与其他格式(如文本文档和图像)中数据之间的连接

有一些例外情况——特别是在基因组学等领域,可能存在二进制数据库格式或甚至网络流——但总体而言,您将处理的绝大多数数据集将使用这两种格式的某种组合。

要查看数据集中的内容,我们可以使用ls方法:

path.ls()
(#3) [Path('annotations'),Path('images'),Path('models')]

我们可以看到这个数据集为我们提供了imagesannotations目录。数据集的网站告诉我们annotations目录包含有关宠物所在位置而不是它们是什么的信息。在本章中,我们将进行分类,而不是定位,也就是说我们关心的是宠物是什么,而不是它们在哪里。因此,我们暂时会忽略annotations目录。那么,让我们来看看images目录里面的内容:

(path/"images").ls()
(#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 笔记本使这变得容易,因为我们可以逐渐构建出可用的东西,然后用于整个数据集。在这一点上,我们必须小心不要做太多假设。例如,如果你仔细观察,你可能会注意到一些宠物品种包含多个单词,因此我们不能简单地在找到的第一个_字符处中断。为了让我们能够测试我们的代码,让我们挑选出一个这样的文件名:

fname = (path/"images").ls()[0]

从这样的字符串中提取信息的最强大和灵活的方法是使用regular expression,也称为regex。正则表达式是一种特殊的字符串,用正则表达式语言编写,它指定了一个一般规则,用于决定另一个字符串是否通过测试(即“匹配”正则表达式),并且可能用于从另一个字符串中提取特定部分。在这种情况下,我们需要一个正则表达式从文件名中提取宠物品种。

我们没有空间在这里为您提供完整的正则表达式教程,但有许多优秀的在线教程,我们知道你们中的许多人已经熟悉这个神奇的工具。如果你不熟悉,那完全没问题——这是一个让你纠正的绝佳机会!我们发现正则表达式是我们编程工具包中最有用的工具之一,我们的许多学生告诉我们,这是他们最兴奋学习的事情之一。所以赶紧去谷歌搜索“正则表达式教程”吧,然后在你看得很开心之后回到这里。书籍网站也提供了我们喜欢的教程列表。

亚历克西斯说

正则表达式不仅非常方便,而且还有有趣的起源。它们之所以被称为“regular”,是因为它们最初是“regular”语言的示例,这是乔姆斯基层次结构中最低的一级。这是语言学家诺姆·乔姆斯基开发的一种语法分类,他还写了《句法结构》,这是一项寻找人类语言基础形式语法的开创性工作。这是计算的魅力之一:你每天使用的工具可能实际上来自太空船。

当你编写正则表达式时,最好的方法是首先针对一个示例尝试。让我们使用findall方法来对fname对象的文件名尝试一个正则表达式:

re.findall(r'(.+)_\d+.jpg$', fname.name)
['great_pyrenees']

这个正则表达式提取出所有字符,直到最后一个下划线字符,只要后续字符是数字,然后是 JPEG 文件扩展名。

现在我们确认了正则表达式对示例的有效性,让我们用它来标记整个数据集。fastai 提供了许多类来帮助标记。对于使用正则表达式进行标记,我们可以使用RegexLabeller类。在这个例子中,我们使用了数据块 API,我们在第二章中看到过(实际上,我们几乎总是使用数据块 API——它比我们在第一章中看到的简单工厂方法更灵活):

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调用中一个重要的部分是我们以前没有见过的这两行:

item_tfms=Resize(460),
batch_tfms=aug_transforms(size=224, min_scale=0.75)

这些行实现了一个我们称之为预调整的 fastai 数据增强策略。预调整是一种特殊的图像增强方法,旨在最大限度地减少数据破坏,同时保持良好的性能。

预调整

我们需要我们的图像具有相同的尺寸,这样它们可以整合成张量传递给 GPU。我们还希望最小化我们执行的不同增强计算的数量。性能要求表明,我们应该尽可能将我们的增强变换组合成更少的变换(以减少计算数量和损失操作的数量),并将图像转换为统一尺寸(以便在 GPU 上更有效地处理)。

挑战在于,如果在调整大小到增强尺寸之后执行各种常见的数据增强变换,可能会引入虚假的空白区域,降低数据质量,或两者兼而有之。例如,将图像旋转 45 度会在新边界的角落区域填充空白,这不会教会模型任何东西。许多旋转和缩放操作将需要插值来创建像素。这些插值像素是从原始图像数据派生的,但质量较低。

为了解决这些挑战,预调整采用了图 5-1 中显示的两种策略:

  1. 将图像调整为相对“大”的尺寸,即明显大于目标训练尺寸。

  2. 将所有常见的增强操作(包括调整大小到最终目标大小)组合成一个,并在 GPU 上一次性执行组合操作,而不是单独执行操作并多次插值。

第一步是调整大小,创建足够大的图像,使其内部区域有多余的边距,以允许进一步的增强变换而不会产生空白区域。这个转换通过调整大小为一个正方形,使用一个大的裁剪尺寸来实现。在训练集上,裁剪区域是随机选择的,裁剪的大小被选择为覆盖图像宽度或高度中较小的那个。在第二步中,GPU 用于所有数据增强,并且所有潜在破坏性操作都一起完成,最后进行单次插值。

训练集上的预调整

图 5-1。训练集上的预调整

这张图片展示了两个步骤:

  1. 裁剪全宽或全高:这在item_tfms中,因此它应用于每个单独的图像,然后再复制到 GPU。它用于确保所有图像具有相同的尺寸。在训练集上,裁剪区域是随机选择的。在验证集上,总是选择图像的中心正方形。

  2. 随机裁剪和增强:这在batch_tfms中,因此它一次在 GPU 上应用于整个批次,这意味着速度快。在验证集上,只有调整大小到模型所需的最终大小。在训练集上,首先进行随机裁剪和任何其他增强。

要在 fastai 中实现此过程,您可以使用Resize作为具有大尺寸的项目转换,以及RandomResizedCrop作为具有较小尺寸的批处理转换。如果在aug_transforms函数中包含min_scale参数,RandomResizedCrop将为您添加,就像在上一节中的DataBlock调用中所做的那样。或者,您可以在初始Resize中使用padsquish而不是crop(默认值)。

图 5-2 显示了一个图像经过缩放、插值、旋转,然后再次插值(这是所有其他深度学习库使用的方法),显示在右侧,以及一个图像经过缩放和旋转作为一个操作,然后插值一次(fastai 方法),显示在左侧。

图 5-2。fastai 数据增强策略(左)与传统方法(右)的比较

您可以看到右侧的图像定义不够清晰,在左下角有反射填充伪影;此外,左上角的草完全消失了。我们发现,在实践中,使用预调整显著提高了模型的准确性,通常也会加快速度。

fastai 库还提供了简单的方法来检查您的数据在训练模型之前的外观,这是一个非常重要的步骤。我们将在下一步中看到这些。

检查和调试 DataBlock

我们永远不能假设我们的代码完美运行。编写DataBlock就像编写蓝图一样。如果您的代码中有语法错误,您将收到错误消息,但是您无法保证您的模板会按照您的意图在数据源上运行。因此,在训练模型之前,您应该始终检查您的数据。

您可以使用show_batch方法来执行此操作:

dls.show_batch(nrows=1, ncols=3)

查看每个图像,并检查每个图像是否具有正确的宠物品种标签。通常,数据科学家使用的数据可能不如领域专家熟悉:例如,我实际上不知道这些宠物品种中的许多是什么。由于我不是宠物品种的专家,我会在这一点上使用谷歌图像搜索一些这些品种,并确保图像看起来与我在输出中看到的相似。

如果在构建DataBlock时出现错误,您可能在此步骤之前不会看到它。为了调试这个问题,我们鼓励您使用summary方法。它将尝试从您提供的源创建一个批次,并提供大量细节。此外,如果失败,您将准确地看到错误发生的位置,并且库将尝试为您提供一些帮助。例如,一个常见的错误是忘记使用Resize转换,因此最终得到不同大小的图片并且无法将它们整理成批次。在这种情况下,摘要将如下所示(请注意,自撰写时可能已更改确切文本,但它将给您一个概念):

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")
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])

您可以看到我们如何收集数据并拆分数据,如何从文件名转换为样本(元组(图像,类别)),然后应用了哪些项目转换以及如何在批处理中无法整理这些样本(因为形状不同)。

一旦您认为数据看起来正确,我们通常建议下一步应该使用它来训练一个简单的模型。我们经常看到人们将实际模型的训练推迟得太久。结果,他们不知道他们的基准结果是什么样的。也许您的问题不需要大量花哨的领域特定工程。或者数据似乎根本无法训练模型。这些都是您希望尽快了解的事情。

对于这个初始测试,我们将使用与第一章中使用的相同简单模型:

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方法:

x,y = dls.one_batch()

正如您所见,这返回了因变量和自变量,作为一个小批量。让我们看看我们的因变量中包含什么:

y
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来查看预测(我们神经网络最后一层的激活)。这个函数默认返回预测和目标,但由于我们已经有了目标,我们可以通过将其赋值给特殊变量_来有效地忽略它们:

preds,_ = learn.get_preds(dl=[(x,y)])
preds[0]
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:

len(preds[0]),preds[0].sum()
(37, tensor(1.0000))

为了将我们模型的激活转换为这样的预测,我们使用了一个叫做softmax的激活函数。

Softmax

在我们的分类模型中,我们在最后一层使用 softmax 激活函数,以确保激活值都在 0 到 1 之间,并且它们总和为 1。

Softmax 类似于我们之前看到的 sigmoid 函数。作为提醒,sigmoid 看起来像这样:

plot_function(torch.sigmoid, min=-4,max=4)

我们可以将这个函数应用于神经网络的一个激活列,并得到一个介于 0 和 1 之间的数字列,因此对于我们的最后一层来说,这是一个非常有用的激活函数。

现在想象一下,如果我们希望目标中有更多类别(比如我们的 37 种宠物品种)。这意味着我们需要比单个列更多的激活:我们需要一个激活每个类别。例如,我们可以创建一个预测 3 和 7 的神经网络,返回两个激活,每个类别一个——这将是创建更一般方法的一个很好的第一步。让我们只是使用一些标准差为 2 的随机数(因此我们将randn乘以 2)作为示例,假设我们有六个图像和两个可能的类别(其中第一列代表 3,第二列代表 7):

acts = torch.randn((6,2))*2
acts
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):

acts.sigmoid()
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:

(acts[:,0]-acts[:,1]).sigmoid()
tensor([0.6025, 0.5021, 0.1332, 0.9966, 0.5959, 0.3661])

第二列(它是 7 的概率)将是该值从 1 中减去的值。现在,我们需要一种适用于多于两列的方法。事实证明,这个名为softmax的函数正是这样的:

def softmax(x): return exp(x) / exp(x).sum(dim=1, keepdim=True)

术语:指数函数(exp)

定义为e**x,其中e是一个特殊的数字,约等于 2.718。它是自然对数函数的倒数。请注意,exp始终为正,并且增长非常迅速!

让我们检查softmax是否为第一列返回与sigmoid相同的值,以及这些值从 1 中减去的值为第二列:

sm_acts = torch.softmax(acts, dim=1)
sm_acts
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]])

softmaxsigmoid的多类别等价物——每当我们有超过两个类别且类别的概率必须加起来为 1 时,我们必须使用它,即使只有两个类别,我们通常也会使用它,只是为了使事情更加一致。我们可以创建其他具有所有激活在 0 和 1 之间且总和为 1 的属性的函数;然而,没有其他函数与我们已经看到是平滑且对称的 sigmoid 函数具有相同的关系。此外,我们很快将看到 softmax 函数与我们将在下一节中看到的损失函数密切配合。

如果我们有三个输出激活,就像在我们的熊分类器中一样,为单个熊图像计算 softmax 看起来会像图 5-3 那样。

熊 softmax 示例

图 5-3. 熊分类器上 softmax 的示例

实际上,这个函数是做什么的呢?取指数确保我们所有的数字都是正数,然后除以总和确保我们将得到一堆加起来等于 1 的数字。指数还有一个很好的特性:如果我们激活中的某个数字略大于其他数字,指数将放大这个差异(因为它呈指数增长),这意味着在 softmax 中,该数字将更接近 1。

直观地,softmax 函数真的想要在其他类别中选择一个类别,因此在我们知道每张图片都有一个明确标签时,训练分类器时是理想的选择。(请注意,在推断过程中可能不太理想,因为有时您可能希望模型告诉您它在训练过程中看到的类别中没有识别出任何一个,并且不选择一个类别,因为它的激活分数略高。在这种情况下,最好使用多个二进制输出列来训练模型,每个列使用 sigmoid 激活。)

Softmax 是交叉熵损失的第一部分,第二部分是对数似然。

对数似然

在上一章中为我们的 MNIST 示例计算损失时,我们使用了这个:

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.whereinputs1-inputs之间进行选择。当我们将二元分类作为具有两个类别的一般分类问题处理时,它变得更容易,因为(正如我们在前一节中看到的)现在有两列包含等同于inputs1-inputs的内容。因此,我们只需要从适当的列中进行选择。让我们尝试在 PyTorch 中实现这一点。对于我们合成的 3 和 7 的示例,假设这些是我们的标签:

targ = tensor([0,1,0,1,1,0])

这些是 softmax 激活:

sm_acts
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的适当列,如下所示:

idx = range(6)
sm_acts[idx, targ]
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

从这个表中可以看出,最后一列可以通过将targidx列作为索引,指向包含37列的两列矩阵来计算。这就是sm_acts[idx, targ]的作用。

这里真正有趣的是,这种方法同样适用于超过两列的情况。想象一下,如果我们为每个数字(0 到 9)添加一个激活列,然后targ包含从 0 到 9 的数字。只要激活列总和为 1(如果我们使用 softmax,它们将是这样),我们将有一个损失函数,显示我们预测每个数字的准确程度。

我们只从包含正确标签的列中选择损失。我们不需要考虑其他列,因为根据 softmax 的定义,它们加起来等于 1 减去与正确标签对应的激活。因此,使正确标签的激活尽可能高必须意味着我们也在降低其余列的激活。

PyTorch 提供了一个与sm_acts[range(n), targ]完全相同的函数(除了它取负数,因为之后应用对数时,我们将得到负数),称为nll_lossNLL代表负对数似然):

-sm_acts[idx, targ]
tensor([-0.6025, -0.4979, -0.1332, -0.0034, -0.4041, -0.3661])
F.nll_loss(sm_acts, targ, reduction='none')
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 的数字没有定义,并且如下所示:

plot_function(torch.log, min=0,max=4)

“对数”这个词让你想起了什么吗?对数函数有这个恒等式:

y = b**a
a = log(y,b)

在这种情况下,我们假设log(y,b)返回log y 以 b 为底。然而,PyTorch 并没有这样定义log:Python 中的log使用特殊数字e(2.718…)作为底。

也许对数是您在过去 20 年中没有考虑过的东西。但对于深度学习中的许多事情来说,对数是一个非常关键的数学概念,所以现在是一个很好的时机来刷新您的记忆。关于对数的关键事情是这样的关系:

log(a*b) = log(a)+log(b)

当我们以这种格式看到它时,它看起来有点无聊;但想想这实际上意味着什么。这意味着当基础信号呈指数或乘法增长时,对数会线性增加。例如,在地震严重程度的里氏震级和噪音级别的分贝尺中使用。它也经常用于金融图表中,我们希望更清楚地显示复合增长率。计算机科学家喜欢使用对数,因为这意味着可以用加法代替修改,这样可以避免产生计算机难以处理的难以处理的规模。

Sylvain 说

不仅是计算机科学家喜欢对数!在计算机出现之前,工程师和科学家使用一种称为滑尺的特殊尺子,通过添加对数来进行乘法运算。对数在物理学中被广泛用于乘法非常大或非常小的数字,以及许多其他领域。

对我们的概率取正对数或负对数的平均值(取决于是否是正确或不正确的类)给出了负对数似然损失。在 PyTorch 中,nll_loss假设您已经对 softmax 取了对数,因此不会为您执行对数运算。

令人困惑的名称,注意

nll_loss中的“nll”代表“负对数似然”,但实际上它根本不进行对数运算!它假设您已经已经进行了对数运算。PyTorch 有一个名为log_softmax的函数,以快速准确的方式结合了logsoftmaxnll_loss设计用于在log_softmax之后使用。

当我们首先进行 softmax,然后对其进行对数似然,这种组合被称为交叉熵损失。在 PyTorch 中,这可以通过nn.CrossEntropyLoss来实现(实际上执行log_softmax然后nll_loss):

loss_func = nn.CrossEntropyLoss()

正如您所看到的,这是一个类。实例化它会给您一个像函数一样行为的对象:

loss_func(acts, targ)
tensor(1.8045)

所有 PyTorch 损失函数都以两种形式提供,刚刚显示的类形式以及在F命名空间中提供的普通函数形式:

F.cross_entropy(acts, targ)
tensor(1.8045)

两者都可以正常工作,并且可以在任何情况下使用。我们注意到大多数人倾向于使用类版本,并且在 PyTorch 的官方文档和示例中更常见,因此我们也会倾向于使用它。

默认情况下,PyTorch 损失函数取所有项目的损失的平均值。您可以使用reduction='none'来禁用这一点:

nn.CrossEntropyLoss(reduction='none')(acts, targ)
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)。由于梯度是线性的,我们不会看到梯度的突然跳跃或指数增加,这应该导致模型的平滑训练。

我们现在已经看到了隐藏在我们损失函数背后的所有部分。但是,虽然这可以对我们的模型表现如何(好或坏)进行评估,但它对于帮助我们知道它是否好并没有任何帮助。现在让我们看看一些解释我们模型预测的方法。

模型解释

直接解释损失函数非常困难,因为它们被设计为计算机可以区分和优化的东西,而不是人类可以理解的东西。这就是为什么我们有指标。这些指标不用于优化过程,而只是帮助我们这些可怜的人类理解发生了什么。在这种情况下,我们的准确率已经看起来相当不错!那么我们在哪里犯了错误呢?

我们在第一章中看到,我们可以使用混淆矩阵来查看模型表现好和表现不佳的地方:

interp = ClassificationInterpretation.from_learner(learn)
interp.plot_confusion_matrix(figsize=(12,12), dpi=60)

哦,亲爱的——在这种情况下,混淆矩阵很难阅读。我们有 37 种宠物品种,这意味着在这个巨大矩阵中有 37×37 个条目!相反,我们可以使用most_confused方法,它只显示混淆矩阵中预测错误最多的单元格(这里至少有 5 个或更多):

interp.most_confused(min_val=5)
[('american_pit_bull_terrier', 'staffordshire_bull_terrier', 10),
 ('Ragdoll', 'Birman', 6)]

由于我们不是宠物品种专家,很难知道这些类别错误是否反映了识别品种时的实际困难。因此,我们再次求助于谷歌。一点点搜索告诉我们,这里显示的最常见的类别错误是即使是专家育种者有时也会对其存在分歧的品种差异。因此,这让我们有些安慰,我们正在走在正确的道路上。

我们似乎有一个良好的基线。现在我们可以做些什么来使它变得更好呢?

改进我们的模型

我们现在将探讨一系列技术,以改进我们模型的训练并使其更好。在此过程中,我们将更详细地解释迁移学习以及如何尽可能最好地微调我们的预训练模型,而不破坏预训练权重。

在训练模型时,我们需要设置的第一件事是学习率。我们在上一章中看到,它需要恰到好处才能尽可能高效地训练,那么我们如何选择一个好的学习率呢?fastai 提供了一个工具来帮助。

学习率查找器

在训练模型时,我们可以做的最重要的事情之一是确保我们有正确的学习率。如果我们的学习率太低,训练模型可能需要很多个 epoch。这不仅浪费时间,还意味着我们可能会出现过拟合的问题,因为每次完整地遍历数据时,我们都给了模型记住数据的机会。

那么我们就把学习率调得很高,对吗?当然,让我们试试看会发生什么:

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):

learn = cnn_learner(dls, resnet34, metrics=error_rate)
lr_min,lr_steep = learn.lr_find()

print(f"Minimum/10: {lr_min:.2e}, steepest point: {lr_steep:.2e}")
Minimum/10: 8.32e-03, steepest point: 6.31e-03

我们可以看到在 1e-6 到 1e-3 的范围内,没有什么特别的事情发生,模型不会训练。然后损失开始减少,直到达到最小值,然后再次增加。我们不希望学习率大于 1e-1,因为这会导致训练发散(您可以自行尝试),但 1e-1 已经太高了:在这个阶段,我们已经离开了损失稳定下降的阶段。

在这个学习率图中,看起来学习率约为 3e-3 可能是合适的,所以让我们选择这个:

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 论文中看到的那样,在第一章中(参见图 1-10 到 1-13),前几层编码了一般概念,比如找到梯度和边缘,后面的层编码了对我们仍然有用的概念,比如找到眼球和毛发。

我们希望以这样的方式训练模型,使其能够记住预训练模型中的所有这些通常有用的想法,用它们来解决我们的特定任务(分类宠物品种),并仅根据我们特定任务的具体要求进行调整。

在微调时,我们的挑战是用能够正确实现我们所需任务(分类宠物品种)的权重替换我们添加的线性层中的随机权重,而不破坏精心预训练的权重和其他层。一个简单的技巧可以实现这一点:告诉优化器仅更新那些随机添加的最终层中的权重。根本不要改变神经网络的其他部分的权重。这被称为冻结那些预训练的层。

当我们从预训练网络创建模型时,fastai 会自动为我们冻结所有预训练层。当我们调用fine_tune方法时,fastai 会做两件事:

  • 训练随机添加的层一个周期,同时冻结所有其他层

  • 解冻所有层,并根据请求的周期数进行训练

尽管这是一个合理的默认方法,但对于您的特定数据集,您可能通过稍微不同的方式做事情来获得更好的结果。fine_tune方法有一些参数可以用来改变其行为,但如果您想获得自定义行为,直接调用底层方法可能更容易。请记住,您可以使用以下语法查看该方法的源代码:

learn.fine_tune??

所以让我们尝试手动操作。首先,我们将使用fit_one_cycle训练随机添加的层三个周期。正如在第一章中提到的,fit_one_cycle是在不使用fine_tune的情况下训练模型的建议方法。我们将在本书后面看到原因;简而言之,fit_one_cycle的作用是以低学习率开始训练,逐渐增加学习率进行第一部分的训练,然后在最后一部分的训练中逐渐降低学习率:

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

然后我们将解冻模型:

learn.unfreeze()

并再次运行lr_find,因为有更多层要训练,而且已经训练了三个周期的权重,意味着我们之前找到的学习率不再合适:

learn.lr_find()
(1.0964782268274575e-05, 1.5848931980144698e-06)

请注意,图表与随机权重时有所不同:我们没有那种表明模型正在训练的陡峭下降。这是因为我们的模型已经训练过了。在这里,我们有一个相对平坦的区域,然后是一个急剧增加的区域,我们应该选择在那个急剧增加之前的一个点,例如 1e-5。具有最大梯度的点不是我们在这里寻找的,应该被忽略。

让我们以适当的学习率进行训练:

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 等人在 2014 年展示的见解,即在迁移学习中,神经网络的不同层应该以不同的速度训练,如图 5-4 所示。

不同层和训练方法对迁移学习的影响(Yosinski)

图 5-4。不同层和训练方法对迁移学习的影响(由 Jason Yosinski 等人提供)

fastai 允许您在任何需要学习率的地方传递 Python slice对象。传递的第一个值将是神经网络最早层的学习率,第二个值将是最后一层的学习率。中间的层将在该范围内等距地乘法地具有学习率。让我们使用这种方法复制先前的训练,但这次我们只将我们网络的最低层的学习率设置为 1e-6;其他层将增加到 1e-4。让我们训练一段时间,看看会发生什么:

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 可以展示训练和验证损失的图表:

learn.recorder.plot_loss()

正如你所看到的,训练损失一直在变得越来越好。但请注意,最终验证损失的改善会减缓,有时甚至会变得更糟!这是模型开始过拟合的时候。特别是,模型开始对其预测变得过于自信。但这并不意味着它一定变得不准确。看一下每个 epoch 的训练结果表,你会经常看到准确率持续提高,即使验证损失变得更糟。最终,重要的是你的准确率,或者更一般地说是你选择的指标,而不是损失。损失只是我们给计算机的函数,帮助我们优化。

在训练模型时,你还需要做出的另一个决定是训练多长时间。我们将在下面考虑这个问题。

选择 epochs 的数量

通常情况下,你会发现在选择训练多少个 epochs 时,你受到的限制更多是时间,而不是泛化和准确性。因此,你训练的第一步应该是简单地选择一个你愿意等待的时间内可以完成的 epochs 数量。然后查看训练和验证损失图,特别是你的指标。如果你看到它们甚至在最后几个 epochs 中仍在变得更好,那么你就知道你没有训练得太久。

另一方面,你可能会发现你选择的指标在训练结束时确实变得更糟。记住,我们不仅仅是在寻找验证损失变得更糟,而是实际的指标。你的验证损失在训练过程中会先变得更糟,因为模型变得过于自信,只有后来才会因为错误地记忆数据而变得更糟。在实践中,我们只关心后一种情况。记住,我们的损失函数是我们用来让优化器有东西可以区分和优化的,实际上我们关心的不是这个。

在 1cycle 训练出现之前,通常会在每个 epoch 结束时保存模型,然后从所有保存的模型中选择准确率最高的模型。这被称为早停。然而,这不太可能给出最好的答案,因为那些中间的 epochs 出现在学习率还没有机会达到小值的情况下,这时它才能真正找到最佳结果。因此,如果你发现你过拟合了,你应该重新从头开始训练模型,并根据之前找到最佳结果的地方选择一个总的 epochs 数量。

如果你有时间训练更多的 epochs,你可能会选择用这段时间来训练更多的参数,也就是使用更深的架构。

更深的架构

一般来说,具有更多参数的模型可以更准确地对数据进行建模。(对于这个泛化有很多很多的例外情况,这取决于你使用的架构的具体情况,但现在这是一个合理的经验法则。)对于我们将在本书中看到的大多数架构,你可以通过简单地添加更多层来创建更大的版本。然而,由于我们想使用预训练模型,我们需要确保选择已经为我们预训练的层数。

这就是为什么在实践中,架构往往只有少数几种变体。例如,在本章中使用的 ResNet 架构有 18、34、50、101 和 152 层的变体,都是在 ImageNet 上预训练的。一个更大的(更多层和参数;有时被描述为模型的容量)ResNet 版本总是能够给我们更好的训练损失,但它可能更容易过拟合,因为它有更多参数可以过拟合。

总的来说,一个更大的模型能够更好地捕捉数据的真实基本关系,以及捕捉和记忆你个别图像的具体细节。

然而,使用更深的模型将需要更多的 GPU 内存,因此你可能需要降低批量大小以避免内存不足错误。当你尝试将太多内容装入 GPU 时,就会发生这种情况,看起来像这样:

Cuda runtime error: out of memory

当发生这种情况时,你可能需要重新启动你的笔记本。解决方法是使用较小的批量大小,这意味着在任何给定时间通过你的模型传递较小的图像组。你可以通过使用bs=创建你想要的批量大小来调用。

更深层次架构的另一个缺点是训练时间要长得多。一个可以大大加快速度的技术是混合精度训练。这指的是在训练过程中尽可能使用不那么精确的数字(半精度浮点数,也称为 fp16)。截至 2020 年初,几乎所有当前的 NVIDIA GPU 都支持一种特殊功能,称为张量核心,可以将神经网络训练速度提高 2-3 倍。它们还需要更少的 GPU 内存。要在 fastai 中启用此功能,只需在创建Learner后添加to_fp16()(你还需要导入模块)。

你实际上无法提前知道适合你特定问题的最佳架构——你需要尝试一些训练。所以现在让我们尝试使用混合精度的 ResNet-50:

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 上调整到较小尺寸?

  2. 如果您不熟悉正则表达式,请查找正则表达式教程和一些问题集,并完成它们。查看书籍网站以获取建议。

  3. 对于大多数深度学习数据集,数据通常以哪两种方式提供?

  4. 查阅L的文档,并尝试使用它添加的一些新方法。

  5. 查阅 Python pathlib模块的文档,并尝试使用Path类的几种方法。

  6. 给出两个图像转换可能降低数据质量的示例。

  7. fastai 提供了哪种方法来查看DataLoaders中的数据?

  8. fastai 提供了哪种方法来帮助您调试DataBlock

  9. 在彻底清理数据之前,是否应该暂停训练模型?

  10. 在 PyTorch 中,交叉熵损失是由哪两个部分组合而成的?

  11. softmax 确保的激活函数的两个属性是什么?为什么这很重要?

  12. 何时可能希望激活函数不具有这两个属性?

  13. 自己计算图 5-3 中的expsoftmax列(即在电子表格、计算器或笔记本中)。

  14. 为什么我们不能使用torch.where为标签可能有多于两个类别的数据集创建损失函数?

  15. log(-2)的值是多少?为什么?

  16. 选择学习率时有哪两个好的经验法则来自学习率查找器?

  17. fine_tune方法执行了哪两个步骤?

  18. 在 Jupyter Notebook 中,如何获取方法或函数的源代码?

  19. 什么是区分性学习率?

  20. 当将 Python slice对象作为学习率传递给 fastai 时,它是如何解释的?

  21. 为什么在使用 1cycle 训练时,提前停止是一个不好的选择?

  22. resnet50resnet101之间有什么区别?

  23. to_fp16是做什么的?

进一步研究

  1. 找到 Leslie Smith 撰写的介绍学习率查找器的论文,并阅读。

  2. 看看是否可以提高本章分类器的准确性。您能达到的最佳准确性是多少?查看论坛和书籍网站,看看其他学生在这个数据集上取得了什么成就以及他们是如何做到的。

第六章:其他计算机视觉问题

原文:www.bookstack.cn/read/th-fastai-book/f1ed7978537dabc2.md

译者:飞龙

协议:CC BY-NC-SA 4.0

在上一章中,你学习了一些在实践中训练模型的重要技术。选择学习率和周期数等考虑因素对于获得良好结果非常重要。

在本章中,我们将看到另外两种计算机视觉问题:多标签分类和回归。第一种情况发生在你想要预测每个图像的多个标签(有时甚至没有标签),第二种情况发生在你的标签是一个或多个数字——数量而不是类别。

在这个过程中,我们将更深入地研究深度学习模型中的输出激活、目标和损失函数。

多标签分类

多标签分类指的是识别图像中可能不只包含一种对象类别的问题。可能有多种对象,或者在你寻找的类别中根本没有对象。

例如,这对我们的熊分类器来说是一个很好的方法。我们在第二章中推出的熊分类器的一个问题是,如果用户上传了任何不是熊的东西,模型仍然会说它是灰熊、黑熊或泰迪熊之一——它无法预测“根本不是熊”。事实上,在我们完成本章后,你可以回到你的图像分类器应用程序,尝试使用多标签技术重新训练它,然后通过传入一个不属于你识别类别的图像来测试它。

实际上,我们并没有看到很多人为这个目的训练多标签分类器的例子——但我们经常看到用户和开发人员抱怨这个问题。看起来这个简单的解决方案并不被广泛理解或赞赏!因为在实践中,很可能有一些图像没有匹配项或有多个匹配项,所以我们应该预期在实践中,多标签分类器比单标签分类器更具普适性。

首先让我们看看多标签数据集是什么样的;然后我们将解释如何准备好供我们的模型使用。你会发现模型的架构与前一章并没有改变;只有损失函数改变了。让我们从数据开始。

数据

对于我们的示例,我们将使用 PASCAL 数据集,该数据集中的每个图像可以有多种分类对象。

我们首先按照通常的方式下载和提取数据集:

from fastai.vision.all import *
path = untar_data(URLs.PASCAL_2007)

这个数据集与我们之前看到的不同,它不是按文件名或文件夹结构化的,而是附带一个 CSV 文件,告诉我们每个图像要使用的标签。我们可以通过将其读入 Pandas DataFrame 来检查 CSV 文件:

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时,我们逐步逐步构建,并使用笔记本检查我们的数据。这是一个很好的方式,可以确保您在编码时保持动力,并留意任何问题。易于调试,因为您知道如果出现问题,它就在您刚刚输入的代码行中!

让我们从没有参数创建的数据块开始,这是最简单的情况:

dblock = DataBlock()

我们可以从中创建一个Datasets对象。唯一需要的是一个源——在这种情况下是我们的 DataFrame:

dsets = dblock.datasets(df)

这包含一个train和一个valid数据集,我们可以对其进行索引:

dsets.train[0]
(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_xget_y函数来实现:

dblock = DataBlock(get_x = lambda r: r['fname'], get_y = lambda r: r['labels'])
dsets = dblock.datasets(df)
dsets.train[0]
('005620.jpg', 'aeroplane')

正如您所看到的,我们并没有以通常的方式定义函数,而是使用了 Python 的lambda关键字。这只是定义并引用函数的一种快捷方式。以下更冗长的方法是相同的:

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]
('002549.jpg', 'tvmonitor')

Lambda 函数非常适合快速迭代,但不兼容序列化,因此我们建议您在训练后要导出您的Learner时使用更冗长的方法(如果您只是在尝试实验,lambda 是可以的)。

我们可以看到独立变量需要转换为完整路径,以便我们可以将其作为图像打开,而因变量需要根据空格字符(这是 Python 的split函数的默认值)进行拆分,以便它变成一个列表:

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]
(Path('/home/sgugger/.fastai/data/pascal_2007/train/008663.jpg'),
 ['car', 'person'])

要实际打开图像并将其转换为张量,我们需要使用一组转换;块类型将为我们提供这些。我们可以使用先前使用过的相同块类型,只有一个例外:ImageBlock将再次正常工作,因为我们有一个指向有效图像的路径,但CategoryBlock不会起作用。问题在于该块返回一个单个整数,但我们需要为每个项目有多个标签。为了解决这个问题,我们使用MultiCategoryBlock。这种类型的块期望接收一个字符串列表,就像我们在这种情况下所做的那样,所以让我们来测试一下:

dblock = DataBlock(blocks=(ImageBlock, MultiCategoryBlock),
                   get_x = get_x, get_y = get_y)
dsets = dblock.datasets(df)
dsets.train[0]
(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函数,告诉我们条件为真或假的所有索引):

idxs = torch.where(dsets.train[0][1]==1.)[0]
dsets.train.vocab[idxs]
(#1) ['dog']

使用 NumPy 数组、PyTorch 张量和 fastai 的L类,我们可以直接使用列表或向量进行索引,这使得很多代码(比如这个例子)更清晰、更简洁。

到目前为止,我们忽略了列is_valid,这意味着DataBlock一直在使用默认的随机拆分。要明确选择我们验证集的元素,我们需要编写一个函数并将其传递给splitter(或使用 fastai 的预定义函数或类之一)。它将获取项目(这里是我们整个 DataFrame)并必须返回两个(或更多)整数列表:

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]
(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.]))

正如我们讨论过的,DataLoaderDataset中的项目整理成一个小批量。这是一个张量的元组,其中每个张量简单地堆叠了Dataset项目中该位置的项目。

现在我们已经确认了单个项目看起来没问题,还有一步,我们需要确保我们可以创建我们的DataLoaders,即确保每个项目的大小相同。为了做到这一点,我们可以使用RandomResizedCrop

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)

现在我们可以显示我们数据的一个样本:

dls.show_batch(nrows=1, ncols=3)

请记住,如果在从DataBlock创建DataLoaders时出现任何问题,或者如果您想查看DataBlock的确切情况,您可以使用我们在上一章中介绍的summary方法。

我们的数据现在已经准备好用于训练模型。正如我们将看到的,当我们创建我们的Learner时,没有任何变化,但在幕后,fastai 库将为我们选择一个新的损失函数:二元交叉熵。

二元交叉熵

现在我们将创建我们的Learner。我们在第四章中看到,Learner对象包含四个主要内容:模型、DataLoaders对象、优化器和要使用的损失函数。我们已经有了我们的DataLoaders,我们可以利用 fastai 的resnet模型(稍后我们将学习如何从头开始创建),并且我们知道如何创建一个SGD优化器。因此,让我们专注于确保我们有一个合适的损失函数。为此,让我们使用cnn_learner创建一个Learner,这样我们就可以查看它的激活:

learn = cnn_learner(dls, resnet18)

我们还看到,Learner中的模型通常是从nn.Module继承的类的对象,并且我们可以使用括号调用它,它将返回模型的激活。你应该将独立变量作为一个小批量传递给它。我们可以尝试从我们的DataLoader中获取一个小批量,然后将其传递给模型:

x,y = dls.train.one_batch()
activs = learn.model(x)
activs.shape
torch.Size([64, 20])

想想为什么activs有这种形状——我们的批量大小为 64,我们需要计算 20 个类别中的每一个的概率。这是其中一个激活的样子:

activs[0]
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

def binary_cross_entropy(inputs, targets):
    inputs = inputs.sigmoid()
    return -torch.where(targets==1, inputs, 1-inputs).log().mean()

请注意,由于我们有一个独热编码的因变量,我们不能直接使用nll_losssoftmax(因此我们不能使用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_lossnn.NLLLoss(没有初始 softmax 的版本),以及F.cross_entropynn.CrossEntropyLoss(具有初始 softmax 的版本)。

由于我们有一个独热编码的目标,我们将使用BCEWithLogitsLoss

loss_func = nn.BCEWithLogitsLoss()
loss = loss_func(activs, y)
loss
tensor(1.0082, device='cuda:5', grad_fn=<BinaryCrossEntropyWithLogitsBackward>)

我们不需要告诉 fastai 使用这个损失函数(尽管如果我们想要的话可以这样做),因为它将自动为我们选择。fastai 知道DataLoaders具有多个类别标签,因此默认情况下将使用nn.BCEWithLogitsLoss

与前一章相比的一个变化是我们使用的指标:因为这是一个多标签问题,我们不能使用准确度函数。为什么呢?嗯,准确度是这样比较我们的输出和我们的目标的:

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:

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的函数。它允许我们绑定一个带有一些参数或关键字参数的函数,从而创建该函数的新版本,每当调用它时,总是包含这些参数。例如,这里是一个接受两个参数的简单函数:

def say_hello(name, say_what="Hello"): return f"{say_what} {name}."
say_hello('Jeremy'),say_hello('Jeremy', 'Ahoy!')
('Hello Jeremy.', 'Ahoy! Jeremy.')

我们可以通过使用partial切换到该函数的法语版本:

f = partial(say_hello, say_what="Bonjour")
f("Jeremy"),f("Sylvain")
('Bonjour Jeremy.', 'Bonjour Sylvain.')

现在我们可以训练我们的模型。让我们尝试将准确度阈值设置为 0.2 作为我们的指标:

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来看到这一点,它会返回验证损失和度量标准:

learn.metrics = partial(accuracy_multi, thresh=0.1)
learn.validate()
(#2) [0.10436797887086868,0.93057781457901]

如果选择的阈值太高,将只选择模型非常有信心的对象:

learn.metrics = partial(accuracy_multi, thresh=0.99)
learn.validate()
(#2) [0.10436797887086868,0.9416930675506592]

我们可以通过尝试几个级别并查看哪个效果最好来找到最佳阈值。如果我们只抓取一次预测,这将快得多:

preds,targs = learn.get_preds()

然后我们可以直接调用度量标准。请注意,默认情况下,get_preds会为我们应用输出激活函数(在本例中为 sigmoid),因此我们需要告诉accuracy_multi不要应用它:

accuracy_multi(preds, targs, thresh=0.9, sigmoid=False)
TensorMultiCategory(0.9554)

现在我们可以使用这种方法找到最佳阈值水平:

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);

在这种情况下,我们使用验证集来选择一个超参数(阈值),这就是验证集的目的。有时学生们表达了他们的担忧,即我们可能会对验证集过拟合,因为我们正在尝试很多值来找出哪个是最好的。然而,正如你在图中看到的,改变阈值在这种情况下会产生一个平滑的曲线,因此我们显然没有选择不合适的异常值。这是一个很好的例子,说明你必须小心理论(不要尝试很多超参数值,否则可能会过拟合验证集)与实践(如果关系是平滑的,这样做是可以的)之间的区别。

这结束了本章专门讨论多标签分类的部分。接下来,我们将看一下回归问题。

回归

很容易将深度学习模型视为被分类到领域中,如计算机视觉NLP等等。事实上,这就是 fastai 对其应用程序进行分类的方式——主要是因为大多数人习惯于这样思考事物。

但实际上,这隐藏了一个更有趣和更深入的视角。一个模型由其独立和依赖变量以及其损失函数定义。这意味着实际上有比简单的基于领域的分割更广泛的模型数组。也许我们有一个独立变量是图像,一个依赖变量是文本(例如,从图像生成标题);或者我们有一个独立变量是文本,一个依赖变量是图像(例如,从标题生成图像——这实际上是深度学习可以做到的!);或者我们有图像、文本和表格数据作为独立变量,我们试图预测产品购买……可能性真的是无穷无尽的。

要能够超越固定应用程序,为新问题制定自己的新颖解决方案,真正理解数据块 API(也许还有我们将在本书后面看到的中间层 API)是有帮助的。举个例子,让我们考虑图像回归的问题。这指的是从一个独立变量是图像,依赖变量是一个或多个浮点数的数据集中学习。通常我们看到人们将图像回归视为一个完全独立的应用程序——但正如你在这里看到的,我们可以将其视为数据块 API 上的另一个 CNN。

我们将直接跳到图像回归的一个有点棘手的变体,因为我们知道你已经准备好了!我们将做一个关键点模型。关键点指的是图像中表示的特定位置——在这种情况下,我们将使用人物的图像,并且我们将寻找每个图像中人脸的中心。这意味着我们实际上将为每个图像预测两个值:人脸中心的行和列。

数据组装

我们将在这一部分使用Biwi Kinect Head Pose 数据集。我们将像往常一样开始下载数据集:

path = untar_data(URLs.BIWI_HEAD_POSE)

让我们看看我们有什么!

path.ls()
(#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文件(我们这里不需要)。让我们看看其中一个目录的内容:

(path/'01').ls()
(#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轻松递归获取所有图像文件,然后编写一个函数,将图像文件名转换为其关联的姿势文件:

img_files = get_image_files(path)
def img2pose(x): return Path(f'{str(x)[:-7]}pose.txt')
img2pose(img_files[0])
Path('13/frame_00349_pose.txt')

让我们来看看我们的第一张图片:

im = PILImage.create(img_files[0])
im.shape
(480, 640)
im.to_thumb(160)

Biwi 数据集网站用于解释与每个图像关联的姿势文本文件的格式,显示头部中心的位置。这些细节对我们来说并不重要,所以我们只会展示我们用来提取头部中心点的函数:

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])

这个函数将坐标作为两个项目的张量返回:

get_ctr(img_files[0])
tensor([384.6370, 259.4787])

我们可以将此函数传递给DataBlock作为get_y,因为它负责为每个项目标记。我们将将图像调整为其输入大小的一半,以加快训练速度。

一个重要的要点是我们不应该只使用随机分割器。在这个数据集中,同一个人出现在多个图像中,但我们希望确保我们的模型可以泛化到它尚未见过的人。数据集中的每个文件夹包含一个人的图像。因此,我们可以创建一个分割器函数,仅为一个人返回True,从而使验证集仅包含该人的图像。

与以前的数据块示例的唯一区别是第二个块是PointBlock。这是必要的,以便 fastai 知道标签代表坐标;这样,它就知道在进行数据增强时,应该对这些坐标执行与图像相同的增强:

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)会自动且正确地将数据增强应用于坐标。因此,如果您使用另一个库,可能需要禁用这些问题的数据增强。

在进行任何建模之前,我们应该查看我们的数据以确认它看起来没问题:

dls = biwi.dataloaders(path)
dls.show_batch(max_n=9, figsize=(8,6))

看起来不错!除了通过视觉查看批次外,还可以查看底层张量(尤其是作为学生;这将有助于澄清您对模型实际看到的内容的理解):

xb,yb = dls.one_batch()
xb.shape,yb.shape
(torch.Size([64, 3, 240, 320]), torch.Size([64, 1, 2]))

确保您了解为什么这些是我们小批量的形状。

这是依赖变量的一个示例行:

yb[0]
tensor([[0.0111, 0.1810]], device='cuda:5')

正如您所看到的,我们不必使用单独的图像回归应用程序;我们所要做的就是标记数据并告诉 fastai 独立变量和因变量代表什么类型的数据。

创建我们的Learner也是一样的。我们将使用与之前相同的函数,只有一个新参数,然后我们就可以准备训练我们的模型了。

训练模型

像往常一样,我们可以使用cnn_learner来创建我们的Learner。还记得在第一章中我们如何使用y_range告诉 fastai 我们目标的范围吗?我们将在这里做同样的事情(fastai 和 PyTorch 中的坐标始终在-1 和+1 之间重新缩放):

learn = cnn_learner(dls, resnet18, y_range=(-1,1))

y_range在 fastai 中使用sigmoid_range实现,其定义如下:

def sigmoid_range(x, lo, hi): return torch.sigmoid(x) * (hi-lo) + lo

如果定义了y_range,则将其设置为模型的最终层。花点时间思考一下这个函数的作用,以及为什么它强制模型在范围(lo,hi)内输出激活。

这是它的样子:

plot_function(partial(sigmoid_range,lo=-1,hi=1), min=-4, max=4)

我们没有指定损失函数,这意味着我们得到了 fastai 选择的默认值。让我们看看它为我们选择了什么:

dls.loss_func
FlattenedLoss of MSELoss()

这是有道理的,因为当坐标被用作因变量时,大多数情况下我们可能会尽可能地预测接近某个值;这基本上就是 MSELoss(均方误差损失)所做的。如果你想使用不同的损失函数,你可以通过使用 loss_func 参数将其传递给 cnn_learner

还要注意,我们没有指定任何指标。这是因为均方误差已经是这个任务的一个有用指标(尽管在我们取平方根之后可能更易解释)。

我们可以使用学习率查找器选择一个好的学习率:

learn.lr_find()

我们将尝试一个学习率为 2e-2:

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,这对应于这个平均坐标预测误差:

math.sqrt(0.0001)
0.01

这听起来非常准确!但是重要的是要用 Learner.show_results 查看我们的结果。左侧是实际(真实)坐标,右侧是我们模型的预测:

learn.show_results(ds_idx=1, max_n=3, figsize=(6,8))

令人惊讶的是,仅仅几分钟的计算,我们就创建了一个如此准确的关键点模型,而且没有任何特定领域的应用。这就是在灵活的 API 上构建并使用迁移学习的力量!特别引人注目的是,我们能够如此有效地使用迁移学习,即使在完全不同的任务之间;我们的预训练模型是用来进行图像分类的,而我们对图像回归进行了微调。

结论

在乍一看完全不同的问题(单标签分类、多标签分类和回归)中,我们最终使用相同的模型,只是输出的数量不同。唯一改变的是损失函数,这就是为什么重要的是要仔细检查你是否为你的问题使用了正确的损失函数。

fastai 将自动尝试从您构建的数据中选择正确的损失函数,但如果您使用纯 PyTorch 构建您的 DataLoader,请确保您认真考虑您选择的损失函数,并记住您很可能想要以下内容:

  • nn.CrossEntropyLoss 用于单标签分类

  • nn.BCEWithLogitsLoss 用于多标签分类

  • nn.MSELoss 用于回归

问卷

  1. 多标签分类如何提高熊分类器的可用性?

  2. 在多标签分类问题中,我们如何对因变量进行编码?

  3. 如何访问 DataFrame 的行和列,就像它是一个矩阵一样?

  4. 如何从 DataFrame 中按名称获取列?

  5. DatasetDataLoader 之间有什么区别?

  6. Datasets 对象通常包含什么?

  7. DataLoaders 对象通常包含什么?

  8. lambda 在 Python 中是做什么的?

  9. 如何使用数据块 API 自定义独立变量和因变量的创建方法?

  10. 当使用一个独热编码的目标时,为什么 softmax 不是一个合适的输出激活函数?

  11. 当使用一个独热编码的目标时,为什么 nll_loss 不是一个合适的损失函数?

  12. nn.BCELossnn.BCEWithLogitsLoss 之间有什么区别?

  13. 为什么在多标签问题中不能使用常规准确率?

  14. 何时可以在验证集上调整超参数?

  15. y_range 在 fastai 中是如何实现的?(看看你是否可以自己实现并在不偷看的情况下测试!)

  16. 回归问题是什么?对于这样的问题应该使用什么损失函数?

  17. 为了确保 fastai 库将相同的数据增强应用于您的输入图像和目标点坐标,您需要做什么?

进一步研究

  1. 阅读关于 Pandas DataFrames 的教程,并尝试一些看起来有趣的方法。查看书籍网站上推荐的教程。

  2. 使用多标签分类重新训练熊分类器。看看你是否可以使其有效地处理不包含任何熊的图像,包括在 Web 应用程序中显示该信息。尝试一张包含两种熊的图像。检查在单标签数据集上使用多标签分类是否会影响准确性。

第七章:训练一个最先进的模型

原文:www.bookstack.cn/read/th-fastai-book/798d5ac22392691a.md

译者:飞龙

协议:CC BY-NC-SA 4.0

本章介绍了更高级的技术,用于训练图像分类模型并获得最先进的结果。如果您想了解更多关于深度学习的其他应用,并稍后回来,您可以跳过它——后续章节不会假设您已掌握这些材料。

我们将看一下什么是归一化,一种强大的数据增强技术叫做 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 上也效果很好!

这里有一个重要的信息:您得到的数据集不一定是您想要的数据集。特别是不太可能是您想要进行开发和原型设计的数据集。您应该力求迭代速度不超过几分钟——也就是说,当您想尝试一个新想法时,您应该能够在几分钟内训练一个模型并查看其效果。如果做一个实验花费的时间更长,考虑如何减少数据集的规模,或简化模型,以提高实验速度。您做的实验越多,效果就越好!

让我们从这个数据集开始:

from fastai.vision.all import *
path = untar_data(URLs.IMAGENETTE)

首先,我们将使用在第五章中介绍的预调整技巧将我们的数据集放入DataLoaders对象中:

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)

然后我们将进行一个作为基线的训练运行:

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:

x,y = dls.one_batch()
x.mean(dim=[0,2,3]),x.std(dim=[0,2,3])
(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 的一个子集),现在看一下一个批次:

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)
dls = get_dls(64, 224)
x,y = dls.one_batch()
x.mean(dim=[0,2,3]),x.std(dim=[0,2,3])
(TensorImage([-0.0787,  0.0525,  0.2136], device='cuda:5'),
 TensorImage([1.2330, 1.2112, 1.3031], device='cuda:5'))

让我们来看看这对训练我们的模型有什么影响:

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,训练的时期比您可能以其他方式做的要少:

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,并进行微调:

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方法;默认情况下,它将使用您的验证集:

preds,targs = learn.tta()
accuracy(preds, targs).item()
0.8737863898277283

正如我们所看到的,使用 TTA 可以显著提高性能,而无需额外的训练。但是,它会使推断变慢——如果你对 TTA 平均了五张图像,推断将变慢五倍。

我们已经看到了一些数据增强如何帮助训练更好的模型。现在让我们专注于一种名为混合的新数据增强技术。

混合

混合(Mixup)是在 2017 年张宏毅等人的论文《混合:超越经验风险最小化》中引入的一种强大的数据增强技术,可以提供极高的准确性,特别是当你没有太多数据,也没有经过预训练的模型,该模型是在与你的数据集相似的数据上训练的。该论文解释道:“虽然数据增强始终会导致改进的泛化,但该过程取决于数据集,并因此需要专业知识的使用。”例如,将图像翻转作为数据增强的一部分是很常见的,但是你应该只水平翻转还是同时垂直翻转呢?答案是取决于你的数据集。此外,如果(例如)翻转对你来说提供的数据增强不够,你不能“多翻转”。有助于拥有数据增强技术,可以“调高”或“调低”变化的程度,以找到最适合你的方法。

对于每个图像,Mixup 的工作方式如下:

  1. 随机从数据集中选择另一个图像。

  2. 随机选择一个权重。

  3. 使用步骤 2 中的权重对所选图像和您的图像进行加权平均;这将是您的自变量。

  4. 将这个图像的标签与您的图像的标签进行加权平均(使用相同的权重);这将是您的因变量。

在伪代码中,我们这样做(其中t是我们加权平均值的权重):

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 中的方程式描述了这一点(其中λ与我们伪代码中的t相同)。

Mixup 论文摘录

图 7-1。Mixup 论文摘录

图 7-2 展示了在 Mixup 中进行图像线性组合的样子。

一座教堂、一个加油站和两者混合的图像。

图 7-2。混合教堂和加油站

第三个图像是通过将第一个图像的 0.3 倍和第二个图像的 0.7 倍相加而构建的。在这个例子中,模型应该预测“教堂”还是“加油站”?正确答案是 30%的教堂和 70%的加油站,因为如果我们采用独热编码目标的线性组合,那就是我们将得到的结果。例如,假设我们有 10 个类别,“教堂”由索引 2 表示,“加油站”由索引 7 表示。独热编码表示如下:

[0, 0, 1, 0, 0, 0, 0, 0, 0, 0] and [0, 0, 0, 0, 0, 0, 0, 1, 0, 0]

这是我们的最终目标:

[0, 0, 0.3, 0, 0, 0, 0, 0.7, 0, 0]

fastai 通过向我们的Learner添加一个callback来完成所有这些操作。Callback是 fastai 中用于在训练循环中注入自定义行为的内容(如学习率调度或混合精度训练)。您将在第十六章中学习有关回调的所有内容,包括如何制作自己的回调。目前,您只需要知道使用cbs参数将回调传递给Learner

这是我们如何使用 Mixup 训练模型的方式:

model = xresnet50()
learn = Learner(dls, model, loss_func=CrossEntropyLossFlat(),
                metrics=accuracy, cbs=Mixup)
learn.fit_one_cycle(5, 3e-3)

当我们用这种方式“混合”的数据训练模型时会发生什么?显然,训练会更加困难,因为很难看清每个图像中的内容。模型必须为每个图像预测两个标签,而不仅仅是一个,并且还必须弄清楚每个标签的权重。然而,过拟合似乎不太可能成为问题,因为我们不会在每个时代中显示相同的图像,而是显示两个图像的随机组合。

与我们看到的其他增强方法相比,Mixup 需要更多的时代来训练以获得更好的准确性。您可以尝试使用fastai repo中的examples/train_imagenette.py脚本来训练 Imagenette,使用 Mixup 和不使用 Mixup。在撰写本文时,Imagenette repo中的排行榜显示,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,然后进行训练。这就是标签平滑。通过鼓励模型变得不那么自信,标签平滑将使您的训练更加健壮,即使存在错误标记的数据。结果将是一个在推理时更好泛化的模型。

这就是标签平滑在实践中的工作方式:我们从独热编码的标签开始,然后用ϵ N(这是希腊字母epsilon,在介绍标签平滑的论文和 fastai 代码中使用)替换所有的 0,其中N是类别数,ϵ是一个参数(通常为 0.1,这意味着我们对标签有 10%的不确定性)。由于我们希望标签总和为 1,我们还用1 - ϵ + ϵ N替换 1。这样,我们不会鼓励模型过于自信地预测。在我们的 Imagenette 示例中有 10 个类别,目标变成了这样(这里是对应于索引 3 的目标):

[0.01, 0.01, 0.01, 0.91, 0.01, 0.01, 0.01, 0.01, 0.01, 0.01]

在实践中,我们不想对标签进行独热编码,幸运的是我们也不需要(独热编码只是用来解释标签平滑和可视化的)。

在实践中使用这个方法,我们只需要在调用Learner时改变损失函数:

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 之间有什么区别?在什么情况下最好在其中一个上进行实验而不是另一个?

  2. 什么是归一化?

  3. 为什么在使用预训练模型时我们不需要关心归一化?

  4. 什么是渐进式调整大小?

  5. 在自己的项目中实现渐进式调整大小。有帮助吗?

  6. 什么是测试时间增强?如何在 fastai 中使用它?

  7. 在推理中使用 TTA 比常规推理更慢还是更快?为什么?

  8. 什么是 Mixup?如何在 fastai 中使用它?

  9. 为什么 Mixup 可以防止模型过于自信?

  10. 为什么使用 Mixup 进行五个时期的训练最终比不使用 Mixup 训练更糟糕?

  11. 标签平滑背后的理念是什么?

  12. 您的数据中有哪些问题可以通过标签平滑来解决?

  13. 在使用五个类别的标签平滑时,与索引 1 相关联的目标是什么?

  14. 当您想在新数据集上快速进行原型实验时,应该采取的第一步是什么?

进一步研究

  1. 使用 fastai 文档构建一个函数,将图像裁剪为每个角落的正方形;然后实现一种 TTA 方法,该方法对中心裁剪和这四个裁剪的预测进行平均。有帮助吗?比 fastai 的 TTA 方法更好吗?

  2. 在 arXiv 上找到 Mixup 论文并阅读。选择一两篇介绍 Mixup 变体的较新文章并阅读它们;然后尝试在您的问题上实现它们。

  3. 找到使用 Mixup 训练 Imagenette 的脚本,并将其用作在自己项目上进行长时间训练的示例。执行它并查看是否有帮助。

  4. 阅读侧边栏["标签平滑,论文"](#label_smoothing);然后查看原始论文的相关部分,看看您是否能够理解。不要害怕寻求帮助!

第八章:协同过滤深入探讨

原文:www.bookstack.cn/read/th-fastai-book/d4afd9df315bb076.md

译者:飞龙

协议:CC BY-NC-SA 4.0

解决的一个常见问题是有一定数量的用户和产品,您想推荐哪些产品最有可能对哪些用户有用。存在许多变体:例如,推荐电影(如 Netflix 上),确定在主页上为用户突出显示什么,决定在社交媒体动态中显示什么故事等。解决这个问题的一般方法称为协同过滤,工作原理如下:查看当前用户使用或喜欢的产品,找到其他使用或喜欢类似产品的用户,然后推荐那些用户使用或喜欢的其他产品。

例如,在 Netflix 上,您可能观看了很多科幻、充满动作并且是上世纪 70 年代制作的电影。Netflix 可能不知道您观看的这些电影的特定属性,但它将能够看到观看了与您观看相同电影的其他人也倾向于观看其他科幻、充满动作并且是上世纪 70 年代制作的电影。换句话说,要使用这种方法,我们不一定需要了解电影的任何信息,只需要知道谁喜欢观看它们。

这种方法可以解决更一般的一类问题,不一定涉及用户和产品。实际上,在协同过滤中,我们更常用项目这个术语,而不是产品。项目可以是人们点击的链接、为患者选择的诊断等。

关键的基础概念是潜在因素。在 Netflix 的例子中,我们假设您喜欢老式、充满动作的科幻电影。但您从未告诉 Netflix 您喜欢这类电影。Netflix 也不需要在其电影表中添加列,说明哪些电影属于这些类型。尽管如此,必须存在一些关于科幻、动作和电影年龄的潜在概念,这些概念对于至少一些人的电影观看决策是相关的。

在本章中,我们将解决这个电影推荐问题。我们将从获取适合协同过滤模型的一些数据开始。

数据初探

我们无法访问 Netflix 的完整电影观看历史数据集,但有一个很好的数据集可供我们使用,称为MovieLens。该数据集包含数千万部电影排名(电影 ID、用户 ID 和数字评分的组合),尽管我们只会使用其中的 10 万部作为示例。如果您感兴趣,可以尝试在完整的 2500 万推荐数据集上复制这种方法,您可以从他们的网站上获取。

该数据集可通过通常的 fastai 函数获得:

from fastai.collab import *
from fastai.tabular.all import *
path = untar_data(URLs.ML_100k)

根据README,主表位于文件u.data中。它是以制表符分隔的,列分别是用户、电影、评分和时间戳。由于这些名称没有编码,我们需要在使用 Pandas 读取文件时指定它们。以下是打开此表并查看的方法:

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 将相同数据交叉制表成了一个人类友好的表格。

电影和用户的交叉表

图 8-1. 电影和用户的交叉表

我们只选择了一些最受欢迎的电影和观看电影最多的用户,作为这个交叉表示例。这个表格中的空单元格是我们希望我们的模型学会填充的内容。这些是用户尚未评论电影的地方,可能是因为他们还没有观看。对于每个用户,我们希望找出他们最有可能喜欢哪些电影。

如果我们知道每个用户对电影可能属于的每个重要类别的喜好程度,比如流派、年龄、喜欢的导演和演员等,以及我们对每部电影的相同信息,那么填写这个表格的一个简单方法是将这些信息相乘,然后使用组合。例如,假设这些因子的范围在-1 到+1 之间,正数表示更强的匹配,负数表示更弱的匹配,类别是科幻、动作和老电影,那么我们可以表示电影《最后的绝地武士》如下:

last_skywalker = np.array([0.98,0.9,-0.9])

在这里,例如,我们将非常科幻评分为 0.98,非常不老评分为-0.9。我们可以表示喜欢现代科幻动作电影的用户如下:

user1 = np.array([0.9,0.8,-0.6])

现在我们可以计算这种组合之间的匹配:

(user1*last_skywalker).sum()
2.1420000000000003

当我们将两个向量相乘并将结果相加时,这被称为点积。它在机器学习中被广泛使用,并构成了矩阵乘法的基础。我们将在第十七章中更多地研究矩阵乘法和点积。

术语:点积

将两个向量的元素相乘,然后将结果相加的数学运算。

另一方面,我们可以表示电影《卡萨布兰卡》如下:

casablanca = np.array([-0.99,-0.3,0.8])

这种组合之间的匹配如下所示:

(user1*casablanca).sum()
-1.611

由于我们不知道潜在因子是什么,也不知道如何为每个用户和电影评分,我们应该学习它们。

学习潜在因子

在指定模型的结构和学习模型之间,实际上几乎没有什么区别,因为我们可以使用我们的一般梯度下降方法。

这种方法的第一步是随机初始化一些参数。这些参数将是每个用户和电影的一组潜在因子。我们将不得不决定要使用多少个。我们将很快讨论如何选择这些,但为了说明,让我们现在使用 5 个。因为每个用户将有一组这些因子,每部电影也将有一组这些因子,我们可以在交叉表中的用户和电影旁边显示这些随机初始化的值,然后我们可以填写这些组合的点积。例如,图 8-2 显示了在 Microsoft Excel 中的样子,顶部左侧的单元格公式显示为示例。

这种方法的第二步是计算我们的预测。正如我们讨论过的,我们可以通过简单地将每部电影与每个用户进行点积来实现这一点。例如,如果第一个潜在用户因子代表用户喜欢动作电影的程度,第一个潜在电影因子代表电影是否有很多动作,那么如果用户喜欢动作电影并且电影中有很多动作,或者用户不喜欢动作电影并且电影中没有任何动作,这两者的乘积将特别高。另一方面,如果存在不匹配(用户喜欢动作电影但电影不是动作片,或者用户不喜欢动作电影但电影是动作片),乘积将非常低。

交叉表中的潜在因子

图 8-2. 交叉表中的潜在因子

第三步是计算我们的损失。我们可以使用任何损失函数,让我们现在选择均方误差,因为这是一种合理的表示预测准确性的方法。

这就是我们需要的全部内容。有了这个,我们可以使用随机梯度下降来优化我们的参数(潜在因素),以最小化损失。在每一步中,随机梯度下降优化器将使用点积计算每部电影与每个用户之间的匹配,并将其与每个用户给出的每部电影的实际评分进行比较。然后它将计算这个值的导数,并通过学习率乘以这个值来调整权重。经过多次这样的操作,损失会变得越来越好,推荐也会变得越来越好。

要使用通常的Learner.fit函数,我们需要将我们的数据放入DataLoaders中,所以让我们现在专注于这一点。

创建 DataLoaders

在展示数据时,我们宁愿看到电影标题而不是它们的 ID。表u.item包含 ID 与标题的对应关系:

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表合并,以获得按标题分类的用户评分:

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:

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 中表示协同过滤,我们不能直接使用交叉表表示,特别是如果我们希望它适应我们的深度学习框架。我们可以将我们的电影和用户潜在因素表表示为简单的矩阵:

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 的单热编码向量会发生什么:

one_hot_3 = one_hot(3, n_users).float()
user_factors.t() @ one_hot_3
tensor([-0.4586, -0.9915, -0.4052, -0.3621, -0.5908])

它给我们的结果与矩阵中索引 3 处的向量相同:

user_factors[3]
tensor([-0.4586, -0.9915, -0.4052, -0.3621, -0.5908])

如果我们一次为几个索引这样做,我们将得到一个独热编码向量的矩阵,这个操作将是一个矩阵乘法!这将是使用这种架构构建模型的一种完全可接受的方式,只是它会比必要的使用更多的内存和时间。我们知道没有真正的基础原因来存储独热编码向量,或者通过搜索找到数字 1 的出现 - 我们应该能够直接使用整数索引到数组中。因此,大多数深度学习库,包括 PyTorch,都包括一个特殊的层,它就是这样做的;它使用整数索引到一个向量中,但其导数的计算方式使其与使用独热编码向量进行矩阵乘法时完全相同。这被称为嵌入

术语:嵌入

通过一个独热编码矩阵相乘,使用计算快捷方式,可以通过直接索引来实现。这是一个非常简单概念的相当花哨的词。您将独热编码矩阵相乘的东西(或者使用计算快捷方式,直接索引)称为嵌入矩阵

在计算机视觉中,我们有一种非常简单的方法通过其 RGB 值获取像素的所有信息:彩色图像中的每个像素由三个数字表示。这三个数字给我们红色、绿色和蓝色,这足以让我们的模型在之后工作。

对于手头的问题,我们没有同样简单的方法来描述用户或电影。可能与流派有关:如果给定用户喜欢爱情片,他们可能会给爱情片更高的评分。其他因素可能是电影是更注重动作还是对话,或者是否有一个特定的演员,用户可能特别喜欢。

我们如何确定用来描述这些数字的数字?答案是,我们不确定。我们将让我们的模型学习它们。通过分析用户和电影之间的现有关系,我们的模型可以自己找出看起来重要或不重要的特征。

这就是嵌入。我们将为我们的每个用户和每个电影分配一个特定长度的随机向量(这里,n_factors=5),并将使它们成为可学习的参数。这意味着在每一步,当我们通过比较我们的预测和目标来计算损失时,我们将计算损失相对于这些嵌入向量的梯度,并根据 SGD(或其他优化器)的规则更新它们。

一开始,这些数字没有任何意义,因为我们是随机选择的,但在训练结束时,它们将有意义。通过学习关于用户和电影之间关系的现有数据,没有任何其他信息,我们将看到它们仍然获得一些重要特征,并且可以将大片与独立电影、动作片与爱情片等区分开来。

我们现在有能力从头开始创建我们的整个模型。

从头开始协同过滤

在我们可以用 PyTorch 编写模型之前,我们首先需要学习面向对象编程和 Python 的基础知识。如果您以前没有进行过面向对象编程,我们将在这里为您进行快速介绍,但我们建议您在继续之前查阅教程并进行一些练习。

面向对象编程中的关键思想是。我们在本书中一直在使用类,比如DataLoaderStringLearner。Python 还让我们很容易地创建新类。这是一个简单类的示例:

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,因此你可以使用它来设置和获取任何你需要的属性:

ex = Example('Sylvain')
ex.say('nice to meet you')
'Hello Sylvain, nice to meet you.'

还要注意,创建一个新的 PyTorch 模块需要继承自Module继承是一个重要的面向对象的概念,在这里我们不会详细讨论——简而言之,它意味着我们可以向现有类添加额外的行为。PyTorch 已经提供了一个Module类,它提供了一些我们想要构建的基本基础。因此,我们在定义类的名称后面添加这个超类的名称,如下面的示例所示。

你需要知道创建一个新的 PyTorch 模块的最后一件事是,当调用你的模块时,PyTorch 将调用你的类中的一个名为forward的方法,并将包含在调用中的任何参数传递给它。这是定义我们的点积模型的类:

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。如前所述,我们使用嵌入层来表示我们的用户和电影潜在因子的矩阵:

x,y = dls.one_batch()
x.shape
torch.Size([64, 2])

现在我们已经定义了我们的架构并创建了参数矩阵,我们需要创建一个Learner来优化我们的模型。在过去,我们使用了特殊函数,比如cnn_learner,为特定应用程序为我们设置了一切。由于我们在这里从头开始做事情,我们将使用普通的Learner类:

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 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)

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)
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

这是一个合理的开始,但我们可以做得更好。一个明显缺失的部分是,有些用户在推荐中只是更积极或更消极,有些电影只是比其他电影更好或更差。但在我们的点积表示中,我们没有任何方法来编码这两件事。如果你只能说一部电影,例如,它非常科幻,非常动作导向,非常不老旧,那么你实际上没有办法说大多数人是否喜欢它。

这是因为在这一点上我们只有权重;我们没有偏差。如果我们为每个用户有一个可以添加到我们的分数中的单个数字,对于每部电影也是如此,那么这将非常好地处理这个缺失的部分。因此,首先让我们调整我们的模型架构:

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)

让我们尝试训练这个模型,看看效果如何:

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 值的抛物线

因此,让我们的模型学习高参数可能导致它用一个过于复杂、具有非常尖锐变化的函数拟合训练集中的所有数据点,这将导致过拟合。

限制我们的权重过大会阻碍模型的训练,但会产生一个更好泛化的状态。回顾一下理论,权重衰减(或wd)是一个控制我们在损失中添加的平方和的参数(假设parameters是所有参数的张量):

loss_with_wd = loss + wd * (parameters**2).sum()

然而,在实践中,计算那个大和并将其添加到损失中将非常低效(也许在数值上不稳定)。如果你还记得一点高中数学,你可能会记得p**2关于p的导数是2*p,所以将那个大和添加到我们的损失中,实际上等同于这样做:

parameters.grad += wd * 2 * parameters

实际上,由于wd是我们选择的一个参数,我们可以使它变为两倍大,所以在这个方程中我们甚至不需要*2。要在 fastai 中使用权重衰减,在调用fitfit_one_cycle时传递wd即可(可以同时传递):

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中:

class T(Module):
    def __init__(self): self.a = torch.ones(3)

L(T().parameters())
(#0) []

要告诉Module我们希望将一个张量视为参数,我们必须将其包装在nn.Parameter类中。这个类不添加任何功能(除了自动为我们调用requires_grad_)。它只用作一个“标记”,以显示要包含在parameters中的内容:

class T(Module):
    def __init__(self): self.a = nn.Parameter(torch.ones(3))

L(T().parameters())
(#1) [Parameter containing:
tensor([1., 1., 1.], requires_grad=True)]

所有 PyTorch 模块都使用nn.Parameter来表示任何可训练参数,这就是为什么我们直到现在都不需要显式使用这个包装器:

class T(Module):
    def __init__(self): self.a = nn.Linear(1, 3, bias=False)

t = T()
L(t.parameters())
(#1) [Parameter containing:
tensor([[-0.9595],
        [-0.8490],
        [ 0.8159]], requires_grad=True)]
type(t.a.weight)
torch.nn.parameter.Parameter

我们可以创建一个张量作为参数,进行随机初始化,如下所示:

def create_params(size):
    return nn.Parameter(torch.zeros(*size).normal_(0, 0.01))

让我们再次使用这个来创建DotProductBias,但不使用Embedding

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)

然后让我们再次训练它,以检查我们是否得到了与前一节中看到的大致相同的结果:

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

现在,让我们看看我们的模型学到了什么。

解释嵌入和偏差

我们的模型已经很有用,因为它可以为我们的用户提供电影推荐,但看到它发现了什么参数也很有趣。最容易解释的是偏差。以下是偏差向量中值最低的电影:

movie_bias = learn.model.movie_bias.squeeze()
idxs = movie_bias.argsort()[:5]
[dls.classes['title'][i] for i in idxs]
['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)']

想想这意味着什么。它表明对于这些电影中的每一部,即使用户与其潜在因素非常匹配(稍后我们将看到,这些因素往往代表动作水平、电影年龄等等),他们通常仍然不喜欢它。我们本可以简单地按照电影的平均评分对其进行排序,但查看学到的偏差告诉我们更有趣的事情。它告诉我们不仅仅是电影是人们不喜欢观看的类型,而且即使是他们本来会喜欢的类型,人们也倾向于不喜欢观看!同样地,以下是偏差最高的电影:

idxs = movie_bias.argsort(descending=True)[:5]
[dls.classes['title'][i] for i in idxs]
['L.A. Confidential (1997)',
 'Titanic (1997)',
 'Silence of the Lambs, The (1991)',
 'Shawshank Redemption, The (1994)',
 'Star Wars (1977)']

因此,例如,即使您通常不喜欢侦探电影,您可能会喜欢LA 机密

直接解释嵌入矩阵并不那么容易。对于人类来说,因素太多了。但有一种技术可以提取出这种矩阵中最重要的基础方向,称为主成分分析(PCA)。我们不会在本书中详细讨论这个,因为您要成为深度学习从业者并不特别重要,但如果您感兴趣,我们建议您查看 fast.ai 课程面向程序员的计算线性代数。图 8-3 显示了基于两个最强的 PCA 组件的电影的外观。

基于两个最强的 PCA 组件的电影表示

图 8-3. 基于两个最强的 PCA 组件的电影表示

我们可以看到模型似乎已经发现了经典流行文化电影的概念,或者这里代表的是广受好评

杰里米说

无论我训练多少模型,我永远不会停止被这些随机初始化的数字组合所感动和惊讶,这些数字通过如此简单的机制训练,竟然能够自己发现关于我的数据的东西。我几乎觉得可以欺骗,我可以创建一个能够做有用事情的代码,而从未真正告诉它如何做这些事情!

我们从头开始定义了我们的模型,以教给您内部情况,但您可以直接使用 fastai 库来构建它。我们将在下一节看看如何做到这一点。

使用 fastai.collab

我们可以使用 fastai 的collab_learner使用先前显示的确切结构创建和训练协同过滤模型:

learn = collab_learner(dls, n_factors=50, y_range=(0, 5.5))
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

通过打印模型可以看到层的名称:

learn.model
EmbeddingDotBias(
  (u_weight): Embedding(944, 50)
  (i_weight): Embedding(1635, 50)
  (u_bias): Embedding(944, 1)
  (i_bias): Embedding(1635, 1)
)

我们可以使用这些来复制我们在上一节中所做的任何分析,例如:

movie_bias = learn.model.i_bias.weight.squeeze()
idxs = movie_bias.argsort(descending=True)[:5]
[dls.classes['title'][i] for i in idxs]
['Titanic (1997)',
 "Schindler's List (1993)",
 'Shawshank Redemption, The (1994)',
 'L.A. Confidential (1997)',
 'Silence of the Lambs, The (1991)']

我们可以使用这些学到的嵌入来查看距离

嵌入距离

在二维地图上,我们可以通过使用毕达哥拉斯定理的公式来计算两个坐标之间的距离:x 2 + y 2(假设xy是每个轴上坐标之间的距离)。对于一个 50 维的嵌入,我们可以做完全相同的事情,只是将所有 50 个坐标距离的平方相加。

如果有两部几乎相同的电影,它们的嵌入向量也必须几乎相同,因为喜欢它们的用户几乎完全相同。这里有一个更一般的想法:电影的相似性可以由喜欢这些电影的用户的相似性来定义。这直接意味着两部电影的嵌入向量之间的距离可以定义这种相似性。我们可以利用这一点找到与“沉默的羔羊”最相似的电影:

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]
'Dial M for Murder (1954)'

现在我们已经成功训练了一个模型,让我们看看如何处理没有用户数据的情况。我们如何向新用户推荐?

引导协同过滤模型

在实践中使用协同过滤模型的最大挑战是“引导问题”。这个问题的最极端版本是没有用户,因此没有历史可供学习。您向您的第一个用户推荐什么产品?

但即使您是一家历史悠久的公司,拥有长期的用户交易记录,您仍然会面临一个问题:当新用户注册时,您该怎么办?实际上,当您向您的产品组合添加新产品时,您该怎么办?这个问题没有魔法解决方案,而我们建议的解决方案实际上只是“运用常识”的变体。您可以将新用户分配为其他用户所有嵌入向量的平均值,但这会带来一个问题,即该潜在因素的特定组合可能并不常见(例如,科幻因素的平均值可能很高,而动作因素的平均值可能很低,但很少有人喜欢科幻而不喜欢动作)。最好选择一个特定用户来代表“平均品味”。

更好的方法是使用基于用户元数据的表格模型来构建您的初始嵌入向量。当用户注册时,考虑一下您可以询问哪些问题来帮助您了解他们的口味。然后,您可以创建一个模型,其中因变量是用户的嵌入向量,而自变量是您问他们的问题的结果,以及他们的注册元数据。我们将在下一节中看到如何创建这些类型的表格模型。(您可能已经注意到,当您注册 Pandora 和 Netflix 等服务时,它们往往会问您一些关于您喜欢的电影或音乐类型的问题;这就是它们如何提出您的初始协同过滤推荐的方式。)

需要注意的一点是,一小部分非常热情的用户可能最终会有效地为整个用户群设置推荐。这是一个非常常见的问题,例如,在电影推荐系统中。看动漫的人往往会看很多动漫,而且不怎么看其他东西,花很多时间在网站上评分。因此,动漫往往在许多“有史以来最佳电影”列表中被过度代表。在这种特殊情况下,很明显您有一个代表性偏见的问题,但如果偏见发生在潜在因素中,可能一点也不明显。

这样的问题可能会改变您的用户群体的整体构成,以及您系统的行为。这特别是由于正反馈循环。如果您的一小部分用户倾向于设定您的推荐系统的方向,他们自然会吸引更多类似他们的人来到您的系统。这当然会放大原始的表征偏见。这种偏见是一种被指数级放大的自然倾向。您可能已经看到一些公司高管对他们的在线平台如何迅速恶化表示惊讶,以至于表达了与创始人价值观不符的价值观。在存在这种类型的反馈循环的情况下,很容易看到这种分歧如何迅速发生,以及以一种隐藏的方式,直到为时已晚。

在这样一个自我强化的系统中,我们可能应该预期这些反馈循环是常态,而不是例外。因此,您应该假设您会看到它们,为此做好计划,并提前确定如何处理这些问题。尝试考虑反馈循环可能在您的系统中表示的所有方式,以及您如何能够在数据中识别它们。最终,这又回到了我们关于如何在推出任何类型的机器学习系统时避免灾难的最初建议。这一切都是为了确保有人参与其中;有仔细的监控,以及一个渐进和周到的推出。

我们的点积模型效果相当不错,并且是许多成功的现实世界推荐系统的基础。这种协同过滤方法被称为概率矩阵分解(PMF)。另一种方法,通常在给定相同数据时效果类似,是深度学习。

协同过滤的深度学习

将我们的架构转换为深度学习模型的第一步是获取嵌入查找的结果并将这些激活连接在一起。这给我们一个矩阵,然后我们可以按照通常的方式通过线性层和非线性传递它们。

由于我们将连接嵌入矩阵,而不是取它们的点积,所以两个嵌入矩阵可以具有不同的大小(不同数量的潜在因素)。fastai 有一个函数get_emb_sz,根据 fast.ai 发现在实践中往往效果良好的启发式方法,返回推荐的嵌入矩阵大小:

embs = get_emb_sz(dls)
embs
[(944, 74), (1635, 101)]

让我们实现这个类:

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)

并使用它创建一个模型:

model = CollabNN(*embs)

CollabNN以与本章中先前类似的方式创建我们的Embedding层,只是现在我们使用embs大小。self.layers与我们在第四章为 MNIST 创建的迷你神经网络是相同的。然后,在forward中,我们应用嵌入,连接结果,并通过迷你神经网络传递。最后,我们像以前的模型一样应用sigmoid_range

让我们看看它是否训练:

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:

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.modelEmbeddingNN类型的对象。让我们看一下 fastai 对这个类的代码:

@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=0out_sz=1;除此之外,它只传递它收到的任何参数。

尽管EmbeddingNN的结果比点积方法稍差一些(这显示了为领域精心构建架构的力量),但它确实允许我们做一件非常重要的事情:我们现在可以直接将其他用户和电影信息、日期和时间信息或任何可能与推荐相关的信息纳入考虑。这正是TabularModel所做的。事实上,我们现在已经看到,EmbeddingNN只是一个TabularModel,其中n_cont=0out_sz=1。因此,我们最好花一些时间了解TabularModel,以及如何使用它获得出色的结果!我们将在下一章中做到这一点。

结论

对于我们的第一个非计算机视觉应用,我们研究了推荐系统,并看到梯度下降如何从评分历史中学习有关项目的内在因素或偏差。然后,这些因素可以为我们提供有关数据的信息。

我们还在 PyTorch 中构建了我们的第一个模型。在书的下一部分中,我们将做更多这样的工作,但首先,让我们完成对深度学习的其他一般应用的探讨,继续处理表格数据。

问卷

  1. 协同过滤解决了什么问题?

  2. 它是如何解决的?

  3. 为什么协同过滤预测模型可能无法成为非常有用的推荐系统?

  4. 协同过滤数据的交叉表表示是什么样的?

  5. 编写代码创建 MovieLens 数据的交叉表表示(您可能需要进行一些网络搜索!)。

  6. 什么是潜在因素?为什么它是“潜在”的?

  7. 什么是点积?使用纯 Python 和列表手动计算点积。

  8. pandas.DataFrame.merge是做什么的?

  9. 什么是嵌入矩阵?

  10. 嵌入和一个独热编码向量矩阵之间的关系是什么?

  11. 如果我们可以使用独热编码向量来做同样的事情,为什么我们需要Embedding

  12. 在我们开始训练之前,嵌入包含什么内容(假设我们没有使用预训练模型)?

  13. 创建一个类(尽量不要偷看!)并使用它。

  14. x[:,0]返回什么?

  15. 重写DotProduct类(尽量不要偷看!)并用它训练模型。

  16. 在 MovieLens 中使用什么样的损失函数是好的?为什么?

  17. 如果我们在 MovieLens 中使用交叉熵损失会发生什么?我们需要如何更改模型?

  18. 点积模型中偏差的用途是什么?

  19. 权重衰减的另一个名称是什么?

  20. 写出权重衰减的方程(不要偷看!)。

  21. 写出权重衰减的梯度方程。为什么它有助于减少权重?

  22. 为什么减少权重会导致更好的泛化?

  23. PyTorch 中的argsort是做什么的?

  24. 对电影偏差进行排序是否会得到与按电影平均评分相同的结果?为什么/为什么不?

  25. 如何打印模型中层的名称和详细信息?

  26. 协同过滤中的“自举问题”是什么?

  27. 如何处理新用户的自举问题?对于新电影呢?

  28. 反馈循环如何影响协同过滤系统?

  29. 在协同过滤中使用神经网络时,为什么我们可以为电影和用户使用不同数量的因素?

  30. 为什么在CollabNN模型中有一个nn.Sequential

  31. 如果我们想要向协同过滤模型添加有关用户和项目的元数据,或者有关日期和时间等信息,应该使用什么样的模型?

进一步研究

  1. 看看Embedding版本的DotProductBiascreate_params版本之间的所有差异,并尝试理解为什么需要进行每一项更改。如果不确定,尝试撤销每个更改以查看发生了什么。(注意:甚至在forward中使用的括号类型也已更改!)

  2. 找到另外三个协同过滤正在使用的领域,并在这些领域中确定这种方法的优缺点。

  3. 使用完整的 MovieLens 数据集完成这个笔记本,并将结果与在线基准进行比较。看看你能否提高准确性。在书的网站和 fast.ai 论坛上寻找想法。请注意,完整数据集中有更多列,看看你是否也可以使用这些列(下一章可能会给你一些想法)。

  4. 为 MovieLens 创建一个使用交叉熵损失的模型,并将其与本章中的模型进行比较。

第九章:表格建模深入探讨

原文:www.bookstack.cn/read/th-fastai-book/863394bdf5dc8421.md

译者:飞龙

协议:CC BY-NC-SA 4.0

表格建模将数据以表格形式(如电子表格或 CSV 文件)呈现。目标是基于其他列中的值来预测一列中的值。在本章中,我们将不仅看深度学习,还将看更一般的机器学习技术,如随机森林,因为根据您的问题,它们可能会给出更好的结果。

我们将看看我们应该如何预处理和清理数据,以及如何在训练后解释我们模型的结果,但首先我们将看看如何通过使用嵌入将包含类别的列馈送到期望数字的模型中。

分类嵌入

在表格数据中,某些列可能包含数值数据,如“年龄”,而其他列包含字符串值,如“性别”。数值数据可以直接输入模型(经过一些可选的预处理),但其他列需要转换为数字。由于这些值对应不同的类别,我们通常将这种类型的变量称为分类变量。第一种类型被称为连续 变量

术语:连续和分类变量

连续变量是数值数据,如“年龄”,可以直接输入模型,因为可以直接进行加法和乘法。分类变量包含多个离散级别,如“电影 ID”,对于这些级别,加法和乘法没有意义(即使它们以数字形式存储)。

2015 年底,Rossmann 销售竞赛在 Kaggle 上举行。参赛者获得了有关德国各个商店的各种信息,并被要求尝试预测若干天的销售额。目标是帮助公司适当管理库存,并能够满足需求而不必持有不必要的库存。官方训练集提供了大量有关商店的信息。允许参赛者使用额外的数据,只要这些数据是公开的并对所有参与者可用。

其中一位金牌得主使用了深度学习,在已知的最先进深度学习表格模型的早期示例中。他们的方法涉及远少于其他金牌得主的基于领域知识的特征工程。论文“分类变量的实体嵌入”描述了他们的方法。在书籍网站的在线专章中,我们展示了如何从头开始复制它,并获得论文中显示的相同准确性。在论文的摘要中,作者(Cheng Guo 和 Felix Bekhahn)说:

实体嵌入不仅可以减少内存使用量并加快神经网络的速度,与独热编码相比,更重要的是通过将相似值映射到嵌入空间中的相邻位置,揭示了分类变量的固有属性...[它]在具有大量高基数特征的数据集中特别有用,其他方法往往容易过拟合...由于实体嵌入为分类变量定义了距离度量,因此可以用于可视化分类数据和数据聚类。

当我们构建协同过滤模型时,我们已经注意到了所有这些要点。然而,我们可以清楚地看到这些见解远不止于协同过滤。

该论文还指出(正如我们在前一章中讨论的),嵌入层与在每个独热编码输入层之后放置普通线性层完全等效。作者使用图 9-1 中的图表来展示这种等效性。请注意,“密集层”是与“线性层”相同含义的术语,而独热编码层代表输入。

这一见解很重要,因为我们已经知道如何训练线性层,所以从架构和训练算法的角度来看,嵌入层只是另一层。我们在前一章中实践中也看到了这一点,当我们构建了一个与这个图表完全相同的协同过滤神经网络时。

就像我们分析了电影评论的嵌入权重一样,实体嵌入论文的作者分析了他们的销售预测模型的嵌入权重。他们发现的结果非常惊人,并展示了他们的第二个关键见解:嵌入将分类变量转换为连续且有意义的输入。

神经网络中的实体嵌入

图 9-1。神经网络中的实体嵌入(由 Cheng Guo 和 Felix Berkhahn 提供)

图 9-2 中的图像说明了这些想法。它们基于论文中使用的方法,以及我们添加的一些分析。

状态嵌入和地图

图 9-2。状态嵌入和地图(由 Cheng Guo 和 Felix Berkhahn 提供)

左侧是State类别可能值的嵌入矩阵图。对于分类变量,我们称变量的可能值为其“级别”(或“类别”或“类别”),因此这里一个级别是“柏林”,另一个是“汉堡”等。右侧是德国地图。德国各州的实际物理位置不是提供的数据的一部分,但模型本身学会了它们必须在哪里,仅基于商店销售的行为!

您还记得我们谈论过嵌入之间的距离吗?论文的作者绘制了商店嵌入之间的距离与商店之间的实际地理距离之间的关系(参见图 9-3)。他们发现它们非常接近!

商店距离

图 9-3。存储距离(由 Cheng Guo 和 Felix Berkhahn 提供)

我们甚至尝试绘制一周中的日期和一年中的月份的嵌入,发现在日历上彼此相邻的日期和月份也在嵌入中靠近,如图 9-4 所示。

这两个示例中突出的是,我们向模型提供了基本关于离散实体的分类数据(例如德国各州或一周中的日期),然后模型学习了这些实体的嵌入,定义了它们之间的连续距离概念。由于嵌入距离是基于数据中的真实模式学习的,因此该距离往往与我们的直觉相匹配。

日期嵌入

图 9-4。日期嵌入(由 Cheng Guo 和 Felix Berkhahn 提供)

此外,嵌入本身是有价值的,因为模型更擅长理解连续变量。这并不奇怪,因为模型由许多连续参数权重和连续激活值构成,这些值通过梯度下降(一种用于找到连续函数最小值的学习算法)进行更新。

另一个好处是,我们可以将连续嵌入值与真正连续的输入数据简单地结合在一起:我们只需连接变量并将连接输入到我们的第一个密集层中。换句话说,在与原始连续输入数据交互之前,原始分类数据通过嵌入层进行转换。这就是 fastai 和 Guo 和 Berkhahn 处理包含连续和分类变量的表格模型的方式。

使用这种连接方法的一个示例是谷歌在 Google Play 上进行推荐的方式,正如在论文“广泛和深度学习用于推荐系统”中所解释的那样。图 9-5 说明了这一点。

有趣的是,谷歌团队结合了我们在上一章看到的两种方法:点积(他们称之为交叉乘积)和神经网络方法。

谷歌 Play 推荐系统

图 9-5. 谷歌 Play 推荐系统

让我们暂停一下。到目前为止,解决我们所有建模问题的方法都是训练一个深度学习模型。确实,对于像图像、声音、自然语言文本等复杂的非结构化数据,这是一个相当好的经验法则。深度学习在协同过滤方面也表现得非常出色。但对于分析表格数据来说,它并不总是最佳的起点。

超越深度学习

大多数机器学习课程会向你介绍几十种算法,简要介绍它们背后的数学原理,可能还会有一个玩具示例。你会被展示的各种技术茫然不解,对如何应用它们几乎没有实际的理解。

好消息是,现代机器学习可以归结为几种广泛适用的关键技术。最近的研究表明,绝大多数数据集最适合用两种方法建模:

  • 决策树集成(即随机森林和梯度提升机),主要用于结构化数据(比如大多数公司数据库表中可能找到的数据)

  • 使用 SGD 学习的多层神经网络(即浅层和/或深度学习),主要用于非结构化数据(比如音频、图像和自然语言)

尽管深度学习几乎总是在非结构化数据方面明显优越,但对于许多种结构化数据,这两种方法往往给出相似的结果。但决策树集成往往训练更快,通常更容易解释,不需要特殊的 GPU 硬件进行规模推断,并且通常需要更少的超参数调整。它们也比深度学习流行得早得多,因此在它们周围有更成熟的工具和文档生态系统。

最重要的是,解释表格数据模型的关键步骤对于决策树集成来说要容易得多。有工具和方法可以回答相关问题,比如:数据集中哪些列对你的预测最重要?它们与因变量有什么关系?它们如何相互作用?哪些特定特征对某个特定观察最重要?

因此,决策树集成是我们分析新表格数据集的第一步方法。

这一准则的例外情况是当数据集符合以下条件之一时:

  • 有一些高基数分类变量非常重要(“基数”指代表示类别的离散级别的数量,因此高基数分类变量是指像邮政编码这样可能有数千个可能级别的变量)。

  • 有一些包含最好用神经网络理解的数据的列,比如纯文本数据。

在实践中,当我们处理符合这些特殊条件的数据集时,我们总是尝试决策树集成和深度学习,看哪个效果更好。在我们的协同过滤示例中,深度学习可能是一个有用的方法,因为我们至少有两个高基数分类变量:用户和电影。但在实践中,事情往往没有那么明确,通常会有高基数和低基数分类变量以及连续变量的混合。

无论如何,很明显我们需要将决策树集成添加到我们的建模工具箱中!

到目前为止,我们几乎所有的繁重工作都是使用 PyTorch 和 fastai 完成的。但是这些库主要设计用于进行大量矩阵乘法和导数计算(即,类似深度学习的操作!)。决策树根本不依赖于这些操作,因此 PyTorch 没有太多用处。

相反,我们将主要依赖一个名为scikit-learn(也称为sklearn)的库。Scikit-learn 是一个流行的库,用于创建机器学习模型,使用的方法不包括深度学习。此外,我们需要进行一些表格数据处理和查询,因此我们将使用 Pandas 库。最后,我们还需要 NumPy,因为这是 sklearn 和 Pandas 都依赖的主要数值编程库。

我们没有时间在本书中深入研究所有这些库,因此我们只会涉及每个库的一些主要部分。对于更深入的讨论,我们强烈建议阅读 Wes McKinney 的Python 数据分析(O'Reilly)。McKinney 是 Pandas 的创始人,因此您可以确信信息是准确的!

首先,让我们收集我们将使用的数据。

数据集

本章中使用的数据集来自于蓝皮书对推土机的 Kaggle 竞赛,该竞赛的描述如下:“比赛的目标是根据其使用情况、设备类型和配置来预测拍卖中特定重型设备的销售价格。数据来源于拍卖结果发布,并包括有关使用情况和设备配置的信息。”

这是一种非常常见的数据集类型和预测问题,类似于您在项目或工作场所中可能遇到的情况。该数据集可以在 Kaggle 上下载,Kaggle 是一个举办数据科学竞赛的网站。

Kaggle 竞赛

Kaggle 是一个非常棒的资源,适合有志成为数据科学家或任何希望提高机器学习技能的人。没有什么比亲自动手实践并获得实时反馈来帮助您提高技能。

Kaggle 提供了以下内容:

  • 有趣的数据集

  • 关于您的表现的反馈

  • 排行榜可以看到什么是好的,什么是可能的,以及什么是最先进的

  • 获奖选手分享有用的技巧和技术的博客文章

到目前为止,我们所有的数据集都可以通过 fastai 的集成数据集系统下载。然而,在本章中我们将使用的数据集只能从 Kaggle 获取。因此,您需要在该网站上注册,然后转到比赛页面。在该页面上点击规则,然后点击我理解并接受。(尽管比赛已经结束,您不会参加,但您仍然需要同意规则才能下载数据。)

下载 Kaggle 数据集的最简单方法是使用 Kaggle API。您可以通过使用pip安装它,并在笔记本单元格中运行以下命令:

!pip install kaggle

使用 Kaggle API 需要一个 API 密钥;要获取一个,点击 Kaggle 网站上的个人资料图片,选择我的账户;然后点击创建新的 API 令牌。这将在您的 PC 上保存一个名为kaggle.json的文件。您需要将此密钥复制到您的 GPU 服务器上。为此,请打开您下载的文件,复制内容,并将其粘贴到与本章相关的笔记本中的以下单引号内(例如,creds = '{"username":"*xxx*","key":"*xxx*"}'``):

creds = ''

然后执行此单元格(这只需要运行一次):

cred_path = Path('~/.kaggle/kaggle.json').expanduser()
if not cred_path.exists():
    cred_path.parent.mkdir(exist_ok=True)
    cred_path.write(creds)
    cred_path.chmod(0o600)

现在您可以从 Kaggle 下载数据集!选择一个路径来下载数据集:

path = URLs.path('bluebook')
path
Path('/home/sgugger/.fastai/archive/bluebook')

然后使用 Kaggle API 将数据集下载到该路径并解压缩:

if not path.exists():
    path.mkdir()
    api.competition_download_cli('bluebook-for-bulldozers', path=path)
    file_extract(path/'bluebook-for-bulldozers.zip')

path.ls(file_type='text')
(#7) [Path('Valid.csv'),Path('Machine_Appendix.csv'),Path('ValidSolution.csv'),P
 > ath('TrainAndValid.csv'),Path('random_forest_benchmark_test.csv'),Path('Test.
 > csv'),Path('median_benchmark.csv')]

现在我们已经下载了数据集,让我们来看一下!

查看数据

Kaggle 提供了有关我们数据集中某些字段的信息。数据页面解释了train.csv中的关键字段如下:

SalesID

销售的唯一标识符。

MachineID

机器的唯一标识符。一台机器可以被多次出售。

销售价格

机器在拍卖中的售价(仅在train.csv中提供)。

销售日期

销售日期。

在任何数据科学工作中,直接查看数据是很重要的,以确保您了解格式、存储方式、包含的值类型等。即使您已经阅读了数据的描述,实际数据可能并非您所期望的。我们将从将训练集读入 Pandas DataFrame 开始。通常,除非 Pandas 实际耗尽内存并返回错误,否则最好也指定low_memory=Falselow_memory参数默认为True,告诉 Pandas 一次只查看几行数据,以确定每列中包含的数据类型。这意味着 Pandas 最终可能会为不同的行使用不同的数据类型,这通常会导致数据处理错误或模型训练问题。

让我们加载数据并查看列:

df = pd.read_csv(path/'TrainAndValid.csv', low_memory=False)
df.columns
Index(['SalesID', 'SalePrice', 'MachineID', 'ModelID', 'datasource',
       'auctioneerID', 'YearMade', 'MachineHoursCurrentMeter', 'UsageBand',
       'saledate', 'fiModelDesc', 'fiBaseModel', 'fiSecondaryDesc',
       'fiModelSeries', 'fiModelDescriptor', 'ProductSize',
       'fiProductClassDesc', 'state', 'ProductGroup', 'ProductGroupDesc',
       'Drive_System', 'Enclosure', 'Forks', 'Pad_Type', 'Ride_Control',
       'Stick', 'Transmission', 'Turbocharged', 'Blade_Extension',
       'Blade_Width', 'Enclosure_Type', 'Engine_Horsepower', 'Hydraulics',
       'Pushblock', 'Ripper', 'Scarifier', 'Tip_Control', 'Tire_Size',
       'Coupler', 'Coupler_System', 'Grouser_Tracks', 'Hydraulics_Flow',
       'Track_Type', 'Undercarriage_Pad_Width', 'Stick_Length', 'Thumb',
       'Pattern_Changer', 'Grouser_Type', 'Backhoe_Mounting', 'Blade_Type',
       'Travel_Controls', 'Differential_Type', 'Steering_Controls'],
      dtype='object')

这是我们要查看的许多列!尝试浏览数据集,了解每个列中包含的信息类型。我们很快将看到如何“聚焦”于最有趣的部分。

在这一点上,一个很好的下一步是处理有序列的列。这指的是包含字符串或类似内容的列,但其中这些字符串具有自然排序。例如,这里是ProductSize的级别:

df['ProductSize'].unique()
array([nan, 'Medium', 'Small', 'Large / Medium', 'Mini', 'Large', 'Compact'],
 > dtype=object)

我们可以告诉 Pandas 这些级别的适当排序方式如下:

sizes = 'Large','Large / Medium','Medium','Small','Mini','Compact'
df['ProductSize'] = df['ProductSize'].astype('category')
df['ProductSize'].cat.set_categories(sizes, ordered=True, inplace=True)

最重要的数据列是因变量——我们想要预测的变量。请记住,模型的度量是反映预测有多好的函数。重要的是要注意项目使用的度量标准。通常,选择度量标准是项目设置的重要部分。在许多情况下,选择一个好的度量标准将需要不仅仅是选择一个已经存在的变量。这更像是一个设计过程。您应该仔细考虑哪种度量标准,或一组度量标准,实际上衡量了对您重要的模型质量概念。如果没有变量代表该度量标准,您应该看看是否可以从可用的变量构建度量标准。

然而,在这种情况下,Kaggle 告诉我们要使用的度量标准是实际和预测拍卖价格之间的平方对数误差(RMLSE)。我们只需要进行少量处理即可使用这个度量标准:我们取价格的对数,这样该值的m_rmse将给出我们最终需要的值:

dep_var = 'SalePrice'
df[dep_var] = np.log(df[dep_var])

我们现在准备探索我们的第一个用于表格数据的机器学习算法:决策树。

决策树

决策树集成,顾名思义,依赖于决策树。所以让我们从那里开始!决策树对数据提出一系列关于数据的二元(是或否)问题。在每个问题之后,树的那部分数据在“是”和“否”分支之间分割,如图 9-6 所示。经过一个或多个问题后,可以基于所有先前答案做出预测,或者需要另一个问题。

现在,这一系列问题是一个过程,用于获取任何数据项,无论是来自训练集还是新数据项,并将该项分配到一个组中。换句话说,在提出问题并回答问题之后,我们可以说该项属于与所有其他训练数据项相同的组,这些数据项对问题的答案相同。但这有什么好处呢?我们模型的目标是预测项目的值,而不是将它们分配到训练数据集中的组中。好处在于我们现在可以为这些组中的每个项目分配一个预测值——对于回归,我们取该组中项目的目标均值。

决策树示例

图 9-6. 决策树示例

让我们考虑如何找到正确的问题。当然,我们不希望自己创建所有这些问题-这就是计算机的作用!训练决策树的基本步骤可以很容易地写下来:

  1. 依次循环数据集的每一列。

  2. 对于每一列,依次循环该列的每个可能级别。

  3. 尝试将数据分成两组,基于它们是否大于或小于该值(或者如果它是一个分类变量,则基于它们是否等于或不等于该分类变量的水平)。

  4. 找到这两组中每组的平均销售价格,并查看这与该组中每个设备的实际销售价格有多接近。将这视为一个非常简单的“模型”,其中我们的预测只是该项组的平均销售价格。

  5. 在循环遍历所有列和每个可能的级别后,选择使用该简单模型给出最佳预测的分割点。

  6. 现在我们的数据有两组,基于这个选定的分割。将每个组视为一个单独的数据集,并通过返回到步骤 1 为每个组找到最佳分割。

  7. 递归地继续这个过程,直到每个组达到某个停止标准-例如,当组中只有 20 个项目时停止进一步分割。

尽管这是一个很容易自己实现的算法(这是一个很好的练习),但我们可以节省一些时间,使用内置在 sklearn 中的实现。

然而,首先,我们需要做一些数据准备。

Alexis 说

这是一个值得思考的有益问题。如果您考虑到定义决策树的过程本质上选择了一个关于变量的分割问题序列,您可能会问自己,我们如何知道这个过程选择了正确的序列?规则是选择产生最佳分割(即最准确地将项目分为两个不同类别)的分割问题,然后将同样的规则应用于该分割产生的组,依此类推。这在计算机科学中被称为“贪婪”方法。您能想象出一个情景,其中提出一个“不那么强大”的分割问题会使未来的分割更好(或者我应该说更好地导致更好的结果)吗?

处理日期

我们需要做的第一件数据准备工作是丰富我们对日期的表示。我们刚刚描述的决策树的基本基础是二分 - 将一组分成两组。我们查看序数变量,并根据变量的值是大于(或小于)阈值来划分数据集,我们查看分类变量,并根据变量的级别是否是特定级别来划分数据集。因此,这个算法有一种根据序数和分类数据划分数据集的方法。

但是这如何适用于常见的数据类型,日期呢?您可能希望将日期视为序数值,因为说一个日期比另一个日期更大是有意义的。然而,日期与大多数序数值有所不同,因为一些日期在某种方面与其他日期有质的不同,这通常与我们建模的系统相关。

为了帮助我们的算法智能处理日期,我们希望我们的模型不仅知道一个日期是否比另一个日期更近或更早。我们可能希望我们的模型根据日期的星期几、某一天是否是假期、所在月份等来做决策。为此,我们用一组日期元数据列替换每个日期列,例如假期、星期几和月份。这些列提供了我们认为会有用的分类数据。

fastai 带有一个函数,可以为我们执行此操作-我们只需传递包含日期的列名:

df = add_datepart(df, 'saledate')

在那里的同时,让我们为测试集做同样的事情:

df_test = pd.read_csv(path/'Test.csv', low_memory=False)
df_test = add_datepart(df_test, 'saledate')

我们可以看到我们的 DataFrame 中现在有很多新的列:

' '.join(o for o in df.columns if o.startswith('sale'))
'saleYear saleMonth saleWeek saleDay saleDayofweek saleDayofyear
 > saleIs_month_end saleIs_month_start saleIs_quarter_end saleIs_quarter_start
 > saleIs_year_end saleIs_year_start saleElapsed'

这是一个很好的第一步,但我们需要做更多的清理。为此,我们将使用 fastai 对象TabularPandasTabularProc

使用 TabularPandas 和 TabularProc

第二个预处理步骤是确保我们可以处理字符串和缺失数据。默认情况下,sklearn 都不能处理。相反,我们将使用 fastai 的TabularPandas类,它包装了一个 Pandas DataFrame 并提供了一些便利。为了填充一个TabularPandas,我们将使用两个TabularProcCategorifyFillMissingTabularProc类似于常规的Transform,但有以下不同:

  • 它返回传递给它的完全相同的对象,在原地修改对象后返回。

  • 它在数据首次传入时运行变换,而不是在访问数据时懒惰地运行。

Categorify是一个TabularProc,用数字分类列替换列。FillMissing是一个TabularProc,用列的中位数替换缺失值,并创建一个新的布尔列,对于任何值缺失的行,该列设置为True。这两个变换几乎适用于您将使用的每个表格数据集,因此这是您数据处理的一个很好的起点:

procs = [Categorify, FillMissing]

TabularPandas还将为我们处理数据集的拆分为训练集和验证集。但是,我们需要非常小心处理我们的验证集。我们希望设计它,使其类似于 Kaggle 将用来评判比赛的测试集

回想一下验证集和测试集之间的区别,如第一章中所讨论的。验证集是我们从训练中保留的数据,以确保训练过程不会在训练数据上过拟合。测试集是更深层次地被我们自己保留的数据,以确保我们在探索各种模型架构和超参数时不会在验证数据上过拟合。

我们看不到测试集。但我们确实希望定义我们的验证数据,使其与训练数据具有与测试集相同类型的关系。

在某些情况下,随机选择数据点的子集就足够了。但这不是这种情况,因为这是一个时间序列。

如果您查看测试集中表示的日期范围,您会发现它覆盖了 2012 年 5 月的六个月期间,这比训练集中的任何日期都要晚。这是一个很好的设计,因为竞赛赞助商希望确保模型能够预测未来。但这意味着如果我们要有一个有用的验证集,我们也希望验证集比训练集更晚。Kaggle 的训练数据在 2012 年 4 月结束,因此我们将定义一个更窄的训练数据集,其中只包括 2011 年 11 月之前的 Kaggle 训练数据,并且我们将定义一个验证集,其中包括 2011 年 11 月之后的数据。

为了做到这一点,我们使用np.where,这是一个有用的函数,返回(作为元组的第一个元素)所有True值的索引:

cond = (df.saleYear<2011) | (df.saleMonth<10)
train_idx = np.where( cond)[0]
valid_idx = np.where(~cond)[0]

splits = (list(train_idx),list(valid_idx))

TabularPandas需要告诉哪些列是连续的,哪些是分类的。我们可以使用辅助函数cont_cat_split自动处理:

cont,cat = cont_cat_split(df, 1, dep_var=dep_var)
to = TabularPandas(df, procs, cat, cont, y_names=dep_var, splits=splits)

TabularPandas的行为很像一个 fastai 的Datasets对象,包括提供trainvalid属性:

len(to.train),len(to.valid)
(404710, 7988)

我们可以看到数据仍然显示为类别的字符串(这里我们只显示了一些列,因为完整的表太大了,无法放在一页上)。

to.show(3)
state ProductGroup Drive_System Enclosure SalePrice
0 Alabama WL #na# EROPS w AC 11.097410
1 North Carolina WL #na# EROPS w AC 10.950807
2 New York SSL #na# OROPS 9.210340

然而,底层项目都是数字:

to.items.head(3)
state ProductGroup Drive_System Enclosure
0 1 6 0 3
1 33 6 0 3
2 32 3 0 6

将分类列转换为数字是通过简单地用数字替换每个唯一级别来完成的。与级别相关联的数字是按照它们在列中出现的顺序连续选择的,因此在转换后的分类列中,数字没有特定的含义。唯一的例外是,如果您首先将列转换为 Pandas 有序类别(就像我们之前为ProductSize所做的那样),那么您选择的排序将被使用。我们可以通过查看classes属性来查看映射:

to.classes['ProductSize']
(#7) ['#na#','Large','Large / Medium','Medium','Small','Mini','Compact']

由于处理数据到这一点需要一分钟左右的时间,我们应该保存它,这样以后我们可以继续从这里继续工作,而不必重新运行之前的步骤。fastai 提供了一个使用 Python 的pickle系统保存几乎任何 Python 对象的save方法:

(path/'to.pkl').save(to)

以后要读回来,您将键入:

to = (path/'to.pkl').load()

现在所有这些预处理都完成了,我们准备创建一个决策树。

创建决策树

首先,我们定义我们的自变量和因变量:

xs,y = to.train.xs,to.train.y
valid_xs,valid_y = to.valid.xs,to.valid.y

现在我们的数据都是数字的,没有缺失值,我们可以创建一个决策树:

m = DecisionTreeRegressor(max_leaf_nodes=4)
m.fit(xs, y);

为了简单起见,我们告诉 sklearn 只创建了四个叶节点。要查看它学到了什么,我们可以显示决策树:

draw_tree(m, xs, size=7, leaves_parallel=True, precision=2)

理解这幅图片是理解决策树的最好方法之一,所以我们将从顶部开始,逐步解释每个部分。

顶部节点代表初始模型,在进行任何分割之前,所有数据都在一个组中。这是最简单的模型。这是在不问任何问题的情况下得到的结果,将始终预测值为整个数据集的平均值。在这种情况下,我们可以看到它预测销售价格的对数值为 10.1。它给出了均方误差为 0.48。这个值的平方根是 0.69。(请记住,除非您看到m_rmse,或者均方根误差,否则您看到的值是在取平方根之前的,因此它只是差异的平方的平均值。)我们还可以看到在这个组中有 404,710 条拍卖记录,这是我们训练集的总大小。这里显示的最后一部分信息是找到的最佳分割的决策标准,即基于coupler_system列进行分割。

向下移动并向左移动,这个节点告诉我们,在coupler_system小于 0.5 的设备拍卖记录中有 360,847 条。这个组中我们的因变量的平均值是 10.21。从初始模型向下移动并向右移动,我们来到了coupler_system大于 0.5 的记录。

底部行包含我们的叶节点:没有答案出现的节点,因为没有更多问题需要回答。在这一行的最右边是包含coupler_system大于 0.5 的记录的节点。平均值为 9.21,因此我们可以看到决策树算法确实找到了一个单一的二进制决策,将高价值与低价值的拍卖结果分开。仅询问coupler_system预测的平均值为 9.21,而不是 10.1。

在第一个决策点后返回到顶部节点后,我们可以看到已经进行了第二个二进制决策分割,基于询问YearMade是否小于或等于 1991.5。对于这个条件为真的组(请记住,这是根据coupler_systemYearMade进行的两个二进制决策),平均值为 9.97,在这个组中有 155,724 条拍卖记录。对于这个条件为假的拍卖组,平均值为 10.4,有 205,123 条记录。因此,我们可以看到决策树算法成功地将我们更昂贵的拍卖记录分成了两组,这两组在价值上有显著差异。

我们可以使用 Terence Parr 强大的dtreeviz 库显示相同的信息:

samp_idx = np.random.permutation(len(y))[:500]
dtreeviz(m, xs.iloc[samp_idx], y.iloc[samp_idx], xs.columns, dep_var,
        fontname='DejaVu Sans', scale=1.6, label_fontsize=10,
        orientation='LR')

这显示了每个分割点数据分布的图表。我们可以清楚地看到我们的YearMade数据存在问题:显然有一些在 1000 年制造的推土机!很可能,这只是一个缺失值代码(在数据中没有出现的值,用作占位符的值,用于在值缺失的情况下)。对于建模目的,1000 是可以的,但正如你所看到的,这个异常值使得我们感兴趣的数值更难以可视化。所以,让我们用 1950 年替换它:

xs.loc[xs['YearMade']<1900, 'YearMade'] = 1950
valid_xs.loc[valid_xs['YearMade']<1900, 'YearMade'] = 1950

这个改变使得树的可视化中的分割更加清晰,尽管这并没有在模型结果上有任何显著的改变。这是决策树对数据问题有多么弹性的一个很好的例子!

m = DecisionTreeRegressor(max_leaf_nodes=4).fit(xs, y)
dtreeviz(m, xs.iloc[samp_idx], y.iloc[samp_idx], xs.columns, dep_var,
        fontname='DejaVu Sans', scale=1.6, label_fontsize=10,
        orientation='LR')

现在让决策树算法构建一个更大的树。在这里,我们没有传递任何停止标准,比如max_leaf_nodes

m = DecisionTreeRegressor()
m.fit(xs, y);

我们将创建一个小函数来检查我们模型的均方根误差(m_rmse),因为比赛是根据这个来评判的:

def r_mse(pred,y): return round(math.sqrt(((pred-y)**2).mean()), 6)
def m_rmse(m, xs, y): return r_mse(m.predict(xs), y)
m_rmse(m, xs, y)
0.0

所以,我们的模型完美了,对吧?不要那么快……记住,我们真的需要检查验证集,以确保我们没有过拟合:

m_rmse(m, valid_xs, valid_y)
0.337727

哎呀——看起来我们可能过拟合得很严重。原因如下:

m.get_n_leaves(), len(xs)
(340909, 404710)

我们的叶节点数几乎和数据点一样多!这似乎有点过于热情。事实上,sklearn 的默认设置允许它继续分裂节点,直到每个叶节点只包含一个项目。让我们改变停止规则,告诉 sklearn 确保每个叶节点至少包含 25 个拍卖记录:

m = DecisionTreeRegressor(min_samples_leaf=25)
m.fit(to.train.xs, to.train.y)
m_rmse(m, xs, y), m_rmse(m, valid_xs, valid_y)
(0.248562, 0.32368)

看起来好多了。让我们再次检查叶节点的数量:

m.get_n_leaves()
12397

更加合理!

Alexis 说

对于一个叶节点比数据项更多的过拟合决策树,这是我的直觉。考虑一下“二十个问题”游戏。在那个游戏中,选择者秘密想象一个物体(比如,“我们的电视机”),猜测者可以提出 20 个是或否的问题来猜测物体是什么(比如“它比一个面包盒大吗?”)。猜测者并不是在尝试预测一个数值,而只是在识别所有可想象物体集合中的特定物体。当你的决策树的叶节点多于域中可能的物体时,它本质上是一个训练有素的猜测者。它已经学会了识别训练集中特定数据项所需的问题序列,并且只是通过描述该项的值来“预测”。这是一种记忆训练集的方式,即过拟合。

构建决策树是创建数据模型的好方法。它非常灵活,因为它可以清楚地处理变量之间的非线性关系和交互作用。但我们可以看到在如何泛化(通过创建小树可以实现)和在训练集上的准确性(通过使用大树可以实现)之间存在一个基本的妥协。

那么我们如何兼顾两全呢?我们将在处理一个重要的遗漏细节之后向您展示:如何处理分类变量。

分类变量

在前一章中,当使用深度学习网络时,我们通过独热编码处理分类变量,并将其馈送到嵌入层。嵌入层帮助模型发现这些变量不同级别的含义(分类变量的级别没有固有含义,除非我们使用 Pandas 手动指定一个排序)。在决策树中,我们没有嵌入层,那么这些未处理的分类变量如何在决策树中发挥作用呢?例如,像产品代码这样的东西如何使用?

简短的答案是:它就是有效!想象一种情况,其中一个产品代码在拍卖中比其他任何产品代码都要昂贵得多。在这种情况下,任何二元分割都将导致该产品代码位于某个组中,而该组将比其他组更昂贵。因此,我们简单的决策树构建算法将选择该分割。稍后,在训练过程中,算法将能够进一步分割包含昂贵产品代码的子组,随着时间的推移,树将聚焦于那一个昂贵的产品。

还可以使用一位编码来替换单个分类变量,其中每一列代表变量的一个可能级别。Pandas 有一个get_dummies方法可以做到这一点。

然而,实际上并没有证据表明这种方法会改善最终结果。因此,我们通常会尽可能避免使用它,因为它确实会使您的数据集更难处理。在 2019 年,这个问题在 Marvin Wright 和 Inke König 的论文“Splitting on Categorical Predictors in Random Forests”中得到了探讨:

对于名义预测器,标准方法是考虑所有 2^(k − 1) − 1 个k预测类别的 2-分区。然而,这种指数关系会产生大量需要评估的潜在分割,增加了计算复杂性并限制了大多数实现中可能的类别数量。对于二元分类和回归,已经证明按照每个分割中的预测类别进行排序会导致与标准方法完全相同的分割。这减少了计算复杂性,因为对于具有k个类别的名义预测器,只需要考虑k − 1 个分割。

现在您了解了决策树的工作原理,是时候尝试那种最佳的解决方案了:随机森林。

随机森林

1994 年,伯克利大学教授 Leo Breiman 在退休一年后发表了一份名为“Bagging Predictors”的小型技术报告,这个报告成为现代机器学习中最有影响力的想法之一。报告开始说:

Bagging 预测器是一种生成预测器的多个版本并使用这些版本来获得聚合预测器的方法。聚合平均了这些版本……通过对学习集进行自助复制并将其用作新的学习集来形成多个版本。测试表明,bagging 可以显著提高准确性。关键因素是预测方法的不稳定性。如果扰动学习集可以导致构建的预测器发生显著变化,那么 bagging 可以提高准确性。

这是 Breiman 提出的程序:

  1. 随机选择数据的子集(即“学习集的自助复制”)。

  2. 使用这个子集训练模型。

  3. 保存该模型,然后返回到步骤 1 几次。

  4. 这将为您提供多个经过训练的模型。要进行预测,请使用所有模型进行预测,然后取每个模型预测的平均值。

这个过程被称为bagging。它基于一个深刻而重要的观点:尽管在数据子集上训练的每个模型会比在完整数据集上训练的模型产生更多错误,但这些错误不会相互关联。不同的模型会产生不同的错误。因此,这些错误的平均值为零!因此,如果我们取所有模型预测的平均值,我们应该得到一个预测,随着模型数量的增加,它会越来越接近正确答案。这是一个非凡的结果——这意味着我们可以通过多次在不同随机数据子集上训练它来改进几乎任何类型的机器学习算法的准确性,并对其预测进行平均。

2001 年,Breiman 继续展示了这种建模方法,当应用于决策树构建算法时,特别强大。他甚至比仅仅随机选择每个模型训练的行更进一步,还在每棵决策树的每个分裂点随机选择了一部分列。他将这种方法称为随机森林。今天,它可能是最广泛使用和实际重要的机器学习方法。

实质上,随机森林是一个模型,它平均了大量决策树的预测结果,这些决策树是通过随机变化各种参数生成的,这些参数指定了用于训练树和其他树参数的数据。Bagging 是一种特定的集成方法,或者将多个模型的结果组合在一起。为了看看它在实践中是如何工作的,让我们开始创建我们自己的随机森林!

创建随机森林

我们可以像创建决策树一样创建随机森林,只是现在我们还指定了指示森林中应该有多少树,如何对数据项(行)进行子集化以及如何对字段(列)进行子集化的参数。

在下面的函数定义中,n_estimators定义了我们想要的树的数量,max_samples定义了每棵树训练时要抽样的行数,max_features定义了在每个分裂点抽样的列数(其中0.5表示“取一半的总列数”)。我们还可以指定何时停止分裂树节点,有效地限制树的深度,通过包含我们在前一节中使用的相同min_samples_leaf参数。最后,我们传递n_jobs=-1告诉 sklearn 使用所有 CPU 并行构建树。通过创建一个小函数,我们可以更快地尝试本章其余部分的变化:

def rf(xs, y, n_estimators=40, max_samples=200_000,
       max_features=0.5, min_samples_leaf=5, **kwargs):
    return RandomForestRegressor(n_jobs=-1, n_estimators=n_estimators,
        max_samples=max_samples, max_features=max_features,
        min_samples_leaf=min_samples_leaf, oob_score=True).fit(xs, y)
m = rf(xs, y);

我们的验证 RMSE 现在比我们上次使用DecisionTreeRegressor生成的结果要好得多,后者只使用了所有可用数据生成了一棵树:

m_rmse(m, xs, y), m_rmse(m, valid_xs, valid_y)
(0.170896, 0.233502)

随机森林最重要的特性之一是它对超参数选择不太敏感,比如max_features。您可以将n_estimators设置为尽可能高的数字,以便训练更多的树,树越多,模型就越准确。max_samples通常可以保持默认值,除非您有超过 200,000 个数据点,在这种情况下,将其设置为 200,000 将使其在准确性上有很小影响的情况下更快地训练。max_features=0.5min_samples_leaf=4通常效果很好,尽管 sklearn 的默认值也很好。

sklearn 文档展示了一个例子,展示了不同max_features选择的效果,以及树的数量增加。在图中,蓝色曲线使用最少的特征,绿色曲线使用最多的特征(使用所有特征)。正如您在图 9-7 中所看到的,使用较少特征但具有更多树的模型具有最低的错误结果。

sklearn max_features 图表

图 9-7. 基于最大特征和树的数量的错误(来源:https://oreil.ly/E0Och

为了查看n_estimators的影响,让我们从森林中的每棵树获取预测结果(这些在estimators_属性中):

preds = np.stack([t.predict(valid_xs) for t in m.estimators_])

如您所见,preds.mean(0)给出了与我们的随机森林相同的结果:

r_mse(preds.mean(0), valid_y)
0.233502

让我们看看随着树的数量增加,RMSE 会发生什么变化。如您所见,大约在 30 棵树后,改进水平就会显著减少:

plt.plot([r_mse(preds[:i+1].mean(0), valid_y) for i in range(40)]);

我们在验证集上的表现比在训练集上差。但这是因为我们过拟合了,还是因为验证集涵盖了不同的时间段,或者两者都有?根据我们已经看到的信息,我们无法确定。然而,随机森林有一个非常聪明的技巧叫做袋外(OOB)误差,可以帮助我们解决这个问题(以及更多!)。

袋外误差

回想一下,在随机森林中,每棵树都是在训练数据的不同子集上训练的。OOB 错误是一种通过在计算行的错误时仅包括那些行包含在训练中的树来测量训练数据集中的预测错误的方法。这使我们能够看到模型是否过拟合,而无需单独的验证集。

Alexis 说

我对此的直觉是,由于每棵树都是在不同的随机选择的行子集上训练的,因此袋外错误有点像想象每棵树因此也有自己的验证集。该验证集只是未被选中用于该树训练的行。

这在我们只有少量训练数据的情况下特别有益,因为它使我们能够看到我们的模型是否在不移除物品以创建验证集的情况下泛化。OOB 预测可在oob_prediction_属性中找到。请注意,我们将它们与训练标签进行比较,因为这是在使用训练集的树上计算的:

r_mse(m.oob_prediction_, y)
0.210686

我们可以看到我们的 OOB 错误远低于验证集错误。这意味着除了正常的泛化错误之外,还有其他原因导致了该错误。我们将在本章后面讨论这些原因。

这是解释我们模型预测的一种方式——现在让我们更专注于这些。

模型解释

对于表格数据,模型解释尤为重要。对于给定的模型,我们最有兴趣的是以下内容:

  • 我们对使用特定数据行进行的预测有多自信?

  • 对于使用特定数据行进行预测,最重要的因素是什么,它们如何影响该预测?

  • 哪些列是最强的预测因子,哪些可以忽略?

  • 哪些列在预测目的上实际上是多余的?

  • 当我们改变这些列时,预测会如何变化?

正如我们将看到的,随机森林特别适合回答这些问题。让我们从第一个问题开始!

用于预测置信度的树方差

我们看到模型如何平均每棵树的预测以获得整体预测——也就是说,一个值的估计。但是我们如何知道估计的置信度?一种简单的方法是使用树之间预测的标准差,而不仅仅是均值。这告诉我们预测的相对置信度。一般来说,我们会更谨慎地使用树给出非常不同结果的行的结果(更高的标准差),而不是在树更一致的情况下使用结果(更低的标准差)。

在"创建随机森林"中,我们看到如何使用 Python 列表推导来对验证集进行预测,对森林中的每棵树都这样做:

preds = np.stack([t.predict(valid_xs) for t in m.estimators_])
preds.shape
(40, 7988)

现在我们对验证集中的每棵树和每个拍卖都有一个预测(40 棵树和 7,988 个拍卖)。

使用这种方法,我们可以获得每个拍卖的所有树的预测的标准差:

preds_std = preds.std(0)

以下是前五个拍卖的预测的标准差——也就是验证集的前五行:

preds_std[:5]
array([0.21529149, 0.10351274, 0.08901878, 0.28374773, 0.11977206])

正如您所看到的,预测的置信度差异很大。对于一些拍卖,标准差较低,因为树是一致的。对于其他拍卖,标准差较高,因为树不一致。这是在生产环境中会有用的信息;例如,如果您使用此模型来决定在拍卖中对哪些物品进行竞标,低置信度的预测可能会导致您在竞标之前更仔细地查看物品。

特征重要性

仅仅知道一个模型能够做出准确的预测通常是不够的,我们还想知道它是如何做出预测的。特征重要性给了我们这种洞察力。我们可以直接从 sklearn 的随机森林中获取这些信息,方法是查看feature_importances_属性。这里有一个简单的函数,我们可以用它将它们放入一个 DataFrame 并对它们进行排序:

def rf_feat_importance(m, df):
    return pd.DataFrame({'cols':df.columns, 'imp':m.feature_importances_}
                       ).sort_values('imp', ascending=False)

我们模型的特征重要性显示,前几个最重要的列的重要性得分比其余的要高得多,其中(不出所料)YearMadeProductSize位于列表的顶部:

fi = rf_feat_importance(m, xs)
fi[:10]
cols imp
69 YearMade 0.182890
6 ProductSize 0.127268
30 Coupler_System 0.117698
7 fiProductClassDesc 0.069939
66 ModelID 0.057263
77 saleElapsed 0.050113
32 Hydraulics_Flow 0.047091
3 fiSecondaryDesc 0.041225
31 Grouser_Tracks 0.031988
1 fiModelDesc 0.031838

特征重要性的图表显示了相对重要性更清晰:

def plot_fi(fi):
    return fi.plot('cols', 'imp', 'barh', figsize=(12,7), legend=False)

plot_fi(fi[:30]);

这些重要性是如何计算的相当简单而优雅。特征重要性算法循环遍历每棵树,然后递归地探索每个分支。在每个分支,它查看用于该分割的特征是什么,以及模型由于该分割而改善了多少。该改善(按该组中的行数加权)被添加到该特征的重要性分数中。这些分数在所有树的所有分支中求和,最后对分数进行归一化,使它们总和为 1。

去除低重要性变量

看起来我们可以通过去除低重要性的变量来使用列的子集,并且仍然能够获得良好的结果。让我们尝试只保留那些具有特征重要性大于 0.005 的列:

to_keep = fi[fi.imp>0.005].cols
len(to_keep)
21

我们可以使用列的这个子集重新训练我们的模型:

xs_imp = xs[to_keep]
valid_xs_imp = valid_xs[to_keep]
m = rf(xs_imp, y)

这里是结果:

m_rmse(m, xs_imp, y), m_rmse(m, valid_xs_imp, valid_y)
(0.181208, 0.232323)

我们的准确率大致相同,但我们有更少的列需要研究:

len(xs.columns), len(xs_imp.columns)
(78, 21)

我们发现,通常改进模型的第一步是简化它——78 列对我们来说太多了,我们无法深入研究它们!此外,在实践中,通常更简单、更易解释的模型更容易推出和维护。

这也使得我们的特征重要性图更容易解释。让我们再次看一下:

plot_fi(rf_feat_importance(m, xs_imp));

使这个更难解释的一点是,似乎有一些含义非常相似的变量:例如,ProductGroupProductGroupDesc。让我们尝试去除任何冗余特征。

去除冗余特征

让我们从这里开始:

cluster_columns(xs_imp)

在这个图表中,最相似的列对是在树的左侧远离“根”处早期合并在一起的。毫不奇怪,ProductGroupProductGroupDesc字段很早就合并了,saleYearsaleElapsed,以及fiModelDescfiBaseModel也是如此。它们可能是如此密切相关,以至于它们实际上是彼此的同义词。

确定相似性

最相似的对是通过计算秩相关性来找到的,这意味着所有的值都被它们的(在列内的第一、第二、第三等)替换,然后计算相关性。(如果你愿意,可以跳过这个细节,因为它在本书中不会再次出现!)

让我们尝试删除一些这些密切相关特征,看看模型是否可以简化而不影响准确性。首先,我们创建一个快速训练随机森林并返回 OOB 分数的函数,通过使用较低的max_samples和较高的min_samples_leaf。OOB 分数是由 sklearn 返回的一个数字,范围在 1.0(完美模型)和 0.0(随机模型)之间。(在统计学中被称为R²,尽管这些细节对于这个解释并不重要。)我们不需要它非常准确——我们只是要用它来比较不同的模型,基于删除一些可能冗余的列:

def get_oob(df):
    m = RandomForestRegressor(n_estimators=40, min_samples_leaf=15,
        max_samples=50000, max_features=0.5, n_jobs=-1, oob_score=True)
    m.fit(df, y)
    return m.oob_score_

这是我们的基线:

get_oob(xs_imp)
0.8771039618198545

现在我们尝试逐个删除我们可能冗余的变量:

{c:get_oob(xs_imp.drop(c, axis=1)) for c in (
    'saleYear', 'saleElapsed', 'ProductGroupDesc','ProductGroup',
    'fiModelDesc', 'fiBaseModel',
    'Hydraulics_Flow','Grouser_Tracks', 'Coupler_System')}
{'saleYear': 0.8759666979317242,
 'saleElapsed': 0.8728423449081594,
 'ProductGroupDesc': 0.877877012281002,
 'ProductGroup': 0.8772503407182847,
 'fiModelDesc': 0.8756415073829513,
 'fiBaseModel': 0.8765165299438019,
 'Hydraulics_Flow': 0.8778545895742573,
 'Grouser_Tracks': 0.8773718142788077,
 'Coupler_System': 0.8778016988955392}

现在让我们尝试删除多个变量。我们将从我们之前注意到的紧密对齐的一对中的每个变量中删除一个。让我们看看这样做会发生什么:

to_drop = ['saleYear', 'ProductGroupDesc', 'fiBaseModel', 'Grouser_Tracks']
get_oob(xs_imp.drop(to_drop, axis=1))
0.8739605718147015

看起来不错!这与拥有所有字段的模型相比几乎没有差别。让我们创建没有这些列的数据框,并保存它们:

xs_final = xs_imp.drop(to_drop, axis=1)
valid_xs_final = valid_xs_imp.drop(to_drop, axis=1)
(path/'xs_final.pkl').save(xs_final)
(path/'valid_xs_final.pkl').save(valid_xs_final)

我们可以稍后重新加载它们:

xs_final = (path/'xs_final.pkl').load()
valid_xs_final = (path/'valid_xs_final.pkl').load()

现在我们可以再次检查我们的 RMSE,以确认准确性没有发生实质性变化:

m = rf(xs_final, y)
m_rmse(m, xs_final, y), m_rmse(m, valid_xs_final, valid_y)
(0.183263, 0.233846)

通过专注于最重要的变量并删除一些冗余的变量,我们大大简化了我们的模型。现在,让我们看看这些变量如何影响我们的预测,使用部分依赖图。

部分依赖

正如我们所看到的,最重要的预测变量是ProductSizeYearMade。我们想要了解这些预测变量与销售价格之间的关系。首先,最好检查每个类别的值的计数(由 Pandas 的value_counts方法提供),看看每个类别有多常见:

p = valid_xs_final['ProductSize'].value_counts(sort=False).plot.barh()
c = to.classes['ProductSize']
plt.yticks(range(len(c)), c);

最大的组是#na#,这是 fastai 用于缺失值的标签。

让我们对YearMade做同样的事情。由于这是一个数值特征,我们需要绘制一个直方图,将年份值分组为几个离散的箱:

ax = valid_xs_final['YearMade'].hist()

除了我们用于编码缺失年份值的特殊值 1950 之外,大多数数据都是 1990 年后的。

现在我们准备看部分依赖图。部分依赖图试图回答这个问题:如果一行除了关注的特征之外没有变化,它会如何影响因变量?

例如,YearMade 如何影响销售价格,其他条件都相同?为了回答这个问题,我们不能简单地取每个YearMade的平均销售价格。这种方法的问题在于,许多其他因素也会随着年份的变化而变化,比如销售哪些产品、有多少产品带空调、通货膨胀等等。因此,仅仅对具有相同YearMade的所有拍卖品进行平均会捕捉到每个其他字段如何随着YearMade的变化而变化以及这种整体变化如何影响价格的效果。

相反,我们将YearMade列中的每个值替换为 1950,然后计算每个拍卖品的预测销售价格,并对所有拍卖品进行平均。然后我们对 1951、1952 等年份做同样的操作,直到我们的最终年份 2011。这隔离了仅YearMade的影响(即使通过对一些想象中的记录进行平均,我们分配了一个可能永远不会实际存在的YearMade值以及一些其他值)。

Alexis 说

如果你有哲学头脑,思考我们为了做出这个计算而搅乱的不同种类的假设性可能会让人眩晕。首先,每个预测都是假设的,因为我们没有记录实证数据。其次,我们不仅仅是想知道如果我们改变YearMade以及所有其他因素会如何改变销售价格。相反,我们非常明确地询问在一个假设的世界中,只有YearMade改变了销售价格会如何改变。哎呀!我们能够提出这样的问题令人印象深刻。如果你对更深入探讨分析这些微妙之处的形式主义感兴趣,我推荐 Judea Pearl 和 Dana Mackenzie 最近的关于因果关系的书籍为什么之书(Basic Books)。

有了这些平均值,我们就可以在 x 轴上绘制每年,y 轴上绘制每个预测。最终,这是一个偏依赖图。让我们来看一下:

from sklearn.inspection import plot_partial_dependence

fig,ax = plt.subplots(figsize=(12, 4))
plot_partial_dependence(m, valid_xs_final, ['YearMade','ProductSize'],
                        grid_resolution=20, ax=ax);

首先看YearMade的图表,特别是涵盖 1990 年后的年份部分(因为,正如我们注意到的,这是我们拥有最多数据的地方),我们可以看到年份和价格之间几乎是线性关系。请记住我们的因变量是取对数后的,所以这意味着实际上价格呈指数增长。这是我们所期望的:折旧通常被认为是随时间的乘法因子,因此对于给定的销售日期,改变制造年份应该显示出与销售价格的指数关系。

ProductSize的部分图有点令人担忧。它显示我们看到的最终组,即缺失值,价格最低。要在实践中使用这一见解,我们需要找出为什么它经常缺失以及这意味着什么。缺失值有时可以是有用的预测因子-这完全取决于导致它们缺失的原因。然而,有时它们可能表明数据泄漏

数据泄漏

在论文“数据挖掘中的泄漏:制定、检测和避免”中,Shachar Kaufman 等人描述了泄漏如下:

关于数据挖掘问题的目标的信息引入,这些信息不应该合法地从中挖掘出来。泄漏的一个微不足道的例子是一个模型将目标本身用作输入,因此得出例如“雨天下雨”的结论。实际上,引入这种非法信息是无意的,并且由数据收集、聚合和准备过程促成。

他们举了一个例子:

在 IBM 的一个实际商业智能项目中,根据其网站上发现的关键词,识别了某些产品的潜在客户。结果证明这是泄漏,因为用于训练的网站内容是在潜在客户已经成为客户的时间点进行采样的,网站包含了 IBM 购买的产品的痕迹,比如“Websphere”这样的词(例如,在关于购买的新闻稿或客户使用的特定产品功能中)。

数据泄漏是微妙的,可以采取多种形式。特别是,缺失值通常代表数据泄漏。

例如,Jeremy 参加了一个 Kaggle 竞赛,旨在预测哪些研究人员最终会获得研究资助。这些信息是由一所大学提供的,包括成千上万个研究项目的示例,以及有关涉及的研究人员和每个资助是否最终被接受的数据。大学希望能够使用在这次竞赛中开发的模型来排名哪些资助申请最有可能成功,以便优先处理。

Jeremy 使用随机森林对数据进行建模,然后使用特征重要性来找出哪些特征最具预测性。他注意到了三件令人惊讶的事情:

  • 该模型能够在 95%以上的时间内正确预测谁将获得资助。

  • 显然,毫无意义的标识列是最重要的预测因子。

  • 星期几和一年中的日期列也具有很高的预测性;例如,大多数在星期日日期的资助申请被接受,许多被接受的资助申请日期在 1 月 1 日。

对于标识列,部分依赖图显示,当信息缺失时,申请几乎总是被拒绝。实际上,事实证明,大学在接受资助申请后才填写了大部分这些信息。通常,对于未被接受的申请,这些信息只是留空。因此,这些信息在申请接收时并不可用,并且不会对预测模型可用——这是数据泄漏。

同样,成功申请的最终处理通常在一周或一年结束时自动完成。最终处理日期最终出现在数据中,因此,尽管这些信息具有预测性,但实际上在接收申请时并不可用。

这个例子展示了识别数据泄漏最实用和简单方法,即构建模型,然后执行以下操作:

  • 检查模型的准确性是否过于完美

  • 寻找在实践中不合理的重要预测因子。

  • 寻找在实践中不合理的部分依赖图结果。

回想一下我们的熊探测器,这与我们在第二章中提供的建议相符——通常先构建模型,然后进行数据清理是一个好主意,而不是反过来。模型可以帮助您识别潜在的数据问题。

它还可以帮助您确定哪些因素影响特定预测,使用树解释器。

树解释器

在本节开始时,我们说我们想要能够回答五个问题:

  • 我们对使用特定数据行进行预测有多自信?

  • 对于预测特定数据行,最重要的因素是什么,它们如何影响该预测?

  • 哪些列是最强的预测因子?

  • 哪些列在预测目的上实际上是多余的?

  • 当我们改变这些列时,预测会如何变化?

我们已经处理了其中四个;只剩下第二个问题。要回答这个问题,我们需要使用treeinterpreter库。我们还将使用waterfallcharts库来绘制结果图表。您可以通过在笔记本单元格中运行以下命令来安装这些:

!pip install treeinterpreter
!pip install waterfallcharts

我们已经看到如何计算整个随机森林中的特征重要性。基本思想是查看每个变量对模型改进的贡献,在每棵树的每个分支处,然后将每个变量的所有这些贡献相加。

我们可以完全相同的方式做,但只针对单个数据行。例如,假设我们正在查看拍卖中的特定物品。我们的模型可能预测这个物品会非常昂贵,我们想知道原因。因此,我们取出那一行数据并将其通过第一棵决策树,查看树中每个点处使用的分割。对于每个分割,我们找到相对于树的父节点的增加或减少。我们对每棵树都这样做,并将每个分割变量的重要性变化相加。

例如,让我们选择验证集的前几行:

row = valid_xs_final.iloc[:5]

然后我们可以将这些传递给treeinterpreter

prediction,bias,contributions = treeinterpreter.predict(m, row.values)

prediction只是随机森林的预测。bias是基于取因变量的平均值(即每棵树的根模型)的预测。contributions是最有趣的部分-它告诉我们由于每个独立变量的变化而导致的预测总变化。因此,对于每行,contributions加上bias必须等于prediction。让我们只看第一行:

prediction[0], bias[0], contributions[0].sum()
(array([9.98234598]), 10.104309759725059, -0.12196378442186026)

瀑布图最清晰地显示贡献。这显示了所有独立变量的正负贡献如何相加以创建最终预测,这里标有“净”标签的右侧列:

waterfall(valid_xs_final.columns, contributions[0], threshold=0.08,
          rotation_value=45,formatting='{:,.3f}');

这种信息在生产中最有用,而不是在模型开发过程中。您可以使用它为数据产品的用户提供有关预测背后的基本推理的有用信息。

现在我们已经介绍了一些经典的机器学习技术来解决这个问题,让我们看看深度学习如何帮助!

外推和神经网络

随机森林存在的问题,就像所有机器学习或深度学习算法一样,它们并不总是很好地推广到新数据。我们将看到在哪些情况下神经网络更好地推广,但首先,让我们看看随机森林存在的外推问题以及它们如何帮助识别域外数据。

外推问题

让我们考虑一个简单的任务,从显示略带噪音的线性关系的 40 个数据点中进行预测:

x_lin = torch.linspace(0,20, steps=40)
y_lin = x_lin + torch.randn_like(x_lin)
plt.scatter(x_lin, y_lin);

虽然我们只有一个独立变量,但 sklearn 期望独立变量的矩阵,而不是单个向量。因此,我们必须将我们的向量转换为一个具有一列的矩阵。换句话说,我们必须将shape[40]更改为[40,1]。一种方法是使用unsqueeze方法,在请求的维度上为张量添加一个新的单位轴:

xs_lin = x_lin.unsqueeze(1)
x_lin.shape,xs_lin.shape
(torch.Size([40]), torch.Size([40, 1]))

更灵活的方法是使用特殊值None切片数组或张量,这会在该位置引入一个额外的单位轴:

x_lin[:,None].shape
torch.Size([40, 1])

现在我们可以为这些数据创建一个随机森林。我们将只使用前 30 行来训练模型:

m_lin = RandomForestRegressor().fit(xs_lin[:30],y_lin[:30])

然后我们将在完整数据集上测试模型。蓝点是训练数据,红点是预测:

plt.scatter(x_lin, y_lin, 20)
plt.scatter(x_lin, m_lin.predict(xs_lin), color='red', alpha=0.5);

我们有一个大问题!我们在训练数据范围之外的预测都太低了。你认为这是为什么?

请记住,随机森林只是对多棵树的预测进行平均。而树只是预测叶子中行的平均值。因此,树和随机森林永远无法预测超出训练数据范围的值。这对于表示随时间变化的数据,如通货膨胀,且希望对未来时间进行预测的数据尤为棘手。您的预测将系统性地过低。

但问题不仅限于时间变量。随机森林无法对其未见过的数据类型进行外推,从更一般的意义上讲。这就是为什么我们需要确保我们的验证集不包含域外数据。

查找域外数据

有时很难知道您的测试集是否与训练数据以相同方式分布,或者如果不同,哪些列反映了这种差异。有一种简单的方法可以弄清楚这一点,那就是使用随机森林!

但在这种情况下,我们不使用随机森林来预测我们实际的因变量。相反,我们尝试预测一行是在验证集还是训练集中。要看到这一点,让我们将训练集和验证集结合起来,创建一个代表每行来自哪个数据集的因变量,使用该数据构建一个随机森林,并获取其特征重要性:

df_dom = pd.concat([xs_final, valid_xs_final])
is_valid = np.array([0]*len(xs_final) + [1]*len(valid_xs_final))

m = rf(df_dom, is_valid)
rf_feat_importance(m, df_dom)[:6]
cols imp
5 saleElapsed 0.859446
9 SalesID 0.119325
13 MachineID 0.014259
0 YearMade 0.001793
8 fiModelDesc 0.001740
11 Enclosure 0.000657

这显示训练集和验证集之间有三列显着不同:saleElapsedSalesIDMachineIDsaleElapsed 的差异相当明显:它是数据集开始和每行之间的天数,因此直接编码了日期。SalesID 的差异表明拍卖销售的标识符可能会随时间递增。MachineID 表明类似的情况可能发生在这些拍卖中出售的个别物品上。

让我们先获取原始随机森林模型的 RMSE 基线,然后逐个确定移除这些列的影响:

m = rf(xs_final, y)
print('orig', m_rmse(m, valid_xs_final, valid_y))

for c in ('SalesID','saleElapsed','MachineID'):
    m = rf(xs_final.drop(c,axis=1), y)
    print(c, m_rmse(m, valid_xs_final.drop(c,axis=1), valid_y))
orig 0.232795
SalesID 0.23109
saleElapsed 0.236221
MachineID 0.233492

看起来我们应该能够移除 SalesIDMachineID 而不会失去任何准确性。让我们检查一下:

time_vars = ['SalesID','MachineID']
xs_final_time = xs_final.drop(time_vars, axis=1)
valid_xs_time = valid_xs_final.drop(time_vars, axis=1)

m = rf(xs_final_time, y)
m_rmse(m, valid_xs_time, valid_y)
0.231307

删除这些变量略微提高了模型的准确性;但更重要的是,这应该使其随时间更具弹性,更易于维护和理解。我们建议对所有数据集尝试构建一个以 is_valid 为因变量的模型,就像我们在这里所做的那样。它通常可以揭示您可能会忽略的微妙的领域转移问题。

在我们的情况下,可能有助于简单地避免使用旧数据。通常,旧数据显示的关系已经不再有效。让我们尝试只使用最近几年的数据:

xs['saleYear'].hist();

在这个子集上训练的结果如下:

filt = xs['saleYear']>2004
xs_filt = xs_final_time[filt]
y_filt = y[filt]
m = rf(xs_filt, y_filt)
m_rmse(m, xs_filt, y_filt), m_rmse(m, valid_xs_time, valid_y)
(0.17768, 0.230631)

稍微好一点,这表明您不应该总是使用整个数据集;有时候子集可能更好。

让我们看看使用神经网络是否有帮助。

使用神经网络

我们可以使用相同的方法构建一个神经网络模型。让我们首先复制设置 TabularPandas 对象的步骤:

df_nn = pd.read_csv(path/'TrainAndValid.csv', low_memory=False)
df_nn['ProductSize'] = df_nn['ProductSize'].astype('category')
df_nn['ProductSize'].cat.set_categories(sizes, ordered=True, inplace=True)
df_nn[dep_var] = np.log(df_nn[dep_var])
df_nn = add_datepart(df_nn, 'saledate')

我们可以通过使用相同的列集合来为我们的神经网络利用我们在随机森林中修剪不需要的列的工作:

df_nn_final = df_nn[list(xs_final_time.columns) + [dep_var]]

在神经网络中,与决策树方法相比,分类列的处理方式大不相同。正如我们在第八章中看到的,在神经网络中,处理分类变量的一个很好的方法是使用嵌入。为了创建嵌入,fastai 需要确定哪些列应该被视为分类变量。它通过比较变量中不同级别的数量与 max_card 参数的值来实现这一点。如果较低,fastai 将把该变量视为分类变量。嵌入大小大于 10,000 通常只应在测试是否有更好的方法来分组变量之后使用,因此我们将使用 9,000 作为我们的 max_card 值:

cont_nn,cat_nn = cont_cat_split(df_nn_final, max_card=9000, dep_var=dep_var)

然而,在这种情况下,有一个变量绝对不能被视为分类变量:saleElapsed。按定义,分类变量无法在其所见值范围之外进行外推,但我们希望能够预测未来的拍卖销售价格。因此,我们需要将其作为连续变量处理:

cont_nn.append('saleElapsed')
cat_nn.remove('saleElapsed')

让我们来看看我们目前选择的每个分类变量的基数:

df_nn_final[cat_nn].nunique()
YearMade                73
ProductSize              6
Coupler_System           2
fiProductClassDesc      74
ModelID               5281
Hydraulics_Flow          3
fiSecondaryDesc        177
fiModelDesc           5059
ProductGroup             6
Enclosure                6
fiModelDescriptor      140
Drive_System             4
Hydraulics              12
Tire_Size               17
dtype: int64

有关设备“型号”的两个变量,都具有类似非常高的基数,这表明它们可能包含相似的冗余信息。请注意,当分析冗余特征时,我们不一定会注意到这一点,因为这依赖于相似变量按相同顺序排序(即,它们需要具有类似命名的级别)。拥有 5,000 个级别的列意味着我们的嵌入矩阵需要 5,000 列,如果可能的话最好避免。让我们看看删除其中一个这些型号列对随机森林的影响:

xs_filt2 = xs_filt.drop('fiModelDescriptor', axis=1)
valid_xs_time2 = valid_xs_time.drop('fiModelDescriptor', axis=1)
m2 = rf(xs_filt2, y_filt)
m_rmse(m, xs_filt2, y_filt), m_rmse(m2, valid_xs_time2, valid_y)
(0.176706, 0.230642)

影响很小,因此我们将其作为神经网络的预测变量移除:

cat_nn.remove('fiModelDescriptor')

我们可以像创建随机森林那样创建我们的TabularPandas对象,但有一个非常重要的补充:归一化。随机森林不需要任何归一化——树构建过程只关心变量中值的顺序,而不关心它们的缩放。但正如我们所见,神经网络确实关心这一点。因此,在构建TabularPandas对象时,我们添加Normalize处理器:

procs_nn = [Categorify, FillMissing, Normalize]
to_nn = TabularPandas(df_nn_final, procs_nn, cat_nn, cont_nn,
                      splits=splits, y_names=dep_var)

表格模型和数据通常不需要太多的 GPU 内存,因此我们可以使用更大的批量大小:

dls = to_nn.dataloaders(1024)

正如我们讨论过的,为回归模型设置y_range是一个好主意,所以让我们找到我们因变量的最小值和最大值:

y = to_nn.train.y
y.min(),y.max()
(8.465899897028686, 11.863582336583399)

现在我们可以创建Learner来创建这个表格模型。像往常一样,我们使用特定于应用程序的学习函数,以利用其应用程序定制的默认值。我们将损失函数设置为 MSE,因为这就是这个比赛使用的损失函数。

默认情况下,对于表格数据,fastai 创建一个具有两个隐藏层的神经网络,分别具有 200 和 100 个激活。这对于小数据集效果很好,但在这里我们有一个相当大的数据集,所以我们将层大小增加到 500 和 250:

from fastai.tabular.all import *
learn = tabular_learner(dls, y_range=(8,12), layers=[500,250],
                        n_out=1, loss_func=F.mse_loss)
learn.lr_find()
(0.005754399299621582, 0.0002754228771664202)

不需要使用fine_tune,所以我们将使用fit_one_cycle进行几个周期的训练,看看效果如何:

learn.fit_one_cycle(5, 1e-2)
epoch train_loss valid_loss time
0 0.069705 0.062389 00:11
1 0.056253 0.058489 00:11
2 0.048385 0.052256 00:11
3 0.043400 0.050743 00:11
4 0.040358 0.050986 00:11

我们可以使用我们的r_mse函数将结果与之前得到的随机森林结果进行比较:

preds,targs = learn.get_preds()
r_mse(preds,targs)
0.2258

它比随机森林要好得多(尽管训练时间更长,对超参数调整要求更高)。

在继续之前,让我们保存我们的模型,以防以后想再次使用它:

learn.save('nn')

另一件可以帮助泛化的事情是使用几个模型并平均它们的预测——一个技术,如前面提到的,称为集成

集成

回想一下随机森林之所以效果如此好的最初原因:每棵树都有错误,但这些错误彼此之间不相关,因此一旦有足够多的树,这些错误的平均值应该趋向于零。类似的推理可以用来考虑平均使用不同算法训练的模型的预测。

在我们的情况下,我们有两个非常不同的模型,使用非常不同的算法进行训练:一个是随机森林,一个是神经网络。可以合理地期望每个模型产生的错误类型会有很大不同。因此,我们可能会期望它们的预测平均值会比任何一个单独的预测都要好。

正如我们之前看到的,随机森林本身就是一个集成模型。但是我们可以将一个随机森林包含在另一个集成中——一个由随机森林和神经网络组成的集成!虽然集成不会决定建模过程的成功与否,但它确实可以为您构建的任何模型增加一点小提升。

我们必须注意的一个小问题是,我们的 PyTorch 模型和我们的 sklearn 模型创建了不同类型的数据:PyTorch 给我们一个秩为 2 的张量(列矩阵),而 NumPy 给我们一个秩为 1 的数组(向量)。squeeze会从张量中删除任何单位轴,to_np将其转换为 NumPy 数组:

rf_preds = m.predict(valid_xs_time)
ens_preds = (to_np(preds.squeeze()) + rf_preds) /2

这给我们比任何一个模型单独取得的结果都要好:

r_mse(ens_preds,valid_y)
0.22291

实际上,这个结果比 Kaggle 排行榜上显示的任何分数都要好。然而,它并不直接可比,因为 Kaggle 排行榜使用了一个我们无法访问的单独数据集。Kaggle 不允许我们提交到这个旧的比赛中,以找出我们的表现如何,但我们的结果确实看起来令人鼓舞!

提升

到目前为止,我们集成的方法是使用装袋,它涉及将许多模型(每个模型在不同的数据子集上训练)组合起来通过平均它们。正如我们所看到的,当应用于决策树时,这被称为随机森林

在另一种重要的集成方法中,称为提升,我们添加模型而不是对它们进行平均。以下是提升的工作原理:

  1. 训练一个欠拟合数据集的小模型。

  2. 计算该模型在训练集中的预测。

  3. 从目标中减去预测值;这些被称为残差,代表了训练集中每个点的误差。

  4. 回到第 1 步,但是不要使用原始目标,而是使用残差作为训练的目标。

  5. 继续这样做,直到达到停止标准,比如最大树的数量,或者观察到验证集错误变得更糟。

使用这种方法,每棵新树都将尝试拟合所有先前树的错误。因为我们不断通过从先前树的残差中减去每棵新树的预测来创建新的残差,残差会变得越来越小。

使用提升树集成进行预测,我们计算每棵树的预测,然后将它们全部加在一起。有许多遵循这种基本方法的模型,以及许多相同模型的名称。梯度提升机(GBMs)和梯度提升决策树(GBDTs)是您最有可能遇到的术语,或者您可能会看到实现这些模型的特定库的名称;在撰写本文时,XGBoost是最受欢迎的。

请注意,与随机森林不同,使用这种方法,没有什么可以阻止我们过拟合。在随机森林中使用更多树不会导致过拟合,因为每棵树都是独立的。但是在提升集成中,拥有更多树,训练错误就会变得更好,最终您将在验证集上看到过拟合。

我们不会在这里详细介绍如何训练梯度提升树集成,因为这个领域发展迅速,我们提供的任何指导几乎肯定会在您阅读时过时。在我们撰写本文时,sklearn 刚刚添加了一个HistGradientBoostingRegressor类,提供了出色的性能。对于这个类,以及我们见过的所有梯度提升树方法,有许多要调整的超参数。与随机森林不同,梯度提升树对这些超参数的选择非常敏感;在实践中,大多数人使用一个循环来尝试一系列超参数,找到最适合的那些。

另一种取得很好结果的技术是在机器学习模型中使用神经网络学习的嵌入。

将嵌入与其他方法结合

我们在本章开头提到的实体嵌入论文的摘要中指出:“从训练的神经网络中获得的嵌入在作为输入特征时显著提高了所有测试的机器学习方法的性能。”它包括在图 9-8 中显示的非常有趣的表格。

嵌入与其他方法结合

图 9-8。使用神经网络嵌入作为其他机器学习方法的输入的效果(由 Cheng Guo 和 Felix Berkhahn 提供)

这显示了四种建模技术之间的平均百分比误差(MAPE)的比较,其中三种我们已经看过,还有一种是k-最近邻(KNN),这是一种非常简单的基准方法。第一列数字包含在比赛中使用这些方法的结果;第二列显示如果您首先使用具有分类嵌入的神经网络,然后在模型中使用这些分类嵌入而不是原始分类列会发生什么。正如您所看到的,在每种情况下,使用嵌入而不是原始类别可以显著改善模型。

这是一个非常重要的结果,因为它表明您可以在推断时获得神经网络的性能改进的大部分,而无需使用神经网络。您可以只使用一个嵌入,这实际上只是一个数组查找,以及一个小的决策树集成。

这些嵌入甚至不需要为组织中的每个模型或任务单独学习。相反,一旦为特定任务的列学习了一组嵌入,它们可以存储在一个中心位置,并在多个模型中重复使用。实际上,我们从与其他大公司的从业者的私下交流中得知,这在许多地方已经发生了。

结论

我们已经讨论了表格建模的两种方法:决策树集成和神经网络。我们还提到了两种决策树集成:随机森林和梯度提升机。每种方法都是有效的,但也需要做出妥协:

  • 随机森林是最容易训练的,因为它们对超参数选择非常有韧性,需要很少的预处理。它们训练速度快,如果有足够的树,就不会过拟合。但是它们可能会稍微不够准确,特别是在需要外推的情况下,比如预测未来的时间段。

  • 梯度提升机理论上训练速度与随机森林一样快,但实际上您将不得不尝试很多超参数。它们可能会过拟合,但通常比随机森林稍微准确一些。

  • 神经网络需要最长的训练时间,并需要额外的预处理,比如归一化;这种归一化也需要在推断时使用。它们可以提供很好的结果并很好地外推,但只有在您小心处理超参数并注意避免过拟合时才能实现。

我们建议从随机森林开始分析。这将为您提供一个强大的基准线,您可以确信这是一个合理的起点。然后,您可以使用该模型进行特征选择和部分依赖分析,以更好地了解您的数据。

基于这个基础,您可以尝试神经网络和 GBM,如果它们在合理的时间内在验证集上给出显著更好的结果,您可以使用它们。如果决策树集成对您有效,尝试将分类变量的嵌入添加到数据中,看看这是否有助于您的决策树学习更好。

问卷

  1. 什么是连续变量?

  2. 什么是分类变量?

  3. 分类变量可能的取值中使用的两个词是什么?

  4. 什么是密集层?

  5. 实体嵌入如何减少内存使用量并加快神经网络的速度?

  6. 实体嵌入特别适用于哪些类型的数据集?

  7. 机器学习算法的两个主要家族是什么?

  8. 为什么有些分类列需要在它们的类别中有特殊的排序?如何在 Pandas 中实现这一点?

  9. 总结决策树算法的作用。

  10. 日期与常规分类或连续变量有何不同,如何预处理它以使其能够在模型中使用?

  11. 在推土机比赛中应该选择一个随机的验证集吗?如果不是,应该选择什么样的验证集?

  12. pickle 是什么,它有什么用?

  13. 在本章中绘制的决策树中,如何计算msesamplesvalues

  14. 在构建决策树之前,我们如何处理异常值?

  15. 我们如何在决策树中处理分类变量?

  16. 什么是装袋法?

  17. 创建随机森林时,max_samplesmax_features之间有什么区别?

  18. 如果将n_estimators增加到一个非常高的值,是否会导致过拟合?为什么或为什么不?

  19. 在“创建随机森林”部分,在图 9-7 之后,为什么preds.mean(0)给出了与我们的随机森林相同的结果?

  20. 什么是袋外误差?

  21. 列出模型的验证集误差可能比 OOB 误差更糟糕的原因。如何测试您的假设?

  22. 解释为什么随机森林很适合回答以下每个问题:

    • 我们对使用特定数据行的预测有多自信?

    • 对于预测特定数据行,最重要的因素是什么,它们如何影响该预测?

    • 哪些列是最强的预测因子?

    • 随着这些列的变化,预测会如何变化?

  23. 移除不重要变量的目的是什么?

  24. 哪种类型的图表适合展示树解释器的结果?

  25. 什么是外推问题?

  26. 如何判断测试或验证集的分布方式是否与训练集不同?

  27. 为什么我们将saleElapsed作为连续变量,即使它的唯一值不到 9,000 个?

  28. 什么是提升?

  29. 我们如何在随机森林中使用嵌入?我们是否期望这有所帮助?

  30. 为什么我们不总是使用神经网络进行表格建模?

进一步研究

  1. 选择一个 Kaggle 上的带表格数据的比赛(当前或过去),并尝试调整本章中所见的技术以获得最佳结果。将您的结果与私人排行榜进行比较。

  2. 自己从头开始实现本章中的决策树算法,并在第一个练习中使用的数据集上尝试它。

  3. 在本章中使用神经网络中的嵌入在随机森林中,并查看是否可以改进我们看到的随机森林结果。

  4. 解释TabularModel源代码的每一行做了什么(除了BatchNorm1dDropout层)。

第十章:NLP 深入探讨:RNNs

原文:www.bookstack.cn/read/th-fastai-book/38414c136aca063a.md

译者:飞龙

协议:CC BY-NC-SA 4.0

在第一章中,我们看到深度学习可以用于处理自然语言数据集并取得出色的结果。我们的示例依赖于使用预训练的语言模型,并对其进行微调以对评论进行分类。该示例突出了 NLP 和计算机视觉中迁移学习的区别:通常情况下,在 NLP 中,预训练模型是在不同任务上训练的。

我们所谓的语言模型是一个经过训练以猜测文本中下一个单词的模型(在读取之前的单词后)。这种任务称为自监督学习:我们不需要为我们的模型提供标签,只需向其提供大量文本。它有一个过程可以从数据中自动获取标签,这个任务并不是微不足道的:为了正确猜测句子中的下一个单词,模型将必须发展对英语(或其他语言)的理解。自监督学习也可以用于其他领域;例如,参见“自监督学习和计算机视觉”以了解视觉应用。自监督学习通常不用于直接训练的模型,而是用于预训练用于迁移学习的模型。

术语:自监督学习

使用嵌入在自变量中的标签来训练模型,而不是需要外部标签。例如,训练一个模型来预测文本中的下一个单词。

我们在第一章中用于分类 IMDb 评论的语言模型是在维基百科上预训练的。通过直接微调这个语言模型到电影评论分类器,我们取得了出色的结果,但通过一个额外的步骤,我们甚至可以做得更好。维基百科的英语与 IMDb 的英语略有不同,因此,我们可以将我们的预训练语言模型微调到 IMDb 语料库,然后将那个作为我们分类器的基础。

即使我们的语言模型了解我们在任务中使用的语言的基础知识(例如,我们的预训练模型是英语),熟悉我们的目标语料库的风格也是有帮助的。它可能是更非正式的语言,或者更技术性的,有新词要学习或者不同的句子构成方式。在 IMDb 数据集的情况下,将会有很多电影导演和演员的名字,通常比维基百科中看到的语言更不正式。

我们已经看到,使用 fastai,我们可以下载一个预训练的英语语言模型,并用它来获得 NLP 分类的最新结果。(我们预计很快将提供更多语言的预训练模型;实际上,当您阅读本书时,它们可能已经可用。)那么,为什么我们要详细学习如何训练语言模型呢?

当然,一个原因是了解您正在使用的模型的基础知识是有帮助的。但还有另一个非常实际的原因,那就是如果在微调分类模型之前微调(基于序列的)语言模型,您将获得更好的结果。例如,对于 IMDb 情感分析任务,数据集包括额外的 50,000 条电影评论,这些评论没有任何积极或消极的标签。由于训练集中有 25,000 条带标签的评论,验证集中有 25,000 条,总共有 100,000 条电影评论。我们可以使用所有这些评论来微调仅在维基百科文章上训练的预训练语言模型,这将导致一个特别擅长预测电影评论下一个单词的语言模型。

这被称为通用语言模型微调(ULMFiT)方法。介绍它的论文表明,在将语言模型微调到传递学习到分类任务之前,这个额外的微调阶段会导致预测显著更好。使用这种方法,我们在 NLP 中有三个传递学习阶段,如图 10-1 所总结。

ULMFiT 过程的图表

图 10-1。ULMFiT 过程

我们现在将探讨如何将神经网络应用于这个语言建模问题,使用前两章介绍的概念。但在继续阅读之前,请暂停一下,思考一下将如何处理这个问题。

文本预处理

到目前为止,我们学到的如何构建语言模型并不明显。句子的长度可能不同,文档可能很长。那么我们如何使用神经网络来预测句子的下一个单词呢?让我们找出答案!

我们已经看到分类变量可以作为神经网络的独立变量使用。以下是我们为单个分类变量采取的方法:

  1. 制作该分类变量的所有可能级别的列表(我们将称此列表为词汇)。

  2. 用词汇表中的索引替换每个级别。

  3. 为此创建一个包含每个级别的行的嵌入矩阵(即,词汇表中的每个项目)。

  4. 将此嵌入矩阵用作神经网络的第一层。(专用嵌入矩阵可以将步骤 2 中创建的原始词汇索引作为输入;这相当于但比使用表示索引的独热编码向量作为输入更快速和更有效。)

我们几乎可以用文本做同样的事情!新的是序列的概念。首先,我们将数据集中的所有文档连接成一个大字符串,然后将其拆分为单词(或标记),从而给我们一个非常长的单词列表。我们的独立变量将是从我们非常长的列表中的第一个单词开始并以倒数第二个单词结束的单词序列,我们的因变量将是从第二个单词开始并以最后一个单词结束的单词序列。

我们的词汇将由一些常见词汇和我们语料库中特定的新词汇(例如电影术语或演员的名字)混合组成。我们的嵌入矩阵将相应构建:对于预训练模型词汇中的词,我们将使用预训练模型的嵌入矩阵中的相应行;但对于新词,我们将没有任何内容,因此我们将只是用随机向量初始化相应的行。

创建语言模型所需的每个步骤都与自然语言处理领域的术语相关联,并且有 fastai 和 PyTorch 类可用于帮助。步骤如下:

标记化

将文本转换为单词列表(或字符,或子字符串,取决于您模型的粒度)。

数值化

列出所有出现的唯一单词(词汇表),并通过查找其在词汇表中的索引将每个单词转换为一个数字。

语言模型数据加载器创建

fastai 提供了一个LMDataLoader类,它会自动处理创建一个依赖变量,该变量与独立变量相差一个标记。它还处理一些重要的细节,例如如何以保持所需结构的方式对训练数据进行洗牌。

语言模型创建

我们需要一种特殊类型的模型,可以处理我们以前没有见过的输入列表,这些列表可能非常大或非常小。有许多方法可以做到这一点;在本章中,我们将使用循环神经网络(RNN)。我们将在第十二章中详细介绍 RNN 的细节,但现在,您可以将其视为另一个深度神经网络。

让我们详细看看每个步骤是如何工作的。

分词

当我们说“将文本转换为单词列表”时,我们忽略了很多细节。例如,我们如何处理标点符号?我们如何处理像“don’t”这样的单词?它是一个单词还是两个?长的医学或化学术语怎么办?它们应该被分割成各自的含义部分吗?连字符词怎么处理?像德语和波兰语这样的语言如何处理,它们可以从许多部分组成一个非常长的单词?像日语和中文这样的语言如何处理,它们根本不使用基础,也没有一个明确定义的单词的概念?

由于这些问题没有一个正确答案,所以也没有一个分词的方法。有三种主要方法:

基于单词的

将一个句子按空格分割,同时应用特定于语言的规则,尝试在没有空格的情况下分隔含义部分(例如将“don’t”转换为“do n’t”)。通常,标点符号也会被分割成单独的标记。

基于子词的

根据最常出现的子字符串将单词分割成较小的部分。例如,“occasion”可能被分词为“o c ca sion”。

基于字符的

将一个句子分割成其各个字符。

我们将在这里看一下单词和子词的分词,将字符为基础的分词留给你在本章末尾的问卷中实现。

行话:Token

由分词过程创建的列表的一个元素。它可以是一个单词,一个单词的一部分(一个子词),或一个单个字符。

使用 fastai 进行单词分词

fastai 并没有提供自己的分词器,而是提供了一个一致的接口来使用外部库中的一系列分词器。分词是一个活跃的研究领域,新的和改进的分词器不断涌现,因此 fastai 使用的默认值也会发生变化。然而,API 和选项不应该发生太大变化,因为 fastai 试图在底层技术发生变化时保持一致的 API。

让我们尝试一下我们在第一章中使用的 IMDb 数据集:

from fastai.text.all import *
path = untar_data(URLs.IMDB)

我们需要获取文本文件以尝试一个分词器。就像get_image_files(我们已经使用了很多次)获取路径中的所有图像文件一样,get_text_files获取路径中的所有文本文件。我们还可以选择性地传递folders来限制搜索到特定的子文件夹列表:

files = get_text_files(path, folders = ['train', 'test', 'unsup'])

这是一个我们将要分词的评论(我们这里只打印开头部分以节省空间):

txt = files[0].open().read(); txt[:75]
'This movie, which I just discovered at the video store, has apparently sit '

在撰写本书时,fastai 的默认英语单词分词器使用了一个名为spaCy的库。它有一个复杂的规则引擎,具有针对 URL、特殊英语单词等的特殊规则,以及更多。然而,我们不会直接使用SpacyTokenizer,而是使用WordTokenizer,因为它将始终指向 fastai 当前默认的单词分词器(取决于你阅读本书的时间,可能不一定是 spaCy)。

让我们试一试。我们将使用 fastai 的coll_repr(*collection*,*n*)函数来显示结果。这会显示collection的前n个项目,以及完整的大小——这是L默认使用的。请注意,fastai 的分词器接受一个要分词的文档集合,因此我们必须将txt包装在一个列表中:

spacy = WordTokenizer()
toks = first(spacy([txt]))
print(coll_repr(toks, 30))
(#201) ['This','movie',',','which','I','just','discovered','at','the','video','s
 > tore',',','has','apparently','sit','around','for','a','couple','of','years','
 > without','a','distributor','.','It',"'s",'easy','to','see'...]

正如你所看到的,spaCy 主要只是将单词和标点符号分开。但它在这里也做了其他事情:它将“it's”分割成“it”和“’s”。这是直观的;这些实际上是分开的单词。分词是一个令人惊讶的微妙任务,当你考虑到所有必须处理的细节时。幸运的是,spaCy 为我们处理得相当好——例如,在这里我们看到“.”在终止句子时被分开,但在首字母缩写或数字中不会被分开:

first(spacy(['The U.S. dollar $1 is $1.00.']))
(#9) ['The','U.S.','dollar','$','1','is','$','1.00','.']

然后 fastai 通过Tokenizer类为分词过程添加了一些额外功能:

tkn = Tokenizer(spacy)
print(coll_repr(tkn(txt), 31))
(#228) ['xxbos','xxmaj','this','movie',',','which','i','just','discovered','at',
 > 'the','video','store',',','has','apparently','sit','around','for','a','couple
 > ','of','years','without','a','distributor','.','xxmaj','it',"'s",'easy'...]

请注意,现在有一些以“xx”开头的标记,这不是英语中常见的单词前缀。这些是特殊标记

例如,列表中的第一项xxbos是一个特殊标记,表示新文本的开始(“BOS”是一个标准的 NLP 缩写,意思是“流的开始”)。通过识别这个开始标记,模型将能够学习需要“忘记”先前说过的内容,专注于即将出现的单词。

这些特殊标记并不是直接来自 spaCy。它们存在是因为 fastai 默认添加它们,通过在处理文本时应用一系列规则。这些规则旨在使模型更容易识别句子中的重要部分。在某种意义上,我们正在将原始的英语语言序列翻译成一个简化的标记化语言——这种语言被设计成易于模型学习。

例如,规则将用一个感叹号替换四个感叹号,后面跟着一个特殊的重复字符标记,然后是数字四。通过这种方式,模型的嵌入矩阵可以编码关于重复标点等一般概念的信息,而不需要为每个标点符号的重复次数添加单独的标记。同样,一个大写的单词将被替换为一个特殊的大写标记,后面跟着单词的小写版本。这样,嵌入矩阵只需要单词的小写版本,节省了计算和内存资源,但仍然可以学习大写的概念。

以下是一些你会看到的主要特殊标记:

xxbos

指示文本的开始(这里是一篇评论)

xxmaj

指示下一个单词以大写字母开头(因为我们将所有字母转换为小写)

xxunk

指示下一个单词是未知的

要查看使用的规则,可以查看默认规则:

defaults.text_proc_rules
[<function fastai.text.core.fix_html(x)>,
 <function fastai.text.core.replace_rep(t)>,
 <function fastai.text.core.replace_wrep(t)>,
 <function fastai.text.core.spec_add_spaces(t)>,
 <function fastai.text.core.rm_useless_spaces(t)>,
 <function fastai.text.core.replace_all_caps(t)>,
 <function fastai.text.core.replace_maj(t)>,
 <function fastai.text.core.lowercase(t, add_bos=True, add_eos=False)>]

如常,你可以通过在笔记本中键入以下内容查看每个规则的源代码:

??replace_rep

以下是每个标记的简要摘要:

fix_html

用可读版本替换特殊的 HTML 字符(IMDb 评论中有很多这样的字符)

replace_rep

用一个特殊标记替换任何重复三次或更多次的字符(xxrep),重复的次数,然后是字符

replace_wrep

用一个特殊标记替换任何重复三次或更多次的单词(xxwrep),重复的次数,然后是单词

spec_add_spaces

在/和#周围添加空格

rm_useless_spaces

删除所有空格的重复

replace_all_caps

将所有大写字母单词转换为小写,并在其前面添加一个特殊标记(xxcap

replace_maj

将大写的单词转换为小写,并在其前面添加一个特殊标记(xxmaj

lowercase

将所有文本转换为小写,并在开头(xxbos)和/或结尾(xxeos)添加一个特殊标记

让我们看看其中一些的操作:

coll_repr(tkn('&copy;   Fast.ai www.fast.ai/INDEX'), 31)
"(#11) ['xxbos','©','xxmaj','fast.ai','xxrep','3','w','.fast.ai','/','xxup','ind
 > ex'...]"

现在让我们看看子词标记化是如何工作的。

子词标记化

除了在前一节中看到的单词标记化方法之外,另一种流行的标记化方法是子词标记化。单词标记化依赖于一个假设,即空格在句子中提供了有意义的组件的有用分隔。然而,这个假设并不总是适用。例如,考虑这个句子:我的名字是郝杰瑞(中文中的“My name is Jeremy Howard”)。这对于单词标记器来说不会很好,因为其中没有空格!像中文和日文这样的语言不使用空格,事实上它们甚至没有一个明确定义的“单词”概念。其他语言,如土耳其语和匈牙利语,可以将许多子词组合在一起而不使用空格,创建包含许多独立信息片段的非常长的单词。

为了处理这些情况,通常最好使用子词标记化。这个过程分为两步:

  1. 分析一组文档以找到最常出现的字母组。这些将成为词汇表。

  2. 使用这个子词单元的词汇对语料库进行标记化。

让我们看一个例子。对于我们的语料库,我们将使用前 2,000 条电影评论:

txts = L(o.open().read() for o in files[:2000])

我们实例化我们的标记器,传入我们想要创建的词汇表的大小,然后我们需要“训练”它。也就是说,我们需要让它阅读我们的文档并找到常见的字符序列以创建词汇表。这是通过setup完成的。正如我们将很快看到的,setup是一个特殊的 fastai 方法,在我们通常的数据处理流程中会自动调用。然而,由于目前我们正在手动执行所有操作,因此我们必须自己调用它。这是一个为给定词汇表大小执行这些步骤并显示示例输出的函数:

def subword(sz):
    sp = SubwordTokenizer(vocab_sz=sz)
    sp.setup(txts)
    return ' '.join(first(sp([txt]))[:40])

让我们试一试:

subword(1000)
'▁This ▁movie , ▁which ▁I ▁just ▁dis c over ed ▁at ▁the ▁video ▁st or e , ▁has
 > ▁a p par ent ly ▁s it ▁around ▁for ▁a ▁couple ▁of ▁years ▁without ▁a ▁dis t
 > ri but or . ▁It'

使用 fastai 的子词标记器时,特殊字符代表原始文本中的空格字符。

如果我们使用较小的词汇表,每个标记将代表更少的字符,并且需要更多的标记来表示一个句子:

subword(200)
'▁ T h i s ▁movie , ▁w h i ch ▁I ▁ j us t ▁ d i s c o ver ed ▁a t ▁the ▁ v id e
 > o ▁ st or e , ▁h a s'

另一方面,如果我们使用较大的词汇表,大多数常见的英语单词将最终出现在词汇表中,我们将不需要那么多来表示一个句子:

subword(10000)
"▁This ▁movie , ▁which ▁I ▁just ▁discover ed ▁at ▁the ▁video ▁store , ▁has
 > ▁apparently ▁sit ▁around ▁for ▁a ▁couple ▁of ▁years ▁without ▁a ▁distributor
 > . ▁It ' s ▁easy ▁to ▁see ▁why . ▁The ▁story ▁of ▁two ▁friends ▁living"

选择子词词汇表大小代表一种折衷:较大的词汇表意味着每个句子的标记较少,这意味着训练速度更快,内存更少,并且模型需要记住的状态更少;但是,缺点是,这意味着更大的嵌入矩阵,这需要更多的数据来学习。

总的来说,子词标记化提供了一种在字符标记化(即使用较小的子词词汇表)和单词标记化(即使用较大的子词词汇表)之间轻松切换的方法,并且处理每种人类语言而无需开发特定于语言的算法。它甚至可以处理其他“语言”,如基因组序列或 MIDI 音乐符号!因此,过去一年中,它的流行度飙升,似乎很可能成为最常见的标记化方法(当您阅读本文时,它可能已经是了!)。

一旦我们的文本被分割成标记,我们需要将它们转换为数字。我们将在下一步中看到这一点。

使用 fastai 进行数字化

数字化是将标记映射到整数的过程。这些步骤基本上与创建Category变量所需的步骤相同,例如 MNIST 中数字的因变量:

  1. 制作该分类变量的所有可能级别的列表(词汇表)。

  2. 用词汇表中的索引替换每个级别。

让我们看看在之前看到的单词标记化文本上的实际操作:

toks = tkn(txt)
print(coll_repr(tkn(txt), 31))
(#228) ['xxbos','xxmaj','this','movie',',','which','i','just','discovered','at',
 > 'the','video','store',',','has','apparently','sit','around','for','a','couple
 > ','of','years','without','a','distributor','.','xxmaj','it',"'s",'easy'...]

就像SubwordTokenizer一样,我们需要在Numericalize上调用setup;这是我们创建词汇表的方法。这意味着我们首先需要我们的标记化语料库。由于标记化需要一段时间,fastai 会并行进行;但是对于这个手动演示,我们将使用一个小的子集:

toks200 = txts[:200].map(tkn)
toks200[0]
(#228)
 > ['xxbos','xxmaj','this','movie',',','which','i','just','discovered','at'...]

我们可以将这个传递给setup来创建我们的词汇表:

num = Numericalize()
num.setup(toks200)
coll_repr(num.vocab,20)
"(#2000) ['xxunk','xxpad','xxbos','xxeos','xxfld','xxrep','xxwrep','xxup','xxmaj
 > ','the','.',',','a','and','of','to','is','in','i','it'...]"

我们的特殊规则标记首先出现,然后每个单词按频率顺序出现一次。Numericalize的默认值为min_freq=3max_vocab=60000max_vocab=60000导致 fastai 用特殊的未知单词标记xxunk替换除最常见的 60,000 个单词之外的所有单词。这有助于避免过大的嵌入矩阵,因为这可能会减慢训练速度并占用太多内存,并且还可能意味着没有足够的数据来训练稀有单词的有用表示。然而,通过设置min_freq来处理最后一个问题更好;默认值min_freq=3意味着出现少于三次的任何单词都将被替换为xxunk

fastai 还可以使用您提供的词汇表对数据集进行数字化,方法是将单词列表作为vocab参数传递。

一旦我们创建了我们的Numericalize对象,我们可以像使用函数一样使用它:

nums = num(toks)[:20]; nums
tensor([  2,   8,  21,  28,  11,  90,  18,  59,   0,  45,   9, 351, 499,  11,
 > 72, 533, 584, 146,  29,  12])

这一次,我们的标记已经转换为模型可以接收的整数张量。我们可以检查它们是否映射回原始文本:

' '.join(num.vocab[o] for o in nums)
'xxbos xxmaj this movie , which i just xxunk at the video store , has apparently
 > sit around for a'

| xxbos | xxmaj | 在 | 这个 | 章节 | , | 我们 | 将 | 回顾 | 一下 | 分类 | 的 | 例子 |

回到我们之前的例子,有 6 个长度为 15 的批次,如果我们选择序列长度为 5,那意味着我们首先输入以下数组:

处理图像时,我们需要将它们全部调整为相同的高度和宽度,然后将它们组合在一起形成一个小批次,以便它们可以有效地堆叠在一个张量中。这里会有一点不同,因为不能简单地将文本调整为所需的长度。此外,我们希望我们的语言模型按顺序阅读文本,以便它可以有效地预测下一个单词是什么。这意味着每个新批次应该从上一个批次结束的地方开始。

假设我们有以下文本:

在这一章中,我们将回顾我们在第一章中学习的分类电影评论的例子,并深入挖掘。首先,我们将看一下将文本转换为数字所需的处理步骤以及如何自定义它。通过这样做,我们将有另一个使用数据块 API 中的预处理器的例子。

然后我们将学习如何构建一个语言模型并训练它一段时间。

标记化过程将添加特殊标记并处理标点以返回这个文本:

xxbos 在这一章中,我们将回顾我们在第一章中学习的分类电影评论的例子,并深入挖掘。首先,我们将看一下将文本转换为数字所需的处理步骤以及如何自定义它。通过这样做,我们将有另一个使用数据块 API 中的预处理器的例子。

现在我们有 90 个标记,用空格分隔。假设我们想要一个批次大小为 6。我们需要将这个文本分成 6 个长度为 15 的连续部分:

转换 文本 数字
电影 评论 我们 研究
首先 我们 处理
如何 自定义 通过
预处理器 数据 xxup
学习 我们 如何 构建

在理想的情况下,我们可以将这一个批次提供给我们的模型。但这种方法不具有可扩展性,因为在这个玩具示例之外,一个包含所有标记的单个批次不太可能适合我们的 GPU 内存(这里有 90 个标记,但所有 IMDb 评论一起给出了数百万个)。

因此,我们需要将这个数组更细地划分为固定序列长度的子数组。在这些子数组内部和之间保持顺序非常重要,因为我们将使用一个保持状态的模型,以便在预测接下来的内容时记住之前读到的内容。

| 语言 | 模型 | 和 | 训练 |

xxbos xxmaj 这个 章节
分类 例子 电影
首先 我们
如何 自定义
预处理器 使用
学习 我们 如何 构建

然后,这一个:

我们 回顾
章节 1 深入
处理 步骤 必要
通过 这样做
数据 xxup api
现在我们有了数字,我们需要将它们分批放入模型中。

最后:

将我们的文本放入语言模型的批次中
更深
将文本转换为数字,并按行翻译成中文:
我们

回到我们的电影评论数据集,第一步是通过将各个文本串联在一起将其转换为流。与图像一样,最好随机化输入的顺序,因此在每个时期的开始,我们将对条目进行洗牌以生成新的流(我们对文档的顺序进行洗牌,而不是其中的单词顺序,否则文本将不再有意义!)。

然后将此流切成一定数量的批次(这是我们的批量大小)。例如,如果流有 50,000 个标记,我们设置批量大小为 10,这将给我们 5,000 个标记的 10 个小流。重要的是我们保留标记的顺序(因此从 1 到 5,000 为第一个小流,然后从 5,001 到 10,000…),因为我们希望模型读取连续的文本行(如前面的示例)。在预处理期间,在每个文本的开头添加一个xxbos标记,以便模型知道当读取流时新条目何时开始。

因此,总结一下,每个时期我们都会对文档集合进行洗牌,并将它们连接成一个标记流。然后将该流切成一批固定大小的连续小流。我们的模型将按顺序读取小流,并由于内部状态,无论我们选择的序列长度如何,它都将产生相同的激活。

当我们创建LMDataLoader时,所有这些都是由 fastai 库在幕后完成的。我们首先将我们的Numericalize对象应用于标记化的文本

nums200 = toks200.map(num)

然后将其传递给LMDataLoader

dl = LMDataLoader(nums200)

让我们通过获取第一批来确认这是否给出了预期的结果

x,y = first(dl)
x.shape,y.shape
(torch.Size([64, 72]), torch.Size([64, 72]))

然后查看独立变量的第一行,这应该是第一个文本的开头:

' '.join(num.vocab[o] for o in x[0][:20])
'xxbos xxmaj this movie , which i just xxunk at the video store , has apparently
 > sit around for a'

依赖变量是相同的,只是偏移了一个标记:

' '.join(num.vocab[o] for o in y[0][:20])
'xxmaj this movie , which i just xxunk at the video store , has apparently sit
 > around for a couple'

这就完成了我们需要对数据应用的所有预处理步骤。我们现在准备训练我们的文本分类器。

训练文本分类器

正如我们在本章开头看到的那样,使用迁移学习训练最先进的文本分类器有两个步骤:首先,我们需要微调在 Wikipedia 上预训练的语言模型以适应 IMDb 评论的语料库,然后我们可以使用该模型来训练分类器。

像往常一样,让我们从组装数据开始。

使用 DataBlock 的语言模型

TextBlock传递给DataBlock时,fastai 会自动处理标记化和数值化。所有可以传递给TokenizerNumericalize的参数也可以传递给TextBlock。在下一章中,我们将讨论分别运行每个步骤的最简单方法,以便进行调试,但您也可以通过在数据的子集上手动运行它们来进行调试,如前几节所示。不要忘记DataBlock的方便的summary方法,用于调试数据问题非常有用。

这是我们如何使用TextBlock使用 fastai 的默认值创建语言模型的方式:

get_imdb = partial(get_text_files, folders=['train', 'test', 'unsup'])

dls_lm = DataBlock(
    blocks=TextBlock.from_folder(path, is_lm=True),
    get_items=get_imdb, splitter=RandomSplitter(0.1)
).dataloaders(path, path=path, bs=128, seq_len=80)

与我们在DataBlock中使用的以前类型不同的一件事是,我们不仅仅直接使用类(即TextBlock(...),而是调用类方法。类方法是 Python 方法,如其名称所示,属于而不是对象。(如果您对类方法不熟悉,请务必在网上搜索更多信息,因为它们在许多 Python 库和应用程序中常用;我们在本书中以前使用过几次,但没有特别提到。)TextBlock之所以特殊是因为设置数值化器的词汇表可能需要很长时间(我们必须读取和标记化每个文档以获取词汇表)。

为了尽可能高效,fastai 执行了一些优化:

  • 它将标记化的文档保存在临时文件夹中,因此不必多次对其进行标记化。

  • 它并行运行多个标记化过程,以利用计算机的 CPU。

我们需要告诉TextBlock如何访问文本,以便它可以进行这种初始预处理——这就是from_folder的作用。

show_batch然后以通常的方式工作:

dls_lm.show_batch(max_n=2)
text text_
0 xxbos xxmaj it ’s awesome ! xxmaj in xxmaj story xxmaj mode , your going from punk to pro . xxmaj you have to complete goals that involve skating , driving , and walking . xxmaj you create your own skater and give it a name , and you can make it look stupid or realistic . xxmaj you are with your friend xxmaj eric throughout the game until he betrays you and gets you kicked off of the skateboard xxmaj it ’s awesome ! xxmaj in xxmaj story xxmaj mode , your going from punk to pro . xxmaj you have to complete goals that involve skating , driving , and walking . xxmaj you create your own skater and give it a name , and you can make it look stupid or realistic . xxmaj you are with your friend xxmaj eric throughout the game until he betrays you and gets you kicked off of the skateboard xxunk
1 what xxmaj i ‘ve read , xxmaj death xxmaj bed is based on an actual dream , xxmaj george xxmaj barry , the director , successfully transferred dream to film , only a genius could accomplish such a task . \n\n xxmaj old mansions make for good quality horror , as do portraits , not sure what to make of the killer bed with its killer yellow liquid , quite a bizarre dream , indeed . xxmaj also , this xxmaj i ‘ve read , xxmaj death xxmaj bed is based on an actual dream , xxmaj george xxmaj barry , the director , successfully transferred dream to film , only a genius could accomplish such a task . \n\n xxmaj old mansions make for good quality horror , as do portraits , not sure what to make of the killer bed with its killer yellow liquid , quite a bizarre dream , indeed . xxmaj also , this is

现在我们的数据准备好了,我们可以对预训练语言模型进行微调。

微调语言模型

将整数单词索引转换为我们可以用于神经网络的激活时,我们将使用嵌入,就像我们在协同过滤和表格建模中所做的那样。然后,我们将把这些嵌入馈送到递归神经网络(RNN)中,使用一种称为AWD-LSTM的架构(我们将在第十二章中向您展示如何从头开始编写这样一个模型)。正如我们之前讨论的,预训练模型中的嵌入与为不在预训练词汇表中的单词添加的随机嵌入合并。这在language_model_learner内部自动处理:

learn = language_model_learner(
    dls_lm, AWD_LSTM, drop_mult=0.3,
    metrics=[accuracy, Perplexity()]).to_fp16()

默认使用的损失函数是交叉熵损失,因为我们基本上有一个分类问题(不同类别是我们词汇表中的单词)。这里使用的困惑度指标通常用于 NLP 的语言模型:它是损失的指数(即torch.exp(cross_entropy))。我们还包括准确性指标,以查看我们的模型在尝试预测下一个单词时有多少次是正确的,因为交叉熵(正如我们所见)很难解释,并且更多地告诉我们有关模型信心而不是准确性。

让我们回到本章开头的流程图。第一个箭头已经为我们完成,并作为 fastai 中的预训练模型提供,我们刚刚构建了第二阶段的DataLoadersLearner。现在我们准备好对我们的语言模型进行微调!

ULMFiT 过程的图表

每个时代的训练需要相当长的时间,因此我们将在训练过程中保存中间模型结果。由于fine_tune不会为我们执行此操作,因此我们将使用fit_one_cycle。就像cnn_learner一样,当使用预训练模型(这是默认设置)时,language_model_learner在使用时会自动调用freeze,因此这将仅训练嵌入(模型中唯一包含随机初始化权重的部分——即我们 IMDb 词汇表中存在但不在预训练模型词汇表中的单词的嵌入):

learn.fit_one_cycle(1, 2e-2)
epoch train_loss valid_loss accuracy perplexity time
0 4.120048 3.912788 0.299565 50.038246 11:39

这个模型训练时间较长,所以现在是谈论保存中间结果的好机会。

保存和加载模型

您可以轻松保存模型的状态如下:

learn.save('1epoch')

这将在 learn.path/models/ 中创建一个名为 1epoch.pth 的文件。如果您想在另一台机器上加载模型,或者稍后恢复训练,可以按照以下方式加载此文件的内容:

learn = learn.load('1epoch')

一旦初始训练完成,我们可以在解冻后继续微调模型:

learn.unfreeze()
learn.fit_one_cycle(10, 2e-3)
epoch train_loss valid_loss accuracy perplexity time
0 3.893486 3.772820 0.317104 43.502548 12:37
1 3.820479 3.717197 0.323790 41.148880 12:30
2 3.735622 3.659760 0.330321 38.851997 12:09
3 3.677086 3.624794 0.333960 37.516987 12:12
4 3.636646 3.601300 0.337017 36.645859 12:05
5 3.553636 3.584241 0.339355 36.026001 12:04
6 3.507634 3.571892 0.341353 35.583862 12:08
7 3.444101 3.565988 0.342194 35.374371 12:08
8 3.398597 3.566283 0.342647 35.384815 12:11
9 3.375563 3.568166 0.342528 35.451500 12:05

完成后,我们保存所有模型,除了将激活转换为在我们的词汇表中选择每个标记的概率的最终层。不包括最终层的模型称为编码器。我们可以使用 save_encoder 来保存它:

learn.save_encoder('finetuned')

术语:编码器

不包括任务特定的最终层。当应用于视觉 CNN 时,这个术语与“主体”几乎意思相同,但在 NLP 和生成模型中更常用“编码器”。

这完成了文本分类过程的第二阶段:微调语言模型。我们现在可以使用它来微调一个分类器,使用 IMDb 的情感标签。然而,在继续微调分类器之前,让我们快速尝试一些不同的东西:使用我们的模型生成随机评论。

文本生成

因为我们的模型经过训练可以猜测句子的下一个单词,所以我们可以用它来写新评论:

TEXT = "I liked this movie because"
N_WORDS = 40
N_SENTENCES = 2
preds = [learn.predict(TEXT, N_WORDS, temperature=0.75)
         for _ in range(N_SENTENCES)]
print("\n".join(preds))
i liked this movie because of its story and characters . The story line was very
 > strong , very good for a sci - fi film . The main character , Alucard , was
 > very well developed and brought the whole story
i liked this movie because i like the idea of the premise of the movie , the (
 > very ) convenient virus ( which , when you have to kill a few people , the "
 > evil " machine has to be used to protect

正如您所看到的,我们添加了一些随机性(我们根据模型返回的概率选择一个随机单词),这样我们就不会得到完全相同的评论两次。我们的模型没有任何关于句子结构或语法规则的编程知识,但它显然已经学会了很多关于英语句子:我们可以看到它正确地大写了(I 被转换为 i,因为我们的规则要求两个字符或更多才能认为一个单词是大写的,所以看到它小写是正常的)并且使用一致的时态。一般的评论乍一看是有意义的,只有仔细阅读时才能注意到有些地方有点不对。对于在几个小时内训练的模型来说,这还不错!

但我们的最终目标不是训练一个生成评论的模型,而是对其进行分类...所以让我们使用这个模型来做到这一点。

创建分类器数据加载器

我们现在从语言模型微调转向分类器微调。简而言之,语言模型预测文档的下一个单词,因此不需要任何外部标签。然而,分类器预测外部标签——在 IMDb 的情况下,是文档的情感。

这意味着我们用于 NLP 分类的 DataBlock 结构看起来非常熟悉。它几乎与我们为许多图像分类数据集看到的相同:

dls_clas = DataBlock(
    blocks=(TextBlock.from_folder(path, vocab=dls_lm.vocab),CategoryBlock),
    get_y = parent_label,
    get_items=partial(get_text_files, folders=['train', 'test']),
    splitter=GrandparentSplitter(valid_name='test')
).dataloaders(path, path=path, bs=128, seq_len=72)

就像图像分类一样,show_batch 显示了依赖变量(情感,在这种情况下)与每个独立变量(电影评论文本):

dls_clas.show_batch(max_n=3)
文本 类别
0 xxbos 我给这部电影打了 3 颗头骨的评分,只是因为女孩们知道如何尖叫,这部电影本可以更好,如果演员更好的话,双胞胎还行,我相信他们是邪恶的,但是最大和最小的兄弟,他们表现得真的很糟糕,看起来他们在读剧本而不是表演……。剧透:如果他们是吸血鬼,为什么他们会冻结血液?吸血鬼不能喝冻结的血液,电影中的姐姐说让我们在她活着的时候喝她……。但是当他们搬到另一栋房子时,他们带了一个冷藏盒装着他们的冻结血液。剧透结束\n\n 这是浪费时间,这让我很生气,因为我读了所有关于它的评论 neg
1 xxbos 我已经阅读了所有的《爱来的方式》系列书籍。我充分了解电影无法使用书中的所有方面,但通常它们至少会有书中的主要内容。我对这部电影感到非常失望。这部电影中唯一与书中相同的是,书中有 xxmaj missy 的父亲来到 xxunk (在书中父母都来了)。就是这样。故事情节扭曲且牵强,是的,悲伤,与书中完全不同,我无法享受。即使我没有读过这本书,它也太悲伤了。我知道拓荒生活很艰难,但整部电影都是一个沮丧的故事。评分 neg
2 xxbos 这部电影,用一个更好的词来说,很糟糕。我从哪里开始呢……\n\n 电影摄影 - 这或许是我今年看过的最糟糕的。看起来就像摄影师之间在互相抛接相机。也许他们只有一台相机。这让你感觉像是一个排球。\n\n 有一堆场景,零零散散地扔进去,完全没有连贯性。当他们做 '分屏' 时,那是荒谬的。一切都被压扁了,看起来荒谬。颜色调整完全错了。这些人需要学会如何平衡相机。这部 '电影' 制作很差, neg

DataBlock 的定义来看,每个部分都与我们构建的先前数据块相似,但有两个重要的例外:

  • TextBlock.from_folder 不再具有 is_lm=True 参数。

  • 我们传递了为语言模型微调创建的 vocab

我们传递语言模型的 vocab 是为了确保我们使用相同的标记到索引的对应关系。否则,我们在微调语言模型中学到的嵌入对这个模型没有任何意义,微调步骤也没有任何用处。

通过传递 is_lm=False(或者根本不传递 is_lm,因为它默认为 False),我们告诉 TextBlock 我们有常规标记的数据,而不是将下一个标记作为标签。然而,我们必须处理一个挑战,这与将多个文档合并成一个小批次有关。让我们通过一个示例来看,尝试创建一个包含前 10 个文档的小批次。首先我们将它们数值化:

nums_samp = toks200[:10].map(num)

现在让我们看看这 10 条电影评论中每条有多少个标记:

nums_samp.map(len)
(#10) [228,238,121,290,196,194,533,124,581,155]

记住,PyTorch 的 DataLoader 需要将批次中的所有项目整合到一个张量中,而一个张量具有固定的形状(即,每个轴上都有特定的长度,并且所有项目必须一致)。这应该听起来很熟悉:我们在图像中也遇到了同样的问题。在那种情况下,我们使用裁剪、填充和/或压缩来使所有输入大小相同。对于文档来说,裁剪可能不是一个好主意,因为我们可能会删除一些关键信息(话虽如此,对于图像也是同样的问题,我们在那里使用裁剪;数据增强在自然语言处理领域尚未得到很好的探索,因此也许在自然语言处理中也有使用裁剪的机会!)。你不能真正“压缩”一个文档。所以只剩下填充了!

我们将扩展最短的文本以使它们都具有相同的大小。为此,我们使用一个特殊的填充标记,该标记将被我们的模型忽略。此外,为了避免内存问题并提高性能,我们将大致相同长度的文本批量处理在一起(对于训练集进行一些洗牌)。我们通过在每个时期之前(对于训练集)按长度对文档进行排序来实现这一点。结果是,整理成单个批次的文档往往具有相似的长度。我们不会将每个批次填充到相同的大小,而是使用每个批次中最大文档的大小作为目标大小。

动态调整图像大小

可以对图像执行类似的操作,这对于不规则大小的矩形图像特别有用,但在撰写本文时,尚无库提供良好的支持,也没有任何涵盖此内容的论文。然而,我们计划很快将其添加到 fastai 中,因此请关注本书的网站;一旦我们成功运行,我们将添加有关此内容的信息。

当使用TextBlockis_lm=False时,数据块 API 会自动为我们进行排序和填充。(对于语言模型数据,我们不会遇到这个问题,因为我们首先将所有文档连接在一起,然后将它们分成相同大小的部分。)

我们现在可以创建一个用于分类文本的模型:

learn = text_classifier_learner(dls_clas, AWD_LSTM, drop_mult=0.5,
                                metrics=accuracy).to_fp16()

在训练分类器之前的最后一步是从我们微调的语言模型中加载编码器。我们使用load_encoder而不是load,因为我们只有编码器的预训练权重可用;load默认情况下会在加载不完整的模型时引发异常:

learn = learn.load_encoder('finetuned')

微调分类器

最后一步是使用有区分性的学习率和逐步解冻进行训练。在计算机视觉中,我们经常一次性解冻整个模型,但对于 NLP 分类器,我们发现逐层解冻会产生真正的差异:

learn.fit_one_cycle(1, 2e-2)
epoch train_loss valid_loss accuracy time
0 0.347427 0.184480 0.929320 00:33

仅仅一个时期,我们就获得了与第一章中的训练相同的结果——还不错!我们可以将freeze_to设置为-2,以冻结除最后两个参数组之外的所有参数组:

learn.freeze_to(-2)
learn.fit_one_cycle(1, slice(1e-2/(2.6**4),1e-2))
epoch train_loss valid_loss accuracy time
0 0.247763 0.171683 0.934640 00:37

然后我们可以解冻更多层并继续训练:

learn.freeze_to(-3)
learn.fit_one_cycle(1, slice(5e-3/(2.6**4),5e-3))
epoch train_loss valid_loss accuracy time
0 0.193377 0.156696 0.941200 00:45

最后,整个模型!

learn.unfreeze()
learn.fit_one_cycle(2, slice(1e-3/(2.6**4),1e-3))
epoch train_loss valid_loss accuracy time
0 0.172888 0.153770 0.943120 01:01
1 0.161492 0.155567 0.942640 00:57

我们达到了 94.3%的准确率,这在仅仅三年前是最先进的性能。通过在所有文本上训练另一个模型,并对这两个模型的预测进行平均,我们甚至可以达到 95.1%的准确率,这是由 ULMFiT 论文引入的最先进技术。仅仅几个月前,通过微调一个更大的模型并使用昂贵的数据增强技术(将句子翻译成另一种语言,然后再翻译回来,使用另一个模型进行翻译)来打破了这一记录。

使用预训练模型让我们构建了一个非常强大的微调语言模型,可以用来生成假评论或帮助对其进行分类。这是令人兴奋的事情,但要记住这项技术也可以被用于恶意目的。

虚假信息和语言模型

即使是基于规则的简单算法,在广泛使用深度学习语言模型之前,也可以用来创建欺诈账户并试图影响决策者。ProPublica 的计算记者 Jeff Kao 分析了发送给美国联邦通信委员会(FCC)有关 2017 年废除网络中立提案的评论。在他的文章“可能伪造了一百多万条支持废除网络中立的评论”中,他报告了他是如何发现一大批反对网络中立的评论,这些评论似乎是通过某种 Mad Libs 风格的邮件合并生成的。在图 10-2 中,Kao 已经帮助着色编码了这些虚假评论,以突出它们的公式化特性。

图 10-2. FCC 在网络中立辩论期间收到的评论

Kao 估计“2200 多万条评论中不到 80 万条...可以被认为是真正独特的”,“超过 99%的真正独特评论支持保持网络中立”。

鉴于自 2017 年以来语言建模的进展,这种欺诈性活动现在几乎不可能被发现。您现在拥有所有必要的工具来创建一个引人注目的语言模型 - 可以生成与上下文相关、可信的文本。它不一定会完全准确或正确,但它会是可信的。想象一下,当这种技术与我们近年来了解到的各种虚假信息活动结合在一起时会意味着什么。看看 Reddit 对话中显示的图 10-3,其中基于 OpenAI 的 GPT-2 算法的语言模型正在讨论美国政府是否应该削减国防开支。

Reddit 上的算法自言自语

图 10-3. Reddit 上的算法自言自语

在这种情况下,解释了正在使用算法生成对话。但想象一下,如果一个坏演员决定在社交网络上发布这样的算法会发生什么 - 他们可以慢慢而谨慎地这样做,让算法随着时间逐渐发展出追随者和信任。要做到这一点并不需要太多资源,就可以让成千上万的账户这样做。在这种情况下,我们很容易想象到在线讨论的绝大部分都是来自机器人,而没有人会意识到这种情况正在发生。

我们已经开始看到机器学习被用来生成身份的例子。例如,图 10-4 显示了 Katie Jones 的 LinkedIn 个人资料。

图 10-4. Katie Jones 的 LinkedIn 个人资料

Katie Jones 在 LinkedIn 上与几位主流华盛顿智库成员有联系。但她并不存在。你看到的那张图片是由生成对抗网络自动生成的,而某人名为 Katie Jones 的确没有毕业于战略与国际研究中心。

许多人假设或希望算法将在这里为我们辩护 - 我们将开发能够自动识别自动生成内容的分类算法。然而,问题在于这将永远是一场军备竞赛,更好的分类(或鉴别器)算法可以用来创建更好的生成算法。

结论

在本章中,我们探讨了 fastai 库中提供的最后一个开箱即用的应用:文本。我们看到了两种类型的模型:可以生成文本的语言模型,以及可以确定评论是积极还是消极的分类器。为了构建一个最先进的分类器,我们使用了一个预训练的语言模型,对其进行微调以适应我们任务的语料库,然后使用其主体(编码器)与一个新的头部进行分类。

在结束本书的这一部分之前,我们将看看 fastai 库如何帮助您为您的特定问题组装数据。

问卷

  1. 什么是自监督学习?

  2. 什么是语言模型?

  3. 为什么语言模型被认为是自监督的?

  4. 自监督模型通常用于什么?

  5. 为什么我们要微调语言模型?

  6. 创建一流文本分类器的三个步骤是什么?

  7. 50,000 个未标记的电影评论如何帮助为 IMDb 数据集创建更好的文本分类器?

  8. 为语言模型准备数据的三个步骤是什么?

  9. 什么是标记化?为什么我们需要它?

  10. 列出三种标记化方法。

  11. 什么是 xxbos

  12. 列出 fastai 在标记化期间应用的四条规则。

  13. 为什么重复字符被替换为一个显示重复次数和被重复的字符的标记?

  14. 什么是数值化?

  15. 为什么会有单词被替换为“未知单词”标记?

  16. 使用批量大小为 64,表示第一批次的张量的第一行包含数据集的前 64 个标记。那个张量的第二行包含什么?第二批次的第一行包含什么?(小心 - 学生经常答错这个问题!一定要在书的网站上检查你的答案。)

  17. 为什么文本分类需要填充?为什么语言建模不需要填充?

  18. NLP 的嵌入矩阵包含什么?它的形状是什么?

  19. 什么是困惑度?

  20. 为什么我们必须将语言模型的词汇传递给分类器数据块?

  21. 什是逐步解冻?

  22. 为什么文本生成总是可能领先于自动识别机器生成的文本?

进一步研究

  1. 看看你能学到关于语言模型和虚假信息的什么。今天最好的语言模型是什么?看看它们的一些输出。你觉得它们令人信服吗?坏人如何最好地利用这样的模型来制造冲突和不确定性?

  2. 考虑到模型不太可能能够一致地识别机器生成的文本,可能需要哪些其他方法来处理利用深度学习的大规模虚假信息活动?

第十一章:使用 fastai 的中级 API 进行数据整理

原文:www.bookstack.cn/read/th-fastai-book/cedbd4fb150718e5.md

译者:飞龙

协议:CC BY-NC-SA 4.0

我们已经看到了TokenizerNumericalize对文本集合的处理方式,以及它们如何在数据块 API 中使用,该 API 直接使用TextBlock处理这些转换。但是,如果我们只想应用这些转换中的一个,要么是为了查看中间结果,要么是因为我们已经对文本进行了标记化,我们该怎么办?更一般地说,当数据块 API 不足以满足我们特定用例的需求时,我们需要使用 fastai 的中级 API来处理数据。数据块 API 是建立在该层之上的,因此它将允许您执行数据块 API 所做的一切,以及更多更多。

深入了解 fastai 的分层 API

fastai 库是建立在分层 API上的。在最顶层是应用程序,允许我们在五行代码中训练模型,正如我们在第一章中看到的。例如,对于为文本分类器创建DataLoaders,我们使用了这一行:

from fastai.text.all import *

dls = TextDataLoaders.from_folder(untar_data(URLs.IMDB), valid='test')

当您的数据排列方式与 IMDb 数据集完全相同时,工厂方法TextDataLoaders.from_folder非常方便,但实际上,情况通常不会如此。数据块 API 提供了更多的灵活性。正如我们在前一章中看到的,我们可以通过以下方式获得相同的结果:

path = untar_data(URLs.IMDB)
dls = DataBlock(
    blocks=(TextBlock.from_folder(path),CategoryBlock),
    get_y = parent_label,
    get_items=partial(get_text_files, folders=['train', 'test']),
    splitter=GrandparentSplitter(valid_name='test')
).dataloaders(path)

但有时它并不够灵活。例如,出于调试目的,我们可能需要仅应用与此数据块一起的部分转换。或者我们可能希望为 fastai 不直接支持的应用程序创建一个DataLoaders。在本节中,我们将深入探讨 fastai 内部用于实现数据块 API 的组件。了解这些将使您能够利用这个中间层 API 的强大和灵活性。

中级 API

中级 API 不仅包含用于创建DataLoaders的功能。它还具有回调系统,允许我们以任何我们喜欢的方式自定义训练循环,以及通用优化器。这两者将在第十六章中介绍。

转换

在前一章中研究标记化和数值化时,我们首先获取了一堆文本:

files = get_text_files(path, folders = ['train', 'test'])
txts = L(o.open().read() for o in files[:2000])

然后我们展示了如何使用Tokenizer对它们进行标记化

tok = Tokenizer.from_folder(path)
tok.setup(txts)
toks = txts.map(tok)
toks[0]
(#374) ['xxbos','xxmaj','well',',','"','cube','"','(','1997',')'...]

以及如何进行数值化,包括自动为我们的语料库创建词汇表:

num = Numericalize()
num.setup(toks)
nums = toks.map(num)
nums[0][:10]
tensor([   2,    8,   76,   10,   23, 3112,   23,   34, 3113,   33])

这些类还有一个decode方法。例如,Numericalize.decode会将字符串标记返回给我们:

nums_dec = num.decode(nums[0][:10]); nums_dec
(#10) ['xxbos','xxmaj','well',',','"','cube','"','(','1997',')']

Tokenizer.decode将其转换回一个字符串(但可能不完全与原始字符串相同;这取决于标记器是否是可逆的,在我们撰写本书时,默认的单词标记器不是):

tok.decode(nums_dec)
'xxbos xxmaj well , " cube " ( 1997 )'

decode被 fastai 的show_batchshow_results以及其他一些推断方法使用,将预测和小批量转换为人类可理解的表示。

在前面的示例中,对于toknum,我们创建了一个名为setup的对象(如果需要为tok训练标记器并为num创建词汇表),将其应用于我们的原始文本(通过将对象作为函数调用),然后最终将结果解码回可理解的表示。大多数数据预处理任务都需要这些步骤,因此 fastai 提供了一个封装它们的类。这就是Transform类。TokenizeNumericalize都是Transform

一般来说,Transform是一个行为类似于函数的对象,它具有一个可选的setup方法,用于初始化内部状态(例如num内部的词汇表),以及一个可选的decode方法,用于反转函数(正如我们在tok中看到的那样,这种反转可能不完美)。

decode 的一个很好的例子可以在我们在 第七章 中看到的 Normalize 转换中找到:为了能够绘制图像,它的 decode 方法会撤消归一化(即,乘以标准差并加回均值)。另一方面,数据增强转换没有 decode 方法,因为我们希望展示图像上的效果,以确保数据增强按我们的意愿进行工作。

Transform 的一个特殊行为是它们总是应用于元组。一般来说,我们的数据总是一个元组 (input, target)(有时有多个输入或多个目标)。当对这样的项目应用转换时,例如 Resize,我们不希望整个元组被调整大小;相反,我们希望分别调整输入(如果适用)和目标(如果适用)。对于进行数据增强的批处理转换也是一样的:当输入是图像且目标是分割掩模时,需要将转换(以相同的方式)应用于输入和目标。

如果我们将一个文本元组传递给 tok,我们可以看到这种行为:

tok((txts[0], txts[1]))
((#374) ['xxbos','xxmaj','well',',','"','cube','"','(','1997',')'...],
 (#207)
 > ['xxbos','xxmaj','conrad','xxmaj','hall','went','out','with','a','bang'...])

编写您自己的转换

如果您想编写一个自定义的转换来应用于您的数据,最简单的方法是编写一个函数。正如您在这个例子中看到的,Transform 只会应用于匹配的类型,如果提供了类型(否则,它将始终被应用)。在下面的代码中,函数签名中的 :int 表示 f 仅应用于 ints。这就是为什么 tfm(2.0) 返回 2.0,但 tfm(2) 在这里返回 3

def f(x:int): return x+1
tfm = Transform(f)
tfm(2),tfm(2.0)
(3, 2.0)

在这里,f 被转换为一个没有 setup 和没有 decode 方法的 Transform

Python 有一种特殊的语法,用于将一个函数(如 f)传递给另一个函数(或类似函数的东西,在 Python 中称为 callable),称为 decorator。通过在可调用对象前加上 @ 并将其放在函数定义之前来使用装饰器(关于 Python 装饰器有很多很好的在线教程,如果这对您来说是一个新概念,请查看其中一个)。以下代码与前面的代码相同:

@Transform
def f(x:int): return x+1
f(2),f(2.0)
(3, 2.0)

如果您需要 setupdecode,您需要对 Transform 进行子类化,以在 encodes 中实现实际的编码行为,然后(可选)在 setups 中实现设置行为和在 decodes 中实现解码行为:

class NormalizeMean(Transform):
    def setups(self, items): self.mean = sum(items)/len(items)
    def encodes(self, x): return x-self.mean
    def decodes(self, x): return x+self.mean

在这里,NormalizeMean 将在设置期间初始化某个状态(传递的所有元素的平均值);然后转换是减去该平均值。为了解码目的,我们通过添加平均值来实现该转换的反向。这里是 NormalizeMean 的一个示例:

tfm = NormalizeMean()
tfm.setup([1,2,3,4,5])
start = 2
y = tfm(start)
z = tfm.decode(y)
tfm.mean,y,z
(3.0, -1.0, 2.0)

请注意,每个方法的调用和实现是不同的:

调用 实现
nn.Module(PyTorch) ()(即,作为函数调用) forward
Transform () encodes
Transform decode() decodes
Transform setup() setups

因此,例如,您永远不会直接调用 setups,而是会调用 setup。原因是 setup 在为您调用 setups 之前和之后做了一些工作。要了解有关 Transform 及如何使用它们根据输入类型实现不同行为的更多信息,请务必查看 fastai 文档中的教程。

Pipeline

要将几个转换组合在一起,fastai 提供了 Pipeline 类。我们通过向 Pipeline 传递一个 Transform 列表来定义一个 Pipeline;然后它将组合其中的转换。当您在对象上调用 Pipeline 时,它将自动按顺序调用其中的转换:

tfms = Pipeline([tok, num])
t = tfms(txts[0]); t[:20]
tensor([   2,    8,   76,   10,   23, 3112,   23,   34, 3113,   33,   10,    8,
 > 4477,   22,   88,   32,   10,   27,   42,   14])

您可以对编码结果调用 decode,以获取可以显示和分析的内容:

tfms.decode(t)[:100]
'xxbos xxmaj well , " cube " ( 1997 ) , xxmaj vincenzo \'s first movie , was one
 > of the most interesti'

Transform 中与 Transform 不同的部分是设置。要在一些数据上正确设置 TransformPipeline,您需要使用 TfmdLists

TfmdLists 和 Datasets:转换的集合

您的数据通常是一组原始项目(如文件名或 DataFrame 中的行),您希望对其应用一系列转换。我们刚刚看到,一系列转换在 fastai 中由Pipeline表示。将这个Pipeline与您的原始项目组合在一起的类称为TfmdLists

TfmdLists

以下是在前一节中看到的转换的简短方式:

tls = TfmdLists(files, [Tokenizer.from_folder(path), Numericalize])

在初始化时,TfmdLists将自动调用每个Transformsetup方法,依次提供每个原始项目而不是由所有先前的Transform转换的项目。我们可以通过索引到TfmdLists中的任何原始元素来获得我们的Pipeline的结果:

t = tls[0]; t[:20]
tensor([    2,     8,    91,    11,    22,  5793,    22,    37,  4910,    34,
 > 11,     8, 13042,    23,   107,    30,    11,    25,    44,    14])

TfmdLists知道如何解码以进行显示:

tls.decode(t)[:100]
'xxbos xxmaj well , " cube " ( 1997 ) , xxmaj vincenzo \'s first movie , was one
 > of the most interesti'

实际上,它甚至有一个show方法:

tls.show(t)
xxbos xxmaj well , " cube " ( 1997 ) , xxmaj vincenzo 's first movie , was one
 > of the most interesting and tricky ideas that xxmaj i 've ever seen when
 > talking about movies . xxmaj they had just one scenery , a bunch of actors
 > and a plot . xxmaj so , what made it so special were all the effective
 > direction , great dialogs and a bizarre condition that characters had to deal
 > like rats in a labyrinth . xxmaj his second movie , " cypher " ( 2002 ) , was
 > all about its story , but it was n't so good as " cube " but here are the
 > characters being tested like rats again .

 " nothing " is something very interesting and gets xxmaj vincenzo coming back
 > to his ' cube days ' , locking the characters once again in a very different
 > space with no time once more playing with the characters like playing with
 > rats in an experience room . xxmaj but instead of a thriller sci - fi ( even
 > some of the promotional teasers and trailers erroneous seemed like that ) , "
 > nothing " is a loose and light comedy that for sure can be called a modern
 > satire about our society and also about the intolerant world we 're living .
 > xxmaj once again xxmaj xxunk amaze us with a great idea into a so small kind
 > of thing . 2 actors and a blinding white scenario , that 's all you got most
 > part of time and you do n't need more than that . xxmaj while " cube " is a
 > claustrophobic experience and " cypher " confusing , " nothing " is
 > completely the opposite but at the same time also desperate .

 xxmaj this movie proves once again that a smart idea means much more than just
 > a millionaire budget . xxmaj of course that the movie fails sometimes , but
 > its prime idea means a lot and offsets any flaws . xxmaj there 's nothing
 > more to be said about this movie because everything is a brilliant surprise
 > and a totally different experience that i had in movies since " cube " .

TfmdLists以“s”命名,因为它可以使用splits参数处理训练集和验证集。您只需要传递在训练集中的元素的索引和在验证集中的元素的索引:

cut = int(len(files)*0.8)
splits = [list(range(cut)), list(range(cut,len(files)))]
tls = TfmdLists(files, [Tokenizer.from_folder(path), Numericalize],
                splits=splits)

然后可以通过trainvalid属性访问它们:

tls.valid[0][:20]
tensor([    2,     8,    20,    30,    87,   510,  1570,    12,   408,   379,
 > 4196,    10,     8,    20,    30,    16,    13, 12216,   202,   509])

如果您手动编写了一个Transform,一次执行所有预处理,将原始项目转换为具有输入和目标的元组,那么TfmdLists是您需要的类。您可以使用dataloaders方法直接将其转换为DataLoaders对象。这是我们稍后在本章中将要做的事情。

一般来说,您将有两个(或更多)并行的转换流水线:一个用于将原始项目处理为输入,另一个用于将原始项目处理为目标。例如,在这里,我们定义的流水线仅将原始文本处理为输入。如果我们要进行文本分类,还必须将标签处理为目标。

为此,我们需要做两件事。首先,我们从父文件夹中获取标签名称。有一个名为parent_label的函数:

lbls = files.map(parent_label)
lbls
(#50000) ['pos','pos','pos','pos','pos','pos','pos','pos','pos','pos'...]

然后我们需要一个Transform,在设置期间将抓取的唯一项目构建为词汇表,然后在调用时将字符串标签转换为整数。fastai 为我们提供了这个;它被称为Categorize

cat = Categorize()
cat.setup(lbls)
cat.vocab, cat(lbls[0])
((#2) ['neg','pos'], TensorCategory(1))

要在我们的文件列表上自动执行整个设置,我们可以像以前一样创建一个TfmdLists

tls_y = TfmdLists(files, [parent_label, Categorize()])
tls_y[0]
TensorCategory(1)

但是然后我们得到了两个分开的对象用于我们的输入和目标,这不是我们想要的。这就是Datasets发挥作用的地方。

Datasets

Datasets将并行应用两个(或更多)流水线到相同的原始对象,并构建一个包含结果的元组。与TfmdLists一样,它将自动为我们进行设置,当我们索引到Datasets时,它将返回一个包含每个流水线结果的元组:

x_tfms = [Tokenizer.from_folder(path), Numericalize]
y_tfms = [parent_label, Categorize()]
dsets = Datasets(files, [x_tfms, y_tfms])
x,y = dsets[0]
x[:20],y

TfmdLists一样,我们可以将splits传递给Datasets以在训练和验证集之间拆分我们的数据:

x_tfms = [Tokenizer.from_folder(path), Numericalize]
y_tfms = [parent_label, Categorize()]
dsets = Datasets(files, [x_tfms, y_tfms], splits=splits)
x,y = dsets.valid[0]
x[:20],y
(tensor([    2,     8,    20,    30,    87,   510,  1570,    12,   408,   379,
 > 4196,    10,     8,    20,    30,    16,    13, 12216,   202,   509]),
 TensorCategory(0))

它还可以解码任何处理过的元组或直接显示它:

t = dsets.valid[0]
dsets.decode(t)
('xxbos xxmaj this movie had horrible lighting and terrible camera movements .
 > xxmaj this movie is a jumpy horror flick with no meaning at all . xxmaj the
 > slashes are totally fake looking . xxmaj it looks like some 17 year - old
 > idiot wrote this movie and a 10 year old kid shot it . xxmaj with the worst
 > acting you can ever find . xxmaj people are tired of knives . xxmaj at least
 > move on to guns or fire . xxmaj it has almost exact lines from " when a xxmaj
 > stranger xxmaj calls " . xxmaj with gruesome killings , only crazy people
 > would enjoy this movie . xxmaj it is obvious the writer does n\'t have kids
 > or even care for them . i mean at show some mercy . xxmaj just to sum it up ,
 > this movie is a " b " movie and it sucked . xxmaj just for your own sake , do
 > n\'t even think about wasting your time watching this crappy movie .',
 'neg')

最后一步是将我们的Datasets对象转换为DataLoaders,可以使用dataloaders方法完成。在这里,我们需要传递一个特殊参数来解决填充问题(正如我们在前一章中看到的)。这需要在我们批处理元素之前发生,所以我们将其传递给before_batch

dls = dsets.dataloaders(bs=64, before_batch=pad_input)

dataloaders直接在我们的Datasets的每个子集上调用DataLoader。fastai 的DataLoader扩展了 PyTorch 中同名类,并负责将我们的数据集中的项目整理成批次。它有很多自定义点,但您应该知道的最重要的是:

after_item

在数据集中抓取项目后应用于每个项目。这相当于DataBlock中的item_tfms

before_batch

在整理之前应用于项目列表上。这是将项目填充到相同大小的理想位置。

after_batch

在构建后对整个批次应用。这相当于DataBlock中的batch_tfms

最后,这是为了准备文本分类数据所需的完整代码:

tfms = [[Tokenizer.from_folder(path), Numericalize], [parent_label, Categorize]]
files = get_text_files(path, folders = ['train', 'test'])
splits = GrandparentSplitter(valid_name='test')(files)
dsets = Datasets(files, tfms, splits=splits)
dls = dsets.dataloaders(dl_type=SortedDL, before_batch=pad_input)

与之前的代码的两个不同之处是使用GrandparentSplitter来分割我们的训练和验证数据,以及dl_type参数。这是告诉dataloaders使用DataLoaderSortedDL类,而不是通常的类。SortedDL通过将大致相同长度的样本放入批次来构建批次。

这与我们之前的DataBlock完全相同:

path = untar_data(URLs.IMDB)
dls = DataBlock(
    blocks=(TextBlock.from_folder(path),CategoryBlock),
    get_y = parent_label,
    get_items=partial(get_text_files, folders=['train', 'test']),
    splitter=GrandparentSplitter(valid_name='test')
).dataloaders(path)

但现在你知道如何定制每一个部分了!

让我们现在通过一个计算机视觉示例练习刚学到的关于使用这个中级 API 进行数据预处理。

应用中级数据 API:SiamesePair

一个暹罗模型需要两张图片,并且必须确定它们是否属于同一类。在这个例子中,我们将再次使用宠物数据集,并准备数据用于一个模型,该模型将预测两张宠物图片是否属于同一品种。我们将在这里解释如何为这样的模型准备数据,然后我们将在第十五章中训练该模型。

首先要做的是-让我们获取数据集中的图片:

from fastai.vision.all import *
path = untar_data(URLs.PETS)
files = get_image_files(path/"images")

如果我们根本不关心显示我们的对象,我们可以直接创建一个转换来完全预处理那个文件列表。但是我们想要查看这些图片,因此我们需要创建一个自定义类型。当您在TfmdListsDatasets对象上调用show方法时,它将解码项目,直到达到包含show方法的类型,并使用它来显示对象。该show方法会传递一个ctx,它可以是图像的matplotlib轴,也可以是文本的 DataFrame 行。

在这里,我们创建了一个SiameseImage对象,它是Tuple的子类,旨在包含三个东西:两张图片和一个布尔值,如果图片是同一品种则为True。我们还实现了特殊的show方法,使其将两张图片与中间的黑线连接起来。不要太担心if测试中的部分(这是在 Python 图片而不是张量时显示SiameseImage的部分);重要的部分在最后三行:

class SiameseImage(Tuple):
    def show(self, ctx=None, **kwargs):
        img1,img2,same_breed = self
        if not isinstance(img1, Tensor):
            if img2.size != img1.size: img2 = img2.resize(img1.size)
            t1,t2 = tensor(img1),tensor(img2)
            t1,t2 = t1.permute(2,0,1),t2.permute(2,0,1)
        else: t1,t2 = img1,img2
        line = t1.new_zeros(t1.shape[0], t1.shape[1], 10)
        return show_image(torch.cat([t1,line,t2], dim=2),
                          title=same_breed, ctx=ctx)

让我们创建一个第一个SiameseImage并检查我们的show方法是否有效:

img = PILImage.create(files[0])
s = SiameseImage(img, img, True)
s.show();

我们也可以尝试一个不属于同一类的第二张图片:

img1 = PILImage.create(files[1])
s1 = SiameseImage(img, img1, False)
s1.show();

我们之前看到的转换的重要之处是它们会分派到元组或其子类。这正是为什么在这种情况下我们选择子类化Tuple的原因-这样,我们可以将适用于图像的任何转换应用于我们的SiameseImage,并且它将应用于元组中的每个图像:

s2 = Resize(224)(s1)
s2.show();

这里Resize转换应用于两个图片中的每一个,但不应用于布尔标志。即使我们有一个自定义类型,我们也可以从库中的所有数据增强转换中受益。

现在我们准备构建Transform,以便为暹罗模型准备数据。首先,我们需要一个函数来确定所有图片的类别:

def label_func(fname):
    return re.match(r'^(.*)_\d+.jpg$', fname.name).groups()[0]

对于每张图片,我们的转换将以 0.5 的概率从同一类中绘制一张图片,并返回一个带有真标签的SiameseImage,或者从另一类中绘制一张图片并返回一个带有假标签的SiameseImage。这一切都在私有的_draw函数中完成。训练集和验证集之间有一个区别,这就是为什么转换需要用拆分初始化:在训练集上,我们将每次读取一张图片时进行随机选择,而在验证集上,我们将在初始化时进行一次性随机选择。这样,在训练期间我们会得到更多不同的样本,但始终是相同的验证集:

class SiameseTransform(Transform):
    def __init__(self, files, label_func, splits):
        self.labels = files.map(label_func).unique()
        self.lbl2files = {l: L(f for f in files if label_func(f) == l)
                          for l in self.labels}
        self.label_func = label_func
        self.valid = {f: self._draw(f) for f in files[splits[1]]}

    def encodes(self, f):
        f2,t = self.valid.get(f, self._draw(f))
        img1,img2 = PILImage.create(f),PILImage.create(f2)
        return SiameseImage(img1, img2, t)

    def _draw(self, f):
        same = random.random() < 0.5
        cls = self.label_func(f)
        if not same:
            cls = random.choice(L(l for l in self.labels if l != cls))
        return random.choice(self.lbl2files[cls]),same

然后我们可以创建我们的主要转换:

splits = RandomSplitter()(files)
tfm = SiameseTransform(files, label_func, splits)
tfm(files[0]).show();

在数据收集的中级 API 中,我们有两个对象可以帮助我们在一组项目上应用转换:TfmdListsDatasets。如果您记得刚才看到的内容,一个应用一系列转换的Pipeline,另一个并行应用多个Pipeline,以构建元组。在这里,我们的主要转换已经构建了元组,因此我们使用TfmdLists

tls = TfmdLists(files, tfm, splits=splits)
show_at(tls.valid, 0);

最后,我们可以通过调用dataloaders方法在DataLoaders中获取我们的数据。这里需要注意的一点是,这个方法不像DataBlock那样接受item_tfmsbatch_tfms。fastai 的DataLoader有几个钩子,这些钩子以事件命名;在我们抓取项目后应用的内容称为after_item,在构建批次后应用的内容称为after_batch

dls = tls.dataloaders(after_item=[Resize(224), ToTensor],
    after_batch=[IntToFloatTensor, Normalize.from_stats(*imagenet_stats)])

请注意,我们需要传递比通常更多的转换,这是因为数据块 API 通常会自动添加它们:

  • ToTensor是将图像转换为张量的函数(再次,它应用于元组的每个部分)。

  • IntToFloatTensor将包含 0 到 255 之间整数的图像张量转换为浮点数张量,并除以 255,使值在 0 到 1 之间。

现在我们可以使用这个DataLoaders来训练模型。与cnn_learner提供的通常模型相比,它需要更多的定制,因为它必须接受两个图像而不是一个,但我们将看到如何创建这样的模型并在第十五章中进行训练。

结论

fastai 提供了分层 API。当数据处于通常设置之一时,只需一行代码即可获取数据,这使得初学者可以专注于训练模型,而无需花费太多时间组装数据。然后,高级数据块 API 通过允许您混合和匹配构建块来提供更多灵活性。在其下面,中级 API 为您提供更大的灵活性,以在项目上应用转换。在您的实际问题中,这可能是您需要使用的内容,我们希望它使数据处理步骤尽可能简单。

问卷调查

  1. 为什么我们说 fastai 具有“分层”API?这是什么意思?

  2. Transform为什么有一个decode方法?它是做什么的?

  3. Transform为什么有一个setup方法?它是做什么的?

  4. 当在元组上调用Transform时,它是如何工作的?

  5. 编写自己的Transform时需要实现哪些方法?

  6. 编写一个完全规范化项目的Normalize转换(减去数据集的平均值并除以标准差),并且可以解码该行为。尽量不要偷看!

  7. 编写一个Transform,用于对标记化文本进行数字化(它应该从已见数据集自动设置其词汇,并具有decode方法)。如果需要帮助,请查看 fastai 的源代码。

  8. 什么是Pipeline

  9. 什么是TfmdLists

  10. 什么是Datasets?它与TfmdLists有什么不同?

  11. 为什么TfmdListsDatasets带有“s”这个名字?

  12. 如何从TfmdListsDatasets构建DataLoaders

  13. 在从TfmdListsDatasets构建DataLoaders时,如何传递item_tfmsbatch_tfms

  14. 当您希望自定义项目与show_batchshow_results等方法一起使用时,您需要做什么?

  15. 为什么我们可以轻松地将 fastai 数据增强转换应用于我们构建的SiamesePair

进一步研究

  1. 使用中级 API 在自己的数据集上准备DataLoaders中的数据。尝试在 Pet 数据集和 Adult 数据集上进行此操作,这两个数据集来自第一章。

  2. 查看fastai 文档中的 Siamese 教程,了解如何为新类型的项目自定义show_batchshow_results的行为。在您自己的项目中实现它。

理解 fastai 的应用:总结

恭喜你——你已经完成了本书中涵盖训练模型和使用深度学习的关键实用部分的所有章节!你知道如何使用所有 fastai 内置的应用程序,以及如何使用数据块 API 和损失函数进行定制。你甚至知道如何从头开始创建神经网络并训练它!(希望你现在也知道一些问题要问,以确保你的创作有助于改善社会。)

你已经掌握的知识足以创建许多类型的神经网络应用的完整工作原型。更重要的是,它将帮助你了解深度学习模型的能力和局限性,以及如何设计一个适应它们的系统。

在本书的其余部分,我们将逐个拆解这些应用程序,以了解它们构建在哪些基础之上。这对于深度学习从业者来说是重要的知识,因为它使您能够检查和调试您构建的模型,并创建定制的新应用程序,以适应您特定的项目。

第三部分:深度学习的基础。

第十二章:从头开始的语言模型

原文:www.bookstack.cn/read/th-fastai-book/3279cd1df4e892dc.md

译者:飞龙

协议:CC BY-NC-SA 4.0

我们现在准备深入…深入深度学习!您已经学会了如何训练基本的神经网络,但是如何从那里创建最先进的模型呢?在本书的这一部分,我们将揭开所有的神秘,从语言模型开始。

您在第十章中看到了如何微调预训练的语言模型以构建文本分类器。在本章中,我们将解释该模型的内部结构以及 RNN 是什么。首先,让我们收集一些数据,这些数据将允许我们快速原型化各种模型。

数据

每当我们开始处理一个新问题时,我们总是首先尝试想出一个最简单的数据集,这样可以让我们快速轻松地尝试方法并解释结果。几年前我们开始进行语言建模时,我们没有找到任何可以快速原型的数据集,所以我们自己制作了一个。我们称之为Human Numbers,它简单地包含了用英语写出的前 10000 个数字。

Jeremy 说

我在高度经验丰富的从业者中经常看到的一个常见实际错误是在分析过程中未能在适当的时间使用适当的数据集。特别是,大多数人倾向于从太大、太复杂的数据集开始。

我们可以按照通常的方式下载、提取并查看我们的数据集:

from fastai.text.all import *
path = untar_data(URLs.HUMAN_NUMBERS)
path.ls()
(#2) [Path('train.txt'),Path('valid.txt')]

让我们打开这两个文件,看看里面有什么。首先,我们将把所有文本连接在一起,忽略数据集给出的训练/验证拆分(我们稍后会回到这一点):

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
(#9998) ['one \n','two \n','three \n','four \n','five \n','six \n','seven
 > \n','eight \n','nine \n','ten \n'...]

我们将所有这些行连接在一个大流中。为了标记我们从一个数字到下一个数字的转变,我们使用.作为分隔符:

text = ' . '.join([l.strip() for l in lines])
text[:100]
'one . two . three . four . five . six . seven . eight . nine . ten . eleven .
 > twelve . thirteen . fo'

我们可以通过在空格上拆分来对这个数据集进行标记化:

tokens = text.split(' ')
tokens[:10]
['one', '.', 'two', '.', 'three', '.', 'four', '.', 'five', '.']

为了数值化,我们必须创建一个包含所有唯一标记(我们的词汇表)的列表:

vocab = L(*tokens).unique()
vocab
(#30) ['one','.','two','three','four','five','six','seven','eight','nine'...]

然后,我们可以通过查找每个词在词汇表中的索引,将我们的标记转换为数字:

word2idx = {w:i for i,w in enumerate(vocab)}
nums = L(word2idx[i] for i in tokens)
nums
(#63095) [0,1,2,1,3,1,4,1,5,1...]

现在我们有了一个小数据集,语言建模应该是一个简单的任务,我们可以构建我们的第一个模型。

我们的第一个从头开始的语言模型

将这转换为神经网络的一个简单方法是指定我们将基于前三个单词预测每个单词。我们可以创建一个包含每个三个单词序列的列表作为我们的自变量,以及每个序列后面的下一个单词作为因变量。

我们可以用普通的 Python 来做到这一点。首先让我们用标记来确认它是什么样子的:

L((tokens[i:i+3], tokens[i+3]) for i in range(0,len(tokens)-4,3))
(#21031) [(['one', '.', 'two'], '.'),(['.', 'three', '.'], 'four'),(['four',
 > '.', 'five'], '.'),(['.', 'six', '.'], 'seven'),(['seven', '.', 'eight'],
 > '.'),(['.', 'nine', '.'], 'ten'),(['ten', '.', 'eleven'], '.'),(['.',
 > 'twelve', '.'], 'thirteen'),(['thirteen', '.', 'fourteen'], '.'),(['.',
 > 'fifteen', '.'], 'sixteen')...]

现在我们将使用数值化值的张量来做到这一点,这正是模型实际使用的:

seqs = L((tensor(nums[i:i+3]), nums[i+3]) for i in range(0,len(nums)-4,3))
seqs
(#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类轻松地对这些进行批处理。现在,我们将随机拆分序列:

bs = 64
cut = int(len(seqs) * 0.8)
dls = DataLoaders.from_dsets(seqs[:cut], seqs[cut:], bs=64, shuffle=False)

现在我们可以创建一个神经网络架构,它以三个单词作为输入,并返回词汇表中每个可能的下一个单词的概率预测。我们将使用三个标准线性层,但有两个调整。

第一个调整是,第一个线性层将仅使用第一个词的嵌入作为激活,第二层将使用第二个词的嵌入加上第一层的输出激活,第三层将使用第三个词的嵌入加上第二层的输出激活。关键效果是每个词都在其前面的任何单词的信息上下文中被解释。

第二个调整是,这三个层中的每一个将使用相同的权重矩阵。一个词对来自前面单词的激活的影响方式不应该取决于单词的位置。换句话说,激活值会随着数据通过层移动而改变,但是层权重本身不会从一层到另一层改变。因此,一个层不会学习一个序列位置;它必须学会处理所有位置。

由于层权重不会改变,您可能会认为顺序层是“重复的相同层”。事实上,PyTorch 使这一点具体化;我们可以创建一个层并多次使用它。

我们的 PyTorch 语言模型

我们现在可以创建我们之前描述的语言模型模块:

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 显示了我们将如何用一个隐藏层表示神经网络。

简单神经网络的图示表示

图 12-1。简单神经网络的图示表示

每个形状代表激活:矩形代表输入,圆圈代表隐藏(内部)层激活,三角形代表输出激活。我们将在本章中的所有图表中使用这些形状(在 图 12-2 中总结)。

我们图示表示中使用的形状

图 12-2。我们图示表示中使用的形状

箭头代表实际的层计算——即线性层后跟激活函数。使用这种符号,图 12-3 显示了我们简单语言模型的外观。

我们基本语言模型的表示

图 12-3。我们基本语言模型的表示

为了简化事情,我们已经从每个箭头中删除了层计算的细节。我们还对箭头进行了颜色编码,使所有具有相同颜色的箭头具有相同的权重矩阵。例如,所有输入层使用相同的嵌入矩阵,因此它们都具有相同的颜色(绿色)。

让我们尝试训练这个模型,看看效果如何:

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

要查看这是否有效,请查看一个非常简单的模型会给我们什么结果。在这种情况下,我们总是可以预测最常见的标记,因此让我们找出在我们的验证集中最常见的目标是哪个标记:

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
(tensor(29), 'thousand', 0.15165200855716662)

最常见的标记的索引是 29,对应于标记 thousand。总是预测这个标记将给我们大约 15% 的准确率,所以我们表现得更好!

Alexis 说

我的第一个猜测是分隔符会是最常见的标记,因为每个数字都有一个分隔符。但查看 tokens 提醒我,大数字用许多单词写成,所以在通往 10,000 的路上,你会经常写“thousand”:five thousand, five thousand and one, five thousand and two 等等。糟糕!查看数据对于注意到微妙特征以及尴尬明显的特征都很有帮助。

这是一个不错的第一个基线。让我们看看如何用循环重构它。

我们的第一个循环神经网络

查看我们模块的代码,我们可以通过用 for 循环替换调用层的重复代码来简化它。除了使我们的代码更简单外,这样做的好处是我们将能够同样适用于不同长度的标记序列——我们不会被限制在长度为三的标记列表上:

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)

让我们检查一下,看看我们使用这种重构是否得到相同的结果:

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 相同的箭头颜色)。

基本循环神经网络

图 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时记住了其激活,这代表了它在批处理中用于不同样本的情况:

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是每个这些部分的长度。例如,如果我们使用整个数据集(尽管我们实际上将在一会儿将其分成训练和验证),我们有:

m = len(seqs)//bs
m,bs,len(seqs)
(328, 64, 21031)

第一批将由样本组成

(0, m, 2*m, ..., (bs-1)*m)

样本的第二批

(1, m+1, 2*m+1, ..., (bs-1)*m+1)

等等。这样,每个时期,模型将在每批次的每行上看到大小为3*m的连续文本块(因为每个文本的大小为 3)。

以下函数执行重新索引:

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以确保文本按顺序阅读:

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方法。由于我们实现了该方法来将模型的隐藏状态设置为零,这将确保我们在阅读这些连续文本块之前以干净的状态开始。我们也可以开始训练更长一点:

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 在每个标记后进行预测

图 12-5。RNN 在每个标记后进行预测

这很容易添加。我们需要首先改变我们的数据,使得因变量在每个三个输入词后的每个三个词中都有。我们使用一个属性sl(用于序列长度),并使其稍微变大:

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的第一个元素,我们可以看到它包含两个相同大小的列表。第二个列表与第一个相同,但偏移了一个元素:

[L(vocab[o] for o in s) for s in seqs[0]]
[(#16) ['one','.','two','.','three','.','four','.','five','.'...],
 (#16) ['.','two','.','three','.','four','.','five','.','six'...]]

现在我们需要修改我们的模型,使其在每个单词之后输出一个预测,而不仅仅是在一个三个词序列的末尾:

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之前,我们需要将它们展平:

def loss_func(inp, targ):
    return F.cross_entropy(inp.view(-1, len(vocab)), targ.view(-1))

我们现在可以使用这个损失函数来训练模型:

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

图 12-6. 2 层 RNN

展开的表示在图 12-7 中显示(类似于图 12-3)。

2 层展开的 RNN

图 12-7. 2 层展开的 RNN

让我们看看如何在实践中实现这一点。

模型

我们可以通过使用 PyTorch 的RNN类来节省一些时间,该类实现了我们之前创建的内容,但也给了我们堆叠多个 RNN 的选项,正如我们之前讨论的那样:

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_()
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 中的图表显示了浮点数的精度如何随着数字线变化。

浮点数的精度

图 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/shehis/her

在实践中,RNN 在保留句子中较早发生的记忆方面表现非常糟糕,这就是在 LSTM 中有另一个隐藏状态(称为cell state)的动机。cell state 将负责保持长期短期记忆,而隐藏状态将专注于预测下一个标记。让我们更仔细地看看如何实现这一点,并从头开始构建一个 LSTM。

从头开始构建一个 LSTM

为了构建一个 LSTM,我们首先必须了解其架构。图 12-9 显示了其内部结构。

显示 LSTM 内部架构的图表

图 12-9. LSTM 的架构

在这张图片中,我们的输入x t从左侧进入,带有先前的隐藏状态(h t-1)和 cell state(c t-1)。四个橙色框代表四个层(我们的神经网络),激活函数可以是 sigmoid(σ)或 tanh。tanh 只是一个重新缩放到范围-1 到 1 的 sigmoid 函数。它的数学表达式可以写成这样:

tanh ( x ) = e x +e -x e x -e -x = 2 σ ( 2 x ) - 1

其中σ是 sigmoid 函数。图中的绿色圆圈是逐元素操作。右侧输出的是新的隐藏状态(h t)和新的 cell state(c t),准备接受我们的下一个输入。新的隐藏状态也被用作输出,这就是为什么箭头分开向上移动。

让我们逐一查看四个神经网络(称为)并解释图表——但在此之前,请注意 cell state(顶部)几乎没有改变。它甚至没有直接通过神经网络!这正是为什么它将继续保持较长期的状态。

首先,将输入和旧隐藏状态的箭头连接在一起。在本章前面编写的 RNN 中,我们将它们相加。在 LSTM 中,我们将它们堆叠在一个大张量中。这意味着我们的嵌入的维度(即x t的维度)可以与隐藏状态的维度不同。如果我们将它们称为n_inn_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 输出结合,结果就是新的隐藏状态。在代码方面,我们可以这样写相同的步骤:

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 上移动一个张量,使其全部在一个连续的数组中),所以我们为输入和隐藏状态使用两个单独的层。优化和重构后的代码如下:

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方法将张量分成四部分。它的工作原理如下:

t = torch.arange(0,10); t
tensor([0, 1, 2, 3, 4, 5, 6, 7, 8, 9])
t.chunk(2)
(tensor([0, 1, 2, 3, 4]), tensor([5, 6, 7, 8, 9]))

现在让我们使用这个架构来训练一个语言模型!

使用 LSTMs 训练语言模型

这是与LMModel5相同的网络,使用了两层 LSTM。我们可以以更高的学习率进行训练,时间更短,获得更好的准确性:

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_()
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 语言模型”。这篇论文展示了如何有效地使用 dropout、激活正则化和时间激活正则化可以使一个 LSTM 击败以前需要更复杂模型的最新结果。作者将使用这些技术的 LSTM 称为AWD-LSTM。我们将依次看看这些技术。

Dropout

Dropout是由 Geoffrey Hinton 等人在“通过防止特征探测器的共适应来改进神经网络”中引入的一种正则化技术。基本思想是在训练时随机将一些激活变为零。这确保所有神经元都积极地朝着输出工作,如图 12-10 所示(来自 Nitish Srivastava 等人的“Dropout:防止神经网络过拟合的简单方法”)。

文章中显示 dropout 如何关闭神经元的图

图 12-10。在神经网络中应用 dropout(由 Nitish Srivastava 等人提供)

Hinton 在一次采访中解释了 dropout 的灵感时使用了一个很好的比喻:

我去了我的银行。出纳员不断变换,我问其中一个原因。他说他不知道,但他们经常被调动。我想这一定是因为需要员工之间的合作才能成功欺诈银行。这让我意识到,随机在每个示例中删除不同的神经元子集将防止阴谋,从而减少过拟合。

在同一次采访中,他还解释了神经科学提供了额外的灵感:

我们并不真正知道为什么神经元会突触。有一种理论是它们想要变得嘈杂以进行正则化,因为我们的参数比数据点多得多。dropout 的想法是,如果你有嘈杂的激活,你可以承担使用一个更大的模型。

这解释了为什么 dropout 有助于泛化的想法:首先它帮助神经元更好地合作;然后它使激活更嘈杂,从而使模型更健壮。

然而,我们可以看到,如果我们只是将这些激活置零而不做其他任何操作,我们的模型将会训练出问题:如果我们从五个激活的总和(由于我们应用了 ReLU,它们都是正数)变为只有两个,这不会有相同的规模。因此,如果我们以概率p应用 dropout,我们通过将所有激活除以1-p来重新缩放它们(平均p将被置零,所以剩下1-p),如图 12-11 所示。

介绍 dropout 的文章中的一个图,显示神经元是开启/关闭状态

图 12-11。应用 dropout 时为什么要缩放激活(由 Nitish Srivastava 等人提供)

这是 PyTorch 中 dropout 层的完整实现(尽管 PyTorch 的原生层实际上是用 C 而不是 Python 编写的):

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一样):

loss += alpha * activations.pow(2).mean()

时间激活正则化与我们在句子中预测标记有关。这意味着当我们按顺序阅读它们时,我们的 LSTM 的输出应该在某种程度上是有意义的。TAR 通过向损失添加惩罚来鼓励这种行为,使两个连续激活之间的差异尽可能小:我们的激活张量的形状为bs x sl x n_hid,我们在序列长度轴上(中间维度)读取连续激活。有了这个,TAR 可以表示如下:

loss += beta * (activations[:,1:] - activations[:,:-1]).pow(2).mean()

然后,alphabeta是要调整的两个超参数。为了使这项工作成功,我们需要让我们的带有 dropout 的模型返回三个东西:正确的输出,LSTM 在 dropout 之前的激活以及 LSTM 在 dropout 之后的激活。通常在 dropout 后的激活上应用 AR(以免惩罚我们之后转换为零的激活),而 TAR 应用在未经 dropout 的激活上(因为这些零会在两个连续时间步之间产生很大的差异)。然后,一个名为RNNRegularizer的回调将为我们应用这种正则化。

训练带有权重绑定的正则化 LSTM

我们可以将 dropout(应用在我们进入输出层之前)与 AR 和 TAR 相结合,以训练我们之前的 LSTM。我们只需要返回三个东西而不是一个:我们的 LSTM 的正常输出,dropout 后的激活以及我们的 LSTM 的激活。最后两个将由回调RNNRegularization捕获,以便为其对损失的贡献做出贡献。

我们可以从AWD-LSTM 论文中添加另一个有用的技巧是权重绑定。在语言模型中,输入嵌入表示从英语单词到激活的映射,输出隐藏层表示从激活到英语单词的映射。直觉上,我们可能会期望这些映射是相同的。我们可以通过将相同的权重矩阵分配给这些层来在 PyTorch 中表示这一点:

self.h_o.weight = self.i_h.weight

LMMModel7中,我们包括了这些最终的调整:

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

learn = Learner(dls, LMModel7(len(vocab), 64, 2, 0.5),
                loss_func=CrossEntropyLossFlat(), metrics=accuracy,
                cbs=[ModelResetter, RNNRegularizer(alpha=2, beta=1)])

TextLearner会自动为我们添加这两个回调函数(使用alphabeta的默认值),因此我们可以简化前面的行:

learn = TextLearner(dls, LMModel7(len(vocab), 64, 2, 0.4),
                    loss_func=CrossEntropyLossFlat(), metrics=accuracy)

然后我们可以训练模型,并通过增加权重衰减到0.1来添加额外的正则化:

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 架构。您可以在书籍网站的额外章节中找到它。

问卷

  1. 如果您的项目数据集非常庞大且复杂,处理它需要大量时间,您应该怎么做?

  2. 为什么在创建语言模型之前我们要将数据集中的文档连接起来?

  3. 要使用标准的全连接网络来预测前三个单词给出的第四个单词,我们需要对模型进行哪两个调整?

  4. 我们如何在 PyTorch 中跨多个层共享权重矩阵?

  5. 编写一个模块,预测句子前两个单词给出的第三个单词,而不偷看。

  6. 什么是循环神经网络?

  7. 隐藏状态是什么?

  8. LMModel1中隐藏状态的等价物是什么?

  9. 为了在 RNN 中保持状态,为什么按顺序将文本传递给模型很重要?

  10. 什么是 RNN 的“展开”表示?

  11. 为什么在 RNN 中保持隐藏状态会导致内存和性能问题?我们如何解决这个问题?

  12. 什么是 BPTT?

  13. 编写代码打印出验证集的前几个批次,包括将标记 ID 转换回英文字符串,就像我们在第十章中展示的 IMDb 数据批次一样。

  14. ModelResetter回调函数的作用是什么?我们为什么需要它?

  15. 为每三个输入词预测一个输出词的缺点是什么?

  16. 为什么我们需要为LMModel4设计一个自定义损失函数?

  17. 为什么LMModel4的训练不稳定?

  18. 在展开表示中,我们可以看到递归神经网络有许多层。那么为什么我们需要堆叠 RNN 以获得更好的结果?

  19. 绘制一个堆叠(多层)RNN 的表示。

  20. 如果我们不经常调用detach,为什么在 RNN 中应该获得更好的结果?为什么在实践中可能不会发生这种情况?

  21. 为什么深度网络可能导致非常大或非常小的激活?这为什么重要?

  22. 在计算机的浮点数表示中,哪些数字是最精确的?

  23. 为什么消失的梯度会阻止训练?

  24. 在 LSTM 架构中有两个隐藏状态为什么有帮助?每个的目的是什么?

  25. 在 LSTM 中这两个状态被称为什么?

  26. tanh 是什么,它与 sigmoid 有什么关系?

  27. LSTMCell中这段代码的目的是什么:

    h = torch.stack([h, input], dim=1)
    
  28. 在 PyTorch 中chunk是做什么的?

  29. 仔细研究LSTMCell的重构版本,确保你理解它如何以及为什么与未重构版本执行相同的操作。

  30. 为什么我们可以为LMModel6使用更高的学习率?

  31. AWD-LSTM 模型中使用的三种正则化技术是什么?

  32. 什么是 dropout?

  33. 为什么我们要用 dropout 来缩放权重?这是在训练期间、推理期间还是两者都应用?

  34. Dropout中这行代码的目的是什么:

    if not self.training: return x
    
  35. 尝试使用bernoulli_来了解它的工作原理。

  36. 如何在 PyTorch 中将模型设置为训练模式?在评估模式下呢?

  37. 写出激活正则化的方程(数学或代码,任你选择)。它与权重衰减有什么不同?

  38. 写出时间激活正则化的方程(数学或代码,任你选择)。为什么我们不会在计算机视觉问题中使用这个?

  39. 语言模型中的权重绑定是什么?

进一步研究

  1. LMModel2中,为什么forward可以从h=0开始?为什么我们不需要写h=torch.zeros(...)

  2. 从头开始编写一个 LSTM 的代码(你可以参考图 12-9)。

  3. 搜索互联网了解 GRU 架构并从头开始实现它,尝试训练一个模型。看看能否获得类似于本章中看到的结果。将你的结果与 PyTorch 内置的GRU模块的结果进行比较。

  4. 查看 fastai 中 AWD-LSTM 的源代码,并尝试将每行代码映射到本章中展示的概念。

第十三章:卷积神经网络

原文:www.bookstack.cn/read/th-fastai-book/44d8848dfac0c1b0.md

译者:飞龙

协议:CC BY-NC-SA 4.0

在第四章中,我们学习了如何创建一个识别图像的神经网络。我们能够在区分 3 和 7 方面达到 98%以上的准确率,但我们也看到 fastai 内置的类能够接近 100%。让我们开始尝试缩小这个差距。

在本章中,我们将首先深入研究卷积是什么,并从头开始构建一个 CNN。然后,我们将研究一系列技术来改善训练稳定性,并学习库通常为我们应用的所有调整,以获得出色的结果。

卷积的魔力

机器学习从业者手中最强大的工具之一是特征工程特征是数据的一种转换,旨在使其更容易建模。例如,我们在第九章中用于我们表格数据集预处理的add_datepart函数向 Bulldozers 数据集添加了日期特征。我们能够从图像中创建哪些特征呢?

术语:特征工程

创建输入数据的新转换,以使其更容易建模。

在图像的背景下,特征是一种视觉上独特的属性。例如,数字 7 的特征是在数字的顶部附近有一个水平边缘,以及在其下方有一个从右上到左下的对角边缘。另一方面,数字 3 的特征是在数字的左上角和右下角有一个方向的对角边缘,在左下角和右上角有相反的对角边缘,在中间、顶部和底部有水平边缘等等。那么,如果我们能够提取关于每个图像中边缘出现位置的信息,然后将该信息用作我们的特征,而不是原始像素呢?

事实证明,在图像中找到边缘是计算机视觉中非常常见的任务,而且非常简单。为了做到这一点,我们使用一种称为卷积的东西。卷积只需要乘法和加法——这两种操作是我们将在本书中看到的每个深度学习模型中绝大部分工作的原因!

卷积将一个卷积核应用于图像。卷积核是一个小矩阵,例如图 13-1 右上角的 3×3 矩阵。

应用卷积到一个位置

图 13-1。将卷积应用到一个位置

左侧的 7×7 网格是我们将应用卷积核的图像。卷积操作将卷积核的每个元素与图像的一个 3×3 块的每个元素相乘。然后将这些乘积的结果相加。图 13-1 中的图示显示了将卷积核应用于图像中单个位置的示例,即围绕 18 单元格的 3×3 块。

让我们用代码来做这个。首先,我们创建一个小的 3×3 矩阵如下:

top_edge = tensor([[-1,-1,-1],
                   [ 0, 0, 0],
                   [ 1, 1, 1]]).float()

我们将称之为卷积核(因为这是时髦的计算机视觉研究人员称呼的)。当然,我们还需要一张图片:

path = untar_data(URLs.MNIST_SAMPLE)
im3 = Image.open(path/'train'/'3'/'12.png')
show_image(im3);

现在我们将取图像的顶部 3×3 像素正方形,并将这些值中的每一个与我们的卷积核中的每个项目相乘。然后我们将它们加在一起,就像这样:

im3_t = tensor(im3)
im3_t[0:3,0:3] * top_edge
tensor([[-0., -0., -0.],
        [0., 0., 0.],
        [0., 0., 0.]])
(im3_t[0:3,0:3] * top_edge).sum()
tensor(0.)

到目前为止并不是很有趣——左上角的所有像素都是白色的。但让我们选择一些更有趣的地方:

df = pd.DataFrame(im3_t[:10,:20])
df.style.set_properties(**{'font-size':'6pt'}).background_gradient('Greys')

数字的顶部部分

在 5,7 单元格处有一个顶边。让我们在那里重复我们的计算:

(im3_t[4:7,6:9] * top_edge).sum()
tensor(762.)

在 8,18 单元格处有一个右边缘。这给我们带来了什么?

(im3_t[7:10,17:20] * top_edge).sum()
tensor(-29.)

正如您所看到的,这个小计算返回了一个高数字,其中 3×3 像素的正方形代表顶边(即,在正方形顶部有低值,紧接着是高值)。这是因为我们的卷积核中的-1值在这种情况下影响很小,但1值影响很大。

让我们稍微看一下数学。过滤器将在我们的图像中取任意大小为 3×3 的窗口,如果我们像这样命名像素值

a 1 a 2 a 3 a 4 a 5 a 6 a 7 a 8 a 9

它将返回a 1 + a 2 + a 3 - a 7 - a 8 - a 9。如果我们在图像的某个部分,其中a 1a 2a 3加起来等于a 7a 8a 9,那么这些项将互相抵消,我们将得到 0。然而,如果a 1大于a 7a 2大于a 8a 3大于a 9,我们将得到一个更大的数字作为结果。因此,这个过滤器检测水平边缘,更准确地说,我们从图像顶部的亮部到底部的暗部。

将我们的过滤器更改为顶部为1,底部为-1的行将检测从暗到亮的水平边缘。将1-1放在列而不是行中会给我们检测垂直边缘的过滤器。每组权重将产生不同类型的结果。

让我们创建一个函数来为一个位置执行此操作,并检查它是否与之前的结果匹配:

def apply_kernel(row, col, kernel):
    return (im3_t[row-1:row+2,col-1:col+2] * kernel).sum()
apply_kernel(5,7,top_edge)
tensor(762.)

但请注意,我们不能将其应用于角落(例如,位置 0,0),因为那里没有完整的 3×3 正方形。

映射卷积核

我们可以在坐标网格上映射apply_kernel()。也就是说,我们将取我们的 3×3 卷积核,并将其应用于图像的每个 3×3 部分。例如,图 13-2 显示了 3×3 卷积核可以应用于 5×5 图像第一行的位置。

在网格上应用卷积核

图 13-2. 在网格上应用卷积核

要获得坐标网格,我们可以使用嵌套列表推导,如下所示:

[[(i,j) for j in range(1,5)] for i in range(1,5)]
[[(1, 1), (1, 2), (1, 3), (1, 4)],
 [(2, 1), (2, 2), (2, 3), (2, 4)],
 [(3, 1), (3, 2), (3, 3), (3, 4)],
 [(4, 1), (4, 2), (4, 3), (4, 4)]]

嵌套列表推导

在 Python 中经常使用嵌套列表推导,所以如果你以前没有见过它们,请花几分钟确保你理解这里发生了什么,并尝试编写自己的嵌套列表推导。

这是将我们的卷积核应用于坐标网格的结果:

rng = range(1,27)
top_edge3 = tensor([[apply_kernel(i,j,top_edge) for j in rng] for i in rng])

show_image(top_edge3);

看起来不错!我们的顶部边缘是黑色的,底部边缘是白色的(因为它们是顶部边缘的相反)。现在我们的图像中也包含负数,matplotlib已自动更改了我们的颜色,使得白色是图像中最小的数字,黑色是最高的,零显示为灰色。

我们也可以尝试同样的方法来处理左边缘:

left_edge = tensor([[-1,1,0],
                    [-1,1,0],
                    [-1,1,0]]).float()

left_edge3 = tensor([[apply_kernel(i,j,left_edge) for j in rng] for i in rng])

show_image(left_edge3);

正如我们之前提到的,卷积是将这样的内核应用于网格的操作。Vincent Dumoulin 和 Francesco Visin 的论文“深度学习卷积算术指南”中有许多出色的图表,展示了如何应用图像内核。图 13-3 是论文中的一个示例,显示了(底部)一个浅蓝色的 4×4 图像,应用了一个深蓝色的 3×3 内核,创建了一个顶部的 2×2 绿色输出激活图。

将 3x3 内核应用于 4x4 图像的结果

图 13-3。将 3×3 内核应用于 4×4 图像的结果(由 Vincent Dumoulin 和 Francesco Visin 提供)

看一下结果的形状。如果原始图像的高度为h,宽度为w,我们可以找到多少个 3×3 窗口?正如您从示例中看到的,有h-2乘以w-2个窗口,因此我们得到的结果图像的高度为h-2,宽度为w-2

我们不会从头开始实现这个卷积函数,而是使用 PyTorch 的实现(它比我们在 Python 中能做的任何事情都要快)。

PyTorch 中的卷积

卷积是一个如此重要且广泛使用的操作,PyTorch 已经内置了它。它被称为F.conv2d(回想一下,F是从torch.nn.functional中导入的 fastai,正如 PyTorch 建议的)。PyTorch 文档告诉我们它包括这些参数:

input

形状为(minibatch, in_channels, iH, iW)的输入张量

weight

形状为(out_channels, in_channels, kH, kW)的滤波器

这里iH,iW是图像的高度和宽度(即28,28),kH,kW是我们内核的高度和宽度(3,3)。但显然 PyTorch 期望这两个参数都是秩为 4 的张量,而当前我们只有秩为 2 的张量(即矩阵,或具有两个轴的数组)。

这些额外轴的原因是 PyTorch 有一些技巧。第一个技巧是 PyTorch 可以同时将卷积应用于多个图像。这意味着我们可以一次在批次中的每个项目上调用它!

第二个技巧是 PyTorch 可以同时应用多个内核。因此,让我们也创建对角边缘内核,然后将我们的四个边缘内核堆叠成一个单个张量:

diag1_edge = tensor([[ 0,-1, 1],
                     [-1, 1, 0],
                     [ 1, 0, 0]]).float()
diag2_edge = tensor([[ 1,-1, 0],
                     [ 0, 1,-1],
                     [ 0, 0, 1]]).float()

edge_kernels = torch.stack([left_edge, top_edge, diag1_edge, diag2_edge])
edge_kernels.shape
torch.Size([4, 3, 3])

为了测试这个,我们需要一个DataLoader和一个样本小批量。让我们使用数据块 API:

mnist = DataBlock((ImageBlock(cls=PILImageBW), CategoryBlock),
                  get_items=get_image_files,
                  splitter=GrandparentSplitter(),
                  get_y=parent_label)

dls = mnist.dataloaders(path)
xb,yb = first(dls.valid)
xb.shape
torch.Size([64, 1, 28, 28])

默认情况下,fastai 在使用数据块时会将数据放在 GPU 上。让我们将其移动到 CPU 用于我们的示例:

xb,yb = to_cpu(xb),to_cpu(yb)

一个批次包含 64 张图片,每张图片有 1 个通道,每个通道有 28×28 个像素。F.conv2d也可以处理多通道(彩色)图像。通道是图像中的单个基本颜色——对于常规全彩图像,有三个通道,红色、绿色和蓝色。PyTorch 将图像表示为一个秩为 3 的张量,具有以下维度:

[*channels*, *rows*, *columns*]

我们将在本章后面看到如何处理多个通道。传递给F.conv2d的内核需要是秩为 4 的张量:

[*channels_in*, *features_out*, *rows*, *columns*]

edge_kernels目前缺少其中一个:我们需要告诉 PyTorch 内核中的输入通道数是 1,我们可以通过在第一个位置插入一个大小为 1 的轴来实现(这称为单位轴),PyTorch 文档显示in_channels应该是预期的。要在张量中插入一个单位轴,我们使用unsqueeze方法:

edge_kernels.shape,edge_kernels.unsqueeze(1).shape
(torch.Size([4, 3, 3]), torch.Size([4, 1, 3, 3]))

现在这是edge_kernels的正确形状。让我们将所有这些传递给conv2d

edge_kernels = edge_kernels.unsqueeze(1)
batch_features = F.conv2d(xb, edge_kernels)
batch_features.shape
torch.Size([64, 4, 26, 26])

输出形状显示我们有 64 个图像在小批量中,4 个内核,以及 26×26 的边缘映射(我们从前面讨论中开始是 28×28 的图像,但每边丢失一个像素)。我们可以看到我们得到了与手动操作时相同的结果:

show_image(batch_features[0,0]);

PyTorch 最重要的技巧是它可以使用 GPU 并行地完成所有这些工作-将多个核应用于多个图像,跨多个通道。并行进行大量工作对于使 GPU 高效工作至关重要;如果我们一次执行每个操作,通常会慢几百倍(如果我们使用前一节中的手动卷积循环,将慢数百万倍!)。因此,要成为一名优秀的深度学习从业者,一个需要练习的技能是让 GPU 一次处理大量工作。

不要在每个轴上丢失这两个像素会很好。我们这样做的方法是添加填充,简单地在图像周围添加额外的像素。最常见的是添加零像素。

步幅和填充

通过适当的填充,我们可以确保输出激活图与原始图像的大小相同,这在构建架构时可以使事情变得简单得多。图 13-4 显示了添加填充如何允许我们在图像角落应用核。

带填充的卷积

图 13-4。带填充的卷积

使用 5×5 输入,4×4 核和 2 像素填充,我们最终得到一个 6×6 的激活图,如我们在图 13-5 中所看到的。

4x4 核与 5x5 输入和 2 像素填充

图 13-5。一个 4×4 的核与 5×5 的输入和 2 像素的填充(由 Vincent Dumoulin 和 Francesco Visin 提供)

如果我们添加一个大小为ks乘以ks的核(其中ks是一个奇数),为了保持相同的形状,每一侧所需的填充是ks//2。对于ks的偶数,需要在上/下和左/右两侧填充不同数量,但实际上我们几乎从不使用偶数滤波器大小。

到目前为止,当我们将核应用于网格时,我们每次将其移动一个像素。但我们可以跳得更远;例如,我们可以在每次核应用后移动两个像素,就像图 13-6 中所示。这被称为步幅-2卷积。实践中最常见的核大小是 3×3,最常见的填充是 1。正如您将看到的,步幅-2 卷积对于减小输出大小很有用,而步幅-1 卷积对于添加层而不改变输出大小也很有用。

3x3 核与 5x5 输入,步幅 2 卷积和 1 像素填充

图 13-6。一个 3×3 的核与 5×5 的输入,步幅 2 卷积和 1 像素填充(由 Vincent Dumoulin 和 Francesco Visin 提供)

在大小为h乘以w的图像中,使用填充 1 和步幅 2 将给出大小为(h+1)//2乘以(w+1)//2的结果。每个维度的一般公式是

(n + 2*pad - ks) // stride + 1

其中pad是填充,ks是我们核的大小,stride是步幅。

现在让我们看看如何计算我们卷积结果的像素值。

理解卷积方程

为了解释卷积背后的数学,fast.ai 学生 Matt Kleinsmith 提出了一个非常聪明的想法,展示了不同视角的 CNNs。事实上,这个想法非常聪明,非常有帮助,我们也会在这里展示!

这是我们的 3×3 像素图像,每个像素都用字母标记:

图像

这是我们的核,每个权重都用希腊字母标记:

核

由于滤波器适合图像四次,我们有四个结果:

激活

图 13-7 显示了我们如何将核应用于图像的每个部分以产生每个结果。

应用核

图 13-7。应用核

方程视图在图 13-8 中。

方程

图 13-8。方程

请注意,偏置项b对于图像的每个部分都是相同的。您可以将偏置视为滤波器的一部分,就像权重(α、β、γ、δ)是滤波器的一部分一样。

这里有一个有趣的见解——卷积可以被表示为一种特殊类型的矩阵乘法,如图 13-9 所示。权重矩阵就像传统神经网络中的那些一样。但是,这个权重矩阵具有两个特殊属性:

  1. 灰色显示的零是不可训练的。这意味着它们在优化过程中将保持为零。

  2. 一些权重是相等的,虽然它们是可训练的(即可更改的),但它们必须保持相等。这些被称为共享权重

零对应于滤波器无法触及的像素。权重矩阵的每一行对应于滤波器的一次应用。

卷积作为矩阵乘法

图 13-9。卷积作为矩阵乘法

现在我们了解了卷积是什么,让我们使用它们来构建一个神经网络。

我们的第一个卷积神经网络

没有理由相信某些特定的边缘滤波器是图像识别最有用的卷积核。此外,我们已经看到在后续层中,卷积核变成了来自较低层特征的复杂转换,但我们不知道如何手动构建这些转换。

相反,最好学习卷积核的值。我们已经知道如何做到这一点——SGD!实际上,模型将学习对分类有用的特征。当我们使用卷积而不是(或者除了)常规线性层时,我们创建了一个卷积神经网络(CNN)。

创建 CNN

让我们回到第四章中的基本神经网络。它的定义如下:

simple_net = nn.Sequential(
    nn.Linear(28*28,30),
    nn.ReLU(),
    nn.Linear(30,1)
)

我们可以查看模型的定义:

simple_net
Sequential(
  (0): Linear(in_features=784, out_features=30, bias=True)
  (1): ReLU()
  (2): Linear(in_features=30, out_features=1, bias=True)
)

现在我们想要创建一个类似于这个线性模型的架构,但是使用卷积层而不是线性层。nn.Conv2dF.conv2d的模块等效物。在创建架构时,它比F.conv2d更方便,因为在实例化时会自动为我们创建权重矩阵。

这是一个可能的架构:

broken_cnn = sequential(
    nn.Conv2d(1,30, kernel_size=3, padding=1),
    nn.ReLU(),
    nn.Conv2d(30,1, kernel_size=3, padding=1)
)

这里需要注意的一点是,我们不需要指定28*28作为输入大小。这是因为线性层需要在权重矩阵中为每个像素设置一个权重,因此它需要知道有多少像素,但卷积会自动应用于每个像素。权重仅取决于输入和输出通道的数量以及核大小,正如我们在前一节中看到的。

想一想输出形状会是什么;然后让我们尝试一下:

broken_cnn(xb).shape
torch.Size([64, 1, 28, 28])

这不是我们可以用来进行分类的东西,因为我们需要每个图像一个单独的输出激活,而不是一个 28×28 的激活图。处理这个问题的一种方法是使用足够多的步幅为 2 的卷积,使得最终层的大小为 1。经过一次步幅为 2 的卷积后,大小将为 14×14;经过两次后,将为 7×7;然后是 4×4,2×2,最终大小为 1。

现在让我们尝试一下。首先,我们将定义一个函数,其中包含我们在每个卷积中将使用的基本参数:

def conv(ni, nf, ks=3, act=True):
    res = nn.Conv2d(ni, nf, stride=2, kernel_size=ks, padding=ks//2)
    if act: res = nn.Sequential(res, nn.ReLU())
    return res

重构

重构神经网络的部分,可以减少由于架构不一致而导致的错误,也可以更明显地向读者展示哪些层的部分实际上在改变。

当我们使用步幅为 2 的卷积时,通常会同时增加特征的数量。这是因为我们通过将激活图中的激活数量减少 4 倍来减少层的容量,我们不希望一次过多地减少层的容量。

术语:通道和特征

这两个术语通常可以互换使用,指的是权重矩阵的第二轴的大小,即卷积后每个网格单元的激活数量。特征从不用于指代输入数据,但通道可以指代输入数据(通常是颜色)或网络内部的激活。

以下是我们如何构建一个简单的 CNN:

simple_cnn = sequential(
    conv(1 ,4),            #14x14
    conv(4 ,8),            #7x7
    conv(8 ,16),           #4x4
    conv(16,32),           #2x2
    conv(32,2, act=False), #1x1
    Flatten(),
)

Jeremy 说

我喜欢在每个卷积后添加类似这里的注释,以显示每个层后激活图的大小。这些注释假定输入大小为 28×28。

现在网络输出两个激活,这对应于我们标签中的两个可能级别:

simple_cnn(xb).shape
torch.Size([64, 2])

我们现在可以创建我们的Learner

learn = Learner(dls, simple_cnn, loss_func=F.cross_entropy, metrics=accuracy)

要查看模型中发生的情况,我们可以使用summary

learn.summary()
Sequential (Input shape: ['64 x 1 x 28 x 28'])
================================================================
Layer (type)         Output Shape         Param #    Trainable
================================================================
Conv2d               64 x 4 x 14 x 14     40         True
________________________________________________________________
ReLU                 64 x 4 x 14 x 14     0          False
________________________________________________________________
Conv2d               64 x 8 x 7 x 7       296        True
________________________________________________________________
ReLU                 64 x 8 x 7 x 7       0          False
________________________________________________________________
Conv2d               64 x 16 x 4 x 4      1,168      True
________________________________________________________________
ReLU                 64 x 16 x 4 x 4      0          False
________________________________________________________________
Conv2d               64 x 32 x 2 x 2      4,640      True
________________________________________________________________
ReLU                 64 x 32 x 2 x 2      0          False
________________________________________________________________
Conv2d               64 x 2 x 1 x 1       578        True
________________________________________________________________
Flatten              64 x 2               0          False
________________________________________________________________

Total params: 6,722
Total trainable params: 6,722
Total non-trainable params: 0

Optimizer used: <function Adam at 0x7fbc9c258cb0>
Loss function: <function cross_entropy at 0x7fbca9ba0170>

Callbacks:
  - TrainEvalCallback
  - Recorder
  - ProgressCallback

请注意,最终的Conv2d层的输出是64x2x1x1。我们需要去除那些额外的1x1轴;这就是Flatten所做的。这基本上与 PyTorch 的squeeze方法相同,但作为一个模块。

让我们看看这是否训练!由于这是我们从头开始构建的比以前更深的网络,我们将使用更低的学习率和更多的时代:

learn.fit_one_cycle(2, 0.01)
时代 训练损失 验证损失 准确性 时间
0 0.072684 0.045110 0.990186 00:05
1 0.022580 0.030775 0.990186 00:05

成功!它越来越接近我们之前的resnet18结果,尽管还不完全达到,而且需要更多的时代,我们需要使用更低的学习率。我们还有一些技巧要学习,但我们越来越接近能够从头开始创建现代 CNN。

理解卷积算术

我们可以从总结中看到,我们有一个大小为64x1x28x28的输入。轴是批次、通道、高度、宽度。这通常表示为NCHW(其中N是批次大小)。另一方面,TensorFlow 使用NHWC轴顺序。这是第一层:

m = learn.model[0]
m
Sequential(
  (0): Conv2d(1, 4, kernel_size=(3, 3), stride=(2, 2), padding=(1, 1))
  (1): ReLU()
)

因此,我们有 1 个输入通道,4 个输出通道和一个 3×3 的内核。让我们检查第一个卷积的权重:

m[0].weight.shape
torch.Size([4, 1, 3, 3])

总结显示我们有 40 个参数,4*1*3*3是 36。其他四个参数是什么?让我们看看偏差包含什么:

m[0].bias.shape
torch.Size([4])

我们现在可以利用这些信息来澄清我们在上一节中的陈述:“当我们使用步幅为 2 的卷积时,我们经常增加特征的数量,因为我们通过 4 的因子减少了激活图中的激活数量;我们不希望一次性太多地减少层的容量。”

每个通道都有一个偏差。(有时通道被称为特征滤波器,当它们不是输入通道时。) 输出形状是64x4x14x14,因此这将成为下一层的输入形状。根据summary,下一层有 296 个参数。让我们忽略批次轴,保持简单。因此,对于14*14=196个位置,我们正在乘以296-8=288个权重(为简单起见忽略偏差),因此在这一层有196*288=56,448次乘法。下一层将有7*7*(1168-16)=56,448次乘法。

这里发生的情况是,我们的步幅为 2 的卷积将网格大小14x14减半到7x7,并且我们将滤波器数量从 8 增加到 16,导致总体计算量没有变化。如果我们在每个步幅为 2 的层中保持通道数量不变,那么网络中所做的计算量会随着深度增加而减少。但我们知道,更深层次必须计算语义丰富的特征(如眼睛或毛发),因此我们不会期望减少计算是有意义的。

另一种思考这个问题的方式是基于感受野。

感受野

接受域是参与层计算的图像区域。在书籍网站上,您会找到一个名为conv-example.xlsx的 Excel 电子表格,展示了使用 MNIST 数字计算两个步幅为 2 的卷积层的过程。每个层都有一个单独的核。图 13-10 展示了如果我们点击conv2部分中的一个单元格,显示第二个卷积层的输出,并点击trace precedents时看到的内容。

conv2 层的直接前置

图 13-10. Conv2 层的直接前置

这里,有绿色边框的单元格是我们点击的单元格,蓝色高亮显示的单元格是它的前置——用于计算其值的单元格。这些单元格是输入层(左侧)的对应 3×3 区域单元格和滤波器(右侧)的单元格。现在让我们再次点击trace precedents,看看用于计算这些输入的单元格。图 13-11 展示了发生了什么。

conv2 层的次要前置

图 13-11. Conv2 层的次要前置

在这个例子中,我们只有两个步幅为 2 的卷积层,因此现在追溯到了输入图像。我们可以看到输入层中的一个 7×7 区域单元格用于计算 Conv2 层中的单个绿色单元格。这个 7×7 区域是 Conv2 中绿色激活的输入的接受域。我们还可以看到现在需要第二个滤波器核,因为我们有两个层。

从这个例子中可以看出,我们在网络中越深(特别是在一个层之前有更多步幅为 2 的卷积层时),该层中激活的接受域就越大。一个大的接受域意味着输入图像的大部分被用来计算该层中每个激活。我们现在知道,在网络的深层,我们有语义丰富的特征,对应着更大的接受域。因此,我们期望我们需要更多的权重来处理这种不断增加的复杂性。这是另一种说法,与我们在前一节提到的相同:当我们在网络中引入步幅为 2 的卷积时,我们也应该增加通道数。

在撰写这一特定章节时,我们有很多问题需要回答,以便尽可能好地向您解释 CNN。信不信由你,我们在 Twitter 上找到了大部分答案。在我们继续讨论彩色图像之前,我们将快速休息一下,与您谈谈这个问题。

关于 Twitter 的一点说明

总的来说,我们并不是社交网络的重度用户。但我们写这本书的目标是帮助您成为最优秀的深度学习从业者,我们不提及 Twitter 在我们自己的深度学习之旅中有多么重要是不合适的。

您看,Twitter 还有另一部分,远离唐纳德·特朗普和卡戴珊家族,深度学习研究人员和从业者每天都在这里交流。在我们撰写这一部分时,Jeremy 想要再次确认我们关于步幅为 2 的卷积的说法是否准确,所以他在 Twitter 上提问:

twitter 1

几分钟后,这个答案出现了:

twitter 2

Christian Szegedy 是Inception的第一作者,这是 2014 年 ImageNet 的获奖作品,也是现代神经网络中许多关键见解的来源。两小时后,这个出现了:

twitter 3

你认识那个名字吗?您在第二章中看到过,当时我们在谈论今天建立深度学习基础的图灵奖获得者!

Jeremy 还在 Twitter 上询问有关我们在第七章中描述的标签平滑是否准确,并再次直接从 Christian Szegedy(标签平滑最初是在 Inception 论文中引入的)那里得到了回应:

twitter 4

今天深度学习领域的许多顶尖人物经常在 Twitter 上活跃,并且非常乐意与更广泛的社区互动。一个好的开始方法是查看 Jeremy 的最近的 Twitter 点赞,或者Sylvain 的。这样,您可以看到我们认为有趣和有用的人发表的 Twitter 用户列表。

Twitter 是我们保持与有趣论文、软件发布和其他深度学习新闻最新的主要途径。为了与深度学习社区建立联系,我们建议在fast.ai 论坛和 Twitter 上都积极参与。

话虽如此,让我们回到本章的重点。到目前为止,我们只展示了黑白图片的示例,每个像素只有一个值。实际上,大多数彩色图像每个像素有三个值来定义它们的颜色。接下来我们将看看如何处理彩色图像。

彩色图像

彩色图片是一个三阶张量:

im = image2tensor(Image.open('images/grizzly.jpg'))
im.shape
torch.Size([3, 1000, 846])
show_image(im);

第一个轴包含红色、绿色和蓝色的通道:

_,axs = subplots(1,3)
for bear,ax,color in zip(im,axs,('Reds','Greens','Blues')):
    show_image(255-bear, ax=ax, cmap=color)

我们看到卷积操作是针对图像的一个通道上的一个滤波器(我们的示例是在一个正方形上完成的)。卷积层将接受一个具有一定数量通道的图像(对于常规 RGB 彩色图像的第一层有三个通道),并输出一个具有不同数量通道的图像。与我们的隐藏大小代表线性层中神经元数量一样,我们可以决定有多少个滤波器,并且每个滤波器都可以专门化(一些用于检测水平边缘,其他用于检测垂直边缘等等),从而产生类似我们在第二章中学习的示例。

在一个滑动窗口中,我们有一定数量的通道,我们需要同样数量的滤波器(我们不对所有通道使用相同的核)。因此,我们的核不是 3×3 的大小,而是ch_in(通道数)乘以 3×3。在每个通道上,我们将窗口的元素乘以相应滤波器的元素,然后对结果求和(如前所述),并对所有滤波器求和。在图 13-12 中给出的示例中,我们在该窗口上的卷积层的结果是红色+绿色+蓝色。

在 RGB 图像上进行卷积

图 13-12. 在 RGB 图像上进行卷积

因此,为了将卷积应用于彩色图片,我们需要一个大小与第一个轴匹配的核张量。在每个位置,核和图像块的相应部分相乘。

然后,所有这些都相加在一起,为每个输出特征的每个网格位置产生一个单个数字,如图 13-13 所示。

添加 RGB 滤波器

图 13-13. 添加 RGB 滤波器

然后我们有ch_out这样的滤波器,因此最终,我们的卷积层的结果将是一个具有ch_out通道的图像批次,高度和宽度由前面概述的公式给出。这给我们ch_out大小为ch_in x ks x ks的张量,我们将其表示为一个四维大张量。在 PyTorch 中,这些权重的维度顺序是ch_out x ch_in x ks x ks

此外,我们可能希望为每个滤波器设置一个偏置。在前面的示例中,我们的卷积层的最终结果将是y R + y G + y B + b。就像在线性层中一样,我们有多少个卷积核就有多少个偏置,因此偏置是大小为ch_out的向量。

在使用彩色图像进行训练 CNN 时不需要特殊机制。只需确保您的第一层有三个输入。

有很多处理彩色图像的方法。例如,您可以将它们转换为黑白色,从 RGB 转换为 HSV(色调、饱和度和值)颜色空间等。一般来说,实验证明,改变颜色的编码不会对模型结果产生任何影响,只要在转换中不丢失信息。因此,转换为黑白色是一个坏主意,因为它完全删除了颜色信息(这可能是关键的;例如,宠物品种可能具有独特的颜色);但通常转换为 HSV 不会产生任何影响。

现在您知道了第一章中“神经网络学习到的内容”中的那些图片来自Zeiler 和 Fergus 的论文的含义!作为提醒,这是他们关于一些第 1 层权重的图片:

Zeiler 和 Fergus 找到的第 1 层卷积核

这是将卷积核的三个切片,对于每个输出特征,显示为图像。我们可以看到,即使神经网络的创建者从未明确创建用于查找边缘的卷积核,神经网络也会使用 SGD 自动发现这些特征。

现在让我们看看如何训练这些 CNN,并向您展示 fastai 在底层使用的所有技术,以实现高效的训练。

提高训练稳定性

由于我们在识别 3 和 7 方面做得很好,让我们转向更难的事情——识别所有 10 个数字。这意味着我们需要使用MNIST而不是MNIST_SAMPLE

path = untar_data(URLs.MNIST)
path.ls()
(#2) [Path('testing'),Path('training')]

数据在两个名为trainingtesting的文件夹中,因此我们必须告诉GrandparentSplitter这一点(默认为trainvalid)。我们在get_dls函数中执行此操作,该函数定义使得稍后更改批量大小变得容易:

def get_dls(bs=64):
    return DataBlock(
        blocks=(ImageBlock(cls=PILImageBW), CategoryBlock),
        get_items=get_image_files,
        splitter=GrandparentSplitter('training','testing'),
        get_y=parent_label,
        batch_tfms=Normalize()
    ).dataloaders(path, bs=bs)

dls = get_dls()

记住,在使用数据之前先查看数据总是一个好主意:

dls.show_batch(max_n=9, figsize=(4,4))

现在我们的数据准备好了,我们可以在上面训练一个简单的模型。

一个简单的基线

在本章的前面,我们基于类似于conv函数构建了一个模型:

def conv(ni, nf, ks=3, act=True):
    res = nn.Conv2d(ni, nf, stride=2, kernel_size=ks, padding=ks//2)
    if act: res = nn.Sequential(res, nn.ReLU())
    return res

让我们从一个基本的 CNN 作为基线开始。我们将使用与之前相同的一个,但有一个调整:我们将使用更多的激活。由于我们有更多的数字需要区分,我们可能需要学习更多的滤波器。

正如我们讨论过的,通常我们希望每次有一个步幅为 2 的层时将滤波器数量加倍。在整个网络中增加滤波器数量的一种方法是在第一层中将激活数量加倍,然后每个之后的层也将比之前的版本大一倍。

但这会产生一个微妙的问题。考虑应用于每个像素的卷积核。默认情况下,我们使用一个 3×3 像素的卷积核。因此,在每个位置上,卷积核被应用到了总共 3×3=9 个像素。以前,我们的第一层有四个输出滤波器。因此,在每个位置上,从九个像素计算出四个值。想想如果我们将输出加倍到八个滤波器会发生什么。然后当我们应用我们的卷积核时,我们将使用九个像素来计算八个数字。这意味着它实际上并没有学到太多:输出大小几乎与输入大小相同。只有当神经网络被迫这样做时,即从操作的输出数量明显小于输入数量时,它们才会创建有用的特征。

为了解决这个问题,我们可以在第一层使用更大的卷积核。如果我们使用一个 5×5 像素的卷积核,每次卷积核应用时将使用 25 个像素。从中创建八个滤波器将意味着神经网络将不得不找到一些有用的特征:

def simple_cnn():
    return sequential(
        conv(1 ,8, ks=5),        #14x14
        conv(8 ,16),             #7x7
        conv(16,32),             #4x4
        conv(32,64),             #2x2
        conv(64,10, act=False),  #1x1
        Flatten(),
    )

正如您将在接下来看到的,我们可以在模型训练时查看模型内部,以尝试找到使其训练更好的方法。为此,我们使用ActivationStats回调,记录每个可训练层的激活的均值、标准差和直方图(正如我们所见,回调用于向训练循环添加行为;我们将在第十六章中探讨它们的工作原理):

from fastai.callback.hook import *

我们希望快速训练,这意味着以较高的学习率进行训练。让我们看看在 0.06 时的效果如何:

def fit(epochs=1):
    learn = Learner(dls, simple_cnn(), loss_func=F.cross_entropy,
                    metrics=accuracy, cbs=ActivationStats(with_hist=True))
    learn.fit(epochs, 0.06)
    return learn
learn = fit()
轮数 训练损失 验证损失 准确率 时间
0 2.307071 2.305865 0.113500 00:16

这次训练效果不佳!让我们找出原因。

传递给Learner的回调的一个方便功能是它们会自动提供,名称与回调类相同,除了使用驼峰命名法。因此,我们的ActivationStats回调可以通过activation_stats访问。我相信你还记得learn.recorder...你能猜到它是如何实现的吗?没错,它是一个名为Recorder的回调!

ActivationStats包含一些方便的实用程序,用于绘制训练期间的激活。plot_layer_stats(*idx*)绘制第idx层激活的均值和标准差,以及接近零的激活百分比。这是第一层的图表:

learn.activation_stats.plot_layer_stats(0)

通常情况下,我们的模型在训练期间应该具有一致或至少平滑的层激活均值和标准差。接近零的激活值特别有问题,因为这意味着我们的模型中有一些计算根本没有做任何事情(因为乘以零得到零)。当一个层中有一些零时,它们通常会传递到下一层...然后创建更多的零。这是我们网络的倒数第二层:

learn.activation_stats.plot_layer_stats(-2)

正如预期的那样,问题在网络末端变得更糟,因为不稳定性和零激活在层间累积。让我们看看如何使训练更稳定。

增加批量大小

使训练更稳定的一种方法是增加批量大小。较大的批次具有更准确的梯度,因为它们是从更多数据计算出来的。然而,较大的批量大小意味着每个轮数的批次更少,这意味着您的模型更新权重的机会更少。让我们看看批量大小为 512 是否有帮助:

dls = get_dls(512)
learn = fit()
轮数 训练损失 验证损失 准确率 时间
0 2.309385 2.302744 0.113500 00:08

让我们看看倒数第二层是什么样的:

learn.activation_stats.plot_layer_stats(-2)

再次,我们的大多数激活值接近零。让我们看看我们可以做些什么来改善训练稳定性。

1cycle 训练

我们的初始权重不适合我们要解决的任务。因此,以高学习率开始训练是危险的:我们很可能会使训练立即发散,正如我们所见。我们可能也不想以高学习率结束训练,这样我们就不会跳过一个最小值。但我们希望在训练期间保持高学习率,因为这样我们可以更快地训练。因此,我们应该在训练过程中改变学习率,从低到高,然后再次降低到低。

莱斯利·史密斯(是的,就是发明学习率查找器的那个人!)在他的文章“超收敛:使用大学习率非常快速地训练神经网络”中发展了这个想法。他设计了一个学习率时间表,分为两个阶段:一个阶段学习率从最小值增长到最大值(预热),另一个阶段学习率再次降低到最小值(退火)。史密斯称这种方法的组合为1cycle 训练

1cycle 训练允许我们使用比其他类型训练更高的最大学习率,这带来了两个好处:

  • 通过使用更高的学习率进行训练,我们可以更快地训练——这种现象史密斯称之为超收敛

  • 通过使用更高的学习率进行训练,我们过拟合较少,因为我们跳过了尖锐的局部最小值,最终进入了更平滑(因此更具有泛化能力)的损失部分。

第二点是一个有趣而微妙的观察;它基于这样一个观察:一个泛化良好的模型,如果你稍微改变输入,它的损失不会发生很大变化。如果一个模型在较大的学习率下训练了相当长的时间,并且在这样做时能找到一个好的损失,那么它一定找到了一个泛化良好的区域,因为它在批次之间跳动很多(这基本上就是高学习率的定义)。问题在于,正如我们所讨论的,直接跳到高学习率更有可能导致损失发散,而不是看到损失改善。因此,我们不会直接跳到高学习率。相反,我们从低学习率开始,我们的损失不会发散,然后允许优化器逐渐找到参数的更平滑的区域,逐渐提高学习率。

然后,一旦我们找到了参数的一个良好平滑区域,我们希望找到该区域的最佳部分,这意味着我们必须再次降低学习率。这就是为什么 1cycle 训练有一个渐进的学习率预热和渐进的学习率冷却。许多研究人员发现,实践中这种方法导致更准确的模型和更快的训练。这就是为什么在 fastai 中fine_tune默认使用这种方法。

在第十六章中,我们将学习有关 SGD 中的动量。简而言之,动量是一种技术,优化器不仅朝着梯度的方向迈出一步,而且继续朝着以前的步骤的方向前进。 Leslie Smith 在“神经网络超参数的纪律方法:第 1 部分”中介绍了循环动量的概念。它建议动量与学习率的方向相反变化:当我们处于高学习率时,我们使用较少的动量,在退火阶段再次使用更多动量。

我们可以通过调用fit_one_cycle在 fastai 中使用 1cycle 训练:

def fit(epochs=1, lr=0.06):
    learn = Learner(dls, simple_cnn(), loss_func=F.cross_entropy,
                    metrics=accuracy, cbs=ActivationStats(with_hist=True))
    learn.fit_one_cycle(epochs, lr)
    return learn
learn = fit()
epoch train_loss valid_loss accuracy time
0 0.210838 0.084827 0.974300 00:08

我们终于取得了一些进展!现在它给我们一个合理的准确率。

我们可以通过在learn.recorder上调用plot_sched来查看训练过程中的学习率和动量。learn.recorder(顾名思义)记录了训练过程中发生的一切,包括损失、指标和超参数,如学习率和动量:

learn.recorder.plot_sched()

Smith 的原始 1cycle 论文使用了线性热身和线性退火。正如您所看到的,我们通过将其与另一种流行方法——余弦退火相结合,在 fastai 中改进了这种方法。fit_one_cycle提供了以下您可以调整的参数:

lr_max

将使用的最高学习率(这也可以是每个层组的学习率列表,或包含第一个和最后一个层组学习率的 Python slice对象)

div

lr_max除以多少以获得起始学习率

div_final

lr_max除以多少以获得结束学习率

pct_start

用于热身的批次百分比

moms

一个元组(*mom1*,*mom2*,*mom3*),其中mom1是初始动量,mom2是最小动量,mom3是最终动量

让我们再次查看我们的层统计数据:

learn.activation_stats.plot_layer_stats(-2)

非零权重的百分比正在得到很大的改善,尽管仍然相当高。通过使用color_dim并传递一个层索引,我们可以更多地了解我们的训练情况:

learn.activation_stats.color_dim(-2)

color_dim是由 fast.ai 与学生 Stefano Giomo 共同开发的。Giomo 将这个想法称为丰富多彩维度,并提供了一个深入解释这种方法背后的历史和细节。基本思想是创建一个层的激活直方图,我们希望它会遵循一个平滑的模式,如正态分布(图 13-14)。

丰富多彩维度的直方图

图 13-14。丰富多彩维度的直方图(由 Stefano Giomo 提供)

为了创建color_dim,我们将左侧显示的直方图转换为底部显示的彩色表示。然后,我们将其翻转,如右侧所示。我们发现,如果我们取直方图值的对数,分布会更清晰。然后,Giomo 描述:

每个层的最终图是通过将每批次的激活直方图沿水平轴堆叠而成的。因此,可视化中的每个垂直切片代表单个批次的激活直方图。颜色强度对应直方图的高度;换句话说,每个直方图柱中的激活数量。

图 13-15 展示了这一切是如何结合在一起的。

丰富多彩维度的总结

图 13-15。丰富多彩维度的总结(由 Stefano Giomo 提供)

这说明了为什么当f遵循正态分布时,log(f)比f更丰富多彩,因为取对数会将高斯曲线变成二次曲线,这样不会那么狭窄。

因此,让我们再次看看倒数第二层的结果:

learn.activation_stats.color_dim(-2)

这展示了一个经典的“糟糕训练”图片。我们从几乎所有激活都为零开始——这是我们在最左边看到的,所有的深蓝色。底部的明黄色代表接近零的激活。然后,在最初的几批中,我们看到非零激活数量呈指数增长。但它走得太远并崩溃了!我们看到深蓝色回来了,底部再次变成明黄色。它几乎看起来像是训练重新从头开始。然后我们看到激活再次增加并再次崩溃。重复几次后,最终我们看到激活在整个范围内分布。

如果训练一开始就能平稳进行会更好。指数增长然后崩溃的周期往往会导致大量接近零的激活,从而导致训练缓慢且最终结果不佳。解决这个问题的一种方法是使用批量归一化。

批量归一化

为了解决前一节中出现的训练缓慢和最终结果不佳的问题,我们需要解决初始大比例接近零的激活,并尝试在整个训练过程中保持良好的激活分布。

Sergey Ioffe 和 Christian Szegedy 在 2015 年的论文“Batch Normalization: Accelerating Deep Network Training by Reducing Internal Covariate Shift”中提出了这个问题的解决方案。在摘要中,他们描述了我们所见过的问题:

训练深度神经网络的复杂性在于每一层输入的分布在训练过程中会发生变化,因为前一层的参数发生变化。这需要降低学习率和谨慎的参数初始化,从而减慢训练速度...我们将这种现象称为内部协变量转移,并通过对层输入进行归一化来解决这个问题。

他们说他们的解决方案如下:

将归一化作为模型架构的一部分,并对每个训练小批量进行归一化。批量归一化使我们能够使用更高的学习率,并且对初始化要求不那么严格。

这篇论文一经发布就引起了极大的兴奋,因为它包含了图 13-16 中的图表,清楚地表明批量归一化可以训练出比当前最先进技术(Inception架构)更准确且速度快约 5 倍的模型。

批量归一化的影响

图 13-16. 批量归一化的影响(由 Sergey Ioffe 和 Christian Szegedy 提供)

批量归一化(通常称为batchnorm)通过取层激活的均值和标准差的平均值来归一化激活。然而,这可能会导致问题,因为网络可能希望某些激活非常高才能进行准确的预测。因此,他们还添加了两个可学习参数(意味着它们将在 SGD 步骤中更新),通常称为gammabeta。在将激活归一化以获得一些新的激活向量y之后,批量归一化层返回gamma*y + beta

这就是为什么我们的激活可以具有任何均值或方差,独立于前一层结果的均值和标准差。这些统计数据是分开学习的,使得我们的模型训练更容易。在训练和验证期间的行为是不同的:在训练期间,我们使用批次的均值和标准差来归一化数据,而在验证期间,我们使用训练期间计算的统计数据的运行均值。

让我们在conv中添加一个批量归一化层:

def conv(ni, nf, ks=3, act=True):
    layers = [nn.Conv2d(ni, nf, stride=2, kernel_size=ks, padding=ks//2)]
    layers.append(nn.BatchNorm2d(nf))
    if act: layers.append(nn.ReLU())
    return nn.Sequential(*layers)

并适应我们的模型:

learn = fit()
epoch train_loss valid_loss accuracy time
0 0.130036 0.055021 0.986400 00:10

这是一个很好的结果!让我们看看color_dim

learn.activation_stats.color_dim(-4)

这正是我们希望看到的:激活的平稳发展,没有“崩溃”。Batchnorm 在这里真的兑现了承诺!事实上,批量归一化非常成功,我们几乎可以在所有现代神经网络中看到它(或类似的东西)。

关于包含批归一化层的模型的一个有趣观察是,它们往往比不包含批归一化层的模型更好地泛化。尽管我们尚未看到对这里发生的事情进行严格分析,但大多数研究人员认为原因是批归一化为训练过程添加了一些额外的随机性。每个小批次的均值和标准差都会与其他小批次有所不同。因此,激活每次都会被不同的值归一化。为了使模型能够做出准确的预测,它必须学会对这些变化变得稳健。通常,向训练过程添加额外的随机性通常有所帮助。

由于事情进展顺利,让我们再训练几个周期,看看情况如何。实际上,让我们增加学习率,因为批归一化论文的摘要声称我们应该能够“以更高的学习率训练”:

learn = fit(5, lr=0.1)
epoch train_loss valid_loss accuracy time
0 0.191731 0.121738 0.960900 00:11
1 0.083739 0.055808 0.981800 00:10
2 0.053161 0.044485 0.987100 00:10
3 0.034433 0.030233 0.990200 00:10
4 0.017646 0.025407 0.991200 00:10
learn = fit(5, lr=0.1)
epoch train_loss valid_loss accuracy time
0 0.183244 0.084025 0.975800 00:13
1 0.080774 0.067060 0.978800 00:12
2 0.050215 0.062595 0.981300 00:12
3 0.030020 0.030315 0.990700 00:12
4 0.015131 0.025148 0.992100 00:12

在这一点上,我认为我们知道如何识别数字了!是时候转向更难的东西了…

结论

我们已经看到,卷积只是一种矩阵乘法,对权重矩阵有两个约束:一些元素始终为零,一些元素被绑定(强制始终具有相同的值)。在第一章中,我们看到了 1986 年书籍并行分布式处理中的八个要求;其中之一是“单元之间的连接模式”。这正是这些约束所做的:它们强制执行一定的连接模式。

这些约束允许我们在不牺牲表示复杂视觉特征的能力的情况下,在模型中使用更少的参数。这意味着我们可以更快地训练更深的模型,减少过拟合。尽管普遍逼近定理表明在一个隐藏层中应该可能用全连接网络表示任何东西,但我们现在看到,通过深思熟虑网络架构,我们可以训练出更好的模型。

卷积是我们在神经网络中看到的最常见的连接模式(连同常规线性层,我们称之为全连接),但很可能会发现更多。

我们还看到了如何解释网络中各层的激活,以查看训练是否顺利,以及批归一化如何帮助规范训练并使其更加平滑。在下一章中,我们将使用这两个层来构建计算机视觉中最流行的架构:残差网络。

问卷

  1. 特征是什么?

  2. 为顶部边缘检测器编写卷积核矩阵。

  3. 写出 3×3 卷积核对图像中单个像素应用的数学运算。

  4. 应用于 3×3 零矩阵的卷积核的值是多少?

  5. 填充是什么?

  6. 步幅是什么?

  7. 创建一个嵌套列表推导来完成您选择的任何任务。

  8. PyTorch 的 2D 卷积的inputweight参数的形状是什么?

  9. 通道是什么?

  10. 卷积和矩阵乘法之间的关系是什么?

  11. 卷积神经网络是什么?

  12. 重构神经网络定义的部分有什么好处?

  13. 什么是Flatten?MNIST CNN 中需要包含在哪里?为什么?

  14. NCHW 是什么意思?

  15. 为什么 MNIST CNN 的第三层有7*7*(1168-16)次乘法运算?

  16. 什么是感受野?

  17. 经过两次步幅为 2 的卷积后,激活的感受野大小是多少?为什么?

  18. 自己运行conv-example.xlsx并尝试使用trace precedents进行实验。

  19. 看一下 Jeremy 或 Sylvain 最近的 Twitter“喜欢”列表,看看是否有任何有趣的资源或想法。

  20. 彩色图像如何表示为张量?

  21. 彩色输入下卷积是如何工作的?

  22. 我们可以使用什么方法来查看DataLoaders中的数据?

  23. 为什么我们在每次步幅为 2 的卷积后将滤波器数量加倍?

  24. 为什么在 MNIST 的第一个卷积中使用较大的内核(使用simple_cnn)?

  25. ActivationStats为每个层保存了什么信息?

  26. 在训练后如何访问学习者的回调?

  27. plot_layer_stats绘制了哪三个统计数据?x 轴代表什么?

  28. 为什么接近零的激活是有问题的?

  29. 使用更大的批量大小进行训练的优缺点是什么?

  30. 为什么我们应该避免在训练开始时使用高学习率?

  31. 什么是 1cycle 训练?

  32. 使用高学习率进行训练的好处是什么?

  33. 为什么我们希望在训练结束时使用较低的学习率?

  34. 什么是循环动量?

  35. 哪个回调在训练期间跟踪超参数值(以及其他信息)?

  36. color_dim图中的一列像素代表什么?

  37. color_dim中,“坏训练”是什么样子?为什么?

  38. 批规范化层包含哪些可训练参数?

  39. 在训练期间批规范化使用哪些统计数据进行规范化?验证期间呢?

  40. 为什么具有批规范化层的模型泛化能力更好?

进一步研究

  1. 除了边缘检测器,计算机视觉中还使用了哪些特征(尤其是在深度学习变得流行之前)?

  2. PyTorch 中还有其他规范化层。尝试它们,看看哪种效果最好。了解其他规范化层的开发原因以及它们与批规范化的区别。

  3. 尝试将激活函数移动到conv中的批规范化层后。这会有所不同吗?看看你能找到关于推荐顺序及原因的信息。

第十四章:ResNets

原文:www.bookstack.cn/read/th-fastai-book/482a7208820b3d90.md

译者:飞龙

协议:CC BY-NC-SA 4.0

在本章中,我们将在上一章介绍的 CNN 基础上构建,并向您解释 ResNet(残差网络)架构。它是由 Kaiming He 等人于 2015 年在文章“Deep Residual Learning for Image Recognition”中引入的,到目前为止是最常用的模型架构。最近在图像模型中的发展几乎总是使用残差连接的相同技巧,大多数时候,它们只是原始 ResNet 的调整。

我们将首先展示最初设计的基本 ResNet,然后解释使其性能更好的现代调整。但首先,我们需要一个比 MNIST 数据集更难一点的问题,因为我们已经在常规 CNN 上接近 100%的准确率了。

回到 Imagenette

当我们已经在上一章的 MNIST 中看到的准确率已经很高时,要评估我们对模型的任何改进将会很困难,因此我们将通过回到 Imagenette 来解决一个更困难的图像分类问题。我们将继续使用小图像以保持事情相对快速。

让我们获取数据——我们将使用已经调整大小为 160 像素的版本以使事情更快,然后将随机裁剪到 128 像素:

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)
dls = get_data(URLs.IMAGENETTE_160, 160, 128)
dls.show_batch(max_n=4)

当我们查看 MNIST 时,我们处理的是 28×28 像素的图像。对于 Imagenette,我们将使用 128×128 像素的图像进行训练。稍后,我们希望能够使用更大的图像,至少与 224×224 像素的 ImageNet 标准一样大。您还记得我们如何从 MNIST 卷积神经网络中获得每个图像的单个激活向量吗?

我们采用的方法是确保有足够的步幅为 2 的卷积,以使最终层具有 1 的网格大小。然后我们展平我们最终得到的单位轴,为每个图像获得一个向量(因此,对于一个小批量的激活矩阵)。我们可以对 Imagenette 做同样的事情,但这会导致两个问题:

  • 我们需要很多步幅为 2 的层,才能使我们的网格在最后变成 1×1 的大小——可能比我们本来会选择的要多。

  • 该模型将无法处理除最初训练的大小之外的任何大小的图像。

处理第一个问题的一种方法是以一种处理 1×1 以外的网格大小的方式展平最终的卷积层。我们可以简单地将矩阵展平为向量,就像我们以前做过的那样,通过将每一行放在前一行之后。事实上,这是卷积神经网络直到 2013 年几乎总是采用的方法。最著名的例子是 2013 年 ImageNet 的获奖者 VGG,有时今天仍在使用。但这种架构还有另一个问题:它不仅不能处理与训练集中使用的相同大小的图像之外的图像,而且需要大量内存,因为展平卷积层导致许多激活被馈送到最终层。因此,最终层的权重矩阵是巨大的。

这个问题通过创建完全卷积网络来解决。完全卷积网络的技巧是对卷积网格中的激活进行平均。换句话说,我们可以简单地使用这个函数:

def avg_pool(x): return x.mean((2,3))

正如您所看到的,它正在计算 x 轴和 y 轴上的平均值。这个函数将始终将一组激活转换为每个图像的单个激活。PyTorch 提供了一个稍微更灵活的模块,称为nn.AdaptiveAvgPool2d,它将一组激活平均到您需要的任何大小的目标(尽管我们几乎总是使用大小为 1)。

因此,一个完全卷积网络具有多个卷积层,其中一些将是步幅为 2 的,在最后是一个自适应平均池化层,一个展平层来移除单位轴,最后是一个线性层。这是我们的第一个完全卷积网络:

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,然后在之前获取的数据上对其进行训练:

def get_learner(m):
    return Learner(dls, m, loss_func=nn.CrossEntropyLoss(), metrics=accuracy
                  ).to_fp16()

learn = get_learner(get_model())
learn.lr_find()
(0.47863011360168456, 3.981071710586548)

对于 CNN 来说,3e-3 通常是一个很好的学习率,这在这里也是如此,所以让我们试一试:

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 的这些方面。

构建现代 CNN:ResNet

我们现在已经拥有构建我们自从本书开始就一直在计算机视觉任务中使用的模型所需的所有要素:ResNets。我们将介绍它们背后的主要思想,并展示它如何在 Imagenette 上提高了准确性,然后构建一个带有所有最新调整的版本。

跳跃连接

2015 年,ResNet 论文的作者们注意到了一件他们觉得奇怪的事情。即使使用了批量归一化,他们发现使用更多层的网络表现不如使用更少层的网络,并且模型之间没有其他差异。最有趣的是,这种差异不仅在验证集中观察到,而且在训练集中也观察到;因此这不仅仅是一个泛化问题,而是一个训练问题。正如论文所解释的:

出乎意料的是,这种退化并不是由过拟合引起的,向适当深度的模型添加更多层会导致更高的训练错误,正如我们的实验[先前报告]和彻底验证的那样。

这种现象在图 14-1 中的图表中有所说明,左侧是训练错误,右侧是测试错误。

不同深度网络的训练

图 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 块

图 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;我们要求它预测yx之间的差异。因此,这些块的任务不是预测特定的特征,而是最小化x和期望的y之间的误差。因此,ResNet 擅长学习不做任何事情和通过两个卷积层块(具有可训练权重)之间的区别。这就是这些模型得名的原因:它们在预测残差(提醒:“残差”是预测减去目标)。

这两种关于 ResNet 的思考方式共享的一个关键概念是学习的便利性。这是一个重要的主题。回想一下普遍逼近定理,它指出一个足够大的网络可以学习任何东西。这仍然是真的,但事实证明,在原始数据和训练方案下,网络在原则上可以学习的东西与它实际上容易学习的东西之间存在非常重要的区别。过去十年中神经网络的许多进步都像 ResNet 块一样:意识到如何使一些一直可能的东西变得可行。

真实身份路径

原始论文实际上并没有在每个块的最后一个 batchnorm 层中使用零作为gamma的初始值的技巧;这是几年后才出现的。因此,ResNet 的原始版本并没有真正以真实的身份路径开始训练 ResNet 块,但是尽管如此,具有“穿越”跳过连接的能力确实使其训练效果更好。添加 batchnorm gamma初始化技巧使模型能够以更高的学习速率训练。

这是一个简单 ResNet 块的定义(fastai 将最后一个 batchnorm 层的gamma权重初始化为零,因为norm_type=NormType.BatchZero):

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:

def _conv_block(ni,nf,stride):
    return nn.Sequential(
        ConvLayer(ni, nf, stride=stride),
        ConvLayer(nf, nf, act_cls=None, norm_type=NormType.BatchZero))
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==nfidconv什么也不做,如果stride==1pool也不做任何操作,这正是我们在跳过连接中想要的。

此外,您会看到我们已经从convs的最后一个卷积层和idconv中删除了 ReLU(act_cls=None),并将其移到我们添加跳跃连接之后。这样做的想法是整个 ResNet 块就像一个层,您希望激活在层之后。

让我们用ResBlock替换我们的block并尝试一下:

def block(ni,nf): return ResBlock(ni, nf, stride=2)
learn = get_learner(get_model())
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

def block(ni, nf):
    return nn.Sequential(ResBlock(ni, nf, stride=2), ResBlock(nf, nf))
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 等人撰写的“可视化神经网络损失景观”。它表明使用跳跃连接有助于平滑损失函数,这使得训练更容易,因为它避免了陷入非常陡峭的区域。图 14-3 展示了该论文中的一幅惊人图片,说明了 SGD 需要导航以优化普通 CNN(左侧)与 ResNet(右侧)之间的不同之处。

ResNet 对损失景观的影响

图 14-3. ResNet 对损失景观的影响(由 Hao Li 等人提供)

我们的第一个模型已经很好了,但进一步的研究发现了更多可以应用的技巧,使其变得更好。我们接下来将看看这些技巧。

一个最先进的 ResNet

“用卷积神经网络进行图像分类的技巧”中,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 块开始,而是从几个卷积层开始,然后是一个最大池化层。这就是网络的第一层,称为的样子:

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)]
_resnet_stem(3,32,32,64)
[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层:

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_inch_out,步幅为指定的stride,其余所有块都是步幅为 1 的块,从ch_outch_out张量。一旦块被定义,我们的模型就是纯顺序的,这就是为什么我们将其定义为nn.Sequential的子类。(暂时忽略expansion参数;我们将在下一节讨论它。暂时设为1,所以它不起作用。)

模型的各个版本(ResNet-18、-34、-50 等)只是改变了每个组中块的数量。这是 ResNet-18 的定义:

rn = ResNet(dls.c, [2,2,2,2])

让我们训练一下,看看它与之前的模型相比如何:

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 块的比较

图 14-4. 常规和瓶颈 ResNet 块的比较(由 Kaiming He 等人提供)

为什么这很有用?1×1 卷积速度更快,因此即使这似乎是一个更复杂的设计,这个块的执行速度比我们看到的第一个 ResNet 块更快。这样一来,我们可以使用更多的滤波器:正如我们在插图中看到的,输入和输出的滤波器数量是四倍更高的(256 而不是 64)。1×1 卷积减少然后恢复通道数(因此称为瓶颈)。总体影响是我们可以在相同的时间内使用更多的滤波器。

让我们尝试用这种瓶颈设计替换我们的ResBlock

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传递给ResNetexpansion参数,因为我们需要从四倍少的通道开始,最终将以四倍多的通道结束。

像这样更深的网络通常在仅训练 5 个时期时不会显示出改进,所以这次我们将将其增加到 20 个时期,以充分利用我们更大的模型。为了获得更好的结果,让我们也使用更大的图像:

dls = get_data(URLs.IMAGENETTE_320, presize=320, resize=224)

我们不必为更大的 224 像素图像做任何调整;由于我们的全卷积网络,它可以正常工作。这也是为什么我们能够在本书的早期进行渐进调整的原因——我们使用的模型是全卷积的,所以我们甚至能够微调使用不同尺寸训练的模型。现在我们可以训练我们的模型并查看效果:

rn = ResNet(dls.c, [3,4,6,3], 4)
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 不适用?

  2. 我们在 Imagenette 上做了什么?

  3. 什么是自适应池化?

  4. 什么是平均池化?

  5. 为什么在自适应平均池化层之后需要Flatten

  6. 什么是跳跃连接?

  7. 为什么跳跃连接使我们能够训练更深的模型?

  8. 图 14-1 展示了什么?这是如何导致跳跃连接的想法的?

  9. 什么是恒等映射?

  10. ResNet 块的基本方程是什么(忽略批量归一化和 ReLU 层)?

  11. ResNet 与残差有什么关系?

  12. 当存在步幅为 2 的卷积时,我们如何处理跳跃连接?当滤波器数量发生变化时呢?

  13. 我们如何用向量点积表示 1×1 卷积?

  14. 使用F.conv2dnn.Conv2d创建一个 1×1 卷积并将其应用于图像。图像的形状会发生什么变化?

  15. noop函数返回什么?

  16. 解释图 14-3 中显示的内容。

  17. 何时使用前 5 准确度比前 1 准确度更好?

  18. CNN 的“起始”是什么?

  19. 为什么在 CNN 的起始部分使用普通卷积而不是 ResNet 块?

  20. 瓶颈块与普通 ResNet 块有何不同?

  21. 为什么瓶颈块更快?

  22. 完全卷积网络(以及具有自适应池化的网络)如何实现渐进式调整大小?

进一步研究

  1. 尝试为 MNIST 创建一个带有自适应平均池化的完全卷积网络(请注意,您将需要更少的步幅为 2 的层)。与没有这种池化层的网络相比如何?

  2. 在第十七章中,我们介绍了爱因斯坦求和符号。快进去看看它是如何工作的,然后使用torch.einsum编写一个 1×1 卷积操作的实现。将其与使用torch.conv2d进行相同操作进行比较。

  3. 使用纯 PyTorch 或纯 Python 编写一个前 5 准确度函数。

  4. 在 Imagenette 上训练一个模型更多的 epochs,使用和不使用标签平滑。查看 Imagenette 排行榜,看看你能达到最佳结果有多接近。阅读描述领先方法的链接页面。

第十五章:应用架构深入探讨

原文:www.bookstack.cn/read/th-fastai-book/fc209e8bb92c8c12.md

译者:飞龙

协议:CC BY-NC-SA 4.0

我们现在处于一个令人兴奋的位置,我们可以完全理解我们为计算机视觉、自然语言处理和表格分析使用的最先进模型的架构。在本章中,我们将填补有关 fastai 应用模型如何工作的所有缺失细节,并向您展示如何构建它们。

我们还将回到我们在第十一章中看到的用于 Siamese 网络的自定义数据预处理流程,并向您展示如何使用 fastai 库中的组件为新任务构建自定义预训练模型。

我们将从计算机视觉开始。

计算机视觉

对于计算机视觉应用,我们使用cnn_learnerunet_learner函数来构建我们的模型,具体取决于任务。在本节中,我们将探讨如何构建我们在本书的第 I 部分和 II 部分中使用的Learner对象。

cnn_learner

让我们看看当我们使用cnn_learner函数时会发生什么。我们首先向这个函数传递一个用于网络主体的架构。大多数情况下,我们使用 ResNet,您已经知道如何创建,所以我们不需要深入研究。预训练权重将根据需要下载并加载到 ResNet 中。

然后,对于迁移学习,网络需要被切割。这指的是切掉最后一层,该层仅负责 ImageNet 特定的分类。实际上,我们不仅切掉这一层,还切掉自自适应平均池化层以及之后的所有内容。这样做的原因很快就会变得清楚。由于不同的架构可能使用不同类型的池化层,甚至完全不同类型的头部,我们不仅仅搜索自适应池化层来决定在哪里切割预训练模型。相反,我们有一个信息字典,用于确定每个模型的主体在哪里结束,头部从哪里开始。我们称之为model_meta—这是resnet50的信息:

model_meta[resnet50]
{'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函数创建的:

create_head(20,2)
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

深度学习中最有趣的架构之一是我们在第一章中用于分割的架构。分割是一项具有挑战性的任务,因为所需的输出实际上是一幅图像,或者一个像素网格,包含了每个像素的预测标签。其他任务也有类似的基本设计,比如增加图像的分辨率(超分辨率)、给黑白图像添加颜色(着色)、或将照片转换为合成画作(风格转移)——这些任务在本书的在线章节中有介绍,所以在阅读完本章后一定要查看。在每种情况下,我们都是从一幅图像开始,将其转换为另一幅具有相同尺寸或纵横比的图像,但像素以某种方式被改变。我们将这些称为生成式视觉模型

我们的做法是从与前一节中看到的开发 CNN 头部的确切方法开始。例如,我们从一个 ResNet 开始,然后截断自适应池化层和之后的所有层。然后我们用我们的自定义头部替换这些层,执行生成任务。

在上一句中有很多含糊之处!我们到底如何创建一个生成图像的 CNN 头部?如果我们从一个 224 像素的输入图像开始,那么在 ResNet 主体的末尾,我们将得到一个 7×7 的卷积激活网格。我们如何将其转换为一个 224 像素的分割掩模?

当然,我们使用神经网络来做这个!所以我们需要一种能够在 CNN 中增加网格大小的层。一个简单的方法是用一个 2×2 的方块替换 7×7 网格中的每个像素。这四个像素中的每一个将具有相同的值——这被称为最近邻插值。PyTorch 为我们提供了一个可以做到这一点的层,因此一个选项是创建一个包含步长为 1 的卷积层(以及通常的批归一化和 ReLU 层)和 2×2 最近邻插值层的头部。实际上,你现在可以尝试一下!看看你是否可以创建一个设计如此的自定义头部,并在 CamVid 分割任务上尝试一下。你应该会发现你得到了一些合理的结果,尽管它们不会像我们在第一章中的结果那样好。

另一种方法是用转置卷积替换最近邻和卷积的组合,也被称为步长一半卷积。这与常规卷积相同,但首先在输入的所有像素之间插入零填充。这在图片上最容易看到——图 15-1 显示了一张来自我们在第十三章讨论过的优秀的卷积算术论文中的图表,展示了一个应用于 3×3 图像的 3×3 转置卷积。

一个转置卷积

图 15-1. 一个转置卷积(由 Vincent Dumoulin 和 Francesco Visin 提供)

正如你所看到的,结果是增加输入的大小。你现在可以通过使用 fastai 的ConvLayer类来尝试一下;在你的自定义头部中传递参数transpose=True来创建一个转置卷积,而不是一个常规卷积。

然而,这两种方法都不是很好。问题在于我们的 7×7 网格根本没有足够的信息来创建一个 224×224 像素的输出。要求每个网格单元的激活具有足够的信息来完全重建输出中的每个像素是非常困难的。

解决方案是使用跳跃连接,就像 ResNet 中那样,但是从 ResNet 主体中的激活一直跳到架构对面的转置卷积的激活。这种方法在 2015 年 Olaf Ronneberger 等人的论文“U-Net:用于生物医学图像分割的卷积网络”中有所阐述。尽管该论文侧重于医学应用,但 U-Net 已经彻底改变了各种生成视觉模型。

U-Net 架构

图 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 库编写一个自定义模型。

孪生网络

让我们回到我们在第十一章中为孪生网络设置的输入管道。你可能还记得,它由一对图像组成,标签为TrueFalse,取决于它们是否属于同一类。

利用我们刚刚看到的内容,让我们为这个任务构建一个自定义模型并对其进行训练。如何做?我们将使用一个预训练的架构并将我们的两个图像传递给它。然后我们可以连接结果并将它们发送到一个自定义头部,该头部将返回两个预测。在模块方面,看起来像这样:

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

encoder = create_body(resnet34, cut=-2)

然后我们可以创建我们的头部。查看编码器告诉我们最后一层有 512 个特征,所以这个头部将需要接收512*4。为什么是 4?首先我们必须乘以 2,因为我们有两个图像。然后我们需要第二次乘以 2,因为我们的连接池技巧。因此我们创建头部如下:

head = create_head(512*4, 2, ps=0.5)

有了我们的编码器和头部,我们现在可以构建我们的模型:

model = SiameseModel(encoder, head)

在使用Learner之前,我们还需要定义两件事。首先,我们必须定义要使用的损失函数。它是常规的交叉熵,但由于我们的目标是布尔值,我们需要将它们转换为整数,否则 PyTorch 会抛出错误:

def loss_func(out, targ):
    return nn.CrossEntropyLoss()(out, targ.long())

更重要的是,为了充分利用迁移学习,我们必须定义一个自定义的splittersplitter是一个告诉 fastai 库如何将模型分成参数组的函数。这些在幕后用于在进行迁移学习时仅训练模型的头部。

这里我们想要两个参数组:一个用于编码器,一个用于头部。因此我们可以定义以下splitterparams只是一个返回给定模块的所有参数的函数):

def siamese_splitter(model):
    return [params(model.encoder), params(model.head)]

然后,我们可以通过传递数据、模型、损失函数、分割器和任何我们想要的指标来定义我们的Learner。由于我们没有使用 fastai 的传输学习便利函数(如cnn_learner),我们必须手动调用learn.freeze。这将确保只有最后一个参数组(在本例中是头部)被训练:

learn = Learner(dls, model, loss_func=loss_func,
                splitter=siamese_splitter, metrics=accuracy)
learn.freeze()

然后我们可以直接使用通常的方法训练我们的模型:

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

现在我们解冻并使用有区别的学习率微调整个模型一点(即,对于主体使用较低的学习率,对于头部使用较高的学习率):

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 论文中描述的一种方法,称为“用于文本分类的 BPTT(BPT3C)”:

我们将文档分成固定长度为b的批次。在每个批次的开始,模型使用前一个批次的最终状态进行初始化;我们跟踪用于平均值和最大池化的隐藏状态;梯度被反向传播到隐藏状态对最终预测有贡献的批次。在实践中,我们使用可变长度的反向传播序列。

换句话说,分类器包含一个for循环,循环遍历每个序列的批次。状态在批次之间保持不变,并且存储每个批次的激活。最后,我们使用相同的平均值和最大连接池技巧,这与我们用于计算机视觉模型的方法相同,但这一次,我们不是在 CNN 网格单元上进行池化,而是在 RNN 序列上进行池化。

对于这个for循环,我们需要将我们的数据分批处理,但每个文本需要单独处理,因为它们各自有自己的标签。然而,这些文本很可能不会都是相同的长度,这意味着我们无法将它们都放在同一个数组中,就像我们在语言模型中所做的那样。

这就是填充将会有所帮助的地方:当获取一堆文本时,我们确定最长的文本,然后用一个特殊的标记xxpad填充较短的文本。为了避免在同一批次中有一个包含 2,000 个标记的文本和一个包含 10 个标记的文本的极端情况(因此有很多填充和浪费的计算),我们通过确保相似大小的文本被放在一起来改变随机性。文本在训练集中仍然会以某种随机顺序排列(对于验证集,我们可以简单地按长度顺序排序),但不完全是这样。

这是由 fastai 库在创建我们的DataLoaders时在幕后自动完成的。

表格

最后,让我们看看fastai.tabular模型。(我们不需要单独查看协同过滤,因为我们已经看到这些模型只是表格模型或使用点积方法,我们之前从头开始实现。)

这是TabularModelforward方法:

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中的每行代码。第一行只是测试是否有任何嵌入需要处理-如果只有连续变量,我们可以跳过这一部分:

if self.n_emb != 0:

self.embeds包含嵌入矩阵,因此这会获取每个激活

    x = [e(x_cat[:,i]) for i,e in enumerate(self.embeds)]

并将它们连接成一个单一张量:

    x = torch.cat(x, 1)

然后应用了辍学。您可以将emb_drop传递给__init__以更改此值:

    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

最后,这些通过线性层传递(每个线性层包括批量归一化,如果use_bnTrue,并且辍学,如果ps设置为某个值或值列表):

return self.layers(x)

恭喜!现在您已经了解了 fastai 库中使用的每个架构的所有细节!

结论

正如您所看到的,深度学习架构的细节现在不应该让您感到恐惧。您可以查看 fastai 和 PyTorch 的代码,看看发生了什么。更重要的是,尝试理解为什么会发生这种情况。查看代码中引用的论文,并尝试看看代码如何与描述的算法相匹配。

现在我们已经调查了模型的所有部分以及传递给它的数据,我们可以考虑这对于实际深度学习意味着什么。如果您拥有无限的数据,无限的内存和无限的时间,那么建议很简单:在所有数据上训练一个巨大的模型很长时间。但深度学习不简单的原因是您的数据、内存和时间通常是有限的。如果内存或时间不足,解决方案是训练一个较小的模型。如果您无法训练足够长时间以过拟合,那么您没有充分利用模型的容量。

因此,第一步是达到过拟合的点。然后问题是如何减少过拟合。图 15-3 显示了我们建议从那里优先考虑的步骤。

减少过拟合的步骤

图 15-3。减少过拟合的步骤

许多从业者在面对过拟合模型时,从这个图表的完全错误的一端开始。他们的起点是使用更小的模型或更多的正则化。除非训练模型占用太多时间或内存,否则使用更小的模型应该是您采取的最后一步。减小模型的大小会降低模型学习数据中微妙关系的能力。

相反,您的第一步应该是寻求创建更多数据。这可能涉及向您已经拥有的数据添加更多标签,找到模型可以被要求解决的其他任务(或者,换个角度思考,识别您可以建模的不同类型的标签),或者通过使用更多或不同的数据增强技术创建额外的合成数据。由于 Mixup 和类似方法的发展,现在几乎所有类型的数据都可以获得有效的数据增强。

一旦您获得了您认为可以合理获得的尽可能多的数据,并且通过利用您可以找到的所有标签并进行所有有意义的增强来尽可能有效地使用它,如果您仍然过拟合,您应该考虑使用更具一般化能力的架构。例如,添加批量归一化可能会提高泛化能力。

如果在尽力使用数据和调整架构后仍然过拟合,您可以考虑正则化。一般来说,在最后一层或两层添加 dropout 可以很好地正则化您的模型。然而,正如我们从 AWD-LSTM 开发故事中学到的那样,在整个模型中添加不同类型的 dropout 通常会更有帮助。一般来说,具有更多正则化的较大模型更灵活,因此比具有较少正则化的较小模型更准确。

只有在考虑了所有这些选项之后,我们才建议您尝试使用较小版本的架构。

问卷

  1. 神经网络的头是什么?

  2. 神经网络的主体是什么?

  3. 什么是“剪切”神经网络?为什么我们需要在迁移学习中这样做?

  4. model_meta是什么?尝试打印它以查看里面的内容。

  5. 阅读create_head的源代码,并确保你理解每一行的作用。

  6. 查看create_head的输出,并确保你理解每一层的存在原因,以及create_head源代码是如何创建它的。

  7. 找出如何改变create_cnn创建的 dropout、层大小和层数,并查看是否可以找到能够提高宠物识别准确性的值。

  8. AdaptiveConcatPool2d是什么作用?

  9. 什么是最近邻插值?如何用它来上采样卷积激活?

  10. 什么是转置卷积?还有另一个名称是什么?

  11. 创建一个带有transpose=True的卷积层,并将其应用于图像。检查输出形状。

  12. 绘制 U-Net 架构。

  13. 什么是用于文本分类的 BPTT(BPT3C)?

  14. 在 BPT3C 中如何处理不同长度的序列?

  15. 尝试在笔记本中逐行运行TabularModel.forward的每一行,每个单元格一行,并查看每个步骤的输入和输出形状。

  16. TabularModel中的self.layers是如何定义的?

  17. 预防过拟合的五个步骤是什么?

  18. 为什么在尝试其他方法预防过拟合之前不减少架构复杂性?

进一步研究

  1. 编写自己的自定义头,并尝试使用它训练宠物识别器。看看是否可以获得比 fastai 默认更好的结果。

  2. 尝试在 CNN 头部之间切换AdaptiveConcatPool2dAdaptiveAvgPool2d,看看会有什么不同。

  3. 编写自己的自定义分割器,为每个 ResNet 块创建一个单独的参数组,以及一个单独的参数组用于干扰。尝试使用它进行训练,看看是否可以改善宠物识别器。

  4. 阅读关于生成图像模型的在线章节,并创建自己的着色器、超分辨率模型或风格转移模型。

  5. 使用最近邻插值创建一个自定义头,并用它在 CamVid 上进行分割。

第十六章:训练过程

原文:www.bookstack.cn/read/th-fastai-book/76501b0f60767008.md

译者:飞龙

协议:CC BY-NC-SA 4.0

现在你知道如何为计算机视觉、自然图像处理、表格分析和协同过滤创建最先进的架构,也知道如何快速训练它们。所以我们完成了,对吧?还没有。我们仍然需要探索一下训练过程的更多内容。

我们在第四章中解释了随机梯度下降的基础:将一个小批量数据传递给模型,用损失函数将其与目标进行比较,然后计算这个损失函数对每个权重的梯度,然后使用公式更新权重:

new_weight = weight - lr * weight.grad

我们在训练循环中从头开始实现了这个,看到 PyTorch 提供了一个简单的nn.SGD类,可以为我们的每个参数进行这个计算。在本章中,我们将构建一些更快的优化器,使用一个灵活的基础。但在训练过程中,我们可能还想要改变一些东西。对于训练循环的任何调整,我们都需要一种方法来向 SGD 的基础添加一些代码。fastai 库有一个回调系统来做到这一点,我们将教你所有相关知识。

让我们从标准的 SGD 开始建立一个基线;然后我们将介绍最常用的优化器。

建立基线

首先,我们将使用普通的 SGD 创建一个基线,并将其与 fastai 的默认优化器进行比较。我们将通过使用与第十四章中相同的get_data来获取 Imagenette:

dls = get_data(URLs.IMAGENETTE_160, 160, 128)

我们将创建一个没有预训练的 ResNet-34,并传递任何接收到的参数:

def get_learner(**kwargs):
    return cnn_learner(dls, resnet34, pretrained=False,
                    metrics=accuracy, **kwargs).to_fp16()

这是默认的 fastai 优化器,具有通常的 3e-3 学习率:

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 使用任何优化器:

learn = get_learner(opt_func=SGD)

首先要看的是lr_find

learn.lr_find()
(0.017378008365631102, 3.019951861915615e-07)

看起来我们需要使用比我们通常使用的更高的学习率:

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中的定义:

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 步骤:

def sgd_cb(p, lr, **kwargs): p.data.add_(-lr, p.grad.data)

我们可以使用cbs参数将这个传递给Optimizer;我们需要使用partial,因为Learner将调用这个函数来创建我们的优化器:

opt_func = partial(Optimizer, cbs=[sgd_cb])

让我们看看这是否有效:

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 中呢?我们可以使用移动平均值,而不仅仅是当前梯度,来进行我们的步骤:

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 显示了一个单参数的噪声数据示例,其中动量曲线以红色绘制,参数的梯度以蓝色绘制。梯度增加,然后减少,动量很好地跟随总体趋势,而不会受到噪声的太大影响。

显示动量示例的图表

图 16-1。动量的一个例子

如果损失函数有窄谷,我们需要导航:普通的 SGD 会使我们从一边反弹到另一边,而带有动量的 SGD 会将这些平均值平滑地滚动到一侧。参数beta确定我们使用的动量的强度:使用较小的beta,我们会保持接近实际梯度值,而使用较高的beta,我们将主要朝着梯度的平均值前进,直到梯度的任何变化使得该趋势移动。

使用较大的beta,我们可能会错过梯度改变方向并滚动到一个小的局部最小值。这是一个期望的副作用:直观地,当我们向模型展示一个新的输入时,它会看起来像训练集中的某个东西,但不会完全像它。它将对应于损失函数中接近我们在训练结束时得到的最小值的点,但不会那个最小值。因此,我们宁愿在一个宽阔的最小值中进行训练,附近的点具有近似相同的损失(或者如果你喜欢的话,损失尽可能平坦的点)。图 16-2 显示了当我们改变beta时,图 16-1 中的图表如何变化。

显示 beta 值如何影响动量的图表

图 16-2。不同 beta 值的动量

我们可以看到在这些示例中,beta太高会导致梯度的整体变化被忽略。在带动量的 SGD 中,通常使用的beta值为 0.9。

fit_one_cycle默认从 0.95 开始,逐渐调整到 0.85,然后在训练结束时逐渐移回到 0.95。让我们看看在普通 SGD 中添加动量后我们的训练情况如何。

要向我们的优化器添加动量,我们首先需要跟踪移动平均梯度,我们可以使用另一个回调来实现。当优化器回调返回一个dict时,它用于更新优化器的状态,并在下一步传回优化器。因此,这个回调将跟踪梯度平均值,存储在名为grad_avg的参数中:

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

def momentum_step(p, lr, grad_avg, **kwargs): p.data.add_(-lr, grad_avg)
opt_func = partial(Optimizer, cbs=[average_grad,momentum_step], mom=0.9)

Learner将自动调度momlr,因此fit_one_cycle甚至可以与我们自定义的Optimizer一起使用:

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
learn.recorder.plot_sched()

我们仍然没有得到很好的结果,所以让我们看看我们还能做什么。

RMSProp

RMSProp 是由 Geoffrey Hinton 在他的 Coursera 课程“神经网络机器学习”第 6 讲 e中介绍的 SGD 的另一种变体。与 SGD 的主要区别在于它使用自适应学习率:每个参数都有自己特定的学习率,由全局学习率控制。这样,我们可以通过为需要大幅度改变的权重提供更高的学习率来加速训练,而对于已经足够好的权重,则提供较低的学习率。

我们如何决定哪些参数应该具有较高的学习率,哪些不应该?我们可以查看梯度来获取一个想法。如果一个参数的梯度一直接近于零,那么该参数将需要更高的学习率,因为损失是平的。另一方面,如果梯度到处都是,我们可能应该小心并选择一个较低的学习率以避免发散。我们不能简单地平均梯度来查看它们是否变化很多,因为大正数和大负数的平均值接近于零。相反,我们可以使用通常的技巧,即取绝对值或平方值(然后在平均后取平方根)。

再次,为了确定噪声背后的一般趋势,我们将使用移动平均值,具体来说是梯度的平方的移动平均值。然后,我们将通过使用当前梯度(用于方向)除以这个移动平均值的平方根来更新相应的权重(这样,如果它很低,有效的学习率将更高,如果它很高,有效的学习率将更低):

w.square_avg = alpha * w.square_avg + (1-alpha) * (w.grad ** 2)
new_w = w - lr * w.grad / math.sqrt(w.square_avg + eps)

epsepsilon)是为了数值稳定性而添加的(通常设置为 1e-8),alpha的默认值通常为 0.99。

我们可以通过做与avg_grad类似的事情将其添加到Optimizer中,但是多了一个**2

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}

我们可以像以前一样定义我们的步骤函数和优化器:

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)

让我们试一试:

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

好多了!现在我们只需将这些想法结合起来,我们就有了 Adam,fastai 的默认优化器。

Adam

Adam 将 SGD 与动量和 RMSProp 的思想结合在一起:它使用梯度的移动平均作为方向,并除以梯度平方的移动平均的平方根,为每个参数提供自适应学习率。

Adam 计算移动平均值的方式还有一个不同之处。它采用无偏移动平均值,即

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)。

将所有内容放在一起,我们的更新步骤看起来像这样:

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,文献建议的(beta1,beta2)的默认值为(0.9,0.999)

在 fastai 中,Adam 是我们使用的默认优化器,因为它可以加快训练速度,但我们发现beta2=0.99更适合我们使用的调度类型。beta1是动量参数,我们在调用fit_one_cycle时用参数moms指定。至于eps,fastai 使用默认值 1e-5。eps不仅仅对数值稳定性有用。更高的eps限制了调整学习率的最大值。举个极端的例子,如果eps为 1,那么调整后的学习率永远不会高于基本学习率。

与其在书中展示所有这些代码,我们让你去看看 fastai 的优化器笔记本https://oreil.ly/24_O[GitHub 存储库](浏览 _nbs文件夹并搜索名为optimizer的笔记本)。你会看到到目前为止我们展示的所有代码,以及 Adam 和其他优化器,以及许多示例和测试。

当我们从 SGD 转换为 Adam 时,有一件事情会改变,那就是我们如何应用权重衰减,这可能会产生重要的后果。

解耦权重衰减

权重衰减,我们在第八章中讨论过,相当于(在普通 SGD 的情况下)用以下方式更新参数:

new_weight = weight - lr*weight.grad - lr*wd*weight

这个公式的最后一部分解释了这种技术的名称:每个权重都会被lr * wd的因子衰减。

权重衰减的另一个名称是L2 正则化,它包括将所有平方权重的总和添加到损失中(乘以权重衰减)。正如我们在第八章中看到的,这可以直接表达在梯度上:

weight.grad += wd*weight

对于 SGD,这两个公式是等价的。然而,这种等价性仅适用于标准 SGD,因为正如我们在动量、RMSProp 或 Adam 中看到的,更新周围有一些额外的梯度公式。

大多数库使用第二种公式,但 Ilya Loshchilov 和 Frank Hutter 在“解耦权重衰减正则化”中指出,第一种方法是 Adam 优化器或动量的唯一正确方法,这就是为什么 fastai 将其设为默认值。

现在你知道了learn.fit_one_cycle这行代码背后隐藏的一切!

然而,优化器只是训练过程的一部分。当你需要改变 fastai 的训练循环时,你不能直接改变库内的代码。相反,我们设计了一套回调系统,让你可以在独立的块中编写任何你喜欢的调整,然后进行混合和匹配。

回调

有时候你需要稍微改变事物的工作方式。事实上,我们已经看到了这种情况的例子:Mixup,fp16 训练,每个时期重置模型以训练 RNN 等。我们如何进行这种类型的调整训练过程?

我们已经看到了基本训练循环,借助Optimizer类的帮助,对于单个时期,它看起来像这样:

for xb,yb in dl:
    loss = loss_func(model(xb), yb)
    loss.backward()
    opt.step()
    opt.zero_grad()

图 16-3 展示了如何形象地描绘这一点。

基本训练循环

图 16-3. 基本训练循环

深度学习从业者通常自定义训练循环的常规方式是复制现有训练循环,然后将特定更改所需的代码插入其中。这几乎是你在网上找到的所有代码的样子。但是它存在严重问题。

某个特定调整过的训练循环不太可能满足您的特定需求。可以对训练循环进行数百次更改,这意味着可能有数十亿种可能的排列组合。您不能只是从这里的一个训练循环中复制一个调整,从那里的另一个训练循环中复制另一个调整,然后期望它们都能一起工作。每个都将基于对其所在环境的不同假设,使用不同的命名约定,并期望数据以不同的格式存在。

我们需要一种方法,允许用户在训练循环的任何部分插入自己的代码,但以一种一致和明确定义的方式。计算机科学家已经提出了一个优雅的解决方案:回调。回调是您编写并注入到另一段代码中的代码片段,在预定义的点执行。事实上,回调已经多年用于深度学习训练循环。问题在于,在以前的库中,只能在可能需要的一小部分地方注入代码——更重要的是,回调无法执行它们需要执行的所有操作。

为了能够像手动复制和粘贴训练循环并直接插入代码一样灵活,回调必须能够读取训练循环中的所有可能信息,根据需要修改所有信息,并完全控制批次、周期甚至整个训练循环何时应该终止。fastai 是第一个提供所有这些功能的库。它修改了训练循环,使其看起来像图 16-4。

带有回调的训练循环

图 16-4. 带有回调的训练循环

这种方法的有效性在过去几年中得到了验证——通过使用 fastai 回调系统,我们能够实现我们尝试的每一篇新论文,并满足每一个修改训练循环的用户请求。训练循环本身并不需要修改。图 16-5 展示了添加的一些回调。

一些 fastai 回调

图 16-5. 一些 fastai 回调

这很重要,因为这意味着我们头脑中的任何想法,我们都可以实现。我们永远不需要深入 PyTorch 或 fastai 的源代码,并临时拼凑一个系统来尝试我们的想法。当我们实现自己的回调来开发自己的想法时,我们知道它们将与 fastai 提供的所有其他功能一起工作——因此我们将获得进度条、混合精度训练、超参数退火等等。

另一个优点是,它使逐渐删除或添加功能以及执行消融研究变得容易。您只需要调整传递给 fit 函数的回调列表。

例如,这是每个训练循环批次运行的 fastai 源代码:

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.xbself.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回调来为我们执行此操作。但它究竟是如何工作的呢?这是该类的完整源代码:

class ModelResetter(Callback):
    def begin_train(self):    self.model.reset()
    def begin_validate(self): self.model.reset()

是的,实际上就是这样!它只是在完成时代的训练或验证后,调用一个名为reset的方法。

回调通常像这样“简短而甜美”。实际上,让我们再看一个。这是添加 RNN 正则化(AR 和 TAR)的 fastai 回调的源代码:

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.modelself.pred来访问训练循环的属性。这是因为Callback将始终尝试获取其内部Learner中没有的属性。这些是self.learn.modelself.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 源代码:

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 的变体以及为什么它们可能更强大。在撰写本文时,开发新的优化器是一个活跃的研究领域,因此在阅读本章时,可能会在书籍网站上发布新变体的附录。请务必查看我们的通用优化器框架如何帮助您快速实现新的优化器。

我们还研究了强大的回调系统,该系统允许您通过允许您在每个步骤之间检查和修改任何参数来自定义训练循环的每一部分。

问卷调查

  1. SGD 一步的方程是什么,以数学或代码形式(您喜欢的方式)?

  2. 我们传递什么给cnn_learner以使用非默认优化器?

  3. 什么是优化器回调?

  4. 优化器中的zero_grad是做什么的?

  5. 优化器中的step是做什么的?通常优化器中如何实现它?

  6. 重写sgd_cb以使用+=运算符,而不是add_

  7. 什么是动量?写出方程式。

  8. 动量的物理类比是什么?它如何应用在我们的模型训练设置中?

  9. 动量值越大对梯度有什么影响?

  10. 1cycle 训练的动量的默认值是多少?

  11. RMSProp 是什么?写出方程。

  12. 梯度的平方值表示什么?

  13. Adam 与动量和 RMSProp 有何不同?

  14. 写出 Adam 的方程。

  15. 计算几批虚拟值的unbias_avgw.avg的值。

  16. 在 Adam 中,eps值较高会产生什么影响?

  17. 阅读 fastai 存储库中的优化器笔记本并执行它。

  18. 在哪些情况下,像 Adam 这样的动态学习率方法会改变权重衰减的行为?

  19. 训练循环的四个步骤是什么?

  20. 为什么使用回调比为每个想要添加的调整编写新的训练循环更好?

  21. fastai 回调系统设计的哪些方面使其像复制和粘贴代码片段一样灵活?

  22. 在编写回调时,如何获取可用事件的列表?

  23. 编写ModelResetter回调(请不要偷看)。

  24. 如何在回调内部访问训练循环的必要属性?何时可以使用或不使用与它们配套的快捷方式?

  25. 回调如何影响训练循环的控制流?

  26. 编写TerminateOnNaN回调(如果可能的话,请不要偷看)。

  27. 如何确保你的回调在另一个回调之后或之前运行?

进一步研究

  1. 查阅“修正的 Adam”论文,使用通用优化器框架实现它,并尝试一下。搜索其他最近在实践中表现良好的优化器,并选择一个实现。

  2. 查看文档中的混合精度回调。尝试理解每个事件和代码行的作用。

  3. 从头开始实现自己版本的学习率查找器。与 fastai 的版本进行比较。

  4. 查看 fastai 附带的回调的源代码。看看能否找到一个与你要做的类似的回调,以获得一些灵感。

深度学习基础:总结

恭喜你——你已经完成了书中“深度学习基础”部分!现在你理解了 fastai 的所有应用程序和最重要的架构是如何构建的,以及训练它们的推荐方法——你拥有构建这些内容所需的所有信息。虽然你可能不需要创建自己的训练循环或批归一化层,但了解幕后发生的事情对于调试、性能分析和部署解决方案非常有帮助。

既然你现在理解了 fastai 应用的基础,一定要花时间深入研究源代码笔记本,并运行和实验它们的部分。这将让你更清楚地了解 fastai 中的所有内容是如何开发的。

在下一节中,我们将更深入地探讨:我们将探索神经网络的实际前向和后向传递是如何进行的,以及我们可以利用哪些工具来获得更好的性能。然后,我们将继续进行一个项目,将书中的所有材料汇集在一起,用它来构建一个用于解释卷积神经网络的工具。最后但并非最不重要的是,我们将从头开始构建 fastai 的Learner类。

第四部分:从零开始的深度学习

第十七章:基础神经网络

原文:www.bookstack.cn/read/th-fastai-book/f6eef03cb69f6500.md

译者:飞龙

协议:CC BY-NC-SA 4.0

本章开始了一段旅程,我们将深入研究我们在前几章中使用的模型的内部。我们将涵盖许多我们以前见过的相同内容,但这一次我们将更加密切地关注实现细节,而不那么密切地关注事物为什么是这样的实际问题。

我们将从头开始构建一切,仅使用对张量的基本索引。我们将从头开始编写一个神经网络,然后手动实现反向传播,以便我们在调用loss.backward时确切地知道 PyTorch 中发生了什么。我们还将看到如何使用自定义autograd函数扩展 PyTorch,允许我们指定自己的前向和后向计算。

从头开始构建神经网络层

让我们首先刷新一下我们对基本神经网络中如何使用矩阵乘法的理解。由于我们正在从头开始构建一切,所以最初我们将仅使用纯 Python(除了对 PyTorch 张量的索引),然后在看到如何创建后,将纯 Python 替换为 PyTorch 功能。

建模神经元

神经元接收一定数量的输入,并为每个输入设置内部权重。它对这些加权输入求和以产生输出,并添加内部偏置。在数学上,这可以写成

o u t = i=1 n x i w i + b

如果我们将输入命名为( x 1 , , x n ),我们的权重为( w 1 , , w n ),以及我们的偏置b。在代码中,这被翻译为以下内容:

output = sum([x*w for x,w in zip(inputs,weights)]) + bias

然后将此输出馈送到非线性函数中,称为激活函数,然后发送到另一个神经元。在深度学习中,最常见的是修正线性单元,或ReLU,正如我们所见,这是一种花哨的说法:

def relu(x): return x if x >= 0 else 0

然后通过在连续的层中堆叠许多这些神经元来构建深度学习模型。我们创建一个具有一定数量的神经元(称为隐藏大小)的第一层,并将所有输入链接到每个神经元。这样的一层通常称为全连接层密集层(用于密集连接),或线性层

它要求您计算每个input和具有给定weight的每个神经元的点积:

sum([x*w for x,w in zip(input,weight)])

如果您对线性代数有一点了解,您可能会记得当您进行矩阵乘法时会发生许多这些点积。更准确地说,如果我们的输入在大小为batch_size乘以n_inputs的矩阵x中,并且如果我们已将神经元的权重分组在大小为n_neurons乘以n_inputs的矩阵w中(每个神经元必须具有与其输入相同数量的权重),以及将所有偏置分组在大小为n_neurons的向量b中,则此全连接层的输出为

y = x @ w.t() + b

其中@表示矩阵乘积,w.t()w的转置矩阵。然后输出y的大小为batch_size乘以n_neurons,在位置(i,j)上我们有这个(对于数学爱好者):

y i,j = k=1 n x i,k w k,j + b j

或者在代码中:

y[i,j] = sum([a * b for a,b in zip(x[i,:],w[j,:])]) + b[j]

转置是必要的,因为在矩阵乘积m @ n的数学定义中,系数(i,j)如下:

sum([a * b for a,b in zip(m[i,:],n[:,j])])

所以我们需要的非常基本的操作是矩阵乘法,因为这是神经网络核心中隐藏的内容。

从头开始的矩阵乘法

让我们编写一个函数,计算两个张量的矩阵乘积,然后再允许我们使用 PyTorch 版本。我们只会在 PyTorch 张量中使用索引:

import torch
from torch import tensor

我们需要三个嵌套的for循环:一个用于行索引,一个用于列索引,一个用于内部求和。acar分别表示a的列数和行数(对于b也是相同的约定),我们通过检查a的列数是否与b的行数相同来确保计算矩阵乘积是可能的:

def matmul(a,b):
    ar,ac = a.shape # n_rows * n_cols
    br,bc = b.shape
    assert ac==br
    c = torch.zeros(ar, bc)
    for i in range(ar):
        for j in range(bc):
            for k in range(ac): c[i,j] += a[i,k] * b[k,j]
    return c

为了测试这一点,我们假装(使用随机矩阵)我们正在处理一个包含 5 个 MNIST 图像的小批量,将它们展平为28*28向量,然后使用线性模型将它们转换为 10 个激活值:

m1 = torch.randn(5,28*28)
m2 = torch.randn(784,10)

让我们计时我们的函数,使用 Jupyter 的“魔术”命令%time

%time t1=matmul(m1, m2)
CPU times: user 1.15 s, sys: 4.09 ms, total: 1.15 s
Wall time: 1.15 s

看看这与 PyTorch 内置的@有什么区别?

%timeit -n 20 t2=m1@m2
14 µs ± 8.95 µs per loop (mean ± std. dev. of 7 runs, 20 loops each)

正如我们所看到的,在 Python 中三个嵌套循环是一个坏主意!Python 是一种慢速语言,这不会高效。我们在这里看到 PyTorch 比 Python 快大约 100,000 倍——而且这还是在我们开始使用 GPU 之前!

这种差异是从哪里来的?PyTorch 没有在 Python 中编写矩阵乘法,而是使用 C++来加快速度。通常,当我们在张量上进行计算时,我们需要向量化它们,以便利用 PyTorch 的速度,通常使用两种技术:逐元素算术和广播。

逐元素算术

所有基本运算符(+-*/><==)都可以逐元素应用。这意味着如果我们为两个具有相同形状的张量aba+b,我们将得到一个由ab元素之和组成的张量:

a = tensor([10., 6, -4])
b = tensor([2., 8, 7])
a + b
tensor([12., 14.,  3.])

布尔运算符将返回一个布尔数组:

a < b
tensor([False,  True,  True])

如果我们想知道a的每个元素是否小于b中对应的元素,或者两个张量是否相等,我们需要将这些逐元素操作与torch.all结合起来:

(a < b).all(), (a==b).all()
(tensor(False), tensor(False))

allsummean这样的缩减操作返回只有一个元素的张量,称为秩-0 张量。如果要将其转换为普通的 Python 布尔值或数字,需要调用.item

(a + b).mean().item()
9.666666984558105

逐元素操作适用于任何秩的张量,只要它们具有相同的形状:

m = tensor([[1., 2, 3], [4,5,6], [7,8,9]])
m*m
tensor([[ 1.,  4.,  9.],
        [16., 25., 36.],
        [49., 64., 81.]])

但是,不能对形状不同的张量执行逐元素操作(除非它们是可广播的,如下一节所讨论的):

n = tensor([[1., 2, 3], [4,5,6]])
m*n
 RuntimeError: The size of tensor a (3) must match the size of tensor b (2) at
 dimension 0

通过逐元素算术,我们可以去掉我们的三个嵌套循环中的一个:我们可以在将a的第i行和b的第j列对应的张量相乘之前对它们进行求和,这将加快速度,因为内部循环现在将由 PyTorch 以 C 速度执行。

要访问一列或一行,我们可以简单地写a[i,:]b[:,j]:表示在该维度上取所有内容。我们可以限制这个并只取该维度的一个切片,通过传递一个范围,比如1:5,而不仅仅是:。在这种情况下,我们将取第 1 到第 4 列的元素(第二个数字是不包括在内的)。

一个简化是我们总是可以省略尾随冒号,因此a[i,:]可以缩写为a[i]。考虑到所有这些,我们可以编写我们矩阵乘法的新版本:

def matmul(a,b):
    ar,ac = a.shape
    br,bc = b.shape
    assert ac==br
    c = torch.zeros(ar, bc)
    for i in range(ar):
        for j in range(bc): c[i,j] = (a[i] * b[:,j]).sum()
    return c
%timeit -n 20 t3 = matmul(m1,m2)
1.7 ms ± 88.1 µs per loop (mean ± std. dev. of 7 runs, 20 loops each)

我们已经快了约 700 倍,只是通过删除那个内部的for循环!这只是开始——通过广播,我们可以删除另一个循环并获得更重要的加速。

广播

正如我们在第四章中讨论的那样,广播是由Numpy 库引入的一个术语,用于描述在算术操作期间如何处理不同秩的张量。例如,显然无法将 3×3 矩阵与 4×5 矩阵相加,但如果我们想将一个标量(可以表示为 1×1 张量)与矩阵相加呢?或者大小为 3 的向量与 3×4 矩阵?在这两种情况下,我们可以找到一种方法来理解这个操作。

广播为编码规则提供了特定的规则,用于在尝试进行逐元素操作时确定形状是否兼容,以及如何扩展较小形状的张量以匹配较大形状的张量。如果您想要能够编写快速执行的代码,掌握这些规则是至关重要的。在本节中,我们将扩展我们之前对广播的处理,以了解这些规则。

使用标量进行广播

使用标量进行广播是最简单的广播类型。当我们有一个张量a和一个标量时,我们只需想象一个与a形状相同且填充有该标量的张量,并执行操作:

a = tensor([10., 6, -4])
a > 0
tensor([ True,  True, False])

我们如何能够进行这种比较?0广播以具有与a相同的维度。请注意,这是在不在内存中创建一个充满零的张量的情况下完成的(这将是低效的)。

如果要通过减去均值(标量)来标准化数据集(矩阵)并除以标准差(另一个标量),这是很有用的:

m = tensor([[1., 2, 3], [4,5,6], [7,8,9]])
(m - 5) / 2.73
tensor([[-1.4652, -1.0989, -0.7326],
        [-0.3663,  0.0000,  0.3663],
        [ 0.7326,  1.0989,  1.4652]])

如果矩阵的每行有不同的均值怎么办?在这种情况下,您需要将一个向量广播到一个矩阵。

将向量广播到矩阵

我们可以将一个向量广播到一个矩阵中:

c = tensor([10.,20,30])
m = tensor([[1., 2, 3], [4,5,6], [7,8,9]])
m.shape,c.shape
(torch.Size([3, 3]), torch.Size([3]))
m + c
tensor([[11., 22., 33.],
        [14., 25., 36.],
        [17., 28., 39.]])

这里,c的元素被扩展为使三行匹配,从而使操作成为可能。同样,PyTorch 实际上并没有在内存中创建三个c的副本。这是由幕后的expand_as方法完成的:

c.expand_as(m)
tensor([[10., 20., 30.],
        [10., 20., 30.],
        [10., 20., 30.]])

如果我们查看相应的张量,我们可以请求其storage属性(显示用于张量的内存实际内容)来检查是否存储了无用的数据:

t = c.expand_as(m)
t.storage()
 10.0
 20.0
 30.0
[torch.FloatStorage of size 3]

尽管张量在官方上有九个元素,但内存中只存储了三个标量。这是可能的,这要归功于给该维度一个 0 步幅的巧妙技巧。在该维度上(这意味着当 PyTorch 通过添加步幅查找下一行时,它不会移动):

t.stride(), t.shape
((0, 1), torch.Size([3, 3]))

由于m的大小为 3×3,有两种广播的方式。在最后一个维度上进行广播的事实是一种约定,这是来自广播规则的规定,与我们对张量排序的方式无关。如果我们这样做,我们会得到相同的结果:

c + m
tensor([[11., 22., 33.],
        [14., 25., 36.],
        [17., 28., 39.]])

实际上,只有通过n,我们才能将大小为n的向量广播到大小为m的矩阵中:

c = tensor([10.,20,30])
m = tensor([[1., 2, 3], [4,5,6]])
c+m
tensor([[11., 22., 33.],
        [14., 25., 36.]])

这不起作用:

c = tensor([10.,20])
m = tensor([[1., 2, 3], [4,5,6]])
c+m
 RuntimeError: The size of tensor a (2) must match the size of tensor b (3) at
 dimension 1

如果我们想在另一个维度上进行广播,我们必须改变向量的形状,使其成为一个 3×1 矩阵。这可以通过 PyTorch 中的unsqueeze方法来实现:

c = tensor([10.,20,30])
m = tensor([[1., 2, 3], [4,5,6], [7,8,9]])
c = c.unsqueeze(1)
m.shape,c.shape
(torch.Size([3, 3]), torch.Size([3, 1]))

这次,c在列侧进行了扩展:

c+m
tensor([[11., 12., 13.],
        [24., 25., 26.],
        [37., 38., 39.]])

与以前一样,只有三个标量存储在内存中:

t = c.expand_as(m)
t.storage()
 10.0
 20.0
 30.0
[torch.FloatStorage of size 3]

扩展后的张量具有正确的形状,因为列维度的步幅为 0:

t.stride(), t.shape
((1, 0), torch.Size([3, 3]))

使用广播,如果需要添加维度,则默认情况下会在开头添加。在之前进行广播时,PyTorch 在幕后执行了c.unsqueeze(0)

c = tensor([10.,20,30])
c.shape, c.unsqueeze(0).shape,c.unsqueeze(1).shape
(torch.Size([3]), torch.Size([1, 3]), torch.Size([3, 1]))

unsqueeze命令可以被None索引替换:

c.shape, c[None,:].shape,c[:,None].shape
(torch.Size([3]), torch.Size([1, 3]), torch.Size([3, 1]))

您可以始终省略尾随冒号,...表示所有前面的维度:

c[None].shape,c[...,None].shape
(torch.Size([1, 3]), torch.Size([3, 1]))

有了这个,我们可以在我们的矩阵乘法函数中删除另一个for循环。现在,我们不再将a[i]乘以b[:,j],而是使用广播将a[i]乘以整个矩阵b,然后对结果求和:

def matmul(a,b):
    ar,ac = a.shape
    br,bc = b.shape
    assert ac==br
    c = torch.zeros(ar, bc)
    for i in range(ar):
#       c[i,j] = (a[i,:]          * b[:,j]).sum() # previous
        c[i]   = (a[i  ].unsqueeze(-1) * b).sum(dim=0)
    return c
%timeit -n 20 t4 = matmul(m1,m2)
357 µs ± 7.2 µs per loop (mean ± std. dev. of 7 runs, 20 loops each)

现在我们比第一次实现快了 3700 倍!在继续之前,让我们更详细地讨论一下广播规则。

广播规则

在操作两个张量时,PyTorch 会逐个元素地比较它们的形状。它从尾部维度开始,逆向工作,在遇到空维度时添加 1。当以下情况之一为真时,两个维度是兼容的:

  • 它们是相等的。

  • 其中之一是 1,此时该维度会被广播以使其与其他维度相同。

数组不需要具有相同数量的维度。例如,如果您有一个 256×256×3 的 RGB 值数组,并且想要按不同值缩放图像中的每种颜色,您可以将图像乘以一个具有三个值的一维数组。根据广播规则排列这些数组的尾部轴的大小表明它们是兼容的:

Image  (3d tensor): 256 x 256 x 3
Scale  (1d tensor):  (1)   (1)  3
Result (3d tensor): 256 x 256 x 3

然而,一个大小为 256×256 的 2D 张量与我们的图像不兼容:

Image  (3d tensor): 256 x 256 x   3
Scale  (1d tensor):  (1)  256 x 256
Error

在我们早期的例子中,使用了一个 3×3 矩阵和一个大小为 3 的向量,广播是在行上完成的:

Matrix (2d tensor):   3 x 3
Vector (1d tensor): (1)   3
Result (2d tensor):   3 x 3

作为练习,尝试确定何时需要添加维度(以及在何处),以便将大小为64 x 3 x 256 x 256的图像批次与三个元素的向量(一个用于均值,一个用于标准差)进行归一化。

另一种简化张量操作的有用方法是使用爱因斯坦求和约定。

爱因斯坦求和

在使用 PyTorch 操作@torch.matmul之前,我们可以实现矩阵乘法的最后一种方法:爱因斯坦求和einsum)。这是一种将乘积和求和以一般方式组合的紧凑表示。我们可以写出这样的方程:

ik,kj -> ij

左侧表示操作数的维度,用逗号分隔。这里我们有两个分别具有两个维度(i,kk,j)的张量。右侧表示结果维度,所以这里我们有一个具有两个维度i,j的张量。

爱因斯坦求和符号的规则如下:

  1. 重复的索引会被隐式求和。

  2. 每个索引在任何项中最多只能出现两次。

  3. 每个项必须包含相同的非重复索引。

因此,在我们的例子中,由于k是重复的,我们对该索引求和。最终,该公式表示当我们在(i,j)中放入所有第一个张量中的系数(i,k)与第二个张量中的系数(k,j)相乘的总和时得到的矩阵……这就是矩阵乘积!

以下是我们如何在 PyTorch 中编写这段代码:

def matmul(a,b): return torch.einsum('ik,kj->ij', a, b)

爱因斯坦求和是一种非常实用的表达涉及索引和乘积和的操作的方式。请注意,您可以在左侧只有一个成员。例如,

torch.einsum('ij->ji', a)

返回矩阵a的转置。您也可以有三个或更多成员:

torch.einsum('bi,ij,bj->b', a, b, c)

这将返回一个大小为b的向量,其中第k个坐标是a[k,i] b[i,j] c[k,j]的总和。当您有更多维度时,这种表示特别方便,因为有批次。例如,如果您有两批次的矩阵并且想要计算每批次的矩阵乘积,您可以这样做:

torch.einsum('bik,bkj->bij', a, b)

让我们回到使用einsum实现的新matmul,看看它的速度:

%timeit -n 20 t5 = matmul(m1,m2)
68.7 µs ± 4.06 µs per loop (mean ± std. dev. of 7 runs, 20 loops each)

正如您所看到的,它不仅实用,而且非常快。einsum通常是在 PyTorch 中执行自定义操作的最快方式,而无需深入研究 C++和 CUDA。(但通常不如精心优化的 CUDA 代码快,正如您从“从头开始的矩阵乘法”的结果中看到的。)

现在我们知道如何从头开始实现矩阵乘法,我们准备构建我们的神经网络——具体来说,是它的前向和后向传递——只使用矩阵乘法。

前向和后向传递

正如我们在第四章中看到的,为了训练一个模型,我们需要计算给定损失对其参数的所有梯度,这被称为反向传播。在前向传播中,我们根据矩阵乘积计算给定输入上模型的输出。当我们定义我们的第一个神经网络时,我们还将深入研究适当初始化权重的问题,这对于使训练正确开始至关重要。

定义和初始化一个层

首先我们将以两层神经网络为例。正如我们所看到的,一层可以表示为y = x @ w + b,其中x是我们的输入,y是我们的输出,w是该层的权重(如果我们不像之前那样转置,则大小为输入数量乘以神经元数量),b是偏置向量:

def lin(x, w, b): return x @ w + b

我们可以将第二层叠加在第一层上,但由于数学上两个线性操作的组合是另一个线性操作,只有在中间放入一些非线性的东西才有意义,称为激活函数。正如本章开头提到的,在深度学习应用中,最常用的激活函数是 ReLU,它返回x0的最大值。

在本章中,我们实际上不会训练我们的模型,因此我们将为我们的输入和目标使用随机张量。假设我们的输入是大小为 100 的 200 个向量,我们将它们分组成一个批次,我们的目标是 200 个随机浮点数:

x = torch.randn(200, 100)
y = torch.randn(200)

对于我们的两层模型,我们将需要两个权重矩阵和两个偏置向量。假设我们的隐藏大小为 50,输出大小为 1(对于我们的输入之一,相应的输出在这个玩具示例中是一个浮点数)。我们随机初始化权重,偏置为零:

w1 = torch.randn(100,50)
b1 = torch.zeros(50)
w2 = torch.randn(50,1)
b2 = torch.zeros(1)

然后我们的第一层的结果就是这样的:

l1 = lin(x, w1, b1)
l1.shape
torch.Size([200, 50])

请注意,这个公式适用于我们的输入批次,并返回一个隐藏状态批次:l1是一个大小为 200(我们的批次大小)乘以 50(我们的隐藏大小)的矩阵。

然而,我们的模型初始化方式存在问题。要理解这一点,我们需要查看l1的均值和标准差(std):

l1.mean(), l1.std()
(tensor(0.0019), tensor(10.1058))

均值接近零,这是可以理解的,因为我们的输入和权重矩阵的均值都接近零。但标准差,表示我们的激活离均值有多远,从 1 变为 10。这是一个真正的大问题,因为这只是一个层。现代神经网络可以有数百层,因此如果每一层将我们的激活的规模乘以 10,到了最后一层,我们将无法用计算机表示数字。

实际上,如果我们在x和大小为 100×100 的随机矩阵之间进行 50 次乘法运算,我们将得到这个结果:

x = torch.randn(200, 100)
for i in range(50): x = x @ torch.randn(100,100)
x[0:5,0:5]
tensor([[nan, nan, nan, nan, nan],
        [nan, nan, nan, nan, nan],
        [nan, nan, nan, nan, nan],
        [nan, nan, nan, nan, nan],
        [nan, nan, nan, nan, nan]])

结果是到处都是nan。也许我们的矩阵的规模太大了,我们需要更小的权重?但如果我们使用太小的权重,我们将遇到相反的问题-我们的激活的规模将从 1 变为 0.1,在 100 层之后,我们将到处都是零:

x = torch.randn(200, 100)
for i in range(50): x = x @ (torch.randn(100,100) * 0.01)
x[0:5,0:5]
tensor([[0., 0., 0., 0., 0.],
        [0., 0., 0., 0., 0.],
        [0., 0., 0., 0., 0.],
        [0., 0., 0., 0., 0.],
        [0., 0., 0., 0., 0.]])

因此,我们必须精确地缩放我们的权重矩阵,以使我们的激活的标准差保持在 1。我们可以通过数学计算出要使用的确切值,正如 Xavier Glorot 和 Yoshua Bengio 在“理解训练深度前馈神经网络的困难”中所示。给定层的正确比例是1 / n in,其中n in代表输入的数量。

在我们的情况下,如果有 100 个输入,我们应该将我们的权重矩阵缩放为 0.1:

x = torch.randn(200, 100)
for i in range(50): x = x @ (torch.randn(100,100) * 0.1)
x[0:5,0:5]
tensor([[ 0.7554,  0.6167, -0.1757, -1.5662,  0.5644],
        [-0.1987,  0.6292,  0.3283, -1.1538,  0.5416],
        [ 0.6106,  0.2556, -0.0618, -0.9463,  0.4445],
        [ 0.4484,  0.7144,  0.1164, -0.8626,  0.4413],
        [ 0.3463,  0.5930,  0.3375, -0.9486,  0.5643]])

终于,一些既不是零也不是nan的数字!请注意,即使经过了那 50 个虚假层,我们的激活的规模仍然是稳定的:

x.std()
tensor(0.7042)

如果你稍微调整一下 scale 的值,你会注意到即使从 0.1 稍微偏离,你会得到非常小或非常大的数字,因此正确初始化权重非常重要。

让我们回到我们的神经网络。由于我们稍微改变了我们的输入,我们需要重新定义它们:

x = torch.randn(200, 100)
y = torch.randn(200)

对于我们的权重,我们将使用正确的 scale,这被称为Xavier 初始化(或Glorot 初始化):

from math import sqrt
w1 = torch.randn(100,50) / sqrt(100)
b1 = torch.zeros(50)
w2 = torch.randn(50,1) / sqrt(50)
b2 = torch.zeros(1)

现在如果我们计算第一层的结果,我们可以检查均值和标准差是否受控制:

l1 = lin(x, w1, b1)
l1.mean(),l1.std()
(tensor(-0.0050), tensor(1.0000))

非常好。现在我们需要经过一个 ReLU,所以让我们定义一个。ReLU 去除负数并用零替换它们,这另一种说法是它将我们的张量夹在零处:

def relu(x): return x.clamp_min(0.)

我们通过这个激活:

l2 = relu(l1)
l2.mean(),l2.std()
(tensor(0.3961), tensor(0.5783))

现在我们回到原点:我们的激活均值变为 0.4(这是可以理解的,因为我们去除了负数),标准差下降到 0.58。所以像以前一样,经过几层后我们可能最终会得到零:

x = torch.randn(200, 100)
for i in range(50): x = relu(x @ (torch.randn(100,100) * 0.1))
x[0:5,0:5]
tensor([[0.0000e+00, 1.9689e-08, 4.2820e-08, 0.0000e+00, 0.0000e+00],
        [0.0000e+00, 1.6701e-08, 4.3501e-08, 0.0000e+00, 0.0000e+00],
        [0.0000e+00, 1.0976e-08, 3.0411e-08, 0.0000e+00, 0.0000e+00],
        [0.0000e+00, 1.8457e-08, 4.9469e-08, 0.0000e+00, 0.0000e+00],
        [0.0000e+00, 1.9949e-08, 4.1643e-08, 0.0000e+00, 0.0000e+00]])

这意味着我们的初始化不正确。为什么?在 Glorot 和 Bengio 撰写他们的文章时,神经网络中最流行的激活函数是双曲正切(tanh,他们使用的那个),而该初始化并没有考虑到我们的 ReLU。幸运的是,有人已经为我们计算出了正确的 scale 供我们使用。在“深入研究整流器:超越人类水平的性能”(我们之前见过的文章,介绍了 ResNet),Kaiming He 等人表明我们应该使用以下 scale 代替:2 / n in,其中n in是我们模型的输入数量。让我们看看这给我们带来了什么:

x = torch.randn(200, 100)
for i in range(50): x = relu(x @ (torch.randn(100,100) * sqrt(2/100)))
x[0:5,0:5]
tensor([[0.2871, 0.0000, 0.0000, 0.0000, 0.0026],
        [0.4546, 0.0000, 0.0000, 0.0000, 0.0015],
        [0.6178, 0.0000, 0.0000, 0.0180, 0.0079],
        [0.3333, 0.0000, 0.0000, 0.0545, 0.0000],
        [0.1940, 0.0000, 0.0000, 0.0000, 0.0096]])

好了:这次我们的数字不全为零。所以让我们回到我们神经网络的定义,并使用这个初始化(被称为Kaiming 初始化He 初始化):

x = torch.randn(200, 100)
y = torch.randn(200)
w1 = torch.randn(100,50) * sqrt(2 / 100)
b1 = torch.zeros(50)
w2 = torch.randn(50,1) * sqrt(2 / 50)
b2 = torch.zeros(1)

让我们看看通过第一个线性层和 ReLU 后激活的规模:

l1 = lin(x, w1, b1)
l2 = relu(l1)
l2.mean(), l2.std()
(tensor(0.5661), tensor(0.8339))

好多了!现在我们的权重已经正确初始化,我们可以定义我们的整个模型:

def model(x):
    l1 = lin(x, w1, b1)
    l2 = relu(l1)
    l3 = lin(l2, w2, b2)
    return l3

这是前向传播。现在剩下的就是将我们的输出与我们拥有的标签(在这个例子中是随机数)进行比较,使用损失函数。在这种情况下,我们将使用均方误差。(这是一个玩具问题,这是下一步计算梯度所使用的最简单的损失函数。)

唯一的微妙之处在于我们的输出和目标形状并不完全相同——经过模型后,我们得到这样的输出:

out = model(x)
out.shape
torch.Size([200, 1])

为了去掉这个多余的 1 维,我们使用squeeze函数:

def mse(output, targ): return (output.squeeze(-1) - targ).pow(2).mean()

现在我们准备计算我们的损失:

loss = mse(out, y)

前向传播到此结束,现在让我们看一下梯度。

梯度和反向传播

我们已经看到 PyTorch 通过一个神奇的调用loss.backward计算出我们需要的所有梯度,但让我们探究一下背后发生了什么。

现在我们需要计算损失相对于模型中所有权重的梯度,即w1b1w2b2中的所有浮点数。为此,我们需要一点数学,具体来说是链式法则。这是指导我们如何计算复合函数导数的微积分规则:

(gf) ' ( x ) = g ' ( f ( x ) ) f ' ( x )

Jeremy 说

我发现这种符号很难理解,所以我喜欢这样想:如果 y = g(u)u=f(x),那么 dy/dx = dy/du * du/dx。这两种符号意思相同,所以使用任何一种都可以。

我们的损失是不同函数的大组合:均方误差(实际上是均值和平方的组合),第二个线性层,一个 ReLU,和第一个线性层。例如,如果我们想要损失相对于 b2 的梯度,而我们的损失由以下定义:

loss = mse(out,y) = mse(lin(l2, w2, b2), y)

链式法则告诉我们我们有这个:

dloss db 2 = dloss dout × dout db 2 = d dout m s e ( o u t , y ) × d db 2 l i n ( l 2 , w 2 , b 2 )

要计算损失相对于 b 2 的梯度,我们首先需要损失相对于我们的输出 o u t 的梯度。如果我们想要损失相对于 w 2 的梯度也是一样的。然后,要得到损失相对于 b 1w 1 的梯度,我们将需要损失相对于 l 1 的梯度,这又需要损失相对于 l 2 的梯度,这将需要损失相对于 o u t 的梯度。

因此,为了计算更新所需的所有梯度,我们需要从模型的输出开始,逐层向后工作,一层接一层地——这就是为什么这一步被称为反向传播。我们可以通过让我们实现的每个函数(relumselin)提供其反向步骤来自动化它:也就是说,如何从损失相对于输出的梯度推导出损失相对于输入的梯度。

在这里,我们将这些梯度填充到每个张量的属性中,有点像 PyTorch 在.grad中所做的那样。

首先是我们模型输出(损失函数的输入)相对于损失的梯度。我们撤消了mse中的squeeze,然后我们使用给出x 2的导数的公式:2 x。均值的导数只是 1/n,其中n是我们输入中的元素数:

def mse_grad(inp, targ):
    # grad of loss with respect to output of previous layer
    inp.g = 2. * (inp.squeeze() - targ).unsqueeze(-1) / inp.shape[0]

对于 ReLU 和我们的线性层的梯度,我们使用相对于输出的损失的梯度(在out.g中)并应用链式法则来计算相对于输出的损失的梯度(在inp.g中)。链式法则告诉我们inp.g = relu'(inp) * out.grelu的导数要么是 0(当输入为负数时),要么是 1(当输入为正数时),因此这给出了以下结果:

def relu_grad(inp, out):
    # grad of relu with respect to input activations
    inp.g = (inp>0).float() * out.g

计算损失相对于线性层中的输入、权重和偏差的梯度的方案是相同的:

def lin_grad(inp, out, w, b):
    # grad of matmul with respect to input
    inp.g = out.g @ w.t()
    w.g = inp.t() @ out.g
    b.g = out.g.sum(0)

我们不会深入讨论定义它们的数学公式,因为对我们的目的来说它们不重要,但如果你对这个主题感兴趣,可以查看可汗学院出色的微积分课程。

一旦我们定义了这些函数,我们就可以使用它们来编写后向传递。由于每个梯度都会自动填充到正确的张量中,我们不需要将这些_grad函数的结果存储在任何地方——我们只需要按照前向传递的相反顺序执行它们,以确保在每个函数中out.g存在:

def forward_and_backward(inp, targ):
    # forward pass:
    l1 = inp @ w1 + b1
    l2 = relu(l1)
    out = l2 @ w2 + b2
    # we don't actually need the loss in backward!
    loss = mse(out, targ)

    # backward pass:
    mse_grad(out, targ)
    lin_grad(l2, out, w2, b2)
    relu_grad(l1, l2)
    lin_grad(inp, l1, w1, b1)

现在我们可以在w1.gb1.gw2.gb2.g中访问我们模型参数的梯度。我们已经成功定义了我们的模型——现在让我们让它更像一个 PyTorch 模块。

重构模型

我们使用的三个函数有两个相关的函数:一个前向传递和一个后向传递。我们可以创建一个类将它们包装在一起,而不是分开编写它们。该类还可以存储后向传递的输入和输出。这样,我们只需要调用backward

class Relu():
    def __call__(self, inp):
        self.inp = inp
        self.out = inp.clamp_min(0.)
        return self.out

    def backward(self): self.inp.g = (self.inp>0).float() * self.out.g

__call__是 Python 中的一个魔术名称,它将使我们的类可调用。当我们键入y = Relu()(x)时,将执行这个操作。我们也可以对我们的线性层和 MSE 损失做同样的操作:

class Lin():
    def __init__(self, w, b): self.w,self.b = w,b

    def __call__(self, inp):
        self.inp = inp
        self.out = inp@self.w + self.b
        return self.out

    def backward(self):
        self.inp.g = self.out.g @ self.w.t()
        self.w.g = self.inp.t() @ self.out.g
        self.b.g = self.out.g.sum(0)
class Mse():
    def __call__(self, inp, targ):
        self.inp = inp
        self.targ = targ
        self.out = (inp.squeeze() - targ).pow(2).mean()
        return self.out

    def backward(self):
        x = (self.inp.squeeze()-self.targ).unsqueeze(-1)
        self.inp.g = 2.*x/self.targ.shape[0]

然后我们可以把一切都放在一个模型中,我们用我们的张量w1b1w2b2来初始化:

class Model():
    def __init__(self, w1, b1, w2, b2):
        self.layers = [Lin(w1,b1), Relu(), Lin(w2,b2)]
        self.loss = Mse()

    def __call__(self, x, targ):
        for l in self.layers: x = l(x)
        return self.loss(x, targ)

    def backward(self):
        self.loss.backward()
        for l in reversed(self.layers): l.backward()

这种重构和将事物注册为模型的层的好处是,前向和后向传递现在非常容易编写。如果我们想要实例化我们的模型,我们只需要写这个:

model = Model(w1, b1, w2, b2)

然后前向传递可以这样执行:

loss = model(x, y)

然后使用这个进行后向传递:

model.backward()

转向 PyTorch

我们编写的LinMseRelu类有很多共同之处,所以我们可以让它们都继承自同一个基类:

class LayerFunction():
    def __call__(self, *args):
        self.args = args
        self.out = self.forward(*args)
        return self.out

    def forward(self):  raise Exception('not implemented')
    def bwd(self):      raise Exception('not implemented')
    def backward(self): self.bwd(self.out, *self.args)

然后我们只需要在每个子类中实现forwardbwd

class Relu(LayerFunction):
    def forward(self, inp): return inp.clamp_min(0.)
    def bwd(self, out, inp): inp.g = (inp>0).float() * out.g
class Lin(LayerFunction):
    def __init__(self, w, b): self.w,self.b = w,b

    def forward(self, inp): return inp@self.w + self.b

    def bwd(self, out, inp):
        inp.g = out.g @ self.w.t()
        self.w.g = self.inp.t() @ self.out.g
        self.b.g = out.g.sum(0)
class Mse(LayerFunction):
    def forward (self, inp, targ): return (inp.squeeze() - targ).pow(2).mean()
    def bwd(self, out, inp, targ):
        inp.g = 2*(inp.squeeze()-targ).unsqueeze(-1) / targ.shape[0]

我们模型的其余部分可以与以前相同。这越来越接近 PyTorch 的做法。我们需要区分的每个基本函数都被写成一个torch.autograd.Function对象,它有一个forward和一个backward方法。PyTorch 将跟踪我们进行的任何计算,以便能够正确运行反向传播,除非我们将张量的requires_grad属性设置为False

编写其中一个(几乎)和编写我们原始类一样容易。不同之处在于我们选择保存什么并将其放入上下文变量中(以确保我们不保存不需要的任何内容),并在backward传递中返回梯度。很少需要编写自己的Function,但如果您需要某些奇特的东西或想要干扰常规函数的梯度,这里是如何编写的:

from torch.autograd import Function

class MyRelu(Function):
    @staticmethod
    def forward(ctx, i):
        result = i.clamp_min(0.)
        ctx.save_for_backward(i)
        return result

    @staticmethod
    def backward(ctx, grad_output):
        i, = ctx.saved_tensors
        return grad_output * (i>0).float()

用于构建利用这些Function的更复杂模型的结构是torch.nn.Module。这是所有模型的基本结构,到目前为止您看到的所有神经网络都是从该类中继承的。它主要有助于注册所有可训练的参数,正如我们已经看到的可以在训练循环中使用的那样。

要实现一个nn.Module,你只需要做以下几步:

  1. 确保在初始化时首先调用超类__init__

  2. 将模型的任何参数定义为具有nn.Parameter属性。

  3. 定义一个forward函数,返回模型的输出。

这里是一个从头开始的线性层的例子:

import torch.nn as nn

class LinearLayer(nn.Module):
    def __init__(self, n_in, n_out):
        super().__init__()
        self.weight = nn.Parameter(torch.randn(n_out, n_in) * sqrt(2/n_in))
        self.bias = nn.Parameter(torch.zeros(n_out))

    def forward(self, x): return x @ self.weight.t() + self.bias

正如您所看到的,这个类会自动跟踪已定义的参数:

lin = LinearLayer(10,2)
p1,p2 = lin.parameters()
p1.shape,p2.shape
(torch.Size([2, 10]), torch.Size([2]))

正是由于nn.Module的这个特性,我们可以只说opt.step,并让优化器循环遍历参数并更新每个参数。

请注意,在 PyTorch 中,权重存储为一个n_out x n_in矩阵,这就是为什么在前向传递中我们有转置的原因。

通过使用 PyTorch 中的线性层(也使用 Kaiming 初始化),我们在本章中一直在构建的模型可以这样编写:

class Model(nn.Module):
    def __init__(self, n_in, nh, n_out):
        super().__init__()
        self.layers = nn.Sequential(
            nn.Linear(n_in,nh), nn.ReLU(), nn.Linear(nh,n_out))
        self.loss = mse

    def forward(self, x, targ): return self.loss(self.layers(x).squeeze(), targ)

fastai 提供了自己的Module变体,与nn.Module相同,但不需要您调用super().__init__()(它会自动为您执行):

class Model(Module):
    def __init__(self, n_in, nh, n_out):
        self.layers = nn.Sequential(
            nn.Linear(n_in,nh), nn.ReLU(), nn.Linear(nh,n_out))
        self.loss = mse

    def forward(self, x, targ): return self.loss(self.layers(x).squeeze(), targ)

在第十九章中,我们将从这样一个模型开始,看看如何从头开始构建一个训练循环,并将其重构为我们在之前章节中使用的内容。

结论

在本章中,我们探讨了深度学习的基础,从矩阵乘法开始,然后实现了神经网络的前向和反向传递。然后我们重构了我们的代码,展示了 PyTorch 在底层的工作原理。

以下是一些需要记住的事项:

  • 神经网络基本上是一堆矩阵乘法,中间夹杂着非线性。

  • Python 很慢,所以为了编写快速代码,我们必须对其进行向量化,并利用诸如逐元素算术和广播等技术。

  • 如果从末尾开始向后匹配的维度相同(如果它们相同,或者其中一个是 1),则两个张量是可广播的。为了使张量可广播,我们可能需要使用unsqueezeNone索引添加大小为 1 的维度。

  • 正确初始化神经网络对于开始训练至关重要。当我们有 ReLU 非线性时,应使用 Kaiming 初始化。

  • 反向传递是应用链式法则多次计算,从我们模型的输出开始,逐层向后计算梯度。

  • 在子类化nn.Module时(如果不使用 fastai 的Module),我们必须在我们的__init__方法中调用超类__init__方法,并且我们必须定义一个接受输入并返回所需结果的forward函数。

问卷

  1. 编写 Python 代码来实现一个单个神经元。

  2. 编写实现 ReLU 的 Python 代码。

  3. 用矩阵乘法的术语编写一个密集层的 Python 代码。

  4. 用纯 Python 编写一个密集层的 Python 代码(即使用列表推导和内置到 Python 中的功能)。

  5. 一个层的“隐藏大小”是什么?

  6. 在 PyTorch 中,t方法是做什么的?

  7. 为什么在纯 Python 中编写矩阵乘法非常慢?

  8. matmul中,为什么ac==br

  9. 在 Jupyter Notebook 中,如何测量执行单个单元格所需的时间?

  10. 什么是逐元素算术?

  11. 编写 PyTorch 代码来测试 a 的每个元素是否大于 b 的对应元素。

  12. 什么是秩为 0 的张量?如何将其转换为普通的 Python 数据类型?

  13. 这返回什么,为什么?

    tensor([1,2]) + tensor([1])
    
  14. 这返回什么,为什么?

    tensor([1,2]) + tensor([1,2,3])
    
  15. 逐元素算术如何帮助我们加速 matmul

  16. 广播规则是什么?

  17. expand_as 是什么?展示一个如何使用它来匹配广播结果的示例。

  18. unsqueeze 如何帮助我们解决某些广播问题?

  19. 我们如何使用索引来执行与 unsqueeze 相同的操作?

  20. 我们如何显示张量使用的内存的实际内容?

  21. 将大小为 3 的向量添加到大小为 3×3 的矩阵时,向量的元素是添加到矩阵的每一行还是每一列?(确保通过在笔记本中运行此代码来检查您的答案。)

  22. 广播和 expand_as 会导致内存使用增加吗?为什么或为什么不?

  23. 使用爱因斯坦求和实现 matmul

  24. einsum 的左侧重复索引字母代表什么?

  25. 爱因斯坦求和符号的三条规则是什么?为什么?

  26. 神经网络的前向传播和反向传播是什么?

  27. 为什么我们需要在前向传播中存储一些计算出的中间层的激活?

  28. 具有标准差远离 1 的激活的缺点是什么?

  29. 权重初始化如何帮助避免这个问题?

  30. 初始化权重的公式是什么,以便在普通线性层和 ReLU 后跟线性层中获得标准差为 1?

  31. 为什么有时我们必须在损失函数中使用 squeeze 方法?

  32. squeeze 方法的参数是做什么的?为什么可能很重要包含这个参数,尽管 PyTorch 不需要它?

  33. 链式法则是什么?展示本章中提出的两种形式中的任意一种方程。

  34. 展示如何使用链式法则计算 mse(lin(l2, w2, b2), y) 的梯度。

  35. ReLU 的梯度是什么?用数学或代码展示它。(您不应该需要记住这个—尝试使用您对函数形状的知识来弄清楚它。)

  36. 在反向传播中,我们需要以什么顺序调用 *_grad 函数?为什么?

  37. __call__ 是什么?

  38. 编写 torch.autograd.Function 时我们必须实现哪些方法?

  39. 从头开始编写 nn.Linear 并测试其是否有效。

  40. nn.Module 和 fastai 的 Module 之间有什么区别?

进一步研究

  1. 将 ReLU 实现为 torch.autograd.Function 并用它训练模型。

  2. 如果您对数学感兴趣,请确定数学符号中线性层的梯度。将其映射到本章中的实现。

  3. 了解 PyTorch 中的 unfold 方法,并结合矩阵乘法实现自己的二维卷积函数。然后训练一个使用它的 CNN。

  4. 使用 NumPy 而不是 PyTorch 在本章中实现所有内容。

第十八章:使用 CAM 解释 CNN

原文:www.bookstack.cn/read/th-fastai-book/20b17a5a56ea9b6f.md

译者:飞龙

协议:CC BY-NC-SA 4.0

现在我们知道如何从头开始构建几乎任何东西,让我们利用这些知识来创建全新(并非常有用!)的功能:类激活图。它让我们对 CNN 为何做出预测有一些见解。

在这个过程中,我们将学习到 PyTorch 中一个我们之前没有见过的方便功能,hook,并且我们将应用本书中介绍的许多概念。如果你想真正测试你对本书材料的理解,完成本章后,尝试将其放在一边,从头开始重新创建这里的想法(不要偷看!)。

CAM 和 Hooks

类激活图(CAM)是由周博磊等人在“学习用于区分定位的深度特征”中引入的。它使用最后一个卷积层的输出(就在平均池化层之前)以及预测结果,为我们提供一个热图可视化,解释模型为何做出决定。这是一个有用的解释工具。

更准确地说,在我们最终卷积层的每个位置,我们有与最后一个线性层中一样多的滤波器。因此,我们可以计算这些激活与最终权重的点积,以便为我们特征图上的每个位置得到用于做出决定的特征的分数。

在训练模型时,我们需要一种方法来访问模型内部的激活。在 PyTorch 中,可以通过 hook 来实现。Hook 是 PyTorch 的等价于 fastai 的回调。然而,与允许您像 fastai 的 Learner 回调一样将代码注入训练循环不同,hook 允许您将代码注入前向和反向计算本身。我们可以将 hook 附加到模型的任何层,并且在计算输出(前向 hook)或反向传播(后向 hook)时执行。前向 hook 是一个接受三个参数的函数——一个模块,它的输入和输出——它可以执行任何您想要的行为。(fastai 还提供了一个方便的 HookCallback,我们这里不涉及,但看看 fastai 文档;它使使用 hook 更容易一些。)

为了说明,我们将使用我们在第一章中训练的相同的猫狗模型:

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

首先,我们将获取一张猫的图片和一批数据:

img = PILImage.create('images/chapter1_cat_example.jpg')
x, = first(dls.test_dl([img]))

对于 CAM,我们想要存储最后一个卷积层的激活。我们将我们的 hook 函数放在一个类中,这样它就有一个我们稍后可以访问的状态,并且只存储输出的副本:

class Hook():
    def hook_func(self, m, i, o): self.stored = o.detach().clone()

然后我们可以实例化一个 Hook 并将其附加到我们想要的层,即 CNN 主体的最后一层:

hook_output = Hook()
hook = learn.model[0].register_forward_hook(hook_output.hook_func)

现在我们可以获取一个批次并将其通过我们的模型:

with torch.no_grad(): output = learn.model.eval()(x)

我们可以访问我们存储的激活:

act = hook_output.stored[0]

让我们再次双重检查我们的预测:

F.softmax(output, dim=-1)
tensor([[7.3566e-07, 1.0000e+00]], device='cuda:0')

我们知道 0(对于 False)是“狗”,因为在 fastai 中类别会自动排序,但我们仍然可以通过查看 dls.vocab 来进行双重检查:

dls.vocab
(#2) [False,True]

所以,我们的模型非常确信这是一张猫的图片。

为了对我们的权重矩阵(2 乘以激活数量)与激活(批次大小乘以激活乘以行乘以列)进行点积,我们使用自定义的 einsum

x.shape
torch.Size([1, 3, 224, 224])
cam_map = torch.einsum('ck,kij->cij', learn.model[1][-1].weight, act)
cam_map.shape
torch.Size([2, 7, 7])

对于我们批次中的每个图像,对于每个类别,我们得到一个 7×7 的特征图,告诉我们激活较高和较低的位置。这将让我们看到哪些图片区域影响了模型的决策。

例如,我们可以找出哪些区域使模型决定这个动物是一只猫(请注意,由于DataLoader对输入x进行了归一化,我们需要decode,并且由于在撰写本书时,PyTorch 在索引时不保留类型,我们需要转换为TensorImage——这可能在您阅读本文时已经修复):

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');

在这种情况下,明亮黄色的区域对应于高激活,紫色区域对应于低激活。在这种情况下,我们可以看到头部和前爪是使模型决定这是一张猫的图片的两个主要区域。

完成钩子后,应该将其删除,否则可能会泄漏一些内存:

hook.remove()

这就是为什么将Hook类作为上下文管理器通常是一个好主意,当您进入时注册钩子,当您退出时删除它。上下文管理器是一个 Python 构造,在with子句中创建对象时调用__enter__,在with子句结束时调用__exit__。例如,这就是 Python 处理with open(...) as f:构造的方式,您经常会看到用于打开文件而不需要在最后显式调用close(f)

如果我们将Hook定义如下

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()

我们可以安全地这样使用它:

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?”由 Ramprasaath R. Selvaraju 等人介绍了一种变体,使用所需类的最终激活的梯度。如果您还记得一点关于反向传播的知识,最后一层输出的梯度与该层输入的梯度相对应,因为它是一个线性层。

对于更深的层,我们仍然希望梯度,但它们不再等于权重。我们必须计算它们。PyTorch 在反向传播期间为我们计算每一层的梯度,但它们不会被存储(除了requires_gradTrue的张量)。然而,我们可以在反向传播上注册一个钩子,PyTorch 将把梯度作为参数传递给它,因此我们可以在那里存储它们。为此,我们将使用一个HookBwd类,它的工作方式类似于Hook,但是拦截并存储梯度而不是激活:

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。我们的钩子拦截了我们将用作权重的梯度:

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 的权重由特征图上的梯度平均值给出。然后就像以前一样:

w = grad[0].mean(dim=[1,2], keepdim=True)
cam_map = (w * act[0]).sum(0)
_,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');

Grad-CAM 的新颖之处在于我们可以在任何层上使用它。例如,在这里我们将其用于倒数第二个 ResNet 组的输出:

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
w = grad[0].mean(dim=[1,2], keepdim=True)
cam_map = (w * act[0]).sum(0)

现在我们可以查看此层的激活图:

_,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');

结论

模型解释是一个活跃研究领域,我们只是在这一简短章节中探讨了可能性的一部分。类激活图让我们了解模型为什么预测了某个结果,它展示了图像中对于给定预测最负责的区域。这可以帮助我们分析假阳性,并找出在我们的训练中缺少了哪种数据以避免它们。

问卷调查

  1. PyTorch 中的 hook 是什么?

  2. CAM 使用哪个层的输出?

  3. 为什么 CAM 需要一个 hook?

  4. 查看ActivationStats类的源代码,看看它如何使用 hooks。

  5. 编写一个 hook,用于存储模型中给定层的激活(如果可能的话,不要偷看)。

  6. 为什么我们在获取激活之前要调用eval?为什么我们要使用no_grad

  7. 使用torch.einsum来计算模型主体最后激活的每个位置的“狗”或“猫”得分。

  8. 如何检查类别的顺序(即索引→类别的对应关系)?

  9. 为什么我们在显示输入图像时使用decode

  10. 什么是上下文管理器?需要定义哪些特殊方法来创建一个?

  11. 为什么我们不能对网络的内部层使用普通的 CAM?

  12. 为了执行 Grad-CAM,为什么我们需要在反向传播中注册一个 hook?

  13. output是每个图像每个类别的输出激活的秩为 2 的张量时,为什么我们不能调用output.backward

进一步研究

  1. 尝试移除keepdim,看看会发生什么。查阅 PyTorch 文档中的这个参数。为什么我们在这个笔记本中需要它?

  2. 创建一个类似这个的笔记本,但用于 NLP,并用它来找出电影评论中哪些词对于评估特定电影评论的情感最重要。

第十九章:从头开始创建一个 fastai 学习器

原文:www.bookstack.cn/read/th-fastai-book/5443c76c2b161687.md

译者:飞龙

协议:CC BY-NC-SA 4.0

这最后一章(除了结论和在线章节)将会有所不同。它包含的代码比以前的章节要多得多,而叙述要少得多。我们将介绍新的 Python 关键字和库,而不进行讨论。这一章的目的是为您开展一项重要的研究项目。您将看到,我们将从头开始实现 fastai 和 PyTorch API 的许多关键部分,仅建立在我们在第十七章中开发的组件上!这里的关键目标是最终拥有自己的Learner类和一些回调函数,足以训练一个模型在 Imagenette 上,包括我们学习的每个关键技术的示例。在构建Learner的过程中,我们将创建我们自己的ModuleParameter和并行DataLoader的版本,这样您就会对 PyTorch 类的功能有一个很好的了解。

本章末尾的问卷调查对于本章非常重要。这是我们将指导您探索许多有趣方向的地方,使用本章作为起点。我们建议您在计算机上跟着本章进行学习,并进行大量的实验、网络搜索和其他必要的工作,以了解发生了什么。在本书的其余部分,您已经积累了足够的技能和专业知识来做到这一点,所以我们相信您会做得很好!

让我们开始手动收集一些数据。

数据

查看untar_data的源代码,看看它是如何工作的。我们将在这里使用它来访问 Imagene 的 160 像素版本,以在本章中使用:

path = untar_data(URLs.IMAGENETTE_160)

要访问图像文件,我们可以使用get_image_files

t = get_image_files(path)
t[0]
Path('/home/jhoward/.fastai/data/imagenette2-160/val/n03417042/n03417042_3752.JP
 > EG')

或者我们可以使用 Python 的标准库glob来做同样的事情:

from glob import glob
files = L(glob(f'{path}/**/*.JPEG', recursive=True)).map(Path)
files[0]
Path('/home/jhoward/.fastai/data/imagenette2-160/val/n03417042/n03417042_3752.JP
 > EG')

如果您查看get_image_files的源代码,您会发现它使用了 Python 的os.walk;这是一个比glob更快、更灵活的函数,所以一定要尝试一下。

我们可以使用 Python Imaging Library 的Image类打开一张图片:

im = Image.open(files[0])
im

im_t = tensor(im)
im_t.shape
torch.Size([160, 213, 3])

这将成为我们独立变量的基础。对于我们的因变量,我们可以使用pathlib中的Path.parent。首先,我们需要我们的词汇表

lbls = files.map(Self.parent.name()).unique(); lbls
(#10) ['n03417042','n03445777','n03888257','n03394916','n02979186','n03000684','
 > n03425413','n01440764','n03028079','n02102040']

以及反向映射,感谢L.val2idx

v2i = lbls.val2idx(); v2i
{'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的东西:

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__

train_filt = L(o.parent.parent.name=='train' for o in files)
train,valid = files[train_filt],files[~train_filt]
len(train),len(valid)
(9469, 3925)

现在我们可以尝试一下:

train_ds,valid_ds = Dataset(train),Dataset(valid)
x,y = train_ds[0]
x.shape,y
(torch.Size([64, 64, 3]), tensor(0))
show_image(x, title=lbls[y]);

正如您所看到的,我们的数据集返回独立变量和因变量作为元组,这正是我们需要的。我们需要将这些整合成一个小批量。通常,可以使用torch.stack来完成这个任务,这就是我们将在这里使用的方法:

def collate(idxs, ds):
    xb,yb = zip(*[ds[i] for i in idxs])
    return torch.stack(xb),torch.stack(yb)

这是一个包含两个项目的小批量,用于测试我们的collate

x,y = collate([1,2], train_ds)
x.shape,y
(torch.Size([2, 64, 64, 3]), tensor([0, 0]))

现在我们有了数据集和一个整合函数,我们准备创建DataLoader。我们将在这里添加两个东西:一个可选的shuffle用于训练集,以及一个ProcessPoolExecutor来并行进行预处理。并行数据加载器非常重要,因为打开和解码 JPEG 图像是一个缓慢的过程。一个 CPU 核心不足以快速解码图像以使现代 GPU 保持繁忙。这是我们的DataLoader类:

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)

让我们尝试一下我们的训练和验证数据集:

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)
(torch.Size([128, 64, 64, 3]), torch.Size([128]), 74)

这个数据加载器的速度不比 PyTorch 的慢,但它要简单得多。因此,如果您正在调试一个复杂的数据加载过程,不要害怕尝试手动操作,以帮助您准确地了解发生了什么。

对于归一化,我们需要图像统计数据。通常,可以在一个训练小批量上计算这些数据,因为这里不需要精度:

stats = [xb.mean((0,1,2)),xb.std((0,1,2))]
stats
[tensor([0.4544, 0.4453, 0.4141]), tensor([0.2812, 0.2766, 0.2981])]

我们的Normalize类只需要存储这些统计数据并应用它们(要查看为什么需要to_device,请尝试将其注释掉,然后查看后面的笔记本中会发生什么):

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]

我们总是喜欢在笔记本中测试我们构建的一切,一旦我们构建它:

norm = Normalize(stats)
def tfm_x(x): return norm(x).permute((0,3,1,2))
t = tfm_x(x)
t.mean((0,2,3)),t.std((0,2,3))
(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中的内容。”这里有一个确切的定义:

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现在表现得就像一个张量,正如我们所希望的:

Parameter(tensor(3.))
tensor(3., requires_grad=True)

现在我们有了这个,我们可以定义Module

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的定义中:

self.params + sum([m.parameters() for m in self.children], [])

这意味着我们可以询问任何Module的参数,并且它将返回它们,包括所有子模块(递归地)。但是它是如何知道它的参数是什么的呢?这要归功于实现 Python 的特殊__setattr__方法,每当 Python 在类上设置属性时,它就会为我们调用。我们的实现包括这一行:

if isinstance(v,Parameter): self.register_parameters(v)

正如你所看到的,这是我们将新的Parameter类用作“标记”的地方——任何属于这个类的东西都会被添加到我们的params中。

Python 的__call__允许我们定义当我们的对象被视为函数时会发生什么;我们只需调用forward(这里不存在,所以子类需要添加)。在我们这样做之前,如果定义了钩子,我们将调用一个钩子。现在你可以看到 PyTorch 的钩子并没有做任何花哨的事情——它们只是调用任何已注册的钩子。

除了这些功能之外,我们的Module还提供了cudatraining属性,我们很快会用到。

现在我们可以创建我们的第一个Module,即ConvLayer

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正确工作:

l = ConvLayer(3, 4)
len(l.parameters())
2

并且我们可以调用它(这将导致forward被调用):

xbt = tfm_x(xb)
r = l(xbt)
r.shape
torch.Size([128, 4, 64, 64])

同样,我们可以实现Linear

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

测试一下是否有效:

l = Linear(4,2)
r = l(torch.ones(3,4))
r.shape
torch.Size([3, 2])

让我们也创建一个测试模块来检查,如果我们将多个参数作为属性包含,它们是否都被正确注册:

class T(Module):
    def __init__(self):
        super().__init__()
        self.c,self.l = ConvLayer(3,4),Linear(4,2)

由于我们有一个卷积层和一个线性层,每个层都有权重和偏置,我们期望总共有四个参数:

t = T()
len(t.parameters())
4

我们还应该发现,在这个类上调用cuda会将所有这些参数放在 GPU 上:

t.cuda()
t.l.w.device
device(type='cuda', index=5)

现在我们可以使用这些部分来创建一个 CNN。

简单的 CNN

正如我们所见,Sequential类使许多架构更容易实现,所以让我们创建一个:

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

class AdaptivePool(Module):
    def forward(self, x): return x.mean((2,3))

这就足够我们创建一个 CNN 了!

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)
    )

让我们看看我们的参数是否都被正确注册了:

m = simple_cnn()
len(m.parameters())
10

现在我们可以尝试添加一个钩子。请注意,我们在Module中只留了一个钩子的空间;您可以将其变成列表,或者使用类似Pipeline的东西将几个钩子作为单个函数运行:

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
0.5239089727401733 0.8776043057441711
0.43470510840415955 0.8347987532615662
0.4357188045978546 0.7621666193008423
0.46562111377716064 0.7416611313819885
torch.Size([128, 10])

我们有数据和模型。现在我们需要一个损失函数。

损失

我们已经看到如何定义“负对数似然”:

def nll(input, target): return -input[range(target.shape[0]), target].mean()

实际上,这里没有对数,因为我们使用与 PyTorch 相同的定义。这意味着我们需要将对数与 softmax 放在一起:

def log_softmax(x): return (x.exp()/(x.exp().sum(-1,keepdim=True))).log()

sm = log_softmax(r); sm[0][0]
tensor(-1.2790, grad_fn=<SelectBackward>)

将这些结合起来就得到了我们的交叉熵损失:

loss = nll(sm, yb)
loss
tensor(2.5666, grad_fn=<NegBackward>)

请注意公式

log a b = log ( a ) - log ( b )

在计算对数 softmax 时,这给出了一个简化,之前定义为(x.exp()/(x.exp().sum(-1))).log()

def log_softmax(x): return x - x.exp().sum(-1,keepdim=True).log()
sm = log_softmax(r); sm[0][0]
tensor(-1.2790, grad_fn=<SelectBackward>)

然后,有一种更稳定的计算指数和的对数的方法,称为LogSumExp技巧。这个想法是使用以下公式

log j=1 n e x j = log e a j=1 n e x j -a = a + log j=1 n e x j -a

其中ax j的最大值。

以下是相同的代码:

x = torch.rand(5)
a = x.max()
x.exp().sum().log() == a + (x-a).exp().sum().log()
tensor(True)

我们将其放入一个函数中

def logsumexp(x):
    m = x.max(-1)[0]
    return m + (x-m[:,None]).exp().sum(-1).log()

logsumexp(r)[0]
tensor(3.9784, grad_fn=<SelectBackward>)

因此我们可以将其用于我们的log_softmax函数:

def log_softmax(x): return x - x.logsumexp(-1,keepdim=True)

这与之前得到的结果相同:

sm = log_softmax(r); sm[0][0]
tensor(-1.2790, grad_fn=<SelectBackward>)

我们可以使用这些来创建交叉熵

def cross_entropy(preds, yb): return nll(log_softmax(preds), yb).mean()

现在让我们将所有这些部分组合起来创建一个学习者

学习者

我们有数据、模型和损失函数;在我们可以拟合模型之前,我们只需要另一件事,那就是优化器!这里是 SGD:

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来存储它们。我们不需要任何其他功能,只需要一个地方来存储它们并访问它们:

class DataLoaders:
    def __init__(self, *dls): self.train,self.valid = dls

dls = DataLoaders(train_dl,valid_dl)

现在我们准备创建我们的学习者类:

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
    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。这个循环

for self.epoch in range(n_epochs)

并在每个 epoch 中分别调用self.one_epoch,然后train=True,然后train=False。然后self.one_epochdls.traindls.valid中的每个批次调用self.one_batch,适当地(在将DataLoader包装在fastprogress.progress_bar之后)。最后,self.one_batch遵循我们在本书中看到的适合一个小批量的一系列步骤。

在每个步骤之前和之后,Learner调用selfself调用__call__(这是标准的 Python 功能)。__call__self.cbs中的每个回调上使用getattr(cb,name),这是 Python 的内置函数,返回具有请求名称的属性(在本例中是一个方法)。因此,例如,self('before_fit')将为每个定义了该方法的回调调用cb.before_fit()

正如您所看到的,Learner实际上只是使用了我们的标准训练循环,只是在适当的时候还调用了回调。所以让我们定义一些回调!

回调

Learner.__init__中,我们有

for cb in cbs: cb.learner = self

换句话说,每个回调都知道它是在哪个学习器中使用的。这是至关重要的,否则回调无法从学习器中获取信息,或者更改学习器中的内容。因为从学习器中获取信息是如此常见,我们通过将Callback定义为GetAttr的子类,并将默认属性定义为learner,使其更容易:

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

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之前,让我们创建一个回调来跟踪和打印进度。否则,我们将无法真正知道它是否正常工作:

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了!

cbs = [SetupLearnerCB(),TrackResults()]
learn = Learner(simple_cnn(), dls, cross_entropy, lr=0.1, cbs=cbs)
learn.fit(1)
0 True 2.1275552130636814 0.2314922378287042

0 False 1.9942575636942674 0.2991082802547771

惊人的是,我们可以用如此少的代码实现 fastai 的Learner中的所有关键思想!现在让我们添加一些学习率调度。

调度学习率

如果我们想要获得良好的结果,我们将需要一个 LR finder 和 1cycle 训练。这两个都是退火回调,也就是说,它们在训练过程中逐渐改变超参数。这是LRFinder

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中看到这个异常被捕获。(您应该自己添加和测试CancelBatchExceptionCancelEpochException等。)让我们尝试一下,将其添加到我们的回调列表中:

lrfind = LRFinder()
learn = Learner(simple_cnn(), dls, cross_entropy, lr=0.1, cbs=cbs+[lrfind])
learn.fit(2)
0 True 2.6336045582954903 0.11014890695955222

0 False 2.230653363853503 0.18318471337579617

并查看结果:

plt.plot(lrfind.lrs[:-2],lrfind.losses[:-2])
plt.xscale('log')

现在我们可以定义我们的OneCycle训练回调:

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:

onecyc = OneCycle(0.1)
learn = Learner(simple_cnn(), dls, cross_entropy, lr=0.1, cbs=cbs+[onecyc])

让我们适应一段时间,看看它的样子(我们不会在书中展示所有输出——在笔记本中尝试以查看结果):

learn.fit(8)

最后,我们将检查学习率是否遵循我们定义的调度(如您所见,我们这里没有使用余弦退火):

plt.plot(onecyc.lrs);

结论

我们已经通过在本章中重新实现它们来探索 fastai 库的关键概念。由于这本书大部分内容都是代码,您应该尝试通过查看书籍网站上相应的笔记本来进行实验。现在您已经了解了它是如何构建的,作为下一步,请务必查看 fastai 文档中的中级和高级教程,以了解如何自定义库的每一个部分。

问卷调查

实验

对于这里要求您解释函数或类是什么的问题,您还应该完成自己的代码实验。

  1. 什么是glob

  2. 如何使用 Python 图像处理库打开图像?

  3. L.map是做什么的?

  4. Self是做什么的?

  5. 什么是L.val2idx

  6. 您需要实现哪些方法来创建自己的Dataset

  7. 当我们从 Imagenette 打开图像时为什么要调用convert

  8. ~是做什么的?它如何用于拆分训练和验证集?

  9. ~是否适用于LTensor类?NumPy 数组、Python 列表或 Pandas DataFrames 呢?

  10. 什么是ProcessPoolExecutor

  11. L.range(self.ds)是如何工作的?

  12. __iter__是什么?

  13. 什么是first

  14. permute是什么?为什么需要它?

  15. 什么是递归函数?它如何帮助我们定义parameters方法?

  16. 编写一个递归函数,返回斐波那契数列的前 20 个项目。

  17. 什么是super

  18. 为什么Module的子类需要重写forward而不是定义__call__

  19. ConvLayer中,为什么init取决于act

  20. 为什么Sequential需要调用register_modules

  21. 编写一个打印每个层激活形状的钩子。

  22. 什么是 LogSumExp?

  23. 为什么log_softmax有用?

  24. 什么是GetAttr?它如何帮助回调?

  25. 重新实现本章中的一个回调,而不继承自CallbackGetAttr

  26. Learner.__call__是做什么的?

  27. 什么是getattr?(注意与GetAttr的大小写区别!)

  28. fit中为什么有一个try块?

  29. 为什么在one_batch中检查model.training

  30. 什么是store_attr

  31. TrackResults.before_epoch的目的是什么?

  32. model.cuda是做什么的?它是如何工作的?

  33. 为什么我们需要在LRFinderOneCycle中检查model.training

  34. OneCycle中使用余弦退火。

进一步研究

  1. 从头开始编写resnet18(如有需要,请参考第十四章),并在本章中使用Learner进行训练。

  2. 从头开始实现一个批归一化层,并在您的resnet18中使用它。

  3. 为本章编写一个 Mixup 回调。

  4. 向 SGD 添加动量。

  5. 从 fastai(或任何其他库)中挑选几个您感兴趣的特性,并使用本章中创建的对象实现它们。

  6. 选择一篇尚未在 fastai 或 PyTorch 中实现的研究论文,并使用本章中创建的对象进行实现。然后:

    • 将论文移植到 fastai。

    • 向 fastai 提交拉取请求,或创建自己的扩展模块并发布。

    提示:您可能会发现使用nbdev来创建和部署您的软件包很有帮助。

第二十章:总结思考

原文:www.bookstack.cn/read/th-fastai-book/cedc7ab42349d210.md

译者:飞龙

协议:CC BY-NC-SA 4.0

恭喜!你成功了!如果你已经完成了到这一点的所有笔记本,你已经加入了一个小但不断增长的人群,他们能够利用深度学习的力量解决实际问题。你可能不会觉得这样—事实上,你可能不会。我们一再看到完成 fast.ai 课程的学生明显低估了自己作为深度学习从业者的效力。我们也看到这些人经常被具有传统学术背景的其他人低估。所以如果你要超越自己的期望和他人的期望,那么在关闭这本书后,你接下来要做的事情比你到目前为止所做的更重要。

最重要的是保持动力。事实上,从你对优化器的研究中知道,动量是可以自我增强的!所以想想你现在可以做些什么来维持和加速你的深度学习之旅。图 20-1 可以给你一些想法。

接下来要做什么

图 20-1。接下来要做什么

在这本书中,我们谈了很多关于写作的价值,无论是代码还是散文。但也许你到目前为止还没有写得像你希望的那样多。没关系!现在是一个扭转局面的好机会。此时你有很多话要说。也许你已经在一个数据集上尝试了一些实验,其他人似乎没有以同样的方式看待。告诉世界!或者你可能正在考虑尝试一些在阅读时想到的想法——现在是把这些想法转化为代码的好时机。

如果你想分享你的想法,一个相对低调的地方是fast.ai 论坛。你会发现那里的社区非常支持和乐于助人,所以请过来告诉我们你在做什么。或者看看你是否可以回答一些问题,帮助那些在学习早期的人。

如果你在深度学习之旅中取得了一些成功,无论大小,请务必告诉我们!在论坛上发布这些信息尤其有帮助,因为了解其他学生的成功可以极大地激励人们。

对许多人来说,保持与学习之旅的联系最重要的方法之一是围绕它建立一个社区。例如,你可以尝试在你当地社区设立一个小型深度学习聚会,或者一个学习小组,甚至可以在当地聚会上做一个关于你到目前为止学到的内容或者你感兴趣的某个特定方面的演讲。你现在还不是世界领先的专家也没关系——重要的是要记住你现在知道很多其他人不知道的东西,所以他们很可能会欣赏你的观点。

另一个许多人发现有用的社区活动是定期的读书俱乐部或论文阅读俱乐部。你可能已经在你的社区找到了一些,如果没有,你可以尝试开始一个。即使只有另一个人和你一起做,也会帮助你获得支持和鼓励,让你开始行动起来。

如果你不在一个容易与志同道合的人聚在一起的地方,可以去论坛,因为人们总是在组建虚拟学习小组。这些通常涉及一群人每周一次通过视频聊天讨论一个深度学习主题。

希望到这一点,你已经有了一些小项目和实验。我们建议你的下一步是选择其中一个,并尽可能把它做得更好。真正把它打磨成你能做到的最好作品——一件让你真正自豪的作品。这将迫使你更深入地了解一个主题,测试你的理解,并让你看到当你全力以赴时你能做到什么。

此外,您可能想看一下fast.ai 免费在线课程,它涵盖了与本书相同的内容。有时,以两种方式看同样的材料确实有助于澄清思路。事实上,人类学习研究人员发现,学习材料的最佳方式之一是从不同角度看同一件事,用不同的方式描述。

如果您选择接受最后的任务,那就是把这本书送给您认识的某人,并帮助另一个人开始他们自己的深度学习之旅!

附录 A. 创建博客

原文:www.bookstack.cn/read/th-fastai-book/a54eca534010f193.md

译者:飞龙

协议:CC BY-NC-SA 4.0

在第二章中,我们建议您可能希望尝试博客作为帮助消化您正在阅读和练习的信息的一种方式。但是如果您还没有博客呢?应该使用哪个平台呢?

不幸的是,当涉及博客时,似乎您必须做出一个困难的决定:要么使用一个使事情变得容易但会让您和读者受到广告、付费墙和费用的平台,要么花费数小时设置自己的托管服务并花费数周学习各种复杂的细节。也许“自己动手”方法最大的好处是,您真正拥有自己的帖子,而不是受制于服务提供商及其关于如何在未来变现您的内容的决定。

然而,事实证明,您可以兼得两全!

使用 GitHub Pages 进行博客

一个很好的解决方案是在一个名为GitHub Pages的平台上托管您的博客,这是免费的,没有广告或付费墙,并以标准方式使您的数据可用,以便您随时将您的博客迁移到另一个主机。但是,我们看到的所有使用 GitHub Pages 的方法都需要了解命令行和只有软件开发人员可能熟悉的神秘工具。例如,GitHub 的官方文档中关于设置博客的说明包括安装 Ruby 编程语言,使用git命令行工具,复制版本号等一长串指令,总共有 17 个步骤!

为了减少麻烦,我们创建了一种简单的方法,允许您使用完全基于浏览器的界面来满足您所有的博客需求。您将在大约五分钟内启动并运行您的新博客。这不需要任何费用,如果您愿意,您可以轻松添加自己的自定义域名。在本节中,我们将解释如何做到这一点,使用我们创建的名为fast_template的模板。(注:请务必查看书籍网站以获取最新的博客推荐,因为新工具总是不断涌现。)

创建存储库

您需要一个 GitHub 账户,所以现在前往那里并创建一个账户(如果您还没有)。通常,GitHub 是由软件开发人员用于编写代码的,他们使用一个复杂的命令行工具来处理它,但我们将向您展示一种根本不使用命令行的方法!

要开始,请将浏览器指向https://github.com/fastai/fast_template/generate(确保您已登录)。这将允许您创建一个存储博客的地方,称为存储库。您将看到一个类似于图 A-1 的屏幕。请注意,您必须使用此处显示的确切格式输入您的存储库名称,即您的 GitHub 用户名后跟.github.io

创建新存储库的 GitHub 页面的屏幕截图

图 A-1. 创建您的存储库

一旦您输入了这些内容,以及您喜欢的任何描述,点击“从模板创建存储库”。您可以选择将存储库设为“私有”,但由于您正在创建一个希望其他人阅读的博客,希望公开可用的基础文件对您来说不会成为问题。

现在,让我们设置您的主页!

设置您的主页

当读者访问您的博客时,他们将首先看到一个名为 index.md 的文件的内容。这是一个 Markdown 文件。Markdown 是一种强大而简单的创建格式化文本的方式,比如项目符号、斜体、超链接等等。它被广泛使用,包括 Jupyter 笔记本中的所有格式,几乎 GitHub 网站的每个部分,以及互联网上的许多其他地方。要创建 Markdown 文本,您只需输入普通英语,然后添加一些特殊字符以添加特殊行为。例如,如果您在单词或短语前后输入 * 字符,那将使其变为 斜体。现在让我们试一试。

要打开文件,请在 GitHub 中点击其文件名。要编辑它,请点击屏幕最右侧的铅笔图标,如 图 A-2 所示。

显示点击编辑文件的位置的截图

图 A-2. 编辑此文件

您可以添加、编辑或替换您看到的文本。点击“预览更改”按钮(图 A-3)查看您的 Markdown 文本在博客中的样子。您添加或更改的行将在左侧显示为绿色条。

显示点击预览更改的位置的截图

图 A-3. 预览更改以捕捉任何错误

要保存更改,请滚动到页面底部,然后点击“提交更改”,如 图 A-4 所示。在 GitHub 上,提交 意味着将其保存到 GitHub 服务器。

显示点击提交更改的位置的截图

图 A-4. 提交更改以保存它们

接下来,您应该配置您的博客设置。要这样做,请点击名为 _config.yml 的文件,然后点击编辑按钮,就像您为 index 文件所做的那样。更改标题、描述和 GitHub 用户名值(参见 图 A-5)。您需要保留冒号前的名称不变,并在冒号后(和每行的空格后)输入您的新值。如果愿意,您还可以添加您的电子邮件地址和 Twitter 用户名,但请注意,如果在此处填写它们,它们将出现在您的公共博客上。

显示配置文件和如何填写的截图

图 A-5. 填写配置文件

完成后,像处理 index 文件一样提交您的更改;然后等待一两分钟,让 GitHub 处理您的新博客。将您的网络浏览器指向 .github.io(将 替换为您的 GitHub 用户名)。您应该看到您的博客,它看起来类似于 图 A-6。

显示网站用户名.github.io 的截图

图 A-6. 您的博客已上线!

创建文章

现在您已经准备好创建您的第一篇文章了。您所有的文章都将放在 _posts 文件夹中。现在点击它,然后点击“创建文件”按钮。您需要小心使用格式 ---.md 命名您的文件,就像 图 A-7 中所示,其中 是一个四位数, 是两位数。 可以是任何您想要的,可以帮助您记住这篇文章是关于什么的。.md 扩展名是用于 Markdown 文档的。

显示创建新博客文章的正确语法的截图

图 A-7. 命名您的文章

然后,您可以输入第一篇文章的内容。唯一的规则是您的文章的第一行必须是一个 Markdown 标题。这是通过在行的开头放置 # 创建的,如 图 A-8 中所示(这将创建一个一级标题,在文档开头只需使用一次;您可以使用 ## 创建二级标题,使用 ### 创建三级标题,依此类推)。

显示博客文章开头的截图

图 A-8. 标题的 Markdown 语法

和之前一样,你可以点击预览按钮查看你的 Markdown 格式会是什么样子(图 A-9)。

显示用 HTML 解释的相同博客文章的截图

图 A-9. 之前的 Markdown 语法在你的博客上会是什么样子

你需要点击“提交新文件”按钮将其保存到 GitHub,如图 A-10 所示。

显示点击提交新文件的位置的截图

图 A-10. 提交更改以保存它们

再次查看你的博客首页,你会看到这篇文章现在已经出现了——图 A-11 展示了我们刚刚添加的示例文章的结果。请记住,你需要等待一分钟左右让 GitHub 处理请求,然后文件才会显示出来。

显示博客网站上第一篇文章的截图

图 A-11. 你的第一篇文章已发布!

你可能已经注意到我们提供了一个示例博客文章,你现在可以继续删除。像之前一样,转到你的_posts文件夹,点击2020-01-14-welcome.md。然后点击最右边的垃圾桶图标,如图 A-12 所示。

显示如何删除模拟文章的截图

图 A-12. 删除示例博客文章

在 GitHub 中,直到你提交,什么都不会真正改变——包括删除文件!所以,在你点击垃圾桶图标后,滚动到页面底部并提交你的更改。

你可以通过添加一行 Markdown 来在你的帖子中包含图片,就像下面这样:

![Image description](https://github.com/OpenDocCN/ibooker-dl-zh/raw/master/docs/dl-cdr-fai-pt/img/filename.jpg)

为了使这个工作起效,你需要将图片放在你的images文件夹中。要做到这一点,点击images文件夹,然后点击“上传文件”按钮(图 A-13)。

显示如何上传新文件的截图

图 A-13. 从你的计算机上传文件

现在让我们看看如何直接从你的计算机上完成所有这些操作。

同步 GitHub 和你的计算机

有很多原因你可能想要将你的博客内容从 GitHub 复制到你的计算机上——你可能想要能够离线阅读或编辑你的帖子,或者也许你想要备份以防发生什么事情影响了你的 GitHub 存储库。

GitHub 不仅让你将存储库复制到你的计算机上;它还让你同步它与你的计算机。这意味着你可以在 GitHub 上进行更改,它们会复制到你的计算机上;你也可以在你的计算机上进行更改,它们会复制到 GitHub 上。你甚至可以让其他人访问和修改你的博客,他们的更改和你的更改将在下次同步时自动合并。

为了使这个工作起效,你需要在你的计算机上安装一个叫做GitHub 桌面的应用程序。它可以在 Mac、Windows 和 Linux 上运行。按照安装说明进行安装,当你运行它时,它会要求你登录 GitHub 并选择要同步的存储库。点击“从互联网克隆存储库”,如图 A-14 所示。

显示如何克隆你的存储库的截图

图 A-14. 在 GitHub 桌面上克隆你的存储库

一旦 GitHub 完成同步你的存储库,你就可以点击“在资源管理器中查看存储库文件”(或 Finder),如图 A-15 所示,你将看到你博客的本地副本!尝试在你的计算机上编辑其中一个文件。然后返回到 GitHub 桌面,你会看到同步按钮等待你按下。当你点击它时,你的更改将被复制到 GitHub,你将在网站上看到它们的反映。

显示克隆存储库的截图

图 A-15. 本地查看你的文件

如果你以前没有使用过git,GitHub 桌面是一个很好的开始方式。正如你将会发现的,它是大多数数据科学家使用的基本工具。我们希望你现在也喜欢的另一个工具是 Jupyter Notebook——你也可以直接用它来写博客!

博客的 Jupyter

您还可以使用 Jupyter 笔记本撰写博客文章。您的 Markdown 单元格、代码单元格和所有输出都将出现在您导出的博客文章中。在您阅读本书时,最佳方法可能已经发生了变化,因此请查看书籍网站获取最新信息。截至目前,从笔记本创建博客的最简单方法是使用fastpages,这是fast_template的更高级版本。

要使用笔记本撰写博客,只需将其放入您的博客存储库中的_notebooks文件夹中,它将出现在您的博客文章列表中。在撰写笔记本时,写下您希望观众看到的内容。由于大多数写作平台很难包含代码和输出,因此我们许多人习惯于包含比应该更少的真实示例。这是一个很好的方法,可以养成在写作时包含大量示例的习惯。

通常,您会希望隐藏导入语句等样板代码。您可以在任何单元格顶部添加#hide,使其不显示在输出中。Jupyter 显示单元格的最后一行的结果,因此不需要包含print。(包含不需要的额外代码会增加读者的认知负担;因此不要包含您实际不需要的代码!)

附录 B. 数据项目清单

原文:www.bookstack.cn/read/th-fastai-book/b8ffc03c29b9fdcd.md

译者:飞龙

协议:CC BY-NC-SA 4.0

创建有用的数据项目远不止培训准确的模型!当 Jeremy 做咨询时,他总是试图了解组织开发数据项目的背景,基于以下考虑,总结在图 B-1 中:

战略

组织试图做什么(目标),以及如何更好地做到(杠杆)?

数据

组织是否捕获必要的数据并使其可用?

分析

组织需要哪些见解?

实施

组织有哪些能力?

维护

有哪些系统用于跟踪运营环境的变化?

约束

在前述各个领域中需要考虑哪些约束条件?

分析价值链

图 B-1. 分析价值链

他开发了一个问卷,让客户在项目开始之前填写,然后在整个项目过程中,他会帮助他们完善答案。这份问卷基于数十年来在许多行业中进行的项目,包括农业、采矿、银行、啤酒酿造、电信、零售等。

在我们深入分析价值链之前,问卷的第一部分涉及数据项目中最重要的员工:数据科学家。

数据科学家

数据科学家应该有明确的晋升路径,同时应该有招聘计划,直接将数据专家引入高级管理职位。在一个数据驱动的组织中,数据科学家应该是薪酬最高的员工之一。应该建立系统,让整个组织的数据科学家合作并互相学习。

  • 组织目前拥有哪些数据科学技能?

  • 数据科学家是如何招聘的?

  • 如何在组织内部识别具有数据科学技能的人?

  • 正在寻找哪些技能?如何评判这些技能?这些技能是如何被选定为重要的?

  • 正在使用哪种数据科学咨询?在哪些情况下数据科学被外包?这项工作如何转移到组织中?

  • 数据科学家的薪酬是多少?他们向谁汇报?他们的技能如何保持更新?

  • 数据科学家的职业发展路径是什么?

  • 有多少高管具有较强的数据分析专业知识?

  • 数据科学家的工作是如何选择和分配的?

  • 数据科学家有哪些软件和硬件可以访问?

战略

所有数据项目都应基于解决战略重要的问题。因此,首先必须先了解业务战略。

  • 组织今天最重要的五个战略问题是什么?

  • 有哪些数据可用于帮助解决这些问题?

  • 这些问题是否采用数据驱动方法?数据科学家是否在处理这些问题?

  • 组织可以最大程度影响哪些利润驱动因素?(见图 B-2。)

组织中可能是重要利润驱动因素的因素

图 B-2. 组织中可能是重要利润驱动因素的因素
  • 对于确定的每个关键利润驱动因素,组织可以采取哪些具体行动和决策来影响该驱动因素,包括运营行动(例如,致电客户)和战略决策(例如,发布新产品)?

  • 对于最重要的行动和决策,可能有哪些数据可用(无论是组织内部的,还是来自供应商,或者未来可能收集的数据),可以帮助优化结果?

  • 根据前述分析,组织内哪些是数据驱动分析的最大机会?

  • 对于每个机会:

    • 它旨在影响哪个价值驱动因素?

    • 它将推动哪些具体行动或决策?

    • 这些行动和决策将如何与项目结果联系起来?

    • 项目的预期投资回报率是多少?

    • 有哪些时间限制和截止日期可能会对此产生影响?

数据

没有数据,我们无法训练模型!数据还需要是可用的、集成的和可验证的。

  • 组织有哪些数据平台?这些可能包括数据仓库、OLAP 立方体、数据仓库、Hadoop 集群、OLTP 系统、部门电子表格等。

  • 提供已收集的任何提供组织数据可用性概述的信息,以及当前工作和未来建立数据平台的计划。

  • 有哪些工具和流程可用于在系统和格式之间移动数据?

  • 数据来源如何被不同用户和管理员访问?

  • 组织的数据科学家和系统管理员可以使用哪些数据访问工具(例如数据库客户端、OLAP 客户端、内部软件、SAS)?每种工具有多少人使用,他们在组织中的职位是什么?

  • 用户如何被告知新系统、系统变更、新的和变更的数据元素等信息?提供例子。

  • 关于数据访问限制,如何做出决策?如何管理访问受限数据的请求?由谁管理?基于什么标准?平均响应时间是多长?有多少请求被接受?如何跟踪?

  • 组织如何决定何时收集额外数据或购买外部数据?提供例子。

  • 到目前为止,用于分析最近数据驱动项目的数据是什么?发现什么是最有用的?什么是无用的?这是如何评判的?

  • 提供哪些额外的内部数据可能为拟议项目的数据驱动决策提供有用的见解?外部数据呢?

  • 访问或整合这些数据可能存在哪些潜在的约束或挑战?

  • 在过去两年中,数据收集、编码、集成等方面发生了哪些变化,可能会影响到收集到的数据的解释或可用性?

分析

数据科学家需要能够访问适合其特定需求的最新工具。应定期评估新工具,看它们是否比当前方法有显著改进。

  • 组织使用哪些分析工具?由谁使用?它们是如何选择、配置和维护的?

  • 如何在客户机器上设置额外的分析工具?完成这个过程的平均时间是多久?有多少请求被接受?

  • 外部顾问建立的分析系统是如何转移到组织中的?是否要求外部承包商限制使用的系统,以确保结果符合内部基础设施?

  • 在什么情况下使用了云处理?使用云的计划是什么?

  • 在什么情况下使用外部专家进行专业分析?这是如何管理的?专家是如何被识别和选择的?

  • 最近项目尝试过哪些分析工具?

  • 什么有效,什么无效?为什么?

  • 提供迄今为止为这些项目所做工作的任何输出。

  • 这项分析的结果如何评判?使用了哪些指标?与哪些基准进行比较?如何知道一个模型是否“足够好”?

  • 在什么情况下,组织使用可视化,而不是表格报告,而不是预测建模(以及类似的机器学习工具)?对于更高级的建模方法,模型是如何校准和测试的?提供例子。

实施

IT 约束往往是数据项目的失败原因。要提前考虑!

  • 提供一些过去数据驱动项目的成功和失败实施的例子,并提供有关 IT 集成和人力资本挑战的详细信息,以及如何应对这些挑战。

  • 在实施之前,如何确认分析模型的有效性?它们是如何进行基准测试的?

  • 如何定义分析项目实施的性能要求(速度和准确性方面)?

  • 对于拟议的项目,提供以下信息:

    • 用于支持数据驱动决策和行动的 IT 系统是什么

    • 这种 IT 集成将如何完成

    • 有哪些可用的替代方案可能需要较少的 IT 集成

    • 哪些工作将受到数据驱动方法的影响

    • 这些员工将如何接受培训、监督和支持

    • 可能出现的实施挑战是什么

    • 哪些利益相关者将需要确保实施成功,以及他们可能如何看待这些项目及其潜在影响

维护

除非仔细跟踪模型,否则可能会发现它们导致灾难。

  • 第三方构建的分析系统如何维护?何时将其转移到内部团队?

  • 如何跟踪模型的有效性?组织何时决定重建模型?

  • 数据变更如何在内部传达,以及如何管理?

  • 数据科学家如何与软件工程师合作,以确保算法正确实施?

  • 测试用例是何时开发的,如何维护?

  • 何时对代码进行重构?在重构过程中如何维护和验证模型的正确性和性能?

  • 维护和支持需求如何记录?这些日志如何使用?

约束

对于每个正在考虑的项目,列举可能影响项目成功的潜在约束。

  • IT 系统是否需要修改或开发以使用项目的结果?是否有可以避免重大 IT 变更的更简单的实现方式?如果有,使用简化实现方式将如何显著减少影响?

  • 数据收集、分析或实施上存在哪些监管约束?最近是否已经审查了相关立法和先例?可能存在哪些变通方法?

  • 存在哪些组织约束,包括文化、技能或结构?

  • 存在哪些管理约束?

  • 是否有过去的分析项目可能会影响组织对数据驱动方法的看法?

posted @ 2025-11-22 09:01  绝不原创的飞龙  阅读(14)  评论(0)    收藏  举报