深度学习模式与实践指南-全-

深度学习模式与实践指南(全)

原文:Deep Learning Patterns and Practices

译者:飞龙

协议:CC BY-NC-SA 4.0

前置内容

前言

作为一名谷歌员工,我的职责之一是教育软件工程师如何使用机器学习。我已经有了创建在线教程、聚会、会议演示、培训研讨会和为私人编码学校和大学研究生课程编写课程的经验,但我总是寻找新的有效教学方法。

在谷歌之前,我在日本 IT 行业担任了 20 年的首席研究科学家——这一切都没有涉及深度学习。今天我所看到的一切,我们 15 年前在创新实验室里就已经在做;区别在于我们需要一个满屋子的科学家和庞大的预算。深度学习带来的变化如此之快,真是令人难以置信。

回到 2000 年代末,我正在使用来自世界各地国家和国际来源的小型结构化数据集和地理空间数据。同事们称我为数据科学家,但没有人知道数据科学家究竟是什么。然后出现了大数据,我不了解大数据工具和框架,突然间我不再是数据科学家。什么?我不得不匆忙学习大数据背后的工具和概念,再次成为数据科学家。

随后出现了在大数据集上的机器学习,如线性/逻辑回归和 CART 分析,而我自从几十年前研究生毕业以来就没有使用过统计学,再次我不是数据科学家。什么?我不得不再次匆忙学习统计学,再次成为数据科学家。然后出现了深度学习,我不了解神经网络的理论和框架,突然间我不再是数据科学家。什么?我再次匆忙学习深度学习理论和其他深度学习框架。再次,我成为数据科学家。

致谢

我想感谢所有在 Manning 出版社帮助我完成这个过程的人。Frances Lefkowitz,我的开发编辑;Deirdre Hiam,我的项目编辑;Sharon Wilkey,我的校对编辑;Keri Hales,我的校对员;以及 Aleksandar Dragosavljević,我的审稿编辑。

向所有审稿人表示感谢:Ariel Gamino、Arne Peter Raulf、Barry Siegel、Brian R. Gaines、Christopher Marshall、Curtis Bates、Eros Pedrini、Hilde Van Gysel、Ishan Khurana、Jen Lee、Karthikeyarajan Rajendran、Michael Kareev、Muhammad Sohaib Arif、Nick Vazquez、Ninoslav Cerkez、Oliver Korten、Piyush Mehta、Richard Tobias、Romit Singhai、Sayak Paul、Sergio Govoni、Simone Sguazza、Udendran Mudaliyar、Vishwesh Ravi Shrimali 和 Viton Vitanis,你们的建议帮助这本书变得更好。

向所有分享个人和客户见解的谷歌云 AI 团队表示感谢,你们的见解帮助这本书覆盖了更广泛的受众。

关于这本书

应该阅读这本书的人

欢迎来到我的最新尝试,深度学习模式与实践。这本书是为软件工程师、机器学习工程师以及初级、中级和高级数据科学家所写。尽管你可能认为对于后者来说,最初章节可能有些冗余,但我的独特方法可能会让你获得额外的洞察和受欢迎的复习。本书的结构使得每位读者都能达到“点燃”的点,并能够自我推动进入深度学习。

我主要在计算机视觉的背景下教授设计模式和惯例,因为深度学习的设计模式最初就是在这里演化的。自然语言理解和结构化数据模型的发展落后,并且继续专注于经典方法。但随着它们赶上,这些领域发展了自己的深度学习设计模式,我在本书中也讨论了这些模式和惯例。

尽管我为计算机视觉模型提供了代码,但我强调的是这些方法和创新背后的概念:它们是如何设置的以及为什么这样设置。这些基本概念适用于自然语言处理、结构化数据、信号处理和其他领域,通过概括,你应该能够将概念、方法和技术适应你领域中的问题。我讨论的许多模型和技术都是领域无关的,在本书中,我也讨论了自然语言处理、自然语言理解和结构化数据领域中的关键创新,在适当的地方。

至于一般背景,你应该至少了解 Python 的基础知识。如果你还在为理解什么是可理解性或什么是生成器而感到困惑,或者如果你对奇怪的多元数组切片以及堆上哪些对象是可变的和不可变的还有一些困惑,那都是可以的。对于这本书来说,这些都是可以的。

对于那些想要成为机器学习工程师的软件工程师——那意味着什么?机器学习工程师(MLE)是应用工程师。你不需要知道统计学(真的,你不需要!),你也不需要知道计算理论。如果你在大学微积分课上对导数是什么感到困倦,那没关系,如果有人让你做矩阵乘法,你可以自由地问,“为什么?”

你的任务是学习框架的旋钮和杠杆,并将你的技能和经验应用于解决现实世界的问题。这正是我将帮助你做到的,也是 TF.Keras 中使用的模式设计所关注的。

本书是为处于相当水平的机器学习工程师和数据科学家设计的。对于那些追求数据科学家路线的人,我鼓励你学习补充的与统计学相关的材料。

在我们开始之前,我想解释你将如何学习,所以这个第一部分更多地解释了我的教学哲学和方法。然后我们将回顾一些基础知识,包括术语、从经典或语义 AI 到窄或统计 AI 的演变,以及机器学习的基本步骤概述。最后,我们将从高层次的角度看看这本书的内容:机器学习的现代模型融合方法。

我不使用传统的西方死记硬背的方法,重复,重复,测试正确答案,然后垂直提升。除了我认为这种方法效果较差之外,我还认为它无意中具有歧视性。

相反,我有幸在多元文化和教学方法中教授工程和科学,并发展了一种独特的教学方法,我称之为“横向方法”:我从核心概念开始,然后通过我所说的“抽象”螺旋式扩展。当有问题被问起时,我会在反思他们的想法之前,逐渐开始指向其他学生的想法,指向答案。我不进行测验,学生试图得到 100%。相反,我给学生布置作业,每个学生都会失败。我让学生不断打击问题,挣扎,在这个过程中,他们将会开始发现他们需要学习的潜在原理。例如,我可能会布置一个作业,要求使用标准的 ResNet50 模型训练 CIFAR-10 数据集,并指出相应的 ResNet 论文的作者在 CIFAR-10 上实现了 97%的准确率。每个学生都会失败,模型不会收敛,他们不会超过 70%,等等。

然后,我将学生组成团队一起解决问题。通过相互讨论,他们学会了一起概括。在他们能够理解之前,我会进行跳跃,给学生提出另一个难以解决的问题——然后这个过程再次开始。我从不给学生死记硬背的机会。

以我的例子来说,我可能在黑板上写下四种可能的解决方案,比如 1)图像增强,2)更多的正则化,3)更多的超参数搜索,4)延迟下采样到神经网络深处(这是正确答案)。然后在中途,我会停止学生,让每个团队陈述他们尝试的解决方案以及到目前为止学到了什么。然后我会解释每个解决方案为什么可行/不可行,然后再次改变问题。

当学生进步到更高级的水平时,我从教师角色转变为所谓的师生角色,并参与学习。学生们在教我,也互相教对方,就像我在教他们一样。我观察每个学生,寻找我所说的“点燃”——学生作为学习者自我推动的时刻,那就是学生持续学习的时候。我发现我的教学方法中,整个课堂一起进步,没有学生被落下。

不时地,学校管理员会参加我的某个课程来观察。他们会听到学生们的低语,并想观察它是如何运作的。当然,管理员需要给每件事都起个名字。在一所私立编码学校,管理员将其描述为“每个人都是学习者”。学生们从老师那里学习,老师从学生那里学习,学生们也从彼此那里学习。管理员称之为“让我们共同学习”。

我为自己的教学方法取了一个名字,我称之为“我相信自己”。我告诉我的学生,如果你自己(学生)首先不相信自己,你怎么能相信我(老师)呢?

本书是如何组织的:路线图

本书分为三个部分:基础知识、设计模式和用于生产训练和部署的设计模式。

第一部分“深度学习基础知识”为读者提供了对深度学习的复习,包括卷积神经网络简介,以及讨论了今天所有领域——计算机视觉、自然语言处理和结构化数据——的主流概念和术语。

模型设计模式在第二部分“基本设计模式”中呈现。在第五章到第七章中,我介绍了现代设计模式,以及它们如何应用于许多当前和以前最先进的深度学习模型。我涵盖了过程性重用设计模式,这是手工制作模型的主要方法。我教授了研究人员发现的设计方法、改进以及大模型在层中更深入(第五章)、更宽(第六章)以及使用替代或现成连接模式(第七章)的优缺点。

第五章探讨了卷积神经网络的过程性设计模式,以及残差块在自然语言理解中的注意力机制中的身份链接开发。

第六章扩展了卷积神经网络的过程性设计模式,以及研究人员如何探索在层中变宽作为深入的一种替代方案。我展示了这种方法,如 ResNeXt,与深度层相比,在减少对记忆和消失梯度的暴露时实现了比较高的准确性。我还探讨了宽卷积神经网络与宽度和深度以及结构化数据中的 TabNet 模型的发展的相关性。

第七章涵盖了模型设计模式,这些模式探索了其他替代层连接,以在层中更深入或更广泛地增加准确性,减少参数数量,并在模型中间潜在空间中增加信息增益。

第八章探讨了移动卷积神经网络独特的考虑因素和特殊约束。由于这些设备的内存限制,必须在大小和准确性之间进行权衡。我将讲解这些权衡的进展、优缺点,以及移动网络的设计如何与大型模型对应物不同,以适应这些权衡。

第九章介绍了用于无监督学习的自动编码器。作为独立模型,自动编码器的实际应用范围非常有限。但自动编码器的发现对预训练模型的进步做出了贡献。这些模型更好地泛化到分布外的服务——即,在生产部署的模型中,预测请求的分布与模型训练的数据不同。我还探讨了自动编码器在自然语言理解中与嵌入的相似性。

本书第二部分的所有模型都对深度学习的研发做出了开创性的贡献,并且至今仍在使用,或者它们的贡献已被纳入今天的模型中。

第三部分,“与管道协同工作”,探讨了生产管道的设计模式和最佳实践。在第十章中,我们探讨了超参数调整,包括手动和自动调整。我讲解了指定搜索空间的设计决策、优缺点以及最佳实践,以及搜索该搜索空间的模式。

第十一章讨论了迁移学习,并介绍了处理权重迁移和调整相似和远程任务的概念和方法。我还探讨了在从头开始完全训练的模型中,预训练期间权重重用的应用。

第十二章至第十四章从高层次上审视了生产管道。我们在第十二章和第十三章深入探讨了数据端。第十二章介绍了数据分布,是唯一一个详细讨论统计学的章节。自 2017 年以来,统计学领域发生了很大变化,当时人们期望拥有博士级别的统计学知识。如今,这些知识的大部分都隐藏在深度学习框架(如 TensorFlow)中,或者被自动化。理解数据分布和搜索空间仍然是统计学中预期知识的主要领域之一,并且可以显著影响训练成本以及模型在生产部署时的泛化能力。

最后,第十三章和第十四章从数据端转向部署端。我介绍了构建数据端和训练端的概念和最佳实践。

关于代码

本书包含许多源代码示例,无论是编号列表还是与普通文本并列。在这两种情况下,源代码都格式化为 fixed-width font like this 以便与普通文本区分。有时代码也会被加粗以突出显示与章节中先前步骤不同的代码,例如当新功能添加到现有代码行时。

在许多情况下,原始源代码已被重新格式化;我们添加了换行并重新调整了缩进以适应书籍中的可用页面空间。此外,当代码在文本中描述时,源代码中的注释通常已从列表中删除。代码注释伴随着许多列表,突出显示重要概念。

书中的所有代码示例都是用 Python 编写的,并且是可运行的代码;尽管它们可能缺少导入语句。在许多情况下,代码示例是更大代码组件的一部分,例如一个模型。在这些情况下,整个代码都可在我的 Google Cloud AI 开发者关系公共 GitHub 仓库 (github.com/GoogleCloudPlatform/keras-idiomatic-programmer/tree/master/zoo) 中找到。

liveBook 讨论论坛

购买《深度学习模式与实践》包括免费访问由 Manning 出版公司运行的私人网络论坛,您可以在论坛中就书籍发表评论、提出技术问题,并从作者和其他用户那里获得帮助。要访问论坛,请访问 livebook.manning.com/#!/book/deep-learning-patterns-and-practices/discussion。您还可以在 livebook.manning.com/#!/discussion 了解更多关于 Manning 论坛和行为准则的信息。

曼宁对读者的承诺是提供一个平台,在这里读者之间以及读者与作者之间可以进行有意义的对话。这并不是对作者参与特定数量活动的承诺,作者对论坛的贡献仍然是自愿的(且未支付报酬)。我们建议您尝试向作者提出一些挑战性的问题,以免他的兴趣转移!只要书籍在印刷中,论坛和先前讨论的存档将可通过出版社的网站访问。

其他在线资源

对于框架,我使用 TensorFlow 2.x,它已经集成了 Keras 模型 API。我认为这两个的结合是超越其生产价值的教育工具。

材料是多模态的。除了书籍和仓库中的完整代码示例外,还有针对每个章节的演示文稿、研讨会、实验室和预先录制的讲座,这些都可以在我的 Google Cloud AI 开发者关系 YouTube 账号 (www.youtube.com/channel/UC8OV0VkzHTp8_PUwEdzlBJg) 上找到。

关于作者

我坚信我终身积累的经验使我成为教授深度学习概念的理想人选之一。当这本书首次印刷时,我将近 60 岁。我拥有丰富的知识和经验,这些在当今的劳动力市场中得到了体现。1987 年,我获得了人工智能的高级学位。我专攻自然语言处理。当我从大学毕业时,我以为我会写有声读物。嗯,那是人工智能的冬天。

在我的早期职业生涯中,我还采取了其他方向。首先,我成为了主框架计算机政府安全方面的专家。随着我在操作系统内核设计和编码方面的技能越来越熟练,我成为了 UNIX 的内核开发者,是当今重型 UNIX 内核的众多贡献者之一。在那些同样的年份里,我参与了共享软件(在开源之前)并创立了 WINNIX,这是一个与商业 MKS Toolkit 竞争在 DOS 环境中运行 UNIX 外壳和命令的共享软件程序。

此后,我开发了低级目标代码工具。在 20 世纪 90 年代初,我成为了安全级计算和大规模并行计算机的编译器/汇编器的专家。我开发了 MetaC,它为传统操作系统和高度安全和大规模并行计算机的操作系统内核提供了仪器。

在 20 世纪 90 年代末,我改变了职业道路,成为日本夏普公司的研究科学家。在几年之内,我成为了公司在北美的主研科学家。在 20 年的时间里,夏普根据我的研究提交了 200 多项美国专利申请,其中 115 项获得批准。我的专利涵盖了太阳能、远程会议、成像、数字交互式标牌和自动驾驶汽车等领域。此外,在 2014-2015 年间,我被认定为开放数据和数据本体领域的世界级领先专家,并创立了 opengeocode 组织。

在 2017 年 3 月,在我的一个朋友的推动下,我开始了解“这个被称为深度学习的东西是什么?”对我来说这是自然而然的。我有大数据背景,曾担任成像科学家和研究员,拥有人工智能研究生学位,从事自动驾驶汽车的工作——这一切似乎都相得益彰。因此,我做出了这个跳跃。

在 2018 年的夏天,谷歌向我提出担任谷歌云 AI 的员工。我在那年的 10 月接受了这个职位。在谷歌的工作经历非常棒。今天,我与谷歌及其企业客户中的大量 AI 专家合作,教授、指导、咨询,并解决挑战,以在大规模生产规模上使深度学习得以实施。

关于封面插图

《深度学习模式与实践》的封面图题为“Indien”,或印度人。这幅插图取自雅克·格拉塞·德·圣索沃尔(1757–1810)的作品集,名为《不同国家的服饰》,于 1784 年在法国出版。每一幅插图都是手工精心绘制和着色的。格拉塞·德·圣索沃尔收藏的丰富多样性生动地提醒我们,200 年前世界的城镇和地区在文化上有多么不同。人们彼此孤立,说着不同的方言和语言。在街道或乡村,仅凭他们的服饰就能轻易识别他们居住的地方以及他们的职业或社会地位。

自那以后,我们的着装方式发生了变化,当时地区间的多样性已经消失。现在很难区分不同大陆、不同城镇、地区或国家的人们。也许我们用文化多样性换取了更加丰富多彩的个人生活——当然,是为了更加多样化和快节奏的技术生活。

在难以区分一本计算机书与另一本计算机书的今天,曼宁通过基于两百年前区域生活的丰富多样性,并由格拉塞·德·圣索沃尔的图画使之重现的书封面,庆祝了计算机行业的创新精神和主动性。

第一部分:深度学习基础知识

在本篇第一部分,你将学习构建深度学习模型的基础知识。我们从深度神经网络(DNN)的基本原理和步骤开始,辅以大量图表来展示这些步骤,并附上代码片段以实现这些步骤。我将描述每个步骤,然后带你通过代码。接下来,我们将探讨卷积神经网络(CNN)的原理和步骤。我将带你了解早期最先进的卷积网络(如 VGGs 和 ResNets)背后的核心设计模式。你将学习如何编写这些模型架构的代码,完整的代码可以从我们的公共 GitHub 仓库中获取。

一旦你开始编写 CNN 代码,下一步是什么?你需要训练它们。我们通过学习训练 CNN 模型的基础知识来结束本部分。

1 设计现代机器学习

本章涵盖

  • 从经典人工智能发展到尖端方法

  • 将设计模式应用于深度学习

  • 介绍用于建模神经网络的程序重用设计模式

深度学习的最新革命是在宏观层面而不是微观层面,通过在谷歌云人工智能工作期间我提出的模型融合方法。在这种方法中,模型被分解成可组合的单元,这些单元共享和适应组件以使用相同初始数据实现不同的目标。组件以各种连接模式相互连接,其中每个组件通过设计学习模型之间的通信接口,而不需要后端应用程序。

此外,模型融合可用于训练物联网(IoT)设备进行数据丰富,将物联网传感器从静态转变为动态学习设备——一种称为模型融合的技术。融合正在提供将人工智能投入生产并在规模和操作复杂性方面实现 2017 年难以想象的方法,当时将人工智能投入生产的推动力刚开始出现。

例如,考虑一下在租赁市场的各个方面(如定价、物业状况和设施)上视觉房地产数据的操作复杂性。使用模型融合方法,您可以创建一个连接单个模型组件的视觉分析管道,每个组件处理这些方面的一个。最终,您将拥有一个系统,可以自动学习确定条件、设施和一般市场吸引力以及相应的适当租金定价。

模型融合方法鼓励工程师将模型视为可以适应以创建单个组件的设计模式或模板。因此,如果你希望使用这种方法,你需要了解其他工程师为解决与你将遇到的类似问题而开发的键模型和系统的设计。

本书的目标是通过向您介绍开创性深度学习模型的设计模式以及将这些组件组合在一起以开发、训练、部署和服务的更大深度学习系统的设计或系统架构,帮助您深入理解。即使您从未与大型企业融合合作,熟练掌握这些模型和架构的底层设计也将提高您创建的任何深度学习系统的工程水平。

1.1 适应性重点

由于本书针对的是不太经验的深度学习工程师和数据科学家,第一部分从基本深度神经网络(DNNs)、卷积神经网络(CNNs)和残差神经网络(ResNets)的设计开始。第一部分还探讨了简单训练管道的架构。关于这些网络和架构的整本书都是写的,所以在这里你将更多地回顾它们是如何工作的,重点是设计模式和原则。这里的目的是概述基本深度学习组件的设计,这些组件将适合第二部分中你将看到的所有模型。

话虽如此,如果你对基础知识了如指掌,你可以直接跳到第二部分,这部分将探讨深度学习发展中的关键模型。我的方法是提供每个模型设计的足够信息,以便你可以对它们进行实验,并提出解决你可能会遇到的 AI 挑战的方案。这些模型大致按照时间顺序介绍,因此第二部分也充当了深度学习历史的一部分,重点在于从一种模型到另一种模型的演变。

现在,如果企业生产正在向模型开发自动学习转变,你可能会质疑检查这些手动设计、以前的前沿(SOTA)模型的价值。然而,许多这些模型继续作为标准模型使用,尤其是在迁移学习方面。其他一些模型从未进入生产,但它们负责的发现至今仍在使用。

模型开发用于生产仍然是一系列自动和手工设计的学习的组合——这对于专有需求或优势往往至关重要。但手工设计并不意味着从头开始;通常,你会从一个标准模型开始,进行微调和调整。为了有效地做到这一点,你需要知道模型是如何工作的以及为什么它会以这种方式工作,其设计背后的概念,以及你将从其他 SOTA 模型中学到的替代构建块的优缺点。

书的最后一部分深入探讨了用于生产的训练和部署的设计模式。虽然并非所有读者都会部署我关注的那些企业级系统,但我认为这些信息对所有读者都相关。熟悉许多类型和规模的系统,这些系统针对各种问题,可以帮助你在需要跳出思维定势解决问题时有所帮助。你对底层概念和设计的了解越多,你变得越有能力且适应性越强。

这种适应性可能是这本书最有价值的收获。生产涉及大量的移动部件,并且不断有“猴子 wrench”被扔进混合物中。如果工程师或数据科学家只是机械地记住框架中可重复的步骤集合,他们将如何处理他们遇到的多样化任务,以及解决扔向他们的“猴子 wrench”呢?雇主寻找的不仅仅是技能和经验;他们想知道你的技术适应性如何。

想象一下自己在面试中:你在技能和工作经验上得分很高,并且在股票机器学习(ML)编码挑战中表现出色。然后面试官给你一个意外或不同寻常的问题,一个“猴子 wrench”。他们这样做是为了观察你如何思考挑战,你应用了哪些概念以及背后的推理,你如何评估各种解决方案的优缺点,以及你的调试能力。这就是适应性。这正是我希望深度学习开发者和数据科学家从这本书中获得的东西。

1.1.1 计算机视觉引领潮流

我主要在计算机视觉的背景下教授所有这些概念,因为设计模式最初是在计算机视觉中演化的。但它们也适用于自然语言处理(NLP)、结构化数据、信号处理和其他领域。如果我们把时钟拨回到 2012 年之前,所有领域的机器学习(ML)主要使用基于经典统计的方法。

诸如斯坦福大学的 Fei-Fei Liu 和加拿大多伦多大学的 Geoffrey Hinton 等学术研究人员开始率先将神经网络应用于计算机视觉。Liu 及其学生编制了一个计算机视觉数据集,现在被称为 ImageNet,以推进计算机视觉的研究。ImageNet,连同 PASCAL 数据集,成为 2010 年年度 ImageNet 大规模视觉识别挑战(ILSVRC)竞赛的基础。早期参赛者使用了传统的图像识别/信号处理方法。

然后,在 2012 年,多伦多大学的 Alex Krizhevsky 提交了一个使用卷积层的深度学习模型,AlexNet。这个模型赢得了 ILSVRC 竞赛,并且领先优势明显。与 Hinton 和 Ilya Sutskever 共同设计的 AlexNet 模型开启了深度学习。在他们相应的论文《使用深度卷积神经网络的 ImageNet 分类》(mng.bz/1ApV)中,他们展示了如何设计神经网络。

在 2013 年,纽约大学的 Matthew Zeiler 和 Rob Fergus 通过微调 AlexNet 成为他们所说的 ZFNet 赢得了比赛。这种基于彼此成功的模式持续发展。牛津大学的视觉几何组在此基础上扩展了 AlexNet 的设计原则,并在 2014 年的比赛中获胜。2015 年,微软研究院的 Kaiming He 等人进一步扩展了 AlexNet/VGG 的设计原则,并引入了新的设计模式,赢得了比赛。他们的模型 ResNet 以及他们的“用于图像识别的深度残差学习”论文(arxiv.org/abs/1512.03385),引发了探索 CNN 设计空间的热潮。

1.1.2 超越计算机视觉:自然语言处理(NLP)、自然语言理解(NLU)、结构化数据

在这些早期使用深度学习为计算机视觉开发设计原则和设计模式的年份里,自然语言理解(NLU)和结构化数据模型的发展落后,并继续专注于经典方法。他们使用了经典的机器学习框架,如自然语言工具包(NLTK)用于文本输入,以及基于决策树的经典算法,如随机森林,用于结构化数据输入。

在自然语言理解(NLU)领域,随着循环神经网络(RNNs)、长短期记忆(LSTM)和门控循环单元(GRU)层的引入,取得了进展。2017 年,随着自然语言中的 Transformer 设计模式的引入以及 Ashish Vaswani 等人撰写的相应论文“Attention Is All You Need”(arxiv.org/abs/1706.03762),这一进展实现了飞跃。谷歌大脑,作为谷歌 AI 内部的一个深度学习研究组织,在 ResNet 中早期采用了类似的注意力机制。同样,随着结构化数据设计模式的引入,例如 2016 年由 Google Research 的技术无关研究小组 Heng-Tze Cheng 等人概述的“Wide & Deep Learning for Recommender Systems”(arxiv.org/abs/1606.07792),设计模式的发展也随之演进。

当我专注于计算机视觉,以教授设计模式的发展和当前状态时,我会适当提及自然语言理解(NLU)和结构化数据方面的相应进展。本书中的许多概念适用于多个领域和数据类型。例如,第二章到第四章涵盖了通用基础,而第三部分除了第一章外,所有章节都涵盖了与模型和数据类型无关的概念。

在有意义的章节中,主要在第二部分,我引入了来自计算机视觉之外的例子。例如,在第五章,我比较了具有身份链接的残差块在 NLU 中与注意力在 Transformer 中的发展。在第六章,我们将探讨宽 CNN 如何与结构化数据的宽度和深度模型以及 TabNet 的发展相关。第九章解释了自编码器在 NLU 中与嵌入的比较,第十一章讨论了迁移学习步骤如何与 NLU 和结构化数据相媲美。

通过从计算机视觉、自然语言处理和结构化数据中分享的例子进行泛化,你应该能够将概念、方法和技术应用到你的领域中的问题。

1.2 机器学习方法的演变

要理解现代方法,我们首先必须了解我们在人工智能和机器学习方面的现状,以及我们是如何到达这个阶段的。本节介绍了在当今生产环境中工作的几个顶级方法和设计模式,包括智能自动化、机器设计、模型融合和模型合并。

1.2.1 经典人工智能与窄人工智能

让我们简要地介绍一下经典人工智能和当今现代窄人工智能之间的区别。在经典人工智能(也称为语义人工智能)中,模型被设计为基于规则的系统。这些系统被用来解决无法用数学方程式解决的问题。相反,系统被设置成模仿一个主题或领域专家。图 1.1 展示了这种方法的一个视觉表示。

图片

图 1.1 在经典人工智能方法中,领域专家设计规则来模仿他们的知识。

经典人工智能在低维输入空间(例如,具有少量不同输入)中表现良好;输入空间可以被分成离散的段,如类别或箱;并且离散空间与输出之间保持强烈的线性关系。领域专家设计了一套基于输入和状态转换的规则,以模仿他们的专业知识。然后程序员将这些规则转换成一个基于规则的系统,通常是“如果AB为真,则C为真”的形式。

这种系统非常适合像预测葡萄酒的质量和适宜性这样的问题,这只需要一小套规则。例如,对于葡萄酒选择器,输入可能包括餐点是午餐还是晚餐、主菜、场合以及是否包含甜点。但经典人工智能无法扩展到更大的问题;准确性会大幅下降,规则需要不断细化以试图避免下降。设计规则的领域专家之间的不一致性是导致不准确性的另一个问题。

窄人工智能(也称为统计人工智能)中,模型在大量数据上得到训练,减轻了对领域专家的需求。相反,模型使用统计原理来学习输入数据分布中的模式,也称为抽样分布。这些模式可以以高精度应用于训练中未见的样本。当使用由大量代表更大人群或总体分布的数据组成的抽样分布进行训练时,我们可以建模没有经典人工智能带来的约束的问题。换句话说,窄人工智能可以在输入空间具有显著更高维度(意味着大量不同的输入)以及可以混合离散和连续输入的情况下工作得非常好。

让我们通过将两者都应用于预测房屋售价来对比基于规则的窄人工智能。基于规则的系统通常只能考虑少量输入;例如,地块大小、平方英尺、卧室数量、浴室数量和财产税。这样的系统可以预测类似房屋的中位价格,但不能预测任何单个房屋的价格,因为财产与价格之间的关系是非线性的。

让我们退一步,讨论线性关系和非线性关系之间的区别。在线性关系中,一个变量的值可以预测另一个变量的值。例如,假设我们有一个函数y = f(x),我们将其定义为 2 × x。对于任何值x,我们可以以 100%的置信度预测y的值。在非线性关系中,只能通过任何值x的概率分布来预测y的值。

使用我们的住房示例,我们可以说y = f(x)作为售价 = sqft × 每平方英尺价格。现实情况是,许多其他变量会影响每平方英尺价格,并且这些变量如何影响价格存在一些不确定性。换句话说,房屋的平方英尺与售价之间存在非线性关系,它本身只能预测售价的概率分布。

在窄人工智能中,我们显著增加了输入的数量以学习非线性,例如添加房屋建造年份、升级许可发放时间、建筑类型、屋顶和侧面的材料、学区信息、就业机会、平均收入、社区以及犯罪率、公园、公共交通和高速公路的邻近程度。这些额外的变量有助于模型以高置信度学习概率分布。值来自固定集合的输入,如建筑架构,是离散的,而来自无界范围的输入,如平均收入,是连续的

窄 AI 模型通过学习分割输入的边界来处理具有高非线性输出(预测)的输入,如果这些分割与输出有强烈的线性关系。这些类型的模型基于统计学,需要大量数据,因此被称为窄 AI,因为它们擅长解决一个领域内有限范围的任务的狭窄问题。窄模型在泛化到广泛范围的问题上并不擅长。图 1.2 说明了窄 AI 的方法。

图 1.2 在窄 AI 中,模型通过在大数据集上训练,这些数据集代表了更广泛的群体,来学习成为领域专家。

另一种区分经典 AI 和窄 AI 的方法是观察这两种模型在误差率降低方面的差异,因为深度学习不断推动向贝叶斯理论误差极限迈进。贝叶斯将这个理论误差极限描述为一种进步,如图 1.3 所示。

图 1.3 机器学习向贝叶斯理论误差极限迈进。

首先,一个普通非专家解决任务的误差率会是什么?然后,一个专家解决任务的误差率会是什么(这类似于语义 AI)?一群专家解决任务的误差率会是什么?最后,理论极限:无限多个专家解决任务的误差率会是什么?

在大量计算机视觉和 NLP 任务中,深度学习已经达到了一群专家的误差率,远远超过了传统的软件应用和专家系统。到 2020 年,研究人员和企业机器学习工程师开始追求处于贝叶斯理论误差极限范围内的生产系统。

1.2.2 计算机学习的下一步

现在我们已经了解了我们是如何到达这里的,我们确切地在哪里?随着计算机学习的改变,我们首先从人工智能转向智能自动化。然后我们进入了机器设计、模型融合和模型合并。让我们定义这些现代进步。

智能自动化

正如我们刚才看到的,早期的人工智能意味着经典 AI,这主要是基于规则的,需要领域专家。这使我们能够基本上编写软件程序来开始自动化通常手工完成的任务。然后,在窄 AI 中,我们将统计学应用于学习,消除了对领域专家的需求。

下一个主要进步是智能自动化IA)。在这种方法中,模型学习自动化过程的(近)最优方式,其性能和准确性超过了手动或计算机自动化的对应物。

通常,IA 系统作为一个管道流程工作。累积信息、转换和状态转换是管道中各个点的模型输入。每个模型的输出或预测被用来执行下一个信息转换和/或决定下一个状态转换。通常,每个模型都是独立训练和部署的,通常作为一个微服务,后端应用程序驱动整个管道流程。

IA 的一个例子是从来自不同来源和格式的患者医疗记录中自动提取患者信息,包括模型从未训练过的来源。我在 2018 年从事了医疗保健领域这类系统的架构设计工作。如今,许多现成的提供商使这些系统可用;Google Cloud Healthcare API (cloud.google.com/healthcare)就是其中之一。

到 2019 年,AI 正在大量企业规模的公司中进入全面生产。在整个一年中,我会与谷歌最大的客户进行越来越多的会议。我们现在用商业术语来谈论 AI。这些技术概念已经演变成了商业概念。

在这些会议中,我们不再使用AI,而是用IA来揭示这个过程。我们让客户描述他们想要应用 AI 的过程中的每个步骤(手动和计算机辅助)。假设一个步骤的成本是 100,000 美元。过去,我们的倾向是直接跳到那个步骤并应用 AI——“大回报”。但假设另一个步骤的成本只是几分钱,但每天发生一百万次——那就是每天 10,000 美元,或每年 3,650,000 美元。假设我们可以用每年运营成本为 40,000 美元的模型来替代这个低垂的果实。没有人会留下 3,610,000 美元。

这就是智能自动化。程序员不再编写预设计的算法来自动化,而是引导模型智能地学习最优算法。图 1.4 展示了我们将如何将智能自动化应用于索赔处理流程的单一步骤。

图片

图 1.4 智能自动化应用于索赔处理

让我们对这个管道中发生的事情进行一个高级回顾。在第 1 步,与索赔相关的文件被扫描并摄入到 IA 管道中。在第 2 步,以前文档操作员随后查看每个扫描的文档并标记的做法被一个为这个索赔处理任务训练的自然语言分类模型所取代。

这种替代有几个优点。首先,消除了人工成本。除了计算机比人类速度快之外,这个过程可以分布,以便可以并行处理大量文档。

第二,正确标记文档类别的错误率与人类错误率相比大幅降低。让我们考虑一下原因。每个操作员可能具有不同的训练水平和经验,以及广泛的准确性差异。此外,人类疲劳也会导致错误率上升。但假设我们有一千名经过培训的人类操作员查看相同的文档,并且我们使用多数投票法来决定如何标记文档。我们预计错误率将大幅降低,接近于零。

模型就是这样做的:它已经在大量由大量经过培训的人类操作员标记的文档上进行了训练。通过这种方式,一旦训练完成,模型的性能就等同于一群经过培训的人类操作员的集体性能。

在手动版本的步骤 3 中,专家人工操作员会检查标记以进一步减少错误。这一步骤在 IA 流程中并未被消除——但由于步骤 2 中错误的大幅减少,人工操作员的工作量显著减少。

IA 流程下游继续降低/消除人工操作员成本,并进一步降低错误率。一旦到达最后一步,经过培训的主题专家(SME)将对支付授权(或非授权)进行最终审查。现在,由于 SME 审查的信息准确性更高,人类主观决策更加可靠,进一步降低了错误主观决策的成本。

我们在行业中已经停止使用机器学习这个术语,并用机器设计来代替它,以便与计算机辅助设计(CAD)进行类比。我们将 CAD 应用于那些即使设计一个次优解也太复杂的工程问题。这些系统具有建筑组件、数学知识和专家系统规则,而 SMEs 则指导 CAD 系统找到良好的次优解。

在机器设计中,系统学习的是建筑组件、数学知识和规则,而机器学习工程师则指导机器设计以找到最优解。通过转向机器设计,我们释放了高价值的人力资源去解决下一阶段的挑战性问题,加速他们的技术进步,并为业务带来更高的投资回报率(ROI)。

机器设计

在深度学习之前,SMEs 设计软件程序来搜索软件和硬件中具有高复杂性的部分中的良好解决方案。通常,这些程序是搜索优化和基于规则技术的组合。

在下一个发展阶段,机器设计,模型学习了一种(近似)最优的方式来设计和集成软件和硬件组件。这些系统在性能、准确性和复杂性方面都超越了由具有 CAD 程序辅助的小型企业专家(SME)设计的模型。人类设计师利用他们的现实世界专业知识来引导模型搜索解决方案的空间。

考虑一个有两个 X 射线部门的医院;一个部门有一台昂贵的 X 射线机,另一个部门有一台低成本 X 射线机。一位检查医生根据患者患有肺炎的可能性选择将患者送往哪个部门进行肺炎诊断确认。如果肺炎的可能性低,或者不太可能,医生会根据医院政策和降低保险提供商成本的愿望,将患者送往低成本 X 射线机。如果确定的可能性高,或者很可能,患者应得到昂贵的 X 射线。这是一个机器设计影响超参数和架构搜索空间,并指导从不同分布(在这种情况下是 X 射线机)自动学习医学图像的系统管道的例子。

请记住,如果使用两个 X 射线设备产生的累积 X 射线和诊断确定数据来训练一个模型,我们会有数据偏差。模型不是从数据中学习,而是可能无意中学习了两个医疗设备的独特特征——视角偏差。模型中视角偏差的经典例子是确定狗与狼的情况,模型无意中学习了狼的雪,因为所有狼的训练图片都是在冬天拍摄的。

在机器设计中,除了训练模型外,系统还学习了对抗性模型(称为代理)的最佳训练管道。如果您想深入了解,请参阅“一种用于从胸部 X 光片中稳健分类肺炎的对抗性方法”(arxiv.org/pdf/2001.04051.pdf),这是一篇关于这个问题的基础机器设计论文。

模型融合

模型融合是开发更精确、成本更低的预测维护和故障检测系统(例如在物联网传感器系统中使用的系统)的下一项进步。传统上,非常昂贵的设备和基础设施,如工厂机器、飞机和地区电力基础设施,都内置了物联网传感器。这些连续的感官数据将被输送到由专家设计的基于规则的算法中。

这些传统系统的问题在于它们容易受到高环境变化的影响,这影响了它们的可靠性。例如,在电力行业中,输电线路在每个塔上都有传感器,用于监控塔之间的线路阻抗异常。由于风对线路连接的压力、温度变化影响导电性以及次要因素(如水分积累)的影响,阻抗可能会波动。

模型融合通过使用具有更高操作成本的机器学习模型生成标签数据来转换专家设计的系统,从而提高了物联网系统的可靠性。继续我们的例子,电力行业今天使用无人机和计算机视觉训练的深度学习模型定期检查输电线路。这个过程非常准确,但操作成本更高。因此,它被用来为低成本传感器系统创建的阻抗传感器数据生成标签数据。在较高操作成本下生成的标签数据随后被用来训练另一个模型,该模型使用阻抗传感器(低成本系统)来实现可比的可靠性。

模型融合

在深度学习之前,应用程序要么是运行在后端服务器上的单体应用程序,要么是服务器上的核心骨干,该服务器使用分布式微服务。在模型融合中,模型(们)本质上成为整个应用程序,直接共享模型组件和输出,并在模型之间学习通信接口。所有这些都不需要庞大的后端应用程序或微服务。

想象一下房地产行业的模型管道,它使用房屋和公寓大楼的照片进行视觉分析,以确定租金定价。一组模型中的模型是串联在一起的,每个模型都针对特定的特征进行训练;一起,它们自动确定租赁条件、租赁设施和市场吸引力,并得出相应的定价。

让我们比较一下更传统的 IA,其中流程管道中的每一步都是一个单独部署的模型实例,输入可以是原始图像、转换和状态,所有这些都由专家设计的基于规则的后端应用程序控制。相比之下,在融合中,模型实例直接相互通信。每个模型都学会了执行其专业任务的最佳方法(例如,确定条件);学会了模型之间的最佳通信路径和表示(房屋、房间和设施模型);以及学会了确定状态变化(财产条件)的最佳方法。

在 2021 年,我预计在企业层面,生产将转向模型融合。我们仍在努力弄清楚如何使其运作。在融合之前,多个模型会被部署执行不同的任务,开发者会构建一个后端应用程序,该程序执行表示状态转移(REST)或微服务调用。我们仍然编写了应用程序的逻辑,以及应用程序和模型之间的接口和数据通信。图 1.5 是我在 2019 年底为体育广播设计的模型融合的一个示例。

图片

图 1.5 应用到体育广播的模型融合

让我们逐步了解这个过程。首先,合并处理实时视频;也就是说,合并处理正在实时连续处理视频。视频被实时解析为一系列时间序列的帧。每一帧都是比赛的图像,例如一个准备击球的棒球运动员。每一帧首先由一组共享的卷积层(共享卷积层)进行处理,生成跨下游任务的通用内部编码。换句话说,每个下游任务(模型)不是从相同的输入图像开始并处理成内部编码,而是输入图像只编码一次,编码在下游重复使用。这加快了模型的响应速度,并缩小了其在内存中的大小。

接下来,生成的通用编码通过一个对象检测模型,该模型已经针对通用编码进行训练,而不是输入图像,从而减少了检测对象的大小并提高了速度。比如说,对象检测被训练以识别包括人物、球员装备、体育场和场地在内的对象。对于它在画面中识别的每个对象,它将输出一个对象级别的嵌入,这是一个低维度的表示(例如,缩小尺寸的编码)以及在上游输入帧内的空间坐标。

这些对象级别的嵌入现在成为另一组下游任务的输入。接下来,你会看到被分类为人物的嵌入被传递到一个已经针对嵌入与原始图像进行训练的面部识别模型。例如,该模型可能被训练以识别球员、官员、裁判、教练和安全人员,并相应地标记嵌入。然后,特定于球员的对象嵌入被传递到一个姿态估计模型,该模型找到人体关键点并分类识别的人物的姿态,例如球员 A 处于击球位置。

接下来,对象级别的嵌入(球员、齿轮、体育场等)与特定于球员的姿态结合成一个信息丰富的密集嵌入。所有这些丰富的信息都传递给另一个模型来预测球员的动作,例如球员 A 正在准备击球。这个预测动作随后传递给另一个模型,将动作转换为叠加在直播上的字幕文本。

假设体育赛事正在全球范围内播出,被各种语言的观众观看。图像字幕模型的输出(例如,英语)被传递给另一个模型,该模型执行针对每个市场的特定语言翻译。在每个市场中,翻译的文本被转换为语音进行实时解说。

正如你所见,模型已经从单一任务的预测和独立部署,发展到执行多项任务、共享模型组件并集成形成解决方案的模型,例如在医疗文档处理和体育广播的例子中。另一种描述这些集成模型解决方案的方式是作为serving pipeline(服务管道)。管道由连接的组件组成;一个组件的输出是另一个组件的输入,每个组件都是可配置的、可替换的,并且具有版本控制和历史记录。在今天的生产机器学习中,使用管道的范围涵盖了整个端到端流程。

1.3 设计模式的益处

在 2017 年之前,所有领域的神经网络模型的实现大多数都是用批处理脚本风格编写的。随着人工智能研究人员和经验丰富的软件工程师越来越多地参与研究和设计,我们开始看到模型编码的转变,这反映了软件工程原则的重用和设计模式。

设计模式意味着存在一种最佳实践,用于构建和编码一个可以在广泛情况下重用的模型,例如图像分类、目标检测和跟踪、人脸识别、图像分割、超分辨率和图像数据的风格迁移;文本数据的文档分类、情感分析、实体提取和摘要;以及非结构化数据的分类、回归和预测。

深度学习设计模式的发展导致了模型融合、模型融合和机器设计——其中模型组件可以被重用和适应。这些模型组件的设计模式允许研究人员和其他深度学习实践者逐步开发模型组件和应用的最佳实践,适用于所有模型和数据类型。这种知识共享加速了设计模式的发展,并促进了模型组件的重用,使得深度学习能够广泛应用于生产应用。

我在这本书中介绍的历史上最先进的模型中,许多都揭示了被纳入今天现代生产中的知识和概念。尽管许多这些模型最终停止使用,但理解其背后的知识、概念和构建块组件对于理解和实践今天的深度学习至关重要。

神经网络模型最早的设计模式之一是过程重用,它同时被应用于计算机视觉、NLU 和结构化数据。与软件应用一样,我们设计过程重用模型作为反映数据流和将组件分解为可重用函数的组件。

使用过程重用设计模式有许多好处——并且现在仍然如此。首先,它简化了在架构图中表示模型的任务。在正式设计模式的使用之前,每个研究团队都在其发表的论文中发明了自己的方法来表示其模型架构。设计模式还定义了如何表示模型结构和流程。拥有一致和精细的方法简化了架构图的表示。其次,模型架构对其他研究人员和机器学习工程师来说更容易理解。此外,从标准模式开始工作揭示了设计的内部运作,这反过来使得模型更容易修改,也更容易进行故障排除和调试。

在 2016 年,研究论文开始提出组件流——通常被称为,(表征性)学习器和(转换性)任务。在 2016 年之前,研究论文将它们的模型作为单一架构提出。这些单一架构使得研究人员难以证明新概念改善了模型中的任何一部分。因为这些组件包含重复的流程模式,最终出现了可配置组件的概念。这些重复的流程模式随后被其他研究人员在他们的模型架构设计中重用和改进。尽管在 NLU 和结构化数据中模型组件的应用落后,但到 2017 年,我们开始看到它们在研究论文中的出现。今天,无论模型类型和领域如何,你都会看到模型设计由相同的三种主要模型组件组成。

将模型分解为组件的早期设计模式是 SqueezeNet (arxiv.org/pdf/1602.07360.pdf),它使用了基于元参数的可配置组件。元参数的引入,描述了如何配置模型组件,有助于正式化如何表示、设计和实现可配置组件。基于可配置组件设计模型为研究人员提供了在尝试各种组件配置的同时,按组件衡量性能改进的手段。这种设计方法在开发应用软件时是标准做法;其众多好处之一是它促进了代码重用。

用于重用的过程模式是第一个,也是最基本的可重用设计,因此它们是本书的重点。后来,我们所说的工厂模式和抽象工厂模式被引入来进行机器设计。工厂设计模式使用 SOTA(最先进的技术)构建块作为工厂,并寻找与需求匹配的最佳设计。抽象工厂模式进一步抽象,寻找最佳的工厂,然后使用该工厂来寻找最佳模型。

但在这本书中,你将学习基石设计,从第一部分的基本 DNN 和 CNN 架构开始,过渡到第二部分为程序重用编码的原始模型,最后在第三部分完成对现代生产管道的巡礼。

摘要

  • 深度学习从经典 AI 发展到窄 AI,这导致了使用 AI 来解决具有高维输入的问题。

  • 深度学习已经从实验模型发展到数据、训练、部署和服务的可重用和可配置管道方法。

  • 在企业规模的尖端,机器学习从业者正在使用模型融合、模型融合和机器设计。

  • 程序重用设计模式是构建块,也是通往今天企业规模尖端领先地位的过渡。

2 深度神经网络

本章节涵盖

  • 分析神经网络和深度神经网络的架构

  • 在训练过程中使用前向和反向传播来学习模型权重

  • 在 TF.Keras 顺序和功能 API 中编码神经网络模型

  • 理解各种模型任务类型

  • 使用策略防止过拟合

本章节从神经网络的一些基础知识开始。一旦你掌握了基础知识,我将向你介绍如何使用 TF.Keras 轻松地编码深度神经网络(DNNs),TF.Keras 提供了两种编码神经网络的风格:顺序 API 和功能 API。我们将使用这两种风格来编写示例代码。

本章还涵盖了模型的基本类型。每种模型类型,如回归和分类,都学习不同类型的任务。你想要学习的任务决定了你将设计的模型类型。你还将学习权重、偏差、激活和优化器的根本原理,以及它们如何有助于提高模型的准确性。

为了总结本章内容,我们将编写一个图像分类器。最后,我将介绍在训练过程中出现的过拟合问题,以及使用 dropout 的早期解决过拟合的方法。

2 神经网络基础知识

让我们从神经网络的一些基础知识开始。首先,本节涵盖了神经网络的输入层,然后是如何将其连接到输出层,接着是如何在之间添加隐藏层以形成一个深度神经网络。从那里,我们将介绍层由节点组成,节点的作用,以及层如何相互连接以形成全连接神经网络。

2.1.1 输入层

神经网络输入层接受数字!所有输入数据都被转换为数字。一切都是数字。文本变成数字,语音变成数字,图片变成数字,已经是数字的东西仍然是数字。

神经网络以向量、矩阵或张量作为输入。这些只是表示数组中维度数量的名称。一个向量是一维数组,例如数字列表。一个矩阵是二维数组,例如黑白图像中的像素。而一个张量是三维或更多维度的数组——例如,矩阵堆叠,其中每个矩阵的维度相同。就是这样。图 2.1 展示了这些概念。

图 2.1 深度学习中数组的类型

说到数字,你可能听说过像规范化标准化这样的术语。在标准化中,数字被转换成以零为中心,每侧的均值有一个标准差。如果你现在正在说,“我不做统计学”,我知道你的感受。但别担心。像 scikit-learn(scikit-learn.org)和 NumPy(numpy.org)这样的包提供了库调用,为你完成这项工作。标准化基本上是一个可以按的按钮,甚至不需要杠杆,所以没有需要设置的参数。

说到包,你将大量使用 NumPy。NumPy 是什么,为什么它如此受欢迎?鉴于 Python 的解释性特性,该语言处理大型数组不佳——就像真正的大、超级大的数字数组——成千上万的、数以万计的、数以百万计的数字。想想卡尔·萨根关于宇宙大小的著名引言:亿万个星星。那是一个张量!

有一天,一个 C 程序员有了这样的想法,用低级的 C 编写一个处理超级大数组的性能实现,然后添加了一个外部 Python 包装器。NumPy 就这样诞生了。如今,NumPy 是一个包含许多有用方法和属性的库,如shape属性,它告诉你数组的形状(或维度),以及where()方法,它允许你在超级大数组上进行类似 SQL 的查询。

所有 Python 机器学习框架,如 TensorFlow 和 PyTorch,都会将 NumPy 多维数组作为输入层输入。至于 C、Java 或 C++等,神经网络中的输入层就像在编程语言中传递给函数的参数一样。就是这样。

让我们开始安装你需要的 Python 包。我假设你已经安装了 Python 3.x版本(www.python.org/downloads/)。无论你是直接安装它,还是作为 Anaconda(www.anaconda.com/products/enterprise)等更大包的一部分安装它,你都会得到一个叫做pip的便捷命令行工具。这个工具用于安装你将来需要的任何 Python 包,只需一个命令调用。你使用pip install然后是包名。它会去 Python 包索引(PyPI),Python 包的全局存储库,为你下载并安装包。这相当简单。

我们想从下载和安装 TensorFlow 框架和 NumPy 包开始。猜猜看?它们的名称在注册表中是tensorflownumpy——幸运的是,非常明显。让我们一起来做。打开命令行,输入以下命令:

pip install tensorflow
pip install numpy

在 TensorFlow 2.0 中,Keras 被内置为推荐的模型 API,现在被称为TF.Keras。TF.Keras 基于面向对象编程,包含一系列类及其相关的方法和属性。

让我们从简单开始。假设我们有一个房屋数据集。每一行有 14 个数据列。一列表示房屋的销售价格。我们将称之为标签。其他 13 列包含有关房屋的信息,如面积和财产税。这些都是数字。我们将称之为特征。我们想要做的是学习从特征中预测(或估计)标签。

现在,在我们拥有所有这些计算能力和这些令人惊叹的机器学习框架之前,数据分析师是通过手工或使用 Microsoft Excel 电子表格中的公式来处理这些事情的,这些电子表格包含一定量的数据和大量的线性代数。然而,我们将使用 Keras 和 TensorFlow。

我们将首先从 TensorFlow 导入 Keras 模块,然后实例化一个Input类对象。对于这个类对象,我们定义输入的形状或维度。在我们的例子中,输入是一个包含 13 个元素的向量(数组),每个元素对应一个特征:

from tensorflow.keras import Input

Input(shape=(13,))

当你在笔记本中运行这两行代码时,你会看到这个输出:

<tf.Tensor 'input_1:0' shape=(?, 13) dtype=float32>

这个输出显示了Input(shape=(13,))的评估结果。它产生了一个名为input_1:0的张量对象。这个名称将在稍后帮助你调试模型时很有用。shape中的?表示输入对象可以接受任意数量的条目(示例或行),每个条目包含 13 个元素。也就是说,在运行时,它将绑定 13 个元素的向量数量到实际传递的示例(行)数量,这被称为(迷你)批量大小dtype显示了元素的默认数据类型,在这种情况下是一个 32 位的浮点数(单精度)。

2.1.2 深度神经网络

DeepMind, 深度学习,深,深,深。哦,这一切都是什么意思?在这里,“深”的意思是指神经网络在输入层和输出层之间有一层或多层。正如你稍后将要读到的,通过深入隐藏层,研究人员已经能够获得更高的准确率。

将有向图可视化成深度层。根节点是输入层,终端节点是输出层。中间的层被称为隐藏层深层层。所以一个四层的深度神经网络(DNN)架构看起来会是这样:

  • 输入层

  • 隐藏层

  • 隐藏层

  • 输出层

要开始,我们将假设除了输出层之外,每一层的每个神经网络节点都是相同类型的神经网络节点。我们还将假设每一层的每个节点都与下一层的每个节点相连。这被称为全连接神经网络(FCNN),如图 2.2 所示。例如,如果输入层有三个节点,下一个(隐藏)层有四个节点,那么第一层的每个节点都与下一层的所有四个节点相连——总共 12 个连接(3 × 4)。

图 2.2 深度神经网络在输入层和输出层之间有一个或多个隐藏层。这是一个全连接网络,因此每个层的节点都相互连接。

2.1.3 前馈网络

DNN 和 CNN(你将在第三章中了解更多关于 CNN 的内容)被称为前馈神经网络前馈意味着数据按顺序通过网络,单向地从输入层流向输出层。这与过程式编程中的函数类似。输入作为参数在输入层中传递,函数根据输入(在隐藏层中)执行一系列按顺序的动作,然后输出结果(输出层)。

当在 TF.Keras 中编码前馈网络时,你会在博客和其他教程中看到两种不同的风格。我将简要介绍这两种风格,这样当你看到一种风格的代码片段时,你可以将其翻译成另一种风格。

2.1.4 顺序 API 方法

顺序 API 方法对于初学者来说更容易阅读和遵循,但代价是它的灵活性较低。本质上,你使用Sequential类对象创建一个空的 feed-forward 神经网络,然后逐个“添加”一层,直到输出层。在以下示例中,省略号代表伪代码:

from tensorflow.keras import Sequential

model = Sequential()                    ❶
model.add( ...the first layer... )      ❷
model.add( ...the next layer... )       ❷
model.add( ...the output layer... )     ❷

❶ 创建一个空模型

❷ 按顺序添加层的占位符

或者,可以在实例化Sequential类对象时,以列表的形式按顺序指定层,作为参数传递:

model = Sequential([ ...the first layer...,
                     ...the next layer...,
                     ...the output layer...
                   ])

因此,你可能会问,何时使用add()方法与在Sequential对象实例化时指定列表?嗯,两种方法生成相同的模型和行为,所以这是一个个人偏好的问题。我倾向于在教程和演示材料中使用更详尽的add()方法以提高清晰度。但如果是编写生产代码,我使用更简洁的列表方法,这样我可以更容易地可视化和编辑代码。

2.1.5 功能 API 方法

功能 API 方法更为高级,允许你构建非顺序流模型——例如分支、跳转链接、多个输入和输出(你将在第 2.4 节中看到多个输入和输出的工作方式)。你分别构建层,然后将它们连接起来。这一步骤给你提供了以创新方式连接层的自由。本质上,对于前馈神经网络,你创建层,将它们绑定到另一个或多个层,然后在Model类对象的最终实例化中拉取所有层。

input = layers.(...the first layer...)                                 ❶
hidden = layers.(...the next layer...)( ...the layer to bind to... )   ❷
output = layers.(...the output layer...)( /the layer to bind to... )   ❸
model = Model(input, output)                                           ❹

❶ 构建输入层

❷ 构建隐藏层并将其绑定到输入层

❸ 构建输出层并将其绑定到隐藏层

❹ 按照从输入层到输出层的绑定组装模型

2.1.6 输入形状与输入层

输入 形状 和输入 在一开始可能会让人困惑。它们不是同一件事。更具体地说,输入层中的节点数量不需要与输入向量的形状相匹配。这是因为输入向量中的每个元素都将传递到输入层中的每个节点,如图 2.3 所示。

图片

图 2.3 输入(形状)和输入层不同。输入中的每个元素都与输入层中的每个节点相连。

例如,如果我们的输入层有 10 个节点,并且我们使用之前提到的 13 元素输入向量作为例子,我们将在输入向量和输入层之间有 130 个连接(10 × 13)。

输入向量中的每个元素与输入层中的每个节点之间的每个连接都有一个 权重,输入层中的每个节点都有一个 偏差。将输入向量与输入层之间的每个连接以及层之间的连接视为发送一个信号,表示它对输入值将如何对模型的预测做出贡献的强烈信念。我们需要有一个测量这个信号强度的指标,这就是权重的作用。它是一个系数,用于乘以输入层的输入值和后续层的先前值。

现在,这些连接中的每一个都像是在 x-y 平面上的一个向量。理想情况下,我们希望这些向量在 y 轴上的同一个中心点(例如,0 原点)相交。但它们并没有。为了使这些向量相互关联,偏差是每个向量从 y 轴中心点的偏移量。

权重和偏差是神经网络在训练过程中将“学习”的内容。权重和偏差也被称为 参数。这些值在模型训练后将与模型一起保留。否则,这个操作对你来说是不可见的。

2.1.7 密集层

在 TF.Keras 中,FCNN(全连接神经网络)的层被称为 密集层。一个密集层有 n 个节点,并且与前一层完全连接。

让我们继续,通过在 TF.Keras 中定义一个三层神经网络,使用 sequential API 方法,来构建我们的例子。我们的输入层有 10 个节点,接受一个 13 元素向量(13 个特征)作为输入,该向量连接到第二个(隐藏)层,该层有 10 个节点,然后连接到第三个(输出)层,该层只有一个节点。由于输出层将输出单个实数值(例如,房屋的预测价格),因此输出层只需要一个节点。在这个例子中,我们将使用神经网络作为 回归器,这意味着神经网络将输出一个单一的实数:

输入层 = 10 个节点

隐藏层 = 10 个节点

输出层 = 1 个节点

对于输入层和隐藏层,我们可以选择任意数量的节点。节点越多,神经网络的学习能力越强。但节点越多也意味着复杂性增加,训练和预测所需的时间也越长。

在下面的代码示例中,我们对 Dense 类对象进行了三次 add() 调用。add() 方法以我们指定的相同顺序添加层。第一个(位置)参数是节点的数量,第一层和第二层是 10 个,第三层是 1 个。请注意,在第一个 Dense 层中,我们添加了(关键字)参数 input_shape。这就是我们定义输入向量并将其连接到 Dense 层的第一个(输入)层的地方:

from tensorflow.keras import Sequential
from tensorflow.keras.layers import Dense

model = Sequential()
model.add(Dense(10, input_shape=(13,)))    ❶
model.add(Dense(10))                       ❷
model.add(Dense(1))                        ❸

❶ 在顺序模型中,第一层需要 input_shape 参数。

❷ 构建隐藏层

❸ 将输出层构建为一个回归器——单个节点

或者,我们可以在实例化 Sequential 类对象时,将层的顺序序列定义为列表参数:

from tensorflow.keras import Sequential
from tensorflow.keras.layers import Dense

model = Sequential([
                   Dense(10, input_shape=(13,)),    ❶
                   Dense(10),                       ❶
                   Dense(1)                         ❶
                   ])

❶ 层以顺序列表的形式指定。

让我们现在做同样的事情,但使用功能 API 方法。首先,我们通过实例化一个 Input 类对象来创建一个输入向量。Input 对象的(位置)参数是输入的形状,它可以是一个向量、矩阵或张量。在我们的例子中,我们有一个 13 个元素的向量。因此,我们的形状是 (13,)。我确信你已经注意到了尾随的逗号。这是为了克服 Python 中的一个怪癖。如果没有逗号,(13) 将被评估为一个表达式:一个由括号包围的整数值 13。添加一个逗号告诉解释器这是一个 元组(一个有序值集)。

接下来,我们通过实例化一个 Dense 类对象来创建输入层。Dense 对象的位置参数是节点的数量,在我们的例子中是 10 个。注意以下特殊的语法:(inputs)Dense 对象是一个可调用的对象;通过实例化 Dense 返回的对象可以作为函数调用。因此,我们将其作为函数调用,在这种情况下,该函数将输入向量(或层输出)作为(位置)参数来连接它;因此我们传递给它 inputs,这样输入向量就被绑定到 10 节点的输入层。

接下来,我们通过实例化另一个具有 10 个节点的 Dense 对象来创建隐藏层。使用它作为可调用的对象,我们将它(完全)连接到输入层。

然后我们通过实例化另一个具有一个节点的 Dense 对象来创建输出层。使用它作为可调用的对象,我们将它(完全)连接到隐藏层。

最后,我们通过实例化一个 Model 类对象,传递输入向量和输出层的(位置)参数来将这些全部组合起来。记住,所有其他层之间已经连接,因此在实例化 Model() 对象时我们不需要指定它们:

from tensorflow.keras import Input, Model
from tensorflow.keras.layers import Dense

inputs = Input((13,))           ❶
input = Dense(10)(inputs)       ❷
hidden = Dense(10)(input)       ❸
output = Dense(1)(hidden)       ❹
model = Model(inputs, output)   ❺

❶ 构建输入向量(13 个元素)

❷ 构建第一个(输入)层(10 个节点)并将其连接到输入向量

❸ 构建下一个(隐藏)层(10 个节点)并将其连接到输入层

❹ 构建输出层(1 个节点)并将其连接到前一个(隐藏)层

❺ 构建神经网络,指定输入和输出层

2.1.8 激活函数

在训练或预测(通过推理)时,层中的每个节点都会向下一层的节点输出一个值。我们不想原样传递值,而有时希望以特定方式更改值。这个过程称为激活函数

想象一个返回结果的函数,比如return result。在激活函数的情况下,我们不会返回result,而是会返回将结果值传递到另一个(激活)函数的结果,例如return A(result``),其中A()是激活函数。从概念上讲,你可以这样想:

def layer(params):
    """ inside are the nodes """
    result = some_calculations
    return A(result)

def A(result):
    """ modifies the result """
    return some_modified_value_of_result

激活函数帮助神经网络更快、更好地学习。默认情况下,当未指定激活函数时,将一层中的值原样(未改变)传递到下一层。最基本的激活函数是阶跃函数。如果值大于 0,则输出 1;否则,输出 0。阶跃函数已经很久没有使用了。

让我们暂停一下,讨论激活函数的目的。你可能已经听说过非线性这个术语。这是什么?对我来说,更重要的是,它不是什么?

在传统统计学中,我们在低维空间中工作,输入和输出之间存在强烈的线性相关性。这种相关性可以通过输入的多项式变换来计算,当变换后,与输出有线性相关性。最基本的一个例子是直线的斜率,表示为y = mx + b。在这种情况下,xy是直线的坐标,我们想要拟合m的值,即斜率,以及b,即直线与 y 轴的交点。

在深度学习中,我们在高维空间中工作,输入和输出之间存在显著的非线性。这种非线性意味着输入不是基于输入的多项式变换均匀地与输出相关(不接近)。例如,假设财产税是房屋价值的固定百分比率(r)。财产税可以用一个函数表示,该函数将税率乘以房屋价值。因此,我们有一个线性(直线)的关系,即价值(输入)与财产税(输出)之间的关系:

tax = f (value) = r × value

让我们看看用于测量地震的对数刻度,其中增加 1 表示释放的能量是 10 倍。例如,4 级地震比 3 级地震强 10 倍。通过对输入功率应用对数变换,我们得到功率和刻度之间的线性关系:

scale = f (power) = log(power)

在非线性关系中,输入序列中的序列与输出有不同的线性关系,在深度学习中,我们希望学习到每个输入序列的分离点以及线性函数。例如,考虑年龄与收入的关系来展示非线性关系。一般来说,幼儿没有收入,小学生有零花钱,青少年有零花钱加上家务钱,稍大一点的青少年通过工作赚钱,然后当他们上大学时,他们的收入降到零!大学毕业后,他们的收入逐渐增加,直到退休,这时收入变得固定。我们可以将这种非线性建模为年龄序列,并学习每个序列的线性函数,如图所示:

收入 = F1(年龄) = 0 对于年龄 [0..5]
收入 = F2(年龄) = c1 对于年龄 [6..9]
收入 = F3(年龄) = c1 + (w1 × 年龄) 对于年龄 [10..15]
收入 = F4(年龄) = (w2 × 年龄) 对于年龄 [16..18]
收入 = F5(年龄) = 0 对于年龄 [19..22]
收入 = F6(年龄) = (w3 × 年龄) 对于年龄 [23..64]
收入 = F7(年龄) = c2 对于年龄 [65+]

激活函数有助于在输入序列中找到非线性分离以及节点的对应聚类,然后学习到与输出的(近)线性关系。大多数情况下,你会使用三种激活函数:修正线性单元(ReLU)、Sigmoid 和 Softmax。

我们将从 ReLU 开始,因为它是除了输出层之外所有模型中最常用的。Sigmoid 和 Softmax 激活将在第 2.2 节和第 2.3 节中介绍。ReLU 如图 2.4 所示,将大于零的值原样通过(不变);否则,它通过零(无信号)。

图像

图 2.4 矩形线性单元函数将所有负值裁剪为零。本质上,任何负值都等同于无信号,或~零。

ReLU 通常用于层之间。虽然早期研究人员在层之间使用了不同的激活函数(例如双曲正切函数),但研究人员发现 ReLU 在训练模型时产生了最佳结果。在我们的例子中,我们将在每一层之间添加一个 ReLU:

from tensorflow.keras import Sequential
from tensorflow.keras.layers import Dense, ReLU

model = Sequential()
model.add(Dense(10, input_shape=(13,)))
model.add(ReLU())                        ❶
model.add(Dense(10))
model.add(ReLU())                        ❶
model.add(Dense(1))

❶ 习惯上,将 ReLU 激活添加到每个非输出层

让我们来看看我们的模型对象内部,看看我们是否构建了我们认为的那样。你可以通过使用summary()方法来完成这个操作。这个方法对于可视化你构建的层以及验证你打算构建的内容是否实际构建了非常有用。它将按顺序显示每个层的摘要:

model.summary()
Layer (type)                 Output Shape              Param #   
=================================================================
dense_56 (Dense)             (None, 10)                140       
___________________________________________________________________
re_lu_18 (ReLU)              (None, 10)                0         
_________________________________________________________________
dense_57 (Dense)             (None, 10)                110       
_________________________________________________________________
re_lu_19 (ReLU)              (None, 10)                0         
_________________________________________________________________
dense_58 (Dense)             (None, 1)                 11        
=================================================================
Total params: 261
Trainable params: 261
Non-trainable params: 0
_________________________________________________________________

对于这个代码示例,你可以看到总结从 10 个节点的密集层(输入层)开始,接着是一个 ReLU 激活函数,然后是一个 10 个节点的第二个密集层(隐藏层),接着是一个 ReLU 激活函数,最后是一个 1 个节点的密集层(输出层)。所以,是的,我们得到了我们预期的结果。

接下来,让我们看看摘要中的参数字段。输入层显示 140 个参数。这是如何计算的?我们有 13 个输入和 10 个节点,所以 13 × 10 是 130。140 是从哪里来的?输入和每个节点之间的每个连接都有一个权重,这些权重加起来是 130。但是每个节点还有一个额外的偏差。这是 10 个节点,所以 130 + 10 = 140。正如我所说的,这是神经网络在训练期间将“学习”的权重和偏差。偏差是一个学习到的偏移量,在概念上等同于线的斜率中的 y 截距(b),这是线与 y 轴相交的地方:

y = b + mx

在下一个(隐藏)层,您可以看到 110 个参数。这是从输入层连接到隐藏层每个节点的 10 个输出(10 × 10)加上隐藏层中节点的 10 个偏差,总共 110 个参数需要学习。

2.1.9 简写语法

TF.Keras 在指定层时提供了简写语法。实际上,您不需要像上一个例子那样在层之间单独指定激活函数。相反,您可以在实例化Dense层时将激活函数指定为一个(关键字)参数。

您可能会问,为什么不总是使用简写语法?正如您将在第三章中看到的,在今天的模型架构中,激活函数位于另一个中间层(批归一化)之前,或者根本位于层之前(预激活批归一化)。以下代码示例与上一个示例完全相同:

from tensorflow.keras import Sequential
from tensorflow.keras.layers import Dense

model = Sequential()
model.add(Dense(10, input_shape=(13,), activation='relu'))     ❶
model.add(Dense(10, activation='relu'))                        ❶
model.add(Dense(1))

❶ 激活函数在层中指定为关键字参数。

让我们调用这个模型的summary()方法:

model.summary()
Layer (type)                Output Shape              Param #   
=================================================================
dense_1 (Dense)             (None, 10)                140       
_________________________________________________________________
dense_2 (Dense)             (None, 10)                110       
_________________________________________________________________
dense_3 (Dense)             (None, 1)                 11        
=================================================================
Total params: 261
Trainable params: 261
Non-trainable params: 0
_________________________________________________________________

嗯,您没有看到层之间的激活,就像在之前的例子中那样。为什么?这是因为summary()方法显示输出的方式有点奇怪。它们仍然存在。

2.1.10 使用优化器提高准确度

一旦您完成了神经网络前馈部分的构建,就像我们在简单示例中所做的那样,您需要添加一些东西来训练模型。这是通过compile()方法完成的。这一步在训练期间添加了反向传播。让我们定义并探讨这个概念。

每次我们将数据(或数据批次)通过神经网络向前传递时,它都会计算预测结果中的误差(称为损失),这些误差与实际值(称为标签)进行比较,并使用这些信息来逐步调整节点的权重和偏差。对于模型来说,这个过程就是学习的过程。

如我所说,误差的计算称为损失。它可以以多种方式计算。由于我们设计的示例神经网络是一个回归器(意味着输出,房价,是一个实数值),我们希望使用最适合回归器的损失函数。通常,对于这种类型的神经网络,我们使用均方误差方法来计算损失。在 Keras 中,compile()方法接受一个(关键字)参数loss,用于指定我们想要如何计算损失。我们将传递给它mse(代表均方误差)的值。

过程中的下一步是使用在反向传播过程中出现的优化器来最小化损失。优化器基于梯度下降;可以选择不同的梯度下降算法变体。这些术语一开始可能难以理解。本质上,每次我们通过神经网络传递数据时,我们都会使用计算出的损失来决定如何改变层中的权重和偏置。目标是逐渐接近权重和偏置的正确值,以准确预测或估计每个示例的标签。这个过程被称为收敛。优化器的任务是计算权重的更新,以逐渐接近正确的值并达到收敛。

随着损失的逐渐降低,我们正在收敛。当损失达到平台期后,我们实现了收敛。结果是神经网络的准确率。在梯度下降之前,早期 AI 研究人员使用的方法可能需要超级计算机数年才能在非平凡问题上找到收敛。梯度下降算法的发现之后,这个时间缩短到了几天、几小时,甚至在普通计算能力上只需几分钟。让我们跳过数学,只说梯度下降是数据科学家的一种魔法,它使得在良好的局部最优解上收敛成为可能。

对于我们的回归器神经网络,我们将使用rmsprop方法(均方根属性):

model.compile(loss='mse', optimizer='rmsprop')

现在我们已经完成了您第一个可训练神经网络的构建。在开始准备数据和训练模型之前,我们将介绍更多关于神经网络的设计。这些设计使用了之前提到的两种激活函数:sigmoid 和 softmax。

2.2 DNN 二分类器

DNN 的另一种形式是二分类器,也称为逻辑分类器。当我们使用二分类器时,我们希望神经网络预测输入是否是某物。输出可以有两种状态或类别:是/否、真/假、0/1 等等。

例如,假设我们有一个信用卡交易数据集,每个交易都被标记为欺诈或非欺诈。记住,标签是我们想要预测的内容。

总体而言,我们迄今为止学到的设计方法没有改变,除了单节点输出层的激活函数以及损失/优化器方法。与回归器不同,我们将在输出节点上使用一个sigmoid激活函数,而不是线性激活函数。sigmoid 函数将所有值压缩到 0 和 1 之间,如图 2.5 所示。随着值远离中心,它们会迅速移动到 0 和 1 的极端(渐近线)。

图像

图 2.5 sigmoid 函数

我们现在将使用我们讨论过的两种风格来编写这段代码。让我们从之前的代码示例开始,其中我们将激活函数指定为一个(关键字)参数。在这个例子中,我们在输出Dense层中添加了参数activation= 'sigmoid',以便将最终节点的输出结果通过 sigmoid 函数传递。

接下来,我们将我们的损失参数更改为binary_crossentropy。这是在二分类器中通常使用的损失函数:

from tensorflow.keras import Sequential
from tensorflow.keras.layers import Dense

model = Sequential()
model.add(Dense(10, input_shape=(13,), activation='relu'))
model.add(Dense(10, activation='relu'))
model.add(Dense(1, activation='sigmoid'))      ❶

model.compile(loss='binary_crossentropy',
              optimizer='rmsprop',
              metrics=['accuracy'])            ❷

❶ Sigmoid 函数用于二分类。

❷ 二分类器中损失函数和优化器的常见约定

并非所有激活函数都有自己的类,例如ReLU。这是 TF.Keras 框架中的另一个特性。相反,一个名为Activation的类可以创建任何受支持的激活函数。参数是预定义的激活函数名称。在我们的例子中,relu代表修正线性单元,而sigmoid代表 sigmoid。以下代码与前面的代码执行相同的操作:

from tensorflow.keras import Sequential
from tensorflow.keras.layers import Dense, Activation

model = Sequential()
model.add(Dense(10, input_shape=(13,)))
model.add(Activation('relu'))     ❶
model.add(Dense(10))
model.add(Activation('relu'))     ❶
model.add(Dense(1))
model.add(Activation('sigmoid')   ❶

model.compile(loss='binary_crossentropy',
              optimizer='rmsprop',
              metrics=['accuracy'])

❶ 可以使用 Activation()方法指定激活函数。

现在,我们将使用功能 API 方法重写相同的代码。请注意,我们反复使用了变量x。这是一个常见的做法。我们希望避免创建大量的一次性使用变量。由于我们知道在这种类型的神经网络中,除了输入和输出之外,每一层的输出都是下一层的输入(或激活),因此我们持续使用x作为连接变量。

到现在为止,你应该开始熟悉这两种方法:

from tensorflow.keras import Model, Input
from tensorflow.keras.layers import Dense, ReLU, Activation

inputs = Input((13,))
x = Dense(10)(inputs)
x = Activation('relu')(x)              ❶
x = Dense(10)(x)
x = Activation('relu')(x)              ❶
x = Dense(1)(x)
output = Activation('sigmoid')(x)      ❶
model = Model(inputs, output)

model.compile(loss='binary_crossentropy',
              optimizer='rmsprop',
              metrics=['accuracy'])

❶ 使用功能 API 指定的激活函数

2.3 DNN 多分类器

假设我们有一组身体测量值(例如身高和体重)以及与每组测量值相关的性别,我们想要预测某人是否是婴儿、幼儿、儿童、青少年或成人。我们希望我们的模型能够从多个类别或标签中进行分类或预测——在这个例子中,我们有总共五个年龄类别的类别。为此,我们可以使用 DNN 的另一种形式,称为多分类器

我们已经可以看到我们将会有一些复杂性。例如,成年男性平均身高比女性高。但在青春期前,女孩往往比男孩高。我们知道男性在成年早期比在青少年时期体重增加得更多,但女性平均来说不太可能增重。因此,我们应该预料到在青春期前预测女孩、青春期预测男孩和成年预测女性时会出现问题。

这些问题是非线性的例子;特征与预测之间的关系不是线性的。相反,这种关系可以被分解为不连续的线性段。这正是神经网络擅长的类型的问题。

让我们增加一个第四个测量值,即鼻子的表面积。例如,《整形外科学年鉴》(Annals of Plastic Surgery)的研究(pubmed.ncbi.nlm.nih.gov/3579170/)表明,对于女孩和男孩来说,鼻子的表面积从 6 岁到 18 岁持续增长,并在 18 岁时基本停止增长。

因此,现在我们有四个特征,一个由五个类别组成的标签。在下一个例子中,我们将改变我们的输入向量到 4,以匹配特征的数量,并将我们的输出层改变为 5 个节点,以匹配类别的数量。在这种情况下,每个输出节点对应一个独特的类别(婴儿、幼儿等等)。我们希望训练神经网络,使每个输出节点输出一个介于 0 到 1 之间的值作为预测。例如,0.75 意味着该节点有 75%的信心认为预测是相应的类别。

每个输出节点将独立学习和预测其对应类别的置信度。然而,这个过程导致了一个问题:因为值是独立的,它们不会加起来等于 1(100%)。这就是 softmax 函数有用的地方。这个数学函数将一组值(输出层的输出)压缩到 0 到 1 的范围内,同时确保所有值加起来等于 1。完美。这样,我们可以选择具有最高值的输出节点,并说出预测的内容以及该预测的置信度。所以如果最高值是 0.97,我们可以说我们在预测中估计的置信度为 97%。

图 2.6 是一个多类模型的图示。在这个例子中,输出层有两个节点,每个节点对应预测一个不同的类别。每个节点独立地预测它对输入属于相应类别的信念强度。这两个独立的预测随后通过 softmax 激活函数,将值压缩到总和为 1(100%)。在这个例子中,一个类别以 97%的置信度被预测,另一个以 3%的置信度被预测。

图片

图 2.6 为多类分类器的输出层添加 softmax 激活有助于提高模型预测的置信度。

以下代码展示了构建多类分类器 DNN 的示例。我们首先设置我们的输入层和输出层,分别使用多个特征和多个类别。然后我们将激活函数从sigmoid改为softmax。接下来,我们将损失函数设置为categorical_crossentropy。这通常是最推荐用于多类分类的。我们不会深入探讨交叉熵背后的统计学原理,除了交叉熵计算多个概率分布的损失。在二元分类器中,我们有两个概率分布并使用binary_crossentropy计算;而在多类分类器中,我们使用categorical_crossentropy来计算多个(多于两个)概率分布的损失。

最后,我们将使用一种流行且广泛使用的梯度下降变体,称为Adam 优化器adam)。Adam结合了其他方法的一些方面,如rmsprop均方根)和adagrad自适应梯度),以及自适应学习率。它通常被认为是适用于各种神经网络的最佳优化器:

from tensorflow.keras import Sequential
from tensorflow.keras.layers import Dense

model = Sequential()
model.add(Dense(10, input_shape=(4,), activation='relu'))    ❶
model.add(Dense(10, activation='relu'))
model.add(Dense(5, activation='softmax'))                    ❷

model.compile(loss='categorical_crossentropy', 
        optimizer='adam', 
        metrics=['accuracy'])                                ❸

❶ 输入层为四个特征的 1D 向量

❷ 在输出层,多类分类器使用 softmax 激活函数。

❸ 多类分类器损失函数和优化器的常用约定

2.4 DNN 多标签多类分类器

现在,让我们看看预测每个输入的两个或更多类别(标签)。让我们使用我们之前的例子,预测某人是否是婴儿、幼儿、儿童、青少年或成人。这次,我们将从特征中移除性别,并将其作为要预测的一个标签。我们的输入将是身高、体重和鼻表面面积,我们的输出将是两个类别:年龄类别(婴儿、幼儿等)和性别(男性或女性)。一个预测示例可能看起来像这样:

[身高,体重,鼻表面面积] -> 神经网络 -> [儿童,女性]

为了从多个输入中预测两个或更多标签,正如我们在这里所做的那样,我们使用——你猜对了——一个多标签多类分类器。为此,我们需要对我们之前的多类分类器做一些修改。在我们的输出层,我们的输出类别数是所有输出类别的总和。在这种情况下,我们之前有五个,现在我们再增加两个用于性别,总共七个。我们还希望将每个输出类别视为二元分类器,这意味着我们希望得到一个是/否类型的答案,因此我们将激活函数改为sigmoid。对于我们的编译语句,我们模仿了本章中更简单的 DNN 所做的事情,将损失函数设置为binary_crossentropy,并将优化器设置为rmsprop。你可以在这里看到每个步骤的实现:

from tensorflow.keras import Sequential
from tensorflow.keras.layers import Dense

model = Sequential()
model.add(Dense(10, input_shape=(3,), activation='relu'))    ❶
model.add(Dense(10, activation='relu'))
model.add(Dense(7, activation='sigmoid'))                    ❷

model.compile(loss='binary_crossentropy',                    ❸
        optimizer='rmsprop', 
        metrics=['accuracy'])

❶ 输入向量仅仅是身高、体重和鼻表面面积

❷ 将年龄人口统计和性别类别合并为一个分类器输出

❸ 使用 sigmoid 激活函数和二元交叉熵损失函数来独立预测每个类别为 0 或 1,或者接近 0 或 1。

你看到这个设计可能存在潜在问题吗?让我们假设我们输出两个具有最高值(从 0 到 1)的类别(标签),即最自信的预测。如果在一个预测中,神经网络以高置信度预测了青少年和青少年,以及男性和女性以较低置信度,会怎样呢?嗯,我们可以通过一些后逻辑来修复这个问题,通过从前五个输出类别(年龄人口统计)中选择最高置信度,并从最后两个类别(性别)中选择最高置信度。换句话说,我们将七个输出类别分为两个相应的类别,并从每个类别中选择置信度最高的输出。

功能 API 使我们能够在不添加任何后逻辑的情况下修复这个问题。在这种情况下,我们想要用两个并行输出层替换结合两个类别集的输出层,一个用于第一组类别(年龄类别),另一个用于第二组类别(性别)。您可以在图 2.7 中看到这个设置。

图片

图 2.7 多标签多类分类器的两个并行输出层

在下面的代码示例中,只有最终输出层与之前的代码列表不同。在这里,我们不是只有一个输出层,而是有两个并行层。

然后当我们使用Model类将所有内容组合在一起时,我们传递的是一个输出层的列表:[output1, output2]。最后,由于每个输出层都做出独立的预测,我们可以将它们作为多类分类器来处理——这意味着我们返回使用categorical_crossentropy作为损失函数和adam作为优化器。

这种多标签多类分类器的设计也可以称为具有多个输出的神经网络,其中每个输出学习不同的任务。由于我们将训练这个模型进行多个独立的预测,这也被称为多任务模型

from tensorflow.keras import Input, Model
from tensorflow.keras.layers import Dense

inputs = Input((3,))
x = Dense(10, activation='relu')(inputs)
x = Dense(10, activation='relu')(x)
output1 = Dense(5, activation='softmax')(x)     ❶
output2 = Dense(2, activation='softmax')(x)     ❶
model = Model(inputs, [output1, output2])

model.compile(loss='categorical_crossentropy', 
        optimizer='adam', 
        metrics=['accuracy'])

❶ 每个类别都有一个独立的输出层,并得到相同输入的副本。

那么哪种设计对于多标签多类分类器是正确的(或更好的)?这取决于应用。如果所有类别都属于单个类别——例如年龄人口统计——使用第一个模式,即单一任务。如果类别来自不同的类别——例如年龄人口统计和性别——使用第二个模式,即多任务。在这个例子中,我们使用多任务模式,因为我们想要学习两个类别作为输出。

2.5 简单图像分类器

你现在已经看到了 DNN 的基本类型以及如何使用 TF.Keras 来编码它们。所以现在让我们构建我们的第一个简单的图像分类模型。

在计算机视觉中,神经网络被用于图像分类。让我们从基础知识开始。对于小灰度图像,如图 2.8 所示,我们可以使用一个类似于我们之前描述的多类分类器的深度神经网络来预测年龄人口统计。这种类型的 DNN 已经在使用修改后的国家标准与技术研究院(MNIST)数据集的文献中广泛发表,这是一个用于识别手写数字的数据集。该数据集由 28 × 28 像素大小的灰度图像组成。每个像素由一个从 0 到 255 的整数值表示(0 是黑色,255 是白色,中间的值是灰色)。

图 2.8 灰度图像的矩阵表示

尽管如此,我们需要进行一个修改。灰度图像是一个矩阵(二维数组)。将矩阵想象成一个网格,大小为高度 × 宽度,其中宽度代表列,高度代表行。然而,深度神经网络将向量作为输入,这是一个一维数组。那么我们能做什么呢?我们可以将二维矩阵展平成一维向量。

2.5.1 展平

我们将通过将每个像素视为一个特征来进行分类。以 MNIST 数据集为例,28 × 28 的图像将有 784 个像素,因此有 784 个特征。我们通过展平将矩阵(二维)转换为向量(一维)。

展平是将每一行按顺序放入向量的过程。因此,向量从像素的第一行开始,然后是第二行像素,以此类推,最后以最后一行像素结束。图 2.9 展示了将矩阵展平成向量的过程。

图 2.9 将矩阵展平成向量

你可能会在这个时候问,为什么我们需要将二维矩阵展平成一维向量?这是因为在一个深度神经网络中,密集层的输入必须是一维向量。在下一章中,当我们介绍卷积神经网络(CNN)时,你会看到一些使用二维输入的卷积层示例。

在下一个示例中,我们在神经网络的开头添加一个层来展平输入,使用Flatten类。剩余的层和激活对于 MNIST 数据集来说是典型的。请注意,Flatten对象的输入形状是二维形状(28, 28)。该对象输出的形状将是一维的(784,)

from tensorflow.keras import Sequential
from tensorflow.keras.layers import Dense, Flatten, ReLU, Activation

model = Sequential()
model.add(Flatten(input_shape=(28,28)))      ❶
model.add(Dense(512, activation='relu'))     ❷
model.add(Dense(512, activation='relu'))     ❷
model.add(Dense(10, activation='softmax'))

model.compile(loss='categorical_crossentropy', 
              optimizer='adam', 
              metrics=['accuracy'])

❶ 将二维灰度图像展平成一维向量作为 DNN 的输入。

❷ MNIST 通常作为输入,并在 128、256 和 512 个节点之间有一个隐藏的密集层。

现在我们通过使用summary()方法来查看层。正如你所见,摘要中的第一层是展平层,显示该层的输出是 784 个节点。这正是我们想要的。同时注意网络在训练过程中需要学习的参数数量,接近 70 万个:

model.summary()
Layer (type)                 Output Shape              Param #   
=================================================================
flatten_1 (Flatten)          (None, 784)               0         
_________________________________________________________________
dense_69 (Dense)             (None, 512)               401920    
_________________________________________________________________
re_lu_20 (ReLU)              (None, 512)               0         
_________________________________________________________________
dense_70 (Dense)             (None, 512)               262656    
_________________________________________________________________
re_lu_21 (ReLU)              (None, 512)               0         
_________________________________________________________________
dense_71 (Dense)             (None, 10)                5130      
_________________________________________________________________
activation_10 (Activation)   (None, 10)                0         
=================================================================
Total params: 669,706
Trainable params: 669,706
Non-trainable params: 0
_________________________________________________________________

2.5.2 过拟合和 dropout

在训练过程中,数据集被分为训练数据和测试数据(也称为保留数据)。在神经网络的训练过程中只使用训练数据。一旦神经网络达到收敛,我们在第四章中详细讨论了这一点,训练就会停止,如图 2.10 所示。

图片

图 2.10 收敛发生在损失曲线平缓时。

之后,为了获得模型在训练数据上的准确率,训练数据再次正向传递,但不启用反向传播,因此没有学习。这也被称为在训练好的神经网络中运行推理预测模式。在训练/测试分割中,之前保留并未作为训练一部分的测试数据再次正向传递,不启用反向传播,以获得准确率。

为什么我们要将测试数据从训练数据中分割出来并保留?理想情况下,训练数据和测试数据的准确率将几乎相同。实际上,测试数据的准确率总是略低。这有一个原因。

一旦达到收敛,持续将训练数据通过神经网络,会导致神经元越来越多地记住训练样本,而不是泛化到训练过程中从未见过的样本。这被称为过拟合。当神经网络对训练数据过拟合时,你将获得很高的训练准确率,但在测试/评估数据上的准确率会显著降低。

即使没有训练到收敛,你也会有一些过拟合。数据集/问题可能存在非线性(这就是为什么你使用神经网络)。因此,单个神经元将以不同的速率收敛。在测量收敛时,你是在看整个系统。在此之前,一些神经元已经收敛,而持续的训练将导致它们过拟合。这就是为什么测试/评估准确率总是至少略低于训练数据的准确率。

为了解决训练神经网络时的过拟合问题,我们可以使用正则化。这会在训练过程中添加少量的随机噪声,以防止模型记住样本,并在模型训练后更好地泛化到未见过的样本。

最基本的正则化类型被称为dropout。Dropout 就像遗忘。当我们教小孩子时,我们使用死记硬背,就像我们要求他们记住 1 到 12 的乘法表。我们让他们反复练习,反复练习,直到他们能够 100%正确地按任何顺序背诵出正确的答案。但如果我们问他们“13 乘以 13 等于多少?”他们可能会给我们一个茫然的表情。在这个时候,乘法表已经在他们的记忆中过拟合了。每个乘法对的答案,即样本,被硬编码在大脑的记忆细胞中,他们没有方法将那种知识扩展到 1 到 12 之外。

随着孩子们的成长,我们转向抽象。我们不是教孩子记忆答案,而是教他们如何计算答案——尽管他们可能会犯计算错误。在这个第二个教学阶段,一些与死记硬背相关的神经元会死亡。这些神经元的死亡(意味着遗忘)与抽象的结合使得孩子的头脑能够进行概括,现在可以解决任意的乘法问题,尽管有时他们会犯错误,甚至在 12×12 乘法表中,以某种概率分布。

神经网络中的 dropout 技术模拟了这种向抽象和学习的迁移过程,通过概率分布的不确定性进行学习。在任意层之间,你可以添加一个 dropout 层,在那里你指定一个百分比(介于 0 和 1 之间)来遗忘。节点本身不会被丢弃,而是在训练过程中,每个前向传递的随机选择不会传递信号。随机选择的节点的信号将被遗忘。例如,如果你指定 50%的 dropout(0.5),在每次数据的前向传递中,随机选择一半的节点将不会发送信号。

优势在于,我们最小化了局部过拟合的影响,同时持续训练神经网络以实现整体收敛。dropout 的常见做法是设置 20%到 50%之间的值。

在下面的代码示例中,我们在输入层和隐藏层中添加了 50%的 dropout。注意,我们在激活函数(ReLU)之前放置了它。由于 dropout 会导致节点信号为零,因此添加Dropout层在激活函数之前或之后无关紧要:

from tensorflow.keras import Sequential
from tensorflow.keras.layers import Dense, Flatten, ReLU, Activation, Dropout

model = Sequential()
model.add(Flatten(input_shape=(28,28)))
model.add(Dense(512))
model.add(Dropout(0.5))     ❶
model.add(ReLU())

model.add(Dense(512))
model.add(Dropout(0.5))     ❶
model.add(ReLU())

model.add(Dense(10))
model.add(Activation('softmax'))

model.compile(loss='categorical_crossentropy', 
              optimizer='adam', 
              metrics=['accuracy'])

❶ 添加 dropout 是为了防止过拟合。

摘要

  • 神经网络的输入和输入层不是同一回事,也不需要相同的大小。输入是样本的特征,而输入层是学习预测相应标签的第一个权重和偏差层。

  • 深度神经网络在输入层和输出层之间有一个或多个层,这些层被称为隐藏层。用编程函数作类比,输入层是函数的参数,输出层是函数的返回值,而隐藏层是函数内部的代码,它将输入参数转换为输出返回值。

  • 神经网络是有向无环图,数据从输入层正向传递到输出层。

  • 激活函数,如 ReLU 和 softmax,压缩了层的输出信号,研究人员发现这有助于模型更好地学习。

  • 优化器的作用是更新权重,从当前批次的损失中,以便后续批次的损失更小。

  • 回归器使用线性激活函数来预测连续的实数值,例如预测房屋的销售价格。

  • 二元分类器使用 sigmoid 激活函数来预测二元状态:真/假,1/0,是/否。

  • 多类分类器使用 softmax 激活函数从一组类别中预测一个类别,例如预测一个人的年龄人口统计。

  • 顺序型 API 易于入门,但它的局限性在于不支持模型中的分支。

  • 功能型 API 比顺序型 API 更适合用于生产模型。

  • 当模型在训练过程中记住训练样本时,会发生过拟合,这阻止了模型泛化到它未训练过的样本。正则化方法在训练过程中注入少量随机噪声,这已被证明在防止记忆化方面是有效的。

3 卷积和残差神经网络

本章涵盖了

  • 理解卷积神经网络的结构

  • 构建 ConvNet 模型

  • 设计和构建 VGG 模型

  • 设计和构建残差网络模型

第二章介绍了深度神经网络(DNN)背后的基本原理,这是一种基于密集层的网络架构。我们还演示了如何使用密集层制作一个简单的图像分类器,并讨论了尝试将 DNN 扩展到更大图像尺寸时的局限性。使用卷积层进行特征提取和学习的神经网络,称为“卷积神经网络”(CNN),使得将图像分类器扩展到实际应用成为可能。

本章涵盖了早期 SOTA 卷积神经网络的架构设计模式和设计模式的发展。本章按其演变的顺序,介绍了三个设计模式:

  • ConvNet

  • VGG

  • 残差网络

这些设计模式中的每一个都对当今现代 CNN 设计做出了贡献。ConvNet,以 AlexNet 作为早期例子,引入了通过池化交替进行特征提取和降维的模式,并随着层深度的增加而逐次增加滤波器的数量。VGG 将卷积分组到由一个或多个卷积组成的块中,并在块的末尾延迟降维操作。残差网络进一步将分组块分组,将降维延迟到组末,并使用特征池化以及池化进行降维,以及分支路径——即恒等链接——的概念,用于块之间的特征重用。

3.1 卷积神经网络

早期卷积神经网络是一种可以看作由两部分组成的神经网络,即前端和后端。后端是一个深度神经网络(DNN),我们之前已经讨论过。名称“卷积神经网络”来源于前端,称为“卷积层”。前端充当预处理器的角色。DNN 后端执行“分类学习”。CNN 前端将图像数据预处理成 DNN 可以学习的计算上实用的形式。CNN 前端执行“特征学习”。

图 3.1 描述了一个 CNN,其中卷积层作为前端从图像中学习特征,然后将这些特征传递给后端 DNN 进行基于特征的分类。

图片

图 3.1 卷积作为前端,用于从图像中学习特征,然后将这些特征传递给后端 DNN 进行分类。

本节涵盖了组装这些早期卷积神经网络的基本步骤和组件。虽然我们没有特别介绍 AlexNet,但其作为 2012 年 ILSVRC 图像分类冠军的成功,可以被视为研究人员探索和发展卷积设计的催化剂。AlexNet 前端的组装和设计原则被纳入了最早的设计模式,即 ConvNet,以供实际应用。

3.1.1 为什么我们使用 CNN 而不是 DNN 进行图像模型

一旦我们处理到更大的图像尺寸,对于深度神经网络(DNN)来说,像素的数量在计算上变得过于昂贵,以至于无法实现。假设你有一个 1MB 的图像,其中每个像素由一个字节(0..255 值)表示。在 1MB 中,你有 100 万个像素。这将需要一个包含 100 万个元素的输入向量。假设输入层有 1024 个节点。仅在输入层就需要更新和学习的权重数量就超过 10 亿(100 万 × 1024)!天哪。回到超级计算机和一生的计算能力。

让我们将其与我们第二章的 MNIST 示例进行对比,输入层有 784 像素 × 512 节点。这意味着有 40 万个权重需要学习,这比 10 亿小得多。你可以在你的笔记本电脑上完成前者,但不要尝试后者。

在接下来的小节中,我们将探讨 CNN 网络组件是如何解决原本可能是一个计算上不切实际的权重数量(也称为参数)的问题,这对于图像分类来说。

3.1.2 下采样(调整大小)

为了解决参数过多的问题,一种方法是通过称为下采样的过程降低图像的分辨率。但如果我们过度降低图像分辨率,在某个点上,我们可能失去清晰区分图像中内容的能力;它变得模糊,或者有伪影。因此,第一步是将分辨率降低到我们仍然有足够细节的水平。

对于日常计算机视觉来说,一个常见的约定是 224 × 224 像素。我们通过调整大小来实现这一点。即使在这个较低的分辨率和三个通道的颜色图像,以及 1024 节点的输入层,我们仍然有 1.54 亿个权重需要更新和学习(224 × 224 × 3 × 1024);参见图 3.2。

图片

图 3.2 输入层在调整大小前后的参数数量(图片来源:Pixabay,Stockvault)

因此,在引入使用卷积层之前,使用神经网络在真实世界图像上进行训练是不可能的。首先,卷积层是神经网络的前端,它将图像从基于高维像素的图像转换为基于显著低维度的特征图像。这些显著低维度的特征可以成为 DNN 的输入向量。因此,卷积前端是图像数据和 DNN 之间的前端。

但假设我们拥有足够的计算能力,只使用深度神经网络(DNN)并在输入层学习 1.54 亿个权重,就像我们前面的例子一样。嗯,像素在输入层的位置非常依赖。所以,我们学会了识别图片左边的猫。但然后我们把猫移到图片中间。现在我们必须学会从一组新的像素位置识别猫——哇!现在把它移到右边,加上躺着的猫、在空中跳跃等等。

从各种角度学习识别图像被称为平移不变性。对于基本的二维渲染,如数字和字母,这是可行的(暴力法),但对于其他所有东西,这都不行。早期的研究表明,当你将初始图像展平成 1D 向量时,你失去了构成被分类对象的特征的空间关系,比如猫。即使你成功地训练了一个 DNN,比如基于像素在图片中间识别猫,那么如果这个对象在图像中移动了位置,这个 DNN 不太可能识别出该对象。

接下来,我们将讨论卷积如何学习特征而不是像素,同时保留二维形状以进行空间关系,从而解决了这个问题。

3.1.3 特征检测

对于这些更高分辨率和更复杂的图像,我们通过检测和分类特征来进行识别,而不是对像素位置进行分类。可视化一张图像,问问自己是什么让你识别出那里的东西?超越问“那是一个人、一只猫还是一栋建筑?”这样的高级问题,去问为什么你能区分站在建筑物前面的人,或者从他们手中分离出一只猫。你的眼睛正在识别低级特征,如边缘、模糊和对比度。

如图 3.3 所示,这些低级特征被构建成轮廓,然后是空间关系。突然之间,眼睛/大脑有了识别鼻子、耳朵、眼睛的能力——感知到那是一只猫脸,或者那是一个人脸。

图片

图 3.3 人眼识别低级特征到高级特征的流程

在计算机中,卷积层负责在图像中进行特征检测。每个卷积由一组过滤器组成。这些过滤器是N × M的值矩阵,用于检测特征可能存在的情况。把它们想象成小窗口。它们在图像上滑动,并在每个位置,将过滤器与该位置的像素值进行比较。这种比较是通过矩阵点积完成的,但在这里我们将跳过统计。重要的是,这个操作的结果将生成一个值,表示在图像的该位置检测到特征的程度有多强。例如,4 的值表示特征的检测比 1 的值更强。

在神经网络之前,成像科学家手动设计这些过滤器。今天,过滤器以及神经网络中的权重都是学习得到的。在卷积层中,我们指定过滤器的尺寸和过滤器的数量。典型的过滤器尺寸是 3 × 3 和 5 × 5,其中 3 × 3 是最常见的。过滤器的数量变化更多,但它们通常是 16 的倍数,例如浅层 CNN 的 16、32 或 64,以及深层 CNN 的 256、512 和 1024。

此外,我们指定一个步长,这是过滤器在图像上滑动的速率。例如,如果步长是 1,则过滤器每次前进 1 个像素;因此,过滤器在 3 × 3 的过滤器中会部分重叠前一步(并且因此步长为 2 的过滤器也是如此)。步长为 3 没有重叠。最常见的方法是使用步长 1 和 2。每个学习到的过滤器都会产生一个特征图,这是一个映射,表示在图像的特定位置检测到特征的程度,如图 3.4 所示。

图 3.4 过滤器在图像上滑动以产生检测到的特征的特征图。

过滤器可以在到达图像边缘时停止,或者继续直到覆盖最后一列,如图 3.5 所示。前者称为无填充。后者称为填充。当过滤器部分超出边缘时,我们想要为这些虚拟像素提供一个值。典型值是零或相同——与最后一列相同。

图 3.5 过滤器停止的位置取决于填充。

当你有多个卷积层时,一个常见的做法是在深层层中保持相同的过滤器数量或增加过滤器数量,并在第一层使用步长 1,在深层层使用步长 2。过滤器数量的增加提供了从粗略检测特征到在粗略特征内进行更详细检测的手段。步长的增加抵消了保留数据大小的增加;这个过程被称为特征池化,其中特征图被下采样。

CNNs 使用两种类型的下采样:池化和特征池化。在池化中,使用一个固定的算法来下采样图像数据的大小。在特征池化中,学习特定数据集的最佳下采样算法:

更多的过滤器 => 更多的数据

更大的步长 => 更少的数据

我们将在下一节更详细地研究池化。我们将在 3.2 节深入研究特征池化。

3.1.4 池化

尽管生成的每个特征图通常与图像大小相等或更小,但由于我们生成了多个特征图(例如,16 个),总数据量会增加。哎呀!下一步是减少总数据量,同时保留检测到的特征及其对应的空间关系。

正如我所说的,这一步被称为池化,这与下采样(或子采样)相同。在这个过程中,特征图通过在特征图内部使用最大值(下采样)或平均像素平均值(子采样)调整到更小的维度。在池化中,如图 3.6 所示,我们将要池化的区域大小设置为N × M矩阵以及步长。常见的做法是 2 × 2 的池化大小和 2 的步长。这将导致像素数据的 75%减少,同时仍然保留足够的分辨率,以确保检测到的特征不会丢失。

图片

图 3.6 池化将特征图调整到更小的维度。

另一种看待池化的方式是在信息增益的背景下。通过减少不需要或不那么有信息的像素(例如,背景中的像素),我们正在减少熵,并使剩余的像素更有信息量。

3.1.5 展平

记住,深度神经网络以向量作为输入——数字的一维数组。在池化图的情况下,我们有一个 2D 矩阵的列表(复数),因此我们需要将它们转换成一个单一的 1D 向量,然后它成为 DNN 的输入向量。这个过程被称为展平:我们将 2D 矩阵的列表展平成一个单一的 1D 向量。

这相当直接。我们以第一个池化图的第一行为 1D 向量的开始。然后我们取第二行并将其附加到末尾,接着是第三行,以此类推。然后我们继续到第二个池化图,并执行相同的操作,持续地将每一行附加到我们完成最后一个池化图。只要我们通过池化图遵循相同的顺序,检测到的特征之间的空间关系将在训练和推理(预测)过程中保持一致,如图 3.7 所示。

例如,如果我们有 16 个大小为 20 × 20 的池化图,每个池化图有 3 个通道(例如,彩色图像中的 RGB 通道),我们的 1D 向量大小将是 16 × 20 × 20 × 3 = 19,200 个元素。

图片

图 3.7 当池化图被展平时,空间关系得以保持。

3.2 CNN 的 ConvNet 设计

现在我们开始使用 TF.Keras。让我们假设一个假设但与现实世界相似的情况。贵公司的应用程序支持人机界面,并且目前可以通过语音激活访问。你被分配了一个开发概念证明的任务,以展示将手语纳入人机界面,以符合联邦无障碍法律。相关的法律,1973 年康复法案的第五百零三部分,“禁止联邦承包商和分包商在就业中歧视残疾人,并要求雇主采取积极行动招募、雇佣、晋升和留住这些个人”(www.dol.gov/agencies/ofccp/section-503)。

你不应该假设你可以通过使用任意标记的手语图像和图像增强来训练模型。数据、其准备和模型的设计必须与实际的“野外”部署相匹配。否则,除了导致令人失望的准确率外,模型可能会学习噪声,使其暴露于可能导致意外后果的假阳性,并且容易受到黑客攻击。第十二章将更详细地介绍这一点。

对于我们的概念验证,我们将仅展示识别英文字母(从 A 到 Z)的手势。此外,我们假设个人将直接在摄像头前从正面的角度进行手势。我们不希望模型学习,例如,手势者的种族。因此,出于这个和其他原因,颜色并不重要。

为了让我们的模型不学习颜色(噪声),我们将以灰度模式对其进行训练。我们将设计模型以在灰度下学习和预测,这个过程也被称为推理。我们希望模型学习的是手的轮廓。我们将设计模型为两部分,即卷积前端和 DNN 后端,如图 3.8 所示。

图片

图 3.8 带有卷积前端和 DNN 后端的 ConvNet

以下代码示例是用顺序 API 方法编写的,并且是长格式;激活函数是通过相应的方法指定的(而不是在添加相应层时将其作为参数指定)。

我们首先通过使用Conv2D类对象添加一个 16 个滤波器的卷积层作为第一层。回想一下,滤波器的数量等于将要生成的特征图的数量(在这种情况下,16)。每个滤波器的大小将是 3 × 3,这是通过kernel_size参数指定的,步长为 2,由strides参数指定。

注意,对于strides,指定了一个(2, 2)的元组而不是单个值 2。第一个数字是水平步长(横跨),第二个数字是垂直步长(向下)。这些水平和垂直值通常是相同的,因此我们通常说“步长为 2”而不是“2 × 2 步长”。

你可能会问,Conv2D这个名字中的 2D 部分是什么意思?2D 表示卷积层的输入将是一堆矩阵(二维数组)。对于本章,我们将坚持使用 2D 卷积,这是计算机视觉中的常见做法。

让我们计算从这个层输出的尺寸将会是多少。如您所回忆的,在步长为 1 的情况下,每个输出特征图的大小将与图像相同。有 16 个滤波器,那将是输入的 16 倍。但由于我们使用了步长为 2(特征池化),每个特征图将减少 75%,因此总输出大小将是输入的 4 倍。

卷积层的输出随后通过 ReLU 激活函数,然后传递给最大池化层,使用MaxPool2D类对象。池化区域的大小将是 2 × 2,由参数pool_size指定,步长为 2,由参数strides指定。池化层将特征图减少 75%到池化特征图。

让我们计算池化层之后的输出大小。我们知道输入的大小是输入大小的 4 倍。再额外减少 75%,输出大小与输入相同。那么我们在这里得到了什么?首先,我们训练了一组滤波器来学习第一组粗糙特征(从而获得信息增益),消除了非必要的像素信息(减少熵),并学会了下采样特征图的最佳方法。嗯,看起来我们得到了很多。

然后将池化特征图展平,使用Flatten类对象,形成一个 1D 向量,用于输入到深度神经网络(DNN)。我们将简要介绍一下参数padding。对于我们的目的来说,可以说在几乎所有情况下,你都会使用值same;只是默认值是valid,因此你需要明确地添加它。

最后,我们为我们的图像选择一个输入大小。我们希望尽可能减小大小,同时不丢失识别手部轮廓所需的特征检测。在这种情况下,我们选择 128 × 128。Conv2D类有一个特性:它总是要求指定通道数,而不是默认为灰度图的 1;因此我们将其指定为(128, 128, 1)而不是(128, 128)。

下面是代码:

from tensorflow.keras.models import Sequential
from tensorflow.keras.layers import Dense, ReLU, Activation
from tensorflow.keras.layers import Conv2D, MaxPooling2D, Flatten

model = Sequential()
model.add(Conv2D(16, kernel_size=(3, 3), strides=(2, 2), padding="same", 
                 input_shape=(128, 128, 1)))                  ❶
model.add(ReLU())
model.add(MaxPooling2D(pool_size=(2, 2), strides=(2, 2)))     ❷
model.add(Flatten())                                          ❸

model.add(Dense(512))
model.add(ReLU())
model.add(Dense(26))
model.add(Activation('softmax'))
model.compile(loss='categorical_crossentropy', 
              optimizer='adam',
              metrics=['accuracy'])

❶ 图像数据输入到一个卷积层。

❷ 通过池化减少了特征图的大小。

❸ 在输出层之前,2D 特征图被展平成一个 1D 向量。

通过使用summary()方法,让我们来看看我们模型中各层的详细信息:

model.summary()
Layer (type)                     Output Shape            Param # 
=================================================================
conv2d_1 (Conv2D)                (None, 64, 64, 16)      160         ❶
_________________________________________________________________
re_lu_1 (ReLU)                   (None, 64, 64, 16)      0  
_________________________________________________________________
max_pooling2d_1 (MaxPooling2     (None, 32, 32, 16)      0           ❷
_________________________________________________________________
flatten_1 (Flatten)              (None, 16384)           0
_________________________________________________________________
dense_1 (Dense)                  (None, 512)             8389120     ❸
_________________________________________________________________
re_lu_2 (ReLU)                   (None, 512)             0     
_________________________________________________________________
dense_2 (Dense)                  (None, 26)              13338       ❹
_________________________________________________________________
activation_1 (Activation)        (None, 26)              0     
=================================================================
Total params: 8,402,618
Trainable params: 8,402,618
Non-trainable params: 0

❶ 卷积层的输出是 16 个 2D 大小为 64 × 64 的特征图。

❷ 池化层的输出将特征图大小减少到 32 × 32。

❸ 512 节点密集层的参数数量超过 800 万;展平层中的每个节点都与密集层中的每个节点相连。

❹ 最终的密集层有 26 个节点,每个节点对应英文字母表中的一个字母。

下面是如何读取输出形状列。对于Conv2D输入层,输出形状显示为(None, 64, 64, 16)。元组中的第一个值是单个前向传递中将通过的示例(批大小)数量。由于这是在训练时确定的,因此设置为None以表示当模型被喂数据时将绑定。最后一个数字是过滤器的数量,我们将其设置为 16。中间的两个数字(64, 64)是特征图的输出大小——在这种情况下,每个为 64 × 64 像素(总共 16)。输出大小由过滤器大小(3 × 3)、步长(2 × 2)和填充(same)决定。我们指定的组合将使高度和宽度减半,总大小减少 75%。

对于MaxPooling2D层,池化特征图的输出大小将是 32 × 32。通过指定 2 × 2 的池化区域和 2 的步长,池化特征图的高度和宽度将减半,总大小减少 75%。

从池化特征图得到的展平输出是一个大小为 16,384 的 1D 向量,计算方式为 16 × (32 × 32)。让我们看看这加起来是否等于我们之前计算的,即特征图的输出大小应该与输入大小相同。我们的输入是 128 × 128,也就是 16,384,这与Flatten层的输出大小相匹配。

展平后的池化特征图中的每个元素(像素)随后被输入到 DNN 输入层的每个节点中,该层有 512 个节点。因此,展平层和输入层之间的连接数是 16,384 × 512 = ~8.4 百万。这就是该层需要学习的权重数量,并且大部分计算将(压倒性地)发生在这里。

现在我们以序列方法风格的变体来展示相同的代码示例。在这里,激活方法是通过在每个层的实例化中使用参数activation来指定的(例如Conv2D(), Dense()):

from tensorflow.keras.models import Sequential
from tensorflow.keras.layers import Dense

from tensorflow.keras.layers import Conv2D, MaxPooling2D, Flatten

model = Sequential()

model.add(Conv2D(16, kernel_size=(3, 3), strides=(2, 2), padding="same", 
                 activation='relu', input_shape=(128,128, 1)))
model.add(MaxPooling2D(pool_size=(2, 2), strides=(2, 2)))
model.add(Flatten())

model.add(Dense(512, activation='relu'))
model.add(Dense(26, activation='softmax'))

model.compile(loss='categorical_crossentropy', 
              optimizer='adam', 
              metrics=['accuracy'])

现在我们以第三种方式展示相同的代码示例,使用功能 API 方法。在这种方法中,我们分别定义每个层,从输入向量开始,到输出层结束。在每一层,我们使用多态来调用实例化的类(层)对象作为可调用对象,并传入前一个层的对象来连接它。

例如,对于第一个Dense层,当作为可调用对象调用时,我们将Flatten层的层对象作为参数传递。作为一个可调用对象,这将导致Flatten层和第一个Dense层完全连接(Flatten层的每个节点将连接到Dense层的每个节点):

from tensorflow.keras import Input, Model
from tensorflow.keras.layers import Dense
from tensorflow.keras.layers import Conv2D, MaxPooling2D, Flatten

inputs  = Input(shape=(128, 128, 1))                                       ❶
layer   = Conv2D(16, kernel_size=(3, 3), strides=(2, 2), padding="same",
                 activation='relu')(inputs)                                ❷
layer   = MaxPooling2D(pool_size=(2, 2), strides=(2, 2))(layer)            ❸
layer   = Flatten()(layer)
layer   = Dense(512, activation='relu')(layer)
outputs = Dense(26, activation='softmax')(layer)

model = Model(inputs, outputs)

❶ 对于卷积层,需要指定通道数。

❷ 构建卷积层

❸ 通过池化减少特征图的大小

3.3 VGG 网络

VGG 类型的卷积神经网络是由牛津大学的视觉几何组设计的。它是为了在国际 ILSVRC 图像识别竞赛中竞争 1000 类图像而设计的。2014 年的 VGGNet 在图像定位任务中获得了第一名,在图像分类任务中获得了第二名。

虽然 AlexNet(及其相应的卷积网络设计模式)被认为是卷积网络的鼻祖,但 VGGNet(及其相应的 VGG 设计模式)被认为是基于卷积组正规化设计模式的之父。像它的 AlexNet 先辈一样,它继续将卷积层视为前端,并保留一个大的 DNN 后端用于分类任务。VGG 设计模式背后的基本原理如下:

  • 将多个卷积分组为具有相同数量的过滤器的块

  • 在块之间逐步加倍过滤器数量

  • 将池化延迟到块的末尾

当在当今的背景下讨论 VGG 设计模式时,可能会对术语 groupblock 产生初始混淆。在为 VGGNet 进行研究时,作者使用了术语 卷积组。随后,研究人员将分组模式细化成由卷积块组成的卷积组。在今天的命名法中,VGG 组会被称为

它是使用一些易于学习的原则设计的。卷积前端由一系列相同大小的卷积对(后来是三对)组成,随后是最大池化。最大池化层将生成的特征图下采样 75%,然后下一对(或三对)卷积层将学习到的过滤器数量加倍。卷积设计背后的原理是,早期层学习粗略特征,后续层通过增加过滤器,学习越来越精细的特征,而最大池化用于层之间以最小化特征图大小的增长(以及随后学习的参数)。最后,深度神经网络(DNN)后端由两个大小相同、每个有 4096 个节点的密集隐藏层和一个用于分类的 1000 个节点的最终密集输出层组成。图 3.9 描述了 VGG 架构中的第一个卷积组。

图 3.9 在 VGG 架构中,卷积被分组,池化被延迟到组末尾。

最著名的版本是 VGG16 和 VGG19。在竞赛中使用的 VGG16 和 VGG19,以及它们的竞赛训练权重,都已公开发布。由于它们在迁移学习中经常被使用,其他人保留了 ImageNet 预训练的 VGG16 或 VGG19 的卷积前端和相应的权重,并附加了一个新的 DNN 后端,用于重新训练新的图像类别。图 3.10 是 VGG16 的架构描述。

图 3.10 VGG16 架构由 VGG 组的卷积前端组成,后面跟着 DNN 后端。

因此,让我们继续用两种编码风格来编写 VGG16:第一种是顺序流,第二种是使用重用函数来复制层的公共块,并指定它们特定设置的参数。我们还将更改指定kernel_sizepool_size的方式,将它们作为关键字参数指定,而不是位置参数:

from tensorflow.keras import Sequential
from tensorflow.keras.layers import Conv2D, MaxPooling2D, Flatten, Dense

model = Sequential()

model.add(Conv2D(64, (3, 3), strides=(1, 1), padding="same", 
                 activation="relu", input_shape=(224, 224, 3)))     ❶
model.add(Conv2D(64, (3, 3), strides=(1, 1), padding="same",
                 activation="relu"))
model.add(MaxPooling2D((2, 2), strides=(2, 2))) 

model.add(Conv2D(128, (3, 3), strides=(1, 1), padding="same", 
                 activation="relu"))                                ❷
model.add(Conv2D(128, (3, 3), strides=(1, 1), padding="same",
                 activation="relu"))
model.add(MaxPooling2D((2, 2), strides=(2, 2))) 

model.add(Conv2D(256, (3, 3), strides=(1, 1), padding="same", 
                 activation="relu"))                                ❸
model.add(Conv2D(256, (3, 3), strides=(1, 1), padding="same", 
                 activation="relu"))
model.add(Conv2D(256, (3, 3), strides=(1, 1), padding="same", 
                 activation="relu"))
model.add(MaxPooling2D((2, 2), strides=(2, 2))) 

model.add(Conv2D(512, (3, 3), strides=(1, 1), padding="same", 
                 activation="relu"))                                ❹
model.add(Conv2D(512, (3, 3), strides=(1, 1), padding="same", 
                 activation="relu"))
model.add(Conv2D(512, (3, 3), strides=(1, 1), padding="same", 
                 activation="relu"))
model.add(MaxPooling2D((2, 2), strides=(2, 2))) 

model.add(Conv2D(512, (3, 3), strides=(1, 1), padding="same", 
                 activation="relu"))                                ❺
model.add(Conv2D(512, (3, 3), strides=(1, 1), padding="same", 
                 activation="relu"))
model.add(Conv2D(512, (3, 3), strides=(1, 1), padding="same", 
                 activation="relu"))
model.add(MaxPooling2D((2, 2), strides=(2, 2))) 

model.add(Flatten())                                                ❻
model.add(Dense(4096, activation='relu'))
model.add(Dense(4096, activation='relu'))

model.add(Dense(1000, activation='softmax'))                        ❼

model.compile(loss='categorical_crossentropy', 
              optimizer='adam', 
              metrics=['accuracy'])

❶ 第一个卷积块

❷ 第二个卷积块——过滤器数量加倍

❸ 第三个卷积块——过滤器数量加倍

❹ 第四个卷积块——过滤器数量加倍

❺ 第五(最终)个卷积块

❻ DNN 后端

❼ 用于分类的输出层(1000 个类别)

你刚刚编写了一个 VGG16——不错。现在让我们用过程重用风格来编写相同的代码。在这个例子中,我们创建了一个过程(函数)conv_block``(),它构建卷积块,并接受块中层数(2 或 3)和过滤器数量(64、128、256 或 512)作为参数。注意,我们将第一个卷积层放在conv_block之外。第一层需要input_shape参数。我们本可以将它编码为conv_block的标志,但由于它只会出现一次,这不是重用。所以我们将其内联:

from tensorflow.keras import Sequential
from tensorflow.keras.layers import Conv2D, MaxPooling2D, Flatten, Dense

def conv_block(n_layers, n_filters):                ❶
    """
        n_layers : number of convolutional layers
        n_filters: number of filters
    """
    for n in range(n_layers):
        model.add(Conv2D(n_filters, (3, 3), strides=(1, 1), padding="same",
                         activation="relu"))
    model.add(MaxPooling2D(2, strides=2))

model = Sequential()      
model.add(Conv2D(64, (3, 3), strides=(1, 1), padding="same", 
                 activation="relu", 
                 input_shape=(224, 224, 3)))        ❷
conv_block(1, 64)                                   ❸
conv_block(2, 128)                                  ❹
conv_block(3, 256)                                  ❹
conv_block(3, 512)                                  ❹
conv_block(3, 512)                                  ❹

model.add(Flatten())
model.add(Dense(4096, activation='relu'))
model.add(Dense(4096, activation='relu'))

model.add(Dense(1000, activation='softmax'))

model.compile(loss='categorical_crossentropy', 
              optimizer='adam', 
              metrics=['accuracy'])

❶ 卷积块作为过程实现

❷ 第一个卷积块单独指定,因为它需要input_shape参数

❸ 第一个卷积块的剩余部分

❹ 第二至第五个卷积块

尝试在两个示例上运行 model.summary(),你会发现输出是相同的。

3.4 ResNet 网络

微软研究院设计的ResNet类型 CNN 是为了在国际 ILSVRC 竞赛中竞争。2015 年的比赛中,ResNet 在 ImageNet 和 Common Objects in Context (COCO)竞赛的所有类别中都获得了第一名。

在上一节中介绍的 VGGNet 设计模式在模型架构的层数深度上存在局限性,在遭受梯度消失和梯度爆炸之前,模型架构的深度是有限的。此外,不同层以不同速率收敛可能导致训练过程中的发散。

对于残差网络中残差块设计模式组件的研究人员提出了一个他们称之为恒等链接的新颖层连接。恒等链接引入了特征重用的最早概念。在恒等链接之前,每个卷积块都对前一个卷积输出进行特征提取,而不保留任何先前输出的知识。恒等链接可以看作是当前和先前卷积输出之间的耦合,以重用从早期提取中获得的特征信息。同时,与 ResNet 一起,其他研究人员——例如谷歌的 Inception v1(GoogLeNet)——进一步将卷积设计模式细化成组和块。与这些设计改进并行的是批量归一化的引入。

使用身份链接以及批量归一化在层之间提供了更多的稳定性,减少了梯度消失和爆炸以及层间的发散,使得模型架构可以在层中更深,从而提高预测的准确性。

3.4.1 架构

ResNet 以及这个类别中的其他架构使用不同的层到层连接模式。我们之前讨论的模式(ConvNet 和 VGG)使用的是全连接层到层模式。

ResNet34 引入了新的块层和层连接模式,分别是残差块和恒等连接。ResNet34 中的残差块由两个没有池化层的相同卷积层组成。每个块都有一个恒等连接,它创建了一个在残差块的输入和输出之间的并行路径,如图 3.11 所示。与 VGG 一样,每个后续块将滤波器的数量加倍。在块序列的末尾进行池化。

图像

图 3.11 一个残差块,其中输入是一个矩阵加到卷积的输出上

神经网络的一个问题是,当我们增加更深的层(在假设增加准确性的前提下),它们的性能可能会下降。情况可能会变得更糟,而不是更好。这有几个原因。当我们深入时,我们正在添加更多的参数(权重)。参数越多,每个训练数据中的输入适合过多参数的地方就越多。不是泛化,神经网络将简单地学习每个训练示例(死记硬背)。另一个问题是协变量偏移:随着我们深入,权重的分布会变宽(进一步分散),这使得神经网络收敛变得更加困难。前者导致测试(保留)数据上的性能下降,后者在训练数据上也是如此,以及梯度消失或爆炸。

残差块允许神经网络构建更深的层,而不会降低测试数据上的性能。一个 ResNet 块可以看作是一个添加了恒等连接的 VGG 块。虽然块的 VGG 风格执行特征检测,但恒等连接保留了输入给下一个后续块,因此下一个块的输入包括前一个特征检测和输入。

通过保留过去(前一个输入)的信息,这种块设计使得神经网络可以比 VGG 对应物更深,同时提高准确性。从数学上讲,我们可以将 VGG 和 ResNet 表示如下。对于这两种情况,我们希望学习一个h(x)的公式,它是测试数据的分布(例如,标签)。对于 VGG,我们学习一个函数f(x, {W}),其中{W}代表权重。对于 ResNet,我们通过添加“+ x”这一项来修改方程,其中x是恒等:

VGG: h(x) = f(x, {W})

ResNet: h(x) = f(x, {W}) + x

以下代码片段展示了如何通过使用功能 API 方法在 TF.Keras 中编码一个残差块。变量x代表一个层的输出,它是下一层的输入。在块的开始,我们保留前一个块/层的输出作为变量shortcut。然后我们将前一个块/层的输出(x)通过两个卷积层,每次都将前一个层的输出作为下一层的输入。最后,块的最后一个输出(保留在变量x中)与原始的x值(快捷方式)相加(矩阵加法)。这是恒等连接,通常被称为快捷方式

shortcut = x                          ❶
x = layers.Conv2D(64, kernel_size=(3, 3), strides=(1, 1), padding='same')(x)
x = layers.ReLU()(x)
x = layers.Conv2D(64, kernel_size=(3, 3), strides=(1, 1), padding='same')(x)
x = layers.ReLU()(x)                  ❷
x = layers.add([shortcut, x])         ❸

❶ 记住块的输入。

❷ 卷积序列的输出

❸ 输入到输出的矩阵加法

现在我们使用过程式风格将整个网络组合起来。此外,我们还需要添加 ResNet 的入口卷积层,然后是 DNN 分类器。

正如我们在 VGG 示例中所做的那样,我们定义了一个生成残差块模式的程序(函数),遵循我们在前面的代码片段中使用的模式。对于我们的residual_block``()过程,我们传入块的滤波器数量和输入层(前一个层的输出)。

ResNet 架构将一个(224, 224, 3)向量作为输入——一个 224(高度)× 224(宽度)像素的 RGB 图像(3 个通道)。第一层是一个基本的卷积层,使用一个相当大的 7 × 7 的滤波器。然后通过一个最大池化层减小输出(特征图)的大小。

在初始卷积层之后是一系列残差块组。每个后续组将过滤器数量加倍(类似于 VGG)。然而,与 VGG 不同,组之间没有池化层来减少特征图的大小。现在,如果我们直接将这些块连接起来,我们会遇到问题。下一个块的输入形状基于前一个块的过滤器大小(让我们称它为X)。下一个块通过加倍过滤器,将导致该残差块的输出大小加倍(让我们称它为 2X)。恒等链接将尝试将输入矩阵(X)和输出矩阵(2X)相加。哎呀——我们得到一个错误,表示我们无法广播(对于加法操作)不同大小的矩阵。

对于 ResNet,这是通过在每个“加倍”的残差块组之间添加一个卷积块来解决的。如图 3.12 所示,卷积块将过滤器加倍以改变大小,并将步长加倍以将特征图大小减少 75%(执行特征池化)。

图片

图 3.12 卷积块执行池化并将特征图数量加倍,为下一个卷积组做准备。

最后一个残差块组的输出传递到一个池化和展平层(GlobalAveragePooling2D),然后传递到一个有 1000 个节点的单个Dense层(类别数量):

from tensorflow.keras import Model
import tensorflow.keras.layers as layers

def residual_block(n_filters, x):                                           ❶
    """ Create a Residual Block of Convolutions
        n_filters: number of filters
        x        : input into the block
    """
    shortcut = x
    x = layers.Conv2D(n_filters, (3, 3), strides=(1, 1), padding="same", 
                      activation="relu")(x)
    x = layers.Conv2D(n_filters, (3, 3), strides=(1, 1), padding="same", 
                      activation="relu")(x)
    x = layers.add([shortcut, x])
    return x

def conv_block(n_filters, x):                                               ❷
    """ Create Block of Convolutions without Pooling
        n_filters: number of filters
        x        : input into the block
    """
    x = layers.Conv2D(n_filters, (3, 3), strides=(2, 2), padding="same", 
                      activation="relu")(x)
    x = layers.Conv2D(n_filters, (3, 3), strides=(2, 2), padding="same", 
                      activation="relu")(x)
    return x

inputs = layers.Input(shape=(224, 224, 3))                                  ❸

x = layers.Conv2D(64, kernel_size=(7, 7), strides=(2, 2), padding='same', 
                  activation='relu')(inputs)                                ❹
x = layers.MaxPool2D(pool_size=(3, 3), strides=(2, 2), padding='same')(x)   ❹

for _ in range(2):                                                          ❺
    x = residual_block(64, x)                                               ❺

x = conv_block(128, x)                                                      ❻

for _ in range(3):
    x = residual_block(128, x)

x = conv_block(256, x)

for _ in range(5):
    x = residual_block(256, x)

x = conv_block(512, x)

    x = residual_block(512, x)

x = layers.GlobalAveragePooling2D()(x)

outputs = layers.Dense(1000, activation='softmax')(x)

model = Model(inputs, outputs)

❶ 残差块作为过程

❷ 卷积块作为过程

❸ 输入张量

❹ 首个卷积层,其中池化特征图将减少 75%

❺ 64 个过滤器的第一个残差块组

❻ 将过滤器大小加倍并减少特征图 75%(步长 s = 2, 2)以适应下一个残差组

现在运行model.summary()。我们看到需要学习的总参数数量是 2100 万。这与拥有 1.38 亿参数的 VGG16 形成对比。所以 ResNet 架构在计算上快了六倍。这种减少主要是由残差块的结构实现的。注意,深度神经网络后端只是一个单独的输出Dense层。实际上,没有后端。早期的残差块组充当 CNN 前端进行特征检测,而后面的残差块执行分类。这样做时,与 VGG 不同,不需要几个全连接的密集层,这会大大增加参数数量。

与之前示例中的池化不同,在池化中每个特征图的尺寸根据步长的大小而减小,GlobalAveragePooling2D就像一个超级充电版的池化:每个特征图被一个单一值所替代,在这种情况下是相应特征图中所有值的平均值。例如,如果输入是 256 个特征图,输出将是一个大小为 256 的 1D 向量。在 ResNet 之后,使用GlobalAveragePooling2D在最后一个池化阶段成为深度卷积神经网络的通用实践,这显著减少了进入分类器的参数数量,而没有在表示能力上造成重大损失。

另一个优点是恒等连接,它提供了在不降低性能的情况下添加更深层的功能,以实现更高的准确率。

ResNet50 引入了一种称为瓶颈残差块的残差块变体。在这个版本中,两个 3 × 3 卷积层组被一组 1 × 1、然后 3 × 3、最后 1 × 1 卷积层组所取代。第一个 1 × 1 卷积执行降维操作,降低计算复杂度,最后一个卷积恢复维度,通过 4 倍增加滤波器的数量。中间的 3 × 3 卷积被称为瓶颈卷积,就像瓶子的颈部。如图 3.13 所示的瓶颈残差块允许构建更深层的神经网络,而不降低性能,并进一步降低计算复杂度。

图 3.13 瓶颈设计使用 1 × 1 卷积进行降维和扩展。

这里是一个将瓶颈残差块作为可重用函数编写的代码片段:

def bottleneck_block(n_filters, x):
    """ Create a Bottleneck Residual Block of Convolutions
        n_filters: number of filters
        x        : input into the block
    """
    shortcut = x
    x = layers.Conv2D(n_filters, (1, 1), strides=(1, 1), padding="same", 
                      activation="relu")(x)                                 ❶
    x = layers.Conv2D(n_filters, (3, 3), strides=(1, 1), padding="same", 
                      activation="relu")(x)                                 ❷
    x = layers.Conv2D(n_filters * 4, (1, 1), strides=(1, 1), padding="same",
                      activation="relu")(x)                                 ❸
    x = layers.add([shortcut, x])                                           ❹
    return x

❶ 用于降维的 1 × 1 瓶颈卷积

❷ 用于特征提取的 3 × 3 卷积

❸ 用于降维的 1 × 1 投影卷积

❹ 输入到输出的矩阵加法

残差块引入了表示能力和表示等价性的概念。表示能力是衡量一个块作为特征提取器强大程度的一个指标。表示等价性是指一个块可以被分解成具有更低计算复杂度的形式,同时保持其表示能力。残差瓶颈块的设计被证明可以保持 ResNet34 块的表示能力,同时降低计算复杂度。

3.4.2 批标准化

在神经网络中添加更深层的问题还包括梯度消失问题。这实际上是关于计算机硬件的。在训练过程中(反向传播和梯度下降的过程),在每一层,权重都会乘以非常小的数字——具体来说,是小于 1 的数字。正如你所知,两个小于 1 的数字相乘会得到一个更小的数字。当这些微小的值通过更深层传播时,它们会持续变小。在某个点上,计算机硬件无法再表示这个值——因此,出现了梯度消失

如果我们尝试使用半精度浮点数(16 位浮点数)进行矩阵运算,而不是单精度浮点数(32 位浮点数),问题会进一步加剧。前者的优势在于权重(和数据)存储的空间减少了一半——按照一般经验,将计算大小减半,我们可以在每个计算周期内执行四倍的指令。当然,问题是,即使精度更小,我们也会更早地遇到梯度消失问题。

批归一化是一种应用于层输出(在激活函数之前或之后)的技术。不深入统计学方面,它在训练过程中对权重的偏移进行归一化。这有几个优点:它平滑了(在整个批次中)变化量,从而减缓了得到一个无法由硬件表示的极小数字的可能性。此外,通过缩小权重之间的偏移量,可以使用更高的学习率并减少总的训练时间,从而更快地收敛。在 TF.Keras 中,使用 BatchNormalization 类将批归一化添加到层中。

在早期的实现中,批归一化是在激活函数之后实现的。批归一化发生在卷积和密集层之后。当时,人们争论批归一化应该在激活函数之前还是之后。此代码示例在卷积和密集层中,在激活函数之前和之后都使用了后激活批归一化:

from tensorflow.keras import Sequential
from tensorflow.keras.layers import Conv2D, ReLU, BatchNormalization, Flatten
from tensorflow.keras.layers import Dense

model = Sequential()

model.add(Conv2D(64, (3, 3), strides=(1, 1), padding='same', 
                 input_shape=(128, 128, 3)))
model.add(BatchNormalization())
model.add(ReLU())                  ❶

model.add(Flatten())

model.add(Dense(4096))
model.add(ReLU())

model.add(BatchNormalization())    ❷

❶ 在激活函数之前添加批归一化

❷ 在激活函数之后添加批归一化

3.4.3 ResNet50

ResNet50 是一个广为人知的模型,通常被用作通用模型,例如用于迁移学习、作为目标检测中的共享层,以及用于性能基准测试。该模型有三个版本:v1、v1.5 和 v2。

ResNet50 v1 正式化了卷积组的概念。这是一个共享相同配置(如滤波器数量)的卷积块集合。在 v1 中,神经网络被分解成组,每个组将前一个组的滤波器数量翻倍。

此外,移除了单独的卷积块以将滤波器数量加倍的概念,并替换为使用线性投影的残差块。每个组从使用线性投影在标识连接上进行的残差块开始,以加倍滤波器数量,而其余的残差块直接将输入传递到输出以进行矩阵加法操作。此外,具有线性投影的残差块中的第一个 1 × 1 卷积使用步长为 2(特征池化),这也称为带步长的卷积,如图 3.14 所示,减少了特征图大小 75%。

图片

图 3.14 标识连接被替换为 1 × 1 投影以匹配卷积输出上的特征图数量,以便进行矩阵加法操作。

以下是对 ResNet50 v1 使用瓶颈块与批量归一化相结合的实现:

from tensorflow.keras import Model
import tensorflow.keras.layers as layers

def identity_block(x, n_filters):
    """ Create a Bottleneck Residual Block of Convolutions
        n_filters: number of filters
        x        : input into the block
    """
    shortcut = x

    x = layers.Conv2D(n_filters, (1, 1), strides=(1, 1))(x)
    x = layers.BatchNormalization()(x)
    x = layers.ReLU()(x)

    x = layers.Conv2D(n_filters, (3, 3), strides=(1, 1), padding="same")(x)
    x = layers.BatchNormalization()(x)
    x = layers.ReLU()(x)

    x = layers.Conv2D(n_filters * 4, (1, 1), strides=(1, 1))(x)
    x = layers.BatchNormalization()(x)

    x = layers.add([shortcut, x])
    x = layers.ReLU()(x)

    return x

def projection_block(x, n_filters, strides=(2,2)):                        ❶
    """ Create Block of Convolutions with feature pooling
        Increase the number of filters by 4X
        x        : input into the block
        n_filters: number of filters
    """
    shortcut = layers.Conv2D(4 * n_filters, (1, 1), strides=strides)(x)   ❷
    shortcut = layers.BatchNormalization()(shortcut)

    x = layers.Conv2D(n_filters, (1, 1), strides=strides)(x)
    x = layers.BatchNormalization()(x)
    x = layers.ReLU()(x)

    x = layers.Conv2D(n_filters, (3, 3), strides=(1, 1), padding='same')(x)
    x = layers.BatchNormalization()(x)
    x = layers.ReLU()(x)

    x = layers.Conv2D(4 * n_filters, (1, 1), strides=(1, 1))(x)
    x = layers.BatchNormalization()(x)

    x = layers.add([x, shortcut])
    x = layers.ReLU()(x)

    return x

inputs = layers.Input(shape=(224, 224, 3))

x = layers.ZeroPadding2D(padding=(3, 3))(inputs)
x = layers.Conv2D(64, kernel_size=(7, 7), strides=(2, 2), padding='valid')(x)
x = layers.BatchNormalization()(x)
x = layers.ReLU()(x)
x = layers.ZeroPadding2D(padding=(1, 1))(x)
x = layers.MaxPool2D(pool_size=(3, 3), strides=(2, 2))(x)

x = projection_block(64, x, strides=(1,1))                                ❸

for _ in range(2):
    x = identity_block(64, x)

x = projection_block(128, x)

for _ in range(3):
    x = identity_block(128, x)

x = projection_block(256, x)

for _ in range(5):
    x = identity_block(256, x)

x = projection_block(512, x)

for _ in range(2):
    x = identity_block(512, x)

x = layers.GlobalAveragePooling2D()(x)

outputs = layers.Dense(1000, activation='softmax')(x)

model = Model(inputs, outputs)

❶ 投影块作为过程

❷ 在快捷连接上进行 1 × 1 投影卷积以匹配输出大小

❸ 首个组之后的每个卷积组都以投影块开始。

如图 3.15 所示,v1.5 引入了对瓶颈设计的重构,进一步降低了计算复杂度,同时保持了表示能力。在具有线性投影的残差块中的特征池化(步长=2)从第一个 1 × 1 卷积移动到 3 × 3 卷积,降低了计算复杂度,并在 ImageNet 上提高了 0.5%的结果。

图片

图 3.15 维度降低从 1 × 1 卷积移动到 3 × 3 卷积。

以下是对具有投影连接的 ResNet50 v1 残差块的实现:

def projection_block(x, n_filters, strides=(2,2)):
    """ Create Block of Convolutions with feature pooling
        Increase the number of filters by 4X
        x        : input into the block
        n_filters: number of filters
    """
    shortcut = layers.Conv2D(4 * n_filters, (1, 1), strides=strides)(x)
    shortcut = layers.BatchNormalization()(shortcut)

    x = layers.Conv2D(n_filters, (1, 1), strides=(1, 1))(x)
    x = layers.BatchNormalization()(x)
    x = layers.ReLU()(x)

    x = layers.Conv2D(n_filters, (3, 3), strides=(2, 2), padding='same')(x) ❶
    x = layers.BatchNormalization()(x)
    x = layers.ReLU()(x)

    x = layers.Conv2D(4 * n_filters, (1, 1), strides=(1, 1))(x)
    x = layers.BatchNormalization()(x)

    x = layers.add([x, shortcut])
    x = layers.ReLU()(x)
    return x

❶ 使用步长为 2 将瓶颈移动到 3 × 3 卷积

ResNet50 v2引入了预激活批量归一化BN-RE-Conv),其中批量归一化和激活函数被放置在相应的卷积或密集层之前(而不是之后)。现在这已成为一种常见做法,如图中所示,v2 中实现了具有标识连接的残差块:

def identity_block(x, n_filters):
      """ Create a Bottleneck Residual Block of Convolutions
            n_filters: number of filters
            x            : input into the block
      """
      shortcut = x

      x = layers.BatchNormalization()(x)                         ❶
      x = layers.ReLU()(x)                                       ❶
      x = layers.Conv2D(n_filters, (1, 1), strides=(1, 1))(x)

      x = layers.BatchNormalization()(x)                         ❶
      x = layers.ReLU()(x)
      x = layers.Conv2D(n_filters, (3, 3), strides=(1, 1), padding="same")(x)

      x = layers.BatchNormalization()(x)                         ❶
      x = layers.ReLU()(x)
      x = layers.Conv2D(n_filters * 4, (1, 1), strides=(1, 1))(x)

      x = layers.add([shortcut, x])
      return x

❶ 在卷积之前进行批量归一化

摘要

  • 卷积神经网络可以描述为向深度神经网络添加前端。

  • CNN 前端的目的是将高维像素输入降低到低维特征表示。

  • 特征表示的较低维度使得使用真实世界图像进行深度学习变得实用。

  • 使用图像缩放和池化来减少模型中的参数数量,而不损失信息。

  • 使用一系列级联的滤波器来检测特征与人类眼睛有相似之处。

  • VGG 形式化了重复的卷积模式的概念。

  • 残差网络引入了特征重用的概念,并证明了在相同层数的情况下,与 VGG 相比可以获得更高的准确率,并且可以加深层数以获得更高的准确率。

  • 批标准化允许模型在暴露于梯度消失或梯度爆炸之前,在层中更深入地学习以获得更高的准确性。

4 训练基础

本章涵盖了

  • 前向传播和反向传播

  • 分割数据集和预处理数据

  • 使用验证数据监控过拟合

  • 使用检查点和提前停止以实现更经济的训练

  • 使用超参数与模型参数

  • 训练对位置和尺度的不变性

  • 组装和访问磁盘上的数据集

  • 保存并恢复训练好的模型

本章涵盖了训练模型的基础知识。在 2019 年之前,大多数模型都是根据这一套基本步骤进行训练的。可以将本章视为一个基础。

在本章中,我们将介绍随着时间的推移通过实验和试错开发的方法、技术和最佳实践。我们将从回顾前向传播和反向传播开始。虽然这些概念和实践在深度学习之前就已经存在,但多年的改进使模型训练变得实用——特别是,在数据分割、喂入以及使用梯度下降在反向传播期间更新权重的方式。这些技术改进提供了训练模型到收敛的手段,即模型预测准确率达到平台期的点。

在数据预处理和增强中开发的其他训练技术旨在推动收敛到更高的平台,并帮助模型更好地泛化到模型未训练过的数据。进一步的改进继续通过超参数搜索和调整、检查点和提前停止,以及更高效地从磁盘存储中抽取数据格式和方法,使训练更加经济。所有这些技术相结合,使得深度学习在现实世界中的应用在计算和经济上都具有可行性。

4.1 前向传播和反向传播

让我们从监督训练的概述开始。在训练模型时,你将数据前向通过模型,并计算预测结果的错误程度——损失。然后,将损失反向传播以更新模型的参数,这就是模型正在学习的内容——参数的值。

在训练模型时,你从代表模型将部署的目标环境的训练数据开始。换句话说,这些数据是人群分布的采样分布。训练数据由示例组成。每个示例有两个部分:特征,也称为独立变量;以及相应的标签,也称为因变量

标签也被称为真实值(“正确答案”)。我们的目标是训练一个模型,一旦部署并给出来自人群(模型之前从未见过的例子)的无标签示例,模型就能泛化到足以准确预测标签(“正确答案”)——监督学习。这一步被称为推理

在训练过程中,我们通过输入层(也称为模型的底部)将训练数据的批次(也称为样本)输入到模型中。随着数据向前移动,经过模型各层的参数(权重和偏置)的转换,训练数据被转化为输出节点(也称为模型的顶部)。在输出节点,我们测量我们与“正确”答案的距离,这被称为损失。然后,我们将损失反向传播通过模型的各层,并更新参数以更接近下一个批次得到正确答案。

我们继续重复这个过程,直到达到收敛,这可以描述为“在这个训练运行中,我们已经达到了尽可能高的准确性。”

4.1.1 输入

输入是从训练数据中采样批次并通过模型前馈批次的过程,然后在输出处计算损失。一个批次可以是随机选择的一个或多个训练数据示例。

批次的大小通常是恒定的,这被称为(迷你)批次大小。所有训练数据被分成批次,通常每个示例只会出现在一个批次中。

所有训练数据被多次输入到模型中。每次我们输入整个训练数据,都称为一个epoch。每个 epoch 是批次的不同随机排列——也就是说,没有两个 epoch 具有相同的示例顺序——如图 4.1 所示。

图 4.1 在训练过程中,训练数据的迷你批次通过神经网络前馈。

4.1.2 反向传播

在本节中,我们探讨反向传播发现的的重要性以及它今天的应用。

背景

让我们回顾历史,了解反向传播对深度学习成功的重要性。在早期的神经网络中,如感知器和单层神经元,学术研究人员尝试了各种更新权重的方法以获得正确答案。

当他们只使用少量神经元和简单问题时,逻辑上的第一次尝试只是随机更新。最终,令人惊讶的是,随机猜测竟然有效。然而,这种方法并不适用于大量神经元(比如数千个)和实际应用;正确的随机猜测可能需要数百万年。

下一个逻辑步骤是使随机值与预测的偏差成正比。换句话说,偏差越大,随机值的范围就越大;偏差越小,随机值的范围就越小。不错——现在我们可能只需要数千年就能在实际应用中猜测正确的随机值。

最终,学术研究人员尝试了多层感知器(MLPs),但将随机值与它们偏离正确答案的程度(损失)成比例的技术并没有奏效。他们发现,当你有多个层次时,这种技术会产生左手(一层)抵消右手(另一层)工作的效果。

这些研究人员发现,虽然输出层权重的更新与预测中的损失相关,但早期层中权重的更新与下一层的更新相关。因此,形成了反向传播的概念。在此阶段,学术研究人员超越了仅使用随机分布来计算更新的方法。尝试了许多方法,但都没有改进,直到开发出一种更新权重的方法,不是基于下一层的变化量,而是基于变化率——因此发现了梯度下降技术并进行了发展。

基于批次的反向传播

在将每个训练数据批次正向通过模型并计算损失后,损失将通过模型进行反向传播。我们逐层更新模型的参数(权重和参数),从顶层(输出)开始,移动到底层(输入)。参数如何更新是损失、当前参数的值以及前一层所做的更新的组合。

实现这一般方法的通用方法是基于梯度下降。优化器是梯度下降的一种实现,其任务是更新参数以最小化后续批次上的损失(最大化接近正确答案的程度)。图 4.2 展示了这一过程。

图片

图 4.2 从迷你批次计算出的损失通过反向传播;优化器更新权重以最小化下一批次的损失。

4.2 数据集划分

数据集是一组足够大且多样化的示例,足以代表所建模的总体(采样分布)。当一个数据集符合此定义,并且经过清理(无噪声),以及以适合机器学习训练的格式准备时,我们称之为精心制作的数据集。本书不涉及数据集清理的细节,因为它是一个庞大且多样化的主题,可以成为一本单独的书籍。我们在本书中涉及数据清理的各个方面,当相关时。

对于学术和研究目的,有许多精心制作的数据库可供使用。其中一些用于图像分类的知名数据库包括 MNIST(在第二章中介绍)、CIFAR-10/100、SVHN、Flowers 和 Cats vs. Dogs。MNIST 和 CIFAR-10/100(加拿大高级研究研究所)已内置到 TF.Keras 框架中。SVHN(街景房屋号码)、Flowers 和 Cats vs. Dogs 可通过 TensorFlow Datasets(TFDS)获得。在本节中,我们将使用这些数据集进行教程演示。

一旦你拥有了一个精心挑选的数据集,下一步就是将其分割成用于训练的示例和用于测试(也称为评估保留)的示例。我们使用数据集中作为训练数据的部分来训练模型。如果我们假设训练数据是一个好的采样分布(代表总体分布),那么训练数据的准确性应该反映在将模型部署到现实世界中对模型在训练期间未见过的总体中的示例进行预测时的准确性。

但在我们部署模型之前,我们如何知道这是否正确呢?因此,测试(保留)数据的目的。我们在模型训练完成后,留出一部分数据集来测试,看看我们是否可以得到可比的准确性。

例如,假设我们完成训练后,在训练数据上达到了 99%的准确性,但在测试数据上只有 70%的准确性。出了些问题(例如,过拟合)。那么我们为训练和测试预留多少呢?从历史上看,经验法则一直是 80/20:80%用于训练,20%用于测试。但这已经改变了,但我们将从这一经验法则开始,并在后面的章节中讨论现代更新。

4.2.1 训练和测试集

重要的是,我们能够假设我们的数据集足够大,以至于如果我们将其分成 80%和 20%,并且示例是随机选择的,以便两个数据集都将成为代表总体分布的好的采样分布,那么模型在部署后将会做出预测(推理)。图 4.3 说明了这个过程。

图 4.3 在分割成训练和测试数据之前,训练数据首先被随机打乱。

让我们从使用精心挑选的数据集进行训练的逐步过程开始。作为第一步,我们导入精心挑选的 TF.Keras 内置 MNIST 数据集,如下面的代码所示。TF.Keras 内置数据集有一个load_data()方法。此方法将数据集加载到内存中,该数据集已经随机打乱并预先分割成训练和测试数据。训练和测试数据进一步被分成特征(在这种情况下是图像数据)和相应的标签(代表每个数字的数值 0 到 9)。将训练和测试的特征和标签分别称为(x_train, y_train)(x_test, y_test)是一种常见的约定:

from tensorflow.keras.datasets import mnist                   ❶

(x_train, y_train), (x_test, y_test) = mnist.load_data()      ❷
print(x_train.shape, y_train.shape)
print(x_test.shape, y_test.shape)yp

❶ MNIST 是该框架中的一个内置数据集。

❷ 内置数据集会自动随机打乱并预先分割成训练和测试数据。

MNIST 数据集包含 60,000 个训练样本和 10,000 个测试样本,十个数字 0 到 9 的分布均匀(平衡)。每个示例由一个 28×28 像素的灰度图像(单通道)组成。从以下输出中,你可以看到训练数据(x_train, y_train)由 60,000 个大小为 28×28 的图像和相应的 60,000 个标签组成,而测试数据(x_test, y_test)由 10,000 个示例和标签组成:

(60000, 28, 28) (60000,)
(10000, 28, 28) (10000,)

4.2.2 独热编码

让我们构建一个简单的 DNN 来训练我们的精选数据集。在下一个代码示例中,我们首先通过使用Flatten层将 28×28 图像输入展平为 1D 向量,然后是两个各有 512 个节点的隐藏Dense()层,每个层使用relu激活函数的约定。最后,输出层是一个有 10 个节点的Dense层,每个数字一个节点。由于这是一个多类分类器,输出层的激活函数是softmax

接下来,我们通过使用categorical_crossentropy作为损失函数和adam作为优化器来编译模型,以符合多类分类器的约定:

from tensorflow.keras import Sequential
from tensorflow.keras.layers import Flatten, Dense

model = Sequential()
model.add(Flatten(input_shape=(28, 28)))         ❶
model.add(Dense(512, activation='relu'))         ❷
model.add(Dense(512, activation='relu'))         ❸
model.add(Dense(10, activation='softmax'))       ❹
model.compile(loss='categorical_crossentropy',
              optimizer='adam', 
              metrics=['acc'])

❶ 将 2D 灰度图像展平为 1D 向量以供 DNN 使用

❷ DNN 的实际输入层,一旦图像被展平

❸ 一个隐藏层

❹ DNN 的输出层

使用此数据集训练此模型的最基本方法是使用fit()方法。我们将传递训练数据(x_train, y_train)作为参数。我们将剩余的关键字参数设置为它们的默认值:

model.fit(x_train, y_train)

当你运行前面的代码时,你会看到一个错误信息:

ValueError: You are passing a target array of shape (60000, 1) while using
as loss 'categorical_crossentropy'. 'categorical_crossentropy' expects 
targets to be binary matrices (1s and 0s) of shape (samples, classes).

发生了什么问题?这是与我们选择的损失函数有关的问题。它将比较每个输出节点与相应的输出期望之间的差异。例如,如果答案是数字 3,我们需要一个 10 个元素的向量(每个数字一个元素),在 3 的索引处有一个 1(100%的概率),在其余索引处有 0(0%的概率)。在这种情况下,我们需要将标量值标签转换为在相应索引处有 1 的 10 个元素的向量。这被称为独热编码,如图 4.4 所示。

图 4.4 独热编码标签的大小与输出类别的数量相同。

让我们通过首先从 TF.Keras 导入to_categorical()函数,然后使用它将标量值标签转换为独热编码标签来修复我们的示例。注意,我们将值 10 传递给to_categorical(),以指示独热编码标签的大小(类别数量):

from tensorflow.keras.utils import to_categorical    ❶
y_train = to_categorical(y_train, 10)                ❷
y_test = to_categorical(y_test, 10)                  ❷

model.fit(x_train, y_train)

❶ 使用方法进行独热编码

❷ 对训练和测试标签进行独热编码

现在你运行这个,你的输出将看起来像这样:

60000/60000 [==============================] - 5s 81us/sample - loss: 
1.3920 - acc: 0.9078                                                    ❶

❶ 训练数据上的准确率刚好超过 90%。

这样做是有效的,我们在训练数据上达到了 90%的准确率——但我们还可以简化这一步骤。compile()方法内置了 one-hot 编码。要启用它,我们只需将损失函数从categorical_crossentropy更改为sparse_categorical_crossentropy。在此模式下,损失函数将接收标签作为标量值,并在执行交叉熵损失计算之前动态地将它们转换为 one-hot 编码的标签。

我们在以下示例中这样做,并且还设置关键字参数epoch为 10,以便将整个训练数据输入模型 10 次:

from tensorflow.keras import Sequential
from tensorflow.keras.layers import Flatten, Dense

model = Sequential()
model.add(Flatten(input_shape=(28, 28)))
model.add(Dense(512, activation='relu'))
model.add(Dense(512, activation='relu'))
model.add(Dense(10, activation='softmax'))
model.compile(loss='sparse_categorical_crossentropy', optimizer='adam', 
              metrics=['acc'])

from tensorflow.keras.datasets import mnist
(x_train, y_train), (x_test, y_test) = mnist.load_data()     ❶

model.fit(x_train, y_train, epochs=10)                       ❷

❶ 将 MNIST 数据集加载到内存中

❷ 对 MNIST 模型进行 10 个 epoch 的训练

在第 10 个 epoch 后,你应该看到训练数据上的准确率大约为 97%:

Epoch 10/10
60000/60000 [==============================] - 5s 83us/sample - loss: 
0.0924 - acc: 0.9776

4.3 数据正则化

我们可以进一步改进这一点。通过mnist()模块加载的图像数据是原始格式;每个图像是一个 28 × 28 的整数值矩阵,范围从 0 到 255。如果你检查训练模型内的参数(权重和偏差),它们是非常小的数字,通常从-1 到 1。通常,当数据通过层前馈并通过一层参数矩阵乘以下一层的参数时,结果是一个非常小的数字。

我们前面示例的问题在于输入值相当大(高达 255),这将在通过层乘法时产生很大的初始数值。这将导致参数学习它们的最佳值需要更长的时间——如果它们能学习的话。

4.3.1 正则化

我们可以通过将输入值压缩到更小的范围来增加参数学习最优值的速度,并提高我们的收敛概率(随后讨论)。一种简单的方法是将它们按比例压缩到 0 到 1 的范围。我们可以通过将每个值除以 255 来实现这一点。

在下面的代码中,我们添加了通过将每个像素值除以 255 来规范化输入数据的步骤。load_data()函数以 NumPy 格式将数据集加载到内存中。NumPy是一个用 C 语言编写并带有 Python 包装器(CPython)的高性能数组处理模块,在模型训练期间,当整个训练数据集都在内存中时,它非常高效。第十三章涵盖了当训练数据集太大而无法放入内存时的方法和格式。

一个NumPy 数组是一个实现算术运算符多态性的类对象。在我们的例子中,我们展示了单个除法操作(x_train / 255.0)。除法运算符被 NumPy 数组覆盖,并实现了广播操作——这意味着数组中的每个元素都将除以 255.0。

默认情况下,NumPy 使用双精度(64 位)进行浮点运算。默认情况下,TF.Keras 模型中的参数是单精度浮点数(32 位)。为了提高效率,作为最后一步,我们使用 NumPy 的astype()方法将广播除法的结果转换为 32 位。如果我们没有进行转换,从输入层到输入层的初始矩阵乘法将需要双倍的机器周期(64×32 而不是 32×32):

from tensorflow.keras import Sequential
from tensorflow.keras.layers import Flatten, Dense
import numpy as np

model = Sequential()
model.add(Flatten(input_shape=(28, 28)))
model.add(Dense(512, activation='relu'))
model.add(Dense(512, activation='relu'))
model.add(Dense(10, activation='softmax'))
model.compile(loss='sparse_categorical_crossentropy', optimizer='adam', 
              metrics=['acc'])

from tensorflow.keras.datasets import mnist
(x_train, y_train), (x_test, y_test) = mnist.load_data()
x_train = (x_train / 255.0).astype(np.float32)              ❶
x_test  = (x_test  / 255.0).astype(np.float32)              ❶

model.fit(x_train, y_train, epochs=10)

❶ 将像素数据从 0 标准化到 1

下面的输出是运行前面代码的结果。让我们将输出与先前非标准化输入的标准化输入进行比较。在先前的输入中,我们在第 10 个 epoch 后达到了 97%的准确率。在我们的标准化输入中,我们只需第二个 epoch 就达到了相同的准确率,在第 10 个 epoch 后几乎达到了 99.5%的准确率。因此,当我们标准化输入数据时,我们学得更快,更准确:

...
Epoch 2/10
60000/60000 [==============================] - 5s 84us/sample - loss: 
0.0808 - acc: 0.9744
...
Epoch 10/10
60000/60000 [==============================] - 5s 81us/sample - loss: 
0.0187 - acc: 0.9943

现在,让我们通过在测试(保留)数据上使用evaluate()方法来评估我们的模型,看看模型在训练期间从未见过的数据上的表现如何。evaluate()方法在推理模式下操作:测试数据被正向传递通过模型进行预测,但没有反向传播。模型的参数不会被更新。最后,evaluate()将输出损失和整体准确率:

model.evaluate(x_test, y_test)

在以下输出中,我们看到准确率约为 98%,与训练准确率 99.5%相比。这是预期的。在训练过程中总会发生一些过拟合。我们寻找的是训练和测试之间非常小的差异,在这种情况下大约是 1.5%:

10000/10000 [==============================] - 0s 23us/sample - loss:
0.0949 - acc: 0.9790

4.3.2 标准化

除了前面示例中使用的归一化之外,还有许多方法可以压缩输入数据。例如,一些机器学习从业者更喜欢将输入值压缩在-1 和 1 之间(而不是 0 和 1),这样值就集中在 0。以下代码是一个示例实现,它将每个元素除以最大值的一半(在这个例子中是 127.5),然后从结果中减去 1:

x_train = ((x_train / 127.5) - 1).astype(np.float32)

将值压缩在-1 和 1 之间是否比压缩在 0 和 1 之间产生更好的结果?我在研究文献或我的个人经验中都没有看到任何表明有差异的内容。

这种方法和之前的方法不需要对输入数据进行任何预分析,除了知道最大值。另一种称为标准化的技术被认为可以产生更好的结果。然而,它需要对整个输入数据进行预分析(扫描)以找到其平均值和标准差。然后,你将数据中心化在输入数据全分布的平均值处,并将值压缩在+/-一个标准差之间。以下代码实现了当输入数据作为 NumPy 多维数组存储在内存中时的标准化,使用了 NumPy 方法np.mean()np.std()

import numpy as np
mean = np.mean(x_train)                                 ❶
std = np.std(x_train)                                   ❷
x_train = ((x_train - mean) / std).astype(np.float32)   ❸

❶ 计算像素数据的平均值

❷ 计算像素数据的标准差

❸ 使用均值和标准差对像素数据进行标准化

4.4 验证和过拟合

本节演示了一个过拟合的案例,然后展示了如何在训练过程中检测过拟合以及我们可能如何解决这个问题。让我们重新回顾一下过拟合的含义。通常,为了获得更高的准确率,我们会构建更大和更大的模型。一个后果是模型可以死记硬背一些或所有示例。模型学习的是示例,而不是从示例中学习泛化,以准确预测训练过程中从未见过的示例。在极端情况下,一个模型可以达到 100%的训练准确率,但在测试(对于 10 个类别,那就是 10%的准确率)时具有随机准确率。

4.4.1 验证

假设训练模型需要几个小时。你真的想等到训练结束再在测试数据上测试,以了解模型是否过拟合吗?当然不想。相反,我们留出一小部分训练数据,我们称之为验证数据

我们不使用验证数据来训练模型。相反,在每个 epoch 之后,我们使用验证数据来估计测试数据的可能结果。像测试数据一样,验证数据通过模型前馈(推理模式)而不更新模型的参数,我们测量损失和准确率。图 4.5 描述了此过程。

图 4.5 在每个 epoch,使用验证数据来估计测试数据的可能准确率。

如果数据集非常小,并且使用更少的数据进行训练会产生负面影响,我们可以使用交叉验证。不是一开始就留出一部分模型永远不会训练的训练数据,而是在每个 epoch 进行随机分割。在每个 epoch 的开始,随机选择验证示例,并在此 epoch 中不用于训练,而是用于验证测试。但由于选择是随机的,一些或所有示例将出现在其他 epoch 的训练数据中。今天的 datasets 都很大,所以很少需要这种技术。图 4.6 说明了数据集的交叉验证分割。

图 4.6 在每个 epoch,随机选择一个折作为验证数据。

接下来,我们将训练一个简单的 CNN 来对 CIFAR-10 数据集中的图像进行分类。我们的数据集是此小型图像数据集的一个子集,大小为 32 × 32 × 3。它包含 60,000 个训练图像和 10,000 个测试图像,涵盖了 10 个类别:飞机、汽车、鸟、猫、鹿、狗、青蛙、马、船和卡车。

在我们的简单 CNN 中,我们有一个 32 个滤波器的 3 × 3 核大小的卷积层,后面跟着一个步长最大池化层。然后输出被展平并传递到最后一个输出密集层。图 4.7 说明了这个过程。

图 4.7 用于分类 CIFAR-10 图像的简单卷积神经网络

这是训练我们的简单 CNN 的代码:

from tensorflow.keras import Sequential
from tensorflow.keras.layers import Flatten, Dense, Conv2D, MaxPooling2D
import numpy as np

model = Sequential()
model.add(Conv2D(32, (3, 3), activation='relu', input_shape=(32, 32, 3)))
model.add(MaxPooling2D((2, 2)))
model.add(Flatten())
model.add(Dense(10, activation='softmax'))
model.compile(loss='sparse_categorical_crossentropy', optimizer='adam', 
              metrics=['acc'])

from tensorflow.keras.datasets import cifar10
(x_train, y_train), (x_test, y_test) = cifar10.load_data()
x_train = (x_train / 255.0).astype(np.float32)
x_test  = (x_test  / 255.0).astype(np.float32)

model.fit(x_train, y_train, epochs=15, validation_split=0.1)    ❶

❶ 使用 10%的训练数据用于验证——未进行训练

在这里,我们已将关键字参数 validation_split=0.1 添加到 fit() 方法中,以便在每个 epoch 之后为验证测试保留 10%的训练数据。

以下是在运行 15 个 epochs 后的输出。你可以看到,在第 4 个 epoch 之后,训练和评估准确率基本上是相同的。但在第 5 个 epoch 之后,我们开始看到它们开始分散(65%对 61%)。到第 15 个 epoch 时,分散非常大(74%对 63%)。我们的模型显然在第 5 个 epoch 开始过拟合:

Train on 45000 samples, validate on 5000 samples
... 
Epoch 4/15
45000/45000 [==============================] - 8s 184us/sample - loss: 1.0444 
➥ - acc: 0.6386 - val_loss: 1.0749 - val_acc: 0.6374                      ❶
Epoch 5/15
45000/45000 [==============================] - 9s 192us/sample - loss: 0.9923 
➥ - acc: 0.6587 - val_loss: 1.1099 - val_acc: 0.6182                      ❷
...
Epoch 15/15
45000/45000 [==============================] - 8s 180us/sample - loss: 0.7256 
➥ - acc: 0.7498 - val_loss: 1.1019 - val_acc: 0.6382                      ❸

❶ 在第 4 个 epoch 之后,训练数据和验证数据的准确率大致相同。

❷ 在第 5 个 epoch 之后,训练数据和验证数据之间的准确率开始分散。

❸ 在第 15 个 epoch 之后,训练数据和验证数据之间的准确率相差甚远。

现在我们来让模型不要过度拟合示例,而是从它们中泛化。正如前面章节所讨论的,我们希望在训练期间添加一些正则化——一些噪声——这样模型就不能死记硬背训练示例。在这个代码示例中,我们通过在最终密集层之前添加 50%的 dropout 来修改我们的模型。由于 dropout 会减慢我们的学习(因为遗忘),我们将 epochs 的数量增加到 20:

from tensorflow.keras import Sequential
from tensorflow.keras.layers import Flatten, Dense, Conv2D
from tensorflow.keras.layers import MaxPooling2D, Dropout
import numpy as np

model = Sequential()
model.add(Conv2D(32, (3, 3), activation='relu', input_shape=(32, 32, 3)))
model.add(MaxPooling2D((2, 2)))
model.add(Flatten(input_shape=(28, 28)))
model.add(Dropout(0.5))                          ❶
model.add(Dense(10, activation='softmax'))
model.compile(loss='sparse_categorical_crossentropy', optimizer='adam', 
              metrics=['acc'])

from tensorflow.keras.datasets import cifar10
(x_train, y_train), (x_test, y_test) = cifar10.load_data()
x_train = (x_train / 255.0).astype(np.float32)
x_test  = (x_test  / 255.0).astype(np.float32)

model.fit(x_train, y_train, epochs=20, validation_split=0.1)

❶ 向训练中添加噪声以防止过拟合

从以下输出中我们可以看到,虽然达到可比的训练准确率需要更多的 epochs,但训练和测试准确率是可比的。因此,模型正在学习泛化而不是死记硬背训练示例:

Epoch 18/20
45000/45000 [==============================] - 18s 391us/sample - loss: 
➥ 1.0029 - acc: 0.6532 - val_loss: 1.0069 - val_acc: 0.6600              ❶
Epoch 19/20
45000/45000 [==============================] - 17s 377us/sample - loss: 
➥ 0.9975 - acc: 0.6538 - val_loss: 1.0388 - val_acc: 0.6478              ❶
Epoch 20/20
45000/45000 [==============================] - 17s 381us/sample - loss: 
➥ 0.9891 - acc: 0.6568 - val_loss: 1.0562 - val_acc: 0.6502              ❶

❶ 通过使用 dropout 添加噪声,可以保持训练和验证准确率不偏离。

4.4.2 损失监控

到目前为止,我们一直专注于准确率。你看到的另一个输出指标是训练和验证数据批次的平均损失。理想情况下,我们希望看到每个 epoch 准确率的持续增加。但我们也可能看到一系列的 epochs,其中准确率持平或甚至波动±一小部分。

重要的是我们看到损失持续下降。在这种情况下,平台期或波动发生是因为我们接近或悬浮在线性分离的线上,或者还没有完全越过一条线,但随着损失的下降,我们正在接近。

让我们从另一个角度来观察。假设你正在构建一个用于区分狗和猫的分类器。在分类层上,你有两个输出节点:一个用于猫,一个用于狗。假设在某个特定的批次中,当模型错误地将狗分类为猫时,输出值(置信度)为猫 0.6,狗 0.4。在随后的批次中,当模型再次将狗错误分类为猫时,输出值变为 0.55(猫)和 0.45(狗)。这些值现在更接近真实值,因此损失正在减少,但它们仍未通过 0.5 的阈值,因此精度尚未改变。然后假设在另一个随后的批次中,狗图像的输出值为 0.49(猫)和 0.51(狗);损失进一步减少,因为我们越过了 0.5 的阈值,精度有所上升。

4.4.3 使用层深入探索

如前几章所述,仅仅通过增加层数来深入探索,可能会导致模型不稳定,而没有解决像身份链接和批量归一化等技术问题。例如,我们进行矩阵乘法的许多值都是小于 1 的小数。乘以两个小于 1 的数,你会得到一个更小的数。在某个点上,数值变得如此之小,以至于硬件无法表示该值,这被称为梯度消失。在其他情况下,参数可能过于接近以至于无法区分——或者相反,分布得太远,这被称为梯度爆炸

以下代码示例通过使用一个没有采用防止数值不稳定性方法(如每个密集层后的批量归一化)的 40 层深度神经网络(DNN)来演示这一点:

model = Sequential()
model.add(Dense(64, activation='relu', input_shape=(28, 28))
for _ in range(40):                                              ❶
    model.add(Dense(64, activation='relu'))                      ❶
model.add(Dense(10, activation='softmax'))
model.compile(loss='sparse_categorical_crossentropy', optimizer='adam', 
              metrics=['acc'])

from tensorflow.keras.datasets import mnist
(x_train, y_train), (x_test, y_test) = mnist.load_data()
x_train = (x_train / 255.0).astype(np.float32)
x_test  = (x_test  / 255.0).astype(np.float32)

model.fit(x_train, y_train, epochs=10, validation_split=0.1)

❶ 构建一个包含 40 个隐藏层的模型

在以下输出中,你可以看到在前三个 epoch 中,训练和评估数据上的精度持续增加,相应的损失持续减少。但之后,精度变得不稳定;模型数值不稳定:

Train on 54000 samples, validate on 6000 samples
Epoch 1/10
54000/54000 [==============================] - 9s 161us/sample - loss: 1.4461 
➥ - acc: 0.4367 - val_loss: 0.8802 - val_acc: 0.7223
Epoch 2/10
54000/54000 [==============================] - 7s 134us/sample - loss: 0.8054 
➥ - acc: 0.7202 - val_loss: 0.7419 - val_acc: 0.7727
Epoch 3/10
54000/54000 [==============================] - 7s 136us/sample - loss: 0.8606 
➥ - acc: 0.7530 - val_loss: 0.6923 - val_acc: 0.8352                       ❶
Epoch 4/10
54000/54000 [==============================] - 8s 139us/sample - loss: 0.8743 
➥ - acc: 0.7472 - val_loss: 0.7726 - val_acc: 0.7617
Epoch 5/10
54000/54000 [==============================] - 8s 139us/sample - loss: 0.7491 
➥ - acc: 0.7863 - val_loss: 0.9322 - val_acc: 0.7165                       ❷
Epoch 6/10
54000/54000 [==============================] - 7s 134us/sample - loss: 0.9151 
➥ - acc: 0.7087 - val_loss: 0.8160 - val_acc: 0.7573                       ❷
Epoch 7/10
54000/54000 [==============================] - 7s 135us/sample - loss: 0.9764 
➥ - acc: 0.6836 - val_loss: 0.7796 - val_acc: 0.7555                       ❷
Epoch 8/10
54000/54000 [==============================] - 7s 134us/sample - loss: 0.8836 
➥ - acc: 0.7202 - val_loss: 0.8348 - val_acc: 0.7382
Epoch 9/10
54000/54000 [==============================] - 8s 140us/sample - loss: 0.7975 
➥ - acc: 0.7626 - val_loss: 0.7838 - val_acc: 0.7760
Epoch 10/10
54000/54000 [==============================] - 8s 140us/sample - loss: 0.7317 
➥ - acc: 0.7719 - val_loss: 0.5664 - val_acc: 0.8282

❶ 模型精度在训练和评估数据上稳定提升。

❷ 模型精度在训练和评估数据上变得不稳定。

4.5 收敛

在训练阶段早期的假设是,你将训练数据喂给模型的次数越多,精度就越好。但我们发现,尤其是在更大、更复杂的网络中,在某个点上,精度会下降。今天,我们根据模型在应用中的使用方式,寻找在可接受的局部最优解上的收敛。如果我们过度训练神经网络,以下情况可能会发生:

  • 神经网络对训练数据过度拟合,显示在训练数据上的精度增加,但在测试数据上的精度下降。

  • 在更深的神经网络中,层将以非均匀的方式学习,并具有不同的收敛速率。因此,当一些层正在向收敛迈进时,其他层可能已经收敛并开始发散。

  • 继续训练可能会导致神经网络跳出局部最优,并开始收敛到另一个更不准确的局部最优。

图 4.8 显示了在训练模型时理想情况下我们希望看到的收敛情况。你开始时在早期 epoch 中损失减少得相当快,随着训练逐渐接近(近)最优解,减少的速度减慢,然后最终停滞——这时,你达到了收敛。

图 4.8 当损失停滞时发生收敛。

让我们从使用 CIFAR-10 数据集在 TF.Keras 中构建一个简单的 ConvNet 模型开始,以演示收敛和发散的概念。在这段代码中,我故意省略了防止过拟合的方法,如 dropout 或批量归一化:

from tensorflow.keras import Sequential
from tensorflow.keras.layers import Conv2D, MaxPooling2D
from tensorflow.keras.layers import Dropout, Flatten, Dense
from tensorflow.keras.datasets import cifar10
from tensorflow.keras.utils import to_categorical
import numpy as np

(x_train, y_train), (x_test, y_test) = cifar10.load_data()

height = x_train.shape[1]                              ❶
width  = x_train.shape[2]                              ❶

x_train = (x_train / 255.0).astype(np.float32)         ❷
x_test  = (x_test  / 255.0).astype(np.float32)         ❷

model = Sequential()
model.add(Conv2D(32, kernel_size=(3, 3),
                 activation='relu',
                 input_shape=(height, width, 3)))      ❸
model.add(Conv2D(64, (3, 3), activation='relu'))
model.add(MaxPooling2D(pool_size=(2, 2)))
model.add(Flatten())
model.add(Dense(128, activation='relu'))
model.add(Dense(10, activation='softmax'))
model.compile(loss='sparse_categorical_crossentropy', optimizer='adam',
              metrics=['accuracy'])

model.fit(x_train, y_train, epochs=20, validation_split=0.1)

❶ 计算数据集中图像的高度和宽度

❷ 标准化输入数据

❸ 将模型的输入形状设置为数据集中图像的高度和宽度

前六个 epoch 的统计数据如下。你可以看到每次通过时损失都在稳步减少,这意味着神经网络正在接近拟合数据。此外,训练数据的准确率从 52.35%上升到 87.4%,验证数据的准确率从 63.46%上升到 67.14%:

Train on 45000 samples, validate on 5000 samples
Epoch 1/20
45000/45000 [==============================] - 53s 1ms/sample - loss: 1.3348 
➥ - acc: 0.5235 - val_loss: 1.0552 - val_acc: 0.6346                       ❶
Epoch 2/20
45000/45000 [==============================] - 52s 1ms/sample - loss: 0.9527 
➥ - acc: 0.6667 - val_loss: 0.9452 - val_acc: 0.6726
Epoch 3/20
45000/45000 [==============================] - 52s 1ms/sample - loss: 0.7789 
➥ - acc: 0.7252 - val_loss: 0.9277 - val_acc: 0.6882
Epoch 4/20
45000/45000 [==============================] - 419s 9ms/sample - loss: 0.6328 
➥ - acc: 0.7785 - val_loss: 0.9324 - val_acc: 0.6964
Epoch 5/20
45000/45000 [==============================] - 53s 1ms/sample - loss: 0.4855 
➥ - acc: 0.8303 - val_loss: 1.0453 - val_acc: 0.6860
Epoch 6/20
45000/45000 [==============================] - 51s 1ms/sample - loss: 0.3575 
➥ - acc: 0.8746 - val_loss: 1.2903 - val_acc: 0.6714                       ❷

❶ 训练数据的初始损失

❷ 训练数据损失稳步下降,但在验证数据损失上有拟合数据的迹象

现在我们来看第 11 到 20 个 epoch。你可以看到我们在训练数据上达到了 98.46%,这意味着我们非常紧密地拟合了它。另一方面,我们的验证数据准确率在 66.58%处停滞。因此,经过六个 epoch 后,继续训练没有提供任何改进,我们可以得出结论,在第 7 个 epoch 时,模型已经过拟合到训练数据:

Epoch 11/20
45000/45000 [==============================] - 52s 1ms/sample - loss: 0.0966 
➥ - acc: 0.9669 - val_loss: 2.1891 - val_acc: 0.6694                     ❶
Epoch 12/20
45000/45000 [==============================] - 50s 1ms/sample - loss: 0.0845 
➥ - acc: 0.9712 - val_loss: 2.3046 - val_acc: 0.6666
.....
Epoch 20/20
45000/45000 [==============================] - 1683s 37ms/sample - loss: 
➥ 0.0463 - acc: 0.9848 - val_loss: 3.1512 - val_acc: 0.6658              ❷

❶ 验证损失持续上升,而模型变得非常过拟合训练数据。

❷ 验证损失非常高,模型高度拟合训练数据。

训练数据和验证数据的损失函数值也表明模型正在过拟合。训练数据在第 11 到 20 个 epoch 之间的损失函数值持续减小,但对于相应的验证数据,它停滞并变得更差(发散)。

4.6 检查点和提前停止

本节介绍了两种使训练更经济的技巧:检查点和提前停止。当模型过拟合并发散时,检查点非常有用,我们希望在收敛点恢复模型权重,而不需要额外的重新训练成本。你可以将提前停止视为检查点的扩展。我们有一个监控系统在最早的时刻检测到发散,然后停止训练,在发散点恢复检查点时节省额外的成本。

4.6.1 检查点

检查点是指在训练过程中定期保存学习到的模型参数和当前超参数值。这样做有两个原因:

  • 为了能够从上次停止的地方恢复模型的训练,而不是从头开始重新训练

  • 为了识别训练中模型给出最佳结果的过去某个点

在第一种情况下,我们可能希望将训练分散到不同的会话中,作为管理资源的一种方式。例如,我们可能每天预留(或被授权)一个小时用于训练。每天一小时的训练结束后,训练将进行检查点保存。第二天,训练将通过从检查点恢复来继续。例如,你可能在一家有固定计算费用预算的研究机构工作,你的团队正在尝试训练一个计算成本较高的模型。为了管理预算,你的团队可能被分配了每日计算费用的限额。

为什么仅仅保存模型的权重和偏差就不够呢?在神经网络中,一些超参数值会动态变化,例如学习率和衰减。我们希望在训练暂停时的相同超参数值下继续。

在另一种场景中,我们可能将连续学习作为持续集成和持续交付(CI/CD)过程的一部分来实现。在这种情况下,新的标记图像会持续添加到训练数据中,我们只想增量地重新训练模型,而不是在每个集成周期从头开始重新训练。

在第二种情况下,我们可能希望在模型训练超过最佳最优值并开始发散和/或过拟合后找到最佳结果。我们不想从更少的 epoch(或其他超参数变化)开始重新训练,而是识别出达到最佳结果的 epoch,并将学习到的模型参数恢复(设置)为该 epoch 结束时检查点的参数。

检查点在每个 epoch 结束时发生,但我们是否应该在每个 epoch 后都进行检查点保存?可能不是。这可能会在空间上变得昂贵。让我们假设模型有 2500 万个参数(例如,ResNet50),每个参数是一个 32 位的浮点值(4 字节)。那么每个检查点就需要 100 MB 来保存。经过 10 个 epoch 后,这已经需要 1 GB 的磁盘空间了。

我们通常只在模型参数数量较小和/或 epoch 数量较小的情况下在每个 epoch 后进行检查点保存。在下面的代码示例中,使用ModelCheckpoint类实例化了一个检查点。参数filepath表示检查点的文件路径。文件路径可以是完整的文件路径或格式化的文件路径。在前者的情况下,检查点文件每次都会被覆盖。

在下面的代码中,我们使用格式语法epoch:02d为每个检查点生成一个唯一的文件,基于 epoch 编号。例如,如果是第三个 epoch,文件将是 mymodel-03.ckpt:

from tensorflow.keras.callbacks import ModelCheckpoint               ❶

filepath = "mymodel-{epoch:02d}.ckpt"                                ❷

checkpoint = ModelCheckpoint(filepath)                               ❸

model.fit(x_train, y_train, epochs=epochs, callbacks=[checkpoint])   ❹

❶ 导入 ModelCheckpoint 类

❷ 为每个 epoch 设置唯一的文件路径名

❸ 创建一个 ModelCheckpoint 对象

❹ 训练模型并使用回调参数启用检查点

然后,可以使用 load_model() 方法从检查点恢复模型:

from tensorflow.keras.models import load_model    ❶

model = load_model('mymodel-03.ckpt')             ❷

❶ 导入 load_model 方法

❷ 从保存的检查点恢复模型

对于具有更多参数和/或 epoch 数的模型,我们可以选择使用参数 period 在每 n 个 epoch 上保存一个检查点。在这个例子中,每四个 epoch 保存一个检查点:

from tensorflow.keras.callbacks import ModelCheckpoint

filepath = "mymodel-{epoch:02d}.ckpt"

checkpoint = ModelCheckpoint(filepath, period=4)       ❶

model.fit(x_train, y_train, epochs=epochs, callbacks=[checkpoint])

❶ 每四个 epoch 创建一个检查点

或者,我们可以使用参数 save_best_only=True 和参数 monitor 来保存当前最佳检查点,以基于测量结果做出决策。例如,如果参数 monitor 设置为 val_acc,则只有在验证准确率高于上次保存的检查点时才会写入检查点。如果参数设置为 val_loss,则只有在验证损失低于上次保存的检查点时才会写入检查点:

from tensorflow.keras.callbacks import ModelCheckpoint

filepath = "mymodel-best.ckpt"                                   ❶
checkpoint = ModelCheckpoint(filepath, save_best_only=True, 
➥ monitor='val_acc')                                            ❷

model.fit(x_train, y_train, epochs=epochs, callbacks=[checkpoint])

❶ 保存最佳检查点的文件路径

❷ 仅当验证损失小于上次检查点时保存检查点

4.6.2 提前停止

提前停止 是设置一个条件,使得训练在设定的限制(例如,epoch 数)之前提前终止。这通常是为了在达到目标目标时(例如,准确率水平或评估损失的收敛)节省资源或防止过拟合而设置的。例如,我们可能会设置 20 个 epoch 的训练,每个 epoch 平均 30 分钟,总共 10 小时。但如果目标在 8 个 epoch 后达成,提前终止训练将节省 6 小时资源。

提前停止的指定方式与检查点类似。实例化一个 EarlyStopping 对象,并配置一个目标目标,然后将其传递给 fit() 方法的 callbacks 参数。在这个例子中,只有在验证损失停止减少时,训练才会提前停止:

from tensorflow.keras.callbacks import EarlyStopping                 ❶

earlystop = EarlyStopping(monitor='val_loss')                        ❷

model.fit(x_train, y_train, epochs=epochs, callbacks=[earlystop])    ❸

❶ 导入 EarlyStopping 类

❷ 当验证损失停止减少时设置提前停止

❸ 训练模型并使用提前停止,如果验证损失停止减少则提前停止训练

除了监控验证损失以实现提前停止外,我们还可以通过参数设置 monitor="val_acc" 监控验证准确率。存在一些额外的参数用于微调,以防止意外提前停止;例如,在损失曲线上陷入鞍点(一个损失曲线的平坦区域)时,更多的训练将克服这种情况。参数 patience 指定了在提前停止之前没有改进的最小 epoch 数,而 min_delta 指定了确定模型是否改进的最小阈值。在这个例子中,如果在三个 epoch 后验证损失没有改进,训练将提前停止:

from tensorflow.keras.callbacks import EarlyStopping

earlystop = EarlyStopping(monitor='val_loss', patience=3)     ❶

model.fit(x_train, y_train, epochs=epochs, callbacks=[earlystop])

❶ 当验证损失停止减少三个 epoch 时设置提前停止

4.7 超参数

让我们先解释一下学习参数和超参数之间的区别。学习参数,权重和偏差,是在训练过程中学习的。对于神经网络来说,这些通常是每个神经网络连接上的权重和每个节点的偏差。对于卷积神经网络(CNNs),学习参数是每个卷积层中的过滤器。这些学习参数在模型完成训练后仍作为模型的一部分。

超参数是用于训练模型的参数,但它们本身不是训练模型的一部分。训练后,超参数不再存在。超参数通过回答诸如这些问题来提高模型的训练效果:

  • 训练模型需要多长时间?

  • 模型收敛有多快?

  • 它是否找到了全局最优解?

  • 模型的准确度如何?

  • 模型过拟合的程度如何?

超参数的另一个视角是,它们是衡量模型开发成本和质量的一种手段。随着我们在第十章进一步探讨超参数,我们将深入研究这些问题和其他问题。

4.7.1 Epochs

最基本的超参数是 epoch 的数量,尽管现在这更常见地被步骤所取代。epoch 超参数是在训练过程中你将整个训练数据通过神经网络的次数。

训练在计算时间上非常昂贵。它包括正向传播将训练数据传递过去和反向传播来更新(训练)模型的参数。例如,如果一次完整的数据传递(epoch)需要 15 分钟,而我们运行 100 个 epoch,训练时间将需要 25 小时。

4.7.2 步骤

提高准确度和减少训练时间的另一种方法是改变训练数据集的抽样分布。对于 epoch,我们考虑从我们的训练数据中按顺序抽取批次。尽管我们在每个 epoch 的开始时随机打乱训练数据,但抽样分布仍然是相同的。

让我们现在考虑我们想要识别的主题的整个群体。在统计学中,我们称这为总体分布(图 4.9)。

图 4.9 总体分布与人群中的随机样本之间的区别

但我们永远不会有一个数据集是实际的整个总体分布。相反,我们有样本,我们称之为总体分布的抽样分布(图 4.10)。

图 4.10 由人群中的随机样本组成的抽样分布。

提高我们的模型的另一种方法是学习训练模型的最佳抽样分布。尽管我们的数据集可能是固定的,但我们可以使用几种技术来改变分布,从而学习最适合训练模型的抽样分布。这些方法包括以下内容:

  • 正则化/丢弃

  • 批标准化

  • 数据增强

从这个角度来看,我们不再将神经网络视为对训练数据的顺序遍历,而是将其视为从训练数据的采样分布中进行随机抽取。在这种情况下,步骤指的是我们将从训练数据的采样分布中抽取的批次(抽取)的数量。

当我们在神经网络中添加 dropout 层时,我们是在每个样本的基础上随机丢弃激活。除了减少神经网络的过拟合,我们还改变了分布。

在批量归一化中,我们最小化训练数据批次(样本)之间的协方差偏移。正如我们在输入上使用标准化一样,激活也被使用标准化重新缩放(我们减去批次均值并除以批次标准差)。这种归一化减少了模型参数更新中的波动;这个过程被称为增加更多稳定性到训练中。此外,这种归一化模仿从更具有代表性的总体分布的采样分布中抽取的过程。

通过数据增强(在第十三章中讨论),我们在一组参数内修改现有示例来创建新的示例。然后我们随机选择修改,这也有助于改变分布。

在批量归一化、正则化/dropout 和数据增强的情况下,没有两个 epoch 会有相同的采样分布。在这种情况下,现在的做法是限制从每个新的采样分布中随机抽取(步骤)的数量,进一步改变分布。例如,如果步骤设置为 1000,那么每个 epoch 中,只有 1000 个随机批次将被选中并输入到神经网络中进行训练。

在 TF.Keras 中,我们可以将epochssteps_per_epoch参数指定为fit()方法的参数,作为参数:

model.fit(x_train, y_train, batch_size=batch_size, epochs=epochs,
          steps_per_epoch=1000)

4.7.3 批量大小

要了解如何设置批量大小,你应该对三种梯度下降算法有基本的了解:随机梯度下降、批量梯度下降和迷你批量梯度下降。算法是模型参数在训练期间更新的(学习)手段。

随机梯度下降

随机梯度下降SGD)中,模型在训练过程中每个示例被输入后更新。由于每个示例都是随机选择的,因此示例之间的方差可能导致梯度的大幅波动。

一个好处是,在训练过程中,我们不太可能收敛到局部(即较差的)最优解,而更有可能找到全局最优解并收敛。另一个好处是,损失变化的速率可以实时监控,这可能有助于自动超参数调整的算法。缺点是这每轮计算成本更高。

批量梯度下降

批量梯度下降中,每个示例的错误损失是在训练过程中每个示例被输入时计算的,但模型的更新是在每个 epoch 结束时(在所有训练数据通过之后)进行的。因此,由于它是基于所有示例的损失来计算的,而不是单个示例,所以梯度被平滑了。

这种方法的优点是每个 epoch 的计算成本更低,训练过程更可靠地收敛。缺点是模型可能收敛到一个不太准确的局部最优解,并且需要运行整个 epoch 来监控性能数据。

小批量梯度下降

小批量梯度下降方法是在随机梯度下降和批量梯度下降之间的折中。不是单个示例或所有示例,神经网络被输入到小批量中,这些小批量是整个训练数据的一个子集。小批量的大小越小,训练越类似于随机梯度下降,而较大的批量大小则更类似于批量梯度下降。

对于某些模型和数据集,随机梯度下降(SGD)效果最好。通常,使用小批量梯度下降的折中方案是一种常见做法。超参数batch_size表示小批量的大小。由于硬件架构,最节省时间/空间的批量大小是 8 的倍数,例如 8、16、32 和 64。首先尝试的批量大小通常是 32,然后是 128。对于在高端硬件(HW)加速器(如 GPU 和 TPU)上的极大数据集,常见的批量大小是 256 和 512。在 TF.Keras 中,你可以在模型的fit()方法中指定batch_size

model.fit(x_train, y_train, batch_size=32)

4.7.4 学习率

学习率通常是超参数中最有影响力的。它可以对训练神经网络所需的时间长度以及神经网络是否收敛到局部(较差)最优解,以及是否收敛到全局(最佳)最优解产生重大影响。

在反向传播过程中更新模型参数时,梯度下降算法被用来从损失函数中导出一个值,以添加/减去到模型参数中。这些添加和减去可能会导致参数值的大幅波动。如果一个模型有并且继续有参数值的大幅波动,那么模型的参数将会“四处乱飞”并且永远不会收敛。

如果你观察到损失和/或准确性的波动很大,那么你的模型训练并没有收敛。如果训练没有收敛,那么运行多少个 epoch 都没有关系;模型永远不会完成训练。

学习率为我们提供了一种控制模型参数更新程度的方法。在基本方法中,学习率是 0 到 1 之间的一个固定系数,它乘以要添加/减去的值,以减少添加或减去的量。这些较小的增量在训练期间增加了稳定性,并增加了收敛的可能性。

小学习率与大脑学习率

如果我们使用一个非常小的学习率,如 0.001,我们将在更新模型参数时消除大的摆动。这通常可以保证训练将收敛到一个局部最优解。但有一个缺点。首先,我们使增量越小,需要的训练数据(周期)遍历次数越多,以最小化损失。这意味着需要更多的时间来训练。其次,增量越小,训练探索其他局部最优解的可能性就越小,这些局部最优解可能比训练收敛到的更准确;相反,它可能收敛到一个较差的局部最优解或卡在鞍点上。

一个大的学习率,如 0.1,在更新模型参数时很可能会引起大的跳跃。在某些情况下,它可能最初导致更快的收敛(更少的周期)。缺点是,即使你最初收敛得很快,跳跃可能会超过并开始导致收敛来回摆动,或者跳到不同的局部最优解。在非常高的学习率下,训练可能开始发散(损失增加)。

许多因素有助于确定在训练过程中的不同时间点最佳学习率是什么。在最佳实践中,该速率将在 10e-5 到 0.1 之间。

这是一个基本的公式,通过将学习率乘以计算出的添加/减去的量(梯度)来调整权重:

weight += -learning_rate * gradient

衰减

一种常见的做法是开始时使用稍微大一点的学习率,然后逐渐减小它,这也被称为学习率衰减。较大的学习率最初会探索不同的局部最优解,以收敛到并使初始深度摆动到相应的局部最优解。收敛速率和最小化损失函数的初始更新可以用来聚焦于最佳(好的)局部最优解。

从那个点开始,学习率逐渐衰减。随着学习率的衰减,出现偏离良好局部最优解的摆动可能性降低,稳定下降的学习率将调整收敛,使其接近最小点(尽管,越来越小的学习率会增加训练时间)。因此,衰减成为在最终精度的小幅度提升和整体训练时间之间的权衡。

以下是一个基本公式,在更新权重计算中添加衰减:在每次更新中,学习率通过衰减量(称为固定衰减)减少:

weight += -learning_rate * gradient
learning_rate -= decay

在实践中,衰减公式通常是基于时间、步长或余弦衰减。这些公式可以用简化的术语表达,迭代可以是批量或周期。默认情况下,TF.Keras 优化器使用基于时间的衰减。公式如下:

  • 基于时间的衰减

    learning_rate *= (1 / (1 + decay * iteration))
    
  • 步长衰减

    learning_rate = initial_learning_rate * decay**iteration
    
  • 余弦衰减

    learning_rate = c * (1 + cos(pi * (steps_per_epoch * interaction)/epochs))
    # where c is typically in range 0.45 to 0.55
    

动量

另一种常见的做法是根据先前变化来加速或减速变化率。如果我们有大的收敛跳跃,我们可能会跳出局部最优,所以我们可能希望减速学习率。如果我们有小的或没有收敛变化,我们可能希望加速学习率以跳过一个鞍点。通常,动量的值在 0.5 到 0.99 之间:

velocity = (momentum * velocity) - (learning_rate * gradient)
weight += velocity

自适应学习率

许多流行的算法会动态调整学习率:

  • Adadelta

  • Adagrad

  • Adam

  • AdaMax

  • AMSGrad

  • 动量

  • Nadam

  • Nesterov

  • RMSprop

这些算法的解释超出了本节的范围。有关这些和其他优化器的更多信息,请参阅tf.keras.optimizers的文档(mng.bz/Par9)。对于 TF.Keras,这些学习率算法是在定义优化器以最小化损失函数时指定的:

from tensorflow.keras import optimizers

optimizer = optimizers.RMSprop(lr=0.001, rho=0.9, epsilon=None, decay=0.0)  ❶
model.compile(loss='mean_squared_error', optimizer=optimizer)               ❷

❶ 指定优化器的学习率和衰减

❷ 编译模型,指定损失函数和优化器

4.8 不变性

那么,不变性是什么意思?在神经网络的情况下,这意味着当输入被转换时,结果(预测)保持不变。在训练图像分类器的情况下,可以使用图像增强来训练模型,使其能够识别图像中无论对象的大小和位置如何的对象,而无需额外的训练数据。

让我们考虑一个图像分类器 CNN(这个类比也可以应用于目标检测)。我们希望被分类的对象无论其在图像中的位置如何都能被正确识别。如果我们变换输入,使得对象在图像中移动到新的位置,我们希望结果(预测)保持不变。

对于 CNN 和一般成像,我们希望模型支持的主要不变性类型是平移尺度不变性。在 2019 年之前,平移和尺度不变性是通过在模型训练之前使用图像增强预处理来处理的,即在 CPU 上对图像数据进行预处理,同时在 GPU 上训练时提供数据。我们将在本节中讨论这些传统技术。

训练平移/尺度不变性的一个方法是为每个类别(每个对象)提供足够的图像,使得对象在图像中的位置、旋转、尺度以及视角都不同。嗯,这可能不太实际收集。

结果表明,使用图像增强预处理自动生成平移/尺度不变图像的方法非常直接,这种预处理通过矩阵操作高效执行。基于矩阵的变换可以通过各种 Python 包执行,例如 TF.Keras ImageDataGenerator类、TensorFlow tf.image模块或 OpenCV。

图 4.11 描述了在向模型提供训练数据时的典型图像增强流程。对于每个抽取的批次,从批次中的图像中选择一个随机子集进行增强(例如,50%)。然后,根据某些约束(例如,从-30 到 30 度的随机旋转值)随机变换这个随机选择的图像子集。然后,将修改后的批次(原始图像加上增强图像)输入模型进行训练。

图片

图 4.11 在图像增强过程中,随机选择批次中的图像子集进行增强。

4.8.1 平移不变性

本小节将介绍如何在训练数据集中手动增强图像,以便模型学会识别图像中的对象,而不管其在图像中的位置如何。例如,我们希望模型能够识别出无论马在图像中朝哪个方向,都能识别出马,或者无论苹果在背景中的位置如何,都能识别出苹果。

平移不变性在图像输入的上下文中包括以下内容:

  • 垂直/水平位置(对象可以在图片的任何位置)

  • 旋转(对象可以处于任何旋转角度)

垂直/水平变换通常是通过矩阵滚动操作或裁剪来执行的。一个方向(例如,镜像)通常是通过矩阵翻转来实现的。旋转通常是通过矩阵转置来处理的。

翻转

矩阵翻转通过在垂直或水平轴上翻转图像来变换图像。由于图像数据表示为 2D 矩阵的堆叠(每个通道一个),翻转可以通过矩阵转置函数高效执行,而无需更改像素数据(例如插值)。图 4.12 比较了图像的原始版本和翻转版本。

图片

图 4.12 比较了一个苹果:原始图像、垂直轴翻转和水平轴翻转(图片来源:malerapaso,iStock)

让我们从展示如何使用 Python 中流行的图像库来翻转图像开始。以下代码演示了如何使用 Python 的 PIL 图像库中的矩阵转置方法来垂直(镜像)和水平翻转图像:

from PIL import Image

image = Image.open('apple.jpg')                  ❶

image.show()                                     ❷

flip = image.transpose(Image.FLIP_LEFT_RIGHT)    ❸
flip.show()                                      ❸

flip = image.transpose(Image.FLIP_TOP_BOTTOM)    ❹
flip.show()                                      ❹

❶ 将图像读入内存

❷ 显示图像的原始视角

❸ 在垂直轴上翻转图像(镜像)

❹ 在水平轴上翻转图像(上下颠倒)

或者,可以使用 PIL 类ImageOps模块来执行翻转,如下所示:

from PIL import Image, ImageOps

image = Image.open('apple.jpg')     ❶

flip = ImageOps.mirror(image)       ❷
flip.show()                         ❷

flip = ImageOps.flip(image)v        ❸
flip.show()                         ❸

❶ 读取图像

❷ 在垂直轴上翻转图像(镜像)

❸ 在水平轴上翻转图像(上下颠倒)

以下代码演示了如何使用 OpenCV 中的矩阵转置方法垂直(镜像)和水平翻转图像:

import cv2
from matplotlib import pyplot as plt 

image = cv2.imread('apple.jpg')

plt.imshow(image)             ❶

flip = cv2.flip(image, 1)     ❷
plt.imshow(flip)              ❷

flip = cv2.flip(image, 0)     ❸
plt.imshow(flip)              ❸

❶ 以原始视角显示图像

❷ 在垂直轴上翻转图像(镜像)

❸ 在水平轴上翻转图像(上下颠倒)

以下代码演示了如何使用 NumPy 中的矩阵转置方法翻转图像垂直(镜像)和水平翻转:

import numpy as np
import cv2
from matplotlib import pyplot as plt 

image = cv2.imread('apple.jpg')
plt.imshow(image)
flip = np.flip(image, 1)     ❶
plt.imshow(flip)             ❶

flip = np.flip(image, 0)     ❷
plt.imshow(flip)             ❷

❶ 在垂直轴上翻转图像(镜像)

❷ 在水平轴上翻转图像(上下颠倒)

旋转 90/180/270

除了翻转之外,可以使用矩阵转置操作旋转图像 90 度(左转)、180 度和 270 度(右转)。与翻转一样,该操作效率高,不需要插值像素,并且没有裁剪的副作用。图 4.13 比较了原始图像和 90 度旋转版本。

图 4.13 苹果的 90 度、180 度和 270 度旋转比较

以下代码演示了如何使用 Python 的 PIL 图像库中的矩阵转置方法旋转图像 90 度、180 度和 270 度:

from PIL import Image

image = Image.open('apple.jpg')

rotate = image.transpose(Image.ROTATE_90)     ❶
rotate.show()                                 ❶

rotate = image.transpose(Image.ROTATE_180)    ❷
rotate.show()                                 ❷

rotate = image.transpose(Image.ROTATE_270)    ❸
rotate.show()                                 ❸

❶ 旋转图像 90 度

❷ 旋转图像 180 度

❸ 旋转图像 270 度

OpenCV 没有 90 度或 270 度的转置方法;您可以使用带有-1 值的翻转方法来执行 180 度翻转。(使用imutils模块演示了 OpenCV 中其他所有旋转方法,见下文小节。)

import cv2
from matplotlib import pyplot as plt 

image = cv2.imread('apple.jpg')

rotate = cv2.flip(image, -1)     ❶
plt.imshow(rotate)               ❶

❶ 旋转图像 180 度

下一个示例演示了如何使用 NumPy 的rot90()方法旋转图像 90 度、180 度和 270 度,其中第一个参数是要旋转 90 度的图像,第二个参数(k)是旋转的次数:

import numpy as np
import cv2
from matplotlib import pyplot as plt 

image = cv2.imread('apple.jpg')

rotate = np.rot90(image, 1)     ❶
plt.imshow(rotate)              ❶

rotate = np.rot90(image, 2)     ❷
plt.imshow(rotate)              ❷

rotate = np.rot90(image, 3)     ❸
plt.imshow(rotate)              ❸

❶ 旋转图像 90 度

❷ 旋转图像 180 度

❸ 旋转图像 270 度

当翻转图像 90 度或 270 度时,您正在改变图像的方向,如果图像的高度和宽度相同,则这不是问题。如果不相同,旋转后的图像高度和宽度将互换,并且不会与神经网络的输入向量匹配。在这种情况下,您应使用imutils模块或其他方法调整图像大小。

旋转

旋转通过在-180 度和 180 度之间旋转图像来变换图像。通常,旋转的角度是随机选择的。您可能还想限制旋转的范围以匹配模型部署的环境。以下是一些常见的做法:

  • 如果图像将直接对齐,请使用-15 到 15 度的范围。

  • 如果图像可能处于倾斜状态,请使用-30 度到 30 度的范围。

  • 对于小型物体,如包裹或货币,请使用-180 度到 180 度的完整范围。

旋转的另一个问题是,如果您在相同大小的边界内旋转图像,除了 90 度、180 度或 270 度之外,图像的边缘部分将最终超出边界(裁剪)。

图 4.14 展示了使用 PIL 方法rotate()将苹果图像旋转 45 度的例子。你可以看到苹果底部的一部分和叶子被裁剪掉了。

图 4.14 旋转非 90 度倍数时的图像裁剪示例

正确处理旋转的方法是在更大的边界区域内旋转,这样图像的任何部分都不会被裁剪,然后将旋转后的图像调整回原始大小。为此,我推荐使用imutils模块(由 Adrian Rosebrock 创建,mng.bz/JvR0),它包含了一组针对 OpenCV 的便利方法:

import cv2, imutils
from matplotlib import pyplot as plt 

image = cv2.imread('apple.jpg')

shape = (image.shape[0], image.shape[1])                            ❶

rotate = imutils.rotate_bound(image, 45)                            ❷

rotate = cv2.resize(rotate, shape, interpolation=cv2.INTER_AREA)    ❸
plt.imshow(rotate)

❶ 记录原始高度和宽度

❷ 旋转图像

❸ 将图像调整回原始形状

移动

移动会将图像中的像素数据在垂直(高度)或水平(宽度)轴上移动+/-。这将改变被分类对象在图像中的位置。图 4.15 显示了苹果图像向下移动 10%和向上移动 10%的情况。

图 4.15 苹果的比较:原始图像,向下移动 10%,向上移动 10%

以下代码演示了使用 NumPy 的np.roll()方法垂直和水平移动图像+/- 10%:

import cv2
import numpy as np
from matplotlib import pyplot as plt 

image = cv2.imread('apple.jpg')

height = image.shape[0]                            ❶
Width  = image.shape[1]                            ❶

roll = np.roll(image, height // 10, axis=0)        ❷
plt.imshow(roll)

roll = np.roll(image, -(height // 10), axis=0)     ❸
plt.imshow(roll)

roll = np.roll(image, width // 10, axis=1)         ❹
plt.imshow(roll)

roll = np.roll(image, -(width // 10), axis=1)      ❺
plt.imshow(roll)

❶ 获取图像的高度和宽度

❷ 向下移动图像 10%

❸ 向上移动图像 10%

❹ 向右移动图像 10%

❺ 向左移动图像 10%

移动是高效的,因为它作为矩阵的滚动操作实现;行(高度)或列(宽度)被移动。因此,移出末尾的像素被添加到开始处。

如果移动太大,图像可以分裂成两块,每块都与另一块相对。图 4.16 显示了苹果垂直移动了 50%,导致其破碎。

图 4.16 当图像移动过多时,它变得破碎。

为了避免破碎,通常将图像的移动限制在不超过 20%。或者,我们可以裁剪图像,并用黑色填充裁剪的空间,如下所示使用 OpenCV:

import cv2
from matplotlib import pyplot as plt 

image = cv2.imread('apple.png')
image = cv2.cvtColor(image, cv2.COLOR_BGR2RGB)

height = image.shape[0]                                           ❶
width  = image.shape[1]                                           ❶

image = image[0: height//2,:,:]                                   ❷

image = cv2.copyMakeBorder(image, (height//4), (height//4), 0, 0, 
                           cv2.BORDER_CONSTANT, 0)                ❸

plt.imshow(image)

❶ 获取图像的高度

❷ 删除图像底部(50%)

❸ 添加黑色边框以重新调整图像大小

此代码生成了图 4.17 的输出。

图 4.17 使用裁剪和填充避免图像破碎

4.8.2 尺度不变性

本小节介绍了如何在训练数据集中手动增强图像,以便模型学会识别图像中的对象,无论对象的大小如何。例如,我们希望模型能够识别出苹果,无论它占据图像的大部分还是作为图像背景上的小部分。

在图像输入的上下文中,尺度不变性包括以下内容:

  • 缩放(对象可以是图像中的任何大小)

  • 放射性(对象可以从任何角度观看)

缩放

缩放通过从图像中心放大图像来转换图像,这是通过调整大小和裁剪操作完成的。你找到图像的中心,计算围绕中心的裁剪边界框,然后裁剪图像。图 4.18 是放大 2 倍的苹果图像。

图片

图 4.18 在放大后裁剪图像以保持相同的图像大小。

当使用Image.resize()放大图像时,Image.BICUBIC插值通常提供最佳结果。此代码演示了如何使用 Python 的 PIL 图像库放大图像:

from PIL import Image
image = Image.open('apple.jpg')

zoom = 2 
height, width = image.size                                                 ❶

image = image.resize( (int(height*zoom), int(width*zoom)), Image.BICUBIC)  ❷

center = (image.size[0]//2, image.size[1]//2)                              ❸

crop = (int(center[0]//zoom), int(center[1]//zoom))                        ❹

box = ( crop[0], crop[1], (center[0] + crop[0]), (center[1] + crop[1]) )   ❺

image = image.crop( box )                                                  ❻
image.show()

❶ 记录图像的原始高度和宽度

❷ 通过缩放(按比例)调整图像大小

❸ 找到缩放图像的中心

❹ 计算裁剪的左上角

❺ 计算裁剪边界框

❻ 裁剪图像

下一个代码示例演示了如何使用 OpenCV 图像库放大图像。当使用cv2.resize()插值放大图像时,cv2.INTER_CUBIC通常提供最佳结果。插值cv2.INTER_LINEAR更快,并提供几乎相当的结果。插值cv2.INTER_AREA通常用于减小图像。

import cv2
from matplotlib import pyplot as plt 

zoom = 2 

height, width = image.shape[:2]                                             ❶

center = (image.shape[0]//2, image.shape[1]//2)                             ❷
z_height = int(height // zoom)                                              ❷
z_width  = int(width  // zoom)                                              ❷

image = image[(center[0] - z_height//2):(center[0] + z_height//2), center[1] -
              z_width//2:(center[1] + z_width//2)]                          ❸

image = cv2.resize(image, (width, height), interpolation=cv2.INTER_CUBIC)   ❹

plt.imshow(image)

❶ 记录图像的原始高度和宽度

❷ 找到缩放图像的中心

❸ 通过形成裁剪边界框来切割缩放图像

❹ 将裁剪的图像重新调整回原始大小

4.8.3 TF.Keras ImageDataGenerator

TF.Keras 图像预处理模块支持使用ImageDataGenerator类进行多种图像增强。这个类创建一个生成器,用于生成增强图像的批次。类的初始化器接受零个或多个参数,用于指定增强的类型。以下是一些参数,我们将在本节中介绍:

  • horizontal_flip=True|False

  • vertical_flip=True|False

  • rotation_range=degrees

  • zoom_range=(lower, upper)

  • width_shift_range=percent

  • height_shift_range=percent

  • brightness_range=(lower, upper)

翻转

在以下代码示例中,我们执行以下操作:

  1. 读取一个苹果的单张图像。

  2. 创建一个包含一个图像(苹果)的批次。

  3. 实例化一个ImageDataGenerator对象。

  4. 使用我们的增强选项(在这种情况下,水平和垂直翻转)初始化ImageDataGenerator

  5. 使用ImageDataGeneratorflow()方法创建一个批次生成器。

  6. 通过生成器迭代六次,每次返回一个包含一个图像的x批次。

    • 生成器将每次迭代随机选择一个增强(包括无增强)。

    • 变换(增强)后,像素值类型将是 32 位浮点数。

    • 将像素的数据类型改回 8 位整数,以便使用 Matplotlib 显示。

下面是代码:

from tensorflow.keras.preprocessing.image import ImageDataGenerator
import cv2
import numpy as np
from matplotlib import pyplot as plt

image = cv2.imread('apple.jpg')                                          ❶
batch = np.asarray([image])                                              ❶

datagen = ImageDataGenerator(horizontal_flip=True, vertical_flip=True)   ❷

step=0                                                                   ❸
for x in datagen.flow(batch, batch_size=1):                              ❸
        step += 1
        if step > 6: break
        plt.figure()
        plt.imshow(x[0].astype(np.uint8))                                ❹

❶ 制作一个包含一个图像(苹果)的批次

❷ 创建一个用于增强数据的生成器

❸ 运行生成器,其中每个图像都是随机增强

❹ 增强操作将像素数据转换为浮点数,然后将其转换回 uint8 以显示图像。

旋转

在以下代码中,我们使用rotation_range参数设置介于-60 度和 60 度之间的随机旋转。请注意,旋转操作不执行边界检查和调整大小(如imutils.rotate_bound()),因此图像的一部分可能最终被裁剪:

datagen = ImageDataGenerator(rotation_range=60)

缩放

在此代码中,我们使用zoom_range参数设置从 0.5(缩小)到 2(放大)的随机值。该值可以是两个元素的元组或列表:

datagen = ImageDataGenerator(zoom_range=(0.5, 2))

平移

在此代码中,我们使用width_shift_rangeheight_shift_range设置从 0 到 20%的随机值以水平或垂直移动:

datagen = ImageDataGenerator(width_shift_range=0.2, height_shift_range=0.2)

亮度

在以下代码中,我们使用brightness_range参数设置从 0.5(较暗)到 2(较亮)的随机值。该值可以是两个元素的元组或列表:

datagen = ImageDataGenerator(brightness_range=(0.5, 2))

作为最后的注意事项,像亮度这样的变换,在像素值上添加一个固定量,是在归一化或标准化之后完成的。如果在此之前完成,归一化和标准化会将值压缩到相同的原始范围,从而撤销变换。

4.9 原始(磁盘)数据集

到目前为止,我们已经讨论了直接从内存中存储和访问的图像的训练技术。这对于小型数据集有效,例如那些包含微小图像的数据集,或者对于包含少于 50,000 个图像的大型图像数据集。但是,一旦我们开始使用较大尺寸的图像和大量图像进行训练,例如几十万个图像,您的数据集很可能存储在磁盘上。本小节涵盖了在磁盘上存储图像和访问它们进行训练的常见约定。

除了用于学术/研究目的的精选数据集之外,我们在生产中使用的数据集很可能存储在磁盘上(如果是结构化数据,则为数据库)。在图像数据的情况下,我们需要执行以下操作:

  1. 从磁盘读取图像及其对应的标签到内存中(假设图像数据适合内存)。

  2. 将图像调整大小以匹配 CNN 的输入向量。

接下来,我们将介绍几种在磁盘上布局图像数据集的常用方法。

4.9.1 目录结构

将图像放置在本地磁盘上的目录文件夹结构中是最常见的布局之一。在此布局中,如图 4.19 所示,根(父)文件夹是数据集的容器。在根级别以下有一个或多个子目录。每个子目录对应一个类别(标签)并包含对应类别的图像。

使用我们的猫和狗示例,我们可能有一个名为 cats_n_dogs 的父目录,其中包含两个子目录,一个名为 cats,另一个名为 dogs。在每个子目录中会有对应类别的图像。

图 4.19 按类别目录文件夹布局

或者,如果数据集已经被分割成训练和测试数据,我们首先按训练/测试分组数据,然后按猫和狗的两个类别分组数据,如图 4.20 所示。

图像

图 4.20 按训练和测试数据分割的目录文件夹布局

当数据集是分层标记时,每个顶级类别(标签)子文件夹根据类别(标签)层次进一步划分为子文件夹。以我们的猫和狗为例,每个图像根据是否是猫或狗(物种)进行分层标记,然后按品种划分。见图 4.21。

图像

图 4.21 分层目录文件夹布局用于分层标记

4.9.2 CSV 文件

另一种常见的布局是使用逗号分隔值(CSV)文件来标识每个图像的位置和类别(标签)。在这种情况下,CSV 文件中的每一行都是一个单独的图像,CSV 文件至少包含两列,一列用于图像的位置,另一列用于图像的类别(标签)。位置可能是一个本地路径、远程位置,或者作为位置值的嵌入像素数据:

  • 本地路径示例:

            label,location
            'cat', cats_n_dogs/cat/1.jpg
            'dog',cats_n_dogs/dog/2.jpg
    

    ...

  • 远程路径示例:

            label,location
            'cat','http://mysite.com/cats_n_dogs/cat/1.jpg'
             'dog','http://mysite.com/cats_n_dogs/dog/2.jpg'
    

    ...

  • 嵌入数据示例:

            label,location
             'cat',[[...],[...],[...]]
             'dog',[[...], [...], [...]]
    

4.9.3 JSON 文件

另一种常见的布局是使用 JavaScript 对象表示法(JSON)文件来标识每个图像的位置和类别(标签)。在这种情况下,JSON 文件是一个对象数组;每个对象是一个单独的图像,每个对象至少有两个键,一个用于图像的位置,另一个用于图像的类别(标签)。

位置可能是一个本地路径、远程位置,或者作为位置值的嵌入像素数据。以下是一个本地路径示例:

 [
            {'label': 'cat', 'location': 'cats_n_dogs/cat/1.jpg' },
            {'label': 'dog', 'location': 'cats_n_dogs/dog/2.jpg'}
            ...
]

4.9.4 读取图像

在磁盘数据集上训练时,第一步是从磁盘读取图像到内存。磁盘上的图像将以 JPG、PNG 或 TIF 等图像格式存在。这些格式定义了图像的编码和压缩方式以便存储。可以通过使用 PIL 的Image.open()方法将图像读取到内存中:

from PIL import Image
image = Image.open('myimage.jpg')

在实践中,你可能需要读取很多图像。假设你想要读取子目录(例如,猫)下的所有图像。在下面的代码中,我们扫描(获取子目录中所有文件的列表),将每个文件作为图像读取,并将读取的图像列表作为列表维护:

from PIL import Image
import os

def loadImages(subdir):                                 ❶
        images = []

        files = os.scandir(subdir)                      ❷
        for file in files:                              ❸
                 images.append(Image.open(file.path))   ❸
        return images

loadImages('cats')                                      ❹

❶ 读取单个类别标签下子文件夹中所有图像的步骤

❷ 获取子目录 cats 中所有文件的列表

❸ 读取每个图像并将其内存中的图像追加到列表中

❹ 读取子目录 cats 中的所有图像

注意,os.scandir()是在 Python 3.5 中添加的。如果你使用 Python 2.7 或更早版本的 Python 3,你可以使用pip install scandir来获取兼容版本。

让我们扩展前面的例子并假设图像数据集是按目录结构布局的;每个子目录是一个类别(标签)。在这种情况下,我们希望分别扫描每个子目录并记录类别的子目录名称:

import os

def loadDirectory(parent):                               ❶
        classes = {}
        dataset = []

        for subdir in os.scandir(parent):                ❷
                if not subdir.is_dir():                  ❸
                        continue

                classes[subdir.name] = len(dataset)      ❹

                dataset.append(loadImages(subdir.path))

                print("Processed:", subdir.name, "# Images", 
                      len(dataset[len(dataset)-1]))

        return dataset, classes                          ❺

loadDirectory('cats_n_dogs')                             ❻

❶ 读取数据集所有图像的类别的步骤

❷ 获取数据集父目录(根目录)下的所有子目录列表

❸ 忽略任何非子目录的条目(例如,许可证文件)

❹ 维护类别(子目录名称)到标签(索引)的映射

❺ 返回数据集图像和类别映射

❻ 读取数据集 cats_n_dogs 中所有图像的类别

现在我们尝试一个例子,其中图像的位置是远程的(非本地)并且由 URL 指定。在这种情况下,我们需要对 URL 指定的资源(图像)的内容发出 HTTP 请求,然后将响应解码成二进制字节流:

from PIL import Image 
import requests                                                  ❶
from io import BytesIO                                           ❷

def remoteImage(url):
        try:
                response = requests.get(url)                     ❸
                return Image.open(BytesIO(response.content))     ❹
        except:
                return None

❶ Python 的 HTTP 请求包

❷ Python 的反序列化 I/O 到字节流的包

❸ 请求指定 URL 的图像内容

❹ 将反序列化的内容读取到内存中作为图像

在读取训练图像后,您需要设置通道数以匹配卷积神经网络的输入形状,例如灰度图像的单通道或 RGB 图像的三个通道。

通道数是您图像中的颜色平面的数量。例如,灰度图像将有一个颜色通道。RGB 颜色图像将有三个颜色通道,每个通道分别对应红色、绿色和蓝色。在大多数情况下,这将是一个单通道(灰度)或三个通道(RGB),如图 4.22 所示。

图片

图 4.22 灰度图像有一个通道,RGB 图像有三个通道。

Image.open() 方法将根据磁盘上存储的图像的通道数读取图像。因此,如果是一个灰度图像,该方法将作为一个通道读取它;如果是 RGB 图像,它将作为三个通道读取;如果是 RGBA(+alpha 通道),它将作为四个通道读取。

通常,当处理 RGBA 图像时,可以丢弃 alpha 通道。它是设置图像中每个像素透明度的掩码,因此不包含有助于图像识别的其他信息。

一旦图像被读入内存,下一步是将图像转换为与您的神经网络输入形状匹配的通道数。因此,如果神经网络接受灰度图像(单通道),我们希望将其转换为灰度;或者如果神经网络接受 RGB 图像(三个通道),我们希望将其转换为 RGB。convert() 方法执行通道转换。参数值 L 转换为单通道(灰度),RGB 转换为三个通道(RGB 颜色)。在这里,我们已经更新了 loadImages() 函数以包括通道转换:

from PIL import Image 
import os

def loadImages(subdir, channels):
        images = []

        files = os.scandir(subdir)
        for file in files:
                   image = Image.open(file.path)
                   if channels == 1:                   ❶
                        image = image.convert('L')     ❶
                   else:                               ❷
                        image = image.convert('RGB')   ❷
                   images.append(image)
        return images

loadImages('cats', 3)                                  ❸

❶ 转换为灰度图

❷ 转换为 RGB

❸ 指定转换为 RGB

4.9.5 调整大小

到目前为止,您已经看到了如何从磁盘读取图像,获取标签,然后设置通道数以匹配 CNN 输入形状中的通道数。接下来,我们需要调整图像的高度和宽度以最终匹配训练期间输入图像的形状。

例如,一个二维卷积神经网络将具有形式为(高度,宽度,通道)的形状。我们已经处理了通道部分,所以接下来我们需要调整每个图像的像素高度和宽度以匹配输入形状。例如,如果输入形状是(128,128,3),我们希望将每个图像的高度和宽度调整到(128,128)。resize() 方法将执行调整大小。

在大多数情况下,您将减小每个图像的大小(下采样)。例如,一个 1024 × 768 的图像将大小为 3 MB。这比神经网络所需的分辨率要高得多(更多细节请见第三章)。当图像被下采样时,一些分辨率(细节)将会丢失。为了最小化下采样时的影响,一个常见的做法是在 PIL 中使用反走样算法。最后,我们将然后将我们的 PIL 图像列表转换为多维数组:

from PIL import Image 
import os
import numpy as np

def loadImages(subdir, channels, shape):
        images = []

        files = os.scandir(subdir)
        for file in files:
                image = Image.open(file.path)
                if channels == 1:
                    image = image.convert('L')
                else:
                    image = image.convert('RGB')

                images.append(image.resize(shape, Image.ANTIALIAS))    ❶

        return np.asarray(images)                                      ❷

loadImages('cats', 3, (128, 128))                                      ❸

❶ 将图像调整为目标输入形状

❷ 在单次调用中将所有 PIL 图像转换为 NumPy 数组

❸ 指定目标输入大小为 128 × 128。

让我们现在使用 OpenCV 重复前面的步骤。通过使用 cv2.imread() 方法将图像读入内存。我发现这个方法的一个优点是输出已经是一个多维 NumPy 数据类型:

import cv2

image = cv2.imread('myimage.jpg')

OpenCV 相对于 PIL 的另一个优点是您可以在读取图像时进行通道转换,而不是第二步。默认情况下,cv2.imread() 将图像转换为三通道 RGB 图像。您可以指定一个第二个参数,以指示要使用的通道转换。在以下示例中,我们在读取图像时进行通道转换:

if channel == 1:                                                  ❶
        image = cv2.imread('myimage.jpg', cv2.IMREAD_GRAYSCALE)
else:                                                             ❷
        image = cv2.imread('myimage.jpg', cv2.IMREAD_COLOR)

❶ 以单通道(灰度)图像读取图像

❷ 以三通道(彩色)图像读取图像

在下一个示例中,我们从远程位置(url)读取图像,并在同一时间进行通道转换。在这种情况下,我们使用 cv2.imdecode() 方法:

try:
        response = requests.get(url)
        if channel == 1:
               return cv2.imdecode(BytesIO(response.content),
                                   cv2.IMREAD_GRAYSCALE)
        else:
               return cv2.imdecode(BytesIO(response.content),
                                   cv2.IMREAD_COLOR)
except:
       return None

使用 cv2.resize() 方法调整图像大小。第二个参数是一个包含调整后图像高度和宽度的元组。可选的(关键字)第三个参数是在调整大小时使用的插值算法。由于在大多数情况下您将进行下采样,一个常见的做法是使用 cv2.INTER_AREA 算法以在保留信息和最小化下采样图像时的伪影方面获得最佳结果:

image = cv2.resize(image, (128, 128), interpolation=cv2.INTER_AREA)

让我们现在使用 OpenCV 重写 loadImages() 函数:

import cv2
import os
import numpy as np

def loadImages(subdir, channels, shape):
        images = []

        files = os.scandir(subdir)
        for file in files:
                   if channels == 1:
                       image = cv2.imread(file.path, cv2.IMREAD_GRAYSCALE)
                   else:
                       image = cv2.imread(file.path, cv2.IMREAD_COLOR)

                   images.append(cv2.resize(image, shape, cv2.INTER_AREA))  ❶
        return np.asarray(images)

loadImages('cats', 3, (128, 128))                                           ❷

❶ 将图像调整为目标输入形状

❷ 指定目标输入形状为 128 × 128

4.10 模型保存/恢复

在本小节中,我们将介绍训练后的内容:现在你已经训练了一个模型,接下来你该做什么?嗯,你可能会想要保存模型架构和相应的学习权重和偏差(参数),然后随后恢复模型以进行部署。

4.10.1 保存

在 TF.Keras 中,我们可以保存模型和训练好的参数(权重和偏差)。模型和权重可以分别保存或一起保存。save()方法将权重/偏差和模型保存到 TensorFlow SavedModel 格式的指定文件夹中。以下是一个示例:

model.fit(x_train, y_train, epochs=epochs, batch_size=batch_size)    ❶

model.save('mymodel')                                                ❷

❶ 训练模型

❷ 保存模型和训练好的权重和偏差

训练好的权重/偏差和模型可以分别保存。save_weights()方法将模型的参数仅保存到 TensorFlow Checkpoint 格式的指定文件夹中。以下是一个示例:

model.fit(x_train, y_train, epochs=epochs, batch_size=batch_size)

model.save_weights('myweights')       ❶

❶ 仅保存训练好的权重和偏差

4.10.2 恢复

在 TF.Keras 中,我们可以恢复模型架构和/或模型参数(权重和偏差)。通常,恢复模型架构是为了加载预构建模型,而同时加载模型架构和模型参数通常用于迁移学习(在第十一章中讨论)。

注意,加载模型和模型参数与检查点不同,因为我们不是在恢复超参数的当前状态。因此,这种方法不应用于持续学习:

from tensorflow.keras.models import load_model

model = load_model('mymodel')      ❶

❶ 加载预训练模型

在下一个代码示例中,使用load_weights()方法将模型的训练好的权重/偏差加载到相应的预构建模型中:

from tensorflow.keras.models import load_weights

model = load_model('mymodel')      ❶
model.load_weights('myweights')    ❷

❶ 加载预构建模型

❷ 加载模型的预训练权重

摘要

  • 当一批图像被前向传递时,预测值与真实值之间的差异是损失。优化器使用损失来确定在反向传播中如何更新权重。

  • 将一小部分数据集保留为测试数据,不进行训练。训练完成后,使用测试数据来观察模型泛化能力与记忆数据示例之间的差异。

  • 在每个 epoch 之后使用验证数据来检测模型过拟合。

  • 与归一化相比,像素数据的标准化更受欢迎,因为它有助于略微提高收敛速度。

  • 当训练过程中损失值达到平台期时,会发生收敛。

  • 超参数用于改进模型的训练,但不是模型的一部分。

  • 增强允许使用更少的原始图像进行训练以实现不变性。

  • 检查点用于在训练发散后恢复一个好的 epoch,而无需重新启动训练。

  • 提前停止通过检测模型不会随着进一步训练而改进来节省训练时间和成本。

  • 小数据集可以从内存存储和访问中进行训练,但大数据集是从磁盘存储和访问中进行训练的。

  • 训练完成后,保存模型架构和学习的参数,然后随后恢复模型以进行部署。

第二部分. 基本设计模式

在这一部分,你将学习如何使用过程重用设计模式设计和编码模型。我将向你展示如何简单易行地将过程重用,这是软件工程中的一个基本原理,应用于深度学习模型。你将看到如何将模型分解为其标准三个组件——主干、学习者和任务——以及组件之间的接口,以及如何为每个部分应用过程重用模式。

接下来,你将看到如何将这种设计模式应用于各种开创性的最先进(SOTA)计算机视觉模型以及一些结构化数据和 NLP 的示例。我将带你通过编码一系列 SOTA 模型,并涵盖它们对深度学习发展的贡献:VGG、ResNet、ResNeXt、Inception、DenseNet、WRN、Xception 和 SE-Net。然后我们将关注内存受限设备(如智能手机或物联网传感器)的移动模型。我们将探讨为使模型在内存受限设备上运行而开发的设计原则的进展,从 MobileNet 开始,然后是 SqueezeNet 和 ShuffleNet。同样,我们将使用过程重用设计模式编码这些移动模型,然后你将看到如何使用 TensorFlow Lite 部署和提供这些模型。

第二部分的大部分章节都专注于监督学习模型,其中数据被标记。但最后一章介绍了自动编码器,它进行无监督学习——用未由人类标记的数据训练模型。你将学习设计并编码用于压缩、图像去噪、超分辨率和预训练任务的自动编码器。

5.过程设计模式

本章涵盖

  • 介绍卷积神经网络的过程设计模式

  • 将过程设计模式的架构分解为宏观和微观组件

  • 使用过程设计模式编码前 SOTA 模型

在 2017 年之前,大多数神经网络模型的实现都是用批处理脚本风格编写的。随着人工智能研究人员和经验丰富的软件工程师越来越多地参与研究和设计,我们开始看到模型编码的转向,这反映了软件工程原则的可重用性和设计模式。

使用设计模式为神经网络模型编写代码的最早版本之一是使用可重用的过程风格。设计模式意味着存在一种当前的最佳实践,用于构建和编码一个模型,该模型可以在广泛的案例中重新应用,例如图像分类、目标检测和跟踪、面部识别、图像分割、超分辨率和风格迁移。

那么,设计模式的引入是如何帮助 CNN(以及其他架构,如 NLP 中的 transformer)的进步的呢?首先,它帮助其他研究人员理解和复制一个模型的架构。将模型分解为其可重用组件或模式,为其他从业者提供了观察、理解和然后进行高效设备实验的手段。

我们可以看到,早在 AlexNet 向 VGG 的过渡时期,这种情况就已经发生了。AlexNet 的作者(mng.bz/1ApV)没有足够的资源在单个 GPU 上运行 AlexNet 模型。他们设计了一个可以在两个 GPU 上并行运行的 CNN 架构。为了解决这个问题,他们提出了一个具有两个镜像卷积路径的设计,这个设计赢得了 2012 年 ILSVRC 图像分类竞赛。很快,其他研究人员抓住了拥有重复卷积模式的想法,他们开始研究卷积模式的影响,除了分析整体性能。2014 年,GoogLeNet(arxiv.org/abs/1409.4842)和 VGG(arxiv.org/pdf/1409.1556.pdf)基于模型和相应的研究论文,使用了模型中重复的卷积模式;这些创新分别成为了 2014 年 ILSVRC 竞赛的冠军和亚军。

理解过程设计模式的架构对于你打算将其应用于你构建的任何模型至关重要。在本章中,我将首先向你展示如何构建这个模式,通过将其分解为其宏观架构组件,然后是微观架构组和块。一旦你看到各个部分如何单独和共同工作,你就可以开始使用构建这些部分的代码了。

为了展示过程设计模式如何使模型组件的再现更容易,我们将将其应用于几个以前 SOTA 模型:VGG、ResNet、ResNeXt、Inception、DenseNet 和 SqueezeNet。这应该让您对这些模型的工作原理有更深入的理解,以及实际再现它们的经验。这些架构的一些显著特点如下:

  • VGG—2014 年 ImageNet ILSVRC 挑战赛图像分类的获胜者

  • ResNet—2015 年 ImageNet ILSVRC 挑战赛图像分类的获胜者

  • ResNeXt—2016 年,作者通过引入宽卷积层提高了准确性

  • Inception—2014 年 ImageNet ILSVRC 挑战赛物体检测的获胜者

  • DenseNet—2017 年,作者引入了特征图重用

  • SqueezeNet—2016 年,作者引入了可配置组件的概念

我们将简要介绍基于 Idiomatic 设计模式的一个基于过程的 CNN 模型设计模式。

5.1 基本神经网络架构

Idiomatic 设计模式将模型视为由一个整体宏观架构模式组成,然后每个宏观组件依次由一个微观架构设计组成。模型宏观和微观架构的概念是在 2016 年 SqueezeNet 的研究论文中引入的(arxiv.org/abs/1602.07360)。对于 CNN,宏观架构遵循由三个宏观组件组成的惯例:主干、学习器和任务,如图 5.1 所示。

图 5.1 CNN 宏观架构由三个组件组成:主干、学习器和任务。

如您所见,主干组件接收输入(图像)并执行初始的粗略级别特征提取,这成为学习组件的输入。在这个例子中,主干包括一个预主干组,它执行数据预处理,以及一个主干卷积组,它执行粗略级别的特征提取。

然后,由任意数量的卷积组组成的学习器从提取的粗略特征中进行详细的特征提取和表示学习。学习组件的输出被称为潜在空间

任务组件从潜在空间中输入表示学习任务(例如分类)。

虽然这本书主要关注 CNN,但这种主干、学习器和任务组件的宏观架构可以应用于其他神经网络架构,例如在自然语言处理中具有注意力机制的 Transformer 网络。

通过使用功能 API 查看 Idiomatic 设计模式的骨架模板,您可以在高层次上看到组件之间的数据流。我们将使用这个模板(在以下代码块中),并在使用 Idiomatic 设计模式的章节中在此基础上构建。该骨架由两个主要组件组成:

  • 主要组件(过程)的输入/输出定义:主干、学习器和任务

  • 输入(张量)流经主要组件

这是骨架模板:

def stem(input_shape):                            ❶
    ''' stem layers 
        Input_shape : the shape of the input tensor
    '''
    return outputs

def learner(inputs):                              ❷
    ''' leaner layers 
        inputs : the input tensors (feature maps)
    '''
    return outputs

def task(inputs, n_classes):                      ❸
    ''' classifier layers 
        inputs    : the input tensors (feature maps)
        n_classes : the number of output classes
    '''
    return outputs

inputs = Input(input_shape=(224, 224, 3))         ❹
outputs = stem(inputs)
outputs = learner(outputs)
outputs = task(x, n_classes=1000)
model = Model(inputs, outputs)                    ❺

❶ 构建主干组件

❷ 构建学习组件

❸ 为分类器构建任务组件

❹ 定义输入张量

❺ 组装模型

在这个例子中,Input类定义了模型的输入张量;对于 CNN 来说,它由图像的形状组成。元组(224,224,3)指的是一个 224 × 224 RGB(三通道)图像。Model类是使用 TF.Keras 功能 API 编码神经网络时的最后一步。这一步是模型的最终构建步骤(称为compile()方法)。Model类的参数是模型输入(s)张量和输出(s)张量。在我们的例子中,我们有一个输入张量和输出张量。图 5.2 描述了这些步骤。

图 5.2 构建 CNN 模型的步骤:定义输入,构建组件,编译成图

现在让我们更详细地看看三个宏观组件。

5.2 主干组件

主干组件是神经网络的人口点。其主要目的是执行第一层(粗粒度)特征提取,同时将特征图减少到适合学习组件的大小。主干组件输出的特征图数量和大小是通过同时平衡两个标准来设计的:

  • 最大化粗粒度特征的特征提取。这里的目的是给模型提供足够的信息来学习更细粒度的特征,同时不超过模型的能力。

  • 最小化下游学习组件中的参数数量。理想情况下,你希望最小化特征图的大小和训练模型所需的时间,但又不影响模型的表现。

这个初始任务由主干卷积组执行。现在让我们看看一些来自知名 CNN 模型(VGG、ResNet、ResNeXt 和 Inception)的主干组的变体。

5.2.1 VGG

VGG 架构赢得了 2014 年 ImageNet ILSVRC 图像分类竞赛,被认为是现代 CNN 之父,而 AlexNet 被认为是祖父。VGG 通过使用模式将 CNN 构建成组件和组的形式,正式化了这一概念。在 VGG 之前,CNN 被构建为 ConvNets,其用途并未超出学术上的新奇之处。

VGG 是第一个在生产中具有实际应用的。在其开发后的几年里,研究人员继续将更现代的 SOTA 架构发展与 VGG 进行比较,并使用 VGG 作为早期 SOTA 目标检测模型的分类骨干。

VGG,连同 Inception,正式化了拥有一个进行粗略级别特征提取的第一卷积组的概念,我们现在称之为stem 组件。随后的卷积组将进行更细级别的特征提取和学习,我们现在称之为表征学习,因此这个第二主要组件被称为学习者

研究人员最终发现 VGG stem 的一个缺点:它在提取的粗略特征图中保留了输入大小(224 × 224),导致进入学习者的参数数量过多。参数数量的增加不仅增加了内存占用,还降低了训练和预测的性能。研究人员随后在后续的 SOTA 模型中通过在 stem 组件中添加池化来解决此问题,减少了粗略级别特征图的输出大小。这种变化减少了内存占用,同时提高了性能,而没有损失精度。

输出 64 个粗略级别特征图的惯例至今仍然存在,尽管一些现代 CNN 的 stem 可能输出 32 个特征图。

图 5.3 中所示的 VGG stem 组件被设计为以 224 × 224 × 3 图像作为输入,并输出 64 个特征图,每个特征图大小为 224 × 224。换句话说,VGG stem 组没有对特征图进行任何尺寸缩减。

图片

图 5.3 VGG stem 组使用 3 × 3 滤波器进行粗略级别特征提取。

现在看看一个代码示例,用于在 Idiomatic 设计模式中编码 VGG stem 组件,该模式由一个单一的卷积层(Conv2D)组成。这个层使用 3 × 3 滤波器对 64 个滤波器进行粗略级别特征提取,不进行特征图尺寸的缩减。对于(224, 224, 3)图像输入(ImageNet 数据集),这个 stem 组的输出将是(224, 224, 64):

def stem(inputs):
    """ Construct the Stem Convolutional Group
        inputs : the input tensor
    """
    outputs = Conv2D(64, (3, 3), strides=(1, 1), padding="same",
                     activation="relu")(inputs)
    return outputs

使用 Idiomatic 过程重用设计模式为 VGG 编写的完整代码可以在 GitHub 上找到(mng.bz/qe4w)。

5.2.2 ResNet

ResNet 架构赢得了 2015 年 ImageNet ILSVRC 竞赛的图像分类奖项,它是第一个结合了最大化粗略级别特征提取和通过特征图减少最小化参数的常规步骤的架构。当将他们的模型与 VGG 进行比较时,ResNet 的作者发现他们可以将 stem 组件中提取的特征图大小减少 94%,从而减少内存占用并提高模型性能,而不影响精度。

注意:将较新模型与之前的 SOTA 模型进行比较的过程被称为消融研究,这在机器学习领域是一种常见的做法。基本上,研究人员会复制之前模型的研究,然后为新模型使用相同的配置(例如,图像增强或学习率)。这使得他们能够与早期模型进行直接的苹果对苹果的比较。

ResNet 的作者还选择使用一个极大的 7 × 7 粗滤波器,覆盖了 49 个像素的区域。他们的理由是模型需要一个非常大的滤波器才能有效。缺点是在基组件中矩阵乘法或 matmul 操作的大量增加。最终,研究人员在后来的 SOTA 模型中发现 5 × 5 滤波器同样有效且更高效。在传统的 CNN 中,5 × 5 滤波器通常被两个 3 × 3 滤波器的堆叠所取代,第一个卷积是无步长的(没有池化),第二个卷积是步长的(带有特征池化)。

几年来,ResNet v1 和改进的 v2 成为了图像分类生产中实际使用的默认架构,以及在目标检测模型中的骨干。除了其改进的性能和准确性之外,公开的预训练 ResNets 版本在图像分类、目标检测和图像分割任务中广泛可用,因此这种架构成为了迁移学习的标准。即使今天,在高调的模型动物园中,如 TensorFlow Hub,预训练的 ResNet v2 仍然作为图像分类的骨干而高度流行。然而,今天更现代的预训练图像分类惯例是更小、更快、更准确的 EfficientNet。图 5.4 展示了 ResNet 基组件中的层。

图像

图 5.4 ResNet 基组件通过步长卷积和最大池化积极减少特征图的大小。

在 ResNet 中,基组件由一个用于粗略特征提取的卷积层组成。该模型使用 7 × 7 的滤波器大小,在更宽的窗口上获取粗略特征,根据理论,这将提取更大的特征。7 × 7 的滤波器覆盖 49 个像素(相比之下,3 × 3 的滤波器覆盖 9 个像素)。使用更大的滤波器大小也显著增加了每个滤波器步骤(因为滤波器在图像上滑动)的计算量(矩阵乘法)。以每个像素为基础,3 × 3 有 9 次矩阵乘法,而 7 × 7 有 49 次。在 ResNet 之后,使用 7 × 7 来获取更大粗略级特征的传统不再被追求。

注意,VGG 和 ResNet 的基组件都输出 64 个初始特征图。这继续成为研究人员通过试错学习到的一个相当常见的惯例。

对于特征图降维,ResNet 基组件同时进行特征池化步骤(步长卷积)和下采样(最大池化)。

卷积层在滑动滤波器穿过图像时不会使用填充。因此,当滤波器到达图像边缘时,它会停止。由于边缘前的最后几个像素没有自己的滑动,输出尺寸小于输入尺寸,如图 5.5 所示。结果是输入和输出特征图的尺寸没有得到保留。例如,在步长为 1、滤波器大小为 3×3、输入特征图大小为 32×32 的卷积中,输出特征图将是 30×30。计算尺寸损失是直接的。如果滤波器大小是N × N,则尺寸损失将是N – 1 个像素。在 TF.Keras 中,这是通过将关键字参数padding='valid'指定给Conv2D层来实现的。

图片

图 5.5 填充和不填充的选项导致滤波器的不同停止位置。

或者,我们可以将滤波器滑动到边缘,直到最后一行和最后一列都被覆盖。但滤波器的一部分会悬停在虚拟像素上。这样,边缘前的最后几个像素将有自己的滑动,输出特征图的尺寸得到保留。

存在几种填充虚拟像素的策略。如今最常用的惯例是在边缘使用相同的像素值填充虚拟像素,如图 5.5 所示。在 TF.Keras 中,这是通过将关键字参数padding='same'指定给Conv2D层来实现的。

ResNet 在遵循这一惯例之前就已经存在,并且用零值填充了虚拟像素;这就是为什么你在主干组中看到ZeroPadding2D层,其中在图像周围放置了零填充。如今,我们通常使用相同的填充来填充图像,并将特征图尺寸的减少推迟到池化或特征池化。通过反复试验,研究人员发现这种方法在保持图像边缘特征提取信息方面效果更好。

图 5.6 展示了在大小为H × W × 3(RGB 的三个通道)的图像上使用填充的卷积。使用单个滤波器,我们将输出一个大小为H × W × 1 的特征图。

图片

图 5.6 使用单个滤波器的填充卷积产生特征提取的最小变异性。

图 5.7 展示了在大小为H × W × 3(RGB 的三个通道)的图像上使用多个滤波器C的卷积。在这里,我们将输出一个大小为H × W × C的特征图。

图片

图 5.7 使用多个滤波器的填充卷积按比例增加了特征提取的变异性。

你是否曾见过如图 5.6 所示的单个输出特征图的主干卷积?答案是:没有。这是因为单个滤波器只能学习提取单个粗略特征。这对于图像来说是不行的!即使我们的图像是简单的平行线序列(一个特征)并且我们只想计数线条,这仍然是不行的:我们无法控制滤波器学习提取哪个特征。在这个过程中仍然存在一定程度的随机性,因此我们需要一些冗余来保证足够的滤波器能够学习提取重要特征。

你是否曾在 CNN 的某个地方输出单个特征图?答案是:是的。这将是通过 1 × 1 瓶颈卷积进行的一种激进减少。1 × 1 瓶颈卷积通常用于 CNN 中不同卷积之间的特征重用。

再次强调,这涉及到权衡。一方面,你希望结合 CNN 中某处特征提取/学习的优势与另一处的优势(特征重用)。问题是,重用整个先前的特征图,在数量和大小上,可能会在参数上造成潜在的爆炸。这种增加的内存占用和速度降低抵消了好处。ResNet 的作者选择了特征减少的量,这是在准确度、大小和性能之间最佳权衡的结果。

接下来,看看使用 Idiomatic 设计模式编码 ResNet 主干组件的示例。该代码演示了通过图 5.3 中先前展示的层进行顺序流:

  • Conv2D层使用 7 × 7 滤波器大小进行粗略级特征提取,并使用strides=(2, 2)进行特征池化。

  • MaxPooling层执行下采样以进一步减少特征图。

值得注意的是,ResNet 是第一个使用批归一化(BatchNormalization)约定的模型之一。早期的约定,现在称为 Conv-BN-RE,批归一化位于卷积和密集层之后。为了提醒你,批归一化通过将层的输出重新分配到正态分布来稳定神经网络。这允许神经网络在更深层次上运行而不会出现梯度消失或爆炸。更多详情,请参阅 Sergey Ioffe 和 Christian Szegedy 的论文“Batch Normalization: Accelerating Deep Network Training by Reducing Internal Covariate Shift”(arxiv.org/abs/1502.03167)。

def stem(inputs):
    """ Construct the Stem Convolutional Group
        inputs : the input vector
    """

    outputs = ZeroPadding2D(padding=(3, 3))(inputs)                         ❶

    outputs = Conv2D(64, (7, 7), strides=(2, 2), padding='valid')(outputs)  ❷
    outputs = BatchNormalization()(outputs)
    outputs = ReLU()(outputs)

    outputs = ZeroPadding2D(padding=(1, 1))(outputs)                        ❸
    outputs = MaxPooling2D((3, 3), strides=(2, 2))(outputs)
    return outputs

❶ 224 × 224 的图像在第一卷积之前被零填充(黑色——无信号)以成为 230 × 230 的图像。

❷ 第一卷积层,使用大(粗略)滤波器

❸ 使用 2 × 2 步长,池化后的特征图将减少 75%。

使用 Idiomatic 程序重用设计模式为 ResNet 编写的完整代码版本可在 GitHub 上找到(mng.bz/7jK9)。

5.2.3 ResNeXt

在 ResNet 之后出现的模型使用了相同填充的惯例,这减少了层到单个步进卷积(特征池化)和步进最大池化(下采样),同时保持了相同的计算复杂度。Facebook AI Research 的 ResNeXt 模型 (arxiv.org/abs/1512.03385),以及谷歌公司的 Inception,引入了在学习者组件中使用宽残差块。如果你不知道宽和深残差块的影响,我在第六章中会解释。在这里,我只是想让你知道,卷积中的填充出现在早期的 SOTA 宽残差模型中。至于生产中的使用,ResNeXt 架构和其他宽 CNN 很少出现在内存受限的设备之外;后续在尺寸、速度和准确性方面的改进更为突出。

图 5.8 ResNeXt 基础组件通过组合特征和最大池化进行积极的特征图减少。

注意,由于使用了相同填充的惯例,因此没有必要使用 ZeroPadding 层来保持特征图大小。

以下是一个在 Idiomatic 设计模式中编码 ResNeXt 基础组件的代码示例。在这个例子中,你可以看到与 ResNet 基础组件的对比;ZeroPadding 层不存在,而是用 padding='same' 替换了 Conv2DMaxPooling 层:

def stem(inputs):
    """ Construct the Stem Convolution Group
        inputs : input vector
    """
    outputs = Conv2D(64, (7, 7), strides=(2, 2), padding='same')(inputs)    ❶
    outputs = BatchNormalization()(outputs)
    outputs = ReLU()(outputs)
    outputs = MaxPooling2D((3, 3), strides=(2, 2), padding='same')(outputs) ❶
    return outputs

❶ 使用 padding='same' 而不是 VGG 中的 ZeroPadding2D

在后续的模型中,7 × 7 滤波器大小被替换为较小的 5 × 5 滤波器,它具有更低的计算复杂度。今天的常见惯例是将 5 × 5 滤波器重构为两个 3 × 3 滤波器,它们具有相同的表示能力,但计算复杂度更低。

在 GitHub (mng.bz/my6r) 上有使用 Idiomatic 程序重用设计模式为 ResNeXt 编写的完整代码示例。

5.2.4 Xception

当前惯例是用两个 3 × 3 卷积层替换一个单独的 5 × 5 卷积层。图 5.9 中显示的 Xception (arxiv.org/abs/1610.02357) 基础组件是一个例子。第一个 3 × 3 卷积是步进(特征池化)的,并产生 32 个过滤器,第二个 3 × 3 卷积没有步进,将输出特征图的数量加倍到 64。然而,尽管在学术上具有新颖性,Xception 的架构并未在生产中得到采用,也没有被后续研究人员进一步发展。

图 5.9 Xception 基础组件

在这个例子中,对于在 Idiomatic 设计模式中编码 Xception 基础组件,你可以看到两个 3 × 3 卷积(重构的 5 × 5),第一个卷积是步进的(特征池化)。这两个卷积都紧跟着 Conv-BN-RE 形式的批量归一化:

def stem(inputs):
        """ Create the stem entry into the neural network
            inputs : input tensor to neural network
        """

        outputs = Conv2D(32, (3, 3), strides=(2, 2))(inputs)     ❶
        outputs = BatchNormalization()(outputs)
        outputs = ReLU()(outputs)

        outputs = Conv2D(64, (3, 3), strides=(1, 1))(outputs)    ❶
        outputs = BatchNormalization()(outputs)
        outputs = ReLU()(outputs)
        return outputs

❶ 将 5 × 5 卷积重构为两个 3 × 3 卷积

在 GitHub 上使用 Idiomatic 程序重用设计模式对 Xception 进行完整代码实现(mng.bz/5WzB)。

5.3 预前缀

2019 年,我们开始看到在基组件中添加一个预前缀组的兴起。预前缀的目的在于将一些或所有上游执行的数据预处理移动到图(模型)中。在预前缀组件开发之前,数据预处理发生在单独的模块中,然后在模型部署到未来示例上进行推理(预测)时必须重复执行。通常,这是在 CPU 上完成的。然而,许多数据预处理步骤可以被图操作所替代,然后在通常部署模型的 GPU 上更有效地执行。

预前缀也是即插即用的,它们可以被添加或从现有模型中移除并重用。我将在稍后介绍预前缀的技术细节。在这里,我只是想提供一个预前缀组通常执行的功能的摘要:

  • 预处理

    • 将模型适应不同的输入大小

    • 正规化

  • 增强处理

    • 调整大小和裁剪

    • 平移和尺度不变性

图 5.10 描述了如何向现有模型添加预前缀组。要附加预前缀,你需要创建一个新的空包装器模型,添加预前缀,然后添加现有模型。在后一个步骤中,预前缀组的输出形状必须与现有模型基组件的输入形状相匹配。

图 5.10 向现有模型添加预前缀,形成新的包装器模型

以下是将预前缀组添加到现有模型的典型方法示例。在这段代码中,实例化了一个空的Sequential包装器模型。然后添加预前缀组,接着添加现有模型。只要输出张量与模型的输入张量匹配(例如,(224, 224, 3)),这将有效。

from tf.keras.layers.experimental.preprocessing import Normalization

def prestem(input_shape):
    ''' pre-stem layers '''
    outputs = Normalization(input_shape=input_shape)
    return outputs

wrapper_model = Sequential()                            ❶

wrapper_model.add(prestem(input_shape=(224, 224, 3))    ❷

wrapper_model.add(model)                                ❸

❶ 创建一个空包装器模型

❷ 使用预前缀启动包装器模型

❸ 将现有模型添加到包装器模型

接下来我们将解释学习组件的设计,该组件将连接到基组件。

5.4 学习组件

学习组件是我们通常通过更详细的特征提取执行特征学习的地方。这个过程也被称为表示性转换性学习(因为转换性学习依赖于任务)。学习组件由一个或多个卷积组组成,每个组由一个或多个卷积块组成。

根据共同的模型配置属性,卷积块被组装成组。在传统的 CNN 中,卷积组的常见属性是输入或输出滤波器的数量,或输入或输出特征图的大小。例如,在 ResNet 中,组的可配置属性是卷积块的数量,以及每个块的滤波器数量。

图 5.11 展示了一个可配置的卷积组。如图所示,卷积块对应于组中块数量的元参数。大多数 SOTA 架构中除了最后一个组之外的所有组具有相同数量的输出特征图,这对应于输入滤波器数量的元参数。最后一个块可能会改变组输出的特征图数量(例如,加倍),这对应于输出滤波器数量的元参数。最外层(在图像中标有[特征]池化块)指的是具有延迟下采样的组,这对应于池化类型的元参数。

图 5.11 卷积组元参数:输入/输出滤波器数量和输出特征图大小

以下代码是编码学习组件的骨架模板(和示例)。在这个例子中,组的配置属性作为字典值列表传递,每个组一个值。learner() 函数遍历组配置属性列表;每次迭代对应于相应组的组参数(group_params)。

相应地,group() 函数遍历组中每个块的块参数(block_params)。然后,block() 函数根据传递给它的特定于块的配置参数构建块。

如图 5.11 所示,传递给 block() 方法的可配置属性作为关键字参数列表将是输入滤波器数量(in_filters)、输出滤波器数量(out_filters)和卷积层数量(n_layers)。如果输入和输出滤波器数量相同,通常使用单个关键字参数(n_filters):

def learner(inputs, groups):
    ''' leaner layers 
        inputs : the input tensors (feature maps)
        groups : the block parameters for each group
    '''
    outputs = inputs
    for group_parmas in groups:                        ❶
        outputs = group(outputs, **group_params)
    return outputs

def group(inputs, **blocks):
    ''' group layers
        inputs : the input tensors (feature maps)
        blocks : the block parameters for each block
    '''
    outputs = inputs
    for block_parmas in blocks:                        ❷
        outputs = block(**block_params)
    return outputs

def block(inputs, **params):
    ''' block layers
        inputs : the input tensors (feature maps)
        params : the block parameters for the block
    '''
    ...
    return outputs

outputs = learner(outputs, [ {'n_filters: 128'},       ❸
                 {'n_filters: 128'},  
                 {'n_filters: 256'} ]   

❶ 遍历每个组属性的字典值

❷ 遍历每个块属性的字典值

❸ 通过指定组数和每个组的滤波器数量来组装学习组件

5.4.1 ResNet

在 ResNet50、101 和 151 中,学习组件由四个卷积组组成。第一个组使用非步进卷积层作为第一个卷积块的投影捷径,该块从主干组件接收输入。其他三个卷积组在第一个卷积块的投影捷径中使用步进卷积层(特征池化)。图 5.12 展示了这种配置。

图 5.12 在 ResNet 学习器组件中,第一个组以一个非步长的投影快捷方式块开始。

现在我们将查看一个使用 ResNet50 学习器组件的骨架模板的示例应用。请注意,在learner()函数中,我们移除了第一个组的配置属性。在这个应用中,我们这样做是因为第一个组以一个非步长的投影快捷方式残差块开始,而所有剩余的组使用步长投影快捷方式。或者,我们也可以使用配置属性来指示第一个残差块是否步长,并消除特殊情况(编码单独的块构建)。

def learner(inputs, groups):
    """ Construct the Learner
        inputs: input to the learner
        groups: group parameters per group
    """
    outputs = inputs

    group_params = groups.pop(0)                                  ❶
    outputs = group(outputs, **group_params, strides=(1, 1))

    for group_params in groups:                                   ❷
        outputs = group(outputs, **group_params, strides=(2, 2))
    return outputs

❶ 首个残差组没有步长。

❷ 剩余的残差组使用步长卷积。

尽管 ResNets 今天仍然被用作图像分类骨干的标准模型,但如图 5.13 所示的 50 层 ResNet50 是标准。在 50 层时,模型在合理的大小和性能下提供了高精度。更大的 101 层和 151 层的 ResNet 在精度上只有轻微的增加,但大小显著增加,性能降低。

图 5.13 在 ResNet 组宏架构中,第一个块使用投影快捷方式,而剩余的块使用身份链接。

每个组以一个具有线性投影快捷方式的残差块开始,后面跟着一个或多个具有身份快捷方式的残差块。组中的所有残差块具有相同数量的输出滤波器。每个组依次将输出滤波器的数量加倍,具有线性投影快捷方式的残差块将输入到组中的滤波器数量加倍。

ResNets(例如,50、101、152)由四个卷积组组成;四个组的输出滤波器遵循加倍惯例,从 64 开始,然后是 128、256,最后是 512。数字惯例(50)指的是卷积层的数量,这决定了每个卷积组中的卷积块数量。

以下是一个使用 ResNet50 卷积组骨架模板的示例应用。对于group()函数,我们移除了第一个块的配置属性,我们知道对于 ResNet 来说这是一个投影块,然后迭代剩余的块作为身份块:

def group(inputs, blocks, strides=(2, 2)):
    """ Construct a Residual Group
        inputs    : input into the group
        blocks    : block parameters for each block
        strides   : whether the projection block is a strided convolution
    """
    outputs = inputs

    block_params = blocks.pop(0)                           ❶
    outputs = projection_block(outputs, strides=strides, **block_params)

    for block_params in blocks:                            ❷
        outputs = identity_block(outputs, **block_params)
    return outputs

❶ 残差组中的第一个块使用线性投影快捷链接。

❷ 剩余的块使用身份快捷链接。

在 GitHub 上有一个使用 Idiomatic procedure reuse 设计模式为 ResNet 编写的完整代码示例 (mng.bz/7jK9)。

5.4.2 DenseNet

DenseNet 中的学习组件(https://arxiv.org/abs/1608.06993)由四个卷积组组成,如图 5.14 所示。除了最后一个组外,每个组都将池化延迟到组末尾,这被称为过渡块。最后一个卷积组没有过渡块,因为没有后续的组。特征图将由任务组件进行池化和展平,因此不需要(冗余)在组末进行池化。这种将最终池化延迟到最后一个组到任务组件的模式,至今仍是一个常见的惯例。

图片

图 5.14 DenseNet 学习组件由四个具有延迟池化的卷积组组成。

以下是一个使用骨架模板编码 DenseNet 学习组件的示例实现。请注意,我们在遍历组之前移除最后一个组配置属性。我们将最后一个组视为一个特殊情况,因为该组不以过渡块结束。或者,我们也可以使用配置参数来指示一个组是否包含过渡块,从而消除特殊情况(即编写单独的块构造)。参数reduction指定了延迟池化期间特征图大小减少的量:

def learner(inputs, groups, reduction):
    """ Construct the Learner
        inputs    : input to the learner
        groups    : set of number of blocks per group
        reduction : the amount to reduce (compress) feature maps by
    """
    outputs = inputs

    last = groups.pop()                                 ❶

    for group_params in groups:                         ❷
        outputs = group(outputs, reduction, **group_params)

    outputs = group(outputs, last, reduction=None)      ❸
    return outputs

❶ 移除最后一个密集组参数并保存以供结尾使用

❷ 使用中间过渡块构建除最后一个密集组之外的所有密集组

❸ 在没有过渡块的最后一个密集组中添加

让我们看看 DenseNet 中的一个卷积组(图 5.15)。它仅由两种类型的卷积块组成。第一个块是用于特征学习的 DenseNet 块,最后一个块是一个过渡块,用于在下一个组之前减小特征图的大小,这被称为压缩因子

图片

图 5.15 DenseNet 组由一系列密集块和一个用于在输出特征图中进行降维的最终过渡块组成。

DenseNet 块本质上是一个残差块,除了在输出处不添加(矩阵加法操作)恒等链接,而是进行连接。在 ResNet 中,先前输入的信息只向前传递一个块。使用连接,特征图的信息累积,每个块将所有累积的信息向前传递给所有后续块。

这种特征图的连接会导致随着层数的加深,特征图的大小和相应的参数持续增长。为了控制(减少)增长,每个卷积块末尾的过渡块压缩(减小)了连接的特征图的尺寸。否则,如果没有缩减,随着层数的加深,需要学习的参数数量将显著增加,导致训练时间延长,而准确率没有提高。

以下是一个编码 DenseNet 卷积组的示例实现:

def group(inputs, reduction=None, **blocks):
    """ Construct a Dense Group
        inputs    : input tensor to the group
        reduction : amount to reduce feature maps by
        blocks    : parameters for each dense block in the group
    """
    outputs = inputs 

    for block_params in blocks:                            ❶
        outputs = residual_block(outputs, **block_params)

    if reduction is not None:                              ❷
        outputs = trans_block(outputs, reduction)
    return outputs

❶ 构建一组密集连接的残差块

❷ 构建中间过渡块

在 GitHub 上使用 Idiomatic 程序重用设计模式对 DenseNet 进行完整代码实现的示例是 (mng.bz/6N0o)。接下来,我们将解释任务组件的设计,学习组件将连接到该组件。

5.5 任务组件

任务组件是我们通常执行任务学习的地方。在用于图像分类的大规模传统 CNN 中,这个组件通常由两层组成:

  • 瓶颈层——将最终特征图的维度缩减到潜在空间

  • 分类层——执行模型正在学习的任务

学习组件的输出是特征图的最终减小尺寸(例如,4 × 4 像素)。瓶颈层执行最终特征图的维度缩减,然后输入到分类层进行分类。

在本节的剩余部分,我们将以图像分类器为例描述任务组件;我们将其称为 分类组件

5.5.1 ResNet

对于 ResNet50,特征图的数目是 2048。分类组件的第一个层既将特征图展平成 1D 向量,又使用 GlobalAveragePooling2D 等方法减小尺寸。这个展平/缩减层也被称为瓶颈层,如前所述。瓶颈层之后是一个 Dense 层,用于分类。

图 5.16 描述了 ResNet50 分类器。分类组件的输入来自学习组件的最终特征图(潜在空间),然后通过 GlobalAveragePooling2D,将每个特征图的尺寸减小到单个像素,并将其展平成一个 1D 向量(瓶颈)。从这个瓶颈层输出的内容通过 Dense 层,其中节点的数量对应于类别的数量。输出是所有类别的概率分布,通过 softmax 激活函数压缩,使其总和达到 100%。

图片

图 5.16 ResNet 分类组

以下是将此方法编码为分类组件的示例,包括用于展平和维度缩减的 GlobalAveragePooling2D,然后是用于分类的 Dense 层:

def classifier(inputs, n_classes):
    """ The output classifier
        inputs    : input tensor to the classifier
        n_classes : number of output classes
    """
    outputs = GlobalAveragePooling2D()(inputs)                   ❶

    outputs = Dense(n_classes, activation='softmax')(outputs)    ❷
    return outputs

❶ 使用全局平均池化将特征图(潜在空间)减少并展平成一个 1D 特征向量(瓶颈层)

❷ 用于输入最终分类的完全连接的 Dense 层

使用 Idiomatic 过程重用设计模式为 ResNet 提供的完整代码版本可在 GitHub 上找到(mng.bz/7jK9)。

5.5.2 多层输出

在早期部署的机器学习生产系统中,模型被视为独立的算法,我们只对最终输出(预测)感兴趣。今天,我们构建的不是模型,而是由模型混合或组合而成的应用程序。因此,我们不再将任务组件视为单个输出。

相反,我们认为它有四个输出,这取决于模型如何连接到应用程序中的其他模型。这些输出如下:

  • 特征提取

    • 高维度(编码)

    • 低维度(嵌入)—特征向量

  • 预测

    • 预测预激活(概率)—软目标

    • 激活后(输出)—硬目标

后续章节将介绍这些输出的目的(第九章关于自编码器,第十一章关于迁移学习,第十四章关于训练管道中的预训练任务),你将看到分类器中的每一层都有两个并行输出。在图 5.17 中描述的传统分类器的多输出中,你可以看到任务组件的输入也是模型的独立输出,被称为编码。编码随后通过全局平均池化进行降维,进一步减小学习组件提取的特征的大小。全局平均池化的输出也是模型的独立输出,被称为嵌入

图片

图 5.17 多输出分类器组具有四个输出—两个用于特征提取共享,两个用于概率分布

嵌入随后传递到一个预激活的密集层(在 softmax 激活之前)。预激活层的输出也是模型的独立输出,被称为预激活概率分布。这个概率分布随后通过 softmax 得到激活后的概率分布,成为模型的第四个独立输出。所有这些输出都可以被下游任务使用。

让我们描述一个简单的现实世界示例,即使用多输出任务组件:从车辆照片中估计维修成本。我们希望对两个类别进行估计:轻微损坏(如凹痕和划痕)的成本,以及重大损坏(如碰撞损坏)的成本。我们可能会尝试在一个任务组件中完成这项工作,该组件作为回归器输出一个实值(美元值),但我们在训练期间实际上是在过度加载任务组件,因为它在学习很小的值(轻微损坏)和大的值(重大损坏)。在训练期间,值的广泛分布可能会阻止模型收敛。

这种方法是将这个问题解决为两个独立的任务组件:一个用于轻微损坏,一个用于重大损坏。轻微损坏的任务组件将只学习很小的值,而重大损坏的任务组件将只学习大的值——因此,两个任务组件应该在训练过程中收敛。

接下来,我们考虑与两个任务共享哪个输出级别。对于轻微损坏,我们关注的是微小的物体。虽然我们没有涵盖物体检测,但历史上在小型物体上进行物体分类的问题在于,在池化后的裁剪特征图中包含的空间信息太少。解决方案是从更早的卷积层中的特征图进行物体分类;这样,特征图就会足够大,当裁剪出微小的物体时,仍然保留足够的空间信息以进行物体分类。

在我们的例子中存在一个类似的问题。对于轻微的损坏,物体(每个凹痕)将会非常小,我们需要更大的特征图来检测它们。因此,为了这个目的,我们在平均和池化之前将高维编码连接到执行轻微损坏估计的任务。另一方面,重大的碰撞损坏不需要很多细节。例如,如果保险杠有凹痕,无论凹痕的大小或位置如何,都必须更换。因此,为了这个目的,我们在平均和池化之后将低维嵌入连接到执行重大损坏估计的任务。图 5.18 展示了这个例子。

图片

图 5.18 展示了使用共享模型顶部的多输出从多任务组件估计车辆维修成本

以下是将多输出编码到分类组件的示例实现。特征提取和预测输出是通过捕获每一层的张量输入来实现的。在分类器的末尾,我们将返回单个输出替换为返回所有四个输出的元组:

def classifier(inputs, n_classes):
    """ The output classifier
        inputs    : input tensor to the classifier
        n_classes : number of output classes
    """
    encoding = inputs                                       ❶

    embeddings = GlobalAveragePooling2D()(inputs)           ❷

    probabilities = Dense(n_classes)(embeddings)            ❸

    outputs = Activation('softmax')(outputs)                ❹

    return encoding, embeddings, probabilities, outputs     ❺

❶ 高维特征提取(编码)

❷ 低维特征提取(嵌入)

❸ 预激活概率(软标签)

❹ 后激活概率(硬标签)

❺ 返回所有四个输出的元组

5.5.3 SqueezeNet

在紧凑模型中,尤其是对于移动设备,GlobalAveraging2D后面跟着一个Dense层被使用 softmax 激活的Conv2D所取代。Conv2D中的滤波器数量设置为类别的数量,然后是GlobalAveraging2D以展平到类别的数量。"SqueezeNet"论文由 Forrest Iandola 等人撰写(arxiv.org/pdf/1602.07360.pdf),解释了用卷积层替换密集层的理由:"注意 SqueezeNet 中没有全连接层;这个设计选择受到了 NiN(Lin et al., 2013)架构的启发。"

图 5.19 是一个使用此方法对分类组件进行编码的 SqueezeNet 示例。SqueezeNet 于 2016 年由 DeepScale、加州大学伯克利分校和斯坦福大学为移动设备开发,当时是 SOTA。

图 5.19 SqueezeNet 分类组件

您可以看到,它使用的是 1 × 1 卷积而不是密集层,其中滤波器的数量对应于类别的数量(C)。这样,1 × 1 卷积正在学习类别的概率分布,而不是输入特征图的投影。然后得到的(C)个特征图被每个减少到一个单一的真实值用于概率分布,并展平成一个 1D 输出向量。例如,如果 1 × 1 卷积输出的每个特征图大小为 3 × 3(9 像素),则选择值最高的像素作为对应类别的概率。然后 1D 向量通过 softmax 激活函数进行压缩,使得所有概率之和为 1。

让我们将其与我们之前在大规模 SOTA 模型中讨论的全局平均池化和密集层方法进行对比。假设最终特征图的大小为 3 × 3(9 像素)。然后我们将 9 个像素平均到一个值,并根据每个特征图的单个平均值进行概率分布。在 SqueezeNet 使用的方法中,执行概率分布的卷积层看到的是 9 像素的特征图(而不是平均的单个像素),并且有更多的像素来学习概率分布。这可能是 SqueezeNet 的作者为了补偿较小模型底部的较少特征提取/学习而做出的选择。

以下是对 SqueezeNet 分类组件进行编码的示例。在这个例子中,Conv2D的滤波器数量是类别的数量(n_classes),然后是GlobalAveragePooling2D。由于这个层是一个静态层(未学习),它没有激活参数,因此我们必须明确地跟随一个 softmax 激活层:

def classifier(inputs, n_classes):
    ''' Construct the Classifier
        inputs   : input tensor to the classifier
        n_classes: number of output classes
    '''
    encoding = Conv2D(n_classes, (1, 1), strides=1,               ❶
                     activation='relu', padding='same')(inputs)

    embedding = GlobalAveragePooling2D()(outputs)                 ❷
    outputs = Activation('softmax')(outputs)                      ❸
    return outputs

❶ 将滤波器的数量设置为输出类别的数量

❷ 将每个特征图(类别)减少到一个单一值(软标签)

❸ 使用 softmax 将所有类别概率压缩,使其总和达到 100%

使用 Idiomatic 过程重用设计模式对 SqueezeNet 的完整代码实现位于 GitHub 上(mng.bz/XYmv)。

5.6 超越计算机视觉:NLP

如第一章所述,我在计算机视觉的背景下解释的设计模式在自然语言处理和结构化数据中也有类似的原则和模式。为了了解过程设计模式如何应用于 NLP,让我们看看自然语言理解(NLU)这类 NLP 的一个例子。

5.6.1 自然语言理解

让我们从查看图 5.20 中 NLU 的一般模型架构开始。在 NLU 中,模型学习理解文本,并基于这种理解执行任务。任务的例子包括对文本进行分类、情感分析和实体提取。

图片

图 5.20 与所有深度学习模型一样,NLU 模型由词干、学习者和任务组件组成。不同之处在于每个组件内部。

我们可能会根据类型对医疗文档进行分类;例如,识别每个文档是处方、医生笔记、索赔提交或其他文档。对于情感分析,任务可能是确定评论是正面还是负面(二分类)或从负面到正面的排名(多分类)。对于实体提取,我们的任务可能是从实验室结果和医生/护士笔记中提取健康指标。

一个 NLU 模型被分解成所有深度学习模型都包含的相同组件:词干、学习者和任务。不同之处在于每个组件中发生的事情。

在一个 NLU 模型中,词干由一个编码器组成。其目的是将文本的字符串表示转换为基于数字的向量,称为嵌入。这个嵌入的维度比字符串输入更高,并包含关于单词、字符或句子的更丰富的上下文信息。词干编码器实际上是一个已经预训练的另一个模型。将词干编码器想象成一个字典。对于每个单词,它输出所有可能的含义,从低维度到高维度。嵌入的一个常见例子是N维度的向量,其中每个元素代表另一个单词,其值表示这个单词与其他单词的相关程度。

接下来,嵌入被传递到学习器组件。在一个 NLU 模型中,学习器由一个或多个编码器组组成,这些组又由一个或多个编码器块组成。每个这些块都基于一个设计模式,例如在转换器模型中的注意力块,而块和组的组装基于编码器模式的设计原则。

你可能已经注意到,茎和学习者都指的是编码器。它们在每个组件中不是同一种类型的编码器。 两个不同的事物使用相同的名称可能会有些令人困惑,所以我将进行澄清。当我们谈论生成嵌入的编码器时,我们将称之为茎编码器;否则,我们指的是学习者中的编码器。

学习者中编码器的目的是将嵌入转换为文本意义的低维表示,这被称为中间表示。这与在 CNN 中学习图像的基本特征相似。

任务组件与计算机视觉的对应组件非常相似。中间表示被展平成一个一维向量并进行了池化。对于分类和语义分析,池化表示被传递到一个 softmax 密集层,以预测跨类或语义排名的概率分布。

至于实体提取,任务组件与对象检测模型的任务组件相当;你正在学习两个任务:对提取的实体进行分类,以及在提取实体的文本中微调位置边界。

5.6.2 Transformer 架构

现在我们来看一下现代(NLU)模型的一个方面,它与计算机视觉中的 SOTA 相当。如第一章所述,NLU 的一个重大变化是在 2017 年谷歌大脑引入 Transformer 模型架构以及相应的论文“Attention is All You Need”由 Ashishh Vaswani 等人发表(arxiv.org/abs/1706.03762)。Transformer 架构解决了 NLU 中的一个难题:如何处理本质上类似于时间序列的文本序列——即,意义依赖于单词的序列顺序。在 Transformer 架构之前,NLU 模型被实现为循环神经网络(RNNs),这些网络会保留文本的序列顺序并学习单词的重要性(长记忆)或非重要性(短记忆)。

Transformer 模型所做的是引入了一种新的机制,称为注意力,它将 NLU 模型从时间序列转换为空间模型。我们不再将单词、字符或句子视为序列,而是将一组单词作为一个块来表示,就像图像一样。模型学习提取关键上下文——特征。注意力机制在残差网络中的身份链接作用相似。它为更重要的上下文添加了注意力——权重。

图 5.21 展示了在转换器架构中的一个注意力块。该块输入是一组来自前一个块的上下文图,类似于特征图。注意力机制为对上下文理解更为重要的上下文块部分添加权重(表示在此处需要关注)。然后,注意力上下文图被传递到一个前馈层,该层输出下一组上下文图。

图片

图 5.21 一个注意力块为对文本理解更为重要的上下文部分添加权重。

在下一章中,我们将介绍宽卷积神经网络,这是一种关注较宽层而不是较深层的架构模式。

摘要

  • 使用设计模式来设计和编码卷积神经网络可以使模型更易于理解,节省时间,确保模型代表了最佳 SOTA 实践,并且易于他人复现。

  • 程序设计模式使用了软件工程中的重用原则,这是软件工程师广泛实践的原则。

  • 宏架构由主干、学习者和任务组件组成,这些组件定义了模型中的流程以及在哪里/进行何种类型的学习。

  • 微架构由定义模型如何执行学习的组和块设计模式组成。

  • 预处理组的目的在于扩展现有的(预训练)模型以用于上游数据预处理、图像增强以及对其他部署环境的适应。将预处理器作为即插即用模块实现,为机器学习操作提供了部署模型而不需要附带上游代码的能力。

  • 任务组件的目的是从潜在空间中学习一个特定于模型的任务,编码在特征提取和表示学习过程中的学习。

  • 多层输出的目的是以最有效的方式扩展模型之间的互连性,同时保持性能目标。

  • 在转换器中的注意力机制提供了以类似于计算机视觉的方式按顺序学习关键特征的方法,而不需要循环网络。

6 宽卷积神经网络

本章涵盖

  • 介绍宽卷积层设计模式

  • 理解宽层与深层的优势

  • 将微架构模式重构以降低计算复杂性

  • 使用过程设计模式编码前 SOTA 宽卷积模型

到目前为止,我们一直专注于具有更深层、块层和残差网络中的捷径的网络,用于图像相关的任务,如分类、目标定位和图像分割。现在我们将探讨具有宽而不是深的卷积层的网络。从 2014 年的 Inception v1(GoogLeNet)开始,到 2015 年的 ResNeXt 和 Inception v2,神经网络设计转向了宽层,减少了深层层的需求。本质上,宽层设计意味着并行进行多个卷积,然后将它们的输出连接起来。相比之下,深层层具有顺序卷积并聚合它们的输出。

那么,是什么导致了宽层设计模式的实验?当时,研究人员了解到,为了提高模型的准确性,它们需要更多的容量。更具体地说,它们需要具有冗余的过量容量。

早期与 VGG (arxiv.org/pdf/1409.1556.pdf) 和 ResNet v1 (arxiv.org/abs/1512.03385) 的合作工作表明,更深层的容量确实增加了准确性。例如,AlexNet(2012)是第一个卷积神经网络提交作品,也是 ILSVRC 挑战赛的获胜者,实现了 5 类错误率为 15.3%,比 2011 年的获胜者提高了 10%。ZFNet 基于 AlexNet,并在 2013 年成为获胜者,5 类错误率为 14.8%。然后,在 2014 年,VGG 在层中进一步加深,以 7.3%的 5 类错误率成为第一名,而 ResNet 在 2015 年甚至更深,以 3.57%的 5 类错误率获得第一名。

但这些设计都遇到了瓶颈,限制了层的深度,从而限制了增加容量的能力。一个主要问题是梯度消失和梯度爆炸。当向模型添加更深层时,这些深层层的权重更有可能变得太小(消失)或太大(爆炸)。在训练过程中,模型会崩溃,就像计算机程序崩溃一样。

2015 年批量归一化的引入在某种程度上解决了这个问题。该开创性论文的作者 (arxiv.org/abs/1502.03167) 假设,在训练过程中将每层的权重重新分配到正态分布中,可以解决深层层中权重变得太小或太大的问题。其他研究人员验证了这一假设,批量归一化成为今天仍在继续的惯例。

但在加深层数的过程中,仍然存在一个问题:记忆化。结果证明,更深层的网络,通过增加过载来提高准确性,比浅层网络更容易记忆数据。也就是说,在过载的情况下,训练数据中的例子可能会直接映射到节点上,而不是从训练数据中泛化。当你在训练数据上看到准确率提高,但在训练过程中未看到的例子上准确率急剧下降时,我们说模型是过拟合的。而过拟合表明模型是在记忆而不是在学习。在深层添加一些噪声,如 dropout 和高斯噪声,可以减少记忆化,但并不能完全消除。

但如果我们通过在浅层中使卷积更宽来增加容量会怎样呢?这增加的容量将减少在发生记忆化的深层中进一步深化的需求。以 ResNeXt 为例,它在 2016 年 ILSVRC 竞赛中获得了第二名。它将残差块中的顺序卷积替换为并行卷积,以在浅层增加容量。

本章涵盖了宽层设计演变的过程,从原始的 inception 模块原理开始,该原理在 Inception v1 中被重新设计,然后是 Inception v2 和 v3 中的宽层块改进。我们还将探讨 Facebook AI Research 在 ResNeXt 中以及巴黎理工学院在 Wide Residual Network 中的宽层设计并行演变。

6.1 Inception v1

Inception v1 (arxiv.org/abs/1409.4842),在原名 GoogLeNet 下赢得了 2014 年 ILSVRC 竞赛中的目标检测奖项,引入了inception 模块。这个卷积层具有不同滤波器尺寸的并行卷积,每个卷积的输出被连接在一起。这里的想法是,而不是试图为某一层选择最佳的滤波器尺寸,每一层都有多个并行滤波器尺寸,层“学习哪个尺寸是最佳的”。

例如,假设你设计了一个具有多层卷积的模型,但你不知道哪个滤波器尺寸会给你最佳的结果。比如说,你想知道三个尺寸——3 × 3、5 × 5 或 7 × 7——哪个能给你最好的准确率。为了比较准确率,你将不得不制作三个版本的模型,每个滤波器尺寸一个,并训练每一个。但假设你现在想知道每个层的最佳滤波器尺寸。也许第一层应该是 7 × 7,下一层是 5 × 5,其余的是 3 × 3(或任何其他组合)。根据深度,这可能意味着数百甚至数千种可能的组合。训练每一种组合将是一项巨大的工作。

相反,inception 模块的设计通过在每个卷积层让每个特征图通过不同滤波器大小的并行卷积来解决该问题。这种创新使得模型能够通过单个模型实例的版本和训练来学习适当的滤波器大小。

6.1.1 朴素 inception 模块

图 6.1 展示了 朴素 inception 模块,展示了这种方法。

图 6.1 作为实验各种 inception 模块变体的理论基础的朴素 inception 模块

原生的 inception 模块是一个卷积块。块输入通过四个分支:一个用于降维的池化层,以及 1 × 1、3 × 3 和 5 × 5 的卷积层。然后,池化和其他卷积层的输出被连接在一起。

不同的滤波器大小捕捉不同的细节级别。1 × 1 卷积捕捉特征的细微细节,而 5 × 5 捕捉更抽象的特征。您可以在朴素 inception 模块的示例实现中看到这个过程。来自先前块(层)x 的输入分支并通过最大池化层、1 × 1、3 × 3 和 5 × 5 卷积,然后这些卷积被连接在一起:

x1 = MaxPooling2D((3, 3), strides=(1,1), padding='same')(x)              ❶
x2 = Conv2D(64, (1, 1), strides=(1, 1), padding='same', activation='relu')(x)                                              
x3 = Conv2D(96, (3, 3), strides=(1, 1), padding='same', activation='relu')(x)                                              
x4 = Conv2D(48, (5, 5), strides=(1, 1), padding='same', activation='relu')(x)                                              

output = Concatenate()([x1, x2, x3, x4])                                 ❷

❶ inception 分支,其中 x 是前一层

❷ 将四个分支的输出连接在一起

通过设置 padding='same',输入的高度和宽度维度得到保留。这允许将每个分支的相应输出连接在一起。例如,如果输入是 256 个大小为 28 × 28 的特征图,分支层的维度如下,其中 ? 是批处理大小的占位符:

x1 (pool)       :  (?, 28, 28, 256)
x2 (1x1)        :  (?, 28, 28, 64)
x2 (3x3)        :  (?, 28, 28, 96)
x3 (5x5)        :  (?, 28, 28, 48)

连接后,输出结果如下:

x (concat)      : (?, 28, 28, 464)

让我们看看我们是如何得到这些数字的。首先,卷积和最大池化都是非步进(意味着它们有步长 1),所以特征图没有下采样。其次,由于我们设置了 padding='same',我们不会在边缘丢失任何像素宽度/高度。因此,输出的特征图大小将与输入相同,因此在输出中是 28 × 28。

现在,让我们看看最大池化分支,它输出的特征图数量与输入相同,因此我们得到 256。三个卷积分支的特征图数量等于滤波器的数量,因此会是 64、96、48。然后如果我们把所有分支的特征图加起来,我们得到 464。

对于一个朴素 inception 模块,summary() 显示有 544,000 个参数需要训练:

max_pooling2d_1 (MaxPooling2D (None, 28, 28, 256)  0      input_1[0][0]
_______________________________________________________________________________
conv2d_1 (Conv2D)            (None, 28, 28, 64)    16448  input_1[0][0]
_______________________________________________________________________________
conv2d_2 (Conv2D)            (None, 28, 28, 96)    221280 input_1[0][0]
_______________________________________________________________________________
conv2d_3 (Conv2D)            (None, 28, 28, 48)    307248 input_1[0][0]
_______________________________________________________________________________
concatenate_1 (Concatenate)  (None, 28, 28, 464)   0      max_pooling2d_1[0][0]
conv2d_1[0][0]               
conv2d_2[0][0]               
conv2d_3[0][0]               
===============================================================================
Total params: 544,976
Trainable params: 544,976

如果省略 padding='same' 参数(默认为 padding='valid'),形状将如下所示:

x1 (pool)       :  (?, 26, 26, 256)
x2 (1x1)        :  (?, 28, 28, 64)
x2 (3x3)        :  (?, 26, 26, 96)
x3 (5x5)        :  (?, 24, 24, 48)

由于宽度和高度维度不匹配,如果您尝试连接这些层,您将得到以下错误:

ValueError: A Concatenate layer requires inputs with matching shapes except
➥ for the concat axis. Got inputs shapes: [(None, 26, 26, 256), (None, 28, 
➥ 28, 64), (None, 26, 26, 96), (None, 24, 24, 48)]

原始的 Inception 模块是 Inception v1 作者的原理。当作者参加 ILSVRC 比赛时,他们通过使用瓶颈残差块设计重构了模块,称为 Inception v1 模块。该模块保持了准确性,并且在训练时计算成本更低。

6.1.2 Inception v1 模块

Inception v1 通过在池化、3 × 3 和 5 × 5 分支中添加 1 × 1 瓶颈卷积,进一步实现了维度降低。这种维度降低将整体计算复杂度降低了近三分之二。

在这一点上,你可能想知道,为什么使用 1 × 1 卷积?一个 1 像素的过滤器如何能够学习到任何特征?1 × 1 卷积的使用就像粘合代码。1 × 1 卷积要么用于扩展或减少输出中的通道数,同时保持通道大小(形状)。扩展通道数被称为线性投影;我们在 5.3.1 节中讨论了这一点。

减少,也称为瓶颈,用于减少块输入和卷积层输入之间的通道数。线性投影和瓶颈卷积类似于上采样和下采样,除了我们不是扩展或减少通道的大小,而是减少通道的数量。在这种情况下,因为我们正在减少通道数,所以我们可以说我们正在压缩数据——这就是我们使用术语维度降低的原因。我们可以使用静态算法来完成这项工作,或者,正如这个例子中,我们学习最优方法来减少通道数。这与最大池化和特征池化类似;在最大池化中,我们使用静态算法来减少通道的大小,而在特征池化中,我们学习最优方法来减少大小

图 6.2 展示了 Inception v1 模块。3 × 3 和 5 × 5 分支之前有一个 1 × 1 瓶颈卷积,而池化分支之后有一个 1 × 1 瓶颈卷积。

图片

图 6.2 Inception v1 模块(模块)的设计,该模块被用于 2014 年 ILSVRC 提交

下面是一个 Inception v1 模块(模块)的示例,其中池化、3 × 3 和 5 × 5 卷积分支增加了额外的 1 × 1 瓶颈卷积:

x1 = MaxPooling2D((3, 3), strides=(1,1), padding='same')(x)                   ❶
x1 = Conv2D(64, (1, 1), strides=(1, 1), padding='same',                       ❶
➥ activation='relu')(x1)                                                     ❶
x2 = Conv2D(64, (1, 1), strides=(1, 1), padding='same', activation='relu')(x) ❶
x3 = Conv2D(64, (1, 1), strides=(1, 1), padding='same', activation='relu')(x) ❶
x3 = Conv2D(96, (3, 3), strides=(1, 1), padding='same',                       ❶
➥ activation='relu')(x3)                                                     ❶
x4 = Conv2D(64, (1, 1), strides=(1, 1), padding='same', activation='relu')(x) ❶
x4 = Conv2D(48, (5, 5), strides=(1, 1), padding='same',                       ❶
➥ activation='relu')(x4)                                                     ❶

x = Concatenate([x1, x2, x3, x4])                                             ❷

❶ Inception 分支,其中 x 是前一层

❷ 将分支的特征图连接在一起

对于这些层的summary()显示需要训练 198,000 个参数,相比之下,使用瓶颈卷积进行维度降低的原生 Inception 模块有 544,000 个参数。模型的设计者能够保持与原始 Inception 模块相同的准确度水平,但训练速度更快,预测(推理)性能得到改善:

max_pooling2d_1 (MaxPooling2D (None, 28, 28, 256)  0           input_1[0][0]        
____________________________________________________________________________________
conv2d_1 (Conv2D)             (None, 28, 28, 64)   16448       input_1[0][0]
____________________________________________________________________________________
conv2d_2 (Conv2D)             (None, 28, 28, 64)   16448       input_1[0][0]
____________________________________________________________________________________
conv2d_3 (Conv2D)             (None, 28, 28, 64)   16448       max_pooling2d_1[0][0]
____________________________________________________________________________________
conv2d_4 (Conv2D)             (None, 28, 28, 64)   16448       input_1[0][0]
____________________________________________________________________________________
conv2d_5 (Conv2D)             (None, 28, 28, 96)   55392       conv2d_4[0][0]
____________________________________________________________________________________
conv2d_6 (Conv2D)             (None, 28, 28, 48)   76848       conv2d_2[0][0]
____________________________________________________________________________________
concatenate_430 (Concatenate) (None, 28, 28, 272)  0           conv2d_3[0][0]
conv2d_1[0][0]               
conv2d_5[0][0]               
conv2d_6[0][0]               
====================================================================================
Total params: 198,032
Trainable params: 198,032

正如您在图 6.3 中看到的,当 Inception v1 架构被重构为程序设计模式时,它由四个组件组成:主干、学习器、分类器和辅助分类器。总体而言,程序设计模式表示的宏观架构与之前我展示的 SOTA 模型相同,只是增加了辅助分类器。如图所示,学习器组件由五个卷积组组成,每个组有不同的卷积块数量。第二和第四个卷积组是唯一只有一个卷积块的组,并且是与辅助分类器相连的组。

图片

图 6.3 在 Inception v1 宏观架构中,辅助分类器被添加到第二和第四个 Inception 组之后。

Inception v1 在 2014 年 ILSVRC 挑战中对象检测项目中获得第一名的成绩,证明了在深度层的同时探索宽层设计模式的有用性。

注意,我一直在将模块称为块,这是在此设计模式中使用的术语。接下来,我们将更详细地探讨每个组件。

6.1.3 主干

主干是进入神经网络的人口。输入(图像)通过一系列(深度)卷积和最大池化进行处理,就像传统的 ConvNet 一样。

让我们更深入地探讨一下主干部分,以便您可以看到其结构与当时传统 SOTA 主干的不同(见图 6.4)。Inception 使用了一个非常粗略的 7 × 7 初始滤波器,随后是一个非常激进的特性图减少,包括两个步进卷积和两个最大池化。另一方面,它逐渐将特性图的数量从 64 增加到 192。Inception 在卷积中无法移动滤波器到边缘的情况下进行编码。因此,为了在减少过程中保持高度和宽度减半,添加了零填充。

图片

图 6.4 Inception v1 主干由一个粗略的 7 × 7 滤波器和一个详细的 3 × 3 滤波器组成,每个卷积后都进行最大池化以降低维度。

6.1.4 学习器

学习器是由五个组中的九个 Inception 块组成的集合,如图 6.3 和图 6.5 所示。图中较宽的组代表两个或三个 Inception 块的一组,而较细的组是一个单独的 Inception 块,总共九个 Inception 块。第四和第七个块(单个块)被单独列出,以突出它们有一个额外的组件,即辅助分类器。

图片

图 6.5 Inception v1 学习组件的组配置和块数量。

6.1.5 辅助分类器

辅助分类器是一组两个分类器,作为训练神经网络的辅助工具。每个辅助分类器由一个卷积层、一个密集层和一个最终的 softmax 激活函数(图 6.6)组成。softmax(你可能已经知道)是统计学中的一个方程,它接受一组独立的概率(从 0 到 1)作为输入,并将该组压缩,使得所有概率加起来等于 1。在一个每个类别有一个节点的最终密集层中,每个节点都会做出独立的预测(从 0 到 1),通过将值传递给 softmax 函数,所有类别的预测概率之和将等于 1。

图 6.6 Inception v1/v2 辅助分类器组

Inception v1 架构引入了辅助分类器的概念。这里的原理是,随着神经网络层数的加深(随着前层离最终分类器越来越远),前层更容易受到梯度消失和增加的训练时间(增加的 epoch 数量)的影响,以训练最前层的权重。图 6.7 展示了这一过程。当权重更新从后层传播过来时,更新往往会逐渐变小。

图 6.7 在反向传播过程中,通过层传递的权重更新逐渐变小。

如 Inception v1 的作者们所理论化的,这可能导致两个他们想要解决的问题。首先,如果更新变得太小,乘法运算可能会导致一个太小以至于无法由浮点计算机硬件表示的数字(这被称为梯度消失)。

另一个问题是在早期层的更新远小于后期层时,它们将需要更长的时间来收敛并增加训练时间。此外,如果后期层提前收敛而早期层较晚收敛,那么后期层可能会开始记住数据,而早期层仍在学习泛化。

Inception v1 的作者们认为,在半深层,有一些信息可以用来预测或分类输入,尽管其准确性不如最终分类器。这些早期的分类器更靠近前层,因此不太容易受到梯度消失的影响。在训练过程中,损失函数成为辅助分类器和最终分类器损失的组合。换句话说,作者们认为,将辅助和最终分类器的损失组合起来,将导致所有层权重的更新更加均匀,从而缓解梯度消失并减少训练时间。

图 6.6 中展示的辅助分类器在 Inception v1 和 Inception v2 中都被使用。在 Inception 之后,由于两个原因,没有继续使用辅助分类器的做法。首先,随着模型层级的加深,梯度消失(和爆炸)的问题在深层比在前面层更为明显,因此该理论在深层神经网络中并未得到验证。其次,2015 年引入的批量归一化在所有层面上统一解决了这个问题。

与 VGG 设计相比,Inception v1 在分类器中(无论是辅助还是最终分类器)消除了添加额外的密集层,而 VGG 则有两个额外的 4096 节点密集层。作者理论认为,额外的密集节点对于训练最终的分类密集层是不必要的。这大大降低了计算复杂度,而没有在分类器上降低准确性。在随后的 SOTA 模型中,研究人员发现他们可以消除瓶颈和最终分类器层之间的所有先前密集层,而不会降低准确性。

Inception 是最后一批使用分类器中的 dropout 层进行正则化以减少过拟合的 SOTA 模型之一。在随后的批量归一化引入后,研究人员观察到归一化在每个层上添加了少量的正则化,并且比 dropout 更有效地促进了泛化。最终,研究人员引入了称为权重正则化的显式逐层正则化,进一步提高了正则化效果。因此,随后 dropout 的使用逐渐被淘汰。

6.1.6 分类器

图 6.8 展示了在训练神经网络和预测中使用的最终(非辅助)分类器。请注意,在预测时,辅助分类器被移除。该分类器通过两层实现全局平均池化步骤;第一层(AveragePooling2D)对每个特征图进行平均池化,形成 1 × 1 的特征图,随后通过一个展平层将其展平成一个一维向量。在分类的Dense层之前,使用了一个Dropout层进行正则化——这是当时的一种常见做法。

图片

图 6.8 在 Inception 最终分类器中,池化到 1 × 1 像素图和展平是作为两个步骤完成的。

使用 Idiomatic 过程重用设计模式为 Inception v1 编写的完整代码可以在 GitHub 上找到(mng.bz/oGnd)。

接下来,让我们看看 Inception v2 如何引入了计算密集型卷积分解的概念。

6.2 Inception v2:分解卷积

在卷积中,滤波器(核)的大小越大,计算成本就越高。提出 Inception v2 架构的论文计算出,inception 模块中的 5 × 5 卷积比 3 × 3 卷积计算成本高 2.78 倍。换句话说,5 × 5 滤波器需要几乎三倍的 matmul 操作,需要更多的时间进行训练和预测。作者的目标是找到一种方法,用 3 × 3 滤波器替换 5 × 5 滤波器,以减少训练/预测时间,同时不牺牲模型的准确率。

Inception v2 引入了分解来降低 inception 模块中更昂贵的卷积的计算复杂度,并减少从表示瓶颈中损失的信息。图 6.9 描述了表示损失。在这个描述中,我们展示了一个覆盖 25 像素区域的 5 × 5 滤波器。在每次滤波器滑动时,25 像素区域被(表示为)一个单独的像素所替代。在后续池化操作中,对应特征图中的这个单独像素将被减半。表示损失是压缩比,在这种情况下为 50(25 到 0.5)。对于较小的 3 × 3 滤波器,表示损失为 18(9 到 0.5)。

图像

图 6.9 滤波器与后续池化后输出的像素值之间的表示损失

在 Inception v2 模块中,5 × 5 滤波器被两个 3 × 3 滤波器的堆栈所取代,这导致替换的 5 × 5 滤波器的计算复杂度降低了 33%。

此外,当存在滤波器大小的大差异时,会发生表示瓶颈损失。通过用两个 3 × 3 滤波器替换 5 × 5 滤波器,所有非瓶颈滤波器现在具有相同的大小,并且 Inception v2 架构的整体准确率超过 Inception v1。

图 6.10 展示了 Inception v2 块:v1 中的 5 × 5 卷积被两个 3 × 3 卷积所取代。

图像

图 6.10 在 Inception v2 块中,5 × 5 卷积被更高效的两个 3 × 3 卷积堆栈所取代。

Inception v2 还增加了在每个卷积层使用后激活批量归一化(Conv-BN-ReLU)。由于批量归一化直到 2015 年才被引入,因此 2014 年的 Inception v1 没有使用这种技术的优势。图 6.11 展示了添加批量归一化(Conv-ReLU)前后的前一个卷积层和激活之间的差异。

图像

图 6.11 添加批量归一化前后卷积层和激活的比较

以下是一个 Inception v2 模块的代码示例,它与 v1 的不同之处如下:

  • 每个卷积层后面都跟着一个批量归一化。

  • v1 的 5 × 5 卷积被两个 3 × 3 卷积所取代,将更昂贵的 5 × 5 分解为更便宜的 3 × 3 卷积对,从而降低了计算复杂性和信息损失,减少了表示瓶颈。

x1 = MaxPooling2D((3, 3), strides=(1,1), padding='same')(x)    ❶
x1 = Conv2D(64, (1, 1), strides=(1, 1), padding='same')(x1)
x1 = BatchNormalization()(x1)                                  ❷
x1 = ReLU()(x1)

x2 = Conv2D(64, (1, 1), strides=(1, 1), padding='same')(x)     ❶
x2 = BatchNormalization()(x2)
x2 = ReLU()(x2)

x3 = Conv2D(64, (1, 1), strides=(1, 1), padding='same')(x)     ❶
x3 = BatchNormalization()(x3)
x3 = ReLU()(x3)
x3 = Conv2D(96, (3, 3), strides=(1, 1), padding='same')(x3)
x3 = BatchNormalization()(x3)
x3 = ReLU()(x3)

x4 = Conv2D(64, (1, 1), strides=(1, 1), padding='same')(x)     ❶
x4 = BatchNormalization()(x4)
x4 = ReLU()(x4)
x4 = Conv2D(48, (3, 3), strides=(1, 1), padding='same')(x4)
x4 = BatchNormalization()(x4)
x4 = ReLU()(x4)
x4 = Conv2D(48, (3, 3), strides=(1, 1), padding='same')(x4)
x4 = BatchNormalization()(x4)
x4 = ReLU()(x4)

x = Concatenate([x1, x2, x3, x4])                              ❸

❶ 使用后激活批量归一化

❷ Inception 分支,其中 x 是前一层

❸ 将分支的特征图连接在一起

与 Inception v1 模块的 198,000 个参数相比,这些层的 summary() 显示需要训练 169,000 个参数。

使用 Idiomatic 程序重用设计模式对 Inception v2 的完整代码实现位于 GitHub 上 (mng.bz/oGnd)。接下来,我们将描述 Inception 架构在 Inception v3 中的重设计过程。

6.3 Inception v3:架构重设计

Inception v3 引入了新的宏观架构设计,同时重新设计了主干组,并仅使用一个辅助分类器。Christian Szegedy 等人在其论文标题中提到了这种安排,“重新思考 Inception 架构” (arxiv.org/abs/1512.00567)。

作者指出,近年来在提高准确率和降低参数大小方面取得了显著进展,这既涉及更深层次的架构,也涉及更宽的架构。AlexNet 有 6000 万个参数,VGG 的参数是其三倍,而 Inception v1 的参数只有 500 万。作者强调了需要高效架构,这些架构可以将模型应用于现实世界,并在参数数量减少的同时实现更高的准确率提升。

在他们看来,Inception v1/v2 架构对于这个目的来说过于复杂。例如,为了增加容量而将滤波器组的尺寸加倍,会使参数数量增加四倍。重设计的动机是简化架构,同时在扩展时保持计算收益,并提高当时 SOTA 模型的准确性。

在重设计中,卷积块被重构,以便架构可以高效地扩展。图 6.12 显示了宏观架构:学习组件由三个组(A、B 和 C)组成,用于特征学习,以及两个用于特征图减少的网格减少组。此外,辅助分类器的数量从两个减少到一个。

图 6.12 重设计的 Inception v3 宏观架构简化了 Inception v1 和 v2 架构。

让我们接下来更详细地看看这些重设计的组件。

6.3.1 Inception 组和块

四个设计原则塑造了 Inception 架构的重设计。

  1. 避免表示损失。

  2. 高维表示更容易在网络中局部处理。

  3. 在低维嵌入上执行空间聚合不会在表示能力上造成很大损失。

  4. 平衡网络的宽度和深度。

Inception 的微架构反映了第一个设计原则,即通过实现特征图大小的更渐进式减少来减少从表示瓶颈中损失的信息。它还解决了原则 4,即平衡卷积层的宽度和深度。作者观察到,当宽度和深度的增加是并行进行,并且计算预算在这两者之间平衡时,最优的改进发生了。因此,该模型在宽度和深度上同时增加,以贡献于更高品质的网络。

现在让我们进一步放大,看看 A、B 和 C 组的重新设计。A 组的块与早期版本相同,而 B 组和 C 组有所不同。A、B 和 C 组的输出特征图大小分别为 35 × 35、17 × 17 和 8 × 8。注意特征图大小的逐渐减少,因为每个组将H × W减半。这种在三组中的逐渐减少反映了设计原则 1,即减少表示损失,因为网络变得更深。

图 6.13 和 6.14 分别显示了 B 组和 C 组的块。在这两个组中,一些N × N卷积被分解为N × 1 和 1 × N 的空间可分离卷积。这种调整反映了设计原则 3,即当在特征图的较低维度上执行时,空间可分离卷积不会丢失表示能力。

图像

图 6.13 使用空间可分离卷积的 Inception v3 块 17 × 17(组 B)

图像

图 6.14 使用并行空间可分离卷积的 Inception v3 块 8 × 8(组 C)

在 Inception 的早期版本中,为了减少维度,卷积组之间的特征图进行了池化,数量加倍,研究人员认为这导致了表示损失(设计原则 1)。他们提出,组之间的特征图减少阶段可以用并行卷积和池化来完成,如图 6.15 所示。然而,在 Inception v3 之后,这种在减少过程中消除表示损失的方法没有进一步追求。

图像

图 6.15 使用特征图并行池化以减少表示损失

A 组和 B 组之后的并行池化分别显示在图 6.16 和 6.17 中。被称为网格减少的这些池化块将前一个组的输出特征图(或通道)的数量减少,以匹配下一个组的输入。因此,网格减少块 A 从 35 × 35 减少到 17 × 17,网格减少块 B 从 17 × 17 减少到 8 × 8(满足设计原则 1)。

图像

图 6.16 Inception v3 网格减少块 17 × 17(组 A)

此外,反映设计原则 3,7 × 7 卷积,以及 B 组和 C 组中的一些 3 × 3 卷积,以及 B 组中的网格减少,分别被(7 × 1, 1 × 7)和(3 × 1, 1 × 3)的空间卷积所取代。

图 6.17 Inception v3 网格减少块 8 × 8(组 B)

现在我们来比较 Inception v1 和 v2 中的正常卷积,我们将其简单地称为正常卷积,与 v3 中的空间可分离卷积。

6.3.2 正常卷积

正常卷积中,核(例如,3 × 3)应用于高度(H)、宽度(W)和深度(D)通道。每次核移动时,矩阵乘法操作的次数等于像素的数量,即H × W × D

例如,一个具有三个通道的 RGB 图像(应用 3 × 3 核到所有三个通道)使用了 3 × 3 × 3 = 27 次矩阵乘法(matmul)操作,生成一个N × M × 1(例如,8 × 8 × 1)的特征图(每个核),其中NM是特征图的结果高度和宽度;参见图 6.18

图 6.18 带有一个滤波器的填充卷积

如果我们指定卷积的输出为 256 个滤波器,我们就有 256 个核需要训练。在 RGB 示例中使用 256 个 3 × 3 核的情况下,这意味着每次核移动时需要进行 6912 次矩阵乘法操作;参见图 6.19。因此,即使核大小很小(3 × 3),随着我们增加输出特征图的数量以获得更强的表示能力,正常卷积的计算成本也会变得很高。

图 6.19 带有多个滤波器的填充卷积

6.3.3 空间可分离卷积

相比之下,空间可分离卷积将 2D 核(例如,3 × 3)分解为两个较小的 1D 核。如果我们用H × W表示 2D 核,那么分解的两个较小的 1D 核将是H × 1 和 1 × W。这种分解将计算总量减少了一半。虽然这种分解并不总是保持表示等价性,但研究人员证明了他们在 Inception v3 中能够保持表示等价性。图 6.20 比较了正常卷积和可分离卷积。

在使用 3 × 3 核的 RGB 示例中,每次核移动时,正常卷积会有 3 × 3 × 3(通道)= 27 次矩阵乘法操作。在相同的 RGB 示例中,使用因式分解的 3 × 3 核,空间可分离卷积每次核移动时会有(3 × 1 × 3) + (1 × 3 × 3) = 18 次矩阵乘法操作。因此,矩阵乘法操作的数量减少了三分之一(18 / 27)。

图 6.20 正常卷积与空间可分离卷积的比较

在使用 256 个 3 × 3 核的 RGB 示例中,每次核移动时我们有 4608 次矩阵乘法操作,与正常卷积相比,正常卷积会有 6912 次矩阵乘法操作。

6.3.4 茎重新设计和实现

到 Inception v3 被设计出来的时候,用两个 3 × 3 的卷积堆叠替换粗略的 5 × 5 滤波器已经成为一种常见的做法,这比单个 5 × 5 滤波器计算量更小(18 次矩阵乘法操作与 25 次相比)并且保留了表示能力。使用同样的原理,作者理论化地认为,一个计算成本较高的 7 × 7 粗略级卷积(每次移动 49 次矩阵乘法),可以被三个 3 × 3 卷积的堆叠(每次移动 27 次矩阵乘法)所替代。这减少了茎组件中的参数数量,同时保留了表示能力。

图 6.21 Inception v3 茎组,由替换 v1/v2 中 7 × 7 卷积的三个 3 × 3 卷积堆叠组成

图 6.21 展示了茎卷积组中的 7 × 7 卷积是如何分解并替换为三个 3 × 3 卷积的堆叠的,如下所示:

  1. 第一个 3 × 3 是一个步长卷积(strides=2, 2),它执行特征图减少。

  2. 第二个 3 × 3 是一个常规卷积。

  3. 第三个 3 × 3 将滤波器的数量翻倍。

Inception v3 将是基于分解(或非分解)的 7 × 7 粗滤波器的最后一批 SOTA 模型之一。今天的当前做法是分解(或非分解)的 5 × 5。

下一个代码示例是实现 Inception v3 的茎组,它包括以下内容:

  1. 三个 3 × 3 卷积的堆叠(分解的 7 × 7),其中第一个卷积用于特征池化(输入形状的 25%大小)。

  2. 一个最大池化层用于进一步减少特征图的维度(输入形状的 6%大小)。

  3. 1 × 1 线性投影卷积将特征图的数量从 64 扩展到 80。

  4. 一个 3 × 3 的卷积用于进一步将维度扩展到 192 个特征图。

  5. 第二个最大池化层用于进一步减少特征图的维度(输入形状的 1.5%大小)。

x = Conv2D(32, (3, 3), strides=(2, 2), padding='same')(input)     ❶
x = BatchNormalization()(x)
x = ReLU()(x)
x = Conv2D(32, (3, 3), strides=(1, 1), padding='same')(x)
x = BatchNormalization()(x)
x = ReLU()(x)
x = Conv2D(64, (3, 3), strides=(1, 1), padding='same')(x)
x = BatchNormalization()(x)
x = ReLU()(x)

# max pooling layer
x = MaxPooling2D((3, 3), strides=(2, 2), padding='same')(x) 

x = Conv2D(80, (1, 1), strides=(1, 1), padding='same')(x)         ❷
x = BatchNormalization()(x)
x = ReLU()(x)

x = Conv2D(192, (3, 3), strides=(1, 1), padding='same')(x)        ❸
x = BatchNormalization()(x)
x = ReLU()(x)

x = MaxPooling2D((3, 3), strides=(2, 2), padding='same')(x)       ❹

❶ Inception v3 茎,7 × 7 被替换为一个 3 × 3 卷积的堆叠

❷ 一个 1 × 1 线性投影卷积

❸ 特征图扩展(维度扩展)

❹ 特征图池化(维度减少)

对于茎组的一个summary()显示有 614,000 个参数用于输入(229, 229, 3)训练。

6.3.5 辅助分类器

Inception v3 的另一个变化是将两个辅助分类器减少到一个,并进一步简化,如图 6.22 所示。作者解释说,他们做出这些改变是因为“他们发现辅助分类器在训练初期并没有导致收敛性的改善。”通过保留单个分类器,他们似乎瞄准了中间点。

图 6.22 Inception v3 辅助组

同样,他们采用了当时的一种惯例,在最终的分类器之前移除额外的密集层,进一步减少参数。早期的研究人员已经确立,移除额外的密集层(在分类密集层之前)不会导致准确度下降。

辅助分类器进一步简化为以下内容:

  • 一个平均池化层(AveragePooling2D),将每个特征图减少到一个 1 × 1 的矩阵

  • 一个 3 × 3 卷积层(Conv2D),输出 768 个 1 × 1 特征图

  • 一个展平层(Flatten)将特征图展平为 768 个元素的 1D 向量

  • 一个用于分类的最终密集层(Dense

使用 Idiomatic 过程重用设计模式为 Inception v3 编写的完整代码可以在 GitHub 上找到(mng.bz/oGnd)。

6.4 ResNeXt:宽残差神经网络

Facebook AI Research 的ResNeXt,是 2016 年 ILSVRC 竞赛 ImageNet 的第二名,引入了一个使用分割-变换-合并模式进行并行卷积的宽残差块。这种并行卷积的架构被称为组卷积

并行卷积的数量构成了宽度,称为基数。例如,在 2016 年的比赛中,ResNeXt 架构使用了 32 个基数,这意味着每个 ResNeXt 层由 32 个并行卷积组成。

这里的想法是添加并行卷积可以帮助模型在不加深层的情况下提高准确性,而加深层更容易导致记忆化。在他们的消融研究中(arxiv.org/abs/1611.05431),Saining Xie 等人比较了 ResNeXt 与 ResNet 的 50、101 和 200 层,以及 Inception v3 的 101 和 200 层在 ImageNet 数据集上的表现。在所有情况下,相同深度层的 ResNeXt 架构都实现了更高的准确性。

如果你查看预训练模型存储库,例如 TensorFlow Hub,你可以看到 SE-ResNeXt 变体具有略高的计算量和更高的准确性,并且被选为图像分类的主干。

6.4.1 ResNeXt 块

在每个 ResNeXt 层中,从前一层的输入被分配到并行卷积中,每个卷积的输出(特征图)被连接回一起。最后,将层的输入矩阵加到连接的输出(恒等连接)上,形成残差块。这一系列层被称为分割-变换-合并缩放操作。定义这些术语将有助于阐明操作:

  • 分割指的是根据基数将特征图分成组。

  • 变换是指每个组中并行卷积中发生的事情。

  • 合并指的是结果特征图的连接操作。

  • 缩放表示恒等连接中的加法操作。

分割-变换-合并操作的目标是在不增加参数的情况下提高准确性。它是通过将基本变换(w × x)转换为网络内神经元聚合变换来实现的。

现在来看实现这些概念的架构。如图 6.23 所示,ResNeXt 的宽残差块组包括以下内容:

  • 一个第一瓶颈卷积(1 × 1 核)

  • 一个基数N的分割-分支-连接卷积(组卷积)

  • 一个最终的瓶颈卷积(1 × 1 核)

  • 输入和最终卷积输出之间的恒等连接(快捷连接)

图像

图 6.23 实现分割-变换-合并和缩放操作的具有恒等快捷连接的残差块

让我们更详细地看看组卷积的分割-变换-合并操作(图 6.24)。以下是三个主要步骤的应用方式:

  1. 分割:输入(特征图)被均匀分割成N组(其中N是基数)。

  2. 变换:每个组通过一个独立的 3 × 3 卷积。

  3. 合并:将所有变换后的组连接在一起。

图像

图 6.24 实现分割-变换-合并操作的 ResNeXt 组卷积

第一个瓶颈卷积通过减少(压缩)输入特征图的数量来执行维度降低。我们在第五章查看瓶颈残差块以及在 6.1 至 6.3 节查看 Inception 模块时看到了瓶颈卷积的类似用途。

在瓶颈卷积之后,特征图根据基数分配到并行卷积中。例如,如果输入特征图的数量(或通道数)为 128,基数是 32,每个并行卷积将获得 4 个特征图,这是特征图数量除以基数,即 128 除以 32。

然后将并行卷积的输出连接回完整的特征图集合,然后通过最终的瓶颈卷积进行另一轮维度降低。与残差块类似,输入到 ResNeXt 块和从 ResNeXt 块输出的输入之间存在恒等连接,然后进行矩阵加法。

以下是一个 ResNeXt 块的编码示例,它由四个代码序列组成:

  1. 块输入(快捷连接)通过一个 1 × 1 瓶颈卷积进行维度降低。

  2. 分割-变换操作(组卷积)。

  3. 合并操作(连接)。

  4. 输入与合并操作的输出(恒等连接)矩阵相加作为缩放操作。

shortcut = x                                                               ❶
                                                                           ❶
x = Conv2D(filters_in, (1, 1), strides=(1, 1), padding='same')(shortcut)   ❶
x = BatchNormalization()(x)
x = ReLU()(x)

filters_card = filters_in // cardinality                                   ❷

groups = []                                                                ❸
for i in range(cardinality):                                               ❸
    group = Lambda(lambda z: z[:, :, :, i * filters_card:i *               ❸
                             filters_card + filters_card])(x)              ❸
    groups.append(Conv2D(filters_card, (3, 3), strides=(1, 1),             ❸
                         padding='same')(group))                           ❸

x = Concatenate()(groups)                                                  ❹
x = BatchNormalization()(x)
x = ReLU()(x)

x = Conv2D(filters_out, (1, 1), strides=(1, 1), padding='same')(x)         ❺
x = BatchNormalization()(x)

x = Add()([shortcut, x])                                                   ❻
x = ReLU()(x)
return x

❶ 短路连接是一个用于维度降低的 1 × 1 瓶颈卷积

❷ 通过除以基数(宽度)大小来计算每个组的通道数

❸ 执行分割-变换步骤

❹ 通过连接组卷积的输出执行合并步骤

❺ 1 × 1 线性投影以恢复维度

❻ 将快捷连接添加到块的输出中

注意:在此代码列表中,Lambda()方法执行特征图的分割。序列z[:, :, :, i * filters_card:i * filters_card + filters_card]是一个滑动窗口,它沿着第四维分割输入特征图;第四维是通道 B × H × W × C

6.4.2 ResNeXt 架构

如图 6.25 所示,该架构从输入的茎卷积组开始,包括一个 7×7 的卷积,然后通过最大池化层来减少数据。

图 6.25 展示了 ResNeXt 学习组件在卷积组之间的特征池化

在茎卷积组之后是四个 ResNeXt 块组。每个组相对于输入逐步将输出的滤波器数量翻倍。在每个块之间是一个步长卷积,它有两个作用:

  • 它将数据减少 75%(特征池化)。

  • 它将前一层输出的滤波器数量翻倍,因此当在当前层的输入和输出之间建立恒等连接时,滤波器的数量与矩阵加法操作匹配。

在最终的 ResNeXt 组之后,输出被传递到分类组件。分类器由一个最大池化层和一个展平层组成,展平层将输入展平成一个一维向量,然后将其传递到一个单层密集层进行分类。

在 GitHub 上(mng.bz/my6r)有一个使用 Idiomatic procedure reuse 设计模式为 ResNeXt 编写的完整代码示例。

6.5 宽残差网络

2016 年由巴黎理工学院的学者们提出的宽残差网络WRN),对宽卷积神经网络采取了另一种方法。研究者们从理论出发,认为随着模型层级的加深,特征复用减少,因此训练时间更长。他们使用残差网络进行了一项研究,并为每个残差块中的滤波器数量(宽度)添加了一个乘数参数。这减少了网络的深度。当他们对这种设计进行测试时,发现只有 16 层的 WRN 就能超越其他 SOTA 架构。

很快,一个名为 DenseNet 的设计将展示另一种处理深层特征复用的方法。与 WRN 类似,DenseNet 基于增加特征复用将导致更强的表示能力和更高的准确性的假设。然而,DenseNet 通过将输入与每个残差块的输出进行特征图拼接来实现复用。

在他们的消融研究中,“Wide Residual Networks”(arxiv.org/pdf/1605.07146.pdf),Sergey Zagoruyko 和 Nikos Komodakis 将他们的加宽原则应用于 ResNet50,他们称之为 WRN-50-2,并发现它优于更深层的 ResNet101。今天的 SOTA 模型采用使用宽层和深层的原则来实现更高的性能、更快的训练和更少的记忆化。

6.5.1 WRN-50-2 架构

该 WRN 模型采用了以下设计考虑:

  1. 使用预激活批量归一化(BN-RE-Conv)以实现更快的训练,就像在 ResNet v2 中一样。

  2. 使用两个 3 × 3 卷积(B(3, 3)),如 ResNet34 中所示,而不是 ResNet50 中更不具表现力的瓶颈残差块(B(1,3,1))。这里的理由是基于瓶颈设计有助于减少参数以增加准确性的事实,随着网络的加深。通过更宽来提高准确性,网络变得更浅,因此可以保留更具表现力的堆叠。

  3. 用 l 表示每个组中卷积层的数量,用k表示乘以过滤器数量的宽度因子。

  4. 将 dropout 操作从顶层(这是惯例)移动到残差块中的每个卷积层之间以及 ReLU 之后。这里的推理是为了扰动批量归一化。

在图 6.26 的宏观架构中,你可以看到这三个原则在起作用,因此每个卷积组将输出特征的数量翻倍。每个卷积都使用预激活批量归一化(设计原则 1)。组内的每个残差块使用 B(3,3)残差块(设计原则 2)。并且元参数k用于每个卷积的过滤器数量宽度乘数(设计原则 3)。未展示的是残差块中的 dropout(设计原则 4)。

图 6.26 在 WRN 宏观架构中,每个卷积组逐渐将输出特征图的数量翻倍。

6.5.2 宽残差块

让我们关注宽残差块,它由各种残差组组成。图 6.27 显示,两个 3 × 3 卷积(B(3,3))的过滤器数量都乘以一个可配置的宽度因子(k)。在 3 × 3 卷积之间是一个用于块级正则化的 dropout 层。否则,宽残差块的设计与 ResNet34 残差块相同。

图 6.27 带有标识快捷方式的宽残差块

这里是一个宽残差块的编码示例:

shortcut = x                                 ❶

x = BatchNormalization()(x)                  ❷
x = ReLU()(x)
x = Conv2D(filters_out, (3, 3), strides=(1, 1), padding='same')(x) 

x = BatchNormalization()(x)                  ❸
x = ReLU()(x)
x = Dropout(rate)(x)                         ❹
x = Conv2D(filters_out, (3, 3), strides=(1, 1), padding='same')(x)

x = Add()([shortcut, x])                     ❺
return x

❶ 记住输入

❷ 第一个 3 × 3 卷积使用预激活批量归一化

❸ 第二个 3 × 3 卷积使用预激活批量归一化

❹ ReLU 之后的 dropout 以扰动批量归一化

❺ 标识链接,将输入添加到块的输出

使用 Idiomatic 过程重用设计模式为 WRN 提供完整代码实现可在 GitHub 上找到 (mng.bz/n2oa).

6.6 超越计算机视觉:结构化数据

让我们看看结构化数据模型中宽度和深度层概念是如何演变的。在 2016 年之前,大多数结构化数据的应用继续使用经典机器学习方法,而不是深度学习。与计算机视觉中使用的非结构化数据不同,结构化数据具有多种输入,包括数值、分类和特征工程。这种输入范围意味着在密集层的层中深入挖掘并不那么有效,以使模型学习输入特征和相应标签之间的非线性关系。

图 6.28 展示了在 2016 年之前将深度学习应用于结构化数据的方法。在这种方法中,所有特征输入都通过一系列密集层进行处理——深入其中,隐藏的密集层本质上就是学习器。最后密集层的输出随后传递到任务组件。任务组件与计算机视觉中的任务组件相当。最后密集层的输出已经是一个一维向量。该向量可能还会进行一些额外的池化操作,然后传递到一个具有对应于任务的激活函数的最终密集层:对于回归,使用线性或 ReLU;对于二分类,使用 sigmoid;对于多分类,使用 softmax。

图片

图 6.28 在 2016 年之前,结构化数据模型的方法使用了深层 DNN。

对于结构化数据,你希望学习记忆和泛化。记忆是学习特征值的共现(协变关系)。泛化是学习在训练数据分布中没有看到但部署时会在数据分布中看到的新特征组合。

我将使用人脸检测来阐述记忆和泛化的区别。在记忆中,网络的某些部分学会锁定特定样本群(例如,特定的眼睛模式或肤色)的模式。一旦锁定,这部分网络将在相似示例上发出极高的置信度信号,但在可比示例上置信度较低。随着越来越多的神经网络被锁定,神经网络退化成决策树。这类似于经典 AI 中专家系统的规则集。如果一个示例与专家编码的模式匹配,它就会被识别。否则,它无法被识别。

例如,假设神经网络锁定在眼睛、肤色、穿孔、眼镜、帽子、头发遮挡和面部毛发等模式上。然后我们提交一张有面部彩绘的孩子的图像,模型无法识别出人脸。然后你可以使用面部彩绘的图像重新训练,但由于锁定,你需要进一步增加模型的参数容量来记忆新的模式。然后还有另一个模式和另一个模式——这就是专家系统的问题所在。

在泛化过程中,冗余的节点簇弱信号识别模式,并在模型中集体作为集成。模型中冗余弱信号簇越多,模型泛化以识别未训练过的模式的可能性就越大。

然后,在 2016 年,谷歌研究发布了宽度和深度网络模型架构及其相应的论文,“推荐系统中的宽度和深度学习”由 Heng-Tze Cheng 等人撰写(arxiv.org/pdf/1606.07792.pdf)。虽然这篇论文是针对改进推荐模型而特定的,但这种模型已被广泛应用于不同结构化数据模型类型。推荐是一个有趣的挑战,因为它同时利用了泛化和记忆化。目标是进行具有高价值转换的利基推荐,这需要泛化,除了记忆化广泛常见的共现之外。宽度和深度架构在一个模型中结合了记忆化和泛化。它本质上是由两个在任务组件中结合的模型组成。

图 6.29 展示了宽度和深度架构。这个架构也是一个多模态架构,因为它接受两种不同类型的两个单独输入。

图 6.29 展示了输入在宽层和深度层之间的分配,以及层的输出结合到任务组件中。

让我们更深入地探讨这个架构。学习组件由两部分组成,一个多层深度神经网络和一个单层宽密集层。宽密集层充当线性回归器并记忆高频共现。深度神经网络学习非线性并推广到低频(稀疏)共现以及训练数据中未出现的共现。宽密集层的输入是基础特征(非交叉特征),这些特征已经过特征预处理,并转换为转换特征(例如,分类特征的独热编码)。它们直接输入到宽密集层,因此没有主干。多层密集神经网络的输入是基础特征和交叉特征。在这种情况下,一个主干组件通过使用编码器将组合特征转换为嵌入。

然后,从宽密集层和多层深度神经网络输出的结果在任务组件中结合,并且可能还会进行额外的池化。任务组件基本上与计算机视觉模型中的相同。宽密集层和多层深度神经网络层一起训练。

摘要

  • 减少深层网络中记忆化暴露的一种方法是使用并行卷积。这允许使用更浅的卷积神经网络来解决过拟合问题。

  • 当卷积设计模式可以被重构为计算成本更低且更小(在参数数量上)的另一种模式时,就会发生表示等价。通过分解,模型在更少的计算需求下保持了相同水平的信息(或特征)提取。这允许模型更小、训练更快,并减少预测的延迟。

  • 在 Inception 设计中引入了将常规卷积重构为计算量更小的空间可分离卷积的概念。Inception 在 ImageNet 数据集上展示了在保持性能目标方面的表示等价性。

  • ResNeXt 在并行分组卷积中引入了分割-变换-合并模式。这种模式在不加深层的情况下提高了先前残差网络的准确性。

  • 将批归一化从 WRN 的后激活阶段移动到前激活阶段的目的是提高模型准确性。前激活批归一化进一步减少了需要加深层的必要性,从而减少了防止过拟合的正则化需求。前激活方法提高了训练速度,使得可以使用略高的学习率来实现可比的收敛。

  • 在 WRN 中添加了一个宽度乘数作为元参数,用于在浅层宽残差网络中寻找宽度,从而得到一个在准确性方面(即准确度)与更深层的残差网络表现相当(或一样好)的模型。

  • 现代用于结构化数据的深度学习模型同时使用宽层和深层;宽层负责记忆,而深层负责泛化。

7 种交替连接模式

本章涵盖

  • 理解更深更宽层的交替连接模式

  • 通过特征图重用、进一步重构卷积和 squeeze-excitation 来提高准确性

  • 使用过程设计模式编码交替连接的模型(DenseNet、Xception、SE-Net)

到目前为止,我们已经研究了具有深层卷积网络的卷积网络和具有宽层卷积网络的卷积网络。特别是,我们看到了相应的连接模式如何在卷积块之间和内部解决梯度消失和爆炸以及过度容量导致的记忆问题。

这些增加深度和宽度层的方法,以及深层层中的正则化(添加噪声以减少过拟合),减少了记忆问题,但当然并没有消除它。因此,研究人员探索了残差卷积块内部和之间的其他连接模式,以进一步减少记忆,而不会显著增加参数数量和计算操作。

在本章中,我们将介绍其中的三种交替连接模式:DenseNet、Xception 和 SE-Net。这些模式都有相似的目标:减少连接组件的计算复杂性。但它们在解决问题的方法上有所不同。让我们首先概述这些差异。然后,我们将在本章的剩余部分查看每种模式的细节。

2017 年,康奈尔大学、清华大学和 Facebook 人工智能研究部门的学者们认为,传统残差块中的残差连接只部分允许深层层使用早期层的特征提取。通过将输入与输出进行矩阵相加,输入的特征信息在向深层层进展的过程中逐渐稀释。作者们提出使用特征图拼接,他们称之为特征重用,来代替矩阵相加。他们的理由是,每个残差块输出的特征图将在所有剩余的(深层)层中重用。为了防止模型参数随着特征图在深层层中累积而爆炸性增长,他们在卷积组之间引入了激进的降维特征图。在他们进行的消融研究中,DenseNet 比之前的残差块网络获得了更好的性能。

在同年,Keras 的创造者 François Chollet 引入了 Xception,它将 Inception v3 模型重新设计成新的流程模式。新的模式由入口、中间和出口组成,与之前的 Inception 设计不同。虽然其他研究人员没有采用这种新的流程模式,但他们确实采用了 Chollet 对正常和可分离卷积进一步重构为深度可分离卷积的改进。这个过程减少了矩阵操作的数量,同时保持了表示等价性(稍后将有更多介绍)。这种重构继续出现在许多 SOTA 模型中——尤其是那些为内存和计算受限设备(如移动设备)设计的模型。

2017 年稍后,中国科学院和牛津大学的研究人员为残差块引入了另一种连接模式,该模式可以集成到传统的残差网络中。SE-Net 的连接模式,正如其名称所示,在残差块的输出和与块输入的矩阵加法操作之间插入了一个微块(称为SE 链接)。这个微块对输出特征图进行了积极的维度缩减,或挤压,随后是维度扩展,或激励。研究人员假设这个挤压-激励步骤会使特征图变得更加通用。他们将 SE 链接插入到 ResNet 和 ResNeXt 中,并在测试中未看到的示例上(保留数据)展示了平均 2%的性能提升。

现在我们有了整体图景,让我们来看看这三种模式如何解决在连接级别降低复杂性的问题。

7.1 DenseNet:密集连接卷积神经网络

DenseNet模型引入了密集连接卷积网络的概念。相应的论文,“Densely Connected Convolutional Networks”,由 Gao Huang 等人撰写(arxiv.org/abs/1608.06993),获得了 2017 年计算机视觉和模式识别会议(CVPR)最佳论文奖。该设计基于每个残差块层输出连接到每个后续残差块层输入的原则。

这扩展了残差块中身份链接的概念(在第四章中介绍)。本节提供了关于宏观架构、组和块组件以及相应设计原则的详细信息。

7.1.1 密集组

在 DenseNet 之前,残差块的输入和输出之间的身份链接是通过矩阵加法组合的。相比之下,在密集块中,残差块的输入被连接到残差块的输出。这种变化引入了特征(映射)重用的概念。

在图 7.1 中,你可以看到残差块和密集残差块之间的连接性差异。在残差块中,输入特征图中的值被加到输出特征图上。虽然这保留了一些块中的信息,但它可以被看作是通过加法操作稀释了。在 DenseNet 残差块版本中,输入特征图被完全保留,因此没有发生稀释。

图片

图 7.1 残差块与密集块对比:密集块使用矩阵连接而不是矩阵加法操作。

将矩阵加法替换为连接的优点:

  • 进一步缓解深层网络中的消失梯度问题

  • 通过较窄的特征图进一步降低计算复杂度(参数)

使用连接,输出(分类器)和特征图之间的距离更短。缩短的距离减少了消失梯度问题,允许构建更深层的网络,从而产生更高的精度。

特征图的复用与矩阵加法的前一操作具有表示等价性,但具有显著更少的过滤器。作者将这种配置称为较窄的层。使用较窄的层,可以减少训练所需的总体参数数量。作者理论认为,特征复用允许模型在更深层的网络中达到更高的精度,而不会暴露于消失梯度或记忆化。

这里有一个比较的例子。假设某层的输出是大小为 28 × 28 × 10 的特征图。经过矩阵加法后,输出仍然是 28 × 28 × 10 的特征图。它们内部的价值是残差块输入和输出的加和,因此没有保留原始值——换句话说,它们已经被合并。在密集块中,输入特征图是连接到残差块输出的,而不是合并,从而保留了恒等连接的原始值。在我们的例子中,输入和输出为 28 × 28 × 10,连接后的输出将是 28 × 28 × 20。继续到下一个块,输出将是 28 × 28 × 40。

以这种方式,每一层的输出都连接到下一层的输入,从而产生了描述这类模型的短语密集连接。图 7.2 展示了密集组中残差块的一般构造和恒等连接。

图片

图 7.2 在这个密集的组微架构中,残差块输出和输入(恒等连接)之间使用矩阵连接操作。

如您所见,一个密集组由多个密集块组成。每个密集块由一个残差块(没有身份链接)和从输入到残差块再到输出的身份链接组成。然后,输入和输出特征图被连接成一个单一的输出,这成为下一个密集块的输入。这样,每个密集块输出的特征图都会被后续的每个密集块重用(共享)。

DenseNet 研究人员引入了一个元参数k,它指定了每个卷积组中的滤波器数量。他们尝试了k = 12, 24, 和 32。对于 ImageNet,他们使用了k = 32,并设置了四个密集组。他们发现,他们可以用一半的参数得到与 ResNet 网络相当的结果。例如,他们训练了一个与 ResNet50 相当参数的 DenseNet,参数数量为 2000 万,并得到了与更深层的 ResNet101 相当的结果,参数数量为 4000 万。

以下代码是一个密集组的示例实现。密集残差块的数量由参数n_blocks指定,输出滤波器的数量由n_filters指定,压缩因子由compression指定。对于最后一组,由于缺少过渡块(我们将在下一节中讨论),将参数compression设置为None来表示:

def group(x, n_blocks, n_filters, compression=None):
    """ Construct a Dense Group
        x           : input to the group
        n_blocks    : number of residual blocks in dense block
        n_filters   : number of filters in convolution layer in residual block
        compression : amount to reduce feature maps by
    """
    for _ in range(n_blocks):            ❶
        x = dense_block(x, n_filters)

    if compression is not None:          ❷
        x = trans_block(x, reduction)
    return x

❶ 构建一组密集连接的残差块

❷ 构建中间过渡块

让我们再次讨论为什么 DenseNet 和其他 SOTA 模型在任务组件(例如,分类器)之前没有对特征图进行最终池化。这些模型在块内进行特征提取,并在组末进行特征汇总,我们称这个过程为特征学习。每个组总结它所学习的特征,以减少后续组对特征图进一步处理的计算复杂度。最后一组(非池化)的特征图在大小上进行了优化,以表示在潜在空间中的高维编码。在此提醒,在多任务模型中,例如在目标检测中,潜在空间是在任务之间(或模型融合的情况下,在模型接口之间)共享的。

一旦最终特征图进入任务组件,它们将进行最后一次池化——但这次池化的方式是为了学习任务而不是特征汇总。在任务组件中的这个最后池化步骤是瓶颈层,输出被称为潜在空间的低维嵌入,这也可能与其他任务和模型共享。

DenseNet 架构有四个密集组,每个组都由可配置数量的密集块组成。现在让我们来看看密集块的结构和设计。

7.1.2 密集块

DenseNet 中的残差块使用 B(1, 3)模式,这是一个 1×1 卷积后跟一个 3×3 卷积。然而,1×1 卷积是一个线性投影而不是瓶颈:1×1 通过 4 倍的扩展因子扩展了输出特征图(滤波器)的数目。然后 3×3 执行维度缩减,将输出特征图的数目恢复到与输入特征图相同的数目。

图 7.3 展示了残差密集块中特征图的空间扩展和缩减。请注意,输入和输出特征图的数目和大小保持不变。在块内部,1×1 线性投影扩展了特征图的数目,而随后的 3×3 卷积则同时进行特征提取和特征图缩减。正是这个最后的卷积将输出特征图的数目和大小恢复到与输入相同——这个过程被称为维度恢复**。

图像

图 7.3 在残差密集块的卷积层中进行维度扩展和缩减时,输入和输出特征图的数目和大小保持不变。

图 7.4 说明了残差密集块,它由以下部分组成:

  • 一个将特征图数目增加四倍的 1×1 线性投影卷积

  • 一个既执行特征提取又恢复特征图数目的 3×3 卷积

  • 一个将残差块的输入特征图和输出特征图连接的操作

图像

图 7.4 使用连接操作进行特征重用的具有恒等快捷方式的残差密集块

DenseNet 还采用了现代惯例,使用前激活批量归一化(BN-ReLU-Conv)来提高准确度。在后激活中,ReLU 激活和批量归一化发生在卷积之后。在前激活中,批量归一化和 ReLU 发生在卷积之前。

前人研究者发现,通过从后激活转换为前激活,模型在准确度上提高了 0.5 到 2 个百分点。(例如,ResNet v2 研究者,如“深度残差网络中的恒等映射”[arxiv.org/abs/1603.05027]中所述。)

以下代码是一个密集残差块的示例实现,它包括以下步骤:

  1. 在变量shortcut中保存输入特征图的副本。

  2. 一个将特征图数目增加四倍的前激活 1×1 线性投影

  3. 一个用于特征提取和恢复特征图数目的前激活 3×3 卷积

  4. 将保存的输入特征图与输出特征图进行连接,以实现特征重用

shortcut = x                                                       ❶
x = BatchNormalization()(x)                                        ❷
x = ReLU()(x)                                                      ❷
x = Conv2D(4 * n_filters, (1, 1), strides=(1, 1))(x)               ❷

x = BatchNormalization()(x)                                        ❸
x = ReLU()(x)                                                      ❸
x = Conv2D(n_filters, (3, 3), strides=(1, 1), padding='same')(x)   ❸

x = Concatenate()([shortcut, x])                                   ❹

❶ 记录输入

❷ 维度扩展,通过 4 倍扩展滤波器(DenseNet-B)

❸ 使用 padding='same'填充的 3×3 瓶颈卷积以保持特征图形状

❹ 将输入(恒等变换)与残差块的输出连接起来,其中连接在层之间提供了特征重用。

7.1.3 DenseNet 宏观架构

在学习组件中,在每个密集组之间插入一个过渡块以进一步减少计算复杂度。过渡块是一个步长卷积,也称为特征池化,用于在从一个密集组移动到下一个密集组时减少连接特征图的整体大小(特征重用)。如果没有这种减少,特征图的整体大小会随着每个密集块的逐步增加而逐渐翻倍,这将导致训练参数数量的爆炸性增长。通过减少参数数量,DenseNet 可以在参数数量仅线性增加的情况下更深入地扩展层。

在我们查看过渡块的架构之前,让我们首先看看它在学习组件中的位置。如图 7.5 所示,学习组件由四个密集组组成,过渡块位于每个密集组之间。

图 7.5 展示了密集组之间的过渡块的大规模 DenseNet 架构

现在,让我们近距离观察每个密集组之间的过渡块。

7.1.4 密集过渡块

过渡块由两个步骤组成:

  • 一个 1 × 1 瓶颈卷积,通过压缩因子C减少了输出特征图(通道)的数量。

  • 在瓶颈之后跟随的步长平均池化,将每个特征图的大小减少 75%。当我们说步长时,我们通常指的是步长为 2。步长为 2 将特征图的高度和宽度维度减少一半,这将像素数量减少四分之一(25%)。

图 7.6 描述了此过程。在这里,过滤器/C代表 1 × 1 瓶颈卷积中减少特征图数量的特征图压缩。随后的平均池化是步长的,它减少了减少数量后的特征图大小

图 7.6 在密集过渡块中,特征图维度通过 1 × 1 瓶颈卷积和步长平均池化层同时减少。

现在,这种压缩是如何实际工作的呢?如图 7.7 所示,我们开始时有八个特征图,每个大小为H × W,总共可以表示为H × W × 8。1 × 1 瓶颈卷积中的压缩比是 2。因此,瓶颈将输入并输出一半数量的特征图,在这个例子中是 4。我们可以将其表示为H × W × 4。然后步长平均池化将 4 个特征图的维度减少一半,最终输出大小为 0.5H × 0.5W × 4。

图 7.7 展示了过渡块中特征图(压缩)减少的过程

要压缩特征图的数量,我们需要知道进入过渡块的特征图数量(通道数)。在下面的代码示例中,这是通过 x.shape[-1] 获得的。我们使用索引-1 来引用输入张量(B, H, W, C)的最后一个维度,这是通道数。输入张量中的特征图数量然后乘以compression因子(范围从 0 到 1)。请注意,在 Python 中,乘法操作以浮点值执行,因此我们将结果转换回整数:

n_filters = int(x.shape[-1]) * compression )           ❶
x = BatchNormalization()(x)
x = Conv2D(n_filters, (1, 1), strides=(1, 1))(x)       ❷
x = AveragePooling2D((2, 2), strides=(2, 2))(x)        ❸

❶ 计算特征图数量(DenseNet-C)的减少(压缩)

❷ 使用 BN-LI-Conv 形式的批量归一化进行 1 × 1 瓶颈卷积

❸ 在池化时使用平均值(平均)来减少 75%

在 GitHub 上提供了使用 Idiomatic procedure reuse 设计模式为 DenseNet 编写的完整代码示例 (mng.bz/6N0o).

7.2 Xception:极端 Inception

如前所述,Xception (极端 Inception) 架构是由 Keras 的创造者 François Chollet 于 2017 年在谷歌提出的,作为对 Inception v3 架构的进一步改进建议。在他的论文“Xception: Deep Learning with Depthwise Separable Convolutions” (arxiv.org/pdf/1610.02357.pdf) 中,Chollet 认为 Inception 风格模块的成功基于一个将空间相关性从通道相关性中大量解耦的因子化。这种解耦导致参数数量减少,同时仍然保持表示能力。他提出,我们可以通过完全解耦空间和通道相关性来进一步减少参数,同时保持表示能力。如果你觉得这些关于解耦的想法有点复杂,你会在 7.2.5 节中找到一个更详细的解释。

Chollet 在他的论文中做出了另一个重要的声明:他声称他对 Xception 架构的重设计实际上比 Inception 架构更简单,并且可以使用像 Keras 这样的高级库仅用 30 到 40 行代码来实现。

Chollet 的结论基于比较 Inception v3 和 Xception 在 ImageNet 和谷歌的内部 Joint Foto Tree (JFT)数据集上的准确性的实验。他在两个模型中使用了相同数量的参数,因此他认为任何准确性的提高都是由于更有效地使用参数。JFT 数据集包含 3.5 亿张图片和 17,000 个类别;Xception 在 JFT 数据集上比 Inception 高出 4.3%。在他的 ImageNet 实验中,该数据集包含 1.2 百万张图片和 1000 个类别,准确性的差异可以忽略不计。

从 Inception v3 到 Xception 有两个主要变化:

  • 将 Inception 架构中使用的三个 Inception 风格的残差组(A、B 和 C)重新组织为入口、中间和出口流。在这种新方法下,主干组成为入口的一部分,分类器成为出口的一部分,这降低了 Inception 风格残差块的结构复杂性。

  • 在 Inception v3 块中将卷积分解为空间可分离卷积的操作被替换为深度可分离卷积,这减少了 83%的矩阵乘法操作。

与 Inception v3 一样,Xception 使用后激活批量归一化(Conv-BN-ReLU)。

让我们看一下整体宏架构,然后看看重新设计组件(入口、出口和中间流)的细节。在本节末尾,我们将回到起点,我会解释空间卷积分解为深度可分离卷积的过程。

7.2.1 Xception 架构

Chollet 将传统的主干-学习者-分类器排列重新组合为入口流、中间流和出口流。您可以在图 7.8 中看到这一点,该图显示了重新组合并回溯到过程重用设计模式的 Xception 架构。入口和中间代表特征学习,出口流代表分类学习。

虽然我已经多次阅读了 Chollet 的论文,但我找不到描述架构为具有入口、中间和出口流的理由。我认为直接将这些残差组在学习者组件中的三种风格称为 A、B 和 C 会更清晰。论文似乎暗示他的决定是为了简化他所说的 Inception 的复杂架构。他希望这种简化使得架构可以用 30 到 40 行高级库(如 Keras 或 TensorFlow-Slim)的代码实现,同时保持参数数量相当。无论如何,后续的研究者并没有采用 Chollet 关于入口、中间和出口流的术语。

图片

图 7.8 Xception 宏架构将主要组件重新组合为入口、中间和出口流。以下是它们如何适应主干、学习者和任务组件。

如您所见,主干组件被纳入入口流,分类器组件被纳入出口流。从入口到出口流的残差卷积组共同构成了学习者组件的等效部分。

Xception 架构的骨架实现显示了代码是如何分为输入流程、中间流程和输出流程部分的。输入流程进一步细分为主干和主体,输出流程进一步细分为分类器和主体。这些部分在代码模板中用三个顶级函数表示:entryFlow()middleFlow()exitFlow()entryFlow()函数包含嵌套的stem()函数,表示主干包含在输入流程中,而exitFlow()包含嵌套的函数classifier(),表示分类器包含在输出流程中。

为了简洁,省略了函数体细节。使用 Idiomatic procedure reuse 设计模式为 Xception 提供的完整代码版本可在 GitHub 上找到 (mng.bz/5WzB)。

def entryFlow(inputs):
    """ Create the entry flow section
        inputs : input tensor to neural network
    """
    def stem(inputs):                                         ❶
        """ Create the stem entry into the neural network
            inputs : input tensor to neural network
        """
                                                            ❷
        return x

    x = stem(inputs)                                        ❸

    for n_filters in [128, 256, 728]:                       ❹
        x = projection_block(x, n_filters)

    return x

def middleFlow(x):
    """ Create the middle flow section
        inputs : input tensor into section
    """
    for _ in range(8):                                      ❺
        x = residual_block(x, 728)
    return x

def exitFlow(x, n_classes):
    """ Create the exit flow section
        x         : input to the exit flow section
        n_classes : number of output classes
    """
    def classifier(x, n_classes):                           ❻
        """ The output classifier
            x         : input to the classifier
            n_classes : number of output classes
        """
                                                            ❼
        return x
                                                            ❼

    x = classifier(x, n_classes)                            ❽
    return x

inputs = Input(shape=(299, 299, 3))                         ❾

x = entryFlow(inputs)                                       ❿

x = middleFlow(x)                                           ⓫

outputs = exitFlow(x, 1000)                                 ⓬

model = Model(inputs, outputs)

❶ 主干组件是输入流程的一部分。

❷ 为了简洁,代码已被删除。

❸ 主干组件是输入流程的一部分。

❹ 使用线性投影构建三个残差块。

❺ 中间流程构建 8 个相同的残差块。

❻ 分类器组件是输出流程的一部分。

❼ 为了简洁,代码已被删除。

❽ 中间流程构建 8 个相同的残差块。

❾ 创建形状为(229, 229, 3)的输入向量。

❿ 构建输入流程。

⓫ 构建中间流程。

⓬ 构建用于 1000 个类别的输出流程。

7.2.2 Xception 的输入流程

输入流程组件包括主干卷积组,随后是三个 Xception 输入流程风格的残差块,依次输出 128、256 和 728 个特征图。图 7.9 显示了输入流程以及主干组如何作为子组件嵌入其中。

图 7.9 Xception 输入流程的微架构。

主干部分由两个 3 × 3 卷积层的堆叠组成,如图 7.10 所示。第二个 3 × 3 卷积层将输出特征图的数量加倍(维度扩展),其中一个卷积层采用步进以进行特征池化(维度减少)。对于 Xception,堆叠中的滤波器数量分别为 32 和 64,这是一个常见的约定。堆叠中的第一个卷积层,采用步进,原本是为了减少堆叠中第二个 3 × 3 卷积层的参数数量。另一种约定是在第二个 3 × 3 卷积层,采用步进进行特征汇总,并放弃了参数减少。

图 7.10 Xception 主干组的层构建,用于两个 3 × 3 卷积层的堆叠。

接下来是入口流式残差块,如图 7.11 所示。入口流式使用一个 B(3, 3)残差块,随后在身份链路上进行最大池化和 1 × 1 线性投影。3 × 3 卷积是深度可分离卷积(SeparableConv2D),与 Inception v3 不同,后者使用了正常和空间可分离卷积的组合。最大池化使用 3 × 3 的池化大小,因此从 9 像素窗口中输出最大值(与 2 × 2 的 4 像素相比)。请注意,1 × 1 线性投影也是步进的,以减少特征图的大小,以匹配最大池化层从残差路径中减少的特征图大小。

图片

图 7.11 带线性投影快捷方式的 Xception 残差块

现在让我们看看一个入口流式残差块的示例实现。以下代码表示:

  1. 一个 1 × 1 线性投影来增加特征图的数量并减小大小以匹配残差路径的输出(shortcut

  2. 两个 3 × 3 深度可分离卷积

  3. 线性投影链路(shortcut)的特征图与残差路径输出的矩阵加法操作

def projection_block(x, n_filters):
    """ Create a residual block using Depthwise Separable Convolutions with  
        Projection shortcut
        x        : input into residual block
        n_filters: number of filters
    """

    shortcut = Conv2D(n_filters, (1, 1), strides=(2, 2), padding='same')
               (x)                                                 ❶
    shortcut = BatchNormalization()(shortcut)                      ❶

    x = SeparableConv2D(n_filters, (3, 3), padding='same')(x)      ❷
    x = BatchNormalization()(x)
    x = ReLU()(x)

    x = SeparableConv2D(n_filters, (3, 3), padding='same')(x)      ❸
    x = BatchNormalization()(x)
    x = ReLU()(x)

    x = MaxPooling2D((3, 3), strides=(2, 2), padding='same')(x)    ❹

    x = Add()([x, shortcut])                                       ❺
    return x

❶ 投影快捷方式使用步进卷积来减小特征图的大小,同时将滤波器数量加倍以匹配块的输出,以便进行矩阵加法操作。

❷ 首个深度可分离卷积

❸ 第二个深度可分离卷积

❹ 将特征图的大小减少 75%

❺ 将投影快捷方式添加到块的输出

7.2.3 Xception 的中间流

中间流由八个中间流式残差块组成,每个块输出 728 个特征图。在组内保持输入/输出特征图数量相同是惯例;而在组之间,特征图的数量逐渐增加。相比之下,Xception 中入口和中间流的输出特征图数量保持不变,而不是增加。

中间流式残差块,如图 7.12 所示,使用八个 B(3, 3, 3)残差块。与入口流残差块不同,在所有块之间输入和输出特征图的数量保持相同,因此没有池化操作,也没有在身份链路上进行 1 × 1 步进线性投影。

图片

图 7.12 Xception 中间流微架构由八个相同的残差块组成。

现在让我们看看每个残差块中发生了什么。图 7.13 显示了三个 3 × 3 卷积,它们是深度可分离卷积(SeparableConv2D)。(我们很快就会了解到深度可分离卷积究竟是什么。)

图片

图 7.13 带身份快捷方式的残差块中间流:矩阵加法操作中输入特征图和残差路径的数量和大小相同。

以下代码是中间流样式残差块的示例实现,其中 B(3, 3, 3)样式使用深度可分离卷积(SeparableConv2D)实现:

def residual_block(x, n_filters):
    """ Create a residual block using Depthwise Separable Convolutions
        x        : input into residual block
        n_filters: number of filters
    """

    shortcut = x

    x = SeparableConv2D(n_filters, (3, 3), padding='same')(x)     ❶
    x = BatchNormalization()(x)
    x = ReLU()(x)

    x = SeparableConv2D(n_filters, (3, 3), padding='same')(x)     ❶
    x = BatchNormalization()(x)
    x = ReLU()(x)

    x = SeparableConv2D(n_filters, (3, 3), padding='same')(x)     ❶
    x = BatchNormalization()(x)
    x = ReLU()(x)

    x = Add()([x, shortcut])                                      ❷
    return x

❶ 三个 3 × 3 深度可分离卷积的序列

❷ 将恒等连接添加到块的输出

7.2.4 Xception 的出口流

现在来看出口流。它由一个出口流样式的残差块组成,后面跟着一个卷积块(非残差块),然后是分类器。如图 7.14 所示,分类器组是出口流的子组件。

图 7.14 Xception 出口流逐步增加特征图的数量。

出口流以 728 个特征图和中间流的输出作为输入,并在分类器之前逐步增加特征图的数量到 2048。与 Inception v3 和 ResNet 等大型 CNN 的常规做法相比,这些 CNN 在瓶颈层之前生成 2048 个最终特征图,形成了所谓的高维编码

让我们仔细看看那个单独的出口流样式残差块,如图 7.15 所示。这个残差块是一个 B(3,3),两个卷积分别输出 728 和 1024 个特征图。两个卷积后面跟着一个 3 × 3 的最大池化,然后是一个 1 × 1 的线性投影用于恒等连接。与中间流相比,出口流块增加了特征图的数量,并在出口流中的单个残差块和卷积块之间延迟了池化。

图 7.15 Xception 出口流残差块带有线性投影快捷连接延迟了最终特征图数量增加和池化的进程。

注意,出口流残差块结构与入口流相同,除了出口流样式在块中进行维度扩展,从 728 个特征图扩展到 1024 个特征图,而入口流不进行任何维度扩展。

现在来看出口流卷积块,它位于残差块之后,如图 7.16 所示。此块由两个 3 × 3 深度可分离卷积组成,每个卷积都进行维度扩展。这种扩展将特征图的数量增加到 1156 和 2048,分别,这完成了在瓶颈层之前增加最终特征图数量的延迟增长。

图 7.16 Xception 出口流卷积块完成了最终特征图数量的延迟增长。

出口流的最后一个组是分类器,由一个GlobalAveragePooling2D层组成,该层将最终特征图池化和展平成一个 1D 向量,然后是一个具有 softmax 激活的分类Dense层。

7.2.5 深度可分离卷积

如承诺的那样,我们终于要深入探讨 Xception 架构中的深度可分离卷积了。自从它们被引入以来,深度可分离卷积在卷积神经网络中得到了广泛的应用,因为它们能够在保持表示能力的同时降低计算成本。深度可分离卷积最初由 Laurent Sifre 和 Stéphane Mallat 于 2014 年在 Google Brain 工作期间提出(参见arxiv.org/abs/1403.1687),自那时起,深度可分离卷积在各种 SOTA 模型中得到了研究和应用,包括 Xception、MobileNet 和 ShuffleNet。

简而言之,深度可分离卷积将一个 2D 核分解为两个 2D 核;第一个是深度卷积,第二个是点卷积。为了完全理解这一点,我们首先需要了解两个相关概念:深度卷积和点卷积,深度卷积就是由这两个概念构建的。

7.2.6 深度卷积

深度卷积中,核被分割成一个单独的H × W × 1 核,每个通道一个,每个核只对一个通道进行操作,而不是对所有通道进行操作。在这种安排中,跨通道关系与空间关系解耦。正如 Chollet 所建议的,完全解耦空间和通道卷积会导致更少的 matmul 操作,并且精度与没有解耦和正常卷积的模型相当,以及与部分解耦和空间可分离卷积的模型相当。

因此,在图 7.17 中展示的 RGB 示例中,使用 3 × 3 核,深度卷积将会有三个 3 × 3 × 1 的核。当核移动时,乘法操作的次数与正常卷积相同(例如,在三个通道上为 27)。然而,输出是一个 D 深度的特征图,而不是一个 2D(depth=1)的特征图。

图片

图 7.17 在这个深度卷积中,核被分割成单个H × W × 1 核。

7.2.7 点卷积

深度卷积的输出随后作为点卷积的输入,形成深度可分离卷积。点卷积执行解耦的空间卷积。点卷积结合深度卷积的输出,并将特征图的数量扩展到匹配指定的过滤器(特征图)数量。组合输出与正常或可分离卷积(89)相同数量的特征图,但矩阵乘法操作更少(减少了 83%)。

点卷积,如图 7.18 所示,具有 1 × 1 × D(通道数)。它将遍历每个像素,生成一个N × M × 1 的特征图,这取代了N × M × D的特征图。

图片

图 7.18 点卷积

在点卷积中,我们使用 1 × 1 × D 核,每个输出一个。正如图 7.17 中的前一个例子一样,如果我们的输出是 256 个滤波器(特征图),我们将使用 256 个 1 × 1 × D 核。

在图 7.17 中使用的 RGB 示例中,深度卷积使用了 3 × 3 × 3 核,每次核移动时都有 27 次乘法操作。这将随后是一个 1 × 1 × 3 × 256(其中 256 是输出滤波器的数量)——这是 768。总的乘法操作数将是 795,而不是正常卷积的 6912 次和空间可分离卷积的 4608 次。

在 Xception 架构中,inception 模块中的空间可分离卷积被深度可分离卷积所取代,通过 83%的计算复杂度(乘法操作数)减少。使用 Idiomatic procedure reuse 设计模式对 Xception 的完整代码实现可在 GitHub 上找到(mng.bz/5WzB)。

7.3 SE-Net:挤压和激励

现在我们将转向另一种替代连接设计,即squeeze-excitation-scale patternSE-Net,它可以通过仅添加少量参数来添加到现有的残差网络中以提高准确率。

在“Squeeze-and-Excitation Networks”中引入了这种模式(arxiv.org/abs/1709.01507),Jie Hu 等人解释说,之前对模型的改进主要集中在卷积层之间的空间关系上。因此,他们决定采取不同的方法,并研究基于通道之间关系的新网络设计。他们的想法是,特征重校准可以使用全局信息来选择性地强调重要特征并降低不太重要的特征的重要性。

为了实现选择性强调特征的能力,作者提出了在残差块内添加一个squeeze-excitation (SE) 链接的概念。这个块将位于卷积层(或多个层)的输出和与恒等链接的矩阵加法操作之间。这个概念赢得了 2017 年 ILSVRC 竞赛的 ImageNet。

他们的消融研究指出了 SE-Net 方法的一些好处,包括以下这些:

  • 可以添加到现有的 SOTA 架构中,例如 ResNet、ResNeXt 和 Inception。

  • 在实现更高准确率的同时,参数增加最小。例如:

    • ResNet50 的 ImageNet top-5 错误率为 7.48%,SE-ResNet50 为 6.62%

    • ResNeXt50 的 ImageNet top-5 错误率为 5.9%,SE-ResNeXt50 为 5.49%

    • Inception 的 ImageNet top-5 错误率为 7.89%,SE-Inception 为 7.14%

7.3.1 SE-Net 的架构

SE-Net 架构,如图 7.19 所示,由一个现有的残差网络组成,然后通过在残差块中插入 SE 链接进行改造。改造后的 ResNet 和 ResNeXt 架构分别称为SE-ResNetSE-ResNeXt

图 7.19 SE-Net 宏架构展示了将 SE-链接添加到每个残差块

7.3.2 SE-Net 的组和块

如果我们分解宏观架构,我们会发现图 7.19 中的每个卷积组都由一个或多个残差块组成,构成一个残差组。每个残差块都有一个 SE 链接。这种对残差组的近距离观察在图 7.20 中展示。

图片

图 7.20 在残差组中,每个残差块都插入了一个 SE 链接。

现在,让我们分解残差组。图 7.21 展示了 SE 链接是如何插入到残差块中,位于卷积层(s)输出和矩阵加法操作之间。

图片

图 7.21 残差块展示了 SE 链接是如何插入到残差路径和矩阵加法操作(标记为 Add)之间的(对于身份链接)

以下代码是向 ResNet 残差块添加 SE 链接的示例实现。在块的末尾,在 B(3,3) 输出和矩阵加法操作(Add())之间插入对 squeeze_excite_link() 的调用。在 squeeze_excite_link() 函数中,我们实现了 SE 链接(在下一小节中详细介绍)。

参数 ratio 是在激励操作之前对输入进行挤压操作的维度降低量(比率)。

def identity_block(x, n_filters, ratio=16):
    """ Create a Bottleneck Residual Block with Identity Link
        x        : input into the block
        n_filters: number of filters
        ratio    : amount of filter reduction during squeeze
    """
    shortcut = x

    x = Conv2D(n_filters, (1, 1), strides=(1, 1))(x)                    ❶
    x = BatchNormalization()(x)
    x = ReLU()(x)

    x = Conv2D(n_filters, (3, 3), strides=(1, 1), padding="same")(x)    ❷
    x = BatchNormalization()(x)
    x = ReLU()(x)

    x = Conv2D(n_filters * 4, (1, 1), strides=(1, 1))(x)                ❸
    x = BatchNormalization()(x)

    x = squeeze_excite_link(x, ratio)                                   ❹

    x = Add()([shortcut, x])                                            ❺
    x = ReLU()(x)
    return x

❶ 用于维度降低的 1 × 1 卷积

❷ 瓶颈层使用 3 × 3 卷积

❸ 1 × 1 卷积通过增加过滤器数量 4 倍来实现维度恢复

❹ 将输出通过挤压-激励链接

❺ 将身份链接(输入)添加到残差块的输出

7.3.3 SE 链接

现在,让我们详细探讨 SE 链接(图 7.22)。该链接由三个层组成。前两层执行挤压操作。使用全局平均池化将每个输入特征图(通道)减少到一个单一值,输出一个大小为 C(通道)的 1D 向量,然后将其重塑为一个大小为 C(通道)的 1-×-1 像素的 2D 矩阵。密集层随后通过减少比率 r 进一步减少输出,结果是一个大小为 C / r(通道)的 1-×-1 像素的 2D 矩阵。

图片

图 7.22 挤压-激励块展示了挤压、激励然后缩放操作

挤压后的输出随后传递到第三层,该层通过恢复到链接输入的通道数(C)来进行激励。请注意,这与使用 1 × 1 线性投影卷积相当,但这里使用的是密集层。

最后一步是一个缩放操作,它由一个从输入的身份链接组成,其中来自挤压-激励操作的 1 × 1 × C 向量与输入(H × W × C)进行矩阵乘法。缩放操作之后,输出维度(特征图的数量和大小)恢复到输入的原始维度(缩放)。

现在让我们看看 SE 链接的一个示例实现,包括挤压、激励和缩放操作。注意在GlobalAveragePooling2D之后的Reshape操作,将池化的 1D 向量转换为 1-×-1 像素的 2D 向量,以便后续的两个Dense层执行挤压和激励操作。激励产生的 1 × 1 × C矩阵随后与输入(shortcut)进行矩阵乘法,以进行缩放操作:

def squeeze_excite_link(x, ratio=16):
    """ Create a Squeeze and Excite link
        x    : input to the link
        ratio : amount of filter reduction during squeeze
    """
    shortcut = x
    n_filters = x.shape[-1]                                  ❶
    x = GlobalAveragePooling2D()(x)                          ❷
    x = Reshape((1, 1, n_filters))(x)                        ❸
    x = Dense(n_filters // ratio, activation='relu')(x)      ❹
    x = Dense(n_filters, activation='sigmoid')(x)            ❺
    x = Multiply()([shortcut, x])                            ❻
    return x

❶ 获取 SE 链接输入中的特征图(滤波器)数量

❷ 使用全局平均池化进行维度减少的挤压操作,将输出一个 1D 向量

❸ 将输出重塑为 1 × 1 特征图(1 × 1 × C)

❹ 通过减少比例将滤波器数量(1 × 1 × C / r)减少

❺ 通过恢复滤波器数量(1 × 1 × C)进行维度恢复的激励操作

❻ 缩放操作,将挤压/激励输出与输入(W × H × C)相乘

使用 Idiomatic 程序重用设计模式在 GitHub 上提供了一个 SE-Net 的完整代码实现(mng.bz/vea7)。

摘要

  • DenseNet 中的特征重用通过将残差块的输入到输出的特征图拼接代替矩阵加法。这种分类提高了现有 SOTA 模型的准确性。

  • 在 DenseNet 中使用 1 × 1 卷积来学习是针对特定数据集进行上采样和下采样的最佳方式。

  • 在 Xception 中将空间可分离卷积进一步重构为深度可分离卷积,进一步降低计算成本,同时保持表示等价。

  • 在 SE-Net 中添加 squeeze-excite-scale 模式,同时仅增加少量参数,可以提高准确性。

8 个移动卷积神经网络

本章涵盖了

  • 理解移动卷积网络的设计原则和独特要求

  • 检查 MobileNet v1 和 v2、SqueezeNet 和 ShuffleNet 的设计模式

  • 使用过程设计模式对这些模型的编码示例

  • 通过量化模型并在 TensorFlow Lite(TF Lite)中执行它们来使模型更加紧凑

您现在已经学习了几个大型模型的无内存约束关键设计模式。现在让我们转向设计模式,例如来自 Facebook 的流行应用 FaceApp,这些模式针对内存受限设备,如手机和物联网设备进行了优化。

与它们的 PC 或云等价物相比,紧凑模型面临一个特殊挑战:它们需要在显著更少的内存中运行,因此不能从使用过容量以实现高精度中受益。为了适应这些受限的内存大小,模型在推理或预测时需要显著减少参数。紧凑模型的架构依赖于精度和延迟之间的权衡。模型占用的设备内存越多,精度越高,但响应时间延迟越长。

在早期的 SOTA 移动卷积模型中,研究人员找到了通过方法来解决这种权衡,这些方法在大幅减少参数和计算复杂性的同时,保持了最小程度的精度损失。这些方法依赖于对卷积的进一步重构,例如深度可分离卷积(MobileNet)和点卷积组卷积(ShuffleNet)。这些重构技术提供了增加容量以提高精度的手段,否则这些更极端的重构方法会导致精度损失。

本章介绍了两种重构方法,这些方法用于两种不同的模型:MobileNet 和 SqueezeNet。我们还将探讨第三种模型 ShuffleNet 中针对内存受限设备的另一种新颖方法。我们将在本章结束时探讨其他策略,以进一步减少内存占用,例如参数压缩和量化,以使模型更加紧凑。

在我们开始研究这三个模型的细节之前,让我简要比较一下它们处理有限内存的方法。MobileNet 的研究人员探索了通过调整模型以适应各种内存大小和延迟要求,以及这种调整对精度的影响。

SqueezeNet 研究人员提出了一种称为“fire 模块”的块模式,该模式在模型大小减少高达 90%后仍能保持精度。fire 模块使用深度压缩。这种压缩神经网络大小的方法在 Song Han 等人撰写的《深度压缩》一文中被介绍,该文于 2015 年国际学习表示会议(ICLR)上提出(arxiv.org/abs/1510.00149)。

同时,ShuffleNet 研究人员专注于为部署在极低功耗计算设备上的模型(例如,10 到 150 MFLOPs)增加表示能力。他们提出了两种方法:在一个高度分解的组内进行通道洗牌,以及点卷积。

现在,我们可以深入了解每个细节。

8.1 MobileNet v1

MobileNet v1是谷歌在 2017 年推出的一种架构,用于生成更小的网络,可以适应移动和物联网设备,同时保持与较大网络近似的准确性。在 Andrew G. Howard 等人撰写的“MobileNets”一文中(arxiv.org/abs/1704.04861),MobileNet v1 架构用深度可分离卷积替换了常规卷积,以进一步降低计算复杂度。(如您所记得,我们在第七章讨论 Xception 模型时,已经介绍了将常规卷积重构为深度可分离卷积的理论。)让我们看看 MobileNet 如何将这种方法应用于紧凑的模型。

8.1.1 架构

MobileNet v1 架构结合了几个针对受限内存设备的设计原则:

  • 茎卷积组引入了一个额外的参数,称为分辨率乘数,用于更激进地减少输入到学习组件的特征图大小。(这在图 8.1 中标记为A。)

  • 同样,学习组件为学习组件内部的特征图数量增加了一个宽度乘数参数,以实现更激进的减少。

  • 该模型使用深度卷积(如 Xception 中所示)来降低计算复杂度,同时保持表示等价性(C)。

  • 分类组件使用卷积层代替密集层进行最终分类(D)。

你可以在图 8.1 的宏架构中看到这些创新的应用,字母 A、B、C 和 D 标记了相应的特征。

图 8.1 MobileNet v1 宏架构在茎和学习者(A 和 B)中使用了元参数,在学习者中使用了深度卷积(C),在分类器中使用了卷积层而不是密集层(D)。

与我们之前讨论的模型不同,MobileNets 是根据其输入分辨率进行分类的。例如,MobileNet-224 的输入为(224, 224, 3)。卷积组遵循从上一个组加倍滤波器数量的惯例。

让我们先看看两个新的超参数,宽度乘数和分辨率乘数,看看它们如何以及在哪里帮助细化网络。然后我们将逐步介绍茎、学习者和分类组件。

8.1.2 宽度乘数

引入的第一个超参数是宽度乘数α(alpha),它在每一层均匀地细化网络。让我们快速看一下细化网络的优势和劣势。

我们知道,通过减薄,我们正在减少层之间的参数数量,并且指数级减少矩阵乘法操作的次数。例如,使用密集层,如果在减薄之前,一个密集层的输出和相应的输入各有 100 个参数,那么我们将有 10,000 次矩阵乘法(matmul)操作。换句话说,两个完全连接的 100 节点密集层,每通过一个 1D 向量就会进行 100 x 100 次矩阵乘法操作。

现在我们将其减薄一半。即 50 个输出参数和 50 个输入参数。现在我们已经将矩阵乘法操作的次数减少到 2500 次。结果是内存大小减少 50%,计算(延迟)减少 75%。缺点是我们进一步减少了过容量以提高准确性,并将需要探索其他策略来补偿这一点。

在每一层,输入通道的数量是 αM,输出通道的数量是 αN,其中 MN 是未减薄的 MobileNet 的通道数(特征图)。现在让我们看看如何通过减薄网络层来计算参数的减少。α(alpha)的值从 0 到 1,并且通过 α²(参数数量)减少 MobileNet 的计算复杂度。α < 1 的值被称为减薄 MobileNet。通常,这些值是 0.25(6% 的未减薄)、0.50(25%)和 0.75(56%)。让我们继续进行计算。如果 α 因子是 0.25,那么得到的复杂度是 0.25 × 0.25,计算结果是 0.0625。

在论文中报告的测试结果中,未减薄的 MobileNet-224 在 ImageNet 上有 70.6% 的准确性,参数数量为 420 万,矩阵乘加操作为 5.69 亿,而 0.25(宽度乘数)MobileNet-224 有 50.6% 的准确性,参数数量为 50 万,矩阵乘加操作为 4100 万。这些结果表明,通过激进减薄导致的过容量损失并没有被模型设计有效地抵消。因此,研究人员转向减少分辨率,结果证明这更有利于保持准确性。

8.1.3 分辨率乘数

第二个引入的超参数是分辨率乘数 ρ(rho),它减少了输入形状以及每个层的特征图大小。

当我们在不改变主干组件的情况下降低输入分辨率时,进入学习组件的特征图大小相应减少。例如,如果输入图像的高度和宽度减少一半,输入像素的数量将减少 75%。如果我们保持相同的粗略级滤波器和滤波器数量,输出的特征图将减少 75%。由于特征图减少,这将导致每个卷积(模型大小)和矩阵乘法操作(延迟)的数量减少。请注意,这与宽度细化不同,宽度细化会减少特征图的数量,同时保持其大小。

缺点是,如果我们过于激进地减少,当我们到达瓶颈时特征图的大小可能变为 1 × 1 像素,本质上失去了空间关系。我们可以通过减少中间层的数量来补偿这一点,使特征图大于 1 × 1,但这样我们会为了精度而移除更多的冗余。

在论文中报告的测试结果中,一个 0.25(分辨率乘数)的 MobileNet-224 在 4.2 百万个参数和 1.86 亿次矩阵乘加操作下达到了 64.4%的准确率。鉴于ρ(rho)的值在 0 到 1 之间,并且将 MobileNet 的计算复杂度降低到ρ²。如果ρ因子为 0.25,则结果复杂度为 0.25 × 0.25,计算结果为 0.0625。

以下是一个 MobileNet-224 的骨架模板。请注意,使用参数alpharho作为宽度和分辨率乘数:

def stem(inputs, alpha):                             ❶
    """ Construct the stem group
        inputs : input tensor
        alpha  : with multiplier
    """
                                                     ❷
    return outputs

def learner(inputs, alpha):                          ❶
    """ Construct the learner group
        inputs : input to the learner
        alpha  : with multiplier
    """
                                                     ❷
    return outputs

def classifier(inputs, alpha, dropout, n_classes):   ❶
    """ Construct the classifier group
        inputs : input to the classifier
        alpha  : with multiplier
        Dropout: percent of dropout
        n_classes: number of output classes
    """
                                                     ❷
    return outputs

inputs = Input((224*rho, 224*rho, 3))                ❸
outputs = stem(inputs, alpha)
outputs = learner(outputs, alpha)
outputs = classifier(outputs, alpha, dropout, n_classes)
model = Model(inputs, outputs)

❶ 模型中所有层使用的宽度乘数

❷ 为了简洁性移除的代码

❸ 仅在输入张量上使用的分辨率乘数

8.1.4 主干

主干组件由一个步进的 3 × 3 卷积(用于特征池化)和一个 64 个滤波器的单个深度可分离块组成。步进卷积和深度可分离块中的滤波器数量进一步通过超参数α(alpha)减少。通过超参数ρ(rho)减少输入大小不是在模型中完成,而是在输入预处理函数的上游完成。

让我们讨论一下这与当时大型模型的传统主干有何不同。通常,第一个卷积层会从粗略的 7 × 7、5 × 5 或重构的两个 3 × 3 卷积层(64 个滤波器)开始。粗略卷积会进行步进以减少特征图的大小,然后跟随一个最大池化层以进一步减少特征图的大小。

在 MobileNet v1 主干中,继续使用 64 个滤波器和两个 3 × 3 卷积层的传统,但有三个显著的变化:

  • 第一个卷积输出的特征图数量是第二个卷积的一半(32)。这起到瓶颈的作用,在双 3 × 3 堆叠中减少计算复杂度。

  • 第二个卷积被替换为深度可分离卷积,进一步降低了茎中的计算复杂度。

  • 没有最大池化,只有第一个步长卷积导致一个特征图大小的减少。

这里的权衡是保持特征图的大小更大——是H × W的两倍。这抵消了在第一级粗略特征提取中激进减少计算复杂度所带来的表示损失。

图 8.2 展示了茎组件,它由两个 3 × 3 卷积的堆叠组成。第一个是一个正常的卷积,它执行特征池化(步长)。第二个是一个深度卷积,它保持特征图的大小(非步长)。步长 3 × 3 卷积没有使用填充。为了保持特征图减少 75%(0.5H × 0.5W),在卷积之前对输入添加了零填充。注意在输入大小上使用元参数ρ进行分辨率降低,以及在 3 × 3 卷积的双重堆叠上使用α进行网络细化。

图 8.2 展示了 MobileNet 茎组在 3 × 3 卷积堆叠中细化网络。

以下是一个茎组件的实现示例。正如您所看到的,卷积层使用了后激活批量归一化(Conv-BN-RE),因此模型没有使用预激活批量归一化的好处,这被发现可以将准确率从 0.5 提升到 2%:

def stem(inputs, alpha):
    """ Construct the stem group
        inputs : input tensor
        alpha  : with multiplier
    """
     x = ZeroPadding2D(padding=((0, 1), (0, 1)))(inputs)                 ❶
     x = Conv2D(32 * alpha, (3, 3), strides=(2, 2), padding='valid')(x)
     x = BatchNormalization()(x)
     x = ReLU(6.0)(x)

     x = depthwise_block(x, 64, alpha, (1, 1))                           ❷
     return x

❶ 输入特征图零填充的卷积块

❷ 深度可分离卷积块

注意,在这个例子中,ReLU有一个可选参数,其值为 6.0。这是ReLUmax_value参数,默认为None。它的目的是剪辑任何高于max_value的值。因此,在前面的例子中,所有输出都将位于 0 到 6.0 的范围内。在移动网络中,如果权重后来被量化,通常会将ReLU的输出进行剪辑。

在这个上下文中,“量化”是指使用较低位表示的计算;我将在第 8.5.1 节中解释这个过程的细节。研究发现,当ReLU的输出有一个约束范围时,量化模型可以保持更好的准确率。一般做法是将其设置为 6.0。

让我们简要讨论一下选择 6 这个值的理由。这个概念是在 Alex Krizhevsky 2010 年的论文“CIFAR-10 上的卷积深度信念网络”中提出的(www.cs.utoronto.ca/~kriz/conv-cifar10-aug2010.pdf)。Krizhevsky 将其作为解决深层网络梯度爆炸问题的解决方案。

当激活的输出变得非常大时,它可能会主导周围激活的输出。结果,该网络区域会表现出对称性,这意味着它会减少,就像只有一个节点一样。通过实验,Krizhevsky 发现 6 这个值是最好的。

记住,这在我们意识到批归一化的好处之前。批归一化会在每个连续的深度处压缩激活,因此不再需要截断。

在量化引入时截断 ReLU 返回值的概念。简而言之,当权重被量化时,我们正在减少表示值的位数。如果我们将权重映射到,比如说,一个 8 位整数范围,我们必须根据实际输出值的分布将整个输出范围“分桶”到 256 个桶中。范围越长,浮点值映射到桶中的拉伸就越薄,使得每个桶的特征就越不显著。

这里的理论是,那些 98%、99%和 99.5%置信度的值本质上是一样的,而较低值则更加独特——也就是说,输出有 70%的置信度。但是,通过截断,我们将所有高于 6 的值视为本质上 100%,并且仅对 0 到 6 之间的分布进行分桶,这些值对于推理更有意义。

8.1.5 学习者

MobileNet-224 中的学习组件由四个组组成,每个组包含两个或更多的卷积块。每个组将前一个组的过滤器数量翻倍,并且每个组中的第一个块使用步长卷积(特征池化)将特征图大小减少 75%。

构建 MobileNet 组遵循与大型卷积网络组相同的原理。两者通常具有以下特点:

  1. 每组过滤器数量的进展,例如将过滤器数量翻倍

  2. 通过使用步长卷积或延迟最大池化来减少输出的特征图大小

您可以在图 8.3 中看到,MobileNet 组在第一个块中使用步长卷积来减少特征图(原则 2)。尽管图中没有显示,但学习器中的每个组从 128 开始将过滤器数量翻倍(原则 1)。

图 8.3 在 MobileNet v1 的学习组件中,每个组是一系列深度卷积块。

图 8.4 放大了学习组中的深度卷积块。在 v1 中,模型的作者使用了卷积块设计而不是残差块设计;没有恒等连接。每个块本质上是一个单深度可分离卷积,由两个独立的卷积层构建。第一层是一个 3×3 的深度卷积,后面跟着一个 1×1 的点卷积。当结合时,这些形成深度可分离卷积。过滤器的数量,即对应于特征图的数量,可以通过元参数α进一步减少以进行网络细化。

图 8.4 MobileNet v1 卷积块

接下来是一个深度可分离卷积块的示例实现。第一步是计算在应用宽度乘数 alpha 后网络变薄的 filters 数量。对于组中的第一个块,使用步长卷积 (strides=(2, 2)) 对特征图大小进行减少(特征池化)。这对应于之前提到的卷积组设计原则 2,其中组中的第一个块通常对输入特征图的大小进行维度降低。

def depthwise_block(x, n_filters, alpha, strides):
    """ Construct a Depthwise Separable Convolution block
        x         : input to the block
        n_filters : number of filters
        alpha     : width multiplier
        strides   : strides
    """
    filters = int(n_filters * alpha)                                ❶

    if strides == (2, 2):                                           ❷
        x = ZeroPadding2D(padding=((0, 1), (0, 1)))(x)
        padding = 'valid'
    else:
        padding = 'same'

    x = DepthwiseConv2D((3, 3), strides, padding=padding)(x)        ❸
    x = BatchNormalization()(x)
    x = ReLU(6.0)(x)

    x = Conv2D(filters, (1, 1), strides=(1, 1), padding='same')(x)  ❹
    x = BatchNormalization()(x)
    x = ReLU(6.0)(x)
    return x

❶ 将宽度滤波器应用于特征图数量

❷ 在进行步长卷积时添加零填充,以匹配滤波器的数量

❸ 深度卷积

❹ 点卷积

8.1.6 分类器

分类组件与大型模型的传统分类器不同,它在分类步骤中使用卷积层代替密集层。像其他当时的分类器一样,为了防止记忆化,它在分类之前添加了一个 dropout 层进行正则化。

你可以在图 8.5 中看到,分类组件包含一个 GlobalAveragePooling2D 层,用于展平特征图并将高维编码降低到低维编码(每个特征图 1 个像素)。然后使用 softmax 激活函数,其中滤波器的数量是类别的数量,通过一个 Reshape 层将 1D 向量重塑为 2D 向量。然后是另一个 Reshape 层,将输出重塑回 1D 向量(每个类别一个元素)。在 2D 卷积之前是用于正则化的 Dropout 层。

图 8.5 MobileNet v1 分类组使用卷积层进行分类

下面的示例实现是分类组件。第一个 Reshape 层将 GlobalAveragePooling2D 的 1D 向量重塑为大小为 1 × 1 的 2D 向量。第二个 Reshape 层将 Conv2D 的 2D 1 × 1 输出重塑为用于 softmax 概率分布(分类)的 1D 向量:

def classifier(x, alpha, dropout, n_classes):
    """ Construct the classifier group
        x         : input to the classifier
        alpha     : width multiplier
        dropout   : dropout percentage
        n_classes : number of output classes
    """
    x = GlobalAveragePooling2D()(x)                                         ❶

    shape = (1, 1, int(1024 * alpha))                                       ❷
    x = Reshape(shape)(x)

    x = Dropout(dropout)(x)                                                 ❸

    x = Conv2D(n_classes, (1, 1), padding='same', activation='softmax')(x)  ❹

    x = Reshape((n_classes, ))(x)                                           ❺
    return x

❶ 将特征图展平为 1D 特征图 (α, N)

❷ 将展平的特征图重塑为 (α, 1, 1, 1024)

❸ 执行 dropout 以防止过拟合

❹ 使用卷积进行分类(模拟全连接层)

❺ 将结果输出重塑为包含类别数量的 1D 向量

使用 Idiomatic procedure reuse 设计模式对 MobileNet v1 进行完整代码实现的示例位于 GitHub 上 (mng.bz/Q2rG).

8.2 MobileNet v2

在改进版本 1 之后,谷歌在 2018 年 Mark Sandler 等人撰写的“MobileNetV2: Inverted Residuals and Linear Bottlenecks”一文中引入了 MobileNet v2 (arxiv.org/abs/1801.04381)。新的架构用倒残差块替换了卷积块以提高性能。该论文总结了倒残差块的好处:

  • 显著减少操作数量,同时保持与卷积块相同的准确度

  • 显著减少推理所需的内存占用

8.2.1 架构

MobileNet v2 架构结合了几个针对受限内存设备的设计原则:

  • 它继续使用超参数(alpha)作为宽度乘数,如 v1 中所述,在根部和学习者组件中进行网络稀疏化。

  • 继续使用深度可分离卷积代替常规卷积,如 v1 中所述,以显著降低计算复杂度(延迟),同时保持几乎相当的表现力。

  • 用残差块替换卷积块,允许更深的层以获得更高的准确度。

  • 引入了一种新的残差块设计,作者们称之为倒残差块

  • 用 1 × 1 非线性卷积替换 1 × 1 线性卷积。

根据作者的说法,最后修改的原因,使用 1 × 1 线性卷积: “此外,我们发现,为了保持表现力,移除狭窄层中的非线性是很重要的。” 在他们的消融研究中,他们比较了使用 1 × 1 非线性卷积(带有ReLU)和使用 1 × 1 线性卷积(不带ReLU),通过移除 ReLU 在 ImageNet 上获得了 1%的 top-1 准确度提升。

作者们将他们的主要贡献描述为一种新颖的层模块:线性瓶颈的倒残差。我在第 8.2.3 节中详细描述了倒残差块。

图 8.6 展示了 MobileNet v2 架构。在宏观架构中,学习者组件由四个倒残差组组成,随后是一个最终的 1 × 1 线性卷积,这意味着激活函数是线性的。每个倒残差组将前一个组的滤波器数量增加。每个组的滤波器数量通过元参数宽度乘数α(alpha)进行稀疏化。最终的 1 × 1 卷积进行线性投影,将最终的特征图数量增加到四倍,达到 2048。

图 8.6 MobileNet v2 宏观架构

8.2.2 根部

根部组件与 v1 相似,除了在初始 3 × 3 卷积层之后,它不跟随 v1 中的深度卷积块(图 8.7)。因此,粗粒度特征提取的表现力将低于 v1 中的 3 × 3 双栈。作者们没有说明为什么表现力的降低没有影响模型,该模型在准确度上优于 v1。

图 8.7 MobileNet v2 根部组

8.2.3 学习者

学习组件由七个倒置残差组组成,随后是一个 1 × 1 的线性卷积。每个倒置残差组包含两个或更多的倒置残差块。每个组逐渐增加滤波器的数量,也称为输出通道。每个组从步长卷积开始,随着每个组逐渐增加特征图(通道)的数量,减少特征图的大小(通道)。

图 8.8 描述了一个 MobileNet v2 组,其中第一个倒置残差块进行了步长操作以减少特征图的大小,以抵消每个组中特征图数量逐渐增加的趋势。如图表所示,只有第 2、3、4 和 6 组以步长倒置残差块开始。换句话说,第 1、5 和 7 组以非步长残差块开始。此外,每个非步长块都有一个恒等连接,而步长块没有恒等连接。

图片

图 8.8 MobileNet v2 组微架构

以下是一个 MobileNet v2 组的示例实现。该组遵循以下惯例:第一个块执行降维以减少特征图的大小。在这种情况下,第一个倒置块是步长的(特征池化),其余块不是步长的(无特征池化)。

def group(x, n_filters, n_blocks, alpha, expansion=6, strides=(2, 2)):
    """ Construct an Inverted Residual Group
        x         : input to the group
        n_filters : number of filters
        n_blocks  : number of blocks in the group
        alpha     : width multiplier
        expansion : multiplier for expanding the number of filters
        strides   : whether the first inverted residual block is strided.
    """ 
    x = inverted_block(x, n_filters, alpha, expansion, strides=strides)    ❶

    for _ in range(n_blocks - 1):                                          ❷
        x = inverted_block(x, n_filters, alpha, expansion, strides=(1, 1))
    return x

❶ 组中的第一个倒置残差块可能是步长的。

❷ 构建剩余的块

该块被称为倒置残差块,因为它反转(倒置)了围绕中间卷积层的降维和扩展关系,这与传统的残差块不同,例如在 ResNet50 中。它不是从 1 × 1 的瓶颈卷积开始进行降维,以 1 × 1 的线性投影卷积结束以恢复维度,而是顺序相反。一个倒置块从 1 × 1 的投影卷积开始进行维度扩展,并以 1 × 1 的瓶颈卷积结束以恢复维度(图 8.9)。

图片

图 8.9 残差瓶颈块与倒置残差块之间的概念差异

在他们比较 MobileNet v1 中的瓶颈残差块设计与 v2 中的倒置残差块设计的消融研究中,作者在 ImageNet 上实现了 1.4%的 top-1 准确率提升。倒置残差块设计也更加高效,将总参数数量从 420 万减少到 340 万,并将 matmul 操作的数量从 5.75 亿减少到 3 亿。

接下来,我们将更深入地探讨反演背后的机制。MobileNet v2 引入了一种新的元参数扩展,用于初始的 1 × 1 投影卷积。1 × 1 投影卷积执行维度扩展,而元参数指定了扩展滤波器数量的量。换句话说,1 × 1 投影卷积将特征图数量扩展到高维空间。

中间卷积是一个 3 × 3 深度卷积。这随后是一个线性点卷积,它减少了特征图(也称为 通道),将它们恢复到原始数量。请注意,恢复卷积使用线性激活而不是非线性(ReLU)。作者发现,为了保持表示能力,移除狭窄层中的非线性很重要。

作者还发现,ReLU 激活在低维空间中会丢失信息,但当有大量滤波器时可以弥补这一点。这里的假设是,块的输入处于低维空间,但扩展了滤波器的数量,因此保持使用 ReLU 激活在第一个 1 × 1 卷积中的原因。

MobileNet v2 研究人员将扩展量称为块的 表达能力。在他们主要的实验中,他们尝试了 5 到 10 之间的扩展因子,并观察到准确率几乎没有差异。由于扩展的增加会导致参数数量的增加,而准确率的提升却很小,因此作者们在消融研究中使用了 6 的扩展比率。

图 8.10 展示了反演残差块。你可以看到其设计在减少内存占用同时保持准确性的基础上又迈出了新的一步。

图 8.10 带有恒等快捷连接的反演残差块反转了 v1 中 1 × 1 卷积的关系。

下面的示例实现了一个反演残差块。为了理解上下文,请记住,反演残差块的输入是来自先前块或低维空间中的主干组的输出。然后,通过 1 × 1 投影卷积将输入投影到更高维的空间,其中执行 3 × 3 深度卷积。然后,点卷积的 1 × 1 线性卷积将输出恢复到输入的较低维度。

这里有一些显著的步骤:

  • 宽度因子应用于块的输出滤波器数量:filters = int(n_filters * alpha)

  • 输入通道(特征图)的数量由 n_channels = int(x.shape[-1]) 确定。

  • expansion 因子大于 1 时,应用 1 × 1 线性投影。

  • 在第一个组的第一块之外,每个块都执行 Add() 操作:if n_channels == filters and strides == (1, 1)

def inverted_block(x, n_filters, alpha, expansion=6, strides=(1, 1)):
    """ Construct an Inverted Residual Block
        x         : input to the block
        n_filters : number of filters
        alpha     : width multiplier
        expansion : multiplier for expanding number of filters
        strides   : strides
    """
    shortcut = x  # Remember input

    filters = int(n_filters * alpha)                                  ❶

    n_channels = int(x.shape[-1])

    if expansion > 1:                                                 ❷
        # 1x1 linear convolution
        x = Conv2D(expansion * n_channels, (1, 1), padding='same')(x)
        x = BatchNormalization()(x)
        x = ReLU(6.)(x)

    if strides == (2, 2):                                             ❸
        x = ZeroPadding2D(padding=((0, 1), (0, 1)))(x)
        padding = 'valid'
    else:
        padding = 'same'

    x = DepthwiseConv2D((3, 3), strides, padding=padding)(x)          ❹
    x = BatchNormalization()(x)
    x = ReLU(6.)(x)

    x = Conv2D(filters, (1, 1), strides=(1, 1), padding='same')(x)    ❺
    x = BatchNormalization()(x)

    if n_channels == filters and strides == (1, 1):                   ❻
        x = Add()([shortcut, x])
    return x

❶ 将宽度乘数应用于点卷积的特征图数量

❷ 当不是组中的第一个块时,进行维度扩展(dimensionality expansion)

❸ 在步进卷积(特征池化)时向特征图添加零填充(zero padding)

❹ 3 × 3 深度卷积

❺ 1 × 1 线性点卷积

❻ 当输入滤波器数量与输出滤波器数量相匹配时,向输出添加身份链接(identity link)

8.2.4 分类器

在 v2 版本中,研究人员采用了传统的GlobalAveragePooling2D层后跟Dense层的方法,这在第五章第 5.4 节中已经介绍过。早期的卷积神经网络,如 AlexNet、ZFNet 和 VGG,会将瓶颈层(最终特征图)进行扁平化,然后接一个或多个隐藏密集层,最后是用于分类的最终密集层。例如,VGG 在最终密集层之前使用了两个包含 4096 个节点的层。

随着表示学习(representational learning)的改进,从 ResNet 和 Inception 开始,分类器中隐藏层的需求变得不再必要,同样,不需要将数据降维到瓶颈层(bottleneck layer)的扁平化层。MobileNet v2 沿袭了这一做法,当潜在空间(latent space)具有足够的表示信息时,我们可以进一步将其降低到低维空间——瓶颈层。在高表示信息下,模型可以将低维度的数据,也称为嵌入特征向量,直接传递到分类器的密集层,而不需要中间的隐藏密集层。图 8.11 展示了分类器组件。

图片

图 8.11 MobileNet v2 分类器组

在作者的消融研究中,他们比较了 MobileNet v1 和 v2 在 ImageNet 分类任务上的表现。MobileNet v2 实现了 72% 的 top-1 准确率,而 v1 实现了 70.6%。使用 Idiomatic procedure reuse 设计模式对 MobileNet v2 的完整代码实现可在 GitHub 上找到 (mng.bz/Q2rG)。

接下来,我们将介绍 SqueezeNet,它引入了 fire 模块以及用于配置微架构属性的宏观架构和元参数的术语。虽然当时其他研究人员也在探索这个概念,但 SqueezeNet 的作者为这个创新性的里程碑式进展创造了术语,为后来宏观架构搜索、机器设计和模型融合的进步奠定了基础。对我个人而言,当我第一次阅读他们的论文和这些概念时,感觉就像一个灯泡突然亮了起来。

8.3 SqueezeNet

SqueezeNet 是由 DeepScale、加州大学伯克利分校和斯坦福大学于 2016 年共同研究引入的架构。在相应的"SqueezeNet"论文("SqueezeNet: AlexNet-Level Accuracy with 50x Fewer Parameters and <0.5MB Model Size"; arxiv.org/abs/1602.07360)中,Forrest N. Iandola 等人介绍了一种新型模块,即"fire 模块",以及微架构、宏架构和超参数的术语。作者们的目标是找到一个参数更少但与知名 AlexNet 模型具有相当准确性的 CNN 架构。

火模块的设计基于他们对微架构的研究以实现这一目标。"微架构"是模块或组的设计,而"宏架构"则是模块或组之间的连接方式。引入"超参数"这一术语有助于更好地区分什么是超参数(在第十章中详细讨论)。

通常,在训练过程中学习的权重和偏差是模型参数。"超参数"这个术语可能会令人困惑。一些研究人员/实践者使用这个术语来指代用于训练模型的可调参数,而其他人则使用这个术语来包括模型架构(例如,层和宽度)。在 SqueezeNet 论文中,作者们使用"元参数"来指代可配置的模型架构结构——例如,每组中的块数,每个块中卷积层的滤波器数量,以及组末端的维度缩减量。

作者们在他们的论文中解决了几个问题。首先,他们想要展示一种 CNN 架构设计,这种设计可以在移动设备上运行,同时仍然保持与 ImageNet 2012 数据集上 AlexNet 相当的准确性。在这方面,作者们在参数数量减少了 50 倍的情况下,通过经验实证达到了与 AlexNet 相同的结果。

其次,他们想要展示一种小型 CNN 架构,在压缩后仍能保持准确性。在这里,作者们在使用深度压缩算法压缩后,没有压缩的情况下达到了相同的结果,将模型的大小从 4.8 MB 减少到 0.47 MB。将模型大小降低到 0.5 MB 以下,同时保持 AlexNet 的准确性,证明了在极端内存受限的物联网设备(如微控制器)上放置模型的实用性。

在他们的 SqueezeNet 论文中,作者们将实现目标的设计原则称为策略 1、2 和 3:

  • 策略 1——主要使用 1 × 1 滤波器,这比更常见的 3 × 3 滤波器减少了 9 倍的参数数量。SqueezeNet 的 v1.0 版本使用了 1 × 1 到 3 × 3 滤波器的 2:1 比例。

  • 策略 2—减少 3 × 3 层的输入滤波器数量以进一步减少参数数量。他们将火模块的这个部分称为 挤压层

  • 策略 3—尽可能晚地延迟特征图的下采样。这与早期下采样以保持精度的传统做法相反。作者在早期卷积层使用了步长为 1,而延迟使用步长为 2。

作者陈述了他们策略的以下理由:

策略 1 和 2 是关于在尝试保持精度的同时,巧妙地减少 CNN 中的参数数量。策略 3 是关于在有限的参数预算内最大化精度

作者将他们的架构命名为其 fire 块的设计,该设计使用了一个挤压操作后跟一个扩展操作。

8.3.1 架构

SqueezeNet 架构由一个主干组、三个包含总共八个 fire 块(在论文中称为 模块)的 fire 组和一个分类器组组成。作者没有明确说明他们为什么选择三个 fire 组和八个 fire 块,但描述了一种宏观架构探索,该探索展示了一种成本效益高的方法,即通过训练每组中块的数量和输入到输出的滤波器大小的不同组合来设计针对特定内存足迹和精度范围的模型。

图 8.12 展示了架构。在宏观架构视图中,你可以看到三个 fire 组。特征学习在主干组和前两个 fire 组中进行。最后一个 fire 组与分类组重叠,进行特征学习和分类学习。

图 8.12 SqueezeNet 宏观架构

前两个 fire 组将输入到输出的特征图数量翻倍,从 16 开始,翻倍到 32,然后再次翻倍到 64。第一和第二 fire 组都延迟了维度减少到组的末尾。最后一个 fire 组不进行特征图数量的加倍或维度减少,但在组的末尾添加了一个 dropout 以进行正则化。这一步骤与当时的传统做法不同,当时 dropout 层本应放置在瓶颈层(特征图减少并展平为 1D 向量)之后的分类器组中。

8.3.2 主干

主干组件使用了一个粗略级别的 7 × 7 卷积层,这与当时使用 5 × 5 或重构的两个 3 × 3 卷积层的传统做法相反。主干执行了激进的特性图减少,这继续是现在的传统做法。

粗略的 7 × 7 卷积进行了步长(特征池化)以实现 75% 的减少,随后是一个最大池化层以进一步减少 75%,结果得到特征图的大小仅为输入通道的 6%。图 8.13 描述了主干组件。

图 8.13 SqueezeNet 主干组

8.3.3 学习者

学习者由三个火组组成。第一个火组的输入为 16 个滤波器(通道),输出为 32 个滤波器(通道)。回想一下,主干输出 96 个通道,因此第一个火组通过减少到 16 个滤波器对输入进行降维。第二个火组将这个数量加倍,输入为 32 个滤波器(通道),输出为 64 个滤波器(通道)。

第一个和第二个火组都由多个火块组成。除了最后一个火块外,所有火块使用相同数量的输入滤波器。最后一个火块将输出滤波器的数量加倍。两个火组都使用MaxPooling2D层将特征图的下采样延迟到组的末尾。

第三个火组由一个 64 个滤波器的单个火块组成,随后是一个用于正则化的 dropout 层,在分类组之前。这与当时的惯例略有不同,因为 SqueezeNet 的 dropout 层出现在分类器的瓶颈层之前,而不是之后。图 8.14 描述了一个火组。

图 8.14 在 SqueezeNet 组微架构中,最后一个火组使用 dropout 而不是 max pooling。

以下是对第一个和第二个火组的示例实现。请注意,参数filters是一个列表,其中每个元素对应一个火块,其值是该块的滤波器数量。例如,考虑第一个火组,它由三个火块组成;输入为 16 个滤波器,输出为 32 个滤波器。参数filters将是列表[16, 16, 32]。

在为组添加所有火块之后,添加一个MaxPooling2D层以进行延迟下采样:

def group(x, filters):
    ''' Construct a Fire Group
        x     : input to the group
        filters: list of number of filters per fire block (module)
    '''
    for n_filters in filters:                      ❶
        x = fire_block(x, n_filters)

    x = MaxPooling2D((3, 3), strides=(2, 2))(x)    ❷
    return x

❶ 为组添加火块(模块)

❷ 在组的末尾添加延迟下采样

图 8.15 说明了火块,它由两个卷积层组成。第一层是挤压层,第二层是扩展层。挤压层通过使用 1 × 1 瓶颈卷积将输入通道的数量减少到更低的维度,同时保持足够的信息供扩展层中的后续卷积使用。挤压操作显著减少了参数数量和相应的矩阵乘法操作。换句话说,1 × 1 瓶颈卷积学习最大化挤压特征图数量到更少的特征图的最佳方式,同时仍然能够在后续的扩展中进行特征提取。

图 8.15 SqueezeNet 火块

扩展层是两个卷积的分支:一个 1 × 1 的线性投影卷积和一个 3 × 3 卷积,特征提取发生在其中。卷积的输出(feature maps)随后被连接。扩展层通过 8 倍因子扩展了 feature maps 的数量。

让我们举一个例子。来自主干的第一个 fire 块的输入是 96 个特征图(通道),squeeze 层将其减少到 16 个特征图。然后扩展层将其扩展 8 倍,因此输出再次是 96 个特征图。下一个(第二个)fire 块再次将其压缩到 16 个特征图,以此类推。

下面的示例是一个 fire 块的实现。该块从 squeeze 层的 1 × 1 瓶颈卷积开始。squeeze 层的输出squeeze分支到两个并行扩展卷积expand1x1expand3x3。最后,两个扩展卷积的输出被连接在一起。

def fire_block(x, n_filters):
    ''' Construct a Fire Block
        x        : input to the block
        n_filters: number of filters
    '''
    squeeze = Conv2D(n_filters, (1, 1), strides=1, activation='relu', 
                     padding='same')(x)                                     ❶

    expand1x1 = Conv2D(n_filters * 4, (1, 1), strides=1, activation='relu',
                       padding='same')(squeeze)                             ❷
    expand3x3 = Conv2D(n_filters * 4, (3, 3), strides=1, activation='relu', ❷
                       padding='same')(squeeze)                             ❷

    x = Concatenate()([expand1x1, expand3x3])                               ❸
    return x

❶ 带有 1 × 1 瓶颈卷积的 squeeze 层

❷ 扩展层分支为 1 × 1 和 3 × 3 卷积,并加倍了滤波器的数量。

❸ 从激励层输出的分支被连接在一起。

8.3.4 分类器

分类器不遵循传统的GlobalAveragingPooling2D层后跟一个Dense层(输出节点的数量等于类别的数量)的做法。相反,它使用一个卷积层,滤波器的数量等于类别的数量,然后跟一个GlobalAveragingPooling2D层。这种安排将每个先前的滤波器(类别)减少到单个值。然后,GlobalAveragingPooling2D层的输出通过 softmax 激活,得到所有类别的概率分布。

让我们重新审视一个传统的分类器。在传统的分类器中,最终的 feature maps 在瓶颈层被减少并展平到更低的维度,通常使用GlobalAveragingPooling2D。现在,每个 feature map 将有一个像素作为 1D 向量(嵌入)。这个 1D 向量随后被传递到一个密集层,其中节点的数量等于输出类别的数量。

图 8.16 显示了分类器组件。在 SqueezeNet 中,最终的 feature maps 通过一个 1 × 1 的线性投影,该投影学习将最终的 feature maps 投影到一个新的集合,该集合正好等于输出类别的数量。现在,这些投影的 feature maps,每个对应一个类别,被减少到每个 feature map 的单个像素,并展平,成为一个长度正好等于输出类别数量的 1D 向量。这个 1D 向量随后通过 softmax 进行预测。

图片

图 8.16 使用卷积而不是密集层进行分类的 SqueezeNet 分类器组

基本区别是什么?在传统的分类器中,密集层学习分类。在这个移动版本中,1 × 1 线性投影学习分类。

以下是对分类器的一个示例实现。在这个例子中,输入是最终的特征图,通过一个Conv2D层进行 1 × 1 线性投影到输出类别数量。随后,特征图通过GlobalAveragePooling2D被减少到一个单像素的 1D 向量:

def classifier(x, n_classes):
    ''' Construct the Classifier
        x        : input to the classifier
        n_classes: number of output classes
    '''
    x = Conv2D(n_classes, (1, 1), strides=1, activation='relu', 
               padding='same')(x)        ❶

    x = GlobalAveragePooling2D()(x)      ❷
    x = Activation('softmax')(x)         ❷
    return x

❶ 将过滤器数量设置为类别数量

❷ 将每个过滤器(类别)简化为单个值用于分类

接下来,让我们通过构建传统的大规模 SOTA 模型的传统方法来更深入地了解分类器设计。图 8.17 展示了传统方法。最终的特征图被全局池化成一个 1 × 1 矩阵(一个值)。然后矩阵被展平成一个长度等于特征图数量的 1D 向量(例如 ResNet 中的 2048)。然后 1D 向量通过一个具有 softmax 激活的密集层,输出每个类别的概率。

图片

图 8.17 传统的大规模 SOTA 分类器中的特征图处理

图 8.18 展示了 SqueezeNet 中的方法。特征图通过一个 1 × 1 瓶颈卷积进行处理,将特征图的数量减少到类别数量。本质上,这是类别预测步骤——除了我们没有单个值,而是一个N × N矩阵。然后N × N矩阵的预测被全局池化成 1 × 1 矩阵,这些矩阵随后被展平成一个 1D 向量,其中每个元素是对应类别的概率。

图片

图 8.18 使用卷积而不是密集层进行分类

8.3.5 绕过连接

在他们的消融研究中,作者使用 ResNet 中引入的恒等连接对块进行微架构搜索,他们将这种连接称为绕过连接。他们在论文中说,SqueezeNet 位于“CNN 架构的广泛且大部分未探索的设计空间中。”他们探索的一部分包括他们所说的微架构设计空间。他们指出,他们受到了 ResNet 作者在 ResNet34 上带有和不带有绕过连接的 A/B 比较的启发,并通过绕过连接获得了 2%的性能提升。

作者尝试了他们所说的简单绕过和复杂绕过。在简单绕过中,他们在 ImageNet 上获得了 2.9%的 top-1 准确率提升和 2.2%的 top-5 准确率提升,而没有增加计算复杂度。因此,他们的改进与 ResNet 作者观察到的改进相当。

复杂旁路中,他们观察到较小的改进,准确率仅提高了 1.3%,模型大小从 4.8 MB 增加到 7.7 MB。在简单旁路中,模型大小没有增加。作者得出结论,简单旁路是足够的。

简单旁路

在简单旁路中,恒等链接仅在第一个 fire 块(组入口)和过滤器加倍之前的 fire 块中出现。图 8.19 说明了具有简单旁路连接的 fire 组。组中的第一个 fire 块具有旁路连接(恒等链接),然后是 fire 块,它将输出通道数(特征图)加倍。

图片

图 8.19 SqueezeNet 组带有简单旁路块

现在我们来近距离观察一个具有简单旁路(恒等链接)连接的 fire 块。这如图 8.20 所示。请注意,块输入被添加到连接操作的输出中。

图片

图 8.20 SqueezeNet fire 块带有恒等链接

让我们一步步来看。首先,我们知道,使用矩阵加法操作,输入上的特征图数量必须与连接操作的输出数量相匹配。对于许多 fire 块来说,这是正确的。例如,从 stem 组中,我们有 96 个特征图作为输入,在 squeeze 层中减少到 16,然后通过 expand 层扩展 8 倍(回到 96)。由于输入上的特征图数量等于输出,我们可以添加一个恒等链接。但并非所有 fire 块都是这样,这就是为什么只有一部分具有旁路连接。

以下是一个具有简单旁路连接(恒等链接)的 fire 块的示例实现。在这个实现中,我们传递额外的参数bypass。如果它是真的,我们在块的末尾添加一个最终层,该层对来自连接操作的输出执行矩阵加法(Add()):

def fire_block(x, n_filters, bypass=False):
    ''' Construct a Fire Block
        x        : input to the block
        n_filters: number of filters in the block
        bypass   : whether block has an identity shortcut
    '''
    shortcut = x

    squeeze = Conv2D(n_filters, (1, 1), strides=1, activation='relu', 
                     padding='same')(x)

    expand1x1 = Conv2D(n_filters * 4, (1, 1), strides=1, activation='relu',
                       padding='same')(squeeze)
    expand3x3 = Conv2D(n_filters * 4, (3, 3), strides=1, activation='relu',
                       padding='same')(squeeze)

    x = Concatenate()([expand1x1, expand3x3])

    if bypass:                      ❶
        x = Add()([x, shortcut])

    return x

❶ 当 bypass 为 True 时,输入(快捷方式)会矩阵加到 fire 块的输出上。

复杂旁路

在作者接下来的微架构搜索中,他们探索了在不使用恒等链接(简单旁路)的情况下向剩余的 fire 块添加线性投影。线性投影会将输入特征的数量投影到连接操作后等于输出特征图数量的数量。他们将这称为复杂旁路

目的是看看这是否会进一步提高 top-1/top-5 准确率,尽管会增加模型大小。正如我之前提到的,他们的实验表明使用复杂旁路对目标是有害的。图 8.21 描绘了一个 fire 组,其中剩余的没有简单旁路(恒等链接)的 fire 块具有复杂旁路(线性投影链接)。

图片

图 8.21 SqueezeNet 组带有投影快捷 fire 块(复杂旁路)

现在让我们更详细地看看图 8.22 中所示的带复杂旁路的 fire 块。请注意,在身份链接上的 1 × 1 线性投影将滤波器(通道)的数量增加了 8。这是为了匹配分支 1 × 1 和 3 × 3 输出连接的大小,两者都增加了输出大小 4(4 + 4 = 8)。在身份链接上使用 1 × 1 线性投影是将复杂旁路与简单旁路区分开来的关键。

图片

图 8.22 SqueezeNet 带投影快捷方式的 fire 块(复杂旁路)

在消融研究中,使用简单的旁路将 ImageNet 上 vanilla SqueezeNet 的准确率从 57.5%提高到 60.4%。对于复杂旁路,准确率仅提高到 58.8%。作者没有对为什么会出现这种情况做出结论,除了说这很有趣。使用 Idiomatic procedure reuse 设计模式对 SqueezeNet 的完整代码实现可在 GitHub 上找到(mng.bz/XYmv)。

接下来,我们将介绍 ShuffleNet,它引入了点卷积和通道洗牌(转置)操作,在不增加计算复杂性和尺寸的情况下增加特征图的数量。

8.4 ShuffleNet v1

大型网络的一个挑战是它们需要许多特征图,通常有数千个,这意味着它们有很高的计算成本。因此,在 2017 年,Face++的 Xiangyu Zhang 等人提出了一种在大幅降低计算成本的同时拥有大量特征图的方法。这种新的架构称为ShuffleNet v1 (arxiv.org/abs/1707.01083),专门为通常在手机、无人机和机器人上发现的低计算设备设计。

该架构引入了新的层操作:分组点卷积和通道洗牌。与 MobileNet 相比,作者发现 ShuffleNet 通过显著的优势实现了更好的性能:在 40 MFLOPs 的水平上,ImageNet top-1 错误率绝对降低了 7.8%。虽然作者报告了在 MobileNet 对应版本上的准确率提升,但 MobileNets 仍然在生产中受到青睐,尽管它们现在正被 EfficientNets 所取代。

8.4.1 架构

ShuffleNet 架构由三个洗牌组组成,论文中将其称为阶段。该架构遵循传统做法,每个组将前一个组的输出通道或特征图数量翻倍。图 8.23 展示了 ShuffleNet 架构。

图片

图 8.23 在 ShuffleNet v1 宏架构中,每个组将输出特征图的数量翻倍。

8.4.2 茎

与当时其他移动端 SOTA 模型相比,主干组件使用了更精细的 3 × 3 卷积层,而其他模型通常使用 7 × 7 或两个 3 × 3 卷积层的堆叠。主干组件,如图 8.24 所示,执行了激进的特征图降维,这至今仍是一种惯例。3 × 3 卷积层采用步长(特征池化)以实现 75%的降维,随后通过一个最大池化层进一步降维 75%,结果得到的特征图大小仅为输入通道的 6%。从输入到 6%的通道尺寸降低一直是一种传统做法。

图像

图 8.24 ShuffleNet 主干组件通过结合特征和最大池化来降低输出特征图的大小,降至输入大小的 6%。

8.4.3 学习组件

学习组件中的每个组由一个步长洗牌块(在论文中称为“单元”)组成,后面跟一个或多个洗牌块。步长洗牌块将输出通道数加倍,同时将每个通道的大小减少 75%。每个特征中过滤器数量和输出特征图的逐步加倍,当时是一种惯例,并且至今仍保持。同时,当一组将输出特征图的数量加倍时,它们的尺寸也会减少,以防止在层中深入时参数增长爆炸。

与 MobileNet v1/v2 类似,ShuffleNet 组在组的开始处进行特征图降维,使用步长洗牌块。这与 SqueezeNet 和大型 SOTA 模型将特征图降维推迟到组末的做法形成对比。通过在组开始处减小尺寸,参数数量和矩阵乘法操作的数量显著减少,但代价是减少了表示能力。

图 8.25 说明了洗牌组。组从步长洗牌块开始,它在组的开始处进行特征图尺寸的降低,然后跟一个或多个洗牌块。步长和随后的洗牌块将前一个组的过滤器数量加倍。例如,如果前一个组有 144 个过滤器,当前组将加倍到 288 个。

图像

图 8.25 ShuffleNet 组微观架构

下面的示例实现了一个洗牌组。参数n_blocks是组中的块数,n_filters是每个块的过滤器数量。参数reduction是洗牌块中维度降低的元参数(随后讨论),参数n_partitions是用于通道洗牌的分区元参数(随后讨论)。第一个块是一个步长洗牌块,其余块不是步长的:for _ in range(n_blocks-1)

def group(x, n_partitions, n_blocks, n_filters, reduction):
    ''' Construct a Shuffle Group
        x            : input to the group
        n_partitions : number of groups to partition feature maps (channels) 
        ➥ into.
        n_blocks     : number of shuffle blocks for this group
        n_filters    : number of output filters
        reduction    : dimensionality reduction
    '''
    x = strided_shuffle_block(x, n_partitions, n_filters, reduction)   ❶

    for _ in range(n_blocks-1):                                        ❷
        x = shuffle_block(x, n_partitions, n_filters, reduction)
    return x

❶ 组中的第一个块是一个步长洗牌块。

❷ 添加剩余的非步长洗牌块

Shuffle 块基于 B(1, 3, 1)残差块,其中 3 × 3 卷积是深度卷积(如 MobileNet)。作者指出,由于昂贵的密集 1 × 1 卷积,Xception 和 ResNeXt 等架构在极小的网络中效率降低。为了解决这个问题,他们用逐点组卷积替换了 1 × 1 逐点卷积,以降低计算复杂度。图 8.26 显示了设计上的差异。

图 8.26 比较 ResNet 和 ShuffleNet B(1,3,1)设计

当参数reduction小于 1 时,第一个逐点组卷积也会对块输入的滤波器数量进行降维(reduction * n_filters),然后在第二个逐点组卷积的输出通道中恢复,以匹配矩阵加法操作的输入残差。

他们还偏离了 Xception 中在深度卷积后使用 ReLU 的惯例,转而使用线性激活。他们对此变化的原因并不明确,使用线性激活的优势也不清楚。论文仅陈述:“批归一化(BN)和非线性的使用与[ResNet, ResNeXt]类似,只是我们没有像[Xception]建议的那样在深度卷积后使用 ReLU。”在第一个逐点组卷积和深度卷积之间是通道洗牌操作,这两个操作将在后面讨论。

图 8.27 展示了 shuffle 块。你可以看到通道洗牌是如何在 3 × 3 深度卷积之前插入到 B(1,3,1)残差块设计中的,特征提取就在这里发生。B(1, 3, 1)残差块是一个与 MobileNet v1 相当的瓶颈设计,其中第一个 1 × 1 卷积进行降维,第二个 1 × 1 卷积进行升维。该块继续遵循 MobileNet 中的惯例,将 3 × 3 深度卷积与 1 × 1 逐点组卷积配对,形成一个深度可分离卷积。尽管如此,它与 MobileNet v1 的不同之处在于,将第一个 1 × 1 瓶颈卷积改为 1 × 1 瓶颈逐点组卷积。

图 8.27 ShuffleNet 块使用惯用设计

以下是一个 shuffle 块的示例实现。该块从函数pw_group_conv``()中定义的逐点 1 × 1 组卷积开始,其中参数值int(reduction * n_filters)指定了降维。接下来是函数channel_shuffle()中定义的通道洗牌,然后是深度卷积(DepthwiseConv2D)。接下来是最终的逐点组 1 × 1 卷积,它恢复了维度。最后,将块的输入通过矩阵加法(Add())与逐点组卷积的输出相加。

def shuffle_block(x, n_partitions, n_filters, reduction):
    ''' Construct a shuffle Shuffle block
        x           : input to the block
        n_partitions: number of groups to partition feature maps (channels) into.
        n_filters   : number of filters
        reduction   : dimensionality reduction factor (e.g, 0.25)
    '''

    shortcut = x

    x = pw_group_conv(x, n_partitions, int(reduction * n_filters))    ❶
    x = ReLU()(x)

    x = channel_shuffle(x, n_partitions)                              ❷

    x = DepthwiseConv2D((3, 3), strides=1, padding='s                 ❸
    x = BatchNormalization()(x)

    x = pw_group_conv(x, n_partitions, n_filters)                     ❹

    x = Add()([shortcut, x])                                          ❺
    x = ReLU()(x)
    return x

❶ 第一个逐点组卷积操作进行了一次降维。

❷ 通道洗牌

❸ 3 × 3 深度卷积

❹ 第二组卷积进行维度恢复。

❺ 将输入(快捷连接)添加到块的输出中

点卷积组

以下是一个点卷积组卷积的示例实现。函数首先确定输入通道的数量 (in_filters = x.shape[-1])。接下来,通过将输入通道数除以组数 (n_partitions) 来确定每个组的通道数。然后,特征图按比例分配到各个组中 (lambda),每个组通过一个单独的 1 × 1 点卷积。最后,将组卷积的输出连接在一起并通过批量归一化层。

def pw_group_conv(x, n_partitions, n_filters):
    ''' A Pointwise Group Convolution
        x        : input tensor
        n_groups : number of groups to partition feature maps (channels) into.
        n_filers : number of filters
    '''
    in_filters = x.shape[-1]                                         ❶

    grp_in_filters  = in_filters // n_partitions                     ❷
    grp_out_filters = int(n_filters / n_partitions + 0.5)            ❷

    groups = []                                                      ❸
    for i in range(n_partitions):
        group = Lambda(lambda x: x[:, :, :, grp_in_filters * i: 
                                   grp_in_filters * (i + 1)])(x)     ❹

        conv = Conv2D(grp_out_filters, (1,1), padding='same', strides=1)(group)

        groups.append(conv)                                          ❺

    x = Concatenate()(groups)                                        ❻
    x = BatchNormalization()(x)                                      ❼
    return x

❶ 计算输入特征图(通道)的数量

❷ 计算每个组的输入和输出滤波器(通道)数量。注意向上取整。

❸ 在每个通道组上执行 1 × 1 线性点卷积

❹ 沿通道组切片特征图

❺ 在列表中保持组点卷积。

❻ 将组点卷积的输出连接在一起

❼ 对连接的组输出(特征图)进行批量归一化

步长洗牌块

步长洗牌块与以下不同:

  • 短路连接(块输入)的维度通过 3 × 3 平均池化操作减少。

  • 在非步长洗牌块中,使用矩阵加法而不是连接残差和快捷特征图。

关于使用连接,作者推理出“用通道连接替换逐元素加法,这样可以在很少的计算成本下轻松扩大通道维度。”

图 8.28 描述了一个步长洗牌块。你可以看到与非步长洗牌块的两个不同之处。在快捷连接上添加了一个平均池化,通过将特征图减少到 0.5H × 0.5W 来进行维度减少。这是为了匹配步长 3 × 3 深度卷积所做的特征池化大小,这样它们就可以连接在一起——而不是在非步长洗牌块中的矩阵加法。

图 8.28 步长洗牌块

以下是一个步长洗牌块的示例实现。参数 n_filters 是块中卷积层的滤波器数量。参数 reduction 是用于进一步细化网络的元参数,参数 n_partitions 指定了将特征图划分成多少组进行点卷积组。

函数首先创建投影快捷连接。输入通过一个步长的 AveragePooling2D 层,将投影快捷连接中的特征图大小减少到 0.5H × 0.5W

输入随后通过 1 × 1 点卷积组卷积(pw_ group_conv())。请注意,网络细化发生在第一个点卷积组卷积(int(reduction * n_filters))。输入经过通道混洗(channel_ shuffle()),然后通过 3 × 3 步长深度卷积,进行特征提取和特征池化;注意这里没有 ReLU 激活。

DepthwiseConv2D()的输出随后通过第二个 1 × 1 点卷积组卷积,其输出随后与投影快捷连接。

def strided_shuffle_block(x, n_partitions, n_filters, reduction):
    ''' Construct a Strided Shuffle Block
        x           : input to the block
        n_partitions: number of groups to partition feature maps (channels) 
        ➥ into.
        n_filters   : number of filters
        reduction   : dimensionality reduction factor (e.g, 0.25)
    '''
    # projection shortcut
    shortcut = x
    shortcut = AveragePooling2D((3, 3), strides=2, padding='same')(shortcut) ❶
    n_filters -= int(x.shape[-1])                                           ❷

    x = pw_group_conv(x, n_partitions, int(reduction * n_filters))
    x = ReLU()(x)

    x = channel_shuffle(x, n_partitions)

    x = DepthwiseConv2D((3, 3), strides=2, padding='same')(x)
    x = BatchNormalization()(x)

    x = pw_group_conv(x, n_partitions, n_filters)

    x = Concatenate()([shortcut, x])                                        ❸
    x = ReLU()(x)
    return x

❶ 使用平均池化进行瓶颈快捷连接

❷ 在第一个块中,入口点卷积组卷积的输出滤波器数量调整为与出口点卷积组卷积匹配。

❸ 将投影快捷连接到块的输出

通道混洗

通道混洗被设计用来克服组卷积的副作用,从而帮助信息在输出通道中流动。组卷积通过确保每个卷积只操作对应的输入通道组,显著降低了计算成本。正如作者所指出的,如果将多个组卷积堆叠在一起,会产生一个副作用:某些通道的输出仅来自输入通道的一小部分。换句话说,每个组卷积仅限于根据单个特征图(通道)学习其滤波器的下一个特征提取级别,而不是所有或部分输入特征图。

图 8.29 展示了将通道分成组并随后混洗通道的过程。本质上,混洗是通过构建新的通道来实现的,因为每个混洗通道都包含来自其他每个通道的部分——从而增加了输出通道之间的信息流。

图 8.29 通道混洗

让我们更仔细地看看这个过程。我们从一个输入通道组开始,我在图中用灰色阴影表示,以表明它们是不同的通道(不是副本)。接下来,根据分区设置,通道被分成相等大小的分区,我们称之为。在我们的表示中,每个组有三个独立的通道。我们构建了三个通道的混洗版本。通过灰色阴影,我们表示每个混洗通道是由每个未混洗通道的部分组成的,并且每个混洗通道的部分是不同的。

例如,第一个混洗通道是由三个未混洗通道的特征图的前三分之一构建的。第二个混洗通道是由三个未混洗通道的特征图的前三分之一构建的,以此类推。

以下是一个通道洗牌的示例实现。参数 n_partitions 指定了将输入特征图 x 分成多少组。我们使用输入的形状来确定 B × H × W × C(其中 C 是通道),然后计算每个组的通道数(grp_in_channels)。

接下来的三个 Lambda 操作执行以下操作:

  1. 将输入从 B × H × W × C 重塑为 B × W × W × G × Cg。添加了一个第五维 G(组),并将 C 重塑为 G × Cg,其中 Cg 是每个组中通道的子集。

  2. k.permute_dimensions() 执行了图 5.27 中展示的通道洗牌操作。

  3. 第二次重塑将洗牌后的通道重新构建为形状 B × H × W × C

def channel_shuffle(x, n_partitions):
    ''' Implements the channel shuffle layer
        x            : input tensor
        n_partitions : number of groups to partition feature maps (channels) into.
    '''
    batch, height, width, n_channels = x.shape                              ❶

    grp_in_channels  = n_channels // n_partitions                           ❷

    x = Lambda(lambda z: K.reshape(z, [-1, height, width, n_partitions, 
                                   grp_in_channels]))(x)                    ❸

    x = Lambda(lambda z: K.permute_dimensions(z, (0, 1, 2, 4, 3)))(x)       ❹

    x = Lambda(lambda z: K.reshape(z, [-1, height, width, n_channels]))(x)  ❺
    return x

❶ 获取输入张量的维度

❷ 推导每个组中输入滤波器(通道)的数量

❸ 分离通道组

❹ 交换通道组的顺序(洗牌)(即,3, 4 => 4, 3)

❺ 恢复输出形状

在他们的消融研究中,作者发现,在复杂性和准确性之间的最佳权衡是在减少因子为 1(无减少)的情况下,并将组分区数量设置为 8。使用 Idiomatic 过程重用设计模式为 ShuffleNet 编写的完整代码可以在 GitHub 上找到(mng.bz/oGop)。

接下来,我们将介绍如何使用量化缩小内存受限设备的模型大小,并使用 TensorFlow Lite Python 包进行转换/预测,以部署移动模型。

8.5 部署

我们将通过介绍部署移动卷积模型的基本知识来结束本章。我们首先将探讨量化,它减少了参数大小,从而降低了内存占用。量化在部署模型之前进行。接下来,我们将了解如何使用 TF Lite 在内存受限的设备上执行模型。在我们的示例中,我们使用 Python 环境作为代理。我们不会深入探讨与 Android 或 iOS 相关的具体细节。

8.5.1 量化

量化 是一个减少表示数字的位数的过程。对于内存受限的设备,我们希望在不会显著损失准确性的情况下,以较低的位表示存储权重。

由于神经网络对计算中的小错误具有一定的鲁棒性,因此在推理时不需要像训练时那样高的精度。这为在移动神经网络中降低权重的精度提供了机会。传统的减少方法是将 32 位浮点权重值替换为 8 位整数的离散近似。主要优势是,从 32 位到 8 位的减少只需要模型四分之一的内存空间。

在推理(预测)过程中,权重被缩放回其大约 32 位浮点值,以便进行矩阵运算,然后通过激活函数。现代硬件加速器已被设计用于优化此缩放操作,以实现名义上的计算开销。

在传统的缩减中,32 位浮点权重被分成整数范围内的桶(桶)。对于 8 位值,这将会有 256 个桶,如图 8.30 所示。

图 8.30 量化将浮点数范围分类为一系列由整数类型表示的固定桶。

在此示例中执行量化时,首先确定权重的浮点数范围,我们将其称为[rmin, rmax],即最小值和最大值。然后,该范围按桶的数量(在 8 位整数的情况下为 256)进行线性划分。

根据硬件加速器,我们可能还会在 CPU(和 TPU)上看到从两次到三次的执行速度提升。GPU 不支持整数运算。

对于原生支持 float16(半精度)的 GPU,量化是通过将 float32 值转换为 float16 来完成的。这将模型的内存占用减半,并且通常将执行速度提高四倍。

此外,当权重的浮点数范围受到限制(缩小)时,量化效果最佳。对于移动模型,当前惯例是使用 ReLU 的max_value为 6.0。

我们应该小心量化非常小的模型。大型模型受益于权重的冗余,并且在量化为 8 位整数时对精度损失具有免疫力。最先进的移动模型已被设计为在量化时限制精度损失的数量。如果我们设计较小的模型并对其进行量化,它们在精度上可能会显著下降。

接下来,我们将介绍 TF Lite 在内存受限设备上执行模型。

8.5.2 TF Lite 转换和预测

TF Lite 是内存受限设备上 TensorFlow 模型的执行环境。与原生的 TensorFlow 运行时环境不同,TF Lite 运行时环境要小得多,更容易适应内存受限设备。虽然针对此目的进行了优化,但它也带来了一些权衡。例如,一些 TF 图操作不受支持,一些操作需要额外的步骤。我们不会涵盖不受支持的图操作,但我们会介绍所需的额外步骤。

以下代码演示了使用 TensorFlow Lite 对现有模型进行量化,其中模型是训练好的 TF.Keras 模型。第一步是将 SavedModel 格式的模型转换为 TF Lite 模型格式。这是通过实例化一个TFLiteConverter并将内存中或磁盘上的 SavedModel 格式模型传递给它来完成的,然后调用convert()方法:

import tensorflow as tf

converter = tf.lite.TFLiteConverter.from_saved_model(model)     ❶

tflite_model = converter.convert()                              ❷

❶ 为 TF.Keras(SavedModel 格式)模型创建转换器实例

❷ 将模型转换为 TF Lite 格式

该模型的 TF Lite 版本不是 TensorFlow SavedModel 格式。您不能直接使用 predict() 等方法。相反,我们使用 TF Lite 解释器。您必须首先按照以下方式设置 TF Lite 模型的解释器:

  1. 为 TF Lite 模型实例化一个 TF Lite 解释器。

  2. 指示解释器为模型分配输入和输出张量。

  3. 获取模型输入和输出张量的详细信息,这些信息将需要在预测时了解。

以下代码演示了这些步骤:

interpreter = tf.lite.Interpreter(model_content=tflite_model)    ❶
interpreter.allocate_tensors()                                   ❷

input_details = interpreter.get_input_details()                  ❸
output_details = interpreter.get_output_details()                ❸
input_shape = input_details[0]['shape']

❶ 实例化 TF Lite 模型的解释器

❷ 为模型分配输入和输出张量

❸ 获取预测所需的输入和输出张量详情

input_detailsoutput_details 作为列表返回;元素的数量分别对应输入和输出张量的数量。例如,具有单个输入(例如图像)和单个输出(多类分类器)的模型将分别为输入和输出张量有一个元素。

每个元素都包含一个包含相应详细信息的字典。在输入张量的情况下,键 shape 返回一个元组,表示输入的形状。例如,如果模型以 (32, 32, 3) 的图像(例如 CIFAR-10)作为输入,则键将返回 (32, 32, 3)。

要进行单个预测,我们执行以下操作:

  1. 准备输入以形成一个大小为 1 的批次。对于我们的 CIFAR-10 示例,这将是一个 (1, 32, 32, 3)。

  2. 将批次分配给输入张量。

  3. 调用解释器以执行预测。

  4. 从模型获取输出张量(例如,多类模型中的 softmax 输出)。

以下代码演示了这些步骤:

import numpy as np

data = np.expand_dims(x_test[1], axis=0)                          ❶

interpreter.set_tensor(input_details[0]['index'], data)           ❷

interpreter.invoke()                                              ❸

softmax = interpreter.get_tensor(output_details[0]['index'])      ❹

label = np.argmax(softmax)                                        ❺

❶ 将单个输入转换为大小为 1 的批次

❷ 将批次分配给输入张量

❸ 执行(调用)解释器以执行预测

❹ 从模型获取输出

❺ 多类示例,确定从 softmax 输出预测的标签

对于批量预测,我们需要修改(调整大小)解释器的输入和输出张量以适应批次大小。以下代码在分配张量之前将解释器的批次大小调整为 128,对于 (32, 32, 3) 的输入(CIFAR-10):

interpreter = tf.lite.Interpreter(model_content=tflite_model)              ❶

interpreter.resize_tensor_input(input_details[0]['index'], (128, 32, 32, 3))    
interpreter.resize_tensor_input(output_details[0]['index'], (128, 10))     ❷

interpreter.allocate_tensors()                                             ❸

❶ 实例化 TF Lite 模型的解释器

❷ 对 128 批次的输入和输出张量进行调整大小

❸ 为模型分配输入和输出张量

摘要

  • 使用深度卷积和网络细化在 MobileNet v1 中的重构展示了在内存受限设备上运行模型并达到 AlexNet 准确率的能力。

  • 将 MobileNet v2 中的残差块重新设计为倒残差块进一步减少了内存占用并提高了准确率。

  • SqueezeNet 引入了使用元参数配置组和块属性的计算效率宏架构搜索的概念。

  • ShuffleNet v1 中的重构和通道洗牌展示了在极端内存受限的设备上运行模型的能力,例如微控制器。

  • 量化技术提供了一种方法,通过减少内存占用,可以将内存占用降低 75%,同时几乎不会损失推理精度。

  • 使用 TF Lite 将 SavedModel 格式转换为量化 TF Lite 格式,并在内存受限的设备上进行预测部署。

9 自编码器

本章涵盖了

  • 理解深度神经网络(DNN)和卷积神经网络(CNN)自编码器的设计原则和模式

  • 使用过程设计模式编码这些模型

  • 训练自编码器时的正则化

  • 使用自编码器进行压缩、去噪和超分辨率

  • 使用自编码器进行预训练以提高模型泛化能力

到目前为止,我们只讨论了监督学习模型。自编码器模型属于无监督学习的范畴。提醒一下,在监督学习中,我们的数据由特征(例如,图像数据)和标签(例如,类别)组成,我们训练模型学习从特征预测标签。在无监督学习中,我们可能没有标签或者不使用它们,我们训练模型在数据中找到相关模式。你可能会问,没有标签我们能做什么?我们可以做很多事情,自编码器就是可以从未标记数据中学习的一种模型架构。

自编码器是无监督学习的根本深度学习模型。即使没有人工标记,自编码器也可以学习图像压缩、表示学习、图像去噪、超分辨率和预训练任务——我们将在本章中介绍每个这些内容。

那么,无监督学习是如何与自编码器一起工作的呢?尽管我们没有图像数据的标签,我们可以操作图像使其同时成为输入数据和输出标签,并训练模型来预测输出标签。例如,输出标签可以是简单的输入图像——在这里,模型将学习恒等函数。或者,我们可以复制图像并向其添加噪声,然后使用噪声版本作为输入,原始图像作为输出标签——这就是我们的模型学习去噪图像的方式。在本章中,我们将介绍这些以及其他几种将输入图像转换为输出标签的技术。

9.1 深度神经网络自编码器

我们将从这个章节开始介绍自编码器的经典深度神经网络版本。虽然你可以仅使用 DNN 学习到有趣的东西,但它不适用于图像数据,所以接下来的几节我们将转向使用 CNN 自编码器。

9.1.1 自编码器架构

DNN 自编码器如何有用的一个例子是在图像重建方面。我最喜欢的重建之一,通常用作预训练任务,是拼图。在这种情况下,输入图像被分成九个拼块,然后随机打乱。重建任务就是预测拼块被打乱的顺序。由于这个任务本质上是一个多值回归器输出,它非常适合传统的 CNN,其中多类分类器被多值回归器所取代。

自动编码器由两个基本组件组成:编码器和解码器。对于图像重建,编码器学习一个最优(或几乎最优)的方法来逐步将图像数据池化到潜在空间,而解码器学习一个最优(或几乎最优)的方法来逐步反池化潜在空间以进行图像重建。重建任务决定了表示学习和转换学习的类型。例如,在恒等函数中,重建任务是重建输入图像。但你也可以重建一个无噪声的图像(通过降噪)或更高分辨率的图像(超分辨率)。这些类型的重建与自动编码器工作得很好。

让我们看看编码器和解码器在自动编码器中如何协同工作来完成这些类型的重建。基本的自动编码器架构,如图 9.1 所示,实际上有三个关键组件,编码器和解码器之间有潜在空间。编码器对输入进行表示学习,学习一个函数 f(x) = x'。这个 x' 被称为 潜在空间,它是从 x 学习到的低维表示。然后解码器从潜在空间进行转换学习,以执行原始图像的某种形式的重建。

图片

图 9.1 自动编码器宏架构中学习图像输入/输出的恒等函数

假设图 9.1 中的自动编码器学习恒等函数 f(x) = x。由于潜在空间 x' 的维度更低,我们通常将这种形式的自动编码器描述为学习在数据集中压缩图像的最优方式(编码器)然后解压缩图像(解码器)。我们也可以将这描述为函数序列:编码器(x) = x', 解码器(x') = x

换句话说,数据集代表了一种分布,对于这种分布,自动编码器学习最优的方法来压缩图像到更低的维度,并学习最优的解压缩方法来重建图像。让我们更详细地看看编码器和解码器,然后看看我们如何训练这种模型。

9.1.2 编码器

学习恒等函数的基本自动编码器形式使用密集层(隐藏单元)。池化是通过编码器中的每一层逐渐减少节点(隐藏单元)的数量来实现的,而反池化是通过每一层逐渐增加节点数量来学习的。最终反池化密集层中的节点数与输入像素数相同。

对于恒等函数,图像本身是标签。你不需要知道图像描绘的是什么,无论是猫、狗、马、飞机还是其他什么。当模型训练时,图像既是自变量(特征)也是因变量(标签)。

以下代码是自动编码器学习恒等函数的编码器的一个示例实现。它遵循图 9.1 中描述的过程,通过layers参数逐步池化节点(隐藏单元)的数量。编码器的输出是潜在空间。

我们首先将图像输入展平成一个一维向量。参数layers是一个列表;元素的数量是隐藏层的数量,元素值是该层的单元数。由于我们是逐步池化,每个后续元素的值逐渐减小。与用于分类的 CNN 相比,编码器在层上通常较浅,我们添加批量归一化以增强其正则化效果:

def encoder(x, layers):
    ''' Construct the Encoder 
        x     : input to the encoder
        layers: number of nodes per layer
    '''
    x = Flatten()(x)                ❶

    for layer in layers:            ❷
        n_nodes = layer['n_nodes']
        x = Dense(n_nodes)(x)
        x = BatchNormalization()(x)
        x = ReLU()(x)

    return x                        ❸

❶ 输入图像的展平

❷ 逐步单元池化(降维)

❸ 编码(潜在空间)

9.1.3 解码器

现在,让我们看看自动编码器解码器的一个示例实现。同样,遵循图 9.1 中描述的过程,我们通过layers参数逐步反池化节点(隐藏单元)的数量。解码器的输出是重构的图像。为了与编码器对称,我们以相反的方向遍历layers参数。最终Dense层的激活函数是sigmoid。为什么?每个节点代表一个重构的像素。由于我们已经将图像数据归一化到 0 到 1 之间,我们希望将输出挤压到相同的 0 到 1 范围内。

最后,为了重构图像,我们对来自最终Dense层的 1D 向量进行Reshape操作,将其重塑为图像格式(H × W × C):

def decoder(x, layers, input_shape):
    ''' Construct the Decoder
        x     : input to the decoder (encoding)
        layers: nodes per layer
   input_shape: input shape for reconstruction
    '''
    for _ in range(len(layers)-1, 0, -1):                            ❶
        n_nodes = layers[_]['n_nodes']
        x = Dense(n_nodes)(x)
        x = BatchNormalization()(x)
        x = ReLU()(x)

        units = input_shape[0] * input_shape[1] * input_shape[2]     ❷
        x = Dense(units, activation='sigmoid')(x)

        outputs = Reshape(input_shape)(x)                            ❸

        return outputs                                               ❹

❶ 逐步单元反池化(维度扩展)

❷ 最后一次反池化

❸ 重塑回图像输入形状

❹ 解码后的图像

9.1.4 训练

自动编码器想要学习一个低维度的表示(我们称之为潜在空间),然后学习一个根据预定义任务重构图像的变换;在这种情况下,恒等函数。

以下代码示例将训练前面的自动编码器,以学习 MNIST 数据集的恒等函数。该示例创建了一个具有隐藏单元 256、128、64(潜在空间)、128、256 和 784(用于像素重构)的自动编码器。

通常,一个深度神经网络自动编码器在编码器和解码器组件中都会包含三个或有时四个层。由于 DNNs 的有效性有限,增加更多的容量通常不会提高学习恒等函数的效果。

对于 DNN 自编码器,你在这里看到的另一个约定是,编码器中的每一层将节点数量减半,相反,解码器将节点数量加倍,除了最后一层。最后一层重建图像,因此节点数量与输入向量的像素数量相同;在这种情况下,784。在示例中选择从 256 个节点开始是有些任意的;除了从一个大尺寸开始会增加容量外,它对提高学习恒等函数的能力帮助很小,或者根本不起作用。

对于数据集,我们将图像形状从(28,28)扩展到(28,28,1),因为 TF.Keras 模型期望显式指定通道数——即使只有一个通道。最后,我们使用fit()方法训练自编码器,并将x_train作为训练数据和相应的标签(恒等函数)。同样,在评估时,我们将x_test作为测试数据和相应的标签。图 9.2 显示了自编码器学习恒等函数。

图片

图 9.2 自编码器学习两个函数:编码器学习将高维表示转换为低维表示,然后解码器学习将输入转换回高维表示,即输入的翻译。

以下代码演示了如图 9.2 所示的自动编码器的构建和训练,其中训练数据是 MNIST 数据集:

layers = [ {'n_nodes': 256 }, { 'n_nodes': 128 }, { 'n_nodes': 64 } ]      ❶

inputs = Input((28, 28, 1))                                                ❷
encoding = encoder(inputs, layers)
outputs = decoder(encoding, layers, (28, 28, 1))
ae = Model(inputs, outputs)

from tensorflow.keras.datasets import mnist
import numpy as np
(x_train, y_train), (x_test, y_test) = mnist.load_data()
x_train = (x_train / 255.0).astype(np.float32)
x_test  = (x_test  / 255.0).astype(np.float32)
x_train = np.expand_dims(x_train, axis=-1)
x_test  = np.expand_dims(x_test, axis=-1)

ae.compile(loss='binary_crossentropy', optimizer='adam', metrics=['accuracy'])
ae.fit(x_train, x_train, epochs=10, batch_size=32, validation_split=0.1, 
       verbose=1)                                                          ❸
ae.evaluate(x_test, x_test)

❶ 每层的过滤器数量元参数

❷ 构建自动编码器

❸ 无监督训练,其中输入和标签相同

让我们总结一下。自编码器想要学习一个低维度的表示(潜在空间),然后学习一个变换来根据预定义的任务(如恒等函数)重建图像。

使用 Idiomatic procedure reuse 设计模式为 DNN 自编码器提供的完整代码版本可在 GitHub 上找到(mng.bz/JvaK)。接下来,我们将描述如何使用卷积层代替密集层来设计和编写一个自编码器。

9.2 卷积自编码器

在 MNIST 或 CIFAR-10 数据集中的小图像中,DNN 自编码器运行良好。但是,当我们处理较大图像时,使用节点(即隐藏单元)进行(反)池化的自编码器在计算上很昂贵。对于较大图像,深度卷积(DC)自编码器更有效。它们不是学习(反)池化节点,而是学习(反)池化特征图。为此,它们在编码器中使用卷积,在解码器中使用反卷积,也称为转置卷积

当步长卷积(进行特征池化)学习下采样分布的最佳方法时,步长反卷积(特征反池化)则做相反的操作,并学习上采样分布的可行方法。特征池化和反池化都在图 9.3 中展示。

让我们使用与 MNIST 的 DNN 自动编码器相同的上下文来描述这个过程。在那个例子中,编码器和解码器各有三层,编码器从 256 个特征图开始。对于 CNN 自动编码器,相应的等效结构是一个编码器,具有 256、128 和 64 个过滤器的三个卷积层,以及一个具有 128、256 和 C 个过滤器的解码器,其中 C 是输入的通道数。

图 9.3 对比特征池化与特征反池化

9.2.1 架构

深度卷积自动编码器(DC 自动编码器)的宏观架构可以分解如下:

  • Stem—进行粗粒度特征提取

  • Learner—代表性和转换性学习

  • Task (重建)—进行投影和重建

图 9.4 显示了 DC 自动编码器的宏观架构。

图 9.4 DC 自动编码器的宏观架构区分了表示学习和转换学习。

9.2.2 编码器

深度卷积自动编码器(如图 9.5 所示)中的编码器通过使用步长卷积逐步减少特征图的数量(通过特征减少)和特征图的大小(通过特征池化)。

图 9.5 CNN 编码器中输出特征图的数量和尺寸的逐步减少

如你所见,编码器逐步减少过滤器的数量,也称为通道,以及相应的尺寸。编码器的输出是潜在空间。

现在让我们看看一个编码器的示例代码实现。参数layers是一个列表,其中元素的数量是卷积层的数量,元素值是每个卷积的过滤器数量。由于我们是逐步池化,每个后续元素的值都是逐步变小的。此外,每个卷积层通过使用步长为 2 来减少特征图的大小,进一步对特征图进行池化。

在这个实现中,对于卷积,我们使用 Conv-BN-RE 约定。你可能想尝试使用 BN-RE-Conv 来查看是否能得到更好的结果。

def encoder(inputs, layers):
    """ Construct the Encoder
        inputs : the input vector
        layers : number of filters per layer
    """
    outputs = inputs

    for n_filters in layers:                                                ❶
        outputs = Conv2D(n_filters, (3, 3), strides=(2, 2), padding='same')
                        (outputs)
        outputs = BatchNormalization()(outputs)
        outputs = ReLU()(outputs)

    return outputs                                                          ❷

❶ 逐步特征池化(降维)

❷ 编码(潜在空间)

9.2.3 解码器

对于解码器,如图 9.6 所示。解码器通过使用步长反卷积(转置卷积)逐步增加特征图的数量(通过特征扩展)和特征图的大小(通过特征反池化)。最后一个反池化层根据重建任务将特征图投影。对于恒等函数示例,该层将特征图投影到编码器输入图像的形状。

图 9.6 CNN 解码器中输出特征图数量和尺寸的渐进扩展

这里是一个实现恒等函数解码器的示例。在这个例子中,输出是一个 RGB 图像;因此,在最后一个转置卷积层上有三个过滤器,每个过滤器对应一个 RGB 通道:

def decoder(inputs, layers):
    """ Construct the Decoder
      inputs : input to decoder
      layers : the number of filters per layer (in encoder)
    """
    outputs = inputs
    for _ in range(len(layers)-1, 0, -1):            ❶
        n_filters = layers[_]
        outputs = Conv2DTranspose(n_filters, (3, 3), strides=(2, 2), 
                                  padding='same')(outputs)
        outputs = BatchNormalization()(outputs)
        outputs = ReLU()(outputs)

    outputs = Conv2DTranspose(3, (3, 3), strides=(2, 2), padding='same')
                             (outputs)               ❷
    outputs = BatchNormalization()(outputs)
    outputs = Activation('sigmoid')(outputs)

    return outputs                                   ❸

❶ 渐进特征反池化(维度扩展)

❷ 最后的反池化和恢复到图像输入形状

❸ 解码后的图像

现在让我们将编码器与解码器组装起来。

在这个例子中,卷积层将逐步从 64 个、32 个到 16 个过滤器进行特征池化,而反卷积层将逐步从 32 个、64 个到 3 个过滤器进行特征反池化,以重建图像。对于 CIFAR,图像大小非常小(32 × 32 × 3),因此如果我们添加更多层,潜在空间将太小,无法进行重建;如果我们通过更多过滤器加宽层,我们可能会因为额外的参数容量而面临过拟合(欠拟合)的风险。

layers = [64, 32, 16]                   ❶

inputs = Input(shape=(32, 32, 3))

encoding = encoder(inputs, layers)      ❷
                                        ❷
outputs = decoder(encoding, layers)     ❷
                                        ❷
model = Model(inputs, outputs)          ❷

❶ 编码器每层的过滤器数量元参数

❷ 构建自编码器

使用 Idiomatic 程序重用设计模式为 CNN 自编码器编写的一个完整代码示例在 GitHub 上(mng.bz/JvaK)。

9.3 稀疏自编码器

潜在空间的大小是一个权衡。如果我们做得太大,模型可能会过度拟合训练数据的表示空间,而无法泛化。如果我们做得太小,它可能会欠拟合,以至于我们无法执行指定的任务(例如,恒等函数)的转换和重建。

我们希望在这两者之间找到一个“甜蜜点”。为了增加自编码器不过度拟合或欠拟合的可能性,一种方法是添加一个稀疏性约束。稀疏性约束的概念是限制瓶颈层输出潜在空间的神经元激活。这既是一个压缩函数,也是一个正则化器,有助于自编码器泛化潜在空间表示。

稀疏性约束通常描述为仅激活具有大激活值的单元,并使其余单元输出为零。换句话说,接近零的激活被设置为零(稀疏性)。

从数学上讲,我们可以这样表述:我们希望任何单元(σ[i])的激活被限制在平均激活值(σ[µ])的附近:

σ[i] σ[µ]

为了实现这一点,我们添加了一个惩罚项,该惩罚项惩罚当激活 σ[i] 显著偏离 σ[µ] 时。

在 TF.Keras 中,我们通过在编码器的最后一层添加 activity_regularizer 参数来添加稀疏性约束。该值指定了激活值在 +/– 零附近的阈值,将其更改为零。一个典型的值是 1e-4。

下面是使用稀疏性约束实现的 DC-自编码器的实现。参数 layers 是一个列表,表示逐步池化特征图的数量。我们首先从列表的末尾弹出,这是编码器的最后一层。然后我们继续构建剩余的层。然后我们使用弹出(最后一层)的特征图数量来构建最后一层,其中我们添加稀疏性约束。这个最后的卷积层是潜在空间:

from tensorflow.keras.regulaziers import l1

def encoder(inputs, layers):
    """ Construct the Encoder
        inputs : the input vector
        layers : number of filters per layer
    """
    outputs = inputs

    last_filters = layers.pop()                                             ❶

    for n_filters in layers:                                                ❷
        outputs = Conv2D(n_filters, (3, 3), strides=(2, 2), padding='same')
                        (outputs)
        outputs = BatchNormalization()(outputs)
        outputs = ReLU()(outputs)

    outputs = Conv2D(last_filters, (3, 3), strides=(2, 2), padding='same',  ❸
               activity_regularizer=l1(1e-4))(outputs)
    outputs = BatchNormalization()(outputs)
    outputs = ReLU()(outputs)

    return outputs

❶ 保留最后一层

❷ 特征池化

❸ 在编码器的最后一层添加稀疏性约束

9.4 去噪自编码器

使用自编码器的另一种方式是将其训练为图像去噪器。我们输入一个噪声图像,然后输出图像的去噪版本。将这个过程视为学习带有一些噪声的恒等函数。如果我们用方程表示这个过程,假设 x 是图像,e 是噪声。该函数学习返回 x

f(x + e) = x

我们不需要为此目的更改自编码器架构;相反,我们更改我们的训练数据。更改训练数据需要三个基本步骤:

  1. 构建一个随机生成器,它将输出一个具有你想要添加到训练(和测试)图像中的噪声值范围的随机分布。

  2. 在训练时,向训练数据中添加噪声。

  3. 对于标签,使用原始图像。

下面是训练用于去噪的自编码器的代码。我们将噪声设置为在以 0.5 为中心的正态分布内,标准差为 0.5。然后我们将随机噪声分布添加到训练数据的副本(x_train_noisy)中。我们使用 fit() 方法来训练去噪器,其中噪声训练数据是训练数据,原始(去噪)训练数据是对应的标签:

noise = np.random.normal(loc=0.5, scale=0.5, size=x_train.shape)         ❶
x_train_noisy = x_train + noise                                          ❷

model.fit(x_train_noisy, x_train, epochs=epochs, batch_size=batch_size, 
          verbose=1)                                                     ❸

❶ 生成噪声为以 0.5 为中心,标准差为 0.5 的正态分布

❷ 将噪声添加到图像训练数据的副本中

❸ 通过将噪声图像作为训练数据,原始图像作为标签来训练编码器

9.5 超分辨率

自编码器也被用来开发用于 超分辨率 (SR) 的模型。这个过程将低分辨率(LR)图像上采样以提高细节,以获得高分辨率(HR)图像。与压缩中学习恒等函数或去噪中学习噪声恒等函数不同,我们想要学习低分辨率图像和高分辨率图像之间的表示映射。让我们用一个函数来表示我们想要学习的这个映射:

f(x[lr]) = x[hr]

在这个方程中,f()代表模型正在学习的变换函数。术语x[lr]代表函数输入的低分辨率图像,而术语x[hr]是函数从高分辨率预测输出的变换。

尽管现在非常先进的模型可以进行超分辨率处理,但早期版本(约 2015 年)使用自动编码器的变体来学习从低分辨率表示到高分辨率表示的映射。一个例子是 Chao Dong 等人提出的超分辨率卷积神经网络(SRCNN)模型,该模型在“使用深度卷积网络进行图像超分辨率”一文中被介绍(arxiv.org/pdf/1501.00092.pdf)。在这种方法中,模型学习在多维空间中对低分辨率图像的表示(潜在空间)。然后它学习从低分辨率图像的高维空间到高分辨率图像的映射,以重建高分辨率图像。注意,这与典型的自动编码器相反,自动编码器在低维空间中学习表示。

9.5.1 预上采样 SR

SRCNN 模型的创造者引入了全卷积神经网络在图像超分辨率中的应用。这种方法被称为预上采样 SR 方法,如图 9.7 所示。我们可以将模型分解为四个组件:低分辨率特征提取、高维表示、编码到低维表示,以及用于重建的卷积层。

图 9.7 预上采样超分辨率模型学习从低分辨率图像重建高分辨率图像。

让我们深入了解。与自动编码器不同,在低分辨率特征提取组件中没有特征池化(或下采样)。相反,特征图的大小与低输入图像中的通道大小相同。例如,如果输入形状是(16,16,3),则特征图的H × W将保持 16 × 16。

在主干卷积中,特征图的数量从输入的通道数(3)显著增加到,这为我们提供了低分辨率图像的高维表示。然后编码器将高维表示降低到低维表示。最后的卷积将图像重建为高分辨率图像。

通常,您会通过使用现有的图像数据集来训练这种方法,该数据集成为 HR 图像。然后您复制训练数据,其中每个图像都已被调整大小为更小,然后调整回原始大小。为了进行这两次调整大小,您使用静态算法,如双三次插值。LR 图像将与 HR 图像具有相同的大小,但由于调整大小操作期间所做的近似,LR 图像的质量将低于原始图像。

究竟什么是插值,更具体地说,双三次插值?可以这样想:如果我们有 4 个像素,用 2 个像素替换它们,或者反过来,你需要一种数学方法来对替换表示进行良好的估计——这就是插值。三次插值是用于向量的特定方法(1D),而 双三次 是用于矩阵(2D)的变体。对于图像缩小,双三次插值通常比其他插值算法给出更好的估计。

这里有一个代码示例,用于展示使用 CIFAR-10 数据集进行此训练数据准备的过程。在这个例子中,NumPy 数组 x_train 包含了训练数据图像。然后我们通过依次将 x_train 中的每个图像调整大小到一半的 H × W(16, 16),然后将图像调整回原始的 H × W(32, 32),并在 x_train_lr 中放置相同的索引位置,来创建一个低分辨率配对列表 x_train_lr。最后,我们对两组图像中的像素数据进行归一化:

from tensorflow.keras.datasets import cifar10
import numpy as np
import cv2

(x_train, y_train), (x_test, y_test) = cifar10.load_data()               ❶

x_train_lr = []                                                          ❷
for image in x_train:                                                    ❷
    image = cv2.resize(image, (16, 16), interpolation=cv2.INTER_CUBIC)   ❷
    x_train_lr.append(cv2.resize(image, (32, 32),                        ❷
                      interpolation=cv2.INTER_CUBIC))                    ❷
x_train_lr = np.asarray(x_train_lr)                                      ❷

x_train = (x_train / 255.0).astype(np.float32)                           ❸
x_train_lr = (x_train_lr / 255.0).astype(np.float32)                     ❸

❶ 将 CIFAR-10 数据集下载到内存中作为高分辨率图像

❷ 创建训练图像的低分辨率配对

❸ 对训练中的像素数据进行归一化

现在,让我们看看用于在小型图像(如 CIFAR-10)上实现高分辨率重建质量的预上采样 SR 模型的代码。为了训练它,我们将原始 CIFAR-10 32 × 32 图像(x_train)视为高分辨率图像,将镜像配对图像(x_train_lr)视为低分辨率图像。对于训练,低分辨率图像是输入,配对的 HR 图像是相应的标签。

这个例子在 CIFAR-10 上仅用 20 个周期就得到了相当好的重建结果,重建准确率为 88%。如代码所示,stem() 组件使用粗略的 9 × 9 滤波器进行低分辨率特征提取,并为高维表示输出 64 个特征图。encoder() 由一个卷积组成,使用 1 × 1 瓶颈卷积将低分辨率表示从高维度降低到低维度,并将特征图的数量减少到 32。最后,使用粗略的 5 × 5 滤波器学习从低分辨率表示到高分辨率的映射以进行重建:

from tensorflow.keras import Input, Model
from tensorflow.keras.layers import Conv2D, BatchNormalization
from tensorflow.ketas.layers import ReLU, Conv2DTranspose, Activation
from tensorflow.keras.optimizers import Adam

def stem(inputs):                                      ❶

    x = Conv2D(64, (9, 9), padding='same')(inputs)     ❷
    x = BatchNormalization()(x)                        ❷
    x = ReLU()(x)                                      ❷
    return x

def encoder(x):
    x = Conv2D(32, (1, 1), padding='same')(x)          ❸
    x = BatchNormalization()(x)                        ❸
    x = ReLU()(x)                                      ❸

    x = Conv2D(3, (5, 5), padding='same')(x)           ❹
    x = BatchNormalization()(x)                        ❹
    outputs = Activation('sigmoid')(x)                 ❹
    return outputs

inputs = Input((32, 32, 3))
x = stem(inputs)
outputs = encoder(x)

model = Model(inputs, outputs)
model.compile(loss='mean_squared_error', optimizer=Adam(lr=0.001), 
              metrics=['accuracy'])

model.fit(x_train_lr, x_train, epochs=25, batch_size=32, verbose=1, 
          validation_split=0.1)

❶ 低分辨率特征提取

❷ 高维表示

❸ 作为编码器的 1 × 1 瓶颈卷积

❹ 用于将重建为高分辨率图像的 5 × 5 卷积

现在让我们看看一些实际的图像。图 9.8 展示了 CIFAR-10 训练数据集中同一只孔雀的一组图像。前两个图像是用于训练的低分辨率和高分辨率图像对,第三个是模型训练后对同一孔雀图像的超分辨率重建。请注意,低分辨率图像比高分辨率图像有更多的伪影——即边缘周围的区域是方形的,颜色过渡不平滑。重建的超分辨率图像在边缘周围的色彩过渡更平滑,类似于高分辨率图像。

图 9.8 预上采样超分辨率中 LR、HR 配对和重建 SR 图像的比较

9.5.2 后上采样超分辨率

另一个 SRCNN 风格模型的例子是后上采样超分辨率模型,如图 9.9 所示。我们可以将这个模型分解为三个部分:低分辨率特征提取、高维表示和重建的解码器。

图 9.9 后上采样超分辨率模型

让我们更深入地探讨。与自动编码器不同,在低分辨率特征提取组件中没有特征池化(或下采样)。相反,特征图的大小与低输入图像中的通道大小相同。例如,如果输入形状是 (16, 16, 3),特征图的 H × W 将保持 16 × 16。

在卷积过程中,我们逐步增加特征图的数量——这就是我们得到高维空间的原因。例如,我们可能从三通道输入到 16,然后到 32,再到 64 个特征图。所以你可能想知道为什么维度更高?我们希望丰富的不同低分辨率特征提取表示有助于我们学习从它们到高分辨率的映射,这样我们就可以使用反卷积进行重建。但是,如果我们有太多的特征图,我们可能会使模型暴露在训练数据中的映射记忆中。

通常,我们使用现有的图像数据集来训练超分辨率模型,这些数据集将成为高分辨率图像,然后复制训练数据,其中每个图像都被调整大小以生成低分辨率图像对。

以下代码示例展示了使用 CIFAR-10 数据集进行此训练数据准备的过程。在这个例子中,NumPy 数组 x_train 包含训练数据图像。然后我们通过逐个调整 x_train 中每个图像的大小,并将其放置在 x_train_lr 中的相同索引位置,创建了一个低分辨率图像对列表 x_train_lr。最后,我们对两组图像中的像素数据进行归一化。

在后上采样的情况下,低分辨率图像保持为 16 × 16,而不是像预上采样那样调整回 32 × 32,这是因为在调整回 32 × 32 时,通过静态插值丢失了像素信息。

from tensorflow.keras.datasets import cifar10
import numpy as np
import cv2

(x_train, y_train), (x_test, y_test) = cifar10.load_data()     ❶

x_train_lr = []                                                ❷
for image in x_train:                                          ❷
    x_train_lr.append(cv2.resize(image, (16, 16),              ❷
                       interpolation=cv2.INTER_CUBIC))         ❷
x_train_lr = np.asarray(x_train_lr)                            ❷

x_train = (x_train / 255.0).astype(np.float32)                 ❸
x_train_lr = (x_train_lr / 255.0).astype(np.float32)           ❸

❶ 将 CIFAR-10 数据集作为高分辨率图像下载到内存中

❷ 对训练图像进行低分辨率配对

❸ 对训练的像素数据进行归一化

下面的代码实现了一个后上采样 SR 模型,它在 CIFAR-10 等小图像上获得了良好的 HR 重建质量。我们专门为 CIFAR-10 编写了这个实现。为了训练它,我们将原始 CIFAR-10 32 × 32 图像 (x_train) 作为 HR 图像,将镜像配对图像 (x_train_lr) 作为 LR 图像。对于训练,LR 图像是输入,配对的 HR 图像是相应的标签。

这个示例在 CIFAR-10 上仅用 20 个 epoch 就获得了相当好的重建结果,重建准确率达到 90%。在这个示例中,stem()learner() 组件执行低分辨率特征提取,并逐步扩展特征图维度从 16、32 到 64 个特征图。64 个特征图的最后一个卷积的输出是高维表示。decoder() 由一个反卷积组成,用于学习从低分辨率表示到高分辨率的映射以进行重建:

from tensorflow.keras import Input, Model
from tensorflow.keras.layers import Conv2D, BatchNormalization
from tensorflow.keras.layers import ReLU, Conv2DTranspose, Activation
from tensorflow.keras.optimizers import Adam

def stem(inputs):                                     ❶
    x = Conv2D(16, (3, 3), padding='same')(inputs)
    x = BatchNormalization()(x)
    x = ReLU()(x)
    return x

def learner(x):                                       ❶
    x = Conv2D(32, (3, 3), padding='same')(x)
    x = BatchNormalization()(x)
    x = ReLU()(x)
    x = Conv2D(64, (3, 3), padding='same')(x)         ❷
    x = BatchNormalization()(x)                       ❷
    x = ReLU()(x)                                     ❷
    return x

def decoder(x):                                       ❸
    x = Conv2DTranspose(3, (3, 3), strides=2, padding='same')(x)
    x = BatchNormalization()(x)
    x = Activation('sigmoid')(x)
    return x

inputs = Input((16, 16, 3))
x = stem(inputs)
x = learner(x)
outputs = decoder(x)

model = Model(inputs, outputs)
model.compile(loss='binary_crossentropy', optimizer=Adam(lr=0.001), 
              metrics=['accuracy'])
model.fit(x_train_lr, x_train, epochs=25, batch_size=32, verbose=1, 
          validation_split=0.1)

❶ 低分辨率特征提取

❷ 高维表示

❸ 低到高分辨率重建

让我们回到之前看过的那些孔雀图像。在图 9.10 中,前两个图像是用于训练的低分辨率和高分辨率配对,第三个是模型训练后对同一孔雀图像的超分辨率重建。与之前的预上采样 SR 模型一样,后上采样 SR 模型产生的重建 SR 图像比低分辨率图像的伪影更少。

图片

图 9.10 LR、HR 配对和后上采样 SR 重建图像的比较

在 GitHub 上提供了使用 Idiomatic 程序重用设计模式对 SRCNN 进行完整代码实现的示例 (mng.bz/w0a2).

9.6 预训练任务

正如我们讨论的,自动编码器可以在没有标签的情况下进行训练,以学习关键特征的特征提取,这些特征我们可以重新用于迄今为止给出的示例之外:压缩和去噪。

我们所说的“关键特征”是什么意思?对于成像,我们希望我们的模型学习数据的本质特征,而不是数据本身。这使得模型不仅能够泛化到同一分布中的未见数据,而且还能在模型部署后,当输入分布发生偏移时,更好地预测其正确性。

例如,假设我们有一个训练好的模型用于识别飞机,训练时使用的图像包括各种场景,如停机坪、滑向航站楼和在空中,但没有一个是停机库中的。如果在部署模型后,它现在看到了停机库中的飞机,那么输入分布发生了变化;这被称为数据漂移。而当飞机图像出现在停机库中时,我们得到的准确度会降低。

在这个示例案例中,我们可能会尝试通过重新训练模型并添加包含背景中飞机的额外图像来改进模型。很好,现在部署时它工作了。但假设新模型看到了它没有训练过的其他背景中的飞机,比如在水面上的飞机(水上飞机)、在飞机坟场上的沙地上的飞机、在工厂中部分组装的飞机。好吧,在现实世界中,总有你预料不到的事情!

正因如此,学习数据集中的基本特征而不是数据本身非常重要。对于自动编码器来说,它们必须学习像素之间的相关性——即表示学习。相关性越强,关系越有可能在潜在空间表示中显现出来,相关性越弱,则不太可能显现。

我们不会在这里详细讨论使用前缀任务进行预训练,但我们将简要地在此处提及它,特别是在自动编码器的上下文中。就我们的目的而言,我们希望使用自动编码器方法来训练主干卷积组,以便在数据集上训练模型之前学习提取基本粗略级特征。以下是步骤:

  1. 在目标模型上进行预热(监督学习)训练,以实现数值稳定(将在第十四章中进一步讨论)。

  2. 构建一个自动编码器,其中模型的主干组作为编码器,反转的主干组作为解码器。

  3. 将目标模型中的数值稳定权重转移到自动编码器的编码器中。

  4. 在前缀任务(例如,压缩、去噪)上训练(无监督学习)自动编码器。

  5. 将前缀任务训练的权重从自动编码器的编码器转移到目标模型。

  6. 训练(监督学习)目标模型。

图 9.11 描述了这些步骤。

图片

图 9.11 使用自动编码器预训练主干组,以改善在模型完全使用标记数据训练后对未见数据的泛化。

让我们再讨论一下这种前缀任务的一部分。你可能已经想到,来自主干卷积组的输出将大于输入。当我们对通道进行静态或特征池化时,我们增加了总通道数。例如,我们可能使用池化将通道大小减少到 25%甚至仅为 6%,但我们将通道数从三个(RGB)增加到 64 个左右。

因此,潜在空间现在比输入更大,更容易过拟合。为此特定目的,我们构建了一个稀疏自动编码器来抵消过拟合的潜在可能性。

以下是一个示例实现。虽然我们尚未讨论UpSampling2D层,但它是对步长MaxPooling2D的逆操作。它不是使用静态算法将高度和宽度减半,而是使用静态算法将高度和宽度增加 2:

from tensorflow.keras import Input, Model
from tensorflow.keras.layers import Conv2D, Conv2DTranspose
from tensorflow.keras.layers import MaxPooling2D, UpSampling2D
from tensorflow.keras.regularizers import l1

def stem(inputs):
    x = Conv2D(64, (5, 5), strides=(2, 2), padding='same', 
               activity_regularizer=l1(1e-4))(inputs)                   ❶
    x = MaxPooling2D((2, 2), strides=(2, 2))(x)                         ❷
    return x

def inverted_stem(inputs):
    x = UpSampling2D((2, 2))(inputs)                                    ❸
    x = Conv2DTranspose(3, (5, 5), strides=(2, 2), padding='same')(x)   ❹
    return x

inputs = Input((128, 128, 3))
_encoder = stem(inputs)
_decoder = inverted_stem(_encoder)
model = Model(inputs, _decoder)

❶ 使用 5 × 5 滤波器进行粗略特征提取并使用特征池化

❷ 使用最大池化将特征图减少到图像大小的 6%

❸ 反转最大池化

❹ 反转特征池化并重建图像

以下是从该自动编码器的summary()方法输出的内容。请注意,输入大小等于输出大小:

Layer (type)                 Output Shape              Param #   
=================================================================
input_4 (InputLayer)         [(None, 128, 128, 3)]     0         
_________________________________________________________________
conv2d_2 (Conv2D)            (None, 64, 64, 64)        4864      
_________________________________________________________________
max_pooling2d_1 (MaxPooling2 (None, 32, 32, 64)        0         
_________________________________________________________________
up_sampling2d (UpSampling2D) (None, 64, 64, 64)        0         
_________________________________________________________________
conv2d_transpose (Conv2DTran (None, 128, 128, 3)       4803      
=================================================================
Total params: 9,667
Trainable params: 9,667
Non-trainable params: 0

9.7 超越计算机视觉:序列到序列

让我们简要地看看一种基本的自然语言处理模型架构,称为序列到序列Seq2Seq)。此类模型结合了自然语言理解(NLU)——理解文本,和自然语言生成(NLG)——生成新文本。对于 NLG,Seq2Seq 模型可以执行诸如语言翻译、摘要和问答等操作。例如,聊天机器人是执行问答的 Seq2Seq 模型。

在第五章的结尾,我们介绍了 NLU 模型架构,并看到了组件设计如何与计算机视觉相媲美。我们还研究了注意力机制,它与残差网络中的身份链接相当。我们没有涵盖的是在 2017 年引入的 Transformer 模型架构,它引入了注意力机制。这一创新将 NLU 从基于时间序列的解决方案,使用 RNN,转变为空间问题。在 RNN 中,模型一次只能查看文本输入的片段并保持顺序。此外,对于每个片段,模型必须保留重要特征的记忆。这增加了模型设计的复杂性,因为您需要在图中实现循环以保留先前看到的特征。有了 Transformer 和注意力机制,模型可以一次性查看文本。

图 9.12 展示了 Transformer 模型架构,该架构实现了一个 Seq2Seq 模型。

图 9.12 Transformer 架构包括用于 NLU 的编码器和用于 NLG 的解码器

如您所见,学习组件包括用于 NLU 的编码器和用于 NLG 的解码器。您通过使用文本对、句子、段落等来训练模型。例如,如果您正在训练一个问答聊天机器人,输入将是问题,标签是答案。对于摘要,输入将是文本,标签是摘要。

在转换器模型中,编码器按顺序学习输入上下文的降维,这与计算机视觉自动编码器中编码器的表征学习相当。编码器的输出被称为中间表示,与计算机视觉自动编码器中的潜在空间相当。

解码器按顺序学习将中间表示扩展到变换上下文的维度扩展,这与计算机视觉自动编码器中解码器的变换学习相当。

解码器的输出传递给任务组件,该组件学习文本生成。文本生成任务与计算机视觉自动编码器中的重建任务相当。

摘要

  • 自动编码器学习输入到低维表示的最佳映射,然后学习映射回高维表示,以便可以进行图像的变换重建。

  • 自动编码器可以学习的变换函数示例包括恒等函数(压缩)、去噪图像和构建图像的高分辨率版本。

  • 在卷积神经网络自动编码器中,池化操作通过步长卷积完成,而反池化操作通过步长反卷积完成。

  • 在无监督学习中使用自动编码器可以训练模型学习数据集分布的基本特征,而无需标签。

  • 使用编码器作为无监督学习预训练任务的前缀可以辅助后续的监督学习,以学习更好的泛化所需的基本特征。

  • NLU 的 Seq2Seq 模型模式使用一个编码器和解码器,与自动编码器相当。

第三部分. 使用管道

在本第三部分,你将学习如何设计和构建用于模型训练、部署和服务的生产级管道。我们首先向你介绍超参数调优在底层是如何工作的,然后展示使用 KerasTuner 的 DIY 方法和自动超参数调优。在两种情况下,有效的超参数调优都需要在选择搜索空间时做出良好的判断,因此我们讨论了这些最佳实践。

接下来,我们将转向迁移学习。在迁移学习中,你将重用另一个训练模型的权重,并使用更少的数据和更少的训练时间微调新的模型。我们涵盖了迁移学习的几种变体,一种是在新数据集的领域与训练模型非常相似时(例如,蔬菜与水果),另一种是在领域非常不同时。最后,我们介绍了在完整训练时初始化模型的领域迁移技术。

在剩余的章节中,我们将深入探讨整个生产级管道。我们首先探讨数据分布背后的概念以及它们如何影响部署的模型对训练期间未见过的真实世界输入的泛化能力。你将学习提高模型泛化训练的技术。接下来,我们将深入研究数据管道的组件、设计和配置,包括数据仓库、ETL 过程和模型喂养。你将学习以多种方式编码这些管道,使用 TF.Keras、tf.data、TFRecords 和 TensorFlow Extended (TFX)。

最后,我们将所有内容整合在一起,展示管道如何扩展到训练、部署,然后是服务。你将看到部署的硬件资源细节,如沙箱、负载均衡和自动扩展。在服务方面,你将学习如何通过使用预构建和自定义容器从云端提供服务,以及从边缘提供服务,并熟悉生产部署和 A/B 测试的细节。

10 超参数调整

本章涵盖

  • 在预热训练之前初始化模型中的权重

  • 手动和自动进行超参数搜索

  • 为训练模型构建学习率调度器

  • 在训练过程中正则化模型

超参数调整是寻找训练超参数最优设置的过程,以便我们最小化训练时间最大化测试准确率。通常,这两个目标无法完全优化。如果我们最小化训练时间,我们可能无法达到最佳准确率。同样,如果我们最大化测试准确率,我们可能需要更长时间进行训练。

调整是找到满足你目标的最优超参数设置组合。例如,如果你的目标是尽可能高的准确率,你可能不会关心最小化训练时间。在另一种情况下,如果你只需要良好的(但不是最好的)准确率,并且你持续进行重新训练,你可能希望找到在最小化训练时间的同时获得这种良好准确率的设置。

通常,一个目标没有特定的设置。更有可能的是,在搜索空间内,各种设置组合都能实现你的目标。你需要找到其中之一——这就是调整的目的。

现在,我们调整的超参数有哪些?我们将在本章中详细探讨这些内容,但基本上它们是指导模型训练以最大化实现目标的参数。本章我们将调整的参数,例如,包括批量大小、学习率和学习率调度器。

在本章中,我们将探讨几种常用的超参数搜索(调整)技术。图 10.1 展示了传统生产环境中整体超参数过程的概览。目前不必担心细节,我们将一步步进行讲解。

图片

图 10.1 传统生产训练环境中的超参数过程

我将简要地浏览这个图表,以便你了解本章余下部分我们将遵循的过程。第一步是选择模型权重最佳初始化,我们将花些时间了解为什么这个选择可以显著影响训练结果。我们将从基于研究和进展的预定分布开始,进而探讨选择分布中抽取的一种替代方法:彩票原则。

接下来,在权重初始化后,我们转向预热预训练。这个过程从数值上稳定了权重,这将增加在训练时间和模型准确率方面获得更优结果的可能性。

一旦权重数值稳定,我们将探讨搜索和调整超参数的技术。

在我们初始化良好且数值稳定的权重和超参数调整完毕后,我们进入实际训练阶段,首先采用一些技术来进一步提高获得更优结果的可能性。其中一种我们将在此使用的技术是在训练的后期部分调整学习率。这可以显著提高收敛到全局或近似最优解的机会。换句话说,这些技术增加了在较低总体经济成本下产生更精确模型的概率。

我们将通过介绍在训练过程中权重更新时实施的常见正则化技术来结束本章。正则化有助于减少记忆(过拟合),同时增加模型在生产部署时对示例的泛化能力。我们将讨论生产中最常用的两种技术:权重衰减(也称为核正则化层正则化)和标签平滑。

10.1 权重初始化

当我们从零开始训练一个模型时,我们需要给权重一个初始值。这个过程称为初始化。为了简单起见,我们可以先设置所有权重为相同的值——比如说,0 或 1。然而,这样做是不行的,因为反向传播中梯度下降的工作方式意味着每个权重都会进行相同的更新。

那个神经网络将是对称的,相当于一个单独的节点。一个单独的节点只能做出单一的二元决策,并且只能解决具有线性分离的问题,如逻辑与或或。逻辑异或问题不能通过单个节点解决,因为它需要一个非线性分离。早期感知器模型无法解决异或问题,这归因于从 1984 年到 2012 年人工智能研究减少和资金减少,这被称为人工智能冬天

因此,我们需要将模型中的权重设置为随机值分布。理想情况下,分布范围应较小(在-1 和 1 之间),且以 0 为中心。在过去几年中,为了初始化权重,已经使用了几个随机分布的范围。为什么权重应该在一个小的分布范围内?好吧,如果我们的范围很大,较大的初始化权重将主导模型更新中的较小权重,导致稀疏性、准确性降低,并可能无法收敛。

10.1.1 权重分布

让我们先澄清权重初始化和权重分布之间的区别。权重初始化是在训练模型之前为权重设置的初始值,是起点。权重分布是我们选择那些初始权重的来源。

三种权重分布已被证明是最受研究人员欢迎的。均匀分布在整个范围内均匀分布。这不再使用。Xavier,或Glorot分布,是对均匀分布的改进,是一种以零为中心的随机正态分布。其标准差设置为以下公式,其中fan_in是层的输入数量:

sqrt(1 / fan_in)

这是在早期 SOTA 模型中流行的一种方法,最适合激活函数为 tanh(双曲正切)时使用。现在很少使用。

最后,我们有He-normal 分布,它是对 Xavier 分布的改进。如今,几乎所有权重初始化都是使用 He-normal 分布进行的;它是当前的主流分布,最适合 ReLU 激活函数。这种随机分布是以零为中心的正态分布,其标准差设置为以下公式,其中fan_in是层的输入数量:

sqrt(2 / fan_in)

现在我们来看看如何实现这一点。在 TF.Keras 中,默认情况下,权重初始化为 Xavier 分布(称为glorot_uniform)。要将权重初始化为 He-normal 分布,必须显式设置关键字参数kernel_initializerhe_normal。以下是实现方式:

x = Conv2D(16, (3, 3), strides=1, padding='same', activation='relu',
           kernel_initializer='he_normal')(inputs)                      ❶

outputs = Dense(10, activation='softmax',
                    kernel_initializer='he_normal')(x)                  ❶

❶ 将权重初始化为 He-normal 分布

10.1.2 彩票假设

一旦研究人员就用于初始化神经网络的权重分布达成共识,下一个问题是,从分布中抽取的最佳方法是什么?我们将从讨论彩票假设开始,它引发了一系列从分布中抽取的快速进展,这进而导致了数值稳定性概念(在第 10.1.3 节中介绍)。

2019 年提出了用于权重初始化的彩票假设。该假设包含两个假设:

  • 从随机分布中抽取的两个值不会相等。对于权重初始化的随机分布抽取中,有些抽取结果比其他抽取结果更好。

  • 大型模型具有高精度,因为它们实际上是一系列小型模型的集合。每个模型从随机分布中抽取不同的值,其中一个抽取值是“中奖彩票”。

随后尝试从训练的大型模型中识别和提取具有“中奖彩票”的子模型到一个紧凑模型,但从未成功。因此,由 Jonathan Frankle 和 Michael Carbin 在“彩票假设”(arxiv.org/abs/1803.03635)中提出的方法现在不再使用,但后续研究导致了其他变体。在本节中,我们将探讨其中一种常用的变体。

然而,关于“中奖彩票”的问题尚未解决。另一群机器学习实践者使用预训练多个模型实例的方法,每个实例都有单独的抽取。通常,当使用这种方法时,我们使用非常小的学习率(例如,0.0001)运行少量 epoch。对于每个 epoch,步数远少于训练数据的大小。通过这样做,我们可以在短时间内预训练大量实例。一旦完成,选择具有最佳目标指标(如训练损失)的模型实例。假设这种抽取的中奖彩票比其他抽取更好。

图 10.2 通过使用彩票假设方法展示了预训练模型实例。创建了多个参考模型架构的副本以进行训练,每个副本从随机分布中抽取不同的样本。然后,每个实例使用相同的小学习率进行少量 epoch/减少的步数进行预训练。如果计算资源可用,预训练是分布式的。一旦完成,检查每个预训练模型的训练损失。具有最低训练损失的实例是具有最佳抽取的实例——即中奖彩票。

图片

图 10.2 使用彩票假设方法进行预训练

我们可以使用以下代码实现此过程。样本中显示的主要步骤如下:

  1. 创建 10 个模型实例,每个实例都有单独的抽取进行权重初始化。我们这样做是为了模拟这样一个原则:没有两个抽取是相同的。在这个例子中,选择 10 只是一个任意数。实例数量越多,每个实例都有单独的抽取,那么其中某个抽取是中奖彩票的可能性就越大。

  2. 对每个实例进行少量 epoch 和步数的训练。

  3. 选择具有最低训练损失的模型实例(best)。

这里是代码:

def make_model():
    ''' make an instance of the model '''
    bottom = ResNet50(include_top=False, weights=None, 
                       input_shape=(32, 32, 3))
    model = Sequential()
    model.add(bottom)
    model.add(Flatten())
    model.add(Dense(10, activation='softmax'))
    model.compile(loss='sparse_categorical_crossentropy',
                  optimizer=Adam(0.0001),
                  metrics=['acc'])
    return model

lottery = []                            ❶
for _ in range(10):
    lottery.append(make_model())

from tensorflow.keras.datasets import cifar10
from tensorflow.keras.preprocessing.image import ImageDataGenerator
import numpy as np
(x_train, y_train), (x_test, y_test) = cifar10.load_data()
x_train = (x_train / 255.0).astype(np.float32)

best = (None, 99999)                    ❷
datagen = ImageDataGenerator()
for model in lottery:

    result = model.fit(datagen.flow(x_train, y_train, batch_size=32),
                       epochs=3, 
                       steps_per_epoch=100)
    print(result.history['loss'][2])
    loss = result.history['loss'][2]
    if loss < best[1]:
        best = (model, loss)

❶ 创建 10 个模型实例,每个实例都有单独的抽取进行初始化

❷ 预训练并选择具有最低训练损失的实例

接下来,我们看看另一种权重初始化的方法,即使用预热来对权重进行数值稳定性。

10.1.3 预热(数值稳定性)

与彩票假设方法在权重初始化方面采取不同方法的数值稳定性方法,是目前在完整训练之前初始化权重的流行技术。在彩票假设中,大模型被视为子模型的集合,其中一个子模型拥有中奖彩票。在数值稳定性方法中,大模型被分为上层(底部)和下层(顶部)。

虽然我们之前讨论了底部顶部的区别,但这种术语可能对一些读者来说仍然显得有些倒退——对我来说确实如此。在神经网络中,输入层是底部,输出层是顶部。输入从模型的底部馈入,预测从顶部输出。

假设较低(顶部)层在训练期间为较高(底部)层提供数值稳定性。或者更具体地说,较低层为较高层提供数值稳定性,以便它们可以学习获胜的彩票(初始化抽签)。图 10.3 描述了此过程。

图 10.3 预训练以实现底层数值稳定性,以便高层学习获胜的彩票初始化

此方法通常在模型完整训练之前作为一个预热训练周期来实现。对于预热训练,我们从一个非常小的学习率开始,以避免引起权重的大幅波动,并使权重向获胜彩票移动。预热学习率的典型初始值在 1e-5 到 1e-4 的范围内。

我们对模型进行少量周期(通常是四到五个)的训练,并在每个周期后逐步提高学习率到为训练所选的初始学习率。

图 10.4 说明了预热训练方法,如图 10.1 中的步骤 1、2 和 3 所示。与彩票假设不同,我们从一个参考模型的单个实例开始训练。从非常低的学习率开始,其中权重通过微小的调整,模型以完整周期进行训练。每次学习率逐渐与完整训练的初始学习率成比例。达到最终周期后,模型实例中的权重被认为是数值稳定的。

图 10.4 预热预训练以实现数值稳定性

在下面的代码示例中,你可以看到实现了以下五个关键步骤:

  1. 实例化一个模型的单个权重初始化实例。

  2. 定义学习率调度器warmup_scheduler(),在每个周期后提高学习率。第 10.3 节详细介绍了学习率调度器。

  3. 将预热调度器作为fit()方法的回调。

  4. 训练少量周期(例如,四个)。

def make_model(w_lr):
    ''' make an instance of the model '''
    bottom = ResNet50(include_top=False, weights=None, 
                       input_shape=(32, 32, 3))
    model = Sequential()
    model.add(bottom)
    model.add(Flatten())
    model.add(Dense(10, activation='softmax'))
    model.compile(loss='sparse_categorical_crossentropy', 
                   optimizer=Adam(w_lr),
                  metrics=['acc'])
    return model

w_lr = 0.0001                                                     ❶
i_lr = 0.001  
w_epochs = 4  
w_step   = (i_lr - w_lr) / w_epochs 

model = make_model(w_lr)                                          ❷

def warmup_scheduler(epoch, lr):
    """ learning rate scheduler for warmup training
        epoch : current epoch iteration
        lr    : current learning rate
    """
    if epoch == 0:
        return lr
    return lr + w_step                                          ❸

from tensorflow.keras.callbacks import LearningRateScheduler    ❹
lrate = LearningRateScheduler(warmup_scheduler, verbose=1)

from tensorflow.keras.datasets import cifar10
from tensorflow.keras.preprocessing.image import ImageDataGenerator
import numpy as np
(x_train, y_train), (x_test, y_test) = cifar10.load_data()
x_train = (x_train / 255.0).astype(np.float32)

result = model.fit(x_train, y_train, batch_size=32, epochs=4, 
                    validation_split=0.1,  
                   verbose=1, callbacks=[lrate])

❶ 设置预热学习率和学习率步长

❷ 创建模型并设置初始学习率为预热率

❸ 从预热率逐步增加到完整训练的初始学习率

❹ 创建回调到学习率调度器

现在我们已经介绍了预训练,让我们来看看超参数搜索背后的基础。然后我们将把在这里学到的所有内容付诸实践,并对模型进行完整训练。

10.2 超参数搜索基础

一旦你的模型权重初始化具有数值稳定性(无论是由抽签还是预热),我们进行超参数搜索,也称为超参数调整超参数优化

记住,超参数搜索的目的是找到(近似)最佳超参数设置,以最大化您模型针对目标(例如,训练速度或评估准确性)的训练。而且,正如我们之前讨论的,我们区分模型配置的参数,称为元参数,以及训练的参数,称为超参数。在本节中,我们只关注调整超参数。

通常,在训练预配置模型时,我们尝试调整的超参数如下:

  • 学习率

  • 批量大小

  • 学习率调度器

  • 正则化

注意:不要在权重未进行数值稳定的模型上进行超参数搜索。如果没有权重的数值稳定,实践者可能会无意中丢弃性能较差的组合,而这些组合可能原本是好的组合。

让我们从视觉开始。图 10.5 展示了搜索空间。黑色区域代表产生最佳结果的超参数组合。搜索空间中可能存在多个最佳组合区域;在这种情况下,我们有三个黑色点。通常,在每个最佳区域附近都有一个较大的近似最佳结果区域,用灰色表示。搜索空间的大部分,用白色空间表示,产生非最佳(和非近似)的结果。

图片

图 10.5 超参数搜索空间

如您所见,白色区域相对于黑色区域的数量,如果您随机挑选一些超参数组合,您不太可能找到一个最佳或近似最佳结果。因此,您需要一个策略。一个好的策略是具有高概率落在近似最佳区域(s)的策略;处于近似最佳区域内可以缩小搜索空间,以找到附近的最佳区域。

10.2.1 超参数搜索的手动方法

在我们进入自动化搜索之前,让我们先通过一个手动方法来了解一下。我在训练计算机视觉模型方面有很多经验,并且在选择超参数方面有很强的直觉。我能够利用这种学到的直觉来进行手动引导的搜索。我通常遵循以下这四个初始步骤:

  1. 粗调初始学习率。

  2. 调整批量率。

  3. 精调初始学习率。

  4. 调整学习率调度器。

粗调初始学习率

我首先使用固定的批量大小和固定的学习率。如果是一个小数据集,通常少于 50,000 个示例,我使用 32 个批量大小;否则,我使用 256 个。我选择一个学习率的中心点——通常为 0.001。然后我在中心点(例如,0.001)及其一个数量级更大(例如,0.01)和更小(0.0001)的位置进行实验。我查看三个运行之间的验证损失和准确性,并决定哪个方向会导致更好的收敛。

如果我有一个具有最低验证损失和最高验证准确率的运行,我将选择那个。有时一个运行上的较低验证损失不会导致更高的准确率。在这些情况下,我更多地依赖直觉,但倾向于倾向于基于最低验证损失做出决定。

然后,我在现有和更好的收敛点之间选择一个新的中心点。例如,如果中心和收敛点是 0.001 和 0.01,我选择 0.005 作为中心,并使用一个数量级更大(0.05)和更小(0.0005),然后重复实验。我重复这种分而治之的策略,直到中心点给我最佳的收敛,这成为粗调的初始学习率。我很有可能接近最优区域(灰色)。

调整批量大小

接下来,我调整批量大小。一般来说,对于小型数据集使用 32,对于大型数据集使用 256 代表最低水平。所以我将尝试更高的值。我使用 2 倍因子。例如,如果我的批量大小是 32,我将尝试使用粗略学习率的 64。如果收敛性有所改善,我将尝试 128,依此类推。当它没有改善时,我选择之前的好值。

微调初始学习率

在这一点上,我很有可能已经接近了最优区域(黑色)。批量大小越大,每批损失的变化就越小。因此,如果我们增加了批量大小,我们通常可以提高学习率。

考虑到较大的批量大小,我重复进行了学习率的调整实验,以粗略学习率作为初始中心点。

学习率调度

在这一点上,我开始一个完整的训练运行,当验证准确率停止提高时进行早期停止。我通常首先尝试在学习率上使用余弦退火(随后讨论)。如果那有显著的改进,我通常就停在那里。否则,我会回顾最初的完整运行,并找到验证准确率平顶或发散的时期。然后我设置一个学习率调度器,在该点之前的一个时期将学习率降低一个数量级。

这通常给我一个非常好的起点,我现在可以专注于其他预训练步骤,如增强和标签平滑(在第 10.4 节中讨论)。

10.2.2 网格搜索

网格 搜索 是超参数搜索的最古老形式。这意味着你在狭窄的搜索空间中搜索每一个可能的组合;这是对于新问题获得洞察力的固有的人类方法。这种方法只有少数参数和值时才是实用的。例如,如果我们有三个学习率值和两个批量大小,组合的数量将是 3 × 2 或 6,这是实用的。让我们稍微增加一下,增加到五个学习率值和三个批量大小。现在就是 5 × 3 或 15。哇,看看组合是如何快速增长的!

由于与整个搜索空间相比,(近)最优区域要小得多,我们不太可能早期就找到一个好的组合。

这种方法不再使用,因为它计算开销大。以下是一个网格搜索的示例实现。我在这里提出它,以便你可以在下一小节中将它与随机搜索进行比较。

在这个例子中,我们在两个超参数上进行网格搜索:学习率(lr)和批量大小(bs)。对于两者,我们指定要尝试的值集,例如学习率指定为[0.1, 0.01]。然后我们使用两个嵌套循环迭代器生成学习率和批量大小值集的所有组合。对于每个组合,我们获取模型预训练实例的副本(get_model())并对其进行几个 epoch 的训练。保持最佳验证分数及其对应的超参数组合的运行总计(best)。完成后,best元组包含导致最低验证损失的超参数设置。

best = (None, 0, 0, 0)
epochs = 5

for lr in [0.1, 0.01]:                                     ❶
    for bs in [32, 64]:
        model = get_model(lr)                              ❷

        result = model.fit(x_train, y_train, batch_size=bs, epochs=epochs,
                           validation_split=0.1)           ❸

        val_acc = result.history['val_acc'][epochs-1]      ❹
        if val_acc > best[1]:
            best = (model, val_acc, lr, bs)

❶ 对三个学习率和两个批量大小进行网格搜索

❷ 在编译模型时设置学习率

❸ 训练几个 epoch

❹ 使用验证准确率来选择最佳的学习率和批量大小组合

10.2.3 随机搜索

让我们转向随机搜索方法,这种方法在寻找好的超参数方面比网格搜索计算成本更低。你可能会问,随机搜索怎么可能比网格搜索计算成本更低(它只是随机的)?

为了回答这个问题,让我们回顾一下我们之前对超参数搜索空间的描述。我们知道其中只有一小部分有最优组合,所以我们随机找到它的概率非常低。但我们还知道,大量更大的区域是近最优的,所以我们使用随机搜索落在这些区域之一的概率大大提高。

一旦搜索找到一个近最优组合,我们就知道在附近很可能存在一个最优组合。在这个时候,我们将随机搜索缩小到围绕近最优组合的区域。如果新的组合提高了结果,我们可能会进一步缩小围绕新组合附近的随机搜索。

总结这些步骤:

  1. 设置搜索空间的边界。

  2. 在整个搜索空间内进行随机搜索。

  3. 一旦找到一个近最优组合,将搜索空间缩小到新组合的附近。

  4. 持续重复,直到找到一个满足你的目标标准的组合。

    • 如果新的组合提高了结果,进一步缩小围绕新组合的搜索空间。

    • 如果在预定义的试验次数后结果没有改善,则返回到搜索整个搜索空间(步骤 2)。

图 10.6 说明了前三个步骤。

图 10.6 超参数随机搜索

这里是前三个步骤的一个示例实现。在这段代码中,我们做了以下操作:

  1. 在整个搜索空间上运行五个试验。因为这个例子只有少数几种组合,通常五个试验就足够了。

  2. 选择一个随机组合的学习率(lr)和批量大小(bs)。

  3. 在模型的一个预训练实例上进行短时间训练。

  4. 维护最佳验证准确率和超参数组合(best)的累计记录。

  5. 从五个试验中选择最佳验证准确率作为接近最优的。

  6. 在接近最优的超参数(2X 和 1/2X )周围设置一个狭窄的搜索空间。

  7. 在狭窄的搜索空间内再运行五个试验。

from random import randint

learning_rates = [0.1, 0.01, 0.001, 0.0001]                            ❶
batch_sizes = [ 32, 128, 512]                                          ❶

trials = 5  
epochs = 3  

best = (None, 0, 0, 0)

for _ in range(trials):                                                ❷
    lr = learning_rates[randint(0, 3)]                                 ❸
    bs = batch_sizes[randint(0, 2)]                                    ❸
    model = get_model(lr)
    result = model.fit(x_train, y_train, epochs=epochs, batch_size=bs,  
                       validation_split=0.1, verbose=0)                ❹

    val_acc = result.history['val_acc'][epochs-1]                      ❺
    if val_acc > best[1]:                                              ❺
        best = (model, val_acc, lr, bs)                                ❺

learning_rates = [ best[2] / 2, best[2] * 2]                           ❻
batch_sizes = [best[3] // 2, int(best[3] * 2)]
for _ in range(trials):                                                ❼
    lr = learning_rates[randint(0, 1)]
    bs = batch_sizes[randint(0, 1)]
    model = get_model(lr)
    result = model.fit(x_train, y_train, epochs=epochs, batch_size=bs,  
                       validation_split=0.1, verbose=0)

    val_acc = result.history['val_acc'][epochs-1]
    if val_acc > best[1]:
        best = (model, val_acc, lr, bs)

❶ 如果我们进行网格搜索,我们将有 4 × 3 = 12 种组合。

❷ 步骤 1:第一轮试验,找到最佳接近最优组合

❸ 步骤 2:选择随机组合

❹ 步骤 3:为试验进行短时间训练

❺ 步骤 4 和 5:记录当前最佳结果

❻ 步骤 6:将搜索空间缩小到最佳接近最优的附近

❼ 步骤 7:在缩小后的搜索空间周围运行另一组试验

我在没有数值稳定化的情况下运行了这段代码,使用了 CIFAR-10 数据集。在完整搜索空间的前五个试验之后,最佳验证准确率为 0.352。在缩小搜索空间后,最佳验证准确率跃升至 0.487,学习率为 0.0002,批量大小为 64。

我然后重复了这个过程,但这次我在进行超参数搜索之前首先对模型进行了数值稳定化。在完整搜索空间的前五个试验之后,最佳验证准确率为 0.569。在缩小搜索空间后,最佳验证准确率跃升至 0.576,学习率为 0.1,批量大小为 512。哇,这真是太好了。而且我们还没有对学习率调度、正则化、增强和标签平滑进行调整!

接下来,我们将讨论如何使用自动超参数搜索工具 KerasTuner,它是一个 TF.Keras 的附加模块。你可能会问,为什么我们要学习手动方法,而不是直接使用自动方法?即使使用自动方法,你也需要引导搜索空间。使用手动方法可以帮助你获得引导搜索空间的专长。对我来说,以及研究人员来说,开发手动方法让我们对未来自动搜索的改进有了洞察。最后,你可能会发现,现成的自动方法并不适合你的专有数据集和模型,你可以通过你独特的学习方法来改进它。

10.2.4 KerasTuner

KerasTuner 是 TF.Keras 的一个附加模块,用于进行自动化超参数调整。它有两种方法:随机搜索和超参数搜索。为了简洁,本节将介绍随机搜索方法。了解这种方法将使您对在搜索空间稀疏且好的组合较少的情况下搜索超参数的整体方法有所了解。

注意,我建议您参考在线文档(keras-team.github.io/keras-tuner/)了解超参数调整,这是一种用于改进随机搜索时间的 bandit 算法方法。您可以在李莎·李等人的“Hyberband”中找到更多信息(arxiv.org/abs/1603.06560)。

像所有自动化工具一样,KerasTuner 既有优点也有缺点。自动化且使用起来相对简单显然是优点。对我来说,无法调整批量大小是一个很大的缺点,因为你最终不得不手动调整批量大小。

这是安装 KerasTuner 的pip命令:

pip install -U keras-tuner

要使用 KerasTuner,我们首先创建一个 tuner 实例。在以下示例中,我们创建了一个RandomSearch类的实例。这个实例化需要三个必需的参数:

  1. 可调整超参数(hp)模型

  2. 目标测量(例如,验证准确率)

  3. 训练试验的最大数量(实验)

from kerastuner.tuners import RandomSearch

tuner = RandomSearch(hp_model,               ❶
                     objective='val_acc',    ❷
                     max_trials=3)           ❸

❶ 获取可调整超参数的模型

❷ 用于比较(改进)的训练指标

❸ 训练试验次数

在这个例子中,为了演示目的,我将试验次数设置得较低(3 次)。最多尝试三种随机组合。根据你的搜索空间大小,你通常会使用更大的数字。这是一个权衡。试验次数越多,探索的搜索空间就越大,但所需的计算成本(时间)也越高。

接下来,我们创建一个函数来实例化一个可调整超参数的模型。该函数接受一个参数,表示为hp。这是一个由 KerasTuner 传入的超参数控制变量。

在我们的例子中,我们将仅调整学习率。我们首先获取我们模型的一个数值稳定的版本实例,正如我之前推荐的那样。然后,我们使用compile()方法中的optimizer参数设置实例的学习率。在我们的例子中,我们将使用超参数调整器(hp)控制方法hp.Choice()指定四个学习率的选择。这告诉调整器要搜索的参数值集合。在这种情况下,我们将选择设置为[1e-1, 1e-2, 1e-3, 1e-4]

def hp_model(hp):
    ''' hp is passed in by the tuner '''
    model = tf.keras.models.load_model('numeric')                             ❶

    model.compile(loss='sparse_categorical_crossentropy', metrics=['acc'],    ❷
                  optimizer=Adam(hp.Choice('learning_rate',  
                                           values=[1e-1, 1e-2, 1e-3, 1e-4]))) ❸
    return model

❶ 加载已保存(在磁盘上)的模型

❷ 重新编译模型以重置学习率

❸ 将学习率设置为可调整的参数

接下来,我们准备进行超参数调整。我们使用tunersearch()方法开始搜索。该方法接受与 Keras 模型fit()方法相同的参数。注意,在search()中明确指定了批大小,因此它不是自动可调的。在我们的例子中,我们的训练数据是 CIFAR-10 训练数据:

tuner.search(x_train, y_train, batch_size=32, validation_data=(x_test, y_test))

现在是结果!首先,使用results_summary()方法查看试验的摘要:

tuner.results_summary()

这里是输出,它显示 0.1 是最佳学习率:

Results summary
|-Results in ./untitled_project
|-Showing 10 best trials
|-Objective(name='val_acc', direction='max')
Trial summary
|-Trial ID: 0963640822565bfc03280657d5350d26
|-Score: 0.4927000105381012
|-Best step: 0
Hyperparameters:
|-learning_rate: 0.0001
Trial summary
|-Trial ID: 9c6ed7a1276c55a921eaf1d3f528d64d
|-Score: 0.28610000014305115
|-Best step: 0
Hyperparameters:
|-learning_rate: 0.01
Trial summary
|-Trial ID: d269858c936c2b6a2941e66f880304c7
|-Score: 0.10599999874830246
|-Best step: 0
Hyperparameters:
|-learning_rate: 0.1      ❶

❶ 选定的最佳学习率

然后,你使用get_best_models()方法来获取相应的模型。此方法根据参数num_models按降序返回最佳模型列表。在这种情况下,我们只想得到最佳的一个,所以我们将它设置为 1。

models = tuner.get_best_models(num_models=1)
model = models[0]

最后,你的结果和模型存储在一个文件夹中,可以在实例化tuner时通过参数project_name指定。如果没有指定,文件夹名称默认为untitled_project。为了清理试验后的文件夹,你会删除这个文件夹。

10.3 学习率调度器

到目前为止,在我们的示例中,我们一直在整个训练过程中保持学习率不变。你可以用恒定的学习率得到好的结果,但它不如在训练过程中调整学习率有效。

通常,在训练过程中,你会从较大的学习率逐渐降低到较小的学习率。最初,你希望尽可能开始使用较大的学习率,而不引起数值不稳定性。较大的学习率允许优化器探索不同的路径(局部最优解),并在最小化损失方面取得一些初始的大幅收益,从而加快训练速度。

但一旦我们朝着良好的局部最优解取得良好进展,如果我们继续使用高学习率,我们可能会开始来回震荡,无法收敛,或者无意中跳出良好的局部最优解,开始向较差的局部最优解收敛。

因此,随着我们接近收敛,我们开始降低学习率,以采取越来越小的步骤,这样就不会震荡,并找到局部最优解中的最佳路径以收敛。

那么,“学习率调度器”这个术语是什么意思呢?这意味着我们将有一个方法来监控训练过程,并根据一定的条件对学习率进行调整,以找到并收敛到最佳或近似的局部最优解。在本节中,我们将介绍几种常见的方法:包括时间衰减、斜坡、常数步长和余弦退火。我们将从描述时间衰减方法开始,这是 TF.Keras 优化器集内置的方法,用于在训练过程中逐步降低学习率。

10.3.1 Keras 衰减参数

TF.Keras 优化器支持使用decay参数逐步降低学习率。优化器使用时间衰减方法。时间衰减的数学公式如下,其中lr是学习率,k是衰减系数,t是迭代次数(例如,epochs):

lr = lr 0 / (1 + kt)

在 TF.Keras 中,时间衰减的实现方式如下:

lr = lr × (1.0 / (1.0 + decay × iterations))

以下是在compile()方法中指定优化器时设置学习率时间衰减的示例:

model.compile(optimizer=SGD(lr=0.1, decay=1e-3))

表 10.1 显示了使用先前设置的前 10 个 epochs 的学习率进度;典型的衰减值在 1e-3 和 1e-6 之间。

表 10.1 学习率随 epoch 的衰减进度

迭代(epoch) 学习率
1 0.0999
2 0.0997
3 0.0994
4 0.0990
5 0.0985
6 0.0979
7 0.0972
8 0.0964
9 0.0955
10 0.0945

10.3.2 Keras 学习率调度器

如果使用时间衰减没有产生最佳结果,你可以使用LearningRateScheduler回调函数实现自己的自定义方法来逐步降低学习率。在生产环境中,ML 团队随着时间的推移进行实验并找到自定义调整,使训练更加高效,并在目标上产生更好的结果,例如在生产部署时的分类准确率。

以下代码是一个示例实现,其步骤在此概述:

  1. 定义我们的学习率调度器回调函数。

  2. 在训练过程中(通过fit()方法),传递给回调函数的参数是当前 epoch 计数(epoch)和学习率(lr)。

  3. 对于第一个 epoch,返回当前的(初始)学习值。

  4. 否则,实现一个逐步降低学习率的方法。

  5. 实例化一个用于学习率调度器的回调函数。

  6. 将回调函数传递给fit()方法。

from tensorflow.keras.callbacks import LearningRateScheduler

def lr_scheduler(epoch, lr):
    ''' Set the learning rate at the beginning of epoch
       epoch: The epoch count (first epoch is zero)
       lr:  The current learning rate
    '''
    if epoch == 0:                                                        ❶
        return lr

      return n_lr                                                         ❷

model.compile(loss='categorical_crossentropy', optimizer=Adam(lr=0.01))   ❸

lr_callback = LearningRateScheduler(lr_scheduler)                         ❹

model.fit(x_train, y_train, epochs=epochs, batch_size=batch_size,  
          callbacks=[lr_callback])                                        ❺

❶ 步骤 3:对于第一个(0)epoch,从初始学习率开始

❷ 步骤 4:添加你的逐步降低学习率的实现

❸ 步骤 1:设置初始学习率

❹ 步骤 5:创建学习率调度器的回调函数

❺ 步骤 2 和 6:为训练启用学习率调度器

10.3.3 渐增

因此,你已经完成了数值稳定性的预训练步骤和批量大小以及初始学习率的超参数调整。现在你准备好实现你的学习率调度器算法了。通常,你可以使用一个渐增算法来实现这一点,该算法在指定数量的 epochs 后重置学习率。通常,在这个阶段,我会进行一次扩展的训练运行。我通常从 50 个 epochs 开始,并在评估损失上设置一个提前停止条件(patience为 2)。无论数据集如何,我通常会看到两种情况之一:

  • 在最后(50)个 epochs 中,评估损失保持稳定和一致地减少。

  • 在最后一个时期之前,验证损失出现平台期,并且提前停止已经启动。

如果我看到验证损失持续减少,我将继续重复额外的 50 个时期,直到出现提前停止。

一旦我设置了提前停止,我会查看它发生在哪个时期。比如说,它发生在第 40 个时期。然后我会减去几个时期,通常是 5 个(在这种情况下,结果是 35)。然后我将我的学习率调度器硬编码为在该时期降低一个数量级的学习率。几乎 100%的情况下,我的训练会改善到更低的验证损失和更高的验证准确率。图 10.7 显示了降低的学习率。

图片

图 10.7 降低的学习率

以下是一个斜坡学习率调度器的示例实现:

epoch_ramp = 35                  ❶

def lr_scheduler(epoch, lr):
    if epoch == epoch_ramp:      ❷
        return lr / 10.0         ❷
    return lr

❶ 设置降低一个数量级的时期

❷ 在斜坡时期降低学习率一个数量级

这通常不是我的最后一步,而是我用它来了解这个数据集的损失地形可能是什么样子。从那以后,我计划我的完整训练学习率调度器。在这个层面上,解释损失地形会太具有挑战性。相反,我将介绍你可以尝试的各种学习率调度器策略。

10.3.4 恒定步长

在恒定步长方法中,我们希望在最后一个时期内以等量递增的方式从初始学习率到零。这个方法很简单。你将初始学习率除以时期数。图 10.8 展示了这个方法。

图片

图 10.8 恒定步长学习率

这是一个学习率调度器的步长方法的示例实现:

epochs = 200            ❶
lr = 0.001              ❷
step = lr / epochs      ❸

def lr_scheduler(epochs, lr):
    ''' step learning rate '''
    return lr - step

❶ 训练的时期数

❷ 由超参数调整确定的初始学习率

❸ 每个时期后步长衰减的大小

10.3.5 余弦退火

余弦退火方法在研究人员中很受欢迎,在关于消融研究的学术论文中经常出现。它也被称为循环学习率。这里的理念是,不是在训练过程中逐渐降低学习率,而是在周期中这样做。

更简单地来说,我们从初始学习率开始,逐渐降低到一个较低的学习率,然后我们再次逐渐提高它。我们持续重复这个循环,但每次循环开始时的速率(高)和结束时的速率(低)都更低——因此我们在循环中仍然在向更低的方向进步。

那么,它的优势是什么?它提供了定期探索其他局部最优解(跳出)和逃离鞍点的机会。对于局部最优解,它就像进行一次梁搜索。训练过程可能会跳出当前的局部最优解,并开始深入另一个。虽然一开始没有任何迹象表明新的局部最优解更好,但最终会是这样。原因如下。随着训练的进行,我们将深入到比较差的局部最优解更好的局部最优解。随着学习率的下降,我们跳出好的局部最优解的可能性越来越小。另一种思考这种周期性行为的方式是探索与利用:在周期的较高端,训练正在探索新的路径,而在较低端,它正在利用好的路径。随着训练的进展,我们逐渐减少探索,增加利用。

另一个优势是,在我们使用学习率周期的低端深入挖掘后,我们可能会卡在鞍点上。让我们使用以下图表来帮助理解鞍点是什么。

如果我们的特征(自变量)与标签(因变量)之间存在线性关系,一旦我们发现了变化率,我们就会深入到全局最优解,无论学习率如何(如图中第一条曲线所示)。

另一方面,如果关系是多项式的,我们将看到更像是凸曲线的东西,全局最优解作为曲线的最低点。原则上,只要我们持续降低学习率,我们就会下降到最低点,避免在曲线两侧来回弹跳(如图中第二条曲线所示)。

但深度学习的力量在于特征与标签之间存在非线性(和非多项式)关系(如图中第三条曲线所示)。在这种情况下,考虑损失空间由山谷、山峰和鞍点组成,一个山谷是全局最优解。我们的目标当然是找到这个山谷,这就是探索多个局部最优解(山谷)的优势。

鞍点是在山谷中具有平台的部分;它在继续下降之前变得平坦。如果我们的学习率非常低,我们将在平台上无休止地弹跳。因此,虽然我们希望在训练接近结束时拥有那个很小的学习率,但我们希望它偶尔上升,以推动我们在下降到最低点时离开鞍点。

图 10.9 对比了线性/多项式与非线性关系之间的损失表面,显示了峰值、谷值和平台——这些可以成为鞍点。

图像

图 10.9 梯度下降和学习率变化率斜率

当使用余弦衰减与早停结合时,我们必须重新思考停止的目标(验证准确率)。如果我们使用非周期性衰减进行训练,我们可能会在停止前使用非常小的阈值差异。但是,由于周期性行为,我们在探索(周期的末端)时可能会看到差异的突然激增(验证损失增加)。因此,我们需要为早停使用更大的差距。另一种选择是使用自定义早停,随着周期末端的降低,逐渐减小差异。

以下是一个使用余弦衰减的学习率调度器的示例实现。该函数有点复杂。我们使用余弦函数np.cos()从 0 到 1 生成正弦波。例如,余弦(π)是-1,余弦(2π)是 1,所以传递给np.cos()的值的计算是π的倍数。这样,值就是正的,计算中加 1,结果现在将在 0 到 2 的范围内。然后,该值减半(0.5 倍),所以结果现在将在 0 到 1 的范围内。然后,衰减通过 alpha 调整,它设置最小学习率的下限。

def cosine_decay(epoch, lr, alpha=0.0):
        """ Cosine Decay
        """
        cosine_decay = 0.5 * (1 + np.cos(np.pi * (e_steps * epoch) / t_steps)) ❶
        decayed = (1 - alpha) * cosine_decay + alpha                           ❷
        return lr * decayed                                                    ❸

def lr_scheduler(epochs, lr):
    ''' cosine annealing learning rate '''
    return cosine_decay(epochs, lr)                                            ❹

❶ 计算介于 0 和 2 之间的余弦值并减半

❷ 通过 alpha 调整值

❸ 返回衰减后的学习率

❹ 将学习率调度器回调连接到余弦衰减函数

在 TF 2.x 中,余弦衰减被添加为内置的学习率调度器:

from tf.keras.experimental import CosineDecay                       ❶

lrate = CosineDecay(initial_learning_rate, decay_steps, alpha)      ❷

model.fit(x_train, y_train, epochs=epochs, batch_size=batch_size,  
          callbacks=[lrate])                                        ❸

❶ 导入 CosineDecay 内置学习率调度器

❷ 实例化 CosineDecay 学习率调度器

❸ 在训练期间将学习率调度器作为回调添加

10.4 正则化

下一个重要的超参数是正则化。这指的是向训练中添加噪声的方法,使得模型不会记住训练数据。我们可以延迟记忆的时间越长,在预测未训练数据(如测试(保留)数据)时获得更高模型准确率的机会就越好。

让我们更简单地重申这一点。我们希望模型学习基本特征(泛化),而不是数据(记忆)。

关于正则化中的 dropout 的说明:现在没有人那样做了;这是古老的。

10.4.1 权重正则化

目前最广泛使用的正则化形式是权重正则化,也称为权重衰减。权重正则化是按层应用。其目的是在反向传播中向权重更新添加与权重大小相关的噪声。这种噪声通常被称为惩罚,权重较大的层比权重较小的层有更大的惩罚。

不深入探讨梯度下降和反向传播,可以说损失计算是更新每一层权重计算的一部分。例如,在回归器模型中,我们通常使用均方误差来表示预测值 (ŷ) 和实际(真实 – y)值之间的损失,可以表示如下:

损失函数 = MSE(ŷ, y)

为了为每一层添加噪声,我们希望按权重大小比例添加一小部分作为惩罚:

损失函数 = MSE(ŷ, y) + penalty

惩罚 = decay × R(w)

在这里,decay 是权重衰减,其值 << 1。而 R(w) 是应用于该层权重 w 的正则化函数。TF.Keras 支持以下正则化函数:

  • L1—绝对权重的总和,也称为 Lasso 正则化

  • L2—平方权重的总和,也称为 Ridge 正则化

  • L1L2—绝对和平方权重的总和,也称为 Elastic Net 正则化

现代 SOTA 研究论文中引用的消融研究使用 L2 权重正则化,其值范围在 0.0005 到 0.001 之间。根据我的经验,我发现 0.001 以上的值在权重正则化上过于激进,并且训练无法收敛。

在 TF.Keras 中,使用关键字参数 kernel_regularizer 来设置每层的权重正则化。如果您使用它,您应该在所有具有学习参数的层上指定它(例如,Conv2DDense)。以下是一个为卷积层(Conv2D)指定L2权重衰减正则化的示例实现:

from tensorflow.keras.regularizers import L2

inputs = Input((128, 128, 3))
x = Conv2D(16, (3, 3), strides=(1, 1), kernel_regularizer=L2(0.001))(inputs)

10.4.2 标签平滑

标签 平滑方法从不同的角度进行正则化。到目前为止,我们讨论了添加噪声以防止记忆化的技术,从而使模型能够泛化到模型在训练期间未见过的同一分布内的示例。

然而,我们发现,即使我们惩罚这些权重更新以防止记忆化,这些模型在预测上往往过于自信(高概率值)。

当模型过于自信时,真实标签和非真实标签之间的距离可以有很大差异。当绘制时,它更倾向于看起来像散点图而不是簇;如果真实标签聚集在一起,即使置信度较低,这也是更理想的情况。图 10.10 展示了使用硬目标标签的过于自信的模型。

图 10.10 训练时作为独热编码标签(0 或 1)的标签

标签平滑通过使预测不那么自信来帮助模型泛化,这导致真实标签和非真实标签之间的距离聚集在一起。

在标签平滑中,我们将 one-hot 编码的标签(真实值)从绝对确定性(1 和 0)调整为小于绝对确定性,用α(阿尔法)表示。例如,对于真实标签,我们不是将其值设置为 1(100%),而是将其设置为略低一些的值,比如 0.9(90%),然后将所有非真实值从 0(0%)调整到降低真实标签的相同数量(例如,10%)。

图 10.11 说明了标签平滑。在这个描述中,将输出密集层的预测与标签平滑后的真实标签进行比较,称为软目标。损失是从软目标而不是硬目标计算出来的,这在实践中已被证明可以使真实值和非真实值之间的距离更加一致。这些距离更有可能形成簇,这有助于模型更加泛化。

图片

图 10.11 标签平滑作为当标签小于绝对确定性时的软目标

在 TF 2.x 中,标签平滑内置在损失函数中。要使用它,显式实例化相应的损失函数并设置关键字参数label_smoothing。在实践中,α因子保持较小,0.1 是最常用的值。

from tensorflow.keras.losses import CategoricalCrossentropy

model.compile(loss=CategoricalCrossentropy(label_smoothing=0.1),   ❶
              optimizer='adam', metrics=['acc'])                   ❶

❶ 在编译模型时设置标签平滑

接下来,我们将总结我们在超参数方面所涵盖的内容,以及它们如何影响在训练时间和目标(例如,准确率)方面实现最佳结果。

10.5 超越计算机视觉

所有深度学习模型架构,无论数据类型或领域,都有可调整的超参数。调整它们的策略是相同的。无论你是在处理计算机视觉、自然语言理解还是结构化数据,深度学习领域的四大超参数都存在:学习率、学习率衰减、批量大小和正则化。

正则化的超参数在模型架构和不同领域之间可能类型不同。很多时候它们并不不同。例如,权重衰减可以应用于任何具有可学习权重的层,无论它是计算机视觉、NLU 还是结构化数据模型。

一些模型架构,如深度神经网络和提升树,有一些历史上独特的超参数。例如,对于 DNN,你可能看到调整层数和每层的单元数。对于提升树,你可能看到调整树的数量和叶子数。但是,由于超参数(用于训练模型)和元参数(用于配置模型架构)的划分,这些可调整的参数现在被称为元参数。因此,如果你在深度神经网络中同时调整层数和单元数以及学习率,实际上你正在进行宏观架构搜索和超参数调整的并行操作。

摘要

  • 不同的权重分布和抽取会影响训练过程中的收敛性。

  • 在搜索最佳权重初始化(抽签原则)与学习最佳权重初始化(预热)之间的区别在于,模型学习最佳初始化而不是通过经验找到它。

  • 当数据集较小时,使用手动方法进行超参数搜索是最佳选择,但其缺点是您可能会忽略在训练过程中实现更好结果的超参数值。

  • 网格搜索用于小搜索空间,而在大搜索空间中进行超参数调整时,随机搜索效率更高。

  • 使用 KerasTuner 进行超参数搜索可以自动化搜索过程,但其缺点是您无法手动引导搜索。

  • 用于学习率衰减的各种算法包括时间衰减、恒定步长、斜坡步长和余弦退火。

  • 设置学习率调度器涉及定义回调函数,在回调函数中实现自定义学习率算法,并将回调函数添加到fit()方法中。

  • 常规的正则化方法包括权重衰减和标签平滑。

11 迁移学习

本章涵盖

  • 使用 TF.Keras 和 TensorFlow Hub 中的预构建和预训练模型

  • 在类似和不同领域之间执行任务迁移学习

  • 使用特定领域权重初始化迁移学习模型

  • 确定何时重用高维或低维潜在空间

TensorFlow 和 TF.Keras 支持广泛的预构建和预训练模型。预训练模型可以直接使用,而预构建模型则可以从零开始训练。通过替换任务组,预训练模型也可以重新配置以执行任何数量的任务。用重新训练替换或重新配置任务组的过程称为迁移学习

从本质上讲,迁移学习意味着将解决一个任务的知识迁移到解决另一个任务。与从头开始训练模型相比,迁移学习的优势是新的任务可以更快地训练,并且需要的数据更少。把它看作是一种重用:我们正在重用带有其学习权重的模型。

你可能会问,我能否将一个模型架构学习到的权重重用于另一个模型?不,两个模型必须是相同的架构,例如 ResNet50 到 ResNet50。另一个常见的问题是:我能否将学习到的权重重用于任何不同的任务?你可以,但结果将取决于预训练模型的领域和新数据集之间的相似程度。所以我们真正所说的学习权重是指学习到的基本特征、相应的特征提取和潜在空间表示——表示学习。

让我们看看几个例子,看看迁移学习是否会产生期望的结果。假设我们有一个针对水果种类和品种的预训练模型,我们还有一个针对蔬菜种类和品种的新数据集。高度可能的是,水果的学习表示可以用于蔬菜,我们只需要训练任务组。但如果我们的新数据集包括卡车和面包车的型号和制造商。在这种情况下,数据集领域之间的差异非常大,水果学习到的表示不太可能用于卡车和面包车。在类似领域的情况下,我们希望新模型执行的任务在领域上与原始模型训练的数据相似。

另一种学习表示的方法是使用在大量不同图像类别上训练的模型。许多 AI 公司提供这种类型的迁移学习服务。通常,他们的预训练模型是在数万个图像类别上训练的。这里的假设是,由于这种广泛的多样性,学习到的表示中的一部分可以在任何任意新的数据集上重用。缺点是,为了覆盖如此广泛的多样性,潜在空间必须非常大——因此你最终得到的是一个在任务组中非常大的模型(过参数化)。

第三种方法是在参数高效、窄域训练模型和大规模训练模型之间找到一个合适的平衡点。例如,ResNet50 和更近期的 EffcientNet-B7 都是使用包含 1000 个不同类别图像的 ImageNet 数据集进行预训练的。DIY 迁移学习项目通常使用这些模型。例如,ResNet50 具有合理高效的潜在空间,但足够大,可以在任务组件之前用于迁移学习到各种图像分类数据集;潜在空间由 2048 个 4×4 特征图组成。

让我们总结这三种方法:

  • 相似领域迁移:

    • 参数高效、窄域预训练模型

    • 重新训练新的任务组件

  • 不同领域迁移:

    • 参数过剩、窄域预训练模型

    • 使用其他组件的微调重新训练新的任务组件

  • 通用迁移

    • 参数过剩、通用领域预训练模型

    • 重新训练新的任务组件

预训练模型也可以在迁移学习中重复使用,以从预训练模型学习不同类型的任务。例如,假设我们有一个预训练模型,它可以从房屋前外部的图片中分类建筑风格。现在假设我们想要学习预测房屋的售价。很可能,基本特征、特征提取和潜在空间会转移到不同类型的任务上,例如回归器——一个输出单个实数的模型(例如,房屋的售价)。如果其他任务类型也可以使用原始数据集进行训练,那么这种将迁移学习应用于其他任务类型通常是可能的。

本章介绍了从公共资源中获取预构建和预训练的 SOTA 模型:TF.Keras 和 TensorFlow Hub。然后我将向您展示如何直接使用这些模型。最后,您将学习各种使用预训练模型进行迁移学习的方法。

11.1 TF.Keras 预构建模型

TF.Keras 框架附带预构建模型,您可以使用它们直接训练新模型,或者修改和/或微调以进行迁移学习。这些模型基于图像分类的最佳模型,在 ImageNet 等竞赛中获奖的模型,这些模型在深度学习研究论文中被频繁引用。

预构建 Keras 模型的文档可以在 Keras 网站上找到(keras.io/api/applications/)。表 11.1 列出了 Keras 预构建模型架构。

表 11.1 Keras 预构建模型

模型类型 SOTA 模型架构
顺序 CNN VGG16, VGG19
残差 CNN ResNet, ResNet v2
宽残差 CNN ResNeXt, Inception v3, InceptionResNet v2
交替连接的 CNN DenseNet, Xception, NASNet
移动 CNN MobileNet, MobileNet v2

预构建的 Keras 模型是从keras.applications模块导入的。以下是可以导入的预构建 SOTA 模型的示例。例如,如果您想使用 VGG16,只需将 VGG19 替换为 VGG16 即可。一些模型架构可以选择不同数量的层,例如 VGG、ResNet、ResNeXt 和 DenseNet。

from tensorflow.keras.applications import VGG19
from tensorflow.keras.applications import ResNet50
from tensorflow.keras.applications import InceptionV3
from tensorflow.keras.applications import InceptionResNetV2
from tensorflow.keras.applications import DenseNet121
from tensorflow.keras.applications import DenseNet169
from tensorflow.keras.applications import DenseNet201
from tensorflow.keras.applications import Xception
from tensorflow.keras.applications import NASNetLarge
from tensorflow.keras.applications import NASNetMobile
from tensorflow.keras.applications import MobileNet

11.1.1 基础模型

默认情况下,TF.Keras 预构建模型是完整的但未训练的,这意味着权重和偏差是随机初始化的。每个未训练的预构建 CNN 模型都针对特定的输入形状(见文档)和输出类数量进行配置。在大多数情况下,输入形状是(224, 224, 3)或(299, 299, 3)。模型还将以通道优先的格式接收输入,例如(3, 224, 224)和(3, 299, 299)。输出类数量通常是 1000,这意味着模型可以识别 1000 个常见的图像标签。这些预构建但未训练的模型本身可能对您不太有用,因为您必须在一个具有相同数量标签(1000)的数据集上完全训练它们。了解这些预构建模型的内容很重要,这样您就可以使用预训练的权重、新的任务组件或两者结合来重新配置。我们将在本章中涵盖所有三种后续的重新配置。

图 11.1 展示了预构建 CNN 模型的架构。该架构包括为输入形状预设的茎卷积组、一个用于更多卷积组(学习者)的预设、瓶颈层以及预设为 1000 个类别的分类器层。

图片

列表 11.1 以深灰色显示任务组层的预构建 CNN 模型架构

预构建模型没有分配损失函数和优化器。在使用它们之前,我们必须发出compile()方法来分配损失、优化器和性能度量。在下面的代码示例中,我们首先导入并实例化一个 ResNet50 预构建模型,然后编译模型:

from tensorflow.keras.applications import ResNet50

model = ResNet50()                                               ❶

model.compile(loss='categorical_crossentropy', optimizer='adam', 
              metrics=['accuracy'])                              ❷

❶ 获取一个完整且未训练的预构建 ResNet50 模型

❷ 将模型编译为用于数据集的分类器

以这种方式使用预构建模型相当有限,不仅因为输入大小固定,而且分类器的类别数量也是固定的,即 1000。您需要完成的任何任务很可能不会使用默认配置。接下来,我们将探讨配置预构建模型以执行各种任务的方法。

11.1.2 用于预测的预训练 ImageNet 模型

所有预构建的模型都附带从ImageNet 2012数据集预训练的权重和偏差,该数据集包含 1000 个类别中的 120 万张图像。如果你的需求仅仅是预测图像是否在 ImageNet 数据集的 1000 个类别中,你可以直接使用预训练的预构建模型。标签标识符到类名的映射可以在 GitHub 上找到(gist.github.com/yrevar/942d3a0ac09ec9e5eb3a)。类别的例子包括秃鹰、卫生纸、草莓和气球等。

让我们使用预训练的 ResNet 模型,该模型使用 ImageNet 权重进行预训练,来对大象的图像进行分类(或预测)。以下是步骤,一步一步来:

  1. preprocess_input()方法将根据预构建的 ResNet 模型使用的方法对图像进行预处理。

  2. decode_predictions()方法将标签标识符映射回类名。

  3. 使用 ImageNet 权重实例化预构建的 ResNet 模型。

  4. 使用 OpenCV 读取大象的图像,并将其调整大小为(224, 224)以适应模型的输入形状。

  5. 然后使用模型的preprocessed_input()方法对图像进行预处理。

  6. 然后将图像重塑为一批。

  7. 然后使用predict()方法通过模型对图像进行分类。

  8. 然后使用decode_predictions()将前三个预测标签映射到其类名,并打印出来。在这个例子中,我们可能会看到非洲象作为最高预测。

图 11.2 展示了 TF.Keras 预训练模型及其伴随的预处理输入和后处理输出函数。

图片

列表 11.2 TF.Keras 预训练模型及其伴随的预处理输入和后处理输出特定函数

现在我们来看看如何编写这个过程:

from tensorflow.keras.applications import ResNet50
from tensorflow.keras.applications.resnet import preprocess_input, 
                                                 decode_predictions

model = ResNet50(weights='imagenet')                        ❶

image = cv2.imread('elephant.jpg', cv2.IMREAD_COLOR)        ❷

image = cv2.resize(image, (224, 224), cv2.INTER_LINEAR)     ❸

image = preprocess_input(image)                             ❹

image = image.reshape((-1, 224, 224, 3))                    ❺

predictions = model.predict(image)                          ❻

print(decode_predictions(predictions, top=3))               ❼

❶ 获取在 ImageNet 上预训练的 ResNet50 模型

❷ 读取图像并将其作为 NumPy 数组预测到内存中

❸ 将图像调整大小以适应预训练模型的输入形状

❹ 使用与预训练模型相同的图像处理方法对图像进行预处理

❺ 将单个图像形状(224, 224, 3)重塑为单个图像的一批(1, 224, 224, 3)以供 predict()方法使用

❻ 调用 predict()方法对图像进行分类

❽ 使用预训练模型的解码函数根据预测标签显示类名

11.1.3 新的分类器

在所有预构建模型中,可以移除最终的分类器层并替换为新的分类器——以及另一个任务,如回归器。然后,可以使用新的分类器来训练预构建模型以适应新的数据集和类别集。例如,如果你有一个包含 20 种面条菜肴的数据集,你只需移除现有的分类器层,用新的 20 节点分类器层替换它,编译模型,并用面条菜肴数据集进行训练。

在所有预构建的模型中,分类器层被称为顶层。对于 TF.Keras 预构建模型,输入形状默认为(224, 224, 3),输出层的类别数为 1000。当你实例化一个 TF.Keras 预构建模型时,你会设置参数include_topFalse以获取一个不带分类器层的模型实例。另外,当include_top=False时,我们可以使用参数input_shape指定模型的不同输入形状。

现在我们来描述这个流程及其在我们 20 种面条菜品分类器中的应用。假设你拥有一家面条餐厅,厨师们不断地将各种新鲜烹制的面条菜品放在点餐柜台上。顾客可以挑选任何菜品,为了简化起见,让我们假设所有面条菜品的价格相同。收银员只需要计算面条菜品的数量。但你仍然有一些问题需要解决。有时你的厨师准备过多的一种或多种菜品,这些菜品变凉后不得不丢弃,因此你损失了收入。其他时候,你的厨师准备得太少的一种或多种菜品,顾客因为他们的菜品不可用而去了另一家餐厅——这是一个机会损失的情况。

为了解决这两个问题,你计划在结账处放置一个摄像头,并在丢弃冷面条菜品的烹饪区域放置另一个摄像头。你希望摄像头能够实时分类购买的面条菜品和丢弃的菜品,并将这些信息显示给厨师,以便他们更好地估计需要准备哪些菜品。

让我们开始实施你的计划。首先,因为你是一家现有的面条餐厅,你雇佣了一个人来拍摄放在点餐柜台上的菜品照片。当拍照时,厨师会喊出菜品的名字,这个名字会与照片一起记录。假设在一天的业务结束时,你的面条菜品数量为 500 种。假设菜品的分布相当均匀,这将给你平均每种面条菜品 25 张照片。这可能看起来每个类别的数量很少,但既然它们是你的菜品,背景总是相同的,这可能是足够的。现在你只需要从音频录音中标记照片。

现在你已经准备好进行训练了。你从 TF.Keras 获取一个预构建的模型,并指定include_top=False以删除 1000 类分类器的密集层——你将随后用 20 节点的密集层替换它。因为你移动很多面条菜品,所以你希望模型预测速度快,因此你想要减少参数数量,同时不影响模型的准确性。你不再从(224, 224, 3)大小的 ImageNet 进行预测,而是指定input_shape=(100, 100, 3)以改变模型的输入向量大小为(100, 100, 3)。

我们也可以在预构建模型中删除最终的展平/池化层(瓶颈层),通过设置参数 pooling=None 来替换成你自己的。

图 11.3 描述了一个可重构的预构建 CNN 模型架构。它由一个可配置输入大小的茎卷积组、一个或多个卷积组(学习器)以及可选的可配置瓶颈层组成。

列表 11.3 在这个没有分类器层的可重构预构建模型架构中,保留池化层是可选的。

至于输入形状,预构建模型的文档对最小输入形状大小有限制。对于大多数模型,这是 (32, 32, 3)。我通常不建议以这种方式使用预构建模型,因为对于这些架构中的大多数,全局平均池化层(瓶颈层)之前的最终特征图将是 1 × 1(单像素)特征图——本质上丢失了所有空间关系。然而,研究人员发现,当与 CIFAR-10 和 CIFAR-100(32, 32, 3)图像一起使用时,他们能够在进入竞赛级(如 ImageNet)图像数据集(224, 224, 3)之前找到良好的超参数设置。

在下面的代码中,我们实例化了一个预构建的 ResNet50 模型,并用一个新的分类器替换了它,用于我们的 20 种面条菜肴示例:

  1. 我们使用参数 include_top=False 移除了现有的 1000 个节点的分类器。

  2. 我们使用参数 input_shape 将输入形状设置为 (100, 100, 3),以适应较小的输入尺寸。

  3. 我们决定保留最终的池化/展平层(瓶颈层),将其作为全局平均池化层,参数为 pooling

  4. 我们添加了一个替换的密集层,包含 20 个节点,对应于面条菜肴的数量,以及一个 softmax 激活函数作为顶层。

    • 预构建 ResNet50 模型的最后一个(输出)层是 model.output。这对应于瓶颈层,因为我们删除了默认的分类器。

    • 我们将预构建 ResNet50 的 model.output 绑定为替换密集层的输入。

  5. 我们构建了模型。输入是 ResNet 模型的输入,即 models.input

  6. 最后,我们编译模型以进行训练,并将损失函数设置为 categorical_crossentropy,优化器设置为 adam,这是图像分类模型的最佳实践。

from tensorflow.keras.applications import ResNet50
from tensorflow.keras.layers import Dense

model = ResNet50(include_top=False, input_shape=(100, 100, 3), pooling='avg') ❶

outputs = Dense(20, activation='softmax')(model.output)                       ❷
model = Model(model.input, outputs)

model.compile(loss='categorical_crossentropy', optimizer='adam', 
              metrics=['accuracy'])                                           ❸

❶ 获取输入形状为 (100,100,3) 且没有最终分类器的预构建模型

❷ 添加了 20 个类别的分类器

❸ 编译模型以进行训练

对于大多数 TF.Keras 预构建模型,瓶颈层是一个全局平均池化层。这个层既作为特征图的最终池化层,又作为一个展平操作,将特征图转换为 1D 向量。在某些情况下,我们可能想用我们自己的自定义最终池化/展平层替换这个层。在这种情况下,我们要么指定参数 pooling=None,要么不指定它,这是默认设置。那么我们为什么要这样做呢?

为了回答这个问题,让我们回到我们的面条菜肴。假设当您训练模型时,您得到了 92%的准确率,并希望做得更好。首先,您决定添加图像增强。嗯,我们可能不会考虑水平翻转,因为面条菜肴永远不会被倒着看到!同样,垂直翻转可能也不会有帮助,因为面条碗相当均匀(没有镜像)。我们可以跳过旋转,因为面条碗相当均匀,我们跳过缩放,因为相机到菜肴的位置是固定的。嗯,所以您问,还有什么?

关于移动碗的位置怎么样,因为碗在结账和扔掉柜台时都会移动?您这样做并得到了 94%的准确率。但您希望更高的准确率。凭直觉,我们推测可能特征信息保留得不够,当每个最终特征图通过默认的 GlobalAveragePooling2D 池化减少到一个像素,然后展平成一个 1D 向量时。您查看您的模型摘要,看到最终特征图的大小是 4 × 4。因此,您决定取消默认池化,并用步长为 2 的 MaxPooling2D 替换它,这样每个特征图将减少到 2 × 2,4 个像素而不是一个像素,然后进行展平成一个 1D 向量。

在这个代码示例中,我们用最大池化 (outputs = MaxPooling2D(model.outputs)) 和展平 (outputs = Flatten(outputs)) 替换了瓶颈层,用于我们的 20 种面条菜肴分类器:

from tensorflow.keras.applications import ResNet50
from tensorflow.keras.layers import Dense, Flatten
from tensorflow.keras import Model

model = ResNet50(include_top=False, input_shape=(100, 100, 3), pooling=None) ❶

outputs = MaxPooling2D(model.output)                                         ❷
outputs = Flatten()(ouputs)                                                  ❷

outputs = Dense(20, activation='softmax')(outputs)                           ❸

model = Model(model.input, outputs)
model.compile(loss='categorical_crossentropy', optimizer='adam', 
              metrics=['accuracy'])

❶ 获取输入形状为 (100,100,3) 且不带分类器组的预建模型

❷ 将特征图池化并展平成一个 1D 向量

❸ 添加了一个 20 类别的分类器

在本节中,我们介绍了 TF.Keras 的预建模型和预训练模型。总结一下,预建模型是一个现有的模型,通常基于 SOTA 架构,其输入形状和任务组是可重新配置的,且权重未经过训练。预建模型通常用于从头开始训练模型,具有可重用性和可重新配置以适应您的数据集和任务的优势。缺点是架构可能没有针对您的数据集/任务进行调整,因此最终得到的模型在尺寸和准确性方面可能都不够高效。

预训练模型本质上与预建模型相同,只是权重已经使用另一个数据集(如 ImageNet 数据集)进行了预训练。预训练模型用于即插即用预测或迁移学习,具有通过代表性学习重用快速训练新数据集/任务并减少数据量的优势。缺点是预训练的代表性学习可能不适合您的数据集/任务领域。

在下一节中,我们将使用来自 TensorFlow Hub 存储库的预建模型介绍相同的概念。

11.2 TF Hub 预建模型

TensorFlow Hub,或TF Hub,是一个开源公共仓库,包含预构建和预训练模型,比 TF.Keras 更为广泛。TF.Keras 的预构建/预训练模型适合学习和练习迁移学习,但在生产目的上提供的选项过于有限。TF Hub 包含大量预构建的 SOTA 架构、广泛的任务类别、特定领域的预训练权重以及超出 TensorFlow 组织直接提供的模型之外的公共提交。

本节涵盖了图像分类的预构建模型。TF Hub 为每个模型提供两个版本,具体描述如下:

  • 用于特定类别的图像分类的模块。这个过程与预训练模型相同。

  • 用于提取图像特征向量(瓶颈值)的模块,用于在自定义图像分类器中使用。这些分类器与 TF.Keras 中描述的新分类器相同。

我们将使用两个预构建模型,一个用于开箱即用的分类,另一个用于迁移学习。我们将从 TensorFlow Hub 的预构建模型开源仓库中下载这些模型,该仓库位于www.tensorflow.org/hub

要使用 TF Hub,您首先需要安装tensorflow_hub Python 模块:

pip install tensorflow_hub

在您的 Python 脚本中,通过导入tensorflow_hub模块来访问 TF Hub:

import tensorflow_hub as hub

您现在已设置好下载我们两个模型。

11.2.1 使用 TF Hub 预训练模型

与 TF.Keras 相比,TF Hub 在可加载的模型格式类型方面非常灵活:

  • TF2.x SavedModel—在本地、REST 或云上的微服务、桌面/笔记本电脑或工作站中使用。

  • TF Lite—在移动或内存受限的 IoT 设备上的应用程序服务中使用。

  • TF.js—在客户端浏览器应用程序中使用。

  • Coral—优化用于在 Coral Edge/IoT 设备上作为应用程序服务使用。

本节将仅涵盖 TF 2.x 的 SavedFormat 模型。要加载一个模型,您需要执行以下操作:

  1. 获取 TF Hub 仓库中图像分类器模型的 URL。

  2. 使用hub.KerasLayer()从指定的 URL 指定的仓库中检索模型数据。

  3. 通过使用 TF.Keras sequential API 从模型数据构建一个 TF.Keras SavedModel。

  4. 将输入形状指定为(224, 224, 3),这与预训练模型在 ImageNet 数据库上训练的输入形状相匹配。

model_url = "https://tfhub.dev/google/imagenet/resnet_v2_50/classification/4" ❶

model = tf.keras.Sequential([hub.KerasLayer(model_url,
                             input_shape=(224,224,3))])                       ❷

❶ TF Hub 仓库中 ResNet50 v2 模型数据的存储位置

❷ 从模型数据检索并构建 SavedModel 格式的模型

当您执行model.summary()时,输出将如下所示:

Layer (type)                 Output Shape              Param #   
=================================================================
keras_layer_7 (KerasLayer)   (None, 1001)              25615849  
=================================================================
Total params: 25,615,849
Trainable params: 0
Non-trainable params: 25,615,849

现在,您可以使用该模型进行预测,这被称为推理。图 11.4 描述了使用 TF Hub ImageNet 预训练模型进行预测的以下步骤:

  1. 获取 ImageNet 的标签(类别名称)信息,以便我们将预测的标签(数字索引)转换为类别名称。

  2. 预处理图像以预测以下内容:

    • 将图像输入调整大小以匹配模型的输入:(224, 224, 3)。

    • 标准化图像数据:除以 255。

  3. 对图像调用 predict()

  4. 使用 np.argmax() 返回最高概率的标签索引。

  5. 将预测的标签索引转换为相应的类名。

列表 11.4 使用 TF Hub 的 ImageNet 预训练模型预测标签,然后使用 ImageNet 映射显示预测的类名

这里是这些五个步骤的一个示例实现。

path = tf.keras.utils.get_file('ImageNetLabels.txt',
'https://storage.googleapis.com/download.tensorflow.org/data/
ImageNetLabels.txt')           
imagenet_labels = np.array(open(path).read().splitlines())    ❶

import cv2
import numpy as np
data = cv2.imread('apple.png')                                ❷
data = cv2.resize(data, (224, 224))                           ❷
data = (data / 255.0).astype(np.float32)                      ❷

p = model.predict(np.asarray([data]))                         ❸
y = np.argmax(p)                                              ❸

print(imagenet_labels[y])                                     ❹

❶ 获取从 ImageNet 标签索引到类名的转换

❷ 预处理图像以进行预测

❸ 使用模型进行预测

❹ 将预测的标签索引转换为类名

11.2.2 新的分类器

对于为预训练模型构建新的分类器,我们加载相应的模型 URL,表示为模型的特征向量版本。这个版本加载了预训练模型,但没有模型顶部或分类器。这允许你添加自己的顶部或任务组。模型的输出是输出层。我们还可以指定一个与 TF Hub 模型默认输入形状不同的新输入形状。

以下是一个加载预训练 ResNet50 v2 模型特征向量版本的示例实现,我们将添加自己的任务组件以训练 CIFAR-10 模型。由于我们的 CIFAR-10 输入大小与 TF Hub 的 ResNet50 v2 版本不同,其大小为(224, 224, 3),因此我们还可以选择指定输入形状:

  1. 获取 TF Hub 存储库中图像分类器模型的 URL。

  2. 使用 hub.KerasLayer() 从由 URL 指定的存储库中检索模型数据。

  3. 为 CIFAR-10 数据集指定新的输入形状为(32, 32, 3)。

f_url = "https://tfhub.dev/google/imagenet/resnet_v2_50/feature_vector/4"  ❶

f_layer = hub.KerasLayer(f_url, input_shape=(32,32,3))                     ❷

❶ TF Hub 存储库中 ResNet50 v2 特征向量版本模型数据的存储位置

❷ 将模型数据作为 TF.Keras 层检索并设置输入形状

这里是构建 CIFAR-10 新分类器的一个示例实现,格式为 SavedModel:

  1. 使用顺序 API 创建 SavedModel。

    • 将预训练的 ResNet v2 的特征向量版本指定为模型底部。

    • 指定一个有 10 个节点(每个 CIFAR-10 类别一个)的密集层作为模型顶部。

  2. 编译模型。

model = tf.keras.Sequential([
                             f_layer,
                             Dense(10, activation='softmax')
                            ])
model.compile(loss='sparse_categorical_crossentropy', optimizer='adam', 
              metrics=['acc'])

当你执行 model.summary() 时,输出将如下所示:

Layer (type)                 Output Shape              Param #   
=================================================================
keras_layer_4 (KerasLayer)   (None, 2048)              23561152  
_________________________________________________________________
dense_2 (Dense)              (None, 10)                20490     
=================================================================
Total params: 23,581,642
Trainable params: 20,490
Non-trainable params: 23,561,152

到目前为止,我们已经涵盖了使用预训练模型进行即插即用预测和使用可重新配置的预构建模型进行更方便的新模型训练。接下来,我们将介绍如何使用和重新配置预训练模型以实现更高效的训练并减少新任务所需的数据。

11.3 领域间的迁移学习

在迁移学习中,我们使用预训练模型完成一个任务,并重新训练分类器和/或微调层以完成新任务。这个过程与我们刚刚在预构建模型上构建新分类器类似,但除此之外,模型是从头开始完全训练的。

迁移学习有两种一般方法:

  • 相似任务——预训练数据集和新数据集来自相似的域(例如水果到蔬菜)。

  • 不同任务——预训练数据集和新数据集来自不同的域(例如水果和卡车/面包车)。

11.3.1 相似任务

如本章前面所讨论的,在决定方法时,我们查看源(预训练)图像域和目标(新)域的相似性。越相似,我们可以重用更多现有底层而无需重新训练。例如,如果我们有一个在水果上训练的模型,那么预训练模型的底层所有层很可能可以重用而无需重新训练来构建一个用于识别蔬菜的新模型。

我们假设在底层学习到的粗略和详细特征对于新分类器将是相同的,并且可以在进入最顶层(的)分类之前直接重用。让我们考虑一些我们可以推测水果和蔬菜来自非常相似域的原因。两者都是天然食品。虽然水果通常在地面上生长,而蔬菜在地下生长,但它们在形状和质地上有相似的物理特性,以及如茎和叶等装饰。

当源域和目标域具有这种高水平相似性时,我们通常可以用新的分类器层替换现有的最顶层分类器层,冻结底层层,并仅训练分类器层。由于我们不需要学习其他层的权重/偏差,因此我们可以用大量更少的数据和更少的周期来训练新域的模型。

虽然拥有更多数据总是更好的,但相似源域和目标域之间的迁移学习提供了使用大量更小数据集进行训练的能力。关于数据集最小尺寸的两个最佳实践如下:

  • 每个类别(标签)的大小是源数据集的 10%。

  • 每个类别(标签)至少有 100 张图片。

新分类器所示的方法相反,我们在训练之前修改代码以冻结所有位于最顶层分类器层之前的层。冻结可以防止这些层(的)权重/偏差在分类器(最顶层)层的训练期间被更新(重新训练)。在 TF.Keras 中,每个层都有trainable属性,默认为True

图 11.5 描述了预训练模型分类器层的重新训练;以下是步骤:

  1. 使用具有预训练权重/偏差的预构建模型(ImageNet 2012),

  2. 从预构建模型中删除现有的分类器(最顶层)。

  3. 冻结剩余的层。

  4. 添加一个新的分类器层。

  5. 通过迁移学习训练模型。

图片

列表 11.5 当源域和目标域相似时,只有分类器权重被重新训练,而剩余模型底层的权重被冻结。

这里是一个示例实现:

from tensorflow.keras.applications import ResNet50
from tensorflow.keras.layers import Dense
from tensorflow.keras import Model

model = ResNet50(include_top=False, pooling='avg', weights='imagenet')   ❶

for layer in model.layers:                                               ❷
    layer.trainable = False                                              ❷

output = Dense(20, activation='softmax')(model.output)                   ❸

model = Model(model.input, output)                                       ❹
model.compile(loss='categorical_crossentropy', optimizer='adam',         ❹
              metrics=['accuracy'])                                      ❹

❶ 获取不带分类器的预训练模型并保留全局平均池化层

❷ 冻结剩余层的权重

❸ 添加一个 20 个类别的分类器

❹ 编译模型以进行训练

注意,在这个代码示例中,我们保留了原始输入形状(224, 224, 3)。在实际操作中,如果我们更改输入形状,现有的训练权重/偏差将不会匹配它们训练的特征提取分辨率。在这种情况下,最好将其作为一个独立任务案例处理。

11.3.2 独立任务

当图像数据集的源域和目标域不同,例如我们例子中的水果和卡车/面包车时,我们开始与之前相似任务方法中的相同步骤,然后继续微调底部层。步骤,如图 11.6 所示,通常如下:

  1. 添加一个新的分类器层并冻结剩余的底部层。

  2. 训练新的分类器层以达到目标周期数。

  3. 重复进行微调:

    • 解冻下一个最底部的卷积组(从顶部到底部的方向)。

    • 训练几个周期以进行微调。

  4. 在卷积组微调后:

    • 解冻卷积主干组。

    • 训练几个周期以进行微调。

在图 11.6 中,你可以看到步骤 2 到 4 的训练周期:在周期 1 中重新训练分类器,在周期 2 到 4 中按顺序微调卷积组,在周期 5 中微调主干。请注意,这与源域和目标域相似且我们只微调分类器的情况不同。

图像

列表 11.6 在这种独特的源到目标迁移学习中,卷积组逐步微调。

以下是一个示例实现,演示了对新分类器级别(周期 1)的粗粒度训练,然后是每个卷积组(周期 2 到 4)的微调,最后是主干卷积组(周期 5)。步骤如下:

  1. 模型底部的层被冻结(layer.trainable = False)。

  2. 在模型顶部添加一个 20 个类别的分类器层。

  3. 分类器层使用 50 个周期进行训练:

from tensorflow.keras.applications import ResNet50
from tensorflow.keras.layers import Dense
from tensorflow.keras import Model

model = ResNet50(include_top=False, pooling='avg', weights='imagenet')

for layer in model.layers:                                                 ❶
    layer.trainable = False                                                ❶

output = Dense(20, activation='softmax')(model.output)                     ❷

model = Model(model.input, output)
model.compile(loss='categorical_crossentropy', optimizer='adam', 
              metrics=['accuracy'])                                        ❸

model.fit(x_data, y_data, batch_size=32, epochs=50, validation_split=0.2)  ❹

❶ 冻结所有预训练层的权重

❷ 添加一个未训练的分类器

❸ 编译模型以进行训练

❹ 粗粒度训练新的分类器

在分类器训练后,模型进行微调(周期 2 到 4):

  1. 从底部到顶部遍历层,识别主干卷积和每个 ResNet 组的结束,这通过一个Add()层检测到。

  2. 对于每个卷积组,构建该组中每个卷积层的列表。

  3. 以相反的顺序构建组列表(groups.insert(0, conv2d)): 从顶部到底部。

  4. 从顶部到底部遍历卷积组,并逐步训练每个组和其前驱,共五个周期。

以下是对这四个步骤的示例实现。

stem = None
groups = []
conv2d = []

first_conv2d = True
for layer in model.layers:
        if type(layer) == layers.convolutional.Conv2D:
            if first_conv2d == True:                                ❶
                stem = layer
                first_conv2d = False
            else:                                                   ❷
                conv2d.append(layer)
        elif type(layer) == layers.merge.Add:                       ❸
                groups.insert(0, conv2d)                            ❹
                conv2d = []

for i in range(1, len(groups)):                                     ❺
        for layer in groups[i]:                                     ❺
            layer.trainable = True                                  ❺

        model.compile(loss='categorical_crossentropy', optimizer='adam', 
                      metrics=['accuracy'])                         ❻
                                                                    ❻
        model.fit(x_data, y_data, batch_size=32, epochs=5)          ❻

❶ 在 ResNet50 中,第一个 Conv2D 是主干卷积层。

❷ 为每个卷积组保持卷积层的列表

❸ 残差网络中的每个卷积组都以一个 Add()层结束。

❹ 以相反的顺序维护列表(最上面的卷积组是列表的顶部)

❺ 一次解冻一个卷积组(从上到下)

❻ 微调(训练)该层

最后,主干卷积以及整个模型额外训练了五个周期(周期 5)。以下是最后一步的示例实现:

stem.trainable = True                                                      ❶
model.compile(loss='categorical_crossentropy', optimizer='adam', 
              metrics=['accuracy'])
model.fit(x_data, y_data, batch_size=32, epochs=5, validation_split=0.2)   ❷

❶ 解冻主干卷积

❷ 进行最终微调

在此示例中,当解冻层进行微调时,必须在发出下一个训练会话之前重新编译模型。

11.3.3 特定领域权重

在之前的迁移学习示例中,我们使用从 ImageNet 2012 数据集学习到的权重初始化了模型的冻结层。但让我们假设你想要使用除 ImageNet 2012 之外特定领域的预训练权重,就像我们关于水果的例子一样。

例如,如果你正在构建一个植物领域的域迁移模型,你可能需要树木、灌木、花朵、杂草、叶子、树枝、水果、蔬菜和种子的图像。但我们不需要每种可能的植物类型——只需要足够的来学习基本特征和特征提取,这些可以推广到更具体和更全面的植物领域。你也可能考虑你想要推广的背景。例如,目标领域可能是室内植物,因此你有家庭室内背景,或者它可能是产品,因此你想要一个货架背景。你应该在源域中有一定数量的这些背景,这样源模型就学会了从潜在空间中过滤掉它们。

在下一个代码示例中,我们首先为特定领域(在这种情况下,是水果产品)训练一个预构建的 ResNet50 架构;然后,我们使用预训练的、特定领域的权重和初始化来训练另一个在类似领域(例如,蔬菜)中的 ResNet50 模型。

图 11.7 描述了将特定领域的权重从水果迁移到类似领域(蔬菜)并进行微调的过程如下:

  1. 实例化一个未初始化的 ResNet50 模型,不带分类器和池化层,我们将其指定为基础模型。

  2. 保存基础模型架构以供以后在迁移学习中重复使用(produce-model)。

  3. 添加一个分类器(FlattenDense层)并针对特定的(源)领域(例如,产品)进行训练。

  4. 保存训练模型的权重(produce-weights)。

  5. 加载基础模型架构(model-produce),它不包含分类器层。

  6. 使用源域的预训练权重初始化基础模型架构(model-produce)。

  7. 为新类似领域添加一个分类器。

  8. 训练新类似领域的模型/分类器。

列表 11.7:与源域类似领域的预训练模型之间的迁移学习

这里是一个将特定领域权重从水果迁移到类似领域蔬菜的迁移学习的示例实现:

from tensorflow.keras.applications import ResNet50
from tensorflow.keras import Model
from tensorflow.keras.layers import Dense, Flatten
from tensorflow.keras.models import load_model

model = ResNet50(include_top=False, pooling=None, input_shape=(100, 100, 3))

model.save('produce-model')                           ❶

output = Flatten(name='bottleneck')(model.output)     ❷
output = Dense(20, activation='softmax')(output)      ❷

model.save_weights('produce-weights')                 ❸

model = load_model('produce-model')                   ❹
model.load_weights('produce-weights')                 ❹

output = Flatten(name='bottleneck')(model.output)     ❺
output = Dense(20, activation='softmax')(output)      ❺

model = Model(model.input, output)                    ❻
model.compile(loss='categorical_crossentropy', optimizer='adam',  
              metrics=['accuracy'])                   ❼

❶ 保存基础模型

❷ 添加分类器

❸ 保存训练好的模型权重

❹ 训练模型

❺ 重新使用基础模型和训练好的权重

❻ 添加分类器

❼ 编译并训练新数据集的新模型

11.3.4 领域迁移权重初始化

另一种迁移学习的形式是将特定领域权重迁移到作为我们将重新训练的模型的权重初始化。在这种情况下,我们试图改进基于随机权重分布算法(例如,对于 ReLU 激活函数的 He-normal)的初始化器,而不是使用彩票假设或数值稳定性。让我们再次看看我们的产品示例,并假设我们已经为数据集实例(如水果)完全训练了一个模型。我们不是从完全训练的模型实例中迁移权重,而是使用一个更早的检查点,其中我们已经建立了数值稳定性。我们将重用这个更早的检查点作为重新训练领域相似数据集(如蔬菜)的初始化器。

转移特定领域权重是一种一次性权重初始化方法。假设是生成一组足够泛化的权重初始化,以便模型训练将导致最佳局部(或全局)最优解。理想情况下,在初始训练期间,模型的权重将执行以下操作:

  • 指向收敛的一般正确方向

  • 防止过度泛化以避免陷入任意局部最优解

  • 作为单次(一次性)训练会话的初始化权重,该会话将收敛到最佳局部最优解

图 11.8 描述了权重初始化的领域迁移。

图片

列表 11.8 使用类似领域的早期检查点作为新模型完全重新训练的权重初始化

这种权重初始化的预训练步骤如下:

  1. 实例化一个 ResNet50 模型,具有随机权重分布(例如,Xavier 或 He-normal)。

  2. 使用高水平的正则化(l2(0.001))以防止拟合数据和小学习率。

  3. 运行几个时代(未展示)。

  4. 使用模型方法 save_weights() 保存权重。

from tensorflow.keras.regularizers import l2

model = ResNet50(include_top=False, pooling='avg', input_shape=(100, 100, 3)) ❶

model.save('base_model')                                                      ❷

output = layers.Dropout(0.75)(model.output)                                   ❸
output = layers.Dense(20, activation='softmax',                               ❸
                      kernel_regularizer=l2(0.001))(output)                   ❸
model  = Model(model.input, output)                                           ❸

model.save_weights('weights-init')                                            ❹

❶ 使用默认权重初始化(He-normal)实例化基础模型

❷ 保存模型

❸ 在基础 ResNet 模型中添加 dropout 层和分类器,并使用激进的正则化级别

❹ 预训练后保存模型和权重

在下一个代码示例中,我们使用保存的预训练权重开始一个完整的训练会话。首先我们加载未初始化的基础模型(base_model),它不包括最顶层。然后我们将保存的预训练权重(weights-init)加载到模型中。接下来,我们添加一个新的最顶层,它是一个有 20 个节点的密集层,用于 20 个类别。我们构建新的模型,编译,然后开始完整的训练。

model = load_model('base_model')                                  ❶

model.load_weights('weights-init')                                ❷

output = Dense(20, activation='softmax')(model.output)            ❸

model = Model(model.input, output)                                ❹
model.compile(loss='categorical_crossentropy', optimizer='adam',  ❹
              metrics=['accuracy'])                               ❹

❶ 重新加载基础模型

❷ 使用域迁移权重初始化来初始化权重

❸ 添加不带 dropout 的分类器

❹ 编译并训练新模型

11.3.5 负迁移

在某些情况下,我们会发现迁移学习的结果比从头开始训练的准确度低:当使用预训练模型来训练新模型时,训练过程中的整体准确度低于如果没有预训练模型时的准确度。这被称为负迁移

在这种情况下,源域和目标域非常不同,以至于源域的学习权重不能在目标域上重用。此外,当权重被重用时,模型将不会收敛,甚至可能会发散。一般来说,我们通常可以在五到十个 epoch 内发现负迁移。

11.4 超越计算机视觉

本章讨论的用于计算机视觉的迁移学习方法也适用于 NLU 模型。除了某些术语外,过程是相同的。在 NLU 模型中,移除顶层有时被称为移除头部

在这两种情况下,你都是在移除所有或部分的任务组件,并用新的任务替换它。你所依赖的是类似于计算机视觉中的潜在空间;中间表示具有学习新任务所必需的上下文(特征)。对于相似任务和不同任务的方 法,在计算机视觉和 NLU 中是相同的。

然而,对于结构化数据来说,情况并非如此。实际上,跨域(数据集)的预训练模型之间不可能进行迁移学习。你可以在同一个数据集上学习不同类型的工作(例如,回归与分类),但你不能在不同特征的数据集之间重用学习到的权重。至少目前还没有一个概念——即具有可跨不同领域(列)的数据集重用基本特征的潜在空间。

摘要

  • 来自 TF.Keras 和 TF Hub 模型存储库的预构建和预训练模型可以用于直接用于预测的重用,或者用于迁移学习新的分类器。

  • 预训练模型的分类器组可以被替换,无论是通用的还是与类似域的,并且可以在更少的训练时间和更小的数据集上重新训练以适应新域。

  • 在迁移学习中,如果新域与之前训练的域相似,则冻结所有层除了新的任务层,并进行微调训练。

  • 在迁移学习中,如果新领域与之前训练的领域不同,你需要在重新训练时按顺序冻结和解冻层,从模型底部开始,逐步向上移动。

  • 在领域迁移权重中,你使用训练模型的权重作为初始权重,并完全训练一个新的模型。

12 数据分布

本章涵盖了

  • 在机器学习中应用分布的统计原理

  • 理解精选数据集和非精选数据集之间的差异

  • 使用总体、抽样和子总体分布

  • 在训练模型时应用分布概念

作为数据科学家和教育工作者,我经常收到软件工程师关于如何提高模型准确性的问题。我给出的五个基本答案,以提高模型的准确性如下:

  • 增加训练时间。

  • 增加模型的深度(或宽度)。

  • 添加正则化。

  • 通过数据增强扩展数据集。

  • 增加超参数调整。

这些是最有可能需要解决的问题,并且通常解决其中之一或多个将提高模型准确性。但重要的是要理解,准确性的限制最终在于用于训练模型的数据库集。这正是我们要探讨的:数据集的细微差别,以及它们如何以及为什么会影响准确性。而“细微差别”指的是数据的分布模式。

在本章中,我们将深入探讨三种类型的数据分布:总体、抽样和子总体。特别是,我们将研究这些分布如何影响模型在现实世界中对数据的准确泛化能力。你会发现,模型的准确性通常与训练或评估数据集生成的预测不同,这种差异被称为服务偏差和数据漂移。

在本章的后半部分,我们将通过一个实际案例来展示如何在训练过程中将不同的数据分布应用于同一模型,并观察在推理阶段,对真实世界服务偏差和数据漂移的不同影响结果。

要理解分布及其对结果和准确性的影响,我们需要回到基础统计学,这可能是你在高中或大学学过的。术语模型不是由人工智能、机器学习或任何其他计算机技术的新发展创造的。这个术语起源于统计学。作为一个软件工程师,你习惯于编写一个算法,该算法通常具有输入和输出之间的多对一关系。我们通常将这种关系称为输入与输出之间的线性关系——换句话说,输出是确定的

在统计学中,输出不是确定的,而是一个概率分布。让我们考虑一下抛硬币的情况。你无法编写一个算法来输出任何单次抛硬币的正确结果(正面或反面),因为它不是确定的。但你可以建模单次、十次或上千次抛硬币的概率分布。

12.1 分布类型

统计学领域处理的是非确定性算法,但其结果是概率分布。就像我们的抛硬币例子一样,如果我抛两次硬币,结果不是确定的。相反,一次抛出正面和一次抛出反面的概率是 50%,两次都是正面的概率是 25%,两次都是反面的概率也是 25%。这些算法被称为模型,它们模拟一个行为,使得预测在概率分布上的输出(或结果)。

在本节中,我们考察了在机器学习建模中最常用的三种分布:总体分布、抽样分布和子总体分布。我们的目标是了解每种分布如何影响深度学习模型的训练,特别是它的准确性。

使用神经网络开发模型的深度学习出现于人工智能领域。近年来,统计建模和深度学习这两个独立的领域已经融合在一起,我们现在将它们都归类为机器学习。但无论你是在做我所说的经典机器学习(统计学)还是基于神经网络的深度学习,你能够建模或学习到的限制都归结于数据集。

为了查看这三个分布,我们将使用 MNIST 数据集(keras.io/datasets/)。这个数据集足够小,我们可以用它来演示这些概念,同时给你留下代码示例,你可以复制并使用这些代码,亲眼看到为什么(以及如何)数据是限制。

12.1.1 总体分布

当你构建一个模型,结果发现它没有像你预期的那样在“野外”(在生产环境中)泛化,通常原因之一是你没有理解你所建模的总体分布。

假设你正在构建一个模型,根据身体特征(身高、发色等)预测美国成年男性的鞋码。这个模型的总体分布将是所有美国成年男性。让我强调所有。当我们说一个总体分布时,它包含人口中的每一个例子——整个人口。有了总体分布,我们就知道鞋码的完整分布以及相应的特征(身高、发色等)。

当然,问题是,你不会拥有美国所有成年男性的数据。相反,你将拥有数据的一个子集:我们随机抽取数据的一批(我们称之为随机样本)来确定批次内的分布,你希望这个分布尽可能接近整体人口的分布。

图 12.1 展示了在总体分布内的随机抽样。外圈,标记为总体,代表总体中的所有例子,例如在我们关于美国所有成年男性鞋码的例子中。内圈,标记为随机样本,代表随机选择的一组例子,例如在美国随机选择的一定数量的成年男性。对于总体分布,我们知道诸如确切的大小(成年男性的数量)、平均值(平均鞋码)和标准差(不同尺寸的百分比)等信息。这些在统计学上被称为总体的参数,这是一个确定性分布。假设我们没有总体分布,我们希望使用随机样本来估计参数——这被称为统计量。样本越大、越随机,我们的估计就越有可能接近参数。

图片

图 12.1 展示了总体分布及其内部的随机抽样

12.1.2 抽样分布

使用抽样分布的目标是拥有足够多的来自总体的随机样本,这样,这些样本内部的分布可以共同用来预测整个总体的分布,从而我们可以将模型推广到总体。这里的关键词是预测,意味着我们从样本中确定一个概率分布,而不是从总体中确定一个确定性分布。

让我们以我们的鞋码例子为例。如果我们只有一个例子,我们可能无法充分地模拟分布的参数。但如果我们有一千个例子,我们可能能够显著提高模拟参数的能力。但是等等,如果那一千个例子并不是真正随机的——比如说它们是从专业运动鞋店的购买中收集的。这些例子可能会倾向于某些非随机例子的特征(特性)。因此,抽样分布中的例子需要是随机选择的。

图 12.2 描述了一个总体抽样分布。一个抽样分布由随机选择的一组例子组成,通常大小相同。例如,我们可能雇佣了不同的调查公司来收集我们的鞋码数据,每个公司使用自己的选择标准。每个公司根据其选择标准收集了一百个随机样本的数据。

图片

图 12.2:预测总体分布参数的抽样分布

我们可以假设这些单独的随机样本是总体参数的弱预测器。相反,我们将它们视为一个整体。例如,如果我们取每个随机样本均值的平均值,给定足够数量和足够大小的随机样本,我们可以更准确地预测总体的均值。

通常,您用于训练模型的数据库是一个抽样分布,样本量越大,例子越随机,您的模型就越有可能推广到群体的参数。

12.1.3 子群体分布

您需要理解,无论您的数据集有多大、多么全面,它很可能是一个子群体的抽样分布,而不是整个群体。子群体是群体的一部分,由一组特征定义,并且与群体的概率分布不同。例如,在我们的早期成年男性鞋类例子中,假设我们的样本都来自一家专门为职业运动员销售运动鞋的连锁店。有了足够的样本,我们可以开发出一个具有代表性的抽样分布,因此可以预测职业运动员的子群体,但它不太可能代表整个群体。

这与偏差不同,只要我们的意图是模拟该子群体而不是整个群体。当从随机样本批次中抽取时,会出现偏差,无论我们抽取多少,相应的抽样分布都不会代表我们正在模拟的群体——因为我们是从子群体中抽取的随机样本。图 12.3 展示了子群体分布。

图片

图 12.3 子群体分布

12.2 分布外

假设您已经训练了一个模型并将其部署在数据集上,但它并没有像您的评估数据那样在生产中推广。这个模型可能看到了与训练时不同的例子分布。我们称这种情况为“分布外”,也称为“服务偏差”。换句话说,您的模型是在一个与部署模型看到的不同的子群体分布上训练的。

在本节中,我们将使用 MNIST 数据集来演示在模型部署时如何检测分布外的群体。然后我们将探讨改进模型以推广到分布外群体的方法。我们已在第二章中首次讨论了 MNIST 数据集。我们将从对该数据集的简要回顾开始。

12.2.1 MNIST 精选数据集

MNIST 是一个包含 70,000 个手写数字图像的数据集,每个数字的比例平衡。训练一个模型以在数据集上达到接近 100%的准确率非常容易(因此它是机器学习的“hello, world”示例)。但几乎所有的“实际应用”中的训练模型都会失败——因为 MNIST 中的图像分布是一个子群体。

MNIST 是一个精选的数据集。数据管理员选择了符合一定定义的特征的样本进行包含。换句话说,精选数据集足以代表一个亚群体,可以对该亚群体的参数进行建模,但否则可能不代表整个群体(例如,所有数字)。

在 MNIST 的情况下,每个样本是一个 28-×-28 像素的图像,数字的绘制位于中间。数字是白色的,背景是灰色的,数字周围至少有 4 像素的填充。图 12.4 显示了 MNIST 图像的布局。这个数字 7 的实例只是从数据集中随机选择的任意随机选择,仅用于示例目的。

图 12.4 MNIST 图像的布局

12.2.2 设置环境

首先,让我们做一下我所说的家务管理。以下是我们将在所有示例中使用的代码片段。它包括导入 TF.Keras API 以设计和训练模型,我们将使用的各种 Python 库,以及最后,加载预建在 TF.Keras API 中的 MNIST 数据集:

from tensorflow.keras import Sequential
from tensorflow.keras.layers import Flatten, Dense, Activation, ReLU, 
from tensorflow.keras.layers import MaxPooling2D, Conv2D, Dropout

import numpy as np
import random
import cv2

from tensorflow.keras.datasets import mnist                 ❶

(x_train, y_train), (x_test, y_test) = mnist.load_data()    ❶

❶ 获取 MNIST 的内置数据集

Keras 的数据集是通用格式,因此我们需要进行一些初始数据准备,以便用于训练 DNN 或 CNN。这些准备包括以下内容:

  • 像素数据(x_trainx_test)包含原始 INT8 值(0 到 255)。我们将像素数据归一化到 0 到 1 的 FLOAT32。

  • 图像数据矩阵的形状为高度 × 宽度 (H × W)。Keras 期望张量的形状为高度 × 宽度 × 通道。这些是灰度图像,因此我们将训练和测试数据调整为(H × W × 1)。

在准备(我们将在 12.2.3 节中讨论)之前,我们将留出一份数据集的测试和训练数据的副本。

x_test_copy  = x_test                                ❶
x_train_copy = x_train                               ❶

x_train = (x_train / 255.0).astype(np.float32)       ❷
x_test  = (x_test  / 255.0).astype(np.float32)       ❷

x_train = x_train.reshape(-1, 28, 28, 1)             ❸
x_test  = x_test.reshape(-1, 28, 28, 1)              ❸

print("x_train", x_train.shape, "x_test", x_test.shape)

print("y_train", y_train.shape, "y_test", y_test.shape)

❶ 留出原始训练和测试数据的副本

❷ 将像素数据归一化并转换为 32 位浮点数

❸ 调整形状以符合 TF.Keras 模型 API

12.2.3 挑战(在野外)

除了从这个精选数据集中随机选择测试数据(称为保留集)之外,我们还将创建另外两个测试数据集,作为展示训练模型在野外可能看到的示例。这两个额外的数据集,称为反转集筛选集,将包含训练数据未表示的示例。换句话说,原始 MNIST 数据集是数字群体中的一个亚群体,而我们这两个新的数据集是数字的不同亚群体。反转集和筛选集的分布与 MNIST 数据集不同,因此我们称它们相对于 MNIST 数据集为分布外

我们将使用这两个额外的测试数据集来展示模型将如何失败,并找到我们可能修改训练和数据集以克服这些局限性的方法。每个集合由什么构成?

  • 倒置集——像素数据被倒置,使得图像现在是在白色背景上的灰色数字。

  • 平移集——图像向右平移了 4 个像素,因此不再居中。由于至少有 4 个像素的填充,没有任何数字会被裁剪。

图 12.5 是从原始测试数据、倒置测试数据和平移测试数据中选取的单个测试图像的示例。

图 12.5 原始和野外分布外的示例

在此代码中,我们从原始测试数据集的副本中创建了两个额外的测试数据集:

x_test_invert = np.invert(x_test_copy)                        ❶
x_test_invert = (x_test_invert / 255.0).astype(np.float32)    ❶

x_test_shift = np.roll(x_test_copy, 4)                        ❷
x_test_shift = (x_test_shift / 255.0).astype(np.float32)      ❷

x_test_invert = x_test_invert.reshape(-1, 28, 28, 1)
x_test_shift  = x_test_shift.reshape(-1, 28, 28, 1)

❶ “野外”倒置数据

❷ “野外”平移数据

12.2.4 作为 DNN 进行训练

我们将首先基于现有的 MNIST 子集训练一个模型,将准确率与来自同一子集的保留集进行比较,最后测试并比较它们与野外分布数据。

MNIST 非常简单,我们可以用 DNN 构建一个 97%+准确率的分类器。下一个代码示例是一个构建简单 DNN 的函数,包括以下内容:

  • 参数nodes是一个列表,指定每层的节点数。

  • DNN 的输入是形状为 28 × 28 × 1 的图像

  • 输入被展平成一个长度为 784 的 1D 向量。

  • 每个层后都有一个可选的 dropout(用于正则化)。

  • 最后一个有 10 个节点的密集层,带有 softmax 激活函数,是分类器。

图 12.6 MNIST 模型的可配置 DNN 架构

图 12.6 展示了本例中可配置的 DNN 架构。

def DNN(nodes, dropout=False):                                             ❶
  model = Sequential()
  model.add(Flatten(input_shape=(28, 28, 1)))
  for n_nodes in nodes:
    model.add(Dense(n_nodes))
    model.add(ReLU())
    if dropout:
      model.add(Dropout(0.5))
      dropout /= 2.0
  model.add(Dense(10))
  model.add(Activation('softmax'))

  model.compile(optimizer='adam', loss='sparse_categorical_crossentropy',
                metrics=['accuracy'])                                      ❷
  model.summary()
  return model

❶ 构建简单 DNN 的函数

❷ 编译多类分类器的 DNN

在我们的第一次测试中,我们将数据集在一个包含 512 个节点的单层(不包括输出层)上进行训练。图 12.7 展示了相应的架构。

图 12.7 我们第一个 MNIST 模型的单层、512 节点 DNN

下面是构建、训练和评估我们第一次测试模型的代码:

model = DNN([512])
model.fit(x_train, y_train, epochs=10, batch_size=32, shuffle=True,
          verbose=2)                                                 ❶
score = model.evaluate(x_test, y_test, verbose=1)                    ❷
print("test", score)

❶ 在 MNIST 上训练模型

❷ 评估训练好的模型

summary()方法的输出将如下所示:

Layer (type)                 Output Shape              Param #   
=================================================================
flatten_1 (Flatten)          (None, 784)               0         
_________________________________________________________________
dense_1 (Dense)              (None, 512)               401920    
_________________________________________________________________
re_lu_1 (ReLU)               (None, 512)               0         
_________________________________________________________________
dense_2 (Dense)              (None, 10)                5130      
_________________________________________________________________
activation_1 (Activation)    (None, 10)                0         
=================================================================
Total params: 407,050

可训练参数的数量是我们模型复杂度的衡量标准,共有 408,000 个参数。我们总共训练了 10 个 epoch(我们将整个训练数据通过模型输入 10 次)。以下是从训练中得到的输出。训练准确率迅速达到 99%+,我们在测试(保留)数据上的准确率接近 98%。

Epoch 1/10
2019-02-08 12:14:59.065963: I tensorflow/core/platform/cpu_feature_guard.cc:141] Your CPU supports instructions that this TensorFlow binary was not compiled to use: AVX2 AVX512F FMA
 - 5s - loss: 0.2007 - acc: 0.9409
Epoch 2/10
 - 5s - loss: 0.0897 - acc: 0.9743
Epoch 3/10
 - 5s - loss: 0.0651 - acc: 0.9817
Epoch 4/10
 - 5s - loss: 0.0517 - acc: 0.9853
Epoch 5/10
 - 5s - loss: 0.0419 - acc: 0.9887
Epoch 6/10
 - 5s - loss: 0.0341 - acc: 0.9913
Epoch 7/10
 - 5s - loss: 0.0273 - acc: 0.9928
Epoch 8/10
 - 5s - loss: 0.0236 - acc: 0.9939
Epoch 9/10
 - 5s - loss: 0.0188 - acc: 0.9953
Epoch 10/10
 - 5s - loss: 0.0163 - acc: 0.9961
10000/10000 [==============================] - 0s 21us/step

test [0.11250439590732676, 0.9791]

到目前为止,看起来不错。现在让我们尝试在倒置和平移的测试数据集上使用模型:

score = model.evaluate(x_test_invert, y_test, verbose=1)     ❶
print("inverted", score)
score = model.evaluate(x_test_shift, y_test, verbose=1)      ❷
print("shifted", score)

❶ 在野外分布的倒置数据集上评估模型

❷ 在野外分布的平移数据集上评估模型

以下是从测试中得到的输出。我们在倒置数据集上的准确率仅为 2%,在平移数据集上表现较好,但只有 41%:

inverted [15.660332287597656, 0.0206]
shifted [7.46930496673584, 0.4107]

发生了什么?对于反转数据集,看起来我们的模型将灰色背景和数字的纯度作为数字识别的一部分来学习。因此,当我们反转数据时,模型完全无法对其进行分类。

对于移动数据集,密集层没有保留像素之间的空间关系。每个像素都是独特的特征。因此,即使像素的微小移动也足以大幅降低准确率。

因此,为了提高准确率,我们可能尝试增加输入层的节点数量——节点越多,学习效果越好。让我们用 1024 个节点重复相同的测试。图 12.8 展示了相应的架构。

图片

图 12.8 为我们的第二个 MNIST 模型训练的更宽的单层 1024 节点深度神经网络

下面是构建、训练和评估第二个测试模型的代码:

model = DNN([1024])                   ❶
model.fit(x_train, y_train, epochs=10, batch_size=32, shuffle=True, 
verbose=2)
score = model.evaluate(x_test, y_test, verbose=1)
print("test", score)

❶ 节点数量加倍(变宽)。

model.summary()的输出如下:

Layer (type)                 Output Shape              Param #   
=================================================================
flatten_2 (Flatten)          (None, 784)               0         
_________________________________________________________________
dense_3 (Dense)              (None, 1024)              803840    
_________________________________________________________________
re_lu_2 (ReLU)               (None, 1024)              0         
_________________________________________________________________
dense_4 (Dense)              (None, 10)                10250     
_________________________________________________________________
activation_2 (Activation)    (None, 10)                0         
=================================================================
Total params: 814,090
Trainable params: 814,090

你可以看到,通过将输入层的节点数量加倍,我们也将计算复杂度(可训练参数的数量)加倍。让我们看看这能否提高我们替代测试数据的准确率。

没有,我们在反转数据集上看到了微小的提升,大约 5%,但这太低了,可能只是噪声,而在移动数据集上的准确率大约相同,为 40%。所以增加输入层的节点数量(变宽)并没有帮助过滤掉(未学习)数字的背景和纯度,也没有学习空间关系:

inverted [15.157325344848633, 0.0489]
shifted [7.736222146606445, 0.4038]

另一种我们可能尝试的方法是增加层数(变深)。这次,让我们使用两个 512 节点的层。图 12.9 展示了我们的模型架构。

图片

图 12.9 为我们的第三个 MNIST 模型训练的更深的两层深度神经网络(512 + 512 个节点)

下面是构建、训练和评估第三个测试模型的代码:

model = DNN([512, 512])                                              ❶
model.fit(x_train, y_train, epochs=10, batch_size=32, shuffle=True, 
verbose=2)
score = model.evaluate(x_test, y_test, verbose=1)
print("test", score)

❶ 增加层数(变深)

model.summary()的输出结果:

Total params: 669,706
Trainable params: 669,706

让我们看看这能否提高我们替代测试数据的准确率:

inverted [14.464950880432129, 0.1025]
shifted [8.786513813018798, 0.3887]

我们在移动数据集上看到了另一个轻微的提升,达到 10%。但这真的有所改善吗?我们有 10 个类别(数字)。如果我们随机猜测,我们会有 10%的时间猜对。这仍然是一个纯粹随机的结果——这里没有学习到任何东西。看起来增加层并没有帮助学习空间关系。

另一种方法可能是添加一些正则化,以防止模型过度拟合训练数据并使其更具泛化能力。我们将使用每层 512 节点的相同两层深度神经网络,并在第一层后添加 50%的 dropout,在第二层后添加 25%的 dropout。过去,在第一层使用更高的 dropout(学习粗糙特征)和在后续层使用较小的 dropout(学习更精细的特征)是一种常见的做法。图 12.10 显示了模型架构。

图片

图 12.10 添加 dropout 以改善泛化的 DNN

下面是构建、训练和评估我们第四次测试模型的代码:

model = DNN([512, 512], True)         ❶
model.fit(x_train, y_train, epochs=10, batch_size=32, shuffle=True,
          verbose=2)
score = model.evaluate(x_test, y_test, verbose=1)
print("test", score)

❶ 添加 dropout 进行正则化

让我们看看这能否提高我们备用测试数据上的准确率:

inverted [15.862942279052735, 0.0144]
shifted [8.341207506561279, 0.3965]

没有改进。因此,加宽层、加深层和正则化并没有帮助模型在分布外的测试数据集中识别数字。也许问题在于 DNN 根本不是泛化到分布外模型的正确模型架构。接下来,我们将尝试 CNN 并看看会发生什么。

12.2.5 作为 CNN 的训练

好的,现在让我们在一个卷积神经网络中测试三个数据集的准确率。有了卷积层,我们至少应该学会空间关系。也许卷积层会过滤掉背景以及数字的白色。

以下代码按照以下方式构建我们的 CNN:

  • 参数filters是一个列表,指定每个卷积的过滤器数量。

  • CNN 的输入是形状为 28 × 28 × 1 的图像。

  • 每次卷积后,最大池化将特征图大小减少 75%。

  • 每个卷积/最大池化层之后发生 25%的 dropout(正则化)。

  • 最后一个具有 10 个节点和 softmax 激活函数的密集层是分类器。

def CNN(filters):                                                           ❶
  model = Sequential()
  first = True
  for n_filters in filters:
    if first:
      model.add(Conv2D(n_filters, (3, 3), strides=1, input_shape=(28, 28, 1)))
    else:
      model.add(Conv2D(n_filters, (3, 3), strides=1))
    model.add(ReLU())
    model.add(MaxPooling2D((2, 2), strides=2))
    model.add(Dropout(0.25))
  model.add(Flatten())
  model.add(Dense(10))
  model.add(Activation('softmax'))

  model.compile(optimizer='adam', loss='sparse_categorical_crossentropy',
                metrics=['accuracy'])                                       ❷
  model.summary()
  return model

❶ 构建简单 CNN 的函数

❷ 编译 CNN 以进行多类分类器

让我们从具有单个 16 个过滤器的卷积层的 CNN 开始。图 12.11 说明了模型架构。

图 12.11 用于 MNIST 训练的单层 CNN

下面是我们使用 CNN 进行第一次测试的构建、训练和评估模型的代码:

model = CNN([16])                                                    ❶
model.fit(x_train, y_train, epochs=10, batch_size=32, shuffle=True,
           verbose=2)
score = model.evaluate(x_test, y_test, verbose=1)  
print("test", score)

❶ 构建具有 16 个过滤器的 CNN

model.summary()的输出如下:

_________________________________________________________________
Layer (type)                 Output Shape              Param #   
=================================================================
conv2d_1 (Conv2D)            (None, 26, 26, 16)        160       
_________________________________________________________________
re_lu_1 (ReLU)               (None, 26, 26, 16)        0         
_________________________________________________________________
max_pooling2d_1 (MaxPooling2 (None, 13, 13, 16)        0         
_________________________________________________________________
dropout_1 (Dropout)          (None, 13, 13, 16)        0         
_________________________________________________________________
flatten_1 (Flatten)          (None, 2704)              0         
_________________________________________________________________
dense_1 (Dense)              (None, 10)                27050     
_________________________________________________________________
activation_1 (Activation)    (None, 10)                0         
=================================================================
Total params: 27,210
Trainable params: 27,210

下面是我们 CNN 训练的结果:

test [0.05741905354047194, 0.9809]

您可以看到,我们可以使用具有许多更少的可训练参数的 CNN(27,000 个参数与超过 400,000 个参数相比)在测试数据上获得相当准确的准确率(98%)。

让我们看看这能否提高我们备用测试数据上的准确率:

inverted [2.1893138484954835, 0.5302]
shifted [2.231996842956543, 0.5682]

是的,这确实产生了可测量的差异。我们从之前倒置数据集上的 10%准确率提高到 50%准确率。因此,卷积层似乎有助于过滤(而不是学习)数字的背景或白色。

但准确率仍然太低。对于偏移数据集,我们将其提高到 57%。这仍然低于我们的目标,但我们也可以看到,现在卷积层正在学习空间关系。那么,我们在这里学到了什么呢?嗯,如果你有一个错误的模型架构,无论你如何加深或加宽模型,或者添加多少正则化,模型都不会泛化到分布外的测试数据。我们还了解到,CNN 不仅泛化得更好,而且在参数方面也更为高效,在我们的第一次测试中,我们只使用了茎而没有学习组件。

如果一个卷积层能改善事情,那么让我们看看使用两个卷积层我们能做得更好。我们将使用两层:第一层有 16 个过滤器,第二层有 32 个过滤器。随着 CNN 逐渐加深,加倍过滤器数量是一种常见的做法。图 12.12 展示了我们的模型架构。

图 12.12 深度两层 CNN 用于 MNIST 训练

这里是构建、训练和评估我们第二次测试中 CNN 模型的代码:

model = CNN([16, 32])                                               ❶
model.fit(x_train, y_train, epochs=10, batch_size=32, shuffle=True, 
verbose=2)
score = model.evaluate(x_test, y_test, verbose=1)
print("test", score)

❶ 构建一个两层 CNN

这是我们的 CNN 训练结果:

test [0.03628469691830687, 0.9882]

再次,我们在测试数据上获得了相当准确的精度,略有提升,达到约 99%。让我们看看添加卷积层是否能够提高我们在替代测试数据上的精度:

inverted [1.2761547603607177, 0.6332]
shifted [0.6951200264453888, 0.7679]

我们确实看到了一些渐进的改进。我们的反转数据集上升到 63%。因此,它正在学习更好地过滤掉数字的背景和白色,但仍然没有达到目标。我们的平移数据集测试跃升至 76%。所以你可以看到卷积层是如何学习数字的空间关系与图像中的位置(与 DNN 相比)。

12.2.6 图像增强

最后,让我们使用图像增强来尝试改进对分布外替代测试数据的泛化。回想一下,图像增强是一个通过在现有样本上进行小修改来生成新样本的过程。这些修改不会改变图像的分类,而且图像仍然会被人类眼睛识别为那个类别。

图 12.13 展示了图像增强的一个示例,其中一张猫的图片被随机旋转,然后裁剪并调整大小回到原始形状。这张图片仍然可以被人类眼睛识别为猫。

图 12.13 使用随机选择的平移生成的图像增强示例管道

除了向训练集中添加更多样本外,某些类型的增强可以帮助模型泛化,以便准确分类测试(保留)数据集之外的图像,否则模型可能会在这些图像上失败。

正如我们在 CNN 中看到的,我们在平移图像上仍然缺乏足够的精度;因此,我们的模型还没有完全学会将数字的空间关系从图像中的位置和背景中分离出来。我们可以添加更多过滤器并增加卷积层,以尝试提高平移图像上的精度。这将使模型更复杂,训练时间更长,并且在部署进行预测(推理)时会有更大的内存占用和更长的延迟。

或者,我们将通过使用图像增强来随机左右移动图像最多 20%来改进模型。由于我们的图像宽度为 28 像素,20%意味着图像在任一方向上最多移动 6 像素。我们有一个最小 4 像素的边界,因此数字的裁剪将很少或没有。

我们将使用 TF.Keras 中的ImageDataGenerator类来进行图像增强。在下面的代码示例中,我们执行以下操作:

  • 创建与之前相同的 CNN 模型。

  • 实例化一个ImageDataGenerator生成器对象,其参数width_shift_range=0.2将在训练期间通过随机左右移动图像+/- 20%来增强数据集。

  • 调用fit_generator()方法,使用我们的图像增强生成器和现有的训练数据来训练模型。

  • 在生成器中指定steps_per_epoch的数值为训练样本数除以批大小;否则,生成器将在第一个 epoch 上无限循环:

from tensorflow.keras.preprocessing.image import ImageDataGenerator

model = CNN([16, 32])
datagen = ImageDataGenerator(width_shift_range=0.2)                  ❶
model.fit_generator(datagen.flow(x_train, y_train, batch_size=32),
                    steps_per_epoch= 60000 // 32 , epochs=10)        ❷
score = model.evaluate(x_test, y_test, verbose=1)
print("test", score)

❶ 实例化随机左右移动图像 +/- 20%的生成器

❷ 使用图像增强训练模型

下面是我们对 CNN 训练的结果:

test [0.046405045648082156, 0.986]

让我们看看这能否提高分布外测试数据的准确率:

inverted [4.463096208190918, 0.2338]
shifted [0.06386796866590157, 0.9796]

哇,现在我们在移动数据上的准确率接近 98%。所以我们能够训练模型学习数字在图像中移动时的空间关系,而不会增加模型的复杂性。但在倒置数据上我们还没有看到任何改进。

现在我们来训练模型,以过滤掉数字的背景和白色,以提高模型泛化到分布外倒置测试数据的能力。在下面的代码中,我们像测试数据那样取了 10%的训练数据(x_train_copy[0:6000])并对其进行倒置。为什么是 10%而不是全部训练数据?当我们想要训练一个模型来过滤掉某些东西时,我们通常可以用整个训练数据分布的 10%来完成。

接下来,我们将原始训练数据与额外的倒置训练数据合并,将两个训练集连接在一起——包括x_train(数据)和y_train(标签——在我们的训练集中总共 66,000 张图像(与 60,000 张相比):

x_train_invert = np.invert(x_train_copy[0:6000])                        ❶
x_train_invert = (x_train_invert / 255.0).astype(np.float32)            ❶
x_train_invert = x_train_invert.reshape(-1, 28, 28, 1)                  ❶

y_train_invert = x_train[0:6000]                                        ❷

x_combine = np.append(x_train, x_train_invert, axis=0)                  ❸
y_combine = np.append(y_train, y_train_invert, axis=0)                  ❸

model = CNN([16, 32])
datagen = ImageDataGenerator(width_shift_range=0.2)
datagen.fit(x_train_combine)
model.fit_generator( datagen.flow(x_combine, y_combine, batch_size=32), 
                     steps_per_epoch= 66000 // 32 , epochs=10)          ❹
score = model.evaluate(x_test, y_test, verbose=1)
print("test", score)

❶ 从(副本)训练数据中选择 10%并对其进行倒置

❷ 选择相同的 10%的对应标签

❸ 将两个训练数据集合并成一个训练集

❹ 使用合并的训练数据集训练模型

下面是我们对 CNN 训练的结果:

test [0.04763028650498018, 0.9847]

让我们看看这能否提高我们备用测试数据的准确率:

inverted [0.13941174189522862, 0.9589]
shifted [0.06449916120804847, 0.979]

哇,我们在倒置图像上的测试准确率接近 96%。

12.2.7 最终测试

作为最后的测试,我从谷歌图片搜索中随机选择了一些手写单个数字的“野外”图像。这些图像包括用彩色绘制的、用圆珠笔绘制的、用画笔绘制的以及由小孩子用蜡笔绘制的图像。在我完成测试后,我使用本章训练的 CNN 只得到了 40%的准确率。

为什么只有 40%,我们该如何诊断原因?问题应该是模型学习了哪个子群体分布?模型是否学会了独立于背景对比的数字轮廓的泛化,还是它只是学会了数字要么是白色要么是黑色?如果我们用黑色数字在灰色背景上(而不是白色)进行测试会发生什么?

MNIST 的训练和测试数据是用笔或铅笔绘制的数字,所以线条很细。我的一些“野外”图像线条较粗,是用圆珠笔、画笔或蜡笔绘制的。模型是否学会了泛化线条的粗细?关于纹理呢?用蜡笔和颜料绘制的数字有粗糙的纹理;这些纹理差异是否作为边缘在卷积层中被学习?

作为最后的例子,假设你开发了一个用于在工厂中检测零件缺陷的模型。相机位于一个固定的位置,其视角覆盖着一个有凹槽的灰色输送带。一切正常,直到有一天,所有者用一条光滑的黄色输送带来替换它,以给工厂增添一些色彩,现在缺陷检测模型失败了。发生了什么?好吧,因为灰色输送带在所有训练图像中,它就会成为潜在空间中学习特征的一部分,在进入任务学习器(分类器)之前。这类似于经典的狗与狼的案例,其中所有的狼照片都是在冬天拍摄的。在这个经典案例中,当训练模型被给了一张背景有雪的狗的照片(分布外)时,模型预测的是。在这种情况下,模型只是学会了意味着

摘要

  • 样本分布模型了一个群体分布的参数。

  • 子群体分布模型了一个偏差,这是群体分布的一个子部分。

  • 如果你在一个子群体分布上进行训练,并且你的模型在生产中对它看到的例子没有泛化,那么生产数据很可能超出了你训练的子群体分布。这也被称为服务偏差。

  • 添加更深或更宽的层以及/或更多的正则化通常不会帮助泛化到分布外的群体。

  • 从图像增强中生成训练样本有时可以帮助泛化到分布外的群体。

  • 当图像增强不足以泛化时,你需要添加来自分布外子群体的训练示例。

13 数据管道

本章涵盖

  • 理解训练数据集的常见数据格式和存储类型

  • 使用 TensorFlow TFRecord 格式和 tf.data 进行数据集表示和转换

  • 构建用于训练期间向模型提供数据的数据管道

  • 使用 TF.Keras 预处理层、层子类化和 TFX 组件进行预处理

  • 使用数据增强来训练具有平移、缩放和视口不变性的模型

您已经构建了模型,根据需要使用了可组合模型。您已经训练和重新训练了它,并进行了测试和重新测试。现在您准备将其发布。在接下来的两章中,您将学习如何发布一个模型。更具体地说,您将使用 TensorFlow 2.x生态系统和 TensorFlow Extended (TFX)将模型从准备和探索阶段迁移到生产环境。

在生产环境中,训练和部署等操作作为管道执行。管道具有可配置、可重用、版本控制和保留历史记录的优势。由于生产管道的广泛性,我们需要两章来涵盖它。本章重点介绍数据管道组件,这些组件构成了生产管道的前端。下一章将涵盖训练和部署组件。

让我们从一张图开始,这样您可以看到从开始到结束的过程。图 13.1 显示了基本端到端(e2e)生产管道的整体视图。

图片

图 13.1 基本端到端(e2e)生产管道从数据管道开始,然后移动到训练和部署管道。

现代基本机器学习端到端(e2e)管道从数据仓库开始,这是所有生产模型训练数据的存储库。企业规模的公司正在训练的模型数量各不相同。但以下是我 2019 年经验中的一些例子。生产模型(新版本)重新训练的时间间隔已从每月减少到每周,在某些情况下甚至每天。我的雇主谷歌每天重新训练超过 4000 个模型。在这个规模上的数据仓库是一项巨大的任务。

从数据仓库中,我们需要高效地为模型训练提供数据,并确保数据及时提供,且没有输入/输出(I/O)瓶颈。编译和组装训练批次的上游过程必须在实时中足够快,以免阻碍 GPU/CPU 训练硬件。

在企业规模上,数据仓库通常分布在大量或庞大的计算实例上,无论是在本地、云端还是混合部署,这使得在训练过程中高效地提供数据变得更加具有挑战性。

现在是模型训练阶段。但我们不是训练单个模型的实例。我们并行训练多个实例以找到最佳版本,并且我们在多个阶段进行,从数据收集、准备、增强和预训练到完整训练的超参数搜索。

如果我们将时钟倒退几年,这个管道过程始于领域专家做出有根据的猜测并自动化它们。今天,这些阶段正在变得自我学习。我们已经从自动化(由专家设定规则)发展到自动学习,机器持续地从专家的人类指导中自我学习以不断改进。这就是为什么训练多个模型实例的原因:为了自动学习哪个模型实例将成为最佳训练模型。

然后是版本控制。我们需要一种方法来评估新的训练实例与过去版本,以回答新实例是否比上一个实例更好的问题。如果是这样,就进行版本控制;如果不是,就重复这个过程。对于新版本,将模型部署到生产使用。

在本章中,我们介绍了将数据从存储中移动、预处理数据和将其分批以在训练期间提供给模型实例的过程。在下一章中,我们将介绍训练、重新训练和持续训练、候选模型验证、版本控制、部署以及部署后的测试。

13.1 数据格式和存储

我们将首先查看用于存储机器学习图像数据的各种格式。从历史上看,图像数据存储在以下之一:

  • 压缩图像格式(例如,JPG)

  • 未压缩的原始图像格式(例如,BMP)

  • 高维格式(例如,HDF5 或 DICOM)

使用 TensorFlow 2.x,我们以 TFRecord 格式存储图像数据。让我们更详细地看看这四种格式中的每一种。

13.1.1 压缩和原始图像格式

当深度学习首次在计算机视觉中变得流行时,我们通常直接从原始图像数据中训练,在图像数据解压缩之后。有两种基本方法来准备这种图像数据:从磁盘绘制 JPG、PNG 或其他压缩格式的训练批次;以及在 RAM 中绘制压缩图像的批次。

从磁盘抽取批次

在第一种方法中,当我们构建训练批次时,我们从磁盘以压缩格式(如 JPG 或 PNG)读取图像批次。然后我们在内存中解压缩它们,调整大小,并进行图像预处理,如归一化像素数据。

图 13.2 描述了此过程。在这个例子中,根据批次大小指定的 JPG 图像子集被读入内存,然后解压缩,最后调整大小到模型的输入形状以进行训练。

图片

从磁盘绘制压缩图像很容易做,但持续重新处理以进行训练的成本很高。

让我们看看这种方法的一些优缺点。首先,它非常容易做。以下是步骤:

  1. 创建磁盘上所有图像路径及其对应标签的索引(例如,CSV 索引文件)。

  2. 将索引读入内存并随机打乱索引。

  3. 通过使用打乱的索引文件,将一批图像及其对应标签绘制到内存中。

  4. 解压图像。

  5. 将解压后的图像调整到模型的输入形状以进行训练。

最大的缺点是,在训练模型时,你必须为每个 epoch 重复前面的步骤。可能成为问题的一步是从磁盘获取数据。这一步可能成为 I/O 瓶颈,并且具体依赖于磁盘存储的类型和数据的位置。理想情况下,我们希望数据存储在尽可能快的读取访问磁盘操作中,并且尽可能靠近(通过限制网络带宽)进行训练的计算设备。

为了比较磁盘和内存之间的这种权衡,假设你正在使用一个具有 ImageNet 输入形状(224, 224, 3)的 SOTA 模型。这个大小对于一般图像分类来说是典型的,而对于图像目标检测或分割,则使用像(512, 512, 3)这样的大尺寸。

形状为(224, 224, 3)的图像需要 150,000 字节的内存(224 × 224 × 3 = 150,000)。为了在 ImageNet 输入形状中连续存储 50,000 个训练图像,你需要 8 GB(50,000 × 150,000)的 RAM——这超过了操作系统、后台应用程序和模型训练所需的内存。现在假设你有 100,000 个训练图像。那么你需要 16 GB 的 RAM。如果你有一百万个图像,你需要 160 GB 的 RAM。

这将需要大量的内存,并且通常只有对于较小的数据集,将所有图像以未压缩格式存储在内存中才是实用的。对于学术和其他教程目的,训练数据集通常足够小,可以将解压和调整大小的图像完全存储在内存中。但在生产环境中,由于数据集太大而无法完全存储在内存中,我们需要使用一种策略,该策略结合了从磁盘获取图像。

从 RAM 中的压缩图像中抽取批次

在这种第二种策略中,我们消除了磁盘 I/O,但每次图像出现在批次中时,仍然在内存中解压缩和调整大小。通过消除磁盘 I/O,我们防止了 I/O 瓶颈,否则会减慢训练速度。例如,如果训练包括 100 个 epoch,每个图像将被解压缩和调整大小 100 次——但所有压缩图像都保持在内存中。

平均 JPEG 压缩约为 10:1。压缩图像的大小将取决于图像来源。例如,如果图像来自 350 万像素的手机(350 万像素),则压缩图像约为 350,000 字节。如果我们的图像是为浏览器加载进行优化的网页图像,则未压缩图像通常在 150,000 到 200,000 字节之间。

假设你有 100,000 张经过优化的训练图像,2 GB 的 RAM 就足够了(100K × 15K = 1.5 GB)。如果你有一百万张训练图像,16 GB 的 RAM 就足够了(1M × 15K = 15 GB)。

图 13.3 说明了这里概述的第二个方法:

  1. 将所有压缩图像及其相应的标签读取到内存中作为一个列表。

  2. 为内存中所有图像列表索引及其相应的标签创建一个索引。

  3. 随机打乱索引。

  4. 通过使用打乱的索引文件从内存中抽取一批图像及其相应的标签。

  5. 解压缩图像。

  6. 调整解压缩图像的大小。

图片

图 13.3 从 RAM 中抽取压缩图像消除了磁盘 I/O,从而加快了过程。

这种方法对于中等大小的数据集来说通常是一个合理的折衷方案。假设我们拥有 200,000 张大小经过优化的网页图像。我们只需要 4 GB 的内存来在内存中存储所有压缩图像,而无需反复从磁盘读取。即使是大批量的图像(比如说,1024 张经过优化的网页图像),我们也只需要额外的 150 MB 内存来存储解压缩的图像——平均每张图像 150,000 字节。

下面是我的常规做法:

  1. 如果我的训练数据的解压缩大小小于或等于我的 RAM,我将使用内存中的解压缩图像进行训练。这是最快的选项。

  2. 如果我的训练数据的压缩大小小于或等于我的 RAM,我将使用内存中的压缩图像进行训练。这是下一个最快的选项。

  3. 否则,我将使用从磁盘抽取的图像进行训练,或者使用我接下来要讨论的混合方法。

混合方法

接下来,让我们考虑从磁盘和内存中喂食训练图像的混合方法。我们为什么要这样做呢?我们想要在可用内存空间和不断从磁盘重新读取图像的 I/O 受限之间找到一个最佳平衡点。

要做到这一点,我们将回顾第十二章中关于采样分布的概念,它近似了总体分布。想象一下,你有 16 GB 的内存来存储数据,预处理后的数据集在调整大小后是 64 GB。在混合喂食中,我们一次取一个大的预处理数据段(在我们的例子中是 8 GB),这些数据段已经被分层(示例与训练数据类别分布相匹配)。然后我们反复将相同的段作为 epochs 输入到神经网络中。但每次,我们都会进行图像增强,使得每个 epoch 都是整个预处理图像数据集的唯一采样分布。

我建议在极大数据集上使用这种方法,比如一百万张图像。有了 16 GB 的内存,你可以存储非常大的子分布,并且能够与反复从磁盘读取相比,在可比的训练批次中获得收敛,同时减少训练时间或计算实例需求。

下面是进行混合内存/磁盘喂食的步骤。你还可以在图 13.4 中看到这个过程:

  1. 在磁盘上创建一个对预处理的图像数据的分层索引。

  2. 根据可用的内存将分层索引划分为存储一个段在内存中的分区。

  3. 对于每个段,重复指定数量的 epoch:

    • 在每个 epoch 中随机打乱段。

    • 在每个 epoch 中随机应用图像增强以创建独特的采样分布。

    • 将 mini-batch 输送到神经网络。

图像

图 13.4 从磁盘混合绘制图像作为训练数据的采样分布

13.1.2 HDF5 格式

层次数据格式 5 (HDF5) 已经是存储高维数据(如高分辨率卫星图像)的长期通用格式。因此,你可能想知道什么是 高维性?我们将此术语与信息非常密集的单维数据相关联,并且/或具有许多维度(我们称之为 多维数据)。正如之前关于 TFRecords 的讨论,这些格式本身并没有实质性地减少存储所需的磁盘空间。相反,它们的目的在于快速读取访问以减少 I/O 开销。

HDF5 是一种用于存储和访问大量多维数据(如图像)的高效格式。规范可以在 HDF5 for Python 网站找到([www.h5py.org/](https://www.h5py.org/))。该格式支持数据集和组对象,以及每个对象的属性(元数据)。

使用 HDF5 存储图像训练数据的优点包括以下内容:

  • 具有广泛的科学用途,例如 NASA 使用的卫星图像(见 mng.bz/qevJ

  • 优化了高速数据切片访问

  • NumPy 是否与 NumPy 语法兼容,允许从磁盘访问,就像在内存中一样

  • 具有对多维表示、属性和分类的分层访问

Python 的 HDF5 包可以按照以下方式安装:

pip install h5py

让我们从创建一个包含最基本 HDF5 表示的 dataset 开始,这个表示由原始(解压缩)图像数据和相应的整数标签数据组成。在这个表示中,我们创建了两个 dataset 对象,一个用于图像数据,另一个用于相应的标签:

dataset['images'] : [...]
dataset['labels'] : [...]

以下代码是一个示例实现。训练数据和标签都是 NumPy 格式。我们打开一个 HDF5 文件以进行写入访问,并创建两个数据集,一个用于图像,一个用于标签:

from tensorflow.keras.datasets import cifar10
(x_train, y_train), (x_test, y_test) = cifar10.load_data()

with h5py.File('myfile.h5', 'w') as hf:          ❶
    hf.create_dataset("images", data=x_train)    ❷
    hf.create_dataset("labels", data=y_train)    ❸

❶ 打开 HDF5 文件以进行写入访问

❷ 将训练图像存储为名为“images”的数据集

❸ 将训练标签存储为名为“labels”的数据集

现在,当我们想要读取图像和标签时,我们首先打开 HDF5 以进行读取访问。然后我们为数据集的图像和标签创建一个迭代器。HDF5 文件句柄是一个字典对象,我们通过数据集名称作为键来引用我们的命名数据集。

接下来,我们重新打开 HDF5 文件进行读取访问,然后为数据集的图像和标签创建 HDF5 迭代器,使用键 imageslabels。在这里,x_trainy_train 是 HDF5 迭代器的别名。数据实际上尚未在内存中:

hf = h5py.File('myfile.h5', 'r')    ❶
x_train = hf['images']              ❷
y_train = hf['labels']              ❸

❶ 打开 HDF5 文件进行读取访问

❷ 为图像数据集创建 HDF5 迭代器

❸ 为标签数据集创建 HDF5 迭代器

由于 HDF5 迭代器使用 NumPy 语法,我们可以通过使用 NumPy 数组切片直接访问数据,这将从磁盘获取数据并将其加载到内存中的 NumPy 数组。在以下代码中,我们通过图像的数组切片(x_batch)和相应的标签(y_batch)获取单个批次:

x_batch = x_train[0:100]     ❶
y_batch = y_train[0:100]     ❷

❶ 前面的 100 张图像现在作为 NumPy 数组存储在内存中。

❷ 前面的 100 个标签现在作为 NumPy 数组存储在内存中。

接下来,我们将整个数据集作为批次迭代,并将每个批次输入到模型中进行训练。假设在我们的 HDF5 数据集中存储了 50,000 张图像(例如,在 CIFAR-10 数据集中)。

我们以 50 个批次的尺寸遍历 HDF5 数据集。每次,我们引用下一个顺序数组切片。迭代器将每次从磁盘中抽取 50 张图像并将它们加载到内存中的 x_batch 数组。我们同样为相应的标签执行相同的操作,这些标签被加载到 y_batch 数组中。然后,我们将图像批次和相应的标签传递给 TF.Keras 方法 train_on_batch(),该方法对模型执行单个批次的更新:

examples = 50000
batch_size = 50
batches = examples / batch_size
for batch in range(batches):    
    x_batch = x_train[batch*batch_size:(batch+1)*batch_size]     ❶
    y_batch = y_train[batch*batch_size:(batch+1)*batch_size]     ❶
    model.train_on_batch(x_batch, y_batch)                       ❷

❶ 从 HDF5 文件中抽取下一个批次作为内存中的 NumPy 切片

❷ 更新批次的模型

HDF5 组

接下来,我们将查看使用组存储 HDF5 格式数据集的另一种存储表示。这种方法具有更高效的存储,消除了存储标签的需求,并且可以存储分层数据集。在这种表示中,我们将为每个类别(标签)及其对应的数据集创建一个单独的组。

以下示例描述了这种表示。我们有两个类别,catsdogs,并为每个类别创建一个组。在两个组中,我们为相应的图像创建一个数据集。请注意,我们不再需要存储标签数组,因为它们由组名称隐含表示:

Group['cats']
    Dataset['images']: [...]
Group['dogs']
    Dataset['images']: [...]

以下代码是一个示例实现,其中 x_catsx_dogs 是猫和狗图像对应的内存中 NumPy 数组:

with h5py.File('myfile.h5', 'w') as hf:
    cats = hf.create_group('cats')                 ❶
    cats.create_dataset('images', data=x_cats)     ❶
    dogs = hf.create_group('dogs')                 ❷
    dogs.create_dataset('images', data=x_dogs)     ❷

❶ 在组中创建猫类别及其对应的用于存储猫图像的数据集

❷ 在组中创建狗类别及其对应的用于存储狗图像的数据集

然后我们从猫和狗的组版本中读取一批数据。在这个例子中,我们打开 HDF5 组句柄到猫和狗的组。然后使用字典语法引用 HDF5 组句柄。例如,要获取猫图片的迭代器,我们将其引用为 cats['images']。接下来,我们使用 NumPy 数组切片从猫数据集中抽取 25 张图片和从狗数据集中抽取 25 张图片,将它们作为 x_batch 存入内存。最后一步,我们在 y_batch 中生成相应的整数标签。我们将 0 分配给 cats,将 1 分配给 dogs

hf = h5py.File('myfile.h5', 'r')
cats = hf['cats']                                                        ❶
dogs = hf['dogs']                                                        ❶
x_batch = np.concatenate([cats['images'][0:25], dogs['images'][0:25]])   ❷
y_batch = np.concatenate([np.full((25), 0), np.full((25), 1)])           ❸

❶ 为猫和狗的组打开 HDF5 组句柄

❷ 在相应的组内从猫和狗数据集中抽取一批数据

❸ 创建相应的标签

该格式支持对图像进行分层存储,当图像具有分层标签时。如果图像具有分层标签,每个组将进一步划分为子组的层次结构,如下所示。此外,我们使用Group属性显式地为相应的标签分配一个唯一的整数值:

Group['cats']
        Attribute: {label: 0}
        Group['persian']:
                Attribute: {label: 100}
                Dataset['images']: [...]
        Group['siamese']:
                Attribute: {label: 101}
                Dataset['images']: [...]
Group['dogs']
        Attribute: {label: 1}
        Group['poodles']:
                Attribute: {label: 200}
                Dataset['images']: [...]
        Group['beagle']:
                Attribute: {label: 201}
                Dataset['images']: [...]

要实现这种分层存储,我们创建顶级组和子组。在这个例子中,我们为猫创建一个顶级组。然后,使用猫的 HDF5 组句柄,我们为每个品种创建子组,例如persiansiamese。然后,对于每个品种的子组,我们为相应的图片创建一个数据集。此外,我们使用attrs属性显式地为唯一的标签值分配:

with h5py.File('myfile.h5', 'w') as hf:
    cats = hf.create_group('cats')                             ❶
    cats.attrs['label'] = 0                                    ❶

    breed = cats.create_group('persian')                       ❷
    breed.attrs['label'] = 100                                 ❷
    breed.create_dataset('images', data=x_cats['persian'])     ❷
    breed = cats.create_group('siamese')                       ❸
      breed.attrs['label'] = 101                               ❸
    breed.create_dataset('images', data=x_cats['siamese'])     ❸

❶ 为猫创建顶级组并分配标签 0 作为属性

❷ 在猫组下创建一个二级子组用于波斯猫品种,分配标签,并添加波斯猫的图片

❸ 在猫组下为品种暹罗猫创建一个二级子组,分配标签,并添加暹罗猫的图片

总结来说,HDF5 组功能是访问分层标记数据的简单高效存储方法,尤其是对于具有分层关系的多标签数据集。另一个常见的多标签分层示例是产品。在分层结构的顶部,你有两个类别:水果蔬菜。在这两个类别下面是类型(例如,苹果、香蕉、橙子),在类型下面是品种(例如,格兰尼史密斯、加拉、金色美味)。

13.1.3 DICOM 格式

虽然 HDF5 格式在卫星图像中广泛使用,但医学数字成像和通信DICOM)格式在医学成像中使用。实际上,DICOM 是存储和访问医学成像数据(如 CT 扫描和 X 射线)以及患者信息的 ISO 12052 国际标准。这种格式比 HDF5 更早,专门用于医学研究和医疗保健系统,广泛使用,拥有大量的公开去标识的健康成像数据集。如果你正在处理医学成像数据,你需要熟悉这种格式。

在这里,我将介绍一些使用该格式的基本指南,以及一个演示示例。但如果你是,或者计划成为医学影像方面的专家,我建议你查看 DICOM 网站上的 DICOM 规范和培训教程(www.dicomstandard.org/)。

可以按照以下方式安装 Python 的 DICOM 包:

pip install pydicom

通常,DICOM 数据集非常大,达到数百个吉字节。这是因为该格式仅用于医学成像,通常包含用于分割的极高分辨率图像,并且可能还包含每张图像的 3D 切片层。

Pydicom 是一个 Python 开源包,用于处理 DICOM 格式的医学图像,它提供了一个用于演示的小数据集。我们将使用这个数据集进行我们的编码示例。让我们首先导入 Pydicom 包并获取测试数据集CT_small.dcm

import pydicom
from pydicom.data import get_testdata_files

dcm_file = get_testdata_files('CT_small.dcm')[0]     ❶

❶ 此 Pydicom 方法返回演示数据集的文件名列表。

在 DICOM 中,标记的数据还包含表格数据,如患者信息。图像、标签和表格数据可以用于训练一个多模态模型(具有两个或更多输入层的模型),每个输入层具有不同的数据类型(例如,图像或数值)。

让我们看看如何从 DICOM 文件格式中读取图像和标签。我们将读取我们的演示数据集,该数据集模拟了患者医学影像数据的真实世界示例,并首先获取一些关于数据集的基本信息。每个数据集包含大量患者信息,可以作为一个字典访问。这个例子只展示了其中的一些字段,其中大部分已经被去标识化。研究日期表示图像拍摄的时间,而模态是成像类型(在这种情况下,为 CT 扫描):

dataset = pydicom.dcmread(dcm_file)
for key in ['PatientID', 'PatientName', 'PatientAge', 'PatientBirthDate', 
            'PatientSex', 'PatientWeight', 'StudyDate', 'Modality']:
    print(key, dataset[key])

PatientID (0010, 0020) Patient ID                          LO: '1CT1'
PatientName (0010, 0010) Patient's Name                      PN: 'CompressedSamples^CT1'
PatientAge (0010, 1010) Patient's Age                      AS: '000Y'
PatientBirthDate (0010, 0030) Patient's Birth Date         DA: ''
PatientSex (0010, 0040) Patient's Sex                      CS: 'O'
PatientWeight (0010, 1030) Patient's Weight                DS: "0.0"
StudyDate (0008, 0020) Study Date                          DA: '20040119'
Modality (0008, 0060) Modality                             CS: 'CT'

最后,我们将提取图像数据并按此处和图 13.5 所示进行显示:

rows = int(dataset.Rows)
cols = int(dataset.Columns)
print("Image size.......: {rows:d} x {cols:d}, {size:d} bytes".format(
        rows=rows, cols=cols, size=len(dataset.PixelData)))

plt.imshow(dataset.pixel_array, cmap=plt.cm.bone)
plt.show()

图 13.5 从 DICOM 文件中提取的图像

关于访问和解析 DICOM 图像的更多详细信息可以在规范以及 Pydicom 教程中找到(pydicom.github.io/)。

13.1.4 TFRecord 格式

TFRecord是 TensorFlow 用于存储和访问用于 TensorFlow 训练的数据集的标准格式。这种二进制格式最初是为了使用 Google 的协议缓冲区定义高效地序列化结构化数据而设计的,但后来由 TensorFlow 团队进一步开发,用于高效地序列化非结构化数据,如图像、视频和文本。除了是 TensorFlow 组织推荐的训练数据格式外,该格式已无缝集成到 TF 生态系统,包括tf.data和 TFX。

在这里,我们再次只是了解一下如何使用该格式为训练 CNN 的图像。对于详细信息和标准信息,请查看教程 (www.tensorflow.org/tutorials/load_data/tfrecord)。

图 13.6 是使用 TFRecords 作为 tf.data 表示的分层关系图。以下是三个步骤:

  1. 在最高层是 tf.data.Dataset。这是训练数据集的内存表示。

  2. 下一个级别是一系列一个或多个 TFRecords。这些是数据集的磁盘存储。

  3. 在最底层是 tf.Example 记录;每个记录包含一个单一的数据示例。

图 13.6 tf.data、TFRecords 和 tf.Example 的分层关系

现在我们从底层开始描述这种关系。我们将把训练数据中的每一个数据示例转换为 tf.Example 对象。例如,如果我们有 50,000 个训练图像,我们就有 50,000 个 tf.Example 记录。接下来,我们将这些记录序列化,以便它们在磁盘上作为 TFRecord 文件具有快速的读取访问。这些文件是为了顺序访问而设计的,不是随机访问,以最小化读取访问,因为它们将只写入一次但将被多次读取。

对于大量数据,记录通常被分割成多个 TFRecord 文件,以进一步最小化特定于存储设备的读取访问时间。虽然每个序列化的 tf.Example 条目的大小、示例数量、存储设备类型和分布将最好地决定分区大小;TensorFlow 团队建议每个分区的大小为 100 到 200 MB 作为一般规则。

tf.Example: Features

tf.Example 的格式与 Python 字典和 JSON 对象都有相似之处。一个示例(例如,一个图像)及其相应的元数据(例如,标签)封装在 tf.Example 类对象中。此对象由一个或多个 tf.train.Feature 条目列表组成。每个特征条目可以是以下数据类型之一:

  • tf.train.ByteList

  • tf.train.FloatList

  • tf.train.Int64List

tf.train.ByteList 类型用于字节序列或字符串。字节的例子可以是图像的编码或原始字节,字符串的例子可以是 NLP 模型的文本字符串或标签的类名。

tf.train.FloatList 类型用于 32 位(单精度)或 64 位(双精度)浮点数。结构化数据集中某一列的连续实值是一个例子。

tf.train.Int64List 类型用于 32 位和 64 位的有符号和无符号整数以及布尔值。对于整数,此类型用于结构化数据集中某一列的分类值,或标签的标量值,例如。

使用 tf.Example 格式编码图像数据时,采用了一些常见的做法:

  • 用于编码图像数据的特征条目

  • 为图像形状(用于重建)创建一个特征条目

  • 为相应的标签创建一个特征条目

以下是一个定义 tf.train.Example 以编码图像的通用示例;/entries here/ 是图像数据和相应元数据的字典条目的占位符,我们将在后面讨论。请注意,TensorFlow 将此格式称为 tf.Example,数据类型称为 tf.train.Example。这可能会让人一开始感到困惑。

example = tf.train.Example(features = { /entries here/ })

tf.Example:压缩图像

在下一个示例中,我们创建一个未解码的图像 tf.train.Example 对象(图像以压缩的磁盘格式存储)。这种方法的好处是,当作为 TFRecord 的一部分存储时,占用的磁盘空间最少。缺点是,在训练过程中,每次从磁盘读取 TFRecord 并向神经网络提供数据时,都必须解压缩图像数据;这是时间和空间之间的权衡。

在下面的代码示例中,我们定义了一个函数,用于将磁盘上的图像文件(参数 path)和相应的标签(参数 label)转换为以下形式:

  • 首先使用 OpenCV 方法 cv2.imread() 读取磁盘上的图像,并将其解压缩为原始位图,以获取图像的形状(行数、列数、通道数)。

  • 使用 tf.io.gfile.GFile() 再次从磁盘读取图像,格式保持原始压缩状态。注意,tf.io.gfile.GFile() 等同于文件 open() 函数,但如果图像存储在 GCS 存储桶中,该方法针对 I/O 读写性能进行了优化。

  • 使用三个字典条目为特征对象实例化一个 tf.train.Example() 实例:

    • image — 一个 BytesList,用于存储未压缩(原始磁盘数据)的图像数据

    • label — 一个表示标签值的 Int64List

    • shape — 一个表示图像形状(行数、高度、通道数)的 Int64List 元组

在我们的示例中,如果我们假设磁盘上图像的大小为 24,000 字节,那么 TFRecord 文件中 tf.train.Example 条目的大小大约为 25,000 字节。

import tensorflow as tf
import numpy as np
import sys
import cv2

def TFExampleImage(path, label):
        ''' The original compressed version of the image '''

        image = cv2.imread(path)                                            ❶
        shape = image.shape                                                 ❶

        with tf.io.gfile.GFile(path, 'rb') as f:                            ❷
            disk_image = f.read()                                           ❷

        return tf.train.Example(features = tf.train.Features(feature = {
        'image': tf.train.Feature(bytes_list = tf.train.BytesList(value =
                                  [disk_image])),                           ❸
        'label': tf.train.Feature(int64_list = tf.train.Int64List(value =
                                  [label])),                                ❹
        'shape': tf.train.Feature(int64_list = tf.train.Int64List(value =
                                  [shape[0], shape[1], shape[2]]))          ❺
        }))

example = TFExampleImage('example.jpg', 0)
print(example.ByteSize())

❶ 使用 OpenCV 获取图像的形状

❷ 使用 TensorFlow 从 GCS 存储桶中读取压缩图像

❸ 为压缩图像的字节数据创建一个特征条目

❹ 为相应的标签创建一个特征条目

❺ 为相应的形状(H × W × C)创建一个特征条目

tf.Example:未压缩图像

在接下来的代码示例中,我们创建一个 tf.train.Example 条目来存储图像的未压缩版本到 TFRecord 中。这样做的好处是只需从磁盘读取一次图像,并且在训练过程中从磁盘上的 TFRecord 读取条目时不需要解压缩。

缺点是条目的大小将显著大于图像的磁盘版本。在前面的示例中,假设 95% 的 JPEG 压缩率,TFRecord 中的条目大小将是 500,000 字节。注意,在图像数据的 BytesList 编码中,保留了 np.uint8 数据格式。

def TFExampleImageUncompressed(path, label):
        ''' The uncompressed version of the image '''

        image = cv2.imread(path)                                            ❶
        shape = image.shape                                                 ❶

        return tf.train.Example(features = tf.train.Features(feature = {
        'image': tf.train.Feature(bytes_list = tf.train.BytesList(value = 
                                  [image.tostring()])),                     ❷
        'label': tf.train.Feature(int64_list = tf.train.Int64List(value = 
                                  [label])),                                ❸
        'shape': tf.train.Feature(int64_list = tf.train.Int64List(value = 
                                  [shape[0], shape[1], shape[2]]))          ❹
        }))

example = TFExampleImageUncompressed('example.jpg', 0)
print(example.ByteSize())

❶ 使用 OpenCV 读取未压缩的图像

❷ 为未压缩的图像字节数据创建一个特征条目

❸ 为相应的标签创建一个特征条目

❹ 为相应的形状(H × W × C)创建一个特征条目

tf.Example:机器学习就绪

在我们的最后一个代码示例中,我们首先对像素数据进行归一化(通过除以 255)并存储归一化的图像数据。这种方法的优势在于,在训练过程中每次从磁盘上的 TFRecord 读取条目时,我们不需要对像素数据进行归一化。缺点是现在像素数据以 np.float32 存储的,比相应的 np.uint8 大四倍。假设相同的图像示例,现在 TFRecord 的大小将是 200 万字节。

def TFExampleImageNormalized(path, label):
        ''' The normalized version of the image '''

        image = (cv2.imread(path) / 255.0).astype(np.float32)               ❶
        shape = image.shape                                                 ❶

        return tf.train.Example(features = tf.train.Features(feature = {
        'image': tf.train.Feature(bytes_list = tf.train.BytesList(value =
                                  [image.tostring()])),                     ❷
        'label': tf.train.Feature(int64_list = tf.train.Int64List(value =
                                  [label])),                                ❸
        'shape': tf.train.Feature(int64_list = tf.train.Int64List(value =
                                  [shape[0], shape[1], shape[2]]))          ❹
        }))

example = TFExampleImageNormalized('example.jpg', 0)
print(example.ByteSize())

❶ 使用 OpenCV 读取未压缩的图像并对像素数据进行归一化

❷ 为未压缩的图像字节数据创建一个特征条目

❸ 为相应的标签创建一个特征条目

❹ 为相应的形状(H × W × C)创建一个特征条目

TFRecord:写入记录

现在我们已经在内存中构建了一个 tf.train.Example 条目,下一步是将它写入磁盘上的 TFRecord 文件。我们将这样做是为了在训练模型时从磁盘上喂入训练数据。

为了最大化写入和从磁盘存储读取的效率,记录被序列化为字符串格式以存储在 Google 的协议缓冲区格式中。在下面的代码中,tf.io.TFRecordWriter 是一个函数,它将序列化的记录写入到这种格式的文件中。在将 TFRecord 写入磁盘时,使用文件名后缀 .tfrecord 是一个常见的约定。

 with tf.io.TFRecordWriter('example.tfrecord') as writer:     ❶
        writer.write(example.SerializeToString())             ❷

❶ 创建一个 TFRecord 文件写入对象

❷ 将单个序列化的 tf.train.Example 条目写入文件

磁盘上的 TFRecord 文件可能包含多个 tf.train.Example 条目。以下代码将多个序列化的 tf.train.Example 条目写入 TFRecord 文件:

with tf.io.TFRecordWriter('example.tfrecord') as writer:      ❶
        for example in examples:                              ❷
            writer.write(example.SerializeToString())         ❷

❶ 创建一个 TFRecord 文件写入对象

❷ 将每个 tf.train.Example 条目顺序写入 TFRecord 文件

TFRecord:读取记录

下一个代码示例演示了如何按顺序从 TFRecord 文件中读取每个 tf.train.Example 条目。我们假设文件 example.tfrecord 包含多个序列化的 tf.train.Example 条目。

tf.compat.v1.io.record_interator() 创建了一个迭代器对象,当在 for 语句中使用时,将按顺序读取内存中的每个序列化的 tf.train.ExampleParseFromString() 方法用于将数据反序列化为内存中的 tf.train.Example 格式。

iterator = tf.compat.v1.io.tf_record_iterator('example.tfrecord')  ❶
for entry in iterator:                                             ❷
        example = tf.train.Example()                               ❷
        example.ParseFromString(entry)                             ❷

❶ 创建一个迭代器以按顺序遍历 tf.train.Example 条目

❷ 遍历每个条目并将序列化字符串转换为 tf.train.Example

或者,我们可以通过使用 tf.data.TFRecordDataset 类来读取和迭代来自 TFRecord 文件的一组 tf.train.Example 条目。在下一个代码示例中,我们执行以下操作:

  • 实例化一个 tf.data.TFRecordDataset 对象作为磁盘记录的迭代器

  • 定义字典 feature_description 以指定如何反序列化序列化的 tf.train.Example 条目

  • 定义辅助函数 _parse_function() 以接受一个序列化的 tf.train .Example (proto) 并使用字典 feature_description 进行反序列化

  • 使用 map() 方法迭代反序列化每个 tf.train.Example 条目

dataset = tf.data.TFRecordDataset('example.tfrecord')                   ❶

feature_description = {                                                 ❷
    'image': tf.io.FixedLenFeature([],  tf.string),                     ❷
    'label': tf.io.FixedLenFeature([],  tf.int64),                      ❷
    'shape': tf.io.FixedLenFeature([3], tf.int64),                      ❷
}

def _parse_function(proto):                                             ❸
    ''' parse the next serialized tf.train.Example using the feature    ❸
    description '''                                                     ❸
    return tf.io.parse_single_example(proto, feature_description)       ❸

parsed_dataset = dataset.map(_parse_function)                           ❹

❶ 为磁盘上的数据集创建一个迭代器

❷ 创建一个字典描述以反序列化 tf.train.Example

❸ 用于 tf.train.Example 的顺序解析函数

❹ 使用 map() 函数解析数据集的每个条目

如果我们打印 parsed_dataset,输出应该如下所示:

<MapDataset shapes: {image: (), shape: (), label: ()}, 
types: {image: tf.string, shape: tf.int64, label: tf.int64}>

13.2 数据馈送

在上一节中,我们讨论了数据在内存和磁盘上的结构和存储方式,用于训练。本节介绍使用 tf.data 将数据摄入到管道中,tf.data 是 TensorFlow 模块,用于构建数据集管道。它可以从各种来源构建管道,例如内存中的 NumPy 和 TensorFlow 张量以及磁盘上的 TFRecords。

数据集管道是通过 tf.data.Dataset 类创建的生成器。因此,tf.data 指的是 Python 模块,而 tf.data.Dataset 指的是数据集管道。数据管道用于预处理和为训练模型提供数据。

首先,我们将从内存中的 NumPy 数据构建数据管道,然后随后将构建一个从磁盘上的 TFRecords 构建的数据管道。

13.2.1 NumPy

要从 NumPy 数据创建一个内存中的数据集生成器,我们使用 tf.data .Dataset 方法 from_tensor_slices``()。该方法将训练数据作为参数,指定为一个元组:(images, labels)

在以下代码中,我们使用 CIFAR-10 NumPy 数据创建一个 tf.data.Dataset,我们将其指定为参数值 (x_train, y_train)

from tensorflow.data import Dataset
from tensorflow.keras.datasets import cifar10
(x_train, y_train), (x_test, y_test) = cifar10.load_data()

dataset = Dataset.from_tensor_slices((x_train, y_train))     ❶

❶ 为内存中的 NumPy 训练数据创建一个数据集生成器

注意,dataset 是一个生成器;因此它不可索引。你不能执行 dataset[0] 并期望获取第一个元素。这将抛出一个异常。

接下来,我们将遍历数据集。但我们要分批进行,就像我们在使用 TF.Keras 中的 fit() 方法馈送数据时指定批大小一样。在下一个代码示例中,我们使用 batch() 方法将数据集的批大小设置为 128。请注意,batch 不是一个属性。它不会改变现有数据集的状态,而是创建一个新的生成器。这就是为什么我们将 dataset .batch(128) 赋值回原始的 dataset 变量。TensorFlow 将这些类型的数据集方法称为 数据集转换

接下来,我们遍历数据集,并对每个批次 (x_batch, y_batch) 打印其形状。对于每个批次,这将输出图像数据的形状为 (128, 32, 32, 3) 以及相应的标签的形状为 (128, 1):

dataset = dataset.batch(128)                ❶
for x_batch, y_batch in dataset:            ❷
    print(x_batch.shape, y_batch.shape)     ❷

❶ 将数据集转换为以 128 个批次的迭代

❷ 以 128 个批次的批量遍历数据集

如果我们第二次重复相同的 for 循环迭代,我们将不会得到任何输出。为什么?发生了什么?默认情况下,数据集生成器只遍历一次数据集。为了连续重复,就像我们有多个时代一样,我们使用 repeat() 方法作为另一个数据集转换。由于我们希望每个时代都能看到不同随机顺序的批次,我们使用 shuffle() 方法作为另一个数据集转换。这里展示了数据集转换的顺序:

dataset = dataset.shuffle(1024)
dataset = dataset.repeat()
dataset = dataset.batch(128)

数据集转换方法也是可链式的。通常可以看到它们被链在一起。这一行与前面的三行序列相同:

dataset = dataset.shuffle(1024).repeat().batch(128)

应用转换的顺序很重要。如果我们首先使用 repeat() 然后是 shuffle() 转换,那么在第一个时代,批次将不会被随机化。

还要注意,我们为 shuffle() 转换指定了一个值。这个值表示每次从数据集中拉取到内存中并混洗的示例数量。例如,如果我们有足够的内存来存储整个数据集,我们将此值设置为训练数据中的示例总数(例如,CIFAR-10 的 50000)。这将一次性混洗整个数据集——一个完整的混洗。如果我们没有足够的内存,我们需要计算我们可以节省多少内存,并将其除以内存中每个示例的大小。假设我们有 2 GB 的空闲内存,每个内存中的示例是 200,000 字节。在这种情况下,我们将大小设置为 10,000(2 GB / 200K)。

在下一个代码示例中,我们使用 tf.data.Dataset 作为数据管道,用 CIFAR-10 数据训练一个简单的卷积神经网络。fit() 方法与 tf.data.Dataset 生成器兼容。我们不是传递原始图像数据和相应的标签,而是传递由变量 dataset 指定的数据集生成器。

因为它是一个生成器,fit() 方法不知道一个时代中会有多少批次。因此,我们需要额外指定 steps_per_epoch 并将其设置为训练数据中的批次数量。在我们的例子中,我们将其计算为训练数据中的示例数量除以批次大小 (50000 // 128)

from tensorflow.keras import Sequential
from tensorflow.keras.layers import Conv2D, ReLU, Activation, 
from tensorflow.keras.layers import BatchNormalization, Dense, Flatten

model = Sequential()
model.add(Conv2D(16, (3,3), strides=1, padding='same', input_shape=(32, 32, 3)))
model.add(BatchNormalization())
model.add(ReLU())
model.add(Conv2D(32, (3,3), strides=1, padding='same'))
model.add(BatchNormalization())
model.add(ReLU())
model.add(Flatten())
model.add(Dense(10, activation='softmax'))
model.compile(loss='sparse_categorical_crossentropy', optimizer='adam', 
              metrics=['acc'])

batches = 50000 // 128                                              ❶
model.fit(dataset, steps_per_epoch=batches, epochs=5, verbose=1)    ❷

❶ 计算数据集中的批次数量

❷ 使用 fit() 方法通过数据集生成器进行训练

在本节中,我们介绍了从内存中的数据源构建数据管道,例如在 NumPy 或 TensorFlow 张量格式中。接下来,我们将介绍使用 TFRecords 从磁盘上的数据源构建数据管道。

13.2.2 TFRecord

要从 TFRecord 文件创建磁盘上的数据集生成器,我们使用 tf.data 方法 TFRecordDataset()。该方法接受单个 TFRecord 文件的路径或多个 TFRecord 文件路径列表作为参数。如前所述,每个 TFRecord 文件可能包含一个或多个训练示例,例如图像,并且为了 I/O 性能,训练数据可能跨越多个 TFRecord 文件。

此代码为单个 TFRecord 文件创建数据集生成器:

dataset = tf.data.TFRecordDataset('example.tfrecord')

此代码示例为多个 TFRecord 文件创建数据集生成器,当数据集跨越多个 TFRecord 文件时:

dataset = tf.data.TFRecordDataset(['example1.tfrecord', 'example2.tfrecord'])

接下来,我们必须告诉数据集生成器如何解析 TFRecord 文件中的每个序列化条目。我们使用 map() 方法,它允许我们定义一个用于解析 TFRecord 特定示例的函数,该函数将在每次从磁盘读取示例时应用(映射)到每个示例。

在以下示例中,我们首先定义 feature_description 来描述如何解析 TFRecord 特定的条目。使用前面的示例,我们假设条目的布局是一个字节编码的图像键/值,一个整数标签键/值,以及一个三个元素的整数形状键/值。然后我们使用 tf.io.parse_single_example() 方法根据特征描述解析 TFRecord 文件中的序列化示例:

feature_description = {                                                ❶
    'image': tf.io.FixedLenFeature([], tf.string),                     ❶
    'label': tf.io.FixedLenFeature([], tf.int64),                      ❶
    'shape': tf.io.FixedLenFeature([3], tf.int64),                     ❶
}

def _parse_function(proto):                                            ❷
    ''' parse the next serialized tf.train.Example using the feature description '''
    return tf.io.parse_single_example(proto, feature_description)      ❸

dataset = dataset.map(_parse_function)  

为反序列化 tf.train.Example 创建字典描述

用于 tf.train.Example 的顺序解析函数

使用 map() 函数解析数据集中的每个条目

让我们现在进行一些更多的数据集转换,然后看看我们迭代磁盘上的 TFRecord 时会看到什么。在这个代码示例中,我们应用转换以打乱顺序并将批大小设置为 2。然后我们以两个示例为一批迭代数据集,并显示相应的 labelshape 键/值:

dataset = dataset.shuffle(4).batch(2)         ❶
for entry in dataset:                         ❷
    print(entry['label'], entry['shape'])     ❷

为磁盘上的数据库创建迭代器

以两个示例为一批迭代磁盘上的 TFRecord

以下输出显示每个批次包含两个示例,第一批的标签是 0 和 1,第二批的标签是 1 和 0,所有图像的大小为 (512, 512, 3):

tf.Tensor([0 1], shape=(2,), dtype=int64) tf.Tensor(
[[512 512   3]
 [512 512   3]], shape=(2, 3), dtype=int64)
tf.Tensor([1 0], shape=(2,), dtype=int64) tf.Tensor(
[[512 512   3]
 [512 512   3]], shape=(2, 3), dtype=int64)

TFRecord:压缩图像

到目前为止,我们还没有解决序列化图像数据编码的格式。通常,图像以压缩格式(如 JPEG)或未压缩格式(原始)编码。在下一个代码示例中,我们在 _parse_function() 中添加一个额外的步骤,使用 tf.io.decode_jpg() 将图像数据从压缩格式(JPEG)解码为未压缩格式。因此,随着每个示例从磁盘读取并反序列化,现在图像数据已解码:

dataset = tf.data.TFRecordDataset(['example.tfrecord'])   

feature_description = {   
    'image': tf.io.FixedLenFeature([], tf.string),  
    'label': tf.io.FixedLenFeature([], tf.int64),  
    'shape': tf.io.FixedLenFeature([3], tf.int64), 
}

def _parse_function(proto):   
    ''' parse the next serialized tf.train.Example 
        using the feature description 
    '''
    example = tf.io.parse_single_example(proto, feature_description)
    example['image'] = tf.io.decode_jpg(example['image'])              ❶
    return example

dataset = dataset.map(_parse_function)

解码压缩的 JPEG 图像

TFRecord:未压缩图像

在下一个代码示例中,编码的图像数据以未压缩格式存储在 TFRecord 文件中。因此,我们不需要解压缩它,但仍然需要使用 tf.io.decode_raw() 将编码的字节数组解码为原始位图格式。

在此阶段,原始解码数据是一个一维数组,因此我们需要将其重新调整回其原始形状。在获取原始解码数据后,我们从shape键/值中获取原始形状,然后使用tf.reshape()调整原始图像数据:

dataset = tf.data.TFRecordDataset(['tfrec/example.tfrecord'])

feature_description = {
    'image': tf.io.FixedLenFeature([], tf.string),
    'label': tf.io.FixedLenFeature([], tf.int64), 
    'shape': tf.io.FixedLenFeature([3], tf.int64),
}

def _parse_function(proto): 
    ''' parse the next serialized tf.train.Example using the 
        feature description 
    '''
    example = tf.io.parse_single_example(proto, feature_description)  
    example['image'] = tf.io.decode_raw(example['image'], tf.uint8)    ❶
    shape = example['shape']                                           ❷
    example['image'] = tf.reshape(example['image'], shape)             ❸
    return example

dataset = dataset.map(_parse_function)

❶ 将图像数据解码为未压缩的原始格式

❷ 获取原始图像形状

❸ 将解码的图像重新调整回原始形状

13.3 数据预处理

到目前为止,我们已经涵盖了数据格式、存储以及从内存或磁盘读取训练数据,以及一些数据预处理。在本节中,我们将更详细地介绍预处理。首先,我们将探讨如何将预处理从上游数据管道移出,并移动到预处理器模型组件中,然后我们将探讨如何使用 TFX 设置预处理管道。

13.3.1 使用预处理的预处理

您应该记得,当 TensorFlow 2.0 发布时,其中一个建议是将预处理移动到图中。我们可以采取两种方法。首先,我们可以将其硬编码到图中。其次,我们可以使预处理独立于模型,但实现即插即用,这样预处理就会在图中进行,并且可以互换。这种即插即用预处理的优点如下:

  • 在训练和部署管道中的可重用和可互换组件

  • 在图中运行,而不是在 CPU 的上游运行,从而在为训练提供模型时消除潜在的 I/O 绑定

图 13.7 展示了使用即插即用预处理器进行预处理。这个展示显示了在训练或部署模型时可以选择的即插即用预处理器组件集合。连接预处理器的要求是它的输出形状必须与模型的输入形状匹配。

图 13.7 即插即用预处理器在训练和部署期间可以互换。

预处理器要实现与现有模型(已训练和未训练)的即插即用,有两个要求:

  • 预处理器的输出必须与模型的输入匹配。例如,如果模型以输入(224, 224, 3)为输入——例如标准的 ResNet50——那么预处理器的输出也必须是(224, 224, 3)。

  • 预处理输入的形状必须与输入源匹配,无论是用于训练还是预测。例如,输入源的大小可能与模型训练时的大小不同,而预处理器已经被训练来学习调整图像大小的最佳方法。

即插即用预处理器通常分为两种类型:

  • 在部署后与模型一起用于预测。例如,预处理器处理输入源的调整大小和归一化,当预测请求由未压缩图像的原始字节组成时。

  • 仅在训练期间使用,部署后不使用。例如,预前缀在训练期间对图像进行随机增强,以学习平移和尺度不变性,从而消除配置数据管道进行图像增强的需要。

我们将介绍两种构建预前缀的方法,以将数据预处理移动到图中。第一种方法为 TF.Keras 2.x 添加了层,用于此目的,第二种方法使用子类化来创建自己的自定义预处理层。

TF.Keras 预处理层

为了进一步帮助和鼓励将预处理移动到图中,TF.Keras 2.2 及后续版本引入了新的预处理层。这消除了使用子类化构建常见预处理步骤的需要。本节涵盖了这三个层:RescalingResizingCenterCrop。对于完整列表,请参阅 TF.Keras 文档 (mng.bz/7jqe)。

图 13.8 展示了通过包装技术将即插即用的预前缀附加到现有模型的过程。在这里,创建了一个第二个模型实例,我们称之为 包装模型。使用顺序 API,例如,包装模型由两个组件组成:首先添加预前缀,然后添加现有模型。为了将现有模型连接到预前缀,预前缀的输出形状必须匹配现有模型的输入形状。

图 13.8 一个包装模型将一个预前缀附加到现有模型上。

下一个代码示例实现了一个即插即用的预前缀,我们在训练现有模型之前添加它。首先,我们创建了一个未训练的 ConvNet,包含两个 16 和 32 个过滤器的卷积 (Conv2D) 层。然后我们将特征图展平 (Flatten) 成一个 1D 向量,不进行降维,作为瓶颈层和最终的 Dense 层进行分类。我们将使用这个 ConvNet 模型作为我们想要训练和部署的模型。

接下来,我们实例化另一个空模型,我们将称之为 wrapper 模型。包装模型将包含两个部分:预前缀和未训练的 ConvNet 模型。对于预前缀,我们添加预处理层 Rescaling 以将整数像素数据归一化到浮点值 0 和 1 之间。由于预前缀将是包装模型中的输入层,我们添加参数 (input_shape=(32, 32, 3)) 以指定输入形状。由于 Rescaling 不改变输入的大小,预前缀的输出与模型输入相匹配。

最后,我们训练包装模型并使用包装模型进行预测。因此,对于训练和预测,整数像素数据的归一化现在成为包装模型的一部分,在图上执行,而不是在 CPU 上上游执行。

from tensorflow.keras import Sequential
from tensorflow.keras.layers import Conv2D, ReLU, Activation, 
from tensorflow.keras.layers import BatchNormalization, Dense, Flatten
from tensorflow.keras.layers.experimental.preprocessing import Rescaling    ❶

model = Sequential()                                                        ❷
model.add(Conv2D(16, (3,3), strides=1, padding='same', input_shape=(32, 32, 3)))
model.add(BatchNormalization())
model.add(ReLU())
model.add(Conv2D(32, (3,3), strides=1, padding='same'))
model.add(BatchNormalization())
model.add(ReLU())
model.add(Flatten())
model.add(Dense(10, activation='softmax'))

wrapper = Sequential()                                                      ❸
wrapper.add(Rescaling(scale=1.0/255, input_shape=(32, 32, 3)))              ❸

wrapper.add(model)                                                          ❹
wrapper.compile(loss='sparse_categorical_crossentropy', optimizer='adam',   ❹
                metrics=['acc'])                                            ❹

from tensorflow.keras.datasets import cifar10
(x_train, y_train), (x_test, y_test) = cifar10.load_data()
wrapper.fit(x_train, y_train, epochs=5, batch_size=32, verbose=1)           ❺
wrapper.evaluate(x_test, y_test)                                            ❺

❶ 导入缩放预处理层

❷ 构建一个简单的 ConvNet

❸ 使用缩放构建预前缀

❹ 将预前缀添加到 ConvNet

❺ 使用预预处理对 ConvNet 进行训练和测试

可即插即用的预预处理可以包含多个预处理层,如图 13.9 所示,例如图像输入的调整大小,随后是像素数据的重新缩放。在此表示中,由于缩放不会改变输出形状,因此前一个调整大小层的输出形状必须与茎组输入形状相匹配。

图 13.9 带有两个预处理层的预预处理

以下代码实现了一个可插拔的预预处理,它执行两个功能:调整输入大小并归一化像素数据。我们首先创建与上一个示例相同的 ConvNet。接下来,我们创建一个包含两个预处理层的包装模型:一个用于图像调整大小(Resizing)和一个用于归一化(Rescaling)。在此示例中,ConvNet 的输入形状为(28, 28, 3)。我们使用预预处理将输入从(32, 32, 3)调整大小到(28, 28, 3)以匹配 ConvNet 并归一化像素数据:

from tensorflow.keras import Sequential
from tensorflow.keras.layers import Conv2D, ReLU, BatchNormalization, 
from tensorflow.keras.layers import Dense, Flatten
from tensorflow.keras.layers.experimental.preprocessing import Rescaling
from tensorflow.keras.layers.experimental.preprocessing import Resizing

from tensorflow.keras.datasets import cifar10
(x_train, y_train), (x_test, y_test) = cifar10.load_data()

model = Sequential()
model.add(Conv2D(16, (3,3), strides=1, padding='same', input_shape=(28, 28, 3)))
model.add(BatchNormalization())
model.add(ReLU())
model.add(Conv2D(32, (3,3), strides=1, padding='same'))
model.add(BatchNormalization())
model.add(ReLU())
model.add(Flatten())
model.add(Dense(10, activation='softmax'))

wrapper = Sequential()                                                 ❶
wrapper.add(Resizing(height=28, width=28, input_shape=(32, 32, 3)))    ❷
wrapper.add(Rescaling(scale=1.0/255))                                  ❷
wrapper.add(model)                                                     ❸
wrapper.compile(loss='sparse_categorical_crossentropy', optimizer='adam', 
metrics=['acc'])

wrapper.fit(x_train, y_train, epochs=5, batch_size=32, verbose=1)
wrapper.evaluate(x_test, y_test)

❶ 创建包装模型

❷ 将预预处理添加到包装模型中

❸ 将 ConvNet 添加到模型中并进行训练

现在我们已经训练了模型,我们可以移除预预处理并使用模型进行推理。在下一个示例中,我们假设图像测试数据已经调整为(28, 28, 3)以匹配我们的 ConvNet,并且我们在模型上游对像素数据进行归一化。我们知道包装模型的前两层是预预处理,这意味着我们的底层训练模型从第三层开始;因此我们将 model 设置为 wrapper.layers[2]。现在我们可以使用不带预处理的底层模型进行推理:

x_test = (x_test / 255.0).astype(np.float32)      ❶
model = wrapper.layers[2]                         ❷
model.evaluate(x_test, y_test)                    ❸

❶ 数据预处理在 CPU 上上游进行

❷ 获取不带预处理的底层模型

❸ 使用底层模型进行评估(预测)

预处理链式连接

图 13.10 描述了预预处理链式连接;一个预预处理将与模型部署一起保留,另一个将在模型部署时移除。在此,我们创建了两个包装模型:一个内部包装和一个外部包装。内部包装包含一个将在模型部署时保留的预处理预预处理,而外部包装包含一个将在模型部署时从模型中移除的图像增强预预处理。对于训练,我们训练外部包装模型,对于部署,我们部署内部包装模型。

图 13.10 预预处理链式连接——内部预预处理在部署后与模型一起保留,外部预预处理被移除。

在我们的最终示例中,我们将两个预处理层连接在一起。第一个预处理层用于训练,然后在推理时移除,第二个层则保留在模型中。在第一个(内部)预处理层中,我们对整数像素数据进行归一化(Rescaling)。在第二个(外部)预处理层中,我们对输入图像进行中心裁剪(CenterCrop)以进行训练。我们还设置第二个预处理层的输入大小为任意高度和宽度:(None, None, 3)。因此,我们可以在训练期间将不同大小的图像输入到第二个预处理层,它将它们裁剪为(32, 32, 3),然后将其作为输入传递给第一个预处理层,该层执行归一化。

最后,当训练完成后,我们移除第二个(外部)预处理层,并在没有中心裁剪的情况下进行推理:

from tensorflow.keras import Sequential
from tensorflow.keras.layers import Conv2D, ReLU, BatchNormalization,
from tensorflow.keras.layers import Dense, Flatten
from tensorflow.keras.layers.experimental.preprocessing import Rescaling,
from tensorflow.keras.layers.experimental.preprocessing import CenterCrop

from tensorflow.keras.datasets import cifar10
(x_train, y_train), (x_test, y_test) = cifar10.load_data()

model = Sequential()                                                        ❶
model.add(Conv2D(16, (3,3), strides=1, padding='same', input_shape=(32, 32, 3)))
model.add(BatchNormalization())
model.add(ReLU())
model.add(Conv2D(32, (3,3), strides=1, padding='same'))
model.add(BatchNormalization())
model.add(ReLU())
model.add(Flatten())
model.add(Dense(10, activation='softmax'))
model.compile(loss='sparse_categorical_crossentropy', 
              optimizer='adam', metrics=['acc'])

wrapper1 = Sequential()                                                     ❷
wrapper1.add(Rescaling(scale=1.0/255, input_shape=(32, 32, 3)))             ❷
wrapper1.add(model)                                                         ❷
wrapper1.compile(loss='sparse_categorical_crossentropy',                    ❷
                 optimizer='adam', metrics=['acc'])                         ❷

wrapper2 = Sequential()                                                     ❸
wrapper2.add(CenterCrop(height=32, width=32, input_shape=(None, None, 3)))  ❸
wrapper2.add(wrapper1)                                                      ❸
wrapper2.compile(loss='sparse_categorical_crossentropy',                    ❸
                 optimizer='adam', metrics=['acc'])                         ❸

wrapper2.fit(x_train, y_train, epochs=5, batch_size=32, verbose=1)          ❹

wrapper2.layers[1].evaluate(x_test, y_test)                                 ❺

❶ 构建 ConvNet 模型

❷ 将第一个预处理层附加到图像数据在训练和推理期间的归一化

❸ 在训练期间将第二个预处理层附加到图像数据以进行中心裁剪

❹ 使用第一个和第二个预处理层训练模型

❺ 仅使用第一个预处理层进行推理

TF.Keras 子类化层

作为使用 TF.Keras 内置预处理层的替代方案,我们可以通过层子类化创建自己的自定义预处理层。当你需要一个不是预构建在 TF.Keras 预处理层中的自定义预处理步骤时,这很有用。

TF.Keras 中所有预定义层都是TF.Keras.Layer类的子类。要创建自己的自定义层,你需要执行以下操作:

  1. 创建一个继承(继承自)TF.Keras.Layer类的类。

  2. 覆盖__init__()build()call()方法。

让我们现在通过子类化来构建我们自己的预处理层Rescaling版本。在下一个示例代码实现中,我们定义了Rescaling类,它继承自TF.Keras.Layer。接下来,我们覆盖了初始化器__init__()。在底层的Layer类中,初始化器接受两个参数:

  • input_shape—当作为模型中的第一个层使用时,模型输入的形状

  • name—为这个层实例定义的用户可定义名称

我们通过super()调用将这些两个参数传递到底层的Layer初始化器。

任何剩余的__init__()参数都是层特定的(自定义)参数。对于Rescaling,我们添加了scale参数并将其值保存在类对象中。

接下来,我们覆盖了build()方法。当使用compile()编译模型或使用功能 API 将一个层绑定到另一个层时,会调用此方法。底层方法接受input_shape参数,该参数指定了层的输入形状。底层参数self.kernel设置了层的内核形状;内核形状指定了参数的数量。如果我们有可学习的参数,我们将设置内核形状及其初始化方式。由于Rescaling没有可学习的参数,我们将其设置为None

最后,我们覆盖了 call() 方法。当图在训练或推理时执行时,将调用此方法。底层方法将 inputs 作为参数,这是层的输入张量,并返回输出张量。在我们的情况下,我们将输入张量中的每个像素值乘以在层初始化时设置的 scale 因子,并输出缩放后的张量。

我们添加装饰器 @tf.function 来告诉 TensorFlow AutoGraph (www.tensorflow.org/api_docs/python/tf/autograph) 将此方法中的 Python 代码转换为模型中的图操作。AutoGraph 是 TensorFlow 2.0 中引入的一个工具,它是一个预编译器,可以将各种 Python 操作转换为静态图操作。这允许可以将转换为静态图操作的 Python 代码从在 CPU 上执行转移到图中的执行。虽然支持许多 Python 构造进行转换,但转换仅限于非 eager 张量的图操作。

import tensorflow as tf
from tensorflow.keras import Sequential
from tensorflow.keras.layers import Conv2D, ReLU, Activation, 
from tensorflow.keras.layers import BatchNormalization, Dense, Flatten
from tensorflow.keras.layers import Layer

class Rescaling(Layer):                                                     ❶
    """ Custom Layer for Preprocessing Input """
    def __init__(self, scale, input_shape=None, name=None):                 ❷
        """ Constructor """
        super(Rescaling, self).__init__(input_shape=input_shape, name=name)
        self.scale = scale                                                  ❸

    def build(self, input_shape):                                           ❹
        """ Handler for building the layer """
        self.kernel = None                                                  ❺

    @tf.function                                                            ❻
    def call(self, inputs):                                                 ❼
        """ Handler for layer object is callable """
        inputs = inputs * self.scale                                        ❽
        return inputs

❶ 使用层子类化定义自定义层

❷ 覆盖初始化器并添加输入参数 scale

❸ 在层对象实例中保存缩放因子

❹ 覆盖 build() 方法

❺ 没有可学习的(可训练)参数。

❻ 告诉 AutoGraph 将方法转换为放入模型中的图操作

❼ 覆盖 call() 方法

❽ 对输入张量中的每个像素(元素)进行缩放

关于 LayerModel 子类化的详细信息,请参阅 TensorFlow 团队提供的各种教程和笔记本示例,例如“通过子类化创建新的层和模型” (mng.bz/my54)。

13.3.2 使用 TF Extended 进行预处理

到目前为止,我们已经讨论了从底层组件构建数据管道。在这里,我们看到如何使用更高层次的组件,这些组件封装了更多的步骤,使用 TensorFlow Extended 来构建数据管道。

TensorFlow Extended (TFX) 是一个端到端的生产管道。本节涵盖了 TFX 的数据管道部分,如图 13.11 所示。

图 13.11 TFX 数据管道

在高层次上,ExampleGen 组件从数据集源中摄取数据。StatisticsGen 组件分析数据集中的示例,并生成数据集分布的统计数据。SchemaGen 组件通常用于结构化数据,从数据集统计数据中推导出数据模式。例如,它可能推断特征类型,如分类或数值,数据类型,范围,并设置数据策略,例如如何处理缺失数据。ExampleValidator 组件根据数据模式监控训练和提供数据中的异常。这四个组件共同构成了 TFX 数据验证库。

Transform 组件执行数据转换,例如特征工程、数据预处理和数据增强。该组件由 TFX Transform 库组成。

TFX 包不是 TensorFlow 2.x 版本的组成部分,因此您需要单独安装它,如下所示:

pip install tfx

本小节的其余部分仅从高层次概述这些组件。有关详细参考和教程,请参阅 TensorFlow 文档中的 TFX (www.tensorflow.org/tfx)。

接下来,让我们创建一个代码片段,用于导入我们将在所有后续代码示例中使用的模块和类:

from tfx.utils.dsl_utils import external_input                              ❶
from tfx.components import ImportExampleGen                                 ❷
from tfx.components import StatisticsGen, SchemaGen, ExampleValidator       ❸
from tfx.components import Transform                                        ❸
from tfx.orchestration.experimental.interactive.interactive_context import  ❹
InteractiveContext                               

❶ 导入 util 以从外部源读取数据集

❷ 导入 ExampleGen 组件实例用于 TFRecords

❸ 导入剩余的 TFX 数据管道组件

❹ 导入 TFX 管道编排

在后续的代码示例中,我们将使用 TFX 管道编排模块进行交互式演示。这些代码序列设置了一个管道,但在编排执行管道之前,没有任何操作。

context = InteractiveContext()       ❶

❶ 实例化交互式管道编排

ExampleGen

ExampleGen 组件是 TFX 数据管道的入口点。其目的是从一个数据集中抽取示例批次。它支持多种数据集格式,包括 CSV 文件、TFRecords 和 Google BigQuery。ExampleGen 的输出是 tf.Example 记录。

下一个代码示例实例化了用于磁盘上 TFRecord 格式数据集(例如,图像)的 ExampleGen 组件。它包括两个步骤。

让我们从第二个步骤开始。我们将 ExampleGen 组件实例化为子类 ImportExampleGen,其中初始化器将示例输入源(input=examples)作为参数。

现在让我们退一步,定义一个连接到输入源的连接器。由于输入源是 TFRecords,我们使用 TFX 工具方法 external_input() 将连接器映射到磁盘上的 TFRecords 和我们的 ImportExampleGen 实例之间:

examples = external_input('tfrec')                ❶
example_gen = ImportExampleGen(input=examples)    ❶
context.run(example_gen)                          ❷

❶ 实例化一个以 TFRecords 为输入源的 ExampleGen

❷ 执行管道

统计生成器

StatisticsGen 组件从示例输入源生成数据集统计信息。这些示例可以是训练/评估数据或服务数据(后者在此未涉及)。在下一个代码示例中,我们为训练/评估数据生成数据集统计信息。我们实例化一个 StatisticsGen() 实例,并将示例源传递给初始化器。在这里,示例的源是前一个代码示例中我们的 example_gen 实例的输出。输出通过 ExampleGen 属性 outputs 指定,它是一个字典,键/值对为 examples

statistics_gen = StatisticsGen(
      examples=example_gen.outputs['examples'])        ❶
context.run(statistics_gen)                            ❷
statistics_gen.outputs['statistics']._artifacts[0]     ❸

❶ 使用 ExampleGen 输出创建一个 StatisticsGen 实例

❷ 执行管道

❸ 显示统计信息的交互式输出

最后一条代码输出的结果将类似于以下内容。uri 属性是一个本地目录,用于存储统计信息。split_names 属性表示两组统计信息,一组用于训练,另一组用于评估:

Artifact of type 'ExampleStatistics' (uri: /tmp/tfx-interactive-2020-05-28T19_02_20.322858-8g1v59q7/StatisticsGen/statistics/2) at 0x7f9c7a1414d0
.type <class 'tfx.types.standard_artifacts.ExampleStatistics'>
.uri /tmp/tfx-interactive-2020-05-28T19_02_20.322858-8g1v59q7/StatisticsGen/statistics/2
.span 0
.split_names ["train", "eval"]

SchemaGen

SchemaGen 组件从数据集统计信息中生成模式。在下一个代码示例中,我们为训练/评估数据生成数据集统计信息中的模式。我们实例化了一个 SchemaGen() 对象,并将数据集统计信息的来源传递给初始化器。在我们的例子中,统计信息的来源是前一个代码示例中 statistics_gen 实例的输出。输出通过 StatisticsGen 属性 outputs 指定,该属性是一个字典,键/值对为 statistics

schema_gen = SchemaGen(
    statistics=statistics_gen.outputs['statistics'])    ❶
context.run(schema_gen)
schema_gen.outputs['schema']._artifacts[0]              ❷

❶ 使用 ExampleGen 的输出实例化一个 SchemaGen

❷ 显示模式的交互式输出

最后一条代码输出的结果将类似于以下内容。uri 属性是一个本地目录,用于存储模式。模式的文件名将是 schema.pbtxt。

Artifact of type 'Schema' (uri: /tmp/tfx-interactive-2020-05-28T19_02_20
➥ .322858-8g1v59q7/SchemaGen/schema/4) at 0x7f9c500d1790
.type <class 'tfx.types.standard_artifacts.Schema'>
.uri /tmp/tfx-interactive-2020-05-28T19_02_20.322858-8g1v59q7/SchemaGen/schema/4

对于我们的例子,schema.pbtxt 的内容将类似于以下内容:

feature {
  name: "image"
  value_count {
    min: 1
    max: 1
  }
  type: BYTES
  presence {
    min_fraction: 1.0
    min_count: 1
  }
}
feature {
  name: "label"
  value_count {
    min: 0
    max: 1
  }
  type: INT
  presence {
    min_fraction: 1.0
    min_count: 1
  }
}
feature {
  name: "shape"
  value_count {
    min: 3
    max: 3
  }
  type: INT
  presence {
    min_fraction: 1.0
    min_count: 1
  }
}

示例验证器

ExampleValidator 组件通过使用数据集统计信息和模式作为输入来识别数据集中的异常。在下一个代码示例中,我们识别了训练/评估数据的数据集统计信息和模式中的异常。我们实例化了一个 ExampleValidator() 对象,并将数据集统计信息的来源和模式传递给初始化器。在我们的例子中,统计信息和模式的来源分别是前一个代码示例中 statistics_gen 实例和 schema_gen 实例的输出。

example_validator = ExampleValidator(
    statistics=statistics_gen.outputs['statistics'],
    schema=schema_gen.outputs['schema'])                  ❶
context.run(example_validator)
example_validator.outputs['anomalies']._artifacts[0]      ❷

❶ 实例化一个 ExampleValidator

❷ 显示异常的交互式输出

最后一条代码输出的结果将类似于以下内容。uri 属性是一个本地目录,用于存储异常信息(如果有存储的话)。

Artifact of type 'ExampleAnomalies' (uri: ) at 0x7f9c780cbdd0
.type <class 'tfx.types.standard_artifacts.ExampleAnomalies'>
.uri
.span 0

转换

Transform 组件在训练或推理过程中将数据集转换作为示例被绘制到批次中执行。数据集转换通常是结构化数据的特征工程和数据预处理。

在下面的代码示例中,我们将数据集的示例批次进行转换。我们实例化了一个Transform()实例。初始化器接受三个参数:要转换的examples的输入源、数据schema以及一个自定义 Python 脚本来执行转换(例如,my_preprocessing_fn.py)。我们不会介绍如何编写用于转换的自定义 Python 脚本;更多详情,请参阅 TensorFlow 教程中的 TFX 组件部分(mng.bz/5Wqa)。

transform = Transform(
    examples=example_gen.outputs['examples'],
    schema=schema_gen.outputs['schema'],
    module_file='my_preprocessing_fn.py')  
context.run(transform)

下一节将介绍如何将图像增强集成到现有的数据管道中,例如使用tf.data和/或使用预茎构建的数据管道。

13.4 数据增强

图像 (数据)增强 在过去几年中有着各种各样的目的。最初,它被视为通过在现有图像上执行一些随机变换,将更多图像添加到现有数据集以进行训练的手段。随后,研究人员了解到某些类型的增强可以扩展模型的检测能力,例如对于不变性和遮挡。

本节展示了如何将图像增强添加到现有的数据管道中。我们将从图像增强背后的基本概念及其如何帮助模型泛化到未训练的示例开始。然后,我们将转向将方法集成到tf.data管道中的方法。最后,我们将看到如何通过在训练期间附加到模型的预茎中并随后断开连接的预处理层来集成它。

本节重点介绍常见的增强技术和实现,以扩展模型的不变性检测能力。接下来,我们将描述不变性是什么以及为什么它很重要。

13.4.1 不变性

今天,我们不再将图像增强的目的视为仅仅是为了向训练集中添加更多示例。相反,它是一种通过具有特定目的来生成现有图像的额外图像,以训练模型实现平移、缩放和视口不变性的手段。

好吧,这一切意味着什么呢?这意味着我们希望无论图像中的位置(平移)、对象的大小(缩放)和观看角度(视口),都能识别图像中的对象(或视频帧中的对象)。图像增强使我们能够训练模型实现不变性,而无需额外的真实世界人工标注数据。

图像增强通过随机变换训练数据中的图像来实现,以实现不同的平移、缩放和视口。在研究论文中,执行以下四种图像增强类型是一种常见的做法:

  • 随机中心裁剪

  • 随机翻转

  • 随机旋转

  • 随机平移

让我们详细看看这四种类型。

随机中心裁剪

在一个 裁剪 中,我们取图像的一部分。通常,裁剪是矩形的。中心裁剪是正方形,并且位于原始图像的中心(图 13.12)。裁剪的大小随机变化,因此在某些情况下,它只是图像的一小部分,而在其他情况下,则是一大部分。然后,裁剪的图像被调整大小以适应模型的输入大小。

这种变换有助于训练模型以实现尺度不变性,因为我们正在随机放大图像中物体的大小。你可能想知道这些随机裁剪是否可能会裁剪掉所有或过多的感兴趣物体,导致图像无用。通常,这不会发生,以下是一些原因:

  • 前景物体(感兴趣的对象)往往出现在图片的中心或附近。

  • 我们为裁剪设置一个最小尺寸,防止裁剪太小以至于不包含可用的数据。

  • 物体的边缘被裁剪出来有助于训练模型进行遮挡,其中其他物体遮挡了感兴趣物体的一部分。

图 13.12 随机中心裁剪

随机翻转

在一个 翻转 中,我们在水平或垂直轴上翻转图像。如果我们沿垂直轴翻转,我们得到一个镜像图像。如果我们沿水平轴翻转,我们得到一个颠倒的图像。这种变换有助于训练模型以实现视口不变性。

你可能会想,在某些情况下,镜像或颠倒的图像在现实世界的应用中可能没有意义。例如,你可能会说停车标志的镜像没有意义,或者一辆颠倒的卡车。也许它确实有意义。也许停车标志是通过后视镜看到的?也许你的车翻了,卡车从你的视角看确实是颠倒的。

随机翻转还有助于学习物体的基本特征,这些特征与背景分离——无论模型在现实世界预测中部署时的实际视口如何。

随机旋转

在一个 旋转 中,我们沿着中心点旋转图像。我们可以旋转最多 360 度,但由于随机变换的常见做法是将它们串联起来,因此与随机翻转结合时,+/- 30 度的范围就足够了。这种变换有助于训练模型以实现视口不变性。

图 13.13 是两个串联的随机变换的例子。第一个是随机旋转,然后是随机中心裁剪。

图 13.13 随机变换链

随机平移

在一个随机平移中,我们垂直或水平地移动图像。如果我们水平移动,我们就会从左侧或右侧丢弃像素,并用相同数量的黑色像素(无信号)在对面替换它们。如果我们垂直移动,我们就会从顶部或底部丢弃像素,并用相同数量的黑色像素(无信号)在对面替换它们。一个一般规则是,将平移限制在图像宽度/高度的+/-20%以内,以防止裁剪掉太多感兴趣的对象。这种变换有助于训练模型以实现平移不变性。

除了这里提到的四种之外,还有大量的其他变换技术可以用于实现不变性。

13.4.2 使用 tf.data 进行增强

可以通过使用map()方法将图像变换添加到tf.data.Dataset管道中。在这种情况下,我们将变换编码为一个 Python 函数,该函数以图像为输入并输出变换后的图像。然后我们将该函数指定为map()方法的参数,该参数将应用于批处理中的每个元素。

在下一个示例中,我们定义了一个flip()函数,该函数将对数据集中的每个图像执行随机翻转平移,每次图像被绘制到批处理中时。在示例中,我们从一个 NumPy 图像训练数据元组及其相应的标签(x_train, y_train)创建tf.data.Dataset。然后我们将flip()函数应用于数据集,即dataset.map(flip)。由于批处理中的每个图像都是一个图像和标签的元组,因此变换函数需要两个参数:(image, label)。同样,我们需要返回相应的元组,但用变换后的输入图像替换:(transform, label)

import tensorflow as tf
from tensorflow.keras.datasets import cifar10
(x_train, y_train), (x_test, y_test) = cifar10.load_data()

def flip(image, label):                                             ❶
    transform = tf.image.random_flip_left_right(image)              ❷
    transform = tf.image.random_flip_up_down(transform)             ❷

    return transform, label                                         ❸

dataset = tf.data.Dataset.from_tensor_slices((x_train, y_train))
dataset = dataset.map(flip)                                         ❹

❶ 执行图像变换的函数,输入图像和相应的标签

❷ 随机翻转输入图像

❸ 返回变换后的图像和相应的标签

❹ 将翻转变换函数应用于每个图像/标签对

接下来,我们将对tf.data.Dataset进行多个变换。在下面的代码示例中,我们添加第二个变换函数以进行随机裁剪。请注意,tf.image.random_crop()方法不是一个中心裁剪。与总是居中的随机大小不同,这个 TensorFlow 方法设置一个固定的大小,由shape指定,但图像中的裁剪位置是随机的。然后我们将两个变换链在一起,首先进行随机翻转,然后进行随机裁剪:dataset.map(flip).map(crop)

def crop(image, label):                                                ❶
    shape = (int(image.shape[0] * 0.8), int(image.shape[1] * 0.8), 
             image.shape[2])                                           ❷
    transform = tf.image.random_crop(image, shape)                     ❸

    return transform, label

dataset = tf.data.Dataset.from_tensor_slices((x_train, y_train))
dataset = dataset.map(flip).map(crop)                                  ❹

❶ 执行图像变换的函数,输入图像和相应的标签

❷ 根据原始图像大小的 80%选择裁剪大小

❸ 随机裁剪输入图像

❹ 应用一系列变换

13.4.3 预处理

TF.Keras.layers.experimental.preprocessing模块提供了几个预处理层,这些层提供了在模型中作为预前缀组件执行图像增强的手段。因此,这些操作将在 GPU(或等效)上发生,而不是在 CPU 上游发生。由于预前缀是即插即用的,在训练完成后,可以在将模型部署到生产之前断开这个预前缀组件。

在 TensorFlow 2.2 中,支持平移、缩放和视口不变性的预处理层如下:

  • CenterCrop

  • RandomCrop

  • RandomRotation

  • 随机翻译

  • RandomFlip

在以下示例中,我们将两个预处理层RandomFlip()RandomTranslation()作为即插即用的预前缀进行组合,以实现不变性:我们创建一个空的wrapper模型,添加即插即用的预前缀,然后添加model。对于部署,我们像本章前面所演示的那样,断开即插即用的预前缀。

wrapper = Sequential()                                                   ❶
wrapper.add(RandomFlip())                                                ❷
wrapper.add(RandomTranslation(fill_mode='constant', height_factor=0.2,   ❷
                              width_factor=0.2))                         ❷

wrapper.add(model)                                                       ❸

❶ 创建包装模型

❷ 添加不变性预前缀

❸ 添加基础模型

摘要

  • 数据管道的基本组件包括数据存储、数据检索、数据预处理和数据馈送。

  • 为了获得最佳的 I/O 性能,如果整个数据集可以适合内存,则在训练期间使用内存中的数据馈送;否则,使用磁盘上的数据馈送。

  • 根据数据是否在磁盘上以压缩或未压缩的形式存储,存在额外的空间和时间性能权衡。你可能可以通过基于子群体采样分布的混合方法来平衡这些权衡。

  • 如果你处理卫星数据,你需要了解 HDF5 格式。如果你处理医学影像数据,你需要了解 DICOM。

  • 图像增强的主要目的是训练一个模型,使其对平移、缩放和视口不变性有更好的泛化能力,以便更好地泛化到训练期间未见过的示例。

  • 可以通过使用tf.data或 TFX 在模型上游构建数据管道。

  • 可以通过使用子类化的 TF.Keras 预处理层在模型下游构建数据管道。

  • 预前缀可以被设计为预处理即插即用组件,并在训练和提供期间保持附加。

  • 预前缀可以被设计为增强即插即用插件,并在训练期间附加,在推理期间断开。

14 训练和部署流程

本章涵盖

  • 在生产环境中为模型训练提供数据

  • 为持续重新训练进行调度

  • 使用版本控制和部署前后的模型评估

  • 在单体和分布式部署中部署模型以处理大规模的按需和批量请求

在上一章中,我们探讨了端到端生产机器学习流程中的数据流程部分。在这里,在本书的最后一章,我们将涵盖端到端流程的最后一部分:训练、部署和服务。

为了用视觉方式提醒你,图 14.1 显示了整个流程,它来自第十三章。我已经圈出了本章我们将要解决的问题的部分系统。

图 14.1 本章重点介绍的端到端生产流程

你可能会问,究竟什么是流程,为什么我们使用它,无论是用于机器学习生产还是任何由编排管理的程序化生产操作?通常,当工作,如训练或其他由编排处理的操作,有多个按顺序发生的步骤时,你会使用流程:执行步骤 A,执行步骤 B,等等。

将这些步骤放入机器学习生产流程中可以带来多重好处。首先,该流程可以重复用于后续的训练和部署工作。其次,该流程可以被容器化,因此可以作为异步批量作业运行。第三,流程可以在多个计算实例之间分布,其中流程内的不同任务在不同的计算实例上执行,或者同一任务的各个部分可以在不同的计算实例上并行执行。最后,所有与流程执行相关的任务都可以被跟踪,其状态/结果可以保存为历史记录。

本章首先介绍了在生产环境中为训练提供模型的程序,包括顺序和分布式系统,以及使用tf.data和 TensorFlow Extended (TFX)的示例实现。然后我们学习如何安排训练和提供计算资源。我们将从介绍可重复使用的流程开始,讨论如何使用元数据将流程集成到生产环境中,以及历史和版本控制用于跟踪和审计。

接下来我们将看到模型是如何在投入生产环境前进行评估的。如今,我们不仅仅是将测试(保留)数据中的指标与模型前一个版本的测试指标进行比较。相反,我们识别出在生产环境中看到的不同子群体和分布,并构建额外的评估数据,这些数据通常被称为评估切片。然后,模型在一个模拟的生产环境中进行评估,通常称为沙盒,以查看它在响应时间和扩展性方面的表现如何。我包括了一些在沙盒环境中评估候选模型的 TFX 实现示例。

然后我们将转向将模型部署到生产环境并服务于按需和批量预测的过程。您将找到针对当前流量需求的扩展和负载均衡方法。您还将了解服务平台的配置情况。最后,我们讨论了在将模型部署到生产环境后,如何使用 A/B 测试方法进一步评估模型与之前版本的区别,以及如何使用持续评估方法在生产过程中获得洞察后进行后续重新训练。

14.1 模型喂养

图 14.2 是训练管道中模型喂养过程的概述。在前端是数据管道,它执行提取和准备训练数据的任务(图中的步骤 1)。由于今天我们在生产环境中处理的数据量非常大,我们将假设数据是从磁盘按需抽取的。因此,模型喂养器充当生成器,并执行以下操作:

  • 向数据管道请求示例(步骤 2)

  • 从数据管道接收这些示例(步骤 3)

  • 将接收到的示例组装成用于训练的批次格式(步骤 4)

模型喂养器将每个批次交给训练方法,该训练方法依次向前馈送每个批次(步骤 5)到模型,计算前馈结束时的损失(步骤 6),并通过反向传播更新权重(步骤 7)。

图片

图 14.2 数据管道与训练方法之间模型喂养过程的交互

模型喂养器位于数据管道和训练函数之间,可能在训练过程中成为 I/O 瓶颈,因此考虑其实施方式,以便喂养器能够以训练方法可以消耗的速度生成批次非常重要。例如,如果模型喂养器作为一个单 CPU 线程运行,而数据管道是一个多 CPU 或 GPU,训练过程是一个多 GPU,那么很可能会导致喂养器无法以接收示例的速度处理它们,或者以训练 GPU 可以消耗的速度生成批次。

由于模型喂养器与训练方法的关系,模型喂养器必须在训练方法消耗当前批次之前或同时,在内存中准备好下一个批次。生产环境中的模型喂养器通常是一个多线程过程,在多个 CPU 核心上运行。在训练过程中向模型提供训练示例有两种方式:顺序和分布式。

顺序训练模型喂养器

图 14.3 展示了一个顺序模型喂养器。我们从一个共享内存区域开始,然后经过以下四个步骤:

  • 为模型喂养器保留的共享内存区域,用于在内存中保留两个或更多批次(步骤 1)。

  • 在共享内存中实现了一个先进先出(FIFO)队列(步骤 1)。

  • 第一个异步过程将准备好的批次放入队列中(步骤 2 和 3)。

  • 当训练方法请求时(步骤 3 和 4),第二个异步过程从队列中拉取下一个批次。

通常,顺序方法在计算资源方面是最经济的,当完成训练的时间周期在您的训练时间要求内时,会使用这种方法。其好处是直接的:没有计算开销,就像分布式系统那样,CPU/GPUs 可以全速运行。

图片

图 14.3 顺序训练的模型馈送器

分布式训练的模型馈送器

在分布式训练中,例如在多个 GPU 上,模型馈送器处的 I/O 瓶颈的影响可能会变得更加严重。如图 14.4 所示,它与单实例、非分布式顺序方法不同,因为多个异步提交过程正在从队列中拉取批次,以并行训练多个模型实例。

图片

图片

虽然分布式方法会引入一些计算效率低下,但在您的框架不允许顺序方法完成训练时,会使用它。通常,时间要求是基于业务需求,而未能满足业务需求比计算效率低下有更高的成本。

在分布式训练中,第一个异步过程必须以等于或大于其他多个异步过程拉取批次的速率(步骤 2)将多个批次提交到队列中。每个分布式训练节点都有一个异步过程用于从队列中拉取批次。最后,第三个异步过程协调从队列中拉取批次并等待完成(步骤 3)。在这种分布式训练形式中,每个分布式训练节点都有一个第二个异步过程(步骤 2),其中节点可以是

  • 网络连接在一起的独立计算实例

  • 同一计算实例上的独立硬件加速器(如 GPU)

  • 多核计算实例(如 CPU)上的独立线程

你可能会问,当每个实例只能看到批次的一个子集时,模型是如何进行训练的?这是一个好问题。在这种分布式方法中,我们使用权重的批次平滑。

这样想:每个模型实例从训练数据的子采样分布中学习,我们需要一种方法来合并每个子采样分布中学习到的权重。每个节点在完成一个批次后,将向其他节点发送其权重更新。当接收节点收到权重更新时,它将与自己的批次中的权重更新进行平均——这就是权重批次平滑的原因。

有两种常见的网络方法用于发送权重。一种是在所有节点都连接到的子网上广播权重。另一种是使用环形网络,其中每个节点将其权重更新发送给下一个连接的节点。

这种分布式训练形式有两个后果,无论是广播还是环形。首先,有所有的网络活动。其次,你不知道权重更新的消息何时会出现。它是完全无协调和临时的。因此,权重批平滑固有的低效性会导致与顺序方法相比需要更多的训练轮次。

带有参数服务器的模型提供者

另一种分布式训练版本使用参数服务器。参数服务器通常运行在另一个节点上,通常是 CPU。例如,在谷歌的 TPU pods 中,每组四个 TPU 都有一个基于 CPU 的参数服务器。其目的是克服异步更新批平滑权重的低效性。

在这种分布式训练形式中,权重更新的批平滑是同步发生的。如图 14.5 所示的参数服务器将不同的批次分发给每个训练节点,然后等待每个节点完成其对应批次的消耗(步骤 1),并将损失计算发送回参数服务器(步骤 2)。参数服务器接收到每个训练节点的损失计算后,平均损失并在参数服务器维护的主副本上更新权重,然后将更新的权重发送给每个训练节点(步骤 3)。然后参数服务器向模型提供者发出信号,以分发下一组并行批次(步骤 4)。

这种同步方法的优点是,与上述异步方法相比,它不需要那么多的训练轮次。但缺点是,每个训练节点必须等待参数服务器发出接收下一批次的信号,因此训练节点可能运行在 GPU 或其他计算能力以下。

图片

图 14.5 分布式训练中的参数服务器

有几点需要指出。对于每一轮,每个分布式训练节点都会从另一个节点接收不同的批次。因为训练节点之间的损失可能会有很大差异,以及等待参数更新权重的开销,分布式训练通常使用更大的批次大小。较大的批次可以平滑或减少并行批次之间的差异,以及在训练过程中的 I/O 瓶颈。

14.1.1 使用 tf.data.Dataset 进行模型提供

在第十三章中,我们看到了如何使用tf.data.Dataset构建数据管道。它可以作为模型提供的机制。本质上,tf.data.Dataset的一个实例是一个生成器。它可以集成到顺序和分布式训练中。然而,在分布式提供者中,该实例不充当参数服务器,因为该功能由底层分布式系统执行。

tf.data.Dataset 的主要优点包括设置批量大小、对数据进行随机打乱以及并行预取当前批次的下一个批次。

以下代码是使用 tf.data.Dataset 在训练期间为模型提供数据的示例,使用了一个虚拟模型——一个没有参数的单层 (Flatten) Sequential 模型进行训练。为了演示,我们使用了 TF.Keras 内置数据集的 CIFAR-10 数据。

由于本例中的 CIFAR-10 数据将已经在内存中,当通过 cifar.load_data() 加载时,我们将创建一个生成器,该生成器将从内存源提供批次。第一步是创建我们的内存数据集的生成器。我们使用 from_tensor_slices() 来完成这个任务,它接受一个参数,即内存中的训练示例和相应的标签的元组 (x_train, y_train)。注意,此方法不会复制训练数据。相反,它构建了一个指向训练数据源的索引,并使用该索引来打乱、迭代和获取示例:

from tensorflow.keras.datasets import cifar10
import numpy as np
from tensorflow.keras import Sequential
from tensorflow.keras.layers import Flatten

(x_train, y_train), (x_test, y_test) = cifar10.load_data()
x_train = (x_train / 255.0).astype(np.float32)
x_test  = (x_test  / 255.0).astype(np.float32)

model = Sequential([ Flatten(input_shape=(32, 32, 3))] )
model.compile(loss='sparse_categorical_crossentropy', optimizer='adam')

dataset = tf.data.Dataset.from_tensor_slices((x_train, y_train))    ❶
dataset = dataset.batch(32).shuffle(1000).repeat().prefetch(2)      ❷

model.fit(dataset, epochs=10, steps_per_epoch=len(x_train//32))     ❸

❶ 创建一个 tf.data.Dataset 作为 CIFAR-10 训练数据的模型喂养生成器

❷ 设置模型喂养属性

❸ 在训练时使用生成器作为模型喂养器

现在我们已经有了前面代码示例中的生成器,我们将添加一些属性来将其完整地作为模型喂养器:

  • 我们将批量大小设置为 32 (batch(32))。

  • 我们在内存中随机打乱每次 1000 个示例(shuffle(1000))。

  • 我们反复遍历整个训练数据(repeat())。如果没有 repeat(),生成器只会对训练数据进行单次遍历。

  • 在提供批量的同时,在喂养器队列中预取最多两个批次(prefetch(2))。

接下来,我们可以将生成器作为训练输入源传递给 fit(dataset, epochs=10, steps_per_epoch=len(x_train//32)) 命令进行训练。此命令将生成器视为迭代器,并且对于每次交互,生成器将执行模型喂养任务。

由于我们使用生成器进行模型喂养,并且 repeat() 将导致生成器无限迭代,因此 fit() 方法不知道它何时已经消耗了一个 epoch 的全部训练数据。因此,我们需要告诉 fit() 方法一个 epoch 由多少个批次组成,我们使用关键字参数 steps_per_epoch 来设置。

动态更新批量大小

在第十章中,我们讨论了批量大小与学习率成反比的关系。在训练期间,这种反比关系意味着传统的模型喂养技术将按比例增加批量以适应学习率的降低。虽然 TF.Keras 有一个内置的 LearningRateScheduler 回调来动态更新学习率,但它目前还没有同样的能力来更新批量大小。相反,我将向您展示在降低学习率的同时动态更新批量大小的 DIY 版本。

我将在描述实现它的代码时解释 DIY 过程。在这种情况下,我们添加一个外层训练循环以动态更新批量大小。回想一下,在fit()方法中,批量大小被指定为一个参数。因此,要更新批量大小,我们将划分 epoch 并多次调用fit()。在循环内部,我们将对模型进行指定数量的 epoch 的训练。至于循环,每次迭代时,我们将更新学习率和批量大小,并在循环中设置要训练的 epoch 数量。在for循环中,我们使用一个元组的列表,每个元组将指定学习率(lr)、批量大小(bs)和 epoch 数量(epochs);例如,(0.01, 32, 10)

在循环中重置epochs的数量很简单,因为我们可以将其指定为fit()方法的参数。对于学习率,我们通过(重新)编译模型并在指定优化器参数时重置学习率来重置它——Adam(lr=lr)。在训练过程中重新编译模型是可以的,因为它不会影响模型的权重。换句话说,重新编译不会撤销之前的训练。

重置tf.data.Dataset的批量大小并不简单,因为一旦设置,就无法重置。相反,我们将在每次循环迭代中为训练数据创建一个新的生成器,其中我们将使用batch()方法指定当前的批量大小。

from tensorflow.keras.datasets import cifar10
import numpy as np
from tensorflow.keras import Sequential
from tensorflow.keras.layers import Flatten, Dense, Conv2D, MaxPooling2D
from tensorflow.keras.optimizers import Adam

(x_train, y_train), (x_test, y_test) = cifar10.load_data()
x_train = (x_train / 255.0).astype(np.float32)
x_test  = (x_test  / 255.0).astype(np.float32)

model = Sequential([ Conv2D(16, (3, 3), activation='relu', 
                            input_shape=(32, 32, 3)),
                     Conv2D(32, (3, 3), strides=(2, 2), activation='relu'),
                     MaxPooling2D((2, 2), strides=2),
                     Flatten(),
                     Dense(10, activation='softmax')
                   ])

for lr, bs, epochs in [ (0.01, 32, 10), (0.005, 64, 10), (0.0025, 128, 10) ]:  ❶
    print("hyperparams: lr", lr, "bs", bs, "epochs", epochs)
    dataset = tf.data.Dataset.from_tensor_slices((x_train, y_train))           ❷
    dataset = dataset.shuffle(1000).repeat().batch(bs).prefetch(2)             ❷

    model.compile(loss='sparse_categorical_crossentropy', 
     optimizer=Adam(lr=lr), 
                  metrics=['acc'])                                             ❸
    model.fit(dataset, epochs=epochs, steps_per_epoch=200, verbose=1)          ❹

❶ 外层循环用于在训练期间动态重置超参数

❷ 创建一个新的生成器以重置批量大小

❸ 重新编译模型以重置学习率

❹ 使用重置的 epoch 数量训练模型

让我们看看运行我们的 DIY 版本动态重置训练中超参数的简略输出。您可以看到在外层循环的第一次迭代中,第 10 个 epoch 的训练准确率为 51%。在第二次迭代中,学习率减半,批量大小加倍,第 10 个 epoch 的训练准确率为 58%,在第三次迭代中达到 61%。如您从输出中观察到的,我们在三个迭代中能够保持损失持续减少和准确率增加,因为我们逐渐缩小到损失空间。

hyperparams: lr 0.01 bs 32 epochs 10
Epoch 1/10
200/200 [==============================] - 1s 3ms/step - loss: 1.9392 - acc: 0.2973
Epoch 2/10
200/200 [==============================] - 1s 3ms/step - loss: 1.6730 - acc: 0.4130
...
Epoch 10/10
200/200 [==============================] - 1s 3ms/step - loss: 1.3809 - acc: 0.5170

hyperparams: lr 0.005 bs 64 epochs 10
Epoch 1/10
200/200 [==============================] - 1s 3ms/step - loss: 1.2248 - acc: 0.5704
Epoch 2/10
200/200 [==============================] - 1s 3ms/step - loss: 1.2740 - acc: 0.5510
...
Epoch 10/10
200/200 [==============================] - 1s 3ms/step - loss: 1.1876 - acc: 0.5853

hyperparams: lr 0.0025 bs 128 epochs 10
Epoch 1/10
200/200 [==============================] - 1s 4ms/step - loss: 1.1186 - acc: 0.6063
Epoch 2/10
200/200 [==============================] - 1s 3ms/step - loss: 1.1434 - acc: 0.5997
...
Epoch 10/10
200/200 [==============================] - 1s 3ms/step - loss: 1.1156 - acc: 0.6129

14.1.2 使用 tf.Strategy 进行分布式喂养

TensorFlow 模块tf.distribute.Strategy提供了一个方便且封装的接口,为你完成所有工作,用于在同一个计算实例上的多个 GPU 之间或多个 TPU 之间进行分布式训练。它实现了本章前面描述的同步参数服务器。此 TensorFlow 模块针对 TensorFlow 模型的分布式训练以及并行 Google TPUs 上的分布式训练进行了优化。

当在单个计算实例上使用多个 GPU 进行训练时,你使用 tf .distribute.MirrorStrategy,而当在 TPU 上进行训练时,你使用 tf.distribute .TPUStrategy。在本章中,除了指出你将使用 tf.distribute.experimental.ParameterServerStrategy 以实现跨网络的异步参数服务器之外,我们不会涵盖跨机器的分布式训练。跨多个机器的分布式训练设置相对复杂,可能需要单独的一章。如果你在构建 TensorFlow 模型,并且训练过程中需要大量的并行处理以满足业务目标,我建议使用这种方法,并学习 TensorFlow 文档。

这是我们在单机上设置分布式训练运行的步骤,该机器具有多个 CPU 或 GPU:

  1. 实例化一个分布策略。

  2. 在分布策略的作用域内

    • 创建模型。

    • 编译模型。

  3. 训练模型。

这些步骤可能看起来有些不合常理,因为我们是在构建和编译模型时设置分布策略,而不是在训练时。在 TensorFlow 中,模型构建需要知道它将使用分布式训练策略进行训练。截至本文撰写时,TensorFlow 团队最近发布了一个新的实验版本,其中分布策略可以在不编译模型的情况下设置。

以下是实现前面三个步骤和两个子步骤的代码,这些步骤在此处描述:

  1. 我们定义函数 create_model() 来创建用于训练的模型实例。

  2. 我们实例化分布策略:strategy = tf.distribute.MirrorStrategy().

  3. 我们设置分布上下文:with strategy.scope().

  4. 在分布上下文中,我们创建模型的实例:model = create_model()。然后我们编译它:model.compile()

  5. 最后,我们训练模型。

def create_model():                                               ❶
    model = Sequential([ Conv2D(16, (3, 3), activation='relu', 
                                input_shape=(32, 32, 3)),
                         Conv2D(32, (3, 3), strides=(2, 2), 
                                activation='relu'),
                         MaxPooling2D((2, 2), strides=2),
                         Flatten(),
                         Dense(10, activation='softmax')
                       ])
    return model

strategy = tf.distribute.MirroredStrategy()                       ❷

with strategy.scope():  bbbb                                      ❸
    model = create_model()                                        ❸
    model.compile(loss='sparse_categorical_crossentropy', optimizer='adam')    

model.fit(dataset, epochs=10, steps_per_epoch=200)                ❹

❶ 创建模型实例的函数

❷ 实例化分布策略

❸ 在分布策略的作用域内创建和编译模型

❹ 训练模型

你可能会问,我能否使用已经构建好的模型?答案是:不可以;你必须在分布策略的作用域内构建模型。例如,以下代码将导致错误,提示模型未在分布策略的作用域内构建:

model = create_model()          ❶
with strategy.scope():
    model.compile(loss='sparse_categorical_crossentropy', optimizer='adam')

❶ 模型未在分布策略的作用域内构建

再次,你可能还会问:我已经有一个预先构建或预训练的模型,它不是为分布策略构建的;我还能进行分布式训练吗?这里的答案是:可以。如果你有一个保存到磁盘的 TF.Keras 模型,当你使用 load_model() 将其加载回内存时,它将隐式地构建模型。以下是从预训练模型设置分布策略的示例实现:

with strategy.scope():
    model = tf.keras.models.load_model('my_model')                          ❶
    model.compile(loss='sparse_categorical_crossentropy', optimizer='adam')

❶ 从磁盘加载时模型会隐式重建

同样,当从模型存储库加载预构建模型时,存在隐式的加载和相应的隐式构建。以下代码序列是加载tf.keras.applications内置模型存储库中模型的示例,其中模型被隐式重建:

with strategy.scope():
   model = tf.keras.applications.ResNet50()                                ❶
   model.compile(loss='sparse_categorical_crossentropy', optimizer='adam')

❶ 从存储库加载时模型被隐式重建

默认情况下,镜像策略将使用计算实例上的所有 GPU。您可以使用num_replicas_in_sync属性获取将要使用的 GPU 或 CPU 核心数。您还可以明确设置要使用的 GPU 或核心。在以下代码示例中,我们将分布策略设置为使用两个 GPU:

strategy = tf.distribute.MirroredStrategy(['/gpu:0', '/gpu:1'])
print("GPUs:", strategy.num_replicas_in_sync)

以下代码示例生成了以下输出:

INFO:tensorflow:Using MirroredStrategy with devices ('/job:localhost/replica:0/task:0/device:GPU:0', '/job:localhost/replica:0/task:0/device:GPU:1')
GPUs: 2

14.1.3 使用 TFX 进行模型喂养

第十三章涵盖了 TFX 端到端生产管道的数据管道部分。本节涵盖了训练管道组件的相应 TFX 模型喂养方面,作为另一种实现。图 14.6 描述了训练管道组件及其与数据管道的关系。训练管道由以下组件组成:

  • 训练器—训练模型

  • 调优器—调整超参数(例如,学习率)

  • 评估器—评估模型的客观指标,例如准确性,并将结果与基线(例如,上一个版本)进行比较

  • 基础设施评估器—在部署前在沙盒服务环境中测试模型

图片

图 14.6 TFX 训练管道由调优器、训练器、评估器和基础设施评估器组件组成。

编排

让我们回顾一下 TFX 和管道的一般好处。如果我们单独执行训练/部署模型中的每个步骤,我们将其称为任务感知架构。每个组件都了解自己,但不知道连接的组件或之前执行的历史。

TFX 实现了编排。在编排中,一个管理接口监督每个组件的执行,记住过去组件的执行情况,并维护历史记录。如第十三章所述,每个组件的输出是工件;这些是执行的结果和历史。在编排中,这些工件或对其的引用被存储为元数据。对于 TFX,元数据以关系格式存储,因此可以通过 SQL 数据库进行存储和访问。

让我们更深入地探讨编排的好处,然后我们将介绍 TFX 中模型喂养的工作方式。通过编排,如图 14.7 所示,我们可以做以下事情:

  • 在另一个组件(或多个组件)完成后执行组件的调度。例如,我们可以在从训练数据生成特征模式后调度数据转换的执行。

  • 当组件的执行相互不依赖时,并行调度组件的执行。例如,在数据转换完成后,我们可以并行调度超参数调整和训练。

  • 如果组件的执行没有变化(缓存),则重用组件先前执行中的工件。例如,如果训练数据没有变化,转换组件的缓存工件(即转换图)可以在不重新执行的情况下重用。

  • 为每个组件提供不同的计算引擎实例。例如,数据管道组件可能配置在 CPU 计算实例上,而训练组件配置在 GPU 计算实例上。

  • 如果任务支持分布,例如调整和训练,则可以将任务分布到多个计算实例上。

  • 将组件的工件与组件先前执行的工件进行比较。例如,评估组件可以将模型的指标(例如,准确率)与先前训练的模型版本进行比较。

  • 通过能够向前和向后移动通过生成的工件来调试和审计管道的执行。

图片

图 14.7 编排器摄取表示为图的管道,并配置实例和调度任务。

训练组件

Trainer组件支持训练 TensorFlow 估计器、TF.Keras 模型和其他自定义训练循环。由于 TensorFlow 2.x建议逐步淘汰估计器,我们将仅关注配置 TF.Keras 模型的训练组件,并向其提供数据。训练组件需要以下最小参数:

  • module_file—这是用于自定义训练模型的 Python 脚本。它必须包含一个run_fn()函数作为训练的入口点。

  • examples—用于训练模型的示例,这些示例来自ExampleGen组件的输出,example_gen.outputs['examples']

  • schema—数据集模式,它来自SchemaGen组件的输出,schema_gen['schema']

  • custom_executor_spec—自定义训练的执行器,它将在module_file中调用run_fn()函数。

from tfx.components import Trainer
from tfx.components.base import executor_spec                              ❶
from tfx.components.trainer import GenericExecutor                         ❶

trainer = Trainer(
    module_file=module_file,                                               ❷
    examples=example_gen.outputs['examples'],                              ❸
    schema=schema_gen.outputs['schema'],                                   ❹
    custom_executor_spec=executor_spec.ExecutorClassSpec(GenericExecutor)  ❺
)

❶ 自定义训练的导入

❷ 自定义训练的 Python 脚本

❸ 在训练过程中向模型提供数据的训练数据源

❹ 从数据集中推断出的模式

❺ 自定义训练的自定义执行器

如果训练数据需要由Transform组件进行预处理,我们需要设置以下两个参数:

  • transformed_examples—设置为Transform组件的输出,transform.outputs['transformed_examples']

  • transform_graph—由Transform组件产生的静态转换图,transform.outputs['transformed_graph']

trainer = Trainer(
    module_file=module_file,
    transformed_examples=transform.outputs['transformed_examples'],    ❶
    transform_graph=transform.outputs['transform_graph'],              ❶
    schema=schema_gen.outputs['schema'],
    custom_executor_spec=executor_spec.ExecutorClassSpec(GenericExecutor)
)

❶ 训练数据从Transform组件馈送到静态转换图。

通常,我们希望将其他超参数传递到训练模块中。这些可以通过将 train_argseval_args 作为附加参数传递给 Trainer 组件。这些参数被设置为键/值对列表,并转换为 Google 的 protobuf 格式。以下代码传递了训练和评估的步骤数:

from tfx.proto import trainer_pb2                                          ❶
trainer = Trainer(
    module_file=module_file,
    transformed_examples=transform.outputs['transformed_examples'],
    transform_graph=transform.outputs['transform_graph'],   
    schema=schema_gen.outputs['schema'],
    custom_executor_spec=executor_spec.ExecutorClassSpec(GenericExecutor),
    train_args=trainer_pb2.TrainArgs(num_steps=10000),                     ❷
    eval_args=trainer_pb2.EvalArgs(num_steps=5000)                         ❷
)

❶ 导入用于传递超参数的 TFX protobuf 格式

❷ 将超参数作为 protobuf 消息传递到 Trainer 组件中

现在,让我们看看自定义 Python 脚本中 run_fn() 函数的基本要求。run_fn() 的参数由传递给 Trainer 组件的参数构建,并且作为属性访问。在以下示例实现中,我们执行以下操作:

  • 提取训练的总步骤数:training_args.train_steps

  • 提取每个 epoch 后的验证步骤数:training_args .eval_steps

  • 获取训练和评估数据的 TFRecord 文件路径:training_args .train_files。注意,ExampleGen 不会提供内存中的 tf.Examples,而是提供包含 tf.Examples 的磁盘上的 TFRecords。

  • 获取转换图,training_args.transform_output,并构建转换执行函数,tft.TFTransformOutput()

  • 调用内部函数 _input_fn() 来创建训练和验证数据集的迭代器。

  • 使用内部函数 _build_model() 构建或加载 TF.Keras 模型。

  • 使用 fit() 方法训练模型。

  • 获取存储训练模型的托管目录,training_args.output,该目录作为可选参数 output 传递给 Trainer 组件。

  • 将训练好的模型保存到指定的服务输出位置,model.save (serving_dir)

from tfx.components.trainer.executor import TrainerFnArgs
import tensorflow_transform as tft

BATCH_SIZE = 64                                                                  ❶
STEPS_PER_EPOCH = 250                                                            ❶

def run_fn(training_args: TrainerFnArgs):
    train_steps = training_args.train_steps                                      ❷
    eval_steps  = training_args.eval_steps                                       ❷

    train_files = training_args.train_files                                      ❸
    eval_files  = training_args.eval_files                                       ❸

    tf_transform_output = tft.TFTransformOutput(training_args.transform_output)  ❹
    train_dataset = _input_fn(train_files, tf_transform_output, BATCH_SIZE)      ❹
    eval_dataset  = _input_fn(eval_files, tf_transform_output, BATCH_SIZE)       ❹

    model = _build_model()                                                       ❺

    epochs = train_steps // STEPS_PER_EPOCH                                      ❻

    model.fit(train_dataset, epochs=epochs, validation_data=eval_dataset,
              validation_steps=eval_steps)                                       ❼

    serving_dir = training_args.output                                           ❽
    model.save(serving_dir)                                                      ❽

❶ 将超参数设置为常量

❷ 将训练/验证步骤作为参数传递给 Trainer 组件

❸ 将训练/验证数据作为参数传递给 Trainer 组件

❹ 为训练和验证数据创建数据集迭代器

❺ 构建或加载用于训练的模型

❻ 计算 epoch 数量

❼ 训练模型

❽ 将模型以 SavedModel 格式保存到指定的服务目录

在构建自定义 Python 训练脚本时,有很多细微之处和多种方向可以选择。有关更多详细信息和建议,我们建议查看 TFX 的 Trainer 组件指南(www.tensorflow.org/tfx/guide/trainer)。

Tuner 组件

Tuner 组件是训练流程中的可选任务。您可以在自定义 Python 训练脚本中硬编码训练的超参数,或者使用 Tuner 来找到超参数的最佳值。

Tuner 的参数与 Trainer 非常相似。也就是说,Tuner 将进行短训练运行以找到最佳超参数。但与返回训练模型的 Trainer 不同,Tuner 的输出是调优的超参数值。通常不同的两个参数是 train_argseval_args。由于这些将是较短的训练运行,因此调优器的步骤数通常是完整训练的 20% 或更少。

另一个要求是自定义的 Python 训练脚本 module_file 包含函数入口 tuner_fn()。典型的做法是使用一个包含 run_fn()tuner_fn() 函数的单个 Python 训练脚本。

tuner = Tuner(
    module_file=module_file,
    transformed_examples=transform.outputs['transformed_examples'],
    transform_graph=transform.outputs['transform_graph'],
    schema=schema_gen.outputs['schema'],
    train_args=trainer_pb2.TrainArgs(num_steps=2000),    ❶
    eval_args=trainer_pb2.EvalArgs(num_steps=1000)       ❶
)

❶ 调优时较短的训练运行步骤数

接下来,我们将查看 tuner_fn() 的一个示例实现。我们将使用 KerasTuner 进行超参数调优,但你可以使用与你的模型框架兼容的任何调优器。我们之前在第十章中介绍了使用 KerasTuner。它是一个独立的包,因此你需要按照以下方式安装它:

pip install keras-tuner

Trainer 组件类似,将 Tuner 组件的参数和默认值作为 tuner_args 参数的属性传递给 tuner_fn()。请注意,函数的起始部分与 run_fn() 相同,但在到达训练步骤时有所不同。我们不是调用 fit() 方法并保存训练好的模型,而是这样做:

  1. 实例化一个 KerasTuner:

    • 我们使用 build_model() 作为超参数模型参数。

    • 调用内部函数 _get_hyperparameters() 来指定超参数搜索空间。

    • 将最大试验次数设置为 6。

    • 设置选择最佳超参数值的指标。在这种情况下,是验证准确率。

  2. 将调优器和剩余的训练参数传递给 TunerFnResult() 实例,该实例将执行调优。

  3. 返回调优试验的结果。

import kerastuner

def tuner_fn(tuner_args: FnArgs) -> TunerFnResult:                          ❶
    train_steps = tuner_args.train_steps
    eval_steps  = tuner_args.eval_steps 

    train_files = tuner_args.train_files
    eval_files  = tuner_args.eval_files 

    tf_transform_output = tft.TFTransformOutput(tuner_args.transform_output)
    train_dataset = _input_fn(train_files, tf_transform_output, BATCH_SIZE)
    eval_dataset  = _input_fn(eval_files, tf_transform_output, BATCH_SIZE)

    tuner = kerastuner.RandomSearch(_build_model(),                         ❷
                                    max_trails=6, 
                                    hyperparameters=_get_hyperparameters(), ❸
                                    objective='val_accuracy'
                                   )

    result = TunerFnResult(tuner=tuner,                                     ❹
                           fit_kwargs={                                     ❺
                               'x': train_dataset,                          ❺
                               'validation_data': eval_dataset,             ❺
                               'steps_per_epoch': train_steps,              ❺
                               'validation_steps': eval_steps               ❺
                           })
    return result

❶ 超参数调优的入口点函数

❷ 为随机搜索实例化 KerasTuner

❸ 获取超参数搜索空间

❹ 使用指定的调优实例实例化和执行调优试验

❺ 调优期间短训练运行的训练参数

现在,让我们看看 TunerTrainer 组件是如何串联在一起形成一个可执行管道的。在下面的示例实现中,我们对 Trainer 组件的实例化进行了一次修改,添加了可选参数 hyperparameters 并将输入连接到 Tuner 组件的输出。现在,当我们使用 context.run() 执行 Trainer 实例时,协调器将看到对 Tuner 的依赖,并将它的执行安排在 Trainer 组件进行完整训练之前:

tuner = Tuner(
    module_file=module_file,
    transformed_examples=transform.outputs['transformed_examples'],
    transform_graph=transform.outputs['transform_graph'],
    schema=schema_gen.outputs['schema'],
    train_args=trainer_pb2.TrainArgs(num_steps=2000),
    eval_args=trainer_pb2.EvalArgs(num_steps=1000)   
)
trainer = Trainer(
    module_file=module_file,
    transformed_examples=transform.outputs['transformed_examples'],
    transform_graph=transform.outputs['transform_graph'],   
    schema=schema_gen.outputs['schema'],
    custom_executor_spec=executor_spec.ExecutorClassSpec(GenericExecutor),
    hyperparameters=tuner.outputs['best_hyperparameters'],     ❶
    train_args=trainer_pb2.TrainArgs(num_steps=10000),   
    eval_args=trainer_pb2.EvalArgs(num_steps=5000)   
)

context.run(trainer)                                           ❷

❶ 从 Tuner 组件获取调优的超参数

❷ 执行 Tuner/Trainer 管道

与训练器一样,Python 超参数调整脚本可以自定义。请参阅 TFX 的指南了解Tuner组件(www.tensorflow.org/tfx/guide/tuner)。

14.2 训练调度器

在研究或开发环境中,训练管道通常是手动启动的;管道中的每个任务都是手动启动的,这样如果需要,每个任务都可以被观察和调试。在生产方面,它们是自动化的;自动化使得执行管道更加高效,劳动强度更低,并且更具可扩展性。在本节中,我们将了解生产环境中调度的工作方式,因为大量的训练作业可以排队进行训练,并且/或者模型会持续重新训练。

生产环境的需求与研究和开发不同,如下所示:

  • 在生产环境中,计算和网络 I/O 的数量可能会有很大变化,因为在生产环境中,大量模型可能会并行持续重新训练。

  • 训练作业可能有不同的优先级,因为它们必须在部署的交付时间表内完成。

  • 训练工作可能需要按需资源,例如可能按使用情况配置的特殊硬件,如云实例。

  • 训练作业的长度可能会因重启和超参数调整而变化。

图 14.8 描述了一个适用于具有上述需求的大规模生产环境的端到端生产管道作业调度器。我们使用一个概念视图,即生产环境中的作业调度尚未得到开源机器学习框架的充分支持,但由付费机器学习服务(如云提供商)以不同程度的支持。

图 14.8 大规模生产环境中管道作业调度

让我们深入探讨图 14.8 中所示的生产环境的一些假设,这在企业环境中是典型的:

  • 没有定制工作。尽管在模型开发过程中可能存在定制工作,但一旦模型进入生产阶段,它将使用预定义的、受版本控制的管道进行训练和部署。

  • 管道有定义好的依赖关系。例如,一个用于图像输入模型的训练管道将有一个只能与特定于图像数据的数据管道结合的依赖关系。

  • 管道可能有可配置的属性。例如,数据管道的源输入和输出形状是可配置的。

  • 如果对管道进行了升级,它将成为下一个版本。保留上一个版本和执行历史。

  • 一个作业请求指定了管道需求。这些需求可以通过引用特定的管道和版本来指定,或者通过属性来指定,调度器将确定最佳匹配的管道。需求还可以指定可配置的属性,例如数据管道的输出形状。

  • 作业请求指定了执行需求。例如,如果使用类似 AutoML 的服务,它可能指定计算时间内的最大训练预算。在另一个例子中,它可能指定提前停止条件,或者重新启动训练作业的条件。

  • 作业请求指定了计算需求。例如,如果进行分布式训练,它可能指定计算实例的数量和类型。需求通常包括操作系统和软件要求。

  • 作业请求指定了优先级需求。通常,这要么是按需,要么是批量。按需作业通常在计算资源可用于配置时调度。批量请求通常在满足一定条件后才会延迟。例如,它可能指定执行的时间窗口,或者等待计算实例最经济的时候。

  • 按需工作可以设置一个可选的优先级条件。如果没有设置,通常以先进先出(FIFO)的方式调度。指定了优先级的作业可能会改变其在 FIFO 调度队列中的位置。例如,一个估计时长为X并在时间Y完成的作业可能会被提升到队列中以满足需求。

  • 作业从队列中调度后,其管道组装、执行和计算需求将转交给调度器。

14.2.1 管道版本控制

在生产环境中,管道是受版本控制的。除了版本控制之外,每个版本的管道都将包含用于跟踪的元数据。这些元数据可能包括以下内容:

  • 管道创建和最后更新的时间

  • 管道上次使用的时间和使用的作业

  • 虚拟机(VM)依赖项

  • 平均执行时间

  • 故障率

图 14.9 描述了一个数据管道和相应可重用组件的存储库,这些组件都处于版本控制之下。在这个例子中,我们有两个可重用组件的存储库:

  • 磁盘上的图像迭代器—用于构建特定于数据集存储格式的数据集迭代器的组件

  • 内存中转换—用于数据预处理和不变性转换的组件

图 14.9 展示了使用不同内存中可重用组件的相同数据管道的不同版本

在这个例子中,我们展示了一个具有两个版本的单个数据管道;v2 配置为使用标准化代替 v1 中的缩放。此外,v2 的历史记录比 v1 的历史记录有更好的训练结果。数据管道由磁盘上的图像迭代器和内存中可重用转换组件以及针对管道的特定代码组成。版本 v1 使用缩放进行数据归一化。假设后来我们发现标准化在训练图像数据集时给出了更好的结果,例如验证准确率。因此,我们将缩放替换为标准化,从而创建了管道的新版本 v2。

图 14.10 使用版本清单来识别特定版本管道中可重用组件的版本

现在我们来看一个不太明显的版本控制系统(图 14.10)。我们将继续使用现有的数据管道示例,但这次磁盘上的 TFRecords 迭代器已更新到 v2,v2 比 v1 有 5%的性能提升。

由于这是数据管道的可配置属性,为什么我们要更新相应数据管道的版本号,而该数据管道本身并没有改变?如果我们想重现或审计使用该管道的训练作业,我们需要知道作业完成时的可重用组件的版本号。在我们的例子中,我们这样做如下:

  • 为可重用组件和相应的版本号创建清单。

  • 更新数据管道以包含更新的清单。

  • 更新数据管道上的版本号。

14.2.2 元数据

现在我们来讨论如何使用管道和其他资源(如数据集)存储元数据,以及它如何影响训练作业的组装、执行和调度。那么什么是元数据,它与工件和历史记录有何不同?历史记录是关于保留管道执行的信息,而元数据是关于保留管道状态的信息。工件是历史记录和元数据的组合。

参考我们的示例数据管道,假设我们正在使用版本 v3,但我们使用一个新的数据集资源。当时,我们在数据集资源上只有数据集中的示例数量这个统计数据。我们不知道的是示例的平均值和标准差。因此,当 v3 数据管道与新数据集组装时,底层的管道管理会查询数据集的平均值和标准差的状态。由于它们是未知的,管道管理会在标准化组件之前添加一个组件来计算标准化组件所需的值。图 14.11 的上半部分描述了当平均值和标准差的状态未知时管道的构建。

现在,假设我们在没有任何更改数据集的情况下再次运行此管道。我们会重新计算平均值和标准差吗?不,当管道管理查询数据集并发现值已知时,它将添加一个组件来使用缓存的值。图 14.11 的下半部分描述了当平均值和标准差的状态未知时管道的构建。

图 14.11 管理管道选择根据数据集的状态信息计算平均值/标准差或使用缓存值

现在,让我们通过添加一些新示例来更新数据集,我们将这个数据集版本称为 v2。由于示例已被更新,这使之前的均值和标准差计算无效,因此更新将此统计信息恢复为“未知”。

由于统计信息恢复为未知状态,下一次使用更新后的 v2 数据集的 v3 数据管道版本时,管道管理将再次添加计算均值和标准差的组件。图 14.12 展示了这一数据管道的重构过程。

图片

图 14.12 管道管理在数据集中添加新示例后,将重新计算均值和标准差添加到管道中。

14.2.3 历史

历史指的是管道实例执行的输出结果。例如,考虑一个在模型完整训练之前进行超参数搜索的训练管道。超参数搜索空间和搜索中选定的值成为管道执行历史的一部分。

图 14.13 展示了管道实例的执行过程,它包括以下内容:

  • 管道组件的版本,v1

  • 训练数据和对应状态,统计信息

  • 训练模型资源及其对应状态,指标

  • 执行实例的版本,v1.1,以及对应的历史,超参数

图片

图 14.13 展示了管道实例的执行历史,其中工件包括状态、历史和资源

现在,我们如何将历史数据整合到相同管道的后续执行实例中?图 14.14 展示了与图 14.13 相同的管道配置,但使用了新的数据集版本 v2。v2 数据集与 v1 的不同之处在于包含少量新的示例;这些新示例的数量远小于示例总数。

图片

图 14.14 管道管理在新增示例数量显著较少时重用之前执行历史中选定的超参数

在管道实例的组装过程中,管道管理可以使用之前执行实例的历史数据。在我们的示例中,新增示例的数量足够低,以至于管道管理可以重用之前执行历史中选定的超参数值,从而消除了重新执行超参数搜索的开销。

图 14.15 展示了我们示例中管道管理的另一种方法。在这种替代方案中,管道管理继续在第二个执行实例中配置执行超参数搜索的任务,但有所不同:

  • 假设第二次执行实例的新超参数值将位于第一次执行中选定的值附近

  • 将搜索空间缩小到第一个执行实例历史中选定参数周围的小ε范围内

到目前为止,我们已经涵盖了端到端生产管道和调度的数据与训练部分。下一节将介绍在将模型部署到生产环境之前如何评估模型。

图片

图 14.15 当新示例数量显著较少时,管道管理将超参数搜索空间缩小到先前执行历史附近的区域。

14.3 模型评估

在生产环境中,模型评估的目的是在部署到生产之前确定其相对于基线的性能。如果是第一次部署模型,基线由生产团队指定,通常被称为机器学习操作。否则,基线是当前部署的生产模型,通常被称为受祝福模型。与基线进行比较的模型称为候选模型

14.3.1 候选模型与受祝福模型比较

之前,我们在实验和开发的环境中介绍了模型评估,其中的评估基于测试(保留)数据集的客观指标。然而,在生产中,评估基于一系列更广泛的因素,例如资源消耗、扩展以及在生产中受祝福模型看到的样本集(这些不是测试数据集的一部分)。

例如,假设我们想要评估生产模型的下一个候选版本。我们想要进行苹果对苹果的比较。为此,我们将评估受祝福模型和候选模型对相同的测试数据,确保测试数据具有与用于训练的数据集相同的采样分布。我们还想用相同的生产请求子集测试这两个模型;这些请求应该具有与受祝福模型在生产中实际看到的相同的采样分布。为了使候选模型取代受祝福模型并成为下一个部署的版本,测试和生产样本的指标值(例如,分类的准确率)必须在两者上都更好。在图 14.16 中,你可以看到我们如何设置这个测试。

所以,你可能会问,为什么我们不直接用与受祝福模型相同的测试数据来评估候选模型呢?嗯,现实情况是,一旦模型部署,它在预测的例子中的分布很可能与训练时的分布不同。我们还想评估模型在部署后可能看到的分布。接下来,我们将介绍从训练和生成环境中分布变化的两种类型:服务偏差和数据漂移。

图片

图 14.16 候选模型的评估包括训练和生产数据的数据分布。

服务偏差

现在我们深入探讨一下为什么我们要将候选模型与生产数据进行评估。在第十二章中,我们讨论了你的训练可能是一个子群体的抽样分布,而不是整个群体。首先,我们假设部署模型的预测请求来自相同的子群体。例如,假设模型被训练来识别 10 种水果,并且所有部署模型的预测请求都是这 10 种水果——相同的子群体。

但现在假设我们没有相同的抽样分布。生产模型看到的每个类的频率与训练数据不同。例如,假设训练数据完美平衡,每种水果有 10%的示例,测试数据上的整体分类准确率为 97%。但对于 10 个类别中的一个(比如桃子),准确率为 75%。现在假设 40%的预测请求是针对部署的幸运模型的桃子。在这种情况下,子群体保持不变,但训练数据和生产请求之间的抽样分布发生了变化。这被称为服务偏差

那么,我们如何做到这一点呢?首先,我们必须配置一个系统来捕获预测的随机选择及其对应的结果。假设你想要收集所有预测的 5%。你可以创建一个介于 1 到 20 之间的整数均匀随机分布,并为每个预测从分布中抽取一个值。如果抽取的值是 1,你保存预测及其对应的结果。在采样周期结束后,你手动检查保存的预测/结果,并确定每个预测的正确真实值。然后,你将手动标记的真实值与预测结果进行比较,以确定部署生产模型上的指标。

然后你使用相同生产样本的手动标记版本评估候选模型。

数据漂移

现在假设生产抽样分布不是来自与训练数据相同的子群体,而是不同的子群体。继续我们的 10 种水果的例子,并假设训练数据包括新鲜成熟的果实。但我们的模型部署在果园的拖拉机上,那里的果实可以处于各种成熟阶段:绿色、成熟、腐烂。这些绿色和腐烂的果实是训练数据中的不同子群体。在这种情况下,抽样分布保持不变,但训练数据和生产请求之间的子群体发生了变化。这被称为数据漂移

在这种情况下,我们想要将生产样本分离并划分为两部分:一部分与训练数据的子群体相同(例如,成熟的果实),另一部分与训练数据的子群体不同(例如,绿色和腐烂的果实)。然后,我们对生产样本的每个部分进行单独评估。

总的来说,测试、服务偏差和数据漂移样本分别被称为评估切片,如图 14.17 所示。一个组织可能对其生产有自定义的评估切片定义,而这一组测试、服务偏差和数据漂移是一般规则。

图 14.17 生产中的评估切片,包括来自训练数据的样本、服务偏差和生产请求的数据漂移

扩展

现在假设我们的候选模型在所有评估切片中至少在一个指标上与受祝福模型相等或更好。我们现在可以版本化候选模型并将其作为受祝福模型的替代品部署吗?还不行。我们还没有了解候选模型与受祝福模型相比在计算性能上的表现。也许候选模型需要更多的内存,或者也许候选模型的延迟更长。

在我们做出最终决定之前,我们应该将模型部署到一个沙盒环境中,该环境复制了已部署的受祝福模型的计算环境。我们还想确保,在评估期间,生产环境中的预测请求实时复制,并发送到生产环境和沙盒环境。我们的目标是收集沙盒模型的利用率指标,例如消耗的计算和内存资源以及预测结果的延迟时间。你可以在图 14.18 中看到这个沙盒设置。

图 14.18 在部署之前,最后一步是在沙盒环境中运行候选模型,使用与受祝福模型相同的预测请求。

你可能会问,为什么我们需要在沙盒环境中测试候选模型。我们想知道新模型在服务性能上是否继续满足业务需求。也许候选模型在矩阵乘法操作上有显著增加,以至于返回预测的延迟时间更长,不符合业务需求。也许内存占用增加,使得模型在高服务负载下开始进行内存到页面缓存。

现在让我们考虑一些场景。首先,你可能会说,即使内存占用或计算扩展更大,或者延迟更长,我们也可以简单地添加更多的计算和/或内存资源。但是,有许多原因你可能无法仅仅添加更多资源。如果模型部署在受限环境中,例如移动设备,例如,你无法更改内存或计算设备。或者也许环境拥有极好的资源,但不能进一步修改,例如已经发射到太空的航天器。或者也许模型被一个学区使用,该学区有固定的计算成本预算。

无论原因如何,都必须进行最终的缩放评估,以确定其使用的资源。对于受限制的环境,例如手机或物联网设备,您希望了解候选模型是否会继续满足已部署模型的运行要求。如果您的工作环境不受限制,例如自动扩展的云计算实例,您需要知道新模型是否符合 ROI 的成本要求。

14.3.2 TFX 评估

现在我们来看看如何使用 TFX 评估当前训练的模型,以便我们可以决定它是否会成为下一个批准的模型。本质上,我们使用EvaluatorInfraValidator组件。

Evaluator

Trainer组件完成后执行的Evaluator组件评估模型与基线。我们将来自ExampleGen组件的评估数据集以及来自Trainer组件的训练模型输入到Evaluator中。

如果存在之前批准的模型,我们也会将其输入。如果没有之前批准的模型,则跳过与批准模型基线的比较。

Evaluator组件使用 TensorFlow 模型分析度量库,除了 TFX 之外还需要导入,如下所示:

from tfx.components import Evaluator, ResolverNode
import tensorflow_model_analysis as tfma

下面的代码示例演示了将Evaluator组件构建到 TFX 管道中的最小要求,这些参数包括:

  • examplesExampleGen的输出,它生成用于评估的示例批次

  • model—用于评估的Trainer训练模型的输出

evaluator = Evaluator(examples=example_gen.output['examples'],    ❶
                      model=trainer.output['model'],              ❶
                      baseline_model=None,                        ❷
                      eval_config=None                            ❸
                     )

❶ 参数的最小要求

❷ 没有用于比较的基线模型

❸ 用于评估的默认数据集切片

在前面的示例中,参数eval_config设置为None。在这种情况下,Evaluator将使用整个数据集进行评估,并使用在模型训练时指定的度量,例如分类模型的准确率。

当指定eval_config参数时,它接受一个tfma.EvalConfig实例,该实例接受三个参数:

  • model_specs—模型输入和输出的规范。默认情况下,假设输入是默认的服务签名。

  • metrics_specs—用于评估的一个或多个度量的规范。如果没有指定,则使用在模型训练时指定的度量。

  • slicing_specs—用于评估的数据集的一个或多个切片的规范。如果没有指定,则使用整个数据集。

eval_config = tfma.EvalConfig(model_specs=[],
                              metrics_specs=[],
                              slicing_specs=[]
                             )

EvalConfig的参数差异很大,我建议阅读 TensorFlow TFX 教程中的Evaluator组件(www.tensorflow.org/tfx/guide/evaluator),以获得比我在这里涵盖的范围更深入的理解。

如果存在用于比较的先前批准的模型,则baseline_model参数设置为 TFX 组件ResolverNode的实例。

下面的代码示例是 ResolverNode 的最小规范,其中参数如下:

  • instance_name—这是分配给下一个祝福模型的名称,该模型作为元数据存储。

  • resolver_class—这是要使用的解析器对象的实例类型。在这种情况下,我们指定了实例类型以祝福最新的模型。

  • model—这指定了要祝福的模型类型。在这种情况下,Channel (type=Model) 可以是 TensorFlow 估计器或 TF.Keras 模型。

  • model_blessing—这指定了如何在元数据中存储祝福的模型。

from tfx.dsl.experimental.lastest_blessed_model_resolver import LatestBlessedModelResolver
from tfx.types import Channel
from tfx.types.standard_artifacts import Model, ModelBlessing

baseline_model = ResolverNode(instance_name='blessed_model', 
                              resolver_class=LatestBlessedModelResolver,
                              model=Channel(type=Model),
                              model_blessing=Channel(type=ModelBlessing)
                             )

在前面的代码示例中,如果这是第一次为该模型调用 ResolverNode() 实例,则当前模型成为祝福的模型,并以 blessed_model 的实例名称存储在元数据中作为祝福模型。

否则,当前模型将与之前祝福的模型进行比较,该模型被标识为 blessed_model,并从元数据存储中相应地检索。在这种情况下,两个模型将与相同的评估片段进行比较,并比较它们相应的指标。如果新模型在指标上有所改进,它将成为下一个版本的 blessed_model 实例。

InfraValidator

在管道中的下一个组件是 InfraValidatorInfra 指的是 基础设施。只有当当前训练模型成为新的祝福模型时,才会调用此组件。此组件的目的是确定模型是否可以在模拟生产环境的沙盒环境中加载和查询。用户负责定义沙盒环境。换句话说,用户决定沙盒环境与生产环境的接近程度,因此决定了 InfraValidator 测试的准确性。

下一个代码示例展示了 InfraValidator 的最小参数要求:

  • model—训练模型(在此示例中,来自 Trainer 组件的当前训练模型)

  • serving_spec—沙盒环境的规范

from tfx.components import Evaluator, ResolverNode

infra_validator = InfraValidator(model=trainer.outputs['model'],     ❶
                                 serving_spec=serving_spec           ❷
                                )

❶ 部署到沙盒环境的训练模型

❷ 沙盒环境的规范

服务规范由两部分组成:

  • 服务二进制的类型。截至 TFX 版本 0.22,仅支持 TensorFlow Serving。

  • 服务平台的类型,可以是

    • Kubernetes

    • 本地 Docker 容器

此示例展示了使用 TensorFlow Serving 和 Kubernetes 集群指定服务规范的最小要求:

from tfx.proto.infra_validator_pb2 import ServingSpec

serving_spec = ServingSpec(tensorflow_serving=TensorflowServing(tags=['latest']),
                           kubernetes=KubernetesConfig()
                          )

TFX 关于 ServingSpec 的文档目前很少,并将您重定向到 GitHub 存储库中的 protobuf 定义(mng.bz/6NqA)以获取更多信息。

14.4 提供预测服务

现在我们有了新的祝福模型,我们将探讨如何将模型部署到生产环境中以提供预测服务。生产模型通常用于按需(实时)或批量预测。

批量预测与从部署的模型中进行的按需(实时)预测有何不同?有一个关键的区别,但除此之外,它们在结果上基本上是相同的:

  • 按需(实时)——为整个实例集(一个或多个数据项)进行按需预测,并实时返回结果

  • 批量预测服务——在后台为整个实例集进行排队(批量)预测,并在准备好时将结果存储在云存储桶中

14.4.1 按需(实时)服务

对于按需预测,例如通过交互式网站进行的在线请求,模型被部署到一个或多个计算实例上,并接收作为 HTTP 请求的预测请求。一个预测请求可以包含一个或多个单个预测;每个预测通常被称为实例。您可以有单实例请求,其中用户只想对一张图像进行分类,或者多实例请求,其中模型将为多张图像返回预测。

假设模型接收单实例请求:用户提交一个图像并希望得到一个预测,如分类或图像标题。这些都是通过互联网实时到达的按需请求。例如,它们可能来自用户浏览器中运行的 Web 应用程序,或者服务器上的后端应用程序作为微服务获取预测。

图 14.19 展示了这个过程。在这个描述中,模型包含在一个服务二进制文件中,该文件由一个 Web 服务器、服务功能和受祝福的模型组成。Web 服务器接收预测请求作为一个 HTTP 请求包,提取请求内容,并将内容传递给服务功能。服务功能随后将内容预处理成受祝福模型输入层期望的格式和形状,然后输入到受祝福模型中。受祝福模型将预测返回给服务功能,服务功能执行任何后处理以进行最终交付,然后将其返回给 Web 服务器,Web 服务器将后处理的预测作为 HTTP 响应包返回。

图片

图 14.19 一个在生产二进制文件上的模型通过互联网接收按需预测请求

如您在图 14.19 中看到的,在客户端,一个或多个预测请求被传递给一个 Web 客户端。Web 客户端随后将创建一个单实例或多实例的预测 HTTP 请求包。预测请求被编码,通常为 base64,以确保通过互联网安全传输,并放置在 HTTP 请求包的内容部分。

Web 服务器接收 HTTP 请求,解码内容部分,并将单个或多个预测请求传递给服务功能。

现在我们来深入探讨服务函数的目的和构建方式。通常,在客户端,内容(如图片、视频、文本和结构化数据)以原始格式发送到服务二进制文件,而不进行任何预处理。当网络服务器接收到请求后,它会从请求包中提取内容并将其传递给服务函数。在传递给服务函数之前,内容可能需要进行解码,例如 base64 解码。

假设内容是一个包含单个实例请求的内容,例如 JPG 或 PNG 格式的压缩图像。假设模型的输入层是不压缩的图像字节,格式为多维数组,例如 TensorFlow 张量或 NumPy 数组。至少,服务函数必须执行模型之外的任何预处理(例如,预茎)。假设模型没有预茎,服务函数需要执行以下操作:

  • 确定图像数据的压缩格式,例如从 MIME 类型。

  • 将图像解压缩为原始字节。

  • 将原始字节重塑为高度 × 宽度 × 通道(例如,RGB)。

  • 将图像调整大小以匹配模型的输入形状。

  • 对像素数据进行缩放,以进行归一化或标准化。

接下来是一个图像分类模型的服务函数示例实现,其中图像数据的预处理发生在模型上游,没有预茎。在这个例子中,serving_fn()方法通过将方法分配为模型的签名serving_default在服务二进制文件中的网络服务器上注册。我们在服务函数中添加了装饰器@tf.function,该装饰器指示 AutoGraph 编译器将 Python 代码转换为静态图,然后可以在 GPU 上与模型一起运行。在这个例子中,假设网络服务器将提取的预测请求内容(在这种情况下,JPG 压缩字节)作为 TensorFlow 字符串传递。对tf.saved_model.save()的调用将服务函数保存到与模型相同的存储位置,该位置由参数export_path指定。

现在我们来看看这个服务函数的主体。在下面的代码示例中,我们假设服务二进制文件中的网络服务器从 HTTP 请求包中提取内容,解码 base64 编码,并将内容(压缩的 JPG 图像字节)作为 TensorFlow 字符串数据类型tf.string传递。然后服务函数执行以下操作:

  • 调用一个预处理函数preprocess_fn(),将 JPG 图像解码为原始字节,并调整大小和缩放以匹配底层模型的输入层,作为一个多维 TensorFlow 数组。

  • 将多维 TensorFlow 数组传递给底层模型m_call()

  • 将底层模型返回的预测prob返回给网络服务器。

  • 服务二进制文件中的 Web 服务器将预测结果打包到 HTTP 响应数据包中,返回给 Web 客户端。

@tf.function(input_signature=[tf.TensorSpec([None], tf.string)])
def serving_fn(bytes_inputs):                                     ❶
    images = preprocess_fn(bytes_inputs)                          ❷
    prob = m_call(**images)                                       ❸
    return prob                                                   ❹

tf.saved_model.save(model, export_path, signatures={              ❺
    'serving_default': serving_fn,                                ❺
})

❶ 定义接收通过服务二进制文件的 Web 服务器内容的服务函数

❷ 将内容转换为与底层模型输入层匹配的方法

❸ 将预处理数据传递给底层模型进行预测。

❹ 预测结果被返回到服务二进制文件的 Web 服务器,作为 HTTP 响应返回。

❺ 将服务函数作为静态图与底层模型保存

以下是一个服务函数预处理步骤的示例实现。在这个例子中,函数preprocess_fn()接收来自 Web 服务器的 base64 解码后的 TensorFlow 字符串,并执行以下操作:

  • 调用 TensorFlow 静态图操作tf.io.decode_jpeg()将输入解压缩为一个解压缩图像,作为多维 TensorFlow 数组。

  • 调用 TensorFlow 静态图操作tf.image.convert_image_dtype()将整数像素值转换为 32 位浮点值,并将值缩放到 0 到 1 的范围(归一化)。

  • 调用 TensorFlow 静态图操作tf.image.resize()将图像调整大小以适应模型的输入形状。在这个例子中,那将是(192, 192, 3),其中值 3 是通道数。

  • 将预处理后的图像数据传递给底层模型的输入层,该输入层由层的签名numpy_inputs指定。

def _preprocess(bytes_input):
    decoded = tf.io.decode_jpeg(bytes_input, channels=3)             ❶
    decoded = tf.image.convert_image_dtype(decoded, tf.float32)      ❷
    resized = tf.image.resize(decoded, size=(192, 192))              ❸
    return resized

@tf.function(input_signature=[tf.TensorSpec([None], tf.string)])
def preprocess_fn(bytes_inputs):
    with tf.device("cpu:0"):
        decoded_images = tf.map_fn(_preprocess, bytes_inputs, 
        dtype=tf.float32)                                           ❹
    return {"numpy_inputs": decoded_images}                         ❺

❶ 将 TensorFlow 字符串解码为编码的 JPG,并将其转换为 TensorFlow 多维解压缩图像原始字节数据。

❷ 将像素转换为 32 位浮点值并缩放

❸ 将图像调整大小以适应底层模型的输入形状

❹ 预处理请求中的每个图像

❺ 将预处理图像传递给底层模型的输入层

以下是对底层模型调用的示例实现,此处进行了描述:

  • 参数model是编译后的 TF.Keras 模型,其中call()方法是模型的前向馈预测方法。

  • get_concrete_function()方法构建了一个围绕底层模型的包装器,用于执行。包装器提供了从服务函数中的静态图切换到底层模型中的动态图的接口。

m_call = tf.function(model.call).get_concrete_function([tf.TensorSpec(shape=
            [None, 192, 192, 3], dtype=tf.float32, name="numpy_inputs")])

14.4.2 批量预测

批量 预测与部署模型进行按需预测不同。在按需预测中,你创建一个服务二进制文件和服务平台来部署模型;我们称之为端点。然后你将模型部署到该端点。最后,用户向端点发出按需(实时)预测请求。

相比之下,批量预测从创建一个用于预测的批量作业开始。作业服务随后为批量预测请求分配资源,并将结果返回给调用者。然后作业服务释放请求的资源。

批量预测通常用于不需要立即响应的情况,因此响应可以延迟;需要处理的预测数量巨大(数百万);并且只需要为处理批量分配计算资源。

例如,考虑一家金融机构,在银行日结束时有一百万笔交易,并且它有一个模型可以预测未来 10 天的存款和现金余额。由于预测是时间序列的,逐笔发送交易到实时预测服务是没有意义且效率低下的。相反,在银行日结束时,交易数据被提取(例如,从 SQL 数据库中)并作为一个单独的批量作业提交。然后为服务二进制文件和平台分配计算资源,处理作业,并释放服务二进制文件和平台(释放资源)。

图 14.20 展示了一个批量预测服务。此过程有五个主要步骤:

  1. 累积的数据被提取并打包成批量请求,例如从 SQL 数据库中提取。

  2. 批量请求已排队,队列管理器确定计算资源需求和优先级。

  3. 当批量作业准备就绪时,它将从队列中出队到调度器。

  4. 调度器为服务二进制文件和平台分配资源,然后提交批量作业。

  5. 批量作业完成后,结果被存储,调度器释放分配的计算资源。

图 14.20 一个队列和调度器根据每个作业协调服务二进制文件和平台的分配和释放。

接下来,我们将介绍如何在 TFX 中部署模型以进行按需和批量预测。

14.4.3 TFX 部署管道组件

在 TFX 中,部署管道由组件 PusherBulk Inference 以及一个服务二进制文件和平台组成。服务平台可以是基于云的、本地化的、边缘设备或基于浏览器的。对于基于云的模型,推荐的服务平台是 TensorFlow Serving。

图 14.21 展示了 TFX 部署管道的组件。Pusher 组件用于部署模型以进行按需预测或批量预测。Bulk Inference 组件处理批量预测。

图 14.21 一个 TFX 部署管道可以部署模型以进行按需服务和/或批量预测。

Pusher

以下是一个示例实现,展示了实例化 Pusher 组件以部署模型到服务二进制文件所需的最小要求:

  • model—要部署到服务二进制文件和平台的训练模型(在这种情况下,来自 Trainer 组件的当前训练模型实例)

  • push_destination—在服务二进制文件中安装模型的目录位置

from tfx.components import Pusher
from tfx.proto import pusher_pb2

pusher = Pusher(model=trainer.outputs['model'],                  ❶
                push_destination=pusher_pb2.PushDestination(     ❷

filesystem=pusher_pb2.PushDestination.FileSystem(

                base_directory=serving_model_dir                 ❸
                                                           )
               )

❶ 要部署的训练模型

❷ 部署模型的二进制文件目标

❸ 在服务二进制文件中的目录位置安装模型

在生产环境中,我们通常将其纳入部署管道中,只有当它是新的受祝福模型时才部署模型。以下是一个示例实现,其中只有当模型是新的受祝福模型时才部署模型:

  • model—来自Trainer组件的当前训练的模型

  • model_blessing—来自Evaluator组件的当前受祝福的模型

在这个例子中,只有在模型和受祝福的模型是相同的模型实例时,才会部署模型:

pusher = Pusher(model=trainer.outputs['model'],                  ❶
                model_blessing=evaluator.outputs['blessing'],    ❷
                push_destination=pusher_pb2.PushDestination( 

                filesystem=pusher_pb2.PushDestination.FileSystem(

                base_directory=serving_model_dir  
                                                            )
                                                           )
               )

❶ 当前训练的模型

❷ 当前受祝福的模型实例

接下来,我们将介绍在 TFX 中进行批量预测。

批量推理器

BulkInferrer组件执行批量预测服务,TFX 文档将其称为批量推理。以下代码是使用当前训练的模型进行批量预测的最小参数的示例实现:

  • examples—用于进行预测的示例。在这种情况下,它们来自ExampleGen组件的一个实例。

  • model—用于批量预测的模型(在这种情况下,当前训练的模型)。

  • inference_result—存储批量预测结果的位置。

from tfx.components import BulkInferrer

bulk_inferrer = BulkInferrer(examples=examples_gen.outputs['examples'],  ❶
                             model=trainer.outputs['model'],             ❷
                             inference_result=location                   ❸
                            )

❶ 批量预测的示例

❷ 用于批量预测的模型

❸ 存储预测结果的位置

以下是一个示例实现,仅当当前训练的模型是受祝福的模型时,使用当前训练的模型进行批量预测的最小参数。在这个例子中,只有在当前训练的模型和受祝福的模型实例相同时,才会执行批量预测。

from tfx.components import BulkInferrer

bulk_inferrer = BulkInferrer(examples=examples_gen.outputs['examples'],   
                             model=trainer.outputs['model'],  
                             model_blessing=evaluator.outputs['blessing'],  ❶
                             inference_result=location
                            )

❶ 当前受祝福的模型实例

14.4.4 A/B 测试

我们现在已经完成了对新训练的模型的两次测试,以查看它是否准备好成为下一个生产版本,即受祝福的模型。我们使用预定的评估数据在两个模型之间进行了模型指标的直接比较。我们还测试了候选模型在沙盒模拟的生产环境中。

尽管没有实际部署候选模型,但我们仍然不确定它是否是更好的模型。我们需要在实时生产环境中评估候选模型的性能。为此,我们向候选模型提供实时预测的子集,并测量候选模型和当前生产模型之间每个预测的结果。然后我们分析测量的数据或指标,以查看候选模型是否实际上是一个更好的模型。

这是在机器学习生产环境中的 A/B 测试。图 14.22 展示了这个过程。如图所示,两个模型都部署到了同一个实时生产环境中,预测流量在当前受祝福的(A)和候选受祝福的(B)之间分配。每个模型都看到基于百分比的随机选择的预测选择。

图片

图 14.22 在实时生产环境中对当前模型和候选模型进行 A/B 测试,其中候选模型获得一小部分实时预测

如果候选模型不如当前模型好,我们不希望在生产环境中得到一个糟糕的结果。因此,我们通常将流量百分比保持尽可能小,但足以测量两者之间的差异。一个典型的分配是候选模型 5%,生产模型 95%。

接下来的问题是,你要测量什么?你已经测量并比较了模型的客观指标,所以重复这些指标的价值不大,尤其是如果评估切片包括服务倾斜和数据漂移。你在这里想要测量的是业务目标的结果有多好。

例如,假设你的模型是一个部署到制造装配线上的图像分类模型,用于寻找缺陷。对于每个模型,你有两个桶:一个用于好零件,一个用于缺陷。在指定的时间段后,你的 QA 人员会手动检查来自生产模型和候选模型桶中的采样分布,然后比较两者。特别是,他们想要回答两个问题:

  • 候选模型检测到的缺陷数量是否与生产模型检测到的数量相等或更多?这些都是真阳性。

  • 候选模型检测到的非缺陷数量是否与缺陷数量相等或更少?这些都是假阳性。

正如这个例子所示,你必须确定业务目标:增加真阳性,减少假阳性,或者两者都要。

让我们再考虑一个例子。假设我们正在为一个电子商务网站上的语言模型工作,这个模型执行的任务包括图像标题生成、为交易问题提供聊天机器人,以及为聊天机器人对用户的响应进行语言翻译。在这种情况下,我们可能测量的指标可能是完成交易的总数或每笔交易的平均收入。换句话说,候选模型是否能触及更广泛的受众并/或创造更多的收入?

14.4.5 负载均衡

一旦模型部署到按需生产环境,预测请求的量随时间可能会大幅变化。理想情况下,模型应在延迟约束内满足最高峰的需求,同时也要最小化计算成本。

如果模型是单体模型,即作为一个单一模型实例部署,我们可以简单地通过增加计算资源或 GPU 数量来满足第一个要求。但这样做会损害第二个要求,即最小化计算成本。

就像其他当代云应用一样,当请求流量有显著变化时,我们使用自动扩展和负载均衡来进行请求的分布式处理。让我们看看自动扩展在机器学习中的应用是如何工作的。

术语自动扩展负载均衡可能看起来可以互换使用。但实际上,它们是两个独立的过程,协同工作。在自动扩展中,过程是响应整体当前预测请求负载进行提供(添加)和取消提供(删除)计算实例。在负载均衡中,过程是确定如何将当前预测请求负载分配到现有的已提供计算实例,并确定何时指令自动扩展过程提供或取消提供计算实例。

图 14.23 描述了一个适用于机器学习生产环境的负载均衡场景。本质上,负载均衡计算节点接收预测请求,然后将它们重定向到服务二进制,该服务二进制接收预测响应并将它们返回给客户端调用者。

图 14.23 一个负载均衡器将请求分配给由自动扩展节点动态提供和取消提供的多个服务二进制。

让我们更深入地看看图 14.23。负载均衡器监控流量负载,例如单位时间内的预测请求频率、网络流量的进出量以及返回预测请求响应的延迟时间。

这监控数据实时输入到自动扩展节点。自动扩展节点由 MLOps 人员配置以满足性能标准。如果性能低于预设的阈值和时长,自动扩展器将动态地提供一个或多个新的服务二进制副本实例。同样,如果性能高于预设的阈值和时长,自动扩展器将动态地取消提供一个或多个现有的服务二进制副本实例。

随着自动扩展器添加服务二进制,它将服务二进制注册到负载均衡器。同样,随着它移除服务二进制,它将服务二进制注销到负载均衡器。这告诉负载均衡器哪些服务二进制是活跃的,以便负载均衡器可以分配预测结果。

通常,负载均衡器配置了健康监控器来监控每个服务二进制的健康状态。如果确定服务二进制不健康,健康监控器将指令自动扩展节点取消提供该服务二进制并提供一个新的服务二进制作为替代。

14.4.6 持续评估

持续评估(CE)是软件开发过程中的持续集成(CI)和持续部署(CD)的机器学习生产扩展。这个扩展通常表示为 CI/CD/CE。持续评估意味着我们在模型部署到生产后,监控模型接收到的预测请求和响应,并对预测响应进行评估。这与使用现有的测试、服务偏差和数据漂移切片评估模型所做的工作类似。这样做是为了检测由于生产中预测请求随时间变化而导致的模型性能下降。

持续评估的典型过程如下:

  • 将预配置的百分比(例如,2%)的预测请求和响应保存以供手动评估。

  • 保存的预测请求和响应是随机选择的。

  • 在某个周期性基础上,保存的预测请求和响应会手动审查并评估与模型的目标指标。

  • 如果评估确定模型在目标指标上的表现低于模型部署前的评估,手动评估人员将识别出由于服务偏差、数据漂移和任何未预见到的情况导致的性能不佳的示例。这些都是异常情况。

  • 确定的示例会手动标记并添加到训练数据集中,其中一部分被保留为相应的评估切片。

  • 模型要么是增量重新训练,要么是全面重新训练。

图 14.24 描述了将部署的生产模型中的持续评估集成到模型开发过程中的 CI/CD/CE 方法。

图 14.24 一个生产部署的模型会持续评估以识别表现不佳的示例,然后这些示例将被添加到数据集中以重新训练模型。

14.5 生产管道设计演变

让我们以对机器学习从研究到全面生产的概念和必要性如何演变的简要讨论来结束这本书。你可能对模型融合的部分特别感兴趣,因为它是深度学习下一个前沿领域之一。

机器学习方法的演变是如何影响我们实际进行机器学习的方式的?深度学习模型的发展从实验室的实验到在全面的生产环境中部署和服务的演变。

14.5.1 机器学习作为管道

你可能之前见过这种情况。一个成功的机器学习工程师需要将机器学习解决方案分解为以下步骤:

  1. 确定问题的模型类型。

  2. 设计模型。

  3. 准备模型的数据。

  4. 训练模型。

  5. 部署模型。

机器学习工程师将这些步骤组织成一个两阶段端到端管道。第一个端到端管道包括前三个步骤,如图 14.25 所示为建模、数据工程和训练。一旦机器学习工程师在这个阶段取得成功,它将与部署步骤相结合,形成一个第二个端到端管道。通常,模型被部署到容器环境中,并通过基于 REST 或微服务接口访问。

图片

图 14.25 2017 年端到端机器学习管道的流行实践

那是 2017 年的流行做法。我将其称为发现阶段。组成部分是什么以及它们是如何相互配合的?

14.5.2 将机器学习作为 CI/CD 生产过程

在 2018 年,企业正在正式化 CI/CD 生产过程,我将其称为探索阶段。图 14.26 是我 2018 年末在谷歌演示中向商业决策者展示的幻灯片,捕捉了那时的我们所在的位置。这不仅仅是一个技术过程,还包括了计划和质量管理。数据工程变得更加明确,包括提取、分析、转换、管理和服务步骤。模型设计和训练包括特征工程,部署扩展到包括持续学习。

图片

图 14.26 到 2018 年,谷歌和其他大型企业已经开始正式化生产过程,包括计划和质量管理阶段以及技术过程。

14.5.3 生产中的模型合并

今天的生产模型没有单一的输出层。相反,它们有多个输出层,从基本特征提取(常见层)、表示空间、潜在空间(特征向量、编码)和概率分布空间(软标签和硬标签)。现在的模型是整个应用;没有后端。

这些模型学习最佳的接口和数据通信方式。2021 年的企业机器学习工程师现在正在指导模型合并中的搜索空间,其一个通用示例在图 14.27 中有所描述。

图片

图 14.27 模型合并——当模型成为整个应用时!

让我们分解这个通用示例。在左侧是合并的输入。输入通过一组常见的卷积层进行处理,形成所谓的共享模型底部。在这个描述中,共享模型底部的输出有四个学习到的输出表示:1) 高维潜在空间,2) 低维潜在空间,3) 预激活条件概率分布,和 4) 后激活独立概率分布。

这些学习到的输出表示被专门的下游学习任务重复使用,这些任务执行某些操作(例如,状态转换变化或转换)。图中的每个任务(1、2、3 和 4)都重复使用对任务目标最优化(大小、速度、准确性)的输出表示。这些个别任务可能随后产生多个学习到的输出表示,或者将来自多个任务的学习表示(密集嵌入)组合起来,以供进一步的下游任务使用,正如你在第一章的体育广播示例中看到的。

不仅服务管道能够实现这些类型的解决方案,而且管道内的组件可以进行版本控制和重新配置。这使得这些组件可重用,这是现代软件工程的基本原则之一。

摘要

  • 训练管道的基本组件包括模型喂养、模型评估和训练调度器。

  • 模型每个实例的目标指标被保存为元数据。当模型实例的目标指标优于当前受祝福的模型时,该模型实例被祝福。

  • 每个受祝福的模型都在模型存储库中进行跟踪和版本控制。

  • 当模型用于分布式训练时,批次大小会增加,以平滑不同批次之间并行馈送时的差异。

  • 在编排中,管理接口监督每个组件的执行,记住过去组件的执行情况,并维护历史记录。

  • 评估切片包括与训练数据相同分布的示例,以及在生产中看到的分布外示例。这包括服务偏差和数据漂移。

  • 部署管道的基本组件包括部署、服务、扩展和持续评估。

  • 在实时生产环境中使用 A/B 测试来确定候选模型是否优于当前生产模型,例如,如果发生意外情况,不要干扰生产。

  • 在实时生产环境中使用持续评估来识别服务偏差、数据漂移和异常,从而可以向数据集添加新的标记数据,并对模型进行进一步的重训练。

posted @ 2025-11-23 09:26  绝不原创的飞龙  阅读(2)  评论(0)    收藏  举报