Sklearn-TensorFlow-与-Keras-机器学习实用指南第三版-全-

Sklearn、TensorFlow 与 Keras 机器学习实用指南第三版(全)

原文:Hands-On Machine Learning with Scikit-Learn, Keras, and TensorFlow

译者:飞龙

协议:CC BY-NC-SA 4.0

前言

机器学习海啸

2006 年,Geoffrey Hinton 等人发表了一篇论文,展示了如何训练一个能够以最先进的精度(>98%)识别手写数字的深度神经网络。他们将这种技术称为“深度学习”。深度神经网络是我们大脑皮层的(非常)简化模型,由一系列人工神经元层组成。在当时,训练深度神经网络被普遍认为是不可能的,大多数研究人员在 1990 年代末放弃了这个想法。这篇论文重新激起了科学界的兴趣,不久之后,许多新论文证明了深度学习不仅是可能的,而且能够实现令人惊叹的成就,其他任何机器学习(ML)技术都无法匹敌(在巨大的计算能力和大量数据的帮助下)。这种热情很快扩展到许多其他机器学习领域。

十年后,机器学习已经征服了行业,如今它是许多高科技产品中许多神奇功能的核心,比如排名您的网络搜索结果,为您的智能手机提供语音识别,推荐视频,甚至可能驾驶您的汽车。

在您的项目中应用机器学习

因此,您对机器学习感到兴奋,并希望加入这个派对!

也许您想让您自制的机器人拥有自己的大脑?让它识别人脸?或学会四处走动?

或者您的公司有大量数据(用户日志、财务数据、生产数据、机器传感器数据、热线统计、人力资源报告等),很可能如果您知道在哪里寻找,您可以发现一些隐藏的宝藏。通过机器学习,您可以实现以下目标以及更多

  • 分析客户并找到每个群体的最佳营销策略。

  • 根据类似客户购买的产品,为每个客户推荐产品。

  • 检测哪些交易可能是欺诈性的。

  • 预测明年的收入。

无论出于何种原因,您已经决定学习机器学习并在您的项目中实施它。好主意!

目标和方法

本书假设您对机器学习几乎一无所知。其目标是为您提供实现能够从数据中学习的程序所需的概念、工具和直觉。

我们将涵盖大量技术,从最简单和最常用的(如线性回归)到一些经常赢得比赛的深度学习技术。为此,我们将使用生产就绪的 Python 框架:

  • Scikit-Learn 非常易于使用,同时高效实现了许多机器学习算法,因此它是学习机器学习的绝佳入门点。它由 David Cournapeau 于 2007 年创建,现在由法国国家计算机与自动化研究所(Inria)的一组研究人员领导。

  • TensorFlow 是一个更复杂的分布式数值计算库。它通过在数百个多 GPU(图形处理单元)服务器上分布计算,使得训练和运行非常大的神经网络变得高效。TensorFlow(TF)由 Google 创建,并支持许多其大规模机器学习应用。它于 2015 年 11 月开源,2.0 版本于 2019 年 9 月发布。

  • Keras 是一个高级深度学习 API,使训练和运行神经网络变得非常简单。Keras 与 TensorFlow 捆绑在一起,并依赖于 TensorFlow 进行所有密集计算。

本书倾向于实践方法,通过具体的工作示例和一点点理论来培养对机器学习的直观理解。

提示

虽然您可以不用拿起笔记本阅读本书,但我强烈建议您尝试一下代码示例。

代码示例

本书中的所有代码示例都是开源的,可以在https://github.com/ageron/handson-ml3上在线获取,作为 Jupyter 笔记本。这些是交互式文档,包含文本、图片和可执行的代码片段(在我们的案例中是 Python)。开始的最简单最快的方法是使用 Google Colab 运行这些笔记本:这是一个免费服务,允许您直接在线运行任何 Jupyter 笔记本,无需在您的机器上安装任何东西。您只需要一个网络浏览器和一个 Google 账号。

注意

在本书中,我假设您正在使用 Google Colab,但我也在其他在线平台上测试了这些笔记本,如 Kaggle 和 Binder,所以如果您愿意,也可以使用这些平台。或者,您可以安装所需的库和工具(或本书的 Docker 镜像),并在自己的机器上直接运行这些笔记本。请参阅https://homl.info/install上的说明。

本书旨在帮助您完成工作。如果您希望使用代码示例以外的其他内容,并且该使用超出了公平使用准则的范围(例如出售或分发 O'Reilly 图书的内容,或将本书的大量材料整合到产品文档中),请通过permissions@oreilly.com联系我们以获取许可。

我们感谢,但不要求署名。署名通常包括标题、作者、出版商和 ISBN。例如:“使用 Scikit-Learn、Keras 和 TensorFlow 进行实践机器学习 by Aurélien Géron. 版权所有 2023 Aurélien Géron, 978-1-098-12597-4.”

先决条件

本书假定您具有一些 Python 编程经验。如果您还不了解 Python,https://learnpython.org是一个很好的开始。Python.org上的官方教程也非常不错。

本书还假定您熟悉 Python 的主要科学库,特别是NumPyPandasMatplotlib。如果您从未使用过这些库,不用担心;它们很容易学习,我为每个库创建了一个教程。您可以在https://homl.info/tutorials上在线访问它们。

此外,如果您想完全理解机器学习算法的工作原理(不仅仅是如何使用它们),那么您应该至少对一些数学概念有基本的了解,尤其是线性代数。具体来说,您应该知道什么是向量和矩阵,以及如何执行一些简单的操作,比如添加向量,或转置和相乘矩阵。如果您需要快速了解线性代数(这真的不是什么难事!),我在https://homl.info/tutorials提供了一个教程。您还会找到一个关于微分计算的教程,这可能有助于理解神经网络是如何训练的,但并非完全必要掌握重要概念。本书偶尔还使用其他数学概念,如指数和对数,一些概率论,以及一些基本的统计概念,但没有太高级的内容。如果您需要帮助,请查看https://khanacademy.org,该网站提供许多优秀且免费的数学课程。

路线图

本书分为两部分。第一部分,“机器学习基础”,涵盖以下主题:

  • 机器学习是什么,它试图解决什么问题,以及其系统的主要类别和基本概念

  • 典型机器学习项目中的步骤

  • 通过将模型拟合到数据来学习

  • 优化成本函数

  • 处理,清洗和准备数据

  • 选择和工程特征

  • 使用交叉验证选择模型和调整超参数

  • 机器学习的挑战,特别是欠拟合和过拟合(偏差/方差权衡)

  • 最常见的学习算法:线性和多项式回归,逻辑回归,k-最近邻,支持向量机,决策树,随机森林和集成方法

  • 减少训练数据的维度以对抗“维度灾难”

  • 其他无监督学习技术,包括聚类,密度估计和异常检测

第二部分,“神经网络和深度学习”,涵盖以下主题:

  • 神经网络是什么以及它们适用于什么

  • 使用 TensorFlow 和 Keras 构建和训练神经网络

  • 最重要的神经网络架构:用于表格数据的前馈神经网络,用于计算机视觉的卷积网络,用于序列处理的循环网络和长短期记忆(LSTM)网络,用于自然语言处理的编码器-解码器和Transformer(以及更多!),自编码器,生成对抗网络(GANs)和扩散模型用于生成学习

  • 训练深度神经网络的技术

  • 如何构建一个代理(例如游戏中的机器人),通过试错学习良好策略,使用强化学习

  • 高效加载和预处理大量数据

  • 规模化训练和部署 TensorFlow 模型

第一部分主要基于 Scikit-Learn,而第二部分使用 TensorFlow 和 Keras。

注意

不要过于仓促地跳入深水:尽管深度学习无疑是机器学习中最令人兴奋的领域之一,但您应该先掌握基础知识。此外,大多数问题可以使用更简单的技术(如随机森林和集成方法)很好地解决(在第一部分讨论)。深度学习最适合复杂问题,如图像识别,语音识别或自然语言处理,它需要大量数据,计算能力和耐心(除非您可以利用预训练的神经网络,如您将看到的那样)。

第一版和第二版之间的变化

如果您已经阅读了第一版,以下是第一版和第二版之间的主要变化:

  • 所有代码都已从 TensorFlow 1.x 迁移到 TensorFlow 2.x,并且我用更简单的 Keras 代码替换了大部分低级 TensorFlow 代码(图形,会话,特征列等)。

  • 第二版引入了用于加载和预处理大型数据集的 Data API,用于规模训练和部署 TF 模型的分布策略 API,用于将模型投入生产的 TF Serving 和 Google Cloud AI Platform,以及(简要介绍)TF Transform,TFLite,TF Addons/Seq2Seq,TensorFlow.js 和 TF Agents。

  • 它还引入了许多其他 ML 主题,包括一个新的无监督学习章节,用于目标检测和语义分割的计算机视觉技术,使用卷积神经网络(CNN)处理序列,使用循环神经网络(RNN)、CNN 和Transformer进行自然语言处理(NLP),GANs 等。

有关更多详细信息,请参阅https://homl.info/changes2

第二版和第三版之间的变化

如果您阅读了第二版,以下是第二版和第三版之间的主要变化:

  • 所有代码都已更新到最新的库版本。特别是,这第三版引入了许多新的 Scikit-Learn 补充(例如,特征名称跟踪,基于直方图的梯度提升,标签传播等)。它还引入了用于超参数调整的 Keras Tuner 库,用于自然语言处理的 Hugging Face 的 Transformers 库,以及 Keras 的新预处理和数据增强层。

  • 添加了几个视觉模型(ResNeXt、DenseNet、MobileNet、CSPNet 和 EfficientNet),以及选择正确模型的指南。

  • 第十五章现在分析芝加哥公共汽车和轨道乘客数据,而不是生成的时间序列,并介绍了 ARMA 模型及其变体。

  • 第十六章关于自然语言处理现在构建了一个英语到西班牙语的翻译模型,首先使用编码器-解码器 RNN,然后使用Transformer模型。该章还涵盖了语言模型,如 Switch Transformers、DistilBERT、T5 和 PaLM(带有思维链提示)。此外,它介绍了视觉Transformer(ViTs)并概述了一些基于Transformer的视觉模型,如数据高效图像Transformer(DeiTs)、Perceiver 和 DINO,以及一些大型多模态模型的简要概述,包括 CLIP、DALL·E、Flamingo 和 GATO。

  • 第十七章关于生成学习现在引入了扩散模型,并展示了如何从头开始实现去噪扩散概率模型(DDPM)。

  • 第十九章从 Google Cloud AI 平台迁移到 Google Vertex AI,并使用分布式 Keras Tuner 进行大规模超参数搜索。该章现在包括您可以在线尝试的 TensorFlow.js 代码。它还介绍了其他分布式训练技术,包括 PipeDream 和 Pathways。

  • 为了容纳所有新内容,一些部分被移至在线,包括安装说明、核主成分分析(PCA)、贝叶斯高斯混合的数学细节、TF Agents,以及以前的附录 A(练习解决方案)、C(支持向量机数学)和 E(额外的神经网络架构)。

更多详情请查看https://homl.info/changes3

其他资源

有许多优秀的资源可供学习机器学习。例如,Andrew Ng 在 Coursera 上的 ML 课程令人惊叹,尽管需要投入大量时间。

还有许多关于机器学习的有趣网站,包括 Scikit-Learn 的出色用户指南。您可能还会喜欢Dataquest,它提供非常好的交互式教程,以及像Quora上列出的 ML 博客。

还有许多关于机器学习的入门书籍。特别是:

  • Joel Grus 的《从零开始的数据科学》,第二版(O'Reilly),介绍了机器学习的基础知识,并使用纯 Python 实现了一些主要算法(从头开始,正如名称所示)。

  • Stephen Marsland 的《机器学习:算法视角》,第二版(Chapman&Hall),是机器学习的一个很好的入门,深入涵盖了各种主题,使用 Python 中的代码示例(也是从头开始,但使用 NumPy)。

  • Sebastian Raschka 的《Python 机器学习》,第三版(Packt Publishing),也是机器学习的一个很好的入门,利用了 Python 开源库(Pylearn 2 和 Theano)。

  • François Chollet 的《Python 深度学习》,第二版(Manning),是一本非常实用的书,以清晰简洁的方式涵盖了广泛的主题,正如你可能从优秀的 Keras 库的作者所期望的那样。它更偏向于代码示例而不是数学理论。

  • Andriy Burkov 的《百页机器学习书》(自出版)非常简短,但涵盖了令人印象深刻的一系列主题,以平易近人的术语介绍,而不回避数学方程式。

  • Yaser S. Abu-Mostafa,Malik Magdon-Ismail 和 Hsuan-Tien Lin 的《从数据中学习》(AMLBook)是一个相当理论化的 ML 方法,提供了深刻的见解,特别是关于偏差/方差权衡(参见第四章)。

  • Stuart Russell 和 Peter Norvig 的《人工智能:现代方法》,第 4 版(Pearson),是一本涵盖大量主题的伟大(而庞大)的书籍,包括机器学习。它有助于将 ML 置于透视中。

  • Jeremy Howard 和 Sylvain Gugger 的《使用 fastai 和 PyTorch 进行编码的深度学习》(O'Reilly)提供了一个清晰实用的深度学习介绍,使用了 fastai 和 PyTorch 库。

最后,加入 ML 竞赛网站,如Kaggle.com,将使您能够在实际问题上练习技能,并获得来自一些最优秀的 ML 专业人士的帮助和见解。

本书使用的约定

本书使用以下排版约定:

斜体

指示新术语、URL、电子邮件地址、文件名和文件扩展名。

等宽

用于程序清单,以及在段落中引用程序元素,如变量或函数名、数据库、数据类型、环境变量、语句和关键字。

等宽粗体

显示用户应按照字面意义输入的命令或其他文本。

等宽斜体

显示应替换为用户提供的值或由上下文确定的值的文本。

标点

为避免混淆,本书中引号外的标点符号。对纯粹主义者表示歉意。

提示

此元素表示提示或建议。

注意

此元素表示一般说明。

警告

此元素表示警告或注意事项。

致谢

在我最疯狂的梦想中,我从未想象过这本书的第一版和第二版会获得如此庞大的读者群。我收到了许多读者的留言,许多人提出问题,一些人友善地指出勘误,大多数人给我寄来鼓励的话语。我无法表达我对所有这些读者的巨大支持的感激之情。非常感谢你们所有人!如果您在代码示例中发现错误(或者只是想提问),请毫不犹豫地在 GitHub 上提交问题,或者如果您在文本中发现错误,请提交勘误。一些读者还分享了这本书如何帮助他们找到第一份工作,或者如何帮助他们解决了他们正在处理的具体问题。我发现这样的反馈极具激励性。如果您觉得这本书有帮助,我会很高兴如果您能与我分享您的故事,无论是私下(例如通过LinkedIn)还是公开(例如在 Twitter 上@ aureliengeron 发推文或撰写亚马逊评论)。

同样非常感谢所有那些慷慨提供时间和专业知识来审阅这第三版,纠正错误并提出无数建议的出色人士。多亏了他们,这版书变得更好了:Olzhas Akpambetov、George Bonner、François Chollet、Siddha Ganju、Sam Goodman、Matt Harrison、Sasha Sobran、Lewis Tunstall、Leandro von Werra 和我亲爱的弟弟 Sylvain。你们都太棒了!

我也非常感激许多人在我前进的道路上支持我,回答我的问题,提出改进建议,并在 GitHub 上贡献代码:特别是 Yannick Assogba、Ian Beauregard、Ulf Bissbort、Rick Chao、Peretz Cohen、Kyle Gallatin、Hannes Hapke、Victor Khaustov、Soonson Kwon、Eric Lebigot、Jason Mayes、Laurence Moroney、Sara Robinson、Joaquín Ruales 和 Yuefeng Zhou。

没有 O'Reilly 出色的员工,特别是 Nicole Taché,这本书就不会存在,她给了我深刻的反馈,总是充满活力、鼓励和帮助:我无法想象有比她更好的编辑。非常感谢 Michele Cronin,她在最后几章中给予我鼓励,并帮助我完成了最后的工作。感谢整个制作团队,特别是 Elizabeth Kelly 和 Kristen Brown。同样感谢 Kim Cofer 进行了彻底的编辑工作,以及 Johnny O'Toole,他管理了与亚马逊的关系,并回答了我许多问题。感谢 Kate Dullea 大大改进了我的插图。感谢 Marie Beaugureau、Ben Lorica、Mike Loukides 和 Laurel Ruma 相信这个项目,并帮助我定义其范围。感谢 Matt Hacker 和整个 Atlas 团队回答了我关于格式、AsciiDoc、MathML 和 LaTeX 的所有技术问题,感谢 Nick Adams、Rebecca Demarest、Rachel Head、Judith McConville、Helen Monroe、Karen Montgomery、Rachel Roumeliotis 以及 O'Reilly 的所有其他贡献者。

我永远不会忘记所有在这本书的第一版和第二版中帮助过我的美好人们:朋友、同事、专家,包括 TensorFlow 团队的许多成员。名单很长:Olzhas Akpambetov,Karmel Allison,Martin Andrews,David Andrzejewski,Paige Bailey,Lukas Biewald,Eugene Brevdo,William Chargin,François Chollet,Clément Courbet,Robert Crowe,Mark Daoust,Daniel “Wolff” Dobson,Julien Dubois,Mathias Kende,Daniel Kitachewsky,Nick Felt,Bruce Fontaine,Justin Francis,Goldie Gadde,Irene Giannoumis,Ingrid von Glehn,Vincent Guilbeau,Sandeep Gupta,Priya Gupta,Kevin Haas,Eddy Hung,Konstantinos Katsiapis,Viacheslav Kovalevskyi,Jon Krohn,Allen Lavoie,Karim Matrah,Grégoire Mesnil,Clemens Mewald,Dan Moldovan,Dominic Monn,Sean Morgan,Tom O’Malley,James Pack,Alexander Pak,Haesun Park,Alexandre Passos,Ankur Patel,Josh Patterson,André Susano Pinto,Anthony Platanios,Anosh Raj,Oscar Ramirez,Anna Revinskaya,Saurabh Saxena,Salim Sémaoune,Ryan Sepassi,Vitor Sessak,Jiri Simsa,Iain Smears,Xiaodan Song,Christina Sorokin,Michel Tessier,Wiktor Tomczak,Dustin Tran,Todd Wang,Pete Warden,Rich Washington,Martin Wicke,Edd Wilder-James,Sam Witteveen,Jason Zaman,Yuefeng Zhou,以及我的兄弟 Sylvain。

最后,我要无限感谢我亲爱的妻子 Emmanuelle 和我们三个美妙的孩子 Alexandre、Rémi 和 Gabrielle,他们鼓励我努力完成这本书。他们无尽的好奇心是无价的:向妻子和孩子们解释这本书中一些最困难的概念帮助我澄清了思路,直接改进了许多部分。此外,他们还不断给我送来饼干和咖啡,还能要求什么呢?

¹ Geoffrey E. Hinton 等人,“深度信念网络的快速学习算法”,《神经计算》18 (2006): 1527–1554。

² 尽管 Yann LeCun 的深度卷积神经网络自上世纪 90 年代以来在图像识别方面表现良好,但它们并不是通用的。

第一部分:机器学习的基础知识

第一章:机器学习的概览

不久前,如果您拿起手机问路回家,它会无视您,人们会质疑您的理智。但是机器学习不再是科幻:数十亿人每天都在使用它。事实上,它实际上已经存在几十年,用于一些专业应用,比如光学字符识别(OCR)。第一个真正成为主流的机器学习应用是在上世纪 90 年代席卷全球的:垃圾邮件过滤器。它并不是一个自我意识的机器人,但从技术上讲它确实算是机器学习:它学习得如此出色,以至于您很少需要将电子邮件标记为垃圾邮件。它之后又出现了数百个机器学习应用,现在悄悄地支持着您经常使用的数百种产品和功能:语音提示、自动翻译、图像搜索、产品推荐等等。

机器学习从何开始,到何结束?机器学习某事究竟意味着什么?如果我下载了所有维基百科文章的副本,我的计算机真的学到了什么吗?它突然变聪明了吗?在本章中,我将首先澄清机器学习是什么,以及为什么您可能想要使用它。

然后,在我们开始探索机器学习大陆之前,我们将看一看地图,了解主要区域和最显著的地标:监督学习与无监督学习及其变体,在线学习与批量学习,基于实例与基于模型的学习。然后我们将看一看典型机器学习项目的工作流程,讨论您可能面临的主要挑战,并介绍如何评估和微调机器学习系统。

本章介绍了许多数据科学家应该牢记的基本概念(和行话)。这将是一个高层次的概述(这是唯一一个没有太多代码的章节),都相当简单,但我的目标是确保在继续阅读本书的其余部分之前,一切对您都是清晰的。所以泡杯咖啡,让我们开始吧!

提示

如果您已经熟悉机器学习基础知识,您可能想直接跳到第二章。如果您不确定,尝试在继续之前回答本章末尾列出的所有问题。

什么是机器学习?

机器学习是编程计算机以便它们可以从数据中学习的科学(和艺术)。

以下是一个稍微更一般的定义:

[机器学习是]一门研究领域,赋予计算机学习的能力,而无需明确编程。

亚瑟·塞缪尔,1959

还有一个更加工程导向的定义:

如果一个计算机程序在某个任务T上通过经验E,根据性能度量P的表现随着经验E的增加而改善,那么就说它从经验E中学习。

汤姆·米切尔,1997

您的垃圾邮件过滤器是一个机器学习程序,通过用户标记的垃圾邮件示例和常规邮件示例(非垃圾邮件,也称为“正常邮件”),可以学习如何标记垃圾邮件。系统用于学习的示例称为训练集。每个训练示例称为训练实例(或样本)。学习和预测的机器学习系统的部分称为模型。神经网络和随机森林是模型的示例。

在这种情况下,任务T是为新邮件标记垃圾邮件,经验E训练数据,性能度量P需要定义;例如,您可以使用正确分类的邮件比率。这种特定的性能度量称为准确度,在分类任务中经常使用。

如果您只是下载了所有维基百科文章的副本,您的计算机拥有了更多数据,但它并不会突然在任何任务上变得更好。这不是机器学习。

为什么使用机器学习?

考虑如何使用传统编程技术编写垃圾邮件过滤器(图 1-1):

  1. 首先,您会检查垃圾邮件通常是什么样子的。您可能会注意到一些单词或短语(如“4U”、“信用卡”、“免费”和“惊人”)在主题行中经常出现。也许您还会注意到发件人姓名、电子邮件正文和其他部分中的一些其他模式。

  2. 你会为你注意到的每个模式编写一个检测算法,如果检测到这些模式中的一些,你的程序将会标记电子邮件为垃圾邮件。

  3. 您会测试您的程序,并重复步骤 1 和 2,直到它足够好以启动。

mls3 0101

图 1-1. 传统方法

由于问题很难,您的程序很可能会变成一长串复杂规则——相当难以维护。

相比之下,基于机器学习技术的垃圾邮件过滤器会自动学习哪些单词和短语是垃圾邮件的良好预测器,通过检测垃圾邮件示例中单词的异常频繁模式与正常邮件示例进行比较(图 1-2)。该程序更短,更易于维护,而且很可能更准确。

mls3 0102

图 1-2. 机器学习方法

如果垃圾邮件发送者注意到他们所有包含“4U”的电子邮件都被阻止了呢?他们可能会开始写“For U”代替。使用传统编程技术的垃圾邮件过滤器需要更新以标记“For U”电子邮件。如果垃圾邮件发送者不断绕过您的垃圾邮件过滤器,您将需要永远编写新规则。

相比之下,基于机器学习技术的垃圾邮件过滤器会自动注意到“For U”在用户标记的垃圾邮件中变得异常频繁,并开始在没有您干预的情况下标记它们(图 1-3)。

mls3 0103

图 1-3. 自动适应变化

机器学习表现出色的另一个领域是对于那些对传统方法来说要么太复杂,要么没有已知算法的问题。例如,考虑语音识别。假设你想从简单开始,编写一个能够区分“one”和“two”这两个词的程序。你可能会注意到,“two”这个词以高音(“T”)开头,因此你可以硬编码一个算法来测量高音强度,并使用它来区分“one”和“two”——但显然,这种技术无法扩展到成千上万个单词,由数百万个非常不同的人在嘈杂环境中以及数十种语言中说出。最好的解决方案(至少是今天)是编写一个算法,通过给定每个单词的许多示例录音来自学习。

最后,机器学习可以帮助人类学习(图 1-4)。机器学习模型可以被检查以查看它们学到了什么(尽管对于某些模型来说可能有些棘手)。例如,一旦一个垃圾邮件过滤器已经在足够多的垃圾邮件上训练过,就可以轻松地检查它,以显示它认为是最佳垃圾邮件预测器的单词和单词组合的列表。有时这会揭示出意想不到的相关性或新趋势,从而更好地理解问题。挖掘大量数据以发现隐藏模式被称为数据挖掘,而机器学习在这方面表现出色。

mls3 0104

图 1-4. 机器学习可以帮助人类学习

总之,机器学习非常适用于:

  • 需要大量微调或长列表规则的现有解决方案的问题(机器学习模型通常可以简化代码并比传统方法表现更好)

  • 对于使用传统方法得不到好解决方案的复杂问题(最好的机器学习技术或许可以找到解决方案)

  • 波动的环境(机器学习系统可以轻松地在新数据上重新训练,始终保持最新状态)

  • 获取关于复杂问题和大量数据的见解

应用示例

让我们看一些机器学习任务的具体示例,以及可以解决它们的技术:

分析生产线上产品的图像以自动分类它们

这是图像分类,通常使用卷积神经网络(CNN;参见第十四章)或有时使用Transformer(参见第十六章)进行处理。

检测脑部扫描中的肿瘤

这是语义图像分割,其中图像中的每个像素都被分类(因为我们想要确定肿瘤的确切位置和形状),通常使用 CNN 或Transformer。

自动分类新闻文章

这是自然语言处理(NLP),更具体地说是文本分类,可以使用循环神经网络(RNN)和 CNN 来解决,但Transformer效果更好(参见第十六章)。

自动标记讨论论坛上的攻击性评论

这也是文本分类,使用相同的 NLP 工具。

自动总结长文档

这是一种称为文本摘要的 NLP 分支,再次使用相同的工具。

创建一个聊天机器人或个人助手

这涉及许多 NLP 组件,包括自然语言理解(NLU)和问答模块。

根据许多绩效指标预测公司明年的收入

这是一个回归任务(即预测值),可以使用任何回归模型来解决,例如线性回归或多项式回归模型(参见第四章)、回归支持向量机(参见第五章)、回归随机森林(参见第七章)或人工神经网络(参见第十章)。如果您想考虑过去绩效指标的序列,可能需要使用 RNN、CNN 或Transformer(参见第十五章和第十六章)。

使您的应用程序对语音命令做出反应

这是语音识别,需要处理音频样本:由于它们是长而复杂的序列,通常使用 RNN、CNN 或Transformer进行处理(参见第十五章和第十六章)。

检测信用卡欺诈

这是异常检测,可以使用隔离森林、高斯混合模型或自编码器来解决(参见第九章)。

根据客户的购买情况对客户进行分段,以便为每个细分设计不同的营销策略

这是聚类,可以使用k-means、DBSCAN 等方法来实现(参见第九章)。

在清晰而富有洞察力的图表中表示复杂的高维数据集

这是数据可视化,通常涉及降维技术(参见第八章)。

根据过去的购买记录推荐客户可能感兴趣的产品

这是一个推荐系统。一种方法是将过去的购买记录(以及有关客户的其他信息)输入到人工神经网络中(参见第十章),并让其输出最可能的下一个购买。这种神经网络通常会在所有客户的过去购买序列上进行训练。

为游戏构建一个智能机器人

这通常是通过强化学习(RL)来解决的(请参阅第十八章),这是机器学习的一个分支,训练代理人(如机器人)选择能够随着时间最大化奖励的动作(例如,每次玩家失去一些生命值时机器人都会获得奖励),在给定环境(例如游戏)中。击败围棋世界冠军的著名 AlphaGo 程序就是使用 RL 构建的。

这个列表可以继续下去,但希望它能让您感受到机器学习可以处理的任务的广泛和复杂性,以及您将为每个任务使用的技术类型。

机器学习系统的类型

有许多不同类型的机器学习系统,将它们根据以下标准进行广泛分类是有用的:

  • 它们在训练过程中如何受监督(监督、无监督、半监督、自监督等)

  • 无论它们是否可以在飞行中逐步学习(在线与批量学习)

  • 它们是通过简单地将新数据点与已知数据点进行比较,还是通过检测训练数据中的模式并构建预测模型,就像科学家所做的那样(基于实例与基于模型的学习)

这些标准并不是互斥的;您可以以任何您喜欢的方式组合它们。例如,一款最先进的垃圾邮件过滤器可能会使用深度神经网络模型进行在线学习,该模型是使用人类提供的垃圾邮件和正常邮件示例进行训练的;这使其成为一个在线、基于模型的监督学习系统。

让我们更仔细地看看这些标准中的每一个。

训练监督

ML 系统可以根据训练过程中获得的监督量和类型进行分类。有许多类别,但我们将讨论主要的类别:监督学习、无监督学习、自监督学习、半监督学习和强化学习。

监督学习

监督学习中,您向算法提供的训练集包括所需的解决方案,称为标签(图 1-5)。

mls3 0105

图 1-5. 用于垃圾邮件分类的带标签训练集(监督学习的示例)

典型的监督学习任务是分类。垃圾邮件过滤器就是一个很好的例子:它通过许多示例电子邮件及其类别(垃圾邮件或正常邮件)进行训练,并且必须学会如何对新邮件进行分类。

另一个典型的任务是预测一个目标数值,例如一辆汽车的价格,给定一组特征(里程、年龄、品牌等)。这种类型的任务被称为回归(图 1-6)。为了训练系统,您需要提供许多汽车的示例,包括它们的特征和目标(即它们的价格)。

请注意,一些回归模型也可以用于分类,反之亦然。例如,逻辑回归通常用于分类,因为它可以输出与属于给定类别的概率相对应的值(例如,属于垃圾邮件的概率为 20%)。

mls3 0106

图 1-6. 一个回归问题:根据输入特征预测一个值(通常有多个输入特征,有时有多个输出值)
注意

在监督学习中,目标标签通常被视为同义词,但目标在回归任务中更常见,标签在分类任务中更常见。此外,特征有时被称为预测变量属性。这些术语可能指个别样本(例如,“这辆车的里程特征等于 15,000”)或所有样本(例如,“里程特征与价格强相关”)。

无监督学习

无监督学习中,正如您可能猜到的那样,训练数据是未标记的(图 1-7)。系统试图在没有老师的情况下学习。

例如,假设您有关于博客访问者的大量数据。您可能希望运行一个聚类算法来尝试检测相似访问者的群组。在任何时候,您都不告诉算法访问者属于哪个群组:它会在没有您帮助的情况下找到这些连接。例如,它可能注意到您的 40%访问者是喜欢漫画书并且通常在放学后阅读您的博客的青少年,而 20%是喜欢科幻并且在周末访问的成年人。如果使用层次聚类算法,它还可以将每个群组细分为更小的群组。这可能有助于您为每个群体定位您的帖子。

mls3 0107

图 1-7. 无监督学习的未标记训练集

mls3 0108

图 1-8. 聚类

可视化算法也是无监督学习的很好的例子:你向它们提供大量复杂且未标记的数据,它们会输出数据的二维或三维表示,可以轻松绘制出来。这些算法试图尽可能保留数据的结构(例如,尝试在可视化中保持输入空间中的不同簇不重叠),以便您可以了解数据的组织方式,也许还可以识别出意想不到的模式。

一个相关的任务是降维,其目标是简化数据而不丢失太多信息。一种方法是将几个相关特征合并为一个。例如,一辆汽车的里程可能与其年龄强相关,因此降维算法将它们合并为一个代表汽车磨损程度的特征。这被称为特征提取

mls3 0109

图 1-9. t-SNE 可视化示例,突出显示语义簇⁠²
提示

在将训练数据提供给另一个机器学习算法(如监督学习算法)之前,尝试使用降维算法减少训练数据的维数通常是一个好主意。这样可以使运行速度更快,数据占用的磁盘和内存空间更少,并且在某些情况下可能表现更好。

另一个重要的无监督任务是异常检测——例如,检测异常的信用卡交易以防止欺诈,捕捉制造缺陷,或在将数据提供给另一个学习算法之前自动删除异常值。系统在训练期间主要展示正常实例,因此学会了识别它们;然后,当它看到一个新实例时,它可以判断它是否看起来像一个正常实例,或者它很可能是一个异常(见图 1-10)。一个非常相似的任务是新颖性检测:它旨在检测看起来与训练集中所有实例都不同的新实例。这需要一个非常“干净”的训练集,不包含您希望算法检测的任何实例。例如,如果您有成千上万张狗的图片,其中 1%的图片代表吉娃娃,那么新颖性检测算法不应该将新的吉娃娃图片视为新颖。另一方面,异常检测算法可能认为这些狗非常罕见,与其他狗有很大不同,因此很可能将它们分类为异常(对吉娃娃没有冒犯意图)。

mls3 0110

图 1-10. 异常检测

最后,另一个常见的无监督任务是关联规则学习,其目标是挖掘大量数据并发现属性之间的有趣关系。例如,假设您拥有一家超市。在销售日志上运行关联规则可能会发现购买烧烤酱和薯片的人也倾向于购买牛排。因此,您可能希望将这些物品放在彼此附近。

半监督学习

由于标记数据通常耗时且昂贵,您通常会有大量未标记的实例和少量标记的实例。一些算法可以处理部分标记的数据。这被称为半监督学习(图 1-11)。

mls3 0111

图 1-11. 两类(三角形和正方形)的半监督学习:未标记的示例(圆圈)有助于将新实例(十字)分类为三角形类,而不是正方形类,即使它更接近标记的正方形

一些照片托管服务,如 Google 相册,就是很好的例子。一旦您将所有家庭照片上传到该服务,它会自动识别出同一个人 A 出现在照片 1、5 和 11 中,而另一个人 B 出现在照片 2、5 和 7 中。这是算法的无监督部分(聚类)。现在系统只需要您告诉它这些人是谁。只需为每个人添加一个标签⁠³,它就能够为每张照片中的每个人命名,这对于搜索照片很有用。

大多数半监督学习算法是无监督和监督算法的组合。例如,可以使用聚类算法将相似的实例分组在一起,然后每个未标记的实例可以用其簇中最常见的标签进行标记。一旦整个数据集被标记,就可以使用任何监督学习算法。

自监督学习

另一种机器学习方法涉及从完全未标记的数据集中生成一个完全标记的数据集。再次,一旦整个数据集被标记,就可以使用任何监督学习算法。这种方法被称为自监督学习

例如,如果您有一个大量未标记的图像数据集,您可以随机遮挡每个图像的一小部分,然后训练一个模型恢复原始图像(图 1-12)。在训练过程中,遮挡的图像被用作模型的输入,原始图像被用作标签。

mls3 0112

图 1-12. 自监督学习示例:输入(左)和目标(右)

得到的模型本身可能非常有用,例如用于修复损坏的图像或从图片中删除不需要的物体。但通常情况下,使用自监督学习训练的模型并不是最终目标。通常您会想要微调和调整模型以执行一个略有不同的任务,一个您真正关心的任务。

例如,假设您真正想要的是一个宠物分类模型:给定任何宠物的图片,它将告诉您它属于哪个物种。如果您有一个大量未标记的宠物照片数据集,您可以通过使用自监督学习训练一个图像修复模型来开始。一旦表现良好,它应该能够区分不同的宠物物种:当它修复一个脸部被遮盖的猫的图像时,它必须知道不要添加狗的脸。假设您的模型架构允许这样做(大多数神经网络架构都允许),那么就可以调整模型,使其预测宠物物种而不是修复图像。最后一步是在一个标记的数据集上对模型进行微调:模型已经知道猫、狗和其他宠物物种的外观,因此这一步只是为了让模型学习它已经知道的物种与我们期望从中得到的标签之间的映射。

注意

从一个任务中转移知识到另一个任务被称为迁移学习,这是当今机器学习中最重要的技术之一,特别是在使用深度神经网络(即由许多层神经元组成的神经网络)时。我们将在第二部分中详细讨论这个问题。

有些人认为自监督学习是无监督学习的一部分,因为它处理完全未标记的数据集。但是自监督学习在训练过程中使用(生成的)标签,因此在这方面更接近于监督学习。而“无监督学习”这个术语通常用于处理聚类、降维或异常检测等任务,而自监督学习侧重于与监督学习相同的任务:主要是分类和回归。简而言之,最好将自监督学习视为其自己的类别。

强化学习

强化学习是一种非常不同的学习方式。在这种情况下,学习系统被称为代理,它可以观察环境,选择和执行动作,并获得奖励(或以负奖励形式的惩罚,如图 1-13 所示)。然后,它必须自己学习什么是最佳策略,称为策略,以获得最大的奖励。策略定义了代理在特定情况下应该选择什么动作。

mls3 0113

图 1-13。强化学习

例如,许多机器人实现强化学习算法来学习如何行走。DeepMind 的 AlphaGo 程序也是强化学习的一个很好的例子:2017 年 5 月,它在围棋比赛中击败了当时世界排名第一的柯洁,成为头条新闻。它通过分析数百万场比赛学习了其获胜策略,然后对自己进行了许多场比赛。请注意,在与冠军对战时学习被关闭;AlphaGo 只是应用了它学到的策略。正如您将在下一节中看到的那样,这被称为离线学习

批量学习与在线学习

用于分类机器学习系统的另一个标准是系统是否能够从不断涌入的数据流中逐步学习。

批量学习

批量学习中,系统无法逐步学习:它必须使用所有可用数据进行训练。这通常需要大量时间和计算资源,因此通常在离线状态下进行。首先训练系统,然后将其投入生产并在不再学习的情况下运行;它只是应用它所学到的知识。这被称为离线学习

不幸的是,模型的性能往往会随着时间的推移而缓慢下降,仅仅因为世界在不断发展,而模型保持不变。这种现象通常被称为模型腐烂数据漂移。解决方案是定期使用最新数据对模型进行重新训练。您需要多久才能做到这一点取决于用例:如果模型对猫和狗的图片进行分类,其性能将会缓慢下降,但如果模型处理快速演变的系统,例如在金融市场上进行预测,那么它可能会迅速下降。

警告

即使是训练用于分类猫和狗图片的模型,也可能需要定期重新训练,不是因为猫和狗会在一夜之间发生变异,而是因为相机不断变化,图像格式、清晰度、亮度和大小比例也在变化。此外,人们可能会在明年喜欢不同的品种,或者决定给他们的宠物戴上小帽子——谁知道呢?

如果您希望批量学习系统了解新数据(例如新类型的垃圾邮件),您需要从头开始对完整数据集进行新版本系统的训练(不仅仅是新数据,还包括旧数据),然后用新模型替换旧模型。幸运的是,整个机器学习系统的训练、评估和启动过程可以相当容易地自动化(正如我们在图 1-3 中看到的那样),因此即使是批量学习系统也可以适应变化。只需根据需要更新数据并从头开始训练新版本的系统。

这个解决方案简单且通常效果良好,但使用完整数据集进行训练可能需要很多小时,因此您通常只会每 24 小时或甚至每周训练一个新系统。如果您的系统需要适应快速变化的数据(例如,预测股票价格),那么您需要一个更具反应性的解决方案。

此外,对完整数据集进行训练需要大量的计算资源(CPU、内存空间、磁盘空间、磁盘 I/O、网络 I/O 等)。如果您有大量数据并且自动化系统每天从头开始训练,那么最终会花费很多钱。如果数据量很大,甚至可能无法使用批量学习算法。

最后,如果您的系统需要能够自主学习并且资源有限(例如,智能手机应用程序或火星车),那么携带大量训练数据并每天花费大量资源进行训练是一个障碍。

在所有这些情况下,更好的选择是使用能够增量学习的算法。

在线学习

在线学习中,您通过顺序地逐个或以小组(称为小批量)的方式向系统提供数据实例来逐步训练系统。每个学习步骤都很快且便宜,因此系统可以在数据到达时即时学习新数据(参见图 1-14)。

mls3 0114

图 1-14。在在线学习中,模型经过训练并投入生产,然后随着新数据的到来而不断学习

在线学习对需要极快适应变化的系统非常有用(例如,检测股市中的新模式)。如果您的计算资源有限,例如在移动设备上训练模型,这也是一个不错的选择。

此外,可以使用在线学习算法在无法适应一台机器的主内存的大型数据集上训练模型(这称为离线学习)。该算法加载部分数据,在该数据上运行训练步骤,并重复该过程,直到在所有数据上运行完毕(参见图 1-15)。

mls3 0115

图 1-15。使用在线学习处理大型数据集

在线学习系统的一个重要参数是它们应该如何快速适应变化的数据:这被称为“学习率”。如果设置较高的学习率,那么您的系统将迅速适应新数据,但也会很快忘记旧数据(您不希望垃圾邮件过滤器只标记它所展示的最新类型的垃圾邮件)。相反,如果设置较低的学习率,系统将具有更多的惯性;也就是说,它将学习得更慢,但也会对新数据中的噪声或非代表性数据点序列(异常值)不太敏感。

警告

离线学习通常是在离线系统上完成的(即不在实时系统上),因此“在线学习”可能是一个令人困惑的名称。将其视为“增量学习”。

在线学习的一个重大挑战是,如果向系统提供了错误数据,系统的性能将下降,可能会很快下降(取决于数据质量和学习率)。如果这是一个实时系统,您的客户会注意到。例如,错误的数据可能来自错误(例如,机器人上的传感器故障),也可能来自试图操纵系统的人(例如,通过垃圾信息搜索引擎以在搜索结果中排名靠前)。为了降低这种风险,您需要密切监控系统,并在检测到性能下降时及时关闭学习(并可能恢复到先前工作状态)。您还可能希望监控输入数据并对异常数据做出反应;例如,使用异常检测算法(参见第九章)。

基于实例与基于模型的学习

将机器学习系统分类的另一种方法是通过它们的泛化方式。大多数机器学习任务都是关于进行预测。这意味着给定一些训练示例,系统需要能够对它以前从未见过的示例进行良好的预测(泛化)。在训练数据上有一个良好的性能度量是好的,但不足够;真正的目标是在新实例上表现良好。

泛化有两种主要方法:基于实例的学习和基于模型的学习。

基于实例的学习

可能最琐碎的学习形式就是纯粹靠记忆学习。如果您按照这种方式创建垃圾邮件过滤器,它将只标记所有与用户已标记的电子邮件相同的电子邮件,这并不是最糟糕的解决方案,但肯定不是最好的解决方案。

而不仅仅是标记与已知垃圾邮件相同的电子邮件,您的垃圾邮件过滤器还可以被编程为标记与已知垃圾邮件非常相似的电子邮件。这需要两封电子邮件之间的相似度度量。两封电子邮件之间的(非常基本的)相似度度量可以是计算它们共同拥有的单词数量。如果一封电子邮件与已知的垃圾邮件有许多共同单词,系统将标记该电子邮件为垃圾邮件。

这称为基于实例的学习:系统通过记忆示例,然后使用相似度度量将新案例泛化到学习示例(或其中的一个子集)。例如,在图 1-16 中,新实例将被分类为三角形,因为大多数最相似的实例属于该类。

mls3 0116

图 1-16. 基于实例的学习

基于模型的学习和典型的机器学习工作流程

从一组示例中泛化的另一种方法是构建这些示例的模型,然后使用该模型进行预测。这称为基于模型的学习(图 1-17)。

mls3 0117

图 1-17. 基于模型的学习

例如,假设您想知道金钱是否让人们快乐,因此您从OECD 网站下载更美好生活指数数据和世界银行统计数据关于人均国内生产总值(GDP)。然后您连接这些表格并按人均 GDP 排序。表 1-1 显示了您获得的摘录。

表 1-1. 金钱让人更快乐吗?

国家 人均 GDP(美元) 生活满意度
土耳其 28,384 5.5
匈牙利 31,008 5.6
法国 42,026 6.5
美国 60,236 6.9
新西兰 42,404 7.3
澳大利亚 48,698 7.3
丹麦 55,938 7.6

让我们为这些国家的数据绘制图表(图 1-18)。

mls3 0118

图 1-18. 你看到这里有一个趋势吗?

这里似乎有一个趋势!尽管数据是嘈杂的(即部分随机),但看起来生活满意度随着国家人均 GDP 的增加而更多或更少地呈线性增长。因此,您决定将生活满意度建模为国家人均 GDP 的线性函数。这一步称为模型选择:您选择了一个只有一个属性,即国家人均 GDP 的生活满意度的线性模型(方程 1-1)。

方程 1-1. 一个简单的线性模型

life_satisfaction = θ 0 + θ 1 × GDP_per_capita

该模型有两个模型参数θ[0]和θ[1]。⁠⁴ 通过调整这些参数,您可以使您的模型表示任何线性函数,如图 1-19 所示。

mls3 0119

图 1-19. 几种可能的线性模型

在使用模型之前,您需要定义参数值θ[0]和θ[1]。您如何知道哪些值会使您的模型表现最佳?要回答这个问题,您需要指定一个性能度量。您可以定义一个度量模型好坏效用函数(或适应函数),也可以定义一个度量模型成本函数。对于线性回归问题,人们通常使用一个测量线性模型预测与训练示例之间距离的成本函数;目标是最小化这个距离。

这就是线性回归算法的作用:您将训练示例提供给它,它会找到使线性模型最适合您的数据的参数。这称为训练模型。在我们的情况下,算法发现最佳参数值为θ[0] = 3.75 和θ[1] = 6.78 × 10^(–5)。

警告

令人困惑的是,“模型”这个词可以指一个模型类型(例如线性回归),也可以指一个完全指定的模型架构(例如具有一个输入和一个输出的线性回归),或者指准备用于预测的最终训练好的模型(例如具有一个输入和一个输出的线性回归,使用θ[0] = 3.75 和θ[1] = 6.78 × 10^(–5))。模型选择包括选择模型类型和完全指定其架构。训练模型意味着运行算法以找到使其最佳拟合训练数据的模型参数,并希望在新数据上做出良好的预测。

现在模型尽可能地拟合训练数据(对于线性模型),如您在图 1-20 中所见。

mls3 0120

图 1-20。最佳拟合训练数据的线性模型

您现在准备运行模型进行预测。例如,假设您想知道塞浦路斯人有多幸福,而 OECD 数据没有答案。幸运的是,您可以使用您的模型进行良好的预测:查找塞浦路斯的人均 GDP,找到 37655 美元,然后应用您的模型,发现生活满意度可能在 3.75 + 37655 × 6.78 × 10^(–5) = 6.30 左右。

为了激起您的兴趣,示例 1-1 展示了加载数据、将输入X与标签y分开、创建散点图进行可视化、然后训练线性模型并进行预测的 Python 代码。⁠⁵

示例 1-1。使用 Scikit-Learn 训练和运行线性模型
import matplotlib.pyplot as plt
import numpy as np
import pandas as pd
from sklearn.linear_model import LinearRegression

# Download and prepare the data
data_root = "https://github.com/ageron/data/raw/main/"
lifesat = pd.read_csv(data_root + "lifesat/lifesat.csv")
X = lifesat[["GDP per capita (USD)"]].values
y = lifesat[["Life satisfaction"]].values

# Visualize the data
lifesat.plot(kind='scatter', grid=True,
             x="GDP per capita (USD)", y="Life satisfaction")
plt.axis([23_500, 62_500, 4, 9])
plt.show()

# Select a linear model
model = LinearRegression()

# Train the model
model.fit(X, y)

# Make a prediction for Cyprus
X_new = [[37_655.2]]  # Cyprus' GDP per capita in 2020
print(model.predict(X_new)) # output: [[6.30165767]]
注意

如果您使用了基于实例的学习算法,您会发现以色列的人均 GDP 最接近塞浦路斯的人均 GDP(38341 美元),由于 OECD 数据告诉我们以色列人的生活满意度为 7.2,您可能会预测塞浦路斯的生活满意度为 7.2。如果您稍微放大一点并查看两个最接近的国家,您会发现立陶宛和斯洛文尼亚,两者的生活满意度都为 5.9。将这三个值平均,您会得到 6.33,这与基于模型的预测非常接近。这个简单的算法称为k-最近邻回归(在这个例子中,k = 3)。

在前面的代码中用k-最近邻回归替换线性回归模型就像替换这些行一样容易:

from sklearn.linear_model import LinearRegression
model = LinearRegression()

这两个:

from sklearn.neighbors import KNeighborsRegressor
model = KNeighborsRegressor(n_neighbors=3)

如果一切顺利,您的模型将做出良好的预测。如果不是,您可能需要使用更多属性(就业率、健康、空气污染等)、获取更多或更高质量的训练数据,或者选择一个更强大的模型(例如多项式回归模型)。

总结:

  • 您研究了数据。

  • 您选择了一个模型。

  • 您在训练数据上对其进行了训练(即,学习算法搜索使模型参数值最小化成本函数)。

  • 最后,您应用模型对新案例进行预测(这称为推断),希望这个模型能很好地泛化。

这是一个典型的机器学习项目的样子。在第二章中,您将通过从头到尾完成一个项目来亲身体验这一过程。

到目前为止,我们已经涵盖了很多内容:您现在知道机器学习真正关注的是什么,为什么它有用,一些最常见的 ML 系统类别是什么,以及典型项目工作流程是什么样的。现在让我们看看在学习过程中可能出现的问题,阻止您做出准确的预测。

机器学习的主要挑战

简而言之,由于您的主要任务是选择一个模型并在一些数据上进行训练,可能出错的两个因素是“坏模型”和“坏数据”。让我们从坏数据的例子开始。

训练数据数量不足

对于一个幼儿来学习什么是苹果,只需要您指向一个苹果并说“苹果”(可能需要重复几次这个过程)。现在孩子能够识别各种颜色和形状的苹果了。天才。

机器学习还没有完全成熟;大多数机器学习算法需要大量数据才能正常工作。即使对于非常简单的问题,您通常也需要成千上万的示例,而对于像图像或语音识别这样的复杂问题,您可能需要数百万的示例(除非您可以重用现有模型的部分)。

非代表性训练数据

为了很好地泛化,您的训练数据必须代表您想要泛化到的新案例。无论您使用基于实例的学习还是基于模型的学习,这一点都是真实的。

例如,您之前用于训练线性模型的国家集合并不完全代表性;它不包含人均 GDP 低于 23,500 美元或高于 62,500 美元的任何国家。图 1-22 展示了当您添加这样的国家时数据的样子。

如果您在这些数据上训练线性模型,您将得到实线,而旧模型由虚线表示。正如您所看到的,不仅添加一些缺失的国家会显著改变模型,而且清楚地表明这样一个简单的线性模型可能永远不会很好地工作。似乎非常富裕的国家并不比中等富裕的国家更幸福(事实上,它们似乎稍微不那么幸福!),反之,一些贫穷的国家似乎比许多富裕国家更幸福。

通过使用非代表性的训练集,您训练了一个不太可能做出准确预测的模型,特别是对于非常贫穷和非常富裕的国家。

mls3 0122

图 1-22。更具代表性的训练样本

使用代表您想要泛化到的案例的训练集至关重要。这通常比听起来更难:如果样本太小,您将会有抽样误差(即由于偶然性导致的非代表性数据),但即使是非常大的样本也可能是非代表性的,如果抽样方法有缺陷的话。这被称为抽样偏差

低质量数据

显然,如果您的训练数据中充满错误、异常值和噪音(例如,由于质量不佳的测量),这将使系统更难检测到潜在的模式,因此您的系统更不可能表现良好。花时间清理训练数据通常是非常值得的。事实上,大多数数据科学家花费大部分时间就是在做这件事。以下是您需要清理训练数据的几个例子:

  • 如果一些实例明显是异常值,简单地丢弃它们或尝试手动修复错误可能有所帮助。

  • 如果一些实例缺少一些特征(例如,5%的客户没有指定年龄),您必须决定是否要完全忽略这个属性,忽略这些实例,填补缺失值(例如,用中位数年龄),或者训练一个带有该特征的模型和一个不带该特征的模型。

无关特征

俗话说:垃圾进,垃圾出。只有当训练数据包含足够相关的特征而不包含太多无关的特征时,您的系统才能学习。机器学习项目成功的关键部分之一是提出一组良好的特征进行训练。这个过程被称为特征工程,包括以下步骤:

  • 特征选择(在现有特征中选择最有用的特征进行训练)

  • 特征提取(将现有特征组合以生成更有用的特征——正如我们之前看到的,降维算法可以帮助)

  • 通过收集新数据创建新特征

现在我们已经看了很多坏数据的例子,让我们看一看一些坏算法的例子。

过拟合训练数据

假设您正在访问一个外国国家,出租车司机宰客。您可能会说那个国家的所有出租车司机都是小偷。过度概括是我们人类经常做的事情,不幸的是,如果我们不小心,机器也会陷入同样的陷阱。在机器学习中,这被称为过拟合:这意味着模型在训练数据上表现良好,但在泛化上表现不佳。

图 1-23 显示了一个高次多项式生活满意度模型的例子,它在训练数据上过拟合。尽管它在训练数据上的表现比简单线性模型要好得多,但您真的会相信它的预测吗?

mls3 0123

图 1-23. 过拟合训练数据

像深度神经网络这样的复杂模型可以检测数据中的微妙模式,但如果训练集存在噪声,或者太小,引入了抽样误差,那么模型很可能会检测到噪声本身中的模式(就像出租车司机的例子)。显然,这些模式不会推广到新实例。例如,假设您向生活满意度模型提供了更多属性,包括无信息的属性,如国家名称。在这种情况下,复杂模型可能会检测到模式,比如训练数据中所有带有W的国家名称的生活满意度大于 7:新西兰(7.3)、挪威(7.6)、瑞典(7.3)和瑞士(7.5)。您有多大信心认为W-满意度规则适用于卢旺达或津巴布韦?显然,这种模式纯粹是偶然出现在训练数据中,但模型无法判断一个模式是真实的还是仅仅是数据中的噪声所致。

警告

过拟合发生在模型相对于训练数据的数量和噪声过多时。以下是可能的解决方案:

  • 通过选择具有较少参数的模型(例如,线性模型而不是高次多项式模型)、减少训练数据中的属性数量或约束模型来简化模型。

  • 收集更多的训练数据。

  • 减少训练数据中的噪声(例如,修复数据错误和删除异常值)。

通过对模型进行约束使其变得更简单并减少过拟合风险称为正则化。例如,我们之前定义的线性模型有两个参数,θ[0]和θ[1]。这给学习算法两个自由度来调整模型以适应训练数据:它可以调整线的高度(θ[0])和斜率(θ[1])。如果我们强制θ[1] = 0,算法将只有一个自由度,并且更难正确拟合数据:它只能上下移动线以尽可能接近训练实例,因此最终会在均值附近。一个非常简单的模型!如果允许算法修改θ[1]但强制保持较小,则学习算法实际上将在一个自由度和两个自由度之间。它将产生一个比具有两个自由度的模型更简单,但比只有一个自由度的模型更复杂的模型。您希望找到完全拟合训练数据并保持模型足够简单以确保其良好泛化的正确平衡。

图 1-24 展示了三个模型。虚线代表原始模型,该模型是在以圆圈表示的国家上进行训练的(不包括以方块表示的国家),实线是我们的第二个模型,训练了所有国家(圆圈和方块),虚线是一个使用与第一个模型相同数据进行训练但带有正则化约束的模型。您可以看到正则化强制模型具有较小的斜率:这个模型不像第一个模型那样很好地拟合训练数据(圆圈),但实际上更好地泛化到在训练过程中没有见过的新示例(方块)。

mls3 0124

图 1-24. 正则化减少过拟合风险

在学习过程中应用的正则化量可以通过超参数来控制。超参数是学习算法的参数(而不是模型的参数)。因此,它不受学习算法本身的影响;它必须在训练之前设置,并在训练过程中保持不变。如果将正则化超参数设置为非常大的值,您将得到一个几乎平坦的模型(斜率接近零);学习算法几乎肯定不会过度拟合训练数据,但更不太可能找到一个好的解决方案。调整超参数是构建机器学习系统的重要部分(您将在下一章中看到一个详细的示例)。

训练数据欠拟合

正如您可能猜到的,欠拟合是过拟合的相反:当您的模型过于简单而无法学习数据的基本结构时,就会发生欠拟合。例如,对生活满意度的线性模型容易发生欠拟合;现实比模型更复杂,因此其预测很可能不准确,即使在训练示例上也是如此。

以下是解决此问题的主要选项:

  • 选择一个更强大的模型,具有更多参数。

  • 向学习算法提供更好的特征(特征工程)。

  • 减少模型的约束(例如通过减少正则化超参数)。

退一步

到目前为止,您已经了解了很多关于机器学习的知识。然而,我们讨论了许多概念,您可能感到有点迷茫,所以让我们退一步,看看整体情况:

  • 机器学习是通过从数据中学习来使机器在某些任务上变得更好,而不是必须明确编写规则。

  • 有许多不同类型的 ML 系统:监督或非监督,批处理或在线,基于实例或基于模型。

  • 在一个机器学习项目中,你在训练集中收集数据,并将训练集提供给学习算法。如果算法是基于模型的,它会调整一些参数以使模型适应训练集(即,在训练集上做出良好的预测),然后希望它也能在新案例上做出良好的预测。如果算法是基于实例的,它只是通过记忆例子并使用相似度度量来将它们与学习的实例进行比较,从而推广到新实例。

  • 如果你的训练集太小,或者数据不具代表性,噪音太大,或者包含无关特征(垃圾进,垃圾出),系统将无法表现良好。最后,你的模型既不能太简单(这样它会欠拟合),也不能太复杂(这样它会过拟合)。

最后一个重要的主题要讨论:一旦你训练了一个模型,你不希望只是“希望”它能推广到新案例。你希望对其进行评估,并在必要时进行微调。让我们看看如何做到这一点。

测试和验证

了解一个模型将如何推广到新案例的唯一方法是实际在新案例上尝试。一种方法是将你的模型投入生产并监控其表现。这种方法效果很好,但如果你的模型非常糟糕,用户会抱怨——这不是最好的主意。

更好的选择是将数据分为两组:训练集测试集。正如这些名称所暗示的,你使用训练集训练你的模型,并使用测试集测试它。新案例的错误率称为泛化误差(或样本外误差),通过在测试集上评估你的模型,你可以得到这个误差的估计。这个值告诉你你的模型在它从未见过的实例上的表现如何。

如果训练误差很低(即,你的模型在训练集上犯了很少的错误),但泛化误差很高,这意味着你的模型正在过拟合训练数据。

提示

通常使用 80%的数据进行训练,保留20%用于测试。但是,这取决于数据集的大小:如果包含 1000 万个实例,那么保留 1%意味着你的测试集将包含 100,000 个实例,可能足够得到泛化误差的良好估计。

超参数调整和模型选择

评估一个模型很简单:只需使用一个测试集。但是假设你在两种类型的模型之间犹豫不决(比如,线性模型和多项式模型):你如何决定呢?一种选择是训练两种模型,并比较它们在测试集上的泛化效果。

现在假设线性模型泛化效果更好,但你想应用一些正则化来避免过拟合。问题是,你如何选择正则化超参数的值?一种选择是使用 100 个不同的值训练 100 个不同的模型。假设你找到了一个最佳的超参数值,可以产生泛化误差最低的模型——比如,只有 5%的误差。你将这个模型投入生产,但不幸的是它的表现并不如预期,产生了 15%的错误。发生了什么?

问题在于你在测试集上多次测量了泛化误差,并且调整了模型和超参数以产生最佳模型针对那个特定集合。这意味着该模型不太可能在新数据上表现得很好。

解决这个问题的常见方法称为留出验证(图 1-25):您只需留出部分训练集来评估几个候选模型并选择最佳模型。新的留出集称为验证集(或开发集,或开发集)。更具体地说,您在减少的训练集上(即完整训练集减去验证集)上训练多个具有不同超参数的模型,并选择在验证集上表现最佳的模型。在进行留出验证过程之后,您在完整训练集上(包括验证集)训练最佳模型,这将为您提供最终模型。最后,您评估这个最终模型在测试集上,以获得泛化误差的估计。

mls3 0125

图 1-25. 使用留出验证进行模型选择

这种解决方案通常效果很好。然而,如果验证集太小,则模型评估将不够精确:您可能会错误地选择次优模型。相反,如果验证集太大,则剩余的训练集将比完整训练集小得多。为什么这样不好呢?因为最终模型将在完整训练集上训练,所以将候选模型训练在一个小得多的训练集上进行比较并不理想。这就好比选择最快的短跑选手参加马拉松比赛。解决这个问题的一种方法是执行重复的交叉验证,使用许多小的验证集。每个模型在其余数据上训练后,每个验证集对其进行一次评估。通过对模型的所有评估进行平均,您将获得更准确的性能度量。然而,这种方法的一个缺点是:训练时间将乘以验证集的数量。

数据不匹配

在某些情况下,很容易获得大量用于训练的数据,但这些数据可能不会完全代表将在生产中使用的数据。例如,假设您想创建一个移动应用程序来拍摄花朵并自动确定它们的种类。您可以轻松地在网上下载数百万张花朵图片,但它们不会完全代表实际使用移动设备上应用程序拍摄的图片。也许您只有 1,000 张代表性图片(即实际使用应用程序拍摄的图片)。

在这种情况下,最重要的规则是记住验证集和测试集必须尽可能代表您预期在生产中使用的数据,因此它们应该完全由代表性图片组成:您可以对它们进行洗牌,并将一半放入验证集,一半放入测试集(确保没有重复或近似重复的图片同时出现在两个集合中)。在对网络图片训练模型后,如果您观察到模型在验证集上的表现令人失望,您将不知道这是因为您的模型已经过度拟合训练集,还是仅仅是由于网络图片和移动应用程序图片之间的不匹配。

一种解决方案是在另一个由 Andrew Ng 命名为训练-开发集的集合中保留一些训练图片(来自网络)(图 1-26)。在模型训练完成后(在训练集上,不是在训练-开发集上),您可以在训练-开发集上评估它。如果模型表现不佳,则必须过度拟合训练集,因此应尝试简化或正则化模型,获取更多训练数据,并清理训练数据。但如果模型在训练-开发集上表现良好,则可以在开发集上评估模型。如果模型在开发集上表现不佳,则问题可能来自数据不匹配。您可以尝试通过预处理网络图像使其看起来更像移动应用程序将拍摄的图片,然后重新训练模型来解决此问题。一旦您有一个在训练-开发集和开发集上表现良好的模型,您可以最后一次在测试集上评估它,以了解它在生产中的表现如何。

mls3 0126

图 1-26。当真实数据稀缺时(右侧),您可以使用类似丰富的数据(左侧)进行训练,并在训练-开发集中保留一些数据以评估过拟合;然后使用真实数据来评估数据不匹配(开发集)并评估最终模型的性能(测试集)

练习

在本章中,我们已经介绍了机器学习中一些最重要的概念。在接下来的章节中,我们将深入探讨并编写更多代码,但在此之前,请确保您能回答以下问题:

  1. 您如何定义机器学习?

  2. 您能否列出四种应用程序的类型,它们在哪些方面表现出色?

  3. 什么是标记训练集?

  4. 最常见的两种监督任务是什么?

  5. 您能否列出四种常见的无监督任务?

  6. 您会使用什么类型的算法来允许机器人在各种未知地形中行走?

  7. 您会使用什么类型的算法将客户分成多个群组?

  8. 您会将垃圾邮件检测问题框定为监督学习问题还是无监督学习问题?

  9. 什么是在线学习系统?

  10. 什么是离线学习?

  11. 依赖相似度度量进行预测的算法类型是什么?

  12. 模型参数和模型超参数之间有什么区别?

  13. 基于模型的算法搜索什么?它们成功的最常见策略是什么?它们如何进行预测?

  14. 您能否列出机器学习中的四个主要挑战?

  15. 如果您的模型在训练数据上表现良好,但对新实例的泛化能力差,那么发生了什么?您能否列出三种可能的解决方案?

  16. 什么是测试集,为什么要使用它?

  17. 验证集的目的是什么?

  18. 什么是训练-开发集,何时需要它,以及如何使用它?

  19. 如果使用测试集调整超参数会出现什么问题?

这些练习的解决方案可以在本章笔记本的末尾找到,网址为https://homl.info/colab3

¹ 有趣的事实:这个听起来奇怪的名字是由弗朗西斯·高尔顿引入的统计术语,当时他正在研究高个子父母的孩子往往比父母矮的事实。由于孩子们较矮,他将此称为回归到平均值。然后,这个名字被应用于他用来分析变量之间相关性的方法。

² 请注意,动物与车辆相当分离,马与鹿接近但与鸟类相距甚远。图由 Richard Socher 等人允许复制,来源于“通过跨模态转移实现零样本学习”,第 26 届国际神经信息处理系统会议论文集 1(2013):935-943。

³ 这就是系统完美运行的时候。在实践中,它经常为每个人创建几个簇,并有时会混淆看起来相似的两个人,因此您可能需要为每个人提供一些标签,并手动清理一些簇。

⁴ 按照惯例,希腊字母θ(theta)经常用于表示模型参数。

⁵ 如果您还不理解所有的代码,没关系;我将在接下来的章节中介绍 Scikit-Learn。

⁶ 例如,根据上下文知道是写“to”、“two”还是“too”。

⁷ Peter Norvig 等,“数据的不合理有效性”,《IEEE 智能系统》24 卷 2 期(2009 年):8–12。

⁸ 图片经 Michele Banko 和 Eric Brill 许可重印,“用于自然语言消歧的非常大的语料库的扩展”,《计算语言学协会第 39 届年会论文集》(2001 年):26–33。

⁹ David Wolpert,“学习算法之间缺乏先验区别”,《神经计算》8 卷 7 期(1996 年):1341–1390。

第二章:端到端的机器学习项目

在本章中,您将通过一个示例项目端到端地工作,假装自己是一家房地产公司最近雇用的数据科学家。这个例子是虚构的;目标是说明机器学习项目的主要步骤,而不是了解房地产业务。以下是我们将要走过的主要步骤:

  1. 看大局。

  2. 获取数据。

  3. 探索和可视化数据以获得洞见。

  4. 为机器学习算法准备数据。

  5. 选择模型并训练它。

  6. 微调您的模型。

  7. 呈现解决方案。

  8. 启动、监控和维护您的系统。

使用真实数据

当您学习机器学习时,最好尝试使用真实世界的数据,而不是人工数据集。幸运的是,有成千上万的开放数据集可供选择,涵盖各种领域。以下是您可以查找数据的一些地方:

在本章中,我们将使用 StatLib 存储库中的加利福尼亚房价数据集(见图 2-1)。该数据集基于 1990 年加利福尼亚人口普查数据。虽然不是最新的(在那个时候,旧金山湾区的漂亮房子仍然是负担得起的),但它具有许多学习的优点,因此我们将假装它是最新的数据。出于教学目的,我添加了一个分类属性并删除了一些特征。

mls3 0201

图 2-1。加利福尼亚房屋价格

看大局

欢迎来到机器学习房地产公司!您的第一个任务是使用加利福尼亚人口普查数据来建立该州房价模型。这些数据包括加利福尼亚每个街区组的人口、中位收入和中位房价等指标。街区组是美国人口普查局发布样本数据的最小地理单位(一个街区组通常有 600 到 3000 人口)。我会简称它们为“区”。

您的模型应该从这些数据中学习,并能够预测任何地区的房屋中位数价格,考虑到所有其他指标。

提示

由于您是一个组织良好的数据科学家,您应该做的第一件事是拿出您的机器学习项目清单。您可以从附录 A 开始;对于大多数机器学习项目来说,这应该运行得相当顺利,但请确保根据您的需求进行调整。在本章中,我们将逐个检查许多项目,但也会跳过一些,要么是因为它们是不言自明的,要么是因为它们将在后续章节中讨论。

构建问题框架

向老板提出问题的第一个问题是业务目标究竟是什么。构建模型可能不是最终目标。公司希望如何使用和从这个模型中受益?知道目标很重要,因为它将决定您如何构建问题,选择哪些算法,使用哪种性能度量来评估您的模型,以及您将花费多少精力来调整它。

你的老板回答说,你模型的输出(一个区域的中位房价的预测)将被馈送到另一个机器学习系统(见图 2-2),以及许多其他信号。这个下游系统将确定是否值得在一个给定区域进行投资。做对这一点至关重要,因为它直接影响收入。

下一个问题要问你的老板是,当前的解决方案是什么样子的(如果有的话)。当前情况通常会给你一个性能的参考,以及解决问题的见解。你的老板回答说,目前专家们手动估计区域房价:一个团队收集关于一个区域的最新信息,当他们无法获得中位房价时,他们使用复杂的规则来估计。

mls3 0202

图 2-2。房地产投资的机器学习流程

这是昂贵且耗时的,他们的估计并不好;在他们设法找出实际的中位房价的情况下,他们经常意识到他们的估计偏差超过 30%。这就是为什么公司认为训练一个模型来预测一个区域的中位房价,给出该区域的其他数据,将是有用的。人口普查数据看起来是一个很好的数据集,可以用来实现这个目的,因为它包括数千个区域的中位房价,以及其他数据。

有了所有这些信息,你现在可以开始设计你的系统了。首先,确定模型将需要什么样的训练监督:是监督、无监督、半监督、自监督还是强化学习任务?这是一个分类任务、回归任务还是其他任务?应该使用批量学习还是在线学习技术?在继续阅读之前,暂停一下,尝试自己回答这些问题。

你找到答案了吗?让我们看看。这显然是一个典型的监督学习任务,因为模型可以通过标记的示例进行训练(每个实例都带有预期的输出,即区域的中位房价)。这是一个典型的回归任务,因为模型将被要求预测一个值。更具体地说,这是一个多元回归问题,因为系统将使用多个特征来进行预测(区域的人口,中位收入等)。这也是一个单变量回归问题,因为我们只是尝试预测每个区域的一个单一值。如果我们试图预测每个区域的多个值,那么它将是一个多元回归问题。最后,系统中没有连续的数据流进入,没有特别需要快速调整到变化的数据,数据足够小以适应内存,因此普通的批量学习应该做得很好。

提示

如果数据很大,你可以将批量学习工作分配到多台服务器上(使用 MapReduce 技术),或者使用在线学习技术。

选择一个性能度量

你的下一步是选择一个性能度量。回归问题的一个典型性能度量是均方根误差(RMSE)。它给出了系统在预测中通常产生多少误差的概念,对大误差给予更高的权重。方程 2-1 显示了计算 RMSE 的数学公式。

方程 2-1。均方根误差(RMSE)

RMSE ( X , h ) = 1 m i=1 m h(x (i) )-y (i) 2

尽管均方根误差通常是回归任务的首选性能度量,但在某些情况下,您可能更喜欢使用另一个函数。例如,如果有许多异常值区域。在这种情况下,您可能考虑使用平均绝对误差(MAE,也称为平均绝对偏差),如方程 2-2 所示:

方程 2-2. 平均绝对误差(MAE)

MAE ( X , h ) = 1 m i=1 m h ( x (i) ) - y (i)

均方根误差(RMSE)和平均绝对误差(MAE)都是衡量两个向量之间距离的方法:预测向量和目标值向量。各种距离度量,或范数,都是可能的:

  • 计算平方和的根(RMSE)对应于欧几里德范数:这是我们都熟悉的距离概念。它也被称为ℓ[2] 范数,表示为∥ · ∥[2](或只是∥ · ∥)。

  • 计算绝对值之和(MAE)对应于ℓ[1] 范数,表示为∥ · ∥[1]。这有时被称为曼哈顿范数,因为它测量了在城市中两点之间的距离,如果您只能沿着正交的城市街区行驶。

  • 更一般地,包含n个元素的向量v的ℓ[k] 范数被定义为∥v∥[k] = (|v[1]|^(k) + |v[2]|^(k) + ... + |v[n]|(*k*))(1/k)。ℓ[0]给出向量中非零元素的数量,ℓ[∞]给出向量中的最大绝对值。

范数指数越高,就越关注大值并忽略小值。这就是为什么均方根误差(RMSE)比平均绝对误差(MAE)更容易受到异常值的影响。但是当异常值呈指数稀有性(如钟形曲线)时,均方根误差表现非常好,通常更受青睐。

检查假设

最后,列出并验证迄今为止已经做出的假设(由您或他人)是一个很好的做法;这可以帮助您及早发现严重问题。例如,您的系统输出的区域价格将被馈送到下游机器学习系统中,您假设这些价格将被如此使用。但是,如果下游系统将价格转换为类别(例如,“便宜”,“中等”或“昂贵”),然后使用这些类别而不是价格本身呢?在这种情况下,完全准确地获取价格并不重要;您的系统只需要获取正确的类别。如果是这样,那么问题应该被定义为一个分类任务,而不是一个回归任务。您不希望在为回归系统工作数月后才发现这一点。

幸运的是,在与负责下游系统的团队交谈后,您确信他们确实需要实际价格,而不仅仅是类别。太好了!您已经准备就绪,灯光是绿色的,现在可以开始编码了!

获取数据

现在是动手的时候了。毫不犹豫地拿起你的笔记本电脑,浏览代码示例。正如我在前言中提到的,本书中的所有代码示例都是开源的,可在在线作为 Jupyter 笔记本使用,这些笔记本是交互式文档,包含文本、图像和可执行代码片段(在我们的情况下是 Python)。在本书中,我假设您正在 Google Colab 上运行这些笔记本,这是一个免费服务,让您可以直接在线运行任何 Jupyter 笔记本,而无需在您的计算机上安装任何东西。如果您想使用另一个在线平台(例如 Kaggle),或者如果您想在自己的计算机上本地安装所有内容,请参阅本书 GitHub 页面上的说明。

使用 Google Colab 运行代码示例

首先,打开一个网络浏览器,访问https://homl.info/colab3:这将带您到 Google Colab,并显示本书的 Jupyter 笔记本列表(参见图 2-3)。您将在每章找到一个笔记本,以及一些额外的笔记本和 NumPy、Matplotlib、Pandas、线性代数和微积分的教程。例如,如果您点击02_end_to_end_machine_learning_project.ipynb,将会在 Google Colab 中打开第二章的笔记本(参见图 2-4)。

Jupyter 笔记本由一系列单元格组成。每个单元格包含可执行代码或文本。尝试双击第一个文本单元格(其中包含句子“欢迎来到机器学习房地产公司!”)。这将打开单元格进行编辑。请注意,Jupyter 笔记本使用 Markdown 语法进行格式化(例如,**粗体***斜体*# 标题url等)。尝试修改这段文本,然后按 Shift-Enter 查看结果。

mls3 0203

图 2-3. Google Colab 中的笔记本列表

mls3 0204

图 2-4. 在 Google Colab 中的笔记本

接下来,通过选择插入→“代码单元格”菜单来创建一个新的代码单元格。或者,您可以在工具栏中点击+代码按钮,或者将鼠标悬停在单元格底部直到看到+代码和+文本出现,然后点击+代码。在新的代码单元格中,输入一些 Python 代码,例如print("Hello World"),然后按 Shift-Enter 运行此代码(或者点击单元格左侧的▷按钮)。

如果您尚未登录 Google 账户,现在将被要求登录(如果您尚未拥有 Google 账户,您需要创建一个)。一旦您登录,当您尝试运行代码时,您将看到一个安全警告,告诉您这个笔记本不是由 Google 编写的。一个恶意的人可能会创建一个试图欺骗您输入 Google 凭据的笔记本,以便访问您的个人数据,因此在运行笔记本之前,请务必确保信任其作者(或在运行之前仔细检查每个代码单元格将执行的操作)。假设您信任我(或者您计划检查每个代码单元格),现在可以点击“仍然运行”。

Colab 将为您分配一个新的运行时:这是位于 Google 服务器上的免费虚拟机,包含一堆工具和 Python 库,包括大多数章节所需的一切(在某些章节中,您需要运行一个命令来安装额外的库)。这将需要几秒钟。接下来,Colab 将自动连接到此运行时,并使用它来执行您的新代码单元格。重要的是,代码在运行时上运行,而不是在您的计算机上。代码的输出将显示在单元格下方。恭喜,您已在 Colab 上运行了一些 Python 代码!

提示

要插入新的代码单元格,您也可以键入 Ctrl-M(或 macOS 上的 Cmd-M),然后按 A(在活动单元格上方插入)或 B(在下方插入)。还有许多其他可用的键盘快捷键:您可以通过键入 Ctrl-M(或 Cmd-M)然后 H 来查看和编辑它们。如果您选择在 Kaggle 上或使用 JupyterLab 或带有 Jupyter 扩展的 IDE(如 Visual Studio Code)在自己的机器上运行笔记本,您将看到一些细微差异——运行时称为内核,用户界面和键盘快捷键略有不同等等——但从一个 Jupyter 环境切换到另一个并不太困难。

保存您的代码更改和数据

您可以对 Colab 笔记本进行更改,并且只要保持浏览器标签打开,这些更改将持续存在。但一旦关闭它,更改将丢失。为了避免这种情况,请确保通过选择文件→“在驱动器中保存副本”将笔记本保存到您的谷歌驱动器。或者,您可以通过选择文件→下载→“下载.ipynb”将笔记本下载到计算机。然后,您可以稍后访问https://colab.research.google.com并再次打开笔记本(从谷歌驱动器或通过从计算机上传)。

警告

Google Colab 仅用于交互使用:您可以在笔记本中玩耍并调整代码,但不能让笔记本在长时间内无人看管运行,否则运行时将关闭并丢失所有数据。

如果笔记本生成了您关心的数据,请确保在运行时关闭之前下载这些数据。要做到这一点,请点击文件图标(参见图 2-5 中的步骤 1),找到要下载的文件,点击其旁边的垂直点(步骤 2),然后点击下载(步骤 3)。或者,您可以在运行时挂载您的谷歌驱动器,使笔记本能够直接读写文件到谷歌驱动器,就像它是一个本地目录一样。为此,请点击文件图标(步骤 1),然后点击谷歌驱动器图标(在图 2-5 中用圈圈圈出)并按照屏幕上的说明操作。

mls3 0205

图 2-5。从 Google Colab 运行时下载文件(步骤 1 至 3),或挂载您的谷歌驱动器(圈圈图标)

默认情况下,您的谷歌驱动器将挂载在/content/drive/MyDrive。如果要备份数据文件,只需通过运行!cp /content/my_great_model /content/drive/MyDrive将其复制到此目录。任何以感叹号(!)开头的命令都被视为 shell 命令,而不是 Python 代码:cp是 Linux shell 命令,用于将文件从一个路径复制到另一个路径。请注意,Colab 运行时在 Linux 上运行(具体来说是 Ubuntu)。

交互性的力量和危险

Jupyter 笔记本是交互式的,这是一件好事:您可以逐个运行每个单元格,在任何时候停止,插入单元格,玩弄代码,返回并再次运行相同的单元格等等,我强烈鼓励您这样做。如果您只是逐个运行单元格而从不玩弄它们,您学习速度不会那么快。然而,这种灵活性是有代价的:很容易按错误的顺序运行单元格,或者忘记运行一个单元格。如果发生这种情况,后续的代码单元格很可能会失败。例如,每个笔记本中的第一个代码单元格包含设置代码(如导入),因此请确保首先运行它,否则什么都不会起作用。

提示

如果您遇到奇怪的错误,请尝试重新启动运行时(通过选择运行时→“重新启动运行时”菜单)然后再次从笔记本开头运行所有单元格。这通常可以解决问题。如果不行,很可能是您所做的更改之一破坏了笔记本:只需恢复到原始笔记本并重试。如果仍然失败,请在 GitHub 上提交问题。

书中代码与笔记本代码

您有时可能会注意到本书中的代码与笔记本中的代码之间存在一些小差异。这可能是由于以下几个原因:

  • 图书馆可能在您阅读这些文字时略有变化,或者尽管我尽力了,但书中可能存在错误。遗憾的是,我不能在您的这本书中神奇地修复代码(除非您正在阅读电子副本并且可以下载最新版本),但我可以修复笔记本。因此,如果您在从本书中复制代码后遇到错误,请查找笔记本中的修复代码:我将努力保持它们没有错误,并与最新的库版本保持同步。

  • 笔记本包含一些额外的代码来美化图形(添加标签,设置字体大小等)并将它们保存为高分辨率以供本书使用。如果您愿意,可以安全地忽略这些额外的代码。

我优化了代码以提高可读性和简单性:我尽可能将其线性化和扁平化,定义了很少的函数或类。目标是确保您运行的代码通常就在您眼前,而不是嵌套在几层抽象中,需要搜索。这也使您更容易玩弄代码。为简单起见,我没有进行太多的错误处理,并且将一些不常见的导入放在需要它们的地方(而不是像 PEP 8 Python 风格指南建议的那样将它们放在文件顶部)。也就是说,您的生产代码不会有太大的不同:只是更模块化,还有额外的测试和错误处理。

好了!一旦您熟悉了 Colab,您就可以下载数据了。

下载数据

在典型环境中,您的数据可能存储在关系数据库或其他常见数据存储中,并分布在多个表/文档/文件中。要访问它,您首先需要获取您的凭据和访问授权,并熟悉数据模式。然而,在这个项目中,情况要简单得多:您只需下载一个压缩文件housing.tgz,其中包含一个名为housing.csv的逗号分隔值(CSV)文件,其中包含所有数据。

与手动下载和解压数据相比,通常最好编写一个函数来执行此操作。特别是如果数据经常更改,这将非常有用:您可以编写一个小脚本,使用该函数获取最新数据(或者您可以设置定期自动执行此操作的计划任务)。自动获取数据的过程也很有用,如果您需要在多台机器上安装数据集。

这是用于获取和加载数据的函数:

from pathlib import Path
import pandas as pd
import tarfile
import urllib.request

def load_housing_data():
    tarball_path = Path("datasets/housing.tgz")
    if not tarball_path.is_file():
        Path("datasets").mkdir(parents=True, exist_ok=True)
        url = "https://github.com/ageron/data/raw/main/housing.tgz"
        urllib.request.urlretrieve(url, tarball_path)
        with tarfile.open(tarball_path) as housing_tarball:
            housing_tarball.extractall(path="datasets")
    return pd.read_csv(Path("datasets/housing/housing.csv"))

housing = load_housing_data()

当调用load_housing_data()时,它会查找datasets/housing.tgz文件。如果找不到该文件,它会在当前目录内创建datasets目录(默认为/content,在 Colab 中),从ageron/data GitHub 存储库下载housing.tgz文件,并将其内容提取到datasets目录中;这将创建datasets/housing目录,并在其中包含housing.csv文件。最后,该函数将此 CSV 文件加载到一个包含所有数据的 Pandas DataFrame 对象中,并返回它。

快速查看数据结构

您可以通过使用 DataFrame 的head()方法来查看数据的前五行(请参见图 2-6)。

mls3 0206

图 2-6。数据集中的前五行

每一行代表一个地区。有 10 个属性(并非所有属性都显示在屏幕截图中):longitudelatitudehousing_median_agetotal_roomstotal_bedroomspopulationhouseholdsmedian_incomemedian_house_valueocean_proximity

info()方法对于快速获取数据的简要描述非常有用,特别是总行数、每个属性的类型和非空值的数量:

>>> housing.info()
<class 'pandas.core.frame.DataFrame'>
RangeIndex: 20640 entries, 0 to 20639
Data columns (total 10 columns):
 #   Column              Non-Null Count  Dtype
---  ------              --------------  -----
 0   longitude           20640 non-null  float64
 1   latitude            20640 non-null  float64
 2   housing_median_age  20640 non-null  float64
 3   total_rooms         20640 non-null  float64
 4   total_bedrooms      20433 non-null  float64
 5   population          20640 non-null  float64
 6   households          20640 non-null  float64
 7   median_income       20640 non-null  float64
 8   median_house_value  20640 non-null  float64
 9   ocean_proximity     20640 non-null  object
dtypes: float64(9), object(1)
memory usage: 1.6+ MB
注意

在本书中,当代码示例包含代码和输出混合时,就像这里一样,它的格式类似于 Python 解释器,以便更好地阅读:代码行以>>>(或缩进块的情况下为...)为前缀,输出没有前缀。

数据集中有 20,640 个实例,这意味着按照机器学习的标准来说,它相当小,但是非常适合入门。您会注意到total_bedrooms属性只有 20,433 个非空值,这意味着有 207 个地区缺少这个特征。您需要稍后处理这个问题。

所有属性都是数字的,除了ocean_proximity。它的类型是object,因此它可以保存任何类型的 Python 对象。但是由于您从 CSV 文件中加载了这些数据,您知道它必须是一个文本属性。当您查看前五行时,您可能会注意到ocean_proximity列中的值是重复的,这意味着它可能是一个分类属性。您可以使用value_counts()方法找出存在哪些类别以及每个类别包含多少个地区:

>>> housing["ocean_proximity"].value_counts()
<1H OCEAN     9136
INLAND        6551
NEAR OCEAN    2658
NEAR BAY      2290
ISLAND           5
Name: ocean_proximity, dtype: int64

让我们看看其他字段。describe()方法显示了数字属性的摘要(图 2-7)。

mls3 0207

图 2-7。每个数字属性的总结

countmeanminmax行是不言自明的。请注意,空值被忽略了(因此,例如,total_bedroomscount是 20,433,而不是 20,640)。std行显示了标准差,它衡量了值的分散程度。⁠⁵ 25%50%75%行显示了相应的百分位数:百分位数表示给定百分比的观察值中有多少落在一组观察值之下。例如,25%的地区的housing_median_age低于 18,而 50%低于 29,75%低于 37。这些通常被称为第 25 百分位数(或第一个四分位数)、中位数和第 75 百分位数(或第三四分位数)。

另一种快速了解您正在处理的数据类型的方法是为每个数字属性绘制直方图。直方图显示了具有给定值范围的实例数量(在水平轴上)(在垂直轴上)。您可以一次绘制一个属性,也可以在整个数据集上调用hist()方法(如下面的代码示例所示),它将为每个数字属性绘制一个直方图(参见图 2-8):

import matplotlib.pyplot as plt

housing.hist(bins=50, figsize=(12, 8))
plt.show()

mls3 0208

图 2-8。每个数字属性的直方图

查看这些直方图,您会注意到一些事情:

  • 首先,中位收入属性看起来不像是以美元(USD)表示的。在与收集数据的团队核实后,他们告诉您数据已经被缩放,并且对于更高的中位收入,已经被限制在 15(实际上是 15.0001),对于更低的中位收入,已经被限制在 0.5(实际上是 0.4999)。这些数字大致表示数万美元(例如,3 实际上表示约 30,000 美元)。在机器学习中使用预处理的属性是很常见的,这并不一定是问题,但您应该尝试了解数据是如何计算的。

  • 房屋中位年龄和房屋中位价值也被限制了。后者可能是一个严重的问题,因为它是您的目标属性(您的标签)。您的机器学习算法可能会学习到价格永远不会超过那个限制。您需要与客户团队(将使用您系统输出的团队)核实,看看这是否是一个问题。如果他们告诉您他们需要精确的预测,甚至超过 50 万美元,那么您有两个选择:

    • 收集那些标签被限制的地区的正确标签。

    • 从训练集中删除这些地区(也从测试集中删除,因为如果您的系统预测超过 50 万美元的值,它不应该被评估不良)。

  • 这些属性具有非常不同的比例。当我们探索特征缩放时,我们将在本章后面讨论这一点。

  • 最后,许多直方图是右偏的:它们在中位数的右侧延伸得更远,而不是左侧。这可能会使一些机器学习算法更难检测到模式。稍后,您将尝试转换这些属性,使其具有更对称和钟形分布。

现在您应该对您正在处理的数据有更好的了解。

警告

等等!在进一步查看数据之前,您需要创建一个测试集,将其放在一边,永远不要查看它。

创建一个测试集

在这个阶段自愿设置一部分数据似乎有点奇怪。毕竟,您只是快速浏览了数据,而在决定使用什么算法之前,您肯定应该更多地了解它,对吧?这是真的,但您的大脑是一个惊人的模式检测系统,这也意味着它很容易过拟合:如果您查看测试集,您可能会在测试数据中发现一些看似有趣的模式,导致您选择特定类型的机器学习模型。当您使用测试集估计泛化误差时,您的估计将过于乐观,您将启动一个性能不如预期的系统。这被称为数据窥探偏差。

创建测试集在理论上很简单;随机选择一些实例,通常是数据集的 20%(如果您的数据集非常大,则可以更少),并将它们放在一边:

import numpy as np

def shuffle_and_split_data(data, test_ratio):
    shuffled_indices = np.random.permutation(len(data))
    test_set_size = int(len(data) * test_ratio)
    test_indices = shuffled_indices[:test_set_size]
    train_indices = shuffled_indices[test_set_size:]
    return data.iloc[train_indices], data.iloc[test_indices]

然后,您可以像这样使用这个函数:

>>> train_set, test_set = shuffle_and_split_data(housing, 0.2)
>>> len(train_set)
16512
>>> len(test_set)
4128

好吧,这样做是有效的,但并不完美:如果再次运行程序,它将生成不同的测试集!随着时间的推移,您(或您的机器学习算法)将看到整个数据集,这是您要避免的。

一种解决方案是在第一次运行时保存测试集,然后在后续运行中加载它。另一个选项是在调用np.random.permutation()之前设置随机数生成器的种子(例如,使用np.random.seed(42))⁠^([6](ch02.html#idm45720239285936)),以便它始终生成相同的洗牌索引。

然而,这两种解决方案在获取更新的数据集后会失效。为了在更新数据集后仍然保持稳定的训练/测试分割,一个常见的解决方案是使用每个实例的标识符来决定是否应该放入测试集中(假设实例具有唯一且不可变的标识符)。例如,您可以计算每个实例标识符的哈希值,并且如果哈希值低于或等于最大哈希值的 20%,则将该实例放入测试集中。这确保了测试集将在多次运行中保持一致,即使您刷新数据集。新的测试集将包含新实例的 20%,但不会包含以前在训练集中的任何实例。

这是一个可能的实现:

from zlib import crc32

def is_id_in_test_set(identifier, test_ratio):
    return crc32(np.int64(identifier)) < test_ratio * 2**32

def split_data_with_id_hash(data, test_ratio, id_column):
    ids = data[id_column]
    in_test_set = ids.apply(lambda id_: is_id_in_test_set(id_, test_ratio))
    return data.loc[~in_test_set], data.loc[in_test_set]

不幸的是,住房数据集没有标识符列。最简单的解决方案是使用行索引作为 ID:

housing_with_id = housing.reset_index()  # adds an `index` column
train_set, test_set = split_data_with_id_hash(housing_with_id, 0.2, "index")

如果使用行索引作为唯一标识符,您需要确保新数据附加到数据集的末尾,并且永远不会删除任何行。如果这不可能,那么您可以尝试使用最稳定的特征来构建唯一标识符。例如,一个地区的纬度和经度保证在几百万年内保持稳定,因此您可以将它们组合成一个 ID,如下所示:⁠^([7](ch02.html#idm45720242437568))

housing_with_id["id"] = housing["longitude"] * 1000 + housing["latitude"]
train_set, test_set = split_data_with_id_hash(housing_with_id, 0.2, "id")

Scikit-Learn 提供了一些函数,以各种方式将数据集拆分为多个子集。最简单的函数是train_test_split(),它基本上与我们之前定义的shuffle_and_split_data()函数做的事情差不多,但有一些额外的功能。首先,有一个random_state参数,允许您设置随机生成器种子。其次,您可以传递多个具有相同行数的数据集,并且它将在相同的索引上拆分它们(例如,如果您有一个单独的标签 DataFrame,这是非常有用的):

from sklearn.model_selection import train_test_split

train_set, test_set = train_test_split(housing, test_size=0.2, random_state=42)

到目前为止,我们已经考虑了纯随机抽样方法。如果您的数据集足够大(尤其是相对于属性数量),这通常是可以接受的,但如果不是,您就有可能引入显著的抽样偏差。当调查公司的员工决定打电话给 1,000 人询问一些问题时,他们不会仅仅在电话簿中随机挑选 1,000 人。他们会努力确保这 1,000 人在问题上代表整个人口。例如,美国人口中 51.1%是女性,48.9%是男性,因此在美国进行良好的调查将尝试保持这个比例在样本中:511 名女性和 489 名男性(至少如果可能的话,答案可能会因性别而有所不同)。这被称为分层抽样:人口被划分为称为的同质子群,从每个层中抽取正确数量的实例以确保测试集代表整体人口。如果进行调查的人员使用纯随机抽样,那么抽取一个偏斜的测试集,女性参与者少于 48.5%或多于 53.5%的概率约为 10.7%。无论哪种方式,调查结果可能会相当偏倚。

假设您与一些专家交谈过,他们告诉您,中位收入是预测中位房价的一个非常重要的属性。您可能希望确保测试集代表整个数据集中各种收入类别。由于中位收入是一个连续的数值属性,您首先需要创建一个收入类别属性。让我们更仔细地看一下中位收入直方图(回到图 2-8):大多数中位收入值聚集在 1.5 到 6 之间(即 15,000 美元至 60,000 美元),但有些中位收入远远超过 6。对于每个层,您的数据集中应该有足够数量的实例,否则对层重要性的估计可能会有偏差。这意味着您不应该有太多层,并且每个层应该足够大。以下代码使用pd.cut()函数创建一个具有五个类别(从 1 到 5 标记)的收入类别属性;类别 1 范围从 0 到 1.5(即 15,000 美元以下),类别 2 从 1.5 到 3,依此类推:

housing["income_cat"] = pd.cut(housing["median_income"],
                               bins=[0., 1.5, 3.0, 4.5, 6., np.inf],
                               labels=[1, 2, 3, 4, 5])

这些收入类别在图 2-9 中表示:

housing["income_cat"].value_counts().sort_index().plot.bar(rot=0, grid=True)
plt.xlabel("Income category")
plt.ylabel("Number of districts")
plt.show()

现在您可以根据收入类别进行分层抽样。Scikit-Learn 在sklearn.model_selection包中提供了许多拆分器类,实现了各种策略来将数据集拆分为训练集和测试集。每个拆分器都有一个split()方法,返回相同数据的不同训练/测试拆分的迭代器。

mls3 0209

图 2-9。收入类别直方图

要准确,split()方法产生训练和测试索引,而不是数据本身。如果您想更好地估计模型的性能,拥有多个拆分可能是有用的,这将在本章后面讨论交叉验证时看到。例如,以下代码生成了同一数据集的 10 个不同的分层拆分:

from sklearn.model_selection import StratifiedShuffleSplit

splitter = StratifiedShuffleSplit(n_splits=10, test_size=0.2, random_state=42)
strat_splits = []
for train_index, test_index in splitter.split(housing, housing["income_cat"]):
    strat_train_set_n = housing.iloc[train_index]
    strat_test_set_n = housing.iloc[test_index]
    strat_splits.append([strat_train_set_n, strat_test_set_n])

现在,您可以使用第一个拆分:

strat_train_set, strat_test_set = strat_splits[0]

或者,由于分层抽样相当常见,可以使用train_test_split()函数的stratify参数来更快地获得单个拆分:

strat_train_set, strat_test_set = train_test_split(
    housing, test_size=0.2, stratify=housing["income_cat"], random_state=42)

让我们看看这是否按预期工作。您可以从测试集中查看收入类别的比例:

>>> strat_test_set["income_cat"].value_counts() / len(strat_test_set)
3    0.350533
2    0.318798
4    0.176357
5    0.114341
1    0.039971
Name: income_cat, dtype: float64

使用类似的代码,您可以测量完整数据集中的收入类别比例。图 2-10 比较了整体数据集中的收入类别比例,使用分层抽样生成的测试集以及使用纯随机抽样生成的测试集。如您所见,使用分层抽样生成的测试集的收入类别比例几乎与完整数据集中的相同,而使用纯随机抽样生成的测试集则有所偏差。

mls3 0210

图 2-10。分层与纯随机抽样的抽样偏差比较

您不会再使用income_cat列,因此可以将其删除,将数据恢复到原始状态:

for set_ in (strat_train_set, strat_test_set):
    set_.drop("income_cat", axis=1, inplace=True)

我们花了相当多的时间在测试集生成上,这是一个经常被忽视但至关重要的机器学习项目的关键部分。此外,当我们讨论交叉验证时,许多这些想法将在以后派上用场。现在是时候进入下一个阶段了:探索数据。

探索和可视化数据以获得洞见

到目前为止,您只是快速浏览了数据,以对您正在处理的数据类型有一个大致了解。现在的目标是深入一点。

首先,请确保您已经将测试集放在一边,只探索训练集。此外,如果训练集非常庞大,您可能希望对探索集进行抽样,以便在探索阶段进行操作更加轻松和快速。在这种情况下,训练集相当小,因此您可以直接在完整集上工作。由于您将尝试对完整训练集进行各种转换,因此应该先复制原始数据,以便之后可以恢复到原始状态:

housing = strat_train_set.copy()

可视化地理数据

由于数据集包含地理信息(纬度和经度),因此创建一个散点图来可视化所有地区是一个好主意(图 2-11):

housing.plot(kind="scatter", x="longitude", y="latitude", grid=True)
plt.show()

mls3 0211

图 2-11。数据的地理散点图

这看起来确实像加利福尼亚,但除此之外很难看出任何特定的模式。将alpha选项设置为0.2可以更容易地可视化数据点密度较高的地方(图 2-12):

housing.plot(kind="scatter", x="longitude", y="latitude", grid=True, alpha=0.2)
plt.show()

现在好多了:您可以清楚地看到高密度区域,即旧金山湾区、洛杉矶周围和圣迭戈周围,以及中央谷地(特别是萨克拉门托和弗雷斯诺周围)的一长串相当高密度区域。

我们的大脑非常擅长发现图片中的模式,但您可能需要调整可视化参数来突出这些模式。

mls3 0212

图 2-12。一个更好的可视化,突出显示高密度区域

接下来,您将查看房屋价格(图 2-13)。每个圆的半径代表地区的人口(选项s),颜色代表价格(选项c)。在这里,您使用了一个预定义的颜色映射(选项cmap)称为jet,从蓝色(低值)到红色(高价格):⁠⁸

housing.plot(kind="scatter", x="longitude", y="latitude", grid=True,
             s=housing["population"] / 100, label="population",
             c="median_house_value", cmap="jet", colorbar=True,
             legend=True, sharex=False, figsize=(10, 7))
plt.show()

这幅图告诉你,房价与位置(例如靠近海洋)和人口密度密切相关,这可能是你已经知道的。聚类算法应该对检测主要集群和添加衡量到集群中心距离的新特征很有用。海洋接近度属性也可能有用,尽管在加利福尼亚北部,沿海地区的房价并不太高,所以这并不是一个简单的规则。

mls3 0213

图 2-13. 加利福尼亚房价:红色是昂贵的,蓝色是便宜的,较大的圆圈表示人口较多的地区

寻找相关性

由于数据集不太大,你可以使用corr()方法轻松计算每对属性之间的标准相关系数(也称为皮尔逊相关系数):

corr_matrix = housing.corr()

现在你可以看看每个属性与房屋中位价值的相关程度:

>>> corr_matrix["median_house_value"].sort_values(ascending=False)
median_house_value    1.000000
median_income         0.688380
total_rooms           0.137455
housing_median_age    0.102175
households            0.071426
total_bedrooms        0.054635
population           -0.020153
longitude            -0.050859
latitude             -0.139584
Name: median_house_value, dtype: float64

相关系数的范围是-1 到 1。当它接近 1 时,意味着有很强的正相关性;例如,当中位收入上升时,中位房价往往会上涨。当系数接近-1 时,意味着有很强的负相关性;你可以看到纬度和中位房价之间存在微弱的负相关性(即,当你向北走时,价格略有下降的趋势)。最后,接近 0 的系数意味着没有线性相关性。

检查属性之间的相关性的另一种方法是使用 Pandas 的scatter_matrix()函数,它将每个数值属性与其他每个数值属性绘制在一起。由于现在有 11 个数值属性,你将得到 11² = 121 个图,这些图无法放在一页上,所以你决定专注于一些看起来与房屋中位价值最相关的有希望的属性(图 2-14):

from pandas.plotting import scatter_matrix

attributes = ["median_house_value", "median_income", "total_rooms",
              "housing_median_age"]
scatter_matrix(housing[attributes], figsize=(12, 8))
plt.show()

mls3 0214

图 2-14. 这个散点矩阵将每个数值属性与其他每个数值属性绘制在一起,主对角线上是每个数值属性值的直方图(从左上到右下)

如果 Pandas 将每个变量与自身绘制在一起,主对角线将充满直线,这将没有太大用处。因此,Pandas 显示了每个属性的直方图(还有其他选项可用;请参阅 Pandas 文档以获取更多详细信息)。

看着相关性散点图,似乎最有希望预测房屋中位价值的属性是中位收入,所以你放大了它们的散点图(图 2-15):

housing.plot(kind="scatter", x="median_income", y="median_house_value",
             alpha=0.1, grid=True)
plt.show()

mls3 0215

图 2-15. 中位收入与中位房价

这个图揭示了一些事情。首先,相关性确实非常强;你可以清楚地看到上升趋势,点的分散程度也不大。其次,你之前注意到的价格上限在$500,000 处清晰可见。但这个图还显示了其他不太明显的直线:一个在$450,000 左右的水平线,另一个在$350,000 左右,也许还有一个在$280,000 左右,以及一些更低的线。你可能想尝试删除相应的地区,以防止你的算法学习复制这些数据怪癖。

警告

相关系数只能测量线性相关性(“随着x的增加,y通常上升/下降”)。它可能完全忽略非线性关系(例如,“随着x接近 0,y通常上升”)。图 2-16 展示了各种数据集以及它们的相关系数。请注意,尽管底部行的所有图都具有相关系数为 0,但它们的坐标轴显然是独立的:这些是非线性关系的示例。此外,第二行显示了相关系数等于 1 或-1 的示例;请注意,这与斜率无关。例如,您的身高以英寸为单位与以英尺或纳米为单位的身高的相关系数为 1。

mls3 0216

图 2-16. 各种数据集的标准相关系数(来源:维基百科;公共领域图像)

尝试属性组合

希望前面的部分给您提供了一些探索数据和获得见解的方法。您发现了一些可能需要在将数据馈送到机器学习算法之前清理的数据怪癖,并且找到了属性之间的有趣相关性,特别是与目标属性相关的。您还注意到一些属性具有右偏分布,因此您可能希望对其进行转换(例如,通过计算其对数或平方根)。当然,每个项目的情况会有很大不同,但总体思路是相似的。

在准备数据用于机器学习算法之前,您可能希望尝试各种属性组合。例如,一个地区的总房间数如果不知道有多少户,就不是很有用。您真正想要的是每户的房间数。同样,单独的卧室总数并不是很有用:您可能想要将其与房间数进行比较。每户人口也似乎是一个有趣的属性组合。您可以按照以下方式创建这些新属性:

housing["rooms_per_house"] = housing["total_rooms"] / housing["households"]
housing["bedrooms_ratio"] = housing["total_bedrooms"] / housing["total_rooms"]
housing["people_per_house"] = housing["population"] / housing["households"]

然后再次查看相关矩阵:

>>> corr_matrix = housing.corr()
>>> corr_matrix["median_house_value"].sort_values(ascending=False)
median_house_value    1.000000
median_income         0.688380
rooms_per_house       0.143663
total_rooms           0.137455
housing_median_age    0.102175
households            0.071426
total_bedrooms        0.054635
population           -0.020153
people_per_house     -0.038224
longitude            -0.050859
latitude             -0.139584
bedrooms_ratio       -0.256397
Name: median_house_value, dtype: float64

嘿,不错!新的bedrooms_ratio属性与房屋中位数的相关性要比总房间数或卧室总数高得多。显然,卧室/房间比例较低的房屋往往更昂贵。每户房间数也比一个地区的总房间数更具信息量——显然,房屋越大,价格越高。

这一轮探索不必绝对彻底;重点是要以正确的方式开始,并迅速获得有助于获得第一个相当不错原型的见解。但这是一个迭代过程:一旦您建立起一个原型并使其运行起来,您可以分析其输出以获得更多见解,并回到这一探索步骤。

为机器学习算法准备数据

现在是为您的机器学习算法准备数据的时候了。您应该为此目的编写函数,而不是手动操作,有几个很好的理由:

  • 这将使您能够轻松在任何数据集上重现这些转换(例如,下次获取新数据集时)。

  • 您将逐渐构建一个转换函数库,可以在将来的项目中重复使用。

  • 您可以在实时系统中使用这些函数,在将新数据馈送到算法之前对其进行转换。

  • 这将使您能够轻松尝试各种转换,并查看哪种转换组合效果最好。

但首先,恢复到一个干净的训练集(再次复制strat_train_set)。您还应该分开预测变量和标签,因为您不一定希望对预测变量和目标值应用相同的转换(请注意,drop()会创建数据的副本,不会影响strat_train_set):

housing = strat_train_set.drop("median_house_value", axis=1)
housing_labels = strat_train_set["median_house_value"].copy()

清理数据

大多数机器学习算法无法处理缺失特征,因此您需要处理这些问题。例如,您之前注意到total_bedrooms属性有一些缺失值。您有三种选项来解决这个问题:

  1. 摆脱相应的地区。

  2. 摆脱整个属性。

  3. 将缺失值设置为某个值(零、均值、中位数等)。这称为填充

使用 Pandas DataFrame 的dropna()drop()fillna()方法可以轻松实现这些功能:

housing.dropna(subset=["total_bedrooms"], inplace=True)  # option 1

housing.drop("total_bedrooms", axis=1)  # option 2

median = housing["total_bedrooms"].median()  # option 3
housing["total_bedrooms"].fillna(median, inplace=True)

您决定选择第 3 个选项,因为它是最不破坏性的,但是不使用前面的代码,而是使用一个方便的 Scikit-Learn 类:SimpleImputer。好处是它将存储每个特征的中位数值:这将使得不仅可以在训练集上填补缺失值,还可以在验证集、测试集和任何输入到模型的新数据上填补缺失值。要使用它,首先需要创建一个SimpleImputer实例,指定您要用该属性的中位数替换每个属性的缺失值:

from sklearn.impute import SimpleImputer

imputer = SimpleImputer(strategy="median")

由于中位数只能计算在数值属性上,因此您需要创建一个仅包含数值属性的数据副本(这将排除文本属性ocean_proximity):

housing_num = housing.select_dtypes(include=[np.number])

现在,您可以使用fit()方法将imputer实例拟合到训练数据中:

imputer.fit(housing_num)

imputer只是计算了每个属性的中位数,并将结果存储在其statistics_实例变量中。只有total_bedrooms属性有缺失值,但是您不能确定系统上线后新数据中是否会有缺失值,因此最好将imputer应用于所有数值属性:

>>> imputer.statistics_
array([-118.51 , 34.26 , 29\. , 2125\. , 434\. , 1167\. , 408\. , 3.5385])
>>> housing_num.median().values
array([-118.51 , 34.26 , 29\. , 2125\. , 434\. , 1167\. , 408\. , 3.5385])

现在,您可以使用这个“训练好的”imputer来转换训练集,用学习到的中位数替换缺失值:

X = imputer.transform(housing_num)

缺失值也可以用均值(strategy="mean")、最频繁值(strategy="most_frequent")或常数值(strategy="constant", fill_value=…)替换。最后两种策略支持非数值数据。

提示

sklearn.impute包中还有更强大的填充器(仅适用于数值特征):

  • KNNImputer用该特征的k个最近邻值的均值替换每个缺失值。距离基于所有可用的特征。

  • IterativeImputer为每个特征训练一个回归模型,以预测基于所有其他可用特征的缺失值。然后,它再次在更新后的数据上训练模型,并在每次迭代中重复这个过程,改进模型和替换值。

Scikit-Learn 转换器输出 NumPy 数组(有时候也输出 SciPy 稀疏矩阵),即使它们以 Pandas DataFrame 作为输入。因此,imputer.transform(housing_num)的输出是一个 NumPy 数组:X既没有列名也没有索引。幸运的是,将X包装在 DataFrame 中并从housing_num中恢复列名和索引并不太困难:

housing_tr = pd.DataFrame(X, columns=housing_num.columns,
                          index=housing_num.index)

处理文本和分类属性

到目前为止,我们只处理了数值属性,但是您的数据可能还包含文本属性。在这个数据集中,只有一个:ocean_proximity属性。让我们看一下前几个实例的值:

>>> housing_cat = housing[["ocean_proximity"]]
>>> housing_cat.head(8)
 ocean_proximity
13096        NEAR BAY
14973       <1H OCEAN
3785           INLAND
14689          INLAND
20507      NEAR OCEAN
1286           INLAND
18078       <1H OCEAN
4396         NEAR BAY

这不是任意的文本:可能的值有限,每个值代表一个类别。因此,这个属性是一个分类属性。大多数机器学习算法更喜欢使用数字,所以让我们将这些类别从文本转换为数字。为此,我们可以使用 Scikit-Learn 的OrdinalEncoder类:

from sklearn.preprocessing import OrdinalEncoder

ordinal_encoder = OrdinalEncoder()
housing_cat_encoded = ordinal_encoder.fit_transform(housing_cat)

这是housing_cat_encoded中前几个编码值的样子:

>>> housing_cat_encoded[:8]
array([[3.],
 [0.],
 [1.],
 [1.],
 [4.],
 [1.],
 [0.],
 [3.]])

您可以使用categories_实例变量获取类别列表。它是一个包含每个分类属性的类别的 1D 数组的列表(在这种情况下,由于只有一个分类属性,因此包含一个单一数组的列表):

>>> ordinal_encoder.categories_
[array(['<1H OCEAN', 'INLAND', 'ISLAND', 'NEAR BAY', 'NEAR OCEAN'],
 dtype=object)]

这种表示的一个问题是,机器学习算法会假设两个相邻的值比两个远离的值更相似。在某些情况下这可能没问题(例如,对于有序类别如“bad”、“average”、“good”和“excellent”),但显然不适用于ocean_proximity列(例如,类别 0 和 4 明显比类别 0 和 1 更相似)。为了解决这个问题,一个常见的解决方案是为每个类别创建一个二进制属性:当类别是"<1H OCEAN"时一个属性等于 1(否则为 0),当类别是"INLAND"时另一个属性等于 1(否则为 0),依此类推。这被称为独热编码,因为只有一个属性将等于 1(热),而其他属性将等于 0(冷)。新属性有时被称为虚拟属性。Scikit-Learn 提供了一个OneHotEncoder类来将分类值转换为独热向量:

from sklearn.preprocessing import OneHotEncoder

cat_encoder = OneHotEncoder()
housing_cat_1hot = cat_encoder.fit_transform(housing_cat)

默认情况下,OneHotEncoder的输出是 SciPy 的稀疏矩阵,而不是 NumPy 数组:

>>> housing_cat_1hot
<16512x5 sparse matrix of type '<class 'numpy.float64'>'
 with 16512 stored elements in Compressed Sparse Row format>

稀疏矩阵是包含大部分零的矩阵的非常高效的表示。实际上,它内部只存储非零值及其位置。当一个分类属性有数百或数千个类别时,对其进行独热编码会导致一个非常大的矩阵,除了每行一个单独的 1 之外,其他都是 0。在这种情况下,稀疏矩阵正是你需要的:它将节省大量内存并加快计算速度。你可以像使用普通的 2D 数组一样使用稀疏矩阵,但如果你想将其转换为(密集的)NumPy 数组,只需调用toarray()方法:

>>> housing_cat_1hot.toarray()
array([[0., 0., 0., 1., 0.],
 [1., 0., 0., 0., 0.],
 [0., 1., 0., 0., 0.],
 ...,
 [0., 0., 0., 0., 1.],
 [1., 0., 0., 0., 0.],
 [0., 0., 0., 0., 1.]])

或者,当创建OneHotEncoder时设置sparse=False,在这种情况下,transform()方法将直接返回一个常规(密集的)NumPy 数组。

OrdinalEncoder一样,你可以使用编码器的categories_实例变量获取类别列表:

>>> cat_encoder.categories_
[array(['<1H OCEAN', 'INLAND', 'ISLAND', 'NEAR BAY', 'NEAR OCEAN'],
 dtype=object)]

Pandas 有一个名为get_dummies()的函数,它也将每个分类特征转换为一种独热表示,每个类别一个二进制特征:

>>> df_test = pd.DataFrame({"ocean_proximity": ["INLAND", "NEAR BAY"]})
>>> pd.get_dummies(df_test)
 ocean_proximity_INLAND  ocean_proximity_NEAR BAY
0                       1                         0
1                       0                         1

看起来很简单漂亮,那为什么不使用它而不是OneHotEncoder呢?嗯,OneHotEncoder的优势在于它记住了它训练过的类别。这非常重要,因为一旦你的模型投入生产,它应该被喂入与训练期间完全相同的特征:不多,也不少。看看我们训练过的cat_encoder在我们让它转换相同的df_test时输出了什么(使用transform(),而不是fit_transform()):

>>> cat_encoder.transform(df_test)
array([[0., 1., 0., 0., 0.],
 [0., 0., 0., 1., 0.]])

看到区别了吗?get_dummies()只看到了两个类别,所以输出了两列,而OneHotEncoder按正确顺序输出了每个学习类别的一列。此外,如果你给get_dummies()一个包含未知类别(例如"<2H OCEAN")的 DataFrame,它会高兴地为其生成一列:

>>> df_test_unknown = pd.DataFrame({"ocean_proximity": ["<2H OCEAN", "ISLAND"]})
>>> pd.get_dummies(df_test_unknown)
 ocean_proximity_<2H OCEAN  ocean_proximity_ISLAND
0                          1                       0
1                          0                       1

但是OneHotEncoder更聪明:它会检测到未知类别并引发异常。如果你愿意,你可以将handle_unknown超参数设置为"ignore",在这种情况下,它将只用零表示未知类别:

>>> cat_encoder.handle_unknown = "ignore"
>>> cat_encoder.transform(df_test_unknown)
array([[0., 0., 0., 0., 0.],
 [0., 0., 1., 0., 0.]])
提示

如果一个分类属性有大量可能的类别(例如,国家代码,职业,物种),那么独热编码将导致大量的输入特征。这可能会减慢训练速度并降低性能。如果发生这种情况,您可能希望用与类别相关的有用的数值特征替换分类输入:例如,您可以用到海洋的距离替换ocean_proximity特征(类似地,国家代码可以用国家的人口和人均 GDP 替换)。或者,您可以使用category_encoders包在GitHub上提供的编码器之一。或者,在处理神经网络时,您可以用一个可学习的低维向量替换每个类别,称为嵌入。这是表示学习的一个例子(有关更多详细信息,请参见第十三章和第十七章)。

当您使用 DataFrame 拟合任何 Scikit-Learn 估计器时,估计器会将列名存储在feature_names_in_属性中。然后,Scikit-Learn 确保在此之后将任何 DataFrame 提供给该估计器(例如,用于transform()predict())具有相同的列名。转换器还提供了一个get_feature_names_out()方法,您可以使用它来构建围绕转换器输出的 DataFrame:

>>> cat_encoder.feature_names_in_
array(['ocean_proximity'], dtype=object)
>>> cat_encoder.get_feature_names_out()
array(['ocean_proximity_<1H OCEAN', 'ocean_proximity_INLAND',
 'ocean_proximity_ISLAND', 'ocean_proximity_NEAR BAY',
 'ocean_proximity_NEAR OCEAN'], dtype=object)
>>> df_output = pd.DataFrame(cat_encoder.transform(df_test_unknown),
...                          columns=cat_encoder.get_feature_names_out(),
...                          index=df_test_unknown.index)
...

特征缩放和转换

您需要对数据应用的最重要的转换之一是特征缩放。除了少数例外,当输入的数值属性具有非常不同的比例时,机器学习算法的表现不佳。这适用于房屋数据:房间总数的范围大约从 6 到 39,320,而中位收入的范围仅从 0 到 15。如果没有进行任何缩放,大多数模型将倾向于忽略中位收入,更多地关注房间数量。

有两种常见的方法可以使所有属性具有相同的比例:最小-最大缩放标准化

警告

与所有估计器一样,将缩放器仅适配于训练数据非常重要:永远不要对除训练集以外的任何内容使用fit()fit_transform()。一旦您有了一个经过训练的缩放器,您就可以使用它来transform()任何其他集合,包括验证集,测试集和新数据。请注意,虽然训练集的值将始终缩放到指定的范围内,但如果新数据包含异常值,则这些值可能会缩放到范围之外。如果要避免这种情况,只需将clip超参数设置为True

最小-最大缩放(许多人称之为归一化)是最简单的:对于每个属性,值被移动和重新缩放,以便最终范围为 0 到 1。这是通过减去最小值并除以最小值和最大值之间的差异来执行的。Scikit-Learn 提供了一个名为MinMaxScaler的转换器。它有一个feature_range超参数,让您更改范围,如果出于某种原因,您不想要 0-1(例如,神经网络最适合具有零均值输入,因此-1 到 1 的范围更可取)。使用起来非常简单:

from sklearn.preprocessing import MinMaxScaler

min_max_scaler = MinMaxScaler(feature_range=(-1, 1))
housing_num_min_max_scaled = min_max_scaler.fit_transform(housing_num)

标准化是不同的:首先它减去均值(因此标准化值具有零均值),然后将结果除以标准差(因此标准化值的标准差等于 1)。与最小-最大缩放不同,标准化不限制值到特定范围。然而,标准化受异常值的影响要小得多。例如,假设一个地区的中位收入等于 100(错误),而不是通常的 0-15。将最小-最大缩放到 0-1 范围会将这个异常值映射到 1,并将所有其他值压缩到 0-0.15,而标准化不会受到太大影响。Scikit-Learn 提供了一个名为StandardScaler的标准化器:

from sklearn.preprocessing import StandardScaler

std_scaler = StandardScaler()
housing_num_std_scaled = std_scaler.fit_transform(housing_num)
提示

如果要对稀疏矩阵进行缩放而不先将其转换为密集矩阵,可以使用StandardScaler,并将其with_mean超参数设置为False:它只会通过标准差除以数据,而不会减去均值(因为这样会破坏稀疏性)。

当一个特征的分布具有重尾(即远离均值的值并不呈指数稀有)时,最小-最大缩放和标准化会将大多数值压缩到一个小范围内。机器学习模型通常不喜欢这种情况,正如您将在第四章中看到的那样。因此,在对特征进行缩放之前,您应该首先对其进行转换以缩小重尾,并尽可能使分布大致对称。例如,对于具有右侧重尾的正特征,常见的做法是用其平方根替换特征(或将特征提升到 0 到 1 之间的幂)。如果特征具有非常长且重尾的分布,例如幂律分布,那么用其对数替换特征可能有所帮助。例如,population特征大致遵循幂律:拥有 1 万居民的地区只比拥有 1,000 居民的地区少 10 倍,而不是指数级别少。图 2-17 显示了当计算其对数时,这个特征看起来更好多少:它非常接近高斯分布(即钟形)。

mls3 0217

图 2-17。将特征转换为更接近高斯分布

处理重尾特征的另一种方法是将特征分桶。这意味着将其分布分成大致相等大小的桶,并用桶的索引替换每个特征值,就像我们创建income_cat特征时所做的那样(尽管我们只用它进行分层抽样)。例如,您可以用百分位数替换每个值。使用等大小的桶进行分桶会产生一个几乎均匀分布的特征,因此不需要进一步缩放,或者您可以只除以桶的数量以将值强制为 0-1 范围。

当一个特征具有多峰分布(即有两个或更多明显峰值,称为模式)时,例如housing_median_age特征,将其分桶也可能有所帮助,但这次将桶 ID 视为类别而不是数值值。这意味着桶索引必须进行编码,例如使用OneHotEncoder(因此通常不希望使用太多桶)。这种方法将使回归模型更容易学习不同范围的特征值的不同规则。例如,也许大约 35 年前建造的房屋有一种不合时宜的风格,因此它们比其年龄单独表明的要便宜。

将多模态分布转换的另一种方法是为每个模式(至少是主要的模式)添加一个特征,表示房屋年龄中位数与该特定模式之间的相似性。相似性度量通常使用径向基函数(RBF)计算 - 任何仅取决于输入值与固定点之间距离的函数。最常用的 RBF 是高斯 RBF,其输出值随着输入值远离固定点而指数衰减。例如,房龄x与 35 之间的高斯 RBF 相似性由方程 exp(–γ(x – 35)²)给出。超参数γ(gamma)确定随着x远离 35,相似性度量衰减的速度。使用 Scikit-Learn 的rbf_kernel()函数,您可以创建一个新的高斯 RBF 特征,衡量房屋年龄中位数与 35 之间的相似性:

from sklearn.metrics.pairwise import rbf_kernel

age_simil_35 = rbf_kernel(housing[["housing_median_age"]], [[35]], gamma=0.1)

图 2-18 显示了这个新特征作为房屋年龄中位数的函数(实线)。它还显示了如果使用较小的gamma值,该特征会是什么样子。正如图表所示,新的年龄相似性特征在 35 岁时达到峰值,正好在房屋年龄中位数分布的峰值附近:如果这个特定年龄组与较低价格有很好的相关性,那么这个新特征很可能会有所帮助。

mls3 0218

图 2-18. 高斯 RBF 特征,衡量房屋年龄中位数与 35 之间的相似性

到目前为止,我们只看了输入特征,但目标值可能也需要转换。例如,如果目标分布具有重尾,您可能选择用其对数替换目标。但是,如果这样做,回归模型现在将预测中位数房价的对数,而不是中位数房价本身。如果您希望预测的中位数房价,您需要计算模型预测的指数。

幸运的是,大多数 Scikit-Learn 的转换器都有一个inverse_transform()方法,使得计算其转换的逆变换变得容易。例如,以下代码示例显示如何使用StandardScaler缩放标签(就像我们为输入做的那样),然后在生成的缩放标签上训练一个简单的线性回归模型,并使用它对一些新数据进行预测,然后使用训练好的缩放器的inverse_transform()方法将其转换回原始比例。请注意,我们将标签从 Pandas Series 转换为 DataFrame,因为StandardScaler需要 2D 输入。此外,在此示例中,我们只对单个原始输入特征(中位收入)进行了模型训练,以简化问题:

from sklearn.linear_model import LinearRegression

target_scaler = StandardScaler()
scaled_labels = target_scaler.fit_transform(housing_labels.to_frame())

model = LinearRegression()
model.fit(housing[["median_income"]], scaled_labels)
some_new_data = housing[["median_income"]].iloc[:5]  # pretend this is new data

scaled_predictions = model.predict(some_new_data)
predictions = target_scaler.inverse_transform(scaled_predictions)

这样做效果很好,但更简单的选择是使用TransformedTargetRegressor。我们只需要构建它,给定回归模型和标签转换器,然后在训练集上拟合它,使用原始未缩放的标签。它将自动使用转换器来缩放标签,并在生成的缩放标签上训练回归模型,就像我们之前做的那样。然后,当我们想要进行预测时,它将调用回归模型的predict()方法,并使用缩放器的inverse_transform()方法来生成预测:

from sklearn.compose import TransformedTargetRegressor

model = TransformedTargetRegressor(LinearRegression(),
                                   transformer=StandardScaler())
model.fit(housing[["median_income"]], housing_labels)
predictions = model.predict(some_new_data)

自定义转换器

尽管 Scikit-Learn 提供了许多有用的转换器,但对于自定义转换、清理操作或组合特定属性等任务,您可能需要编写自己的转换器。

对于不需要任何训练的转换,您可以编写一个函数,该函数以 NumPy 数组作为输入,并输出转换后的数组。例如,如前一节所讨论的,通常最好通过用其对数替换具有重尾分布的特征(假设特征为正且尾部在右侧)来转换特征。让我们创建一个对数转换器,并将其应用于population特征:

from sklearn.preprocessing import FunctionTransformer

log_transformer = FunctionTransformer(np.log, inverse_func=np.exp)
log_pop = log_transformer.transform(housing[["population"]])

inverse_func参数是可选的。它允许您指定一个逆转换函数,例如,如果您计划在TransformedTargetRegressor中使用您的转换器。

您的转换函数可以将超参数作为额外参数。例如,以下是如何创建一个与之前相同的高斯 RBF 相似性度量的转换器:

rbf_transformer = FunctionTransformer(rbf_kernel,
                                      kw_args=dict(Y=[[35.]], gamma=0.1))
age_simil_35 = rbf_transformer.transform(housing[["housing_median_age"]])

请注意,RBF 核函数没有逆函数,因为在固定点的给定距离处始终存在两个值(除了距离为 0 的情况)。还要注意,rbf_kernel()不会单独处理特征。如果您传递一个具有两个特征的数组,它将测量 2D 距离(欧几里得距离)以衡量相似性。例如,以下是如何添加一个特征,用于衡量每个地区与旧金山之间的地理相似性:

sf_coords = 37.7749, -122.41
sf_transformer = FunctionTransformer(rbf_kernel,
                                     kw_args=dict(Y=[sf_coords], gamma=0.1))
sf_simil = sf_transformer.transform(housing[["latitude", "longitude"]])

自定义转换器也可用于组合特征。例如,这里有一个FunctionTransformer,计算输入特征 0 和 1 之间的比率:

>>> ratio_transformer = FunctionTransformer(lambda X: X[:, [0]] / X[:, [1]])
>>> ratio_transformer.transform(np.array([[1., 2.], [3., 4.]]))
array([[0.5 ],
 [0.75]])

FunctionTransformer非常方便,但如果您希望您的转换器是可训练的,在fit()方法中学习一些参数,并在transform()方法中稍后使用它们,该怎么办?为此,您需要编写一个自定义类。Scikit-Learn 依赖于鸭子类型,因此这个类不必继承自任何特定的基类。它只需要三个方法:fit()(必须返回self)、transform()fit_transform()

只需将TransformerMixin作为基类添加进去,就可以免费获得fit_transform():默认实现只会调用fit(),然后调用transform()。如果将BaseEstimator作为基类(并且避免在构造函数中使用*args**kwargs),还会获得两个额外的方法:get_params()set_params()。这些对于自动超参数调整会很有用。

例如,这里有一个自定义的转换器,它的功能类似于StandardScaler

from sklearn.base import BaseEstimator, TransformerMixin
from sklearn.utils.validation import check_array, check_is_fitted

class StandardScalerClone(BaseEstimator, TransformerMixin):
    def __init__(self, with_mean=True):  # no *args or **kwargs!
        self.with_mean = with_mean

    def fit(self, X, y=None):  # y is required even though we don't use it
        X = check_array(X)  # checks that X is an array with finite float values
        self.mean_ = X.mean(axis=0)
        self.scale_ = X.std(axis=0)
        self.n_features_in_ = X.shape[1]  # every estimator stores this in fit()
        return self  # always return self!

    def transform(self, X):
        check_is_fitted(self)  # looks for learned attributes (with trailing _)
        X = check_array(X)
        assert self.n_features_in_ == X.shape[1]
        if self.with_mean:
            X = X - self.mean_
        return X / self.scale_

以下是一些需要注意的事项:

  • sklearn.utils.validation包含了几个我们可以用来验证输入的函数。为简单起见,我们将在本书的其余部分跳过这些测试,但生产代码应该包含它们。

  • Scikit-Learn 管道要求fit()方法有两个参数Xy,这就是为什么我们需要y=None参数,即使我们不使用y

  • 所有 Scikit-Learn 估计器在fit()方法中设置n_features_in_,并确保传递给transform()predict()的数据具有这个特征数量。

  • fit()方法必须返回self

  • 这个实现并不完全:所有的估计器在传入 DataFrame 时应该在fit()方法中设置feature_names_in_。此外,所有的转换器应该提供一个get_feature_names_out()方法,以及一个inverse_transform()方法,当它们的转换可以被逆转时。更多细节请参考本章末尾的最后一个练习。

一个自定义转换器可以(并经常)在其实现中使用其他估计器。例如,以下代码演示了一个自定义转换器,在fit()方法中使用KMeans聚类器来识别训练数据中的主要聚类,然后在transform()方法中使用rbf_kernel()来衡量每个样本与每个聚类中心的相似程度:

from sklearn.cluster import KMeans

class ClusterSimilarity(BaseEstimator, TransformerMixin):
    def __init__(self, n_clusters=10, gamma=1.0, random_state=None):
        self.n_clusters = n_clusters
        self.gamma = gamma
        self.random_state = random_state

    def fit(self, X, y=None, sample_weight=None):
        self.kmeans_ = KMeans(self.n_clusters, random_state=self.random_state)
        self.kmeans_.fit(X, sample_weight=sample_weight)
        return self  # always return self!

    def transform(self, X):
        return rbf_kernel(X, self.kmeans_.cluster_centers_, gamma=self.gamma)

    def get_feature_names_out(self, names=None):
        return [f"Cluster {i} similarity" for i in range(self.n_clusters)]
提示

您可以通过将一个实例传递给sklearn.utils.estimator_checks包中的check_estimator()来检查您的自定义估计器是否符合 Scikit-Learn 的 API。有关完整的 API,请查看https://scikit-learn.org/stable/developers

正如您将在第九章中看到的,k-means 是一种在数据中定位聚类的算法。它搜索的聚类数量由n_clusters超参数控制。训练后,聚类中心可以通过cluster_centers_属性获得。KMeansfit()方法支持一个可选参数sample_weight,让用户指定样本的相对权重。k-means 是一种随机算法,意味着它依赖于随机性来定位聚类,因此如果您想要可重现的结果,必须设置random_state参数。正如您所看到的,尽管任务复杂,代码还是相当简单的。现在让我们使用这个自定义转换器:

cluster_simil = ClusterSimilarity(n_clusters=10, gamma=1., random_state=42)
similarities = cluster_simil.fit_transform(housing[["latitude", "longitude"]],
                                           sample_weight=housing_labels)

这段代码创建了一个ClusterSimilarity转换器,将聚类数设置为 10。然后它使用训练集中每个区域的纬度和经度调用fit_transform(),通过每个区域的中位房价加权。转换器使用k-means 来定位聚类,然后测量每个区域与所有 10 个聚类中心之间的高斯 RBF 相似性。结果是一个矩阵,每个区域一行,每个聚类一列。让我们看一下前三行,四舍五入到两位小数:

>>> similarities[:3].round(2)
array([[0\.  , 0.14, 0\.  , 0\.  , 0\.  , 0.08, 0\.  , 0.99, 0\.  , 0.6 ],
 [0.63, 0\.  , 0.99, 0\.  , 0\.  , 0\.  , 0.04, 0\.  , 0.11, 0\.  ],
 [0\.  , 0.29, 0\.  , 0\.  , 0.01, 0.44, 0\.  , 0.7 , 0\.  , 0.3 ]])

图 2-19 显示了k-means 找到的 10 个聚类中心。根据它们与最近聚类中心的地理相似性,地区被着色。如您所见,大多数聚类位于人口稠密和昂贵的地区。

mls3 0219

图 2-19. 高斯 RBF 相似度到最近的聚类中心

转换管道

如您所见,有许多数据转换步骤需要按正确顺序执行。幸运的是,Scikit-Learn 提供了Pipeline类来帮助处理这样的转换序列。这是一个用于数值属性的小管道,它将首先填充然后缩放输入特征:

from sklearn.pipeline import Pipeline

num_pipeline = Pipeline([
    ("impute", SimpleImputer(strategy="median")),
    ("standardize", StandardScaler()),
])

Pipeline构造函数接受一系列步骤定义的名称/估计器对(2 元组)列表。名称可以是任何您喜欢的内容,只要它们是唯一的且不包含双下划线(__)。稍后在我们讨论超参数调整时,它们将很有用。所有估计器必须是转换器(即,它们必须具有fit_transform()方法),除了最后一个,它可以是任何东西:一个转换器,一个预测器或任何其他类型的估计器。

提示

在 Jupyter 笔记本中,如果import sklearn并运行sklearn.​set_config(display="diagram"),则所有 Scikit-Learn 估计器都将呈现为交互式图表。这对于可视化管道特别有用。要可视化num_pipeline,请在最后一行中运行一个包含num_pipeline的单元格。单击估计器将显示更多详细信息。

如果您不想为转换器命名,可以使用make_pipeline()函数;它将转换器作为位置参数,并使用转换器类的名称创建一个Pipeline,名称为小写且没有下划线(例如,"simpleimputer"):

from sklearn.pipeline import make_pipeline

num_pipeline = make_pipeline(SimpleImputer(strategy="median"), StandardScaler())

如果多个转换器具有相同的名称,将在它们的名称后附加索引(例如,"foo-1""foo-2"等)。

当您调用管道的fit()方法时,它会按顺序在所有转换器上调用fit_transform(),将每次调用的输出作为下一次调用的参数,直到达到最终的估计器,对于最终的估计器,它只调用fit()方法。

管道公开与最终估计器相同的方法。在这个例子中,最后一个估计器是StandardScaler,它是一个转换器,因此管道也像一个转换器。如果调用管道的transform()方法,它将顺序应用所有转换到数据。如果最后一个估计器是预测器而不是转换器,则管道将具有predict()方法而不是transform()方法。调用它将顺序应用所有转换到数据,并将结果传递给预测器的predict()方法。

让我们调用管道的fit_transform()方法,并查看输出的前两行,保留两位小数:

>>> housing_num_prepared = num_pipeline.fit_transform(housing_num)
>>> housing_num_prepared[:2].round(2)
array([[-1.42,  1.01,  1.86,  0.31,  1.37,  0.14,  1.39, -0.94],
 [ 0.6 , -0.7 ,  0.91, -0.31, -0.44, -0.69, -0.37,  1.17]])

如您之前所见,如果要恢复一个漂亮的 DataFrame,可以使用管道的get_feature_names_out()方法:

df_housing_num_prepared = pd.DataFrame(
    housing_num_prepared, columns=num_pipeline.get_feature_names_out(),
    index=housing_num.index)

管道支持索引;例如,pipeline[1]返回管道中的第二个估计器,pipeline[:-1]返回一个包含除最后一个估计器之外的所有估计器的Pipeline对象。您还可以通过steps属性访问估计器,该属性是名称/估计器对的列表,或者通过named_steps字典属性访问估计器,该属性将名称映射到估计器。例如,num_pipeline["simpleimputer"]返回名为"simpleimputer"的估计器。

到目前为止,我们已经分别处理了分类列和数值列。有一个单一的转换器可以处理所有列,对每一列应用适当的转换会更方便。为此,您可以使用ColumnTransformer。例如,以下ColumnTransformernum_pipeline(我们刚刚定义的)应用于数值属性,将cat_pipeline应用于分类属性:

from sklearn.compose import ColumnTransformer

num_attribs = ["longitude", "latitude", "housing_median_age", "total_rooms",
               "total_bedrooms", "population", "households", "median_income"]
cat_attribs = ["ocean_proximity"]

cat_pipeline = make_pipeline(
    SimpleImputer(strategy="most_frequent"),
    OneHotEncoder(handle_unknown="ignore"))

preprocessing = ColumnTransformer([
    ("num", num_pipeline, num_attribs),
    ("cat", cat_pipeline, cat_attribs),
])

首先我们导入 ColumnTransformer 类,然后定义数值和分类列名的列表,并为分类属性构建一个简单的管道。最后,我们构建一个 ColumnTransformer。它的构造函数需要一个三元组(3 元组)的列表,每个三元组包含一个名称(必须是唯一的,不包含双下划线)、一个转换器和一个应用转换器的列名(或索引)列表。

提示

如果你想要删除列,可以指定字符串 "drop",如果你想要保留列不变,可以指定 "passthrough"。默认情况下,剩余的列(即未列出的列)将被删除,但是如果你想要这些列被处理方式不同,可以将 remainder 超参数设置为任何转换器(或 "passthrough")。

由于列名的列举并不是很方便,Scikit-Learn 提供了一个 make_column_selector() 函数,返回一个选择器函数,你可以用它自动选择给定类型的所有特征,比如数值或分类。你可以将这个选择器函数传递给 ColumnTransformer,而不是列名或索引。此外,如果你不关心命名转换器,你可以使用 make_column_transformer(),它会为你选择名称,就像 make_pipeline() 一样。例如,以下代码创建了与之前相同的 ColumnTransformer,只是转换器自动命名为 "pipeline-1""pipeline-2",而不是 "num""cat"

from sklearn.compose import make_column_selector, make_column_transformer

preprocessing = make_column_transformer(
    (num_pipeline, make_column_selector(dtype_include=np.number)),
    (cat_pipeline, make_column_selector(dtype_include=object)),
)

现在我们准备将这个 ColumnTransformer 应用到房屋数据中:

housing_prepared = preprocessing.fit_transform(housing)

太棒了!我们有一个预处理管道,它接受整个训练数据集,并将每个转换器应用于适当的列,然后水平连接转换后的列(转换器绝不能改变行数)。再次返回一个 NumPy 数组,但你可以使用 preprocessing.get_feature_names_out() 获取列名,并像之前一样将数据包装在一个漂亮的 DataFrame 中。

注意

OneHotEncoder 返回一个稀疏矩阵,而 num_pipeline 返回一个密集矩阵。当存在稀疏和密集矩阵混合时,ColumnTransformer 会估计最终矩阵的密度(即非零单元格的比例),如果密度低于给定阈值(默认为 sparse_threshold=0.3),则返回稀疏矩阵。在这个例子中,返回一个密集矩阵。

你的项目进展得很顺利,你几乎可以开始训练一些模型了!现在你想创建一个单一的管道,执行到目前为止你已经尝试过的所有转换。让我们回顾一下管道将做什么以及为什么:

  • 数值特征中的缺失值将被中位数替换,因为大多数 ML 算法不希望有缺失值。在分类特征中,缺失值将被最频繁的类别替换。

  • 分类特征将被独热编码,因为大多数 ML 算法只接受数值输入。

  • 将计算并添加一些比率特征:bedrooms_ratiorooms_per_housepeople_per_house。希望这些特征与房屋价值中位数更好地相关,并帮助 ML 模型。

  • 还将添加一些集群相似性特征。这些特征可能对模型比纬度和经度更有用。

  • 具有长尾的特征将被其对数替换,因为大多数模型更喜欢具有大致均匀或高斯分布的特征。

  • 所有数值特征将被标准化,因为大多数 ML 算法更喜欢所有特征具有大致相同的尺度。

构建执行所有这些操作的管道的代码现在应该对你来说很熟悉:

def column_ratio(X):
    return X[:, [0]] / X[:, [1]]

def ratio_name(function_transformer, feature_names_in):
    return ["ratio"]  # feature names out

def ratio_pipeline():
    return make_pipeline(
        SimpleImputer(strategy="median"),
        FunctionTransformer(column_ratio, feature_names_out=ratio_name),
        StandardScaler())

log_pipeline = make_pipeline(
    SimpleImputer(strategy="median"),
    FunctionTransformer(np.log, feature_names_out="one-to-one"),
    StandardScaler())
cluster_simil = ClusterSimilarity(n_clusters=10, gamma=1., random_state=42)
default_num_pipeline = make_pipeline(SimpleImputer(strategy="median"),
                                     StandardScaler())
preprocessing = ColumnTransformer([
        ("bedrooms", ratio_pipeline(), ["total_bedrooms", "total_rooms"]),
        ("rooms_per_house", ratio_pipeline(), ["total_rooms", "households"]),
        ("people_per_house", ratio_pipeline(), ["population", "households"]),
        ("log", log_pipeline, ["total_bedrooms", "total_rooms", "population",
                               "households", "median_income"]),
        ("geo", cluster_simil, ["latitude", "longitude"]),
        ("cat", cat_pipeline, make_column_selector(dtype_include=object)),
    ],
    remainder=default_num_pipeline)  # one column remaining: housing_median_age

如果你运行这个 ColumnTransformer,它将执行所有转换并输出一个具有 24 个特征的 NumPy 数组:

>>> housing_prepared = preprocessing.fit_transform(housing)
>>> housing_prepared.shape
(16512, 24)
>>> preprocessing.get_feature_names_out()
array(['bedrooms__ratio', 'rooms_per_house__ratio',
 'people_per_house__ratio', 'log__total_bedrooms',
 'log__total_rooms', 'log__population', 'log__households',
 'log__median_income', 'geo__Cluster 0 similarity', [...],
 'geo__Cluster 9 similarity', 'cat__ocean_proximity_<1H OCEAN',
 'cat__ocean_proximity_INLAND', 'cat__ocean_proximity_ISLAND',
 'cat__ocean_proximity_NEAR BAY', 'cat__ocean_proximity_NEAR OCEAN',
 'remainder__housing_median_age'], dtype=object)

选择并训练模型

终于!你确定了问题,获取了数据并对其进行了探索,对训练集和测试集进行了抽样,并编写了一个预处理管道来自动清理和准备数据以供机器学习算法使用。现在你准备好选择和训练一个机器学习模型了。

在训练集上训练和评估

好消息是,由于之前的所有步骤,现在事情将变得容易!你决定训练一个非常基本的线性回归模型来开始:

from sklearn.linear_model import LinearRegression

lin_reg = make_pipeline(preprocessing, LinearRegression())
lin_reg.fit(housing, housing_labels)

完成了!你现在有一个可用的线性回归模型。你可以在训练集上尝试一下,查看前五个预测值,并将其与标签进行比较:

>>> housing_predictions = lin_reg.predict(housing)
>>> housing_predictions[:5].round(-2)  # -2 = rounded to the nearest hundred
array([243700., 372400., 128800.,  94400., 328300.])
>>> housing_labels.iloc[:5].values
array([458300., 483800., 101700.,  96100., 361800.])

好吧,它起作用了,但并不总是:第一个预测结果相差太远(超过 20 万美元!),而其他预测结果更好:两个相差约 25%,两个相差不到 10%。记住你选择使用 RMSE 作为性能指标,所以你想使用 Scikit-Learn 的mean_squared_error()函数在整个训练集上测量这个回归模型的 RMSE,将squared参数设置为False

>>> from sklearn.metrics import mean_squared_error
>>> lin_rmse = mean_squared_error(housing_labels, housing_predictions,
...                               squared=False)
...
>>> lin_rmse
68687.89176589991

这比没有好,但显然不是一个很好的分数:大多数地区的median_housing_values在 12 万美元到 26.5 万美元之间,因此典型的预测误差 68,628 美元确实令人不满意。这是一个模型欠拟合训练数据的例子。当这种情况发生时,可能意味着特征提供的信息不足以做出良好的预测,或者模型不够强大。正如我们在前一章中看到的,修复欠拟合的主要方法是选择更强大的模型,用更好的特征来训练算法,或者减少模型的约束。这个模型没有正则化,这排除了最后一个选项。你可以尝试添加更多特征,但首先你想尝试一个更复杂的模型看看它的表现如何。

你决定尝试一个DecisionTreeRegressor,因为这是一个相当强大的模型,能够在数据中找到复杂的非线性关系(决策树在第六章中有更详细的介绍):

from sklearn.tree import DecisionTreeRegressor

tree_reg = make_pipeline(preprocessing, DecisionTreeRegressor(random_state=42))
tree_reg.fit(housing, housing_labels)

现在模型已经训练好了,你可以在训练集上评估它:

>>> housing_predictions = tree_reg.predict(housing)
>>> tree_rmse = mean_squared_error(housing_labels, housing_predictions,
...                                squared=False)
...
>>> tree_rmse
0.0

等等,什么!?一点错误都没有?这个模型真的完全完美吗?当然,更有可能的是模型严重过拟合了数据。你怎么确定?正如你之前看到的,你不想在准备启动一个你有信心的模型之前触摸测试集,所以你需要使用部分训练集进行训练和部分进行模型验证。

使用交叉验证进行更好的评估

评估决策树模型的一种方法是使用train_​test_split()函数将训练集分成一个较小的训练集和一个验证集,然后针对较小的训练集训练模型,并针对验证集评估模型。这需要一些努力,但并不太困难,而且效果还不错。

一个很好的选择是使用 Scikit-Learn 的k_-fold 交叉验证功能。以下代码将训练集随机分成 10 个不重叠的子集,称为folds,然后对决策树模型进行 10 次训练和评估,每次选择一个不同的 fold 进行评估,并使用其他 9 个 folds 进行训练。结果是一个包含 10 个评估分数的数组:

from sklearn.model_selection import cross_val_score

tree_rmses = -cross_val_score(tree_reg, housing, housing_labels,
                              scoring="neg_root_mean_squared_error", cv=10)
警告

Scikit-Learn 的交叉验证功能期望一个效用函数(值越大越好)而不是成本函数(值越小越好),因此评分函数实际上与 RMSE 相反。它是一个负值,因此您需要改变输出的符号以获得 RMSE 分数。

让我们看看结果:

>>> pd.Series(tree_rmses).describe()
count       10.000000
mean     66868.027288
std       2060.966425
min      63649.536493
25%      65338.078316
50%      66801.953094
75%      68229.934454
max      70094.778246
dtype: float64

现在决策树看起来不像之前那么好。事实上,它似乎表现几乎和线性回归模型一样糟糕!请注意,交叉验证允许您不仅获得模型性能的估计,还可以获得这个估计的精确度(即其标准偏差)。决策树的 RMSE 约为 66,868,标准偏差约为 2,061。如果您只使用一个验证集,您将无法获得这些信息。但是交叉验证的代价是多次训练模型,因此并非总是可行。

如果您对线性回归模型计算相同的度量,您会发现均值 RMSE 为 69,858,标准偏差为 4,182。因此,决策树模型似乎比线性模型表现稍微好一些,但由于严重过拟合,差异很小。我们知道存在过拟合问题,因为训练误差很低(实际上为零),而验证误差很高。

现在让我们尝试最后一个模型:RandomForestRegressor。正如你将在第七章中看到的,随机森林通过在特征的随机子集上训练许多决策树,然后平均它们的预测来工作。这种由许多其他模型组成的模型被称为集成:它们能够提升底层模型的性能(在本例中是决策树)。代码与之前基本相同:

from sklearn.ensemble import RandomForestRegressor

forest_reg = make_pipeline(preprocessing,
                           RandomForestRegressor(random_state=42))
forest_rmses = -cross_val_score(forest_reg, housing, housing_labels,
                                scoring="neg_root_mean_squared_error", cv=10)

让我们看一下分数:

>>> pd.Series(forest_rmses).describe()
count       10.000000
mean     47019.561281
std       1033.957120
min      45458.112527
25%      46464.031184
50%      46967.596354
75%      47325.694987
max      49243.765795
dtype: float64

哇,这好多了:随机森林看起来对这个任务非常有前途!然而,如果你训练一个RandomForest并在训练集上测量 RMSE,你会发现大约为 17,474:这个值要低得多,这意味着仍然存在相当多的过拟合。可能的解决方案是简化模型,约束它(即对其进行正则化),或者获得更多的训练数据。然而,在深入研究随机森林之前,你应该尝试许多其他来自各种机器学习算法类别的模型(例如,几个具有不同核的支持向量机,可能还有一个神经网络),而不要花费太多时间调整超参数。目标是列出几个(两到五个)有前途的模型。

调整模型

假设您现在有了几个有前途的模型的候选名单。现在您需要对它们进行微调。让我们看看您可以这样做的几种方法。

网格搜索

一种选择是手动调整超参数,直到找到一组很好的超参数值的组合。这将是非常繁琐的工作,你可能没有时间去探索很多组合。

相反,您可以使用 Scikit-Learn 的GridSearchCV类来为您搜索。您只需要告诉它您想要尝试哪些超参数以及要尝试的值,它将使用交叉验证来评估所有可能的超参数值组合。例如,以下代码搜索RandomForestRegressor的最佳超参数值组合:

from sklearn.model_selection import GridSearchCV

full_pipeline = Pipeline([
    ("preprocessing", preprocessing),
    ("random_forest", RandomForestRegressor(random_state=42)),
])
param_grid = [
    {'preprocessing__geo__n_clusters': [5, 8, 10],
     'random_forest__max_features': [4, 6, 8]},
    {'preprocessing__geo__n_clusters': [10, 15],
     'random_forest__max_features': [6, 8, 10]},
]
grid_search = GridSearchCV(full_pipeline, param_grid, cv=3,
                           scoring='neg_root_mean_squared_error')
grid_search.fit(housing, housing_labels)

请注意,您可以引用管道中任何估计器的任何超参数,即使这个估计器深度嵌套在多个管道和列转换器中。例如,当 Scikit-Learn 看到"preprocessing__geo__n_clusters"时,它会在双下划线处拆分这个字符串,然后在管道中查找名为"preprocessing"的估计器,并找到预处理ColumnTransformer。接下来,它在这个ColumnTransformer中查找名为"geo"的转换器,并找到我们在纬度和经度属性上使用的ClusterSimilarity转换器。然后找到这个转换器的n_clusters超参数。类似地,random_forest__max_features指的是名为"random_forest"的估计器的max_features超参数,这当然是RandomForest模型(max_features超参数将在第七章中解释)。

提示

将预处理步骤包装在 Scikit-Learn 管道中允许您调整预处理超参数以及模型超参数。这是一个好事,因为它们经常互动。例如,也许增加n_clusters需要增加max_features。如果适合管道转换器计算成本很高,您可以将管道的memory超参数设置为缓存目录的路径:当您首次适合管道时,Scikit-Learn 将保存适合的转换器到此目录。然后,如果您再次使用相同的超参数适合管道,Scikit-Learn 将只加载缓存的转换器。

在这个param_grid中有两个字典,所以GridSearchCV将首先评估第一个dict中指定的n_clustersmax_features超参数值的所有 3×3=9 个组合,然后它将尝试第二个dict中超参数值的所有 2×3=6 个组合。因此,总共网格搜索将探索 9+6=15 个超参数值的组合,并且它将对每个组合进行 3 次管道训练,因为我们使用 3 折交叉验证。这意味着将有总共 15×3=45 轮训练!可能需要一段时间,但完成后,您可以像这样获得最佳参数组合:

>>> grid_search.best_params_
{'preprocessing__geo__n_clusters': 15, 'random_forest__max_features': 6}

在这个示例中,通过将n_clusters设置为 15 并将max_features设置为 8 获得了最佳模型。

提示

由于 15 是为n_clusters评估的最大值,您可能应该尝试使用更高的值进行搜索;分数可能会继续提高。

您可以使用grid_search.best_estimator_访问最佳估计器。如果GridSearchCV初始化为refit=True(这是默认值),那么一旦它使用交叉验证找到最佳估计器,它将在整个训练集上重新训练。这通常是一个好主意,因为提供更多数据可能会提高其性能。

评估分数可使用grid_search.cv_results_获得。这是一个字典,但如果将其包装在 DataFrame 中,您将获得所有测试分数的一个很好的列表,每个超参数组合和每个交叉验证拆分的平均测试分数:

>>> cv_res = pd.DataFrame(grid_search.cv_results_)
>>> cv_res.sort_values(by="mean_test_score", ascending=False, inplace=True)
>>> [...]  # change column names to fit on this page, and show rmse = -score
>>> cv_res.head()  # note: the 1st column is the row ID
 n_clusters max_features  split0  split1  split2  mean_test_rmse
12         15            6   43460   43919   44748           44042
13         15            8   44132   44075   45010           44406
14         15           10   44374   44286   45316           44659
7          10            6   44683   44655   45657           44999
9          10            6   44683   44655   45657           44999

最佳模型的平均测试 RMSE 分数为 44,042,比使用默认超参数值(47,019)获得的分数更好。恭喜,您已成功微调了最佳模型!

随机搜索

当您探索相对较少的组合时,像在先前的示例中一样,网格搜索方法是可以接受的,但是RandomizedSearchCV通常更可取,特别是当超参数搜索空间很大时。这个类可以像GridSearchCV类一样使用,但是它不是尝试所有可能的组合,而是评估固定数量的组合,在每次迭代中为每个超参数选择一个随机值。这可能听起来令人惊讶,但这种方法有几个好处:

  • 如果您的一些超参数是连续的(或离散的但具有许多可能的值),并且让随机搜索运行,比如说,1,000 次迭代,那么它将探索每个超参数的 1,000 个不同值,而网格搜索只会探索您为每个超参数列出的少量值。

  • 假设一个超参数实际上并没有太大的差异,但您还不知道。如果它有 10 个可能的值,并且您将其添加到网格搜索中,那么训练将需要更长时间。但如果将其添加到随机搜索中,将不会有任何区别。

  • 如果有 6 个要探索的超参数,每个超参数有 10 个可能的值,那么网格搜索除了训练模型一百万次之外别无选择,而随机搜索可以根据您选择的任意迭代次数运行。

对于每个超参数,您必须提供可能值的列表或概率分布:

from sklearn.model_selection import RandomizedSearchCV
from scipy.stats import randint

param_distribs = {'preprocessing__geo__n_clusters': randint(low=3, high=50),
                  'random_forest__max_features': randint(low=2, high=20)}

rnd_search = RandomizedSearchCV(
    full_pipeline, param_distributions=param_distribs, n_iter=10, cv=3,
    scoring='neg_root_mean_squared_error', random_state=42)

rnd_search.fit(housing, housing_labels)

Scikit-Learn 还有HalvingRandomSearchCVHalvingGridSearchCV超参数搜索类。它们的目标是更有效地利用计算资源,要么训练更快,要么探索更大的超参数空间。它们的工作原理如下:在第一轮中,使用网格方法或随机方法生成许多超参数组合(称为“候选”)。然后使用这些候选来训练模型,并像往常一样使用交叉验证进行评估。然而,训练使用有限资源,这显著加快了第一轮的速度。默认情况下,“有限资源”意味着模型在训练集的一小部分上进行训练。然而,也可能存在其他限制,比如如果模型有一个超参数来设置它,则减少训练迭代次数。一旦每个候选都被评估过,只有最好的候选才能进入第二轮,他们被允许使用更多资源进行竞争。经过几轮后,最终的候选将使用全部资源进行评估。这可能会节省一些调整超参数的时间。

集成方法

微调系统的另一种方法是尝试组合表现最佳的模型。该组(或“集成”)通常比最佳单个模型表现更好——就像随机森林比它们依赖的单个决策树表现更好一样——特别是如果单个模型产生非常不同类型的错误。例如,您可以训练和微调一个k最近邻模型,然后创建一个集成模型,该模型只预测随机森林预测和该模型的预测的平均值。我们将在第七章中更详细地介绍这个主题。

分析最佳模型及其错误

通过检查最佳模型,您通常会对问题有很好的洞察。例如,RandomForestRegressor可以指示每个属性对于进行准确预测的相对重要性:

>>> final_model = rnd_search.best_estimator_  # includes preprocessing
>>> feature_importances = final_model["random_forest"].feature_importances_
>>> feature_importances.round(2)
array([0.07, 0.05, 0.05, 0.01, 0.01, 0.01, 0.01, 0.19, [...], 0.01])

让我们按降序对这些重要性分数进行排序,并将它们显示在其对应的属性名称旁边:

>>> sorted(zip(feature_importances,
...            final_model["preprocessing"].get_feature_names_out()),
...            reverse=True)
...
[(0.18694559869103852, 'log__median_income'),
 (0.0748194905715524, 'cat__ocean_proximity_INLAND'),
 (0.06926417748515576, 'bedrooms__ratio'),
 (0.05446998753775219, 'rooms_per_house__ratio'),
 (0.05262301809680712, 'people_per_house__ratio'),
 (0.03819415873915732, 'geo__Cluster 0 similarity'),
 [...]
 (0.00015061247730531558, 'cat__ocean_proximity_NEAR BAY'),
 (7.301686597099842e-05, 'cat__ocean_proximity_ISLAND')]

有了这些信息,您可能想尝试删除一些不太有用的特征(例如,显然只有一个ocean_proximity类别是真正有用的,因此您可以尝试删除其他类别)。

提示

sklearn.feature_selection.SelectFromModel转换器可以自动为您删除最不重要的特征:当您拟合它时,它会训练一个模型(通常是随机森林),查看其feature_importances_属性,并选择最有用的特征。然后当您调用transform()时,它会删除其他特征。

您还应该查看系统产生的具体错误,然后尝试理解为什么会出错以及如何解决问题:添加额外特征或去除无信息的特征,清理异常值等。

现在也是一个好时机确保您的模型不仅在平均情况下表现良好,而且在所有类型的地区(无论是农村还是城市,富裕还是贫穷,北方还是南方,少数民族还是非少数民族等)中都表现良好。为每个类别创建验证集的子集需要一些工作,但这很重要:如果您的模型在某个地区类别上表现不佳,那么在解决问题之前可能不应部署该模型,或者至少不应用于为该类别做出预测,因为这可能会带来更多伤害而不是好处。

在测试集上评估您的系统

调整模型一段时间后,最终你有了一个表现足够好的系统。你准备在测试集上评估最终模型。这个过程没有什么特别的;只需从测试集中获取预测变量和标签,运行你的final_model来转换数据并进行预测,然后评估这些预测结果:

X_test = strat_test_set.drop("median_house_value", axis=1)
y_test = strat_test_set["median_house_value"].copy()

final_predictions = final_model.predict(X_test)

final_rmse = mean_squared_error(y_test, final_predictions, squared=False)
print(final_rmse)  # prints 41424.40026462184

在某些情况下,这种泛化误差的点估计可能不足以说服您启动:如果它比当前生产中的模型仅好 0.1%怎么办?您可能想要知道这个估计有多精确。为此,您可以使用scipy.stats.t.interval()计算泛化误差的 95%置信区间。您得到一个相当大的区间,从 39,275 到 43,467,而您之前的点估计为 41,424 大致位于其中间:

>>> from scipy import stats
>>> confidence = 0.95
>>> squared_errors = (final_predictions - y_test) ** 2
>>> np.sqrt(stats.t.interval(confidence, len(squared_errors) - 1,
...                          loc=squared_errors.mean(),
...                          scale=stats.sem(squared_errors)))
...
array([39275.40861216, 43467.27680583])

如果您进行了大量的超参数调整,性能通常会略低于使用交叉验证测量的性能。这是因为您的系统最终被微调以在验证数据上表现良好,可能不会在未知数据集上表现得那么好。在这个例子中并非如此,因为测试 RMSE 低于验证 RMSE,但当发生这种情况时,您必须抵制调整超参数以使测试集上的数字看起来好的诱惑;这种改进不太可能推广到新数据。

现在是项目预启动阶段:您需要展示您的解决方案(突出显示您学到了什么,什么有效,什么无效,做出了什么假设以及您的系统有什么限制),记录一切,并创建具有清晰可视化和易于记忆的陈述的漂亮演示文稿(例如,“收入中位数是房价的头号预测因子”)。在这个加利福尼亚州住房的例子中,系统的最终性能并不比专家的价格估计好多少,这些估计通常偏离 30%,但如果这样做可以为专家节省一些时间,让他们可以从事更有趣和更有成效的任务,那么启动它可能仍然是一个好主意。

启动、监控和维护您的系统

完美,您已获得启动批准!现在您需要准备好将解决方案投入生产(例如,完善代码,编写文档和测试等)。然后,您可以将模型部署到生产环境。最基本的方法就是保存您训练的最佳模型,将文件传输到生产环境,并加载它。要保存模型,您可以像这样使用joblib库:

import joblib

joblib.dump(final_model, "my_california_housing_model.pkl")
提示

通常最好保存您尝试的每个模型,这样您就可以轻松地返回到您想要的任何模型。您还可以保存交叉验证分数,也许是验证集上的实际预测结果。这将使您能够轻松比较不同模型类型之间的分数,并比较它们所产生的错误类型。

一旦您的模型转移到生产环境,您就可以加载并使用它。为此,您必须首先导入模型依赖的任何自定义类和函数(这意味着将代码转移到生产环境),然后使用joblib加载模型并用它进行预测:

import joblib
[...]  # import KMeans, BaseEstimator, TransformerMixin, rbf_kernel, etc.

def column_ratio(X): [...]
def ratio_name(function_transformer, feature_names_in): [...]
class ClusterSimilarity(BaseEstimator, TransformerMixin): [...]

final_model_reloaded = joblib.load("my_california_housing_model.pkl")

new_data = [...]  # some new districts to make predictions for
predictions = final_model_reloaded.predict(new_data)

例如,也许该模型将在网站内使用:用户将输入有关新区域的一些数据,然后点击“估算价格”按钮。这将向包含数据的查询发送到 Web 服务器,Web 服务器将其转发到您的 Web 应用程序,最后您的代码将简单地调用模型的predict()方法(您希望在服务器启动时加载模型,而不是每次使用模型时都加载)。或者,您可以将模型封装在专用的 Web 服务中,您的 Web 应用程序可以通过 REST API 查询该服务⁠¹³(请参见图 2-20)。这样可以更容易地将模型升级到新版本,而不会中断主要应用程序。它还简化了扩展,因为您可以启动所需数量的 Web 服务,并将来自 Web 应用程序的请求负载均衡到这些 Web 服务中。此外,它允许您的 Web 应用程序使用任何编程语言,而不仅仅是 Python。

mls3 0220

图 2-20. 作为 Web 服务部署并由 Web 应用程序使用的模型

另一种流行的策略是将模型部署到云端,例如在 Google 的 Vertex AI 上(以前称为 Google Cloud AI 平台和 Google Cloud ML 引擎):只需使用joblib保存您的模型并将其上传到 Google Cloud Storage(GCS),然后转到 Vertex AI 并创建一个新的模型版本,将其指向 GCS 文件。就是这样!这为您提供了一个简单的网络服务,可以为您处理负载平衡和扩展。它接受包含输入数据(例如一个地区的数据)的 JSON 请求,并返回包含预测的 JSON 响应。然后,您可以在您的网站(或您正在使用的任何生产环境)中使用此网络服务。正如您将在第十九章中看到的那样,将 TensorFlow 模型部署到 Vertex AI 与部署 Scikit-Learn 模型并没有太大不同。

但部署并不是故事的终点。您还需要编写监控代码,定期检查系统的实时性能,并在性能下降时触发警报。它可能会非常快速地下降,例如如果您的基础设施中的某个组件出现故障,但请注意,它也可能会非常缓慢地衰减,这很容易在很长一段时间内不被注意到。这是很常见的,因为模型腐烂:如果模型是使用去年的数据训练的,那么它可能不适应今天的数据。

因此,您需要监控模型的实时性能。但如何做到这一点呢?嗯,这取决于情况。在某些情况下,可以从下游指标推断模型的性能。例如,如果您的模型是推荐系统的一部分,并且建议用户可能感兴趣的产品,那么每天销售的推荐产品数量很容易监控。如果这个数字下降(与非推荐产品相比),那么主要嫌疑人就是模型。这可能是因为数据管道出现问题,或者可能是模型需要重新训练以适应新数据(我们将很快讨论)。

但是,您可能还需要人工分析来评估模型的性能。例如,假设您训练了一个图像分类模型(我们将在第三章中讨论这些内容)来检测生产线上各种产品缺陷。在数千个有缺陷的产品运送给客户之前,如何在模型性能下降时获得警报?一个解决方案是向人工评分员发送模型分类的所有图片样本(特别是模型不太确定的图片)。根据任务的不同,评分员可能需要是专家,也可能是非专业人员,例如众包平台上的工人(例如,亚马逊 Mechanical Turk)。在某些应用中,甚至可以是用户自己,例如通过调查或重新利用的验证码进行回应。

无论如何,您都需要建立一个监控系统(无论是否有人工评分员来评估实时模型),以及定义在发生故障时应该采取的所有相关流程以及如何为其做好准备。不幸的是,这可能是一项很多工作。事实上,通常比构建和训练模型要多得多。

如果数据不断发展,您将需要定期更新数据集并重新训练模型。您应该尽可能自动化整个过程。以下是一些您可以自动化的事项:

  • 定期收集新数据并对其进行标记(例如,使用人工评分员)。

  • 编写一个脚本来自动训练模型并微调超参数。该脚本可以自动运行,例如每天或每周一次,具体取决于您的需求。

  • 编写另一个脚本,将在更新的测试集上评估新模型和先前模型,并在性能没有下降时将模型部署到生产环境(如果性能下降了,请确保调查原因)。该脚本可能会测试模型在测试集的各种子集上的性能,例如贫困或富裕地区,农村或城市地区等。

您还应该确保评估模型的输入数据质量。有时性能会因为信号质量不佳(例如,故障传感器发送随机值,或其他团队的输出变得陈旧)而略微下降,但可能需要一段时间,直到系统性能下降到足以触发警报。如果监控模型的输入,您可能会更早地发现这一点。例如,如果越来越多的输入缺少某个特征,或者均值或标准差与训练集相差太远,或者分类特征开始包含新类别,您可以触发警报。

最后,确保您保留您创建的每个模型的备份,并具备回滚到先前模型的过程和工具,以防新模型因某种原因开始严重失败。备份还使得可以轻松地将新模型与先前模型进行比较。同样,您应该保留每个数据集版本的备份,以便在新数据集损坏时(例如,如果添加到其中的新数据被证明充满异常值),可以回滚到先前数据集。备份数据集还可以让您评估任何模型与任何先前数据集的关系。

正如您所看到的,机器学习涉及相当多的基础设施。第十九章讨论了其中的一些方面,但这是一个名为ML Operations(MLOps)的非常广泛的主题,值得有一本专门的书来讨论。因此,如果您的第一个机器学习项目需要大量的工作和时间来构建和部署到生产环境,也不要感到惊讶。幸运的是,一旦所有基础设施就绪,从构思到生产将会更快。

试一试!

希望本章让您对机器学习项目的外观有了一个很好的了解,并向您展示了一些可以用来训练出色系统的工具。正如您所看到的,大部分工作在数据准备阶段:构建监控工具,设置人工评估流水线,以及自动化常规模型训练。当然,机器学习算法很重要,但可能更好的是熟悉整个过程,并且对三四种算法很熟悉,而不是花费所有时间探索高级算法。

因此,如果您还没有这样做,现在是时候拿起笔记本电脑,选择您感兴趣的数据集,并尝试从头到尾完成整个过程。一个很好的开始地方是在竞赛网站上,比如Kaggle:您将有一个数据集可以使用,一个明确的目标,以及可以分享经验的人。玩得开心!

练习

以下练习基于本章的房屋数据集:

  1. 尝试使用各种超参数的支持向量机回归器(sklearn.svm.SVR),例如kernel="linear"(对于C超参数的各种值)或kernel="rbf"(对于Cgamma超参数的各种值)。请注意,支持向量机在大型数据集上不容易扩展,因此您可能应该仅在训练集的前 5,000 个实例上训练模型,并仅使用 3 折交叉验证,否则将需要数小时。现在不用担心超参数的含义;我们将在第五章中讨论它们。最佳SVR预测器的表现如何?

  2. 尝试用RandomizedSearchCV替换GridSearchCV

  3. 尝试在准备流水线中添加SelectFromModel转换器,仅选择最重要的属性。

  4. 尝试创建一个自定义转换器,在其fit()方法中训练一个k最近邻回归器(sklearn.neighbors.KNeighborsRegressor),并在其transform()方法中输出模型的预测。然后将此功能添加到预处理流水线中,使用纬度和经度作为该转换器的输入。这将在模型中添加一个特征,该特征对应于最近地区的房屋中位数价格。

  5. 使用GridSearchCV自动探索一些准备选项。

  6. 尝试从头开始再次实现StandardScalerClone类,然后添加对inverse_transform()方法的支持:执行scaler.​inverse_transform(scaler.fit_transform(X))应该返回一个非常接近X的数组。然后添加特征名称的支持:如果输入是 DataFrame,则在fit()方法中设置feature_names_in_。该属性应该是一个列名的 NumPy 数组。最后,实现get_feature_names_out()方法:它应该有一个可选的input_features=None参数。如果传递了,该方法应该检查其长度是否与n_features_in_匹配,并且如果定义了feature_names_in_,则应该匹配;然后返回input_features。如果input_featuresNone,那么该方法应该返回feature_names_in_(如果定义了)或者长度为n_features_in_np.array(["x0", "x1", ...])

这些练习的解决方案可以在本章笔记本的末尾找到,网址为https://homl.info/colab3

原始数据集出现在 R. Kelley Pace 和 Ronald Barry 的“稀疏空间自回归”中,统计与概率信件 33,第 3 期(1997 年):291-297。

馈送给机器学习系统的信息片段通常称为信号,这是指克劳德·香农在贝尔实验室开发的信息论,他的理论是:您希望有高的信噪比。

记住,转置运算符将列向量翻转为行向量(反之亦然)。

您可能还需要检查法律约束,例如不应将私有字段复制到不安全的数据存储中。

标准差通常用σ(希腊字母 sigma)表示,它是方差的平方根,即与均值的平方偏差的平均值。当一个特征具有钟形的正态分布(也称为高斯分布)时,这是非常常见的,“68-95-99.7”规则适用:大约 68%的值落在均值的 1σ范围内,95%在 2σ范围内,99.7%在 3σ范围内。

经常会看到人们将随机种子设置为 42。这个数字没有特殊的属性,除了它是生命、宇宙和一切终极问题的答案。

位置信息实际上相当粗糙,因此许多地区将具有相同的 ID,因此它们最终会进入相同的集合(测试或训练)。这引入了一些不幸的抽样偏差。

如果您是在灰度模式下阅读本文,请拿一支红笔,在从旧金山湾到圣迭戈的大部分海岸线上涂鸦(正如您所期望的那样)。您也可以在萨克拉门托周围添加一块黄色的区域。

有关设计原则的更多详细信息,请参阅 Lars Buitinck 等人的“机器学习软件的 API 设计经验:来自 Scikit-Learn 项目的经验”,arXiv 预印本 arXiv:1309.0238(2013)。

一些预测器还提供测量其预测置信度的方法。

当您阅读这些文字时,可能会有可能使所有的转换器在接收到 DataFrame 作为输入时输出 Pandas DataFrames:Pandas 输入,Pandas 输出。可能会有一个全局配置选项:sklearn.set_config(pandas_in_out=True)

查看 SciPy 的文档以获取更多详细信息。

¹³ 简而言之,REST(或 RESTful)API 是基于 HTTP 的 API,遵循一些约定,例如使用标准的 HTTP 动词来读取、更新、创建或删除资源(GET、POST、PUT 和 DELETE),并使用 JSON 作为输入和输出。

¹⁴ 验证码是一种测试,用于确保用户不是机器人。这些测试经常被用作标记训练数据的廉价方式。

第三章:分类

在第一章中,我提到最常见的监督学习任务是回归(预测值)和分类(预测类)。在第二章中,我们探讨了一个回归任务,使用各种算法(如线性回归、决策树和随机森林)来预测房屋价值(这将在后面的章节中进一步详细解释)。现在我们将把注意力转向分类系统。

MNIST

在本章中,我们将使用 MNIST 数据集,这是由美国人口普查局的高中学生和员工手写的 70,000 张小数字图像集。每个图像都带有它代表的数字标签。这个数据集已经被研究了很多次,通常被称为机器学习的“hello world”:每当人们提出一个新的分类算法时,他们都很好奇它在 MNIST 上的表现如何,任何学习机器学习的人迟早都会处理这个数据集。

Scikit-Learn 提供许多辅助函数来下载流行的数据集。MNIST 就是其中之一。以下代码从 OpenML.org 获取 MNIST 数据集:¹

from sklearn.datasets import fetch_openml

mnist = fetch_openml('mnist_784', as_frame=False)

sklearn.datasets包主要包含三种类型的函数:fetch_*函数,如fetch_openml()用于下载真实数据集,load_*函数用于加载与 Scikit-Learn 捆绑的小型玩具数据集(因此不需要通过互联网下载),以及make_*函数用于生成虚假数据集,对测试很有用。生成的数据集通常作为包含输入数据和目标的(X, y)元组返回,都作为 NumPy 数组。其他数据集作为sklearn.utils.Bunch对象返回,这些对象是字典,其条目也可以作为属性访问。它们通常包含以下条目:

"DESCR"

数据集描述

“数据”

输入数据,通常作为 2D NumPy 数组

“目标”

标签,通常作为 1D NumPy 数组

fetch_openml()函数有点不同,因为默认情况下它将输入返回为 Pandas DataFrame,将标签返回为 Pandas Series(除非数据集是稀疏的)。但是 MNIST 数据集包含图像,而 DataFrame 并不理想,因此最好设置as_frame=False以将数据作为 NumPy 数组获取。让我们看看这些数组:

>>> X, y = mnist.data, mnist.target
>>> X
array([[0., 0., 0., ..., 0., 0., 0.],
 [0., 0., 0., ..., 0., 0., 0.],
 [0., 0., 0., ..., 0., 0., 0.],
 ...,
 [0., 0., 0., ..., 0., 0., 0.],
 [0., 0., 0., ..., 0., 0., 0.],
 [0., 0., 0., ..., 0., 0., 0.]])
>>> X.shape
(70000, 784)
>>> y
array(['5', '0', '4', ..., '4', '5', '6'], dtype=object)
>>> y.shape
(70000,)

共有 70,000 张图像,每张图像有 784 个特征。这是因为每个图像是 28×28 像素,每个特征只是表示一个像素的强度,从 0(白色)到 255(黑色)。让我们看一下数据集中的一个数字(图 3-1)。我们只需要获取一个实例的特征向量,将其重塑为 28×28 数组,并使用 Matplotlib 的imshow()函数显示它。我们使用cmap="binary"来获取一个灰度色图,其中 0 是白色,255 是黑色:

import matplotlib.pyplot as plt

def plot_digit(image_data):
    image = image_data.reshape(28, 28)
    plt.imshow(image, cmap="binary")
    plt.axis("off")

some_digit = X[0]
plot_digit(some_digit)
plt.show()

mls3 0301

图 3-1。MNIST 图像示例

这看起来像一个 5,事实上标签告诉我们是这样的:

>>> y[0]
'5'

为了让您感受分类任务的复杂性,图 3-2 显示了 MNIST 数据集中的更多图像。

但是!在仔细检查数据之前,您应该始终创建一个测试集并将其放在一边。fetch_openml()返回的 MNIST 数据集实际上已经分为训练集(前 60,000 张图像)和测试集(最后 10,000 张图像):²

X_train, X_test, y_train, y_test = X[:60000], X[60000:], y[:60000], y[60000:]

训练集已经为我们洗牌,这很好,因为这保证了所有交叉验证折叠将是相似的(我们不希望一个折叠缺少一些数字)。此外,一些学习算法对训练实例的顺序敏感,如果它们连续获得许多相似实例,则表现会很差。洗牌数据集确保这种情况不会发生。³

mls3 0302

图 3-2。来自 MNIST 数据集的数字

训练二元分类器

现在,让我们简化问题,只尝试识别一个数字,例如数字 5。这个“5 检测器”将是一个二元分类器的示例,能够区分只有两个类别的 5 和非 5。首先,我们将为这个分类任务创建目标向量:

y_train_5 = (y_train == '5')  # True for all 5s, False for all other digits
y_test_5 = (y_test == '5')

现在让我们选择一个分类器并对其进行训练。一个很好的开始地方是使用随机梯度下降(SGD,或随机 GD)分类器,使用 Scikit-Learn 的SGDClassifier类。这个分类器能够高效处理非常大的数据集。部分原因是因为 SGD 独立处理训练实例,一次一个,这也使得 SGD 非常适合在线学习,稍后您将看到。让我们创建一个SGDClassifier并在整个训练集上对其进行训练:

from sklearn.linear_model import SGDClassifier

sgd_clf = SGDClassifier(random_state=42)
sgd_clf.fit(X_train, y_train_5)

现在我们可以使用它来检测数字 5 的图像:

>>> sgd_clf.predict([some_digit])
array([ True])

分类器猜测这幅图像代表数字 5(True)。在这种特殊情况下,看起来它猜对了!现在,让我们评估这个模型的性能。

性能指标

评估分类器通常比评估回归器要困难得多,因此我们将在本章的大部分时间中讨论这个主题。有许多性能指标可用,所以抓杯咖啡,准备学习一堆新概念和首字母缩略词!

使用交叉验证测量准确率

评估模型的一个好方法是使用交叉验证,就像您在第二章中所做的那样。让我们使用cross_val_score()函数来评估我们的SGDClassifier模型,使用三折交叉验证。请记住,k-fold 交叉验证意味着将训练集分成k折(在本例中为三折),然后训练模型k次,每次保留一个不同的折叠用于评估(参见第二章):

>>> from sklearn.model_selection import cross_val_score
>>> cross_val_score(sgd_clf, X_train, y_train_5, cv=3, scoring="accuracy")
array([0.95035, 0.96035, 0.9604 ])

哇!在所有交叉验证折叠中超过 95%的准确率(正确预测的比例)?看起来很惊人,不是吗?好吧,在你太兴奋之前,让我们看一个只将每个图像分类为最频繁类别的虚拟分类器,这种情况下是负类别(即 5):

from sklearn.dummy import DummyClassifier

dummy_clf = DummyClassifier()
dummy_clf.fit(X_train, y_train_5)
print(any(dummy_clf.predict(X_train)))  # prints False: no 5s detected

您能猜到这个模型的准确率吗?让我们找出来:

>>> cross_val_score(dummy_clf, X_train, y_train_5, cv=3, scoring="accuracy")
array([0.90965, 0.90965, 0.90965])

没错,它的准确率超过 90%!这仅仅是因为大约 10%的图像是 5,所以如果您总是猜测一幅图像是 5,您将有 90%的准确率。胜过诺斯特拉达姆。

这说明为什么准确率通常不是分类器的首选性能指标,特别是当您处理倾斜数据集(即某些类别比其他类别更频繁时)。评估分类器性能的一个更好方法是查看混淆矩阵(CM)。

混淆矩阵

混淆矩阵的一般思想是计算类 A 的实例被分类为类 B 的次数,对于所有 A/B 对。例如,要知道分类器将 8 的图像误判为 0 的次数,您将查看混淆矩阵的第 8 行,第 0 列。

计算混淆矩阵,首先需要一组预测结果,以便与实际目标进行比较。您可以对测试集进行预测,但最好现在保持不变(记住,您只想在项目的最后阶段使用测试集,一旦您准备启动分类器)。相反,您可以使用cross_val_predict()函数:

from sklearn.model_selection import cross_val_predict

y_train_pred = cross_val_predict(sgd_clf, X_train, y_train_5, cv=3)

就像cross_val_score()函数一样,cross_val_predict()执行k-fold 交叉验证,但不返回评估分数,而是返回在每个测试折叠上做出的预测。这意味着您可以获得训练集中每个实例的干净预测(“干净”是指“样本外”:模型对训练期间从未见过的数据进行预测)。

现在您已经准备好使用confusion_matrix()函数获取混淆矩阵。只需将目标类(y_train_5)和预测类(y_train_pred)传递给它:

>>> from sklearn.metrics import confusion_matrix
>>> cm = confusion_matrix(y_train_5, y_train_pred)
>>> cm
array([[53892,   687],
 [ 1891,  3530]])

混淆矩阵中的每一行代表一个实际类别,而每一列代表一个预测类别。该矩阵的第一行考虑了非 5 的图像(负类):其中 53,892 个被正确分类为非 5(称为真负例),而剩下的 687 个被错误分类为 5(假正例,也称为类型 I 错误)。第二行考虑了 5 的图像(正类):其中 1,891 个被错误分类为非 5(假阴性,也称为类型 II 错误),而剩下的 3,530 个被正确分类为 5(真正例)。一个完美的分类器只会有真正例和真负例,因此其混淆矩阵只会在其主对角线上有非零值(从左上到右下):

>>> y_train_perfect_predictions = y_train_5  # pretend we reached perfection
>>> confusion_matrix(y_train_5, y_train_perfect_predictions)
array([[54579,     0],
 [    0,  5421]])

混淆矩阵提供了很多信息,但有时您可能更喜欢一个更简洁的指标。一个有趣的指标是正预测的准确率;这被称为分类器的精度(方程 3-1)。

方程 3-1。精度

精度 = TP TP+FP

TP是真正例的数量,FP是假正例的数量。

实现完美精度的一个微不足道的方法是创建一个分类器,总是做出负预测,除了在它最有信心的实例上做出一个单一的正预测。如果这一个预测是正确的,那么分类器的精度就是 100%(精度=1/1=100%)。显然,这样的分类器不会很有用,因为它会忽略除了一个正实例之外的所有实例。因此,精度通常与另一个指标一起使用,该指标称为召回率,也称为敏感度真正例率(TPR):这是分类器正确检测到的正实例的比率(方程 3-2)。

方程 3-2。回想一下

召回率 = TP TP+FN

FN,当然,是假阴性的数量。

如果您对混淆矩阵感到困惑,图 3-3 可能会有所帮助。

mls3 0303

图 3-3。一个说明混淆矩阵的示例,显示真负例(左上)、假正例(右上)、假阴性(左下)和真正例(右下)

精度和召回率

Scikit-Learn 提供了几个函数来计算分类器的指标,包括精度和召回率:

>>> from sklearn.metrics import precision_score, recall_score
>>> precision_score(y_train_5, y_train_pred)  # == 3530 / (687 + 3530)
0.8370879772350012
>>> recall_score(y_train_5, y_train_pred)  # == 3530 / (1891 + 3530)
0.6511713705958311

现在我们的 5 检测器看起来并不像我们在查看其准确性时那么出色。当它声称一幅图像代表 5 时,它只有 83.7%的时间是正确的。此外,它只能检测到 65.1%的 5。

通常方便将精度和召回率结合成一个称为F[1]分数的单一指标,特别是当您需要一个单一指标来比较两个分类器时。 F[1]分数是精度和召回率的调和平均(方程 3-3)。而普通平均值对所有值都一视同仁,调和平均值更加重视低值。因此,只有当召回率和精度都很高时,分类器才会获得高的 F[1]分数。

方程 3-3。F[1]分数

F 1 = 2 1 精度+1 召回率 = 2 × 精度×召回率 精度+召回率 = TP TP+FN+FP 2

要计算 F[1]分数,只需调用f1_score()函数:

>>> from sklearn.metrics import f1_score
>>> f1_score(y_train_5, y_train_pred)
0.7325171197343846

F[1]分数偏向于具有类似精度和召回率的分类器。这并不总是你想要的:在某些情境下,你更关心精度,而在其他情境下,你真的很在意召回率。例如,如果你训练一个用于检测适合儿童观看的视频的分类器,你可能更喜欢一个拒绝许多好视频(低召回率)但仅保留安全视频(高精度)的分类器,而不是一个召回率更高但让一些非常糟糕的视频出现在你的产品中的分类器(在这种情况下,你甚至可能想添加一个人工流水线来检查分类器的视频选择)。另一方面,假设你训练一个用于在监控图像中检测扒手的分类器:如果你的分类器只有 30%的精度,只要召回率达到 99%就可以了(当然,保安人员会收到一些错误警报,但几乎所有扒手都会被抓住)。

不幸的是,你不能两全其美:提高精度会降低召回率,反之亦然。这被称为精度/召回率权衡

精度/召回率权衡

为了理解这种权衡,让我们看看SGDClassifier是如何做出分类决策的。对于每个实例,它根据决策函数计算得分。如果该得分大于阈值,则将实例分配给正类;否则将其分配给负类。图 3-4 显示了一些数字,从最低得分的左侧到最高得分的右侧。假设决策阈值位于中间箭头处(两个 5 之间):你会发现在该阈值右侧有 4 个真正例(实际为 5),以及 1 个假正例(实际上是 6)。因此,使用该阈值,精度为 80%(5 个中的 4 个)。但在 6 个实际为 5 的情况下,分类器只检测到 4 个,因此召回率为 67%(6 个中的 4 个)。如果提高阈值(将其移动到右侧的箭头处),假正例(6)变为真负例,从而增加精度(在这种情况下最高可达 100%),但一个真正例变为假负例,将召回率降低到 50%。相反,降低阈值会增加召回率并降低精度。

mls3 0304

图 3-4。精度/召回率权衡:图像按其分类器得分排名,高于所选决策阈值的图像被视为正例;阈值越高,召回率越低,但(一般而言)精度越高

Scikit-Learn 不允许直接设置阈值,但它确实让您访问它用于做出预测的决策得分。您可以调用分类器的decision_function()方法,而不是调用predict()方法,该方法返回每个实例的得分,然后根据这些得分使用任何阈值进行预测:

>>> y_scores = sgd_clf.decision_function([some_digit])
>>> y_scores
array([2164.22030239])
>>> threshold = 0
>>> y_some_digit_pred = (y_scores > threshold)
array([ True])

SGDClassifier使用阈值等于 0,因此前面的代码返回与predict()方法相同的结果(即True)。让我们提高阈值:

>>> threshold = 3000
>>> y_some_digit_pred = (y_scores > threshold)
>>> y_some_digit_pred
array([False])

这证实了提高阈值会降低召回率。图像实际上代表一个 5,当阈值为 0 时分类器检测到它,但当阈值增加到 3,000 时却错过了它。

如何决定使用哪个阈值?首先,使用cross_val_predict()函数获取训练集中所有实例的分数,但是这次指定要返回决策分数而不是预测:

y_scores = cross_val_predict(sgd_clf, X_train, y_train_5, cv=3,
                             method="decision_function")

使用这些分数,使用precision_recall_curve()函数计算所有可能阈值的精度和召回率(该函数添加最后一个精度为 0 和最后一个召回率为 1,对应于无限阈值):

from sklearn.metrics import precision_recall_curve

precisions, recalls, thresholds = precision_recall_curve(y_train_5, y_scores)

最后,使用 Matplotlib 绘制精度和召回率作为阈值值的函数(图 3-5)。让我们展示我们选择的 3,000 的阈值:

plt.plot(thresholds, precisions[:-1], "b--", label="Precision", linewidth=2)
plt.plot(thresholds, recalls[:-1], "g-", label="Recall", linewidth=2)
plt.vlines(threshold, 0, 1.0, "k", "dotted", label="threshold")
[...]  # beautify the figure: add grid, legend, axis, labels, and circles
plt.show()

mls3 0305

图 3-5. 精度和召回率与决策阈值
注意

你可能会想知道为什么图 3-5 中的精度曲线比召回率曲线更加崎岖。原因是当你提高阈值时,精度有时会下降(尽管通常会上升)。要理解原因,请回顾图 3-4,注意当你从中心阈值开始,将其向右移动一个数字时会发生什么:精度从 4/5(80%)下降到 3/4(75%)。另一方面,当增加阈值时,召回率只能下降,这解释了为什么其曲线看起来平滑。

在这个阈值下,精度接近 90%,召回率约为 50%。选择一个良好的精度/召回率折衷的另一种方法是直接绘制精度与召回率的图表,如图 3-6 所示(显示相同的阈值):

plt.plot(recalls, precisions, linewidth=2, label="Precision/Recall curve")
[...]  # beautify the figure: add labels, grid, legend, arrow, and text
plt.show()

mls3 0306

图 3-6. 精度与召回率

你可以看到精度在约 80%的召回率处开始急剧下降。你可能希望在该下降之前选择一个精度/召回率折衷,例如在约 60%的召回率处。但是,选择取决于你的项目。

假设你决定追求 90%的精度。你可以使用第一个图表找到需要使用的阈值,但这不太精确。或者,你可以搜索给出至少 90%精度的最低阈值。为此,你可以使用 NumPy 数组的argmax()方法。这将返回最大值的第一个索引,这在这种情况下意味着第一个True值:

>>> idx_for_90_precision = (precisions >= 0.90).argmax()
>>> threshold_for_90_precision = thresholds[idx_for_90_precision]
>>> threshold_for_90_precision
3370.0194991439557

要进行预测(目前只是在训练集上),而不是调用分类器的predict()方法,你可以运行这段代码:

y_train_pred_90 = (y_scores >= threshold_for_90_precision)

让我们检查这些预测的精度和召回率:

>>> precision_score(y_train_5, y_train_pred_90)
0.9000345901072293
>>> recall_at_90_precision = recall_score(y_train_5, y_train_pred_90)
>>> recall_at_90_precision
0.4799852425751706

太棒了,你有一个 90%的精度分类器!正如你所看到的,几乎可以轻松地创建一个任意精度的分类器:只需设置足够高的阈值,就可以了。但是等等,不要那么快——如果召回率太低,高精度的分类器就不太有用!对于许多应用程序来说,48%的召回率根本不好。

提示

如果有人说:“让我们达到 99%的精度”,你应该问:“召回率是多少?”

ROC 曲线

接收者操作特征(ROC)曲线是与二元分类器一起使用的另一个常见工具。它与精度/召回率曲线非常相似,但是 ROC 曲线不是绘制精度与召回率,而是绘制真正例率(召回率的另一个名称)与假正例率(FPR)。FPR(也称为误报率)是被错误分类为正例的负实例的比率。它等于 1 减去真负例率(TNR),即被正确分类为负例的负实例的比率。TNR 也称为特异性。因此,ROC 曲线绘制灵敏度(召回率)与 1-特异性

要绘制 ROC 曲线,首先使用roc_curve()函数计算各种阈值的 TPR 和 FPR:

from sklearn.metrics import roc_curve

fpr, tpr, thresholds = roc_curve(y_train_5, y_scores)

然后,您可以使用 Matplotlib 绘制 FPR 与 TPR。以下代码生成图 3-7 中的图。要找到对应于 90%精确度的点,我们需要查找所需阈值的索引。由于在这种情况下,阈值按降序列出,因此我们在第一行上使用<=而不是>=

idx_for_threshold_at_90 = (thresholds <= threshold_for_90_precision).argmax()
tpr_90, fpr_90 = tpr[idx_for_threshold_at_90], fpr[idx_for_threshold_at_90]

plt.plot(fpr, tpr, linewidth=2, label="ROC curve")
plt.plot([0, 1], [0, 1], 'k:', label="Random classifier's ROC curve")
plt.plot([fpr_90], [tpr_90], "ko", label="Threshold for 90% precision")
[...]  # beautify the figure: add labels, grid, legend, arrow, and text
plt.show()

mls3 0307

图 3-7。ROC 曲线绘制了所有可能阈值的假阳性率与真阳性率之间的关系;黑色圆圈突出显示了选择的比率(在 90%精确度和 48%召回率处)

再次存在权衡:召回率(TPR)越高,分类器产生的假阳性(FPR)就越多。虚线代表纯随机分类器的 ROC 曲线;一个好的分类器尽可能远离该线(朝向左上角)。

比较分类器的一种方法是测量曲线下面积(AUC)。完美的分类器的 ROC AUC 等于 1,而纯随机分类器的 ROC AUC 等于 0.5。Scikit-Learn 提供了一个估计 ROC AUC 的函数:

>>> from sklearn.metrics import roc_auc_score
>>> roc_auc_score(y_train_5, y_scores)
0.9604938554008616
提示

由于 ROC 曲线与精确率/召回率(PR)曲线非常相似,您可能想知道如何决定使用哪个。作为经验法则,当正类别很少或您更关心假阳性而不是假阴性时,应优先选择 PR 曲线。否则,请使用 ROC 曲线。例如,查看先前的 ROC 曲线(以及 ROC AUC 分数),您可能会认为分类器非常好。但这主要是因为与负例(非 5)相比,正例(5)很少。相比之下,PR 曲线清楚地表明分类器有改进的空间:曲线实际上可以更接近右上角(再次参见图 3-6)。

现在让我们创建一个RandomForestClassifier,我们可以将其 PR 曲线和 F[1]分数与SGDClassifier的进行比较:

from sklearn.ensemble import RandomForestClassifier

forest_clf = RandomForestClassifier(random_state=42)

precision_recall_curve()函数期望每个实例的标签和分数,因此我们需要训练随机森林分类器并使其为每个实例分配一个分数。但是,由于RandomForestClassifier类的工作方式(我们将在第七章中介绍),它没有decision_function()方法。幸运的是,它有一个predict_proba()方法,为每个实例返回类概率,并且我们可以将正类别的概率作为分数,因此它将正常工作。⁴我们可以调用cross_val_predict()函数,使用交叉验证训练RandomForestClassifier,并使其为每个图像预测类概率,如下所示:

y_probas_forest = cross_val_predict(forest_clf, X_train, y_train_5, cv=3,
                                    method="predict_proba")

让我们看看训练集中前两个图像的类概率:

>>> y_probas_forest[:2]
array([[0.11, 0.89],
 [0.99, 0.01]])

模型预测第一幅图像为正面的概率为 89%,并且预测第二幅图像为负面的概率为 99%。由于每幅图像要么是正面要么是负面,因此每行中的概率总和为 100%。

警告

这些是估计概率,而不是实际概率。例如,如果您查看模型将其分类为正面的所有图像,其估计概率在 50%到 60%之间,大约 94%实际上是正面的。因此,在这种情况下,模型的估计概率要低得多,但模型也可能过于自信。sklearn.calibration包含工具,可以校准估计的概率,使其更接近实际概率。有关更多详细信息,请参阅本章笔记本中的额外材料部分。

第二列包含正类别的估计概率,因此让我们将它们传递给precision_recall_curve()函数:

y_scores_forest = y_probas_forest[:, 1]
precisions_forest, recalls_forest, thresholds_forest = precision_recall_curve(
    y_train_5, y_scores_forest)

现在我们准备绘制 PR 曲线。为了查看它们的比较,也有必要绘制第一个 PR 曲线(图 3-8):

plt.plot(recalls_forest, precisions_forest, "b-", linewidth=2,
         label="Random Forest")
plt.plot(recalls, precisions, "--", linewidth=2, label="SGD")
[...]  # beautify the figure: add labels, grid, and legend
plt.show()

mls3 0308

图 3-8。比较 PR 曲线:随机森林分类器优于 SGD 分类器,因为其 PR 曲线更接近右上角,并且具有更大的 AUC

正如您在图 3-8 中所看到的,RandomForestClassifier的 PR 曲线看起来比SGDClassifier的要好得多:它更接近右上角。它的 F[1]分数和 ROC AUC 分数也显著更好:

>>> y_train_pred_forest = y_probas_forest[:, 1] >= 0.5  # positive proba ≥ 50%
>>> f1_score(y_train_5, y_pred_forest)
0.9242275142688446
>>> roc_auc_score(y_train_5, y_scores_forest)
0.9983436731328145

尝试测量精确度和召回率得分:您应该会发现大约 99.1%的精确度和 86.6%的召回率。还不错!

您现在知道如何训练二元分类器,选择适合您任务的适当度量标准,使用交叉验证评估您的分类器,选择适合您需求的精确度/召回率折衷,并使用多种指标和曲线比较各种模型。您已经准备好尝试检测不仅仅是数字 5 了。

多类分类

而二元分类器区分两个类别,多类分类器(也称为多项分类器)可以区分两个以上的类别。

一些 Scikit-Learn 分类器(例如LogisticRegressionRandomForestClassifierGaussianNB)能够本地处理多个类别。其他严格的二元分类器(例如SGDClassifierSVC)。然而,有各种策略可用于使用多个二元分类器执行多类分类。

创建一个能够将数字图像分类为 10 个类别(从 0 到 9)的系统的一种方法是训练 10 个二元分类器,每个数字一个(一个 0 检测器,一个 1 检测器,一个 2 检测器,依此类推)。然后,当您想要对一幅图像进行分类时,您会从每个分类器中获取该图像的决策分数,并选择输出最高分数的类别。这被称为一对剩余(OvR)策略,有时也称为一对所有(OvA)。

另一种策略是为每对数字训练一个二元分类器:一个用于区分 0 和 1,另一个用于区分 0 和 2,另一个用于 1 和 2,依此类推。这被称为一对一(OvO)策略。如果有N个类别,您需要训练N×(N - 1)/ 2 个分类器。对于 MNIST 问题,这意味着训练 45 个二元分类器!当您想要对一幅图像进行分类时,您必须通过所有 45 个分类器并查看哪个类别赢得了最多的决斗。OvO 的主要优势在于每个分类器只需要在包含它必须区分的两个类别的训练集部分上进行训练。

一些算法(如支持向量机分类器)随着训练集的大小而扩展得很差。对于这些算法,OvO 更受青睐,因为在小训练集上训练许多分类器比在大训练集上训练少数分类器要快。然而,对于大多数二元分类算法,OvR 更受青睐。

Scikit-Learn 会检测到您尝试将二元分类算法用于多类分类任务时,并根据算法自动运行 OvR 或 OvO。让我们尝试使用sklearn.svm.SVC类中的支持向量机分类器(参见第五章)。我们只会在前 2,000 幅图像上进行训练,否则会花费很长时间:

from sklearn.svm import SVC

svm_clf = SVC(random_state=42)
svm_clf.fit(X_train[:2000], y_train[:2000])  # y_train, not y_train_5

这很容易!我们使用原始目标类别从 0 到 9(y_train)来训练SVC,而不是使用 5 对剩余目标类别(y_train_5)。由于有 10 个类别(即超过 2 个),Scikit-Learn 使用了 OvO 策略并训练了 45 个二元分类器。现在让我们对一幅图像进行预测:

>>> svm_clf.predict([some_digit])
array(['5'], dtype=object)

这是正确的!这段代码实际上进行了 45 次预测——每对类别一次——并选择了赢得最多决斗的类别。如果调用decision_function()方法,您会看到它为每个实例返回 10 个分数:每个类别一个。每个类别得分等于赢得的决斗数加上或减去一个小调整(最大±0.33)以打破平局,基于分类器的分数:

>>> some_digit_scores = svm_clf.decision_function([some_digit])
>>> some_digit_scores.round(2)
array([[ 3.79,  0.73,  6.06,  8.3 , -0.29,  9.3 ,  1.75,  2.77,  7.21,
 4.82]])

最高分是 9.3,确实对应于类别 5:

>>> class_id = some_digit_scores.argmax()
>>> class_id
5

当分类器训练完成时,它会将目标类别列表存储在其classes_属性中,按值排序。在 MNIST 的情况下,classes_数组中每个类别的索引恰好与类别本身匹配(例如,索引为 5 的类在数组中是类'5'),但通常您不会那么幸运;您需要像这样查找类标签:

>>> svm_clf.classes_
array(['0', '1', '2', '3', '4', '5', '6', '7', '8', '9'], dtype=object)
>>> svm_clf.classes_[class_id]
'5'

如果您想强制 Scikit-Learn 使用一对一或一对多,您可以使用OneVsOneClassifierOneVsRestClassifier类。只需创建一个实例并将分类器传递给其构造函数(甚至不必是二元分类器)。例如,此代码使用 OvR 策略基于SVC创建一个多类分类器:

from sklearn.multiclass import OneVsRestClassifier

ovr_clf = OneVsRestClassifier(SVC(random_state=42))
ovr_clf.fit(X_train[:2000], y_train[:2000])

让我们进行预测,并检查训练过的分类器数量:

>>> ovr_clf.predict([some_digit])
array(['5'], dtype='<U1')
>>> len(ovr_clf.estimators_)
10

在多类数据集上训练SGDClassifier并使用它进行预测同样简单:

>>> sgd_clf = SGDClassifier(random_state=42)
>>> sgd_clf.fit(X_train, y_train)
>>> sgd_clf.predict([some_digit])
array(['3'], dtype='<U1')

哎呀,那是错误的。预测错误确实会发生!这次 Scikit-Learn 在幕后使用了 OvR 策略:由于有 10 个类别,它训练了 10 个二元分类器。decision_function()方法现在返回每个类别的一个值。让我们看看 SGD 分类器为每个类别分配的分数:

>>> sgd_clf.decision_function([some_digit]).round()
array([[-31893., -34420.,  -9531.,   1824., -22320.,  -1386., -26189.,
 -16148.,  -4604., -12051.]])

您可以看到分类器对其预测并不是很自信:几乎所有分数都非常负面,而类别 3 的分数为+1,824,类别 5 也不远处为-1,386。当然,您会希望对这个分类器进行多个图像的评估。由于每个类别中的图像数量大致相同,准确度指标是可以接受的。通常情况下,您可以使用cross_val_score()函数来评估模型:

>>> cross_val_score(sgd_clf, X_train, y_train, cv=3, scoring="accuracy")
array([0.87365, 0.85835, 0.8689 ])

它在所有测试折叠上都超过了 85.8%。如果使用随机分类器,您将获得 10%的准确率,因此这并不是一个很差的分数,但您仍然可以做得更好。简单地缩放输入(如第二章中讨论的)可以将准确率提高到 89.1%以上:

>>> from sklearn.preprocessing import StandardScaler
>>> scaler = StandardScaler()
>>> X_train_scaled = scaler.fit_transform(X_train.astype("float64"))
>>> cross_val_score(sgd_clf, X_train_scaled, y_train, cv=3, scoring="accuracy")
array([0.8983, 0.891 , 0.9018])

错误分析

如果这是一个真实的项目,您现在将按照机器学习项目清单中的步骤进行操作(请参阅附录 A)。您将探索数据准备选项,尝试多个模型,列出最佳模型,使用GridSearchCV微调其超参数,并尽可能自动化。在这里,我们假设您已经找到了一个有希望的模型,并且想要找到改进它的方法。其中一种方法是分析它所犯的错误类型。

首先,看一下混淆矩阵。为此,您首先需要使用cross_val_predict()函数进行预测;然后您可以将标签和预测传递给confusion_matrix()函数,就像您之前所做的那样。然而,由于现在有 10 个类别而不是 2 个,混淆矩阵将包含相当多的数字,可能很难阅读。

彩色混淆矩阵图表更容易分析。要绘制这样的图表,请使用ConfusionMatrixDisplay.from_predictions()函数,如下所示:

from sklearn.metrics import ConfusionMatrixDisplay

y_train_pred = cross_val_predict(sgd_clf, X_train_scaled, y_train, cv=3)
ConfusionMatrixDisplay.from_predictions(y_train, y_train_pred)
plt.show()

这将生成图 3-9 中的左侧图。这个混淆矩阵看起来相当不错:大多数图像都在主对角线上,这意味着它们被正确分类了。请注意,对角线上的第 5 行和第 5 列的单元格看起来比其他数字稍暗。这可能是因为模型在 5 上犯了更多错误,或者因为数据集中的 5 比其他数字少。这就是通过将每个值除以相应(真实)类别中图像的总数(即除以行的总和)来对混淆矩阵进行归一化的重要性。这可以通过简单地设置normalize="true"来完成。我们还可以指定values_format=".0%"参数以显示没有小数的百分比。以下代码生成图 3-9 中右侧的图表:

ConfusionMatrixDisplay.from_predictions(y_train, y_train_pred,
                                        normalize="true", values_format=".0%")
plt.show()

现在我们可以很容易地看到,只有 82%的 5 的图像被正确分类。模型在 5 的图像中最常见的错误是将它们错误分类为 8:这发生在所有 5 的 10%中。但只有 2%的 8 被错误分类为 5;混淆矩阵通常不是对称的!如果你仔细观察,你会注意到许多数字被错误分类为 8,但从这个图表中并不立即明显。如果你想让错误更加突出,你可以尝试在正确预测上设置零权重。以下代码就是这样做的,并生成了图 3-10 中左边的图表:

sample_weight = (y_train_pred != y_train)
ConfusionMatrixDisplay.from_predictions(y_train, y_train_pred,
                                        sample_weight=sample_weight,
                                        normalize="true", values_format=".0%")
plt.show()

mls3 0309

图 3-9。混淆矩阵(左)和相同的通过行归一化的 CM(右)

mls3 0310

图 3-10。仅显示错误的混淆矩阵,通过行归一化(左)和通过列归一化(右)

现在你可以更清楚地看到分类器所犯的错误类型。类别 8 的列现在非常亮,这证实了许多图像被错误分类为 8。事实上,这是几乎所有类别中最常见的错误分类。但要注意如何解释这个图表中的百分比:记住我们已经排除了正确的预测。例如,第 7 行第 9 列的 36% 意味着 36%的所有 7 的图像被错误分类为 9。它意味着 36%的模型在 7 的图像上犯的错误被错误分类为 9。实际上,只有 3%的 7 的图像被错误分类为 9,你可以在右边的图表中看到图 3-9。

也可以通过列而不是通过行对混淆矩阵进行归一化:如果设置normalize="pred",你会得到图 3-10 中右边的图表。例如,你可以看到 56%的错误分类的 7 实际上是 9。

分析混淆矩阵通常可以让你了解如何改进你的分类器。从这些图表中看,你的努力应该花在减少错误的 8 上。例如,你可以尝试收集更多看起来像 8 的(但实际上不是)数字的训练数据,这样分类器就可以学会区分它们和真正的 8。或者你可以设计新的特征来帮助分类器,例如,编写一个算法来计算闭环的数量(例如,8 有两个,6 有一个,5 没有)。或者你可以预处理图像(例如,使用 Scikit-Image、Pillow 或 OpenCV)使一些模式,如闭环,更加突出。

分析单个错误也是了解你的分类器在做什么以及为什么失败的好方法。例如,让我们以混淆矩阵样式绘制 3 和 5 的示例(图 3-11):

cl_a, cl_b = '3', '5'
X_aa = X_train[(y_train == cl_a) & (y_train_pred == cl_a)]
X_ab = X_train[(y_train == cl_a) & (y_train_pred == cl_b)]
X_ba = X_train[(y_train == cl_b) & (y_train_pred == cl_a)]
X_bb = X_train[(y_train == cl_b) & (y_train_pred == cl_b)]
[...]  # plot all images in X_aa, X_ab, X_ba, X_bb in a confusion matrix style

mls3 0311

图 3-11。一些 3 和 5 的图像以混淆矩阵的方式组织

正如你所看到的,分类器错误分类的一些数字(即,左下角和右上角的块)写得非常糟糕,即使是人类也会难以分类。然而,大多数错误分类的图像对我们来说似乎是明显的错误。理解分类器为什么犯错可能很困难,但请记住,人类大脑是一个出色的模式识别系统,我们的视觉系统在任何信息到达我们的意识之前都进行了大量复杂的预处理。因此,这个任务看起来简单并不意味着它是简单的。回想一下,我们使用了一个简单的SGDClassifier,它只是一个线性模型:它只是为每个像素分配一个类别权重,当它看到一个新的图像时,它只是将加权像素强度相加以获得每个类别的得分。由于 3 和 5 之间只相差几个像素,这个模型很容易混淆它们。

3s 和 5s 之间的主要区别是连接顶线和底部弧线的小线的位置。如果你画一个 3,连接处稍微向左移动,分类器可能会将其分类为 5,反之亦然。换句话说,这个分类器对图像的移动和旋转非常敏感。减少 3/5 混淆的一种方法是预处理图像,确保它们居中且旋转不太多。然而,这可能并不容易,因为它需要预测每个图像的正确旋转。一个更简单的方法是通过增加训练集中略微移动和旋转的变体来增强训练集。这将迫使模型学会更容忍这些变化。这被称为数据增强(我们将在第十四章中介绍;也请参见本章末尾的练习 2)。

多标签分类

到目前为止,每个实例总是被分配到一个类。但在某些情况下,您可能希望您的分类器为每个实例输出多个类。考虑一个人脸识别分类器:如果它在同一张图片中识别出几个人,它应该做什么?它应该为它识别出的每个人附上一个标签。假设分类器已经训练好了识别三张脸:Alice、Bob 和 Charlie。那么当分类器看到 Alice 和 Charlie 的图片时,它应该输出[True, False, True](意思是“Alice 是,Bob 不是,Charlie 是”)。这样一个输出多个二进制标签的分类系统被称为多标签分类系统。

我们暂时不会讨论人脸识别,但让我们看一个更简单的例子,仅供说明目的:

import numpy as np
from sklearn.neighbors import KNeighborsClassifier

y_train_large = (y_train >= '7')
y_train_odd = (y_train.astype('int8') % 2 == 1)
y_multilabel = np.c_[y_train_large, y_train_odd]

knn_clf = KNeighborsClassifier()
knn_clf.fit(X_train, y_multilabel)

这段代码创建一个包含每个数字图像两个目标标签的y_multilabel数组:第一个指示数字是否大(7、8 或 9),第二个指示数字是否奇数。然后代码创建一个KNeighborsClassifier实例,支持多标签分类(并非所有分类器都支持),并使用多目标数组训练这个模型。现在您可以进行预测,并注意到它输出了两个标签:

>>> knn_clf.predict([some_digit])
array([[False,  True]])

而且它预测正确了!数字 5 确实不是大的(False)且是奇数(True)。

有许多方法可以评估多标签分类器,选择正确的度量标准取决于您的项目。一种方法是测量每个单独标签的 F[1]分数(或之前讨论过的任何其他二元分类器度量标准),然后简单地计算平均分数。以下代码计算所有标签的平均 F[1]分数:

>>> y_train_knn_pred = cross_val_predict(knn_clf, X_train, y_multilabel, cv=3)
>>> f1_score(y_multilabel, y_train_knn_pred, average="macro")
0.976410265560605

这种方法假设所有标签都同等重要,但这可能并非总是如此。特别是,如果您有比 Bob 或 Charlie 更多的 Alice 图片,您可能希望在 Alice 图片上给分类器的分数更多的权重。一个简单的选择是为每个标签赋予一个权重,等于其支持(即具有该目标标签的实例数)。要做到这一点,只需在调用f1_score()函数时设置average="weighted"。⁠⁵

如果您希望使用不原生支持多标签分类的分类器,比如SVC,一种可能的策略是为每个标签训练一个模型。然而,这种策略可能难以捕捉标签之间的依赖关系。例如,一个大数字(7、8 或 9)是奇数的可能性是偶数的两倍,但“奇数”标签的分类器不知道“大”标签的分类器预测了什么。为了解决这个问题,模型可以被组织成一个链:当一个模型做出预测时,它使用输入特征加上链中之前所有模型的预测。

好消息是,Scikit-Learn 有一个名为ChainClassifier的类,它就是做这个的!默认情况下,它将使用真实标签进行训练,根据它们在链中的位置为每个模型提供适当的标签。但是,如果设置cv超参数,它将使用交叉验证为训练集中的每个实例获取“干净”(样本外)预测,并且这些预测将用于以后在链中训练所有模型。以下是一个示例,展示如何使用交叉验证策略创建和训练ChainClassifier。与之前一样,我们将只使用训练集中的前 2,000 幅图像以加快速度:

from sklearn.multioutput import ClassifierChain

chain_clf = ClassifierChain(SVC(), cv=3, random_state=42)
chain_clf.fit(X_train[:2000], y_multilabel[:2000])

现在我们可以使用这个ChainClassifier进行预测:

>>> chain_clf.predict([some_digit])
array([[0., 1.]])

多输出分类

我们将在这里讨论的最后一种分类任务类型称为多输出-多类别分类(或多输出分类)。这是多标签分类的一种泛化,其中每个标签可以是多类别的(即,它可以有两个以上的可能值)。

为了说明这一点,让我们构建一个从图像中去除噪声的系统。它将以嘈杂的数字图像作为输入,然后(希望)输出一个干净的数字图像,表示为像 MNIST 图像一样的像素强度数组。请注意,分类器的输出是多标签的(每个像素一个标签),每个标签可以有多个值(像素强度范围从 0 到 255)。因此,这是一个多输出分类系统的示例。

注意

分类和回归之间的界限有时是模糊的,比如在这个例子中。可以说,预测像素强度更类似于回归而不是分类。此外,多输出系统不仅限于分类任务;您甚至可以拥有一个系统,它为每个实例输出多个标签,包括类标签和值标签。

让我们从使用 NumPy 的randint()函数向 MNIST 图像添加噪声来创建训练集和测试集。目标图像将是原始图像:

np.random.seed(42)  # to make this code example reproducible
noise = np.random.randint(0, 100, (len(X_train), 784))
X_train_mod = X_train + noise
noise = np.random.randint(0, 100, (len(X_test), 784))
X_test_mod = X_test + noise
y_train_mod = X_train
y_test_mod = X_test

让我们看一下测试集中的第一幅图像(图 3-12)。是的,我们正在窥探测试数据,所以您现在应该皱起眉头。

mls3 0312

图 3-12. 一幅嘈杂的图像(左)和目标干净图像(右)

左边是嘈杂的输入图像,右边是干净的目标图像。现在让我们训练分类器,让它清理这幅图像(图 3-13):

knn_clf = KNeighborsClassifier()
knn_clf.fit(X_train_mod, y_train_mod)
clean_digit = knn_clf.predict([X_test_mod[0]])
plot_digit(clean_digit)
plt.show()

mls3 0313

图 3-13. 清理后的图像

看起来接近目标了!这结束了我们的分类之旅。您现在知道如何为分类任务选择良好的度量标准,选择适当的精确度/召回率折衷,比较分类器,以及更一般地构建各种任务的良好分类系统。在接下来的章节中,您将了解您一直在使用的所有这些机器学习模型实际上是如何工作的。

练习

  1. 尝试为 MNIST 数据集构建一个分类器,在测试集上实现超过 97%的准确率。提示:KNeighborsClassifier对这个任务效果很好;您只需要找到好的超参数值(尝试在weightsn_neighbors超参数上进行网格搜索)。

  2. 编写一个函数,可以将 MNIST 图像向任何方向(左、右、上或下)移动一个像素。然后,对于训练集中的每个图像,创建四个移位副本(每个方向一个)并将它们添加到训练集中。最后,在这个扩展的训练集上训练您最好的模型,并在测试集上测量其准确率。您应该观察到您的模型现在表现得更好了!这种人为扩展训练集的技术称为数据增强训练集扩展

  3. 解决泰坦尼克号数据集。一个很好的开始地方是Kaggle。或者,您可以从https://homl.info/titanic.tgz下载数据并解压缩这个 tarball,就像您在第二章中为房屋数据所做的那样。这将给您两个 CSV 文件,train.csvtest.csv,您可以使用pandas.read_csv()加载。目标是训练一个分类器,可以根据其他列预测Survived列。

  4. 构建一个垃圾邮件分类器(一个更具挑战性的练习):

    1. Apache SpamAssassin 的公共数据集下载垃圾邮件和正常邮件的示例。

    2. 解压数据集并熟悉数据格式。

    3. 将数据分割为训练集和测试集。

    4. 编写一个数据准备流水线,将每封电子邮件转换为特征向量。您的准备流水线应该将一封电子邮件转换为一个(稀疏)向量,指示每个可能单词的存在或不存在。例如,如果所有电子邮件只包含四个单词,“Hello”、“how”、“are”、“you”,那么电子邮件“Hello you Hello Hello you”将被转换为向量[1, 0, 0, 1](表示[“Hello”存在,“how”不存在,“are”不存在,“you”存在]),或者如果您更喜欢计算每个单词出现的次数,则为[3, 0, 0, 2]。

      您可能希望在准备流水线中添加超参数,以控制是否剥离电子邮件头部,将每封电子邮件转换为小写,删除标点符号,用“URL”替换所有 URL,用“NUMBER”替换所有数字,甚至执行词干提取(即修剪单词结尾;有 Python 库可用于执行此操作)。

    5. 最后,尝试几种分类器,看看是否可以构建一个既具有高召回率又具有高精度的垃圾邮件分类器。

这些练习的解决方案可以在本章笔记本的末尾找到,网址为https://homl.info/colab3

¹ 默认情况下,Scikit-Learn 会将下载的数据集缓存到名为scikit_learn_data的目录中,该目录位于您的主目录中。

² fetch_openml()返回的数据集并不总是被洗牌或分割。

³ 在某些情况下,洗牌可能不是一个好主意——例如,如果您正在处理时间序列数据(如股票市场价格或天气状况)。我们将在第十五章中探讨这个问题。

⁴ Scikit-Learn 分类器总是具有decision_function()方法或predict_proba()方法,有时两者都有。

⁵ Scikit-Learn 提供了一些其他平均选项和多标签分类器指标;更多细节请参阅文档。

⁶ 您可以使用scipy.ndimage.interpolation模块中的shift()函数。例如,shift(image, [2, 1], cval=0)将图像向下移动两个像素,向右移动一个像素。

第四章:训练模型

到目前为止,我们大多将机器学习模型及其训练算法视为黑匣子。如果您在之前章节的一些练习中有所了解,您可能会对不知道底层原理的情况下能做多少事情感到惊讶:您优化了一个回归系统,改进了一个数字图像分类器,甚至从头开始构建了一个垃圾邮件分类器,所有这些都是在不知道它们实际如何工作的情况下完成的。实际上,在许多情况下,您并不真正需要知道实现细节。

然而,对事物如何运作有一个良好的理解可以帮助您快速找到适当的模型、正确的训练算法以及适合您任务的一组良好的超参数。了解底层原理还将帮助您更有效地调试问题并执行错误分析。最后,本章讨论的大多数主题将对理解、构建和训练神经网络(本书的第二部分中讨论)至关重要。

在本章中,我们将首先看一下线性回归模型,这是最简单的模型之一。我们将讨论两种非常不同的训练方法:

  • 使用一个“封闭形式”方程⁠¹直接计算最适合训练集的模型参数(即最小化训练集上成本函数的模型参数)。

  • 使用一种称为梯度下降(GD)的迭代优化方法,逐渐调整模型参数以最小化训练集上的成本函数,最终收敛到与第一种方法相同的参数集。我们将看一下几种梯度下降的变体,当我们研究神经网络时会一再使用:批量 GD、小批量 GD 和随机 GD。

接下来我们将看一下多项式回归,这是一个可以拟合非线性数据集的更复杂模型。由于这个模型比线性回归有更多的参数,所以更容易过拟合训练数据。我们将探讨如何通过学习曲线检测是否存在这种情况,然后我们将看一下几种正则化技术,可以减少过拟合训练集的风险。

最后,我们将研究另外两种常用于分类任务的模型:逻辑回归和 softmax 回归。

警告

本章将包含相当多的数学方程,使用线性代数和微积分的基本概念。要理解这些方程,您需要知道向量和矩阵是什么;如何转置、相乘和求逆;以及什么是偏导数。如果您对这些概念不熟悉,请查看在线补充材料中作为 Jupyter 笔记本提供的线性代数和微积分入门教程。对于那些真正对数学过敏的人,您仍然应该阅读本章,并简单跳过方程;希望文本足以帮助您理解大部分概念。

线性回归

在第一章中,我们看了一个关于生活满意度的简单回归模型:

life_satisfaction = θ[0] + θ[1] × GDP_per_capita

该模型只是输入特征GDP_per_capita的线性函数。θ[0]和θ[1]是模型的参数。

更一般地,线性模型通过简单地计算输入特征的加权和加上一个称为偏置项(也称为截距项)的常数来进行预测,如方程 4-1 所示。

方程 4-1。线性回归模型预测

y ^ = θ 0 + θ 1 x 1 + θ 2 x 2 + + θ n x n

在这个方程中:

  • ŷ是预测值。

  • n是特征数量。

  • x[i]是第i个特征值。

  • θ[j]是第j个模型参数,包括偏置项θ[0]和特征权重θ[1]、θ[2]、⋯、θ[n]。

这可以用矢量化形式更简洁地表示,如方程 4-2 所示。

方程 4-2. 线性回归模型预测(矢量化形式)

y^=hθ(x)=θ·x

在这个方程中:

  • h[θ]是假设函数,使用模型参数θ

  • θ是模型的参数向量,包括偏置项θ[0]和特征权重θ[1]到θ[n]。

  • x是实例的特征向量,包含x[0]到x[n],其中x[0]始终等于 1。

  • θ · x是向量θx的点积,等于θ[0]x[0] + θ[1]x[1] + θ[2]x[2] + ... + θ[n]x[n]。

注意

在机器学习中,向量通常表示为列向量,这是具有单列的二维数组。如果θx是列向量,那么预测值为y^=θx,其中θθ转置(行向量而不是列向量),θxθx的矩阵乘法。当然,这是相同的预测,只是现在表示为单元格矩阵而不是标量值。在本书中,我将使用这种表示法,以避免在点积和矩阵乘法之间切换。

好的,这就是线性回归模型,但我们如何训练它呢?嗯,回想一下,训练模型意味着设置其参数,使模型最好地适应训练集。为此,我们首先需要一个衡量模型与训练数据拟合程度的指标。在第二章中,我们看到回归模型最常见的性能指标是均方根误差(方程 2-1)。因此,要训练线性回归模型,我们需要找到最小化 RMSE 的θ的值。在实践中,最小化均方误差(MSE)比最小化 RMSE 更简单,并且会导致相同的结果(因为最小化正函数的值也会最小化其平方根)。

警告

在训练期间,学习算法通常会优化不同的损失函数,而不是用于评估最终模型的性能指标。这通常是因为该函数更容易优化和/或因为在训练期间仅需要额外的项(例如,用于正则化)。一个好的性能指标应尽可能接近最终的业务目标。一个好的训练损失易于优化,并且与指标强相关。例如,分类器通常使用成本函数进行训练,如对数损失(稍后在本章中将看到),但使用精度/召回率进行评估。对数损失易于最小化,这样做通常会提高精度/召回率。

线性回归假设h[θ]在训练集X上的 MSE 是使用方程 4-3 计算的。

方程 4-3. 线性回归模型的 MSE 成本函数

MSE ( X , h θ ) = 1 m i=1 m (θ x (i) -y (i) ) 2

大多数这些符号在第二章中已经介绍过(参见“符号”)。唯一的区别是我们写h[θ]而不是只写h,以明确模型是由向量θ参数化的。为了简化符号,我们将只写 MSE(θ)而不是 MSE(X, h[θ])。

正规方程

为了找到最小化 MSE 的θ的值,存在一个闭式解——换句话说,一个直接给出结果的数学方程。这被称为正规方程(方程 4-4)。

方程 4-4. 正规方程

θ ^ = (X X) -1 X y

在这个方程中:

  • θ^是最小化成本函数的θ的值。

  • y是包含y((1))到*y*((m))的目标值向量。

让我们生成一些看起来线性的数据来测试这个方程(图 4-1):

import numpy as np

np.random.seed(42)  # to make this code example reproducible
m = 100  # number of instances
X = 2 * np.random.rand(m, 1)  # column vector
y = 4 + 3 * X + np.random.randn(m, 1)  # column vector

mls3 0401

图 4-1. 随机生成的线性数据集

现在让我们使用正规方程计算θ^。我们将使用 NumPy 的线性代数模块(np.linalg)中的inv()函数计算矩阵的逆,以及矩阵乘法的dot()方法:

from sklearn.preprocessing import add_dummy_feature

X_b = add_dummy_feature(X)  # add x0 = 1 to each instance
theta_best = np.linalg.inv(X_b.T @ X_b) @ X_b.T @ y
注意

@运算符执行矩阵乘法。如果AB是 NumPy 数组,则A @ B等同于np.matmul(A, B)。许多其他库,如 TensorFlow、PyTorch 和 JAX,也支持@运算符。但是,不能在纯 Python 数组(即列表的列表)上使用@

我们用来生成数据的函数是y = 4 + 3x[1] + 高斯噪声。让我们看看方程找到了什么:

>>> theta_best
array([[4.21509616],
 [2.77011339]])

我们希望θ[0] = 4 和θ[1] = 3,而不是θ[0] = 4.215 和θ[1] = 2.770。足够接近,但噪声使得无法恢复原始函数的确切参数。数据集越小且噪声越大,问题就越困难。

现在我们可以使用θ^进行预测:

>>> X_new = np.array([[0], [2]])
>>> X_new_b = add_dummy_feature(X_new)  # add x0 = 1 to each instance
>>> y_predict = X_new_b @ theta_best
>>> y_predict
array([[4.21509616],
 [9.75532293]])

让我们绘制这个模型的预测(图 4-2):

import matplotlib.pyplot as plt

plt.plot(X_new, y_predict, "r-", label="Predictions")
plt.plot(X, y, "b.")
[...]  # beautify the figure: add labels, axis, grid, and legend
plt.show()

mls3 0402

图 4-2. 线性回归模型预测

使用 Scikit-Learn 执行线性回归相对简单:

>>> from sklearn.linear_model import LinearRegression
>>> lin_reg = LinearRegression()
>>> lin_reg.fit(X, y)
>>> lin_reg.intercept_, lin_reg.coef_
(array([4.21509616]), array([[2.77011339]]))
>>> lin_reg.predict(X_new)
array([[4.21509616],
 [9.75532293]])

请注意,Scikit-Learn 将偏置项(intercept_)与特征权重(coef_)分开。LinearRegression类基于scipy.linalg.lstsq()函数(名称代表“最小二乘法”),您可以直接调用该函数:

>>> theta_best_svd, residuals, rank, s = np.linalg.lstsq(X_b, y, rcond=1e-6)
>>> theta_best_svd
array([[4.21509616],
 [2.77011339]])

这个函数计算θ^=X+y,其中X+X伪逆(具体来说,是 Moore-Penrose 逆)。您可以使用np.linalg.pinv()直接计算伪逆:

>>> np.linalg.pinv(X_b) @ y
array([[4.21509616],
 [2.77011339]])

伪逆本身是使用称为奇异值分解(SVD)的标准矩阵分解技术计算的,可以将训练集矩阵X分解为三个矩阵U Σ V^⊺的矩阵乘法(参见numpy.linalg.svd())。伪逆计算为X+=VΣ+U。为了计算矩阵Σ+,算法取Σ并将小于一个微小阈值的所有值设为零,然后用它们的倒数替换所有非零值,最后转置结果矩阵。这种方法比计算正规方程更有效,而且可以很好地处理边缘情况:实际上,如果矩阵X^⊺X不可逆(即奇异),例如如果m<n或者某些特征是冗余的,那么正规方程可能无法工作,但伪逆总是被定义的。

计算复杂度

正规方程计算X⊺**X**的逆,这是一个(*n*+1)×(*n*+1)矩阵(其中*n*是特征数)。求解这样一个矩阵的*计算复杂度*通常约为*O*(*n*(2.4))到O(n³),取决于实现。换句话说,如果特征数翻倍,计算时间大约会乘以 2^(2.4)=5.3 到 2³=8。

Scikit-Learn 的LinearRegression类使用的 SVD 方法大约是O(n²)。如果特征数量翻倍,计算时间大约会乘以 4。

警告

当特征数量增多时(例如 100,000),正规方程和 SVD 方法都变得非常慢。积极的一面是,它们都与训练集中实例数量线性相关(它们是O(m)),因此它们可以有效地处理大型训练集,只要它们可以放入内存。

此外,一旦训练好线性回归模型(使用正规方程或任何其他算法),预测速度非常快:计算复杂度与您要进行预测的实例数量和特征数量成正比。换句话说,对两倍实例(或两倍特征)进行预测将花费大约两倍的时间。

现在我们将看一种非常不同的训练线性回归模型的方法,这种方法更适用于特征数量较多或训练实例太多无法放入内存的情况。

梯度下降

梯度下降是一种通用的优化算法,能够找到各种问题的最优解。梯度下降的一般思想是迭代地调整参数,以最小化成本函数。

假设你在浓雾中的山中迷失了方向,只能感受到脚下的坡度。快速到达山谷底部的一个好策略是沿着最陡的坡度方向下坡。这正是梯度下降所做的:它测量了关于参数向量θ的误差函数的局部梯度,并沿着下降梯度的方向前进。一旦梯度为零,你就到达了一个最小值!

在实践中,您首先用随机值填充θ(这称为随机初始化)。然后逐渐改进它,每次尝试减少成本函数(例如 MSE)一点点,直到算法收敛到最小值(参见图 4-3)。

mls3 0403

图 4-3。在这个梯度下降的描述中,模型参数被随机初始化,并不断调整以最小化成本函数;学习步长大小与成本函数的斜率成比例,因此随着成本接近最小值,步长逐渐变小

梯度下降中的一个重要参数是步长的大小,由学习率超参数确定。如果学习率太小,那么算法将需要经过许多迭代才能收敛,这将花费很长时间(参见图 4-4)。

mls3 0404

图 4-4。学习率太小

另一方面,如果学习率太高,您可能会跳过山谷,最终停在另一侧,甚至可能比之前更高。这可能导致算法发散,产生越来越大的值,无法找到一个好的解决方案(参见图 4-5)。

mls3 0405

图 4-5。学习率太高

此外,并非所有成本函数都像漂亮的、规则的碗一样。可能会有洞、脊、高原和各种不规则的地形,使得收敛到最小值变得困难。图 4-6 展示了梯度下降的两个主要挑战。如果随机初始化将算法开始于左侧,则它将收敛到局部最小值,这不如全局最小值好。如果它从右侧开始,则穿过高原将需要很长时间。如果您停得太早,您将永远无法达到全局最小值。

mls3 0406

图 4-6。梯度下降的陷阱

幸运的是,线性回归模型的 MSE 成本函数恰好是一个凸函数,这意味着如果您选择曲线上的任意两点,连接它们的线段永远不会低于曲线。这意味着没有局部最小值,只有一个全局最小值。它还是一个连续函数,斜率永远不会突然改变。这两个事实有一个重要的结果:梯度下降保证可以无限接近全局最小值(如果等待足够长的时间且学习率不太高)。

虽然成本函数的形状像一个碗,但如果特征具有非常不同的比例,它可能是一个延长的碗。图 4-7 展示了在特征 1 和 2 具有相同比例的训练集上的梯度下降(左侧),以及在特征 1 的值远小于特征 2 的训练集上的梯度下降(右侧)。

mls3 0407

图 4-7。特征缩放的梯度下降(左)和不缩放的梯度下降(右)

正如您所看到的,左侧的梯度下降算法直接朝向最小值,因此快速到达,而右侧首先朝向几乎与全局最小值方向正交的方向,最终沿着几乎平坦的山谷长途跋涉。它最终会到达最小值,但需要很长时间。

警告

在使用梯度下降时,您应确保所有特征具有相似的比例(例如,使用 Scikit-Learn 的StandardScaler类),否则收敛所需的时间将更长。

这个图表还说明了训练模型意味着寻找一组模型参数的组合,使得成本函数(在训练集上)最小化。这是在模型的参数空间中进行的搜索。模型的参数越多,空间的维度就越多,搜索就越困难:在一个 300 维的草堆中搜索一根针比在 3 维空间中要困难得多。幸运的是,由于线性回归的情况下成本函数是凸的,所以这根针就在碗底。

批量梯度下降

要实现梯度下降,您需要计算成本函数相对于每个模型参数θ[j]的梯度。换句话说,您需要计算如果您稍微改变θ[j],成本函数将如何变化。这被称为偏导数。这就像问,“如果我面向东,脚下的山坡有多陡?”然后面向北问同样的问题(如果您可以想象一个超过三维的宇宙,那么其他维度也是如此)。方程 4-5 计算了关于参数θ[j]的 MSE 的偏导数,表示为∂ MSE(θ) / ∂θ[j]。

方程 4-5. 成本函数的偏导数

θ j MSE ( θ ) = 2 m i=1 m ( θ x (i) - y (i) ) x j (i)

与单独计算这些偏导数不同,您可以使用方程 4-6 一次性计算它们。梯度向量,表示为∇[θ]MSE(θ),包含成本函数的所有偏导数(每个模型参数一个)。

方程 4-6. 成本函数的梯度向量

θ MSE ( θ ) = θ 0 MSE ( θ ) θ 1 MSE ( θ ) θ n MSE ( θ ) = 2 m X ( X θ - y )

警告

请注意,这个公式涉及对整个训练集X进行计算,每次梯度下降步骤都要进行!这就是为什么该算法被称为批量梯度下降:它在每一步使用整个批量的训练数据(实际上,全梯度下降可能是一个更好的名称)。因此,在非常大的训练集上,它非常慢(我们很快将看到一些更快的梯度下降算法)。然而,梯度下降随着特征数量的增加而扩展得很好;当特征数量达到数十万时,使用梯度下降训练线性回归模型比使用正规方程或 SVD 分解要快得多。

一旦有了指向上坡的梯度向量,只需朝相反方向前进以下坡。这意味着从θ中减去∇[θ]MSE(θ)。这就是学习率η发挥作用的地方:⁠⁴将梯度向量乘以η来确定下坡步长的大小(方程 4-7)。

方程 4-7. 梯度下降步骤

θ(下一步)=θ-ηθMSE(θ)

让我们快速实现这个算法:

eta = 0.1  # learning rate
n_epochs = 1000
m = len(X_b)  # number of instances

np.random.seed(42)
theta = np.random.randn(2, 1)  # randomly initialized model parameters

for epoch in range(n_epochs):
    gradients = 2 / m * X_b.T @ (X_b @ theta - y)
    theta = theta - eta * gradients

这并不难!每次对训练集的迭代称为epoch。让我们看看得到的theta

>>> theta
array([[4.21509616],
 [2.77011339]])

嘿,这正是正规方程找到的!梯度下降完美地工作了。但是如果您使用了不同的学习率(eta)会怎样呢?图 4-8 显示了使用三种不同学习率的梯度下降的前 20 步。每个图中底部的线代表随机起始点,然后每个迭代由越来越深的线表示。

mls3 0408

图 4-8. 不同学习率的梯度下降

在左侧,学习率太低:算法最终会达到解,但需要很长时间。在中间,学习率看起来相当不错:在几个迭代中,它已经收敛到解。在右侧,学习率太高:算法发散,跳来跳去,实际上每一步都离解越来越远。

要找到一个好的学习率,可以使用网格搜索(参见第二章)。然而,您可能希望限制迭代次数,以便网格搜索可以消除收敛时间过长的模型。

您可能想知道如何设置迭代次数。如果太低,当算法停止时,您仍然离最优解很远;但如果太高,您将浪费时间,因为模型参数不再改变。一个简单的解决方案是设置一个非常大的迭代次数,但在梯度向量变得微小时中断算法——也就是说,当其范数小于一个微小数ϵ(称为容差)时——因为这表示梯度下降已经(几乎)达到了最小值。

随机梯度下降

批量梯度下降的主要问题在于,它在每一步使用整个训练集来计算梯度,这使得在训练集很大时非常缓慢。相反,随机梯度下降 在每一步选择训练集中的一个随机实例,并仅基于该单个实例计算梯度。显然,一次只处理一个实例使得算法更快,因为每次迭代时需要操作的数据量很少。这也使得在庞大的训练集上进行训练成为可能,因为每次迭代只需要一个实例在内存中(随机梯度下降可以作为一种离线算法实现;参见第一章)。

另一方面,由于其随机(即随机)性质,这种算法比批量梯度下降不规则得多:成本函数不会温和地减少直到达到最小值,而是会上下波动,仅平均减少。随着时间的推移,它最终会非常接近最小值,但一旦到达那里,它将继续上下波动,永远不会稳定下来(参见图 4-9)。一旦算法停止,最终的参数值将是不错的,但不是最优的。

mls3 0409

图 4-9。使用随机梯度下降,每个训练步骤比使用批量梯度下降快得多,但也更不规则。

当成本函数非常不规则时(如图 4-6 中所示),这实际上可以帮助算法跳出局部最小值,因此随机梯度下降比批量梯度下降更有可能找到全局最小值。

因此,随机性有助于摆脱局部最优解,但也不好,因为这意味着算法永远无法稳定在最小值处。解决这一困境的一个方法是逐渐降低学习率。步骤开始很大(有助于快速取得进展并摆脱局部最小值),然后变得越来越小,允许算法在全局最小值处稳定下来。这个过程类似于模拟退火,这是一种受金属冶炼过程启发的算法,其中熔化的金属被慢慢冷却。确定每次迭代学习率的函数称为学习计划。如果学习率降低得太快,您可能会陷入局部最小值,甚至最终冻结在最小值的一半。如果学习率降低得太慢,您可能会在最小值周围跳来跳去很长时间,并且如果您在训练过早停止,最终会得到一个次优解。

此代码使用简单的学习计划实现随机梯度下降:

n_epochs = 50
t0, t1 = 5, 50  # learning schedule hyperparameters

def learning_schedule(t):
    return t0 / (t + t1)

np.random.seed(42)
theta = np.random.randn(2, 1)  # random initialization

for epoch in range(n_epochs):
    for iteration in range(m):
        random_index = np.random.randint(m)
        xi = X_b[random_index : random_index + 1]
        yi = y[random_index : random_index + 1]
        gradients = 2 * xi.T @ (xi @ theta - yi)  # for SGD, do not divide by m
        eta = learning_schedule(epoch * m + iteration)
        theta = theta - eta * gradients

按照惯例,我们按照m次迭代的轮次进行迭代;每一轮称为epoch,如前所述。虽然批量梯度下降代码通过整个训练集迭代了 1,000 次,但这段代码只通过训练集迭代了 50 次,并达到了一个相当不错的解决方案:

>>> theta
array([[4.21076011],
 [2.74856079]])

图 4-10 显示了训练的前 20 步(请注意步骤的不规则性)。

请注意,由于实例是随机选择的,一些实例可能在每个 epoch 中被多次选择,而其他实例可能根本不被选择。如果您想确保算法在每个 epoch 中通过每个实例,另一种方法是对训练集进行洗牌(确保同时洗牌输入特征和标签),然后逐个实例地进行,然后再次洗牌,依此类推。然而,这种方法更复杂,通常不会改善结果。

mls3 0410

图 4-10。随机梯度下降的前 20 步
警告

在使用随机梯度下降时,训练实例必须是独立同分布的(IID),以确保参数平均被拉向全局最优解。确保这一点的一个简单方法是在训练期间对实例进行洗牌(例如,随机选择每个实例,或在每个 epoch 开始时对训练集进行洗牌)。如果不对实例进行洗牌,例如,如果实例按标签排序,则 SGD 将从优化一个标签开始,然后是下一个标签,依此类推,并且不会接近全局最小值。

要使用 Scikit-Learn 进行随机梯度下降线性回归,您可以使用SGDRegressor类,默认情况下优化 MSE 成本函数。以下代码最多运行 1,000 个时代(max_iter)或在 100 个时代内损失下降不到 10^(–5)(tol)时停止(n_iter_no_change)。它以学习率 0.01(eta0)开始,使用默认学习计划(与我们使用的不同)。最后,它不使用任何正则化(penalty=None;稍后会详细介绍):

from sklearn.linear_model import SGDRegressor

sgd_reg = SGDRegressor(max_iter=1000, tol=1e-5, penalty=None, eta0=0.01,
                       n_iter_no_change=100, random_state=42)
sgd_reg.fit(X, y.ravel())  # y.ravel() because fit() expects 1D targets

再次,您会发现解决方案与正规方程返回的解非常接近:

>>> sgd_reg.intercept_, sgd_reg.coef_
(array([4.21278812]), array([2.77270267]))
提示

所有 Scikit-Learn 估计器都可以使用fit()方法进行训练,但有些估计器还有一个partial_fit()方法,您可以调用它来对一个或多个实例运行一轮训练(它会忽略max_itertol等超参数)。反复调用partial_fit()会逐渐训练模型。当您需要更多控制训练过程时,这是很有用的。其他模型则有一个warm_start超参数(有些模型两者都有):如果您设置warm_start=True,在已训练的模型上调用fit()方法不会重置模型;它将继续训练在哪里停止,遵守max_itertol等超参数。请注意,fit()会重置学习计划使用的迭代计数器,而partial_fit()不会。

小批量梯度下降

我们将要看的最后一个梯度下降算法称为小批量梯度下降。一旦您了解了批量梯度下降和随机梯度下降,这就很简单了:在每一步中,小批量梯度下降不是基于完整训练集(批量梯度下降)或仅基于一个实例(随机梯度下降)计算梯度,而是在称为小批量的小随机实例集上计算梯度。小批量梯度下降相对于随机梯度下降的主要优势在于,您可以通过硬件优化矩阵运算获得性能提升,尤其是在使用 GPU 时。

该算法在参数空间中的进展比随机梯度下降更加稳定,尤其是在使用相当大的小批量时。因此,小批量梯度下降最终会比随机梯度下降更接近最小值,但它可能更难逃离局部最小值(在存在局部最小值的问题中,不同于具有 MSE 成本函数的线性回归)。图 4-11 显示了训练过程中三种梯度下降算法在参数空间中的路径。它们最终都接近最小值,但批量梯度下降的路径实际上停在最小值处,而随机梯度下降和小批量梯度下降则继续移动。但是,请不要忘记,批量梯度下降需要很长时间才能完成每一步,如果您使用良好的学习计划,随机梯度下降和小批量梯度下降也会达到最小值。

mls3 0411

图 4-11. 参数空间中的梯度下降路径

表 4-1 比较了迄今为止我们讨论过的线性回归算法(请回忆m是训练实例的数量,n是特征的数量)。

表 4-1. 线性回归算法比较

算法 m 支持离线 n 超参数 需要缩放 Scikit-Learn
正规方程 0 N/A
SVD 0 LinearRegression
批量梯度下降 2 N/A
随机梯度下降 ≥2 SGDRegressor
小批量梯度下降 ≥2 N/A

训练后几乎没有区别:所有这些算法最终得到非常相似的模型,并以完全相同的方式进行预测。

多项式回归

如果你的数据比一条直线更复杂怎么办?令人惊讶的是,你可以使用线性模型来拟合非线性数据。一个简单的方法是将每个特征的幂作为新特征添加,然后在这个扩展的特征集上训练线性模型。这种技术称为多项式回归

让我们看一个例子。首先,我们将生成一些非线性数据(参见图 4-12),基于一个简单的二次方程——即形式为y = ax² + bx + c的方程——再加上一些噪声:

np.random.seed(42)
m = 100
X = 6 * np.random.rand(m, 1) - 3
y = 0.5 * X ** 2 + X + 2 + np.random.randn(m, 1)

mls3 0412

图 4-12。生成的非线性和嘈杂数据集

显然,一条直线永远无法正确拟合这些数据。因此,让我们使用 Scikit-Learn 的PolynomialFeatures类来转换我们的训练数据,将训练集中每个特征的平方(二次多项式)作为新特征添加到训练数据中(在这种情况下只有一个特征):

>>> from sklearn.preprocessing import PolynomialFeatures
>>> poly_features = PolynomialFeatures(degree=2, include_bias=False)
>>> X_poly = poly_features.fit_transform(X)
>>> X[0]
array([-0.75275929])
>>> X_poly[0]
array([-0.75275929,  0.56664654])

X_poly现在包含了X的原始特征以及该特征的平方。现在我们可以将LinearRegression模型拟合到这个扩展的训练数据上(图 4-13):

>>> lin_reg = LinearRegression()
>>> lin_reg.fit(X_poly, y)
>>> lin_reg.intercept_, lin_reg.coef_
(array([1.78134581]), array([[0.93366893, 0.56456263]]))

mls3 0413

图 4-13。多项式回归模型预测

不错:模型估计y ^ = 0.56 x 1 2 + 0.93 x 1 + 1.78,而实际上原始函数是y = 0.5 x 1 2 + 1.0 x 1 + 2.0 + 高斯噪声

请注意,当存在多个特征时,多项式回归能够找到特征之间的关系,这是普通线性回归模型无法做到的。这是因为PolynomialFeatures还会添加给定次数的所有特征组合。例如,如果有两个特征abPolynomialFeaturesdegree=3不仅会添加特征a²、a³、b²和b³,还会添加组合aba²bab²。

警告

PolynomialFeatures(degree=*d*)将包含n个特征的数组转换为包含(n + d)! / d!n!个特征的数组,其中n!是n阶乘,等于 1 × 2 × 3 × ⋯ × n。注意特征数量的组合爆炸!

学习曲线

如果进行高次多项式回归,你很可能会比普通线性回归更好地拟合训练数据。例如,图 4-14 将一个 300 次多项式模型应用于前面的训练数据,并将结果与纯线性模型和二次模型(二次多项式)进行比较。请注意,300 次多项式模型在训练实例周围摆动以尽可能接近训练实例。

mls3 0414

图 4-14。高次多项式回归

这个高次多项式回归模型严重过拟合了训练数据,而线性模型则欠拟合了。在这种情况下,最能泛化的模型是二次模型,这是有道理的,因为数据是使用二次模型生成的。但通常你不会知道是什么函数生成了数据,那么你如何决定模型应该有多复杂呢?你如何判断你的模型是过拟合还是欠拟合了数据?

在第二章中,您使用交叉验证来估计模型的泛化性能。如果模型在训练数据上表现良好,但根据交叉验证指标泛化能力差,那么您的模型是过拟合的。如果两者表现都不好,那么它是拟合不足的。这是判断模型过于简单或过于复杂的一种方法。

另一种方法是查看学习曲线,这是模型的训练误差和验证误差作为训练迭代的函数的图表:只需在训练集和验证集上定期评估模型,并绘制结果。如果模型无法进行增量训练(即,如果它不支持partial_fit()warm_start),那么您必须在逐渐扩大的训练集子集上多次训练它。

Scikit-Learn 有一个有用的learning_curve()函数来帮助解决这个问题:它使用交叉验证来训练和评估模型。默认情况下,它会在不断增长的训练集子集上重新训练模型,但如果模型支持增量学习,您可以在调用learning_curve()时设置exploit_incremental_learning=True,它将逐步训练模型。该函数返回评估模型的训练集大小,以及每个大小和每个交叉验证折叠的训练和验证分数。让我们使用这个函数来查看普通线性回归模型的学习曲线(参见图 4-15):

from sklearn.model_selection import learning_curve

train_sizes, train_scores, valid_scores = learning_curve(
    LinearRegression(), X, y, train_sizes=np.linspace(0.01, 1.0, 40), cv=5,
    scoring="neg_root_mean_squared_error")
train_errors = -train_scores.mean(axis=1)
valid_errors = -valid_scores.mean(axis=1)

plt.plot(train_sizes, train_errors, "r-+", linewidth=2, label="train")
plt.plot(train_sizes, valid_errors, "b-", linewidth=3, label="valid")
[...]  # beautify the figure: add labels, axis, grid, and legend
plt.show()

mls3 0415

图 4-15. 学习曲线

这个模型拟合不足。为了了解原因,首先让我们看看训练误差。当训练集中只有一个或两个实例时,模型可以完美拟合它们,这就是曲线从零开始的原因。但随着新实例被添加到训练集中,模型无法完美拟合训练数据,因为数据存在噪声,而且根本不是线性的。因此,训练数据的误差会上升,直到达到一个平台,在这一点上,向训练集添加新实例不会使平均误差变得更好或更糟。现在让我们看看验证误差。当模型在非常少的训练实例上训练时,它无法正确泛化,这就是为什么验证误差最初相当大的原因。然后,随着模型展示更多的训练示例,它学习,因此验证误差慢慢下降。然而,再次,一条直线无法很好地对数据建模,因此误差最终会达到一个接近另一条曲线的平台。

这些学习曲线是典型的拟合不足模型。两条曲线都达到了一个平台;它们接近且相当高。

提示

如果您的模型对训练数据拟合不足,增加更多的训练样本将无济于事。您需要使用更好的模型或提出更好的特征。

现在让我们看看相同数据上 10 次多项式模型的学习曲线(参见图 4-16):

from sklearn.pipeline import make_pipeline

polynomial_regression = make_pipeline(
    PolynomialFeatures(degree=10, include_bias=False),
    LinearRegression())

train_sizes, train_scores, valid_scores = learning_curve(
    polynomial_regression, X, y, train_sizes=np.linspace(0.01, 1.0, 40), cv=5,
    scoring="neg_root_mean_squared_error")
[...]  # same as earlier

mls3 0416

图 4-16. 10 次多项式模型的学习曲线

这些学习曲线看起来有点像之前的曲线,但有两个非常重要的区别:

  • 训练数据上的误差比以前低得多。

  • 曲线之间存在差距。这意味着模型在训练数据上的表现明显优于验证数据,这是过拟合模型的标志。然而,如果您使用更大的训练集,这两条曲线将继续接近。

提示

改进过拟合模型的一种方法是提供更多的训练数据,直到验证误差达到训练误差。

正则化线性模型

正如您在第一章和第二章中看到的,减少过拟合的一个好方法是对模型进行正则化(即,约束它):它的自由度越少,过拟合数据的难度就越大。对多项式模型进行正则化的一种简单方法是减少多项式次数。

对于线性模型,通常通过约束模型的权重来实现正则化。我们现在将看一下岭回归、套索回归和弹性网络回归,它们实现了三种不同的约束权重的方式。

岭回归

岭回归(也称为Tikhonov 正则化)是线性回归的正则化版本:一个等于αmi=1nθi2正则化项被添加到 MSE 中。这迫使学习算法不仅拟合数据,还要尽量保持模型权重尽可能小。请注意,正则化项应该只在训练期间添加到成本函数中。一旦模型训练完成,您希望使用未经正则化的 MSE(或 RMSE)来评估模型的性能。

超参数α控制着您希望对模型进行多少正则化。如果α=0,则岭回归就是线性回归。如果α非常大,则所有权重最终都非常接近零,结果是一条通过数据均值的平坦线。方程 4-8 呈现了岭回归成本函数。⁠⁷

方程 4-8。岭回归成本函数

J(θ)=MSE(θ)+αmi=1nθi2

请注意,偏置项θ[0]不被正则化(总和从i=1 开始,而不是 0)。如果我们将w定义为特征权重的向量(θ[1]到θ[n]),则正则化项等于α(∥ w ∥[2])² / m,其中∥ w ∥[2]表示权重向量的ℓ[2]范数。⁠⁸ 对于批量梯度下降,只需将 2αw / m添加到对应于特征权重的 MSE 梯度向量的部分,而不要将任何内容添加到偏置项的梯度(参见方程 4-6)。

警告

在执行岭回归之前,重要的是对数据进行缩放(例如,使用StandardScaler),因为它对输入特征的规模敏感。这对大多数正则化模型都是正确的。

图 4-17 显示了在一些非常嘈杂的线性数据上使用不同α值训练的几个岭模型。在左侧,使用普通的岭模型,导致线性预测。在右侧,首先使用PolynomialFeatures(degree=10)扩展数据,然后使用StandardScaler进行缩放,最后将岭模型应用于生成的特征:这是带有岭正则化的多项式回归。请注意,增加α会导致更平缓(即,更不极端,更合理)的预测,从而减少模型的方差但增加其偏差。

mls3 0417

图 4-17。线性(左)和多项式(右)模型,都具有不同级别的岭正则化

与线性回归一样,我们可以通过计算闭式方程或执行梯度下降来执行岭回归。优缺点是相同的。方程 4-9 显示了闭式解,其中A是(n + 1) × (n + 1) 单位矩阵,⁠⁹除了左上角的单元格为 0,对应于偏置项。

方程 4-9. 岭回归闭式解

θ ^ = (X X+αA) -1 X y

以下是如何使用 Scikit-Learn 执行岭回归的闭式解(一种方程 4-9 的变体,使用 André-Louis Cholesky 的矩阵分解技术):

>>> from sklearn.linear_model import Ridge
>>> ridge_reg = Ridge(alpha=0.1, solver="cholesky")
>>> ridge_reg.fit(X, y)
>>> ridge_reg.predict([[1.5]])
array([[1.55325833]])

使用随机梯度下降:⁠¹⁰

>>> sgd_reg = SGDRegressor(penalty="l2", alpha=0.1 / m, tol=None,
...                        max_iter=1000, eta0=0.01, random_state=42)
...
>>> sgd_reg.fit(X, y.ravel())  # y.ravel() because fit() expects 1D targets
>>> sgd_reg.predict([[1.5]])
array([1.55302613])

penalty超参数设置要使用的正则化项的类型。指定"l2"表示您希望 SGD 将正则化项添加到 MSE 成本函数中,等于alpha乘以权重向量的ℓ[2]范数的平方。这就像岭回归一样,只是在这种情况下没有除以m;这就是为什么我们传递alpha=0.1 / m,以获得与Ridge(alpha=0.1)相同的结果。

提示

RidgeCV类也执行岭回归,但它会自动使用交叉验证调整超参数。它大致相当于使用GridSearchCV,但它针对岭回归进行了优化,并且运行快得多。其他几个估计器(主要是线性的)也有高效的 CV 变体,如LassoCVElasticNetCV

Lasso 回归

最小绝对值收缩和选择算子回归(通常简称为Lasso 回归)是线性回归的另一个正则化版本:就像岭回归一样,它向成本函数添加一个正则化项,但是它使用权重向量的ℓ[1]范数,而不是ℓ[2]范数的平方(参见方程 4-10)。请注意,ℓ[1]范数乘以 2α,而ℓ[2]范数在岭回归中乘以α / m。选择这些因子是为了确保最佳α值与训练集大小无关:不同的范数导致不同的因子(有关更多细节,请参阅Scikit-Learn 问题#15657)。

方程 4-10. Lasso 回归成本函数

J(θ)=MSE(θ)+2αi=1nθi

图 4-18 显示了与图 4-17 相同的内容,但用 Lasso 模型替换了岭模型,并使用不同的α值。

mls3 0418

图 4-18. 线性(左)和多项式(右)模型,都使用不同级别的 Lasso 正则化

Lasso 回归的一个重要特征是它倾向于消除最不重要特征的权重(即将它们设置为零)。例如,图 4-18 中右侧图中的虚线看起来大致是立方形:高次多项式特征的所有权重都等于零。换句话说,Lasso 回归自动执行特征选择,并输出具有少量非零特征权重的稀疏模型

你可以通过查看图 4-19 来了解这种情况:坐标轴代表两个模型参数,背景轮廓代表不同的损失函数。在左上角的图中,轮廓代表ℓ[1]损失(|θ[1]| + |θ[2]|),随着你靠近任何轴,损失会线性下降。例如,如果你将模型参数初始化为θ[1] = 2 和θ[2] = 0.5,运行梯度下降将等量减少两个参数(如虚线黄线所示);因此θ[2]会先达到 0(因为它最初更接近 0)。之后,梯度下降将沿着槽滚动,直到达到θ[1] = 0(稍微反弹一下,因为ℓ[1]的梯度从不接近 0:对于每个参数,它们要么是-1 要么是 1)。在右上角的图中,轮廓代表套索回归的成本函数(即,MSE 成本函数加上ℓ[1]损失)。小白色圆圈显示了梯度下降优化某些模型参数的路径,这些参数最初设定为θ[1] = 0.25 和θ[2] = -1:再次注意路径如何迅速到达θ[2] = 0,然后沿着槽滚动并最终在全局最优解周围反弹(由红色方块表示)。如果增加α,全局最优解将沿着虚线黄线向左移动,而如果减小α,全局最优解将向右移动(在这个例子中,未正则化 MSE 的最佳参数为θ[1] = 2 和θ[2] = 0.5)。

mls3 0419

图 4-19。套索与岭正则化

两个底部图表展示了相同的情况,但使用了ℓ[2]惩罚。在左下角的图中,你可以看到随着我们靠近原点,ℓ[2]损失减少,因此梯度下降直接朝着那个点前进。在右下角的图中,轮廓代表岭回归的成本函数(即,MSE 成本函数加上ℓ[2]损失)。正如你所看到的,随着参数接近全局最优解,梯度变小,因此梯度下降自然减慢。这限制了反弹,有助于岭回归比套索收敛更快。还要注意,当增加α时,最佳参数(由红色方块表示)越来越接近原点,但它们永远不会完全消失。

提示

为了防止在使用套索回归时梯度下降在最后反弹到最优解周围,你需要在训练过程中逐渐减小学习率。它仍然会在最优解周围反弹,但步长会变得越来越小,因此会收敛。

套索成本函数在θ[i] = 0(对于 i = 1, 2, ⋯, n)处不可微,但如果在任何θ[i] = 0 时使用子梯度向量 g⁠¹¹,梯度下降仍然有效。方程 4-11 展示了一个你可以用于套索成本函数的梯度下降的子梯度向量方程。

方程 4-11。套索回归子梯度向量

g(θ,J)=θMSE(θ)+2αsign(θ1)sign(θ2)sign(θn)where sign(θi)=-1if θi<00if θi=0+1if θi>0

这里有一个使用Lasso类的小型 Scikit-Learn 示例:

>>> from sklearn.linear_model import Lasso
>>> lasso_reg = Lasso(alpha=0.1)
>>> lasso_reg.fit(X, y)
>>> lasso_reg.predict([[1.5]])
array([1.53788174])

请注意,您也可以使用SGDRegressor(penalty="l1", alpha=0.1)

弹性网回归

弹性网回归是岭回归和套索回归之间的中间地带。正则化项是岭回归和套索回归正则化项的加权和,您可以控制混合比例r。当r=0 时,弹性网等同于岭回归,当r=1 时,它等同于套索回归(方程 4-12)。

方程 4-12。弹性网成本函数

J(θ)=MSE(θ)+r2αi=1nθi+(1-r)αmi=1nθi2

那么何时使用弹性网回归,或者岭回归、套索回归,或者普通线性回归(即没有任何正则化)?通常最好至少有一点点正则化,因此通常应避免普通线性回归。岭回归是一个很好的默认选择,但如果您怀疑只有少数特征是有用的,您应该更喜欢套索或弹性网,因为它们倾向于将无用特征的权重降至零,正如前面讨论的那样。总的来说,相对于套索,弹性网更受青睐,因为当特征数量大于训练实例数量或者多个特征强相关时,套索可能表现不稳定。

这里有一个使用 Scikit-Learn 的ElasticNet的简短示例(l1_ratio对应混合比例r):

>>> from sklearn.linear_model import ElasticNet
>>> elastic_net = ElasticNet(alpha=0.1, l1_ratio=0.5)
>>> elastic_net.fit(X, y)
>>> elastic_net.predict([[1.5]])
array([1.54333232])

早停

一种非常不同的正则化迭代学习算法(如梯度下降)的方法是在验证错误达到最小值时停止训练。这被称为早停止。图 4-20 显示了一个复杂模型(在本例中,是一个高次多项式回归模型)在我们之前使用的二次数据集上使用批量梯度下降进行训练。随着时代的变迁,算法学习,其在训练集上的预测误差(RMSE)下降,以及在验证集上的预测误差也下降。然而,一段时间后,验证错误停止下降并开始上升。这表明模型已经开始过拟合训练数据。通过早停止,您只需在验证错误达到最小值时停止训练。这是一种简单而高效的正则化技术,Geoffrey Hinton 称之为“美丽的免费午餐”。

mls3 0420

图 4-20。早停止正则化
提示

对于随机梯度下降和小批量梯度下降,曲线不那么平滑,可能很难知道是否已经达到最小值。一个解决方案是只有在验证错误超过最小值一段时间后(当您确信模型不会再有更好的表现时),然后将模型参数回滚到验证错误最小值的点。

这是早停止的基本实现:

from copy import deepcopy
from sklearn.metrics import mean_squared_error
from sklearn.preprocessing import StandardScaler

X_train, y_train, X_valid, y_valid = [...]  # split the quadratic dataset

preprocessing = make_pipeline(PolynomialFeatures(degree=90, include_bias=False),
                              StandardScaler())
X_train_prep = preprocessing.fit_transform(X_train)
X_valid_prep = preprocessing.transform(X_valid)
sgd_reg = SGDRegressor(penalty=None, eta0=0.002, random_state=42)
n_epochs = 500
best_valid_rmse = float('inf')

for epoch in range(n_epochs):
    sgd_reg.partial_fit(X_train_prep, y_train)
    y_valid_predict = sgd_reg.predict(X_valid_prep)
    val_error = mean_squared_error(y_valid, y_valid_predict, squared=False)
    if val_error < best_valid_rmse:
        best_valid_rmse = val_error
        best_model = deepcopy(sgd_reg)

这段代码首先添加多项式特征并缩放所有输入特征,对于训练集和验证集都是如此(代码假定您已将原始训练集分成较小的训练集和验证集)。然后它创建一个没有正则化和较小学习率的SGDRegressor模型。在训练循环中,它调用partial_fit()而不是fit(),以执行增量学习。在每个时代,它测量验证集上的 RMSE。如果低于迄今为止看到的最低 RMSE,则将模型的副本保存在best_model变量中。这个实现实际上并没有停止训练,但它允许您在训练后返回到最佳模型。请注意,使用copy.deepcopy()复制模型,因为它同时复制了模型的超参数和学习参数。相比之下,sklearn.base.clone()只复制模型的超参数。

逻辑回归

正如在第一章中讨论的那样,一些回归算法可以用于分类(反之亦然)。逻辑回归(也称为logit 回归)通常用于估计一个实例属于特定类别的概率(例如,这封电子邮件是垃圾邮件的概率是多少?)。如果估计的概率大于给定阈值(通常为 50%),则模型预测该实例属于该类别(称为正类,标记为“1”),否则预测它不属于该类别(即属于负类,标记为“0”)。这使其成为一个二元分类器。

估计概率

那么逻辑回归是如何工作的呢?就像线性回归模型一样,逻辑回归模型计算输入特征的加权和(加上偏置项),但是不像线性回归模型直接输出结果,它输出这个结果的逻辑(参见方程 4-13)。

方程 4-13。逻辑回归模型估计概率(向量化形式)

p ^ = h θ ( x ) = σ ( θ x )

逻辑函数 σ(·) 是一个 S 形函数,输出介于 0 和 1 之间的数字。它的定义如 方程式 4-14 和 图 4-21 所示。

方程式 4-14. 逻辑函数

σ ( t ) = 1 1+exp(-t)mls3 0421

图 4-21. 逻辑函数

逻辑回归模型一旦估计出概率 p^ = hθ,即实例 x 属于正类的概率,它可以轻松地进行预测 ŷ(见 方程式 4-15)。

方程式 4-15. 使用 50% 阈值概率的逻辑回归模型预测

y ^ = 0 if p ^ < 0.5 1 if p ^ 0.5

注意到当 t < 0 时,σ(t) < 0.5,当 t ≥ 0 时,σ(t) ≥ 0.5,因此使用默认的 50% 概率阈值的逻辑回归模型会在 θ^⊺ x 为正时预测为 1,为负时预测为 0。

注意

得分 t 通常被称为 对数几率。这个名字来自于对数几率函数的定义,即 logit(p) = log(p / (1 – p)),它是逻辑函数的反函数。实际上,如果计算估计概率 p 的对数几率,你会发现结果是 t。对数几率也被称为 对数几率比,因为它是正类估计概率与负类估计概率之间的比值的对数。

训练和成本函数

现在你知道逻辑回归模型如何估计概率并进行预测了。但是它是如何训练的呢?训练的目标是设置参数向量 θ,使模型为正实例(y = 1)估计出高概率,为负实例(y = 0)估计出低概率。这个想法被 方程式 4-16 中的成本函数所捕捉,针对单个训练实例 x

方程式 4-16. 单个训练实例的成本函数

c(θ)=-log(p^)if y=1-log(1-p^)if y=0

这个成本函数是有意义的,因为当 t 接近 0 时,–log(t) 会变得非常大,所以如果模型为正实例估计出接近 0 的概率,成本会很大,如果模型为负实例估计出接近 1 的概率,成本也会很大。另一方面,当 t 接近 1 时,–log(t) 接近 0,所以如果负实例的估计概率接近 0,或者正实例的估计概率接近 1,成本会接近 0,这正是我们想要的。

整个训练集上的成本函数是所有训练实例的平均成本。它可以用一个称为对数损失的单个表达式来表示,如方程 4-17 所示。

方程 4-17。逻辑回归成本函数(对数损失)

J(θ)=-1mi=1my(i)logp^(i)+(1-y(i))log1-p^(i)

警告

对数损失不是凭空想出来的。可以用数学方法(使用贝叶斯推断)证明,最小化这种损失将导致具有最大可能性的模型是最优的,假设实例围绕其类的平均值遵循高斯分布。当您使用对数损失时,这是您所做的隐含假设。这种假设错误越大,模型就会越有偏见。同样,当我们使用 MSE 来训练线性回归模型时,我们隐含地假设数据是纯线性的,再加上一些高斯噪声。因此,如果数据不是线性的(例如,如果是二次的),或者噪声不是高斯的(例如,如果异常值不是指数稀有的),那么模型就会有偏见。

坏消息是,没有已知的闭式方程可以计算最小化这个成本函数的θ的值(没有等价于正规方程)。但好消息是,这个成本函数是凸的,因此梯度下降(或任何其他优化算法)保证会找到全局最小值(如果学习率不是太大,并且等待足够长的时间)。成本函数对于j^(th)模型参数θ[j]的偏导数由方程 4-18 给出。

方程 4-18。逻辑成本函数偏导数

数学显示="block"> θ j J ( θ ) = 1 m i=1 m σ ( θ x (i) ) - y (i) x j (i)

这个方程看起来非常像方程 4-5:对于每个实例,它计算预测误差并将其乘以j^(th)特征值,然后计算所有训练实例的平均值。一旦有包含所有偏导数的梯度向量,您就可以在批量梯度下降算法中使用它。就是这样:您现在知道如何训练逻辑回归模型了。对于随机梯度下降,您将一次处理一个实例,对于小批量梯度下降,您将一次处理一个小批量。

决策边界

我们可以使用鸢尾花数据集来说明逻辑回归。这是一个包含 150 朵三种不同物种鸢尾花(Iris setosaIris versicolorIris virginica)的萼片和花瓣长度和宽度的著名数据集(参见图 4-22)。

mls3 0422

图 4-22。三种鸢尾植物物种的花朵⁠¹²

让我们尝试构建一个基于花瓣宽度特征的分类器来检测Iris virginica类型。第一步是加载数据并快速查看:

>>> from sklearn.datasets import load_iris
>>> iris = load_iris(as_frame=True)
>>> list(iris)
['data', 'target', 'frame', 'target_names', 'DESCR', 'feature_names',
 'filename', 'data_module']
>>> iris.data.head(3)
 sepal length (cm)  sepal width (cm)  petal length (cm)  petal width (cm)
0                5.1               3.5                1.4               0.2
1                4.9               3.0                1.4               0.2
2                4.7               3.2                1.3               0.2
>>> iris.target.head(3)  # note that the instances are not shuffled
0    0
1    0
2    0
Name: target, dtype: int64
>>> iris.target_names
array(['setosa', 'versicolor', 'virginica'], dtype='<U10')

接下来我们将拆分数据并在训练集上训练逻辑回归模型:

from sklearn.linear_model import LogisticRegression
from sklearn.model_selection import train_test_split

X = iris.data[["petal width (cm)"]].values
y = iris.target_names[iris.target] == 'virginica'
X_train, X_test, y_train, y_test = train_test_split(X, y, random_state=42)

log_reg = LogisticRegression(random_state=42)
log_reg.fit(X_train, y_train)

让我们看看模型对花朵的估计概率,这些花朵的花瓣宽度从 0 厘米到 3 厘米不等(参见图 4-23):⁠¹³

X_new = np.linspace(0, 3, 1000).reshape(-1, 1)  # reshape to get a column vector
y_proba = log_reg.predict_proba(X_new)
decision_boundary = X_new[y_proba[:, 1] >= 0.5][0, 0]

plt.plot(X_new, y_proba[:, 0], "b--", linewidth=2,
         label="Not Iris virginica proba")
plt.plot(X_new, y_proba[:, 1], "g-", linewidth=2, label="Iris virginica proba")
plt.plot([decision_boundary, decision_boundary], [0, 1], "k:", linewidth=2,
         label="Decision boundary")
[...] # beautify the figure: add grid, labels, axis, legend, arrows, and samples
plt.show()

mls3 0423

图 4-23。估计的概率和决策边界

Iris virginica花朵的花瓣宽度(表示为三角形)范围从 1.4 厘米到 2.5 厘米,而其他鸢尾花(用方块表示)通常具有较小的花瓣宽度,范围从 0.1 厘米到 1.8 厘米。请注意,存在一些重叠。大约在 2 厘米以上,分类器非常确信花朵是Iris virginica(输出该类的高概率),而在 1 厘米以下,它非常确信它不是Iris virginica(“非 Iris virginica”类的高概率)。在这两个极端之间,分类器不确定。但是,如果要求它预测类别(使用predict()方法而不是predict_proba()方法),它将返回最有可能的类别。因此,在大约 1.6 厘米处有一个决策边界,两个概率都等于 50%:如果花瓣宽度大于 1.6 厘米,分类器将预测花朵是Iris virginica,否则它将预测它不是(即使它不太自信):

>>> decision_boundary
1.6516516516516517
>>> log_reg.predict([[1.7], [1.5]])
array([ True, False])

图 4-24 显示了相同的数据集,但这次显示了两个特征:花瓣宽度和长度。一旦训练完成,逻辑回归分类器可以根据这两个特征估计新花朵是Iris virginica的概率。虚线代表模型估计 50%概率的点:这是模型的决策边界。请注意,这是一个线性边界。⁠¹⁴ 每条平行线代表模型输出特定概率的点,从 15%(左下角)到 90%(右上角)。所有超过右上线的花朵根据模型有超过 90%的概率是Iris virginica

mls3 0424

图 4-24。线性决策边界
注意

控制 Scikit-Learn LogisticRegression模型正则化强度的超参数不是alpha(像其他线性模型一样),而是它的倒数:CC值越高,模型的正则化就越

与其他线性模型一样,逻辑回归模型可以使用ℓ[1]或ℓ[2]惩罚进行正则化。Scikit-Learn 实际上默认添加了ℓ[2]惩罚。

Softmax 回归

逻辑回归模型可以直接泛化为支持多类别,而无需训练和组合多个二元分类器(如第三章中讨论的)。这称为softmax 回归多项式逻辑回归

这个想法很简单:给定一个实例x,Softmax 回归模型首先为每个类别k计算一个分数s**k,然后通过应用softmax 函数(也称为归一化指数函数)来估计每个类别的概率。计算s**k的方程应该看起来很熟悉,因为它就像线性回归预测的方程(参见方程 4-19)。

方程 4-19。类别 k 的 Softmax 分数

s k ( x ) = (θ (k) ) x

注意每个类别都有自己专用的参数向量θ^((k))。所有这些向量通常被存储为参数矩阵 Θ 的行。

一旦你计算出每个类别对于实例x的得分,你可以通过将得分通过 softmax 函数(方程 4-20)来估计实例属于类别k的概率p^k。该函数计算每个得分的指数,然后对它们进行归一化(除以所有指数的和)。这些得分通常被称为对数几率或对数几率(尽管它们实际上是未归一化的对数几率)。

方程 4-20. Softmax 函数

p ^ k = σ s(x) k = exps k (x) j=1 K exps j (x)

在这个方程中:

  • K 是类别的数量。

  • s(x)是包含实例x每个类别得分的向量。

  • σ(s(x))[k]是实例x属于类别k的估计概率,给定该实例每个类别的得分。

就像逻辑回归分类器一样,默认情况下,softmax 回归分类器预测具有最高估计概率的类别(即具有最高得分的类别),如方程 4-21 所示。

方程 4-21. Softmax 回归分类器预测

y ^ = argmax k σ s(x) k = argmax k s <mi k ( x ) = argmax k (θ (k) ) x

argmax运算符返回最大化函数的变量值。在这个方程中,它返回最大化估计概率σ(s(x))[k]的k值。

提示

softmax 回归分类器一次只预测一个类别(即它是多类别的,而不是多输出的),因此它只能用于具有互斥类别的情况,例如不同种类的植物。你不能用它来识别一张图片中的多个人。

现在你知道模型如何估计概率并进行预测了,让我们来看看训练。目标是让模型估计目标类的概率很高(因此其他类的概率很低)。最小化方程 4-22 中显示的成本函数,称为交叉熵,应该能够实现这个目标,因为当模型估计目标类的概率很低时,它会受到惩罚。交叉熵经常用来衡量一组估计的类别概率与目标类别的匹配程度。

方程 4-22. 交叉熵成本函数

J(Θ)=-1mi=1mk=1Kyk(i)logp^k(i)

在这个方程中,yk(i)是第i个实例属于第k类的目标概率。一般来说,它要么等于 1,要么等于 0,取决于实例是否属于该类。

注意,当只有两类(K = 2)时,这个成本函数等同于逻辑回归成本函数(对数损失;参见方程 4-17)。

这个成本函数关于θ^((k))的梯度向量由方程 4-23 给出。

方程 4-23. 类别 k 的交叉熵梯度向量

θ (k) J ( Θ ) = 1 m i=1 m p ^ k (i) - y k (i) x (i)

现在你可以计算每个类别的梯度向量,然后使用梯度下降(或任何其他优化算法)来找到最小化成本函数的参数矩阵Θ

让我们使用 softmax 回归将鸢尾花分类为所有三类。当你在多于两类上训练 Scikit-Learn 的LogisticRegression分类器时,它会自动使用 softmax 回归(假设你使用solver="lbfgs",这是默认值)。它还默认应用ℓ[2]正则化,你可以使用之前提到的超参数C来控制:

X = iris.data[["petal length (cm)", "petal width (cm)"]].values
y = iris["target"]
X_train, X_test, y_train, y_test = train_test_split(X, y, random_state=42)

softmax_reg = LogisticRegression(C=30, random_state=42)
softmax_reg.fit(X_train, y_train)

所以下次当你发现一朵花瓣长 5 厘米,宽 2 厘米的鸢尾花时,你可以让你的模型告诉你它是什么类型的鸢尾花,它会以 96%的概率回答Iris virginica(第 2 类)(或以 4%的概率回答Iris versicolor):

>>> softmax_reg.predict([[5, 2]])
array([2])
>>> softmax_reg.predict_proba([[5, 2]]).round(2)
array([[0\.  , 0.04, 0.96]])

图 4-25 显示了由背景颜色表示的决策边界。请注意,任意两个类之间的决策边界是线性的。图中还显示了Iris versicolor类的概率,由曲线表示(例如,标有 0.30 的线表示 30% 概率边界)。请注意,模型可以预测估计概率低于 50% 的类。例如,在所有决策边界相交的点,所有类的估计概率均为 33%。

mls3 0425

图 4-25. Softmax 回归决策边界

在本章中,你学习了训练线性模型的各种方法,包括回归和分类。你使用闭式方程解决线性回归问题,以及梯度下降,并学习了在训练过程中如何向成本函数添加各种惩罚以对模型进行正则化。在此过程中,你还学习了如何绘制学习曲线并分析它们,以及如何实现早期停止。最后,你学习了逻辑回归和 softmax 回归的工作原理。我们已经打开了第一个机器学习黑匣子!在接下来的章节中,我们将打开更多黑匣子,从支持向量机开始。

练习

  1. 如果你有一个拥有数百万个特征的训练集,你可以使用哪种线性回归训练算法?

  2. 假设你的训练集中的特征具有非常不同的尺度。哪些算法可能会受到影响,以及如何受影响?你可以采取什么措施?

  3. 在训练逻辑回归模型时,梯度下降是否会陷入局部最小值?

  4. 如果让所有梯度下降算法运行足够长的时间,它们会导致相同的模型吗?

  5. 假设你使用批量梯度下降,并在每个时期绘制验证误差。如果你注意到验证误差持续上升,可能出现了什么问题?如何解决?

  6. 当验证误差上升时,立即停止小批量梯度下降是一个好主意吗?

  7. 在我们讨论的梯度下降算法中,哪种算法会最快接近最优解?哪种实际上会收敛?如何使其他算法也收敛?

  8. 假设你正在使用多项式回归。你绘制学习曲线并注意到训练误差和验证误差之间存在很大差距。发生了什么?有哪三种方法可以解决这个问题?

  9. 假设你正在使用岭回归,并且注意到训练误差和验证误差几乎相等且相当高。你会说模型存在高偏差还是高方差?你应该增加正则化超参数α还是减少它?

  10. 为什么要使用:

    1. 是否可以使用岭回归代替普通线性回归(即,没有任何正则化)?

    2. 是否可以使用 Lasso 代替岭回归?

    3. 是否可以使用弹性网络代替 Lasso 回归?

  11. 假设你想要将图片分类为室内/室外和白天/黑夜。你应该实现两个逻辑回归分类器还是一个 softmax 回归分类器?

  12. 使用 NumPy 实现批量梯度下降并进行早期停止以进行 softmax 回归,而不使用 Scikit-Learn。将其应用于鸢尾花数据集等分类任务。

这些练习的解决方案可在本章笔记本的末尾找到,网址为https://homl.info/colab3

¹ 闭式方程仅由有限数量的常数、变量和标准操作组成:例如,a = sin(bc)。没有无限求和、极限、积分等。

² 从技术上讲,它的导数是Lipschitz 连续的。

³ 由于特征 1 较小,改变θ[1]以影响成本函数需要更大的变化,这就是为什么碗沿着θ[1]轴被拉长的原因。

⁴ Eta(η)是希腊字母表的第七个字母。

⁵ 而正规方程只能执行线性回归,梯度下降算法可以用来训练许多其他模型,您将会看到。

⁶ 这种偏差的概念不应与线性模型的偏差项混淆。

⁷ 通常使用符号J(θ)表示没有简短名称的代价函数;在本书的其余部分中,我经常会使用这种符号。上下文将清楚地表明正在讨论哪个代价函数。

⁸ 范数在第二章中讨论。

⁹ 一个全是 0 的方阵,除了主对角线(从左上到右下)上的 1。

¹⁰ 或者,您可以使用Ridge类与"sag"求解器。随机平均梯度下降是随机梯度下降的一种变体。有关更多详细信息,请参阅由不列颠哥伦比亚大学的 Mark Schmidt 等人提出的演示“使用随机平均梯度算法最小化有限和”

¹¹ 您可以将非可微点处的次梯度向量视为该点周围梯度向量之间的中间向量。

¹² 照片来源于相应的维基百科页面。Iris virginica照片由 Frank Mayfield 拍摄(知识共享署名-相同方式共享 2.0),Iris versicolor照片由 D. Gordon E. Robertson 拍摄(知识共享署名-相同方式共享 3.0),Iris setosa照片为公共领域。

¹³ NumPy 的reshape()函数允许一个维度为-1,表示“自动”:该值是从数组的长度和剩余维度推断出来的。

¹⁴ 它是一组点x,使得θ[0] + θ[1]x[1] + θ[2]x[2] = 0,这定义了一条直线。

第五章:支持向量机

支持向量机(SVM)是一个强大且多功能的机器学习模型,能够执行线性或非线性分类、回归,甚至新颖性检测。SVM 在小到中等大小的非线性数据集(即,数百到数千个实例)上表现出色,尤其适用于分类任务。然而,它们在处理非常大的数据集时并不很好,您将看到。

本章将解释 SVM 的核心概念,如何使用它们以及它们的工作原理。让我们开始吧!

线性 SVM 分类

支持向量机背后的基本思想最好通过一些可视化来解释。图 5-1 展示了在第四章末尾介绍的鸢尾花数据集的一部分。这两个类可以很容易地用一条直线分开(它们是线性可分的)。左图显示了三种可能线性分类器的决策边界。决策边界由虚线表示的模型非常糟糕,甚至不能正确地分开这两个类。其他两个模型在这个训练集上表现完美,但它们的决策边界与实例非常接近,因此这些模型在新实例上可能表现不佳。相比之下,右图中的实线代表 SVM 分类器的决策边界;这条线不仅分开了两个类,而且尽可能远离最接近的训练实例。您可以将 SVM 分类器视为在类之间拟合最宽可能的街道(由平行虚线表示)。这被称为大边距分类

mls3 0501

图 5-1. 大边距分类

请注意,添加更多训练实例“离开街道”不会对决策边界产生任何影响:它完全由位于街道边缘的实例决定(或“支持”)。这些实例被称为支持向量(它们在图 5-1 中被圈出)。

警告

支持向量机对特征的尺度敏感,如您可以在图 5-2 中看到。在左图中,垂直尺度远大于水平尺度,因此最宽可能的街道接近水平。经过特征缩放(例如,使用 Scikit-Learn 的StandardScaler),右图中的决策边界看起来好多了。

mls3 0502

图 5-2. 特征尺度的敏感性

软边距分类

如果我们严格要求所有实例必须远离街道并位于正确的一侧,这被称为硬边距分类。硬边距分类存在两个主要问题。首先,它仅在数据线性可分时有效。其次,它对异常值敏感。图 5-3 展示了鸢尾花数据集中仅有一个额外异常值的情况:在左侧,找到硬边距是不可能的;在右侧,决策边界与图 5-1 中看到的没有异常值的情决策边界非常不同,模型可能不会很好地泛化。

mls3 0503

图 5-3. 硬边距对异常值的敏感性

为了避免这些问题,我们需要使用一个更灵活的模型。目标是在尽可能保持街道尽可能宽阔和限制边距违规(即,最终位于街道中间甚至错误一侧的实例)之间找到一个良好的平衡。这被称为软边距分类

在使用 Scikit-Learn 创建 SVM 模型时,您可以指定几个超参数,包括正则化超参数C。如果将其设置为较低的值,则会得到左侧图 5-4 中的模型。如果设置为较高的值,则会得到右侧的模型。正如您所看到的,减少C会使街道变宽,但也会导致更多的间隔违规。换句话说,减少C会导致更多的实例支持街道,因此过拟合的风险较小。但是,如果减少得太多,那么模型最终会欠拟合,就像这里的情况一样:C=100的模型看起来比C=1的模型更容易泛化。

mls3 0504

图 5-4. 大间隔(左)与较少间隔违规(右)
提示

如果您的 SVM 模型过拟合,可以尝试通过减少C来对其进行正则化。

以下 Scikit-Learn 代码加载了鸢尾花数据集,并训练了一个线性 SVM 分类器来检测Iris virginica花。该流水线首先对特征进行缩放,然后使用LinearSVCC=1进行训练:

from sklearn.datasets import load_iris
from sklearn.pipeline import make_pipeline
from sklearn.preprocessing import StandardScaler
from sklearn.svm import LinearSVC

iris = load_iris(as_frame=True)
X = iris.data[["petal length (cm)", "petal width (cm)"]].values
y = (iris.target == 2)  # Iris virginica

svm_clf = make_pipeline(StandardScaler(),
                        LinearSVC(C=1, random_state=42))
svm_clf.fit(X, y)

生成的模型在图 5-4 的左侧表示。

然后,像往常一样,您可以使用模型进行预测:

>>> X_new = [[5.5, 1.7], [5.0, 1.5]]
>>> svm_clf.predict(X_new)
array([ True, False])

第一个植物被分类为Iris virginica,而第二个没有。让我们看看 SVM 用于做出这些预测的分数。这些分数衡量了每个实例与决策边界之间的有符号距离:

>>> svm_clf.decision_function(X_new)
array([ 0.66163411, -0.22036063])

LogisticRegression不同,LinearSVC没有predict_proba()方法来估计类概率。也就是说,如果您使用SVC类(稍后讨论)而不是LinearSVC,并将其probability超参数设置为True,那么模型将在训练结束时拟合一个额外的模型,将 SVM 决策函数分数映射到估计概率。在幕后,这需要使用 5 倍交叉验证为训练集中的每个实例生成样本外预测,然后训练一个LogisticRegression模型,因此会显著减慢训练速度。之后,predict_proba()predict_log_proba()方法将可用。

非线性 SVM 分类

尽管线性 SVM 分类器高效且通常表现出色,但许多数据集甚至远非线性可分。处理非线性数据集的一种方法是添加更多特征,例如多项式特征(就像我们在第四章中所做的那样);在某些情况下,这可能会导致一个线性可分的数据集。考虑图 5-5 中的左侧图:它代表一个只有一个特征x[1]的简单数据集。正如您所看到的,这个数据集是线性不可分的。但是,如果添加第二个特征x[2] = (x[1])²,那么得到的 2D 数据集就是完全线性可分的。

mls3 0505

图 5-5. 添加特征使数据集线性可分

要使用 Scikit-Learn 实现这个想法,您可以创建一个包含PolynomialFeatures转换器(在“多项式回归”中讨论)、StandardScalerLinearSVC分类器的流水线。让我们在 moons 数据集上测试这个流水线,这是一个用于二元分类的玩具数据集,其中数据点呈两个交错新月形状(参见图 5-6)。您可以使用make_moons()函数生成这个数据集:

from sklearn.datasets import make_moons
from sklearn.preprocessing import PolynomialFeatures

X, y = make_moons(n_samples=100, noise=0.15, random_state=42)

polynomial_svm_clf = make_pipeline(
    PolynomialFeatures(degree=3),
    StandardScaler(),
    LinearSVC(C=10, max_iter=10_000, random_state=42)
)
polynomial_svm_clf.fit(X, y)

mls3 0506

图 5-6. 使用多项式特征的线性 SVM 分类器

多项式核

添加多项式特征很容易实现,并且可以与各种机器学习算法(不仅仅是 SVM)很好地配合。也就是说,在低多项式度数下,这种方法无法处理非常复杂的数据集,而在高多项式度数下,它会创建大量特征,使模型变得过于缓慢。

幸运的是,在使用 SVM 时,你可以应用一种几乎神奇的数学技术,称为核技巧(稍后在本章中解释)。核技巧使得可以获得与添加许多多项式特征相同的结果,即使是非常高次的,而无需实际添加它们。这意味着特征数量不会组合爆炸。这个技巧由SVC类实现。让我们在 moons 数据集上测试一下:

from sklearn.svm import SVC

poly_kernel_svm_clf = make_pipeline(StandardScaler(),
                                    SVC(kernel="poly", degree=3, coef0=1, C=5))
poly_kernel_svm_clf.fit(X, y)

这段代码使用三次多项式核训练了一个 SVM 分类器,左侧在图 5-7 中表示。右侧是另一个使用十次多项式核的 SVM 分类器。显然,如果你的模型出现过拟合,你可能需要降低多项式次数。相反,如果出现欠拟合,你可以尝试增加它。超参数coef0控制模型受高次项和低次项影响的程度。

mls3 0507

图 5-7. 使用多项式核的 SVM 分类器
提示

虽然超参数通常会自动调整(例如使用随机搜索),但了解每个超参数实际上是做什么以及它如何与其他超参数交互是很有帮助的:这样,你可以将搜索范围缩小到一个更小的空间。

相似性特征

解决非线性问题的另一种技术是添加使用相似性函数计算的特征,该函数衡量每个实例与特定“地标”的相似程度,就像我们在第二章中添加地理相似性特征时所做的那样。例如,让我们取之前的一维数据集,在x[1] = -2 和x[1] = 1 处添加两个地标(参见图 5-8 中的左图)。接下来,我们将定义相似性函数为带有γ = 0.3 的高斯 RBF。这是一个钟形函数,从 0(远离地标)变化到 1(在地标处)。

现在我们准备计算新特征。例如,让我们看一下实例x[1] = -1:它距离第一个地标 1,距离第二个地标 2。因此,它的新特征是x[2] = exp(–0.3 × 1²) ≈ 0.74 和x[3] = exp(–0.3 × 2²) ≈ 0.30。图 5-8 中右侧的图显示了转换后的数据集(放弃了原始特征)。如你所见,现在它是线性可分的。

mls3 0508

图 5-8. 使用高斯 RBF 的相似性特征

你可能想知道如何选择地标。最简单的方法是在数据集中的每个实例位置创建一个地标。这样做会创建许多维度,从而增加转换后的训练集线性可分的机会。缺点是,一个包含m个实例和n个特征的训练集会转换为一个包含m个实例和m个特征的训练集(假设你放弃了原始特征)。如果你的训练集非常大,最终会得到同样数量的特征。

高斯 RBF 核

与多项式特征方法一样,相似性特征方法可以与任何机器学习算法一起使用,但计算所有额外特征可能会很昂贵(尤其是在大型训练集上)。再次,核技巧发挥了 SVM 的魔力,使得可以获得与添加许多相似性特征相同的结果,但实际上并没有这样做。让我们尝试使用高斯 RBF 核的SVC类:

rbf_kernel_svm_clf = make_pipeline(StandardScaler(),
                                   SVC(kernel="rbf", gamma=5, C=0.001))
rbf_kernel_svm_clf.fit(X, y)

这个模型在图 5-9 的左下角表示。其他图显示了使用不同超参数gammaγ)和C训练的模型。增加gamma会使钟形曲线变窄(参见图 5-8 中的左侧图)。因此,每个实例的影响范围更小:决策边界最终变得更加不规则,围绕个别实例摆动。相反,较小的gamma值会使钟形曲线变宽:实例的影响范围更大,决策边界变得更加平滑。因此,γ就像一个正则化超参数:如果你的模型过拟合,应该减小γ;如果欠拟合,应该增加γ(类似于C超参数)。

mls3 0509

图 5-9。使用 RBF 核的 SVM 分类器

其他核存在,但使用得更少。一些核专门用于特定的数据结构。字符串核有时用于对文本文档或 DNA 序列进行分类(例如,使用字符串子序列核或基于 Levenshtein 距离的核)。

提示

有这么多核可供选择,你如何决定使用哪一个?作为一个经验法则,你应该始终首先尝试线性核。LinearSVC类比SVC(kernel="linear")快得多,特别是当训练集非常大时。如果不太大,你也应该尝试核化的 SVM,首先使用高斯 RBF 核;它通常效果很好。然后,如果你有多余的时间和计算能力,你可以尝试使用一些其他核进行超参数搜索。如果有专门针对你的训练集数据结构的核,也要试一试。

SVM 类和计算复杂度

LinearSVC类基于liblinear库,该库实现了线性 SVM 的优化算法。⁠¹ 它不支持核技巧,但随着训练实例数量和特征数量的增加,它的缩放几乎是线性的。其训练时间复杂度大约为O(m × n)。如果需要非常高的精度,算法会花费更长的时间。这由容差超参数ϵ(在 Scikit-Learn 中称为tol)控制。在大多数分类任务中,默认容差是可以接受的。

SVC类基于libsvm库,该库实现了一个支持核技巧的算法。⁠² 训练时间复杂度通常在O(m² × n)和O(m³ × n)之间。不幸的是,这意味着当训练实例数量变大时(例如,数十万个实例),算法会变得非常慢,因此这个算法最适合小型或中等大小的非线性训练集。它对特征数量的缩放效果很好,特别是对于稀疏特征(即每个实例具有很少的非零特征)。在这种情况下,算法的缩放大致与每个实例的平均非零特征数量成比例。

SGDClassifier类默认也执行大边距分类,其超参数,特别是正则化超参数(alphapenalty)和learning_rate,可以调整以产生与线性 SVM 类似的结果。它使用随机梯度下降进行训练(参见第四章),允许增量学习并且使用很少的内存,因此可以用于在 RAM 中无法容纳的大型数据集上训练模型(即用于外存学习)。此外,它的缩放非常好,因为其计算复杂度为O(m × n)。表 5-1 比较了 Scikit-Learn 的 SVM 分类类。

表 5-1。Scikit-Learn 用于 SVM 分类的类比较

类别 时间复杂度 外存支持 需要缩放 核技巧
LinearSVC O(m × n)
SVC O(m² × n) 到 O(m³ × n)
SGDClassifier O(m × n)

现在让我们看看 SVM 算法如何用于线性和非线性回归。

SVM 回归

要将 SVM 用于回归而不是分类,关键是调整目标:不再试图在两个类之间拟合尽可能大的间隔同时限制间隔违规,SVM 回归试图在尽可能多的实例间隔上拟合,同时限制间隔违规(即实例间隔之外)。间隔的宽度由超参数ϵ控制。图 5-10 显示了在一些线性数据上训练的两个线性 SVM 回归模型,一个具有较小的间隔(ϵ = 0.5),另一个具有较大的间隔(ϵ = 1.2)。

mls3 0510

图 5-10。SVM 回归

减小ϵ会增加支持向量的数量,从而对模型进行正则化。此外,如果在间隔内添加更多训练实例,不会影响模型的预测;因此,该模型被称为ϵ-不敏感

您可以使用 Scikit-Learn 的LinearSVR类执行线性 SVM 回归。以下代码生成了左侧图中表示的模型图 5-10:

from sklearn.svm import LinearSVR

X, y = [...]  # a linear dataset
svm_reg = make_pipeline(StandardScaler(),
                        LinearSVR(epsilon=0.5, random_state=42))
svm_reg.fit(X, y)

为了处理非线性回归任务,您可以使用核化的 SVM 模型。图 5-11 显示了在随机二次训练集上使用二次多项式核进行 SVM 回归。左图中有一些正则化(即较小的C值),右图中的正则化要少得多(即较大的C值)。

mls3 0511

图 5-11。使用二次多项式核的 SVM 回归

以下代码使用 Scikit-Learn 的SVR类(支持核技巧)生成了左侧图中表示的模型图 5-11:

from sklearn.svm import SVR

X, y = [...]  # a quadratic dataset
svm_poly_reg = make_pipeline(StandardScaler(),
                             SVR(kernel="poly", degree=2, C=0.01, epsilon=0.1))
svm_poly_reg.fit(X, y)

SVR类是SVC类的回归等价物,LinearSVR类是LinearSVC类的回归等价物。LinearSVR类与训练集的大小呈线性比例(就像LinearSVC类一样),而SVR类在训练集增长非常大时变得非常慢(就像SVC类一样)。

注意

支持向量机也可以用于新颖性检测,正如您将在第九章中看到的那样。

本章的其余部分将解释 SVM 如何进行预测以及它们的训练算法是如何工作的,从线性 SVM 分类器开始。如果您刚开始学习机器学习,可以安全地跳过这部分,直接转到本章末尾的练习,并在以后想要更深入地了解 SVM 时再回来。

线性 SVM 分类器的内部工作原理

线性 SVM 分类器通过首先计算决策函数θ^⊺ x = θ[0] x[0] + ⋯ + θ[n] x[n]来预测新实例x的类别,其中x[0]是偏置特征(始终等于 1)。如果结果为正,则预测的类别ŷ为正类(1);否则为负类(0)。这与LogisticRegression(在第四章中讨论)完全相同。

注意

到目前为止,我一直使用将所有模型参数放在一个向量θ中的约定,包括偏置项θ[0]和输入特征权重θ[1]到θ[n]。这需要向所有实例添加一个偏置输入x[0] = 1。另一个非常常见的约定是将偏置项b(等于θ[0])和特征权重向量w(包含θ[1]到θ[n])分开。在这种情况下,不需要向输入特征向量添加偏置特征,线性 SVM 的决策函数等于w^⊺ x + b = w[1] x[1] + ⋯ + w[n] x[n] + b。我将在本书的其余部分中使用这种约定。

因此,使用线性 SVM 分类器进行预测非常简单。那么训练呢?这需要找到使街道或边界尽可能宽阔的权重向量w和偏置项b,同时限制边界违规的数量。让我们从街道的宽度开始:为了使其更宽,我们需要使w更小。这在 2D 中可能更容易可视化,如图 5-12 所示。让我们将街道的边界定义为决策函数等于-1 或+1 的点。在左图中,权重w[1]为 1,因此w[1] x[1] = -1 或+1 的点是x[1] = -1 和+1:因此边界的大小为 2。在右图中,权重为 0.5,因此w[1] x[1] = -1 或+1 的点是x[1] = -2 和+2:边界的大小为 4。因此,我们需要尽可能保持w较小。请注意,偏置项b对边界的大小没有影响:调整它只是移动边界,而不影响其大小。

mls3 0512

图 5-12. 较小的权重向量导致较大的边界

我们还希望避免边界违规,因此我们需要决策函数对所有正训练实例大于 1,对负训练实例小于-1。如果我们定义t^((i)) = -1 为负实例(当y^((i)) = 0 时),t^((i)) = 1 为正实例(当y^((i)) = 1 时),那么我们可以将这个约束写为t((*i*))(**w**x^((i)) + b) ≥ 1 对所有实例成立。

因此,我们可以将硬间隔线性 SVM 分类器的目标表达为方程 5-1 中的约束优化问题。

方程 5-1. 硬间隔线性 SVM 分类器目标

minimize w,b 1 2 w w subject to t (i) ( w x (i) + b ) 1 for i = 1 , 2 , , m

注意

我们最小化的是½ w^⊺ w,它等于½∥ w ∥²,而不是最小化∥ w ∥(w的范数)。实际上,½∥ w ∥²具有一个简单的导数(就是w),而∥ w ∥在w = 0 处不可微。优化算法在可微函数上通常效果更好。

为了得到软间隔目标,我们需要为每个实例引入一个松弛变量 ζ^((i)) ≥ 0:⁠³ ζ^((i))衡量第i个实例允许违反边界的程度。现在我们有两个相互冲突的目标:尽量减小松弛变量以减少边界违规,同时尽量减小½ w^⊺ w以增加边界。这就是C超参数的作用:它允许我们定义这两个目标之间的权衡。这给我们带来了方程 5-2 中的约束优化问题。

方程 5-2. 软间隔线性 SVM 分类器目标

最小化 w,b,ζ 1 2 w w + C i=1 m ζ (i) 受限于 满足 t (i) ( w x (i) + b ) 1 - ζ (i) ζ (i) 0 对于 i = 1 , 2 , , m

硬间隔和软间隔问题都是具有线性约束的凸二次优化问题。这些问题被称为二次规划(QP)问题。许多现成的求解器可用于通过使用本书范围之外的各种技术来解决 QP 问题。⁠⁴

使用 QP 求解器是训练 SVM 的一种方法。另一种方法是使用梯度下降来最小化铰链损失平方铰链损失(见图 5-13)。给定正类别(即,t=1)的实例x,如果决策函数的输出ss = w^⊺ x + b)大于或等于 1,则损失为 0。这发生在实例偏离街道并位于正侧时。给定负类别(即,t=-1)的实例,如果s ≤ -1,则损失为 0。这发生在实例偏离街道并位于负侧时。实例距离正确边界越远,损失越高:对于铰链损失,它线性增长,对于平方铰链损失,它二次增长。这使得平方铰链损失对异常值更敏感。但是,如果数据集干净,它往往会更快地收敛。默认情况下,LinearSVC使用平方铰链损失,而SGDClassifier使用铰链损失。这两个类允许您通过将loss超参数设置为"hinge""squared_hinge"来选择损失。SVC类的优化算法找到了与最小化铰链损失类似的解。

mls3 0513

图 5-13. 铰链损失(左)和平方铰链损失(右)

接下来,我们将看另一种训练线性 SVM 分类器的方法:解决对偶问题。

对偶问题

给定一个约束优化问题,称为原始问题,可以表达一个不同但密切相关的问题,称为对偶问题。对于对偶问题的解通常给出原始问题解的下界,但在某些条件下,它可以与原始问题具有相同的解。幸运的是,SVM 问题恰好符合这些条件,⁠⁵,因此您可以选择解决原始问题或对偶问题;两者都将有相同的解。方程 5-3 显示了线性 SVM 目标的对偶形式。如果您想了解如何从原始问题推导出对偶问题,请参阅本章笔记本中的额外材料部分。

方程 5-3. 线性 SVM 目标的对偶形式

最小化 α12i=1mj=1mα(i)α(j)t(i)t(j)x(i)x(j)-i=1mα(i)受限于 α(i)0 对于所有 i=1,2,,m 和 i=1mα(i)t(i)=0

一旦找到最小化这个方程的向量α ^(使用 QP 求解器),使用方程 5-4 来计算最小化原始问题的w ^b^。在这个方程中,n[s]代表支持向量的数量。

方程 5-4. 从对偶解到原始解

w ^ = i=1 m α ^ (i) t (i) x (i) b ^ = 1 n s i=1 α ^ (i) >0 m t (i) - w ^ x (i)

当训练实例的数量小于特征数量时,对偶问题比原始问题更快解决。更重要的是,对偶问题使核技巧成为可能,而原始问题则不行。那么这个核技巧到底是什么呢?

核化支持向量机

假设你想对一个二维训练集(比如 moons 训练集)应用二次多项式转换,然后在转换后的训练集上训练一个线性 SVM 分类器。方程 5-5 展示了你想应用的二次多项式映射函数ϕ

方程 5-5. 二次多项式映射

ϕ x = ϕ x 1 x 2 = x 1 2 2 x 1 x 2 x 2 2

请注意,转换后的向量是 3D 而不是 2D。现在让我们看看如果我们应用这个二次多项式映射,然后计算转换后向量的点积,2D 向量ab会发生什么(参见方程 5-6)。

方程 5-6. 二次多项式映射的核技巧

ϕ (a) ϕ ( b ) = a 1 2 2a 1 a 2 a 2 2 b 1 2 2 b 1 b 2 b 2 2 = a 1 2 b 1 2 + 2 a 1 b 1 a 2 b 2 + a 2 2 b 2 2 = a 1 b 1 +a 2 b 2 2 = a 1 a 2 b 1 b 2 2 = (a b) 2

如何?转换后的向量的点积等于原始向量的点积的平方:ϕ(a)^⊺ ϕ(b) = (a^⊺ b)²。

这里的关键见解是:如果将转换 ϕ 应用于所有训练实例,那么对偶问题(参见方程 5-3)将包含点积 ϕ(x((*i*)))ϕ(x^((j)))。但如果 ϕ 是在方程 5-5 中定义的二次多项式变换,那么你可以简单地用(x (i) x (j) ) 2来替换这些转换后向量的点积。因此,你根本不需要转换训练实例;只需在方程 5-3 中用其平方替换点积。结果将严格与你经历转换训练集然后拟合线性 SVM 算法的麻烦完全相同,但这个技巧使整个过程更加高效。

函数Kab)=(a^⊺ b)²是一个二次多项式核。在机器学习中,是一个能够基于原始向量ab计算点积ϕa)^⊺ ϕb)的函数,而无需计算(甚至了解)变换ϕ。方程 5-7 列出了一些最常用的核。

方程 5-7。常见核

线性: K ( a , b ) = a b 多项式: K ( a , b ) = γa b+r d 高斯 RBF: K ( a , b ) = exp ( - γ a-b 2 ) 双曲正切: K ( a , b ) = tanh γ a b + r

我们还有一个问题要解决。方程 5-4 展示了如何在线性 SVM 分类器的情况下从对偶解到原始解的转换。但是如果应用核技巧,你会得到包含ϕx^((i)))的方程。事实上,w </mo></mover></math>必须具有与*ϕ*(*x*((i)))相同数量的维度,这可能非常庞大甚至无限,因此无法计算。但是,如何在不知道w ^的情况下进行预测呢?好消息是,你可以将方程 5-4 中的w </mo></mover></math>公式代入新实例**x**((n))的决策函数中,得到一个只涉及输入向量点积的方程。这使得可以使用核技巧(方程 5-8)。

方程 5-8。使用核化 SVM 进行预测

h w ^,b ^ ϕ ( x (n) ) = w ^ ϕ ( x (n) ) + b ^ = i=1 m α ^ (i) t (i) ϕ(x (i) ) ϕ ( x (n) ) + b ^ = i=1 m α ^ (i) t (i) ϕ (x (i) ) ϕ ( x (n) ) + b ^ = i=1 α ^ (i) >0 m α ^ (i) t (i) K ( x (i) , x (n) ) + b ^

请注意,由于α^((i)) ≠ 0 仅对支持向量有效,因此进行预测涉及计算新输入向量 x^((n)) 与仅支持向量的点积,而不是所有训练实例。当然,您需要使用相同的技巧来计算偏置项 b^(方程 5-9)。

方程 5-9。使用核技巧计算偏置项

b ^ = 1 n s i=1 α ^ (i) >0 m t (i) - w ^ ϕ ( x (i) ) = 1 n s i=1 α ^ (i) >0 m t (i) - j=1 m α ^ (j) t (j) ϕ(x (j) ) ϕ ( x (i) ) = 1 n s i=1 α ^ (i) >0 m t (i) - j=1 α ^ (j) >0 m α ^ (j) t (j) K ( x (i) , x (j) )

如果您开始头痛,那是完全正常的:这是核技巧的一个不幸的副作用。

注意

还可以实现在线核化 SVM,能够进行增量学习,如论文“增量和减量支持向量机学习”⁠⁷和“具有在线和主动学习的快速核分类器”中所述。⁠⁸这些核化 SVM 是用 Matlab 和 C++实现的。但对于大规模非线性问题,您可能需要考虑使用随机森林(参见第七章)或神经网络(参见第 II 部分)。

练习

  1. 支持向量机背后的基本思想是什么?

  2. 支持向量是什么?

  3. 在使用 SVM 时为什么重要对输入进行缩放?

  4. SVM 分类器在对一个实例进行分类时能输出置信度分数吗?概率呢?

  5. 您如何在LinearSVCSVCSGDClassifier之间进行选择?

  6. 假设您使用 RBF 核训练了一个 SVM 分类器,但似乎对训练集欠拟合。您应该增加还是减少γgamma)?C呢?

  7. 模型是ϵ-insensitive是什么意思?

  8. 使用核技巧的目的是什么?

  9. 在一个线性可分数据集上训练一个LinearSVC。然后在相同数据集上训练一个SVC和一个SGDClassifier。看看是否可以让它们产生大致相同的模型。

  10. 在葡萄酒数据集上训练一个 SVM 分类器,您可以使用sklearn.datasets.load_wine()加载该数据集。该数据集包含了由 3 个不同的种植者生产的 178 个葡萄酒样本的化学分析:目标是训练一个能够根据葡萄酒的化学分析预测种植者的分类模型。由于 SVM 分类器是二元分类器,您需要使用一对所有来对所有三个类进行分类。您能达到什么准确度?

  11. 在加利福尼亚住房数据集上训练和微调一个 SVM 回归器。您可以使用原始数据集,而不是我们在第二章中使用的调整版本,您可以使用sklearn.datasets.fetch_california_housing()加载该数据集。目标代表数十万美元。由于有超过 20,000 个实例,SVM 可能会很慢,因此在超参数调整中,您应该使用更少的实例(例如 2,000)来测试更多的超参数组合。您最佳模型的 RMSE 是多少?

这些练习的解决方案可以在本章笔记本的末尾找到,网址为https://homl.info/colab3

¹ Chih-Jen Lin 等人,“用于大规模线性 SVM 的双坐标下降方法”,第 25 届国际机器学习会议论文集(2008 年):408–415。

² John Platt,“顺序最小优化:用于训练支持向量机的快速算法”(微软研究技术报告,1998 年 4 月 21 日)。

³ Zeta(ζ)是希腊字母表的第六个字母。

⁴ 要了解更多关于二次规划的知识,您可以开始阅读 Stephen Boyd 和 Lieven Vandenberghe 的书籍凸优化(剑桥大学出版社)或观看 Richard Brown 的系列视频讲座

⁵ 目标函数是凸函数,不等式约束是连续可微的凸函数。

⁶ 如第四章中所解释的,两个向量ab的点积通常表示为a·b。然而,在机器学习中,向量经常被表示为列向量(即单列矩阵),因此点积通过计算a^⊺b来实现。为了与本书的其余部分保持一致,我们将在这里使用这种表示法,忽略了这实际上导致了一个单元格矩阵而不是标量值的事实。

⁷ Gert Cauwenberghs 和 Tomaso Poggio,“增量和减量支持向量机学习”,《第 13 届国际神经信息处理系统会议论文集》(2000 年):388–394。

⁸ Antoine Bordes 等人,“具有在线和主动学习的快速核分类器”,《机器学习研究杂志》6(2005 年):1579–1619。

第六章:决策树

决策树是多功能的机器学习算法,可以执行分类和回归任务,甚至多输出任务。它们是强大的算法,能够拟合复杂的数据集。例如,在第二章中,你在加利福尼亚住房数据集上训练了一个DecisionTreeRegressor模型,完美拟合了它(实际上,过度拟合了)。

决策树也是随机森林(参见第七章)的基本组件之一,随机森林是当今最强大的机器学习算法之一。

在本章中,我们将首先讨论如何训练、可视化和使用决策树进行预测。然后我们将介绍 Scikit-Learn 使用的 CART 训练算法,并探讨如何正则化树并将其用于回归任务。最后,我们将讨论决策树的一些局限性。

训练和可视化决策树

为了理解决策树,让我们构建一个并看看它如何进行预测。以下代码在鸢尾数据集上训练了一个DecisionTreeClassifier(参见第四章):

from sklearn.datasets import load_iris
from sklearn.tree import DecisionTreeClassifier

iris = load_iris(as_frame=True)
X_iris = iris.data[["petal length (cm)", "petal width (cm)"]].values
y_iris = iris.target

tree_clf = DecisionTreeClassifier(max_depth=2, random_state=42)
tree_clf.fit(X_iris, y_iris)

你可以通过首先使用export_graphviz()函数输出一个名为iris_tree.dot的图形定义文件来可视化训练好的决策树:

from sklearn.tree import export_graphviz

export_graphviz(
        tree_clf,
        out_file="iris_tree.dot",
        feature_names=["petal length (cm)", "petal width (cm)"],
        class_names=iris.target_names,
        rounded=True,
        filled=True
    )

然后你可以使用graphviz.Source.from_file()来加载并在 Jupyter 笔记本中显示文件:

from graphviz import Source

Source.from_file("iris_tree.dot")

Graphviz是一个开源图形可视化软件包。它还包括一个dot命令行工具,可以将.dot文件转换为各种格式,如 PDF 或 PNG。

你的第一个决策树看起来像图 6-1。

mls3 0601

图 6-1. 鸢尾决策树

进行预测

让我们看看图 6-1 中表示的树如何进行预测。假设你找到一朵鸢尾花,想根据其花瓣对其进行分类。你从根节点(深度 0,顶部)开始:这个节点询问花瓣长度是否小于 2.45 厘米。如果是,那么你向下移动到根节点的左子节点(深度 1,左侧)。在这种情况下,它是一个叶节点(即,它没有任何子节点),所以它不会提出任何问题:只需查看该节点的预测类别,决策树预测你的花是鸢尾山鸢尾class=setosa)。

现在假设你找到另一朵花,这次花瓣长度大于 2.45 厘米。你再次从根节点开始,但现在向下移动到右侧子节点(深度 1,右侧)。这不是一个叶节点,它是一个分裂节点,所以它提出另一个问题:花瓣宽度是否小于 1.75 厘米?如果是,那么你的花很可能是鸢尾杂色(深度 2,左侧)。如果不是,它很可能是鸢尾维吉尼亚(深度 2,右侧)。就是这么简单。

注意

决策树的许多优点之一是它们几乎不需要数据准备。事实上,它们根本不需要特征缩放或居中。

节点的samples属性计算它适用于多少训练实例。例如,有 100 个训练实例的花瓣长度大于 2.45 厘米(深度 1,右侧),其中有 100 个训练实例的花瓣宽度小于 1.75 厘米(深度 2,左侧)。节点的value属性告诉您此节点适用于每个类别的训练实例数量:例如,右下节点适用于 0 个Iris setosa,1 个Iris versicolor和 45 个Iris virginica的训练实例。最后,节点的gini属性测量其基尼不纯度:如果所有适用于该节点的训练实例属于同一类,则节点是“纯净的”(gini=0)。例如,由于深度为 1 的左节点仅适用于Iris setosa训练实例,因此它是纯净的,其基尼不纯度为 0。方程 6-1 显示了训练算法如何计算第i个节点的基尼不纯度G[i]。深度为 2 的左节点的基尼不纯度等于 1 - (0/54)² - (49/54)² - (5/54)² ≈ 0.168。

方程 6-1. 基尼不纯度

G i = 1 - k=1 n p i,k 2

在这个方程中:

  • G[i]是第i个节点的基尼不纯度。

  • p[i,k]是第i个节点中训练实例中类k实例的比率。

注意

Scikit-Learn 使用 CART 算法,该算法仅生成二叉树,即分裂节点始终具有两个子节点(即问题只有是/否答案)。但是,其他算法(如 ID3)可以生成具有多于两个子节点的决策树。

图 6-2 显示了此决策树的决策边界。粗垂直线代表根节点(深度 0)的决策边界:花瓣长度=2.45 厘米。由于左侧区域是纯净的(仅Iris setosa),因此无法进一步分裂。但是,右侧区域是不纯的,因此深度为 1 的右节点在花瓣宽度=1.75 厘米处分裂(由虚线表示)。由于max_depth设置为 2,决策树在那里停止。如果将max_depth设置为 3,则两个深度为 2 的节点将分别添加另一个决策边界(由两个垂直虚线表示)。

mls3 0602

图 6-2. 决策树决策边界
提示

决策树结构,包括图 6-1 中显示的所有信息,可以通过分类器的tree_属性获得。输入help(tree_clf.tree_)以获取详细信息,并查看本章笔记本中的示例。

估计类别概率

决策树还可以估计一个实例属于特定类k的概率。首先,它遍历树以找到此实例的叶节点,然后返回该节点中类k的训练实例的比率。例如,假设您找到了一朵花,其花瓣长 5 厘米,宽 1.5 厘米。相应的叶节点是深度为 2 的左节点,因此决策树输出以下概率:Iris setosa为 0%(0/54),Iris versicolor为 90.7%(49/54),Iris virginica为 9.3%(5/54)。如果要求它预测类别,则输出Iris versicolor(类别 1),因为它具有最高概率。让我们来检查一下:

>>> tree_clf.predict_proba([[5, 1.5]]).round(3)
array([[0\.   , 0.907, 0.093]])
>>> tree_clf.predict([[5, 1.5]])
array([1])

完美!请注意,在图 6-2 的右下矩形中的任何其他位置,估计的概率将是相同的——例如,如果花瓣长 6 厘米,宽 1.5 厘米(即使在这种情况下,很明显它很可能是Iris virginica)。

CART 训练算法

Scikit-Learn 使用分类和回归树(CART)算法来训练决策树(也称为“生长”树)。该算法首先通过使用单个特征k和阈值t[k](例如,“花瓣长度≤2.45 厘米”)将训练集分成两个子集。它如何选择kt[k]?它搜索产生最纯净子集的配对(kt[k]),并根据它们的大小加权。方程 6-2 给出了算法试图最小化的成本函数。

方程 6-2. 用于分类的 CART 成本函数

J ( k , t k ) = m left m G left + m right m G right 其中 G left/right 衡量 左/右 子集的不纯度 m left/right 左/右 子集中实例的数量

一旦 CART 算法成功将训练集分成两部分,它会使用相同的逻辑分割子集,然后是子子集,依此类推,递归地进行。一旦达到最大深度(由max_depth超参数定义),或者无法找到能够减少不纯度的分割时,递归停止。另外几个超参数(稍后描述)控制额外的停止条件:min_samples_splitmin_samples_leafmin_weight_fraction_leafmax_leaf_nodes

警告

正如您所看到的,CART 算法是一种贪婪算法:它贪婪地在顶层搜索最佳分割,然后在每个后续级别重复该过程。它不检查分割是否会导致几个级别下可能的最低不纯度。贪婪算法通常会产生一个相当好但不能保证是最佳的解决方案。

不幸的是,找到最佳树被认为是一个NP 完全问题。⁠¹ 它需要O(exp(m))的时间,使得即使对于小型训练集,问题也难以解决。这就是为什么在训练决策树时我们必须接受“相当好”的解决方案。

计算复杂度

进行预测需要从根节点到叶节点遍历决策树。决策树通常大致平衡,因此遍历决策树需要经过大约O(log2)个节点,其中 log2 是m二进制对数,等于 log(m) / log(2)。由于每个节点只需要检查一个特征的值,因此整体预测复杂度为O(log2),与特征数量无关。因此,即使处理大型训练集,预测也非常快速。

训练算法在每个节点上比较所有特征(或少于max_features设置的特征)的所有样本。在每个节点上比较所有特征的所有样本会导致训练复杂度为O(n × m log2)。

基尼不纯度还是熵?

默认情况下,DecisionTreeClassifier类使用基尼不纯度度量,但您可以通过将criterion超参数设置为"entropy"来选择不纯度度量。熵的概念起源于热力学,作为分子无序性的度量:当分子静止且有序时,熵接近零。熵后来传播到各种领域,包括香农的信息论,在那里它衡量消息的平均信息内容,正如我们在第四章中看到的那样。当所有消息相同时,熵为零。在机器学习中,熵经常用作不纯度度量:当集合中只包含一个类的实例时,其熵为零。方程 6-3 显示了i^(th)节点熵的定义。例如,图 6-1 中深度为 2 的左节点的熵等于-(49/54) log[2] (49/54) - (5/54) log[2] (5/54) ≈ 0.445。

方程 6-3. 熵

H i = - k=1 p i,k 0 n p i,k log 2 ( p i,k )

那么,您应该使用基尼不纯度还是熵?事实是,大多数情况下并没有太大的区别:它们导致类似的树。基尼不纯度计算速度稍快,因此是一个很好的默认选择。然而,当它们不同时,基尼不纯度倾向于将最频繁的类隔离在树的自己分支中,而熵倾向于产生稍微更平衡的树。

正则化超参数

决策树对训练数据做出很少的假设(与线性模型相反,线性模型假设数据是线性的,例如)。如果不加约束,树结构将自适应于训练数据,非常紧密地拟合它——事实上,很可能过度拟合。这样的模型通常被称为非参数模型,不是因为它没有任何参数(通常有很多),而是因为参数的数量在训练之前不确定,因此模型结构可以自由地紧密地贴近数据。相比之下,参数模型,如线性模型,具有预先确定的参数数量,因此其自由度受限,减少了过度拟合的风险(但增加了欠拟合的风险)。

为了避免过度拟合训练数据,您需要在训练期间限制决策树的自由度。正如您现在所知,这被称为正则化。正则化超参数取决于所使用的算法,但通常您至少可以限制决策树的最大深度。在 Scikit-Learn 中,这由max_depth超参数控制。默认值为None,表示无限制。减少max_depth将使模型正则化,从而减少过度拟合的风险。

DecisionTreeClassifier类还有一些其他参数,类似地限制了决策树的形状:

max_features

在每个节点评估用于分裂的最大特征数

max_leaf_nodes

叶节点的最大数量

min_samples_split

节点在可以分裂之前必须具有的最小样本数

min_samples_leaf

叶节点必须具有的最小样本数

min_weight_fraction_leaf

min_samples_leaf相同,但表示为加权实例总数的分数

增加min_*超参数或减少max_*超参数将使模型正则化。

注意

其他算法首先训练没有限制的决策树,然后修剪(删除)不必要的节点。如果一个节点的子节点都是叶节点,并且它提供的纯度改进不具有统计显著性,则认为该节点是不必要的。标准统计检验,如χ²检验(卡方检验),用于估计改进纯粹是由于偶然性导致的概率(称为零假设)。如果这个概率,称为p 值,高于给定阈值(通常为 5%,由超参数控制),则认为该节点是不必要的,其子节点将被删除。修剪将继续进行,直到所有不必要的节点都被修剪。

让我们在 moons 数据集上测试正则化,该数据集在第五章中介绍。我们将训练一个没有正则化的决策树,另一个使用min_samples_leaf=5。以下是代码;图 6-3 显示了每棵树的决策边界:

from sklearn.datasets import make_moons

X_moons, y_moons = make_moons(n_samples=150, noise=0.2, random_state=42)

tree_clf1 = DecisionTreeClassifier(random_state=42)
tree_clf2 = DecisionTreeClassifier(min_samples_leaf=5, random_state=42)
tree_clf1.fit(X_moons, y_moons)
tree_clf2.fit(X_moons, y_moons)

mls3 0603

图 6-3. 未正则化树的决策边界(左)和正则化树(右)

左边的未正则化模型明显过拟合,右边的正则化模型可能会更好地泛化。我们可以通过在使用不同随机种子生成的测试集上评估这两棵树来验证这一点:

>>> X_moons_test, y_moons_test = make_moons(n_samples=1000, noise=0.2,
...                                         random_state=43)
...
>>> tree_clf1.score(X_moons_test, y_moons_test)
0.898
>>> tree_clf2.score(X_moons_test, y_moons_test)
0.92

事实上,第二棵树在测试集上有更好的准确性。

回归

决策树也能够执行回归任务。让我们使用 Scikit-Learn 的DecisionTreeRegressor类构建一个回归树,对一个带有max_depth=2的嘈杂二次数据集进行训练:

import numpy as np
from sklearn.tree import DecisionTreeRegressor

np.random.seed(42)
X_quad = np.random.rand(200, 1) - 0.5  # a single random input feature
y_quad = X_quad ** 2 + 0.025 * np.random.randn(200, 1)

tree_reg = DecisionTreeRegressor(max_depth=2, random_state=42)
tree_reg.fit(X_quad, y_quad)

生成的树在图 6-4 中表示。

mls3 0604

图 6-4. 用于回归的决策树

这棵树看起来与您之前构建的分类树非常相似。主要区别在于,每个节点不是预测一个类别,而是预测一个值。例如,假设您想对一个新实例进行预测,其中x[1] = 0.2。根节点询问x[1] ≤ 0.197。由于不是,算法转到右子节点,询问x[1] ≤ 0.772。由于是,算法转到左子节点。这是一个叶节点,它预测value=0.111。这个预测是与该叶节点关联的 110 个训练实例的目标值的平均值,导致这 110 个实例的均方误差等于 0.015。

这个模型的预测在左边的图 6-5 中表示。如果设置max_depth=3,则得到右边的预测。请注意,每个区域的预测值始终是该区域实例的目标值的平均值。该算法分割每个区域的方式是使大多数训练实例尽可能接近该预测值。

mls3 0605

图 6-5. 两个决策树回归模型的预测

CART 算法的工作方式与之前描述的相同,只是现在它试图以最小化 MSE 的方式分割训练集,而不是试图最小化不纯度。方程 6-4 显示了算法试图最小化的成本函数。

方程 6-4. CART 回归的成本函数

J(k,tk)=mleftmMSEleft+mrightmMSErightwhereMSEnode=inode(y</mo></mover><mtext>node</mtext></msub><mo>-</mo><msup><mi>y</mi><mrow><mo>(</mo><mi>i</mi><mo>)</mo></mrow></msup><mo>)</mo></mrow><mn>2</mn></msup></mrow><msub><mi>m</mi><mi>node</mi></msub></mfrac></mtd></mtr><mtr><mtd><msub><mover><mi>y</mi><mo>node=inodey(i)mnode

就像分类任务一样,决策树在处理回归任务时容易过拟合。没有任何正则化(即使用默认超参数),您会得到图 6-6 左侧的预测结果。这些预测显然严重过拟合了训练集。只需设置min_samples_leaf=10就会得到一个更合理的模型,如图 6-6 右侧所示。

mls3 0606

图 6-6。未正则化回归树的预测(左)和正则化树(右)

对轴方向的敏感性

希望到目前为止您已经相信决策树有很多优点:它们相对容易理解和解释,简单易用,多功能且强大。然而,它们也有一些局限性。首先,正如您可能已经注意到的,决策树喜欢正交的决策边界(所有分割都垂直于一个轴),这使它们对数据的方向敏感。例如,图 6-7 显示了一个简单的线性可分数据集:在左侧,决策树可以轻松分割它,而在右侧,数据集旋转了 45°后,决策边界看起来过于复杂。尽管两个决策树都完美拟合了训练集,但右侧的模型很可能泛化效果不佳。

mls3 0607

图 6-7。对训练集旋转的敏感性

限制这个问题的一种方法是对数据进行缩放,然后应用主成分分析转换。我们将在第八章中详细讨论 PCA,但现在您只需要知道它以一种减少特征之间相关性的方式旋转数据,这通常(不总是)使决策树更容易处理。

让我们创建一个小型流水线,对数据进行缩放并使用 PCA 进行旋转,然后在该数据上训练DecisionTreeClassifier。图 6-8 显示了该树的决策边界:正如您所看到的,旋转使得只使用一个特征z[1]就可以很好地拟合数据集,该特征是原始花瓣长度和宽度的线性函数。以下是代码:

from sklearn.decomposition import PCA
from sklearn.pipeline import make_pipeline
from sklearn.preprocessing import StandardScaler

pca_pipeline = make_pipeline(StandardScaler(), PCA())
X_iris_rotated = pca_pipeline.fit_transform(X_iris)
tree_clf_pca = DecisionTreeClassifier(max_depth=2, random_state=42)
tree_clf_pca.fit(X_iris_rotated, y_iris)

mls3 0608

图 6-8。树在经过缩放和 PCA 旋转的鸢尾花数据集上的决策边界

决策树具有高方差

更一般地,决策树的主要问题是它们具有相当高的方差:对超参数或数据进行微小更改可能会产生非常不同的模型。实际上,由于 Scikit-Learn 使用的训练算法是随机的——它在每个节点随机选择要评估的特征集,即使在完全相同的数据上重新训练相同的决策树也可能产生非常不同的模型,例如图 6-9 中所示的模型(除非您设置random_state超参数)。如您所见,它看起来与先前的决策树非常不同(图 6-2)。

mls3 0609

图 6-9. 在相同数据上重新训练相同模型可能会产生非常不同的模型

幸运的是,通过对许多树的预测进行平均,可以显著减少方差。这样的树集成称为随机森林,它是当今最强大的模型之一,您将在下一章中看到。

练习

  1. 在一个包含一百万实例的训练集上训练(无限制)的决策树的大致深度是多少?

  2. 节点的基尼不纯度通常低于还是高于其父节点的?它通常低于/高于,还是总是低于/高于?

  3. 如果一个决策树对训练集过拟合,尝试减小max_depth是一个好主意吗?

  4. 如果一个决策树对训练集欠拟合,尝试对输入特征进行缩放是一个好主意吗?

  5. 在包含一百万实例的训练集上训练一个决策树需要一个小时,那么在包含一千万实例的训练集上训练另一个决策树大约需要多长时间?提示:考虑 CART 算法的计算复杂度。

  6. 在给定训练集上训练一个决策树需要一个小时,如果您增加特征数量会需要多长时间?

  7. 按照以下步骤为 moons 数据集训练和微调决策树:

    1. 使用make_moons(n_samples=10000, noise=0.4)生成一个 moons 数据集。

    2. 使用train_test_split()将数据集分割为训练集和测试集。

    3. 使用网格搜索结合交叉验证(借助GridSearchCV类)来找到DecisionTreeClassifier的良好超参数值。提示:尝试不同的max_leaf_nodes值。

    4. 使用这些超参数在完整的训练集上训练模型,并在测试集上评估模型的性能。您应该获得大约 85%到 87%的准确率。

  8. 按照以下步骤生成一个森林:

    1. 继续上一个练习,生成 1,000 个训练集的子集,每个子集包含随机选择的 100 个实例。提示:您可以使用 Scikit-Learn 的ShuffleSplit类来实现。

    2. 在每个子集上训练一个决策树,使用在前一个练习中找到的最佳超参数值。在测试集上评估这 1,000 个决策树。由于它们是在较小的数据集上训练的,这些决策树可能表现得比第一个决策树更差,仅获得大约 80%的准确率。

    3. 现在是魔法时刻。对于每个测试集实例,生成 1,000 个决策树的预测,并仅保留最频繁的预测(您可以使用 SciPy 的mode()函数)。这种方法为您提供了测试集上的多数投票预测

    4. 在测试集上评估这些预测:您应该获得比第一个模型稍高的准确率(大约高 0.5%到 1.5%)。恭喜,您已经训练了一个随机森林分类器!

这些练习的解决方案可在本章笔记本的末尾找到,网址为https://homl.info/colab3

¹ P 是可以在多项式时间内解决的问题集(即数据集大小的多项式)。NP 是可以在多项式时间内验证解决方案的问题集。NP-hard 问题是可以在多项式时间内减少到已知 NP-hard 问题的问题。NP-complete 问题既是 NP 又是 NP-hard。一个重要的数学问题是 P 是否等于 NP。如果 P ≠ NP(这似乎是可能的),那么任何 NP-complete 问题都不会找到多项式算法(除非有一天在量子计算机上)。

² 有关更多细节,请参阅 Sebastian Raschka 的有趣分析

第七章:集成学习和随机森林

假设您向成千上万的随机人提出一个复杂的问题,然后汇总他们的答案。在许多情况下,您会发现这种汇总的答案比专家的答案更好。这被称为群体的智慧。类似地,如果您汇总一组预测器(如分类器或回归器)的预测,通常会比最佳个体预测器的预测更好。一组预测器称为集成;因此,这种技术称为集成学习,集成学习算法称为集成方法

作为集成方法的一个示例,您可以训练一组决策树分类器,每个分类器在训练集的不同随机子集上训练。然后,您可以获得所有单独树的预测,得到得票最多的类别就是集成的预测(请参见第六章中的最后一个练习)。这样的决策树集成称为随机森林,尽管它很简单,但这是当今最强大的机器学习算法之一。

正如在第二章中讨论的那样,您通常会在项目结束时使用集成方法,一旦您已经构建了几个良好的预测器,将它们组合成一个更好的预测器。事实上,在机器学习竞赛中获胜的解决方案通常涉及几种集成方法,最著名的是Netflix Prize 竞赛

在本章中,我们将研究最流行的集成方法,包括投票分类器、装袋和粘贴集成、随机森林和提升,以及堆叠集成。

投票分类器

假设您已经训练了几个分类器,每个分类器的准确率约为 80%。您可能有一个逻辑回归分类器,一个 SVM 分类器,一个随机森林分类器,一个k最近邻分类器,也许还有几个(请参见图 7-1)。

mls3 0701

图 7-1. 训练多样化的分类器

创建一个更好的分类器的一个非常简单的方法是汇总每个分类器的预测:得票最多的类别是集成的预测。这种多数投票分类器称为硬投票分类器(请参见图 7-2)。

mls3 0702

图 7-2. 硬投票分类器预测

令人惊讶的是,这种投票分类器通常比集成中最好的分类器的准确率更高。事实上,即使每个分类器都是弱学习器(意味着它的表现仅略好于随机猜测),只要集成中有足够数量的弱学习器并且它们足够多样化,集成仍然可以是一个强学习器(实现高准确率)。

这是如何可能的?以下类比可以帮助解开这个谜团。假设您有一个略带偏见的硬币,正面朝上的概率为 51%,反面朝上的概率为 49%。如果您抛掷它 1,000 次,通常会得到大约 510 次正面和 490 次反面,因此大多数是正面。如果您进行计算,您会发现在 1,000 次抛掷后获得大多数正面的概率接近 75%。您抛掷硬币的次数越多,概率就越高(例如,进行 10,000 次抛掷后,概率超过 97%)。这是由于大数定律:随着您不断抛掷硬币,正面的比例越来越接近正面的概率(51%)。图 7-3 显示了 10 组有偏硬币抛掷。您可以看到随着抛掷次数的增加,正面的比例接近 51%。最终,所有 10 组数据最终都接近 51%,它们始终保持在 50%以上。

mls3 0703

图 7-3. 大数定律

类似地,假设你构建一个包含 1,000 个分类器的集成,这些分类器单独的正确率仅为 51%(略高于随机猜测)。如果你预测多数投票的类别,你可以期望达到高达 75%的准确性!然而,这仅在所有分类器完全独立,产生不相关错误时才成立,而这显然不是事实,因为它们是在相同数据上训练的。它们很可能会产生相同类型的错误,因此会有很多错误类别的多数投票,降低了集成的准确性。

提示

集成方法在预测器尽可能独立时效果最好。获得多样化分类器的一种方法是使用非常不同的算法对它们进行训练。这增加了它们会产生非常不同类型错误的机会,提高了集成的准确性。

Scikit-Learn 提供了一个非常容易使用的VotingClassifier类:只需给它一个名称/预测器对的列表,然后像普通分类器一样使用它。让我们在 moons 数据集上尝试一下(在第五章介绍)。我们将加载并拆分 moons 数据集为训练集和测试集,然后创建和训练一个由三个不同分类器组成的投票分类器:

from sklearn.datasets import make_moons
from sklearn.ensemble import RandomForestClassifier, VotingClassifier
from sklearn.linear_model import LogisticRegression
from sklearn.model_selection import train_test_split
from sklearn.svm import SVC

X, y = make_moons(n_samples=500, noise=0.30, random_state=42)
X_train, X_test, y_train, y_test = train_test_split(X, y, random_state=42)

voting_clf = VotingClassifier(
    estimators=[
        ('lr', LogisticRegression(random_state=42)),
        ('rf', RandomForestClassifier(random_state=42)),
        ('svc', SVC(random_state=42))
    ]
)
voting_clf.fit(X_train, y_train)

当你拟合VotingClassifier时,它会克隆每个估计器并拟合这些克隆。原始估计器可以通过estimators属性获得,而拟合的克隆可以通过estimators_属性获得。如果你更喜欢字典而不是列表,可以使用named_estimatorsnamed_estimators_。首先,让我们看看每个拟合分类器在测试集上的准确性:

>>> for name, clf in voting_clf.named_estimators_.items():
...     print(name, "=", clf.score(X_test, y_test))
...
lr = 0.864
rf = 0.896
svc = 0.896

当你调用投票分类器的predict()方法时,它执行硬投票。例如,投票分类器为测试集的第一个实例预测类别 1,因为三个分类器中有两个预测该类别:

>>> voting_clf.predict(X_test[:1])
array([1])
>>> [clf.predict(X_test[:1]) for clf in voting_clf.estimators_]
[array([1]), array([1]), array([0])]

现在让我们看看投票分类器在测试集上的表现:

>>> voting_clf.score(X_test, y_test)
0.912

就是这样!投票分类器的表现优于所有个体分类器。

如果所有分类器都能估计类别概率(即它们都有predict_proba()方法),那么你可以告诉 Scikit-Learn 预测具有最高类别概率的类别,这是所有个体分类器的平均值。这被称为软投票。它通常比硬投票表现更好,因为它更加重视高置信度的投票。你只需要将投票分类器的voting超参数设置为"soft",并确保所有分类器都能估计类别概率。这在SVC类中默认情况下不适用,因此你需要将其probability超参数设置为True(这将使SVC类使用交叉验证来估计类别概率,从而减慢训练速度,并添加一个predict_proba()方法)。让我们试一试:

>>> voting_clf.voting = "soft"
>>> voting_clf.named_estimators["svc"].probability = True
>>> voting_clf.fit(X_train, y_train)
>>> voting_clf.score(X_test, y_test)
0.92

仅通过使用软投票,我们达到了 92%的准确性,不错!

Bagging 和 Pasting

获得多样化分类器的一种方法是使用非常不同的训练算法,正如刚才讨论的。另一种方法是对每个预测器使用相同的训练算法,但在训练集的不同随机子集上训练它们。当采样替换时,这种方法称为bagging(bootstrap 聚合的缩写)⁠。当采样替换时,它被称为pasting

换句话说,bagging 和 pasting 都允许训练实例在多个预测器之间多次采样,但只有 bagging 允许训练实例在同一个预测器中多次采样。这种采样和训练过程在图 7-4 中表示。

mls3 0704

图 7-4。Bagging 和 Pasting 涉及在训练集的不同随机样本上训练多个预测器

一旦所有预测器都训练完毕,集成可以通过简单地聚合所有预测器的预测来对新实例进行预测。聚合函数通常是分类的统计模式(即最频繁的预测,就像硬投票分类器一样),或者是回归的平均值。每个单独的预测器的偏差比在原始训练集上训练时更高,但聚合减少了偏差和方差。通常,集成的结果是,与在原始训练集上训练的单个预测器相比,集成具有类似的偏差但更低的方差。

正如你在图 7-4 中看到的,预测器可以通过不同的 CPU 核心甚至不同的服务器并行训练。同样,预测也可以并行进行。这是 Bagging 和 Pasting 如此受欢迎的原因之一:它们的扩展性非常好。

Scikit-Learn 中的 Bagging 和 Pasting

Scikit-Learn 为 Bagging 和 Pasting 提供了一个简单的 API:BaggingClassifier类(或用于回归的BaggingRegressor)。以下代码训练了一个由 500 个决策树分类器组成的集成:每个分类器都是在从训练集中随机抽取的 100 个训练实例上进行训练的(这是 Bagging 的一个示例,但如果你想使用 Pasting,只需设置bootstrap=False)。n_jobs参数告诉 Scikit-Learn 要使用多少 CPU 核心进行训练和预测,-1告诉 Scikit-Learn 使用所有可用的核心:

from sklearn.ensemble import BaggingClassifier
from sklearn.tree import DecisionTreeClassifier

bag_clf = BaggingClassifier(DecisionTreeClassifier(), n_estimators=500,
                            max_samples=100, n_jobs=-1, random_state=42)
bag_clf.fit(X_train, y_train)
注意

如果基本分类器可以估计类概率(即具有predict_proba()方法),BaggingClassifier会自动执行软投票而不是硬投票,决策树分类器就是这种情况。

图 7-5 比较了单个决策树的决策边界和一个由 500 棵树组成的 Bagging 集成的决策边界(来自前面的代码),它们都是在 moons 数据集上训练的。正如你所看到的,集成的预测很可能比单个决策树的预测更好地泛化:集成具有可比较的偏差,但方差更小(在训练集上大致产生相同数量的错误,但决策边界不那么不规则)。

Bagging 在每个预测器训练的子集中引入了更多的多样性,因此 Bagging 的偏差比 Pasting 略高;但额外的多样性也意味着预测器之间的相关性更低,因此集成的方差降低。总的来说,Bagging 通常会产生更好的模型,这解释了为什么通常会优先选择它。但是如果你有多余的时间和 CPU 计算能力,你可以使用交叉验证来评估 Bagging 和 Pasting,并选择最好的方法。

mls3 0705

图 7-5。单个决策树(左)与由 500 棵树组成的 Bagging 集成(右)

袋外评估

在 Bagging 中,某些训练实例可能会被任何给定的预测器多次抽样,而其他实例可能根本不被抽样。默认情况下,BaggingClassifier使用替换抽样(bootstrap=True)对m个训练实例进行抽样,其中m是训练集的大小。通过这个过程,可以在数学上证明,每个预测器平均只有约 63%的训练实例被抽样。剩下的 37%未被抽样的训练实例被称为袋外(OOB)实例。请注意,它们对于所有预测器来说并不是相同的 37%。

可以使用 OOB 实例评估装袋集成,无需单独的验证集:实际上,如果有足够的估计器,那么训练集中的每个实例很可能是几个估计器的 OOB 实例,因此这些估计器可以用于为该实例进行公平的集成预测。一旦您对每个实例进行了预测,就可以计算集成的预测准确性(或任何其他度量)。

在 Scikit-Learn 中,您可以在创建BaggingClassifier时设置oob_score=True来请求训练后自动进行 OOB 评估。以下代码演示了这一点。生成的评估分数可在oob_score_属性中获得:

>>> bag_clf = BaggingClassifier(DecisionTreeClassifier(), n_estimators=500,
...                             oob_score=True, n_jobs=-1, random_state=42)
...
>>> bag_clf.fit(X_train, y_train)
>>> bag_clf.oob_score_
0.896

根据这个 OOB 评估,这个BaggingClassifier在测试集上可能会达到约 89.6%的准确率。让我们验证一下:

>>> from sklearn.metrics import accuracy_score
>>> y_pred = bag_clf.predict(X_test)
>>> accuracy_score(y_test, y_pred)
0.92

我们在测试中获得了 92%的准确率。OOB 评估有点太悲观了,低了 2%多一点。

每个训练实例的 OOB 决策函数也可以通过oob_decision_function_属性获得。由于基本估计器具有predict_proba()方法,决策函数返回每个训练实例的类概率。例如,OOB 评估估计第一个训练实例属于正类的概率为 67.6%,属于负类的概率为 32.4%:

>>> bag_clf.oob_decision_function_[:3]  # probas for the first 3 instances
array([[0.32352941, 0.67647059],
 [0.3375    , 0.6625    ],
 [1\.        , 0\.        ]])

随机补丁和随机子空间

BaggingClassifier类还支持对特征进行抽样。抽样由两个超参数控制:max_featuresbootstrap_features。它们的工作方式与max_samplesbootstrap相同,但用于特征抽样而不是实例抽样。因此,每个预测器将在输入特征的随机子集上进行训练。

当处理高维输入(例如图像)时,这种技术特别有用,因为它可以显着加快训练速度。对训练实例和特征进行抽样被称为随机补丁方法。保留所有训练实例(通过设置bootstrap=Falsemax_samples=1.0)但对特征进行抽样(通过将bootstrap_features设置为True和/或将max_features设置为小于1.0的值)被称为随机子空间方法

对特征进行抽样会导致更多的预测器多样性,以换取更低的方差稍微增加一点偏差。

随机森林

正如我们所讨论的,随机森林是一组决策树的集成,通常通过装袋方法(有时是粘贴)进行训练,通常将max_samples设置为训练集的大小。您可以使用RandomForestClassifier类来训练随机森林分类器,该类更方便且针对决策树进行了优化(类似地,还有一个用于回归任务的RandomForestRegressor类)。以下代码使用 500 棵树训练了一个随机森林分类器,每棵树最多限制为 16 个叶节点,使用所有可用的 CPU 核心:

from sklearn.ensemble import RandomForestClassifier

rnd_clf = RandomForestClassifier(n_estimators=500, max_leaf_nodes=16,
                                 n_jobs=-1, random_state=42)
rnd_clf.fit(X_train, y_train)

y_pred_rf = rnd_clf.predict(X_test)

除了一些例外,RandomForestClassifier具有DecisionTreeClassifier的所有超参数(用于控制树的生长方式),以及BaggingClassifier的所有超参数来控制集成本身。

随机森林算法在生长树时引入了额外的随机性;在分裂节点时不是搜索最佳特征(参见第六章),而是在一组随机特征中搜索最佳特征。默认情况下,它对特征进行采样n(其中n是特征的总数)。该算法导致更大的树多样性,这(再次)以更低的方差换取更高的偏差,通常产生更好的模型。因此,以下BaggingClassifier等同于之前的RandomForestClassifier

bag_clf = BaggingClassifier(
    DecisionTreeClassifier(max_features="sqrt", max_leaf_nodes=16),
    n_estimators=500, n_jobs=-1, random_state=42)

额外树

在随机森林中生长树时,在每个节点只考虑一组随机特征进行分裂(如前所述)。还可以通过为每个特征使用随机阈值而不是搜索最佳阈值(正如常规决策树所做)来使树更加随机。为此,只需在创建DecisionTreeClassifier时设置splitter="random"

这样极端随机树的森林被称为极端随机树(或简称为额外树)集成。再次,这种技术以更低的方差换取更高的偏差。相比于常规随机森林,额外树分类器的训练速度也更快,因为在每个节点为每个特征找到最佳阈值是生长树中最耗时的任务之一。

您可以使用 Scikit-Learn 的ExtraTreesClassifier类创建一个额外树分类器。其 API 与RandomForestClassifier类相同,只是bootstrap默认为False。同样,ExtraTreesRegressor类与RandomForestRegressor类具有相同的 API,只是bootstrap默认为False

提示

很难事先确定RandomForestClassifier的表现是好还是坏于ExtraTreesClassifier。通常,唯一的方法是尝试两者并使用交叉验证进行比较。

特征重要性

随机森林的另一个很好的特性是它可以轻松测量每个特征的相对重要性。Scikit-Learn 通过查看使用该特征的树节点平均减少不纯度的程度来衡量特征的重要性,跨森林中的所有树。更准确地说,这是一个加权平均值,其中每个节点的权重等于与其相关联的训练样本数(参见第六章)。

Scikit-Learn 在训练后自动计算每个特征的重要性得分,然后将结果进行缩放,使所有重要性的总和等于 1。您可以使用feature_importances_变量访问结果。例如,以下代码在鸢尾花数据集上训练一个RandomForestClassifier(在第四章介绍),并输出每个特征的重要性。看起来最重要的特征是花瓣长度(44%)和宽度(42%),而花萼长度和宽度相比之下不太重要(分别为 11%和 2%):

>>> from sklearn.datasets import load_iris
>>> iris = load_iris(as_frame=True)
>>> rnd_clf = RandomForestClassifier(n_estimators=500, random_state=42)
>>> rnd_clf.fit(iris.data, iris.target)
>>> for score, name in zip(rnd_clf.feature_importances_, iris.data.columns):
...     print(round(score, 2), name)
...
0.11 sepal length (cm)
0.02 sepal width (cm)
0.44 petal length (cm)
0.42 petal width (cm)

同样,如果您在 MNIST 数据集上训练随机森林分类器(在第三章介绍),并绘制每个像素的重要性,则会得到图 7-6 中所代表的图像。

mls3 0706

图 7-6. MNIST 像素重要性(根据随机森林分类器)

随机森林非常方便,可以快速了解哪些特征实际上很重要,特别是如果您需要执行特征选择时。

提升

Boosting(最初称为hypothesis boosting)指的是任何可以将几个弱学习器组合成一个强学习器的集成方法。大多数提升方法的一般思想是顺序训练预测器,每个预测器都试图纠正其前身。有许多提升方法可用,但目前最流行的是AdaBoost(缩写为adaptive boosting)和gradient boosting。让我们从 AdaBoost 开始。

AdaBoost

新预测器纠正其前身的一种方法是更多地关注前身欠拟合的训练实例。这导致新的预测器越来越关注困难的情况。这是 AdaBoost 使用的技术。

例如,在训练 AdaBoost 分类器时,算法首先训练一个基本分类器(如决策树),并使用它对训练集进行预测。然后增加被错误分类的训练实例的相对权重。然后训练第二个分类器,使用更新后的权重,再次对训练集进行预测,更新实例权重,依此类推(参见图 7-7)。

图 7-8 显示了在 moons 数据集上的五个连续预测器的决策边界(在这个例子中,每个预测器都是一个具有 RBF 核的高度正则化的 SVM 分类器)。第一个分类器错误地预测了许多实例,因此它们的权重被提升。因此,第二个分类器在这些实例上做得更好,依此类推。右侧的图表示相同序列的预测器,只是学习率减半(即,在每次迭代中,错误分类的实例权重提升要少得多)。正如您所看到的,这种顺序学习技术与梯度下降有一些相似之处,只是 AdaBoost 不是调整单个预测器的参数以最小化成本函数,而是逐渐将预测器添加到集成中,使其变得更好。

mls3 0707

图 7-7。AdaBoost 顺序训练与实例权重更新

一旦所有预测器都训练完毕,集成就会像装袋或粘贴一样进行预测,只是预测器根据它们在加权训练集上的整体准确性具有不同的权重。

mls3 0708

图 7-8。连续预测器的决策边界
警告

这种顺序学习技术有一个重要的缺点:训练不能并行化,因为每个预测器只能在前一个预测器训练和评估之后进行训练。因此,它的扩展性不如装袋或粘贴。

让我们更仔细地看看 AdaBoost 算法。每个实例权重w^((i))最初设置为 1/m。首先训练一个预测器,并在训练集上计算其加权错误率r[1];参见方程 7-1。

方程 7-1。第 j 个预测器的加权错误率

rj = i=1 y^ j (i) y (i) m w(i) 其中 y^ j(i) jth 预测器的 预测对于 i th 实例

然后使用方程 7-2 计算预测器的权重α[j],其中η是学习率超参数(默认为 1)。⁠¹⁵ 预测器越准确,其权重就越高。如果它只是随机猜测,那么它的权重将接近于零。然而,如果它经常错误(即比随机猜测更不准确),那么它的权重将是负数。

方程 7-2. 预测器权重

α j = η log 1-r j r j

接下来,AdaBoost 算法使用方程 7-3 更新实例权重,提升错误分类实例的权重。

方程 7-3. 权重更新规则

对于 i = 1 , 2 , , m w (i) w (i) 如果 y j ^ (i) = y (i) w (i) exp ( α j ) 如果 y j ^ (i) y (i)

然后对所有实例权重进行归一化(即除以i=1mw(i))。

最后,使用更新后的权重训练一个新的预测器,并重复整个过程:计算新预测器的权重,更新实例权重,然后训练另一个预测器,依此类推。当达到所需数量的预测器或找到一个完美的预测器时,算法停止。

为了进行预测,AdaBoost 简单地计算所有预测器的预测值,并使用预测器权重α[j]对它们进行加权。预测的类别是获得加权投票多数的类别(参见方程 7-4)。

方程 7-4. AdaBoost 预测

y ^ ( x ) = argmax k j=1 y ^ j (x)=k N α j where N is the number of predictors

Scikit-Learn 使用了 AdaBoost 的多类版本称为SAMME⁠¹⁶(代表使用多类指数损失函数的逐步增加建模)。当只有两个类别时,SAMME 等同于 AdaBoost。如果预测器可以估计类别概率(即,如果它们有一个predict_proba()方法),Scikit-Learn 可以使用 SAMME 的变体称为SAMME.RR代表“真实”),它依赖于类别概率而不是预测,并通常表现更好。

以下代码基于 Scikit-Learn 的AdaBoostClassifier类训练了一个基于 30 个决策树桩的 AdaBoost 分类器(正如您所期望的那样,还有一个AdaBoostRegressor类)。决策树桩是一个max_depth=1的决策树——换句话说,由一个决策节点和两个叶节点组成的树。这是AdaBoostClassifier类的默认基础估计器:

from sklearn.ensemble import AdaBoostClassifier

ada_clf = AdaBoostClassifier(
    DecisionTreeClassifier(max_depth=1), n_estimators=30,
    learning_rate=0.5, random_state=42)
ada_clf.fit(X_train, y_train)
提示

如果您的 AdaBoost 集成对训练集过拟合,可以尝试减少估计器的数量或更强烈地正则化基础估计器。

梯度提升

另一个非常流行的提升算法是梯度提升。⁠¹⁷ 就像 AdaBoost 一样,梯度提升通过顺序添加预测器到集成中,每个预测器都纠正其前任。然而,与 AdaBoost 在每次迭代中调整实例权重不同,这种方法试图将新的预测器拟合到前一个预测器产生的残差错误上。

让我们通过一个简单的回归示例,使用决策树作为基础预测器;这被称为梯度树提升,或梯度提升回归树(GBRT)。首先,让我们生成一个带有噪声的二次数据集,并将DecisionTreeRegressor拟合到它:

import numpy as np
from sklearn.tree import DecisionTreeRegressor

np.random.seed(42)
X = np.random.rand(100, 1) - 0.5
y = 3 * X[:, 0] ** 2 + 0.05 * np.random.randn(100)  # y = 3x² + Gaussian noise

tree_reg1 = DecisionTreeRegressor(max_depth=2, random_state=42)
tree_reg1.fit(X, y)

接下来,我们将在第一个预测器产生的残差错误上训练第二个DecisionTreeRegressor

y2 = y - tree_reg1.predict(X)
tree_reg2 = DecisionTreeRegressor(max_depth=2, random_state=43)
tree_reg2.fit(X, y2)

然后我们将在第二个预测器产生的残差错误上训练第三个回归器:

y3 = y2 - tree_reg2.predict(X)
tree_reg3 = DecisionTreeRegressor(max_depth=2, random_state=44)
tree_reg3.fit(X, y3)

现在我们有一个包含三棵树的集成。它可以通过简单地将所有树的预测相加来对新实例进行预测:

>>> X_new = np.array([[-0.4], [0.], [0.5]])
>>> sum(tree.predict(X_new) for tree in (tree_reg1, tree_reg2, tree_reg3))
array([0.49484029, 0.04021166, 0.75026781])

图 7-9 在左列中表示这三棵树的预测,右列中表示集成的预测。在第一行中,集成只有一棵树,因此其预测与第一棵树的预测完全相同。在第二行中,新树是在第一棵树的残差错误上训练的。您可以看到集成的预测等于前两棵树的预测之和。类似地,在第三行中,另一棵树是在第二棵树的残差错误上训练的。您可以看到随着树被添加到集成中,集成的预测逐渐变得更好。

您可以使用 Scikit-Learn 的GradientBoostingRegressor类更轻松地训练 GBRT 集合(还有一个用于分类的GradientBoostingClassifier类)。就像RandomForestRegressor类一样,它有用于控制决策树增长的超参数(例如max_depthmin_samples_leaf),以及用于控制集合训练的超参数,比如树的数量(n_estimators)。以下代码创建了与前一个相同的集合:

from sklearn.ensemble import GradientBoostingRegressor

gbrt = GradientBoostingRegressor(max_depth=2, n_estimators=3,
                                 learning_rate=1.0, random_state=42)
gbrt.fit(X, y)

mls3 0709

图 7-9。在这个梯度提升的描述中,第一个预测器(左上角)被正常训练,然后每个连续的预测器(左中和左下)都是在前一个预测器的残差上进行训练;右列显示了结果集合的预测

learning_rate超参数缩放每棵树的贡献。如果将其设置为一个较低的值,比如0.05,则需要更多的树来拟合训练集,但预测通常会更好地泛化。这是一种称为缩减的正则化技术。图 7-10 显示了使用不同超参数训练的两个 GBRT 集合:左侧的集合没有足够的树来拟合训练集,而右侧的集合有大约适量的树。如果添加更多的树,GBRT 将开始过拟合训练集。

mls3 0710

图 7-10。GBRT 集合,预测器不足(左)和刚好足够(右)

要找到最佳数目的树,可以像往常一样使用GridSearchCVRandomizedSearchCV进行交叉验证,但也有一种更简单的方法:如果将n_iter_no_change超参数设置为一个整数值,比如 10,那么GradientBoostingRegressor将在训练过程中自动停止添加更多的树,如果看到最后的 10 棵树没有帮助。这只是早停(在第四章中介绍),但需要一点耐心:它容忍在停止之前几次迭代没有进展。让我们使用早停来训练集合:

gbrt_best = GradientBoostingRegressor(
    max_depth=2, learning_rate=0.05, n_estimators=500,
    n_iter_no_change=10, random_state=42)
gbrt_best.fit(X, y)

如果将n_iter_no_change设置得太低,训练可能会过早停止,模型会欠拟合。但如果设置得太高,它将过拟合。我们还设置了一个相当小的学习率和一个较高数量的估计器,但由于早停,训练集合中实际的估计器数量要低得多:

>>> gbrt_best.n_estimators_
92

当设置了n_iter_no_change时,fit()方法会自动将训练集分成一个较小的训练集和一个验证集:这使得它可以在每次添加新树时评估模型的性能。验证集的大小由validation_fraction超参数控制,默认为 10%。tol超参数确定了仍然被视为微不足道的最大性能改进。默认值为 0.0001。

GradientBoostingRegressor类还支持一个subsample超参数,指定用于训练每棵树的训练实例的分数。例如,如果subsample=0.25,则每棵树都是在选择的 25%训练实例上随机训练的。现在你可能已经猜到了,这种技术以更低的方差换取更高的偏差。它还显著加快了训练速度。这被称为随机梯度提升

基于直方图的梯度提升

Scikit-Learn 还提供了另一种针对大型数据集进行优化的 GBRT 实现:基于直方图的梯度提升(HGB)。它通过对输入特征进行分箱,用整数替换它们来工作。箱数由max_bins超参数控制,默认为 255,不能设置得比这更高。分箱可以大大减少训练算法需要评估的可能阈值数量。此外,使用整数可以使用更快速和更节省内存的数据结构。构建箱的方式消除了在训练每棵树时对特征进行排序的需要。

因此,这种实现的计算复杂度为O(b×m),而不是O(n×m×log(m)),其中b是箱数,m是训练实例数,n是特征数。实际上,这意味着 HGB 在大型数据集上的训练速度可以比常规 GBRT 快数百倍。然而,分箱会导致精度损失,这起到了正则化的作用:根据数据集的不同,这可能有助于减少过拟合,也可能导致欠拟合。

Scikit-Learn 提供了两个 HGB 类:HistGradientBoostingRegressorHistGradientBoostingClassifier。它们类似于GradientBoostingRegressorGradientBoostingClassifier,但有一些显著的区别:

  • 如果实例数大于 10,000,则自动启用提前停止。您可以通过将early_stopping超参数设置为TrueFalse来始终启用或关闭提前停止。

  • 不支持子采样。

  • n_estimators被重命名为max_iter

  • 唯一可以调整的决策树超参数是max_leaf_nodesmin_samples_leafmax_depth

HGB 类还有两个很好的特性:它们支持分类特征和缺失值。这在预处理方面简化了很多。但是,分类特征必须表示为从 0 到小于max_bins的整数。您可以使用OrdinalEncoder来实现。例如,以下是如何为第二章介绍的加利福尼亚住房数据集构建和训练完整管道的方法:

from sklearn.pipeline import make_pipeline
from sklearn.compose import make_column_transformer
from sklearn.ensemble import HistGradientBoostingRegressor
from sklearn.preprocessing import OrdinalEncoder

hgb_reg = make_pipeline(
    make_column_transformer((OrdinalEncoder(), ["ocean_proximity"]),
                            remainder="passthrough"),
    HistGradientBoostingRegressor(categorical_features=[0], random_state=42)
)
hgb_reg.fit(housing, housing_labels)

整个管道和导入一样简短!不需要填充器、缩放器或独热编码器,非常方便。请注意,categorical_features必须设置为分类列的索引(或布尔数组)。在没有进行任何超参数调整的情况下,该模型的 RMSE 约为 47,600,这还算不错。

提示

Python ML 生态系统中还提供了几种经过优化的梯度提升实现:特别是XGBoostCatBoostLightGBM。这些库已经存在了几年。它们都专门用于梯度提升,它们的 API 与 Scikit-Learn 的非常相似,并提供许多附加功能,包括 GPU 加速;您一定要去了解一下!此外,TensorFlow 随机森林库提供了各种随机森林算法的优化实现,包括普通随机森林、极端树、GBRT 等等。

堆叠

本章我们将讨论的最后一种集成方法称为堆叠(缩写为堆叠泛化)。它基于一个简单的想法:不要使用微不足道的函数(如硬投票)来聚合集成中所有预测器的预测,为什么不训练一个模型来执行这种聚合呢?图 7-11 展示了这样一个集成在新实例上执行回归任务。底部的三个预测器中的每一个都预测不同的值(3.1、2.7 和 2.9),然后最终的预测器(称为混合器元学习器)将这些预测作为输入,做出最终的预测(3.0)。

mls3 0711

图 7-11。使用混合预测器聚合预测

要训练混合器,首先需要构建混合训练集。您可以对集成中的每个预测器使用cross_val_predict(),以获取原始训练集中每个实例的样本外预测(图 7-12),并将这些用作输入特征来训练混合器;目标可以简单地从原始训练集中复制。请注意,无论原始训练集中的特征数量如何(在本例中只有一个),混合训练集将包含每个预测器的一个输入特征(在本例中为三个)。一旦混合器训练完成,基本预测器将最后一次在完整的原始训练集上重新训练。

mls3 0712

图 7-12。在堆叠集成中训练混合器

实际上可以通过这种方式训练几个不同的混合器(例如,一个使用线性回归,另一个使用随机森林回归),以获得一个完整的混合器层,并在其上再添加另一个混合器以生成最终预测,如图 7-13 所示。通过这样做,您可能会挤出更多的性能,但这将在训练时间和系统复杂性方面付出代价。

mls3 0713

图 7-13。多层堆叠集成中的预测

Scikit-Learn 提供了两个用于堆叠集成的类:StackingClassifierStackingRegressor。例如,我们可以用StackingClassifier替换本章开始时在 moons 数据集上使用的VotingClassifier

from sklearn.ensemble import StackingClassifier

stacking_clf = StackingClassifier(
    estimators=[
        ('lr', LogisticRegression(random_state=42)),
        ('rf', RandomForestClassifier(random_state=42)),
        ('svc', SVC(probability=True, random_state=42))
    ],
    final_estimator=RandomForestClassifier(random_state=43),
    cv=5  # number of cross-validation folds
)
stacking_clf.fit(X_train, y_train)

对于每个预测器,堆叠分类器将调用predict_proba()(如果可用);如果不可用,它将退而求其次调用decision_function(),或者作为最后手段调用predict()。如果您没有提供最终的估计器,StackingClassifier将使用LogisticRegression,而StackingRegressor将使用RidgeCV

如果您在测试集上评估这个堆叠模型,您会发现 92.8%的准确率,比使用软投票的投票分类器稍好,后者获得了 92%。

总之,集成方法是多才多艺、强大且相当简单易用的。随机森林、AdaBoost 和 GBRT 是您应该为大多数机器学习任务测试的第一批模型,它们在异构表格数据方面表现尤为出色。此外,由于它们需要非常少的预处理,因此非常适合快速搭建原型。最后,像投票分类器和堆叠分类器这样的集成方法可以帮助将系统的性能推向极限。

练习

  1. 如果您在完全相同的训练数据上训练了五个不同的模型,并且它们都达到了 95%的精度,是否有可能将这些模型组合以获得更好的结果?如果可以,如何?如果不行,为什么?

  2. 硬投票分类器和软投票分类器之间有什么区别?

  3. 将一个装袋集成的训练分布到多个服务器上是否可以加快训练速度?那么对于粘贴集成、提升集成、随机森林或堆叠集成呢?

  4. 什么是袋外评估的好处?

  5. 什么使得额外树集成比常规随机森林更随机?这种额外的随机性如何帮助?额外树分类器比常规随机森林慢还是快?

  6. 如果您的 AdaBoost 集成对训练数据拟合不足,应该调整哪些超参数,如何调整?

  7. 如果您的梯度提升集成对训练集过拟合,应该增加还是减少学习率?

  8. 加载 MNIST 数据集(在第三章中介绍),将其分为训练集、验证集和测试集(例如,使用 50,000 个实例进行训练,10,000 个用于验证,10,000 个用于测试)。然后训练各种分类器,如随机森林分类器、额外树分类器和 SVM 分类器。接下来,尝试将它们组合成一个集成,使用软投票或硬投票在验证集上优于每个单独的分类器。一旦找到一个,尝试在测试集上运行。它的表现比单个分类器好多少?

  9. 运行上一个练习中的各个分类器,对验证集进行预测,并使用结果预测创建一个新的训练集:每个训练实例是一个向量,包含所有分类器对图像的预测集合,目标是图像的类别。在这个新的训练集上训练一个分类器。恭喜您——您刚刚训练了一个混合器,它与分类器一起形成了一个堆叠集成!现在在测试集上评估集成。对于测试集中的每个图像,使用所有分类器进行预测,然后将预测结果输入混合器以获得集成的预测。它与您之前训练的投票分类器相比如何?现在尝试使用StackingClassifier。性能更好吗?如果是,为什么?

这些练习的解决方案可以在本章笔记本的末尾找到,网址为https://homl.info/colab3

¹ 想象从一副牌中随机抽取一张卡片,写下来,然后将其放回到牌组中再抽取下一张卡片:同一张卡片可能被多次抽样。

² Leo Breiman,“Bagging 预测器”,机器学习 24, no. 2 (1996): 123–140。

³ 在统计学中,带替换的重采样被称为自助法

⁴ Leo Breiman,“在大型数据库和在线分类中粘贴小投票”,机器学习 36, no. 1–2 (1999): 85–103。

⁵ 偏差和方差在第四章中介绍过。

max_samples也可以设置为 0.0 到 1.0 之间的浮点数,此时采样实例的最大数量等于训练集大小乘以max_samples

⁷ 当m增长时,这个比率接近 1 – exp(–1) ≈ 63%。

⁸ Gilles Louppe 和 Pierre Geurts,“随机补丁上的集成”,计算机科学讲义 7523 (2012): 346–361。

⁹ 何天金,“用于构建决策森林的随机子空间方法”,IEEE 模式分析与机器智能 20, no. 8 (1998): 832–844。

¹⁰ 何天金,“随机决策森林”,第三届文档分析与识别国际会议论文集 1 (1995): 278。

¹¹ 如果您想要一个除决策树以外的袋子,BaggingClassifier 类仍然很有用。

¹² Pierre Geurts 等,“极端随机树”,机器学习 63, no. 1 (2006): 3–42。

¹³ Yoav Freund 和 Robert E. Schapire, “一个决策理论的在线学习泛化及其在 Boosting 中的应用”, 计算机与系统科学杂志 55, no. 1 (1997): 119–139.

¹⁴ 这仅供说明目的。SVMs 通常不是 AdaBoost 的好基础预测器;它们速度慢且在其中不稳定。

¹⁵ 原始的 AdaBoost 算法不使用学习率超参数。

¹⁶ 更多细节请参见 Ji Zhu 等人的 “多类别 AdaBoost”, 统计学及其界面 2, no. 3 (2009): 349–360.

¹⁷ 梯度提升首次在 Leo Breiman 的 1997 年论文 “Arcing the Edge” 中引入,并在 Jerome H. Friedman 的 1999 年论文 “Greedy Function Approximation: A Gradient Boosting Machine” 中进一步发展。

¹⁸ David H. Wolpert, “堆叠泛化”, 神经网络 5, no. 2 (1992): 241–259.

第八章:降维

许多机器学习问题涉及每个训练实例数千甚至数百万个特征。所有这些特征不仅使训练变得极其缓慢,而且还会使找到一个好的解决方案变得更加困难,您将会看到。这个问题通常被称为维度灾难

幸运的是,在现实世界的问题中,通常可以大大减少特征的数量,将一个棘手的问题转变为一个可处理的问题。例如,考虑 MNIST 图像(在第三章介绍):图像边缘的像素几乎总是白色,因此您可以完全从训练集中删除这些像素,而不会丢失太多信息。正如我们在上一章中看到的,(图 7-6)证实这些像素对于分类任务是完全不重要的。此外,两个相邻的像素通常高度相关:如果将它们合并成一个像素(例如,通过取两个像素强度的平均值),您不会丢失太多信息。

警告

降低维度确实会导致一些信息丢失,就像将图像压缩为 JPEG 可能会降低其质量一样,因此,即使它会加快训练速度,但可能会使您的系统表现稍微变差。它还会使您的流程管道变得更加复杂,因此更难维护。因此,我建议您在考虑使用降维之前,首先尝试使用原始数据训练系统。在某些情况下,降低训练数据的维度可能会过滤掉一些噪音和不必要的细节,从而导致更高的性能,但一般情况下不会;它只会加快训练速度。

除了加快训练速度,降维对于数据可视化也非常有用。将维度降低到二维(或三维)使得可以在图表上绘制高维训练集的简化视图,并经常通过直观地检测模式(如聚类)获得一些重要的见解。此外,数据可视化对于向非数据科学家(特别是将使用您的结果的决策者)传达您的结论至关重要。

在本章中,我们将首先讨论维度灾难,并了解高维空间中发生的情况。然后我们将考虑降维的两种主要方法(投影和流形学习),并将介绍三种最流行的降维技术:PCA、随机投影和局部线性嵌入(LLE)。

维度灾难

我们习惯于生活在三维空间中⁠¹,当我们尝试想象高维空间时,我们的直觉会失败。即使是基本的四维超立方体在我们的脑海中也难以想象(见图 8-1),更不用说一个弯曲在 1000 维空间中的 200 维椭球了。

mls3 0801

图 8-1. 点、线段、正方形、立方体和四维立方体(0D 到 4D 超立方体)⁠²

事实证明,许多事物在高维空间中表现出非常不同。例如,如果在一个单位正方形(一个 1×1 的正方形)中随机选择一个点,它只有约 0.4%的机会位于距离边界不到 0.001 的位置(换句话说,随机点在任何维度上“极端”的可能性非常小)。但在一个 10000 维单位超立方体中,这个概率大于 99.999999%。高维超立方体中的大多数点都非常靠近边界。⁠³

这里有一个更棘手的差异:如果你在单位正方形中随机选择两个点,这两点之间的距离平均约为 0.52。如果你在 3D 单位立方体中随机选择两个点,平均距离将约为 0.66。但是如果你在一个 100 万维单位超立方体中随机选择两个点呢?平均距离,信不信由你,将约为 408.25(大约1,000,0006)!这是违反直觉的:当两点都位于同一个单位超立方体内时,它们怎么会相距如此遥远呢?嗯,在高维空间中有很多空间。因此,高维数据集很可能非常稀疏:大多数训练实例可能相互之间相距很远。这也意味着新实例很可能与任何训练实例相距很远,使得预测比在低维度中不可靠得多,因为它们将基于更大的外推。简而言之,训练集的维度越高,过拟合的风险就越大。

理论上,解决维度灾难的一个方法可能是增加训练集的大小,以达到足够密度的训练实例。不幸的是,在实践中,达到给定密度所需的训练实例数量随着维度的增加呈指数增长。仅仅具有 100 个特征——明显少于 MNIST 问题中的特征数量——这些特征范围从 0 到 1,你需要的训练实例数量将超过可观察宇宙中的原子数量,以便训练实例在平均情况下相距 0.1,假设它们均匀分布在所有维度上。

降维的主要方法

在我们深入研究具体的降维算法之前,让我们看一看降维的两种主要方法:投影和流形学习。

投影

在大多数实际问题中,训练实例并均匀分布在所有维度上。许多特征几乎是恒定的,而其他特征高度相关(正如前面讨论的 MNIST)。因此,所有训练实例都位于(或接近)高维空间中的一个更低维度子空间内。这听起来很抽象,让我们看一个例子。在图 8-2 中,你可以看到由小球表示的 3D 数据集。

mls3 0802

图 8-2. 一个接近 2D 子空间的 3D 数据集

注意到所有训练实例都接近一个平面:这是高维(3D)空间中的一个低维(2D)子空间。如果我们将每个训练实例垂直投影到这个子空间上(如短虚线连接实例到平面),我们得到了在图 8-3 中显示的新的 2D 数据集。哇!我们刚刚将数据集的维度从 3D 降低到 2D。请注意,轴对应于新特征z[1]和z[2]:它们是平面上投影的坐标。

mls3 0803

图 8-3. 投影后的新 2D 数据集

流形学习

然而,投影并不总是降维的最佳方法。在许多情况下,子空间可能扭曲变化,例如在著名的瑞士卷玩具数据集中所示的图 8-4。

mls3 0804

图 8-4. 瑞士卷数据集

简单地投影到平面上(例如,通过删除x[3])会将瑞士卷的不同层压缩在一起,如图 8-5 的左侧所示。你可能想要的是展开瑞士卷,以获得图 8-5 右侧的 2D 数据集。

mls3 0805

图 8-5. 投影到平面上压缩(左)与展开瑞士卷(右)

瑞士卷是一个 2D 流形的例子。简单地说,一个 2D 流形是一个可以在更高维空间中弯曲和扭曲的 2D 形状。更一般地,一个d维流形是一个n维空间的一部分(其中d < n),在局部上类似于一个d维超平面。在瑞士卷的情况下,d = 2,n = 3:它在局部上类似于一个 2D 平面,但在第三维中卷曲。

许多降维算法通过对训练实例所在的流形进行建模来工作;这称为流形学习。它依赖于流形假设,也称为流形假设,即大多数真实世界的高维数据集接近一个低维流形。这种假设经常在实践中被观察到。

再次想想 MNIST 数据集:所有手写数字图像都有一些相似之处。它们由连接的线条组成,边界是白色的,它们大致居中。如果您随机生成图像,只有极小的一部分会看起来像手写数字。换句话说,如果您尝试创建一个数字图像,可用的自由度要比您允许生成任何图像时的自由度大大降低。这些约束往往会将数据集压缩到一个低维流形中。

流形假设通常伴随着另一个隐含的假设:如果在流形的低维空间中表达任务(例如分类或回归),那么任务会更简单。例如,在图 8-6 的顶部行中,瑞士卷被分为两类:在 3D 空间中(左侧)决策边界会相当复杂,但在 2D 展开的流形空间中(右侧)决策边界是一条直线。

然而,这种隐含的假设并不总是成立。例如,在图 8-6 的底部行,决策边界位于x[1] = 5。这个决策边界在原始的 3D 空间中看起来非常简单(一个垂直平面),但在展开的流形中看起来更复杂(四个独立线段的集合)。

简而言之,在训练模型之前减少训练集的维度通常会加快训练速度,但并不总是会导致更好或更简单的解决方案;这完全取决于数据集。

希望您现在对维度诅咒有了一个很好的理解,以及降维算法如何对抗它,特别是在流形假设成立时。本章的其余部分将介绍一些最流行的降维算法。

mls3 0806

图 8-6。降维后决策边界可能并不总是更简单

PCA

主成分分析(PCA)是迄今为止最流行的降维算法。首先它确定与数据最接近的超平面,然后将数据投影到该超平面上,就像在图 8-2 中一样。

保留方差

在将训练集投影到低维超平面之前,您首先需要选择正确的超平面。例如,一个简单的 2D 数据集在图 8-7 的左侧表示,以及三个不同的轴(即 1D 超平面)。右侧是数据集投影到每个轴上的结果。正如您所看到的,对实线的投影保留了最大的方差(顶部),而对虚线的投影保留了很少的方差(底部),对虚线的投影保留了中等数量的方差(中部)。

mls3 0807

图 8-7。选择要投影的子空间

选择保留最大方差量的轴似乎是合理的,因为它很可能会比其他投影丢失更少的信息。另一个证明这种选择的方法是,它是最小化原始数据集与其在该轴上的投影之间的均方距离的轴。这是PCA背后的相当简单的想法。⁠⁴

主成分

PCA 确定在训练集中占据最大方差量的轴。在图 8-7 中,它是实线。它还找到第二个轴,与第一个轴正交,占剩余方差的最大部分。在这个 2D 示例中没有选择:它是虚线。如果是高维数据集,PCA 还会找到第三个轴,与前两个轴正交,以及第四个、第五个等等——与数据集中的维数一样多的轴。

i轴称为数据的第i主成分(PC)。在图 8-7 中,第一个 PC 是向量c[1]所在的轴,第二个 PC 是向量c[2]所在的轴。在图 8-2 中,前两个 PC 位于投影平面上,第三个 PC 是与该平面正交的轴。在投影之后,在图 8-3 中,第一个 PC 对应于z[1]轴,第二个 PC 对应于z[2]轴。

注意

对于每个主成分,PCA 找到一个指向 PC 方向的零中心单位向量。由于两个相对的单位向量位于同一轴上,PCA 返回的单位向量的方向不稳定:如果稍微扰动训练集并再次运行 PCA,则单位向量可能指向与原始向量相反的方向。但是,它们通常仍然位于相同的轴上。在某些情况下,一对单位向量甚至可能旋转或交换(如果沿这两个轴的方差非常接近),但是它们定义的平面通常保持不变。

那么如何找到训练集的主成分呢?幸运的是,有一种称为奇异值分解(SVD)的标准矩阵分解技术,可以将训练集矩阵X分解为三个矩阵U Σ V^⊺的矩阵乘法,其中V包含定义您正在寻找的所有主成分的单位向量,如方程 8-1 所示。

第 8-1 方程。主成分矩阵

V = c 1 c 2 c n

以下 Python 代码使用 NumPy 的svd()函数获取在图 8-2 中表示的 3D 训练集的所有主成分,然后提取定义前两个 PC 的两个单位向量:

import numpy as np

X = [...]  # create a small 3D dataset
X_centered = X - X.mean(axis=0)
U, s, Vt = np.linalg.svd(X_centered)
c1 = Vt[0]
c2 = Vt[1]
警告

PCA 假定数据集围绕原点居中。正如您将看到的,Scikit-Learn 的 PCA 类会为您处理数据的中心化。如果您自己实现 PCA(如前面的示例中),或者使用其他库,请不要忘记首先对数据进行中心化。

投影到 d 维

一旦确定了所有主成分,您可以通过将数据集投影到由前 d 个主成分定义的超平面上来将数据集的维度降低到 d 维。选择这个超平面可以确保投影尽可能保留更多的方差。例如,在 Figure 8-2 中,3D 数据集被投影到由前两个主成分定义的 2D 平面上,保留了数据集大部分的方差。因此,2D 投影看起来非常像原始的 3D 数据集。

将训练集投影到超平面上,并获得降维后的数据集 X[d-proj],维度为 d,计算训练集矩阵 X 与矩阵 W[d] 的矩阵乘法,其中 W[d] 定义为包含 V 的前 d 列的矩阵,如 Equation 8-2 所示。

Equation 8-2. 将训练集投影到 d

X d-proj = X W d

以下 Python 代码将训练集投影到由前两个主成分定义的平面上:

W2 = Vt[:2].T
X2D = X_centered @ W2

就是这样!现在你知道如何通过将数据集投影到任意维度来降低数据集的维度,同时尽可能保留更多的方差。

使用 Scikit-Learn

Scikit-Learn 的 PCA 类使用 SVD 来实现 PCA,就像我们在本章中之前所做的那样。以下代码应用 PCA 将数据集的维度降低到两个维度(请注意,它会自动处理数据的居中):

from sklearn.decomposition import PCA

pca = PCA(n_components=2)
X2D = pca.fit_transform(X)

在将 PCA 转换器拟合到数据集后,其 components_ 属性保存了 W[d] 的转置:它包含了前 d 个主成分的每一行。

解释方差比

另一个有用的信息是每个主成分的解释方差比,可以通过 explained_variance_ratio_ 变量获得。该比率表示数据集方差沿着每个主成分的比例。例如,让我们看看在 Figure 8-2 中表示的 3D 数据集的前两个主成分的解释方差比:

>>> pca.explained_variance_ratio_
array([0.7578477 , 0.15186921])

这个输出告诉我们大约 76% 的数据集方差沿着第一个主成分,大约 15% 沿着第二个主成分。这留下了约 9% 给第三个主成分,因此可以合理地假设第三个主成分可能携带的信息很少。

选择正确的维度数量

不要随意选择要降维到的维度数量,更简单的方法是选择维度数量,使其总和占方差的足够大比例,比如 95%(当然,有一个例外,如果你是为了数据可视化而降维,那么你会希望将维度降低到 2 或 3)。

以下代码加载并拆分 MNIST 数据集(在 Chapter 3 中介绍),并在不降维的情况下执行 PCA,然后计算保留训练集 95% 方差所需的最小维度数量:

from sklearn.datasets import fetch_openml

mnist = fetch_openml('mnist_784', as_frame=False)
X_train, y_train = mnist.data[:60_000], mnist.target[:60_000]
X_test, y_test = mnist.data[60_000:], mnist.target[60_000:]

pca = PCA()
pca.fit(X_train)
cumsum = np.cumsum(pca.explained_variance_ratio_)
d = np.argmax(cumsum >= 0.95) + 1  # d equals 154

然后,您可以将 n_components=d,再次运行 PCA,但有一个更好的选择。而不是指定要保留的主成分数量,您可以将 n_components 设置为介于 0.0 和 1.0 之间的浮点数,表示您希望保留的方差比例:

pca = PCA(n_components=0.95)
X_reduced = pca.fit_transform(X_train)

实际的主成分数量是在训练过程中确定的,并存储在 n_components_ 属性中:

>>> pca.n_components_
154

另一个选项是将解释的方差作为维度数量的函数绘制出来(简单地绘制cumsum;参见图 8-8)。曲线通常会出现一个拐点,解释的方差增长速度会变慢。在这种情况下,您可以看到将维度降低到约 100 维不会丢失太多解释的方差。

mls3 0808

图 8-8。解释的方差作为维度数量的函数

最后,如果您将降维作为监督学习任务(例如分类)的预处理步骤,则可以像调整任何其他超参数一样调整维度数量(参见第二章)。例如,以下代码示例创建了一个两步流水线,首先使用 PCA 降低维度,然后使用随机森林进行分类。接下来,它使用RandomizedSearchCV来找到 PCA 和随机森林分类器的超参数的良好组合。此示例进行了快速搜索,仅调整了 2 个超参数,在仅训练了 1,000 个实例的情况下运行了仅 10 次迭代,但如果您有时间,请随时进行更彻底的搜索:

from sklearn.ensemble import RandomForestClassifier
from sklearn.model_selection import RandomizedSearchCV
from sklearn.pipeline import make_pipeline

clf = make_pipeline(PCA(random_state=42),
                    RandomForestClassifier(random_state=42))
param_distrib = {
    "pca__n_components": np.arange(10, 80),
    "randomforestclassifier__n_estimators": np.arange(50, 500)
}
rnd_search = RandomizedSearchCV(clf, param_distrib, n_iter=10, cv=3,
                                random_state=42)
rnd_search.fit(X_train[:1000], y_train[:1000])

让我们看看找到的最佳超参数:

>>> print(rnd_search.best_params_)
{'randomforestclassifier__n_estimators': 465, 'pca__n_components': 23}

有趣的是最佳组件数量是多么低:我们将一个 784 维数据集减少到只有 23 维!这与我们使用了随机森林这一强大模型有关。如果我们改用线性模型,例如SGDClassifier,搜索会发现我们需要保留更多维度(约 70 个)。

用于压缩的 PCA

降维后,训练集占用的空间大大减少。例如,将 PCA 应用于 MNIST 数据集并保留 95%的方差后,我们只剩下 154 个特征,而不是原始的 784 个特征。因此,数据集现在不到原始大小的 20%,而且我们仅损失了 5%的方差!这是一个合理的压缩比率,很容易看出这种大小的减小会极大地加快分类算法的速度。

还可以通过应用 PCA 投影的逆变换将缩减的数据集解压缩回 784 维。这不会给您原始数据,因为投影丢失了一些信息(在丢弃的 5%方差内),但它可能接近原始数据。原始数据和重构数据(压缩然后解压缩)之间的均方距离称为重构误差

inverse_transform()方法让我们将降维后的 MNIST 数据集解压缩回 784 维:

X_recovered = pca.inverse_transform(X_reduced)

图 8-9 展示了原始训练集中的一些数字(左侧),以及压缩和解压缩后的相应数字。您可以看到存在轻微的图像质量损失,但数字仍然基本完整。

mls3 0809

图 8-9。保留 95%方差的 MNIST 压缩

逆变换的方程式显示在方程 8-3 中。

方程 8-3。PCA 逆变换,回到原始维度数量

X recovered = X d-proj W d

随机 PCA

如果将svd_solver超参数设置为"randomized",Scikit-Learn 将使用一种称为随机 PCA的随机算法,快速找到前d个主成分的近似值。其计算复杂度为O(m × d²) + O(d³),而不是完整 SVD 方法的O(m × n²) + O(n³),因此当d远小于n时,它比完整 SVD 快得多:

rnd_pca = PCA(n_components=154, svd_solver="randomized", random_state=42)
X_reduced = rnd_pca.fit_transform(X_train)
提示

默认情况下,svd_solver 实际上设置为 "auto":Scikit-Learn 在 max(m, n) > 500 且 n_components 是小于 min(m, n) 的 80%的整数时,自动使用随机化 PCA 算法,否则使用完整的 SVD 方法。因此,即使删除了 svd_solver="randomized" 参数,上述代码也会使用随机化 PCA 算法,因为 154 < 0.8 × 784。如果要强制 Scikit-Learn 使用完整的 SVD 以获得稍微更精确的结果,可以将 svd_solver 超参数设置为 "full"

增量 PCA

PCA 的前述实现的一个问题是,为了使算法运行,整个训练集必须适合内存。幸运的是,已经开发出了增量 PCA(IPCA)算法,允许您将训练集分成小批次,并逐个小批次馈送这些数据。这对于大型训练集以及在线应用 PCA(即,随着新实例的到来而进行)非常有用。

以下代码将 MNIST 训练集分成 100 个小批次(使用 NumPy 的 array_split() 函数),并将它们馈送给 Scikit-Learn 的 IncrementalPCA 类来将 MNIST 数据集的维度降低到 154 维,就像以前一样。请注意,您必须对每个小批次调用 partial_fit() 方法,而不是对整个训练集调用 fit() 方法:

from sklearn.decomposition import IncrementalPCA

n_batches = 100
inc_pca = IncrementalPCA(n_components=154)
for X_batch in np.array_split(X_train, n_batches):
    inc_pca.partial_fit(X_batch)

X_reduced = inc_pca.transform(X_train)

或者,您可以使用 NumPy 的 memmap 类,它允许您像在内存中一样操作存储在磁盘上的二进制文件中的大数组;该类仅在需要时将数据加载到内存中。为了演示这一点,让我们首先创建一个内存映射(memmap)文件,并将 MNIST 训练集复制到其中,然后调用 flush() 来确保任何仍在缓存中的数据被保存到磁盘。在现实生活中,X_train 通常不会完全适合内存,因此您需要逐块加载它并将每个块保存到 memmap 数组的正确部分:

filename = "my_mnist.mmap"
X_mmap = np.memmap(filename, dtype='float32', mode='write', shape=X_train.shape)
X_mmap[:] = X_train  # could be a loop instead, saving the data chunk by chunk
X_mmap.flush()

接下来,我们可以加载 memmap 文件并像常规 NumPy 数组一样使用它。让我们使用 IncrementalPCA 类来降低其维度。由于该算法在任何给定时间仅使用数组的一小部分,内存使用保持在控制之下。这使得可以调用通常的 fit() 方法而不是 partial_fit() 方法,这非常方便:

X_mmap = np.memmap(filename, dtype="float32", mode="readonly").reshape(-1, 784)
batch_size = X_mmap.shape[0] // n_batches
inc_pca = IncrementalPCA(n_components=154, batch_size=batch_size)
inc_pca.fit(X_mmap)
警告

只有原始的二进制数据保存在磁盘上,因此在加载时需要指定数组的数据类型和形状。如果省略形状,np.memmap() 将返回一个一维数组。

对于非常高维的数据集,PCA 可能太慢了。正如您之前看到的,即使使用随机化 PCA,其计算复杂度仍然是 O(m × d²) + O(d³),因此目标维数 d 不应太大。如果您处理的数据集具有成千上万个特征或更多(例如,图像),那么训练可能会变得非常缓慢:在这种情况下,您应该考虑使用随机投影。

随机投影

正如其名称所示,随机投影算法使用随机线性投影将数据投影到较低维度的空间。这听起来可能很疯狂,但事实证明,这样的随机投影实际上很可能相当好地保留距离,正如 William B. Johnson 和 Joram Lindenstrauss 在一个著名的引理中数学上证明的那样。因此,两个相似的实例在投影后仍然保持相似,而两个非常不同的实例在投影后仍然保持非常不同。

显然,你丢弃的维度越多,丢失的信息就越多,距离就会变得更加扭曲。那么你如何选择最佳的维度数量呢?Johnson 和 Lindenstrauss 提出了一个方程,确定保留的最小维度数量,以确保——高概率下——距离不会改变超过给定的容差。例如,如果你有一个包含m=5,000 个实例,每个实例有n=20,000 个特征的数据集,你不希望任意两个实例之间的平方距离变化超过ε=10%,⁶,那么你应该将数据投影到d维度,其中d≥4 log(m) / (½ ε² - ⅓ ε³),即 7,300 维度。这是一个相当显著的降维!请注意,这个方程不使用n,它只依赖于mε。这个方程由johnson_lindenstrauss_min_dim()函数实现:

>>> from sklearn.random_projection import johnson_lindenstrauss_min_dim
>>> m, ε = 5_000, 0.1
>>> d = johnson_lindenstrauss_min_dim(m, eps=ε)
>>> d
7300

现在我们可以生成一个形状为[d, n]的随机矩阵P,其中每个项都是从均值为 0,方差为 1 / d的高斯分布中随机抽样的,然后用它将数据集从n维度投影到d维度:

n = 20_000
np.random.seed(42)
P = np.random.randn(d, n) / np.sqrt(d)  # std dev = square root of variance

X = np.random.randn(m, n)  # generate a fake dataset
X_reduced = X @ P.T

这就是全部!它简单高效,无需训练:算法需要创建随机矩阵的唯一信息是数据集的形状。数据本身根本没有被使用。

Scikit-Learn 提供了一个GaussianRandomProjection类,可以做我们刚才做的事情:当你调用它的fit()方法时,它使用johnson_lindenstrauss_min_dim()来确定输出的维度,然后生成一个随机矩阵,存储在components_属性中。然后当你调用transform()时,它使用这个矩阵来执行投影。在创建转换器时,如果你想调整ε,可以设置eps(默认为 0.1),如果你想强制特定的目标维度d,可以设置n_components。以下代码示例给出了与前面代码相同的结果(你也可以验证gaussian_rnd_proj.components_等于P):

from sklearn.random_projection import GaussianRandomProjection

gaussian_rnd_proj = GaussianRandomProjection(eps=ε, random_state=42)
X_reduced = gaussian_rnd_proj.fit_transform(X)  # same result as above

Scikit-Learn 还提供了第二个随机投影转换器,称为SparseRandomProjection。它以相同的方式确定目标维度,生成相同形状的随机矩阵,并执行相同的投影。主要区别在于随机矩阵是稀疏的。这意味着它使用的内存要少得多:在前面的例子中,约 25 MB,而不是将近 1.2 GB!而且它也更快,无论是生成随机矩阵还是降维:在这种情况下,大约快 50%。此外,如果输入是稀疏的,转换会保持稀疏(除非你设置dense_output=True)。最后,它享有与之前方法相同的保持距离性质,降维的质量是可比较的。简而言之,通常最好使用这个转换器而不是第一个,特别是对于大型或稀疏数据集。

稀疏随机矩阵中非零项的比率r称为其密度。默认情况下,它等于1/n。有了 20,000 个特征,这意味着随机矩阵中大约 141 个单元格中只有 1 个是非零的:这是相当稀疏的!如果你愿意,你可以将density超参数设置为另一个值。稀疏随机矩阵中的每个单元格有一个概率r是非零的,每个非零值要么是–v,要么是+v(两者概率相等),其中v=1/dr

如果你想执行逆变换,你首先需要使用 SciPy 的pinv()函数计算组件矩阵的伪逆,然后将减少的数据乘以伪逆的转置:

components_pinv = np.linalg.pinv(gaussian_rnd_proj.components_)
X_recovered = X_reduced @ components_pinv.T
警告

如果组件矩阵很大,计算伪逆可能需要很长时间,因为pinv()的计算复杂度是O(dn²)(如果d<n),否则是O(nd²)。

总之,随机投影是一种简单、快速、内存高效且令人惊讶地强大的降维算法,尤其在处理高维数据集时应该牢记。

注意

随机投影并不总是用于降低大型数据集的维度。例如,Sanjoy Dasgupta 等人的一篇2017 年论文显示,果蝇的大脑实现了一种类似随机投影的模拟,将密集的低维嗅觉输入映射到稀疏的高维二进制输出:对于每种气味,只有少数输出神经元被激活,但相似的气味会激活许多相同的神经元。这类似于一个名为局部敏感哈希(LSH)的著名算法,通常用于搜索引擎以将相似的文档分组。

LLE

局部线性嵌入(LLE)是一种非线性降维(NLDR)技术。它是一种流形学习技术,不依赖于投影,不同于 PCA 和随机投影。简而言之,LLE 首先衡量每个训练实例与其最近邻的线性关系,然后寻找训练集的低维表示,以便最好地保留这些局部关系(稍后详细介绍)。这种方法使其特别擅长展开扭曲的流形,尤其是在噪声不多时。

以下代码制作了一个瑞士卷,然后使用 Scikit-Learn 的LocallyLinearEmbedding类来展开它:

from sklearn.datasets import make_swiss_roll
from sklearn.manifold import LocallyLinearEmbedding

X_swiss, t = make_swiss_roll(n_samples=1000, noise=0.2, random_state=42)
lle = LocallyLinearEmbedding(n_components=2, n_neighbors=10, random_state=42)
X_unrolled = lle.fit_transform(X_swiss)

变量t是一个包含每个实例沿瑞士卷轴位置的 1D NumPy 数组。在这个示例中我们没有使用它,但它可以用作非线性回归任务的目标。

生成的 2D 数据集显示在图 8-10 中。如您所见,瑞士卷完全展开,实例之间的距离在局部上得到很好保留。然而,在较大尺度上距离并没有得到保留:展开的瑞士卷应该是一个矩形,而不是这种被拉伸和扭曲的带状。尽管如此,LLE 在对流形建模方面做得相当不错。

mls3 0810

图 8-10。使用 LLE 展开的瑞士卷

LLE 的工作原理如下:对于每个训练实例x((*i*)),算法识别其*k*个最近邻居(在前面的代码中*k*=10),然后尝试将**x**((i))重构为这些邻居的线性函数。更具体地说,它尝试找到权重w[i,j],使得x^((i))和 j=1 m w i,j x (j)的平方距离尽可能小,假设如果x((*j*))不是**x**((i))的k个最近邻居之一,则w[i,j]=0。因此,LLE 的第一步是方程 8-4 中描述的约束优化问题,其中W是包含所有权重w[i,j]的权重矩阵。第二个约束只是为每个训练实例x^((i))归一化权重。

方程 8-4。LLE 第 1 步:线性建模局部关系

W ^ = argmin W i=1 m x (i) - j=1 m w i,j x (j) 2 subject to w i,j = 0 if x (j) is not one of the k n.n. of x (i) j=1 m w i,j = 1 for i = 1 , 2 , , m

在这一步之后,权重矩阵 W ^(包含权重 w^i,j)编码了训练实例之间的局部线性关系。第二步是将训练实例映射到一个 d 维空间(其中 d < n),同时尽可能保持这些局部关系。如果 z^((i)) 是在这个 d 维空间中 x^((i)) 的映像,那么我们希望 z^((i)) 和 j=1 m w ^ i,j z (j) 之间的平方距离尽可能小。这个想法导致了方程 8-5 中描述的无约束优化问题。它看起来与第一步非常相似,但是不是保持实例固定并找到最佳权重,而是相反的:保持权重固定并找到实例映像在低维空间中的最佳位置。注意,Z 是包含所有 z^((i)) 的矩阵。

方程 8-5. LLE 步骤 2:在保持关系的同时降低维度

Z ^ = argmin Z i=1 m z (i) - j=1 m w ^ i,j z (j) 2

Scikit-Learn 的 LLE 实现具有以下计算复杂度:O(m log(m)n log(k))用于查找k个最近邻居,O(mnk³)用于优化权重,O(dm²)用于构建低维表示。不幸的是,最后一项中的m²使得这个算法在处理非常大的数据集时效率低下。

正如您所看到的,LLE 与投影技术有很大不同,它显著更复杂,但也可以构建更好的低维表示,特别是在数据是非线性的情况下。

其他降维技术

在我们结束本章之前,让我们快速看一下 Scikit-Learn 中提供的其他几种流行的降维技术:

sklearn.manifold.MDS

多维缩放(MDS)在尝试保持实例之间的距离的同时降低维度。随机投影可以用于高维数据,但在低维数据上效果不佳。

sklearn.manifold.Isomap

Isomap通过将每个实例连接到其最近邻来创建图,然后在尝试保持实例之间的测地距离的同时降低维度。图中两个节点之间的测地距离是这两个节点之间最短路径上的节点数。

sklearn.manifold.TSNE

t-分布随机邻域嵌入(t-SNE)在尝试保持相似实例接近和不相似实例分开的同时降低维度。它主要用于可视化,特别是用于在高维空间中可视化实例的聚类。例如,在本章末尾的练习中,您将使用 t-SNE 来可视化 MNIST 图像的 2D 地图。

sklearn.discriminant_analysis.LinearDiscriminantAnalysis

线性判别分析(LDA)是一种线性分类算法,在训练过程中学习类别之间最具区分性的轴。然后可以使用这些轴来定义一个超平面,将数据投影到该超平面上。这种方法的好处是投影将尽可能地使类别保持分开,因此 LDA 是在运行另一个分类算法之前降低维度的好技术(除非仅使用 LDA 就足够)。

图 8-11 展示了 MDS、Isomap 和 t-SNE 在瑞士卷上的结果。MDS 成功将瑞士卷展平而不丢失其全局曲率,而 Isomap 则完全丢失了曲率。根据下游任务的不同,保留大尺度结构可能是好事或坏事。t-SNE 在展平瑞士卷方面做得相当不错,保留了一些曲率,并且还放大了聚类,将卷撕裂开。同样,这可能是好事或坏事,取决于下游任务。

mls3 0811

图 8-11。使用各种技术将瑞士卷降维为 2D

练习

  1. 降低数据集维度的主要动机是什么?主要缺点是什么?

  2. 维度诅咒是什么?

  3. 一旦数据集的维度降低了,是否可以反转操作?如果可以,如何操作?如果不行,为什么?

  4. PCA 能用于降低高度非线性数据集的维度吗?

  5. 假设您对一个 1000 维数据集执行 PCA,将解释的方差比设置为 95%。结果数据集将有多少维度?

  6. 在什么情况下会使用常规 PCA、增量 PCA、随机 PCA 或随机投影?

  7. 如何评估数据集上降维算法的性能?

  8. 链两种不同的降维算法有意义吗?

  9. 加载 MNIST 数据集(在第三章介绍)并将其分为训练集和测试集(取前 60000 个实例进行训练,剩下的 10000 个进行测试)。在数据集上训练一个随机森林分类器并计时,然后评估测试集上的结果模型。接下来,使用 PCA 降低数据集的维度,解释方差比为 95%。在降维后的数据集上训练一个新的随机森林分类器并查看所需时间。训练速度快了吗?接下来,在测试集上评估分类器。与之前的分类器相比如何?再尝试使用SGDClassifier。现在 PCA 有多大帮助?

  10. 使用 t-SNE 将 MNIST 数据集的前 5000 个图像降至 2 维,并使用 Matplotlib 绘制结果。您可以使用散点图,使用 10 种不同的颜色表示每个图像的目标类别。或者,您可以用散点图中的每个点替换为相应实例的类别(从 0 到 9 的数字),甚至绘制数字图像本身的缩小版本(如果绘制所有数字,可视化将太混乱,因此您应该绘制一个随机样本或仅在没有其他实例已经绘制在附近距离的情况下绘制一个实例)。您应该得到一个具有良好分离的数字簇的漂亮可视化效果。尝试使用其他降维算法,如 PCA、LLE 或 MDS,并比较结果的可视化效果。

这些练习的解决方案可在本章笔记本的末尾找到,网址为https://homl.info/colab3

([1])嗯,如果计算时间,那就是四维,如果你是弦理论学家,那就更多了。

([2])在https://homl.info/30观看一个在 3D 空间中投影的旋转四维立方体。图片由维基百科用户 NerdBoy1392 提供(知识共享署名-相同方式共享 3.0)。转载自https://en.wikipedia.org/wiki/Tesseract

([3])有趣的事实:如果考虑足够多的维度,你认识的任何人可能在至少一个维度上是极端的(例如,他们在咖啡中放多少糖)。

([4])Karl Pearson,“关于空间中点系统的最佳拟合线和平面”,伦敦、爱丁堡和都柏林哲学杂志和科学杂志 2,第 11 号(1901 年):559-572。

([5])Scikit-Learn 使用 David A. Ross 等人描述的算法,“用于稳健视觉跟踪的增量学习”,国际计算机视觉杂志 77,第 1-3 号(2008 年):125-141。

([6])ε是希腊字母ε,通常用于表示微小值。

([7])Sanjoy Dasgupta 等人,“一个基本计算问题的神经算法”,Science 358,第 6364 号(2017 年):793-796。

([8])Sam T. Roweis 和 Lawrence K. Saul,“通过局部线性嵌入进行非线性降维”,Science 290,第 5500 号(2000 年):2323-2326。

第九章:无监督学习技术

尽管今天大多数机器学习应用都是基于监督学习的(因此,这也是大部分投资的方向),但绝大多数可用数据是无标签的:我们有输入特征X,但没有标签y。计算机科学家 Yann LeCun 曾经说过,“如果智能是一个蛋糕,无监督学习就是蛋糕,监督学习就是蛋糕上的糖衣,而强化学习就是蛋糕上的樱桃。”换句话说,无监督学习中有巨大的潜力,我们只是刚刚开始探索。

假设你想创建一个系统,该系统将拍摄制造生产线上每个物品的几张照片,并检测哪些物品有缺陷。你可以相当容易地创建一个系统,自动拍照,这可能每天给你数千张照片。然后你可以在短短几周内构建一个相当大的数据集。但是等等,没有标签!如果你想训练一个普通的二元分类器,预测物品是否有缺陷,你需要将每张图片标记为“有缺陷”或“正常”。这通常需要人类专家坐下来手动查看所有图片。这是一项漫长、昂贵和繁琐的任务,因此通常只会在可用图片的一小部分上进行。结果,标记的数据集将非常小,分类器的性能将令人失望。此外,每当公司对其产品进行任何更改时,整个过程都需要从头开始。如果算法只能利用无标签数据而无需人类标记每张图片,那不是很好吗?这就是无监督学习的作用。

在第八章中,我们看了最常见的无监督学习任务:降维。在本章中,我们将看一些更多的无监督任务:

聚类

目标是将相似的实例分组到中。聚类是数据分析、客户细分、推荐系统、搜索引擎、图像分割、半监督学习、降维等领域的重要工具。

异常检测(也称为离群检测

目标是学习“正常”数据的外观,然后利用它来检测异常实例。这些实例被称为异常值离群值,而正常实例被称为内群值。异常检测在各种应用中非常有用,如欺诈检测、制造中检测有缺陷的产品、识别时间序列中的新趋势,或在训练另一个模型之前从数据集中去除离群值,这可以显著提高结果模型的性能。

密度估计

这是估计生成数据集的随机过程的概率密度函数(PDF)的任务。密度估计通常用于异常检测:位于非常低密度区域的实例可能是异常值。它还可用于数据分析和可视化。

准备好享用蛋糕了吗?我们将从两个聚类算法k-means 和 DBSCAN 开始,然后讨论高斯混合模型,看看它们如何用于密度估计、聚类和异常检测。

聚类算法:k 均值和 DBSCAN

当您在山上徒步时,偶然发现了一种您以前从未见过的植物。您四处看看,发现了更多。它们并不完全相同,但它们足够相似,让您知道它们很可能属于同一物种(或至少同一属)。您可能需要植物学家告诉您那是什么物种,但您肯定不需要专家来识别相似外观的对象组。这就是聚类:识别相似实例并将它们分配到集群或相似实例组的任务。

就像分类一样,每个实例被分配到一个组。但是,与分类不同,聚类是一个无监督的任务。考虑图 9-1:左边是鸢尾花数据集(在第四章介绍),其中每个实例的物种(即其类)用不同的标记表示。这是一个带标签的数据集,适合使用逻辑回归、SVM 或随机森林分类器等分类算法。右边是相同的数据集,但没有标签,因此您不能再使用分类算法。这就是聚类算法介入的地方:其中许多算法可以轻松检测到左下角的集群。这也很容易用我们的眼睛看到,但上右角的集群由两个不同的子集群组成并不那么明显。也就是说,数据集有两个额外的特征(萼片长度和宽度)在这里没有表示,聚类算法可以很好地利用所有特征,因此实际上它们相当好地识别了三个集群(例如,使用高斯混合模型,150 个实例中只有 5 个被分配到错误的集群)。

mls3 0901

图 9-1. 分类(左)与聚类(右)

聚类在各种应用中使用,包括:

客户细分

您可以根据客户的购买和在您网站上的活动对其进行聚类。这有助于了解您的客户是谁以及他们需要什么,这样您就可以根据每个细分调整您的产品和营销活动。例如,客户细分在推荐系统中可能很有用,以建议其他同一集群中的用户喜欢的内容。

数据分析

当您分析新数据集时,运行聚类算法然后分析每个集群单独可能会有所帮助。

降维

一旦数据集被聚类,通常可以测量每个实例与每个集群的亲和力;亲和力是衡量实例适合集群程度的任何度量。然后,每个实例的特征向量x可以用其集群亲和力的向量替换。如果有k个集群,那么这个向量是k维的。新向量通常比原始特征向量低得多,但它可以保留足够的信息供进一步处理。

特征工程

集群亲和力通常作为额外特征是有用的。例如,我们在第二章中使用k-means 将地理集群亲和特征添加到加利福尼亚住房数据集中,这有助于我们获得更好的性能。

异常检测(也称为离群值检测

任何对所有集群的亲和力较低的实例可能是异常值。例如,如果您根据网站用户的行为对其进行了聚类,您可以检测到具有异常行为的用户,例如每秒请求的异常数量。

半监督学习

如果您只有少量标签,您可以执行聚类并将标签传播到同一集群中的所有实例。这种技术可以大大增加后续监督学习算法可用的标签数量,从而提高其性能。

搜索引擎

一些搜索引擎允许您搜索与参考图像相似的图像。要构建这样一个系统,您首先需要将聚类算法应用于数据库中的所有图像;相似的图像将最终位于同一聚类中。然后,当用户提供一个参考图像时,您只需要使用训练过的聚类模型来找到这个图像的聚类,然后简单地返回该聚类中的所有图像。

图像分割

通过根据它们的颜色对像素进行聚类,然后用其聚类的平均颜色替换每个像素的颜色,可以大大减少图像中不同颜色的数量。图像分割在许多对象检测和跟踪系统中使用,因为它使得更容易检测每个对象的轮廓。

对于聚类的定义并没有普遍的定义:它真的取决于上下文,不同的算法将捕捉不同类型的聚类。一些算法寻找围绕特定点集中的实例,称为中心点。其他算法寻找连续的密集实例区域:这些聚类可以采用任何形状。一些算法是分层的,寻找聚类的聚类。等等。

在本节中,我们将介绍两种流行的聚类算法,k-均值和 DBSCAN,并探讨它们的一些应用,如非线性降维、半监督学习和异常检测。

k-均值

考虑在图 9-2 中表示的无标签数据集:您可以清楚地看到五个实例的聚类。k-均值算法是一种简单的算法,能够非常快速和高效地对这种数据集进行聚类,通常只需几次迭代。它是由贝尔实验室的斯图尔特·劳埃德(Stuart Lloyd)在 1957 年提出的,作为脉冲编码调制的技术,但直到 1982 年才在公司外部发表。在 1965 年,爱德华·W·福吉(Edward W. Forgy)几乎发表了相同的算法,因此k-均值有时被称为劳埃德-福吉算法。

mls3 0902

图 9-2。由五个实例聚类组成的无标签数据集

让我们在这个数据集上训练一个k-均值聚类器。它将尝试找到每个聚类的中心,并将每个实例分配给最近的聚类:

from sklearn.cluster import KMeans
from sklearn.datasets import make_blobs

X, y = make_blobs([...])  # make the blobs: y contains the cluster IDs, but we
                          # will not use them; that's what we want to predict
k = 5
kmeans = KMeans(n_clusters=k, random_state=42)
y_pred = kmeans.fit_predict(X)

请注意,您必须指定算法必须找到的聚类数k。在这个例子中,从数据看,k应该设置为 5 是相当明显的,但一般来说并不那么容易。我们将很快讨论这个问题。

每个实例将被分配到五个聚类中的一个。在聚类的上下文中,实例的标签是算法分配给该实例的聚类的索引;这不应与分类中使用的类标签混淆(请记住,聚类是一种无监督学习任务)。KMeans实例保留了它训练过的实例的预测标签,可以通过labels_实例变量获得:

>>> y_pred
array([4, 0, 1, ..., 2, 1, 0], dtype=int32)
>>> y_pred is kmeans.labels_
True

我们还可以看一下算法找到的五个中心点:

>>> kmeans.cluster_centers_
array([[-2.80389616,  1.80117999],
 [ 0.20876306,  2.25551336],
 [-2.79290307,  2.79641063],
 [-1.46679593,  2.28585348],
 [-2.80037642,  1.30082566]])

您可以轻松地将新实例分配给最接近的中心点的聚类:

>>> import numpy as np
>>> X_new = np.array([[0, 2], [3, 2], [-3, 3], [-3, 2.5]])
>>> kmeans.predict(X_new)
array([1, 1, 2, 2], dtype=int32)

如果绘制聚类的决策边界,您将得到一个 Voronoi 图:请参见图 9-3,其中每个中心点用 X 表示。

mls3 0903

图 9-3。k-均值决策边界(Voronoi 图)

绝大多数实例明显被分配到了适当的聚类中,但有一些实例可能被错误标记,特别是在左上角聚类和中心聚类之间的边界附近。事实上,当聚类的直径差异很大时,k-均值算法的表现并不好,因为在将实例分配给聚类时,它只关心到中心点的距离。

与将每个实例分配到单个簇不同,即所谓的硬聚类,给每个实例分配每个簇的分数可能很有用,即所谓的软聚类。分数可以是实例与质心之间的距离或相似度分数(或亲和度),例如我们在第二章中使用的高斯径向基函数。在KMeans类中,transform()方法测量每个实例到每个质心的距离:

>>> kmeans.transform(X_new).round(2)
array([[2.81, 0.33, 2.9 , 1.49, 2.89],
 [5.81, 2.8 , 5.85, 4.48, 5.84],
 [1.21, 3.29, 0.29, 1.69, 1.71],
 [0.73, 3.22, 0.36, 1.55, 1.22]])

在这个例子中,X_new中的第一个实例距离第一个质心约为 2.81,距离第二个质心约为 0.33,距离第三个质心约为 2.90,距离第四个质心约为 1.49,距离第五个质心约为 2.89。如果你有一个高维数据集,并以这种方式进行转换,你最终会得到一个k维的数据集:这种转换可以是一种非常有效的非线性降维技术。或者,你可以使用这些距离作为额外特征来训练另一个模型,就像第二章中那样。

k 均值算法

那么,算法是如何工作的呢?假设你已经得到了质心。你可以通过将每个实例分配到最接近的质心的簇中来轻松地为数据集中的所有实例打上标签。相反,如果你已经得到了所有实例的标签,你可以通过计算该簇中实例的平均值来轻松地找到每个簇的质心。但是你既没有标签也没有质心,那么你该如何继续呢?首先随机放置质心(例如,通过从数据集中随机选择k个实例并使用它们的位置作为质心)。然后给实例打标签,更新质心,给实例打标签,更新质心,依此类推,直到质心不再移动。该算法保证会在有限步数内收敛(通常非常小)。这是因为实例与最接近的质心之间的均方距离在每一步都只能减小,而且由于它不能为负,因此保证会收敛。

你可以在图 9-4 中看到算法的运行过程:质心被随机初始化(左上角),然后实例被标记(右上角),然后质心被更新(左中),实例被重新标记(右中),依此类推。正如你所看到的,在仅三次迭代中,算法已经达到了一个看似接近最优的聚类。

注意

该算法的计算复杂度通常与实例数m、簇数k和维度数n成线性关系。然而,这仅在数据具有聚类结构时才成立。如果没有,那么在最坏情况下,复杂度可能会随实例数呈指数增长。在实践中,这种情况很少发生,k-均值通常是最快的聚类算法之一。

mls3 0904

图 9-4。k-均值算法

尽管算法保证会收敛,但它可能不会收敛到正确的解决方案(即可能会收敛到局部最优解):它是否这样做取决于质心初始化。如果在随机初始化步骤中不幸的话,算法可能会收敛到两种次优解决方案,如图 9-5 所示。

mls3 0905

图 9-5。由于不幸的质心初始化而导致次优解决方案

让我们看看通过改进质心初始化方式来减轻这种风险的几种方法。

质心初始化方法

如果你大致知道质心应该在哪里(例如,如果你之前运行过另一个聚类算法),那么你可以将init超参数设置为包含质心列表的 NumPy 数组,并将n_init设置为1

good_init = np.array([[-3, 3], [-3, 2], [-3, 1], [-1, 2], [0, 2]])
kmeans = KMeans(n_clusters=5, init=good_init, n_init=1, random_state=42)
kmeans.fit(X)

另一个解决方案是使用不同的随机初始化多次运行算法,并保留最佳解决方案。随机初始化的次数由n_init超参数控制:默认情况下等于10,这意味着当您调用fit()时,先前描述的整个算法会运行 10 次,Scikit-Learn 会保留最佳解决方案。但它究竟如何知道哪个解决方案最佳呢?它使用性能指标!该指标称为模型的惯性,它是实例与其最近质心之间的平方距离之和。对于左侧的模型,它大约等于 219.4,在图 9-5 中的右侧模型为 258.6,在图 9-3 中的模型仅为 211.6。KMeans类运行算法n_init次,并保留具有最低惯性的模型。在这个例子中,将选择图 9-3 中的模型(除非我们在n_init连续的随机初始化中非常不幸)。如果您感兴趣,可以通过inertia_实例变量访问模型的惯性:

>>> kmeans.inertia_
211.59853725816836

score()方法返回负惯性(它是负数,因为预测器的score()方法必须始终遵守 Scikit-Learn 的“分数越高越好”的规则:如果一个预测器比另一个更好,则其score()方法应返回更高的分数):

>>> kmeans.score(X)
-211.5985372581684

k-means 算法的一个重要改进k-means++是由 David Arthur 和 Sergei Vassilvitskii 在2006 年的一篇论文中提出的。他们引入了一个更智能的初始化步骤,倾向于选择彼此相距较远的质心,这一改进使k-means 算法更不可能收敛到次优解。该论文表明,为更智能的初始化步骤所需的额外计算是非常值得的,因为这使得可以大大减少运行算法以找到最佳解决方案的次数。k-means++初始化算法的工作方式如下:

  1. 从数据集中均匀随机选择一个质心c^((1))。

  2. 选择一个新的质心c((*i*)),以概率选择实例**x**((i)) Dx(i)2 / j=1mDx(j)2的概率,其中 D(x((*i*)))是实例**x**((i))与已选择的最近质心之间的距离。这种概率分布确保距离已选择的质心更远的实例更有可能被选择为质心。

  3. 重复上一步,直到所有k个质心都被选择。

KMeans类默认使用此初始化方法。

加速的 k 均值和小批量 k 均值

另一个对k-means 算法的改进是由 Charles Elkan 在2003 年的一篇论文中提出的。在一些具有许多簇的大型数据集上,通过避免许多不必要的距离计算,可以加速算法。Elkan 通过利用三角不等式(即,直线始终是两点之间的最短距离⁠^(4)并跟踪实例和质心之间的距离的下限和上限来实现这一点。但是,Elkan 的算法并不总是加速训练,有时甚至会显著减慢训练速度;这取决于数据集。但是,如果您想尝试一下,请设置algorithm="elkan"

k-means 算法的另一个重要变体是由 David Sculley 在2010 年的一篇论文中提出的。⁠⁵ 该算法不是在每次迭代中使用完整数据集,而是能够使用小批量数据,每次迭代时轻微移动质心。这加快了算法的速度(通常提高了三到四倍),并使其能够对不适合内存的大型数据集进行聚类。Scikit-Learn 在MiniBatchKMeans类中实现了这个算法,您可以像使用KMeans类一样使用它:

from sklearn.cluster import MiniBatchKMeans

minibatch_kmeans = MiniBatchKMeans(n_clusters=5, random_state=42)
minibatch_kmeans.fit(X)

如果数据集不适合内存,最简单的选择是使用memmap类,就像我们在第八章中对增量 PCA 所做的那样。或者,您可以一次传递一个小批量到partial_fit()方法,但这将需要更多的工作,因为您需要执行多次初始化并自行选择最佳的初始化。

尽管小批量k-means 算法比常规k-means 算法快得多,但其惯性通常略差一些。您可以在图 9-6 中看到这一点:左侧的图比较了在先前的五个斑点数据集上使用不同数量的簇k训练的小批量k-means 和常规k-means 模型的惯性。两条曲线之间的差异很小,但是可见的。在右侧的图中,您可以看到小批量k-means 在这个数据集上大约比常规k-means 快 3.5 倍。

mls3 0906

图 9-6。小批量k-means 的惯性高于k-means(左侧),但速度更快(右侧),特别是当k增加时

寻找最佳簇的数量

到目前为止,我们将簇的数量k设置为 5,因为通过观察数据,很明显这是正确的簇数量。但通常情况下,要知道如何设置k并不那么容易,如果将其设置为错误的值,结果可能会非常糟糕。如您在图 9-7 中所见,对于这个数据集,将k设置为 3 或 8 会导致相当糟糕的模型。

您可能会认为您可以选择惯性最低的模型。不幸的是,事情并不那么简单。k=3 的惯性约为 653.2,远高于k=5(211.6)。但是对于k=8,惯性只有 119.1。当我们增加k时,惯性并不是一个好的性能指标,因为它会不断降低。事实上,簇越多,每个实例距离其最近的质心就越近,因此惯性就会越低。让我们将惯性作为k的函数绘制出来。当我们这样做时,曲线通常包含一个称为“拐点”的拐点(见图 9-8)。

mls3 0907

图 9-7。簇数量的错误选择:当k太小时,独立的簇会合并在一起(左侧),当k太大时,一些簇会被切割成多个部分(右侧)

mls3 0908

图 9-8。将惯性作为簇数量k的函数绘制出来

正如您所看到的,随着k增加到 4,惯性迅速下降,但随着继续增加k,下降速度变得更慢。这条曲线大致呈手臂形状,并且在k=4 处有一个拐点。因此,如果我们不知道更好的选择,我们可能会认为 4 是一个不错的选择:任何更低的值都会有戏剧性的效果,而任何更高的值都不会有太大帮助,我们可能只是无缘无故地将完全良好的簇一分为二。

选择最佳聚类数值的技术相当粗糙。一个更精确(但也更耗费计算资源)的方法是使用轮廓分数,它是所有实例的平均轮廓系数。实例的轮廓系数等于(b - a)/ max(a, b),其中a是同一簇中其他实例的平均距离(即平均簇内距离),b是最近簇的平均距离(即到下一个最近簇实例的平均距离,定义为最小化b的簇,不包括实例自己的簇)。轮廓系数的取值范围在-1 到+1 之间。接近+1 的系数意味着实例很好地位于自己的簇内,并远离其他簇,而接近 0 的系数意味着它靠近簇边界;最后,接近-1 的系数意味着实例可能被分配到错误的簇。

要计算轮廓分数,您可以使用 Scikit-Learn 的silhouette_score()函数,将数据集中的所有实例和它们被分配的标签传递给它:

>>> from sklearn.metrics import silhouette_score
>>> silhouette_score(X, kmeans.labels_)
0.655517642572828

让我们比较不同聚类数的轮廓分数(参见图 9-9)。

mls3 0909

图 9-9. 使用轮廓分数选择聚类数k

正如您所看到的,这种可视化比之前的更丰富:虽然它确认k=4 是一个非常好的选择,但它也突出了k=5 也相当不错,比k=6 或 7 好得多。这在比较惯性时是看不到的。

当我们绘制每个实例的轮廓系数时,按照它们被分配的簇和系数值进行排序,可以获得更具信息量的可视化结果。这被称为轮廓图(参见图 9-10)。每个图包含一个刀形图形,表示一个簇。形状的高度表示簇中实例的数量,宽度代表簇中实例的轮廓系数排序(宽度越宽越好)。

垂直虚线代表每个聚类数的平均轮廓分数。当一个簇中的大多数实例的系数低于这个分数时(即如果许多实例停在虚线之前,停在左边),那么这个簇就相当糟糕,因为这意味着它的实例与其他簇太接近。在这里我们可以看到当k=3 或 6 时,我们得到了糟糕的簇。但是当k=4 或 5 时,簇看起来相当不错:大多数实例延伸到虚线右侧,接近 1.0。当k=4 时,索引为 1 的簇(从底部数第二个)相当大。当k=5 时,所有簇的大小相似。因此,尽管k=4 的整体轮廓分数略高于k=5,但使用k=5 似乎是个好主意,以获得大小相似的簇。

mls3 0910

图 9-10. 分析不同k值的轮廓图

k 均值的限制

尽管 k 均值有许多优点,尤其是快速和可扩展,但并不完美。正如我们所看到的,需要多次运行算法以避免次优解,而且您需要指定聚类数,这可能会很麻烦。此外,当聚类具有不同大小、不同密度或非球形形状时,k 均值的表现并不好。例如,图 9-11 显示了 k 均值如何对包含三个椭圆形簇的数据集进行聚类,这些簇具有不同的维度、密度和方向。

正如您所看到的,这两种解决方案都不太好。左侧的解决方案更好,但仍然将中间簇的 25%切断并分配给右侧的簇。右侧的解决方案只是糟糕透顶,尽管其惯性更低。因此,根据数据的不同,不同的聚类算法可能表现更好。在这些类型的椭圆形簇上,高斯混合模型效果很好。

mls3 0911

图 9-11。k-means 未能正确聚类这些椭圆形斑点
提示

在运行k-means 之前,对输入特征进行缩放很重要(请参阅第二章)。否则,聚类可能会被拉伸得很长,k-means 的性能会很差。缩放特征并不能保证所有聚类都是漂亮的球形,但通常有助于k-means。

现在让我们看看我们可以从聚类中受益的几种方式。我们将使用k-means,但请随意尝试其他聚类算法。

使用聚类进行图像分割

图像分割是将图像分割成多个段的任务。有几种变体:

  • 颜色分割中,具有相似颜色的像素被分配到同一段。在许多应用中,这已经足够了。例如,如果您想要分析卫星图像以测量某个区域的总森林面积,颜色分割可能就足够了。

  • 语义分割中,属于相同对象类型的所有像素都被分配到同一段。例如,在自动驾驶汽车的视觉系统中,所有属于行人图像的像素可能被分配到“行人”段(将包含所有行人的一个段)。

  • 实例分割中,属于同一独立对象的所有像素都分配给同一段。在这种情况下,每个行人将有一个不同的段。

今天在语义或实例分割方面的最新技术是基于卷积神经网络的复杂架构(请参阅第十四章)。在本章中,我们将专注于(简单得多的)颜色分割任务,使用k-means。

我们将首先导入 Pillow 包(Python Imaging Library 的继任者),然后使用它加载ladybug.png图像(请参见图 9-12 中左上角的图像),假设它位于filepath处:

>>> import PIL
>>> image = np.asarray(PIL.Image.open(filepath))
>>> image.shape
(533, 800, 3)

图像表示为 3D 数组。第一维的大小是高度;第二维是宽度;第三维是颜色通道的数量,在这种情况下是红色、绿色和蓝色(RGB)。换句话说,对于每个像素,有一个包含红色、绿色和蓝色强度的 3D 向量,取值范围为 0 到 255 的无符号 8 位整数。一些图像可能具有较少的通道(例如灰度图像,只有一个通道),而一些图像可能具有更多的通道(例如具有额外alpha 通道用于透明度的图像,或者卫星图像,通常包含用于额外光频率的通道(如红外线))。

以下代码将数组重塑为 RGB 颜色的长列表,然后使用k-means 将这些颜色聚类为八个簇。它创建一个segmented_img数组,其中包含每个像素的最近簇中心(即每个像素的簇的平均颜色),最后将此数组重塑为原始图像形状。第三行使用了高级 NumPy 索引;例如,如果kmeans_.labels_中的前 10 个标签等于 1,则segmented_img中的前 10 种颜色等于kmeans.cluster_centers_[1]

X = image.reshape(-1, 3)
kmeans = KMeans(n_clusters=8, random_state=42).fit(X)
segmented_img = kmeans.cluster_centers_[kmeans.labels_]
segmented_img = segmented_img.reshape(image.shape)

这输出了图 9-12 右上方显示的图像。您可以尝试不同数量的簇,如图所示。当您使用少于八个簇时,请注意瓢虫鲜艳的红色未能获得自己的簇:它与环境中的颜色合并在一起。这是因为k-means 更喜欢相似大小的簇。瓢虫很小——比图像的其他部分小得多——所以即使它的颜色很鲜艳,k-means 也无法为其分配一个簇。

mls3 0912

图 9-12. 使用k-means 进行图像分割,使用不同数量的颜色簇

这并不太难,对吧?现在让我们看看聚类的另一个应用。

使用聚类进行半监督学习

聚类的另一个用例是在半监督学习中,当我们有大量未标记的实例和很少带标签的实例时。在本节中,我们将使用数字数据集,这是一个简单的类似 MNIST 的数据集,包含 1,797 个灰度 8×8 图像,代表数字 0 到 9。首先,让我们加载并拆分数据集(已经洗牌):

from sklearn.datasets import load_digits

X_digits, y_digits = load_digits(return_X_y=True)
X_train, y_train = X_digits[:1400], y_digits[:1400]
X_test, y_test = X_digits[1400:], y_digits[1400:]

我们假装只有 50 个实例的标签。为了获得基准性能,让我们在这 50 个带标签的实例上训练一个逻辑回归模型:

from sklearn.linear_model import LogisticRegression

n_labeled = 50
log_reg = LogisticRegression(max_iter=10_000)
log_reg.fit(X_train[:n_labeled], y_train[:n_labeled])

然后我们可以在测试集上测量这个模型的准确率(请注意测试集必须带标签):

>>> log_reg.score(X_test, y_test)
0.7481108312342569

模型的准确率只有 74.8%。这并不好:实际上,如果您尝试在完整的训练集上训练模型,您会发现它将达到约 90.7%的准确率。让我们看看如何做得更好。首先,让我们将训练集聚类成 50 个簇。然后,对于每个簇,我们将找到距离质心最近的图像。我们将称这些图像为代表性图像

k = 50
kmeans = KMeans(n_clusters=k, random_state=42)
X_digits_dist = kmeans.fit_transform(X_train)
representative_digit_idx = np.argmin(X_digits_dist, axis=0)
X_representative_digits = X_train[representative_digit_idx]

图 9-13 显示了 50 个代表性图像。

mls3 0913

图 9-13. 五十个代表性数字图像(每个簇一个)

让我们看看每个图像并手动标记它们:

y_representative_digits = np.array([1, 3, 6, 0, 7, 9, 2, ..., 5, 1, 9, 9, 3, 7])

现在我们有一个只有 50 个带标签实例的数据集,但它们不是随机实例,而是每个实例都是其簇的代表性图像。让我们看看性能是否有所提高:

>>> log_reg = LogisticRegression(max_iter=10_000)
>>> log_reg.fit(X_representative_digits, y_representative_digits)
>>> log_reg.score(X_test, y_test)
0.8488664987405542

哇!我们的准确率从 74.8%跳升到 84.9%,尽管我们仍然只在 50 个实例上训练模型。由于标记实例通常成本高昂且繁琐,特别是当必须由专家手动完成时,将代表性实例标记而不仅仅是随机实例是一个好主意。

但也许我们可以再进一步:如果我们将标签传播到同一簇中的所有其他实例怎么办?这被称为标签传播

y_train_propagated = np.empty(len(X_train), dtype=np.int64)
for i in range(k):
    y_train_propagated[kmeans.labels_ == i] = y_representative_digits[i]

现在让我们再次训练模型并查看其性能:

>>> log_reg = LogisticRegression()
>>> log_reg.fit(X_train, y_train_propagated)
>>> log_reg.score(X_test, y_test)
0.8942065491183879

我们又获得了显著的准确率提升!让我们看看如果忽略距离其簇中心最远的 1%实例是否能做得更好:这应该消除一些异常值。以下代码首先计算每个实例到其最近簇中心的距离,然后对于每个簇,将 1%最大的距离设置为-1。最后,它创建一个不包含这些标记为-1 距离的实例的集合:

percentile_closest = 99

X_cluster_dist = X_digits_dist[np.arange(len(X_train)), kmeans.labels_]
for i in range(k):
    in_cluster = (kmeans.labels_ == i)
    cluster_dist = X_cluster_dist[in_cluster]
    cutoff_distance = np.percentile(cluster_dist, percentile_closest)
    above_cutoff = (X_cluster_dist > cutoff_distance)
    X_cluster_dist[in_cluster & above_cutoff] = -1

partially_propagated = (X_cluster_dist != -1)
X_train_partially_propagated = X_train[partially_propagated]
y_train_partially_propagated = y_train_propagated[partially_propagated]

现在让我们再次在这部分传播的数据集上训练模型,看看我们能获得什么准确率:

>>> log_reg = LogisticRegression(max_iter=10_000)
>>> log_reg.fit(X_train_partially_propagated, y_train_partially_propagated)
>>> log_reg.score(X_test, y_test)
0.9093198992443325

很棒!只有 50 个带标签的实例(平均每类只有 5 个示例!)我们获得了 90.9%的准确率,实际上略高于我们在完全带标签的数字数据集上获得的性能(90.7%)。这在一定程度上要归功于我们删除了一些异常值,另一部分原因是传播的标签实际上相当不错——它们的准确率约为 97.5%,如下面的代码所示:

>>> (y_train_partially_propagated == y_train[partially_propagated]).mean()
0.9755555555555555
提示

Scikit-Learn 还提供了两个可以自动传播标签的类:sklearn.semi_supervised包中的LabelSpreadingLabelPropagation。这两个类构建了所有实例之间的相似性矩阵,并迭代地将标签从有标签的实例传播到相似的无标签实例。还有一个非常不同的类叫做SelfTrainingClassifier,在相同的包中:你给它一个基础分类器(比如RandomForestClassifier),它会在有标签的实例上训练它,然后用它来预测无标签样本的标签。然后,它会使用它最有信心的标签更新训练集,并重复这个训练和标记的过程,直到不能再添加标签为止。这些技术并非万能之策,但它们偶尔可以为你的模型提供一点提升。

在我们继续讨论高斯混合模型之前,让我们看一下 DBSCAN,这是另一种流行的聚类算法,它基于局部密度估计展示了一种非常不同的方法。这种方法允许算法识别任意形状的簇。

DBSCAN

带有噪声的基于密度的空间聚类应用(DBSCAN)算法将连续的高密度区域定义为簇。它的工作原理如下:

  • 对于每个实例,算法计算距离它小于一个小距离ε(epsilon)的实例有多少个。这个区域被称为实例的ε-邻域

  • 如果一个实例在其ε-邻域中至少有min_samples个实例(包括自身),那么它被视为核心实例。换句话说,核心实例是位于密集区域的实例。

  • 所有在核心实例邻域中的实例都属于同一个簇。这个邻域可能包括其他核心实例;因此,一长串相邻的核心实例形成一个单一的簇。

  • 任何不是核心实例且其邻域中没有核心实例的实例都被视为异常。

如果所有簇都被低密度区域很好地分隔开,这个算法就会很有效。Scikit-Learn 中的DBSCAN类使用起来就像你期望的那样简单。让我们在第五章中介绍的 moons 数据集上测试一下:

from sklearn.cluster import DBSCAN
from sklearn.datasets import make_moons

X, y = make_moons(n_samples=1000, noise=0.05)
dbscan = DBSCAN(eps=0.05, min_samples=5)
dbscan.fit(X)

现在所有实例的标签都可以在labels_实例变量中找到:

>>> dbscan.labels_
array([ 0,  2, -1, -1,  1,  0,  0,  0,  2,  5, [...], 3,  3,  4,  2,  6,  3])

请注意,一些实例的簇索引等于-1,这意味着它们被算法视为异常值。核心实例的索引可以在core_sample_indices_实例变量中找到,核心实例本身可以在components_实例变量中找到:

>>> dbscan.core_sample_indices_
array([  0,   4,   5,   6,   7,   8,  10,  11, [...], 993, 995, 997, 998, 999])
>>> dbscan.components_
array([[-0.02137124,  0.40618608],
 [-0.84192557,  0.53058695],
 [...],
 [ 0.79419406,  0.60777171]])

这种聚类在图 9-14 的左图中表示。正如你所看到的,它识别出了相当多的异常值,以及七个不同的簇。多么令人失望!幸运的是,如果我们通过增加eps到 0.2 来扩大每个实例的邻域,我们得到了右侧的聚类,看起来完美。让我们继续使用这个模型。

mls3 0914

图 9-14。使用两个不同邻域半径的 DBSCAN 聚类

令人惊讶的是,DBSCAN类没有predict()方法,尽管它有一个fit_predict()方法。换句话说,它不能预测一个新实例属于哪个簇。这个决定是因为不同的分类算法对不同的任务可能更好,所以作者决定让用户选择使用哪个。此外,这并不难实现。例如,让我们训练一个KNeighborsClassifier

from sklearn.neighbors import KNeighborsClassifier

knn = KNeighborsClassifier(n_neighbors=50)
knn.fit(dbscan.components_, dbscan.labels_[dbscan.core_sample_indices_])

现在,给定一些新实例,我们可以预测它们最有可能属于哪些簇,甚至为每个簇估计一个概率:

>>> X_new = np.array([[-0.5, 0], [0, 0.5], [1, -0.1], [2, 1]])
>>> knn.predict(X_new)
array([1, 0, 1, 0])
>>> knn.predict_proba(X_new)
array([[0.18, 0.82],
 [1\.  , 0\.  ],
 [0.12, 0.88],
 [1\.  , 0\.  ]])

请注意,我们只在核心实例上训练了分类器,但我们也可以选择在所有实例上或除异常值外的所有实例上进行训练:这个选择取决于最终的任务。

决策边界在图 9-15 中表示(十字表示X_new中的四个实例)。请注意,由于训练集中没有异常值,分类器总是选择一个集群,即使该集群很远。很容易引入一个最大距离,这样远离两个集群的两个实例将被分类为异常值。要做到这一点,使用KNeighborsClassifierkneighbors()方法。给定一组实例,它返回训练集中k个最近邻居的距离和索引(两个矩阵,每个有k列):

>>> y_dist, y_pred_idx = knn.kneighbors(X_new, n_neighbors=1)
>>> y_pred = dbscan.labels_[dbscan.core_sample_indices_][y_pred_idx]
>>> y_pred[y_dist > 0.2] = -1
>>> y_pred.ravel()
array([-1,  0,  1, -1])

mls3 0915

图 9-15. 两个集群之间的决策边界

简而言之,DBSCAN 是一个非常简单但功能强大的算法,能够识别任意形状的任意数量的集群。它对异常值具有鲁棒性,只有两个超参数(epsmin_samples)。然而,如果集群之间的密度差异显著,或者某些集群周围没有足够低密度的区域,DBSCAN 可能无法正确捕捉所有集群。此外,它的计算复杂度大约为O(m²n),因此不适用于大型数据集。

提示

您可能还想尝试层次 DBSCAN(HDBSCAN),它在scikit-learn-contrib项目中实现,通常比 DBSCAN 更好地找到不同密度的集群。

其他聚类算法

Scikit-Learn 实现了几种更多的聚类算法,你应该看一看。我无法在这里详细介绍它们所有,但这里是一个简要概述:

凝聚聚类

从底向上构建一个集群的层次结构。想象许多微小的气泡漂浮在水面上,逐渐相互连接,直到形成一个大的气泡群。类似地,在每次迭代中,凝聚聚类将最近的一对集群连接起来(从单个实例开始)。如果你画出一棵树,每对合并的集群都有一个分支,你将得到一个二叉树的集群,其中叶子是单个实例。这种方法可以捕捉各种形状的集群;它还会生成一个灵活且信息丰富的集群树,而不是强迫你选择特定的集群规模,并且可以与任何成对距离一起使用。如果提供一个连接矩阵,它可以很好地扩展到大量实例,连接矩阵是一个稀疏的 m × m 矩阵,指示哪些实例对是邻居(例如,由 sklearn.neighbors.kneighbors_graph() 返回)。没有连接矩阵,该算法在大型数据集上的扩展性不佳。

BIRCH

平衡迭代减少和层次聚类(BIRCH)算法专门设计用于非常大的数据集,只要特征数量不太大(<20),它可能比批量k-means 更快,结果类似。在训练过程中,它构建一个树结构,包含足够的信息,可以快速将每个新实例分配到一个集群,而无需将所有实例存储在树中:这种方法允许它在处理大型数据集时使用有限的内存。

均值漂移

该算法首先在每个实例上放置一个圆圈;然后对于每个圆圈,计算其中所有实例的平均值,并将圆圈移动,使其位于平均值中心。接下来,迭代这个平移步骤,直到所有圆圈停止移动(即,直到它们每个都位于包含的实例的平均值上)。均值漂移将圆圈移动到更高密度的方向,直到每个圆圈找到局部密度最大值。最后,所有圆圈已经定居在同一位置(或足够接近)的实例被分配到同一个簇。均值漂移具有与 DBSCAN 相同的一些特性,例如它可以找到任意形状的任意数量的簇,它只有很少的超参数(只有一个——圆圈的半径,称为带宽),并且依赖于局部密度估计。但与 DBSCAN 不同的是,均值漂移在簇内密度变化时倾向于将簇切割成片。不幸的是,它的计算复杂度为O(m²n),因此不适用于大型数据集。

亲和传播

在这个算法中,实例之间反复交换消息,直到每个实例都选出另一个实例(或自己)来代表它。这些被选出的实例被称为典范。每个典范和所有选举它的实例形成一个簇。在现实生活中的政治中,你通常希望投票给一个观点与你相似的候选人,但你也希望他们赢得选举,所以你可能会选择一个你不完全同意的候选人,但他更受欢迎。你通常通过民意调查来评估受欢迎程度。亲和传播以类似的方式工作,它倾向于选择位于簇中心附近的典范,类似于k-means。但与k-means 不同的是,你不必提前选择簇的数量:它是在训练过程中确定的。此外,亲和传播可以很好地处理不同大小的簇。不幸的是,这个算法的计算复杂度为O(m²),因此不适用于大型数据集。

谱聚类

该算法使用实例之间的相似性矩阵,并从中创建一个低维嵌入(即,降低矩阵的维度),然后在这个低维空间中使用另一个聚类算法(Scikit-Learn 的实现使用k-means)。谱聚类可以捕捉复杂的簇结构,也可以用于切割图形(例如,识别社交网络上的朋友簇)。当实例数量较大时,它不会很好地扩展,并且当簇的大小差异很大时,它的表现也不好。

现在让我们深入研究高斯混合模型,它可以用于密度估计、聚类和异常检测。

高斯混合模型

高斯混合模型(GMM)是一个概率模型,假设实例是从几个高斯分布的混合中生成的,这些分布的参数是未知的。从单个高斯分布生成的所有实例形成一个簇,通常看起来像一个椭圆体。每个簇可以具有不同的椭圆形状、大小、密度和方向,就像图 9-11 中所示。当你观察一个实例时,你知道它是从高斯分布中生成的一个,但你不知道是哪一个,也不知道这些分布的参数是什么。

有几种 GMM 变体。在GaussianMixture类中实现的最简单的变体中,你必须事先知道高斯分布的数量k。假定数据集X是通过以下概率过程生成的:

  • 对于每个实例,从k个簇中随机选择一个。选择第j个簇的概率是簇的权重ϕ^((j)).⁠⁶ 选择第i个实例的簇的索引被记为z^((i)).

  • 如果第i个实例被分配到第j个聚类(即z^((i)) = j),则该实例的位置x((*i*))是从具有均值**μ**((j))和协方差矩阵Σ((*j*))的高斯分布中随机抽样的。这表示**x**((i)) ~ 𝒩(μ^((j)), Σ^((j))).

那么,您可以用这样的模型做什么呢?嗯,给定数据集X,您通常会从估计权重ϕ和所有分布参数μ((1))到**μ**((k))和Σ((1))到**Σ**((k))开始。Scikit-Learn 的GaussianMixture类使这变得非常容易:

from sklearn.mixture import GaussianMixture

gm = GaussianMixture(n_components=3, n_init=10)
gm.fit(X)

让我们看看算法估计的参数:

>>> gm.weights_
array([0.39025715, 0.40007391, 0.20966893])
>>> gm.means_
array([[ 0.05131611,  0.07521837],
 [-1.40763156,  1.42708225],
 [ 3.39893794,  1.05928897]])
>>> gm.covariances_
array([[[ 0.68799922,  0.79606357],
 [ 0.79606357,  1.21236106]],

 [[ 0.63479409,  0.72970799],
 [ 0.72970799,  1.1610351 ]],

 [[ 1.14833585, -0.03256179],
 [-0.03256179,  0.95490931]]])

太好了,运行得很顺利!事实上,三个聚类中有两个分别生成了 500 个实例,而第三个聚类只包含 250 个实例。因此,真实的聚类权重分别为 0.4、0.4 和 0.2,这大致是算法找到的。同样,真实的均值和协方差矩阵与算法找到的相当接近。但是如何实现的呢?这个类依赖于期望最大化(EM)算法,它与k-means 算法有许多相似之处:它也随机初始化聚类参数,然后重复两个步骤直到收敛,首先将实例分配给聚类(这称为期望步骤),然后更新聚类(这称为最大化步骤)。听起来很熟悉,对吧?在聚类的背景下,您可以将 EM 视为k-means 的一种泛化,它不仅找到聚类中心(μ((1))到**μ**((k))),还找到它们的大小、形状和方向(Σ((1))到**Σ**((k))),以及它们的相对权重(ϕ((1))到*ϕ*((k)))。不过,与k-means 不同,EM 使用软聚类分配,而不是硬分配。对于每个实例,在期望步骤中,算法根据当前的聚类参数估计它属于每个聚类的概率。然后,在最大化步骤中,每个聚类使用数据集中的所有实例进行更新,每个实例的权重由估计的属于该聚类的概率加权。这些概率称为聚类对实例的责任。在最大化步骤中,每个聚类的更新将主要受到它最负责的实例的影响。

警告

不幸的是,就像k-means 一样,EM 可能会收敛到较差的解决方案,因此需要多次运行,仅保留最佳解决方案。这就是为什么我们将n_init设置为 10。请注意:默认情况下,n_init设置为 1。

您可以检查算法是否收敛以及需要多少次迭代:

>>> gm.converged_
True
>>> gm.n_iter_
4

现在您已经估计了每个聚类的位置、大小、形状、方向和相对权重,模型可以轻松地将每个实例分配到最可能的聚类(硬聚类),或者估计它属于特定聚类的概率(软聚类)。只需使用predict()方法进行硬聚类,或者使用predict_proba()方法进行软聚类:

>>> gm.predict(X)
array([0, 0, 1, ..., 2, 2, 2])
>>> gm.predict_proba(X).round(3)
array([[0.977, 0\.   , 0.023],
 [0.983, 0.001, 0.016],
 [0\.   , 1\.   , 0\.   ],
 ...,
 [0\.   , 0\.   , 1\.   ],
 [0\.   , 0\.   , 1\.   ],
 [0\.   , 0\.   , 1\.   ]])

高斯混合模型是一种生成模型,这意味着您可以从中抽样新实例(请注意,它们按簇索引排序):

>>> X_new, y_new = gm.sample(6)
>>> X_new
array([[-0.86944074, -0.32767626],
 [ 0.29836051,  0.28297011],
 [-2.8014927 , -0.09047309],
 [ 3.98203732,  1.49951491],
 [ 3.81677148,  0.53095244],
 [ 2.84104923, -0.73858639]])
>>> y_new
array([0, 0, 1, 2, 2, 2])

还可以估计模型在任何给定位置的密度。这是通过使用score_samples()方法实现的:对于给定的每个实例,该方法估计该位置处的概率密度函数(PDF)的对数。得分越高,密度越大:

>>> gm.score_samples(X).round(2)
array([-2.61, -3.57, -3.33, ..., -3.51, -4.4 , -3.81])

如果计算这些分数的指数,您将得到给定实例位置处 PDF 的值。这些不是概率,而是概率密度:它们可以取任何正值,而不仅仅是 0 到 1 之间的值。要估计实例将落入特定区域的概率,您需要在该区域上积分(如果在可能实例位置的整个空间上这样做,结果将为 1)。

图 9-16 显示了聚类均值、决策边界(虚线)和该模型的密度轮廓。

mls3 0916

图 9-16. 训练的高斯混合模型的聚类均值、决策边界和密度轮廓

太棒了!算法显然找到了一个很好的解决方案。当然,我们通过使用一组二维高斯分布生成数据使其任务变得容易(不幸的是,现实生活中的数据并不总是如此高斯和低维)。我们还给出了正确的聚类数。当维度很多、聚类很多或实例很少时,EM 可能会难以收敛到最佳解决方案。您可能需要通过限制算法需要学习的参数数量来降低任务的难度。一种方法是限制聚类可以具有的形状和方向的范围。这可以通过对协方差矩阵施加约束来实现。为此,请将covariance_type超参数设置为以下值之一:

"spherical"

所有聚类必须是球形的,但它们可以具有不同的直径(即,不同的方差)。

"diag"

聚类可以采用任何椭球形状的任何大小,但椭球体的轴必须与坐标轴平行(即,协方差矩阵必须是对角线的)。

"tied"

所有聚类必须具有相同的椭球形状、大小和方向(即,所有聚类共享相同的协方差矩阵)。

默认情况下,covariance_type等于"full",这意味着每个聚类可以采用任何形状、大小和方向(它有自己的无约束协方差矩阵)。图 9-17 显示了当covariance_type设置为"tied""spherical"时 EM 算法找到的解决方案。

mls3 0917

图 9-17. 绑定聚类(左)和球形聚类(右)的高斯混合模型
注意

训练GaussianMixture模型的计算复杂度取决于实例数m、维度数n、聚类数k以及协方差矩阵的约束。如果covariance_type"spherical""diag",则为O(kmn),假设数据具有聚类结构。如果covariance_type"tied""full",则为O(kmn² + kn³),因此不适用于大量特征。

高斯混合模型也可以用于异常检测。我们将在下一节中看到如何使用。

使用高斯混合模型进行异常检测

使用高斯混合模型进行异常检测非常简单:位于低密度区域的任何实例都可以被视为异常。您必须定义要使用的密度阈值。例如,在一个试图检测有缺陷产品的制造公司中,有缺陷产品的比例通常是众所周知的。假设等于 2%。然后,您将密度阈值设置为导致有 2%的实例位于低于该阈值密度区域的值。如果您注意到您得到了太多的假阳性(即被标记为有缺陷的完全良好产品),您可以降低阈值。相反,如果您有太多的假阴性(即系统未标记为有缺陷的有缺陷产品),您可以增加阈值。这是通常的精确度/召回率权衡(参见第三章)。以下是使用第四百分位数最低密度作为阈值(即,大约 4%的实例将被标记为异常)来识别异常值的方法:

densities = gm.score_samples(X)
density_threshold = np.percentile(densities, 2)
anomalies = X[densities < density_threshold]

图 9-18 将这些异常值表示为星号。

一个密切相关的任务是新颖性检测:它与异常检测不同之处在于,算法被假定在一个未被异常值污染的“干净”数据集上进行训练,而异常检测则不做出这种假设。事实上,异常值检测通常用于清理数据集。

提示

高斯混合模型尝试拟合所有数据,包括异常值;如果异常值过多,这将使模型对“正常性”的看法产生偏见,一些异常值可能会被错误地视为正常值。如果发生这种情况,您可以尝试拟合模型一次,使用它来检测并删除最极端的异常值,然后再次在清理后的数据集上拟合模型。另一种方法是使用鲁棒协方差估计方法(参见EllipticEnvelope类)。

mls3 0918

图 9-18. 使用高斯混合模型进行异常检测

就像k-means 一样,GaussianMixture算法要求您指定聚类数。那么如何找到那个数字呢?

选择聚类数

使用k-means,您可以使用惯性或轮廓分数来选择适当的聚类数。但是对于高斯混合,当聚类不是球形或大小不同时,使用这些度量是不可靠的。相反,您可以尝试找到最小化理论信息准则的模型,例如贝叶斯信息准则(BIC)或阿凯克信息准则(AIC),在方程 9-1 中定义。

方程 9-1. 贝叶斯信息准则(BIC)和阿凯克信息准则(AIC)

B I C = log ( m ) p - 2 log ( L ^ ) A I C = 2 p - 2 log ( L ^ )

在这些方程中:

  • m 一如既往是实例的数量。

  • p 是模型学习的参数数量。

  • L^ 是模型的似然函数的最大化值。

BIC 和 AIC 都惩罚具有更多要学习的参数(例如更多聚类)的模型,并奖励拟合数据良好的模型。它们通常选择相同的模型。当它们不同时,BIC 选择的模型往往比 AIC 选择的模型更简单(参数更少),但往往不那么适合数据(特别是对于更大的数据集)。

要计算 BIC 和 AIC,请调用bic()aic()方法:

>>> gm.bic(X)
8189.747000497186
>>> gm.aic(X)
8102.521720382148

图 9-20 显示了不同聚类数k的 BIC。如您所见,当k=3 时,BIC 和 AIC 都最低,因此这很可能是最佳选择。

mls3 0920

图 9-20. 不同聚类数k的 AIC 和 BIC

贝叶斯高斯混合模型

与手动搜索最佳聚类数不同,您可以使用BayesianGaussianMixture类,该类能够将权重等于(或接近)零的不必要聚类。将聚类数n_components设置为一个您有充分理由认为大于最佳聚类数的值(这假设对问题有一些最小的了解),算法将自动消除不必要的聚类。例如,让我们将聚类数设置为 10,看看会发生什么:

>>> from sklearn.mixture import BayesianGaussianMixture
>>> bgm = BayesianGaussianMixture(n_components=10, n_init=10, random_state=42)
>>> bgm.fit(X)
>>> bgm.weights_.round(2)
array([0.4 , 0.21, 0.4 , 0\.  , 0\.  , 0\.  , 0\.  , 0\.  , 0\.  , 0\.  ])

完美:该算法自动检测到只需要三个簇,并且得到的簇几乎与图 9-16 中的簇相同。

关于高斯混合模型的最后一点说明:尽管它们在具有椭圆形状的簇上表现很好,但在形状非常不同的簇上表现不佳。例如,让我们看看如果我们使用贝叶斯高斯混合模型来对月亮数据集进行聚类会发生什么(参见图 9-21)。

糟糕!该算法拼命搜索椭圆体,因此找到了八个不同的簇,而不是两个。密度估计并不太糟糕,因此这个模型可能可以用于异常检测,但它未能识别出这两个月亮。为了结束本章,让我们快速看一下几种能够处理任意形状簇的算法。

mls3 0921

图 9-21. 将高斯混合拟合到非椭圆形簇

其他用于异常和新颖性检测的算法

Scikit-Learn 实现了其他专门用于异常检测或新颖性检测的算法:

快速 MCD(最小协方差行列式)

EllipticEnvelope类实现,该算法对于异常值检测很有用,特别是用于清理数据集。它假设正常实例(内点)是从单个高斯分布(而不是混合)生成的。它还假设数据集中混入了未从该高斯分布生成的异常值。当算法估计高斯分布的参数(即围绕内点的椭圆包络的形状)时,它会小心地忽略那些最有可能是异常值的实例。这种技术提供了对椭圆包络更好的估计,从而使算法更好地识别异常值。

孤立森林

这是一种用于异常值检测的高效算法,特别适用于高维数据集。该算法构建了一个随机森林,其中每棵决策树都是随机生长的:在每个节点,它随机选择一个特征,然后选择一个随机阈值(在最小值和最大值之间)来将数据集分成两部分。数据集逐渐以这种方式被切割成片段,直到所有实例最终与其他实例隔离开来。异常通常远离其他实例,因此平均而言(在所有决策树中),它们往往比正常实例更快地被隔离。

局部离群因子(LOF)

这个算法也适用于异常值检测。它比较了给定实例周围的实例密度与其邻居周围的密度。异常通常比其k个最近邻更孤立。

一类 SVM

这个算法更适合用于新颖性检测。回想一下,一个核化的 SVM 分类器通过首先(隐式地)将所有实例映射到高维空间,然后在这个高维空间中使用线性 SVM 分类器来分离两个类别(参见第五章)。由于我们只有一个类的实例,一类 SVM 算法尝试在高维空间中将实例与原点分离。在原始空间中,这将对应于找到一个包含所有实例的小区域。如果一个新实例不在这个区域内,那么它就是异常值。有一些需要调整的超参数:用于核化 SVM 的通常超参数,以及一个边际超参数,对应于新实例被错误地认为是新颖的概率,而实际上是正常的。它的效果很好,特别是对于高维数据集,但像所有的 SVM 一样,它不适用于大型数据集。

PCA 和其他具有inverse_transform()方法的降维技术

如果将正常实例的重建误差与异常的重建误差进行比较,后者通常会大得多。这是一种简单而通常相当有效的异常检测方法(请参阅本章的练习以获取示例)。

练习

  1. 你如何定义聚类?你能说出几种聚类算法吗?

  2. 聚类算法的主要应用有哪些?

  3. 描述两种在使用k-means 时选择正确聚类数量的技术。

  4. 什么是标签传播?为什么要实现它,以及如何实现?

  5. 你能说出两种可以扩展到大型数据集的聚类算法吗?还有两种寻找高密度区域的算法吗?

  6. 你能想到一个使用主动学习会有用的用例吗?你会如何实现它?

  7. 异常检测和新颖性检测之间有什么区别?

  8. 什么是高斯混合模型?可以用它来做什么任务?

  9. 你能说出使用高斯混合模型时找到正确聚类数量的两种技术吗?

  10. 经典的 Olivetti 人脸数据集包含 400 张灰度 64×64 像素的人脸图像。每个图像被展平为大小为 4,096 的 1D 向量。共有 40 个不同的人被拍摄(每人 10 次),通常的任务是训练一个模型,可以预测每张图片中代表的是哪个人。使用sklearn.datasets.fetch_olivetti_faces()函数加载数据集,然后将其分为训练集、验证集和测试集(注意数据集已经在 0 到 1 之间缩放)。由于数据集相当小,您可能希望使用分层抽样来确保每个集合中每个人的图像数量相同。接下来,使用k-means 对图像进行聚类,并确保有一个良好数量的聚类(使用本章讨论的技术之一)。可视化聚类:您是否在每个聚类中看到相似的面孔?

  11. 继续使用 Olivetti 人脸数据集,训练一个分类器来预测每张图片中代表的是哪个人,并在验证集上评估它。接下来,使用k-means 作为降维工具,并在减少的集合上训练一个分类器。寻找能让分类器获得最佳性能的聚类数量:你能达到什么性能?如果将减少集合的特征附加到原始特征上(再次搜索最佳聚类数量),会怎样?

  12. 在 Olivetti 人脸数据集上训练一个高斯混合模型。为了加快算法速度,您可能需要降低数据集的维度(例如,使用 PCA,保留 99%的方差)。使用模型生成一些新的面孔(使用sample()方法),并可视化它们(如果使用了 PCA,您需要使用其inverse_transform()方法)。尝试修改一些图像(例如旋转、翻转、变暗)并查看模型是否能检测到异常(即,比较正常图像和异常的score_samples()方法的输出)。

  13. 一些降维技术也可以用于异常检测。例如,取 Olivetti 人脸数据集并使用 PCA 进行降维,保留 99%的方差。然后计算每个图像的重建误差。接下来,取出前面练习中构建的一些修改后的图像,并查看它们的重建误差:注意它有多大。如果绘制一个重建图像,你会看到原因:它试图重建一个正常的脸。

这些练习的解决方案可在本章笔记本的末尾找到,网址为https://homl.info/colab3

¹ 斯图尔特·P·劳埃德,“PCM 中的最小二乘量化”,IEEE 信息理论交易 28, no. 2(1982):129–137。

² 大卫·阿瑟和谢尔盖·瓦西利维茨基,“k-Means++: 小心播种的优势”,第 18 届 ACM-SIAM 离散算法研讨会论文集(2007 年):1027–1035。

³ 查尔斯·埃尔坎,“使用三角不等式加速 k 均值”,第 20 届国际机器学习会议论文集(2003 年):147–153。

⁴ 三角不等式是 AC ≤ AB + BC,其中 A、B 和 C 是三个点,AB、AC 和 BC 是这些点之间的距离。

⁵ 大卫·斯卡利,“Web 规模的 K 均值聚类”,第 19 届国际万维网会议论文集(2010 年):1177–1178。

⁶ Phi(ϕφ)是希腊字母表的第 21 个字母。

第二部分:神经网络和深度学习

第十章:使用 Keras 入门人工神经网络

鸟类启发我们飞行,牛蒡植物启发了钩带,自然启发了无数更多的发明。因此,看看大脑的结构以获取如何构建智能机器的灵感似乎是合乎逻辑的。这就是激发人工神经网络(ANNs)的逻辑,这是受到我们大脑中生物神经元网络启发的机器学习模型。然而,尽管飞机受到鸟类的启发,但它们不必拍打翅膀才能飞行。同样,人工神经网络逐渐与其生物表亲有所不同。一些研究人员甚至主张我们应该完全放弃生物类比(例如,使用“单元”而不是“神经元”),以免将我们的创造力限制在生物学上可行的系统中。⁠^(1)

ANNs 是深度学习的核心。它们多才多艺,强大且可扩展,使其成为处理大规模和高度复杂的机器学习任务的理想选择,例如对数十亿张图像进行分类(例如 Google Images),为语音识别服务提供动力(例如苹果的 Siri),每天向数亿用户推荐最佳观看视频(例如 YouTube),或学会击败围棋世界冠军(DeepMind 的 AlphaGo)。

本章的第一部分介绍了人工神经网络,从快速浏览最初的 ANN 架构开始,一直到如今广泛使用的多层感知器(其他架构将在接下来的章节中探讨)。在第二部分中,我们将看看如何使用 TensorFlow 的 Keras API 实现神经网络。这是一个设计精美且简单的高级 API,用于构建、训练、评估和运行神经网络。但不要被它的简单性所迷惑:它足够表达和灵活,可以让您构建各种各样的神经网络架构。实际上,对于大多数用例来说,它可能已经足够了。如果您需要额外的灵活性,您始终可以使用其较低级别的 API 编写自定义 Keras 组件,甚至直接使用 TensorFlow,正如您将在第十二章中看到的。

但首先,让我们回到过去,看看人工神经网络是如何产生的!

从生物到人工神经元

令人惊讶的是,人工神经网络已经存在了相当长的时间:它们最早是由神经生理学家沃伦·麦卡洛克和数学家沃尔特·皮茨于 1943 年首次提出的。在他们的里程碑论文⁠^(2)“神经活动中内在的思想逻辑演算”,麦卡洛克和皮茨提出了一个简化的计算模型,说明了生物神经元如何在动物大脑中共同工作以使用命题逻辑执行复杂计算。这是第一个人工神经网络架构。从那时起,许多其他架构已经被发明,正如您将看到的。

人工神经网络的早期成功导致了人们普遍相信我们很快将与真正智能的机器交谈。当在 1960 年代清楚地意识到这一承诺将无法实现(至少在相当长一段时间内)时,资金转向其他地方,人工神经网络进入了一个漫长的冬天。在 20 世纪 80 年代初,发明了新的架构并开发了更好的训练技术,引发了对连接主义的兴趣复苏,即神经网络的研究。但进展缓慢,到了 20 世纪 90 年代,其他强大的机器学习技术已经被发明出来,例如支持向量机(参见第五章)。这些技术似乎提供了比人工神经网络更好的结果和更强的理论基础,因此神经网络的研究再次被搁置。

我们现在正在目睹对人工神经网络的又一波兴趣。这波潮流会像以前的那些一样消失吗?好吧,以下是一些有理由相信这一次不同的好理由,以及对人工神经网络的重新兴趣将对我们的生活产生更深远影响的原因:

  • 现在有大量的数据可用于训练神经网络,人工神经网络在非常大型和复杂的问题上经常胜过其他机器学习技术。

  • 自 1990 年以来计算能力的巨大增长现在使得在合理的时间内训练大型神经网络成为可能。这在一定程度上归功于摩尔定律(集成电路中的元件数量在过去 50 年里大约每 2 年翻一番),但也要感谢游戏行业,它刺激了数以百万计的强大 GPU 卡的生产。此外,云平台使这种能力对每个人都可获得。

  • 训练算法已经得到改进。公平地说,它们与 1990 年代使用的算法只有略微不同,但这些相对较小的调整产生了巨大的积极影响。

  • 一些人工神经网络的理论限制在实践中被证明是良性的。例如,许多人认为人工神经网络训练算法注定会陷入局部最优解,但事实证明,在实践中这并不是一个大问题,特别是对于更大的神经网络:局部最优解通常表现几乎和全局最优解一样好。

  • 人工神经网络似乎已经进入了资金和进展的良性循环。基于人工神经网络的惊人产品经常成为头条新闻,这吸引了越来越多的关注和资金,导致了越来越多的进展和更多惊人的产品。

生物神经元

在我们讨论人工神经元之前,让我们快速看一下生物神经元(在图 10-1 中表示)。它是一种在动物大脑中大多数发现的不寻常的细胞。它由一个包含细胞核和大多数细胞复杂组分的细胞体组成,许多分支延伸称为树突,以及一个非常长的延伸称为轴突。轴突的长度可能仅比细胞体长几倍,或者长达成千上万倍。在其末端附近,轴突分裂成许多称为末梢的分支,而在这些分支的顶端是微小的结构称为突触终端(或简称突触),它们连接到其他神经元的树突或细胞体。生物神经元产生称为动作电位(APs,或简称信号)的短电脉冲,这些电脉冲沿着轴突传播,并使突触释放称为神经递质的化学信号。当一个神经元在几毫秒内接收到足够量的这些神经递质时,它会发出自己的电脉冲(实际上,这取决于神经递质,因为其中一些会抑制神经元的发放)。

mls3 1001

图 10-1. 一个生物神经元⁠⁴

因此,单个生物神经元似乎表现出简单的方式,但它们组织在一个庞大的网络中,有数十亿个神经元,每个神经元通常连接到成千上万个其他神经元。高度复杂的计算可以通过一个相当简单的神经元网络执行,就像一个复杂的蚁丘可以从简单的蚂蚁的共同努力中出现一样。生物神经网络(BNNs)的架构是活跃研究的主题,但大脑的某些部分已经被绘制出来。这些努力表明,神经元通常组织成连续的层,特别是在大脑的外层皮层(大脑的外层),如图 10-2 所示。

mls3 1002

图 10-2. 生物神经网络中的多个层(人类皮层)⁠⁶

使用神经元进行逻辑计算

McCulloch 和 Pitts 提出了生物神经元的一个非常简单的模型,后来被称为人工神经元:它具有一个或多个二进制(开/关)输入和一个二进制输出。当其输入中的活动超过一定数量时,人工神经元会激活其输出。在他们的论文中,McCulloch 和 Pitts 表明,即使使用这样简化的模型,也可以构建一个可以计算任何您想要的逻辑命题的人工神经元网络。为了了解这样一个网络是如何工作的,让我们构建一些执行各种逻辑计算的人工神经网络(请参见图 10-3),假设当至少两个输入连接处于活动状态时,神经元被激活。

mls3 1003

图 10-3。执行简单逻辑计算的人工神经网络

让我们看看这些网络的作用:

  • 左侧的第一个网络是恒等函数:如果神经元 A 被激活,则神经元 C 也会被激活(因为它从神经元 A 接收到两个输入信号);但如果神经元 A 处于关闭状态,则神经元 C 也会关闭。

  • 第二个网络执行逻辑 AND 操作:只有当神经元 A 和 B 都被激活时,神经元 C 才会被激活(单个输入信号不足以激活神经元 C)。

  • 第三个网络执行逻辑 OR 操作:只有当神经元 A 或神经元 B 被激活(或两者都被激活)时,神经元 C 才会被激活。

  • 最后,如果我们假设一个输入连接可以抑制神经元的活动(这是生物神经元的情况),那么第四个网络将计算一个稍微更复杂的逻辑命题:只有当神经元 A 处于活动状态且神经元 B 处于关闭状态时,神经元 C 才会被激活。如果神经元 A 一直处于活动状态,那么您将得到一个逻辑 NOT:当神经元 B 处于关闭状态时,神经元 C 处于活动状态,反之亦然。

您可以想象这些网络如何组合以计算复杂的逻辑表达式(请参见本章末尾的练习示例)。

感知器

感知器是最简单的人工神经网络架构之一,由 Frank Rosenblatt 于 1957 年发明。它基于一个略有不同的人工神经元(见图 10-4)称为阈值逻辑单元(TLU),有时也称为线性阈值单元(LTU)。输入和输出是数字(而不是二进制的开/关值),每个输入连接都与一个权重相关联。TLU 首先计算其输入的线性函数:z = w[1] x[1] + w[2] x[2] + ⋯ + w[n] x[n] + b = w^⊺ x + b。然后它将结果应用于阶跃函数hw = step(z)。因此,这几乎就像逻辑回归,只是它使用了一个阶跃函数而不是逻辑函数(第四章)。就像在逻辑回归中一样,模型参数是输入权重w和偏置项b

mls3 1004

图 10-4。TLU:计算其输入w^⊺ x的加权和,加上偏置项b,然后应用一个阶跃函数

感知器中最常用的阶跃函数是海维赛德阶跃函数(见方程式 10-1)。有时也会使用符号函数。

方程式 10-1。感知器中常用的阶跃函数(假设阈值=0)

heaviside ( z ) = 0 if z < 0 1 if z 0 sgn ( z ) = - 1 if z < 0 0 if z = 0 + 1 if z > 0

一个单个的 TLU 可以用于简单的线性二元分类。它计算其输入的线性函数,如果结果超过阈值,则输出正类。否则,输出负类。这可能让你想起了逻辑回归(第四章)或线性 SVM 分类(第五章)。例如,你可以使用一个单个的 TLU 基于花瓣长度和宽度对鸢尾花进行分类。训练这样一个 TLU 需要找到正确的w[1]、w[2]和b的值(训练算法将很快讨论)。

一个感知器由一个或多个 TLU 组成,组织在一个单层中,其中每个 TLU 连接到每个输入。这样的一层被称为全连接层密集层。输入构成输入层。由于 TLU 层产生最终输出,因此被称为输出层。例如,一个具有两个输入和三个输出的感知器在图 10-5 中表示。

mls3 1005

图 10-5。具有两个输入和三个输出神经元的感知器的架构

这个感知器可以同时将实例分类为三个不同的二进制类别,这使它成为一个多标签分类器。它也可以用于多类分类。

由于线性代数的魔力,方程 10-2 可以用来高效地计算一层人工神经元对多个实例的输出。

方程 10-2。计算全连接层的输出

h W,b ( X ) = ϕ ( X W + b )

在这个方程中:

  • 如常,X代表输入特征的矩阵。每个实例一行,每个特征一列。

  • 权重矩阵W包含所有的连接权重。它每行对应一个输入,每列对应一个神经元。

  • 偏置向量b包含所有的偏置项:每个神经元一个。

  • 函数ϕ被称为激活函数:当人工神经元是 TLU 时,它是一个阶跃函数(我们将很快讨论其他激活函数)。

注意

在数学中,矩阵和向量的和是未定义的。然而,在数据科学中,我们允许“广播”:将一个向量添加到矩阵中意味着将它添加到矩阵中的每一行。因此,XW + b首先将X乘以W,得到一个每个实例一行、每个输出一列的矩阵,然后将向量b添加到该矩阵的每一行,这将使每个偏置项添加到相应的输出中,对每个实例都是如此。此外,ϕ然后逐项应用于结果矩阵中的每个项目。

那么,感知器是如何训练的呢?Rosenblatt 提出的感知器训练算法在很大程度上受到Hebb 规则的启发。在他 1949 年的书《行为的组织》(Wiley)中,Donald Hebb 建议,当一个生物神经元经常触发另一个神经元时,这两个神经元之间的连接会变得更加强大。 Siegrid Löwel 后来用引人注目的短语总结了 Hebb 的想法,“一起激活的细胞,一起连接”;也就是说,当两个神经元同时激活时,它们之间的连接权重倾向于增加。这个规则后来被称为 Hebb 规则(或Hebbian 学习)。感知器使用这个规则的变体进行训练,该规则考虑了网络在进行预测时所产生的错误;感知器学习规则加强了有助于减少错误的连接。更具体地说,感知器一次馈送一个训练实例,并为每个实例进行预测。对于每个产生错误预测的输出神经元,它加强了从输入到正确预测的贡献的连接权重。该规则显示在方程 10-3 中。

方程 10-3。感知器学习规则(权重更新)

w i,j (nextstep) = w i,j + η ( y j - y ^ j ) x i

在这个方程中:

  • w[i,] [j]是第i个输入和第j个神经元之间的连接权重。

  • x[i]是当前训练实例的第i个输入值。

  • y^j是当前训练实例的第j个输出神经元的输出。

  • y[j]是当前训练实例的第j个输出神经元的目标输出。

  • η是学习率(参见第四章)。

每个输出神经元的决策边界是线性的,因此感知器无法学习复杂的模式(就像逻辑回归分类器一样)。然而,如果训练实例是线性可分的,Rosenblatt 证明了这个算法会收敛到一个解决方案。这被称为感知器收敛定理

Scikit-Learn 提供了一个Perceptron类,可以像你期望的那样使用,例如在鸢尾花数据集上(在第四章介绍)。

import numpy as np
from sklearn.datasets import load_iris
from sklearn.linear_model import Perceptron

iris = load_iris(as_frame=True)
X = iris.data[["petal length (cm)", "petal width (cm)"]].values
y = (iris.target == 0)  # Iris setosa

per_clf = Perceptron(random_state=42)
per_clf.fit(X, y)

X_new = [[2, 0.5], [3, 1]]
y_pred = per_clf.predict(X_new)  # predicts True and False for these 2 flowers

您可能已经注意到感知器学习算法与随机梯度下降(在第四章介绍)非常相似。事实上,Scikit-Learn 的Perceptron类等同于使用具有以下超参数的SGDClassifierloss="perceptron"learning_rate="constant"eta0=1(学习率)和penalty=None(无正则化)。

在他们 1969 年的专著感知器中,Marvin Minsky 和 Seymour Papert 强调了感知器的一些严重弱点,特别是它们无法解决一些微不足道的问题(例如异或(XOR)分类问题;请参见图 10-6 的左侧)。这也适用于任何其他线性分类模型(如逻辑回归分类器),但研究人员对感知器寄予了更高的期望,有些人对此感到如此失望,以至于完全放弃了神经网络,转而研究更高级的问题,如逻辑、问题解决和搜索。实际应用的缺乏也没有帮助。

事实证明,通过堆叠多个感知器可以消除一些感知器的限制。结果得到的人工神经网络称为多层感知器(MLP)。MLP 可以解决 XOR 问题,您可以通过计算图 10-6 右侧所代表的 MLP 的输出来验证:对于输入(0, 0)或(1, 1),网络输出为 0,对于输入(0, 1)或(1, 0),它输出为 1。尝试验证这个网络确实解决了 XOR 问题!

mls3 1006

图 10-6. XOR 分类问题及解决该问题的 MLP
注意

与逻辑回归分类器相反,感知器不会输出类概率。这是偏爱逻辑回归而不是感知器的一个原因。此外,感知器默认不使用任何正则化,训练会在训练集上没有更多预测错误时停止,因此该模型通常不会像逻辑回归或线性 SVM 分类器那样泛化得很好。然而,感知器可能训练速度稍快。

多层感知器和反向传播

一个 MLP 由一个输入层、一个或多个称为隐藏层的 TLU 层以及一个称为输出层的 TLU 层组成(请参见图 10-7)。靠近输入层的层通常称为较低层,靠近输出的层通常称为较高层

mls3 1007

图 10-7. 一个具有两个输入、一个包含四个神经元的隐藏层和三个输出神经元的多层感知器的架构
注意

信号只能单向流动(从输入到输出),因此这种架构是前馈神经网络(FNN)的一个例子。

当一个人工神经网络包含深度堆叠的隐藏层时,它被称为深度神经网络(DNN)。深度学习领域研究 DNNs,更一般地,它对包含深度堆叠计算的模型感兴趣。尽管如此,许多人在涉及神经网络时都谈论深度学习(即使是浅层的)。

多年来,研究人员努力寻找一种训练 MLP 的方法,但没有成功。在 1960 年代初,一些研究人员讨论了使用梯度下降来训练神经网络的可能性,但正如我们在第四章中看到的,这需要计算模型参数的梯度与模型误差之间的关系;当时如何有效地处理这样一个包含如此多参数的复杂模型,尤其是使用当时的计算机时,这并不清楚。

然后,在 1970 年,一位名叫 Seppo Linnainmaa 的研究人员在他的硕士论文中介绍了一种自动高效计算所有梯度的技术。这个算法现在被称为反向模式自动微分(或简称反向模式自动微分)。通过网络的两次遍历(一次前向,一次后向),它能够计算神经网络中每个模型参数的误差梯度。换句话说,它可以找出如何调整每个连接权重和每个偏差以减少神经网络的误差。然后可以使用这些梯度执行梯度下降步骤。如果重复这个自动计算梯度和梯度下降步骤的过程,神经网络的误差将逐渐下降,直到最终达到最小值。这种反向模式自动微分和梯度下降的组合现在被称为反向传播(或简称反向传播)。

注意

有各种自动微分技术,各有利弊。反向模式自动微分在要求对具有许多变量(例如连接权重和偏差)和少量输出(例如一个损失)进行微分时非常适用。如果想了解更多关于自动微分的信息,请查看附录 B。

反向传播实际上可以应用于各种计算图,不仅仅是神经网络:事实上,Linnainmaa 的硕士论文并不是关于神经网络的,而是更为普遍。在反向传播开始用于训练神经网络之前,还需要几年时间,但它仍然不是主流。然后,在 1985 年,David Rumelhart、Geoffrey Hinton 和 Ronald Williams 发表了一篇开创性的论文⁠¹⁰,分析了反向传播如何使神经网络学习到有用的内部表示。他们的结果非常令人印象深刻,以至于反向传播很快在该领域中流行起来。如今,它是迄今为止最受欢迎的神经网络训练技术。

让我们再详细介绍一下反向传播的工作原理:

  • 它一次处理一个小批量(例如,每个包含 32 个实例),并多次遍历整个训练集。每次遍历称为纪元

  • 每个小批量通过输入层进入网络。然后,算法计算小批量中每个实例的第一个隐藏层中所有神经元的输出。结果传递到下一层,计算其输出并传递到下一层,依此类推,直到得到最后一层的输出,即输出层。这是前向传递:它与进行预测完全相同,只是所有中间结果都被保留,因为它们需要用于反向传递。

  • 接下来,算法测量网络的输出误差(即,使用比较期望输出和网络实际输出的损失函数,并返回一些误差度量)。

  • 然后计算每个输出偏差和每个连接到输出层的连接对误差的贡献。这是通过应用链式法则(可能是微积分中最基本的规则)进行分析的,使得这一步骤快速而精确。

  • 然后,算法测量每个下一层中每个连接贡献的误差量,再次使用链式法则,向后工作直到达到输入层。正如前面解释的那样,这个反向传递有效地测量了网络中所有连接权重和偏差的误差梯度,通过网络向后传播误差梯度(因此算法的名称)。

  • 最后,算法执行梯度下降步骤,调整网络中所有连接权重,使用刚刚计算的误差梯度。

警告

重要的是要随机初始化所有隐藏层的连接权重,否则训练将失败。例如,如果你将所有权重和偏置初始化为零,那么给定层中的所有神经元将完全相同,因此反向传播将以完全相同的方式影响它们,因此它们将保持相同。换句话说,尽管每层有数百个神经元,但你的模型将表现得好像每层只有一个神经元:它不会太聪明。相反,如果你随机初始化权重,你会打破对称,并允许反向传播训练一个多样化的神经元团队。

简而言之,反向传播对一个小批量进行预测(前向传播),测量误差,然后逆向遍历每一层以测量每个参数的误差贡献(反向传播),最后调整连接权重和偏置以减少误差(梯度下降步骤)。

为了使反向传播正常工作,Rumelhart 和他的同事对 MLP 的架构进行了关键更改:他们用逻辑函数替换了阶跃函数,σ(z) = 1 / (1 + exp(–z)),也称为 S 形函数。这是必不可少的,因为阶跃函数只包含平坦段,因此没有梯度可用(梯度下降无法在平坦表面上移动),而 S 形函数在任何地方都有明确定义的非零导数,允许梯度下降在每一步都取得一些进展。事实上,反向传播算法与许多其他激活函数一起工作得很好,不仅仅是 S 形函数。这里有另外两个流行的选择:

双曲正切函数:tanh(z) = 2σ(2z) – 1

就像 S 形函数一样,这个激活函数是S形的,连续的,可微的,但其输出值范围是-1 到 1(而不是 S 形函数的 0 到 1)。这个范围倾向于使每一层的输出在训练开始时更多或更少地集中在 0 附近,这通常有助于加快收敛速度。

修正线性单元函数:ReLU(z) = max(0, z)

ReLU 函数在z = 0 处不可微(斜率突然变化,可能导致梯度下降跳动),其导数在z < 0 时为 0。然而,在实践中,它工作得很好,并且计算速度快,因此已经成为默认选择。重要的是,它没有最大输出值有助于减少梯度下降过程中的一些问题(我们将在第十一章中回到这个问题)。

这些流行的激活函数及其导数在图 10-8 中表示。但等等!为什么我们需要激活函数呢?如果你串联几个线性变换,你得到的只是一个线性变换。例如,如果 f(x) = 2x + 3,g(x) = 5x – 1,那么串联这两个线性函数会给你另一个线性函数:f(g(x)) = 2(5x – 1) + 3 = 10x + 1。因此,如果在层之间没有一些非线性,那么即使是深层堆叠也等效于单层,你无法用它解决非常复杂的问题。相反,具有非线性激活的足够大的 DNN 在理论上可以逼近任何连续函数。

mls3 1008

图 10-8。激活函数(左)及其导数(右)

好了!你知道神经网络是从哪里来的,它们的架构是什么,以及如何计算它们的输出。你也学到了反向传播算法。但神经网络到底能做什么呢?

回归 MLP

首先,MLP 可以用于回归任务。如果要预测单个值(例如,给定房屋的许多特征,预测房屋的价格),则只需一个输出神经元:其输出是预测值。对于多变量回归(即一次预测多个值),您需要每个输出维度一个输出神经元。例如,要在图像中定位对象的中心,您需要预测 2D 坐标,因此需要两个输出神经元。如果还想在对象周围放置一个边界框,则需要另外两个数字:对象的宽度和高度。因此,您最终会得到四个输出神经元。

Scikit-Learn 包括一个MLPRegressor类,让我们使用它来构建一个 MLP,其中包含三个隐藏层,每个隐藏层由 50 个神经元组成,并在加利福尼亚房屋数据集上进行训练。为简单起见,我们将使用 Scikit-Learn 的fetch_california_housing()函数来加载数据。这个数据集比我们在第二章中使用的数据集简单,因为它只包含数值特征(没有ocean_proximity特征),并且没有缺失值。以下代码首先获取并拆分数据集,然后创建一个管道来标准化输入特征,然后将它们发送到MLPRegressor。这对于神经网络非常重要,因为它们是使用梯度下降进行训练的,正如我们在第四章中看到的,当特征具有非常不同的尺度时,梯度下降不会收敛得很好。最后,代码训练模型并评估其验证错误。该模型在隐藏层中使用 ReLU 激活函数,并使用一种称为Adam的梯度下降变体(参见第十一章)来最小化均方误差,还有一点ℓ[2]正则化(您可以通过alpha超参数来控制):

from sklearn.datasets import fetch_california_housing
from sklearn.metrics import mean_squared_error
from sklearn.model_selection import train_test_split
from sklearn.neural_network import MLPRegressor
from sklearn.pipeline import make_pipeline
from sklearn.preprocessing import StandardScaler

housing = fetch_california_housing()
X_train_full, X_test, y_train_full, y_test = train_test_split(
    housing.data, housing.target, random_state=42)
X_train, X_valid, y_train, y_valid = train_test_split(
    X_train_full, y_train_full, random_state=42)

mlp_reg = MLPRegressor(hidden_layer_sizes=[50, 50, 50], random_state=42)
pipeline = make_pipeline(StandardScaler(), mlp_reg)
pipeline.fit(X_train, y_train)
y_pred = pipeline.predict(X_valid)
rmse = mean_squared_error(y_valid, y_pred, squared=False)  # about 0.505

我们得到了约 0.505 的验证 RMSE,这与使用随机森林分类器得到的结果相当。对于第一次尝试来说,这还不错!

请注意,此 MLP 不使用任何激活函数用于输出层,因此可以自由输出任何值。这通常没问题,但是如果要确保输出始终为正值,则应在输出层中使用 ReLU 激活函数,或者使用softplus激活函数,它是 ReLU 的平滑变体:softplus(z) = log(1 + exp(z))。当z为负时,softplus 接近 0,当z为正时,softplus 接近z。最后,如果要确保预测始终落在给定值范围内,则应使用 sigmoid 函数或双曲正切,并将目标缩放到适当的范围:sigmoid 为 0 到 1,tanh 为-1 到 1。遗憾的是,MLPRegressor类不支持输出层中的激活函数。

警告

在几行代码中使用 Scikit-Learn 构建和训练标准 MLP 非常方便,但神经网络的功能有限。这就是为什么我们将在本章的第二部分切换到 Keras 的原因。

MLPRegressor类使用均方误差,这通常是回归任务中想要的,但是如果训练集中有很多异常值,您可能更喜欢使用平均绝对误差。或者,您可能希望使用Huber 损失,它是两者的组合。当误差小于阈值δ(通常为 1)时,它是二次的,但是当误差大于δ时,它是线性的。线性部分使其对异常值不太敏感,而二次部分使其比平均绝对误差更快收敛并更精确。但是,MLPRegressor只支持 MSE。

表 10-1 总结了回归 MLP 的典型架构。

表 10-1. 典型的回归 MLP 架构

超参数 典型值
#隐藏层 取决于问题,但通常为 1 到 5
#每个隐藏层的神经元数 取决于问题,但通常为 10 到 100
#输出神经元 每个预测维度 1 个
隐藏激活 ReLU
输出激活 无,或 ReLU/softplus(如果是正输出)或 sigmoid/tanh(如果是有界输出)
损失函数 MSE,或者如果有异常值则为 Huber

分类 MLP

MLP 也可以用于分类任务。对于二元分类问题,您只需要一个使用 sigmoid 激活函数的输出神经元:输出将是 0 到 1 之间的数字,您可以将其解释为正类的估计概率。负类的估计概率等于 1 减去该数字。

MLP 也可以轻松处理多标签二元分类任务(参见第三章)。例如,您可以有一个电子邮件分类系统,预测每封传入的电子邮件是垃圾邮件还是正常邮件,并同时预测它是紧急还是非紧急邮件。在这种情况下,您需要两个输出神经元,都使用 sigmoid 激活函数:第一个将输出电子邮件是垃圾邮件的概率,第二个将输出它是紧急邮件的概率。更一般地,您将为每个正类分配一个输出神经元。请注意,输出概率不一定相加为 1。这使模型可以输出任何标签组合:您可以有非紧急的正常邮件、紧急的正常邮件、非紧急的垃圾邮件,甚至可能是紧急的垃圾邮件(尽管那可能是一个错误)。

如果每个实例只能属于一个类别,且有三个或更多可能的类别(例如,数字图像分类中的类别 0 到 9),那么您需要每个类别一个输出神经元,并且应该为整个输出层使用 softmax 激活函数(参见图 10-9)。Softmax 函数(在第四章介绍)将确保所有估计的概率在 0 和 1 之间,并且它们相加为 1,因为类别是互斥的。正如您在第三章中看到的,这被称为多类分类。

关于损失函数,由于我们正在预测概率分布,交叉熵损失(或x-熵或简称对数损失,参见第四章)通常是一个不错的选择。

mls3 1009

图 10-9。用于分类的现代 MLP(包括 ReLU 和 softmax)

Scikit-Learn 在sklearn.neural_network包中有一个MLPClassifier类。它几乎与MLPRegressor类相同,只是它最小化交叉熵而不是均方误差。现在尝试一下,例如在鸢尾花数据集上。这几乎是一个线性任务,因此一个具有 5 到 10 个神经元的单层应该足够(确保对特征进行缩放)。

表 10-2 总结了分类 MLP 的典型架构。

表 10-2。典型的分类 MLP 架构

超参数 二元分类 多标签二元分类 多类分类
#隐藏层 通常为 1 到 5 层,取决于任务
#输出神经元 1 每个二元标签 1 个 每个类别 1 个
输出层激活 Sigmoid Sigmoid Softmax
损失函数 X-熵 X-熵 X-熵
提示

在继续之前,我建议您完成本章末尾的练习 1。您将尝试各种神经网络架构,并使用TensorFlow playground可视化它们的输出。这将非常有助于更好地理解 MLP,包括所有超参数(层数和神经元数量、激活函数等)的影响。

现在您已经掌握了开始使用 Keras 实现 MLP 所需的所有概念!

使用 Keras 实现 MLP

Keras 是 TensorFlow 的高级深度学习 API:它允许您构建、训练、评估和执行各种神经网络。最初,Keras 库是由 François Chollet 作为研究项目的一部分开发的⁠¹²,并于 2015 年 3 月作为一个独立的开源项目发布。由于其易用性、灵活性和美观的设计,它很快就受到了欢迎。

注意

Keras 曾支持多个后端,包括 TensorFlow、PlaidML、Theano 和 Microsoft Cognitive Toolkit(CNTK)(最后两个遗憾地已弃用),但自版本 2.4 以来,Keras 仅支持 TensorFlow。同样,TensorFlow 曾包括多个高级 API,但在 TensorFlow 2 发布时,Keras 被正式选择为其首选的高级 API。安装 TensorFlow 将自动安装 Keras,并且没有安装 TensorFlow,Keras 将无法工作。简而言之,Keras 和 TensorFlow 相爱并结为夫妻。其他流行的深度学习库包括Facebook 的 PyTorchGoogle 的 JAX。¹³

现在让我们使用 Keras!我们将首先构建一个用于图像分类的 MLP。

注意

Colab 运行时已预装了最新版本的 TensorFlow 和 Keras。但是,如果您想在自己的机器上安装它们,请参阅https://homl.info/install上的安装说明。

使用顺序 API 构建图像分类器

首先,我们需要加载一个数据集。我们将使用时尚 MNIST,它是 MNIST 的一个替代品(在第三章介绍)。它与 MNIST 具有完全相同的格式(70,000 个 28×28 像素的灰度图像,共 10 个类),但图像代表时尚物品而不是手写数字,因此每个类更加多样化,问题变得比 MNIST 更具挑战性。例如,一个简单的线性模型在 MNIST 上达到约 92%的准确率,但在时尚 MNIST 上只有约 83%。

使用 Keras 加载数据集

Keras 提供了一些实用函数来获取和加载常见数据集,包括 MNIST、时尚 MNIST 等。让我们加载时尚 MNIST。它已经被洗牌并分成一个训练集(60,000 张图片)和一个测试集(10,000 张图片),但我们将从训练集中保留最后的 5,000 张图片用于验证:

import tensorflow as tf

fashion_mnist = tf.keras.datasets.fashion_mnist.load_data()
(X_train_full, y_train_full), (X_test, y_test) = fashion_mnist
X_train, y_train = X_train_full[:-5000], y_train_full[:-5000]
X_valid, y_valid = X_train_full[-5000:], y_train_full[-5000:]
提示

TensorFlow 通常被导入为tf,Keras API 可通过tf.keras使用。

使用 Keras 加载 MNIST 或时尚 MNIST 时,与 Scikit-Learn 相比的一个重要区别是,每个图像都表示为一个 28×28 的数组,而不是大小为 784 的一维数组。此外,像素强度表示为整数(从 0 到 255),而不是浮点数(从 0.0 到 255.0)。让我们看看训练集的形状和数据类型:

>>> X_train.shape
(55000, 28, 28)
>>> X_train.dtype
dtype('uint8')

为简单起见,我们将通过将它们除以 255.0 来将像素强度缩放到 0-1 范围(这也将它们转换为浮点数):

X_train, X_valid, X_test = X_train / 255., X_valid / 255., X_test / 255.

对于 MNIST,当标签等于 5 时,这意味着图像代表手写数字 5。简单。然而,对于时尚 MNIST,我们需要类名列表以了解我们正在处理的内容:

class_names = ["T-shirt/top", "Trouser", "Pullover", "Dress", "Coat",
               "Sandal", "Shirt", "Sneaker", "Bag", "Ankle boot"]

例如,训练集中的第一张图像代表一个踝靴:

>>> class_names[y_train[0]]
'Ankle boot'

图 10-10 显示了时尚 MNIST 数据集的一些样本。

mls3 1010

图 10-10。时尚 MNIST 的样本

使用顺序 API 创建模型

现在让我们构建神经网络!这是一个具有两个隐藏层的分类 MLP:

tf.random.set_seed(42)
model = tf.keras.Sequential()
model.add(tf.keras.layers.Input(shape=[28, 28]))
model.add(tf.keras.layers.Flatten())
model.add(tf.keras.layers.Dense(300, activation="relu"))
model.add(tf.keras.layers.Dense(100, activation="relu"))
model.add(tf.keras.layers.Dense(10, activation="softmax"))

让我们逐行查看这段代码:

  • 首先,设置 TensorFlow 的随机种子以使结果可重现:每次运行笔记本时,隐藏层和输出层的随机权重将保持相同。您还可以选择使用 tf.keras.utils.set_random_seed() 函数,它方便地为 TensorFlow、Python (random.seed()) 和 NumPy (np.random.seed()) 设置随机种子。

  • 下一行创建一个 Sequential 模型。这是 Keras 模型中最简单的一种,用于仅由一系列按顺序连接的层组成的神经网络。这被称为顺序 API。

  • 接下来,我们构建第一层(一个 Input 层)并将其添加到模型中。我们指定输入的 shape,它不包括批量大小,只包括实例的形状。Keras 需要知道输入的形状,以便确定第一个隐藏层的连接权重矩阵的形状。

  • 然后我们添加一个 Flatten 层。它的作用是将每个输入图像转换为 1D 数组:例如,如果它接收到一个形状为 [32, 28, 28] 的批量,它将将其重塑为 [32, 784]。换句话说,如果它接收到输入数据 X,它会计算 X.reshape(-1, 784)。这个层没有任何参数;它只是用来进行一些简单的预处理。

  • 接下来我们添加一个具有 300 个神经元的 Dense 隐藏层。它将使用 ReLU 激活函数。每个 Dense 层都管理着自己的权重矩阵,其中包含神经元与它们的输入之间的所有连接权重。它还管理着一个偏置项向量(每个神经元一个)。当它接收到一些输入数据时,它会计算 方程 10-2。

  • 然后我们添加一个具有 100 个神经元的第二个 Dense 隐藏层,同样使用 ReLU 激活函数。

  • 最后,我们添加一个具有 10 个神经元(每个类一个)的 Dense 输出层,使用 softmax 激活函数,因为类是互斥的。

提示

指定 activation="relu" 等同于指定 activation=tf.keras.activations.relu。其他激活函数可以在 tf.keras.activations 包中找到。我们将在本书中使用许多这些激活函数;请参阅 https://keras.io/api/layers/activations 获取完整列表。我们还将在 第十二章 中定义我们自己的自定义激活函数。

与刚刚逐个添加层不同,通常更方便的做法是在创建 Sequential 模型时传递一个层列表。您还可以删除 Input 层,而是在第一层中指定 input_shape

model = tf.keras.Sequential([
    tf.keras.layers.Flatten(input_shape=[28, 28]),
    tf.keras.layers.Dense(300, activation="relu"),
    tf.keras.layers.Dense(100, activation="relu"),
    tf.keras.layers.Dense(10, activation="softmax")
])

模型的 summary() 方法显示了所有模型的层,包括每个层的名称(除非在创建层时设置了名称,否则会自动生成),其输出形状(None 表示批量大小可以是任意值),以及其参数数量。摘要以总参数数量结束,包括可训练和不可训练参数。在这里我们只有可训练参数(您将在本章后面看到一些不可训练参数):

>>> model.summary()
Model: "sequential"
_________________________________________________________________
 Layer (type)                Output Shape              Param #
=================================================================
 flatten (Flatten)           (None, 784)               0

 dense (Dense)               (None, 300)               235500

 dense_1 (Dense)             (None, 100)               30100

 dense_2 (Dense)             (None, 10)                1010

=================================================================
Total params: 266,610
Trainable params: 266,610
Non-trainable params: 0
_________________________________________________________________

注意,Dense 层通常具有大量参数。例如,第一个隐藏层有 784 × 300 个连接权重,再加上 300 个偏置项,总共有 235,500 个参数!这使得模型具有相当大的灵活性来拟合训练数据,但也意味着模型有过拟合的风险,特别是当训练数据不多时。我们稍后会回到这个问题。

模型中的每个层必须具有唯一的名称(例如,"dense_2")。您可以使用构造函数的name参数显式设置层名称,但通常最好让 Keras 自动命名层,就像我们刚刚做的那样。Keras 获取层的类名并将其转换为蛇形命名法(例如,MyCoolLayer类的层默认命名为"my_cool_layer")。Keras 还确保名称在全局范围内是唯一的,即使跨模型也是如此,如果需要,会附加索引,例如"dense_2"。但是为什么要确保名称在模型之间是唯一的呢?这样可以轻松合并模型而不会出现名称冲突。

提示

Keras 管理的所有全局状态都存储在Keras 会话中,您可以使用tf.keras.backend.clear_session()清除它。特别是,这将重置名称计数器。

您可以使用layers属性轻松获取模型的层列表,或使用get_layer()方法按名称访问层:

>>> model.layers
[<keras.layers.core.flatten.Flatten at 0x7fa1dea02250>,
 <keras.layers.core.dense.Dense at 0x7fa1c8f42520>,
 <keras.layers.core.dense.Dense at 0x7fa188be7ac0>,
 <keras.layers.core.dense.Dense at 0x7fa188be7fa0>]
>>> hidden1 = model.layers[1]
>>> hidden1.name
'dense'
>>> model.get_layer('dense') is hidden1
True

可以使用其get_weights()set_weights()方法访问层的所有参数。对于Dense层,这包括连接权重和偏差项:

>>> weights, biases = hidden1.get_weights()
>>> weights
array([[ 0.02448617, -0.00877795, -0.02189048, ...,  0.03859074, -0.06889391],
 [ 0.00476504, -0.03105379, -0.0586676 , ..., -0.02763776, -0.04165364],
 ...,
 [ 0.07061854, -0.06960931,  0.07038955, ..., 0.00034875,  0.02878492],
 [-0.06022581,  0.01577859, -0.02585464, ..., 0.00272203, -0.06793761]],
 dtype=float32)
>>> weights.shape
(784, 300)
>>> biases
array([0., 0., 0., 0., 0., 0., 0., 0., 0., ...,  0., 0., 0.], dtype=float32)
>>> biases.shape
(300,)

请注意,Dense层随机初始化连接权重(这是为了打破对称性,如前所述),偏差初始化为零,这是可以的。如果要使用不同的初始化方法,可以在创建层时设置kernel_initializerkernel是连接权重矩阵的另一个名称)或bias_initializer。我们将在第十一章进一步讨论初始化器,完整列表在https://keras.io/api/layers/initializers

注意

权重矩阵的形状取决于输入的数量,这就是为什么在创建模型时我们指定了input_shape。如果您没有指定输入形状,没关系:Keras 会等到知道输入形状后才真正构建模型参数。这将在您提供一些数据(例如,在训练期间)或调用其build()方法时发生。在模型参数构建之前,您将无法执行某些操作,例如显示模型摘要或保存模型。因此,如果在创建模型时知道输入形状,最好指定它。

编译模型

创建模型后,必须调用其compile()方法来指定要使用的损失函数和优化器。可选地,您可以指定在训练和评估过程中计算的额外指标列表:

model.compile(loss="sparse_categorical_crossentropy",
              optimizer="sgd",
              metrics=["accuracy"])
注意

使用loss="sparse_categorical_crossentropy"等同于使用loss=tf.keras.losses.sparse_categorical_​cross⁠entropy。同样,使用optimizer="sgd"等同于使用optimizer=tf.keras.optimizers.SGD(),使用metrics=["accuracy"]等同于使用metrics=​[tf.keras.metrics.sparse_categorical_accuracy](使用此损失时)。在本书中,我们将使用许多其他损失、优化器和指标;有关完整列表,请参见https://keras.io/api/losseshttps://keras.io/api/optimizershttps://keras.io/api/metrics

这段代码需要解释。我们使用"sparse_categorical_crossentropy"损失,因为我们有稀疏标签(即,对于每个实例,只有一个目标类索引,本例中为 0 到 9),并且类是互斥的。如果相反,对于每个实例有一个目标概率类(例如,独热向量,例如,[0., 0., 0., 1., 0., 0., 0., 0., 0., 0.]表示类 3),那么我们需要使用"categorical_crossentropy"损失。如果我们进行二元分类或多标签二元分类,则在输出层中使用"sigmoid"激活函数,而不是"softmax"激活函数,并且我们将使用"binary_crossentropy"损失。

提示

如果你想将稀疏标签(即类别索引)转换为独热向量标签,请使用tf.keras.utils.to_categorical()函数。要反过来,使用带有axis=1np.argmax()函数。

关于优化器,"sgd"表示我们将使用随机梯度下降来训练模型。换句话说,Keras 将执行前面描述的反向传播算法(即反向模式自动微分加梯度下降)。我们将在第十一章中讨论更高效的优化器。它们改进了梯度下降,而不是自动微分。

注意

当使用SGD优化器时,调整学习率是很重要的。因此,通常你会想要使用optimizer=tf.keras.optimizers.SGD(learning_rate=__???__)来设置学习率,而不是optimizer="sgd",后者默认学习率为 0.01。

最后,由于这是一个分类器,所以在训练和评估过程中测量其准确性是有用的,这就是为什么我们设置metrics=["accuracy"]

训练和评估模型

现在模型已经准备好进行训练了。为此,我们只需要调用它的fit()方法:

>>> history = model.fit(X_train, y_train, epochs=30,
...                     validation_data=(X_valid, y_valid))
...
Epoch 1/30
1719/1719 [==============================] - 2s 989us/step
 - loss: 0.7220 - sparse_categorical_accuracy: 0.7649
 - val_loss: 0.4959 - val_sparse_categorical_accuracy: 0.8332
Epoch 2/30
1719/1719 [==============================] - 2s 964us/step
 - loss: 0.4825 - sparse_categorical_accuracy: 0.8332
 - val_loss: 0.4567 - val_sparse_categorical_accuracy: 0.8384
[...]
Epoch 30/30
1719/1719 [==============================] - 2s 963us/step
 - loss: 0.2235 - sparse_categorical_accuracy: 0.9200
 - val_loss: 0.3056 - val_sparse_categorical_accuracy: 0.8894

我们传递输入特征(X_train)和目标类别(y_train),以及训练的时期数量(否则默认为 1,这绝对不足以收敛到一个好的解决方案)。我们还传递一个验证集(这是可选的)。Keras 将在每个时期结束时在这个集合上测量损失和额外的指标,这对于查看模型的实际表现非常有用。如果在训练集上的表现比在验证集上好得多,那么你的模型可能过度拟合训练集,或者存在错误,比如训练集和验证集之间的数据不匹配。

提示

形状错误是非常常见的,特别是在刚开始时,所以你应该熟悉错误消息:尝试用错误形状的输入和/或标签拟合模型,看看你得到的错误。同样,尝试用loss="categorical_crossentropy"而不是loss="sparse_categorical_crossentropy"来编译模型。或者你可以移除Flatten层。

就是这样!神经网络已经训练好了。在训练过程中的每个时期,Keras 会在进度条的左侧显示迄今为止处理的小批量数量。批量大小默认为 32,由于训练集有 55,000 张图像,模型每个时期会经过 1,719 个批次:1,718 个大小为 32,1 个大小为 24。在进度条之后,你可以看到每个样本的平均训练时间,以及训练集和验证集上的损失和准确性(或者你要求的任何其他额外指标)。请注意,训练损失下降了,这是一个好迹象,验证准确性在 30 个时期后达到了 88.94%。这略低于训练准确性,所以有一点过拟合,但不是很严重。

提示

不要使用validation_data参数传递验证集,你可以将validation_split设置为你希望 Keras 用于验证的训练集比例。例如,validation_split=0.1告诉 Keras 使用数据的最后 10%(在洗牌之前)作为验证集。

如果训练集非常倾斜,某些类别过度表示,而其他类别则表示不足,那么在调用 fit() 方法时设置 class_weight 参数会很有用,以给予少数类别更大的权重,而给予多数类别更小的权重。这些权重将在计算损失时由 Keras 使用。如果需要每个实例的权重,可以设置 sample_weight 参数。如果同时提供了 class_weightsample_weight,那么 Keras 会将它们相乘。每个实例的权重可能很有用,例如,如果一些实例由专家标记,而其他实例使用众包平台标记:你可能希望给前者更多的权重。您还可以为验证集提供样本权重(但不是类别权重),方法是将它们作为 validation_data 元组的第三个项目添加。

fit() 方法返回一个 History 对象,其中包含训练参数 (history.params)、经历的每个 epoch 的列表 (history.epoch),最重要的是一个字典 (history.history),其中包含每个 epoch 结束时在训练集和验证集(如果有的话)上测量的损失和额外指标。如果使用这个字典创建一个 Pandas DataFrame,并调用它的 plot() 方法,就可以得到 Figure 10-11 中显示的学习曲线:

import matplotlib.pyplot as plt
import pandas as pd

pd.DataFrame(history.history).plot(
    figsize=(8, 5), xlim=[0, 29], ylim=[0, 1], grid=True, xlabel="Epoch",
    style=["r--", "r--.", "b-", "b-*"])
plt.show()

mls3 1011

图 10-11. 学习曲线:每个 epoch 结束时测量的平均训练损失和准确率,以及每个 epoch 结束时测量的平均验证损失和准确率

您可以看到,在训练过程中,训练准确率和验证准确率都在稳步增加,而训练损失和验证损失都在减少。这是好的。验证曲线在开始时相对接近,但随着时间的推移,它们之间的差距变得更大,这表明存在一些过拟合。在这种特殊情况下,模型在训练开始阶段在验证集上的表现似乎比在训练集上好,但实际情况并非如此。验证错误是在 每个 epoch 结束时计算的,而训练错误是在 每个 epoch 期间 使用运行平均值计算的,因此训练曲线应该向左移动半个 epoch。如果这样做,您会看到在训练开始阶段,训练和验证曲线几乎完美重合。

训练集的性能最终会超过验证集的性能,这通常是在训练足够长时间后的情况。你可以看出模型还没有完全收敛,因为验证损失仍在下降,所以你可能应该继续训练。只需再次调用 fit() 方法,因为 Keras 会从离开的地方继续训练:你应该能够达到约 89.8% 的验证准确率,而训练准确率将继续上升到 100%(这并不总是情况)。

如果你对模型的性能不满意,你应该回去调整超参数。首先要检查的是学习率。如果这没有帮助,尝试另一个优化器(并在更改任何超参数后重新调整学习率)。如果性能仍然不理想,那么尝试调整模型超参数,如层数、每层神经元的数量以及每个隐藏层要使用的激活函数类型。你也可以尝试调整其他超参数,比如批量大小(可以在fit()方法中使用batch_size参数设置,默认为 32)。我们将在本章末回到超参数调整。一旦你对模型的验证准确率感到满意,你应该在部署模型到生产环境之前在测试集上评估它以估计泛化误差。你可以使用evaluate()方法轻松实现这一点(它还支持其他几个参数,如batch_sizesample_weight;请查看文档以获取更多详细信息):

>>> model.evaluate(X_test, y_test)
313/313 [==============================] - 0s 626us/step
 - loss: 0.3243 - sparse_categorical_accuracy: 0.8864
[0.32431697845458984, 0.8863999843597412]

正如你在第二章中看到的,通常在测试集上的性能会略低于验证集,因为超参数是在验证集上调整的,而不是在测试集上(然而,在这个例子中,我们没有进行任何超参数调整,所以较低的准确率只是运气不佳)。记住要抵制在测试集上调整超参数的诱惑,否则你对泛化误差的估计将会过于乐观。

使用模型进行预测

现在让我们使用模型的predict()方法对新实例进行预测。由于我们没有实际的新实例,我们将只使用测试集的前三个实例:

>>> X_new = X_test[:3]
>>> y_proba = model.predict(X_new)
>>> y_proba.round(2)
array([[0\.  , 0\.  , 0\.  , 0\.  , 0\.  , 0.01, 0\.  , 0.02, 0\.  , 0.97],
 [0\.  , 0\.  , 0.99, 0\.  , 0.01, 0\.  , 0\.  , 0\.  , 0\.  , 0\.  ],
 [0\.  , 1\.  , 0\.  , 0\.  , 0\.  , 0\.  , 0\.  , 0\.  , 0\.  , 0\.  ]],
 dtype=float32)

对于每个实例,模型会为每个类别(从类别 0 到类别 9)估计一个概率。这类似于 Scikit-Learn 分类器中predict_proba()方法的输出。例如,对于第一幅图像,它估计类别 9(踝靴)的概率为 96%,类别 7(运动鞋)的概率为 2%,类别 5(凉鞋)的概率为 1%,其他类别的概率可以忽略不计。换句话说,它非常确信第一幅图像是鞋类,很可能是踝靴,但也可能是运动鞋或凉鞋。如果你只关心估计概率最高的类别(即使概率很低),那么你可以使用argmax()方法来获取每个实例的最高概率类别索引:

>>> import numpy as np
>>> y_pred = y_proba.argmax(axis=-1)
>>> y_pred
array([9, 2, 1])
>>> np.array(class_names)[y_pred]
array(['Ankle boot', 'Pullover', 'Trouser'], dtype='<U11')

在这里,分类器实际上正确分类了所有三幅图像(这些图像显示在图 10-12 中):

>>> y_new = y_test[:3]
>>> y_new
array([9, 2, 1], dtype=uint8)

mls3 1012

图 10-12。正确分类的时尚 MNIST 图像

现在你知道如何使用 Sequential API 构建、训练和评估分类 MLP 了。但是回归呢?

使用 Sequential API 构建回归 MLP

让我们回到加利福尼亚房屋问题,并使用与之前相同的 MLP,由 3 个每层 50 个神经元组成的隐藏层,但这次使用 Keras 构建它。

使用顺序 API 构建、训练、评估和使用回归 MLP 与分类问题的操作非常相似。以下代码示例中的主要区别在于输出层只有一个神经元(因为我们只想预测一个值),并且没有使用激活函数,损失函数是均方误差,度量标准是 RMSE,我们使用了像 Scikit-Learn 的MLPRegressor一样的 Adam 优化器。此外,在这个例子中,我们不需要Flatten层,而是使用Normalization层作为第一层:它执行的操作与 Scikit-Learn 的StandardScaler相同,但必须使用其adapt()方法拟合训练数据之前调用模型的fit()方法。 (Keras 还有其他预处理层,将在第十三章中介绍)。让我们来看一下:

tf.random.set_seed(42)
norm_layer = tf.keras.layers.Normalization(input_shape=X_train.shape[1:])
model = tf.keras.Sequential([
    norm_layer,
    tf.keras.layers.Dense(50, activation="relu"),
    tf.keras.layers.Dense(50, activation="relu"),
    tf.keras.layers.Dense(50, activation="relu"),
    tf.keras.layers.Dense(1)
])
optimizer = tf.keras.optimizers.Adam(learning_rate=1e-3)
model.compile(loss="mse", optimizer=optimizer, metrics=["RootMeanSquaredError"])
norm_layer.adapt(X_train)
history = model.fit(X_train, y_train, epochs=20,
                    validation_data=(X_valid, y_valid))
mse_test, rmse_test = model.evaluate(X_test, y_test)
X_new = X_test[:3]
y_pred = model.predict(X_new)
注意

当您调用adapt()方法时,Normalization层会学习训练数据中的特征均值和标准差。然而,当您显示模型的摘要时,这些统计数据被列为不可训练的。这是因为这些参数不受梯度下降的影响。

正如您所看到的,顺序 API 非常清晰和简单。然而,虽然Sequential模型非常常见,但有时构建具有更复杂拓扑结构或多个输入或输出的神经网络是很有用的。为此,Keras 提供了功能 API。

使用功能 API 构建复杂模型

非顺序神经网络的一个例子是Wide & Deep神经网络。这种神经网络架构是由 Heng-Tze Cheng 等人在 2016 年的一篇论文中介绍的。它直接连接所有或部分输入到输出层,如图 10-13 所示。这种架构使得神经网络能够学习深层模式(使用深层路径)和简单规则(通过短路径)。相比之下,常规的 MLP 强制所有数据通过完整的层堆栈流动;因此,数据中的简单模式可能会被这一系列转换所扭曲。

mls3 1013

图 10-13。Wide & Deep 神经网络

让我们构建这样一个神经网络来解决加利福尼亚房屋问题:

normalization_layer = tf.keras.layers.Normalization()
hidden_layer1 = tf.keras.layers.Dense(30, activation="relu")
hidden_layer2 = tf.keras.layers.Dense(30, activation="relu")
concat_layer = tf.keras.layers.Concatenate()
output_layer = tf.keras.layers.Dense(1)

input_ = tf.keras.layers.Input(shape=X_train.shape[1:])
normalized = normalization_layer(input_)
hidden1 = hidden_layer1(normalized)
hidden2 = hidden_layer2(hidden1)
concat = concat_layer([normalized, hidden2])
output = output_layer(concat)

model = tf.keras.Model(inputs=[input_], outputs=[output])

在高层次上,前五行创建了构建模型所需的所有层,接下来的六行使用这些层就像函数一样从输入到输出,最后一行通过指向输入和输出创建了一个 Keras Model对象。让我们更详细地看一下这段代码:

  • 首先,我们创建五个层:一个Normalization层用于标准化输入,两个具有 30 个神经元的Dense层,使用 ReLU 激活函数,一个Concatenate层,以及一个没有任何激活函数的单个神经元的输出层的Dense层。

  • 接下来,我们创建一个Input对象(变量名input_用于避免遮蔽 Python 内置的input()函数)。这是模型将接收的输入类型的规范,包括其shape和可选的dtype,默认为 32 位浮点数。一个模型实际上可能有多个输入,您很快就会看到。

  • 然后,我们像使用函数一样使用Normalization层,将其传递给Input对象。这就是为什么这被称为功能 API。请注意,我们只是告诉 Keras 应该如何连接这些层;实际上还没有处理任何数据,因为Input对象只是一个数据规范。换句话说,它是一个符号输入。这个调用的输出也是符号的:normalized不存储任何实际数据,它只是用来构建模型。

  • 同样,我们将normalized传递给hidden_layer1,输出hidden1,然后将hidden1传递给hidden_layer2,输出hidden2

  • 到目前为止,我们已经按顺序连接了层,然后使用concat_layer将输入和第二个隐藏层的输出连接起来。再次强调,实际数据尚未连接:这都是符号化的,用于构建模型。

  • 然后我们将concat传递给output_layer,这给我们最终的output

  • 最后,我们创建一个 KerasModel,指定要使用的输入和输出。

构建了这个 Keras 模型之后,一切都和之前一样,所以这里不需要重复:编译模型,调整Normalization层,拟合模型,评估模型,并用它进行预测。

但是,如果您想通过宽路径发送一部分特征,并通过深路径发送另一部分特征(可能有重叠),如图 10-14 所示呢?在这种情况下,一个解决方案是使用多个输入。例如,假设我们想通过宽路径发送五个特征(特征 0 到 4),并通过深路径发送六个特征(特征 2 到 7)。我们可以这样做:

input_wide = tf.keras.layers.Input(shape=[5])  # features 0 to 4
input_deep = tf.keras.layers.Input(shape=[6])  # features 2 to 7
norm_layer_wide = tf.keras.layers.Normalization()
norm_layer_deep = tf.keras.layers.Normalization()
norm_wide = norm_layer_wide(input_wide)
norm_deep = norm_layer_deep(input_deep)
hidden1 = tf.keras.layers.Dense(30, activation="relu")(norm_deep)
hidden2 = tf.keras.layers.Dense(30, activation="relu")(hidden1)
concat = tf.keras.layers.concatenate([norm_wide, hidden2])
output = tf.keras.layers.Dense(1)(concat)
model = tf.keras.Model(inputs=[input_wide, input_deep], outputs=[output])

mls3 1014

图 10-14。处理多个输入

在这个例子中,与之前的例子相比,有几点需要注意:

  • 每个Dense层都是在同一行上创建并调用的。这是一种常见的做法,因为它使代码更简洁而不失清晰度。但是,我们不能对Normalization层这样做,因为我们需要对该层进行引用,以便在拟合模型之前调用其adapt()方法。

  • 我们使用了tf.keras.layers.concatenate(),它创建了一个Concatenate层,并使用给定的输入调用它。

  • 在创建模型时,我们指定了inputs=[input_wide, input_deep],因为有两个输入。

现在我们可以像往常一样编译模型,但是在调用fit()方法时,不是传递单个输入矩阵X_train,而是必须传递一对矩阵(X_train_wide, X_train_deep),每个输入一个。对于X_valid,以及在调用evaluate()predict()时的X_testX_new也是如此:

optimizer = tf.keras.optimizers.Adam(learning_rate=1e-3)
model.compile(loss="mse", optimizer=optimizer, metrics=["RootMeanSquaredError"])

X_train_wide, X_train_deep = X_train[:, :5], X_train[:, 2:]
X_valid_wide, X_valid_deep = X_valid[:, :5], X_valid[:, 2:]
X_test_wide, X_test_deep = X_test[:, :5], X_test[:, 2:]
X_new_wide, X_new_deep = X_test_wide[:3], X_test_deep[:3]

norm_layer_wide.adapt(X_train_wide)
norm_layer_deep.adapt(X_train_deep)
history = model.fit((X_train_wide, X_train_deep), y_train, epochs=20,
                    validation_data=((X_valid_wide, X_valid_deep), y_valid))
mse_test = model.evaluate((X_test_wide, X_test_deep), y_test)
y_pred = model.predict((X_new_wide, X_new_deep))
提示

您可以传递一个字典{"input_wide": X_train_wide, "input_deep": X_train_deep},而不是传递一个元组(X_train_wide, X_train_deep),如果在创建输入时设置了name="input_wide"name="input_deep"。当有多个输入时,这是非常推荐的,可以澄清代码并避免顺序错误。

还有许多用例需要多个输出:

  • 任务可能需要这样做。例如,您可能希望在图片中定位和分类主要对象。这既是一个回归任务,也是一个分类任务。

  • 同样,您可能有基于相同数据的多个独立任务。当然,您可以为每个任务训练一个神经网络,但在许多情况下,通过训练一个单一神经网络,每个任务一个输出,您将在所有任务上获得更好的结果。这是因为神经网络可以学习数据中对所有任务都有用的特征。例如,您可以对面部图片执行多任务分类,使用一个输出来对人的面部表情(微笑,惊讶等)进行分类,另一个输出用于识别他们是否戴眼镜。

  • 另一个用例是作为正则化技术(即,一种训练约束,其目标是减少过拟合,从而提高模型的泛化能力)。例如,您可能希望在神经网络架构中添加一个辅助输出(参见图 10-15),以确保网络的基础部分自己学到一些有用的东西,而不依赖于网络的其余部分。

mls3 1015

图 10-15。处理多个输出,在这个例子中添加一个辅助输出进行正则化

添加额外的输出非常容易:我们只需将其连接到适当的层并将其添加到模型的输出列表中。例如,以下代码构建了图 10-15 中表示的网络:

[...]  # Same as above, up to the main output layer
output = tf.keras.layers.Dense(1)(concat)
aux_output = tf.keras.layers.Dense(1)(hidden2)
model = tf.keras.Model(inputs=[input_wide, input_deep],
                       outputs=[output, aux_output])

每个输出都需要自己的损失函数。因此,当我们编译模型时,应该传递一个损失列表。如果我们传递一个单一损失,Keras 将假定所有输出都必须使用相同的损失。默认情况下,Keras 将计算所有损失并简单地将它们相加以获得用于训练的最终损失。由于我们更关心主要输出而不是辅助输出(因为它仅用于正则化),我们希望给主要输出的损失分配更大的权重。幸运的是,在编译模型时可以设置所有损失权重:

optimizer = tf.keras.optimizers.Adam(learning_rate=1e-3)
model.compile(loss=("mse", "mse"), loss_weights=(0.9, 0.1), optimizer=optimizer,
              metrics=["RootMeanSquaredError"])
提示

您可以传递一个字典loss={"output": "mse", "aux_output": "mse"},而不是传递一个元组loss=("mse", "mse"),假设您使用name="output"name="aux_output"创建了输出层。就像对于输入一样,这样可以澄清代码并避免在有多个输出时出现错误。您还可以为loss_weights传递一个字典。

现在当我们训练模型时,我们需要为每个输出提供标签。在这个例子中,主要输出和辅助输出应该尝试预测相同的事物,因此它们应该使用相同的标签。因此,我们需要传递(y_train, y_train),或者如果输出被命名为"output""aux_output",则传递一个字典{"output": y_train, "aux_output": y_train},而不是传递y_train。对于y_validy_test也是一样的:

norm_layer_wide.adapt(X_train_wide)
norm_layer_deep.adapt(X_train_deep)
history = model.fit(
    (X_train_wide, X_train_deep), (y_train, y_train), epochs=20,
    validation_data=((X_valid_wide, X_valid_deep), (y_valid, y_valid))
)

当我们评估模型时,Keras 会返回损失的加权和,以及所有单独的损失和指标:

eval_results = model.evaluate((X_test_wide, X_test_deep), (y_test, y_test))
weighted_sum_of_losses, main_loss, aux_loss, main_rmse, aux_rmse = eval_results
提示

如果设置return_dict=True,那么evaluate()将返回一个字典而不是一个大元组。

类似地,predict()方法将为每个输出返回预测:

y_pred_main, y_pred_aux = model.predict((X_new_wide, X_new_deep))

predict()方法返回一个元组,并且没有return_dict参数以获得一个字典。但是,您可以使用model.output_names创建一个:

y_pred_tuple = model.predict((X_new_wide, X_new_deep))
y_pred = dict(zip(model.output_names, y_pred_tuple))

正如您所看到的,您可以使用功能 API 构建各种架构。接下来,我们将看一下您可以构建 Keras 模型的最后一种方法。

使用子类 API 构建动态模型

顺序 API 和功能 API 都是声明式的:您首先声明要使用哪些层以及它们应该如何连接,然后才能开始向模型提供一些数据进行训练或推断。这有许多优点:模型可以很容易地被保存、克隆和共享;其结构可以被显示和分析;框架可以推断形状并检查类型,因此可以在任何数据通过模型之前尽早捕获错误。调试也相当简单,因为整个模型是一组静态图层。但是反过来也是如此:它是静态的。一些模型涉及循环、变化的形状、条件分支和其他动态行为。对于这种情况,或者如果您更喜欢更具有命令式编程风格,子类 API 适合您。

使用这种方法,您可以对Model类进行子类化,在构造函数中创建所需的层,并在call()方法中使用它们执行您想要的计算。例如,创建以下WideAndDeepModel类的实例会给我们一个与我们刚刚使用功能 API 构建的模型等效的模型:

class WideAndDeepModel(tf.keras.Model):
    def __init__(self, units=30, activation="relu", **kwargs):
        super().__init__(**kwargs)  # needed to support naming the model
        self.norm_layer_wide = tf.keras.layers.Normalization()
        self.norm_layer_deep = tf.keras.layers.Normalization()
        self.hidden1 = tf.keras.layers.Dense(units, activation=activation)
        self.hidden2 = tf.keras.layers.Dense(units, activation=activation)
        self.main_output = tf.keras.layers.Dense(1)
        self.aux_output = tf.keras.layers.Dense(1)

    def call(self, inputs):
        input_wide, input_deep = inputs
        norm_wide = self.norm_layer_wide(input_wide)
        norm_deep = self.norm_layer_deep(input_deep)
        hidden1 = self.hidden1(norm_deep)
        hidden2 = self.hidden2(hidden1)
        concat = tf.keras.layers.concatenate([norm_wide, hidden2])
        output = self.main_output(concat)
        aux_output = self.aux_output(hidden2)
        return output, aux_output

model = WideAndDeepModel(30, activation="relu", name="my_cool_model")

这个例子看起来与前一个例子相似,只是我们在构造函数中将层的创建与它们在call()方法中的使用分开。而且我们不需要创建Input对象:我们可以在call()方法中使用input参数。

现在我们有了一个模型实例,我们可以对其进行编译,调整其归一化层(例如,使用model.norm_layer_wide.adapt(...)model.norm_​layer_deep.adapt(...)),拟合它,评估它,并使用它进行预测,就像我们使用功能 API 一样。

这个 API 的一个重要区别是,您可以在call()方法中包含几乎任何您想要的东西:for循环,if语句,低级别的 TensorFlow 操作——您的想象力是唯一的限制(参见第十二章)!这使得它成为一个很好的 API,特别适用于研究人员尝试新想法。然而,这种额外的灵活性是有代价的:您的模型架构被隐藏在call()方法中,因此 Keras 无法轻松地检查它;模型无法使用tf.keras.models.clone_model()进行克隆;当您调用summary()方法时,您只会得到一个层列表,而没有关于它们如何连接在一起的任何信息。此外,Keras 无法提前检查类型和形状,容易出错。因此,除非您真的需要额外的灵活性,否则您可能应该坚持使用顺序 API 或功能 API。

提示

Keras 模型可以像常规层一样使用,因此您可以轻松地将它们组合在一起构建复杂的架构。

现在您知道如何使用 Keras 构建和训练神经网络,您会想要保存它们!

保存和恢复模型

保存训练好的 Keras 模型就是这么简单:

model.save("my_keras_model", save_format="tf")

当您设置save_format="tf"时,Keras 会使用 TensorFlow 的SavedModel格式保存模型:这是一个目录(带有给定名称),包含多个文件和子目录。特别是,saved_model.pb文件包含模型的架构和逻辑,以序列化的计算图形式,因此您不需要部署模型的源代码才能在生产中使用它;SavedModel 就足够了(您将在第十二章中看到这是如何工作的)。keras_metadata.pb文件包含 Keras 所需的额外信息。variables子目录包含所有参数值(包括连接权重、偏差、归一化统计数据和优化器参数),如果模型非常大,可能会分成多个文件。最后,assets目录可能包含额外的文件,例如数据样本、特征名称、类名等。默认情况下,assets目录为空。由于优化器也被保存了,包括其超参数和可能存在的任何状态,加载模型后,您可以继续训练。

注意

如果设置save_format="h5"或使用以.h5.hdf5.keras结尾的文件名,则 Keras 将使用基于 HDF5 格式的 Keras 特定格式将模型保存到单个文件中。然而,大多数 TensorFlow 部署工具需要使用 SavedModel 格式。

通常会有一个脚本用于训练模型并保存它,以及一个或多个脚本(或 Web 服务)用于加载模型并用于评估或进行预测。加载模型和保存模型一样简单:

model = tf.keras.models.load_model("my_keras_model")
y_pred_main, y_pred_aux = model.predict((X_new_wide, X_new_deep))

您还可以使用save_weights()load_weights()来仅保存和加载参数值。这包括连接权重、偏差、预处理统计数据、优化器状态等。参数值保存在一个或多个文件中,例如my_weights.data-00004-of-00052,再加上一个索引文件,如my_weights.index

仅保存权重比保存整个模型更快,占用更少的磁盘空间,因此在训练过程中保存快速检查点非常完美。如果您正在训练一个大模型,需要数小时或数天,那么您必须定期保存检查点以防计算机崩溃。但是如何告诉fit()方法保存检查点呢?使用回调。

使用回调

fit()方法接受一个callbacks参数,让您可以指定一个对象列表,Keras 会在训练之前和之后、每个时代之前和之后,甚至在处理每个批次之前和之后调用它们。例如,ModelCheckpoint回调会在训练期间定期保存模型的检查点,默认情况下在每个时代结束时:

checkpoint_cb = tf.keras.callbacks.ModelCheckpoint("my_checkpoints",
                                                   save_weights_only=True)
history = model.fit([...], callbacks=[checkpoint_cb])

此外,在训练过程中使用验证集时,您可以在创建 ModelCheckpoint 时设置 save_best_only=True。在这种情况下,它只会在模型在验证集上的表现迄今为止最好时保存您的模型。这样,您就不需要担心训练时间过长和过拟合训练集:只需在训练后恢复最后保存的模型,这将是验证集上的最佳模型。这是实现提前停止的一种方式(在第四章中介绍),但它实际上不会停止训练。

另一种方法是使用 EarlyStopping 回调。当在一定数量的周期(由 patience 参数定义)内在验证集上测量不到进展时,它将中断训练,如果您设置 restore_best_weights=True,它将在训练结束时回滚到最佳模型。您可以结合这两个回调来保存模型的检查点,以防计算机崩溃,并在没有进展时提前中断训练,以避免浪费时间和资源并减少过拟合:

early_stopping_cb = tf.keras.callbacks.EarlyStopping(patience=10,
                                                     restore_best_weights=True)
history = model.fit([...], callbacks=[checkpoint_cb, early_stopping_cb])

由于训练将在没有进展时自动停止(只需确保学习率不要太小,否则可能会一直缓慢进展直到结束),所以可以将周期数设置为一个较大的值。EarlyStopping 回调将在 RAM 中存储最佳模型的权重,并在训练结束时为您恢复它们。

提示

tf.keras.callbacks中还有许多其他回调可用。

如果您需要额外的控制,您可以轻松编写自己的自定义回调。例如,以下自定义回调将在训练过程中显示验证损失和训练损失之间的比率(例如,用于检测过拟合):

class PrintValTrainRatioCallback(tf.keras.callbacks.Callback):
    def on_epoch_end(self, epoch, logs):
        ratio = logs["val_loss"] / logs["loss"]
        print(f"Epoch={epoch}, val/train={ratio:.2f}")

正如您可能期望的那样,您可以实现 on_train_begin()on_train_end()on_epoch_begin()on_epoch_end()on_batch_begin()on_batch_end()。回调也可以在评估和预测期间使用,如果您需要的话(例如,用于调试)。对于评估,您应该实现 on_test_begin()on_test_end()on_test_batch_begin()on_test_batch_end(),这些方法由 evaluate() 调用。对于预测,您应该实现 on_predict_begin()on_predict_end()on_predict_batch_begin()on_predict_batch_end(),这些方法由 predict() 调用。

现在让我们再看看在使用 Keras 时您绝对应该拥有的另一个工具:TensorBoard。

使用 TensorBoard 进行可视化

TensorBoard 是一个很棒的交互式可视化工具,您可以使用它来查看训练过程中的学习曲线,比较多次运行之间的曲线和指标,可视化计算图,分析训练统计数据,查看模型生成的图像,将复杂的多维数据投影到 3D 并自动为您进行聚类,分析您的网络(即,测量其速度以识别瓶颈),等等!

TensorBoard 在安装 TensorFlow 时会自动安装。但是,您需要一个 TensorBoard 插件来可视化分析数据。如果您按照https://homl.info/install上的安装说明在本地运行所有内容,那么您已经安装了插件,但如果您在使用 Colab,则必须运行以下命令:

%pip install -q -U tensorboard-plugin-profile

要使用 TensorBoard,必须修改程序,以便将要可视化的数据输出到称为事件文件的特殊二进制日志文件中。每个二进制数据记录称为摘要。TensorBoard 服务器将监视日志目录,并自动捕捉更改并更新可视化:这使您能够可视化实时数据(有短暂延迟),例如训练期间的学习曲线。通常,您希望将 TensorBoard 服务器指向一个根日志目录,并配置程序,使其在每次运行时写入不同的子目录。这样,同一个 TensorBoard 服务器实例将允许您可视化和比较程序的多次运行中的数据,而不会混淆一切。

让我们将根日志目录命名为my_logs,并定义一个小函数,根据当前日期和时间生成日志子目录的路径,以便在每次运行时都不同:

from pathlib import Path
from time import strftime

def get_run_logdir(root_logdir="my_logs"):
    return Path(root_logdir) / strftime("run_%Y_%m_%d_%H_%M_%S")

run_logdir = get_run_logdir()  # e.g., my_logs/run_2022_08_01_17_25_59

好消息是,Keras 提供了一个方便的TensorBoard()回调,它会为您创建日志目录(以及必要时的父目录),并在训练过程中创建事件文件并写入摘要。它将测量模型的训练和验证损失和指标(在本例中是 MSE 和 RMSE),还会对神经网络进行分析。使用起来很简单:

tensorboard_cb = tf.keras.callbacks.TensorBoard(run_logdir,
                                                profile_batch=(100, 200))
history = model.fit([...], callbacks=[tensorboard_cb])

就是这样!在这个例子中,它将在第一个时期的 100 和 200 批之间对网络进行分析。为什么是 100 和 200?嗯,神经网络通常需要几批数据来“热身”,所以你不希望太早进行分析,而且分析会使用资源,最好不要为每一批数据都进行分析。

接下来,尝试将学习率从 0.001 更改为 0.002,然后再次运行代码,使用一个新的日志子目录。你将得到一个类似于这样的目录结构:

my_logs
├── run_2022_08_01_17_25_59
│   ├── train
│   │   ├── events.out.tfevents.1659331561.my_host_name.42042.0.v2
│   │   ├── events.out.tfevents.1659331562.my_host_name.profile-empty
│   │   └── plugins
│   │       └── profile
│   │           └── 2022_08_01_17_26_02
│   │               ├── my_host_name.input_pipeline.pb
│   │               └── [...]
│   └── validation
│       └── events.out.tfevents.1659331562.my_host_name.42042.1.v2
└── run_2022_08_01_17_31_12
    └── [...]

每次运行都有一个目录,每个目录包含一个用于训练日志和一个用于验证日志的子目录。两者都包含事件文件,而训练日志还包括分析跟踪。

现在你已经准备好事件文件,是时候启动 TensorBoard 服务器了。可以直接在 Jupyter 或 Colab 中使用 TensorBoard 的 Jupyter 扩展来完成,该扩展会随 TensorBoard 库一起安装。这个扩展在 Colab 中是预安装的。以下代码加载了 TensorBoard 的 Jupyter 扩展,第二行启动了一个 TensorBoard 服务器,连接到这个服务器并直接在 Jupyter 中显示用户界面。服务器会监听大于或等于 6006 的第一个可用 TCP 端口(或者您可以使用--port选项设置您想要的端口)。

%load_ext tensorboard
%tensorboard --logdir=./my_logs
提示

如果你在自己的机器上运行所有内容,可以通过在终端中执行tensorboard --logdir=./my_logs来启动 TensorBoard。您必须首先激活安装了 TensorBoard 的 Conda 环境,并转到handson-ml3目录。一旦服务器启动,访问http://localhost:6006

现在你应该看到 TensorBoard 的用户界面。点击 SCALARS 选项卡查看学习曲线(参见图 10-16)。在左下角,选择要可视化的日志(例如第一次和第二次运行的训练日志),然后点击epoch_loss标量。注意,训练损失在两次运行期间都很好地下降了,但在第二次运行中,由于更高的学习率,下降速度稍快。

mls3 1016

图 10-16。使用 TensorBoard 可视化学习曲线

您还可以在 GRAPHS 选项卡中可视化整个计算图,在 PROJECTOR 选项卡中将学习的权重投影到 3D 中,在 PROFILE 选项卡中查看性能跟踪。TensorBoard()回调还有选项可以记录额外的数据(请参阅文档以获取更多详细信息)。您可以点击右上角的刷新按钮(⟳)使 TensorBoard 刷新数据,也可以点击设置按钮(⚙)激活自动刷新并指定刷新间隔。

此外,TensorFlow 在tf.summary包中提供了一个较低级别的 API。以下代码使用create_file_writer()函数创建一个SummaryWriter,并将此写入器用作 Python 上下文来记录标量、直方图、图像、音频和文本,所有这些都可以使用 TensorBoard 进行可视化:

test_logdir = get_run_logdir()
writer = tf.summary.create_file_writer(str(test_logdir))
with writer.as_default():
    for step in range(1, 1000 + 1):
        tf.summary.scalar("my_scalar", np.sin(step / 10), step=step)

        data = (np.random.randn(100) + 2) * step / 100  # gets larger
        tf.summary.histogram("my_hist", data, buckets=50, step=step)

        images = np.random.rand(2, 32, 32, 3) * step / 1000  # gets brighter
        tf.summary.image("my_images", images, step=step)

        texts = ["The step is " + str(step), "Its square is " + str(step ** 2)]
        tf.summary.text("my_text", texts, step=step)

        sine_wave = tf.math.sin(tf.range(12000) / 48000 * 2 * np.pi * step)
        audio = tf.reshape(tf.cast(sine_wave, tf.float32), [1, -1, 1])
        tf.summary.audio("my_audio", audio, sample_rate=48000, step=step)

如果您运行此代码并在 TensorBoard 中点击刷新按钮,您将看到几个选项卡出现:IMAGES、AUDIO、DISTRIBUTIONS、HISTOGRAMS 和 TEXT。尝试点击 IMAGES 选项卡,并使用每个图像上方的滑块查看不同时间步的图像。同样,转到 AUDIO 选项卡并尝试在不同时间步听音频。正如您所看到的,TensorBoard 甚至在 TensorFlow 或深度学习之外也是一个有用的工具。

提示

您可以通过将结果发布到https://tensorboard.dev来在线共享您的结果。为此,只需运行!tensorboard dev upload --logdir ./my_logs。第一次运行时,它会要求您接受条款和条件并进行身份验证。然后您的日志将被上传,您将获得一个永久链接,以在 TensorBoard 界面中查看您的结果。

让我们总结一下你在本章学到的内容:你现在知道神经网络的起源,MLP 是什么以及如何将其用于分类和回归,如何使用 Keras 的顺序 API 构建 MLP,以及如何使用功能 API 或子类 API 构建更复杂的模型架构(包括 Wide & Deep 模型,以及具有多个输入和输出的模型)。您还学会了如何保存和恢复模型,以及如何使用回调函数进行检查点、提前停止等。最后,您学会了如何使用 TensorBoard 进行可视化。您已经可以开始使用神经网络来解决许多问题了!但是,您可能想知道如何选择隐藏层的数量、网络中的神经元数量以及所有其他超参数。让我们现在来看看这个问题。

微调神经网络超参数

神经网络的灵活性也是它们的主要缺点之一:有许多超参数需要调整。不仅可以使用任何想象得到的网络架构,甚至在基本的 MLP 中,您可以更改层的数量、每层中要使用的神经元数量和激活函数的类型、权重初始化逻辑、要使用的优化器类型、学习率、批量大小等。您如何知道哪种超参数组合对您的任务最好?

一种选择是将您的 Keras 模型转换为 Scikit-Learn 估计器,然后使用GridSearchCVRandomizedSearchCV来微调超参数,就像您在第二章中所做的那样。为此,您可以使用 SciKeras 库中的KerasRegressorKerasClassifier包装类(有关更多详细信息,请参阅https://github.com/adriangb/scikeras)。但是,还有一种更好的方法:您可以使用Keras Tuner库,这是一个用于 Keras 模型的超参数调整库。它提供了几种调整策略,可以高度定制,并且与 TensorBoard 有很好的集成。让我们看看如何使用它。

如果您按照https://homl.info/install中的安装说明在本地运行所有内容,那么您已经安装了 Keras Tuner,但如果您使用 Colab,则需要运行 %pip install -q -U keras-tuner。接下来,导入 keras_tuner,通常为 kt,然后编写一个函数来构建、编译并返回一个 Keras 模型。该函数必须接受一个 kt.HyperParameters 对象作为参数,它可以用来定义超参数(整数、浮点数、字符串等)以及它们可能的取值范围,这些超参数可以用来构建和编译模型。例如,以下函数构建并编译了一个用于分类时尚 MNIST 图像的 MLP,使用超参数如隐藏层的数量(n_hidden)、每层神经元的数量(n_neurons)、学习率(learning_rate)和要使用的优化器类型(optimizer):

import keras_tuner as kt

def build_model(hp):
    n_hidden = hp.Int("n_hidden", min_value=0, max_value=8, default=2)
    n_neurons = hp.Int("n_neurons", min_value=16, max_value=256)
    learning_rate = hp.Float("learning_rate", min_value=1e-4, max_value=1e-2,
                             sampling="log")
    optimizer = hp.Choice("optimizer", values=["sgd", "adam"])
    if optimizer == "sgd":
        optimizer = tf.keras.optimizers.SGD(learning_rate=learning_rate)
    else:
        optimizer = tf.keras.optimizers.Adam(learning_rate=learning_rate)

    model = tf.keras.Sequential()
    model.add(tf.keras.layers.Flatten())
    for _ in range(n_hidden):
        model.add(tf.keras.layers.Dense(n_neurons, activation="relu"))
    model.add(tf.keras.layers.Dense(10, activation="softmax"))
    model.compile(loss="sparse_categorical_crossentropy", optimizer=optimizer,
                  metrics=["accuracy"])
    return model

函数的第一部分定义了超参数。例如,hp.Int("n_hidden", min_value=0, max_value=8, default=2) 检查了名为 "n_hidden" 的超参数是否已经存在于 hpHyperParameters 对象中,如果存在,则返回其值。如果不存在,则注册一个新的整数超参数,名为 "n_hidden",其可能的取值范围从 0 到 8(包括边界),并返回默认值,在本例中默认值为 2(当未设置 default 时,返回 min_value)。 "n_neurons" 超参数以类似的方式注册。 "learning_rate" 超参数注册为一个浮点数,范围从 10^(-4) 到 10^(-2),由于 sampling="log",所有尺度的学习率将被等概率采样。最后,optimizer 超参数注册了两个可能的值:"sgd" 或 "adam"(默认值是第一个,本例中为 "sgd")。根据 optimizer 的值,我们创建一个具有给定学习率的 SGD 优化器或 Adam 优化器。

函数的第二部分只是使用超参数值构建模型。它创建一个 Sequential 模型,从一个 Flatten 层开始,然后是请求的隐藏层数量(由 n_hidden 超参数确定)使用 ReLU 激活函数,以及一个具有 10 个神经元(每类一个)的输出层,使用 softmax 激活函数。最后,函数编译模型并返回它。

现在,如果您想进行基本的随机搜索,可以创建一个 kt.RandomSearch 调谐器,将 build_model 函数传递给构造函数,并调用调谐器的 search() 方法:

random_search_tuner = kt.RandomSearch(
    build_model, objective="val_accuracy", max_trials=5, overwrite=True,
    directory="my_fashion_mnist", project_name="my_rnd_search", seed=42)
random_search_tuner.search(X_train, y_train, epochs=10,
                           validation_data=(X_valid, y_valid))

RandomSearch 调谐器首先使用一个空的 Hyperparameters 对象调用 build_model() 一次,以收集所有超参数规范。然后,在这个例子中,它运行 5 个试验;对于每个试验,它使用在其各自范围内随机抽样的超参数构建一个模型,然后对该模型进行 10 个周期的训练,并将其保存到 my_fashion_mnist/my_rnd_search 目录的子目录中。由于 overwrite=True,在训练开始之前 my_rnd_search 目录将被删除。如果您再次运行此代码,但使用 overwrite=Falsemax_trials=10,调谐器将继续从上次停止的地方进行调谐,运行 5 个额外的试验:这意味着您不必一次性运行所有试验。最后,由于 objective 设置为 "val_accuracy",调谐器更喜欢具有更高验证准确性的模型,因此一旦调谐器完成搜索,您可以像这样获取最佳模型:

top3_models = random_search_tuner.get_best_models(num_models=3)
best_model = top3_models[0]

您还可以调用 get_best_hyperparameters() 来获取最佳模型的 kt.HyperParameters

>>> top3_params = random_search_tuner.get_best_hyperparameters(num_trials=3)
>>> top3_params[0].values  # best hyperparameter values
{'n_hidden': 5,
 'n_neurons': 70,
 'learning_rate': 0.00041268008323824807,
 'optimizer': 'adam'}

每个调谐器都由一个所谓的oracle指导:在每次试验之前,调谐器会询问 oracle 告诉它下一个试验应该是什么。RandomSearch调谐器使用RandomSearchOracle,它非常基本:就像我们之前看到的那样,它只是随机选择下一个试验。由于 oracle 跟踪所有试验,您可以要求它给出最佳试验,并显示该试验的摘要:

>>> best_trial = random_search_tuner.oracle.get_best_trials(num_trials=1)[0]
>>> best_trial.summary()
Trial summary
Hyperparameters:
n_hidden: 5
n_neurons: 70
learning_rate: 0.00041268008323824807
optimizer: adam
Score: 0.8736000061035156

这显示了最佳超参数(与之前一样),以及验证准确率。您也可以直接访问所有指标:

>>> best_trial.metrics.get_last_value("val_accuracy")
0.8736000061035156

如果您对最佳模型的性能感到满意,您可以在完整的训练集(X_train_fully_train_full)上继续训练几个时期,然后在测试集上评估它,并将其部署到生产环境(参见第十九章):

best_model.fit(X_train_full, y_train_full, epochs=10)
test_loss, test_accuracy = best_model.evaluate(X_test, y_test)

在某些情况下,您可能希望微调数据预处理超参数或model.fit()参数,比如批量大小。为此,您必须使用略有不同的技术:而不是编写一个build_model()函数,您必须子类化kt.HyperModel类并定义两个方法,build()fit()build()方法执行与build_model()函数完全相同的操作。fit()方法接受一个HyperParameters对象和一个已编译的模型作为参数,以及所有model.fit()参数,并拟合模型并返回History对象。关键是,fit()方法可以使用超参数来决定如何预处理数据,调整批量大小等。例如,以下类构建了与之前相同的模型,具有相同的超参数,但它还使用一个布尔型"normalize"超参数来控制是否在拟合模型之前标准化训练数据:

class MyClassificationHyperModel(kt.HyperModel):
    def build(self, hp):
        return build_model(hp)

    def fit(self, hp, model, X, y, **kwargs):
        if hp.Boolean("normalize"):
            norm_layer = tf.keras.layers.Normalization()
            X = norm_layer(X)
        return model.fit(X, y, **kwargs)

然后,您可以将此类的实例传递给您选择的调谐器,而不是传递build_model函数。例如,让我们基于MyClassificationHyperModel实例构建一个kt.Hyperband调谐器:

hyperband_tuner = kt.Hyperband(
    MyClassificationHyperModel(), objective="val_accuracy", seed=42,
    max_epochs=10, factor=3, hyperband_iterations=2,
    overwrite=True, directory="my_fashion_mnist", project_name="hyperband")

这个调谐器类似于我们在第二章中讨论的HalvingRandomSearchCV类:它首先为少数时期训练许多不同的模型,然后消除最差的模型,仅保留前1 / factor个模型(在这种情况下是前三分之一),重复此选择过程,直到只剩下一个模型。max_epochs参数控制最佳模型将被训练的最大时期数。在这种情况下,整个过程重复两次(hyperband_iterations=2)。每个超带迭代中所有模型的总训练时期数约为max_epochs * (log(max_epochs) / log(factor)) ** 2,因此在这个例子中大约为 44 个时期。其他参数与kt.RandomSearch相同。

现在让我们运行 Hyperband 调谐器。我们将使用TensorBoard回调,这次指向根日志目录(调谐器将负责为每个试验使用不同的子目录),以及一个EarlyStopping回调:

root_logdir = Path(hyperband_tuner.project_dir) / "tensorboard"
tensorboard_cb = tf.keras.callbacks.TensorBoard(root_logdir)
early_stopping_cb = tf.keras.callbacks.EarlyStopping(patience=2)
hyperband_tuner.search(X_train, y_train, epochs=10,
                       validation_data=(X_valid, y_valid),
                       callbacks=[early_stopping_cb, tensorboard_cb])

现在,如果您打开 TensorBoard,将--logdir指向my_fashion_mnist/hyperband/tensorboard目录,您将看到所有试验结果的展示。确保访问 HPARAMS 选项卡:它包含了所有尝试过的超参数组合的摘要,以及相应的指标。请注意,在 HPARAMS 选项卡内部有三个选项卡:表格视图、平行坐标视图和散点图矩阵视图。在左侧面板的下部,取消选中除了validation.epoch_accuracy之外的所有指标:这将使图表更清晰。在平行坐标视图中,尝试选择validation.epoch_accuracy列中的高值范围:这将仅显示达到良好性能的超参数组合。单击其中一个超参数组合,相应的学习曲线将出现在页面底部。花些时间浏览每个选项卡;这将帮助您了解每个超参数对性能的影响,以及超参数之间的相互作用。

Hyperband 比纯随机搜索更聪明,因为它分配资源的方式更为高效,但在其核心部分仍然是随机探索超参数空间;它快速,但粗糙。然而,Keras Tuner 还包括一个kt.BayesianOptimization调谐器:这种算法通过拟合一个称为高斯过程的概率模型逐渐学习哪些超参数空间区域最有前途。这使得它逐渐聚焦于最佳超参数。缺点是该算法有自己的超参数:alpha代表您在试验中期望性能指标中的噪声水平(默认为 10^(–4)),beta指定您希望算法探索而不仅仅利用已知的超参数空间中的良好区域(默认为 2.6)。除此之外,这个调谐器可以像之前的调谐器一样使用:

bayesian_opt_tuner = kt.BayesianOptimization(
    MyClassificationHyperModel(), objective="val_accuracy", seed=42,
    max_trials=10, alpha=1e-4, beta=2.6,
    overwrite=True, directory="my_fashion_mnist", project_name="bayesian_opt")
bayesian_opt_tuner.search([...])

超参数调整仍然是一个活跃的研究领域,许多其他方法正在被探索。例如,查看 DeepMind 出色的2017 年论文,其中作者使用进化算法共同优化了一组模型和它们的超参数。谷歌也采用了进化方法,不仅用于搜索超参数,还用于探索各种模型架构:它为谷歌 Vertex AI 上的 AutoML 服务提供动力(参见第十九章)。术语AutoML指的是任何系统,它负责 ML 工作流的大部分。进化算法甚至已成功用于训练单个神经网络,取代了无处不在的梯度下降!例如,查看 Uber 在2017 年发布的文章,作者介绍了他们的Deep Neuroevolution技术。

尽管有这些令人兴奋的进展和所有这些工具和服务,但仍然有必要了解每个超参数的合理值,以便您可以构建一个快速原型并限制搜索空间。以下部分提供了选择 MLP 中隐藏层和神经元数量以及选择一些主要超参数的良好值的指导方针。

隐藏层的数量

对于许多问题,您可以从一个隐藏层开始并获得合理的结果。具有一个隐藏层的 MLP 在理论上可以建模甚至最复杂的函数,只要它有足够的神经元。但对于复杂问题,深度网络比浅层网络具有更高的参数效率:它们可以使用指数级较少的神经元来建模复杂函数,从而使它们在相同数量的训练数据下达到更好的性能。

要理解为什么,假设您被要求使用绘图软件画一片森林,但是禁止复制和粘贴任何东西。这将需要大量的时间:您必须逐个绘制每棵树,一枝一枝,一叶一叶。如果您可以绘制一片叶子,复制并粘贴它以绘制一根树枝,然后复制并粘贴该树枝以创建一棵树,最后复制并粘贴这棵树以制作一片森林,您将很快完成。现实世界的数据通常以这种分层方式结构化,深度神经网络会自动利用这一事实:较低的隐藏层模拟低级结构(例如各种形状和方向的线段),中间隐藏层将这些低级结构组合起来模拟中级结构(例如正方形、圆形),最高隐藏层和输出层将这些中级结构组合起来模拟高级结构(例如人脸)。

这种分层结构不仅有助于深度神经网络更快地收敛到一个好的解决方案,而且还提高了它们对新数据集的泛化能力。例如,如果您已经训练了一个模型来识别图片中的人脸,现在想要训练一个新的神经网络来识别发型,您可以通过重用第一个网络的较低层来启动训练。而不是随机初始化新神经网络的前几层的权重和偏置,您可以将它们初始化为第一个网络较低层的权重和偏置的值。这样网络就不必从头学习出现在大多数图片中的所有低级结构;它只需要学习更高级的结构(例如发型)。这就是所谓的迁移学习

总之,对于许多问题,您可以从只有一个或两个隐藏层开始,神经网络就能正常工作。例如,您可以仅使用一个具有几百个神经元的隐藏层在 MNIST 数据集上轻松达到 97% 以上的准确率,使用两个具有相同总神经元数量的隐藏层在大致相同的训练时间内达到 98% 以上的准确率。对于更复杂的问题,您可以增加隐藏层的数量,直到开始过拟合训练集。非常复杂的任务,例如大型图像分类或语音识别,通常需要具有数十层(甚至数百层,但不是全连接的,如您将在第十四章中看到的)的网络,并且需要大量的训练数据。您很少需要从头开始训练这样的网络:更常见的做法是重用执行类似任务的预训练最先进网络的部分。这样训练速度会更快,需要的数据量也会更少(我们将在第十一章中讨论这一点)。

隐藏层中的神经元数量

输入层和输出层的神经元数量取决于您的任务所需的输入和输出类型。例如,MNIST 任务需要 28 × 28 = 784 个输入和 10 个输出神经元。

至于隐藏层,过去常见的做法是将它们大小设计成金字塔形,每一层的神经元数量越来越少——其理由是许多低级特征可以融合成远远较少的高级特征。一个典型的用于 MNIST 的神经网络可能有 3 个隐藏层,第一个有 300 个神经元,第二个有 200 个,第三个有 100 个。然而,这种做法已经被大多数人放弃,因为似乎在大多数情况下,在所有隐藏层中使用相同数量的神经元表现得同样好,甚至更好;此外,只需调整一个超参数,而不是每一层一个。尽管如此,根据数据集的不同,有时将第一个隐藏层设计得比其他隐藏层更大可能会有所帮助。

就像层数一样,您可以尝试逐渐增加神经元的数量,直到网络开始过拟合。或者,您可以尝试构建一个比实际需要的层数和神经元稍多一点的模型,然后使用提前停止和其他正则化技术来防止过度拟合。Google 的科学家 Vincent Vanhoucke 将此称为“伸展裤”方法:不要浪费时间寻找完全符合您尺寸的裤子,只需使用大号伸展裤,它们会缩小到合适的尺寸。通过这种方法,您可以避免可能破坏模型的瓶颈层。实际上,如果一层的神经元太少,它将没有足够的表征能力来保留来自输入的所有有用信息(例如,具有两个神经元的层只能输出 2D 数据,因此如果它以 3D 数据作为输入,一些信息将丢失)。无论网络的其余部分有多大和强大,该信息都将永远无法恢复。

提示

一般来说,增加层数而不是每层的神经元数量会更有效。

学习率、批量大小和其他超参数

隐藏层和神经元的数量并不是您可以在 MLP 中调整的唯一超参数。以下是一些最重要的超参数,以及如何设置它们的提示:

学习率

学习率可以说是最重要的超参数。一般来说,最佳学习率约为最大学习率的一半(即训练算法发散的学习率上限,如我们在第四章中看到的)。找到一个好的学习率的方法是训练模型几百次迭代,从非常低的学习率(例如,10^(-5))开始,逐渐增加到非常大的值(例如,10)。这是通过在每次迭代时将学习率乘以一个常数因子来完成的(例如,通过(10 / 10(-5))(1 / 500)在 500 次迭代中从 10^(-5)增加到 10)。如果将损失作为学习率的函数绘制出来(使用对数刻度的学习率),您应该会看到它一开始下降。但过一段时间,学习率将变得太大,因此损失会迅速上升:最佳学习率将略低于损失开始上升的点(通常比转折点低约 10 倍)。然后,您可以重新初始化您的模型,并使用这个好的学习率进行正常训练。我们将在第十一章中探讨更多学习率优化技术。

优化器

选择比普通的小批量梯度下降更好的优化器(并调整其超参数)也非常重要。我们将在第十一章中研究几种高级优化器。

批量大小

批量大小可能会对模型的性能和训练时间产生重大影响。使用大批量大小的主要好处是硬件加速器如 GPU 可以高效处理它们(参见第十九章),因此训练算法将每秒看到更多实例。因此,许多研究人员和从业者建议使用能够适应 GPU RAM 的最大批量大小。然而,有一个问题:在实践中,大批量大小通常会导致训练不稳定,特别是在训练开始时,由此产生的模型可能不会像使用小批量大小训练的模型那样泛化得好。2018 年 4 月,Yann LeCun 甚至在推特上发表了“朋友们不要让朋友们使用大于 32 的小批量”的言论,引用了 Dominic Masters 和 Carlo Luschi 在2018 年的一篇论文的结论,该论文认为使用小批量(从 2 到 32)更可取,因为小批量在更短的训练时间内产生更好的模型。然而,其他研究结果却指向相反的方向。例如,2017 年,Elad Hoffer 等人的论文和 Priya Goyal 等人的论文显示,可以使用非常大的批量大小(高达 8,192),并结合各种技术,如学习率预热(即从小学习率开始训练,然后逐渐增加,如第十一章中讨论的那样),以获得非常短的训练时间,而不会出现泛化差距。因此,一种策略是尝试使用大批量大小,结合学习率预热,如果训练不稳定或最终性能令人失望,则尝试改用小批量大小。

激活函数

我们在本章前面讨论了如何选择激活函数:一般来说,ReLU 激活函数将是所有隐藏层的一个很好的默认选择,但对于输出层,它真的取决于您的任务。

迭代次数

在大多数情况下,实际上不需要调整训练迭代次数:只需使用早停止即可。

提示

最佳学习率取决于其他超参数,尤其是批量大小,因此如果您修改任何超参数,请确保同时更新学习率。

有关调整神经网络超参数的最佳实践,请查看 Leslie Smith 的优秀2018 年论文

这结束了我们关于人工神经网络及其在 Keras 中的实现的介绍。在接下来的几章中,我们将讨论训练非常深的网络的技术。我们还将探讨如何使用 TensorFlow 的低级 API 自定义模型,以及如何使用 tf.data API 高效加载和预处理数据。我们将深入研究其他流行的神经网络架构:用于图像处理的卷积神经网络,用于序列数据和文本的循环神经网络和 transformers,用于表示学习的自编码器,以及用于建模和生成数据的生成对抗网络。

练习

  1. TensorFlow playground是由 TensorFlow 团队构建的一个方便的神经网络模拟器。在这个练习中,您将只需点击几下就可以训练几个二元分类器,并调整模型的架构和超参数,以便对神经网络的工作原理和超参数的作用有一些直观的认识。花一些时间来探索以下内容:

    1. 神经网络学习的模式。尝试通过点击运行按钮(左上角)训练默认的神经网络。注意到它如何快速找到分类任务的良好解决方案。第一个隐藏层中的神经元已经学会了简单的模式,而第二个隐藏层中的神经元已经学会了将第一个隐藏层的简单模式组合成更复杂的模式。一般来说,层数越多,模式就越复杂。

    2. 激活函数。尝试用 ReLU 激活函数替换 tanh 激活函数,并重新训练网络。注意到它找到解决方案的速度更快,但这次边界是线性的。这是由于 ReLU 函数的形状。

    3. 局部最小值的风险。修改网络架构,只有一个有三个神经元的隐藏层。多次训练它(要重置网络权重,点击播放按钮旁边的重置按钮)。注意到训练时间变化很大,有时甚至会卡在局部最小值上。

    4. 当神经网络太小时会发生什么。移除一个神经元,只保留两个。注意到神经网络现在无法找到一个好的解决方案,即使你尝试多次。模型参数太少,系统地欠拟合训练集。

    5. 当神经网络足够大时会发生什么。将神经元数量设置为八,并多次训练网络。注意到现在训练速度一致快速,从不卡住。这突显了神经网络理论中的一个重要发现:大型神经网络很少会卡在局部最小值上,即使卡住了,这些局部最优解通常几乎和全局最优解一样好。然而,它们仍然可能在长时间的高原上卡住。

    6. 深度网络中梯度消失的风险。选择螺旋数据集(“DATA”下方的右下数据集),并将网络架构更改为每个有八个神经元的四个隐藏层。注意到训练时间更长,经常在高原上卡住很长时间。还要注意到最高层(右侧)的神经元比最低层(左侧)的神经元进化得更快。这个问题被称为梯度消失问题,可以通过更好的权重初始化和其他技术、更好的优化器(如 AdaGrad 或 Adam)或批量归一化(在第十一章中讨论)来缓解。

    7. 更进一步。花一个小时左右的时间玩弄其他参数,了解它们的作用,建立对神经网络的直观理解。

  2. 使用原始人工神经元(如图 10-3 中的人工神经元)绘制一个 ANN,计算AB(其中 ⊕ 表示异或操作)。提示:AB = (A ∧ ¬ B) ∨ (¬ AB)。

  3. 通常更倾向于使用逻辑回归分类器而不是经典感知器(即使用感知器训练算法训练的阈值逻辑单元的单层)。如何调整感知器使其等效于逻辑回归分类器?

  4. 为什么 Sigmoid 激活函数是训练第一个 MLP 的关键因素?

  5. 列出三种流行的激活函数。你能画出它们吗?

  6. 假设你有一个 MLP,由一个具有 10 个传递神经元的输入层、一个具有 50 个人工神经元的隐藏层和一个具有 3 个人工神经元的输出层组成。所有人工神经元都使用 ReLU 激活函数。

    1. 输入矩阵X的形状是什么?

    2. 隐藏层权重矩阵W[h]和偏置向量b[h]的形状是什么?

    3. 输出层权重矩阵W[o]和偏置向量b[o]的形状是什么?

    4. 网络输出矩阵Y的形状是什么?

    5. 写出计算网络输出矩阵Y的方程,作为XW[h]、b[h]、W[o]和b[o]的函数。

  7. 如果你想将电子邮件分类为垃圾邮件或正常邮件,输出层需要多少个神经元?输出层应该使用什么激活函数?如果你想处理 MNIST 数据集,输出层需要多少个神经元,应该使用哪种激活函数?对于让你的网络预测房价,如第二章中所述,需要多少个神经元,应该使用什么激活函数?

  8. 什么是反向传播,它是如何工作的?反向传播和反向模式自动微分之间有什么区别?

  9. 在基本的 MLP 中,你可以调整哪些超参数?如果 MLP 过拟合训练数据,你可以如何调整这些超参数来尝试解决问题?

  10. 在 MNIST 数据集上训练一个深度 MLP(可以使用tf.keras.datasets.mnist.load_data()加载)。看看你是否可以通过手动调整超参数获得超过 98%的准确率。尝试使用本章介绍的方法搜索最佳学习率(即通过指数增长学习率,绘制损失曲线,并找到损失飙升的点)。接下来,尝试使用 Keras Tuner 调整超参数,包括保存检查点、使用早停止,并使用 TensorBoard 绘制学习曲线。

这些练习的解决方案可以在本章笔记本的末尾找到,网址为https://homl.info/colab3

¹ 你可以通过对生物启发开放,而不害怕创建生物不现实的模型,来获得两全其美,只要它们运行良好。

² Warren S. McCulloch 和 Walter Pitts,“神经活动中固有思想的逻辑演算”,《数学生物学公报》5 卷 4 期(1943 年):115-113。

³ 它们实际上并没有连接,只是非常接近,可以非常快速地交换化学信号。

⁴ Bruce Blaus 绘制的图像(知识共享 3.0)。来源:https://en.wikipedia.org/wiki/Neuron

⁵ 在机器学习的背景下,“神经网络”一词通常指的是人工神经网络,而不是生物神经网络。

⁶ S. Ramon y Cajal 绘制的皮层层析图(公有领域)。来源:https://en.wikipedia.org/wiki/Cerebral_cortex

⁷ 请注意,这个解决方案并不唯一:当数据点线性可分时,有无穷多个可以将它们分开的超平面。

⁸ 例如,当输入为(0,1)时,左下神经元计算 0 × 1 + 1 × 1 - 3 / 2 = -1 / 2,为负数,因此输出为 0。右下神经元计算 0 × 1 + 1 × 1 - 1 / 2 = 1 / 2,为正数,因此输出为 1。输出神经元接收前两个神经元的输出作为输入,因此计算 0 × (-1) + 1 × 1 - 1 / 2 = 1 / 2。这是正数,因此输出为 1。

⁹ 在 20 世纪 90 年代,具有两个以上隐藏层的人工神经网络被认为是深度的。如今,常见的是看到具有数十层甚至数百层的人工神经网络,因此“深度”的定义非常模糊。

¹⁰ 大卫·鲁梅尔哈特等人,“通过误差传播学习内部表示”(国防技术信息中心技术报告,1985 年 9 月)。

¹¹ 生物神经元似乎实现了一个大致呈 S 形的激活函数,因此研究人员长时间坚持使用 Sigmoid 函数。但事实证明,在人工神经网络中,ReLU 通常效果更好。这是生物类比可能误导的一个案例。

¹² ONEIROS 项目(开放式神经电子智能机器人操作系统)。Chollet 在 2015 年加入了谷歌,继续领导 Keras 项目。

¹³ PyTorch 的 API 与 Keras 的相似,因此一旦你了解了 Keras,如果你想要的话,切换到 PyTorch 并不困难。PyTorch 在 2018 年的普及程度呈指数增长,这在很大程度上要归功于其简单性和出色的文档,而这些正是 TensorFlow 1.x 当时的主要弱点。然而,TensorFlow 2 和 PyTorch 一样简单,部分原因是它已经将 Keras 作为其官方高级 API,并且开发人员大大简化和清理了其余的 API。文档也已经完全重新组织,现在更容易找到所需的内容。同样,PyTorch 的主要弱点(例如,有限的可移植性和没有计算图分析)在 PyTorch 1.0 中已经得到了很大程度的解决。健康的竞争对每个人都有益。

¹⁴ 您还可以使用 tf.keras.utils.plot_model() 生成模型的图像。

¹⁵ Heng-Tze Cheng 等人,“广泛和深度学习用于推荐系统”第一届深度学习推荐系统研讨会论文集(2016):7–10。

¹⁶ 短路径也可以用于向神经网络提供手动设计的特征。

¹⁷ Keras 模型有一个 output 属性,所以我们不能将其用作主输出层的名称,这就是为什么我们将其重命名为 main_output

¹⁸ 目前这是默认设置,但 Keras 团队正在研究一种可能成为未来默认设置的新格式,因此我更喜欢明确设置格式以保证未来兼容。

¹⁹ Hyperband 实际上比连续减半法更复杂;参见 Lisha Li 等人的论文,“Hyperband: 一种新颖的基于贝叶斯的超参数优化方法”,机器学习研究杂志 18(2018 年 4 月):1–52。

²⁰ Max Jaderberg 等人,“神经网络的基于人口的训练”,arXiv 预印本 arXiv:1711.09846(2017)。

²¹ Dominic Masters 和 Carlo Luschi,“重新审视深度神经网络的小批量训练”,arXiv 预印本 arXiv:1804.07612(2018)。

²² Elad Hoffer 等人,“训练时间更长,泛化效果更好:弥合神经网络大批量训练的泛化差距”,第 31 届国际神经信息处理系统会议论文集(2017):1729–1739。

²³ Priya Goyal 等人,“准确、大型小批量 SGD:在 1 小时内训练 ImageNet”,arXiv 预印本 arXiv:1706.02677(2017)。

²⁴ Leslie N. Smith,“神经网络超参数的纪律性方法:第 1 部分—学习率、批量大小、动量和权重衰减”,arXiv 预印本 arXiv:1803.09820(2018)。

²⁵ 在https://homl.info/extra-anns的在线笔记本中还介绍了一些额外的人工神经网络架构。

第十一章:训练深度神经网络

在第十章中,您构建、训练和微调了您的第一个人工神经网络。但它们是浅层网络,只有几个隐藏层。如果您需要解决一个复杂的问题,比如在高分辨率图像中检测数百种对象,您可能需要训练一个更深的人工神经网络,也许有 10 层或更多层,每一层包含数百个神经元,通过数十万个连接相连。训练深度神经网络并不是一件轻松的事情。以下是您可能遇到的一些问题:

  • 在训练过程中,当反向传播通过 DNN 向后流动时,您可能会面临梯度变得越来越小或越来越大的问题。这两个问题都会使得较低层非常难以训练。

  • 您可能没有足够的训练数据来训练这样一个庞大的网络,或者标记成本太高。

  • 训练可能会非常缓慢。

  • 一个拥有数百万参数的模型会严重增加过拟合训练集的风险,特别是如果训练实例不足或者太嘈杂。

在本章中,我们将逐个讨论这些问题,并提出解决方法。我们将首先探讨梯度消失和梯度爆炸问题以及它们最流行的解决方案。接下来,我们将看看迁移学习和无监督预训练,这可以帮助您解决复杂任务,即使您只有很少的标记数据。然后,我们将讨论各种优化器,可以极大地加快训练大型模型。最后,我们将介绍一些用于大型神经网络的流行正则化技术。

有了这些工具,您将能够训练非常深的网络。欢迎来到深度学习!

梯度消失/爆炸问题

正如在第十章中讨论的那样,反向传播算法的第二阶段是从输出层到输入层,沿途传播错误梯度。一旦算法计算出网络中每个参数相对于成本函数的梯度,它就会使用这些梯度来更新每个参数,进行梯度下降步骤。

不幸的是,随着算法向下进行到更低的层,梯度通常会变得越来越小。结果是,梯度下降更新几乎不会改变较低层的连接权重,训练永远不会收敛到一个好的解决方案。这被称为梯度消失问题。在某些情况下,相反的情况可能发生:梯度会变得越来越大,直到层的权重更新变得非常大,算法发散。这是梯度爆炸问题,最常出现在递归神经网络中(参见第十五章)。更一般地说,深度神经网络受到不稳定梯度的困扰;不同层可能以非常不同的速度学习。

或者在-r 和+r 之间的均匀分布,r = sqrt(3 / fan_avg)

在他们的论文中,Glorot 和 Bengio 提出了一种显著减轻不稳定梯度问题的方法。他们指出,我们需要信号在两个方向上正确地流动:在前向方向进行预测时,以及在反向方向进行反向传播梯度时。我们不希望信号消失,也不希望它爆炸和饱和。为了使信号正确地流动,作者认为每一层的输出方差应该等于其输入方差,并且在反向方向通过一层之后,梯度在前后具有相等的方差(如果您对数学细节感兴趣,请查看论文)。实际上,除非层具有相等数量的输入和输出(这些数字称为层的fan-infan-out),否则不可能保证两者都相等,但 Glorot 和 Bengio 提出了一个在实践中被证明非常有效的良好折衷方案:每层的连接权重必须随机初始化,如方程 11-1 所述,其中fan[avg] = (fan[in] + fan[out]) / 2。这种初始化策略称为Xavier 初始化Glorot 初始化,以论文的第一作者命名。

观察 Sigmoid 激活函数(参见图 11-1),您会发现当输入变大(负或正)时,函数在 0 或 1 处饱和,导数非常接近 0(即曲线在两个极端处平坦)。因此,当反向传播开始时,几乎没有梯度可以通过网络向后传播,存在的微小梯度会随着反向传播通过顶层逐渐稀释,因此对于较低层几乎没有剩余的梯度。

图 11-1。Sigmoid 激活函数饱和

Glorot 和 He 初始化

这种不幸的行为早在很久以前就被经验性地观察到,这也是深度神经网络在 2000 年代初大多被放弃的原因之一。当训练 DNN 时,梯度不稳定的原因并不清楚,但在 2010 年的一篇论文中,Xavier Glorot 和 Yoshua Bengio 揭示了一些端倪。作者发现了一些嫌疑人,包括当时最流行的 Sigmoid(逻辑)激活函数和权重初始化技术的组合(即均值为 0,标准差为 1 的正态分布)。简而言之,他们表明,使用这种激活函数和初始化方案,每一层的输出方差远大于其输入方差。在网络中前进,每一层的方差在每一层之后都会增加,直到激活函数在顶层饱和。实际上,这种饱和现象被 sigmoid 函数的均值为 0.5 而不是 0 所加剧(双曲正切函数的均值为 0,在深度网络中的表现略好于 sigmoid 函数)。

方程 11-1。Glorot 初始化(使用 Sigmoid 激活函数时)

正态分布,均值为 0,方差为σ² = 1 / fan_avg

如果您在方程式 11-1 中用fan[in]替换fan[avg],您将得到 Yann LeCun 在 1990 年代提出的初始化策略。他称之为LeCun 初始化。Genevieve Orr 和 Klaus-Robert Müller 甚至在他们 1998 年的书Neural Networks: Tricks of the Trade(Springer)中推荐了这种方法。当fan[in] = fan[out]时,LeCun 初始化等同于 Glorot 初始化。研究人员花了十多年的时间才意识到这个技巧有多重要。使用 Glorot 初始化可以显著加快训练速度,这是深度学习成功的实践之一。

一些论文提供了不同激活函数的类似策略。这些策略仅在方差的规模和它们是否使用fan[avg]或fan[in]上有所不同,如表 11-1 所示(对于均匀分布,只需使用r=3σ2)。为 ReLU 激活函数及其变体提出的初始化策略称为He 初始化Kaiming 初始化,以论文的第一作者命名。对于 SELU,最好使用 Yann LeCun 的初始化方法,最好使用正态分布。我们将很快介绍所有这些激活函数。

表 11-1。每种激活函数的初始化参数

初始化 激活函数 σ²(正态)
Glorot 无,tanh,sigmoid,softmax 1 / fan[avg]
He ReLU,Leaky ReLU,ELU,GELU,Swish,Mish 2 / fan[in]
LeCun SELU 1 / fan[in]

默认情况下,Keras 使用均匀分布的 Glorot 初始化。当您创建一个层时,您可以通过设置kernel_initializer="he_uniform"kernel_initializer="he_normal"来切换到 He 初始化。

import tensorflow as tf

dense = tf.keras.layers.Dense(50, activation="relu",
                              kernel_initializer="he_normal")

或者,您可以使用VarianceScaling初始化器获得表 11-1 中列出的任何初始化方法,甚至更多。例如,如果您想要使用均匀分布并基于fan[avg](而不是fan[in])进行 He 初始化,您可以使用以下代码:

he_avg_init = tf.keras.initializers.VarianceScaling(scale=2., mode="fan_avg",
                                                    distribution="uniform")
dense = tf.keras.layers.Dense(50, activation="sigmoid",
                              kernel_initializer=he_avg_init)

更好的激活函数

2010 年 Glorot 和 Bengio 的一篇论文中的一个见解是,不稳定梯度的问题在一定程度上是由于激活函数的选择不当。直到那时,大多数人都认为,如果自然界选择在生物神经元中使用大致为 S 形的激活函数,那么它们一定是一个很好的选择。但事实证明,其他激活函数在深度神经网络中表现得更好,特别是 ReLU 激活函数,主要是因为它对于正值不会饱和,而且计算速度非常快。

不幸的是,ReLU 激活函数并不完美。它存在一个称为dying ReLUs的问题:在训练过程中,一些神经元实际上“死亡”,意味着它们停止输出除 0 以外的任何值。在某些情况下,您可能会发现您网络的一半神经元已经死亡,尤其是如果您使用了较大的学习率。当神经元的权重被微调得使得 ReLU 函数的输入(即神经元输入的加权和加上偏置项)在训练集中的所有实例中都为负时,神经元就会死亡。当这种情况发生时,它只会继续输出零,并且梯度下降不再影响它,因为当其输入为负时,ReLU 函数的梯度为零。

为了解决这个问题,您可能希望使用 ReLU 函数的变体,比如leaky ReLU

Leaky ReLU

leaky ReLU 激活函数定义为 LeakyReLUα = max(αz, z)(参见图 11-2)。超参数α定义了函数“泄漏”的程度:它是z < 0 时函数的斜率。对于z < 0,具有斜率的 leaky ReLU 永远不会死亡;它们可能会陷入长时间的昏迷,但最终有机会苏醒。Bing Xu 等人在 2015 年的一篇论文比较了几种 ReLU 激活函数的变体,其中一个结论是,泄漏变体总是优于严格的 ReLU 激活函数。事实上,设置α=0.2(一个巨大的泄漏)似乎比α=0.01(一个小泄漏)表现更好。该论文还评估了随机泄漏 ReLU(RReLU),其中α在训练期间在给定范围内随机选择,并在测试期间固定为平均值。RReLU 表现也相当不错,并似乎作为正则化器,减少了过拟合训练集的风险。最后,该论文评估了参数泄漏 ReLU(PReLU),其中α在训练期间被授权学习:它不再是一个超参数,而是一个可以像其他参数一样通过反向传播修改的参数。据报道,PReLU 在大型图像数据集上明显优于 ReLU,但在较小的数据集上存在过拟合训练集的风险。

mls3 1102

图 11-2. Leaky ReLU:类似于 ReLU,但对负值有一个小的斜率

Keras 在tf.keras.layers包中包含了LeakyReLUPReLU类。就像其他 ReLU 变体一样,您应该使用 He 初始化。例如:

leaky_relu = tf.keras.layers.LeakyReLU(alpha=0.2)  # defaults to alpha=0.3
dense = tf.keras.layers.Dense(50, activation=leaky_relu,
                              kernel_initializer="he_normal")

如果您愿意,您也可以在模型中将LeakyReLU作为一个单独的层来使用;对于训练和预测没有任何影响:

model = tf.keras.models.Sequential([
    [...]  # more layers
    tf.keras.layers.Dense(50, kernel_initializer="he_normal"),  # no activation
    tf.keras.layers.LeakyReLU(alpha=0.2),  # activation as a separate layer
    [...]  # more layers
])

对于 PReLU,将LeakyReLU替换为PReLU。目前在 Keras 中没有官方实现 RReLU,但您可以相当容易地实现自己的(要了解如何做到这一点,请参见第十二章末尾的练习)。

ReLU、leaky ReLU 和 PReLU 都存在一个问题,即它们不是平滑函数:它们的导数在z=0 处突然变化。正如我们在第四章中讨论 lasso 时看到的那样,这种不连续性会导致梯度下降在最优点周围反弹,并减慢收敛速度。因此,现在我们将看一些 ReLU 激活函数的平滑变体,从 ELU 和 SELU 开始。

ELU 和 SELU

2015 年,Djork-Arné Clevert 等人提出了一篇论文,提出了一种新的激活函数,称为指数线性单元(ELU),在作者的实验中表现优于所有 ReLU 变体:训练时间缩短,神经网络在测试集上表现更好。方程式 11-2 展示了这个激活函数的定义。

方程式 11-2. ELU 激活函数

ELU α ( z ) = α ( exp ( z ) - 1 ) if z < 0 z if z 0

ELU 激活函数看起来很像 ReLU 函数(参见图 11-3),但有一些主要区别:

  • z < 0 时,它会取负值,这使得单元的平均输出更接近于 0,并有助于缓解梯度消失问题。超参数α定义了当z是一个较大的负数时 ELU 函数接近的值的相反数。通常设置为 1,但您可以像调整其他超参数一样进行调整。

  • z < 0 时具有非零梯度,避免了死神经元问题。

  • 如果α等于 1,则该函数在任何地方都是平滑的,包括在z = 0 附近,这有助于加快梯度下降的速度,因为它在z = 0 的左右两侧不会反弹太多。

在 Keras 中使用 ELU 就像设置activation="elu"一样简单,与其他 ReLU 变体一样,应该使用 He 初始化。ELU 激活函数的主要缺点是它的计算速度比 ReLU 函数及其变体慢(由于使用了指数函数)。在训练期间更快的收敛速度可能会弥补这种缓慢的计算,但是在测试时,ELU 网络将比 ReLU 网络慢一点。

mls3 1103

图 11-3. ELU 和 SELU 激活函数

不久之后,Günter Klambauer 等人在2017 年的一篇论文中介绍了缩放 ELU(SELU)激活函数:正如其名称所示,它是 ELU 激活函数的缩放变体(大约是 ELU 的 1.05 倍,使用α ≈ 1.67)。作者们表明,如果构建一个仅由一堆稠密层(即 MLP)组成的神经网络,并且所有隐藏层使用 SELU 激活函数,那么网络将自标准化:每一层的输出在训练过程中倾向于保持均值为 0,标准差为 1,从而解决了梯度消失/爆炸的问题。因此,SELU 激活函数可能在 MLP 中胜过其他激活函数,尤其是深层网络。要在 Keras 中使用它,只需设置activation="selu"。然而,自标准化发生的条件有一些(请参阅论文进行数学证明):

  • 输入特征必须标准化:均值为 0,标准差为 1。

  • 每个隐藏层的权重必须使用 LeCun 正态初始化。在 Keras 中,这意味着设置kernel_initializer="lecun_normal"

  • 只有在普通 MLP 中才能保证自标准化属性。如果尝试在其他架构中使用 SELU,如循环网络(参见第十五章)或具有跳跃连接(即跳过层的连接,例如在 Wide & Deep 网络中),它可能不会胜过 ELU。

  • 您不能使用正则化技术,如ℓ[1]或ℓ[2]正则化、最大范数、批量归一化或常规的 dropout(这些将在本章后面讨论)。

这些是重要的限制条件,因此尽管 SELU 有所承诺,但并没有获得很大的关注。此外,另外三种激活函数似乎在大多数任务上表现出色:GELU、Swish 和 Mish。

GELU、Swish 和 Mish

GELU是由 Dan Hendrycks 和 Kevin Gimpel 在2016 年的一篇论文中引入的。再次,您可以将其视为 ReLU 激活函数的平滑变体。其定义在方程 11-3 中给出,其中Φ是标准高斯累积分布函数(CDF):Φ(z)对应于从均值为 0、方差为 1 的正态分布中随机抽取的值低于z的概率。

方程 11-3. GELU 激活函数

GELU(z)=zΦ(z)

如您在图 11-4 中所见,GELU 类似于 ReLU:当其输入z非常负时,它接近 0,当z非常正时,它接近z。然而,到目前为止我们讨论的所有激活函数都是凸函数且单调递增的,而 GELU 激活函数则不是:从左到右,它开始直线上升,然后下降,达到大约-0.17 的低点(接近 z≈-0.75),最后反弹上升并最终向右上方直线前进。这种相当复杂的形状以及它在每个点上都有曲率的事实可能解释了为什么它效果如此好,尤其是对于复杂任务:梯度下降可能更容易拟合复杂模式。在实践中,它通常优于迄今讨论的任何其他激活函数。然而,它的计算成本稍高,提供的性能提升并不总是足以证明额外成本的必要性。尽管如此,可以证明它大致等于zσ(1.702 z),其中σ是 sigmoid 函数:使用这个近似也非常有效,并且计算速度更快。

mls3 1104

图 11-4. GELU、Swish、参数化 Swish 和 Mish 激活函数

GELU 论文还介绍了sigmoid linear unit(SiLU)激活函数,它等于zσ(z),但在作者的测试中被 GELU 表现得更好。有趣的是,Prajit Ramachandran 等人在2017 年的一篇论文中重新发现了 SiLU 函数,通过自动搜索好的激活函数。作者将其命名为Swish,这个名字很受欢迎。在他们的论文中,Swish 表现优于其他所有函数,包括 GELU。Ramachandran 等人后来通过添加额外的超参数β来推广 Swish,用于缩放 sigmoid 函数的输入。推广后的 Swish 函数为 Swishβ = zσ(βz),因此 GELU 大致等于使用β = 1.702 的推广 Swish 函数。您可以像调整其他超参数一样调整β。另外,也可以将β设置为可训练的,让梯度下降来优化它:这样可以使您的模型更加强大,但也会有过拟合数据的风险。

另一个相当相似的激活函数是Mish,它是由 Diganta Misra 在2019 年的一篇论文中引入的。它被定义为 mish(z) = ztanh(softplus(z)),其中 softplus(z) = log(1 + exp(z))。就像 GELU 和 Swish 一样,它是 ReLU 的平滑、非凸、非单调变体,作者再次进行了许多实验,并发现 Mish 通常优于其他激活函数,甚至比 Swish 和 GELU 稍微好一点。图 11-4 展示了 GELU、Swish(默认β = 1 和β = 0.6)、最后是 Mish。如您所见,当z为负时,Mish 几乎完全重叠于 Swish,当z为正时,几乎完全重叠于 GELU。

提示

那么,对于深度神经网络的隐藏层,你应该使用哪种激活函数?对于简单任务,ReLU 仍然是一个很好的默认选择:它通常和更复杂的激活函数一样好,而且计算速度非常快,许多库和硬件加速器提供了 ReLU 特定的优化。然而,对于更复杂的任务,Swish 可能是更好的默认选择,甚至可以尝试带有可学习β参数的参数化 Swish 来处理最复杂的任务。Mish 可能会给出稍微更好的结果,但需要更多的计算。如果你非常关心运行时延迟,那么你可能更喜欢 leaky ReLU,或者对于更复杂的任务,可以使用参数化 leaky ReLU。对于深度 MLP,可以尝试使用 SELU,但一定要遵守之前列出的约束条件。如果你有多余的时间和计算能力,也可以使用交叉验证来评估其他激活函数。

Keras 支持 GELU 和 Swish,只需使用activation="gelu"activation="swish"。然而,它目前不支持 Mish 或广义 Swish 激活函数(但请参阅第十二章了解如何实现自己的激活函数和层)。

激活函数就介绍到这里!现在,让我们看一种完全不同的解决不稳定梯度问题的方法:批量归一化。

批量归一化

尽管使用 He 初始化与 ReLU(或其任何变体)可以显著减少训练开始时梯度消失/爆炸问题的危险,但并不能保证它们在训练过程中不会再次出现。

在一篇2015 年的论文中,Sergey Ioffe 和 Christian Szegedy 提出了一种称为批量归一化(BN)的技术,解决了这些问题。该技术包括在模型中在每个隐藏层的激活函数之前或之后添加一个操作。这个操作简单地将每个输入零中心化和归一化,然后使用每层两个新的参数向量进行缩放和移位:一个用于缩放,另一个用于移位。换句话说,该操作让模型学习每个层输入的最佳缩放和均值。在许多情况下,如果将 BN 层作为神经网络的第一层,您就不需要标准化训练集。也就是说,不需要StandardScalerNormalization;BN 层会为您完成(大致上,因为它一次只看一个批次,并且还可以重新缩放和移位每个输入特征)。

为了将输入零中心化和归一化,算法需要估计每个输入的均值和标准差。它通过评估当前小批量输入的均值和标准差来实现这一点(因此称为“批量归一化”)。整个操作在方程式 11-4 中逐步总结。

方程式 11-4. 批量归一化算法

1 . μ B = 1 m B i=1 m B x (i) 2 . σ B 2 = 1 m B i=1 m B (x (i) -μ B ) 2 3 . x ^ (i) = x (i) -μ B σ B 2 +ε 4 . z (i) = γ x ^ (i) + β

在这个算法中:

  • μ[B] 是在整个小批量B上评估的输入均值向量(它包含每个输入的一个均值)。

  • m[B] 是小批量中实例的数量。

  • σ[B] 是输入标准差的向量,也是在整个小批量上评估的(它包含每个输入的一个标准差)。

  • x ^ ^((i)) 是实例i的零中心化和归一化输入向量。

  • ε 是一个微小的数字,避免了除以零,并确保梯度不会增长太大(通常为 10^(–5))。这被称为平滑项

  • γ 是该层的输出比例参数向量(它包含每个输入的一个比例参数)。

  • ⊗ 表示逐元素乘法(每个输入都会乘以其对应的输出比例参数)。

  • β 是该层的输出偏移参数向量(它包含每个输入的一个偏移参数)。每个输入都会被其对应的偏移参数偏移。

  • z^((i)) 是 BN 操作的输出。它是输入的重新缩放和偏移版本。

因此,在训练期间,BN 会标准化其输入,然后重新缩放和偏移它们。很好!那么,在测试时呢?嗯,事情并不那么简单。实际上,我们可能需要为单个实例而不是一批实例进行预测:在这种情况下,我们将无法计算每个输入的均值和标准差。此外,即使我们有一批实例,它可能太小,或者实例可能不是独立且同分布的,因此在批次实例上计算统计数据将是不可靠的。一个解决方案可能是等到训练结束,然后通过神经网络运行整个训练集,并计算 BN 层每个输入的均值和标准差。这些“最终”输入均值和标准差可以在进行预测时代替批次输入均值和标准差。然而,大多数批次归一化的实现在训练期间通过使用该层输入均值和标准差的指数移动平均值来估计这些最终统计数据。这就是当您使用BatchNormalization层时 Keras 自动执行的操作。总之,在每个批次归一化的层中学习了四个参数向量:γ(输出缩放向量)和β(输出偏移向量)通过常规反向传播学习,而μ(最终输入均值向量)和σ(最终输入标准差向量)则使用指数移动平均值进行估计。请注意,μσ是在训练期间估计的,但仅在训练后使用(以替换公式 11-4 中的批次输入均值和标准差)。

Ioffe 和 Szegedy 证明了批次归一化显著改善了他们进行实验的所有深度神经网络,从而在 ImageNet 分类任务中取得了巨大的改进(ImageNet 是一个大型图像数据库,被分类为许多类别,通常用于评估计算机视觉系统)。梯度消失问题得到了很大程度的减轻,以至于他们可以使用饱和激活函数,如 tanh 甚至 sigmoid 激活函数。网络对权重初始化也不那么敏感。作者能够使用更大的学习率,显著加快学习过程。具体来说,他们指出:

应用于最先进的图像分类模型,批次归一化在 14 倍更少的训练步骤下实现了相同的准确性,并且以显著的优势击败了原始模型。[...] 使用一组批次归一化的网络,我们在 ImageNet 分类上取得了最佳发布结果:达到 4.9%的前 5 验证错误率(和 4.8%的测试错误率),超过了人类评分者的准确性。

最后,就像一份源源不断的礼物,批次归一化就像一个正则化器,减少了对其他正则化技术(如本章后面描述的 dropout)的需求。

然而,批量归一化确实给模型增加了一些复杂性(尽管它可以消除对输入数据进行归一化的需要,如前面讨论的)。此外,还存在运行时惩罚:由于每一层需要额外的计算,神经网络的预测速度变慢。幸运的是,通常可以在训练后将 BN 层与前一层融合在一起,从而避免运行时惩罚。这是通过更新前一层的权重和偏置,使其直接产生适当规模和偏移的输出来实现的。例如,如果前一层计算XW + b,那么 BN 层将计算γ ⊗ (XW + b - μ) / σ + β(忽略分母中的平滑项ε)。如果我们定义W′ = γW / σb′ = γ ⊗ (b - μ) / σ + β,则方程简化为XW′ + b′。因此,如果我们用更新后的权重和偏置(W′和b′)替换前一层的权重和偏置(Wb),我们可以摆脱 BN 层(TFLite 的转换器会自动执行此操作;请参阅第十九章)。

注意

您可能会发现训练速度相当慢,因为使用批量归一化时,每个时期需要更多的时间。通常,这通常会被 BN 的收敛速度更快所抵消,因此需要更少的时期才能达到相同的性能。总的来说,墙上的时间通常会更短(这是您墙上时钟上测量的时间)。

使用 Keras 实现批量归一化

与 Keras 的大多数事物一样,实现批量归一化是简单直观的。只需在每个隐藏层的激活函数之前或之后添加一个BatchNormalization层。您还可以将 BN 层添加为模型中的第一层,但通常在此位置使用普通的Normalization层效果一样好(它的唯一缺点是您必须首先调用其adapt()方法)。例如,这个模型在每个隐藏层后应用 BN,并将其作为模型中的第一层(在展平输入图像之后):

model = tf.keras.Sequential([
    tf.keras.layers.Flatten(input_shape=[28, 28]),
    tf.keras.layers.BatchNormalization(),
    tf.keras.layers.Dense(300, activation="relu",
                          kernel_initializer="he_normal"),
    tf.keras.layers.BatchNormalization(),
    tf.keras.layers.Dense(100, activation="relu",
                          kernel_initializer="he_normal"),
    tf.keras.layers.BatchNormalization(),
    tf.keras.layers.Dense(10, activation="softmax")
])

就这样!在这个只有两个隐藏层的微小示例中,批量归一化不太可能产生很大的影响,但对于更深的网络,它可能产生巨大的差异。

让我们显示模型摘要:

>>> model.summary()
Model: "sequential"
_________________________________________________________________
Layer (type)                 Output Shape              Param #
=================================================================
flatten (Flatten)            (None, 784)               0
_________________________________________________________________
batch_normalization (BatchNo (None, 784)               3136
_________________________________________________________________
dense (Dense)                (None, 300)               235500
_________________________________________________________________
batch_normalization_1 (Batch (None, 300)               1200
_________________________________________________________________
dense_1 (Dense)              (None, 100)               30100
_________________________________________________________________
batch_normalization_2 (Batch (None, 100)               400
_________________________________________________________________
dense_2 (Dense)              (None, 10)                1010
=================================================================
Total params: 271,346
Trainable params: 268,978
Non-trainable params: 2,368
_________________________________________________________________

正如您所看到的,每个 BN 层都会为每个输入添加四个参数:γβμσ(例如,第一个 BN 层会添加 3,136 个参数,即 4×784)。最后两个参数,μσ,是移动平均值;它们不受反向传播的影响,因此 Keras 将它们称为“不可训练”⁠¹³(如果您计算 BN 参数的总数,3,136 + 1,200 + 400,然后除以 2,您将得到 2,368,这是该模型中不可训练参数的总数)。

让我们看看第一个 BN 层的参数。其中两个是可训练的(通过反向传播),另外两个不是:

>>> [(var.name, var.trainable) for var in model.layers[1].variables]
[('batch_normalization/gamma:0', True),
 ('batch_normalization/beta:0', True),
 ('batch_normalization/moving_mean:0', False),
 ('batch_normalization/moving_variance:0', False)]

BN 论文的作者主张在激活函数之前而不是之后添加 BN 层(就像我们刚刚做的那样)。关于这一点存在一些争论,因为哪种方式更可取似乎取决于任务-您也可以尝试这个来看看哪个选项在您的数据集上效果最好。要在激活函数之前添加 BN 层,您必须从隐藏层中删除激活函数,并在 BN 层之后作为单独的层添加它们。此外,由于批量归一化层包含每个输入的一个偏移参数,您可以在创建时通过传递use_bias=False来删除前一层的偏置项。最后,通常可以删除第一个 BN 层,以避免将第一个隐藏层夹在两个 BN 层之间。更新后的代码如下:

model = tf.keras.Sequential([
    tf.keras.layers.Flatten(input_shape=[28, 28]),
    tf.keras.layers.Dense(300, kernel_initializer="he_normal", use_bias=False),
    tf.keras.layers.BatchNormalization(),
    tf.keras.layers.Activation("relu"),
    tf.keras.layers.Dense(100, kernel_initializer="he_normal", use_bias=False),
    tf.keras.layers.BatchNormalization(),
    tf.keras.layers.Activation("relu"),
    tf.keras.layers.Dense(10, activation="softmax")
])

BatchNormalization类有很多可以调整的超参数。默认值通常是可以的,但偶尔您可能需要调整momentum。当BatchNormalization层更新指数移动平均值时,该超参数将被使用;给定一个新值v(即,在当前批次上计算的新的输入均值或标准差向量),该层使用以下方程更新运行平均值v^

v ^ v ^ × momentum + v × ( 1 - momentum )

一个良好的动量值通常接近于 1;例如,0.9,0.99 或 0.999。对于更大的数据集和更小的小批量,您希望有更多的 9。

另一个重要的超参数是axis:它确定应该对哪个轴进行归一化。默认为-1,这意味着默认情况下将归一化最后一个轴(使用在其他轴上计算的均值和标准差)。当输入批次为 2D(即,批次形状为[批次大小,特征])时,这意味着每个输入特征将基于在批次中所有实例上计算的均值和标准差进行归一化。例如,前面代码示例中的第一个 BN 层将独立地归一化(和重新缩放和移位)784 个输入特征中的每一个。如果我们将第一个 BN 层移到Flatten层之前,那么输入批次将是 3D,形状为[批次大小,高度,宽度];因此,BN 层将计算 28 个均值和 28 个标准差(每个像素列一个,跨批次中的所有实例和列中的所有行计算),并且将使用相同的均值和标准差归一化给定列中的所有像素。还将有 28 个比例参数和 28 个移位参数。如果您仍希望独立处理 784 个像素中的每一个,则应将axis=[1, 2]

批量归一化已经成为深度神经网络中最常用的层之一,特别是在深度卷积神经网络中讨论的(第十四章),以至于在架构图中通常被省略:假定在每一层之后都添加了 BN。现在让我们看看最后一种稳定梯度的技术:梯度裁剪。

梯度裁剪

另一种缓解梯度爆炸问题的技术是在反向传播过程中裁剪梯度,使其永远不超过某个阈值。这被称为梯度裁剪。⁠¹⁴ 这种技术通常用于循环神经网络中,其中使用批量归一化是棘手的(正如您将在第十五章中看到的)。

在 Keras 中,实现梯度裁剪只需要在创建优化器时设置clipvalueclipnorm参数,就像这样:

optimizer = tf.keras.optimizers.SGD(clipvalue=1.0)
model.compile([...], optimizer=optimizer)

这个优化器将梯度向量的每个分量剪切到-1.0 和 1.0 之间的值。这意味着损失的所有偏导数(对每个可训练参数)将在-1.0 和 1.0 之间被剪切。阈值是您可以调整的超参数。请注意,这可能会改变梯度向量的方向。例如,如果原始梯度向量是[0.9, 100.0],它主要指向第二轴的方向;但是一旦您按值剪切它,您会得到[0.9, 1.0],它大致指向两个轴之间的对角线。在实践中,这种方法效果很好。如果您希望确保梯度剪切不改变梯度向量的方向,您应该通过设置clipnorm而不是clipvalue来按范数剪切。如果其ℓ[2]范数大于您选择的阈值,则会剪切整个梯度。例如,如果设置clipnorm=1.0,那么向量[0.9, 100.0]将被剪切为[0.00899964, 0.9999595],保持其方向但几乎消除第一个分量。如果您观察到梯度在训练过程中爆炸(您可以使用 TensorBoard 跟踪梯度的大小),您可能希望尝试按值剪切或按范数剪切,使用不同的阈值,看看哪个选项在验证集上表现最好。

重用预训练层

通常不建议从头开始训练一个非常大的 DNN,而不是先尝试找到一个现有的神经网络,完成与您尝试解决的任务类似的任务(我将在第十四章中讨论如何找到它们)。如果找到这样的神经网络,那么通常可以重用大部分层,除了顶部的层。这种技术称为迁移学习。它不仅会显著加快训练速度,而且需要的训练数据明显较少。

假设您可以访问一个经过训练的 DNN,用于将图片分类为 100 个不同的类别,包括动物、植物、车辆和日常物品,现在您想要训练一个 DNN 来分类特定类型的车辆。这些任务非常相似,甚至部分重叠,因此您应该尝试重用第一个网络的部分(参见图 11-5)。

注意

如果您新任务的输入图片与原始任务中使用的图片大小不同,通常需要添加一个预处理步骤,将它们调整为原始模型期望的大小。更一般地说,当输入具有相似的低级特征时,迁移学习效果最好。

mls3 1105

图 11-5。重用预训练层

通常应该替换原始模型的输出层,因为它很可能对新任务没有用处,而且可能不会有正确数量的输出。

同样,原始模型的上层隐藏层不太可能像下层那样有用,因为对于新任务最有用的高级特征可能与对原始任务最有用的特征有很大不同。您需要找到要重用的正确层数。

提示

任务越相似,您将希望重用的层次就越多(从较低层次开始)。对于非常相似的任务,尝试保留所有隐藏层,只替换输出层。

首先尝试冻结所有重用的层(即使它们的权重不可训练,以便梯度下降不会修改它们并保持固定),然后训练您的模型并查看其表现。然后尝试解冻顶部一两个隐藏层,让反向传播调整它们,看看性能是否提高。您拥有的训练数据越多,您可以解冻的层次就越多。解冻重用层时降低学习率也很有用:这将避免破坏它们微调的权重。

如果您仍然无法获得良好的性能,并且训练数据很少,尝试删除顶部隐藏层并再次冻结所有剩余的隐藏层。您可以迭代直到找到要重用的正确层数。如果您有大量训练数据,您可以尝试替换顶部隐藏层而不是删除它们,甚至添加更多隐藏层。

使用 Keras 进行迁移学习

让我们看一个例子。假设时尚 MNIST 数据集仅包含八个类别,例如除凉鞋和衬衫之外的所有类别。有人在该数据集上构建并训练了一个 Keras 模型,并获得了相当不错的性能(>90%的准确率)。我们将这个模型称为 A。现在您想要解决一个不同的任务:您有 T 恤和套头衫的图像,并且想要训练一个二元分类器:对于 T 恤(和上衣)为正,对于凉鞋为负。您的数据集非常小;您只有 200 张带标签的图像。当您为这个任务训练一个新模型(我们称之为模型 B),其架构与模型 A 相同时,您获得了 91.85%的测试准确率。在喝早晨咖啡时,您意识到您的任务与任务 A 非常相似,因此也许迁移学习可以帮助?让我们找出来!

首先,您需要加载模型 A 并基于该模型的层创建一个新模型。您决定重用除输出层以外的所有层:

[...]  # Assuming model A was already trained and saved to "my_model_A"
model_A = tf.keras.models.load_model("my_model_A")
model_B_on_A = tf.keras.Sequential(model_A.layers[:-1])
model_B_on_A.add(tf.keras.layers.Dense(1, activation="sigmoid"))

请注意,model_Amodel_B_on_A现在共享一些层。当您训练model_B_on_A时,它也会影响model_A。如果您想避免这种情况,您需要在重用其层之前克隆model_A。为此,您可以使用clone_model()克隆模型 A 的架构,然后复制其权重:

model_A_clone = tf.keras.models.clone_model(model_A)
model_A_clone.set_weights(model_A.get_weights())
警告

tf.keras.models.clone_model()仅克隆架构,而不是权重。如果您不使用set_weights()手动复制它们,那么当首次使用克隆模型时,它们将被随机初始化。

现在您可以为任务 B 训练model_B_on_A,但由于新的输出层是随机初始化的,它将产生大误差(至少在最初的几个时期),因此会产生大误差梯度,可能会破坏重用的权重。为了避免这种情况,一种方法是在最初的几个时期内冻结重用的层,让新层有时间学习合理的权重。为此,将每个层的trainable属性设置为False并编译模型:

for layer in model_B_on_A.layers[:-1]:
    layer.trainable = False

optimizer = tf.keras.optimizers.SGD(learning_rate=0.001)
model_B_on_A.compile(loss="binary_crossentropy", optimizer=optimizer,
                     metrics=["accuracy"])
注意

在冻结或解冻层之后,您必须始终编译您的模型。

现在您可以为模型训练几个时期,然后解冻重用的层(这需要重新编译模型)并继续训练以微调任务 B 的重用层。在解冻重用的层之后,通常最好降低学习率,再次避免损坏重用的权重。

history = model_B_on_A.fit(X_train_B, y_train_B, epochs=4,
                           validation_data=(X_valid_B, y_valid_B))

for layer in model_B_on_A.layers[:-1]:
    layer.trainable = True

optimizer = tf.keras.optimizers.SGD(learning_rate=0.001)
model_B_on_A.compile(loss="binary_crossentropy", optimizer=optimizer,
                     metrics=["accuracy"])
history = model_B_on_A.fit(X_train_B, y_train_B, epochs=16,
                           validation_data=(X_valid_B, y_valid_B))

那么,最终的结论是什么?好吧,这个模型的测试准确率为 93.85%,比 91.85%高出两个百分点!这意味着迁移学习将错误率减少了近 25%:

>>> model_B_on_A.evaluate(X_test_B, y_test_B)
[0.2546142041683197, 0.9384999871253967]

您相信了吗?您不应该相信:我作弊了!我尝试了许多配置,直到找到一个表现出强烈改进的配置。如果您尝试更改类别或随机种子,您会发现改进通常会下降,甚至消失或反转。我所做的被称为“折磨数据直到它招认”。当一篇论文看起来过于积极时,您应该持怀疑态度:也许这种花哨的新技术实际上并没有太大帮助(事实上,它甚至可能降低性能),但作者尝试了许多变体并仅报告了最佳结果(这可能仅仅是纯粹的运气),而没有提及他们在过程中遇到了多少失败。大多数情况下,这并不是恶意的,但这是科学中许多结果永远无法重现的原因之一。

为什么我作弊了?事实证明,迁移学习在小型密集网络上效果不佳,可能是因为小型网络学习的模式较少,而密集网络学习的是非常具体的模式,这些模式不太可能在其他任务中有用。迁移学习最适用于深度卷积神经网络,这些网络倾向于学习更通用的特征检测器(特别是在较低层)。我们将在第十四章中重新讨论迁移学习,使用我们刚讨论的技术(这次不会作弊,我保证!)。

无监督预训练

假设您想要解决一个复杂的任务,但您没有太多标记的训练数据,而不幸的是,您找不到一个类似任务训练的模型。不要失去希望!首先,您应该尝试收集更多标记的训练数据,但如果您无法做到,您仍然可以执行无监督预训练(见图 11-6)。事实上,收集未标记的训练示例通常很便宜,但标记它们却很昂贵。如果您可以收集大量未标记的训练数据,您可以尝试使用它们来训练一个无监督模型,例如自编码器或生成对抗网络(GAN;见第十七章)。然后,您可以重复使用自编码器的较低层或 GAN 的鉴别器的较低层,添加顶部的输出层,然后使用监督学习(即使用标记的训练示例)微调最终网络。

正是这种技术在 2006 年由 Geoffrey Hinton 及其团队使用,导致了神经网络的复兴和深度学习的成功。直到 2010 年,无监督预训练(通常使用受限玻尔兹曼机(RBMs;请参阅https://homl.info/extra-anns中的笔记本))是深度网络的标准,只有在消失梯度问题得到缓解后,纯粹使用监督学习训练 DNN 才变得更加普遍。无监督预训练(今天通常使用自编码器或 GAN,而不是 RBMs)仍然是一个很好的选择,当您有一个复杂的任务需要解决,没有类似的可重用模型,但有大量未标记的训练数据时。

请注意,在深度学习的早期阶段,训练深度模型是困难的,因此人们会使用一种称为贪婪逐层预训练的技术(在图 11-6 中描述)。他们首先使用单层训练一个无监督模型,通常是一个 RBM,然后冻结该层并在其顶部添加另一层,然后再次训练模型(实际上只是训练新层),然后冻结新层并在其顶部添加另一层,再次训练模型,依此类推。如今,事情简单得多:人们通常一次性训练完整的无监督模型,并使用自编码器或 GAN,而不是 RBMs。

mls3 1106

图 11-6。在无监督训练中,模型使用无监督学习技术在所有数据上进行训练,包括未标记的数据,然后使用监督学习技术仅在标记的数据上对最终任务进行微调;无监督部分可以像这里所示一次训练一层,也可以直接训练整个模型

辅助任务上的预训练

如果您没有太多标记的训练数据,最后一个选择是在一个辅助任务上训练第一个神经网络,您可以轻松获取或生成标记的训练数据,然后重复使用该网络的较低层来执行实际任务。第一个神经网络的较低层将学习特征检测器,很可能可以被第二个神经网络重复使用。

例如,如果您想构建一个识别人脸的系统,您可能只有每个个体的少量图片,显然不足以训练一个良好的分类器。收集每个人数百张照片是不现实的。但是,您可以在网络上收集大量随机人的照片,并训练第一个神经网络来检测两张不同图片是否展示了同一个人。这样的网络将学习良好的人脸特征检测器,因此重用其较低层将允许您训练一个使用很少训练数据的良好人脸分类器。

对于自然语言处理(NLP)应用,您可以下载数百万个文本文档的语料库,并从中自动生成标记数据。例如,您可以随机屏蔽一些单词并训练模型来预测缺失的单词是什么(例如,它应该预测句子“What ___ you saying?”中缺失的单词可能是“are”或“were”)。如果您可以训练模型在这个任务上达到良好的性能,那么它将已经对语言有相当多的了解,您肯定可以在实际任务中重复使用它,并在标记数据上进行微调(我们将在第十五章中讨论更多的预训练任务)。

注意

自监督学习是指从数据本身自动生成标签,例如文本屏蔽示例,然后使用监督学习技术在生成的“标记”数据集上训练模型。

更快的优化器

训练一个非常庞大的深度神经网络可能会非常缓慢。到目前为止,我们已经看到了四种加速训练(并达到更好解决方案)的方法:应用良好的连接权重初始化策略,使用良好的激活函数,使用批量归一化,并重用预训练网络的部分(可能是为辅助任务构建的或使用无监督学习)。另一个巨大的加速来自使用比常规梯度下降优化器更快的优化器。在本节中,我们将介绍最流行的优化算法:动量、Nesterov 加速梯度、AdaGrad、RMSProp,最后是 Adam 及其变体。

动量

想象一颗保龄球在光滑表面上缓坡滚动:它会从慢慢开始,但很快会积累动量,直到最终达到终端速度(如果有一些摩擦或空气阻力)。这就是动量优化的核心思想,由鲍里斯·波利亚克在 1964 年提出。与此相反,常规梯度下降在坡度平缓时会采取小步骤,在坡度陡峭时会采取大步骤,但它永远不会加速。因此,与动量优化相比,常规梯度下降通常要慢得多才能达到最小值。

请记住,梯度下降通过直接减去成本函数J(θ)相对于权重的梯度(∇[θ]J(θ))乘以学习率η来更新权重θ。方程式为θθ - η∇[θ]J(θ)。它不关心先前的梯度是什么。如果局部梯度很小,它会走得很慢。

动量优化非常关注先前梯度是什么:在每次迭代中,它从动量向量 m(乘以学习率η)中减去局部梯度,然后通过添加这个动量向量来更新权重(参见方程 11-5)。换句话说,梯度被用作加速度,而不是速度。为了模拟某种摩擦机制并防止动量增长过大,该算法引入了一个新的超参数β,称为动量,必须设置在 0(高摩擦)和 1(无摩擦)之间。典型的动量值为 0.9。

方程 11-5. 动量算法

1 . m β m - η θ J ( θ ) 2 . θ θ + m

您可以验证,如果梯度保持不变,则终端速度(即权重更新的最大大小)等于该梯度乘以学习率η乘以 1 / (1 - β)(忽略符号)。例如,如果β = 0.9,则终端速度等于梯度乘以学习率的 10 倍,因此动量优化的速度比梯度下降快 10 倍!这使得动量优化比梯度下降更快地摆脱高原。我们在第四章中看到,当输入具有非常不同的比例时,成本函数看起来像一个拉长的碗(参见图 4-7)。梯度下降很快下降陡峭的斜坡,但然后需要很长时间才能下降到山谷。相比之下,动量优化将会越来越快地滚动到山谷,直到达到底部(最优解)。在不使用批量归一化的深度神经网络中,上层通常会出现具有非常不同比例的输入,因此使用动量优化会有很大帮助。它还可以帮助跳过局部最优解。

注意

由于动量的原因,优化器可能会稍微超调,然后返回,再次超调,并在稳定在最小值之前多次振荡。这是有摩擦力的好处之一:它消除了这些振荡,从而加快了收敛速度。

在 Keras 中实现动量优化是一件轻而易举的事情:只需使用SGD优化器并设置其momentum超参数,然后躺下来赚钱!

optimizer = tf.keras.optimizers.SGD(learning_rate=0.001, momentum=0.9)

动量优化的一个缺点是它增加了另一个需要调整的超参数。然而,在实践中,动量值 0.9 通常效果很好,几乎总是比常规梯度下降更快。

Nesterov 加速梯度

动量优化的一个小变体,由Yurii Nesterov 于 1983 年提出,¹⁶几乎总是比常规动量优化更快。Nesterov 加速梯度(NAG)方法,也称为Nesterov 动量优化,测量成本函数的梯度不是在本地位置θ处,而是稍微向前在动量方向,即θ + βm(参见方程 11-6)。

第 11-6 方程。Nesterov 加速梯度算法

1 . m β m - η θ J ( θ + β m ) 2 . θ θ + m

这个小调整有效是因为通常动量向量将指向正确的方向(即朝向最优解),因此使用稍微更准确的梯度测量更有利于使用稍微更远处的梯度,而不是原始位置处的梯度,如您在图 11-7 中所见(其中∇[1]表示在起始点θ处测量的成本函数的梯度,而∇[2]表示在位于θ + βm的点处测量的梯度)。

mls3 1107

图 11-7。常规与 Nesterov 动量优化:前者应用动量步骤之前计算的梯度,而后者应用动量步骤之后计算的梯度

如您所见,Nesterov 更新最终更接近最优解。随着时间的推移,这些小的改进累积起来,NAG 最终比常规动量优化快得多。此外,请注意,当动量将权重推过山谷时,∇[1]继续推动更远,而∇[2]则向山谷底部推回。这有助于减少振荡,因此 NAG 收敛更快。

要使用 NAG,只需在创建SGD优化器时设置nesterov=True

optimizer = tf.keras.optimizers.SGD(learning_rate=0.001, momentum=0.9,
                                    nesterov=True)

AdaGrad

考虑再次延长碗问题:梯度下降首先快速沿着最陡的斜坡下降,这并不直指全局最优解,然后它非常缓慢地下降到山谷底部。如果算法能够更早地纠正方向,使其更多地指向全局最优解,那将是很好的。AdaGrad算法通过沿着最陡的维度缩小梯度向量来实现这种校正(参见方程 11-7)。

方程 11-7。AdaGrad 算法

1 . s s + θ J ( θ ) θ J ( θ ) 2 . θ θ - η θ J ( θ ) s + ε

第一步将梯度的平方累积到向量s中(请记住,⊗符号表示逐元素乘法)。这种向量化形式等同于计算s[i] ← s[i] + (∂J(θ)/∂θ[i])²,对于向量s的每个元素s[i]来说,换句话说,每个s[i]累积了成本函数对参数θ[i]的偏导数的平方。如果成本函数沿第i维陡峭,那么在每次迭代中s[i]将变得越来越大。

第二步几乎与梯度下降完全相同,但有一个重大区别:梯度向量被一个因子s+ε缩小(⊘符号表示逐元素除法,ε是一个平滑项,用于避免除以零,通常设置为 10^(–10))。这个向量化形式等价于同时计算所有参数θ[i]的θiθi-ηJ(θ)/θi/si+ε

简而言之,这个算法会衰减学习率,但对于陡峭的维度比对于坡度较缓的维度衰减得更快。这被称为自适应学习率。它有助于更直接地指向全局最优(参见图 11-8)。另一个好处是它需要更少的调整学习率超参数η

mls3 1108

图 11-8. AdaGrad 与梯度下降的比较:前者可以更早地纠正方向指向最优点

在简单的二次问题上,AdaGrad 通常表现良好,但在训练神经网络时经常会过早停止:学习率被缩小得太多,以至于算法最终在达到全局最优之前完全停止。因此,即使 Keras 有一个Adagrad优化器,你也不应该用它来训练深度神经网络(尽管对于简单任务如线性回归可能是有效的)。不过,理解 AdaGrad 有助于理解其他自适应学习率优化器。

RMSProp

正如我们所见,AdaGrad 有减速得太快并且永远无法收敛到全局最优的风险。RMSProp算法⁠¹⁸通过仅累积最近迭代的梯度来修复这个问题,而不是自训练开始以来的所有梯度。它通过在第一步中使用指数衰减来实现这一点(参见方程 11-8)。

方程 11-8. RMSProp 算法

1 . s ρ s + ( 1 - ρ ) θ J ( θ ) θ J ( θ ) 2 . θ θ - η θ J ( θ ) s + ε

衰减率ρ通常设置为 0.9。⁠¹⁹ 是的,这又是一个新的超参数,但这个默认值通常效果很好,所以你可能根本不需要调整它。

正如你所期望的,Keras 有一个RMSprop优化器:

optimizer = tf.keras.optimizers.RMSprop(learning_rate=0.001, rho=0.9)

除了在非常简单的问题上,这个优化器几乎总是比 AdaGrad 表现得更好。事实上,直到 Adam 优化算法出现之前,它一直是许多研究人员首选的优化算法。

亚当

Adam,代表自适应矩估计,结合了动量优化和 RMSProp 的思想:就像动量优化一样,它跟踪过去梯度的指数衰减平均值;就像 RMSProp 一样,它跟踪过去梯度的平方的指数衰减平均值(见 Equation 11-9)。这些是梯度的均值和(未居中)方差的估计。均值通常称为第一时刻,而方差通常称为第二时刻,因此算法的名称。

方程 11-9. Adam 算法

1 . m β 1 m - ( 1 - β 1 ) θ J ( θ ) 2 . s β 2 s + ( 1 - β 2 ) θ J ( θ ) θ J ( θ ) 3 . m^ m 1 - β 1 t 4 . s^ s 1-β 2 t 5 . θ θ + η m^ s^ + ε

在这个方程中,t代表迭代次数(从 1 开始)。

如果只看步骤 1、2 和 5,你会注意到 Adam 与动量优化和 RMSProp 的相似之处:β[1]对应于动量优化中的ββ[2]对应于 RMSProp 中的ρ。唯一的区别是步骤 1 计算的是指数衰减平均值而不是指数衰减和,但实际上这些是等价的,除了一个常数因子(衰减平均值只是衰减和的 1 - β[1]倍)。步骤 3 和 4 有点技术细节:由于ms初始化为 0,在训练开始时它们会偏向于 0,因此这两个步骤将有助于在训练开始时提升ms

动量衰减超参数β[1]通常初始化为 0.9,而缩放衰减超参数β[2]通常初始化为 0.999。与之前一样,平滑项ε通常初始化为一个非常小的数字,如 10^(–7)。这些是Adam类的默认值。以下是如何在 Keras 中创建 Adam 优化器的方法:

optimizer = tf.keras.optimizers.Adam(learning_rate=0.001, beta_1=0.9,
                                     beta_2=0.999)

由于 Adam 是一种自适应学习率算法,类似于 AdaGrad 和 RMSProp,它需要较少调整学习率超参数η。您通常可以使用默认值η=0.001,使得 Adam 比梯度下降更容易使用。

提示

如果您开始感到对所有这些不同技术感到不知所措,并想知道如何为您的任务选择合适的技术,不用担心:本章末尾提供了一些实用指南。

最后,值得一提的是 Adam 的三个变体:AdaMax、Nadam 和 AdamW。

AdaMax

Adam 论文还介绍了 AdaMax。请注意,在方程式 11-9 的第 2 步中,Adam 在s中累积梯度的平方(对于最近的梯度有更大的权重)。在第 5 步中,如果我们忽略ε和步骤 3 和 4(这些都是技术细节),Adam 通过s的平方根缩小参数更新。简而言之,Adam 通过时间衰减梯度的ℓ[2]范数缩小参数更新(回想一下,ℓ[2]范数是平方和的平方根)。

AdaMax 用ℓ[∞]范数(一种说法是最大值)替换了ℓ[2]范数。具体来说,它用smax(β2s, abs(θJ(θ)))替换了方程式 11-9 的第 2 步,删除了第 4 步,在第 5 步中,它通过s的因子缩小梯度更新,s是时间衰减梯度的绝对值的最大值。

实际上,这使得 AdaMax 比 Adam 更稳定,但这确实取决于数据集,总体上 Adam 表现更好。因此,如果您在某些任务上遇到 Adam 的问题,这只是另一个您可以尝试的优化器。

Nadam

Nadam 优化是 Adam 优化加上 Nesterov 技巧,因此它通常会比 Adam 收敛速度稍快。在介绍这种技术的研究报告中,研究员 Timothy Dozat 比较了许多不同的优化器在各种任务上的表现,发现 Nadam 通常优于 Adam,但有时会被 RMSProp 超越。

AdamW

AdamW是 Adam 的一个变体,它集成了一种称为权重衰减的正则化技术。权重衰减通过将模型的权重在每次训练迭代中乘以一个衰减因子,如 0.99,来减小权重的大小。这可能让您想起ℓ[2]正则化(在第四章介绍),它也旨在保持权重较小,事实上,可以在数学上证明,当使用 SGD 时,ℓ[2]正则化等效于权重衰减。然而,当使用 Adam 或其变体时,ℓ[2]正则化和权重衰减等效:实际上,将 Adam 与ℓ[2]正则化结合使用会导致模型通常不如 SGD 产生的模型泛化能力好。AdamW 通过正确地将 Adam 与权重衰减结合来解决这个问题。

警告

自适应优化方法(包括 RMSProp、Adam、AdaMax、Nadam 和 AdamW 优化)通常很好,快速收敛到一个好的解决方案。然而,阿希亚·C·威尔逊等人在一篇2017 年的论文中表明,它们可能导致在某些数据集上泛化能力较差的解决方案。因此,当您对模型的性能感到失望时,请尝试使用 NAG:您的数据集可能只是对自适应梯度过敏。还要关注最新的研究,因为它发展迅速。

要在 Keras 中使用 Nadam、AdaMax 或 AdamW,请将tf.keras.optimizers.Adam替换为tf.keras.optimizers.Nadamtf.keras.optimizers.Adamaxtf.keras.optimizers.experimental.AdamW。对于 AdamW,您可能需要调整weight_decay超参数。

到目前为止讨论的所有优化技术只依赖于一阶偏导数雅可比)。优化文献中还包含基于二阶偏导数海森,即雅可比的偏导数)的惊人算法。不幸的是,这些算法很难应用于深度神经网络,因为每个输出有n²个海森(其中n是参数的数量),而不是每个输出只有n个雅可比。由于 DNN 通常具有成千上万个参数甚至更多,第二阶优化算法通常甚至无法适应内存,即使能够适应,计算海森也太慢。

表 11-2 比较了到目前为止我们讨论过的所有优化器(是不好的,是平均的,是好的)。

表 11-2。优化器比较

收敛速度 收敛质量
SGD * ***
SGD(momentum=...) ** ***
SGD(momentum=..., nesterov=True) ** ***
Adagrad *** *(过早停止)
RMSprop *** ** or ***
Adam *** ** or ***
AdaMax *** ** or ***
Nadam *** ** or ***
AdamW *** ** or ***

学习率调度

找到一个好的学习率非常重要。如果设置得太高,训练可能会发散(如“梯度下降”中讨论的)。如果设置得太低,训练最终会收敛到最优解,但需要很长时间。如果设置得稍微偏高,它会在一开始就非常快地取得进展,但最终会围绕最优解打转,从未真正稳定下来。如果你的计算预算有限,你可能需要在训练收敛之前中断训练,得到一个次优解(参见图 11-9)。

mls3 1109

图 11-9。不同学习率η的学习曲线

如第十章中讨论的,您可以通过训练模型几百次,将学习率从一个非常小的值指数增加到一个非常大的值,然后查看学习曲线并选择一个略低于学习曲线开始迅速上升的学习率来找到一个好的学习率。然后,您可以重新初始化您的模型,并使用该学习率进行训练。

但是你可以比恒定学习率做得更好:如果你从一个较大的学习率开始,然后在训练停止快速取得进展时降低它,你可以比使用最佳恒定学习率更快地达到一个好的解。有许多不同的策略可以在训练过程中降低学习率。从一个低学习率开始,增加它,然后再次降低它也可能是有益的。这些策略被称为学习计划(我在第四章中简要介绍了这个概念)。这些是最常用的学习计划:

幂调度

将学习率设置为迭代次数t的函数:η(t) = η[0] / (1 + t/s)^(c)。初始学习率η[0],幂c(通常设置为 1)和步长s是超参数。学习率在每一步下降。经过s步,学习率降至η[0]的一半。再经过s步,它降至η[0]的 1/3,然后降至η[0]的 1/4,然后η[0]的 1/5,依此类推。正如您所看到的,这个调度首先快速下降,然后变得越来越慢。当然,幂调度需要调整η[0]和s(可能还有c)。

指数调度

将学习率设置为η(t) = η[0] 0.1^(t/s)。学习率将每s步逐渐降低 10 倍。虽然幂调度使学习率降低得越来越慢,指数调度则每s步将其降低 10 倍。

分段常数调度

在一些时期内使用恒定的学习率(例如,η[0] = 0.1,持续 5 个时期),然后在另一些时期内使用较小的学习率(例如,η[1] = 0.001,持续 50 个时期),依此类推。尽管这种解决方案可能效果很好,但需要调整以找出正确的学习率序列以及每个学习率使用的时间长度。

性能调度

N步测量验证错误(就像提前停止一样),当错误停止下降时,将学习率降低λ倍。

1cycle 调度

1cycle 是由 Leslie Smith 在2018 年的一篇论文中提出的。与其他方法相反,它从增加初始学习率η[0]开始,线性增长到训练中途的η[1]。然后在训练的第二半部分线性降低学习率至η[0],最后几个时期通过几个数量级的降低率(仍然是线性)来完成。最大学习率η[1]是使用我们用来找到最佳学习率的相同方法选择的,初始学习率η[0]通常低 10 倍。当使用动量时,我们首先使用高动量(例如 0.95),然后在训练的前半部分将其降低到较低的动量(例如 0.85,线性),然后在训练的后半部分将其提高到最大值(例如 0.95),最后几个时期使用该最大值。Smith 进行了许多实验,表明这种方法通常能够显著加快训练速度并达到更好的性能。例如,在流行的 CIFAR10 图像数据集上,这种方法仅在 100 个时期内达到了 91.9%的验证准确率,而通过标准方法(使用相同的神经网络架构)在 800 个时期内仅达到了 90.3%的准确率。这一壮举被称为超级收敛

Andrew Senior 等人在2013 年的一篇论文中比较了使用动量优化训练深度神经网络进行语音识别时一些最流行的学习调度的性能。作者得出结论,在这种情况下,性能调度和指数调度表现良好。他们更青睐指数调度,因为它易于调整,并且收敛到最佳解稍快。他们还提到,它比性能调度更容易实现,但在 Keras 中,这两个选项都很容易。也就是说,1cycle 方法似乎表现得更好。

在 Keras 中实现幂调度是最简单的选择——只需在创建优化器时设置衰减超参数:

optimizer = tf.keras.optimizers.SGD(learning_rate=0.01, decay=1e-4)

衰减s的倒数(将学习率除以一个单位所需的步数),Keras 假设c等于 1。

指数调度和分段调度也很简单。您首先需要定义一个函数,该函数接受当前 epoch 并返回学习率。例如,让我们实现指数调度:

def exponential_decay_fn(epoch):
    return 0.01 * 0.1 ** (epoch / 20)

如果您不想硬编码 η[0] 和 s,您可以创建一个返回配置函数的函数:

def exponential_decay(lr0, s):
    def exponential_decay_fn(epoch):
        return lr0 * 0.1 ** (epoch / s)
    return exponential_decay_fn

exponential_decay_fn = exponential_decay(lr0=0.01, s=20)

接下来,创建一个 LearningRateScheduler 回调,将调度函数传递给它,并将此回调传递给 fit() 方法:

lr_scheduler = tf.keras.callbacks.LearningRateScheduler(exponential_decay_fn)
history = model.fit(X_train, y_train, [...], callbacks=[lr_scheduler])

LearningRateScheduler 将在每个 epoch 开始时更新优化器的 learning_rate 属性。通常每个 epoch 更新一次学习率就足够了,但是如果您希望更频繁地更新它,例如在每一步,您可以随时编写自己的回调(请参阅本章笔记本中“指数调度”部分的示例)。在每一步更新学习率可能有助于处理每个 epoch 中的许多步骤。或者,您可以使用 tf.keras.​optimiz⁠ers.schedules 方法,稍后会进行描述。

提示

训练后,history.history["lr"] 可以让您访问训练过程中使用的学习率列表。

调度函数可以选择将当前学习率作为第二个参数。例如,以下调度函数将前一个学习率乘以 0.1^(1/20),这将导致相同的指数衰减(除了衰减现在从第 0 个 epoch 开始而不是第 1 个):

def exponential_decay_fn(epoch, lr):
    return lr * 0.1 ** (1 / 20)

这个实现依赖于优化器的初始学习率(与之前的实现相反),所以请确保适当设置它。

当您保存一个模型时,优化器及其学习率也会被保存。这意味着使用这个新的调度函数,您可以加载一个训练好的模型,并继续在离开的地方继续训练,没有问题。然而,如果您的调度函数使用 epoch 参数,情况就不那么简单了:epoch 不会被保存,并且每次调用 fit() 方法时都会被重置为 0。如果您要继续训练一个模型,这可能会导致一个非常大的学习率,这可能会损坏模型的权重。一个解决方案是手动设置 fit() 方法的 initial_epoch 参数,使 epoch 从正确的值开始。

对于分段常数调度,您可以使用以下类似的调度函数(与之前一样,如果您愿意,您可以定义一个更通用的函数;请参阅笔记本中“分段常数调度”部分的示例),然后创建一个带有此函数的 LearningRateScheduler 回调,并将其传递给 fit() 方法,就像对指数调度一样:

def piecewise_constant_fn(epoch):
    if epoch < 5:
        return 0.01
    elif epoch < 15:
        return 0.005
    else:
        return 0.001

对于性能调度,请使用 ReduceLROnPlateau 回调。例如,如果您将以下回调传递给 fit() 方法,每当最佳验证损失连续五个 epoch 没有改善时,它将把学习率乘以 0.5(还有其他选项可用;请查看文档以获取更多详细信息):

lr_scheduler = tf.keras.callbacks.ReduceLROnPlateau(factor=0.5, patience=5)
history = model.fit(X_train, y_train, [...], callbacks=[lr_scheduler])

最后,Keras 提供了另一种实现学习率调度的方法:您可以使用 tf.keras.​opti⁠mizers.schedules 中可用的类之一定义一个调度学习率,然后将其传递给任何优化器。这种方法在每一步而不是每个 epoch 更新学习率。例如,以下是如何实现与我们之前定义的 exponential_decay_fn() 函数相同的指数调度:

import math

batch_size = 32
n_epochs = 25
n_steps = n_epochs * math.ceil(len(X_train) / batch_size)
scheduled_learning_rate = tf.keras.optimizers.schedules.ExponentialDecay(
    initial_learning_rate=0.01, decay_steps=n_steps, decay_rate=0.1)
optimizer = tf.keras.optimizers.SGD(learning_rate=scheduled_learning_rate)

这很简单明了,而且当您保存模型时,学习率及其调度(包括其状态)也会被保存。

至于 1cycle,Keras 不支持它,但是可以通过创建一个自定义回调,在每次迭代时修改学习率来实现它,代码不到 30 行。要从回调的 on_batch_begin() 方法中更新优化器的学习率,您需要调用 tf.keras.​back⁠end.set_value(self.model.optimizer.learning_rate, new_learning_rate)。请参阅笔记本中的“1Cycle Scheduling”部分以获取示例。

总之,指数衰减、性能调度和 1cycle 可以显著加快收敛速度,所以试一试吧!

通过正则化避免过拟合

有了四个参数,我可以拟合一只大象,有了五个我可以让它摇动它的鼻子。

约翰·冯·诺伊曼,引用自恩里科·费米在《自然》427 中

拥有成千上万个参数,你可以拟合整个动物园。深度神经网络通常有数万个参数,有时甚至有数百万个。这给予它们极大的自由度,意味着它们可以拟合各种复杂的数据集。但这种极大的灵活性也使得网络容易过拟合训练集。通常需要正则化来防止这种情况发生。

我们已经在第十章中实现了最好的正则化技术之一:提前停止。此外,即使批量归一化是为了解决不稳定梯度问题而设计的,它也像一个相当不错的正则化器。在本节中,我们将研究神经网络的其他流行正则化技术:ℓ[1] 和 ℓ[2] 正则化、dropout 和最大范数正则化。

ℓ[1] 和 ℓ[2] 正则化

就像你在第四章中为简单线性模型所做的那样,你可以使用 ℓ[2] 正则化来约束神经网络的连接权重,和/或者使用 ℓ[1] 正则化如果你想要一个稀疏模型(其中许多权重等于 0)。以下是如何将 ℓ[2] 正则化应用于 Keras 层的连接权重,使用正则化因子为 0.01:

layer = tf.keras.layers.Dense(100, activation="relu",
                              kernel_initializer="he_normal",
                              kernel_regularizer=tf.keras.regularizers.l2(0.01))

l2() 函数返回一个正则化器,在训练过程中的每一步都会调用它来计算正则化损失。然后将其添加到最终损失中。正如你所期望的那样,如果你想要 ℓ[1] 正则化,你可以简单地使用tf.keras.regularizers.l1();如果你想要同时使用 ℓ[1] 和 ℓ[2] 正则化,可以使用tf.keras.regularizers.l1_l2()(指定两个正则化因子)。

由于通常希望在网络的所有层中应用相同的正则化器,以及在所有隐藏层中使用相同的激活函数和相同的初始化策略,你可能会发现自己重复相同的参数。这会使代码变得丑陋且容易出错。为了避免这种情况,你可以尝试重构代码以使用循环。另一个选择是使用 Python 的functools.partial()函数,它允许你为任何可调用对象创建一个薄包装器,并设置一些默认参数值:

from functools import partial

RegularizedDense = partial(tf.keras.layers.Dense,
                           activation="relu",
                           kernel_initializer="he_normal",
                           kernel_regularizer=tf.keras.regularizers.l2(0.01))

model = tf.keras.Sequential([
    tf.keras.layers.Flatten(input_shape=[28, 28]),
    RegularizedDense(100),
    RegularizedDense(100),
    RegularizedDense(10, activation="softmax")
])
警告

正如我们之前看到的,当使用 SGD、动量优化和 Nesterov 动量优化时,ℓ[2] 正则化是可以的,但在使用 Adam 及其变种时不行。如果你想要在使用 Adam 时进行权重衰减,那么不要使用 ℓ[2] 正则化:使用 AdamW 替代。

Dropout

Dropout 是深度神经网络中最流行的正则化技术之一。它是由 Geoffrey Hinton 等人在 2012 年的一篇论文中提出的,并在 2014 年由 Nitish Srivastava 等人进一步详细阐述,已被证明非常成功:许多最先进的神经网络使用了 dropout,因为它使它们的准确率提高了 1%–2%。这听起来可能不多,但当一个模型已经有 95%的准确率时,获得 2%的准确率提升意味着将错误率减少了近 40%(从 5%的错误率降至大约 3%)。

这是一个相当简单的算法:在每个训练步骤中,每个神经元(包括输入神经元,但始终不包括输出神经元)都有一个概率p在训练期间暂时“被丢弃”,这意味着在这个训练步骤中它将被完全忽略,但在下一个步骤中可能会活跃(参见图 11-10)。超参数p称为dropout 率,通常设置在 10%到 50%之间:在循环神经网络中更接近 20%-30%(参见第十五章),在卷积神经网络中更接近 40%-50%(参见第十四章)。训练后,神经元不再被丢弃。这就是全部(除了我们将立即讨论的一个技术细节)。

最初令人惊讶的是,这种破坏性技术居然有效。如果一家公司告诉员工每天早上抛硬币决定是否去上班,公司会表现得更好吗?谁知道呢;也许会!公司将被迫调整其组织;它不能依赖任何一个人来操作咖啡机或执行其他关键任务,因此这种专业知识必须分散到几个人身上。员工必须学会与许多同事合作,而不仅仅是少数几个人。公司将变得更具弹性。如果有人离职,这不会有太大影响。目前尚不清楚这种想法是否适用于公司,但对于神经网络来说,它确实有效。使用 dropout 训练的神经元无法与其相邻的神经元共同适应;它们必须尽可能独立地发挥作用。它们也不能过度依赖少数输入神经元;它们必须关注每个输入神经元。它们最终对输入的轻微变化不太敏感。最终,您将获得一个更健壮的网络,具有更好的泛化能力。

mls3 1110

图 11-10。使用 dropout 正则化,每次训练迭代中,一个或多个层中的所有神经元的随机子集(除了输出层)会“被丢弃”;这些神经元在这次迭代中输出为 0(由虚线箭头表示)

理解 dropout 的另一种方法是意识到在每个训练步骤中生成了一个独特的神经网络。由于每个神经元可以存在或不存在,因此存在 2^(N)个可能的网络(其中N是可丢弃神经元的总数)。这是一个如此巨大的数字,以至于同一个神经网络被重复抽样几乎是不可能的。一旦您运行了 10,000 个训练步骤,您实际上已经训练了 10,000 个不同的神经网络,每个神经网络只有一个训练实例。这些神经网络显然不是独立的,因为它们共享许多权重,但它们仍然是不同的。最终的神经网络可以看作是所有这些较小神经网络的平均集合。

提示

在实践中,通常只能将 dropout 应用于顶部一到三层的神经元(不包括输出层)。

有一个小但重要的技术细节。假设p=75%:平均每次训练步骤中只有 25%的神经元是活跃的。这意味着在训练后,神经元将连接到四倍于训练期间的输入神经元。为了补偿这一事实,我们需要在训练期间将每个神经元的输入连接权重乘以四。如果不这样做,神经网络在训练期间和训练后将看到不同的数据,表现不佳。更一般地,在训练期间,我们需要将连接权重除以“保留概率”(1-p)。

使用 Keras 实现 dropout,可以使用tf.keras.layers.Dropout层。在训练期间,它会随机丢弃一些输入(将它们设置为 0),并将剩余的输入除以保留概率。训练结束后,它什么也不做;它只是将输入传递给下一层。以下代码在每个密集层之前应用了 dropout 正则化,使用了 0.2 的 dropout 率:

model = tf.keras.Sequential([
    tf.keras.layers.Flatten(input_shape=[28, 28]),
    tf.keras.layers.Dropout(rate=0.2),
    tf.keras.layers.Dense(100, activation="relu",
                          kernel_initializer="he_normal"),
    tf.keras.layers.Dropout(rate=0.2),
    tf.keras.layers.Dense(100, activation="relu",
                          kernel_initializer="he_normal"),
    tf.keras.layers.Dropout(rate=0.2),
    tf.keras.layers.Dense(10, activation="softmax")
])
[...]  # compile and train the model
警告

由于 dropout 只在训练期间激活,比较训练损失和验证损失可能会产生误导。特别是,模型可能会过度拟合训练集,但训练和验证损失却相似。因此,请确保在没有 dropout 的情况下评估训练损失(例如,在训练后)。

如果观察到模型过拟合,可以增加 dropout 率。相反,如果模型对训练集拟合不足,可以尝试减少 dropout 率。对于大型层,增加 dropout 率,对于小型层,减少 dropout 率也有帮助。此外,许多最先进的架构仅在最后一个隐藏层之后使用 dropout,因此如果全局 dropout 太强,您可能想尝试这样做。

Dropout 确实会显著减慢收敛速度,但在适当调整后通常会得到更好的模型。因此,额外的时间和精力通常是值得的,特别是对于大型模型。

提示

如果要对基于 SELU 激活函数的自正则化网络进行正则化(如前面讨论的),应该使用alpha dropout:这是一种保留其输入均值和标准差的 dropout 变体。它是在与 SELU 一起引入的同一篇论文中提出的,因为常规 dropout 会破坏自正则化。

蒙特卡洛(MC)Dropout

2016 年,Yarin Gal 和 Zoubin Ghahramani 的一篇论文建立了使用 dropout 的更多好理由:

  • 首先,该论文建立了 dropout 网络(即包含Dropout层的神经网络)与近似贝叶斯推断之间的深刻联系,为 dropout 提供了坚实的数学理论基础。

  • 其次,作者引入了一种强大的技术称为MC dropout,它可以提升任何经过训练的 dropout 模型的性能,而无需重新训练它甚至修改它。它还提供了模型不确定性的更好度量,并且可以在几行代码中实现。

如果这一切听起来像某种“奇怪的技巧”点击诱饵,那么看看以下代码。这是 MC dropout 的完整实现,增强了我们之前训练的 dropout 模型而无需重新训练它:

import numpy as np

y_probas = np.stack([model(X_test, training=True)
                     for sample in range(100)])
y_proba = y_probas.mean(axis=0)

请注意,model(X)类似于model.predict(X),只是它返回一个张量而不是 NumPy 数组,并支持training参数。在这个代码示例中,设置training=True确保Dropout层保持活动状态,因此所有预测都会有些不同。我们只对测试集进行 100 次预测,并计算它们的平均值。更具体地说,每次调用模型都会返回一个矩阵,每个实例一行,每个类别一列。因为测试集中有 10,000 个实例和 10 个类别,所以这是一个形状为[10000, 10]的矩阵。我们堆叠了 100 个这样的矩阵,所以y_probas是一个形状为[100, 10000, 10]的 3D 数组。一旦我们在第一个维度上取平均值(axis=0),我们得到y_proba,一个形状为[10000, 10]的数组,就像我们在单次预测中得到的一样。就是这样!在打开 dropout 的情况下对多次预测取平均值会给我们一个通常比关闭 dropout 的单次预测结果更可靠的蒙特卡洛估计。例如,让我们看看模型对 Fashion MNIST 测试集中第一个实例的预测,关闭 dropout:

>>> model.predict(X_test[:1]).round(3)
array([[0\.   , 0\.   , 0\.   , 0\.   , 0\.   , 0.024, 0\.   , 0.132, 0\.   ,
 0.844]], dtype=float32)

模型相当自信(84.4%)这张图片属于第 9 类(踝靴)。与 MC dropout 预测进行比较:

>>> y_proba[0].round(3)
array([0\.   , 0\.   , 0\.   , 0\.   , 0\.   , 0.067, 0\.   , 0.209, 0.001,
 0.723], dtype=float32)

模型似乎仍然更喜欢类别 9,但其置信度降至 72.3%,类别 5(凉鞋)和 7(运动鞋)的估计概率增加,这是有道理的,因为它们也是鞋类。

MC dropout 倾向于提高模型概率估计的可靠性。这意味着它不太可能自信但错误,这可能是危险的:想象一下一个自动驾驶汽车自信地忽略一个停车标志。了解哪些其他类别最有可能也很有用。此外,您可以查看概率估计的标准差

>>> y_std = y_probas.std(axis=0)
>>> y_std[0].round(3)
array([0\.   , 0\.   , 0\.   , 0.001, 0\.   , 0.096, 0\.   , 0.162, 0.001,
 0.183], dtype=float32)

显然,类别 9 的概率估计存在相当大的方差:标准差为 0.183,应与估计的概率 0.723 进行比较:如果您正在构建一个风险敏感的系统(例如医疗或金融系统),您可能会对这种不确定的预测极为谨慎。您绝对不会将其视为 84.4%的自信预测。模型的准确性也从 87.0%略微提高到 87.2%:

>>> y_pred = y_proba.argmax(axis=1)
>>> accuracy = (y_pred == y_test).sum() / len(y_test)
>>> accuracy
0.8717
注意

您使用的蒙特卡洛样本数量(在此示例中为 100)是一个可以调整的超参数。它越高,预测和不确定性估计就越准确。但是,如果您将其加倍,推断时间也将加倍。此外,在一定数量的样本之上,您将注意到改进很小。您的任务是根据您的应用程序找到延迟和准确性之间的正确权衡。

如果您的模型包含在训练期间以特殊方式行为的其他层(例如BatchNormalization层),那么您不应该像我们刚刚做的那样强制训练模式。相反,您应该用以下MCDropout类替换Dropout层:⁠³⁰

class MCDropout(tf.keras.layers.Dropout):
    def call(self, inputs, training=False):
        return super().call(inputs, training=True)

在这里,我们只是子类化Dropout层,并覆盖call()方法以强制其training参数为True(请参阅第十二章)。类似地,您可以通过子类化AlphaDropout来定义一个MCAlphaDropout类。如果您从头开始创建一个模型,只需使用MCDropout而不是Dropout。但是,如果您已经使用Dropout训练了一个模型,您需要创建一个与现有模型相同但使用Dropout而不是MCDropout的新模型,然后将现有模型的权重复制到新模型中。

简而言之,MC dropout 是一种很棒的技术,可以提升 dropout 模型并提供更好的不确定性估计。当然,由于在训练期间只是常规的 dropout,因此它也起到了正则化的作用。

最大范数正则化

神经网络的另一种流行的正则化技术称为最大范数正则化:对于每个神经元,它约束传入连接的权重w,使得∥ w ∥[2] ≤ r,其中r是最大范数超参数,∥ · ∥[2]是ℓ[2]范数。

最大范数正则化不会向整体损失函数添加正则化损失项。相反,通常是在每个训练步骤之后计算∥ w ∥[2],并在需要时重新缩放www r / ∥ w ∥[2])。

减小r会增加正则化的程度,并有助于减少过拟合。最大范数正则化还可以帮助缓解不稳定的梯度问题(如果您没有使用批量归一化)。

在 Keras 中实现最大范数正则化,将每个隐藏层的kernel_constraint参数设置为具有适当最大值的max_norm()约束,如下所示:

dense = tf.keras.layers.Dense(
    100, activation="relu", kernel_initializer="he_normal",
    kernel_constraint=tf.keras.constraints.max_norm(1.))

在每次训练迭代之后,模型的fit()方法将调用max_norm()返回的对象,将该层的权重传递给它,并得到重新缩放的权重,然后替换该层的权重。正如您将在第十二章中看到的,如果需要,您可以定义自己的自定义约束函数,并将其用作kernel_constraint。您还可以通过设置bias_constraint参数来约束偏置项。

max_norm()函数有一个默认为0axis参数。一个Dense层通常具有形状为[输入数量神经元数量]的权重,因此使用axis=0意味着最大范数约束将独立应用于每个神经元的权重向量。如果您想在卷积层中使用最大范数(参见第十四章),请确保适当设置max_norm()约束的axis参数(通常为axis=[0, 1, 2])。

总结和实用指南

在本章中,我们涵盖了各种技术,您可能想知道应该使用哪些技术。这取决于任务,目前还没有明确的共识,但我发现表 11-3 中的配置在大多数情况下都能很好地工作,而不需要太多的超参数调整。尽管如此,请不要将这些默认值视为硬性规则!

表 11-3. 默认 DNN 配置

超参数 默认值
内核初始化器 He 初始化
激活函数 如果是浅层则为 ReLU;如果是深层则为 Swish
归一化 如果是浅层则为无;如果是深层则为批量归一化
正则化 提前停止;如果需要则使用权重衰减
优化器 Nesterov 加速梯度或 AdamW
学习率调度 性能调度或 1cycle

如果网络是简单的密集层堆叠,则它可以自我归一化,您应该使用表 11-4 中的配置。

表 11-4. 自我归一化网络的 DNN 配置

超参数 默认值
内核初始化器 LeCun 初始化
激活函数 SELU
归一化 无(自我归一化)
正则化 如果需要则使用 Alpha dropout
优化器 Nesterov 加速梯度
学习率调度 性能调度或 1cycle

不要忘记对输入特征进行归一化!您还应尝试重用预训练神经网络的部分,如果您可以找到一个解决类似问题的模型,或者如果您有大量未标记数据,则使用无监督预训练,或者如果您有大量类似任务的标记数据,则使用辅助任务的预训练。

虽然前面的指南应该涵盖了大多数情况,但这里有一些例外情况:

  • 如果您需要一个稀疏模型,您可以使用ℓ[1]正则化(并在训练后选择性地将微小权重归零)。如果您需要一个更稀疏的模型,您可以使用 TensorFlow 模型优化工具包。这将破坏自我归一化,因此在这种情况下应使用默认配置。

  • 如果您需要一个低延迟模型(执行闪电般快速预测的模型),您可能需要使用更少的层,使用快速激活函数(如 ReLU 或 leaky ReLU),并在训练后将批量归一化层折叠到前面的层中。拥有一个稀疏模型也会有所帮助。最后,您可能希望将浮点精度从 32 位减少到 16 位甚至 8 位(参见“将模型部署到移动设备或嵌入式设备”)。再次,查看 TF-MOT。

  • 如果您正在构建一个风险敏感的应用程序,或者推断延迟在您的应用程序中并不是非常重要,您可以使用 MC dropout 来提高性能,并获得更可靠的概率估计,以及不确定性估计。

有了这些指导,您现在已经准备好训练非常深的网络了!我希望您现在相信,只使用方便的 Keras API 就可以走很长一段路。然而,可能会有一天,当您需要更多控制时,例如编写自定义损失函数或调整训练算法时。对于这种情况,您将需要使用 TensorFlow 的较低级别 API,您将在下一章中看到。

练习

  1. Glorot 初始化和 He 初始化旨在解决什么问题?

  2. 只要使用 He 初始化随机选择的值将所有权重初始化为相同值,这样做可以吗?

  3. 将偏置项初始化为 0 可以吗?

  4. 在本章讨论的每种激活函数中,您希望在哪些情况下使用?

  5. 当使用SGD优化器时,如果将momentum超参数设置得太接近 1(例如 0.99999),可能会发生什么?

  6. 列出三种可以生成稀疏模型的方法。

  7. Dropout 会减慢训练速度吗?它会减慢推断速度(即对新实例进行预测)吗?MC dropout 呢?

  8. 练习在 CIFAR10 图像数据集上训练深度神经网络:

    1. 构建一个具有 20 个每层 100 个神经元的隐藏层的 DNN(这太多了,但这是这个练习的重点)。使用 He 初始化和 Swish 激活函数。

    2. 使用 Nadam 优化和提前停止,在 CIFAR10 数据集上训练网络。您可以使用tf.keras.datasets.cifar10.load_​data()加载数据集。该数据集由 60,000 个 32×32 像素的彩色图像组成(50,000 个用于训练,10,000 个用于测试),具有 10 个类别,因此您需要一个具有 10 个神经元的 softmax 输出层。记得每次更改模型架构或超参数时都要搜索正确的学习率。

    3. 现在尝试添加批量归一化并比较学习曲线:它是否比以前收敛得更快?它是否产生更好的模型?它如何影响训练速度?

    4. 尝试用 SELU 替换批量归一化,并进行必要的调整以确保网络自我归一化(即标准化输入特征,使用 LeCun 正态初始化,确保 DNN 仅包含一系列密集层等)。

    5. 尝试使用 alpha dropout 对模型进行正则化。然后,在不重新训练模型的情况下,看看是否可以通过 MC dropout 获得更好的准确性。

    6. 使用 1cycle 调度重新训练您的模型,看看它是否提高了训练速度和模型准确性。

这些练习的解决方案可在本章笔记本的末尾找到,网址为https://homl.info/colab3

¹ Xavier Glorot 和 Yoshua Bengio,“理解训练深度前馈神经网络的困难”,第 13 届人工智能和统计国际会议论文集(2010):249-256。

² 这里有一个类比:如果将麦克风放大器的旋钮调得太接近零,人们就听不到您的声音,但如果将其调得太接近最大值,您的声音将被饱和,人们将听不懂您在说什么。现在想象一下这样一系列放大器:它们都需要适当设置,以便您的声音在链的末端响亮清晰地传出。您的声音必须以与进入时相同的幅度从每个放大器中传出。

³ 例如,Kaiming He 等人,“深入研究整流器:在 ImageNet 分类上超越人类水平表现”,2015 年 IEEE 国际计算机视觉大会论文集(2015):1026-1034。

⁴ 如果神经元下面的层中的输入随时间演变并最终返回到 ReLU 激活函数再次获得正输入的范围内,死神经元可能会复活。例如,如果梯度下降调整了死神经元下面的神经元,这种情况可能会发生。

⁵ Bing Xu 等人,“卷积网络中修正激活的实证评估”,arXiv 预印本 arXiv:1505.00853(2015)。

⁶ Djork-Arné Clevert 等人,“指数线性单元(ELUs)快速准确的深度网络学习”,国际学习表示会议论文集,arXiv 预印本(2015 年)。

⁷ Günter Klambauer 等人,“自正则化神经网络”,第 31 届国际神经信息处理系统会议论文集(2017):972–981。

⁸ Dan Hendrycks 和 Kevin Gimpel,“高斯误差线性单元(GELUs)”,arXiv 预印本 arXiv:1606.08415(2016)。

⁹ 如果曲线上任意两点之间的线段永远不会低于曲线,则函数是凸的。单调函数只增加或只减少。

¹⁰ Prajit Ramachandran 等人,“寻找激活函数”,arXiv 预印本 arXiv:1710.05941(2017)。

¹¹ Diganta Misra,“Mish:一种自正则化的非单调激活函数”,arXiv 预印本 arXiv:1908.08681(2019)。

¹² Sergey Ioffe 和 Christian Szegedy,“批量归一化:通过减少内部协变量转移加速深度网络训练”,第 32 届国际机器学习会议论文集(2015):448–456。

¹³ 然而,它们是根据训练数据在训练期间估计的,因此可以说它们是可训练的。在 Keras 中,“不可训练”实际上意味着“不受反向传播影响”。

¹⁴ Razvan Pascanu 等人,“关于训练递归神经网络的困难”,第 30 届国际机器学习会议论文集(2013):1310–1318。

¹⁵ Boris T. Polyak,“加速迭代方法收敛的一些方法”,苏联计算数学和数学物理杂志 4,第 5 期(1964):1–17。

¹⁶ Yurii Nesterov,“一种具有收敛速率O(1/k²)的无约束凸最小化问题方法”,苏联科学院学报 269(1983):543–547。

¹⁷ John Duchi 等人,“用于在线学习和随机优化的自适应次梯度方法”,机器学习研究杂志 12(2011):2121–2159。

¹⁸ 该算法由 Geoffrey Hinton 和 Tijmen Tieleman 于 2012 年创建,并由 Geoffrey Hinton 在他关于神经网络的 Coursera 课程中介绍(幻灯片:https://homl.info/57;视频:https://homl.info/58)。有趣的是,由于作者没有撰写描述该算法的论文,研究人员经常在其论文中引用“第 6e 讲座的第 29 张幻灯片”。

¹⁹ ρ是希腊字母 rho。

²⁰ Diederik P. Kingma 和 Jimmy Ba,“Adam:一种随机优化方法”,arXiv 预印本 arXiv:1412.6980(2014)。

²¹ Timothy Dozat,“将 Nesterov 动量合并到 Adam 中”(2016)。

²² Ilya Loshchilov 和 Frank Hutter,“解耦权重衰减正则化”,arXiv 预印本 arXiv:1711.05101(2017)。

²³ Ashia C. Wilson 等人,“机器学习中自适应梯度方法的边际价值”,神经信息处理系统进展 30(2017):4148–4158。

Leslie N. Smith,“神经网络超参数的纪律性方法:第 1 部分—学习率、批量大小、动量和权重衰减”,arXiv 预印本 arXiv:1803.09820(2018)。

Andrew Senior 等人,“深度神经网络在语音识别中的学习率的实证研究”,IEEE 国际会议论文集(2013):6724–6728。

Geoffrey E. Hinton 等人,“通过防止特征探测器的共适应来改进神经网络”,arXiv 预印本 arXiv:1207.0580(2012)。

Nitish Srivastava 等人,“Dropout:防止神经网络过拟合的简单方法”,机器学习研究杂志 15(2014):1929–1958。

Yarin Gal 和 Zoubin Ghahramani,“Dropout 作为贝叶斯近似:在深度学习中表示模型不确定性”,第 33 届国际机器学习会议论文集(2016):1050–1059。

具体来说,他们表明训练一个 dropout 网络在数学上等同于在一种特定类型的概率模型中进行近似贝叶斯推断,这种模型被称为深高斯过程

这个MCDropout类将与所有 Keras API 一起工作,包括顺序 API。如果您只关心功能 API 或子类 API,您不必创建一个MCDropout类;您可以创建一个常规的Dropout层,并使用training=True调用它。

第十二章:使用 TensorFlow 进行自定义模型和训练

到目前为止,我们只使用了 TensorFlow 的高级 API,Keras,但它已经让我们走得很远:我们构建了各种神经网络架构,包括回归和分类网络,Wide & Deep 网络,自正则化网络,使用各种技术,如批量归一化,dropout 和学习率调度。事实上,您将遇到的 95%用例不需要除了 Keras(和 tf.data)之外的任何东西(请参见第十三章)。但现在是时候深入研究 TensorFlow,看看它的低级Python API。当您需要额外控制以编写自定义损失函数,自定义指标,层,模型,初始化程序,正则化器,权重约束等时,这将非常有用。您甚至可能需要完全控制训练循环本身;例如,应用特殊的转换或约束到梯度(超出仅仅剪切它们)或为网络的不同部分使用多个优化器。我们将在本章中涵盖所有这些情况,并且还将看看如何使用 TensorFlow 的自动生成图功能来提升您的自定义模型和训练算法。但首先,让我们快速浏览一下 TensorFlow。

TensorFlow 的快速浏览

正如您所知,TensorFlow 是一个强大的用于数值计算的库,特别适用于大规模机器学习(但您也可以用它来进行需要大量计算的任何其他任务)。它由 Google Brain 团队开发,驱动了谷歌许多大规模服务,如 Google Cloud Speech,Google Photos 和 Google Search。它于 2015 年 11 月开源,现在是业界最广泛使用的深度学习库:无数项目使用 TensorFlow 进行各种机器学习任务,如图像分类,自然语言处理,推荐系统和时间序列预测。

那么 TensorFlow 提供了什么?以下是一个摘要:

  • 它的核心与 NumPy 非常相似,但支持 GPU。

  • 它支持分布式计算(跨多个设备和服务器)。

  • 它包括一种即时(JIT)编译器,允许它优化计算以提高速度和内存使用。它通过从 Python 函数中提取计算图,优化它(例如通过修剪未使用的节点),并有效地运行它(例如通过自动并行运行独立操作)来工作。

  • 计算图可以导出为可移植格式,因此您可以在一个环境中训练 TensorFlow 模型(例如在 Linux 上使用 Python),并在另一个环境中运行它(例如在 Android 设备上使用 Java)。

  • 它实现了反向模式自动微分(请参见第十章和附录 B)并提供了一些优秀的优化器,如 RMSProp 和 Nadam(请参见第十一章),因此您可以轻松最小化各种损失函数。

TensorFlow 提供了许多建立在这些核心功能之上的功能:最重要的当然是 Keras,但它还有数据加载和预处理操作(tf.data,tf.io 等),图像处理操作(tf.image),信号处理操作(tf.signal)等等(请参见图 12-1 以获取 TensorFlow 的 Python API 概述)。

提示

我们将涵盖 TensorFlow API 的许多包和函数,但不可能覆盖所有内容,因此您应该花些时间浏览 API;您会发现它非常丰富且有很好的文档。

在最低级别上,每个 TensorFlow 操作(简称 op)都是使用高效的 C++代码实现的。许多操作有多个称为内核的实现:每个内核专门用于特定设备类型,如 CPU、GPU,甚至 TPU(张量处理单元)。正如您可能知道的,GPU 可以通过将计算分成许多较小的块并在许多 GPU 线程上并行运行来显着加快计算速度。TPU 速度更快:它们是专门用于深度学习操作的定制 ASIC 芯片(我们将在第十九章讨论如何使用 GPU 或 TPU 与 TensorFlow)。

mls3 1201

图 12-1. TensorFlow 的 Python API

TensorFlow 的架构如图 12-2 所示。大部分时间,您的代码将使用高级 API(特别是 Keras 和 tf.data),但当您需要更灵活性时,您将使用较低级别的 Python API,直接处理张量。无论如何,TensorFlow 的执行引擎将有效地运行操作,即使跨多个设备和机器,如果您告诉它的话。

TensorFlow 不仅可以在 Windows、Linux 和 macOS 上运行,还可以在移动设备上运行(使用 TensorFlow Lite),包括 iOS 和 Android(请参阅第十九章)。请注意,如果您不想使用 Python API,还可以使用其他语言的 API:有 C++、Java 和 Swift 的 API。甚至还有一个名为 TensorFlow.js 的 JavaScript 实现,可以直接在浏览器中运行您的模型。

mls3 1202

图 12-2. TensorFlow 的架构

TensorFlow 不仅仅是一个库。TensorFlow 是一个庞大生态系统中心。首先,有用于可视化的 TensorBoard(请参阅第十章)。接下来,有由 Google 构建的用于将 TensorFlow 项目投入生产的一套库,称为TensorFlow Extended (TFX):它包括用于数据验证、预处理、模型分析和服务的工具(使用 TF Serving;请参阅第十九章)。Google 的 TensorFlow Hub 提供了一种轻松下载和重复使用预训练神经网络的方式。您还可以在 TensorFlow 的model garden中获得许多神经网络架构,其中一些是预训练的。查看TensorFlow 资源https://github.com/jtoy/awesome-tensorflow以获取更多基于 TensorFlow 的项目。您可以在 GitHub 上找到数百个 TensorFlow 项目,因此通常很容易找到您正在尝试做的任何事情的现有代码。

提示

越来越多的机器学习论文随着它们的实现发布,有时甚至附带预训练模型。请查看https://paperswithcode.com以轻松找到它们。

最后但并非最不重要的是,TensorFlow 拥有一支充满激情和乐于助人的开发团队,以及一个庞大的社区为其改进做出贡献。要提出技术问题,您应该使用https://stackoverflow.com,并在问题中标记tensorflowpython。您可以通过GitHub提交错误和功能请求。要进行一般讨论,请加入TensorFlow 论坛

好了,现在是开始编码的时候了!

像 NumPy 一样使用 TensorFlow

TensorFlow 的 API 围绕着张量展开,这些张量从操作流向操作,因此得名 TensorFlow。张量与 NumPy 的ndarray非常相似:通常是一个多维数组,但也可以保存标量(例如42)。当我们创建自定义成本函数、自定义指标、自定义层等时,这些张量将非常重要,让我们看看如何创建和操作它们。

张量和操作

您可以使用tf.constant()创建一个张量。例如,这里是一个表示具有两行三列浮点数的矩阵的张量:

>>> import tensorflow as tf
>>> t = tf.constant([[1., 2., 3.], [4., 5., 6.]])  # matrix
>>> t
<tf.Tensor: shape=(2, 3), dtype=float32, numpy=
array([[1., 2., 3.],
 [4., 5., 6.]], dtype=float32)>

就像ndarray一样,tf.Tensor有一个形状和一个数据类型(dtype):

>>> t.shape
TensorShape([2, 3])
>>> t.dtype
tf.float32

索引工作方式与 NumPy 类似:

>>> t[:, 1:]
<tf.Tensor: shape=(2, 2), dtype=float32, numpy=
array([[2., 3.],
 [5., 6.]], dtype=float32)>
>>> t[..., 1, tf.newaxis]
<tf.Tensor: shape=(2, 1), dtype=float32, numpy=
array([[2.],
 [5.]], dtype=float32)>

最重要的是,各种张量操作都是可用的:

>>> t + 10
<tf.Tensor: shape=(2, 3), dtype=float32, numpy=
array([[11., 12., 13.],
 [14., 15., 16.]], dtype=float32)>
>>> tf.square(t)
<tf.Tensor: shape=(2, 3), dtype=float32, numpy=
array([[ 1.,  4.,  9.],
 [16., 25., 36.]], dtype=float32)>
>>> t @ tf.transpose(t)
<tf.Tensor: shape=(2, 2), dtype=float32, numpy=
array([[14., 32.],
 [32., 77.]], dtype=float32)>

请注意,编写t + 10等同于调用tf.add(t, 10)(实际上,Python 调用了魔术方法t.__add__(10),它只是调用了tf.add(t, 10))。其他运算符,如-*,也受支持。@运算符在 Python 3.5 中添加,用于矩阵乘法:它等同于调用tf.matmul()函数。

注意

许多函数和类都有别名。例如,tf.add()tf.math.add()是相同的函数。这使得 TensorFlow 可以为最常见的操作保留简洁的名称,同时保持良好组织的包。

张量也可以保存标量值。在这种情况下,形状为空:

>>> tf.constant(42)
<tf.Tensor: shape=(), dtype=int32, numpy=42>
注意

Keras API 有自己的低级 API,位于tf.keras.backend中。这个包通常被导入为K,以简洁为主。它曾经包括函数如K.square()K.exp()K.sqrt(),您可能在现有代码中遇到:这在 Keras 支持多个后端时编写可移植代码很有用,但现在 Keras 只支持 TensorFlow,您应该直接调用 TensorFlow 的低级 API(例如,使用tf.square()而不是K.square())。从技术上讲,K.square()及其相关函数仍然存在以保持向后兼容性,但tf.keras.backend包的文档只列出了一些实用函数,例如clear_session()(在第十章中提到)。

您将找到所有您需要的基本数学运算(tf.add()tf.multiply()tf.square()tf.exp()tf.sqrt()等)以及大多数您可以在 NumPy 中找到的操作(例如tf.reshape()tf.squeeze()tf.tile())。一些函数的名称与 NumPy 中的名称不同;例如,tf.reduce_mean()tf.reduce_sum()tf.reduce_max()tf.math.log()相当于np.mean()np.sum()np.max()np.log()。当名称不同时,通常有很好的理由。例如,在 TensorFlow 中,您必须编写tf.transpose(t);您不能像在 NumPy 中那样只写t.T。原因是tf.transpose()函数与 NumPy 的T属性并不完全相同:在 TensorFlow 中,将创建一个具有其自己的转置数据副本的新张量,而在 NumPy 中,t.T只是相同数据的一个转置视图。同样,tf.reduce_sum()操作之所以被命名为这样,是因为其 GPU 核心(即 GPU 实现)使用的减少算法不保证元素添加的顺序:因为 32 位浮点数的精度有限,每次调用此操作时结果可能会发生微小变化。tf.reduce_mean()也是如此(当然tf.reduce_max()是确定性的)。

张量和 NumPy

张量与 NumPy 兼容:您可以从 NumPy 数组创建张量,反之亦然。您甚至可以将 TensorFlow 操作应用于 NumPy 数组,将 NumPy 操作应用于张量:

>>> import numpy as np
>>> a = np.array([2., 4., 5.])
>>> tf.constant(a)
<tf.Tensor: id=111, shape=(3,), dtype=float64, numpy=array([2., 4., 5.])>
>>> t.numpy()  # or np.array(t)
array([[1., 2., 3.],
 [4., 5., 6.]], dtype=float32)
>>> tf.square(a)
<tf.Tensor: id=116, shape=(3,), dtype=float64, numpy=array([4., 16., 25.])>
>>> np.square(t)
array([[ 1.,  4.,  9.],
 [16., 25., 36.]], dtype=float32)
警告

请注意,NumPy 默认使用 64 位精度,而 TensorFlow 使用 32 位。这是因为 32 位精度通常对神经网络来说足够了,而且运行速度更快,使用的内存更少。因此,当您从 NumPy 数组创建张量时,请确保设置dtype=tf.float32

类型转换

类型转换可能会严重影响性能,并且当它们自动完成时很容易被忽略。为了避免这种情况,TensorFlow 不会自动执行任何类型转换:如果您尝试在具有不兼容类型的张量上执行操作,它只会引发异常。例如,您不能将浮点张量和整数张量相加,甚至不能将 32 位浮点数和 64 位浮点数相加:

>>> tf.constant(2.) + tf.constant(40)
[...] InvalidArgumentError: [...] expected to be a float tensor [...]
>>> tf.constant(2.) + tf.constant(40., dtype=tf.float64)
[...] InvalidArgumentError: [...] expected to be a float tensor [...]

这可能一开始有点烦人,但请记住这是为了一个好的目的!当然,当您真正需要转换类型时,您可以使用tf.cast()

>>> t2 = tf.constant(40., dtype=tf.float64)
>>> tf.constant(2.0) + tf.cast(t2, tf.float32)
<tf.Tensor: id=136, shape=(), dtype=float32, numpy=42.0>

变量

到目前为止,我们看到的tf.Tensor值是不可变的:我们无法修改它们。这意味着我们不能使用常规张量来实现神经网络中的权重,因为它们需要通过反向传播进行调整。此外,其他参数可能也需要随时间变化(例如,动量优化器会跟踪过去的梯度)。我们需要的是tf.Variable

>>> v = tf.Variable([[1., 2., 3.], [4., 5., 6.]])
>>> v
<tf.Variable 'Variable:0' shape=(2, 3) dtype=float32, numpy=
array([[1., 2., 3.],
 [4., 5., 6.]], dtype=float32)>

tf.Variable的行为很像tf.Tensor:您可以执行相同的操作,它与 NumPy 很好地配合,对类型也一样挑剔。但是它也可以使用assign()方法(或assign_add()assign_sub(),它们会增加或减少给定值来就地修改变量)。您还可以使用单个单元格(或切片)的assign()方法或使用scatter_update()scatter_nd_update()方法来修改单个单元格(或切片):

v.assign(2 * v)           # v now equals [[2., 4., 6.], [8., 10., 12.]]
v[0, 1].assign(42)        # v now equals [[2., 42., 6.], [8., 10., 12.]]
v[:, 2].assign([0., 1.])  # v now equals [[2., 42., 0.], [8., 10., 1.]]
v.scatter_nd_update(      # v now equals [[100., 42., 0.], [8., 10., 200.]]
    indices=[[0, 0], [1, 2]], updates=[100., 200.])

直接赋值不起作用:

>>> v[1] = [7., 8., 9.]
[...] TypeError: 'ResourceVariable' object does not support item assignment
注意

在实践中,您很少需要手动创建变量;Keras 提供了一个add_weight()方法,它会为您处理,您将看到。此外,模型参数通常会直接由优化器更新,因此您很少需要手动更新变量。

其他数据结构

TensorFlow 支持几种其他数据结构,包括以下内容(请参阅本章笔记本中的“其他数据结构”部分或附录 C 了解更多详细信息):

稀疏张量(tf.SparseTensor

高效地表示大部分为零的张量。tf.sparse包含了稀疏张量的操作。

张量数组(tf.TensorArray

是张量列表。它们默认具有固定长度,但可以选择性地扩展。它们包含的所有张量必须具有相同的形状和数据类型。

不规则张量(tf.RaggedTensor

表示张量列表,所有张量的秩和数据类型相同,但大小不同。张量大小变化的维度称为不规则维度tf.ragged包含了不规则张量的操作。

字符串张量

是类型为tf.string的常规张量。这些表示字节字符串,而不是 Unicode 字符串,因此如果您使用 Unicode 字符串(例如,像"café"这样的常规 Python 3 字符串)创建字符串张量,那么它将自编码为 UTF-8(例如,b"caf\xc3\xa9")。或者,您可以使用类型为tf.int32的张量来表示 Unicode 字符串,其中每个项目表示一个 Unicode 代码点(例如,[99, 97, 102, 233])。tf.strings包(带有s)包含用于字节字符串和 Unicode 字符串的操作(以及将一个转换为另一个的操作)。重要的是要注意tf.string是原子的,这意味着其长度不会出现在张量的形状中。一旦您将其转换为 Unicode 张量(即,一个包含 Unicode 代码点的tf.int32类型的张量),长度将出现在形状中。

集合

表示为常规张量(或稀疏张量)。例如,tf.constant([[1, 2], [3, 4]])表示两个集合{1, 2}和{3, 4}。更一般地,每个集合由张量的最后一个轴中的向量表示。您可以使用tf.sets包中的操作来操作集合。

队列

在多个步骤中存储张量。TensorFlow 提供各种类型的队列:基本的先进先出(FIFO)队列(FIFOQueue),以及可以优先处理某些项目的队列(PriorityQueue),对其项目进行洗牌的队列(RandomShuffleQueue),以及通过填充来批处理不同形状的项目的队列(PaddingFIFOQueue)。这些类都在tf.queue包中。

有了张量、操作、变量和各种数据结构,你现在可以定制你的模型和训练算法了!

自定义模型和训练算法

你将首先创建一个自定义损失函数,这是一个简单而常见的用例。

自定义损失函数

假设你想训练一个回归模型,但你的训练集有点嘈杂。当然,你首先尝试通过删除或修复异常值来清理数据集,但结果还不够好;数据集仍然很嘈杂。你应该使用哪种损失函数?均方误差可能会过分惩罚大误差,导致模型不够精确。平均绝对误差不会像惩罚异常值那样严重,但训练可能需要一段时间才能收敛,训练出的模型可能不够精确。这可能是使用 Huber 损失的好时机(在第十章介绍)。Huber 损失在 Keras 中是可用的(只需使用tf.keras.losses.Huber类的实例),但让我们假装它不存在。要实现它,只需创建一个函数,该函数将标签和模型预测作为参数,并使用 TensorFlow 操作来计算包含所有损失的张量(每个样本一个):

def huber_fn(y_true, y_pred):
    error = y_true - y_pred
    is_small_error = tf.abs(error) < 1
    squared_loss = tf.square(error) / 2
    linear_loss  = tf.abs(error) - 0.5
    return tf.where(is_small_error, squared_loss, linear_loss)
警告

为了获得更好的性能,你应该使用矢量化的实现,就像这个例子一样。此外,如果你想要从 TensorFlow 的图优化功能中受益,你应该只使用 TensorFlow 操作。

也可以返回平均损失而不是单个样本损失,但这不推荐,因为这样做会使在需要时无法使用类权重或样本权重(参见第十章)。

现在你可以在编译 Keras 模型时使用这个 Huber 损失函数,然后像往常一样训练你的模型:

model.compile(loss=huber_fn, optimizer="nadam")
model.fit(X_train, y_train, [...])

就是这样!在训练期间的每个批次中,Keras 将调用huber_fn()函数来计算损失,然后使用反向模式自动微分来计算损失相对于所有模型参数的梯度,最后执行梯度下降步骤(在这个例子中使用 Nadam 优化器)。此外,它将跟踪自从 epoch 开始以来的总损失,并显示平均损失。

但是当你保存模型时,这个自定义损失会发生什么?

保存和加载包含自定义组件的模型

保存包含自定义损失函数的模型可以正常工作,但是当你加载它时,你需要提供一个将函数名称映射到实际函数的字典。更一般地,当你加载包含自定义对象的模型时,你需要将名称映射到对象:

model = tf.keras.models.load_model("my_model_with_a_custom_loss",
                                   custom_objects={"huber_fn": huber_fn})
提示

如果你用@keras.utils.​reg⁠ister_keras_serializable()装饰huber_fn()函数,它将自动可用于load_model()函数:不需要将其包含在custom_objects字典中。

使用当前的实现,任何在-1 和 1 之间的错误都被认为是“小”。但是如果你想要一个不同的阈值呢?一个解决方案是创建一个函数来创建一个配置好的损失函数:

def create_huber(threshold=1.0):
    def huber_fn(y_true, y_pred):
        error = y_true - y_pred
        is_small_error = tf.abs(error) < threshold
        squared_loss = tf.square(error) / 2
        linear_loss  = threshold * tf.abs(error) - threshold ** 2 / 2
        return tf.where(is_small_error, squared_loss, linear_loss)
    return huber_fn

model.compile(loss=create_huber(2.0), optimizer="nadam")

不幸的是,当你保存模型时,threshold不会被保存。这意味着在加载模型时你将需要指定threshold的值(注意要使用的名称是"huber_fn",这是你给 Keras 的函数的名称,而不是创建它的函数的名称):

model = tf.keras.models.load_model(
    "my_model_with_a_custom_loss_threshold_2",
    custom_objects={"huber_fn": create_huber(2.0)}
)

你可以通过创建tf.keras.losses.Loss类的子类,然后实现它的get_config()方法来解决这个问题:

class HuberLoss(tf.keras.losses.Loss):
    def __init__(self, threshold=1.0, **kwargs):
        self.threshold = threshold
        super().__init__(**kwargs)

    def call(self, y_true, y_pred):
        error = y_true - y_pred
        is_small_error = tf.abs(error) < self.threshold
        squared_loss = tf.square(error) / 2
        linear_loss  = self.threshold * tf.abs(error) - self.threshold**2 / 2
        return tf.where(is_small_error, squared_loss, linear_loss)

    def get_config(self):
        base_config = super().get_config()
        return {**base_config, "threshold": self.threshold}

让我们来看看这段代码:

  • 构造函数接受**kwargs并将它们传递给父构造函数,父构造函数处理标准超参数:损失的name和用于聚合单个实例损失的reduction算法。默认情况下,这是"AUTO",等同于"SUM_OVER_BATCH_SIZE":损失将是实例损失的总和,加权后再除以批量大小(而不是加权平均)。其他可能的值是"SUM""NONE"

  • call()方法接受标签和预测值,计算所有实例损失,并返回它们。

  • get_config()方法返回一个字典,将每个超参数名称映射到其值。它首先调用父类的get_config()方法,然后将新的超参数添加到此字典中。

然后您可以在编译模型时使用此类的任何实例:

model.compile(loss=HuberLoss(2.), optimizer="nadam")

当您保存模型时,阈值将与模型一起保存;当您加载模型时,您只需要将类名映射到类本身:

model = tf.keras.models.load_model("my_model_with_a_custom_loss_class",
                                   custom_objects={"HuberLoss": HuberLoss})

当您保存模型时,Keras 会调用损失实例的get_config()方法,并以 SavedModel 格式保存配置。当您加载模型时,它会在HuberLoss类上调用from_config()类方法:这个方法由基类(Loss)实现,并创建一个类的实例,将**config传递给构造函数。

损失就是这样了!正如您现在将看到的,自定义激活函数、初始化器、正则化器和约束并没有太大不同。

自定义激活函数、初始化器、正则化器和约束

大多数 Keras 功能,如损失、正则化器、约束、初始化器、指标、激活函数、层,甚至完整模型,都可以以类似的方式进行自定义。大多数情况下,您只需要编写一个带有适当输入和输出的简单函数。这里有一个自定义激活函数的示例(相当于tf.keras.activations.softplus()tf.nn.softplus())、一个自定义 Glorot 初始化器的示例(相当于tf.keras.initializers.glorot_normal())、一个自定义ℓ[1]正则化器的示例(相当于tf.keras.regularizers.l1(0.01))以及一个确保权重都为正的自定义约束的示例(相当于tf.keras.​con⁠straints.nonneg()tf.nn.relu()):

def my_softplus(z):
    return tf.math.log(1.0 + tf.exp(z))

def my_glorot_initializer(shape, dtype=tf.float32):
    stddev = tf.sqrt(2. / (shape[0] + shape[1]))
    return tf.random.normal(shape, stddev=stddev, dtype=dtype)

def my_l1_regularizer(weights):
    return tf.reduce_sum(tf.abs(0.01 * weights))

def my_positive_weights(weights):  # return value is just tf.nn.relu(weights)
    return tf.where(weights < 0., tf.zeros_like(weights), weights)

正如您所看到的,参数取决于自定义函数的类型。然后可以像这里展示的那样正常使用这些自定义函数:

layer = tf.keras.layers.Dense(1, activation=my_softplus,
                              kernel_initializer=my_glorot_initializer,
                              kernel_regularizer=my_l1_regularizer,
                              kernel_constraint=my_positive_weights)

激活函数将应用于此Dense层的输出,并将其结果传递给下一层。层的权重将使用初始化器返回的值进行初始化。在每个训练步骤中,权重将传递给正则化函数以计算正则化损失,然后将其添加到主损失中以获得用于训练的最终损失。最后,在每个训练步骤之后,将调用约束函数,并将层的权重替换为受约束的权重。

如果一个函数有需要与模型一起保存的超参数,那么您将希望子类化适当的类,比如tf.keras.regu⁠larizers.​​Reg⁠⁠ularizertf.keras.constraints.Constrainttf.keras.initializers.​Ini⁠tializertf.keras.layers.Layer(适用于任何层,包括激活函数)。就像您为自定义损失所做的那样,这里是一个简单的ℓ[1]正则化类,它保存了其factor超参数(这次您不需要调用父构造函数或get_config()方法,因为它们不是由父类定义的):

class MyL1Regularizer(tf.keras.regularizers.Regularizer):
    def __init__(self, factor):
        self.factor = factor

    def __call__(self, weights):
        return tf.reduce_sum(tf.abs(self.factor * weights))

    def get_config(self):
        return {"factor": self.factor}

请注意,您必须为损失、层(包括激活函数)和模型实现call()方法,或者为正则化器、初始化器和约束实现__call__()方法。对于指标,情况有些不同,您将立即看到。

自定义指标

损失和指标在概念上并不相同:损失(例如,交叉熵)被梯度下降用来训练模型,因此它们必须是可微的(至少在评估它们的点上),它们的梯度不应该在任何地方都为零。此外,如果它们不容易被人类解释也是可以的。相反,指标(例如,准确率)用于评估模型:它们必须更容易被解释,可以是不可微的或者在任何地方梯度为零。

也就是说,在大多数情况下,定义一个自定义指标函数与定义一个自定义损失函数完全相同。实际上,我们甚至可以使用我们之前创建的 Huber 损失函数作为指标;它会工作得很好(在这种情况下,持久性也会以相同的方式工作,只保存函数的名称"huber_fn",而不是阈值):

model.compile(loss="mse", optimizer="nadam", metrics=[create_huber(2.0)])

在训练期间的每个批次,Keras 将计算这个指标并跟踪自开始时的平均值。大多数情况下,这正是你想要的。但并非总是如此!例如,考虑一个二元分类器的精度。正如你在第三章中看到的,精度是真正例的数量除以正例的预测数量(包括真正例和假正例)。假设模型在第一个批次中做出了五个正面预测,其中四个是正确的:这是 80%的精度。然后假设模型在第二个批次中做出了三个正面预测,但它们全部是错误的:这是第二个批次的 0%精度。如果你只计算这两个精度的平均值,你会得到 40%。但等一下——这不是这两个批次的模型精度!事实上,总共有四个真正例(4 + 0)中的八个正面预测(5 + 3),所以总体精度是 50%,而不是 40%。我们需要的是一个对象,它可以跟踪真正例的数量和假正例的数量,并且可以在需要时基于这些数字计算精度。这正是tf.keras.metrics.Precision类所做的:

>>> precision = tf.keras.metrics.Precision()
>>> precision([0, 1, 1, 1, 0, 1, 0, 1], [1, 1, 0, 1, 0, 1, 0, 1])
<tf.Tensor: shape=(), dtype=float32, numpy=0.8>
>>> precision([0, 1, 0, 0, 1, 0, 1, 1], [1, 0, 1, 1, 0, 0, 0, 0])
<tf.Tensor: shape=(), dtype=float32, numpy=0.5>

在这个例子中,我们创建了一个Precision对象,然后像一个函数一样使用它,为第一个批次传递标签和预测,然后为第二个批次(如果需要,还可以传递样本权重)。我们使用了与刚才讨论的示例中相同数量的真正例和假正例。在第一个批次之后,它返回 80%的精度;然后在第二个批次之后,它返回 50%(这是到目前为止的总体精度,而不是第二个批次的精度)。这被称为流式指标(或有状态指标),因为它逐渐更新,批次之后。

在任何时候,我们可以调用result()方法来获取指标的当前值。我们还可以通过使用variables属性查看其变量(跟踪真正例和假正例的数量),并可以使用reset_states()方法重置这些变量:

>>> precision.result()
<tf.Tensor: shape=(), dtype=float32, numpy=0.5>
>>> precision.variables
[<tf.Variable 'true_positives:0' [...], numpy=array([4.], dtype=float32)>,
 <tf.Variable 'false_positives:0' [...], numpy=array([4.], dtype=float32)>]
>>> precision.reset_states()  # both variables get reset to 0.0

如果需要定义自己的自定义流式指标,创建tf.keras.metrics.Metric类的子类。这里是一个基本示例,它跟踪总 Huber 损失和迄今为止看到的实例数量。当要求结果时,它返回比率,这只是平均 Huber 损失:

class HuberMetric(tf.keras.metrics.Metric):
    def __init__(self, threshold=1.0, **kwargs):
        super().__init__(**kwargs)  # handles base args (e.g., dtype)
        self.threshold = threshold
        self.huber_fn = create_huber(threshold)
        self.total = self.add_weight("total", initializer="zeros")
        self.count = self.add_weight("count", initializer="zeros")

    def update_state(self, y_true, y_pred, sample_weight=None):
        sample_metrics = self.huber_fn(y_true, y_pred)
        self.total.assign_add(tf.reduce_sum(sample_metrics))
        self.count.assign_add(tf.cast(tf.size(y_true), tf.float32))

    def result(self):
        return self.total / self.count

    def get_config(self):
        base_config = super().get_config()
        return {**base_config, "threshold": self.threshold}

让我们走一遍这段代码:

  • 构造函数使用add_weight()方法创建需要在多个批次中跟踪指标状态的变量——在这种情况下,所有 Huber 损失的总和(total)和迄今为止看到的实例数量(count)。如果愿意,你也可以手动创建变量。Keras 跟踪任何设置为属性的tf.Variable(更一般地,任何“可跟踪”的对象,如层或模型)。

  • 当你将这个类的实例用作函数时(就像我们用Precision对象做的那样),update_state()方法会被调用。它根据一个批次的标签和预测更新变量(以及样本权重,但在这种情况下我们忽略它们)。

  • result()方法计算并返回最终结果,在这种情况下是所有实例上的平均 Huber 指标。当你将指标用作函数时,首先调用update_state()方法,然后调用result()方法,并返回其输出。

  • 我们还实现了get_config()方法,以确保threshold与模型一起保存。

  • reset_states()方法的默认实现将所有变量重置为 0.0(但如果需要,你可以覆盖它)。

注意

Keras 会无缝处理变量持久性;不需要任何操作。

当你使用简单函数定义指标时,Keras 会自动为每个批次调用它,并在每个时期期间跟踪平均值,就像我们手动做的那样。因此,我们的HuberMetric类的唯一好处是threshold将被保存。但当然,有些指标,比如精度,不能简单地在批次上进行平均:在这些情况下,除了实现流式指标之外别无选择。

现在你已经构建了一个流式指标,构建一个自定义层将会变得轻而易举!

自定义层

有时候你可能想要构建一个包含一种 TensorFlow 没有提供默认实现的奇特层的架构。或者你可能只是想要构建一个非常重复的架构,在这种架构中,一个特定的层块被重复多次,将每个块视为单个层会很方便。对于这些情况,你会想要构建一个自定义层。

有一些没有权重的层,比如tf.keras.layers.Flattentf.keras.layers.ReLU。如果你想创建一个没有任何权重的自定义层,最简单的方法是编写一个函数并将其包装在tf.keras.layers.Lambda层中。例如,以下层将对其输入应用指数函数:

exponential_layer = tf.keras.layers.Lambda(lambda x: tf.exp(x))

然后,这个自定义层可以像任何其他层一样使用,使用序贯 API、函数式 API 或子类 API。你也可以将它用作激活函数,或者你可以使用activation=tf.exp。指数层有时用于回归模型的输出层,当要预测的值具有非常不同的规模时(例如,0.001、10.、1,000.)。事实上,指数函数是 Keras 中的标准激活函数之一,所以你可以简单地使用activation="exponential"

你可能会猜到,要构建一个自定义的有状态层(即带有权重的层),你需要创建tf.keras.layers.Layer类的子类。例如,以下类实现了Dense层的简化版本:

class MyDense(tf.keras.layers.Layer):
    def __init__(self, units, activation=None, **kwargs):
        super().__init__(**kwargs)
        self.units = units
        self.activation = tf.keras.activations.get(activation)

    def build(self, batch_input_shape):
        self.kernel = self.add_weight(
            name="kernel", shape=[batch_input_shape[-1], self.units],
            initializer="glorot_normal")
        self.bias = self.add_weight(
            name="bias", shape=[self.units], initializer="zeros")

    def call(self, X):
        return self.activation(X @ self.kernel + self.bias)

    def get_config(self):
        base_config = super().get_config()
        return {**base_config, "units": self.units,
                "activation": tf.keras.activations.serialize(self.activation)}

让我们来看看这段代码:

  • 构造函数将所有超参数作为参数(在这个例子中是unitsactivation),并且重要的是它还接受一个**kwargs参数。它调用父构造函数,将kwargs传递给它:这会处理标准参数,如input_shapetrainablename。然后它将超参数保存为属性,使用tf.keras.activations.get()函数将activation参数转换为适当的激活函数(它接受函数、标准字符串如"relu""swish",或者简单地None)。

  • build()方法的作用是通过为每个权重调用add_weight()方法来创建层的变量。build()方法在第一次使用该层时被调用。在那时,Keras 将知道该层输入的形状,并将其传递给build()方法,这通常是创建一些权重所必需的。例如,我们需要知道前一层中的神经元数量以创建连接权重矩阵(即"kernel"):这对应于输入的最后一个维度的大小。在build()方法的最后(仅在最后),您必须调用父类的build()方法:这告诉 Keras 该层已构建(它只是设置self.built = True)。

  • call()方法执行所需的操作。在这种情况下,我们计算输入X和层的内核的矩阵乘法,添加偏置向量,并将激活函数应用于结果,这给出了层的输出。

  • get_config()方法与以前的自定义类中的方法一样。请注意,通过调用tf.keras.​activa⁠tions.serialize()保存激活函数的完整配置。

现在您可以像使用任何其他层一样使用MyDense层!

注意

Keras 会自动推断输出形状,除非该层是动态的(稍后将看到)。在这种(罕见)情况下,您需要实现compute_output_shape()方法,该方法必须返回一个TensorShape对象。

要创建具有多个输入的层(例如,Concatenate),call()方法的参数应该是一个包含所有输入的元组。要创建具有多个输出的层,call()方法应该返回输出的列表。例如,以下示例玩具层接受两个输入并返回三个输出:

class MyMultiLayer(tf.keras.layers.Layer):
    def call(self, X):
        X1, X2 = X
        return X1 + X2, X1 * X2, X1 / X2

这个层现在可以像任何其他层一样使用,但当然只能使用功能 API 和子类 API,而不能使用顺序 API(顺序 API 只接受具有一个输入和一个输出的层)。

如果您的层在训练和测试期间需要具有不同的行为(例如,如果它使用DropoutBatchNormalization层),那么您必须在call()方法中添加一个training参数,并使用此参数来决定要执行什么操作。例如,让我们创建一个在训练期间添加高斯噪声(用于正则化)但在测试期间不执行任何操作的层(Keras 有一个执行相同操作的层,tf.keras.layers.GaussianNoise):

class MyGaussianNoise(tf.keras.layers.Layer):
    def __init__(self, stddev, **kwargs):
        super().__init__(**kwargs)
        self.stddev = stddev

    def call(self, X, training=False):
        if training:
            noise = tf.random.normal(tf.shape(X), stddev=self.stddev)
            return X + noise
        else:
            return X

有了这个,您现在可以构建任何您需要的自定义层!现在让我们看看如何创建自定义模型。

自定义模型

我们已经在第十章中讨论了使用子类 API 创建自定义模型类。这很简单:子类化tf.keras.Model类,在构造函数中创建层和变量,并实现call()方法以执行您希望模型执行的操作。例如,假设我们想要构建图 12-3 中表示的模型。

mls3 1203

图 12-3。自定义模型示例:一个包含跳过连接的自定义ResidualBlock层的任意模型

输入首先经过一个密集层,然后通过由两个密集层和一个加法操作组成的残差块(如您将在第十四章中看到的,残差块将其输入添加到其输出中),然后通过这个相同的残差块再进行三次,然后通过第二个残差块,最终结果通过一个密集输出层。如果这个模型看起来没有太多意义,不要担心;这只是一个示例,说明您可以轻松构建任何您想要的模型,甚至包含循环和跳过连接的模型。要实现这个模型,最好首先创建一个ResidualBlock层,因为我们将创建一对相同的块(并且可能希望在另一个模型中重用它):

class ResidualBlock(tf.keras.layers.Layer):
    def __init__(self, n_layers, n_neurons, **kwargs):
        super().__init__(**kwargs)
        self.hidden = [tf.keras.layers.Dense(n_neurons, activation="relu",
                                             kernel_initializer="he_normal")
                       for _ in range(n_layers)]

    def call(self, inputs):
        Z = inputs
        for layer in self.hidden:
            Z = layer(Z)
        return inputs + Z

这个层有点特殊,因为它包含其他层。Keras 会自动处理这一点:它会自动检测hidden属性包含可跟踪对象(在这种情况下是层),因此它们的变量会自动添加到此层的变量列表中。这个类的其余部分是不言自明的。接下来,让我们使用子类 API 来定义模型本身:

class ResidualRegressor(tf.keras.Model):
    def __init__(self, output_dim, **kwargs):
        super().__init__(**kwargs)
        self.hidden1 = tf.keras.layers.Dense(30, activation="relu",
                                             kernel_initializer="he_normal")
        self.block1 = ResidualBlock(2, 30)
        self.block2 = ResidualBlock(2, 30)
        self.out = tf.keras.layers.Dense(output_dim)

    def call(self, inputs):
        Z = self.hidden1(inputs)
        for _ in range(1 + 3):
            Z = self.block1(Z)
        Z = self.block2(Z)
        return self.out(Z)

我们在构造函数中创建层,并在call()方法中使用它们。然后可以像任何其他模型一样使用此模型(编译、拟合、评估和使用它进行预测)。如果您还希望能够使用save()方法保存模型,并使用tf.keras.models.load_model()函数加载模型,则必须在ResidualBlock类和ResidualRegressor类中实现get_config()方法(就像我们之前做的那样)。或者,您可以使用save_weights()load_weights()方法保存和加载权重。

Model类是Layer类的子类,因此模型可以像层一样定义和使用。但是模型具有一些额外的功能,包括当然包括compile()fit()evaluate()predict()方法(以及一些变体),还有get_layer()方法(可以通过名称或索引返回模型的任何层)和save()方法(以及对tf.keras.models.load_model()tf.keras.models.clone_model()的支持)。

提示

如果模型提供的功能比层更多,为什么不将每个层都定义为模型呢?技术上您可以这样做,但通常更清晰的做法是区分模型的内部组件(即层或可重用的层块)和模型本身(即您将训练的对象)。前者应该是Layer类的子类,而后者应该是Model类的子类。

有了这些,您可以自然而简洁地构建几乎任何您在论文中找到的模型,使用顺序 API、函数 API、子类 API,甚至这些的混合。“几乎”任何模型?是的,还有一些事情我们需要看一下:首先是如何基于模型内部定义损失或指标,其次是如何构建自定义训练循环。

基于模型内部的损失和指标

我们之前定义的自定义损失和指标都是基于标签和预测(以及可选的样本权重)。有时您可能希望基于模型的其他部分(例如其隐藏层的权重或激活)定义损失。这可能对正则化目的或监视模型的某些内部方面很有用。

要基于模型内部定义自定义损失,可以根据模型的任何部分计算损失,然后将结果传递给add_loss()方法。例如,让我们构建一个由五个隐藏层堆叠加一个输出层组成的自定义回归 MLP 模型。这个自定义模型还将在最上面的隐藏层之上具有一个辅助输出。与这个辅助输出相关联的损失将被称为重建损失(参见第十七章):它是重建和输入之间的均方差差异。通过将这个重建损失添加到主要损失中,我们将鼓励模型通过隐藏层尽可能保留更多信息,即使这些信息对于回归任务本身并不直接有用。在实践中,这种损失有时会改善泛化能力(它是一种正则化损失)。还可以使用模型的add_metric()方法添加自定义指标。以下是具有自定义重建损失和相应指标的自定义模型的代码:

class ReconstructingRegressor(tf.keras.Model):
    def __init__(self, output_dim, **kwargs):
        super().__init__(**kwargs)
        self.hidden = [tf.keras.layers.Dense(30, activation="relu",
                                             kernel_initializer="he_normal")
                       for _ in range(5)]
        self.out = tf.keras.layers.Dense(output_dim)
        self.reconstruction_mean = tf.keras.metrics.Mean(
            name="reconstruction_error")

    def build(self, batch_input_shape):
        n_inputs = batch_input_shape[-1]
        self.reconstruct = tf.keras.layers.Dense(n_inputs)

    def call(self, inputs, training=False):
        Z = inputs
        for layer in self.hidden:
            Z = layer(Z)
        reconstruction = self.reconstruct(Z)
        recon_loss = tf.reduce_mean(tf.square(reconstruction - inputs))
        self.add_loss(0.05 * recon_loss)
        if training:
            result = self.reconstruction_mean(recon_loss)
            self.add_metric(result)
        return self.out(Z)

让我们来看一下这段代码:

  • 构造函数创建了一个具有五个密集隐藏层和一个密集输出层的 DNN。我们还创建了一个Mean流式指标,用于在训练过程中跟踪重建误差。

  • build()方法创建一个额外的密集层,用于重构模型的输入。它必须在这里创建,因为其单元数必须等于输入的数量,在调用build()方法之前这个数量是未知的。

  • call()方法通过所有五个隐藏层处理输入,然后将结果传递给重构层,该层生成重构。

  • 然后call()方法计算重构损失(重构和输入之间的均方差),并使用add_loss()方法将其添加到模型的损失列表中。请注意,我们通过将重构损失乘以 0.05 来缩小重构损失(这是一个可以调整的超参数)。这确保了重构损失不会主导主要损失。

  • 接下来,在训练过程中,call()方法更新重构度量并将其添加到模型中以便显示。这段代码示例实际上可以通过调用self.add_metric(recon_loss)来简化:Keras 将自动为您跟踪均值。

  • 最后,call()方法将隐藏层的输出传递给输出层,并返回其输出。

在训练过程中,总损失和重构损失都会下降:

Epoch 1/5
363/363 [========] - 1s 820us/step - loss: 0.7640 - reconstruction_error: 1.2728
Epoch 2/5
363/363 [========] - 0s 809us/step - loss: 0.4584 - reconstruction_error: 0.6340
[...]

在大多数情况下,到目前为止我们讨论的一切将足以实现您想构建的任何模型,即使是具有复杂架构、损失和指标。然而,对于一些架构,如 GANs(参见第十七章),您将不得不自定义训练循环本身。在我们到达那里之前,我们必须看看如何在 TensorFlow 中自动计算梯度。

使用自动微分计算梯度

要了解如何使用自动微分(参见第十章和附录 B)自动计算梯度,让我们考虑一个简单的玩具函数:

def f(w1, w2):
    return 3 * w1 ** 2 + 2 * w1 * w2

如果你懂微积分,你可以分析地找到这个函数相对于w1的偏导数是6 * w1 + 2 * w2。你也可以找到它相对于w2的偏导数是2 * w1。例如,在点(w1, w2) = (5, 3),这些偏导数分别等于 36 和 10,因此在这一点的梯度向量是(36,10)。但如果这是一个神经网络,这个函数会复杂得多,通常有数万个参数,通过手工分析找到偏导数将是一个几乎不可能的任务。一个解决方案是通过测量当你微调相应参数一点点时函数的输出如何变化来计算每个偏导数的近似值:

>>> w1, w2 = 5, 3
>>> eps = 1e-6
>>> (f(w1 + eps, w2) - f(w1, w2)) / eps
36.000003007075065
>>> (f(w1, w2 + eps) - f(w1, w2)) / eps
10.000000003174137

看起来不错!这个方法运行得相当好,而且易于实现,但它只是一个近似值,重要的是你需要至少针对每个参数调用一次f()(不是两次,因为我们可以只计算一次f(w1, w2))。每个参数至少调用一次f()使得这种方法在大型神经网络中变得难以处理。因此,我们应该使用反向模式自动微分。TensorFlow 使这变得非常简单:

w1, w2 = tf.Variable(5.), tf.Variable(3.)
with tf.GradientTape() as tape:
    z = f(w1, w2)

gradients = tape.gradient(z, [w1, w2])

首先我们定义两个变量w1w2,然后我们创建一个tf.GradientTape上下文,它将自动记录涉及变量的每个操作,最后我们要求这个磁带计算结果z相对于两个变量[w1, w2]的梯度。让我们看看 TensorFlow 计算的梯度:

>>> gradients
[<tf.Tensor: shape=(), dtype=float32, numpy=36.0>,
 <tf.Tensor: shape=(), dtype=float32, numpy=10.0>]

太棒了!结果不仅准确(精度仅受浮点误差限制),而且gradient()方法只需通过记录的计算一次(按相反顺序),无论有多少变量,因此非常高效。就像魔术一样!

提示

为了节省内存,在tf.GradientTape()块中只放入严格的最小值。或者,通过在tf.GradientTape()块内创建一个with tape.stop_recording()块来暂停记录。

在调用其gradient()方法后,磁带会立即被擦除,因此如果尝试两次调用gradient(),将会收到异常:

with tf.GradientTape() as tape:
    z = f(w1, w2)

dz_dw1 = tape.gradient(z, w1)  # returns tensor 36.0
dz_dw2 = tape.gradient(z, w2)  # raises a RuntimeError!

如果您需要多次调用gradient(),您必须使磁带持久化,并在每次完成后删除它以释放资源:

with tf.GradientTape(persistent=True) as tape:
    z = f(w1, w2)

dz_dw1 = tape.gradient(z, w1)  # returns tensor 36.0
dz_dw2 = tape.gradient(z, w2)  # returns tensor 10.0, works fine now!
del tape

默认情况下,磁带只会跟踪涉及变量的操作,因此,如果您尝试计算z相对于除变量以外的任何东西的梯度,结果将是None

c1, c2 = tf.constant(5.), tf.constant(3.)
with tf.GradientTape() as tape:
    z = f(c1, c2)

gradients = tape.gradient(z, [c1, c2])  # returns [None, None]

但是,您可以强制磁带监视任何您喜欢的张量,记录涉及它们的每个操作。然后,您可以计算相对于这些张量的梯度,就像它们是变量一样:

with tf.GradientTape() as tape:
    tape.watch(c1)
    tape.watch(c2)
    z = f(c1, c2)

gradients = tape.gradient(z, [c1, c2])  # returns [tensor 36., tensor 10.]

在某些情况下,这可能很有用,比如如果您想要实现一个正则化损失,惩罚激活在输入变化很小时变化很大的情况:损失将基于激活相对于输入的梯度。由于输入不是变量,您需要告诉磁带监视它们。

大多数情况下,梯度磁带用于计算单个值(通常是损失)相对于一组值(通常是模型参数)的梯度。这就是反向模式自动微分的优势所在,因为它只需要进行一次前向传递和一次反向传递就可以一次性获得所有梯度。如果尝试计算向量的梯度,例如包含多个损失的向量,那么 TensorFlow 将计算向量总和的梯度。因此,如果您需要获取各个梯度(例如,每个损失相对于模型参数的梯度),您必须调用磁带的jacobian()方法:它将为向量中的每个损失执行一次反向模式自动微分(默认情况下全部并行)。甚至可以计算二阶偏导数(Hessians,即偏导数的偏导数),但在实践中很少需要(请参阅本章笔记本的“使用自动微分计算梯度”部分以获取示例)。

在某些情况下,您可能希望阻止梯度通过神经网络的某些部分进行反向传播。为此,您必须使用tf.stop_gradient()函数。该函数在前向传递期间返回其输入(类似于tf.identity()),但在反向传播期间不允许梯度通过(它的作用类似于常数):

def f(w1, w2):
    return 3 * w1 ** 2 + tf.stop_gradient(2 * w1 * w2)

with tf.GradientTape() as tape:
    z = f(w1, w2)  # the forward pass is not affected by stop_gradient()

gradients = tape.gradient(z, [w1, w2])  # returns [tensor 30., None]

最后,当计算梯度时,您可能偶尔会遇到一些数值问题。例如,如果在x=10^(-50)处计算平方根函数的梯度,结果将是无穷大。实际上,该点的斜率并不是无穷大,但它超过了 32 位浮点数的处理能力:

>>> x = tf.Variable(1e-50)
>>> with tf.GradientTape() as tape:
...     z = tf.sqrt(x)
...
>>> tape.gradient(z, [x])
[<tf.Tensor: shape=(), dtype=float32, numpy=inf>]

为了解决这个问题,在计算平方根时,通常建议向x(例如 10^(-6))添加一个微小值。

指数函数也经常引起头痛,因为它增长非常快。例如,之前定义的my_softplus()的方式在数值上不稳定。如果计算my_softplus(100.0),您将得到无穷大而不是正确的结果(约为 100)。但是可以重写该函数以使其在数值上稳定:softplus 函数被定义为 log(1 + exp(z)),这也等于 log(1 + exp(–|z|)) + max(z, 0)(请参阅数学证明的笔记本),第二种形式的优势在于指数项不会爆炸。因此,这是my_softplus()函数的更好实现:

def my_softplus(z):
    return tf.math.log(1 + tf.exp(-tf.abs(z))) + tf.maximum(0., z)

在一些罕见的情况下,一个数值稳定的函数可能仍然具有数值不稳定的梯度。在这种情况下,你将不得不告诉 TensorFlow 使用哪个方程来计算梯度,而不是让它使用自动微分。为此,你必须在定义函数时使用@tf.​cus⁠tom_gradient装饰器,并返回函数的通常结果以及计算梯度的函数。例如,让我们更新my_softplus()函数,使其也返回一个数值稳定的梯度函数:

@tf.custom_gradient
def my_softplus(z):
    def my_softplus_gradients(grads):  # grads = backprop'ed from upper layers
        return grads * (1 - 1 / (1 + tf.exp(z)))  # stable grads of softplus

    result = tf.math.log(1 + tf.exp(-tf.abs(z))) + tf.maximum(0., z)
    return result, my_softplus_gradients

如果你懂微积分(参见关于这个主题的教程笔记本),你会发现 log(1 + exp(z))的导数是 exp(z) / (1 + exp(z))。但这种形式是不稳定的:对于较大的z值,它最终会计算出无穷大除以无穷大,返回 NaN。然而,通过一点代数操作,你可以证明它也等于 1 - 1 / (1 + exp(z)),这是稳定的。my_softplus_gradients()函数使用这个方程来计算梯度。请注意,这个函数将接收到目前为止反向传播的梯度,一直到my_softplus()函数,并根据链式法则,我们必须将它们与这个函数的梯度相乘。

现在当我们计算my_softplus()函数的梯度时,即使对于较大的输入值,我们也会得到正确的结果。

恭喜!现在你可以计算任何函数的梯度(只要在计算时它是可微的),甚至在需要时阻止反向传播,并编写自己的梯度函数!这可能比你需要的灵活性更多,即使你构建自己的自定义训练循环。接下来你将看到如何做到这一点。

自定义训练循环

在某些情况下,fit()方法可能不够灵活以满足你的需求。例如,我们在第十章中讨论的Wide & Deep 论文使用了两种不同的优化器:一种用于宽路径,另一种用于深路径。由于fit()方法只使用一个优化器(在编译模型时指定的那个),实现这篇论文需要编写自己的自定义循环。

你可能也喜欢编写自定义训练循环,只是为了更有信心地确保它们确实按照你的意图执行(也许你对fit()方法的一些细节不确定)。有时候,让一切都显式化可能会感觉更安全。然而,请记住,编写自定义训练循环会使你的代码变得更长、更容易出错,并且更难维护。

提示

除非你在学习或确实需要额外的灵活性,否则应该优先使用fit()方法而不是实现自己的训练循环,特别是如果你在团队中工作。

首先,让我们构建一个简单的模型。不需要编译它,因为我们将手动处理训练循环:

l2_reg = tf.keras.regularizers.l2(0.05)
model = tf.keras.models.Sequential([
    tf.keras.layers.Dense(30, activation="relu", kernel_initializer="he_normal",
                          kernel_regularizer=l2_reg),
    tf.keras.layers.Dense(1, kernel_regularizer=l2_reg)
])

接下来,让我们创建一个小函数,从训练集中随机抽取一个批次的实例(在第十三章中,我们将讨论 tf.data API,它提供了一个更好的替代方案):

def random_batch(X, y, batch_size=32):
    idx = np.random.randint(len(X), size=batch_size)
    return X[idx], y[idx]

让我们还定义一个函数,用于显示训练状态,包括步数、总步数、自开始时的平均损失(我们将使用Mean指标来计算),以及其他指标:

def print_status_bar(step, total, loss, metrics=None):
    metrics = " - ".join([f"{m.name}: {m.result():.4f}"
                          for m in [loss] + (metrics or [])])
    end = "" if step < total else "\n"
    print(f"\r{step}/{total} - " + metrics, end=end)

这段代码很容易理解,除非你不熟悉 Python 的字符串格式化:{m.result():.4f}将指标的结果格式化为小数点后四位的浮点数,使用\r(回车)和end=""确保状态栏始终打印在同一行上。

有了这个,让我们开始吧!首先,我们需要定义一些超参数,并选择优化器、损失函数和指标(在这个例子中只有 MAE):

n_epochs = 5
batch_size = 32
n_steps = len(X_train) // batch_size
optimizer = tf.keras.optimizers.SGD(learning_rate=0.01)
loss_fn = tf.keras.losses.mean_squared_error
mean_loss = tf.keras.metrics.Mean(name="mean_loss")
metrics = [tf.keras.metrics.MeanAbsoluteError()]

现在我们准备构建自定义循环了!

for epoch in range(1, n_epochs + 1):
    print("Epoch {}/{}".format(epoch, n_epochs))
    for step in range(1, n_steps + 1):
        X_batch, y_batch = random_batch(X_train_scaled, y_train)
        with tf.GradientTape() as tape:
            y_pred = model(X_batch, training=True)
            main_loss = tf.reduce_mean(loss_fn(y_batch, y_pred))
            loss = tf.add_n([main_loss] + model.losses)

        gradients = tape.gradient(loss, model.trainable_variables)
        optimizer.apply_gradients(zip(gradients, model.trainable_variables))
        mean_loss(loss)
        for metric in metrics:
            metric(y_batch, y_pred)

        print_status_bar(step, n_steps, mean_loss, metrics)

    for metric in [mean_loss] + metrics:
        metric.reset_states()

这段代码中有很多内容,让我们来逐步解释一下:

  • 我们创建两个嵌套循环:一个用于时期,另一个用于时期内的批次。

  • 然后我们从训练集中抽取一个随机批次。

  • tf.GradientTape() 块内,我们对一个批次进行预测,使用模型作为一个函数,并计算损失:它等于主要损失加上其他损失(在这个模型中,每层有一个正则化损失)。由于 mean_squared_error() 函数返回每个实例的一个损失,我们使用 tf.reduce_mean() 计算批次的平均值(如果您想对每个实例应用不同的权重,这就是您应该做的地方)。正则化损失已经被减少为每个单一标量,所以我们只需要对它们求和(使用 tf.add_n(),它对相同形状和数据类型的多个张量求和)。

  • 接下来,我们要求磁带计算损失相对于每个可训练变量的梯度——不是所有变量!——并将它们应用于优化器以执行梯度下降步骤。

  • 然后我们更新平均损失和指标(在当前时期内),并显示状态栏。

  • 在每个时期结束时,我们重置平均损失和指标的状态。

如果您想应用梯度裁剪(参见第十一章),请设置优化器的 clipnormclipvalue 超参数。如果您想对梯度应用任何其他转换,只需在调用 apply_gradients() 方法之前这样做。如果您想向模型添加权重约束(例如,在创建层时设置 kernel_constraintbias_constraint),您应该更新训练循环以在 apply_gradients() 之后应用这些约束,就像这样:

for variable in model.variables:
    if variable.constraint is not None:
        variable.assign(variable.constraint(variable))
警告

在训练循环中调用模型时不要忘记设置 training=True,特别是如果您的模型在训练和测试期间表现不同(例如,如果它使用 BatchNormalizationDropout)。如果是自定义模型,请确保将 training 参数传播到您的模型调用的层。

正如您所看到的,有很多事情需要做对,很容易出错。但好的一面是,您可以完全控制。

现在您知道如何自定义模型的任何部分⁠¹⁵和训练算法,让我们看看如何使用 TensorFlow 的自动生成图形功能:它可以显著加快您的自定义代码,并且还将其移植到 TensorFlow 支持的任何平台(参见第十九章)。

TensorFlow 函数和图形

回到 TensorFlow 1,图形是不可避免的(伴随着复杂性),因为它们是 TensorFlow API 的核心部分。自从 TensorFlow 2(2019 年发布)以来,图形仍然存在,但不再是核心部分,而且使用起来简单得多(多得多!)。为了展示它们有多简单,让我们从一个计算其输入的立方的微不足道的函数开始:

def cube(x):
    return x ** 3

我们显然可以使用 Python 值(如整数或浮点数)调用此函数,或者我们可以使用张量调用它:

>>> cube(2)
8
>>> cube(tf.constant(2.0))
<tf.Tensor: shape=(), dtype=float32, numpy=8.0>

现在,让我们使用 tf.function() 将这个 Python 函数转换为 TensorFlow 函数

>>> tf_cube = tf.function(cube)
>>> tf_cube
<tensorflow.python.eager.def_function.Function at 0x7fbfe0c54d50>

然后,这个 TF 函数可以像原始的 Python 函数一样使用,并且将返回相同的结果(但始终作为张量):

>>> tf_cube(2)
<tf.Tensor: shape=(), dtype=int32, numpy=8>
>>> tf_cube(tf.constant(2.0))
<tf.Tensor: shape=(), dtype=float32, numpy=8.0>

在幕后,tf.function() 分析了 cube() 函数执行的计算,并生成了一个等效的计算图!正如您所看到的,这是相当轻松的(我们很快会看到这是如何工作的)。或者,我们也可以将 tf.function 用作装饰器;这实际上更常见:

@tf.function
def tf_cube(x):
    return x ** 3

原始的 Python 函数仍然可以通过 TF 函数的 python_function 属性访问,以防您需要它:

>>> tf_cube.python_function(2)
8

TensorFlow 优化计算图,修剪未使用的节点,简化表达式(例如,1 + 2 将被替换为 3)等。一旦优化的图准备就绪,TF 函数将有效地执行图中的操作,按适当的顺序(并在可能时并行执行)。因此,TF 函数通常比原始 Python 函数运行得快得多,特别是如果它执行复杂计算。大多数情况下,您实际上不需要知道更多:当您想要提升 Python 函数时,只需将其转换为 TF 函数。就这样!

此外,如果在调用tf.function()时设置jit_compile=True,那么 TensorFlow 将使用加速线性代数(XLA)为您的图编译专用内核,通常融合多个操作。例如,如果您的 TF 函数调用tf.reduce_sum(a * b + c),那么没有 XLA,函数首先需要计算a * b并将结果存储在临时变量中,然后将c添加到该变量中,最后调用tf.reduce_sum()处理结果。使用 XLA,整个计算将编译为单个内核,该内核将一次性计算tf.reduce_sum(a * b + c),而不使用任何大型临时变量。这不仅速度更快,而且使用的 RAM 大大减少。

当您编写自定义损失函数、自定义指标、自定义层或任何其他自定义函数,并在 Keras 模型中使用它(就像我们在本章中一直做的那样),Keras 会自动将您的函数转换为 TF 函数——无需使用tf.function()。因此,大多数情况下,这种魔术是 100%透明的。如果您希望 Keras 使用 XLA,只需在调用compile()方法时设置jit_compile=True。简单!

提示

您可以通过在创建自定义层或自定义模型时设置dynamic=True来告诉 Keras将您的 Python 函数转换为 TF 函数。或者,您可以在调用模型的compile()方法时设置run_eagerly=True

默认情况下,TF 函数为每个唯一的输入形状和数据类型生成一个新图,并将其缓存以供后续调用。例如,如果您调用tf_cube(tf.constant(10)),将为形状为[]的 int32 张量生成一个图。然后,如果您调用tf_cube(tf.constant(20)),将重用相同的图。但是,如果您随后调用tf_cube(tf.constant([10, 20])),将为形状为[2]的 int32 张量生成一个新图。这就是 TF 函数处理多态性(即不同的参数类型和形状)的方式。但是,这仅适用于张量参数:如果将数值 Python 值传递给 TF 函数,则将为每个不同的值生成一个新图:例如,调用tf_cube(10)tf_cube(20)将生成两个图。

警告

如果您多次使用不同的数值 Python 值调用 TF 函数,则将生成许多图,减慢程序速度并使用大量 RAM(您必须删除 TF 函数才能释放它)。Python 值应保留用于将具有少量唯一值的参数,例如每层神经元的数量之类的超参数。这样可以使 TensorFlow 更好地优化模型的每个变体。

AutoGraph 和跟踪

那么 TensorFlow 如何生成图呢?它首先通过分析 Python 函数的源代码来捕获所有控制流语句,比如for循环、while循环和if语句,以及breakcontinuereturn语句。这第一步被称为AutoGraph。TensorFlow 必须分析源代码的原因是 Python 没有提供其他捕获控制流语句的方法:它提供了像__add__()__mul__()这样的魔术方法来捕获+*等运算符,但没有__while__()__if__()这样的魔术方法。在分析函数代码之后,AutoGraph 会输出一个升级版本的函数,其中所有控制流语句都被适当的 TensorFlow 操作替换,比如tf.while_loop()用于循环,tf.cond()用于if语句。例如,在图 12-4 中,AutoGraph 分析了sum_squares() Python 函数的源代码,并生成了tf__sum_squares()函数。在这个函数中,for循环被替换为loop_body()函数的定义(包含原始for循环的主体),然后调用for_stmt()函数。这个调用将在计算图中构建适当的tf.while_loop()操作。

mls3 1204

图 12-4. TensorFlow 如何使用 AutoGraph 和跟踪生成图

接下来,TensorFlow 调用这个“升级”函数,但不是传递参数,而是传递一个符号张量—一个没有实际值的张量,只有一个名称、一个数据类型和一个形状。例如,如果您调用sum_squares(tf.constant(10)),那么tf__sum_squares()函数将被调用,传递一个类型为 int32、形状为[]的符号张量。该函数将在图模式下运行,这意味着每个 TensorFlow 操作都会在图中添加一个节点来表示自己和其输出张量(与常规模式相反,称为急切执行急切模式)。在图模式下,TF 操作不执行任何计算。图模式是 TensorFlow 1 中的默认模式。在图 12-4 中,您可以看到tf__sum_squares()函数被调用,其参数是一个符号张量(在这种情况下,一个形状为[]的 int32 张量),以及在跟踪期间生成的最终图。节点表示操作,箭头表示张量(生成的函数和图都被简化了)。

提示

为了查看生成的函数源代码,您可以调用tf.autograph.to_code(sum_squares.python_function)。代码并不一定要漂亮,但有时可以帮助调试。

TF 函数规则

大多数情况下,将执行 TensorFlow 操作的 Python 函数转换为 TF 函数是微不足道的:用@tf.function装饰它,或者让 Keras 为您处理。但是,有一些规则需要遵守:

  • 如果调用任何外部库,包括 NumPy 甚至标准库,这个调用只会在跟踪期间运行;它不会成为图的一部分。实际上,TensorFlow 图只能包括 TensorFlow 构造(张量、操作、变量、数据集等)。因此,请确保使用tf.reduce_sum()而不是np.sum()tf.sort()而不是内置的sorted()函数,等等(除非您真的希望代码只在跟踪期间运行)。这还有一些额外的影响:

    • 如果您定义了一个 TF 函数f(*x*),它只返回np.random.rand(),那么只有在跟踪函数时才会生成一个随机数,因此f(tf.constant(2.))f(tf.constant(3.))将返回相同的随机数,但f(tf.constant([2., 3.]))将返回一个不同的随机数。如果将np.random.rand()替换为tf.random.uniform([]),那么每次调用都会生成一个新的随机数,因为该操作将成为图的一部分。

    • 如果您的非 TensorFlow 代码具有副作用(例如记录某些内容或更新 Python 计数器),那么您不应该期望每次调用 TF 函数时都会发生这些副作用,因为它们只会在函数被跟踪时发生。

    • 您可以在 tf.py_function() 操作中包装任意的 Python 代码,但这样做会影响性能,因为 TensorFlow 将无法对此代码进行任何图优化。这也会降低可移植性,因为图仅在安装了正确库的平台上运行 Python 可用(和 Python 可用的平台)。

  • 您可以调用其他 Python 函数或 TF 函数,但它们应该遵循相同的规则,因为 TensorFlow 将捕获它们的操作在计算图中。请注意,这些其他函数不需要用 @tf.function 装饰。

  • 如果函数创建了 TensorFlow 变量(或任何其他有状态的 TensorFlow 对象,例如数据集或队列),它必须在第一次调用时才能这样做,否则您将收到异常。通常最好在 TF 函数之外创建变量(例如,在自定义层的 build() 方法中)。如果要为变量分配新值,请确保调用其 assign() 方法,而不是使用 = 运算符。

  • 您的 Python 函数的源代码应该对 TensorFlow 可用。如果源代码不可用(例如,如果您在 Python shell 中定义函数,无法访问源代码,或者如果您仅将编译后的 *.pyc Python 文件部署到生产环境),则图生成过程将失败或功能有限。

  • TensorFlow 仅会捕获对张量或 tf.data.Dataset 进行迭代的 for 循环(请参见第十三章)。因此,请确保使用 for i in tf.range(*x*) 而不是 for i in range(*x*),否则循环将不会在图中被捕获。相反,它将在跟踪期间运行。(如果 for 循环旨在构建图,例如在神经网络中创建每个层,那么这可能是您想要的。)

  • 一如既往,出于性能原因,您应该尽可能使用矢量化实现,而不是使用循环。

是时候总结了!在本章中,我们从 TensorFlow 的简要概述开始,然后看了 TensorFlow 的低级 API,包括张量、操作、变量和特殊数据结构。然后我们使用这些工具来自定义 Keras API 中的几乎每个组件。最后,我们看了 TF 函数如何提升性能,如何使用 AutoGraph 和跟踪生成图形,以及编写 TF 函数时应遵循的规则(如果您想进一步打开黑匣子并探索生成的图形,您将在附录 D 中找到技术细节)。

在下一章中,我们将学习如何使用 TensorFlow 高效加载和预处理数据。

练习

  1. 您如何用简短的句子描述 TensorFlow?它的主要特点是什么?您能否列出其他流行的深度学习库?

  2. TensorFlow 是否可以替代 NumPy?它们之间的主要区别是什么?

  3. tf.range(10)tf.constant(np.​ara⁠nge(10)) 会得到相同的结果吗?

  4. 您能否列出 TensorFlow 中除了常规张量之外的其他六种数据结构?

  5. 您可以通过编写函数或子类化 tf.keras.losses.Loss 类来定义自定义损失函数。您会在什么时候使用每个选项?

  6. 同样,您可以在函数中定义自定义指标,也可以作为 tf.keras.metrics.Metric 的子类。您会在什么时候使用每个选项?

  7. 何时应该创建自定义层而不是自定义模型?

  8. 有哪些需要编写自定义训练循环的用例?

  9. 自定义 Keras 组件可以包含任意的 Python 代码吗,还是必须可转换为 TF 函数?

  10. 如果您希望函数可转换为 TF 函数,主要需要遵守哪些规则?

  11. 何时需要创建一个动态的 Keras 模型?如何做到这一点?为什么不将所有模型都设置为动态的呢?

  12. 实现一个执行层归一化的自定义层(我们将在第十五章中使用这种类型的层):

    1. build()方法应该定义两个可训练的权重αβ,形状都是input_shape[-1:],数据类型为tf.float32α应该初始化为 1,β初始化为 0。

    2. call()方法应该计算每个实例特征的平均值μ和标准差σ。为此,您可以使用tf.nn.moments(inputs, axes=-1, keepdims=True),它返回所有实例的平均值μ和方差σ²(计算方差的平方根以获得标准差)。然后函数应该计算并返回α ⊗ (X - μ)/(σ + ε) + β,其中 ⊗ 表示逐元素乘法(*),ε是一个平滑项(一个小常数,避免除以零,例如 0.001)。

    3. 确保您的自定义层产生与tf.keras.layers.LayerNormalization层相同(或非常接近)的输出。

  13. 使用自定义训练循环训练一个模型,以处理 Fashion MNIST 数据集(参见第十章):

    1. 显示每个时代、迭代、平均训练损失和每个时代的平均准确率(在每次迭代更新),以及每个时代结束时的验证损失和准确率。

    2. 尝试使用不同的优化器以及不同的学习率来处理上层和下层。

这些练习的解决方案可以在本章笔记本的末尾找到,网址为https://homl.info/colab3

然而,Facebook 的 PyTorch 库目前在学术界更受欢迎:比起 TensorFlow 或 Keras,更多的论文引用 PyTorch。此外,Google 的 JAX 库正在获得动力,尤其是在学术界。

TensorFlow 包括另一个名为estimators API的深度学习 API,但现在已经不推荐使用。

如果您有需要(但您可能不会),您可以使用 C++ API 编写自己的操作。

要了解更多关于 TPU 以及它们如何工作的信息,请查看https://homl.info/tpus

tf.math.log()是一个值得注意的例外,它通常被使用,但没有tf.log()的别名,因为这可能会与日志记录混淆。

使用加权平均值不是一个好主意:如果这样做,那么具有相同权重但在不同批次中的两个实例将对训练产生不同的影响,这取决于每个批次的总权重。

{**x, [...]}语法是在 Python 3.5 中添加的,用于将字典x中的所有键/值对合并到另一个字典中。自 Python 3.9 起,您可以使用更好的x | y语法(其中xy是两个字典)。

然而,Huber 损失很少用作度量标准——通常更喜欢使用 MAE 或 MSE。

这个类仅用于说明目的。一个更简单和更好的实现方法是只需子类化tf.keras.metrics.Mean类;请参阅本章笔记本的“流式指标”部分以获取示例。

Keras API 将此参数称为input_shape,但由于它还包括批量维度,我更喜欢将其称为batch_input_shape

在 Keras 中,“子类 API”通常只指通过子类化创建自定义模型,尽管在本章中您已经看到,许多其他东西也可以通过子类化创建。

由于 TensorFlow 问题#46858,这种情况下调用super().build()可能会失败,除非在您阅读此内容时已修复该问题。如果没有,请将此行替换为self.built = True

您还可以在模型内的任何层上调用add_loss(),因为模型会递归地从所有层中收集损失。

如果磁带超出范围,例如当使用它的函数返回时,Python 的垃圾收集器会为您删除它。

除了优化器之外,很少有人会自定义这些;请参阅笔记本中的“自定义优化器”部分以获取示例。

然而,在这个简单的例子中,计算图非常小,几乎没有任何优化的空间,所以tf_cube()实际上比cube()运行得慢得多。

第十三章:使用 TensorFlow 加载和预处理数据

在第二章中,您看到加载和预处理数据是任何机器学习项目的重要部分。您使用 Pandas 加载和探索(修改后的)加利福尼亚房屋数据集——该数据集存储在 CSV 文件中——并应用 Scikit-Learn 的转换器进行预处理。这些工具非常方便,您可能会经常使用它们,特别是在探索和实验数据时。

然而,在大型数据集上训练 TensorFlow 模型时,您可能更喜欢使用 TensorFlow 自己的数据加载和预处理 API,称为tf.data。它能够非常高效地加载和预处理数据,使用多线程和排队从多个文件中并行读取数据,对样本进行洗牌和分批处理等。此外,它可以实时执行所有这些操作——在 GPU 或 TPU 正在训练当前批次数据时,它会在多个 CPU 核心上加载和预处理下一批数据。

tf.data API 允许您处理无法放入内存的数据集,并充分利用硬件资源,从而加快训练速度。tf.data API 可以直接从文本文件(如 CSV 文件)、具有固定大小记录的二进制文件以及使用 TensorFlow 的 TFRecord 格式的二进制文件中读取数据。

TFRecord 是一种灵活高效的二进制格式,通常包含协议缓冲区(一种开源二进制格式)。tf.data API 还支持从 SQL 数据库中读取数据。此外,许多开源扩展可用于从各种数据源中读取数据,例如 Google 的 BigQuery 服务(请参阅https://tensorflow.org/io)。

Keras 还提供了强大而易于使用的预处理层,可以嵌入到您的模型中:这样,当您将模型部署到生产环境时,它将能够直接摄取原始数据,而无需您添加任何额外的预处理代码。这消除了训练期间使用的预处理代码与生产中使用的预处理代码之间不匹配的风险,这可能会导致训练/服务偏差。如果您将模型部署在使用不同编程语言编写的多个应用程序中,您不必多次重新实现相同的预处理代码,这也减少了不匹配的风险。

正如您将看到的,这两个 API 可以联合使用——例如,从 tf.data 提供的高效数据加载和 Keras 预处理层的便利性中受益。

在本章中,我们将首先介绍 tf.data API 和 TFRecord 格式。然后我们将探索 Keras 预处理层以及如何将它们与 tf.data API 一起使用。最后,我们将快速查看一些相关的库,您可能会发现它们在加载和预处理数据时很有用,例如 TensorFlow Datasets 和 TensorFlow Hub。所以,让我们开始吧!

tf.data API

整个 tf.data API 围绕着 tf.data.Dataset 的概念展开:这代表了一系列数据项。通常,您会使用逐渐从磁盘读取数据的数据集,但为了简单起见,让我们使用 tf.data.Dataset.from_tensor_slices() 从一个简单的数据张量创建数据集:

>>> import tensorflow as tf
>>> X = tf.range(10)  # any data tensor
>>> dataset = tf.data.Dataset.from_tensor_slices(X)
>>> dataset
<TensorSliceDataset shapes: (), types: tf.int32>

from_tensor_slices() 函数接受一个张量,并创建一个 tf.data.Dataset,其中的元素是沿着第一维度的所有 X 的切片,因此这个数据集包含 10 个项目:张量 0、1、2、…​、9。在这种情况下,如果我们使用 tf.data.Dataset.range(10),我们将获得相同的数据集(除了元素将是 64 位整数而不是 32 位整数)。

您可以简单地迭代数据集的项目,如下所示:

>>> for item in dataset:
...     print(item)
...
tf.Tensor(0, shape=(), dtype=int32)
tf.Tensor(1, shape=(), dtype=int32)
[...]
tf.Tensor(9, shape=(), dtype=int32)
注意

tf.data API 是一个流式 API:您可以非常高效地迭代数据集的项目,但该 API 不适用于索引或切片。

数据集还可以包含张量的元组,或名称/张量对的字典,甚至是张量的嵌套元组和字典。在对元组、字典或嵌套结构进行切片时,数据集将仅切片它包含的张量,同时保留元组/字典结构。例如:

>>> X_nested = {"a": ([1, 2, 3], [4, 5, 6]), "b": [7, 8, 9]}
>>> dataset = tf.data.Dataset.from_tensor_slices(X_nested)
>>> for item in dataset:
...     print(item)
...
{'a': (<tf.Tensor: [...]=1>, <tf.Tensor: [...]=4>), 'b': <tf.Tensor: [...]=7>}
{'a': (<tf.Tensor: [...]=2>, <tf.Tensor: [...]=5>), 'b': <tf.Tensor: [...]=8>}
{'a': (<tf.Tensor: [...]=3>, <tf.Tensor: [...]=6>), 'b': <tf.Tensor: [...]=9>}

链接转换

一旦您有了数据集,您可以通过调用其转换方法对其应用各种转换。每个方法都会返回一个新的数据集,因此您可以像这样链接转换(此链在图 13-1 中有示例):

>>> dataset = tf.data.Dataset.from_tensor_slices(tf.range(10))
>>> dataset = dataset.repeat(3).batch(7)
>>> for item in dataset:
...     print(item)
...
tf.Tensor([0 1 2 3 4 5 6], shape=(7,), dtype=int32)
tf.Tensor([7 8 9 0 1 2 3], shape=(7,), dtype=int32)
tf.Tensor([4 5 6 7 8 9 0], shape=(7,), dtype=int32)
tf.Tensor([1 2 3 4 5 6 7], shape=(7,), dtype=int32)
tf.Tensor([8 9], shape=(2,), dtype=int32)

在这个例子中,我们首先在原始数据集上调用repeat()方法,它返回一个将原始数据集的项目重复三次的新数据集。当然,这不会将所有数据在内存中复制三次!如果您调用此方法而没有参数,新数据集将永远重复源数据集,因此迭代数据集的代码将不得不决定何时停止。

然后我们在这个新数据集上调用batch()方法,再次创建一个新数据集。这个新数据集将把前一个数据集的项目分组成七个项目一组的批次。

mls3 1301

图 13-1. 链接数据集转换

最后,我们迭代这个最终数据集的项目。batch()方法必须输出一个大小为两而不是七的最终批次,但是如果您希望删除这个最终批次,使所有批次具有完全相同的大小,可以调用batch()并使用drop_remainder=True

警告

数据集方法不会修改数据集,它们会创建新的数据集。因此,请确保保留对这些新数据集的引用(例如,使用dataset = ...),否则什么也不会发生。

您还可以通过调用map()方法来转换项目。例如,这将创建一个所有批次乘以二的新数据集:

>>> dataset = dataset.map(lambda x: x * 2)  # x is a batch
>>> for item in dataset:
...     print(item)
...
tf.Tensor([ 0  2  4  6  8 10 12], shape=(7,), dtype=int32)
tf.Tensor([14 16 18  0  2  4  6], shape=(7,), dtype=int32)
[...]

这个map()方法是您将调用的方法,用于对数据进行任何预处理。有时这将包括一些可能相当密集的计算,比如重塑或旋转图像,因此您通常会希望启动多个线程以加快速度。这可以通过将num_parallel_calls参数设置为要运行的线程数,或者设置为tf.data.AUTOTUNE来完成。请注意,您传递给map()方法的函数必须可以转换为 TF 函数(请参阅第十二章)。

还可以使用filter()方法简单地过滤数据集。例如,此代码创建一个仅包含总和大于 50 的批次的数据集:

>>> dataset = dataset.filter(lambda x: tf.reduce_sum(x) > 50)
>>> for item in dataset:
...     print(item)
...
tf.Tensor([14 16 18  0  2  4  6], shape=(7,), dtype=int32)
tf.Tensor([ 8 10 12 14 16 18  0], shape=(7,), dtype=int32)
tf.Tensor([ 2  4  6  8 10 12 14], shape=(7,), dtype=int32)

您经常会想查看数据集中的一些项目。您可以使用take()方法来实现:

>>> for item in dataset.take(2):
...     print(item)
...
tf.Tensor([14 16 18  0  2  4  6], shape=(7,), dtype=int32)
tf.Tensor([ 8 10 12 14 16 18  0], shape=(7,), dtype=int32)

数据洗牌

正如我们在第四章中讨论的,梯度下降在训练集中的实例是独立且同分布(IID)时效果最好。确保这一点的一个简单方法是对实例进行洗牌,使用shuffle()方法。它将创建一个新数据集,首先用源数据集的前几个项目填充缓冲区。然后,每当需要一个项目时,它将从缓冲区随机取出一个项目,并用源数据集中的新项目替换它,直到完全迭代源数据集。在这一点上,它将继续从缓冲区随机取出项目,直到缓冲区为空。您必须指定缓冲区大小,并且很重要的是要足够大,否则洗牌效果不会很好。⁠¹ 只是不要超出您拥有的 RAM 量,尽管即使您有很多 RAM,也没有必要超出数据集的大小。如果您希望每次运行程序时都获得相同的随机顺序,可以提供一个随机种子。例如,以下代码创建并显示一个包含 0 到 9 的整数,重复两次,使用大小为 4 的缓冲区和随机种子 42 进行洗牌,并使用批次大小为 7 进行批处理的数据集:

>>> dataset = tf.data.Dataset.range(10).repeat(2)
>>> dataset = dataset.shuffle(buffer_size=4, seed=42).batch(7)
>>> for item in dataset:
...     print(item)
...
tf.Tensor([3 0 1 6 2 5 7], shape=(7,), dtype=int64)
tf.Tensor([8 4 1 9 4 2 3], shape=(7,), dtype=int64)
tf.Tensor([7 5 0 8 9 6], shape=(6,), dtype=int64)
提示

如果在打乱的数据集上调用repeat(),默认情况下它将在每次迭代时生成一个新的顺序。这通常是个好主意,但是如果您希望在每次迭代中重复使用相同的顺序(例如,用于测试或调试),可以在调用shuffle()时设置reshuffle_each_​itera⁠tion=False

对于一个无法放入内存的大型数据集,这种简单的打乱缓冲区方法可能不够,因为缓冲区相对于数据集来说很小。一个解决方案是对源数据本身进行打乱(例如,在 Linux 上可以使用shuf命令对文本文件进行打乱)。这将显著改善打乱效果!即使源数据已经被打乱,通常也会希望再次打乱,否则每个时期将重复相同的顺序,模型可能会出现偏差(例如,由于源数据顺序中偶然存在的一些虚假模式)。为了进一步打乱实例,一个常见的方法是将源数据拆分为多个文件,然后在训练过程中以随机顺序读取它们。然而,位于同一文件中的实例仍然会相互靠近。为了避免这种情况,您可以随机选择多个文件并同时读取它们,交错它们的记录。然后在此基础上使用shuffle()方法添加一个打乱缓冲区。如果这听起来很费力,不用担心:tf.data API 可以在几行代码中实现所有这些。让我们看看您可以如何做到这一点。

从多个文件中交错行

首先,假设您已经加载了加利福尼亚房屋数据集,对其进行了打乱(除非已经打乱),并将其分为训练集、验证集和测试集。然后将每个集合分成许多 CSV 文件,每个文件看起来像这样(每行包含八个输入特征加上目标中位房价):

MedInc,HouseAge,AveRooms,AveBedrms,Popul…,AveOccup,Lat…,Long…,MedianHouseValue
3.5214,15.0,3.050,1.107,1447.0,1.606,37.63,-122.43,1.442
5.3275,5.0,6.490,0.991,3464.0,3.443,33.69,-117.39,1.687
3.1,29.0,7.542,1.592,1328.0,2.251,38.44,-122.98,1.621
[...]

假设train_filepaths包含训练文件路径列表(您还有valid_filepathstest_filepaths):

>>> train_filepaths
['datasets/housing/my_train_00.csv', 'datasets/housing/my_train_01.csv', ...]

或者,您可以使用文件模式;例如,train_filepaths = "datasets/housing/my_train_*.csv"。现在让我们创建一个仅包含这些文件路径的数据集:

filepath_dataset = tf.data.Dataset.list_files(train_filepaths, seed=42)

默认情况下,list_files()函数返回一个打乱文件路径的数据集。一般来说这是件好事,但是如果出于某种原因不想要这样,可以设置shuffle=False

接下来,您可以调用interleave()方法一次从五个文件中读取并交错它们的行。您还可以使用skip()方法跳过每个文件的第一行(即标题行):

n_readers = 5
dataset = filepath_dataset.interleave(
    lambda filepath: tf.data.TextLineDataset(filepath).skip(1),
    cycle_length=n_readers)

interleave()方法将创建一个数据集,从filepath_dataset中提取五个文件路径,对于每个文件路径,它将调用您提供的函数(在本例中是 lambda 函数)来创建一个新的数据集(在本例中是TextLineDataset)。清楚地说,在这个阶段总共会有七个数据集:文件路径数据集、交错数据集以及交错数据集内部创建的五个TextLineDataset。当您迭代交错数据集时,它将循环遍历这五个TextLineDataset,从每个数据集中逐行读取,直到所有数据集都用完。然后它将从filepath_dataset中获取下一个五个文件路径,并以相同的方式交错它们,依此类推,直到文件路径用完。为了使交错效果最佳,最好拥有相同长度的文件;否则最长文件的末尾将不会被交错。

默认情况下,interleave()不使用并行处理;它只是顺序地从每个文件中一次读取一行。如果您希望实际并行读取文件,可以将interleave()方法的num_parallel_calls参数设置为您想要的线程数(请记住,map()方法也有这个参数)。甚至可以将其设置为tf.data.AUTOTUNE,让 TensorFlow 根据可用的 CPU 动态选择正确的线程数。现在让我们看看数据集现在包含什么:

>>> for line in dataset.take(5):
...     print(line)
...
tf.Tensor(b'4.5909,16.0,[...],33.63,-117.71,2.418', shape=(), dtype=string)
tf.Tensor(b'2.4792,24.0,[...],34.18,-118.38,2.0', shape=(), dtype=string)
tf.Tensor(b'4.2708,45.0,[...],37.48,-122.19,2.67', shape=(), dtype=string)
tf.Tensor(b'2.1856,41.0,[...],32.76,-117.12,1.205', shape=(), dtype=string)
tf.Tensor(b'4.1812,52.0,[...],33.73,-118.31,3.215', shape=(), dtype=string)

这些是随机选择的五个 CSV 文件的第一行(忽略标题行)。看起来不错!

注意

可以将文件路径列表传递给 TextLineDataset 构造函数:它将按顺序遍历每个文件的每一行。如果还将 num_parallel_reads 参数设置为大于一的数字,那么数据集将并行读取该数量的文件,并交错它们的行(无需调用 interleave() 方法)。但是,它不会对文件进行洗牌,也不会跳过标题行。

数据预处理

现在我们有一个返回每个实例的住房数据集,其中包含一个字节字符串的张量,我们需要进行一些预处理,包括解析字符串和缩放数据。让我们实现一些自定义函数来执行这些预处理:

X_mean, X_std = [...]  # mean and scale of each feature in the training set
n_inputs = 8

def parse_csv_line(line):
    defs = [0.] * n_inputs + [tf.constant([], dtype=tf.float32)]
    fields = tf.io.decode_csv(line, record_defaults=defs)
    return tf.stack(fields[:-1]), tf.stack(fields[-1:])

def preprocess(line):
    x, y = parse_csv_line(line)
    return (x - X_mean) / X_std, y

让我们逐步解释这段代码:

  • 首先,代码假设我们已经预先计算了训练集中每个特征的均值和标准差。X_meanX_std 只是包含八个浮点数的 1D 张量(或 NumPy 数组),每个输入特征一个。可以使用 Scikit-Learn 的 StandardScaler 在数据集的足够大的随机样本上完成这个操作。在本章的后面,我们将使用 Keras 预处理层来代替。

  • parse_csv_line() 函数接受一个 CSV 行并对其进行解析。为了帮助实现这一点,它使用 tf.io.decode_csv() 函数,该函数接受两个参数:第一个是要解析的行,第二个是包含 CSV 文件中每列的默认值的数组。这个数组(defs)告诉 TensorFlow 不仅每列的默认值是什么,还告诉它列的数量和类型。在这个例子中,我们告诉它所有特征列都是浮点数,缺失值应默认为零,但我们为最后一列(目标)提供了一个空的 tf.float32 类型的默认值数组:该数组告诉 TensorFlow 这一列包含浮点数,但没有默认值,因此如果遇到缺失值,它将引发异常。

  • tf.io.decode_csv() 函数返回一个标量张量列表(每列一个),但我们需要返回一个 1D 张量数组。因此,我们对除最后一个(目标)之外的所有张量调用 tf.stack():这将这些张量堆叠成一个 1D 数组。然后我们对目标值做同样的操作:这将使其成为一个包含单个值的 1D 张量数组,而不是标量张量。tf.io.decode_csv() 函数完成后,它将返回输入特征和目标。

  • 最后,自定义的 preprocess() 函数只调用 parse_csv_line() 函数,通过减去特征均值然后除以特征标准差来缩放输入特征,并返回一个包含缩放特征和目标的元组。

让我们测试这个预处理函数:

>>> preprocess(b'4.2083,44.0,5.3232,0.9171,846.0,2.3370,37.47,-122.2,2.782')
(<tf.Tensor: shape=(8,), dtype=float32, numpy=
 array([ 0.16579159,  1.216324  , -0.05204564, -0.39215982, -0.5277444 ,
 -0.2633488 ,  0.8543046 , -1.3072058 ], dtype=float32)>,
 <tf.Tensor: shape=(1,), dtype=float32, numpy=array([2.782], dtype=float32)>)

看起来不错!preprocess() 函数可以将一个实例从字节字符串转换为一个漂亮的缩放张量,带有相应的标签。我们现在可以使用数据集的 map() 方法将 preprocess() 函数应用于数据集中的每个样本。

将所有内容放在一起

为了使代码更具重用性,让我们将迄今为止讨论的所有内容放在另一个辅助函数中;它将创建并返回一个数据集,该数据集将高效地从多个 CSV 文件中加载加利福尼亚房屋数据,对其进行预处理、洗牌和分批处理(参见图 13-2):

def csv_reader_dataset(filepaths, n_readers=5, n_read_threads=None,
                       n_parse_threads=5, shuffle_buffer_size=10_000, seed=42,
                       batch_size=32):
    dataset = tf.data.Dataset.list_files(filepaths, seed=seed)
    dataset = dataset.interleave(
        lambda filepath: tf.data.TextLineDataset(filepath).skip(1),
        cycle_length=n_readers, num_parallel_calls=n_read_threads)
    dataset = dataset.map(preprocess, num_parallel_calls=n_parse_threads)
    dataset = dataset.shuffle(shuffle_buffer_size, seed=seed)
    return dataset.batch(batch_size).prefetch(1)

请注意,我们在最后一行使用了 prefetch() 方法。这对性能很重要,你现在会看到。

mls3 1302

图 13-2. 从多个 CSV 文件加载和预处理数据

预取

通过在自定义csv_reader_dataset()函数末尾调用prefetch(1),我们正在创建一个数据集,该数据集将尽力始终领先一个批次。换句话说,当我们的训练算法在处理一个批次时,数据集将已经在并行工作,准备好获取下一个批次(例如,从磁盘读取数据并对其进行预处理)。这可以显著提高性能,如图 13-3 所示。

如果我们还确保加载和预处理是多线程的(通过在调用interleave()map()时设置num_parallel_calls),我们可以利用多个 CPU 核心,希望准备一个数据批次的时间比在 GPU 上运行训练步骤要短:这样 GPU 将几乎 100%利用(除了从 CPU 到 GPU 的数据传输时间)[3],训练将运行得更快。

mls3 1303

图 13-3。通过预取,CPU 和 GPU 并行工作:当 GPU 处理一个批次时,CPU 处理下一个批次
提示

如果您计划购买 GPU 卡,其处理能力和内存大小当然非常重要(特别是对于大型计算机视觉或自然语言处理模型,大量的 RAM 至关重要)。对于良好性能同样重要的是 GPU 的内存带宽;这是它每秒可以将多少千兆字节的数据进出其 RAM。

如果数据集足够小,可以放入内存,您可以通过使用数据集的cache()方法将其内容缓存到 RAM 来显着加快训练速度。通常应在加载和预处理数据之后,但在洗牌、重复、批处理和预取之前执行此操作。这样,每个实例只会被读取和预处理一次(而不是每个时期一次),但数据仍然会在每个时期以不同的方式洗牌,下一批数据仍然会提前准备好。

您现在已经学会了如何构建高效的输入管道,从多个文本文件加载和预处理数据。我们已经讨论了最常见的数据集方法,但还有一些您可能想看看的方法,例如concatenate()zip()window()reduce()shard()flat_map()apply()unbatch()padded_batch()。还有一些更多的类方法,例如from_generator()from_tensors(),它们分别从 Python 生成器或张量列表创建新数据集。请查看 API 文档以获取更多详细信息。还请注意,tf.data.experimental中提供了一些实验性功能,其中许多功能可能会在未来的版本中成为核心 API 的一部分(例如,请查看CsvDataset类,以及make_csv_dataset()方法,该方法负责推断每列的类型)。

使用数据集与 Keras

现在,我们可以使用我们之前编写的自定义csv_reader_dataset()函数为训练集、验证集和测试集创建数据集。训练集将在每个时期进行洗牌(请注意,验证集和测试集也将进行洗牌,尽管我们实际上并不需要):

train_set = csv_reader_dataset(train_filepaths)
valid_set = csv_reader_dataset(valid_filepaths)
test_set = csv_reader_dataset(test_filepaths)

现在,您可以简单地使用这些数据集构建和训练 Keras 模型。当您调用模型的fit()方法时,您传递train_set而不是X_train, y_train,并传递validation_data=valid_set而不是validation_data=(X_valid, y_valid)fit()方法将负责每个时期重复训练数据集,每个时期使用不同的随机顺序:

model = tf.keras.Sequential([...])
model.compile(loss="mse", optimizer="sgd")
model.fit(train_set, validation_data=valid_set, epochs=5)

同样,您可以将数据集传递给evaluate()predict()方法:

test_mse = model.evaluate(test_set)
new_set = test_set.take(3)  # pretend we have 3 new samples
y_pred = model.predict(new_set)  # or you could just pass a NumPy array

与其他数据集不同,new_set通常不包含标签。如果包含标签,就像这里一样,Keras 会忽略它们。请注意,在所有这些情况下,您仍然可以使用 NumPy 数组而不是数据集(但当然它们需要先加载和预处理)。

如果您想构建自己的自定义训练循环(如第十二章中讨论的),您可以很自然地遍历训练集:

n_epochs = 5
for epoch in range(n_epochs):
    for X_batch, y_batch in train_set:
        [...]  # perform one gradient descent step

实际上,甚至可以创建一个 TF 函数(参见第十二章),用于整个时期训练模型。这可以真正加快训练速度:

@tf.function
def train_one_epoch(model, optimizer, loss_fn, train_set):
    for X_batch, y_batch in train_set:
        with tf.GradientTape() as tape:
            y_pred = model(X_batch)
            main_loss = tf.reduce_mean(loss_fn(y_batch, y_pred))
            loss = tf.add_n([main_loss] + model.losses)
        gradients = tape.gradient(loss, model.trainable_variables)
        optimizer.apply_gradients(zip(gradients, model.trainable_variables))

optimizer = tf.keras.optimizers.SGD(learning_rate=0.01)
loss_fn = tf.keras.losses.mean_squared_error
for epoch in range(n_epochs):
    print("\rEpoch {}/{}".format(epoch + 1, n_epochs), end="")
    train_one_epoch(model, optimizer, loss_fn, train_set)

在 Keras 中,compile()方法的steps_per_execution参数允许您定义fit()方法在每次调用用于训练的tf.function时将处理的批次数。默认值只是 1,因此如果将其设置为 50,您通常会看到显着的性能改进。但是,Keras 回调的on_batch_*()方法只会在每 50 批次时调用一次。

恭喜,您现在知道如何使用 tf.data API 构建强大的输入管道!然而,到目前为止,我们一直在使用常见、简单和方便但不是真正高效的 CSV 文件,并且不太支持大型或复杂的数据结构(如图像或音频)。因此,让我们看看如何改用 TFRecords。

提示

如果您对 CSV 文件(或者您正在使用的其他格式)感到满意,您不一定必须使用 TFRecords。俗话说,如果它没有坏,就不要修理!当训练过程中的瓶颈是加载和解析数据时,TFRecords 非常有用。

TFRecord 格式

TFRecord 格式是 TensorFlow 存储大量数据并高效读取的首选格式。它是一个非常简单的二进制格式,只包含一系列大小不同的二进制记录(每个记录由长度、用于检查长度是否损坏的 CRC 校验和、实际数据,最后是数据的 CRC 校验和组成)。您可以使用tf.io.TFRecordWriter类轻松创建 TFRecord 文件:

with tf.io.TFRecordWriter("my_data.tfrecord") as f:
    f.write(b"This is the first record")
    f.write(b"And this is the second record")

然后,您可以使用tf.data.TFRecordDataset来读取一个或多个 TFRecord 文件:

filepaths = ["my_data.tfrecord"]
dataset = tf.data.TFRecordDataset(filepaths)
for item in dataset:
    print(item)

这将输出:

tf.Tensor(b'This is the first record', shape=(), dtype=string)
tf.Tensor(b'And this is the second record', shape=(), dtype=string)
提示

默认情况下,TFRecordDataset将逐个读取文件,但您可以使其并行读取多个文件,并通过传递文件路径列表给构造函数并将num_parallel_reads设置为大于 1 的数字来交错它们的记录。或者,您可以通过使用list_files()interleave()来获得与我们之前读取多个 CSV 文件相同的结果。

压缩的 TFRecord 文件

有时将 TFRecord 文件压缩可能很有用,特别是如果它们需要通过网络连接加载。您可以通过设置options参数创建一个压缩的 TFRecord 文件:

options = tf.io.TFRecordOptions(compression_type="GZIP")
with tf.io.TFRecordWriter("my_compressed.tfrecord", options) as f:
    f.write(b"Compress, compress, compress!")

在读取压缩的 TFRecord 文件时,您需要指定压缩类型:

dataset = tf.data.TFRecordDataset(["my_compressed.tfrecord"],
                                  compression_type="GZIP")

协议缓冲区简介

尽管每个记录可以使用您想要的任何二进制格式,但 TFRecord 文件通常包含序列化的协议缓冲区(也称为protobufs)。这是一个在 2001 年由谷歌开发的便携式、可扩展和高效的二进制格式,并于 2008 年开源;protobufs 现在被广泛使用,特别是在grpc中,谷歌的远程过程调用系统。它们使用一个看起来像这样的简单语言进行定义:

syntax = "proto3";
message Person {
    string name = 1;
    int32 id = 2;
    repeated string email = 3;
}

这个 protobuf 定义表示我们正在使用 protobuf 格式的第 3 版,并且指定每个Person对象(可选)可能具有一个字符串类型的name、一个 int32 类型的id,以及零个或多个字符串类型的email字段。数字123是字段标识符:它们将在每个记录的二进制表示中使用。一旦你在.proto文件中有了一个定义,你就可以编译它。这需要使用 protobuf 编译器protoc在 Python(或其他语言)中生成访问类。请注意,你通常在 TensorFlow 中使用的 protobuf 定义已经为你编译好了,并且它们的 Python 类是 TensorFlow 库的一部分,因此你不需要使用protoc。你只需要知道如何在 Python 中使用protobuf 访问类。为了说明基础知识,让我们看一个简单的示例,使用为Personprotobuf 生成的访问类(代码在注释中有解释):

>>> from person_pb2 import Person  # import the generated access class
>>> person = Person(name="Al", id=123, email=["a@b.com"])  # create a Person
>>> print(person)  # display the Person
name: "Al"
id: 123
email: "a@b.com"
>>> person.name  # read a field
'Al'
>>> person.name = "Alice"  # modify a field
>>> person.email[0]  # repeated fields can be accessed like arrays
'a@b.com'
>>> person.email.append("c@d.com")  # add an email address
>>> serialized = person.SerializeToString()  # serialize person to a byte string
>>> serialized
b'\n\x05Alice\x10{\x1a\x07a@b.com\x1a\x07c@d.com'
>>> person2 = Person()  # create a new Person
>>> person2.ParseFromString(serialized)  # parse the byte string (27 bytes long)
27
>>> person == person2  # now they are equal
True

简而言之,我们导入由protoc生成的Person类,创建一个实例并对其进行操作,可视化它并读取和写入一些字段,然后使用SerializeToString()方法对其进行序列化。这是准备保存或通过网络传输的二进制数据。当读取或接收这些二进制数据时,我们可以使用ParseFromString()方法进行解析,并获得被序列化的对象的副本。

你可以将序列化的Person对象保存到 TFRecord 文件中,然后加载和解析它:一切都会正常工作。然而,ParseFromString()不是一个 TensorFlow 操作,所以你不能在 tf.data 管道中的预处理函数中使用它(除非将其包装在tf.py_function()操作中,这会使代码变慢且不太可移植,正如你在第十二章中看到的)。然而,你可以使用tf.io.decode_proto()函数,它可以解析任何你想要的 protobuf,只要你提供 protobuf 定义(请参考笔记本中的示例)。也就是说,在实践中,你通常会希望使用 TensorFlow 提供的专用解析操作的预定义 protobuf。现在让我们来看看这些预定义的 protobuf。

TensorFlow Protobufs

TFRecord 文件中通常使用的主要 protobuf 是Exampleprotobuf,它表示数据集中的一个实例。它包含一个命名特征列表,其中每个特征可以是一个字节字符串列表、一个浮点数列表或一个整数列表。以下是 protobuf 定义(来自 TensorFlow 源代码):

syntax = "proto3";
message BytesList { repeated bytes value = 1; }
message FloatList { repeated float value = 1 [packed = true]; }
message Int64List { repeated int64 value = 1 [packed = true]; }
message Feature {
    oneof kind {
        BytesList bytes_list = 1;
        FloatList float_list = 2;
        Int64List int64_list = 3;
    }
};
message Features { map<string, Feature> feature = 1; };
message Example { Features features = 1; };

BytesListFloatListInt64List的定义足够简单明了。请注意,对于重复的数值字段,使用[packed = true]进行更有效的编码。Feature包含一个BytesList、一个FloatList或一个Int64List。一个Features(带有s)包含一个将特征名称映射到相应特征值的字典。最后,一个Example只包含一个Features对象。

注意

为什么会定义Example,因为它只包含一个Features对象?嗯,TensorFlow 的开发人员可能有一天决定向其中添加更多字段。只要新的Example定义仍然包含相同 ID 的features字段,它就是向后兼容的。这种可扩展性是 protobuf 的一个伟大特性。

这是你如何创建一个代表同一个人的tf.train.Example

from tensorflow.train import BytesList, FloatList, Int64List
from tensorflow.train import Feature, Features, Example

person_example = Example(
    features=Features(
        feature={
            "name": Feature(bytes_list=BytesList(value=[b"Alice"])),
            "id": Feature(int64_list=Int64List(value=[123])),
            "emails": Feature(bytes_list=BytesList(value=[b"a@b.com",
                                                          b"c@d.com"]))
        }))

这段代码有点冗长和重复,但你可以很容易地将其包装在一个小的辅助函数中。现在我们有了一个Example protobuf,我们可以通过调用其SerializeToString()方法将其序列化,然后将生成的数据写入 TFRecord 文件。让我们假装写入五次,以假装我们有几个联系人:

with tf.io.TFRecordWriter("my_contacts.tfrecord") as f:
    for _ in range(5):
        f.write(person_example.SerializeToString())

通常,您会写比五个Example更多的内容!通常情况下,您会创建一个转换脚本,从当前格式(比如 CSV 文件)读取数据,为每个实例创建一个Example protobuf,将它们序列化,并保存到几个 TFRecord 文件中,最好在此过程中对它们进行洗牌。这需要一些工作,所以再次确保这确实是必要的(也许您的流水线使用 CSV 文件运行良好)。

现在我们有一个包含多个序列化Example的漂亮 TFRecord 文件,让我们尝试加载它。

加载和解析示例

为了加载序列化的Example protobufs,我们将再次使用tf.data.TFRecordDataset,并使用tf.io.parse_single_example()解析每个Example。它至少需要两个参数:包含序列化数据的字符串标量张量,以及每个特征的描述。描述是一个字典,将每个特征名称映射到tf.io.FixedLenFeature描述符,指示特征的形状、类型和默认值,或者tf.io.VarLenFeature描述符,仅指示特征列表的长度可能变化的类型(例如"emails"特征)。

以下代码定义了一个描述字典,然后创建了一个TFRecordDataset,并对其应用了一个自定义预处理函数,以解析该数据集包含的每个序列化Example protobuf:

feature_description = {
    "name": tf.io.FixedLenFeature([], tf.string, default_value=""),
    "id": tf.io.FixedLenFeature([], tf.int64, default_value=0),
    "emails": tf.io.VarLenFeature(tf.string),
}

def parse(serialized_example):
    return tf.io.parse_single_example(serialized_example, feature_description)

dataset = tf.data.TFRecordDataset(["my_contacts.tfrecord"]).map(parse)
for parsed_example in dataset:
    print(parsed_example)

固定长度的特征被解析为常规张量,但变长特征被解析为稀疏张量。您可以使用tf.sparse.to_dense()将稀疏张量转换为密集张量,但在这种情况下,更简单的方法是直接访问其值:

>>> tf.sparse.to_dense(parsed_example["emails"], default_value=b"")
<tf.Tensor: [...] dtype=string, numpy=array([b'a@b.com', b'c@d.com'], [...])>
>>> parsed_example["emails"].values
<tf.Tensor: [...] dtype=string, numpy=array([b'a@b.com', b'c@d.com'], [...])>

您可以使用tf.io.parse_example()批量解析示例,而不是使用tf.io.parse_single_example()逐个解析它们:

def parse(serialized_examples):
    return tf.io.parse_example(serialized_examples, feature_description)

dataset = tf.data.TFRecordDataset(["my_contacts.tfrecord"]).batch(2).map(parse)
for parsed_examples in dataset:
    print(parsed_examples)  # two examples at a time

最后,BytesList可以包含您想要的任何二进制数据,包括任何序列化对象。例如,您可以使用tf.io.encode_jpeg()使用 JPEG 格式对图像进行编码,并将这些二进制数据放入BytesList中。稍后,当您的代码读取 TFRecord 时,它将从解析Example开始,然后需要调用tf.io.decode_jpeg()来解析数据并获取原始图像(或者您可以使用tf.io.decode_image(),它可以解码任何 BMP、GIF、JPEG 或 PNG 图像)。您还可以通过使用tf.io.serialize_tensor()对张量进行序列化,然后将生成的字节字符串放入BytesList特征中,将任何您想要的张量存储在BytesList中。稍后,当您解析 TFRecord 时,您可以使用tf.io.parse_tensor()解析这些数据。请参阅本章的笔记本https://homl.info/colab3 ,了解在 TFRecord 文件中存储图像和张量的示例。

正如您所看到的,Example protobuf 非常灵活,因此对于大多数用例来说可能已经足够了。但是,当您处理列表列表时,可能会有些繁琐。例如,假设您想对文本文档进行分类。每个文档可以表示为一个句子列表,其中每个句子表示为一个单词列表。也许每个文档还有一个评论列表,其中每个评论表示为一个单词列表。还可能有一些上下文数据,比如文档的作者、标题和发布日期。TensorFlow 的SequenceExample protobuf 就是为这种用例而设计的。

使用 SequenceExample Protobuf 处理列表列表

这是SequenceExample protobuf 的定义:

message FeatureList { repeated Feature feature = 1; };
message FeatureLists { map<string, FeatureList> feature_list = 1; };
message SequenceExample {
    Features context = 1;
    FeatureLists feature_lists = 2;
};

SequenceExample包含一个Features对象用于上下文数据和一个包含一个或多个命名FeatureList对象(例如,一个名为"content"FeatureList和另一个名为"comments"FeatureList)的FeatureLists对象。每个FeatureList包含一个Feature对象列表,每个Feature对象可能是字节字符串列表、64 位整数列表或浮点数列表(在此示例中,每个Feature可能代表一个句子或评论,可能以单词标识符列表的形式)。构建SequenceExample、序列化它并解析它类似于构建、序列化和解析Example,但您必须使用tf.io.parse_single_sequence_example()来解析单个SequenceExampletf.io.parse_sequence_example()来解析批处理。这两个函数返回一个包含上下文特征(作为字典)和特征列表(也作为字典)的元组。如果特征列表包含不同大小的序列(如前面的示例),您可能希望使用tf.RaggedTensor.from_sparse()将它们转换为不规则张量(请参阅完整代码的笔记本):

parsed_context, parsed_feature_lists = tf.io.parse_single_sequence_example(
    serialized_sequence_example, context_feature_descriptions,
    sequence_feature_descriptions)
parsed_content = tf.RaggedTensor.from_sparse(parsed_feature_lists["content"])

现在您已经知道如何使用 tf.data API、TFRecords 和 protobufs 高效存储、加载、解析和预处理数据,是时候将注意力转向 Keras 预处理层了。

Keras 预处理层

为神经网络准备数据通常需要对数值特征进行归一化、对分类特征和文本进行编码、裁剪和调整图像等。有几种选项:

  • 预处理可以提前在准备训练数据文件时完成,使用您喜欢的任何工具,如 NumPy、Pandas 或 Scikit-Learn。您需要在生产中应用完全相同的预处理步骤,以确保您的生产模型接收到与训练时相似的预处理输入。

  • 或者,您可以在加载数据时使用 tf.data 进行即时预处理,通过使用该数据集的map()方法对数据集的每个元素应用预处理函数,就像本章前面所做的那样。同样,您需要在生产中应用相同的预处理步骤。

  • 最后一种方法是直接在模型内部包含预处理层,这样它可以在训练期间即时预处理所有输入数据,然后在生产中使用相同的预处理层。本章的其余部分将讨论这种最后一种方法。

Keras 提供了许多预处理层,您可以将其包含在模型中:它们可以应用于数值特征、分类特征、图像和文本。我们将在接下来的部分中讨论数值和分类特征,以及基本文本预处理,我们将在第十四章中涵盖图像预处理,以及在第十六章中涵盖更高级的文本预处理。

归一化层

正如我们在第十章中看到的,Keras 提供了一个Normalization层,我们可以用来标准化输入特征。我们可以在创建层时指定每个特征的均值和方差,或者更简单地在拟合模型之前将训练集传递给该层的adapt()方法,以便该层可以在训练之前自行测量特征的均值和方差:

norm_layer = tf.keras.layers.Normalization()
model = tf.keras.models.Sequential([
    norm_layer,
    tf.keras.layers.Dense(1)
])
model.compile(loss="mse", optimizer=tf.keras.optimizers.SGD(learning_rate=2e-3))
norm_layer.adapt(X_train)  # computes the mean and variance of every feature
model.fit(X_train, y_train, validation_data=(X_valid, y_valid), epochs=5)
提示

传递给adapt()方法的数据样本必须足够大,以代表您的数据集,但不必是完整的训练集:对于Normalization层,从训练集中随机抽取的几百个实例通常足以获得特征均值和方差的良好估计。

由于我们在模型中包含了Normalization层,现在我们可以将这个模型部署到生产环境中,而不必再担心归一化的问题:模型会自动处理(参见图 13-4)。太棒了!这种方法完全消除了预处理不匹配的风险,当人们尝试为训练和生产维护不同的预处理代码,但更新其中一个并忘记更新另一个时,就会发生这种情况。生产模型最终会接收到以其不期望的方式预处理的数据。如果他们幸运的话,会得到一个明显的错误。如果不幸的话,模型的准确性会悄悄下降。

mls3 1304

图 13-4。在模型中包含预处理层

直接在模型中包含预处理层很简单明了,但会减慢训练速度(在Normalization层的情况下只会稍微减慢):实际上,由于预处理是在训练过程中实时进行的,每个时期只会发生一次。我们可以通过在训练之前仅对整个训练集进行一次归一化来做得更好。为此,我们可以像使用 Scikit-Learn 的StandardScaler一样单独使用Normalization层:

norm_layer = tf.keras.layers.Normalization()
norm_layer.adapt(X_train)
X_train_scaled = norm_layer(X_train)
X_valid_scaled = norm_layer(X_valid)

现在我们可以在经过缩放的数据上训练模型,这次不需要Normalization层:

model = tf.keras.models.Sequential([tf.keras.layers.Dense(1)])
model.compile(loss="mse", optimizer=tf.keras.optimizers.SGD(learning_rate=2e-3))
model.fit(X_train_scaled, y_train, epochs=5,
          validation_data=(X_valid_scaled, y_valid))

很好!这应该会加快训练速度。但是现在当我们将模型部署到生产环境时,模型不会对其输入进行预处理。为了解决这个问题,我们只需要创建一个新模型,将适应的Normalization层和刚刚训练的模型包装在一起。然后我们可以将这个最终模型部署到生产环境中,它将负责对其输入进行预处理和进行预测(参见图 13-5):

final_model = tf.keras.Sequential([norm_layer, model])
X_new = X_test[:3]  # pretend we have a few new instances (unscaled)
y_pred = final_model(X_new)  # preprocesses the data and makes predictions

mls3 1305

图 13-5。在训练之前仅对数据进行一次预处理,然后将这些层部署到最终模型中

现在我们拥有了最佳的两种方式:训练很快,因为我们只在训练开始前对数据进行一次预处理,而最终模型可以在运行时对其输入进行预处理,而不会有任何预处理不匹配的风险。

此外,Keras 预处理层与 tf.data API 很好地配合。例如,可以将tf.data.Dataset传递给预处理层的adapt()方法。还可以使用数据集的map()方法将 Keras 预处理层应用于tf.data.Dataset。例如,以下是如何将适应的Normalization层应用于数据集中每个批次的输入特征的方法:

dataset = dataset.map(lambda X, y: (norm_layer(X), y))

最后,如果您需要比 Keras 预处理层提供的更多特性,您可以随时编写自己的 Keras 层,就像我们在第十二章中讨论的那样。例如,如果Normalization层不存在,您可以使用以下自定义层获得类似的结果:

import numpy as np

class MyNormalization(tf.keras.layers.Layer):
    def adapt(self, X):
        self.mean_ = np.mean(X, axis=0, keepdims=True)
        self.std_ = np.std(X, axis=0, keepdims=True)

    def call(self, inputs):
        eps = tf.keras.backend.epsilon()  # a small smoothing term
        return (inputs - self.mean_) / (self.std_ + eps)

接下来,让我们看看另一个用于数值特征的 Keras 预处理层:Discretization层。

Discretization 层

Discretization层的目标是通过将值范围(称为箱)映射到类别,将数值特征转换为分类特征。这对于具有多峰分布的特征或与目标具有高度非线性关系的特征有时是有用的。例如,以下代码将数值age特征映射到三个类别,小于 18 岁,18 到 50 岁(不包括),50 岁或以上:

>>> age = tf.constant([[10.], [93.], [57.], [18.], [37.], [5.]])
>>> discretize_layer = tf.keras.layers.Discretization(bin_boundaries=[18., 50.])
>>> age_categories = discretize_layer(age)
>>> age_categories
<tf.Tensor: shape=(6, 1), dtype=int64, numpy=array([[0],[2],[2],[1],[1],[0]])>

在这个例子中,我们提供了期望的分箱边界。如果你愿意,你可以提供你想要的箱数,然后调用层的adapt()方法,让它根据值的百分位数找到合适的箱边界。例如,如果我们设置num_bins=3,那么箱边界将位于第 33 和第 66 百分位数之下的值(在这个例子中,值为 10 和 37):

>>> discretize_layer = tf.keras.layers.Discretization(num_bins=3)
>>> discretize_layer.adapt(age)
>>> age_categories = discretize_layer(age)
>>> age_categories
<tf.Tensor: shape=(6, 1), dtype=int64, numpy=array([[1],[2],[2],[1],[2],[0]])>

通常不应将诸如此类的类别标识符直接传递给神经网络,因为它们的值无法有意义地进行比较。相反,它们应该被编码,例如使用独热编码。现在让我们看看如何做到这一点。

CategoryEncoding 层

当只有少量类别(例如,少于十几个或二十个)时,独热编码通常是一个不错的选择(如第二章中讨论的)。为此,Keras 提供了CategoryEncoding层。例如,让我们对刚刚创建的age_categories特征进行独热编码:

>>> onehot_layer = tf.keras.layers.CategoryEncoding(num_tokens=3)
>>> onehot_layer(age_categories)
<tf.Tensor: shape=(6, 3), dtype=float32, numpy=
array([[0., 1., 0.],
 [0., 0., 1.],
 [0., 0., 1.],
 [0., 1., 0.],
 [0., 0., 1.],
 [1., 0., 0.]], dtype=float32)>

如果尝试一次对多个分类特征进行编码(只有当它们都使用相同的类别时才有意义),CategoryEncoding类将默认执行多热编码:输出张量将包含每个输入特征中存在的每个类别的 1。例如:

>>> two_age_categories = np.array([[1, 0], [2, 2], [2, 0]])
>>> onehot_layer(two_age_categories)
<tf.Tensor: shape=(3, 3), dtype=float32, numpy=
array([[1., 1., 0.],
 [0., 0., 1.],
 [1., 0., 1.]], dtype=float32)>

如果您认为知道每个类别出现的次数是有用的,可以在创建CategoryEncoding层时设置output_mode="count",在这种情况下,输出张量将包含每个类别的出现次数。在前面的示例中,输出将与之前相同,只是第二行将变为[0., 0., 2.]

请注意,多热编码和计数编码都会丢失信息,因为无法知道每个活动类别来自哪个特征。例如,[0, 1][1, 0]都被编码为[1., 1., 0.]。如果要避免这种情况,那么您需要分别对每个特征进行独热编码,然后连接输出。这样,[0, 1]将被编码为[1., 0., 0., 0., 1., 0.][1, 0]将被编码为[0., 1., 0., 1., 0., 0.]。您可以通过调整类别标识符来获得相同的结果,以便它们不重叠。例如:

>>> onehot_layer = tf.keras.layers.CategoryEncoding(num_tokens=3 + 3)
>>> onehot_layer(two_age_categories + [0, 3])  # adds 3 to the second feature
<tf.Tensor: shape=(3, 6), dtype=float32, numpy=
array([[0., 1., 0., 1., 0., 0.],
 [0., 0., 1., 0., 0., 1.],
 [0., 0., 1., 1., 0., 0.]], dtype=float32)>

在此输出中,前三列对应于第一个特征,最后三列对应于第二个特征。这使模型能够区分这两个特征。但是,这也增加了馈送到模型的特征数量,因此需要更多的模型参数。很难事先知道单个多热编码还是每个特征的独热编码哪个效果最好:这取决于任务,您可能需要测试两种选项。

现在您可以使用独热编码或多热编码对分类整数特征进行编码。但是对于分类文本特征呢?为此,您可以使用StringLookup层。

StringLookup 层

让我们使用 Keras 的StringLookup层对cities特征进行独热编码:

>>> cities = ["Auckland", "Paris", "Paris", "San Francisco"]
>>> str_lookup_layer = tf.keras.layers.StringLookup()
>>> str_lookup_layer.adapt(cities)
>>> str_lookup_layer([["Paris"], ["Auckland"], ["Auckland"], ["Montreal"]])
<tf.Tensor: shape=(4, 1), dtype=int64, numpy=array([[1], [3], [3], [0]])>

我们首先创建一个StringLookup层,然后将其适应到数据:它发现有三个不同的类别。然后我们使用该层对一些城市进行编码。默认情况下,它们被编码为整数。未知类别被映射为 0,就像在这个例子中的“Montreal”一样。已知类别从最常见的类别开始编号,从最常见到最不常见。

方便的是,当创建StringLookup层时设置output_mode="one_hot",它将为每个类别输出一个独热向量,而不是一个整数:

>>> str_lookup_layer = tf.keras.layers.StringLookup(output_mode="one_hot")
>>> str_lookup_layer.adapt(cities)
>>> str_lookup_layer([["Paris"], ["Auckland"], ["Auckland"], ["Montreal"]])
<tf.Tensor: shape=(4, 4), dtype=float32, numpy=
array([[0., 1., 0., 0.],
 [0., 0., 0., 1.],
 [0., 0., 0., 1.],
 [1., 0., 0., 0.]], dtype=float32)>
提示

Keras 还包括一个IntegerLookup层,其功能类似于StringLookup层,但输入为整数,而不是字符串。

如果训练集非常大,可能会方便地将层适应于训练集的随机子集。在这种情况下,层的adapt()方法可能会错过一些较少见的类别。默认情况下,它会将它们全部映射到类别 0,使它们在模型中无法区分。为了减少这种风险(同时仅在训练集的子集上调整层),您可以将num_oov_indices设置为大于 1 的整数。这是要使用的未知词汇(OOV)桶的数量:每个未知类别将使用哈希函数对 OOV 桶的数量取模,伪随机地映射到其中一个 OOV 桶。这将使模型能够区分至少一些罕见的类别。例如:

>>> str_lookup_layer = tf.keras.layers.StringLookup(num_oov_indices=5)
>>> str_lookup_layer.adapt(cities)
>>> str_lookup_layer([["Paris"], ["Auckland"], ["Foo"], ["Bar"], ["Baz"]])
<tf.Tensor: shape=(4, 1), dtype=int64, numpy=array([[5], [7], [4], [3], [4]])>

由于有五个 OOV 桶,第一个已知类别的 ID 现在是 5(“巴黎”)。但是,"Foo""Bar""Baz"是未知的,因此它们各自被映射到 OOV 桶中的一个。 "Bar"有自己的专用桶(ID 为 3),但不幸的是,"Foo""Baz"被映射到相同的桶中(ID 为 4),因此它们在模型中保持不可区分。这被称为哈希碰撞。减少碰撞风险的唯一方法是增加 OOV 桶的数量。但是,这也会增加总类别数,这将需要更多的 RAM 和额外的模型参数,一旦类别被独热编码。因此,不要将该数字增加得太多。

将类别伪随机映射到桶中的这种想法称为哈希技巧。Keras 提供了一个专用的层,就是Hashing层。

哈希层

对于每个类别,Keras 的Hashing层计算一个哈希值,取模于桶(或“bin”)的数量。映射完全是伪随机的,但在运行和平台之间是稳定的(即,只要桶的数量不变,相同的类别将始终被映射到相同的整数)。例如,让我们使用Hashing层来编码一些城市:

>>> hashing_layer = tf.keras.layers.Hashing(num_bins=10)
>>> hashing_layer([["Paris"], ["Tokyo"], ["Auckland"], ["Montreal"]])
<tf.Tensor: shape=(4, 1), dtype=int64, numpy=array([[0], [1], [9], [1]])>

这个层的好处是它根本不需要适应,这有时可能很有用,特别是在核外设置中(当数据集太大而无法放入内存时)。然而,我们再次遇到了哈希碰撞:“东京”和“蒙特利尔”被映射到相同的 ID,使它们在模型中无法区分。因此,通常最好坚持使用StringLookup层。

现在让我们看另一种编码类别的方法:可训练的嵌入。

使用嵌入编码分类特征

嵌入是一种高维数据(例如类别或词汇中的单词)的密集表示。如果有 50,000 个可能的类别,那么独热编码将产生一个 50,000 维的稀疏向量(即,大部分为零)。相比之下,嵌入将是一个相对较小的密集向量;例如,只有 100 个维度。

在深度学习中,嵌入通常是随机初始化的,然后通过梯度下降与其他模型参数一起训练。例如,在加利福尼亚住房数据集中,"NEAR BAY"类别最初可以由一个随机向量表示,例如[0.131, 0.890],而"NEAR OCEAN"类别可能由另一个随机向量表示,例如[0.631, 0.791]。在这个例子中,我们使用了 2D 嵌入,但维度的数量是一个可以调整的超参数。

由于这些嵌入是可训练的,它们在训练过程中会逐渐改进;由于它们在这种情况下代表的是相当相似的类别,梯度下降肯定会使它们彼此更接近,同时也会使它们远离"INLAND"类别的嵌入(参见图 13-6)。实际上,表示得越好,神经网络就越容易做出准确的预测,因此训练倾向于使嵌入成为类别的有用表示。这被称为表示学习(您将在第十七章中看到其他类型的表示学习)。

mls3 1306

图 13-6。嵌入将在训练过程中逐渐改进

Keras 提供了一个Embedding层,它包装了一个嵌入矩阵:这个矩阵每行对应一个类别,每列对应一个嵌入维度。默认情况下,它是随机初始化的。要将类别 ID 转换为嵌入,Embedding层只需查找并返回对应于该类别的行。就是这样!例如,让我们用五行和 2D 嵌入初始化一个Embedding层,并用它来编码一些类别:

>>> tf.random.set_seed(42)
>>> embedding_layer = tf.keras.layers.Embedding(input_dim=5, output_dim=2)
>>> embedding_layer(np.array([2, 4, 2]))
<tf.Tensor: shape=(3, 2), dtype=float32, numpy=
array([[-0.04663396,  0.01846724],
 [-0.02736737, -0.02768031],
 [-0.04663396,  0.01846724]], dtype=float32)>

正如您所看到的,类别 2 被编码(两次)为 2D 向量[-0.04663396, 0.01846724],而类别 4 被编码为[-0.02736737, -0.02768031]。由于该层尚未训练,这些编码只是随机的。

警告

Embedding层是随机初始化的,因此除非使用预训练权重初始化,否则在模型之外作为独立的预处理层使用它是没有意义的。

如果要嵌入一个分类文本属性,您可以简单地将StringLookup层和Embedding层连接起来,就像这样:

>>> tf.random.set_seed(42)
>>> ocean_prox = ["<1H OCEAN", "INLAND", "NEAR OCEAN", "NEAR BAY", "ISLAND"]
>>> str_lookup_layer = tf.keras.layers.StringLookup()
>>> str_lookup_layer.adapt(ocean_prox)
>>> lookup_and_embed = tf.keras.Sequential([
...     str_lookup_layer,
...     tf.keras.layers.Embedding(input_dim=str_lookup_layer.vocabulary_size(),
...                               output_dim=2)
... ])
...
>>> lookup_and_embed(np.array([["<1H OCEAN"], ["ISLAND"], ["<1H OCEAN"]]))
<tf.Tensor: shape=(3, 2), dtype=float32, numpy=
array([[-0.01896119,  0.02223358],
 [ 0.02401174,  0.03724445],
 [-0.01896119,  0.02223358]], dtype=float32)>

请注意,嵌入矩阵中的行数需要等于词汇量的大小:这是总类别数,包括已知类别和 OOV 桶(默认只有一个)。StringLookup类的vocabulary_size()方法方便地返回这个数字。

提示

在这个例子中,我们使用了 2D 嵌入,但一般来说,嵌入通常有 10 到 300 个维度,取决于任务、词汇量和训练集的大小。您将需要调整这个超参数。

将所有内容放在一起,现在我们可以创建一个 Keras 模型,可以处理分类文本特征以及常规数值特征,并为每个类别(以及每个 OOV 桶)学习一个嵌入:

X_train_num, X_train_cat, y_train = [...]  # load the training set
X_valid_num, X_valid_cat, y_valid = [...]  # and the validation set

num_input = tf.keras.layers.Input(shape=[8], name="num")
cat_input = tf.keras.layers.Input(shape=[], dtype=tf.string, name="cat")
cat_embeddings = lookup_and_embed(cat_input)
encoded_inputs = tf.keras.layers.concatenate([num_input, cat_embeddings])
outputs = tf.keras.layers.Dense(1)(encoded_inputs)
model = tf.keras.models.Model(inputs=[num_input, cat_input], outputs=[outputs])
model.compile(loss="mse", optimizer="sgd")
history = model.fit((X_train_num, X_train_cat), y_train, epochs=5,
                    validation_data=((X_valid_num, X_valid_cat), y_valid))

这个模型有两个输入:num_input,每个实例包含八个数值特征,以及cat_input,每个实例包含一个分类文本输入。该模型使用我们之前创建的lookup_and_embed模型来将每个海洋接近类别编码为相应的可训练嵌入。接下来,它使用concatenate()函数将数值输入和嵌入连接起来,生成完整的编码输入,准备输入神经网络。在这一点上,我们可以添加任何类型的神经网络,但为了简单起见,我们只添加一个单一的密集输出层,然后我们创建 KerasModel,使用我们刚刚定义的输入和输出。接下来,我们编译模型并训练它,传递数值和分类输入。

正如您在第十章中看到的,由于Input层的名称是"num""cat",我们也可以将训练数据传递给fit()方法,使用字典而不是元组:{"num": X_train_num, "cat": X_train_cat}。或者,我们可以传递一个包含批次的tf.data.Dataset,每个批次表示为((X_batch_num, X_batch_cat), y_batch)或者({"num": X_batch_num, "cat": X_batch_cat}, y_batch)。当然,验证数据也是一样的。

注意

先进行独热编码,然后通过一个没有激活函数和偏置的Dense层等同于一个Embedding层。然而,Embedding层使用的计算量要少得多,因为它避免了许多零乘法——当嵌入矩阵的大小增长时,性能差异变得明显。Dense层的权重矩阵起到了嵌入矩阵的作用。例如,使用大小为 20 的独热向量和一个具有 10 个单元的Dense层等同于使用一个input_dim=20output_dim=10Embedding层。因此,在Embedding层后面的层中使用的嵌入维度不应该超过单元数。

好了,现在您已经学会了如何对分类特征进行编码,是时候将注意力转向文本预处理了。

文本预处理

Keras 为基本文本预处理提供了一个TextVectorization层。与StringLookup层类似,您必须在创建时传递一个词汇表,或者使用adapt()方法从一些训练数据中学习词汇表。让我们看一个例子:

>>> train_data = ["To be", "!(to be)", "That's the question", "Be, be, be."]
>>> text_vec_layer = tf.keras.layers.TextVectorization()
>>> text_vec_layer.adapt(train_data)
>>> text_vec_layer(["Be good!", "Question: be or be?"])
<tf.Tensor: shape=(2, 4), dtype=int64, numpy=
array([[2, 1, 0, 0],
 [6, 2, 1, 2]])>

两个句子“Be good!”和“Question: be or be?”分别被编码为[2, 1, 0, 0][6, 2, 1, 2]。词汇表是从训练数据中的四个句子中学习的:“be” = 2,“to” = 3,等等。为构建词汇表,adapt()方法首先将训练句子转换为小写并去除标点,这就是为什么“Be”、“be”和“be?”都被编码为“be” = 2。接下来,句子被按空格拆分,生成的单词按降序频率排序,产生最终的词汇表。在编码句子时,未知单词被编码为 1。最后,由于第一个句子比第二个句子短,因此用 0 进行了填充。

提示

TextVectorization层有许多选项。例如,您可以通过设置standardize=None来保留大小写和标点,或者您可以将任何标准化函数作为standardize参数传递。您可以通过设置split=None来防止拆分,或者您可以传递自己的拆分函数。您可以设置output_sequence_length参数以确保输出序列都被裁剪或填充到所需的长度,或者您可以设置ragged=True以获得一个不规则张量而不是常规张量。请查看文档以获取更多选项。

单词 ID 必须进行编码,通常使用Embedding层:我们将在第十六章中进行这样做。或者,您可以将TextVectorization层的output_mode参数设置为"multi_hot""count"以获得相应的编码。然而,简单地计算单词通常不是理想的:像“to”和“the”这样的单词非常频繁,几乎没有影响,而“basketball”等更稀有的单词则更具信息量。因此,通常最好将output_mode设置为"tf_idf",它代表词频 × 逆文档频率(TF-IDF)。这类似于计数编码,但在训练数据中频繁出现的单词被降权,反之,稀有单词被升权。例如:

>>> text_vec_layer = tf.keras.layers.TextVectorization(output_mode="tf_idf")
>>> text_vec_layer.adapt(train_data)
>>> text_vec_layer(["Be good!", "Question: be or be?"])
<tf.Tensor: shape=(2, 6), dtype=float32, numpy=
array([[0.96725637, 0.6931472 , 0\. , 0\. , 0\. , 0\.        ],
 [0.96725637, 1.3862944 , 0\. , 0\. , 0\. , 1.0986123 ]], dtype=float32)>

TF-IDF 的变体有很多种,但TextVectorization层实现的方式是将每个单词的计数乘以一个权重,该权重等于 log(1 + d / (f + 1)),其中d是训练数据中的句子总数(也称为文档),f表示这些训练句子中包含给定单词的数量。例如,在这种情况下,训练数据中有d = 4 个句子,单词“be”出现在f = 3 个句子中。由于单词“be”在句子“Question: be or be?”中出现了两次,它被编码为 2 × log(1 + 4 / (1 + 3)) ≈ 1.3862944。单词“question”只出现一次,但由于它是一个不太常见的单词,它的编码几乎一样高:1 × log(1 + 4 / (1 + 1)) ≈ 1.0986123。请注意,对于未知单词,使用平均权重。

这种文本编码方法易于使用,并且对于基本的自然语言处理任务可以得到相当不错的结果,但它有几个重要的局限性:它只适用于用空格分隔单词的语言,它不区分同音异义词(例如“to bear”与“teddy bear”),它不提示您的模型单词“evolution”和“evolutionary”之间的关系等。如果使用多热编码、计数或 TF-IDF 编码,则单词的顺序会丢失。那么还有哪些其他选项呢?

一种选择是使用TensorFlow Text 库,它提供比TextVectorization层更高级的文本预处理功能。例如,它包括几种子词标记器,能够将文本分割成比单词更小的标记,这使得模型更容易检测到“evolution”和“evolutionary”之间有一些共同之处(有关子词标记化的更多信息,请参阅第十六章)。

另一个选择是使用预训练的语言模型组件。现在让我们来看看这个。

使用预训练语言模型组件

TensorFlow Hub 库使得在您自己的模型中重用预训练模型组件变得容易,用于文本、图像、音频等。这些模型组件称为模块。只需浏览TF Hub 存储库,找到您需要的模块,将代码示例复制到您的项目中,模块将自动下载并捆绑到一个 Keras 层中,您可以直接包含在您的模型中。模块通常包含预处理代码和预训练权重,并且通常不需要额外的训练(但当然,您的模型的其余部分肯定需要训练)。

例如,一些强大的预训练语言模型是可用的。最强大的模型非常庞大(几个千兆字节),因此为了快速示例,让我们使用nnlm-en-dim50模块,版本 2,这是一个相当基本的模块,它将原始文本作为输入并输出 50 维句子嵌入。我们将导入 TensorFlow Hub 并使用它来加载模块,然后使用该模块将两个句子编码为向量:

>>> import tensorflow_hub as hub
>>> hub_layer = hub.KerasLayer("https://tfhub.dev/google/nnlm-en-dim50/2")
>>> sentence_embeddings = hub_layer(tf.constant(["To be", "Not to be"]))
>>> sentence_embeddings.numpy().round(2)
array([[-0.25,  0.28,  0.01,  0.1 ,  [...] ,  0.05,  0.31],
 [-0.2 ,  0.2 , -0.08,  0.02,  [...] , -0.04,  0.15]], dtype=float32)

hub.KerasLayer层从给定的 URL 下载模块。这个特定的模块是一个句子编码器:它将字符串作为输入,并将每个字符串编码为单个向量(在本例中是一个 50 维向量)。在内部,它解析字符串(在空格上拆分单词)并使用在一个巨大的语料库上预训练的嵌入矩阵嵌入每个单词:Google News 7B 语料库(七十亿字长!)。然后计算所有单词嵌入的平均值,结果就是句子嵌入。

您只需要在您的模型中包含这个hub_layer,然后就可以开始了。请注意,这个特定的语言模型是在英语上训练的,但许多其他语言也可用,以及多语言模型。

最后,由 Hugging Face 提供的优秀开源Transformers 库也使得在您自己的模型中包含强大的语言模型组件变得容易。您可以浏览Hugging Face Hub,选择您想要的模型,并使用提供的代码示例开始。它以前只包含语言模型,但现在已扩展到包括图像模型等。

我们将在第十六章中更深入地讨论自然语言处理。现在让我们看一下 Keras 的图像预处理层。

图像预处理层

Keras 预处理 API 包括三个图像预处理层:

  • tf.keras.layers.Resizing将输入图像调整为所需大小。例如,Resizing(height=100, width=200)将每个图像调整为 100×200,可能会扭曲图像。如果设置crop_to_aspect_ratio=True,则图像将被裁剪到目标图像比例,以避免扭曲。

  • tf.keras.layers.Rescaling重新缩放像素值。例如,Rescaling(scale=2/255, offset=-1)将值从 0 → 255 缩放到-1 → 1。

  • tf.keras.layers.CenterCrop裁剪图像,保留所需高度和宽度的中心区域。

例如,让我们加载一些示例图像并对它们进行中心裁剪。为此,我们将使用 Scikit-Learn 的load_sample_images()函数;这将加载两个彩色图像,一个是中国寺庙的图像,另一个是花朵的图像(这需要 Pillow 库,如果您正在使用 Colab 或者按照安装说明进行操作,应该已经安装):

from sklearn.datasets import load_sample_images

images = load_sample_images()["images"]
crop_image_layer = tf.keras.layers.CenterCrop(height=100, width=100)
cropped_images = crop_image_layer(images)

Keras 还包括几个用于数据增强的层,如RandomCropRandomFlipRandomTranslationRandomRotationRandomZoomRandomHeightRandomWidthRandomContrast。这些层仅在训练期间激活,并随机对输入图像应用一些转换(它们的名称是不言自明的)。数据增强将人为增加训练集的大小,通常会导致性能提升,只要转换后的图像看起来像真实的(非增强的)图像。我们将在下一章更详细地介绍图像处理。

注意

在幕后,Keras 预处理层基于 TensorFlow 的低级 API。例如,Normalization层使用tf.nn.moments()来计算均值和方差,Discretization层使用tf.raw_ops.Bucketize()CategoricalEncoding使用tf.math.bincount()IntegerLookupStringLookup使用tf.lookup包,HashingTextVectorization使用tf.strings包中的几个操作,Embedding使用tf.nn.embedding_lookup(),图像预处理层使用tf.image包中的操作。如果 Keras 预处理 API 不满足您的需求,您可能偶尔需要直接使用 TensorFlow 的低级 API。

现在让我们看看在 TensorFlow 中另一种轻松高效地加载数据的方法。

TensorFlow 数据集项目

TensorFlow 数据集(TFDS)项目使加载常见数据集变得非常容易,从小型数据集如 MNIST 或 Fashion MNIST 到像 ImageNet 这样的大型数据集(您将需要相当大的磁盘空间!)。列表包括图像数据集、文本数据集(包括翻译数据集)、音频和视频数据集、时间序列等等。您可以访问https://homl.info/tfds查看完整列表,以及每个数据集的描述。您还可以查看了解您的数据,这是一个用于探索和理解 TFDS 提供的许多数据集的工具。

TFDS 并未与 TensorFlow 捆绑在一起,但如果您在 Colab 上运行或者按照https://homl.info/install的安装说明进行安装,那么它已经安装好了。然后您可以导入tensorflow_datasets,通常为tfds,然后调用tfds.load()函数,它将下载您想要的数据(除非之前已经下载过),并将数据作为数据集字典返回(通常一个用于训练,一个用于测试,但这取决于您选择的数据集)。例如,让我们下载 MNIST:

import tensorflow_datasets as tfds

datasets = tfds.load(name="mnist")
mnist_train, mnist_test = datasets["train"], datasets["test"]

然后您可以应用任何您想要的转换(通常是洗牌、批处理和预取),然后准备训练您的模型。这里是一个简单的示例:

for batch in mnist_train.shuffle(10_000, seed=42).batch(32).prefetch(1):
    images = batch["image"]
    labels = batch["label"]
    # [...] do something with the images and labels
提示

load()函数可以对其下载的文件进行洗牌:只需设置shuffle_files=True。但是这可能不够,最好对训练数据进行更多的洗牌。

请注意,数据集中的每个项目都是一个包含特征和标签的字典。但是 Keras 期望每个项目是一个包含两个元素的元组(再次,特征和标签)。您可以使用map()方法转换数据集,就像这样:

mnist_train = mnist_train.shuffle(buffer_size=10_000, seed=42).batch(32)
mnist_train = mnist_train.map(lambda items: (items["image"], items["label"]))
mnist_train = mnist_train.prefetch(1)

但是通过设置as_supervised=True,让load()函数为您执行此操作会更简单(显然,这仅适用于带标签的数据集)。

最后,TFDS 提供了一种方便的方法来使用split参数拆分数据。例如,如果您想要使用训练集的前 90%进行训练,剩余的 10%进行验证,整个测试集进行测试,那么您可以设置split=["train[:90%]", "train[90%:]", "test"]load()函数将返回所有三个集合。这里是一个完整的示例,使用 TFDS 加载和拆分 MNIST 数据集,然后使用这些集合来训练和评估一个简单的 Keras 模型:

train_set, valid_set, test_set = tfds.load(
    name="mnist",
    split=["train[:90%]", "train[90%:]", "test"],
    as_supervised=True
)
train_set = train_set.shuffle(buffer_size=10_000, seed=42).batch(32).prefetch(1)
valid_set = valid_set.batch(32).cache()
test_set = test_set.batch(32).cache()
tf.random.set_seed(42)
model = tf.keras.Sequential([
    tf.keras.layers.Flatten(input_shape=(28, 28)),
    tf.keras.layers.Dense(10, activation="softmax")
])
model.compile(loss="sparse_categorical_crossentropy", optimizer="nadam",
              metrics=["accuracy"])
history = model.fit(train_set, validation_data=valid_set, epochs=5)
test_loss, test_accuracy = model.evaluate(test_set)

恭喜,您已经到达了这个相当技术性的章节的结尾!您可能会觉得它与神经网络的抽象美有些远,但事实是深度学习通常涉及大量数据,知道如何高效加载、解析和预处理数据是一项至关重要的技能。在下一章中,我们将看一下卷积神经网络,这是图像处理和许多其他应用中最成功的神经网络架构之一。

练习

  1. 为什么要使用 tf.data API?

  2. 将大型数据集拆分为多个文件的好处是什么?

  3. 在训练过程中,如何判断您的输入管道是瓶颈?您可以做些什么来解决它?

  4. 您可以将任何二进制数据保存到 TFRecord 文件中吗,还是只能序列化协议缓冲区?

  5. 为什么要费心将所有数据转换为Example协议缓冲区格式?为什么不使用自己的协议缓冲区定义?

  6. 在使用 TFRecords 时,何时应该激活压缩?为什么不系统地这样做?

  7. 数据可以在编写数据文件时直接进行预处理,或者在 tf.data 管道中进行,或者在模型内的预处理层中进行。您能列出每个选项的一些优缺点吗?

  8. 列举一些常见的编码分类整数特征的方法。文本呢?

  9. 加载时尚 MNIST 数据集(在第十章中介绍);将其分为训练集、验证集和测试集;对训练集进行洗牌;并将每个数据集保存到多个 TFRecord 文件中。每个记录应该是一个序列化的Example协议缓冲区,具有两个特征:序列化图像(使用tf.io.serialize_tensor()来序列化每个图像),和标签。然后使用 tf.data 为每个集创建一个高效的数据集。最后,使用 Keras 模型来训练这些数据集,包括一个预处理层来标准化每个输入特征。尝试使输入管道尽可能高效,使用 TensorBoard 来可视化分析数据。

  10. 在这个练习中,您将下载一个数据集,将其拆分,创建一个tf.data.Dataset来高效加载和预处理数据,然后构建和训练一个包含Embedding层的二元分类模型:

    1. 下载大型电影评论数据集,其中包含来自互联网电影数据库(IMDb)的 50,000 条电影评论。数据组织在两个目录中,traintest,每个目录包含一个pos子目录,其中包含 12,500 条正面评论,以及一个neg子目录,其中包含 12,500 条负面评论。每个评论存储在单独的文本文件中。还有其他文件和文件夹(包括预处理的词袋版本),但在这个练习中我们将忽略它们。

    2. 将测试集分为验证集(15,000)和测试集(10,000)。

    3. 使用 tf.data 为每个集创建一个高效的数据集。

    4. 创建一个二元分类模型,使用TextVectorization层来预处理每个评论。

    5. 添加一个Embedding层,并计算每个评论的平均嵌入,乘以单词数量的平方根(参见第十六章)。然后将这个重新缩放的平均嵌入传递给您模型的其余部分。

    6. 训练模型并查看您获得的准确性。尝试优化您的管道,使训练尽可能快。

    7. 使用 TFDS 更轻松地加载相同的数据集:tfds.load("imdb_reviews")

这些练习的解决方案可以在本章笔记本的末尾找到,网址为https://homl.info/colab3

¹ 想象一副排好序的扑克牌在您的左边:假设您只拿出前三张牌并洗牌,然后随机选取一张放在右边,将另外两张留在手中。再从左边拿一张牌,在手中的三张牌中洗牌,随机选取一张放在右边。当您像这样处理完所有的牌后,您的右边将有一副扑克牌:您认为它会被完美洗牌吗?

² 一般来说,只预取一个批次就可以了,但在某些情况下,您可能需要预取更多。或者,您可以通过将tf.data.AUTOTUNE传递给prefetch(),让 TensorFlow 自动决定。

³ 但是请查看实验性的tf.data.experimental.prefetch_to_device()函数,它可以直接将数据预取到 GPU。任何带有experimental的 TensorFlow 函数或类的名称可能会在未来版本中发生更改而没有警告。如果实验性函数失败,请尝试删除experimental一词:它可能已经移至核心 API。如果没有,请查看笔记本,我会确保其中包含最新的代码。

⁴ 由于 protobuf 对象旨在被序列化和传输,它们被称为消息

⁵ 本章包含了您使用 TFRecords 所需了解的最基本知识。要了解更多关于 protobufs 的信息,请访问https://homl.info/protobuf

⁶ Tomáš Mikolov 等人,“单词和短语的分布式表示及其组合性”,第 26 届国际神经信息处理系统会议论文集 2(2013):3111–3119。

⁷ Malvina Nissim 等人,“公平比耸人听闻更好:男人对医生,女人对医生”,arXiv 预印本 arXiv:1905.09866(2019)。

⁸ TensorFlow Hub 没有与 TensorFlow 捆绑在一起,但如果您在 Colab 上运行或者按照https://homl.info/install的安装说明进行安装,那么它已经安装好了。

⁹ 要精确,句子嵌入等于句子中单词嵌入的平均值乘以句子中单词数的平方根。这是为了弥补随着n增长,n个随机向量的平均值会变短的事实。

¹⁰ 对于大图像,您可以使用tf.io.encode_jpeg()。这将节省大量空间,但会损失一些图像质量。

第十四章:使用卷积神经网络进行深度计算机视觉

尽管 IBM 的 Deep Blue 超级计算机在 1996 年击败了国际象棋世界冠军加里·卡斯帕罗夫,但直到最近计算机才能可靠地执行看似微不足道的任务,比如在图片中检测小狗或识别口语。为什么这些任务对我们人类来说如此轻松?答案在于感知主要发生在我们的意识领域之外,在我们大脑中专门的视觉、听觉和其他感觉模块中。当感觉信息达到我们的意识时,它已经被赋予高级特征;例如,当你看到一张可爱小狗的图片时,你无法选择看到小狗,注意到它的可爱。你也无法解释如何识别一个可爱的小狗;对你来说这是显而易见的。因此,我们不能信任我们的主观经验:感知并不是微不足道的,要理解它,我们必须看看我们的感觉模块是如何工作的。

卷积神经网络(CNNs)起源于对大脑视觉皮层的研究,自上世纪 80 年代以来就被用于计算机图像识别。在过去的 10 年里,由于计算能力的增加、可用训练数据的增加,以及第十一章中介绍的用于训练深度网络的技巧,CNNs 已经成功在一些复杂的视觉任务上实现了超人类表现。它们驱动着图像搜索服务、自动驾驶汽车、自动视频分类系统等。此外,CNNs 并不局限于视觉感知:它们在许多其他任务上也取得了成功,比如语音识别和自然语言处理。然而,我们现在将专注于视觉应用。

在本章中,我们将探讨 CNNs 的起源,它们的构建模块是什么样的,以及如何使用 Keras 实现它们。然后我们将讨论一些最佳的 CNN 架构,以及其他视觉任务,包括目标检测(对图像中的多个对象进行分类并在其周围放置边界框)和语义分割(根据对象所属的类别对每个像素进行分类)。

视觉皮层的结构

David H. Hubel 和 Torsten Wiesel 在1958 年对猫进行了一系列实验⁠¹,1959 年(以及几年后对猴子进行的实验⁠^(3)),为视觉皮层的结构提供了关键见解(这两位作者因其工作于 1981 年获得了诺贝尔生理学或医学奖)。特别是,他们表明视觉皮层中许多神经元具有小的局部感受野,这意味着它们只对位于视觉场有限区域内的视觉刺激做出反应(见图 14-1,其中五个神经元的局部感受野由虚线圈表示)。不同神经元的感受野可能重叠,它们共同覆盖整个视觉场。

mls3 1401

图 14-1. 视觉皮层中的生物神经元对视觉场中称为感受野的小区域中的特定模式做出反应;随着视觉信号通过连续的大脑模块,神经元对更大感受野中的更复杂模式做出反应

此外,作者们表明,一些神经元只对水平线的图像做出反应,而另一些只对具有不同方向的线做出反应(两个神经元可能具有相同的感受野,但对不同的线方向做出反应)。他们还注意到一些神经元具有更大的感受野,它们对更复杂的模式做出反应,这些模式是低级模式的组合。这些观察结果导致了这样一个想法,即高级神经元基于相邻低级神经元的输出(在图 14-1 中,注意到每个神经元只连接到前一层附近的神经元)。这种强大的架构能够在视觉领域的任何区域检测各种复杂的模式。

这些对视觉皮层的研究启发了 1980 年引入的neocognitron,逐渐演变成我们现在称之为卷积神经网络的东西。一个重要的里程碑是 Yann LeCun 等人在 1998 年发表的一篇论文,介绍了著名的LeNet-5架构,这种架构被银行广泛用于识别支票上的手写数字。这种架构具有一些你已经了解的构建块,如全连接层和 Sigmoid 激活函数,但它还引入了两个新的构建块:卷积层池化层。现在让我们来看看它们。

注意

为什么不简单地使用具有全连接层的深度神经网络来进行图像识别任务呢?不幸的是,尽管这对于小图像(例如 MNIST)效果很好,但对于较大的图像来说,由于需要的参数数量巨大,它会崩溃。例如,一个 100×100 像素的图像有 10,000 个像素,如果第一层只有 1,000 个神经元(这已经严重限制了传递到下一层的信息量),这意味着总共有 1 千万个连接。而这只是第一层。CNN 通过部分连接的层和权重共享来解决这个问题。

卷积层

CNN 最重要的构建块是卷积层:第一个卷积层中的神经元不与输入图像中的每个像素相连接(就像在前几章讨论的层中那样),而只与其感受野中的像素相连接(参见图 14-2)。反过来,第二个卷积层中的每个神经元只与第一层中一个小矩形内的神经元相连接。这种架构允许网络在第一个隐藏层集中于小的低级特征,然后在下一个隐藏层中将它们组合成更大的高级特征,依此类推。这种分层结构在现实世界的图像中很常见,这也是 CNN 在图像识别方面表现出色的原因之一。

mls3 1402

图 14-2。具有矩形局部感受野的 CNN 层
注意

到目前为止,我们看到的所有多层神经网络都由一长串神经元组成,我们必须在将输入图像馈送到神经网络之前将其展平为 1D。在 CNN 中,每一层都以 2D 表示,这使得更容易将神经元与其对应的输入匹配。

给定层中位于第i行,第j列的神经元连接到前一层中位于第i到第i + f[h] – 1 行,第j到第j + f[w] – 1 列的神经元的输出,其中f[h]和f[w]是感受野的高度和宽度(参见图 14-3)。为了使一层具有与前一层相同的高度和宽度,通常在输入周围添加零,如图中所示。这称为零填充

还可以通过间隔感受野来将大输入层连接到一个较小的层,如图 14-4 所示。这显着降低了模型的计算复杂性。从一个感受野到下一个感受野的水平或垂直步长称为步幅。在图中,一个 5×7 的输入层(加上零填充)连接到一个 3×4 的层,使用 3×3 的感受野和步幅为 2(在这个例子中,步幅在两个方向上是相同的,但不一定要这样)。上层中位于第i行,第j列的神经元连接到前一层中位于第i×s[h]到第i×s[h]+f[h]–1 行,第j×s[w]到第j×s[w]+f[w]–1 列的神经元的输出,其中s[h]和s[w]是垂直和水平步幅。

mls3 1403

图 14-3。层与零填充之间的连接

mls3 1404

图 14-4。使用步幅为 2 降低维度

滤波器

一个神经元的权重可以表示为一个与感受野大小相同的小图像。例如,图 14-5 显示了两组可能的权重,称为滤波器(或卷积核,或只是内核)。第一个滤波器表示为一个黑色正方形,中间有一条垂直白线(它是一个 7×7 的矩阵,除了中间列全是 1,其他都是 0);使用这些权重的神经元将忽略其感受野中的所有内容,除了中间的垂直线(因为所有输入将被乘以 0,除了中间的垂直线)。第二个滤波器是一个黑色正方形,中间有一条水平白线。使用这些权重的神经元将忽略其感受野中的所有内容,除了中间的水平线。

mls3 1405

图 14-5。应用两个不同的滤波器以获得两个特征图

现在,如果一个层中的所有神经元使用相同的垂直线滤波器(和相同的偏置项),并且您将输入图像输入到网络中,如图 14-5 所示(底部图像),该层将输出左上角的图像。请注意,垂直白线得到增强,而其余部分变得模糊。类似地,如果所有神经元使用相同的水平线滤波器,则会得到右上角的图像;请注意,水平白线得到增强,而其余部分被模糊化。因此,一个充满使用相同滤波器的神经元的层会输出一个特征图,突出显示激活滤波器最多的图像区域。但不用担心,您不必手动定义滤波器:相反,在训练期间,卷积层将自动学习其任务中最有用的滤波器,上面的层将学会将它们组合成更复杂的模式。

堆叠多个特征图

到目前为止,为了简单起见,我已经将每个卷积层的输出表示为一个 2D 层,但实际上,卷积层有多个滤波器(您决定有多少个),并且每个滤波器输出一个特征图,因此在 3D 中更准确地表示(请参见图 14-6)。每个特征图中的每个像素都有一个神经元,并且给定特征图中的所有神经元共享相同的参数(即相同的内核和偏置项)。不同特征图中的神经元使用不同的参数。神经元的感受野与之前描述的相同,但它跨越了前一层的所有特征图。简而言之,卷积层同时将多个可训练滤波器应用于其输入,使其能够在其输入的任何位置检测多个特征。

mls3 1406

图 14-6。两个具有多个滤波器(内核)的卷积层,处理具有三个颜色通道的彩色图像;每个卷积层输出一个特征图每个滤波器
注意

所有特征图中的所有神经元共享相同的参数,这显著减少了模型中的参数数量。一旦 CNN 学会在一个位置识别模式,它就可以在任何其他位置识别它。相比之下,一旦全连接的神经网络学会在一个位置识别模式,它只能在那个特定位置识别它。

输入图像也由多个子层组成:每个颜色通道一个。如第九章中所述,通常有三个:红色、绿色和蓝色(RGB)。灰度图像只有一个通道,但有些图像可能有更多通道,例如捕捉额外光频率(如红外线)的卫星图像。

具体来说,在给定卷积层l中特征图k中第i行、第j列的神经元与前一层l – 1 中位于第i × s[h]至i × s[h] + f[h] – 1 行和第j × s[w]至j × s[w] + f[w] – 1 列的神经元的输出相连,跨所有特征图(在第l – 1 层)。请注意,在同一层中,位于相同行i和列j但在不同特征图中的所有神经元与前一层中相同位置的神经元的输出相连。

方程 14-1 总结了前面的解释,用一个大数学方程表示:它展示了如何计算卷积层中给定神经元的输出。由于所有不同的索引,它看起来有点丑陋,但它的作用只是计算所有输入的加权和,再加上偏置项。

方程 14-1。计算卷积层中神经元的输出

z i,j,k = b k + u=0 f h -1 v=0 f w -1 k'=0 f n ' -1 x i ' ,j ' ,k ' × w u,v,k ' ,k with i ' = i × s h + u j ' = j × s w + v

在这个方程中:

  • z[i,] [j,] [k] 是位于卷积层(第l层)特征图k中第i行、第j列的神经元的输出。

  • 如前所述,s[h] 和 s[w] 是垂直和水平步幅,f[h] 和 f[w] 是感受野的高度和宽度,f[n′] 是前一层(第l – 1 层)中特征图的数量。

  • x[i′,] [j′,] [k′] 是位于第l – 1 层,第i′行、第j′列、特征图k′(或通道k′,如果前一层是输入层)的神经元的输出。

  • b[k] 是特征图k(在第l层)的偏置项。您可以将其视为微调特征图k的整体亮度的旋钮。

  • w[u,] [v,] [k′,] [k]是层l中特征图k中的任何神经元与其输入之间的连接权重,该输入位于行u、列v(相对于神经元的感受野),以及特征图k′。

让我们看看如何使用 Keras 创建和使用卷积层。

使用 Keras 实现卷积层

首先,让我们加载和预处理一些样本图像,使用 Scikit-Learn 的load_sample_image()函数和 Keras 的CenterCropRescaling层(这些都是在第十三章中介绍的):

from sklearn.datasets import load_sample_images
import tensorflow as tf

images = load_sample_images()["images"]
images = tf.keras.layers.CenterCrop(height=70, width=120)(images)
images = tf.keras.layers.Rescaling(scale=1 / 255)(images)

让我们看一下images张量的形状:

>>> images.shape
TensorShape([2, 70, 120, 3])

哎呀,这是一个 4D 张量;我们以前从未见过这个!所有这些维度是什么意思?嗯,有两个样本图像,这解释了第一个维度。然后每个图像是 70×120,因为这是我们在创建CenterCrop层时指定的大小(原始图像是 427×640)。这解释了第二和第三维度。最后,每个像素在每个颜色通道上保存一个值,有三个颜色通道——红色、绿色和蓝色,这解释了最后一个维度。

现在让我们创建一个 2D 卷积层,并将这些图像输入其中,看看输出是什么。为此,Keras 提供了一个Convolution2D层,别名为Conv2D。在幕后,这个层依赖于 TensorFlow 的tf.nn.conv2d()操作。让我们创建一个具有 32 个滤波器的卷积层,每个滤波器大小为 7×7(使用kernel_size=7,相当于使用kernel_size=(7 , 7)),并将这个层应用于我们的两个图像的小批量:

conv_layer = tf.keras.layers.Conv2D(filters=32, kernel_size=7)
fmaps = conv_layer(images)
注意

当我们谈论 2D 卷积层时,“2D”指的是空间维度(高度和宽度),但正如你所看到的,该层接受 4D 输入:正如我们所看到的,另外两个维度是批量大小(第一个维度)和通道数(最后一个维度)。

现在让我们看一下输出的形状:

>>> fmaps.shape
TensorShape([2, 64, 114, 32])

输出形状与输入形状类似,有两个主要区别。首先,有 32 个通道而不是 3 个。这是因为我们设置了filters=32,所以我们得到 32 个输出特征图:在每个位置的红色、绿色和蓝色的强度代替,我们现在有每个位置的每个特征的强度。其次,高度和宽度都减小了 6 个像素。这是因为Conv2D层默认不使用任何零填充,这意味着我们在输出特征图的两侧丢失了一些像素,取决于滤波器的大小。在这种情况下,由于卷积核大小为 7,我们水平和垂直各丢失 6 个像素(即每侧 3 个像素)。

警告

默认选项令人惊讶地被命名为padding="valid",实际上意味着根本没有零填充!这个名称来自于这样一个事实,即在这种情况下,每个神经元的感受野严格位于输入内部的有效位置(不会超出边界)。这不是 Keras 的命名怪癖:每个人都使用这种奇怪的命名法。

如果我们设置padding="same",那么输入将在所有侧面填充足够的零,以确保输出特征图最终与输入具有相同大小(因此这个选项的名称):

>>> conv_layer = tf.keras.layers.Conv2D(filters=32, kernel_size=7,
...                                     padding="same")
...
>>> fmaps = conv_layer(images)
>>> fmaps.shape
TensorShape([2, 70, 120, 32])

这两种填充选项在图 14-7 中有所说明。为简单起见,这里只显示了水平维度,但当然相同的逻辑也适用于垂直维度。

如果步幅大于 1(在任何方向上),那么输出大小将不等于输入大小,即使padding="same"。例如,如果设置strides=2(或等效地strides=(2, 2)),那么输出特征图将是 35×60:垂直和水平方向都减半。图 14-8 展示了当strides=2时会发生什么,两种填充选项都有。

mls3 1407

图 14-7。当strides=1时的两种填充选项

mls3 1408

图 14-8。当步长大于 1 时,即使使用"same"填充(和"valid"填充可能会忽略一些输入),输出也会小得多

如果您感兴趣,这是输出大小是如何计算的:

  • padding="valid"时,如果输入的宽度为i[h],那么输出宽度等于(i[h] - f[h] + s[h]) / s[h],向下取整。请记住f[h]是卷积核的宽度,s[h]是水平步长。除法中的余数对应于输入图像右侧被忽略的列。同样的逻辑也可以用来计算输出高度,以及图像底部被忽略的行。

  • padding="same"时,输出宽度等于i[h] / s[h],向上取整。为了实现这一点,在输入图像的左右两侧填充适当数量的零列(如果可能的话,数量相等,或者在右侧多一个)。假设输出宽度为o[w],那么填充的零列数为(o[w] - 1) × s[h] + f[h] - i[h]。同样的逻辑也可以用来计算输出高度和填充行数。

现在让我们来看一下层的权重(在方程 14-1 中被标记为w[u,] [v,] [k',] [k]和b[k])。就像Dense层一样,Conv2D层保存所有层的权重,包括卷积核和偏置。卷积核是随机初始化的,而偏置初始化为零。这些权重可以通过weights属性作为 TF 变量访问,也可以通过get_weights()方法作为 NumPy 数组访问:

>>> kernels, biases = conv_layer.get_weights()
>>> kernels.shape
(7, 7, 3, 32)
>>> biases.shape
(32,)

kernels数组是 4D 的,其形状为[kernel_height, kernel_width, input_channels, output_channels]。biases数组是 1D 的,形状为[output_channels]。输出通道的数量等于输出特征图的数量,也等于滤波器的数量。

最重要的是,需要注意输入图像的高度和宽度不会出现在卷积核的形状中:这是因为输出特征图中的所有神经元共享相同的权重,正如之前解释的那样。这意味着您可以将任何大小的图像馈送到这一层,只要它们至少与卷积核一样大,并且具有正确数量的通道(在这种情况下为三个)。

最后,通常情况下,您会希望在创建Conv2D层时指定一个激活函数(如 ReLU),并指定相应的内核初始化器(如 He 初始化)。这与Dense层的原因相同:卷积层执行线性操作,因此如果您堆叠多个卷积层而没有任何激活函数,它们都等同于单个卷积层,它们将无法学习到真正复杂的内容。

正如您所看到的,卷积层有很多超参数:filterskernel_sizepaddingstridesactivationkernel_initializer等。通常情况下,您可以使用交叉验证来找到正确的超参数值,但这是非常耗时的。我们将在本章后面讨论常见的 CNN 架构,以便让您了解在实践中哪些超参数值效果最好。

内存需求

CNN 的另一个挑战是卷积层需要大量的 RAM。这在训练过程中尤为明显,因为反向传播的反向传递需要在前向传递期间计算的所有中间值。

例如,考虑一个具有 200 个 5×5 滤波器的卷积层,步幅为 1,使用"same"填充。如果输入是一个 150×100 的 RGB 图像(三个通道),那么参数数量为(5×5×3+1)×200=15,200(+1 对应于偏置项),与全连接层相比相当小。然而,这 200 个特征图中的每一个包含 150×100 个神经元,每个神经元都需要计算其 5×5×3=75 个输入的加权和:总共有 2.25 亿次浮点乘法。虽然不像全连接层那么糟糕,但仍然相当计算密集。此外,如果使用 32 位浮点数表示特征图,那么卷积层的输出将占用 200×150×100×32=9600 万位(12 MB)的 RAM。而这只是一个实例的情况——如果一个训练批次包含 100 个实例,那么这一层将使用 1.2 GB 的 RAM!

在推断(即对新实例进行预测时),一个层占用的 RAM 可以在计算下一层后立即释放,因此你只需要两个连续层所需的 RAM。但在训练期间,前向传播期间计算的所有内容都需要保留以进行反向传播,因此所需的 RAM 量至少是所有层所需 RAM 的总量。

提示

如果由于内存不足错误而导致训练崩溃,你可以尝试减小小批量大小。或者,你可以尝试使用步幅减少维度,去掉一些层,使用 16 位浮点数代替 32 位浮点数,或者将 CNN 分布在多个设备上(你将在第十九章中看到如何做)。

现在让我们来看看 CNN 的第二个常见构建块:池化层

池化层

一旦你理解了卷积层的工作原理,池化层就很容易理解了。它们的目标是对输入图像进行子采样(即缩小),以减少计算负载、内存使用和参数数量(从而限制过拟合的风险)。

就像在卷积层中一样,池化层中的每个神经元连接到前一层中有限数量的神经元的输出,这些神经元位于一个小的矩形感受野内。你必须像以前一样定义它的大小、步幅和填充类型。然而,池化神经元没有权重;它所做的只是使用聚合函数(如最大值或平均值)聚合输入。图 14-9 展示了最大池化层,这是最常见的池化层类型。在这个例子中,我们使用了一个 2×2 的池化核,步幅为 2,没有填充。在图 14-9 中的左下角感受野中,输入值为 1、5、3、2,因此只有最大值 5 传播到下一层。由于步幅为 2,输出图像的高度和宽度都是输入图像的一半(向下取整,因为我们没有使用填充)。

mls3 1409

图 14-9。最大池化层(2×2 池化核,步幅 2,无填充)
注意

池化层通常独立地处理每个输入通道,因此输出深度(即通道数)与输入深度相同。

除了减少计算、内存使用和参数数量之外,最大池化层还引入了一定程度的不变性,如图 14-10 所示。在这里,我们假设亮像素的值低于暗像素的值,并考虑三个图像(A、B、C)通过一个 2×2 内核和步幅 2 的最大池化层。图像 B 和 C 与图像 A 相同,但向右移动了一个和两个像素。正如您所看到的,图像 A 和 B 的最大池化层的输出是相同的。这就是平移不变性的含义。对于图像 C,输出是不同的:向右移动一个像素(但仍然有 50%的不变性)。通过在 CNN 中的几层之间插入一个最大池化层,可以在更大的尺度上获得一定程度的平移不变性。此外,最大池化还提供了一定程度的旋转不变性和轻微的尺度不变性。这种不变性(即使有限)在预测不应该依赖这些细节的情况下可能是有用的,比如在分类任务中。

然而,最大池化也有一些缺点。显然,它非常破坏性:即使使用一个微小的 2×2 内核和步幅为 2,输出在两个方向上都会变小两倍(因此其面积会变小四倍),简单地丢弃了输入值的 75%。在某些应用中,不变性并不理想。以语义分割为例(根据像素所属的对象对图像中的每个像素进行分类的任务,我们将在本章后面探讨):显然,如果输入图像向右平移一个像素,输出也应该向右平移一个像素。在这种情况下的目标是等变性,而不是不变性:对输入的微小变化应导致输出的相应微小变化。

mls3 1410

图 14-10。对小平移的不变性

使用 Keras 实现池化层

以下代码创建了一个MaxPooling2D层,别名为MaxPool2D,使用一个 2×2 内核。步幅默认为内核大小,因此此层使用步幅为 2(水平和垂直)。默认情况下,它使用"valid"填充(即根本不填充):

max_pool = tf.keras.layers.MaxPool2D(pool_size=2)

要创建一个平均池化层,只需使用AveragePooling2D,别名为AvgPool2D,而不是MaxPool2D。正如您所期望的那样,它的工作方式与最大池化层完全相同,只是计算均值而不是最大值。平均池化层曾经非常流行,但现在人们大多使用最大池化层,因为它们通常表现更好。这可能看起来令人惊讶,因为计算均值通常比计算最大值丢失的信息更少。但另一方面,最大池化仅保留最强的特征,摆脱了所有无意义的特征,因此下一层得到了一个更干净的信号来处理。此外,最大池化比平均池化提供更强的平移不变性,并且需要稍少的计算。

请注意,最大池化和平均池化可以沿深度维度而不是空间维度执行,尽管这不太常见。这可以让 CNN 学习对各种特征具有不变性。例如,它可以学习多个滤波器,每个滤波器检测相同模式的不同旋转(例如手写数字;参见图 14-11),深度最大池化层将确保输出不管旋转如何都是相同的。CNN 也可以学习对任何东西具有不变性:厚度、亮度、倾斜、颜色等等。

mls3 1411

图 14-11。深度最大池化可以帮助 CNN 学习旋转不变性(在这种情况下)

Keras 不包括深度最大池化层,但实现一个自定义层并不太困难:

class DepthPool(tf.keras.layers.Layer):
    def __init__(self, pool_size=2, **kwargs):
        super().__init__(**kwargs)
        self.pool_size = pool_size

    def call(self, inputs):
        shape = tf.shape(inputs)  # shape[-1] is the number of channels
        groups = shape[-1] // self.pool_size  # number of channel groups
        new_shape = tf.concat([shape[:-1], [groups, self.pool_size]], axis=0)
        return tf.reduce_max(tf.reshape(inputs, new_shape), axis=-1)

这一层将其输入重塑为所需大小的通道组(pool_size),然后使用tf.reduce_max()来计算每个组的最大值。这种实现假定步幅等于池大小,这通常是你想要的。或者,您可以使用 TensorFlow 的tf.nn.max_pool()操作,并在Lambda层中包装以在 Keras 模型中使用它,但遗憾的是,此操作不实现 GPU 的深度池化,只实现 CPU 的深度池化。

在现代架构中经常看到的最后一种类型的池化层是全局平均池化层。它的工作方式非常不同:它只是计算每个整个特征图的平均值(就像使用与输入具有相同空间维度的池化核的平均池化层)。这意味着它只输出每个特征图和每个实例的一个数字。尽管这当然是极其破坏性的(大部分特征图中的信息都丢失了),但它可以在输出层之前非常有用,稍后您将在本章中看到。要创建这样的层,只需使用GlobalAveragePooling2D类,别名GlobalAvgPool2D

global_avg_pool = tf.keras.layers.GlobalAvgPool2D()

这等同于以下Lambda层,它计算空间维度(高度和宽度)上的平均值:

global_avg_pool = tf.keras.layers.Lambda(
    lambda X: tf.reduce_mean(X, axis=[1, 2]))

例如,如果我们将这一层应用于输入图像,我们将得到每个图像的红色、绿色和蓝色的平均强度:

>>> global_avg_pool(images)
<tf.Tensor: shape=(2, 3), dtype=float32, numpy=
array([[0.64338624, 0.5971759 , 0.5824972 ],
 [0.76306933, 0.26011038, 0.10849128]], dtype=float32)>

现在您知道如何创建卷积神经网络的所有构建模块了。让我们看看如何组装它们。

CNN 架构

典型的 CNN 架构堆叠了几个卷积层(每个通常后面跟着一个 ReLU 层),然后是一个池化层,然后又是几个卷积层(+ReLU),然后是另一个池化层,依此类推。随着图像通过网络的传递,图像变得越来越小,但也通常变得越来越深(即具有更多的特征图),这要归功于卷积层(参见图 14-12)。在堆栈的顶部,添加了一个常规的前馈神经网络,由几个全连接层(+ReLUs)组成,最后一层输出预测(例如,一个 softmax 层,输出估计的类别概率)。

mls3 1412

图 14-12. 典型的 CNN 架构
提示

一个常见的错误是使用太大的卷积核。例如,不要使用一个 5×5 的卷积层,而是堆叠两个 3×3 的卷积层:这将使用更少的参数,需要更少的计算,并且通常表现更好。一个例外是第一个卷积层:它通常可以有一个大的卷积核(例如 5×5),通常具有 2 或更大的步幅。这将减少图像的空间维度,而不会丢失太多信息,而且由于输入图像通常只有三个通道,因此成本不会太高。

这是如何实现一个基本的 CNN 来处理时尚 MNIST 数据集的(在第十章介绍):

from functools import partial

DefaultConv2D = partial(tf.keras.layers.Conv2D, kernel_size=3, padding="same",
                        activation="relu", kernel_initializer="he_normal")
model = tf.keras.Sequential([
    DefaultConv2D(filters=64, kernel_size=7, input_shape=[28, 28, 1]),
    tf.keras.layers.MaxPool2D(),
    DefaultConv2D(filters=128),
    DefaultConv2D(filters=128),
    tf.keras.layers.MaxPool2D(),
    DefaultConv2D(filters=256),
    DefaultConv2D(filters=256),
    tf.keras.layers.MaxPool2D(),
    tf.keras.layers.Flatten(),
    tf.keras.layers.Dense(units=128, activation="relu",
                          kernel_initializer="he_normal"),
    tf.keras.layers.Dropout(0.5),
    tf.keras.layers.Dense(units=64, activation="relu",
                          kernel_initializer="he_normal"),
    tf.keras.layers.Dropout(0.5),
    tf.keras.layers.Dense(units=10, activation="softmax")
])

让我们来看一下这段代码:

  • 我们使用functools.partial()函数(在第十一章介绍)来定义DefaultConv2D,它的作用就像Conv2D,但具有不同的默认参数:一个小的 3 的内核大小,"same"填充,ReLU 激活函数,以及相应的 He 初始化器。

  • 接下来,我们创建Sequential模型。它的第一层是一个具有 64 个相当大的滤波器(7×7)的DefaultConv2D。它使用默认的步幅 1,因为输入图像不是很大。它还设置input_shape=[28, 28, 1],因为图像是 28×28 像素,具有单个颜色通道(即灰度)。当您加载时尚 MNIST 数据集时,请确保每个图像具有这种形状:您可能需要使用np.reshape()np.expanddims()来添加通道维度。或者,您可以在模型中使用Reshape层作为第一层。

  • 然后我们添加一个使用默认池大小为 2 的最大池化层,因此它将每个空间维度除以 2。

  • 然后我们重复相同的结构两次:两个卷积层后面跟着一个最大池化层。对于更大的图像,我们可以多次重复这个结构。重复次数是一个可以调整的超参数。

  • 请注意,随着我们向 CNN 向输出层上升,滤波器的数量会翻倍(最初为 64,然后为 128,然后为 256):这是有道理的,因为低级特征的数量通常相当低(例如,小圆圈,水平线),但有许多不同的方法可以将它们组合成更高级别的特征。在每个池化层后将滤波器数量翻倍是一种常见做法:由于池化层将每个空间维度除以 2,我们可以在下一层中加倍特征图的数量,而不用担心参数数量、内存使用或计算负载的激增。

  • 接下来是全连接网络,由两个隐藏的密集层和一个密集输出层组成。由于这是一个有 10 个类别的分类任务,输出层有 10 个单元,并且使用 softmax 激活函数。请注意,我们必须在第一个密集层之前扁平化输入,因为它期望每个实例的特征是一个 1D 数组。我们还添加了两个 dropout 层,每个的 dropout 率为 50%,以减少过拟合。

如果您使用"sparse_categorical_crossentropy"损失编译此模型,并将模型拟合到 Fashion MNIST 训练集,它应该在测试集上达到超过 92%的准确率。这并不是最先进的,但是相当不错,显然比我们在第十章中使用密集网络取得的成绩要好得多。

多年来,这种基本架构的变体已经被开发出来,导致了该领域的惊人进步。这种进步的一个很好的衡量标准是在 ILSVRC(ImageNet 挑战)等比赛中的错误率。在这个比赛中,图像分类的前五错误率,即系统的前五个预测中没有包括正确答案的测试图像数量,从超过 26%下降到不到 2.3%仅仅用了六年。这些图像相当大(例如,高度为 256 像素),有 1000 个类别,其中一些非常微妙(尝试区分 120 种狗品种)。查看获胜作品的演变是了解 CNN 如何工作以及深度学习研究如何进展的好方法。

我们将首先看一下经典的 LeNet-5 架构(1998 年),然后看一下几位 ILSVRC 挑战的获胜者:AlexNet(2012),GoogLeNet(2014),ResNet(2015)和 SENet(2017)。在此过程中,我们还将看一些其他架构,包括 Xception,ResNeXt,DenseNet,MobileNet,CSPNet 和 EfficientNet。

LeNet-5

LeNet-5 架构可能是最广为人知的 CNN 架构。正如前面提到的,它是由 Yann LeCun 在 1998 年创建的,并且被广泛用于手写数字识别(MNIST)。它由表 14-1 中显示的层组成。

表 14-1. LeNet-5 架构

类型 特征图 尺寸 核大小 步幅 激活函数
Out 全连接 10 RBF
F6 全连接 84 tanh
C5 卷积 120 1 × 1 5 × 5 1 tanh
S4 平均池化 16 5 × 5 2 × 2 2 tanh
C3 卷积 16 10 × 10 5 × 5 1 tanh
S2 平均池化 6 14 × 14 2 × 2 2 tanh
C1 卷积 6 28 × 28 5 × 5 1 tanh
In 输入 1 32 × 32

正如您所看到的,这看起来与我们的时尚 MNIST 模型非常相似:一堆卷积层和池化层,然后是一个密集网络。也许与更现代的分类 CNN 相比,主要的区别在于激活函数:今天,我们会使用 ReLU 而不是 tanh,使用 softmax 而不是 RBF。还有一些其他不太重要的差异,但如果您感兴趣,可以在本章的笔记本中找到https://homl.info/colab3。Yann LeCun 的网站还展示了 LeNet-5 对数字进行分类的精彩演示。

AlexNet

AlexNet CNN 架构⁠¹¹在 2012 年 ILSVRC 挑战赛中大幅领先:它实现了 17%的前五错误率,而第二名竞争对手仅实现了 26%!AlexaNet 由 Alex Krizhevsky(因此得名)、Ilya Sutskever 和 Geoffrey Hinton 开发。它类似于 LeNet-5,只是更大更深,它是第一个直接将卷积层堆叠在一起的模型,而不是将池化层堆叠在每个卷积层之上。表 14-2 展示了这种架构。

表 14-2. AlexNet 架构

类型 特征图 大小 核大小 步幅 填充 激活函数
Out 全连接 1,000 Softmax
F10 全连接 4,096 ReLU
F9 全连接 4,096 ReLU
S8 最大池化 256 6 × 6 3 × 3 2 valid
C7 卷积 256 13 × 13 3 × 3 1 same ReLU
C6 卷积 384 13 × 13 3 × 3 1 same ReLU
C5 卷积 384 13 × 13 3 × 3 1 same ReLU
S4 最大池化 256 13 × 13 3 × 3 2 valid
C3 卷积 256 27 × 27 5 × 5 1 same ReLU
S2 最大池化 96 27 × 27 3 × 3 2 valid
C1 卷积 96 55 × 55 11 × 11 4 valid ReLU
In 输入 3(RGB) 227 × 227

为了减少过拟合,作者使用了两种正则化技术。首先,他们在训练期间对 F9 和 F10 层的输出应用了 50%的 dropout 率的 dropout(在第十一章中介绍)。其次,他们通过随机移动训练图像的各种偏移量、水平翻转它们和改变光照条件来执行数据增强。

AlexNet 还在 C1 和 C3 层的 ReLU 步骤之后立即使用了一个竞争性归一化步骤,称为局部响应归一化(LRN):最强烈激活的神经元抑制了位于相邻特征图中相同位置的其他神经元。这种竞争性激活已经在生物神经元中观察到。这鼓励不同的特征图专门化,将它们分开并迫使它们探索更广泛的特征,最终提高泛化能力。方程 14-2 展示了如何应用 LRN。

方程 14-2. 局部响应归一化(LRN)

b i = a i k+α j=j low j high a j 2 -β with j high = min i + r 2 , f n - 1 j low = max 0 , i - r 2

在这个方程中:

  • b[i] 是位于特征图i中的神经元的归一化输出,在某一行u和列v(请注意,在这个方程中,我们只考虑位于这一行和列的神经元,因此uv没有显示)。

  • a[i] 是 ReLU 步骤后,但规范化之前的神经元的激活。

  • kαβr是超参数。k称为偏置r称为深度半径

  • f[n] 是特征图的数量。

例如,如果r = 2,并且一个神经元具有强烈的激活,则它将抑制位于其上下特征图中的神经元的激活。

在 AlexNet 中,超参数设置为:r = 5,α = 0.0001,β = 0.75,k = 2。您可以使用tf.nn.local_response_normalization()函数来实现这一步骤(如果要在 Keras 模型中使用它,可以将其包装在Lambda层中)。

由 Matthew Zeiler 和 Rob Fergus 开发的 AlexNet 的一个变体称为ZF Net⁠¹²,并赢得了 2013 年 ILSVRC 挑战赛。它本质上是 AlexNet,只是调整了一些超参数(特征图数量、卷积核大小、步幅等)。

GoogLeNet

GoogLeNet 架构由 Google Research 的 Christian Szegedy 等人开发,⁠¹³,并通过将前五错误率降低到 7%以下赢得了 ILSVRC 2014 挑战。这一出色的性能在很大程度上来自于该网络比以前的 CNN 更深(如您将在图 14-15 中看到的)。这得益于称为inception 模块的子网络,⁠¹⁴,它允许 GoogLeNet 比以前的架构更有效地使用参数:实际上,GoogLeNet 的参数比 AlexNet 少 10 倍(大约 600 万个而不是 6000 万个)。

图 14-14 显示了 Inception 模块的架构。符号“3×3 + 1(S)”表示该层使用 3×3 内核,步幅 1 和"same"填充。输入信号首先并行输入到四个不同的层中。所有卷积层使用 ReLU 激活函数。请注意,顶部卷积层使用不同的内核大小(1×1、3×3 和 5×5),使它们能够捕获不同尺度的模式。还要注意,每个单独的层都使用步幅 1 和"same"填充(即使是最大池化层),因此它们的输出与它们的输入具有相同的高度和宽度。这使得可以在最终的深度连接层(即将来自所有四个顶部卷积层的特征图堆叠在一起)中沿深度维度连接所有输出。可以使用 Keras 的Concatenate层来实现,使用默认的axis=-1

mls3 1414

图 14-14。Inception 模块

您可能会想知道为什么 Inception 模块具有具有 1×1 内核的卷积层。毕竟,这些层不能捕获任何特征,因为它们一次只查看一个像素,对吧?实际上,这些层有三个目的:

  • 尽管它们不能捕获空间模式,但它们可以捕获沿深度维度(即跨通道)的模式。

  • 它们被配置为输出比它们的输入更少的特征图,因此它们充当瓶颈层,意味着它们降低了维度。这降低了计算成本和参数数量,加快了训练速度并提高了泛化能力。

  • 每对卷积层([1×1、3×3]和[1×1、5×5])就像一个强大的卷积层,能够捕获更复杂的模式。卷积层等效于在图像上扫过一个密集层(在每个位置,它只查看一个小的感受野),而这些卷积层对等于在图像上扫过两层神经网络。

简而言之,您可以将整个 Inception 模块视为一个超级卷积层,能够输出捕获各种尺度复杂模式的特征图。

现在让我们来看看 GoogLeNet CNN 的架构(参见图 14-15)。每个卷积层和每个池化层输出的特征图数量在内核大小之前显示。该架构非常深,以至于必须用三列来表示,但实际上 GoogLeNet 是一个高高的堆叠,包括九个 Inception 模块(带有旋转顶部的方框)。Inception 模块中的六个数字表示模块中每个卷积层输出的特征图数量(与图 14-14 中的顺序相同)。请注意,所有卷积层都使用 ReLU 激活函数。

让我们来看看这个网络:

  • 前两层将图像的高度和宽度分别除以 4(因此其面积除以 16),以减少计算负载。第一层使用大的内核大小,7×7,以便保留大部分信息。

  • 然后,本地响应归一化层确保前面的层学习到各种各样的特征(如前面讨论的)。

  • 接下来是两个卷积层,其中第一个充当瓶颈层。正如前面提到的,您可以将这一对看作一个更聪明的单个卷积层。

  • 再次,本地响应归一化层确保前面的层捕获各种各样的模式。

  • 接下来,一个最大池化层将图像的高度和宽度减少了一半,以加快计算速度。

  • 然后是 CNN 的骨干:一个高高的堆叠,包括九个 Inception 模块,交替使用一对最大池化层来降低维度并加快网络速度。

  • 接下来,全局平均池化层输出每个特征图的平均值:这会丢弃任何剩余的空间信息,这没关系,因为在那一点上剩下的空间信息并不多。事实上,GoogLeNet 的输入图像通常期望为 224×224 像素,因此经过 5 个最大池化层后,每个将高度和宽度除以 2,特征图缩小到 7×7。此外,这是一个分类任务,而不是定位任务,因此物体在哪里并不重要。由于这一层带来的降维,不需要在 CNN 的顶部有几个全连接层(就像在 AlexNet 中那样),这大大减少了网络中的参数数量,并限制了过拟合的风险。

  • 最后几层很容易理解:用于正则化的 dropout,然后是一个具有 1,000 个单元的全连接层(因为有 1,000 个类别),以及一个 softmax 激活函数来输出估计的类别概率。

mls3 1415

图 14-15。GoogLeNet 架构

原始的 GoogLeNet 架构包括两个辅助分类器,插在第三和第六个 inception 模块的顶部。它们都由一个平均池化层、一个卷积层、两个全连接层和一个 softmax 激活层组成。在训练过程中,它们的损失(缩小了 70%)被添加到整体损失中。目标是解决梯度消失问题并对网络进行正则化,但后来证明它们的效果相对较小。

后来,Google 的研究人员提出了 GoogLeNet 架构的几个变体,包括 Inception-v3 和 Inception-v4,使用略有不同的 inception 模块以实现更好的性能。

VGGNet

在 ILSVRC 2014 挑战赛中的亚军是VGGNet,Karen Simonyan 和 Andrew Zisserman,来自牛津大学视觉几何组(VGG)研究实验室,开发了一个非常简单和经典的架构;它有 2 或 3 个卷积层和一个池化层,然后再有 2 或 3 个卷积层和一个池化层,依此类推(达到 16 或 19 个卷积层,取决于 VGG 的变体),再加上一个最终的具有 2 个隐藏层和输出层的密集网络。它使用小的 3×3 滤波器,但数量很多。

ResNet

Kaiming He 等人在 ILSVRC 2015 挑战赛中使用Residual Network (ResNet)赢得了冠军,其前五错误率令人惊叹地低于 3.6%。获胜的变体使用了一个由 152 层组成的极深 CNN(其他变体有 34、50 和 101 层)。它证实了一个普遍趋势:计算机视觉模型变得越来越深,参数越来越少。能够训练如此深的网络的关键是使用跳跃连接(也称为快捷连接):输入到一个层的信号也被添加到堆栈中更高的层的输出中。让我们看看为什么这很有用。

在训练神经网络时,目标是使其模拟目标函数h(x)。如果将输入x添加到网络的输出中(即添加一个跳跃连接),那么网络将被迫模拟f(x) = h(x) - x而不是h(x)。这被称为残差学习

mls3 1416

图 14-16。残差学习

当初始化一个常规的神经网络时,它的权重接近于零,因此网络只会输出接近于零的值。如果添加一个跳跃连接,结果网络将只输出其输入的副本;换句话说,它最初模拟的是恒等函数。如果目标函数与恒等函数相当接近(这通常是情况),这将大大加快训练速度。

此外,如果添加许多跳跃连接,即使有几个层尚未开始学习,网络也可以开始取得进展(参见图 14-17)。由于跳跃连接,信号可以轻松地在整个网络中传播。深度残差网络可以看作是一堆残差单元(RUs),其中每个残差单元是一个带有跳跃连接的小型神经网络。

现在让我们看一下 ResNet 的架构(参见图 14-18)。它非常简单。它的开头和结尾与 GoogLeNet 完全相同(除了没有丢弃层),中间只是一个非常深的残差单元堆栈。每个残差单元由两个卷积层组成(没有池化层!),使用 3×3 的卷积核和保持空间维度(步幅 1,"same"填充)的批量归一化(BN)和 ReLU 激活。

mls3 1417

图 14-17。常规深度神经网络(左)和深度残差网络(右)

mls3 1418

图 14-18。ResNet 架构

请注意,每隔几个残差单元,特征图的数量会加倍,同时它们的高度和宽度会减半(使用步幅为 2 的卷积层)。当这种情况发生时,输入不能直接添加到残差单元的输出中,因为它们的形状不同(例如,这个问题影响了由虚线箭头表示的跳跃连接在图 14-18 中的情况)。为了解决这个问题,输入通过一个步幅为 2 的 1×1 卷积层,并具有正确数量的输出特征图(参见图 14-19)。

mls3 1419

图 14-19。更改特征图大小和深度时的跳跃连接

存在不同变体的架构,具有不同数量的层。ResNet-34 是一个具有 34 层的 ResNet(仅计算卷积层和全连接层),包含 3 个输出 64 个特征图的 RU,4 个输出 128 个特征图的 RU,6 个输出 256 个特征图的 RU,以及 3 个输出 512 个特征图的 RU。我们将在本章后面实现这个架构。

注意

Google 的Inception-v4⁠¹⁸架构融合了 GoogLeNet 和 ResNet 的思想,并在 ImageNet 分类中实现了接近 3%的前五错误率。

比 ResNet-152 更深的 ResNet,例如 ResNet-152,使用略有不同的残差单元。它们不是使用两个具有 256 个特征图的 3×3 卷积层,而是使用三个卷积层:首先是一个只有 64 个特征图的 1×1 卷积层(少了 4 倍),它充当瓶颈层(如前所述),然后是一个具有 64 个特征图的 3×3 层,最后是另一个具有 256 个特征图的 1×1 卷积层(4 倍 64),恢复原始深度。ResNet-152 包含 3 个输出 256 个映射的这样的 RU,然后是 8 个输出 512 个映射的 RU,一个令人惊叹的 36 个输出 1024 个映射的 RU,最后是 3 个输出 2048 个映射的 RU。

Xception

值得注意的是 GoogLeNet 架构的另一个变种:Xception(代表Extreme Inception)由 Keras 的作者 François Chollet 于 2016 年提出,并在一个庞大的视觉任务(3.5 亿张图片和 1.7 万个类别)上明显优于 Inception-v3。就像 Inception-v4 一样,它融合了 GoogLeNet 和 ResNet 的思想,但是用一个特殊类型的层称为深度可分离卷积层(或简称可分离卷积层)替换了 inception 模块。这些层在一些 CNN 架构中之前已经被使用过,但在 Xception 架构中并不像现在这样核心。常规卷积层使用滤波器,试图同时捕捉空间模式(例如,椭圆)和跨通道模式(例如,嘴+鼻子+眼睛=脸),而可分离卷积层则做出了空间模式和跨通道模式可以分别建模的强烈假设(见图 14-20)。因此,它由两部分组成:第一部分对每个输入特征图应用一个单一的空间滤波器,然后第二部分专门寻找跨通道模式——这只是一个具有 1×1 滤波器的常规卷积层。

由于可分离卷积层每个输入通道只有一个空间滤波器,所以应避免在通道较少的层之后使用它们,比如输入层(尽管图 14-20 中是这样的,但那只是为了说明目的)。因此,Xception 架构以 2 个常规卷积层开始,然后剩下的架构只使用可分离卷积(总共 34 个),再加上一些最大池化层和通常的最终层(一个全局平均池化层和一个密集输出层)。

你可能会想为什么 Xception 被认为是 GoogLeNet 的一个变种,因为它根本不包含任何 inception 模块。嗯,正如之前讨论的那样,一个 inception 模块包含有 1×1 滤波器的卷积层:这些滤波器专门寻找跨通道模式。然而,位于它们之上的卷积层是常规卷积层,既寻找空间模式又寻找跨通道模式。因此,你可以将一个 inception 模块看作是一个常规卷积层(同时考虑空间模式和跨通道模式)和一个可分离卷积层(分别考虑它们)之间的中间层。实际上,可分离卷积层通常表现更好。

mls3 1420

图 14-20。深度可分离卷积层
提示

可分离卷积层使用更少的参数、更少的内存和更少的计算量比常规卷积层,通常表现更好。考虑默认使用它们,除了在通道较少的层之后(比如输入通道)。在 Keras 中,只需使用SeparableConv2D代替Conv2D:这是一个即插即用的替代。Keras 还提供了一个DepthwiseConv2D层,实现深度可分离卷积层的第一部分(即,对每个输入特征图应用一个空间滤波器)。

SENet

在 ILSVRC 2017 挑战中获胜的架构是Squeeze-and-Excitation Network (SENet)。这个架构扩展了现有的架构,如 inception 网络和 ResNets,并提升了它们的性能。这使得 SENet 以惊人的 2.25%的前五错误率赢得了比赛!扩展版本的 inception 网络和 ResNets 分别称为SE-InceptionSE-ResNet。提升来自于 SENet 在原始架构的每个 inception 模块或残差单元中添加了一个小型神经网络,称为SE 块,如图 14-21 所示。

mls3 1421

图 14-21. SE-Inception 模块(左)和 SE-ResNet 单元(右)

一个 SE 块分析其所附加的单元的输出,专注于深度维度(不寻找任何空间模式),并学习哪些特征通常是最活跃的。然后,它使用这些信息来重新校准特征映射,如图 14-22 所示。例如,一个 SE 块可能学习到嘴巴、鼻子和眼睛通常一起出现在图片中:如果你看到嘴巴和鼻子,你应该期望也看到眼睛。因此,如果该块在嘴巴和鼻子特征映射中看到强烈的激活,但在眼睛特征映射中只有轻微的激活,它将增强眼睛特征映射(更准确地说,它将减少不相关的特征映射)。如果眼睛有些混淆,这种特征映射的重新校准将有助于解决模糊性。

mls3 1422

图 14-22. 一个 SE 块执行特征映射重新校准

一个 SE 块由三层组成:一个全局平均池化层,一个使用 ReLU 激活函数的隐藏密集层,以及一个使用 sigmoid 激活函数的密集输出层(见图 14-23)。

mls3 1423

图 14-23. SE 块架构

与之前一样,全局平均池化层计算每个特征映射的平均激活:例如,如果其输入包含 256 个特征映射,它将输出 256 个数字,表示每个滤波器的整体响应水平。接下来的层是“挤压”发生的地方:这一层的神经元数量明显少于 256 个——通常比特征映射的数量少 16 倍(例如,16 个神经元)——因此 256 个数字被压缩成一个小向量(例如,16 维)。这是特征响应分布的低维向量表示(即嵌入)。这个瓶颈步骤迫使 SE 块学习特征组合的一般表示(当我们讨论自编码器时,我们将再次看到这个原则在第十七章中)。最后,输出层接受嵌入并输出一个包含每个特征映射的重新校准向量(例如,256 个),每个数字在 0 到 1 之间。然后特征映射乘以这个重新校准向量,因此不相关的特征(具有低重新校准分数)被缩小,而相关的特征(具有接近 1 的重新校准分数)被保留。

其他值得注意的架构

还有许多其他 CNN 架构可以探索。以下是一些最值得注意的简要概述:

ResNeXt⁠²²

ResNeXt 改进了 ResNet 中的残差单元。而最佳 ResNet 模型中的残差单元只包含 3 个卷积层,ResNeXt 的残差单元由许多并行堆栈组成(例如,32 个堆栈),每个堆栈有 3 个卷积层。然而,每个堆栈中的前两层只使用少量滤波器(例如,只有四个),因此总参数数量与 ResNet 中的相同。然后,所有堆栈的输出相加,并将结果传递给下一个残差单元(以及跳跃连接)。

DenseNet⁠²³

DenseNet 由几个密集块组成,每个块由几个密集连接的卷积层组成。这种架构在使用相对较少的参数的同时实现了出色的准确性。什么是“密集连接”?每一层的输出被馈送为同一块内每一层之后的每一层的输入。例如,块中的第 4 层以该块中第 1、2 和 3 层的输出的深度级联作为输入。密集块之间由几个过渡层分隔。

MobileNet⁠²⁴

MobileNets 是精简的模型,旨在轻量且快速,因此在移动和 Web 应用程序中很受欢迎。它们基于深度可分离卷积层,类似于 Xception。作者提出了几个变体,以牺牲一点准确性换取更快速和更小的模型。

CSPNet⁠²⁵

交叉阶段部分网络(CSPNet)类似于 DenseNet,但是每个密集块的部分输入直接连接到该块的输出,而不经过该块。

EfficientNet⁠²⁶

EfficientNet 可以说是这个列表中最重要的模型。作者提出了一种有效地扩展任何 CNN 的方法,通过以原则性的方式同时增加深度(层数)、宽度(每层的滤波器数量)和分辨率(输入图像的大小)。这被称为复合缩放。他们使用神经架构搜索来找到一个适合 ImageNet 的缩小版本(具有更小和更少的图像)的良好架构,然后使用复合缩放来创建这种架构的越来越大的版本。当 EfficientNet 模型推出时,它们在所有计算预算中都远远超过了所有现有的模型,并且它们仍然是当今最好的模型之一。

理解 EfficientNet 的复合缩放方法有助于更深入地理解 CNN,特别是如果您需要扩展 CNN 架构。它基于计算预算的对数度量,标记为ϕ:如果您的计算预算翻倍,则ϕ增加 1。换句话说,用于训练的浮点运算数量与 2^(ϕ)成比例。您的 CNN 架构的深度、宽度和分辨率应分别按α(*ϕ*)、*β*(ϕ)和γ^(ϕ)缩放。因子αβγ必须大于 1,且α + β² + γ²应接近 2。这些因子的最佳值取决于 CNN 的架构。为了找到 EfficientNet 架构的最佳值,作者从一个小的基线模型(EfficientNetB0)开始,固定ϕ = 1,然后简单地运行了一个网格搜索:他们发现α = 1.2,β = 1.1,γ = 1.1。然后,他们使用这些因子创建了几个更大的架构,命名为 EfficientNetB1 到 EfficientNetB7,对应不断增加的ϕ值。

选择正确的 CNN 架构

有这么多 CNN 架构,您如何选择最适合您项目的架构?这取决于您最关心的是什么:准确性?模型大小(例如,用于部署到移动设备)?在 CPU 上的推理速度?在 GPU 上的推理速度?表 14-3 列出了目前在 Keras 中可用的最佳预训练模型(您将在本章后面看到如何使用它们),按模型大小排序。您可以在https://keras.io/api/applications找到完整列表。对于每个模型,表格显示要使用的 Keras 类名(在tf.keras.applications包中)、模型的大小(MB)、在 ImageNet 数据集上的 Top-1 和 Top-5 验证准确率、参数数量(百万)以及在 CPU 和 GPU 上使用 32 张图像的推理时间(毫秒),使用性能较强的硬件。⁠²⁷ 对于每列,最佳值已突出显示。正如您所看到的,通常较大的模型更准确,但并非总是如此;例如,EfficientNetB2 在大小和准确性上均优于 InceptionV3。我之所以将 InceptionV3 保留在列表中,是因为在 CPU 上它几乎比 EfficientNetB2 快一倍。同样,InceptionResNetV2 在 CPU 上速度很快,而 ResNet50V2 和 ResNet101V2 在 GPU 上速度极快。

表 14-3。Keras 中可用的预训练模型

类名 大小(MB) Top-1 准确率 Top-5 准确率 参数 CPU(ms) GPU(ms)
MobileNetV2 14 71.3% 90.1% 3.5M 25.9 3.8
MobileNet 16 70.4% 89.5% 4.3M 22.6 3.4
NASNetMobile 23 74.4% 91.9% 5.3M 27.0 6.7
EfficientNetB0 29 77.1% 93.3% 5.3M 46.0 4.9
EfficientNetB1 31 79.1% 94.4% 7.9M 60.2 5.6
EfficientNetB2 36 80.1% 94.9% 9.2M 80.8 6.5
EfficientNetB3 48 81.6% 95.7% 12.3M 140.0 8.8
EfficientNetB4 75 82.9% 96.4% 19.5M 308.3 15.1
InceptionV3 92 77.9% 93.7% 23.9M 42.2 6.9
ResNet50V2 98 76.0% 93.0% 25.6M 45.6 4.4
EfficientNetB5 118 83.6% 96.7% 30.6M 579.2 25.3
EfficientNetB6 166 84.0% 96.8% 43.3M 958.1 40.4
ResNet101V2 171 77.2% 93.8% 44.7M 72.7 5.4
InceptionResNetV2 215 80.3% 95.3% 55.9M 130.2 10.0
EfficientNetB7 256 84.3% 97.0% 66.7M 1578.9 61.6

希望您喜欢这次对主要 CNN 架构的深入探讨!现在让我们看看如何使用 Keras 实现其中一个。

使用 Keras 实现 ResNet-34 CNN

到目前为止,大多数描述的 CNN 架构可以很自然地使用 Keras 实现(尽管通常您会加载一个预训练网络,正如您将看到的)。为了说明这个过程,让我们使用 Keras 从头开始实现一个 ResNet-34。首先,我们将创建一个ResidualUnit层:

DefaultConv2D = partial(tf.keras.layers.Conv2D, kernel_size=3, strides=1,
                        padding="same", kernel_initializer="he_normal",
                        use_bias=False)

class ResidualUnit(tf.keras.layers.Layer):
    def __init__(self, filters, strides=1, activation="relu", **kwargs):
        super().__init__(**kwargs)
        self.activation = tf.keras.activations.get(activation)
        self.main_layers = [
            DefaultConv2D(filters, strides=strides),
            tf.keras.layers.BatchNormalization(),
            self.activation,
            DefaultConv2D(filters),
            tf.keras.layers.BatchNormalization()
        ]
        self.skip_layers = []
        if strides > 1:
            self.skip_layers = [
                DefaultConv2D(filters, kernel_size=1, strides=strides),
                tf.keras.layers.BatchNormalization()
            ]

    def call(self, inputs):
        Z = inputs
        for layer in self.main_layers:
            Z = layer(Z)
        skip_Z = inputs
        for layer in self.skip_layers:
            skip_Z = layer(skip_Z)
        return self.activation(Z + skip_Z)

正如您所看到的,这段代码与图 14-19 非常接近。在构造函数中,我们创建所有需要的层:图中右侧的主要层和左侧的跳过层(仅在步幅大于 1 时需要)。然后在call()方法中,我们让输入经过主要层和跳过层(如果有的话),然后我们添加两个输出并应用激活函数。

现在我们可以使用Sequential模型构建一个 ResNet-34,因为它实际上只是一长串的层——现在我们有了ResidualUnit类,可以将每个残差单元视为一个单独的层。代码与图 14-18 非常相似:

model = tf.keras.Sequential([
    DefaultConv2D(64, kernel_size=7, strides=2, input_shape=[224, 224, 3]),
    tf.keras.layers.BatchNormalization(),
    tf.keras.layers.Activation("relu"),
    tf.keras.layers.MaxPool2D(pool_size=3, strides=2, padding="same"),
])
prev_filters = 64
for filters in [64] * 3 + [128] * 4 + [256] * 6 + [512] * 3:
    strides = 1 if filters == prev_filters else 2
    model.add(ResidualUnit(filters, strides=strides))
    prev_filters = filters

model.add(tf.keras.layers.GlobalAvgPool2D())
model.add(tf.keras.layers.Flatten())
model.add(tf.keras.layers.Dense(10, activation="softmax"))

这段代码中唯一棘手的部分是将ResidualUnit层添加到模型的循环:正如前面解释的,前 3 个 RU 有 64 个滤波器,然后接下来的 4 个 RU 有 128 个滤波器,依此类推。在每次迭代中,当滤波器的数量与前一个 RU 中的数量相同时,我们必须将步幅设置为 1;否则,我们将其设置为 2;然后我们添加ResidualUnit,最后我们更新prev_filters

令人惊讶的是,我们只需大约 40 行代码,就可以构建赢得 ILSVRC 2015 挑战的模型!这既展示了 ResNet 模型的优雅之处,也展示了 Keras API 的表现力。实现其他 CNN 架构会有点长,但并不难。不过,Keras 内置了几种这些架构,为什么不直接使用呢?

使用 Keras 中的预训练模型

通常,您不必手动实现标准模型,如 GoogLeNet 或 ResNet,因为在tf.keras.applications包中只需一行代码即可获得预训练网络。

例如,您可以使用以下代码加载在 ImageNet 上预训练的 ResNet-50 模型:

model = tf.keras.applications.ResNet50(weights="imagenet")

就这些!这将创建一个 ResNet-50 模型,并下载在 ImageNet 数据集上预训练的权重。要使用它,您首先需要确保图像的尺寸正确。ResNet-50 模型期望 224×224 像素的图像(其他模型可能期望其他尺寸,如 299×299),因此让我们使用 Keras 的Resizing层(在第十三章中介绍)来调整两个示例图像的大小(在将它们裁剪到目标纵横比之后):

images = load_sample_images()["images"]
images_resized = tf.keras.layers.Resizing(height=224, width=224,
                                          crop_to_aspect_ratio=True)(images)

预训练模型假定图像以特定方式预处理。在某些情况下,它们可能期望输入被缩放为 0 到 1,或者从-1 到 1 等等。每个模型都提供了一个preprocess_input()函数,您可以用它来预处理您的图像。这些函数假设原始像素值的范围是 0 到 255,这在这里是正确的:

inputs = tf.keras.applications.resnet50.preprocess_input(images_resized)

现在我们可以使用预训练模型进行预测:

>>> Y_proba = model.predict(inputs)
>>> Y_proba.shape
(2, 1000)

像往常一样,输出Y_proba是一个矩阵,每行代表一个图像,每列代表一个类别(在本例中有 1,000 个类别)。如果您想显示前K个预测结果,包括类别名称和每个预测类别的估计概率,请使用decode_predictions()函数。对于每个图像,它返回一个包含前K个预测结果的数组,其中每个预测结果表示为一个包含类别标识符、其名称和相应置信度分数的数组:

top_K = tf.keras.applications.resnet50.decode_predictions(Y_proba, top=3)
for image_index in range(len(images)):
    print(f"Image #{image_index}")
    for class_id, name, y_proba in top_K[image_index]:
        print(f" {class_id} - {name:12s}{y_proba:.2%}")

输出如下所示:

Image #0
  n03877845 - palace       54.69%
  n03781244 - monastery    24.72%
  n02825657 - bell_cote    18.55%
Image #1
  n04522168 - vase         32.66%
  n11939491 - daisy        17.81%
  n03530642 - honeycomb    12.06%

正确的类别是 palace 和 dahlia,因此模型对第一张图像是正确的,但对第二张图像是错误的。然而,这是因为 dahlia 不是 1,000 个 ImageNet 类之一。考虑到这一点,vase 是一个合理的猜测(也许这朵花在花瓶里?),daisy 也不是一个坏选择,因为 dahlias 和 daisies 都属于同一菊科家族。

正如您所看到的,使用预训练模型创建一个相当不错的图像分类器非常容易。正如您在表 14-3 中看到的,tf.keras.applications中提供了许多其他视觉模型,从轻量级快速模型到大型准确模型。

但是,如果您想要为不属于 ImageNet 的图像类别使用图像分类器,那么您仍然可以通过使用预训练模型来进行迁移学习获益。

用于迁移学习的预训练模型

如果您想构建一个图像分类器,但没有足够的数据来从头开始训练它,那么通常可以重用预训练模型的较低层,正如我们在第十一章中讨论的那样。例如,让我们训练一个模型来对花的图片进行分类,重用一个预训练的 Xception 模型。首先,我们将使用 TensorFlow Datasets(在第十三章中介绍)加载花卉数据集:

import tensorflow_datasets as tfds

dataset, info = tfds.load("tf_flowers", as_supervised=True, with_info=True)
dataset_size = info.splits["train"].num_examples  # 3670
class_names = info.features["label"].names  # ["dandelion", "daisy", ...]
n_classes = info.features["label"].num_classes  # 5

请注意,您可以通过设置with_info=True来获取有关数据集的信息。在这里,我们获取数据集的大小和类的名称。不幸的是,只有一个"train"数据集,没有测试集或验证集,所以我们需要拆分训练集。让我们再次调用tfds.load(),但这次将前 10%的数据集用于测试,接下来的 15%用于验证,剩下的 75%用于训练:

test_set_raw, valid_set_raw, train_set_raw = tfds.load(
    "tf_flowers",
    split=["train[:10%]", "train[10%:25%]", "train[25%:]"],
    as_supervised=True)

所有三个数据集都包含单独的图像。我们需要对它们进行批处理,但首先我们需要确保它们都具有相同的大小,否则批处理将失败。我们可以使用Resizing层来实现这一点。我们还必须调用tf.keras.applications.xception.preprocess_input()函数,以适当地预处理图像以供 Xception 模型使用。最后,我们还将对训练集进行洗牌并使用预取:

batch_size = 32
preprocess = tf.keras.Sequential([
    tf.keras.layers.Resizing(height=224, width=224, crop_to_aspect_ratio=True),
    tf.keras.layers.Lambda(tf.keras.applications.xception.preprocess_input)
])
train_set = train_set_raw.map(lambda X, y: (preprocess(X), y))
train_set = train_set.shuffle(1000, seed=42).batch(batch_size).prefetch(1)
valid_set = valid_set_raw.map(lambda X, y: (preprocess(X), y)).batch(batch_size)
test_set = test_set_raw.map(lambda X, y: (preprocess(X), y)).batch(batch_size)

现在每个批次包含 32 个图像,所有图像都是 224×224 像素,像素值范围从-1 到 1。完美!

由于数据集不是很大,一点数据增强肯定会有所帮助。让我们创建一个数据增强模型,将其嵌入到我们的最终模型中。在训练期间,它将随机水平翻转图像,稍微旋转它们,并调整对比度:

data_augmentation = tf.keras.Sequential([
    tf.keras.layers.RandomFlip(mode="horizontal", seed=42),
    tf.keras.layers.RandomRotation(factor=0.05, seed=42),
    tf.keras.layers.RandomContrast(factor=0.2, seed=42)
])
提示

tf.keras.preprocessing.image.ImageDataGenerator类使从磁盘加载图像并以各种方式增强它们变得容易:您可以移动每个图像,旋转它,重新缩放它,水平或垂直翻转它,剪切它,或者应用任何您想要的转换函数。这对于简单的项目非常方便。然而,tf.data 管道并不复杂,通常更快。此外,如果您有 GPU 并且将预处理或数据增强层包含在模型内部,它们将在训练过程中受益于 GPU 加速。

接下来让我们加载一个在 ImageNet 上预训练的 Xception 模型。通过设置include_top=False来排除网络的顶部。这将排除全局平均池化层和密集输出层。然后我们添加自己的全局平均池化层(将其输入设置为基础模型的输出),然后是一个具有每个类别一个单元的密集输出层,使用 softmax 激活函数。最后,我们将所有这些包装在一个 Keras Model中:

base_model = tf.keras.applications.xception.Xception(weights="imagenet",
                                                     include_top=False)
avg = tf.keras.layers.GlobalAveragePooling2D()(base_model.output)
output = tf.keras.layers.Dense(n_classes, activation="softmax")(avg)
model = tf.keras.Model(inputs=base_model.input, outputs=output)

如第十一章中解释的,通常冻结预训练层的权重是一个好主意,至少在训练开始时是这样的:

for layer in base_model.layers:
    layer.trainable = False
警告

由于我们的模型直接使用基础模型的层,而不是base_model对象本身,设置base_model.trainable=False不会产生任何效果。

最后,我们可以编译模型并开始训练:

optimizer = tf.keras.optimizers.SGD(learning_rate=0.1, momentum=0.9)
model.compile(loss="sparse_categorical_crossentropy", optimizer=optimizer,
              metrics=["accuracy"])
history = model.fit(train_set, validation_data=valid_set, epochs=3)
警告

如果你在 Colab 上运行,请确保运行时正在使用 GPU:选择运行时→“更改运行时类型”,在“硬件加速器”下拉菜单中选择“GPU”,然后点击保存。可以在没有 GPU 的情况下训练模型,但速度会非常慢(每个时期几分钟,而不是几秒)。

在训练模型几个时期后,其验证准确率应该达到 80%以上,然后停止提高。这意味着顶层现在已经训练得相当好,我们准备解冻一些基础模型的顶层,然后继续训练。例如,让我们解冻第 56 层及以上的层(这是 14 个残差单元中第 7 个的开始,如果列出层名称,你会看到):

for layer in base_model.layers[56:]:
    layer.trainable = True

不要忘记在冻结或解冻层时编译模型。还要确保使用更低的学习率以避免破坏预训练权重:

optimizer = tf.keras.optimizers.SGD(learning_rate=0.01, momentum=0.9)
model.compile(loss="sparse_categorical_crossentropy", optimizer=optimizer,
              metrics=["accuracy"])
history = model.fit(train_set, validation_data=valid_set, epochs=10)

这个模型应该在测试集上达到大约 92%的准确率,在几分钟的训练时间内(使用 GPU)。如果调整超参数,降低学习率,并进行更长时间的训练,应该能够达到 95%至 97%的准确率。有了这个,你可以开始在自己的图像和类别上训练出色的图像分类器!但计算机视觉不仅仅是分类。例如,如果你还想知道图片中花朵的位置在哪里?让我们现在来看看。

分类和定位

在图片中定位一个对象可以被表达为一个回归任务,如第十章中讨论的:预测一个对象周围的边界框,一个常见的方法是预测对象中心的水平和垂直坐标,以及它的高度和宽度。这意味着我们有四个数字要预测。对模型不需要太多改变;我们只需要添加一个具有四个单元的第二个密集输出层(通常在全局平均池化层之上),并且可以使用 MSE 损失进行训练:

base_model = tf.keras.applications.xception.Xception(weights="imagenet",
                                                     include_top=False)
avg = tf.keras.layers.GlobalAveragePooling2D()(base_model.output)
class_output = tf.keras.layers.Dense(n_classes, activation="softmax")(avg)
loc_output = tf.keras.layers.Dense(4)(avg)
model = tf.keras.Model(inputs=base_model.input,
                       outputs=[class_output, loc_output])
model.compile(loss=["sparse_categorical_crossentropy", "mse"],
              loss_weights=[0.8, 0.2],  # depends on what you care most about
              optimizer=optimizer, metrics=["accuracy"])

但是现在我们有一个问题:花卉数据集中没有围绕花朵的边界框。因此,我们需要自己添加。这通常是机器学习项目中最困难和最昂贵的部分之一:获取标签。花时间寻找合适的工具是个好主意。要用边界框注释图像,您可能想使用开源图像标注工具,如 VGG Image Annotator、LabelImg、OpenLabeler 或 ImgLab,或者商业工具如 LabelBox 或 Supervisely。您还可以考虑众包平台,如亚马逊机械土耳其,如果您有大量图像需要注释。然而,设置众包平台、准备发送给工人的表格、监督他们并确保他们产生的边界框的质量是好的,这是相当多的工作,所以确保这是值得的。Adriana Kovashka 等人撰写了一篇非常实用的论文关于计算机视觉中的众包。我建议您查看一下,即使您不打算使用众包。如果只有几百张甚至几千张图像需要标记,并且您不打算经常这样做,最好自己做:使用合适的工具,只需要几天时间,您还将更好地了解您的数据集和任务。

现在假设您已经为花卉数据集中的每个图像获得了边界框(暂时假设每个图像只有一个边界框)。然后,您需要创建一个数据集,其项目将是经过预处理的图像的批次以及它们的类标签和边界框。每个项目应该是一个形式为(images, (class_labels, bounding_boxes))的元组。然后您就可以开始训练您的模型!

提示

边界框应该被归一化,使得水平和垂直坐标以及高度和宽度的范围都在 0 到 1 之间。此外,通常预测高度和宽度的平方根,而不是直接预测高度和宽度:这样,对于大边界框的 10 像素误差不会受到与小边界框的 10 像素误差一样多的惩罚。

均方误差通常作为训练模型的成本函数效果相当不错,但不是评估模型如何预测边界框的好指标。这方面最常见的度量是交并比(IoU):预测边界框与目标边界框之间的重叠区域除以它们的并集的面积(参见图 14-24)。在 Keras 中,它由tf.keras.metrics.MeanIoU类实现。

对单个对象进行分类和定位是很好的,但是如果图像中包含多个对象(通常在花卉数据集中是这种情况),怎么办呢?

mls3 1424

图 14-24。边界框的 IoU 度量

目标检测

在图像中对多个对象进行分类和定位的任务称为目标检测。直到几年前,一种常见的方法是采用一个 CNN,该 CNN 经过训练,可以对图像中大致位于中心的单个对象进行分类和定位,然后在图像上滑动这个 CNN,并在每一步进行预测。通常,CNN 被训练来预测不仅类别概率和边界框,还有一个对象性分数:这是估计的概率,即图像确实包含一个位于中心附近的对象。这是一个二元分类输出;它可以通过具有单个单元的密集输出层产生,使用 sigmoid 激活函数并使用二元交叉熵损失进行训练。

注意

有时会添加一个“无对象”类,而不是对象性分数,但总的来说,这并不起作用得很好:最好分开回答“是否存在对象?”和“对象的类型是什么?”这两个问题。

这种滑动 CNN 方法在图 14-25 中有所说明。在这个例子中,图像被切成了一个 5×7 的网格,我们看到一个 CNN——厚厚的黑色矩形——在所有 3×3 区域上滑动,并在每一步进行预测。

mls3 1425

图 14-25。通过在图像上滑动 CNN 来检测多个对象

在这个图中,CNN 已经对这三个 3×3 区域进行了预测:

  • 当查看左上角的 3×3 区域(位于第二行第二列的红色阴影网格单元中心)时,它检测到了最左边的玫瑰。请注意,预测的边界框超出了这个 3×3 区域的边界。这完全没问题:即使 CNN 看不到玫瑰的底部部分,它仍能合理猜测它可能在哪里。它还预测了类别概率,给“玫瑰”类别一个很高的概率。最后,它预测了一个相当高的物体得分,因为边界框的中心位于中心网格单元内(在这个图中,物体得分由边界框的厚度表示)。

  • 当查看下一个 3×3 区域,向右移动一个网格单元(位于阴影蓝色正方形中心)时,它没有检测到任何位于该区域中心的花朵,因此预测的物体得分非常低;因此,可以安全地忽略预测的边界框和类别概率。您可以看到,预测的边界框也不好。

  • 最后,当查看下一个 3×3 区域,再向右移动一个网格单元(位于阴影绿色单元中心)时,它检测到了顶部的玫瑰,尽管不完美:这朵玫瑰没有很好地位于该区域中心,因此预测的物体得分并不是很高。

您可以想象,将 CNN 滑动到整个图像上会给您总共 15 个预测的边界框,以 3×5 的网格组织,每个边界框都伴随着其估计的类别概率和物体得分。由于对象的大小可能不同,您可能希望再次在更大的 4×4 区域上滑动 CNN,以获得更多的边界框。

这种技术相当简单,但正如您所看到的,它经常会在稍微不同的位置多次检测到相同的对象。需要一些后处理来摆脱所有不必要的边界框。一个常见的方法是称为非极大值抑制。下面是它的工作原理:

  1. 首先,摆脱所有物体得分低于某个阈值的边界框:因为 CNN 认为该位置没有对象,所以边界框是无用的。

  2. 找到具有最高物体得分的剩余边界框,并摆脱所有与其重叠很多的其他剩余边界框(例如,IoU 大于 60%)。例如,在图 14-25 中,具有最大物体得分的边界框是覆盖最左边的玫瑰的厚边界框。与这朵相同玫瑰接触的另一个边界框与最大边界框重叠很多,因此我们将摆脱它(尽管在这个例子中,它在上一步中已经被移除)。

  3. 重复步骤 2,直到没有更多需要摆脱的边界框。

这种简单的目标检测方法效果相当不错,但需要多次运行 CNN(在这个例子中为 15 次),因此速度相当慢。幸运的是,有一种更快的方法可以在图像上滑动 CNN:使用全卷积网络(FCN)。

全卷积网络

FCN 的概念最初是由 Jonathan Long 等人在2015 年的一篇论文中提出的,用于语义分割(根据对象所属的类别对图像中的每个像素进行分类的任务)。作者指出,可以用卷积层替换 CNN 顶部的密集层。为了理解这一点,让我们看一个例子:假设一个具有 200 个神经元的密集层位于一个输出 100 个大小为 7×7 的特征图的卷积层的顶部(这是特征图的大小,而不是卷积核的大小)。每个神经元将计算来自卷积层的所有 100×7×7 激活的加权和(加上一个偏置项)。现在让我们看看如果我们用 200 个大小为 7×7 的滤波器和"valid"填充的卷积层来替换密集层会发生什么。这一层将输出 200 个大小为 1×1 的特征图(因为卷积核恰好是输入特征图的大小,而且我们使用"valid"填充)。换句话说,它将输出 200 个数字,就像密集层一样;如果你仔细观察卷积层执行的计算,你会注意到这些数字将与密集层产生的数字完全相同。唯一的区别是密集层的输出是一个形状为[批量大小, 200]的张量,而卷积层将输出一个形状为[批量大小, 1, 1, 200]的张量。

提示

要将密集层转换为卷积层,卷积层中的滤波器数量必须等于密集层中的单元数量,滤波器大小必须等于输入特征图的大小,并且必须使用"valid"填充。步幅可以设置为 1 或更多,稍后您将看到。

为什么这很重要?嗯,密集层期望特定的输入大小(因为它对每个输入特征有一个权重),而卷积层将愉快地处理任何大小的图像(但是,它期望其输入具有特定数量的通道,因为每个卷积核包含每个输入通道的不同权重集)。由于 FCN 只包含卷积层(和具有相同属性的池化层),它可以在任何大小的图像上进行训练和执行!

例如,假设我们已经训练了一个用于花卉分类和定位的 CNN。它是在 224×224 的图像上训练的,并输出 10 个数字:

  • 输出 0 到 4 通过 softmax 激活函数发送,这给出了类别概率(每个类别一个)。

  • 输出 5 通过 sigmoid 激活函数发送,这给出了物体得分。

  • 输出 6 和 7 代表边界框的中心坐标;它们也经过 sigmoid 激活函数,以确保它们的范围在 0 到 1 之间。

  • 最后,输出 8 和 9 代表边界框的高度和宽度;它们不经过任何激活函数,以允许边界框延伸到图像的边界之外。

现在我们可以将 CNN 的密集层转换为卷积层。实际上,我们甚至不需要重新训练它;我们可以直接将密集层的权重复制到卷积层!或者,在训练之前,我们可以将 CNN 转换为 FCN。

现在假设在输出层之前的最后一个卷积层(也称为瓶颈层)在网络输入 224×224 图像时输出 7×7 特征图(参见图 14-26 的左侧)。如果我们将 FCN 输入 448×448 图像(参见图 14-26 的右侧),瓶颈层现在将输出 14×14 特征图。⁠³² 由于密集输出层被使用大小为 7×7 的 10 个滤波器的卷积层替换,使用"valid"填充和步幅 1,输出将由 10 个特征图组成,每个大小为 8×8(因为 14-7+1=8)。换句话说,FCN 将仅处理整个图像一次,并输出一个 8×8 的网格,其中每个单元包含 10 个数字(5 个类别概率,1 个物体性分数和 4 个边界框坐标)。这就像拿着原始 CNN 并在图像上每行移动 8 步,每列移动 8 步。为了可视化这一点,想象将原始图像切成一个 14×14 的网格,然后在这个网格上滑动一个 7×7 的窗口;窗口将有 8×8=64 个可能的位置,因此有 8×8 个预测。然而,FCN 方法更有效,因为网络只看一次图像。事实上,You Only Look Once(YOLO)是一个非常流行的目标检测架构的名称,我们将在接下来看一下。

mls3 1426

图 14-26。相同的全卷积网络处理小图像(左)和大图像(右)

只看一次

YOLO 是由 Joseph Redmon 等人在2015 年的一篇论文中提出的一种快速准确的目标检测架构。⁠³³ 它非常快速,可以在视频上实时运行,就像在 Redmon 的演示中看到的那样。YOLO 的架构与我们刚讨论的架构非常相似,但有一些重要的区别:

  • 对于每个网格单元,YOLO 只考虑边界框中心位于该单元内的对象。边界框坐标是相对于该单元的,其中(0, 0)表示单元的左上角,(1, 1)表示右下角。然而,边界框的高度和宽度可能远远超出单元。

  • 它为每个网格单元输出两个边界框(而不仅仅是一个),这使得模型能够处理两个对象非常接近,以至于它们的边界框中心位于同一个单元格内的情况。每个边界框还附带自己的物体性分数。

  • YOLO 还为每个网格单元输出一个类别概率分布,每个网格单元预测 20 个类别概率,因为 YOLO 是在包含 20 个类别的 PASCAL VOC 数据集上训练的。这产生了一个粗糙的类别概率图。请注意,模型为每个网格单元预测一个类别概率分布,而不是每个边界框。然而,可以在后处理期间估计每个边界框的类别概率,方法是测量每个边界框与类别概率图中的每个类别匹配的程度。例如,想象一张图片中有一个人站在一辆车前面。将会有两个边界框:一个大的水平边界框用于车,一个较小的垂直边界框用于人。这些边界框的中心可能位于同一个网格单元内。那么我们如何确定应该为每个边界框分配哪个类别呢?嗯,类别概率图将包含一个“车”类占主导地位的大区域,里面将有一个“人”类占主导地位的较小区域。希望车的边界框大致匹配“车”区域,而人的边界框大致匹配“人”区域:这将允许为每个边界框分配正确的类别。

YOLO 最初是使用 Darknet 开发的,Darknet 是由 Joseph Redmon 最初用 C 开发的开源深度学习框架,但很快就被移植到了 TensorFlow、Keras、PyTorch 等。多年来不断改进,包括 YOLOv2、YOLOv3 和 YOLO9000(再次由 Joseph Redmon 等人开发)、YOLOv4(由 Alexey Bochkovskiy 等人开发)、YOLOv5(由 Glenn Jocher 开发)和 PP-YOLO(由 Xiang Long 等人开发)。

每个版本都带来了一些令人印象深刻的速度和准确性改进,使用了各种技术;例如,YOLOv3 在一定程度上提高了准确性,部分原因在于锚先验,利用了某些边界框形状比其他形状更有可能的事实,这取决于类别(例如,人们倾向于具有垂直边界框,而汽车通常不会)。他们还增加了每个网格单元的边界框数量,他们在不同数据集上进行了训练,包含更多类别(YOLO9000 的情况下最多达到 9,000 个类别,按层次结构组织),他们添加了跳跃连接以恢复在 CNN 中丢失的一些空间分辨率(我们将很快讨论这一点,当我们看语义分割时),等等。这些模型也有许多变体,例如 YOLOv4-tiny,它经过优化,可以在性能较弱的机器上进行训练,并且可以运行得非常快(每秒超过 1,000 帧!),但平均精度均值(mAP)略低。

许多目标检测模型都可以在 TensorFlow Hub 上找到,通常具有预训练权重,例如 YOLOv5、SSDFaster R-CNNEfficientDet

SSD 和 EfficientDet 是“一次查看”检测模型,类似于 YOLO。EfficientDet 基于 EfficientNet 卷积架构。Faster R-CNN 更复杂:图像首先经过 CNN,然后输出传递给区域建议网络(RPN),该网络提出最有可能包含对象的边界框;然后为每个边界框运行分类器,基于 CNN 的裁剪输出。使用这些模型的最佳起点是 TensorFlow Hub 的出色目标检测教程

到目前为止,我们只考虑在单个图像中检测对象。但是视频呢?对象不仅必须在每一帧中被检测到,还必须随着时间进行跟踪。现在让我们快速看一下目标跟踪。

目标跟踪

目标跟踪是一项具有挑战性的任务:对象移动,它们可能随着接近或远离摄像机而变大或变小,它们的外观可能会随着转身或移动到不同的光照条件或背景而改变,它们可能会被其他对象暂时遮挡,等等。

最受欢迎的目标跟踪系统之一是DeepSORT。它基于经典算法和深度学习的组合:

  • 它使用Kalman 滤波器来估计给定先前检测的对象最可能的当前位置,并假设对象倾向于以恒定速度移动。

  • 它使用深度学习模型来衡量新检测和现有跟踪对象之间的相似度。

  • 最后,它使用匈牙利算法将新检测映射到现有跟踪对象(或新跟踪对象):该算法有效地找到最小化检测和跟踪对象预测位置之间距离的映射组合,同时最小化外观差异。

例如,想象一个红色球刚从相反方向移动的蓝色球上弹起。根据球的先前位置,卡尔曼滤波器将预测球会相互穿过:实际上,它假设对象以恒定速度移动,因此不会预期弹跳。如果匈牙利算法只考虑位置,那么它会愉快地将新的检测结果映射到错误的球上,就好像它们刚刚相互穿过并交换了颜色。但由于相似度度量,匈牙利算法会注意到问题。假设球不太相似,算法将新的检测结果映射到正确的球上。

提示

在 GitHub 上有一些 DeepSORT 的实现,包括 YOLOv4 + DeepSORT 的 TensorFlow 实现:https://github.com/theAIGuysCode/yolov4-deepsort

到目前为止,我们已经使用边界框定位了对象。这通常足够了,但有时您需要更精确地定位对象,例如在视频会议中去除人物背后的背景。让我们看看如何降到像素级别。

语义分割

语义分割中,每个像素根据其所属对象的类别进行分类(例如,道路、汽车、行人、建筑等),如图 14-27 所示。请注意,同一类别的不同对象被区分。例如,分割图像右侧的所有自行车最终会成为一个大块像素。这项任务的主要困难在于,当图像经过常规 CNN 时,由于步幅大于 1 的层,它们逐渐失去空间分辨率;因此,常规 CNN 可能只会知道图像左下角某处有一个人,但不会比这更精确。

mls3 1427

图 14-27. 语义分割

与目标检测一样,有许多不同的方法来解决这个问题,有些方法相当复杂。然而,在 Jonathan Long 等人于 2015 年提出的一篇关于完全卷积网络的论文中提出了一个相当简单的解决方案。作者首先采用了一个预训练的 CNN,并将其转换为 FCN。CNN 对输入图像应用了总步幅为 32(即,如果将所有大于 1 的步幅相加),这意味着最后一层输出的特征图比输入图像小 32 倍。这显然太粗糙了,因此他们添加了一个单一的上采样层,将分辨率乘以 32。

有几种可用的上采样解决方案(增加图像的大小),例如双线性插值,但这只能在×4 或×8 的范围内工作得相当好。相反,他们使用转置卷积层:⁠³⁹这相当于首先通过插入空行和列(全是零)来拉伸图像,然后执行常规卷积(参见图 14-28)。或者,有些人更喜欢将其视为使用分数步幅的常规卷积层(例如,图 14-28 中的步幅为 1/2)。转置卷积层可以初始化为执行接近线性插值的操作,但由于它是一个可训练的层,在训练期间会学习做得更好。在 Keras 中,您可以使用Conv2DTranspose层。

注意

在转置卷积层中,步幅定义了输入将被拉伸多少,而不是滤波器步长的大小,因此步幅越大,输出就越大(与卷积层或池化层不同)。

mls3 1428

图 14-28. 使用转置卷积层进行上采样

使用转置卷积层进行上采样是可以的,但仍然太不精确。为了做得更好,Long 等人从较低层添加了跳跃连接:例如,他们将输出图像上采样了 2 倍(而不是 32 倍),并添加了具有这种双倍分辨率的较低层的输出。然后,他们将结果上采样了 16 倍,导致总的上采样因子为 32(参见图 14-29)。这恢复了在较早的池化层中丢失的一些空间分辨率。在他们最好的架构中,他们使用了第二个类似的跳跃连接,以从更低的层中恢复更精细的细节。简而言之,原始 CNN 的输出经过以下额外步骤:上采样×2,添加较低层的输出(适当比例),上采样×2,添加更低层的输出,最后上采样×8。甚至可以将缩放超出原始图像的大小:这可以用于增加图像的分辨率,这是一种称为超分辨率的技术。

mls3 1429

图 14-29。跳跃层从较低层恢复一些空间分辨率

实例分割类似于语义分割,但不是将同一类别的所有对象合并成一个大块,而是将每个对象与其他对象区分开来(例如,它识别每辆自行车)。例如,由 Kaiming He 等人在2017 年的一篇论文中提出的Mask R-CNN架构,通过为每个边界框额外生成一个像素掩码来扩展 Faster R-CNN 模型。因此,您不仅可以获得围绕每个对象的边界框,以及一组估计的类别概率,还可以获得一个像素掩码,该掩码定位属于对象的边界框中的像素。该模型可在 TensorFlow Hub 上获得,预训练于 COCO 2017 数据集。尽管该领域发展迅速,但如果您想尝试最新和最优秀的模型,请查看https://paperswithcode.com的最新技术部分。

正如您所看到的,深度计算机视觉领域广阔且快速发展,每年都会涌现出各种架构。几乎所有这些架构都基于卷积神经网络,但自 2020 年以来,另一种神经网络架构已进入计算机视觉领域:Transformer(我们将在第十六章中讨论)。过去十年取得的进步令人瞩目,研究人员现在正专注于越来越困难的问题,例如对抗学习(试图使网络更具抗干扰性,以防止被设计用来欺骗它的图像)、可解释性(了解网络为何做出特定分类)、现实图像生成(我们将在第十七章中回顾)、单次学习(一个系统只需看到一次对象就能识别该对象)、预测视频中的下一帧、结合文本和图像任务等等。

现在进入下一章,我们将看看如何使用递归神经网络和卷积神经网络处理序列数据,例如时间序列。

练习

  1. 相比于完全连接的 DNN,CNN 在图像分类方面有哪些优势?

  2. 考虑一个由三个卷积层组成的 CNN,每个卷积层都有 3×3 的内核,步幅为 2,且具有"same"填充。最底层输出 100 个特征映射,中间层输出 200 个,顶层输出 400 个。输入图像是 200×300 像素的 RGB 图像:

    1. CNN 中的参数总数是多少?

    2. 如果我们使用 32 位浮点数,那么在对单个实例进行预测时,这个网络至少需要多少 RAM?

    3. 当在一个包含 50 张图像的小批量上进行训练时呢?

  3. 如果您的 GPU 在训练 CNN 时内存不足,您可以尝试哪五种方法来解决这个问题?

  4. 为什么要添加最大池化层而不是具有相同步幅的卷积层?

  5. 何时要添加局部响应归一化层?

  6. 您能否列出 AlexNet 相对于 LeNet-5 的主要创新?GoogLeNet、ResNet、SENet、Xception 和 EfficientNet 的主要创新又是什么?

  7. 什么是全卷积网络?如何将密集层转换为卷积层?

  8. 语义分割的主要技术难点是什么?

  9. 从头开始构建自己的 CNN,并尝试在 MNIST 上实现最高可能的准确性。

  10. 使用大型图像分类的迁移学习,经过以下步骤:

    1. 创建一个包含每类至少 100 张图像的训练集。例如,您可以根据位置(海滩、山脉、城市等)对自己的图片进行分类,或者您可以使用现有数据集(例如来自 TensorFlow 数据集)。

    2. 将其分为训练集、验证集和测试集。

    3. 构建输入管道,应用适当的预处理操作,并可选择添加数据增强。

    4. 在这个数据集上微调一个预训练模型。

  11. 按照 TensorFlow 的风格转移教程进行操作。这是使用深度学习生成艺术的有趣方式。

这些练习的解决方案可在本章笔记本的末尾找到,网址为https://homl.info/colab3

(1)David H. Hubel,“不受限制的猫条纹皮层单元活动”,《生理学杂志》147 卷(1959 年):226-238。

(2)David H. Hubel 和 Torsten N. Wiesel,“猫条纹皮层单个神经元的感受野”,《生理学杂志》148 卷(1959 年):574-591。

(3)David H. Hubel 和 Torsten N. Wiesel,“猴子条纹皮层的感受野和功能结构”,《生理学杂志》195 卷(1968 年):215-243。

(4)福岛邦彦,“Neocognitron:一种不受位置偏移影响的模式识别机制的自组织神经网络模型”,《生物控制论》36 卷(1980 年):193-202。

(5)Yann LeCun 等人,“基于梯度的学习应用于文档识别”,《IEEE 会议录》86 卷,第 11 期(1998 年):2278-2324。

(6)卷积是一种数学操作,它将一个函数滑动到另一个函数上,并测量它们逐点乘积的积分。它与傅里叶变换和拉普拉斯变换有深刻的联系,并且在信号处理中被广泛使用。卷积层实际上使用交叉相关,这与卷积非常相似(有关更多详细信息,请参见https://homl.info/76)。

(7)为了产生相同大小的输出,一个全连接层需要 200×150×100 个神经元,每个神经元连接到所有 150×100×3 个输入。它将有 200×150×100×(150×100×3+1)≈1350 亿个参数!

(8)在国际单位制(SI)中,1 MB = 1,000 KB = 1,000×1,000 字节 = 1,000×1,000×8 位。而 1 MiB = 1,024 kiB = 1,024×1,024 字节。所以 12 MB ≈ 11.44 MiB。

(9)我们迄今讨论过的其他内核具有权重,但池化内核没有:它们只是无状态的滑动窗口。

(10)Yann LeCun 等人,“基于梯度的学习应用于文档识别”,《IEEE 会议录》86 卷,第 11 期(1998 年):2278-2324。

(11)Alex Krizhevsky 等人,“使用深度卷积神经网络对 ImageNet 进行分类”,《第 25 届国际神经信息处理系统会议论文集》1 卷(2012 年):1097-1105。

¹² Matthew D. Zeiler 和 Rob Fergus,“可视化和理解卷积网络”,欧洲计算机视觉会议论文集(2014):818-833。

¹³ Christian Szegedy 等人,“使用卷积深入”,IEEE 计算机视觉和模式识别会议论文集(2015):1-9。

¹⁴ 在 2010 年的电影Inception中,角色们不断深入多层梦境;因此这些模块的名称。

¹⁵ Karen Simonyan 和 Andrew Zisserman,“用于大规模图像识别的非常深的卷积网络”,arXiv 预印本 arXiv:1409.1556(2014)。

¹⁶ Kaiming He 等人,“用于图像识别的深度残差学习”,arXiv 预印本 arXiv:1512:03385(2015)。

¹⁷ 描述神经网络时,通常只计算具有参数的层。

¹⁸ Christian Szegedy 等人,“Inception-v4,Inception-ResNet 和残差连接对学习的影响”,arXiv 预印本 arXiv:1602.07261(2016)。

¹⁹ François Chollet,“Xception:深度学习与深度可分离卷积”,arXiv 预印本 arXiv:1610.02357(2016)。

²⁰ 这个名称有时可能会有歧义,因为空间可分离卷积通常也被称为“可分离卷积”。

²¹ Jie Hu 等人,“挤压激励网络”,IEEE 计算机视觉和模式识别会议论文集(2018):7132-7141。

²² Saining Xie 等人,“聚合残差变换用于深度神经网络”,arXiv 预印本 arXiv:1611.05431(2016)。

²³ Gao Huang 等人,“密集连接卷积网络”,arXiv 预印本 arXiv:1608.06993(2016)。

²⁴ Andrew G. Howard 等人,“MobileNets:用于移动视觉应用的高效卷积神经网络”,arXiv 预印本 arxiv:1704.04861(2017)。

²⁵ Chien-Yao Wang 等人,“CSPNet:一种可以增强 CNN 学习能力的新骨干”,arXiv 预印本 arXiv:1911.11929(2019)。

²⁶ Mingxing Tan 和 Quoc V. Le,“EfficientNet:重新思考卷积神经网络的模型缩放”,arXiv 预印本 arXiv:1905.11946(2019)。

²⁷ 一款 92 核心的 AMD EPYC CPU,带有 IBPB,1.7 TB 的 RAM 和一款 Nvidia Tesla A100 GPU。

²⁸ 在 ImageNet 数据集中,每个图像都映射到WordNet 数据集中的一个单词:类别 ID 只是一个 WordNet ID。

²⁹ Adriana Kovashka 等人,“计算机视觉中的众包”,计算机图形学和视觉基础与趋势 10,第 3 期(2014):177-243。

³⁰ Jonathan Long 等人,“用于语义分割的全卷积网络”,IEEE 计算机视觉和模式识别会议论文集(2015):3431-3440。

³¹ 有一个小例外:使用"valid"填充的卷积层会在输入大小小于核大小时报错。

³² 这假设我们在网络中只使用了"same"填充:"valid"填充会减小特征图的大小。此外,448 可以被 2 整除多次,直到达到 7,没有任何舍入误差。如果任何一层使用不同于 1 或 2 的步幅,那么可能会有一些舍入误差,因此特征图最终可能会变小。

³³ Joseph Redmon 等人,“You Only Look Once: Unified, Real-Time Object Detection”,《IEEE 计算机视觉与模式识别会议论文集》(2016):779–788。

³⁴ 您可以在 TensorFlow Models 项目中找到 YOLOv3、YOLOv4 及其微小变体,网址为https://homl.info/yolotf

³⁵ Wei Liu 等人,“SSD: Single Shot Multibox Detector”,《第 14 届欧洲计算机视觉会议论文集》1(2016):21–37。

³⁶ Shaoqing Ren 等人,“Faster R-CNN: Towards Real-Time Object Detection with Region Proposal Networks”,《第 28 届国际神经信息处理系统会议论文集》1(2015):91–99。

³⁷ Mingxing Tan 等人,“EfficientDet: Scalable and Efficient Object Detection”,arXiv 预印本 arXiv:1911.09070(2019)。

³⁸ Nicolai Wojke 等人,“Simple Online and Realtime Tracking with a Deep Association Metric”,arXiv 预印本 arXiv:1703.07402(2017)。

³⁹ 这种类型的层有时被称为反卷积层,但它执行数学家所说的反卷积,因此应避免使用这个名称。

⁴⁰ Kaiming He 等人,“Mask R-CNN”,arXiv 预印本 arXiv:1703.06870(2017)。

第十五章:使用 RNNs 和 CNNs 处理序列

预测未来是你经常做的事情,无论是在结束朋友的句子还是预期早餐时咖啡的味道。在本章中,我们将讨论循环神经网络(RNNs)-一类可以预测未来的网络(嗯,至少在一定程度上)。RNNs 可以分析时间序列数据,例如您网站上每日活跃用户的数量,您所在城市的每小时温度,您家每日的用电量,附近汽车的轨迹等等。一旦 RNN 学习了数据中的过去模式,它就能利用自己的知识来预测未来,当然前提是过去的模式在未来仍然成立。

更一般地说,RNNs 可以处理任意长度的序列,而不是固定大小的输入。例如,它们可以将句子、文档或音频样本作为输入,使它们非常适用于自然语言处理应用,如自动翻译或语音转文本。

在本章中,我们将首先介绍 RNNs 的基本概念以及如何使用时间反向传播来训练它们。然后,我们将使用它们来预测时间序列。在此过程中,我们将研究常用的 ARMA 模型系列,通常用于预测时间序列,并将它们用作与我们的 RNNs 进行比较的基准。之后,我们将探讨 RNNs 面临的两个主要困难:

  • 不稳定的梯度(在第十一章中讨论),可以通过各种技术来缓解,包括循环丢失循环层归一化

  • (非常)有限的短期记忆,可以使用 LSTM 和 GRU 单元进行扩展。

RNNs 并不是处理序列数据的唯一类型的神经网络。对于小序列,常规的密集网络可以胜任,而对于非常长的序列,例如音频样本或文本,卷积神经网络也可以表现得相当不错。我们将讨论这两种可能性,并通过实现 WaveNet 来结束本章-一种能够处理数万个时间步的 CNN 架构。让我们开始吧!

循环神经元和层

到目前为止,我们已经专注于前馈神经网络,其中激活仅在一个方向中流动,从输入层到输出层。循环神经网络看起来非常像前馈神经网络,只是它还有指向后方的连接。

让我们看看最简单的 RNN,由一个神经元组成,接收输入,产生输出,并将该输出发送回自身,如图 15-1(左)所示。在每个时间步 t(也称为),这个循环神经元接收输入x[(t)]以及来自上一个时间步的自己的输出ŷ[(t–1)]。由于在第一个时间步没有先前的输出,通常将其设置为 0。我们可以沿着时间轴表示这个小网络,如图 15-1(右)所示。这被称为将网络展开到时间轴(每个时间步表示一个循环神经元)。

mls3 1501

图 15-1. 一个循环神经元(左)在时间轴上展开(右)

您可以轻松创建一个循环神经元层。在每个时间步t,每个神经元都接收来自输入向量x[(t)]和上一个时间步的输出向量ŷ[(t–1)],如图 15-2 所示。请注意,现在输入和输出都是向量(当只有一个神经元时,输出是标量)。

mls3 1502

图 15-2. 一个循环神经元层(左)在时间轴上展开(右)

每个递归神经元有两组权重:一组用于输入x[(t)],另一组用于上一个时间步的输出ŷ[(t–1)]。让我们称这些权重向量为w[x]和w[ŷ]。如果我们考虑整个递归层而不仅仅是一个递归神经元,我们可以将所有权重向量放入两个权重矩阵:W[x]和W[ŷ]。

整个递归层的输出向量可以按照你所期望的方式计算,如方程 15-1 所示,其中b是偏置向量,ϕ(·)是激活函数(例如,ReLU⁠¹)。

方程 15-1. 单个实例的递归层输出

ŷ(t)=ϕWxx(t)+Wŷŷ(t-1)+b

就像前馈神经网络一样,我们可以通过将时间步t的所有输入放入输入矩阵X[(t)](参见方程 15-2)来一次性计算整个小批量的递归层输出。

方程 15-2. 一次传递中递归神经元层的所有实例的输出:[小批量

Ŷ (t) = ϕ X (t) W x + Ŷ (t-1) W ŷ + b = ϕ X (t) Ŷ (t-1) W + b with W = W x W ŷ

在这个方程中:

  • Ŷ[(t)]是一个m×n[neurons]矩阵,包含小批量中每个实例在时间步t的层输出(m是小批量中实例的数量,n[neurons]是神经元的数量)。

  • X[(t)]是一个m×n[inputs]矩阵,包含所有实例的输入(n[inputs]是输入特征的数量)。

  • W[x]是一个n[inputs]×n[neurons]矩阵,包含当前时间步输入的连接权重。

  • W[ŷ]是一个n[neurons]×n[neurons]矩阵,包含上一个时间步输出的连接权重。

  • b是一个大小为n[neurons]的向量,包含每个神经元的偏置项。

  • 权重矩阵W[x]和W[ŷ]通常垂直连接成一个形状为(n[inputs] + n[neurons]) × n[neurons]的单个权重矩阵W(参见方程 15-2 的第二行)。

  • 符号[X[(t)] Ŷ[(t–1)]]表示矩阵X[(t)]和Ŷ[(t–1)]的水平连接。

注意,Ŷ[(t)]是X[(t)]和Ŷ[(t–1)]的函数,X[(t–1)]和Ŷ[(t–2)]的函数,X[(t–2)]和Ŷ[(t–3)]的函数,依此类推。这使得Ŷ[(t)]是自时间t=0(即X[(0)], X[(1)], …​, X[(t)])以来所有输入的函数。在第一个时间步骤,t=0 时,没有先前的输出,因此通常假定它们都是零。

记忆单元

由于递归神经元在时间步骤t的输出是前几个时间步骤的所有输入的函数,因此可以说它具有一种记忆形式。在时间步骤之间保留一些状态的神经网络的一部分称为记忆单元(或简称单元)。单个递归神经元或一层递归神经元是一个非常基本的单元,只能学习短模式(通常约为 10 个步骤长,但这取决于任务)。在本章后面,我们将看一些更复杂和强大的单元类型,能够学习更长的模式(大约长 10 倍,但这也取决于任务)。

时间步骤t时的单元状态,表示为h[(t)](“h”代表“隐藏”),是该时间步骤的一些输入和上一个时间步骤的状态的函数:h[(t)] = f(x[(t)], h[(t–1)])。在时间步骤t的输出,表示为ŷ[(t)],也是前一个状态和当前输入的函数。在我们迄今讨论的基本单元的情况下,输出只等于状态,但在更复杂的单元中,情况并非总是如此,如图 15-3 所示。

mls3 1503

图 15-3。单元的隐藏状态和输出可能不同

输入和输出序列

RNN 可以同时接受一系列输入并产生一系列输出(参见图 15-4 左上方的网络)。这种序列到序列网络对于预测时间序列非常有用,例如您家每天的用电量:您向其提供过去N天的数据,并训练它输出将来一天的用电量(即从N – 1 天前到明天)。

或者,您可以向网络提供一系列输入并忽略除最后一个之外的所有输出(参见图 15-4 右上方的网络)。这是一个序列到向量网络。例如,您可以向网络提供与电影评论相对应的一系列单词,网络将输出情感分数(例如,从 0 [讨厌]到 1 [喜爱])。

相反,您可以在每个时间步骤反复向网络提供相同的输入向量,并让它输出一个序列(参见图 15-4 左下方的网络)。这是一个向量到序列网络。例如,输入可以是一幅图像(或 CNN 的输出),输出可以是该图像的标题。

最后,您可以有一个序列到向量网络,称为编码器,后面是一个向量到序列网络,称为解码器(参见图 15-4 的右下方网络)。例如,这可以用于将一种语言的句子翻译成另一种语言。您将向网络提供一种语言的句子,编码器将把这个句子转换成一个单一的向量表示,然后解码器将把这个向量解码成另一种语言的句子。这种两步模型,称为编码器-解码器⁠²比尝试使用单个序列到序列的 RNN 实时翻译要好得多(就像左上角表示的那种):一个句子的最后几个词可能会影响翻译的前几个词,因此您需要等到看完整个句子后再进行翻译。我们将在第十六章中介绍编码器-解码器的实现(正如您将看到的,它比图 15-4 所暗示的要复杂一些)。

mls3 1504

图 15-4. 序列到序列(左上)、序列到向量(右上)、向量到序列(左下)和编码器-解码器(右下)网络

这种多功能性听起来很有前途,但如何训练循环神经网络呢?

训练 RNNs

要训练 RNN,关键是将其通过时间展开(就像我们刚刚做的那样),然后使用常规的反向传播(参见图 15-5)。这种策略称为通过时间的反向传播(BPTT)。

就像常规反向传播一样,首先通过展开的网络进行第一次前向传递(由虚线箭头表示)。然后使用损失函数ℒ(Y[(0)], Y[(1)], …​, Y[(T)]; Ŷ[(0)], Ŷ[(1)], …​, Ŷ[(T)])评估输出序列(其中Y[(i)]是第i个目标,Ŷ[(i)]是第i个预测,T是最大时间步长)。请注意,此损失函数可能会忽略一些输出。例如,在序列到向量的 RNN 中,除了最后一个输出之外,所有输出都会被忽略。在图 15-5 中,损失函数仅基于最后三个输出计算。然后,该损失函数的梯度通过展开的网络向后传播(由实线箭头表示)。在这个例子中,由于输出Ŷ[(0)]和Ŷ[(1)]没有用于计算损失,梯度不会通过它们向后传播;它们只会通过Ŷ[(2)]、Ŷ[(3)]和Ŷ[(4)]向后传播。此外,由于在每个时间步骤中使用相同的参数Wb,它们的梯度将在反向传播过程中被多次调整。一旦反向阶段完成并计算出所有梯度,BPTT 可以执行梯度下降步骤来更新参数(这与常规反向传播没有区别)。

mls3 1505

图 15-5. 通过时间反向传播

幸运的是,Keras 会为您处理所有这些复杂性,您将看到。但在我们到达那里之前,让我们加载一个时间序列,并开始使用传统工具进行分析,以更好地了解我们正在处理的内容,并获得一些基准指标。

预测时间序列

好了!假设您刚被芝加哥交通管理局聘为数据科学家。您的第一个任务是构建一个能够预测明天公交和轨道乘客数量的模型。您可以访问自 2001 年以来的日常乘客数据。让我们一起看看您将如何处理这个问题。我们将从加载和清理数据开始:

import pandas as pd
from pathlib import Path

path = Path("datasets/ridership/CTA_-_Ridership_-_Daily_Boarding_Totals.csv")
df = pd.read_csv(path, parse_dates=["service_date"])
df.columns = ["date", "day_type", "bus", "rail", "total"]  # shorter names
df = df.sort_values("date").set_index("date")
df = df.drop("total", axis=1)  # no need for total, it's just bus + rail
df = df.drop_duplicates()  # remove duplicated months (2011-10 and 2014-07)

我们加载 CSV 文件,设置短列名,按日期对行进行排序,删除多余的total列,并删除重复行。现在让我们看看前几行是什么样子的:

>>> df.head()
 day_type     bus    rail
date
2001-01-01        U  297192  126455
2001-01-02        W  780827  501952
2001-01-03        W  824923  536432
2001-01-04        W  870021  550011
2001-01-05        W  890426  557917

在 2001 年 1 月 1 日,芝加哥有 297,192 人乘坐公交车,126,455 人乘坐火车。day_type列包含W表示工作日A表示周六U表示周日或假期。

现在让我们绘制 2019 年几个月的公交和火车乘客量数据,看看它是什么样子的(参见图 15-6):

import matplotlib.pyplot as plt

df["2019-03":"2019-05"].plot(grid=True, marker=".", figsize=(8, 3.5))
plt.show()

mls3 1506

图 15-6。芝加哥的日常乘客量

请注意,Pandas 在范围中包括起始月份和结束月份,因此这将绘制从 3 月 1 日到 5 月 31 日的数据。这是一个时间序列:在不同时间步长上具有值的数据,通常在规则间隔上。更具体地说,由于每个时间步长有多个值,因此称为多变量时间序列。如果我们只看bus列,那将是一个单变量时间序列,每个时间步长有一个值。在处理时间序列时,预测未来值(即预测)是最典型的任务,这也是我们将在本章中重点关注的内容。其他任务包括插补(填补缺失的过去值)、分类、异常检测等。

查看图 15-6,我们可以看到每周明显重复的类似模式。这被称为每周季节性。实际上,在这种情况下,季节性非常强,通过简单地复制一周前的值来预测明天的乘客量将产生相当不错的结果。这被称为天真预测:简单地复制过去的值来进行预测。天真预测通常是一个很好的基准,有时甚至在某些情况下很难超越。

注意

一般来说,天真预测意味着复制最新已知值(例如,预测明天与今天相同)。然而,在我们的情况下,复制上周的值效果更好,因为存在强烈的每周季节性。

为了可视化这些天真预测,让我们将两个时间序列(公交和火车)以及相同时间序列向右移动一周(即向右移动)的时间序列叠加使用虚线。我们还将绘制两者之间的差异(即时间t处的值减去时间t - 7 处的值);这称为差分(参见图 15-7):

diff_7 = df[["bus", "rail"]].diff(7)["2019-03":"2019-05"]

fig, axs = plt.subplots(2, 1, sharex=True, figsize=(8, 5))
df.plot(ax=axs[0], legend=False, marker=".")  # original time series
df.shift(7).plot(ax=axs[0], grid=True, legend=False, linestyle=":")  # lagged
diff_7.plot(ax=axs[1], grid=True, marker=".")  # 7-day difference time series
plt.show()

不错!注意滞后时间序列如何紧密跟踪实际时间序列。当一个时间序列与其滞后版本相关联时,我们说该时间序列是自相关的。正如您所看到的,大多数差异都相当小,除了五月底。也许那时有一个假期?让我们检查day_type列:

>>> list(df.loc["2019-05-25":"2019-05-27"]["day_type"])
['A', 'U', 'U']

mls3 1507

图 15-7。与 7 天滞后时间序列叠加的时间序列(顶部),以及tt - 7 之间的差异(底部)

事实上,那时有一个长周末:周一是阵亡将士纪念日假期。我们可以使用这一列来改进我们的预测,但现在让我们只测量我们任意关注的三个月期间(2019 年 3 月、4 月和 5 月)的平均绝对误差,以获得一个大致的概念:

>>> diff_7.abs().mean()
bus     43915.608696
rail    42143.271739
dtype: float64

我们的天真预测得到大约 43,916 名公交乘客和约 42,143 名火车乘客的 MAE。一眼看去很难判断这是好是坏,所以让我们将预测误差放入透视中,通过将它们除以目标值来进行评估:

>>> targets = df[["bus", "rail"]]["2019-03":"2019-05"]
>>> (diff_7 / targets).abs().mean()
bus     0.082938
rail    0.089948
dtype: float64

我们刚刚计算的是平均绝对百分比误差(MAPE):看起来我们的天真预测为公交大约为 8.3%,火车为 9.0%。有趣的是,火车预测的 MAE 看起来比公交预测的稍好一些,而 MAPE 则相反。这是因为公交乘客量比火车乘客量大,因此自然预测误差也更大,但当我们将误差放入透视时,结果表明公交预测实际上略优于火车预测。

提示

MAE、MAPE 和 MSE 是评估预测的最常见指标之一。与往常一样,选择正确的指标取决于任务。例如,如果您的项目对大误差的影响是小误差的平方倍,那么 MSE 可能更可取,因为它会严厉惩罚大误差。

观察时间序列,似乎没有明显的月度季节性,但让我们检查一下是否存在年度季节性。我们将查看 2001 年至 2019 年的数据。为了减少数据窥探的风险,我们暂时忽略更近期的数据。让我们为每个系列绘制一个 12 个月的滚动平均线,以可视化长期趋势(参见图 15-8):

period = slice("2001", "2019")
df_monthly = df.resample('M').mean()  # compute the mean for each month
rolling_average_12_months = df_monthly[period].rolling(window=12).mean()

fig, ax = plt.subplots(figsize=(8, 4))
df_monthly[period].plot(ax=ax, marker=".")
rolling_average_12_months.plot(ax=ax, grid=True, legend=False)
plt.show()

mls3 1508

图 15-8。年度季节性和长期趋势

是的!确实存在一些年度季节性,尽管比每周季节性更嘈杂,对于铁路系列而言更为明显,而不是公交系列:我们看到每年大致相同日期出现高峰和低谷。让我们看看如果绘制 12 个月的差分会得到什么(参见图 15-9):

df_monthly.diff(12)[period].plot(grid=True, marker=".", figsize=(8, 3))
plt.show()

mls3 1509

图 15-9。12 个月的差分

注意,差分不仅消除了年度季节性,还消除了长期趋势。例如,2016 年至 2019 年时间序列中存在的线性下降趋势在差分时间序列中变为大致恒定的负值。事实上,差分是一种常用的技术,用于消除时间序列中的趋势和季节性:研究平稳时间序列更容易,这意味着其统计特性随时间保持不变,没有任何季节性或趋势。一旦您能够对差分时间序列进行准确的预测,只需将先前减去的过去值添加回来,就可以将其转换为实际时间序列的预测。

您可能会认为我们只是试图预测明天的乘客量,因此长期模式比短期模式更不重要。您是对的,但是,通过考虑长期模式,我们可能能够稍微提高性能。例如,2017 年 10 月,每日公交乘客量减少了约 2500 人,这代表每周减少约 570 名乘客,因此如果我们处于 2017 年 10 月底,通过从上周复制数值,减去 570,来预测明天的乘客量是有道理的。考虑趋势将使您的平均预测略微更准确。

现在您熟悉了乘客量时间序列,以及时间序列分析中一些最重要的概念,包括季节性、趋势、差分和移动平均,让我们快速看一下一个非常流行的统计模型家族,通常用于分析时间序列。

ARMA 模型家族

我们将从上世纪 30 年代由赫尔曼·沃尔德(Herman Wold)开发的自回归移动平均(ARMA)模型开始:它通过对滞后值的简单加权和添加移动平均来计算其预测,非常类似我们刚刚讨论的。具体来说,移动平均分量是通过最近几个预测误差的加权和来计算的。方程 15-3 展示了该模型如何进行预测。

第 15-3 方程。使用 ARMA 模型进行预测

y^(t)=i=1pαiy(t-i)+i=1qθiϵ(t-i)with ϵ(t)=y(t)-y^(t)

在这个方程中:

  • ŷ[(t)]是模型对时间步t的预测。

  • y[(t)]是时间步t的时间序列值。

  • 第一个总和是时间序列过去p个值的加权和,使用学习到的权重α[i]。数字p是一个超参数,它决定模型应该查看过去多远。这个总和是模型的自回归组件:它基于过去的值执行回归。

  • 第二个总和是过去q个预测误差ε[(t)]的加权和,使用学习到的权重θ[i]。数字q是一个超参数。这个总和是模型的移动平均组件。

重要的是,这个模型假设时间序列是平稳的。如果不是,那么差分可能有所帮助。在一个时间步上使用差分将产生时间序列的导数的近似值:实际上,它将给出每个时间步的系列斜率。这意味着它将消除任何线性趋势,将其转换为一个常数值。例如,如果你对系列[3, 5, 7, 9, 11]应用一步差分,你会得到差分系列[2, 2, 2, 2]。

如果原始时间序列具有二次趋势而不是线性趋势,那么一轮差分将不足够。例如,系列[1, 4, 9, 16, 25, 36]经过一轮差分后变为[3, 5, 7, 9, 11],但如果你再进行第二轮差分,你会得到[2, 2, 2, 2]。因此,进行两轮差分将消除二次趋势。更一般地,连续运行d轮差分计算时间序列的d阶导数的近似值,因此它将消除多项式趋势直到d阶。这个超参数d被称为积分阶数

差分是 1970 年由乔治·博克斯和格威林·詹金斯在他们的书《时间序列分析》(Wiley)中介绍的自回归积分移动平均(ARIMA)模型的核心贡献:这个模型运行d轮差分使时间序列更平稳,然后应用常规 ARMA 模型。在进行预测时,它使用这个 ARMA 模型,然后将差分减去的项加回来。

ARMA 家族的最后一个成员是季节性 ARIMA(SARIMA)模型:它以与 ARIMA 相同的方式对时间序列建模,但另外还为给定频率(例如每周)建模一个季节性组件,使用完全相同的 ARIMA 方法。它总共有七个超参数:与 ARIMA 相同的pdq超参数,再加上额外的PDQ超参数来建模季节性模式,最后是季节性模式的周期,标记为s。超参数PDQ就像pdq一样,但它们用于模拟时间序列在t – st – 2st – 3s等时刻。

让我们看看如何将 SARIMA 模型拟合到铁路时间序列,并用它来预测明天的乘客量。我们假设今天是 2019 年 5 月的最后一天,我们想要预测“明天”,也就是 2019 年 6 月 1 日的铁路乘客量。为此,我们可以使用statsmodels库,其中包含许多不同的统计模型,包括由ARIMA类实现的 ARMA 模型及其变体:

from statsmodels.tsa.arima.model import ARIMA

origin, today = "2019-01-01", "2019-05-31"
rail_series = df.loc[origin:today]["rail"].asfreq("D")
model = ARIMA(rail_series,
              order=(1, 0, 0),
              seasonal_order=(0, 1, 1, 7))
model = model.fit()
y_pred = model.forecast()  # returns 427,758.6

在这个代码示例中:

  • 我们首先导入ARIMA类,然后我们从 2019 年初开始到“今天”获取铁路乘客数据,并使用asfreq("D")将时间序列的频率设置为每天:在这种情况下,这不会改变数据,因为它已经是每天的,但如果没有这个,ARIMA类将不得不猜测频率,并显示警告。

  • 接下来,我们创建一个ARIMA实例,将所有数据传递到“今天”,并设置模型超参数:order=(1, 0, 0)表示p=1,d=0,q=0,seasonal_order=(0, 1, 1, 7)表示P=0,D=1,Q=1,s=7。请注意,statsmodels API 与 Scikit-Learn 的 API 有些不同,因为我们在构建时将数据传递给模型,而不是将数据传递给fit()方法。

  • 接下来,我们拟合模型,并用它为“明天”,也就是 2019 年 6 月 1 日,做出预测。

预测为 427,759 名乘客,而实际上有 379,044 名。哎呀,我们偏差 12.9%——这相当糟糕。实际上,这比天真预测稍微糟糕,天真预测为 426,932,偏差为 12.6%。但也许那天我们只是运气不好?为了检查这一点,我们可以在循环中运行相同的代码,为三月、四月和五月的每一天进行预测,并计算该期间的平均绝对误差:

origin, start_date, end_date = "2019-01-01", "2019-03-01", "2019-05-31"
time_period = pd.date_range(start_date, end_date)
rail_series = df.loc[origin:end_date]["rail"].asfreq("D")
y_preds = []
for today in time_period.shift(-1):
    model = ARIMA(rail_series[origin:today],  # train on data up to "today"
                  order=(1, 0, 0),
                  seasonal_order=(0, 1, 1, 7))
    model = model.fit()  # note that we retrain the model every day!
    y_pred = model.forecast()[0]
    y_preds.append(y_pred)

y_preds = pd.Series(y_preds, index=time_period)
mae = (y_preds - rail_series[time_period]).abs().mean()  # returns 32,040.7

啊,好多了!平均绝对误差约为 32,041,比我们用天真预测得到的平均绝对误差(42,143)显著低。因此,虽然模型并不完美,但平均而言仍然远远超过天真预测。

此时,您可能想知道如何为 SARIMA 模型选择良好的超参数。有几种方法,但最简单的方法是粗暴的方法:进行网格搜索。对于要评估的每个模型(即每个超参数组合),您可以运行前面的代码示例,仅更改超参数值。通常pqPQ值较小(通常为 0 到 2,有时可达 5 或 6),dD通常为 0 或 1,有时为 2。至于s,它只是主要季节模式的周期:在我们的情况下是 7,因为有强烈的每周季节性。具有最低平均绝对误差的模型获胜。当然,如果它更符合您的业务目标,您可以用另一个指标替换平均绝对误差。就是这样!

为机器学习模型准备数据

现在我们有了两个基线,天真预测和 SARIMA,让我们尝试使用迄今为止涵盖的机器学习模型来预测这个时间序列,首先从基本的线性模型开始。我们的目标是根据过去 8 周(56 天)的数据来预测明天的乘客量。因此,我们模型的输入将是序列(通常是生产中的每天一个序列),每个序列包含从时间步t - 55 到t的 56 个值。对于每个输入序列,模型将输出一个值:时间步t + 1 的预测。

但我们将使用什么作为训练数据呢?嗯,这就是诀窍:我们将使用过去的每个 56 天窗口作为训练数据,每个窗口的目标将是紧随其后的值。

Keras 实际上有一个很好的实用函数称为tf.keras.utils.timeseries_dataset_from_array(),帮助我们准备训练集。它以时间序列作为输入,并构建一个 tf.data.Dataset(在第十三章中介绍)包含所需长度的所有窗口,以及它们对应的目标。以下是一个示例,它以包含数字 0 到 5 的时间序列为输入,并创建一个包含所有长度为 3 的窗口及其对应目标的数据集,分组成大小为 2 的批次:

import tensorflow as tf

my_series = [0, 1, 2, 3, 4, 5]
my_dataset = tf.keras.utils.timeseries_dataset_from_array(
    my_series,
    targets=my_series[3:],  # the targets are 3 steps into the future
    sequence_length=3,
    batch_size=2
)

让我们检查一下这个数据集的内容:

>>> list(my_dataset)
[(<tf.Tensor: shape=(2, 3), dtype=int32, numpy=
 array([[0, 1, 2],
 [1, 2, 3]], dtype=int32)>,
 <tf.Tensor: shape=(2,), dtype=int32, numpy=array([3, 4], dtype=int32)>),
 (<tf.Tensor: shape=(1, 3), dtype=int32, numpy=array([[2, 3, 4]], dtype=int32)>,
 <tf.Tensor: shape=(1,), dtype=int32, numpy=array([5], dtype=int32)>)]

数据集中的每个样本是长度为 3 的窗口,以及其对应的目标(即窗口后面的值)。窗口是[0, 1, 2],[1, 2, 3]和[2, 3, 4],它们各自的目标是 3,4 和 5。由于总共有三个窗口,不是批次大小的倍数,最后一个批次只包含一个窗口而不是两个。

另一种获得相同结果的方法是使用 tf.data 的Dataset类的window()方法。这更复杂,但它给了您完全的控制,这将在本章后面派上用场,让我们看看它是如何工作的。window()方法返回一个窗口数据集的数据集:

>>> for window_dataset in tf.data.Dataset.range(6).window(4, shift=1):
...     for element in window_dataset:
...         print(f"{element}", end=" ")
...     print()
...
0 1 2 3
1 2 3 4
2 3 4 5
3 4 5
4 5
5

在这个例子中,数据集包含六个窗口,每个窗口相对于前一个窗口向前移动一个步骤,最后三个窗口较小,因为它们已经到达系列的末尾。通常情况下,您会希望通过向window()方法传递drop_remainder=True来摆脱这些较小的窗口。

window()方法返回一个嵌套数据集,类似于一个列表的列表。当您想要通过调用其数据集方法(例如,对它们进行洗牌或分批处理)来转换每个窗口时,这将非常有用。然而,我们不能直接使用嵌套数据集进行训练,因为我们的模型将期望张量作为输入,而不是数据集。

因此,我们必须调用flat_map()方法:它将嵌套数据集转换为平坦数据集(包含张量而不是数据集)。例如,假设{1, 2, 3}表示包含张量 1、2 和 3 序列的数据集。如果展平嵌套数据集{{1, 2}, {3, 4, 5, 6}},您将得到平坦数据集{1, 2, 3, 4, 5, 6}。

此外,flat_map()方法接受一个函数作为参数,允许您在展平之前转换嵌套数据集中的每个数据集。例如,如果您将函数lambda ds: ds.batch(2)传递给flat_map(),那么它将把嵌套数据集{{1, 2}, {3, 4, 5, 6}}转换为平坦数据集{[1, 2], [3, 4], [5, 6]}:这是一个包含 3 个大小为 2 的张量的数据集。

考虑到这一点,我们准备对数据集进行展平处理:

>>> dataset = tf.data.Dataset.range(6).window(4, shift=1, drop_remainder=True)
>>> dataset = dataset.flat_map(lambda window_dataset: window_dataset.batch(4))
>>> for window_tensor in dataset:
...     print(f"{window_tensor}")
...
[0 1 2 3]
[1 2 3 4]
[2 3 4 5]

由于每个窗口数据集恰好包含四个项目,对窗口调用batch(4)会产生一个大小为 4 的单个张量。太棒了!现在我们有一个包含连续窗口的数据集,表示为张量。让我们创建一个小助手函数,以便更容易地从数据集中提取窗口:

def to_windows(dataset, length):
    dataset = dataset.window(length, shift=1, drop_remainder=True)
    return dataset.flat_map(lambda window_ds: window_ds.batch(length))

最后一步是使用map()方法将每个窗口拆分为输入和目标。我们还可以将生成的窗口分组成大小为 2 的批次:

>>> dataset = to_windows(tf.data.Dataset.range(6), 4)  # 3 inputs + 1 target = 4
>>> dataset = dataset.map(lambda window: (window[:-1], window[-1]))
>>> list(dataset.batch(2))
[(<tf.Tensor: shape=(2, 3), dtype=int64, numpy=
 array([[0, 1, 2],
 [1, 2, 3]])>,
 <tf.Tensor: shape=(2,), dtype=int64, numpy=array([3, 4])>),
 (<tf.Tensor: shape=(1, 3), dtype=int64, numpy=array([[2, 3, 4]])>,
 <tf.Tensor: shape=(1,), dtype=int64, numpy=array([5])>)]

正如您所看到的,我们现在得到了与之前使用timeseries_dataset_from_array()函数相同的输出(稍微费劲一些,但很快就会值得)。

现在,在开始训练之前,我们需要将数据分为训练期、验证期和测试期。我们现在将专注于铁路乘客量。我们还将通过一百万分之一的比例缩小它,以确保值接近 0-1 范围;这与默认的权重初始化和学习率很好地配合:

rail_train = df["rail"]["2016-01":"2018-12"] / 1e6
rail_valid = df["rail"]["2019-01":"2019-05"] / 1e6
rail_test = df["rail"]["2019-06":] / 1e6
注意

处理时间序列时,通常希望按时间划分。但在某些情况下,您可能能够沿其他维度划分,这将使您有更长的时间段进行训练。例如,如果您有关于 2001 年至 2019 年间 10,000 家公司财务状况的数据,您可能能够将这些数据分割到不同的公司。然而,很可能这些公司中的许多将强相关(例如,整个经济部门可能一起上涨或下跌),如果在训练集和测试集中有相关的公司,那么您的测试集将不会那么有用,因为其泛化误差的度量将是乐观偏倚的。

接下来,让我们使用timeseries_dataset_from_array()为训练和验证创建数据集。由于梯度下降期望训练集中的实例是独立同分布的(IID),正如我们在第四章中看到的那样,我们必须设置参数shuffle=True来对训练窗口进行洗牌(但不洗牌其中的内容):

seq_length = 56
train_ds = tf.keras.utils.timeseries_dataset_from_array(
    rail_train.to_numpy(),
    targets=rail_train[seq_length:],
    sequence_length=seq_length,
    batch_size=32,
    shuffle=True,
    seed=42
)
valid_ds = tf.keras.utils.timeseries_dataset_from_array(
    rail_valid.to_numpy(),
    targets=rail_valid[seq_length:],
    sequence_length=seq_length,
    batch_size=32
)

现在我们已经准备好构建和训练任何回归模型了!

使用线性模型进行预测

让我们首先尝试一个基本的线性模型。我们将使用 Huber 损失,通常比直接最小化 MAE 效果更好,如第十章中讨论的那样。我们还将使用提前停止:

tf.random.set_seed(42)
model = tf.keras.Sequential([
    tf.keras.layers.Dense(1, input_shape=[seq_length])
])
early_stopping_cb = tf.keras.callbacks.EarlyStopping(
    monitor="val_mae", patience=50, restore_best_weights=True)
opt = tf.keras.optimizers.SGD(learning_rate=0.02, momentum=0.9)
model.compile(loss=tf.keras.losses.Huber(), optimizer=opt, metrics=["mae"])
history = model.fit(train_ds, validation_data=valid_ds, epochs=500,
                    callbacks=[early_stopping_cb])

该模型达到了约 37,866 的验证 MAE(结果可能有所不同)。这比天真的预测要好,但比 SARIMA 模型要差。⁵

我们能用 RNN 做得更好吗?让我们看看!

使用简单 RNN 进行预测

让我们尝试最基本的 RNN,其中包含一个具有一个循环神经元的单个循环层,就像我们在图 15-1 中看到的那样:

model = tf.keras.Sequential([
    tf.keras.layers.SimpleRNN(1, input_shape=[None, 1])
])

Keras 中的所有循环层都期望形状为[批量大小时间步长维度]的 3D 输入,其中维度对于单变量时间序列为 1,对于多变量时间序列为更多。请记住,input_shape参数忽略第一个维度(即批量大小),由于循环层可以接受任意长度的输入序列,因此我们可以将第二个维度设置为None,表示“任意大小”。最后,由于我们处理的是单变量时间序列,我们需要最后一个维度的大小为 1。这就是为什么我们指定输入形状为[None, 1]:它表示“任意长度的单变量序列”。请注意,数据集实际上包含形状为[批量大小时间步长]的输入,因此我们缺少最后一个维度,大小为 1,但在这种情况下,Keras 很友好地为我们添加了它。

这个模型的工作方式与我们之前看到的完全相同:初始状态h[(init)]设置为 0,并传递给一个单个的循环神经元,以及第一个时间步的值x[(0)]。神经元计算这些值加上偏置项的加权和,并使用默认的双曲正切函数对结果应用激活函数。结果是第一个输出y[0]。在简单 RNN 中,这个输出也是新状态h[0]。这个新状态传递给相同的循环神经元,以及下一个输入值x[(1)],并且这个过程重复直到最后一个时间步。最后,该层只输出最后一个值:在我们的情况下,序列长度为 56 步,因此最后一个值是y[55]。所有这些都同时为批次中的每个序列执行,本例中有 32 个序列。

注意

默认情况下,Keras 中的循环层只返回最终输出。要使它们返回每个时间步的一个输出,您必须设置return_sequences=True,如您将看到的。

这就是我们的第一个循环模型!这是一个序列到向量的模型。由于只有一个输出神经元,输出向量的大小为 1。

现在,如果您编译、训练和评估这个模型,就像之前的模型一样,您会发现它一点用也没有:其验证 MAE 大于 100,000!哎呀。这是可以预料到的,有两个原因:

  1. 该模型只有一个循环神经元,因此在每个时间步进行预测时,它只能使用当前时间步的输入值和上一个时间步的输出值。这不足以进行预测!换句话说,RNN 的记忆极为有限:只是一个数字,它的先前输出。让我们来数一下这个模型有多少参数:由于只有一个循环神经元,只有两个输入值,整个模型只有三个参数(两个权重加上一个偏置项)。这对于这个时间序列来说远远不够。相比之下,我们之前的模型可以一次查看所有 56 个先前的值,并且总共有 57 个参数。

  2. 时间序列包含的值从 0 到约 1.4,但由于默认激活函数是 tanh,循环层只能输出-1 到+1 之间的值。它无法预测 1.0 到 1.4 之间的值。

让我们解决这两个问题:我们将创建一个具有更大的循环层的模型,其中包含 32 个循环神经元,并在其顶部添加一个密集的输出层,其中只有一个输出神经元,没有激活函数。循环层将能够在一个时间步到下一个时间步传递更多信息,而密集输出层将把最终输出从 32 维投影到 1 维,没有任何值范围约束:

univar_model = tf.keras.Sequential([
    tf.keras.layers.SimpleRNN(32, input_shape=[None, 1]),
    tf.keras.layers.Dense(1)  # no activation function by default
])

现在,如果您像之前那样编译、拟合和评估这个模型,您会发现其验证 MAE 达到了 27,703。这是迄今为止我们训练过的最佳模型,甚至击败了 SARIMA 模型:我们做得相当不错!

提示

我们只对时间序列进行了归一化,没有去除趋势和季节性,但模型仍然表现良好。这很方便,因为这样可以快速搜索有前途的模型,而不用太担心预处理。然而,为了获得最佳性能,您可能希望尝试使时间序列更加平稳;例如,使用差分。

使用深度 RNN 进行预测

通常会堆叠多层单元,如图 15-10 所示。这给你一个深度 RNN

mls3 1510

图 15-10. 深度 RNN(左)在时间轴上展开(右)

使用 Keras 实现深度 RNN 很简单:只需堆叠循环层。在下面的示例中,我们使用三个SimpleRNN层(但我们也可以使用任何其他类型的循环层,如LSTM层或GRU层,我们将很快讨论)。前两个是序列到序列层,最后一个是序列到向量层。最后,Dense层生成模型的预测(您可以将其视为向量到向量层)。因此,这个模型就像图 15-10 中表示的模型一样,只是忽略了Ŷ[(0)]到Ŷ[(t–1_)]的输出,并且在Ŷ[(t)]之上有一个密集层,输出实际预测:

deep_model = tf.keras.Sequential([
    tf.keras.layers.SimpleRNN(32, return_sequences=True, input_shape=[None, 1]),
    tf.keras.layers.SimpleRNN(32, return_sequences=True),
    tf.keras.layers.SimpleRNN(32),
    tf.keras.layers.Dense(1)
])
警告

确保对所有循环层设置return_sequences=True(除非您只关心最后的输出,最后一个循环层除外)。如果您忘记为一个循环层设置此参数,它将输出一个 2D 数组,其中仅包含最后一个时间步的输出,而不是包含所有时间步输出的 3D 数组。下一个循环层将抱怨您没有以预期的 3D 格式提供序列。

如果您训练和评估这个模型,您会发现它的 MAE 约为 31,211。这比两个基线都要好,但它并没有击败我们的“更浅”的 RNN。看起来这个 RNN 对我们的任务来说有点太大了。

多变量时间序列预测

神经网络的一个很大的优点是它们的灵活性:特别是,它们几乎不需要改变架构就可以处理多变量时间序列。例如,让我们尝试使用公交和铁路数据作为输入来预测铁路时间序列。事实上,让我们也加入日期类型!由于我们总是可以提前知道明天是工作日、周末还是假日,我们可以将日期类型系列向未来推移一天,这样模型就会将明天的日期类型作为输入。为简单起见,我们将使用 Pandas 进行此处理:

df_mulvar = df[["bus", "rail"]] / 1e6  # use both bus & rail series as input
df_mulvar["next_day_type"] = df["day_type"].shift(-1)  # we know tomorrow's type
df_mulvar = pd.get_dummies(df_mulvar)  # one-hot encode the day type

现在 df_mulvar 是一个包含五列的 DataFrame:公交和铁路数据,以及包含下一天类型的独热编码的三列(请记住,有三种可能的日期类型,WAU)。接下来我们可以像之前一样继续。首先,我们将数据分为三个时期,用于训练、验证和测试:

mulvar_train = df_mulvar["2016-01":"2018-12"]
mulvar_valid = df_mulvar["2019-01":"2019-05"]
mulvar_test = df_mulvar["2019-06":]

然后我们创建数据集:

train_mulvar_ds = tf.keras.utils.timeseries_dataset_from_array(
    mulvar_train.to_numpy(),  # use all 5 columns as input
    targets=mulvar_train["rail"][seq_length:],  # forecast only the rail series
    [...]  # the other 4 arguments are the same as earlier
)
valid_mulvar_ds = tf.keras.utils.timeseries_dataset_from_array(
    mulvar_valid.to_numpy(),
    targets=mulvar_valid["rail"][seq_length:],
    [...]  # the other 2 arguments are the same as earlier
)

最后我们创建 RNN:

mulvar_model = tf.keras.Sequential([
    tf.keras.layers.SimpleRNN(32, input_shape=[None, 5]),
    tf.keras.layers.Dense(1)
])

请注意,与我们之前构建的 univar_model RNN 唯一的区别是输入形状:在每个时间步骤,模型现在接收五个输入,而不是一个。这个模型实际上达到了 22,062 的验证 MAE。现在我们取得了很大的进展!

事实上,让 RNN 预测公交和铁路乘客量并不太难。您只需要在创建数据集时更改目标,将其设置为训练集的 mulvar_train[["bus", "rail"]][seq_length:],验证集的 mulvar_valid[["bus", "rail"]][seq_length:]。您还必须在输出 Dense 层中添加一个额外的神经元,因为现在它必须进行两次预测:一次是明天的公交乘客量,另一次是铁路乘客量。就是这样!

正如我们在第十章中讨论的那样,对于多个相关任务使用单个模型通常比为每个任务使用单独的模型效果更好,因为为一个任务学习的特征可能对其他任务也有用,而且因为在多个任务中表现良好可以防止模型过拟合(这是一种正则化形式)。然而,这取决于任务,在这种特殊情况下,同时预测公交和铁路乘客量的多任务 RNN 并不像专门预测其中一个的模型表现得那么好(使用所有五列作为输入)。尽管如此,它对铁路的验证 MAE 达到了 25,330,对公交达到了 26,369,这还是相当不错的。

提前预测多个时间步

到目前为止,我们只预测了下一个时间步的值,但我们也可以通过适当更改目标来预测几个步骤之后的值(例如,要预测两周后的乘客量,我们只需将目标更改为比 1 天后提前 14 天的值)。但是如果我们想预测接下来的 14 个值呢?

第一种选择是取我们之前为铁路时间序列训练的 univar_model RNN,让它预测下一个值,并将该值添加到输入中,就好像预测的值实际上已经发生了;然后我们再次使用模型来预测下一个值,依此类推,如下面的代码所示:

import numpy as np

X = rail_valid.to_numpy()[np.newaxis, :seq_length, np.newaxis]
for step_ahead in range(14):
    y_pred_one = univar_model.predict(X)
    X = np.concatenate([X, y_pred_one.reshape(1, 1, 1)], axis=1)

在这段代码中,我们取验证期间前 56 天的铁路乘客量,并将数据转换为形状为 [1, 56, 1] 的 NumPy 数组(请记住,循环层期望 3D 输入)。然后我们重复使用模型来预测下一个值,并将每个预测附加到输入系列中,沿着时间轴(axis=1)。生成的预测在图 15-11 中绘制。

警告

如果模型在一个时间步骤上出现错误,那么接下来的时间步骤的预测也会受到影响:错误往往会累积。因此,最好只在少数步骤中使用这种技术。

mls3 1511

图 15-11。提前 14 步预测,一次预测一步

第二个选项是训练一个 RNN 一次性预测接下来的 14 个值。我们仍然可以使用一个序列到向量模型,但它将输出 14 个值而不是 1。然而,我们首先需要改变目标,使其成为包含接下来 14 个值的向量。为此,我们可以再次使用timeseries_dataset_from_array(),但这次要求它创建没有目标(targets=None)的数据集,并且具有更长的序列,长度为seq_length + 14。然后我们可以使用数据集的map()方法对每个序列批次应用自定义函数,将其分成输入和目标。在这个例子中,我们使用多变量时间序列作为输入(使用所有五列),并预测未来 14 天的铁路乘客量。

def split_inputs_and_targets(mulvar_series, ahead=14, target_col=1):
    return mulvar_series[:, :-ahead], mulvar_series[:, -ahead:, target_col]

ahead_train_ds = tf.keras.utils.timeseries_dataset_from_array(
    mulvar_train.to_numpy(),
    targets=None,
    sequence_length=seq_length + 14,
    [...]  # the other 3 arguments are the same as earlier
).map(split_inputs_and_targets)
ahead_valid_ds = tf.keras.utils.timeseries_dataset_from_array(
    mulvar_valid.to_numpy(),
    targets=None,
    sequence_length=seq_length + 14,
    batch_size=32
).map(split_inputs_and_targets)

现在我们只需要将输出层的单元数从 1 增加到 14:

ahead_model = tf.keras.Sequential([
    tf.keras.layers.SimpleRNN(32, input_shape=[None, 5]),
    tf.keras.layers.Dense(14)
])

训练完这个模型后,你可以像这样一次性预测接下来的 14 个值:

X = mulvar_valid.to_numpy()[np.newaxis, :seq_length]  # shape [1, 56, 5]
Y_pred = ahead_model.predict(X)  # shape [1, 14]

这种方法效果相当不错。它对于第二天的预测显然比对未来 14 天的预测要好,但它不会像之前的方法那样累积误差。然而,我们仍然可以做得更好,使用一个序列到序列(或seq2seq)模型。

使用序列到序列模型进行预测

不是只在最后一个时间步训练模型来预测接下来的 14 个值,而是在每一个时间步都训练它来预测接下来的 14 个值。换句话说,我们可以将这个序列到向量的 RNN 转变为一个序列到序列的 RNN。这种技术的优势在于损失函数将包含 RNN 在每一个时间步的输出,而不仅仅是最后一个时间步的输出。

这意味着会有更多的误差梯度通过模型流动,它们不需要像以前那样通过时间流动,因为它们将来自每一个时间步的输出,而不仅仅是最后一个时间步。这将使训练更加稳定和快速。

明确一点,在时间步 0,模型将输出一个包含时间步 1 到 14 的预测的向量,然后在时间步 1,模型将预测时间步 2 到 15,依此类推。换句话说,目标是连续窗口的序列,每个时间步向后移动一个时间步。目标不再是一个向量,而是一个与输入相同长度的序列,每一步包含一个 14 维向量。

准备数据集并不是简单的,因为每个实例的输入是一个窗口,输出是窗口序列。一种方法是连续两次使用我们之前创建的to_windows()实用函数,以获得连续窗口的窗口。例如,让我们将数字 0 到 6 的系列转换为包含 4 个连续窗口的数据集,每个窗口长度为 3:

>>> my_series = tf.data.Dataset.range(7)
>>> dataset = to_windows(to_windows(my_series, 3), 4)
>>> list(dataset)
[<tf.Tensor: shape=(4, 3), dtype=int64, numpy=
 array([[0, 1, 2],
 [1, 2, 3],
 [2, 3, 4],
 [3, 4, 5]])>,
 <tf.Tensor: shape=(4, 3), dtype=int64, numpy=
 array([[1, 2, 3],
 [2, 3, 4],
 [3, 4, 5],
 [4, 5, 6]])>]

现在我们可以使用map()方法将这些窗口的窗口分割为输入和目标:

>>> dataset = dataset.map(lambda S: (S[:, 0], S[:, 1:]))
>>> list(dataset)
[(<tf.Tensor: shape=(4,), dtype=int64, numpy=array([0, 1, 2, 3])>,
 <tf.Tensor: shape=(4, 2), dtype=int64, numpy=
 array([[1, 2],
 [2, 3],
 [3, 4],
 [4, 5]])>),
 (<tf.Tensor: shape=(4,), dtype=int64, numpy=array([1, 2, 3, 4])>,
 <tf.Tensor: shape=(4, 2), dtype=int64, numpy=
 array([[2, 3],
 [3, 4],
 [4, 5],
 [5, 6]])>)]

现在数据集包含长度为 4 的输入序列,目标是包含下两个步骤的序列,每个时间步。例如,第一个输入序列是[0, 1, 2, 3],对应的目标是[[1, 2], [2, 3], [3, 4], [4, 5]],这是每个时间步的下两个值。如果你和我一样,可能需要几分钟来理解这个概念。慢慢来!

注意

也许令人惊讶的是,目标值包含在输入中出现的值。这是不是作弊?幸运的是,完全不是:在每一个时间步,RNN 只知道过去的时间步;它无法向前看。它被称为因果模型。

让我们创建另一个小型实用函数来为我们的序列到序列模型准备数据集。它还会负责洗牌(可选)和分批处理:

def to_seq2seq_dataset(series, seq_length=56, ahead=14, target_col=1,
                       batch_size=32, shuffle=False, seed=None):
    ds = to_windows(tf.data.Dataset.from_tensor_slices(series), ahead + 1)
    ds = to_windows(ds, seq_length).map(lambda S: (S[:, 0], S[:, 1:, 1]))
    if shuffle:
        ds = ds.shuffle(8 * batch_size, seed=seed)
    return ds.batch(batch_size)

现在我们可以使用这个函数来创建数据集:

seq2seq_train = to_seq2seq_dataset(mulvar_train, shuffle=True, seed=42)
seq2seq_valid = to_seq2seq_dataset(mulvar_valid)

最后,我们可以构建序列到序列模型:

seq2seq_model = tf.keras.Sequential([
    tf.keras.layers.SimpleRNN(32, return_sequences=True, input_shape=[None, 5]),
    tf.keras.layers.Dense(14)
])

这几乎与我们之前的模型完全相同:唯一的区别是在SimpleRNN层中设置了return_sequences=True。这样,它将输出一个向量序列(每个大小为 32),而不是在最后一个时间步输出单个向量。Dense层足够聪明,可以处理序列作为输入:它将在每个时间步应用,以 32 维向量作为输入,并输出 14 维向量。实际上,获得完全相同结果的另一种方法是使用具有核大小为 1 的Conv1D层:Conv1D(14, kernel_size=1)

提示

Keras 提供了一个TimeDistributed层,允许您将任何向量到向量层应用于输入序列中的每个向量,在每个时间步。它通过有效地重新塑造输入来实现这一点,以便将每个时间步视为单独的实例,然后重新塑造层的输出以恢复时间维度。在我们的情况下,我们不需要它,因为Dense层已经支持序列作为输入。

训练代码与往常一样。在训练期间,使用所有模型的输出,但在训练后,只有最后一个时间步的输出才重要,其余可以忽略。例如,我们可以这样预测未来 14 天的铁路乘客量:

X = mulvar_valid.to_numpy()[np.newaxis, :seq_length]
y_pred_14 = seq2seq_model.predict(X)[0, -1]  # only the last time step's output

如果评估此模型对t + 1 的预测,您将发现验证 MAE 为 25,519。对于t + 2,它为 26,274,随着模型试图进一步预测未来,性能会逐渐下降。在t + 14 时,MAE 为 34,322。

提示

您可以结合两种方法来预测多步:例如,您可以训练一个模型,预测未来 14 天,然后将其输出附加到输入,然后再次运行模型,以获取接下来 14 天的预测,并可能重复该过程。

简单的 RNN 在预测时间序列或处理其他类型的序列时可能表现得很好,但在长时间序列或序列上表现不佳。让我们讨论一下原因,并看看我们能做些什么。

处理长序列

要在长序列上训练 RNN,我们必须在许多时间步上运行它,使展开的 RNN 成为一个非常深的网络。就像任何深度神经网络一样,它可能会遇到不稳定的梯度问题,如第十一章中讨论的:可能需要很长时间来训练,或者训练可能不稳定。此外,当 RNN 处理长序列时,它将逐渐忘记序列中的第一个输入。让我们从不稳定的梯度问题开始,看看这两个问题。

解决不稳定梯度问题

许多我们在深度网络中用来缓解不稳定梯度问题的技巧也可以用于 RNN:良好的参数初始化,更快的优化器,辍学等。然而,非饱和激活函数(例如 ReLU)在这里可能不会有太大帮助。实际上,它们可能会导致 RNN 在训练过程中更加不稳定。为什么?嗯,假设梯度下降以一种增加第一个时间步输出的方式更新权重。由于相同的权重在每个时间步使用,第二个时间步的输出也可能略有增加,第三个时间步也是如此,直到输出爆炸——而非饱和激活函数无法阻止这种情况。

您可以通过使用较小的学习率来减少这种风险,或者可以使用饱和激活函数,如双曲正切(这解释了为什么它是默认值)。

同样,梯度本身也可能爆炸。如果注意到训练不稳定,可以监控梯度的大小(例如,使用 TensorBoard),并可能使用梯度裁剪。

此外,批量归一化不能像深度前馈网络那样有效地与 RNN 一起使用。实际上,您不能在时间步之间使用它,只能在循环层之间使用。

更准确地说,从技术上讲,可以在内存单元中添加一个 BN 层(您很快就会看到),以便在每个时间步上应用它(既在该时间步的输入上,也在上一个步骤的隐藏状态上)。然而,相同的 BN 层将在每个时间步上使用相同的参数,而不考虑输入和隐藏状态的实际比例和偏移。实际上,这并不会产生良好的结果,如 César Laurent 等人在2015 年的一篇论文中所证明的:作者发现,只有当 BN 应用于层的输入时才略有益处,而不是应用于隐藏状态。换句话说,当应用于循环层之间(即在图 15-10 中垂直地)时,它略好于什么都不做,但不适用于循环层内部(即水平地)。在 Keras 中,您可以通过在每个循环层之前添加一个 BatchNormalization 层来简单地在层之间应用 BN,但这会减慢训练速度,并且可能帮助不大。

另一种规范化方法在 RNN 中通常效果更好:层归一化。这个想法是由 Jimmy Lei Ba 等人在2016 年的一篇论文中提出的:它与批归一化非常相似,但不同的是,层归一化是在特征维度上进行归一化,而不是在批次维度上。一个优点是它可以在每个时间步上独立地为每个实例计算所需的统计数据。这也意味着它在训练和测试期间的行为是相同的(与 BN 相反),它不需要使用指数移动平均来估计训练集中所有实例的特征统计数据,就像 BN 那样。与 BN 类似,层归一化为每个输入学习一个比例和偏移参数。在 RNN 中,它通常在输入和隐藏状态的线性组合之后立即使用。

让我们使用 Keras 在简单内存单元中实现层归一化。为此,我们需要定义一个自定义内存单元,它就像一个常规层一样,只是它的 call() 方法接受两个参数:当前时间步的 inputs 和上一个时间步的隐藏 states

请注意,states 参数是一个包含一个或多个张量的列表。在简单的 RNN 单元中,它包含一个张量,等于上一个时间步的输出,但其他单元可能有多个状态张量(例如,LSTMCell 有一个长期状态和一个短期状态,您很快就会看到)。一个单元还必须有一个 state_size 属性和一个 output_size 属性。在简单的 RNN 中,两者都简单地等于单元的数量。以下代码实现了一个自定义内存单元,它将表现得像一个 SimpleRNNCell,但它还会在每个时间步应用层归一化:

class LNSimpleRNNCell(tf.keras.layers.Layer):
    def __init__(self, units, activation="tanh", **kwargs):
        super().__init__(**kwargs)
        self.state_size = units
        self.output_size = units
        self.simple_rnn_cell = tf.keras.layers.SimpleRNNCell(units,
                                                             activation=None)
        self.layer_norm = tf.keras.layers.LayerNormalization()
        self.activation = tf.keras.activations.get(activation)

    def call(self, inputs, states):
        outputs, new_states = self.simple_rnn_cell(inputs, states)
        norm_outputs = self.activation(self.layer_norm(outputs))
        return norm_outputs, [norm_outputs]

让我们来看一下这段代码:

  • 我们的 LNSimpleRNNCell 类继承自 tf.keras.layers.Layer 类,就像任何自定义层一样。

  • 构造函数接受单位数和所需的激活函数,并设置 state_sizeoutput_size 属性,然后创建一个没有激活函数的 SimpleRNNCell(因为我们希望在线性操作之后但在激活函数之前执行层归一化)。然后构造函数创建 LayerNormalization 层,最后获取所需的激活函数。

  • call()方法首先应用simpleRNNCell,它计算当前输入和先前隐藏状态的线性组合,并返回结果两次(实际上,在SimpleRNNCell中,输出就等于隐藏状态:换句话说,new_states[0]等于outputs,因此我们可以在call()方法的其余部分安全地忽略new_states)。接下来,call()方法应用层归一化,然后是激活函数。最后,它将输出返回两次:一次作为输出,一次作为新的隐藏状态。要使用此自定义细胞,我们只需要创建一个tf.keras.layers.RNN层,将其传递给一个细胞实例:

custom_ln_model = tf.keras.Sequential([
    tf.keras.layers.RNN(LNSimpleRNNCell(32), return_sequences=True,
                        input_shape=[None, 5]),
    tf.keras.layers.Dense(14)
])

同样,您可以创建一个自定义细胞,在每个时间步之间应用 dropout。但有一个更简单的方法:Keras 提供的大多数循环层和细胞都有dropoutrecurrent_dropout超参数:前者定义要应用于输入的 dropout 率,后者定义隐藏状态之间的 dropout 率,即时间步之间。因此,在 RNN 中不需要创建自定义细胞来在每个时间步应用 dropout。

通过这些技术,您可以缓解不稳定梯度问题,并更有效地训练 RNN。现在让我们看看如何解决短期记忆问题。

提示

在预测时间序列时,通常有必要在预测中包含一些误差范围。为此,一种方法是使用 MC dropout,介绍在第十一章中:在训练期间使用recurrent_dropout,然后在推断时通过使用model(X, training=True)来保持 dropout 处于活动状态。多次重复此操作以获得多个略有不同的预测,然后计算每个时间步的这些预测的均值和标准差。

解决短期记忆问题

由于数据在经过 RNN 时经历的转换,每个时间步都会丢失一些信息。过一段时间后,RNN 的状态几乎不包含最初输入的任何痕迹。这可能是一个停滞不前的问题。想象一下多莉鱼试图翻译一句长句子;当她读完时,她已经不记得它是如何开始的。为了解决这个问题,引入了各种具有长期记忆的细胞类型。它们已经被证明非常成功,以至于基本细胞不再被广泛使用。让我们首先看看这些长期记忆细胞中最受欢迎的:LSTM 细胞。

LSTM 细胞

长短期记忆(LSTM)细胞是由 Sepp Hochreiter 和 Jürgen Schmidhuber 于 1997 年提出的,并在多年来逐渐得到了几位研究人员的改进,如 Alex Graves,Haşim Sak 和 Wojciech Zaremba。如果将 LSTM 细胞视为黑匣子,它可以被用作基本细胞,只是它的性能会更好;训练会更快收敛,并且它会检测数据中更长期的模式。在 Keras 中,您可以简单地使用LSTM层而不是SimpleRNN层:

model = tf.keras.Sequential([
    tf.keras.layers.LSTM(32, return_sequences=True, input_shape=[None, 5]),
    tf.keras.layers.Dense(14)
])

或者,您可以使用通用的tf.keras.layers.RNN层,将LSTMCell作为参数传递给它。但是,当在 GPU 上运行时,LSTM层使用了优化的实现(请参阅第十九章),因此通常最好使用它(RNN层在定义自定义细胞时非常有用,就像我们之前做的那样)。

那么 LSTM 细胞是如何工作的呢?其架构显示在图 15-12 中。如果不看盒子里面的内容,LSTM 细胞看起来与常规细胞完全相同,只是其状态分为两个向量:h[(t)]和c[(t)](“c”代表“cell”)。您可以将h[(t)]视为短期状态,将c[(t)]视为长期状态。

mls3 1512

图 15-12. LSTM 单元

现在让我们打开盒子!关键思想是网络可以学习将什么存储在长期状态中,什么丢弃,以及从中读取什么。当长期状态c[(t–1)]从左到右穿过网络时,您可以看到它首先经过一个遗忘门,丢弃一些记忆,然后通过加法操作添加一些新的记忆(通过输入门选择的记忆)。结果c[(t)]直接发送出去,没有进一步的转换。因此,在每个时间步骤,一些记忆被丢弃,一些记忆被添加。此外,在加法操作之后,长期状态被复制并通过 tanh 函数传递,然后结果由输出门过滤。这产生了短期状态h[(t)](这等于此时间步骤的单元输出y[(t))。现在让我们看看新记忆来自哪里以及门是如何工作的。

首先,当前输入向量x[(t)]和先前的短期状态h[(t–1)]被馈送到四个不同的全连接层。它们各自有不同的作用:

  • 主要层是输出g[(t)]的层。它通常的作用是分析当前输入x[(t)]和先前(短期)状态h[(t–1)]。在基本单元中,除了这一层外没有其他内容,其输出直接发送到y[(t)]和h[(t)]。但在 LSTM 单元中,这一层的输出不会直接输出;相反,其最重要的部分存储在长期状态中(其余部分被丢弃)。

  • 其他三个层是门控制器。由于它们使用逻辑激活函数,输出范围从 0 到 1。正如您所看到的,门控制器的输出被馈送到逐元素乘法操作:如果它们输出 0,则关闭门,如果它们输出 1,则打开门。具体来说:

    • 遗忘门(由f[(t)]控制)控制着应该擦除长期状态的哪些部分。

    • 输入门(由i[(t)]控制)控制着应该将g[(t)]的哪些部分添加到长期状态中。

    • 最后,输出门(由o[(t)]控制)控制着长期状态的哪些部分应该在此时间步骤被读取并输出,既输出到h[(t)],也输出到y[(t)]。

简而言之,LSTM 单元可以学习识别重要的输入(这是输入门的作用),将其存储在长期状态中,保留它直到需要(这是遗忘门的作用),并在需要时提取它。这解释了为什么这些单元在捕捉时间序列、长文本、音频记录等长期模式方面取得了惊人的成功。

方程 15-4 总结了如何计算单元的长期状态、短期状态以及每个时间步骤的输出,针对单个实例(整个小批量的方程非常相似)。

方程 15-4. LSTM 计算

i (t) = σ ( W xi x (t) + W hi h (t-1) + b i ) f (t) = σ ( W xf x (t) + W hf h (t-1) + b f ) o (t) = σ ( W xo x (t) + W ho h (t-1) + b o ) g (t) = tanh ( W xg x (t) + W hg h (t-1) + b g ) c (t) = f (t) c (t-1) + i (t) g (t) y (t) = h (t) = o (t) tanh ( c (t) )

在这个方程中:

  • W[xi]、W[xf]、W[xo]和W[xg]是每个四层的权重矩阵,用于它们与输入向量x[(t)]的连接。

  • W[hi]、W[hf]、W[ho]和W[hg]是每个四层的权重矩阵,用于它们与先前的短期状态h[(t–1)]的连接。

  • b[i]、b[f]、b[o]和b[g]是每个四层的偏置项。请注意,TensorFlow 将b[f]初始化为一个全为 1 的向量,而不是 0。这可以防止在训练开始时忘记所有内容。

LSTM 单元有几个变体。一个特别流行的变体是 GRU 单元,我们现在将看一下。

GRU 单元

门控循环单元 (GRU)单元(见图 15-13)由 Kyunghyun Cho 等人在2014 年的一篇论文中提出,该论文还介绍了我们之前讨论过的编码器-解码器网络。

mls3 1513

图 15-13. GRU 单元

GRU 单元是 LSTM 单元的简化版本,看起来表现同样出色(这解释了它日益增长的受欢迎程度)。这些是主要的简化:

  • 两个状态向量合并成一个单一向量h[(t)]。

  • 一个单一的门控制器z[(t)]控制遗忘门和输入门。如果门控制器输出 1,则遗忘门打开(= 1),输入门关闭(1 - 1 = 0)。如果输出 0,则相反发生。换句话说,每当必须存储一个记忆时,将首先擦除将存储它的位置。这实际上是 LSTM 单元的一个常见变体。

  • 没有输出门;完整状态向量在每个时间步输出。然而,有一个新的门控制器r[(t)],控制哪部分先前状态将被显示给主层(g[(t)])。

方程 15-5 总结了如何计算每个时间步的单个实例的单元状态。

方程 15-5. GRU 计算

z (t) = σ ( W xz x (t) + W hz h (t-1) + bz ) r (t) = σ ( W xr x (t) + W hr h (t-1) + br ) g (t) = tanh W xg x (t) + W hg ( r (t) h (t-1) ) + bg h (t) = z (t) h (t-1) + ( 1 - z (t) ) g (t)

Keras 提供了一个tf.keras.layers.GRU层:使用它只需要将SimpleRNNLSTM替换为GRU。它还提供了一个tf.keras.layers.GRUCell,以便根据 GRU 单元创建自定义单元。

LSTM 和 GRU 单元是 RNN 成功的主要原因之一。然而,虽然它们可以处理比简单 RNN 更长的序列,但它们仍然有相当有限的短期记忆,并且很难学习 100 个时间步或更多的序列中的长期模式,例如音频样本、长时间序列或长句子。解决这个问题的一种方法是缩短输入序列;例如,使用 1D 卷积层。

使用 1D 卷积层处理序列

在第十四章中,我们看到 2D 卷积层通过在图像上滑动几个相当小的卷积核(或滤波器),产生多个 2D 特征图(每个卷积核一个)。类似地,1D 卷积层在序列上滑动多个卷积核,每个卷积核产生一个 1D 特征图。每个卷积核将学习检测单个非常短的连续模式(不超过卷积核大小)。如果使用 10 个卷积核,则该层的输出将由 10 个 1D 序列组成(长度相同),或者您可以将此输出视为单个 10D 序列。这意味着您可以构建一个由循环层和 1D 卷积层(甚至 1D 池化层)组成的神经网络。如果使用步幅为 1 和"same"填充的 1D 卷积层,则输出序列的长度将与输入序列的长度相同。但是,如果使用"valid"填充或大于 1 的步幅,则输出序列将短于输入序列,因此请确保相应调整目标。

例如,以下模型与之前的模型相同,只是它以一个 1D 卷积层开始,通过步幅为 2 对输入序列进行下采样。卷积核的大小大于步幅,因此所有输入都将用于计算该层的输出,因此模型可以学习保留有用信息,仅丢弃不重要的细节。通过缩短序列,卷积层可能有助于GRU层检测更长的模式,因此我们可以将输入序列长度加倍至 112 天。请注意,我们还必须裁剪目标中的前三个时间步:实际上,卷积核的大小为 4,因此卷积层的第一个输出将基于输入时间步 0 到 3,第一个预测将是时间步 4 到 17(而不是时间步 1 到 14)。此外,由于步幅,我们必须将目标下采样一半:

conv_rnn_model = tf.keras.Sequential([
    tf.keras.layers.Conv1D(filters=32, kernel_size=4, strides=2,
                           activation="relu", input_shape=[None, 5]),
    tf.keras.layers.GRU(32, return_sequences=True),
    tf.keras.layers.Dense(14)
])

longer_train = to_seq2seq_dataset(mulvar_train, seq_length=112,
                                       shuffle=True, seed=42)
longer_valid = to_seq2seq_dataset(mulvar_valid, seq_length=112)
downsampled_train = longer_train.map(lambda X, Y: (X, Y[:, 3::2]))
downsampled_valid = longer_valid.map(lambda X, Y: (X, Y[:, 3::2]))
[...]  # compile and fit the model using the downsampled datasets

如果您训练和评估此模型,您会发现它的性能优于之前的模型(略有优势)。事实上,实际上可以仅使用 1D 卷积层并完全放弃循环层!

WaveNet

2016 年的一篇论文,⁠¹⁶ Aaron van den Oord 和其他 DeepMind 研究人员介绍了一种名为WaveNet的新颖架构。他们堆叠了 1D 卷积层,每一层的扩张率(每个神经元的输入间隔)都加倍:第一个卷积层一次只能看到两个时间步,而下一个卷积层则看到四个时间步(其感受野为四个时间步),下一个卷积层看到八个时间步,依此类推(参见图 15-14)。通过加倍扩张率,较低层学习短期模式,而较高层学习长期模式。由于加倍扩张率,网络可以非常高效地处理极大的序列。

mls3 1514

图 15-14。WaveNet 架构

论文的作者实际上堆叠了 10 个具有 1、2、4、8、…、256、512 扩张率的卷积层,然后他们又堆叠了另一组 10 个相同的层(扩张率也是 1、2、4、8、…、256、512),然后再次堆叠了另一组相同的 10 层。他们通过指出,具有这些扩张率的单个 10 个卷积层堆栈将像具有大小为 1,024 的卷积核的超高效卷积层一样(速度更快,更强大,参数数量显著减少)。他们还在每一层之前用与扩张率相等的零填充输入序列,以保持整个网络中相同的序列长度。

以下是如何实现一个简化的 WaveNet 来处理与之前相同的序列的方法:⁠¹⁷

wavenet_model = tf.keras.Sequential()
wavenet_model.add(tf.keras.layers.Input(shape=[None, 5]))
for rate in (1, 2, 4, 8) * 2:
    wavenet_model.add(tf.keras.layers.Conv1D(
        filters=32, kernel_size=2, padding="causal", activation="relu",
        dilation_rate=rate))
wavenet_model.add(tf.keras.layers.Conv1D(filters=14, kernel_size=1))

这个Sequential模型从一个明确的输入层开始——这比仅在第一层上设置input_shape要简单。然后,它继续使用“因果”填充的 1D 卷积层,这类似于“相同”填充,只是零值仅附加在输入序列的开头,而不是两侧。这确保了卷积层在进行预测时不会窥视未来。然后,我们添加使用不断增长的扩张率的类似对层:1、2、4、8,再次是 1、2、4、8。最后,我们添加输出层:一个具有 14 个大小为 1 的滤波器的卷积层,没有任何激活函数。正如我们之前看到的那样,这样的卷积层等效于具有 14 个单元的Dense层。由于因果填充,每个卷积层输出与其输入序列相同长度的序列,因此我们在训练期间使用的目标可以是完整的 112 天序列:无需裁剪或降采样。

我们在本节讨论的模型对乘客量预测任务提供了类似的性能,但它们在任务和可用数据量方面可能会有很大差异。在 WaveNet 论文中,作者在各种音频任务(因此该架构的名称)上实现了最先进的性能,包括文本转语音任务,在多种语言中产生令人难以置信的逼真声音。他们还使用该模型逐个音频样本生成音乐。当您意识到一秒钟的音频可能包含成千上万个时间步时,这一壮举就显得更加令人印象深刻——即使是 LSTM 和 GRU 也无法处理如此长的序列。

警告

如果您在测试期间评估我们最佳的芝加哥乘客量模型,从 2020 年开始,您会发现它们的表现远远不如预期!为什么呢?嗯,那时候是 Covid-19 大流行开始的时候,这对公共交通产生了很大影响。正如前面提到的,这些模型只有在它们从过去学到的模式在未来继续时才能很好地工作。无论如何,在将模型部署到生产环境之前,请验证它在最近的数据上表现良好。一旦投入生产,请确保定期监控其性能。

有了这个,您现在可以处理各种时间序列了!在第十六章中,我们将继续探索 RNN,并看看它们如何处理各种 NLP 任务。

练习

  1. 您能想到一些序列到序列 RNN 的应用吗?序列到向量 RNN 和向量到序列 RNN 呢?

  2. RNN 层的输入必须具有多少维度?每个维度代表什么?输出呢?

  3. 如果您想构建一个深度序列到序列 RNN,哪些 RNN 层应该具有return_sequences=True?序列到向量 RNN 呢?

  4. 假设您有一个每日单变量时间序列,并且您想要预测接下来的七天。您应该使用哪种 RNN 架构?

  5. 在训练 RNN 时主要的困难是什么?您如何处理它们?

  6. 您能勾画出 LSTM 单元的架构吗?

  7. 为什么要在 RNN 中使用 1D 卷积层?

  8. 您可以使用哪种神经网络架构来对视频进行分类?

  9. 为 SketchRNN 数据集训练一个分类模型,该数据集可在 TensorFlow Datasets 中找到。

  10. 下载巴赫赞美诗数据集并解压缩。这是由约翰·塞巴斯蒂安·巴赫创作的 382 首赞美诗。每首赞美诗长 100 至 640 个时间步长,每个时间步长包含 4 个整数,其中每个整数对应于钢琴上的一个音符的索引(除了值为 0,表示没有播放音符)。训练一个模型——循环的、卷积的,或两者兼而有之——可以预测下一个时间步长(四个音符),给定来自赞美诗的时间步长序列。然后使用这个模型生成类似巴赫的音乐,一次一个音符:您可以通过给模型提供赞美诗的开头并要求它预测下一个时间步长来实现这一点,然后将这些时间步长附加到输入序列并要求模型预测下一个音符,依此类推。还要确保查看谷歌的 Coconet 模型,该模型用于关于巴赫的一个不错的谷歌涂鸦。

这些练习的解决方案可在本章笔记本的末尾找到,网址为https://homl.info/colab3

¹ 请注意,许多研究人员更喜欢在 RNN 中使用双曲正切(tanh)激活函数,而不是 ReLU 激活函数。例如,参见 Vu Pham 等人的2013 年论文“Dropout Improves Recurrent Neural Networks for Handwriting Recognition”。基于 ReLU 的 RNN 也是可能的,正如 Quoc V. Le 等人的2015 年论文“初始化修正线性单元的循环网络的简单方法”中所示。

² Nal Kalchbrenner 和 Phil Blunsom,“循环连续翻译模型”,2013 年经验方法自然语言处理会议论文集(2013):1700–1709。

³ 芝加哥交通管理局的最新数据可在芝加哥数据门户上找到。

⁴ 有其他更有原则的方法来选择好的超参数,基于分析自相关函数(ACF)和偏自相关函数(PACF),或最小化 AIC 或 BIC 指标(在第九章中介绍)以惩罚使用太多参数的模型并减少过拟合数据的风险,但网格搜索是一个很好的起点。有关 ACF-PACF 方法的更多详细信息,请查看 Jason Brownlee 的这篇非常好的文章

⁵ 请注意,验证期从 2019 年 1 月 1 日开始,因此第一个预测是 2019 年 2 月 26 日,八周后。当我们评估基线模型时,我们使用了从 3 月 1 日开始的预测,但这应该足够接近。

⁶ 随意尝试这个模型。例如,您可以尝试预测接下来 14 天的公交和轨道乘客量。您需要调整目标,包括两者,并使您的模型输出 28 个预测,而不是 14 个。

⁷ César Laurent 等人,“批量归一化循环神经网络”,IEEE 国际声学、语音和信号处理会议论文集(2016):2657–2661。

⁸ Jimmy Lei Ba 等人,“层归一化”,arXiv 预印本 arXiv:1607.06450(2016)。

⁹ 更简单的方法是继承自SimpleRNNCell,这样我们就不必创建内部的SimpleRNNCell或处理state_sizeoutput_size属性,但这里的目标是展示如何从头开始创建自定义单元。

¹⁰ 动画电影海底总动员海底奇兵中一个患有短期记忆丧失的角色。

¹¹ Sepp Hochreiter 和 Jürgen Schmidhuber,“长短期记忆”,神经计算 9,第 8 期(1997 年):1735–1780。

¹² Haşim Sak 等,“基于长短期记忆的大词汇语音识别循环神经网络架构”,arXiv 预印本 arXiv:1402.1128(2014 年)。

¹³ Wojciech Zaremba 等,“循环神经网络正则化”,arXiv 预印本 arXiv:1409.2329(2014 年)。

¹⁴ Kyunghyun Cho 等,“使用 RNN 编码器-解码器学习短语表示进行统计机器翻译”,2014 年经验方法自然语言处理会议论文集(2014 年):1724–1734。

¹⁵ 请参阅 Klaus Greff 等的“LSTM:搜索空间奥德赛”IEEE 神经网络与学习系统交易 28,第 10 期(2017 年):2222–2232。这篇论文似乎表明所有 LSTM 变体表现大致相同。

¹⁶ Aaron van den Oord 等,“WaveNet:原始音频的生成模型”,arXiv 预印本 arXiv:1609.03499(2016 年)。

¹⁷ 完整的 WaveNet 使用了更多技巧,例如类似于 ResNet 中的跳过连接和类似于 GRU 单元中的门控激活单元。有关更多详细信息,请参阅本章的笔记本。

第十六章:使用 RNN 和注意力进行自然语言处理

当艾伦·图灵在 1950 年想象他著名的Turing 测试时,他提出了一种评估机器匹配人类智能能力的方法。他本可以测试许多事情,比如识别图片中的猫、下棋、创作音乐或逃离迷宫,但有趣的是,他选择了一项语言任务。更具体地说,他设计了一个聊天机器人,能够愚弄对话者以为它是人类。这个测试确实有其弱点:一组硬编码规则可以愚弄毫无戒心或天真的人类(例如,机器可以对某些关键词给出模糊的预定义答案,可以假装在回答一些最奇怪的问题时开玩笑或喝醉,或者可以通过用自己的问题回答难题来逃避困难的问题),并且许多人类智能的方面完全被忽视(例如,解释非言语交流,如面部表情,或学习手动任务的能力)。但这个测试确实突显了掌握语言可能是智人最伟大的认知能力。

我们能否构建一台能够掌握书面和口头语言的机器?这是自然语言处理研究的终极目标,但实际上研究人员更专注于更具体的任务,比如文本分类、翻译、摘要、问答等等。

自然语言任务的一种常见方法是使用循环神经网络。因此,我们将继续探索循环神经网络(在第十五章中介绍),首先是字符 RNNchar-RNN,训练以预测句子中的下一个字符。这将使我们能够生成一些原创文本。我们将首先使用无状态 RNN(在每次迭代中学习文本的随机部分,没有关于文本其余部分的信息),然后我们将构建有状态 RNN(在训练迭代之间保留隐藏状态,并继续阅读离开的地方,使其能够学习更长的模式)。接下来,我们将构建一个 RNN 来执行情感分析(例如,阅读电影评论并提取评价者对电影的感受),这次将句子视为单词序列,而不是字符。然后我们将展示如何使用 RNN 来构建一个编码器-解码器架构,能够执行神经机器翻译(NMT),将英语翻译成西班牙语。

在本章的第二部分,我们将探索注意机制。正如它们的名字所示,这些是神经网络组件,它们学习选择模型在每个时间步应该关注的输入部分。首先,我们将通过注意机制提高基于 RNN 的编码器-解码器架构的性能。接下来,我们将完全放弃 RNN,并使用一个非常成功的仅注意架构,称为transformer,来构建一个翻译模型。然后,我们将讨论过去几年自然语言处理中一些最重要的进展,包括基于 transformer 的 GPT 和 BERT 等非常强大的语言模型。最后,我将向您展示如何开始使用 Hugging Face 出色的 Transformers 库。

让我们从一个简单而有趣的模型开始,这个模型可以像莎士比亚一样写作(某种程度上)。

使用字符 RNN 生成莎士比亚文本

在一篇著名的2015 年博客文章中,安德烈·卡帕西展示了如何训练一个 RNN 来预测句子中的下一个字符。然后可以使用这个char-RNN逐个字符生成新文本。以下是一个经过训练所有莎士比亚作品后由 char-RNN 模型生成的文本的小样本:

潘达鲁斯:

唉,我想他将会被接近并且这一天

当一点点智慧被获得而从未被喂养时,

而谁不是一条链,是他死亡的主题,

我不应该睡觉。

虽然不是杰作,但仍然令人印象深刻,模型能够学习单词、语法、正确的标点符号等,只是通过学习预测句子中的下一个字符。这是我们的第一个语言模型示例;本章后面讨论的类似(但更强大)的语言模型是现代自然语言处理的核心。在本节的其余部分,我们将逐步构建一个 char-RNN,从创建数据集开始。

创建训练数据集

首先,使用 Keras 方便的 tf.keras.utils.get_file() 函数,让我们下载所有莎士比亚的作品。数据是从 Andrej Karpathy 的char-rnn 项目加载的:

import tensorflow as tf

shakespeare_url = "https://homl.info/shakespeare"  # shortcut URL
filepath = tf.keras.utils.get_file("shakespeare.txt", shakespeare_url)
with open(filepath) as f:
    shakespeare_text = f.read()

让我们打印前几行:

>>> print(shakespeare_text[:80])
First Citizen:
Before we proceed any further, hear me speak.

All:
Speak, speak.

看起来像是莎士比亚的作品!

接下来,我们将使用 tf.keras.layers.TextVectorization 层(在第十三章介绍)对此文本进行编码。我们设置 split="character" 以获得字符级别的编码,而不是默认的单词级别编码,并且我们使用 standardize="lower" 将文本转换为小写(这将简化任务):

text_vec_layer = tf.keras.layers.TextVectorization(split="character",
                                                   standardize="lower")
text_vec_layer.adapt([shakespeare_text])
encoded = text_vec_layer([shakespeare_text])[0]

现在,每个字符都映射到一个整数,从 2 开始。TextVectorization 层将值 0 保留给填充标记,将值 1 保留给未知字符。目前我们不需要这两个标记,所以让我们从字符 ID 中减去 2,并计算不同字符的数量和总字符数:

encoded -= 2  # drop tokens 0 (pad) and 1 (unknown), which we will not use
n_tokens = text_vec_layer.vocabulary_size() - 2  # number of distinct chars = 39
dataset_size = len(encoded)  # total number of chars = 1,115,394

接下来,就像我们在第十五章中所做的那样,我们可以将这个非常长的序列转换为一个窗口的数据集,然后用它来训练一个序列到序列的 RNN。目标将类似于输入,但是向“未来”移动了一个时间步。例如,数据集中的一个样本可能是代表文本“to be or not to b”(没有最后的“e”)的字符 ID 序列,相应的目标是代表文本“o be or not to be”(有最后的“e”,但没有开头的“t”)的字符 ID 序列。让我们编写一个小型实用函数,将字符 ID 的长序列转换为输入/目标窗口对的数据集:

def to_dataset(sequence, length, shuffle=False, seed=None, batch_size=32):
    ds = tf.data.Dataset.from_tensor_slices(sequence)
    ds = ds.window(length + 1, shift=1, drop_remainder=True)
    ds = ds.flat_map(lambda window_ds: window_ds.batch(length + 1))
    if shuffle:
        ds = ds.shuffle(buffer_size=100_000, seed=seed)
    ds = ds.batch(batch_size)
    return ds.map(lambda window: (window[:, :-1], window[:, 1:])).prefetch(1)

这个函数开始得很像我们在第十五章中创建的 to_windows() 自定义实用函数:

  • 它以一个序列作为输入(即编码文本),并创建一个包含所需长度的所有窗口的数据集。

  • 它将长度增加一,因为我们需要下一个字符作为目标。

  • 然后,它会对窗口进行洗牌(可选),将它们分批处理,拆分为输入/输出对,并激活预取。

图 16-1 总结了数据集准备步骤:它展示了长度为 11 的窗口,批量大小为 3。每个窗口的起始索引在其旁边标出。

mls3 1601

图 16-1. 准备一个洗牌窗口的数据集

现在我们准备创建训练集、验证集和测试集。我们将大约使用文本的 90%进行训练,5%用于验证,5%用于测试:

length = 100
tf.random.set_seed(42)
train_set = to_dataset(encoded[:1_000_000], length=length, shuffle=True,
                       seed=42)
valid_set = to_dataset(encoded[1_000_000:1_060_000], length=length)
test_set = to_dataset(encoded[1_060_000:], length=length)
提示

我们将窗口长度设置为 100,但您可以尝试调整它:在较短的输入序列上训练 RNN 更容易更快,但 RNN 将无法学习任何长于 length 的模式,所以不要将其设置得太小。

就是这样!准备数据集是最困难的部分。现在让我们创建模型。

构建和训练 Char-RNN 模型

由于我们的数据集相当大,而建模语言是一个相当困难的任务,我们需要不止一个简单的具有几个循环神经元的 RNN。让我们构建并训练一个由 128 个单元组成的 GRU 层的模型(如果需要,稍后可以尝试调整层数和单元数):

model = tf.keras.Sequential([
    tf.keras.layers.Embedding(input_dim=n_tokens, output_dim=16),
    tf.keras.layers.GRU(128, return_sequences=True),
    tf.keras.layers.Dense(n_tokens, activation="softmax")
])
model.compile(loss="sparse_categorical_crossentropy", optimizer="nadam",
              metrics=["accuracy"])
model_ckpt = tf.keras.callbacks.ModelCheckpoint(
    "my_shakespeare_model", monitor="val_accuracy", save_best_only=True)
history = model.fit(train_set, validation_data=valid_set, epochs=10,
                    callbacks=[model_ckpt])

让我们仔细看看这段代码:

  • 我们使用一个Embedding层作为第一层,用于编码字符 ID(嵌入在第十三章中介绍)。Embedding层的输入维度数是不同字符 ID 的数量,输出维度数是一个可以调整的超参数,我们暂时将其设置为 16。Embedding层的输入将是形状为[批量大小, 窗口长度]的 2D 张量,Embedding层的输出将是形状为[批量大小, 窗口长度, 嵌入大小]的 3D 张量。

  • 我们使用一个Dense层作为输出层:它必须有 39 个单元(n_tokens),因为文本中有 39 个不同的字符,并且我们希望在每个时间步输出每个可能字符的概率。39 个输出概率应该在每个时间步加起来为 1,因此我们将 softmax 激活函数应用于Dense层的输出。

  • 最后,我们编译这个模型,使用"sparse_categorical_crossentropy"损失和 Nadam 优化器,然后训练模型多个 epoch,使用ModelCheckpoint回调来保存训练过程中验证准确性最好的模型。

提示

如果您在启用 GPU 的 Colab 上运行此代码,则训练大约需要一到两个小时。如果您不想等待那么长时间,可以减少 epoch 的数量,但当然模型的准确性可能会降低。如果 Colab 会话超时,请确保快速重新连接,否则 Colab 运行时将被销毁。

这个模型不处理文本预处理,所以让我们将其包装在一个最终模型中,包含tf.keras.layers.TextVectorization层作为第一层,加上一个tf.keras.layers.Lambda层,从字符 ID 中减去 2,因为我们暂时不使用填充和未知标记:

shakespeare_model = tf.keras.Sequential([
    text_vec_layer,
    tf.keras.layers.Lambda(lambda X: X - 2),  # no <PAD> or <UNK> tokens
    model
])

现在让我们用它来预测句子中的下一个字符:

>>> y_proba = shakespeare_model.predict(["To be or not to b"])[0, -1]
>>> y_pred = tf.argmax(y_proba)  # choose the most probable character ID
>>> text_vec_layer.get_vocabulary()[y_pred + 2]
'e'

太好了,模型正确预测了下一个字符。现在让我们使用这个模型假装我们是莎士比亚!

生成虚假的莎士比亚文本

使用 char-RNN 模型生成新文本时,我们可以将一些文本输入模型,让模型预测最有可能的下一个字母,将其添加到文本末尾,然后将扩展后的文本提供给模型猜测下一个字母,依此类推。这被称为贪婪解码。但在实践中,这经常导致相同的单词一遍又一遍地重复。相反,我们可以随机采样下一个字符,概率等于估计的概率,使用 TensorFlow 的tf.random.categorical()函数。这将生成更多样化和有趣的文本。categorical()函数会根据类别对数概率(logits)随机采样随机类别索引。例如:

>>> log_probas = tf.math.log([[0.5, 0.4, 0.1]])  # probas = 50%, 40%, and 10%
>>> tf.random.set_seed(42)
>>> tf.random.categorical(log_probas, num_samples=8)  # draw 8 samples
<tf.Tensor: shape=(1, 8), dtype=int64, numpy=array([[0, 1, 0, 2, 1, 0, 0, 1]])>

为了更好地控制生成文本的多样性,我们可以将 logits 除以一个称为温度的数字,我们可以根据需要进行调整。接近零的温度偏好高概率字符,而高温度使所有字符具有相等的概率。在生成相对严格和精确的文本(如数学方程式)时,通常更喜欢较低的温度,而在生成更多样化和创意性的文本时,更喜欢较高的温度。以下next_char()自定义辅助函数使用这种方法选择要添加到输入文本中的下一个字符:

def next_char(text, temperature=1):
    y_proba = shakespeare_model.predict([text])[0, -1:]
    rescaled_logits = tf.math.log(y_proba) / temperature
    char_id = tf.random.categorical(rescaled_logits, num_samples=1)[0, 0]
    return text_vec_layer.get_vocabulary()[char_id + 2]

接下来,我们可以编写另一个小的辅助函数,它将重复调用next_char()以获取下一个字符并将其附加到给定的文本中:

def extend_text(text, n_chars=50, temperature=1):
    for _ in range(n_chars):
        text += next_char(text, temperature)
    return text

现在我们准备生成一些文本!让我们尝试不同的温度值:

>>> tf.random.set_seed(42)
>>> print(extend_text("To be or not to be", temperature=0.01))
To be or not to be the duke
as it is a proper strange death,
and the
>>> print(extend_text("To be or not to be", temperature=1))
To be or not to behold?

second push:
gremio, lord all, a sistermen,
>>> print(extend_text("To be or not to be", temperature=100))
To be or not to bef ,mt'&o3fpadm!$
wh!nse?bws3est--vgerdjw?c-y-ewznq

莎士比亚似乎正在遭受一场热浪。为了生成更具说服力的文本,一个常见的技术是仅从前 k 个字符中采样,或者仅从总概率超过某个阈值的最小一组顶级字符中采样(这被称为核心采样)。另外,您可以尝试使用波束搜索,我们将在本章后面讨论,或者使用更多的GRU层和更多的神经元每层,训练更长时间,并在需要时添加一些正则化。还要注意,模型目前无法学习比length更长的模式,length只是 100 个字符。您可以尝试将此窗口扩大,但这也会使训练更加困难,即使 LSTM 和 GRU 单元也无法处理非常长的序列。另一种替代方法是使用有状态的 RNN。

有状态的 RNN

到目前为止,我们只使用了无状态的 RNN:在每次训练迭代中,模型从一个全零的隐藏状态开始,然后在每个时间步更新这个状态,在最后一个时间步之后,将其丢弃,因为不再需要。如果我们指示 RNN 在处理训练批次后保留这个最终状态,并将其用作下一个训练批次的初始状态,会怎样呢?这样,模型可以学习长期模式,尽管只通过短序列进行反向传播。这被称为有状态的 RNN。让我们看看如何构建一个。

首先,注意到有状态的 RNN 只有在批次中的每个输入序列从上一个批次中对应序列的确切位置开始时才有意义。因此,我们构建有状态的 RNN 需要做的第一件事是使用顺序且不重叠的输入序列(而不是我们用来训练无状态 RNN 的洗牌和重叠序列)。在创建tf.data.Dataset时,因此在调用window()方法时必须使用shift=length(而不是shift=1)。此外,我们必须调用shuffle()方法。

不幸的是,为有状态的 RNN 准备数据集时,批处理比为无状态的 RNN 更加困难。实际上,如果我们调用batch(32),那么 32 个连续窗口将被放入同一个批次中,接下来的批次将不会继续每个窗口的位置。第一个批次将包含窗口 1 到 32,第二个批次将包含窗口 33 到 64,因此如果您考虑,比如说,每个批次的第一个窗口(即窗口 1 和 33),您会发现它们不是连续的。这个问题的最简单解决方案就是只使用批量大小为 1。以下的to_dataset_for_stateful_rnn()自定义实用函数使用这种策略来为有状态的 RNN 准备数据集:

def to_dataset_for_stateful_rnn(sequence, length):
    ds = tf.data.Dataset.from_tensor_slices(sequence)
    ds = ds.window(length + 1, shift=length, drop_remainder=True)
    ds = ds.flat_map(lambda window: window.batch(length + 1)).batch(1)
    return ds.map(lambda window: (window[:, :-1], window[:, 1:])).prefetch(1)

stateful_train_set = to_dataset_for_stateful_rnn(encoded[:1_000_000], length)
stateful_valid_set = to_dataset_for_stateful_rnn(encoded[1_000_000:1_060_000],
                                                 length)
stateful_test_set = to_dataset_for_stateful_rnn(encoded[1_060_000:], length)

图 16-2 总结了这个函数的主要步骤。

mls3 1602

图 16-2。为有状态的 RNN 准备连续序列片段的数据集

批处理更加困难,但并非不可能。例如,我们可以将莎士比亚的文本分成 32 个等长的文本,为每个文本创建一个连续输入序列的数据集,最后使用tf.data.Dataset.zip(datasets).map(lambda *windows: tf.stack(windows))来创建正确的连续批次,其中批次中的第n个输入序列从上一个批次中的第n个输入序列结束的地方开始(请参阅笔记本获取完整代码)。

现在,让我们创建有状态的 RNN。在创建每个循环层时,我们需要将stateful参数设置为True,因为有状态的 RNN 需要知道批量大小(因为它将为批次中的每个输入序列保留一个状态)。因此,我们必须在第一层中设置batch_input_shape参数。请注意,我们可以将第二维度留空,因为输入序列可以具有任意长度:

model = tf.keras.Sequential([
    tf.keras.layers.Embedding(input_dim=n_tokens, output_dim=16,
                              batch_input_shape=[1, None]),
    tf.keras.layers.GRU(128, return_sequences=True, stateful=True),
    tf.keras.layers.Dense(n_tokens, activation="softmax")
])

在每个时期结束时,我们需要在回到文本开头之前重置状态。为此,我们可以使用一个小的自定义 Keras 回调:

class ResetStatesCallback(tf.keras.callbacks.Callback):
    def on_epoch_begin(self, epoch, logs):
        self.model.reset_states()

现在我们可以编译模型并使用我们的回调函数进行训练:

model.compile(loss="sparse_categorical_crossentropy", optimizer="nadam",
              metrics=["accuracy"])
history = model.fit(stateful_train_set, validation_data=stateful_valid_set,
                    epochs=10, callbacks=[ResetStatesCallback(), model_ckpt])
提示

训练完这个模型后,只能用它来对与训练时相同大小的批次进行预测。为了避免这个限制,创建一个相同的无状态模型,并将有状态模型的权重复制到这个模型中。

有趣的是,尽管 char-RNN 模型只是训练来预测下一个字符,但这看似简单的任务实际上也需要它学习一些更高级的任务。例如,要找到“Great movie, I really”之后的下一个字符,了解到这句话是积极的是有帮助的,所以下一个字符更有可能是“l”(代表“loved”)而不是“h”(代表“hated”)。事实上,OpenAI 的 Alec Radford 和其他研究人员在一篇 2017 年的论文中描述了他们如何在大型数据集上训练了一个类似于大型 char-RNN 模型,并发现其中一个神经元表现出色地作为情感分析分类器:尽管该模型在没有任何标签的情况下进行了训练,但他们称之为情感神经元达到了情感分析基准测试的最新性能。这预示并激励了 NLP 中的无监督预训练。

但在探索无监督预训练之前,让我们将注意力转向单词级模型以及如何在监督方式下用它们进行情感分析。在这个过程中,您将学习如何使用掩码处理可变长度的序列。

情感分析

生成文本可能很有趣且有教育意义,但在实际项目中,自然语言处理的最常见应用之一是文本分类,尤其是情感分析。如果在 MNIST 数据集上进行图像分类是计算机视觉的“Hello world!”,那么在 IMDb 评论数据集上进行情感分析就是自然语言处理的“Hello world!”。IMDb 数据集包含了来自著名的互联网电影数据库的 50,000 条英文电影评论(25,000 条用于训练,25,000 条用于测试),每条评论都有一个简单的二进制目标,表示其是否为负面(0)或正面(1)。就像 MNIST 一样,IMDb 评论数据集之所以受欢迎是有充分理由的:它足够简单,可以在笔记本电脑上在合理的时间内处理,但足够具有挑战性和有趣。

让我们使用 TensorFlow Datasets 库加载 IMDb 数据集(在第十三章中介绍)。我们将使用训练集的前 90%进行训练,剩下的 10%用于验证:

import tensorflow_datasets as tfds

raw_train_set, raw_valid_set, raw_test_set = tfds.load(
    name="imdb_reviews",
    split=["train[:90%]", "train[90%:]", "test"],
    as_supervised=True
)
tf.random.set_seed(42)
train_set = raw_train_set.shuffle(5000, seed=42).batch(32).prefetch(1)
valid_set = raw_valid_set.batch(32).prefetch(1)
test_set = raw_test_set.batch(32).prefetch(1)
提示

如果您愿意,Keras 还包括一个用于加载 IMDb 数据集的函数:tf.keras.datasets.imdb.load_data()。评论已经被预处理为单词 ID 的序列。

让我们检查一些评论:

>>> for review, label in raw_train_set.take(4):
...     print(review.numpy().decode("utf-8"))
...     print("Label:", label.numpy())
...
This was an absolutely terrible movie. Don't be lured in by Christopher [...]
Label: 0
I have been known to fall asleep during films, but this is usually due to [...]
Label: 0
Mann photographs the Alberta Rocky Mountains in a superb fashion, and [...]
Label: 0
This is the kind of film for a snowy Sunday afternoon when the rest of the [...]
Label: 1

有些评论很容易分类。例如,第一条评论中的第一句话包含“terrible movie”这几个词。但在许多情况下,事情并不那么简单。例如,第三条评论一开始是积极的,尽管最终是一个负面评论(标签 0)。

为了为这个任务构建一个模型,我们需要预处理文本,但这次我们将其分成单词而不是字符。为此,我们可以再次使用tf.keras.​lay⁠ers.TextVectorization层。请注意,它使用空格来识别单词边界,在某些语言中可能效果不佳。例如,中文书写不使用单词之间的空格,越南语甚至在单词内部也使用空格,德语经常将多个单词连接在一起,没有空格。即使在英语中,空格也不总是分词的最佳方式:想想“San Francisco”或“#ILoveDeepLearning”。

幸运的是,有解决这些问题的解决方案。在2016 年的一篇论文,爱丁堡大学的 Rico Sennrich 等人探索了几种在子词级别对文本进行标记和去标记化的方法。这样,即使您的模型遇到了以前从未见过的罕见单词,它仍然可以合理地猜测它的含义。例如,即使模型在训练期间从未见过单词“smartest”,如果它学会了单词“smart”并且还学会了后缀“est”表示“最”,它可以推断出“smartest”的含义。作者评估的技术之一是字节对编码(BPE)。BPE 通过将整个训练集拆分为单个字符(包括空格),然后重复合并最频繁的相邻对,直到词汇表达到所需大小。

Google 的 Taku Kudo 在 2018 年发表的一篇论文进一步改进了子词标记化,通常消除了标记化之前需要进行特定于语言的预处理的需要。此外,该论文提出了一种称为子词正则化的新型正则化技术,通过在训练期间在标记化中引入一些随机性来提高准确性和稳健性:例如,“New England”可以被标记为“New”+“England”,或“New”+“Eng”+“land”,或简单地“New England”(只有一个标记)。Google 的SentencePiece项目提供了一个开源实现,该实现在 Taku Kudo 和 John Richardson 的一篇论文中有描述。

TensorFlow Text库还实现了各种标记化策略,包括WordPiece(BPE 的变体),最后但同样重要的是,Hugging Face 的 Tokenizers 库实现了一系列极快的标记化器。

然而,对于英语中的 IMDb 任务,使用空格作为标记边界应该足够好。因此,让我们继续创建一个TextVectorization层,并将其调整到训练集。我们将词汇表限制为 1,000 个标记,包括最常见的 998 个单词以及一个填充标记和一个未知单词的标记,因为很少见的单词不太可能对这个任务很重要,并且限制词汇表大小将减少模型需要学习的参数数量:

vocab_size = 1000
text_vec_layer = tf.keras.layers.TextVectorization(max_tokens=vocab_size)
text_vec_layer.adapt(train_set.map(lambda reviews, labels: reviews))

最后,我们可以创建模型并训练它:

embed_size = 128
tf.random.set_seed(42)
model = tf.keras.Sequential([
    text_vec_layer,
    tf.keras.layers.Embedding(vocab_size, embed_size),
    tf.keras.layers.GRU(128),
    tf.keras.layers.Dense(1, activation="sigmoid")
])
model.compile(loss="binary_crossentropy", optimizer="nadam",
              metrics=["accuracy"])
history = model.fit(train_set, validation_data=valid_set, epochs=2)

第一层是我们刚刚准备的TextVectorization层,接着是一个Embedding层,将单词 ID 转换为嵌入。嵌入矩阵需要每个词汇表中的标记一行(vocab_size),每个嵌入维度一列(此示例使用 128 维,但这是一个可以调整的超参数)。接下来我们使用一个GRU层和一个具有单个神经元和 sigmoid 激活函数的Dense层,因为这是一个二元分类任务:模型的输出将是评论表达对电影积极情绪的估计概率。然后我们编译模型,并在我们之前准备的数据集上进行几个时期的拟合(或者您可以训练更长时间以获得更好的结果)。

遗憾的是,如果运行此代码,通常会发现模型根本无法学习任何东西:准确率保持接近 50%,不比随机机会好。为什么呢?评论的长度不同,因此当TextVectorization层将它们转换为标记 ID 序列时,它使用填充标记(ID 为 0)填充较短的序列,使它们与批次中最长序列一样长。结果,大多数序列以许多填充标记结尾——通常是几十甚至几百个。即使我们使用的是比SimpleRNN层更好的GRU层,它的短期记忆仍然不太好,因此当它经过许多填充标记时,它最终会忘记评论的内容!一个解决方案是用等长的句子批次喂给模型(这也加快了训练速度)。另一个解决方案是让 RNN 忽略填充标记。这可以通过掩码来实现。

掩码

使用 Keras 让模型忽略填充标记很简单:在创建Embedding层时简单地添加mask_zero=True。这意味着所有下游层都会忽略填充标记(其 ID 为 0)。就是这样!如果对先前的模型进行几个时期的重新训练,您会发现验证准确率很快就能达到 80%以上。

这种工作方式是,Embedding层创建一个等于tf.math.not_equal(inputs, 0)掩码张量:它是一个布尔张量,形状与输入相同,如果标记 ID 为 0,则等于False,否则等于True。然后,该掩码张量会被模型自动传播到下一层。如果该层的call()方法有一个mask参数,那么它会自动接收到掩码。这使得该层能够忽略适当的时间步。每个层可能会以不同的方式处理掩码,但通常它们只是忽略被掩码的时间步(即掩码为False的时间步)。例如,当循环层遇到被掩码的时间步时,它只是复制前一个时间步的输出。

接下来,如果该层的supports_masking属性为True,那么掩码会自动传播到下一层。只要层具有supports_masking=True,它就会继续这样传播。例如,当return_sequences=True时,循环层的supports_masking属性为True,但当return_sequences=False时,它为False,因为在这种情况下不再需要掩码。因此,如果您有一个具有多个return_sequences=True的循环层,然后是一个return_sequences=False的循环层的模型,那么掩码将自动传播到最后一个循环层:该层将使用掩码来忽略被掩码的步骤,但不会进一步传播掩码。同样,如果在我们刚刚构建的情感分析模型中创建Embedding层时设置了mask_zero=True,那么GRU层将自动接收和使用掩码,但不会进一步传播,因为return_sequences没有设置为True

提示

一些层在将掩码传播到下一层之前需要更新掩码:它们通过实现compute_mask()方法来实现,该方法接受两个参数:输入和先前的掩码。然后计算更新后的掩码并返回。compute_mask()的默认实现只是返回先前的掩码而没有更改。

许多 Keras 层支持掩码:SimpleRNNGRULSTMBidirectionalDenseTimeDistributedAdd等(都在tf.keras.layers包中)。然而,卷积层(包括Conv1D)不支持掩码——它们如何支持掩码并不明显。

如果掩码一直传播到输出,那么它也会应用到损失上,因此被掩码的时间步将不会对损失产生贡献(它们的损失将为 0)。这假设模型输出序列,这在我们的情感分析模型中并不是这样。

警告

LSTMGRU层具有基于 Nvidia 的 cuDNN 库的优化实现。但是,此实现仅在所有填充标记位于序列末尾时支持遮罩。它还要求您使用几个超参数的默认值:activationrecurrent_activationrecurrent_dropoutunrolluse_biasreset_after。如果不是这种情况,那么这些层将退回到(速度慢得多的)默认 GPU 实现。

如果要实现支持遮罩的自定义层,应在call()方法中添加一个mask参数,并显然使方法使用该遮罩。此外,如果遮罩必须传播到下一层,则应在构造函数中设置self.supports_masking=True。如果必须在传播之前更新遮罩,则必须实现compute_mask()方法。

如果您的模型不以Embedding层开头,可以使用tf.keras.layers.Masking层代替:默认情况下,它将遮罩设置为tf.math.reduce_any(tf.math.not_equal(X, 0), axis=-1),意味着最后一个维度全是零的时间步将在后续层中被遮罩。

使用遮罩层和自动遮罩传播对简单模型效果最好。对于更复杂的模型,例如需要将Conv1D层与循环层混合时,并不总是适用。在这种情况下,您需要显式计算遮罩并将其传递给适当的层,可以使用函数式 API 或子类 API。例如,以下模型与之前的模型等效,只是使用函数式 API 构建,并手动处理遮罩。它还添加了一点辍学,因为之前的模型略微过拟合:

inputs = tf.keras.layers.Input(shape=[], dtype=tf.string)
token_ids = text_vec_layer(inputs)
mask = tf.math.not_equal(token_ids, 0)
Z = tf.keras.layers.Embedding(vocab_size, embed_size)(token_ids)
Z = tf.keras.layers.GRU(128, dropout=0.2)(Z, mask=mask)
outputs = tf.keras.layers.Dense(1, activation="sigmoid")(Z)
model = tf.keras.Model(inputs=[inputs], outputs=[outputs])

遮罩的最后一种方法是使用不规则张量来向模型提供输入。实际上,您只需在创建TextVectorization层时设置ragged=True,以便将输入序列表示为不规则张量:

>>> text_vec_layer_ragged = tf.keras.layers.TextVectorization(
...     max_tokens=vocab_size, ragged=True)
...
>>> text_vec_layer_ragged.adapt(train_set.map(lambda reviews, labels: reviews))
>>> text_vec_layer_ragged(["Great movie!", "This is DiCaprio's best role."])
<tf.RaggedTensor [[86, 18], [11, 7, 1, 116, 217]]>

将这种不规则张量表示与使用填充标记的常规张量表示进行比较:

>>> text_vec_layer(["Great movie!", "This is DiCaprio's best role."])
<tf.Tensor: shape=(2, 5), dtype=int64, numpy=
array([[ 86,  18,   0,   0,   0],
 [ 11,   7,   1, 116, 217]])>

Keras 的循环层内置支持不规则张量,因此您无需执行其他操作:只需在模型中使用此TextVectorization层。无需传递mask_zero=True或显式处理遮罩——这一切都已为您实现。这很方便!但是,截至 2022 年初,Keras 中对不规则张量的支持仍然相对较新,因此存在一些问题。例如,目前无法在 GPU 上运行时将不规则张量用作目标(但在您阅读这些内容时可能已解决)。

无论您喜欢哪种遮罩方法,在训练此模型几个时期后,它将变得非常擅长判断评论是积极的还是消极的。如果使用tf.keras.callbacks.TensorBoard()回调,您可以在 TensorBoard 中可视化嵌入,看到诸如“棒极了”和“惊人”的词逐渐聚集在嵌入空间的一侧,而诸如“糟糕”和“可怕”的词聚集在另一侧。有些词并不像您可能期望的那样积极(至少在这个模型中),比如“好”这个词,可能是因为许多负面评论包含短语“不好”。

重用预训练的嵌入和语言模型

令人印象深刻的是,这个模型能够基于仅有 25,000 条电影评论学习到有用的词嵌入。想象一下,如果我们有数十亿条评论来训练,这些嵌入会有多好!不幸的是,我们没有,但也许我们可以重用在其他(非常)大型文本语料库上训练的词嵌入(例如,亚马逊评论,可在 TensorFlow 数据集上找到)?毕竟,“amazing”这个词无论是用来谈论电影还是其他事物,通常都有相同的含义。此外,也许即使它们是在另一个任务上训练的,嵌入也对情感分析有用:因为“awesome”和“amazing”这样的词有相似的含义,它们很可能会在嵌入空间中聚集,即使是用于预测句子中的下一个词这样的任务。如果所有积极词和所有消极词形成簇,那么这对情感分析将是有帮助的。因此,我们可以不训练词嵌入,而是下载并使用预训练的嵌入,例如谷歌的Word2vec 嵌入,斯坦福的GloVe 嵌入,或 Facebook 的FastText 嵌入

使用预训练词嵌入在几年内很受欢迎,但这种方法有其局限性。特别是,一个词无论上下文如何,都有一个表示。例如,“right”这个词在“left and right”和“right and wrong”中以相同的方式编码,尽管它们表示两个非常不同的含义。为了解决这个限制,Matthew Peters 在 2018 年引入了来自语言模型的嵌入(ELMo):这些是从深度双向语言模型的内部状态中学习到的上下文化词嵌入。与仅在模型中使用预训练嵌入不同,您可以重用预训练语言模型的一部分。

大约在同一时间,Jeremy Howard 和 Sebastian Ruder 的通用语言模型微调(ULMFiT)论文展示了无监督预训练在 NLP 任务中的有效性:作者们使用自监督学习(即从数据自动生成标签)在庞大的文本语料库上训练了一个 LSTM 语言模型,然后在各种任务上进行微调。他们的模型在六个文本分类任务上表现优异(在大多数情况下将错误率降低了 18-24%)。此外,作者们展示了一个仅使用 100 个标记示例进行微调的预训练模型可以达到与从头开始训练 10,000 个示例相同的性能。在 ULMFiT 论文之前,使用预训练模型只是计算机视觉中的常态;在 NLP 领域,预训练仅限于词嵌入。这篇论文标志着 NLP 的一个新时代的开始:如今,重用预训练语言模型已成为常态。

例如,让我们基于通用句子编码器构建一个分类器,这是由谷歌研究人员团队在 2018 年介绍的模型架构。这个模型基于 transformer 架构,我们将在本章后面讨论。方便的是,这个模型可以在 TensorFlow Hub 上找到。

import os
import tensorflow_hub as hub

os.environ["TFHUB_CACHE_DIR"] = "my_tfhub_cache"
model = tf.keras.Sequential([
    hub.KerasLayer("https://tfhub.dev/google/universal-sentence-encoder/4",
                   trainable=True, dtype=tf.string, input_shape=[]),
    tf.keras.layers.Dense(64, activation="relu"),
    tf.keras.layers.Dense(1, activation="sigmoid")
])
model.compile(loss="binary_crossentropy", optimizer="nadam",
              metrics=["accuracy"])
model.fit(train_set, validation_data=valid_set, epochs=10)
提示

这个模型非常庞大,接近 1GB 大小,因此下载可能需要一些时间。默认情况下,TensorFlow Hub 模块保存在临时目录中,并且每次运行程序时都会重新下载。为了避免这种情况,您必须将TFHUB_CACHE_DIR环境变量设置为您选择的目录:模块将保存在那里,只会下载一次。

请注意,TensorFlow Hub 模块 URL 的最后部分指定我们想要模型的第 4 个版本。这种版本控制确保如果 TF Hub 上发布了新的模块版本,它不会破坏我们的模型。方便的是,如果你只在 Web 浏览器中输入这个 URL,你将得到这个模块的文档。

还要注意,在创建hub.KerasLayer时,我们设置了trainable=True。这样,在训练期间,预训练的 Universal Sentence Encoder 会进行微调:通过反向传播调整一些权重。并非所有的 TensorFlow Hub 模块都是可微调的,所以确保查看你感兴趣的每个预训练模块的文档。

训练后,这个模型应该能达到超过 90%的验证准确率。这实际上非常好:如果你尝试自己执行这个任务,你可能只会稍微好一点,因为许多评论中既包含积极的评论,也包含消极的评论。对这些模棱两可的评论进行分类就像抛硬币一样。

到目前为止,我们已经看过使用 char-RNN 进行文本生成,以及使用基于可训练嵌入的单词级 RNN 模型进行情感分析,以及使用来自 TensorFlow Hub 的强大预训练语言模型。在接下来的部分中,我们将探索另一个重要的 NLP 任务:神经机器翻译(NMT)。

神经机器翻译的编码器-解码器网络

让我们从一个简单的NMT 模型开始,它将英语句子翻译成西班牙语(参见图 16-3)。

简而言之,架构如下:英语句子作为输入馈送给编码器,解码器输出西班牙语翻译。请注意,西班牙语翻译也在训练期间作为解码器的输入使用,但是向后移动了一步。换句话说,在训练期间,解码器被给予上一步应该输出的单词作为输入,而不管它实际输出了什么。这被称为“教师强迫”——一种显著加速训练并提高模型性能的技术。对于第一个单词,解码器被给予序列开始(SOS)标记,期望解码器以序列结束(EOS)标记结束句子。

每个单词最初由其 ID 表示(例如,单词“soccer”的 ID 为854)。接下来,一个Embedding层返回单词嵌入。然后这些单词嵌入被馈送给编码器和解码器。

在每一步中,解码器为输出词汇表(即西班牙语)中的每个单词输出一个分数,然后 softmax 激活函数将这些分数转换为概率。例如,在第一步中,“Me”这个词可能有 7%的概率,“Yo”可能有 1%的概率,依此类推。具有最高概率的单词被输出。这非常类似于常规的分类任务,事实上你可以使用"sparse_categorical_crossentropy"损失来训练模型,就像我们在 char-RNN 模型中所做的那样。

mls3 1603

图 16-3。一个简单的机器翻译模型

请注意,在推断时(训练后),你将没有目标句子来馈送给解码器。相反,你需要将它刚刚输出的单词作为上一步的输入,如图 16-4 所示(这将需要一个在图中未显示的嵌入查找)。

提示

在一篇2015 年的论文,Samy Bengio 等人提出逐渐从在训练期间将前一个“目标”标记馈送给解码器转变为将前一个“输出”标记馈送给解码器。

mls3 1604

图 16-4。在推断时,解码器作为输入接收它刚刚在上一个时间步输出的单词

让我们构建并训练这个模型!首先,我们需要下载一个英语/西班牙语句子对的数据集:

url = "https://storage.googleapis.com/download.tensorflow.org/data/spa-eng.zip"
path = tf.keras.utils.get_file("spa-eng.zip", origin=url, cache_dir="datasets",
                               extract=True)
text = (Path(path).with_name("spa-eng") / "spa.txt").read_text()

每行包含一个英语句子和相应的西班牙语翻译,用制表符分隔。我们将从删除西班牙字符“¡”和“¿”开始,TextVectorization层无法处理这些字符,然后我们将解析句子对并对它们进行洗牌。最后,我们将它们分成两个单独的列表,每种语言一个:

import numpy as np

text = text.replace("¡", "").replace("¿", "")
pairs = [line.split("\t") for line in text.splitlines()]
np.random.shuffle(pairs)
sentences_en, sentences_es = zip(*pairs)  # separates the pairs into 2 lists

让我们看一下前三个句子对:

>>> for i in range(3):
...     print(sentences_en[i], "=>", sentences_es[i])
...
How boring! => Qué aburrimiento!
I love sports. => Adoro el deporte.
Would you like to swap jobs? => Te gustaría que intercambiemos los trabajos?

接下来,让我们创建两个TextVectorization层——每种语言一个,并对文本进行调整:

vocab_size = 1000
max_length = 50
text_vec_layer_en = tf.keras.layers.TextVectorization(
    vocab_size, output_sequence_length=max_length)
text_vec_layer_es = tf.keras.layers.TextVectorization(
    vocab_size, output_sequence_length=max_length)
text_vec_layer_en.adapt(sentences_en)
text_vec_layer_es.adapt([f"startofseq {s} endofseq" for s in sentences_es])

这里有几件事需要注意:

  • 我们将词汇表大小限制为 1,000,这相当小。这是因为训练集不是很大,并且使用较小的值将加快训练速度。最先进的翻译模型通常使用更大的词汇表(例如 30,000),更大的训练集(几千兆字节)和更大的模型(数百甚至数千兆字节)。例如,查看赫尔辛基大学的 Opus-MT 模型,或 Facebook 的 M2M-100 模型。

  • 由于数据集中的所有句子最多有 50 个单词,我们将output_sequence_length设置为 50:这样输入序列将自动填充为零,直到它们都是 50 个标记长。如果训练集中有任何超过 50 个标记的句子,它将被裁剪为 50 个标记。

  • 对于西班牙文本,我们在调整TextVectorization层时为每个句子添加“startofseq”和“endofseq”:我们将使用这些词作为 SOS 和 EOS 标记。您可以使用任何其他单词,只要它们不是实际的西班牙单词。

让我们检查两种词汇表中的前 10 个标记。它们以填充标记、未知标记、SOS 和 EOS 标记(仅在西班牙语词汇表中)、然后按频率递减排序的实际单词开始:

>>> text_vec_layer_en.get_vocabulary()[:10]
['', '[UNK]', 'the', 'i', 'to', 'you', 'tom', 'a', 'is', 'he']
>>> text_vec_layer_es.get_vocabulary()[:10]
['', '[UNK]', 'startofseq', 'endofseq', 'de', 'que', 'a', 'no', 'tom', 'la']

接下来,让我们创建训练集和验证集(如果需要,您也可以创建一个测试集)。我们将使用前 100,000 个句子对进行训练,其余用于验证。解码器的输入是西班牙语句子加上一个 SOS 标记前缀。目标是西班牙语句子加上一个 EOS 后缀:

X_train = tf.constant(sentences_en[:100_000])
X_valid = tf.constant(sentences_en[100_000:])
X_train_dec = tf.constant([f"startofseq {s}" for s in sentences_es[:100_000]])
X_valid_dec = tf.constant([f"startofseq {s}" for s in sentences_es[100_000:]])
Y_train = text_vec_layer_es([f"{s} endofseq" for s in sentences_es[:100_000]])
Y_valid = text_vec_layer_es([f"{s} endofseq" for s in sentences_es[100_000:]])

好的,现在我们准备构建我们的翻译模型。我们将使用功能 API,因为模型不是顺序的。它需要两个文本输入——一个用于编码器,一个用于解码器——所以让我们从这里开始:

encoder_inputs = tf.keras.layers.Input(shape=[], dtype=tf.string)
decoder_inputs = tf.keras.layers.Input(shape=[], dtype=tf.string)

接下来,我们需要使用我们之前准备的TextVectorization层对这些句子进行编码,然后为每种语言使用一个Embedding层,其中mask_zero=True以确保自动处理掩码。嵌入大小是一个您可以调整的超参数,像往常一样:

embed_size = 128
encoder_input_ids = text_vec_layer_en(encoder_inputs)
decoder_input_ids = text_vec_layer_es(decoder_inputs)
encoder_embedding_layer = tf.keras.layers.Embedding(vocab_size, embed_size,
                                                    mask_zero=True)
decoder_embedding_layer = tf.keras.layers.Embedding(vocab_size, embed_size,
                                                    mask_zero=True)
encoder_embeddings = encoder_embedding_layer(encoder_input_ids)
decoder_embeddings = decoder_embedding_layer(decoder_input_ids)
提示

当语言共享许多单词时,您可能会获得更好的性能,使用相同的嵌入层用于编码器和解码器。

现在让我们创建编码器并传递嵌入输入:

encoder = tf.keras.layers.LSTM(512, return_state=True)
encoder_outputs, *encoder_state = encoder(encoder_embeddings)

为了保持简单,我们只使用了一个LSTM层,但您可以堆叠几个。我们还设置了return_state=True以获得对层最终状态的引用。由于我们使用了一个LSTM层,实际上有两个状态:短期状态和长期状态。该层分别返回这些状态,这就是为什么我们必须写*encoder_state来将两个状态分组在一个列表中。现在我们可以使用这个(双重)状态作为解码器的初始状态:

decoder = tf.keras.layers.LSTM(512, return_sequences=True)
decoder_outputs = decoder(decoder_embeddings, initial_state=encoder_state)

接下来,我们可以通过具有 softmax 激活函数的Dense层将解码器的输出传递,以获得每个步骤的单词概率:

output_layer = tf.keras.layers.Dense(vocab_size, activation="softmax")
Y_proba = output_layer(decoder_outputs)

就是这样!我们只需要创建 KerasModel,编译它并训练它:

model = tf.keras.Model(inputs=[encoder_inputs, decoder_inputs],
                       outputs=[Y_proba])
model.compile(loss="sparse_categorical_crossentropy", optimizer="nadam",
              metrics=["accuracy"])
model.fit((X_train, X_train_dec), Y_train, epochs=10,
          validation_data=((X_valid, X_valid_dec), Y_valid))

训练后,我们可以使用该模型将新的英语句子翻译成西班牙语。但这并不像调用model.predict()那样简单,因为解码器期望的输入是上一个时间步预测的单词。一种方法是编写一个自定义记忆单元,跟踪先前的输出并在下一个时间步将其馈送给编码器。但为了保持简单,我们可以多次调用模型,每轮预测一个额外的单词。让我们为此编写一个小型实用程序函数:

def translate(sentence_en):
    translation = ""
    for word_idx in range(max_length):
        X = np.array([sentence_en])  # encoder input
        X_dec = np.array(["startofseq " + translation])  # decoder input
        y_proba = model.predict((X, X_dec))[0, word_idx]  # last token's probas
        predicted_word_id = np.argmax(y_proba)
        predicted_word = text_vec_layer_es.get_vocabulary()[predicted_word_id]
        if predicted_word == "endofseq":
            break
        translation += " " + predicted_word
    return translation.strip()

该函数只是逐步预测一个单词,逐渐完成翻译,并在达到 EOS 标记时停止。让我们试试看!

>>> translate("I like soccer")
'me gusta el fútbol'

万岁,它起作用了!嗯,至少对于非常短的句子是这样。如果您尝试使用这个模型一段时间,您会发现它还不是双语的,特别是在处理更长的句子时会遇到困难。例如:

>>> translate("I like soccer and also going to the beach")
'me gusta el fútbol y a veces mismo al bus'

翻译说:“我喜欢足球,有时甚至喜欢公共汽车”。那么你如何改进呢?一种方法是增加训练集的大小,并在编码器和解码器中添加更多的LSTM层。但这只能让你走得更远,所以让我们看看更复杂的技术,从双向循环层开始。

双向 RNN

在每个时间步骤,常规循环层在生成输出之前只查看过去和现在的输入。换句话说,它是因果的,这意味着它不能预测未来。这种类型的 RNN 在预测时间序列时或在序列到序列(seq2seq)模型的解码器中是有意义的。但对于文本分类等任务,或者在 seq2seq 模型的编码器中,通常最好在编码给定单词之前查看下一个单词。

例如,考虑短语“右臂”,“正确的人”和“批评的权利”:要正确编码单词“right”,您需要向前查看。一个解决方案是在相同的输入上运行两个循环层,一个从左到右读取单词,另一个从右到左读取单词,然后在每个时间步骤组合它们的输出,通常通过连接它们。这就是双向循环层的作用(参见图 16-5)。

mls3 1605

图 16-5. 双向循环层

在 Keras 中实现双向循环层,只需将循环层包装在tf.keras.layers.Bidirectional层中。例如,以下Bidirectional层可以用作我们翻译模型中的编码器:

encoder = tf.keras.layers.Bidirectional(
    tf.keras.layers.LSTM(256, return_state=True))
注意

Bidirectional层将创建GRU层的克隆(但是在相反方向),并且将同时运行并连接它们的输出。因此,尽管GRU层有 10 个单元,Bidirectional层将在每个时间步输出 20 个值。

只有一个问题。这一层现在将返回四个状态而不是两个:前向LSTM层的最终短期和长期状态,以及后向LSTM层的最终短期和长期状态。我们不能直接将这个四重状态用作解码器的LSTM层的初始状态,因为它只期望两个状态(短期和长期)。我们不能使解码器双向,因为它必须保持因果关系:否则在训练过程中会作弊,而且不起作用。相反,我们可以连接两个短期状态,并连接两个长期状态:

encoder_outputs, *encoder_state = encoder(encoder_embeddings)
encoder_state = [tf.concat(encoder_state[::2], axis=-1),  # short-term (0 & 2)
                 tf.concat(encoder_state[1::2], axis=-1)]  # long-term (1 & 3)

现在让我们看看另一种在推理时可以极大提高翻译模型性能的流行技术:束搜索。

束搜索

假设您已经训练了一个编码器-解码器模型,并且您使用它将句子“I like soccer”翻译成西班牙语。您希望它会输出正确的翻译“me gusta el fútbol”,但不幸的是它输出了“me gustan los jugadores”,意思是“我喜欢球员”。看着训练集,您注意到许多句子如“I like cars”,翻译成“me gustan los autos”,所以模型在看到“I like”后输出“me gustan los”并不荒谬。不幸的是,在这种情况下是一个错误,因为“soccer”是单数。模型无法回头修正,所以它尽力完成句子,这种情况下使用了“jugadores”这个词。我们如何让模型有机会回头修正之前的错误呢?最常见的解决方案之一是beam search:它跟踪一个最有希望的句子列表(比如说前三个),在每个解码器步骤中尝试扩展它们一个词,只保留* k 个最有可能的句子。参数k被称为beam width*。

例如,假设您使用模型来翻译句子“I like soccer”,使用 beam search 和 beam width 为 3(参见图 16-6)。在第一个解码器步骤中,模型将为翻译句子中每个可能的第一个词输出一个估计概率。假设前三个词是“me”(75%的估计概率),“a”(3%)和“como”(1%)。这是我们目前的短列表。接下来,我们使用模型为每个句子找到下一个词。对于第一个句子(“me”),也许模型为“gustan”这个词输出 36%的概率,“gusta”这个词输出 32%的概率,“encanta”这个词输出 16%的概率,依此类推。请注意,这些实际上是条件概率,假设句子以“me”开头。对于第二个句子(“a”),模型可能为“mi”这个词输出 50%的条件概率,依此类推。假设词汇表有 1,000 个词,我们将得到每个句子 1,000 个概率。

接下来,我们计算我们考虑的 3,000 个两个词的句子的概率(3 × 1,000)。我们通过将每个词的估计条件概率乘以它完成的句子的估计概率来做到这一点。例如,“me”的句子的估计概率为 75%,而“gustan”这个词的估计条件概率(假设第一个词是“me”)为 36%,所以“me gustan”的估计概率为 75% × 36% = 27%。在计算了所有 3,000 个两个词的句子的概率之后,我们只保留前 3 个。在这个例子中,它们都以“me”开头:“me gustan”(27%),“me gusta”(24%)和“me encanta”(12%)。目前,“me gustan”这个句子领先,但“me gusta”还没有被淘汰。

mls3 1606

图 16-6。beam search,beam width 为 3

然后我们重复相同的过程:我们使用模型预测这三个句子中的下一个词,并计算我们考虑的所有 3,000 个三个词的句子的概率。也许现在前三个是“me gustan los”(10%),“me gusta el”(8%)和“me gusta mucho”(2%)。在下一步中,我们可能得到“me gusta el fútbol”(6%),“me gusta mucho el”(1%)和“me gusta el deporte”(0.2%)。请注意,“me gustan”已经被淘汰,正确的翻译现在领先。我们在没有额外训练的情况下提高了我们的编码器-解码器模型的性能,只是更明智地使用它。

提示

TensorFlow Addons 库包含一个完整的 seq2seq API,让您可以构建带有注意力的编码器-解码器模型,包括 beam search 等等。然而,它的文档目前非常有限。实现 beam search 是一个很好的练习,所以试一试吧!查看本章的笔记本,了解可能的解决方案。

通过这一切,您可以为相当短的句子获得相当不错的翻译。不幸的是,这种模型在翻译长句子时会表现得非常糟糕。问题再次出在 RNN 的有限短期记忆上。注意力机制是解决这个问题的划时代创新。

注意力机制

考虑一下从单词“soccer”到其翻译“fútbol”的路径,回到图 16-3:这是相当长的!这意味着这个单词的表示(以及所有其他单词)需要在实际使用之前经过许多步骤。我们难道不能让这条路径变短一点吗?

这是 Dzmitry Bahdanau 等人在一篇具有里程碑意义的2014 年论文中的核心思想,作者在其中介绍了一种技术,允许解码器在每个时间步关注适当的单词(由编码器编码)。例如,在解码器需要输出单词“fútbol”的时间步,它将把注意力集中在单词“soccer”上。这意味着从输入单词到其翻译的路径现在要短得多,因此 RNN 的短期记忆限制对其影响要小得多。注意机制彻底改变了神经机器翻译(以及深度学习一般)的方式,显著改进了技术水平,特别是对于长句子(例如,超过 30 个单词)。

注意

NMT 中最常用的度量标准是双语评估助手(BLEU)分数,它将模型产生的每个翻译与人类产生的几个好翻译进行比较:它计算出现在任何目标翻译中的n-gram(n个单词序列)的数量,并调整分数以考虑在目标翻译中产生的n-gram 的频率。

图 16-7 展示了我们带有注意力机制的编码器-解码器模型。在左侧,您可以看到编码器和解码器。现在,我们不仅在每一步将编码器的最终隐藏状态和前一个目标单词发送给解码器(尽管这仍在进行,但在图中没有显示),还将所有编码器的输出发送给解码器。由于解码器无法一次处理所有这些编码器的输出,因此它们需要被聚合:在每个时间步,解码器的记忆单元计算所有编码器输出的加权和。这决定了它在这一步将关注哪些单词。权重α[(t,i)]是第t个解码器时间步的第i个编码器输出的权重。例如,如果权重α[(3,2)]远大于权重α[(3,0)]和α[(3,1)],那么解码器将在这个时间步更多地关注第 2 个单词(“soccer”)的编码器输出,而不是其他两个输出。解码器的其余部分与之前的工作方式相同:在每个时间步,记忆单元接收我们刚刚讨论的输入,以及来自上一个时间步的隐藏状态,最后(尽管在图中没有表示)它接收来自上一个时间步的目标单词(或在推断时,来自上一个时间步的输出)。

mls3 1607

图 16-7。使用带有注意力模型的编码器-解码器网络的神经机器翻译

但是这些α[(t,i)]权重是从哪里来的呢?嗯,它们是由一个称为对齐模型(或注意力层)的小型神经网络生成的,该模型与其余的编码器-解码器模型一起进行训练。这个对齐模型在图 16-7 的右侧进行了说明。它以一个由单个神经元组成的Dense层开始,处理每个编码器的输出,以及解码器的先前隐藏状态(例如h[(2)])。这一层为每个编码器输出(例如e[(3,] [2)])输出一个分数(或能量):这个分数衡量每个输出与解码器先前隐藏状态的对齐程度。例如,在图 16-7 中,模型已经输出了“me gusta el”(意思是“我喜欢”),所以现在期望一个名词:单词“soccer”是与当前状态最匹配的,所以它得到了一个高分。最后,所有分数都通过 softmax 层,以获得每个编码器输出的最终权重(例如α[(3,2)])。给定解码器时间步的所有权重加起来等于 1。这种特定的注意力机制被称为Bahdanau 注意力(以 2014 年论文的第一作者命名)。由于它将编码器输出与解码器的先前隐藏状态连接起来,因此有时被称为连接注意力(或加性注意力)。

注意

如果输入句子有n个单词,并假设输出句子长度大致相同,那么这个模型将需要计算大约n²个权重。幸运的是,这种二次计算复杂度仍然可行,因为即使是长句子也不会有成千上万个单词。

另一个常见的注意力机制,称为Luong 注意力乘法注意力,是在2015 年提出的,由 Minh-Thang Luong 等人提出⁠¹⁹。因为对齐模型的目标是衡量编码器输出和解码器先前隐藏状态之间的相似性,作者建议简单地计算这两个向量的点积(参见第四章),因为这通常是一个相当好的相似性度量,而现代硬件可以非常高效地计算它。为了实现这一点,两个向量必须具有相同的维度。点积给出一个分数,所有分数(在给定的解码器时间步长上)都通过 softmax 层,以给出最终的权重,就像 Bahdanau 注意力中一样。Luong 等人提出的另一个简化是使用当前时间步的解码器隐藏状态,而不是上一个时间步(即h[(t)]而不是h[(t–1)]),然后直接使用注意力机制的输出(标记为h~(t))来计算解码器的预测,而不是用它来计算解码器当前的隐藏状态。研究人员还提出了一种点积机制的变体,其中编码器输出首先经过一个全连接层(没有偏置项),然后再计算点积。这被称为“一般”点积方法。研究人员将两种点积方法与连接注意力机制(添加一个重新缩放参数向量v)进行了比较,他们观察到点积变体的性能优于连接注意力。因此,连接注意力现在使用较少。这三种注意力机制的方程式总结在方程式 16-1 中。

方程式 16-1。注意力机制

h~(t)=iα(t,i)y(i) with α(t,i)=expe(t,i)i'expe(t,i') and e(t,i)=h(t)y(i)doth(t)Wy(i)generalvtanh(W[h(t);y(i)])concat

Keras 为 Luong attention 提供了tf.keras.layers.Attention层,为 Bahdanau attention 提供了AdditiveAttention层。让我们将 Luong attention 添加到我们的编码器-解码器模型中。由于我们需要将所有编码器的输出传递给Attention层,所以在创建编码器时,我们首先需要设置return_sequences=True

encoder = tf.keras.layers.Bidirectional(
    tf.keras.layers.LSTM(256, return_sequences=True, return_state=True))

接下来,我们需要创建注意力层,并将解码器的状态和编码器的输出传递给它。然而,为了在每一步访问解码器的状态,我们需要编写一个自定义的记忆单元。为简单起见,让我们使用解码器的输出而不是其状态:实际上这也很有效,并且编码更容易。然后我们直接将注意力层的输出传递给输出层,就像 Luong 注意力论文中建议的那样:

attention_layer = tf.keras.layers.Attention()
attention_outputs = attention_layer([decoder_outputs, encoder_outputs])
output_layer = tf.keras.layers.Dense(vocab_size, activation="softmax")
Y_proba = output_layer(attention_outputs)

就是这样!如果训练这个模型,你会发现它现在可以处理更长的句子。例如:

>>> translate("I like soccer and also going to the beach")
'me gusta el fútbol y también ir a la playa'

简而言之,注意力层提供了一种让模型集中注意力于输入的一部分的方法。但是还有另一种方式来思考这个层:它充当了一个可微分的记忆检索机制。

例如,假设编码器分析了输入句子“I like soccer”,并且成功理解了单词“I”是主语,单词“like”是动词,因此在这些单词的输出中编码了这些信息。现在假设解码器已经翻译了主语,并且认为接下来应该翻译动词。为此,它需要从输入句子中提取动词。这类似于字典查找:就好像编码器创建了一个字典{"subject": "They", "verb": "played", ...},解码器想要查找与键“verb”对应的值。

然而,模型没有离散的令牌来表示键(如“主语”或“动词”);相反,它具有这些概念的矢量化表示,这些表示在训练期间学习到,因此用于查找的查询不会完全匹配字典中的任何键。解决方案是计算查询与字典中每个键之间的相似度度量,然后使用 softmax 函数将这些相似度分数转换为总和为 1 的权重。正如我们之前看到的那样,这正是注意力层所做的。如果代表动词的键与查询最相似,那么该键的权重将接近 1。

接下来,注意力层计算相应值的加权和:如果“动词”键的权重接近 1,那么加权和将非常接近单词“played”的表示。

这就是为什么 Keras 的AttentionAdditiveAttention层都期望输入一个包含两个或三个项目的列表:querieskeys,以及可选的values。如果不传递任何值,则它们会自动等于键。因此,再次查看前面的代码示例,解码器输出是查询,编码器输出既是键也是值。对于每个解码器输出(即每个查询),注意力层返回与解码器输出最相似的编码器输出(即键/值)的加权和。

关键是,注意力机制是一个可训练的内存检索系统。它非常强大,以至于您实际上可以仅使用注意力机制构建最先进的模型。进入Transformer架构。

注意力就是你所需要的:原始Transformer架构

在一篇开创性的2017 年论文,⁠²⁰一组谷歌研究人员建议“注意力就是你所需要的”。他们创建了一种称为Transformer的架构,显著改进了 NMT 的最新技术,而不使用任何循环或卷积层,仅使用注意力机制(加上嵌入层、稠密层、归一化层和其他一些部分)。由于该模型不是循环的,所以不像 RNN 那样容易受到梯度消失或梯度爆炸问题的困扰,可以在较少的步骤中训练,更容易在多个 GPU 上并行化,并且可以比 RNN 更好地捕捉长距离模式。原始的 2017 年Transformer架构在图 16-8 中表示。

简而言之,图 16-8 的左侧是编码器,右侧是解码器。每个嵌入层输出一个形状为[批量大小序列长度嵌入大小]的 3D 张量。之后,随着数据流经Transformer,张量逐渐转换,但形状保持不变。

mls3 1608

图 16-8。原始的 2017 年Transformer架构⁠²²

如果您将Transformer用于 NMT,则在训练期间必须将英语句子馈送给编码器,将相应的西班牙语翻译馈送给解码器,并在每个句子开头插入额外的 SOS 令牌。在推理时,您必须多次调用Transformer,逐字产生翻译,并在每轮将部分翻译馈送给解码器,就像我们之前在translate()函数中所做的那样。

编码器的作用是逐渐转换输入——英文句子的单词表示——直到每个单词的表示完美地捕捉到单词的含义,在句子的上下文中。例如,如果你用句子“I like soccer”来喂给编码器,那么单词“like”将以一个相当模糊的表示开始,因为这个单词在不同的上下文中可能有不同的含义:想想“I like soccer”和“It’s like that”。但是经过编码器后,单词的表示应该捕捉到给定句子中“like”的正确含义(即喜欢),以及可能需要用于翻译的任何其他信息(例如,它是一个动词)。

解码器的作用是逐渐将翻译句子中的每个单词表示转换为翻译中下一个单词的单词表示。例如,如果要翻译的句子是“I like soccer”,解码器的输入句子是“ me gusta el fútbol”,那么经过解码器后,“el”的单词表示将最终转换为“fútbol”的表示。同样,“fútbol”的表示将被转换为 EOS 标记的表示。

经过解码器后,每个单词表示都经过一个带有 softmax 激活函数的最终Dense层,希望能够输出正确下一个单词的高概率和所有其他单词的低概率。预测的句子应该是“me gusta el fútbol ”。

那是大局观;现在让我们更详细地走一遍图 16-8:

  • 首先,注意到编码器和解码器都包含被堆叠N次的模块。在论文中,N = 6。整个编码器堆栈的最终输出在每个这些N级别上被馈送到解码器。

  • 放大一下,你会发现你已经熟悉大部分组件:有两个嵌入层;几个跳跃连接,每个连接后面跟着一个层归一化层;几个由两个密集层组成的前馈模块(第一个使用 ReLU 激活函数,第二个没有激活函数);最后输出层是使用 softmax 激活函数的密集层。如果需要的话,你也可以在注意力层和前馈模块之后添加一点 dropout。由于所有这些层都是时间分布的,每个单词都独立于其他所有单词。但是我们如何通过完全分开地查看单词来翻译句子呢?嗯,我们不能,这就是新组件发挥作用的地方:

    • 编码器的多头注意力层通过关注同一句子中的所有其他单词来更新每个单词的表示。这就是单词“like”的模糊表示变得更丰富和更准确的表示的地方,捕捉了它在给定句子中的确切含义。我们将很快讨论这是如何工作的。

    • 解码器的掩码多头注意力层做同样的事情,但当处理一个单词时,它不会关注在它之后的单词:这是一个因果层。例如,当处理单词“gusta”时,它只会关注“ me gusta”这几个单词,而忽略“el fútbol”这几个单词(否则那就是作弊了)。

    • 解码器的上层多头注意力层是解码器关注英文句子中的单词的地方。这被称为交叉注意力,在这种情况下不是自我注意力。例如,当解码器处理单词“el”并将其表示转换为“fútbol”的表示时,解码器可能会密切关注单词“soccer”。

    • 位置编码是密集向量(类似于单词嵌入),表示句子中每个单词的位置。第n个位置编码被添加到每个句子中第n个单词的单词嵌入中。这是因为Transformer架构中的所有层都忽略单词位置:没有位置编码,您可以对输入序列进行洗牌,它只会以相同方式洗牌输出序列。显然,单词的顺序很重要,这就是为什么我们需要以某种方式向Transformer提供位置信息的原因:将位置编码添加到单词表示是实现这一点的好方法。

注意

图 16-8 中每个多头注意力层的前两个箭头代表键和值,第三个箭头代表查询。在自注意力层中,所有三个都等于前一层输出的单词表示,而在解码器的上层注意力层中,键和值等于编码器的最终单词表示,查询等于前一层输出的单词表示。

让我们更详细地了解Transformer架构中的新颖组件,从位置编码开始。

位置编码

位置编码是一个密集向量,用于编码句子中单词的位置:第i个位置编码被添加到句子中第i个单词的单词嵌入中。实现这一点的最简单方法是使用Embedding层,并使其对批处理中从 0 到最大序列长度的所有位置进行编码,然后将结果添加到单词嵌入中。广播规则将确保位置编码应用于每个输入序列。例如,以下是如何将位置编码添加到编码器和解码器输入的方法:

max_length = 50  # max length in the whole training set
embed_size = 128
pos_embed_layer = tf.keras.layers.Embedding(max_length, embed_size)
batch_max_len_enc = tf.shape(encoder_embeddings)[1]
encoder_in = encoder_embeddings + pos_embed_layer(tf.range(batch_max_len_enc))
batch_max_len_dec = tf.shape(decoder_embeddings)[1]
decoder_in = decoder_embeddings + pos_embed_layer(tf.range(batch_max_len_dec))

请注意,此实现假定嵌入表示为常规张量,而不是不规则张量。²³ 编码器和解码器共享相同的Embedding层用于位置编码,因为它们具有相同的嵌入大小(这通常是这种情况)。

Transformer论文的作者选择使用基于正弦和余弦函数在不同频率下的固定位置编码,而不是使用可训练的位置编码。位置编码矩阵P在方程 16-2 中定义,并在图 16-9 的顶部(转置)表示,其中P[p,i]是句子中位于第p位置的单词的编码的第i个分量。

方程 16-2。正弦/余弦位置编码

Pp,i=sin(p/10000i/d)如果i是偶数cos(p/10000(i-1)/d)如果i是奇数mls3 1609

图 16-9。正弦/余弦位置编码矩阵(转置,顶部)关注两个i值(底部)

这个解决方案可以提供与可训练位置编码相同的性能,并且可以扩展到任意长的句子,而不需要向模型添加任何参数(然而,当有大量预训练数据时,通常会优先选择可训练位置编码)。在这些位置编码添加到单词嵌入之后,模型的其余部分可以访问句子中每个单词的绝对位置,因为每个位置都有一个唯一的位置编码(例如,句子中位于第 22 个位置的单词的位置编码由图 16-9 左上角的垂直虚线表示,您可以看到它是唯一的)。此外,选择振荡函数(正弦和余弦)使模型能够学习相对位置。例如,相距 38 个单词的单词(例如,在位置p=22 和p=60 处)在编码维度i=100 和i=101 中始终具有相同的位置编码值,如图 16-9 所示。这解释了为什么我们需要每个频率的正弦和余弦:如果我们只使用正弦(i=100 处的蓝色波),模型将无法区分位置p=22 和p=35(由十字标记)。

在 TensorFlow 中没有PositionalEncoding层,但创建一个并不太困难。出于效率原因,我们在构造函数中预先计算位置编码矩阵。call()方法只是将这个编码矩阵截断到输入序列的最大长度,并将其添加到输入中。我们还设置supports_masking=True以将输入的自动掩码传播到下一层:

class PositionalEncoding(tf.keras.layers.Layer):
    def __init__(self, max_length, embed_size, dtype=tf.float32, **kwargs):
        super().__init__(dtype=dtype, **kwargs)
        assert embed_size % 2 == 0, "embed_size must be even"
        p, i = np.meshgrid(np.arange(max_length),
                           2 * np.arange(embed_size // 2))
        pos_emb = np.empty((1, max_length, embed_size))
        pos_emb[0, :, ::2] = np.sin(p / 10_000 ** (i / embed_size)).T
        pos_emb[0, :, 1::2] = np.cos(p / 10_000 ** (i / embed_size)).T
        self.pos_encodings = tf.constant(pos_emb.astype(self.dtype))
        self.supports_masking = True

    def call(self, inputs):
        batch_max_length = tf.shape(inputs)[1]
        return inputs + self.pos_encodings[:, :batch_max_length]

让我们使用这个层将位置编码添加到编码器的输入中:

pos_embed_layer = PositionalEncoding(max_length, embed_size)
encoder_in = pos_embed_layer(encoder_embeddings)
decoder_in = pos_embed_layer(decoder_embeddings)

现在让我们更深入地看一下Transformer模型的核心,即多头注意力层。

多头注意力

要理解多头注意力层的工作原理,我们首先必须了解它基于的缩放点积注意力层。它的方程式在方程式 16-3 中以矢量化形式显示。它与 Luong 注意力相同,只是有一个缩放因子。

方程式 16-3. 缩放点积注意力

注意力(Q,K,V)=softmaxQK<mi d<mi k<mi e<mi y<mi sV

在这个方程中:

  • Q是包含每个查询的一行的矩阵。其形状为[n[queries], d[keys]],其中n[queries]是查询的数量,d[keys]是每个查询和每个键的维度数量。

  • K是包含每个的一行的矩阵。其形状为[n[keys], d[keys]],其中n[keys]是键和值的数量。

  • V是包含每个的一行的矩阵。其形状为[n[keys], d[values]],其中d[values]是每个值的维度数量。

  • Q K^⊺的形状为[n[queries], n[keys]]:它包含每个查询/键对的一个相似度分数。为了防止这个矩阵过大,输入序列不能太长(我们将在本章后面讨论如何克服这个限制)。softmax 函数的输出具有相同的形状,但所有行的总和为 1。最终输出的形状为[n[queries], d[values]]:每个查询有一行,其中每行代表查询结果(值的加权和)。

  • 缩放因子 1 / (<mi d <mi keys)将相似度分数缩小,以避免饱和 softmax 函数,这会导致梯度很小。

  • 可以通过在计算 softmax 之前,向相应的相似性分数添加一个非常大的负值来屏蔽一些键/值对,这在掩码多头注意力层中非常有用。

如果在创建tf.keras.layers.Attention层时设置use_scale=True,那么它将创建一个额外的参数,让该层学习如何正确地降低相似性分数。Transformer模型中使用的缩放后的点积注意力几乎相同,只是它总是将相似性分数按相同因子缩放,即 1 / (d keys)。

请注意,Attention层的输入就像QKV一样,只是多了一个批处理维度(第一个维度)。在内部,该层仅通过一次调用tf.matmul(queries, keys)计算批处理中所有句子的所有注意力分数:这使得它非常高效。实际上,在 TensorFlow 中,如果AB是具有两个以上维度的张量,比如形状为[2, 3, 4, 5]和[2, 3, 5, 6],那么tf.matmul(A, B)将把这些张量视为 2×3 数组,其中每个单元格包含一个矩阵,并将相应的矩阵相乘:A中第i行和第j列的矩阵将与B中第i行和第j列的矩阵相乘。由于一个 4×5 矩阵与一个 5×6 矩阵的乘积是一个 4×6 矩阵,tf.matmul(A, B)将返回一个形状为[2, 3, 4, 6]的数组。

现在我们准备看一下多头注意力层。其架构如图 16-10 所示。

mls3 1610

图 16-10. 多头注意力层架构

正如您所看到的,它只是一堆缩放后的点积注意力层,每个层之前都有一个值、键和查询的线性变换(即没有激活函数的时间分布密集层)。所有输出都简单地连接在一起,并通过最终的线性变换(再次是时间分布的)。

但是为什么?这种架构背后的直觉是什么?好吧,再次考虑一下句子“I like soccer”中的单词“like”。编码器足够聪明,能够编码它是一个动词的事实。但是单词表示还包括其在文本中的位置,这要归功于位置编码,它可能还包括许多其他对其翻译有用的特征,比如它是现在时。简而言之,单词表示编码了单词的许多不同特征。如果我们只使用一个缩放后的点积注意力层,我们只能一次性查询所有这些特征。

这就是为什么多头注意力层应用多个不同的线性变换值、键和查询:这使得模型能够将单词表示的许多不同特征投影到不同的子空间中,每个子空间都专注于单词的某些特征。也许其中一个线性层将单词表示投影到一个只剩下单词是动词信息的子空间,另一个线性层将提取出它是现在时的事实,依此类推。然后缩放后的点积注意力层实现查找阶段,最后我们将所有结果连接起来并将它们投影回原始空间。

Keras 包括一个tf.keras.layers.MultiHeadAttention层,因此我们现在拥有构建Transformer其余部分所需的一切。让我们从完整的编码器开始,它与图 16-8 中的完全相同,只是我们使用两个块的堆叠(N = 2)而不是六个,因为我们没有一个庞大的训练集,并且我们还添加了一点辍学:

N = 2  # instead of 6
num_heads = 8
dropout_rate = 0.1
n_units = 128  # for the first dense layer in each feedforward block
encoder_pad_mask = tf.math.not_equal(encoder_input_ids, 0)[:, tf.newaxis]
Z = encoder_in
for _ in range(N):
    skip = Z
    attn_layer = tf.keras.layers.MultiHeadAttention(
        num_heads=num_heads, key_dim=embed_size, dropout=dropout_rate)
    Z = attn_layer(Z, value=Z, attention_mask=encoder_pad_mask)
    Z = tf.keras.layers.LayerNormalization()(tf.keras.layers.Add()([Z, skip]))
    skip = Z
    Z = tf.keras.layers.Dense(n_units, activation="relu")(Z)
    Z = tf.keras.layers.Dense(embed_size)(Z)
    Z = tf.keras.layers.Dropout(dropout_rate)(Z)
    Z = tf.keras.layers.LayerNormalization()(tf.keras.layers.Add()([Z, skip]))

这段代码应该大多数都很简单,除了一个问题:掩码。在撰写本文时,MultiHeadAttention层不支持自动掩码,因此我们必须手动处理。我们该如何做?

MultiHeadAttention层接受一个attention_mask参数,这是一个形状为[batch size, max query length, max value length]的布尔张量:对于每个查询序列中的每个标记,这个掩码指示应该关注对应值序列中的哪些标记。我们想告诉MultiHeadAttention层忽略值中的所有填充标记。因此,我们首先使用tf.math.not_equal(encoder_input_ids, 0)计算填充掩码。这将返回一个形状为[batch size, max sequence length]的布尔张量。然后我们使用[:, tf.newaxis]插入第二个轴,得到形状为[batch size, 1, max sequence length]的掩码。这使我们能够在调用MultiHeadAttention层时将此掩码用作attention_mask:由于广播,相同的掩码将用于每个查询中的所有标记。这样,值中的填充标记将被正确忽略。

然而,该层将为每个单独的查询标记计算输出,包括填充标记。我们需要掩盖与这些填充标记对应的输出。回想一下,在Embedding层中我们使用了mask_zero,并且在PositionalEncoding层中我们将supports_masking设置为True,因此自动掩码一直传播到MultiHeadAttention层的输入(encoder_in)。我们可以利用这一点在跳过连接中:实际上,Add层支持自动掩码,因此当我们将Zskip(最初等于encoder_in)相加时,输出将自动正确掩码。天啊!掩码需要比代码更多的解释。

现在开始解码器!再次,掩码将是唯一棘手的部分,所以让我们从那里开始。第一个多头注意力层是一个自注意力层,就像在编码器中一样,但它是一个掩码多头注意力层,这意味着它是因果的:它应该忽略未来的所有标记。因此,我们需要两个掩码:一个填充掩码和一个因果掩码。让我们创建它们:

decoder_pad_mask = tf.math.not_equal(decoder_input_ids, 0)[:, tf.newaxis]
causal_mask = tf.linalg.band_part(  # creates a lower triangular matrix
    tf.ones((batch_max_len_dec, batch_max_len_dec), tf.bool), -1, 0)

填充掩码与我们为编码器创建的掩码完全相同,只是基于解码器的输入而不是编码器的。因果掩码使用tf.linalg.band_part()函数创建,该函数接受一个张量并返回一个将对角线带外的所有值设置为零的副本。通过这些参数,我们得到一个大小为batch_max_len_dec(批处理中输入序列的最大长度)的方阵,左下三角形中为 1,右上角为 0。如果我们将此掩码用作注意力掩码,我们将得到我们想要的:第一个查询标记只会关注第一个值标记,第二个只会关注前两个,第三个只会关注前三个,依此类推。换句话说,查询标记不能关注未来的任何值标记。

现在让我们构建解码器:

encoder_outputs = Z  # let's save the encoder's final outputs
Z = decoder_in  # the decoder starts with its own inputs
for _ in range(N):
    skip = Z
    attn_layer = tf.keras.layers.MultiHeadAttention(
        num_heads=num_heads, key_dim=embed_size, dropout=dropout_rate)
    Z = attn_layer(Z, value=Z, attention_mask=causal_mask & decoder_pad_mask)
    Z = tf.keras.layers.LayerNormalization()(tf.keras.layers.Add()([Z, skip]))
    skip = Z
    attn_layer = tf.keras.layers.MultiHeadAttention(
        num_heads=num_heads, key_dim=embed_size, dropout=dropout_rate)
    Z = attn_layer(Z, value=encoder_outputs, attention_mask=encoder_pad_mask)
    Z = tf.keras.layers.LayerNormalization()(tf.keras.layers.Add()([Z, skip]))
    skip = Z
    Z = tf.keras.layers.Dense(n_units, activation="relu")(Z)
    Z = tf.keras.layers.Dense(embed_size)(Z)
    Z = tf.keras.layers.LayerNormalization()(tf.keras.layers.Add()([Z, skip]))

对于第一个注意力层,我们使用causal_mask & decoder_pad_mask来同时掩盖填充标记和未来标记。因果掩码只有两个维度:它缺少批处理维度,但这没关系,因为广播确保它在批处理中的所有实例中被复制。

对于第二个注意力层,没有特别之处。唯一需要注意的是我们使用encoder_pad_mask而不是decoder_pad_mask,因为这个注意力层使用编码器的最终输出作为其值。

我们快要完成了。我们只需要添加最终的输出层,创建模型,编译它,然后训练它:

Y_proba = tf.keras.layers.Dense(vocab_size, activation="softmax")(Z)
model = tf.keras.Model(inputs=[encoder_inputs, decoder_inputs],
                       outputs=[Y_proba])
model.compile(loss="sparse_categorical_crossentropy", optimizer="nadam",
              metrics=["accuracy"])
model.fit((X_train, X_train_dec), Y_train, epochs=10,
          validation_data=((X_valid, X_valid_dec), Y_valid))

恭喜!您已经从头开始构建了一个完整的 Transformer,并对其进行了自动翻译的训练。这变得相当高级了!

提示

Keras 团队创建了一个新的Keras NLP 项目,其中包括一个 API,可以更轻松地构建Transformer。您可能还对新的Keras CV 项目(用于计算机视觉)感兴趣。

但领域并没有就此停止。现在让我们来探讨一些最近的进展。

Transformer模型的大量涌现

2018 年被称为 NLP 的“ImageNet 时刻”。从那时起,进展一直令人震惊,基于巨大数据集训练的基于Transformer的架构越来越大。

首先,Alec Radford 和其他 OpenAI 研究人员的GPT 论文再次展示了无监督预训练的有效性,就像 ELMo 和 ULMFiT 论文之前一样,但这次使用了类似Transformer的架构。作者们预训练了一个由 12 个Transformer模块堆叠而成的大型但相当简单的架构,只使用了像原始Transformer解码器中的掩码多头注意力层。他们在一个非常庞大的数据集上进行了训练,使用了我们用于莎士比亚 char-RNN 的相同自回归技术:只需预测下一个标记。这是一种自监督学习形式。然后,他们对各种语言任务进行了微调,每个任务只进行了轻微的调整。这些任务非常多样化:它们包括文本分类、蕴涵(句子 A 是否对句子 B 施加、涉及或暗示必要的后果)、相似性(例如,“今天天气很好”与“阳光明媚”非常相似)和问答(给定一些提供一些背景的文本段落,模型必须回答一些多项选择题)。

然后谷歌的BERT 论文出现了:它也展示了在大型语料库上进行自监督预训练的有效性,使用了与 GPT 类似的架构,但只使用了非掩码多头注意力层,就像原始Transformer的编码器中一样。这意味着模型是自然双向的;因此 BERT 中的 B(来自Transformer的双向编码器表示)。最重要的是,作者提出了两个预训练任务,解释了模型大部分的强度:

掩码语言模型(MLM)

句子中的每个单词有 15%的概率被掩盖,模型经过训练,以预测被掩盖的单词。例如,如果原始句子是“她在生日聚会上玩得很开心”,那么模型可能会得到句子“她聚会上玩得很开心”,它必须预测单词“had”和“birthday”(其他输出将被忽略)。更准确地说,每个选择的单词有 80%的概率被掩盖,10%的概率被替换为随机单词(为了减少预训练和微调之间的差异,因为模型在微调过程中不会看到标记),以及 10%的概率被保留(以偏向模型正确答案)。

下一个句子预测(NSP)

该模型经过训练,以预测两个句子是否连续。例如,它应该预测“狗在睡觉”和“它打呼噜”是连续的句子,而“狗在睡觉”和“地球绕着太阳转”不是连续的。后来的研究表明,NSP 并不像最初认为的那么重要,因此在大多数后来的架构中被放弃了。

该模型同时在这两个任务上进行训练(参见图 16-11)。对于 NSP 任务,作者在每个输入的开头插入了一个类标记(),相应的输出标记表示模型的预测:句子 B 跟在句子 A 后面,或者不是。这两个输入句子被连接在一起,只用一个特殊的分隔标记()分开,然后作为输入提供给模型。为了帮助模型知道每个输入标记属于哪个句子,每个标记的位置嵌入上面添加了一个段嵌入:只有两种可能的段嵌入,一个用于句子 A,一个用于句子 B。对于 MLM 任务,一些输入词被屏蔽(正如我们刚才看到的),模型试图预测这些词是什么。损失仅在 NSP 预测和被屏蔽的标记上计算,而不是在未被屏蔽的标记上。

mls3 1611

图 16-11. BERT 训练和微调过程

在对大量文本进行无监督预训练阶段之后,该模型然后在许多不同的任务上进行微调,每个任务的变化都很小。例如,对于文本分类(如情感分析),所有输出标记都被忽略,除了第一个,对应于类标记,一个新的输出层取代了以前的输出层,以前的输出层只是一个用于 NSP 的二元分类层。

2019 年 2 月,就在 BERT 发布几个月后,Alec Radford、Jeffrey Wu 和其他 OpenAI 研究人员发表了GPT-2 论文,提出了一个与 GPT 非常相似但规模更大的架构(超过 15 亿个参数!)。研究人员展示了新改进的 GPT 模型可以进行零样本学习(ZSL),这意味着它可以在许多任务上取得良好的表现而无需任何微调。这只是朝着更大更大模型的竞赛的开始:谷歌的Switch Transformers(2021 年 1 月推出)使用了 1 万亿个参数,很快就会推出更大的模型,比如 2021 年 6 月北京人工智能学会(BAII)宣布的 Wu Dao 2.0 模型。

巨型模型的这种趋势不幸地导致只有经济实力雄厚的组织才能负担得起训练这样的模型:成本很容易就能达到几十万美元甚至更高。训练单个模型所需的能量相当于一个美国家庭几年的电力消耗;这一点根本不环保。许多这些模型甚至太大,无法在常规硬件上使用:它们无法适应内存,速度也会非常慢。最后,有些模型成本如此之高,以至于不会公开发布。

幸运的是,聪明的研究人员正在找到新的方法来缩小Transformer并使其更具数据效率。例如,2019 年 10 月由 Hugging Face 的 Victor Sanh 等人推出的DistilBERT 模型是基于 BERT 的一个小型快速Transformer模型。它可以在 Hugging Face 出色的模型中心找到,其中包括成千上万个其他模型——本章后面将会看到一个示例。

DistilBERT 是使用蒸馏(因此得名)进行训练的:这意味着将知识从一个教师模型转移到一个通常比教师模型小得多的学生模型。通常通过使用教师对每个训练实例的预测概率作为学生的目标来实现。令人惊讶的是,蒸馏通常比在相同数据集上从头开始训练学生更有效!事实上,学生受益于教师更加微妙的标签。

在 BERT 之后,还有更多的 transformer 架构陆续推出,几乎每个月都有,通常在所有 NLP 任务的最新技术上有所改进:XLNet(2019 年 6 月),RoBERTa(2019 年 7 月),StructBERT(2019 年 8 月),ALBERT(2019 年 9 月),T5(2019 年 10 月),ELECTRA(2020 年 3 月),GPT3(2020 年 5 月),DeBERTa(2020 年 6 月),Switch Transformers(2021 年 1 月),Wu Dao 2.0(2021 年 6 月),Gopher(2021 年 12 月),GPT-NeoX-20B(2022 年 2 月),Chinchilla(2022 年 3 月),OPT(2022 年 5 月),等等。每个模型都带来了新的想法和技术,但我特别喜欢谷歌研究人员的T5 论文:它将所有 NLP 任务都框定为文本到文本,使用编码器-解码器 transformer。例如,要将“I like soccer”翻译成西班牙语,您只需用输入句子“translate English to Spanish: I like soccer”调用模型,它会输出“me gusta el fútbol”。要总结一段文字,您只需输入“summarize:”后跟段落,它会输出摘要。对于分类,只需将前缀更改为“classify:”,模型会输出类名,作为文本。这简化了使用模型,也使其能够在更多任务上进行预训练。

最后但并非最不重要的是,在 2022 年 4 月,谷歌研究人员使用了一个名为Pathways的新大规模训练平台(我们将在第十九章中简要讨论),来训练一个名为Pathways 语言模型(PaLM),拥有惊人的 5400 亿个参数,使用了超过 6000 个 TPU。除了其令人难以置信的规模之外,这个模型是一个标准的 transformer,只使用解码器(即,带有掩码多头注意力层),只有一些微调(详细信息请参阅论文)。这个模型在各种 NLP 任务中取得了令人难以置信的表现,特别是在自然语言理解(NLU)方面。它能够完成令人印象深刻的壮举,比如解释笑话,给出详细的逐步回答问题的答案,甚至编码。这在一定程度上归功于模型的规模,也归功于一种称为思维链提示的技术,这种技术是几个月前由另一个谷歌研究团队引入的。

在问答任务中,常规提示通常包括一些问题和答案的示例,例如:“Q: Roger 有 5 个网球。他买了 2 罐网球。每罐有 3 个网球。他现在有多少网球?A: 11。”然后提示继续提出实际问题,比如“Q: John 照顾 10 只狗。每只狗每天需要 0.5 小时散步和照顾自己的事务。他每周花多少时间照顾狗?A:”,模型的任务是附加答案:在这种情况下是“35”。

但是通过思维链提示,示例答案包括导致结论的所有推理步骤。例如,不是“A: 11”,提示包含“A: Roger 从 5 个球开始。2 罐每罐 3 个网球,总共 6 个网球。5 + 6 = 11。”这鼓励模型给出对实际问题的详细答案,比如“John 照顾 10 只狗。每只狗每天需要 0.5 小时散步和照顾自己的事务。所以是 10 × 0.5 = 5 小时每天。5 小时每天 × 7 天每周 = 35 小时每周。答案是每周 35 小时。”这是论文中的一个实际例子!

这个模型不仅比使用常规提示更频繁地给出正确答案——我们鼓励模型深思熟虑——而且还提供了所有推理步骤,这对于更好地理解模型答案背后的原理是有用的。

transformers 已经在 NLP 领域占据了主导地位,但它们并没有止步于此:它们很快也扩展到了计算机视觉领域。

视觉 transformers

注意机制在 NMT 之外的第一个应用是使用视觉注意力生成图像字幕:一个卷积神经网络首先处理图像并输出一些特征图,然后一个带有注意力机制的解码器 RNN 逐个单词生成字幕。

在每个解码器时间步骤(即每个单词),解码器使用注意力模型专注于图像的正确部分。例如,在图 16-12 中,模型生成了字幕“A woman is throwing a frisbee in a park”,您可以看到当解码器准备输出单词“frisbee”时,它的注意力集中在输入图像的哪个部分:显然,它的大部分注意力集中在飞盘上。

mls3 1612

图 16-12。视觉注意力:输入图像(左)和生成单词“frisbee”之前模型的焦点(右)⁠

当 transformers 在 2017 年问世并且人们开始在 NLP 之外进行实验时,它们最初是与 CNN 一起使用的,而不是取代它们。相反,transformers 通常用来取代 RNN,例如在图像字幕模型中。在2020 年的一篇论文中,Facebook 的研究人员提出了一个混合 CNN-transformer 架构用于目标检测。再次,CNN 首先处理输入图像并输出一组特征图,然后这些特征图被转换为序列并馈送到 transformer 中,transformer 输出边界框预测。但是,大部分视觉工作仍然由 CNN 完成。

然后,在 2020 年 10 月,一组谷歌研究人员发布了一篇论文,介绍了一种完全基于 transformer 的视觉模型,称为vision transformer(ViT)。这个想法非常简单:只需将图像切成小的 16×16 的方块,并将方块序列视为单词表示的序列。更准确地说,方块首先被展平为 16×16×3=768 维向量——3 代表 RGB 颜色通道——然后这些向量经过一个线性层进行转换但保留其维度。然后产生的向量序列可以像单词嵌入序列一样处理:这意味着添加位置嵌入,并将结果传递给 transformer。就是这样!这个模型在 ImageNet 图像分类上击败了现有技术,但公平地说,作者们必须使用超过 3 亿张额外的图像进行训练。这是有道理的,因为 transformer 没有像卷积神经网络那样多的归纳偏差,所以它们需要额外的数据来学习 CNN 隐含假设中的东西。

注意

归纳偏差是模型由于其架构而做出的隐含假设。例如,线性模型隐含地假设数据是线性的。CNN 隐含地假设在一个位置学习到的模式在其他位置也可能有用。RNN 隐含地假设输入是有序的,并且最近的标记比较重要。模型具有的归纳偏差越多,假设它们是正确的,模型所需的训练数据就越少。但是,如果隐含的假设是错误的,那么即使在大型数据集上训练,模型也可能表现不佳。

仅仅两个月后,Facebook 的一个研究团队发布了一篇论文,介绍了数据高效图像变换器(DeiTs)。他们的模型在 ImageNet 上取得了竞争性的结果,而无需额外的训练数据。该模型的架构与原始 ViT 几乎相同,但作者使用了一种蒸馏技术,将来自最先进的 CNN 模型的知识转移到他们的模型中。

然后,2021 年 3 月,DeepMind 发布了一篇重要的论文,介绍了Perceiver架构。这是一种多模态Transformer,意味着您可以向其提供文本、图像、音频或几乎任何其他模态。直到那时,Transformer由于注意力层中的性能和 RAM 瓶颈而被限制在相当短的序列中。这排除了音频或视频等模态,并迫使研究人员将图像视为补丁序列,而不是像素序列。瓶颈是由于自我注意力,其中每个标记必须关注每个其他标记:如果输入序列有M个标记,那么注意力层必须计算一个M×M矩阵,如果M非常大,这可能会很大。Perceiver 通过逐渐改进由N个标记组成的输入的相当短的潜在表示来解决这个问题——通常只有几百个。 (潜在一词表示隐藏或内部。)该模型仅使用交叉注意力层,将潜在表示作为查询输入,并将(可能很大的)输入作为值输入。这只需要计算一个M×N矩阵,因此计算复杂度与M线性相关,而不是二次的。经过几个交叉注意力层后,如果一切顺利,潜在表示最终会捕捉到输入中的所有重要内容。作者还建议在连续的交叉注意力层之间共享权重:如果这样做,那么 Perceiver 实际上就变成了一个 RNN。实际上,共享的交叉注意力层可以被看作是不同时间步的相同记忆单元,而潜在表示对应于单元的上下文向量。相同的输入会在每个时间步骤中重复馈送到记忆单元。看来 RNN 并没有完全消亡!

仅仅一个月后,Mathilde Caron 等人介绍了DINO,一个令人印象深刻的视觉变换器,完全不使用标签进行训练,使用自我监督,并能够进行高精度的语义分割。该模型在训练期间被复制,其中一个网络充当教师,另一个充当学生。梯度下降仅影响学生,而教师的权重只是学生权重的指数移动平均值。学生被训练以匹配教师的预测:由于它们几乎是相同的模型,这被称为自蒸馏。在每个训练步骤中,输入图像以不同方式增强教师和学生,因此它们不会看到完全相同的图像,但它们的预测必须匹配。这迫使它们提出高级表示。为了防止模式坍塌,即学生和教师总是输出相同的内容,完全忽略输入,DINO 跟踪教师输出的移动平均值,并调整教师的预测,以确保它们平均保持在零点上。DINO 还迫使教师对其预测具有高置信度:这被称为锐化。这些技术共同保留了教师输出的多样性。

在一篇 2021 年的论文中,Google 研究人员展示了如何根据数据量来扩展或缩小 ViTs。他们成功创建了一个庞大的 20 亿参数模型,在 ImageNet 上达到了超过 90.4%的 top-1 准确率。相反,他们还训练了一个缩小模型,在 ImageNet 上达到了超过 84.8%的 top-1 准确率,只使用了 1 万张图像:每类只有 10 张图像!

视觉 transformers 的进展一直在稳步进行。例如,2022 年 3 月,Mitchell Wortsman 等人的一篇论文展示了首先训练多个 transformers,然后平均它们的权重以创建一个新的改进模型是可能的。这类似于集成(见第七章),只是最终只有一个模型,这意味着没有推理时间惩罚。

transformers 领域的最新趋势在于构建大型多模态模型,通常能够进行零样本或少样本学习。例如,OpenAI 的 2021 年 CLIP 论文提出了一个大型 transformer 模型,预训练以匹配图像的标题:这个任务使其能够学习出色的图像表示,然后该模型可以直接用于诸如使用简单文本提示进行图像分类的任务,比如“一张猫的照片”。不久之后,OpenAI 宣布了DALL·E,能够根据文本提示生成惊人的图像。DALL·E 2生成更高质量的图像,使用扩散模型(见第十七章)。

2022 年 4 月,DeepMind 发布了Flamingo paper,介绍了一系列在多种任务和多种模态下预训练的模型,包括文本、图像和视频。一个模型可以用于非常不同的任务,比如问答、图像描述等。不久之后,2022 年 5 月,DeepMind 推出了GATO,一个多模态模型,可以作为强化学习代理的策略(强化学习将在第十八章介绍)。同一个 transformer 可以与您聊天,为图像加注释,玩 Atari 游戏,控制(模拟的)机械臂等,所有这些只需“仅有”12 亿个参数。冒险还在继续!

注意

这些惊人的进步使一些研究人员认为人类水平的 AI 已经近在眼前,认为“规模就是一切”,并且一些模型可能“稍微有意识”。其他人指出,尽管取得了惊人的进步,这些模型仍然缺乏人类智能的可靠性和适应性,我们推理的符号能力,基于单个例子进行泛化的能力等等。

正如您所看到的,transformers 无处不在!好消息是,通常您不必自己实现 transformers,因为许多优秀的预训练模型可以通过 TensorFlow Hub 或 Hugging Face 的模型中心轻松下载。您已经看到如何使用 TF Hub 中的模型,所以让我们通过快速查看 Hugging Face 的生态系统来结束本章。

Hugging Face 的 Transformers 库

今天谈论 transformers 时不可能不提到 Hugging Face,这是一家为 NLP、视觉等构建了一整套易于使用的开源工具的人工智能公司。他们生态系统的核心组件是 Transformers 库,它允许您轻松下载一个预训练模型,包括相应的分词器,然后根据需要在自己的数据集上进行微调。此外,该库支持 TensorFlow、PyTorch 和 JAX(使用 Flax 库)。

使用 Transformers 库的最简单方法是使用transformers.pipeline()函数:只需指定您想要的任务,比如情感分析,它会下载一个默认的预训练模型,准备好使用——真的再简单不过了:

from transformers import pipeline

classifier = pipeline("sentiment-analysis")  # many other tasks are available
result = classifier("The actors were very convincing".)

结果是一个 Python 列表,每个输入文本对应一个字典:

>>> result
[{'label': 'POSITIVE', 'score': 0.9998071789741516}]

在此示例中,模型正确地发现句子是积极的,置信度约为 99.98%。当然,您也可以将一批句子传递给模型:

>>> classifier(["I am from India.", "I am from Iraq."])
[{'label': 'POSITIVE', 'score': 0.9896161556243896},
 {'label': 'NEGATIVE', 'score': 0.9811071157455444}]

pipeline()函数使用给定任务的默认模型。例如,对于文本分类任务,如情感分析,在撰写本文时,默认为distilbert-base-uncased-finetuned-sst-2-english——一个在英文维基百科和英文书籍语料库上训练的带有小写标记器的 DistilBERT 模型,并在斯坦福情感树库 v2(SST 2)任务上进行了微调。您也可以手动指定不同的模型。例如,您可以使用在多种自然语言推理(MultiNLI)任务上进行微调的 DistilBERT 模型,该任务将两个句子分类为三类:矛盾、中性或蕴含。以下是如何操作:

>>> model_name = "huggingface/distilbert-base-uncased-finetuned-mnli"
>>> classifier_mnli = pipeline("text-classification", model=model_name)
>>> classifier_mnli("She loves me. [SEP] She loves me not.")
[{'label': 'contradiction', 'score': 0.9790192246437073}]
提示

您可以在https://huggingface.co/models找到可用的模型,以及在https://huggingface.co/tasks找到任务列表。

pipeline API 非常简单方便,但有时您需要更多控制。对于这种情况,Transformers 库提供了许多类,包括各种标记器、模型、配置、回调等。例如,让我们使用TFAutoModelForSequenceClassificationAutoTokenizer类加载相同的 DistilBERT 模型及其对应的标记器:

from transformers import AutoTokenizer, TFAutoModelForSequenceClassification

tokenizer = AutoTokenizer.from_pretrained(model_name)
model = TFAutoModelForSequenceClassification.from_pretrained(model_name)

接下来,让我们标记一对句子。在此代码中,我们激活填充,并指定我们希望使用 TensorFlow 张量而不是 Python 列表:

token_ids = tokenizer(["I like soccer. [SEP] We all love soccer!",
                       "Joe lived for a very long time. [SEP] Joe is old."],
                      padding=True, return_tensors="tf")
提示

在将"Sentence 1 [SEP] Sentence 2"传递给标记器时,您可以等效地传递一个元组:("Sentence 1", "Sentence 2")

输出是BatchEncoding类的类似字典实例,其中包含标记 ID 序列,以及包含填充标记的掩码为 0:

>>> token_ids
{'input_ids': <tf.Tensor: shape=(2, 15), dtype=int32, numpy=
array([[ 101, 1045, 2066, 4715, 1012,  102, 2057, 2035, 2293, 4715,  999,
 102,    0,    0,    0],
 [ 101, 3533, 2973, 2005, 1037, 2200, 2146, 2051, 1012,  102, 3533,
 2003, 2214, 1012,  102]], dtype=int32)>,
 'attention_mask': <tf.Tensor: shape=(2, 15), dtype=int32, numpy=
array([[1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 0, 0],
 [1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1]], dtype=int32)>}

当调用标记器时设置return_token_type_ids=True,您还将获得一个额外的张量,指示每个标记属于哪个句子。这对某些模型是必需的,但对 DistilBERT 不是。

接下来,我们可以直接将这个BatchEncoding对象传递给模型;它返回一个包含其预测类 logits 的TFSequenceClassifierOutput对象:

>>> outputs = model(token_ids)
>>> outputs
TFSequenceClassifierOutput(loss=None, logits=[<tf.Tensor: [...] numpy=
array([[-2.1123817 ,  1.1786783 ,  1.4101017 ],
 [-0.01478387,  1.0962474 , -0.9919954 ]], dtype=float32)>], [...])

最后,我们可以应用 softmax 激活函数将这些 logits 转换为类概率,并使用argmax()函数预测每个输入句子对的具有最高概率的类:

>>> Y_probas = tf.keras.activations.softmax(outputs.logits)
>>> Y_probas
<tf.Tensor: shape=(2, 3), dtype=float32, numpy=
array([[0.01619702, 0.43523544, 0.5485676 ],
 [0.08672056, 0.85204804, 0.06123142]], dtype=float32)>
>>> Y_pred = tf.argmax(Y_probas, axis=1)
>>> Y_pred  # 0 = contradiction, 1 = entailment, 2 = neutral
<tf.Tensor: shape=(2,), dtype=int64, numpy=array([2, 1])>

在此示例中,模型正确将第一对句子分类为中性(我喜欢足球并不意味着每个人都喜欢),将第二对句子分类为蕴含(乔确实应该很老)。

如果您希望在自己的数据集上微调此模型,您可以像通常使用 Keras 一样训练模型,因为它只是一个常规的 Keras 模型,具有一些额外的方法。但是,由于模型输出的是 logits 而不是概率,您必须使用tf.keras.losses.SparseCategoricalCrossentropy(from_logits=True)损失,而不是通常的"sparse_categorical_crossentropy"损失。此外,模型在训练期间不支持BatchEncoding输入,因此您必须使用其data属性来获取一个常规字典:

sentences = [("Sky is blue", "Sky is red"), ("I love her", "She loves me")]
X_train = tokenizer(sentences, padding=True, return_tensors="tf").data
y_train = tf.constant([0, 2])  # contradiction, neutral
loss = tf.keras.losses.SparseCategoricalCrossentropy(from_logits=True)
model.compile(loss=loss, optimizer="nadam", metrics=["accuracy"])
history = model.fit(X_train, y_train, epochs=2)

Hugging Face 还构建了一个 Datasets 库,您可以使用它轻松下载标准数据集(如 IMDb)或自定义数据集,并用它来微调您的模型。它类似于 TensorFlow Datasets,但还提供了在运行时执行常见预处理任务的工具,如掩码。数据集列表可在https://huggingface.co/datasets上找到。

这应该让您开始使用 Hugging Face 的生态系统。要了解更多信息,您可以前往https://huggingface.co/docs查看文档,其中包括许多教程笔记本、视频、完整 API 等。我还建议您查看 O'Reilly 图书使用 Hugging Face 构建自然语言处理应用的Transformer,作者是来自 Hugging Face 团队的 Lewis Tunstall、Leandro von Werra 和 Thomas Wolf。

在下一章中,我们将讨论如何使用自编码器以无监督的方式学习深度表示,并使用生成对抗网络来生成图像等!

练习

  1. 使用有状态 RNN 与无状态 RNN 的优缺点是什么?

  2. 为什么人们使用编码器-解码器 RNN 而不是普通的序列到序列 RNN 进行自动翻译?

  3. 如何处理可变长度的输入序列?可变长度的输出序列呢?

  4. 什么是波束搜索,为什么要使用它?可以使用什么工具来实现它?

  5. 什么是注意力机制?它如何帮助?

  6. Transformer架构中最重要的层是什么?它的目的是什么?

  7. 什么时候需要使用采样 softmax?

  8. 嵌入 Reber 语法被 Hochreiter 和 Schmidhuber 在关于 LSTMs 的论文中使用。它们是产生诸如“BPBTSXXVPSEPE”之类字符串的人工语法。查看 Jenny Orr 的关于这个主题的很好介绍,然后选择一个特定的嵌入 Reber 语法(例如 Orr 页面上表示的那个),然后训练一个 RNN 来识别一个字符串是否符合该语法。您首先需要编写一个能够生成包含约 50%符合语法的字符串和 50%不符合语法的字符串的训练批次的函数。

  9. 训练一个能够将日期字符串从一种格式转换为另一种格式的编码器-解码器模型(例如,从“2019 年 4 月 22 日”到“2019-04-22”)。

  10. 浏览 Keras 网站上关于“使用双编码器进行自然语言图像搜索”的示例。您将学习如何构建一个能够在同一嵌入空间内表示图像和文本的模型。这使得可以使用文本提示搜索图像,就像 OpenAI 的 CLIP 模型一样。

  11. 使用 Hugging Face Transformers 库下载一个预训练的语言模型,能够生成文本(例如,GPT),并尝试生成更具说服力的莎士比亚文本。您需要使用模型的generate()方法-请参阅 Hugging Face 的文档以获取更多详细信息。

这些练习的解决方案可在本章笔记本的末尾找到,网址为https://homl.info/colab3

艾伦·图灵,“计算机机器和智能”,心灵 49(1950 年):433-460。

当然,单词chatbot出现得更晚。图灵称其测试为模仿游戏:机器 A 和人类 B 通过文本消息与人类审问者 C 聊天;审问者提出问题以确定哪一个是机器(A 还是 B)。如果机器能够愚弄审问者,那么它通过了测试,而人类 B 必须尽力帮助审问者。

由于输入窗口重叠,因此在这种情况下时代的概念并不那么清晰:在每个时代(由 Keras 实现),模型实际上会多次看到相同的字符。

Alec Radford 等人,“学习生成评论和发现情感”,arXiv 预印本 arXiv:1704.01444(2017 年)。

Rico Sennrich 等人,“使用子词单元进行稀有词的神经机器翻译”,计算语言学年会第 54 届年会论文集 1(2016 年):1715-1725。

⁶ Taku Kudo,“子词规范化:改进神经网络翻译模型的多个子词候选”,arXiv 预印本 arXiv:1804.10959(2018)。

⁷ Taku Kudo 和 John Richardson,“SentencePiece:用于神经文本处理的简单且语言无关的子词标记器和去标记器”,arXiv 预印本 arXiv:1808.06226(2018)。

⁸ Yonghui Wu 等人,“谷歌的神经机器翻译系统:弥合人类和机器翻译之间的差距”,arXiv 预印本 arXiv:1609.08144(2016)。

⁹ 不规则张量在第十二章中被介绍,详细内容在附录 C 中。

¹⁰ Matthew Peters 等人,“深度上下文化的词表示”,2018 年北美计算语言学分会年会论文集:人类语言技术 1(2018):2227–2237。

¹¹ Jeremy Howard 和 Sebastian Ruder,“文本分类的通用语言模型微调”,计算语言学年会第 56 届年会论文集 1(2018):328–339。

¹² Daniel Cer 等人,“通用句子编码器”,arXiv 预印本 arXiv:1803.11175(2018)。

¹³ Ilya Sutskever 等人,“使用神经网络进行序列到序列学习”,arXiv 预印本(2014)。

¹⁴ Samy Bengio 等人,“使用循环神经网络进行序列预测的计划抽样”,arXiv 预印本 arXiv:1506.03099(2015)。

¹⁵ 这个数据集由Tatoeba 项目的贡献者创建的句子对组成。网站作者选择了约 120,000 个句子对https://manythings.org/anki。该数据集在创作共用署名 2.0 法国许可下发布。其他语言对也可用。

¹⁶ 在 Python 中,如果运行a, *b = [1, 2, 3, 4],那么a等于1b等于[2, 3, 4]

¹⁷ Sébastien Jean 等人,“在神经机器翻译中使用非常大的目标词汇”,计算语言学年会第 53 届年会和亚洲自然语言处理联合国际会议第 7 届年会论文集 1(2015):1–10。

¹⁸ Dzmitry Bahdanau 等人,“通过联合学习对齐和翻译的神经机器翻译”,arXiv 预印本 arXiv:1409.0473(2014)。

¹⁹ Minh-Thang Luong 等人,“基于注意力的神经机器翻译的有效方法”,2015 年自然语言处理经验方法会议论文集(2015):1412–1421。

²⁰ Ashish Vaswani 等人,“注意力就是一切”,第 31 届国际神经信息处理系统会议论文集(2017):6000–6010。

²¹ 由于Transformer使用时间分布密集层,可以说它使用了核大小为 1 的 1D 卷积层。

²² 这是“注意力就是一切”论文中的图 1,经作者的亲切许可再现。

²³ 如果您使用最新版本的 TensorFlow,可以使用不规则张量。

²⁴ 这是“注意力机制是你所需要的一切”中图 2 的右侧部分,经作者亲切授权复制。

²⁵ 当您阅读本文时,这很可能会发生变化;请查看Keras 问题#16248获取更多详细信息。当这种情况发生时,将不需要设置attention_mask参数,因此也不需要创建encoder_pad_mask

²⁶ 目前Z + skip不支持自动屏蔽,这就是为什么我们不得不写tf.keras.​lay⁠ers.Add()([Z, skip])的原因。再次强调,当您阅读本文时,情况可能已经发生变化。

²⁷ Alec Radford 等人,“通过生成式预训练改进语言理解”(2018 年)。

²⁸ 例如,“简在朋友的生日派对上玩得很开心”意味着“简喜欢这个派对”,但与“每个人都讨厌这个派对”相矛盾,与“地球是平的”无关。

²⁹ Jacob Devlin 等人,“BERT:深度双向 Transformer 的预训练”,2018 年北美计算语言学协会会议论文集:人类语言技术 1(2019 年)。

³⁰ 这是论文中的图 1,经作者亲切授权复制。

³¹ Alec Radford 等人,“语言模型是无监督多任务学习者”(2019 年)。

³² William Fedus 等人,“Switch Transformers: 通过简单高效的稀疏性扩展到万亿参数模型”(2021 年)。

³³ Victor Sanh 等人,“DistilBERT,Bert 的精简版本:更小、更快、更便宜、更轻”,arXiv 预印本 arXiv:1910.01108(2019 年)。

³⁴ Mariya Yao 在这篇文章中总结了许多这些模型:https://homl.info/yaopost

³⁵ Colin Raffel 等人,“探索统一文本到文本 Transformer 的迁移学习极限”,arXiv 预印本 arXiv:1910.10683(2019 年)。

³⁶ Aakanksha Chowdhery 等人,“PaLM: 使用路径扩展语言建模”,arXiv 预印本 arXiv:2204.02311(2022 年)。

³⁷ Jason Wei 等人,“思维链提示引发大型语言模型的推理”,arXiv 预印本 arXiv:2201.11903(2022 年)。

³⁸ Kelvin Xu 等人,“展示、关注和叙述:带有视觉注意力的神经图像字幕生成”,第 32 届国际机器学习会议论文集(2015 年):2048–2057。

³⁹ 这是论文中图 3 的一部分。经作者亲切授权复制。

⁴⁰ Marco Tulio Ribeiro 等人,“‘为什么我应该相信你?’:解释任何分类器的预测”,第 22 届 ACM SIGKDD 国际知识发现与数据挖掘会议论文集(2016 年):1135–1144。

⁴¹ Nicolas Carion 等人,“使用 Transformer 进行端到端目标检测”,arXiv 预印本 arxiv:2005.12872(2020 年)。

⁴² Alexey Dosovitskiy 等人,“一幅图像价值 16x16 个词:大规模图像识别的 Transformer”,arXiv 预印本 arxiv:2010.11929(2020 年)。

⁴³ Hugo Touvron 等人,“训练数据高效的图像 Transformer 和通过注意力蒸馏”,arXiv 预印本 arxiv:2012.12877(2020 年)。

⁴⁴ Andrew Jaegle 等人,“Perceiver: 带有迭代注意力的通用感知”,arXiv 预印本 arxiv:2103.03206(2021)。

⁴⁵ Mathilde Caron 等人,“自监督视觉 Transformer 中的新兴属性”,arXiv 预印本 arxiv:2104.14294(2021)。

⁴⁶ Xiaohua Zhai 等人,“缩放视觉 Transformer”,arXiv 预印本 arxiv:2106.04560v1(2021)。

⁴⁷ Mitchell Wortsman 等人,“模型汤:多个微调模型的权重平均提高准确性而不增加推理时间”,arXiv 预印本 arxiv:2203.05482v1(2022)。

⁴⁸ Alec Radford 等人,“从自然语言监督中学习可转移的视觉模型”,arXiv 预印本 arxiv:2103.00020(2021)。

⁴⁹ Aditya Ramesh 等人,“零样本文本到图像生成”,arXiv 预印本 arxiv:2102.12092(2021)。

⁵⁰ Aditya Ramesh 等人,“具有 CLIP 潜变量的分层文本条件图像生成”,arXiv 预印本 arxiv:2204.06125(2022)。

⁵¹ Jean-Baptiste Alayrac 等人,“Flamingo:用于少样本学习的视觉语言模型”,arXiv 预印本 arxiv:2204.14198(2022)。

⁵² Scott Reed 等人,“通用主体代理”,arXiv 预印本 arxiv:2205.06175(2022)。

第十七章:自编码器、GANs 和扩散模型

自编码器是人工神经网络,能够学习输入数据的密集表示,称为潜在表示编码,而无需任何监督(即,训练集未标记)。这些编码通常比输入数据的维度低得多,使得自编码器在降维方面非常有用(参见第八章),特别是用于可视化目的。自编码器还充当特征检测器,并且可以用于深度神经网络的无监督预训练(正如我们在第十一章中讨论的那样)。最后,一些自编码器是生成模型:它们能够随机生成看起来非常类似于训练数据的新数据。例如,您可以在人脸图片上训练一个自编码器,然后它将能够生成新的人脸。

生成对抗网络(GANs)也是能够生成数据的神经网络。事实上,它们可以生成如此逼真的人脸图片,以至于很难相信它们所代表的人并不存在。您可以通过访问https://thispersondoesnotexist.com来亲自判断,这是一个展示由名为StyleGAN的 GAN 架构生成的人脸的网站。您还可以查看https://thisrentaldoesnotexist.com来查看一些生成的 Airbnb 列表。GANs 现在被广泛用于超分辨率(增加图像的分辨率)、着色、强大的图像编辑(例如,用逼真的背景替换照片炸弹客)、将简单的草图转换为逼真的图像、预测视频中的下一帧、增强数据集(用于训练其他模型)、生成其他类型的数据(如文本、音频和时间序列)、识别其他模型的弱点以加强它们等等。

生成学习领域的一个较新的成员是扩散模型。在 2021 年,它们成功生成了比 GANs 更多样化和高质量的图像,同时训练也更容易。然而,扩散模型运行速度较慢。

自编码器、GANs 和扩散模型都是无监督的,它们都学习潜在表示,它们都可以用作生成模型,并且有许多类似的应用。然而,它们的工作方式非常不同:

  • 自编码器简单地学习将输入复制到输出。这听起来可能是一个琐碎的任务,但正如你将看到的,以各种方式约束网络可能会使其变得相当困难。例如,您可以限制潜在表示的大小,或者您可以向输入添加噪声并训练网络以恢复原始输入。这些约束阻止了自编码器直接将输入轻松复制到输出,迫使其学习表示数据的有效方式。简而言之,编码是自编码器在某些约束下学习身份函数的副产品。

  • GANs 由两个神经网络组成:一个生成器试图生成看起来类似于训练数据的数据,另一个鉴别器试图区分真实数据和假数据。这种架构在深度学习中非常独特,因为生成器和鉴别器在训练过程中相互竞争:生成器经常被比作试图制造逼真的假币的罪犯,而鉴别器则像是试图区分真假货币的警察调查员。对抗训练(训练竞争的神经网络)被广泛认为是 2010 年代最重要的创新之一。2016 年,Yann LeCun 甚至说这是“过去 10 年中机器学习中最有趣的想法”。

  • 去噪扩散概率模型(DDPM)被训练用于从图像中去除一点噪音。如果你拿一张完全充满高斯噪音的图像,然后反复在该图像上运行扩散模型,一个高质量的图像将逐渐出现,类似于训练图像(但不完全相同)。

在本章中,我们将深入探讨自编码器的工作原理以及如何将其用于降维、特征提取、无监督预训练或生成模型。这将自然地引导我们到 GAN。我们将构建一个简单的 GAN 来生成假图像,但我们会看到训练通常相当困难。我们将讨论对抗训练中遇到的主要困难,以及一些解决这些困难的主要技术。最后,我们将构建和训练一个 DDPM,并用它生成图像。让我们从自编码器开始!

高效的数据表示

你觉得以下哪个数字序列最容易记住?

  • 40, 27, 25, 36, 81, 57, 10, 73, 19, 68

  • 50, 48, 46, 44, 42, 40, 38, 36, 34, 32, 30, 28, 26, 24, 22, 20, 18, 16, 14

乍一看,第一个序列似乎更容易,因为它要短得多。然而,如果你仔细看第二个序列,你会注意到它只是从 50 到 14 的偶数列表。一旦你注意到这个模式,第二个序列比第一个容易记忆得多,因为你只需要记住模式(即递减的偶数)和起始和结束数字(即 50 和 14)。请注意,如果你能快速轻松地记住非常长的序列,你就不会太在意第二个序列中的模式。你只需要把每个数字背下来,就这样。难以记忆长序列的事实使得识别模式变得有用,希望这解释清楚了为什么在训练期间对自编码器进行约束会促使其发现和利用数据中的模式。

记忆、感知和模式匹配之间的关系在 20 世纪 70 年代初由威廉·查斯和赫伯特·西蒙著名研究。他们观察到,专业的国际象棋选手能够在只看棋盘五秒钟的情况下记住游戏中所有棋子的位置,这是大多数人会觉得不可能的任务。然而,这只有在棋子被放置在现实位置(来自实际游戏)时才是这样,而不是当棋子被随机放置时。国际象棋专家的记忆力并不比你我好多少;他们只是更容易看到国际象棋的模式,这要归功于他们对游戏的经验。注意到模式有助于他们有效地存储信息。

就像这个记忆实验中的国际象棋选手一样,自编码器查看输入,将其转换为高效的潜在表示,然后输出与输入非常接近的内容(希望如此)。自编码器始终由两部分组成:一个编码器(或识别网络),将输入转换为潜在表示,然后是一个解码器(或生成网络),将内部表示转换为输出(参见图 17-1)。

mls3 1701

图 17-1。国际象棋记忆实验(左)和简单的自编码器(右)

正如您所看到的,自编码器通常具有与多层感知器(MLP;参见第十章)相同的架构,只是输出层中的神经元数量必须等于输入数量。在这个例子中,有一个由两个神经元组成的隐藏层(编码器),以及一个由三个神经元组成的输出层(解码器)。输出通常被称为重构,因为自编码器试图重构输入。成本函数包含一个重构损失,当重构与输入不同时,惩罚模型。

因为内部表示的维度比输入数据低(是 2D 而不是 3D),所以自编码器被称为欠完备。欠完备自编码器不能简单地将其输入复制到编码中,但它必须找到一种输出其输入的方式。它被迫学习输入数据中最重要的特征(并丢弃不重要的特征)。

让我们看看如何实现一个非常简单的欠完备自编码器进行降维。

使用欠完备线性自编码器执行 PCA

如果自编码器仅使用线性激活函数,并且成本函数是均方误差(MSE),那么它最终会执行主成分分析(PCA;参见第八章)。

以下代码构建了一个简单的线性自编码器,用于在 3D 数据集上执行 PCA,将其投影到 2D:

import tensorflow as tf

encoder = tf.keras.Sequential([tf.keras.layers.Dense(2)])
decoder = tf.keras.Sequential([tf.keras.layers.Dense(3)])
autoencoder = tf.keras.Sequential([encoder, decoder])

optimizer = tf.keras.optimizers.SGD(learning_rate=0.5)
autoencoder.compile(loss="mse", optimizer=optimizer)

这段代码与我们在过去章节中构建的所有 MLP 并没有太大的不同,但有几点需要注意:

  • 我们将自编码器组织成两个子组件:编码器和解码器。两者都是常规的Sequential模型,每个都有一个Dense层,自编码器是一个包含编码器后面是解码器的Sequential模型(请记住,模型可以作为另一个模型中的一层使用)。

  • 自编码器的输出数量等于输入数量(即 3)。

  • 为了执行 PCA,我们不使用任何激活函数(即所有神经元都是线性的),成本函数是 MSE。这是因为 PCA 是一种线性变换。很快我们将看到更复杂和非线性的自编码器。

现在让我们在与我们在第八章中使用的相同简单生成的 3D 数据集上训练模型,并使用它对该数据集进行编码(即将其投影到 2D):

X_train = [...]  # generate a 3D dataset, like in Chapter 8
history = autoencoder.fit(X_train, X_train, epochs=500, verbose=False)
codings = encoder.predict(X_train)

请注意,X_train既用作输入又用作目标。图 17-2 显示了原始 3D 数据集(左侧)和自编码器的隐藏层的输出(即编码层,右侧)。正如您所看到的,自编码器找到了最佳的 2D 平面来投影数据,尽可能保留数据中的方差(就像 PCA 一样)。

mls3 1702

图 17-2. 由欠完备线性自编码器执行的近似 PCA
注意

您可以将自编码器视为执行一种自监督学习,因为它基于一种带有自动生成标签的监督学习技术(在本例中简单地等于输入)。

堆叠自编码器

就像我们讨论过的其他神经网络一样,自编码器可以有多个隐藏层。在这种情况下,它们被称为堆叠自编码器(或深度自编码器)。添加更多层有助于自编码器学习更复杂的编码。也就是说,必须小心不要使自编码器过于强大。想象一个如此强大的编码器,它只学习将每个输入映射到一个单一的任意数字(解码器学习反向映射)。显然,这样的自编码器将完美地重构训练数据,但它不会在过程中学习任何有用的数据表示,并且不太可能很好地推广到新实例。

堆叠自编码器的架构通常关于中心隐藏层(编码层)是对称的。简单来说,它看起来像三明治。例如,时尚 MNIST 的自编码器(在第十章介绍)可能有 784 个输入,然后是具有 100 个神经元的隐藏层,然后是具有 30 个神经元的中心隐藏层,然后是具有 100 个神经元的另一个隐藏层,最后是具有 784 个神经元的输出层。这个堆叠自编码器在图 17-3 中表示。

mls3 1703

图 17-3. 堆叠自编码器

使用 Keras 实现堆叠自编码器

您可以实现一个堆叠自编码器,非常类似于常规的深度 MLP:

stacked_encoder = tf.keras.Sequential([
    tf.keras.layers.Flatten(),
    tf.keras.layers.Dense(100, activation="relu"),
    tf.keras.layers.Dense(30, activation="relu"),
])
stacked_decoder = tf.keras.Sequential([
    tf.keras.layers.Dense(100, activation="relu"),
    tf.keras.layers.Dense(28 * 28),
    tf.keras.layers.Reshape([28, 28])
])
stacked_ae = tf.keras.Sequential([stacked_encoder, stacked_decoder])

stacked_ae.compile(loss="mse", optimizer="nadam")
history = stacked_ae.fit(X_train, X_train, epochs=20,
                         validation_data=(X_valid, X_valid))

让我们来看看这段代码:

  • 就像之前一样,我们将自编码器模型分成两个子模型:编码器和解码器。

  • 编码器接收 28×28 像素的灰度图像,将它们展平,使每个图像表示为大小为 784 的向量,然后通过两个逐渐减小的“密集”层(100 个单元,然后是 30 个单元)处理这些向量,都使用 ReLU 激活函数。对于每个输入图像,编码器输出大小为 30 的向量。

  • 解码器接收大小为 30 的编码(由编码器输出),并通过两个逐渐增大的“密集”层(100 个单元,然后是 784 个单元)处理它们,并将最终向量重新整形为 28×28 的数组,以便解码器的输出具有与编码器输入相同的形状。

  • 在编译堆叠自编码器时,我们使用 MSE 损失和 Nadam 优化。

  • 最后,我们使用X_train作为输入和目标来训练模型。同样,我们使用X_valid作为验证输入和目标。

可视化重构

确保自编码器得到正确训练的一种方法是比较输入和输出:差异不应太大。让我们绘制一些验证集中的图像,以及它们的重构:

import numpy as np

def plot_reconstructions(model, images=X_valid, n_images=5):
    reconstructions = np.clip(model.predict(images[:n_images]), 0, 1)
    fig = plt.figure(figsize=(n_images * 1.5, 3))
    for image_index in range(n_images):
        plt.subplot(2, n_images, 1 + image_index)
        plt.imshow(images[image_index], cmap="binary")
        plt.axis("off")
        plt.subplot(2, n_images, 1 + n_images + image_index)
        plt.imshow(reconstructions[image_index], cmap="binary")
        plt.axis("off")

plot_reconstructions(stacked_ae)
plt.show()

图 17-4 显示了生成的图像。

mls3 1704

图 17-4. 原始图像(顶部)及其重构(底部)

重构是可以识别的,但有点丢失。我们可能需要训练模型更长时间,或者使编码器和解码器更深,或者使编码更大。但是,如果我们使网络过于强大,它将能够进行完美的重构,而不必学习数据中的任何有用模式。现在,让我们使用这个模型。

可视化时尚 MNIST 数据集

现在我们已经训练了一个堆叠自编码器,我们可以使用它来降低数据集的维度。对于可视化,与其他降维算法(如我们在第八章中讨论的算法)相比,这并不会产生很好的结果,但自编码器的一个重要优势是它们可以处理具有许多实例和许多特征的大型数据集。因此,一种策略是使用自编码器将维度降低到合理水平,然后使用另一个降维算法进行可视化。让我们使用这种策略来可视化时尚 MNIST。首先,我们将使用堆叠自编码器的编码器将维度降低到 30,然后我们将使用 Scikit-Learn 的 t-SNE 算法实现将维度降低到 2 以进行可视化:

from sklearn.manifold import TSNE

X_valid_compressed = stacked_encoder.predict(X_valid)
tsne = TSNE(init="pca", learning_rate="auto", random_state=42)
X_valid_2D = tsne.fit_transform(X_valid_compressed)

现在我们可以绘制数据集:

plt.scatter(X_valid_2D[:, 0], X_valid_2D[:, 1], c=y_valid, s=10, cmap="tab10")
plt.show()

图 17-5 显示了生成的散点图,通过显示一些图像进行美化。t-SNE 算法识别出几个与类别相匹配的簇(每个类别由不同的颜色表示)。

mls3 1705

图 17-5. 使用自编码器后跟 t-SNE 的时尚 MNIST 可视化

因此,自编码器可以用于降维。另一个应用是无监督的预训练。

使用堆叠自编码器进行无监督预训练

正如我们在第十一章中讨论的,如果你正在处理一个复杂的监督任务,但没有太多标记的训练数据,一个解决方案是找到一个执行类似任务的神经网络,并重复使用其较低层。这样可以使用少量训练数据训练高性能模型,因为你的神经网络不需要学习所有低级特征;它只需重复使用现有网络学习的特征检测器。

同样,如果你有一个大型数据集,但其中大部分是未标记的,你可以首先使用所有数据训练一个堆叠自编码器,然后重复使用较低层来创建一个用于实际任务的神经网络,并使用标记数据进行训练。例如,图 17-6 展示了如何使用堆叠自编码器为分类神经网络执行无监督预训练。在训练分类器时,如果你确实没有太多标记的训练数据,可能需要冻结预训练层(至少是较低的层)。

mls3 1706

图 17-6。使用自编码器进行无监督预训练
注意

拥有大量未标记数据和少量标记数据是很常见的。构建一个大型未标记数据集通常很便宜(例如,一个简单的脚本可以从互联网上下载数百万张图片),但标记这些图片(例如,将它们分类为可爱或不可爱)通常只能由人类可靠地完成。标记实例是耗时且昂贵的,因此通常只有少量人类标记的实例,甚至更少。

实现上没有什么特别之处:只需使用所有训练数据(标记加未标记)训练一个自编码器,然后重复使用其编码器层来创建一个新的神经网络(请参考本章末尾的练习示例)。

接下来,让我们看一些训练堆叠自编码器的技术。

绑定权重

当一个自编码器是整齐对称的,就像我们刚刚构建的那样,一个常见的技术是将解码器层的权重与编码器层的权重绑定在一起。这样可以减半模型中的权重数量,加快训练速度并限制过拟合的风险。具体来说,如果自编码器总共有N层(不包括输入层),而W[L]表示第L层的连接权重(例如,第 1 层是第一个隐藏层,第N/2 层是编码层,第N层是输出层),那么解码器层的权重可以定义为W[L] = W[NL+1]^⊺(其中L = N / 2 + 1, …​, N)。

使用 Keras 在层之间绑定权重,让我们定义一个自定义层:

class DenseTranspose(tf.keras.layers.Layer):
    def __init__(self, dense, activation=None, **kwargs):
        super().__init__(**kwargs)
        self.dense = dense
        self.activation = tf.keras.activations.get(activation)

    def build(self, batch_input_shape):
        self.biases = self.add_weight(name="bias",
                                      shape=self.dense.input_shape[-1],
                                      initializer="zeros")
        super().build(batch_input_shape)

    def call(self, inputs):
        Z = tf.matmul(inputs, self.dense.weights[0], transpose_b=True)
        return self.activation(Z + self.biases)

这个自定义层就像一个常规的Dense层,但它使用另一个Dense层的权重,经过转置(设置transpose_b=True等同于转置第二个参数,但更高效,因为它在matmul()操作中实时执行转置)。然而,它使用自己的偏置向量。现在我们可以构建一个新的堆叠自编码器,与之前的模型类似,但解码器的Dense层与编码器的Dense层绑定:

dense_1 = tf.keras.layers.Dense(100, activation="relu")
dense_2 = tf.keras.layers.Dense(30, activation="relu")

tied_encoder = tf.keras.Sequential([
    tf.keras.layers.Flatten(),
    dense_1,
    dense_2
])

tied_decoder = tf.keras.Sequential([
    DenseTranspose(dense_2, activation="relu"),
    DenseTranspose(dense_1),
    tf.keras.layers.Reshape([28, 28])
])

tied_ae = tf.keras.Sequential([tied_encoder, tied_decoder])

这个模型实现了与之前模型大致相同的重构误差,使用了几乎一半的参数数量。

一次训练一个自编码器

与我们刚刚做的整个堆叠自编码器一次性训练不同,可以一次训练一个浅层自编码器,然后将它们堆叠成一个单一的堆叠自编码器(因此得名),如图 17-7 所示。这种技术现在不太常用,但你可能仍然会遇到一些论文讨论“贪婪逐层训练”,所以了解其含义是很有必要的。

mls3 1707

图 17-7。一次训练一个自编码器

在训练的第一阶段,第一个自编码器学习重建输入。然后我们使用这个第一个自编码器对整个训练集进行编码,这给我们一个新的(压缩的)训练集。然后我们在这个新数据集上训练第二个自编码器。这是训练的第二阶段。最后,我们构建一个大的三明治,使用所有这些自编码器,如图 17-7 所示(即,我们首先堆叠每个自编码器的隐藏层,然后反向堆叠输出层)。这给我们最终的堆叠自编码器(请参阅本章笔记本中“逐个训练自编码器”部分以获取实现)。通过这种方式,我们可以轻松训练更多的自编码器,构建一个非常深的堆叠自编码器。

正如我之前提到的,深度学习浪潮的一个触发因素是Geoffrey Hinton 等人在 2006 年发现深度神经网络可以通过无监督的方式进行预训练,使用这种贪婪的逐层方法。他们用受限玻尔兹曼机(RBMs;参见https://homl.info/extra-anns)来实现这一目的,但在 2007 年Yoshua Bengio 等人⁠²表明自编码器同样有效。几年来,这是训练深度网络的唯一有效方式,直到第十一章中引入的许多技术使得可以一次性训练深度网络。

自编码器不仅限于密集网络:你也可以构建卷积自编码器。现在让我们来看看这些。

卷积自编码器

如果你处理的是图像,那么迄今为止我们看到的自编码器效果不佳(除非图像非常小):正如你在第十四章中看到的,卷积神经网络比密集网络更适合处理图像。因此,如果你想为图像构建一个自编码器(例如用于无监督预训练或降维),你将需要构建一个卷积自编码器。⁠³ 编码器是由卷积层和池化层组成的常规 CNN。它通常减少输入的空间维度(即高度和宽度),同时增加深度(即特征图的数量)。解码器必须执行相反操作(放大图像并将其深度降至原始维度),为此你可以使用转置卷积层(或者,你可以将上采样层与卷积层结合)。以下是 Fashion MNIST 的基本卷积自编码器:

conv_encoder = tf.keras.Sequential([
    tf.keras.layers.Reshape([28, 28, 1]),
    tf.keras.layers.Conv2D(16, 3, padding="same", activation="relu"),
    tf.keras.layers.MaxPool2D(pool_size=2),  # output: 14 × 14 x 16
    tf.keras.layers.Conv2D(32, 3, padding="same", activation="relu"),
    tf.keras.layers.MaxPool2D(pool_size=2),  # output: 7 × 7 x 32
    tf.keras.layers.Conv2D(64, 3, padding="same", activation="relu"),
    tf.keras.layers.MaxPool2D(pool_size=2),  # output: 3 × 3 x 64
    tf.keras.layers.Conv2D(30, 3, padding="same", activation="relu"),
    tf.keras.layers.GlobalAvgPool2D()  # output: 30
])
conv_decoder = tf.keras.Sequential([
    tf.keras.layers.Dense(3 * 3 * 16),
    tf.keras.layers.Reshape((3, 3, 16)),
    tf.keras.layers.Conv2DTranspose(32, 3, strides=2, activation="relu"),
    tf.keras.layers.Conv2DTranspose(16, 3, strides=2, padding="same",
                                    activation="relu"),
    tf.keras.layers.Conv2DTranspose(1, 3, strides=2, padding="same"),
    tf.keras.layers.Reshape([28, 28])
])
conv_ae = tf.keras.Sequential([conv_encoder, conv_decoder])

还可以使用其他架构类型创建自编码器,例如 RNNs(请参阅笔记本中的示例)。

好的,让我们退后一步。到目前为止,我们已经看过各种类型的自编码器(基本、堆叠和卷积),以及如何训练它们(一次性或逐层)。我们还看过一些应用:数据可视化和无监督预训练。

迄今为止,为了强迫自编码器学习有趣的特征,我们限制了编码层的大小,使其欠完备。实际上还有许多其他类型的约束可以使用,包括允许编码层与输入一样大,甚至更大,从而产生过完备自编码器。接下来,我们将看一些其他类型的自编码器:去噪自编码器、稀疏自编码器和变分自编码器。

去噪自编码器

另一种强制自编码器学习有用特征的方法是向其输入添加噪声,训练它恢复原始的无噪声输入。这个想法自上世纪 80 年代就存在了(例如,Yann LeCun 在 1987 年的硕士论文中提到了这一点)。在2008 年的一篇论文中,Pascal Vincent 等人表明自编码器也可以用于特征提取。在2010 年的一篇论文中,Vincent 等人介绍了堆叠去噪自编码器

噪声可以是添加到输入的纯高斯噪声,也可以是随机关闭的输入,就像 dropout 中一样(在第十一章中介绍)。图 17-8 展示了这两种选项。

实现很简单:这是一个常规的堆叠自编码器,附加了一个Dropout层应用于编码器的输入(或者您可以使用一个GaussianNoise层)。请记住,Dropout层仅在训练期间激活(GaussianNoise层也是如此):

dropout_encoder = tf.keras.Sequential([
    tf.keras.layers.Flatten(),
    tf.keras.layers.Dropout(0.5),
    tf.keras.layers.Dense(100, activation="relu"),
    tf.keras.layers.Dense(30, activation="relu")
])
dropout_decoder = tf.keras.Sequential([
    tf.keras.layers.Dense(100, activation="relu"),
    tf.keras.layers.Dense(28 * 28),
    tf.keras.layers.Reshape([28, 28])
])
dropout_ae = tf.keras.Sequential([dropout_encoder, dropout_decoder])

mls3 1708

图 17-8。去噪自编码器,带有高斯噪声(左)或 dropout(右)

图 17-9 显示了一些嘈杂的图像(一半像素关闭),以及基于 dropout 的去噪自编码器重建的图像。请注意,自编码器猜测了实际输入中不存在的细节,例如白色衬衫的顶部(底部行,第四幅图)。正如您所看到的,去噪自编码器不仅可以用于数据可视化或无监督预训练,就像我们迄今讨论过的其他自编码器一样,而且还可以非常简单高效地从图像中去除噪声。

mls3 1709

图 17-9。嘈杂的图像(顶部)及其重建(底部)

稀疏自编码器

另一种通常导致良好特征提取的约束是稀疏性:通过向成本函数添加适当的项,自编码器被推动减少编码层中活跃神经元的数量。例如,它可能被推动使编码层中平均只有 5%的显著活跃神经元。这迫使自编码器将每个输入表示为少量激活的组合。结果,编码层中的每个神经元通常最终代表一个有用的特征(如果您每个月只能说几个词,您可能会尽量使它们值得倾听)。

一个简单的方法是在编码层中使用 sigmoid 激活函数(将编码限制在 0 到 1 之间),使用一个大的编码层(例如,具有 300 个单元),并向编码层的激活添加一些ℓ[1]正则化。解码器只是一个常规的解码器:

sparse_l1_encoder = tf.keras.Sequential([
    tf.keras.layers.Flatten(),
    tf.keras.layers.Dense(100, activation="relu"),
    tf.keras.layers.Dense(300, activation="sigmoid"),
    tf.keras.layers.ActivityRegularization(l1=1e-4)
])
sparse_l1_decoder = tf.keras.Sequential([
    tf.keras.layers.Dense(100, activation="relu"),
    tf.keras.layers.Dense(28 * 28),
    tf.keras.layers.Reshape([28, 28])
])
sparse_l1_ae = tf.keras.Sequential([sparse_l1_encoder, sparse_l1_decoder])

这个ActivityRegularization层只是返回其输入,但作为副作用,它会添加一个训练损失,等于其输入的绝对值之和。这只影响训练。同样,您可以删除ActivityRegularization层,并在前一层中设置activity_regularizer=tf.keras.regularizers.l1(1e-4)。这种惩罚将鼓励神经网络生成接近 0 的编码,但由于如果不能正确重建输入也会受到惩罚,因此它必须输出至少几个非零值。使用ℓ[1]范数而不是ℓ[2]范数将推动神经网络保留最重要的编码,同时消除不需要的编码(而不仅仅是减少所有编码)。

另一种方法,通常会产生更好的结果,是在每次训练迭代中测量编码层的实际稀疏度,并在测量到的稀疏度与目标稀疏度不同时对模型进行惩罚。我们通过计算编码层中每个神经元的平均激活值来实现这一点,整个训练批次上。批次大小不能太小,否则平均值将不准确。

一旦我们有了每个神经元的平均激活,我们希望通过向成本函数添加稀疏损失来惩罚那些激活过多或不足的神经元。例如,如果我们测量到一个神经元的平均激活为 0.3,但目标稀疏度为 0.1,那么它必须受到惩罚以减少激活。一种方法可能是简单地将平方误差(0.3 - 0.1)²添加到成本函数中,但实际上更好的方法是使用 Kullback–Leibler (KL)散度(在第四章中简要讨论),它比均方误差具有更强的梯度,如您可以在图 17-10 中看到的那样。

mls3 1710

图 17-10. 稀疏损失

给定两个离散概率分布PQ,这些分布之间的 KL 散度,记为DKL,可以使用方程 17-1 计算。

方程 17-1. Kullback–Leibler 散度

D KL ( P Q ) = i P ( i ) log P(i) Q(i)

在我们的情况下,我们想要衡量编码层中神经元激活的目标概率p和通过测量整个训练批次上的平均激活来估计的实际概率q之间的差异。因此,KL 散度简化为方程 17-2。

方程 17-2. 目标稀疏度p和实际稀疏度q之间的 KL 散度

D KL ( p q ) = p log p q + ( 1 - p ) log 1-p 1-q

一旦我们计算了编码层中每个神经元的稀疏损失,我们将这些损失相加并将结果添加到成本函数中。为了控制稀疏损失和重构损失的相对重要性,我们可以将稀疏损失乘以一个稀疏权重超参数。如果这个权重太高,模型将严格遵循目标稀疏度,但可能无法正确重构输入,使模型无用。相反,如果权重太低,模型将主要忽略稀疏目标,并且不会学习任何有趣的特征。

现在我们有了所有需要基于 KL 散度实现稀疏自编码器的东西。首先,让我们创建一个自定义正则化器来应用 KL 散度正则化:

kl_divergence = tf.keras.losses.kullback_leibler_divergence

class KLDivergenceRegularizer(tf.keras.regularizers.Regularizer):
    def __init__(self, weight, target):
        self.weight = weight
        self.target = target

    def __call__(self, inputs):
        mean_activities = tf.reduce_mean(inputs, axis=0)
        return self.weight * (
            kl_divergence(self.target, mean_activities) +
            kl_divergence(1. - self.target, 1. - mean_activities))

现在我们可以构建稀疏自编码器,使用KLDivergenceRegularizer来对编码层的激活进行正则化:

kld_reg = KLDivergenceRegularizer(weight=5e-3, target=0.1)
sparse_kl_encoder = tf.keras.Sequential([
    tf.keras.layers.Flatten(),
    tf.keras.layers.Dense(100, activation="relu"),
    tf.keras.layers.Dense(300, activation="sigmoid",
                          activity_regularizer=kld_reg)
])
sparse_kl_decoder = tf.keras.Sequential([
    tf.keras.layers.Dense(100, activation="relu"),
    tf.keras.layers.Dense(28 * 28),
    tf.keras.layers.Reshape([28, 28])
])
sparse_kl_ae = tf.keras.Sequential([sparse_kl_encoder, sparse_kl_decoder])

在 Fashion MNIST 上训练这个稀疏自编码器后,编码层的稀疏度大约为 10%。

变分自编码器

2013 年,Diederik Kingma 和 Max Welling⁠⁶引入了一个重要类别的自编码器,并迅速成为最受欢迎的变体之一:变分自编码器(VAEs)。

VAEs 在这些特定方面与我们迄今讨论过的所有自编码器都有很大不同:

  • 它们是概率自编码器,这意味着它们的输出在训练后部分地由机会决定(与去噪自编码器相反,在训练期间仅使用随机性)。

  • 最重要的是,它们是生成自编码器,这意味着它们可以生成看起来像是从训练集中采样的新实例。

这两个特性使得 VAEs 与 RBM 相当相似,但它们更容易训练,采样过程也更快(对于 RBM,您需要等待网络稳定到“热平衡”状态,然后才能对新实例进行采样)。正如它们的名字所暗示的,变分自编码器执行变分贝叶斯推断,这是进行近似贝叶斯推断的有效方法。回想一下,贝叶斯推断意味着根据新数据更新概率分布,使用从贝叶斯定理推导出的方程。原始分布称为先验,而更新后的分布称为后验。在我们的情况下,我们想要找到数据分布的一个很好的近似。一旦我们有了这个,我们就可以从中进行采样。

让我们看看 VAEs 是如何工作的。图 17-11(左)展示了一个变分自编码器。您可以认出所有自编码器的基本结构,具有一个编码器后面跟着一个解码器(在这个例子中,它们都有两个隐藏层),但有一个转折:编码器不是直接为给定输入产生编码,而是产生一个均值编码 μ 和一个标准差 σ。然后,实际编码随机地从均值 μ 和标准差 σ 的高斯分布中采样。之后解码器正常解码采样的编码。图的右侧显示了一个训练实例通过这个自编码器的过程。首先,编码器产生 μσ,然后一个编码被随机采样(请注意它并不完全位于 μ),最后这个编码被解码;最终输出类似于训练实例。

mls3 1711

图 17-11. 变分自编码器(左)和通过它的实例(右)

正如您在图中所看到的,尽管输入可能具有非常复杂的分布,但变分自编码器倾向于产生看起来像是从简单的高斯分布中采样的编码:⁠⁷在训练期间,成本函数(下面讨论)推动编码逐渐在编码空间(也称为潜在空间)内迁移,最终看起来像一个高斯点云。一个很大的结果是,在训练变分自编码器之后,您可以非常容易地生成一个新实例:只需从高斯分布中随机采样一个随机编码,解码它,然后就完成了!

现在,让我们看一下成本函数。它由两部分组成。第一部分是通常的重构损失,推动自编码器重现其输入。我们可以使用 MSE 来实现这一点,就像我们之前做的那样。第二部分是潜在损失,推动自编码器具有看起来像是从简单高斯分布中抽样的编码:这是目标分布(即高斯分布)与编码的实际分布之间的 KL 散度。数学比稀疏自编码器更复杂,特别是由于高斯噪声,它限制了可以传输到编码层的信息量。这推动自编码器学习有用的特征。幸运的是,方程简化了,因此可以使用 Equation 17-3 计算潜在损失。⁠⁸

方程 17-3。变分自编码器的潜在损失

L=-12i=1n1+log(σi2)-σi2-μi2

在这个方程中,ℒ是潜在损失,n是编码的维度,μ[i]和σ[i]是编码的第i个分量的均值和标准差。向量μσ(包含所有μ[i]和σ[i])由编码器输出,如 Figure 17-11(左)所示。

变分自编码器架构的常见调整是使编码器输出γ = log(σ²)而不是σ。然后可以根据 Equation 17-4 计算潜在损失。这种方法在数值上更稳定,加快了训练速度。

方程 17-4。变分自编码器的潜在损失,使用γ = log(σ²)重写

L=-12i=1n1+γi-exp(γi)-μi2

现在,让我们开始为 Fashion MNIST 构建一个变分自编码器(如 Figure 17-11 所示,但使用γ调整)。首先,我们需要一个自定义层来根据μγ抽样编码:

class Sampling(tf.keras.layers.Layer):
    def call(self, inputs):
        mean, log_var = inputs
        return tf.random.normal(tf.shape(log_var)) * tf.exp(log_var / 2) + mean

这个Sampling层接受两个输入:meanμ)和log_varγ)。它使用函数tf.random.normal()从均值为 0,标准差为 1 的高斯分布中抽样一个随机向量(与γ形状相同)。然后将其乘以 exp(γ / 2)(数学上等于σ,您可以验证),最后加上μ并返回结果。这样从均值为μ,标准差为σ的高斯分布中抽样一个编码向量。

接下来,我们可以创建编码器,使用函数式 API,因为模型不是完全顺序的:

codings_size = 10

inputs = tf.keras.layers.Input(shape=[28, 28])
Z = tf.keras.layers.Flatten()(inputs)
Z = tf.keras.layers.Dense(150, activation="relu")(Z)
Z = tf.keras.layers.Dense(100, activation="relu")(Z)
codings_mean = tf.keras.layers.Dense(codings_size)(Z)  # μ
codings_log_var = tf.keras.layers.Dense(codings_size)(Z)  # γ
codings = Sampling()([codings_mean, codings_log_var])
variational_encoder = tf.keras.Model(
    inputs=[inputs], outputs=[codings_mean, codings_log_var, codings])

注意,输出codings_meanμ)和codings_log_varγ)的Dense层具有相同的输入(即第二个Dense层的输出)。然后,我们将codings_meancodings_log_var都传递给Sampling层。最后,variational_encoder模型有三个输出。只需要codings,但我们也添加了codings_meancodings_log_var,以防我们想要检查它们的值。现在让我们构建解码器:

decoder_inputs = tf.keras.layers.Input(shape=[codings_size])
x = tf.keras.layers.Dense(100, activation="relu")(decoder_inputs)
x = tf.keras.layers.Dense(150, activation="relu")(x)
x = tf.keras.layers.Dense(28 * 28)(x)
outputs = tf.keras.layers.Reshape([28, 28])(x)
variational_decoder = tf.keras.Model(inputs=[decoder_inputs], outputs=[outputs])

对于这个解码器,我们可以使用顺序 API 而不是功能 API,因为它实际上只是一个简单的层堆栈,与我们迄今构建的许多解码器几乎相同。最后,让我们构建变分自编码器模型:

_, _, codings = variational_encoder(inputs)
reconstructions = variational_decoder(codings)
variational_ae = tf.keras.Model(inputs=[inputs], outputs=[reconstructions])

我们忽略编码器的前两个输出(我们只想将编码输入解码器)。最后,我们必须添加潜在损失和重构损失:

latent_loss = -0.5 * tf.reduce_sum(
    1 + codings_log_var - tf.exp(codings_log_var) - tf.square(codings_mean),
    axis=-1)
variational_ae.add_loss(tf.reduce_mean(latent_loss) / 784.)

我们首先应用方程 17-4 来计算批处理中每个实例的潜在损失,对最后一个轴求和。然后我们计算批处理中所有实例的平均损失,并将结果除以 784,以确保它具有适当的比例,与重构损失相比。实际上,变分自编码器的重构损失应该是像素重构误差的总和,但是当 Keras 计算"mse"损失时,它计算所有 784 个像素的平均值,而不是总和。因此,重构损失比我们需要的要小 784 倍。我们可以定义一个自定义损失来计算总和而不是平均值,但将潜在损失除以 784(最终损失将比应该的大 784 倍,但这只是意味着我们应该使用更大的学习率)更简单。

最后,我们可以编译和拟合自编码器!

variational_ae.compile(loss="mse", optimizer="nadam")
history = variational_ae.fit(X_train, X_train, epochs=25, batch_size=128,
                             validation_data=(X_valid, X_valid))

生成时尚 MNIST 图像

现在让我们使用这个变分自编码器生成看起来像时尚物品的图像。我们只需要从高斯分布中随机采样编码,并解码它们:

codings = tf.random.normal(shape=[3 * 7, codings_size])
images = variational_decoder(codings).numpy()

图 17-12 显示了生成的 12 张图像。

mls3 1712

图 17-12. 由变分自编码器生成的时尚 MNIST 图像

这些图像中的大多数看起来相当令人信服,虽然有点模糊。其余的不太好,但不要对自编码器太苛刻——它只有几分钟时间学习!

变分自编码器使得执行语义插值成为可能:不是在像素级别插值两个图像,看起来就像两个图像只是叠加在一起,我们可以在编码级别进行插值。例如,让我们在潜在空间中沿着任意线取几个编码,并解码它们。我们得到一系列图像,逐渐从裤子变成毛衣(见图 17-13):

codings = np.zeros([7, codings_size])
codings[:, 3] = np.linspace(-0.8, 0.8, 7)  # axis 3 looks best in this case
images = variational_decoder(codings).numpy()

mls3 1713

图 17-13. 语义插值

现在让我们转向 GANs:它们更难训练,但当你设法让它们工作时,它们会产生非常惊人的图像。

生成对抗网络

生成对抗网络是由 Ian Goodfellow 等人在2014 年的一篇论文中提出的⁠⁹,尽管这个想法几乎立即激发了研究人员的兴趣,但要克服训练 GANs 的一些困难还需要几年时间。就像许多伟大的想法一样,事后看来似乎很简单:让神经网络相互竞争,希望这种竞争能推动它们取得卓越的成就。如图 17-14 所示,GAN 由两个神经网络组成:

生成器

以随机分布(通常是高斯)作为输入,并输出一些数据——通常是图像。您可以将随机输入视为要生成的图像的潜在表示(即编码)。因此,正如您所看到的,生成器提供了与变分自编码器中的解码器相同的功能,并且可以以相同的方式用于生成新图像:只需将一些高斯噪声输入,它就会输出一个全新的图像。但是,它的训练方式非常不同,您很快就会看到。

鉴别器

以生成器的假图像或训练集中的真实图像作为输入,必须猜测输入图像是假还是真。

mls3 1714

图 17-14. 生成对抗网络

在训练过程中,生成器和鉴别器有着相反的目标:鉴别器试图区分假图像和真实图像,而生成器试图生成看起来足够真实以欺骗鉴别器的图像。由于 GAN 由具有不同目标的两个网络组成,因此无法像训练常规神经网络那样进行训练。每个训练迭代被分为两个阶段:

  • 在第一阶段,我们训练鉴别器。从训练集中抽取一批真实图像,并通过生成器生成相同数量的假图像来完成。标签设置为 0 表示假图像,1 表示真实图像,并且鉴别器在这个带标签的批次上进行一步训练,使用二元交叉熵损失。重要的是,在这个阶段只有鉴别器的权重被优化。

  • 在第二阶段,我们训练生成器。我们首先使用它生成另一批假图像,然后再次使用鉴别器来判断图像是假的还是真实的。这次我们不在批次中添加真实图像,所有标签都设置为 1(真实):换句话说,我们希望生成器生成鉴别器会(错误地)认为是真实的图像!在这一步骤中,鉴别器的权重被冻结,因此反向传播只影响生成器的权重。

注意

生成器实际上从未看到任何真实图像,但它逐渐学会生成令人信服的假图像!它所得到的只是通过鉴别器反向传播的梯度。幸运的是,鉴别器越好,这些二手梯度中包含的关于真实图像的信息就越多,因此生成器可以取得显著进展。

让我们继续构建一个简单的 Fashion MNIST GAN。

首先,我们需要构建生成器和鉴别器。生成器类似于自编码器的解码器,鉴别器是一个常规的二元分类器:它以图像作为输入,最终以包含单个单元并使用 sigmoid 激活函数的Dense层结束。对于每个训练迭代的第二阶段,我们还需要包含生成器后面的鉴别器的完整 GAN 模型:

codings_size = 30

Dense = tf.keras.layers.Dense
generator = tf.keras.Sequential([
    Dense(100, activation="relu", kernel_initializer="he_normal"),
    Dense(150, activation="relu", kernel_initializer="he_normal"),
    Dense(28 * 28, activation="sigmoid"),
    tf.keras.layers.Reshape([28, 28])
])
discriminator = tf.keras.Sequential([
    tf.keras.layers.Flatten(),
    Dense(150, activation="relu", kernel_initializer="he_normal"),
    Dense(100, activation="relu", kernel_initializer="he_normal"),
    Dense(1, activation="sigmoid")
])
gan = tf.keras.Sequential([generator, discriminator])

接下来,我们需要编译这些模型。由于鉴别器是一个二元分类器,我们可以自然地使用二元交叉熵损失。gan模型也是一个二元分类器,因此它也可以使用二元交叉熵损失。然而,生成器只会通过gan模型进行训练,因此我们根本不需要编译它。重要的是,在第二阶段之前鉴别器不应该被训练,因此在编译gan模型之前我们将其设置为不可训练:

discriminator.compile(loss="binary_crossentropy", optimizer="rmsprop")
discriminator.trainable = False
gan.compile(loss="binary_crossentropy", optimizer="rmsprop")
注意

trainable属性只有在编译模型时才会被 Keras 考虑,因此在运行此代码后,如果我们调用其fit()方法或train_on_batch()方法(我们将使用),则discriminator是可训练的,而在调用这些方法时gan模型是不可训练的。

由于训练循环是不寻常的,我们不能使用常规的fit()方法。相反,我们将编写一个自定义训练循环。为此,我们首先需要创建一个Dataset来迭代图像:

batch_size = 32
dataset = tf.data.Dataset.from_tensor_slices(X_train).shuffle(buffer_size=1000)
dataset = dataset.batch(batch_size, drop_remainder=True).prefetch(1)

现在我们准备编写训练循环。让我们将其封装在一个train_gan()函数中:

def train_gan(gan, dataset, batch_size, codings_size, n_epochs):
    generator, discriminator = gan.layers
    for epoch in range(n_epochs):
        for X_batch in dataset:
            # phase 1 - training the discriminator
            noise = tf.random.normal(shape=[batch_size, codings_size])
            generated_images = generator(noise)
            X_fake_and_real = tf.concat([generated_images, X_batch], axis=0)
            y1 = tf.constant([[0.]] * batch_size + [[1.]] * batch_size)
            discriminator.train_on_batch(X_fake_and_real, y1)
            # phase 2 - training the generator
            noise = tf.random.normal(shape=[batch_size, codings_size])
            y2 = tf.constant([[1.]] * batch_size)
            gan.train_on_batch(noise, y2)

train_gan(gan, dataset, batch_size, codings_size, n_epochs=50)

正如之前讨论的,您可以在每次迭代中看到两个阶段:

  • 在第一阶段,我们向生成器提供高斯噪声以生成假图像,并通过连接相同数量的真实图像来完成这一批次。目标y1设置为 0 表示假图像,1 表示真实图像。然后我们对这一批次训练鉴别器。请记住,在这个阶段鉴别器是可训练的,但我们不会触及生成器。

  • 在第二阶段,我们向 GAN 提供一些高斯噪声。其生成器将开始生成假图像,然后鉴别器将尝试猜测这些图像是假还是真实的。在这个阶段,我们试图改进生成器,这意味着我们希望鉴别器失败:这就是为什么目标y2都设置为 1,尽管图像是假的。在这个阶段,鉴别器是可训练的,因此gan模型中唯一会改进的部分是生成器。

就是这样!训练后,您可以随机从高斯分布中抽取一些编码,并将它们馈送给生成器以生成新图像:

codings = tf.random.normal(shape=[batch_size, codings_size])
generated_images = generator.predict(codings)

如果显示生成的图像(参见图 17-15),您会发现在第一个时期结束时,它们已经开始看起来像(非常嘈杂的)时尚 MNIST 图像。

mls3 1715

图 17-15。在训练一个时期后由 GAN 生成的图像

不幸的是,图像从未真正比那更好,甚至可能会出现 GAN 似乎忘记了它学到的东西的时期。为什么会这样呢?原来,训练 GAN 可能是具有挑战性的。让我们看看为什么。

训练 GAN 的困难

在训练过程中,生成器和鉴别器不断试图在零和博弈中互相智胜。随着训练的进行,游戏可能会进入博弈论家称之为纳什均衡的状态,以数学家约翰·纳什命名:这是当没有玩家会因为改变自己的策略而变得更好,假设其他玩家不改变自己的策略。例如,当每个人都在道路的左侧行驶时,就达到了纳什均衡:没有司机会因为成为唯一一个换边的人而变得更好。当然,还有第二种可能的纳什均衡:当每个人都在道路的右侧行驶时。不同的初始状态和动态可能导致一个或另一个均衡。在这个例子中,一旦达到均衡状态(即,与其他人一样在同一侧行驶),就会有一个单一的最佳策略,但是纳什均衡可能涉及多种竞争策略(例如,捕食者追逐猎物,猎物试图逃跑,双方都不会因为改变策略而变得更好)。

那么这如何应用于 GAN 呢?嗯,GAN 论文的作者们证明了 GAN 只能达到单一的纳什均衡:那就是生成器生成完全逼真的图像,鉴别器被迫猜测(50%真实,50%假)。这个事实非常令人鼓舞:似乎只需要训练足够长的时间,它最终会达到这种均衡,为您提供一个完美的生成器。不幸的是,事情并不那么简单:没有任何保证这种均衡会被达到。

最大的困难被称为模式坍塌:这是指生成器的输出逐渐变得不那么多样化。这是如何发生的呢?假设生成器在制作令人信服的鞋子方面比其他任何类别都更擅长。它会用鞋子更多地欺骗鉴别器,这将鼓励它生成更多的鞋子图像。逐渐地,它会忘记如何制作其他任何东西。与此同时,鉴别器将看到的唯一假图像将是鞋子,因此它也会忘记如何鉴别其他类别的假图像。最终,当鉴别器设法将假鞋子与真实鞋子区分开来时,生成器将被迫转向另一个类别。然后它可能擅长衬衫,忘记鞋子,鉴别器也会跟随。GAN 可能逐渐在几个类别之间循环,从未真正擅长其中任何一个。

此外,由于生成器和鉴别器不断相互对抗,它们的参数可能最终会振荡并变得不稳定。训练可能开始正常,然后由于这些不稳定性,突然出现无明显原因的分歧。由于许多因素影响这些复杂的动态,GAN 对超参数非常敏感:您可能需要花费大量精力对其进行微调。实际上,这就是为什么在编译模型时我使用 RMSProp 而不是 Nadam:使用 Nadam 时,我遇到了严重的模式崩溃。

自 2014 年以来,这些问题一直让研究人员忙碌不已:许多论文已经发表在这个主题上,一些论文提出了新的成本函数(尽管谷歌研究人员在2018 年的一篇论文中质疑了它们的效率)或稳定训练或避免模式崩溃问题的技术。例如,一种流行的技术称为经验重播,它包括在每次迭代中存储生成器生成的图像在重播缓冲区中(逐渐删除较旧的生成图像),并使用来自该缓冲区的真实图像加上假图像来训练鉴别器(而不仅仅是当前生成器生成的假图像)。这减少了鉴别器过度拟合最新生成器输出的机会。另一种常见的技术称为小批量鉴别:它测量批次中图像的相似性,并将此统计信息提供给鉴别器,以便它可以轻松拒绝缺乏多样性的整个批次的假图像。这鼓励生成器产生更多样化的图像,减少模式崩溃的机会。其他论文简单地提出了表现良好的特定架构。

简而言之,这仍然是一个非常活跃的研究领域,GAN 的动态仍然没有完全被理解。但好消息是取得了巨大进展,一些结果真的令人惊叹!因此,让我们看一些最成功的架构,从几年前的深度卷积 GAN 开始。然后我们将看一下两个更近期(更复杂)的架构。

深度卷积 GAN

原始 GAN 论文的作者尝试了卷积层,但只尝试生成小图像。不久之后,许多研究人员尝试基于更深的卷积网络生成更大的图像的 GAN。这被证明是棘手的,因为训练非常不稳定,但 Alec Radford 等人最终在 2015 年底成功了,经过许多不同架构和超参数的实验。他们将其架构称为深度卷积 GAN(DCGANs)。以下是他们为构建稳定的卷积 GAN 提出的主要准则:

  • 用步进卷积(在鉴别器中)和转置卷积(在生成器中)替换任何池化层。

  • 在生成器和鉴别器中使用批量归一化,除了生成器的输出层和鉴别器的输入层。

  • 删除更深层次架构的全连接隐藏层。

  • 在生成器的所有层中使用 ReLU 激活,除了输出层应使用 tanh。

  • 在鉴别器的所有层中使用泄漏 ReLU 激活。

这些准则在许多情况下都适用,但并非总是如此,因此您可能仍需要尝试不同的超参数。实际上,仅仅改变随机种子并再次训练完全相同的模型有时会奏效。以下是一个在时尚 MNIST 上表现相当不错的小型 DCGAN:

codings_size = 100

generator = tf.keras.Sequential([
    tf.keras.layers.Dense(7 * 7 * 128),
    tf.keras.layers.Reshape([7, 7, 128]),
    tf.keras.layers.BatchNormalization(),
    tf.keras.layers.Conv2DTranspose(64, kernel_size=5, strides=2,
                                    padding="same", activation="relu"),
    tf.keras.layers.BatchNormalization(),
    tf.keras.layers.Conv2DTranspose(1, kernel_size=5, strides=2,
                                    padding="same", activation="tanh"),
])
discriminator = tf.keras.Sequential([
    tf.keras.layers.Conv2D(64, kernel_size=5, strides=2, padding="same",
                           activation=tf.keras.layers.LeakyReLU(0.2)),
    tf.keras.layers.Dropout(0.4),
    tf.keras.layers.Conv2D(128, kernel_size=5, strides=2, padding="same",
                           activation=tf.keras.layers.LeakyReLU(0.2)),
    tf.keras.layers.Dropout(0.4),
    tf.keras.layers.Flatten(),
    tf.keras.layers.Dense(1, activation="sigmoid")
])
gan = tf.keras.Sequential([generator, discriminator])

生成器接受大小为 100 的编码,将其投影到 6,272 维度(7 * 7 * 128),并重新整形结果以获得一个 7×7×128 张量。这个张量被批量归一化并馈送到一个步幅为 2 的转置卷积层,将其从 7×7 上采样到 14×14,并将其深度从 128 减少到 64。结果再次进行批量归一化,并馈送到另一个步幅为 2 的转置卷积层,将其从 14×14 上采样到 28×28,并将深度从 64 减少到 1。这一层使用 tanh 激活函数,因此输出将范围从-1 到 1。因此,在训练 GAN 之前,我们需要将训练集重新缩放到相同的范围。我们还需要重新整形以添加通道维度:

X_train_dcgan = X_train.reshape(-1, 28, 28, 1) * 2. - 1. # reshape and rescale

鉴别器看起来很像用于二元分类的常规 CNN,只是不是使用最大池化层来对图像进行下采样,而是使用步幅卷积(strides=2)。请注意,我们使用了泄漏的 ReLU 激活函数。总体而言,我们遵守了 DCGAN 的指导方针,只是将鉴别器中的BatchNormalization层替换为Dropout层;否则,在这种情况下训练会不稳定。随意调整这个架构:您将看到它对超参数非常敏感,特别是两个网络的相对学习率。

最后,要构建数据集,然后编译和训练这个模型,我们可以使用之前的相同代码。经过 50 个训练周期后,生成器产生的图像如图 17-16 所示。它还不完美,但其中许多图像相当令人信服。

mls3 1716

图 17-16。DCGAN 在训练 50 个周期后生成的图像

如果您扩大这个架构并在大量人脸数据集上进行训练,您可以获得相当逼真的图像。事实上,DCGAN 可以学习相当有意义的潜在表示,如图 17-17 所示:生成了许多图像,手动选择了其中的九个(左上角),包括三个戴眼镜的男性,三个不戴眼镜的男性和三个不戴眼镜的女性。对于这些类别中的每一个,用于生成图像的编码被平均,然后基于结果的平均编码生成图像(左下角)。简而言之,左下角的三幅图像分别代表位于其上方的三幅图像的平均值。但这不是在像素级别计算的简单平均值(这将导致三个重叠的脸),而是在潜在空间中计算的平均值,因此图像看起来仍然像正常的脸。令人惊讶的是,如果您计算戴眼镜的男性,减去不戴眼镜的男性,再加上不戴眼镜的女性——其中每个术语对应于一个平均编码——并生成对应于此编码的图像,您将得到右侧面孔网格中心的图像:一个戴眼镜的女性!其周围的其他八幅图像是基于相同向量加上一点噪音生成的,以展示 DCGAN 的语义插值能力。能够在人脸上进行算术运算感觉像是科幻!

然而,DCGAN 并不完美。例如,当您尝试使用 DCGAN 生成非常大的图像时,通常会出现局部令人信服的特征,但整体上存在不一致,比如一只袖子比另一只长得多的衬衫,不同的耳环,或者眼睛看向相反的方向。您如何解决这个问题?

mls3 1717

图 17-17。视觉概念的向量算术(DCGAN 论文中的第 7 部分图)⁠¹³
提示

如果将每个图像的类别作为额外输入添加到生成器和鉴别器中,它们将学习每个类别的外观,因此您将能够控制生成器生成的每个图像的类别。这被称为条件 GAN(CGAN)。⁠¹⁴

GAN 的渐进增长

在一篇2018 年的论文,Nvidia 的研究人员 Tero Kerras 等人提出了一项重要技术:他们建议在训练开始时生成小图像,然后逐渐向生成器和鉴别器添加卷积层,以生成越来越大的图像(4×4、8×8、16×16,...,512×512、1,024×1,024)。这种方法类似于贪婪逐层训练堆叠自编码器。额外的层被添加到生成器的末尾和鉴别器的开头,并且之前训练过的层仍然可训练。

例如,当将生成器的输出从 4×4 增加到 8×8 时(参见图 17-18),在现有卷积层(“Conv 1”)中添加了一个上采样层(使用最近邻过滤)以生成 8×8 特征图。这些被馈送到新的卷积层(“Conv 2”),然后馈送到新的输出卷积层。为了避免破坏 Conv 1 的训练权重,我们逐渐淡入两个新的卷积层(在图 17-18 中用虚线表示),并淡出原始输出层。最终输出是新输出(权重为α)和原始输出(权重为 1-α)的加权和,从 0 逐渐增加α到 1。当向鉴别器添加新的卷积层时(后跟一个平均池化层进行下采样),也使用类似的淡入/淡出技术。请注意,所有卷积层都使用"same"填充和步幅为 1,因此它们保留其输入的高度和宽度。这包括原始卷积层,因此它现在产生 8×8 的输出(因为其输入现在是 8×8)。最后,输出层使用核大小为 1。它们只是将它们的输入投影到所需数量的颜色通道(通常为 3)。

mls3 1718

图 17-18。逐渐增长的 GAN:GAN 生成器输出 4×4 彩色图像(左);我们将其扩展到输出 8×8 图像(右)

该论文还介绍了几种旨在增加输出多样性(以避免模式崩溃)并使训练更稳定的技术:

小批量标准差层

添加到鉴别器的末尾附近。对于输入中的每个位置,它计算批次中所有通道和所有实例的标准差(S = tf.math.reduce_std(inputs, axis=[0, -1]))。然后,这些标准差在所有点上进行平均以获得单个值(v = tf.reduce_mean(S))。最后,在批次中的每个实例中添加一个额外的特征图,并填充计算出的值(tf.concat([inputs, tf.fill([batch_size, height, width, 1], v)], axis=-1))。这有什么帮助呢?如果生成器生成具有很少变化的图像,那么在鉴别器的特征图中将会有很小的标准差。由于这一层,鉴别器将更容易访问这个统计数据,使得它不太可能被生成器欺骗,生成器产生的多样性太少。这将鼓励生成器产生更多样化的输出,减少模式崩溃的风险。

均衡学习率

使用均值为 0,标准差为 1 的高斯分布初始化所有权重,而不是使用 He 初始化。然而,在运行时(即每次执行该层时),权重会按照 He 初始化的相同因子进行缩放:它们会被除以2ninputs,其中n[inputs]是该层的输入数量。论文表明,当使用 RMSProp、Adam 或其他自适应梯度优化器时,这种技术显著提高了 GAN 的性能。实际上,这些优化器通过其估计的标准偏差对梯度更新进行归一化(参见第十一章),因此具有较大动态范围的参数将需要更长时间进行训练,而具有较小动态范围的参数可能会更新得太快,导致不稳定性。通过在模型本身中重新缩放权重,而不仅仅在初始化时重新缩放它们,这种方法确保了在整个训练过程中所有参数的动态范围相同,因此它们都以相同的速度学习。这既加快了训练速度,又稳定了训练过程。

像素级归一化层

在生成器的每个卷积层之后添加。它根据同一图像和位置处的所有激活进行归一化,但跨所有通道(除以均方激活的平方根)。在 TensorFlow 代码中,这是inputs / tf.sqrt(tf.reduce_mean(tf.square(X), axis=-1, keepdims=True) + 1e-8)(需要平滑项1e-8以避免除以零)。这种技术避免了由于生成器和鉴别器之间的激烈竞争而导致激活爆炸。

所有这些技术的结合使作者能够生成极具说服力的高清面部图像。但是,“说服力”到底是什么意思呢?评估是在使用 GAN 时面临的一大挑战:尽管可以自动评估生成图像的多样性,但评估其质量是一项更加棘手和主观的任务。一种技术是使用人类评分者,但这既昂贵又耗时。因此,作者提出了一种方法,即考虑生成图像与训练图像之间的局部图像结构的相似性,考虑每个尺度。这个想法引领他们走向另一个开创性的创新:StyleGANs。

StyleGANs

高分辨率图像生成领域的最新技术再次由同一 Nvidia 团队在2018 年的一篇论文中推进,引入了流行的 StyleGAN 架构。作者在生成器中使用风格转移技术,以确保生成的图像在每个尺度上具有与训练图像相同的局部结构,极大地提高了生成图像的质量。鉴别器和损失函数没有被修改,只有生成器。StyleGAN 生成器由两个网络组成(参见图 17-19):

映射网络

一个将潜在表示z(即编码)映射到向量w的八层 MLP。然后,将该向量通过多个仿射变换(即没有激活函数的Dense层,在图 17-19 中用“A”框表示)发送,从而产生多个向量。这些向量控制生成图像的风格在不同层次上,从细粒度纹理(例如头发颜色)到高级特征(例如成人或儿童)。简而言之,映射网络将编码映射到多个风格向量。

合成网络

负责生成图像。它有一个恒定的学习输入(明确地说,这个输入在训练之后将是恒定的,但在训练期间,它会通过反向传播不断调整)。它通过多个卷积和上采样层处理这个输入,就像之前一样,但有两个变化。首先,在输入和所有卷积层的输出(在激活函数之前)中添加了一些噪音。其次,每个噪音层后面都跟着一个自适应实例归一化(AdaIN)层:它独立地标准化每个特征图(通过减去特征图的均值并除以其标准差),然后使用风格向量确定每个特征图的比例和偏移(风格向量包含每个特征图的一个比例和一个偏置项)。

mls3 1719

图 17-19. StyleGAN 的生成器架构(来自 StyleGAN 论文的图 1 的一部分)⁠¹⁸

独立于编码添加噪音的想法非常重要。图像的某些部分是相当随机的,比如每个雀斑或头发的确切位置。在早期的 GAN 中,这种随机性要么来自编码,要么是生成器本身产生的一些伪随机噪音。如果来自编码,这意味着生成器必须将编码的表征能力的相当一部分用于存储噪音,这是相当浪费的。此外,噪音必须能够流经网络并到达生成器的最终层:这似乎是一个不必要的约束,可能会减慢训练速度。最后,一些视觉伪影可能会出现,因为在不同级别使用相同的噪音。如果生成器尝试生成自己的伪随机噪音,这种噪音可能看起来不太令人信服,导致更多的视觉伪影。此外,生成器的一部分权重将被用于生成伪随机噪音,这再次似乎是浪费的。通过添加额外的噪音输入,所有这些问题都可以避免;GAN 能够利用提供的噪音为图像的每个部分添加适量的随机性。

每个级别的添加噪音都是不同的。每个噪音输入由一个充满高斯噪音的单个特征图组成,该特征图被广播到所有特征图(给定级别的)并使用学习的每个特征比例因子进行缩放(这由图 17-19 中的“B”框表示)然后添加。

最后,StyleGAN 使用一种称为混合正则化(或风格混合)的技术,其中一定比例的生成图像是使用两种不同的编码生成的。具体来说,编码c[1]和c[2]被送入映射网络,得到两个风格向量w[1]和w[2]。然后合成网络根据第一级的风格w[1]和剩余级别的风格w[2]生成图像。截断级别是随机选择的。这可以防止网络假设相邻级别的风格是相关的,从而鼓励 GAN 中的局部性,这意味着每个风格向量只影响生成图像中的有限数量的特征。

有这么多种类的 GAN,需要一本整书来覆盖它们。希望这个介绍给您带来了主要思想,最重要的是让您有学习更多的愿望。继续实现您自己的 GAN,如果一开始学习有困难,请不要灰心:不幸的是,这是正常的,需要相当多的耐心才能使其正常运行,但结果是值得的。如果您在实现细节上遇到困难,有很多 Keras 或 TensorFlow 的实现可以参考。实际上,如果您只是想快速获得一些惊人的结果,那么您可以使用预训练模型(例如,有适用于 Keras 的预训练 StyleGAN 模型可用)。

现在我们已经研究了自编码器和 GANs,让我们看看最后一种架构:扩散模型。

扩散模型

扩散模型的理念已经存在多年,但它们首次以现代形式在斯坦福大学和加州大学伯克利分校的 Jascha Sohl-Dickstein 等人的2015 年论文中得到正式形式化。作者们应用了热力学工具来建模扩散过程,类似于一滴牛奶在茶杯中扩散的过程。核心思想是训练一个模型来学习反向过程:从完全混合状态开始,逐渐将牛奶从茶中“分离”。利用这个想法,他们在图像生成方面取得了令人期待的结果,但由于当时 GANs 生成的图像更具说服力,扩散模型并没有得到太多关注。

然后,在 2020 年,也来自加州大学伯克利分校的 Jonathan Ho 等人成功构建了一个能够生成高度逼真图像的扩散模型,他们称之为去噪扩散概率模型(DDPM)。几个月后,OpenAI 研究人员 Alex Nichol 和 Prafulla Dhariwal 的2021 年论文分析了 DDPM 架构,并提出了几项改进,使 DDPM 最终击败了 GANs:DDPM 不仅比 GANs 更容易训练,而且生成的图像更加多样化且质量更高。正如您将看到的那样,DDPM 的主要缺点是生成图像需要很长时间,与 GANs 或 VAEs 相比。

那么 DDPM 究竟是如何工作的呢?假设您从一张猫的图片开始(就像您将在图 17-20 中看到的那样),记为x[0],并且在每个时间步t中向图像添加一点均值为 0,方差为β[t]的高斯噪声。这种噪声对于每个像素都是独立的:我们称之为各向同性。您首先得到图像x[1],然后x[2],依此类推,直到猫完全被噪声隐藏,无法看到。最后一个时间步记为T。在最初的 DDPM 论文中,作者使用T = 1,000,并且他们安排了方差β[t]的方式,使得猫信号在时间步 0 和T之间线性消失。在改进的 DDPM 论文中,T增加到了 4,000,并且方差安排被调整为在开始和结束时变化更慢。简而言之,我们正在逐渐将猫淹没在噪声中:这被称为正向过程

随着我们在正向过程中不断添加更多高斯噪声,像素值的分布变得越来越高斯。我遗漏的一个重要细节是,每一步像素值都会被稍微重新缩放,缩放因子为1-βt。这确保了像素值的均值逐渐接近 0,因为缩放因子比 1 稍微小一点(想象一下反复将一个数字乘以 0.99)。这也确保了方差将逐渐收敛到 1。这是因为像素值的标准差也会被1-βt缩放,因此方差会被 1 - β[t](即缩放因子的平方)缩放。但是方差不能收缩到 0,因为我们在每一步都添加方差为β[t]的高斯噪声。而且由于当你对高斯分布求和时方差会相加,您可以看到方差只能收敛到 1 - β[t] + β[t] = 1。

前向扩散过程总结在 Equation 17-5 中。这个方程不会教你任何新的关于前向过程的知识,但理解这种数学符号是有用的,因为它经常在机器学习论文中使用。这个方程定义了给定 x[t–1] 的概率分布 qx[t] 的概率分布,其均值为 x[t–1] 乘以缩放因子,并且具有等于 β[t]I 的协方差矩阵。这是由 β[t] 乘以单位矩阵 I 得到的,这意味着噪音是各向同性的,方差为 β[t]。

方程 17-5. 前向扩散过程的概率分布 q

q(xt|xt-1)=N(1-βtxt-1,βtI)

有趣的是,前向过程有一个快捷方式:可以在不必先计算 x[1], x[2], …, x[t–1] 的情况下,给定 x[0] 来采样图像 x[t]。实际上,由于多个高斯分布的和也是一个高斯分布,所有的噪音可以在一次性使用 Equation 17-6 中的公式添加。这是我们将要使用的方程,因为它速度更快。

方程 17-6. 前向扩散过程的快捷方式

q(xt|x0)=Nα¯tx0,(1-α¯t)I

当然,我们的目标不是让猫淹没在噪音中。相反,我们想要创造许多新的猫!我们可以通过训练一个能够执行逆过程的模型来实现这一点:从 x[t] 到 x[t–1]。然后我们可以使用它从图像中去除一点噪音,并重复这个操作多次,直到所有的噪音都消失。如果我们在包含许多猫图像的数据集上训练模型,那么我们可以给它一张完全充满高斯噪音的图片,模型将逐渐使一个全新的猫出现(见 Figure 17-20)。

mls3 1720

图 17-20. 前向过程 q 和逆过程 p

好的,让我们开始编码!我们需要做的第一件事是编写前向过程的代码。为此,我们首先需要实现方差计划。我们如何控制猫消失的速度?最初,100%的方差来自原始猫图像。然后在每个时间步t,方差会按照 1 - β[t]乘以,如前所述,并添加噪声。因此,来自初始分布的方差部分在每一步都会缩小 1 - β[t]倍。如果我们定义α[t] = 1 - β[t],那么经过t个时间步骤后,猫信号将被乘以α̅[t] = α[1α[2]×…​×α[t] = α¯t=i=1tαt。这个“猫信号”因子α̅[t]是我们希望安排的,使其在时间步 0 和T之间逐渐从 1 缩小到 0。在改进的 DDPM 论文中,作者根据方程 17-7 安排α̅[t]。这个计划在图 17-21 中表示。

方程 17-7。前向扩散过程的方差计划方程

βt=1-α¯tα¯t-1,其中 α¯t=f(t)f(0) 和 f(t)=cos(t/T+s1+s·π2)2

在这些方程中:

  • s是一个微小值,防止β[t]在t = 0 附近太小。在论文中,作者使用了s = 0.008。

  • β[t]被剪切为不大于 0.999,以避免在t = T附近的不稳定性。

mls3 1721

图 17-21。噪声方差计划β[t],以及剩余信号方差α̅[t]

让我们创建一个小函数来计算α[t],β[t]和α̅[t],并使用T = 4,000 调用它:

def variance_schedule(T, s=0.008, max_beta=0.999):
    t = np.arange(T + 1)
    f = np.cos((t / T + s) / (1 + s) * np.pi / 2) ** 2
    alpha = np.clip(f[1:] / f[:-1], 1 - max_beta, 1)
    alpha = np.append(1, alpha).astype(np.float32)  # add α₀ = 1
    beta = 1 - alpha
    alpha_cumprod = np.cumprod(alpha)
    return alpha, alpha_cumprod, beta  # αₜ , α̅ₜ , βₜ for t = 0 to T

T = 4000
alpha, alpha_cumprod, beta = variance_schedule(T)

为了训练我们的模型来逆转扩散过程,我们需要来自前向过程不同时间步的嘈杂图像。为此,让我们创建一个prepare_batch()函数,它将从数据集中获取一批干净图像并准备它们:

def prepare_batch(X):
    X = tf.cast(X[..., tf.newaxis], tf.float32) * 2 - 1  # scale from –1 to +1
    X_shape = tf.shape(X)
    t = tf.random.uniform([X_shape[0]], minval=1, maxval=T + 1, dtype=tf.int32)
    alpha_cm = tf.gather(alpha_cumprod, t)
    alpha_cm = tf.reshape(alpha_cm, [X_shape[0]] + [1] * (len(X_shape) - 1))
    noise = tf.random.normal(X_shape)
    return {
        "X_noisy": alpha_cm ** 0.5 * X + (1 - alpha_cm) ** 0.5 * noise,
        "time": t,
    }, noise

让我们看一下这段代码:

  • 为简单起见,我们将使用 Fashion MNIST,因此函数必须首先添加一个通道轴。将像素值从-1 缩放到 1 也会有所帮助,这样它更接近均值为 0,方差为 1 的最终高斯分布。

  • 接下来,该函数创建t,一个包含每个图像批次中随机时间步长的向量,介于 1 和T之间。

  • 然后它使用tf.gather()来获取向量t中每个时间步长的alpha_cumprod的值。这给我们了向量alpha_cm,其中包含每个图像的一个α̅[t]值。

  • 下一行将alpha_cm从[批次大小]重塑为[批次大小, 1, 1, 1]。这是为了确保alpha_cm可以与批次X进行广播。

  • 然后我们生成一些均值为 0,方差为 1 的高斯噪声。

  • 最后,我们使用方程 17-6 将扩散过程应用于图像。请注意,x ** 0.5等于x的平方根。该函数返回一个包含输入和目标的元组。输入表示为一个 Python dict,其中包含嘈杂图像和用于生成每个图像的时间步。目标是用于生成每个图像的高斯噪声。

注意

通过这种设置,模型将预测应从输入图像中减去的噪声,以获得原始图像。为什么不直接预测原始图像呢?嗯,作者尝试过:它简单地效果不如预期。

接下来,我们将创建一个训练数据集和一个验证集,将prepare_batch()函数应用于每个批次。与之前一样,X_trainX_valid包含像素值从 0 到 1 的时尚 MNIST 图像:

def prepare_dataset(X, batch_size=32, shuffle=False):
    ds = tf.data.Dataset.from_tensor_slices(X)
    if shuffle:
        ds = ds.shuffle(buffer_size=10_000)
    return ds.batch(batch_size).map(prepare_batch).prefetch(1)

train_set = prepare_dataset(X_train, batch_size=32, shuffle=True)
valid_set = prepare_dataset(X_valid, batch_size=32)

现在我们准备构建实际的扩散模型本身。它可以是您想要的任何模型,只要它将嘈杂的图像和时间步骤作为输入,并预测应从输入图像中减去的噪声:

def build_diffusion_model():
    X_noisy = tf.keras.layers.Input(shape=[28, 28, 1], name="X_noisy")
    time_input = tf.keras.layers.Input(shape=[], dtype=tf.int32, name="time")
    [...]  # build the model based on the noisy images and the time steps
    outputs = [...]  # predict the noise (same shape as the input images)
    return tf.keras.Model(inputs=[X_noisy, time_input], outputs=[outputs])

DDPM 的作者使用了一个修改过的U-Net 架构,它与我们在第十四章中讨论的 FCN 架构有许多相似之处,用于语义分割:它是一个卷积神经网络,逐渐对输入图像进行下采样,然后再逐渐对其进行上采样,跳跃连接从每个下采样部分的每个级别跨越到相应的上采样部分的级别。为了考虑时间步长,他们使用了与Transformer架构中的位置编码相同的技术对其进行编码(参见第十六章)。在 U-Net 架构的每个级别上,他们通过Dense层传递这些时间编码,并将它们馈送到 U-Net 中。最后,他们还在各个级别使用了多头注意力层。查看本章的笔记本以获取基本实现,或者https://homl.info/ddpmcode以获取官方实现:它基于已弃用的 TF 1.x,但非常易读。

现在我们可以正常训练模型了。作者指出,使用 MAE 损失比 MSE 效果更好。您也可以使用 Huber 损失:

model = build_diffusion_model()
model.compile(loss=tf.keras.losses.Huber(), optimizer="nadam")
history = model.fit(train_set, validation_data=valid_set, epochs=100)

一旦模型训练完成,您可以使用它生成新图像。不幸的是,在反向扩散过程中没有捷径,因此您必须从均值为 0,方差为 1 的高斯分布中随机抽样x[T],然后将其传递给模型预测噪声;使用方程 17-8 从图像中减去它,然后您会得到x[T–1]。重复这个过程 3999 次,直到得到x[0]:如果一切顺利,它应该看起来像一个常规的时尚 MNIST 图像!

方程 17-8。在扩散过程中向后走一步

xt-1=1αtxt-βt1-α¯tϵθ(xt,t)+βtz

在这个方程中,ϵ[θ](x[t], t)代表模型给定输入图像x[t]和时间步长t预测的噪声。θ代表模型参数。此外,z是均值为 0,方差为 1 的高斯噪声。这使得反向过程是随机的:如果您多次运行它,您将得到不同的图像。

让我们编写一个实现这个反向过程的函数,并调用它生成一些图像:

def generate(model, batch_size=32):
    X = tf.random.normal([batch_size, 28, 28, 1])
    for t in range(T, 0, -1):
        noise = (tf.random.normal if t > 1 else tf.zeros)(tf.shape(X))
        X_noise = model({"X_noisy": X, "time": tf.constant([t] * batch_size)})
        X = (
            1 / alpha[t] ** 0.5
            * (X - beta[t] / (1 - alpha_cumprod[t]) ** 0.5 * X_noise)
            + (1 - alpha[t]) ** 0.5 * noise
        )
    return X

X_gen = generate(model)  # generated images

这可能需要一两分钟。这是扩散模型的主要缺点:生成图像很慢,因为模型需要被多次调用。通过使用较小的T值或者同时使用相同模型预测多个步骤,可以加快这一过程,但生成的图像可能不那么漂亮。尽管存在这种速度限制,扩散模型确实生成高质量和多样化的图像,正如您在图 17-22 中所看到的。

mls3 1722

图 17-22。DDPM 生成的图像

最近,扩散模型取得了巨大的进展。特别是,2021 年 12 月由Robin Rombach, Andreas Blattmann 等人发表的一篇论文⁠²³介绍了潜在扩散模型,其中扩散过程发生在潜在空间,而不是在像素空间中。为了实现这一点,使用强大的自编码器将每个训练图像压缩到一个更小的潜在空间中,扩散过程发生在这里,然后自编码器用于解压缩最终的潜在表示,生成输出图像。这极大地加快了图像生成速度,大大减少了训练时间和成本。重要的是,生成的图像质量非常出色。

此外,研究人员还采用了各种调节技术来引导扩散过程,使用文本提示、图像或任何其他输入。这使得快速生成一个漂亮的高分辨率图像成为可能,比如一只读书的蝾螈,或者你可能喜欢的其他任何东西。您还可以使用输入图像来调节图像生成过程。这使得许多应用成为可能,比如外部绘制——在输入图像的边界之外扩展——或内部绘制——填充图像中的空洞。

最后,一个名为稳定扩散的强大预训练潜在扩散模型于 2022 年 8 月由慕尼黑大学 LMU 与包括 StabilityAI 和 Runway 在内的几家公司合作开源,得到了 EleutherAI 和 LAION 的支持。2022 年 9 月,它被移植到 TensorFlow,并包含在KerasCV中,这是由 Keras 团队构建的计算机视觉库。现在任何人都可以在几秒钟内免费生成令人惊叹的图像,即使是在普通笔记本电脑上(请参阅本章的最后一个练习)。可能性是无限的!

在下一章中,我们将转向深度强化学习的一个完全不同的分支。

练习

  1. 自编码器主要用于哪些任务?

  2. 假设你想要训练一个分类器,你有大量未标记的训练数据,但只有几千个标记实例。自编码器如何帮助?你会如何继续?

  3. 如果自编码器完美地重建输入,它一定是一个好的自编码器吗?你如何评估自编码器的性能?

  4. 什么是欠完备和过完备自编码器?过度欠完备自编码器的主要风险是什么?过度完备自编码器的主要风险又是什么?

  5. 如何在堆叠自编码器中绑定权重?这样做的目的是什么?

  6. 什么是生成模型?你能说出一种生成自编码器吗?

  7. 什么是 GAN?你能说出几个 GAN 可以发挥作用的任务吗?

  8. 训练 GAN 时的主要困难是什么?

  9. 扩散模型擅长什么?它们的主要限制是什么?

  10. 尝试使用去噪自编码器预训练图像分类器。您可以使用 MNIST(最简单的选项),或者如果您想要更大的挑战,可以使用更复杂的图像数据集,比如CIFAR10。无论您使用的数据集是什么,都要遵循以下步骤:

    1. 将数据集分割成训练集和测试集。在完整的训练集上训练一个深度去噪自编码器。

    2. 检查图像是否被相当好地重建。可视化激活编码层中每个神经元的图像。

    3. 构建一个分类 DNN,重用自编码器的较低层。仅使用训练集中的 500 张图像进行训练。它在有无预训练的情况下表现更好吗?

  11. 在您选择的图像数据集上训练一个变分自编码器,并使用它生成图像。或者,您可以尝试找到一个您感兴趣的无标签数据集,看看是否可以生成新样本。

  12. 训练一个 DCGAN 来处理您选择的图像数据集,并使用它生成图像。添加经验重放,看看这是否有帮助。将其转换为条件 GAN,您可以控制生成的类别。

  13. 浏览 KerasCV 出色的稳定扩散教程,并生成一幅漂亮的图画,展示一只读书的蝾螈。如果您在 Twitter 上发布您最好的图画,请在@ aureliengeron 处标记我。我很想看看您的创作!

这些练习的解决方案可在本章笔记本的末尾找到,网址为https://homl.info/colab3

¹ William G. Chase 和 Herbert A. Simon,“国际象棋中的感知”,认知心理学 4,第 1 期(1973 年):55-81。

² Yoshua Bengio 等,“深度网络的贪婪逐层训练”,第 19 届神经信息处理系统国际会议论文集(2006):153-160。

³ Jonathan Masci 等,“用于分层特征提取的堆叠卷积自编码器”,第 21 届国际人工神经网络会议论文集 1(2011):52-59。

⁴ Pascal Vincent 等,“使用去噪自编码器提取和组合稳健特征”,第 25 届国际机器学习会议论文集(2008):1096-1103。

⁵ Pascal Vincent 等,“堆叠去噪自编码器:使用局部去噪标准在深度网络中学习有用的表示”,机器学习研究杂志 11(2010):3371-3408。

⁶ Diederik Kingma 和 Max Welling,“自编码变分贝叶斯”,arXiv 预印本 arXiv:1312.6114(2013)。

⁷ 变分自编码器实际上更通用;编码不限于高斯分布。

⁸ 要了解更多数学细节,请查看有关变分自编码器的原始论文,或查看 Carl Doersch 的优秀教程(2016)。

⁹ Ian Goodfellow 等,“生成对抗网络”,第 27 届神经信息处理系统国际会议论文集 2(2014):2672-2680。

¹⁰ 要了解主要 GAN 损失的良好比较,请查看 Hwalsuk Lee 的这个GitHub 项目

¹¹ Mario Lucic 等,“GAN 是否平等?大规模研究”,第 32 届神经信息处理系统国际会议论文集(2018):698-707。

¹² Alec Radford 等,“使用深度卷积生成对抗网络进行无监督表示学习”,arXiv 预印本 arXiv:1511.06434(2015)。

¹³ 在作者的亲切授权下再现。

¹⁴ Mehdi Mirza 和 Simon Osindero,“有条件生成对抗网络”,arXiv 预印本 arXiv:1411.1784(2014)。

¹⁵ Tero Karras 等,“用于改善质量、稳定性和变化的 GAN 的渐进增长”,国际学习表示会议论文集(2018)。

¹⁶ 变量的动态范围是其可能取的最高值和最低值之间的比率。

¹⁷ Tero Karras 等人,“基于风格的生成对抗网络架构”,arXiv 预印本 arXiv:1812.04948(2018)。

¹⁸ 在作者的亲切授权下复制。

¹⁹ Jascha Sohl-Dickstein 等人,“使用非平衡热力学进行深度无监督学习”,arXiv 预印本 arXiv:1503.03585(2015)。

²⁰ Jonathan Ho 等人,“去噪扩散概率模型”(2020)。

²¹ Alex Nichol 和 Prafulla Dhariwal,“改进的去噪扩散概率模型”(2021)。

²² Olaf Ronneberger 等人,“U-Net:用于生物医学图像分割的卷积网络”,arXiv 预印本 arXiv:1505.04597(2015)。

²³ Robin Rombach,Andreas Blattmann 等人,“使用潜在扩散模型进行高分辨率图像合成”,arXiv 预印本 arXiv:2112.10752(2021)。

第十八章:强化学习

强化学习(RL)是当今最激动人心的机器学习领域之一,也是最古老的之一。自上世纪 50 年代以来一直存在,多年来产生了许多有趣的应用,特别是在游戏(例如 TD-Gammon,一个下棋程序)和机器控制方面,但很少成为头条新闻。然而,一场革命发生在2013 年,当时来自英国初创公司 DeepMind 的研究人员展示了一个系统,可以从头开始学习玩几乎任何 Atari 游戏,最终在大多数游戏中超越人类,只使用原始像素作为输入,而不需要任何关于游戏规则的先验知识。这是一系列惊人壮举的开始,最终在 2016 年 3 月,他们的系统 AlphaGo 在围棋比赛中击败了传奇职业选手李世石,并在 2017 年 5 月击败了世界冠军柯洁。没有任何程序曾经接近击败这个游戏的大师,更不用说世界冠军了。如今,整个强化学习领域充满了新的想法,具有广泛的应用范围。

那么,DeepMind(2014 年被 Google 以超过 5 亿美元的价格收购)是如何实现所有这些的呢?回顾起来似乎相当简单:他们将深度学习的力量应用于强化学习领域,而且效果超出了他们最疯狂的梦想。在本章中,我将首先解释什么是强化学习以及它擅长什么,然后介绍深度强化学习中最重要的两种技术:策略梯度和深度 Q 网络,包括对马尔可夫决策过程的讨论。让我们开始吧!

学习优化奖励

在强化学习中,软件 代理 在一个 环境 中进行 观察行动,并从环境中获得 奖励。其目标是学会以一种方式行动,以最大化其随时间的预期奖励。如果您不介意有点拟人化,您可以将积极奖励视为快乐,将负面奖励视为痛苦(在这种情况下,“奖励”这个术语有点误导)。简而言之,代理在环境中行动,并通过试错学习来最大化其快乐并最小化其痛苦。

这是一个非常广泛的设置,可以应用于各种任务。以下是一些示例(参见 图 18-1):

  1. 代理程序可以是控制机器人的程序。在这种情况下,环境是真实世界,代理通过一组传感器(如摄像头和触摸传感器)观察环境,其行动包括发送信号以激活电机。它可能被编程为在接近目标位置时获得积极奖励,而在浪费时间或走错方向时获得负面奖励。

  2. 代理可以是控制 Ms. Pac-Man 的程序。在这种情况下,环境是 Atari 游戏的模拟,行动是九种可能的摇杆位置(左上、下、中心等),观察是屏幕截图,奖励只是游戏得分。

  3. 同样,代理可以是玩围棋等棋盘游戏的程序。只有在赢得比赛时才会获得奖励。

  4. 代理不必控制物理(或虚拟)移动的东西。例如,它可以是一个智能恒温器,每当接近目标温度并节省能源时获得积极奖励,当人类需要调整温度时获得负面奖励,因此代理必须学会预测人类需求。

  5. 代理可以观察股市价格并决定每秒买入或卖出多少。奖励显然是货币收益和损失。

请注意,可能根本没有任何正面奖励;例如,代理可能在迷宫中四处移动,在每个时间步都会获得负面奖励,因此最好尽快找到出口!还有许多其他适合强化学习的任务示例,例如自动驾驶汽车、推荐系统、在网页上放置广告,或者控制图像分类系统应该关注的位置。

mls3 1801

图 18-1. 强化学习示例:(a) 机器人,(b) Ms. Pac-Man,(c) 围棋选手,(d) 恒温器,(e) 自动交易员⁠⁵

策略搜索

软件代理用来确定其行动的算法称为其策略。策略可以是一个神经网络,将观察作为输入并输出要采取的行动(见图 18-2)。

mls3 1802

图 18-2。使用神经网络策略的强化学习

策略可以是你能想到的任何算法,并且不必是确定性的。实际上,在某些情况下,它甚至不必观察环境!例如,考虑一个机器人吸尘器,其奖励是在 30 分钟内吸尘的量。它的策略可以是每秒以概率p向前移动,或者以概率 1 - p随机向左或向右旋转。旋转角度将是- r和+ r之间的随机角度。由于这个策略涉及一些随机性,它被称为随机策略。机器人将有一个不规则的轨迹,这保证了它最终会到达它可以到达的任何地方并清理所有的灰尘。问题是,在 30 分钟内它会吸尘多少?

你会如何训练这样的机器人?你只能调整两个策略参数:概率p和角度范围r。一个可能的学习算法是尝试许多不同的参数值,并选择表现最好的组合(参见图 18-3)。这是一个策略搜索的例子,这种情况下使用了一种蛮力方法。当策略空间太大时(这通常是情况),通过这种方式找到一组好的参数就像在一个巨大的草堆中寻找一根针。

探索政策空间的另一种方法是使用遗传算法。例如,您可以随机创建第一代 100 个政策并尝试它们,然后“淘汰”最差的 80 个政策,并让 20 个幸存者每人产生 4 个后代。后代是其父母的副本加上一些随机变化。幸存的政策及其后代一起构成第二代。您可以继续通过这种方式迭代生成,直到找到一个好的政策。

mls3 1803

图 18-3。政策空间中的四个点(左)和代理的相应行为(右)

另一种方法是使用优化技术,通过评估奖励相对于策略参数的梯度,然后通过沿着梯度朝着更高奖励的方向调整这些参数。我们将在本章后面更详细地讨论这种方法,称为策略梯度(PG)。回到吸尘器机器人,您可以稍微增加p,并评估这样做是否会增加机器人在 30 分钟内吸尘的量;如果是,那么再增加p一些,否则减少p。我们将使用 TensorFlow 实现一个流行的 PG 算法,但在此之前,我们需要为代理创建一个环境——现在是介绍 OpenAI Gym 的时候了。

OpenAI Gym 简介

强化学习的一个挑战是,为了训练一个代理程序,您首先需要一个可用的环境。如果您想编写一个代理程序来学习玩 Atari 游戏,您将需要一个 Atari 游戏模拟器。如果您想编写一个行走机器人,那么环境就是现实世界,您可以直接在该环境中训练您的机器人。然而,这也有其局限性:如果机器人掉下悬崖,您不能简单地点击撤销。您也不能加快时间——增加计算能力不会使机器人移动得更快——而且通常来说,同时训练 1000 个机器人的成本太高。简而言之,在现实世界中训练是困难且缓慢的,因此您通常至少需要一个模拟环境来进行引导训练。例如,您可以使用类似PyBulletMuJoCo的库进行 3D 物理模拟。

OpenAI Gym是一个工具包,提供各种模拟环境(Atari 游戏,棋盘游戏,2D 和 3D 物理模拟等),您可以用它来训练代理程序,比较它们,或者开发新的 RL 算法。

OpenAI Gym 在 Colab 上预先安装,但是它是一个较旧的版本,因此您需要用最新版本替换它。您还需要安装一些它的依赖项。如果您在自己的机器上编程而不是在 Colab 上,并且按照https://homl.info/install上的安装说明进行操作,那么您可以跳过这一步;否则,请输入以下命令:

# Only run these commands on Colab or Kaggle!
%pip install -q -U gym
%pip install -q -U gym[classic_control,box2d,atari,accept-rom-license]

第一个%pip命令将 Gym 升级到最新版本。-q选项代表quiet:它使输出更简洁。-U选项代表upgrade。第二个%pip命令安装了运行各种环境所需的库。这包括来自控制理论(控制动态系统的科学)的经典环境,例如在小车上平衡杆。它还包括基于 Box2D 库的环境——一个用于游戏的 2D 物理引擎。最后,它包括基于 Arcade Learning Environment(ALE)的环境,这是 Atari 2600 游戏的模拟器。几个 Atari 游戏的 ROM 会被自动下载,通过运行这段代码,您同意 Atari 的 ROM 许可证。

有了这个,您就可以使用 OpenAI Gym 了。让我们导入它并创建一个环境:

import gym

env = gym.make("CartPole-v1", render_mode="rgb_array")

在这里,我们创建了一个 CartPole 环境。这是一个 2D 模拟,其中一个小车可以被加速向左或向右,以平衡放在其顶部的杆(参见图 18-4)。这是一个经典的控制任务。

提示

gym.envs.registry字典包含所有可用环境的名称和规格。

mls3 1804

图 18-4。CartPole 环境

在创建环境之后,您必须使用reset()方法对其进行初始化,可以选择性地指定一个随机种子。这将返回第一个观察结果。观察结果取决于环境的类型。对于 CartPole 环境,每个观察结果都是一个包含四个浮点数的 1D NumPy 数组,表示小车的水平位置(0.0 = 中心),其速度(正数表示向右),杆的角度(0.0 = 垂直),以及其角速度(正数表示顺时针)。reset()方法还返回一个可能包含额外环境特定信息的字典。这对于调试或训练可能很有用。例如,在许多 Atari 环境中,它包含剩余的生命次数。然而,在 CartPole 环境中,这个字典是空的。

>>> obs, info = env.reset(seed=42)
>>> obs
array([ 0.0273956 , -0.00611216,  0.03585979,  0.0197368 ], dtype=float32)
>>> info
{}

让我们调用render()方法将这个环境渲染为图像。由于在创建环境时设置了render_mode="rgb_array",图像将作为一个 NumPy 数组返回:

>>> img = env.render()
>>> img.shape  # height, width, channels (3 = Red, Green, Blue)
(400, 600, 3)

然后,您可以使用 Matplotlib 的imshow()函数来显示这个图像,就像往常一样。

现在让我们询问环境有哪些可能的动作:

>>> env.action_space
Discrete(2)

Discrete(2)表示可能的动作是整数 0 和 1,分别代表向左或向右加速。其他环境可能有额外的离散动作,或其他类型的动作(例如连续动作)。由于杆向右倾斜(obs[2] > 0),让我们加速小车向右:

>>> action = 1  # accelerate right
>>> obs, reward, done, truncated, info = env.step(action)
>>> obs
array([ 0.02727336,  0.18847767,  0.03625453, -0.26141977], dtype=float32)
>>> reward
1.0
>>> done
False
>>> truncated
False
>>> info
{}

step() 方法执行所需的动作并返回五个值:

obs

这是新的观察。小车现在向右移动(obs[1] > 0)。杆仍然向右倾斜(obs[2] > 0),但它的角速度现在是负的(obs[3] < 0),所以在下一步之后它可能会向左倾斜。

reward

在这个环境中,无论你做什么,每一步都会获得 1.0 的奖励,所以目标是尽可能让情节运行更长时间。

done

当情节结束时,这个值将是True。当杆倾斜得太多,或者离开屏幕,或者经过 200 步后(在这种情况下,你赢了),情节就会结束。之后,环境必须被重置才能再次使用。

truncated

当一个情节被提前中断时,这个值将是True,例如通过一个强加每个情节最大步数的环境包装器(请参阅 Gym 的文档以获取有关环境包装器的更多详细信息)。一些强化学习算法会将截断的情节与正常结束的情节(即doneTrue时)区别对待,但在本章中,我们将对它们进行相同处理。

info

这个特定于环境的字典可能提供额外的信息,就像reset()方法返回的那样。

提示

当你使用完一个环境后,应该调用它的close()方法来释放资源。

让我们硬编码一个简单的策略,当杆向左倾斜时加速向左,当杆向右倾斜时加速向右。我们将运行此策略,以查看它在 500 个情节中获得的平均奖励:

def basic_policy(obs):
    angle = obs[2]
    return 0 if angle < 0 else 1

totals = []
for episode in range(500):
    episode_rewards = 0
    obs, info = env.reset(seed=episode)
    for step in range(200):
        action = basic_policy(obs)
        obs, reward, done, truncated, info = env.step(action)
        episode_rewards += reward
        if done or truncated:
            break

    totals.append(episode_rewards)

这段代码是不言自明的。让我们看看结果:

>>> import numpy as np
>>> np.mean(totals), np.std(totals), min(totals), max(totals)
(41.698, 8.389445512070509, 24.0, 63.0)

即使尝试了 500 次,这个策略也从未成功让杆连续保持直立超过 63 步。不太好。如果你看一下本章笔记本中的模拟,你会看到小车左右摆动得越来越强烈,直到杆倾斜得太多。让我们看看神经网络是否能提出一个更好的策略。

神经网络策略

让我们创建一个神经网络策略。这个神经网络将以观察作为输入,并输出要执行的动作,就像我们之前硬编码的策略一样。更准确地说,它将为每个动作估计一个概率,然后我们将根据估计的概率随机选择一个动作(参见图 18-5)。在 CartPole 环境中,只有两种可能的动作(左或右),所以我们只需要一个输出神经元。它将输出动作 0(左)的概率p,当然动作 1(右)的概率将是 1 - p。例如,如果它输出 0.7,那么我们将以 70%的概率选择动作 0,或者以 30%的概率选择动作 1。

mls3 1805

图 18-5. 神经网络策略

你可能会想为什么我们根据神经网络给出的概率随机选择一个动作,而不是只选择得分最高的动作。这种方法让代理人在探索新动作和利用已知效果良好的动作之间找到平衡。这里有一个类比:假设你第一次去一家餐馆,所有菜看起来都一样吸引人,所以你随机挑选了一个。如果它很好吃,你可以增加下次点它的概率,但你不应该将这个概率增加到 100%,否则你永远不会尝试其他菜,其中一些可能比你尝试的这个更好。这个探索/利用的困境在强化学习中是核心的。

还要注意,在这种特定环境中,过去的动作和观察可以安全地被忽略,因为每个观察包含了环境的完整状态。如果有一些隐藏状态,那么您可能需要考虑过去的动作和观察。例如,如果环境只透露了小车的位置而没有速度,那么您不仅需要考虑当前观察,还需要考虑上一个观察以估计当前速度。另一个例子是当观察是嘈杂的;在这种情况下,通常希望使用过去几个观察来估计最可能的当前状态。因此,CartPole 问题非常简单;观察是无噪声的,并且包含了环境的完整状态。

以下是使用 Keras 构建基本神经网络策略的代码:

import tensorflow as tf

model = tf.keras.Sequential([
    tf.keras.layers.Dense(5, activation="relu"),
    tf.keras.layers.Dense(1, activation="sigmoid"),
])

我们使用Sequential模型来定义策略网络。输入的数量是观察空间的大小——在 CartPole 的情况下是 4——我们只有五个隐藏单元,因为这是一个相当简单的任务。最后,我们希望输出一个单一的概率——向左移动的概率——因此我们使用具有 sigmoid 激活函数的单个输出神经元。如果有超过两种可能的动作,每种动作将有一个输出神经元,并且我们将使用 softmax 激活函数。

好的,现在我们有一个神经网络策略,它将接收观察并输出动作概率。但是我们如何训练它呢?

评估动作:信用分配问题

如果我们知道每一步的最佳行动是什么,我们可以像平常一样训练神经网络,通过最小化估计概率分布与目标概率分布之间的交叉熵来实现,这将只是常规的监督学习。然而,在强化学习中,智能体得到的唯一指导是通过奖励,而奖励通常是稀疏和延迟的。例如,如果智能体设法在 100 步内平衡杆,它如何知道这 100 个动作中哪些是好的,哪些是坏的?它只知道在最后一个动作之后杆倒了,但肯定不是这个最后一个动作完全负责。这被称为信用分配问题:当智能体获得奖励时,它很难知道哪些动作应该得到赞扬(或责备)。想象一只狗表现良好几个小时后才得到奖励;它会明白为什么会得到奖励吗?

为了解决这个问题,一个常见的策略是基于之后所有奖励的总和来评估一个动作,通常在每一步应用一个折扣因子γ(gamma)。这些折扣后的奖励之和被称为动作的回报。考虑图 18-6 中的例子。如果一个智能体连续三次向右移动,并在第一步后获得+10 奖励,在第二步后获得 0 奖励,最后在第三步后获得-50 奖励,那么假设我们使用一个折扣因子γ=0.8,第一个动作的回报将是 10 + γ × 0 + γ² × (–50) = –22。如果折扣因子接近 0,那么未来的奖励与即时奖励相比不会占据很大比重。相反,如果折扣因子接近 1,那么未来的奖励将几乎和即时奖励一样重要。典型的折扣因子从 0.9 到 0.99 不等。使用折扣因子 0.95,未来 13 步的奖励大约相当于即时奖励的一半(因为 0.95¹³ ≈ 0.5),而使用折扣因子 0.99,未来 69 步的奖励相当于即时奖励的一半。在 CartPole 环境中,动作具有相当短期的影响,因此选择折扣因子 0.95 似乎是合理的。

mls3 1806

图 18-6。计算动作的回报:折扣未来奖励之和

当然,一个好的行动可能会被几个导致杆迅速倒下的坏行动跟随,导致好的行动获得较低的回报。同样,一个好的演员有时可能会出演一部糟糕的电影。然而,如果我们玩足够多次游戏,平均而言好的行动将获得比坏行动更高的回报。我们想要估计一个行动相对于其他可能行动的平均优势有多大。这被称为行动优势。为此,我们必须运行许多情节,并通过减去均值并除以标准差来标准化所有行动回报。之后,我们可以合理地假设具有负优势的行动是坏的,而具有正优势的行动是好的。现在我们有了一种评估每个行动的方法,我们准备使用策略梯度来训练我们的第一个代理。让我们看看如何。

策略梯度

正如前面讨论的,PG 算法通过沿着梯度朝着更高奖励的方向优化策略的参数。一种流行的 PG 算法类别称为REINFORCE 算法,由 Ronald Williams 于 1992 年提出。这里是一个常见的变体:

  1. 首先,让神经网络策略玩游戏多次,并在每一步计算使选择的行动更有可能的梯度,但暂时不应用这些梯度。

  2. 在运行了几个情节之后,使用前一节中描述的方法计算每个行动的优势。

  3. 如果一个行动的优势是正的,这意味着这个行动可能是好的,你希望应用之前计算的梯度,使这个行动在未来更有可能被选择。然而,如果一个行动的优势是负的,这意味着这个行动可能是坏的,你希望应用相反的梯度,使这个行动在未来略微减少。解决方案是将每个梯度向量乘以相应行动的优势。

  4. 最后,计算所有结果梯度向量的平均值,并用它执行一步梯度下降。

让我们使用 Keras 来实现这个算法。我们将训练之前构建的神经网络策略,使其学会在小车上平衡杆。首先,我们需要一个函数来执行一步。我们暂时假设无论采取什么行动都是正确的,以便我们可以计算损失及其梯度。这些梯度将暂时保存一段时间,我们稍后会根据行动的好坏来修改它们:

def play_one_step(env, obs, model, loss_fn):
    with tf.GradientTape() as tape:
        left_proba = model(obs[np.newaxis])
        action = (tf.random.uniform([1, 1]) > left_proba)
        y_target = tf.constant([[1.]]) - tf.cast(action, tf.float32)
        loss = tf.reduce_mean(loss_fn(y_target, left_proba))

    grads = tape.gradient(loss, model.trainable_variables)
    obs, reward, done, truncated, info = env.step(int(action))
    return obs, reward, done, truncated, grads

让我们来看看这个函数:

  • GradientTape块中(参见第十二章),我们首先调用模型,给它一个观察值。我们将观察值重塑为包含单个实例的批次,因为模型期望一个批次。这将输出向左移动的概率。

  • 接下来,我们随机抽取一个介于 0 和 1 之间的浮点数,并检查它是否大于left_probaaction将以left_proba的概率为False,或以1 - left_proba的概率为True。一旦我们将这个布尔值转换为整数,行动将以适当的概率为 0(左)或 1(右)。

  • 现在我们定义向左移动的目标概率:它是 1 减去行动(转换为浮点数)。如果行动是 0(左),那么向左移动的目标概率将是 1。如果行动是 1(右),那么目标概率将是 0。

  • 然后我们使用给定的损失函数计算损失,并使用 tape 计算损失相对于模型可训练变量的梯度。同样,这些梯度稍后会在应用之前进行调整,取决于行动的好坏。

  • 最后,我们执行选择的行动,并返回新的观察值、奖励、该情节是否结束、是否截断,当然还有我们刚刚计算的梯度。

现在让我们创建另一个函数,它将依赖于play_one_step()函数来玩多个回合,返回每个回合和每个步骤的所有奖励和梯度:

def play_multiple_episodes(env, n_episodes, n_max_steps, model, loss_fn):
    all_rewards = []
    all_grads = []
    for episode in range(n_episodes):
        current_rewards = []
        current_grads = []
        obs, info = env.reset()
        for step in range(n_max_steps):
            obs, reward, done, truncated, grads = play_one_step(
                env, obs, model, loss_fn)
            current_rewards.append(reward)
            current_grads.append(grads)
            if done or truncated:
                break

        all_rewards.append(current_rewards)
        all_grads.append(current_grads)

    return all_rewards, all_grads

这段代码返回了一个奖励列表的列表:每个回合一个奖励列表,每个步骤一个奖励。它还返回了一个梯度列表的列表:每个回合一个梯度列表,每个梯度列表包含每个步骤的一个梯度元组,每个元组包含每个可训练变量的一个梯度张量。

该算法将使用play_multiple_episodes()函数多次玩游戏(例如,10 次),然后它将回头查看所有奖励,对其进行折扣,并对其进行归一化。为此,我们需要几个额外的函数;第一个将计算每个步骤的未来折扣奖励总和,第二个将通过减去均值并除以标准差来对所有这些折扣奖励(即回报)在许多回合中进行归一化:

def discount_rewards(rewards, discount_factor):
    discounted = np.array(rewards)
    for step in range(len(rewards) - 2, -1, -1):
        discounted[step] += discounted[step + 1] * discount_factor
    return discounted

def discount_and_normalize_rewards(all_rewards, discount_factor):
    all_discounted_rewards = [discount_rewards(rewards, discount_factor)
                              for rewards in all_rewards]
    flat_rewards = np.concatenate(all_discounted_rewards)
    reward_mean = flat_rewards.mean()
    reward_std = flat_rewards.std()
    return [(discounted_rewards - reward_mean) / reward_std
            for discounted_rewards in all_discounted_rewards]

让我们检查一下这是否有效:

>>> discount_rewards([10, 0, -50], discount_factor=0.8)
array([-22, -40, -50])
>>> discount_and_normalize_rewards([[10, 0, -50], [10, 20]],
...                                discount_factor=0.8)
...
[array([-0.28435071, -0.86597718, -1.18910299]),
 array([1.26665318, 1.0727777 ])]

调用discount_rewards()返回了我们预期的结果(见图 18-6)。您可以验证函数discount_and_normalize_rewards()确实返回了两个回合中每个动作的归一化优势。请注意,第一个回合比第二个回合差得多,因此它的归一化优势都是负数;第一个回合的所有动作都被认为是不好的,反之第二个回合的所有动作都被认为是好的。

我们几乎准备好运行算法了!现在让我们定义超参数。我们将运行 150 次训练迭代,每次迭代玩 10 个回合,每个回合最多持续 200 步。我们将使用折扣因子 0.95:

n_iterations = 150
n_episodes_per_update = 10
n_max_steps = 200
discount_factor = 0.95

我们还需要一个优化器和损失函数。一个常规的 Nadam 优化器,学习率为 0.01,将会很好地完成任务,我们将使用二元交叉熵损失函数,因为我们正在训练一个二元分类器(有两种可能的动作——左或右):

optimizer = tf.keras.optimizers.Nadam(learning_rate=0.01)
loss_fn = tf.keras.losses.binary_crossentropy

现在我们准备构建和运行训练循环!

for iteration in range(n_iterations):
    all_rewards, all_grads = play_multiple_episodes(
        env, n_episodes_per_update, n_max_steps, model, loss_fn)
    all_final_rewards = discount_and_normalize_rewards(all_rewards,
                                                       discount_factor)
    all_mean_grads = []
    for var_index in range(len(model.trainable_variables)):
        mean_grads = tf.reduce_mean(
            [final_reward * all_grads[episode_index][step][var_index]
             for episode_index, final_rewards in enumerate(all_final_rewards)
                 for step, final_reward in enumerate(final_rewards)], axis=0)
        all_mean_grads.append(mean_grads)

    optimizer.apply_gradients(zip(all_mean_grads, model.trainable_variables))

让我们逐步走过这段代码:

  • 在每次训练迭代中,此循环调用play_multiple_episodes()函数,该函数播放 10 个回合,并返回每个步骤中每个回合的奖励和梯度。

  • 然后我们调用discount_and_normalize_rewards()函数来计算每个动作的归一化优势,这在这段代码中称为final_reward。这提供了一个衡量每个动作实际上是好还是坏的指标。

  • 接下来,我们遍历每个可训练变量,并对每个变量计算所有回合和所有步骤中该变量的梯度的加权平均,权重为final_reward

  • 最后,我们使用优化器应用这些均值梯度:模型的可训练变量将被微调,希望策略会有所改善。

我们完成了!这段代码将训练神经网络策略,并成功学会在小车上平衡杆。每个回合的平均奖励将非常接近 200。默认情况下,这是该环境的最大值。成功!

我们刚刚训练的简单策略梯度算法解决了 CartPole 任务,但是它在扩展到更大更复杂的任务时效果不佳。事实上,它具有很高的样本效率低,这意味着它需要很长时间探索游戏才能取得显著进展。这是因为它必须运行多个回合来估计每个动作的优势,正如我们所见。然而,它是更强大算法的基础,比如演员-评论家算法(我们将在本章末简要讨论)。

提示

研究人员试图找到即使代理最初对环境一无所知也能很好运行的算法。然而,除非你在写论文,否则不应该犹豫向代理注入先验知识,因为这将极大加快训练速度。例如,由于你知道杆应该尽可能垂直,你可以添加与杆角度成比例的负奖励。这将使奖励变得不那么稀疏,加快训练速度。此外,如果你已经有一个相当不错的策略(例如硬编码),你可能希望在使用策略梯度来改进之前,训练神经网络来模仿它。

现在我们将看一下另一个流行的算法家族。PG 算法直接尝试优化策略以增加奖励,而我们现在要探索的算法则不那么直接:代理学习估计每个状态的预期回报,或者每个状态中每个动作的预期回报,然后利用这些知识来决定如何行动。要理解这些算法,我们首先必须考虑马尔可夫决策过程(MDPs)。

马尔可夫决策过程

20 世纪初,数学家安德烈·马尔可夫研究了没有记忆的随机过程,称为马尔可夫链。这样的过程具有固定数量的状态,并且在每一步中随机从一个状态演变到另一个状态。它从状态s演变到状态s′的概率是固定的,仅取决于对(s, s′)这一对,而不取决于过去的状态。这就是为什么我们说该系统没有记忆。

图 18-7 显示了一个具有四个状态的马尔可夫链的示例。

mls3 1807

图 18-7. 马尔可夫链示例

假设过程从状态s[0]开始,并且有 70%的概率在下一步保持在该状态。最终,它必定会离开该状态并永远不会回来,因为没有其他状态指向s[0]。如果它进入状态s[1],那么它很可能会进入状态s[2](90%的概率),然后立即返回到状态s[1](100%的概率)。它可能在这两个状态之间交替多次,但最终会陷入状态s[3]并永远留在那里,因为没有出路:这被称为终止状态。马尔可夫链的动态可能非常不同,并且在热力学、化学、统计学等领域被广泛使用。

马尔可夫决策过程是在 20 世纪 50 年代由理查德·贝尔曼首次描述的。⁠¹² 它们类似于马尔可夫链,但有一个区别:在每一步中,代理可以选择几种可能的动作之一,转移概率取决于所选择的动作。此外,一些状态转移会产生一些奖励(正面或负面),代理的目标是找到一个能够随时间最大化奖励的策略。

例如,MDP 在图 18-8 中表示有三个状态(由圆圈表示),并且在每一步最多有三种可能的离散动作(由菱形表示)。

mls3 1808

图 18-8. 马尔可夫决策过程示例

如果代理从状态 s[0] 开始,可以在行动 a[0]、a[1] 或 a[2] 之间选择。如果选择行动 a[1],它就会肯定留在状态 s[0],没有任何奖励。因此,如果愿意,它可以决定永远留在那里。但如果选择行动 a[0],它有 70%的概率获得+10 的奖励并留在状态 s[0]。然后它可以一次又一次地尝试获得尽可能多的奖励,但最终会进入状态 s[1]。在状态 s[1] 中,它只有两种可能的行动:a[0] 或 a[2]。它可以通过反复选择行动 a[0] 来保持原地,或者选择移动到状态 s[2] 并获得-50 的负奖励(疼)。在状态 s[2] 中,它别无选择,只能采取行动 a[1],这很可能会将其带回状态 s[0],在途中获得+40 的奖励。你明白了。通过观察这个 MDP,你能猜出哪种策略会随着时间获得最多的奖励吗?在状态 s[0] 中,很明显行动 a[0] 是最佳选择,在状态 s[2] 中,代理别无选择,只能采取行动 a[1],但在状态 s[1] 中,不明显代理应该保持原地(a[0])还是冒险前进(a[2])。

贝尔曼找到了一种估计任何状态 s最优状态值 V(s) 的方法,这是代理在到达该状态后可以期望的所有折扣未来奖励的总和,假设它采取最优行动。他表明,如果代理采取最优行动,那么贝尔曼最优性方程适用(参见方程 18-1)。这个递归方程表明,如果代理采取最优行动,那么当前状态的最优值等于在采取一个最优行动后平均获得的奖励,再加上这个行动可能导致的所有可能下一个状态的期望最优值。

方程 18-1. 贝尔曼最优性方程

V ( s ) = max a s' T ( s , a , s' ) [ R ( s , a , s' ) + γ · V ( s' ) ] for all s

在这个方程中:

  • T(s, a, s′) 是从状态 s 转移到状态 s′ 的转移概率,假设代理选择行动 a。例如,在图 18-8 中,T(s[2], a[1], s[0]) = 0.8。

  • R(s, a, s′) 是代理从状态 s 转移到状态 s′ 时获得的奖励,假设代理选择行动 a。例如,在图 18-8 中,R(s[2], a[1], s[0]) = +40。

  • γ 是折扣因子。

这个方程直接导致了一个算法,可以精确估计每个可能状态的最优状态值:首先将所有状态值估计初始化为零,然后使用值迭代算法进行迭代更新(参见方程 18-2)。一个显著的结果是,给定足够的时间,这些估计将收敛到最优状态值,对应于最优策略。

方程 18-2. 值迭代算法

V k+1 ( s ) max a s' T ( s , a , s' ) [ R ( s , a , s' ) + γ · V k ( s' ) ] for all s

在这个方程中,V**k是算法的第k次迭代中状态s的估计值。

注意

这个算法是动态规划的一个例子,它将一个复杂的问题分解成可迭代处理的可解子问题。

知道最优状态值可能很有用,特别是用于评估策略,但它并不能为代理提供最优策略。幸运的是,贝尔曼找到了一个非常相似的算法来估计最优状态-动作值,通常称为Q 值(质量值)。状态-动作对(s, a)的最优 Q 值,记为Q**(s, a),是代理在到达状态s并选择动作a*后,在看到此动作结果之前,可以期望平均获得的折现未来奖励的总和,假设在此动作之后它表现最佳。

让我们看看它是如何工作的。再次,您首先将所有 Q 值的估计初始化为零,然后使用Q 值迭代算法进行更新(参见方程 18-3)。

方程 18-3。Q 值迭代算法

Q k+1 ( s , a ) s' T ( s , a , s' ) [ R ( s , a , s' ) + γ · max a' Q k ( s' , a' ) ] for all ( s , a )

一旦您有了最优的 Q 值,定义最优策略π**(s)是微不足道的;当代理处于状态s时,它应该选择具有该状态最高 Q 值的动作:π(s)=argmaxaQ*(s,a)

让我们将这个算法应用到图 18-8 中表示的 MDP 中。首先,我们需要定义 MDP:

transition_probabilities = [  # shape=[s, a, s']
    [[0.7, 0.3, 0.0], [1.0, 0.0, 0.0], [0.8, 0.2, 0.0]],
    [[0.0, 1.0, 0.0], None, [0.0, 0.0, 1.0]],
    [None, [0.8, 0.1, 0.1], None]
]
rewards = [  # shape=[s, a, s']
    [[+10, 0, 0], [0, 0, 0], [0, 0, 0]],
    [[0, 0, 0], [0, 0, 0], [0, 0, -50]],
    [[0, 0, 0], [+40, 0, 0], [0, 0, 0]]
]
possible_actions = [[0, 1, 2], [0, 2], [1]]

例如,要知道在执行动作a[1]后从s[2]到s[0]的转移概率,我们将查找transition_probabilities[2][1][0](为 0.8)。类似地,要获得相应的奖励,我们将查找rewards[2][1][0](为+40)。要获取s[2]中可能的动作列表,我们将查找possible_actions[2](在这种情况下,只有动作a[1]是可能的)。接下来,我们必须将所有 Q 值初始化为零(对于不可能的动作,我们将 Q 值设置为-∞):

Q_values = np.full((3, 3), -np.inf)  # -np.inf for impossible actions
for state, actions in enumerate(possible_actions):
    Q_values[state, actions] = 0.0  # for all possible actions

现在让我们运行 Q 值迭代算法。它重复应用方程 18-3,对每个状态和每个可能的动作的所有 Q 值进行计算:

gamma = 0.90  # the discount factor

for iteration in range(50):
    Q_prev = Q_values.copy()
    for s in range(3):
        for a in possible_actions[s]:
            Q_values[s, a] = np.sum([
                    transition_probabilities[s][a][sp]
                    * (rewards[s][a][sp] + gamma * Q_prev[sp].max())
                for sp in range(3)])

就是这样!得到的 Q 值看起来像这样:

>>> Q_values
array([[18.91891892, 17.02702702, 13.62162162],
 [ 0\.        ,        -inf, -4.87971488],
 [       -inf, 50.13365013,        -inf]])

例如,当代理处于状态s[0]并选择动作a[1]时,预期的折现未来奖励总和约为 17.0。

对于每个状态,我们可以找到具有最高 Q 值的动作:

>>> Q_values.argmax(axis=1)  # optimal action for each state
array([0, 0, 1])

这给出了在使用折扣因子为 0.90 时这个 MDP 的最优策略:在状态s[0]选择动作a[0],在状态s[1]选择动作a[0](即保持不动),在状态s[2]选择动作a[1](唯一可能的动作)。有趣的是,如果将折扣因子增加到 0.95,最优策略会改变:在状态s[1]中,最佳动作变为a[2](冲过火!)。这是有道理的,因为你越重视未来的奖励,你就越愿意忍受现在的一些痛苦,以换取未来的幸福。

时间差异学习

具有离散动作的强化学习问题通常可以建模为马尔可夫决策过程,但代理最初不知道转移概率是多少(它不知道T(s, a, s′)),也不知道奖励将会是什么(它不知道R(s, a, s′))。它必须至少体验每个状态和每个转换一次才能知道奖励,如果要对转移概率有合理的估计,它必须多次体验它们。

时间差异(TD)学习算法与 Q 值迭代算法非常相似,但经过调整以考虑代理只有对 MDP 的部分知识这一事实。通常我们假设代理最初只知道可能的状态和动作,什么也不知道。代理使用一个探索策略——例如,一个纯随机策略——来探索 MDP,随着探索的进行,TD 学习算法根据实际观察到的转换和奖励更新状态值的估计(参见方程 18-4)。

方程 18-4. TD 学习算法

V k+1 (s)(1-α) Vk (s)+α r+γ· Vk (s') 或者,等价地: Vk+1 (s)Vk (s)+α· δk (s,r, s') 其中δk (s,r,s )=r+γ · Vk (s')- Vk(s)

在这个方程中:

  • α是学习率(例如,0.01)。

  • r + γ · V**k被称为TD 目标

  • δk 被称为TD 误差

写出这个方程的第一种形式的更简洁方法是使用符号aαb,意思是a[k+1] ← (1 - α) · a[k] + α ·b[k]。因此,方程 18-4 的第一行可以重写为:V(s)αr+γ·V(s')

提示

TD 学习与随机梯度下降有许多相似之处,包括一次处理一个样本。此外,就像 SGD 一样,只有逐渐降低学习率,它才能真正收敛;否则,它将继续在最优 Q 值周围反弹。

对于每个状态s,该算法跟踪代理离开该状态后获得的即时奖励的平均值,以及它期望获得的奖励,假设它采取最优行动。

Q-Learning

同样,Q-learning 算法是 Q 值迭代算法在转移概率和奖励最初未知的情况下的一种适应。Q-learning 通过观察代理玩(例如,随机玩)并逐渐改进其对 Q 值的估计来工作。一旦它有准确的 Q 值估计(或足够接近),那么最优策略就是选择具有最高 Q 值的动作(即,贪婪策略)。

方程 18-5. Q-learning 算法

Q(s,a) α r+γ· maxa' Q(s', a')

对于每个状态-动作对(sa),该算法跟踪代理离开状态s并采取动作a后获得的奖励r的平均值,以及它期望获得的折现未来奖励的总和。为了估计这个总和,我们取下一个状态s′的 Q 值估计的最大值,因为我们假设目标策略将从那时开始最优地行动。

让我们实现 Q-learning 算法。首先,我们需要让代理探索环境。为此,我们需要一个步骤函数,以便代理可以执行一个动作并获得结果状态和奖励:

def step(state, action):
    probas = transition_probabilities[state][action]
    next_state = np.random.choice([0, 1, 2], p=probas)
    reward = rewards[state][action][next_state]
    return next_state, reward

现在让我们实现代理的探索策略。由于状态空间相当小,一个简单的随机策略就足够了。如果我们运行足够长的时间,代理将多次访问每个状态,并且还将多次尝试每种可能的动作:

def exploration_policy(state):
    return np.random.choice(possible_actions[state])

接下来,在我们像之前一样初始化 Q 值之后,我们准备使用学习率衰减的 Q-learning 算法运行(使用幂调度,引入于第十一章):

alpha0 = 0.05  # initial learning rate
decay = 0.005  # learning rate decay
gamma = 0.90  # discount factor
state = 0  # initial state

for iteration in range(10_000):
    action = exploration_policy(state)
    next_state, reward = step(state, action)
    next_value = Q_values[next_state].max()  # greedy policy at the next step
    alpha = alpha0 / (1 + iteration * decay)
    Q_values[state, action] *= 1 - alpha
    Q_values[state, action] += alpha * (reward + gamma * next_value)
    state = next_state

这个算法将收敛到最优的 Q 值,但需要很多迭代,可能需要相当多的超参数调整。正如您在图 18-9 中看到的那样,Q 值迭代算法(左侧)收敛得非常快,在不到 20 次迭代中,而 Q-learning 算法(右侧)需要大约 8000 次迭代才能收敛。显然,不知道转移概率或奖励使得找到最优策略变得更加困难!

mls3 1809

图 18-9. Q 值迭代算法与 Q-learning 算法的学习曲线

Q-learning 算法被称为离策略算法,因为正在训练的策略不一定是训练过程中使用的策略。例如,在我们刚刚运行的代码中,执行的策略(探索策略)是完全随机的,而正在训练的策略从未被使用过。训练后,最优策略对应于系统地选择具有最高 Q 值的动作。相反,策略梯度算法是在策略算法:它使用正在训练的策略探索世界。令人惊讶的是,Q-learning 能够通过观察代理随机行动来学习最优策略。想象一下,在一只被蒙住眼睛的猴子是你的老师时学习打高尔夫球。我们能做得更好吗?

探索策略

当然,只有当探索策略足够彻底地探索 MDP 时,Q 学习才能起作用。尽管纯随机策略保证最终会访问每个状态和每个转换多次,但这可能需要非常长的时间。因此,更好的选择是使用ε-贪心策略(ε是 epsilon):在每一步中,它以概率ε随机行动,或以概率 1-ε贪婪地行动(即选择具有最高 Q 值的动作)。ε-贪心策略的优势(与完全随机策略相比)在于,随着 Q 值估计变得越来越好,它将花费越来越多的时间探索环境的有趣部分,同时仍然花费一些时间访问 MDP 的未知区域。通常会从较高的ε值(例如 1.0)开始,然后逐渐降低它(例如降至 0.05)。

另一种方法是鼓励探索策略尝试之前尝试过的动作,而不仅仅依赖于机会。这可以作为添加到 Q 值估计中的奖励来实现,如方程 18-6 所示。

方程 18-6. 使用探索函数的 Q 学习

Q(s,a) α r+γ·max a' f Q(s',a' ),N(s', a')

在这个方程中:

  • N(s′, a′)计算动作a′在状态s′中被选择的次数。

  • f(Q, N)是一个探索函数,例如f(Q, N) = Q + κ/(1 + N),其中κ是一个好奇心超参数,衡量了代理对未知的吸引力。

近似 Q 学习和深度 Q 学习

Q 学习的主要问题是它在具有许多状态和动作的大型(甚至中等大小)MDP 中无法很好地扩展。例如,假设您想使用 Q 学习来训练一个代理玩《Ms. Pac-Man》(见图 18-1)。Ms. Pac-Man 可以吃约 150 个豆子,每个豆子可以存在或不存在(即已经被吃掉)。因此,可能的状态数量大于 2¹⁵⁰ ≈ 10⁴⁵。如果您考虑所有鬼和 Ms. Pac-Man 的所有可能位置组合,可能的状态数量将大于地球上的原子数量,因此绝对无法跟踪每个单个 Q 值的估计。

解决方案是找到一个函数Qθ,它用可管理数量的参数(由参数向量θ给出)来近似任何状态-动作对(s, a)的 Q 值。这被称为近似 Q 学习。多年来,人们建议使用从状态中提取的手工制作的特征的线性组合(例如,最近的鬼的距离、它们的方向等)来估计 Q 值,但在 2013 年,DeepMind表明使用深度神经网络可以工作得更好,特别是对于复杂问题,而且不需要任何特征工程。用于估计 Q 值的 DNN 称为深度 Q 网络(DQN),并且使用 DQN 进行近似 Q 学习称为深度 Q 学习

现在,我们如何训练一个 DQN 呢?考虑 DQN 计算给定状态-动作对(s, a)的近似 Q 值。由于贝尔曼,我们知道我们希望这个近似 Q 值尽可能接近我们在状态s中执行动作a后实际观察到的奖励r,加上从那时开始最优地玩的折现值。为了估计未来折现奖励的总和,我们只需在下一个状态s′上执行 DQN,对所有可能的动作a′。我们得到每个可能动作的近似未来 Q 值。然后我们选择最高的(因为我们假设我们将最优地玩),并对其进行折现,这给我们一个未来折现奖励总和的估计。通过将奖励r和未来折现值估计相加,我们得到状态-动作对(s, a)的目标 Q 值y(s, a),如方程 18-7 所示。

方程 18-7. 目标 Q 值

y(s,a)=r+γ·maxa'Qθ(s',a')

有了这个目标 Q 值,我们可以使用任何梯度下降算法运行一个训练步骤。具体来说,我们通常试图最小化估计的 Q 值Qθ和目标 Q 值y(s, a)之间的平方误差,或者使用 Huber 损失来减少算法对大误差的敏感性。这就是深度 Q 学习算法!让我们看看如何实现它来解决 CartPole 环境。

实施深度 Q 学习

我们需要的第一件事是一个深度 Q 网络。理论上,我们需要一个神经网络,将状态-动作对作为输入,并输出一个近似 Q 值。然而,在实践中,使用一个只接受状态作为输入,并为每个可能动作输出一个近似 Q 值的神经网络要高效得多。为了解决 CartPole 环境,我们不需要一个非常复杂的神经网络;几个隐藏层就足够了:

input_shape = [4]  # == env.observation_space.shape
n_outputs = 2  # == env.action_space.n

model = tf.keras.Sequential([
    tf.keras.layers.Dense(32, activation="elu", input_shape=input_shape),
    tf.keras.layers.Dense(32, activation="elu"),
    tf.keras.layers.Dense(n_outputs)
])

使用这个 DQN 选择动作时,我们选择预测 Q 值最大的动作。为了确保代理程序探索环境,我们将使用ε-贪婪策略(即,我们将以概率ε选择一个随机动作):

def epsilon_greedy_policy(state, epsilon=0):
    if np.random.rand() < epsilon:
        return np.random.randint(n_outputs)  # random action
    else:
        Q_values = model.predict(state[np.newaxis], verbose=0)[0]
        return Q_values.argmax()  # optimal action according to the DQN

我们将不再仅基于最新经验训练 DQN,而是将所有经验存储在一个重放缓冲区(或重放内存)中,并在每次训练迭代中从中随机抽取一个训练批次。这有助于减少训练批次中经验之间的相关性,从而极大地帮助训练。为此,我们将使用一个双端队列(deque):

from collections import deque

replay_buffer = deque(maxlen=2000)
提示

deque是一个队列,可以高效地在两端添加或删除元素。从队列的两端插入和删除项目非常快,但当队列变长时,随机访问可能会很慢。如果您需要一个非常大的重放缓冲区,您应该使用循环缓冲区(请参阅笔记本中的实现),或查看DeepMind 的 Reverb 库

每个体验将由六个元素组成:一个状态s,代理程序执行的动作a,产生的奖励r,它达到的下一个状态s′,一个指示该点是否结束的布尔值(done),最后一个指示该点是否截断的布尔值。我们将需要一个小函数从重放缓冲区中随机抽取一批体验。它将返回六个对应于六个体验元素的 NumPy 数组:

def sample_experiences(batch_size):
    indices = np.random.randint(len(replay_buffer), size=batch_size)
    batch = [replay_buffer[index] for index in indices]
    return [
        np.array([experience[field_index] for experience in batch])
        for field_index in range(6)
    ]  # [states, actions, rewards, next_states, dones, truncateds]

让我们还创建一个函数,该函数将使用ε-贪婪策略执行一个单步操作,然后将结果体验存储在重放缓冲区中:

def play_one_step(env, state, epsilon):
    action = epsilon_greedy_policy(state, epsilon)
    next_state, reward, done, truncated, info = env.step(action)
    replay_buffer.append((state, action, reward, next_state, done, truncated))
    return next_state, reward, done, truncated, info

最后,让我们创建一个最后一个函数,该函数将从重放缓冲区中抽取一批体验,并通过在该批次上执行单个梯度下降步骤来训练 DQN:

batch_size = 32
discount_factor = 0.95
optimizer = tf.keras.optimizers.Nadam(learning_rate=1e-2)
loss_fn = tf.keras.losses.mean_squared_error

def training_step(batch_size):
    experiences = sample_experiences(batch_size)
    states, actions, rewards, next_states, dones, truncateds = experiences
    next_Q_values = model.predict(next_states, verbose=0)
    max_next_Q_values = next_Q_values.max(axis=1)
    runs = 1.0 - (dones | truncateds)  # episode is not done or truncated
    target_Q_values = rewards + runs * discount_factor * max_next_Q_values
    target_Q_values = target_Q_values.reshape(-1, 1)
    mask = tf.one_hot(actions, n_outputs)
    with tf.GradientTape() as tape:
        all_Q_values = model(states)
        Q_values = tf.reduce_sum(all_Q_values * mask, axis=1, keepdims=True)
        loss = tf.reduce_mean(loss_fn(target_Q_values, Q_values))

    grads = tape.gradient(loss, model.trainable_variables)
    optimizer.apply_gradients(zip(grads, model.trainable_variables))

这段代码中发生了什么:

  • 首先我们定义一些超参数,然后创建优化器和损失函数。

  • 然后我们创建training_step()函数。它首先对经验进行批量采样,然后使用 DQN 来预测每个经验的下一个状态中每个可能动作的 Q 值。由于我们假设代理将会最优地进行游戏,我们只保留每个下一个状态的最大 Q 值。接下来,我们使用 Equation 18-7 来计算每个经验的状态-动作对的目标 Q 值。

  • 我们希望使用 DQN 来计算每个经验状态-动作对的 Q 值,但是 DQN 还会输出其他可能动作的 Q 值,而不仅仅是代理实际选择的动作。因此,我们需要屏蔽掉所有我们不需要的 Q 值。tf.one_hot()函数使得将动作索引数组转换为这样的屏蔽变得可能。例如,如果前三个经验包含动作 1、1、0,那么屏蔽将以[[0, 1], [0, 1], [1, 0], ...]开始。然后我们可以将 DQN 的输出与这个屏蔽相乘,这将将我们不想要的所有 Q 值置零。然后我们沿着轴 1 求和,去除所有零,只保留经验状态-动作对的 Q 值。这给我们了Q_values张量,包含批量中每个经验的一个预测 Q 值。

  • 接下来,我们计算损失:它是经验状态-动作对的目标和预测 Q 值之间的均方误差。

  • 最后,我们执行梯度下降步骤,以最小化损失与模型可训练变量的关系。

这是最困难的部分。现在训练模型就很简单了:

for episode in range(600):
    obs, info = env.reset()
    for step in range(200):
        epsilon = max(1 - episode / 500, 0.01)
        obs, reward, done, truncated, info = play_one_step(env, obs, epsilon)
        if done or truncated:
            break

    if episode > 50:
        training_step(batch_size)

我们运行 600 个 episode,每个最多 200 步。在每一步中,我们首先计算ε-贪婪策略的epsilon值:它将从 1 线性下降到 0.01,在不到 500 个 episode 内。然后我们调用play_one_step()函数,该函数将使用ε-贪婪策略选择一个动作,然后执行它并记录经验到重放缓冲区。如果 episode 结束或被截断,我们退出循环。最后,如果我们超过第 50 个 episode,我们调用training_step()函数从重放缓冲区中采样一个批次来训练模型。我们之所以在没有训练的情况下运行多个 episode,是为了给重放缓冲区一些时间来填充(如果我们不等待足够长的时间,那么重放缓冲区中将没有足够的多样性)。就是这样:我们刚刚实现了深度 Q 学习算法!

Figure 18-10 显示了代理在每个 episode 中获得的总奖励。

mls3 1810

图 18-10. 深度 Q 学习算法的学习曲线

正如你所看到的,该算法花了一段时间才开始学习任何东西,部分原因是ε在开始时非常高。然后它的进展是不稳定的:它首先在第 220 集左右达到了最大奖励,但立即下降,然后上下几次反弹,不久后看起来它终于稳定在最大奖励附近,大约在第 320 集左右,它的得分再次急剧下降。这被称为灾难性遗忘,这是几乎所有 RL 算法面临的一个大问题之一:当代理探索环境时,它更新其策略,但它在环境的一个部分学到的东西可能会破坏它在环境的其他部分早期学到的东西。经验是相当相关的,学习环境不断变化——这对于梯度下降来说并不理想!如果增加回放缓冲区的大小,算法将不太容易受到这个问题的影响。调整学习率也可能有所帮助。但事实是,强化学习很难:训练通常不稳定,您可能需要尝试许多超参数值和随机种子,才能找到一个表现良好的组合。例如,如果您尝试将激活函数从"elu"更改为"relu",性能将大大降低。

注意

强化学习因训练不稳定性和对超参数值和随机种子选择的极度敏感性而臭名昭著。正如研究人员 Andrej Karpathy 所说,“[监督学习]想要工作。[...]强化学习必须被迫工作”。您需要时间、耐心、毅力,也许还需要一点运气。这是 RL 不像常规深度学习(例如,卷积网络)那样被广泛采用的一个主要原因。但除了 AlphaGo 和 Atari 游戏之外,还有一些真实世界的应用:例如,谷歌使用 RL 来优化其数据中心成本,并且它被用于一些机器人应用、超参数调整和推荐系统中。

你可能会想为什么我们没有绘制损失。事实证明,损失是模型性能的一个很差的指标。损失可能会下降,但代理可能表现更差(例如,当代理陷入环境的一个小区域时,DQN 开始过度拟合这个区域时可能会发生这种情况)。相反,损失可能会上升,但代理可能表现更好(例如,如果 DQN 低估了 Q 值并开始正确增加其预测,代理可能表现更好,获得更多奖励,但损失可能会增加,因为 DQN 还设置了目标,这也会更大)。因此,最好绘制奖励。

到目前为止,我们一直在使用的基本深度 Q 学习算法对于学习玩 Atari 游戏来说太不稳定了。那么 DeepMind 是如何做到的呢?嗯,他们调整了算法!

深度 Q 学习变体

让我们看看一些可以稳定和加速训练的深度 Q 学习算法的变体。

固定 Q 值目标

在基本的深度 Q 学习算法中,模型既用于进行预测,也用于设置自己的目标。这可能导致类似于狗追逐自己尾巴的情况。这种反馈循环可能使网络不稳定:它可能发散、振荡、冻结等。为了解决这个问题,在他们 2013 年的论文中,DeepMind 的研究人员使用了两个 DQN 而不是一个:第一个是在线模型,它在每一步学习并用于移动代理,另一个是目标模型,仅用于定义目标。目标模型只是在线模型的一个克隆:

target = tf.keras.models.clone_model(model)  # clone the model's architecture
target.set_weights(model.get_weights())  # copy the weights

然后,在training_step()函数中,我们只需要更改一行,使用目标模型而不是在线模型来计算下一个状态的 Q 值:

next_Q_values = target.predict(next_states, verbose=0)

最后,在训练循环中,我们必须定期将在线模型的权重复制到目标模型中(例如,每 50 个 episode):

if episode % 50 == 0:
    target.set_weights(model.get_weights())

由于目标模型更新的频率远低于在线模型,Q 值目标更加稳定,我们之前讨论的反馈循环被减弱,其影响也变得不那么严重。这种方法是 DeepMind 研究人员在 2013 年的一篇论文中的主要贡献之一,使代理能够从原始像素学习玩 Atari 游戏。为了稳定训练,他们使用了非常小的学习率 0.00025,他们每 10000 步才更新一次目标模型(而不是 50 步),并且他们使用了一个非常大的重放缓冲区,包含 100 万个经验。他们非常缓慢地减小了epsilon,在 100 万步内从 1 减小到 0.1,并让算法运行了 5000 万步。此外,他们的 DQN 是一个深度卷积网络。

现在让我们来看看另一个 DQN 变体,它再次超越了现有技术水平。

双重 DQN

在一篇 2015 年的论文中,DeepMind 研究人员调整了他们的 DQN 算法,提高了性能并在一定程度上稳定了训练。他们将这个变体称为双重 DQN。更新基于这样一个观察:目标网络容易高估 Q 值。实际上,假设所有动作都是同样好的:目标模型估计的 Q 值应该是相同的,但由于它们是近似值,一些可能略大于其他值,纯粹是偶然的。目标模型将始终选择最大的 Q 值,这个值将略大于平均 Q 值,很可能高估真实的 Q 值(有点像在测量池的深度时计算最高随机波浪的高度)。为了解决这个问题,研究人员建议在选择下一个状态的最佳动作时使用在线模型而不是目标模型,并且仅使用目标模型来估计这些最佳动作的 Q 值。以下是更新后的training_step()函数:

def training_step(batch_size):
    experiences = sample_experiences(batch_size)
    states, actions, rewards, next_states, dones, truncateds = experiences
    next_Q_values = model.predict(next_states, verbose=0)  # ≠ target.predict()
    best_next_actions = next_Q_values.argmax(axis=1)
    next_mask = tf.one_hot(best_next_actions, n_outputs).numpy()
    max_next_Q_values = (target.predict(next_states, verbose=0) * next_mask
                        ).sum(axis=1)
    [...]  # the rest is the same as earlier

仅仅几个月后,DQN 算法的另一个改进被提出;我们接下来将看看这个改进。

优先经验回放

与从重放缓冲区中均匀采样经验不同,为什么不更频繁地采样重要经验呢?这个想法被称为重要性采样(IS)或优先经验回放(PER),并且是由 DeepMind 研究人员在 2015 年的一篇论文中介绍的(再次!)。

更具体地说,如果经验很可能导致快速学习进展,那么这些经验被认为是“重要的”。但是我们如何估计这一点呢?一个合理的方法是测量 TD 误差的大小δ = r + γ·V(s′) – V(s)。较大的 TD 误差表明一个转换(s, a, s′)非常令人惊讶,因此可能值得学习。当一个经验被记录在重放缓冲区中时,其优先级被设置为一个非常大的值,以确保至少被采样一次。然而,一旦被采样(并且每次被采样时),TD 误差δ被计算,并且这个经验的优先级被设置为p = |δ|(再加上一个小常数,以确保每个经验有非零的采样概率)。具有优先级p的经验被采样的概率Pp^(ζ)成正比,其中ζ是一个控制我们希望重要性采样有多贪婪的超参数:当ζ = 0 时,我们只得到均匀采样,当ζ = 1 时,我们得到完全的重要性采样。在论文中,作者使用了ζ = 0.6,但最佳值将取决于任务。

然而,有一个问题:由于样本将偏向于重要经验,我们必须在训练过程中通过根据其重要性降低经验的权重来补偿这种偏差,否则模型将只是过度拟合重要经验。明确地说,我们希望重要经验被更频繁地抽样,但这也意味着我们必须在训练过程中给它们更低的权重。为了做到这一点,我们将每个经验的训练权重定义为w = (n P)^(–β),其中n是回放缓冲区中的经验数量,β是一个超参数,控制我们想要补偿重要性抽样偏差的程度(0 表示根本不补偿,而 1 表示完全补偿)。在论文中,作者在训练开始时使用β = 0.4,并在训练结束时线性增加到β = 1。再次强调,最佳值将取决于任务,但如果你增加一个值,通常也会想要增加另一个值。

现在让我们看一下 DQN 算法的最后一个重要变体。

决斗 DQN

决斗 DQN算法(DDQN,不要与双重 DQN 混淆,尽管这两种技术可以很容易地结合在一起)是由 DeepMind 研究人员在另一篇2015 年的论文中介绍的。要理解它的工作原理,我们首先必须注意到一个状态-动作对(s, a)的 Q 值可以表示为Q(s, a) = V(s) + A(s, a),其中V(s)是状态s的值,A(s, a)是在状态s中采取动作a优势,与该状态下所有其他可能的动作相比。此外,一个状态的值等于该状态的最佳动作a^的 Q 值(因为我们假设最优策略将选择最佳动作),所以V(s) = Q(s, a^),这意味着A(s, a^*) = 0。在决斗 DQN 中,模型估计了状态的值和每个可能动作的优势。由于最佳动作应该具有优势为 0,模型从所有预测的优势中减去了最大预测的优势。这里是一个使用功能 API 实现的简单 DDQN 模型:

input_states = tf.keras.layers.Input(shape=[4])
hidden1 = tf.keras.layers.Dense(32, activation="elu")(input_states)
hidden2 = tf.keras.layers.Dense(32, activation="elu")(hidden1)
state_values = tf.keras.layers.Dense(1)(hidden2)
raw_advantages = tf.keras.layers.Dense(n_outputs)(hidden2)
advantages = raw_advantages - tf.reduce_max(raw_advantages, axis=1,
                                            keepdims=True)
Q_values = state_values + advantages
model = tf.keras.Model(inputs=[input_states], outputs=[Q_values])

算法的其余部分与之前完全相同。事实上,你可以构建一个双重决斗 DQN 并将其与优先经验重放结合起来!更一般地说,许多 RL 技术可以结合在一起,正如 DeepMind 在一篇2017 年的论文中展示的:论文的作者将六种不同的技术结合到一个名为Rainbow的代理中,这在很大程度上超越了现有技术水平。

正如你所看到的,深度强化学习是一个快速发展的领域,还有很多东西等待探索!

一些流行 RL 算法的概述

在我们结束本章之前,让我们简要看一下其他几种流行的算法:

AlphaGo

AlphaGo 使用基于深度神经网络的蒙特卡洛树搜索(MCTS)的变体,在围棋比赛中击败人类冠军。MCTS 是由 Nicholas Metropolis 和 Stanislaw Ulam 于 1949 年发明的。它在运行许多模拟之后选择最佳移动,重复地探索从当前位置开始的搜索树,并在最有希望的分支上花费更多时间。当它到达一个以前未访问过的节点时,它会随机播放直到游戏结束,并更新每个访问过的节点的估计值(排除随机移动),根据最终结果增加或减少每个估计值。AlphaGo 基于相同的原则,但它使用策略网络来选择移动,而不是随机播放。这个策略网络是使用策略梯度进行训练的。原始算法涉及另外三个神经网络,并且更加复杂,但在AlphaGo Zero 论文中被简化,使用单个神经网络来选择移动和评估游戏状态。AlphaZero 论文推广了这个算法,使其能够处理不仅是围棋,还有国际象棋和将棋(日本象棋)。最后,MuZero 论文继续改进这个算法,即使代理开始时甚至不知道游戏规则,也能胜过以前的迭代!

Actor-critic 算法

Actor-critics 是一类将策略梯度与深度 Q 网络结合的 RL 算法。一个 actor-critic 代理包含两个神经网络:一个策略网络和一个 DQN。DQN 通过从代理的经验中学习来进行正常训练。策略网络学习方式不同(并且比常规 PG 快得多):代理不是通过多个情节估计每个动作的价值,然后为每个动作总结未来折现奖励,最后对其进行归一化,而是依赖于 DQN 估计的动作值(评论家)。这有点像运动员(代理)在教练(DQN)的帮助下学习。

异步优势 actor-critic(A3C)⁠²³

这是 DeepMind 研究人员在 2016 年引入的一个重要的 actor-critic 变体,其中多个代理并行学习,探索环境的不同副本。定期但异步地(因此得名),每个代理将一些权重更新推送到主网络,然后从该网络中拉取最新的权重。因此,每个代理都有助于改进主网络,并从其他代理学到的知识中受益。此外,DQN 估计每个动作的优势,而不是估计 Q 值(因此名称中的第二个 A),这有助于稳定训练。

优势 actor-critic(A2C)

A2C 是 A3C 算法的一个变体,它去除了异步性。所有模型更新都是同步的,因此梯度更新是在更大的批次上执行的,这使模型能够更好地利用 GPU 的性能。

软 actor-critic(SAC)⁠²⁴

SAC 是由 Tuomas Haarnoja 和其他加州大学伯克利分校研究人员于 2018 年提出的 actor-critic 变体。它不仅学习奖励,还要最大化其动作的熵。换句话说,它试图尽可能不可预测,同时尽可能获得更多奖励。这鼓励代理探索环境,加快训练速度,并使其在 DQN 产生不完美估计时不太可能重复执行相同的动作。这个算法展示了惊人的样本效率(与所有以前的算法相反,学习速度非常慢)。

近端策略优化(PPO)

这个由 John Schulman 和其他 OpenAI 研究人员开发的算法基于 A2C,但它剪切损失函数以避免过大的权重更新(这经常导致训练不稳定)。PPO 是前一个信任区域策略优化(TRPO)算法的简化版本,也是由 OpenAI 开发的。OpenAI 在 2019 年 4 月的新闻中以其基于 PPO 算法的 AI OpenAI Five 而闻名,该 AI 在多人游戏Dota 2中击败了世界冠军。

基于好奇心的探索

在强化学习中经常出现的问题是奖励的稀疏性,这使得学习变得非常缓慢和低效。加州大学伯克利分校的 Deepak Pathak 和其他研究人员提出了一种激动人心的方法来解决这个问题:为什么不忽略奖励,只是让代理人对探索环境感到极大的好奇心呢?奖励因此变得内在于代理人,而不是来自环境。同样,激发孩子的好奇心更有可能取得好的结果,而不仅仅是因为孩子取得好成绩而奖励他。这是如何运作的呢?代理人不断尝试预测其行动的结果,并寻找结果与其预测不符的情况。换句话说,它希望受到惊喜。如果结果是可预测的(无聊),它会去其他地方。然而,如果结果是不可预测的,但代理人注意到自己无法控制它,那么它也会在一段时间后感到无聊。只有好奇心,作者们成功地训练了一个代理人玩了很多视频游戏:即使代理人输掉了也没有惩罚,游戏重新开始,这很无聊,所以它学会了避免这种情况。

开放式学习(OEL)

OEL 的目标是训练代理人能够不断学习新颖有趣的任务,通常是通过程序生成的。我们还没有达到这一目标,但在过去几年中取得了一些惊人的进展。例如,Uber AI 团队在 2019 年发表的一篇论文介绍了POET 算法,该算法生成多个带有凸起和洞的模拟 2D 环境,并为每个环境训练一个代理人:代理人的目标是尽可能快地行走,同时避开障碍物。该算法从简单的环境开始,但随着时间的推移逐渐变得更加困难:这被称为课程学习。此外,尽管每个代理人只在一个环境中接受训练,但它必须定期与其他代理人竞争,跨所有环境。在每个环境中,获胜者被复制并取代之前的代理人。通过这种方式,知识定期在环境之间传递,并选择最具适应性的代理人。最终,这些代理人比单一任务训练的代理人更擅长行走,并且能够应对更加困难的环境。当然,这个原则也可以应用于其他环境和任务。如果您对 OEL 感兴趣,请务必查看增强 POET 论文,以及 DeepMind 在这个主题上的2021 年论文

提示

如果您想了解更多关于强化学习的知识,请查看 Phil Winder(O'Reilly)的书籍强化学习

本章涵盖了许多主题:策略梯度、马尔可夫链、马尔可夫决策过程、Q 学习、近似 Q 学习、深度 Q 学习及其主要变体(固定 Q 值目标、双重 DQN、对决 DQN 和优先经验重放),最后我们简要介绍了一些其他流行算法。强化学习是一个庞大且令人兴奋的领域,每天都会涌现出新的想法和算法,因此希望本章引发了您的好奇心:有一个整个世界等待您去探索!

练习

  1. 您如何定义强化学习?它与常规监督学习或无监督学习有何不同?

  2. 你能想到本章未提及的三种强化学习的可能应用吗?对于每一种,环境是什么?代理是什么?可能的行动有哪些?奖励是什么?

  3. 什么是折扣因子?如果修改折扣因子,最优策略会改变吗?

  4. 如何衡量强化学习代理的表现?

  5. 什么是信用分配问题?它何时发生?如何缓解它?

  6. 使用重放缓冲区的目的是什么?

  7. 什么是离策略强化学习算法?

  8. 使用策略梯度来解决 OpenAI Gym 的 LunarLander-v2 环境。

  9. 使用双重对决 DQN 训练一个代理,使其在著名的 Atari Breakout 游戏("ALE/Breakout-v5")中达到超人水平。观察结果是图像。为了简化任务,您应该将它们转换为灰度图像(即在通道轴上取平均),然后裁剪和降采样,使它们足够大以进行游戏,但不要过大。单个图像无法告诉您球和挡板的移动方向,因此您应该合并两到三个连续图像以形成每个状态。最后,DQN 应该主要由卷积层组成。

  10. 如果您有大约 100 美元可以花费,您可以购买一个树莓派 3 加上一些廉价的机器人组件,在树莓派上安装 TensorFlow,然后尽情玩耍!例如,可以查看 Lukas Biewald 的这篇 有趣的帖子,或者看看 GoPiGo 或 BrickPi。从简单的目标开始,比如让机器人转身找到最亮的角度(如果有光传感器)或最近的物体(如果有声纳传感器),然后朝着那个方向移动。然后您可以开始使用深度学习:例如,如果机器人有摄像头,可以尝试实现一个目标检测算法,使其检测到人并朝他们移动。您还可以尝试使用强化学习,让代理学习如何独立使用电机来实现这个目标。玩得开心!

这些练习的解答可在本章笔记本的末尾找到,网址为 https://homl.info/colab3

(1)想了解更多细节,请务必查看 Richard Sutton 和 Andrew Barto 的关于强化学习的书籍 Reinforcement Learning: An Introduction(麻省理工学院出版社)。

(2)Volodymyr Mnih 等人,“使用深度强化学习玩 Atari 游戏”,arXiv 预印本 arXiv:1312.5602(2013)。

(3)Volodymyr Mnih 等人,“通过深度强化学习实现人类水平控制”,自然 518(2015):529–533。

(4)查看 DeepMind 系统学习 Space InvadersBreakout 和其他视频游戏的视频,网址为 https://homl.info/dqn3

(5)图像(a)、(d)和(e)属于公共领域。图像(b)是来自 Ms. Pac-Man 游戏的截图,由 Atari 版权所有(在本章中属于合理使用)。图像(c)是从维基百科复制的;由用户 Stevertigo 创建,并在 知识共享署名-相同方式共享 2.0 下发布。

(6)通常更好地给予表现不佳者一点生存的机会,以保留“基因池”中的一些多样性。

⁷ 如果只有一个父母,这被称为无性繁殖。有两个(或更多)父母时,这被称为有性繁殖。后代的基因组(在这种情况下是一组策略参数)是随机由其父母的基因组的部分组成的。

⁸ 用于强化学习的遗传算法的一个有趣例子是增强拓扑的神经进化(NEAT)算法。

⁹ 这被称为梯度上升。它就像梯度下降一样,但方向相反:最大化而不是最小化。

¹⁰ OpenAI 是一家人工智能研究公司,部分资金来自埃隆·马斯克。其宣称的目标是推广和发展有益于人类的友好人工智能(而不是消灭人类)。

¹¹ Ronald J. Williams,“用于连接主义强化学习的简单统计梯度跟随算法”,机器学习8(1992):229–256。

¹² Richard Bellman,“马尔可夫决策过程”,数学与力学杂志6,第 5 期(1957):679–684。

¹³ Alex Irpan 在 2018 年发表的一篇很棒的文章很好地阐述了强化学习的最大困难和局限性。

¹⁴ Hado van Hasselt 等人,“双 Q 学习的深度强化学习”,第 30 届 AAAI 人工智能大会论文集(2015):2094–2100。

¹⁵ Tom Schaul 等人,“优先经验重放”,arXiv 预印本 arXiv:1511.05952(2015)。

¹⁶ 也可能只是奖励有噪音,此时有更好的方法来估计经验的重要性(请参阅论文中的一些示例)。

¹⁷ Ziyu Wang 等人,“用于深度强化学习的对抗网络架构”,arXiv 预印本 arXiv:1511.06581(2015)。

¹⁸ Matteo Hessel 等人,“彩虹:深度强化学习改进的结合”,arXiv 预印本 arXiv:1710.02298(2017):3215–3222。

¹⁹ David Silver 等人,“用深度神经网络和树搜索掌握围棋”,自然529(2016):484–489。

²⁰ David Silver 等人,“在没有人类知识的情况下掌握围棋”,自然550(2017):354–359。

²¹ David Silver 等人,“通过自我对弈掌握国际象棋和将棋的一般强化学习算法”,arXiv 预印本 arXiv:1712.01815。

²² Julian Schrittwieser 等人,“通过学习模型计划掌握 Atari、围棋、国际象棋和将棋”,arXiv 预印本 arXiv:1911.08265(2019)。

²³ Volodymyr Mnih 等人,“深度强化学习的异步方法”,第 33 届国际机器学习会议论文集(2016):1928–1937。

²⁴ Tuomas Haarnoja 等人,“软演员-评论家:带有随机演员的离策略最大熵深度强化学习”,第 35 届国际机器学习会议论文集(2018):1856–1865。

²⁵ John Schulman 等人,“近端策略优化算法”,arXiv 预印本 arXiv:1707.06347(2017)。

²⁶ John Schulman 等人,“信任区域策略优化”,第 32 届国际机器学习会议论文集(2015):1889–1897。

²⁷ Deepak Pathak 等,“由自监督预测驱动的好奇心探索”,第 34 届国际机器学习会议论文集(2017):2778–2787。

²⁸ 王锐等,“配对开放式先驱者(POET):不断生成越来越复杂和多样化的学习环境及其解决方案”,arXiv 预印本 arXiv:1901.01753(2019)。

²⁹ 王锐等,“增强 POET:通过无限创造学习挑战及其解决方案的开放式强化学习”,arXiv 预印本 arXiv:2003.08536(2020)。

³⁰ Open-Ended Learning Team 等,“开放式学习导致普遍能力代理”,arXiv 预印本 arXiv:2107.12808(2021)。

第十九章:规模化训练和部署 TensorFlow 模型

一旦您拥有一个能够做出惊人预测的美丽模型,您会怎么处理呢?嗯,您需要将其投入生产!这可能只是在一批数据上运行模型,也许编写一个每晚运行该模型的脚本。然而,通常情况下会更加复杂。您的基础设施的各个部分可能需要在实时数据上使用该模型,这种情况下,您可能会希望将模型封装在一个 Web 服务中:这样,您的基础设施的任何部分都可以随时使用简单的 REST API(或其他协议)查询模型,正如我们在第二章中讨论的那样。但随着时间的推移,您需要定期使用新数据对模型进行重新训练,并将更新后的版本推送到生产环境。您必须处理模型版本控制,优雅地从一个模型过渡到另一个模型,可能在出现问题时回滚到上一个模型,并可能并行运行多个不同的模型来执行A/B 实验。如果您的产品变得成功,您的服务可能会开始每秒收到大量查询(QPS),并且必须扩展以支持负载。如您将在本章中看到的,一个很好的扩展服务的解决方案是使用 TF Serving,无论是在您自己的硬件基础设施上还是通过诸如 Google Vertex AI 之类的云服务。它将有效地为您提供模型服务,处理优雅的模型过渡等。如果您使用云平台,您还将获得许多额外功能,例如强大的监控工具。

此外,如果你有大量的训练数据和计算密集型模型,那么训练时间可能会变得过长。如果你的产品需要快速适应变化,那么长时间的训练可能会成为一个阻碍因素(例如,想象一下一个新闻推荐系统在推广上周的新闻)。更重要的是,长时间的训练会阻止你尝试新想法。在机器学习(以及许多其他领域),很难事先知道哪些想法会奏效,因此你应该尽可能快地尝试尽可能多的想法。加快训练的一种方法是使用硬件加速器,如 GPU 或 TPU。为了更快地训练,你可以在多台配备多个硬件加速器的机器上训练模型。TensorFlow 的简单而强大的分布策略 API 使这一切变得容易,你将会看到。

在这一章中,我们将学习如何部署模型,首先使用 TF Serving,然后使用 Vertex AI。我们还将简要介绍如何将模型部署到移动应用程序、嵌入式设备和 Web 应用程序。然后我们将讨论如何使用 GPU 加速计算,以及如何使用分布策略 API 在多个设备和服务器上训练模型。最后,我们将探讨如何使用 Vertex AI 规模化训练模型并微调其超参数。这是很多要讨论的话题,让我们开始吧!

为 TensorFlow 模型提供服务

一旦您训练了一个 TensorFlow 模型,您可以在任何 Python 代码中轻松地使用它:如果它是一个 Keras 模型,只需调用它的predict()方法!但随着基础设施的增长,会出现一个更好的选择,即将您的模型封装在一个小型服务中,其唯一作用是进行预测,并让基础设施的其余部分查询它(例如,通过 REST 或 gRPC API)。这样可以将您的模型与基础设施的其余部分解耦,从而可以轻松地切换模型版本或根据需要扩展服务(独立于您的基础设施的其余部分),执行 A/B 实验,并确保所有软件组件依赖于相同的模型版本。这也简化了测试和开发等工作。您可以使用任何您想要的技术(例如,使用 Flask 库)创建自己的微服务,但为什么要重新发明轮子,当您可以直接使用 TF Serving 呢?

使用 TensorFlow Serving

TF Serving 是一个非常高效、经过实战验证的模型服务器,用 C++编写。它可以承受高负载,为您的模型提供多个版本,并监视模型存储库以自动部署最新版本,等等(参见图 19-1)。

mls3 1901

图 19-1。TF Serving 可以为多个模型提供服务,并自动部署每个模型的最新版本。

假设您已经使用 Keras 训练了一个 MNIST 模型,并且希望将其部署到 TF Serving。您需要做的第一件事是将此模型导出为 SavedModel 格式,该格式在第十章中介绍。

导出 SavedModels

您已经知道如何保存模型:只需调用model.save()。现在要对模型进行版本控制,您只需要为每个模型版本创建一个子目录。很简单!

from pathlib import Path
import tensorflow as tf

X_train, X_valid, X_test = [...]  # load and split the MNIST dataset
model = [...]  # build & train an MNIST model (also handles image preprocessing)

model_name = "my_mnist_model"
model_version = "0001"
model_path = Path(model_name) / model_version
model.save(model_path, save_format="tf")

通常最好将所有预处理层包含在最终导出的模型中,这样一旦部署到生产环境中,模型就可以以其自然形式摄取数据。这样可以避免在使用模型的应用程序中单独处理预处理工作。将预处理步骤捆绑在模型中也使得以后更新它们更加简单,并限制了模型与所需预处理步骤之间不匹配的风险。

警告

由于 SavedModel 保存了计算图,因此它只能用于基于纯粹的 TensorFlow 操作的模型,不包括tf.py_function()操作,该操作包装任意的 Python 代码。

TensorFlow 带有一个小的saved_model_cli命令行界面,用于检查 SavedModels。让我们使用它来检查我们导出的模型:

$ saved_model_cli show --dir my_mnist_model/0001
The given SavedModel contains the following tag-sets:
'serve'

这个输出是什么意思?嗯,一个 SavedModel 包含一个或多个metagraphs。一个 metagraph 是一个计算图加上一些函数签名定义,包括它们的输入和输出名称、类型和形状。每个 metagraph 都由一组标签标识。例如,您可能希望有一个包含完整计算图的 metagraph,包括训练操作:您通常会将这个标记为"train"。您可能有另一个包含经过修剪的计算图的 metagraph,只包含预测操作,包括一些特定于 GPU 的操作:这个可能被标记为"serve", "gpu"。您可能还想要其他 metagraphs。这可以使用 TensorFlow 的低级SavedModel API来完成。然而,当您使用 Keras 模型的save()方法保存模型时,它会保存一个标记为"serve"的单个 metagraph。让我们检查一下这个"serve"标签集:

$ saved_model_cli show --dir 0001/my_mnist_model --tag_set serve
The given SavedModel MetaGraphDef contains SignatureDefs with these keys:
SignatureDef key: "__saved_model_init_op"
SignatureDef key: "serving_default"

这个元图包含两个签名定义:一个名为"__saved_model_init_op"的初始化函数,您不需要担心,以及一个名为"serving_default"的默认服务函数。当保存一个 Keras 模型时,默认的服务函数是模型的call()方法,用于进行预测,这一点您已经知道了。让我们更详细地了解这个服务函数:

$ saved_model_cli show --dir 0001/my_mnist_model --tag_set serve \
                       --signature_def serving_default
The given SavedModel SignatureDef contains the following input(s):
 inputs['flatten_input'] tensor_info:
 dtype: DT_UINT8
 shape: (-1, 28, 28)
 name: serving_default_flatten_input:0
The given SavedModel SignatureDef contains the following output(s):
 outputs['dense_1'] tensor_info:
 dtype: DT_FLOAT
 shape: (-1, 10)
 name: StatefulPartitionedCall:0
Method name is: tensorflow/serving/predict

请注意,函数的输入被命名为"flatten_input",输出被命名为"dense_1"。这些对应于 Keras 模型的输入和输出层名称。您还可以看到输入和输出数据的类型和形状。看起来不错!

现在您已经有了一个 SavedModel,下一步是安装 TF Serving。

安装和启动 TensorFlow Serving

有许多安装 TF Serving 的方法:使用系统的软件包管理器,使用 Docker 镜像,从源代码安装等。由于 Colab 运行在 Ubuntu 上,我们可以像这样使用 Ubuntu 的apt软件包管理器:

url = "https://storage.googleapis.com/tensorflow-serving-apt"
src = "stable tensorflow-model-server tensorflow-model-server-universal"
!echo 'deb {url} {src}' > /etc/apt/sources.list.d/tensorflow-serving.list
!curl '{url}/tensorflow-serving.release.pub.gpg' | apt-key add -
!apt update -q && apt-get install -y tensorflow-model-server
%pip install -q -U tensorflow-serving-api

这段代码首先将 TensorFlow 的软件包存储库添加到 Ubuntu 的软件包源列表中。然后它下载 TensorFlow 的公共 GPG 密钥,并将其添加到软件包管理器的密钥列表中,以便验证 TensorFlow 的软件包签名。接下来,它使用apt来安装tensorflow-model-server软件包。最后,它安装tensorflow-serving-api库,这是我们与服务器通信所需的库。

现在我们想要启动服务器。该命令将需要基本模型目录的绝对路径(即my_mnist_model的路径,而不是0001),所以让我们将其保存到MODEL_DIR环境变量中:

import os

os.environ["MODEL_DIR"] = str(model_path.parent.absolute())

然后我们可以启动服务器:

%%bash --bg
tensorflow_model_server \
     --port=8500 \
     --rest_api_port=8501 \
     --model_name=my_mnist_model \
     --model_base_path="${MODEL_DIR}" >my_server.log 2>&1

在 Jupyter 或 Colab 中,%%bash --bg魔术命令将单元格作为 bash 脚本执行,在后台运行。>my_server.log 2>&1部分将标准输出和标准错误重定向到my_server.log文件。就是这样!TF Serving 现在在后台运行,其日志保存在my_server.log中。它加载了我们的 MNIST 模型(版本 1),现在正在分别等待 gRPC 和 REST 请求,端口分别为 8500 和 8501。

现在服务器已经启动运行,让我们首先使用 REST API,然后使用 gRPC API 进行查询。

通过 REST API 查询 TF Serving

让我们从创建查询开始。它必须包含您想要调用的函数签名的名称,当然还有输入数据。由于请求必须使用 JSON 格式,我们必须将输入图像从 NumPy 数组转换为 Python 列表:

import json

X_new = X_test[:3]  # pretend we have 3 new digit images to classify
request_json = json.dumps({
    "signature_name": "serving_default",
    "instances": X_new.tolist(),
})

请注意,JSON 格式是 100%基于文本的。请求字符串如下所示:

>>> request_json
'{"signature_name": "serving_default", "instances": [[[0, 0, 0, 0, ... ]]]}'

现在让我们通过 HTTP POST 请求将这个请求发送到 TF Serving。这可以使用requests库来完成(它不是 Python 标准库的一部分,但在 Colab 上是预安装的):

import requests

server_url = "http://localhost:8501/v1/models/my_mnist_model:predict"
response = requests.post(server_url, data=request_json)
response.raise_for_status()  # raise an exception in case of error
response = response.json()

如果一切顺利,响应应该是一个包含单个"predictions"键的字典。相应的值是预测列表。这个列表是一个 Python 列表,所以让我们将其转换为 NumPy 数组,并将其中包含的浮点数四舍五入到第二位小数:

>>> import numpy as np
>>> y_proba = np.array(response["predictions"])
>>> y_proba.round(2)
array([[0\.  , 0\.  , 0\.  , 0\.  , 0\.  , 0\.  , 0\.  , 1\.  , 0\.  , 0\.  ],
 [0\.  , 0\.  , 0.99, 0.01, 0\.  , 0\.  , 0\.  , 0\.  , 0\.  , 0\.  ],
 [0\.  , 0.97, 0.01, 0\.  , 0\.  , 0\.  , 0\.  , 0.01, 0\.  , 0\.  ]])

万岁,我们有了预测!模型几乎 100%确信第一张图片是 7,99%确信第二张图片是 2,97%确信第三张图片是 1。这是正确的。

REST API 简单易用,当输入和输出数据不太大时效果很好。此外,几乎任何客户端应用程序都可以在没有额外依赖的情况下进行 REST 查询,而其他协议并不总是那么容易获得。然而,它基于 JSON,这是基于文本且相当冗长的。例如,我们不得不将 NumPy 数组转换为 Python 列表,每个浮点数最终都表示为一个字符串。这非常低效,无论是在序列化/反序列化时间方面——我们必须将所有浮点数转换为字符串然后再转回来——还是在有效载荷大小方面:许多浮点数最终使用超过 15 个字符来表示,这相当于 32 位浮点数超过 120 位!这将导致在传输大型 NumPy 数组时出现高延迟和带宽使用。因此,让我们看看如何改用 gRPC。

提示

在传输大量数据或延迟重要时,最好使用 gRPC API,如果客户端支持的话,因为它使用紧凑的二进制格式和基于 HTTP/2 framing 的高效通信协议。

通过 gRPC API 查询 TF Serving

gRPC API 期望一个序列化的PredictRequest协议缓冲区作为输入,并输出一个序列化的PredictResponse协议缓冲区。这些 protobufs 是tensorflow-serving-api库的一部分,我们之前安装过。首先,让我们创建请求:

from tensorflow_serving.apis.predict_pb2 import PredictRequest

request = PredictRequest()
request.model_spec.name = model_name
request.model_spec.signature_name = "serving_default"
input_name = model.input_names[0]  # == "flatten_input"
request.inputs[input_name].CopyFrom(tf.make_tensor_proto(X_new))

这段代码创建了一个PredictRequest协议缓冲区,并填充了必需的字段,包括模型名称(之前定义的),我们想要调用的函数的签名名称,最后是输入数据,以Tensor协议缓冲区的形式。tf.make_tensor_proto()函数根据给定的张量或 NumPy 数组创建一个Tensor协议缓冲区,这里是X_new

接下来,我们将向服务器发送请求并获取其响应。为此,我们将需要grpcio库,该库已预先安装在 Colab 中:

import grpc
from tensorflow_serving.apis import prediction_service_pb2_grpc

channel = grpc.insecure_channel('localhost:8500')
predict_service = prediction_service_pb2_grpc.PredictionServiceStub(channel)
response = predict_service.Predict(request, timeout=10.0)

代码非常简单:在导入之后,我们在 TCP 端口 8500 上创建一个到localhost的 gRPC 通信通道,然后我们在该通道上创建一个 gRPC 服务,并使用它发送一个带有 10 秒超时的请求。请注意,调用是同步的:它将阻塞,直到收到响应或超时期限到期。在此示例中,通道是不安全的(没有加密,没有身份验证),但 gRPC 和 TF Serving 也支持通过 SSL/TLS 的安全通道。

接下来,让我们将PredictResponse协议缓冲区转换为张量:

output_name = model.output_names[0]  # == "dense_1"
outputs_proto = response.outputs[output_name]
y_proba = tf.make_ndarray(outputs_proto)

如果您运行此代码并打印y_proba.round(2),您将获得与之前完全相同的估计类概率。这就是全部内容:只需几行代码,您现在就可以远程访问您的 TensorFlow 模型,使用 REST 或 gRPC。

部署新的模型版本

现在让我们创建一个新的模型版本并导出一个 SavedModel,这次导出到my_mnist_model/0002目录:

model = [...]  # build and train a new MNIST model version

model_version = "0002"
model_path = Path(model_name) / model_version
model.save(model_path, save_format="tf")

在固定的时间间隔(延迟可配置),TF Serving 会检查模型目录是否有新的模型版本。如果找到一个新版本,它会自动优雅地处理过渡:默认情况下,它会用前一个模型版本回答待处理的请求(如果有的话),同时用新版本处理新请求。一旦每个待处理的请求都得到回答,之前的模型版本就会被卸载。您可以在 TF Serving 日志(my_server.log)中看到这个过程:

[...]
Reading SavedModel from: /models/my_mnist_model/0002
Reading meta graph with tags { serve }
[...]
Successfully loaded servable version {name: my_mnist_model version: 2}
Quiescing servable version {name: my_mnist_model version: 1}
Done quiescing servable version {name: my_mnist_model version: 1}
Unloading servable version {name: my_mnist_model version: 1}

提示

如果 SavedModel 包含assets/extra目录中的一些示例实例,您可以配置 TF Serving 在开始使用它来处理请求之前在这些实例上运行新模型。这称为模型预热:它将确保一切都被正确加载,避免第一次请求的长响应时间。

这种方法提供了平稳的过渡,但可能会使用过多的 RAM,特别是 GPU RAM,通常是最有限的。在这种情况下,您可以配置 TF Serving,使其处理所有挂起的请求与先前的模型版本,并在加载和使用新的模型版本之前卸载它。这种配置将避免同时加载两个模型版本,但服务将在短时间内不可用。

正如您所看到的,TF Serving 使部署新模型变得简单。此外,如果您发现第二个版本的效果不如预期,那么回滚到第一个版本就像删除my_mnist_model/0002目录一样简单。

提示

TF Serving 的另一个重要特性是其自动批处理能力,您可以在启动时使用--enable_batching选项来激活它。当 TF Serving 在短时间内接收到多个请求时(延迟可配置),它会在使用模型之前自动将它们批处理在一起。通过利用 GPU 的性能,这将显著提高性能。一旦模型返回预测结果,TF Serving 会将每个预测结果分发给正确的客户端。通过增加批处理延迟(参见--batching_parameters_file选项),您可以在一定程度上牺牲一点延迟以获得更大的吞吐量。

如果您希望每秒获得许多查询,您将希望在多台服务器上部署 TF Serving 并负载平衡查询(请参见图 19-2)。这将需要在这些服务器上部署和管理许多 TF Serving 容器。处理这一问题的一种方法是使用诸如Kubernetes之类的工具,它是一个简化跨多台服务器容器编排的开源系统。如果您不想购买、维护和升级所有硬件基础设施,您将希望在云平台上使用虚拟机,如 Amazon AWS、Microsoft Azure、Google Cloud Platform、IBM Cloud、Alibaba Cloud、Oracle Cloud 或其他平台即服务(PaaS)提供商。管理所有虚拟机,处理容器编排(即使借助 Kubernetes 的帮助),照顾 TF Serving 配置、调整和监控——所有这些都可能成为一项全职工作。幸运的是,一些服务提供商可以为您处理所有这些事务。在本章中,我们将使用 Vertex AI:它是今天唯一支持 TPUs 的平台;它支持 TensorFlow 2、Scikit-Learn 和 XGBoost;并提供一套不错的人工智能服务。在这个领域还有其他几家提供商也能够提供 TensorFlow 模型的服务,比如 Amazon AWS SageMaker 和 Microsoft AI Platform,所以请确保也查看它们。

mls3 1902

图 19-2。使用负载平衡扩展 TF Serving

现在让我们看看如何在云上提供我们出色的 MNIST 模型!

在 Vertex AI 上创建一个预测服务

Vertex AI 是 Google Cloud Platform(GCP)内的一个平台,提供各种与人工智能相关的工具和服务。您可以上传数据集,让人类对其进行标记,将常用特征存储在特征存储中,并将其用于训练或生产中,使用多个 GPU 或 TPU 服务器进行模型训练,并具有自动超参数调整或模型架构搜索(AutoML)功能。您还可以管理已训练的模型,使用它们对大量数据进行批量预测,为数据工作流程安排多个作业,通过 REST 或 gRPC 以规模化方式提供模型服务,并在名为Workbench的托管 Jupyter 环境中对数据和模型进行实验。甚至还有一个Matching Engine服务,可以非常高效地比较向量(即,近似最近邻)。GCP 还包括其他 AI 服务,例如计算机视觉、翻译、语音转文本等 API。

在我们开始之前,有一些设置需要处理:

  1. 登录您的 Google 账户,然后转到Google Cloud Platform 控制台(参见图 19-3)。如果您没有 Google 账户,您将需要创建一个。

  2. 如果这是您第一次使用 GCP,您将需要阅读并接受条款和条件。新用户可以获得免费试用,包括价值 300 美元的 GCP 信用,您可以在 90 天内使用(截至 2022 年 5 月)。您只需要其中的一小部分来支付本章中将使用的服务。注册免费试用后,您仍然需要创建一个付款配置文件并输入您的信用卡号码:这是用于验证目的——可能是为了避免人们多次使用免费试用,但您不会被收取前 300 美元的费用,之后只有在您选择升级到付费账户时才会收费。

    mls3 1903

    图 19-3. Google Cloud Platform 控制台
  3. 如果您以前使用过 GCP 并且您的免费试用已经过期,那么您在本章中将使用的服务将会花费一些钱。这不应该太多,特别是如果您记得在不再需要这些服务时关闭它们。在运行任何服务之前,请确保您理解并同意定价条件。如果服务最终花费超出您的预期,我在此不承担任何责任!还请确保您的计费账户是活动的。要检查,请打开左上角的☰导航菜单,点击计费,然后确保您已设置付款方式并且计费账户是活动的。

  4. GCP 中的每个资源都属于一个 项目。这包括您可能使用的所有虚拟机、存储的文件和运行的训练作业。当您创建一个帐户时,GCP 会自动为您创建一个名为“我的第一个项目”的项目。如果您愿意,可以通过转到项目设置来更改其显示名称:在 ☰ 导航菜单中,选择“IAM 和管理员 → 设置”,更改项目的显示名称,然后单击“保存”。请注意,项目还有一个唯一的 ID 和编号。您可以在创建项目时选择项目 ID,但以后无法更改。项目编号是自动生成的,无法更更改。如果您想创建一个新项目,请单击页面顶部的项目名称,然后单击“新项目”并输入项目名称。您还可以单击“编辑”来设置项目 ID。确保此新项目的计费处于活动状态,以便可以对服务费用进行计费(如果有免费信用)。

    警告

    请始终设置提醒,以便在您知道只需要几个小时时关闭服务,否则您可能会让其运行数天或数月,从而产生潜在的显著成本。

  5. 现在您已经拥有 GCP 帐户和项目,并且计费已激活,您必须激活所需的 API。在☰导航菜单中,选择“API 和服务”,确保启用了 Cloud Storage API。如果需要,点击+启用 API 和服务,找到 Cloud Storage,并启用它。还要启用 Vertex AI API。

您可以继续通过 GCP 控制台完成所有操作,但我建议改用 Python:这样您可以编写脚本来自动化几乎任何您想要在 GCP 上完成的任务,而且通常比通过菜单和表单点击更方便,特别是对于常见任务。

在您使用任何 GCP 服务之前,您需要做的第一件事是进行身份验证。在使用 Colab 时最简单的解决方案是执行以下代码:

from google.colab import auth

auth.authenticate_user()

认证过程基于 OAuth 2.0:一个弹出窗口会要求您确认您希望 Colab 笔记本访问您的 Google 凭据。如果您接受,您必须选择与 GCP 相同的 Google 帐户。然后,您将被要求确认您同意授予 Colab 对 Google Drive 和 GCP 中所有数据的完全访问权限。如果您允许访问,只有当前笔记本将具有访问权限,并且仅在 Colab 运行时到期之前。显然,只有在您信任笔记本中的代码时才应接受此操作。

警告

如果您使用来自 https://github.com/ageron/handson-ml3 的官方笔记本,则应格外小心:如果笔记本的作者心怀不轨,他们可能包含代码来对您的数据进行任何操作。

现在让我们创建一个 Google Cloud Storage 存储桶来存储我们的 SavedModels(GCS 的存储桶是您数据的容器)。为此,我们将使用预先安装在 Colab 中的google-cloud-storage库。我们首先创建一个Client对象,它将作为与 GCS 的接口,然后我们使用它来创建存储桶:

from google.cloud import storage

project_id = "my_project"  # change this to your project ID
bucket_name = "my_bucket"  # change this to a unique bucket name
location = "us-central1"

storage_client = storage.Client(project=project_id)
bucket = storage_client.create_bucket(bucket_name, location=location)
提示

如果您想重用现有的存储桶,请将最后一行替换为bucket = storage_client.bucket(bucket_name)。确保location设置为存储桶的地区。

GCS 使用单个全球命名空间用于存储桶,因此像“machine-learning”这样的简单名称很可能不可用。确保存储桶名称符合 DNS 命名约定,因为它可能在 DNS 记录中使用。此外,存储桶名称是公开的,因此不要在名称中放入任何私人信息。通常使用您的域名、公司名称或项目 ID 作为前缀以确保唯一性,或者只需在名称中使用一个随机数字。

如果您想要,可以更改区域,但请确保选择支持 GPU 的区域。此外,您可能需要考虑到不同区域之间价格差异很大,一些区域产生的 CO₂比其他区域多得多,一些区域不支持所有服务,并且使用单一区域存储桶可以提高性能。有关更多详细信息,请参阅Google Cloud 的区域列表Vertex AI 的位置文档。如果您不确定,最好选择"us-central1"

接下来,让我们将my_mnist_model目录上传到新的存储桶。在 GCS 中,文件被称为blobs(或objects),在幕后它们都只是放在存储桶中,没有任何目录结构。Blob 名称可以是任意的 Unicode 字符串,甚至可以包含斜杠(/)。GCP 控制台和其他工具使用这些斜杠来产生目录的幻觉。因此,当我们上传my_mnist_model目录时,我们只关心文件,而不是目录。

def upload_directory(bucket, dirpath):
    dirpath = Path(dirpath)
    for filepath in dirpath.glob("**/*"):
        if filepath.is_file():
            blob = bucket.blob(filepath.relative_to(dirpath.parent).as_posix())
            blob.upload_from_filename(filepath)

upload_directory(bucket, "my_mnist_model")

这个函数现在运行良好,但如果有很多文件要上传,它会非常慢。通过多线程可以很容易地大大加快速度(请参阅笔记本中的实现)。或者,如果您有 Google Cloud CLI,则可以使用以下命令:

!gsutil -m cp -r my_mnist_model gs://{bucket_name}/

接下来,让我们告诉 Vertex AI 关于我们的 MNIST 模型。要与 Vertex AI 通信,我们可以使用google-cloud-aiplatform库(它仍然使用旧的 AI Platform 名称而不是 Vertex AI)。它在 Colab 中没有预安装,所以我们需要安装它。之后,我们可以导入该库并进行初始化——只需指定一些项目 ID 和位置的默认值——然后我们可以创建一个新的 Vertex AI 模型:我们指定一个显示名称,我们模型的 GCS 路径(在这种情况下是版本 0001),以及我们希望 Vertex AI 使用的 Docker 容器的 URL 来运行此模型。如果您访问该 URL 并向上导航一个级别,您将找到其他可以使用的容器。这个支持带有 GPU 的 TensorFlow 2.8:

from google.cloud import aiplatform

server_image = "gcr.io/cloud-aiplatform/prediction/tf2-gpu.2-8:latest"

aiplatform.init(project=project_id, location=location)
mnist_model = aiplatform.Model.upload(
    display_name="mnist",
    artifact_uri=f"gs://{bucket_name}/my_mnist_model/0001",
    serving_container_image_uri=server_image,
)

现在让我们部署这个模型,这样我们就可以通过 gRPC 或 REST API 查询它以进行预测。为此,我们首先需要创建一个端点。这是客户端应用程序在想要访问服务时连接的地方。然后我们需要将我们的模型部署到这个端点:

endpoint = aiplatform.Endpoint.create(display_name="mnist-endpoint")

endpoint.deploy(
    mnist_model,
    min_replica_count=1,
    max_replica_count=5,
    machine_type="n1-standard-4",
    accelerator_type="NVIDIA_TESLA_K80",
    accelerator_count=1
)

这段代码可能需要几分钟才能运行,因为 Vertex AI 需要设置一个虚拟机。在这个例子中,我们使用一个相当基本的 n1-standard-4 类型的机器(查看https://homl.info/machinetypes 获取其他类型)。我们还使用了一个基本的 NVIDIA_TESLA_K80 类型的 GPU(查看https://homl.info/accelerators 获取其他类型)。如果您选择的区域不是 "us-central1",那么您可能需要将机器类型或加速器类型更改为该区域支持的值(例如,并非所有区域都有 Nvidia Tesla K80 GPU)。

注意

Google Cloud Platform 实施各种 GPU 配额,包括全球范围和每个地区:您不能在未经 Google 授权的情况下创建成千上万个 GPU 节点。要检查您的配额,请在 GCP 控制台中打开“IAM 和管理员 → 配额”。如果某些配额太低(例如,如果您需要在特定地区更多的 GPU),您可以要求增加它们;通常需要大约 48 小时。

Vertex AI 将最初生成最少数量的计算节点(在这种情况下只有一个),每当每秒查询次数变得过高时,它将生成更多节点(最多为您定义的最大数量,这种情况下为五个),并在它们之间负载均衡查询。如果一段时间内 QPS 速率下降,Vertex AI 将自动停止额外的计算节点。因此,成本直接与负载、您选择的机器和加速器类型以及您在 GCS 上存储的数据量相关。这种定价模型非常适合偶尔使用者和有重要使用高峰的服务。对于初创公司来说也是理想的:价格保持低延迟到公司真正开始运营。

恭喜,您已经将第一个模型部署到云端!现在让我们查询这个预测服务:

response = endpoint.predict(instances=X_new.tolist())

我们首先需要将要分类的图像转换为 Python 列表,就像我们之前使用 REST API 向 TF Serving 发送请求时所做的那样。响应对象包含预测结果,表示为 Python 浮点数列表的列表。让我们将它们四舍五入到两位小数并将它们转换为 NumPy 数组:

>>> import numpy as np
>>> np.round(response.predictions, 2)
array([[0\.  , 0\.  , 0\.  , 0\.  , 0\.  , 0\.  , 0\.  , 1\.  , 0\.  , 0\.  ],
 [0\.  , 0\.  , 0.99, 0.01, 0\.  , 0\.  , 0\.  , 0\.  , 0\.  , 0\.  ],
 [0\.  , 0.97, 0.01, 0\.  , 0\.  , 0\.  , 0\.  , 0.01, 0\.  , 0\.  ]])

是的!我们得到了与之前完全相同的预测结果。我们现在在云上有一个很好的预测服务,我们可以从任何地方安全地查询,并且可以根据 QPS 的数量自动扩展或缩小。当您使用完端点后,请不要忘记将其删除,以避免无谓地支付费用:

endpoint.undeploy_all()  # undeploy all models from the endpoint
endpoint.delete()

现在让我们看看如何在 Vertex AI 上运行作业,对可能非常大的数据批次进行预测。

在 Vertex AI 上运行批量预测作业

如果我们需要进行大量预测,那么我们可以请求 Vertex AI 为我们运行预测作业,而不是重复调用我们的预测服务。这不需要端点,只需要一个模型。例如,让我们在测试集的前 100 张图像上运行一个预测作业,使用我们的 MNIST 模型。为此,我们首先需要准备批处理并将其上传到 GCS。一种方法是创建一个文件,每行包含一个实例,每个实例都格式化为 JSON 值——这种格式称为 JSON Lines——然后将此文件传递给 Vertex AI。因此,让我们在一个新目录中创建一个 JSON Lines 文件,然后将此目录上传到 GCS:

batch_path = Path("my_mnist_batch")
batch_path.mkdir(exist_ok=True)
with open(batch_path / "my_mnist_batch.jsonl", "w") as jsonl_file:
    for image in X_test[:100].tolist():
        jsonl_file.write(json.dumps(image))
        jsonl_file.write("\n")

upload_directory(bucket, batch_path)

现在我们准备启动预测作业,指定作业的名称、要使用的机器和加速器的类型和数量,刚刚创建的 JSON Lines 文件的 GCS 路径,以及 Vertex AI 将保存模型预测的 GCS 目录的路径:

batch_prediction_job = mnist_model.batch_predict(
    job_display_name="my_batch_prediction_job",
    machine_type="n1-standard-4",
    starting_replica_count=1,
    max_replica_count=5,
    accelerator_type="NVIDIA_TESLA_K80",
    accelerator_count=1,
    gcs_source=[f"gs://{bucket_name}/{batch_path.name}/my_mnist_batch.jsonl"],
    gcs_destination_prefix=f"gs://{bucket_name}/my_mnist_predictions/",
    sync=True  # set to False if you don't want to wait for completion
)
提示

对于大批量数据,您可以将输入拆分为多个 JSON Lines 文件,并通过gcs_source参数列出它们。

这将需要几分钟的时间,主要是为了在 Vertex AI 上生成计算节点。一旦这个命令完成,预测将会以类似prediction.results-00001-of-00002的文件集合中可用。这些文件默认使用 JSON Lines 格式,每个值都是包含实例及其对应预测(即 10 个概率)的字典。实例按照输入的顺序列出。该作业还会输出prediction-errors文件,如果出现问题,这些文件对于调试可能会有用。我们可以使用batch_prediction_job.iter_outputs()迭代所有这些输出文件,所以让我们遍历所有的预测并将它们存储在y_probas数组中:

y_probas = []
for blob in batch_prediction_job.iter_outputs():
    if "prediction.results" in blob.name:
        for line in blob.download_as_text().splitlines():
            y_proba = json.loads(line)["prediction"]
            y_probas.append(y_proba)

现在让我们看看这些预测有多好:

>>> y_pred = np.argmax(y_probas, axis=1)
>>> accuracy = np.sum(y_pred == y_test[:100]) / 100
0.98

很好,98%的准确率!

JSON Lines 格式是默认格式,但是当处理大型实例(如图像)时,它太冗长了。幸运的是,batch_predict()方法接受一个instances_format参数,让您可以选择另一种格式。它默认为"jsonl",但您可以将其更改为"csv""tf-record""tf-record-gzip""bigquery""file-list"。如果将其设置为"file-list",那么gcs_source参数应指向一个文本文件,其中每行包含一个输入文件路径;例如,指向 PNG 图像文件。Vertex AI 将读取这些文件作为二进制文件,使用 Base64 对其进行编码,并将生成的字节字符串传递给模型。这意味着您必须在模型中添加一个预处理层来解析 Base64 字符串,使用tf.io.decode_base64()。如果文件是图像,则必须使用类似tf.io.decode_image()tf.io.decode_png()的函数来解析结果,如第十三章中所讨论的。

当您完成使用模型后,如果需要,可以通过运行mnist_model.delete()来删除它。您还可以删除在您的 GCS 存储桶中创建的目录,可选地删除存储桶本身(如果为空),以及批量预测作业。

for prefix in ["my_mnist_model/", "my_mnist_batch/", "my_mnist_predictions/"]:
    blobs = bucket.list_blobs(prefix=prefix)
    for blob in blobs:
        blob.delete()

bucket.delete()  # if the bucket is empty
batch_prediction_job.delete()

您现在知道如何将模型部署到 Vertex AI,创建预测服务,并运行批量预测作业。但是如果您想将模型部署到移动应用程序,或者嵌入式设备,比如加热控制系统、健身追踪器或自动驾驶汽车呢?

将模型部署到移动设备或嵌入式设备

机器学习模型不仅限于在拥有多个 GPU 的大型集中式服务器上运行:它们可以更接近数据源运行(这被称为边缘计算),例如在用户的移动设备或嵌入式设备中。去中心化计算并将其移向边缘有许多好处:它使设备即使未连接到互联网时也能智能化,通过不必将数据发送到远程服务器来减少延迟并减轻服务器负载,并且可能提高隐私性,因为用户的数据可以保留在设备上。

然而,将模型部署到边缘也有其缺点。与强大的多 GPU 服务器相比,设备的计算资源通常很少。一个大模型可能无法适应设备,可能使用过多的 RAM 和 CPU,并且可能下载时间过长。结果,应用可能变得无响应,设备可能会发热并迅速耗尽电池。为了避免这一切,您需要制作一个轻量级且高效的模型,而不会牺牲太多准确性。 TFLite库提供了几个工具,帮助您将模型部署到边缘,主要有三个目标:

  • 减小模型大小,缩短下载时间并减少 RAM 使用量。

  • 减少每次预测所需的计算量,以减少延迟、电池使用量和发热。

  • 使模型适应特定设备的限制。

为了减小模型大小,TFLite 的模型转换器可以接受 SavedModel 并将其压缩为基于 FlatBuffers 的更轻量级格式。这是一个高效的跨平台序列化库(有点像协议缓冲区),最初由谷歌为游戏创建。它设计成可以直接将 FlatBuffers 加载到 RAM 中,无需任何预处理:这样可以减少加载时间和内存占用。一旦模型加载到移动设备或嵌入式设备中,TFLite 解释器将执行它以进行预测。以下是如何将 SavedModel 转换为 FlatBuffer 并保存为 .tflite 文件的方法:

converter = tf.lite.TFLiteConverter.from_saved_model(str(model_path))
tflite_model = converter.convert()
with open("my_converted_savedmodel.tflite", "wb") as f:
    f.write(tflite_model)
提示

您还可以使用 tf.lite.TFLiteConverter.from_keras_model(model) 将 Keras 模型直接保存为 FlatBuffer 格式。

转换器还优化模型,既缩小模型大小,又减少延迟。它修剪所有不需要进行预测的操作(例如训练操作),并在可能的情况下优化计算;例如,3 × a + 4 ×_ a_ + 5 × a 将被转换为 12 × a。此外,它尝试在可能的情况下融合操作。例如,如果可能的话,批量归一化层最终会合并到前一层的加法和乘法操作中。要了解 TFLite 可以对模型进行多少优化,可以下载其中一个预训练的 TFLite 模型,例如Inception_V1_quant(点击tflite&pb),解压缩存档,然后打开优秀的Netron 图形可视化工具并上传.pb文件以查看原始模型。这是一个庞大而复杂的图形,对吧?接下来,打开优化后的.tflite模型,惊叹于其美丽!

除了简单地使用较小的神经网络架构之外,您可以减小模型大小的另一种方法是使用较小的位宽:例如,如果您使用半精度浮点数(16 位)而不是常规浮点数(32 位),模型大小将缩小 2 倍,代价是(通常很小的)准确度下降。此外,训练速度将更快,您将使用大约一半的 GPU 内存。

TFLite 的转换器可以进一步将模型权重量化为固定点、8 位整数!与使用 32 位浮点数相比,这导致了四倍的大小减小。最简单的方法称为后训练量化:它只是在训练后量化权重,使用一种相当基本但高效的对称量化技术。它找到最大绝对权重值m,然后将浮点范围–m到+m映射到固定点(整数)范围–127 到+127。例如,如果权重范围从–1.5 到+0.8,则字节–127、0 和+127 将分别对应于浮点–1.5、0.0 和+1.5(参见图 19-5)。请注意,当使用对称量化时,0.0 始终映射为 0。还请注意,在此示例中不会使用字节值+68 到+127,因为它们映射到大于+0.8 的浮点数。

mls3 1905

图 19-5。从 32 位浮点数到 8 位整数,使用对称量化

要执行这种训练后的量化,只需在调用convert()方法之前将DEFAULT添加到转换器优化列表中:

converter.optimizations = [tf.lite.Optimize.DEFAULT]

这种技术显著减小了模型的大小,使得下载速度更快,占用的存储空间更少。在运行时,量化的权重在使用之前会被转换回浮点数。这些恢复的浮点数与原始浮点数并不完全相同,但也不会相差太远,因此精度损失通常是可以接受的。为了避免一直重新计算浮点值,这样会严重减慢模型的速度,TFLite 会对其进行缓存:不幸的是,这意味着这种技术并不会减少 RAM 的使用量,也不会加快模型的速度。它主要用于减小应用程序的大小。

减少延迟和功耗的最有效方法是对激活进行量化,使得计算可以完全使用整数,而无需任何浮点运算。即使使用相同的位宽(例如,32 位整数而不是 32 位浮点数),整数计算使用的 CPU 周期更少,消耗的能量更少,产生的热量也更少。如果还减少位宽(例如,降至 8 位整数),可以获得巨大的加速。此外,一些神经网络加速器设备(如 Google 的 Edge TPU)只能处理整数,因此权重和激活的完全量化是强制性的。这可以在训练后完成;它需要一个校准步骤来找到激活的最大绝对值,因此您需要向 TFLite 提供代表性的训练数据样本(不需要很大),它将通过模型处理数据并测量量化所需的激活统计信息。这一步通常很快。

量化的主要问题是它会失去一点准确性:这类似于在权重和激活中添加噪声。如果准确性下降太严重,那么您可能需要使用量化感知训练。这意味着向模型添加虚假量化操作,以便它在训练过程中学会忽略量化噪声;最终的权重将更加稳健地适应量化。此外,校准步骤可以在训练过程中自动处理,这简化了整个过程。

我已经解释了 TFLite 的核心概念,但要完全编写移动或嵌入式应用程序需要一本专门的书。幸运的是,一些书籍存在:如果您想了解有关为移动和嵌入式设备构建 TensorFlow 应用程序的更多信息,请查看 O'Reilly 的书籍TinyML: Machine Learning with TensorFlow on Arduino and Ultra-Low Power Micro-Controllers,作者是 Pete Warden(TFLite 团队的前负责人)和 Daniel Situnayake,以及AI and Machine Learning for On-Device Development,作者是 Laurence Moroney。

那么,如果您想在网站中使用您的模型,在用户的浏览器中直接运行呢?

在网页中运行模型

在客户端,即用户的浏览器中运行您的机器学习模型,而不是在服务器端运行,可以在许多场景下非常有用,例如:

  • 当您的网络应用经常在用户的连接不稳定或缓慢的情况下使用(例如,徒步者的网站),因此在客户端直接运行模型是使您的网站可靠的唯一方法。

  • 当您需要模型的响应尽可能快时(例如,用于在线游戏)。消除查询服务器进行预测的需要肯定会减少延迟,并使网站更加响应。

  • 当您的网络服务基于一些私人用户数据进行预测,并且您希望通过在客户端进行预测来保护用户的隐私,以便私人数据永远不必离开用户的设备。

对于所有这些场景,您可以使用TensorFlow.js(TFJS)JavaScript 库。该库可以在用户的浏览器中加载 TFLite 模型并直接进行预测。例如,以下 JavaScript 模块导入了 TFJS 库,下载了一个预训练的 MobileNet 模型,并使用该模型对图像进行分类并记录预测结果。您可以在https://homl.info/tfjscode上尝试这段代码,使用 Glitch.com,这是一个允许您免费在浏览器中构建 Web 应用程序的网站;点击页面右下角的预览按钮查看代码的运行情况:

import "https://cdn.jsdelivr.net/npm/@tensorflow/tfjs@latest";
import "https://cdn.jsdelivr.net/npm/@tensorflow-models/mobilenet@1.0.0";

const image = document.getElementById("image");

mobilenet.load().then(model => {
    model.classify(image).then(predictions => {
        for (var i = 0; i < predictions.length; i++) {
            let className = predictions[i].className
            let proba = (predictions[i].probability * 100).toFixed(1)
            console.log(className + " : " + proba + "%");
        }
    });
});

甚至可以将这个网站转变成一个渐进式 Web 应用程序(PWA):这是一个遵守一系列标准的网站,使其可以在任何浏览器中查看,甚至可以在移动设备上作为独立应用程序安装。例如,在移动设备上尝试访问https://homl.info/tfjswpa:大多数现代浏览器会询问您是否想要将 TFJS 演示添加到主屏幕。如果您接受,您将在应用程序列表中看到一个新图标。点击此图标将在其自己的窗口中加载 TFJS 演示网站,就像常规移动应用程序一样。PWA 甚至可以配置为离线工作,通过使用服务工作者:这是一个在浏览器中以自己独立线程运行的 JavaScript 模块,拦截网络请求,使其可以缓存资源,从而使 PWA 可以更快地运行,甚至完全离线运行。它还可以传递推送消息,在后台运行任务等。PWA 允许您管理 Web 和移动设备的单个代码库。它们还使得更容易确保所有用户运行您应用程序的相同版本。您可以在 Glitch.com 上玩这个 TFJS 演示的 PWA 代码,网址是https://homl.info/wpacode

提示

https://tensorflow.org/js/demos上查看更多在您的浏览器中运行的机器学习模型的演示。

TFJS 还支持在您的网络浏览器中直接训练模型!而且速度相当快。如果您的计算机有 GPU 卡,那么 TFJS 通常可以使用它,即使它不是 Nvidia 卡。实际上,TFJS 将在可用时使用 WebGL,由于现代网络浏览器通常支持各种 GPU 卡,TFJS 实际上支持的 GPU 卡比常规的 TensorFlow 更多(后者仅支持 Nvidia 卡)。

在用户的网络浏览器中训练模型可以特别有用,可以确保用户的数据保持私密。模型可以在中央进行训练,然后在浏览器中根据用户的数据进行本地微调。如果您对这个话题感兴趣,请查看联邦学习

再次强调,要全面涵盖这个主题需要一本完整的书。如果您想了解更多关于 TensorFlow.js 的内容,请查看 O'reilly 图书《云端、移动和边缘的实用深度学习》(Anirudh Koul 等著)或《学习 TensorFlow.js》(Gant Laborde 著)。

现在您已经看到如何将 TensorFlow 模型部署到 TF Serving,或者通过 Vertex AI 部署到云端,或者使用 TFLite 部署到移动和嵌入式设备,或者使用 TFJS 部署到 Web 浏览器,让我们讨论如何使用 GPU 加速计算。

使用 GPU 加速计算

在第十一章中,我们看了几种可以显著加快训练速度的技术:更好的权重初始化、复杂的优化器等等。但即使使用了所有这些技术,使用单个 CPU 的单台机器训练大型神经网络可能需要几个小时、几天,甚至几周,具体取决于任务。由于 GPU 的出现,这种训练时间可以缩短到几分钟或几小时。这不仅节省了大量时间,还意味着您可以更轻松地尝试各种模型,并经常使用新数据重新训练您的模型。

在之前的章节中,我们在 Google Colab 上使用了启用 GPU 的运行时。您只需从运行时菜单中选择“更改运行时类型”,然后选择 GPU 加速器类型;TensorFlow 会自动检测 GPU 并使用它加速计算,代码与没有 GPU 时完全相同。然后,在本章中,您看到了如何将模型部署到 Vertex AI 上的多个启用 GPU 的计算节点:只需在创建 Vertex AI 模型时选择正确的启用 GPU 的 Docker 镜像,并在调用endpoint.deploy()时选择所需的 GPU 类型。但是,如果您想购买自己的 GPU 怎么办?如果您想在单台机器上的 CPU 和多个 GPU 设备之间分发计算(参见图 19-6)?这是我们现在将讨论的内容,然后在本章的后面部分我们将讨论如何在多个服务器上分发计算。

mls3 1906

图 19-6。在多个设备上并行执行 TensorFlow 图

获取自己的 GPU

如果你知道你将会长时间大量使用 GPU,那么购买自己的 GPU 可能是经济上合理的。你可能也想在本地训练模型,因为你不想将数据上传到云端。或者你只是想购买一张用于游戏的 GPU 卡,并且想将其用于深度学习。

如果您决定购买 GPU 卡,那么请花些时间做出正确的选择。您需要考虑您的任务所需的 RAM 数量(例如,图像处理或 NLP 通常至少需要 10GB),带宽(即您可以将数据发送到 GPU 和从 GPU 中发送数据的速度),核心数量,冷却系统等。Tim Dettmers 撰写了一篇优秀的博客文章来帮助您选择:我鼓励您仔细阅读。在撰写本文时,TensorFlow 仅支持具有 CUDA Compute Capability 3.5+的 Nvidia 卡(当然还有 Google 的 TPU),但它可能会将其支持扩展到其他制造商,因此请务必查看TensorFlow 的文档以了解今天支持哪些设备。

如果您选择 Nvidia GPU 卡,您将需要安装适当的 Nvidia 驱动程序和几个 Nvidia 库。这些包括计算统一设备架构库(CUDA)工具包,它允许开发人员使用支持 CUDA 的 GPU 进行各种计算(不仅仅是图形加速),以及CUDA 深度神经网络库(cuDNN),一个 GPU 加速的常见 DNN 计算库,例如激活层、归一化、前向和反向卷积以及池化(参见第十四章)。cuDNN 是 Nvidia 的深度学习 SDK 的一部分。请注意,您需要创建一个 Nvidia 开发者帐户才能下载它。TensorFlow 使用 CUDA 和 cuDNN 来控制 GPU 卡并加速计算(参见图 19-7)。

mls3 1907

图 19-7. TensorFlow 使用 CUDA 和 cuDNN 来控制 GPU 并加速 DNNs

安装了 GPU 卡和所有必需的驱动程序和库之后,您可以使用nvidia-smi命令来检查一切是否正确安装。该命令列出了可用的 GPU 卡,以及每张卡上运行的所有进程。在这个例子中,这是一张 Nvidia Tesla T4 GPU 卡,大约有 15GB 的可用内存,并且当前没有任何进程在运行:

$ nvidia-smi
Sun Apr 10 04:52:10 2022
+-----------------------------------------------------------------------------+
| NVIDIA-SMI 460.32.03    Driver Version: 460.32.03    CUDA Version: 11.2     |
|-------------------------------+----------------------+----------------------+
| GPU  Name        Persistence-M| Bus-Id        Disp.A | Volatile Uncorr. ECC |
| Fan  Temp  Perf  Pwr:Usage/Cap|         Memory-Usage | GPU-Util  Compute M. |
|                               |                      |               MIG M. |
|===============================+======================+======================|
|   0  Tesla T4            Off  | 00000000:00:04.0 Off |                    0 |
| N/A   34C    P8     9W /  70W |      3MiB / 15109MiB |      0%      Default |
|                               |                      |                  N/A |
+-------------------------------+----------------------+----------------------+

+-----------------------------------------------------------------------------+
| Processes:                                                                  |
|  GPU   GI   CI        PID   Type   Process name                  GPU Memory |
|        ID   ID                                                   Usage      |
|=============================================================================|
|  No running processes found                                                 |
+-----------------------------------------------------------------------------+

要检查 TensorFlow 是否真正看到您的 GPU,请运行以下命令并确保结果不为空:

>>> physical_gpus = tf.config.list_physical_devices("GPU")
>>> physical_gpus
[PhysicalDevice(name='/physical_device:GPU:0', device_type='GPU')]

管理 GPU 内存

默认情况下,TensorFlow 在第一次运行计算时会自动占用几乎所有可用 GPU 的 RAM,以限制 GPU RAM 的碎片化。这意味着如果您尝试启动第二个 TensorFlow 程序(或任何需要 GPU 的程序),它将很快耗尽 RAM。这种情况并不像您可能认为的那样经常发生,因为通常您会在一台机器上运行一个单独的 TensorFlow 程序:通常是一个训练脚本、一个 TF Serving 节点或一个 Jupyter 笔记本。如果出于某种原因需要运行多个程序(例如,在同一台机器上并行训练两个不同的模型),那么您需要更均匀地在这些进程之间分配 GPU RAM。

如果您的机器上有多个 GPU 卡,一个简单的解决方案是将每个 GPU 卡分配给单个进程。为此,您可以设置CUDA_VISIBLE_DEVICES环境变量,以便每个进程只能看到适当的 GPU 卡。还要设置CUDA_DEVICE_ORDER环境变量为PCI_BUS_ID,以确保每个 ID 始终指向相同的 GPU 卡。例如,如果您有四个 GPU 卡,您可以启动两个程序,将两个 GPU 分配给每个程序,通过在两个单独的终端窗口中执行以下命令来实现:

$ CUDA_DEVICE_ORDER=PCI_BUS_IDCUDA_VISIBLE_DEVICES=0,1python3program_1.py*`#` `and``in``another``terminal:`*$ CUDA_DEVICE_ORDER=PCI_BUS_IDCUDA_VISIBLE_DEVICES=3,2python3program_2.py

程序 1 将只看到 GPU 卡 0 和 1,分别命名为"/gpu:0""/gpu:1",在 TensorFlow 中,程序 2 将只看到 GPU 卡 2 和 3,分别命名为"/gpu:1""/gpu:0"(注意顺序)。一切都将正常工作(参见图 19-8)。当然,您也可以在 Python 中通过设置os.environ["CUDA_DEVICE_ORDER"]os.environ["CUDA_VISIBLE_DEVICES"]来定义这些环境变量,只要在使用 TensorFlow 之前这样做。

mls3 1908

图 19-8。每个程序获得两个 GPU

另一个选项是告诉 TensorFlow 只获取特定数量的 GPU RAM。这必须在导入 TensorFlow 后立即完成。例如,要使 TensorFlow 只在每个 GPU 上获取 2 GiB 的 RAM,您必须为每个物理 GPU 设备创建一个逻辑 GPU 设备(有时称为虚拟 GPU 设备),并将其内存限制设置为 2 GiB(即 2,048 MiB):

for gpu in physical_gpus:
    tf.config.set_logical_device_configuration(
        gpu,
        [tf.config.LogicalDeviceConfiguration(memory_limit=2048)]
    )

假设您有四个 GPU,每个 GPU 至少有 4 GiB 的 RAM:在这种情况下,可以并行运行两个像这样的程序,每个程序使用所有四个 GPU 卡(请参见图 19-9)。如果在两个程序同时运行时运行nvidia-smi命令,则应该看到每个进程在每张卡上占用 2 GiB 的 RAM。

mls3 1909

图 19-9。每个程序都可以获得四个 GPU,但每个 GPU 只有 2 GiB 的 RAM

另一个选项是告诉 TensorFlow 只在需要时获取内存。同样,在导入 TensorFlow 后必须立即执行此操作:

for gpu in physical_gpus:
    tf.config.experimental.set_memory_growth(gpu, True)

另一种方法是将TF_FORCE_GPU_ALLOW_GROWTH环境变量设置为true。使用这个选项,TensorFlow 一旦分配了内存就不会释放它(再次,为了避免内存碎片化),除非程序结束。使用这个选项很难保证确定性行为(例如,一个程序可能会崩溃,因为另一个程序的内存使用量激增),因此在生产环境中,您可能会选择之前的选项之一。然而,有一些情况下它非常有用:例如,当您使用一台机器运行多个 Jupyter 笔记本时,其中几个使用了 TensorFlow。在 Colab 运行时,TF_FORCE_GPU_ALLOW_GROWTH环境变量被设置为true

最后,在某些情况下,您可能希望将一个 GPU 分成两个或更多逻辑设备。例如,如果您只有一个物理 GPU,比如在 Colab 运行时,但您想要测试一个多 GPU 算法,这将非常有用。以下代码将 GPU#0 分成两个逻辑设备,每个设备有 2 GiB 的 RAM(同样,在导入 TensorFlow 后立即执行):

tf.config.set_logical_device_configuration(
    physical_gpus[0],
    [tf.config.LogicalDeviceConfiguration(memory_limit=2048),
     tf.config.LogicalDeviceConfiguration(memory_limit=2048)]
)

这两个逻辑设备被称为"/gpu:0""/gpu:1", 你可以像使用两个普通 GPU 一样使用它们。你可以像这样列出所有逻辑设备:

>>> logical_gpus = tf.config.list_logical_devices("GPU")
>>> logical_gpus
[LogicalDevice(name='/device:GPU:0', device_type='GPU'),
 LogicalDevice(name='/device:GPU:1', device_type='GPU')]

现在让我们看看 TensorFlow 如何决定应该使用哪些设备来放置变量和执行操作。

将操作和变量放在设备上

Keras 和 tf.data 通常会很好地将操作和变量放在它们应该在的位置,但如果您想要更多控制,您也可以手动将操作和变量放在每个设备上:

  • 通常,您希望将数据预处理操作放在 CPU 上,并将神经网络操作放在 GPU 上。

  • GPU 通常具有相对有限的通信带宽,因此重要的是要避免不必要的数据传输进出 GPU。

  • 向机器添加更多的 CPU RAM 是简单且相对便宜的,因此通常有很多,而 GPU RAM 是内置在 GPU 中的:它是一种昂贵且有限的资源,因此如果一个变量在接下来的几个训练步骤中不需要,它可能应该放在 CPU 上(例如,数据集通常应该放在 CPU 上)。

默认情况下,所有变量和操作都将放置在第一个 GPU 上(命名为"/gpu:0"),除非变量和操作没有 GPU 内核:这些将放置在 CPU 上(始终命名为"/cpu:0")。张量或变量的device属性告诉您它被放置在哪个设备上。

>>> a = tf.Variable([1., 2., 3.])  # float32 variable goes to the GPU
>>> a.device
'/job:localhost/replica:0/task:0/device:GPU:0'
>>> b = tf.Variable([1, 2, 3])  # int32 variable goes to the CPU
>>> b.device
'/job:localhost/replica:0/task:0/device:CPU:0'

您现在可以安全地忽略前缀/job:localhost/replica:0/task:0;我们将在本章后面讨论作业、副本和任务。正如您所看到的,第一个变量被放置在 GPU#0 上,这是默认设备。但是,第二个变量被放置在 CPU 上:这是因为整数变量没有 GPU 内核,或者涉及整数张量的操作没有 GPU 内核,因此 TensorFlow 回退到 CPU。

如果您想在与默认设备不同的设备上执行操作,请使用tf.device()上下文:

>>> with tf.device("/cpu:0"):
...     c = tf.Variable([1., 2., 3.])
...
>>> c.device
'/job:localhost/replica:0/task:0/device:CPU:0'
注意

CPU 始终被视为单个设备("/cpu:0"),即使您的计算机有多个 CPU 核心。放置在 CPU 上的任何操作,如果具有多线程内核,则可能在多个核心上并行运行。

如果您明确尝试将操作或变量放置在不存在或没有内核的设备上,那么 TensorFlow 将悄悄地回退到默认选择的设备。当您希望能够在不具有相同数量的 GPU 的不同机器上运行相同的代码时,这是很有用的。但是,如果您希望获得异常,可以运行tf.config.set_soft_device_placement(False)

现在,TensorFlow 如何在多个设备上执行操作呢?

跨多个设备并行执行

正如我们在第十二章中看到的,使用 TF 函数的一个好处是并行性。让我们更仔细地看一下这一点。当 TensorFlow 运行一个 TF 函数时,它首先分析其图形,找到需要评估的操作列表,并计算每个操作的依赖关系数量。然后 TensorFlow 将每个具有零依赖关系的操作(即每个源操作)添加到该操作设备的评估队列中(参见图 19-10)。一旦一个操作被评估,依赖于它的每个操作的依赖计数器都会减少。一旦一个操作的依赖计数器达到零,它就会被推送到其设备的评估队列中。一旦所有输出都被计算出来,它们就会被返回。

mls3 1910

图 19-10. TensorFlow 图的并行执行

CPU 的评估队列中的操作被分派到一个称为inter-op 线程池的线程池中。如果 CPU 有多个核心,那么这些操作将有效地并行评估。一些操作具有多线程 CPU 内核:这些内核将其任务分割为多个子操作,这些子操作被放置在另一个评估队列中,并分派到一个称为intra-op 线程池的第二线程池中(由所有多线程 CPU 内核共享)。简而言之,多个操作和子操作可能在不同的 CPU 核心上并行评估。

对于 GPU 来说,情况要简单一些。GPU 的评估队列中的操作是按顺序评估的。然而,大多数操作都有多线程 GPU 内核,通常由 TensorFlow 依赖的库实现,比如 CUDA 和 cuDNN。这些实现有自己的线程池,它们通常会利用尽可能多的 GPU 线程(这就是为什么 GPU 不需要一个跨操作线程池的原因:每个操作已经占用了大部分 GPU 线程)。

例如,在图 19-10 中,操作 A、B 和 C 是源操作,因此它们可以立即被评估。操作 A 和 B 被放置在 CPU 上,因此它们被发送到 CPU 的评估队列,然后被分派到跨操作线程池并立即并行评估。操作 A 恰好有一个多线程内核;它的计算被分成三部分,在操作线程池中并行执行。操作 C 进入 GPU #0 的评估队列,在这个例子中,它的 GPU 内核恰好使用 cuDNN,它管理自己的内部操作线程池,并在许多 GPU 线程之间并行运行操作。假设 C 先完成。D 和 E 的依赖计数器被减少到 0,因此两个操作都被推送到 GPU #0 的评估队列,并按顺序执行。请注意,即使 D 和 E 都依赖于 C,C 也只被评估一次。假设 B 接下来完成。然后 F 的依赖计数器从 4 减少到 3,由于不为 0,它暂时不运行。一旦 A、D 和 E 完成,那么 F 的依赖计数器达到 0,它被推送到 CPU 的评估队列并被评估。最后,TensorFlow 返回请求的输出。

TensorFlow 执行的另一个神奇之处是当 TF 函数修改状态资源(例如变量)时:它确保执行顺序与代码中的顺序匹配,即使语句之间没有显式依赖关系。例如,如果您的 TF 函数包含v.assign_add(1),然后是v.assign(v * 2),TensorFlow 将确保这些操作按照这个顺序执行。

提示

您可以通过调用tf.config.threading.set_inter_op_parallelism_threads()来控制 inter-op 线程池中的线程数。要设置 intra-op 线程数,请使用tf.config.threading.set_intra_op_parallelism_threads()。如果您不希望 TensorFlow 使用所有 CPU 核心,或者希望它是单线程的,这将非常有用。⁠¹²

有了这些,您就拥有了在任何设备上运行任何操作并利用 GPU 的能力所需的一切!以下是您可以做的一些事情:

  • 您可以并行训练多个模型,每个模型都在自己的 GPU 上:只需为每个模型编写一个训练脚本,并在并行运行时设置CUDA_DEVICE_ORDERCUDA_VISIBLE_DEVICES,以便每个脚本只能看到一个 GPU 设备。这对于超参数调整非常有用,因为您可以并行训练具有不同超参数的多个模型。如果您有一台具有两个 GPU 的单台机器,并且在一个 GPU 上训练一个模型需要一个小时,那么并行训练两个模型,每个模型都在自己专用的 GPU 上,只需要一个小时。简单!

  • 您可以在单个 GPU 上训练一个模型,并在 CPU 上并行执行所有预处理操作,使用数据集的prefetch()方法提前准备好接下来的几批数据,以便在 GPU 需要时立即使用(参见第十三章)。

  • 如果您的模型接受两个图像作为输入,并在使用两个 CNN 处理它们之前将它们连接起来,那么如果您将每个 CNN 放在不同的 GPU 上,它可能会运行得更快。

  • 您可以创建一个高效的集成:只需在每个 GPU 上放置一个不同训练过的模型,这样您就可以更快地获得所有预测结果,以生成集成的最终预测。

但是如果您想通过使用多个 GPU 加速训练呢?

在多个设备上训练模型

训练单个模型跨多个设备有两种主要方法:模型并行,其中模型在设备之间分割,和数据并行,其中模型在每个设备上复制,并且每个副本在不同的数据子集上进行训练。让我们看看这两种选择。

模型并行

到目前为止,我们已经在单个设备上训练了每个神经网络。如果我们想要在多个设备上训练单个神经网络怎么办?这需要将模型分割成单独的块,并在不同的设备上运行每个块。不幸的是,这种模型并行化实际上非常棘手,其有效性确实取决于神经网络的架构。对于全连接网络,从这种方法中通常无法获得太多好处。直觉上,似乎将模型分割的一种简单方法是将每一层放在不同的设备上,但这并不起作用,因为每一层都需要等待前一层的输出才能执行任何操作。也许你可以垂直切割它——例如,将每一层的左半部分放在一个设备上,右半部分放在另一个设备上?这样稍微好一些,因为每一层的两半确实可以并行工作,但问题在于下一层的每一半都需要上一层两半的输出,因此会有大量的跨设备通信(由虚线箭头表示)。这很可能会完全抵消并行计算的好处,因为跨设备通信速度很慢(当设备位于不同的机器上时更是如此)。

mls3 1911

图 19-11。拆分完全连接的神经网络

一些神经网络架构,如卷积神经网络(参见第十四章),包含仅部分连接到较低层的层,因此更容易以有效的方式在设备之间分发块(参见图 19-12)。

mls3 1912

图 19-12。拆分部分连接的神经网络

深度递归神经网络(参见第十五章)可以更有效地跨多个 GPU 进行分割。如果将网络水平分割,将每一层放在不同的设备上,并将输入序列输入网络进行处理,那么在第一个时间步中只有一个设备会处于活动状态(处理序列的第一个值),在第二个时间步中两个设备会处于活动状态(第二层将处理第一层的输出值,而第一层将处理第二个值),当信号传播到输出层时,所有设备将同时处于活动状态(图 19-13)。尽管设备之间仍然存在大量的跨设备通信,但由于每个单元可能相当复杂,理论上并行运行多个单元的好处可能会超过通信惩罚。然而,在实践中,在单个 GPU 上运行的常规堆叠LSTM层实际上运行得更快。

mls3 1913

图 19-13。拆分深度递归神经网络

简而言之,模型并行可能会加快某些类型的神经网络的运行或训练速度,但并非所有类型的神经网络都适用,并且需要特别注意和调整,例如确保需要进行通信的设备在同一台机器上运行。接下来我们将看一个更简单且通常更有效的选择:数据并行。

数据并行

另一种并行训练神经网络的方法是在每个设备上复制它,并在所有副本上同时运行每个训练步骤,为每个副本使用不同的小批量。然后对每个副本计算的梯度进行平均,并将结果用于更新模型参数。这被称为数据并行,有时也称为单程序,多数据(SPMD)。这个想法有许多变体,让我们看看最重要的几种。

使用镜像策略的数据并行

可以说,最简单的方法是在所有 GPU 上完全镜像所有模型参数,并始终在每个 GPU 上应用完全相同的参数更新。这样,所有副本始终保持完全相同。这被称为镜像策略,在使用单台机器时特别高效(参见图 19-14)。

mls3 1914

图 19-14. 使用镜像策略的数据并行

使用这种方法的棘手部分是高效地计算所有 GPU 的所有梯度的平均值,并将结果分布到所有 GPU 上。这可以使用AllReduce算法来完成,这是一类算法,多个节点合作以高效地执行reduce 操作(例如计算平均值、总和和最大值),同时确保所有节点获得相同的最终结果。幸运的是,有现成的实现这种算法,您将会看到。

集中式参数的数据并行

另一种方法是将模型参数存储在执行计算的 GPU 设备之外(称为工作器);例如,在 CPU 上(参见图 19-15)。在分布式设置中,您可以将所有参数放在一个或多个仅称为参数服务器的 CPU 服务器上,其唯一作用是托管和更新参数。

mls3 1915

图 19-15. 集中式参数的数据并行

镜像策略强制所有 GPU 上的权重更新同步进行,而这种集中式方法允许同步或异步更新。让我们来看看这两种选择的优缺点。

同步更新

同步更新中,聚合器会等待所有梯度可用后再计算平均梯度并将其传递给优化器,优化器将更新模型参数。一旦一个副本完成计算其梯度,它必须等待参数更新后才能继续下一个小批量。缺点是一些设备可能比其他设备慢,因此快速设备将不得不在每一步等待慢速设备,使整个过程与最慢设备一样慢。此外,参数将几乎同时复制到每个设备上(在梯度应用后立即),这可能会饱和参数服务器的带宽。

提示

为了减少每个步骤的等待时间,您可以忽略最慢几个副本(通常约 10%)的梯度。例如,您可以运行 20 个副本,但每个步骤只聚合来自最快的 18 个副本的梯度,并忽略最后 2 个的梯度。一旦参数更新,前 18 个副本可以立即开始工作,而无需等待最慢的 2 个副本。这种设置通常被描述为有 18 个副本加上 2 个备用副本

异步更新

使用异步更新时,每当一个副本完成梯度计算后,梯度立即用于更新模型参数。没有聚合(它删除了“均值”步骤在图 19-15 中)和没有同步。副本独立于其他副本工作。由于不需要等待其他副本,这种方法每分钟可以运行更多的训练步骤。此外,尽管参数仍然需要在每一步复制到每个设备,但对于每个副本,这发生在不同的时间,因此带宽饱和的风险降低了。

使用异步更新的数据并行是一个吸引人的选择,因为它简单、没有同步延迟,并且更好地利用了带宽。然而,尽管在实践中它表现得相当不错,但它能够工作几乎令人惊讶!事实上,当一个副本基于某些参数值计算梯度完成时,这些参数将已经被其他副本多次更新(如果有N个副本,则平均更新N - 1 次),并且无法保证计算出的梯度仍然指向正确的方向(参见图 19-16)。当梯度严重过时时,它们被称为过时梯度:它们可以减慢收敛速度,引入噪声和摆动效应(学习曲线可能包含临时振荡),甚至可能使训练算法发散。

mls3 1916

图 19-16。使用异步更新时的过时梯度

有几种方法可以减少陈旧梯度的影响:

  • 降低学习率。

  • 丢弃陈旧的梯度或将其缩小。

  • 调整小批量大小。

  • 在开始的几个时期只使用一个副本(这被称为热身阶段)。在训练开始阶段,梯度通常很大,参数还没有稳定在成本函数的谷底,因此陈旧的梯度可能会造成更大的损害,不同的副本可能会将参数推向完全不同的方向。

2016 年,Google Brain 团队发表的一篇论文对各种方法进行了基准测试,发现使用同步更新和一些备用副本比使用异步更新更有效,不仅收敛更快,而且产生了更好的模型。然而,这仍然是一个活跃的研究领域,所以你不应该立刻排除异步更新。

带宽饱和

无论您使用同步还是异步更新,具有集中参数的数据并行仍然需要在每个训练步骤开始时将模型参数从参数服务器传递到每个副本,并在每个训练步骤结束时将梯度传递到另一个方向。同样,当使用镜像策略时,每个 GPU 生成的梯度将需要与每个其他 GPU 共享。不幸的是,通常会出现这样一种情况,即添加额外的 GPU 将不会改善性能,因为将数据移入和移出 GPU RAM(以及在分布式设置中跨网络)所花费的时间将超过通过分割计算负载获得的加速效果。在那一点上,添加更多的 GPU 将只会加剧带宽饱和,并实际上减慢训练速度。

饱和对于大型密集模型来说更严重,因为它们有很多参数和梯度需要传输。对于小型模型来说,饱和程度较轻(但并行化增益有限),对于大型稀疏模型也较轻,因为梯度通常大部分为零,可以有效传输。Google Brain 项目的发起人和负责人 Jeff Dean 报告 在将计算分布到 50 个 GPU 上时,密集模型的典型加速为 25-40 倍,而在 500 个 GPU 上训练稀疏模型时,加速为 300 倍。正如你所看到的,稀疏模型确实更好地扩展。以下是一些具体例子:

  • 神经机器翻译:在 8 个 GPU 上加速 6 倍

  • Inception/ImageNet:在 50 个 GPU 上加速 32 倍

  • RankBrain:在 500 个 GPU 上加速 300 倍

有很多研究正在进行,以缓解带宽饱和问题,目标是使训练能够与可用的 GPU 数量成线性比例扩展。例如,卡内基梅隆大学、斯坦福大学和微软研究团队在 2018 年提出了一个名为PipeDream的系统,成功将网络通信减少了 90%以上,使得可以在多台机器上训练大型模型成为可能。他们使用了一种称为管道并行的新技术来实现这一目标,该技术结合了模型并行和数据并行:模型被切分成连续的部分,称为阶段,每个阶段在不同的机器上进行训练。这导致了一个异步的管道,所有机器都在很少的空闲时间内并行工作。在训练过程中,每个阶段交替进行一轮前向传播和一轮反向传播:它从输入队列中提取一个小批量数据,处理它,并将输出发送到下一个阶段的输入队列,然后从梯度队列中提取一个小批量的梯度,反向传播这些梯度并更新自己的模型参数,并将反向传播的梯度推送到前一个阶段的梯度队列。然后它一遍又一遍地重复整个过程。每个阶段还可以独立地使用常规的数据并行(例如使用镜像策略),而不受其他阶段的影响。

mls3 1917

图 19-17。PipeDream 的管道并行性

然而,正如在这里展示的那样,PipeDream 不会工作得那么好。要理解原因,考虑在 Figure 19-17 中的第 5 个小批次:当它在前向传递过程中经过第 1 阶段时,来自第 4 个小批次的梯度尚未通过该阶段进行反向传播,但是当第 5 个小批次的梯度流回到第 1 阶段时,第 4 个小批次的梯度将已经被用来更新模型参数,因此第 5 个小批次的梯度将有点过时。正如我们所看到的,这可能会降低训练速度和准确性,甚至使其发散:阶段越多,这个问题就会变得越糟糕。论文的作者提出了缓解这个问题的方法:例如,每个阶段在前向传播过程中保存权重,并在反向传播过程中恢复它们,以确保相同的权重用于前向传递和反向传递。这被称为权重存储。由于这一点,PipeDream 展示了令人印象深刻的扩展能力,远远超出了简单的数据并行性。

这个研究领域的最新突破是由谷歌研究人员在一篇2022 年的论文中发表的:他们开发了一个名为Pathways的系统,利用自动模型并行、异步团队调度等技术,实现了数千个 TPU 几乎 100%的硬件利用率!调度意味着组织每个任务必须运行的时间和位置,团队调度意味着同时并行运行相关任务,并且彼此靠近,以减少任务等待其他任务输出的时间。正如我们在第十六章中看到的,这个系统被用来在超过 6,000 个 TPU 上训练一个庞大的语言模型,几乎实现了 100%的硬件利用率:这是一个令人惊叹的工程壮举。

在撰写本文时,Pathways 尚未公开,但很可能在不久的将来,您将能够使用 Pathways 或类似系统在 Vertex AI 上训练大型模型。与此同时,为了减少饱和问题,您可能会希望使用一些强大的 GPU,而不是大量的弱 GPU,如果您需要在多台服务器上训练模型,您应该将 GPU 分组在少数且连接非常良好的服务器上。您还可以尝试将浮点精度从 32 位(tf.float32)降低到 16 位(tf.bfloat16)。这将减少一半的数据传输量,通常不会对收敛速度或模型性能产生太大影响。最后,如果您正在使用集中式参数,您可以将参数分片(分割)到多个参数服务器上:增加更多的参数服务器将减少每个服务器上的网络负载,并限制带宽饱和的风险。

好的,现在我们已经讨论了所有的理论,让我们实际在多个 GPU 上训练一个模型!

使用分布策略 API 进行规模训练

幸运的是,TensorFlow 带有一个非常好的 API,它负责处理将模型分布在多个设备和机器上的所有复杂性:分布策略 API。要在所有可用的 GPU 上(暂时只在单台机器上)使用数据并行性和镜像策略训练一个 Keras 模型,只需创建一个MirroredStrategy对象,调用它的scope()方法以获取一个分布上下文,并将模型的创建和编译包装在该上下文中。然后正常调用模型的fit()方法:

strategy = tf.distribute.MirroredStrategy()

with strategy.scope():
    model = tf.keras.Sequential([...])  # create a Keras model normally
    model.compile([...])  # compile the model normally

batch_size = 100  # preferably divisible by the number of replicas
model.fit(X_train, y_train, epochs=10,
          validation_data=(X_valid, y_valid), batch_size=batch_size)

在底层,Keras 是分布感知的,因此在这个MirroredStrategy上下文中,它知道必须在所有可用的 GPU 设备上复制所有变量和操作。如果你查看模型的权重,它们是MirroredVariable类型的:

>>> type(model.weights[0])
tensorflow.python.distribute.values.MirroredVariable

请注意,fit() 方法会自动将每个训练批次在所有副本之间进行分割,因此最好确保批次大小可以被副本数量(即可用的 GPU 数量)整除,以便所有副本获得相同大小的批次。就是这样!训练通常会比使用单个设备快得多,而且代码更改确实很小。

训练模型完成后,您可以使用它高效地进行预测:调用predict()方法,它会自动将批处理在所有副本之间分割,以并行方式进行预测。再次强调,批处理大小必须能够被副本数量整除。如果调用模型的save()方法,它将被保存为常规模型,而不是具有多个副本的镜像模型。因此,当您加载它时,它将像常规模型一样运行,在单个设备上:默认情况下在 GPU#0 上,如果没有 GPU 则在 CPU 上。如果您想加载一个模型并在所有可用设备上运行它,您必须在分发上下文中调用tf.keras.models.load_model()

with strategy.scope():
    model = tf.keras.models.load_model("my_mirrored_model")

如果您只想使用所有可用 GPU 设备的子集,您可以将列表传递给MirroredStrategy的构造函数:

strategy = tf.distribute.MirroredStrategy(devices=["/gpu:0", "/gpu:1"])

默认情况下,MirroredStrategy类使用NVIDIA Collective Communications Library(NCCL)进行 AllReduce 均值操作,但您可以通过将cross_device_ops参数设置为tf.distribute.HierarchicalCopyAllReduce类的实例或tf.distribute.ReductionToOneDevice类的实例来更改它。默认的 NCCL 选项基于tf.distribute.NcclAllReduce类,通常更快,但这取决于 GPU 的数量和类型,因此您可能想尝试一下其他选择。

如果您想尝试使用集中式参数的数据并行性,请将MirroredStrategy替换为CentralStorageStrategy

strategy = tf.distribute.experimental.CentralStorageStrategy()

您可以选择设置compute_devices参数来指定要用作工作器的设备列表-默认情况下将使用所有可用的 GPU-您还可以选择设置parameter_device参数来指定要存储参数的设备。默认情况下将使用 CPU,或者如果只有一个 GPU,则使用 GPU。

现在让我们看看如何在一组 TensorFlow 服务器上训练模型!

在 TensorFlow 集群上训练模型

TensorFlow 集群是一组在并行运行的 TensorFlow 进程,通常在不同的机器上,并相互通信以完成一些工作,例如训练或执行神经网络模型。集群中的每个 TF 进程被称为任务TF 服务器。它有一个 IP 地址,一个端口和一个类型(也称为角色工作)。类型可以是"worker""chief""ps"(参数服务器)或"evaluator"

  • 每个worker执行计算,通常在一台或多台 GPU 的机器上。

  • 首席执行计算任务(它是一个工作者),但也处理额外的工作,比如编写 TensorBoard 日志或保存检查点。集群中只有一个首席。如果没有明确指定首席,则按照惯例第一个工作者就是首席。

  • 参数服务器只跟踪变量值,并且通常在仅有 CPU 的机器上。这种类型的任务只能与ParameterServerStrategy一起使用。

  • 评估者显然负责评估。这种类型并不经常使用,当使用时,通常只有一个评估者。

要启动一个 TensorFlow 集群,必须首先定义其规范。这意味着定义每个任务的 IP 地址、TCP 端口和类型。例如,以下集群规范定义了一个有三个任务的集群(两个工作者和一个参数服务器;参见图 19-18)。集群规范是一个字典,每个作业对应一个键,值是任务地址(IP:port)的列表:

cluster_spec = {
    "worker": [
        "machine-a.example.com:2222",     # /job:worker/task:0
        "machine-b.example.com:2222"      # /job:worker/task:1
    ],
    "ps": ["machine-a.example.com:2221"]  # /job:ps/task:0
}

通常每台机器上会有一个任务,但正如这个示例所示,如果需要,您可以在同一台机器上配置多个任务。在这种情况下,如果它们共享相同的 GPU,请确保 RAM 适当分配,如前面讨论的那样。

警告

默认情况下,集群中的每个任务可以与其他任务通信,因此请确保配置防火墙以授权这些机器之间这些端口上的所有通信(如果每台机器使用相同的端口,则通常更简单)。

mls3 1918

图 19-18。一个示例 TensorFlow 集群

当您开始一个任务时,您必须给它指定集群规范,并且还必须告诉它它的类型和索引是什么(例如,worker #0)。一次性指定所有内容的最简单方法(包括集群规范和当前任务的类型和索引)是在启动 TensorFlow 之前设置TF_CONFIG环境变量。它必须是一个 JSON 编码的字典,包含集群规范(在"cluster"键下)和当前任务的类型和索引(在"task"键下)。例如,以下TF_CONFIG环境变量使用我们刚刚定义的集群,并指定要启动的任务是 worker #0:

os.environ["TF_CONFIG"] = json.dumps({
    "cluster": cluster_spec,
    "task": {"type": "worker", "index": 0}
})
提示

通常您希望在 Python 之外定义TF_CONFIG环境变量,这样代码就不需要包含当前任务的类型和索引(这样可以在所有工作节点上使用相同的代码)。

现在让我们在集群上训练一个模型!我们将从镜像策略开始。首先,您需要为每个任务适当设置TF_CONFIG环境变量。集群规范中不应该有参数服务器(删除集群规范中的"ps"键),通常每台机器上只需要一个工作节点。确保为每个任务设置不同的任务索引。最后,在每个工作节点上运行以下脚本:

import tempfile
import tensorflow as tf

strategy = tf.distribute.MultiWorkerMirroredStrategy()  # at the start!
resolver = tf.distribute.cluster_resolver.TFConfigClusterResolver()
print(f"Starting task {resolver.task_type} #{resolver.task_id}")
[...] # load and split the MNIST dataset

with strategy.scope():
    model = tf.keras.Sequential([...])  # build the Keras model
    model.compile([...])  # compile the model

model.fit(X_train, y_train, validation_data=(X_valid, y_valid), epochs=10)

if resolver.task_id == 0:  # the chief saves the model to the right location
    model.save("my_mnist_multiworker_model", save_format="tf")
else:
    tmpdir = tempfile.mkdtemp()  # other workers save to a temporary directory
    model.save(tmpdir, save_format="tf")
    tf.io.gfile.rmtree(tmpdir)  # and we can delete this directory at the end!

这几乎是您之前使用的相同代码,只是这次您正在使用MultiWorkerMirroredStrategy。当您在第一个工作节点上启动此脚本时,它们将在 AllReduce 步骤处保持阻塞,但是一旦最后一个工作节点启动,训练将开始,并且您将看到它们以完全相同的速度前进,因为它们在每一步都进行同步。

警告

在使用MultiWorkerMirroredStrategy时,重要的是确保所有工作人员做同样的事情,包括保存模型检查点或编写 TensorBoard 日志,即使您只保留主要写入的内容。这是因为这些操作可能需要运行 AllReduce 操作,因此所有工作人员必须保持同步。

这个分发策略有两种 AllReduce 实现方式:基于 gRPC 的环形 AllReduce 算法用于网络通信,以及 NCCL 的实现。要使用哪种最佳算法取决于工作人员数量、GPU 数量和类型,以及网络情况。默认情况下,TensorFlow 会应用一些启发式方法为您选择合适的算法,但您可以强制使用 NCCL(或 RING)如下:

strategy = tf.distribute.MultiWorkerMirroredStrategy(
    communication_options=tf.distribute.experimental.CommunicationOptions(
        implementation=tf.distribute.experimental.CollectiveCommunication.NCCL))

如果您希望使用参数服务器实现异步数据并行处理,请将策略更改为ParameterServerStrategy,添加一个或多个参数服务器,并为每个任务适当配置TF_CONFIG。请注意,虽然工作人员将异步工作,但每个工作人员上的副本将同步工作。

最后,如果您可以访问Google Cloud 上的 TPU——例如,如果您在 Colab 中设置加速器类型为 TPU——那么您可以像这样创建一个TPUStrategy

resolver = tf.distribute.cluster_resolver.TPUClusterResolver()
tf.tpu.experimental.initialize_tpu_system(resolver)
strategy = tf.distribute.experimental.TPUStrategy(resolver)

这需要在导入 TensorFlow 后立即运行。然后您可以正常使用这个策略。

提示

如果您是研究人员,您可能有资格免费使用 TPU;请查看https://tensorflow.org/tfrc获取更多详细信息。

现在您可以跨多个 GPU 和多个服务器训练模型:给自己一个鼓励!然而,如果您想训练一个非常大的模型,您将需要许多 GPU,跨多个服务器,这将要求要么购买大量硬件,要么管理大量云虚拟机。在许多情况下,使用一个云服务来为您提供所有这些基础设施的配置和管理会更方便、更经济,只有在您需要时才会提供。让我们看看如何使用 Vertex AI 来实现这一点。

在 Vertex AI 上运行大型训练作业

Vertex AI 允许您使用自己的训练代码创建自定义训练作业。实际上,您可以几乎使用与在自己的 TF 集群上使用的相同的训练代码。您必须更改的主要内容是首席应该保存模型、检查点和 TensorBoard 日志的位置。首席必须将模型保存到 GCS,使用 Vertex AI 在AIP_MODEL_DIR环境变量中提供的路径,而不是将模型保存到本地目录。对于模型检查点和 TensorBoard 日志,您应该分别使用AIP_CHECKPOINT_DIRAIP_TENSORBOARD_LOG_DIR环境变量中包含的路径。当然,您还必须确保训练数据可以从虚拟机访问,例如在 GCS 上,或者从另一个 GCP 服务(如 BigQuery)或直接从网络上访问。最后,Vertex AI 明确设置了"chief"任务类型,因此您应该使用resolved.task_type == "chief"来识别首席,而不是使用resolved.task_id == 0

import os
[...]  # other imports, create MultiWorkerMirroredStrategy, and resolver

if resolver.task_type == "chief":
    model_dir = os.getenv("AIP_MODEL_DIR")  # paths provided by Vertex AI
    tensorboard_log_dir = os.getenv("AIP_TENSORBOARD_LOG_DIR")
    checkpoint_dir = os.getenv("AIP_CHECKPOINT_DIR")
else:
    tmp_dir = Path(tempfile.mkdtemp())  # other workers use temporary dirs
    model_dir = tmp_dir / "model"
    tensorboard_log_dir = tmp_dir / "logs"
    checkpoint_dir = tmp_dir / "ckpt"

callbacks = [tf.keras.callbacks.TensorBoard(tensorboard_log_dir),
             tf.keras.callbacks.ModelCheckpoint(checkpoint_dir)]
[...]  # build and  compile using the strategy scope, just like earlier
model.fit(X_train, y_train, validation_data=(X_valid, y_valid), epochs=10,
          callbacks=callbacks)
model.save(model_dir, save_format="tf")
提示

如果您将训练数据放在 GCS 上,您可以创建一个tf.data.TextLineDatasettf.data.TFRecordDataset来访问它:只需将 GCS 路径作为文件名(例如,gs://my_bucket/data/001.csv)。这些数据集依赖于tf.io.gfile包来访问文件:它支持本地文件和 GCS 文件。

现在您可以在 Vertex AI 上基于这个脚本创建一个自定义训练作业。您需要指定作业名称、训练脚本的路径、用于训练的 Docker 镜像、用于预测的镜像(训练后)、您可能需要的任何其他 Python 库,以及最后 Vertex AI 应该使用作为存储训练脚本的暂存目录的存储桶。默认情况下,这也是训练脚本将保存训练模型、TensorBoard 日志和模型检查点(如果有的话)的地方。让我们创建这个作业:

custom_training_job = aiplatform.CustomTrainingJob(
    display_name="my_custom_training_job",
    script_path="my_vertex_ai_training_task.py",
    container_uri="gcr.io/cloud-aiplatform/training/tf-gpu.2-4:latest",
    model_serving_container_image_uri=server_image,
    requirements=["gcsfs==2022.3.0"],  # not needed, this is just an example
    staging_bucket=f"gs://{bucket_name}/staging"
)

现在让我们在两个拥有两个 GPU 的工作节点上运行它:

mnist_model2 = custom_training_job.run(
    machine_type="n1-standard-4",
    replica_count=2,
    accelerator_type="NVIDIA_TESLA_K80",
    accelerator_count=2,
)

这就是全部内容:Vertex AI 将为您请求的计算节点进行配置(在您的配额范围内),并在这些节点上运行您的训练脚本。一旦作业完成,run()方法将返回一个经过训练的模型,您可以像之前创建的那样使用它:您可以部署到端点,或者用它进行批量预测。如果在训练过程中出现任何问题,您可以在 GCP 控制台中查看日志:在☰导航菜单中,选择 Vertex AI → 训练,点击您的训练作业,然后点击查看日志。或者,您可以点击自定义作业选项卡,复制作业的 ID(例如,1234),然后从☰导航菜单中选择日志记录,并查询resource.labels.job_id=1234

提示

要可视化训练进度,只需启动 TensorBoard,并将其--logdir指向日志的 GCS 路径。它将使用应用程序默认凭据,您可以使用gcloud auth application-default login进行设置。如果您喜欢,Vertex AI 还提供托管的 TensorBoard 服务器。

如果您想尝试一些超参数值,一个选项是运行多个作业。您可以通过在调用run()方法时设置args参数将超参数值作为命令行参数传递给您的脚本,或者您可以使用environment_variables参数将它们作为环境变量传递。

然而,如果您想在云上运行一个大型的超参数调整作业,一个更好的选择是使用 Vertex AI 的超参数调整服务。让我们看看如何做。

Vertex AI 上的超参数调整

Vertex AI 的超参数调整服务基于贝叶斯优化算法,能够快速找到最佳的超参数组合。要使用它,首先需要创建一个接受超参数值作为命令行参数的训练脚本。例如,您的脚本可以像这样使用argparse标准库:

import argparse

parser = argparse.ArgumentParser()
parser.add_argument("--n_hidden", type=int, default=2)
parser.add_argument("--n_neurons", type=int, default=256)
parser.add_argument("--learning_rate", type=float, default=1e-2)
parser.add_argument("--optimizer", default="adam")
args = parser.parse_args()

超参数调整服务将多次调用您的脚本,每次使用不同的超参数值:每次运行称为trial,一组试验称为study。然后,您的训练脚本必须使用给定的超参数值来构建和编译模型。如果需要,您可以使用镜像分发策略,以便每个试验在多 GPU 机器上运行。然后脚本可以加载数据集并训练模型。例如:

import tensorflow as tf

def build_model(args):
    with tf.distribute.MirroredStrategy().scope():
        model = tf.keras.Sequential()
        model.add(tf.keras.layers.Flatten(input_shape=[28, 28], dtype=tf.uint8))
        for _ in range(args.n_hidden):
            model.add(tf.keras.layers.Dense(args.n_neurons, activation="relu"))
        model.add(tf.keras.layers.Dense(10, activation="softmax"))
        opt = tf.keras.optimizers.get(args.optimizer)
        opt.learning_rate = args.learning_rate
        model.compile(loss="sparse_categorical_crossentropy", optimizer=opt,
                      metrics=["accuracy"])
        return model

[...]  # load the dataset
model = build_model(args)
history = model.fit([...])
提示

您可以使用我们之前提到的AIP_*环境变量来确定在哪里保存检查点、TensorBoard 日志和最终模型。

最后,脚本必须将模型的性能报告给 Vertex AI 的超参数调整服务,以便它决定尝试哪些超参数。为此,您必须使用hypertune库,在 Vertex AI 训练 VM 上自动安装:

import hypertune

hypertune = hypertune.HyperTune()
hypertune.report_hyperparameter_tuning_metric(
    hyperparameter_metric_tag="accuracy",  # name of the reported metric
    metric_value=max(history.history["val_accuracy"]),  # metric value
    global_step=model.optimizer.iterations.numpy(),
)

现在您的训练脚本已准备就绪,您需要定义要在其上运行的机器类型。为此,您必须定义一个自定义作业,Vertex AI 将使用它作为每个试验的模板:

trial_job = aiplatform.CustomJob.from_local_script(
    display_name="my_search_trial_job",
    script_path="my_vertex_ai_trial.py",  # path to your training script
    container_uri="gcr.io/cloud-aiplatform/training/tf-gpu.2-4:latest",
    staging_bucket=f"gs://{bucket_name}/staging",
    accelerator_type="NVIDIA_TESLA_K80",
    accelerator_count=2,  # in this example, each trial will have 2 GPUs
)

最后,您准备好创建并运行超参数调整作业:

from google.cloud.aiplatform import hyperparameter_tuning as hpt

hp_job = aiplatform.HyperparameterTuningJob(
    display_name="my_hp_search_job",
    custom_job=trial_job,
    metric_spec={"accuracy": "maximize"},
    parameter_spec={
        "learning_rate": hpt.DoubleParameterSpec(min=1e-3, max=10, scale="log"),
        "n_neurons": hpt.IntegerParameterSpec(min=1, max=300, scale="linear"),
        "n_hidden": hpt.IntegerParameterSpec(min=1, max=10, scale="linear"),
        "optimizer": hpt.CategoricalParameterSpec(["sgd", "adam"]),
    },
    max_trial_count=100,
    parallel_trial_count=20,
)
hp_job.run()

在这里,我们告诉 Vertex AI 最大化名为 "accuracy" 的指标:这个名称必须与训练脚本报告的指标名称匹配。我们还定义了搜索空间,使用对数尺度来设置学习率,使用线性(即均匀)尺度来设置其他超参数。超参数的名称必须与训练脚本的命令行参数匹配。然后我们将最大试验次数设置为 100,同时最大并行运行的试验次数设置为 20。如果你将并行试验的数量增加到(比如)60,总搜索时间将显著减少,最多可减少到 3 倍。但前 60 个试验将同时开始,因此它们将无法从其他试验的反馈中受益。因此,您应该增加最大试验次数来补偿,例如增加到大约 140。

这将需要相当长的时间。一旦作业完成,您可以使用 hp_job.trials 获取试验结果。每个试验结果都表示为一个 protobuf 对象,包含超参数值和结果指标。让我们找到最佳试验:

def get_final_metric(trial, metric_id):
    for metric in trial.final_measurement.metrics:
        if metric.metric_id == metric_id:
            return metric.value

trials = hp_job.trials
trial_accuracies = [get_final_metric(trial, "accuracy") for trial in trials]
best_trial = trials[np.argmax(trial_accuracies)]

现在让我们看看这个试验的准确率,以及其超参数值:

>>> max(trial_accuracies)
0.977400004863739
>>> best_trial.id
'98'
>>> best_trial.parameters
[parameter_id: "learning_rate" value { number_value: 0.001 },
 parameter_id: "n_hidden" value { number_value: 8.0 },
 parameter_id: "n_neurons" value { number_value: 216.0 },
 parameter_id: "optimizer" value { string_value: "adam" }
]

就是这样!现在您可以获取这个试验的 SavedModel,可选择性地再训练一下,并将其部署到生产环境中。

提示

Vertex AI 还包括一个 AutoML 服务,完全负责为您找到合适的模型架构并为您进行训练。您只需要将数据集以特定格式上传到 Vertex AI,这取决于数据集的类型(图像、文本、表格、视频等),然后创建一个 AutoML 训练作业,指向数据集并指定您愿意花费的最大计算小时数。请参阅笔记本中的示例。

现在你拥有了所有需要创建最先进的神经网络架构并使用各种分布策略进行规模化训练的工具和知识,可以在自己的基础设施或云上部署它们,然后在任何地方部署它们。换句话说,你现在拥有超能力:好好利用它们!

练习

  1. SavedModel 包含什么?如何检查其内容?

  2. 什么时候应该使用 TF Serving?它的主要特点是什么?有哪些工具可以用来部署它?

  3. 如何在多个 TF Serving 实例上部署模型?

  4. 在查询由 TF Serving 提供的模型时,何时应该使用 gRPC API 而不是 REST API?

  5. TFLite 通过哪些不同的方式减小模型的大小,使其能在移动设备或嵌入式设备上运行?

  6. 什么是量化感知训练,为什么需要它?

  7. 什么是模型并行和数据并行?为什么通常推荐后者?

  8. 在多台服务器上训练模型时,您可以使用哪些分发策略?您如何选择使用哪种?

  9. 训练一个模型(任何您喜欢的模型)并部署到 TF Serving 或 Google Vertex AI。编写客户端代码,使用 REST API 或 gRPC API 查询它。更新模型并部署新版本。您的客户端代码现在将查询新版本。回滚到第一个版本。

  10. 在同一台机器上使用MirroredStrategy在多个 GPU 上训练任何模型(如果您无法访问 GPU,可以使用带有 GPU 运行时的 Google Colab 并创建两个逻辑 GPU)。再次使用CentralStorageStrategy训练模型并比较训练时间。

  11. 在 Vertex AI 上微调您选择的模型,使用 Keras Tuner 或 Vertex AI 的超参数调整服务。

这些练习的解决方案可以在本章笔记本的末尾找到,网址为https://homl.info/colab3

谢谢!

在我们结束这本书的最后一章之前,我想感谢您读到最后一段。我真诚地希望您阅读这本书和我写作时一样开心,并且它对您的项目,无论大小,都有用。

如果您发现错误,请发送反馈。更一般地,我很想知道您的想法,所以请不要犹豫通过 O'Reilly、ageron/handson-ml3 GitHub 项目或 Twitter 上的@aureliengeron 与我联系。

继续前进,我给你的最好建议是练习和练习:尝试完成所有的练习(如果你还没有这样做),玩一下笔记本电脑,加入 Kaggle 或其他机器学习社区,观看机器学习课程,阅读论文,参加会议,与专家会面。事情发展迅速,所以尽量保持最新。一些 YouTube 频道定期以非常易懂的方式详细介绍深度学习论文。我特别推荐 Yannic Kilcher、Letitia Parcalabescu 和 Xander Steenbrugge 的频道。要了解引人入胜的机器学习讨论和更高层次的见解,请务必查看 ML Street Talk 和 Lex Fridman 的频道。拥有一个具体的项目要去做也会极大地帮助,无论是为了工作还是为了娱乐(最好两者兼顾),所以如果你一直梦想着建造某样东西,就试一试吧!逐步工作;不要立即朝着月球开火,而是专注于你的项目,一步一步地构建它。这需要耐心和毅力,但当你拥有一个行走的机器人,或一个工作的聊天机器人,或者其他你喜欢的任何东西时,这将是极其有益的!

我最大的希望是这本书能激发你构建一个美妙的 ML 应用程序,使我们所有人受益。它会是什么样的?

Aurélien Géron

¹ A/B 实验包括在不同的用户子集上测试产品的两个不同版本,以检查哪个版本效果最好并获得其他见解。

² Google AI 平台(以前称为 Google ML 引擎)和 Google AutoML 在 2021 年合并为 Google Vertex AI。

³ REST(或 RESTful)API 是一种使用标准 HTTP 动词(如 GET、POST、PUT 和 DELETE)以及使用 JSON 输入和输出的 API。gRPC 协议更复杂但更高效;数据使用协议缓冲区进行交换(参见第十三章)。

如果您对 Docker 不熟悉,它允许您轻松下载一组打包在Docker 镜像中的应用程序(包括所有依赖项和通常一些良好的默认配置),然后使用Docker 引擎在您的系统上运行它们。当您运行一个镜像时,引擎会创建一个保持应用程序与您自己系统良好隔离的Docker 容器,但如果您愿意,可以给它一些有限的访问权限。它类似于虚拟机,但速度更快、更轻,因为容器直接依赖于主机的内核。这意味着镜像不需要包含或运行自己的内核。

还有 GPU 镜像可用,以及其他安装选项。有关更多详细信息,请查看官方安装说明

公平地说,这可以通过首先序列化数据,然后将其编码为 Base64,然后创建 REST 请求来减轻。此外,REST 请求可以使用 gzip 进行压缩,从而显著减少有效负载大小。

还要查看 TensorFlow 的Graph Transform Tool,用于修改和优化计算图。

例如,PWA 必须包含不同移动设备大小的图标,必须通过 HTTPS 提供,必须包含包含应用程序名称和背景颜色等元数据的清单文件。

请查看 TensorFlow 文档,获取详细和最新的安装说明,因为它们经常更改。

¹⁰ 正如我们在第十二章中所看到的,内核是特定数据类型和设备类型的操作实现。例如,float32 tf.matmul() 操作有一个 GPU 内核,但 int32 tf.matmul() 没有 GPU 内核,只有一个 CPU 内核。

¹¹ 您还可以使用 tf.debugging.set_log_device_placement(True) 来记录所有设备放置情况。

¹² 如果您想要保证完美的可重现性,这可能很有用,正如我在这个视频中所解释的,基于 TF 1。

¹³ 在撰写本文时,它只是将数据预取到 CPU RAM,但使用 tf.data.experimental.pre⁠fetch​_to_device() 可以使其预取数据并将其推送到您选择的设备,以便 GPU 不必等待数据传输而浪费时间。

如果两个 CNN 相同,则称为孪生神经网络

如果您对模型并行性感兴趣,请查看Mesh TensorFlow

这个名字有点令人困惑,因为听起来好像有些副本是特殊的,什么也不做。实际上,所有副本都是等价的:它们都努力成为每个训练步骤中最快的,失败者在每一步都会变化(除非某些设备真的比其他设备慢)。但是,这意味着如果一个或两个服务器崩溃,训练将继续进行得很好。

Jianmin Chen 等人,“重新审视分布式同步 SGD”,arXiv 预印本 arXiv:1604.00981(2016)。

¹⁸ Aaron Harlap 等人,“PipeDream: 快速高效的管道并行 DNN 训练”,arXiv 预印本 arXiv:1806.03377(2018)。

¹⁹ Paul Barham 等人,“Pathways: 异步分布式数据流 ML”,arXiv 预印本 arXiv:2203.12533(2022)。

²⁰ 有关 AllReduce 算法的更多详细信息,请阅读 Yuichiro Ueno 的文章,该文章介绍了深度学习背后的技术,以及 Sylvain Jeaugey 的文章,该文章介绍了如何使用 NCCL 大规模扩展深度学习训练。

附录 A:机器学习项目清单

此清单可以指导您完成机器学习项目。有八个主要步骤:

  1. 构建问题并全局看问题。

  2. 获取数据。

  3. 探索数据以获得见解。

  4. 准备数据以更好地暴露底层数据模式给机器学习算法。

  5. 探索许多不同的模型并列出最佳模型。

  6. 微调您的模型并将它们组合成一个出色的解决方案。

  7. 展示您的解决方案。

  8. 启动,监控和维护您的系统。

显然,您应该随时根据自己的需求调整此清单。

构建问题并全局看问题

  1. 用业务术语定义目标。

  2. 您的解决方案将如何使用?

  3. 当前的解决方案/变通方法是什么(如果有的话)?

  4. 应该如何框定这个问题(监督/无监督,在线/离线等)?

  5. 应如何衡量性能?

  6. 性能度量是否与业务目标一致?

  7. 达到业务目标所需的最低性能是多少?

  8. 有哪些可比较的问题?您能重复使用经验或工具吗?

  9. 是否有人类专业知识?

  10. 您如何手动解决问题?

  11. 列出到目前为止您(或其他人)已经做出的假设。

  12. 验证假设(如果可能)。

获取数据

注意:尽可能自动化,以便您可以轻松获得新鲜数据。

  1. 列出您需要的数据以及需要多少数据。

  2. 找到并记录您可以获取数据的位置。

  3. 检查它将占用多少空间。

  4. 检查法律义务,并在必要时获得授权。

  5. 获取访问授权。

  6. 创建一个工作空间(具有足够的存储空间)。

  7. 获取数据。

  8. 将数据转换为您可以轻松操作的格式(而不更改数据本身)。

  9. 确保敏感信息被删除或受到保护(例如,匿名化)。

  10. 检查数据的大小和类型(时间序列,样本,地理等)。

  11. 对测试集进行抽样,将其放在一边,永远不要查看(不要窥探数据!)。

探索数据

注意:尝试从领域专家那里获得这些步骤的见解。

  1. 为探索创建数据副本(如果需要,将其采样到可管理的大小)。

  2. 创建一个 Jupyter 笔记本来记录您的数据探索。

  3. 研究每个属性及其特征:

    • 名称

    • 类型(分类,整数/浮点数,有界/无界,文本,结构化等)

    • 缺失值的百分比

    • 噪声和噪声类型(随机的,异常值,舍入误差等)

    • 任务的实用性

    • 分布类型(高斯,均匀,对数等)

  4. 对于监督学习任务,识别目标属性。

  5. 可视化数据。

  6. 研究属性之间的相关性。

  7. 研究您如何手动解决问题。

  8. 识别您可能要应用的有前途的转换。

  9. 识别可能有用的额外数据(返回到“获取数据”步骤)。

  10. 记录您学到的东西。

准备数据

注:

  • 在数据的副本上工作(保持原始数据集完整)。

  • 为您应用的所有数据转换编写函数,有五个原因:

    • 这样您可以在下次获得新数据时轻松准备数据

    • 这样您可以在未来项目中应用这些转换

    • 清理和准备测试集。

    • 在解决方案上线后,清理和准备新数据实例

    • 使您的准备选择易于视为超参数

  1. 清理数据:

    • 修复或删除异常值(可选)。

    • 填补缺失值(例如,用零,平均值,中位数...)或删除它们的行(或列)。

  2. 执行特征选择(可选):

    • 删除为任务提供无用信息的属性。
  3. 在适当的情况下进行特征工程:

    • 离散化连续特征。

    • 分解特征(例如,分类,日期/时间等)。

    • 添加有前途的特征转换(例如,对数(* x ),平方根( x ), x *²等)。

    • 将特征聚合成有前途的新特征。

  4. 执行特征缩放:

    • 标准化或归一化特征。

列出有前途的模型

注:

  • 如果数据很大,您可能希望对较小的训练集进行抽样,以便在合理的时间内训练许多不同的模型(请注意,这会惩罚复杂模型,如大型神经网络或随机森林)。

  • 再次尝试尽可能自动化这些步骤。

  1. 使用标准参数从不同类别(例如线性、朴素贝叶斯、SVM、随机森林、神经网络等)训练许多快速而粗糙的模型。

  2. 测量并比较它们的性能:

    • 对于每个模型,使用N折交叉验证,并计算N折上性能指标的均值和标准差。
  3. 分析每个算法的最重要变量。

  4. 分析模型所犯的错误类型:

    • 人类会使用什么数据来避免这些错误?
  5. 进行一轮快速的特征选择和工程。

  6. 再进行一两次快速迭代,按照之前五个步骤。

  7. 列出前三到五个最有前途的模型,更喜欢产生不同类型错误的模型。

微调系统

注意:

  • 在这一步骤中,您将希望尽可能使用更多数据,特别是在朝着微调的最后阶段。

  • 像往常一样,尽可能自动化。

  1. 使用交叉验证微调超参数:

    • 将数据转换选择视为超参数,特别是在您不确定时(例如,如果您不确定是用零还是中位数替换缺失值,或者只是删除行)。

    • 除非要探索的超参数值非常少,否则更喜欢随机搜索而不是网格搜索。如果训练时间很长,您可能更喜欢贝叶斯优化方法(例如,使用高斯过程先验,如Jasper Snoek 等人所述¹)。

  2. 尝试集成方法。将您最好的模型组合在一起通常会比单独运行它们产生更好的性能。

  3. 一旦您对最终模型有信心,请在测试集上测量其性能以估计泛化误差。

警告

在测量泛化误差后不要调整模型:您只会开始过拟合测试集。

呈现您的解决方案

  1. 记录您所做的工作。

  2. 创建一个漂亮的演示文稿:

    • 确保首先突出整体情况。
  3. 解释为什么您的解决方案实现了业务目标。

  4. 不要忘记呈现您沿途注意到的有趣点:

    • 描述哪些工作了,哪些没有。

    • 列出您的假设和系统的限制。

  5. 确保通过美丽的可视化或易于记忆的陈述传达您的关键发现(例如,“收入中位数是房价的头号预测因子”)。

启动!

  1. 准备好将您的解决方案投入生产(连接到生产数据输入,编写单元测试等)。

  2. 编写监控代码,定期检查系统的实时性能,并在性能下降时触发警报:

    • 注意缓慢退化:随着数据的演变,模型往往会“腐烂”。

    • 测量性能可能需要一个人类管道(例如,通过众包服务)。

    • 还要监控输入的质量(例如,故障传感器发送随机值,或者另一个团队的输出变得陈旧)。这对在线学习系统尤为重要。

  3. 定期在新数据上重新训练您的模型(尽可能自动化)。

¹ Jasper Snoek 等人,“机器学习算法的实用贝叶斯优化”,《第 25 届国际神经信息处理系统会议论文集》2(2012):2951–2959。

附录 B:自动微分

本附录解释了 TensorFlow 的自动微分(autodiff)功能的工作原理,以及它与其他解决方案的比较。

假设您定义一个函数f(x, y) = x²y + y + 2,并且您需要其偏导数∂f/∂x和∂f/∂y,通常用于执行梯度下降(或其他优化算法)。您的主要选择是手动微分、有限差分逼近、前向自动微分和反向自动微分。TensorFlow 实现了反向自动微分,但要理解它,最好先看看其他选项。所以让我们逐个进行,从手动微分开始。

手动微分

计算导数的第一种方法是拿起一支铅笔和一张纸,利用您的微积分知识推导出适当的方程。对于刚刚定义的函数f(x, y),这并不太难;您只需要使用五条规则:

  • 常数的导数是 0。

  • λx的导数是λ(其中λ是一个常数)。

  • xλ的导数是*λx*(λ) ^– ¹,所以x²的导数是 2x

  • 函数和的导数是这些函数的导数之和。

  • λ倍函数的导数是λ乘以其导数。

从这些规则中,您可以推导出方程 B-1。

方程 B-1. f(x, y)的偏导数

f x = (x 2 y) x + y x + 2 x = y (x 2 ) x + 0 + 0 = 2 x y f y = (x 2 y) y + y y + 2 y = x 2 + 1 + 0 = x 2 + 1

对于更复杂的函数,这种方法可能变得非常繁琐,您可能会犯错。幸运的是,还有其他选择。现在让我们看看有限差分逼近。

有限差分逼近

回想一下函数h(x)在点x[0]处的导数h′(x[0])是该点处函数的斜率。更准确地说,导数被定义为通过该点x[0]和函数上另一点x的直线的斜率的极限,当x无限接近x[0]时(参见方程 B-2)。

方程 B-2. 函数h(x)在点x[0]处的导数定义

h ' ( x 0 ) = lim xx 0 h(x)-h(x 0 ) x-x 0 = lim ε0 h(x 0 +ε)-h(x 0 ) ε

因此,如果我们想计算f(x, y)关于xx = 3 和y = 4 处的偏导数,我们可以计算f(3 + ε, 4) - f(3, 4),然后将结果除以ε,使用一个非常小的ε值。这种数值逼近导数的方法称为有限差分逼近,这个特定的方程称为牛顿的差商。以下代码正是这样做的:

def f(x, y):
    return x**2*y + y + 2

def derivative(f, x, y, x_eps, y_eps):
    return (f(x + x_eps, y + y_eps) - f(x, y)) / (x_eps + y_eps)

df_dx = derivative(f, 3, 4, 0.00001, 0)
df_dy = derivative(f, 3, 4, 0, 0.00001)

不幸的是,结果不够精确(对于更复杂的函数来说情况会更糟)。正确的结果分别是 24 和 10,但实际上我们得到了:

>>> df_dx
24.000039999805264
>>> df_dy
10.000000000331966

注意,要计算两个偏导数,我们至少要调用f()三次(在前面的代码中我们调用了四次,但可以进行优化)。如果有 1,000 个参数,我们至少需要调用f() 1,001 次。当处理大型神经网络时,这使得有限差分逼近方法过于低效。

然而,这种方法实现起来非常简单,是检查其他方法是否正确实现的好工具。例如,如果它与您手动推导的函数不一致,那么您的函数可能存在错误。

到目前为止,我们已经考虑了两种计算梯度的方法:手动微分和有限差分逼近。不幸的是,这两种方法都对训练大规模神经网络有致命缺陷。因此,让我们转向自动微分,从正向模式开始。

正向模式自动微分

图 B-1 展示了正向模式自动微分在一个更简单的函数g(x, y) = 5 + xy 上的工作原理。该函数的图在左侧表示。经过正向模式自动微分后,我们得到右侧的图,表示偏导数∂g/∂x = 0 + (0 × x + y × 1) = y(我们可以类似地得到关于y的偏导数)。

该算法将从输入到输出遍历计算图(因此称为“正向模式”)。它从叶节点获取偏导数开始。常数节点(5)返回常数 0,因为常数的导数始终为 0。变量x返回常数 1,因为∂x/∂x = 1,变量y返回常数 0,因为∂y/∂x = 0(如果我们要找关于y的偏导数,结果将相反)。

现在我们有了所有需要的内容,可以向上移动到函数g中的乘法节点。微积分告诉我们,两个函数uv的乘积的导数是∂(u × v)/∂x = ∂v/∂x × u + v × ∂u/∂x。因此,我们可以构建右侧的图的大部分,表示为 0 × x + y × 1。

最后,我们可以到达函数g中的加法节点。如前所述,函数和的导数是这些函数的导数之和,因此我们只需要创建一个加法节点并将其连接到我们已经计算过的图的部分。我们得到了正确的偏导数:∂g/∂x = 0 + (0 × x + y × 1)。

mls3 ab01

图 B-1. 正向模式自动微分

然而,这个方程可以被简化(很多)。通过对计算图应用一些修剪步骤,摆脱所有不必要的操作,我们得到一个只有一个节点的更小的图:∂g/∂x = y。在这种情况下,简化相当容易,但对于更复杂的函数,正向模式自动微分可能会产生一个庞大的图,可能难以简化,并导致性能不佳。

请注意,我们从一个计算图开始,正向模式自动微分产生另一个计算图。这称为符号微分,它有两个好处:首先,一旦导数的计算图被生成,我们可以使用它任意次数来计算给定函数的导数,无论xy的值是多少;其次,如果需要的话,我们可以再次在结果图上运行正向模式自动微分,以获得二阶导数(即导数的导数)。我们甚至可以计算三阶导数,依此类推。

但也可以在不构建图形的情况下运行正向模式自动微分(即数值上,而不是符号上),只需在运行时计算中间结果。其中一种方法是使用双数,它们是形式为a + 的奇怪但迷人的数字,其中ab是实数,ε是一个无穷小数,使得ε² = 0(但ε ≠ 0)。您可以将双数 42 + 24ε看作类似于 42.0000⋯000024,其中有无限多个 0(但当然这只是简化,只是为了让您对双数有一些概念)。双数在内存中表示为一对浮点数。例如,42 + 24ε由一对(42.0, 24.0)表示。

双数可以相加、相乘等,如 Equation B-3 所示。

Equation B-3. 双数的一些操作

λ ( a + b ε ) = λ a + λ b ε ( a + b ε ) + ( c + d ε ) = ( a + c ) + ( b + d ) ε ( a + b ε ) × ( c + d ε ) = a c + ( a d + b c ) ε + ( b d ) ε 2 = a c + ( a d + b c ) ε

最重要的是,可以证明h(a + ) = h(a) + b × h′(a)ε,因此计算h(a + ε)可以一次性得到h(a)和导数h′(a)。图 B-2 显示了使用双重数计算f(x, y)对xx = 3 和y = 4 时的偏导数(我将写为∂f/∂x (3, 4))。我们只需要计算f(3 + ε, 4);这将输出一个双重数,其第一个分量等于f(3, 4),第二个分量等于∂f/∂x (3, 4)。

mls3 ab02

图 B-2. 使用双重数进行正向模式自动微分

要计算∂f/∂y (3, 4),我们需要再次通过图进行计算,但这次是在x = 3 和y = 4 + ε的情况下。

因此,正向模式自动微分比有限差分逼近更准确,但至少在输入较多而输出较少时存在相同的主要缺陷(例如在处理神经网络时):如果有 1,000 个参数,将需要通过图进行 1,000 次传递来计算所有偏导数。这就是逆向模式自动微分的优势所在:它可以在通过图进行两次传递中计算出所有偏导数。让我们看看如何做到的。

逆向模式自动微分

逆向模式自动微分是 TensorFlow 实现的解决方案。它首先沿着图的正向方向(即从输入到输出)进行第一次传递,计算每个节点的值。然后进行第二次传递,这次是在反向方向(即从输出到输入)进行,计算所有偏导数。名称“逆向模式”来自于这个对图的第二次传递,在这个传递中,梯度以相反方向流动。图 B-3 代表了第二次传递。在第一次传递中,所有节点值都是从x = 3 和y = 4 开始计算的。您可以在每个节点的右下角看到这些值(例如,x × x = 9)。为了清晰起见,节点标记为n[1]到n[7]。输出节点是n[7]:f(3, 4) = n[7] = 42。

mls3 ab03

图 B-3. 逆向模式自动微分

这个想法是逐渐沿着图向下走,计算f(x, y)对每个连续节点的偏导数,直到达到变量节点。为此,逆向模式自动微分在方程 B-4 中大量依赖于链式法则

方程 B-4. 链式法则

f x = f n i × n i x

由于n[7]是输出节点,f = n[7],所以∂f / ∂n[7] = 1。

让我们继续沿着图向下走到n[5]:当n[5]变化时,f会变化多少?答案是∂f / ∂n[5] = ∂f / ∂n[7] × ∂n[7] / ∂n[5]。我们已经知道∂f / ∂n[7] = 1,所以我们只需要∂n[7] / ∂n[5]。由于n[7]只是执行n[5] + n[6]的求和,我们发现∂n[7] / ∂n[5] = 1,所以∂f / ∂n[5] = 1 × 1 = 1。

现在我们可以继续到节点n[4]:当n[4]变化时,f会变化多少?答案是∂f / ∂n[4] = ∂f / ∂n[5] × ∂n[5] / ∂n[4]。由于n[5] = n[4] × n[2],我们发现∂n[5] / ∂n[4] = n[2],所以∂f / ∂n[4] = 1 × n[2] = 4。

这个过程一直持续到我们到达图的底部。在那一点上,我们将计算出f(x, y)在x = 3 和y = 4 时的所有偏导数。在这个例子中,我们发现∂f / ∂x = 24 和∂f / ∂y = 10。听起来没错!

反向模式自动微分是一种非常强大和准确的技术,特别是当输入很多而输出很少时,因为它只需要一个前向传递加上一个反向传递来计算所有输出相对于所有输入的所有偏导数。在训练神经网络时,我们通常希望最小化损失,因此只有一个输出(损失),因此只需要通过图两次来计算梯度。反向模式自动微分还可以处理不完全可微的函数,只要您要求它在可微分的点计算偏导数。

在图 B-3 中,数值结果是在每个节点上实时计算的。然而,这并不完全是 TensorFlow 的做法:相反,它创建了一个新的计算图。换句话说,它实现了符号反向模式自动微分。这样,只需要生成一次计算图来计算神经网络中所有参数相对于损失的梯度,然后每当优化器需要计算梯度时,就可以一遍又一遍地执行它。此外,这使得在需要时可以计算高阶导数。

提示

如果您想在 C++中实现一种新类型的低级 TensorFlow 操作,并且希望使其与自动微分兼容,那么您需要提供一个函数,该函数返回函数输出相对于其输入的偏导数。例如,假设您实现了一个计算其输入平方的函数:f(x) = x²。在这种情况下,您需要提供相应的导数函数:f′(x) = 2x

附录 C:特殊数据结构

在本附录中,我们将快速查看 TensorFlow 支持的数据结构,超出了常规的浮点或整数张量。这包括字符串、不规则张量、稀疏张量、张量数组、集合和队列。

字符串

张量可以保存字节字符串,这在自然语言处理中特别有用(请参阅第十六章):

>>> tf.constant(b"hello world")
<tf.Tensor: shape=(), dtype=string, numpy=b'hello world'>

如果尝试构建一个包含 Unicode 字符串的张量,TensorFlow 会自动将其编码为 UTF-8:

>>> tf.constant("café")
<tf.Tensor: shape=(), dtype=string, numpy=b'caf\xc3\xa9'>

还可以创建表示 Unicode 字符串的张量。只需创建一个 32 位整数数组,每个整数代表一个单个 Unicode 码点:⁠¹

>>> u = tf.constant([ord(c) for c in "café"])
>>> u
<tf.Tensor: shape=(4,), [...], numpy=array([ 99,  97, 102, 233], dtype=int32)>
注意

在类型为tf.string的张量中,字符串长度不是张量形状的一部分。换句话说,字符串被视为原子值。但是,在 Unicode 字符串张量(即 int32 张量)中,字符串的长度张量形状的一部分。

tf.strings包含几个函数来操作字符串张量,例如length()用于计算字节字符串中的字节数(或者如果设置unit="UTF8_CHAR",则计算代码点的数量),unicode_encode()用于将 Unicode 字符串张量(即 int32 张量)转换为字节字符串张量,unicode_decode()用于执行相反操作:

>>> b = tf.strings.unicode_encode(u, "UTF-8")
>>> b
<tf.Tensor: shape=(), dtype=string, numpy=b'caf\xc3\xa9'>
>>> tf.strings.length(b, unit="UTF8_CHAR")
<tf.Tensor: shape=(), dtype=int32, numpy=4>
>>> tf.strings.unicode_decode(b, "UTF-8")
<tf.Tensor: shape=(4,), [...], numpy=array([ 99,  97, 102, 233], dtype=int32)>

您还可以操作包含多个字符串的张量:

>>> p = tf.constant(["Café", "Coffee", "caffè", "咖啡"])
>>> tf.strings.length(p, unit="UTF8_CHAR")
<tf.Tensor: shape=(4,), dtype=int32, numpy=array([4, 6, 5, 2], dtype=int32)>
>>> r = tf.strings.unicode_decode(p, "UTF8")
>>> r
<tf.RaggedTensor [[67, 97, 102, 233], [67, 111, 102, 102, 101, 101], [99, 97,
102, 102, 232], [21654, 21857]]>

请注意,解码的字符串存储在RaggedTensor中。那是什么?

不规则张量

不规则张量是一种特殊类型的张量,表示不同大小数组的列表。更一般地说,它是一个具有一个或多个不规则维度的张量,意味着切片可能具有不同长度的维度。在不规则张量r中,第二个维度是一个不规则维度。在所有不规则张量中,第一个维度始终是一个常规维度(也称为均匀维度)。

不规则张量r的所有元素都是常规张量。例如,让我们看看不规则张量的第二个元素:

>>> r[1]
<tf.Tensor: [...], numpy=array([ 67, 111, 102, 102, 101, 101], dtype=int32)>

tf.ragged包含几个函数来创建和操作不规则张量。让我们使用tf.ragged.constant()创建第二个不规则张量,并沿着轴 0 连接它与第一个不规则张量:

>>> r2 = tf.ragged.constant([[65, 66], [], [67]])
>>> tf.concat([r, r2], axis=0)
<tf.RaggedTensor [[67, 97, 102, 233], [67, 111, 102, 102, 101, 101], [99, 97,
102, 102, 232], [21654, 21857], [65, 66], [], [67]]>

结果并不太令人惊讶:r2中的张量是沿着轴 0 在r中的张量之后附加的。但是如果我们沿着轴 1 连接r和另一个不规则张量呢?

>>> r3 = tf.ragged.constant([[68, 69, 70], [71], [], [72, 73]])
>>> print(tf.concat([r, r3], axis=1))
<tf.RaggedTensor [[67, 97, 102, 233, 68, 69, 70], [67, 111, 102, 102, 101, 101,
71], [99, 97, 102, 102, 232], [21654, 21857, 72, 73]]>

这次,请注意r中的第i个张量和r3中的第i个张量被连接。现在这更不寻常,因为所有这些张量都可以具有不同的长度。

如果调用to_tensor()方法,不规则张量将转换为常规张量,用零填充较短的张量以获得相等长度的张量(您可以通过设置default_value参数更改默认值):

>>> r.to_tensor()
<tf.Tensor: shape=(4, 6), dtype=int32, numpy=
array([[   67,    97,   102,   233,     0,     0],
 [   67,   111,   102,   102,   101,   101],
 [   99,    97,   102,   102,   232,     0],
 [21654, 21857,     0,     0,     0,     0]], dtype=int32)>

许多 TF 操作支持不规则张量。有关完整列表,请参阅tf.RaggedTensor类的文档。

稀疏张量

TensorFlow 还可以高效地表示稀疏张量(即包含大多数零的张量)。只需创建一个tf.SparseTensor,指定非零元素的索引和值以及张量的形状。索引必须按“读取顺序”(从左到右,从上到下)列出。如果不确定,只需使用tf.sparse.reorder()。您可以使用tf.sparse.to_dense()将稀疏张量转换为密集张量(即常规张量):

>>> s = tf.SparseTensor(indices=[[0, 1], [1, 0], [2, 3]],
...                     values=[1., 2., 3.],
...                     dense_shape=[3, 4])
...
>>> tf.sparse.to_dense(s)
<tf.Tensor: shape=(3, 4), dtype=float32, numpy=
array([[0., 1., 0., 0.],
 [2., 0., 0., 0.],
 [0., 0., 0., 3.]], dtype=float32)>

请注意,稀疏张量不支持与密集张量一样多的操作。例如,您可以将稀疏张量乘以任何标量值,得到一个新的稀疏张量,但是您不能将标量值添加到稀疏张量中,因为这不会返回一个稀疏张量:

>>> s * 42.0
<tensorflow.python.framework.sparse_tensor.SparseTensor at 0x7f84a6749f10>
>>> s + 42.0
[...] TypeError: unsupported operand type(s) for +: 'SparseTensor' and 'float'

张量数组

tf.TensorArray表示一个张量列表。这在包含循环的动态模型中可能很方便,用于累积结果并稍后计算一些统计数据。您可以在数组中的任何位置读取或写入张量:

array = tf.TensorArray(dtype=tf.float32, size=3)
array = array.write(0, tf.constant([1., 2.]))
array = array.write(1, tf.constant([3., 10.]))
array = array.write(2, tf.constant([5., 7.]))
tensor1 = array.read(1)  # => returns (and zeros out!) tf.constant([3., 10.])

默认情况下,读取一个项目也会用相同形状但全是零的张量替换它。如果不想要这样,可以将clear_after_read设置为False

警告

当您向数组写入时,必须将输出分配回数组,就像这个代码示例中所示。如果不这样做,尽管您的代码在急切模式下可以正常工作,但在图模式下会出错(这些模式在第十二章中讨论)。

默认情况下,TensorArray具有在创建时设置的固定大小。或者,您可以设置size=0dynamic_size=True,以便在需要时自动增长数组。但是,这会影响性能,因此如果您事先知道size,最好使用固定大小数组。您还必须指定dtype,并且所有元素必须与写入数组的第一个元素具有相同的形状。

您可以通过调用stack()方法将所有项目堆叠到常规张量中:

>>> array.stack()
<tf.Tensor: shape=(3, 2), dtype=float32, numpy=
array([[1., 2.],
 [0., 0.],
 [5., 7.]], dtype=float32)>

集合

TensorFlow 支持整数或字符串的集合(但不支持浮点数)。它使用常规张量表示集合。例如,集合{1, 5, 9}只是表示为张量[[1, 5, 9]]。请注意,张量必须至少有两个维度,并且集合必须在最后一个维度中。例如,[[1, 5, 9], [2, 5, 11]]是一个包含两个独立集合的张量:{1, 5, 9}{2, 5, 11}

tf.sets包含几个用于操作集合的函数。例如,让我们创建两个集合并计算它们的并集(结果是一个稀疏张量,因此我们调用to_dense()来显示它):

>>> a = tf.constant([[1, 5, 9]])
>>> b = tf.constant([[5, 6, 9, 11]])
>>> u = tf.sets.union(a, b)
>>> u
<tensorflow.python.framework.sparse_tensor.SparseTensor at 0x132b60d30>
>>> tf.sparse.to_dense(u)
<tf.Tensor: [...], numpy=array([[ 1,  5,  6,  9, 11]], dtype=int32)>

还可以同时计算多对集合的并集。如果某些集合比其他集合短,必须用填充值(例如 0)填充它们:

>>> a = tf.constant([[1, 5, 9], [10, 0, 0]])
>>> b = tf.constant([[5, 6, 9, 11], [13, 0, 0, 0]])
>>> u = tf.sets.union(a, b)
>>> tf.sparse.to_dense(u)
<tf.Tensor: [...] numpy=array([[ 1,  5,  6,  9, 11],
 [ 0, 10, 13,  0,  0]], dtype=int32)>

如果您想使用不同的填充值,比如-1,那么在调用to_dense()时必须设置default_value=-1(或您喜欢的值)。

警告

默认的default_value是 0,所以在处理字符串集合时,必须设置这个参数(例如,设置为空字符串)。

tf.sets中还有其他可用的函数,包括difference()intersection()size(),它们都是不言自明的。如果要检查一个集合是否包含某些给定值,可以计算该集合和值的交集。如果要向集合添加一些值,可以计算集合和值的并集。

队列

队列是一种数据结构,您可以将数据记录推送到其中,然后再将它们取出。TensorFlow 在tf.queue包中实现了几种类型的队列。在实现高效的数据加载和预处理流水线时,它们曾经非常重要,但是 tf.data API 基本上使它们变得无用(也许在一些罕见情况下除外),因为使用起来更简单,并提供了构建高效流水线所需的所有工具。为了完整起见,让我们快速看一下它们。

最简单的队列是先进先出(FIFO)队列。要构建它,您需要指定它可以包含的记录的最大数量。此外,每个记录都是张量的元组,因此您必须指定每个张量的类型,以及可选的形状。例如,以下代码示例创建了一个最多包含三条记录的 FIFO 队列,每条记录包含一个 32 位整数和一个字符串的元组。然后将两条记录推送到队列中,查看大小(此时为 2),并取出一条记录:

>>> q = tf.queue.FIFOQueue(3, [tf.int32, tf.string], shapes=[(), ()])
>>> q.enqueue([10, b"windy"])
>>> q.enqueue([15, b"sunny"])
>>> q.size()
<tf.Tensor: shape=(), dtype=int32, numpy=2>
>>> q.dequeue()
[<tf.Tensor: shape=(), dtype=int32, numpy=10>,
 <tf.Tensor: shape=(), dtype=string, numpy=b'windy'>]

还可以使用enqueue_many()dequeue_many()一次入队和出队多个记录(要使用dequeue_many(),必须在创建队列时指定shapes参数,就像我们之前做的那样):

>>> q.enqueue_many([[13, 16], [b'cloudy', b'rainy']])
>>> q.dequeue_many(3)
[<tf.Tensor: [...], numpy=array([15, 13, 16], dtype=int32)>,
 <tf.Tensor: [...], numpy=array([b'sunny', b'cloudy', b'rainy'], dtype=object)>]

其他队列类型包括:

PaddingFIFOQueue

FIFOQueue相同,但其dequeue_many()方法支持出队不同形状的多个记录。它会自动填充最短的记录,以确保批次中的所有记录具有相同的形状。

PriorityQueue

一个按优先级顺序出队记录的队列。优先级必须作为每个记录的第一个元素包含在其中,是一个 64 位整数。令人惊讶的是,优先级较低的记录将首先出队。具有相同优先级的记录将按照 FIFO 顺序出队。

RandomShuffleQueue

一个记录以随机顺序出队的队列。在 tf.data 出现之前,这对实现洗牌缓冲区很有用。

如果队列已满并且您尝试入队另一个记录,则enqueue*()方法将冻结,直到另一个线程出队一条记录。同样,如果队列为空并且您尝试出队一条记录,则dequeue*()方法将冻结,直到另一个线程将记录推送到队列中。

如果您不熟悉 Unicode 代码点,请查看https://homl.info/unicode

附录 D:TensorFlow 图

在本附录中,我们将探索由 TF 函数生成的图形(请参阅第十二章)。

TF 函数和具体函数

TF 函数是多态的,意味着它们支持不同类型(和形状)的输入。例如,考虑以下tf_cube()函数:

@tf.function
def tf_cube(x):
    return x ** 3

每次您调用一个 TF 函数并使用新的输入类型或形状组合时,它会生成一个新的具体函数,具有为这种特定组合专门优化的图形。这样的参数类型和形状组合被称为输入签名。如果您使用它之前已经见过的输入签名调用 TF 函数,它将重用之前生成的具体函数。例如,如果您调用tf_cube(tf.constant(3.0)),TF 函数将重用用于tf_cube(tf.constant(2.0))(对于 float32 标量张量)的相同具体函数。但是,如果您调用tf_cube(tf.constant([2.0]))tf_cube(tf.constant([3.0]))(对于形状为[1]的 float32 张量),它将生成一个新的具体函数,对于tf_cube(tf.constant([[1.0, 2.0], [3.0, 4.0]]))(对于形状为[2, 2]的 float32 张量),它将生成另一个新的具体函数。您可以通过调用 TF 函数的get_concrete_function()方法来获取特定输入组合的具体函数。然后可以像普通函数一样调用它,但它只支持一个输入签名(在此示例中为 float32 标量张量):

>>> concrete_function = tf_cube.get_concrete_function(tf.constant(2.0))
>>> concrete_function
<ConcreteFunction tf_cube(x) at 0x7F84411F4250>
>>> concrete_function(tf.constant(2.0))
<tf.Tensor: shape=(), dtype=float32, numpy=8.0>

图 D-1 显示了tf_cube() TF 函数,在我们调用tf_cube(2)tf_cube(tf.constant(2.0))之后:生成了两个具体函数,每个签名一个,每个具有自己优化的函数图FuncGraph)和自己的函数定义FunctionDef)。函数定义指向与函数的输入和输出对应的图的部分。在每个FuncGraph中,节点(椭圆形)表示操作(例如,幂运算,常量,或用于参数的占位符如x),而边(操作之间的实箭头)表示将在图中流动的张量。左侧的具体函数专门用于x=2,因此 TensorFlow 成功将其简化为始终输出 8(请注意,函数定义甚至没有输入)。右侧的具体函数专门用于 float32 标量张量,无法简化。如果我们调用tf_cube(tf.constant(5.0)),将调用第二个具体函数,x的占位符操作将输出 5.0,然后幂运算将计算5.0 ** 3,因此输出将为 125.0。

mls3 ad01

图 D-1。tf_cube() TF 函数,及其ConcreteFunction和它们的FuncGraph

这些图中的张量是符号张量,意味着它们没有实际值,只有数据类型、形状和名称。它们代表将在实际值被馈送到占位符x并执行图形后流经图形的未来张量。符号张量使得可以预先指定如何连接操作,并且还允许 TensorFlow 递归推断所有张量的数据类型和形状,鉴于它们的输入的数据类型和形状。

现在让我们继续窥探底层,并看看如何访问函数定义和函数图,以及如何探索图的操作和张量。

探索函数定义和图形

您可以使用graph属性访问具体函数的计算图,并通过调用图的get_operations()方法获取其操作列表:

>>> concrete_function.graph
<tensorflow.python.framework.func_graph.FuncGraph at 0x7f84411f4790>
>>> ops = concrete_function.graph.get_operations()
>>> ops
[<tf.Operation 'x' type=Placeholder>,
 <tf.Operation 'pow/y' type=Const>,
 <tf.Operation 'pow' type=Pow>,
 <tf.Operation 'Identity' type=Identity>]

在这个例子中,第一个操作代表输入参数 x(它被称为 占位符),第二个“操作”代表常数 3,第三个操作代表幂运算(**),最后一个操作代表这个函数的输出(它是一个恒等操作,意味着它不会做任何比幂运算输出的更多的事情⁠^(1))。每个操作都有一个输入和输出张量的列表,您可以通过操作的 inputsoutputs 属性轻松访问。例如,让我们获取幂运算的输入和输出列表:

>>> pow_op = ops[2]
>>> list(pow_op.inputs)
[<tf.Tensor 'x:0' shape=() dtype=float32>,
 <tf.Tensor 'pow/y:0' shape=() dtype=float32>]
>>> pow_op.outputs
[<tf.Tensor 'pow:0' shape=() dtype=float32>]

这个计算图在 图 D-2 中表示。

mls3 ad02

图 D-2. 计算图示例

请注意每个操作都有一个名称。它默认为操作的名称(例如,"pow"),但当调用操作时您可以手动定义它(例如,tf.pow(x, 3, name="other_name"))。如果名称已经存在,TensorFlow 会自动添加一个唯一的索引(例如,"pow_1""pow_2" 等)。每个张量也有一个唯一的名称:它总是输出该张量的操作的名称,如果它是操作的第一个输出,则为 :0,如果它是第二个输出,则为 :1,依此类推。您可以使用图的 get_operation_by_name()get_tensor_by_name() 方法按名称获取操作或张量:

>>> concrete_function.graph.get_operation_by_name('x')
<tf.Operation 'x' type=Placeholder>
>>> concrete_function.graph.get_tensor_by_name('Identity:0')
<tf.Tensor 'Identity:0' shape=() dtype=float32>

具体函数还包含函数定义(表示为协议缓冲区⁠^(2)),其中包括函数的签名。这个签名允许具体函数知道要用输入值填充哪些占位符,以及要返回哪些张量:

>>> concrete_function.function_def.signature
name: "__inference_tf_cube_3515903"
input_arg {
 name: "x"
 type: DT_FLOAT
}
output_arg {
 name: "identity"
 type: DT_FLOAT
}

现在让我们更仔细地看一下跟踪。

更仔细地看一下跟踪

让我们调整 tf_cube() 函数以打印其输入:

@tf.function
def tf_cube(x):
    print(f"x = {x}")
    return x ** 3

现在让我们调用它:

>>> result = tf_cube(tf.constant(2.0))
x = Tensor("x:0", shape=(), dtype=float32)
>>> result
<tf.Tensor: shape=(), dtype=float32, numpy=8.0>

result 看起来不错,但看看打印出来的内容:x 是一个符号张量!它有一个形状和数据类型,但没有值。而且它有一个名称("x:0")。这是因为 print() 函数不是一个 TensorFlow 操作,所以它只会在 Python 函数被跟踪时运行,这发生在图模式下,参数被替换为符号张量(相同类型和形状,但没有值)。由于 print() 函数没有被捕获到图中,所以下一次我们用 float32 标量张量调用 tf_cube() 时,什么也不会被打印:

>>> result = tf_cube(tf.constant(3.0))
>>> result = tf_cube(tf.constant(4.0))

但是,如果我们用不同类型或形状的张量,或者用一个新的 Python 值调用 tf_cube(),函数将再次被跟踪,因此 print() 函数将被调用:

>>> result = tf_cube(2)  # new Python value: trace!
x = 2
>>> result = tf_cube(3)  # new Python value: trace!
x = 3
>>> result = tf_cube(tf.constant([[1., 2.]]))  # new shape: trace!
x = Tensor("x:0", shape=(1, 2), dtype=float32)
>>> result = tf_cube(tf.constant([[3., 4.], [5., 6.]]))  # new shape: trace!
x = Tensor("x:0", shape=(None, 2), dtype=float32)
>>> result = tf_cube(tf.constant([[7., 8.], [9., 10.]]))  # same shape: no trace
警告

如果您的函数具有 Python 副作用(例如,将一些日志保存到磁盘),请注意此代码只会在函数被跟踪时运行(即每次用新的输入签名调用 TF 函数时)。最好假设函数可能在调用 TF 函数时随时被跟踪(或不被跟踪)。

在某些情况下,您可能希望将 TF 函数限制为特定的输入签名。例如,假设您知道您只会用 28 × 28 像素图像的批次调用 TF 函数,但是批次的大小会有很大的不同。您可能不希望 TensorFlow 为每个批次大小生成不同的具体函数,或者依赖它自行决定何时使用 None。在这种情况下,您可以像这样指定输入签名:

@tf.function(input_signature=[tf.TensorSpec([None, 28, 28], tf.float32)])
def shrink(images):
    return images[:, ::2, ::2]  # drop half the rows and columns

这个 TF 函数将接受任何形状为 [*, 28, 28] 的 float32 张量,并且每次都会重用相同的具体函数:

img_batch_1 = tf.random.uniform(shape=[100, 28, 28])
img_batch_2 = tf.random.uniform(shape=[50, 28, 28])
preprocessed_images = shrink(img_batch_1)  # works fine, traces the function
preprocessed_images = shrink(img_batch_2)  # works fine, same concrete function

然而,如果您尝试用 Python 值调用这个 TF 函数,或者用意外的数据类型或形状的张量调用它,您将会得到一个异常:

img_batch_3 = tf.random.uniform(shape=[2, 2, 2])
preprocessed_images = shrink(img_batch_3)  # ValueError! Incompatible inputs

使用 AutoGraph 捕获控制流

如果您的函数包含一个简单的 for 循环,您期望会发生什么?例如,让我们编写一个函数,通过连续添加 1 来将 10 添加到其输入中:

@tf.function
def add_10(x):
    for i in range(10):
        x += 1
    return x

它运行正常,但当我们查看它的图时,我们发现它不包含循环:它只包含 10 个加法操作!

>>> add_10(tf.constant(0))
<tf.Tensor: shape=(), dtype=int32, numpy=15>
>>> add_10.get_concrete_function(tf.constant(0)).graph.get_operations()
[<tf.Operation 'x' type=Placeholder>, [...],
 <tf.Operation 'add' type=AddV2>, [...],
 <tf.Operation 'add_1' type=AddV2>, [...],
 <tf.Operation 'add_2' type=AddV2>, [...],
 [...]
 <tf.Operation 'add_9' type=AddV2>, [...],
 <tf.Operation 'Identity' type=Identity>]

实际上这是有道理的:当函数被跟踪时,循环运行了 10 次,因此x += 1操作运行了 10 次,并且由于它处于图模式下,它在图中记录了这个操作 10 次。您可以将这个for循环看作是一个在创建图表时被展开的“静态”循环。

如果您希望图表包含一个“动态”循环(即在执行图表时运行的循环),您可以手动使用tf.while_loop()操作创建一个,但这并不直观(请参见第十二章笔记本的“使用 AutoGraph 捕获控制流”部分以获取示例)。相反,使用 TensorFlow 的AutoGraph功能要简单得多,详见第十二章。AutoGraph 实际上是默认激活的(如果您需要关闭它,可以在tf.function()中传递autograph=False)。因此,如果它是开启的,为什么它没有捕获add_10()函数中的for循环呢?它只捕获对tf.data.Dataset对象的张量进行迭代的for循环,因此您应该使用tf.range()而不是range()。这是为了给您选择:

  • 如果使用range()for循环将是静态的,这意味着仅在跟踪函数时才会执行。循环将被“展开”为每次迭代的一组操作,正如我们所见。

  • 如果使用tf.range(),循环将是动态的,这意味着它将包含在图表本身中(但在跟踪期间不会运行)。

让我们看看如果在add_10()函数中将range()替换为tf.range()时生成的图表:

>>> add_10.get_concrete_function(tf.constant(0)).graph.get_operations()
[<tf.Operation 'x' type=Placeholder>, [...],
 <tf.Operation 'while' type=StatelessWhile>, [...]]

如您所见,图现在包含一个While循环操作,就好像我们调用了tf.while_loop()函数一样。

在 TF 函数中处理变量和其他资源

在 TensorFlow 中,变量和其他有状态对象,如队列或数据集,被称为资源。TF 函数对它们进行特殊处理:任何读取或更新资源的操作都被视为有状态的,并且 TF 函数确保有状态的操作按照它们出现的顺序执行(与无状态操作相反,后者可能并行运行,因此它们的执行顺序不被保证)。此外,当您将资源作为参数传递给 TF 函数时,它会通过引用传递,因此函数可能会对其进行修改。例如:

counter = tf.Variable(0)

@tf.function
def increment(counter, c=1):
    return counter.assign_add(c)

increment(counter)  # counter is now equal to 1
increment(counter)  # counter is now equal to 2

如果查看函数定义,第一个参数被标记为资源:

>>> function_def = increment.get_concrete_function(counter).function_def
>>> function_def.signature.input_arg[0]
name: "counter"
type: DT_RESOURCE

还可以在函数外部使用定义的tf.Variable,而无需显式将其作为参数传递:

counter = tf.Variable(0)

@tf.function
def increment(c=1):
    return counter.assign_add(c)

TF 函数将将其视为隐式的第一个参数,因此实际上最终会具有相同的签名(除了参数的名称)。但是,使用全局变量可能会很快变得混乱,因此通常应该将变量(和其他资源)封装在类中。好消息是@tf.function也可以很好地与方法一起使用:

class Counter:
    def __init__(self):
        self.counter = tf.Variable(0)

    @tf.function
    def increment(self, c=1):
        return self.counter.assign_add(c)
警告

不要使用=+=-=或任何其他 Python 赋值运算符与 TF 变量。相反,您必须使用assign()assign_add()assign_sub()方法。如果尝试使用 Python 赋值运算符,当调用该方法时将会出现异常。

这种面向对象的方法的一个很好的例子当然是 Keras。让我们看看如何在 Keras 中使用 TF 函数。

使用 TF 函数与 Keras(或不使用)

默认情况下,您在 Keras 中使用的任何自定义函数、层或模型都将自动转换为 TF 函数;您无需做任何事情!但是,在某些情况下,您可能希望停用此自动转换——例如,如果您的自定义代码无法转换为 TF 函数,或者如果您只想调试代码(在急切模式下更容易)。为此,您只需在创建模型或其任何层时传递dynamic=True

model = MyModel(dynamic=True)

如果您的自定义模型或层将始终是动态的,可以使用dynamic=True调用基类的构造函数:

class MyDense(tf.keras.layers.Layer):
    def __init__(self, units, **kwargs):
        super().__init__(dynamic=True, **kwargs)
        [...]

或者,在调用compile()方法时传递run_eagerly=True

model.compile(loss=my_mse, optimizer="nadam", metrics=[my_mae],
              run_eagerly=True)

现在你知道了 TF 函数如何处理多态性(具有多个具体函数),如何使用 AutoGraph 和追踪自动生成图形,图形的样子,如何探索它们的符号操作和张量,如何处理变量和资源,以及如何在 Keras 中使用 TF 函数。

¹ 你可以安全地忽略它 - 它只是为了技术原因而在这里,以确保 TF 函数不会泄漏内部结构。

² 在第十三章中讨论的一种流行的二进制格式。

posted @ 2025-11-19 09:21  绝不原创的飞龙  阅读(4)  评论(0)    收藏  举报